@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.
- package/README.md +117 -657
- package/manifest.json +1 -1
- package/package.json +1 -1
- package/scripts/doctor.js +1 -1
- package/scripts/install-apply.js +9 -0
- package/scripts/rsc.js +90 -2
- package/skills/building-agents/SKILL.md +1 -1
- 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 +0 -1
- package/skills/implement-spec/SKILL.md +1 -2
- package/skills/init/SKILL.md +7 -5
- package/skills/postgresdb/SKILL.md +1 -1
- package/skills/suggest/SKILL.md +17 -13
- package/skills/suggest/evals/cases.yaml +6 -0
- 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 +2 -1
- package/targets/clone-bootstrap.mjs +1 -1
- package/targets/danger-guard.mjs +114 -52
- package/targets/hook-once.mjs +44 -13
- package/targets/knowledge-sync.mjs +759 -0
- package/targets/knowledge-wiring.js +196 -0
- package/targets/memory.js +7 -1
- package/targets/opencode-memory-plugin.js +67 -22
- package/targets/opt-outs.js +4 -0
- package/targets/session-memory-adapter.mjs +51 -6
- package/targets/session-memory-core.mjs +284 -37
- package/targets/session-start.mjs +2 -1
- package/targets/ship-guard.mjs +26 -9
- package/targets/trunk-policy.mjs +86 -0
- 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.
|