@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.
- package/AGENTS.md +1 -1
- package/CLAUDE.md +1 -1
- package/CONTRIBUTING.md +1 -0
- package/cli/doctor.js +40 -5
- package/cli/install.js +6 -1
- package/dist/rcode.js +87 -87
- package/package.json +1 -1
- package/rcode/agents/rcode-hussain-pm.md +37 -3
- package/rcode/agents/rcode-orchestrator.md +91 -0
- package/rcode/agents/rcode-project-researcher.md +19 -1
- package/rcode/agents/rules/executor/correctness-hazard-scan.md +98 -0
- package/rcode/agents/rules/executor/execution-flow.md +8 -0
- package/rcode/agents/rules/executor/self-check.md +8 -0
- package/rcode/agents/rules/orchestrator/contract.md +76 -0
- package/rcode/agents/rules/sprint-checker/dimensions.md +38 -0
- package/rcode/agents/rules/verifier/reachability-check.md +45 -2
- package/rcode/bin/lib/progress.cjs +41 -13
- package/rcode/bin/lib/roadmap.cjs +62 -22
- package/rcode/bin/lib/state-digest.cjs +88 -0
- package/rcode/bin/rcode-hooks.cjs +192 -23
- package/rcode/bin/rcode-tools.cjs +278 -7
- package/rcode/references/REFERENCES_INDEX.md +3 -1
- package/rcode/references/agent-shared-rules.md +123 -0
- package/rcode/references/code-reviewer-playbook.md +5 -0
- package/rcode/references/executor-playbook.md +2 -0
- package/rcode/references/github-comment-style.md +57 -0
- package/rcode/references/persona-executor-mode.md +61 -0
- package/rcode/references/planner-playbook.md +11 -0
- package/rcode/references/questioning.md +100 -2
- package/rcode/references/response-style.md +21 -4
- package/rcode/references/roadmapper-playbook.md +14 -0
- package/rcode/references/verifier-playbook.md +26 -0
- package/rcode/skills/SKILLS_INDEX.md +1 -1
- package/rcode/skills/actions/4-implementation/rcode-herdr-orchestration/references.md +7 -0
- package/rcode/skills/actions/4-implementation/rcode-herdr-orchestration/rules/merge-strategy.md +19 -3
- package/rcode/skills/actions/4-implementation/rcode-herdr-orchestration/templates/wave-prompt.md +3 -1
- package/rcode/skills/agents/{raees-orchestrator → orchestrator}/SKILL.md +1 -1
- package/rcode/team.yaml +20 -1
- package/rcode/workflows/audit-worktrees.md +15 -1
- package/rcode/workflows/execute-verify-phase-goal.md +58 -2
- package/rcode/workflows/execute.md +43 -8
- package/rcode/workflows/new-project-define-requirements.md +36 -0
- package/rcode/workflows/new-project-research-decision.md +61 -1
- package/rcode/workflows/new-project.md +95 -6
- package/rcode/workflows/plan-research-validation.md +8 -2
- package/rcode/workflows/plan-spawn-planner.md +32 -4
- package/rcode/workflows/plan.md +208 -18
- package/rcode/workflows/pr-branch.md +2 -0
- package/rcode/workflows/research-phase.md +12 -4
- package/rcode/workflows/resume-work.md +18 -0
- package/rcode/workflows/ship.md +4 -0
- package/rcode/workflows/verify-phase.md +40 -0
- package/server/dashboard.js +57 -17
- package/server/lib/html/client/components/OrchPanel.js +6 -2
- package/server/lib/html/client/components/XtermPanel.js +7 -2
- package/server/lib/html/client/orchestrator.js +58 -21
- package/server/lib/html/client/views/MemoryView.js +59 -3
- package/server/lib/html/css.js +40 -0
- package/server/lib/html/shell.js +10 -4
- package/server/lib/scanner.js +150 -3
- package/server/lib/view-only.js +32 -0
- package/server/orchestrator.js +63 -4
- /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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
//
|
|
141
|
-
//
|
|
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
|
-
|
|
150
|
-
const
|
|
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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
|
|
120
|
-
|
|
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
|
-
|
|
165
|
-
|
|
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
|
-
|
|
305
|
-
|
|
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
|
-
|
|
427
|
-
|
|
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
|
-
|
|
577
|
-
|
|
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(
|
|
631
|
-
} catch (
|
|
632
|
-
|
|
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
|
-
|
|
646
|
-
|
|
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
|
-
|
|
690
|
-
|
|
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
|
-
|
|
1072
|
-
|
|
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
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
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 };
|