geneseed 4.5.0 → 4.7.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 +30 -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 -28
  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 +28 -15
  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 +27 -18
  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
@@ -28,6 +28,7 @@ import { jsonDumps, jsonDumpsIndent, parseJson } from '../lib/json.mjs';
28
28
  import { comparePaths } from '../lib/paths.mjs';
29
29
  import { sourceReleaseVersion } from '../build/version.mjs';
30
30
  import { aliasesOf } from '../build/source.mjs';
31
+ import { lstripNewlines } from './settings.mjs';
31
32
  import {
32
33
  firstBlockquote, isUserInvokedOnly, skillDescription, stripSkillBodyLinks, pointSideFiles,
33
34
  pushOverrideLines,
@@ -295,7 +296,7 @@ export function writePrimaryAgent(cfg, agentsDir, overrides, claim = (_dest) =>
295
296
  || !statSync(cfg.primaryAgentSrc).isFile()) {
296
297
  return null;
297
298
  }
298
- const body = readText(cfg.primaryAgentSrc).replace(/^\n+/, '');
299
+ const body = lstripNewlines(readText(cfg.primaryAgentSrc));
299
300
  const desc = 'Primary orchestrator — works by the harness Rules and delegates to the '
300
301
  + 'capability subagents.';
301
302
  const fm = [`description: ${jsonDumps(desc)}`, 'mode: primary', 'color: primary'];
@@ -309,19 +310,43 @@ export function writePrimaryAgent(cfg, agentsDir, overrides, claim = (_dest) =>
309
310
  return dest;
310
311
  }
311
312
 
312
- /** `_build_emit._write_command_layer` — the opt-in /slash commands. */
313
- export function writeCommandLayer(cfg, items, commandDir, claim = (_dest) => true,
314
- skillDirOf = (n) => path.join(path.dirname(commandDir), 'skills', n).split(path.sep).join('/')) {
315
- if (!truthyEnv('GENESEED_COMMANDS')) return [];
316
- const byName = new Map();
313
+ /**
314
+ * The top-level skills of `items` (`src/skills/<stem>.md`, no `_` partials) as ordered
315
+ * `[stem, text]` pairs — the one parse both command writers below share.
316
+ */
317
+ function topLevelSkills(cfg, items) {
318
+ /** @type {[string, string][]} */
319
+ const found = [];
317
320
  for (const { text, src } of items) {
318
321
  if (text === null) continue;
319
322
  const sp = path.relative(cfg.src, src).split(path.sep);
320
323
  if (sp.length === 2 && sp[0] === 'skills' && sp[1].endsWith('.md')
321
324
  && !sp[1].startsWith('_')) {
322
- byName.set(sp[1].slice(0, -3), text);
325
+ found.push([sp[1].slice(0, -3), text]);
323
326
  }
324
327
  }
328
+ return found;
329
+ }
330
+
331
+ /** Both command writers' default `skillDirOf`: the skill's own directory beside `commandDir`. */
332
+ const siblingSkillDir = (commandDir) => (n) =>
333
+ path.join(path.dirname(commandDir), 'skills', n).split(path.sep).join('/');
334
+
335
+ /** A command has no base directory, so a side-file pointer names the skill's own. */
336
+ function commandBody(text, stem, skillDir) {
337
+ return stripSkillBodyLinks(pointSideFiles(lstripNewlines(text), stem, skillDir));
338
+ }
339
+
340
+ function writeCommandFile(dest, desc, body) {
341
+ mkdirSync(path.dirname(dest), { recursive: true });
342
+ writeText(dest, `---\ndescription: ${jsonDumps(desc)}\n---\n\n${body}`);
343
+ }
344
+
345
+ /** `_build_emit._write_command_layer` — the opt-in /slash commands. */
346
+ export function writeCommandLayer(cfg, items, commandDir, claim = (_dest) => true,
347
+ skillDirOf = siblingSkillDir(commandDir)) {
348
+ if (!truthyEnv('GENESEED_COMMANDS')) return [];
349
+ const byName = new Map(topLevelSkills(cfg, items));
325
350
  const written = [];
326
351
  for (const [name, skill] of Object.entries(COMMAND_SET)) {
327
352
  // A command runs a skill; `--exclude-skills` took that skill out, so the command goes too.
@@ -333,10 +358,7 @@ export function writeCommandLayer(cfg, items, commandDir, claim = (_dest) => tru
333
358
  const dest = path.join(commandDir, `${name}.md`);
334
359
  if (!claim(dest)) continue;
335
360
  const desc = firstBlockquote(text);
336
- // A command has no base directory, so a side-file pointer names the skill's own.
337
- const body = stripSkillBodyLinks(pointSideFiles(text.replace(/^\n+/, ''), skill, skillDirOf(skill)));
338
- mkdirSync(path.dirname(dest), { recursive: true });
339
- writeText(dest, `---\ndescription: ${jsonDumps(desc)}\n---\n\n${body}`);
361
+ writeCommandFile(dest, desc, commandBody(text, skill, skillDirOf(skill)));
340
362
  written.push(dest);
341
363
  }
342
364
  return written;
@@ -358,22 +380,15 @@ export function writeCommandLayer(cfg, items, commandDir, claim = (_dest) => tru
358
380
  * (Task 4) — it keeps its SKILL.md and does not need a second, command-shaped copy.
359
381
  */
360
382
  export function writeAliasCommands(cfg, items, commandDir, claim = (_dest) => true,
361
- skillDirOf = (n) => path.join(path.dirname(commandDir), 'skills', n).split(path.sep).join('/'),
362
- alsoUserOnly = false) {
383
+ skillDirOf = siblingSkillDir(commandDir), alsoUserOnly = false) {
363
384
  const written = [];
364
- for (const { text, src } of items) {
365
- if (text === null) continue;
366
- const sp = path.relative(cfg.src, src).split(path.sep);
367
- if (sp.length !== 2 || sp[0] !== 'skills' || !sp[1].endsWith('.md') || sp[1].startsWith('_')) continue;
368
- const stem = sp[1].slice(0, -3);
369
- const body = stripSkillBodyLinks(pointSideFiles(text.replace(/^\n+/, ''), stem, skillDirOf(stem)));
385
+ for (const [stem, text] of topLevelSkills(cfg, items)) {
386
+ const body = commandBody(text, stem, skillDirOf(stem));
370
387
  const names = alsoUserOnly && isUserInvokedOnly(text) ? [...aliasesOf(text), stem] : aliasesOf(text);
371
388
  for (const name of names) {
372
389
  const dest = path.join(commandDir, `${name}.md`);
373
390
  if (!claim(dest)) continue;
374
- mkdirSync(path.dirname(dest), { recursive: true });
375
- const desc = name === stem ? skillDescription(text) : `Alias of ${stem}.`;
376
- writeText(dest, `---\ndescription: ${jsonDumps(desc)}\n---\n\n${body}`);
391
+ writeCommandFile(dest, name === stem ? skillDescription(text) : `Alias of ${stem}.`, body);
377
392
  written.push(dest);
378
393
  }
379
394
  }
@@ -144,11 +144,25 @@ export function hashesOf(doc) {
144
144
  }
145
145
 
146
146
  /**
147
- * Where one owned file stands against its recorded hash — the table later tasks (rebuild,
148
- * prune, uninstall) will branch on to stop clobbering a user's edit to a Geneseed-owned file.
149
- * `'unknown'` is deliberately distinct from `'unmodified'`: an older manifest with no `hashes`
150
- * entry for this rel (today's installs, pre-Task-3) must not be read as "proven untouched" —
151
- * see the constraint that a manifest without `hashes` keeps today's behaviour exactly.
147
+ * An OpenCode manifest's `skill_denies` — the `permission.skill` denies Geneseed itself added
148
+ * (`opencodeLayer` records them; a deny the user wrote, even on a Geneseed skill, is never in
149
+ * it) — or `[]` when the manifest, or the key, is absent or malformed, which sweeps nothing.
150
+ * Unlike the Law IV `permission.bash` gates (permanent, never taken back — see
151
+ * `reconcileOpencodePermission`), a skill-visibility deny is reversible: once uninstalled there
152
+ * is no skill left to hide, and the name is the user's to reuse. One home because rebuild's
153
+ * deny sweep and uninstall must agree on what Geneseed owns.
154
+ */
155
+ export function recordedDenies(doc) {
156
+ const d = doc && doc.skill_denies;
157
+ return Array.isArray(d) ? d.filter((n) => typeof n === 'string') : [];
158
+ }
159
+
160
+ /**
161
+ * Where one owned file stands against its recorded hash — the table rebuild, prune and
162
+ * uninstall branch on so none of them clobbers a user's edit to a Geneseed-owned file.
163
+ * `'unknown'` is deliberately distinct from `'unmodified'`: a manifest written before hashes
164
+ * were recorded has no entry for this rel, and must not be read as "proven untouched" — a
165
+ * manifest without `hashes` keeps the pre-hash behaviour exactly.
152
166
  */
153
167
  export function ownedState(root, rel, hashes) {
154
168
  if (!Object.hasOwn(hashes, rel)) return 'unknown';
@@ -103,11 +103,16 @@ const consentRuleOn = (d, excluded = []) =>
103
103
  * like `-d`, so it is deliberately NOT a key here; `-D --force`/`--delete -f`/`-d --force`/
104
104
  * `-df` are the same act again and are a known ceiling this does not chase); `checkout
105
105
  * -f`/`--force` and `switch -f`/`--force`/`--discard-changes` (git's own `switch -h` lists
106
- * `-f, --force` and `--discard-changes` as two separate options); `worktree remove -f`/`--force` (both
107
- * flag-then-path and path-then-flag order); `reflog expire`; `gc --prune` (matched bare, since
108
- * a `--prune` with no `=now` still eventually prunes); and `push *--delete*` / `push * :*` (a
109
- * literal space before the colon, so `push origin HEAD:main`, an ordinary refspec, does not
110
- * match).
106
+ * `-f, --force` and `--discard-changes` as two separate options); `reflog expire`; `gc
107
+ * --prune` (matched bare, since a `--prune` with no `=now` still eventually prunes); and `push
108
+ * *--delete*` / `push * :*` (a literal space before the colon, so `push origin HEAD:main`, an
109
+ * ordinary refspec, does not match).
110
+ *
111
+ * `git worktree remove -f`/`--force` was a key here and was REMOVED (user decision, 2026-10-17:
112
+ * worktree removal is routine housekeeping, not a Law IV deletion). Removal from this list also
113
+ * removes the four globs from `OWNED_BASH`, so an `opencode.json` written before keeps them —
114
+ * neither taken back nor reported as stranded, since Geneseed no longer claims them. Its user
115
+ * deletes them by hand (the CHANGELOG upgrade note says so).
111
116
  *
112
117
  * `git restore` is DELIBERATELY NOT HERE. Its rule is "ask unless `--staged` appears ALONE" —
113
118
  * `--staged --worktree` together still discards the working tree — and a glob has no negation,
@@ -128,8 +133,6 @@ const LAW_IV_BASH = ['git push --force*', 'git push -f*', 'git push *--force*',
128
133
  'git checkout -f*', 'git checkout * -f*', 'git checkout --force*', 'git checkout * --force*',
129
134
  'git switch -f*', 'git switch * -f*', 'git switch --force*', 'git switch * --force*',
130
135
  'git switch --discard-changes*', 'git switch * --discard-changes*',
131
- 'git worktree remove -f*', 'git worktree remove --force*',
132
- 'git worktree remove * -f*', 'git worktree remove * --force*',
133
136
  'git reflog expire*', 'git gc --prune*', 'git gc * --prune*',
134
137
  'git stash drop*', 'git stash clear*',
135
138
  'git push *--delete*', 'git push * :*'];
@@ -145,7 +148,8 @@ const LAW_IV_BASH = ['git push --force*', 'git push -f*', 'git push *--force*',
145
148
  * `-u origin`/`--set-upstream origin`/any remote through its leading `*`; the second key covers
146
149
  * the `refs/heads/` spelling the same way.
147
150
  *
148
- * ORDER IS LOAD-BEARING. `defaultPermission` appends these AFTER `git push*`'s ask (so a loop
151
+ * ORDER IS LOAD-BEARING. `PUSH_CHAIN` (which `defaultPermission` and `OWNED_BASH` both read)
152
+ * puts these AFTER `git push*`'s ask (so a loop
149
153
  * push is not swallowed by the blanket consent ask) and BEFORE `LAW_IV_BASH` (so `findLast`
150
154
  * — OpenCode's own `permission.bash` evaluator — lets a force push, a `+refspec`, or a
151
155
  * `--delete`/bare-`:` push still ask even when the target is `loop/*`; those LAW_IV_BASH keys
@@ -222,18 +226,22 @@ const LOOP_PUSH_ASK_FLAG_EXTRA = [
222
226
  ];
223
227
  const LOOP_PUSH_ASK_TRAILING = ['git push * HEAD:loop/* ?*', 'git push * HEAD:refs/heads/loop/* ?*'];
224
228
 
229
+ /**
230
+ * The tiers after `git push*`, in the `findLast` order the docblocks above depend on — ONE
231
+ * ordered table, so `defaultPermission` and `OWNED_BASH` cannot disagree about it.
232
+ */
233
+ const PUSH_CHAIN = [
234
+ [LOOP_PUSH_ALLOW, 'allow'], [LOOP_PUSH_ASK_EXTRA, 'ask'], [LOOP_PUSH_ALLOW_FLAG, 'allow'],
235
+ [LOOP_PUSH_ASK_FLAG_EXTRA, 'ask'], [LOOP_PUSH_ASK_TRAILING, 'ask'], [LAW_IV_BASH, 'ask'],
236
+ ];
237
+
225
238
  function defaultPermission(doctrines = null, excluded = []) {
226
239
  const bash = { 'rm -rf *': 'ask' };
227
240
  if (consentRuleOn(doctrines, excluded)) {
228
241
  bash['git commit*'] = 'ask';
229
242
  bash['git push*'] = 'ask';
230
243
  }
231
- for (const k of LOOP_PUSH_ALLOW) bash[k] = 'allow';
232
- for (const k of LOOP_PUSH_ASK_EXTRA) bash[k] = 'ask';
233
- for (const k of LOOP_PUSH_ALLOW_FLAG) bash[k] = 'allow';
234
- for (const k of LOOP_PUSH_ASK_FLAG_EXTRA) bash[k] = 'ask';
235
- for (const k of LOOP_PUSH_ASK_TRAILING) bash[k] = 'ask';
236
- for (const k of LAW_IV_BASH) bash[k] = 'ask';
244
+ for (const [keys, verdict] of PUSH_CHAIN) for (const k of keys) bash[k] = verdict;
237
245
  return { bash };
238
246
  }
239
247
 
@@ -245,8 +253,7 @@ function defaultPermission(doctrines = null, excluded = []) {
245
253
  * the pack returns, and to name it when this build no longer wants it but will not remove it.
246
254
  */
247
255
  const OWNED_BASH = ['rm -rf *', 'git commit*', 'git push*',
248
- ...LOOP_PUSH_ALLOW, ...LOOP_PUSH_ASK_EXTRA, ...LOOP_PUSH_ALLOW_FLAG, ...LOOP_PUSH_ASK_FLAG_EXTRA,
249
- ...LOOP_PUSH_ASK_TRAILING, ...LAW_IV_BASH];
256
+ ...PUSH_CHAIN.flatMap(([keys]) => keys)];
250
257
 
251
258
  /**
252
259
  * Bring an ALREADY-WRITTEN `permission` block back into line with the pack selection, and
@@ -626,7 +633,8 @@ export function mergeOpencodeJson(p, agentPath, doctrines = null, excluded = [],
626
633
  // skill reconcile above never ran either — the unwired skill denies are folded into THIS
627
634
  // one WARN rather than going unmentioned (a dedicated `skillOpaque` needs `perm` to be a
628
635
  // dict to even look at `perm.skill`, which this case by definition is not).
629
- const names = [...OWNED_BASH.filter((k) => has(defaultPermission(doctrines, excluded).bash, k)),
636
+ const wanted = defaultPermission(doctrines, excluded).bash;
637
+ const names = [...OWNED_BASH.filter((k) => has(wanted, k)),
630
638
  ...(!permIsObject ? skillNames : [])];
631
639
  process.stderr.write(`[geneseed] WARN: "permission" in ${path.basename(target)} `
632
640
  + `${opaque}, so Geneseed cannot wire its own gates into it — including `
@@ -1236,7 +1244,8 @@ export function managedBlockRead(p, blockId = 'GENESEED') {
1236
1244
  return stripNewlines(inner.slice(0, inner.indexOf(end)));
1237
1245
  }
1238
1246
 
1239
- function lstripNewlines(s) {
1247
+ /** `str.lstrip("\n")`; the one copy — `native.mjs` and `opencode.mjs` import it from here. */
1248
+ export function lstripNewlines(s) {
1240
1249
  return s.replace(/^\n+/, '');
1241
1250
  }
1242
1251
 
package/js/hosts/shim.mjs CHANGED
@@ -9,8 +9,9 @@
9
9
  * `js/build/driver.mjs`, and through it the whole generator, for the two-line
10
10
  * `hookRunnerEntry`, which now lives here beside the `hookPrefix` that consumes its answer.
11
11
  *
12
- * Keep this module's imports to `node:` builtins, `js/lib/fs.mjs` and `./hosts.mjs`: being
13
- * cheap to load is its whole reason to exist.
12
+ * Keep this module's imports to `node:` builtins, `js/lib/fs.mjs`, `./hosts.mjs` and
13
+ * `./hooks-prims.mjs` (whose only import, `js/lib/paths.mjs`, `./hosts.mjs` already loads):
14
+ * being cheap to load is its whole reason to exist.
14
15
  */
15
16
  import {
16
17
  chmodSync, existsSync, mkdirSync, realpathSync, renameSync, statSync, writeFileSync,
@@ -19,6 +20,7 @@ import path from 'node:path';
19
20
  import os from 'node:os';
20
21
  import { readText, isOsError } from '../lib/fs.mjs';
21
22
  import { expanduser } from './hosts.mjs';
23
+ import { isFile } from './hooks-prims.mjs';
22
24
 
23
25
  /** The checkout this module ships in — `js/build/source.mjs`'s `ROOT`, computed locally so the
24
26
  * shim does not import the source loader. */
@@ -295,7 +297,6 @@ export function claudeHookShell(env = process.env, platform = process.platform)
295
297
  */
296
298
  export function claudeGitBashPath(env = process.env) {
297
299
  const read = (name) => Object.entries(env).find(([k]) => k.toUpperCase() === name)?.[1];
298
- const isFile = (p) => { try { return statSync(p).isFile(); } catch { return false; } };
299
300
  const forced = read('CLAUDE_CODE_GIT_BASH_PATH');
300
301
  if (forced && GIT_BASH_NAMES.has(path.basename(forced).toLowerCase()) && isFile(forced)) {
301
302
  return forced;
@@ -26,8 +26,9 @@ const NAME_RE = /^[A-Za-z0-9][\w.:@/-]{0,99}$/;
26
26
  /**
27
27
  * `{kind, name}` for a skill or subagent call, else null. Reads the Claude shape (`tool_name` /
28
28
  * `tool_input`: `Skill` → `skill`, `Agent` or the older `Task` → `subagent_type`), OpenCode's
29
- * (`skill` → `name`, `task` → `subagent_type`) and, best-effort, Bob's (`tool` / `input`, any
30
- * tool whose name contains "skill"): Bob's skill tool name is unverified, no Bob runs here.
29
+ * (`skill` → `name`, `task` → `subagent_type`; V2 hooks see the pre-alias `subagent` → `agent`)
30
+ * and, best-effort, Bob's (`tool` / `input`, any tool whose name contains "skill"): Bob's skill
31
+ * tool name is unverified, no Bob runs here.
31
32
  */
32
33
  export function usageOf(payload) {
33
34
  if (!payload || typeof payload !== 'object') return null;
@@ -38,7 +39,7 @@ export function usageOf(payload) {
38
39
  if (!tool.startsWith('mcp') && tool.includes('skill')) {
39
40
  kind = 'skill';
40
41
  name = ti.skill ?? ti.name ?? ti.skill_name;
41
- } else if (tool === 'agent' || tool === 'task') {
42
+ } else if (tool === 'agent' || tool === 'task' || tool === 'subagent') {
42
43
  kind = 'agent';
43
44
  name = ti.subagent_type ?? ti.agent;
44
45
  }
@@ -14,8 +14,10 @@
14
14
  */
15
15
  import path from 'node:path';
16
16
  import { PLUGIN_SRC, SRC } from '../build/source.mjs';
17
+ import { HOSTS } from '../hosts/hosts.mjs';
17
18
  import { descBlockProblem, firstBlockquote } from '../hosts/native.mjs';
18
19
  import { readText } from '../lib/fs.mjs';
20
+ import { blankCodeSpans } from '../lib/text.mjs';
19
21
  import { which } from '../lib/paths.mjs';
20
22
  import { NO_WINDOW } from '../lib/proc.mjs';
21
23
  import {
@@ -25,15 +27,15 @@ import {
25
27
  import { constitutionProblems, leanBlockProblems } from './checks-constitution.mjs';
26
28
  import { countTableProblems } from './checks-counts.mjs';
27
29
  import { catalogProblems } from '../loop/catalog.mjs';
28
- import { blankCodeSpans, globSorted, isDir, isFile, rglob } from './scan.mjs';
30
+ import { globSorted, isDir, isFile, rglob } from './scan.mjs';
29
31
  import { spawnSync } from 'node:child_process';
30
32
 
31
- // The four harness ids `js/web/docs.mjs`'s `HARNESS_OPEN_RE` accepts, comma-separated, in its
32
- // own `HARNESSES` constant. Not imported: `js/web/docs.mjs` pulls in the web daemon's CLI
33
- // reference, status and update modules, and `inspect/` has no existing import from `web/` to
34
- // extend — paying that weight on every `doctor --all` for one id list is not "cheap" reuse.
35
- // Inlined instead, so it must be kept in step by hand if `HARNESSES` ever changes.
36
- const HARNESS_IDS = 'opencode|claude|openclaude|bob';
33
+ // The harness ids `js/web/docs.mjs`'s `HARNESS_OPEN_RE` accepts — its `HARNESSES`, keyed by the
34
+ // same host ids as `HOSTS`. Not imported from there: `js/web/docs.mjs` pulls in the web daemon's
35
+ // CLI reference, status and update modules, a weight every `doctor --all` would pay for one id
36
+ // list. `HOSTS` is the ids' own home and already in doctor's import closure. Alternation order
37
+ // does not matter: the pattern is anchored at both ends.
38
+ const HARNESS_IDS = HOSTS.map((h) => h.host).join('|');
37
39
 
38
40
  /**
39
41
  * The comment bodies (trimmed) that are known to be FUNCTIONAL — consumed by the render, a
@@ -25,8 +25,7 @@
25
25
  * `GENESEED_GATE_PROBE=1` keeps the probe's asks out of the gate ledger (`ledger()` in
26
26
  * `js/hosts/hooks.mjs`), so `status` never counts a doctor run as a refusal.
27
27
  */
28
- import { existsSync, mkdtempSync, rmSync } from 'node:fs';
29
- import os from 'node:os';
28
+ import { existsSync } from 'node:fs';
30
29
  import path from 'node:path';
31
30
  import { pathToFileURL } from 'node:url';
32
31
  import { liveRecordedHooks } from '../hosts/hosts.mjs';
@@ -34,7 +33,7 @@ import { claudeCfg, installState, installTargets } from '../hosts/installs.mjs';
34
33
  import { SHIM_MARK, claudeGitBashPath } from '../hosts/shim.mjs';
35
34
  import { which } from '../lib/paths.mjs';
36
35
  import { NO_WINDOW } from '../lib/proc.mjs';
37
- import { NOTE, withTempDir } from './scan.mjs';
36
+ import { NOTE, withTempDir, withTempDirAsync } from './scan.mjs';
38
37
  import { spawnSync } from 'node:child_process';
39
38
 
40
39
  /**
@@ -43,7 +42,7 @@ import { spawnSync } from 'node:child_process';
43
42
  * runs everything through `tool-gate --host bob`. OpenCode's guard is an in-process plugin with
44
43
  * no command: `guardProbe` below imports it instead.
45
44
  */
46
- export const PROBED_VERB = { claude: 'git-gate', openclaude: 'git-gate', bob: 'tool-gate' };
45
+ const PROBED_VERB = { claude: 'git-gate', openclaude: 'git-gate', bob: 'tool-gate' };
47
46
 
48
47
  export const BAD_COMMAND = 'git reset --hard HEAD';
49
48
  export const GOOD_COMMAND = 'git status';
@@ -51,7 +50,7 @@ export const GOOD_COMMAND = 'git status';
51
50
  const PROBE_TIMEOUT_MS = 20_000;
52
51
 
53
52
  /** A PreToolUse payload in Claude's shape — the shape every probed host's gate reads. */
54
- export function probePayload(command, cwd) {
53
+ function probePayload(command, cwd) {
55
54
  return JSON.stringify({
56
55
  session_id: 'geneseed-probe', hook_event_name: 'PreToolUse', tool_name: 'Bash',
57
56
  tool_input: { command }, cwd,
@@ -64,7 +63,7 @@ export function probePayload(command, cwd) {
64
63
  * `shell: "powershell"` wins; Bob runs `cmd /c` on Windows; Claude/OpenClaude run Git Bash
65
64
  * there (`claudeGitBashPath`, the rule `claudeHookShell` uses); `sh -c` everywhere else.
66
65
  */
67
- export function probeShell(host, handler, platform = process.platform, env = process.env) {
66
+ function probeShell(host, handler, platform = process.platform, env = process.env) {
68
67
  const cmd = handler.command;
69
68
  if (handler.shell === 'powershell') {
70
69
  const ps = which('powershell') || which('pwsh');
@@ -173,10 +172,10 @@ export function gateProbe(targets = installTargets(), { platform = process.platf
173
172
 
174
173
  // ---- OpenCode: the guard plugin, probed in-process ------------------------------------------
175
174
 
176
- export const GUARD_PLUGIN = 'geneseed-guard.js';
175
+ const GUARD_PLUGIN = 'geneseed-guard.js';
177
176
  /** The guard blocks catastrophic shell (`SHELL_BLOCK_RE`); destructive git is the permission
178
177
  * prompt's job there, and only WARNS in the guard — so its must-refuse payload is this one. */
179
- export const GUARD_BAD_COMMAND = 'rm -rf /';
178
+ const GUARD_BAD_COMMAND = 'rm -rf /';
180
179
 
181
180
  /** The guard plugin of an OpenCode install: `<global>/plugins/` or `<repo>/.opencode/plugins/`
182
181
  * (`emitOpencodeInto` copies the plugins to `<dir>/plugins`). */
@@ -203,7 +202,7 @@ async function quietProbe(fn) {
203
202
  * (`setupV2`'s docblock). An unexpected throw is not a refusal — it is rethrown, and the caller
204
203
  * reports the guard as broken.
205
204
  */
206
- export async function runGuard(mod, cwd) {
205
+ async function runGuard(mod, cwd) {
207
206
  const v1 = async (command) => {
208
207
  const hooks = await mod.server({ worktree: cwd, directory: cwd });
209
208
  try {
@@ -233,7 +232,7 @@ export async function runGuard(mod, cwd) {
233
232
  }
234
233
 
235
234
  /** The verdict for one API's pair — null when sound. */
236
- export function guardVerdict([badRefused, goodRefused]) {
235
+ function guardVerdict([badRefused, goodRefused]) {
237
236
  if (!badRefused) return `registered but not refusing — \`${GUARD_BAD_COMMAND}\` went through`;
238
237
  if (goodRefused) return `refuses everything — \`${GOOD_COMMAND}\` was refused too`;
239
238
  return null;
@@ -269,13 +268,17 @@ export async function guardProbe(targets = installTargets(), env = process.env)
269
268
  }
270
269
  let verdicts;
271
270
  try {
271
+ // ESM under OpenCode's typeless package.json: parses only with syntax detection (Node >=
272
+ // 22.7, the floor). Its MODULE_TYPELESS_PACKAGE_JSON warning fires a tick later, inside
273
+ // quietProbe's stderr swap, so the doctor never prints it.
272
274
  const mod = (await import(pathToFileURL(file).href)).default;
273
275
  if (mod?.id !== 'geneseed-guard' || typeof mod.server !== 'function' || typeof mod.setup !== 'function') {
274
276
  problems.push(`${label}: not probed — it does not export the Geneseed guard `
275
277
  + '({ id, server, setup }) — run: geneseed rebuild-all');
276
278
  continue;
277
279
  }
278
- verdicts = await withTempDirAsync((tmp) => quietProbe(() => runGuard(mod, tmp)));
280
+ verdicts = await withTempDirAsync(
281
+ (tmp) => quietProbe(() => runGuard(mod, tmp)), 'geneseed-probe-');
279
282
  } catch (e) {
280
283
  problems.push(`${label}: does not run (${e?.message || e})`);
281
284
  continue;
@@ -287,9 +290,3 @@ export async function guardProbe(targets = installTargets(), env = process.env)
287
290
  }
288
291
  return { problems, probed };
289
292
  }
290
-
291
- /** `withTempDir` for an async body: the dir outlives the promise, then goes. */
292
- async function withTempDirAsync(fn) {
293
- const tmp = mkdtempSync(path.join(os.tmpdir(), 'geneseed-probe-'));
294
- try { return await fn(tmp); } finally { rmSync(tmp, { recursive: true, force: true }); }
295
- }
@@ -1,6 +1,7 @@
1
1
  /**
2
- * The checks over the REPO's own records — the registry, committed secrets, the hook shim
3
- * and the vendored skill folders (their pins and their SKILL.md frontmatter).
2
+ * The checks over the REPO's own records — the registry, committed secrets, the hook shim,
3
+ * the vendored skill folders (their pins and their SKILL.md frontmatter), skill aliases and
4
+ * side files, the `js/README.md` module map, the host tables, and the scorecard ratchet.
4
5
  *
5
6
  * Same contract as `checks-build.mjs`: an array of problem strings, empty when clean, one
6
7
  * planted fault per check in the gate. This is the group that reads what the repo SAYS about
@@ -10,8 +11,8 @@ import path from 'node:path';
10
11
  import {
11
12
  ALIASES_RE, PLUGIN_SRC, RETIRED_SKILL_IDS, ROOT, SRC, THEMES, WORKFLOW_SRC, aliasesOf, knownSkillIds,
12
13
  } from '../build/source.mjs';
13
- import { CLAUDE_STYLE, HOSTS, liveRecordedHooks } from '../hosts/hosts.mjs';
14
- import { claudeCfg, installTargets } from '../hosts/installs.mjs';
14
+ import { HOSTS } from '../hosts/hosts.mjs';
15
+ import { installTargets, installsReferencingShim } from '../hosts/installs.mjs';
15
16
  import { VENDORED_SKILL_DIRS } from '../hosts/native.mjs';
16
17
  import { COMMAND_SET } from '../hosts/opencode.mjs';
17
18
  import { hookShimPath, shimDeadPaths } from '../hosts/shim.mjs';
@@ -41,12 +42,8 @@ function registryKeys() {
41
42
  const SEMVER_RE = /^\d+\.\d+\.\d+$/;
42
43
  const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
43
44
 
44
- // `ENTITY_STATUSES`, `LAW_CLASSES`, `LAW_CLASS` and `SKILL_CLASS` moved to
45
- // `js/inspect/inventory.mjs` in P6c — see the import above. They crossed HERE in P5g because
46
- // doctor's authoring gates are what validate them, and they belong where Python keeps
47
- // them (`_harness_tui`) now that the catalog reads them too. The gates below are
48
- // unchanged, which is what licensed the move: a copy would have stopped being the
49
- // value under test the first time one of them was edited.
45
+ // `ENTITY_STATUSES` is imported from `js/inspect/inventory.mjs`, not copied: the catalog reads
46
+ // it too, and a copy would stop being the value under test the first time one side changed.
50
47
 
51
48
  /**
52
49
  * `_harness_build._registry_problems` — `registry.json` describes exactly what `src/`
@@ -167,44 +164,6 @@ export function secretProblems() {
167
164
  return problems;
168
165
  }
169
166
 
170
- /**
171
- * Every active Claude-STYLE install (`CLAUDE_STYLE`: claude, bob, openclaude — the hosts that
172
- * share this one machine-wide shim) whose LIVE, recorded hooks still name `shimPath` by exact
173
- * string, keyed by its own config dir.
174
- *
175
- * Split out of `shimProblems` for the case `shimDeadPaths` cannot see: that function reads
176
- * paths back OUT of the shim body, so it has nothing to read once the shim file is gone
177
- * outright (deleted, not moved-and-regenerated) — a plain `isFile` check upstream then calls
178
- * that clean, the same as a source checkout that never emitted. `liveRecordedHooks` is already
179
- * "the manifest claims it AND the settings file still runs it" (`js/hosts/hosts.mjs`), so a
180
- * disabled or never-wired install cannot appear here — only installs for which the missing
181
- * shim is a live, standing failure.
182
- *
183
- * MATCHES THE EXACT PATH, not merely a command that mentions the shim's filename
184
- * (`geneseed-hook`): the shim path is baked per `GENESEED_HOME`, so a machine with more than
185
- * one Geneseed home (a relocated one, or a sandboxed test run beside a real install) must not
186
- * let an install naming a DIFFERENT, perfectly healthy shim answer for this one.
187
- *
188
- * EXPORTED: `status`'s `gateSummary` (`js/inspect/status.mjs`) needs the same answer to fold
189
- * into its `dead` field, the one `statusLines` already renders as a `DEAD` gates row — two
190
- * readers of one fact, not two copies of the install walk.
191
- *
192
- * `targets` is a parameter, the same convention `hookShellProblems` uses, so a test can hand
193
- * it a sandboxed `[host, scope, root]` triple instead of walking this machine's real config
194
- * dirs and registry — the live default every production caller takes.
195
- */
196
- export function installsReferencingShim(shimPath = hookShimPath(), targets = installTargets()) {
197
- const out = [];
198
- for (const [host, scope, root] of targets) {
199
- if (!CLAUDE_STYLE.includes(host)) continue;
200
- const cfg = claudeCfg(root, scope, host);
201
- const live = liveRecordedHooks(cfg)
202
- .some(([, h]) => typeof h.command === 'string' && h.command.includes(shimPath));
203
- if (live) out.push(cfg);
204
- }
205
- return out;
206
- }
207
-
208
167
  /**
209
168
  * `_harness_build._shim_problems` — the hook shim exists and points at something real.
210
169
  *
@@ -220,7 +179,7 @@ export function installsReferencingShim(shimPath = hookShimPath(), targets = ins
220
179
  * reason to own one. But one deleted by hand (or by a cleanup script) out from under an
221
180
  * install that still names it is the same dead-hooks failure as a moved checkout, newly worth
222
181
  * catching now that the two Claude gates fail CLOSED on a dead hook instead of open
223
- * (`onFailure: "block"`, Task 1) — a Bash/Write/Edit call blocked with no shim to even try
182
+ * (`onFailure: "block"`) — a Bash/Write/Edit call blocked with no shim to even try
224
183
  * running is otherwise invisible until the user notices nothing works.
225
184
  * `installsReferencingShim` answers that half; this still returns `[]` when nothing live
226
185
  * names the missing shim.
@@ -243,7 +202,7 @@ export function shimProblems(targets = installTargets()) {
243
202
  if (!isFile(p)) {
244
203
  return installsReferencingShim(p, targets).map((cfg) => `[shim] ${p} does not exist, but ${cfg} `
245
204
  + 'still runs its hooks through it — every hook in that install is dead, and (since '
246
- + "Task 1) Claude's git-gate/rule-gate now BLOCK instead of silently passing. Run: "
205
+ + "Geneseed 4.0.0) Claude's git-gate/rule-gate now BLOCK instead of silently passing. Run: "
247
206
  + 'geneseed rebuild-all');
248
207
  }
249
208
  let body;
@@ -597,7 +556,7 @@ export function hostMatrixProblems(root = ROOT) {
597
556
  * invisible here. A check this repo starts to pass is added by hand; one it stops passing is a
598
557
  * doctor problem until fixed or deliberately removed from this list, in the same commit.
599
558
  */
600
- export const SCORECARD_FLOOR = [
559
+ const SCORECARD_FLOOR = [
601
560
  'architecture_doc',
602
561
  'agent_instructions',
603
562
  'adr_presence',
@@ -630,6 +589,9 @@ export const SCORECARD_FLOOR = [
630
589
  'security_critical_marking',
631
590
  ];
632
591
 
592
+ /** Where the README's scorecard card lives, and what doctor compares against a fresh render. */
593
+ const SCORECARD_CARD = 'docs/assets/scorecard.svg';
594
+
633
595
  /**
634
596
  * The scorecard ratchet — one problem per `SCORECARD_FLOOR` check that no longer passes.
635
597
  *
@@ -659,6 +621,3 @@ export function scorecardProblems(root = ROOT) {
659
621
  + (k ? `: ${k.evidence}. Fix: ${k.remediation}` : ', and the scorecard has no such check');
660
622
  }));
661
623
  }
662
-
663
- /** Where the README's scorecard card lives, and what doctor compares against a fresh render. */
664
- export const SCORECARD_CARD = 'docs/assets/scorecard.svg';
@@ -6,8 +6,9 @@
6
6
  *
7
7
  * Four parts, each the thing the host loads before the user types:
8
8
  * - `carrier` the root instruction file (`AGENT.md`, `CLAUDE.md`, Bob's `rules/geneseed.md` or
9
- * `AGENTS.md`), CRLF decoded exactly as the smoke test decodes it, so the figure
10
- * and `CARRIER_CEILING` are the same measurement.
9
+ * `AGENTS.md`), CRLF folded to LF as the smoke test folds it, so the figure and
10
+ * `CARRIER_CEILING` are the same measurement. `readText` also folds a lone CR,
11
+ * which the smoke test does not, so the two agree on any carrier without one.
11
12
  * - `skills` the name + description every native skill lists (aliases included: they are
12
13
  * SKILL.md files the host lists like any other).
13
14
  * - `agents` the same for native agents; Bob has none, its agents table is in the carrier.
@@ -30,8 +31,6 @@ import { isFile, readText } from '../lib/fs.mjs';
30
31
  */
31
32
  export const CARRIER_CEILING = { full: 62_219, lean: 39_088 };
32
33
 
33
- const decode = (p) => readText(p).split('\r\n').join('\n');
34
-
35
34
  /**
36
35
  * A frontmatter value as the host shows it. The emit writes descriptions JSON-escaped
37
36
  * (`—` for one em dash), and `frontmatter()` has already stripped the quotes, so the
@@ -48,7 +47,7 @@ function fmValue(raw) {
48
47
  /** name + description of one spec file; `fallbackName` for agents, whose name is the file stem. */
49
48
  function listedChars(file, fallbackName) {
50
49
  let fm;
51
- try { [fm] = frontmatter(decode(file)); } catch { return 0; }
50
+ try { [fm] = frontmatter(readText(file)); } catch { return 0; }
52
51
  const name = fmValue(fm.get('name')) || fallbackName;
53
52
  return name.length + fmValue(fm.get('description')).length;
54
53
  }
@@ -61,7 +60,7 @@ function listedChars(file, fallbackName) {
61
60
  export function alwaysOnCost({ host, root, dir, footprint }) {
62
61
  const carrierPath = carriersFor(host).map((rel) => path.join(root, rel)).find(isFile);
63
62
  if (!carrierPath) return null;
64
- const carrier = decode(carrierPath).length;
63
+ const carrier = readText(carrierPath).length;
65
64
 
66
65
  let skills = 0;
67
66
  const skillsDir = path.join(dir, 'skills');
@@ -241,8 +241,8 @@ export function doctorCollect({
241
241
  problems = problems.concat(ran('loops', 'Loop catalogue', loopProblems()));
242
242
  problems = problems.concat(ran('shim', 'Hook shim', shimProbs));
243
243
  // Windows: a Claude install's hooks emitted for Git Bash on a machine that no longer has it
244
- // misbehave under PowerShell (Task 15) — context/learn fail open, and since Task 1 the two
245
- // gates fail CLOSED instead (`onFailure: "block"`). The reverse direction is a note.
244
+ // misbehave under PowerShell — context/learn fail open, and the two gates fail CLOSED
245
+ // instead (`onFailure: "block"`). The reverse direction is a note.
246
246
  problems = problems.concat(ran('hook_shell', 'Hook shell', hookShellProblems()));
247
247
  if (probeRun) {
248
248
  problems = problems.concat(ran('probe', 'Gate probe', [...probeRun.problems, ...extraProbeProblems]));
@@ -14,15 +14,13 @@ import { SRC } from '../build/source.mjs';
14
14
  import { comparePaths, within } from '../lib/paths.mjs';
15
15
  import { isDir, isFile } from '../lib/fs.mjs';
16
16
  import { has } from '../lib/json.mjs';
17
- import { FENCE_RE, INLINE_CODE_RE, blankCodeSpans } from '../lib/text.mjs';
17
+ import { FENCE_RE, INLINE_CODE_RE } from '../lib/text.mjs';
18
18
  import { mkdtempSync, readdirSync, rmSync } from 'node:fs';
19
19
 
20
20
  // `isDir`/`isFile`/`has` are owned by `js/lib` now (single owner across `hosts/`, `build/`,
21
21
  // `inspect/` and `web/`) — re-exported here so this module's own existing callers keep
22
- // importing them from `./scan.mjs` unchanged. `blankCodeSpans` is `js/lib/text.mjs`'s too, for
23
- // the same reason: `js/build/source.mjs`'s `stripAuthorNotes` needs the identical fence/code-span
24
- // answer and cannot import this module (it would cycle back through `../build/source.mjs` above).
25
- export { isDir, isFile, has, blankCodeSpans };
22
+ // importing them from `./scan.mjs` unchanged.
23
+ export { isDir, isFile, has };
26
24
 
27
25
  // --------------------------------------------------------------------------------------
28
26
  // _harness_core's scanning primitives
@@ -106,3 +104,9 @@ export function withTempDir(fn, prefix = 'geneseed-doctor-') {
106
104
  try { return fn(tmp); } finally { rmSync(tmp, { recursive: true, force: true }); }
107
105
  }
108
106
 
107
+ /** `withTempDir` for an async body: the dir outlives the promise, then goes. */
108
+ export async function withTempDirAsync(fn, prefix = 'geneseed-doctor-') {
109
+ const tmp = mkdtempSync(path.join(os.tmpdir(), prefix));
110
+ try { return await fn(tmp); } finally { rmSync(tmp, { recursive: true, force: true }); }
111
+ }
112
+