planrails 0.1.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 (40) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/LICENSE +21 -0
  3. package/README.md +225 -0
  4. package/bin/planrails.mjs +7 -0
  5. package/docs/PLANNING_GUIDE.md +632 -0
  6. package/package.json +51 -0
  7. package/src/hooks/_lib.mjs +32 -0
  8. package/src/hooks/guard-never-delete.sh +29 -0
  9. package/src/hooks/install.mjs +173 -0
  10. package/src/hooks/plan-pre-tool.mjs +71 -0
  11. package/src/hooks/plan-session-start.mjs +46 -0
  12. package/src/hooks/plan-stop.mjs +60 -0
  13. package/src/hooks/plan-subagent-start.mjs +27 -0
  14. package/src/hooks/postcompact-journal.mjs +35 -0
  15. package/src/hooks/precompact-journal.mjs +128 -0
  16. package/src/hooks/selftest.mjs +128 -0
  17. package/src/init.mjs +155 -0
  18. package/src/issue.mjs +55 -0
  19. package/src/plan/fixtures/README.md +7 -0
  20. package/src/plan/fixtures/broken-cli.mjs +27 -0
  21. package/src/plan/fixtures/broken-hooks-root/.claude/settings.json +83 -0
  22. package/src/plan/fixtures/broken-hooks-root/.project-management/plans/.gitkeep +0 -0
  23. package/src/plan/fixtures/broken-hooks-root/CLAUDE.md +9 -0
  24. package/src/plan/fixtures/broken-root/.project-management/plans/broken/PLAN.md +4 -0
  25. package/src/plan/fixtures/broken-root/.project-management/plans/broken/gates.json +1 -0
  26. package/src/plan/fixtures/broken-root/.project-management/plans/broken/rules.json +1 -0
  27. package/src/plan/fixtures/broken-root/.project-management/plans/broken/state.json +67 -0
  28. package/src/plan/fixtures/broken-root/CLAUDE.md +3 -0
  29. package/src/plan/fixtures/broken-trial.mjs +25 -0
  30. package/src/plan/lib/brief.mjs +116 -0
  31. package/src/plan/lib/claude-md.mjs +66 -0
  32. package/src/plan/lib/glob.mjs +81 -0
  33. package/src/plan/lib/judgment.mjs +19 -0
  34. package/src/plan/lib/paths.mjs +65 -0
  35. package/src/plan/lib/schema.mjs +199 -0
  36. package/src/plan/lib/store.mjs +338 -0
  37. package/src/plan/lib/time.mjs +21 -0
  38. package/src/plan/plan.mjs +843 -0
  39. package/src/plan/run.mjs +88 -0
  40. package/src/plan/skill/SKILL.md +15 -0
@@ -0,0 +1,128 @@
1
+ #!/usr/bin/env node
2
+ // PreCompact — writes what this stretch of work actually DID to disk, before
3
+ // compaction throws it away.
4
+ //
5
+ // Why this exists. Compaction keeps the user's messages and discards every tool
6
+ // result. Project instructions (CLAUDE.md) survive, because they are rebuilt each
7
+ // request; what is lost is progress state: which files were written, which
8
+ // commands and gates were run, which searches came back empty. That is the real
9
+ // cause of re-doing work after a compaction. This hook writes that record to
10
+ // .planrails/journal/<session>.md, and postcompact-journal.mjs hands the path back.
11
+ //
12
+ // Reads only the TAIL of the transcript (it can be hundreds of MB). That is
13
+ // correct, not a shortcut: this hook runs at EVERY compaction, so earlier
14
+ // stretches were already journaled by earlier runs.
15
+
16
+ import { openSync, fstatSync, readSync, closeSync, mkdirSync, appendFileSync, existsSync, readFileSync } from "node:fs";
17
+ import { join } from "node:path";
18
+
19
+ import { projectRoot, journalRoot } from "../plan/lib/paths.mjs";
20
+ const TAIL_BYTES = 12 * 1024 * 1024;
21
+ const PROJECT = projectRoot();
22
+ const OUT_DIR = journalRoot();
23
+
24
+ function readTail(path, bytes) {
25
+ const fd = openSync(path, "r");
26
+ try {
27
+ const size = fstatSync(fd).size;
28
+ const start = Math.max(0, size - bytes);
29
+ const len = size - start;
30
+ const buf = Buffer.allocUnsafe(len);
31
+ readSync(fd, buf, 0, len, start);
32
+ const text = buf.toString("utf8");
33
+ return start > 0 ? text.slice(text.indexOf("\n") + 1) : text;
34
+ } finally {
35
+ closeSync(fd);
36
+ }
37
+ }
38
+
39
+ let input = {};
40
+ try { input = JSON.parse(readFileSync(0, "utf8")); } catch { process.exit(0); }
41
+
42
+ const transcript = String(input.transcript_path || "");
43
+ const sid = String(input.session_id || "unknown").replace(/[^A-Za-z0-9_-]/g, "");
44
+ const trigger = String(input.trigger || input.matcher || "auto");
45
+
46
+ if (!transcript || !existsSync(transcript)) process.exit(0);
47
+
48
+ let lines = [];
49
+ try { lines = readTail(transcript, TAIL_BYTES).split("\n"); } catch { process.exit(0); }
50
+
51
+ const wrote = new Set(); // files created or edited
52
+ const ran = []; // gates, scripts, commits
53
+ const readNonImage = new Set();
54
+ let images = 0;
55
+ let lastUser = "";
56
+ let sinceBoundary = false;
57
+
58
+ // Walk backwards to the most recent compaction boundary, then forward from there.
59
+ let startIdx = 0;
60
+ for (let i = lines.length - 1; i >= 0; i--) {
61
+ if (lines[i].includes("compact_boundary") || lines[i].includes('"isCompactSummary":true')) { startIdx = i; break; }
62
+ }
63
+
64
+ for (let i = startIdx; i < lines.length; i++) {
65
+ const raw = lines[i];
66
+ if (!raw) continue;
67
+ let d;
68
+ try { d = JSON.parse(raw); } catch { continue; }
69
+ sinceBoundary = true;
70
+ const m = d.message || {};
71
+ // Real user intent only — skip slash-command echoes and local-command stdout,
72
+ // which are shaped like user messages but say nothing about the work.
73
+ if (m.role === "user" && typeof m.content === "string" && !d.isMeta &&
74
+ !/^\s*<(local-command|command-name|command-message|command-args)/.test(m.content)) {
75
+ lastUser = m.content.slice(0, 400);
76
+ }
77
+ const c = m.content;
78
+ if (!Array.isArray(c)) continue;
79
+ for (const b of c) {
80
+ if (!b || b.type !== "tool_use") continue;
81
+ const i_ = b.input || {};
82
+ if (b.name === "Write" || b.name === "Edit" || b.name === "NotebookEdit") {
83
+ const f = String(i_.file_path || "");
84
+ if (f) wrote.add(f.replace(PROJECT + "/", ""));
85
+ } else if (b.name === "Read") {
86
+ const f = String(i_.file_path || "");
87
+ if (/\.(png|jpe?g|webp|tiff?|gif)$/i.test(f)) images++;
88
+ else if (f) readNonImage.add(f.replace(PROJECT + "/", ""));
89
+ } else if (b.name === "Bash") {
90
+ // Match the FIRST LINE only. A heredoc body often quotes "npm run …" inside
91
+ // prose, which made the journal record documents as if they were commands.
92
+ const first = String(i_.command || "").split("\n")[0].replace(/\s+/g, " ").trim();
93
+ if (/(^|\s|&&|;)(node |npm |npx |pnpm |yarn |git commit|git push|make |cargo |go |python)/.test(first)) {
94
+ ran.push(first.slice(0, 160));
95
+ }
96
+ }
97
+ }
98
+ }
99
+
100
+ if (!sinceBoundary) process.exit(0);
101
+
102
+ const stamp = new Date().toISOString();
103
+ const out = [];
104
+ out.push(`\n## ${stamp} — before ${trigger} compaction`);
105
+ if (lastUser) out.push(`\n**Working on:** ${lastUser.replace(/\n/g, " ")}`);
106
+ const section = (title, items, cap = 40) => {
107
+ const a = [...items];
108
+ if (!a.length) return;
109
+ out.push(`\n**${title}** (${a.length})`);
110
+ for (const x of a.slice(0, cap)) out.push(`- ${x}`);
111
+ if (a.length > cap) out.push(`- …and ${a.length - cap} more`);
112
+ };
113
+ section("Files written or edited", wrote);
114
+ section("Scripts, gates and git run", ran, 25);
115
+ section("Files read (not images)", readNonImage, 30);
116
+ if (images) out.push(`\n**Images read in this thread:** ${images}`);
117
+ out.push(`\n> Written by planrails (precompact-journal.mjs). Tool results are gone after this point; this file is what survives.\n`);
118
+
119
+ try {
120
+ mkdirSync(OUT_DIR, { recursive: true });
121
+ const path = join(OUT_DIR, `${sid}.md`);
122
+ appendFileSync(path, out.join("\n"));
123
+ process.stdout.write(JSON.stringify({
124
+ systemMessage: `Progress journalled before compaction → .planrails/journal/${sid}.md`,
125
+ suppressOutput: true,
126
+ }));
127
+ } catch { /* never block a compaction */ }
128
+ process.exit(0);
@@ -0,0 +1,128 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Self-test for the plan-system hooks. Every payload shape below was captured
4
+ * from a real Claude Code 2.1.269 run on 2026-09-12 (SessionStart carries
5
+ * `source`; Stop carries `stop_hook_active` and `last_assistant_message`;
6
+ * subagents add `agent_id`). The hooks run against a throwaway project root
7
+ * with one active plan, so nothing here touches the real repository.
8
+ *
9
+ * Run: npx planrails hooks selftest (also: npx planrails hooks selftest)
10
+ */
11
+ import { execFileSync, spawnSync } from "node:child_process";
12
+ import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, appendFileSync } from "node:fs";
13
+ import { join, dirname } from "node:path";
14
+ import { tmpdir } from "node:os";
15
+ import { fileURLToPath } from "node:url";
16
+
17
+ const HERE = dirname(fileURLToPath(import.meta.url));
18
+ const box = mkdtempSync(join(tmpdir(), "plan-hooks-selftest-"));
19
+ const env = { ...process.env, PLAN_PROJECT_ROOT: box, TMPDIR: box };
20
+ const plan = join(box, ".project-management", "plans", "p1");
21
+ mkdirSync(plan, { recursive: true });
22
+ mkdirSync(join(box, "src"), { recursive: true });
23
+ writeFileSync(join(box, "CLAUDE.md"), "# t\n\n## Project management\n");
24
+ writeFileSync(join(plan, "PLAN.md"), "# p1\n\n## Why\nTo test the hooks.\n\n## Rules for this plan\n1. Rule one.\n");
25
+ writeFileSync(join(plan, "state.json"), JSON.stringify({
26
+ id: "p1", title: "hook selftest plan", status: "active", created: "2026-09-12", activatedAt: "2026-09-12T10:00:00+05:30", closedAt: null, paths: ["src/**"],
27
+ tasks: [{ id: "T1", title: "the only task", status: "doing", gate: "G1", manualCheck: null, files: [], effort: null, dependsOn: [], notes: "", startedAt: "2026-09-12T10:00:00+05:30", doneAt: null, evidence: null, blocked: null }],
28
+ }));
29
+ writeFileSync(join(plan, "gates.json"), JSON.stringify({ gates: [{ id: "G1", question: "does true exit zero in this shell", notTheSameAs: "anything real", command: "true", passWhen: "exit0", timeoutSec: 10, knownFail: null, couldPassWhileWrongIf: "true is always true, which is the point", kind: "static" }] }));
30
+ writeFileSync(join(plan, "rules.json"), JSON.stringify({ rules: [
31
+ { id: "R1", when: { tool: "Edit|Write", path: "src/**/*.ts" }, text: "RULE-MARKER-XYZ: check the thing before editing.", repeat: "once", why: "selftest", learning: null },
32
+ { id: "R2", when: { tool: "Bash", command: "npm run check" }, text: "BASH-RULE-MARKER: save the output to a file, read the exit code.", repeat: "always", why: "selftest", learning: null },
33
+ ] }));
34
+ writeFileSync(join(plan, "log.jsonl"), JSON.stringify({ at: "2026-01-01T00:00:00+05:30", session: null, task: "T1", what: "an old entry", next: "RESUME-MARKER do the next thing", refs: [], uncommitted: null }) + "\n");
35
+ for (const f of ["learnings.jsonl", "decisions.jsonl", "gate-runs.jsonl"]) writeFileSync(join(plan, f), "");
36
+
37
+ const BASE = { session_id: "sess-selftest-1", transcript_path: join(box, "t.jsonl"), cwd: box, permission_mode: "bypassPermissions" };
38
+ function run(hook, payload, opts = {}) {
39
+ const r = spawnSync(opts.runner || "node", [join(HERE, hook)], { input: JSON.stringify(payload), env, encoding: "utf8" });
40
+ return { out: r.stdout || "", err: r.stderr || "", status: r.status };
41
+ }
42
+ const results = [];
43
+ const check = (name, cond, detail = "") => { results.push([name, Boolean(cond), detail]); };
44
+
45
+ // SessionStart injects the brief
46
+ let r = run("plan-session-start.mjs", { ...BASE, hook_event_name: "SessionStart", source: "startup" });
47
+ check("SessionStart injects the active plan's brief", r.out.includes("additionalContext") && r.out.includes("Plan p1") && r.out.includes("RESUME-MARKER"), r.out.slice(0, 200) + r.err.slice(0, 300));
48
+ check("SessionStart records the session id for the CLI", existsSync(join(box, ".planrails", "hooks", "current-session.json")));
49
+
50
+ // PreToolUse: rule fires once on a matching Edit, records the edit
51
+ const edit = (file) => ({ ...BASE, hook_event_name: "PreToolUse", tool_name: "Edit", tool_input: { file_path: join(box, file), old_string: "a", new_string: "b" } });
52
+ r = run("plan-pre-tool.mjs", edit("src/a.ts"));
53
+ check("PreToolUse injects R1 on a matching Edit", r.out.includes("RULE-MARKER-XYZ") && r.out.includes("additionalContext"), r.out.slice(0, 200) + r.err.slice(0, 300));
54
+ r = run("plan-pre-tool.mjs", edit("src/a.ts"));
55
+ check("R1 (repeat once) does not fire a second time", !r.out.includes("RULE-MARKER-XYZ"), r.out);
56
+ r = run("plan-pre-tool.mjs", edit("docs/x.md"));
57
+ check("no rule fires on a path no rule names", r.out === "", r.out);
58
+ const editsPath = join(box, ".planrails", "hooks", "sess-selftest-1", "edits.jsonl");
59
+ const edits = existsSync(editsPath) ? readFileSync(editsPath, "utf8").trim().split("\n") : [];
60
+ check("edits under the plan's paths are recorded (2), the docs edit is not", edits.length === 2 && edits.every((l) => l.includes("src/a.ts")), `${edits.length}`);
61
+ r = run("plan-pre-tool.mjs", { ...BASE, hook_event_name: "PreToolUse", tool_name: "Bash", tool_input: { command: "npm run check > /tmp/x 2>&1" } });
62
+ const r2 = run("plan-pre-tool.mjs", { ...BASE, hook_event_name: "PreToolUse", tool_name: "Bash", tool_input: { command: "npm run check" } });
63
+ check("R2 (repeat always) fires on every matching Bash", r.out.includes("BASH-RULE-MARKER") && r2.out.includes("BASH-RULE-MARKER"));
64
+ r = run("plan-pre-tool.mjs", { ...BASE, hook_event_name: "PreToolUse", tool_name: "Bash", tool_input: { command: "ls" } });
65
+ check("a Bash rule does not fire on an unrelated command", r.out === "");
66
+ r = run("plan-pre-tool.mjs", { ...edit("src/b.ts"), agent_id: "agent-1", agent_type: "general-purpose" });
67
+ check("a subagent's edit is recorded and marked agent:true", readFileSync(editsPath, "utf8").includes('"agent":true'));
68
+ check("a subagent gets the once-rule even though the main thread already consumed it (dedupe is per agent)", r.out.includes("RULE-MARKER-XYZ"), r.out.slice(0, 120));
69
+ const r4 = run("plan-pre-tool.mjs", { ...edit("src/c.ts"), agent_id: "agent-1", agent_type: "general-purpose" });
70
+ check("…and only once per agent", !r4.out.includes("RULE-MARKER-XYZ"), r4.out);
71
+
72
+ // Stop: unlogged edits block once; a log entry clears it; stop_hook_active never blocks
73
+ r = run("plan-stop.mjs", { ...BASE, hook_event_name: "Stop", stop_hook_active: false, last_assistant_message: "done" });
74
+ check("Stop blocks when edits under the plan are newer than its last log entry", r.out.includes('"block"') && r.out.includes("src/a.ts") && r.out.includes("planrails log p1 --task T1"), r.out.slice(0, 300) + r.err.slice(0, 300));
75
+ r = run("plan-stop.mjs", { ...BASE, hook_event_name: "Stop", stop_hook_active: false, last_assistant_message: "done" });
76
+ check("Stop does not nag twice for the same unlogged stretch", r.out === "", r.out);
77
+ r = run("plan-stop.mjs", { ...BASE, hook_event_name: "Stop", stop_hook_active: true, last_assistant_message: "done" });
78
+ check("Stop with stop_hook_active never blocks", r.out === "");
79
+ appendFileSync(join(plan, "log.jsonl"), JSON.stringify({ at: new Date(Date.now() + 60_000).toISOString(), session: null, task: "T1", what: "logged the edits", next: "carry on", refs: [], uncommitted: null }) + "\n");
80
+ r = run("plan-stop.mjs", { ...BASE, session_id: "sess-selftest-2", hook_event_name: "Stop", stop_hook_active: false });
81
+ check("Stop allows once a log entry is newer than the edits", r.out === "", r.out);
82
+
83
+ // SubagentStart: the do-nots reach a subagent; silent with no active plan
84
+ r = run("plan-subagent-start.mjs", { ...BASE, hook_event_name: "SubagentStart", agent_id: "afa51b8bffc11199f", agent_type: "general-purpose" });
85
+ check("SubagentStart injects the subagent note naming the plan's reports directory", r.out.includes("SUBAGENT NOTE") && r.out.includes("plans/p1/reports/") && r.out.includes("never run"), r.out.slice(0, 200) + r.err.slice(0, 200));
86
+ check("the subagent note stays under 900 chars", (JSON.parse(r.out || "{}").hookSpecificOutput?.additionalContext || "").length < 900);
87
+
88
+ // compaction resets once-per-session injections
89
+ r = run("plan-session-start.mjs", { ...BASE, hook_event_name: "SessionStart", source: "compact" });
90
+ check("SessionStart(compact) re-injects the brief and says tool results are gone", r.out.includes("Plan p1") && r.out.includes("compacted"));
91
+ r = run("plan-pre-tool.mjs", edit("src/a.ts"));
92
+ check("after compaction, a once-rule fires again", r.out.includes("RULE-MARKER-XYZ"), r.out.slice(0, 100));
93
+
94
+ // no active plan → hooks are silent
95
+ writeFileSync(join(plan, "state.json"), readFileSync(join(plan, "state.json"), "utf8").replace('"status":"active"', '"status":"paused"'));
96
+ r = run("plan-session-start.mjs", { ...BASE, session_id: "sess-3", hook_event_name: "SessionStart", source: "startup" });
97
+ const r3 = run("plan-pre-tool.mjs", { ...edit("src/a.ts"), session_id: "sess-3" });
98
+ const r5 = run("plan-subagent-start.mjs", { ...BASE, session_id: "sess-3", hook_event_name: "SubagentStart", agent_id: "x", agent_type: "general-purpose" });
99
+ check("with no active plan, SessionStart, PreToolUse and SubagentStart output nothing", r.out === "" && r3.out === "" && r5.out === "");
100
+
101
+ // the optional never-delete guard (needs jq). The delete command is
102
+ // assembled at runtime so this file never contains it as a word: the hook under
103
+ // test fires on the WORD, wherever it appears — including inside a heredoc that
104
+ // writes this file.
105
+ if (spawnSync("which", ["jq"]).status === 0) {
106
+ const deleteCmd = ["r", "m"].join("") + " -rf build/x";
107
+ r = run("guard-never-delete.sh", { ...BASE, hook_event_name: "PreToolUse", tool_name: "Bash", tool_input: { command: deleteCmd } }, { runner: "bash" });
108
+ const ok = run("guard-never-delete.sh", { ...BASE, hook_event_name: "PreToolUse", tool_name: "Bash", tool_input: { command: "npm run check" } }, { runner: "bash" });
109
+ check("the never-delete guard denies a delete command and allows npm", r.out.includes('"deny"') && ok.out === "");
110
+ } else check("never-delete guard: jq missing — SKIPPED (brew install jq)", true);
111
+
112
+ // journal pair
113
+ const transcript = join(box, "t.jsonl");
114
+ writeFileSync(transcript, [
115
+ JSON.stringify({ message: { role: "user", content: "Working on the selftest" } }),
116
+ JSON.stringify({ message: { role: "assistant", content: [{ type: "tool_use", name: "Edit", input: { file_path: join(box, "src/a.ts") } }] } }),
117
+ JSON.stringify({ message: { role: "assistant", content: [{ type: "tool_use", name: "Bash", input: { command: "npx planrails validate" } }] } }),
118
+ ].join("\n") + "\n");
119
+ r = run("precompact-journal.mjs", { ...BASE, hook_event_name: "PreCompact", trigger: "auto" });
120
+ const journal = join(box, ".planrails", "journal", "sess-selftest-1.md");
121
+ check("PreCompact writes the session journal with the edited file and the command", existsSync(journal) && readFileSync(journal, "utf8").includes("src/a.ts") && readFileSync(journal, "utf8").includes("planrails validate"), r.out + r.err.slice(0, 200));
122
+ r = run("postcompact-journal.mjs", { ...BASE, hook_event_name: "PostCompact" });
123
+ check("PostCompact hands back the journal path", r.out.includes(".planrails/journal/sess-selftest-1.md"));
124
+
125
+ let bad = 0;
126
+ for (const [name, ok, detail] of results) { if (!ok) bad++; process.stdout.write(`${ok ? "ok " : "FAIL"} ${name}${ok ? "" : ` — ${detail}`}\n`); }
127
+ process.stdout.write(bad ? `\nhooks selftest: ${bad} FAILED\n` : `\nhooks selftest: ${results.length} checks pass\n`);
128
+ process.exit(bad ? 1 : 0);
package/src/init.mjs ADDED
@@ -0,0 +1,155 @@
1
+ /**
2
+ * `planrails init` — set a project up, new or existing, without breaking anything
3
+ * that is already there. Idempotent: run it twice and the second run changes
4
+ * nothing and says so.
5
+ *
6
+ * What it does, in order:
7
+ * 1. finds the project root (the cwd, or --dir)
8
+ * 2. writes a minimal package.json if there is none
9
+ * 3. installs planrails as a devDependency, unless it already is (or --no-install)
10
+ * 4. copies docs/PLANNING_GUIDE.md into the project (kept if it already exists; --force overwrites)
11
+ * 5. creates .project-management/plans/
12
+ * 6. creates CLAUDE.md, or adds a "## Project management" section to the existing one,
13
+ * and puts the generated plans block above it
14
+ * 7. adds npm scripts: plan, plan:doctor, plan:brief, plan:validate — and appends
15
+ * the validator to an existing "check" script
16
+ * 8. adds .planrails/ to .gitignore
17
+ * 9. installs the hooks into .claude/settings.json and the /plan skill
18
+ * 10. runs doctor
19
+ */
20
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, copyFileSync, appendFileSync } from "node:fs";
21
+ import { join, resolve, basename } from "node:path";
22
+ import { spawnSync } from "node:child_process";
23
+ import { projectRoot, packageRoot, claudeMdPath, plansRoot, cliName } from "./plan/lib/paths.mjs";
24
+ import { writeBlock, readBlock } from "./plan/lib/claude-md.mjs";
25
+
26
+ const PM_SECTION = `## Project management
27
+
28
+ Multi-session work is tracked as **plans** under \`.project-management/plans/<id>/\`
29
+ (planrails). Create one with \`/plan <raw plan>\`. A SessionStart hook injects every
30
+ active plan's brief at the start of a session and after every compaction;
31
+ \`npx planrails brief\` prints it. A task is done only when \`npx planrails task done\`
32
+ has run its gate and every condition of done is answered. Judgment outranks the
33
+ gate: never make a red gate pass, never trust a green one blind. Guide:
34
+ \`docs/PLANNING_GUIDE.md\`.
35
+ `;
36
+
37
+ function pkgVersion() { return JSON.parse(readFileSync(join(packageRoot(), "package.json"), "utf8")).version; }
38
+ function readJson(p, fallback) { try { return JSON.parse(readFileSync(p, "utf8")); } catch { return fallback; } }
39
+
40
+ export async function init({ dir = null, force = false, noInstall = false, withNeverDelete = false, dryRun = false, log = console.log } = {}) {
41
+ if (dir) process.env.PLAN_PROJECT_ROOT = resolve(dir);
42
+ const root = projectRoot();
43
+ const cli = cliName();
44
+ const did = []; const kept = [];
45
+ const note = (changed, text) => (changed ? did : kept).push(text);
46
+ const write = (p, text) => { if (!dryRun) writeFileSync(p, text); };
47
+ log(`planrails ${pkgVersion()} → ${root}${dryRun ? " (dry run: nothing is written)" : ""}`);
48
+ mkdirSync(root, { recursive: true });
49
+
50
+ // 2. package.json
51
+ const pkgPath = join(root, "package.json");
52
+ let pkg = readJson(pkgPath, null);
53
+ if (!pkg) { pkg = { name: basename(root).toLowerCase().replace(/[^a-z0-9._-]+/g, "-") || "project", version: "0.0.0", private: true }; write(pkgPath, JSON.stringify(pkg, null, 2) + "\n"); note(true, "wrote a minimal package.json"); }
54
+ else note(false, "package.json exists");
55
+
56
+ // 3. the devDependency
57
+ const installed = existsSync(join(root, "node_modules", "planrails", "package.json"));
58
+ const declared = Boolean(pkg.devDependencies?.planrails || pkg.dependencies?.planrails);
59
+ if (installed && declared) note(false, `planrails is a dependency (${readJson(join(root, "node_modules", "planrails", "package.json"), {}).version || "?"})`);
60
+ else if (noInstall || dryRun) note(false, `planrails not installed as a dependency (${noInstall ? "--no-install" : "dry run"}); hooks will use this copy's absolute path`);
61
+ else {
62
+ log(`installing planrails@${pkgVersion()} as a devDependency…`);
63
+ const r = spawnSync("npm", ["install", "--save-dev", `planrails@${pkgVersion()}`], { cwd: root, stdio: "inherit" });
64
+ if (r.status !== 0) { log(`npm install failed (exit ${r.status}). Fix that, or run with --no-install to continue with absolute hook paths.`); return 1; }
65
+ note(true, `installed planrails@${pkgVersion()} as a devDependency`);
66
+ pkg = readJson(pkgPath, pkg);
67
+ }
68
+
69
+ // 4. the guide
70
+ const guideSrc = join(packageRoot(), "docs", "PLANNING_GUIDE.md");
71
+ const guideDest = join(root, "docs", "PLANNING_GUIDE.md");
72
+ if (!existsSync(guideDest)) { if (!dryRun) { mkdirSync(join(root, "docs"), { recursive: true }); copyFileSync(guideSrc, guideDest); } note(true, "copied docs/PLANNING_GUIDE.md"); }
73
+ else if (readFileSync(guideDest, "utf8") === readFileSync(guideSrc, "utf8")) note(false, "docs/PLANNING_GUIDE.md is current");
74
+ else if (force) { if (!dryRun) copyFileSync(guideSrc, guideDest); note(true, "overwrote docs/PLANNING_GUIDE.md (--force)"); }
75
+ else note(false, `docs/PLANNING_GUIDE.md differs from this version's copy — keep yours, or refresh it with: ${cli} update`);
76
+
77
+ // 5. plans directory
78
+ const plans = plansRoot();
79
+ if (!existsSync(plans)) { if (!dryRun) { mkdirSync(plans, { recursive: true }); writeFileSync(join(plans, ".gitkeep"), ""); } note(true, "created .project-management/plans/"); }
80
+ else note(false, ".project-management/plans/ exists");
81
+
82
+ // 6. CLAUDE.md
83
+ const md = claudeMdPath();
84
+ if (!existsSync(md)) { write(md, `# CLAUDE.md\n\nInstructions for AI agents working in this repository. Keep this file short; put depth in docs/.\n\n${PM_SECTION}`); note(true, "created CLAUDE.md with a Project management section"); }
85
+ else {
86
+ const text = readFileSync(md, "utf8");
87
+ if (!/^## Project management\s*$/m.test(text)) { write(md, `${text.replace(/\s*$/, "")}\n\n${PM_SECTION}`); note(true, "added a Project management section to CLAUDE.md"); }
88
+ else note(false, "CLAUDE.md has a Project management section");
89
+ }
90
+ if (!dryRun) {
91
+ const before = existsSync(md) ? readFileSync(md, "utf8") : "";
92
+ if (!readBlock(before)) {
93
+ // Reflect the plans that already exist (an existing project may have some).
94
+ const active = [];
95
+ try { const { activePlanIds, loadPlanCheap } = await import("./plan/lib/store.mjs"); for (const id of activePlanIds()) { const p = loadPlanCheap(id); active.push({ id, title: p.state?.title || id }); } } catch { /* none */ }
96
+ writeBlock(md, active);
97
+ note(true, "added the generated plans block to CLAUDE.md");
98
+ } else note(false, "CLAUDE.md has the plans block");
99
+ }
100
+
101
+ // 7. npm scripts
102
+ pkg = readJson(pkgPath, pkg);
103
+ pkg.scripts = pkg.scripts || {};
104
+ const want = { plan: "planrails", "plan:doctor": "planrails doctor", "plan:brief": "planrails brief", "plan:validate": "planrails validate --all --quiet" };
105
+ let scriptsChanged = false;
106
+ for (const [k, v] of Object.entries(want)) if (!pkg.scripts[k]) { pkg.scripts[k] = v; scriptsChanged = true; }
107
+ if (pkg.scripts.check && !/planrails validate/.test(pkg.scripts.check)) { pkg.scripts.check = `${pkg.scripts.check} && planrails validate --all --quiet`; scriptsChanged = true; }
108
+ if (scriptsChanged) { write(pkgPath, JSON.stringify(pkg, null, 2) + "\n"); note(true, `added npm scripts (plan, plan:doctor, plan:brief, plan:validate${pkg.scripts.check ? "; validator appended to check" : ""})`); }
109
+ else note(false, "npm scripts present");
110
+
111
+ // 8. .gitignore
112
+ const gi = join(root, ".gitignore");
113
+ const giText = existsSync(gi) ? readFileSync(gi, "utf8") : "";
114
+ if (!/^\.planrails\/?\s*$/m.test(giText)) { if (!dryRun) appendFileSync(gi, `${giText && !giText.endsWith("\n") ? "\n" : ""}.planrails/\n`); note(true, "added .planrails/ to .gitignore"); }
115
+ else note(false, ".gitignore ignores .planrails/");
116
+
117
+ // 9. hooks + skill
118
+ const { install, status } = await import("./hooks/install.mjs");
119
+ const r = install({ dryRun, withNeverDelete });
120
+ note(r.changed, r.changed ? "wrote the hooks into .claude/settings.json" : "hooks already installed");
121
+
122
+ log("");
123
+ for (const d of did) log(` + ${d}`);
124
+ for (const k of kept) log(` = ${k}`);
125
+ if (dryRun) { log("\n(dry run) nothing was written."); return 0; }
126
+ // 10. doctor
127
+ const { problems } = status({ print: false });
128
+ log(problems ? `\ndoctor: ${problems} problem(s) — run: ${cli} doctor` : "\ndoctor: healthy");
129
+ log(`\nNext: restart Claude Code (or run /hooks), then type: /plan <your raw plan>\nGuide: docs/PLANNING_GUIDE.md · commands: ${cli} --help`);
130
+ return problems ? 1 : 0;
131
+ }
132
+
133
+ /** Refresh the guide, the skill and the hook entries to this version. Plans are never touched. */
134
+ export async function update({ dir = null, log = console.log } = {}) {
135
+ if (dir) process.env.PLAN_PROJECT_ROOT = resolve(dir);
136
+ const root = projectRoot();
137
+ const guideSrc = join(packageRoot(), "docs", "PLANNING_GUIDE.md");
138
+ const guideDest = join(root, "docs", "PLANNING_GUIDE.md");
139
+ if (!existsSync(guideDest) || readFileSync(guideDest, "utf8") !== readFileSync(guideSrc, "utf8")) { mkdirSync(join(root, "docs"), { recursive: true }); copyFileSync(guideSrc, guideDest); log("refreshed docs/PLANNING_GUIDE.md"); }
140
+ else log("docs/PLANNING_GUIDE.md is current");
141
+ const { install, status } = await import("./hooks/install.mjs");
142
+ install({});
143
+ const { problems } = status({ print: false });
144
+ log(problems ? `doctor: ${problems} problem(s) — run: ${cliName()} doctor` : "doctor: healthy");
145
+ return problems ? 1 : 0;
146
+ }
147
+
148
+ /** Remove the hooks and the skill. Plans, the guide, CLAUDE.md and package.json are left as they are. */
149
+ export async function uninstall({ dir = null, log = console.log } = {}) {
150
+ if (dir) process.env.PLAN_PROJECT_ROOT = resolve(dir);
151
+ const { install } = await import("./hooks/install.mjs");
152
+ install({ remove: true });
153
+ log(`Left in place on purpose: .project-management/plans/, docs/PLANNING_GUIDE.md, the CLAUDE.md section and block, the npm scripts. Remove those by hand if you want them gone; npm uninstall planrails removes the package.`);
154
+ return 0;
155
+ }
package/src/issue.mjs ADDED
@@ -0,0 +1,55 @@
1
+ /**
2
+ * `planrails issue [bug|wish|edge] [--title "…"] [--print] [--gh]`
3
+ *
4
+ * Opens a prefilled GitHub issue for this project's planrails install: the
5
+ * version, Node, the platform, the Claude Code version, and what doctor and
6
+ * validate say — never file contents, never plan text. Opens the browser by
7
+ * default; --print prints the URL; --gh uses the GitHub CLI instead.
8
+ */
9
+ import { readFileSync } from "node:fs";
10
+ import { join } from "node:path";
11
+ import { spawnSync } from "node:child_process";
12
+ import { packageRoot, projectRoot } from "./plan/lib/paths.mjs";
13
+
14
+ export const REPO = "vivmagarwal/planrails";
15
+ const TEMPLATES = { bug: "bug.yml", wish: "wish.yml", edge: "edge-case.yml" };
16
+
17
+ function versionOf(cmd, args) { try { const r = spawnSync(cmd, args, { encoding: "utf8", timeout: 4000 }); return r.status === 0 ? r.stdout.trim().split("\n")[0] : null; } catch { return null; } }
18
+
19
+ export async function environment() {
20
+ const pkg = JSON.parse(readFileSync(join(packageRoot(), "package.json"), "utf8"));
21
+ const lines = [`planrails ${pkg.version}`, `node ${process.version} · ${process.platform} ${process.arch}`, `claude: ${versionOf("claude", ["--version"]) || "not on PATH"}`];
22
+ try {
23
+ const { status } = await import("./hooks/install.mjs");
24
+ const s = status({ print: false });
25
+ lines.push(`hooks: ${s.problems ? `${s.problems} problem(s)` : "ok"} — ${s.lines.filter((l) => !l.ok).map((l) => l.text.replace(projectRoot(), "<project>")).slice(0, 4).join(" | ") || "all installed"}`);
26
+ } catch (e) { lines.push(`hooks: could not check (${e.message})`); }
27
+ try {
28
+ const { listPlanIds, loadPlan, validatePlan } = await import("./plan/lib/store.mjs");
29
+ const ids = listPlanIds();
30
+ let errors = 0, warnings = 0; const first = [];
31
+ for (const id of ids) { try { const v = validatePlan(loadPlan(id)); errors += v.errors.length; warnings += v.warnings.length; for (const e of v.errors.slice(0, 2)) first.push(e); } catch (e) { errors++; first.push(`${id}: ${e.message}`); } }
32
+ lines.push(`plans: ${ids.length} — ${errors} error(s), ${warnings} warning(s)${first.length ? ` — e.g. ${first.slice(0, 3).join(" | ")}` : ""}`);
33
+ } catch (e) { lines.push(`plans: could not validate (${e.message})`); }
34
+ return lines.join("\n");
35
+ }
36
+
37
+ export async function issue({ kind = "bug", title = "", print = false, gh = false, log = console.log } = {}) {
38
+ const template = TEMPLATES[kind];
39
+ if (!template) { log(`issue kind must be one of: ${Object.keys(TEMPLATES).join(", ")}`); return 2; }
40
+ const env = await environment();
41
+ const params = new URLSearchParams({ template, title: title || "", environment: env });
42
+ const url = `https://github.com/${REPO}/issues/new?${params.toString()}`;
43
+ if (gh) {
44
+ const body = `### Environment\n\n\`\`\`\n${env}\n\`\`\`\n\n### What happened / what you wish\n\n(fill in)\n\n### Steps or the case\n\n(fill in)\n`;
45
+ const r = spawnSync("gh", ["issue", "create", "--repo", REPO, "--title", title || `${kind}: (describe)`, "--body", body, "--label", kind === "bug" ? "bug" : kind === "wish" ? "enhancement" : "edge-case"], { stdio: "inherit" });
46
+ if (r.status === 0) return 0;
47
+ log(`gh failed (exit ${r.status}); here is the URL instead:`);
48
+ }
49
+ log(url);
50
+ if (print || gh) return 0;
51
+ const opener = process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["cmd", ["/c", "start", "", url]] : ["xdg-open", [url]];
52
+ const r = spawnSync(opener[0], opener[1], { stdio: "ignore" });
53
+ if (r.status !== 0) log("(could not open a browser — copy the URL above)");
54
+ return 0;
55
+ }
@@ -0,0 +1,7 @@
1
+ # Plan-system fixtures
2
+
3
+ `broken-root/` is a project root whose one plan LIES: task T1 is `done` citing a
4
+ gate run that is not in `gate-runs.jsonl`. It is the known-fail case for the
5
+ validator gate — `PLAN_PROJECT_ROOT=node_modules/planrails/src/plan/fixtures/broken-root node
6
+ src/plan/plan.mjs validate --all --quiet` must exit 1. If it ever exits 0,
7
+ the validator has stopped seeing fabricated evidence.
@@ -0,0 +1,27 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Builds a deliberately BROKEN copy of the plan CLI at .tmp/broken-cli/: the validator no
4
+ * longer refuses a task that cites a gate run which never happened. Gate G3's known-fail
5
+ * case runs the vitest suite against this copy (PLAN_CLI=.tmp/broken-cli/plan.mjs) and the
6
+ * suite must FAIL — that is what proves the suite can see a validator regression, instead of
7
+ * "vitest exits 1 on a missing file", which proved nothing about the gate.
8
+ * Refuses to build if the line it removes is not found (the fixture would then test nothing).
9
+ */
10
+ import { cpSync, mkdirSync, readFileSync, writeFileSync, existsSync } from "node:fs";
11
+ import { join, dirname } from "node:path";
12
+ import { fileURLToPath } from "node:url";
13
+ const src = join(dirname(fileURLToPath(import.meta.url)), "..");
14
+ const root = join(src, "..", "..");
15
+ const out = join(root, ".tmp", "broken-cli");
16
+ mkdirSync(out, { recursive: true });
17
+ for (const f of ["plan.mjs", "run.mjs"]) cpSync(join(src, f), join(out, f));
18
+ cpSync(join(src, "lib"), join(out, "lib"), { recursive: true });
19
+ cpSync(join(src, "skill"), join(out, "skill"), { recursive: true });
20
+ const storePath = join(out, "lib", "store.mjs");
21
+ let store = readFileSync(storePath, "utf8");
22
+ const anchor = "if (!run) E(`${t.id} cites gate run ${t.evidence.runId}, but gate-runs.jsonl has no passing run with that id`);";
23
+ if (!store.includes(anchor)) { console.error("broken-cli: the refusal this fixture removes was not found in store.mjs — update the fixture"); process.exit(3); }
24
+ store = store.replace(anchor, "if (!run) { /* BROKEN ON PURPOSE: a fabricated run is accepted */ }");
25
+ writeFileSync(storePath, store);
26
+ if (!existsSync(join(out, "plan.mjs"))) process.exit(3);
27
+ console.log(`broken CLI built at ${out} (validator accepts a fabricated gate run)`);
@@ -0,0 +1,83 @@
1
+ {
2
+ "hooks": {
3
+ "SessionStart": [
4
+ {
5
+ "hooks": [
6
+ {
7
+ "type": "command",
8
+ "command": "node \"/nonexistent-machine/src/hooks/plan-session-start.mjs\""
9
+ }
10
+ ]
11
+ }
12
+ ],
13
+ "PreToolUse": [
14
+ {
15
+ "matcher": "Bash",
16
+ "hooks": [
17
+ {
18
+ "type": "command",
19
+ "command": "bash \"/nonexistent-machine/src/hooks/guard-never-delete.sh\""
20
+ }
21
+ ]
22
+ },
23
+ {
24
+ "matcher": "Read",
25
+ "hooks": [
26
+ {
27
+ "type": "command",
28
+ "command": "node \"/nonexistent-machine/src/hooks/guard-read.mjs\""
29
+ }
30
+ ]
31
+ },
32
+ {
33
+ "matcher": "Edit|Write|MultiEdit|NotebookEdit|Bash|Read|Agent|Skill|Workflow",
34
+ "hooks": [
35
+ {
36
+ "type": "command",
37
+ "command": "node \"/nonexistent-machine/src/hooks/plan-pre-tool.mjs\""
38
+ }
39
+ ]
40
+ }
41
+ ],
42
+ "SubagentStart": [
43
+ {
44
+ "hooks": [
45
+ {
46
+ "type": "command",
47
+ "command": "node \"/nonexistent-machine/src/hooks/plan-subagent-start.mjs\""
48
+ }
49
+ ]
50
+ }
51
+ ],
52
+ "Stop": [
53
+ {
54
+ "hooks": [
55
+ {
56
+ "type": "command",
57
+ "command": "node \"/nonexistent-machine/src/hooks/plan-stop.mjs\""
58
+ }
59
+ ]
60
+ }
61
+ ],
62
+ "PreCompact": [
63
+ {
64
+ "hooks": [
65
+ {
66
+ "type": "command",
67
+ "command": "node \"/nonexistent-machine/src/hooks/precompact-journal.mjs\""
68
+ }
69
+ ]
70
+ }
71
+ ],
72
+ "PostCompact": [
73
+ {
74
+ "hooks": [
75
+ {
76
+ "type": "command",
77
+ "command": "node \"/nonexistent-machine/src/hooks/postcompact-journal.mjs\""
78
+ }
79
+ ]
80
+ }
81
+ ]
82
+ }
83
+ }
@@ -0,0 +1,9 @@
1
+ # fixture — a project whose hooks point at files that do not exist
2
+
3
+ <!-- plans:begin — generated by src/plan/plan.mjs; do not hand-edit this block -->
4
+ ## Active plans
5
+
6
+ _No active plans. Create one with `/plan <raw plan>` or `npx planrails new <id> --title "…"` (docs/PLANNING_GUIDE.md)._
7
+ <!-- plans:end -->
8
+
9
+ ## Project management
@@ -0,0 +1,4 @@
1
+ # broken
2
+
3
+ ## Why
4
+ This fixture exists so `plan validate` has a known-fail case: its one task claims a gate run that never happened.
@@ -0,0 +1 @@
1
+ { "gates": [ { "id": "G1", "question": "does the fixture command exit zero", "notTheSameAs": "anything about a real system", "command": "true", "passWhen": "exit0", "timeoutSec": 10, "knownFail": null, "couldPassWhileWrongIf": "it is a fixture; it always passes", "kind": "static" } ] }