Scheduling doc sync
Skill aj604/toolshed/plugins/doc-lifecycle/skills/scheduling-doc-sync
Reference docs as checkable claims — a Claude Code plugin for the documentation lifecycle: bootstrap → write → detect drift → fix.
npx -y skills add aj604/toolshed --skill scheduling-doc-syncAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
One thing to look at
- 1 stars1 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
What its author says it does
Copied from the file, not written here
Use when wiring a repo for automated/unattended documentation audit — "set up doc sync", "automate drift detection", "schedule nightly doc checks", "keep docs in sync automatically" — installs the doc-lifecycle GitHub Actions (scheduled read-only drift and bloat audits, an optional policy apply lane, a manual apply dispatch, and a weekly upgrade check) instead of hand-rolling workflow YAML. Also the door for upgrading an existing install.
SKILL.md
53.0 KB, as published. Nobody here has run it
Scheduling Doc Sync
Overview
Installs the shipped automation into a target repo — five workflows:
doc-audit.yml— the scheduled, read-only audit. Derives its scope from the registry, runs the audit engine, publishes a validated report as an artifact and a job summary. Writes nothing.doc-bloat-audit.yml— the weekly, read-only bloat audit. Preflights the registry, plans through the engine's public contract, fans its chunks out to budgeted workers, and publishes the report's typed completion truth. Writes nothing.doc-apply.yml— the manual apply dispatch. A reviewer names the record digests they approve from one audit run; the lane mints the approval set from that selection, plans, applies, and opens a pull request.doc-policy-apply.yml— the audit-chained policy lane. It wakes only after a successful scheduled drift audit and only acts when the consumer committed.doc-lifecycle/auto-apply-policy.json; the engine derives the eligible subset, and the lane opens a real pull request whose review is the semantic review. An absent policy is a clean opt-out, not a permissive default.doc-sync-upgrade.yml— the self-upgrade lane, three jobs split by who decides and who holds credentials. Its weekly schedule only compares the installed version to the plugin's latest release and files one notice issue naming a newer one; regenerating the wiring runs solely on aworkflow_dispatchcarrying that version astarget, and lands as a review PR.
You install wiring; you do not re-derive it. Orchestration lives in the shipped workflow
YAML; every lifecycle rule — scope, verdict contract, approval, application — lives in the
doclifecycle engine package (plugins/doc-lifecycle/engine/README.md), vendored into the
install and reached only through its public CLI. Every run-surface string lives in a shipped
script: render-audit-summary.py for the audit lanes, render-apply-summary.py for the apply
lane, render-report.py for the upgrade lane; the upgrade lane's version comparison and the
shape-check on its dispatched target live in upgrade-gate.py, and which paths an upgrade may
write in stage-upgrade.py. Never inline audit or apply method into workflow YAML — that forks
the method from its one owner.
The model holds no repository write authority. Every job that invokes a model runs with
permissions: contents: read (plus id-token: write for the OAuth exchange only), checks out
with persist-credentials: false, carries no GH_TOKEN, and hands its work forward as an
artifact. The credentialed jobs run no model, and every one of them stages an explicit path
list — git add --pathspec-from-file, never git add -A, with no exception. The apply lane
stages the paths the engine's verified apply result emitted; the upgrade lane stages the path set
stage-upgrade.py authorized out of the regeneration's manifest, and refuses if git staged
anything else or left a change behind in the work tree.
tests/scripts/workflow-permissions_test.py fails the release if any of that slips. What none
of this establishes is that the report was honest — it is model output too; the pull request
the apply lane opens is where a person settles that.
Installs are pinned, not floating. Before each claude-code-action step, a
Pin plugin marketplace step reads the version from .doc-lifecycle/installed-version and
clones that release tag
(VERSION=$(cat …/installed-version); git clone --depth 1 --branch "v${VERSION}" …/toolshed.git "$RUNNER_TEMP/toolshed-marketplace"),
and the action step points plugin_marketplaces at that local path — so the skills a run
executes are frozen at the same version as the vendored wiring, and can't drift apart mid-week.
The version is read at runtime, NOT hardcoded in the workflow YAML, so the workflow files stay
byte-identical across versions — a routine upgrade changes only the lockfile, never a
.github/workflows/ file (which the Actions token cannot push; see Upgrade mode). The upgrade
lane is the exception: its regenerate job clones the target release it is regenerating to —
the dispatched version, and only as upgrade-gate.py normalize re-emitted it — since
installed-version still holds the old version until the upgrade PR merges. Its scheduled job
clones nothing at all. Clone under $RUNNER_TEMP, never inside the work tree, or the exported
edit set captures it. Pin via the local checkout, NOT a
plugin_marketplaces: …/toolshed.git#v<version> ref — claude-code-action's URL validator
requires the value end in .git, so a #<ref> fragment is rejected outright.
doc-sync-upgrade.yml is the only thing that advances the pin, and only via a reviewable PR.
The plugins: selector stays bare doc-lifecycle@toolshed (claude-code-action has no
@version selector there — doc-lifecycle@[email protected] is invalid).
The five workflow templates are in this skill's base directory (announced when the skill
loads), and its own scripts one level down in scripts/ — upgrade-gate.py,
stage-upgrade.py, render-report.py, render-audit-summary.py, render-apply-summary.py,
probe-evidence-tool.py, and bloat-cadence.py. The chunk planner and the two output validators stay in the sibling
skills that own them (detecting-doc-bloat, detecting-doc-drift) and are never vendored here
— both always dispatch their own copy via ${CLAUDE_PLUGIN_ROOT}, so a copy under
.doc-lifecycle/wiring/ would have no reader (aj604/toolshed#77 follow-up).
scripts/apply-upgrade.py is the deterministic upgrade engine — the target release's copy of
it is what the upgrade lane runs, so it is never vendored into the install; stage-upgrade.py is
vendored for the mirror-image reason, because it is the code that bounds what that run may have
written (see Upgrade mode). bloat-cadence.py likewise runs from the release-pinned marketplace
checkout: it is the scheduler's trusted pre/post-model adapter, not consumer wiring another lane
calls, so it is not copied into .doc-lifecycle/wiring/.
The audit lane (doc-audit.yml)
Two jobs, split by trust: audit (the model, contents: read + id-token: write, no
credential) calls the engine's own public CLI — drift-plan for a deterministic scope, then
drift-audit for the validated report; publish (no model, contents: read only, no write
scope at all — never contents: write, never a PR, never a commit) re-validates the report's
freshness against the live repository before rendering the run's job summary. It is still its
own job: the moment this lane needs any GitHub write to publish more than a job summary, that
write lands there, never beside the model. Every third-party action it invokes is pinned to an
immutable commit SHA (tests/scripts/audit-workflow_test.py).
scripts/render-audit-summary.py owns every string this lane puts on the run surface,
including the run that produced no report at all.
Tier-2 tool evidence is declared, not granted. A drift verdict may cite evidence.command
— a local tool it ran — instead of a repository path, but only for a tool the run declared
(plugins/doc-lifecycle/engine/README.md, "Lineage"). The declaration lives in
.doc-lifecycle/evidence-tools.json ({"tools": []} when seeded — tool-free until a consumer
adds to it), and scripts/probe-evidence-tool.py is both halves of the wiring: declared --flags renders drift-audit --evidence-command …, and run <tool> <words> --help is how the
model reaches the tool, refusing any undeclared program and any invocation that is not a
--help/--version read. It runs under the model step's existing Bash(python3 *) allowance,
so the tool grant stays Skill,Read,Grep,Glob,Write,Bash(git *),Bash(python3 *) — widening it
instead was rejected in aj604/toolshed#118, because those patterns are prefix-matched and
naming gh would grant gh api in a job deliberately given no repository credential
(tests/scripts/workflow-permissions_test.py refuses any other executable).
Installed only into a repo that has been through the migration door. This template requires
a landed .doc-lifecycle/registry.json (the document model's classification manifest), which no
consumer has until it runs the migration door ("Migration to the registry contract", below), and
it is closed-world over that registry, so it would fail on every run without one. That file's
presence is exactly what switches this lane on: apply-upgrade.py's adopted_registry() reads
it, and only then does Upgrade mode render doc-audit.yml's {{AUDIT_CRON}}, copy
render-audit-summary.py, and vendor the engine (see Upgrade mode's ownership table). Never
hand-install it ahead of that door.
The bloat audit lane (doc-bloat-audit.yml)
Two jobs with the same read-only trust split as doc-audit.yml, on a separate weekly cadence
because bloat's work shape is different: audit preflights the registry and public
bloat-plan contract before any model turn, then a repository-credential-free coordinator
dispatches one fresh Task worker per pending chunk in parallel waves. Before that action,
bloat-cadence.py prepare asks plan-chunks.py --emit-readonly-prompt to render each exact
slice with public-engine segmentation evidence and asks --emit-turns for that Task's
max_turns. Workers return chunk JSON to the coordinator; they never write a result file.
The pinned action's documented structured_output is the only model-result seam.
bloat-cadence.py collect parses that schema-bound value, rejects unknown or duplicate ids,
requires each outer id to equal the inner result's chunk, writes a candidate under runner.temp
only after validate-bloat-output.py --chunk accepts it, and renders a retry prompt containing
exactly the missing/invalid chunks. That prompt runs in a second, equally read-only model action:
one seam failure earns one fresh retry even when the first action failed, unless the workflow
was cancelled; it never earns a wider budget guessed in YAML or a model-authored repair. Any
retry gap stays absent for trusted completion assembly.
After the workers stop, validate-bloat-output.py --assemble --allow-partial binds the full
public plan and every complete, missing, or invalid chunk into #152's completion envelope;
bloat-audit independently re-derives it and produces the report. The optional
--unswept-out sidecar is uploaded for diagnosis, but the report's typed incomplete entries
are the run surface's source of truth: deleting or omitting the sidecar cannot make an unswept
document look examined. publish revalidates freshness and calls
render-audit-summary.py summary --audit-surface bloat.
Every plan, prompt result, envelope, sidecar, report, and cost artifact lives under
${{ runner.temp }}/doc-bloat-audit, outside the checkout. The model job grants only
contents: read plus id-token: write, drops the checkout credential, and passes no explicit
GitHub-token input to the action. Both coordinator actions allow exactly
Task,Read,Grep,Glob; their workers inherit that boundary: no Skill, Write, or Bash, and
therefore no model-side mutation or command path.
--tools fixes that built-in inventory, the matching --allowedTools value is only the
no-prompt ceiling, and --disallowedTools "mcp__*" removes MCP tools. The pinned action maps an
empty --setting-sources back to all sources, so each attempt instead loads only the supported
user source while CLAUDE_CONFIG_DIR points at its own freshly emptied runner.temp directory;
project/local settings are excluded and auto-memory is disabled.
Claude Code's --add-dir read boundary names only the audit's runner.temp directory and the
pinned detecting-doc-bloat skill directory (not all of runner.temp), so those read tools can
reach the trusted prompts/contracts without widening into unrelated runner state.
Trusted workflow scripts alone render prompts, extract/validate returns, select retries, and
assemble completion. A defense-in-depth post-model step still checks that HEAD is
GITHUB_SHA and refuses staged, unstaged, ignored, or ordinary untracked files before
completion assembly or bloat-audit; it never resets or cleans a mutation. The model action
and every artifact action are pinned to immutable SHAs.
The action can reuse one execution-output path for both invocations. The workflow copies the first action's telemetry before the retry can start, copies retry telemetry separately, and aggregates those distinct snapshots for cost, turn, and duration observability.
Installed on the same registry condition as the other engine lanes. A missing registry
would make the public planner refuse before the sweep has a corpus, so Upgrade mode regenerates
this template only for an install holding .doc-lifecycle/registry.json.
The apply lane (doc-apply.yml)
A reviewer reads a doc-audit.yml run's report, picks the record digests they approve, and
dispatches this workflow with report_run_id, report_digest, records, and base. The named
subset is the semantic approval — the workflow mints the approval set from it
(mint-approval) — and merging the pull request it opens is change approval of the actual diff.
Three jobs, split by trust: revalidate (deterministic, contents: read + actions: read, no
write scope) binds the downloaded report artifact to the dispatched digest, re-validates it
against the requested base, and mints the approval set; plan (the only model, contents: read
id-token: write, no GH_TOKEN,persist-credentials: false) authors an edit plan and nothing else;apply(contents: write+pull-requests: write, no model) runsapply-plan, stages exactly the paths the verified result emitted, commits with the engine's approval trailers, pushes a branch named for the approval digest, and opens a real pull request — never a draft.
A stale report refuses at revalidation naming the lineage field that moved, and apply runs only
on both other jobs succeeding, so nothing is created. Dispatch inputs reach no shell: they travel
through env: or an action's with:, and the record selection is validated to be sha256 digests
before it becomes argv. scripts/render-apply-summary.py owns this lane's run surface — every
refusal, the staged path list, and the PR title, body, and commit message
(tests/scripts/render-apply-summary_test.py, tests/scripts/apply-workflow_test.py).
Installed on the same condition doc-audit.yml is: it needs a landed
.doc-lifecycle/registry.json, the vendored engine, and render-apply-summary.py in
.doc-lifecycle/wiring/, so Upgrade mode installs it for exactly the repos that carry a registry.
It has no knob — manual dispatch carries no schedule to preserve.
The policy apply lane (doc-policy-apply.yml)
A completed doc-audit.yml run enters this lane only when its original event was schedule and
its conclusion was successful. The event's run id names the exact audit-report artifact; no
dispatch string chooses a run, record, branch, or path. revalidate checks the report against the
current default branch before calling the engine's public policy-eligibility and policy-mint
commands. policy-mint derives the selection itself and exposes no --record flag.
The trust split is the manual lane's: deterministic revalidate holds only read scopes,
repository-credential-free plan is the sole model job, and model-free apply alone holds contents: write
and pull-requests: write. The model artifact downloads separately from the trusted approval
bundle; apply-plan enforces operation authority, exact preimages, every approved record being
executed, complete remedies, and whole-diff confinement. The writer stages the resulting
explicit path list and opens a real pull request, never a draft and never a direct write to the
default branch.
Autonomous minting is an explicit consumer choice. The standing declaration lives at
.doc-lifecycle/auto-apply-policy.json:
{
"artifact": "auto-apply-policy",
"schema_version": 1,
"id": "nightly-doc-sync",
"classes": [
"drift-stale-mechanical",
"narrative-anchor-refresh"
]
}
The two closed class names admit mechanical stale-passage replacement and narrative anchor
refresh only. There is no class for bloat, creation, retirement, or movement. Never create this
file without the user's explicit authorization, never overwrite an existing one, and never
infer a permissive default from its absence. With no file, or with no eligible records in a
report, the lane stops cleanly before plan or apply. A present but invalid file fails closed
with the engine's typed refusal. Because no human selected the records, the PR body says
“No human selected these records”; PR review is the semantic review and merging is change
approval.
Installed on the same registry condition as the other engine lanes. Upgrade mode regenerates the workflow but never seeds, rewrites, or removes the policy file; a consumer who has not opted in remains opted out after upgrading.
The install layout
Everything the plugin installs lives under .doc-lifecycle/, in three tiers split by who owns
the bytes:
.doc-lifecycle/
registry.json audit-scope.json drift-waivers.json evidence-tools.json
auto-apply-policy.json (optional) consumer judgment
installed-version version lockfile
wiring/ upgrade-gate.py stage-upgrade.py render-report.py plugin-owned
render-audit-summary.py render-apply-summary.py
probe-evidence-tool.py
engine/ vendored wholesale
state/ sync-marker machine-written
The judgment files at the root are the ones a consumer edits and no upgrade rewrites.
wiring/ is regenerated wholesale by the upgrade lane — a hand edit there survives until the
next upgrade and no longer. state/ holds what the lanes wrote: only the carried sync-marker
today, which a fresh install does not have, so the directory exists only in an install that came
through the relocation (Upgrade mode).
The five workflow files stay in .github/workflows/ — GitHub reads workflows only from there —
and are the only doc-lifecycle content left under .github/.
Preflight (run all; report failures, don't silently skip)
- Target repo has a GitHub remote:
git remote get-url origin. No remote → stop; this pipeline is a GitHub Action. (A non-GitHub repo wants a different trigger — tell the user.) gh auth statussucceeds.- Auth secret:
gh secret listshowsCLAUDE_CODE_OAUTH_TOKEN(preferred — created by Claude Code's/install-github-app, no key-pasting) orANTHROPIC_API_KEY. The workflows pass both toanthropics/claude-code-action; either alone works. If neither: warn, don't block — offer/install-github-app, orgh secret set ANTHROPIC_API_KEYwith the user pasting the value; a lane fails red on its first model call without one. - Actions may create PRs:
gh api repos/{owner}/{repo}/actions/permissions/workflow --jq .can_approve_pull_request_reviewsmust betrue— GitHub blocks Actions-created PRs by default, and the workflow-levelpermissions:block cannot override it (the PR step fails with "GitHub Actions is not permitted to create or approve pull requests"). Iffalse: warn, don't block — offergh api -X PUT repos/{owner}/{repo}/actions/permissions/workflow -F can_approve_pull_request_reviews=true(needs repo admin; also in Settings → Actions → General).
Install
-
Confirm the knobs with the user (defaults are fine unattended):
- audit cron: default
0 1 * * *(01:00 UTC daily); replaces{{AUDIT_CRON}}in doc-audit.yml - bloat audit cron: default
0 4 * * 1(04:00 UTC Mondays); replaces{{BLOAT_AUDIT_CRON}}in doc-bloat-audit.yml - upgrade cron: default
0 2 * * 1(02:00 UTC Mondays); replaces{{UPGRADE_CRON}}in doc-sync-upgrade.yml - auto-apply policy: default disabled. Enabling it is a separate explicit choice; confirm
the policy
idand which ofdrift-stale-mechanicalandnarrative-anchor-refreshit enables. If the file already exists, preserve it and report its current values; never overwrite it during install or upgrade.
doc-apply.ymlanddoc-policy-apply.ymlhave no schedule knobs. The plugin version is NOT a knob either — it's read from the plugin manifest, not chosen (next step). - audit cron: default
-
Resolve the version being installed:
jq -r .version "$CLAUDE_PLUGIN_ROOT/.claude-plugin/plugin.json"(the bare semver — novprefix). -
Confirm
.doc-lifecycle/registry.jsonis landed. Both audit and both apply lanes are closed-world over it and fail on every run without one. Absent → stop and run Migration mode (below), or bootstrapping-docs' registry step for a repo with no docs yet; never hand-install ahead of that door. -
Copy the five workflow templates, replacing the literal placeholders in each:
doc-audit.yml→.github/workflows/doc-audit.yml:{{AUDIT_CRON}}.doc-bloat-audit.yml→.github/workflows/doc-bloat-audit.yml:{{BLOAT_AUDIT_CRON}}.doc-apply.yml→.github/workflows/doc-apply.yml: no placeholder to replace.doc-policy-apply.yml→.github/workflows/doc-policy-apply.yml: no placeholder to replace.doc-sync-upgrade.yml→.github/workflows/doc-sync-upgrade.yml:{{UPGRADE_CRON}}. The workflow YAML carries NO version placeholder — eachPin plugin marketplacestep reads.doc-lifecycle/installed-versionat runtime (written in step 11) and clones that tag, so the workflow files are version-agnostic (Overview). The version from step 2 lands only in that lockfile.
-
Copy this skill's scripts into
.doc-lifecycle/wiring/:scripts/upgrade-gate.py,scripts/stage-upgrade.py,scripts/render-report.py,scripts/render-audit-summary.py,scripts/render-apply-summary.py,scripts/probe-evidence-tool.py(the version-comparison gate, the upgrade lane's path authority, and each lane's run-surface rendering — run from the repo, unit-tested upstream). The chunk planner and the two output validators stay in the sibling skills that own them (detecting-doc-bloat,detecting-doc-drift) and are never vendored here — both always dispatch their own copy via${CLAUDE_PLUGIN_ROOT}, so a copy under.doc-lifecycle/wiring/would have no reader (aj604/toolshed#77 follow-up). -
Vendor the engine: copy
$CLAUDE_PLUGIN_ROOT/engine/wholesale to.doc-lifecycle/wiring/engine/. It is one package whose modules import each other, so a partially-refreshed tree is a version that was never tested — copy all of it, never a subset..doc-lifecycle/wiring/engine/doc-lifecycle.pyis what all four registry-bound engine lanes invoke. -
Seed the audit scope — only if absent: write
.doc-lifecycle/audit-scope.jsonwith the starter{"exclude": [], "include": []}(empty arrays — a valid no-op default the human tunes).plan-chunks.py(the siblingdetecting-doc-bloatskill's own copy) reads it to pick which docs a large bloat audit covers (exclude/include globs) and how to chunk them — thechunking(max_docs/max_lines/max_chunks) key is documented in that script's docstring (policy_scopeis a retired key it now flags and ignores — bulk directory retirement is an enumerable-scopeRETIRE-DOCverdict instead); Migration mode reads the same file to infer documentation roots. An existing file is a tuned config — never overwrite it. -
Seed the drift waivers — only if absent: write
.doc-lifecycle/drift-waivers.jsonwith the starter{"waivers": []}. This is the UNVERIFIABLE disposition record: an entry{"file": <doc>, "claim": <quoted claim text>, "reason": ..., "date": ...}annotates the matching assertion as accepted when the engine is givendrift-audit --waivers, and Migration mode re-keys these onto assertion-unit identity. Matching is containment on the quoted fragment, bounded at both ends (MIN_WAIVER_CLAIM,MAX_WAIVER_UNITSindoclifecycle/drift.py), so rewording a waived line puts it back on the surface — new authorship is a new decision. An existing file is accumulated human judgment — never overwrite it. -
Seed the declared evidence tools — only if absent: write
.doc-lifecycle/evidence-tools.jsonwith{"tools": []}. Tool-free is the honest default; a consumer adds the bare executable names the audit lane's verdicts may cite (audit lane, above). An existing file is a declared boundary — never overwrite it. -
If and only if the user explicitly enabled autonomous minting and no policy file exists, write
.doc-lifecycle/auto-apply-policy.jsonwith the exact confirmedidandclassesin the shape shown under “The policy apply lane.” Do not seed a default. If the file exists, never overwrite it; changing a standing policy is a separate consumer-judgment edit. -
Write the version lockfile:
.doc-lifecycle/installed-version= the bare version from step 2. Unlike the seeded state files, this tracks the wiring version and must equal the pin, so on a fresh install always write it.doc-sync-upgrade.ymlreads it to decide whether a newer release exists; it advances only when an upgrade PR merges. -
Tell the user, concretely:
- the fifteen always-installed files to commit, plus the vendored
engine/tree: the five workflows under.github/workflows/(doc-audit.yml,doc-bloat-audit.yml,doc-apply.yml,doc-policy-apply.yml,doc-sync-upgrade.yml); the six scripts under.doc-lifecycle/wiring/(upgrade-gate.py,stage-upgrade.py,render-report.py,render-audit-summary.py,render-apply-summary.py,probe-evidence-tool.py); and, at.doc-lifecycle/, the three seeded state files (audit-scope.json,drift-waivers.json,evidence-tools.json) andinstalled-version. When enabled, the consumer policy is a sixteenth file..doc-lifecycle/state/stays empty on a fresh install — the marker it holds arrives only from a relocation; - both audit lanes run on their crons and write nothing — drift publishes
audit-report, bloat publishesbloat-audit-report, and each renders a typed job summary whatever the outcome; - applying is a deliberate second step: read that run's report, then
gh workflow run doc-apply -f report_run_id=<id> -f report_digest=<digest> -f records="<digests>" -f base=main— the digests you name are the approval, and the PR it opens is what a merge approves; - when the policy is enabled, every successful scheduled drift audit automatically enters
doc-policy-apply; it stops on no eligible records and otherwise opens a real pull request whose review is the semantic review — it never pushes the default branch; - the weekly upgrade check only detects: when a newer plugin release ships it files one notice issue naming it (one open notice per release, so a repeat check stays quiet), and when the install is already current or ahead of releases it self-explains and stops. It clones nothing and runs none of the release's code;
- upgrading is a separate, human decision: read the release, then
gh workflow run doc-sync-upgrade -f target=<X.Y.Z>, which regenerates the wiring and opens adoc-sync/upgradePR whose merge advances the pin; - run them now with
gh workflow run doc-audit,gh workflow run doc-bloat-audit, andgh workflow run doc-sync-upgrade(the latter with notarget— the detecting half); - to upgrade from a local checkout instead, re-run this skill (see Upgrade mode — consumer state and knobs preserved; wiring + pin + lockfile refreshed).
- the fifteen always-installed files to commit, plus the vendored
Upgrade mode
Run by doc-sync-upgrade.yml's regenerate job when a human dispatches that workflow with a
target (or by a human forcing an upgrade from a local checkout). It regenerates the vendored
wiring at the new version while leaving every consumer-owned value alone. It is not a fresh
install — skip the Preflight (secrets and PR-permissions are already in place) and do not
re-seed the state files.
A version comparison detects; it never authorizes execution. Upgrading means running the
target release's own apply-upgrade.py, which nobody in the consumer repository has read at the
moment it runs, so the schedule reaches only the detect job: it compares two numbers, files one
notice issue naming the release (render-report.py upgrade-notice renders its title and body and
decides nothing; upgrade-gate.py notice reads that title back and dedupes on it, so a repeat
check keeps quiet), and stops — cloning nothing, running none of the release's code, and
holding issues: write as its whole write scope. Execution happens only under
workflow_dispatch carrying a target, and upgrade-gate.py both shape-checks that input to
strict X.Y.Z before it names a git ref and refuses a target that is not strictly newer than the
pin. A dispatch advances the pin; it never rewinds it.
The regeneration is deterministic — scripts/apply-upgrade.py, no model. The workflow YAML
is version-agnostic, so an upgrade is pure mechanics (re-copy the scripts, re-render the
templates with the consumer's preserved knobs, replace the vendored engine, bump the lockfile),
and a tested script owns it — the upgrade lane makes no model call, and needs no model auth. The
workflow runs it from the target release's own checkout, in the job that holds no credential; a
human forcing an upgrade runs the same script against their checkout with
--plugin-root "$CLAUDE_PLUGIN_ROOT":
apply-upgrade.py --plugin-root <doc-lifecycle plugin dir> --repo <install root> --target <version>
[--report-written <file>]
The script writes files only; git/PR is the workflow's job (below). --report-written declares
each repo-relative path as it writes it — the rendered workflows, the copied scripts, any
orphaned vendored script it deleted (a .py file under .doc-lifecycle/wiring/ no longer in the
current wiring — a prior release's script),
installed-version, the files it actually seeded, and the vendored engine as a directory path
(copy_engine empties the destination first, so a deletion has to be stageable). The lane does
not read it: a declaration by the release being landed is not evidence about that release, so
what gets staged comes from stage-upgrade.py comparing trees the lane controls. The flag remains
for a human forcing an upgrade from a checkout they took themselves. Never re-implement the
script's file ops by hand.
Ownership is the whole game — total on wiring, idempotent on state (this table is the contract
apply-upgrade.py implements):
| File | Owner | Upgrade behavior |
|---|---|---|
doc-sync-upgrade.yml | plugin (wiring) | Regenerate from the new template, re-injecting the consumer's existing knob (below), not the template default. No version to re-pin — the Pin steps read installed-version at runtime. |
.doc-lifecycle/wiring/*.py (the three always-installed scripts: upgrade-gate.py, render-report.py, stage-upgrade.py) | plugin (wiring) | Overwrite from the new version. A .py file here no longer in the current wiring (e.g. a script a later release retired) is deleted, not left stale. |
.doc-lifecycle/installed-version | version state | Set to <target> (bare semver). This is what advances the pin; on a version-only release it's the only file that changes. |
.doc-lifecycle/audit-scope.json | consumer (tuned config) | Never touch. A relocation carries it to this path once, and no upgrade rewrites it afterwards. |
.doc-lifecycle/drift-waivers.json | consumer (accepted-claim record) | Never touch. Seed {"waivers": []} only if absent (pre-0.11 installs lack it). |
.doc-lifecycle/state/sync-marker | legacy state | Never touch. No lane reads it. A relocation carries it here byte-for-byte, once; every upgrade after that leaves it alone (stage-upgrade.py authorizes it as a create only). |
doc-audit.yml, doc-bloat-audit.yml, doc-apply.yml, doc-policy-apply.yml | plugin (wiring) | Regenerate, knobs preserved — but only for an install holding .doc-lifecycle/registry.json. An install without one is left exactly as it was. |
.doc-lifecycle/wiring/render-audit-summary.py, render-apply-summary.py, probe-evidence-tool.py | plugin (wiring) | Overwrite, on the same registry condition. |
.doc-lifecycle/evidence-tools.json | consumer (declared tools) | Never touch. Seed {"tools": []} only if absent, on the same registry condition — tool-free is what a consumer opts out of, never what an upgrade hands them. |
.doc-lifecycle/auto-apply-policy.json | consumer (standing authorization) | Never touch and never seed on upgrade. Absence keeps autonomous minting disabled; a relocation carries an existing file byte-for-byte. |
.doc-lifecycle/wiring/engine/ | plugin (wiring) | Replace wholesale, on the same registry condition — the destination is emptied first, so a module deleted upstream stops being importable. Never edited in place. |
.doc-lifecycle/registry.json | consumer (classification) | Never touch. Migration mode produces it; this mode only reads whether it exists. |
Knobs are preserved, not reset — apply-upgrade.py reads each install-time value out of the
currently-installed workflow and substitutes it back into the new template:
doc-sync-upgrade.yml: itscron:→{{UPGRADE_CRON}}. A missing file (an install predating self-upgrade) is the one place it seeds a default (0 2 * * 1) and warns on stderr.doc-audit.yml(registry installs only): itscron:→{{AUDIT_CRON}}. Absent on an install that adopted the registry before this lane existed, so it seeds0 1 * * *and warns, the same shapedoc-sync-upgrade.ymluses.doc-bloat-audit.yml(registry installs only): itscron:→{{BLOAT_AUDIT_CRON}}. Absent before #144, so it seeds the weekly0 4 * * 1default and warns; a present but unreadable cron refuses rather than overwriting the consumer's schedule.doc-apply.ymlanddoc-policy-apply.ymlhave no knobs.
A knob it can't extract fails the run red rather than default-guessing.
Relocating a pre-0.40.0 install
An install from before 0.40.0 keeps its wiring at .github/doc-sync/ with the marker loose beside
it as .github/doc-sync-marker. apply-upgrade.py relocates it — once — when that directory is
present and .doc-lifecycle/wiring/ is not:
- Carried byte-for-byte:
audit-scope.json,drift-waivers.json,evidence-tools.json, and an existingauto-apply-policy.jsonto.doc-lifecycle/, plus the marker to.doc-lifecycle/state/sync-marker. The registry does not move — the engine already writes it at.doc-lifecycle/registry.json. - Written fresh, not moved: the scripts under
wiring/, the vendored engine, and the lockfile. The contract overwrites those unconditionally, so moving bytes about to be replaced would buy nothing. - Removed: exactly the paths named above plus the old directory's
.pyfiles and itsengine/. A file in the old directory outside that named set is left exactly where it is and reported on the run surface — the plugin does not sweep a directory on its way out, so the old directory survives when it still holds one.
It refuses rather than guesses in two shapes: both layouts present (which of the two holds the
live wiring is not knowable from the filesystem), and wiring/ present without
.doc-lifecycle/installed-version beside it (a relocation that stopped partway).
An install predating 0.40.0 cannot be relocated by the automated upgrade lane. That lane runs
the installed copy of stage-upgrade.py — reviewed code the consumer already holds — and a
copy from before this release does not know the new layout, so it refuses the change set as
unowned. Relocate such an install by re-running this skill in Upgrade mode from a local checkout,
which runs the target release's apply-upgrade.py directly.
The job that runs the release's code holds nothing, and the job that holds credentials runs
nothing the release wrote. regenerate has contents: read, no GH_TOKEN, no secret, and a
checkout persisting no credential; it copies the wiring roots (.github/, .doc-lifecycle/) into
a scratch tree under $RUNNER_TEMP and runs the clone's apply-upgrade.py against that copy.
land holds contents: write + pull-requests: write — every byte the release produced reaches
it as data inside the doc-sync-upgrade-bundle artifact.
Both jobs first copy .doc-lifecycle/wiring/*.py to $RUNNER_TEMP/trusted/ and run every wiring
script from there. This is the step the split rests on, and the easy one to get wrong: the
regeneration writes the release's own stage-upgrade.py and render-report.py, and land's
transfer legitimately lands them in .doc-lifecycle/wiring/ — so a step invoking one out of the work
tree afterwards runs the release's code with land's push token, the split defeated two steps
after it was drawn. "The install's tooling, not the release's" is a claim about when the copy was
taken, never about which directory it sits in.
stage-upgrade.py is the authority between the two jobs. manifest derives what changed by
comparing the scratch tree against the install and refuses the whole run if any difference lies
outside what apply-upgrade.py owns (the marker, audit-scope.json, the registry, a workflow
that is not doc-*.yml, a non-.py drop into .doc-lifecycle/wiring/, a symlink, anything outside
the wiring roots), emitting {status, path, sha256} entries plus the changed files. apply
re-derives that same authority from the manifest instead of trusting that manifest already
did — the two run in different trust domains with an artifact in between — checks every bundled
file against its recorded digest, refuses a bundle carrying a file the manifest does not name, and
prints the staging list. verify then checks what git actually staged against that list. All
three run from that pre-transfer copy.
Do not commit or open the PR in upgrade mode — the workflow's land job owns git: it
transfers the manifest's path set into its own checkout, stages exactly that set by pathspec,
opens the doc-sync/upgrade PR (or self-explains a no-op), and the merge is what advances
installed-version. A difference outside the wiring ends the run before any of that, at the
refused status, which names every offending path and states that nothing was staged and no pull
request opened — a regeneration that reached past the wiring is a bug to fix upstream, never
something to sweep into the commit. Anything git staged beyond the authorized set, or left behind
in the work tree, fails the verify check the same way. Regenerating never leaves an install
floating on main: the new wiring is pinned to <target> end to end.
Workflow-file changes can't self-land. The Actions GITHUB_TOKEN cannot push files under
.github/workflows/ (GitHub blocks it; the workflows permission is not grantable to it).
Because the Pin steps read installed-version at runtime, a version-only upgrade touches only
that lockfile (+ the scripts and the vendored engine) and the PR opens normally. But an upgrade
whose new templates change the workflow YAML itself can't be pushed by the workflow — the
land job's Open the upgrade pull request step detects a changed .github/workflows/ file,
writes the diff to the
doc-sync-upgrade-patch artifact, and fails loud with git apply instructions
(render-report.py upgrade-summary --status blocked-workflows). A human applies that patch with
a workflow-scoped credential. This is rare and expected; don't try to "fix" it by widening the
token — the restriction is GitHub's.
Migration to the registry contract
A one-time, guided, interactive run — not Upgrade mode, which is deterministic and
model-free. An install predating the registry has no .doc-lifecycle/registry.json, and the
audit is closed-world, so it needs one before it can run at all. The engine owns every decision
here; this skill only sequences it. Both commands are read-only — the migration is the human
landing a file, never the door.
ENGINE="$CLAUDE_PLUGIN_ROOT/engine/doc-lifecycle.py"
- Draft.
mkdir -p .doc-lifecycle && python3 "$ENGINE" migration-draft --repo . --registry-only > .doc-lifecycle/registry.json. It infers roots and kinds fromaudit-scope.json, the waivers,docs/doc-scope.md,> As ofmarkers (the first line, or the first non-blank line under the title), and directory conventions.--root <path>(repeatable) replaces inference for a repo whose docs sit somewhere unconventional. A refused draft prints nothing and exits 1 — an empty registry file means read stderr, not that there was nothing to infer. - Review the diff, as globs. The draft is one rule per directory plus per-file overrides —
a short diff, deliberately. Run
python3 "$ENGINE" migration-draft --repo .(no--registry-only) to see each rule'sbasisand the documents it claims before judging it. Edit the file; don't argue with the inference. - Dry-run.
python3 "$ENGINE" migration-dry-run --repo .. Read the obligations per kind, the waivers that re-keyed, and the ones that need re-waiving. Exit 1 means blocked — most often a document under a declared root that no rule claims, named in the output. Add a rule or an exclude to the registry file and re-run this step; the loop is edit → dry-run, never back through step 1, which would overwrite your edits with the inference again — step 2's plain draft says so itself once a registry is landed, inmigration-registry-already-landed. There is no unclassified bucket. - Re-waive. Rewrite each
needs_rewaivingentry against what the document says now. Itsmessagestates which of the five reasons applies. - Delete the rejected artifacts. The dry run's
artifactsnames every old report, cache, or approval found and how to regenerate it. Delete them; never edit one into the new shape. - Land it as a normal PR: the registry, the rewritten waivers, the deletions.
Migration rules (these govern the six steps above, not the install below)
- Never hand-write the registry from scratch when an install predating it exists — the draft is what makes the review a diff instead of a per-file slog.
- Never bypass a block. A blocked dry run is the closed-world rule doing its job.
- This mode moves no consumer state.
audit-scope.json,drift-waivers.json, and the sync marker stay untouched;installed-versionis advanced byapply-upgrade.pyin Upgrade mode, not here. The dry run'spreservedstates each of those files' digest and disposition, so nothing about consumer state is left to memory. - Both commands find that state wherever this install keeps it —
.doc-lifecycle/, or.github/doc-sync/on an install that has not run the relocating upgrade. Each payload'sinstall.layoutsays which it read, andinstall.registrysays whether a registry is already landed; don't pass the dry run's--waiversor--installed-versionto "help" it. State standing under both layouts exits 1 withmigration-split-install— keep whichever copy holds your decisions, remove the other, and re-run. Never merge the two by hand into one. - Fresh installs run steps 1–3 too (the door is also bootstrapping-docs' registry step) —
with no prior state it infers from markers and directory conventions alone, and reports
from_version: null.
Rules
- Runs as a GitHub Action (
schedule+workflow_dispatch), not a Claude scheduled task (ties to one user's account) or a local git/session hook (only fires while someone's working). - The model never holds repository write authority. A model job's
permissions:stayscontents: read(+id-token: write), its checkout setspersist-credentials: false, and it carries noGH_TOKEN. Its output leaves as an artifact; a credentialed, model-free job is what writes. Never widen a model job's token "so it can push", and never move aclaude-code-actionstep into a job holding a write scope —tests/scripts/workflow-permissions_test.pyfails the release if either happens. - PR-only output. Never configure a lane to commit doc edits directly to the default branch — not even if asked ("PRs are annoying"). The reviewable pull request is the product. The audit lanes write nothing at all; the manual, policy, and upgrade lanes each land only through a PR a human merges, and none opens a draft.
- Semantic approval is a person naming record digests.
doc-apply.ymlisworkflow_dispatchonly, and it applies exactly the records that dispatch named. Never wire a schedule, a label, or a bot into its trigger, and never widen the selection inside the lane. - Policy minting is a standing opt-in, never an inferred default. Only the engine's
policy-eligibilityandpolicy-mintcommands select records fordoc-policy-apply.yml. Absence of.doc-lifecycle/auto-apply-policy.jsonauthorizes nothing; never seed or overwrite it during an upgrade. The policy lane may chain only a successful scheduleddoc-auditrun. - All four engine lanes need a landed registry.
.doc-lifecycle/registry.jsonis what switchesdoc-audit.yml,doc-bloat-audit.yml,doc-apply.yml, anddoc-policy-apply.ymlon. Installing them without it ships wiring that fails on every run. - Installs are pinned; only the upgrade workflow advances the pin. Every model step is
preceded by a
Pin plugin marketplacestep that clones…/toolshed.gitatv<version>to a local path, andplugin_marketplacespoints there — so the skills a run executes are frozen at the vendored wiring's version. (claude-code-actionrejects aplugin_marketplacesgit URL carrying a#<ref>fragment; its validator requires the value end in.git, so the pin lives in the checkout, not the URL.)installed-versionis the lockfile — it advances only when adoc-sync/upgradePR merges. Never ship an unpinned marketplace checkout (baremain), and never version theplugins:selector (@versionthere is unsupported). - Upgrading is a human's dispatch, never a version comparison's conclusion. A newer release is
a notice issue, not a mandate: the schedule detects and stops, and the target release's code
runs only under
workflow_dispatchnaming atarget, in a job holdingcontents: read, no token and no secret — while the job holdingcontents: writeruns only.doc-lifecycle/wiring/*.pyfrom its own checkout, taken before the release wrote anything. Never route the schedule into the regenerating job, never give the detecting job a scope beyondissues: write, and never move the release'sapply-upgrade.pyinto the credentialed one (tests/scripts/upgrade-workflow_test.py). - Don't customize the installed YAML beyond the audit, bloat-audit, and upgrade cron knobs. Real changes belong upstream in the plugin (aj604/toolshed) so every install gets them on next upgrade.
- Staging is an explicit path list, never
git add -A— no exceptions. Both credentialed jobs stage--pathspec-from-file: the apply lane the paths the engine's verified apply result emitted, the upgrade lane the pathsstage-upgrade.pyauthorized out of the regeneration's manifest. Each then re-checks what landed, and a stray path stops the run before anything is committed or pushed.workflow-permissions_test.pyasserts this over every shipped template with no exemption list. - Reports are build artifacts, never repo content. The audit lanes upload their drift/bloat
reports, completion data, and cost sidecars from
runner.temp; don't let a hand edit reintroduce any of them as committed files. - Typed engine exits are outcomes, not crashes.
drift-auditexits 1 (invalid) and 4 (partial) are each a legitimate typed report, as arevalidate-report's 1/3/4. The lanes capture them with|| code=$?and render each on the run surface — the runner's shell isbash -e, so a bare$?read after the call would never run on the exits that matter. Never "simplify" that back to a plain call, and never addcontinue-on-errorto hide one. - Upgrade preserves consumer state. Overwrite the yml, the scripts, and the vendored engine
freely;
audit-scope.json,drift-waivers.json,evidence-tools.json,auto-apply-policy.json, and the registry are state, not wiring. See Upgrade mode.
Red flags — STOP
- Writing audit or apply method inside a workflow prompt → invoke the skills by name and the engine by its CLI contract.
- Installing
doc-audit.yml,doc-bloat-audit.yml,doc-apply.yml, ordoc-policy-apply.ymlinto a repo with no.doc-lifecycle/registry.json→ closed-world; every run fails. Run Migration mode first. - Overwriting an existing
.doc-lifecycle/audit-scope.json,drift-waivers.json, orevidence-tools.jsonwith the empty starter → consumer state, not wiring; seed only when absent. - Creating or changing
.doc-lifecycle/auto-apply-policy.jsonwithout explicit authorization → it is standing authority, not seeded state. Absence must remain a clean opt-out. - Adding a direct-commit mode, or dropping the upgrade lane's open-PR gate "to simplify" → the gates are the product; see the design doc in aj604/toolshed.
- Committing
drift-report.jsonas repo content → artifact hygiene, not history. - Giving a model job
contents: write, aGH_TOKEN, or a credential-persisting checkout "so it can just push" → that is the mutation path the job split closes. - Replacing either credentialed job's
git add --pathspec-from-filewith a trustinggit add -A, or dropping the leftover check that follows it → what may be written is bounded by the approval set in one lane and by the declared written set in the other, and a broad add hands a stray file the same authority as an approved edit. - Opening the apply lane's PR as a draft, or splicing a
github.event.inputs.*value into arun:block → the PR is the change approval, and dispatch inputs reach argv only after validation (tests/scripts/apply-workflow_test.py). - Naming another executable in a
--allowedToolsgrant → those patterns are prefix-matched, soghgrantsgh apiin a job deliberately given no repository credential. Declare the tool inevidence-tools.jsonand reach it throughprobe-evidence-tool.pyinstead. - Dropping the
Pin plugin marketplaceclone step, or pointingplugin_marketplacesat…/toolshed.git(baremain) → an unpinned install that floats and drifts from the frozen wiring. Pin it via the local checkout of the release tag. - Reaching for
plugin_marketplaces: …/toolshed.git#v<version>→claude-code-actionrejects it ("Invalid marketplace URL format"); its validator requires the URL end in.git. Pin via the local checkout instead. - Writing
plugins: doc-lifecycle@toolshed@<version>→ the@versionselector is unsupported; pin via the local checkout of the release tag only. - Resetting
.doc-lifecycle/installed-version, or overwriting a seeded state file, during an upgrade → upgrade preserves consumer state; only wiring + the pin + the lockfile change. - Making the scheduled upgrade check clone the release or run its
apply-upgrade.py"so upgrades land unattended", or moving that execution into the credentialedlandjob → that is pre-review execution of unreviewed code, which the three-job split exists to close. The schedule detects; a dispatch authorizes. - Running
stage-upgrade.py(or any other check) out of the clone, out of$RUNNER_TEMP/scratch, or out of.doc-lifecycle/wiring/afterland's transfer, or landing the bundle with a trustingcp -a+git add -A→ the boundary is only worth what the code drawing it is, so both jobs run every wiring script from the copy taken before anything wrote, andapplyre-derives the authority from the manifest instead of trustingmanifestalready did. - Staging an upgrade from
apply-upgrade.py --report-writteninside the lane → that is the release being landed declaring what it wrote, which is not evidence about that release. Derive the set withstage-upgrade.py manifest, from trees the lane controls; the flag is for a human running the upgrade against a checkout they took themselves. - Splicing
inputs.targetstraight into thegit clone(or any otherrun:line) → the dispatched value reaches a shell only asupgrade-gate.py normalizere-emitted it, and the same gate is what refuses a target that would rewind the pin.