@damiangilgonzalez/harness 1.3.17 → 1.3.19

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 (37) hide show
  1. package/README.md +117 -657
  2. package/manifest.json +1 -1
  3. package/package.json +1 -1
  4. package/scripts/doctor.js +1 -1
  5. package/scripts/install-apply.js +9 -0
  6. package/scripts/rsc.js +90 -2
  7. package/skills/building-agents/SKILL.md +1 -1
  8. package/skills/grill-with-docs/SKILL.md +0 -1
  9. package/skills/harness/references/daily-curation-automation.md +2 -1
  10. package/skills/harness/references/wiki-protocol.md +3 -1
  11. package/skills/implement/SKILL.md +0 -1
  12. package/skills/implement-spec/SKILL.md +1 -2
  13. package/skills/init/SKILL.md +7 -5
  14. package/skills/postgresdb/SKILL.md +1 -1
  15. package/skills/suggest/SKILL.md +17 -13
  16. package/skills/suggest/evals/cases.yaml +6 -0
  17. package/skills/to-spec/SKILL.md +0 -1
  18. package/skills/to-tickets/SKILL.md +0 -1
  19. package/skills/wayfinder/SKILL.md +0 -1
  20. package/targets/_md-block.js +1 -1
  21. package/targets/claude.js +2 -1
  22. package/targets/clone-bootstrap.mjs +1 -1
  23. package/targets/danger-guard.mjs +114 -52
  24. package/targets/hook-once.mjs +44 -13
  25. package/targets/knowledge-sync.mjs +759 -0
  26. package/targets/knowledge-wiring.js +196 -0
  27. package/targets/memory.js +7 -1
  28. package/targets/opencode-memory-plugin.js +67 -22
  29. package/targets/opt-outs.js +4 -0
  30. package/targets/session-memory-adapter.mjs +51 -6
  31. package/targets/session-memory-core.mjs +284 -37
  32. package/targets/session-start.mjs +2 -1
  33. package/targets/ship-guard.mjs +26 -9
  34. package/targets/trunk-policy.mjs +86 -0
  35. package/targets/userprompt-gate.mjs +7 -2
  36. package/targets/worklog-checkpoint.mjs +16 -1
  37. package/targets/worktree-reaper.mjs +144 -4
@@ -3,37 +3,43 @@
3
3
  // as `node ...` so it runs on every platform including Windows.
4
4
  // argv[2] = absolute project root stdin = PreToolUse hook JSON
5
5
  //
6
- // For a NON-TECHNICAL user (per docs/wiki/harness/user-profile.md → technical_level),
7
- // it DENIES irreversible, foot-gun Bash commands and tells the agent to find a safer,
8
- // scoped alternative. A fully `technical` user is never guarded. Default-safe: if there
9
- // is no profile yet, the harness convention is "assume non-technical", so the guard is ON.
6
+ // It catches irreversible, foot-gun shell commands and puts a HUMAN in front of them. What it does
7
+ // depends on docs/wiki/harness/user-profile.md -> technical_level:
8
+ // - technical -> never guarded.
9
+ // - mixed -> not blocked: the command goes through the normal permission flow, with a
10
+ // one-line warning that names the risk.
11
+ // - non-technical -> `ask`: Claude Code shows the user a confirmation. Their approval IS the
12
+ // or no profile explicit order that unblocks it (default-safe: no profile = assume
13
+ // non-technical, so the guard is ON).
10
14
  //
11
- // Disable per project with .rsc/.no-danger-guard — but only when the USER explicitly asks
12
- // for it (the deny message says so). Fail-open on any internal error (never brick a shell).
15
+ // It used to `deny` for everyone below `technical`, with a reason that began "BLOCKED for a
16
+ // non-technical user". In real sessions that denied ~20 `rm -rf` / `git branch -D` the user had
17
+ // just asked for in so many words ("bórrala"): a deny cannot be overridden from the conversation,
18
+ // so the order never reached the shell, and the label sent the agent into over-explaining to
19
+ // someone it had just been told was not technical. `ask` keeps the stop and lets the person
20
+ // decide; the message describes the COMMAND's risk and never the user.
21
+ //
22
+ // Disable per project with .rsc/.no-danger-guard — only when the USER explicitly asks for it.
23
+ // Fail-open on any internal error (never brick a shell).
13
24
  import { existsSync, readFileSync } from 'node:fs';
14
25
  import { join } from 'node:path';
15
26
 
16
27
  const root = process.argv[2] || process.cwd();
17
28
 
18
29
  function allow() { process.exit(0); }
19
- function deny(why) {
20
- process.stdout.write(JSON.stringify({
21
- hookSpecificOutput: {
22
- hookEventName: 'PreToolUse',
23
- permissionDecision: 'deny',
24
- permissionDecisionReason:
25
- `BLOCKED for a non-technical user — this command ${why}. ` +
26
- `Do NOT run it: explain the risk in plain language and propose a safer, scoped alternative ` +
27
- `(name exact paths, add a WHERE clause, back up first, etc.). ` +
28
- `Only if the USER explicitly insists on allowing dangerous commands here, create .rsc/.no-danger-guard to disable this guard.`,
29
- },
30
- }));
30
+ // `ask` is the human gate; `mixed` gets a warning and no decision at all, so whatever permission
31
+ // rules the project already has still apply (an explicit `allow` here would bypass them).
32
+ function stop(why) {
33
+ const reason = `Este comando ${why}. No se puede deshacer: confirma solo si es lo que quieres.`;
34
+ const out = level === 'mixed'
35
+ ? { systemMessage: `Aviso: este comando ${why}.` }
36
+ : { hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'ask', permissionDecisionReason: reason } };
37
+ process.stdout.write(JSON.stringify(out));
31
38
  process.exit(0);
32
39
  }
33
40
 
34
41
  if (existsSync(join(root, '.rsc', '.no-danger-guard'))) allow();
35
42
 
36
- // technical_level === 'technical' → not guarded. non-technical / mixed / missing → guarded.
37
43
  function technicalLevel() {
38
44
  try {
39
45
  const txt = readFileSync(join(root, 'docs', 'wiki', 'harness', 'user-profile.md'), 'utf8');
@@ -41,14 +47,41 @@ function technicalLevel() {
41
47
  return m ? m[1].toLowerCase() : null;
42
48
  } catch { return null; }
43
49
  }
44
- if (technicalLevel() === 'technical') allow();
50
+ const level = technicalLevel();
51
+ if (level === 'technical') allow();
45
52
 
46
- // Only Bash commands can be dangerous here.
53
+ // Only shell commands can be dangerous here.
47
54
  let input = {};
48
55
  try { input = JSON.parse(readFileSync(0, 'utf8') || '{}'); } catch { allow(); }
49
56
  if (!['Bash', 'PowerShell'].includes(input.tool_name || input.toolName)) allow();
50
- const cmd = input.tool_input?.command || input.toolInput?.command || '';
51
- if (typeof cmd !== 'string' || !cmd) allow();
57
+ const raw = input.tool_input?.command || input.toolInput?.command || '';
58
+ if (typeof raw !== 'string' || !raw) allow();
59
+
60
+ // --- text that is written, not run ------------------------------------------
61
+ // A heredoc body or the value of a message flag is DATA: `cat > doc.md <<'EOF' … rm -rf … EOF`,
62
+ // `git commit -m "… rm -rf …"`, `gh pr create --body "| rm -rf /agent |"`. The guard matched all of
63
+ // them — a markdown table even splits on `|` into a segment that starts with `rm`. They are stripped
64
+ // before any rule looks at the command. A heredoc fed to something that EXECUTES it (a shell, a SQL
65
+ // client, mongosh) keeps its body: `bash <<EOF rm -rf / EOF` really does delete.
66
+ const INTERPRETER = /(^|[\s|;&(])(sudo\s+)?(bash|sh|zsh|dash|ksh|psql|mysql|mariadb|sqlite3|mongosh|mongo|duckdb|clickhouse-client)(?=\s|$)/;
67
+ function stripHeredocs(text) {
68
+ const lines = text.split('\n');
69
+ const out = [];
70
+ for (let i = 0; i < lines.length; i += 1) {
71
+ out.push(lines[i]);
72
+ const m = lines[i].match(/(?<!<)<<(?!<)-?\s*(['"]?)([A-Za-z_][\w.-]*)\1/);
73
+ if (!m) continue;
74
+ let end = i + 1;
75
+ while (end < lines.length && lines[end].trim() !== m[2]) end += 1;
76
+ if (end >= lines.length) continue; // unterminated: leave it, judge what is there
77
+ if (INTERPRETER.test(lines[i])) out.push(...lines.slice(i + 1, end));
78
+ out.push(lines[end]);
79
+ i = end;
80
+ }
81
+ return out.join('\n');
82
+ }
83
+ const MESSAGE_FLAG = /(\s(?:-m|--message|-b|--body|-t|--title|--notes|--description)(?:\s+|=))("(?:[^"\\]|\\[\s\S])*"|'[^']*')/g;
84
+ const cmd = stripHeredocs(raw).replace(MESSAGE_FLAG, '$1""');
52
85
 
53
86
  // --- the dangerous-command list ---------------------------------------------
54
87
  // High-signal, low-false-positive. Each rule: what it catches and the plain why.
@@ -84,7 +117,32 @@ const SHELLS = /^(bash|sh|zsh|dash|ksh)$/i;
84
117
  // this, `echo "never run rm -rf /"` was denied — the guard blocking the conversation about a
85
118
  // command instead of the command, exactly the defect the gitmoji guard had to fix with its own
86
119
  // RUNS_GIT check. A guard that argues with you about a sentence is a guard you turn off.
87
- function runsRm(segment) {
120
+ //
121
+ // Split on shell separators that are OUTSIDE quotes. Splitting the raw string cut `echo "a | rm -rf x"`
122
+ // into a segment that begins with `rm`.
123
+ function splitTopLevel(text) {
124
+ const parts = [];
125
+ let cur = '';
126
+ let quote = null;
127
+ for (let i = 0; i < text.length; i += 1) {
128
+ const c = text[i];
129
+ if (quote) {
130
+ if (c === '\\' && quote === '"') { cur += c + (text[i + 1] ?? ''); i += 1; continue; }
131
+ if (c === quote) quote = null;
132
+ cur += c;
133
+ continue;
134
+ }
135
+ if (c === '"' || c === "'") { quote = c; cur += c; continue; }
136
+ if (c === '\n' || c === ';' || c === '|' || c === '&') { parts.push(cur); cur = ''; continue; }
137
+ cur += c;
138
+ }
139
+ parts.push(cur);
140
+ return parts;
141
+ }
142
+
143
+ // Is this segment a recursive force delete? A shell with -c is walked INTO — `bash -c "cd x && rm -rf y"`
144
+ // really deletes — by judging its quoted script the same way, a few levels deep at most.
145
+ function segmentIsRmRf(segment, depth) {
88
146
  const tokens = segment.trim().split(/\s+/).filter(Boolean);
89
147
  let i = 0;
90
148
  while (i < tokens.length) {
@@ -92,47 +150,51 @@ function runsRm(segment) {
92
150
  if (WRAPPERS.test(bare)) { i += 1; continue; }
93
151
  if (SHELLS.test(bare)) {
94
152
  i += 1;
95
- while (i < tokens.length && /^-/.test(tokens[i])) i += 1; // skip -c and friends
153
+ let script = false;
154
+ while (i < tokens.length && /^-/.test(tokens[i])) { if (/c/.test(tokens[i])) script = true; i += 1; }
155
+ if (script && depth < 3) {
156
+ const body = tokens.slice(i).join(' ').replace(/^(['"])([\s\S]*)\1$/, '$2');
157
+ return commandIsRmRf(body, depth + 1);
158
+ }
96
159
  continue;
97
160
  }
98
- return bare === 'rm' || bare.endsWith('/rm');
99
- }
100
- return false;
101
- }
102
-
103
- function isRmRecursiveForce() {
104
- if (!/\brm\b/.test(cmd)) return false;
105
- for (const segment of cmd.split(/\|\||&&|[|;&]/)) {
106
- if (!runsRm(segment)) continue;
161
+ if (bare !== 'rm' && !bare.endsWith('/rm')) return false;
107
162
  const flags = rmFlagsIn(segment);
108
163
  const hasR = flags.some((f) => /^--recursive$/i.test(f) || /^-[A-Za-z]*r/i.test(f));
109
164
  const hasF = flags.some((f) => /^--force$/i.test(f) || /^-[A-Za-z]*f/i.test(f));
110
- if (hasR && hasF) return true; // -rf / -fr / -Rf / -r -f / --recursive --force
165
+ return hasR && hasF; // -rf / -fr / -Rf / -r -f / --recursive --force
111
166
  }
112
167
  return false;
113
168
  }
114
169
 
170
+ function commandIsRmRf(text, depth = 0) {
171
+ if (!/\brm\b/.test(text)) return false;
172
+ return splitTopLevel(text).some((segment) => segmentIsRmRf(segment, depth));
173
+ }
174
+
175
+ function isRmRecursiveForce() { return commandIsRmRf(cmd); }
176
+
115
177
  const RULES = [
116
- { id: 'rm-rf', why: 'deletes whole files/folders irreversibly (rm with -r and -f)', match: isRmRecursiveForce },
117
- { id: 'find-delete', why: 'mass-deletes matched files (find … -delete / -exec rm)', match: () => /\bfind\b[^|;&]*(-delete\b|-exec\s+rm\b)/i.test(cmd) },
118
- { id: 'dd-disk', why: 'overwrites a raw disk device and can destroy the whole drive (dd of=/dev/…)', match: () => /\bdd\b[^|;&]*\bof=\/dev\//i.test(cmd) },
119
- { id: 'mkfs', why: 'formats a filesystem, erasing everything on it (mkfs)', match: () => /\bmkfs(\.\w+)?\b/i.test(cmd) },
120
- { id: 'curl-pipe-shell', why: 'pipes a downloaded script straight into a shell (curl|bash) — runs untrusted code', match: () => /\b(curl|wget)\b[^|]*\|\s*(sudo\s+)?(sh|bash|zsh)\b/i.test(cmd) },
121
-
122
- { id: 'git-push-force', why: 'force-pushes and can overwrite shared history for everyone (git push --force)', match: () => /\bgit\s+push\b[^|;&]*(--force(?!-with-lease)\b|\s-f\b)/i.test(cmd) },
123
- { id: 'git-reset-hard', why: 'throws away all uncommitted work with no undo (git reset --hard)', match: () => /\bgit\s+reset\b[^|;&]*--hard\b/i.test(cmd) },
124
- { id: 'git-clean', why: 'permanently deletes untracked files (git clean -f)', match: () => /\bgit\s+clean\b[^|;&]*-[a-z]*f/i.test(cmd) },
125
- { id: 'git-branch-D', why: 'force-deletes a branch even if its work was never merged (git branch -D)', match: () => /\bgit\s+branch\b[^|;&]*\s-D\b/.test(cmd) },
126
-
127
- { id: 'sql-drop', why: 'drops an entire database/schema/table (DROP …)', match: () => /\bdrop\s+(database|schema|table)\b/i.test(cmd) },
128
- { id: 'sql-truncate', why: 'empties an entire table (TRUNCATE)', match: () => /\btruncate\s+(table\s+)?\S/i.test(cmd) },
129
- { id: 'sql-delete-no-where', why: 'deletes EVERY row — a DELETE with no WHERE clause', match: () => noWhere(/\bdelete\s+from\s+\S/i) },
130
- { id: 'sql-update-no-where', why: 'rewrites EVERY row — an UPDATE with no WHERE clause', match: () => noWhere(/\bupdate\s+\S+\s+set\b/i) },
131
- { id: 'mongo-wipe', why: 'drops a collection/database or deletes all documents (drop()/dropDatabase/deleteMany({}))', match: () => /\.drop\(\s*\)|dropdatabase\s*\(|deletemany\(\s*\{\s*\}\s*\)|\.remove\(\s*\{\s*\}\s*\)/i.test(cmd) },
178
+ { id: 'rm-rf', why: 'borra archivos o carpetas enteras de forma irreversible (rm con -r y -f)', match: isRmRecursiveForce },
179
+ { id: 'find-delete', why: 'borra en masa todos los archivos que encuentra (find … -delete / -exec rm)', match: () => /\bfind\b[^|;&]*(-delete\b|-exec\s+rm\b)/i.test(cmd) },
180
+ { id: 'dd-disk', why: 'sobrescribe un disco entero y puede destruir todo lo que contiene (dd of=/dev/…)', match: () => /\bdd\b[^|;&]*\bof=\/dev\//i.test(cmd) },
181
+ { id: 'mkfs', why: 'formatea un sistema de archivos y borra todo lo que hay en él (mkfs)', match: () => /\bmkfs(\.\w+)?\b/i.test(cmd) },
182
+ { id: 'curl-pipe-shell', why: 'ejecuta un script descargado sin revisarlo (curl | bash): código que no conocemos', match: () => /\b(curl|wget)\b[^|]*\|\s*(sudo\s+)?(sh|bash|zsh)\b/i.test(cmd) },
183
+
184
+ { id: 'git-push-force', why: 'reescribe en el remoto el historial compartido, para todos (git push --force)', match: () => /\bgit\s+push\b[^|;&]*(--force(?!-with-lease)\b|\s-f\b)/i.test(cmd) },
185
+ { id: 'git-reset-hard', why: 'descarta todo el trabajo sin commitear y no hay forma de recuperarlo (git reset --hard)', match: () => /\bgit\s+reset\b[^|;&]*--hard\b/i.test(cmd) },
186
+ { id: 'git-clean', why: 'borra para siempre los archivos que git no sigue (git clean -f)', match: () => /\bgit\s+clean\b[^|;&]*-[a-z]*f/i.test(cmd) },
187
+ { id: 'git-branch-D', why: 'borra una rama aunque su trabajo no esté fusionado (git branch -D)', match: () => /\bgit\s+branch\b[^|;&]*\s-D\b/.test(cmd) },
188
+
189
+ { id: 'sql-drop', why: 'elimina una base de datos, esquema o tabla entera (DROP …)', match: () => /\bdrop\s+(database|schema|table)\b/i.test(cmd) },
190
+ { id: 'sql-truncate', why: 'vacía una tabla entera (TRUNCATE)', match: () => /\btruncate\s+(table\s+)?\S/i.test(cmd) },
191
+ { id: 'sql-delete-no-where', why: 'borra todas las filas: es un DELETE sin WHERE', match: () => noWhere(/\bdelete\s+from\s+\S/i) },
192
+ { id: 'sql-update-no-where', why: 'reescribe todas las filas: es un UPDATE sin WHERE', match: () => noWhere(/\bupdate\s+\S+\s+set\b/i) },
193
+ { id: 'mongo-wipe', why: 'elimina una colección o base de datos, o todos sus documentos (drop()/dropDatabase/deleteMany({}))', match: () => /\.drop\(\s*\)|dropdatabase\s*\(|deletemany\(\s*\{\s*\}\s*\)|\.remove\(\s*\{\s*\}\s*\)/i.test(cmd) },
132
194
  ];
133
195
 
134
196
  for (const rule of RULES) {
135
- try { if (rule.match()) deny(rule.why); } catch { /* a rule erroring must never block */ }
197
+ try { if (rule.match()) stop(rule.why); } catch { /* a rule erroring must never block */ }
136
198
  }
137
199
 
138
200
  allow();
@@ -94,25 +94,56 @@ Responde en el idioma en que escribe la persona, no en el de este texto.
94
94
  Si el usuario pide construir, anadir o cambiar algo (en cualquier idioma, se juzga la intencion),
95
95
  sigue esta cadena ANTES de escribir codigo. Ninguna skill escribe codigo de una feature hasta que
96
96
  el que esta claro y el usuario ha dado el visto bueno.
97
- 1. Aclarar el que:
98
- - Peticion concreta y acotada -> \`grill-with-docs\`.
99
- - Tema amplio, o el usuario no sabe como abordarlo -> recomiendale \`wayfinder\`.
100
- - \`superpowers:brainstorming\` vale para lo mismo si el usuario lo prefiere.
101
- Cada decision dificil de revertir se registra con \`write-adr\`, en docs/adr/.
102
- 2. Escribir la spec: \`to-spec\`, o \`superpowers:brainstorming\` para cerrarla por secciones.
103
- Sin spec escrita no se programa.
104
- 3. Construir: \`superpowers:writing-plans\` -> \`superpowers:executing-plans\`. Spec grande o que
105
- salio de \`wayfinder\` -> \`to-tickets\` -> \`implement-spec\` (subagentes en paralelo sobre el
106
- grafo de tickets); un solo ticket suelto -> \`implement\`.
107
- Si la tarea toca pantalla, invoca ademas \`flujo-interfaz\`: dice que skill de diseno manda.
108
- 4. Antes de fusionar: \`code-review\` (estandares del repo + lo que pedia la spec), y el cuerpo
109
- de la PR con \`pr\` (resumen visual, evidencia antes/despues, peligro de fusion).
97
+ Elige la cadena por el tamano y encadena tu mismo cada paso, sin esperar a que el usuario lo pida:
98
+ - Pequeno (cabe en una sesion, un solo ticket):
99
+ 1. Aclarar: \`superpowers:brainstorming\` si la idea aun esta verde; \`grill-with-docs\` si ya
100
+ hay un plan que endurecer.
101
+ 2. Construir: \`implement\`.
102
+ - Grande (varias sesiones o muchas piezas):
103
+ 1. Aclarar: \`grill-with-docs\`; tema amplio o sin camino claro -> \`wayfinder\`.
104
+ 2. Escribir la spec: \`to-spec\`. Sin spec escrita no se programa.
105
+ 3. Partirla: \`to-tickets\` (grafo de tickets con bloqueos). Sin tickets no hay \`implement-spec\`.
106
+ 4. Construir: \`implement-spec\` (subagentes en paralelo sobre el grafo de tickets).
107
+ En ambas: cada decision dificil de revertir va a \`write-adr\`, en docs/adr/.
108
+ Si la tarea toca pantalla, invoca ademas \`flujo-interfaz\`: dice que skill de diseno manda.
109
+ Antes de fusionar: \`code-review\` (estandares del repo + lo que pedia la spec), y la PR con \`pr\`.
110
110
  Siempre: \`superpowers:test-driven-development\` y \`superpowers:verification-before-completion\`
111
111
  antes de dar nada por terminado. Los bugs entran por \`superpowers:systematic-debugging\`.
112
112
  Cambio de una linea, typo o ajuste de configuracion -> hazlo y di que te saltas la cadena.
113
113
  ==========================================================
114
114
  `;
115
115
 
116
+ // Some skills the gate names carry `disable-model-invocation: true`: the Skill tool refuses them,
117
+ // so telling the agent to "use `to-spec`" sends it into a refusal and the user ends up typing the
118
+ // command anyway. The flag lives in each SKILL.md, so the gate reads it from there at emit time
119
+ // instead of keeping a second, hand-written list that drifts. Each flagged id is rendered as the
120
+ // slash command the user has to type, plus one line saying so. `roots` are searched in order
121
+ // (project, then user scope); the first SKILL.md found decides. Fail-safe: a skill whose file
122
+ // cannot be read keeps the current wording — this runs every turn and must never break the gate.
123
+ const GATE_ID = /`([a-z][a-z0-9-]*)`/g;
124
+ export function renderGate(text, roots = []) {
125
+ const manual = [];
126
+ for (const id of new Set([...text.matchAll(GATE_ID)].map((m) => m[1]))) {
127
+ for (const r of roots) {
128
+ let md;
129
+ try { md = readFileSync(join(r, '.claude', 'skills', id, 'SKILL.md'), 'utf8'); } catch { continue; }
130
+ const fm = md.match(/^---\r?\n([\s\S]*?)\r?\n---/);
131
+ if (fm && /^disable-model-invocation:\s*true\s*$/m.test(fm[1])) manual.push(id);
132
+ break;
133
+ }
134
+ }
135
+ if (!manual.length) return text;
136
+ const flagged = new Set(manual);
137
+ const lines = text.replace(GATE_ID, (m, id) => (flagged.has(id) ? `\`/${id}\`` : m)).split('\n');
138
+ const note = `Las que van con / (${manual.map((id) => `\`/${id}\``).join(', ')}) no las lances tu: la\n` +
139
+ `herramienta Skill las rechaza. Pide al usuario que escriba \`/<skill>\` (p. ej. \`/${manual[0]}\`).`;
140
+ let close = lines.length - 1;
141
+ while (close > 0 && !lines[close].startsWith('=====')) close -= 1;
142
+ if (close <= 0) return `${lines.join('\n')}\n${note}\n`;
143
+ lines.splice(close, 0, note);
144
+ return lines.join('\n');
145
+ }
146
+
116
147
  /**
117
148
  * Parse the hook payload Claude Code writes to stdin.
118
149
  * Pass `raw` explicitly in tests; in a hook, call with no argument to read fd 0.