geneseed 3.4.0 → 3.6.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 +316 -0
- package/DESIGN.md +11 -14
- package/QUICKSTART.md +3 -1
- package/README.md +51 -42
- package/SETUP.md +25 -27
- package/SHIPPED.md +26 -32
- package/adapters/bob/README.md +12 -9
- package/adapters/copilot/README.md +26 -25
- package/adapters/opencode/GLOBAL-HARNESS-SPEC.md +1 -1
- package/adapters/opencode/README.md +2 -2
- package/adapters/opencode/plugins/geneseed-context.js +5 -3
- package/adapters/opencode/plugins/geneseed-workflow.js +30 -3
- package/adapters/opencode/workflows/_runtime.js +97 -7
- package/bin/build-driver.mjs +5 -1248
- package/bin/geneseed-cli.mjs +11 -6
- package/bin/geneseed-hook.mjs +4 -2
- package/docs/candidate-hosts.md +1 -1
- package/docs/design-history.md +37 -37
- package/docs/extending.md +49 -24
- package/docs/token-footprint.md +52 -26
- package/docs/web/claude-hooks.md +3 -2
- package/docs/web/collaboration.md +2 -2
- package/docs/web/first-session.md +1 -1
- package/docs/web/footprint.md +2 -2
- package/docs/web/harnesses.md +1 -1
- package/docs/web/model.md +1 -1
- package/docs/web/plugin-workflow.md +1 -0
- package/docs/web/rules.md +3 -3
- package/docs/web/setup-choices.md +2 -2
- package/docs/web/themes-author.md +1 -1
- package/docs/web-ui.md +18 -10
- package/harness.config.json +1 -1
- package/js/README.md +6 -5
- package/js/build/driver.mjs +1191 -0
- package/js/build/emit-claude.mjs +17 -10
- package/js/build/emit-common.mjs +4 -4
- package/js/build/generate.mjs +1 -1
- package/js/build/render.mjs +48 -44
- package/js/build/stubs.mjs +1 -1
- package/js/build/themes.mjs +4 -4
- package/js/build/version.mjs +12 -0
- package/js/cli-table.json +21 -6
- package/js/hosts/hooks.mjs +89 -74
- package/js/hosts/hosts.mjs +19 -2
- package/js/hosts/mcp.mjs +14 -20
- package/js/hosts/native.mjs +23 -17
- package/js/hosts/settings.mjs +52 -104
- package/js/inspect/checks-authoring.mjs +117 -3
- package/js/inspect/diff.mjs +1 -1
- package/js/inspect/doctor.mjs +1 -1
- package/js/inspect/excludes.mjs +1 -1
- package/js/inspect/scan.mjs +3 -14
- package/js/inspect/status.mjs +2 -2
- package/js/inspect/validate.mjs +1 -1
- package/js/lib/fs.mjs +23 -8
- package/js/lib/json.mjs +7 -1
- package/js/lib/proc.mjs +6 -4
- package/js/maintain/migrate.mjs +1 -1
- package/js/maintain/setup.mjs +1 -1
- package/js/maintain/uninstall.mjs +17 -15
- package/js/ui/cli.mjs +19 -0
- package/js/ui/menu.mjs +1 -1
- package/js/web/actions.mjs +4 -4
- package/js/web/activity.mjs +4 -4
- package/js/web/api.mjs +14 -11
- package/js/web/daemon.mjs +1 -1
- package/js/web/docs.mjs +12 -12
- package/js/web/handler.mjs +12 -5
- package/js/web/jobs.mjs +1 -1
- package/package.json +1 -1
- package/src/AGENT.md.tmpl +74 -38
- package/src/agents/developer.md +2 -2
- package/src/agents/reviewer.md +5 -2
- package/src/doctrines/README.md +26 -3
- package/src/doctrines/craft.md +78 -5
- package/src/doctrines/ops.md +62 -0
- package/src/doctrines/process.md +91 -4
- package/src/doctrines/rigor.md +40 -0
- package/src/laws/universal.md +27 -21
- package/src/memory/README.md +4 -2
- package/src/modes/foreman.md +5 -5
- package/src/notebook/README.md +2 -1
- package/src/ontology/universal.md +71 -67
- package/src/postures/peer.md +3 -0
- package/src/skills/bruno-collection-generator.md +1 -1
- package/src/skills/bruno-test-writer.md +2 -2
- package/src/skills/daydream/LICENSE +21 -0
- package/src/skills/daydream/VENDOR.md +3 -1
- package/src/skills/debug.md +1 -1
- package/src/skills/document-project.md +127 -25
- package/src/skills/geneseed-code-review.md +6 -2
- package/src/skills/parallel-agents.md +3 -3
- package/src/skills/pipeline.md +4 -4
- package/src/skills/react-view-transitions/VENDOR.md +7 -1
- package/src/skills/workflow.md +12 -4
- package/themes/_TEMPLATE.json +7 -5
- package/themes/biker.json +6 -4
- package/themes/commentator.json +6 -4
- package/themes/cyberpunk.json +6 -4
- package/themes/gamer.json +6 -4
- package/themes/imperial.json +6 -4
- package/themes/joker.json +6 -4
- package/themes/marvin.json +6 -4
- package/themes/mean.json +6 -4
- package/themes/military.json +6 -4
- package/themes/neutral.json +7 -5
- package/themes/opencode/README.md +2 -2
- package/themes/pirate.json +6 -4
- package/themes/sports.json +6 -4
- package/themes/verstappen.json +6 -4
- package/themes/wizard.json +6 -4
- package/web/dist/assets/Activity-Flgf4kHy.js +1 -0
- package/web/dist/assets/ActivityDetail-DIpr85nh.js +1 -0
- package/web/dist/assets/CatalogRow-CjyLjaLb.js +1 -0
- package/web/dist/assets/Diff-Deisdha8.js +2 -0
- package/web/dist/assets/Docs-DGUdY4l4.js +2 -0
- package/web/dist/assets/Doctor-F74lJ69P.js +1 -0
- package/web/dist/assets/FilterInput-BakJLvaV.js +1 -0
- package/web/dist/assets/GreenhouseView-D072lBiD.js +2 -0
- package/web/dist/assets/Harness-DNRP2Va7.js +1 -0
- package/web/dist/assets/JournalView-DWFqZ0Iw.js +1 -0
- package/web/dist/assets/Laws-BN6g3HU7.js +3 -0
- package/web/dist/assets/Library-CDOv4emM.js +1 -0
- package/web/dist/assets/Markdown-BQAvN8xx.js +38 -0
- package/web/dist/assets/OperatorHudView-CwNIUVX8.js +2 -0
- package/web/dist/assets/Profile-CpP9BTne.js +1 -0
- package/web/dist/assets/Rules-DFXsUCB7.js +1 -0
- package/web/dist/assets/Settings-BqZVUtJ5.js +1 -0
- package/web/dist/assets/Skills-Cu6kBgfF.js +1 -0
- package/web/dist/assets/StatusBadge-Cg6j-NJL.js +1 -0
- package/web/dist/assets/activity-iWaWVtsR.js +1 -0
- package/web/dist/assets/drawable-BgfrEBer.js +1 -0
- package/web/dist/assets/index-CAqrWhKl.js +2 -0
- package/web/dist/assets/index-Ruh93A3A.css +1 -0
- package/web/dist/assets/jobBuckets-Dbq8ZlWW.js +1 -0
- package/web/dist/assets/lawCats-BLgxl151.js +1 -0
- package/web/dist/assets/react-DbkvxWG5.js +8 -0
- package/web/dist/assets/rolldown-runtime-CbXtAM7H.js +1 -0
- package/web/dist/index.html +4 -3
- package/web/src/pages/Laws.jsx +22 -8
- package/src/skills/react-view-transitions/README.md +0 -42
- package/web/dist/assets/Activity-DMGhhjQc.js +0 -1
- package/web/dist/assets/ActivityDetail-CeQcDA7e.js +0 -1
- package/web/dist/assets/CatalogRow-CnSbDPyg.js +0 -1
- package/web/dist/assets/Diff-D_ksMBD1.js +0 -5
- package/web/dist/assets/Doctor-Brb3_oCT.js +0 -1
- package/web/dist/assets/FilterInput-DbRWPhtT.js +0 -1
- package/web/dist/assets/GreenhouseView-F3VNFr73.js +0 -2
- package/web/dist/assets/Harness-DJSwfYeF.js +0 -1
- package/web/dist/assets/JournalView-BLRYRnB3.js +0 -1
- package/web/dist/assets/Laws-CKYns9qe.js +0 -3
- package/web/dist/assets/Library-BK0YloSv.js +0 -1
- package/web/dist/assets/Markdown-BzTRzc7a.js +0 -46
- package/web/dist/assets/OperatorHudView-D3OQSGp5.js +0 -2
- package/web/dist/assets/Profile-CPA2u_0g.js +0 -1
- package/web/dist/assets/Rules-Lukel-Ru.js +0 -1
- package/web/dist/assets/Skills-BKRLHH7m.js +0 -1
- package/web/dist/assets/StatusBadge-DI3weRKd.js +0 -1
- package/web/dist/assets/activity-C_H2Bx1P.js +0 -1
- package/web/dist/assets/index-B7_Wga23.js +0 -2
- package/web/dist/assets/index-DzdQjvM1.css +0 -1
- package/web/dist/assets/index-N8dTrVro.js +0 -1
- package/web/dist/assets/index-Py_IQjgK.js +0 -10
- package/web/dist/assets/jobBuckets-DfTUJUxR.js +0 -1
- package/web/dist/assets/lawCats-DUS9UTH4.js +0 -1
- package/web/dist/assets/react-DOpiTn5v.js +0 -32
package/js/hosts/hooks.mjs
CHANGED
|
@@ -8,11 +8,9 @@
|
|
|
8
8
|
*
|
|
9
9
|
* ONE VERB, TWO DIALECTS. `--host` names the host that will read the verdict. Claude Code
|
|
10
10
|
* (the default) reads `hookSpecificOutput.permissionDecision: "ask"` and shows the user a
|
|
11
|
-
* prompt. GitHub Copilot's `
|
|
12
|
-
* `{"
|
|
13
|
-
*
|
|
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
|
|
11
|
+
* prompt. GitHub Copilot's `preToolUse` reads the same decision TOP-LEVEL —
|
|
12
|
+
* `{"permissionDecision": "ask", "permissionDecisionReason": …}` — so `--host copilot` asks
|
|
13
|
+
* exactly where Claude asks, in Copilot's envelope. `--host bob` is Bob's own
|
|
16
14
|
* protocol: PreToolUse ignores stdout and refuses only on EXIT CODE 2, so the two Laws
|
|
17
15
|
* exit 2 with the reason on stderr and the rest is a stderr line with exit 0; SessionStart
|
|
18
16
|
* context is plain stdout, as on Claude. Since P5b they are also what the emitted hooks name: `bin/build-driver.mjs` bakes
|
|
@@ -43,7 +41,7 @@
|
|
|
43
41
|
* and every file left behind. Absolute rather than compared is what let it survive the deletion
|
|
44
42
|
* of the implementation it was once compared against.
|
|
45
43
|
*/
|
|
46
|
-
import { existsSync, statSync, readFileSync, readdirSync, mkdirSync,
|
|
44
|
+
import { existsSync, statSync, readFileSync, readdirSync, mkdirSync,
|
|
47
45
|
appendFileSync, writeFileSync }
|
|
48
46
|
from 'node:fs';
|
|
49
47
|
import path from 'node:path';
|
|
@@ -56,7 +54,9 @@ import { NO_WINDOW } from '../lib/proc.mjs';
|
|
|
56
54
|
// module to find it: the CLI is under a hard transitive `child_process` ban and `learn`
|
|
57
55
|
// spawns. So the resolver has one owner in `js/hosts/hosts.mjs` rather than a second copy — and
|
|
58
56
|
// with it went this file's private `opencodeConfigDir`, whose only caller it was.
|
|
59
|
-
import {
|
|
57
|
+
import {
|
|
58
|
+
GATE_LEDGER, resolveMemoryDir, resolvePath, sovereignBypass,
|
|
59
|
+
} from './hosts.mjs';
|
|
60
60
|
|
|
61
61
|
const ROOT = path.resolve(import.meta.dirname, '../..');
|
|
62
62
|
|
|
@@ -88,32 +88,8 @@ const ROOT = path.resolve(import.meta.dirname, '../..');
|
|
|
88
88
|
// call. For the two GATES that containment is not enough, and `guardGate` below is the
|
|
89
89
|
// contract: these verbs exit 0 and signal on stdout, so "the gate blew up" and "the gate
|
|
90
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`
|
|
92
|
-
//
|
|
93
|
-
// in this series.
|
|
94
|
-
|
|
95
|
-
/**
|
|
96
|
-
* `Path(p).resolve()` — absolute, symlinks followed, and the filesystem's OWN casing.
|
|
97
|
-
*
|
|
98
|
-
* `realpathSync` throws on a path that does not exist, which is routine here (an
|
|
99
|
-
* `excludes.json` may name a folder that was deleted), so the existing prefix is
|
|
100
|
-
* canonicalised and the rest appended verbatim — `resolve(strict=False)`'s behaviour.
|
|
101
|
-
*/
|
|
102
|
-
function resolvePath(p) {
|
|
103
|
-
let cur = path.resolve(expanduser(p));
|
|
104
|
-
const tail = [];
|
|
105
|
-
for (;;) {
|
|
106
|
-
try {
|
|
107
|
-
return tail.length ? path.join(realpathSync.native(cur), ...tail)
|
|
108
|
-
: realpathSync.native(cur);
|
|
109
|
-
} catch {
|
|
110
|
-
const parent = path.dirname(cur);
|
|
111
|
-
if (parent === cur) return path.resolve(expanduser(p));
|
|
112
|
-
tail.unshift(path.basename(cur));
|
|
113
|
-
cur = parent;
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
}
|
|
91
|
+
// A gate that cannot evaluate a call therefore asks, and says why. `resolvePath` comes from
|
|
92
|
+
// `js/hosts/hosts.mjs`, which this module already imports — it had kept a private copy.
|
|
117
93
|
|
|
118
94
|
// NOT imported from `js/lib/fs.mjs` (the owner everywhere else) — hot path, no new import cost.
|
|
119
95
|
const isFile = (p) => { try { return statSync(p).isFile(); } catch { return false; } };
|
|
@@ -242,6 +218,19 @@ const LAZY_DIRS = ['docs', 'doc', 'documentation', 'architecture', 'adr', 'ADR']
|
|
|
242
218
|
const EXCLUDE_DIRS = new Set(['node_modules', '.git', 'dist', 'build', 'vendor', '.next',
|
|
243
219
|
'target', '.venv', '__pycache__', '.opencode', '.harness']);
|
|
244
220
|
|
|
221
|
+
// The root instruction file a host already loads by itself. Injecting it again pays the
|
|
222
|
+
// whole harness twice per session — and was doing exactly that until 2026-09: every hooked
|
|
223
|
+
// host had its own root in EAGER_ROOT. Other tools' roots stay eager (a repo carrying a
|
|
224
|
+
// hand-written AGENTS.md is worth showing Claude Code); only the host's OWN is dropped.
|
|
225
|
+
// The OpenCode context plugin carries the same rule for AGENT.md/AGENTS.md/CLAUDE.md.
|
|
226
|
+
const NATIVE_ROOT = { claude: ['CLAUDE.md'], bob: ['AGENTS.md'], copilot: ['AGENTS.md'] };
|
|
227
|
+
|
|
228
|
+
// Eager injection budget: a root README of 40k chars was going out whole, every session and
|
|
229
|
+
// every compaction, and cost more than the harness itself. Per-file cut at the last line
|
|
230
|
+
// break under the cap; files that would push the total over the budget are listed lazy.
|
|
231
|
+
const EAGER_FILE_BYTES = 16 * 1024;
|
|
232
|
+
const EAGER_TOTAL_BYTES = 48 * 1024;
|
|
233
|
+
|
|
245
234
|
const CLAUDE_MARKERS = ['.claude', '.bob'];
|
|
246
235
|
const GENESEED_MANIFEST = '.geneseed-manifest.json';
|
|
247
236
|
|
|
@@ -310,12 +299,14 @@ function rglobMd(dir, acc = []) {
|
|
|
310
299
|
* Root entry docs are eager; other root markdown, the doc trees and monorepo package
|
|
311
300
|
* READMEs are lazy.
|
|
312
301
|
*/
|
|
313
|
-
export function discoverContext(root) {
|
|
302
|
+
export function discoverContext(root, host = HOST) {
|
|
314
303
|
const eager = new Map();
|
|
315
304
|
const lazy = new Map();
|
|
305
|
+
const native = NATIVE_ROOT[host] || [];
|
|
316
306
|
for (const full of sortPaths(listDir(root).map((n) => path.join(root, n)))) {
|
|
317
307
|
if (!isFile(full)) continue;
|
|
318
308
|
const name = path.basename(full);
|
|
309
|
+
if (native.includes(name)) continue;
|
|
319
310
|
if (EAGER_ROOT.includes(name)) eager.set(full, null);
|
|
320
311
|
else if (path.extname(name).toLowerCase() === '.md') lazy.set(full, null);
|
|
321
312
|
}
|
|
@@ -473,25 +464,42 @@ export function cmdContext(args) {
|
|
|
473
464
|
`=== PROJECT CONTEXT \u2014 binding for this repo (via ${source}) ===`,
|
|
474
465
|
'',
|
|
475
466
|
];
|
|
467
|
+
let spent = 0;
|
|
468
|
+
const demoted = [];
|
|
476
469
|
for (const entry of eager) {
|
|
477
470
|
const p = entry.path === undefined ? '' : entry.path;
|
|
478
471
|
const desc = entry.description === undefined ? '' : entry.description;
|
|
479
472
|
const target = path.isAbsolute(p) ? p : path.join(root, p);
|
|
480
|
-
|
|
473
|
+
let text;
|
|
481
474
|
try {
|
|
482
|
-
|
|
475
|
+
text = readText(target).replace(/\n+$/, '');
|
|
483
476
|
} catch (e) {
|
|
484
|
-
lines.push(
|
|
477
|
+
lines.push(`----- ${disp(p, root)}${desc ? ` \u2014 ${desc}` : ''} -----`,
|
|
478
|
+
`[context] MISSING eager file: ${asOsError(e, target)}`, '');
|
|
479
|
+
continue;
|
|
485
480
|
}
|
|
486
|
-
|
|
481
|
+
if (text.length > EAGER_FILE_BYTES) {
|
|
482
|
+
const nl = text.lastIndexOf('\n', EAGER_FILE_BYTES);
|
|
483
|
+
text = `${text.slice(0, nl > 0 ? nl : EAGER_FILE_BYTES)}\n`
|
|
484
|
+
+ `[context] truncated at ${EAGER_FILE_BYTES / 1024} KB \u2014 read ${disp(p, root)} `
|
|
485
|
+
+ 'on demand for the rest';
|
|
486
|
+
}
|
|
487
|
+
if (spent + text.length > EAGER_TOTAL_BYTES) {
|
|
488
|
+
demoted.push(entry);
|
|
489
|
+
continue;
|
|
490
|
+
}
|
|
491
|
+
spent += text.length;
|
|
492
|
+
lines.push(`----- ${disp(p, root)}${desc ? ` \u2014 ${desc}` : ''} -----`, text, '');
|
|
487
493
|
}
|
|
488
494
|
|
|
489
|
-
if (lazy.length) {
|
|
495
|
+
if (lazy.length || demoted.length) {
|
|
490
496
|
lines.push('--- Lazy entries (load only when the task needs them) ---');
|
|
491
|
-
for (const entry of lazy) {
|
|
497
|
+
for (const entry of [...lazy, ...demoted]) {
|
|
492
498
|
const p = entry.path === undefined ? '' : entry.path;
|
|
493
499
|
const desc = entry.description === undefined ? '' : entry.description;
|
|
494
|
-
|
|
500
|
+
const over = demoted.includes(entry)
|
|
501
|
+
? ` (eager, but over the ${EAGER_TOTAL_BYTES / 1024} KB session budget \u2014 read on demand)` : '';
|
|
502
|
+
lines.push(` - ${disp(p, root)}${desc ? ` \u2014 ${desc}` : ''}${over}`);
|
|
495
503
|
}
|
|
496
504
|
lines.push('');
|
|
497
505
|
}
|
|
@@ -544,9 +552,14 @@ function askDecision(reason) {
|
|
|
544
552
|
let HOST = 'claude';
|
|
545
553
|
const setHost = (args) => { HOST = (args && args.host) || 'claude'; };
|
|
546
554
|
|
|
547
|
-
/**
|
|
548
|
-
|
|
549
|
-
|
|
555
|
+
/**
|
|
556
|
+
* Copilot's `preToolUse` verdict: the same `ask` Claude reads, but top-level rather than under
|
|
557
|
+
* `hookSpecificOutput` (docs.github.com/en/copilot/reference/hooks-configuration). An older
|
|
558
|
+
* emit wired a `toolCall` event answered with `{"block": true}`; Copilot no longer has that
|
|
559
|
+
* event, so those gates never ran.
|
|
560
|
+
*/
|
|
561
|
+
function copilotAskDecision(reason) {
|
|
562
|
+
return `${jsonDumpsCompact({ permissionDecision: 'ask', permissionDecisionReason: reason })}\n`;
|
|
550
563
|
}
|
|
551
564
|
|
|
552
565
|
/**
|
|
@@ -557,18 +570,15 @@ function blockDecision(reason) {
|
|
|
557
570
|
const BOB_DENY_EXIT = 2;
|
|
558
571
|
|
|
559
572
|
/**
|
|
560
|
-
* On
|
|
573
|
+
* On Bob, which has no ask tier, which rules are worth a hard block. Laws I and IV: a
|
|
561
574
|
* credential in a tracked file and a history-discarding git act are wrong in every context,
|
|
562
575
|
* so refusing them costs nothing. Process 1 and process 5 are the USER's calls — a hard
|
|
563
|
-
* block would make
|
|
576
|
+
* block would make Bob unable to commit at all — so they become a warning the host
|
|
564
577
|
* logs. `gate-error` is a warning too: a crashed gate that blocked every tool call would be
|
|
565
578
|
* a lockout, not a safeguard, and there is no prompt through which the user could clear it.
|
|
566
579
|
*/
|
|
567
580
|
const BLOCK_RULES = new Set(['law-1', 'law-4']);
|
|
568
581
|
|
|
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
582
|
/**
|
|
573
583
|
* THE GATE LEDGER — one JSON line per ask, nothing per defer.
|
|
574
584
|
*
|
|
@@ -603,27 +613,23 @@ function ledger(root, verb, rule) {
|
|
|
603
613
|
|
|
604
614
|
/**
|
|
605
615
|
* Ask, and record that we asked. Every ask site goes through here so none can skip the
|
|
606
|
-
* ledger. On `--host
|
|
616
|
+
* ledger. On `--host bob` the ask becomes a block or a warning (see `BLOCK_RULES`); the
|
|
607
617
|
* ledger records the block, never the warning — a warning asked nothing of anyone.
|
|
608
618
|
*/
|
|
609
619
|
function ask(args, verb, rule, reason) {
|
|
610
|
-
if (HOST === '
|
|
620
|
+
if (HOST === 'bob') {
|
|
611
621
|
if (!BLOCK_RULES.has(rule)) {
|
|
612
|
-
//
|
|
622
|
+
// No ask tier; a stderr line is the nudge, and the call goes through.
|
|
613
623
|
err(`[geneseed] ${reason}\n`);
|
|
614
624
|
return 0;
|
|
615
625
|
}
|
|
616
626
|
ledger(args && args.root, verb, rule);
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
return BOB_DENY_EXIT;
|
|
621
|
-
}
|
|
622
|
-
out(blockDecision(reason));
|
|
623
|
-
return 0;
|
|
627
|
+
// stdout is not read on Bob's PreToolUse; the reason goes where Bob's hook log looks.
|
|
628
|
+
err(`[geneseed] BLOCKED: ${reason}\n`);
|
|
629
|
+
return BOB_DENY_EXIT;
|
|
624
630
|
}
|
|
625
631
|
ledger(args && args.root, verb, rule);
|
|
626
|
-
out(askDecision(reason));
|
|
632
|
+
out(HOST === 'copilot' ? copilotAskDecision(reason) : askDecision(reason));
|
|
627
633
|
return 0;
|
|
628
634
|
}
|
|
629
635
|
|
|
@@ -651,10 +657,11 @@ export function guardGate(fn, verb = 'gate') {
|
|
|
651
657
|
/**
|
|
652
658
|
* The hook payload on stdin, or null for a well-formed absence. Not-JSON THROWS.
|
|
653
659
|
*
|
|
654
|
-
* Copilot spells the same payload in camelCase — `toolName`/`
|
|
655
|
-
* `tool_name`/`tool_input` — and
|
|
656
|
-
*
|
|
657
|
-
*
|
|
660
|
+
* Copilot spells the same payload in camelCase — `toolName`/`toolArgs` where Claude sends
|
|
661
|
+
* `tool_name`/`tool_input` — and `toolArgs` may arrive as a JSON STRING rather than an object.
|
|
662
|
+
* Both are folded to Claude's shape so every gate reads one. (`toolInput` and the `after`
|
|
663
|
+
* phase are the retired `toolCall` event's spelling, kept so an install not yet re-emitted
|
|
664
|
+
* still reads right.) A `toolArgs` string that is not JSON throws: fail closed.
|
|
658
665
|
*/
|
|
659
666
|
function readPayload() {
|
|
660
667
|
const raw = readStdin();
|
|
@@ -667,8 +674,12 @@ function readPayload() {
|
|
|
667
674
|
}
|
|
668
675
|
if (!payload || typeof payload !== 'object') return null;
|
|
669
676
|
if (payload.phase === 'after') return null;
|
|
670
|
-
if (payload.tool_input === undefined
|
|
671
|
-
payload.
|
|
677
|
+
if (payload.tool_input === undefined) {
|
|
678
|
+
let ta = payload.toolArgs !== undefined ? payload.toolArgs : payload.toolInput;
|
|
679
|
+
if (typeof ta === 'string') {
|
|
680
|
+
try { ta = JSON.parse(ta); } catch { throw new Error('the payload\'s toolArgs is not JSON'); }
|
|
681
|
+
}
|
|
682
|
+
if (ta !== undefined) payload.tool_input = ta;
|
|
672
683
|
}
|
|
673
684
|
return payload;
|
|
674
685
|
}
|
|
@@ -685,7 +696,9 @@ function gitDecide(args, payload) {
|
|
|
685
696
|
return ask(args, 'git-gate', 'law-4', 'Geneseed Law IV \u2014 a history-rewriting or '
|
|
686
697
|
+ 'discarding git act needs confirmation bound to this specific command');
|
|
687
698
|
}
|
|
688
|
-
|
|
699
|
+
// `--no-consent`: the process pack (or process 5 alone) is off, so the commit/push ask has
|
|
700
|
+
// no rule behind it — but Law IV above is universal and has already run.
|
|
701
|
+
if (args.noConsent || !GIT_GATE_RE.test(command)) return 0;
|
|
689
702
|
return ask(args, 'git-gate', 'process-5', 'Geneseed Doctrine process 5 \u2014 every git '
|
|
690
703
|
+ 'commit/push needs explicit approval');
|
|
691
704
|
}
|
|
@@ -739,9 +752,10 @@ function ruleDecide(args, payload) {
|
|
|
739
752
|
const ti = (payload && payload.tool_input) || {};
|
|
740
753
|
const p = ti.file_path || ti.path || '';
|
|
741
754
|
if (typeof p !== 'string' || !p) return 0;
|
|
742
|
-
// Write carries `content`, Edit carries `new_string
|
|
743
|
-
|
|
744
|
-
|
|
755
|
+
// Write carries `content`, Edit carries `new_string` (Copilot: `file_text`, `new_str`);
|
|
756
|
+
// any of them can plant a credential.
|
|
757
|
+
const body = [ti.content, ti.new_string, ti.file_text, ti.new_str]
|
|
758
|
+
.find((v) => typeof v === 'string') || '';
|
|
745
759
|
if (body && !DOTENV_RE.test(p) && SECRET_RE.test(body)) {
|
|
746
760
|
return ask(args, 'rule-gate', 'law-1', `Geneseed Law I — ${p} would carry a `
|
|
747
761
|
+ 'credential-shaped string. Secrets live in .env or a secret manager, never in a '
|
|
@@ -760,9 +774,9 @@ export const cmdRuleGate = guardGate(ruleGate, 'rule-gate');
|
|
|
760
774
|
// ======================================================================================
|
|
761
775
|
|
|
762
776
|
/**
|
|
763
|
-
* Copilot's
|
|
764
|
-
*
|
|
765
|
-
*
|
|
777
|
+
* Copilot's and Bob's PreToolUse entries carry no matcher over documented tool names, so
|
|
778
|
+
* neither can name `git-gate` for Bash and `rule-gate` for Write the way Claude's
|
|
779
|
+
* `PreToolUse` does. This verb is the two fused, dispatched on the payload rather
|
|
766
780
|
* than on a matcher: a `command` field is a shell call and gets the git checks, a path
|
|
767
781
|
* field is a write and gets the rule checks. Nothing here decides anything the two named
|
|
768
782
|
* gates do not — it only removes the need for a matcher the host does not have.
|
|
@@ -905,7 +919,8 @@ export function readNotes(raw) {
|
|
|
905
919
|
if (s[0] === '{') {
|
|
906
920
|
let payload;
|
|
907
921
|
try { payload = JSON.parse(s); } catch { return raw; }
|
|
908
|
-
|
|
922
|
+
// Copilot's agentStop/preCompact spell it `transcriptPath`.
|
|
923
|
+
const tp = payload && (payload.transcript_path || payload.transcriptPath);
|
|
909
924
|
if (tp) return flattenTranscript(tp);
|
|
910
925
|
return raw;
|
|
911
926
|
}
|
package/js/hosts/hosts.mjs
CHANGED
|
@@ -26,6 +26,9 @@ export const GLOBAL_MANIFEST = '.geneseed-manifest.json';
|
|
|
26
26
|
/** The user's own per-install exclusion list; `js/build/stubs.mjs` seeds it, this file reads it. */
|
|
27
27
|
export const EXCLUDES_FILE = 'excludes.json';
|
|
28
28
|
|
|
29
|
+
/** The gate ledger inside an install: the hooks append to it, `status` counts it. */
|
|
30
|
+
export const GATE_LEDGER = path.join('notebook', 'gates.jsonl');
|
|
31
|
+
|
|
29
32
|
/**
|
|
30
33
|
* `sovereign_bypass` — the user's own excludes.json, read on EVERY hook call so an edit
|
|
31
34
|
* takes effect without a re-emit. Every failure mode degrades to false: a hook must never
|
|
@@ -248,11 +251,25 @@ export const HOSTS = [
|
|
|
248
251
|
* Unknown host -> false, which is the shape that KEEPS the tables. The value arrives from a
|
|
249
252
|
* user-editable `.geneseed-emit` marker, so an unrecognised one must degrade to the portable
|
|
250
253
|
* bundle rather than raise.
|
|
254
|
+
*
|
|
255
|
+
* SPLIT PER KIND (2026-09): Bob reads `.bob/skills/<name>/SKILL.md` natively — verified
|
|
256
|
+
* against its docs in the Bob injection review — but has no markdown agents directory, so a
|
|
257
|
+
* single boolean was wrong both ways: `false` shipped the §4 Skills table on top of a
|
|
258
|
+
* catalogue Bob already had (~1.7k tokens twice), `true` would have stripped the §3 Agents
|
|
259
|
+
* table that is Bob's ONLY agent catalogue. Each CATALOG block in AGENT.md names its kind and
|
|
260
|
+
* resolves against its own flag. Copilot stays unverified and keeps both tables. Mirrored in
|
|
261
|
+
* `bin/build-driver.mjs` (which cannot import this module's spawning neighbours).
|
|
251
262
|
*/
|
|
252
|
-
const NATIVE_CATALOG = {
|
|
263
|
+
const NATIVE_CATALOG = {
|
|
264
|
+
opencode: { skills: true, agents: true },
|
|
265
|
+
claude: { skills: true, agents: true },
|
|
266
|
+
bob: { skills: true, agents: false },
|
|
267
|
+
copilot: { skills: false, agents: false },
|
|
268
|
+
};
|
|
253
269
|
|
|
270
|
+
/** `{ skills, agents }` for a known host, `false` for an unknown one. */
|
|
254
271
|
export function hostCatalogsNatively(host) {
|
|
255
|
-
return
|
|
272
|
+
return NATIVE_CATALOG[host] || false;
|
|
256
273
|
}
|
|
257
274
|
|
|
258
275
|
/** `_harness_learn.MEMORY_DIR_NAMES` — the neutral name and the imperial theme's. */
|
package/js/hosts/mcp.mjs
CHANGED
|
@@ -24,9 +24,9 @@
|
|
|
24
24
|
* `1` on the way back out. `readJsonc` already learned this; the strict-JSON fork has to
|
|
25
25
|
* learn it separately, because it does not go through `readJsonc` at all.
|
|
26
26
|
*
|
|
27
|
-
* A JsonNumber IS AN OBJECT TO `typeof`, which is why `isDict`
|
|
28
|
-
*
|
|
29
|
-
* the JS twin has to be false for its wrapper.
|
|
27
|
+
* A JsonNumber IS AN OBJECT TO `typeof`, which is why `isDict` (js/lib/json.mjs) excludes
|
|
28
|
+
* the wrapper rather than testing `typeof v === 'object'`. Python's `isinstance(x, dict)` is
|
|
29
|
+
* false for a float; the JS twin has to be false for its wrapper.
|
|
30
30
|
*/
|
|
31
31
|
import { existsSync, mkdirSync, renameSync } from 'node:fs';
|
|
32
32
|
import os from 'node:os';
|
|
@@ -35,20 +35,10 @@ import path from 'node:path';
|
|
|
35
35
|
import { bobConfigDir, copilotConfigDir, resolvePath } from './hosts.mjs';
|
|
36
36
|
import { installState, installTargets } from './installs.mjs';
|
|
37
37
|
import { printOut, printErr, readText, writeText } from '../lib/fs.mjs';
|
|
38
|
-
import { jsonDumpsIndent, parseJson } from '../lib/json.mjs';
|
|
38
|
+
import { isDict, jsonDumpsIndent, parseJson } from '../lib/json.mjs';
|
|
39
39
|
import { padEndToWidth } from '../lib/text.mjs';
|
|
40
40
|
import { opencodeTarget, readJsonc } from './settings.mjs';
|
|
41
41
|
|
|
42
|
-
/**
|
|
43
|
-
* `isinstance(x, dict)` for a value that came out of `json.loads`.
|
|
44
|
-
*
|
|
45
|
-
* A JSON value is null, a boolean, a string, a NUMBER, an array or an object — and
|
|
46
|
-
* `parseJson` hands numbers back wrapped, so `typeof v === 'object'` says yes to a float.
|
|
47
|
-
* `constructor === Object` is the plain-object test, and every object `JSON.parse` builds
|
|
48
|
-
* is one.
|
|
49
|
-
*/
|
|
50
|
-
const isDict = (v) => v !== null && v !== undefined && typeof v === 'object'
|
|
51
|
-
&& v.constructor === Object;
|
|
52
42
|
|
|
53
43
|
/** `dict.get(key, default)`. */
|
|
54
44
|
const dget = (obj, key, dflt) => (isDict(obj) && Object.hasOwn(obj, key) ? obj[key] : dflt);
|
|
@@ -184,8 +174,10 @@ export function mcpSetEnabled(config, name, enabled, host = 'opencode') {
|
|
|
184
174
|
*
|
|
185
175
|
* The host fork is the whole function. OpenCode is read comment-tolerantly because a
|
|
186
176
|
* hand-maintained `opencode.jsonc` carries `//`; the other three are read STRICTLY, because
|
|
187
|
-
*
|
|
188
|
-
*
|
|
177
|
+
* their configs are plain JSON by contract, and a file that does not parse as such — for a
|
|
178
|
+
* Claude global install, one that holds projects and history — is not one to rewrite from a
|
|
179
|
+
* best-effort reading. (The comment stripper's trailing-comma pass IS string-aware; an older
|
|
180
|
+
* version of this note said otherwise, and `generate.test.mjs` pins the string-aware case.)
|
|
189
181
|
*/
|
|
190
182
|
export function mcpLoad(p, host = 'opencode') {
|
|
191
183
|
if (!existsSync(p)) return {};
|
|
@@ -244,10 +236,12 @@ export function mcpConfigFor(host, scope, root) {
|
|
|
244
236
|
: resolvePath(path.join(os.homedir(), '.claude.json'));
|
|
245
237
|
}
|
|
246
238
|
if (host === 'bob') {
|
|
247
|
-
//
|
|
248
|
-
//
|
|
249
|
-
|
|
250
|
-
|
|
239
|
+
// Bob reads MCP servers from their OWN file, never from settings.json: `.bob/mcp.json` in
|
|
240
|
+
// the project, `~/.bob/settings/mcp.json` globally, each `{"mcpServers": …}`
|
|
241
|
+
// (bob.ibm.com/docs/ide/configuration/mcp/mcp-in-bob). Servers written into settings.json,
|
|
242
|
+
// as an older version did, never loaded.
|
|
243
|
+
return scope === 'project' ? path.join(root, '.bob', 'mcp.json')
|
|
244
|
+
: path.join(bobConfigDir(), 'settings', 'mcp.json');
|
|
251
245
|
}
|
|
252
246
|
if (host === 'copilot') {
|
|
253
247
|
// The Copilot CLI reads MCP servers from ~/.copilot/mcp-config.json only — no per-repo
|
package/js/hosts/native.mjs
CHANGED
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
import { existsSync, mkdirSync } from 'node:fs';
|
|
32
32
|
import path from 'node:path';
|
|
33
33
|
import { writeText, copyFile, readText } from '../lib/fs.mjs';
|
|
34
|
-
import { jsonDumps, parseJson, formatValue, formatRepr, isTruthy } from '../lib/json.mjs';
|
|
34
|
+
import { jsonDumps, parseJson, formatValue, formatRepr, isTruthy, isDict } from '../lib/json.mjs';
|
|
35
35
|
|
|
36
36
|
/** Mirrors `_build_core.VENDORED_SKILL_DIRS`. */
|
|
37
37
|
export const VENDORED_SKILL_DIRS = new Set(['react-view-transitions', 'daydream', 'token-report']);
|
|
@@ -124,7 +124,7 @@ export function descOf(text) {
|
|
|
124
124
|
* hosts collapse that table to a pointer (`hostCatalogsNatively`), so the trigger reached
|
|
125
125
|
* no host at all. `skillDescription` below concatenates the two.
|
|
126
126
|
*/
|
|
127
|
-
|
|
127
|
+
function triggerOf(text) {
|
|
128
128
|
const lines = text.split('\n');
|
|
129
129
|
const start = lines.findIndex((ln) => /^\*\*Trigger:\*\*/.test(ln.trim()));
|
|
130
130
|
if (start < 0) return '';
|
|
@@ -143,16 +143,20 @@ export function triggerOf(text) {
|
|
|
143
143
|
}
|
|
144
144
|
|
|
145
145
|
/**
|
|
146
|
-
* Purpose + trigger, the emitted `description:` of a skill.
|
|
147
|
-
*
|
|
148
|
-
*
|
|
146
|
+
* Purpose + the trigger's FIRST sentence, the emitted `description:` of a skill. Every
|
|
147
|
+
* description is always-on context on a host that catalogues natively: at the earlier
|
|
148
|
+
* 900-char cap the catalogue weighed 22k chars (~5.6k tokens) across 51 skills, three times
|
|
149
|
+
* the figure the footprint page quoted. The first sentence says WHEN; the later sentences
|
|
150
|
+
* disambiguate, and the body — read as soon as the skill is chosen — still carries them.
|
|
151
|
+
* Cut at the last sentence end inside the cap, never mid-word.
|
|
149
152
|
*/
|
|
150
|
-
const DESCRIPTION_CAP =
|
|
151
|
-
|
|
153
|
+
const DESCRIPTION_CAP = 320;
|
|
154
|
+
function skillDescription(text) {
|
|
152
155
|
const desc = descOf(text);
|
|
153
156
|
const trig = triggerOf(text);
|
|
154
157
|
if (!trig) return desc;
|
|
155
|
-
const
|
|
158
|
+
const first = trig.match(/^.*?[.;](?=\s|$)/);
|
|
159
|
+
const full = `${desc} Use when: ${first ? first[0] : trig}`;
|
|
156
160
|
if (full.length <= DESCRIPTION_CAP) return full;
|
|
157
161
|
const cut = full.slice(0, DESCRIPTION_CAP);
|
|
158
162
|
const end = Math.max(cut.lastIndexOf('. '), cut.lastIndexOf('; '));
|
|
@@ -206,7 +210,7 @@ function isReadonly(text) {
|
|
|
206
210
|
* themselves, and the ones gated on an environment the model cannot see (a herdr pane).
|
|
207
211
|
*/
|
|
208
212
|
const USER_INVOKED_RE = /<!--\s*invocation:\s*user\s*-->/;
|
|
209
|
-
|
|
213
|
+
function isUserInvokedOnly(text) {
|
|
210
214
|
return USER_INVOKED_RE.test(text);
|
|
211
215
|
}
|
|
212
216
|
|
|
@@ -218,8 +222,8 @@ export function stripSkillBodyLinks(body) {
|
|
|
218
222
|
/** `_build_emit._agent_color_map` — validated, always a valid OpenCode slot. */
|
|
219
223
|
function agentColorMap(theme) {
|
|
220
224
|
let raw = null;
|
|
221
|
-
if (
|
|
222
|
-
if (!
|
|
225
|
+
if (isDict(theme)) raw = theme.AGENT_COLORS;
|
|
226
|
+
if (!isDict(raw)) raw = AGENT_COLORS;
|
|
223
227
|
const cleaned = {};
|
|
224
228
|
// Insertion order matters: it is the order the warnings below are printed in, and the
|
|
225
229
|
// parity gate compares the warning stream. Python dicts and JS objects agree on it for
|
|
@@ -244,10 +248,6 @@ function agentColor(stem, theme) {
|
|
|
244
248
|
return Object.hasOwn(colors, stem) ? colors[stem] : colors._default;
|
|
245
249
|
}
|
|
246
250
|
|
|
247
|
-
function isPlainObject(v) {
|
|
248
|
-
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
249
|
-
}
|
|
250
|
-
|
|
251
251
|
/** `is not None` — the test that lets a configured `temperature: 0` survive. */
|
|
252
252
|
function given(v) {
|
|
253
253
|
return v !== undefined && v !== null;
|
|
@@ -370,8 +370,8 @@ export function loadAgentOverrides(base) {
|
|
|
370
370
|
// than reproduced — matching it means detecting a decode error Node does not report.
|
|
371
371
|
return {};
|
|
372
372
|
}
|
|
373
|
-
const agents =
|
|
374
|
-
return
|
|
373
|
+
const agents = isDict(data) ? data.agents : null;
|
|
374
|
+
return isDict(agents) ? agents : {};
|
|
375
375
|
}
|
|
376
376
|
|
|
377
377
|
/** `Path.relative_to(base).as_posix()`. */
|
|
@@ -457,6 +457,12 @@ export function writeNativeLayer(items, agentsDir, skillsDir, overrides = null,
|
|
|
457
457
|
if (targetDir === undefined) continue;
|
|
458
458
|
|
|
459
459
|
if (fname.startsWith('_')) {
|
|
460
|
+
// NOT the agent template: every host loads each `.md` in its agents dir as an agent,
|
|
461
|
+
// so shipping it there registered a phantom `_template` agent, and its authoring steps
|
|
462
|
+
// (theme tokens, README badge) are the source repo's, not an install's. Nothing in an
|
|
463
|
+
// installed harness points at it; the skill template, which AGENT.md and skill-forge
|
|
464
|
+
// do point at, still ships.
|
|
465
|
+
if (folder === 'agents') continue;
|
|
460
466
|
// Authoring templates are shipped verbatim and FLAT — not wrapped as a native
|
|
461
467
|
// skill — so an author following the `_template.md` note has the scaffold on disk.
|
|
462
468
|
const dest = path.join(targetDir, fname);
|