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
@@ -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';
@@ -145,7 +145,8 @@ const LAW_IV_BASH = ['git push --force*', 'git push -f*', 'git push *--force*',
145
145
  * `-u origin`/`--set-upstream origin`/any remote through its leading `*`; the second key covers
146
146
  * the `refs/heads/` spelling the same way.
147
147
  *
148
- * ORDER IS LOAD-BEARING. `defaultPermission` appends these AFTER `git push*`'s ask (so a loop
148
+ * ORDER IS LOAD-BEARING. `PUSH_CHAIN` (which `defaultPermission` and `OWNED_BASH` both read)
149
+ * puts these AFTER `git push*`'s ask (so a loop
149
150
  * push is not swallowed by the blanket consent ask) and BEFORE `LAW_IV_BASH` (so `findLast`
150
151
  * — OpenCode's own `permission.bash` evaluator — lets a force push, a `+refspec`, or a
151
152
  * `--delete`/bare-`:` push still ask even when the target is `loop/*`; those LAW_IV_BASH keys
@@ -222,18 +223,22 @@ const LOOP_PUSH_ASK_FLAG_EXTRA = [
222
223
  ];
223
224
  const LOOP_PUSH_ASK_TRAILING = ['git push * HEAD:loop/* ?*', 'git push * HEAD:refs/heads/loop/* ?*'];
224
225
 
226
+ /**
227
+ * The tiers after `git push*`, in the `findLast` order the docblocks above depend on — ONE
228
+ * ordered table, so `defaultPermission` and `OWNED_BASH` cannot disagree about it.
229
+ */
230
+ const PUSH_CHAIN = [
231
+ [LOOP_PUSH_ALLOW, 'allow'], [LOOP_PUSH_ASK_EXTRA, 'ask'], [LOOP_PUSH_ALLOW_FLAG, 'allow'],
232
+ [LOOP_PUSH_ASK_FLAG_EXTRA, 'ask'], [LOOP_PUSH_ASK_TRAILING, 'ask'], [LAW_IV_BASH, 'ask'],
233
+ ];
234
+
225
235
  function defaultPermission(doctrines = null, excluded = []) {
226
236
  const bash = { 'rm -rf *': 'ask' };
227
237
  if (consentRuleOn(doctrines, excluded)) {
228
238
  bash['git commit*'] = 'ask';
229
239
  bash['git push*'] = 'ask';
230
240
  }
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';
241
+ for (const [keys, verdict] of PUSH_CHAIN) for (const k of keys) bash[k] = verdict;
237
242
  return { bash };
238
243
  }
239
244
 
@@ -245,8 +250,7 @@ function defaultPermission(doctrines = null, excluded = []) {
245
250
  * the pack returns, and to name it when this build no longer wants it but will not remove it.
246
251
  */
247
252
  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];
253
+ ...PUSH_CHAIN.flatMap(([keys]) => keys)];
250
254
 
251
255
  /**
252
256
  * Bring an ALREADY-WRITTEN `permission` block back into line with the pack selection, and
@@ -626,7 +630,8 @@ export function mergeOpencodeJson(p, agentPath, doctrines = null, excluded = [],
626
630
  // skill reconcile above never ran either — the unwired skill denies are folded into THIS
627
631
  // one WARN rather than going unmentioned (a dedicated `skillOpaque` needs `perm` to be a
628
632
  // 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)),
633
+ const wanted = defaultPermission(doctrines, excluded).bash;
634
+ const names = [...OWNED_BASH.filter((k) => has(wanted, k)),
630
635
  ...(!permIsObject ? skillNames : [])];
631
636
  process.stderr.write(`[geneseed] WARN: "permission" in ${path.basename(target)} `
632
637
  + `${opaque}, so Geneseed cannot wire its own gates into it — including `
@@ -1236,7 +1241,8 @@ export function managedBlockRead(p, blockId = 'GENESEED') {
1236
1241
  return stripNewlines(inner.slice(0, inner.indexOf(end)));
1237
1242
  }
1238
1243
 
1239
- function lstripNewlines(s) {
1244
+ /** `str.lstrip("\n")`; the one copy — `native.mjs` and `opencode.mjs` import it from here. */
1245
+ export function lstripNewlines(s) {
1240
1246
  return s.replace(/^\n+/, '');
1241
1247
  }
1242
1248
 
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
+
@@ -12,10 +12,10 @@
12
12
  * on every machine, which is what lets doctor compare them. Colours follow the viewer's theme
13
13
  * through a `prefers-color-scheme` block, which GitHub honours inside an `<img>` SVG.
14
14
  */
15
- import { round1 } from './scorecard.mjs';
15
+ import { computeGrade, round1 } from './scorecard.mjs';
16
16
 
17
- /** The grade band a percentage falls in — the same thresholds as the overall grade. */
18
- const band = (pct) => (pct >= 85 ? 'a' : pct >= 70 ? 'b' : pct >= 55 ? 'c' : pct >= 40 ? 'd' : 'f');
17
+ /** The grade band a percentage falls in — the overall grade's own thresholds, never a copy. */
18
+ const band = (pct) => computeGrade(pct).toLowerCase();
19
19
 
20
20
  const esc = (s) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
21
21