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
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "planrails",
3
+ "version": "0.1.0",
4
+ "description": "Plans that survive compaction, for Claude Code: gates that prove a task done, conditions answered before it closes, briefs and rules injected by hooks, one fresh session per task.",
5
+ "type": "module",
6
+ "bin": {
7
+ "planrails": "bin/planrails.mjs"
8
+ },
9
+ "files": [
10
+ "bin",
11
+ "src",
12
+ "docs/PLANNING_GUIDE.md",
13
+ "README.md",
14
+ "LICENSE",
15
+ "CHANGELOG.md"
16
+ ],
17
+ "engines": {
18
+ "node": ">=20.10"
19
+ },
20
+ "scripts": {
21
+ "test": "node scripts/run-tests.mjs",
22
+ "selftest": "node bin/planrails.mjs selftest && node src/hooks/selftest.mjs",
23
+ "acceptance": "node scripts/acceptance.mjs",
24
+ "trial": "node scripts/trial/run.mjs",
25
+ "prepack": "npm test"
26
+ },
27
+ "dependencies": {
28
+ "zod": "^4.4.3"
29
+ },
30
+ "repository": {
31
+ "type": "git",
32
+ "url": "git+https://github.com/vivmagarwal/planrails.git"
33
+ },
34
+ "bugs": {
35
+ "url": "https://github.com/vivmagarwal/planrails/issues"
36
+ },
37
+ "homepage": "https://github.com/vivmagarwal/planrails#readme",
38
+ "keywords": [
39
+ "claude-code",
40
+ "claude",
41
+ "agent",
42
+ "planning",
43
+ "hooks",
44
+ "compaction",
45
+ "gates",
46
+ "project-management",
47
+ "ai-agents"
48
+ ],
49
+ "author": "vivmagarwal",
50
+ "license": "MIT"
51
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Shared bits for the plan-system hooks. Hooks run on every tool call, so
3
+ * everything here is small and synchronous, and NOTHING here may throw out —
4
+ * a hook that crashes blocks the tool call it was meant to inform.
5
+ */
6
+ import { readFileSync, mkdirSync, existsSync, writeFileSync, appendFileSync } from "node:fs";
7
+ import { join } from "node:path";
8
+ import { hookStateRoot } from "../plan/lib/paths.mjs";
9
+
10
+ export function readInput() {
11
+ try { return JSON.parse(readFileSync(0, "utf8")); } catch { return {}; }
12
+ }
13
+ export function sid(input) { return String(input.session_id || "unknown").replace(/[^A-Za-z0-9_-]/g, "") || "unknown"; }
14
+ export function sessionDir(input) {
15
+ const d = join(hookStateRoot(), sid(input));
16
+ try { mkdirSync(d, { recursive: true }); } catch { /* best effort */ }
17
+ return d;
18
+ }
19
+ export function readJsonSafe(path, fallback) {
20
+ try { return existsSync(path) ? JSON.parse(readFileSync(path, "utf8")) : fallback; } catch { return fallback; }
21
+ }
22
+ export function writeJsonSafe(path, obj) {
23
+ try { writeFileSync(path, JSON.stringify(obj)); } catch { /* best effort */ }
24
+ }
25
+ export function appendSafe(path, line) {
26
+ try { appendFileSync(path, line + "\n"); } catch { /* best effort */ }
27
+ }
28
+ export function emit(obj) { process.stdout.write(JSON.stringify(obj)); }
29
+ /** Add text to Claude's context without blocking anything. Measured to work on 2.1.269 for SessionStart and PreToolUse. */
30
+ export function addContext(eventName, text) {
31
+ emit({ hookSpecificOutput: { hookEventName: eventName, additionalContext: text }, suppressOutput: true });
32
+ }
@@ -0,0 +1,29 @@
1
+ #!/usr/bin/env bash
2
+ # PreToolUse(Bash) — the optional never-delete guard (npx planrails hooks install --with-never-delete).
3
+ # Why: a delete command needs a person to approve it, which turns an unattended run into a stall,
4
+ # and a mistaken delete of ungitted work cannot be undone. Moving aside is always reversible.
5
+ # Uses jq (~8ms) rather than node (~20ms) because this fires on every Bash call.
6
+ # Turn it off with: npx planrails hooks install (without the flag) after removing it, or delete
7
+ # the PreToolUse/Bash entry from .claude/settings.json.
8
+
9
+ cmd=$(jq -r '.tool_input.command // ""' 2>/dev/null)
10
+ [ -z "$cmd" ] && exit 0
11
+
12
+ # A leading word boundary that is start-of-string, whitespace, or a shell separator.
13
+ # Deliberately does NOT match "--rm" (docker), "npm", "charm", "./rm" — the char
14
+ # before the word must be a real separator.
15
+ if printf '%s' "$cmd" | grep -qE '(^|[;&|(`]|[[:space:]])(rm|rmdir|unlink|shred)([[:space:]]|$)'; then
16
+ trash=".planrails/trash/<what>-$(date +%Y-%m-%d)"
17
+ jq -n --arg t "$trash" '{
18
+ hookSpecificOutput: {
19
+ hookEventName: "PreToolUse",
20
+ permissionDecision: "deny",
21
+ permissionDecisionReason: (
22
+ "BLOCKED by the never-delete guard (planrails).\n\n" +
23
+ "A delete needs a person to approve it, which stalls an unattended run; and a mistaken delete of work that is not in git cannot be undone. Move it aside instead:\n mkdir -p " + $t + " && mv <path> " + $t + "/\n\n" +
24
+ "Name the reason in the directory name. Emptying the trash is a person'\''s deliberate act, never a step in a flow."
25
+ )
26
+ }
27
+ }'
28
+ fi
29
+ exit 0
@@ -0,0 +1,173 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Install (or remove) the planrails hooks and the /plan skill in a project.
4
+ *
5
+ * npx planrails hooks install [--with-never-delete] [--dry-run]
6
+ * npx planrails hooks status
7
+ * npx planrails hooks uninstall
8
+ *
9
+ * What it writes: the hook entries in <project>/.claude/settings.json, keeping
10
+ * every other setting exactly as it was (a backup goes to .planrails/backups/
11
+ * first), and the /plan skill at <project>/.claude/skills/plan/SKILL.md.
12
+ *
13
+ * Hook commands use `$CLAUDE_PROJECT_DIR`, which Claude Code sets for every hook
14
+ * command (measured on 2.1.269), so settings.json is portable: commit it once
15
+ * and every teammate's machine runs the copy in THEIR node_modules. If planrails
16
+ * is not installed under the project's node_modules (a checkout, a global
17
+ * install), the command falls back to the absolute path of this copy and
18
+ * `doctor` says so. Paths are always quoted: an unquoted path with a space runs
19
+ * `node /Users/x/My` and silently disables every hook.
20
+ */
21
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, copyFileSync, realpathSync, unlinkSync } from "node:fs";
22
+ import { join, dirname } from "node:path";
23
+ import { spawnSync } from "node:child_process";
24
+ import { fileURLToPath } from "node:url";
25
+ import { projectRoot, packageRoot, claudeSettingsPath, backupRoot, cliName } from "../plan/lib/paths.mjs";
26
+ import { nowIso } from "../plan/lib/time.mjs";
27
+
28
+ const HOOKS_DIR = dirname(fileURLToPath(import.meta.url));
29
+
30
+ /** The managed set. Order within an event is the order Claude Code runs them. */
31
+ export const MANAGED = [
32
+ { event: "SessionStart", file: "plan-session-start.mjs", runner: "node", timeout: 20, statusMessage: "Loading active plan briefs" },
33
+ { event: "PreToolUse", matcher: "Edit|Write|MultiEdit|NotebookEdit|Bash|Read|Agent|Workflow|WebSearch|WebFetch|Skill", file: "plan-pre-tool.mjs", runner: "node", timeout: 10 },
34
+ { event: "SubagentStart", file: "plan-subagent-start.mjs", runner: "node", timeout: 10 },
35
+ { event: "Stop", file: "plan-stop.mjs", runner: "node", timeout: 15 },
36
+ { event: "PreCompact", file: "precompact-journal.mjs", runner: "node", timeout: 60, statusMessage: "Writing progress journal before compaction" },
37
+ { event: "PostCompact", file: "postcompact-journal.mjs", runner: "node", timeout: 15 },
38
+ ];
39
+ /** Opt-in: blocks rm/rmdir/unlink/shred in Bash so an unattended run never stalls on a delete prompt, and nothing is lost by mistake. Needs jq. */
40
+ export const OPTIONAL = {
41
+ "never-delete": { event: "PreToolUse", matcher: "Bash", file: "guard-never-delete.sh", runner: "bash", timeout: 10 },
42
+ };
43
+ const ALL_FILES = new Set([...MANAGED, ...Object.values(OPTIONAL)].map((m) => m.file));
44
+ const SKILL_SRC = join(HOOKS_DIR, "..", "plan", "skill", "SKILL.md");
45
+
46
+ function same(a, b) { try { return realpathSync(a) === realpathSync(b); } catch { return false; } }
47
+ /** Portable when the package sits in the project's node_modules; absolute otherwise. */
48
+ export function commandFor(m) {
49
+ const viaNodeModules = same(packageRoot(), join(projectRoot(), "node_modules", "planrails"));
50
+ const path = viaNodeModules ? `$CLAUDE_PROJECT_DIR/node_modules/planrails/src/hooks/${m.file}` : join(HOOKS_DIR, m.file);
51
+ return `${m.runner} "${path}"`;
52
+ }
53
+ function entryFor(m) {
54
+ const hook = { type: "command", command: commandFor(m), timeout: m.timeout };
55
+ if (m.statusMessage) hook.statusMessage = m.statusMessage;
56
+ return m.matcher ? { matcher: m.matcher, hooks: [hook] } : { hooks: [hook] };
57
+ }
58
+ /** Is this command one of ours? Also matches the pre-package layouts (scripts/hooks, .claude/hooks) so an upgrade replaces them. */
59
+ function isManaged(cmd) {
60
+ const m = /(?:\/src\/hooks\/|\/scripts\/hooks\/|\/\.claude\/hooks\/)([A-Za-z0-9_.-]+)/.exec(String(cmd || ""));
61
+ return Boolean(m && ALL_FILES.has(m[1]));
62
+ }
63
+ function readSettings() {
64
+ const p = claudeSettingsPath();
65
+ if (!existsSync(p)) return {};
66
+ return JSON.parse(readFileSync(p, "utf8"));
67
+ }
68
+ export function skillDest() { return join(projectRoot(), ".claude", "skills", "plan", "SKILL.md"); }
69
+ function skillText() { return readFileSync(SKILL_SRC, "utf8").replace(/npx planrails/g, cliName()); }
70
+ /** Expand $CLAUDE_PROJECT_DIR the way the shell will, for existence checks. */
71
+ function namedPath(cmd) {
72
+ const q = String(cmd).match(/"([^"]+)"/)?.[1] || String(cmd).split(/\s+/)[1] || "";
73
+ return q.replace(/^\$CLAUDE_PROJECT_DIR/, projectRoot());
74
+ }
75
+
76
+ export function install({ dryRun = false, withNeverDelete = false, remove = false } = {}) {
77
+ const cli = cliName();
78
+ const p = claudeSettingsPath();
79
+ const before = readSettings();
80
+ const settings = JSON.parse(JSON.stringify(before));
81
+ settings.hooks = settings.hooks || {};
82
+ const replaced = [];
83
+ const wanted = remove ? [] : [...MANAGED, ...(withNeverDelete ? [OPTIONAL["never-delete"]] : [])];
84
+ // Keep an optional guard that was installed earlier, even if this run did not ask for it.
85
+ const hadNeverDelete = Object.values(settings.hooks).some((es) => (es || []).some((e) => (e.hooks || []).some((h) => String(h.command).includes("guard-never-delete.sh"))));
86
+ if (!remove && hadNeverDelete && !withNeverDelete) wanted.push(OPTIONAL["never-delete"]);
87
+ for (const [event, entries] of Object.entries(settings.hooks)) {
88
+ const kept = [];
89
+ for (const e of entries || []) {
90
+ const ours = (e.hooks || []).filter((h) => isManaged(h.command));
91
+ const theirs = (e.hooks || []).filter((h) => !isManaged(h.command));
92
+ for (const h of ours) if (!String(h.command).includes("/src/hooks/")) replaced.push(`${event}: ${h.command}`);
93
+ if (theirs.length) kept.push({ ...e, hooks: theirs });
94
+ }
95
+ settings.hooks[event] = kept;
96
+ }
97
+ for (const m of wanted) (settings.hooks[m.event] = settings.hooks[m.event] || []).push(entryFor(m));
98
+ for (const k of Object.keys(settings.hooks)) if (!settings.hooks[k].length) delete settings.hooks[k];
99
+ if (!Object.keys(settings.hooks).length) delete settings.hooks;
100
+ const changed = JSON.stringify(before) !== JSON.stringify(settings);
101
+ const lines = [];
102
+ if (replaced.length) lines.push(`replacing ${replaced.length} older entr${replaced.length === 1 ? "y" : "ies"}:\n ${replaced.join("\n ")}`);
103
+ lines.push(`${changed ? (dryRun ? "would write" : "writing") : "no change to"} ${p}`);
104
+ if (!dryRun) {
105
+ if (changed) {
106
+ if (existsSync(p)) {
107
+ const bk = join(backupRoot(), `claude-settings-${nowIso().replace(/[:+]/g, "-")}.json`);
108
+ mkdirSync(dirname(bk), { recursive: true });
109
+ copyFileSync(p, bk);
110
+ lines.push(`previous settings backed up to ${bk}`);
111
+ }
112
+ mkdirSync(dirname(p), { recursive: true });
113
+ writeFileSync(p, JSON.stringify(settings, null, 2) + "\n");
114
+ }
115
+ const dest = skillDest();
116
+ if (remove) {
117
+ if (existsSync(dest)) { unlinkSync(dest); lines.push(`removed /plan skill at ${dest}`); }
118
+ } else {
119
+ const current = existsSync(dest) && readFileSync(dest, "utf8") === skillText();
120
+ if (!current) { mkdirSync(dirname(dest), { recursive: true }); writeFileSync(dest, skillText()); lines.push(`installed /plan skill → ${dest}`); }
121
+ else lines.push(`/plan skill already current at ${dest}`);
122
+ }
123
+ } else if (!remove) lines.push(`(dry run) would install /plan skill → ${skillDest()}`);
124
+ if (wanted.some((m) => m.file === "guard-never-delete.sh") && spawnSync("which", ["jq"]).status !== 0) lines.push("warn: jq not found — the never-delete guard needs it (brew install jq / apt install jq)");
125
+ if (!remove && !same(packageRoot(), join(projectRoot(), "node_modules", "planrails"))) lines.push(`note: planrails is not installed under ${join(projectRoot(), "node_modules")} — hook commands use the absolute path of this copy (${packageRoot()}), which only works on this machine. Run: npm install --save-dev planrails, then ${cli} hooks install`);
126
+ lines.push(remove ? "Restart Claude Code (or run /hooks) so the removal takes effect." : "Restart Claude Code (or run /hooks) so the new hooks load.");
127
+ process.stdout.write(lines.join("\n") + "\n");
128
+ return { changed, replaced };
129
+ }
130
+
131
+ export function status({ print = true } = {}) {
132
+ const cli = cliName();
133
+ const p = claudeSettingsPath();
134
+ const lines = [];
135
+ let problems = 0;
136
+ const settings = existsSync(p) ? readSettings() : null;
137
+ if (!settings) { lines.push({ ok: false, text: `no ${p} — run: ${cli} hooks install` }); problems++; }
138
+ else {
139
+ const all = Object.entries(settings.hooks || {}).flatMap(([event, es]) => (es || []).flatMap((e) => (e.hooks || []).map((h) => ({ event, matcher: e.matcher, command: h.command }))));
140
+ const expected = [...MANAGED, ...(all.some((h) => String(h.command).includes("guard-never-delete.sh")) ? [OPTIONAL["never-delete"]] : [])];
141
+ for (const m of expected) {
142
+ const hit = all.find((h) => h.event === m.event && String(h.command).includes(`/src/hooks/${m.file}`) && (m.matcher ? h.matcher === m.matcher : true));
143
+ const old = all.find((h) => h.event === m.event && /\/(scripts|\.claude)\/hooks\//.test(String(h.command)) && String(h.command).includes(m.file));
144
+ // The path the command names must exist: a settings.json copied from another machine can point at files that are not there,
145
+ // and Claude Code runs the command anyway — exit 1 on every event, every rail silently off.
146
+ const named = hit ? namedPath(hit.command) : null;
147
+ if (hit && named && !existsSync(named)) { lines.push({ ok: false, text: `hook ${m.event} → ${named} does NOT exist on disk — run: npm install, then ${cli} hooks install` }); problems++; }
148
+ else if (hit) lines.push({ ok: true, text: `hook ${m.event}${m.matcher ? `(${m.matcher.length > 24 ? m.matcher.slice(0, 21) + "…" : m.matcher})` : ""} → ${m.file}${String(hit.command).includes("$CLAUDE_PROJECT_DIR") ? "" : " (absolute path: this machine only)"}` });
149
+ else if (old) lines.push({ ok: false, warn: true, text: `hook ${m.event} still points at an older copy of ${m.file} — run: ${cli} hooks install` });
150
+ else { lines.push({ ok: false, text: `hook ${m.event} → ${m.file} NOT installed — run: ${cli} hooks install` }); problems++; }
151
+ }
152
+ if (settings.disableAllHooks) { lines.push({ ok: false, text: "disableAllHooks is true — every hook is off" }); problems++; }
153
+ if (expected.some((m) => m.file === "guard-never-delete.sh") && spawnSync("which", ["jq"]).status !== 0) lines.push({ ok: false, warn: true, text: "jq not on PATH — the never-delete guard cannot run (brew install jq)" });
154
+ }
155
+ const dest = skillDest();
156
+ if (!existsSync(dest)) lines.push({ ok: false, warn: true, text: `/plan skill not installed at ${dest} — run: ${cli} hooks install` });
157
+ else if (readFileSync(dest, "utf8") !== skillText()) lines.push({ ok: false, warn: true, text: `/plan skill is stale — run: ${cli} hooks install` });
158
+ else lines.push({ ok: true, text: "/plan skill installed and current" });
159
+ if (print) for (const l of lines) process.stdout.write(`${l.ok ? "ok " : l.warn ? "warn " : "FAIL "} ${l.text}\n`);
160
+ return { lines, problems };
161
+ }
162
+
163
+ export function selftest() {
164
+ const r = spawnSync("node", [join(HOOKS_DIR, "selftest.mjs")], { stdio: "inherit" });
165
+ if (r.status !== 0) process.exit(r.status || 1);
166
+ }
167
+
168
+ if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
169
+ if (process.argv.includes("--status")) { const { problems } = status(); process.exit(problems ? 1 : 0); }
170
+ else if (process.argv.includes("--selftest")) selftest();
171
+ else if (process.argv.includes("--uninstall")) install({ remove: true });
172
+ else install({ dryRun: process.argv.includes("--dry-run"), withNeverDelete: process.argv.includes("--with-never-delete") });
173
+ }
@@ -0,0 +1,71 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * PreToolUse (Edit · Write · MultiEdit · NotebookEdit · Bash · Read · Agent ·
4
+ * Workflow · WebSearch · WebFetch · Skill) — two jobs, one process:
5
+ *
6
+ * 1. JUST-IN-TIME RULES. Each active plan's rules.json attaches a short rule
7
+ * to a trigger (tool + path / command / prompt). When the call matches, the
8
+ * rule text is added to Claude's context — at the moment it applies, and
9
+ * otherwise never. This is what lets a rule leave CLAUDE.md without being
10
+ * forgotten: the hook cannot forget. Default policy `once` per session
11
+ * (reset on compaction by plan-session-start.mjs).
12
+ *
13
+ * 2. UNLOGGED-WORK LEDGER. An Edit/Write under a plan's `paths` is noted in
14
+ * .planrails/hooks/<session>/edits.jsonl. plan-stop.mjs compares that with
15
+ * the plan's last log entry.
16
+ *
17
+ * Never blocks. Never throws out. Subagents get rules too (they edit files);
18
+ * their edits are marked agent:true.
19
+ */
20
+ import { join } from "node:path";
21
+ import { readFileSync } from "node:fs";
22
+ import { readInput, sessionDir, readJsonSafe, writeJsonSafe, appendSafe, addContext } from "./_lib.mjs";
23
+ import { projectRoot } from "../plan/lib/paths.mjs";
24
+ import { activePlanIds, loadPlanCheap } from "../plan/lib/store.mjs";
25
+ import { ruleMatches, toRepoRelative, anyPathMatches } from "../plan/lib/glob.mjs";
26
+ import { nowIso } from "../plan/lib/time.mjs";
27
+
28
+ const input = readInput();
29
+ const toolName = String(input.tool_name || "");
30
+ const toolInput = input.tool_input || {};
31
+ if (!toolName) process.exit(0);
32
+
33
+ let ids = [];
34
+ try { ids = activePlanIds(); } catch { ids = []; }
35
+ if (!ids.length) process.exit(0);
36
+
37
+ const root = projectRoot();
38
+ const dir = sessionDir(input);
39
+ const injectedPath = join(dir, "injected.json");
40
+ const injected = readJsonSafe(injectedPath, {});
41
+ const EDIT_TOOLS = /^(Edit|Write|MultiEdit|NotebookEdit)$/;
42
+ const fired = [];
43
+
44
+ for (const id of ids) {
45
+ let plan;
46
+ try { plan = loadPlanCheap(id); } catch { continue; }
47
+ if (!plan.state) continue;
48
+ for (const rule of plan.rules?.rules || []) {
49
+ let hit = false;
50
+ try { hit = ruleMatches(rule, { toolName, toolInput, root, cwd: input.cwd || null }); } catch { hit = false; }
51
+ if (!hit) continue;
52
+ // Keyed per agent: subagents share the parent's session_id, so a session-wide key would starve them of once-rules.
53
+ const key = `${input.agent_id ? `agent:${input.agent_id}` : "main"}:${id}/${rule.id}`;
54
+ const n = injected[key] || 0;
55
+ const policy = rule.repeat || "once";
56
+ const fire = policy === "always" ? true : policy === "once" ? n === 0 : n % Math.max(1, Number(policy.split(":")[1] || 1)) === 0;
57
+ injected[key] = n + 1;
58
+ if (!fire) continue;
59
+ let text = rule.text || "";
60
+ if (rule.file) { try { text = readFileSync(join(plan.dir, rule.file), "utf8").trim(); } catch { text = `(rule ${rule.id}: file ${rule.file} could not be read)`; } }
61
+ fired.push(`RULE ${rule.id} (plan ${id}, fires ${policy} — from ${id}/rules.json):\n${text}`);
62
+ }
63
+ if (EDIT_TOOLS.test(toolName)) {
64
+ const rel = toRepoRelative(toolInput.file_path || toolInput.notebook_path || "", root, input.cwd || null);
65
+ if (rel && anyPathMatches(rel, plan.state.paths)) {
66
+ appendSafe(join(dir, "edits.jsonl"), JSON.stringify({ at: nowIso(), plan: id, file: rel, agent: Boolean(input.agent_id) }));
67
+ }
68
+ }
69
+ }
70
+ writeJsonSafe(injectedPath, injected);
71
+ if (fired.length) addContext("PreToolUse", fired.join("\n\n"));
@@ -0,0 +1,46 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * SessionStart (startup · resume · clear · compact) — inject the brief of every
4
+ * active plan.
5
+ *
6
+ * This is the piece that makes "the plan is re-read, never remembered" true
7
+ * without anyone remembering to do it. The brief is rendered from disk at this
8
+ * moment (src/plan/lib/brief.mjs), so it cannot be stale. After a
9
+ * compaction the tool results are gone; the log and the brief are the record.
10
+ *
11
+ * Also records the session id at .planrails/hooks/current-session.json so the
12
+ * CLI can stamp log entries, and on `compact` resets the once-per-session rule
13
+ * injections (the context they were injected into no longer exists).
14
+ */
15
+ import { join } from "node:path";
16
+ import { unlinkSync, existsSync } from "node:fs";
17
+ import { readInput, sessionDir, writeJsonSafe, appendSafe, addContext } from "./_lib.mjs";
18
+ import { hookStateRoot, cliName } from "../plan/lib/paths.mjs";
19
+ import { activePlanIds, loadPlan } from "../plan/lib/store.mjs";
20
+ import { renderBrief } from "../plan/lib/brief.mjs";
21
+ import { nowIso } from "../plan/lib/time.mjs";
22
+
23
+ const input = readInput();
24
+ const source = String(input.source || input.matcher || "startup");
25
+ try {
26
+ const dir = sessionDir(input);
27
+ writeJsonSafe(join(hookStateRoot(), "current-session.json"), { session_id: input.session_id || null, startedAt: nowIso(), source, cwd: input.cwd || null });
28
+ appendSafe(join(dir, "starts.jsonl"), JSON.stringify({ at: nowIso(), source }));
29
+ if (source === "compact" || source === "clear") { const f = join(dir, "injected.json"); if (existsSync(f)) unlinkSync(f); }
30
+ } catch { /* never block a session start */ }
31
+
32
+ let ids = [];
33
+ try { ids = activePlanIds(); } catch { ids = []; }
34
+ if (!ids.length) process.exit(0);
35
+
36
+ const MAX = 3;
37
+ const parts = [];
38
+ parts.push(`PLAN SYSTEM — ${ids.length} active plan(s): ${ids.join(", ")}. The briefs below were rendered from disk just now; they outrank anything you remember about where the work stood.` +
39
+ (source === "compact" ? " You have just compacted: every tool result is gone, and the log + brief are the record of what was already done." : "") +
40
+ (source === "resume" ? " This is a resumed session: check the brief's RESUME line against git status before continuing." : ""));
41
+ for (const id of ids.slice(0, MAX)) {
42
+ try { parts.push(renderBrief(loadPlan(id))); } catch (e) { parts.push(`## Plan ${id} — brief failed to render: ${e.message}. Run: ${cliName()} validate ${id}`); }
43
+ }
44
+ if (ids.length > MAX) parts.push(`(+${ids.length - MAX} more: ${cliName()} brief <id>)`);
45
+ parts.push("Before the first command on a plan this session: read its PLAN.md § Method and § Rules. Rules attached to specific steps arrive automatically when you take that step. Record as you go — the Stop hook will remind you if files under a plan's paths change and nothing is logged. Guide: docs/PLANNING_GUIDE.md § Executing a plan.");
46
+ addContext("SessionStart", parts.join("\n\n"));
@@ -0,0 +1,60 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Stop — "you changed files under a plan and logged nothing."
4
+ *
5
+ * The rule it enforces: update the plan after every landed piece of work, not
6
+ * at the end of the session, because the chat is lost and the plan is not.
7
+ * This hook makes that a check. If this session (main thread or its subagents)
8
+ * edited files under an active plan's `paths` after that plan's last log entry,
9
+ * the stop is blocked ONCE with the exact command to run. `stop_hook_active`
10
+ * guards against a loop: the second stop of the same turn always passes.
11
+ *
12
+ * It reminds once per unlogged stretch (keyed on the plan's last log time), and
13
+ * again only if the stretch has grown by five or more files — a nag on every
14
+ * turn would train everyone to ignore it, which is worse than no hook.
15
+ */
16
+ import { join } from "node:path";
17
+ import { readInput, sessionDir, readJsonSafe, writeJsonSafe, emit } from "./_lib.mjs";
18
+ import { activePlanIds, loadPlan } from "../plan/lib/store.mjs";
19
+ import { shortStamp } from "../plan/lib/time.mjs";
20
+ import { cliName } from "../plan/lib/paths.mjs";
21
+ import { readFileSync, existsSync } from "node:fs";
22
+
23
+ const input = readInput();
24
+ if (input.stop_hook_active) process.exit(0);
25
+
26
+ const dir = sessionDir(input);
27
+ const editsPath = join(dir, "edits.jsonl");
28
+ if (!existsSync(editsPath)) process.exit(0);
29
+ let edits = [];
30
+ try { edits = readFileSync(editsPath, "utf8").split("\n").filter(Boolean).map((l) => JSON.parse(l)); } catch { process.exit(0); }
31
+ if (!edits.length) process.exit(0);
32
+
33
+ let ids = [];
34
+ try { ids = activePlanIds(); } catch { process.exit(0); }
35
+ const remindedPath = join(dir, "reminded.json");
36
+ const reminded = readJsonSafe(remindedPath, {});
37
+ const reasons = [];
38
+
39
+ for (const id of ids) {
40
+ let plan;
41
+ try { plan = loadPlan(id); } catch { continue; }
42
+ const last = plan.log.at(-1);
43
+ const lastAt = last ? Date.parse(last.at) : 0;
44
+ const unlogged = edits.filter((e) => e.plan === id && Date.parse(e.at) > lastAt);
45
+ if (!unlogged.length) continue;
46
+ const files = [...new Set(unlogged.map((e) => e.file))];
47
+ const prev = reminded[id];
48
+ if (prev && prev.lastAt === lastAt && files.length < prev.files + 5) continue;
49
+ reminded[id] = { lastAt, files: files.length };
50
+ const doing = plan.state.tasks.find((t) => t.status === "doing");
51
+ reasons.push(
52
+ `PLAN ${id}: ${files.length} file(s) under its paths changed since its last log entry (${last ? shortStamp(last.at) : "never"}): ` +
53
+ `${files.slice(0, 6).join(", ")}${files.length > 6 ? `, +${files.length - 6} more` : ""}.\n` +
54
+ `Record what landed and the exact next step BEFORE stopping — a compaction or a fresh session reads the log, not this chat:\n` +
55
+ ` ${cliName()} log ${id}${doing ? ` --task ${doing.id}` : ""} --what "<what changed, with numbers/paths>" --next "<the next action, executable by a stranger>"\n` +
56
+ `Anything learned → plan learn; any choice → plan decide; a task that landed → plan task done (it runs the gate). Then stop.`
57
+ );
58
+ }
59
+ writeJsonSafe(remindedPath, reminded);
60
+ if (reasons.length) emit({ decision: "block", reason: reasons.join("\n\n") });
@@ -0,0 +1,27 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * SubagentStart — the do-nots every subagent must carry, as a rail.
4
+ *
5
+ * Measured 2026-09-12 (scratchpad hooktest, Claude Code 2.1.269): a SubagentStart
6
+ * hook's additionalContext reaches the SUBAGENT and not the main thread; the
7
+ * payload carries agent_id, agent_type and the PARENT's session_id. So this is
8
+ * the one place a note can be put in front of every subagent without the main
9
+ * session remembering to write it into every prompt.
10
+ *
11
+ * It stays short (< 700 chars): a subagent's fixed prompt is re-read every turn
12
+ * (CLAUDE.md § TOKEN DISCIPLINE). The task-specific brief comes from the main
13
+ * session (`plan agent-brief <id> <T>`); the rules for a file arrive from
14
+ * plan-pre-tool.mjs at the tool call. Silent when no plan is active.
15
+ */
16
+ import { readInput, addContext } from "./_lib.mjs";
17
+ import { activePlanIds } from "../plan/lib/store.mjs";
18
+
19
+ const input = readInput();
20
+ let ids = [];
21
+ try { ids = activePlanIds(); } catch { ids = []; }
22
+ if (!ids.length) process.exit(0);
23
+ const reports = ids.map((id) => `.project-management/plans/${id}/reports/`).join(" or ");
24
+ addContext("SubagentStart",
25
+ `SUBAGENT NOTE (plan system; active plan${ids.length > 1 ? "s" : ""}: ${ids.join(", ")}). Your prompt is your whole memory of the plan; the main session owns it. ` +
26
+ `Write only the files your prompt names. Put your findings in a report file under ${reports} (every number WITH its locator: file:line, page, verse) and name it in your reply — a finding that lives only in your reply is lost. ` +
27
+ `Never delete (move it aside instead), never run \`plan task done\` or \`plan close\`, never settle a doubt by guessing: write the doubt down. Rules for a file arrive when you touch it; follow them.`);
@@ -0,0 +1,35 @@
1
+ #!/usr/bin/env node
2
+ // PostCompact — closes the loop opened by precompact-journal.mjs.
3
+ //
4
+ // Compaction has just discarded every tool result. This injects the path of the
5
+ // journal that was written a moment earlier, so the next turn re-reads what was
6
+ // already done instead of doing it again.
7
+ import { readFileSync, existsSync, statSync } from "node:fs";
8
+ import { join, relative } from "node:path";
9
+ import { journalRoot, projectRoot } from "../plan/lib/paths.mjs";
10
+
11
+ let input = {};
12
+ try { input = JSON.parse(readFileSync(0, "utf8")); } catch { process.exit(0); }
13
+
14
+ const sid = String(input.session_id || "").replace(/[^A-Za-z0-9_-]/g, "");
15
+ if (!sid) process.exit(0);
16
+
17
+ const path = join(journalRoot(), `${sid}.md`);
18
+ if (!existsSync(path)) process.exit(0);
19
+
20
+ let kb = 0;
21
+ try { kb = Math.round(statSync(path).size / 1024); } catch { /* ignore */ }
22
+ const rel = relative(projectRoot(), path);
23
+
24
+ process.stdout.write(JSON.stringify({
25
+ hookSpecificOutput: {
26
+ hookEventName: "PostCompact",
27
+ additionalContext:
28
+ `A progress journal for this session exists at ${rel} (${kb} KB). ` +
29
+ `The compaction you just went through discarded every tool result, so your record of what ` +
30
+ `was already done is in that file, not in this context. Read its most recent section before ` +
31
+ `resuming — especially the files already written and the commands already run — so you do not repeat work.`,
32
+ },
33
+ suppressOutput: true,
34
+ }));
35
+ process.exit(0);