@damiangilgonzalez/harness 1.3.17 → 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 +1 -1
- 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/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 +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 +2 -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/manifest.json
CHANGED
package/package.json
CHANGED
package/scripts/doctor.js
CHANGED
|
@@ -261,7 +261,7 @@ export function doctor({ target, home, cwd }) {
|
|
|
261
261
|
// #277 — the three "missing" lists share one shape, { id, path, action }, so whoever builds on top
|
|
262
262
|
// of doctor reads them the same way; and `healthy` is what the exit code says.
|
|
263
263
|
for (const [id, e] of Object.entries(state.skills)) {
|
|
264
|
-
for (const f of e.files) if (!existsSync(f)) report.missing.push({ id, path: f, action: 'Run `npx @
|
|
264
|
+
for (const f of e.files) if (!existsSync(f)) report.missing.push({ id, path: f, action: 'Run `npx @damiangilgonzalez/harness sync` to restore this managed skill.' });
|
|
265
265
|
}
|
|
266
266
|
report.healthy = report.hookWired && !report.missing.length && !missingAgents.length && !missingCommands.length;
|
|
267
267
|
return report;
|
package/scripts/rsc.js
CHANGED
|
@@ -885,11 +885,72 @@ async function main() {
|
|
|
885
885
|
const root = W.resolveMainRoot(process.cwd());
|
|
886
886
|
const sub = argv[1];
|
|
887
887
|
|
|
888
|
+
if (sub === 'new') {
|
|
889
|
+
// Creating is not cleaning, so the cleanup opt-out does not apply here. Two things a worktree
|
|
890
|
+
// was measured lacking when it is born: a current base (local main was 400 commits behind
|
|
891
|
+
// origin) and what git does not track (.env, dependencies). The base is fixed here; the rest
|
|
892
|
+
// is the project's own hook, because only the project knows what makes it runnable.
|
|
893
|
+
const branchAt = argv.indexOf('--branch');
|
|
894
|
+
const branch = branchAt >= 0 ? argv[branchAt + 1] : undefined;
|
|
895
|
+
if (branchAt >= 0 && (!branch || branch.startsWith('--'))) { say('rsc: --branch needs a branch name.'); process.exitCode = 1; return; }
|
|
896
|
+
const name = argv.slice(2).find((a, i, all) => !a.startsWith('--') && all[i - 1] !== '--branch');
|
|
897
|
+
if (!name) { say('Use: npx @damiangilgonzalez/harness worktrees new <nombre> [--branch <rama>]'); process.exitCode = 1; return; }
|
|
898
|
+
const out = W.createWorktree(root, name, { branch });
|
|
899
|
+
if (!out.created) { say(`rsc: ${out.reason}`); process.exitCode = 1; return; }
|
|
900
|
+
if (!out.fetched) say(`⚠️ git fetch origin failed (${out.fetchError || 'no detail'}); starting from the last known ${out.base}, which may be stale.`);
|
|
901
|
+
say(`✅ worktree ${out.path}`);
|
|
902
|
+
say(` branch ${out.branch}, from ${out.base}`);
|
|
903
|
+
const hook = W.findPrepareHook(out.path, root);
|
|
904
|
+
if (hook) {
|
|
905
|
+
say(` preparing it: ${hook}`);
|
|
906
|
+
const ran = W.runPrepareHook(hook, out.path, root);
|
|
907
|
+
if (!ran.ok) { say(`⚠️ the preparation hook failed (${ran.reason}). The worktree exists; finish preparing it by hand.`); process.exitCode = 1; }
|
|
908
|
+
} else {
|
|
909
|
+
say(` No preparation hook (${W.PREPARE_HOOKS.join(' or ')}): .env files and dependencies are NOT copied — create the hook to do it on every new worktree.`);
|
|
910
|
+
}
|
|
911
|
+
say(`Next: cd "${out.path}"`);
|
|
912
|
+
return;
|
|
913
|
+
}
|
|
914
|
+
|
|
888
915
|
if (!W.isCleanupEnabled(root)) {
|
|
889
916
|
say(`worktree cleanup is off for this project (.rsc/${W.OPT_OUT}). Delete that file to turn it back on.`);
|
|
890
917
|
return;
|
|
891
918
|
}
|
|
892
919
|
|
|
920
|
+
if (sub === 'reap' && argv.includes('--merged')) {
|
|
921
|
+
// The bulk answer to "delete the worktrees" — asked four times in one project, with up to
|
|
922
|
+
// fourteen open. Merged is judged against origin's trunk AFTER a fetch, so a branch landed in
|
|
923
|
+
// the forge an hour ago counts. Without --confirm this only lists: one look, then one yes.
|
|
924
|
+
const fetched = W.fetchOrigin(root);
|
|
925
|
+
if (!fetched.ok) say(`⚠️ git fetch origin failed (${fetched.err || 'no detail'}); judging against the last known trunk.`);
|
|
926
|
+
const all = W.classifyWorktrees(root);
|
|
927
|
+
const merged = W.mergedCandidates(all);
|
|
928
|
+
const held = all.filter((c) => c.verdict === 'ask' && !merged.includes(c));
|
|
929
|
+
if (!merged.length) say('No merged worktree that can be removed in bulk.');
|
|
930
|
+
if (!argv.includes('--confirm')) {
|
|
931
|
+
if (merged.length) {
|
|
932
|
+
say(`${merged.length} merged worktree(s), clean, removable in one go:`);
|
|
933
|
+
for (const c of merged) say(` ${c.path}${c.branch ? ` (${c.branch})` : ''}`);
|
|
934
|
+
}
|
|
935
|
+
for (const c of held) say(W.describe(c));
|
|
936
|
+
if (merged.length) say('\nRemove them all: npx @damiangilgonzalez/harness worktrees reap --merged --confirm');
|
|
937
|
+
return;
|
|
938
|
+
}
|
|
939
|
+
for (const c of merged) {
|
|
940
|
+
const out = W.reapWorktree(root, c.path, { confirmed: true });
|
|
941
|
+
if (out.removed) {
|
|
942
|
+
say(`✅ removed ${out.path}`);
|
|
943
|
+
if (out.branchKept) say(` branch ${out.branch} kept — git will not delete it safely, and while it exists the commits are recoverable.`);
|
|
944
|
+
} else {
|
|
945
|
+
say(`⏸ kept ${c.path} — ${out.reason}`);
|
|
946
|
+
process.exitCode = 1;
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
// What a bulk yes never covers, said out loud so nobody thinks it was forgotten.
|
|
950
|
+
for (const c of held) say(`⏸ kept ${c.path} — ${W.refusal(c)}`);
|
|
951
|
+
return;
|
|
952
|
+
}
|
|
953
|
+
|
|
893
954
|
const candidates = W.classifyWorktrees(root).filter((c) => c.verdict !== 'skip');
|
|
894
955
|
|
|
895
956
|
if (sub === 'reap') {
|
|
@@ -920,6 +981,7 @@ async function main() {
|
|
|
920
981
|
say('');
|
|
921
982
|
say('Remove the safe ones: npx @damiangilgonzalez/harness worktrees reap');
|
|
922
983
|
say('Remove one by name: npx @damiangilgonzalez/harness worktrees reap <path> [--confirm]');
|
|
984
|
+
say('Remove every merged: npx @damiangilgonzalez/harness worktrees reap --merged [--confirm]');
|
|
923
985
|
return;
|
|
924
986
|
}
|
|
925
987
|
case 'capabilities': {
|
|
@@ -1195,7 +1257,7 @@ async function main() {
|
|
|
1195
1257
|
return void (await runPurge(argv.includes('--dry-run'), argv.includes('--with-docs')));
|
|
1196
1258
|
default:
|
|
1197
1259
|
say(`rsc: unknown command '${cmd}'.`);
|
|
1198
|
-
say('Use: npx @damiangilgonzalez/harness onboard | reassess | add <id...> | install --profile <p> | consult "<text>" | list | capabilities [--full|gap-log] | audit | registry refresh | doctor | sync | memory <on|off|status|save|resume|learn|metrics> | sello <on|off|status|…> | worktrees [reap [path] [--confirm]] | backups | restore <id|latest> | upgrade | repair | uninstall <id> | purge');
|
|
1260
|
+
say('Use: npx @damiangilgonzalez/harness onboard | reassess | add <id...> | install --profile <p> | consult "<text>" | list | capabilities [--full|gap-log] | audit | registry refresh | doctor | sync | memory <on|off|status|save|resume|learn|metrics> | sello <on|off|status|…> | worktrees [new <nombre> [--branch <rama>] | reap [path|--merged] [--confirm]] | backups | restore <id|latest> | upgrade | repair | uninstall <id> | purge');
|
|
1199
1261
|
say('Any command takes --target <claude|codex|cursor|copilot|gemini|…> (comma-separate for several)');
|
|
1200
1262
|
say(' → without it, rsc uses the assistant already installed here; if two are, it asks instead of guessing.');
|
|
1201
1263
|
process.exitCode = 1;
|
|
@@ -14,7 +14,7 @@ A thin provider adapter, a disciplined agent loop, schema-validated tools, provi
|
|
|
14
14
|
|
|
15
15
|
> Program against a **capability interface**, never a vendor SDK. Vendor specifics (model id, tool-schema shape, JSON mode, caching, token limits) live behind one adapter resolved from config. Model names and prices rot — if one appears in business logic it's a bug, and re-verify the dated tables before quoting a number.
|
|
16
16
|
|
|
17
|
-
**Hand off instead when:** a new non-trivial feature has no approved spec + plan under `docs/wiki/sdd/` → stop and write one first (the `to-spec` skill,
|
|
17
|
+
**Hand off instead when:** a new non-trivial feature has no approved spec + plan under `docs/wiki/sdd/` → stop and write one first (the `to-spec` skill, then `to-tickets`), which routes back here once the plan is approved; one-line/low-risk changes go straight through. Anthropic-SDK internals (caching, thinking, batch) in a file that *only* imports `anthropic` → `claude-api` if your environment has it, since this skill stays multi-provider. Workspace scaffolding → [`harness`](../harness/SKILL.md). Choosing *which* coding agent to use → agent-eval territory. Pure prompt-wording tuning with no architecture change → prompt engineering, not this. A one-shot throwaway prompt, or no retrieval/tools/loop/evals at all → you don't need an agent; call the SDK directly and say so.
|
|
18
18
|
|
|
19
19
|
## Decision rules (read before writing code)
|
|
20
20
|
|
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
name: grill-with-docs
|
|
3
3
|
description: "A relentless interrogation to sharpen a plan or design, which also builds documentation (ADRs and a glossary) along the way."
|
|
4
4
|
tags: ["planning", "questioning", "documentation", "adr"]
|
|
5
|
-
disable-model-invocation: true
|
|
6
5
|
profiles: [core, ui, full]
|
|
7
6
|
origin: damiangil
|
|
8
7
|
---
|
|
@@ -42,7 +42,8 @@ process inbox/, then scan the workspace (minus .rscignore) for un-ingested docum
|
|
|
42
42
|
applying Clean-as-you-go (move loose root-level + inbox files into raw/ after a
|
|
43
43
|
verified ingest; copy files nested in user-maintained folders and propose
|
|
44
44
|
consolidation), record them in wiki/.ingested.json, and list ambiguous folders as
|
|
45
|
-
proposals (do not grab them). Then compile pending raw/worklog/ material
|
|
45
|
+
proposals (do not grab them). Then compile pending raw/worklog/ material (skip this
|
|
46
|
+
step if raw/worklog/ does not exist; do not list a missing folder) into wiki/
|
|
46
47
|
pages (update existing before creating new), keep frontmatter + relative markdown
|
|
47
48
|
links OKF-consistent, append significant decisions to wiki/harness/decisions.md,
|
|
48
49
|
refresh the .base-backed navigation, recompute scores.json + the score: property,
|
|
@@ -417,7 +417,9 @@ safety rule the curation pass honors.
|
|
|
417
417
|
### Steps
|
|
418
418
|
|
|
419
419
|
1. **Capture.** Write the worklog to `raw/worklog/YYYY-MM-DD-<slug>.md`
|
|
420
|
-
(`status: unprocessed`). Infer `topic` from content.
|
|
420
|
+
(`status: unprocessed`). Infer `topic` from content. If `raw/worklog/` does
|
|
421
|
+
not exist yet, create it (`mkdir -p`) — never list it blindly: the checkpoint
|
|
422
|
+
hook already says whether it exists and how many worklogs it holds.
|
|
421
423
|
2. **Compile.** Run the normal Compile (Fetch is a no-op — the worklog is already
|
|
422
424
|
`raw`): distill durable points into `wiki/<topic>/` articles (update existing
|
|
423
425
|
before creating new), add markdown-link cross-refs + `## Related`, cascade.
|
|
@@ -2,12 +2,11 @@
|
|
|
2
2
|
name: implement-spec
|
|
3
3
|
description: "Implement the result of /to-spec and /to-tickets in code, orchestrating parallel implementer subagents over the ticket graph."
|
|
4
4
|
tags: ["implementation", "spec", "tickets", "subagents", "orchestration"]
|
|
5
|
-
disable-model-invocation: true
|
|
6
5
|
profiles: [core, ui, full]
|
|
7
6
|
origin: damiangil
|
|
8
7
|
---
|
|
9
8
|
|
|
10
|
-
Te han dado una spec. Esa spec debería tener tickets asociados que describen cómo implementarla.
|
|
9
|
+
Te han dado una spec. Esa spec debería tener tickets asociados que describen cómo implementarla. Si no los tiene, **para**: lanza `to-tickets` sobre la spec y vuelve aquí con el grafo ya publicado. Sin tickets no hay frontera que repartir, y esto se convierte en una implementación a ciegas.
|
|
11
10
|
|
|
12
11
|
El issue tracker debería estar descrito en `docs/agents/issue-tracker.md`. Si falta, pregunta al usuario dónde viven los tickets y cómo se cierra el trabajo (PR, issue, fichero).
|
|
13
12
|
|
package/skills/init/SKILL.md
CHANGED
|
@@ -165,11 +165,13 @@ means the user starts clean.
|
|
|
165
165
|
machine and flags overlap or skills with no footprint, so the project starts with the right set
|
|
166
166
|
rather than a pile. Summarize at their level.
|
|
167
167
|
4. **Tell them about the danger guard.** A `technical_level` of `non-technical` or `mixed` (and the
|
|
168
|
-
state before any profile exists) turns on a `PreToolUse` guard
|
|
169
|
-
`rm -rf`, `git push --force`, `git reset --hard`, `DROP`/`TRUNCATE`,
|
|
170
|
-
`WHERE`, `dd` to a device, `curl | bash
|
|
171
|
-
|
|
172
|
-
|
|
168
|
+
state before any profile exists) turns on a `PreToolUse` guard for irreversible commands —
|
|
169
|
+
`rm -rf`, `git push --force`, `git reset --hard`, `git branch -D`, `DROP`/`TRUNCATE`,
|
|
170
|
+
`DELETE`/`UPDATE` with no `WHERE`, `dd` to a device, `curl | bash`. For `non-technical` (or no
|
|
171
|
+
profile) Claude Code asks the user to confirm each one, saying what the command destroys; their
|
|
172
|
+
yes is the order that runs it. For `mixed` it only warns. A fully `technical` user is never
|
|
173
|
+
guarded. It turns off only if the user explicitly asks: `.rsc/.no-danger-guard`.
|
|
174
|
+
Mention it when you set a non-technical level, so a later confirmation is not a surprise.
|
|
173
175
|
|
|
174
176
|
### Phase 5 — HANDOFF
|
|
175
177
|
|
|
@@ -13,7 +13,7 @@ fix slow SQL, run zero-downtime migrations, and operate/secure the database. Too
|
|
|
13
13
|
example is runnable.
|
|
14
14
|
|
|
15
15
|
New, non-trivial feature with no approved spec + plan under `docs/wiki/sdd/`? Hand off to
|
|
16
|
-
the `to-spec` skill before writing feature code (
|
|
16
|
+
the `to-spec` skill before writing feature code (then `to-tickets`); build
|
|
17
17
|
straight from here only for a genuinely one-line, low-risk change.
|
|
18
18
|
|
|
19
19
|
Deep dives: [schema-and-indexing](references/schema-and-indexing.md) (types, constraints, every index
|
package/skills/suggest/SKILL.md
CHANGED
|
@@ -34,8 +34,8 @@ The shape of the chain:
|
|
|
34
34
|
when the effort will not fit one sitting.
|
|
35
35
|
- Once the shape is known, capture it as a spec, and give any call that would be costly to undo
|
|
36
36
|
its own ADR.
|
|
37
|
-
-
|
|
38
|
-
|
|
37
|
+
- Small work builds straight from the clarified ask; big work is split into tickets first and
|
|
38
|
+
built from them. A screen also routes through `flujo-interfaz`.
|
|
39
39
|
- Review the finished change against the repo's standards and against the spec before merging.
|
|
40
40
|
|
|
41
41
|
Two exceptions, and say out loud when you take one:
|
|
@@ -54,9 +54,9 @@ whole run — advance through the phases without re-asking.
|
|
|
54
54
|
If a step's skill is not installed, offer to add it (§2) before routing. `implement` always
|
|
55
55
|
pairs with test-driven development and a verification pass before anything is called done.
|
|
56
56
|
|
|
57
|
-
Exact skill names
|
|
58
|
-
`
|
|
59
|
-
`
|
|
57
|
+
Exact skill names, by size. Small: `superpowers:brainstorming` or `grill-with-docs` ->
|
|
58
|
+
`implement`. Big: `grill-with-docs` or `wayfinder` -> `to-spec` -> `to-tickets` ->
|
|
59
|
+
`implement-spec`. Both: `write-adr` for decisions, then `code-review` -> `pr`.
|
|
60
60
|
On Claude Code this rule also arrives as a per-turn hook; the brevity here is deduplication,
|
|
61
61
|
not relaxation.
|
|
62
62
|
|
package/skills/to-spec/SKILL.md
CHANGED
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
name: to-spec
|
|
3
3
|
description: "Turns the current conversation into a spec (PRD) and publishes it to the project's issue tracker — no interview, just a synthesis of what has already been discussed."
|
|
4
4
|
tags: ["spec", "prd", "issue-tracker"]
|
|
5
|
-
disable-model-invocation: true
|
|
6
5
|
profiles: [core, ui, full]
|
|
7
6
|
origin: damiangil
|
|
8
7
|
---
|
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
name: to-tickets
|
|
3
3
|
description: "Breaks down a plan, a spec, or the current conversation into a set of \\"tracer bullet\\" tickets (vertical slices), each declaring which tickets block it, and publishes them to the configured tracker — as local files (one per ticket) or as issues with native blocking links in a real tracker."
|
|
4
4
|
tags: ["tickets", "planning", "issue-tracker"]
|
|
5
|
-
disable-model-invocation: true
|
|
6
5
|
profiles: [core, ui, full]
|
|
7
6
|
origin: damiangil
|
|
8
7
|
---
|
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
name: wayfinder
|
|
3
3
|
description: "Plans a huge chunk of work — more than fits in a single agent session — as a shared map of decision tickets in the repo's issue tracker, and resolves them one at a time until the path to the destination is clear."
|
|
4
4
|
tags: ["planning", "issue-tracker", "large-scale-work"]
|
|
5
|
-
disable-model-invocation: true
|
|
6
5
|
profiles: [core, ui, full]
|
|
7
6
|
origin: damiangil
|
|
8
7
|
---
|
package/targets/_md-block.js
CHANGED
|
@@ -21,7 +21,7 @@ export function writeSkill(id, fromDir, toPath) {
|
|
|
21
21
|
export function wireHook(paths, sourceMd, policy = {}) {
|
|
22
22
|
const full = stripFrontmatter(readFileSync(sourceMd, 'utf8'));
|
|
23
23
|
const body = policy.codeHooks === false
|
|
24
|
-
? `# rsc-suggest — always-on operations layer\n\nRead \`docs/wiki/harness/user-profile.md\` before acting. If the user wants to build, add or change something,
|
|
24
|
+
? `# rsc-suggest — always-on operations layer\n\nRead \`docs/wiki/harness/user-profile.md\` before acting. If the user wants to build, add or change something, pick the chain by size and chain each step yourself. Small (one sitting, one ticket): clarify with \`superpowers:brainstorming\` (idea still vague) or \`grill-with-docs\` (a plan to harden), then build with \`implement\`. Big: \`grill-with-docs\` or \`wayfinder\` -> \`to-spec\` -> \`to-tickets\` -> \`implement-spec\`. Hard-to-reverse decisions go to \`write-adr\`; review with \`code-review\` and write the PR with \`pr\` before merging (si toca pantalla, \`flujo-interfaz\` dice que skill de diseno manda) — unless it is a one-line change, typo, or config bump, which skips the chain. Use \`orient\` to keep the user situated and \`suggest\` to offer a missing skill only when the current task needs it. Close with the configured orientation block.\n`
|
|
25
25
|
: full;
|
|
26
26
|
const block = `${MARK_START}\n${body}\n${MARK_END}`;
|
|
27
27
|
let doc = existsSync(paths.hookTarget) ? readFileSync(paths.hookTarget, 'utf8') : '';
|
package/targets/claude.js
CHANGED
|
@@ -175,7 +175,8 @@ export function wireHook(paths, sourceMd, policy = {}) {
|
|
|
175
175
|
|
|
176
176
|
if (policy.codeHooks !== false) {
|
|
177
177
|
// Ship guard: a PreToolUse(Bash) hook that DENIES switching to / merging the trunk
|
|
178
|
-
// while the current feature branch has
|
|
178
|
+
// while the current feature branch has unpushed commits (uncommitted changes only get a
|
|
179
|
+
// worktree hint: they may be the user's own) — forcing the
|
|
179
180
|
// commit → push → PR close (the `ship` skill). Materialized + node-run (Windows-safe),
|
|
180
181
|
// registered idempotently, fail-open, opt-out via .rsc/.no-ship-guard. Other
|
|
181
182
|
// (non-rsc) PreToolUse hooks are preserved.
|
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.
|