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
@@ -1,128 +0,0 @@
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 DELETED
@@ -1,161 +0,0 @@
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 name the project's own copy if it has one, else 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, the hook entries and the CLAUDE.md plans block 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
- // The plans block, rewritten from the plans on disk: a marker or wording written by an older version is replaced.
142
- try {
143
- const { activePlanIds, loadPlanCheap } = await import("./plan/lib/store.mjs");
144
- const active = activePlanIds().map((id) => ({ id, title: loadPlanCheap(id).state?.title || id }));
145
- if (existsSync(claudeMdPath())) log(writeBlock(claudeMdPath(), active) ? "refreshed the plans block in CLAUDE.md" : "the plans block in CLAUDE.md is current");
146
- } catch (e) { log(`could not refresh the plans block in CLAUDE.md: ${e.message}`); }
147
- const { install, status } = await import("./hooks/install.mjs");
148
- install({});
149
- const { problems } = status({ print: false });
150
- log(problems ? `doctor: ${problems} problem(s) — run: ${cliName()} doctor` : "doctor: healthy");
151
- return problems ? 1 : 0;
152
- }
153
-
154
- /** Remove the hooks and the skill. Plans, the guide, CLAUDE.md and package.json are left as they are. */
155
- export async function uninstall({ dir = null, log = console.log } = {}) {
156
- if (dir) process.env.PLAN_PROJECT_ROOT = resolve(dir);
157
- const { install } = await import("./hooks/install.mjs");
158
- install({ remove: true });
159
- 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.`);
160
- return 0;
161
- }
package/src/issue.mjs DELETED
@@ -1,55 +0,0 @@
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
- }
@@ -1,7 +0,0 @@
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.
@@ -1,27 +0,0 @@
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)`);
@@ -1,83 +0,0 @@
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
- }
@@ -1,9 +0,0 @@
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
@@ -1,4 +0,0 @@
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.
@@ -1 +0,0 @@
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" } ] }
@@ -1,67 +0,0 @@
1
- {
2
- "id": "broken",
3
- "title": "A plan whose state lies: T1 is done with no recorded gate run",
4
- "status": "active",
5
- "created": "2026-09-12",
6
- "activatedAt": "2026-09-12T10:00:00+05:30",
7
- "closedAt": null,
8
- "paths": [
9
- "src/**"
10
- ],
11
- "tasks": [
12
- {
13
- "id": "T1",
14
- "title": "A task somebody marked done by hand",
15
- "status": "done",
16
- "gate": "G1",
17
- "manualCheck": null,
18
- "files": [],
19
- "effort": null,
20
- "dependsOn": [],
21
- "notes": "",
22
- "startedAt": "2026-09-12T10:00:00+05:30",
23
- "doneAt": "2026-09-12T11:00:00+05:30",
24
- "evidence": {
25
- "kind": "gate",
26
- "gate": "G1",
27
- "runId": "never-happened",
28
- "at": "2026-09-12T11:00:00+05:30"
29
- },
30
- "blocked": null,
31
- "doneWhen": [],
32
- "doneChecklist": []
33
- }
34
- ],
35
- "doneWhen": [
36
- {
37
- "id": "C1",
38
- "statement": "The task's gate ran in this very command and passed — or a manual reason names who checked it and how.",
39
- "kind": "auto:gate"
40
- },
41
- {
42
- "id": "C2",
43
- "statement": "A log entry names this task and was written after it started, saying what landed and what comes next.",
44
- "kind": "auto:logged"
45
- },
46
- {
47
- "id": "C3",
48
- "statement": "Every file this task lists exists on disk.",
49
- "kind": "auto:files"
50
- },
51
- {
52
- "id": "C4",
53
- "statement": "The docs that describe this change shipped in the same change — name them, or say why none were needed.",
54
- "kind": "manual"
55
- },
56
- {
57
- "id": "C5",
58
- "statement": "Every number in the log entry and in any report carries a locator (file:line, page, verse) or was re-derived — say which.",
59
- "kind": "manual"
60
- },
61
- {
62
- "id": "C6",
63
- "statement": "Anything learned or decided while doing this is recorded with plan learn / plan decide — name the ids, or say nothing was.",
64
- "kind": "manual"
65
- }
66
- ]
67
- }
@@ -1,3 +0,0 @@
1
- # fixture
2
-
3
- ## Project management
@@ -1,25 +0,0 @@
1
- #!/usr/bin/env node
2
- /**
3
- * Builds a TAMPERED copy of a black-box trial's snapshots at .tmp/broken-trial/: every task
4
- * that run 2 closed is rewritten as closed BY HAND (manual evidence, no gate run). Gate G7's
5
- * known-fail case runs trial/check.mjs on that copy, and it must FAIL — proving the trial
6
- * verdict cannot be passed by a run that never let a gate decide anything.
7
- * node node_modules/planrails/src/plan/fixtures/broken-trial.mjs <trial-dir>
8
- */
9
- import { cpSync, mkdirSync, readFileSync, writeFileSync, existsSync } from "node:fs";
10
- import { join, dirname } from "node:path";
11
- import { fileURLToPath } from "node:url";
12
- const trial = process.argv[2];
13
- if (!trial || !existsSync(join(trial, ".tmp", "trial", "snapshot-after-run2.json"))) { console.error("broken-trial: need a trial dir that has run (snapshot-after-run2.json missing)"); process.exit(3); }
14
- const root = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
15
- const out = join(root, ".tmp", "broken-trial");
16
- mkdirSync(join(out, ".tmp"), { recursive: true });
17
- cpSync(join(trial, ".tmp", "trial"), join(out, ".tmp", "trial"), { recursive: true });
18
- let tampered = 0;
19
- for (const f of ["snapshot-after-run2.json", "snapshot-after-run3.json"]) {
20
- const p = join(out, ".tmp", "trial", f); const snap = JSON.parse(readFileSync(p, "utf8"));
21
- for (const plan of Object.values(snap.plans)) { for (const t of plan.tasks) if (t.status === "done" && t.evidence?.kind === "gate") { t.evidence = { kind: "manual", by: "agent", at: t.evidence.at, reason: "TAMPERED: closed by hand, no gate ran" }; tampered++; } plan.gateRuns = 0; }
22
- writeFileSync(p, JSON.stringify(snap, null, 2));
23
- }
24
- if (!tampered) { console.error("broken-trial: no gate-closed task to tamper with — the fixture would test nothing"); process.exit(3); }
25
- console.log(`tampered trial built at ${out} (${tampered} gate closes rewritten as manual)`);