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.
- package/CHANGELOG.md +13 -0
- package/LICENSE +21 -0
- package/README.md +225 -0
- package/bin/planrails.mjs +7 -0
- package/docs/PLANNING_GUIDE.md +632 -0
- package/package.json +51 -0
- package/src/hooks/_lib.mjs +32 -0
- package/src/hooks/guard-never-delete.sh +29 -0
- package/src/hooks/install.mjs +173 -0
- package/src/hooks/plan-pre-tool.mjs +71 -0
- package/src/hooks/plan-session-start.mjs +46 -0
- package/src/hooks/plan-stop.mjs +60 -0
- package/src/hooks/plan-subagent-start.mjs +27 -0
- package/src/hooks/postcompact-journal.mjs +35 -0
- package/src/hooks/precompact-journal.mjs +128 -0
- package/src/hooks/selftest.mjs +128 -0
- package/src/init.mjs +155 -0
- package/src/issue.mjs +55 -0
- package/src/plan/fixtures/README.md +7 -0
- package/src/plan/fixtures/broken-cli.mjs +27 -0
- package/src/plan/fixtures/broken-hooks-root/.claude/settings.json +83 -0
- package/src/plan/fixtures/broken-hooks-root/.project-management/plans/.gitkeep +0 -0
- package/src/plan/fixtures/broken-hooks-root/CLAUDE.md +9 -0
- package/src/plan/fixtures/broken-root/.project-management/plans/broken/PLAN.md +4 -0
- package/src/plan/fixtures/broken-root/.project-management/plans/broken/gates.json +1 -0
- package/src/plan/fixtures/broken-root/.project-management/plans/broken/rules.json +1 -0
- package/src/plan/fixtures/broken-root/.project-management/plans/broken/state.json +67 -0
- package/src/plan/fixtures/broken-root/CLAUDE.md +3 -0
- package/src/plan/fixtures/broken-trial.mjs +25 -0
- package/src/plan/lib/brief.mjs +116 -0
- package/src/plan/lib/claude-md.mjs +66 -0
- package/src/plan/lib/glob.mjs +81 -0
- package/src/plan/lib/judgment.mjs +19 -0
- package/src/plan/lib/paths.mjs +65 -0
- package/src/plan/lib/schema.mjs +199 -0
- package/src/plan/lib/store.mjs +338 -0
- package/src/plan/lib/time.mjs +21 -0
- package/src/plan/plan.mjs +843 -0
- package/src/plan/run.mjs +88 -0
- 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
|
+
}
|
|
File without changes
|
|
@@ -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 @@
|
|
|
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" } ] }
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{ "rules": [] }
|