geneseed 3.1.1 → 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 +68 -1
- package/DESIGN.md +15 -15
- package/README.md +1 -1
- package/SETUP.md +1 -1
- package/SHIPPED.md +1 -1
- package/bin/{geneseed.mjs → build-driver.mjs} +39 -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 +111 -83
- 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} +27 -28
- package/js/{render.mjs → build/render.mjs} +13 -9
- package/js/{checkout.mjs → build/source.mjs} +9 -9
- 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} +18 -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} +63 -55
- 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} +20 -17
- 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} +34 -32
- package/js/{uninstall.mjs → maintain/uninstall.mjs} +55 -56
- 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 +84 -85
- package/js/web/activity.mjs +22 -21
- package/js/web/api.mjs +72 -50
- 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 +21 -19
- package/js/web/routes.mjs +223 -0
- package/js/web/server.mjs +56 -987
- package/package.json +11 -3
- package/src/doctrines/README.md +1 -1
- package/themes/opencode/README.md +1 -1
- package/web/dist/assets/{About-DyK7yRy7.js → About-CPvIFZGh.js} +1 -1
- package/web/dist/assets/{Activity-D_sYBlW-.js → Activity-z0uXdS7J.js} +1 -1
- package/web/dist/assets/{ActivityDetail-BomKcMhy.js → ActivityDetail-CEj5IJpF.js} +1 -1
- package/web/dist/assets/{Diff-BSvi1WUg.js → Diff-BMR8fPuG.js} +1 -1
- package/web/dist/assets/{Doctor-Cz1s-y1n.js → Doctor-Bz5qE4r9.js} +1 -1
- package/web/dist/assets/{Graph-CuntzFJ0.js → Graph-BV6a6t2l.js} +1 -1
- package/web/dist/assets/{Harnesses-DgRyOviS.js → Harnesses-C5Ag5PaQ.js} +1 -1
- package/web/dist/assets/{Laws-CWk3Mpri.js → Laws-BK07NKw9.js} +1 -1
- package/web/dist/assets/{Library-DXhTN9ju.js → Library-CiXN9sC3.js} +1 -1
- package/web/dist/assets/{Markdown-BoiZcdUl.js → Markdown-DG6FjUz-.js} +1 -1
- package/web/dist/assets/{Profile-DfCHlZ03.js → Profile-B2oSvdxH.js} +1 -1
- package/web/dist/assets/{Rules-eND7hJv9.js → Rules-DobVPd80.js} +1 -1
- package/web/dist/assets/{Skills-CDl9opZO.js → Skills-DQPsvsn9.js} +1 -1
- package/web/dist/assets/{StatusBadge-DSDtcArX.js → StatusBadge-BhJ3HhdI.js} +1 -1
- package/web/dist/assets/{Themes-CoAWuIeM.js → Themes-BP38FBU9.js} +1 -1
- package/web/dist/assets/{activity-Dmb0FTAx.js → activity-D9mfv7pY.js} +1 -1
- package/web/dist/assets/index-BE226Gv6.css +1 -0
- package/web/dist/assets/{index-jrY4m9jP.js → index-BZ7cPLMI.js} +1 -1
- package/web/dist/assets/index-BgIs5eIa.js +12 -0
- package/web/dist/assets/{index-Dg58ScBe.js → index-DGM5j2hB.js} +1 -1
- package/web/dist/index.html +2 -2
- package/web/src/pages/Laws.jsx +1 -1
- package/docs/declined.md +0 -200
- package/docs/limits.md +0 -329
- package/js/doctor.mjs +0 -1750
- package/js/emit.mjs +0 -1411
- package/web/dist/assets/index-B-mXquBZ.css +0 -1
- package/web/dist/assets/index-CH_Z725C.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
|
+
}
|