@damiangilgonzalez/harness 1.3.16 → 1.3.18

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.
@@ -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.
package/targets/memory.js CHANGED
@@ -76,10 +76,15 @@ function handler(target, event) {
76
76
  : target === 'codex'
77
77
  ? '"$(git rev-parse --show-toplevel)/.rsc/session-memory-adapter.mjs"'
78
78
  : '".rsc/session-memory-adapter.mjs"';
79
+ // PreCompact gets more room than the per-turn events. It runs once, right before a compaction that
80
+ // itself takes tens of seconds, so a longer ceiling costs nothing in the normal case; and a
81
+ // cancelled PreCompact is the one that loses the budget reset. The per-turn events keep 5 s:
82
+ // they run on every tool call and must stay cheap.
83
+ const seconds = event === 'compact' ? 15 : event === 'end' && target === 'codex' ? 3 : 5;
79
84
  const value = {
80
85
  type: 'command',
81
86
  command: `node ${unixScript} ${target} ${event}`,
82
- timeout: target === 'gemini' ? 5000 : event === 'end' && target === 'codex' ? 3 : 5,
87
+ timeout: target === 'gemini' ? seconds * 1000 : seconds,
83
88
  };
84
89
  if (target === 'codex') {
85
90
  value.commandWindows = `node \"$((git rev-parse --show-toplevel).Trim())/.rsc/session-memory-adapter.mjs\" ${target} ${event}`;
@@ -108,7 +108,10 @@ export function handleLifecycle({ target, event, native = {}, cwd, settings } =
108
108
  const id = sessionId(native, target);
109
109
  const eventName = hookEventName(target, event, native);
110
110
  if (event === 'start') {
111
- const started = capture({ cwd: project, worktreeCwd: here, sessionId: id, target, event: 'start', settings: config });
111
+ // `source: "compact"` is the SessionStart that follows a compaction. It resets the edit budget
112
+ // on its own, so the count still restarts when the PreCompact hook itself never finished.
113
+ const reset = native?.source === 'compact';
114
+ const started = capture({ cwd: project, worktreeCwd: here, sessionId: id, target, event: 'start', reset, settings: config });
112
115
  const resumed = resume({ cwd: project, worktreeCwd: here, target, settings: config });
113
116
  return {
114
117
  output: nativeOutput(target, eventName, resumed.context),
@@ -1,5 +1,5 @@
1
1
  import {
2
- appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync,
2
+ appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync,
3
3
  } from 'node:fs';
4
4
  import { createHash, randomUUID } from 'node:crypto';
5
5
  import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
@@ -167,11 +167,22 @@ function parseStatus(output) {
167
167
  // runs on every hook event: a few hundred dirty files were enough to blow the 5 s hook timeout.
168
168
  // `--stdin-paths` stops at the first path it cannot read (a deleted file), so on failure each file
169
169
  // is hashed on its own, which keeps the old per-file `null` for the unreadable ones.
170
+ //
171
+ // A deleted file is the ordinary case, not the exotic one — any `rm` or `git rm` puts one in the
172
+ // status — and it used to send EVERY file down the per-file path, back over the timeout (400 dirty
173
+ // files and one deletion: ~20 s per event, so PreCompact was cancelled). So paths that are not a
174
+ // readable regular file are settled here, as `null`, before the batch ever sees them.
170
175
  function fingerprintFiles(worktree, files) {
171
176
  if (!files.length) return {};
172
- const batch = git(worktree, ['hash-object', '--no-filters', '--stdin-paths'], `${files.join('\n')}\n`);
177
+ const readable = files.filter((path) => {
178
+ try { return statSync(join(worktree, path)).isFile(); } catch { return false; }
179
+ });
180
+ const isReadable = new Set(readable);
181
+ const missing = Object.fromEntries(files.filter((path) => !isReadable.has(path)).map((path) => [path, null]));
182
+ if (!readable.length) return missing;
183
+ const batch = git(worktree, ['hash-object', '--no-filters', '--stdin-paths'], `${readable.join('\n')}\n`);
173
184
  const hashes = batch.ok ? batch.out.split('\n') : [];
174
- if (hashes.length === files.length) return Object.fromEntries(files.map((path, i) => [path, hashes[i]]));
185
+ if (hashes.length === readable.length) return { ...missing, ...Object.fromEntries(readable.map((path, i) => [path, hashes[i]])) };
175
186
  return Object.fromEntries(files.map((path) => {
176
187
  const result = git(worktree, ['hash-object', '--no-filters', '--', path]);
177
188
  return [path, result.ok ? result.out : null];
@@ -346,13 +357,27 @@ export function capture(input = {}) {
346
357
  const observed = Object.entries(repo.fingerprints || {})
347
358
  .filter(([path, fingerprint]) => fingerprint && previousFingerprints[path] !== fingerprint).length;
348
359
  const editDelta = Math.max(finiteOrNull(input.editDelta, true) || 0, observed);
349
- // Compaction closes a context window, so the edit budget starts again from zero.
350
- const previousEdits = input.event === 'compact' ? 0 : (existing?.editCount || 0);
360
+ // Compaction closes a context window, so the edit budget starts again from zero. Two signals say
361
+ // it happened, and either is enough: PreCompact (`compact`) and the SessionStart that follows it
362
+ // (`reset`, from `source: "compact"`). Relying on PreCompact alone meant a cancelled PreCompact
363
+ // hook — which is what the 5 s timeout did on a dirty tree — left the old count standing.
364
+ const resetsBudget = input.event === 'compact' || input.reset === true;
365
+ const previousEdits = resetsBudget ? 0 : (existing?.editCount || 0);
351
366
  const editCount = Math.max(0, previousEdits + editDelta);
352
367
  // Hint once per threshold crossed, and only on the edit that crosses it: re-emitting on
353
368
  // every later event (Stop in particular) wakes the model again and loops.
354
369
  const crossedThreshold = editCount > previousEdits
355
370
  && Math.floor(editCount / config.editThreshold) > Math.floor(previousEdits / config.editThreshold);
371
+ // And never ON a turn end, even the one that crosses. A Stop hook that speaks is a Stop hook that
372
+ // can start another turn, and that turn ends in another Stop: the field report is ~150 empty
373
+ // assistant replies in a row. A crossing seen at a turn end is kept on the anchor and said by the
374
+ // next event that is not one (the person's next prompt, or the next tool call). Compaction and
375
+ // start never say it either — "consider compacting" while compacting is noise — and a reset
376
+ // drops whatever was pending, because the window it was about is gone.
377
+ const mutedEvents = ['turn', 'stop', 'compact', 'start', 'end', 'sessionEnd'];
378
+ const hintDue = !resetsBudget && (crossedThreshold || anchor.hintPending === true);
379
+ const speaks = !mutedEvents.includes(input.event);
380
+ const hintPending = hintDue && !speaks;
356
381
  const baselineFiles = new Set(anchor.baselineFiles || []);
357
382
  const newDirtyPath = repo.files.some((path) => !baselineFiles.has(path));
358
383
  const baselineFingerprints = anchor.baselineFingerprints || {};
@@ -391,13 +416,20 @@ export function capture(input = {}) {
391
416
  if (errors.length) return { record: null, path: null, notice: null, compactionHint: false, errors };
392
417
  atomicJson(recordPath, record);
393
418
  existing = record;
394
- const compactionHint = Boolean(config.compactionHint && crossedThreshold);
419
+ const compactionHint = Boolean(config.compactionHint && hintDue && speaks);
395
420
  // The fingerprints the next event compares against live on the ANCHOR, not on the record: the
396
421
  // record's schema is closed and every field of it is required, so a new field there would fail
397
422
  // validation on every record already written. Written only when they moved, so a read-only shell
398
- // command costs no extra disk.
423
+ // command costs no extra disk. The pending hint lives there for the same reason.
399
424
  const fingerprintsMoved = observed > 0 || !anchor.lastFingerprints;
400
- if (fingerprintsMoved) atomicJson(anchorPath, { ...anchor, lastFingerprints: repo.fingerprints || {} });
425
+ if (fingerprintsMoved || Boolean(anchor.hintPending) !== hintPending) {
426
+ const { hintPending: _previous, ...rest } = anchor;
427
+ atomicJson(anchorPath, {
428
+ ...rest,
429
+ lastFingerprints: fingerprintsMoved ? (repo.fingerprints || {}) : anchor.lastFingerprints,
430
+ ...(hintPending ? { hintPending: true } : {}),
431
+ });
432
+ }
401
433
  return {
402
434
  record,
403
435
  path: recordPath,
@@ -274,7 +274,8 @@ ${candidates.map((c) => W.summarize(c, root)).join('\n')}
274
274
  ACTION: offer to retire them in ONE line and wait for a yes. On a yes run:
275
275
  npx @damiangilgonzalez/harness worktrees reap (the ones marked safe)
276
276
  npx @damiangilgonzalez/harness worktrees reap <path> (one the user confirmed by name)
277
- A yes in bulk covers only the safe ones; anything marked \`ask\` is confirmed on its own.
277
+ npx @damiangilgonzalez/harness worktrees reap --merged --confirm (safe + "provenance unclear" only, after a fetch; say so when offering)
278
+ A yes in bulk covers only those; any other \`ask\` (uncommitted changes, files in no commit) is confirmed on its own.
278
279
  A no holds for this session. Permanent off: .rsc/.no-worktree-cleanup
279
280
  ================================
280
281
  `;
@@ -5,9 +5,10 @@
5
5
  //
6
6
  // Enforces the "close the feature before you leave it" rule at the one deterministic
7
7
  // moment it matters: when a Bash command tries to switch to the trunk (main/master)
8
- // or merge into it. If the current feature branch has uncommitted changes or commits
9
- // that were never pushed, the guard DENIES the command and tells the agent to run
10
- // `ship` (commit → push → PR). Opening the PR itself is `ship`'s job and the skill's
8
+ // or merge into it. If the current feature branch has commits that were never pushed,
9
+ // the guard DENIES the command and tells the agent to run `ship` (commit → push → PR).
10
+ // Uncommitted changes alone only earn a hint: they may be the user's own work, and git
11
+ // already refuses a switch that would overwrite them. Opening the PR itself is `ship`'s job and the skill's
11
12
  // hard rule; this hook guarantees you cannot quietly abandon unsaved/unpushed work.
12
13
  //
13
14
  // Design: precise (only fires on a trunk switch/merge), local-only (no network, no gh),
@@ -77,10 +78,26 @@ if (!branch || branch === 'HEAD' || branch === 'main' || branch === 'master') al
77
78
 
78
79
  const tail = '\n(If this is intentional and you accept the risk, create .rsc/.no-ship-guard to disable this guard.)';
79
80
 
80
- // 1) Uncommitted work would be carried off the feature branch.
81
+ // 1) Uncommitted changes are NOT a reason to deny. They may well be the user's own work in the main
82
+ // checkout, not the agent's: in real sessions this denied five branch switches over a diff the agent
83
+ // had no business committing, and "commit them first" pushed it to do exactly that. Git itself
84
+ // refuses a switch that would overwrite local changes, and carries the rest along untouched, so the
85
+ // switch is left to git and the agent gets a hint: leave that diff alone and work in a worktree.
86
+ // The unpushed-commits rule below is the guard's real purpose and still denies.
81
87
  const dirty = git('status', '--porcelain');
82
- if (dirty && dirty.length > 0) {
83
- deny(`You're leaving feature branch "${branch}" with uncommitted changes. Commit them first — run the \`ship\` skill (commit → push → PR), don't abandon the diff.${tail}`);
88
+ const worktreeHint = dirty && dirty.length > 0
89
+ ? `The working tree on "${branch}" has uncommitted changes, and they may be the user's, not yours: ` +
90
+ `do not commit, stash or discard them to make room. If git refuses the switch, work in a separate ` +
91
+ `worktree instead: \`git worktree add .claude/worktrees/<name> -b <branch> origin/main\` ` +
92
+ `(or \`npx @damiangilgonzalez/harness worktrees new <name>\`).`
93
+ : '';
94
+ function hint() {
95
+ if (!worktreeHint) allow();
96
+ process.stdout.write(JSON.stringify({
97
+ systemMessage: 'ship-guard: hay cambios sin commitear en el árbol; no se tocan.',
98
+ hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: worktreeHint },
99
+ }));
100
+ process.exit(0);
84
101
  }
85
102
 
86
103
  // 2) Commits exist but were never pushed.
@@ -88,14 +105,14 @@ const upstream = git('rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}'
88
105
  if (upstream) {
89
106
  const ahead = git('rev-list', '--count', `${upstream}..HEAD`);
90
107
  if (ahead && Number(ahead) > 0) {
91
- deny(`Feature branch "${branch}" has ${ahead} commit(s) not pushed to ${upstream}. Push them and open the PR — run the \`ship\` skill — before switching to the trunk.${tail}`);
108
+ deny(`Feature branch "${branch}" has ${ahead} commit(s) not pushed to ${upstream}. Push them and open the PR — run the \`ship\` skill — before switching to the trunk.${worktreeHint ? `\n${worktreeHint}` : ''}${tail}`);
92
109
  }
93
110
  } else {
94
111
  // No upstream at all: if the branch carries commits beyond the trunk, it was never pushed.
95
112
  const aheadOfTrunk = git('rev-list', '--count', 'main..HEAD') ?? git('rev-list', '--count', 'master..HEAD');
96
113
  if (aheadOfTrunk && Number(aheadOfTrunk) > 0) {
97
- deny(`Feature branch "${branch}" was never pushed (no upstream, ${aheadOfTrunk} commit(s) ahead of the trunk). Push it and open a PR — run the \`ship\` skill — before leaving it.${tail}`);
114
+ deny(`Feature branch "${branch}" was never pushed (no upstream, ${aheadOfTrunk} commit(s) ahead of the trunk). Push it and open a PR — run the \`ship\` skill — before leaving it.${worktreeHint ? `\n${worktreeHint}` : ''}${tail}`);
98
115
  }
99
116
  }
100
117
 
101
- allow();
118
+ hint();
@@ -14,7 +14,7 @@
14
14
  import { existsSync } from 'node:fs';
15
15
  import { join } from 'node:path';
16
16
  import { homedir } from 'node:os';
17
- import { claimOnce, readHookInput, WORKFLOW_GATE_TEXT } from './hook-once.mjs';
17
+ import { claimOnce, readHookInput, renderGate, WORKFLOW_GATE_TEXT } from './hook-once.mjs';
18
18
 
19
19
  const root = process.argv[2] || process.cwd();
20
20
 
@@ -33,4 +33,9 @@ if (hook.session_id && turnKey && !claimOnce(`up:${hook.session_id}:${turnKey}`)
33
33
  // that is not installed sends the agent looking for something that does not exist, so the line only
34
34
  // ships when the skill is present in this project or in the user scope.
35
35
  const hasFlujo = [root, homedir()].some((r) => existsSync(join(r, '.claude', 'skills', 'flujo-interfaz', 'SKILL.md')));
36
- process.stdout.write(hasFlujo ? WORKFLOW_GATE_TEXT : WORKFLOW_GATE_TEXT.split('\n').filter((l) => !l.includes('flujo-interfaz')).join('\n'));
36
+ const gate = hasFlujo ? WORKFLOW_GATE_TEXT : WORKFLOW_GATE_TEXT.split('\n').filter((l) => !l.includes('flujo-interfaz')).join('\n');
37
+ // Skills marked `disable-model-invocation: true` are rendered as the slash command the user types,
38
+ // read from the installed SKILL.md so the gate cannot contradict the skill (see renderGate).
39
+ let out = gate;
40
+ try { out = renderGate(gate, [root, homedir()]); } catch { /* fail-safe: keep the plain gate */ }
41
+ process.stdout.write(out);
@@ -5,7 +5,7 @@
5
5
  // Reminds the agent to run a Worklog Sweep (capture what we did this session into
6
6
  // docs/raw/worklog/). The hook only reminds; the agent writes the worklog.
7
7
  // Silent when this workspace has no harness wiki yet.
8
- import { existsSync } from 'node:fs';
8
+ import { existsSync, readdirSync } from 'node:fs';
9
9
  import { join } from 'node:path';
10
10
 
11
11
  const root = process.argv[2] || process.cwd();
@@ -13,10 +13,25 @@ const root = process.argv[2] || process.cwd();
13
13
  // No harness wiki here → nothing to document into. Stay silent.
14
14
  if (!existsSync(join(root, 'docs', 'wiki'))) process.exit(0);
15
15
 
16
+ // The sweep used to open with the agent running `ls docs/raw/worklog` to see what was there, and in
17
+ // a wiki scaffolded before that folder existed (or whose .gitkeep never got committed) that printed
18
+ // an error into the session on every compaction. The hook already knows the answer, so it says it:
19
+ // what is there, or that the folder has to be created first. Nothing is left to list blindly.
20
+ // It does not create the folder itself: an untracked docs/raw/worklog/ is where the session memory
21
+ // prefers to keep its journal, and moving that store mid-session would orphan the session's anchor.
22
+ let state;
23
+ try {
24
+ const entries = readdirSync(join(root, 'docs', 'raw', 'worklog')).filter((name) => name.endsWith('.md'));
25
+ state = `docs/raw/worklog/ exists with ${entries.length} worklog file(s); no need to list it.`;
26
+ } catch {
27
+ state = 'docs/raw/worklog/ does not exist yet: create it first (mkdir -p docs/raw/worklog); do not list it.';
28
+ }
29
+
16
30
  process.stdout.write(`
17
31
  ===== rsc worklog checkpoint =====
18
32
  If this session did meaningful work (files changed, a decision made, a commit),
19
33
  run a WORKLOG SWEEP before context is lost:
34
+ ${state}
20
35
  1. Write docs/raw/worklog/<YYYY-MM-DD>-<slug>.md using the harness
21
36
  wiki-worklog-template.md (what we did · why · files touched · outcome · next).
22
37
  2. Compile it into wiki/ (update existing articles first; wikilinks + Related);