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 underprojects_root(to derive the private termsscangates 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_IDto anchor the cross-repo guard. - Runs:
git— read commands everywhere;git commitonly on the store, throughcommit. 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,describeanduninstallonly. Plan files in the session repo'splans/and in both stores —new,set-status,move,absorb --apply,graduate, and the retirement you perform by hand. The store directories and their READMEs, created0700. Throughattach: a copy of each file you name, either in a directory beside the plan or in the store's_attachments/, the plan's own## Attachmentsrows, and one line in the store's.git/info/exclude. Commits to the store throughcommit; never a commit in the session repo, and never a file in any other repo's working tree —newrefuses and names--for.archive,list,tags,refs,doctor,scanandwherewrite 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:
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.- Put each decision to the user with
AskUserQuestion, using thesuggestline as the recommended option and thecostline as the description. Do not skip to the defaults: thedefault,own_accounts,public_rootsandshareable_rootsanswers decide whether plans land in repos the user does not own, whetherscanwill catch a client's name, and which roots may reach a remote at all.own_accountsis 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. - Record each answer with
config set(above). Never edit the TOML by hand. 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.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_rootmust not be a git repository. This one is fatal and refuses rather than reporting: with a.gitthere the walk returns a single repo named., every real repo becomes invisible, andscanderives 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
whererefuses 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
doctornames 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.gitfile where a checkout has a directory — and stays a repo, because only a worktree's namesworktreesin it. - A directory holding no repos is simply ignored —
doctorcounts them and--strictlists them. - A repo cloned straight into
projects_rootis 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 todefault— whichdoctorreports throughinert_root_rulesif you write one anyway. A git repository is never a candidate root, so neitherdoctornorinstall --explainever 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.defaultanswers 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
toolsorsettingsmatches ordinary English. Put that one name in the config's[private] ignorelist — never widenpublic_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 stagedbefore the commit. Reference the thing by where it lives — the commits and thescan --mode historycommand that print it — not by quoting it. - A hit in pushed history.
--mode historyscans 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 treeenumerates withgit 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--pathat it.--mode stagedand--mode historyread 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
cdinto 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 straycdsurvivable, 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
archivecannot 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 tosection, 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.
absorbspeaks for the aged backlog at the top of a session.session-harvestalready reports plans this session made terminal, under "decisions waiting" — and it has to, becauseabsorbruns before this session has landed anything.listkeeps 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:andupdated:areset-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.
-
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.
-
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 UNVERIFIEDPrefer 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. -
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:
landeddoes not mean published, and retirement deletes the file.set-statusgateslandedon 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 atlandedon 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 fullplans/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 momentrefsruns, which is the normal shape of a retirement rather than an edge case. Confirmed 2026-09-02: before that,refsreported zero while two files written the same hour named the plan.Then grep for section-shaped citations, which
refscannot see —rg -n '§'overplans/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§11is not a link. Confirmed 2026-08-30 retiring two plans:refsfound 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
§Nnumbering, 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 arefsflag — at 0 real hits in 45, a flag would be reporting its own noise, andrefsanswers "who links to this file", which is well defined and is what the deletion gate needs. -
Add a
## Migrated tosection 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.
-
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.
committakes 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.
Pass the path, not a bare filename — nothing can search for a file that no longer exists. It works whether the deletion is staged (python3 <path> commit <the plan that is now gone> -m "retire <topic>"git rm) or the file was simply removed, andgit rmalso 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.
-
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:
--searchmatches 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--showwill not contain it. Use--fileto find the commit that did, and read that one withgit 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 liveand name where it is; go read that file. - A store with no git repository archives nothing.
archivesays so in its header when it finds one, per tier. Fix it withpython3 <path> installbefore 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:
- Sort its sections into threads: implemented and verified (→
landed), genuinely still undecided or stalled (→ its own new plan file,status: ideaorblocked 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. - Run the
landedthreads through "Retiring a plan" immediately, in the same pass — no reason to copy them intoplans/only to retire them a moment later. - 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.
- Only once every thread has a home does the legacy file get its own
## Migrated tosection (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 NotImplementedErrorbefore 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.