# Plan Docs

> Use when capturing an idea, drafting a design, or tracking work-in-progress in a repo's plans/ directory — creating or updating a plans/YYYY-MM-DD-topic.md file (including a bug, idea or risk turned up incidentally), asking what plans exist or what to work on next, here or across every repo, choosing or advancing a status, retiring a landed/abandoned plan once its content has a permanent home elsewhere, migrating a repo's legacy monolithic plan file (PLAN.md, DESIGN.md, ...) onto this convention, or auditing AGENTS.md/README.md/docs for planning/status/future-work content that has drifted in and belongs in plans/ instead. Also owns where a plan file may live and what may be written in it: a work, client or employer repo that cannot take a plans/ directory keeps its plans in the store outside every working tree ($PLANS_HOME), routed per repo by config; an idea with no repo yet is filed unscoped and graduated later; and no plan committed to a repo you publish may name a client, employer or internal project.

- Skill: `theodoread/plan-docs` (Agent Skill, multi-file: 4 files)
- Install (CLI): `npx skillmds@latest add theodoread/plan-docs`
- Raw SKILL.md: https://api.skillmd.com/api/skills/theodoread/plan-docs/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: theodoread (https://skillmd.com/u/theodoread)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/theodoread/plan-docs

---


# 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`](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`](references/prior-art-task-trackers.md).

## What this skill reads, runs and writes

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

## Run the script, don't re-derive it

[`scripts/plans.py`](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:

```shell
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`                               |

<details>
<summary>The rest, by the moment you need them</summary>

```shell
# 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
```

</details>

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:

```shell
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:

```shell
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.

```shell
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:**

```shell
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.

```toml
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:

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

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

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

### What the projects tree has to look like

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

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

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

### Is this machine set up, and what is in it

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

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

## Something that belongs to a repo you are not in

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

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

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

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

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

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

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

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

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

Two habits that hold at every tier:

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

```shell
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.

```shell
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)
