geneseed 4.5.0 → 4.6.0

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 (70) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +6 -6
  3. package/SHIPPED.md +1 -1
  4. package/adapters/opencode/HOW-OPENCODE-LOADS.md +3 -2
  5. package/adapters/opencode/plugins/geneseed-context.js +108 -61
  6. package/adapters/opencode/plugins/geneseed-guard.js +33 -27
  7. package/adapters/opencode/plugins/geneseed-learn.js +1 -1
  8. package/docs/guides/install-clone.md +1 -1
  9. package/docs/guides/install.md +2 -2
  10. package/docs/reference/troubleshoot-install.md +5 -5
  11. package/harness.config.json +1 -1
  12. package/js/README.md +5 -4
  13. package/js/build/bundle.mjs +11 -2
  14. package/js/build/driver.mjs +1 -10
  15. package/js/build/emit-claude.mjs +7 -22
  16. package/js/build/emit-common.mjs +16 -9
  17. package/js/build/emit-opencode.mjs +5 -11
  18. package/js/build/generate.mjs +1 -1
  19. package/js/hosts/hooks-context.mjs +1 -4
  20. package/js/hosts/hooks-done.mjs +23 -6
  21. package/js/hosts/hooks-learn.mjs +5 -5
  22. package/js/hosts/hooks.mjs +19 -9
  23. package/js/hosts/hosts.mjs +2 -0
  24. package/js/hosts/installs.mjs +45 -10
  25. package/js/hosts/native.mjs +5 -13
  26. package/js/hosts/opencode.mjs +37 -22
  27. package/js/hosts/owned.mjs +19 -5
  28. package/js/hosts/settings.mjs +17 -11
  29. package/js/hosts/shim.mjs +4 -3
  30. package/js/hosts/usage.mjs +4 -3
  31. package/js/inspect/checks-authoring.mjs +9 -7
  32. package/js/inspect/checks-probe.mjs +14 -17
  33. package/js/inspect/checks-repo.mjs +13 -54
  34. package/js/inspect/cost.mjs +5 -6
  35. package/js/inspect/doctor.mjs +2 -2
  36. package/js/inspect/scan.mjs +9 -5
  37. package/js/inspect/scorecard-svg.mjs +3 -3
  38. package/js/inspect/scorecard-yaml.mjs +407 -0
  39. package/js/inspect/scorecard.mjs +7 -405
  40. package/js/inspect/status.mjs +14 -13
  41. package/js/lib/node-floor.mjs +4 -3
  42. package/js/maintain/migrate.mjs +2 -3
  43. package/js/maintain/uninstall.mjs +20 -26
  44. package/js/web/actions.mjs +2 -2
  45. package/js/web/api.mjs +36 -24
  46. package/js/web/gates-api.mjs +5 -8
  47. package/js/web/usage-api.mjs +14 -17
  48. package/package.json +2 -2
  49. package/web/dist/assets/{Activity-BZE6REef.js → Activity-Dj0izJwj.js} +1 -1
  50. package/web/dist/assets/{ActivityDetail-DCB8Cr2W.js → ActivityDetail-wjq5W02K.js} +1 -1
  51. package/web/dist/assets/Dashboard-UMbgHbzF.js +2 -0
  52. package/web/dist/assets/{Docs-D30CiqYy.js → Docs-D3ABE_ds.js} +3 -3
  53. package/web/dist/assets/{GreenhouseView-Djt-4oO5.js → GreenhouseView-C238LWBD.js} +1 -1
  54. package/web/dist/assets/{Installs-B3bw5ckh.js → Installs-XSm0a_zQ.js} +2 -2
  55. package/web/dist/assets/JournalView-BUj4Ff-6.js +1 -0
  56. package/web/dist/assets/{Laws-B9day2FV.js → Laws-yGBJFKgY.js} +1 -1
  57. package/web/dist/assets/{Library-BpIrLH-O.js → Library-CkaPVUYU.js} +1 -1
  58. package/web/dist/assets/{Loops-CxH14kyE.js → Loops-CexwOARr.js} +1 -1
  59. package/web/dist/assets/{OperatorHudView-otOz6Gha.js → OperatorHudView-D5qTQat0.js} +1 -1
  60. package/web/dist/assets/{Personal-abL6089y.js → Personal-B_Vx9a7a.js} +1 -1
  61. package/web/dist/assets/Tabs-YZbGqWPW.js +1 -0
  62. package/web/dist/assets/drawable-BoQnwVpr.js +1 -0
  63. package/web/dist/assets/index-BmbWeoTM.js +2 -0
  64. package/web/dist/assets/{index-CO-9wMJY.css → index-BoG1M8Xy.css} +1 -1
  65. package/web/dist/index.html +2 -2
  66. package/web/dist/assets/Dashboard-B4Drq5wp.js +0 -2
  67. package/web/dist/assets/JournalView-OWhx9YKg.js +0 -1
  68. package/web/dist/assets/Tabs-DjWN0i7A.js +0 -1
  69. package/web/dist/assets/drawable-C2ZsBZCv.js +0 -1
  70. package/web/dist/assets/index-CdpLpPQN.js +0 -2
@@ -19,7 +19,8 @@
19
19
  * indicator `*` + `/pom.xml` matching a pom.xml at ANY depth relies on, and `fnmatch` keeps.
20
20
  * * The file list is sorted by the `/` spelling, i.e. upstream's order on POSIX. Only the
21
21
  * first-match evidence strings can tell, never a score.
22
- * * YAML is read by the small subset parser below, not PyYAML — see its `ponytail:` note.
22
+ * * YAML is read by the small subset parser in `scorecard-yaml.mjs`, not PyYAML — see its
23
+ * `ponytail:` note.
23
24
  */
24
25
  import { readdirSync, statSync } from 'node:fs';
25
26
  import path from 'node:path';
@@ -27,409 +28,9 @@ import path from 'node:path';
27
28
  import { isDir, isFile, printErr, printOut, readText, writeText } from '../lib/fs.mjs';
28
29
  import { ALL_CHECKS } from './scorecard-checks.mjs';
29
30
  import { badgeUrl, renderSvg } from './scorecard-svg.mjs';
31
+ import { parseYamlSubset, pyStr, pyTruthy } from './scorecard-yaml.mjs';
30
32
 
31
- // ---------------------------------------------------------------------------------------------
32
- // The YAML subset
33
- // ---------------------------------------------------------------------------------------------
34
-
35
- class YamlError extends Error {}
36
-
37
- /** A flow mapping (`{a: b}`), kept as its source text — the subset does not look inside one. */
38
- export class FlowMap {
39
- constructor(raw) { this.raw = raw; }
40
- }
41
-
42
- const NULLS = new Set(['', '~', 'null', 'Null', 'NULL']);
43
- const TRUES = new Set(['yes', 'Yes', 'YES', 'true', 'True', 'TRUE', 'on', 'On', 'ON']);
44
- const FALSES = new Set(['no', 'No', 'NO', 'false', 'False', 'FALSE', 'off', 'Off', 'OFF']);
45
-
46
- /**
47
- * PyYAML's YAML 1.1 resolver for a plain scalar: null, the eighteen booleans (`on` is `True`,
48
- * which is why upstream looks the GitHub trigger key up under both `"on"` and `True`), ints and
49
- * floats. Everything else stays a string.
50
- */
51
- function resolvePlain(s) {
52
- if (NULLS.has(s)) return null;
53
- if (TRUES.has(s)) return true;
54
- if (FALSES.has(s)) return false;
55
- if (/^[-+]?(0|[1-9][0-9_]*)$/.test(s)) return Number(s.replaceAll('_', ''));
56
- if (/^[-+]?([0-9][0-9_]*)?\.[0-9_]*([eE][-+][0-9]+)?$/.test(s) && /[0-9]/.test(s)) {
57
- return Number(s.replaceAll('_', ''));
58
- }
59
- return s;
60
- }
61
-
62
- /**
63
- * `str(value)` for what the subset produces. A collection prints as Python's `repr`, because
64
- * that is the command text upstream matches: `- echo "a: b"` in a GitLab script is a MAPPING
65
- * to YAML, and `str()` of it is `{'echo "a': 'b"'}`.
66
- */
67
- export function pyStr(v) {
68
- if (typeof v === 'string') return v;
69
- if (v === null || v === undefined) return 'None';
70
- if (v === true) return 'True';
71
- if (v === false) return 'False';
72
- if (v instanceof FlowMap) return v.raw;
73
- if (Array.isArray(v)) return `[${v.map(pyRepr).join(', ')}]`;
74
- if (v instanceof Map) return `{${[...v].map(([k, x]) => `${pyRepr(k)}: ${pyRepr(x)}`).join(', ')}}`;
75
- return String(v);
76
- }
77
-
78
- function pyRepr(v) {
79
- if (typeof v !== 'string') return pyStr(v);
80
- const q = v.includes("'") && !v.includes('"') ? '"' : "'";
81
- const body = v.replace(/[\\\n\r\t]/g, (c) => ({ '\\': '\\\\', '\n': '\\n', '\r': '\\r', '\t': '\\t' })[c])
82
- .replaceAll(q, `\\${q}`);
83
- return `${q}${body}${q}`;
84
- }
85
-
86
- /** `bool(value)` — `allow_failure: {exit_codes: [1]}` and `"false"` are both TRUE upstream. */
87
- export function pyTruthy(v) {
88
- if (v === null || v === undefined || v === false) return false;
89
- if (typeof v === 'number') return v !== 0;
90
- if (typeof v === 'string' || Array.isArray(v)) return v.length > 0;
91
- if (v instanceof Map) return v.size > 0;
92
- if (v instanceof FlowMap) return v.raw.replace(/\s/g, '') !== '{}';
93
- return true;
94
- }
95
-
96
- /** Fold the line breaks inside a multi-line flow or quoted scalar: one break is a space. */
97
- function foldBreaks(s) {
98
- return s.replace(/[ \t]*\n([ \t]*\n)*[ \t]*/g, (m) => {
99
- const n = m.split('\n').length - 1;
100
- return n === 1 ? ' ' : '\n'.repeat(n - 1);
101
- });
102
- }
103
-
104
- const ESCAPES = { n: '\n', t: '\t', r: '\r', 0: '\0', '"': '"', '\\': '\\', '/': '/', ' ': ' ', e: '\x1b', a: '\x07', b: '\b', f: '\f', v: '\v', N: '\x85', _: '\xa0' };
105
-
106
- function unescapeDouble(s) {
107
- return s.replace(/\\(x[0-9a-fA-F]{2}|u[0-9a-fA-F]{4}|U[0-9a-fA-F]{8}|.)/gs, (m, e) => {
108
- if (e.length > 1) return String.fromCodePoint(parseInt(e.slice(1), 16));
109
- if (e in ESCAPES) return ESCAPES[e];
110
- throw new YamlError(`unknown escape \\${e}`);
111
- });
112
- }
113
-
114
- /** Index just past the closing quote of the quoted scalar starting at `s[0]`, or -1. */
115
- function quoteEnd(s) {
116
- const q = s[0];
117
- for (let i = 1; i < s.length; i += 1) {
118
- if (q === '"' && s[i] === '\\') { i += 1; continue; }
119
- if (s[i] === q) {
120
- if (q === "'" && s[i + 1] === "'") { i += 1; continue; }
121
- return i + 1;
122
- }
123
- }
124
- return -1;
125
- }
126
-
127
- function quotedValue(span) {
128
- const body = span.slice(1, -1);
129
- if (span[0] === "'") return foldBreaks(body).replaceAll("''", "'");
130
- // A `\` before a line break escapes the break itself: no space is folded in for it.
131
- return unescapeDouble(foldBreaks(body.replace(/(?<!\\)\\\n[ \t]*/g, '')));
132
- }
133
-
134
- /** Index just past the bracket that closes `s[0]`, honouring quotes; -1 while still open. */
135
- function flowEnd(s) {
136
- let depth = 0;
137
- for (let i = 0; i < s.length; i += 1) {
138
- const c = s[i];
139
- if ((c === '"' || c === "'") && /^$|[\s[{,]/.test(s[i - 1] ?? '')) {
140
- const e = quoteEnd(s.slice(i));
141
- if (e < 0) return -1;
142
- i += e - 1;
143
- } else if (c === '[' || c === '{') depth += 1;
144
- else if (c === ']' || c === '}') { depth -= 1; if (depth === 0) return i + 1; }
145
- }
146
- return -1;
147
- }
148
-
149
- /** A flow sequence of scalars becomes an array; a flow mapping anywhere stays opaque. */
150
- function parseFlow(src) {
151
- if (src[0] === '{') return new FlowMap(src);
152
- const items = [];
153
- let i = 1;
154
- while (i < src.length - 1) {
155
- while (/[\s,]/.test(src[i]) && i < src.length - 1) i += 1;
156
- if (i >= src.length - 1) break;
157
- const rest = src.slice(i, -1);
158
- let end;
159
- if (rest[0] === '[' || rest[0] === '{') {
160
- end = flowEnd(rest);
161
- items.push(parseFlow(rest.slice(0, end)));
162
- } else if (rest[0] === '"' || rest[0] === "'") {
163
- end = quoteEnd(rest);
164
- items.push(quotedValue(rest.slice(0, end)));
165
- } else {
166
- end = rest.search(/,|$/);
167
- items.push(resolvePlain(foldBreaks(rest.slice(0, end)).trim()));
168
- }
169
- i += end;
170
- }
171
- return items;
172
- }
173
-
174
- /**
175
- * The CI-config YAML subset upstream's checks read, parsed into Maps, arrays and scalars.
176
- *
177
- * Returns the document, or `undefined` where PyYAML's `safe_load` would RAISE — and upstream
178
- * then drops the whole file (`_load_yaml` answers `None`), so a raise is behaviour to keep, not
179
- * an error to paper over. Raised here: a second document, a local tag such as GitLab's
180
- * `!reference` (unknown to `safe_load`), a plain value holding `: `, a tab used as indentation.
181
- *
182
- * Handles block mappings and sequences (including a sequence at its key's own indent, and a
183
- * mapping that starts on the `- ` line), `|`/`>` block scalars with chomping and indentation
184
- * indicators, plain scalars (multi-line, ` #` comments), single- and double-quoted scalars
185
- * (escapes, multi-line), flow sequences of scalars, `&anchor` prefixes and `!!` tags.
186
- *
187
- * ponytail: a subset, not YAML. Its ceiling: flow MAPPINGS are kept opaque (`FlowMap`), so a
188
- * job or rule written `{...}` is invisible; aliases (`*x`) and merge keys (`<<: *x`) are not
189
- * resolved — an alias reads as the string `*x` — which is the likeliest real miss on GitLab
190
- * files that share `before_script` through anchors; `? ` complex keys are refused;
191
- * multi-document files are refused as PyYAML refuses them. Upgrade path: vendor a YAML parser
192
- * under `js/vendor/` (CLAUDE.md: never install one) once a scored repo needs any of those.
193
- */
194
- export function parseYamlSubset(text) {
195
- try {
196
- return new YamlParser(text).document();
197
- } catch (e) {
198
- if (e instanceof YamlError) return undefined;
199
- throw e;
200
- }
201
- }
202
-
203
- class YamlParser {
204
- constructor(text) {
205
- this.raw = text.replace(/^\uFEFF/, '').split('\n');
206
- this.lines = this.raw.map((r) => {
207
- const indent = r.length - r.replace(/^ +/, '').length;
208
- const text = r.slice(indent).replace(/\s+$/, '');
209
- // A tab where indentation goes is a YAML error — raised by `peek`, on content lines only.
210
- return { indent, text: text.replace(/^\t+/, ''), tab: text.startsWith('\t') };
211
- });
212
- this.pos = 0;
213
- }
214
-
215
- document() {
216
- let started = false;
217
- let i = 0;
218
- for (; i < this.lines.length; i += 1) {
219
- const { indent, text } = this.lines[i];
220
- if (indent === 0 && /^---(\s|$)/.test(text)) {
221
- if (started) throw new YamlError('a second document');
222
- started = true;
223
- this.lines[i] = { indent: 0, text: '' };
224
- } else if (indent === 0 && /^\.\.\.(\s|$)/.test(text)) {
225
- this.lines.length = i;
226
- break;
227
- } else if (indent === 0 && text.startsWith('%') && !started) {
228
- this.lines[i] = { indent: 0, text: '' };
229
- } else if (text && !text.startsWith('#')) {
230
- started = true;
231
- }
232
- }
233
- const value = this.node(0);
234
- if (this.peek() !== null) throw new YamlError('content after the document');
235
- return value;
236
- }
237
-
238
- /** Index of the next line that is neither blank nor a comment, or null. */
239
- peek() {
240
- for (let i = this.pos; i < this.lines.length; i += 1) {
241
- const { text } = this.lines[i];
242
- if (text && !text.startsWith('#')) {
243
- if (this.lines[i].tab) throw new YamlError('a tab as indentation');
244
- this.pos = i;
245
- return i;
246
- }
247
- }
248
- this.pos = this.lines.length;
249
- return null;
250
- }
251
-
252
- node(minIndent) {
253
- const i = this.peek();
254
- if (i === null || this.lines[i].indent < minIndent) return null;
255
- const { indent, text } = this.lines[i];
256
- if (isSeqItem(text)) return this.seq(indent);
257
- if (splitKey(text)) return this.map(indent);
258
- this.pos = i + 1;
259
- return this.value(text, indent - 1);
260
- }
261
-
262
- /** The value of `key:` with nothing after it — or of a bare `-`. */
263
- nested(indent, sameIndentSeq) {
264
- const i = this.peek();
265
- if (i === null) return null;
266
- const ln = this.lines[i];
267
- if (sameIndentSeq && ln.indent === indent && isSeqItem(ln.text)) return this.seq(indent);
268
- return ln.indent > indent ? this.node(ln.indent) : null;
269
- }
270
-
271
- map(indent) {
272
- const m = new Map();
273
- for (let i = this.peek(); i !== null; i = this.peek()) {
274
- const ln = this.lines[i];
275
- if (ln.indent < indent || (ln.indent === indent && isSeqItem(ln.text))) break;
276
- if (ln.indent > indent) throw new YamlError('bad indentation');
277
- const kv = splitKey(ln.text);
278
- if (!kv) throw new YamlError('expected a key');
279
- this.pos = i + 1;
280
- // Python dicts keep the FIRST position of a duplicate key and the LAST value; so do Maps.
281
- m.set(kv.key, kv.rest === '' || kv.rest.startsWith('#')
282
- ? this.nested(indent, true) : this.value(kv.rest, indent));
283
- }
284
- return m;
285
- }
286
-
287
- seq(indent) {
288
- const arr = [];
289
- for (let i = this.peek(); i !== null; i = this.peek()) {
290
- const ln = this.lines[i];
291
- if (ln.indent < indent || !isSeqItem(ln.text)) break;
292
- if (ln.indent > indent) throw new YamlError('bad indentation');
293
- const after = ln.text.slice(1);
294
- const body = after.trimStart();
295
- if (body === '' || body.startsWith('#')) {
296
- this.pos = i + 1;
297
- arr.push(this.nested(indent, false));
298
- } else {
299
- // `- key: v` opens a mapping whose keys sit at the column `key` starts in: re-read the
300
- // rest of this line as a node of its own at that column.
301
- const col = indent + 1 + (after.length - body.length);
302
- this.lines[i] = { indent: col, text: body };
303
- arr.push(this.node(col));
304
- }
305
- }
306
- return arr;
307
- }
308
-
309
- /** A value that starts on the current line, after `key: ` or `- `. */
310
- value(rest, parentIndent) {
311
- let s = rest;
312
- for (;;) {
313
- const anchor = /^&\S+\s*/.exec(s);
314
- if (anchor) { s = s.slice(anchor[0].length); continue; }
315
- const tag = /^!(\S*)\s*/.exec(s);
316
- if (tag) {
317
- if (!tag[1].startsWith('!')) throw new YamlError(`unknown tag !${tag[1]}`);
318
- s = s.slice(tag[0].length);
319
- continue;
320
- }
321
- break;
322
- }
323
- if (s === '' || s.startsWith('#')) return this.nested(parentIndent, false);
324
- const c = s[0];
325
- if (c === '|' || c === '>') return this.block(s, parentIndent);
326
- if (c === '"' || c === "'" || c === '[' || c === '{') {
327
- let span = s;
328
- let end = c === '[' || c === '{' ? flowEnd(span) : quoteEnd(span);
329
- while (end < 0 && this.pos < this.lines.length) {
330
- span += `\n${this.raw[this.pos].trim()}`;
331
- this.pos += 1;
332
- end = c === '[' || c === '{' ? flowEnd(span) : quoteEnd(span);
333
- }
334
- if (end < 0) throw new YamlError('an unterminated scalar');
335
- const tail = span.slice(end).trim();
336
- if (tail && !tail.startsWith('#')) throw new YamlError('text after a closed scalar');
337
- return c === '[' || c === '{' ? parseFlow(span.slice(0, end)) : quotedValue(span.slice(0, end));
338
- }
339
- if (c === '*') return s.replace(/\s+#.*$/, '');
340
- if (c === '@' || c === '`') throw new YamlError('a reserved indicator');
341
- return resolvePlain(this.plain(s, parentIndent));
342
- }
343
-
344
- /** A plain scalar and its continuation lines, folded. */
345
- plain(first, parentIndent) {
346
- const parts = [first.replace(/\s+#.*$/, '')];
347
- let blanks = 0;
348
- if (!/\s#/.test(first)) {
349
- for (let j = this.pos; j < this.lines.length; j += 1) {
350
- const { indent, text } = this.lines[j];
351
- if (!text) { blanks += 1; continue; }
352
- if (text.startsWith('#') || indent <= parentIndent) break;
353
- parts.push(blanks ? '\n'.repeat(blanks) : ' ', text.replace(/\s+#.*$/, ''));
354
- blanks = 0;
355
- this.pos = j + 1;
356
- if (/\s#/.test(text)) break;
357
- }
358
- }
359
- const out = parts.join('');
360
- if (/:(\s|$)/.test(out)) throw new YamlError('mapping values are not allowed here');
361
- return out.trim();
362
- }
363
-
364
- block(header, parentIndent) {
365
- const h = /^([|>])([1-9]?)([+-]?)([1-9]?)\s*(#.*)?$/.exec(header);
366
- if (!h) throw new YamlError('a bad block scalar header');
367
- const digit = Number(h[2] || h[4] || 0);
368
- const chomp = h[3];
369
- let contentIndent = digit ? parentIndent + digit : null;
370
- const lines = [];
371
- let j = this.pos;
372
- for (; j < this.raw.length && j < this.lines.length; j += 1) {
373
- const r = this.raw[j];
374
- if (r.trim() === '') { lines.push(''); continue; }
375
- const ind = r.length - r.replace(/^ +/, '').length;
376
- if (contentIndent === null) {
377
- if (ind <= parentIndent) break;
378
- contentIndent = ind;
379
- }
380
- if (ind < contentIndent) break;
381
- lines.push(r.slice(contentIndent));
382
- }
383
- this.pos = j;
384
- let trailing = 0;
385
- while (lines.length && lines[lines.length - 1] === '') { lines.pop(); trailing += 1; }
386
- let body;
387
- if (h[1] === '|') {
388
- body = lines.join('\n');
389
- } else {
390
- body = '';
391
- let prevMore = null;
392
- let empties = 0;
393
- for (const l of lines) {
394
- if (l === '') { empties += 1; continue; }
395
- const more = /^[ \t]/.test(l);
396
- if (prevMore === null) body += '\n'.repeat(empties);
397
- else if (!more && !prevMore) body += empties ? '\n'.repeat(empties) : ' ';
398
- else body += '\n'.repeat(empties + 1);
399
- body += l;
400
- prevMore = more;
401
- empties = 0;
402
- }
403
- }
404
- if (chomp === '-' || body === '') return chomp === '+' ? body + '\n'.repeat(trailing) : body;
405
- return chomp === '+' ? `${body}\n${'\n'.repeat(trailing)}` : `${body}\n`;
406
- }
407
- }
408
-
409
- function isSeqItem(text) {
410
- return text === '-' || /^-[ \t]/.test(text);
411
- }
412
-
413
- /** `key: rest` → `{ key, rest }`, or null when the line is not a mapping entry. */
414
- function splitKey(text) {
415
- if (isSeqItem(text) || text[0] === '[' || text[0] === '{') return null;
416
- if (text.startsWith('? ')) throw new YamlError('a complex key');
417
- if (text[0] === '"' || text[0] === "'") {
418
- const end = quoteEnd(text);
419
- if (end < 0) return null;
420
- const m = /^\s*:(\s|$)/.exec(text.slice(end));
421
- if (!m) return null;
422
- return { key: quotedValue(text.slice(0, end)), rest: text.slice(end + m[0].length).trim() };
423
- }
424
- const m = /:(\s|$)/.exec(text);
425
- if (!m) return null;
426
- const hash = text.search(/\s#/);
427
- if (hash >= 0 && hash < m.index) return null;
428
- const raw = text.slice(0, m.index).trimEnd();
429
- const key = resolvePlain(raw);
430
- // Keys stay strings except the booleans and null, which `data.get(True)` must be able to find.
431
- return { key: typeof key === 'number' ? raw : key, rest: text.slice(m.index + 1).trim() };
432
- }
33
+ export { FlowMap, parseYamlSubset, pyStr, pyTruthy } from './scorecard-yaml.mjs';
433
34
 
434
35
  // ---------------------------------------------------------------------------------------------
435
36
  // ci_parser.py
@@ -721,6 +322,7 @@ const CATEGORY_CONFIG = {
721
322
  ai_safeguards: ['AI-Specific Safeguards', 0.15],
722
323
  };
723
324
 
325
+ /** @type {[string, number][]} */
724
326
  const GRADES = [['A', 85], ['B', 70], ['C', 55], ['D', 40]];
725
327
 
726
328
  const GRADE_DESCRIPTIONS = {
@@ -780,7 +382,7 @@ export function round1(x) {
780
382
  }
781
383
 
782
384
  /** The JSON payload: upstream's reporter shape, keys renamed to this module's camelCase. */
783
- export function renderJson(a) {
385
+ function renderJson(a) {
784
386
  const r = (x) => round1(x);
785
387
  return `${JSON.stringify({
786
388
  ...a,
@@ -793,7 +395,7 @@ export function renderJson(a) {
793
395
  }
794
396
 
795
397
  /** The text report: grade, the per-pillar table, then every check that is not at full marks. */
796
- export function renderText(a) {
398
+ function renderText(a) {
797
399
  const out = [
798
400
  `AI Harness Scorecard — ${a.repoName}`,
799
401
  `Grade ${a.grade} — ${round1(a.overallScore)}/100 (${a.passedChecks}/${a.totalChecks} checks passing)`,
@@ -69,11 +69,11 @@ import {
69
69
  // deployed theme and footprint, and `rebuild-all` re-emits in the deployed everything, so
70
70
  // three verbs now read them.
71
71
  import {
72
- defaultTheme, hookShellProblems, installedDefaults, installTargets, legacyProcessCarrierOfDir,
73
- openclaudeDualHarnessRoots, readJsonMaybe, readMaybe,
72
+ defaultTheme, hookShellProblems, installedDefaults, installTargets, installsReferencingShim,
73
+ legacyProcessCarrierOfDir, openclaudeDualHarnessRoots, readJsonMaybe, readMaybe,
74
74
  } from '../hosts/installs.mjs';
75
75
  import { installProfile, rebuildCommand } from '../build/generate.mjs';
76
- import { installsReferencingShim } from './checks-repo.mjs';
76
+ import { isDoctorNote } from './scan.mjs';
77
77
  import { hookShimPath, shimDead } from '../hosts/shim.mjs';
78
78
  import { printOut } from '../lib/fs.mjs';
79
79
  import { codePointLength, padEndToWidth } from '../lib/text.mjs';
@@ -211,20 +211,21 @@ export function gateSummary(cfgDirs, platform = process.platform) {
211
211
  // `dead` is the machine shim's missing targets: every gate below is armed on paper and never
212
212
  // runs, so it outranks `armed` in the row. A shim that exists but names a moved checkout
213
213
  // (`shimDead`) and a shim that is simply GONE while a live install still names it
214
- // (`installsReferencingShim`, Task 1's fix round — `onFailure: "block"` makes a dead gate
215
- // block instead of silently passing, so this stopped being a quiet failure) are the same
216
- // row here: both mean "every hook of every install naming this shim cannot run".
217
- // `fail_open` (Task 15): Claude installs whose bash-form hooks now run under PowerShell. Only
218
- // present when non-empty, so every recorded `--json` panel keeps its shape.
219
- // Only the dirs it was handed, and only those that are Claude's global (`isHostGlobalDir`);
214
+ // (`installsReferencingShim` — `onFailure: "block"` makes a dead gate block instead of
215
+ // silently passing, so a missing shim is no longer a quiet failure) are the same row here:
216
+ // both mean "every hook of every install naming this shim cannot run".
217
+ // `fail_open`: Claude installs whose bash-form hooks now run under PowerShell, where a gate
218
+ // fails open. Only present when non-empty, so every recorded `--json` panel keeps its shape.
219
+ // Only the dirs it was handed, and only those that are a host's global (`isHostGlobalDir`,
220
+ // which throws when a host's `configDir()` cannot resolve — read here as "not that host's");
220
221
  // `platform` is injectable so a test on Linux still exercises the win32 branch.
221
- const isClaude = (c) => { try { return isHostGlobalDir('claude', c); } catch { return false; } };
222
- const claude = cfgDirs.filter(isClaude).map((c) => ['claude', 'global', c]);
223
- const failOpen = hookShellProblems(claude, platform).filter((p) => !p.startsWith('[note] '));
222
+ const isGlobal = (host, c) => { try { return isHostGlobalDir(host, c); } catch { return false; } };
223
+ const claude = cfgDirs.filter((c) => isGlobal('claude', c)).map((c) => ['claude', 'global', c]);
224
+ const failOpen = hookShellProblems(claude, platform).filter((p) => !isDoctorNote(p));
224
225
  // Same `cfgDirs`, widened to every Claude-STYLE host (`CLAUDE_STYLE`: claude, bob,
225
226
  // openclaude — all three share this one machine-wide shim), for `installsReferencingShim`.
226
227
  const styleTargets = cfgDirs.flatMap((c) => CLAUDE_STYLE
227
- .filter((host) => { try { return isHostGlobalDir(host, c); } catch { return false; } })
228
+ .filter((host) => isGlobal(host, c))
228
229
  .map((host) => [host, 'global', c]));
229
230
  const shimPath = hookShimPath();
230
231
  const dead = shimDead();
@@ -10,9 +10,9 @@
10
10
  */
11
11
  import path from 'node:path';
12
12
 
13
- export const NODE_FLOOR = '22.3.0';
13
+ export const NODE_FLOOR = '22.7.0';
14
14
 
15
- /** Numeric, not string, comparison: '22.10.0' is above '22.3.0'. */
15
+ /** Numeric, not string, comparison: '22.10.0' is above '22.7.0'. */
16
16
  export function belowFloor(version, floor = NODE_FLOOR) {
17
17
  const v = version.replace(/^v/, '').split('.').map(Number);
18
18
  const f = floor.split('.').map(Number);
@@ -23,7 +23,8 @@ export function belowFloor(version, floor = NODE_FLOOR) {
23
23
  }
24
24
 
25
25
  if (belowFloor(process.versions.node)) {
26
- process.stderr.write(`geneseed needs Node 22.3 or newer; this is Node ${process.versions.node}. `
26
+ process.stderr.write(`geneseed needs Node ${NODE_FLOOR.replace(/\.0$/, '')} or newer; `
27
+ + `this is Node ${process.versions.node}. `
27
28
  + 'Install a current Node from https://nodejs.org (or your package manager), then run the '
28
29
  + 'command again.\n');
29
30
  process.exit(path.basename(process.argv[1] || '').startsWith('geneseed-hook') ? 0 : 1);
@@ -37,13 +37,12 @@ import path from 'node:path';
37
37
 
38
38
  import { main as driverMain } from '../build/driver.mjs';
39
39
  import { ROOT } from '../build/source.mjs';
40
- import { CLAUDE_STYLE } from '../hosts/hosts.mjs';
40
+ import { CLAUDE_STYLE, settingsFile } from '../hosts/hosts.mjs';
41
41
  import {
42
42
  claudeCfg, claudeReadManifest, EMIT_HOST_SCOPE, installState, installTargets,
43
43
  legacyProcessCarrierOfDir, readMaybe,
44
44
  } from '../hosts/installs.mjs';
45
45
  import { installProfile } from '../build/generate.mjs';
46
- import { settingsFile } from './uninstall.mjs';
47
46
  import {
48
47
  autostartPaths, autostartStale, hookShimPath, migrateShape, shimHome,
49
48
  } from '../hosts/shim.mjs';
@@ -65,7 +64,7 @@ const KNOWN_EMITS = new Set(EMIT_HOST_SCOPE.keys());
65
64
  * BOTH HALVES READ BACK AN EXISTING RESOLVER rather than a second hand-rolled copy
66
65
  * (host-compat B1/B2): `claudeCfg` (`js/hosts/installs.mjs`) is the cfg dir — `root` itself
67
66
  * for a global install, `<root>/<projectMarker>` (`.claude`/`.bob`/`.openclaude`) for a
68
- * project one — and `settingsFile` (`js/maintain/uninstall.mjs`) is the file name inside it,
67
+ * project one — and `settingsFile` (`js/hosts/hosts.mjs`) is the file name inside it,
69
68
  * read off the install's OWN manifest. The manifest is the authority, not a guess at the
70
69
  * canonical name: a pre-existing `bob-global` install with no `managed.settings_file` yet
71
70
  * recorded carries the bare, pre-nesting `settings.json` it was actually written with, not
@@ -52,14 +52,14 @@ import { confirm } from '../lib/prompt.mjs';
52
52
  import {
53
53
  claudeCfg, claudeReadManifest, doctrinesOfDir, EMIT_HOST_SCOPE, emitHostScopeOf,
54
54
  excludedRulesOfDir, installKind, installState,
55
- registeredTargets, DISABLED_STASH,
55
+ registeredTargets,
56
56
  } from '../hosts/installs.mjs';
57
57
  import {
58
- CLAUDE_STYLE, GLOBAL_MANIFEST, HOSTS, VERSION_MARKER, expanduser, isHostGlobalDir, opencodeConfigDir,
58
+ CLAUDE_STYLE, DISABLED_STASH, GLOBAL_MANIFEST, HOSTS, VERSION_MARKER, expanduser, isHostGlobalDir, opencodeConfigDir,
59
59
  resolvePath, settingsFile,
60
60
  } from '../hosts/hosts.mjs';
61
61
  import { mcpCommented, mcpLoad } from '../hosts/mcp.mjs';
62
- import { hashesOf, ownedState, ownedWithin } from '../hosts/owned.mjs';
62
+ import { hashesOf, ownedState, ownedWithin, recordedDenies } from '../hosts/owned.mjs';
63
63
  import {
64
64
  atomicWriteJson, managedBlockRead, managedBlockRemove, managedBlockWrite,
65
65
  loadJsonObject, mergeClaudeSettings, opencodeTarget, OPENCODE_SENTINEL, opencodeSentinelWrite,
@@ -224,18 +224,6 @@ function warnMarkersKept() {
224
224
  const REVERSAL_MARKERS = [GLOBAL_MANIFEST, '.geneseed-theme', '.geneseed-emit',
225
225
  '.geneseed-footprint', VERSION_MARKER];
226
226
 
227
- /**
228
- * The CURRENT manifest's `skill_denies` — the `permission.skill` denies Geneseed itself added
229
- * (`opencodeLayer` in emit-opencode.mjs records them; a deny the user wrote, even on a Geneseed
230
- * skill, is never in it). Task 4 (O-1) wires one per user-only skill, and unlike the Law IV
231
- * `permission.bash` gates (permanent invariants, never taken back — see
232
- * `reconcileOpencodePermission`), a skill-visibility deny is reversible: once uninstalled there
233
- * is no skill left to hide, and the name is the user's to reuse. No record takes back nothing.
234
- */
235
- function skillPermissionNames(man) {
236
- return Array.isArray(man.skill_denies) ? man.skill_denies.filter((n) => typeof n === 'string') : [];
237
- }
238
-
239
227
  /**
240
228
  * `_harness_mcp._unmerge_opencode_json` — drop one `instructions` entry, leave every other
241
229
  * key intact.
@@ -313,10 +301,6 @@ export function archiveStore(store) {
313
301
  return dest;
314
302
  }
315
303
 
316
- // `settingsFile` lives in `hosts.mjs` (the hook stand-down reads it, and must not import this
317
- // module); re-exported here for `migrate`.
318
- export { settingsFile };
319
-
320
304
  /** `_harness_mcp._claude_md_path` — where the manifest says the managed block lives. */
321
305
  function claudeMdPath(cfg, managed) {
322
306
  const rel = ((managed && managed.claude_md) || {}).rel || 'CLAUDE.md';
@@ -386,7 +370,7 @@ export function uninstallGlobal(target, archiveMemory, host = 'opencode', force
386
370
  if (isDir(p) && isEmptyDir(p)) rmdirQuiet(p);
387
371
  }
388
372
  const unmerged = unmergeOpencodeJson(path.join(target, 'opencode.json'),
389
- path.join(target, 'AGENT.md').split(path.sep).join('/'), skillPermissionNames(man));
373
+ path.join(target, 'AGENT.md').split(path.sep).join('/'), recordedDenies(man));
390
374
  managedBlockRemove(path.join(target, OPENCODE_SENTINEL));
391
375
  if (failed.length) warnMarkersKept();
392
376
  else for (const m of REVERSAL_MARKERS) unlinkQuiet(path.join(target, m));
@@ -476,7 +460,7 @@ function opencodeProjectUninstall(root, force = false) {
476
460
  const am = path.join(root, 'AGENT.md');
477
461
  if (isFile(am)) { unlinkQuiet(am); removed += 1; }
478
462
  const unmerged = unmergeOpencodeJson(path.join(root, 'opencode.json'), entry,
479
- man ? skillPermissionNames(man) : []);
463
+ man ? recordedDenies(man) : []);
480
464
  const result = { removed, unmerged, archived: null };
481
465
  if (failed.length) result.failed = failed;
482
466
  if (kept.length) result.kept = kept;
@@ -587,6 +571,21 @@ export function installUninstall(root, host = 'opencode', scope = 'global', memo
587
571
  if (archived.length) out.archived = archived;
588
572
  return out;
589
573
  }
574
+ reconcileRootMarkers(root, host, scope);
575
+ // 4. Tidy an emptied marker dir (.claude/.bob/.openclaude) so no husk lingers in the repo.
576
+ if (data !== root && isDir(data) && isEmptyDir(data)) rmdirQuiet(data);
577
+ const out = withKept({ ok: true, removed: summary.removed ?? 0, memory });
578
+ if (archived.length) out.archived = archived;
579
+ return out;
580
+ }
581
+
582
+ /**
583
+ * `installUninstall`'s step 3 once nothing of THIS host's install survived: drop, rewrite or
584
+ * keep the root markers depending on whether another host is still installed at `root`. Its
585
+ * own function because the survivor argument below is the whole of it, and the caller must
586
+ * only reach it after the reversal (step 1) has unlinked this host's own manifest/markers.
587
+ */
588
+ function reconcileRootMarkers(root, host, scope) {
590
589
  // The root markers are ONE PER ROOT (`generate.mjs`'s "THE MARKER IS TRUSTED ONLY FOR ITS
591
590
  // OWN HOST") and `registryRoots` keeps a row alive only while `.geneseed-emit` exists there
592
591
  // — so in a repo sharing two project installs (`.claude/` + `.opencode/` at the same cwd,
@@ -655,11 +654,6 @@ export function installUninstall(root, host = 'opencode', scope = 'global', memo
655
654
  if (scope === 'global' || host === 'opencode') unlinkQuiet(path.join(root, '.geneseed-theme'));
656
655
  unlinkQuiet(path.join(root, VERSION_MARKER));
657
656
  }
658
- // 4. Tidy an emptied marker dir (.claude/.bob/.openclaude) so no husk lingers in the repo.
659
- if (data !== root && isDir(data) && isEmptyDir(data)) rmdirQuiet(data);
660
- const out = withKept({ ok: true, removed: summary.removed ?? 0, memory });
661
- if (archived.length) out.archived = archived;
662
- return out;
663
657
  }
664
658
 
665
659
  /**
@@ -574,7 +574,7 @@ export function apiInstallCmd(state, body) {
574
574
  const excludeRules = bodyExcludeRules(body) ?? excludedRulesOfDir(root, host);
575
575
  const out = scope === 'global' ? null : String(root);
576
576
  const argv = setupBuildArgs(theme || 'neutral', emit, out, out, fp, pos, mode, doctrines,
577
- PACK_ORDER, excludeRules, trust, excludedSkillsOfDir(root, null, host));
577
+ PACK_ORDER, excludeRules, trust, excludedSkillsOfDir(root, host));
578
578
  return { cmd: [process.execPath, path.join(ROOT, 'bin', 'build-driver.mjs'), ...argv] };
579
579
  }
580
580
 
@@ -675,6 +675,6 @@ export function apiDeployCmd(state, body) {
675
675
  const excludeRules = bodyExcludeRules(body) ?? excludedRulesOfDir(root, host);
676
676
  // project-scope emit name == host name (opencode / claude / bob / openclaude)
677
677
  const argv = setupBuildArgs(theme || 'neutral', host, root, root, fp, pos, mode, doctrines,
678
- PACK_ORDER, excludeRules, trust, excludedSkillsOfDir(root, null, host));
678
+ PACK_ORDER, excludeRules, trust, excludedSkillsOfDir(root, host));
679
679
  return { cmd: [process.execPath, path.join(ROOT, 'bin', 'build-driver.mjs'), ...argv] };
680
680
  }