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.
Files changed (102) hide show
  1. package/CHANGELOG.md +110 -0
  2. package/DESIGN.md +31 -20
  3. package/README.md +2 -2
  4. package/SETUP.md +30 -3
  5. package/SHIPPED.md +1 -1
  6. package/bin/{geneseed.mjs → build-driver.mjs} +88 -42
  7. package/bin/geneseed-cli.mjs +33 -32
  8. package/bin/geneseed-hook.mjs +7 -7
  9. package/docs/design-history.md +40 -40
  10. package/docs/extending.md +113 -85
  11. package/docs/web/rules.md +2 -0
  12. package/docs/web/setup-choices.md +13 -1
  13. package/harness.config.json +1 -1
  14. package/js/README.md +203 -0
  15. package/js/build/bundle.mjs +231 -0
  16. package/js/{catalog.mjs → build/catalog.mjs} +7 -7
  17. package/js/build/emit-claude.mjs +287 -0
  18. package/js/build/emit-common.mjs +255 -0
  19. package/js/build/emit-opencode.mjs +214 -0
  20. package/js/{generate.mjs → build/generate.mjs} +45 -31
  21. package/js/{render.mjs → build/render.mjs} +72 -11
  22. package/js/{checkout.mjs → build/source.mjs} +52 -11
  23. package/js/build/stubs.mjs +256 -0
  24. package/js/{themes.mjs → build/themes.mjs} +24 -24
  25. package/js/build/version.mjs +155 -0
  26. package/js/{hooks.mjs → hosts/hooks.mjs} +47 -44
  27. package/js/{hosts.mjs → hosts/hosts.mjs} +30 -28
  28. package/js/{installs.mjs → hosts/installs.mjs} +68 -16
  29. package/js/{link.mjs → hosts/link.mjs} +29 -28
  30. package/js/{mcp.mjs → hosts/mcp.mjs} +10 -9
  31. package/js/{native.mjs → hosts/native.mjs} +24 -22
  32. package/js/{opencode.mjs → hosts/opencode.mjs} +12 -7
  33. package/js/{settings.mjs → hosts/settings.mjs} +94 -69
  34. package/js/inspect/checks-authoring.mjs +761 -0
  35. package/js/inspect/checks-build.mjs +231 -0
  36. package/js/inspect/checks-repo.mjs +308 -0
  37. package/js/{diff.mjs → inspect/diff.mjs} +34 -31
  38. package/js/inspect/doctor.mjs +286 -0
  39. package/js/{excludes.mjs → inspect/excludes.mjs} +30 -33
  40. package/js/{inventory.mjs → inspect/inventory.mjs} +34 -20
  41. package/js/{registry.mjs → inspect/registry.mjs} +12 -10
  42. package/js/inspect/scan.mjs +125 -0
  43. package/js/{status.mjs → inspect/status.mjs} +27 -25
  44. package/js/inspect/validate.mjs +199 -0
  45. package/js/lib/fs.mjs +37 -759
  46. package/js/lib/json.mjs +344 -0
  47. package/js/lib/paths.mjs +242 -0
  48. package/js/lib/proc.mjs +3 -3
  49. package/js/lib/text.mjs +181 -0
  50. package/js/lib/udiff.mjs +1 -1
  51. package/js/{memory.mjs → maintain/memory.mjs} +10 -10
  52. package/js/{migrate.mjs → maintain/migrate.mjs} +28 -26
  53. package/js/{setup.mjs → maintain/setup.mjs} +49 -37
  54. package/js/{uninstall.mjs → maintain/uninstall.mjs} +62 -58
  55. package/js/{update.mjs → maintain/update.mjs} +51 -51
  56. package/js/{anim.mjs → ui/anim.mjs} +30 -28
  57. package/js/{cli.mjs → ui/cli.mjs} +10 -10
  58. package/js/{menu.mjs → ui/menu.mjs} +10 -9
  59. package/js/{tui.mjs → ui/tui.mjs} +36 -34
  60. package/js/web/actions.mjs +124 -89
  61. package/js/web/activity.mjs +22 -21
  62. package/js/web/api.mjs +88 -58
  63. package/js/web/daemon.mjs +319 -0
  64. package/js/web/docs.mjs +24 -21
  65. package/js/web/graph.mjs +0 -0
  66. package/js/web/handler.mjs +442 -0
  67. package/js/web/jobs.mjs +29 -21
  68. package/js/web/routes.mjs +223 -0
  69. package/js/web/server.mjs +56 -987
  70. package/package.json +11 -3
  71. package/src/AGENT.md.tmpl +1 -1
  72. package/src/doctrines/README.md +1 -1
  73. package/themes/opencode/README.md +1 -1
  74. package/web/dist/assets/{About-_MnJcwb_.js → About-CPvIFZGh.js} +1 -1
  75. package/web/dist/assets/{Activity-DpjqO-FR.js → Activity-z0uXdS7J.js} +1 -1
  76. package/web/dist/assets/{ActivityDetail-Bvrjv8oW.js → ActivityDetail-CEj5IJpF.js} +1 -1
  77. package/web/dist/assets/{Diff-DZ0IehBV.js → Diff-BMR8fPuG.js} +1 -1
  78. package/web/dist/assets/{Doctor-cu4krooO.js → Doctor-Bz5qE4r9.js} +1 -1
  79. package/web/dist/assets/{Graph-DUOPY65j.js → Graph-BV6a6t2l.js} +1 -1
  80. package/web/dist/assets/{Harnesses-B5Osg-BF.js → Harnesses-C5Ag5PaQ.js} +1 -1
  81. package/web/dist/assets/Laws-BK07NKw9.js +3 -0
  82. package/web/dist/assets/{Library-8y69I3_z.js → Library-CiXN9sC3.js} +1 -1
  83. package/web/dist/assets/{Markdown-Ds5GS1OD.js → Markdown-DG6FjUz-.js} +1 -1
  84. package/web/dist/assets/{Profile-7Jxa7ZUX.js → Profile-B2oSvdxH.js} +1 -1
  85. package/web/dist/assets/{Rules-UZ0jWkjg.js → Rules-DobVPd80.js} +1 -1
  86. package/web/dist/assets/{Skills-Bgkl0xLd.js → Skills-DQPsvsn9.js} +1 -1
  87. package/web/dist/assets/{StatusBadge-Cx4caF_2.js → StatusBadge-BhJ3HhdI.js} +1 -1
  88. package/web/dist/assets/{Themes-De8az7Sv.js → Themes-BP38FBU9.js} +1 -1
  89. package/web/dist/assets/{activity-VDAdVQ3q.js → activity-D9mfv7pY.js} +1 -1
  90. package/web/dist/assets/index-BE226Gv6.css +1 -0
  91. package/web/dist/assets/{index-BKrjBRV4.js → index-BZ7cPLMI.js} +1 -1
  92. package/web/dist/assets/index-BgIs5eIa.js +12 -0
  93. package/web/dist/assets/{index-wNiNzFFQ.js → index-DGM5j2hB.js} +1 -1
  94. package/web/dist/index.html +2 -2
  95. package/web/src/pages/Laws.jsx +135 -69
  96. package/docs/declined.md +0 -200
  97. package/docs/limits.md +0 -329
  98. package/js/doctor.mjs +0 -1712
  99. package/js/emit.mjs +0 -1410
  100. package/web/dist/assets/Laws-D8DJgTBp.js +0 -3
  101. package/web/dist/assets/index-1pPSz7Kl.css +0 -1
  102. package/web/dist/assets/index-C-gb-q-r.js +0 -12
@@ -0,0 +1,231 @@
1
+ /**
2
+ * The checks over what the BUILD produced — rendered bytes, themes, colours, links.
3
+ *
4
+ * Every export here answers with an array of problem strings, empty when it finds nothing.
5
+ * That shape is the whole contract: `doctorCollect` in `doctor.mjs` concatenates them and
6
+ * a clean run is byte-identical whichever check is missing, which is why each one is gated
7
+ * by a PLANTED FAULT in `tests/unit/harness.test.mjs` rather than by a comparison.
8
+ */
9
+ import path from 'node:path';
10
+ import { stripCapabilityLinks } from '../build/emit-common.mjs';
11
+ import { STRUCTURE, renderAll } from '../build/render.mjs';
12
+ import { CONFIG, makeCfg } from '../build/source.mjs';
13
+ import { hostCatalogsNatively, resolvePath } from '../hosts/hosts.mjs';
14
+ import { EMIT_HOST_SCOPE, footprintOfDir, themeFiles } from '../hosts/installs.mjs';
15
+ import { isVendoredPath } from '../hosts/native.mjs';
16
+ import { PALETTE_ROLES, colorThemeFiles } from '../hosts/opencode.mjs';
17
+ import { readText, withDiscardableStderr } from '../lib/fs.mjs';
18
+ import { parseJson, formatRepr } from '../lib/json.mjs';
19
+ import {
20
+ ABS_LINK_RE, LINK_RE, TOKEN_RE, has, isDir, isFile, rglob, stemOf, stripCode, within,
21
+ } from './scan.mjs';
22
+ import { existsSync, readFileSync } from 'node:fs';
23
+
24
+ // --------------------------------------------------------------------------------------
25
+ // the checks
26
+ // --------------------------------------------------------------------------------------
27
+
28
+ /**
29
+ * `_harness_build._link_problems` — dead links AND non-hermetic ones.
30
+ *
31
+ * Hermeticity is what lets a bundle be copied or subtree-split into any repo; a link that
32
+ * escapes `out` breaks it silently. The two are separate messages because they have separate
33
+ * causes, and a target that merely does not exist is the DEAD one — the absolute check runs
34
+ * first and returns, so `~/x.md` never reaches the filesystem at all.
35
+ */
36
+ export function linkProblems(md, text, out, rel) {
37
+ const problems = [];
38
+ for (const m of stripCode(text).matchAll(LINK_RE)) {
39
+ const link = m[1];
40
+ const raw = link.split('#')[0].trim();
41
+ if (!raw) continue;
42
+ if (ABS_LINK_RE.test(raw)) {
43
+ problems.push(`non-hermetic absolute link '${link}' in ${rel}`);
44
+ continue;
45
+ }
46
+ // `(md.parent / raw).resolve()` — `resolvePath`, not `path.resolve`, because Python's
47
+ // canonicalises the symlinks in the part that exists and this comparison is against an
48
+ // `out` that went through the same call.
49
+ const target = resolvePath(path.join(path.dirname(md), raw));
50
+ if (!existsSync(target)) problems.push(`dead link '${link}' in ${rel}`);
51
+ else if (!within(target, out)) {
52
+ problems.push(`non-hermetic link '${link}' escapes the bundle in ${rel}`);
53
+ }
54
+ }
55
+ return problems;
56
+ }
57
+
58
+ /**
59
+ * `_harness_build._check_build` — scan one rendered tree for unresolved tokens and bad links.
60
+ *
61
+ * `isVendored` decides which relative shape counts as a vendored folder, and the two callers
62
+ * pass different ones ON PURPOSE: a `files`/opencode-global bundle nests skills at
63
+ * `skills/<name>/…` and a per-repo native layer one level deeper at `.claude/skills/<name>/…`.
64
+ * Vendored folders are verbatim upstream docs carrying their own license, and their internal
65
+ * cross-links point at the upstream project's own files, so they are exempt.
66
+ *
67
+ * `rel` is `str(Path)`, i.e. the platform's separator, because that is what the Python prints
68
+ * — every emit-scan cell in the acceptance matrix names a backslash on Windows.
69
+ */
70
+ export function checkBuild(themeName, out, isVendored = isVendoredPath) {
71
+ const outAbs = resolvePath(out);
72
+ const problems = [];
73
+ for (const md of rglob(outAbs)) {
74
+ if (!md.endsWith('.md') || !isFile(md)) continue;
75
+ const rel = path.relative(outAbs, md);
76
+ if (isVendored(rel)) continue;
77
+ const text = readText(md);
78
+ // `set(TOKEN_RE.findall(text))` — Python iterates the set in ITS order, and the whole
79
+ // list is sorted by `_doctor_collect` before printing, so only uniqueness travels.
80
+ for (const tok of new Set(text.match(TOKEN_RE) ?? [])) {
81
+ problems.push(`[${themeName}] unresolved token ${tok} in ${rel}`);
82
+ }
83
+ for (const p of linkProblems(md, text, outAbs, rel)) problems.push(`[${themeName}] ${p}`);
84
+ }
85
+ return problems;
86
+ }
87
+
88
+ /**
89
+ * `_harness_build._theme_parity_problems` — every theme defines the same VOICE keys.
90
+ *
91
+ * A token present in one theme map and missing from another renders as a raw `{{TOKEN}}`
92
+ * only in the files that use it and only under that theme, so a plain build can miss it
93
+ * entirely. The maps are compared directly, in every mode, whatever `--theme` scoped the rest
94
+ * of the run to.
95
+ */
96
+ export function themeParityProblems() {
97
+ const themes = new Map();
98
+ for (const p of themeFiles()) {
99
+ let doc;
100
+ try { doc = JSON.parse(readText(p)); } catch (e) {
101
+ return [`[themes] ${path.basename(p)} unreadable: ${e.message}`];
102
+ }
103
+ themes.set(stemOf(p), doc);
104
+ }
105
+ if (themes.size < 2) return [];
106
+ const allKeys = new Set();
107
+ for (const t of themes.values()) for (const k of Object.keys(t)) allKeys.add(k);
108
+ const problems = [];
109
+ for (const [name, t] of themes) {
110
+ for (const k of [...allKeys].filter((x) => !has(t, x)).sort()) {
111
+ problems.push(`[themes] '${name}' missing key {${k}} (defined in another theme)`);
112
+ }
113
+ }
114
+ return problems;
115
+ }
116
+
117
+ /** `_harness_build._HEX_RE`. Anchored at BOTH ends, so `#12345` and `#1122334` both fail. */
118
+ const HEX_RE = /^#[0-9a-fA-F]{6}$/;
119
+
120
+ /**
121
+ * `_harness_build._color_theme_problems` — every curated colour theme carries the full
122
+ * palette, in 6-digit hex. Voice-theme parity, for colours.
123
+ *
124
+ * TWO OF ITS THREE ARMS ARE UNREACHABLE FROM ANY CELL, and it is doctor's own ordering that
125
+ * makes them so: the opencode-global emit reads the same files (`writeColorThemes` ->
126
+ * `palette[role]`) and runs BEFORE this check, so a palette that is missing a role, or
127
+ * missing entirely, kills the emit rather than reaching here. The consumer is stricter than
128
+ * the gate. Only the third arm — a value present and not `#rrggbb` — passes the emit through
129
+ * to be reported.
130
+ */
131
+ export function colorThemeProblems() {
132
+ const problems = [];
133
+ for (const p of colorThemeFiles(makeCfg())) {
134
+ let spec;
135
+ try { spec = JSON.parse(readText(p)); } catch (e) {
136
+ problems.push(`[colors] ${path.basename(p)} unreadable: ${e.message}`);
137
+ continue;
138
+ }
139
+ const pal = spec.palette;
140
+ if (pal === null || typeof pal !== 'object' || Array.isArray(pal)) {
141
+ problems.push(`[colors] ${path.basename(p)} has no 'palette' object`);
142
+ continue;
143
+ }
144
+ for (const role of [...PALETTE_ROLES].filter((r) => !has(pal, r)).sort()) {
145
+ problems.push(`[colors] '${stemOf(p)}' palette missing role '${role}'`);
146
+ }
147
+ for (const [role, val] of Object.entries(pal)) {
148
+ if (!(typeof val === 'string' && HEX_RE.test(val))) {
149
+ problems.push(`[colors] '${stemOf(p)}' role '${role}' is not #rrggbb hex: ${formatRepr(val)}`);
150
+ }
151
+ }
152
+ }
153
+ return problems;
154
+ }
155
+
156
+ /**
157
+ * `_harness_build._rendered_problems` — a committed bundle must match a fresh render of
158
+ * `src/` for its OWN recorded theme, emit and footprint.
159
+ *
160
+ * Doctor's temp builds never touch the committed bundle, so drift there is invisible to
161
+ * every other check. All three values are read back off the bundle's own markers rather than
162
+ * assumed, and each for the same reason: assuming one reports every file as drifted the
163
+ * moment that default moves, which is a diagnosis about this function rather than about the
164
+ * bundle. The emit marker is the one that took longest to learn — a host that catalogues
165
+ * capabilities to the model itself gets AGENT.md's tables collapsed to a pointer, and the
166
+ * OpenCode emits additionally strip the per-row spec links, so rendering the portable shape
167
+ * and comparing it against an OpenCode bundle reports AGENT.md stale on EVERY run with no
168
+ * rebuild able to clear it.
169
+ *
170
+ * Host-state files (context.json, MEMORY.md, the markers) are created once and never
171
+ * rendered, so they are not in the render set and are correctly ignored. Notebook files
172
+ * except its `.gitignore` are seed-once and agent-owned after the first build, so they are
173
+ * compared only for EXISTENCE.
174
+ */
175
+ export function renderedProblems(bundle) {
176
+ if (!isDir(bundle)) return [];
177
+ const cfg = makeCfg();
178
+ const marker = path.join(bundle, '.geneseed-theme');
179
+ let themeName;
180
+ if (existsSync(marker)) themeName = readText(marker).trim();
181
+ else if (existsSync(CONFIG)) themeName = parseJson(readText(CONFIG))?.theme ?? 'neutral';
182
+ else themeName = 'neutral';
183
+ let emit;
184
+ try { emit = readText(path.join(bundle, '.geneseed-emit')).trim(); } catch { emit = ''; }
185
+ const host = (EMIT_HOST_SCOPE.get(emit) ?? ['', ''])[0];
186
+ let items;
187
+ try {
188
+ // THE FIRST CALLER IN THE PORT THAT CATCHES A REFUSAL, and that is why the stderr is
189
+ // buffered. `sys.exit(msg)` attaches the message to the EXCEPTION and the interpreter
190
+ // prints it on the way out, so a Python caller that catches SystemExit sees no output at
191
+ // all; this port's convention is to write at the raise site (`loadTheme`, and its
192
+ // docblock says why), which is identical for every caller that lets the throw propagate
193
+ // and wrong for exactly this one. Buffered and replayed on success, so a WARN the render
194
+ // legitimately emits still reaches the user, and discarded with the refusal it belongs to.
195
+ ({ items } = withDiscardableStderr(() => renderAll(cfg, themeName, {
196
+ footprint: footprintOfDir(bundle), nativeCatalog: hostCatalogsNatively(host),
197
+ })));
198
+ } catch (e) {
199
+ // `except SystemExit` — `effectiveTheme` refuses an unknown theme, and the driver's
200
+ // marker for a deliberate refusal is the `exitCode` this port already uses everywhere.
201
+ if (e && e.exitCode !== undefined) {
202
+ return [`[rendered] cannot render theme '${themeName}' for ${path.basename(bundle)}/`];
203
+ }
204
+ throw e;
205
+ }
206
+ const problems = [];
207
+ const nbDirname = STRUCTURE.DIR_NOTEBOOK ?? 'notebook';
208
+ for (const { rel: outRel, text: rendered, src } of items) {
209
+ const dest = path.join(bundle, outRel);
210
+ const parts = outRel.split('/');
211
+ const name = path.basename(bundle);
212
+ if (!existsSync(dest)) {
213
+ problems.push(`[rendered] ${name}/${outRel} missing — rebuild the bundle`);
214
+ } else if (parts[0] === nbDirname && parts[parts.length - 1] !== '.gitignore') {
215
+ continue; // seed-once, agent-owned: a rewrite is not drift
216
+ } else if (rendered !== null) {
217
+ // The OpenCode emits de-link AGENT.md's per-row table entries after rendering, so the
218
+ // bundle on disk legitimately differs from `renderAll`'s output for this one file.
219
+ const text = host === 'opencode' && outRel === 'AGENT.md'
220
+ ? stripCapabilityLinks(cfg, rendered) : rendered;
221
+ if (readText(dest) !== text) {
222
+ problems.push(`[rendered] ${name}/${outRel} stale (differs from a fresh render) `
223
+ + '— rebuild');
224
+ }
225
+ } else if (!readFileSync(dest).equals(readFileSync(src))) {
226
+ problems.push(`[rendered] ${name}/${outRel} stale — rebuild`);
227
+ }
228
+ }
229
+ return problems;
230
+ }
231
+
@@ -0,0 +1,308 @@
1
+ /**
2
+ * The checks over the REPO's own records — the registry, committed secrets, the hook shim
3
+ * and the vendored-skill pins.
4
+ *
5
+ * Same contract as `checks-build.mjs`: an array of problem strings, empty when clean, one
6
+ * planted fault per check in the gate. This is the group that reads what the repo SAYS about
7
+ * itself and compares it against what is on disk.
8
+ */
9
+ import path from 'node:path';
10
+ import { PLUGIN_SRC, ROOT, SRC, THEMES, WORKFLOW_SRC } from '../build/source.mjs';
11
+ import { VENDORED_SKILL_DIRS } from '../hosts/native.mjs';
12
+ import { hookShimPath, shimDeadPaths } from '../hosts/settings.mjs';
13
+ import { readText } from '../lib/fs.mjs';
14
+ import { formatRepr, formatValue } from '../lib/json.mjs';
15
+ import { splitLines } from '../lib/udiff.mjs';
16
+ import { ENTITY_STATUSES } from './inventory.mjs';
17
+ import { isDir, isFile, rglob, srcStems } from './scan.mjs';
18
+ import { readFileSync } from 'node:fs';
19
+
20
+ /**
21
+ * `_harness_build._registry_keys` — every entity the registry must describe.
22
+ *
23
+ * The flat agent and skill specs plus the vendored skill FOLDERS, which have no flat spec and
24
+ * are still shipped capabilities. Keyed `<folder>/<stem>`, the shape doctor already prints.
25
+ */
26
+ function registryKeys() {
27
+ const keys = new Set();
28
+ for (const s of srcStems('agents')) keys.add(`agents/${s}`);
29
+ for (const s of srcStems('skills')) keys.add(`skills/${s}`);
30
+ for (const d of VENDORED_SKILL_DIRS) keys.add(`skills/${d}`);
31
+ return keys;
32
+ }
33
+
34
+ const SEMVER_RE = /^\d+\.\d+\.\d+$/;
35
+ const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
36
+
37
+ // `ENTITY_STATUSES`, `LAW_CLASSES`, `LAW_CLASS` and `SKILL_CLASS` moved to
38
+ // `js/inspect/inventory.mjs` in P6c — see the import above. They crossed HERE in P5g because
39
+ // doctor's authoring gates are what validate them, and they belong where Python keeps
40
+ // them (`_harness_tui`) now that the catalog reads them too. The gates below are
41
+ // unchanged, which is what licensed the move: a copy would have stopped being the
42
+ // value under test the first time one of them was edited.
43
+
44
+ /**
45
+ * `_harness_build._registry_problems` — `registry.json` describes exactly what `src/`
46
+ * provides, with well-formed fields.
47
+ *
48
+ * Two-way, like every anti-drift gate here: a new spec with no row and a row whose spec is
49
+ * gone are both errors. Without the second half a shipped skill would show no lifecycle
50
+ * status in the TUI and web catalogs while doctor stayed green. `last_verified` may be empty
51
+ * — nothing writes it yet — but when set it must be a date.
52
+ */
53
+ export function registryProblems() {
54
+ let doc;
55
+ const file = path.join(ROOT, 'registry.json');
56
+ let raw;
57
+ try { raw = readText(file); } catch (e) {
58
+ return [`[authoring] registry.json unreadable: ${e.message}`];
59
+ }
60
+ try { doc = JSON.parse(raw); } catch (e) {
61
+ return [`[authoring] registry.json is not valid JSON: ${e.message}`];
62
+ }
63
+ const entities = doc && typeof doc === 'object' && !Array.isArray(doc) ? doc.entities : null;
64
+ if (entities === null || typeof entities !== 'object' || Array.isArray(entities)) {
65
+ return ["[authoring] registry.json has no 'entities' object"];
66
+ }
67
+ const expected = registryKeys();
68
+ const present = new Set(Object.keys(entities));
69
+ const problems = [...expected].filter((k) => !present.has(k)).sort()
70
+ .map((key) => `[authoring] ${key} has no row in registry.json`);
71
+ problems.push(...[...present].filter((k) => !expected.has(k)).sort()
72
+ .map((key) => `[authoring] registry.json lists '${key}' but no such entity exists`));
73
+ for (const key of [...expected].filter((k) => present.has(k)).sort()) {
74
+ const row = entities[key];
75
+ if (row === null || typeof row !== 'object' || Array.isArray(row)) {
76
+ problems.push(`[authoring] registry.json['${key}'] is not an object`);
77
+ continue;
78
+ }
79
+ // `row.get(...)` is None for an absent key, and `!r` renders that as `None`; a bare JS
80
+ // lookup is `undefined`, which `formatRepr` would not recognise as one.
81
+ if (!ENTITY_STATUSES.includes(row.status)) {
82
+ problems.push(`[authoring] registry.json['${key}'].status ${formatRepr(row.status ?? null)} `
83
+ + `is not one of ${formatRepr(ENTITY_STATUSES)}`);
84
+ }
85
+ if (!SEMVER_RE.test(formatValue(row.version ?? ''))) {
86
+ problems.push(`[authoring] registry.json['${key}'].version `
87
+ + `${formatRepr(row.version ?? null)} is not a semver (N.N.N)`);
88
+ }
89
+ if (!formatValue(row.owner ?? '').trim()) {
90
+ problems.push(`[authoring] registry.json['${key}'] has no owner`);
91
+ }
92
+ for (const field of ['added', 'last_verified']) {
93
+ const value = formatValue(row[field] ?? '');
94
+ if (value && !ISO_DATE_RE.test(value)) {
95
+ problems.push(`[authoring] registry.json['${key}'].${field} ${formatRepr(value)} is `
96
+ + 'not an ISO date (YYYY-MM-DD) or empty');
97
+ }
98
+ }
99
+ }
100
+ return problems;
101
+ }
102
+
103
+ /**
104
+ * `_harness_build._SECRET_PATTERNS` — credential shapes, by the prefix each issuer stamps,
105
+ * plus the generic assignment. Deliberately narrow: a gate that cries wolf gets disabled.
106
+ *
107
+ * `(?i)` becomes the `i` flag on the last one only, which is where the Python puts it.
108
+ */
109
+ const SECRET_PATTERNS = [
110
+ ['Anthropic API key', /sk-ant-[A-Za-z0-9_-]{20,}/],
111
+ ['OpenAI-style API key', /\bsk-[A-Za-z0-9]{32,}/],
112
+ ['GitHub token', /\bgh[pousr]_[A-Za-z0-9]{36}\b/],
113
+ ['AWS access key id', /\bAKIA[0-9A-Z]{16}\b/],
114
+ ['Slack token', /\bxox[abprs]-[A-Za-z0-9-]{10,}/],
115
+ ['private key block', /-----BEGIN [A-Z ]*PRIVATE KEY-----/],
116
+ ['assigned credential',
117
+ /\b(password|passwd|secret|api[_-]?key|access[_-]?token)\s*[:=]\s*["'][^"'\s]{8,}["']/i],
118
+ ];
119
+
120
+ /**
121
+ * `_harness_build._secret_problems` — no credential may ship.
122
+ *
123
+ * Sweeps the four trees `sourceFingerprint()` hashes, which is exactly the material that ends
124
+ * up inside a user's bundle. Reports file:line and the KIND only — echoing the matched text
125
+ * would republish the secret into every CI log.
126
+ */
127
+ export function secretProblems() {
128
+ const problems = [];
129
+ for (const root of [SRC, THEMES, PLUGIN_SRC, WORKFLOW_SRC]) {
130
+ if (!isDir(root)) continue;
131
+ for (const p of rglob(root)) {
132
+ if (!isFile(p) || p.split(path.sep).includes('__pycache__')) continue;
133
+ // `except (OSError, UnicodeDecodeError): continue` — binary or unreadable, nothing to
134
+ // scan. `readText` would substitute U+FFFD where Python RAISES, so the strict decode is
135
+ // the check rather than a catch, and the newline folding `Path.read_text` does has to
136
+ // be applied by hand afterwards for the LINE NUMBERS to agree.
137
+ let raw;
138
+ try { raw = readFileSync(p); } catch { continue; }
139
+ let s;
140
+ try { s = new TextDecoder('utf-8', { fatal: true }).decode(raw); } catch { continue; }
141
+ s = s.replaceAll('\r\n', '\n').replaceAll('\r', '\n');
142
+ // `path.relative` returns a `..`-prefixed path where `relative_to` RAISES; the fallback
143
+ // is the Python's, for a tree outside the repo (a test tmpdir).
144
+ const r = path.relative(ROOT, p);
145
+ const rel = (r.startsWith('..') || path.isAbsolute(r) ? p : r)
146
+ .split(path.sep).join('/');
147
+ // `str.splitlines()`, not `split('\n')`: it also breaks on VT, FF, NEL and the two
148
+ // Unicode separators, and the line NUMBER is printed.
149
+ splitLines(s).forEach((line, i) => {
150
+ for (const [label, pattern] of SECRET_PATTERNS) {
151
+ if (pattern.test(line)) {
152
+ problems.push(`[authoring] possible ${label} in ${rel}:${i + 1} — a `
153
+ + 'credential must never be committed');
154
+ }
155
+ }
156
+ });
157
+ }
158
+ }
159
+ return problems;
160
+ }
161
+
162
+ /**
163
+ * `_harness_build._shim_problems` — the hook shim exists and points at something real.
164
+ *
165
+ * This gate exists because the shim DISARMED an accidental safety net. When the emitted hook
166
+ * command still carried the checkout path, moving the checkout made that command
167
+ * non-canonical and the next emit's prune-and-rewire repaired every install by itself. Now
168
+ * the config is invariant under a move and the stale path lives in the shim body, which no
169
+ * other gate reads (the build scan only walks `*.md`). Without this a moved checkout leaves
170
+ * every gate silently dead: the hooks still fire, the shim still runs, and the interpreter
171
+ * reports "no such file" into a channel nobody reads.
172
+ *
173
+ * An ABSENT shim is not a problem: a source checkout that has never emitted has no reason to
174
+ * own one.
175
+ */
176
+ export function shimProblems() {
177
+ const p = hookShimPath();
178
+ if (!isFile(p)) return [];
179
+ let body;
180
+ try { body = readText(p); } catch (e) {
181
+ return [`[shim] ${p} exists but cannot be read (${e.message}) — hooks may be dead`];
182
+ }
183
+ // The body is machine-generated and quotes the two baked paths — plus, on POSIX, the argv
184
+ // placeholder `"$@"`, which is not a path. `shimDeadPaths` is the single owner of that rule;
185
+ // `hookPrefix` is its other reader, and the two must not drift.
186
+ return shimDeadPaths(body).map(
187
+ (m) => `[shim] ${p} pointed at ${m}, which does not exist — every hook in every `
188
+ + "install was dead (the checkout most likely moved). This run's own "
189
+ + 'emit has refreshed it; no further action needed.',
190
+ );
191
+ }
192
+
193
+ const HEX40_RE = /^[0-9a-f]{40}$/;
194
+ /** Refs that move under the pin — re-copying months later changes the shipped skill. */
195
+ const MOVING_REFS = new Set(['main', 'master', 'develop', 'dev', 'head', 'latest', 'trunk']);
196
+
197
+ /**
198
+ * `_harness_build._vendor_pin_problems` — every vendored skill folder records where it came
199
+ * from, under which license, and at WHICH immutable commit.
200
+ *
201
+ * First-party folders that merely ride the vendored mechanism for their multi-file layout
202
+ * declare that instead (`**Upstream:** this …`) and are exempt from the pin: there is no
203
+ * upstream to drift against.
204
+ */
205
+ export function vendorPinProblems() {
206
+ const problems = [];
207
+ for (const name of VENDORED_SKILL_DIRS) {
208
+ const folder = path.join(SRC, 'skills', name);
209
+ if (!isDir(folder)) {
210
+ problems.push(`[authoring] VENDORED_SKILL_DIRS lists '${name}' but `
211
+ + `src/skills/${name}/ does not exist`);
212
+ continue;
213
+ }
214
+ let text;
215
+ try { text = readText(path.join(folder, 'VENDOR.md')); } catch {
216
+ problems.push(`[authoring] skills/${name}/ has no VENDOR.md — its upstream, `
217
+ + 'pinned commit and license are unrecorded');
218
+ continue;
219
+ }
220
+ if (!/\*\*License:\*\*/.test(text)) {
221
+ problems.push(`[authoring] skills/${name}/VENDOR.md records no '**License:**'`);
222
+ }
223
+ const upstream = /\*\*Upstream:\*\*\s*(\S+)/.exec(text);
224
+ if (!upstream) {
225
+ problems.push(`[authoring] skills/${name}/VENDOR.md declares no '**Upstream:**'`);
226
+ continue;
227
+ }
228
+ if (upstream[1].toLowerCase().startsWith('this')) continue; // first-party
229
+ const pin = /\*\*Commit:\*\*\s*(\S+)/.exec(text);
230
+ if (!pin) {
231
+ problems.push(`[authoring] skills/${name}/VENDOR.md has no '**Commit:**' pin`);
232
+ } else if (MOVING_REFS.has(pin[1].toLowerCase())) {
233
+ problems.push(`[authoring] skills/${name}/VENDOR.md pins '${pin[1]}', a `
234
+ + 'moving branch — record the commit sha instead');
235
+ } else if (!HEX40_RE.test(pin[1].toLowerCase())) {
236
+ problems.push(`[authoring] skills/${name}/VENDOR.md pin '${pin[1]}' is not `
237
+ + 'a 40-character commit sha');
238
+ }
239
+ }
240
+ return problems;
241
+ }
242
+
243
+
244
+ /**
245
+ * `js/README.md` describes every module under `js/`, and only modules that exist.
246
+ *
247
+ * THE MAP IS THE ONLY THING IN THIS REPO THAT ANSWERS "WHERE DOES X LIVE?", and a map is worse
248
+ * than no map once it is wrong: a reader who cannot find a module knows they are lost, and a
249
+ * reader sent to the wrong file does not. Nothing else notices — the map is prose, it compiles
250
+ * to nothing, and every suite here stays green while it rots.
251
+ *
252
+ * It went in when `js/` reached 56 modules across seven folders, most of them named within the
253
+ * previous week by splits that moved code between files without moving any behaviour. That is
254
+ * exactly the change a map cannot survive unattended.
255
+ *
256
+ * TWO-SIDED, like the partitions elsewhere in this tree: a module with no row is unfindable, and
257
+ * a row naming a module that does not exist sends a reader to an ENOENT. Both are reported.
258
+ * The scan reads the fenced table cells rather than the prose, so a module merely MENTIONED in a
259
+ * paragraph does not count as documented.
260
+ */
261
+ export function moduleMapProblems() {
262
+ const problems = [];
263
+ const mapPath = path.join(String(ROOT), 'js', 'README.md');
264
+ let text;
265
+ try {
266
+ text = readText(mapPath);
267
+ } catch {
268
+ return ['[authoring] js/README.md is missing — it is the module map, and js/inspect/'
269
+ + 'checks-repo.mjs asserts it describes every module under js/'];
270
+ }
271
+
272
+ // A row is `| `name.mjs` | … |`. Reading the CODE SPAN and not the whole line keeps a module
273
+ // named in a "Before editing" paragraph from counting as a row.
274
+ const documented = new Set(
275
+ [...text.matchAll(/^\|\s*`([a-z0-9-]+\.mjs)`\s*\|/gm)].map((m) => m[1]),
276
+ );
277
+
278
+ const onDisk = new Set();
279
+ const jsDir = path.join(String(ROOT), 'js');
280
+ for (const rel of rglob(jsDir)) {
281
+ if (!rel.endsWith('.mjs')) continue;
282
+ // One level down only: `js/<domain>/<module>.mjs` is the shape the map is drawn in.
283
+ // Separators are normalised first: `path.relative` answers with the PLATFORM's, so a split
284
+ // that only knew `/` finds ONE part on Windows and therefore zero modules — which is what
285
+ // the vacuity guard below caught the first time this ran.
286
+ const parts = path.relative(jsDir, rel).replaceAll('\\', '/').split('/');
287
+ if (parts.length === 2) onDisk.add(parts[1]);
288
+ }
289
+
290
+ if (onDisk.size < 30) {
291
+ return [`[authoring] the module scan found only ${onDisk.size} modules under js/ — the walk `
292
+ + 'has gone stale and this check is no longer reading the tree it claims to read'];
293
+ }
294
+
295
+ for (const mod of [...onDisk].sort()) {
296
+ if (!documented.has(mod)) {
297
+ problems.push(`[authoring] js/${mod} has no row in js/README.md — a module the map does not `
298
+ + 'name is a module nobody can find');
299
+ }
300
+ }
301
+ for (const mod of [...documented].sort()) {
302
+ if (!onDisk.has(mod)) {
303
+ problems.push(`[authoring] js/README.md has a row for ${mod}, which does not exist — the map `
304
+ + 'sends a reader to a file that is not there');
305
+ }
306
+ }
307
+ return problems;
308
+ }