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.
- package/CHANGELOG.md +69 -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 +213 -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,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
|
+
}
|