geneseed 3.1.0 → 3.1.2
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 +110 -0
- package/DESIGN.md +31 -20
- package/README.md +2 -2
- package/SETUP.md +30 -3
- package/SHIPPED.md +1 -1
- package/bin/{geneseed.mjs → build-driver.mjs} +88 -42
- package/bin/geneseed-cli.mjs +33 -32
- package/bin/geneseed-hook.mjs +7 -7
- package/docs/design-history.md +40 -40
- package/docs/extending.md +113 -85
- package/docs/web/rules.md +2 -0
- package/docs/web/setup-choices.md +13 -1
- package/harness.config.json +1 -1
- package/js/README.md +203 -0
- package/js/build/bundle.mjs +231 -0
- package/js/{catalog.mjs → build/catalog.mjs} +7 -7
- package/js/build/emit-claude.mjs +287 -0
- package/js/build/emit-common.mjs +255 -0
- package/js/build/emit-opencode.mjs +214 -0
- package/js/{generate.mjs → build/generate.mjs} +45 -31
- package/js/{render.mjs → build/render.mjs} +72 -11
- package/js/{checkout.mjs → build/source.mjs} +52 -11
- package/js/build/stubs.mjs +256 -0
- package/js/{themes.mjs → build/themes.mjs} +24 -24
- package/js/build/version.mjs +155 -0
- package/js/{hooks.mjs → hosts/hooks.mjs} +47 -44
- package/js/{hosts.mjs → hosts/hosts.mjs} +30 -28
- package/js/{installs.mjs → hosts/installs.mjs} +68 -16
- package/js/{link.mjs → hosts/link.mjs} +29 -28
- package/js/{mcp.mjs → hosts/mcp.mjs} +10 -9
- package/js/{native.mjs → hosts/native.mjs} +24 -22
- package/js/{opencode.mjs → hosts/opencode.mjs} +12 -7
- package/js/{settings.mjs → hosts/settings.mjs} +94 -69
- package/js/inspect/checks-authoring.mjs +761 -0
- package/js/inspect/checks-build.mjs +231 -0
- package/js/inspect/checks-repo.mjs +308 -0
- package/js/{diff.mjs → inspect/diff.mjs} +34 -31
- package/js/inspect/doctor.mjs +286 -0
- package/js/{excludes.mjs → inspect/excludes.mjs} +30 -33
- package/js/{inventory.mjs → inspect/inventory.mjs} +34 -20
- package/js/{registry.mjs → inspect/registry.mjs} +12 -10
- package/js/inspect/scan.mjs +125 -0
- package/js/{status.mjs → inspect/status.mjs} +27 -25
- package/js/inspect/validate.mjs +199 -0
- package/js/lib/fs.mjs +37 -759
- package/js/lib/json.mjs +344 -0
- package/js/lib/paths.mjs +242 -0
- package/js/lib/proc.mjs +3 -3
- package/js/lib/text.mjs +181 -0
- package/js/lib/udiff.mjs +1 -1
- package/js/{memory.mjs → maintain/memory.mjs} +10 -10
- package/js/{migrate.mjs → maintain/migrate.mjs} +28 -26
- package/js/{setup.mjs → maintain/setup.mjs} +49 -37
- package/js/{uninstall.mjs → maintain/uninstall.mjs} +62 -58
- package/js/{update.mjs → maintain/update.mjs} +51 -51
- package/js/{anim.mjs → ui/anim.mjs} +30 -28
- package/js/{cli.mjs → ui/cli.mjs} +10 -10
- package/js/{menu.mjs → ui/menu.mjs} +10 -9
- package/js/{tui.mjs → ui/tui.mjs} +36 -34
- package/js/web/actions.mjs +124 -89
- package/js/web/activity.mjs +22 -21
- package/js/web/api.mjs +88 -58
- package/js/web/daemon.mjs +319 -0
- package/js/web/docs.mjs +24 -21
- package/js/web/graph.mjs +0 -0
- package/js/web/handler.mjs +442 -0
- package/js/web/jobs.mjs +29 -21
- package/js/web/routes.mjs +223 -0
- package/js/web/server.mjs +56 -987
- package/package.json +11 -3
- package/src/AGENT.md.tmpl +1 -1
- package/src/doctrines/README.md +1 -1
- package/themes/opencode/README.md +1 -1
- package/web/dist/assets/{About-_MnJcwb_.js → About-CPvIFZGh.js} +1 -1
- package/web/dist/assets/{Activity-DpjqO-FR.js → Activity-z0uXdS7J.js} +1 -1
- package/web/dist/assets/{ActivityDetail-Bvrjv8oW.js → ActivityDetail-CEj5IJpF.js} +1 -1
- package/web/dist/assets/{Diff-DZ0IehBV.js → Diff-BMR8fPuG.js} +1 -1
- package/web/dist/assets/{Doctor-cu4krooO.js → Doctor-Bz5qE4r9.js} +1 -1
- package/web/dist/assets/{Graph-DUOPY65j.js → Graph-BV6a6t2l.js} +1 -1
- package/web/dist/assets/{Harnesses-B5Osg-BF.js → Harnesses-C5Ag5PaQ.js} +1 -1
- package/web/dist/assets/Laws-BK07NKw9.js +3 -0
- package/web/dist/assets/{Library-8y69I3_z.js → Library-CiXN9sC3.js} +1 -1
- package/web/dist/assets/{Markdown-Ds5GS1OD.js → Markdown-DG6FjUz-.js} +1 -1
- package/web/dist/assets/{Profile-7Jxa7ZUX.js → Profile-B2oSvdxH.js} +1 -1
- package/web/dist/assets/{Rules-UZ0jWkjg.js → Rules-DobVPd80.js} +1 -1
- package/web/dist/assets/{Skills-Bgkl0xLd.js → Skills-DQPsvsn9.js} +1 -1
- package/web/dist/assets/{StatusBadge-Cx4caF_2.js → StatusBadge-BhJ3HhdI.js} +1 -1
- package/web/dist/assets/{Themes-De8az7Sv.js → Themes-BP38FBU9.js} +1 -1
- package/web/dist/assets/{activity-VDAdVQ3q.js → activity-D9mfv7pY.js} +1 -1
- package/web/dist/assets/index-BE226Gv6.css +1 -0
- package/web/dist/assets/{index-BKrjBRV4.js → index-BZ7cPLMI.js} +1 -1
- package/web/dist/assets/index-BgIs5eIa.js +12 -0
- package/web/dist/assets/{index-wNiNzFFQ.js → index-DGM5j2hB.js} +1 -1
- package/web/dist/index.html +2 -2
- package/web/src/pages/Laws.jsx +135 -69
- package/docs/declined.md +0 -200
- package/docs/limits.md +0 -329
- package/js/doctor.mjs +0 -1712
- package/js/emit.mjs +0 -1410
- package/web/dist/assets/Laws-D8DJgTBp.js +0 -3
- package/web/dist/assets/index-1pPSz7Kl.css +0 -1
- package/web/dist/assets/index-C-gb-q-r.js +0 -12
|
@@ -7,16 +7,20 @@
|
|
|
7
7
|
* last is that it is also the unit the RUNTIME drives: eleven of its names have a consumer
|
|
8
8
|
* outside the emit tree, ten of them in `rituals/_harness_mcp.py` (remerge, deactivate,
|
|
9
9
|
* reactivate, uninstall), two in `rituals/_harness_exclude.py`, one in
|
|
10
|
-
*
|
|
11
|
-
*
|
|
10
|
+
* Every emit and every lifecycle verb goes through this module:
|
|
11
|
+
* `js/build/emit-opencode.mjs` and `js/build/emit-claude.mjs` wire the host config,
|
|
12
|
+
* `js/maintain/uninstall.mjs` and `js/maintain/migrate.mjs` drive the unwire and the shim
|
|
13
|
+
* migration, `js/inspect/checks-repo.mjs` reads `shimDeadPaths` for the doctor, and
|
|
14
|
+
* `js/web/api.mjs` borrows the same readers for the console.
|
|
12
15
|
*
|
|
13
16
|
* THE STDOUT RULE BINDS HARDEST HERE. The hooks this module writes signal their verdict as
|
|
14
17
|
* a JSON object on stdout and return 0 on EVERY path (`|| exit 0` is not what it looks
|
|
15
18
|
* like — see the shim comment below), so a stray byte printed on a hook path does not make
|
|
16
19
|
* noise, it silently disables a gate. Everything printed here is a generator-time message;
|
|
17
|
-
* the
|
|
18
|
-
*
|
|
19
|
-
* to stderr) is
|
|
20
|
+
* the split between them is asserted absolutely by `tests/unit/settings_jsonc.test.mjs` and
|
|
21
|
+
* `tests/unit/settings_integrity.test.mjs`. The asymmetry (`_warn_commented_jsonc` prints to
|
|
22
|
+
* STDOUT, every other message to stderr) is inherited from `_build_settings.py` and kept
|
|
23
|
+
* deliberately rather than tidied.
|
|
20
24
|
*
|
|
21
25
|
* HOOKS ARE APPEND-ONLY AND FAIL-CLOSED. Nothing here rewrites a hook group in place:
|
|
22
26
|
* `mergeClaudeSettings` appends what is missing and removes only groups the manifest
|
|
@@ -38,10 +42,11 @@ import {
|
|
|
38
42
|
} from 'node:fs';
|
|
39
43
|
import path from 'node:path';
|
|
40
44
|
import os from 'node:os';
|
|
45
|
+
import { printErr, readText, writeText } from '../lib/fs.mjs';
|
|
41
46
|
import {
|
|
42
|
-
jsonDumps, jsonDumpsCompact, jsonDumpsIndent, parseJson,
|
|
43
|
-
|
|
44
|
-
} from '
|
|
47
|
+
jsonDumps, jsonDumpsCompact, jsonDumpsIndent, parseJson, deepEquals, formatRepr,
|
|
48
|
+
indexOfDeepEqual,
|
|
49
|
+
} from '../lib/json.mjs';
|
|
45
50
|
|
|
46
51
|
export const OPENCODE_SCHEMA = 'https://opencode.ai/config.json';
|
|
47
52
|
|
|
@@ -49,7 +54,7 @@ export const OPENCODE_SCHEMA = 'https://opencode.ai/config.json';
|
|
|
49
54
|
*
|
|
50
55
|
* `loaded[event]` where `event` came out of a manifest hands back `Object.prototype`'s
|
|
51
56
|
* member for `constructor`, `toString` or `valueOf`, where Python's `.get` returns None.
|
|
52
|
-
* The same hazard `js/native.mjs` hit with `overrides[stem]`, on a dict the USER edits. */
|
|
57
|
+
* The same hazard `js/hosts/native.mjs` hit with `overrides[stem]`, on a dict the USER edits. */
|
|
53
58
|
function get(obj, key) {
|
|
54
59
|
return Object.prototype.hasOwnProperty.call(obj, key) ? obj[key] : undefined;
|
|
55
60
|
}
|
|
@@ -79,6 +84,23 @@ function isDict(v) {
|
|
|
79
84
|
*/
|
|
80
85
|
const processPackOn = (d) => !Array.isArray(d) || d.includes('process');
|
|
81
86
|
|
|
87
|
+
/**
|
|
88
|
+
* The address of the rule the git gate IS. Not a pack any more — a rule.
|
|
89
|
+
*
|
|
90
|
+
* ⚠ THE GATE MOVED FROM THE PACK TO THE RULE, and the reason is that the pack stopped being
|
|
91
|
+
* the unit a user toggles. With per-rule exclusion an install can keep every other `process`
|
|
92
|
+
* rule and drop this one; keying the boundary on `d.includes('process')` would then leave the
|
|
93
|
+
* hook wired for a rule the carrier no longer states — prompt and boundary disagreeing, which
|
|
94
|
+
* is the single thing this design forbids in either direction.
|
|
95
|
+
*
|
|
96
|
+
* `excluded` defaults to `[]` for the same fail-closed reason `excludedRulesOfDir` does: a
|
|
97
|
+
* caller that has not learned about the second axis excludes nothing, so the gate stays. Every
|
|
98
|
+
* pre-existing call site keeps its exact behaviour without being touched.
|
|
99
|
+
*/
|
|
100
|
+
const CONSENT_RULE = 'process.5';
|
|
101
|
+
const consentRuleOn = (d, excluded = []) =>
|
|
102
|
+
processPackOn(d) && !(Array.isArray(excluded) && excluded.includes(CONSENT_RULE));
|
|
103
|
+
|
|
82
104
|
/**
|
|
83
105
|
* `_build_settings._default_permission`.
|
|
84
106
|
*
|
|
@@ -92,9 +114,9 @@ const processPackOn = (d) => !Array.isArray(d) || d.includes('process');
|
|
|
92
114
|
* Law IV's territory — an always-on invariant — not the process pack's, and the guard
|
|
93
115
|
* plugin's own force-push arm is unconditional for the same reason.
|
|
94
116
|
*/
|
|
95
|
-
export function defaultPermission(doctrines = null) {
|
|
117
|
+
export function defaultPermission(doctrines = null, excluded = []) {
|
|
96
118
|
const bash = { 'rm -rf *': 'ask' };
|
|
97
|
-
if (
|
|
119
|
+
if (consentRuleOn(doctrines, excluded)) {
|
|
98
120
|
bash['git commit*'] = 'ask';
|
|
99
121
|
bash['git push*'] = 'ask';
|
|
100
122
|
}
|
|
@@ -146,7 +168,7 @@ const OWNED_BASH = ['rm -rf *', 'git commit*', 'git push*', 'git push --force*',
|
|
|
146
168
|
* FAIL-CLOSED direction — the boundary asks for a confirmation the constitution no longer
|
|
147
169
|
* compels, exactly as the commit skill's own consent step does with the pack off. The unsafe
|
|
148
170
|
* direction, a rule with no gate behind it, cannot occur here. A FRESH file is unaffected:
|
|
149
|
-
* `mergeOpencodeJson` writes `defaultPermission(doctrines)` whole, so a new pack-off install
|
|
171
|
+
* `mergeOpencodeJson` writes `defaultPermission(doctrines, excluded)` whole, so a new pack-off install
|
|
150
172
|
* has no git gate at all.
|
|
151
173
|
*
|
|
152
174
|
* ⚠ KEY ORDER IS NOT RECONCILED, DELIBERATELY. `defaultPermission` emits its keys in a fixed
|
|
@@ -284,7 +306,7 @@ export function readJsonc(text) {
|
|
|
284
306
|
* `'add'`, which is what the boolean callers this replaced meant.
|
|
285
307
|
*/
|
|
286
308
|
export function warnCommentedJsonc(target, agentPath, permission,
|
|
287
|
-
includeLsp = false, prefix = 'geneseed', doctrines = null) {
|
|
309
|
+
includeLsp = false, prefix = 'geneseed', doctrines = null, excluded = []) {
|
|
288
310
|
process.stdout.write(`[${prefix}] ${path.basename(target)} has comments — not rewriting `
|
|
289
311
|
+ 'it (your edits are kept). Add this to its "instructions" array by hand:\n');
|
|
290
312
|
process.stdout.write(`[${prefix}] ${jsonDumps(agentPath)}\n`);
|
|
@@ -293,7 +315,7 @@ export function warnCommentedJsonc(target, agentPath, permission,
|
|
|
293
315
|
? `[${prefix}] and its "permission".bash is missing gates this build wires — `
|
|
294
316
|
+ 'reconcile it by hand against:\n'
|
|
295
317
|
: `[${prefix}] and, for Geneseed's default ask-gates, a "permission" key:\n`);
|
|
296
|
-
for (const line of jsonDumpsIndent(defaultPermission(doctrines)).split('\n')) {
|
|
318
|
+
for (const line of jsonDumpsIndent(defaultPermission(doctrines, excluded)).split('\n')) {
|
|
297
319
|
process.stdout.write(`[${prefix}] ${line}\n`);
|
|
298
320
|
}
|
|
299
321
|
}
|
|
@@ -322,7 +344,7 @@ export function atomicWriteJson(p, config) {
|
|
|
322
344
|
* re-implements the inverse itself rather than inherit this one's permission/lsp side
|
|
323
345
|
* effects, so the only crossings are the two emit-tree ones (`_build_emit.emit_opencode`,
|
|
324
346
|
* `_build_global.emit_opencode_global`). */
|
|
325
|
-
export function mergeOpencodeJson(p, agentPath, doctrines = null) {
|
|
347
|
+
export function mergeOpencodeJson(p, agentPath, doctrines = null, excluded = []) {
|
|
326
348
|
const target = opencodeTarget(p);
|
|
327
349
|
let config = { $schema: OPENCODE_SCHEMA, instructions: [] };
|
|
328
350
|
let hadComments = false;
|
|
@@ -332,7 +354,7 @@ export function mergeOpencodeJson(p, agentPath, doctrines = null) {
|
|
|
332
354
|
raw = readText(target);
|
|
333
355
|
} catch (e) {
|
|
334
356
|
if (!isOsError(e)) throw e;
|
|
335
|
-
process.stderr.write(`[geneseed] WARN: could not read ${target} (${
|
|
357
|
+
process.stderr.write(`[geneseed] WARN: could not read ${target} (${asOsError(e)}) `
|
|
336
358
|
+ `— NOT touching it. Add ${jsonDumps(agentPath)} to its "instructions" array by `
|
|
337
359
|
+ "hand once it's readable again.\n");
|
|
338
360
|
return target;
|
|
@@ -350,7 +372,7 @@ export function mergeOpencodeJson(p, agentPath, doctrines = null) {
|
|
|
350
372
|
if (!has(config, '$schema')) config.$schema = OPENCODE_SCHEMA;
|
|
351
373
|
let instr = get(config, 'instructions');
|
|
352
374
|
if (!Array.isArray(instr)) instr = [];
|
|
353
|
-
const addInstr =
|
|
375
|
+
const addInstr = indexOfDeepEqual(instr, agentPath) < 0;
|
|
354
376
|
if (addInstr) instr.push(agentPath);
|
|
355
377
|
config.instructions = instr;
|
|
356
378
|
// Absent ⇒ write the whole block; present ⇒ reconcile Geneseed's own git keys. Adding is
|
|
@@ -361,10 +383,10 @@ export function mergeOpencodeJson(p, agentPath, doctrines = null) {
|
|
|
361
383
|
let permChanged = addPerm;
|
|
362
384
|
let stranded = [];
|
|
363
385
|
let opaque = null;
|
|
364
|
-
if (addPerm) config.permission = defaultPermission(doctrines);
|
|
386
|
+
if (addPerm) config.permission = defaultPermission(doctrines, excluded);
|
|
365
387
|
else {
|
|
366
388
|
[permChanged, stranded, opaque] = reconcileOpencodePermission(get(config, 'permission'),
|
|
367
|
-
defaultPermission(doctrines));
|
|
389
|
+
defaultPermission(doctrines, excluded));
|
|
368
390
|
}
|
|
369
391
|
// The shape said no. This is the direction the residue warning above is NOT — a rule stated
|
|
370
392
|
// in AGENT.md with nothing behind it — and it used to pass in silence, because a blanket
|
|
@@ -374,7 +396,7 @@ export function mergeOpencodeJson(p, agentPath, doctrines = null) {
|
|
|
374
396
|
if (opaque) {
|
|
375
397
|
process.stderr.write(`[geneseed] WARN: "permission" in ${path.basename(target)} `
|
|
376
398
|
+ `${opaque}, so Geneseed cannot wire its own gates into it — including `
|
|
377
|
-
+ `${OWNED_BASH.filter((k) => has(defaultPermission(doctrines).bash, k))
|
|
399
|
+
+ `${OWNED_BASH.filter((k) => has(defaultPermission(doctrines, excluded).bash, k))
|
|
378
400
|
.map((k) => jsonDumps(k)).join(', ')}. Your policy is left exactly as written; the `
|
|
379
401
|
+ 'harness states those rules but nothing enforces them at this boundary.\n');
|
|
380
402
|
}
|
|
@@ -404,14 +426,14 @@ export function mergeOpencodeJson(p, agentPath, doctrines = null) {
|
|
|
404
426
|
// block to paste, split by which of the two situations the reader is actually in. It is
|
|
405
427
|
// stdout, not stderr, because on this path the paste-in IS the output.
|
|
406
428
|
warnCommentedJsonc(target, agentPath, addPerm ? 'add' : (permChanged && 'reconcile'),
|
|
407
|
-
addLsp, 'geneseed', doctrines);
|
|
429
|
+
addLsp, 'geneseed', doctrines, excluded);
|
|
408
430
|
return target;
|
|
409
431
|
}
|
|
410
432
|
try {
|
|
411
433
|
atomicWriteJson(target, config);
|
|
412
434
|
} catch (e) {
|
|
413
435
|
if (!isOsError(e)) throw e;
|
|
414
|
-
process.stderr.write(`[geneseed] WARN: could not write ${target} (${
|
|
436
|
+
process.stderr.write(`[geneseed] WARN: could not write ${target} (${asOsError(e)}) — `
|
|
415
437
|
+ `the harness will NOT auto-load until this is fixed. Add ${jsonDumps(agentPath)} `
|
|
416
438
|
+ 'to its "instructions" array by hand.\n');
|
|
417
439
|
}
|
|
@@ -427,7 +449,7 @@ export function mergeOpencodeJson(p, agentPath, doctrines = null) {
|
|
|
427
449
|
* with an injectable failure instead and compares everything except the OS's own wording.
|
|
428
450
|
* Named here so the gap is a decision rather than a surprise at GA.
|
|
429
451
|
*/
|
|
430
|
-
function
|
|
452
|
+
function asOsError(e) {
|
|
431
453
|
return e.message;
|
|
432
454
|
}
|
|
433
455
|
|
|
@@ -458,7 +480,7 @@ export const SHIM_ARGV = new Set(['$@', '%*']);
|
|
|
458
480
|
* Every quoted token in a shim body that names a path which is not there — empty is healthy.
|
|
459
481
|
*
|
|
460
482
|
* ONE OWNER, because the rule has two consumers now and a second copy is a second place for the
|
|
461
|
-
* `"$@"` defect above to come back: `js/
|
|
483
|
+
* `"$@"` defect above to come back: `js/inspect/checks-repo.mjs` turns this into the `[shim] … does not
|
|
462
484
|
* exist` report, and `hookPrefix` reads it to decide whether the shim already on disk is worth
|
|
463
485
|
* protecting from a checkout that will not outlive the emit.
|
|
464
486
|
*/
|
|
@@ -484,7 +506,7 @@ export function shimHome() {
|
|
|
484
506
|
}
|
|
485
507
|
|
|
486
508
|
/**
|
|
487
|
-
* `Path(x).expanduser()` — private twin of `js/hosts.mjs`'s `expanduser`, same guard.
|
|
509
|
+
* `Path(x).expanduser()` — private twin of `js/hosts/hosts.mjs`'s `expanduser`, same guard.
|
|
488
510
|
*
|
|
489
511
|
* `GENESEED_HOME` is user-set, same as any config-dir env var there, so a `~user` value here
|
|
490
512
|
* has the identical failure mode (a hook shim written under a literal `~user` directory) and
|
|
@@ -497,7 +519,7 @@ function expanduser(p) {
|
|
|
497
519
|
if (p.startsWith('~')) {
|
|
498
520
|
const msg = `refusing '${p}': a '~user' path is not expanded by this port; pass an `
|
|
499
521
|
+ 'absolute path, or "~" / "~/…" for your own home directory';
|
|
500
|
-
|
|
522
|
+
printErr(`${msg}\n`);
|
|
501
523
|
const e = new Error(msg);
|
|
502
524
|
e.exitCode = 1;
|
|
503
525
|
throw e;
|
|
@@ -519,12 +541,12 @@ export function hookShimPath(platform = process.platform) {
|
|
|
519
541
|
* refuses to compare the emitted shim for that reason (`_SHIM_GLOB`) and asserts
|
|
520
542
|
* `_shim_health` instead.
|
|
521
543
|
*
|
|
522
|
-
* So the
|
|
523
|
-
*
|
|
524
|
-
*
|
|
525
|
-
* off`, the CRLF, `exit /b` vs `exec`, the
|
|
526
|
-
*
|
|
527
|
-
*
|
|
544
|
+
* So the answer here is NOT "skip it". Taking `runner`, `entry` and `platform` as arguments
|
|
545
|
+
* makes the BODY a pure function of three inputs, which is what lets
|
|
546
|
+
* `tests/unit/hook_form.test.mjs` assert BOTH platform shapes absolutely in one run on
|
|
547
|
+
* whichever host it happens to be on: the `@echo off`, the CRLF, `exit /b` vs `exec`, the
|
|
548
|
+
* quoting that keeps `--root "<cfg>"` intact. What it does not prove is WHICH values the
|
|
549
|
+
* driver passes — that is `bin/build-driver.mjs`'s `hookRunnerEntry()`, gated separately.
|
|
528
550
|
*/
|
|
529
551
|
export function hookShimBody(runner, entry, platform = process.platform) {
|
|
530
552
|
if (platform === 'win32') {
|
|
@@ -698,7 +720,7 @@ export function hookPrefix({ runner, entry, platform = process.platform } = {})
|
|
|
698
720
|
* belongs in — the user's call — not how work is run, so it is not the process pack's to
|
|
699
721
|
* remove. `default` here means unknown, and unknown keeps the gate (see `processPackOn`).
|
|
700
722
|
*/
|
|
701
|
-
export function claudeHookGroups(cfg, hookOpts, doctrines = null) {
|
|
723
|
+
export function claudeHookGroups(cfg, hookOpts, doctrines = null, excluded = []) {
|
|
702
724
|
const run = hookPrefix(hookOpts);
|
|
703
725
|
const mem = `--memory "${path.join(cfg, 'memory')}"`;
|
|
704
726
|
// --root carries the install's own dir so a GLOBAL hook can stand down when a project
|
|
@@ -709,7 +731,7 @@ export function claudeHookGroups(cfg, hookOpts, doctrines = null) {
|
|
|
709
731
|
const learn = `${run} learn ${mem} || exit 0`;
|
|
710
732
|
return {
|
|
711
733
|
PreToolUse: [
|
|
712
|
-
...(
|
|
734
|
+
...(consentRuleOn(doctrines, excluded)
|
|
713
735
|
? [{ matcher: 'Bash', hooks: [{ type: 'command', command: gate }] }] : []),
|
|
714
736
|
{
|
|
715
737
|
matcher: 'Write|Edit|MultiEdit|NotebookEdit',
|
|
@@ -740,8 +762,8 @@ export function claudeHookGroups(cfg, hookOpts, doctrines = null) {
|
|
|
740
762
|
*
|
|
741
763
|
* `scope` is accepted and unused, exactly as in the Python.
|
|
742
764
|
*/
|
|
743
|
-
export function mergeClaudeSettings(p,
|
|
744
|
-
doctrines = null) {
|
|
765
|
+
export function mergeClaudeSettings(p, _scope = 'global', priorHooks = null, hookOpts = {},
|
|
766
|
+
doctrines = null, excluded = []) {
|
|
745
767
|
const prior = (priorHooks || []).filter(isDict);
|
|
746
768
|
let config = {};
|
|
747
769
|
let hadComments = false;
|
|
@@ -761,19 +783,19 @@ export function mergeClaudeSettings(p, scope = 'global', priorHooks = null, hook
|
|
|
761
783
|
}
|
|
762
784
|
let hooks = get(config, 'hooks');
|
|
763
785
|
if (!isDict(hooks)) hooks = {};
|
|
764
|
-
const canonical = claudeHookGroups(path.dirname(p), hookOpts, doctrines);
|
|
786
|
+
const canonical = claudeHookGroups(path.dirname(p), hookOpts, doctrines, excluded);
|
|
765
787
|
const canonFlat = [];
|
|
766
788
|
for (const [event, groups] of Object.entries(canonical)) {
|
|
767
789
|
for (const g of groups) canonFlat.push({ event, group: g });
|
|
768
790
|
}
|
|
769
791
|
let pruned = false;
|
|
770
792
|
for (const rec of prior) {
|
|
771
|
-
if (
|
|
793
|
+
if (indexOfDeepEqual(canonFlat, rec) >= 0) continue;
|
|
772
794
|
const event = get(rec, 'event');
|
|
773
795
|
const group = get(rec, 'group');
|
|
774
796
|
const arr = get(hooks, event);
|
|
775
797
|
if (Array.isArray(arr)) {
|
|
776
|
-
const at =
|
|
798
|
+
const at = indexOfDeepEqual(arr, group);
|
|
777
799
|
if (at >= 0) {
|
|
778
800
|
arr.splice(at, 1);
|
|
779
801
|
pruned = true;
|
|
@@ -786,14 +808,14 @@ export function mergeClaudeSettings(p, scope = 'global', priorHooks = null, hook
|
|
|
786
808
|
let arr = get(hooks, event);
|
|
787
809
|
if (!Array.isArray(arr)) arr = [];
|
|
788
810
|
for (const g of newGroups) {
|
|
789
|
-
if (
|
|
811
|
+
if (indexOfDeepEqual(arr, g) >= 0) continue;
|
|
790
812
|
arr.push(g);
|
|
791
813
|
added.push({ event, group: g });
|
|
792
814
|
}
|
|
793
815
|
hooks[event] = arr;
|
|
794
816
|
}
|
|
795
|
-
const survivors = prior.filter((r) =>
|
|
796
|
-
const managedNow = survivors.concat(added.filter((a) =>
|
|
817
|
+
const survivors = prior.filter((r) => indexOfDeepEqual(canonFlat, r) >= 0);
|
|
818
|
+
const managedNow = survivors.concat(added.filter((a) => indexOfDeepEqual(survivors, a) < 0));
|
|
797
819
|
if (!added.length && !pruned) return [p, managedNow];
|
|
798
820
|
if (hadComments) {
|
|
799
821
|
process.stderr.write(`[geneseed] ${path.basename(p)} has comments — not rewriting it `
|
|
@@ -805,8 +827,10 @@ export function mergeClaudeSettings(p, scope = 'global', priorHooks = null, hook
|
|
|
805
827
|
// carries four events, so by the time control reaches here `hooks` is never empty. A
|
|
806
828
|
// mutation keeping the empty block instead of deleting it is the one of thirty-three that
|
|
807
829
|
// stays green, and it stays green because there is nothing to detect — recorded the way
|
|
808
|
-
// `themed_rel`
|
|
809
|
-
//
|
|
830
|
+
// `themed_rel` is, so "the gate cannot see it" and "there is nothing to see" keep their
|
|
831
|
+
// distance. NOT the way `lstripNewlines` is: that one looked unobservable for two phases
|
|
832
|
+
// and turned out to be mutation M7, killed by `golden.mjs --idempotent` through
|
|
833
|
+
// `managedBlockWrite`'s `updated` branch, which only a RE-emit reaches.
|
|
810
834
|
if (Object.keys(hooks).length) config.hooks = hooks;
|
|
811
835
|
else delete config.hooks;
|
|
812
836
|
mkdirSync(path.dirname(p), { recursive: true });
|
|
@@ -838,7 +862,7 @@ export function unwireClaudeSettings(p, added) {
|
|
|
838
862
|
const group = get(rec, 'group');
|
|
839
863
|
let arr = get(hooks, event);
|
|
840
864
|
if (Array.isArray(arr)) {
|
|
841
|
-
const at =
|
|
865
|
+
const at = indexOfDeepEqual(arr, group);
|
|
842
866
|
if (at >= 0) arr.splice(at, 1);
|
|
843
867
|
}
|
|
844
868
|
arr = get(hooks, event);
|
|
@@ -879,7 +903,7 @@ export function wireClaudeExcludes(p, excludes) {
|
|
|
879
903
|
}
|
|
880
904
|
let cur = get(config, 'claudeMdExcludes');
|
|
881
905
|
if (!Array.isArray(cur)) cur = [];
|
|
882
|
-
const added = want.filter((e) =>
|
|
906
|
+
const added = want.filter((e) => indexOfDeepEqual(cur, e) < 0);
|
|
883
907
|
if (!added.length) return [];
|
|
884
908
|
if (hadComments) {
|
|
885
909
|
process.stderr.write(`[geneseed] ${path.basename(p)} has comments — not rewriting it `
|
|
@@ -894,7 +918,7 @@ export function wireClaudeExcludes(p, excludes) {
|
|
|
894
918
|
atomicWriteJson(p, config);
|
|
895
919
|
} catch (e) {
|
|
896
920
|
if (!isOsError(e)) throw e;
|
|
897
|
-
process.stderr.write(`[geneseed] WARN: could not write ${p} (${
|
|
921
|
+
process.stderr.write(`[geneseed] WARN: could not write ${p} (${asOsError(e)}) — `
|
|
898
922
|
+ 'claudeMdExcludes were not wired. Add to its "claudeMdExcludes" array by hand: '
|
|
899
923
|
+ `${jsonDumpsCompact(added)}\n`);
|
|
900
924
|
return [];
|
|
@@ -917,7 +941,7 @@ export function unwireClaudeExcludes(p, excludes) {
|
|
|
917
941
|
const cur = get(loaded, 'claudeMdExcludes');
|
|
918
942
|
if (!Array.isArray(cur)) return;
|
|
919
943
|
for (const e of excludes) {
|
|
920
|
-
const at =
|
|
944
|
+
const at = indexOfDeepEqual(cur, e);
|
|
921
945
|
if (at >= 0) cur.splice(at, 1);
|
|
922
946
|
}
|
|
923
947
|
if (cur.length) loaded.claudeMdExcludes = cur;
|
|
@@ -954,8 +978,9 @@ export const SHIM_ENTRY_MARK = 'geneseed-hook.mjs';
|
|
|
954
978
|
/**
|
|
955
979
|
* `_build_settings._migrate_shape` — 'legacy' | 'current' | 'none'.
|
|
956
980
|
*
|
|
957
|
-
* PURE
|
|
958
|
-
*
|
|
981
|
+
* PURE — and currently reached only END TO END, by the `migrate/a-node-baked-shim-reads-as-current`
|
|
982
|
+
* cell. No unit corpus drives it directly, which is worth knowing before trusting it.
|
|
983
|
+
* 'none' is not a fault: a Copilot install wires no hooks, and a user's own
|
|
959
984
|
* settings.json may carry three hand-written hooks and no Geneseed entry. Both must read as
|
|
960
985
|
* "nothing to migrate" rather than "unrecognised", or `migrate` refuses on the commonest
|
|
961
986
|
* config on any machine.
|
|
@@ -1043,7 +1068,7 @@ export function settingsIntegrityCheck(p, managed, expect = 'present') {
|
|
|
1043
1068
|
[loaded] = readJsonc(readText(p));
|
|
1044
1069
|
} catch (e) {
|
|
1045
1070
|
if (!isOsError(e)) throw e;
|
|
1046
|
-
problems.push(`${p}: could not read the file (${
|
|
1071
|
+
problems.push(`${p}: could not read the file (${asOsError(e)})`);
|
|
1047
1072
|
return flush();
|
|
1048
1073
|
}
|
|
1049
1074
|
if (!isDict(loaded)) {
|
|
@@ -1060,25 +1085,25 @@ export function settingsIntegrityCheck(p, managed, expect = 'present') {
|
|
|
1060
1085
|
for (const rec of recordedHooks) {
|
|
1061
1086
|
const event = get(rec, 'event');
|
|
1062
1087
|
const group = get(rec, 'group');
|
|
1063
|
-
const hit = presentGroups.some(([e, g]) =>
|
|
1088
|
+
const hit = presentGroups.some(([e, g]) => deepEquals(e, event) && deepEquals(g, group));
|
|
1064
1089
|
if (expect === 'present' && !hit) {
|
|
1065
|
-
problems.push(`${p}: recorded hook group missing — event=${
|
|
1090
|
+
problems.push(`${p}: recorded hook group missing — event=${formatRepr(event ?? null)} `
|
|
1066
1091
|
+ `group=${jsonDumpsCompact(group ?? null)}`);
|
|
1067
1092
|
} else if (expect === 'absent' && hit) {
|
|
1068
1093
|
problems.push(`${p}: recorded hook group still present after unwire — `
|
|
1069
|
-
+ `event=${
|
|
1094
|
+
+ `event=${formatRepr(event ?? null)} group=${jsonDumpsCompact(group ?? null)}`);
|
|
1070
1095
|
}
|
|
1071
1096
|
}
|
|
1072
1097
|
|
|
1073
1098
|
let exclCur = get(loaded, 'claudeMdExcludes');
|
|
1074
1099
|
exclCur = Array.isArray(exclCur) ? exclCur : [];
|
|
1075
1100
|
for (const entry of (get(m, 'settings_excludes') || [])) {
|
|
1076
|
-
const hit =
|
|
1101
|
+
const hit = indexOfDeepEqual(exclCur, entry) >= 0;
|
|
1077
1102
|
if (expect === 'present' && !hit) {
|
|
1078
|
-
problems.push(`${p}: recorded claudeMdExcludes entry missing: ${
|
|
1103
|
+
problems.push(`${p}: recorded claudeMdExcludes entry missing: ${formatRepr(entry)}`);
|
|
1079
1104
|
} else if (expect === 'absent' && hit) {
|
|
1080
1105
|
problems.push(`${p}: recorded claudeMdExcludes entry still present after unwire: `
|
|
1081
|
-
+ `${
|
|
1106
|
+
+ `${formatRepr(entry)}`);
|
|
1082
1107
|
}
|
|
1083
1108
|
}
|
|
1084
1109
|
|
|
@@ -1086,17 +1111,17 @@ export function settingsIntegrityCheck(p, managed, expect = 'present') {
|
|
|
1086
1111
|
// auto-delete (they may be user-authored, or a claim this run legitimately did not
|
|
1087
1112
|
// inherit).
|
|
1088
1113
|
const recordedSet = new Set(recordedHooks.map(
|
|
1089
|
-
(r) => `${
|
|
1114
|
+
(r) => `${formatRepr(get(r, 'event') ?? null)}\u0000`
|
|
1090
1115
|
+ `${jsonDumpsCompact(get(r, 'group') ?? null, { sortKeys: true })}`));
|
|
1091
1116
|
for (const [event, group] of presentGroups) {
|
|
1092
|
-
const key = `${
|
|
1117
|
+
const key = `${formatRepr(event)}\u0000${jsonDumpsCompact(group, { sortKeys: true })}`;
|
|
1093
1118
|
if (recordedSet.has(key)) continue;
|
|
1094
1119
|
const cmds = (get(group, 'hooks') || []).filter(isDict)
|
|
1095
1120
|
.map((h) => (has(h, 'command') ? h.command : ''));
|
|
1096
1121
|
if (cmds.some((c) => typeof c === 'string'
|
|
1097
1122
|
&& GENESEED_HOOK_SNIFF.some((mk) => c.includes(mk)))) {
|
|
1098
1123
|
problems.push(`${p}: Geneseed-pattern hook present but NOT recorded in the manifest `
|
|
1099
|
-
+ `(event=${
|
|
1124
|
+
+ `(event=${formatRepr(event)}) — possibly user-authored; left alone`);
|
|
1100
1125
|
}
|
|
1101
1126
|
}
|
|
1102
1127
|
|
|
@@ -1116,7 +1141,7 @@ function delimiters(blockId) {
|
|
|
1116
1141
|
*/
|
|
1117
1142
|
export function managedBlockWrite(p, content, blockId = 'GENESEED') {
|
|
1118
1143
|
const [begin, end] = delimiters(blockId);
|
|
1119
|
-
const block = `${begin}\n${
|
|
1144
|
+
const block = `${begin}\n${stripEnd(content)}\n${end}\n`;
|
|
1120
1145
|
if (!existsSync(p)) {
|
|
1121
1146
|
mkdirSync(path.dirname(p), { recursive: true });
|
|
1122
1147
|
writeText(p, block);
|
|
@@ -1146,7 +1171,7 @@ export function managedBlockRemove(p, blockId = 'GENESEED', whole = false) {
|
|
|
1146
1171
|
if (!existing.includes(begin) || !existing.includes(end)) return;
|
|
1147
1172
|
const pre = existing.split(begin)[0];
|
|
1148
1173
|
const post = existing.slice(existing.indexOf(end) + end.length);
|
|
1149
|
-
const rest =
|
|
1174
|
+
const rest = stripEnds(`${stripTrailingNewlines(pre)}\n${lstripNewlines(post)}`);
|
|
1150
1175
|
if (rest) writeText(p, `${rest}\n`);
|
|
1151
1176
|
else unlinkSync(p);
|
|
1152
1177
|
}
|
|
@@ -1166,17 +1191,17 @@ export function managedBlockRead(p, blockId = 'GENESEED') {
|
|
|
1166
1191
|
// U+001C–U+001F, which `trim()` leaves. A managed block is user-editable prose in a file
|
|
1167
1192
|
// Geneseed shares with the user, so the set is spelled out rather than approximated — and
|
|
1168
1193
|
// spelled with escapes, never literals: a raw U+2028 in a JS source file ends the line.
|
|
1169
|
-
const
|
|
1194
|
+
const WHITESPACE = '[\t\n\v\f\r\x1c-\x1f \x85\xa0\u1680\u2000-\u200a'
|
|
1170
1195
|
+ '\u2028\u2029\u202f\u205f\u3000]';
|
|
1171
1196
|
|
|
1172
|
-
const RSTRIP_RE = new RegExp(`${
|
|
1173
|
-
const LSTRIP_RE = new RegExp(`^${
|
|
1197
|
+
const RSTRIP_RE = new RegExp(`${WHITESPACE}+$`);
|
|
1198
|
+
const LSTRIP_RE = new RegExp(`^${WHITESPACE}+`);
|
|
1174
1199
|
|
|
1175
|
-
function
|
|
1200
|
+
function stripEnd(s) {
|
|
1176
1201
|
return s.replace(RSTRIP_RE, '');
|
|
1177
1202
|
}
|
|
1178
1203
|
|
|
1179
|
-
function
|
|
1204
|
+
function stripEnds(s) {
|
|
1180
1205
|
return s.replace(LSTRIP_RE, '').replace(RSTRIP_RE, '');
|
|
1181
1206
|
}
|
|
1182
1207
|
|
|
@@ -1184,10 +1209,10 @@ function lstripNewlines(s) {
|
|
|
1184
1209
|
return s.replace(/^\n+/, '');
|
|
1185
1210
|
}
|
|
1186
1211
|
|
|
1187
|
-
function
|
|
1212
|
+
function stripTrailingNewlines(s) {
|
|
1188
1213
|
return s.replace(/\n+$/, '');
|
|
1189
1214
|
}
|
|
1190
1215
|
|
|
1191
1216
|
function stripNewlines(s) {
|
|
1192
|
-
return lstripNewlines(
|
|
1217
|
+
return lstripNewlines(stripTrailingNewlines(s));
|
|
1193
1218
|
}
|