geneseed 4.4.1 → 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 (79) hide show
  1. package/CHANGELOG.md +37 -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 +195 -52
  6. package/adapters/opencode/plugins/geneseed-guard.js +42 -29
  7. package/adapters/opencode/plugins/geneseed-learn.js +1 -1
  8. package/docs/concepts/enforcement-table.md +9 -3
  9. package/docs/concepts/hooks.md +3 -0
  10. package/docs/extending.md +1 -1
  11. package/docs/guides/install-clone.md +1 -1
  12. package/docs/guides/install.md +2 -2
  13. package/docs/reference/plugin-context.md +2 -0
  14. package/docs/reference/troubleshoot-doctor.md +31 -0
  15. package/docs/reference/troubleshoot-install.md +5 -5
  16. package/docs/understand/enforced-vs-asked.md +1 -0
  17. package/harness.config.json +1 -1
  18. package/js/README.md +8 -5
  19. package/js/build/bundle.mjs +11 -2
  20. package/js/build/driver.mjs +1 -10
  21. package/js/build/emit-claude.mjs +7 -22
  22. package/js/build/emit-common.mjs +16 -9
  23. package/js/build/emit-opencode.mjs +5 -11
  24. package/js/build/generate.mjs +1 -1
  25. package/js/cli-table.json +15 -0
  26. package/js/hosts/hooks-context.mjs +1 -4
  27. package/js/hosts/hooks-done.mjs +186 -0
  28. package/js/hosts/hooks-learn.mjs +9 -4
  29. package/js/hosts/hooks.mjs +27 -8
  30. package/js/hosts/hosts.mjs +2 -0
  31. package/js/hosts/installs.mjs +47 -11
  32. package/js/hosts/native.mjs +5 -13
  33. package/js/hosts/opencode.mjs +37 -22
  34. package/js/hosts/owned.mjs +19 -5
  35. package/js/hosts/settings.mjs +17 -11
  36. package/js/hosts/shim.mjs +24 -10
  37. package/js/hosts/usage.mjs +4 -3
  38. package/js/inspect/checks-authoring.mjs +9 -7
  39. package/js/inspect/checks-constitution.mjs +2 -2
  40. package/js/inspect/checks-probe.mjs +292 -0
  41. package/js/inspect/checks-repo.mjs +13 -54
  42. package/js/inspect/cost.mjs +5 -6
  43. package/js/inspect/doctor.mjs +29 -3
  44. package/js/inspect/scan.mjs +9 -5
  45. package/js/inspect/scorecard-svg.mjs +3 -3
  46. package/js/inspect/scorecard-yaml.mjs +407 -0
  47. package/js/inspect/scorecard.mjs +7 -405
  48. package/js/inspect/status.mjs +14 -13
  49. package/js/lib/node-floor.mjs +4 -3
  50. package/js/maintain/migrate.mjs +2 -3
  51. package/js/maintain/uninstall.mjs +20 -26
  52. package/js/web/actions.mjs +2 -2
  53. package/js/web/api.mjs +36 -24
  54. package/js/web/gates-api.mjs +5 -8
  55. package/js/web/usage-api.mjs +14 -17
  56. package/package.json +2 -2
  57. package/src/skills/rule.md +11 -2
  58. package/web/dist/assets/{Activity-BZE6REef.js → Activity-Dj0izJwj.js} +1 -1
  59. package/web/dist/assets/{ActivityDetail-DCB8Cr2W.js → ActivityDetail-wjq5W02K.js} +1 -1
  60. package/web/dist/assets/Dashboard-UMbgHbzF.js +2 -0
  61. package/web/dist/assets/{Docs-D30CiqYy.js → Docs-D3ABE_ds.js} +3 -3
  62. package/web/dist/assets/{GreenhouseView-Djt-4oO5.js → GreenhouseView-C238LWBD.js} +1 -1
  63. package/web/dist/assets/{Installs-B3bw5ckh.js → Installs-XSm0a_zQ.js} +2 -2
  64. package/web/dist/assets/JournalView-BUj4Ff-6.js +1 -0
  65. package/web/dist/assets/{Laws-B9day2FV.js → Laws-yGBJFKgY.js} +1 -1
  66. package/web/dist/assets/{Library-BpIrLH-O.js → Library-CkaPVUYU.js} +1 -1
  67. package/web/dist/assets/{Loops-CxH14kyE.js → Loops-CexwOARr.js} +1 -1
  68. package/web/dist/assets/{OperatorHudView-otOz6Gha.js → OperatorHudView-D5qTQat0.js} +1 -1
  69. package/web/dist/assets/{Personal-abL6089y.js → Personal-B_Vx9a7a.js} +1 -1
  70. package/web/dist/assets/Tabs-YZbGqWPW.js +1 -0
  71. package/web/dist/assets/drawable-BoQnwVpr.js +1 -0
  72. package/web/dist/assets/index-BmbWeoTM.js +2 -0
  73. package/web/dist/assets/{index-CO-9wMJY.css → index-BoG1M8Xy.css} +1 -1
  74. package/web/dist/index.html +2 -2
  75. package/web/dist/assets/Dashboard-B4Drq5wp.js +0 -2
  76. package/web/dist/assets/JournalView-OWhx9YKg.js +0 -1
  77. package/web/dist/assets/Tabs-DjWN0i7A.js +0 -1
  78. package/web/dist/assets/drawable-C2ZsBZCv.js +0 -1
  79. package/web/dist/assets/index-CdpLpPQN.js +0 -2
@@ -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. */
@@ -118,14 +120,18 @@ export function shimDead() {
118
120
  */
119
121
  export function hookShimBody(runner, entry, platform = process.platform) {
120
122
  if (platform === 'win32') {
121
- // Bare `exit /b` propagates the LIVE errorlevel; `%ERRORLEVEL%` would expand at parse
122
- // time and return a stale one. Never plain `exit` — that kills the parent cmd.exe, so
123
+ // `exit /b %ERRORLEVEL%`, on its OWN line. A bare `exit /b` was here, on the claim that it
124
+ // propagates the live errorlevel, and that is false under `cmd /d /s /c` — Bob's hook
125
+ // shell, and Node's `shell: true`: the batch returned 0 after the gate exited 2, so every
126
+ // Bob block on Windows went through (#290; measured, see hook_form.test.mjs). `%ERRORLEVEL%`
127
+ // expands when ITS line is parsed, which is after the runner line has finished, so the value
128
+ // is the runner's, not a stale one. Never plain `exit` — that kills the parent cmd.exe, so
123
129
  // the emitted `|| exit 0` would never get to evaluate.
124
130
  return '@echo off\r\n'
125
131
  + 'rem Generated by Geneseed - do not edit. Rewritten on every emit.\r\n'
126
132
  + 'setlocal\r\n'
127
133
  + `"${runner}" "${entry}" %*\r\n`
128
- + 'exit /b\r\n';
134
+ + 'exit /b %ERRORLEVEL%\r\n';
129
135
  }
130
136
  return '#!/bin/sh\n'
131
137
  + '# Generated by Geneseed - do not edit. Rewritten on every emit.\n'
@@ -281,18 +287,26 @@ const GIT_BASH_NAMES = new Set(['bash.exe', 'sh.exe', 'bash', 'sh']);
281
287
  */
282
288
  export function claudeHookShell(env = process.env, platform = process.platform) {
283
289
  if (platform !== 'win32') return 'bash';
290
+ return claudeGitBashPath(env) ? 'bash' : 'powershell';
291
+ }
292
+
293
+ /**
294
+ * The Git Bash `claudeHookShell` finds on Windows, as a path — or null. One rule, two readers:
295
+ * the emit only needs to know WHETHER, `doctor --probe` (`js/inspect/checks-probe.mjs`) needs
296
+ * the file itself, to run a gate through the same shell Claude would.
297
+ */
298
+ export function claudeGitBashPath(env = process.env) {
284
299
  const read = (name) => Object.entries(env).find(([k]) => k.toUpperCase() === name)?.[1];
285
- const isFile = (p) => { try { return statSync(p).isFile(); } catch { return false; } };
286
300
  const forced = read('CLAUDE_CODE_GIT_BASH_PATH');
287
301
  if (forced && GIT_BASH_NAMES.has(path.basename(forced).toLowerCase()) && isFile(forced)) {
288
- return 'bash';
302
+ return forced;
289
303
  }
290
304
  for (const raw of (read('PATH') || '').split(';')) {
291
305
  const dir = raw.replace(/"/g, '');
292
- if (dir && isFile(path.join(dir, 'git.exe'))
293
- && isFile(path.join(dir, '..', 'bin', 'bash.exe'))) return 'bash';
306
+ const bash = dir ? path.join(dir, '..', 'bin', 'bash.exe') : '';
307
+ if (dir && isFile(path.join(dir, 'git.exe')) && isFile(bash)) return path.resolve(bash);
294
308
  }
295
- return 'powershell';
309
+ return null;
296
310
  }
297
311
 
298
312
  /**
@@ -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
@@ -170,12 +170,12 @@ const DOCTRINE_TOKEN_RE = /\{\{DOCTRINE(?:\}\}|:)/;
170
170
  * The rules the hooks enforce at the tool boundary, and the address each is bound to there. The
171
171
  * hook path cannot afford to read the canon on every tool call, so its gate-ledger keys
172
172
  * (`law-1`, `law-4`, `process-1`, `process-5`, `rigor-5`, `ops-2` in `js/hosts/hooks.mjs` and the OpenCode guard
173
- * plugin), `CONSENT_RULE` in `js/hosts/settings.mjs` and the console's `HOOK_GATED` stay
173
+ * plugin; `law-3` in the done gate, `js/hosts/hooks-done.mjs`), `CONSENT_RULE` in `js/hosts/settings.mjs` and the console's `HOOK_GATED` stay
174
174
  * number-keyed — and this pin is what turns a renumber that moves one of these rules into a
175
175
  * doctor failure instead of a gate silently guarding its neighbour.
176
176
  */
177
177
  export const HOOK_PINNED = {
178
- 'sealed-secrets': 'I', 'deletion-is-deliberate': 'IV',
178
+ 'sealed-secrets': 'I', 'verify-before-asserting': 'III', 'deletion-is-deliberate': 'IV',
179
179
  'persist-insight': 'process 1', 'consent-before-push': 'process 5', 'external-gate': 'rigor 5',
180
180
  'commands-must-return': 'ops 2',
181
181
  };
@@ -0,0 +1,292 @@
1
+ /**
2
+ * `geneseed doctor --probe` — prove each installed gate still REFUSES, not just that it exists.
3
+ *
4
+ * A gate that is registered, launches and then allows everything looks exactly like a working
5
+ * one: `onFailure: "block"` (ADR 0007) covers a gate that never starts, `shimProblems` covers the
6
+ * shim's paths and `hookShellProblems` infers shell drift — none of them observes a verdict. So
7
+ * this runs each live install's own gate command, AS ITS SETTINGS SPELL IT, through the shell
8
+ * the host would use, on two canned payloads: one Law IV call that every build must refuse
9
+ * (`git reset --hard HEAD`, also in Bob's `BLOCK_RULES`) and one that must pass (`git status`).
10
+ * Both directions are judged, because "allows everything" and "refuses everything" (the
11
+ * PowerShell-fallback case `hookShellProblems` can only infer) are both broken gates.
12
+ *
13
+ * WHY THIS SPAWNS, and why it is opt-in. ADR 0002 allows a CLI spawn only as a declared row of
14
+ * `ALLOWED_SPAWNS` (`tests/unit/hook_cli.test.mjs`); this is the row that runs INSTALLED code
15
+ * rather than the checkout's, which is why it waits for `--probe` instead of riding every
16
+ * `doctor`. It runs only a command that `liveRecordedHooks` returns — recorded in our own
17
+ * manifest AND live in the settings — and that still carries `geneseed-hook`, a gate verb and
18
+ * `--root`; anything else is reported and never run. Nothing the payloads name is executed:
19
+ * the gate only reads them, from an empty temp dir.
20
+ *
21
+ * WHAT A PASS DOES NOT CLAIM. The probe runs under doctor's environment — this terminal's PATH
22
+ * and HOME — not the host app's. A host launched with a different environment (an empty HOME, a
23
+ * PATH without the shell's tools) can still break a gate that passes here.
24
+ *
25
+ * `GENESEED_GATE_PROBE=1` keeps the probe's asks out of the gate ledger (`ledger()` in
26
+ * `js/hosts/hooks.mjs`), so `status` never counts a doctor run as a refusal.
27
+ */
28
+ import { existsSync } from 'node:fs';
29
+ import path from 'node:path';
30
+ import { pathToFileURL } from 'node:url';
31
+ import { liveRecordedHooks } from '../hosts/hosts.mjs';
32
+ import { claudeCfg, installState, installTargets } from '../hosts/installs.mjs';
33
+ import { SHIM_MARK, claudeGitBashPath } from '../hosts/shim.mjs';
34
+ import { which } from '../lib/paths.mjs';
35
+ import { NO_WINDOW } from '../lib/proc.mjs';
36
+ import { NOTE, withTempDir, withTempDirAsync } from './scan.mjs';
37
+ import { spawnSync } from 'node:child_process';
38
+
39
+ /**
40
+ * The gate that refuses Law IV on each host (`claudeHookGroups`, `js/hosts/settings.mjs`). On
41
+ * Claude and OpenClaude it is `git-gate`; `tool-gate` there only counts Skill/Agent calls. Bob
42
+ * runs everything through `tool-gate --host bob`. OpenCode's guard is an in-process plugin with
43
+ * no command: `guardProbe` below imports it instead.
44
+ */
45
+ const PROBED_VERB = { claude: 'git-gate', openclaude: 'git-gate', bob: 'tool-gate' };
46
+
47
+ export const BAD_COMMAND = 'git reset --hard HEAD';
48
+ export const GOOD_COMMAND = 'git status';
49
+
50
+ const PROBE_TIMEOUT_MS = 20_000;
51
+
52
+ /** A PreToolUse payload in Claude's shape — the shape every probed host's gate reads. */
53
+ function probePayload(command, cwd) {
54
+ return JSON.stringify({
55
+ session_id: 'geneseed-probe', hook_event_name: 'PreToolUse', tool_name: 'Bash',
56
+ tool_input: { command }, cwd,
57
+ });
58
+ }
59
+
60
+ /**
61
+ * The shell a host runs this handler under, as `{ exe, argv, label, verbatim }` — or null when
62
+ * that shell is not on this machine. Mirrors the emit's own rule: an explicit
63
+ * `shell: "powershell"` wins; Bob runs `cmd /c` on Windows; Claude/OpenClaude run Git Bash
64
+ * there (`claudeGitBashPath`, the rule `claudeHookShell` uses); `sh -c` everywhere else.
65
+ */
66
+ function probeShell(host, handler, platform = process.platform, env = process.env) {
67
+ const cmd = handler.command;
68
+ if (handler.shell === 'powershell') {
69
+ const ps = which('powershell') || which('pwsh');
70
+ return ps ? { exe: ps, argv: ['-NoProfile', '-NonInteractive', '-Command', cmd], label: 'PowerShell' }
71
+ : null;
72
+ }
73
+ if (platform !== 'win32') return { exe: which('sh') || '/bin/sh', argv: ['-c', cmd], label: 'sh' };
74
+ if (host === 'bob') {
75
+ // `/s /c "<cmd>"` with verbatim arguments: cmd strips the outer quotes and keeps the
76
+ // command's own quoting. Node's default quoting would turn its `"` into `\"`, which cmd
77
+ // does not unescape.
78
+ const cmdExe = env.ComSpec || env.COMSPEC || which('cmd');
79
+ return cmdExe ? { exe: cmdExe, argv: ['/d', '/s', '/c', `"${cmd}"`], label: 'cmd', verbatim: true }
80
+ : null;
81
+ }
82
+ const bash = claudeGitBashPath(env);
83
+ return bash ? { exe: bash, argv: ['-c', cmd], label: 'Git Bash' } : null;
84
+ }
85
+
86
+ const ASK_RE = /"permissionDecision":\s*"(?:ask|deny)"/;
87
+
88
+ /** Did the gate refuse? Exit 2 on Bob; on Claude-shaped hosts exit 2 or an ask/deny verdict. */
89
+ export function refused(host, r) {
90
+ if (host === 'bob') return r.status === 2;
91
+ return r.status === 2 || (r.status === 0 && ASK_RE.test(r.stdout || ''));
92
+ }
93
+
94
+ /**
95
+ * The verdict for one gate, from its two runs — null when the gate is sound, or the problem.
96
+ * Pure, so the whole verdict table is pinned without spawning.
97
+ */
98
+ export function probeVerdict(host, bad, good) {
99
+ for (const r of [bad, good]) {
100
+ if (r.status !== 0 && r.status !== 2) {
101
+ const why = r.error ? r.error : `exit ${r.status}`;
102
+ const tail = (r.stderr || '').trim().split(/\r?\n/).pop();
103
+ return `does not run (${why}${tail ? `: ${tail}` : ''})${host === 'claude'
104
+ ? ' — Claude blocks every matching call while this lasts (onFailure: block)'
105
+ : ' — the host lets every call through while this lasts'}`;
106
+ }
107
+ }
108
+ if (!refused(host, bad)) return `registered but not refusing — \`${BAD_COMMAND}\` went through`;
109
+ if (refused(host, good)) return `refuses everything — \`${GOOD_COMMAND}\` was refused too`;
110
+ return null;
111
+ }
112
+
113
+ /** Is this recorded command one of ours for `verb`? A gate verb, `--root`, and our shim. */
114
+ export function isOurGate(command, verb) {
115
+ return command.includes(` ${verb} --root`) && command.includes(SHIM_MARK);
116
+ }
117
+
118
+ /** One spawn of one handler on one payload. THE ONE SPAWN of this module. */
119
+ function runOnce(shell, payload, cwd, env) {
120
+ const r = spawnSync(shell.exe, shell.argv, {
121
+ input: payload, cwd, env, encoding: 'utf-8', timeout: PROBE_TIMEOUT_MS,
122
+ windowsVerbatimArguments: Boolean(shell.verbatim), ...NO_WINDOW,
123
+ });
124
+ const e = /** @type {NodeJS.ErrnoException | undefined} */ (r.error);
125
+ const error = e ? (e.code === 'ETIMEDOUT' ? 'timed out' : e.message) : null;
126
+ return { status: r.status, stdout: r.stdout, stderr: r.stderr, error };
127
+ }
128
+
129
+ /**
130
+ * Probe every live Claude-shaped install. Returns `{ problems, probed }`: `problems` in
131
+ * doctor's line format, `probed` the labels of the gates that ran and behaved.
132
+ * `targets`, `platform` and `env` are parameters so a test hands it a sandboxed install.
133
+ */
134
+ export function gateProbe(targets = installTargets(), { platform = process.platform, env = process.env } = {}) {
135
+ const problems = [];
136
+ const probed = [];
137
+ for (const [host, scope, root] of targets) {
138
+ const verb = PROBED_VERB[host];
139
+ if (!verb || installState(root, host, scope) !== 'active') continue;
140
+ const cfg = claudeCfg(root, scope, host);
141
+ const gates = liveRecordedHooks(cfg)
142
+ .filter(([event, h]) => event === 'PreToolUse' && h.command.includes(` ${verb} --root`));
143
+ if (!gates.length) {
144
+ problems.push(`[probe] ${cfg}: no live ${verb} hook in its settings — nothing refuses `
145
+ + 'a destructive call at the boundary — run: geneseed rebuild-all');
146
+ continue;
147
+ }
148
+ for (const [, handler] of gates) {
149
+ const label = `[probe] ${cfg}: ${verb}`;
150
+ if (!isOurGate(handler.command, verb)) {
151
+ problems.push(`${label} not probed — its command does not run ${SHIM_MARK}, and doctor `
152
+ + 'runs nothing else from a settings file');
153
+ continue;
154
+ }
155
+ const shell = probeShell(host, handler, platform, env);
156
+ if (!shell) {
157
+ problems.push(`${label} does not run — the shell ${host} would use is not on this machine`);
158
+ continue;
159
+ }
160
+ const verdict = withTempDir((tmp) => {
161
+ const runEnv = { ...env, GENESEED_GATE_PROBE: '1', CLAUDE_PROJECT_DIR: tmp };
162
+ const bad = runOnce(shell, probePayload(BAD_COMMAND, tmp), tmp, runEnv);
163
+ const good = runOnce(shell, probePayload(GOOD_COMMAND, tmp), tmp, runEnv);
164
+ return probeVerdict(host, bad, good);
165
+ }, 'geneseed-probe-');
166
+ if (verdict) problems.push(`${label} under ${shell.label} ${verdict}`);
167
+ else probed.push(`${path.normalize(cfg)} (${verb}, ${shell.label})`);
168
+ }
169
+ }
170
+ return { problems, probed };
171
+ }
172
+
173
+ // ---- OpenCode: the guard plugin, probed in-process ------------------------------------------
174
+
175
+ const GUARD_PLUGIN = 'geneseed-guard.js';
176
+ /** The guard blocks catastrophic shell (`SHELL_BLOCK_RE`); destructive git is the permission
177
+ * prompt's job there, and only WARNS in the guard — so its must-refuse payload is this one. */
178
+ const GUARD_BAD_COMMAND = 'rm -rf /';
179
+
180
+ /** The guard plugin of an OpenCode install: `<global>/plugins/` or `<repo>/.opencode/plugins/`
181
+ * (`emitOpencodeInto` copies the plugins to `<dir>/plugins`). */
182
+ export function guardPluginPath(root, scope) {
183
+ return path.join(scope === 'global' ? root : path.join(root, '.opencode'), 'plugins', GUARD_PLUGIN);
184
+ }
185
+
186
+ /** Run `fn` with stderr swallowed and `GENESEED_GATE_PROBE` set, both restored after. */
187
+ async function quietProbe(fn) {
188
+ const write = process.stderr.write;
189
+ const saved = process.env.GENESEED_GATE_PROBE;
190
+ process.stderr.write = () => true;
191
+ process.env.GENESEED_GATE_PROBE = '1';
192
+ try { return await fn(); } finally {
193
+ process.stderr.write = write;
194
+ if (saved === undefined) delete process.env.GENESEED_GATE_PROBE;
195
+ else process.env.GENESEED_GATE_PROBE = saved;
196
+ }
197
+ }
198
+
199
+ /**
200
+ * Both plugin APIs, each on both payloads: `{ v1: [badRefused, goodRefused], v2: [...] }`.
201
+ * V1 (`server`) refuses by THROWING `[geneseed-guard] …`; V2 (`setup`) by RENAMING the call
202
+ * (`setupV2`'s docblock). An unexpected throw is not a refusal — it is rethrown, and the caller
203
+ * reports the guard as broken.
204
+ */
205
+ async function runGuard(mod, cwd) {
206
+ const v1 = async (command) => {
207
+ const hooks = await mod.server({ worktree: cwd, directory: cwd });
208
+ try {
209
+ await hooks['tool.execute.before']({ tool: 'bash' }, { args: { command } });
210
+ return false;
211
+ } catch (e) {
212
+ if (String(e?.message || '').startsWith('[geneseed-guard]')) return true;
213
+ throw e;
214
+ }
215
+ };
216
+ const v2 = async (command) => {
217
+ /** @type {((event: { tool: string }) => Promise<void>) | null} */
218
+ let hook = null;
219
+ await mod.setup({
220
+ location: { project: { directory: cwd }, directory: cwd },
221
+ tool: { hook: (name, fn) => { if (name === 'execute.before') hook = fn; } },
222
+ });
223
+ if (!hook) throw new Error('setup registered no execute.before hook');
224
+ const event = { tool: 'bash', input: { command } };
225
+ await hook(event);
226
+ return event.tool !== 'bash';
227
+ };
228
+ return {
229
+ v1: [await v1(GUARD_BAD_COMMAND), await v1(GOOD_COMMAND)],
230
+ v2: [await v2(GUARD_BAD_COMMAND), await v2(GOOD_COMMAND)],
231
+ };
232
+ }
233
+
234
+ /** The verdict for one API's pair — null when sound. */
235
+ function guardVerdict([badRefused, goodRefused]) {
236
+ if (!badRefused) return `registered but not refusing — \`${GUARD_BAD_COMMAND}\` went through`;
237
+ if (goodRefused) return `refuses everything — \`${GOOD_COMMAND}\` was refused too`;
238
+ return null;
239
+ }
240
+
241
+ /**
242
+ * Probe every live OpenCode install's guard plugin by importing it into this process and
243
+ * calling its hooks — no spawn. Proves the installed FILE and its logic under Node, not under
244
+ * OpenCode's runtime (the weekly host canary proves the plugin loads there). Returns
245
+ * `{ problems, probed }` like `gateProbe`; a guard this environment switched off
246
+ * (`GENESEED_GUARD=off|warn`) is a NOTE, not a failure — that switch is the user's.
247
+ *
248
+ * Only a file named `geneseed-guard.js` in an install `installState` recognises is imported,
249
+ * and only one whose default export is `{ id: 'geneseed-guard', server, setup }`.
250
+ */
251
+ export async function guardProbe(targets = installTargets(), env = process.env) {
252
+ const problems = [];
253
+ const probed = [];
254
+ for (const [host, scope, root] of targets) {
255
+ if (host !== 'opencode' || installState(root, host, scope) !== 'active') continue;
256
+ const file = guardPluginPath(root, scope);
257
+ const label = `[probe] ${file}`;
258
+ if (!existsSync(file)) {
259
+ problems.push(`${label}: no guard plugin — nothing refuses a catastrophic command at the `
260
+ + 'boundary — run: geneseed rebuild-all');
261
+ continue;
262
+ }
263
+ const mode = String(env.GENESEED_GUARD || 'on').toLowerCase();
264
+ if (['off', '0', 'false', 'no', 'warn'].includes(mode)) {
265
+ problems.push(`${NOTE}${label}: not probed — GENESEED_GUARD=${mode} in this environment `
266
+ + 'switches the guard off');
267
+ continue;
268
+ }
269
+ let verdicts;
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.
274
+ const mod = (await import(pathToFileURL(file).href)).default;
275
+ if (mod?.id !== 'geneseed-guard' || typeof mod.server !== 'function' || typeof mod.setup !== 'function') {
276
+ problems.push(`${label}: not probed — it does not export the Geneseed guard `
277
+ + '({ id, server, setup }) — run: geneseed rebuild-all');
278
+ continue;
279
+ }
280
+ verdicts = await withTempDirAsync(
281
+ (tmp) => quietProbe(() => runGuard(mod, tmp)), 'geneseed-probe-');
282
+ } catch (e) {
283
+ problems.push(`${label}: does not run (${e?.message || e})`);
284
+ continue;
285
+ }
286
+ const bad = [['V1', guardVerdict(verdicts.v1)], ['V2', guardVerdict(verdicts.v2)]]
287
+ .filter(([, v]) => v);
288
+ for (const [api, v] of bad) problems.push(`${label}: guard (OpenCode ${api} hook) ${v}`);
289
+ if (!bad.length) probed.push(`${path.normalize(file)} (guard, in-process)`);
290
+ }
291
+ return { problems, probed };
292
+ }
@@ -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';