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.
- package/CHANGELOG.md +54 -16
- package/PLANNER.md +255 -0
- package/README.md +103 -176
- package/bin/planrails.mjs +90 -5
- package/package.json +14 -48
- package/tools/check-plans.mjs +199 -0
- package/docs/PLANNING_GUIDE.md +0 -632
- package/src/hooks/_lib.mjs +0 -32
- package/src/hooks/guard-never-delete.sh +0 -29
- package/src/hooks/install.mjs +0 -179
- package/src/hooks/plan-pre-tool.mjs +0 -71
- package/src/hooks/plan-session-start.mjs +0 -46
- package/src/hooks/plan-stop.mjs +0 -60
- package/src/hooks/plan-subagent-start.mjs +0 -27
- package/src/hooks/postcompact-journal.mjs +0 -35
- package/src/hooks/precompact-journal.mjs +0 -128
- package/src/hooks/selftest.mjs +0 -128
- package/src/init.mjs +0 -161
- package/src/issue.mjs +0 -55
- package/src/plan/fixtures/README.md +0 -7
- package/src/plan/fixtures/broken-cli.mjs +0 -27
- package/src/plan/fixtures/broken-hooks-root/.claude/settings.json +0 -83
- package/src/plan/fixtures/broken-hooks-root/.project-management/plans/.gitkeep +0 -0
- package/src/plan/fixtures/broken-hooks-root/CLAUDE.md +0 -9
- package/src/plan/fixtures/broken-root/.project-management/plans/broken/PLAN.md +0 -4
- package/src/plan/fixtures/broken-root/.project-management/plans/broken/gates.json +0 -1
- package/src/plan/fixtures/broken-root/.project-management/plans/broken/rules.json +0 -1
- package/src/plan/fixtures/broken-root/.project-management/plans/broken/state.json +0 -67
- package/src/plan/fixtures/broken-root/CLAUDE.md +0 -3
- package/src/plan/fixtures/broken-trial.mjs +0 -25
- package/src/plan/lib/brief.mjs +0 -116
- package/src/plan/lib/claude-md.mjs +0 -68
- package/src/plan/lib/glob.mjs +0 -81
- package/src/plan/lib/judgment.mjs +0 -19
- package/src/plan/lib/paths.mjs +0 -65
- package/src/plan/lib/schema.mjs +0 -199
- package/src/plan/lib/store.mjs +0 -338
- package/src/plan/lib/time.mjs +0 -21
- package/src/plan/plan.mjs +0 -843
- package/src/plan/run.mjs +0 -88
- 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
|
+
}
|