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 +73 -0
- package/PLANNER.md +118 -50
- package/README.md +70 -36
- package/bin/planrails.mjs +49 -24
- package/package.json +2 -2
- package/tools/check-plans.mjs +163 -38
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 *"
|
|
11
|
-
|
|
12
|
-
me."* When they do, run **§1 Get ready**, give the
|
|
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
|
|
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
|
|
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** —
|
|
54
|
-
`README.md`. Find the stack, the package manager, 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
|
|
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 ~
|
|
109
|
-
|
|
110
|
-
|
|
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.
|
|
118
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
153
|
-
|
|
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. **
|
|
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
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
11
|
-
re-opens the plan after every compaction
|
|
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
|
|
15
|
-
cell
|
|
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
|
|
34
|
-
|
|
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
|
|
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
|
|
75
|
-
|
|
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
|
|
92
|
-
|
|
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`
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
|
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,
|
|
117
|
-
runs one task at a time
|
|
118
|
-
|
|
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
|
|
124
|
-
node .project-management/planrails/check-plans.mjs --verify
|
|
125
|
-
|
|
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
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
|
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/`.
|
|
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
|
-
*
|
|
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.
|
|
9
|
-
*
|
|
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 (
|
|
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 =
|
|
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
|
-
|
|
52
|
-
|
|
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(`
|
|
73
|
+
console.log(` ↑ ${label} updated ${have || "unstamped (0.3.x or older)"} → ${ver}${force ? " (--force)" : ""}`);
|
|
55
74
|
};
|
|
56
|
-
|
|
57
|
-
|
|
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("
|
|
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 =
|
|
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
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
if (
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
process.exit(
|
|
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
|
+
"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
|
}
|
package/tools/check-plans.mjs
CHANGED
|
@@ -1,30 +1,41 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
// planrails 0.4.0
|
|
2
3
|
/**
|
|
3
|
-
* check-plans — the
|
|
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
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
|
61
|
-
*
|
|
62
|
-
*
|
|
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 === "`") {
|
|
73
|
-
|
|
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,
|
|
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
|
|
96
|
-
* tolerated
|
|
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
|
|
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 (
|
|
156
|
-
const
|
|
157
|
-
|
|
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
|
-
/**
|
|
181
|
-
export function
|
|
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) =>
|
|
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
|
-
|
|
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
|
|
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
|
}
|