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
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* These are the commands the emitted `settings.json` actually invokes on a user's machine,
|
|
5
5
|
* every session: a SessionStart injector, two PreToolUse gates, and a Stop/SubagentStop
|
|
6
|
-
* distiller. Since P5b they are also what the emitted hooks name: `bin/
|
|
6
|
+
* distiller. Since P5b they are also what the emitted hooks name: `bin/build-driver.mjs` bakes
|
|
7
7
|
* `<node> <checkout>/bin/geneseed-hook.mjs` into the machine-wide shim, so an install this
|
|
8
8
|
* driver emits has no Python in its hook path at all — which is what let the interpreter
|
|
9
9
|
* discovery and its exit-4 refusal be deleted rather than merely bypassed.
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* ~11,600 lines and the generator behind them. This module loads none of that, and that is
|
|
15
15
|
* deliberate rather than incidental: the Python originals already refuse to import `build`
|
|
16
16
|
* for the marker filenames they need ("literals keep the hook dependency-free"), and
|
|
17
|
-
* `bin/
|
|
17
|
+
* `bin/build-driver.mjs` could not be imported anyway, since its last statement runs the
|
|
18
18
|
* generator. What that rule does NOT license is a second copy of a shared resolver under a
|
|
19
19
|
* different name — see the primitives section below, where P5d found one that had silently
|
|
20
20
|
* diverged from its twin.
|
|
@@ -26,24 +26,27 @@
|
|
|
26
26
|
* failure has to be diagnosable, and its hook command carries `|| exit 0` for exactly that
|
|
27
27
|
* reason.
|
|
28
28
|
*
|
|
29
|
-
* The gate is `tests/
|
|
30
|
-
* seeded sandbox
|
|
29
|
+
* The gate is `tests/helpers/cli_golden.mjs` replaying `tests/helpers/matrix/cli.{posix,win32}.json`:
|
|
30
|
+
* each verb run in a seeded sandbox, with ABSOLUTE assertions on stdout, stderr, the exit code
|
|
31
|
+
* and every file left behind. Absolute rather than compared is what let it survive the deletion
|
|
32
|
+
* of the implementation it was once compared against.
|
|
31
33
|
*/
|
|
32
34
|
import { existsSync, statSync, readFileSync, readdirSync, mkdirSync, realpathSync }
|
|
33
35
|
from 'node:fs';
|
|
34
36
|
import path from 'node:path';
|
|
35
37
|
import { spawnSync } from 'node:child_process';
|
|
36
38
|
import { fileURLToPath } from 'node:url';
|
|
37
|
-
import { readText, writeText,
|
|
38
|
-
|
|
39
|
-
import {
|
|
39
|
+
import { readText, writeText, printOut, printErr, withDiscardableStderr } from '../lib/fs.mjs';
|
|
40
|
+
import { jsonDumpsCompact } from '../lib/json.mjs';
|
|
41
|
+
import { normcase, comparePaths, toPlatformPath } from '../lib/paths.mjs';
|
|
42
|
+
import { NO_WINDOW } from '../lib/proc.mjs';
|
|
40
43
|
// `harness status` reports the memory store, and `bin/geneseed-cli.mjs` cannot import THIS
|
|
41
44
|
// module to find it: the CLI is under a hard transitive `child_process` ban and `learn`
|
|
42
|
-
// spawns. So the resolver has one owner in `js/hosts.mjs` rather than a second copy — and
|
|
45
|
+
// spawns. So the resolver has one owner in `js/hosts/hosts.mjs` rather than a second copy — and
|
|
43
46
|
// with it went this file's private `opencodeConfigDir`, whose only caller it was.
|
|
44
47
|
import { resolveMemoryDir, expanduser } from './hosts.mjs';
|
|
45
48
|
|
|
46
|
-
const ROOT = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
|
|
49
|
+
const ROOT = path.dirname(path.dirname(path.dirname(fileURLToPath(import.meta.url))));
|
|
47
50
|
|
|
48
51
|
// ---- Python path and text primitives -------------------------------------------------
|
|
49
52
|
// Small, and each one is here because a JS-idiomatic equivalent differs observably.
|
|
@@ -52,8 +55,8 @@ const ROOT = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
|
|
|
52
55
|
// coupling a hook to the emitter", with `readText`/`writeText`/`normcase` as the stated
|
|
53
56
|
// exceptions. That is the wrong line, and `pyStrPath` is what proved it: it was a second
|
|
54
57
|
// implementation of `str(Path(p))` under a different NAME from `js/lib/fs.mjs`'s
|
|
55
|
-
// `
|
|
56
|
-
// where `PurePath` keeps it, fixed `
|
|
58
|
+
// `toPlatformPath`, and the two disagreed. P5c found that `path.normalize` collapses `a/../b`
|
|
59
|
+
// where `PurePath` keeps it, fixed `toPlatformPath`, and gated it with a 25-path corpus — and
|
|
57
60
|
// none of that reached this file, because a corpus finds what it is pointed at and nothing
|
|
58
61
|
// named `pyStrPath` was. The consequence was live: `sovereignBypass` compares an
|
|
59
62
|
// `excludes.json` entry against cwd, so a hand-edited `..` entry made the NODE hook stand
|
|
@@ -63,16 +66,16 @@ const ROOT = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
|
|
|
63
66
|
// So the line is not "duplicate rather than couple" — it is "one owner for anything that
|
|
64
67
|
// reproduces a language primitive, wherever it is used". `fs.mjs` carries no
|
|
65
68
|
// `child_process` and costs nothing at hook latency. What stays local is what is genuinely
|
|
66
|
-
// only the hook's (`fnmatch`, the transcript readers, `
|
|
69
|
+
// only the hook's (`fnmatch`, the transcript readers, `asOsError`'s errno table).
|
|
67
70
|
//
|
|
68
71
|
// AND `expanduser` WAS THE SECOND VIOLATION, closed here. This file carried its own, which
|
|
69
|
-
// returned a `~user` path UNCHANGED while `js/hosts.mjs`'s single owner REFUSES the form —
|
|
72
|
+
// returned a `~user` path UNCHANGED while `js/hosts/hosts.mjs`'s single owner REFUSES the form —
|
|
70
73
|
// an adjudicated product decision (`TheTildeUserFormIsADeliberateDivergence`) that the hook
|
|
71
74
|
// was quietly exempt from because it had a private copy. It now imports the owner. The
|
|
72
75
|
// refusal throws, so `sovereignBypass` and `cmdContext` below contain it per entry / per
|
|
73
76
|
// call: a hook that CRASHES reports success, because these gates exit 0 and signal on
|
|
74
77
|
// stdout, so "the gate blew up" and "the gate found nothing" are the same observation to
|
|
75
|
-
// Claude. `
|
|
78
|
+
// Claude. `resolvePath` just below is the THIRD copy of a primitive `js/hosts/hosts.mjs` also
|
|
76
79
|
// exports, and is the next item in this series.
|
|
77
80
|
|
|
78
81
|
/**
|
|
@@ -82,7 +85,7 @@ const ROOT = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
|
|
|
82
85
|
* `excludes.json` may name a folder that was deleted), so the existing prefix is
|
|
83
86
|
* canonicalised and the rest appended verbatim — `resolve(strict=False)`'s behaviour.
|
|
84
87
|
*/
|
|
85
|
-
function
|
|
88
|
+
function resolvePath(p) {
|
|
86
89
|
let cur = path.resolve(expanduser(p));
|
|
87
90
|
const tail = [];
|
|
88
91
|
for (;;) {
|
|
@@ -121,7 +124,7 @@ function selfAndParents(p) {
|
|
|
121
124
|
* the other. The trailing empty is dropped the way Python drops it (`"a\n"` is one line,
|
|
122
125
|
* `""` is none).
|
|
123
126
|
*/
|
|
124
|
-
function
|
|
127
|
+
function splitLines(text) {
|
|
125
128
|
if (!text) return [];
|
|
126
129
|
const parts = text.split(/\r\n|[\n\r\v\f\x1c\x1d\x1e\x85\u2028\u2029]/);
|
|
127
130
|
if (parts.length && parts[parts.length - 1] === '') parts.pop();
|
|
@@ -129,7 +132,7 @@ function pySplitlines(text) {
|
|
|
129
132
|
}
|
|
130
133
|
|
|
131
134
|
/** `str.split()` with no argument — runs of whitespace, ends stripped, no empties. */
|
|
132
|
-
function
|
|
135
|
+
function splitWords(s) {
|
|
133
136
|
return s.split(/\s+/).filter(Boolean);
|
|
134
137
|
}
|
|
135
138
|
|
|
@@ -156,7 +159,7 @@ const _ERRNO = {
|
|
|
156
159
|
EPERM: [1, 'Operation not permitted'],
|
|
157
160
|
};
|
|
158
161
|
|
|
159
|
-
function
|
|
162
|
+
function asOsError(e, filename) {
|
|
160
163
|
const hit = _ERRNO[e && e.code];
|
|
161
164
|
if (!hit) return String((e && e.message) || e);
|
|
162
165
|
// Python reprs the filename, so a Windows path comes out with its backslashes doubled.
|
|
@@ -208,10 +211,10 @@ function readStdin() {
|
|
|
208
211
|
|
|
209
212
|
// The two output funnels, with Python's newline translation reproduced. They moved to
|
|
210
213
|
// `js/lib/fs.mjs` in P5c — beside `writeText`, which is the same rule for FILES — when
|
|
211
|
-
// `js/excludes.mjs` became the second caller; see that docblock for why the translation
|
|
214
|
+
// `js/inspect/excludes.mjs` became the second caller; see that docblock for why the translation
|
|
212
215
|
// exists and which gate can see it.
|
|
213
|
-
const out =
|
|
214
|
-
const err =
|
|
216
|
+
const out = printOut;
|
|
217
|
+
const err = printErr;
|
|
215
218
|
|
|
216
219
|
// ======================================================================================
|
|
217
220
|
// context — project-context discovery
|
|
@@ -243,7 +246,7 @@ export function globalHookStandingDown(hookRoot, cwd) {
|
|
|
243
246
|
if (isFile(path.join(cand, GENESEED_MANIFEST))) {
|
|
244
247
|
// Path equality, which is case-folded on Windows — `~/.claude` and `~/.Claude` are
|
|
245
248
|
// the same install there and two different ones on Linux.
|
|
246
|
-
return normcase(
|
|
249
|
+
return normcase(resolvePath(cand)) !== normcase(resolvePath(hookRoot));
|
|
247
250
|
}
|
|
248
251
|
}
|
|
249
252
|
return false;
|
|
@@ -265,7 +268,7 @@ export function sovereignBypass(root) {
|
|
|
265
268
|
}
|
|
266
269
|
let cwd;
|
|
267
270
|
try {
|
|
268
|
-
cwd = normcase(
|
|
271
|
+
cwd = normcase(resolvePath(process.cwd()));
|
|
269
272
|
} catch {
|
|
270
273
|
return false;
|
|
271
274
|
}
|
|
@@ -281,7 +284,7 @@ export function sovereignBypass(root) {
|
|
|
281
284
|
// and this caller catches: the reference prints nothing here, so replaying the
|
|
282
285
|
// message would be a stderr divergence on every hook call for one bad entry.
|
|
283
286
|
base = withDiscardableStderr(
|
|
284
|
-
() => normcase(
|
|
287
|
+
() => normcase(toPlatformPath(expanduser(raw.trim()))).replace(/[\\/]+$/, ''));
|
|
285
288
|
} catch {
|
|
286
289
|
// PER ENTRY, and the loop continues. One unusable line in a file the USER hand-edits
|
|
287
290
|
// must not decide the whole function — `expanduser` refuses a `~user` form, and the
|
|
@@ -381,7 +384,7 @@ export function resolveContextSets(root) {
|
|
|
381
384
|
if (env && isFile(env)) {
|
|
382
385
|
// `Path(env)`, and the label is printed — so the separators are folded exactly the
|
|
383
386
|
// way `str(Path(...))` folds them, or the source line differs by every slash in it.
|
|
384
|
-
manifest =
|
|
387
|
+
manifest = toPlatformPath(env);
|
|
385
388
|
} else {
|
|
386
389
|
for (const cand of [path.join(root, '.harness', 'context.json'),
|
|
387
390
|
path.join(root, 'context.json')]) {
|
|
@@ -398,7 +401,7 @@ export function resolveContextSets(root) {
|
|
|
398
401
|
try {
|
|
399
402
|
raw = readText(manifest);
|
|
400
403
|
} catch (e) {
|
|
401
|
-
err(`[context] could not read ${manifest}: ${
|
|
404
|
+
err(`[context] could not read ${manifest}: ${asOsError(e, manifest)}\n`);
|
|
402
405
|
return [[], [], String(manifest)];
|
|
403
406
|
}
|
|
404
407
|
let entries;
|
|
@@ -453,7 +456,7 @@ export function resolveContextSets(root) {
|
|
|
453
456
|
}
|
|
454
457
|
continue;
|
|
455
458
|
}
|
|
456
|
-
const abs = path.isAbsolute(raw) ? raw :
|
|
459
|
+
const abs = path.isAbsolute(raw) ? raw : resolvePath(path.join(root, raw));
|
|
457
460
|
put(abs, load, desc);
|
|
458
461
|
}
|
|
459
462
|
|
|
@@ -468,8 +471,8 @@ export function resolveContextSets(root) {
|
|
|
468
471
|
export function cmdContext(args) {
|
|
469
472
|
// Discovery runs against the project root the hook was launched from — Claude runs
|
|
470
473
|
// SessionStart hooks with cwd = repo root — not the harness package dir.
|
|
471
|
-
// The only two `
|
|
472
|
-
// user typed — `$GENESEED_ROOT` and `--root` — and `
|
|
474
|
+
// The only two `resolvePath` calls in this file whose argument can be a `~user` path the
|
|
475
|
+
// user typed — `$GENESEED_ROOT` and `--root` — and `resolvePath` expands, so both can now
|
|
473
476
|
// throw. Every other caller either builds its argument by `path.join` from an absolute
|
|
474
477
|
// root (so no leading tilde survives) or already sits inside a `try`. Refused input
|
|
475
478
|
// injects nothing, which is the same degrade as "nothing to load" below.
|
|
@@ -477,8 +480,8 @@ export function cmdContext(args) {
|
|
|
477
480
|
let hookRoot;
|
|
478
481
|
try {
|
|
479
482
|
({ root, hookRoot } = withDiscardableStderr(() => ({
|
|
480
|
-
root:
|
|
481
|
-
hookRoot: args.root ?
|
|
483
|
+
root: resolvePath(process.env.GENESEED_ROOT || process.cwd()),
|
|
484
|
+
hookRoot: args.root ? resolvePath(args.root) : null,
|
|
482
485
|
})));
|
|
483
486
|
} catch {
|
|
484
487
|
return 0;
|
|
@@ -505,7 +508,7 @@ export function cmdContext(args) {
|
|
|
505
508
|
try {
|
|
506
509
|
lines.push(readText(target).replace(/\n+$/, ''));
|
|
507
510
|
} catch (e) {
|
|
508
|
-
lines.push(`[context] MISSING eager file: ${
|
|
511
|
+
lines.push(`[context] MISSING eager file: ${asOsError(e, target)}`);
|
|
509
512
|
}
|
|
510
513
|
lines.push('');
|
|
511
514
|
}
|
|
@@ -583,9 +586,9 @@ export function ruleGateTarget(p, root) {
|
|
|
583
586
|
if (name === 'memory.md') return 'the memory index';
|
|
584
587
|
if (!root || !name.endsWith('.md')) return null;
|
|
585
588
|
try {
|
|
586
|
-
const store = normcase(
|
|
589
|
+
const store = normcase(resolvePath(path.join(root, 'memory')));
|
|
587
590
|
// `.parents`, so the store dir itself is not one of its own ancestors.
|
|
588
|
-
return selfAndParents(
|
|
591
|
+
return selfAndParents(resolvePath(p)).slice(1).some((d) => normcase(d) === store)
|
|
589
592
|
? 'memory' : null;
|
|
590
593
|
} catch {
|
|
591
594
|
return null;
|
|
@@ -660,11 +663,11 @@ export function appendAgentLesson(memDir, agent, lesson) {
|
|
|
660
663
|
mkdirSync(d, { recursive: true });
|
|
661
664
|
const f = path.join(d, `${agent}.md`);
|
|
662
665
|
let bullets = [];
|
|
663
|
-
if (existsSync(f)) bullets =
|
|
666
|
+
if (existsSync(f)) bullets = splitLines(readText(f)).filter((l) => l.startsWith('- '));
|
|
664
667
|
const now = new Date();
|
|
665
668
|
const day = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-`
|
|
666
669
|
+ `${String(now.getDate()).padStart(2, '0')}`;
|
|
667
|
-
bullets.push(`- ${day}: ${
|
|
670
|
+
bullets.push(`- ${day}: ${splitWords(lesson).join(' ')}`);
|
|
668
671
|
bullets = bullets.slice(-MAX_AGENT_BULLETS);
|
|
669
672
|
writeText(f, `# ${agent} \u2014 lessons\n${bullets.join('\n')}\n`);
|
|
670
673
|
return f;
|
|
@@ -703,7 +706,7 @@ function flattenTranscript(p) {
|
|
|
703
706
|
return '';
|
|
704
707
|
}
|
|
705
708
|
const acc = [];
|
|
706
|
-
for (let line of
|
|
709
|
+
for (let line of splitLines(raw)) {
|
|
707
710
|
line = line.trim();
|
|
708
711
|
if (!line) continue;
|
|
709
712
|
let obj;
|
|
@@ -747,7 +750,7 @@ export function readNotes(raw) {
|
|
|
747
750
|
return raw;
|
|
748
751
|
}
|
|
749
752
|
|
|
750
|
-
// `resolveMemoryDir` moved to `js/hosts.mjs` in P5d — see the import at the top of this
|
|
753
|
+
// `resolveMemoryDir` moved to `js/hosts/hosts.mjs` in P5d — see the import at the top of this
|
|
751
754
|
// file. Re-exported here so the hook entry's own import list does not change.
|
|
752
755
|
export { resolveMemoryDir };
|
|
753
756
|
|
|
@@ -764,7 +767,7 @@ export function frontmatter(md) {
|
|
|
764
767
|
const m = FRONTMATTER_RE.exec(md);
|
|
765
768
|
if (!m) return [new Map(), md];
|
|
766
769
|
const fm = new Map();
|
|
767
|
-
for (const line of
|
|
770
|
+
for (const line of splitLines(m[1])) {
|
|
768
771
|
const at = line.indexOf(':');
|
|
769
772
|
if (at >= 0) {
|
|
770
773
|
// `str.strip('"')` strips the character from BOTH ends, however many there are.
|
|
@@ -853,7 +856,7 @@ export function memoryDropIndex(memDir, name) {
|
|
|
853
856
|
const idx = path.join(memDir, 'MEMORY.md');
|
|
854
857
|
let lines;
|
|
855
858
|
try {
|
|
856
|
-
lines =
|
|
859
|
+
lines = splitLines(readText(idx));
|
|
857
860
|
} catch {
|
|
858
861
|
return; // the reference's `except OSError: return`
|
|
859
862
|
}
|
|
@@ -882,7 +885,7 @@ export function consolidateMemory(memDir) {
|
|
|
882
885
|
const index = path.join(memDir, 'MEMORY.md');
|
|
883
886
|
const oldSlugs = new Set();
|
|
884
887
|
if (existsSync(index)) {
|
|
885
|
-
for (const line of
|
|
888
|
+
for (const line of splitLines(readText(index))) {
|
|
886
889
|
const m = /^- \[[^\]]*\]\(([^)]+)\.md\)/.exec(line.trim());
|
|
887
890
|
if (m) oldSlugs.add(m[1]);
|
|
888
891
|
}
|
|
@@ -912,7 +915,7 @@ export function consolidateMemory(memDir) {
|
|
|
912
915
|
* would truncate a long reply into a silently-half-parsed set of memory files.
|
|
913
916
|
*/
|
|
914
917
|
function runLlm(llm, prompt) {
|
|
915
|
-
const argv =
|
|
918
|
+
const argv = splitWords(llm);
|
|
916
919
|
// `_harness_learn.py:85` runs this through the wrapped `run(..., capture_output=True)`,
|
|
917
920
|
// which folds in `CREATE_NO_WINDOW`; a model CLI invoked from the windowless daemon
|
|
918
921
|
// otherwise pops a console for as long as it takes to answer.
|
|
@@ -958,7 +961,7 @@ function learnAgentLesson(meta, notes, args) {
|
|
|
958
961
|
const proc = runLlm(llm, prompt);
|
|
959
962
|
const text = proc.stdout.trim();
|
|
960
963
|
if (text && text.toUpperCase() !== 'NOTHING') {
|
|
961
|
-
const lesson =
|
|
964
|
+
const lesson = splitLines(text)[0].replace(/^[-*]+/, '').trim();
|
|
962
965
|
const n = Array.from(lesson).length; // code points, as `len()` counts them
|
|
963
966
|
if (n >= 10 && n <= 300) {
|
|
964
967
|
err(`[learn] agent lesson -> ${appendAgentLesson(memDir, agent, lesson)}\n`);
|
|
@@ -985,7 +988,7 @@ export function cmdLearn(args) {
|
|
|
985
988
|
|
|
986
989
|
// The Stop/SubagentStop hook always passes `--memory <cfg>/memory`; inside an excluded
|
|
987
990
|
// folder the global install must not learn.
|
|
988
|
-
if (args.memory && sovereignBypass(path.dirname(
|
|
991
|
+
if (args.memory && sovereignBypass(path.dirname(toPlatformPath(args.memory)))) return 0;
|
|
989
992
|
|
|
990
993
|
const raw = args.file ? readText(args.file) : readStdin();
|
|
991
994
|
let notes = readNotes(raw);
|
|
@@ -1,22 +1,24 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The four host config dirs, and the two path primitives they are built on.
|
|
3
3
|
*
|
|
4
|
-
* EXTRACTED IN P5c, and the reason is arithmetic rather than taste. `bin/
|
|
4
|
+
* EXTRACTED IN P5c, and the reason is arithmetic rather than taste. `bin/build-driver.mjs`
|
|
5
5
|
* owned these; `bin/geneseed-cli.mjs` needs the same four to find a global install, and a
|
|
6
6
|
* resolver that decides WHERE a driver writes is the last thing that should exist twice.
|
|
7
|
-
* `js/hooks.mjs`
|
|
8
|
-
*
|
|
9
|
-
* owner for both drivers
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
7
|
+
* `js/hosts/hooks.mjs` USED to carry a third copy of `opencodeConfigDir`, on the rule that the
|
|
8
|
+
* hook path stays dependency-free; P5d deleted it when `resolveMemoryDir` moved here and the
|
|
9
|
+
* hook became an importer instead. This module is now the single owner for both drivers AND
|
|
10
|
+
* for the hook.
|
|
11
|
+
*
|
|
12
|
+
* The move is a pure one. What holds it is no longer a byte corpus — `tests/golden.mjs`'s two
|
|
13
|
+
* modes compare a run against another run of the SAME generator, so they would follow a
|
|
14
|
+
* changed resolver rather than catch it — but `tests/unit/node_driver.test.mjs`'s relocation
|
|
15
|
+
* table, which asserts each host's global target absolutely.
|
|
15
16
|
*/
|
|
16
17
|
import { realpathSync, statSync } from 'node:fs';
|
|
17
18
|
import path from 'node:path';
|
|
18
19
|
import os from 'node:os';
|
|
19
|
-
import {
|
|
20
|
+
import { printErr, withDiscardableStderr } from '../lib/fs.mjs';
|
|
21
|
+
import { toPlatformPath } from '../lib/paths.mjs';
|
|
20
22
|
|
|
21
23
|
const isDir = (p) => { try { return statSync(p).isDirectory(); } catch { return false; } };
|
|
22
24
|
|
|
@@ -24,8 +26,8 @@ const isDir = (p) => { try { return statSync(p).isDirectory(); } catch { return
|
|
|
24
26
|
export const GLOBAL_MANIFEST = '.geneseed-manifest.json';
|
|
25
27
|
|
|
26
28
|
/**
|
|
27
|
-
* `_build_core.VERSION_MARKER`. Third caller, so it moves here — `js/
|
|
28
|
-
* `js/diff.mjs` each had a private const of their own and `js/uninstall.mjs` would have made
|
|
29
|
+
* `_build_core.VERSION_MARKER`. Third caller, so it moves here — `js/build/version.mjs` and
|
|
30
|
+
* `js/inspect/diff.mjs` each had a private const of their own and `js/maintain/uninstall.mjs` would have made
|
|
29
31
|
* a third copy of a value whose whole job is being the same string everywhere. Same
|
|
30
32
|
* arithmetic that put `GLOBAL_MANIFEST` above it.
|
|
31
33
|
*/
|
|
@@ -66,7 +68,7 @@ export function expanduser(p) {
|
|
|
66
68
|
const msg = `refusing '${p}': a '~user' path is not expanded by this port (Python's `
|
|
67
69
|
+ "ntpath/posixpath would guess a home directory for 'user' — this port does not); "
|
|
68
70
|
+ 'pass an absolute path, or "~" / "~/…" for your own home directory';
|
|
69
|
-
|
|
71
|
+
printErr(`${msg}\n`);
|
|
70
72
|
const e = new Error(msg);
|
|
71
73
|
e.exitCode = 1;
|
|
72
74
|
throw e;
|
|
@@ -83,7 +85,7 @@ export function expanduser(p) {
|
|
|
83
85
|
* the part that exists and appends the rest verbatim, so that is what this reproduces.
|
|
84
86
|
* It matters because the resolved directory is printed on stdout and compared byte-for-byte.
|
|
85
87
|
*/
|
|
86
|
-
export function
|
|
88
|
+
export function resolvePath(p) {
|
|
87
89
|
let cur = path.resolve(expanduser(p));
|
|
88
90
|
const tail = [];
|
|
89
91
|
for (;;) {
|
|
@@ -100,7 +102,7 @@ export function pyResolve(p) {
|
|
|
100
102
|
|
|
101
103
|
/**
|
|
102
104
|
* `_build_core._opencode_config_dir` — and the four resolvers like it are the reason
|
|
103
|
-
* `bin/
|
|
105
|
+
* `bin/build-driver.mjs` exists rather than a child doing the work.
|
|
104
106
|
*
|
|
105
107
|
* P3c's rule was "the child must never resolve this": a render child that did would write
|
|
106
108
|
* 135 files into the developer's real `~/.config/opencode`. A driver is the PARENT, so the
|
|
@@ -109,10 +111,10 @@ export function pyResolve(p) {
|
|
|
109
111
|
*/
|
|
110
112
|
export function opencodeConfigDir() {
|
|
111
113
|
const env = process.env.OPENCODE_CONFIG_DIR;
|
|
112
|
-
if (env) return
|
|
114
|
+
if (env) return resolvePath(env);
|
|
113
115
|
const xdg = process.env.XDG_CONFIG_HOME;
|
|
114
116
|
const base = xdg ? expanduser(xdg) : path.join(os.homedir(), '.config');
|
|
115
|
-
return
|
|
117
|
+
return resolvePath(path.join(base, 'opencode'));
|
|
116
118
|
}
|
|
117
119
|
|
|
118
120
|
/**
|
|
@@ -125,8 +127,8 @@ export function opencodeConfigDir() {
|
|
|
125
127
|
*/
|
|
126
128
|
export function copilotConfigDir() {
|
|
127
129
|
const env = process.env.COPILOT_CONFIG_DIR;
|
|
128
|
-
if (env) return
|
|
129
|
-
return
|
|
130
|
+
if (env) return resolvePath(env);
|
|
131
|
+
return resolvePath(path.join(os.homedir(), '.copilot'));
|
|
130
132
|
}
|
|
131
133
|
|
|
132
134
|
/**
|
|
@@ -141,14 +143,14 @@ export function copilotConfigDir() {
|
|
|
141
143
|
* as an omission.
|
|
142
144
|
*/
|
|
143
145
|
export function claudeConfigDir() {
|
|
144
|
-
return
|
|
146
|
+
return resolvePath(path.join(os.homedir(), '.claude'));
|
|
145
147
|
}
|
|
146
148
|
|
|
147
149
|
/** `_build_core._bob_config_dir` — `~/.bob`, relocatable via `$BOB_CONFIG_DIR`. */
|
|
148
150
|
export function bobConfigDir() {
|
|
149
151
|
const env = process.env.BOB_CONFIG_DIR;
|
|
150
|
-
if (env) return
|
|
151
|
-
return
|
|
152
|
+
if (env) return resolvePath(env);
|
|
153
|
+
return resolvePath(path.join(os.homedir(), '.bob'));
|
|
152
154
|
}
|
|
153
155
|
|
|
154
156
|
/**
|
|
@@ -158,14 +160,14 @@ export function bobConfigDir() {
|
|
|
158
160
|
* An array rather than an object because the order is observable output, not an
|
|
159
161
|
* implementation detail: `harness exclude add` walks it and prints one message per host, so
|
|
160
162
|
* a reordering is a diff in stderr. (`emit_global` and `native_catalog` stay out;
|
|
161
|
-
* `bin/
|
|
163
|
+
* `bin/build-driver.mjs` receives those decisions from its own dispatch rather than reading a
|
|
162
164
|
* table.)
|
|
163
165
|
*
|
|
164
166
|
* `projectMarker` joined in P5f. `_install_targets` asks "does this cwd carry a project
|
|
165
167
|
* install of host H", and `_claude_cfg` asks "which subdir holds a project install's
|
|
166
168
|
* manifest" — both are the same `.opencode`/`.claude`/`.bob`/`.github` value the Python reads
|
|
167
169
|
* out of `build.HOSTS[host]["project_marker"]`, so it belongs beside the config dir rather
|
|
168
|
-
* than in a second table in `js/installs.mjs`.
|
|
170
|
+
* than in a second table in `js/hosts/installs.mjs`.
|
|
169
171
|
*
|
|
170
172
|
* `agentFile` joined in P5h, and for one caller: `cmd_uninstall` names the managed block's
|
|
171
173
|
* carrier in its "removes:" preamble (`the CLAUDE.md managed block`, `the AGENTS.md managed
|
|
@@ -213,7 +215,7 @@ const MEMORY_DIR_NAMES = ['memory', 'anamnesis'];
|
|
|
213
215
|
* The last two matter for the recommended opencode-global install, whose store lives in
|
|
214
216
|
* `~/.config/opencode` rather than beside any repo. null => stdout-only.
|
|
215
217
|
*
|
|
216
|
-
* HERE rather than in `js/hooks.mjs`, where it was written, because P5d gave it a second
|
|
218
|
+
* HERE rather than in `js/hosts/hooks.mjs`, where it was written, because P5d gave it a second
|
|
217
219
|
* caller that cannot import that file: `bin/geneseed-cli.mjs` is under a transitive
|
|
218
220
|
* `child_process` ban and `learn` spawns the model CLI. This module is the one both can
|
|
219
221
|
* reach, and it already owns the `opencodeConfigDir` the last fallback needs — so the move
|
|
@@ -221,18 +223,18 @@ const MEMORY_DIR_NAMES = ['memory', 'anamnesis'];
|
|
|
221
223
|
*/
|
|
222
224
|
export function resolveMemoryDir(explicit) {
|
|
223
225
|
if (explicit) {
|
|
224
|
-
const p =
|
|
226
|
+
const p = toPlatformPath(explicit);
|
|
225
227
|
return isDir(p) ? p : null;
|
|
226
228
|
}
|
|
227
229
|
const env = process.env.GENESEED_MEMORY;
|
|
228
|
-
if (env && isDir(env)) return
|
|
230
|
+
if (env && isDir(env)) return toPlatformPath(env);
|
|
229
231
|
const cwd = process.cwd();
|
|
230
232
|
const bases = [cwd, path.join(cwd, 'Harness')];
|
|
231
233
|
const gh = process.env.GENESEED_HARNESS;
|
|
232
234
|
if (gh) {
|
|
233
235
|
// PER BASE, and the walk continues. `$GENESEED_HARNESS` is the FOURTH user-controlled
|
|
234
236
|
// tilde input and the one the `~user` refusal was never swept onto: `expanduser` throws
|
|
235
|
-
// on `~someone/x`, and this resolver is reached by `geneseed status` (js/status.mjs's
|
|
237
|
+
// on `~someone/x`, and this resolver is reached by `geneseed status` (js/inspect/status.mjs's
|
|
236
238
|
// `resolveMemoryDir(null)`) and by the `learn` hook, whose entry point
|
|
237
239
|
// (`bin/geneseed-hook.mjs`) has NO top-level try — so an unguarded throw here is a stack
|
|
238
240
|
// trace on a hook path and a non-zero exit, the exact failure the other three guards
|
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
* How a DEPLOYED install is recognised — `_harness_setup`'s detection half and
|
|
3
3
|
* `_harness_mcp`'s registry-reading half, which are one subject in two Python files.
|
|
4
4
|
*
|
|
5
|
-
* EXTRACTED IN P5f, and by the same arithmetic that produced `js/hosts.mjs` and
|
|
6
|
-
* `js/
|
|
7
|
-
* `footprintOfDir` inside `js/status.mjs` because `status` was the only caller; `diff`
|
|
5
|
+
* EXTRACTED IN P5f, and by the same arithmetic that produced `js/hosts/hosts.mjs` and
|
|
6
|
+
* `js/build/source.mjs`: this is the third verb-set to need it. P5d wrote `themeOfDir` and
|
|
7
|
+
* `footprintOfDir` inside `js/inspect/status.mjs` because `status` was the only caller; `diff`
|
|
8
8
|
* renders its 'expected' copy in the theme and footprint the deployment actually uses, and
|
|
9
9
|
* `rebuild-all` re-emits every install in its own theme, emit, footprint, posture and mode.
|
|
10
10
|
* Three callers of one detector is where a private helper becomes a module.
|
|
@@ -32,13 +32,15 @@
|
|
|
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, statSync } from 'node:fs';
|
|
36
36
|
import path from 'node:path';
|
|
37
37
|
|
|
38
|
-
import { CONFIG, PACK_ORDER, THEMES, discoverNames } from '
|
|
39
|
-
import { GLOBAL_MANIFEST, HOSTS,
|
|
40
|
-
import { registryRoots } from '
|
|
41
|
-
import {
|
|
38
|
+
import { CONFIG, PACK_ORDER, THEMES, discoverNames } from '../build/source.mjs';
|
|
39
|
+
import { GLOBAL_MANIFEST, HOSTS, resolvePath } from './hosts.mjs';
|
|
40
|
+
import { registryRoots } from '../inspect/registry.mjs';
|
|
41
|
+
import { printErr, readText } from '../lib/fs.mjs';
|
|
42
|
+
import { formatRepr } from '../lib/json.mjs';
|
|
43
|
+
import { comparePaths } from '../lib/paths.mjs';
|
|
42
44
|
|
|
43
45
|
const isDir = (p) => { try { return statSync(p).isDirectory(); } catch { return false; } };
|
|
44
46
|
const isFile = (p) => { try { return statSync(p).isFile(); } catch { return false; } };
|
|
@@ -99,7 +101,7 @@ export const defaultMode = () => configuredDefault('mode', 'direct');
|
|
|
99
101
|
// pre-2.3 install on its first upgrade — `{"doctrines":["craft"]}` and the gate is gone from
|
|
100
102
|
// carriers that never asked. Unknown resolves to ALL packs; see `doctrinesForBuild` below.
|
|
101
103
|
// The config value keeps exactly one legitimate reader: `configDefaults()` in
|
|
102
|
-
// `bin/
|
|
104
|
+
// `bin/build-driver.mjs`, which answers `geneseed build` — the case where there is no install to
|
|
103
105
|
// ask in the first place.
|
|
104
106
|
|
|
105
107
|
// ---- theme, footprint, posture, mode, read back off a deployed tree -----------------------
|
|
@@ -180,7 +182,7 @@ export function footprintOfDir(d) {
|
|
|
180
182
|
const v = (readMaybe(marker) ?? '').trim();
|
|
181
183
|
if (FOOTPRINTS.includes(v)) return v;
|
|
182
184
|
if (v) {
|
|
183
|
-
|
|
185
|
+
printErr(`[geneseed] WARN: ${marker} holds unknown footprint ${formatRepr(v)} — `
|
|
184
186
|
+ `reading it as 'full'. Known: ${FOOTPRINTS.join(', ')}.\n`);
|
|
185
187
|
}
|
|
186
188
|
}
|
|
@@ -202,7 +204,7 @@ export function footprintOfDir(d) {
|
|
|
202
204
|
* lowercasing. Every shipped name is already lowercase, which is exactly why the difference
|
|
203
205
|
* would go unnoticed — the corpus in `tests/test_pure_function_parity.py` is what covers it.
|
|
204
206
|
*/
|
|
205
|
-
export function
|
|
207
|
+
export function capitalize(s) {
|
|
206
208
|
return s.length ? s[0].toUpperCase() + s.slice(1).toLowerCase() : s;
|
|
207
209
|
}
|
|
208
210
|
|
|
@@ -211,7 +213,7 @@ function leadOfDir(d, names) {
|
|
|
211
213
|
const text = readMaybe(path.join(d, carrier));
|
|
212
214
|
if (text === null) continue;
|
|
213
215
|
for (const name of names) {
|
|
214
|
-
if (text.includes(`**${
|
|
216
|
+
if (text.includes(`**${capitalize(name)}**`)) return name;
|
|
215
217
|
}
|
|
216
218
|
}
|
|
217
219
|
return null;
|
|
@@ -281,7 +283,7 @@ export function doctrinesOfDir(d) {
|
|
|
281
283
|
* got WIDER when the reader was hardened, because more inputs now answer `null`.
|
|
282
284
|
*
|
|
283
285
|
* A config value is a legitimate default only where there is NO INSTALL TO ASK: a fresh
|
|
284
|
-
* `geneseed build`, which `configDefaults()` in `bin/
|
|
286
|
+
* `geneseed build`, which `configDefaults()` in `bin/build-driver.mjs` answers. Anything holding a
|
|
285
287
|
* directory has an install to ask, so it calls this and never the config.
|
|
286
288
|
*
|
|
287
289
|
* `[]` still passes through untouched — a marker that reads `none` is an answer, not a silence.
|
|
@@ -290,6 +292,47 @@ export function doctrinesForBuild(d) {
|
|
|
290
292
|
return doctrinesOfDir(d) ?? [...PACK_ORDER];
|
|
291
293
|
}
|
|
292
294
|
|
|
295
|
+
/** `Excluded rules: process 7, craft 3` — the marker's optional second line. */
|
|
296
|
+
const EXCLUDED_RULES_RE = /^Excluded rules:[ \t]*(.+?)[ \t]*$/m;
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* The individual doctrine rules a DEPLOYED install left out — the second axis, read back.
|
|
300
|
+
*
|
|
301
|
+
* ⚠ ITS NULL SEMANTICS ARE THE OPPOSITE OF `doctrinesOfDir`'S, AND THAT IS THE POINT. A
|
|
302
|
+
* missing `Active packs:` line means "this install does not say", which has to resolve to
|
|
303
|
+
* ALL packs or a pre-migration install loses its consent gate on the next upgrade. A missing
|
|
304
|
+
* `Excluded rules:` line means "nothing was excluded" — because the line is only ever written
|
|
305
|
+
* when something is, so its absence is a STATEMENT rather than a silence. Both answers fail
|
|
306
|
+
* closed: unknown packs bind the most, unknown exclusions take away the least. They only look
|
|
307
|
+
* inconsistent until you ask which direction is safe for each.
|
|
308
|
+
*
|
|
309
|
+
* That is also what makes this backward compatible by construction: every install that exists
|
|
310
|
+
* today has no such line, reads as `[]`, and behaves exactly as it does now.
|
|
311
|
+
*
|
|
312
|
+
* A malformed entry condemns the whole line to `[]` rather than to the part that parsed —
|
|
313
|
+
* same rule as the pack list, same reason, opposite default. Half an exclusion list would
|
|
314
|
+
* take away rules nobody named.
|
|
315
|
+
*/
|
|
316
|
+
export function excludedRulesOfDir(d) {
|
|
317
|
+
for (const carrier of CARRIERS) {
|
|
318
|
+
const text = readMaybe(path.join(d, carrier));
|
|
319
|
+
if (text === null) continue;
|
|
320
|
+
const m = EXCLUDED_RULES_RE.exec(text);
|
|
321
|
+
if (!m) continue;
|
|
322
|
+
if (m[1] === 'none') return [];
|
|
323
|
+
const named = m[1].split(',').map((s) => s.trim());
|
|
324
|
+
// `<pack> <n>` on the page, `<pack>.<n>` in the code — the same asymmetry the rendered
|
|
325
|
+
// line documents. A name this checkout does not ship is a corrupt line, not a hint.
|
|
326
|
+
const ids = named.map((s) => {
|
|
327
|
+
const parts = s.split(/[ \t]+/);
|
|
328
|
+
return parts.length === 2 && PACK_ORDER.includes(parts[0]) && /^\d+$/.test(parts[1])
|
|
329
|
+
? `${parts[0]}.${Number(parts[1])}` : null;
|
|
330
|
+
});
|
|
331
|
+
return ids.every(Boolean) ? [...new Set(ids)].sort() : [];
|
|
332
|
+
}
|
|
333
|
+
return [];
|
|
334
|
+
}
|
|
335
|
+
|
|
293
336
|
// ---- what host a deployed dir belongs to (`_harness_mcp`) ---------------------------------
|
|
294
337
|
|
|
295
338
|
/** `_harness_mcp._claude_read_manifest`. */
|
|
@@ -377,7 +420,7 @@ export function registeredTargets() {
|
|
|
377
420
|
* De-duplicated on the (host, RESOLVED path) pair, which is three behaviours in one rule: a
|
|
378
421
|
* cwd carrying both `.opencode/` and `.claude/` yields two correctly-typed rows, a cwd that
|
|
379
422
|
* IS a global config dir never doubles its own host's row, and a registered root that is also
|
|
380
|
-
* the cwd never doubles either. `
|
|
423
|
+
* the cwd never doubles either. `resolvePath` rather than `path.resolve` because Python's
|
|
381
424
|
* `Path.resolve()` returns the filesystem's own casing — on Windows two spellings of the same
|
|
382
425
|
* directory differ as strings and not as installs, and this Set is keyed by string.
|
|
383
426
|
*/
|
|
@@ -392,12 +435,12 @@ export function installTargets() {
|
|
|
392
435
|
if (scope !== 'global') {
|
|
393
436
|
try {
|
|
394
437
|
const spec = HOSTS.find((h) => h.host === host);
|
|
395
|
-
if (
|
|
438
|
+
if (resolvePath(path.join(root, spec.projectMarker)) === resolvePath(spec.configDir())) {
|
|
396
439
|
return;
|
|
397
440
|
}
|
|
398
441
|
} catch { /* as the Python's bare `except Exception: pass` */ }
|
|
399
442
|
}
|
|
400
|
-
const key = `${host}\0${
|
|
443
|
+
const key = `${host}\0${resolvePath(root)}`;
|
|
401
444
|
if (!seen.has(key)) { seen.add(key); out.push([host, scope, root]); }
|
|
402
445
|
};
|
|
403
446
|
|
|
@@ -437,6 +480,7 @@ export function installTargets() {
|
|
|
437
480
|
export function installedDefaults() {
|
|
438
481
|
const found = {
|
|
439
482
|
theme: null, posture: null, mode: null, emit: null, footprint: null, doctrines: null,
|
|
483
|
+
excludeRules: null,
|
|
440
484
|
};
|
|
441
485
|
const candidates = [];
|
|
442
486
|
for (const { host, configDir } of HOSTS) {
|
|
@@ -470,6 +514,14 @@ export function installedDefaults() {
|
|
|
470
514
|
// for a carrier with no marker, which would end this walk at the FIRST candidate and hide
|
|
471
515
|
// a later carrier that does say. The null has to survive to the caller here.
|
|
472
516
|
if (found.doctrines === null) found.doctrines = doctrinesOfDir(base);
|
|
517
|
+
// ⚠ THE PER-RULE AXIS HAS ONLY TWO STATES, so the walk cannot use `=== null` to mean
|
|
518
|
+
// "keep looking" the way the line above does — `excludedRulesOfDir` answers `[]` for a
|
|
519
|
+
// carrier that excludes nothing AND for one with no marker at all, and the empty list is a
|
|
520
|
+
// real answer. An EMPTY answer therefore keeps the walk going and a non-empty one stops it:
|
|
521
|
+
// the first carrier on this machine that actually names an exclusion is the one that
|
|
522
|
+
// pre-selects. The cost of that choice is that "excludes nothing" and "no carrier" are
|
|
523
|
+
// indistinguishable here, which is exactly what the marker itself already says.
|
|
524
|
+
if (!found.excludeRules?.length) found.excludeRules = excludedRulesOfDir(base);
|
|
473
525
|
}
|
|
474
526
|
return found;
|
|
475
527
|
}
|