Appearance
Retired Items
What happened to every source item that is no longer where it was — declared once, in
scripts/lib/retired-items.json, so that a consuming project's dangling.claude/link can be named and converged instead of cleaned by hand one incident at a time (#318, #558, #661, #1660, #1719, #2009, #2079 — and #2314, which made the record).
Why a record
Retiring, archiving or demoting an item in BOB_SOURCE leaves a symlink to it in every project that linked it. Until #2314 the only knowledge of what disappeared was two hand-edited sets inside the removed-hook pruner (hooks only) and the archive commit's changelog line; commands, agents and skills had nothing, and no convergence step ran the pruner anyway. The record closes both gaps: it is the single source of truth for retired items, every consumer derives from it, and make check refuses a checkout where an item vanished without an entry.
Which items exist is still owned by the directories themselves (hooks/, commands/, agents/, skills/ and their registry/ twins) and which items a project carries by provisions/<project>.json. The record owns only the past.
The file
scripts/lib/retired-items.json, validated by schemas/retired-items.schema.json (make check-schemas and the gate below). Synced to BOB_HOME/scripts/lib/ by deploy.sh like every script.
json
{
"_meta": { "version": 1, "convention": "…" },
"items": [
{ "type": "hooks", "name": "autoloop-gate.sh", "from": "universal", "disposition": "superseded-by", "replacement": "warp-drive-gate.sh", "retired_by": "#2079" },
{ "type": "commands", "name": "cdfork", "from": "universal", "disposition": "moved-to-registry", "retired_by": "#1132" },
{ "type": "commands", "name": "flightplan", "from": "registry", "disposition": "moved-to-universal", "retired_by": "#1872" },
{ "type": "agents", "name": "cloudflare-expert","from": "universal", "disposition": "removed", "retired_by": "a1b2c3d" }
]
}| Field | Meaning |
|---|---|
type | hooks · commands · agents · skills — the item directory. |
name | What a manifest or link calls it: commands/agents without .md, skills by directory, hooks with .sh (what settings.json wires). |
from | Where the item used to live: universal (<type>/) or registry (registry/<type>/). |
disposition | removed — nothing replaces it, a link is pruned · superseded-by — replacement names the live item of the same type (or mechanism:<slug> when a mechanism took over, e.g. mechanism:github-issues for the PM-file validators), a link is pruned · moved-to-registry — same name now in registry/<type>/, a link is retargeted when the project declares it, else pruned with a bob provision add hint · moved-to-universal — same name now in <type>/, a link is retargeted. |
replacement | Required for superseded-by. |
retired_by | The issue (#NNNN) that retired it, or the retiring commit sha when history names no issue. |
retires_on | Optional YYYY-MM-DD: a scheduled retirement (below). Until that date the item is live; from it on, the record is an ordinary one. |
note | Optional one-line context. |
Identity is type + name + from. A name retired on both sides — bug, spec, sprint, auto-loop were removed from commands/ and later from registry/commands/ — has two honest records. A superseded-by item may still be present on disk but unwired (the #661 set: pre-release-checklist.sh, validate-requirement-refs.sh, validate-pr-requirements.sh); every other disposition describes a path that is absent.
How it was seeded
Once, on 2026-09-15, from git history: every path git log --all --name-only had ever recorded under the item directories that no longer existed in the checkout, classified against the live tree (registry twin present → moved-to-registry; universal twin present → moved-to-universal; a rename target present → superseded-by; else removed), with retired_by from the retiring commit's subject. 110 records. From here on the record is maintained by hand, one entry per retirement, and the gate enforces it.
What derives from it
scripts/lib/retired-items.js is the one reader:
| Consumer | Uses |
|---|---|
scripts/orchestrator/provision.js --prune (the dangling-link pruner; run in its --converge form by bob provision refresh and the bob ready links step, #2314) | lookup(type, name) — after the live tree is consulted, a removed / superseded-by record prunes the link naming disposition, replacement and issue; a moved-* record whose copy is not deployed on this machine is [pending]. Classes and the two forms: Repairing drift. |
scripts/rebob/deprecated-cleanup.js (removed-hook references in a project's settings.json, #553 / #661) | hookSets() — REMOVED_HOOKS (script gone) and SUPERSEDED_HOOKS (script present but dead) are derived from the hooks records, no longer hard-coded. When the pruned settings.json is tracked by git it says the prune is an uncommitted change to commit. In the BoB source checkout it writes nothing and names the retired hooks still wired instead; the live-wiring rule below keeps that case from arising (#2897). |
scripts/checks/check-retired-items.js (make check-retired-items) | audit() — the gate below. |
Scheduled retirements
A retirement can be announced before it happens. Give the record a retires_on date and leave the item where it is:
json
{ "type": "commands", "name": "sprint-old", "from": "universal", "disposition": "superseded-by", "replacement": "flightplan", "retired_by": "#9999", "retires_on": "2026-12-31" }Until that date the item is live. It stays on disk and stays wired, the pruners leave it alone, and the gate below accepts it being present. Pages about it show the date and the replacement (on the docs site). On the date it becomes an ordinary record, so the item must be gone by then: stale-record fails a removed item that is still present.
This record is the only place a retirement date is declared. A legacy bob entrypoint name (cdprov, warp and the rest) is the one exception: its date is RETIRE_ON in scripts/bob/gen-shims.sh, and its replacement is the commands.json row whose was it is. No doc carries its own copy of either.
On the docs site
The record also drives what the docs site says about retired items (#2623):
- A banner on the page. A page about one item names it in its frontmatter,
subject: {type: skills, name: foo}.typeishooks,commands,skillsoragents, oraliasfor a legacy entrypoint name. At build timescripts/docs-site/lib/page-dates.jsresolves the subject withretirementOf()inscripts/lib/retired-items.js. If the item is retired or scheduled, it stamps the result into the page. The theme then shows one banner above the provenance strip. It says the item has been retired (or removed, with nothing replacing it) or retires on its date, and names the replacement with a link. A live subject gets no banner. How to declaresubjectis in the Documentation Standard. - The generated references. The generated commands, skills and agents pages never list a retired item among the live ones. Each page ends with a Retired section listing each retired item, what happened to it, its replacement and the issue that retired it. A scheduled item keeps its row until its date, marked
Retires <date>, replaced by <replacement>. - The
check-retired-mentionsgate.make check-retired-mentions, part ofmake docs-check, fails when a current doc presents a retired item as live. That means a line that links to the item's file, or uses its name as an instruction (`name.sh`for a hook,`/name`for a command, the name in code for a skill or agent), without naming the replacement or reading as history ("retired", "removed", "replaced", "was", …). The gate reads git-tracked Markdown underdocs/andrunbooks/, plusREADME.md,CLAUDE.mdandAGENTS.md. It skipsdocs/archive/,docs/audits/,docs/research/and the generated references. Scheduled items are still live and are not flagged. Legacy alias names are guarded bymake check-legacy-namesinstead (#2091). A deliberate historical mention goes inscripts/checks/retired-mentions-allow.jsonas{path, item, pattern?, why}, the same pattern asscripts/bob/rewrite-callers-allow.json. An entry without awhymakes the gate fail with exit 2.--jsonprintsretired-mentions/1.
The gate
make check-retired-items (in make check; --json prints bob-retired-items-check/1; exit 0 clean, 1 violations, 2 unreadable):
| Violation | Meaning |
|---|---|
schema | The file does not validate. |
unrecorded-removal | A path git history has seen under hooks/, commands/, agents/, skills/, registry/{commands,agents,skills}/ is absent and no record names it — an archive cannot land without its record. |
stale-record | A removed / moved-* record describes a path that is present: delete the record or the item. A record whose retires_on is still ahead is exempt: the item is meant to be present until then. |
bad-replacement | superseded-by names an item that does not exist (a mechanism: replacement is exempt). |
bad-move | moved-to-registry / moved-to-universal but the other side is absent. |
duplicate-record | Two records share type + name + from. |
live-wiring | This repo's own .claude/settings.json still wires a hook recorded removed or superseded-by whose retirement has taken effect (a scheduled hook is still meant to be wired). The retiring commit must remove it: left to the removed-hook pruner, it becomes an uncommitted edit to a tracked file and bob update refuses the checkout (#2897). |
The history rule needs full history; a shallow clone reports it as a WARN and still runs the rest. ci.yml checks out with fetch-depth: 0.
Retiring, demoting or promoting an item
The step every archive must take — see Authoring a Skill § 7:
- Move or delete the item (and, for an archive, tag it as the archive procedure says).
- Add its record to
scripts/lib/retired-items.json— the gate names the exacttype / name / fromto add when you forget. make check-retired-items— converged.- Deploy. Consuming projects converge on their next
bob readyorbob provision refresh: the link is retargeted or pruned with the action named; the fleet snapshot'sbroken_linksper project confirms it per machine.
See also
- Repairing drift (prune & repair-fleet) — the pruner's classes, the explicit and convergence forms.
- Hooks Reference — unwired scripts — the superseded-but-present hooks.
- Authored vs. derived — why provisioned links are gitignored (#1479).