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
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* THE SECOND thing the walk shows only as one name: `_diff_collect` runs a whole GLOBAL EMIT
|
|
15
15
|
* into a temp dir. `build.HOSTS[host]["emit_global"]` is a `build.*` reference like any other
|
|
16
16
|
* in the table, and behind it is the generator. That call is why this file imports
|
|
17
|
-
* `bin/
|
|
17
|
+
* `bin/build-driver.mjs` — the same in-process route `js/build/generate.mjs` established for `build`,
|
|
18
18
|
* under the same transitive `child_process` ban.
|
|
19
19
|
*
|
|
20
20
|
* WHAT NO CELL CAN VARY, stated rather than left implicit:
|
|
@@ -22,30 +22,33 @@
|
|
|
22
22
|
* * **The diff algorithm.** A cell can produce ONE edited file and see one hunk. It cannot
|
|
23
23
|
* produce the shapes where `difflib`'s choices differ from any other diff's — ties, the
|
|
24
24
|
* recursion's alignment, and above all `autojunk`, which only engages at 200 lines and
|
|
25
|
-
* changes the hunks on every real harness file. Gated as a corpus
|
|
26
|
-
* `tests/
|
|
27
|
-
*
|
|
28
|
-
*
|
|
25
|
+
* changes the hunks on every real harness file. Gated as a corpus —
|
|
26
|
+
* `tests/fixtures/pure_probe.mjs`'s `unified_diff` entry, replayed by
|
|
27
|
+
* `tests/snapshot/pure_snapshot.test.mjs` against `tests/__snapshots__/primitives/` —
|
|
28
|
+
* measured in both directions (28 of the 176 cases there disagree when `autojunk` is
|
|
29
|
+
* switched off, so the corpus is not describing a constant).
|
|
29
30
|
* * **The timestamp.** `_write_improvements` names the file `improvements-%Y%m%d-%H%M%S.md`
|
|
30
31
|
* and stamps the report with `datetime.now()`, so the two implementations cannot write the
|
|
31
32
|
* same bytes across a second boundary — and a cell runs them in sequence, so the boundary
|
|
32
|
-
* is a coin flip.
|
|
33
|
-
*
|
|
34
|
-
* `
|
|
35
|
-
*
|
|
33
|
+
* is a coin flip. The cell harness normalises both spellings out of the snapshot, which
|
|
34
|
+
* costs the FORMAT assertion and pays it back in `tests/unit/hook_cli.test.mjs`'s
|
|
35
|
+
* `the improvements filename is stamped the declared way` — absolute, and asserted
|
|
36
|
+
* directly rather than by comparison, because a check made tolerant of a value owes one
|
|
37
|
+
* somewhere else.
|
|
36
38
|
*/
|
|
37
39
|
import { existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, statSync } from 'node:fs';
|
|
38
40
|
import os from 'node:os';
|
|
39
41
|
import path from 'node:path';
|
|
40
42
|
|
|
41
|
-
import { emitGlobalInto } from '
|
|
42
|
-
import { GLOBAL_MANIFEST, VERSION_MARKER, expanduser, opencodeConfigDir } from '
|
|
43
|
+
import { emitGlobalInto } from '../../bin/build-driver.mjs';
|
|
44
|
+
import { GLOBAL_MANIFEST, VERSION_MARKER, expanduser, opencodeConfigDir } from '../hosts/hosts.mjs';
|
|
43
45
|
import {
|
|
44
46
|
EMIT_HOST_SCOPE, defaultTheme, doctrinesForBuild, footprintOfDir, modeOfDir, postureOfDir,
|
|
45
47
|
readJsonMaybe, readMaybe, themeOfDir,
|
|
46
|
-
} from '
|
|
47
|
-
import { unifiedDiff,
|
|
48
|
-
import {
|
|
48
|
+
} from '../hosts/installs.mjs';
|
|
49
|
+
import { unifiedDiff, splitLines } from '../lib/udiff.mjs';
|
|
50
|
+
import { printOut, printErr, readText, writeText } from '../lib/fs.mjs';
|
|
51
|
+
import { toPlatformPath } from '../lib/paths.mjs';
|
|
49
52
|
|
|
50
53
|
const isFile = (p) => { try { return statSync(p).isFile(); } catch { return false; } };
|
|
51
54
|
|
|
@@ -100,7 +103,7 @@ export function manifestScope(d) {
|
|
|
100
103
|
*
|
|
101
104
|
* The emit prints its own summary line, which is not part of `diff`'s output; the Python
|
|
102
105
|
* swallows it and lets stderr through, so a WARN from the emit still reaches the user. Both
|
|
103
|
-
* halves matter and only the first needs code here — `
|
|
106
|
+
* halves matter and only the first needs code here — `printOut` and every driver funnel go
|
|
104
107
|
* through `process.stdout.write`, so replacing that one method is the whole redirect.
|
|
105
108
|
*
|
|
106
109
|
* `finally`, because the emit can throw: a swallow that leaked would silence every later line
|
|
@@ -183,13 +186,13 @@ export function diffCollect({ target = null, theme = null, emit = null, footprin
|
|
|
183
186
|
const ta = readText(a);
|
|
184
187
|
const tb = readText(b);
|
|
185
188
|
if (cmpKey(rel, ta) !== cmpKey(rel, tb)) {
|
|
186
|
-
const diff = unifiedDiff(
|
|
189
|
+
const diff = unifiedDiff(splitLines(tb), splitLines(ta), {
|
|
187
190
|
fromfile: `source/${rel}`, tofile: `deployed/${rel}`, lineterm: '',
|
|
188
191
|
});
|
|
189
192
|
files.push({ rel, status: 'edited', diff });
|
|
190
193
|
}
|
|
191
194
|
} else if (isFile(a)) {
|
|
192
|
-
const body =
|
|
195
|
+
const body = splitLines(readText(a));
|
|
193
196
|
files.push({
|
|
194
197
|
rel,
|
|
195
198
|
status: 'added',
|
|
@@ -334,8 +337,8 @@ export function flushExportNotes() {
|
|
|
334
337
|
.sort();
|
|
335
338
|
} catch { return; }
|
|
336
339
|
if (fresh.length === 0) return;
|
|
337
|
-
for (const p of fresh)
|
|
338
|
-
|
|
340
|
+
for (const p of fresh) printOut(`[geneseed] improvements file saved: ${toPlatformPath(p)}\n`);
|
|
341
|
+
printOut('[geneseed] the deployed harness carried local edits — hand the file to '
|
|
339
342
|
+ 'your agent to back-port them into src/.\n');
|
|
340
343
|
}
|
|
341
344
|
|
|
@@ -344,14 +347,14 @@ export function cmdDiff(args) {
|
|
|
344
347
|
const { target, theme, files } = diffCollect({ target: args.target, theme: args.theme });
|
|
345
348
|
if (files === null) {
|
|
346
349
|
if (existsSync(path.join(target, GLOBAL_MANIFEST)) && manifestScope(target) === 'project') {
|
|
347
|
-
|
|
350
|
+
printErr(`[diff] ${target} is a PROJECT install — diff only compares GLOBAL `
|
|
348
351
|
+ 'installs against a fresh render (a project layer is diffed against '
|
|
349
352
|
+ 'its own repo, which this command does not do). Pass --target at a '
|
|
350
353
|
+ 'global config dir (opencode-global/claude-global/bob-global/'
|
|
351
354
|
+ 'copilot-global), or omit '
|
|
352
355
|
+ '--target to use the default.\n');
|
|
353
356
|
} else {
|
|
354
|
-
|
|
357
|
+
printErr(`[diff] no global Geneseed install at ${target} (no ${GLOBAL_MANIFEST}). `
|
|
355
358
|
+ 'Pass --target, or run `--emit opencode-global` first.\n');
|
|
356
359
|
}
|
|
357
360
|
return 1;
|
|
@@ -359,31 +362,31 @@ export function cmdDiff(args) {
|
|
|
359
362
|
const edited = files.filter((f) => f.status === 'edited');
|
|
360
363
|
const added = files.filter((f) => f.status === 'added');
|
|
361
364
|
const missing = files.filter((f) => f.status === 'missing');
|
|
362
|
-
|
|
363
|
-
|
|
365
|
+
printOut(`[diff] deployed ${target} vs source (theme: ${theme})\n`);
|
|
366
|
+
printOut(`[diff] ${edited.length} edited, ${added.length} added-in-deployed, `
|
|
364
367
|
+ `${missing.length} missing-from-deployed\n`);
|
|
365
|
-
for (const f of edited)
|
|
366
|
-
for (const f of added)
|
|
367
|
-
for (const f of missing)
|
|
368
|
+
for (const f of edited) printOut(` ~ ${f.rel} (edited in deployed — review to back-port)\n`);
|
|
369
|
+
for (const f of added) printOut(` + ${f.rel} (only in deployed — your addition)\n`);
|
|
370
|
+
for (const f of missing) printOut(` - ${f.rel} (in source, not deployed — re-emit to add)\n`);
|
|
368
371
|
if (args.out) {
|
|
369
372
|
if (files.length) {
|
|
370
373
|
// Bare `--out` is argparse's `nargs="?"` const: True, meaning "the default timestamped
|
|
371
374
|
// path". `parse()` gives a flag `true` and an option a string, so the two arrive
|
|
372
375
|
// distinguishable here without a third kind of entry in the VERBS table.
|
|
373
376
|
const outPath = args.out === true ? null : args.out;
|
|
374
|
-
|
|
377
|
+
printOut(`[diff] improvements file written: ${
|
|
375
378
|
writeImprovements(target, theme, files, outPath)}\n`);
|
|
376
379
|
} else {
|
|
377
|
-
|
|
380
|
+
printOut('[diff] no differences — nothing written.\n');
|
|
378
381
|
}
|
|
379
382
|
}
|
|
380
383
|
if (args.full) {
|
|
381
384
|
for (const f of edited) {
|
|
382
|
-
|
|
383
|
-
|
|
385
|
+
printOut(`\n--- ${f.rel} (source -> deployed) ---\n`);
|
|
386
|
+
printOut(`${f.diff.join('\n')}\n`);
|
|
384
387
|
}
|
|
385
388
|
} else if (edited.length && !args.out) {
|
|
386
|
-
|
|
389
|
+
printOut('\nRun with --full to see the line-level diffs, or --out FILE to export them.\n');
|
|
387
390
|
}
|
|
388
391
|
return 0;
|
|
389
392
|
}
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `geneseed doctor` — validate the build.
|
|
3
|
+
*
|
|
4
|
+
* Sixteen `*Problems` checks over one theme (or every theme) — and `[doctor] ok — …` when all
|
|
5
|
+
* sixteen find nothing. The checks themselves live beside this file, in `checks-build.mjs`,
|
|
6
|
+
* `checks-repo.mjs` and `checks-authoring.mjs`, over the primitives in `scan.mjs`; what is left
|
|
7
|
+
* here is the driver that chooses the themes, runs the checks and prints the verdict.
|
|
8
|
+
*
|
|
9
|
+
* WHY THE GATE LOOKS THE WAY IT DOES, and it is worth stating here rather than only in the
|
|
10
|
+
* spec: **delete any single check and a clean run is byte-identical**, so a comparison of two
|
|
11
|
+
* runs cannot tell a full doctor from a stub that prints the OK line.
|
|
12
|
+
* `tests/unit/harness.test.mjs` and `tests/unit/authoring_gates.test.mjs` therefore plant ONE
|
|
13
|
+
* FAULT PER CHECK, in a private copy of the checkout — see `copyCheckout` in
|
|
14
|
+
* `tests/helpers/cli_golden.mjs` for why a verb that reads `ROOT` needed a fixture kind that
|
|
15
|
+
* did not exist until the port.
|
|
16
|
+
*
|
|
17
|
+
* WHY THIS IS THE VERB THAT SPAWNS. `bin/geneseed-cli.mjs` is under a blanket transitive
|
|
18
|
+
* `child_process` ban, and its own docblock predicted the day a verb that genuinely spawns
|
|
19
|
+
* would land: this is it. `authoringProblems` runs `node --check` over the OpenCode plugins,
|
|
20
|
+
* and there is no in-process equivalent — `vm.Script` compiles as a SCRIPT and every plugin is
|
|
21
|
+
* ESM (`import { promises as fs } from "node:fs"`), which `node --check` accepts through
|
|
22
|
+
* module-syntax detection and `vm.Script` rejects with a SyntaxError of its own. So the ban is
|
|
23
|
+
* an ALLOW-LIST of exactly the shape the hook entry already carries for `$GENESEED_LLM`: one
|
|
24
|
+
* binding, one call site, and a test that names the argv. What the ban actually protects —
|
|
25
|
+
* that this tool never shells back to the generator — is asserted directly instead.
|
|
26
|
+
*
|
|
27
|
+
* WHAT `doctorCollect` DOES NOT REACH, MEASURED RATHER THAN ASSUMED. It takes `groups` and
|
|
28
|
+
* `cmdDoctor` does not pass it: that parameter is the WEB's, and `apiDoctor` in
|
|
29
|
+
* `js/web/api.mjs` is its only caller. `ran` exists for it — four lines of accumulator that
|
|
30
|
+
* keep the Doctor page's per-check cards a parameter rather than a rewrite.
|
|
31
|
+
*/
|
|
32
|
+
import path from 'node:path';
|
|
33
|
+
import { emitGlobalInto, emitProjectInto, main as driverMain } from '../../bin/build-driver.mjs';
|
|
34
|
+
import { ROOT } from '../build/source.mjs';
|
|
35
|
+
import { resolvePath } from '../hosts/hosts.mjs';
|
|
36
|
+
import { installedDefaults, themeFiles } from '../hosts/installs.mjs';
|
|
37
|
+
import { validateIsVendored } from '../hosts/native.mjs';
|
|
38
|
+
import { printOut } from '../lib/fs.mjs';
|
|
39
|
+
import { authoringProblems } from './checks-authoring.mjs';
|
|
40
|
+
import {
|
|
41
|
+
checkBuild, colorThemeProblems, renderedProblems, themeParityProblems,
|
|
42
|
+
} from './checks-build.mjs';
|
|
43
|
+
import { moduleMapProblems, shimProblems } from './checks-repo.mjs';
|
|
44
|
+
import { isDoctorNote, sortedProblems, sortedUnique, stemOf, withTempDir } from './scan.mjs';
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* `_harness_build._themes_to_check` — which themes doctor validates.
|
|
48
|
+
*
|
|
49
|
+
* An explicit `--theme` wins. Otherwise, unless `--all` forces the maintainer sweep, scope to
|
|
50
|
+
* the theme THIS host installed so a one-theme user is not buried under the same problem
|
|
51
|
+
* echoed across all fourteen. Falls back to the full sweep when nothing is installed (a fresh
|
|
52
|
+
* clone) or the detected theme is unknown, so a maintainer in a clean checkout still gets
|
|
53
|
+
* full coverage. Pure, and gated as such: `--all` is a 14-theme sweep no cell can afford.
|
|
54
|
+
*/
|
|
55
|
+
export function themesToCheck(theme, allThemes, detected, available) {
|
|
56
|
+
if (theme) return [theme];
|
|
57
|
+
if (!allThemes && detected && available.includes(detected)) return [detected];
|
|
58
|
+
return [...available].sort();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* `contextlib.redirect_stdout(io.StringIO())` — swallow an emit's log, and ONLY stdout.
|
|
63
|
+
*
|
|
64
|
+
* The Python redirects stdout and leaves stderr alone, so a WARN from the emit still reaches
|
|
65
|
+
* the user. Reproducing that exactly is the point: swallowing both would hide
|
|
66
|
+
* `warnBobGlobalOverProject` and swallowing neither would put ~200 lines of emit summary into
|
|
67
|
+
* doctor's output. `withPlatformNewlines` inside `emitGlobalInto` wraps whatever `write` is
|
|
68
|
+
* installed at call time, so it translates into this buffer and the bytes never leave.
|
|
69
|
+
*/
|
|
70
|
+
function swallowStdout(fn) {
|
|
71
|
+
const real = process.stdout.write.bind(process.stdout);
|
|
72
|
+
process.stdout.write = () => true;
|
|
73
|
+
try { return fn(); } finally { process.stdout.write = real; }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* `_harness_build._global_emit_problems` — validate the opencode-global emit, in BOTH
|
|
78
|
+
* footprints.
|
|
79
|
+
*
|
|
80
|
+
* The RECOMMENDED install, and otherwise a doctor blind spot: the files build and ./Harness
|
|
81
|
+
* were checked and the global layout never was. The two footprints produce genuinely
|
|
82
|
+
* different AGENT.md bodies — lean swaps the inlined laws for a digest plus a pointer and
|
|
83
|
+
* ships a standalone laws file the full emit does not write — so checking one leaves the
|
|
84
|
+
* other's links and tokens unvalidated, and lean being the default made that the shape most
|
|
85
|
+
* installs actually run.
|
|
86
|
+
*/
|
|
87
|
+
export function globalEmitProblems(themeName) {
|
|
88
|
+
const problems = [];
|
|
89
|
+
for (const footprint of ['lean', 'full']) {
|
|
90
|
+
withTempDir((tmp) => {
|
|
91
|
+
const cfgDir = path.join(tmp, 'cfg');
|
|
92
|
+
try {
|
|
93
|
+
swallowStdout(() => emitGlobalInto('opencode', {
|
|
94
|
+
theme: themeName, out: path.join(tmp, 'bundle'), cfgDir, footprint,
|
|
95
|
+
}));
|
|
96
|
+
} catch (e) {
|
|
97
|
+
if (e && e.exitCode !== undefined) {
|
|
98
|
+
problems.push(`[${themeName} global/${footprint}] build failed`);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
throw e;
|
|
102
|
+
}
|
|
103
|
+
problems.push(...checkBuild(`${themeName} global/${footprint}`, cfgDir));
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
return problems;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* `_harness_build._claude_bob_emit_problems` — validate the claude/bob/copilot PER-REPO
|
|
111
|
+
* emits, which were never checked before.
|
|
112
|
+
*
|
|
113
|
+
* That is exactly why the CLAUDE.md/AGENTS.md skill-table dead links shipped unnoticed: the
|
|
114
|
+
* emits that render straight into a repo were outside doctor's sweep. Scanned with
|
|
115
|
+
* `validateIsVendored` rather than `isVendoredPath`, because the native per-repo layer nests
|
|
116
|
+
* skills one level deeper than a bundle does.
|
|
117
|
+
*/
|
|
118
|
+
export function claudeBobEmitProblems(themeName) {
|
|
119
|
+
const problems = [];
|
|
120
|
+
for (const label of ['claude', 'bob', 'copilot']) {
|
|
121
|
+
withTempDir((tmp) => {
|
|
122
|
+
const root = path.join(tmp, 'root');
|
|
123
|
+
try {
|
|
124
|
+
swallowStdout(() => emitProjectInto(label, {
|
|
125
|
+
theme: themeName, out: path.join(root, 'bundle'), root,
|
|
126
|
+
}));
|
|
127
|
+
} catch (e) {
|
|
128
|
+
if (e && e.exitCode !== undefined) {
|
|
129
|
+
problems.push(`[${themeName} ${label}] build failed`);
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
throw e;
|
|
133
|
+
}
|
|
134
|
+
problems.push(...checkBuild(`${themeName} ${label}`, root, validateIsVendored));
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
return problems;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* `_harness_build._doctor_collect` — run every check; return `[themes, problems]`.
|
|
142
|
+
*
|
|
143
|
+
* ONE PARAMETER OF THE PYTHON IS NOT HERE, and the absence is on purpose. `on_progress` is
|
|
144
|
+
* the TUI's — it drags ~140 lines of curses drawing behind it and nothing in this tree can
|
|
145
|
+
* supply one. `groups` DID land, with the caller that needed it: it is the structured
|
|
146
|
+
* `{check, label, problems}` view the Doctor page renders, and `js/web/api.mjs`'s `apiDoctor`
|
|
147
|
+
* passes an array. `cmdDoctor` passes nothing and gets the flat list, so the return contract
|
|
148
|
+
* is the same either way.
|
|
149
|
+
*
|
|
150
|
+
* THE PER-THEME BUILD IS THE DRIVER, IN-PROCESS. The Python shells to `build.py` and reads a
|
|
151
|
+
* return code; here that is `driverMain`, the route P5e established and the only one the
|
|
152
|
+
* `child_process` allow-list leaves. `cwd=ROOT` does not travel in-process and does not have
|
|
153
|
+
* to: `--out` is an absolute path, which is the one input `resolveOut` would have read the
|
|
154
|
+
* working directory for.
|
|
155
|
+
*/
|
|
156
|
+
export function doctorCollect({
|
|
157
|
+
theme = null, allThemes = false, bundle = null, noBundle = false, groups = null,
|
|
158
|
+
} = {}) {
|
|
159
|
+
const available = themeFiles().map(stemOf);
|
|
160
|
+
if (!available.length) return [[], ['[doctor] no themes found']];
|
|
161
|
+
|
|
162
|
+
// `_ran` — the label is the one place each check is NAMED, and since P6b a caller can
|
|
163
|
+
// pass an array to collect them. `/api/doctor` renders one card per entry, which is the
|
|
164
|
+
// only reason the structured view exists; `cmd_doctor` passes nothing and gets the flat
|
|
165
|
+
// list, so the return contract is unchanged either way. `on_progress` is still P7's.
|
|
166
|
+
const ran = (check, label, probs) => {
|
|
167
|
+
if (groups !== null) groups.push({ check, label, problems: sortedProblems(probs) });
|
|
168
|
+
return probs;
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
// Only probe the deployed install when we actually need it (no theme / not --all).
|
|
172
|
+
const detected = (theme || allThemes) ? null : (installedDefaults().theme ?? null);
|
|
173
|
+
const themes = themesToCheck(theme, allThemes, detected, available);
|
|
174
|
+
// Sampled HERE, before the emit loop below — every emit rewrites the hook shim, so a check
|
|
175
|
+
// placed after them could only ever observe the freshly repaired file and would be dead
|
|
176
|
+
// code that always reports clean. Reported in its usual slot further down.
|
|
177
|
+
const shimProbs = shimProblems();
|
|
178
|
+
let problems = [];
|
|
179
|
+
withTempDir((tmp) => {
|
|
180
|
+
for (const themeName of themes) {
|
|
181
|
+
const out = path.join(tmp, themeName);
|
|
182
|
+
// capture_output=True: BOTH streams, unlike the emits below, which redirect stdout
|
|
183
|
+
// only. A failing build's stderr is not doctor's output either — the return code is.
|
|
184
|
+
const rc = captureBoth(() => driverMain(['--theme', themeName, '--out', out]));
|
|
185
|
+
if (rc !== 0) {
|
|
186
|
+
problems = problems.concat(ran('build', `Build scan (${themeName})`,
|
|
187
|
+
[`[${themeName}] build failed`]));
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
problems = problems.concat(ran('build', `Build scan (${themeName})`,
|
|
191
|
+
checkBuild(themeName, out)));
|
|
192
|
+
problems = problems.concat(ran('global', `Global install (${themeName})`,
|
|
193
|
+
globalEmitProblems(themeName)));
|
|
194
|
+
problems = problems.concat(ran('claude_bob',
|
|
195
|
+
`Claude/Bob/Copilot per-repo emit (${themeName})`, claudeBobEmitProblems(themeName)));
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
problems = problems.concat(ran('parity', 'Theme parity', themeParityProblems()));
|
|
199
|
+
problems = problems.concat(ran('colors', 'Colour themes', colorThemeProblems()));
|
|
200
|
+
problems = problems.concat(ran('authoring', 'Authoring gates', authoringProblems()));
|
|
201
|
+
problems = problems.concat(ran('shim', 'Hook shim', shimProbs));
|
|
202
|
+
problems = problems.concat(ran('map', 'Module map', moduleMapProblems()));
|
|
203
|
+
// P10c's `cli` check is GONE, and the reason is not that it stopped mattering. It hashed
|
|
204
|
+
// `rituals/harness.py` and compared that against a digest baked into `cli.json`, to catch a
|
|
205
|
+
// parser edited without regenerating the table. P2 made the table the OWNED document
|
|
206
|
+
// (`js/cli-table.json`), so there is no generator to fall behind and no second file to hash
|
|
207
|
+
// — the digest was a claim about a file this migration deletes. What replaces it is
|
|
208
|
+
// `tests/unit/cli_table.test.mjs`, which holds the owned table against the two entry points
|
|
209
|
+
// that dispatch from it: no second file to hash, and no generator that can fall behind.
|
|
210
|
+
if (!noBundle) {
|
|
211
|
+
// `Path(bundle).expanduser().resolve()`, which `resolvePath` already IS — the default is
|
|
212
|
+
// deliberately NOT resolved, because the Python's `ROOT / "Harness"` is not either and
|
|
213
|
+
// the resolved spelling is what the messages print.
|
|
214
|
+
const b = bundle ? resolvePath(bundle) : path.join(ROOT, 'Harness');
|
|
215
|
+
problems = problems.concat(ran('bundle', 'Committed bundle drift', renderedProblems(b)));
|
|
216
|
+
}
|
|
217
|
+
return [themes, sortedUnique(problems)];
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** `run(..., capture_output=True)` — both streams into the void, the return code out. */
|
|
221
|
+
function captureBoth(fn) {
|
|
222
|
+
const out = process.stdout.write.bind(process.stdout);
|
|
223
|
+
const err = process.stderr.write.bind(process.stderr);
|
|
224
|
+
process.stdout.write = () => true;
|
|
225
|
+
process.stderr.write = () => true;
|
|
226
|
+
try { return fn(); } finally { process.stdout.write = out; process.stderr.write = err; }
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* `_harness_build.cmd_doctor` — the CLI face.
|
|
231
|
+
*
|
|
232
|
+
* With `--theme`, one theme. With none it scopes to the INSTALLED theme, so a one-theme
|
|
233
|
+
* install is not buried under the same issue repeated across every theme; `--all` is the full
|
|
234
|
+
* maintainer sweep. The cross-theme parity check runs in every mode.
|
|
235
|
+
*/
|
|
236
|
+
export function cmdDoctor(args) {
|
|
237
|
+
const [themes, collected] = doctorCollect({
|
|
238
|
+
theme: args.theme, allThemes: Boolean(args.all), bundle: args.bundle,
|
|
239
|
+
noBundle: Boolean(args.noBundle),
|
|
240
|
+
});
|
|
241
|
+
if (!themes.length) {
|
|
242
|
+
printOut(`${collected.length ? collected[0] : '[doctor] no themes found'}\n`);
|
|
243
|
+
return 1;
|
|
244
|
+
}
|
|
245
|
+
// ⚠ NOTES ARE PRINTED AND NOT COUNTED. The only producer is the pack-off citation report in
|
|
246
|
+
// `constitutionProblems`, and the state it describes is one the install's owner chose: a
|
|
247
|
+
// build that leaves body prose citing a pack it did not render. Silence there is how prose
|
|
248
|
+
// and boundary drift apart, and a non-zero exit there is doctor failing a legal
|
|
249
|
+
// configuration. Nothing in the recorded CLI corpus produces one — the checkout's own
|
|
250
|
+
// `harness.config.json` names no packs — so the byte-compared output is unmoved.
|
|
251
|
+
const notes = collected.filter(isDoctorNote);
|
|
252
|
+
const problems = collected.filter((p) => !isDoctorNote(p));
|
|
253
|
+
const scoped = !args.theme && !args.all && themes.length === 1;
|
|
254
|
+
const note = scoped
|
|
255
|
+
? ` (scoped to installed theme '${themes[0]}'; run with --all to sweep every theme)` : '';
|
|
256
|
+
for (const n of notes) printOut(`${n}\n`);
|
|
257
|
+
if (problems.length) {
|
|
258
|
+
printOut(`[doctor] ${problems.length} problem(s) across ${themes.length} theme(s):\n`);
|
|
259
|
+
for (const p of problems) printOut(` - ${p}\n`);
|
|
260
|
+
if (problems.some((p) => p.includes('dead link'))) {
|
|
261
|
+
printOut(' tip: dead links to skills mean your source is incomplete — run '
|
|
262
|
+
+ '`./geneseed update` (or re-sync src/), then re-check.\n');
|
|
263
|
+
}
|
|
264
|
+
if (problems.some((p) => p.startsWith('[themes]') && p.includes('missing key'))) {
|
|
265
|
+
// ⚠ THE TIP USED TO NAME `./geneseed build --sync-themes`, AND THAT COMMAND ERRORS.
|
|
266
|
+
// It was argued here on the premise that "`build` forwards its extra arguments to the
|
|
267
|
+
// generator" — which was true of the reference and is not true of `cmdBuild`
|
|
268
|
+
// (`js/build/generate.mjs`), which forwards `--theme` and nothing else. So the front door
|
|
269
|
+
// answered `unrecognized arguments: --sync-themes` to anyone who followed doctor's own
|
|
270
|
+
// advice, for as long as the premise went unchecked. The flag belongs to the GENERATOR,
|
|
271
|
+
// whose binary is `geneseed-build` — which is what `README.md` and `SETUP.md` have said
|
|
272
|
+
// all along. A hint is a command; if it is not runnable it is decoration.
|
|
273
|
+
printOut(' tip: a theme is missing a key another theme defines — run '
|
|
274
|
+
+ '`geneseed-build --sync-themes` to fill it from _TEMPLATE.json, '
|
|
275
|
+
+ 'then restyle the added key(s) and re-check.\n');
|
|
276
|
+
}
|
|
277
|
+
if (note) printOut(`${note}\n`);
|
|
278
|
+
return 1;
|
|
279
|
+
}
|
|
280
|
+
printOut('[doctor] ok — ' + `${themes.length} theme(s) clean: no unresolved tokens, no dead `
|
|
281
|
+
+ 'links, nothing escapes the bundle; themes in parity; specs carry purpose '
|
|
282
|
+
+ 'lines; rendered bundle in sync\n');
|
|
283
|
+
if (note) printOut(`${note}\n`);
|
|
284
|
+
return 0;
|
|
285
|
+
}
|
|
286
|
+
|
|
@@ -1,35 +1,32 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* The sovereign-repo exclusion WRITER.
|
|
3
3
|
*
|
|
4
|
-
* THE READER WAS ALREADY HERE. `js/hooks.mjs` has carried `sovereignBypass`, `EXCLUDES_FILE`
|
|
4
|
+
* THE READER WAS ALREADY HERE. `js/hosts/hooks.mjs` has carried `sovereignBypass`, `EXCLUDES_FILE`
|
|
5
5
|
* and `EXCLUDE_DIRS` since P5a: every hook call reads `excludes.json` and stands down when
|
|
6
6
|
* cwd is under an excluded folder. This module is the other half — `harness exclude
|
|
7
7
|
* add|remove|list`, which maintains that file across every global install and wires the
|
|
8
8
|
* host-native per-repo suppression into the excluded repo.
|
|
9
9
|
*
|
|
10
|
-
* THE
|
|
11
|
-
* `
|
|
12
|
-
* `
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* it does NOT cover is two processes writing at the same instant — `_write_excludes` is
|
|
18
|
-
* temp-plus-rename on both sides, so a concurrent pair loses one edit rather than tearing
|
|
19
|
-
* the file, which is the same guarantee the single-writer version had against itself.
|
|
10
|
+
* THE WEB ENDPOINT IS THIS MODULE — there is exactly ONE writer. `js/web/actions.mjs`
|
|
11
|
+
* dispatches straight to `excludeAdd`/`excludeRemove` here, and `apiExcludes` in
|
|
12
|
+
* `js/web/api.mjs` is `excludesSnapshot()` unmodified, so the CLI and the console are one
|
|
13
|
+
* writer over one user-owned file. It used to be two, with a Python endpoint calling the
|
|
14
|
+
* same three names, and the surviving hazard is the one that outlived the split: two
|
|
15
|
+
* PROCESSES writing at the same instant. `writeExcludes` is temp-plus-rename, so a
|
|
16
|
+
* concurrent pair loses one edit rather than tearing the file.
|
|
20
17
|
*
|
|
21
18
|
* Every path a hook takes on this file is READ-only, so nothing here is on the PreToolUse
|
|
22
|
-
* latency budget — which is why it lives beside the CLI and not in `js/hooks.mjs`.
|
|
19
|
+
* latency budget — which is why it lives beside the CLI and not in `js/hosts/hooks.mjs`.
|
|
23
20
|
*/
|
|
24
21
|
import { existsSync, mkdirSync, readdirSync, renameSync, rmdirSync, statSync, unlinkSync }
|
|
25
22
|
from 'node:fs';
|
|
26
23
|
import path from 'node:path';
|
|
27
|
-
import { EXCLUDES_FILE, EXCLUDES_STUB, BOB_RULES_STUB } from '
|
|
28
|
-
import { wireClaudeExcludes, unwireClaudeExcludes } from '
|
|
29
|
-
import { GLOBAL_MANIFEST, HOSTS,
|
|
30
|
-
import {
|
|
31
|
-
|
|
32
|
-
} from '
|
|
24
|
+
import { EXCLUDES_FILE, EXCLUDES_STUB, BOB_RULES_STUB } from '../build/stubs.mjs';
|
|
25
|
+
import { wireClaudeExcludes, unwireClaudeExcludes } from '../hosts/settings.mjs';
|
|
26
|
+
import { GLOBAL_MANIFEST, HOSTS, resolvePath } from '../hosts/hosts.mjs';
|
|
27
|
+
import { readText, writeText, printOut, printErr } from '../lib/fs.mjs';
|
|
28
|
+
import { parseJson, jsonDumpsIndent } from '../lib/json.mjs';
|
|
29
|
+
import { normcase, toPlatformPath } from '../lib/paths.mjs';
|
|
33
30
|
|
|
34
31
|
const get = (o, k) => (o && Object.prototype.hasOwnProperty.call(o, k) ? o[k] : undefined);
|
|
35
32
|
const isDict = (v) => Boolean(v) && typeof v === 'object' && !Array.isArray(v);
|
|
@@ -78,19 +75,19 @@ function writeExcludes(cfg, data) {
|
|
|
78
75
|
}
|
|
79
76
|
|
|
80
77
|
/** `_canon` — `Path(p).expanduser().resolve()`. */
|
|
81
|
-
const canon = (p) =>
|
|
78
|
+
const canon = (p) => resolvePath(p);
|
|
82
79
|
|
|
83
80
|
/**
|
|
84
81
|
* `_same` — is this stored entry the same folder as `repo`?
|
|
85
82
|
*
|
|
86
|
-
* `
|
|
83
|
+
* `toPlatformPath` and not `path.normalize`: the latter collapses `a/../b` to `b`, and Python's
|
|
87
84
|
* `Path()` does not, so a hand-edited entry carrying `..` matches here and would not match
|
|
88
85
|
* there. Getting that backwards makes `exclude remove` unwire a repo the reference left
|
|
89
86
|
* alone.
|
|
90
87
|
*/
|
|
91
88
|
function same(stored, repo) {
|
|
92
89
|
if (typeof stored !== 'string' || !stored) return false;
|
|
93
|
-
return normcase(
|
|
90
|
+
return normcase(toPlatformPath(stored)) === normcase(repo);
|
|
94
91
|
}
|
|
95
92
|
|
|
96
93
|
/**
|
|
@@ -112,7 +109,7 @@ export function excludeAdd(target) {
|
|
|
112
109
|
const priorWired = (isDict(prior) ? get(prior, 'wired') : null) || {};
|
|
113
110
|
const wired = {};
|
|
114
111
|
if (host === 'claude' && isDir(repo)) {
|
|
115
|
-
const entry =
|
|
112
|
+
const entry = resolvePath(path.join(cfg, 'CLAUDE.md')).split(path.sep).join('/');
|
|
116
113
|
const added = wireClaudeExcludes(
|
|
117
114
|
path.join(repo, '.claude', 'settings.local.json'), [entry]);
|
|
118
115
|
if (added.length) {
|
|
@@ -200,7 +197,7 @@ export function excludeRemove(target) {
|
|
|
200
197
|
// Node's own wording. No cell can reach this branch (`unlink` on a file that
|
|
201
198
|
// `isFile` just confirmed, `rmdir` on a directory just measured empty), so a
|
|
202
199
|
// translation table here would be an unobservable guess. Same class as P5b's
|
|
203
|
-
// untested `die()` newline; `js/settings.mjs`'s `
|
|
200
|
+
// untested `die()` newline; `js/hosts/settings.mjs`'s `asOsError` is the same stub for
|
|
204
201
|
// the same reason.
|
|
205
202
|
messages.push(`${host}: could not remove ${stub} (${e.message})`);
|
|
206
203
|
}
|
|
@@ -213,8 +210,8 @@ export function excludeRemove(target) {
|
|
|
213
210
|
}
|
|
214
211
|
|
|
215
212
|
/**
|
|
216
|
-
* `excludes_snapshot` — the union view across installs, shared by the CLI `list` and
|
|
217
|
-
*
|
|
213
|
+
* `excludes_snapshot` — the union view across installs, shared by the CLI `list` and by the
|
|
214
|
+
* web endpoint (`apiExcludes` in `js/web/api.mjs` is this function, unmodified). Also flags installs whose lists diverge: a global emit made
|
|
218
215
|
* after the exclusions were added starts with an empty stub.
|
|
219
216
|
*/
|
|
220
217
|
export function excludesSnapshot() {
|
|
@@ -224,7 +221,7 @@ export function excludesSnapshot() {
|
|
|
224
221
|
for (const e of readExcludes(cfg).excludes) {
|
|
225
222
|
const p = isDict(e) ? get(e, 'path') : e;
|
|
226
223
|
if (typeof p !== 'string' || !p) continue;
|
|
227
|
-
const key = normcase(
|
|
224
|
+
const key = normcase(toPlatformPath(p));
|
|
228
225
|
if (!byPath.has(key)) {
|
|
229
226
|
byPath.set(key, { path: p, hosts: [],
|
|
230
227
|
wired: (isDict(e) ? get(e, 'wired') : null) || {} });
|
|
@@ -242,11 +239,11 @@ export function cmdExclude(args) {
|
|
|
242
239
|
if (args.action === 'list') {
|
|
243
240
|
const snap = excludesSnapshot();
|
|
244
241
|
if (!snap.installs.length) {
|
|
245
|
-
|
|
242
|
+
printOut('[geneseed] no global install found.\n');
|
|
246
243
|
return 1;
|
|
247
244
|
}
|
|
248
245
|
if (!snap.excludes.length) {
|
|
249
|
-
|
|
246
|
+
printOut('[geneseed] no folders excluded.\n');
|
|
250
247
|
return 0;
|
|
251
248
|
}
|
|
252
249
|
const allHosts = new Set(snap.installs.map((i) => i.host));
|
|
@@ -254,18 +251,18 @@ export function cmdExclude(args) {
|
|
|
254
251
|
const missing = [...allHosts].filter((h) => !rec.hosts.includes(h)).sort();
|
|
255
252
|
const flag = missing.length
|
|
256
253
|
? ` (MISSING from: ${missing.join(', ')} — re-run \`harness exclude add\`)` : '';
|
|
257
|
-
|
|
254
|
+
printOut(` ${rec.path} [${rec.hosts.slice().sort().join(', ')}]${flag}\n`);
|
|
258
255
|
}
|
|
259
256
|
return 0;
|
|
260
257
|
}
|
|
261
258
|
if (!args.path) {
|
|
262
|
-
|
|
259
|
+
printErr('[geneseed] exclude add/remove needs a folder path.\n');
|
|
263
260
|
return 2;
|
|
264
261
|
}
|
|
265
262
|
const res = args.action === 'add' ? excludeAdd(args.path) : excludeRemove(args.path);
|
|
266
|
-
for (const m of res.messages)
|
|
263
|
+
for (const m of res.messages) printErr(`[geneseed] ${m}\n`);
|
|
267
264
|
const verb = args.action === 'add' ? 'excluded' : 're-included';
|
|
268
|
-
|
|
265
|
+
printOut(res.ok ? `[geneseed] ${res.path} ${verb}.\n`
|
|
269
266
|
: `[geneseed] nothing done for ${res.path}.\n`);
|
|
270
267
|
return res.ok ? 0 : 1;
|
|
271
268
|
}
|