planrails 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,78 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.0 — 2026-09-13
4
+
5
+ A review of 0.3.0 against a real multi-session plan, the Claude Code docs, and
6
+ the planning systems that came before it found where the promise leaked, and this
7
+ release closes the leaks. Two of them are behaviour changes in the checker; the
8
+ rest is method. Everything stays a prompt plus one script, and `init` still writes
9
+ nothing but its two files.
10
+
11
+ - **Evidence must record the proof's exit code, and it must be 0.** A command
12
+ proof's evidence cell now needs `exit 0` (or `exit code 0`, `exited 0`); a bare
13
+ word such as "done" or "✅", or a pasted `exit 1`, fails the build. This is the
14
+ format `PLANNER.md` always prescribed; the checker now checks it, and every
15
+ `exit N` in the cell counts. A proof cell must be exactly one backticked command
16
+ or the word `owner`, not prose with a span in it; an owner-closed task records
17
+ the owner's words with the date. **This is the one change that can fail a plan
18
+ that passed before** — and only a plan the method already called not done.
19
+ Re-run the proof and paste its exit code.
20
+ - **The checker fails closed on a row it cannot read.** The cell splitter follows
21
+ the CommonMark rule for backtick runs, so a stray backtick or three backticks in
22
+ prose no longer shift the columns; a row whose cell count still differs from the
23
+ header's is a problem instead of a silent pass. A task table inside a code fence
24
+ is ignored, a table right after the task table with no heading between is no
25
+ longer read as tasks, and `**id**` in a header is read.
26
+ - **Two integrity checks keep the reload honest.** When the project has a
27
+ `CLAUDE.md`, every active plan must be reloaded by
28
+ `@.project-management/plans/<id>/PLAN.md` on its own line, outside backticks and
29
+ fences, and no such line may point at a plan that does not exist. An active
30
+ plan's `RESUME` line must name a task that is still open, so a stale NOW is
31
+ caught. Both are skipped for a retired plan, so retiring a plan now means
32
+ setting `status: done` and then backticking its line; a plan with no status
33
+ line counts as active, the safe direction; the reload check is skipped when
34
+ there is no `CLAUDE.md`.
35
+ - **`--verify` has a timeout** (10 minutes per proof) and reports the last line a
36
+ failing proof printed, instead of hanging or saying nothing.
37
+ - **The checker can no longer silently exit 0.** Its entry guard compared paths
38
+ textually, so run through a symlink, or through `/tmp` on macOS, it printed
39
+ nothing and passed. It now compares real paths, and a test runs it as a command,
40
+ and through a symlink, on a plan with a known problem. The same fix went into
41
+ the CLI, which npm installs as a symlink.
42
+ - **The plan carries its own loop.** The PLAN.md template's HTML comments became a
43
+ "How to work this plan" block: set doing, run the proof now with the time from
44
+ `date`, paste `exit N`, done only on 0, update NOW, append a LOG entry, record a
45
+ learning, say where a blocked reason goes, run `git status --short` after a
46
+ compaction, and how to brief a sub-agent. A fresh session with only `CLAUDE.md`
47
+ and the plan completed a two-task plan by the loop and retired it correctly.
48
+ - **The method (PLANNER.md) gained:** the project-root `CLAUDE.md` named as the
49
+ always-loaded file; "check the plan before the first task"; a task per sitting,
50
+ permanent task ids and `dropped` rows; phase-end end-to-end tasks; one active
51
+ plan per repo; a ~2,000-word budget that trims prose before Learnings; "delegate
52
+ a self-contained task" with a five-line brief, one writer per file, output to a
53
+ named file, and the main session running the proof; the engineering standards
54
+ (fix the cause, a red test starts an investigation, never weaken an assertion);
55
+ a fresh-context review at close; retiring a plan by backticking its line, never
56
+ by moving it under a "Finished" heading, which still imports; and the evidence
57
+ behind each rule. A test pins the template inside `PLANNER.md` to the checker,
58
+ so the two cannot drift apart.
59
+ - **`init` updates the tool and never the plans.** Both copied files carry
60
+ `planrails X.Y.Z` near the top. A re-run replaces an older or unstamped copy
61
+ under `.project-management/planrails/` and says what it replaced, keeps a
62
+ same-version copy you edited unless `--force`, never writes into `plans/`, and
63
+ points out 0.2.x files left at the `.project-management/` root. `--root` and
64
+ `--dir` are accepted everywhere. The CLI runs correctly through npm's bin
65
+ symlink.
66
+ - The `/plan` skill reads the project's own copy of `PLANNER.md` and says to run
67
+ `init` if it is missing, so there is one copy per project, not three. The README
68
+ says plainly that automatic reload is Claude Code's; other agents open the plan
69
+ by hand. The worked example carries the new block and a `CLAUDE.md` with the
70
+ reload line, so it exercises all three rails, and its log agrees with its
71
+ learnings. The README, the CLI's next steps and `PLANNER.md` use one start
72
+ sentence, the one that makes the agent report ready and wait. This repo's own plan for this release lives in
73
+ `.project-management/plans/self-sufficient-plan/` and is gated by the checker it
74
+ ships, in CI. 70 tests.
75
+
3
76
  ## 0.3.0 — 2026-09-12
4
77
 
5
78
  Two changes, both asked for by a user planning a long, multi-session feature:
package/PLANNER.md CHANGED
@@ -1,3 +1,4 @@
1
+ <!-- planrails 0.4.0 -->
1
2
  # The Planner
2
3
 
3
4
  You are about to plan a piece of work with a person, then help execute it so the
@@ -7,9 +8,10 @@ This file is the whole method. You, the model, do the setup by following the
7
8
  steps below: make the folders, write the plan, wire the reload line, and — if the
8
9
  project runs Node — copy in one small checker.
9
10
 
10
- **How a person starts you.** They say something like *"Familiarise yourself with
11
- these instructions and tell me when you are ready to plan the next feature with
12
- me."* When they do, run **§1 Get ready**, give the eight-line report, and stop.
11
+ **How a person starts you.** They say something like *"Follow
12
+ `.project-management/planrails/PLANNER.md` and tell me when you are ready to plan
13
+ the next feature with me."* When they do, run **§1 Get ready**, give the
14
+ eight-line report, and stop.
13
15
  Do not start planning until they answer.
14
16
 
15
17
  ---
@@ -20,21 +22,21 @@ Two files per plan, under `.project-management/plans/<id>/`:
20
22
 
21
23
  - **PLAN.md** — the map and the tracker. What we are building, the tasks, and the
22
24
  proof each task is done. A fresh session reads this and knows the whole state.
23
- - **LOG.md** — append-only history. One dated line per landed piece of work.
25
+ - **LOG.md** — append-only history. One dated entry per landed piece of work.
24
26
 
25
27
  The plan is written for an **executor who never saw this prompt** — maybe a fresh
26
28
  session of you after the chat is gone. So everything the executor needs goes in
27
- the plan. If it is not in the plan, it does not exist.
29
+ the plan, including how to work it. If it is not in the plan, it does not exist.
28
30
 
29
31
  Three rules make the plan trustworthy. They are the whole point of this system:
30
32
 
31
- 1. **The reload line.** One line in the project's always-loaded file re-opens the
33
+ 1. **The reload line.** One line in the project-root `CLAUDE.md` re-opens the
32
34
  plan after every compaction, so the state is never lost.
33
35
  2. **Proof before work.** Every task names the command that will prove it done
34
36
  *before* the work starts. No command, no way to fake it later.
35
37
  3. **Evidence at close.** A task is done only when its proof was run and its exit
36
38
  code and last line are pasted into the plan. An empty evidence cell is not
37
- done, whatever the status column says.
39
+ done, whatever the status column says. Exit 0, or it is not done.
38
40
 
39
41
  The plan is also the project's memory. Besides the tasks, PLAN.md carries the
40
42
  **Rules** you must not break, the **Decisions** you made and why, and the
@@ -50,13 +52,14 @@ struggle. That is how you stop paying for the same mistake twice.
50
52
  Do this before you ask the person anything. Read the project; do not make them
51
53
  tell you what the repo already says.
52
54
 
53
- 1. **Read the always-loaded file** — `CLAUDE.md`, else `AGENTS.md`, else
54
- `README.md`. Find the stack, the package manager, the **check command** (the
55
- one run before every commit), and the **test command**.
55
+ 1. **Read the always-loaded file** — the project-root `CLAUDE.md`, else
56
+ `AGENTS.md`, else `README.md`. Find the stack, the package manager, the
57
+ **check command** (the one run before every commit), and the **test command**.
56
58
  2. **List `docs/`** and read the ones this feature touches. Use sub-agents for
57
59
  long files so your own context stays clear. Record each relevant doc's path
58
60
  and one line of what it holds.
59
- 3. **Read `.project-management/`** existing plans and prose. Note the active one.
61
+ 3. **Read `.project-management/`.** Read the active plan in full; list the
62
+ finished ones by id only.
60
63
  4. **Read the last ~20 commits** (`git log --oneline -20`) for how the code moves.
61
64
 
62
65
  **A sub-agent's finding is a lead, not a fact.** If it names a file and line, you
@@ -99,29 +102,46 @@ Create `.project-management/plans/<id>/PLAN.md` and `LOG.md` from the two
99
102
  templates at the end of this file (§ Template — PLAN.md, § Template — LOG.md).
100
103
  Pick a short kebab-case `<id>` (`weekly-digest`). Then:
101
104
 
105
+ - **Write for a senior engineer.** Decisions and context, not obvious steps.
102
106
  - **Name the proof for every task before any work.** The proof is the command
103
107
  that shows the task is done: a test, a build, a check. A task that no command
104
108
  can prove is proven by the person's word — write `owner` in the proof column
105
- and record their words and the date in evidence when they give it.
109
+ and record their words and the date in evidence when they give it (the checker
110
+ wants the date there). The proof cell is exactly one `command`, or `owner`.
111
+ - **A task is one sitting's work with one proof.** Longer than that is two tasks.
112
+ Task ids are permanent: never renumber, and a task you will not do keeps its
113
+ row with status `dropped`.
114
+ - **A plan with phases ends each phase with a task** whose proof is the Done-when
115
+ checks, run end to end, the way a user would.
106
116
  - **Use repo-relative paths** (`lib/digest/query.ts`), never absolute ones. They
107
117
  are clickable and they survive a move to another machine.
108
- - **Keep PLAN.md under ~1,500 words.** History goes in LOG.md, not here.
109
- - **Add the reload line.** In the always-loaded file, under a short "Active plans"
110
- spot, add:
118
+ - **Keep PLAN.md under ~2,000 words.** Trim prose before Learnings or Decisions.
119
+ History goes in LOG.md, not here.
120
+ - **Add the reload line.** In the project-root `CLAUDE.md`, under a short
121
+ "Active plans" spot, add:
111
122
  ```
112
123
  @.project-management/plans/<id>/PLAN.md
113
124
  ```
114
125
  Put it on its own line, outside any code block — an `@` import wrapped in
115
126
  backticks does not load. Claude Code re-reads that file, and everything it
116
127
  imports, at every session start and after every compaction, so the plan comes
117
- back on its own. (For a tool that does not do `@`-imports, put the plan's path
118
- in `AGENTS.md` and open it by hand at the start of each session.)
128
+ back on its own. One active plan per repo at a time; to pause another, set its
129
+ header to `status: paused` and wrap its line in backticks. (For a tool that
130
+ does not do `@`-imports, put the plan's path in `AGENTS.md` and open it by hand
131
+ at the start of each session.)
119
132
  - **Wire the checker, if the project runs Node and has a check command.**
120
133
  `npx planrails init` already put it at `.project-management/planrails/check-plans.mjs`;
121
- if you did not run init, copy this repo's `tools/check-plans.mjs` there. Add
134
+ if you did not run init, copy `tools/check-plans.mjs` from
135
+ https://github.com/vivmagarwal/planrails there. Add
122
136
  `node .project-management/planrails/check-plans.mjs` to the check command. Now the
123
- build fails if a task is marked done with no evidence. If the project is not Node,
137
+ build fails if a task is marked done with no evidence, if an active plan has no
138
+ reload line, or if NOW points at a finished task. If the project is not Node,
124
139
  skip this; the plan still works, and you enforce the gate yourself.
140
+ - **Check the plan before the first task.** You, or a fresh sub-agent with no
141
+ chat context: open every path the plan names, start every proof command,
142
+ confirm the reload line loads and the checker passes, and log what you changed.
143
+ A plan can name a file that does not exist, or a proof that proves nothing;
144
+ ten minutes here saves an hour later.
125
145
 
126
146
  ---
127
147
 
@@ -130,18 +150,21 @@ Pick a short kebab-case `<id>` (`weekly-digest`). Then:
130
150
  **At the start of every session, read the plan back first.** The reload line has
131
151
  already loaded PLAN.md. Read **NOW**, then the **Rules** and the **Learnings**,
132
152
  before you touch anything. The Learnings are mistakes a past session already paid
133
- for — read them and you skip the struggle instead of repeating it.
153
+ for — read them and you skip the struggle instead of repeating it. After a
154
+ compaction, also run `git status --short`: it is the journal of in-flight work
155
+ that NOW may not mention yet.
134
156
 
135
157
  The loop for each task:
136
158
 
137
159
  1. **Set it doing.** Change the status cell to `doing`. Update **NOW**.
138
- 2. **Do the work.**
160
+ 2. **Do the work.** Fix the cause, not the symptom. The simplest change that
161
+ works, end to end.
139
162
  3. **Run the proof.** Right now, not from memory. Copy the exit code and the last
140
- line of output.
163
+ line of output. Take the time from `date`, never from memory.
141
164
  4. **Paste the evidence.** Into the task's evidence cell:
142
- `2026-09-12 14:20 · exit 0 · "6 passed"`.
165
+ `2026-09-12 14:20 · exit 0 · "6 passed"`. Exit 0, or the task is not done.
143
166
  5. **Set it done.** Only now. Update **NOW** to point at the next task.
144
- 6. **Append one line to LOG.md** — what landed, what is next, anything learned,
167
+ 6. **Append an entry to LOG.md** — what landed, what is next, anything learned,
145
168
  any decision made.
146
169
  7. **If the task fought back, record the learning.** An error, a wrong turn, an
147
170
  hour lost before you found the cause — add it to PLAN.md under **Learnings** as
@@ -149,8 +172,35 @@ The loop for each task:
149
172
  the rule, because Learnings reloads every session and the log does not.
150
173
 
151
174
  **Update NOW before you end any turn.** NOW is the first thing a fresh session
152
- reads. If it is stale, the next session repeats your work or starts in the wrong
153
- place.
175
+ reads, so write it for a stranger. On a long task, note the sub-step in NOW at
176
+ each checkpoint. If a task cannot proceed, set it `blocked`, put the reason in its
177
+ evidence cell, and say so in NOW. If NOW is stale, the next session repeats your
178
+ work or starts in the wrong place.
179
+
180
+ **A red test starts an investigation**, not an edit: is the product wrong, the
181
+ test stale, or the environment wrong? Decide which before changing anything, and
182
+ never weaken an assertion to get green.
183
+
184
+ **Edit the plan through the editor tool** or a quoted heredoc. An unquoted shell
185
+ string eats backticks and quotes, and takes your evidence with them.
186
+
187
+ **Delegate a self-contained task.** When a task's row, plus the Rules, Decisions,
188
+ Learnings and its Context lines, is enough to do it, hand it to a sub-agent with
189
+ exactly that brief and nothing else:
190
+
191
+ ```
192
+ Task <id> of <plan path>: <the row's task text>
193
+ Rules / Decisions / Learnings: <the plan's, verbatim> Context: <the lines this task touches>
194
+ Write your output to <file>. One writer per file; do not edit PLAN.md or LOG.md.
195
+ Proof: <the command>. Report: exit code, last line, files touched, anything learned.
196
+ ```
197
+
198
+ Validate one unit before you scale to many. Reconcile the report against the
199
+ files it wrote, not against its reply. Its result is a lead: run the proof
200
+ yourself before you paste evidence. Bulk reading or bulk processing always goes
201
+ to sub-agents that write to disk and report a few lines; the main session never
202
+ holds bulk output, because everything it holds rides along on every later call.
203
+ Keep in the main session only the tasks that need the whole picture.
154
204
 
155
205
  **Your judgement outranks the gate.** The gate is a proposal from a script that
156
206
  cannot see the screen. You can.
@@ -169,14 +219,21 @@ the task `blocked` with the reason, and stop. Do not hand-fix state to look done
169
219
 
170
220
  1. **Re-run every proof.** A plan closes on what the checks say now, not on their
171
221
  last recorded run. Paste fresh evidence.
172
- 2. **Update the docs** the work changed in the same step, per the project's
222
+ 2. **Fresh-context review.** A sub-agent, or a new session, with the plan and
223
+ nothing from the chat, checks the work against **Done when** and reports what
224
+ it cannot see passing. Fix what it finds. Two such reviews once found 26
225
+ defects the self-tests had passed.
226
+ 3. **Update the docs** the work changed — in the same step, per the project's
173
227
  documentation guide if it has one.
174
- 3. **Retire the plan.** Move its reload line out of "Active plans" into a
175
- "Finished" list (or delete the line). The plan files stay on disk; they are the
176
- record.
177
- 4. **Graduate any lasting learning.** A learning that is true beyond this feature
228
+ 4. **Retire the plan.** Set its header to `status: done`, then wrap its reload
229
+ line in backticks, or delete it. Do not move a bare `@` line under a
230
+ "Finished" heading: it still imports, and every retired plan would reload
231
+ forever. The status comes first: the checker holds an `active` plan to its
232
+ reload line. The plan files stay on disk; they are the record.
233
+ 5. **Graduate any lasting learning.** A learning that is true beyond this feature
178
234
  moves to the always-loaded file (`CLAUDE.md` / `AGENTS.md`), so it outlives the
179
- plan you are retiring. One that was only about this work retires with it.
235
+ plan you are retiring. A learning that a check could enforce becomes a test.
236
+ One that was only about this work retires with it.
180
237
 
181
238
  ---
182
239
 
@@ -185,19 +242,29 @@ the task `blocked` with the reason, and stop. Do not hand-fix state to look done
185
242
  Each line here was paid for by a real failure in earlier planning systems:
186
243
 
187
244
  - **The reload line** replaces a whole SessionStart hook. Re-reading the
188
- always-loaded file after compaction is something the tool already does.
245
+ always-loaded file after compaction is something the tool already does. Hooks
246
+ that tried to do more misfired: a crashing pre-tool hook blocks the very call,
247
+ a read guard was wrong twice about sub-agents, a compaction journal came out as
248
+ command stubs.
189
249
  - **Proof before work** exists because a checkbox lies. One project marked a phase
190
250
  "done" three times while it was not; nothing in a status column could catch it.
191
251
  A named command that must be run and pasted can.
192
252
  - **Evidence at close** is the one machine-checkable rail worth keeping. The
193
- checker enforces exactly this and nothing else.
253
+ checker enforces it exit 0, not a word — plus two integrity checks that keep
254
+ the reload honest: the reload line exists, and NOW names a live task. Both
255
+ failures were silent in real plans.
256
+ - **The plan carries its own loop** because a session that never saw this file
257
+ gets only PLAN.md back. It knew where it was; it did not know how to work.
194
258
  - **Learnings live in the plan, not only the log.** The log is history a fresh
195
259
  session does not re-read; the plan is reloaded every session. A mistake written
196
260
  as a rule, where the next session will see it, is the only kind that stops being
197
261
  repeated. The same struggle coming back in a new chat is the exact failure this
198
262
  fixes.
263
+ - **History stays out of the plan.** One plan grew a 16,000-word progress section,
264
+ stamped two hours behind its own log. NOW is three lines; LOG.md is the history.
199
265
  - **Sub-agent findings are leads** because four spot-checked findings were each
200
266
  right in direction and wrong in number, and a wrong number becomes a wrong plan.
267
+ Delegation is method, not machinery: a brief of one unit and nothing else worked.
201
268
  - **Repo-relative paths** because absolute paths break on the next machine, and a
202
269
  path a reader cannot open is worse than none.
203
270
  - **Nothing the method needs is installed.** `npx planrails init` only copies two
@@ -216,7 +283,8 @@ Learnings are plain sections of the plan, not new machinery.)
216
283
 
217
284
  Create `.project-management/plans/<id>/PLAN.md` with this shape. Keep the column
218
285
  names exactly as shown. The checker reads `id`, `status`, `proof` and `evidence`
219
- by name, in any order; `task` is there for you to read.
286
+ by name, in any order; `task` is there for you to read. Keep the "How to work this
287
+ plan" block as it is: it is what a session that never saw this file will follow.
220
288
 
221
289
  ````markdown
222
290
  # <Feature> — plan
@@ -224,25 +292,31 @@ by name, in any order; `task` is there for you to read.
224
292
  status: active · opened <YYYY-MM-DD> · id: <kebab-id>
225
293
 
226
294
  ## NOW
227
- RESUME: <the one thing to do next, with the file e.g. "T2: render the digest (lib/digest/render.ts)">
295
+ RESUME: <T2 — the one thing to do next, with the file; written for a stranger>
228
296
  NEXT: <T3 · T4 · …>
229
- updated: <YYYY-MM-DD HH:MM>
297
+ updated: <YYYY-MM-DD HH:MM, from `date`>
298
+
299
+ ## How to work this plan
300
+ Read NOW, then Rules and Learnings; do not re-read Context. One task at a time:
301
+ 1. Set it `doing`; point NOW at it.
302
+ 2. Do the work: fix causes, not symptoms; the simplest change that works end to end.
303
+ 3. Run the proof now. Paste `YYYY-MM-DD HH:MM · exit N · "last line"` into evidence. Every stamp comes from `date` run at that moment, never typed from memory.
304
+ 4. `done` only if N is 0. Point NOW at the next task; append an entry to LOG.md: did, files, proof, next, learned.
305
+ 5. If it fought back, add a Learning: the trap, then the rule. A verified fact goes in Context, a choice in Decisions.
306
+
307
+ NOW is three lines for a stranger; on a long task note the sub-step, and update it before any turn ends. Blocked: say so in NOW, reason in the evidence cell. A task you will not do is `dropped`; its row stays. After a compaction, `git status --short` shows the in-flight work. Edit this file with the editor or a quoted heredoc; an unquoted shell string eats backticks. A self-contained task may go to a sub-agent briefed with its row, Rules, Decisions, Learnings and Context; it writes to a named file and reports a few lines, which are leads; you run the proof before pasting evidence. If the project runs Node, `node .project-management/planrails/check-plans.mjs` must pass. Full method: `.project-management/planrails/PLANNER.md` §4.
230
308
 
231
309
  ## Goal
232
- <4–5 sentences: what we are building and why. What is true when it ships.>
233
- <!-- If the architecture is non-trivial, add a mermaid diagram here. Skip it for a simple change. -->
310
+ <4–5 sentences: what we are building and why. What is true when it ships. A mermaid diagram only if the architecture is non-trivial.>
234
311
 
235
312
  ## Done when
236
- - <a command or check that must pass — e.g. `npm run check` exits 0>
313
+ - <a command that must exit 0 — e.g. `npm run check`>
237
314
  - <a real-world check — e.g. a digest email arrives for a test user with 5 posts>
238
315
 
239
316
  ## Must not change
240
317
  - <the rails, data, or public shape this work must not break>
241
318
 
242
319
  ## Tasks
243
- <!-- status: todo | doing | done | blocked. proof: a `command` in backticks, or `owner`
244
- for a task only the owner's word can close. evidence: pasted after the proof runs —
245
- required for a done task. -->
246
320
  | id | task | status | proof | evidence |
247
321
  |----|------|--------|-------|----------|
248
322
  | T1 | <what to do> (<path>) | todo | `<command that proves it>` | |
@@ -250,7 +324,6 @@ updated: <YYYY-MM-DD HH:MM>
250
324
  | T3 | update the docs this work changed | todo | owner | |
251
325
 
252
326
  ## Rules for this plan
253
- <!-- Short. These stay in the executor's context the whole time. Max ~15 lines. -->
254
327
  - <a rule that governs this area — e.g. "every email goes through lib/email, never a bare send">
255
328
 
256
329
  ## Decisions
@@ -259,15 +332,10 @@ updated: <YYYY-MM-DD HH:MM>
259
332
  | <YYYY-MM-DD> | <what was chosen> | <the reason and what it rules out> |
260
333
 
261
334
  ## Learnings
262
- <!-- Mistakes already paid for, so no later session repeats them. Each line: the
263
- trap, then the rule it taught, with the real case. Not a Rule (a constraint
264
- known up front) and not a Decision (a choice between options) — a learning is
265
- what a failure taught you. Add one the moment a task fights back. This section
266
- reloads with the plan every session; that is what makes the lesson stick. -->
267
335
  - <the trap you hit> → <the rule that avoids it> (<the real case, one line>)
268
336
 
269
337
  ## Context (read during planning — do not re-read)
270
- - <path> — <one line of what it holds>
338
+ - <path> — <one line of what it holds; mark a sub-agent's unverified finding as a lead>
271
339
  ````
272
340
 
273
341
  ## Template — LOG.md
package/README.md CHANGED
@@ -7,13 +7,17 @@ Long tasks lose their thread. A coding session compacts or ends, and the next on
7
7
  starts blind: it repeats work, or it trusts a status line that says "done" over
8
8
  work that is not. planrails fixes that with three plain rules and almost no code.
9
9
 
10
- 1. **The plan reloads itself.** One line in the file your agent always reads
11
- re-opens the plan after every compaction. The state is never lost.
10
+ 1. **The plan reloads itself.** One line in your project's root `CLAUDE.md`
11
+ re-opens the plan after every compaction, and the plan carries its own
12
+ operating loop, so a session that never saw the planner prompt still works it
13
+ correctly. (Claude Code reloads it for you; another agent opens it by hand.)
12
14
  2. **Every task names its proof before the work starts** — the command that will
13
15
  show it is done. No command, no way to fake it later.
14
- 3. **A task is done only when its proof was run and pasted in.** An empty evidence
15
- cell is not done, whatever the status says. A small, dependency-free checker
16
- enforces this in your build.
16
+ 3. **A task is done only when its proof was run and its exit code pasted in.** An
17
+ empty evidence cell, a bare word, or a recorded `exit 1` is not done, whatever
18
+ the status says. A small, dependency-free checker enforces this in your build,
19
+ and also checks that every active plan has its reload line and that NOW points
20
+ at a task that is still open.
17
21
 
18
22
  It works in any project — Node, Python, Go, a monorepo, Windows — because it adds
19
23
  two files and changes nothing else.
@@ -30,15 +34,17 @@ npx planrails init
30
34
 
31
35
  That copies two files into `.project-management/planrails/` and makes the
32
36
  `plans/` folder. It writes **nothing else** — no `package.json`, no `npm install`,
33
- no hooks, no edits to your `CLAUDE.md`. Run it again any time; it skips files that
34
- already exist. What lands:
37
+ no hooks, no edits to your `CLAUDE.md`. Run it again any time to update: it brings
38
+ the two planner files up to the package version and tells you what it replaced,
39
+ keeps a same-version copy you edited unless you pass `--force`, and never touches
40
+ your plans. What lands:
35
41
 
36
42
  ```
37
43
  .project-management/
38
44
  planrails/
39
45
  PLANNER.md the prompt your agent follows to plan and execute
40
46
  check-plans.mjs the checker (the one machine-enforced rail)
41
- plans/ your plans will live here, one folder each
47
+ plans/ your plans live here, one folder each (a .gitkeep holds the folder)
42
48
  ```
43
49
 
44
50
  Then, two steps:
@@ -71,8 +77,8 @@ The command is the same; the difference is what your agent sees.
71
77
  - **Existing project:** the agent first reads your `CLAUDE.md`/`README`, your
72
78
  `docs/`, and recent commits, and reports what it found before planning — so the
73
79
  plan fits how your code already works. `init` is safe to run in a project that
74
- already has files or its own `.project-management/`; it only adds, never
75
- overwrites (without `--force`).
80
+ already has files or its own `.project-management/`: it only writes its two
81
+ planner files, updating an older copy, and never touches a plan.
76
82
 
77
83
  ### Without npm, or a non-Node project
78
84
 
@@ -88,51 +94,76 @@ planner method does not depend on any language.
88
94
 
89
95
  ### As a `/plan` command in Claude Code
90
96
 
91
- Copy [`skill/`](skill) to `~/.claude/skills/plan/`, and put a copy of `PLANNER.md`
92
- beside it in the same folder. Then `/plan` starts the same flow in any project.
97
+ Copy [`skill/`](skill) to `~/.claude/skills/plan/`. Then `/plan` starts the same
98
+ flow in any project that has run `npx planrails init` the skill reads the
99
+ project's own copy of `PLANNER.md`, so every project follows the version it has.
93
100
 
94
101
  ## How a plan works
95
102
 
96
103
  Each plan is two files: `PLAN.md` (the map and tracker) and `LOG.md` (append-only
97
- history). `PLAN.md` also carries the plan's memory — the rules to keep, the
98
- decisions made, and the **learnings** (a mistake, written as the rule that avoids
99
- it). Because the reload line brings `PLAN.md` back at the start of every session, a
100
- lesson from one chat is read by the next one before it repeats the struggle. The
101
- top of `PLAN.md` is what a fresh session reads first:
104
+ history). `PLAN.md` carries a short "How to work this plan" block — the loop a
105
+ session follows even if it never saw the planner prompt and the plan's memory:
106
+ the rules to keep, the decisions made, and the **learnings** (a mistake, written
107
+ as the rule that avoids it). Because the reload line brings `PLAN.md` back at the
108
+ start of every session, a lesson from one chat is read by the next one before it
109
+ repeats the struggle. The top of `PLAN.md` is what a fresh session reads first. Abridged from
110
+ [`examples/weekly-digest/`](examples/weekly-digest), which also shows the
111
+ `CLAUDE.md` line that reloads it:
102
112
 
103
113
  ```
114
+ # Weekly digest email — plan
115
+
116
+ status: active · opened 2026-09-12 · id: weekly-digest
117
+
104
118
  ## NOW
105
- RESUME: T2 — render the digest through lib/email (lib/digest/render.ts)
106
- NEXT: T3 schedule · T4 docs
119
+ RESUME: T2 — render the digest through the email seam (lib/digest/render.ts)
120
+ NEXT: T3 schedule the Monday send · T4 docs
107
121
  updated: 2026-09-12 14:20
108
122
 
123
+ ## How to work this plan
124
+ (the loop a session follows: set doing, run the proof, paste exit N, done on 0, update NOW, log it)
125
+
109
126
  ## Tasks
110
127
  | id | task | status | proof | evidence |
111
128
  |----|------|--------|-------|----------|
112
- | T1 | the digest query (lib/digest/query.ts) | done | `npx vitest run tests/digest.test.ts` | 2026-09-12 14:05 · exit 0 · "6 passed" |
113
- | T2 | render through the email seam | doing | `npx vitest run tests/render.test.ts` | |
129
+ | T1 | the digest query (lib/digest/query.ts) | done | `npx vitest run tests/digest/query.test.ts` | 2026-09-12 14:05 · exit 0 · "6 passed" |
130
+ | T2 | render through the email seam | doing | `npx vitest run tests/digest/render.test.ts` | |
114
131
  ```
115
132
 
116
- The full method — how the agent gets ready, interviews you, writes the plan, and
117
- runs one task at a time is in [`PLANNER.md`](PLANNER.md). A complete worked plan
118
- is in [`examples/weekly-digest/`](examples/weekly-digest).
133
+ The full method — how the agent gets ready, interviews you, writes the plan,
134
+ checks it before the first task, runs one task at a time, briefs a sub-agent with
135
+ just a task's row and the plan's rules, and closes with a fresh-context review —
136
+ is in [`PLANNER.md`](PLANNER.md). A complete worked plan is in
137
+ [`examples/weekly-digest/`](examples/weekly-digest).
119
138
 
120
139
  ## The checker
121
140
 
122
141
  ```bash
123
- node .project-management/planrails/check-plans.mjs # done tasks must name a proof and carry evidence
124
- node .project-management/planrails/check-plans.mjs --verify # also re-run each done task's proof, expect exit 0
125
- npx planrails check # the same, using the latest published checker
142
+ node .project-management/planrails/check-plans.mjs # done needs a proof and exit 0 evidence; the plan reloads; NOW is current
143
+ node .project-management/planrails/check-plans.mjs --verify # also re-run each done task's proof, expect exit 0
144
+ node .project-management/planrails/check-plans.mjs --root DIR # check another folder (--dir works too)
145
+ npx planrails check # the same, with the published package's checker, which may be newer than your copy
126
146
  ```
127
147
 
128
148
  No dependencies. Node 20+, any OS (Windows included). The default is a fast
129
- structural check: every task that claims to be finished must name a proof and
130
- carry pasted evidence, and no spelling of "done" can slip past it.
131
-
132
- `--verify` goes further and **runs** each proof again. Because it executes the
133
- commands written in the plan, use it only on plans you trust run the default
134
- structural check in CI that builds untrusted pull requests, and keep `--verify`
135
- for your own branch or a trusted pipeline.
149
+ structural check, biased toward catching a faked "done":
150
+
151
+ - every task that claims to be finished must name a proof — exactly one
152
+ `command` in backticks, or the word `owner` and its evidence must record the
153
+ command's exit code, which must be 0; a bare word, an empty cell, or `exit 1`
154
+ fails. An owner-closed task records the owner's words with the date
155
+ - no spelling of "done" slips past it, and a table row it cannot read (a stray
156
+ pipe or backtick) fails closed instead of passing
157
+ - when the project has a `CLAUDE.md`, every active plan must be reloaded by
158
+ `@.project-management/plans/<id>/PLAN.md` on its own line, and no such line may
159
+ point at a plan that does not exist
160
+ - an active plan's `RESUME` line must name a task that is still open
161
+
162
+ `--verify` goes further and **runs** each proof again, with a timeout, and shows
163
+ the last line a failing proof printed. Because it executes the commands written in
164
+ the plan, use it only on plans you trust — run the default structural check in CI
165
+ that builds untrusted pull requests, and keep `--verify` for your own branch or a
166
+ trusted pipeline.
136
167
 
137
168
  ## On a team
138
169
 
@@ -140,7 +171,8 @@ for your own branch or a trusted pipeline.
140
171
  exits 0 when there are no plans, so a teammate who never uses planrails is
141
172
  unaffected.
142
173
  - **The reload line is a plain file include.** `@.project-management/plans/…` in
143
- `CLAUDE.md` just tells Claude Code to load that file; it commits like any doc.
174
+ the root `CLAUDE.md` just tells Claude Code to load that file; it commits like
175
+ any doc. Other agents do not import it: open the plan by hand at session start.
144
176
  - **Decide whether to commit `.project-management/`.** Committing it shares plans
145
177
  and lets CI run the checker. If your repo gitignores it, the checker still runs
146
178
  locally and the plan still reloads for whoever has the files.
@@ -153,7 +185,9 @@ review found ten data-loss and silent-failure paths in that surface, and the
153
185
  monorepos and Windows. 0.2.0 keeps the idea and drops the weight: the same three
154
186
  rails, as a prompt plus one checker, with a two-command CLI that only copies
155
187
  files. 0.3.0 makes learnings a reloaded part of every plan and groups the two
156
- installed files under `.project-management/planrails/`. See
188
+ installed files under `.project-management/planrails/`. 0.4.0 makes the plan
189
+ carry its own loop, makes the checker demand `exit 0` and check the reload line,
190
+ and teaches the executor to brief sub-agents from the plan. See
157
191
  [`CHANGELOG.md`](CHANGELOG.md).
158
192
 
159
193
  MIT.
package/bin/planrails.mjs CHANGED
@@ -3,10 +3,11 @@
3
3
  * planrails — a planner prompt and one checker. Two commands, both safe:
4
4
  *
5
5
  * npx planrails init [--dir DIR] [--force]
6
- * Copies PLANNER.md and the checker into <project>/.project-management/planrails/
6
+ * Puts PLANNER.md and the checker in <project>/.project-management/planrails/
7
7
  * and makes the plans/ folder. It writes nothing else — no package.json, no npm
8
- * install, no hooks, no edits to your CLAUDE.md. Idempotent: it skips files
9
- * that already exist unless you pass --force.
8
+ * install, no hooks, no edits to your CLAUDE.md. Run it again to update: a copy
9
+ * older than this package (or unstamped, 0.3.x) is replaced and you are told;
10
+ * a copy of this version is left alone unless --force; plans/ is never touched.
10
11
  *
11
12
  * npx planrails check [--dir DIR] [--verify]
12
13
  * Runs the checker over the project's plans. --verify re-runs each done
@@ -14,7 +15,7 @@
14
15
  *
15
16
  * planrails --version | --help
16
17
  */
17
- import { readFileSync, copyFileSync, mkdirSync, existsSync, writeFileSync } from "node:fs";
18
+ import { readFileSync, copyFileSync, mkdirSync, existsSync, writeFileSync, realpathSync } from "node:fs";
18
19
  import { join, dirname, resolve } from "node:path";
19
20
  import { fileURLToPath } from "node:url";
20
21
  import { checkPlans } from "../tools/check-plans.mjs";
@@ -24,11 +25,11 @@ const version = () => JSON.parse(readFileSync(join(ROOT, "package.json"), "utf8"
24
25
 
25
26
  const HELP = `planrails ${version()} — a planner prompt and one checker.
26
27
 
27
- npx planrails init [--dir DIR] [--force] set a project up (copies 2 files, writes nothing else)
28
+ npx planrails init [--dir DIR] [--force] set a project up, or update its copy of the planner (2 files, nothing else)
28
29
  npx planrails check [--dir DIR] [--verify] check the project's plans
29
30
  planrails --version | --help
30
31
 
31
- After init, tell your agent: Follow .project-management/planrails/PLANNER.md and plan <the feature> with me.
32
+ After init, tell your agent: Follow .project-management/planrails/PLANNER.md and tell me when you are ready to plan <the feature> with me.
32
33
  Add to the command you run before every commit: node .project-management/planrails/check-plans.mjs
33
34
  Full guide: https://github.com/vivmagarwal/planrails#readme`;
34
35
 
@@ -38,31 +39,50 @@ function flag(args, name) {
38
39
  const i = args.indexOf(name);
39
40
  return i !== -1 && args[i + 1] && !args[i + 1].startsWith("--") ? args[i + 1] : null;
40
41
  }
42
+ const dirArg = (args) => resolve(flag(args, "--dir") || flag(args, "--root") || ".");
43
+
44
+ /** The version a copied file carries near its top (`planrails X.Y.Z`), or null for a 0.3.x copy. */
45
+ export function stampOf(text) {
46
+ const m = text.slice(0, 400).match(/planrails (\d+\.\d+\.\d+)/);
47
+ return m ? m[1] : null;
48
+ }
49
+ const newer = (a, b) => { const [x, y] = [a, b].map((v) => v.split(".").map(Number)); for (let i = 0; i < 3; i++) if (x[i] !== y[i]) return x[i] > y[i]; return false; };
41
50
 
42
51
  function init(args) {
43
- const target = resolve(flag(args, "--dir") || ".");
52
+ const target = dirArg(args);
44
53
  const force = args.includes("--force");
54
+ const ver = version();
45
55
  const pm = join(target, ".project-management");
46
56
  const sys = join(pm, "planrails");
47
57
  const plans = join(pm, "plans");
48
58
  mkdirSync(sys, { recursive: true });
49
59
  mkdirSync(plans, { recursive: true });
60
+ console.log(`planrails ${ver} → ${target}`);
50
61
 
51
- const copy = (from, to, label) => {
52
- if (existsSync(to) && !force) { console.log(` · ${label} already present (use --force to overwrite)`); return; }
62
+ // The two copied files are the tool. A re-run brings them up to this package's
63
+ // version; it never writes into plans/, and never touches anything else.
64
+ const place = (from, to, label) => {
65
+ const src = readFileSync(from, "utf8");
66
+ if (!existsSync(to)) { copyFileSync(from, to); console.log(` + ${label}`); return; }
67
+ const cur = readFileSync(to, "utf8");
68
+ if (cur === src) { console.log(` = ${label} up to date (${ver})`); return; }
69
+ const have = stampOf(cur);
70
+ if (!force && have && newer(have, ver)) { console.log(` · ${label} is ${have}, newer than this package (${ver}) — kept; use --force to replace`); return; }
71
+ if (!force && have === ver) { console.log(` · ${label} is ${ver} but edited — kept; use --force to replace`); return; }
53
72
  copyFileSync(from, to);
54
- console.log(` + ${label}`);
73
+ console.log(` ${label} updated ${have || "unstamped (0.3.x or older)"} → ${ver}${force ? " (--force)" : ""}`);
55
74
  };
56
- console.log(`planrails ${version()} ${target}`);
57
- copy(join(ROOT, "PLANNER.md"), join(sys, "PLANNER.md"), ".project-management/planrails/PLANNER.md");
58
- copy(join(ROOT, "tools", "check-plans.mjs"), join(sys, "check-plans.mjs"), ".project-management/planrails/check-plans.mjs");
75
+ place(join(ROOT, "PLANNER.md"), join(sys, "PLANNER.md"), ".project-management/planrails/PLANNER.md");
76
+ place(join(ROOT, "tools", "check-plans.mjs"), join(sys, "check-plans.mjs"), ".project-management/planrails/check-plans.mjs");
59
77
  const keep = join(plans, ".gitkeep");
60
78
  if (!existsSync(keep)) { writeFileSync(keep, ""); console.log(" + .project-management/plans/"); }
61
- else console.log(" · .project-management/plans/ already present");
79
+ else console.log(" = .project-management/plans/ (your plans are never touched)");
80
+ const loose = ["PLANNER.md", "check-plans.mjs"].filter((f) => existsSync(join(pm, f)));
81
+ if (loose.length) console.log(` ! 0.2.x files at .project-management/ root: ${loose.join(", ")} — the copies now live in planrails/; delete the loose ones and point your check command at .project-management/planrails/check-plans.mjs`);
62
82
 
63
83
  console.log(`
64
84
  Next:
65
- 1. Tell your agent: Follow .project-management/planrails/PLANNER.md and plan <the feature> with me.
85
+ 1. Tell your agent: Follow .project-management/planrails/PLANNER.md and tell me when you are ready to plan <the feature> with me.
66
86
  (Or, in Claude Code, use /plan if you installed the skill.)
67
87
  2. Add to the command you run before every commit:
68
88
  node .project-management/planrails/check-plans.mjs
@@ -72,7 +92,7 @@ Next:
72
92
  }
73
93
 
74
94
  function check(args) {
75
- const root = resolve(flag(args, "--dir") || ".");
95
+ const root = dirArg(args);
76
96
  if (!existsSync(root)) { console.error(`planrails check: --dir path does not exist: ${root}`); return 2; }
77
97
  const verify = args.includes("--verify");
78
98
  const { plans, problems } = checkPlans({ root, verify });
@@ -81,14 +101,19 @@ function check(args) {
81
101
  console.error(`check-plans: ${problems.length} problem(s):\n${problems.map((p) => ` - ${p}`).join("\n")}`);
82
102
  return 1;
83
103
  }
84
- console.log(`check-plans: ${plans.length} plan(s) ok — every completion claim has a proof and pasted evidence${verify ? " (proofs re-run)" : ""}`);
104
+ console.log(`check-plans: ${plans.length} plan(s) ok — every completion claim has a proof and exit 0 evidence, active plans reload, NOW is current${verify ? " (proofs re-run)" : ""}`);
85
105
  return 0;
86
106
  }
87
107
 
88
- const [cmd, ...args] = process.argv.slice(2);
89
- if (cmd === "--version" || cmd === "-v") { console.log(version()); process.exit(0); }
90
- if (!cmd || cmd === "--help" || cmd === "-h") { console.log(HELP); process.exit(cmd ? 0 : 1); }
91
- if (cmd === "init") process.exit(init(args));
92
- if (cmd === "check") process.exit(check(args));
93
- console.error(`planrails: unknown command "${cmd}"\n\n${HELP}`);
94
- process.exit(2);
108
+ // Run the CLI only when this file is the entry point. npm installs the bin as a
109
+ // symlink, so compare real paths, or `npx planrails` would import and do nothing.
110
+ const isMain = (() => { try { return process.argv[1] && realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url)); } catch { return false; } })();
111
+ if (isMain) {
112
+ const [cmd, ...args] = process.argv.slice(2);
113
+ if (cmd === "--version" || cmd === "-v") { console.log(version()); process.exit(0); }
114
+ if (!cmd || cmd === "--help" || cmd === "-h") { console.log(HELP); process.exit(cmd ? 0 : 1); }
115
+ if (cmd === "init") process.exit(init(args));
116
+ if (cmd === "check") process.exit(check(args));
117
+ console.error(`planrails: unknown command "${cmd}"\n\n${HELP}`);
118
+ process.exit(2);
119
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "planrails",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Plans that survive a lost session, and \"done\" that means done. A planner prompt and a tiny checker.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -13,7 +13,7 @@
13
13
  "engines": { "node": ">=20" },
14
14
  "scripts": {
15
15
  "test": "node --test tools/check-plans.test.mjs bin/planrails.test.mjs",
16
- "check": "node bin/planrails.mjs check --dir examples/weekly-digest && npm test",
16
+ "check": "node tools/check-plans.mjs && node bin/planrails.mjs check --dir examples/weekly-digest && npm test",
17
17
  "prepublishOnly": "npm test"
18
18
  }
19
19
  }
@@ -1,30 +1,41 @@
1
1
  #!/usr/bin/env node
2
+ // planrails 0.4.0
2
3
  /**
3
- * check-plans — the one machine-checked rail of the planner.
4
+ * check-plans — the machine-checked rails of the planner.
4
5
  *
5
- * It reads every PLAN.md under .project-management/plans/<id>/ and enforces a
6
- * single rule: a task that claims to be finished must name a proof and carry
7
- * evidence that the proof was run. An empty evidence cell fails the build.
6
+ * It reads every PLAN.md under .project-management/plans/<id>/ and enforces:
7
+ * - a task that claims to be finished must name a proof a `command` in
8
+ * backticks, or the word owner and carry evidence that the proof was run.
9
+ * For a command that means its exit code, pasted, and the code must be 0. An
10
+ * empty evidence cell, a bare word, or a recorded failure is not done.
11
+ * - an active plan must reload: when the project has a CLAUDE.md, it must carry
12
+ * `@.project-management/plans/<id>/PLAN.md` on its own line, and no such line
13
+ * may point at a plan that does not exist. (Skipped when there is no CLAUDE.md.)
14
+ * - NOW must be current: the RESUME line of an active plan may not name only
15
+ * finished tasks. A retired plan says `status: done` (or paused) and is exempt
16
+ * from both; a plan with no status line counts as active.
8
17
  *
9
18
  * The rule is biased toward catching a faked "done": a task counts as a
10
19
  * completion claim UNLESS its status is blank or an explicit not-done word
11
20
  * (todo, doing, blocked, …). So no spelling of "done" — done, completed, ✅,
12
- * shipped, a typo — can slip through unchecked.
21
+ * shipped, a typo — can slip through unchecked. A row the parser cannot read
22
+ * (its cell count differs from the header's) is a problem, never a pass.
13
23
  *
14
24
  * `npx planrails init` copies this file into a project's .project-management/planrails/;
15
25
  * add `node .project-management/planrails/check-plans.mjs` to the command you run before
16
26
  * every commit. No dependencies. Runs on Node 20+ on any OS.
17
27
  *
18
- * node check-plans.mjs # structural: completion claims need proof + evidence
19
- * node check-plans.mjs --verify # ALSO re-runs each claim's proof, expects exit 0.
28
+ * node check-plans.mjs # structural: completion claims need proof + exit 0 evidence
29
+ * node check-plans.mjs --verify # ALSO re-runs each claim's proof, expects exit 0; a proof that
30
+ * # fails shows its last output line, one that hangs times out.
20
31
  * # ⚠ --verify executes the proof commands. Only run it
21
32
  * # on plans you trust — never on an untrusted pull request.
22
- * node check-plans.mjs --root DIR # check a project other than the current directory
33
+ * node check-plans.mjs --root DIR # check a project other than the current directory (--dir works too)
23
34
  *
24
35
  * The functions are pure over their inputs, so check-plans.test.mjs exercises
25
36
  * them without a real project.
26
37
  */
27
- import { readdirSync, readFileSync, existsSync, statSync } from "node:fs";
38
+ import { readdirSync, readFileSync, existsSync, statSync, realpathSync } from "node:fs";
28
39
  import { join } from "node:path";
29
40
  import { spawnSync } from "node:child_process";
30
41
  import { fileURLToPath } from "node:url";
@@ -42,6 +53,10 @@ const NOT_DONE = new Set([
42
53
  ]);
43
54
  // A lone one of these in the evidence cell is a placeholder, not evidence.
44
55
  const NON_EVIDENCE = new Set(["tbd", "tba", "tbc", "todo", "pending", "later", "wip", "none", "na"]);
56
+ // The exit codes inside an evidence cell: "exit 0", "exit code 1", "exit status 1", "exited 3", "exit=0".
57
+ // Every occurrence counts, so "exit 0 … then exit 1" is a failure, not a pass.
58
+ const EXIT_CODE = /\bexit(?:ed|[\s-]*(?:code|status))?\s*[:=]?\s*(-?\d+)/gi;
59
+ const DATE = /\b\d{4}-\d{2}-\d{2}\b/;
45
60
 
46
61
  /** Normalise a status or a short token for matching: drop markup, spaces, hyphens, trailing punctuation. */
47
62
  function norm(s) {
@@ -57,64 +72,105 @@ function evidenceMissing(ev) {
57
72
  }
58
73
 
59
74
  /**
60
- * Split one markdown table row `| a | b |` into trimmed cells, respecting
61
- * backtick code spans and \| escapes so a pipe inside a proof command
62
- * (`npm test | tail -1`) does not shift the columns.
75
+ * Split one markdown table row `| a | b |` into trimmed cells. A `|` inside a
76
+ * code span does not split (so `npm test | tail -1` stays one cell) and `\|` is
77
+ * a literal pipe. Code spans follow the CommonMark rule: a run of N backticks
78
+ * opens a span only if a run of exactly N closes it later in the row; otherwise
79
+ * the run is literal text. So a stray backtick, or three backticks written in
80
+ * prose, shifts nothing — and a row that still comes out short is reported.
63
81
  */
64
82
  function cells(line) {
65
83
  const s = line.trim().replace(/^\|/, "").replace(/\|$/, "");
66
84
  const out = [];
67
85
  let cur = "";
68
- let inCode = false;
69
86
  for (let i = 0; i < s.length; i++) {
70
87
  const ch = s[i];
71
88
  if (ch === "\\" && s[i + 1] === "|") { cur += "|"; i++; continue; }
72
- if (ch === "`") { inCode = !inCode; cur += ch; continue; }
73
- if (ch === "|" && !inCode) { out.push(cur.trim()); cur = ""; continue; }
89
+ if (ch === "`") {
90
+ let n = 1;
91
+ while (s[i + n] === "`") n++;
92
+ const close = closingRun(s, i + n, n);
93
+ if (close === -1) { cur += s.slice(i, i + n); i += n - 1; continue; } // unmatched: literal backticks
94
+ cur += s.slice(i, close + n).replace(/\\\|/g, "|");
95
+ i = close + n - 1;
96
+ continue;
97
+ }
98
+ if (ch === "|") { out.push(cur.trim()); cur = ""; continue; }
74
99
  cur += ch;
75
100
  }
76
101
  out.push(cur.trim());
77
102
  return out;
78
103
  }
104
+ /** The index of the next backtick run of exactly n characters in s at or after `from`, or -1. */
105
+ function closingRun(s, from, n) {
106
+ for (let j = from; j < s.length;) {
107
+ if (s[j] !== "`") { j++; continue; }
108
+ let m = 1;
109
+ while (s[j + m] === "`") m++;
110
+ if (m === n) return j;
111
+ j += m;
112
+ }
113
+ return -1;
114
+ }
79
115
  const isRow = (l) => l.trim().startsWith("|");
80
116
  const isHeading = (l) => /^#{1,6}\s/.test(l.trim());
81
117
  const isSeparator = (l) => /^\|[\s:|-]+\|?\s*$/.test(l.trim());
118
+ /**
119
+ * True for every line inside a ``` or ~~~ fence, and for the fence lines themselves:
120
+ * never a table row. A fence closes only with a fence of the same character and at
121
+ * least the same length (CommonMark), so a ```` block can hold a ``` example.
122
+ */
123
+ function fenceMask(lines) {
124
+ const m = new Array(lines.length);
125
+ let open = null; // { ch, len } of the fence we are inside
126
+ for (let k = 0; k < lines.length; k++) {
127
+ const f = lines[k].match(/^\s*(`{3,}|~{3,})/);
128
+ if (f && !open) { open = { ch: f[1][0], len: f[1].length }; m[k] = true; continue; }
129
+ if (f && open && f[1][0] === open.ch && f[1].length >= open.len && lines[k].trim() === f[1]) { open = null; m[k] = true; continue; }
130
+ m[k] = open !== null;
131
+ }
132
+ return m;
133
+ }
82
134
 
83
135
  const REQUIRED = ["id", "status", "proof", "evidence"];
84
- /** The column indices of a row, and which required columns it is missing. */
136
+ /** The column indices of a header row (markup like **id** ignored), which required columns it is missing, and its cell count. */
85
137
  function headerCols(rowText) {
86
- const h = cells(rowText).map((c) => c.toLowerCase());
138
+ const h = cells(rowText).map((c) => c.replace(/[*_`]/g, "").trim().toLowerCase());
87
139
  const ci = { id: h.indexOf("id"), task: h.indexOf("task"), status: h.indexOf("status"), proof: h.indexOf("proof"), evidence: h.indexOf("evidence") };
88
- return { ci, missing: REQUIRED.filter((k) => ci[k] === -1) };
140
+ return { ci, missing: REQUIRED.filter((k) => ci[k] === -1), count: h.length };
89
141
  }
90
142
 
91
143
  /**
92
144
  * Parse the task rows of a plan. A "task table" is any markdown table whose
93
145
  * header carries the four columns id, status, proof, evidence — found by its
94
146
  * columns, not by a heading, so tasks under "## Tasks", "## Phase 2 Tasks",
95
- * "## Backlog", or a second table are all read; blank lines inside a table are
96
- * tolerated. Returns { found, tasks, missingCols }.
147
+ * "## Backlog", or a second table are all read. Blank lines inside a table are
148
+ * tolerated; a heading, prose, a code fence, or another table's header ends it.
149
+ * Returns { found, tasks, missingCols }. Each task carries its cell count and
150
+ * the header's, so a row the splitter could not read is visible to the gate.
97
151
  */
98
152
  export function parseTasks(text) {
99
153
  const lines = text.split(/\r?\n/);
154
+ const fenced = fenceMask(lines);
100
155
  const tasks = [];
101
156
  let found = false;
102
157
  let i = 0;
103
158
  while (i < lines.length) {
104
- if (!isRow(lines[i]) || isSeparator(lines[i]) || headerCols(lines[i]).missing.length) { i++; continue; }
159
+ if (fenced[i] || !isRow(lines[i]) || isSeparator(lines[i]) || headerCols(lines[i]).missing.length) { i++; continue; }
105
160
  found = true; // lines[i] is a task-table header (a | row naming all four columns)
106
- const { ci } = headerCols(lines[i]);
161
+ const { ci, count } = headerCols(lines[i]);
107
162
  let j = i + 1;
108
163
  for (; j < lines.length; j++) {
109
164
  const l = lines[j];
110
- if (isHeading(l)) break; // a heading ends the table
165
+ if (fenced[j] || isHeading(l)) break; // a fence or a heading ends the table
111
166
  if (l.trim() === "") continue; // a blank line inside the table does not
112
167
  if (!isRow(l)) break; // prose ends the table
113
168
  if (isSeparator(l)) continue;
114
- if (headerCols(l).missing.length === 0) break; // the next table's header — reprocess it
169
+ if (headerCols(l).missing.length === 0) break; // the next task table's header — reprocess it
170
+ if (j + 1 < lines.length && isRow(lines[j + 1]) && isSeparator(lines[j + 1])) break; // any other table's header
115
171
  const c = cells(l);
116
172
  const at = (idx) => (idx >= 0 && idx < c.length ? c[idx] : "");
117
- tasks.push({ id: at(ci.id), task: at(ci.task), status: at(ci.status), proof: at(ci.proof), evidence: at(ci.evidence), line: j + 1 });
173
+ tasks.push({ id: at(ci.id), task: at(ci.task), status: at(ci.status), proof: at(ci.proof), evidence: at(ci.evidence), line: j + 1, cellCount: c.length, headerCount: count });
118
174
  }
119
175
  i = j;
120
176
  }
@@ -122,7 +178,7 @@ export function parseTasks(text) {
122
178
  let missingCols = [];
123
179
  if (!found) {
124
180
  for (let k = 0; k < lines.length && !missingCols.length; k++) {
125
- if (!/^#{1,6}\s+tasks\b/i.test(lines[k].trim())) continue;
181
+ if (fenced[k] || !/^#{1,6}\s+tasks\b/i.test(lines[k].trim())) continue;
126
182
  for (let m = k + 1; m < lines.length && !isHeading(lines[m]); m++) {
127
183
  if (isRow(lines[m]) && !isSeparator(lines[m])) { const miss = headerCols(lines[m]).missing; if (miss.length && miss.length < REQUIRED.length) missingCols = miss; break; }
128
184
  }
@@ -131,13 +187,13 @@ export function parseTasks(text) {
131
187
  return { found, tasks, missingCols };
132
188
  }
133
189
 
134
- /** The command inside a proof cell (backticks stripped), or null for `owner`/prose/empty. */
190
+ /** The command in a proof cell that is exactly one `code span` (backticks stripped), or null for `owner`, prose, a span inside prose, or empty. */
135
191
  export function proofCommand(proof) {
136
- const m = proof.match(/`([^`]+)`/);
192
+ const m = proof.trim().match(/^`([^`]+)`$/);
137
193
  return m ? m[1].trim() : null;
138
194
  }
139
195
 
140
- /** Problems with one plan's tasks. `run` (optional) executes a proof and returns its exit code. */
196
+ /** Problems with one plan's tasks. `run` (optional) executes a proof and returns its exit code, or { code, last }. */
141
197
  export function checkPlan({ id, text, verify = false, run = null }) {
142
198
  const problems = [];
143
199
  const { found, tasks, missingCols } = parseTasks(text);
@@ -147,19 +203,70 @@ export function checkPlan({ id, text, verify = false, run = null }) {
147
203
  return problems;
148
204
  }
149
205
  for (const t of tasks) {
206
+ if (t.cellCount !== t.headerCount) {
207
+ problems.push(`${id} line ${t.line}: row has ${t.cellCount} cell(s) but the header has ${t.headerCount} — a stray | or backtick? A row the gate cannot read fails closed`);
208
+ continue;
209
+ }
150
210
  if (!isCompletionClaim(t.status)) continue;
151
211
  const noProof = EMPTY.test(t.proof);
212
+ const cmd = proofCommand(t.proof);
213
+ const owner = norm(t.proof) === "owner";
152
214
  const noEvidence = evidenceMissing(t.evidence);
153
215
  if (noProof) problems.push(`${id} ${t.id}: status "${t.status}" names no proof (line ${t.line})`);
216
+ else if (!cmd && !owner) problems.push(`${id} ${t.id}: the proof must be a \`command\` in backticks or the word owner, not "${t.proof}" (line ${t.line})`);
154
217
  if (noEvidence) problems.push(`${id} ${t.id}: status "${t.status}" but the evidence cell is empty — run the proof and paste its result (line ${t.line})`);
155
- if (verify && !noProof && !noEvidence && run) {
156
- const cmd = proofCommand(t.proof);
157
- if (cmd) { const code = run(cmd); if (code !== 0) problems.push(`${id} ${t.id}: proof re-run failed — \`${cmd}\` exited ${code}`); }
218
+ else if (cmd) {
219
+ const codes = [...t.evidence.matchAll(EXIT_CODE)].map((m) => m[1]);
220
+ const bad = codes.find((x) => x !== "0");
221
+ if (!codes.length) problems.push(`${id} ${t.id}: evidence does not record the proof's exit code — run it and paste "exit 0" with the last line (line ${t.line})`);
222
+ else if (bad !== undefined) problems.push(`${id} ${t.id}: evidence records exit ${bad} — the proof failed, so the task is not done (line ${t.line})`);
223
+ } else if (owner && !DATE.test(t.evidence)) {
224
+ problems.push(`${id} ${t.id}: an owner-closed task records the owner's words with the date, e.g. "2026-09-13 owner: ship it" (line ${t.line})`);
158
225
  }
226
+ if (verify && cmd && !noEvidence && run) {
227
+ const r = run(cmd);
228
+ const code = typeof r === "number" ? r : r.code;
229
+ if (code !== 0) problems.push(`${id} ${t.id}: proof re-run failed — \`${cmd}\` exited ${code}${r.last ? ` — ${r.last}` : ""}`);
230
+ }
231
+ }
232
+ // NOW must point somewhere live: a RESUME line that names only finished tasks is stale.
233
+ if (isActive(text)) {
234
+ const named = resumeIds(text, tasks.map((t) => t.id).filter(Boolean));
235
+ if (named.length && named.every((n) => tasks.some((t) => t.id === n && isCompletionClaim(t.status))))
236
+ problems.push(`${id}: NOW is stale — RESUME names ${named.map((n) => n.replace(/[`*_]/g, "")).join(", ")}, which ${named.length === 1 ? "is" : "are all"} done; point it at the next open task, or set the plan's status to done`);
159
237
  }
160
238
  return problems;
161
239
  }
162
240
 
241
+ /**
242
+ * Active unless the header says otherwise: `status: done`, `paused`, or another
243
+ * finished word exempts a plan from the reload and NOW rules. A plan with no
244
+ * status line counts as active — the safe direction for a gate.
245
+ */
246
+ const NOT_ACTIVE = new Set(["done", "paused", "retired", "closed", "shipped", "finished", "complete", "completed", "archived", "dropped", "cancelled", "canceled", "abandoned", "superseded", "onhold", "hold"]);
247
+ export function isActive(text) {
248
+ const m = text.match(/^status:\s*([^\s·|]+)/im);
249
+ return !(m && NOT_ACTIVE.has(norm(m[1])));
250
+ }
251
+ /** The task ids the RESUME line names, out of the plan's own ids (T1 does not match inside T12). */
252
+ function resumeIds(text, ids) {
253
+ const m = text.match(/^\s*RESUME:(.*)$/m);
254
+ if (!m) return [];
255
+ const plain = (id) => id.replace(/[`*_\s]/g, "");
256
+ return ids.filter((id) => plain(id) && new RegExp(`(^|[^A-Za-z0-9])${plain(id).replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?![A-Za-z0-9])`).test(m[1]));
257
+ }
258
+ /** The plan ids a CLAUDE.md reloads: `@.project-management/plans/<id>/PLAN.md` on its own line, outside a fence. */
259
+ export function reloadLines(text) {
260
+ const lines = text.split(/\r?\n/);
261
+ const fenced = fenceMask(lines);
262
+ const ids = new Set();
263
+ lines.forEach((l, k) => {
264
+ const m = !fenced[k] && l.match(/^\s*@\.project-management\/plans\/([^/\s]+)\/PLAN\.md\s*$/);
265
+ if (m) ids.add(m[1]);
266
+ });
267
+ return ids;
268
+ }
269
+
163
270
  /** Plan folders under .project-management/plans/. A folder with no PLAN.md is a problem. */
164
271
  export function findPlans(root) {
165
272
  const dir = join(root, ".project-management", "plans");
@@ -177,15 +284,28 @@ export function findPlans(root) {
177
284
  return { plans, problems };
178
285
  }
179
286
 
180
- /** Check every plan under root. Returns { plans, problems }. */
181
- export function checkPlans({ root = ".", verify = false } = {}) {
287
+ /** Run one proof command in root: its exit code (or "timeout after Ns") and the last line it printed. */
288
+ export function runProof(cmd, root, timeoutMs) {
289
+ const r = spawnSync(cmd, { cwd: root, shell: true, encoding: "utf8", timeout: timeoutMs, maxBuffer: 16 * 1024 * 1024 });
290
+ const last = `${r.stdout || ""}\n${r.stderr || ""}`.trim().split(/\r?\n/).filter(Boolean).pop() || "";
291
+ if (r.error && r.error.code === "ETIMEDOUT") return { code: `timeout after ${timeoutMs / 1000}s`, last };
292
+ return { code: r.status ?? 1, last };
293
+ }
294
+
295
+ /** Check every plan under root. Returns { plans, problems }. `--verify` re-runs proofs, each with a timeout (10 min by default). */
296
+ export function checkPlans({ root = ".", verify = false, verifyTimeoutMs = 10 * 60 * 1000 } = {}) {
182
297
  const { plans, problems } = findPlans(root);
183
- const run = verify ? (cmd) => { const r = spawnSync(cmd, { cwd: root, shell: true, stdio: "ignore" }); return r.status ?? 1; } : null;
298
+ const run = verify ? (cmd) => runProof(cmd, root, verifyTimeoutMs) : null;
299
+ const claudeMd = join(root, "CLAUDE.md");
300
+ const reloads = existsSync(claudeMd) ? reloadLines(readFileSync(claudeMd, "utf8")) : null;
184
301
  for (const { id, path } of plans) {
185
302
  let text = "";
186
303
  try { text = readFileSync(path, "utf8"); } catch (e) { problems.push(`${id}: cannot read ${path} (${e.code || e.message})`); continue; }
187
304
  problems.push(...checkPlan({ id, text, verify, run }));
305
+ if (reloads && isActive(text) && !reloads.has(id))
306
+ problems.push(`${id}: the plan is active but CLAUDE.md has no reload line — add "@.project-management/plans/${id}/PLAN.md" on its own line, outside backticks, or the plan will not survive a compaction`);
188
307
  }
308
+ if (reloads) for (const id of reloads) if (!plans.some((p) => p.id === id)) problems.push(`CLAUDE.md reloads "${id}" but .project-management/plans/${id}/PLAN.md does not exist`);
189
309
  return { plans, problems };
190
310
  }
191
311
 
@@ -197,10 +317,15 @@ function flagValue(args, name) {
197
317
  return i !== -1 && args[i + 1] && !args[i + 1].startsWith("--") ? args[i + 1] : null;
198
318
  }
199
319
 
200
- if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
320
+ // Run the CLI only when this file is the entry point. Compare real paths: through a
321
+ // symlink, or /tmp vs /private/tmp on macOS, a textual comparison fails and a gate
322
+ // that silently does nothing exits 0 — the one thing a gate must never do.
323
+ const real = (p) => { try { return realpathSync.native(p); } catch { return realpathSync(p); } };
324
+ const isMain = (() => { try { return Boolean(process.argv[1]) && real(process.argv[1]) === real(fileURLToPath(import.meta.url)); } catch { return false; } })();
325
+ if (isMain) {
201
326
  const args = process.argv.slice(2);
202
327
  const verify = args.includes("--verify");
203
- const root = flagValue(args, "--root") || ".";
328
+ const root = flagValue(args, "--root") || flagValue(args, "--dir") || ".";
204
329
  if (!existsSync(root)) { process.stderr.write(`check-plans: --root path does not exist: ${root}\n`); process.exit(2); }
205
330
  const { plans, problems } = checkPlans({ root, verify });
206
331
  if (!plans.length && !problems.length) { process.stdout.write("check-plans: no plans under .project-management/plans/ — nothing to check\n"); process.exit(0); }
@@ -208,6 +333,6 @@ if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
208
333
  process.stderr.write(`check-plans: ${problems.length} problem(s):\n${problems.map((p) => ` - ${p}`).join("\n")}\n`);
209
334
  process.exit(1);
210
335
  }
211
- process.stdout.write(`check-plans: ${plans.length} plan(s) ok — every completion claim has a proof and pasted evidence${verify ? " (proofs re-run)" : ""}\n`);
336
+ process.stdout.write(`check-plans: ${plans.length} plan(s) ok — every completion claim has a proof and exit 0 evidence, active plans reload, NOW is current${verify ? " (proofs re-run)" : ""}\n`);
212
337
  process.exit(0);
213
338
  }