geneseed 3.1.3 → 3.3.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 +180 -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 +50 -24
- package/adapters/opencode/README.md +5 -1
- package/adapters/opencode/plugins/geneseed-guard.js +40 -6
- package/bin/build-driver.mjs +143 -89
- package/bin/geneseed-cli.mjs +63 -106
- package/bin/geneseed-hook.mjs +26 -16
- package/docs/candidate-hosts.md +74 -0
- package/docs/extending.md +6 -5
- package/docs/token-footprint.md +32 -21
- package/docs/web-ui.md +5 -13
- package/harness.config.json +1 -1
- package/js/README.md +5 -15
- package/js/build/emit-claude.mjs +26 -12
- package/js/build/emit-common.mjs +63 -114
- package/js/build/emit-opencode.mjs +74 -66
- package/js/build/generate.mjs +4 -6
- package/js/build/render.mjs +50 -32
- package/js/build/source.mjs +29 -8
- package/js/build/stubs.mjs +25 -33
- package/js/build/themes.mjs +1 -1
- package/js/build/version.mjs +1 -3
- package/js/cli-table.json +84 -0
- package/js/hosts/hooks.mjs +243 -81
- package/js/hosts/hosts.mjs +57 -5
- package/js/hosts/installs.mjs +35 -28
- package/js/hosts/link.mjs +2 -2
- package/js/hosts/mcp.mjs +3 -1
- package/js/hosts/native.mjs +54 -19
- package/js/hosts/opencode.mjs +39 -28
- package/js/hosts/settings.mjs +206 -115
- package/js/inspect/checks-authoring.mjs +5 -4
- package/js/inspect/checks-build.mjs +1 -1
- package/js/inspect/diff.mjs +1 -3
- package/js/inspect/doctor.mjs +59 -44
- package/js/inspect/excludes.mjs +5 -11
- package/js/inspect/inventory.mjs +38 -56
- package/js/inspect/registry.mjs +4 -7
- package/js/inspect/scan.mjs +8 -15
- package/js/inspect/status.mjs +51 -3
- package/js/lib/fs.mjs +34 -0
- package/js/lib/json.mjs +28 -1
- package/js/lib/paths.mjs +0 -10
- package/js/lib/udiff.mjs +11 -16
- package/js/maintain/memory.mjs +2 -4
- package/js/maintain/migrate.mjs +3 -3
- package/js/maintain/uninstall.mjs +127 -88
- package/js/maintain/update.mjs +4 -4
- package/js/ui/anim.mjs +1 -1
- package/js/ui/menu.mjs +1 -1
- package/js/ui/tui.mjs +3 -20
- package/js/web/actions.mjs +230 -217
- package/js/web/activity.mjs +71 -89
- package/js/web/api.mjs +130 -226
- package/js/web/daemon.mjs +92 -110
- package/js/web/docs.mjs +59 -126
- package/js/web/handler.mjs +94 -123
- package/js/web/jobs.mjs +116 -178
- package/js/web/routes.mjs +47 -192
- package/js/web/server.mjs +47 -82
- package/package.json +1 -1
- package/registry.json +14 -0
- package/src/AGENT.md.tmpl +55 -16
- package/src/agents/explorer.md +2 -1
- package/src/doctrines/README.md +3 -3
- package/src/doctrines/process.md +4 -2
- package/src/doctrines/rigor.md +13 -0
- package/src/laws/universal.md +133 -43
- package/src/ontology/universal.md +38 -13
- package/src/skills/_template.md +5 -1
- package/src/skills/bruno-collection-generator.md +63 -0
- package/src/skills/bruno-test-writer.md +62 -0
- package/src/skills/clarify.md +1 -1
- package/src/skills/crash-course.md +1 -0
- package/src/skills/decode.md +1 -0
- package/src/skills/drill.md +1 -0
- package/src/skills/feynman.md +1 -0
- package/src/skills/forge-mcp.md +2 -1
- package/src/skills/fresh-eyes.md +1 -1
- package/src/skills/herdr.md +1 -0
- package/src/skills/ingest.md +2 -1
- package/src/skills/learning-path.md +1 -0
- package/src/skills/opencode-theme.md +1 -0
- package/src/skills/parallel-agents.md +1 -1
- package/src/skills/ponytail.md +2 -2
- package/src/skills/research.md +4 -2
- package/themes/_TEMPLATE.json +5 -0
- package/themes/biker.json +5 -0
- package/themes/commentator.json +5 -0
- package/themes/cyberpunk.json +5 -0
- package/themes/gamer.json +5 -0
- package/themes/imperial.json +5 -0
- package/themes/joker.json +5 -0
- package/themes/marvin.json +5 -0
- package/themes/mean.json +5 -0
- package/themes/military.json +5 -0
- package/themes/neutral.json +5 -0
- package/themes/pirate.json +5 -0
- package/themes/sports.json +5 -0
- package/themes/verstappen.json +5 -0
- package/themes/wizard.json +5 -0
- package/web/dist/assets/Activity-BfVcLrda.js +1 -0
- package/web/dist/assets/ActivityDetail-CQXccGPl.js +1 -0
- package/web/dist/assets/CatalogRow-DALEQImZ.js +1 -0
- package/web/dist/assets/Diff-BIjxXx46.js +5 -0
- package/web/dist/assets/Doctor-CJLbgsrw.js +1 -0
- package/web/dist/assets/FilterInput-CkUCk_lY.js +1 -0
- package/web/dist/assets/GreenhouseView-DfC5IILJ.js +2 -0
- package/web/dist/assets/Harness-Dm5CCU-v.js +1 -0
- package/web/dist/assets/JournalView-Cpu2eI4s.js +1 -0
- package/web/dist/assets/Laws-DDDVFuQz.js +3 -0
- package/web/dist/assets/Library-Dao3T9na.js +1 -0
- package/web/dist/assets/{marked.esm-JKvyS0Pp.js → Markdown-C__nuWX5.js} +15 -15
- package/web/dist/assets/OperatorHudView-DEpCtAjP.js +2 -0
- package/web/dist/assets/Profile-BlXtUlh2.js +1 -0
- package/web/dist/assets/{Rules-CfsV1EVy.js → Rules-DIre6gxR.js} +1 -1
- package/web/dist/assets/Skills-rY13ZPhM.js +1 -0
- package/web/dist/assets/{StatusBadge-C9bZOEZk.js → StatusBadge-wW_RS_my.js} +1 -1
- package/web/dist/assets/{activity-Cc0n2jwx.js → activity-DpYPKNrA.js} +1 -1
- package/web/dist/assets/index-BEZvPvpF.js +1 -0
- package/web/dist/assets/index-DNQE5ydy.js +10 -0
- package/web/dist/assets/index-DzdQjvM1.css +1 -0
- package/web/dist/assets/index-PmQGRS1X.js +2 -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 +119 -130
- package/js/web/graph.mjs +0 -0
- package/web/dist/assets/Activity-B49fE_ug.js +0 -1
- package/web/dist/assets/ActivityDetail-abt7hzD2.js +0 -1
- package/web/dist/assets/Diff-D3_bVkih.js +0 -5
- package/web/dist/assets/Doctor-hXp7srsx.js +0 -1
- package/web/dist/assets/Graph-DvYMtlvl.js +0 -1
- package/web/dist/assets/Harness-BIWei7N5.js +0 -1
- package/web/dist/assets/Laws-CxmnRdwt.js +0 -3
- package/web/dist/assets/Library-MjbOkHIW.js +0 -1
- package/web/dist/assets/Markdown-Dx_w0faR.js +0 -1
- package/web/dist/assets/Profile-BRFvv2gr.js +0 -1
- package/web/dist/assets/Skills-DArA6IHS.js +0 -1
- package/web/dist/assets/index-Bx3oxZ1o.css +0 -1
- package/web/dist/assets/index-DC3-GlCY.js +0 -1
- package/web/dist/assets/index-DP035wPa.js +0 -12
- package/web/dist/assets/index-dHi-7M-F.js +0 -2
- 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,11 +43,11 @@
|
|
|
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';
|
|
38
|
-
import { fileURLToPath } from 'node:url';
|
|
39
51
|
import { readText, writeText, printOut, printErr, withDiscardableStderr } from '../lib/fs.mjs';
|
|
40
52
|
import { jsonDumpsCompact } from '../lib/json.mjs';
|
|
41
53
|
import { normcase, comparePaths, toPlatformPath } from '../lib/paths.mjs';
|
|
@@ -44,9 +56,9 @@ import { NO_WINDOW } from '../lib/proc.mjs';
|
|
|
44
56
|
// module to find it: the CLI is under a hard transitive `child_process` ban and `learn`
|
|
45
57
|
// spawns. So the resolver has one owner in `js/hosts/hosts.mjs` rather than a second copy — and
|
|
46
58
|
// with it went this file's private `opencodeConfigDir`, whose only caller it was.
|
|
47
|
-
import { resolveMemoryDir, expanduser } from './hosts.mjs';
|
|
59
|
+
import { resolveMemoryDir, expanduser, sovereignBypass } from './hosts.mjs';
|
|
48
60
|
|
|
49
|
-
const ROOT = path.
|
|
61
|
+
const ROOT = path.resolve(import.meta.dirname, '../..');
|
|
50
62
|
|
|
51
63
|
// ---- Python path and text primitives -------------------------------------------------
|
|
52
64
|
// Small, and each one is here because a JS-idiomatic equivalent differs observably.
|
|
@@ -73,10 +85,12 @@ const ROOT = path.dirname(path.dirname(path.dirname(fileURLToPath(import.meta.ur
|
|
|
73
85
|
// an adjudicated product decision (`TheTildeUserFormIsADeliberateDivergence`) that the hook
|
|
74
86
|
// was quietly exempt from because it had a private copy. It now imports the owner. The
|
|
75
87
|
// refusal throws, so `sovereignBypass` and `cmdContext` below contain it per entry / per
|
|
76
|
-
// call
|
|
77
|
-
// stdout, so "the gate blew up" and "the gate
|
|
78
|
-
//
|
|
79
|
-
//
|
|
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.
|
|
80
94
|
|
|
81
95
|
/**
|
|
82
96
|
* `Path(p).resolve()` — absolute, symlinks followed, and the filesystem's OWN casing.
|
|
@@ -101,6 +115,7 @@ function resolvePath(p) {
|
|
|
101
115
|
}
|
|
102
116
|
}
|
|
103
117
|
|
|
118
|
+
// NOT imported from `js/lib/fs.mjs` (the owner everywhere else) — hot path, no new import cost.
|
|
104
119
|
const isFile = (p) => { try { return statSync(p).isFile(); } catch { return false; } };
|
|
105
120
|
const isDir = (p) => { try { return statSync(p).isDirectory(); } catch { return false; } };
|
|
106
121
|
|
|
@@ -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
|
// ======================================================================================
|
|
@@ -575,7 +703,7 @@ export function cmdGitGate(args) {
|
|
|
575
703
|
* Geneseed's own coinage and match anywhere; any other markdown matches only inside THIS
|
|
576
704
|
* install's `<root>/memory/`.
|
|
577
705
|
*/
|
|
578
|
-
|
|
706
|
+
function ruleGateTarget(p, root) {
|
|
579
707
|
if (typeof p !== 'string') return null;
|
|
580
708
|
// Split on BOTH separators rather than taking a basename: a payload carrying a Windows
|
|
581
709
|
// path reaches a POSIX host with its backslashes intact, and `path.basename` there
|
|
@@ -595,23 +723,57 @@ export 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
|
@@ -14,17 +14,69 @@
|
|
|
14
14
|
* changed resolver rather than catch it — but `tests/unit/node_driver.test.mjs`'s relocation
|
|
15
15
|
* table, which asserts each host's global target absolutely.
|
|
16
16
|
*/
|
|
17
|
-
import { realpathSync
|
|
17
|
+
import { realpathSync } from 'node:fs';
|
|
18
18
|
import path from 'node:path';
|
|
19
19
|
import os from 'node:os';
|
|
20
|
-
import { printErr, withDiscardableStderr } from '../lib/fs.mjs';
|
|
21
|
-
import { toPlatformPath } from '../lib/paths.mjs';
|
|
22
|
-
|
|
23
|
-
const isDir = (p) => { try { return statSync(p).isDirectory(); } catch { return false; } };
|
|
20
|
+
import { printErr, withDiscardableStderr, isDir, readText } from '../lib/fs.mjs';
|
|
21
|
+
import { toPlatformPath, normcase } from '../lib/paths.mjs';
|
|
24
22
|
|
|
25
23
|
/** `_build_global.GLOBAL_MANIFEST` — the file whose presence means "a global install". */
|
|
26
24
|
export const GLOBAL_MANIFEST = '.geneseed-manifest.json';
|
|
27
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
|
+
|
|
28
80
|
/**
|
|
29
81
|
* `_build_core.VERSION_MARKER`. Third caller, so it moves here — `js/build/version.mjs` and
|
|
30
82
|
* `js/inspect/diff.mjs` each had a private const of their own and `js/maintain/uninstall.mjs` would have made
|
package/js/hosts/installs.mjs
CHANGED
|
@@ -32,19 +32,16 @@
|
|
|
32
32
|
* web cells recorded it. Both candidates are gone (the argument is in the Python), and the
|
|
33
33
|
* claim is now true rather than aspirational.
|
|
34
34
|
*/
|
|
35
|
-
import { existsSync, readdirSync
|
|
35
|
+
import { existsSync, readdirSync } from 'node:fs';
|
|
36
36
|
import path from 'node:path';
|
|
37
37
|
|
|
38
38
|
import { CONFIG, PACK_ORDER, THEMES, discoverNames } from '../build/source.mjs';
|
|
39
39
|
import { GLOBAL_MANIFEST, HOSTS, resolvePath } from './hosts.mjs';
|
|
40
40
|
import { registryRoots } from '../inspect/registry.mjs';
|
|
41
|
-
import { printErr, readText } from '../lib/fs.mjs';
|
|
41
|
+
import { printErr, readText, isFile, isDir } from '../lib/fs.mjs';
|
|
42
42
|
import { formatRepr } from '../lib/json.mjs';
|
|
43
43
|
import { comparePaths } from '../lib/paths.mjs';
|
|
44
44
|
|
|
45
|
-
const isDir = (p) => { try { return statSync(p).isDirectory(); } catch { return false; } };
|
|
46
|
-
const isFile = (p) => { try { return statSync(p).isFile(); } catch { return false; } };
|
|
47
|
-
|
|
48
45
|
/**
|
|
49
46
|
* `Path.read_text(encoding="utf-8")`, or null where Python raises OSError.
|
|
50
47
|
*
|
|
@@ -150,6 +147,22 @@ function themeFromAgent(agentMd) {
|
|
|
150
147
|
const CARRIERS = ['AGENT.md', 'CLAUDE.md', path.join('rules', 'geneseed.md'),
|
|
151
148
|
'copilot-instructions.md', 'AGENTS.md'];
|
|
152
149
|
|
|
150
|
+
/**
|
|
151
|
+
* The scan `themeOfDir`, `leadOfDir`, `doctrinesOfDir` and `excludedRulesOfDir` each wrote
|
|
152
|
+
* out: try each carrier in order, first answer wins. `probe(carrierPath)` returns
|
|
153
|
+
* `undefined` to mean "keep looking" and anything else — including `null` or `[]`, both
|
|
154
|
+
* legitimate stop values for the two register readers below — to mean "stop, this is the
|
|
155
|
+
* answer". `fallback` is what every caller's own trailing `return …;` supplied once the
|
|
156
|
+
* whole list was exhausted with no answer.
|
|
157
|
+
*/
|
|
158
|
+
function firstCarrier(d, probe, fallback = null) {
|
|
159
|
+
for (const carrier of CARRIERS) {
|
|
160
|
+
const result = probe(path.join(d, carrier));
|
|
161
|
+
if (result !== undefined) return result;
|
|
162
|
+
}
|
|
163
|
+
return fallback;
|
|
164
|
+
}
|
|
165
|
+
|
|
153
166
|
/** `_harness_setup._theme_of_dir` — the marker, else the sigil in one of five carriers. */
|
|
154
167
|
export function themeOfDir(d) {
|
|
155
168
|
const marker = path.join(d, '.geneseed-theme');
|
|
@@ -157,11 +170,7 @@ export function themeOfDir(d) {
|
|
|
157
170
|
const name = (readMaybe(marker) ?? '').trim();
|
|
158
171
|
if (name) return name;
|
|
159
172
|
}
|
|
160
|
-
|
|
161
|
-
const found = themeFromAgent(path.join(d, carrier));
|
|
162
|
-
if (found) return found;
|
|
163
|
-
}
|
|
164
|
-
return null;
|
|
173
|
+
return firstCarrier(d, (carrierPath) => themeFromAgent(carrierPath) || undefined);
|
|
165
174
|
}
|
|
166
175
|
|
|
167
176
|
/** `_harness_setup.FOOTPRINTS`. */
|
|
@@ -209,14 +218,14 @@ export function capitalize(s) {
|
|
|
209
218
|
}
|
|
210
219
|
|
|
211
220
|
function leadOfDir(d, names) {
|
|
212
|
-
|
|
213
|
-
const text = readMaybe(
|
|
214
|
-
if (text === null)
|
|
221
|
+
return firstCarrier(d, (carrierPath) => {
|
|
222
|
+
const text = readMaybe(carrierPath);
|
|
223
|
+
if (text === null) return undefined;
|
|
215
224
|
for (const name of names) {
|
|
216
225
|
if (text.includes(`**${capitalize(name)}**`)) return name;
|
|
217
226
|
}
|
|
218
|
-
|
|
219
|
-
|
|
227
|
+
return undefined;
|
|
228
|
+
});
|
|
220
229
|
}
|
|
221
230
|
|
|
222
231
|
export const postureOfDir = (d) => leadOfDir(d, discoverNames('postures', 'peer'));
|
|
@@ -257,17 +266,16 @@ const ACTIVE_PACKS_RE = /^Active packs:[ \t]*(.+?)[ \t]*$/m;
|
|
|
257
266
|
* exactly the empty field a fold leaves behind, which is what makes this catch it.
|
|
258
267
|
*/
|
|
259
268
|
export function doctrinesOfDir(d) {
|
|
260
|
-
|
|
261
|
-
const text = readMaybe(
|
|
262
|
-
if (text === null)
|
|
269
|
+
return firstCarrier(d, (carrierPath) => {
|
|
270
|
+
const text = readMaybe(carrierPath);
|
|
271
|
+
if (text === null) return undefined;
|
|
263
272
|
const m = ACTIVE_PACKS_RE.exec(text);
|
|
264
|
-
if (!m)
|
|
273
|
+
if (!m) return undefined;
|
|
265
274
|
if (m[1] === 'none') return [];
|
|
266
275
|
const named = m[1].split(',').map((s) => s.trim());
|
|
267
276
|
if (!named.every((n) => PACK_ORDER.includes(n))) return null;
|
|
268
|
-
return PACK_ORDER.filter((
|
|
269
|
-
}
|
|
270
|
-
return null;
|
|
277
|
+
return PACK_ORDER.filter((pk) => named.includes(pk));
|
|
278
|
+
});
|
|
271
279
|
}
|
|
272
280
|
|
|
273
281
|
/**
|
|
@@ -314,11 +322,11 @@ const EXCLUDED_RULES_RE = /^Excluded rules:[ \t]*(.+?)[ \t]*$/m;
|
|
|
314
322
|
* take away rules nobody named.
|
|
315
323
|
*/
|
|
316
324
|
export function excludedRulesOfDir(d) {
|
|
317
|
-
|
|
318
|
-
const text = readMaybe(
|
|
319
|
-
if (text === null)
|
|
325
|
+
return firstCarrier(d, (carrierPath) => {
|
|
326
|
+
const text = readMaybe(carrierPath);
|
|
327
|
+
if (text === null) return undefined;
|
|
320
328
|
const m = EXCLUDED_RULES_RE.exec(text);
|
|
321
|
-
if (!m)
|
|
329
|
+
if (!m) return undefined;
|
|
322
330
|
if (m[1] === 'none') return [];
|
|
323
331
|
const named = m[1].split(',').map((s) => s.trim());
|
|
324
332
|
// `<pack> <n>` on the page, `<pack>.<n>` in the code — the same asymmetry the rendered
|
|
@@ -329,8 +337,7 @@ export function excludedRulesOfDir(d) {
|
|
|
329
337
|
? `${parts[0]}.${Number(parts[1])}` : null;
|
|
330
338
|
});
|
|
331
339
|
return ids.every(Boolean) ? [...new Set(ids)].sort() : [];
|
|
332
|
-
}
|
|
333
|
-
return [];
|
|
340
|
+
}, []);
|
|
334
341
|
}
|
|
335
342
|
|
|
336
343
|
// ---- what host a deployed dir belongs to (`_harness_mcp`) ---------------------------------
|
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
|
|
@@ -86,7 +86,7 @@ import { toPlatformPath } from '../lib/paths.mjs';
|
|
|
86
86
|
const IS_WIN = process.platform === 'win32';
|
|
87
87
|
|
|
88
88
|
/** `_harness_lifecycle._win_bin_dir`. */
|
|
89
|
-
|
|
89
|
+
function winBinDir() {
|
|
90
90
|
// `os.environ.get(...) or str(Path.home())` — an EMPTY LOCALAPPDATA falls back too,
|
|
91
91
|
// which `??` would not do.
|
|
92
92
|
const base = process.env.LOCALAPPDATA || os.homedir();
|
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
|