planrails 0.1.2 → 0.2.1

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.
Files changed (41) hide show
  1. package/CHANGELOG.md +69 -16
  2. package/PLANNER.md +255 -0
  3. package/README.md +103 -176
  4. package/bin/planrails.mjs +90 -5
  5. package/package.json +14 -48
  6. package/tools/check-plans.mjs +213 -0
  7. package/docs/PLANNING_GUIDE.md +0 -632
  8. package/src/hooks/_lib.mjs +0 -32
  9. package/src/hooks/guard-never-delete.sh +0 -29
  10. package/src/hooks/install.mjs +0 -179
  11. package/src/hooks/plan-pre-tool.mjs +0 -71
  12. package/src/hooks/plan-session-start.mjs +0 -46
  13. package/src/hooks/plan-stop.mjs +0 -60
  14. package/src/hooks/plan-subagent-start.mjs +0 -27
  15. package/src/hooks/postcompact-journal.mjs +0 -35
  16. package/src/hooks/precompact-journal.mjs +0 -128
  17. package/src/hooks/selftest.mjs +0 -128
  18. package/src/init.mjs +0 -161
  19. package/src/issue.mjs +0 -55
  20. package/src/plan/fixtures/README.md +0 -7
  21. package/src/plan/fixtures/broken-cli.mjs +0 -27
  22. package/src/plan/fixtures/broken-hooks-root/.claude/settings.json +0 -83
  23. package/src/plan/fixtures/broken-hooks-root/.project-management/plans/.gitkeep +0 -0
  24. package/src/plan/fixtures/broken-hooks-root/CLAUDE.md +0 -9
  25. package/src/plan/fixtures/broken-root/.project-management/plans/broken/PLAN.md +0 -4
  26. package/src/plan/fixtures/broken-root/.project-management/plans/broken/gates.json +0 -1
  27. package/src/plan/fixtures/broken-root/.project-management/plans/broken/rules.json +0 -1
  28. package/src/plan/fixtures/broken-root/.project-management/plans/broken/state.json +0 -67
  29. package/src/plan/fixtures/broken-root/CLAUDE.md +0 -3
  30. package/src/plan/fixtures/broken-trial.mjs +0 -25
  31. package/src/plan/lib/brief.mjs +0 -116
  32. package/src/plan/lib/claude-md.mjs +0 -68
  33. package/src/plan/lib/glob.mjs +0 -81
  34. package/src/plan/lib/judgment.mjs +0 -19
  35. package/src/plan/lib/paths.mjs +0 -65
  36. package/src/plan/lib/schema.mjs +0 -199
  37. package/src/plan/lib/store.mjs +0 -338
  38. package/src/plan/lib/time.mjs +0 -21
  39. package/src/plan/plan.mjs +0 -843
  40. package/src/plan/run.mjs +0 -88
  41. package/src/plan/skill/SKILL.md +0 -15
@@ -0,0 +1,213 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * check-plans — the one machine-checked rail of the planner.
4
+ *
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.
8
+ *
9
+ * The rule is biased toward catching a faked "done": a task counts as a
10
+ * completion claim UNLESS its status is blank or an explicit not-done word
11
+ * (todo, doing, blocked, …). So no spelling of "done" — done, completed, ✅,
12
+ * shipped, a typo — can slip through unchecked.
13
+ *
14
+ * `npx planrails init` copies this file into a project's .project-management/;
15
+ * add `node .project-management/check-plans.mjs` to the command you run before
16
+ * every commit. No dependencies. Runs on Node 20+ on any OS.
17
+ *
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.
20
+ * # ⚠ --verify executes the proof commands. Only run it
21
+ * # 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
23
+ *
24
+ * The functions are pure over their inputs, so check-plans.test.mjs exercises
25
+ * them without a real project.
26
+ */
27
+ import { readdirSync, readFileSync, existsSync, statSync } from "node:fs";
28
+ import { join } from "node:path";
29
+ import { spawnSync } from "node:child_process";
30
+ import { fileURLToPath } from "node:url";
31
+
32
+ const EMPTY = /^[\s\-—–·]*$/; // blank, or a dash/dot someone wrote for "nothing"
33
+
34
+ // A task is a completion claim unless its status is one of these. Kept generous
35
+ // so a normal not-done status is never mistaken for a claim; anything unknown is
36
+ // treated AS a claim (needs evidence), which is the safe direction for a gate.
37
+ const NOT_DONE = new Set([
38
+ "todo", "todos", "doing", "wip", "inprogress", "started", "starting", "blocked", "block",
39
+ "pending", "review", "inreview", "needsreview", "qa", "testing", "test", "backlog",
40
+ "paused", "onhold", "hold", "waiting", "new", "open", "deferred", "planned", "notstarted",
41
+ "cancelled", "canceled", "wontfix", "wontdo", "dropped", "abandoned", "skip", "skipped", "na",
42
+ ]);
43
+ // A lone one of these in the evidence cell is a placeholder, not evidence.
44
+ const NON_EVIDENCE = new Set(["tbd", "tba", "tbc", "todo", "pending", "later", "wip", "none", "na"]);
45
+
46
+ /** Normalise a status or a short token for matching: drop markup, spaces, hyphens, trailing punctuation. */
47
+ function norm(s) {
48
+ return s.replace(/[`*_[\]()'"’/]/g, "").replace(/[.!?]+$/, "").replace(/[\s-]+/g, "").replace(/️/g, "").toLowerCase();
49
+ }
50
+ function isCompletionClaim(status) {
51
+ if (EMPTY.test(status)) return false; // blank = not filled in = not a claim
52
+ return !NOT_DONE.has(norm(status));
53
+ }
54
+ function evidenceMissing(ev) {
55
+ const n = norm(ev);
56
+ return EMPTY.test(ev) || n === "" || NON_EVIDENCE.has(n);
57
+ }
58
+
59
+ /**
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.
63
+ */
64
+ function cells(line) {
65
+ const s = line.trim().replace(/^\|/, "").replace(/\|$/, "");
66
+ const out = [];
67
+ let cur = "";
68
+ let inCode = false;
69
+ for (let i = 0; i < s.length; i++) {
70
+ const ch = s[i];
71
+ 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; }
74
+ cur += ch;
75
+ }
76
+ out.push(cur.trim());
77
+ return out;
78
+ }
79
+ const isRow = (l) => l.trim().startsWith("|");
80
+ const isHeading = (l) => /^#{1,6}\s/.test(l.trim());
81
+ const isSeparator = (l) => /^\|[\s:|-]+\|?\s*$/.test(l.trim());
82
+
83
+ const REQUIRED = ["id", "status", "proof", "evidence"];
84
+ /** The column indices of a row, and which required columns it is missing. */
85
+ function headerCols(rowText) {
86
+ const h = cells(rowText).map((c) => c.toLowerCase());
87
+ 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) };
89
+ }
90
+
91
+ /**
92
+ * Parse the task rows of a plan. A "task table" is any markdown table whose
93
+ * header carries the four columns id, status, proof, evidence — found by its
94
+ * 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 }.
97
+ */
98
+ export function parseTasks(text) {
99
+ const lines = text.split(/\r?\n/);
100
+ const tasks = [];
101
+ let found = false;
102
+ let i = 0;
103
+ while (i < lines.length) {
104
+ if (!isRow(lines[i]) || isSeparator(lines[i]) || headerCols(lines[i]).missing.length) { i++; continue; }
105
+ found = true; // lines[i] is a task-table header (a | row naming all four columns)
106
+ const { ci } = headerCols(lines[i]);
107
+ let j = i + 1;
108
+ for (; j < lines.length; j++) {
109
+ const l = lines[j];
110
+ if (isHeading(l)) break; // a heading ends the table
111
+ if (l.trim() === "") continue; // a blank line inside the table does not
112
+ if (!isRow(l)) break; // prose ends the table
113
+ if (isSeparator(l)) continue;
114
+ if (headerCols(l).missing.length === 0) break; // the next table's header — reprocess it
115
+ const c = cells(l);
116
+ 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 });
118
+ }
119
+ i = j;
120
+ }
121
+ // A helpful message for the common slip: a "## Tasks" table missing one column.
122
+ let missingCols = [];
123
+ if (!found) {
124
+ for (let k = 0; k < lines.length && !missingCols.length; k++) {
125
+ if (!/^#{1,6}\s+tasks\b/i.test(lines[k].trim())) continue;
126
+ for (let m = k + 1; m < lines.length && !isHeading(lines[m]); m++) {
127
+ if (isRow(lines[m]) && !isSeparator(lines[m])) { const miss = headerCols(lines[m]).missing; if (miss.length && miss.length < REQUIRED.length) missingCols = miss; break; }
128
+ }
129
+ }
130
+ }
131
+ return { found, tasks, missingCols };
132
+ }
133
+
134
+ /** The command inside a proof cell (backticks stripped), or null for `owner`/prose/empty. */
135
+ export function proofCommand(proof) {
136
+ const m = proof.match(/`([^`]+)`/);
137
+ return m ? m[1].trim() : null;
138
+ }
139
+
140
+ /** Problems with one plan's tasks. `run` (optional) executes a proof and returns its exit code. */
141
+ export function checkPlan({ id, text, verify = false, run = null }) {
142
+ const problems = [];
143
+ const { found, tasks, missingCols } = parseTasks(text);
144
+ if (!found) {
145
+ if (missingCols.length) problems.push(`${id}: the Tasks table is missing the ${missingCols.map((c) => `"${c}"`).join(", ")} column(s)`);
146
+ else problems.push(`${id}: no readable Tasks table (needs a | id | task | status | proof | evidence | table)`);
147
+ return problems;
148
+ }
149
+ for (const t of tasks) {
150
+ if (!isCompletionClaim(t.status)) continue;
151
+ const noProof = EMPTY.test(t.proof);
152
+ const noEvidence = evidenceMissing(t.evidence);
153
+ if (noProof) problems.push(`${id} ${t.id}: status "${t.status}" names no proof (line ${t.line})`);
154
+ 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}`); }
158
+ }
159
+ }
160
+ return problems;
161
+ }
162
+
163
+ /** Plan folders under .project-management/plans/. A folder with no PLAN.md is a problem. */
164
+ export function findPlans(root) {
165
+ const dir = join(root, ".project-management", "plans");
166
+ const plans = [];
167
+ const problems = [];
168
+ if (!existsSync(dir)) return { plans, problems };
169
+ for (const name of readdirSync(dir)) {
170
+ let isDir = false;
171
+ try { isDir = statSync(join(dir, name)).isDirectory(); } catch { /* ignore */ }
172
+ if (!isDir) continue;
173
+ const p = join(dir, name, "PLAN.md");
174
+ if (existsSync(p)) plans.push({ id: name, path: p });
175
+ else problems.push(`${name}: plan folder has no PLAN.md`);
176
+ }
177
+ return { plans, problems };
178
+ }
179
+
180
+ /** Check every plan under root. Returns { plans, problems }. */
181
+ export function checkPlans({ root = ".", verify = false } = {}) {
182
+ 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;
184
+ for (const { id, path } of plans) {
185
+ let text = "";
186
+ try { text = readFileSync(path, "utf8"); } catch (e) { problems.push(`${id}: cannot read ${path} (${e.code || e.message})`); continue; }
187
+ problems.push(...checkPlan({ id, text, verify, run }));
188
+ }
189
+ return { plans, problems };
190
+ }
191
+
192
+ // --- CLI ----------------------------------------------------------------------
193
+ function flagValue(args, name) {
194
+ const eq = args.find((a) => a.startsWith(`${name}=`));
195
+ if (eq) return eq.slice(name.length + 1);
196
+ const i = args.indexOf(name);
197
+ return i !== -1 && args[i + 1] && !args[i + 1].startsWith("--") ? args[i + 1] : null;
198
+ }
199
+
200
+ if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
201
+ const args = process.argv.slice(2);
202
+ const verify = args.includes("--verify");
203
+ const root = flagValue(args, "--root") || ".";
204
+ if (!existsSync(root)) { process.stderr.write(`check-plans: --root path does not exist: ${root}\n`); process.exit(2); }
205
+ const { plans, problems } = checkPlans({ root, verify });
206
+ if (!plans.length && !problems.length) { process.stdout.write("check-plans: no plans under .project-management/plans/ — nothing to check\n"); process.exit(0); }
207
+ if (problems.length) {
208
+ process.stderr.write(`check-plans: ${problems.length} problem(s):\n${problems.map((p) => ` - ${p}`).join("\n")}\n`);
209
+ process.exit(1);
210
+ }
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`);
212
+ process.exit(0);
213
+ }