agentsclimarketplace

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.

Install
npx -y skills add aj604/toolshed --skill scheduling-doc-sync

Assembled 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 a workflow_dispatch carrying that version as target, 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) runs apply-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)

  1. 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.)
  2. gh auth status succeeds.
  3. Auth secret: gh secret list shows CLAUDE_CODE_OAUTH_TOKEN (preferred — created by Claude Code's /install-github-app, no key-pasting) or ANTHROPIC_API_KEY. The workflows pass both to anthropics/claude-code-action; either alone works. If neither: warn, don't block — offer /install-github-app, or gh secret set ANTHROPIC_API_KEY with the user pasting the value; a lane fails red on its first model call without one.
  4. Actions may create PRs: gh api repos/{owner}/{repo}/actions/permissions/workflow --jq .can_approve_pull_request_reviews must be true — GitHub blocks Actions-created PRs by default, and the workflow-level permissions: block cannot override it (the PR step fails with "GitHub Actions is not permitted to create or approve pull requests"). If false: warn, don't block — offer gh 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

  1. 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 id and which of drift-stale-mechanical and narrative-anchor-refresh it enables. If the file already exists, preserve it and report its current values; never overwrite it during install or upgrade.

    doc-apply.yml and doc-policy-apply.yml have no schedule knobs. The plugin version is NOT a knob either — it's read from the plugin manifest, not chosen (next step).

  2. Resolve the version being installed: jq -r .version "$CLAUDE_PLUGIN_ROOT/.claude-plugin/plugin.json" (the bare semver — no v prefix).

  3. Confirm .doc-lifecycle/registry.json is 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.

  4. 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 — each Pin plugin marketplace step reads .doc-lifecycle/installed-version at 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.
  5. 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).

  6. 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.py is what all four registry-bound engine lanes invoke.

  7. Seed the audit scope — only if absent: write .doc-lifecycle/audit-scope.json with the starter {"exclude": [], "include": []} (empty arrays — a valid no-op default the human tunes). plan-chunks.py (the sibling detecting-doc-bloat skill's own copy) reads it to pick which docs a large bloat audit covers (exclude/include globs) and how to chunk them — the chunking (max_docs / max_lines / max_chunks) key is documented in that script's docstring (policy_scope is a retired key it now flags and ignores — bulk directory retirement is an enumerable-scope RETIRE-DOC verdict instead); Migration mode reads the same file to infer documentation roots. An existing file is a tuned config — never overwrite it.

  8. Seed the drift waivers — only if absent: write .doc-lifecycle/drift-waivers.json with 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 given drift-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_UNITS in doclifecycle/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.

  9. Seed the declared evidence tools — only if absent: write .doc-lifecycle/evidence-tools.json with {"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.

  10. If and only if the user explicitly enabled autonomous minting and no policy file exists, write .doc-lifecycle/auto-apply-policy.json with the exact confirmed id and classes in 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.

  11. 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.yml reads it to decide whether a newer release exists; it advances only when an upgrade PR merges.

  12. 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) and installed-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 publishes bloat-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 a doc-sync/upgrade PR whose merge advances the pin;
    • run them now with gh workflow run doc-audit, gh workflow run doc-bloat-audit, and gh workflow run doc-sync-upgrade (the latter with no target — 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).

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):

FileOwnerUpgrade behavior
doc-sync-upgrade.ymlplugin (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-versionversion stateSet 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.jsonconsumer (tuned config)Never touch. A relocation carries it to this path once, and no upgrade rewrites it afterwards.
.doc-lifecycle/drift-waivers.jsonconsumer (accepted-claim record)Never touch. Seed {"waivers": []} only if absent (pre-0.11 installs lack it).
.doc-lifecycle/state/sync-markerlegacy stateNever 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.ymlplugin (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.pyplugin (wiring)Overwrite, on the same registry condition.
.doc-lifecycle/evidence-tools.jsonconsumer (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.jsonconsumer (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.jsonconsumer (classification)Never touch. Migration mode produces it; this mode only reads whether it exists.

Knobs are preserved, not resetapply-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: its cron:{{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): its cron:{{AUDIT_CRON}}. Absent on an install that adopted the registry before this lane existed, so it seeds 0 1 * * * and warns, the same shape doc-sync-upgrade.yml uses.
  • doc-bloat-audit.yml (registry installs only): its cron:{{BLOAT_AUDIT_CRON}}. Absent before #144, so it seeds the weekly 0 4 * * 1 default and warns; a present but unreadable cron refuses rather than overwriting the consumer's schedule. doc-apply.yml and doc-policy-apply.yml have 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 existing auto-apply-policy.json to .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 .py files and its engine/. 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"
  1. Draft. mkdir -p .doc-lifecycle && python3 "$ENGINE" migration-draft --repo . --registry-only > .doc-lifecycle/registry.json. It infers roots and kinds from audit-scope.json, the waivers, docs/doc-scope.md, > As of markers (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.
  2. 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's basis and the documents it claims before judging it. Edit the file; don't argue with the inference.
  3. 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, in migration-registry-already-landed. There is no unclassified bucket.
  4. Re-waive. Rewrite each needs_rewaiving entry against what the document says now. Its message states which of the five reasons applies.
  5. Delete the rejected artifacts. The dry run's artifacts names every old report, cache, or approval found and how to regenerate it. Delete them; never edit one into the new shape.
  6. 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-version is advanced by apply-upgrade.py in Upgrade mode, not here. The dry run's preserved states 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's install.layout says which it read, and install.registry says whether a registry is already landed; don't pass the dry run's --waivers or --installed-version to "help" it. State standing under both layouts exits 1 with migration-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: stays contents: read (+ id-token: write), its checkout sets persist-credentials: false, and it carries no GH_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 a claude-code-action step into a job holding a write scope — tests/scripts/workflow-permissions_test.py fails 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.yml is workflow_dispatch only, 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-eligibility and policy-mint commands select records for doc-policy-apply.yml. Absence of .doc-lifecycle/auto-apply-policy.json authorizes nothing; never seed or overwrite it during an upgrade. The policy lane may chain only a successful scheduled doc-audit run.
  • All four engine lanes need a landed registry. .doc-lifecycle/registry.json is what switches doc-audit.yml, doc-bloat-audit.yml, doc-apply.yml, and doc-policy-apply.yml on. 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 marketplace step that clones …/toolshed.git at v<version> to a local path, and plugin_marketplaces points there — so the skills a run executes are frozen at the vendored wiring's version. (claude-code-action rejects a plugin_marketplaces git URL carrying a #<ref> fragment; its validator requires the value end in .git, so the pin lives in the checkout, not the URL.) installed-version is the lockfile — it advances only when a doc-sync/upgrade PR merges. Never ship an unpinned marketplace checkout (bare main), and never version the plugins: selector (@version there 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_dispatch naming a target, in a job holding contents: read, no token and no secret — while the job holding contents: write runs only .doc-lifecycle/wiring/*.py from its own checkout, taken before the release wrote anything. Never route the schedule into the regenerating job, never give the detecting job a scope beyond issues: write, and never move the release's apply-upgrade.py into 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 paths stage-upgrade.py authorized 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.py asserts 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-audit exits 1 (invalid) and 4 (partial) are each a legitimate typed report, as are validate-report's 1/3/4. The lanes capture them with || code=$? and render each on the run surface — the runner's shell is bash -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 add continue-on-error to 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, or doc-policy-apply.yml into 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, or evidence-tools.json with the empty starter → consumer state, not wiring; seed only when absent.
  • Creating or changing .doc-lifecycle/auto-apply-policy.json without 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.json as repo content → artifact hygiene, not history.
  • Giving a model job contents: write, a GH_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-file with a trusting git 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 a run: 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 --allowedTools grant → those patterns are prefix-matched, so gh grants gh api in a job deliberately given no repository credential. Declare the tool in evidence-tools.json and reach it through probe-evidence-tool.py instead.
  • Dropping the Pin plugin marketplace clone step, or pointing plugin_marketplaces at …/toolshed.git (bare main) → 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-action rejects 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 @version selector 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 credentialed land job → 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/ after land's transfer, or landing the bundle with a trusting cp -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, and apply re-derives the authority from the manifest instead of trusting manifest already did.
  • Staging an upgrade from apply-upgrade.py --report-written inside the lane → that is the release being landed declaring what it wrote, which is not evidence about that release. Derive the set with stage-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.target straight into the git clone (or any other run: line) → the dispatched value reaches a shell only as upgrade-gate.py normalize re-emitted it, and the same gate is what refuses a target that would rewind the pin.

Keep looking

Skills are one crate of 328,083. Ordering is by how many stacks a row turns up in, so the top of any crate is what has actually been picked rather than what has the most stars.