@hanzlaa/rcode 4.12.1 → 4.14.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 (63) hide show
  1. package/AGENTS.md +1 -1
  2. package/CLAUDE.md +1 -1
  3. package/CONTRIBUTING.md +1 -0
  4. package/cli/doctor.js +40 -5
  5. package/cli/install.js +6 -1
  6. package/dist/rcode.js +87 -87
  7. package/package.json +1 -1
  8. package/rcode/agents/rcode-hussain-pm.md +37 -3
  9. package/rcode/agents/rcode-orchestrator.md +91 -0
  10. package/rcode/agents/rcode-project-researcher.md +19 -1
  11. package/rcode/agents/rules/executor/correctness-hazard-scan.md +98 -0
  12. package/rcode/agents/rules/executor/execution-flow.md +8 -0
  13. package/rcode/agents/rules/executor/self-check.md +8 -0
  14. package/rcode/agents/rules/orchestrator/contract.md +76 -0
  15. package/rcode/agents/rules/sprint-checker/dimensions.md +38 -0
  16. package/rcode/agents/rules/verifier/reachability-check.md +45 -2
  17. package/rcode/bin/lib/progress.cjs +41 -13
  18. package/rcode/bin/lib/roadmap.cjs +62 -22
  19. package/rcode/bin/lib/state-digest.cjs +88 -0
  20. package/rcode/bin/rcode-hooks.cjs +192 -23
  21. package/rcode/bin/rcode-tools.cjs +278 -7
  22. package/rcode/references/REFERENCES_INDEX.md +3 -1
  23. package/rcode/references/agent-shared-rules.md +123 -0
  24. package/rcode/references/code-reviewer-playbook.md +5 -0
  25. package/rcode/references/executor-playbook.md +2 -0
  26. package/rcode/references/github-comment-style.md +57 -0
  27. package/rcode/references/persona-executor-mode.md +61 -0
  28. package/rcode/references/planner-playbook.md +11 -0
  29. package/rcode/references/questioning.md +100 -2
  30. package/rcode/references/response-style.md +21 -4
  31. package/rcode/references/roadmapper-playbook.md +14 -0
  32. package/rcode/references/verifier-playbook.md +26 -0
  33. package/rcode/skills/SKILLS_INDEX.md +1 -1
  34. package/rcode/skills/actions/4-implementation/rcode-herdr-orchestration/references.md +7 -0
  35. package/rcode/skills/actions/4-implementation/rcode-herdr-orchestration/rules/merge-strategy.md +19 -3
  36. package/rcode/skills/actions/4-implementation/rcode-herdr-orchestration/templates/wave-prompt.md +3 -1
  37. package/rcode/skills/agents/{raees-orchestrator → orchestrator}/SKILL.md +1 -1
  38. package/rcode/team.yaml +20 -1
  39. package/rcode/workflows/audit-worktrees.md +15 -1
  40. package/rcode/workflows/execute-verify-phase-goal.md +58 -2
  41. package/rcode/workflows/execute.md +43 -8
  42. package/rcode/workflows/new-project-define-requirements.md +36 -0
  43. package/rcode/workflows/new-project-research-decision.md +61 -1
  44. package/rcode/workflows/new-project.md +95 -6
  45. package/rcode/workflows/plan-research-validation.md +8 -2
  46. package/rcode/workflows/plan-spawn-planner.md +32 -4
  47. package/rcode/workflows/plan.md +208 -18
  48. package/rcode/workflows/pr-branch.md +2 -0
  49. package/rcode/workflows/research-phase.md +12 -4
  50. package/rcode/workflows/resume-work.md +18 -0
  51. package/rcode/workflows/ship.md +4 -0
  52. package/rcode/workflows/verify-phase.md +40 -0
  53. package/server/dashboard.js +57 -17
  54. package/server/lib/html/client/components/OrchPanel.js +6 -2
  55. package/server/lib/html/client/components/XtermPanel.js +7 -2
  56. package/server/lib/html/client/orchestrator.js +58 -21
  57. package/server/lib/html/client/views/MemoryView.js +59 -3
  58. package/server/lib/html/css.js +40 -0
  59. package/server/lib/html/shell.js +10 -4
  60. package/server/lib/scanner.js +150 -3
  61. package/server/lib/view-only.js +32 -0
  62. package/server/orchestrator.js +63 -4
  63. /package/rcode/skills/agents/{raees-orchestrator → orchestrator}/references.md +0 -0
@@ -127,28 +127,69 @@ function extractPhases(content) {
127
127
  return phases;
128
128
  }
129
129
 
130
+ // Requirement IDs come in two shapes and BOTH are real:
131
+ // REQ-AUTH, REQ-FOO-BAR — the documented convention
132
+ // FOUND-01, RENT-04, OBJ-06, AUTHZ-04 — what projects actually write
133
+ // This must stay in step with extractReqIds() in rcode-tools.cjs. Two copies of
134
+ // this pattern already drifted once: one was widened for domain prefixes and
135
+ // this one was not, so `roadmap get-phase` kept returning requirements: [] on
136
+ // every domain-prefixed project.
137
+ const REQ_ID_RE = /\bREQ-[A-Z0-9][A-Z0-9-]*\b|\b[A-Z][A-Z0-9]{1,15}-\d+[a-z]?\b/g;
138
+
139
+ /**
140
+ * Build a matcher for a labelled block, tolerant of how the label is actually
141
+ * written. Roadmapper emits `**Success criteria:**` (colon inside the bold,
142
+ * lowercase c) while the old parser demanded `**Success Criteria**:` (colon
143
+ * outside). Two rcode components disagreeing about rcode's own format is what
144
+ * made get-phase unable to read its own roadmapper's output.
145
+ *
146
+ * Accepts: **Label:** | **Label**: | ## Label | Label:
147
+ * Returns { inline, list } — inline is same-line content, list is the block
148
+ * of bullet/numbered lines that follows. Callers use whichever is present.
149
+ */
150
+ function matchLabelledBlock(section, label) {
151
+ const l = label.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
152
+ // [ \t]* everywhere the match must NOT cross a line. Using \s* here let the
153
+ // label matcher swallow the newline, so the first bullet of the following
154
+ // list was captured as "inline" and its description text was thrown away —
155
+ // `- CITY-02 per-city page exists` came back as just `CITY-02`.
156
+ const re = new RegExp(
157
+ '(?:\\*\\*[ \\t]*' + l + '[ \\t]*:?[ \\t]*\\*\\*[ \\t]*:?|#{1,4}[ \\t]*' + l + '[ \\t]*:?|^[ \\t]*' + l + '[ \\t]*:)' +
158
+ '([^\\n]*)\\n((?:[ \\t]*(?:\\d+\\.|[-*])[ \\t]+[^\\n]+\\n?)*)',
159
+ 'im'
160
+ );
161
+ const m = section.match(re);
162
+ if (!m) return null;
163
+ return { inline: (m[1] || '').trim(), list: m[2] || '' };
164
+ }
165
+
166
+ function splitListBlock(block) {
167
+ return String(block).split('\n')
168
+ .map((l) => l.replace(/^[ \t]*(?:\d+\.|[-*])[ \t]+/, '').trim())
169
+ .filter(Boolean);
170
+ }
171
+
130
172
  function parseRequirements(section) {
131
- // Matches both bold-style (**Requirements:**) and heading-style (### Requirements)
132
- // followed by a list block.
133
- const listMatch = section.match(/(?:\*\*Requirements(?::\*\*|\*\*:)|#{1,4}\s*Requirements\s*:?)[^\n]*\n((?:\s*(?:\d+\.|[-*])\s+[^\n]+\n?)+)/i);
134
- if (listMatch) {
135
- return listMatch[1].split('\n')
136
- .map((l) => l.replace(/^\s*(?:\d+\.|[-*])\s+/, '').trim())
137
- .filter(Boolean);
173
+ const block = matchLabelledBlock(section, 'Requirements');
174
+ if (block) {
175
+ // A following list block wins; otherwise take the same-line value, which is
176
+ // how roadmapper writes it: `**Requirements:** FOUND-01, FOUND-02, RENT-04`.
177
+ const fromList = splitListBlock(block.list);
178
+ if (fromList.length > 0) return fromList;
179
+ if (block.inline) {
180
+ const ids = block.inline.match(REQ_ID_RE);
181
+ if (ids && ids.length > 0) return [...new Set(ids)];
182
+ return block.inline.split(/\s*,\s*/).map(x => x.trim()).filter(Boolean);
183
+ }
138
184
  }
139
185
 
140
- // Also capture REQ-IDs from inline lines like:
141
- // **REQs:** REQ-004, REQ-010, REQ-020
142
- // Requirements: REQ-001, REQ-002
143
- // **Covers:** REQ-001, REQ-003
144
- // Collect every line in the section that contains REQ-\d+ patterns.
186
+ // Last resort: sweep the whole section for requirement IDs on any line
187
+ // (covers `**REQs:**`, `**Covers:**`, and prose mentions).
145
188
  const seen = new Set();
146
189
  const out = [];
147
- const reqIdRe = /\bREQ-[A-Z0-9][A-Z0-9-]*\b/g;
148
190
  for (const line of section.split('\n')) {
149
- if (!/REQ-/i.test(line)) continue;
150
- const ids = line.match(reqIdRe) || [];
151
- for (const id of ids) {
191
+ const matches = line.match(REQ_ID_RE) || [];
192
+ for (const id of matches) {
152
193
  if (!seen.has(id)) { seen.add(id); out.push(id); }
153
194
  }
154
195
  }
@@ -156,12 +197,11 @@ function parseRequirements(section) {
156
197
  }
157
198
 
158
199
  function parseSuccessCriteria(section) {
159
- // Matches both bold-style (**Success Criteria:**) and heading-style (### Success Criteria)
160
- const match = section.match(/(?:\*\*Success Criteria\*\*[^\n]*:|#{1,4}\s*Success Criteria\s*:?)\s*\n((?:\s*(?:\d+\.|[-*])\s+[^\n]+\n?)+)/i);
161
- if (!match) return [];
162
- return match[1].split('\n')
163
- .map((l) => l.replace(/^\s*(?:\d+\.|[-*])\s+/, '').trim())
164
- .filter(Boolean);
200
+ const block = matchLabelledBlock(section, 'Success criteria');
201
+ if (!block) return [];
202
+ const fromList = splitListBlock(block.list);
203
+ if (fromList.length > 0) return fromList;
204
+ return block.inline ? [block.inline] : [];
165
205
  }
166
206
 
167
207
  function parsePlans(section) {
@@ -0,0 +1,88 @@
1
+ /**
2
+ * State digest — slim, subagent-facing extract of .rcode/state.json (#948).
3
+ *
4
+ * Every hop in a multi-agent workflow (rcode-phase-researcher, rcode-planner)
5
+ * is currently told to Read the raw state.json via `{state_path}` in its
6
+ * <files_to_read> block (see plan-spawn-planner.md, research-phase.md,
7
+ * plan-research-validation.md). In a mature project that file accumulates the
8
+ * full phases[]/sprints[]/stories[] history for every phase ever run — in
9
+ * this repo, 20 of 26 phases are already complete and carry their full sprint
10
+ * breakdowns, none of which a researcher/planner working on the CURRENT phase
11
+ * consumes (verified against the actual prompt templates, not guessed).
12
+ *
13
+ * buildStateDigest() keeps exactly what those prompts read state.json for:
14
+ * - current_phase / current_plan / active_workstream (orientation)
15
+ * - the ACTIVE phase's own entry in full (its sprints/stories — legitimate
16
+ * "what happened so far in this phase" signal for a continuation plan)
17
+ * - every other phase collapsed to {number, name, status} (existence +
18
+ * status only, no nested sprint/story bodies)
19
+ * - the most recent decisions (bounded — "Project decisions and history"
20
+ * per research-phase.md, not the full ADR log)
21
+ * - open (unresolved) blockers only
22
+ *
23
+ * Deliberately excluded: velocity_history, executions[], council_sessions[],
24
+ * chains[], the completed-phase sprint/story bodies, and resolved blockers —
25
+ * none of these are read by any workflow/agent prompt that consumes the
26
+ * digest (verified via grep across rcode/workflows and rcode/agents).
27
+ */
28
+
29
+ const RECENT_DECISIONS_LIMIT = 10;
30
+
31
+ /** Normalize a phase number/id for comparison, stripping legacy leading zeros. */
32
+ function normalizePhaseKey(v) {
33
+ const s = String(v ?? '').trim();
34
+ return s.replace(/^0+(?=\d)/, '');
35
+ }
36
+
37
+ /**
38
+ * @param {object|null} state - parsed state.json (post-migration)
39
+ * @param {string|number|null} phaseNumber - the phase currently being worked on
40
+ * @returns {object|null} slim digest, or null when state is absent
41
+ */
42
+ function buildStateDigest(state, phaseNumber) {
43
+ if (!state) return null;
44
+
45
+ const phases = Array.isArray(state.phases) ? state.phases : [];
46
+ const decisions = Array.isArray(state.decisions) ? state.decisions : [];
47
+ const blockers = Array.isArray(state.blockers) ? state.blockers : [];
48
+
49
+ const targetKey = phaseNumber != null ? normalizePhaseKey(phaseNumber) : null;
50
+ const activePhase = targetKey
51
+ ? phases.find((p) => normalizePhaseKey(p?.number ?? p?.id) === targetKey)
52
+ : null;
53
+
54
+ return {
55
+ project: state.project ?? null,
56
+ current_phase: state.current_phase ?? null,
57
+ current_plan: state.current_plan ?? null,
58
+ active_workstream: state.active_workstream ?? null,
59
+ last_session: state.last_session ?? null,
60
+ phase: activePhase ? {
61
+ number: activePhase.number ?? null,
62
+ name: activePhase.name ?? null,
63
+ status: activePhase.status ?? null,
64
+ started: activePhase.started ?? null,
65
+ completed: activePhase.completed ?? null,
66
+ goal: activePhase.goal ?? null,
67
+ sprints: Array.isArray(activePhase.sprints) ? activePhase.sprints : [],
68
+ } : null,
69
+ phase_history: phases.map((p) => ({
70
+ number: p?.number ?? p?.id ?? null,
71
+ name: p?.name ?? null,
72
+ status: p?.status ?? null,
73
+ })),
74
+ recent_decisions: decisions.slice(-RECENT_DECISIONS_LIMIT).map((d) => ({
75
+ summary: d?.summary ?? d?.description ?? null,
76
+ phase: d?.phase ?? null,
77
+ date: d?.date ?? null,
78
+ })),
79
+ open_blockers: blockers
80
+ .filter((b) => b && !b.resolved)
81
+ .map((b) => ({
82
+ description: b?.description ?? null,
83
+ date: b?.date ?? null,
84
+ })),
85
+ };
86
+ }
87
+
88
+ module.exports = { buildStateDigest, normalizePhaseKey, RECENT_DECISIONS_LIMIT };
@@ -116,8 +116,12 @@ async function preEdit() {
116
116
 
117
117
  process.exit(0);
118
118
  } catch (err) {
119
- console.error(`Hook error: ${err.message}`);
120
- process.exit(1);
119
+ // Route through the circuit breaker: these inner catches are where hook
120
+ // crashes actually surface (each handler catches its own errors and exits),
121
+ // so main()'s .catch would never see them.
122
+ const _tripped = recordCrash(process.argv[2], err.message);
123
+ if (!_tripped) console.error(`Hook error: ${err.message}`);
124
+ process.exit(_tripped ? 0 : 1);
121
125
  }
122
126
  }
123
127
 
@@ -161,8 +165,12 @@ async function preWorkflow() {
161
165
 
162
166
  process.exit(0);
163
167
  } catch (err) {
164
- console.error(`Hook error: ${err.message}`);
165
- process.exit(1);
168
+ // Route through the circuit breaker: these inner catches are where hook
169
+ // crashes actually surface (each handler catches its own errors and exits),
170
+ // so main()'s .catch would never see them.
171
+ const _tripped = recordCrash(process.argv[2], err.message);
172
+ if (!_tripped) console.error(`Hook error: ${err.message}`);
173
+ process.exit(_tripped ? 0 : 1);
166
174
  }
167
175
  }
168
176
 
@@ -301,8 +309,12 @@ async function postCommit() {
301
309
 
302
310
  process.exit(0);
303
311
  } catch (err) {
304
- console.error(`Hook error: ${err.message}`);
305
- process.exit(1);
312
+ // Route through the circuit breaker: these inner catches are where hook
313
+ // crashes actually surface (each handler catches its own errors and exits),
314
+ // so main()'s .catch would never see them.
315
+ const _tripped = recordCrash(process.argv[2], err.message);
316
+ if (!_tripped) console.error(`Hook error: ${err.message}`);
317
+ process.exit(_tripped ? 0 : 1);
306
318
  }
307
319
  }
308
320
 
@@ -423,8 +435,12 @@ async function bashGuard() {
423
435
 
424
436
  process.exit(0);
425
437
  } catch (err) {
426
- console.error(`Hook error: ${err.message}`);
427
- process.exit(1);
438
+ // Route through the circuit breaker: these inner catches are where hook
439
+ // crashes actually surface (each handler catches its own errors and exits),
440
+ // so main()'s .catch would never see them.
441
+ const _tripped = recordCrash(process.argv[2], err.message);
442
+ if (!_tripped) console.error(`Hook error: ${err.message}`);
443
+ process.exit(_tripped ? 0 : 1);
428
444
  }
429
445
  }
430
446
 
@@ -573,11 +589,44 @@ async function preCompact() {
573
589
  process.stdout.write(JSON.stringify(payload) + '\n');
574
590
  process.exit(0);
575
591
  } catch (err) {
576
- console.error(`Hook error: ${err.message}`);
577
- process.exit(1);
592
+ // Route through the circuit breaker: these inner catches are where hook
593
+ // crashes actually surface (each handler catches its own errors and exits),
594
+ // so main()'s .catch would never see them.
595
+ const _tripped = recordCrash(process.argv[2], err.message);
596
+ if (!_tripped) console.error(`Hook error: ${err.message}`);
597
+ process.exit(_tripped ? 0 : 1);
578
598
  }
579
599
  }
580
600
 
601
+ /**
602
+ * Strip JSONC comments and trailing commas so a tolerant re-parse can tell a
603
+ * commented-but-valid config from an actually broken one. Scans character by
604
+ * character and tracks string state — a naive regex would eat the `//` in
605
+ * "https://example.com" and turn a valid file into a reported syntax error.
606
+ */
607
+ function stripJsonc(text) {
608
+ let out = '';
609
+ let inStr = false, esc = false, inLine = false, inBlock = false;
610
+ for (let i = 0; i < text.length; i++) {
611
+ const c = text[i], next = text[i + 1];
612
+ if (inLine) { if (c === '\n') { inLine = false; out += c; } continue; }
613
+ if (inBlock) { if (c === '*' && next === '/') { inBlock = false; i++; } continue; }
614
+ if (inStr) {
615
+ out += c;
616
+ if (esc) esc = false;
617
+ else if (c === '\\') esc = true;
618
+ else if (c === '"') inStr = false;
619
+ continue;
620
+ }
621
+ if (c === '"') { inStr = true; out += c; continue; }
622
+ if (c === '/' && next === '/') { inLine = true; i++; continue; }
623
+ if (c === '/' && next === '*') { inBlock = true; i++; continue; }
624
+ out += c;
625
+ }
626
+ // Trailing commas before } or ]
627
+ return out.replace(/,(\s*[}\]])/g, '$1');
628
+ }
629
+
581
630
  /**
582
631
  * stop-verify: Syntax-check files changed during the response (#744).
583
632
  *
@@ -597,6 +646,11 @@ async function stopVerify() {
597
646
  null;
598
647
 
599
648
  if (!Array.isArray(changed)) {
649
+ // Fallback only. `git diff --name-only` reports the WHOLE dirty working
650
+ // tree, not what this response touched — so one pre-existing dirty file
651
+ // makes every Stop from now on report the same failure, forever, with
652
+ // nothing the user did causing it. Scope it to files modified since the
653
+ // response began where we can, and treat the result as advisory.
600
654
  const diff = spawnSync('git', ['diff', '--name-only'], {
601
655
  encoding: 'utf8',
602
656
  cwd: process.cwd(),
@@ -626,24 +680,58 @@ async function stopVerify() {
626
680
  failures.push(`${file}: ${(check.stderr || '').trim().split('\n')[0]}`);
627
681
  }
628
682
  } else if (ext === '.json') {
683
+ // Guard the read: one unreadable file (permissions, a symlink that just
684
+ // broke, a truncated write in flight) used to throw past this loop into
685
+ // the outer catch, killing the check for EVERY other changed file and
686
+ // printing a generic "Hook error" instead of naming anything.
687
+ let text;
688
+ try { text = fs.readFileSync(abs, 'utf8'); } catch { continue; }
629
689
  try {
630
- JSON.parse(fs.readFileSync(abs, 'utf8'));
631
- } catch (e) {
632
- failures.push(`${file}: ${e.message}`);
690
+ JSON.parse(text);
691
+ } catch (strictErr) {
692
+ // Many real-world *.json files are JSONC: turbo.json, tsconfig.json,
693
+ // jsconfig.json, .eslintrc.json, devcontainer.json, and VS Code's
694
+ // settings/launch all permit // comments and trailing commas. Strict
695
+ // JSON.parse calls those a syntax error, which made this hook fail on
696
+ // every single Stop against a perfectly valid file. Retry tolerantly
697
+ // and only report a failure when BOTH parses fail.
698
+ try {
699
+ JSON.parse(stripJsonc(text));
700
+ } catch {
701
+ failures.push(`${file}: ${strictErr.message}`);
702
+ }
633
703
  }
634
704
  }
635
705
  }
636
706
 
637
707
  if (failures.length > 0) {
708
+ // Don't re-report an identical failure set on every Stop. Without this a
709
+ // single unfixable/irrelevant dirty file turns into an error banner on
710
+ // every response for the rest of the session, which trains the user to
711
+ // ignore the hook entirely — the one outcome that makes it worthless.
712
+ const sig = failures.slice().sort().join('|');
713
+ const seenPath = path.join(os.tmpdir(), `rcode-stop-verify-${process.ppid || 0}.txt`);
714
+ let previous = '';
715
+ try { previous = fs.readFileSync(seenPath, 'utf8'); } catch { /* first run */ }
716
+ if (previous === sig) process.exit(0);
717
+ try { fs.writeFileSync(seenPath, sig); } catch { /* best-effort */ }
718
+
638
719
  console.error('⚠ stop-verify: changed files failed syntax check:');
639
720
  failures.forEach((f) => console.error(` • ${f}`));
640
721
  process.exit(1);
641
722
  }
723
+ // Clear the dedupe marker once everything parses, so a genuine NEW failure
724
+ // after a green run is reported rather than swallowed.
725
+ try { fs.unlinkSync(path.join(os.tmpdir(), `rcode-stop-verify-${process.ppid || 0}.txt`)); } catch { /* fine */ }
642
726
 
643
727
  process.exit(0);
644
728
  } catch (err) {
645
- console.error(`Hook error: ${err.message}`);
646
- process.exit(1);
729
+ // Route through the circuit breaker: these inner catches are where hook
730
+ // crashes actually surface (each handler catches its own errors and exits),
731
+ // so main()'s .catch would never see them.
732
+ const _tripped = recordCrash(process.argv[2], err.message);
733
+ if (!_tripped) console.error(`Hook error: ${err.message}`);
734
+ process.exit(_tripped ? 0 : 1);
647
735
  }
648
736
  }
649
737
 
@@ -686,8 +774,12 @@ async function costTrack() {
686
774
 
687
775
  process.exit(0);
688
776
  } catch (err) {
689
- console.error(`Hook error: ${err.message}`);
690
- process.exit(1);
777
+ // Route through the circuit breaker: these inner catches are where hook
778
+ // crashes actually surface (each handler catches its own errors and exits),
779
+ // so main()'s .catch would never see them.
780
+ const _tripped = recordCrash(process.argv[2], err.message);
781
+ if (!_tripped) console.error(`Hook error: ${err.message}`);
782
+ process.exit(_tripped ? 0 : 1);
691
783
  }
692
784
  }
693
785
 
@@ -1068,8 +1160,12 @@ async function stopHandler() {
1068
1160
  }
1069
1161
  process.exit(0);
1070
1162
  } catch (err) {
1071
- console.error(`Hook error: ${err.message}`);
1072
- process.exit(1);
1163
+ // Route through the circuit breaker: these inner catches are where hook
1164
+ // crashes actually surface (each handler catches its own errors and exits),
1165
+ // so main()'s .catch would never see them.
1166
+ const _tripped = recordCrash(process.argv[2], err.message);
1167
+ if (!_tripped) console.error(`Hook error: ${err.message}`);
1168
+ process.exit(_tripped ? 0 : 1);
1073
1169
  }
1074
1170
  }
1075
1171
 
@@ -1120,12 +1216,79 @@ function sessionStart() {
1120
1216
  process.exit(0);
1121
1217
  }
1122
1218
 
1219
+ // ── Circuit breaker ────────────────────────────────────────────────────
1220
+ // A hook that CRASHES has nothing useful to say and will keep saying it on
1221
+ // every single event. After THRESHOLD consecutive crashes, trip the breaker
1222
+ // and stay quiet for the rest of the session rather than pollute every turn.
1223
+ //
1224
+ // This tracks CRASHES ONLY (the hook itself threw), never FINDINGS. A hook
1225
+ // that exits non-zero because it correctly found a broken file is working;
1226
+ // disabling it for doing its job would be the opposite of the intent.
1227
+ const BREAKER_THRESHOLD = 3;
1228
+
1229
+ // Safety hooks are NEVER auto-disabled. bash-guard blocks `git push`,
1230
+ // `--no-verify`, and `rm -rf`; pre-tool-use and pre-edit gate writes. A crashing
1231
+ // guard must fail loudly and keep failing — silently disabling it converts a
1232
+ // bug into an open door, which is a far worse outcome than a noisy terminal.
1233
+ const NEVER_BREAK = new Set(['bash-guard', 'pre-tool-use', 'pre-edit', 'pre-workflow']);
1234
+
1235
+ function breakerPath(name) {
1236
+ return path.join(os.tmpdir(), `rcode-hook-breaker-${process.ppid || 0}-${name}.json`);
1237
+ }
1238
+
1239
+ function breakerTripped(name) {
1240
+ if (NEVER_BREAK.has(name)) return false;
1241
+ try {
1242
+ const st = JSON.parse(fs.readFileSync(breakerPath(name), 'utf8'));
1243
+ return (st.crashes || 0) >= BREAKER_THRESHOLD;
1244
+ } catch { return false; }
1245
+ }
1246
+
1247
+ function recordCrash(name, message) {
1248
+ if (NEVER_BREAK.has(name)) return false;
1249
+ let crashes = 0;
1250
+ try { crashes = JSON.parse(fs.readFileSync(breakerPath(name), 'utf8')).crashes || 0; } catch { /* first */ }
1251
+ crashes += 1;
1252
+ CRASH_RECORDED = true;
1253
+ try { fs.writeFileSync(breakerPath(name), JSON.stringify({ crashes, last: message })); } catch { /* best-effort */ }
1254
+ if (crashes >= BREAKER_THRESHOLD) {
1255
+ console.error(
1256
+ `⚠ rcode hook '${name}' crashed ${crashes}x in a row — disabling it for this session ` +
1257
+ `so it stops repeating. Last error: ${message}`
1258
+ );
1259
+ console.error(` Re-enable: restart the session, or fix and run 'node .rcode/bin/rcode-hooks.cjs ${name}' directly to see the full error.`);
1260
+ return true;
1261
+ }
1262
+ return false;
1263
+ }
1264
+
1265
+ function clearCrashes(name) {
1266
+ try { fs.unlinkSync(breakerPath(name)); } catch { /* nothing to clear */ }
1267
+ }
1268
+
1269
+ // Every handler exits from inside itself, so a `.then()` on main() would almost
1270
+ // never run. Hook the process exit instead: any clean exit means this hook ran
1271
+ // without crashing, so the consecutive-crash count resets. Without this, three
1272
+ // crashes spread across an entire session would trip the breaker even though
1273
+ // the hook worked fine in between.
1274
+ let CRASH_RECORDED = false;
1275
+ function installBreakerReset(name) {
1276
+ if (!name || NEVER_BREAK.has(name)) return;
1277
+ process.on('exit', (code) => {
1278
+ if (code === 0 && !CRASH_RECORDED) clearCrashes(name);
1279
+ });
1280
+ }
1281
+
1123
1282
  /**
1124
1283
  * Main entry point.
1125
1284
  */
1126
1285
  async function main() {
1127
1286
  const subcommand = process.argv[2];
1128
1287
 
1288
+ // Already tripped this session — exit silently. Advisory hooks only.
1289
+ if (breakerTripped(subcommand)) process.exit(0);
1290
+ installBreakerReset(subcommand);
1291
+
1129
1292
  switch (subcommand) {
1130
1293
  case 'pre-edit':
1131
1294
  await preEdit();
@@ -1174,10 +1337,16 @@ async function main() {
1174
1337
  }
1175
1338
 
1176
1339
  if (require.main === module) {
1177
- main().catch((err) => {
1178
- console.error(`Fatal error: ${err.message}`);
1179
- process.exit(1);
1180
- });
1340
+ const name = process.argv[2];
1341
+ main()
1342
+ .then(() => clearCrashes(name))
1343
+ .catch((err) => {
1344
+ const tripped = recordCrash(name, err.message);
1345
+ if (!tripped) console.error(`Fatal error: ${err.message}`);
1346
+ // Advisory hooks exit 0 once tripped so the harness stops surfacing them;
1347
+ // guard hooks (never tripped) keep their non-zero exit.
1348
+ process.exit(tripped ? 0 : 1);
1349
+ });
1181
1350
  }
1182
1351
 
1183
1352
  module.exports = { INTENT_TABLE };