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 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. 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 it applies |
| 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> commit <file> -m "<msg>" # commit one plan 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
# 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, treated as sensitive throughout |
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, where and
new --for stop naming a tier, and install asks one fewer question.
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. The single store is the guarded one: pushing an employer's internal work to a personal remote does not become acceptable because the machine holds only one organisation's work. A sanctioned destination — an internal host, an external drive — is fine; a personal one is not.
[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 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
[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, both stores with their git state and which one
has a remote, 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, a remote on the sensitive tier, 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 sensitive tier's no-remote rule is the design, not an oversight, and doctor reports a
remote there as a problem: local history is the benefit, and one personal remote accumulating
several clients' internal architecture is the outcome to avoid. Adding one is a per-root decision
against that employer's actual policy, never a convenience. Until such a decision is made, treat
that tier 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.
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 that one file, 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
…(truncated)