SuperAPI AI Directory

plan-docs

Office & productivity

View repository

Updated 10-16 02:24

About this skill

Structured, stateful plan files

Convention for plans/YYYY-MM-DD-topic.md — one file per idea or design, a YAML frontmatter status field so its lifecycle is visible without opening it, and a firm rule that plans/ stays a working set, not a permanent archive.

Rationale, prior art, and worked examples: references/design-rationale.md. Whether an existing tool should have been adopted instead — the markdown task trackers, git-bug, beads, and why cross-repo is the case none of them solves cheaply — is references/prior-art-task-trackers.md.

What this skill reads, runs and writes

  • Reads: the session repo's plans/, both plans stores ($PLANS_HOME, default ~/plans, and its sensitive sibling), its own config ($PLAN_DOCS_CONFIG, else ~/.config/plan-docs/config.toml, else %APPDATA%\plan-docs\ on Windows), the directory names under projects_root (to derive the private terms scan gates on — names only, never contents), git history of the session repo and the stores, and, on Claude Code, the transcript path named by $CLAUDE_CODE_SESSION_ID to anchor the cross-repo guard.
  • Runs: git — read commands everywhere; git commit only on the store, through commit. The history-purge sequence in "Never let a client's identity reach a repo you publish" is printed for you to run; the script never runs it.
  • Writes: its own config, through install, config set, describe and uninstall only. Plan files in the session repo's plans/ and in both stores — new, set-status, move, absorb --apply, graduate, and the retirement you perform by hand. The store directories and their READMEs, created 0700. Through attach: a copy of each file you name, either in a directory beside the plan or in the store's _attachments/, the plan's own ## Attachments rows, and one line in the store's .git/info/exclude. Commits to the store through commit; never a commit in the session repo, and never a file in any other repo's working tree — new refuses and names --for. archive, list, tags, refs, doctor, scan and where write nothing.
  • Network: none. Pushing a store is your command, behind the scan.

Run the script, don't re-derive it

scripts/plans.py (stdlib, read-only unless stated) owns every mechanical step below: which directory a plan goes in, creating it with correct frontmatter, the status index, the anchored tag greps, the promotion and deletion gates, inbound references. Run it instead of opening files to work the answer out — the file reads are the expensive part, and each command below is one of them.

Write the path out in full on every call. Shell variables do not survive between an agent's Bash calls — only the working directory does — so a P=… assignment is empty by the next command and every invocation below is the whole path:

python3 ~/.agents/skills/plan-docs/scripts/plans.py list

status: and updated: are set-status' output. They are never lines you type. This is a rule about those two lines, not about any particular transition — it holds for idea -> in-progress, for a bare updated: refresh, and for landed, the status that precedes deletion. Whatever else you are editing in the file, the frontmatter is changed by python3 <path> set-status <file> <status> and by nothing else, because that command is the only thing that runs the gate and the only thing that knows today's date.

Measured across two repos and two sessions, 2026-08-30 and 2026-09-01: four hand-edits, no gate run. Two idea -> in-progress, one updated: stamp that also dropped a depends_on:, and one idea -> landed whose plan was then retired — git rm and all — in the next three commits. Every one went through the file-editing tool. Neither session overrode a refusal; neither reached the code path that could refuse. Both had this skill in context at the time, which is why the sentence above is a rule about two lines rather than an argument that the gate matters.

[PITFALL: the bypass leaves no trace, and the result is indistinguishable from a correct promotion. The frontmatter is well-formed, list renders the new status, and nothing records whether the gate passed or was never consulted. In all four cases it would have passed — the tags had been resolved first — but that was sequencing, not process.]

Start here. These three answer most sessions, and nothing below is needed until the lifecycle reaches it:

the question the command
first call of a session, in any repo absorb — silent unless something is owed
what is open? what should I work on? list — see "Asking what is open"
where does a new plan go, and write it new <topic>, or new … --for <repo>
is this machine set up, and how? doctor
# writing one down
python3 <path> where                        # which directories this repo reads and writes
python3 <path> repos --search <words>       # what each repo is for, to route a plan by
python3 <path> new <topic> --for <repo>     # something belonging to a repo you are not in
python3 <path> new <topic> --to store       # this repo's plan, kept out of a tree another session holds
python3 <path> commit <file>... -m "<msg>"  # commit these plans alone, whatever else is staged
python3 <path> new <topic> --unscoped       # an idea with no repo yet
python3 <path> graduate <file> --to <repo>  # …once it has one

# working on it
python3 <path> set-status <file> planned    # refuses if the gate for that status fails
python3 <path> tags --tag DEFERRED          # anchored, across every plan this repo can see
python3 <path> move <file> --to store       # a repo switching where it keeps plans
python3 <path> attach <plan> <file>...      # copy evidence somewhere stable, and record it

# retiring it, and getting it back
python3 <path> refs <file>                  # inbound references, before retiring
python3 <path> archive --search <words>     # a retired plan, back out of git history

# keeping the machine right
python3 <path> scan                         # no private name reaches a repo you publish
python3 <path> orgs                         # whose repo each directory is, from its own remote
python3 <path> install --explain            # set the machine up, one decision at a time

Every command that reads takes --json, so nothing here has to be parsed out of its text output.

The file argument is positional where a command requires one — set-status, move, refs, graduate — and --file where it narrows a default of every plan: tags, archive. Measured 2026-08-30: a session that had just run tags --file <name> --tag DEFERRED reached for the same shape on the next command, ran refs --file <name> for seven files in one loop, got unrecognized arguments: --file seven times, and re-ran the whole loop.

Where a plan file goes

A plan normally lives in the repo it describes. That is unavailable in most employer and client repos — a plans/ directory is not yours to add there — so there are three routes, and which one a repo uses is configuration, never a judgement call made per session:

route plans live in for
repo <repo>/plans/, committed with the code a repo you own
store <the store for its tier>/<path under projects root> a repo that can't hold its own plans
both reads both, writes one a repo mid-switch, in either direction

The store mirrors each repo's path at whatever depth it sits, so a <root>/<project>/<repo> clone gets <store>/<root>/<project>/<repo> — no slug, no collision between two clients' api. The path is computed from the repo root, not from the working directory.

From a git worktree the mirror is the repository's, not that checkout's — every worktree of a repo shares one mirror and one absorb queue, so a plan written on a feature branch is visible from main. A repo-routed plan is deliberately not redirected: that file travels with the branch it was committed on, which is already the right answer, so it stays in the worktree's own plans/. where prints a worktree: line when it applies, because rel then names a directory you are not standing in and unexplained that reads as a bug. Before this, both directions lost plans in silence: one written from a worktree landed where the main checkout never looks, and one filed for the repo landed where the worktree never looks.

First: which kind of machine is this?

device in the config, and it decides whether the store splits at all:

device the machine holds the store
contractor (default) several parties' work plus your own public repos two repositories, split by sensitivity
work one organisation — an employer-issued or corp device one, with no tier at all

A work device has no boundary for a tier to draw, so the split would be an empty directory every command still reasons about. sensitive_store and shareable_roots stop applying, and install asks about neither.

Nothing there prints a tier, and that is the point rather than tidiness. "Sensitive" is a relative classification — it means "must not go where the other tier goes" — so on a machine with one store there is nothing for it to be relative to, and printing it stated a property of the material nobody had claimed. doctor, list, where and config name no tier on a work device, and --json reports tier: null rather than a word a reader would have to interpret.

It also has a default, which a contractor device deliberately does not. Everything on a corporate machine belongs to one organisation, and an organisation has its own work tracker — so an unmatched repo goes to the store, and the carve-out is the user's own repos, matched by own_accounts against the remote rather than by host. That is what makes it work on a GitHub Enterprise instance, where your repos and the employer's sit on the same hostname and only the account tells them apart. where names the fallback it used (work device default, or work device, your own account), so the answer is never silent. Setting default overrides it.

What does not relax is the remote check — but it asks a different question. Pushing an employer's internal work to a personal remote does not become acceptable because the machine holds one organisation's work, so the single store is the guarded one. What changed is that the check asks which remote rather than whether there is one: a destination you have recorded is silent, and anything else is reported.

python3 <path> config set sanctioned_remotes '["<host>/<owner>"]'   # where the store may push

An entry is <host>/<owner>, <host>/<owner>/<repo>, <host>/ for a whole instance, or a path beginning with / or ~ for a drive or a NAS — matched against the remote's parsed identity, so the ssh, scp-like and https spellings of one destination all answer the same. A bare account name is refused, in code: own_accounts matches a name on any host deliberately, which is what makes one entry cover github.com and an enterprise instance, and reusing that shape here is precisely the hole — it cannot tell your account on the corporate host from the same name on a public one, and only one of those is inside the boundary.

With the key unset, every remote on the guarded store is reported, as before; the message now carries the line that answers it, so it is a decision made once rather than a warning learned and skipped. The check reads a URL and nothing else — whether the repository you push to is private needs a network call this script does not make, so making it private stays your step.

[PITFALL: the default is contractor because the two mistakes cost differently. Guessing contractor on a single-employer machine costs an unused directory and a line of output. Guessing work on a machine that does hold several parties' work puts client plans in a store the user believes is safe to push. The default follows the failure that cannot leak, so a machine nobody configured is never the dangerous one.]

On a contractor device, the store is two repositories

tier holds remote
shareable _unscoped/ and the roots in shareable_roots allowed — usually private
sensitive every other root: employer and client work none

Both are ordinary git repositories with full history, so retirement, archive and the commit-immediately rule work identically in either. Nothing about the plan format, the status vocabulary or the tags changes with the tier.

Both are created mode 0700 — a free default, not a protection to rely on. The tier decides what may leave the machine; the mode only narrows who on the machine can read it, and it is set because it costs nothing: a umask can only narrow a mode passed to mkdir, and Windows ignores the argument, so there is no branch and nothing to configure.

Nothing checks it afterwards, and that is deliberate. This corpus assumes a single-user machine, where there is no second person for the mode to protect against — and a check would have fired on every Windows run, where the concept does not exist, telling the reader to run a command they do not have. A store you move, restore from a backup or create by hand keeps whatever mode it arrives with; if you are on a shared machine and that matters to you, chmod 700 it yourself, because this skill will not notice either way.

You never pick a tier. A root's tier follows from shareable_roots (which defaults to public_roots), and every command resolves it for you — where prints it, new --for prints it and the exact git -C line to commit with, archive searches both. Read what the command tells you rather than deriving the path.

The split is structure, not the safety mechanism. The risk is a client's name inside any file, not a file inside a client's directory — an unscoped idea or a plan for a personal repo can easily name work that is not yours to disclose. So the shareable tier is gated on content, like any repo you publish:

python3 <path> scan --mode history --path <the shareable store>   # before the FIRST push
python3 <path> scan --mode staged  --path <the shareable store>   # before each commit after that

Both exit non-zero on a hit. The tier boundary is only what keeps the whole of a client root off a remote in the first place.

--mode tree is not the pre-push gate, and using it as one is the mistake to avoid. A push ships history, not the working tree, and the two diverge exactly where it matters: a plan that named a client, was reworded, and was committed again leaves a clean tree and a dirty history, and the push publishes the history. Confirmed 2026-08-29 while wiring this store's own remote — --mode tree was written into this section as the gate, and the first real push was the thing that showed it was the wrong question.

The first push is the one moment history mode is cheap to act on: nothing is published, so a hit is still an edit rather than a purge decision. After that, --mode staged on every commit is what keeps history clean going forward, and --mode history becomes the periodic audit rather than a gate.

If you do rewrite, the scan keeps failing until you drop refs/original/. --mode history reads git log --all -p, and git filter-branch leaves the pre-rewrite refs under refs/original/, which --all still walks — so a rewrite that worked reports exactly the hit count it started with, and reads as though it did nothing. Confirmed 2026-08-29 doing this store's first push. Check the branch itself before concluding anything, then drop the backup ref and let the old objects go:

git -C <store> log <branch> -p | grep -i <term>        # the real answer
git -C <store> update-ref -d refs/original/refs/heads/<branch>
git -C <store> reflog expire --expire=now --all && git -C <store> gc --prune=now

Take a copy of the whole directory first. It is the only copy of those plans, and a rewrite is the one operation in this convention that can lose them.

shareable_roots exists as its own key, defaulting to public_roots, because the two questions nearly always agree but are not the same: a root's name may be publishable while its plans are not, or the reverse. Leave it unset until they actually disagree.

Moving a root between tiers moves no files. doctor reports a mirrored root sitting in the wrong store and names where it should go; relocating it is a git mv in two histories and a decision about what gets published, so it is never done automatically.

Whose repo is it? Ask the remote, not the directory

A directory name is where a clone was filed; its remote is who it belongs to, and only the second one stays true when a clone is moved. So [orgs] routes by <host>/<owner> read from the repo's own remote, and it is checked after [repos] and before [roots] — the full order is:

checked key means
1st [repos] exact path this one repository
2nd [orgs] remote owner every clone belonging to that account or organisation
3rd longest [roots] prefix every repo filed under that directory
4th default the machine's answer for everything unmatched
5th the device fallback work devices only, see above

[orgs] sits above [roots] because the two answer different questions and only one of them is about the repository. Where they disagree — one clone from somebody else's organisation sitting under a root routed repo — the remote is right and the directory is a coincidence.

A repo belonging to an organisation you have not decided about is refused a plans/ directory. Organisations keep their own trackers, so a plans/ directory committed into theirs is a convention nobody there agreed to, and the commit is visible inside that organisation the moment it is pushed. where exits 3 naming the org and the line that records the answer either way. Two things deliberately do not trigger it: a repo with no remote (local, so nobody could have agreed or objected — absence of evidence is not evidence), and a machine where own_accounts is unset, because with nothing to compare against every owner reads as foreign and the check would fire on every repo at once, which is how a check gets configured away rather than answered.

python3 <path> orgs            # every owner on this machine, its route, and who decided it
python3 <path> orgs --repos    # …with each organisation's clones listed

It exits 3 when a foreign organisation's repos are routed repo with nobody having said so, so it works as a check and not only as a listing. Like doctor and repos, its output names employers and clients: it is for deciding where plans go, never for pasting into a repo you publish.

where exiting 3 is a question, not a failure. It means no rule covers this repo, or it belongs to an organisation nobody has decided about. Ask the user which route it should use, then record the answer. Never pick a side silently: guessing "repo" writes a directory into someone else's repository, and guessing "store" hides the plan somewhere the user never named.

Record it with config set, never by editing the TOML yourself:

python3 <path> config set roots.<root-name> repo         # a whole root
python3 <path> config set repos.<root>/<repo> store      # one repo, beats any root rule
python3 <path> config set orgs.<host>/<owner> store      # every clone of theirs, beats a root rule
python3 <path> config set own_accounts '["<account>"]'   # the accounts that are yours
python3 <path> config set sanctioned_remotes '["<host>/<owner>"]'  # where the guarded store may push
python3 <path> config set default store                  # everything unmatched
python3 <path> config set view.idea_limit 20             # how many ideas a listing shows

Write an [orgs] key unquoted on the command line. The quotes are how it is spelled inside the TOML file, so copying one out of the config writes a key whose first character is " — well-formed TOML that matches no organisation ever. The script unwraps a quoted name rather than storing it, because that mistake is otherwise silent.

It preserves every comment in the file — those comments carry the reasoning for each key — replaces a commented-out example in place, and rejects a value the config's own schema will not accept, restoring the file rather than leaving it broken. A key's table is whatever precedes its first dot, so a repo path full of dots stays one key.

projects_root = "~/projects"
store = "~/plans" # the shareable tier
# sensitive_store = "~/plans-sensitive" # defaults to <store>-sensitive
default = "store" # omit it and an unmatched repo asks instead

public_roots = ["github.com-personal"] # names that may appear in a published repo
# shareable_roots = ["github.com-personal"] # the tier boundary; defaults to public_roots

own_accounts = ["your-account"] # unset = no repo's ownership is checked at all
# sanctioned_remotes = ["git.corp.example/your-account"] # where the guarded store may push

[roots]
"github.com-personal" = "repo" # longest matching prefix wins

[repos] # an exact repo entry beats any root entry
"github.com-acme/legacy-api" = { mode = "both", write = "store" }

[orgs] # by remote owner; beats [roots], loses to [repos]
"github.com/acme-corp" = "store"

Environment assumptions, and setting them up

$PLANS_HOME (default ~/plans) is the shareable store and $PLANS_SENSITIVE_HOME (default <store>-sensitive) the other half — pinning the first pins both, since the second derives from it; projects_root (default ~/projects) is the root the mirrored paths are relative to; $PLAN_DOCS_CONFIG overrides the config location.

The config is per-machine, not per-user, and is deliberately not version-controlled. It maps the repos that happen to be cloned on this box to routes, so it says nothing meaningful anywhere else — a second machine with a different set of clones needs a different file, not a copy of this one. Do not propose committing it to a repo or syncing it; losing it costs one install and one pass of config set, which is cheaper than maintaining a shared file that is wrong on every machine but one.

Setting up a machine is a walkthrough, and you run it. The script never prompts — it has to keep working when a human runs it by hand, and an interactive prompt inside an agent's Bash call hangs with nothing to type into. So the decisions are printed as data and you are the interactive surface:

  1. python3 <path> install --explain — what it would create, then one block per decision, each with what it is, what is currently set, what it would suggest, and what it costs to get wrong. Writes nothing.
  2. Put each decision to the user with AskUserQuestion, using the suggest line as the recommended option and the cost line as the description. Do not skip to the defaults: the default, own_accounts, public_roots and shareable_roots answers decide whether plans land in repos the user does not own, whether scan will catch a client's name, and which roots may reach a remote at all. own_accounts is the one to confirm rather than accept: the suggestion is built from the commonest remote owner on the machine, which on a corporate box is the employer — recording that answer would mark their organisation as the user's own and make every ownership check downstream pass silently.
  3. Record each answer with config set (above). Never edit the TOML by hand.
  4. python3 <path> install — idempotent: writes the config skeleton if there isn't one (never over an existing one), creates both stores as git repositories, adds neither a remote, creates the repo-less area.
  5. python3 <path> doctor — confirm it took, and that no problem is left.

It asks one question per unrouted root only when no default covers them; with a default set, that answer is already given and the walkthrough stays short.

python3 <path> uninstall reverses it: it removes the config but keeps both stores, because the store is the only copy of those plans; deleting them takes --purge-store --force and a deliberate decision, and the file count that triggers the refusal is taken across both tiers before either is touched.

What the projects tree has to look like

Repos are discovered by walking projects_root and stopping at each .git, so the walk assumes a shape. A collection directory is any directory on the path down to a repo — projects_root itself, each root under it, and each intermediate level of a <root>/<project>/<repo> hierarchy. Collection-ness is derived, never configured: a directory is one if it is not a repo and has repos beneath it.

  • projects_root must not be a git repository. This one is fatal and refuses rather than reporting: with a .git there the walk returns a single repo named ., every real repo becomes invisible, and scan derives almost no terms — a confidentiality gate that passes because it can no longer see anything.
  • A symlink is never followed. Git resolves symlinks, so a link to a repo inside the root enrolls the same repo twice under two paths, and a link to one outside is counted by discovery while where refuses it. Plan in the repo at its real path.
  • A bare repository is neither a repo nor a collection, and is reported as such rather than walked into.
  • A linked worktree is not a second repo. It is enrolled nowhere and doctor names the checkout it belongs to. Without that, one repo was listed as three, routed to three separate store mirrors, and its branch name entered the private term list — and branch names are ordinary words (feat, main, docs), which is how a scan becomes noisy enough to switch off. Both sibling layouts do this: VS Code's default <repo>.worktrees/<name> and the flat <repo>-<branch>. A submodule looks the same from outside — both put a .git file where a checkout has a directory — and stays a repo, because only a worktree's names worktrees in it.
  • A directory holding no repos is simply ignored — doctor counts them and --strict lists them.
  • A repo cloned straight into projects_root is routed with [repos], never [roots]. A [roots] key is a path prefix, and a repo at depth 1 has no prefix, so an entry naming it is never consulted and the repo falls through to default — which doctor reports through inert_root_rules if you write one anyway. A git repository is never a candidate root, so neither doctor nor install --explain ever asks you to categorise one: on a flat ~/projects/<repo> layout, the more common one in the wild, every clone would otherwise be listed as an undecided root, which is one warning per repo on the machine. default answers all of them, and a repo that genuinely needs its own answer takes a [repos] entry when a plan is first written there.

Categorise every root explicitly, even where default would give the same answer. Then a root falling through to default means exactly "this appeared since you last decided anything", and doctor lists it as awaiting a decision — no seen-markers, no registry, just the config read as a record of what has been answered. "Root" there means a directory holding repos, so a machine whose projects root holds only repos has nothing to categorise and doctor says nothing about it. Without that pass, a newly cloned root is routed silently, which is right for a client root and quietly wrong for a personal one: its plans would accumulate in the store mirror forever, because a store-routed repo's mirror is its home and absorb correctly does nothing.

Is this machine set up, and what is in it

python3 <path> doctor

One call for the whole picture: config location, every store with its git state and whether its remote was sanctioned, which roots are enrolled, by which rule and into which tier, which repos actually hold plans, a tally by status and open tag, and a problems list — a store that is not a git repository or has lost its git identity, an unsanctioned remote on the guarded store, a mirrored root filed in the wrong tier, an unset PLANS_HOME, a repo holding plans that no rule routes, an organisation that is not yours whose repos are routed repo, and a foreign organisation's clone filed under a shareable root, whose store plans would land in the tier that may have a remote. Run it when something behaves oddly and before trusting archive, which retrieves nothing from a store with no git history.

It aggregates by root and names an individual repo only when that repo holds plans — a per-repo listing is one row per clone on the machine, which is a roster of employers and clients. Its output is for setting the machine up, never for pasting into a repo you publish.

The guarded store's remote rule is the design, not an oversight — the sensitive tier on a contractor device, the only store on a work device. doctor reports a remote there unless sanctioned_remotes covers it: local history is the benefit, and one personal remote accumulating several clients' internal architecture is the outcome to avoid. Recording a destination is a decision against that employer's actual policy, never a convenience, and until one is recorded treat that store as unbacked-up. Never symlink either store, or a subtree of it, into a work repo — that puts the content back inside the tree repo-scoped agent reads walk.

Never let a client's identity reach a repo you publish

The store exists because work repos can't hold plans. The mirror image of that is the rule that matters more: a plan committed to a repo you publish must not name the client, employer, project or repo it came from. Not the org, not the internal project name, not the work email address, not the ticket prefix. A plan about work for someone else can still be written — describe the shape ("a work root with a <project>/<repo> hierarchy", "a client repo under review pressure") and keep the specifics in the store, where they belong.

Run python3 <path> scan before committing to any repo that is or might become public, and --mode staged immediately before the commit itself. It exits non-zero on a hit. The terms come from the machine — every root, project and repo name under projects_root that is not under a public_roots entry, each root name also split into its organisation (so the client behind <org>.com-<host>-<team> is caught in an @<org>.com address too), plus [private] extra — so a newly cloned client is covered with nothing to maintain, and the list itself never has to be written into a public repo.

[private] extra is not optional decoration: an employer with no repo on this machine has no directory to derive from, and is invisible to the scan until someone adds it.

Only a collection name is split into its organisation. A directory under projects_root that is itself a repository contributes its whole name and nothing else — splitting it is how ordinary words enter the term list, and a gate that flags "repo" in every document is a gate that gets switched off.

Never hand-roll the pattern for an audit. scan --list-terms prints the list the scanner derives; scan --mode history is the audit. A regex written by hand is a narrower list whose edges you cannot see, and it will look like a clean result. Confirmed live 2026-08-29: a repo was surveyed with a hand-written alternation covering the addresses already known about, declared clean apart from those, purged — and the scanner then found an employer's name in that repo's first two commits from 2021, in four branches, because the hand-written pattern omitted one work root the derived list had all along.

Failure modes to handle correctly:

  • A generic hit. A work repo named tools or settings matches ordinary English. Put that one name in the config's [private] ignore list — never widen public_roots, which silences a whole organisation's worth of names to fix one word.
  • Writing about a hit reintroduces it. A plan explaining what leaked, a rationale page using a real name as an example, a commit message quoting the offending line — each puts the term straight back into the tree the scan just cleared. Measured three times in one session, 2026-08-29, each caught by --mode staged before the commit. Reference the thing by where it lives — the commits and the scan --mode history command that print it — not by quoting it.
  • A hit in pushed history. --mode history scans every commit. Redacting the working tree does not remove anything from a published repo; purging history means a force-push and a support request, and it is the user's call, not an edit to make quietly. Report it, name the commits, stop.
  • A path the scan could not read. --mode tree enumerates with git ls-files, and a nested checkout — a linked worktree under .claude/worktrees/, a submodule — comes back as one directory entry rather than as its files. Those paths are listed under the hit count and are not covered by it; each is a repository of its own, scanned by pointing --path at it. --mode staged and --mode history read git's own output and are unaffected, which is the reason the pre-commit rule above calls staged rather than tree.

Confirmed live 2026-08-28: this repo had already published a plan whose measurement table listed six employer/client root directory names, plus one client's internal <project>/<repo> path — written by an agent with no rule telling it not to, into a repo whose own README advertises it as public.

Something that belongs to a repo you are not in

Work that belongs to another repo is filed as a plan for that repo, not performed — a plan file least of all, but the rule is about the work, not about the file type. Parallel sessions on one machine share that tree, so anything appearing there under a session already working in it is the failure this rule exists to prevent, and a commit across repos is one nobody in that repo asked for.

The rule being stated about plan files is how sessions walk past it. A session reads it, uses --for correctly, concludes it has complied, and then edits that repo's source anyway — because the thing being written was not a plan. Confirmed live 2026-08-29: a session did exactly that within the hour, proposing as its recommended option to edit two files in another repo and run that repo's deploy task. An edit is arguably the worse case: a stray plan file is inert and obvious, while an edit to a file that repo's session is holding is a real conflict.

The escape hatch is narrow, and worth quoting rather than paraphrasing — it is "unless it's a very complex process that requires a lot of back and forth to fix". That is not "unless it is small": a one-line fragment edit is exactly the case that feels too small to file and is not. It is about a change that cannot be described faster than it can be done jointly, and that one wants its own session in the owning repo rather than a relay.

Reading another repo stays fine, and is how a filed plan gets written accurately enough to act on. The prohibition is on writing.

For plan files the script enforces it rather than trusting anyone to remember: new refuses to create a plan in a repo other than the one the session is in, and names --for in the error. Commands that act on files which already exist — graduate, and anything reached by --path — warn instead, because those have legitimate uses; when you see that warning, prefer doing the work from a session inside that repo, and if you continue, tell the user exactly what landed where.

The guard is anchored to the repo the session started in, not to the working directory, because cwd is unreliable in both directions — a reset and a persisted cd were both observed inside one session, 2026-08-29. A guard comparing cwd against cwd cannot fire when cwd drifts, since both sides move together; an anchor gives the comparison two independent sides.

Three tiers, most trustworthy first. Nothing here is Claude-only except tier 2, and no tier is required for the skill to work:

tier signal when it applies
1 $PLAN_DOCS_SESSION_REPO any harness, if it exports it
2 $CLAUDE_CODE_SESSION_ID → the session's transcript path Claude Code, no setup needed
3 cwd fallback; cannot detect drift

On a harness that is not Claude Code, export PLAN_DOCS_SESSION_REPO at session start — export PLAN_DOCS_SESSION_REPO="$(git rev-parse --show-toplevel)" — and the guard is exactly as strong as it is under Claude Code. Without it everything still works; the guard just degrades to tier 3 and stops catching a drifted directory. doctor reports which tier is in use and lists the fallback as a problem, so nobody is in the weak tier without being told.

Two habits that hold at every tier:

  • Read the repo: line every create prints. It names the repo the plan just became the property of, derived from where the file was written rather than from any comparison, so it is true regardless of what cwd or the anchor did.
  • Never cd into another repo without asking the user first, and after any cross-repo command treat cwd as unknown until a call re-establishes it. The anchor makes a stray cd survivable, not free — everything else in a session still runs relative to cwd.
python3 <path> new <topic> --for github.com-personal/<repo>   # or an absolute path

It writes into that repo's store mirror, outside every working tree, whatever that repo's route says. Nothing in the target changes. The session working there sees it — list at repo scope reads the store mirror regardless of route — and absorbs it on its own schedule with move <file> --to repo, committing only to its own repo.

The same mirror serves this repo when its own tree is busy. When a plan belongs to the repo you are in but another session is writing that working tree, new <topic> --to store puts it in this repo's store mirror instead, and absorb offers it here later. --for on your own repo is refused, and --unscoped is the wrong fallback: the unscoped area is where absorb never looks. Confirmed 2026-09-13: a plan filed unscoped for that reason sat unoffered for three hours, until a harvest noticed new takes --to and moved it.

No frontmatter marks these. For a repo that keeps its own plans, a file in its store mirror is in transit by definition; for a repo routed to the store, the same file is at its permanent home. Route plus location already says it, so there is nothing to set and nothing to drift.

If the store has uncommitted changes, add a new plan rather than editing an existing one, and reference the plan it relates to. Another session may be holding that file; a new file cannot conflict, while an edit to a held file is the one loss that is not recoverable. Check with git -C <store> status --porcelain — against the store this write targets, which the create command names for you. Checking the other tier answers a question about a different repository.

The check works because both tiers are real git repositories. That is why the sensitive roots are a second repository rather than entries in the shareable one's .gitignore: verified 2026-08-29, a write to a gitignored path does not appear in git status --porcelain at all, so this check would report clean about the tier it cannot see — worse than having no check, because the answer is trusted.

Commit a store plan the moment it is written, never at the end of a session. Every minute the store is dirty is a minute another session must fall back to adding a file it would rather have edited, so the rule above and this one are the same rule from two ends: the fallback is cheap because dirty windows are short, and dirty windows are short because nobody sits on an uncommitted plan. new produces an empty skeleton, so the moment to commit is after the content is written, not at creation.

python3 <path> commit <the plan> -m "<repo>: <what it is>"

Use the command rather than git add && git commit, because the store is one working tree with one index and every session on the machine writes to it. commit builds the commit from HEAD plus the files you name, through a private index, so a parallel session's staged work can neither ride along under your message nor be disturbed by your commit. Measured 2026-08-29, before it existed: a git add was swept into another session's commit twice in one sitting, each time reporting nothing added to commit — which reads exactly like the add failed, when in fact it had succeeded and someone else's commit had already taken it. The content was never wrong; the message described a different change than the diff it carried, and git log -- <path> was the only way to find out where the file actually landed.

Doing it by hand was the single most repeated shape on this machine — 142 calls across 23 sessions, measured 2026-09-01 — which is what earned it a command rather than a longer sentence.

Name every file of one logical change in one call; an absorption is one commit, not N. The command took a single file until 2026-09-09, and four sessions met the cost: two reasoned their way to git -C <store> commit -- <dir> — correctly, since a pathspec commit does not ship the index — one paid it as two commits with two messages for one absorption, and one hit the plural at seven, after five sessions in three repos had filed into the same queue. Each deviation was stated in full in its own commit message, so this is evidence about the wording rather than about those sessions: a rule that gives a mechanism can be argued around by anyone who accepts the mechanism. Naming several paths is that same private index with a loop, so there is nothing left to argue with.

-m is required once you name more than one, because the generated message names a single plan's topic and no default describes a set. There is deliberately no whole-directory form: a directory argument makes it easy to sweep a file this session never touched, and the store has held another session's staged deletions in a second mirror at exactly the moment one of these commits was made — a <dir> argument was correct there by one directory level, and nothing in the command would have said so.

If a path you name has vanished, the command says whether absorb took it. A store plan you edited can be absorbed by another session between your edit and your commit, and the commit then carries a pure deletion under a message announcing an addition. Confirmed 2026-09-04, following the already-owned rule in session-harvest step 2: 76 deletions, 0 insertions, and eight minutes of reading the target repo to establish the content had survived — which it had, absorbed whole. The note names the destination and the commit that added it, and it prints only when a destination actually resolves, so an ordinary retirement is not told its plan was absorbed.

[PITFALL: Edit catches this a step earlier and Write does not, which is the tool rather than the convention. An Edit against an absorbed path fails loudly with File does not exist; a Write to that same path recreates the file, and the commit then records a resurrection under a message written for an append — a plan back in the store that a session already took, which absorb will offer again. Confirmed 2026-09-05, from the harmless side: a session extending a plan it had filed earlier in the run found the store path gone and learned it from Edit. So reach for Edit when adding to a store plan somebody else may be absorbing, and read a failure as news rather than as something to route around with Write.]

Pushing is a separate, gated step, and only the shareable tier has anywhere to push to. Scan before pushing and push only on a clean result — --mode staged on the commit you are about to make, and --mode history before the store's very first push, since that is what a push actually ships and the last moment a hit is an edit rather than a purge decision. The tier boundary keeps whole client roots off the remote, but a client's name inside a personal repo's plan or an unscoped idea is exactly what it cannot catch. The sensitive tier has no remote, so there is nothing to push.

Absorbing what was filed for this repo

On your first plan-docs call in a session, run absorb before anything else:

python3 <path> absorb            # report only — silent when nothing is waiting and nothing is owed
python3 <path> absorb --apply    # move them into this repo's plans/

[PITFALL: in a repo whose plans live in the store, there is nothing for absorb to absorb, and the call is still the right first one. The mirror is that repo's permanent home, so nothing is ever in transit — but this is the one command that carries the retirement prompt, and a store-held plan goes terminal like any other. On a machine where every repo is store-routed, which is the normal shape of a corporate one, that prompt is the whole of what it ever prints. The command is named for the other case; read a silent run as "nothing is owed here", not as a command that did not apply.]

It is silent when the store holds nothing for this repo and nothing is owed retirement, so it costs nothing on the sessions where it does not apply. When it does print filed plans, put the set to the user with AskUserQuestion before applying — one question for the set, not one per plan — then apply, run the repo's quality gate, and commit twice: this repo for the additions, the store for the removals. --apply prints the store's commit with its paths filled in; run it with your message rather than assembling the paths, and note that commit resolves a path where it points and never by basename — a store-relative path committed an absorption's removal as an empty commit in the wrong repo until 2026-09-13. Then carry on with whatever the session was for.

Once per session, not once per command. The script is stateless and cannot tell a first call from a fifth; you can, so the rule lives here. list also prints a one-line footer when plans are waiting, for a session that skipped the proposal.

Committing to both repositories here is not the cross-repo commit this section forbids. You are the session working in this repo: the additions go to your own tree, and the store only loses the file you just took. What is forbidden is writing into a tree that is not yours — and absorb --apply enforces exactly that, refusing when the target is not the repo your session belongs to. Reporting for another repo (absorb --path <other>, no --apply) is a harmless question and stays allowed.

A name collision is a merge, not a rename. absorb refuses when a filed plan's name already exists in plans/, exits non-zero, and destroys neither copy — two plans sharing a name means both cover the topic, and that is the moment to combine them by hand.

Absorption is where a dirty-store split gets reconciled — and absorb can only tell you a reference exists, never why. When a harvest found the store dirty it added a new plan referencing an existing one rather than editing a file another session might have been holding; those two halves want merging. But the common case for one plan citing another is simply that they are related, and several say so outright — "filed alongside it", "this is a new concern rather than an addition to that one", "not re-raised here". So absorb prints references … beside each pair and the judgement is yours: read both, then merge only when they are two halves of one topic.

Measured across two absorptions, 2026-08-29 and 2026-08-30: seven of twelve pairings were deliberate separations. The failure is asymmetric, which is why the report states no cause — a missed genuine split costs one duplicated topic that someone notices later, while a wrongly-merged pair destroys a separation someone reasoned about in writing, and the reasoning goes with it. Nothing re-surfaces a genuine pair after absorption, because the pairing lives in prose, so a real split skipped here is one nobody is reminded of again.

Read the incoming titles as a set, and ask whether any two are one subject — because name equality is the only collision anything detects, and two plans on the same subject with different names are invisible to all of it. That is the shape that actually occurs here: parallel sessions pick their own descriptive filenames, so the ordinary act of naming a plan well defeats the check.

Confirmed 2026-09-03, and the pair is a good one because neither plan was wrong. One was filed into the store about what a skill declares regarding its config and mutations; the other was committed in the target repo within hours, by a parallel session, about where a skill may put things at all — XDG config versus data versus state, and the two store env vars. They compose rather than duplicate, and the dependency runs one way: a disclosure names locations, so its format cannot be settled before the locations are. A session absorbing the first without the second would have designed the disclosure format against locations that were about to change. absorb reported neither fact, because the two did not cite each other — neither author knew the other existed.

[PITFALL: the collision is least visible exactly when it is most likely. Two sessions converge on one subject because the user raised it with both, in the same period — which is also when neither can see the other's filename, since the other plan may not exist yet at filing time. The mechanism that would catch it (citation) needs knowledge the authors cannot have, and the mechanism that does run (name equality) is defeated by choosing a descriptive filename.]

[DECISION: a prompt to think, not a detector. Settled 2026-09-09, after weighing both. Filename word overlap is crude — the 2026-09-03 pair shares only "skills". Frontmatter is no help: neither carried a topic field, and adding one is a new thing to maintain and get wrong. A weak detector that is trusted is worse than a prompt to think, because it converts "I should look" into "the tool would have told me" — and this section's whole argument is already that a wrongly-merged pair destroys a separation someone reasoned about in writing. The same reasoning refuses putting the check on the filing session instead: it only helps when the other plan already exists, and these two were hours apart, so it would have caught one direction and not the other.]

The same gap exists inside one repo's own plans/ — nothing stops two sessions opening two plans on one subject there either, and list groups by status rather than by topic, so a reader scanning it sees them apart. Only the store case has been observed; the in-repo case is the same mechanism with no store involved, and the same prompt covers it whenever you are reading the list as a list.

When it is a merge, four rules, each derived at the point of needing it:

  • Keep the name that describes the merged subject, not one case of it. "The earlier filename" is undefined when both plans share a date, which is the common case.
  • A correction is merged in place, not appended — otherwise the wrong argument stands above the right one and the reader has to arbitrate.
  • Name the merged-away file in the surviving plan, or archive cannot find it: the deleted plan is in git, and that line is the only thing pointing at the name to search for.
  • A cluster member already implemented is marked done, not carried, so the merge does not queue edits that already exist.

A cited filename that resolves to no actual file is not a pair — plans legitimately reference retired and foreign plans in prose, and only a name matching a real file on either side counts.

Absorption applies only to a repo that keeps its own plans. For a repo routed to the store, the mirror is the permanent home and nothing is ever in transit.

The retirement prompt absorb also carries

plans/ is defined as a working set that empties out, and until 2026-09-02 nothing ever asked anyone to empty it. The convention was never missing the mechanism — a five-step procedure, a deletion gate, an inbound-reference check, archive to read a retired plan back — it was missing a trigger. Measured 2026-08-29, machine-wide: nine terminal plans across five repos, two of them reaching that status the same day, so the backlog grew faster than it drained while list's footer printed throughout. One of the nine was not merely unretired but stalled mid-procedure.

So absorb says it, in two groups that are two different requests:

  • STALLED mid-retirement — the plan carries a ## Migrated to section, so step 4 is done and steps 5–6 never ran. Cheapest to finish and likeliest to be lost, since nothing else distinguishes it from a plan nobody has started. Raised whatever its age. Finish these first.
  • awaiting retirement — terminal for RETIREMENT_PROMPT_AFTER_DAYS (3) or more, oldest first, at most five rows before the rest collapse to a count.

Put it to the user as one question, with the cost in it, and make "not now" a real answer. Retirement is a judgement procedure, not a command: triage the content by lifecycle, find a home for the rationale, write ## Migrated to, fix inbound references, delete. So the prompt is an offer to spend a chunk of the current session on something the user did not come here for. "Retiring these two will take most of a session" is a different question from "shall I tidy up?", and only the first one can be answered honestly. A stalled plan is minutes, and asking about it in the same breath as an unstarted one loses that.

Three settled choices, each of which was an open question:

  • The throttle is age, and age is chosen because it needs no state. A marker file recording what was asked and when would be a second lifecycle store with no retirement of its own — the objection this convention already makes to parking anything outside plans/. Three days rather than one so a plan landed on a Friday does not nag on the Saturday. The throttle is about nagging, not readiness — it governs when a plan is raised for somebody else to pick up, and says nothing about the session that landed it, which retires it the same day per the rule above.
  • Three surfaces, three distinct triggers, no split ownership. absorb speaks for the aged backlog at the top of a session. session-harvest already reports plans this session made terminal, under "decisions waiting" — and it has to, because absorb runs before this session has landed anything. list keeps its passive one-line footer for a session already reading plan state. Each answers a question the other two cannot see.
  • The prompt trusts nothing but the status field it reads, and that is a known limit. A plan whose status was hand-edited past the gate looks identical here — which is why "status: and updated: are set-status' output" is a rule at the top of this file rather than a note.

This does not propose retiring a backlog that already exists. That is real work needing real sessions and should be scheduled deliberately; folding it into the change that adds the prompt would make the prompt's first run its worst.

Plans that belong to no repo yet

An idea, task or exploration that has not earned a repo goes to $PLANS_HOME/_unscoped/:

python3 <path> new <topic> --unscoped     # works anywhere, including outside any git repo
python3 <path> list --scope unscoped      # the repo-less backlog on its own
python3 <path> graduate <file> --to <path inside the new repo>

graduate routes the file through the destination repo's own rule — into its committed plans/, or into that repo's store directory — and stamps the repo: frontmatter when the destination is the store. Do it the moment the repo appears, not later: an unscoped plan whose work has moved into a repo is a plan nobody will find again.

Asking what is open

Answer "what plans do we have" and "what should I work on" with one list call, not by opening plan files. The scope is chosen for you:

python3 <path> list                       # auto: this repo when in one, the machine when not
python3 <path> list --scope family        # every repo, always
python3 <path> list --stale 14            # only what nobody has touched in a fortnight
python3 <path> list --since 2026-08-01    # only what moved on or after a date
python3 <path> list --tag DEFERRED        # only plans carrying that open tag
python3 <path> list --all                 # include landed, abandoned and superseded
python3 <path> list --limit 0             # every idea, uncapped

--scope auto is the default and resolves to repo inside a routed repo, family outside any repo and inside the store itself — so a session that has cd'd nowhere in particular gets the machine-wide answer without asking for it.

Repo scope reads the repo's plans/, its store mirror, and the unscoped area, whatever the route says. A route decides where a write lands; letting it decide what a read can see is what kept unscoped plans invisible from every repo on a machine whose roots are all mode = "repo" (confirmed 2026-08-29) — the plans with no other route back to attention were the ones nothing surfaced.

--stale and --since are opposite questions and treat an unstamped plan oppositely: --stale keeps it (nothing says it was touched), --since drops it (nothing says it moved). Neither bounds the default output — the cap below does that, because a date window's size is not predictable.

Output is bounded, and only the idea tier is capped. in-progress, blocked and planned are limited by how much work can actually be in flight; idea grows forever, so it is the only group whose cost is unbounded. The cap is [view] idea_limit (default 10), --limit overrides per call, and the footer always says how many rows were elided. Never read a capped listing as the whole set — if you are about to conclude something about every plan, pass --limit 0 first.

One thing only --scope family reports, because no per-repo command can see it: depends_on as a blocked-by view, summarised as counts. The plans themselves print under --scope repo in the repo being waited on, as "waiting on this repo" — that is where the wait is actionable, and printing every edge machine-wide is a section that grows with the corpus exactly the way the idea tier does (measured 2026-08-29: 22 of 81 lines).

Status drift prints at every scope, and a drifted status is not what it looks like. A status outside the vocabulary — done where landed is defined, or a free-form paragraph where an enum belongs — renders as its own group heading, so at repo scope it reads as a deliberate blocked on … rather than as a mistake. Family scope is still the only view that catches drift in a repo nobody is working in, and it found the first two instances (2026-08-29) and two more (2026-09-02) — but it is the wrong only place to report it, because writing into another repo is out, so every finding had to be filed as a plan and wait for the one session that would never be shown it. Nothing about the check needs the family: the vocabulary is a fixed enum, so one repo has everything required.

[PITFALL: a drift that reads as a synonym hides more than one that reads as prose. A paragraph where an enum belongs is obviously wrong to any reader. done is a single plausible word that renders as a tidy heading — and it is the more consequential of the two, because it quietly asserts the plan is finished while landed, the vocabulary's real terminal status, is gated: set-status landed refused that same plan on an open UNVERIFIED tag the moment it was asked. The synonym did not merely mislabel the state, it routed around the check. Drift is therefore read off the unfiltered set — a landed by hand is grouped as terminal and would otherwise be dropped from the rows before the check ever saw it.]

A terminal-status plan is hidden from the rows but never silently. plans/ is a working set that empties out, so a landed plan still sitting in one is a retirement owed; the footer counts them and --all shows them. That count is often the most useful line in the output.

Like repos, family output names work repos: use it to decide what to work on, never paste it into a repo you publish.

Which repo does a plan belong to?

When content could plausibly belong to more than one repo, don't grep repos to decide, and don't guess:

python3 <path> repos --search "<the words the plan is about>"

It prints every repo under the projects root with its route and a one-line description — the config's [about] entry, else that repo's README first line — ranked by the search words. Take the top two or three and put them to the user as AskUserQuestion options with those descriptions as the option text: an informed guess to confirm, not an open question. Record a better description with python3 <path> describe <repo> "<what belongs there>" whenever a README's own line turns out to be a poor answer. That listing names work repos, so it is for choosing a destination — never paste it into a repo you publish.

Creating a plan

python3 <path> new <topic> — kebab-case topic, one file per topic. It writes YYYY-MM-DD-topic.md in the route's write directory with the frontmatter already correct:

---
status: idea
updated: YYYY-MM-DD
repo: <origin URL> # store-held plans only — location no longer names the repo
---

Body: ## Context → ## Open questions (each unresolved point marked with an inline [NEEDS CLARIFICATION: ...] tag) → ## Recommended direction (rough, non-prescriptive).

This applies to anything turned up incidentally, not only to deliberate design requests. A bug worth fixing later, an idea worth brainstorming, a risk worth mitigating — each gets its own file. Never leave it as future-work prose in README.md, AGENTS.md, a docs page, or a code comment: those describe current state, and prose has no status field, so nothing ever prompts anyone back. (Confirmed live 2026-08-23: a known test-coverage gap sat in a README.md as "a real gap… not yet fixed" instead of a plan file, invisible to anything scanning plans/ for open work.)

Committing a plan file

Run the repo's quality gate before every plans/*.md commit — create, update, or retirement. "Just markdown" is not an exemption: formatters reflow prose, and doc-only commits that skipped the gate are the single most common cause of red CI in repos using this convention.

A store-held plan is committed to the store's git repository instead, and no work repo's gate applies to it. Commit it there anyway, in the same session that wrote it: the store's history is the only record that plan has, and an uncommitted file in a directory nobody browses is the same as no plan at all.

Attaching evidence to a plan

A plan citing a log in Downloads, a scratch directory, or the transcript its own source_session names is citing something that stops existing — the transcript on a timer, the others the next time anyone tidies up. attach copies the file somewhere stable and writes a row into the plan saying what it took:

python3 <path> attach <plan> <file>...           # size decides where each one goes
python3 <path> attach <plan> <file>... --commit  # evidence someone will read
python3 <path> attach <plan> <file>... --local   # bulk output, or something that must stay exact

Two destinations, and only you can judge the axis that matters. A file at or under [attachments] commit_limit_kb (1024 by default) is copied into a directory beside the plan, named for it, and committed with it; anything larger goes to the store's _attachments/ area, which git never sees. Size is the half a script can judge, so it picks the default — the flags are for the other half:

the file where it belongs
another agent's investigation, a screenshot committed
a multi-megabyte log, a data dump local
anything that has to stay byte-exact local

[PITFALL: a committed attachment is an ordinary file in that repository and goes through its gate. dprint reflows markdown, so an investigation report committed as .md stops being byte-exact the first time the gate runs — silently, because the gate rewrites it and passes. Use --local when the exact wording is the evidence.]

A local attachment is the only copy of that file, and attach says so every time it writes one. It is in no git history, so archive cannot bring it back, and on a contractor device it sits in the tier that deliberately has no remote. The plan's row carries a sha256 so a later reader can tell it from a different file of the same name; whether to keep the original as well is your call.

A row never names where the file came from. Independence from an ephemeral path is the whole point of copying it, and a source path can carry a client directory or an account name into a repo you publish. Say in prose what the file is and what it shows; the row carries name, size and digest.

scan sees a committed attachment like any tracked file and cannot see a local one. That asymmetry is deliberate rather than a gap — the local half never leaves the machine.

Attachments travel with their plan. absorb, move and graduate carry the directory, and commit <plan> takes the plan and its attachments as one commit. A directory already sitting where they would land blocks the move exactly as a name collision does: two things claiming one name is a merge, not a rename.

At retirement the committed half is deleted with the plan and stays reachable in the history archive reads. The local half is named by refs and left alone — deleting it is the one irreversible step in a procedure built to be reversible, so it is a decision to put to the user.

Tags

Five inline markers, all [SHOUTY-WORD: text], so the judgment calls below become greps instead of re-reads, and nothing costly is lost when a file is deleted.

tag means at retirement
[NEEDS CLARIFICATION: …] open question blocks planned and landed
[DECISION: …] settled choice + why it beat the alternatives → design rationale
[PITFALL: …] non-obvious trap, confirmed by hitting it → design rationale
[DEFERRED: …] consciously scoped out, still wanted → an open plan; blocks deletion
[UNVERIFIED: …] designed or implemented but not actually proven → verify or defer; blocks landed

Five is the whole vocabulary. Don't add a sixth — inconsistently-applied tags are worse than none, because the greps still return results and get trusted while being incomplete. There is deliberately no [VERIFIED:]; the absence of [UNVERIFIED: is the signal.

set-status enforces the two tag columns above, and it gates on the status you are moving to, not on the one you are leaving. in-progress is deliberately ungated: starting work with questions open is the normal case, and blocked on <reason> exists for when it is not. landed gates on both UNVERIFIED and NEEDS CLARIFICATION, because it is the status that precedes deletion and idea -> landed skips planned entirely. That last clause is a fix, not a description: until 2026-09-02 the question gate hung off planned alone, so the one transition ending in a one-way door was the one nothing checked, and a plan carrying two open questions went straight to landed printing nothing but its status line. DEFERRED blocking deletion is still prose in the retirement procedure rather than a gate — see step 2 there.

Not bare TODO/FIXME — those collide with code comments, making rg TODO useless in a repo that also contains source.

Tag the claim, not the section. One tag per discrete, individually-extractable fact. A tag scoped to "everything below this heading" can't be migrated mechanically, which is the whole point.

A tag opens its own line, starting a paragraph or immediately following a list marker. Searches for it must be anchored the same way — a bare rg '\[DEFERRED:' matches every prose mention of a tag, so any document discussing this convention reports a false backlog. plans.py tags is the anchoring, applied across every directory the repo's route reads:

python3 <path> tags --tag DEFERRED               # the whole backlog, no file opened
python3 <path> tags --file <file>.md             # one plan, all five tags

Tag at status transitions, not while drafting — those are the moments someone is already reading closely. Retrofit an existing corpus in one pass rather than lazily; half-tagged is the failure mode above.

Promoting a plan

Promote in place, in the same file — never split into a second file for the same topic. Resolve every NEEDS CLARIFICATION first, then python3 <path> set-status <file> planned — it runs that gate and refuses while any remain, so a refusal is the answer, not an obstacle to route around with --force. Rewrite the body as ## Context → ## Design (numbered subsections, one per file/component touched, rationale inline) → ## Files touched → ## Verification.

As work proceeds, bump status again with the same command (it also stamps updated) — never by editing the frontmatter, per "status: and updated: are set-status' output" above, which is where the measurement lives. The sections don't change:

  • in-progress — actively being built.
  • blocked on <reason> — stalled on something external, with the reason in the status line itself, e.g. blocked on the upstream API adding a /search endpoint.
  • landed — implemented and verified. Transient; see "Retiring a plan".
  • abandoned — killed before landing. Also transient.
  • superseded by plans/<file>.md — a landed plan whose decision was later reversed by another.

Optional depends_on: [<repo-name>, ...] frontmatter names sibling repos this plan can't fully land without. Omit it for the ordinary single-repo case.

Plans that arrive from another repo

new --for writes four more fields and an ## Evidence section — the inbound mirror of depends_on, for the case where the plan is read in one repo and the thing that produced it happened in another:

source_repo: <the repo you were in> # filled in for you, from where the session was
source_session: 8f3c….jsonl # the harness's transcript for that session
source_moment: 2026-08-22T16:50:15Z # plus a distinctive quoted phrase in the body
source_plan: plans/2026-08-22-why-this.md # the filing repo's plan that owns this decision

The point is that a triage session can re-read the original turns instead of trusting a summary. Record a timestamp and a quoted phrase — either alone can miss in a multi-megabyte transcript — and note that a cited transcript only survives while the harness keeps it (30 days by default), so a capture worth acting on is worth acting on soon.

source_plan answers a different question from the other three: they point at the evidence, it points at whoever still owns the decision. Fill it in when the plan proposes something, and leave it blank when the plan reports a fact — a blank is a real answer and means there is nobody to check with. A filed plan is a snapshot, and the filing repo can change its mind after it has been absorbed. Confirmed 2026-09-04: a repo filed a placement decision on its own reasoning, the user overturned it there the same day, and the absorbing session implemented the superseded version faithfully, wrote the beaten argument into a shipped docstring and a contributing/ page, retired the plan and pushed. Nobody made a mistake — the filing session recorded the correction where it belonged and could not write into the other repo, and the absorbing session implemented exactly what it was handed. What was missing was a named place to look.

So: when a plan you are absorbing proposes rather than reports, read its source_plan before implementing. absorb prints these under decision owned elsewhere so the prompt arrives at the moment of use. A named plan that no longer exists is not a dead end — the filing repo retires plans by deleting them, and plans.py archive --file <name> reads one back out of its own retirement commit.

[DECISION: one field and one prompt, deliberately, with no watcher and no staleness scan. Settled 2026-09-09. The corpus of filed plans is small and this failure needed a same-day reversal by the user of a decision the filing session had itself proposed — rare, and it cost about an hour to recover, so a mechanism firing on every filed plan would be a poor trade. What was missing was never that looking is expensive, only that nobody knew there was somewhere to look. It fails safe in both directions: an absent field, an unfilled one and a retired target all read as "no further information", which is exactly the state before this existed.]

A plan carrying source_repo is not done until its ## Verification names the original repro, checked in the repo where it happened and after the fix is installed there. A fix verified only where it was written has not been tested against the case that produced it.

[PITFALL: the template asks because judgement does not. Filling these in is the whole value and it is exactly what gets skipped. Measured twice: 2026-08-23, a session captured this kind of friction unprompted and correctly, then paraphrased the incident instead of citing it — by an agent that had every reason to do better. 2026-09-01, the session reading the plan that describes that failure filed a cross-repo plan and paraphrased it too. Two for two, which is why the fields are emitted as blanks in the file rather than described in a skill someone might not open.]

Where retired content goes

Three destinations, named by role rather than by path, because every repo lays them out differently — and a repo may have no separate directory for the middle two at all:

role what belongs there
the code itself changes needing no explanation beyond the code and its comments
usage docs anything about how a person or agent uses the result
design rationale settled decisions, rejected alternatives, confirmed pitfalls

Map these onto whatever the repo has — a docs/ tree, a contributing/ tree, a package's own references/, a section of AGENTS.md, a code comment. The requirement is somewhere durable and findable, never a specific directory name. If a repo has no obvious home for design rationale, picking one is part of the first retirement, not a reason to skip it.

Retiring a plan

On reaching landed, abandoned, or an old superseded by ...: plans/ is a working set that empties out — but nothing genuinely costly to work out gets silently dropped. Deleting the file takes it off the working set, not out of the repository: the drafting commits, its final state and the ## Migrated to commit all stay reachable through git, and python3 <path> archive is how they are read back — see "Getting a retired plan back" below. That is what makes the deletion cheap, and it is why a plan is only ever kept in version control.

The session that lands a plan retires it, in the same session, once the change is pushed. Not after a wait, and not left for whoever comes next: that session has already read the code and the destination doc, which are the two expensive halves of a retirement, and a session three days later re-derives both from nothing. The gate is pushed, not landed — retirement deletes the file, so retiring an unpushed change deletes the reason for something that is not in the product (see the PITFALL at step 3 below). Everything absorb's prompt does is therefore an exception handler for plans that slipped past this rule, rather than the normal path.

Settled 2026-09-06 against a measured backlog: sixteen terminal plans had accumulated in agent-skills, fifteen of them landed on one day, and draining them took four batches in one session. What that pass established, and what makes the same-day rule worth stating as a rule:

  • A plan whose rules already shipped still owes its rejected alternatives. Almost every conclusion had already become a sentence in a SKILL.md, often better worded than the plan. What had no home was uniformly the option not taken and the measurement that killed it. So retirement is mostly a search for what was decided against, and the landing session is the one that still remembers.
  • Batch by destination, not by age, when a backlog does form: the expensive half is establishing what the destination file already covers, and one read serves four to six plans. Confirm the destination by reading rather than by what the plan talks about — a plan discusses the tool it found the problem in while concluding about the tool that has to change.
  • The most valuable rationale section is usually the one that existed in no single plan. Four plans retired together turned out to be one finding wearing four faces, which is only visible when they are read as a set.

A repo that keeps its own plans retires them in its own history — absorb first, then retire. set-status refuses a terminal status on a plan still sitting in the store mirror of a repo-routed repo, and names absorb. The reason is that retirement deletes the file and archive reads it back out of the deletion commit: retiring from the store would put the drafting, the landing and the deletion in the store's history while the repo's history has nothing, so archive run inside that repo would find the plan missing. One plan, one history. Non-terminal statuses are unaffected — nothing has been deleted, so nothing is split.

A store-held plan retires exactly like a repo-held one, and is deleted the same way. This is about a repo whose plans live in the store permanently, not one whose plan is merely in transit. The usage docs and design rationale still go into the repo the plan is about — updating a repo's own docs is an ordinary contribution, available even where adding a plans/ directory is not, and it would be owed just the same if the planning had happened in a tracker instead. What is left after that migration is reasoning that belongs to nobody but you, and it stays in the store's git history, which is why the store is a git repository at all.

Triage the file's content by lifecycle first. Split by what each passage is, never by how long the file is: a long file that is all one lifecycle stays one file, while a short one mixing several gets split.

kind example destination
settled decision why tool X beat tool Y, with the evidence design rationale
pitfall a trap confirmed by hitting it design rationale
code contract signatures, flags, behavior already in code/tests/README — drop
verification log "ran it, it worked", dry-run transcripts drop, except the unverified residue
live open work anything still wanted but not done an open plan — see step 2

Code contracts and verification logs are usually the bulk of the deletable volume.

  1. Default: preserve. Assume debugging, investigation and rejected-alternative reasoning has future value unless it is already written down elsewhere. Often it is — check the existing docs before assuming new rationale content is needed.

    • In a repo family, check the sibling repo that owns the concern, not just this one. A plan that designed something later extracted elsewhere describes a decision that repo now owns and keeps current; migrating it here ships a second, diverging copy that reads as authoritative. Point at the owning repo instead, and migrate only what it genuinely doesn't cover.
  2. A plan carrying live unfinished work is not deletable. Run the deletion gate and move everything it finds into a plan that stays, before going further:

    python3 <path> tags --file <file>.md --tag DEFERRED
    python3 <path> tags --file <file>.md --tag UNVERIFIED
    

    Prefer appending to an existing open plan that already owns the concern over spawning a new file. On an untagged legacy plan, grep prose instead (deferred|not yet|follow-up|TODO|known limitation) and read what it finds.

  3. Find inbound references before starting, not after — python3 <path> refs <file>.md, which also warns if the repo has unpushed commits, and names the attachments the plan holds. The count decides whether this is one commit or several. The committed attachments go with the plan; the local ones are the one thing here that deletion cannot undo, so they are the user's call, not a step in the procedure.

    [PITFALL: landed does not mean published, and retirement deletes the file. set-status gates landed on open tags and on nothing about whether the work reached the remote — so a plan can be landed and retired while the change it explains sits in a local commit. The change is then not in the product and the reason for it has been deleted. Push first, or keep the plan until you have. This is reported at retirement rather than gated at landed on purpose: a gate would be unpassable for a repo with no remote — the sensitive store is deliberately one, permanently — so it would only teach people to force past it, and a repo with no upstream is therefore not warned about at all.] It searches the whole repo (code comments and docstrings cite plan paths too) plus the store, on the bare filename rather than the full plans/ path, since short-form references are the easy miss, and including files you have not committed yet — the successor plan written during a retirement is untracked at the moment refs runs, which is the normal shape of a retirement rather than an edge case. Confirmed 2026-09-02: before that, refs reported zero while two files written the same hour named the plan.

    Then grep for section-shaped citations, which refs cannot see — rg -n '§' over plans/ and whatever docs directories the repo has. A sentence citing "that plan's §9 decision" names no filename, so nothing in the deletion gate reaches it, and a link checker does not either: a bare §11 is not a link. Confirmed 2026-08-30 retiring two plans: refs found 9 real hits across seven files and missed two more, both written by earlier sessions that had the plan open and cited its sections the way a reader in context would. Both dangled at deletion, and both were found only because that session ran the grep on a hunch. A plan organised into numbered sections is precisely the one whose citations will be section-shaped.

    Expect most of the output to be noise, and expect the ratio to get worse as a repo grows. Measured twice: 17 lines of which 2 were real on one repo, 2026-08-30; 45 lines of which 0 were real on a second repo, 2026-09-02, retiring six plans across two runs. The difference is not luck — on the second repo 24 of the 45 were one reference document's own internal §N numbering, which is a permanent noise floor that grows with the corpus and never contains a finding. So read the output, but read it expecting nothing; the grep earns its place because a real hit is silent and unrecoverable, not because it is often right. That is also why this stays a documented grep rather than a refs flag — at 0 real hits in 45, a flag would be reporting its own noise, and refs answers "who links to this file", which is well defined and is what the deletion gate needs.

  4. Add a ## Migrated to section naming each destination, and name what you deliberately did not migrate and why. Commit this addition on its own, before deleting the file — add-and-delete in one commit means the section is never recorded in history at all, which defeats the point of writing it.

    • Organize the rationale home by the question a reader arrives with, not one file per retired plan — that just reproduces each plan's lifecycle mixing under a new name. Expect the most valuable file to be one that existed in no single plan.
    • Before dropping anything as "already in the code", check that it is. Verify what you migrate too: prose written months ago about a module drifts, and a plan is not evidence about current behavior.
  5. Fix the references from step 3, then delete the file — only once step 4 is genuinely covered. If there is any doubt whether something worth keeping was captured, ask before deleting; it is a one-way door once the commit lands.

    • commit takes the deletion too, and is still the right way to make it: the store's index is shared whether the change is an addition or a removal.
      python3 <path> commit <the plan that is now gone> -m "retire <topic>"
      
      Pass the path, not a bare filename — nothing can search for a file that no longer exists. It works whether the deletion is staged (git rm) or the file was simply removed, and git rm also prunes the directory it just emptied, which is ordinary for a store mirror holding one last plan. Retiring several at once is one call and one message, same as an absorption.
    • Don't blindly swap the old path for the new one at every hit. A reference to a specific quoted section title needs that title updated to match where the content actually landed — a valid path aimed at a renamed heading still dangles. Some cited content is already duplicated at a third location; point at that copy rather than migrating a second one. And some references are better rewritten than repointed: "X landed in <plan>" just becomes "X landed."
    • The finishing grep should return no live pointers, which is not the same as zero hits. Provenance legitimately survives ("extracted from the now-retired plans/X.md") and should — but must say retired, so a reader knows not to go looking.
  6. Run the repo's gate again before committing the reference fixes. Editing many comments/docstrings in one pass is exactly what quietly trips a line-length rule.

Getting a retired plan back

Deleting a plan is only cheap because the file is still in the repository, and that is only true in practice if getting one back is a single command rather than an archaeology session:

python3 <path> archive                      # every plan deleted from this repo's plans/, newest first
python3 <path> archive --search "<phrase>"  # only the ones whose content ever contained it
python3 <path> archive --show <file>        # print one back, as it stood the moment before deletion
python3 <path> archive --file <file>        # one plan's whole lifecycle: drafting, landing, retirement
python3 <path> archive --all                # every repo on the machine, plus both stores' histories

Each row carries the plan's final status and its ## Migrated to destinations, which are usually the real answer — the content is in a doc somewhere, and only the reasoning that had no home is in the deleted file itself. Nothing is written and no file is restored: a retired plan comes back on stdout, and if the work is live again it earns a new plan file rather than a resurrected one.

Three things worth knowing before trusting a result:

  • --search matches across line breaks, so a phrase the formatter has since reflowed is still found. It also searches every version of every retired plan, not just the final one — so a passage cut two commits before the deletion still matches, and --show will not contain it. Use --file to find the commit that did, and read that one with git show <sha>:<path>.
  • A plan that moved between the repo and the store is not retired, though its old location's history says it was deleted. Those rows say still live and name where it is; go read that file.
  • A store with no git repository archives nothing. archive says so in its header when it finds one, per tier. Fix it with python3 <path> install before retiring anything held there. Both tiers keep full history precisely so this stays true for client plans, which are the ones with no other copy.

Migrating a legacy single plan file

A repo predating this convention often has one big PLAN.md/DESIGN.md/NOTES.md mixing unrelated threads at different lifecycle stages. Don't retire it as a unit — split by thread first, then apply the lifecycle above to each piece:

  1. Sort its sections into threads: implemented and verified (→ landed), genuinely still undecided or stalled (→ its own new plan file, status: idea or blocked on <reason>), and simply inaccurate now (a design later replaced — drop it, it has no destination). One legacy file routinely becomes zero, one, or several plans, not one.
  2. Run the landed threads through "Retiring a plan" immediately, in the same pass — no reason to copy them into plans/ only to retire them a moment later.
  3. Give each still-open thread a real plan file with correct frontmatter, reformatted into the standard sections — not a leftover fragment of the old file's prose.
  4. Only once every thread has a home does the legacy file get its own ## Migrated to section (naming every destination) and go through the normal commit-then-delete sequence.

Don't stash future work in prose docs

Applies to any narrative doc — README.md and docs pages included, not only AGENTS.md. Each should describe the repo as it is right now. A known bug, an unfinished feature or an open risk belongs in its own plan file, linked from the doc if it is worth a pointer, never spelled out there: prose future-work rots into a permanently-true-sounding sentence, or worse, an already-fixed problem still calling itself "not yet fixed".

AGENTS.md (or any equivalent instructions file) gets the strictest version: instructions for developing and deploying the repo only — never planning, ideation, or a status report. Three drifts worth auditing for:

  • Dated status narrative. "Status: implemented and exercised live 2026-08-14" is a changelog entry, not an instruction — true today, stale tomorrow. Trim to an undated statement of what is architecturally true now, and drop the "as of <date>, confirmed working, tests pass" framing entirely; that belongs in a commit message.
  • Stale implementation claims. "These functions are stubs", "not yet confirmed" — silently rots once the work lands. Don't prose-review these: grep the code for the thing described (e.g. rg NotImplementedError before trusting a docstring that claims it) before deciding whether a passage is accurate, superseded, or safe to cut.
  • Speculative asides. "This might be cheaper a different way once X is understood" is a musing, not an instruction. Either it is a real open question deserving its own plan file, or it is colour already captured in the rationale home and can just be dropped.