Skip to content

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

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.

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.

{
"_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.

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.

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.

A retirement can be announced before it happens. Give the record a retires_on date and leave the item where it is:

{ "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.

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}. type is hooks, commands, skills or agents, or alias for a legacy entrypoint name. At build time scripts/docs-site/lib/page-dates.js resolves the subject with retirementOf() in scripts/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 declare subject is 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-mentions gate. make check-retired-mentions, part of make 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 under docs/ and runbooks/, plus README.md, CLAUDE.md and AGENTS.md. It skips docs/archive/, docs/audits/, docs/research/ and the generated references. Scheduled items are still live and are not flagged. Legacy alias names are guarded by make check-legacy-names instead (#2091). A deliberate historical mention goes in scripts/checks/retired-mentions-allow.json as {path, item, pattern?, why}, the same pattern as scripts/bob/rewrite-callers-allow.json. An entry without a why makes the gate fail with exit 2. --json prints retired-mentions/1.

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.

The step every archive must take — see Authoring a Skill § 7:

  1. Move or delete the item (and, for an archive, tag it as the archive procedure says).
  2. Add its record to scripts/lib/retired-items.json — the gate names the exact type / name / from to add when you forget.
  3. make check-retired-items — converged.
  4. Deploy. Consuming projects converge on their next bob ready or bob provision refresh: the link is retargeted or pruned with the action named; the fleet snapshot’s broken_links per project confirms it per machine.