@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.
- package/README.md +113 -658
- package/manifest.json +2 -2
- package/package.json +1 -1
- package/scripts/doctor.js +1 -1
- package/scripts/rsc.js +63 -1
- package/skills/building-agents/SKILL.md +1 -1
- package/skills/code-review/SKILL.md +3 -1
- package/skills/flujo-interfaz/SKILL.md +7 -0
- package/skills/grill-with-docs/SKILL.md +0 -1
- package/skills/harness/references/daily-curation-automation.md +2 -1
- package/skills/harness/references/wiki-protocol.md +3 -1
- package/skills/implement/SKILL.md +2 -1
- package/skills/implement-spec/SKILL.md +5 -4
- package/skills/init/SKILL.md +7 -5
- package/skills/orient/SKILL.md +3 -0
- package/skills/orient/references/orientation-contract.md +9 -0
- package/skills/postgresdb/SKILL.md +1 -1
- package/skills/pr/SKILL.md +15 -1
- package/skills/pr/evals/cases.yaml +6 -0
- package/skills/suggest/SKILL.md +5 -5
- package/skills/to-spec/SKILL.md +0 -1
- package/skills/to-tickets/SKILL.md +0 -1
- package/skills/wayfinder/SKILL.md +0 -1
- package/targets/_md-block.js +1 -1
- package/targets/claude.js +21 -1
- package/targets/danger-guard.mjs +114 -52
- package/targets/hook-once.mjs +44 -13
- package/targets/memory.js +6 -1
- package/targets/session-memory-adapter.mjs +4 -1
- package/targets/session-memory-core.mjs +40 -8
- package/targets/session-start.mjs +2 -1
- package/targets/ship-guard.mjs +26 -9
- package/targets/userprompt-gate.mjs +7 -2
- package/targets/worklog-checkpoint.mjs +16 -1
- package/targets/worktree-reaper.mjs +144 -4
package/targets/danger-guard.mjs
CHANGED
|
@@ -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
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
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
|
-
//
|
|
12
|
-
//
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
50
|
+
const level = technicalLevel();
|
|
51
|
+
if (level === 'technical') allow();
|
|
45
52
|
|
|
46
|
-
// Only
|
|
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
|
|
51
|
-
if (typeof
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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: '
|
|
117
|
-
{ id: 'find-delete', why: '
|
|
118
|
-
{ id: 'dd-disk', why: '
|
|
119
|
-
{ id: 'mkfs', why: '
|
|
120
|
-
{ id: 'curl-pipe-shell', why: '
|
|
121
|
-
|
|
122
|
-
{ id: 'git-push-force', why: '
|
|
123
|
-
{ id: 'git-reset-hard', why: '
|
|
124
|
-
{ id: 'git-clean', why: '
|
|
125
|
-
{ id: 'git-branch-D', why: '
|
|
126
|
-
|
|
127
|
-
{ id: 'sql-drop', why: '
|
|
128
|
-
{ id: 'sql-truncate', why: '
|
|
129
|
-
{ id: 'sql-delete-no-where', why: '
|
|
130
|
-
{ id: 'sql-update-no-where', why: '
|
|
131
|
-
{ id: 'mongo-wipe', why: '
|
|
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())
|
|
197
|
+
try { if (rule.match()) stop(rule.why); } catch { /* a rule erroring must never block */ }
|
|
136
198
|
}
|
|
137
199
|
|
|
138
200
|
allow();
|
package/targets/hook-once.mjs
CHANGED
|
@@ -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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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' ?
|
|
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
|
-
|
|
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
|
|
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 ===
|
|
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
|
-
|
|
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 &&
|
|
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
|
|
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
|
-
|
|
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
|
`;
|
package/targets/ship-guard.mjs
CHANGED
|
@@ -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
|
|
9
|
-
//
|
|
10
|
-
//
|
|
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
|
|
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
|
-
|
|
83
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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);
|