planrails 0.1.2 → 0.2.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.
Files changed (41) hide show
  1. package/CHANGELOG.md +54 -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 +199 -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,199 @@
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
+ /**
84
+ * Parse the task rows of a plan. Scans EVERY "## Tasks" section; within a
85
+ * section it collects all table rows to the next heading, so a blank line in the
86
+ * middle does not end the table and a second table is not lost. Columns are
87
+ * matched by name, in any order. Returns { found, tasks, missingCols }.
88
+ */
89
+ export function parseTasks(text) {
90
+ const lines = text.split(/\r?\n/);
91
+ let found = false;
92
+ const tasks = [];
93
+ const missingCols = new Set();
94
+ for (let i = 0; i < lines.length; i++) {
95
+ if (!/^#{1,6}\s+tasks\b/i.test(lines[i].trim())) continue;
96
+ const rows = [];
97
+ let j = i + 1;
98
+ for (; j < lines.length; j++) {
99
+ if (isHeading(lines[j])) break;
100
+ if (isRow(lines[j])) rows.push({ text: lines[j], line: j + 1 });
101
+ }
102
+ i = j - 1;
103
+ if (!rows.length) continue;
104
+ const header = cells(rows[0].text).map((h) => h.toLowerCase());
105
+ const ci = { id: header.indexOf("id"), task: header.indexOf("task"), status: header.indexOf("status"), proof: header.indexOf("proof"), evidence: header.indexOf("evidence") };
106
+ const missing = ["id", "status", "proof", "evidence"].filter((k) => ci[k] === -1);
107
+ if (missing.length) { missing.forEach((m) => missingCols.add(m)); continue; }
108
+ found = true;
109
+ for (const row of rows.slice(1)) {
110
+ if (isSeparator(row.text)) continue;
111
+ const c = cells(row.text);
112
+ const at = (idx) => (idx >= 0 && idx < c.length ? c[idx] : "");
113
+ if (norm(at(ci.status)) === "status" && norm(at(ci.id)) === "id") continue; // a repeated header row
114
+ tasks.push({ id: at(ci.id), task: at(ci.task), status: at(ci.status), proof: at(ci.proof), evidence: at(ci.evidence), line: row.line });
115
+ }
116
+ }
117
+ return { found, tasks, missingCols: [...missingCols] };
118
+ }
119
+
120
+ /** The command inside a proof cell (backticks stripped), or null for `owner`/prose/empty. */
121
+ export function proofCommand(proof) {
122
+ const m = proof.match(/`([^`]+)`/);
123
+ return m ? m[1].trim() : null;
124
+ }
125
+
126
+ /** Problems with one plan's tasks. `run` (optional) executes a proof and returns its exit code. */
127
+ export function checkPlan({ id, text, verify = false, run = null }) {
128
+ const problems = [];
129
+ const { found, tasks, missingCols } = parseTasks(text);
130
+ if (!found) {
131
+ if (missingCols.length) problems.push(`${id}: the Tasks table is missing the ${missingCols.map((c) => `"${c}"`).join(", ")} column(s)`);
132
+ else problems.push(`${id}: no readable Tasks table (needs a | id | task | status | proof | evidence | table under a "## Tasks" heading)`);
133
+ return problems;
134
+ }
135
+ for (const t of tasks) {
136
+ if (!isCompletionClaim(t.status)) continue;
137
+ const noProof = EMPTY.test(t.proof);
138
+ const noEvidence = evidenceMissing(t.evidence);
139
+ if (noProof) problems.push(`${id} ${t.id}: status "${t.status}" names no proof (line ${t.line})`);
140
+ 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})`);
141
+ if (verify && !noProof && !noEvidence && run) {
142
+ const cmd = proofCommand(t.proof);
143
+ if (cmd) { const code = run(cmd); if (code !== 0) problems.push(`${id} ${t.id}: proof re-run failed — \`${cmd}\` exited ${code}`); }
144
+ }
145
+ }
146
+ return problems;
147
+ }
148
+
149
+ /** Plan folders under .project-management/plans/. A folder with no PLAN.md is a problem. */
150
+ export function findPlans(root) {
151
+ const dir = join(root, ".project-management", "plans");
152
+ const plans = [];
153
+ const problems = [];
154
+ if (!existsSync(dir)) return { plans, problems };
155
+ for (const name of readdirSync(dir)) {
156
+ let isDir = false;
157
+ try { isDir = statSync(join(dir, name)).isDirectory(); } catch { /* ignore */ }
158
+ if (!isDir) continue;
159
+ const p = join(dir, name, "PLAN.md");
160
+ if (existsSync(p)) plans.push({ id: name, path: p });
161
+ else problems.push(`${name}: plan folder has no PLAN.md`);
162
+ }
163
+ return { plans, problems };
164
+ }
165
+
166
+ /** Check every plan under root. Returns { plans, problems }. */
167
+ export function checkPlans({ root = ".", verify = false } = {}) {
168
+ const { plans, problems } = findPlans(root);
169
+ const run = verify ? (cmd) => { const r = spawnSync(cmd, { cwd: root, shell: true, stdio: "ignore" }); return r.status ?? 1; } : null;
170
+ for (const { id, path } of plans) {
171
+ let text = "";
172
+ try { text = readFileSync(path, "utf8"); } catch (e) { problems.push(`${id}: cannot read ${path} (${e.code || e.message})`); continue; }
173
+ problems.push(...checkPlan({ id, text, verify, run }));
174
+ }
175
+ return { plans, problems };
176
+ }
177
+
178
+ // --- CLI ----------------------------------------------------------------------
179
+ function flagValue(args, name) {
180
+ const eq = args.find((a) => a.startsWith(`${name}=`));
181
+ if (eq) return eq.slice(name.length + 1);
182
+ const i = args.indexOf(name);
183
+ return i !== -1 && args[i + 1] && !args[i + 1].startsWith("--") ? args[i + 1] : null;
184
+ }
185
+
186
+ if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
187
+ const args = process.argv.slice(2);
188
+ const verify = args.includes("--verify");
189
+ const root = flagValue(args, "--root") || ".";
190
+ if (!existsSync(root)) { process.stderr.write(`check-plans: --root path does not exist: ${root}\n`); process.exit(2); }
191
+ const { plans, problems } = checkPlans({ root, verify });
192
+ if (!plans.length && !problems.length) { process.stdout.write("check-plans: no plans under .project-management/plans/ — nothing to check\n"); process.exit(0); }
193
+ if (problems.length) {
194
+ process.stderr.write(`check-plans: ${problems.length} problem(s):\n${problems.map((p) => ` - ${p}`).join("\n")}\n`);
195
+ process.exit(1);
196
+ }
197
+ process.stdout.write(`check-plans: ${plans.length} plan(s) ok — every completion claim has a proof and pasted evidence${verify ? " (proofs re-run)" : ""}\n`);
198
+ process.exit(0);
199
+ }