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@toolshed@0.7.0 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, verify-apply-bytes.py, bloat-cadence.py, and
check-repo-integrity.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/. check-repo-integrity.py — the
repository-integrity gate both audit lanes run before assembling anything — is un-vendored for a
sharper reason: it judges the checkout, so it must not live in the checkout it judges.
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.
A report is assembled only from a verified checkout. Between the model step and the
drift-audit call, scripts/check-repo-integrity.py re-checks HEAD, staged changes, tracked
modifications, and untracked additions against the commit the lane planned against, and refuses
the run — no report, no artifact, a typed evidence-integrity-* reason on the run surface — if
any of them moved. verdicts.json is the one path it exempts, because it is the one work-tree
file the lane declared the model would write; everything else this lane generates lives under
${RUNNER_TEMP}/doc-audit/. The bloat lane runs the identical gate and declares no exemption at
all. The gate never resets, restores, or cleans: repairing the tree would erase the evidence the
refusal rests on, and a fresh checkout of the same commit is byte-identical whether or not the
run dirtied its own — so this is the only moment the question is answerable.
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, checks every staged blob against the applier's certified postimage manifest, commits with the engine's approval trailers, checks the commit tree the same way, pushes that exact commit onto 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).
scripts/verify-apply-bytes.py owns the byte binding past that surface: the engine certifies
the postimages it read back off disk, and this compares the staged index and then the commit
tree against them, so a clean filter, an end-of-line attribute, a hook, or any other writer
between the apply and the push refuses rather than landing content nobody approved
(tests/scripts/verify-apply-bytes_test.py). Both apply lanes run it identically, which
tests/scripts/apply-lane-parity_test.py holds true.
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 plus
verify-apply-bytes.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, verifies the staged index and the commit tree against the applier's certified
postimages through the same verify-apply-bytes.py the manual lane runs, pushes the commit that
verification named, and opens a real pull request, never a draft and never a direct write to the
default branch. A re-run whose derived branch already exists reuses it rather than pushing again
or forcing over it — only after re-certifying the commit standing there against this run's own
approval trailer and postimages — treats an already-open pull request for that approval as
success, refuses any other branch or pull request found there, and states which of those
outcomes it reached.
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 verify-apply-bytes.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,scripts/verify-apply-bytes.py(the version-comparison gate, the upgrade lane's path authority, each lane's run-surface rendering, and the apply lanes' byte verification at the index and commit boundaries — 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 sixteen 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 seven 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,verify-apply-bytes.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 seventeenth 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 sixteen 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, verify-apply-bytes.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 `stag
…(truncated)