geneseed 3.2.0 → 3.4.0
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/CHANGELOG.md +150 -0
- package/README.md +24 -14
- package/SETUP.md +38 -7
- package/SHIPPED.md +6 -5
- package/adapters/bob/README.md +40 -10
- package/adapters/claude-code/README.md +5 -0
- package/adapters/claude-code/settings.json +8 -1
- package/adapters/copilot/README.md +52 -25
- package/adapters/opencode/README.md +11 -3
- package/adapters/opencode/plugins/geneseed-guard.js +40 -6
- package/bin/build-driver.mjs +16 -10
- package/bin/geneseed-cli.mjs +55 -56
- package/bin/geneseed-hook.mjs +21 -14
- package/docs/candidate-hosts.md +74 -0
- package/docs/extending.md +10 -8
- package/docs/token-footprint.md +32 -21
- package/docs/web/agents.md +1 -1
- package/docs/web/first-session.md +1 -1
- package/docs/web/skills.md +1 -1
- package/docs/web-ui.md +5 -13
- package/harness.config.json +1 -1
- package/js/README.md +5 -6
- package/js/build/emit-claude.mjs +22 -7
- package/js/build/render.mjs +37 -19
- package/js/build/source.mjs +22 -2
- package/js/cli-table.json +84 -0
- package/js/hosts/hooks.mjs +240 -78
- package/js/hosts/hosts.mjs +56 -2
- package/js/hosts/link.mjs +1 -1
- package/js/hosts/mcp.mjs +3 -1
- package/js/hosts/native.mjs +86 -15
- package/js/hosts/opencode.mjs +14 -0
- package/js/hosts/settings.mjs +173 -5
- package/js/inspect/excludes.mjs +1 -1
- package/js/inspect/inventory.mjs +13 -8
- package/js/inspect/status.mjs +50 -2
- package/js/maintain/uninstall.mjs +38 -11
- package/js/maintain/update.mjs +2 -2
- package/js/web/actions.mjs +8 -8
- package/js/web/api.mjs +5 -10
- package/js/web/docs.mjs +9 -31
- package/js/web/handler.mjs +20 -30
- package/js/web/jobs.mjs +5 -26
- package/js/web/routes.mjs +18 -84
- package/js/web/server.mjs +3 -3
- package/package.json +1 -1
- package/registry.json +99 -78
- package/src/AGENT.md.tmpl +69 -28
- package/src/agents/architect.md +4 -1
- package/src/agents/explorer.md +3 -1
- package/src/agents/operator.md +13 -0
- package/src/agents/pragmatist.md +12 -0
- package/src/agents/researcher.md +72 -0
- package/src/agents/security.md +7 -1
- package/src/agents/skeptic.md +2 -1
- package/src/agents/tester.md +5 -1
- package/src/agents/user-advocate.md +13 -0
- package/src/doctrines/README.md +3 -3
- package/src/doctrines/rigor.md +14 -1
- package/src/laws/universal.md +98 -20
- package/src/ontology/universal.md +38 -13
- package/src/skills/_self-improvement.md +19 -0
- package/src/skills/_template.md +6 -13
- package/src/skills/brainstorm.md +37 -19
- package/src/skills/bruno-collection-generator.md +52 -0
- package/src/skills/bruno-test-writer.md +51 -0
- package/src/skills/ci-fix.md +49 -0
- package/src/skills/codebase-design.md +1 -12
- package/src/skills/commit.md +1 -12
- package/src/skills/consolidate-memory.md +50 -0
- package/src/skills/council.md +1 -12
- package/src/skills/debug.md +1 -12
- package/src/skills/deps-audit.md +46 -0
- package/src/skills/develop.md +38 -20
- package/src/skills/document-project.md +1 -12
- package/src/skills/domain-modeling.md +1 -12
- package/src/skills/forge-mcp.md +2 -13
- package/src/skills/fresh-eyes.md +1 -12
- package/src/skills/frontend-design.md +1 -12
- package/src/skills/geneseed-code-review.md +1 -12
- package/src/skills/geneseed.md +1 -12
- package/src/skills/git-archaeology.md +1 -12
- package/src/skills/git-rescue.md +1 -12
- package/src/skills/handoff.md +1 -12
- package/src/skills/herdr.md +2 -12
- package/src/skills/ingest.md +1 -12
- package/src/skills/migrate.md +1 -12
- package/src/skills/opencode-theme.md +2 -12
- package/src/skills/parallel-agents.md +1 -12
- package/src/skills/pipeline.md +2 -13
- package/src/skills/plan.md +1 -12
- package/src/skills/ponytail.md +1 -12
- package/src/skills/profile.md +1 -12
- package/src/skills/prose.md +1 -12
- package/src/skills/prototype.md +1 -12
- package/src/skills/quiz.md +43 -0
- package/src/skills/refactor.md +1 -12
- package/src/skills/release.md +1 -12
- package/src/skills/repo-map.md +1 -12
- package/src/skills/research.md +6 -14
- package/src/skills/review-response.md +1 -12
- package/src/skills/roast-me.md +1 -12
- package/src/skills/rule.md +1 -12
- package/src/skills/security-audit.md +51 -0
- package/src/skills/ship.md +1 -12
- package/src/skills/skill-forge.md +59 -0
- package/src/skills/teach.md +50 -0
- package/src/skills/tickets.md +1 -12
- package/src/skills/wayfinder.md +1 -12
- package/src/skills/wiki.md +1 -12
- package/src/skills/workflow.md +74 -26
- package/src/skills/worktree.md +52 -0
- package/themes/_TEMPLATE.json +12 -8
- package/themes/biker.json +12 -8
- package/themes/commentator.json +12 -8
- package/themes/cyberpunk.json +12 -8
- package/themes/gamer.json +12 -8
- package/themes/imperial.json +12 -8
- package/themes/joker.json +12 -8
- package/themes/marvin.json +12 -8
- package/themes/mean.json +12 -8
- package/themes/military.json +12 -8
- package/themes/neutral.json +12 -8
- package/themes/pirate.json +12 -8
- package/themes/sports.json +12 -8
- package/themes/verstappen.json +12 -8
- package/themes/wizard.json +12 -8
- package/web/dist/assets/Activity-DMGhhjQc.js +1 -0
- package/web/dist/assets/ActivityDetail-CeQcDA7e.js +1 -0
- package/web/dist/assets/{CatalogRow-DqS4e5XM.js → CatalogRow-CnSbDPyg.js} +1 -1
- package/web/dist/assets/{Diff-CIrGbiqm.js → Diff-D_ksMBD1.js} +1 -1
- package/web/dist/assets/{Doctor-CpI53oHY.js → Doctor-Brb3_oCT.js} +1 -1
- package/web/dist/assets/FilterInput-DbRWPhtT.js +1 -0
- package/web/dist/assets/GreenhouseView-F3VNFr73.js +2 -0
- package/web/dist/assets/{Harness-vRYs11kX.js → Harness-DJSwfYeF.js} +1 -1
- package/web/dist/assets/JournalView-BLRYRnB3.js +1 -0
- package/web/dist/assets/Laws-CKYns9qe.js +3 -0
- package/web/dist/assets/Library-BK0YloSv.js +1 -0
- package/web/dist/assets/{Markdown-kgpg9EQM.js → Markdown-BzTRzc7a.js} +1 -1
- package/web/dist/assets/OperatorHudView-D3OQSGp5.js +2 -0
- package/web/dist/assets/{Profile-CHr-PPf6.js → Profile-CPA2u_0g.js} +1 -1
- package/web/dist/assets/{Rules-CsC6hfqk.js → Rules-Lukel-Ru.js} +1 -1
- package/web/dist/assets/Skills-BKRLHH7m.js +1 -0
- package/web/dist/assets/{StatusBadge-gRrak0nO.js → StatusBadge-DI3weRKd.js} +1 -1
- package/web/dist/assets/{activity-B0-FXZCj.js → activity-C_H2Bx1P.js} +1 -1
- package/web/dist/assets/{index-Bx3I7cRW.js → index-B7_Wga23.js} +1 -1
- package/web/dist/assets/index-DzdQjvM1.css +1 -0
- package/web/dist/assets/{index-DJOhOwwR.js → index-N8dTrVro.js} +1 -1
- package/web/dist/assets/index-Py_IQjgK.js +10 -0
- package/web/dist/assets/jobBuckets-DfTUJUxR.js +1 -0
- package/web/dist/assets/lawCats-DUS9UTH4.js +1 -0
- package/web/dist/index.html +2 -2
- package/web/src/pages/Laws.jsx +82 -64
- package/js/web/graph.mjs +0 -0
- package/src/skills/clarify.md +0 -28
- package/src/skills/crash-course.md +0 -28
- package/src/skills/decode.md +0 -28
- package/src/skills/drill.md +0 -28
- package/src/skills/feynman.md +0 -26
- package/src/skills/gap-detector.md +0 -27
- package/src/skills/learning-path.md +0 -26
- package/src/skills/tdd.md +0 -31
- package/web/dist/assets/Activity-BTCG2gMl.js +0 -1
- package/web/dist/assets/ActivityDetail-julsmPDB.js +0 -1
- package/web/dist/assets/Graph-CS4tA7YS.js +0 -1
- package/web/dist/assets/Laws-BjbHJrJt.js +0 -3
- package/web/dist/assets/Library-CjC7BAWN.js +0 -1
- package/web/dist/assets/Skills-BvatJGfe.js +0 -1
- package/web/dist/assets/index-BnRWQbZb.js +0 -12
- package/web/dist/assets/index-D0f9tJlA.css +0 -1
- package/web/dist/assets/roman-DkYN0LX9.js +0 -1
package/js/hosts/hooks.mjs
CHANGED
|
@@ -1,9 +1,21 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The
|
|
2
|
+
* The five HOOK verbs — `context`, `git-gate`, `rule-gate`, `tool-gate`, `learn` — in Node.
|
|
3
3
|
*
|
|
4
4
|
* These are the commands the emitted `settings.json` actually invokes on a user's machine,
|
|
5
|
-
* every session: a SessionStart injector, two PreToolUse gates
|
|
6
|
-
*
|
|
5
|
+
* every session: a SessionStart injector, two PreToolUse gates (and `tool-gate`, the two
|
|
6
|
+
* fused for a host that runs ONE command per event), and a Stop/SubagentStop/PreCompact
|
|
7
|
+
* distiller.
|
|
8
|
+
*
|
|
9
|
+
* ONE VERB, TWO DIALECTS. `--host` names the host that will read the verdict. Claude Code
|
|
10
|
+
* (the default) reads `hookSpecificOutput.permissionDecision: "ask"` and shows the user a
|
|
11
|
+
* prompt. GitHub Copilot's `toolCall` hook has no ask tier — its stdout contract is
|
|
12
|
+
* `{"block": true|false}` — so on `--host copilot` the two Laws that admit no judgement call
|
|
13
|
+
* (I, IV) BLOCK and everything that was an ask (process 1, process 5, a gate error) is a
|
|
14
|
+
* warning on stderr, which Copilot logs. The same stance the OpenCode guard plugin takes
|
|
15
|
+
* where its `tool.execute.before` can only allow or throw. `--host bob` is Bob's own
|
|
16
|
+
* protocol: PreToolUse ignores stdout and refuses only on EXIT CODE 2, so the two Laws
|
|
17
|
+
* exit 2 with the reason on stderr and the rest is a stderr line with exit 0; SessionStart
|
|
18
|
+
* context is plain stdout, as on Claude. Since P5b they are also what the emitted hooks name: `bin/build-driver.mjs` bakes
|
|
7
19
|
* `<node> <checkout>/bin/geneseed-hook.mjs` into the machine-wide shim, so an install this
|
|
8
20
|
* driver emits has no Python in its hook path at all — which is what let the interpreter
|
|
9
21
|
* discovery and its exit-4 refusal be deleted rather than merely bypassed.
|
|
@@ -31,7 +43,8 @@
|
|
|
31
43
|
* and every file left behind. Absolute rather than compared is what let it survive the deletion
|
|
32
44
|
* of the implementation it was once compared against.
|
|
33
45
|
*/
|
|
34
|
-
import { existsSync, statSync, readFileSync, readdirSync, mkdirSync, realpathSync
|
|
46
|
+
import { existsSync, statSync, readFileSync, readdirSync, mkdirSync, realpathSync,
|
|
47
|
+
appendFileSync, writeFileSync }
|
|
35
48
|
from 'node:fs';
|
|
36
49
|
import path from 'node:path';
|
|
37
50
|
import { spawnSync } from 'node:child_process';
|
|
@@ -43,7 +56,7 @@ import { NO_WINDOW } from '../lib/proc.mjs';
|
|
|
43
56
|
// module to find it: the CLI is under a hard transitive `child_process` ban and `learn`
|
|
44
57
|
// spawns. So the resolver has one owner in `js/hosts/hosts.mjs` rather than a second copy — and
|
|
45
58
|
// with it went this file's private `opencodeConfigDir`, whose only caller it was.
|
|
46
|
-
import { resolveMemoryDir, expanduser } from './hosts.mjs';
|
|
59
|
+
import { resolveMemoryDir, expanduser, sovereignBypass } from './hosts.mjs';
|
|
47
60
|
|
|
48
61
|
const ROOT = path.resolve(import.meta.dirname, '../..');
|
|
49
62
|
|
|
@@ -72,10 +85,12 @@ const ROOT = path.resolve(import.meta.dirname, '../..');
|
|
|
72
85
|
// an adjudicated product decision (`TheTildeUserFormIsADeliberateDivergence`) that the hook
|
|
73
86
|
// was quietly exempt from because it had a private copy. It now imports the owner. The
|
|
74
87
|
// refusal throws, so `sovereignBypass` and `cmdContext` below contain it per entry / per
|
|
75
|
-
// call
|
|
76
|
-
// stdout, so "the gate blew up" and "the gate
|
|
77
|
-
//
|
|
78
|
-
//
|
|
88
|
+
// call. For the two GATES that containment is not enough, and `guardGate` below is the
|
|
89
|
+
// contract: these verbs exit 0 and signal on stdout, so "the gate blew up" and "the gate
|
|
90
|
+
// found nothing" would be the SAME observation to Claude — a crash would be a silent allow.
|
|
91
|
+
// A gate that cannot evaluate a call therefore asks, and says why. `resolvePath` just below
|
|
92
|
+
// is the THIRD copy of a primitive `js/hosts/hosts.mjs` also exports, and is the next item
|
|
93
|
+
// in this series.
|
|
79
94
|
|
|
80
95
|
/**
|
|
81
96
|
* `Path(p).resolve()` — absolute, symlinks followed, and the filesystem's OWN casing.
|
|
@@ -229,7 +244,6 @@ const EXCLUDE_DIRS = new Set(['node_modules', '.git', 'dist', 'build', 'vendor',
|
|
|
229
244
|
|
|
230
245
|
const CLAUDE_MARKERS = ['.claude', '.bob'];
|
|
231
246
|
const GENESEED_MANIFEST = '.geneseed-manifest.json';
|
|
232
|
-
const EXCLUDES_FILE = 'excludes.json';
|
|
233
247
|
|
|
234
248
|
/**
|
|
235
249
|
* `_global_hook_standing_down` — project-bypasses-global.
|
|
@@ -252,52 +266,10 @@ export function globalHookStandingDown(hookRoot, cwd) {
|
|
|
252
266
|
return false;
|
|
253
267
|
}
|
|
254
268
|
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
*/
|
|
260
|
-
export function sovereignBypass(root) {
|
|
261
|
-
if (!root) return false;
|
|
262
|
-
let entries;
|
|
263
|
-
try {
|
|
264
|
-
const data = JSON.parse(readText(path.join(root, EXCLUDES_FILE)));
|
|
265
|
-
entries = Array.isArray(data && data.excludes) ? data.excludes : [];
|
|
266
|
-
} catch {
|
|
267
|
-
return false;
|
|
268
|
-
}
|
|
269
|
-
let cwd;
|
|
270
|
-
try {
|
|
271
|
-
cwd = normcase(resolvePath(process.cwd()));
|
|
272
|
-
} catch {
|
|
273
|
-
return false;
|
|
274
|
-
}
|
|
275
|
-
for (const entry of entries) {
|
|
276
|
-
const raw = (entry && typeof entry === 'object' ? entry.path : entry) || '';
|
|
277
|
-
if (typeof raw !== 'string' || !raw.trim()) continue;
|
|
278
|
-
let base;
|
|
279
|
-
try {
|
|
280
|
-
// `.rstrip("\\/")` AFTER normcase, exactly as the Python orders it, so the separator
|
|
281
|
-
// test below cannot be fooled by a trailing slash in the user's file.
|
|
282
|
-
//
|
|
283
|
-
// `withDiscardableStderr` because `expanduser` writes its refusal at the RAISE SITE
|
|
284
|
-
// and this caller catches: the reference prints nothing here, so replaying the
|
|
285
|
-
// message would be a stderr divergence on every hook call for one bad entry.
|
|
286
|
-
base = withDiscardableStderr(
|
|
287
|
-
() => normcase(toPlatformPath(expanduser(raw.trim()))).replace(/[\\/]+$/, ''));
|
|
288
|
-
} catch {
|
|
289
|
-
// PER ENTRY, and the loop continues. One unusable line in a file the USER hand-edits
|
|
290
|
-
// must not decide the whole function — `expanduser` refuses a `~user` form, and the
|
|
291
|
-
// reference's `Path.expanduser()` raises RuntimeError on the same input on POSIX.
|
|
292
|
-
// Skipping errs toward the gate staying ACTIVE, which is the safe direction: this
|
|
293
|
-
// feeds `git-gate`/`rule-gate`, which exit 0 and signal on stdout, so a crash here
|
|
294
|
-
// would read as a clean pass while gating nothing.
|
|
295
|
-
continue;
|
|
296
|
-
}
|
|
297
|
-
if (cwd === base || cwd.startsWith(base + path.sep)) return true;
|
|
298
|
-
}
|
|
299
|
-
return false;
|
|
300
|
-
}
|
|
269
|
+
// `sovereignBypass` moved to `./hosts.mjs` so `status` can read it without importing this
|
|
270
|
+
// module's `child_process` binding; re-exported because the tests and the excludes writer's
|
|
271
|
+
// docblock name it here.
|
|
272
|
+
export { sovereignBypass };
|
|
301
273
|
|
|
302
274
|
/** `_disp` — relative to the repo root when it sits under it, else verbatim. */
|
|
303
275
|
function disp(pathStr, root) {
|
|
@@ -469,6 +441,7 @@ export function resolveContextSets(root) {
|
|
|
469
441
|
}
|
|
470
442
|
|
|
471
443
|
export function cmdContext(args) {
|
|
444
|
+
setHost(args);
|
|
472
445
|
// Discovery runs against the project root the hook was launched from — Claude runs
|
|
473
446
|
// SessionStart hooks with cwd = repo root — not the harness package dir.
|
|
474
447
|
// The only two `resolvePath` calls in this file whose argument can be a `~user` path the
|
|
@@ -523,7 +496,12 @@ export function cmdContext(args) {
|
|
|
523
496
|
lines.push('');
|
|
524
497
|
}
|
|
525
498
|
|
|
526
|
-
|
|
499
|
+
// Claude Code takes a SessionStart hook's plain stdout as context. Copilot's `sessionStart`
|
|
500
|
+
// wants a JSON document and reads `additionalContext` out of it; plain text there would be
|
|
501
|
+
// parsed as nothing and silently dropped.
|
|
502
|
+
// Bob reads a SessionStart hook's stdout as context exactly as Claude does.
|
|
503
|
+
const text = `${lines.join('\n')}\n`;
|
|
504
|
+
out(HOST === 'copilot' ? `${jsonDumpsCompact({ additionalContext: text })}\n` : text);
|
|
527
505
|
return 0;
|
|
528
506
|
}
|
|
529
507
|
|
|
@@ -537,6 +515,15 @@ export function cmdContext(args) {
|
|
|
537
515
|
// one line and a `commit` on the next are two commands, not one.
|
|
538
516
|
const GIT_GATE_RE = /\bgit\b[^\n]*\b(?:commit|push)\b/;
|
|
539
517
|
|
|
518
|
+
// Law IV's boundary half: the git verbs that rewrite or discard history. Law IX (now rigor 5)
|
|
519
|
+
// says a rule in a prompt is a request and a rule at a boundary is a constraint; before this
|
|
520
|
+
// regex the harness enforced one doctrine rule and no law. `checkout --` is listed because it
|
|
521
|
+
// reverts to the INDEX and eats unstaged work; `checkout <branch>` is not. `clean -f` matches
|
|
522
|
+
// any flag cluster carrying `f` (`-fd`, `-xf`) and never `-n`. `push --force*` trips this gate
|
|
523
|
+
// rather than process 5's so the stronger reason is the one the user reads.
|
|
524
|
+
const DESTRUCTIVE_GIT_RE =
|
|
525
|
+
/\bgit\b[^\n]*\b(?:reset\s+--hard|clean\s+-[a-zA-Z]*f|branch\s+-D|checkout\s+--\s|push\s+[^\n]*--force)/;
|
|
526
|
+
|
|
540
527
|
/** The `permissionDecision: "ask"` document, in Python's compact `json.dumps` spelling. */
|
|
541
528
|
function askDecision(reason) {
|
|
542
529
|
return `${jsonDumpsCompact({
|
|
@@ -548,21 +535,162 @@ function askDecision(reason) {
|
|
|
548
535
|
})}\n`;
|
|
549
536
|
}
|
|
550
537
|
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
538
|
+
/**
|
|
539
|
+
* The host reading this process's verdict — `--host` on the emitted command, `claude` when
|
|
540
|
+
* absent. Module state rather than a parameter because a hook process answers exactly one
|
|
541
|
+
* call: set once at the verb's entry (`guardGate`, `cmdContext`), read where the output
|
|
542
|
+
* dialect is chosen.
|
|
543
|
+
*/
|
|
544
|
+
let HOST = 'claude';
|
|
545
|
+
const setHost = (args) => { HOST = (args && args.host) || 'claude'; };
|
|
546
|
+
|
|
547
|
+
/** Copilot's `toolCall` verdict: `{"block": true}` plus the reason it displays. */
|
|
548
|
+
function blockDecision(reason) {
|
|
549
|
+
return `${jsonDumpsCompact({ block: true, reason })}\n`;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/**
|
|
553
|
+
* Bob's PreToolUse protocol: stdout is ignored and EXIT CODE 2 is the one refusal. The
|
|
554
|
+
* single exception to "every gate exits 0" in this file, confined to `--host bob` — on
|
|
555
|
+
* Claude Code a non-zero exit is a broken hook, on Bob it is the verdict.
|
|
556
|
+
*/
|
|
557
|
+
const BOB_DENY_EXIT = 2;
|
|
558
|
+
|
|
559
|
+
/**
|
|
560
|
+
* On a host with no ask tier, which rules are worth a hard block. Laws I and IV: a
|
|
561
|
+
* credential in a tracked file and a history-discarding git act are wrong in every context,
|
|
562
|
+
* so refusing them costs nothing. Process 1 and process 5 are the USER's calls — a hard
|
|
563
|
+
* block would make Copilot unable to commit at all — so they become a warning the host
|
|
564
|
+
* logs. `gate-error` is a warning too: a crashed gate that blocked every tool call would be
|
|
565
|
+
* a lockout, not a safeguard, and there is no prompt through which the user could clear it.
|
|
566
|
+
*/
|
|
567
|
+
const BLOCK_RULES = new Set(['law-1', 'law-4']);
|
|
568
|
+
|
|
569
|
+
/** Where the gate ledger lives inside an install: the notebook is the agent's own, gitignored space. */
|
|
570
|
+
export const GATE_LEDGER = path.join('notebook', 'gates.jsonl');
|
|
571
|
+
|
|
572
|
+
/**
|
|
573
|
+
* THE GATE LEDGER — one JSON line per ask, nothing per defer.
|
|
574
|
+
*
|
|
575
|
+
* The gates are the only mechanical enforcement the harness has, and until this line
|
|
576
|
+
* nothing recorded whether they ever fired: no way to tell whether Law I has caught a secret
|
|
577
|
+
* this year, or whether the process 5 prompt is signal or noise. `status` counts these by
|
|
578
|
+
* rule. It records the RULE, never the command or the content that tripped it — a ledger
|
|
579
|
+
* that stored the string Law I caught would itself be a place a secret lands. And it records
|
|
580
|
+
* only `asked`: the host never tells a hook how the user answered, so a `decided` column
|
|
581
|
+
* would be a claim the hook cannot make. Written only on the ask path, which is the rare
|
|
582
|
+
* one; the defer path — every other tool call — pays nothing. A ledger that cannot be
|
|
583
|
+
* written must never change the decision, hence the swallow.
|
|
584
|
+
*
|
|
585
|
+
* Capped at the newest MAX_LEDGER_LINES, like the agent lessons and the notes beside it: an
|
|
586
|
+
* install lives for months and `status` only ever counts, so the oldest asks carry no
|
|
587
|
+
* information a trimmed file loses. The re-read happens on the ask path only.
|
|
588
|
+
*/
|
|
589
|
+
const MAX_LEDGER_LINES = 1000;
|
|
590
|
+
function ledger(root, verb, rule) {
|
|
591
|
+
if (!root) return;
|
|
554
592
|
try {
|
|
555
|
-
const
|
|
556
|
-
|
|
557
|
-
|
|
593
|
+
const file = path.join(root, GATE_LEDGER);
|
|
594
|
+
appendFileSync(file, `${JSON.stringify({
|
|
595
|
+
ts: new Date().toISOString(), verb, rule, cwd: process.cwd(),
|
|
596
|
+
})}\n`);
|
|
597
|
+
const lines = readFileSync(file, 'utf8').split('\n').filter(Boolean);
|
|
598
|
+
if (lines.length > MAX_LEDGER_LINES) {
|
|
599
|
+
writeFileSync(file, `${lines.slice(-MAX_LEDGER_LINES).join('\n')}\n`);
|
|
600
|
+
}
|
|
601
|
+
} catch { /* see above: the ledger never decides */ }
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/**
|
|
605
|
+
* Ask, and record that we asked. Every ask site goes through here so none can skip the
|
|
606
|
+
* ledger. On `--host copilot` the ask becomes a block or a warning (see `BLOCK_RULES`); the
|
|
607
|
+
* ledger records the block, never the warning — a warning asked nothing of anyone.
|
|
608
|
+
*/
|
|
609
|
+
function ask(args, verb, rule, reason) {
|
|
610
|
+
if (HOST === 'copilot' || HOST === 'bob') {
|
|
611
|
+
if (!BLOCK_RULES.has(rule)) {
|
|
612
|
+
// Neither host has an ask tier; a stderr line is the nudge, and the call goes through.
|
|
613
|
+
err(`[geneseed] ${reason}\n`);
|
|
614
|
+
return 0;
|
|
615
|
+
}
|
|
616
|
+
ledger(args && args.root, verb, rule);
|
|
617
|
+
if (HOST === 'bob') {
|
|
618
|
+
// stdout is not read on Bob's PreToolUse; the reason goes where Bob's hook log looks.
|
|
619
|
+
err(`[geneseed] BLOCKED: ${reason}\n`);
|
|
620
|
+
return BOB_DENY_EXIT;
|
|
621
|
+
}
|
|
622
|
+
out(blockDecision(reason));
|
|
558
623
|
return 0;
|
|
559
624
|
}
|
|
560
|
-
|
|
561
|
-
out(askDecision(
|
|
562
|
-
+ 'approval'));
|
|
625
|
+
ledger(args && args.root, verb, rule);
|
|
626
|
+
out(askDecision(reason));
|
|
563
627
|
return 0;
|
|
564
628
|
}
|
|
565
629
|
|
|
630
|
+
/**
|
|
631
|
+
* FAIL CLOSED. Wraps a gate verb so that any throw — a payload that is not JSON, a path
|
|
632
|
+
* primitive refusing a form, a bug — becomes an `ask` carrying the error, never a silent
|
|
633
|
+
* allow. The host reads exit 0 + empty stdout as "the gate deferred", which is exactly what a
|
|
634
|
+
* crashed gate would otherwise look like. A well-formed ABSENCE (empty stdin, `{}`, a null
|
|
635
|
+
* `tool_input`) is still a defer: there is nothing to evaluate, and asking on every
|
|
636
|
+
* hand-run invocation would teach the user to allow-list the gate.
|
|
637
|
+
*/
|
|
638
|
+
export function guardGate(fn, verb = 'gate') {
|
|
639
|
+
return (args) => {
|
|
640
|
+
setHost(args);
|
|
641
|
+
try {
|
|
642
|
+
return fn(args);
|
|
643
|
+
} catch (e) {
|
|
644
|
+
const why = e && e.message ? e.message : String(e);
|
|
645
|
+
return ask(args, verb, 'gate-error', `Geneseed gate error — ${why}. The gate could `
|
|
646
|
+
+ 'not evaluate this call, so it does not defer.');
|
|
647
|
+
}
|
|
648
|
+
};
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
/**
|
|
652
|
+
* The hook payload on stdin, or null for a well-formed absence. Not-JSON THROWS.
|
|
653
|
+
*
|
|
654
|
+
* Copilot spells the same payload in camelCase — `toolName`/`toolInput` where Claude sends
|
|
655
|
+
* `tool_name`/`tool_input` — and fires `toolCall` both before AND after the tool runs
|
|
656
|
+
* (`phase`). The field names are folded to Claude's so every gate reads one shape; the
|
|
657
|
+
* `after` phase is a well-formed absence, since there is nothing left to gate.
|
|
658
|
+
*/
|
|
659
|
+
function readPayload() {
|
|
660
|
+
const raw = readStdin();
|
|
661
|
+
if (!raw.trim()) return null;
|
|
662
|
+
let payload;
|
|
663
|
+
try {
|
|
664
|
+
payload = JSON.parse(raw);
|
|
665
|
+
} catch {
|
|
666
|
+
throw new Error('the hook payload on stdin is not JSON');
|
|
667
|
+
}
|
|
668
|
+
if (!payload || typeof payload !== 'object') return null;
|
|
669
|
+
if (payload.phase === 'after') return null;
|
|
670
|
+
if (payload.tool_input === undefined && payload.toolInput !== undefined) {
|
|
671
|
+
payload.tool_input = payload.toolInput;
|
|
672
|
+
}
|
|
673
|
+
return payload;
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
function gitGate(args) {
|
|
677
|
+
if (sovereignBypass(args.root)) return 0;
|
|
678
|
+
return gitDecide(args, readPayload());
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
function gitDecide(args, payload) {
|
|
682
|
+
const command = ((payload && payload.tool_input) || {}).command;
|
|
683
|
+
if (typeof command !== 'string') return 0;
|
|
684
|
+
if (DESTRUCTIVE_GIT_RE.test(command)) {
|
|
685
|
+
return ask(args, 'git-gate', 'law-4', 'Geneseed Law IV \u2014 a history-rewriting or '
|
|
686
|
+
+ 'discarding git act needs confirmation bound to this specific command');
|
|
687
|
+
}
|
|
688
|
+
if (!GIT_GATE_RE.test(command)) return 0;
|
|
689
|
+
return ask(args, 'git-gate', 'process-5', 'Geneseed Doctrine process 5 \u2014 every git '
|
|
690
|
+
+ 'commit/push needs explicit approval');
|
|
691
|
+
}
|
|
692
|
+
export const cmdGitGate = guardGate(gitGate, 'git-gate');
|
|
693
|
+
|
|
566
694
|
// ======================================================================================
|
|
567
695
|
// rule-gate — Doctrine process 1's tool-boundary backstop
|
|
568
696
|
// ======================================================================================
|
|
@@ -595,23 +723,57 @@ function ruleGateTarget(p, root) {
|
|
|
595
723
|
}
|
|
596
724
|
}
|
|
597
725
|
|
|
598
|
-
|
|
726
|
+
// Law I's boundary half. High-precision vendor prefixes only — a generic "looks like entropy"
|
|
727
|
+
// scan would fire on every hash and lockfile, and a gate that cries wolf gets allow-listed.
|
|
728
|
+
// `.env*` is exempt because Law I names it as the place a secret may live.
|
|
729
|
+
const SECRET_RE =
|
|
730
|
+
/\b(?:AKIA[0-9A-Z]{16}|ghp_[A-Za-z0-9]{30,}|github_pat_[A-Za-z0-9_]{30,}|sk-ant-[A-Za-z0-9-]{20,}|xox[abprs]-[0-9A-Za-z-]{10,})\b|-----BEGIN [A-Z ]*PRIVATE KEY-----/;
|
|
731
|
+
const DOTENV_RE = /(^|[\\/])\.env(\.[^\\/]*)?$/;
|
|
732
|
+
|
|
733
|
+
function ruleGate(args) {
|
|
599
734
|
if (sovereignBypass(args.root)) return 0;
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
return 0;
|
|
607
|
-
}
|
|
735
|
+
return ruleDecide(args, readPayload());
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
function ruleDecide(args, payload) {
|
|
739
|
+
const ti = (payload && payload.tool_input) || {};
|
|
740
|
+
const p = ti.file_path || ti.path || '';
|
|
608
741
|
if (typeof p !== 'string' || !p) return 0;
|
|
742
|
+
// Write carries `content`, Edit carries `new_string`; either can plant a credential.
|
|
743
|
+
const body = typeof ti.content === 'string' ? ti.content
|
|
744
|
+
: typeof ti.new_string === 'string' ? ti.new_string : '';
|
|
745
|
+
if (body && !DOTENV_RE.test(p) && SECRET_RE.test(body)) {
|
|
746
|
+
return ask(args, 'rule-gate', 'law-1', `Geneseed Law I — ${p} would carry a `
|
|
747
|
+
+ 'credential-shaped string. Secrets live in .env or a secret manager, never in a '
|
|
748
|
+
+ 'tracked file.');
|
|
749
|
+
}
|
|
609
750
|
const target = ruleGateTarget(p, args.root);
|
|
610
751
|
if (!target) return 0;
|
|
611
|
-
|
|
612
|
-
+
|
|
613
|
-
|
|
752
|
+
return ask(args, 'rule-gate', 'process-1', `Geneseed Doctrine process 1 \u2014 writing to `
|
|
753
|
+
+ `${target}: a standing rule, or a fact to remember? That choice is the user's. Run the `
|
|
754
|
+
+ 'rule skill first.');
|
|
755
|
+
}
|
|
756
|
+
export const cmdRuleGate = guardGate(ruleGate, 'rule-gate');
|
|
757
|
+
|
|
758
|
+
// ======================================================================================
|
|
759
|
+
// tool-gate — both gates behind one command
|
|
760
|
+
// ======================================================================================
|
|
761
|
+
|
|
762
|
+
/**
|
|
763
|
+
* Copilot's settings carry ONE command per hook event, not a list of matcher groups, so
|
|
764
|
+
* its `toolCall` hook cannot name `git-gate` for Bash and `rule-gate` for Write the way
|
|
765
|
+
* Claude's `PreToolUse` does. This verb is the two fused, dispatched on the payload rather
|
|
766
|
+
* than on a matcher: a `command` field is a shell call and gets the git checks, a path
|
|
767
|
+
* field is a write and gets the rule checks. Nothing here decides anything the two named
|
|
768
|
+
* gates do not — it only removes the need for a matcher the host does not have.
|
|
769
|
+
*/
|
|
770
|
+
function toolGate(args) {
|
|
771
|
+
if (sovereignBypass(args.root)) return 0;
|
|
772
|
+
const payload = readPayload();
|
|
773
|
+
const ti = (payload && payload.tool_input) || {};
|
|
774
|
+
return typeof ti.command === 'string' ? gitDecide(args, payload) : ruleDecide(args, payload);
|
|
614
775
|
}
|
|
776
|
+
export const cmdToolGate = guardGate(toolGate, 'tool-gate');
|
|
615
777
|
|
|
616
778
|
// ======================================================================================
|
|
617
779
|
// learn — distil notes/transcripts into deduped memory entries
|
package/js/hosts/hosts.mjs
CHANGED
|
@@ -17,12 +17,66 @@
|
|
|
17
17
|
import { realpathSync } from 'node:fs';
|
|
18
18
|
import path from 'node:path';
|
|
19
19
|
import os from 'node:os';
|
|
20
|
-
import { printErr, withDiscardableStderr, isDir } from '../lib/fs.mjs';
|
|
21
|
-
import { toPlatformPath } from '../lib/paths.mjs';
|
|
20
|
+
import { printErr, withDiscardableStderr, isDir, readText } from '../lib/fs.mjs';
|
|
21
|
+
import { toPlatformPath, normcase } from '../lib/paths.mjs';
|
|
22
22
|
|
|
23
23
|
/** `_build_global.GLOBAL_MANIFEST` — the file whose presence means "a global install". */
|
|
24
24
|
export const GLOBAL_MANIFEST = '.geneseed-manifest.json';
|
|
25
25
|
|
|
26
|
+
/** The user's own per-install exclusion list; `js/build/stubs.mjs` seeds it, this file reads it. */
|
|
27
|
+
export const EXCLUDES_FILE = 'excludes.json';
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* `sovereign_bypass` — the user's own excludes.json, read on EVERY hook call so an edit
|
|
31
|
+
* takes effect without a re-emit. Every failure mode degrades to false: a hook must never
|
|
32
|
+
* fail or block on a file the user owns.
|
|
33
|
+
*
|
|
34
|
+
* LIVES HERE, NOT IN `hooks.mjs`, because two readers need it and only one of them may pay
|
|
35
|
+
* for the other: the hook (hot path, already imports this module) and `status` (the CLI,
|
|
36
|
+
* under the transitive `child_process` ban that `hooks.mjs`'s `learn` verb breaks). A
|
|
37
|
+
* second copy in `js/inspect/` was the alternative, and a copy is what let the `~user`
|
|
38
|
+
* refusal diverge between the two CLIs once already (see `hooks.mjs`'s header).
|
|
39
|
+
*/
|
|
40
|
+
export function sovereignBypass(root) {
|
|
41
|
+
if (!root) return false;
|
|
42
|
+
let entries;
|
|
43
|
+
try {
|
|
44
|
+
const data = JSON.parse(readText(path.join(root, EXCLUDES_FILE)));
|
|
45
|
+
entries = Array.isArray(data && data.excludes) ? data.excludes : [];
|
|
46
|
+
} catch {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
let cwd;
|
|
50
|
+
try {
|
|
51
|
+
cwd = normcase(resolvePath(process.cwd()));
|
|
52
|
+
} catch {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
for (const entry of entries) {
|
|
56
|
+
const raw = (entry && typeof entry === 'object' ? entry.path : entry) || '';
|
|
57
|
+
if (typeof raw !== 'string' || !raw.trim()) continue;
|
|
58
|
+
let base;
|
|
59
|
+
try {
|
|
60
|
+
// `.rstrip("\\/")` AFTER normcase, exactly as the Python orders it, so the separator
|
|
61
|
+
// test below cannot be fooled by a trailing slash in the user's file.
|
|
62
|
+
//
|
|
63
|
+
// `withDiscardableStderr` because `expanduser` writes its refusal at the RAISE SITE
|
|
64
|
+
// and this caller catches: the reference prints nothing here, so replaying the
|
|
65
|
+
// message would be a stderr divergence on every hook call for one bad entry.
|
|
66
|
+
base = withDiscardableStderr(
|
|
67
|
+
() => normcase(toPlatformPath(expanduser(raw.trim()))).replace(/[\\/]+$/, ''));
|
|
68
|
+
} catch {
|
|
69
|
+
// PER ENTRY, and the loop continues. One unusable line in a file the USER hand-edits
|
|
70
|
+
// must not decide the whole function — `expanduser` refuses a `~user` form, and the
|
|
71
|
+
// reference's `Path.expanduser()` raises RuntimeError on the same input on POSIX.
|
|
72
|
+
// Skipping errs toward the gate staying ACTIVE, which is the safe direction.
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
if (cwd === base || cwd.startsWith(base + path.sep)) return true;
|
|
76
|
+
}
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
|
|
26
80
|
/**
|
|
27
81
|
* `_build_core.VERSION_MARKER`. Third caller, so it moves here — `js/build/version.mjs` and
|
|
28
82
|
* `js/inspect/diff.mjs` each had a private const of their own and `js/maintain/uninstall.mjs` would have made
|
package/js/hosts/link.mjs
CHANGED
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
* The second removes a working feature from every GIT-CHECKOUT user — for whom `link` is
|
|
25
25
|
* the only thing that puts `geneseed` on PATH, because there is no package manager in that
|
|
26
26
|
* story — in order to spare npm users a duplicate. It also spends a `web/dist` rebuild on a
|
|
27
|
-
* deletion. So: they ship, they port
|
|
27
|
+
* deletion. So: they ship, and they port.
|
|
28
28
|
*
|
|
29
29
|
* The npm duplication is left as it is rather than papered over with a new "you already have
|
|
30
30
|
* this" refusal: that would be a REDESIGN of the verb, the reference has no such arm, and a
|
package/js/hosts/mcp.mjs
CHANGED
|
@@ -244,8 +244,10 @@ export function mcpConfigFor(host, scope, root) {
|
|
|
244
244
|
: resolvePath(path.join(os.homedir(), '.claude.json'));
|
|
245
245
|
}
|
|
246
246
|
if (host === 'bob') {
|
|
247
|
+
// Nested at global scope, as Bob's hooks doc places the global file (and as the emit
|
|
248
|
+
// now writes it); the project file is flat.
|
|
247
249
|
return scope === 'project' ? path.join(root, '.bob', 'settings.json')
|
|
248
|
-
: path.join(bobConfigDir(), 'settings.json');
|
|
250
|
+
: path.join(bobConfigDir(), 'settings', 'settings.json');
|
|
249
251
|
}
|
|
250
252
|
if (host === 'copilot') {
|
|
251
253
|
// The Copilot CLI reads MCP servers from ~/.copilot/mcp-config.json only — no per-repo
|
package/js/hosts/native.mjs
CHANGED
|
@@ -112,6 +112,53 @@ export function descOf(text) {
|
|
|
112
112
|
return firstBlockquote(text);
|
|
113
113
|
}
|
|
114
114
|
|
|
115
|
+
/**
|
|
116
|
+
* The `**Trigger:**` paragraph of a skill, flattened to one line — markdown links reduced
|
|
117
|
+
* to their text, HTML comments dropped, whitespace collapsed. '' when the skill has none.
|
|
118
|
+
*
|
|
119
|
+
* Why it exists: on Claude Code (and Bob) the frontmatter `description:` is the ONLY text
|
|
120
|
+
* the model matches a task against before deciding to load a skill. The purpose line says
|
|
121
|
+
* what a skill does; the trigger says WHEN — and a catalogue of purposes alone left the
|
|
122
|
+
* model choosing `tdd` from "Drive implementation with tests" without ever seeing "before
|
|
123
|
+
* writing implementation code". The AGENT.md §4 table carried the triggers, but native
|
|
124
|
+
* hosts collapse that table to a pointer (`hostCatalogsNatively`), so the trigger reached
|
|
125
|
+
* no host at all. `skillDescription` below concatenates the two.
|
|
126
|
+
*/
|
|
127
|
+
export function triggerOf(text) {
|
|
128
|
+
const lines = text.split('\n');
|
|
129
|
+
const start = lines.findIndex((ln) => /^\*\*Trigger:\*\*/.test(ln.trim()));
|
|
130
|
+
if (start < 0) return '';
|
|
131
|
+
const para = [];
|
|
132
|
+
for (let i = start; i < lines.length; i += 1) {
|
|
133
|
+
const s = lines[i].trim();
|
|
134
|
+
if (i > start && (s === '' || s.startsWith('#') || s.startsWith('**'))) break;
|
|
135
|
+
para.push(s);
|
|
136
|
+
}
|
|
137
|
+
return para.join(' ')
|
|
138
|
+
.replace(/^\*\*Trigger:\*\*\s*/, '')
|
|
139
|
+
.replace(HTML_COMMENT_RE, '')
|
|
140
|
+
.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
|
|
141
|
+
.replace(/\s+/g, ' ')
|
|
142
|
+
.trim();
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Purpose + trigger, the emitted `description:` of a skill. Capped so a long trigger (a
|
|
147
|
+
* few carry three sentences of disambiguation) cannot blow past what hosts display —
|
|
148
|
+
* cut at the last sentence end inside the cap, never mid-word.
|
|
149
|
+
*/
|
|
150
|
+
const DESCRIPTION_CAP = 900;
|
|
151
|
+
export function skillDescription(text) {
|
|
152
|
+
const desc = descOf(text);
|
|
153
|
+
const trig = triggerOf(text);
|
|
154
|
+
if (!trig) return desc;
|
|
155
|
+
const full = `${desc} Use when: ${trig}`;
|
|
156
|
+
if (full.length <= DESCRIPTION_CAP) return full;
|
|
157
|
+
const cut = full.slice(0, DESCRIPTION_CAP);
|
|
158
|
+
const end = Math.max(cut.lastIndexOf('. '), cut.lastIndexOf('; '));
|
|
159
|
+
return end > desc.length ? cut.slice(0, end + 1) : cut.replace(/\s+\S*$/, '') + '…';
|
|
160
|
+
}
|
|
161
|
+
|
|
115
162
|
/** `_build_render._HTML_COMMENT_RE`. */
|
|
116
163
|
const HTML_COMMENT_RE = /<!--[\s\S]*?-->/g;
|
|
117
164
|
|
|
@@ -149,6 +196,20 @@ function isReadonly(text) {
|
|
|
149
196
|
return text.includes('Read-only');
|
|
150
197
|
}
|
|
151
198
|
|
|
199
|
+
/**
|
|
200
|
+
* A skill only the USER may open. The marker is an HTML comment in the source spec
|
|
201
|
+
* (`<!-- invocation: user -->`, placed under the purpose blockquote), and it renders to
|
|
202
|
+
* Claude Code's `disable-model-invocation: true`: the skill still answers `/name`, but its
|
|
203
|
+
* description leaves the model's always-on catalogue and the model never self-triggers it.
|
|
204
|
+
* Same precedent as `isReadonly` — a phrase in the spec, not a second manifest to keep in
|
|
205
|
+
* step. Which skills carry it is an authoring decision: the teaching skills a human runs on
|
|
206
|
+
* themselves, and the ones gated on an environment the model cannot see (a herdr pane).
|
|
207
|
+
*/
|
|
208
|
+
const USER_INVOKED_RE = /<!--\s*invocation:\s*user\s*-->/;
|
|
209
|
+
export function isUserInvokedOnly(text) {
|
|
210
|
+
return USER_INVOKED_RE.test(text);
|
|
211
|
+
}
|
|
212
|
+
|
|
152
213
|
/** `_build_emit._strip_skill_body_links`. */
|
|
153
214
|
export function stripSkillBodyLinks(body) {
|
|
154
215
|
return body.replace(SKILL_BODY_LINK_RE, '$1');
|
|
@@ -230,22 +291,28 @@ export function pushOverrideLines(fm, ov) {
|
|
|
230
291
|
if (given(ov.steps)) fm.push(`steps: ${formatValue(ov.steps)}`);
|
|
231
292
|
}
|
|
232
293
|
|
|
294
|
+
/** The two opt-in markers a read-only spec may carry. Bare substring tests, like `isReadonly`. */
|
|
295
|
+
const BASH_MARKER = '<!-- bash: allow -->';
|
|
296
|
+
const WEBFETCH_MARKER = '<!-- webfetch: allow -->';
|
|
297
|
+
|
|
233
298
|
/**
|
|
234
299
|
* The readonly-tool line `claudeAgentFrontmatter`/`copilotAgentFrontmatter` each emit — a
|
|
235
300
|
* DENYLIST for Claude, an ALLOWLIST for Copilot — differing only in the key, the base list,
|
|
236
|
-
* the
|
|
237
|
-
*
|
|
238
|
-
* allows it) and `true` for Copilot (
|
|
239
|
-
* the same marker, read in the opposite sense a denylist and an allowlist
|
|
301
|
+
* the gated entries the markers toggle, and whether the value is bracketed. Each `gated`
|
|
302
|
+
* row is `[tool, marker, listedWhenMarked]`: `false` for Claude (the tool is denied UNLESS
|
|
303
|
+
* the marker allows it) and `true` for Copilot (the tool is allowed ONLY IF the marker is
|
|
304
|
+
* present) — the same marker, read in the opposite sense a denylist and an allowlist
|
|
305
|
+
* require. Row order is emit order: WebFetch before Bash keeps the unmarked Claude line
|
|
306
|
+
* byte-identical to what it was before the webfetch marker existed.
|
|
240
307
|
*/
|
|
241
308
|
const READONLY_TOOLS = {
|
|
242
309
|
claude: {
|
|
243
|
-
key: 'disallowedTools', tools: ['Write', 'Edit', 'NotebookEdit',
|
|
244
|
-
|
|
310
|
+
key: 'disallowedTools', tools: ['Write', 'Edit', 'NotebookEdit'], brackets: false,
|
|
311
|
+
gated: [['WebFetch', WEBFETCH_MARKER, false], ['Bash', BASH_MARKER, false]],
|
|
245
312
|
},
|
|
246
313
|
copilot: {
|
|
247
|
-
key: 'tools', tools: ['read', 'search', 'todo', 'agent'],
|
|
248
|
-
|
|
314
|
+
key: 'tools', tools: ['read', 'search', 'todo', 'agent'], brackets: true,
|
|
315
|
+
gated: [['fetch', WEBFETCH_MARKER, true], ['execute', BASH_MARKER, true]],
|
|
249
316
|
},
|
|
250
317
|
};
|
|
251
318
|
|
|
@@ -255,11 +322,11 @@ function claudeCopilotAgentFrontmatter(host, stem, text, overrides) {
|
|
|
255
322
|
const ov = agentOverride(overrides, stem);
|
|
256
323
|
if (isTruthy(ov.model)) fm.push(`model: ${formatValue(ov.model)}`);
|
|
257
324
|
if (isReadonly(text)) {
|
|
258
|
-
const {
|
|
259
|
-
key, tools, extra, extraWhenBashAllowed, brackets,
|
|
260
|
-
} = READONLY_TOOLS[host];
|
|
325
|
+
const { key, tools, brackets, gated } = READONLY_TOOLS[host];
|
|
261
326
|
const list = [...tools];
|
|
262
|
-
|
|
327
|
+
for (const [tool, marker, listedWhenMarked] of gated) {
|
|
328
|
+
if (text.includes(marker) === listedWhenMarked) list.push(tool);
|
|
329
|
+
}
|
|
263
330
|
fm.push(`${key}: ${brackets ? `[${list.join(', ')}]` : list.join(', ')}`);
|
|
264
331
|
}
|
|
265
332
|
return fm;
|
|
@@ -280,8 +347,9 @@ function opencodeAgentFrontmatter(stem, text, overrides, theme = null) {
|
|
|
280
347
|
fm.push(`color: ${agentColor(stem, theme)}`);
|
|
281
348
|
pushOverrideLines(fm, agentOverride(overrides, stem));
|
|
282
349
|
if (isReadonly(text)) {
|
|
283
|
-
fm.push('permission:', ' edit: deny',
|
|
284
|
-
|
|
350
|
+
fm.push('permission:', ' edit: deny',
|
|
351
|
+
` webfetch: ${text.includes(WEBFETCH_MARKER) ? 'allow' : 'deny'}`);
|
|
352
|
+
if (text.includes(BASH_MARKER)) fm.push(' bash:', ' "*": ask');
|
|
285
353
|
else fm.push(' bash: deny');
|
|
286
354
|
}
|
|
287
355
|
return fm;
|
|
@@ -419,7 +487,10 @@ export function writeNativeLayer(items, agentsDir, skillsDir, overrides = null,
|
|
|
419
487
|
kind = 'agent';
|
|
420
488
|
} else {
|
|
421
489
|
// Skills are BYTE-IDENTICAL across hosts: name + description, body link-stripped.
|
|
422
|
-
|
|
490
|
+
// The user-only key is emitted for every host too — Claude Code and Bob honour it,
|
|
491
|
+
// OpenCode and Copilot ignore an unknown key — so the identity holds.
|
|
492
|
+
fm = [`name: ${stem}`, `description: ${jsonDumps(skillDescription(text))}`];
|
|
493
|
+
if (isUserInvokedOnly(text)) fm.push('disable-model-invocation: true');
|
|
423
494
|
body = stripSkillBodyLinks(body);
|
|
424
495
|
dest = path.join(skillsDir, stem, 'SKILL.md');
|
|
425
496
|
kind = 'skill';
|