geneseed 3.19.0 → 4.0.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 (109) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/README.md +9 -1
  3. package/SHIPPED.md +1 -1
  4. package/adapters/bob/README.md +31 -12
  5. package/adapters/claude-code/README.md +24 -17
  6. package/adapters/claude-code/settings.json +4 -6
  7. package/adapters/openclaude/README.md +13 -5
  8. package/adapters/opencode/plugins/geneseed-guard.js +291 -174
  9. package/bin/geneseed-hook.mjs +36 -5
  10. package/docs/README.md +3 -1
  11. package/docs/candidate-hosts.md +0 -8
  12. package/docs/concepts/enforcement-table.md +66 -0
  13. package/docs/concepts/hook-gates.md +2 -2
  14. package/docs/concepts/hooks.md +6 -6
  15. package/docs/concepts/host-matrix.md +4 -3
  16. package/docs/concepts/loop-branch.md +6 -1
  17. package/docs/concepts/loops.md +4 -3
  18. package/docs/concepts/rules.md +1 -1
  19. package/docs/design-history.md +59 -0
  20. package/docs/extending.md +11 -3
  21. package/docs/guides/install-opencode.md +1 -1
  22. package/docs/guides/loop-running.md +1 -1
  23. package/docs/reference/env-context.md +3 -2
  24. package/docs/reference/environment.md +2 -1
  25. package/docs/reference/plugin-guard.md +3 -3
  26. package/docs/reference/troubleshoot-hooks.md +34 -0
  27. package/docs/understand/design-choices-engineering.md +29 -0
  28. package/docs/understand/design-choices-gates.md +23 -0
  29. package/docs/understand/design-choices-hosts.md +21 -0
  30. package/docs/understand/design-choices-install.md +19 -0
  31. package/docs/understand/design-choices-shape.md +21 -0
  32. package/docs/understand/design-choices.md +34 -0
  33. package/docs/understand/enforced-vs-asked.md +5 -4
  34. package/docs/understand/machine-bob.md +1 -1
  35. package/docs/understand/machine-opencode.md +2 -2
  36. package/harness.config.json +1 -1
  37. package/js/build/driver.mjs +42 -16
  38. package/js/build/emit-claude.mjs +24 -8
  39. package/js/build/emit-opencode.mjs +33 -15
  40. package/js/build/generate.mjs +38 -9
  41. package/js/build/source.mjs +39 -0
  42. package/js/build/stubs.mjs +2 -1
  43. package/js/cli-table.json +6 -6
  44. package/js/hosts/gitref.mjs +3 -3
  45. package/js/hosts/hooks-context.mjs +13 -31
  46. package/js/hosts/hooks-learn.mjs +89 -8
  47. package/js/hosts/hooks.mjs +212 -18
  48. package/js/hosts/hosts.mjs +217 -13
  49. package/js/hosts/installs.mjs +259 -46
  50. package/js/hosts/native.mjs +48 -12
  51. package/js/hosts/opencode.mjs +47 -17
  52. package/js/hosts/settings.mjs +366 -42
  53. package/js/hosts/shim.mjs +37 -0
  54. package/js/inspect/checks-authoring.mjs +81 -1
  55. package/js/inspect/checks-constitution.mjs +2 -1
  56. package/js/inspect/checks-repo.mjs +68 -4
  57. package/js/inspect/doctor.mjs +40 -9
  58. package/js/inspect/scan.mjs +5 -4
  59. package/js/inspect/status.mjs +62 -12
  60. package/js/lib/text.mjs +23 -0
  61. package/js/loop/catalog.mjs +7 -2
  62. package/js/loop/state.mjs +2 -2
  63. package/js/maintain/migrate.mjs +42 -5
  64. package/js/maintain/uninstall.mjs +150 -38
  65. package/js/maintain/update.mjs +22 -2
  66. package/js/web/actions.mjs +28 -10
  67. package/js/web/api.mjs +5 -3
  68. package/js/web/catalog.mjs +1 -1
  69. package/js/web/state.mjs +28 -15
  70. package/package.json +1 -1
  71. package/src/agents/_template.md +1 -1
  72. package/src/bricks/_template.md +1 -1
  73. package/src/bricks/apply.md +1 -1
  74. package/src/bricks/test.md +1 -1
  75. package/src/doctrines/ops.md +9 -7
  76. package/src/doctrines/process.md +3 -3
  77. package/src/laws/universal.md +1 -1
  78. package/src/loops/api-endpoint.json +1 -1
  79. package/src/loops/bugfix.json +1 -1
  80. package/src/loops/ci-repair.json +1 -1
  81. package/src/loops/deps-upgrade.json +1 -1
  82. package/src/loops/enforce-architecture-rule.json +1 -1
  83. package/src/loops/feature.json +1 -1
  84. package/src/loops/fix-flaky-tests.json +1 -1
  85. package/src/loops/legacy-refactor.json +1 -1
  86. package/src/loops/legacy-tests.json +1 -1
  87. package/src/loops/refactor.json +1 -1
  88. package/src/loops/remove-dead-code.json +1 -1
  89. package/src/loops/spec-first-feature.json +1 -1
  90. package/src/loops/tdd.json +1 -1
  91. package/src/loops/update-contract.json +1 -1
  92. package/src/skills/_template.md +3 -3
  93. package/src/skills/herdr.md +1 -1
  94. package/web/dist/assets/{Activity-IbwdHHyx.js → Activity-BCtqxjfy.js} +1 -1
  95. package/web/dist/assets/{ActivityDetail-B5uivdWI.js → ActivityDetail-Ba3Oo4dE.js} +1 -1
  96. package/web/dist/assets/{Dashboard-v6WQ37bD.js → Dashboard-DpHza68-.js} +2 -2
  97. package/web/dist/assets/{Docs-CypIfDBX.js → Docs-Bw_-UK0O.js} +1 -1
  98. package/web/dist/assets/{GreenhouseView-ClxP0hQG.js → GreenhouseView-DnJ1Ggp1.js} +1 -1
  99. package/web/dist/assets/{Installs-CPkQeFjO.js → Installs-CWoNKFBk.js} +1 -1
  100. package/web/dist/assets/{JournalView-BeEInhUf.js → JournalView-g6-bw4c8.js} +1 -1
  101. package/web/dist/assets/{Laws-Ds6U5xBQ.js → Laws-DkVWR1tb.js} +1 -1
  102. package/web/dist/assets/{Library-BraUKPyQ.js → Library-CK9HZzjV.js} +1 -1
  103. package/web/dist/assets/{Loops-DWcy3mwK.js → Loops-BP98SMOH.js} +1 -1
  104. package/web/dist/assets/{OperatorHudView-CQyrJQ9m.js → OperatorHudView-DI_n12M9.js} +1 -1
  105. package/web/dist/assets/{Personal-YWqs6qFL.js → Personal-Bk89f13a.js} +1 -1
  106. package/web/dist/assets/{Tabs-EhzEC-Xh.js → Tabs-3WuYIE1b.js} +1 -1
  107. package/web/dist/assets/{drawable-CEKPNV7X.js → drawable-oCUeq9e5.js} +1 -1
  108. package/web/dist/assets/{index-CPi22b7y.js → index-CiXwChvf.js} +2 -2
  109. package/web/dist/index.html +1 -1
@@ -36,11 +36,15 @@ import { existsSync, readdirSync } from 'node:fs';
36
36
  import path from 'node:path';
37
37
 
38
38
  import { CONFIG, PACK_ORDER, THEMES, discoverNames, resolveSkillNames } from '../build/source.mjs';
39
- import { CLAUDE_STYLE, GLOBAL_MANIFEST, HOSTS, resolvePath } from './hosts.mjs';
39
+ import {
40
+ CLAUDE_STYLE, DISABLED_STASH, GLOBAL_MANIFEST, HOSTS, isHostGlobalDir, liveRecordedHooks,
41
+ resolvePath,
42
+ } from './hosts.mjs';
40
43
  import { registryRoots } from '../inspect/registry.mjs';
41
44
  import { printErr, readText, isFile, isDir } from '../lib/fs.mjs';
42
45
  import { formatRepr, isDict } from '../lib/json.mjs';
43
46
  import { comparePaths } from '../lib/paths.mjs';
47
+ import { claudeHookShell } from './shim.mjs';
44
48
 
45
49
  /**
46
50
  * `Path.read_text(encoding="utf-8")`, or null where Python raises OSError.
@@ -137,14 +141,54 @@ function themeFromAgent(agentMd) {
137
141
  }
138
142
 
139
143
  /**
140
- * `_harness_setup.CARRIERS` — the four instruction carriers, in the Python's order.
144
+ * `_harness_setup.CARRIERS` — the Python's four root-level carriers, plus one JS-only addition.
141
145
  *
142
146
  * A shared table rather than three copies of the same list: `_theme_of_dir`,
143
147
  * `_posture_of_dir` and `_mode_of_dir` each walk it, and the order is observable (the first
144
148
  * carrier that answers wins). `rules/geneseed.md` is spelled with `path.join` at each use
145
149
  * because the Python writes `d / "rules" / "geneseed.md"` for that one entry.
150
+ *
151
+ * `.openclaude/CLAUDE.md` is NOT in the Python — OpenClaude didn't exist yet — and it is half
152
+ * of the fix for host-compat verdict B1 (the other half is `carriersFor`, below).
153
+ * OpenClaude's own per-repo carrier is `carrierInLayer` (see `CLAUDE_SHAPED.openclaude` in
154
+ * `js/build/driver.mjs`, and the `HOSTS` column of the same name): it sits at
155
+ * `<repo>/.openclaude/CLAUDE.md`, and the root `CLAUDE.md` is deliberately left untouched so a
156
+ * Claude Code install can share the repo.
157
+ *
158
+ * ⚠ THIS LIST STAYS HOST-AGNOSTIC ON PURPOSE — it is only `firstCarrier`'s FALLBACK for a
159
+ * caller with no host to narrow by (none of this module's exported `*OfDir` readers is one;
160
+ * see `carriersFor`). A caller that DOES have a host must pass it, or in a repo sharing two
161
+ * carriers that answer the same probe — only root `CLAUDE.md` today, between `claude` and
162
+ * `openclaude`'s global scope — this host-agnostic order decides for both of them, which was
163
+ * exactly B1's failure mode reversed: reading `.openclaude/CLAUDE.md` unconditionally made
164
+ * `claude`'s OWN read-back answer OpenClaude's settings in a shared repo.
146
165
  */
147
- const CARRIERS = ['AGENT.md', 'CLAUDE.md', path.join('rules', 'geneseed.md'), 'AGENTS.md'];
166
+ const CARRIERS = [
167
+ 'AGENT.md', path.join('.openclaude', 'CLAUDE.md'), 'CLAUDE.md',
168
+ path.join('rules', 'geneseed.md'), 'AGENTS.md',
169
+ ];
170
+
171
+ /**
172
+ * `host`'s own carrier path(s), narrowed off the single `HOSTS` table — `carrierInLayer` and
173
+ * `projectMarker`/`agentFile` — rather than a second hand-rolled host→carrier map.
174
+ *
175
+ * ONE HOST, ONE ANSWER, EXCEPT BOB. `carrierInLayer` true (OpenClaude only) tries the nested
176
+ * per-repo path FIRST and the bare `agentFile` SECOND, because the same column name means two
177
+ * different locations depending on scope: `<repo>/.openclaude/CLAUDE.md` for a project
178
+ * install, `<cfgDir>/CLAUDE.md` for a global one (see `CLAUDE_SHAPED.openclaude-global`,
179
+ * which has no `carrierInLayer`). Bob is the one host with a second, differently-NAMED
180
+ * carrier — `rules/geneseed.md` at global scope, because Bob never auto-loads a global
181
+ * `AGENTS.md` (`CLAUDE_SHAPED['bob-global']`'s summary says so) — which `HOSTS` has no column
182
+ * for and this hard-codes, same as the un-narrowed `CARRIERS` above always did.
183
+ */
184
+ function carriersFor(host) {
185
+ const row = HOSTS.find((h) => h.host === host);
186
+ if (!row) return CARRIERS;
187
+ if (host === 'bob') return [path.join('rules', 'geneseed.md'), row.agentFile];
188
+ return row.carrierInLayer
189
+ ? [path.join(row.projectMarker, row.agentFile), row.agentFile]
190
+ : [row.agentFile];
191
+ }
148
192
 
149
193
  /**
150
194
  * The scan `themeOfDir`, `leadOfDir`, `doctrinesOfDir` and `excludedRulesOfDir` each wrote
@@ -153,11 +197,35 @@ const CARRIERS = ['AGENT.md', 'CLAUDE.md', path.join('rules', 'geneseed.md'), 'A
153
197
  * legitimate stop values for the two register readers below — to mean "stop, this is the
154
198
  * answer". `fallback` is what every caller's own trailing `return …;` supplied once the
155
199
  * whole list was exhausted with no answer.
200
+ *
201
+ * `host`, when given, narrows the scan to THAT HOST'S OWN carrier(s) via `carriersFor` —
202
+ * `null` (the default) keeps today's full, host-agnostic `CARRIERS` walk, so every call site
203
+ * this task's fix round did not touch is unchanged. Every exported reader below takes the
204
+ * same optional `host` last, mirroring `trustOfDir`'s existing `(d, host = null)` shape one
205
+ * screen down — this is not a new convention, it is that one applied to the carrier scan too.
206
+ *
207
+ * ⚠ HOST-NARROWED: THE FIRST CANDIDATE THAT EXISTS IS AUTHORITATIVE, EVEN IF ITS PROBE COMES
208
+ * BACK `undefined`. `undefined` is overloaded on purpose for the host-agnostic walk above (it
209
+ * means BOTH "this carrier is absent" and "this carrier exists but says nothing"), and that
210
+ * is exactly right for `CARRIERS`, where only one entry can ever exist for a given host —
211
+ * but `carriersFor` can return TWO paths for one host (OpenClaude's nested-then-bare
212
+ * `CLAUDE.md`; Bob's `rules/geneseed.md`-then-`AGENTS.md`), and "exists but says nothing" is
213
+ * the COMMON case for `excludedRulesOfDir`/`excludedSkillsOfDir` — their marker line is
214
+ * written only when something is excluded. Falling through past an existing-but-silent own
215
+ * carrier to the SECOND candidate let it read a sibling host's file that happens to sit at
216
+ * that second path (root `CLAUDE.md`, for OpenClaude's second candidate, in a repo also
217
+ * carrying a Claude install) — the fix-round-1 bug, reversed again. So once a host is given,
218
+ * `isFile` on each candidate (checked AFTER the probe, so a non-existent file's `undefined`
219
+ * still falls through to try the host's OWN next candidate — the one legitimate reason the
220
+ * list has two entries) stops the walk at the first one that exists and returns `fallback`,
221
+ * never a later file's answer.
156
222
  */
157
- function firstCarrier(d, probe, fallback = null) {
158
- for (const carrier of CARRIERS) {
159
- const result = probe(path.join(d, carrier));
223
+ function firstCarrier(d, probe, fallback = null, host = null) {
224
+ for (const carrier of (host === null ? CARRIERS : carriersFor(host))) {
225
+ const carrierPath = path.join(d, carrier);
226
+ const result = probe(carrierPath);
160
227
  if (result !== undefined) return result;
228
+ if (host !== null && isFile(carrierPath)) return fallback;
161
229
  }
162
230
  return fallback;
163
231
  }
@@ -176,14 +244,35 @@ const THEME_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
176
244
  * through to the sigil scan, as a missing marker does. A plain name that is not shipped is
177
245
  * still RETURNED: `loadTheme` then refuses it loudly, which `status`/`rebuild-all`/`migrate`
178
246
  * cells pin ("unknown theme 'nosuchtheme'"), rather than a typo going unnoticed.
247
+ *
248
+ * `host`, when given, narrows the sigil scan to that host's own carrier — see `firstCarrier`.
249
+ *
250
+ * ⚠ THE ROOT MARKER ITSELF IS HOST-NARROWED TOO (host-compat B1, round 3): `driver.mjs` writes
251
+ * it at `d` unconditionally for a GLOBAL emit (every host's `-global` arm, the `writeText`
252
+ * right after `writeMarkers`), but at PROJECT scope only for a host whose emit calls `build()`
253
+ * directly — today that is `opencode` alone; the CLAUDE-STYLE hosts (`claude`/`bob`/
254
+ * `openclaude`, `CLAUDE_STYLE` below) go through `claudeShaped` instead, which never calls it
255
+ * (`driver.mjs`'s own comment: "Deliberately not written for the claude/bob/openclaude
256
+ * PROJECT emits"). So a claude-style host must distrust this marker UNLESS `d` is provably
257
+ * that host's OWN global config dir — `isHostGlobalDir` (`hosts.mjs`), which is what makes this
258
+ * scope-correct without a `scope` parameter: at ANY other `d` (a project root, or another
259
+ * host's global dir entirely) a claude-style host's project emit never wrote this file, so a
260
+ * present one is a SIBLING's (`opencode`'s project marker, in a shared repo — the B1 failure,
261
+ * reached a third way) and `themeOfDir` falls through to `firstCarrier`'s sigil scan instead,
262
+ * exactly as it already must for a claude-style host's own project installs.
179
263
  */
180
- export function themeOfDir(d) {
181
- const marker = path.join(d, '.geneseed-theme');
182
- if (isFile(marker)) {
183
- const name = (readMaybe(marker) ?? '').trim();
184
- if (THEME_NAME_RE.test(name)) return name;
264
+ export function themeOfDir(d, host = null) {
265
+ let trust = host === null || !CLAUDE_STYLE.includes(host);
266
+ // A `configDir()` that throws cannot prove `d` is the global: distrust the marker.
267
+ try { trust ||= isHostGlobalDir(host, d); } catch { /* sigil scan below */ }
268
+ if (trust) {
269
+ const marker = path.join(d, '.geneseed-theme');
270
+ if (isFile(marker)) {
271
+ const name = (readMaybe(marker) ?? '').trim();
272
+ if (THEME_NAME_RE.test(name)) return name;
273
+ }
185
274
  }
186
- return firstCarrier(d, (carrierPath) => themeFromAgent(carrierPath) || undefined);
275
+ return firstCarrier(d, (carrierPath) => themeFromAgent(carrierPath) || undefined, null, host);
187
276
  }
188
277
 
189
278
  /** `_harness_setup.FOOTPRINTS`. */
@@ -230,7 +319,7 @@ export function capitalize(s) {
230
319
  return s.length ? s[0].toUpperCase() + s.slice(1).toLowerCase() : s;
231
320
  }
232
321
 
233
- function leadOfDir(d, names) {
322
+ function leadOfDir(d, names, host = null) {
234
323
  return firstCarrier(d, (carrierPath) => {
235
324
  const text = readMaybe(carrierPath);
236
325
  if (text === null) return undefined;
@@ -238,11 +327,11 @@ function leadOfDir(d, names) {
238
327
  if (text.includes(`**${capitalize(name)}**`)) return name;
239
328
  }
240
329
  return undefined;
241
- });
330
+ }, null, host);
242
331
  }
243
332
 
244
- export const postureOfDir = (d) => leadOfDir(d, discoverNames('postures', 'peer'));
245
- export const modeOfDir = (d) => leadOfDir(d, discoverNames('modes', 'direct'));
333
+ export const postureOfDir = (d, host = null) => leadOfDir(d, discoverNames('postures', 'peer'), host);
334
+ export const modeOfDir = (d, host = null) => leadOfDir(d, discoverNames('modes', 'direct'), host);
246
335
 
247
336
  /**
248
337
  * The loop skill's default trust preset, read back off a deployed install — `null` when no
@@ -274,7 +363,8 @@ export function trustOfDir(d, host = null) {
274
363
  const ACTIVE_PACKS_RE = /^Active packs:[ \t]*(.+?)[ \t]*$/m;
275
364
 
276
365
  /**
277
- * A carrier rendered BEFORE the comms pack existed — the address migration's one detector.
366
+ * A carrier rendered BEFORE the comms pack existed — the pre-4.0.0 address migration's one
367
+ * detector, kept only to WARN now.
278
368
  *
279
369
  * The comms pack took `process 7` (reference codes) out of process, and `process 8` (one
280
370
  * writer per file) was renumbered to `process 7`. An install built before that names
@@ -285,12 +375,67 @@ const ACTIVE_PACKS_RE = /^Active packs:[ \t]*(.+?)[ \t]*$/m;
285
375
  * it. No current carrier can say `process 8` — the pack has seven rules and nothing cites
286
376
  * an eighth.
287
377
  *
288
- * ponytail: text sniff, not a stamp. It holds only while process has seven rules; the day
289
- * process gets an eighth, this must move to a build stamp (or be retired, once no install
290
- * built before the migration can remain).
378
+ * 4.0.0 RETIRED THE AUTO-MIGRATION THIS DROVE (see `warnLegacyProcessCarrier` below): it used
379
+ * to add `comms` and re-address `process 7`/`process 8` on sight, silently, which is exactly
380
+ * backward the day the process pack ever regains an eighth rule for an unrelated reason —
381
+ * this sniff would then fire on a CURRENT carrier and rewrite data nobody asked rewritten. A
382
+ * false positive that only prints a loud warning and reads the carrier as written is safe to
383
+ * keep; a false positive that mutates data was not. See CHANGELOG "Removed" (4.0.0) for the
384
+ * upgrade instruction this now points installs at.
291
385
  */
292
386
  const legacyProcessCarrier = (text) => /\bprocess 8\b/.test(text);
293
387
 
388
+ /**
389
+ * The loud half of retiring the migration: one stderr line pointing a pre-comms install at
390
+ * `geneseed setup`, instead of the silent rewrite `doctrinesOfDir`/`excludedRulesOfDir` used
391
+ * to perform on the same detection. Never thrown — both are read-only introspection called by
392
+ * `uninstall`, `diff`, `validate` and the web console, none of which should become
393
+ * un-runnable against an install that merely needs a rebuild.
394
+ *
395
+ * NEVER `geneseed rebuild-all` — that is the exact command `rebuildAll` (`js/build/generate.mjs`)
396
+ * now REFUSES to run against a legacy carrier (see `legacyProcessCarrierOfDir` there), because
397
+ * running it is what caused the permanent data loss this fix round exists to stop. Pointing the
398
+ * warning at the one command that is guaranteed to do nothing here would be a second silent
399
+ * misread wearing a loud one's clothes.
400
+ *
401
+ * DEDUPED ONCE PER PROCESS — `doctor`/`validate`/`uninstall` read the same install's carrier
402
+ * more than once per run, and a line per read drowns the one thing the owner needs to act on.
403
+ * `resetLegacyProcessCarrierWarning` (below) exists only so tests can see the line fire more
404
+ * than once in one process; nothing else should call it.
405
+ */
406
+ let warnedLegacyProcessCarrier = false;
407
+ function warnLegacyProcessCarrier() {
408
+ if (warnedLegacyProcessCarrier) return;
409
+ warnedLegacyProcessCarrier = true;
410
+ printErr('[geneseed] WARN: this install predates the comms pack (3.7.0, 2026-09-27) — its '
411
+ + "carrier still names 'process 8'. The automatic migration (adding comms, re-addressing "
412
+ + 'process 7/process 8) was removed in 4.0.0; the carrier is now read as written. Run '
413
+ + '`geneseed setup` once to rebuild this install on the current rule addresses.\n');
414
+ }
415
+
416
+ /** Test-only: undo the dedup above so a test can see the warning fire again. */
417
+ export function resetLegacyProcessCarrierWarning() {
418
+ warnedLegacyProcessCarrier = false;
419
+ }
420
+
421
+ /**
422
+ * Whether ANY of `d`'s own carriers still shows the pre-comms signature — the one question
423
+ * `rebuildAll`, `upgrade` (which shells into it) and `migrate` must ask BEFORE calling
424
+ * `installProfile`/`driverMain` on an install, now that `doctrinesOfDir`/`excludedRulesOfDir`
425
+ * no longer rewrite it for them. Re-emitting a legacy carrier through the current template
426
+ * does not raise an error — it just writes a fresh `Active packs:` line that silently drops
427
+ * `comms` and leaves `process 7`/`process 8` unaddressed, permanently, with no further carrier
428
+ * text left afterward for THIS function to ever detect again. Checking before the write is the
429
+ * only point where the loss is still preventable.
430
+ */
431
+ export function legacyProcessCarrierOfDir(d, host = null) {
432
+ return firstCarrier(d, (carrierPath) => {
433
+ const text = readMaybe(carrierPath);
434
+ if (text === null) return undefined;
435
+ return legacyProcessCarrier(text) || undefined;
436
+ }, false, host) === true;
437
+ }
438
+
294
439
  /**
295
440
  * The doctrine packs a DEPLOYED install carries — the third register read back off disk,
296
441
  * beside `postureOfDir` and `modeOfDir`, and the first that is a SET.
@@ -322,7 +467,7 @@ const legacyProcessCarrier = (text) => /\bprocess 8\b/.test(text);
322
467
  * checkout ships; one that is not condemns the whole line to `null`. A trailing comma is
323
468
  * exactly the empty field a fold leaves behind, which is what makes this catch it.
324
469
  */
325
- export function doctrinesOfDir(d) {
470
+ export function doctrinesOfDir(d, host = null) {
326
471
  return firstCarrier(d, (carrierPath) => {
327
472
  const text = readMaybe(carrierPath);
328
473
  if (text === null) return undefined;
@@ -331,14 +476,14 @@ export function doctrinesOfDir(d) {
331
476
  if (m[1] === 'none') return [];
332
477
  const named = m[1].split(',').map((s) => s.trim());
333
478
  if (!named.every((n) => PACK_ORDER.includes(n))) return null;
334
- // MIGRATION: a pre-comms install that carried process carried the codes rule inside it,
335
- // so it keeps that rule by gaining comms. Its old `process 7` exclusion, if any, is
336
- // re-addressed to `comms 1` by `excludedRulesOfDir` in the same pass.
479
+ // RETIRED MIGRATION (4.0.0): a pre-comms install used to gain `comms` here so it kept the
480
+ // codes rule it carried inside `process`. That rewrite is gone — the carrier is read as
481
+ // written, and the owner is warned loudly instead, once per read.
337
482
  if (named.includes('process') && !named.includes('comms') && legacyProcessCarrier(text)) {
338
- named.push('comms');
483
+ warnLegacyProcessCarrier();
339
484
  }
340
485
  return PACK_ORDER.filter((pk) => named.includes(pk));
341
- });
486
+ }, null, host);
342
487
  }
343
488
 
344
489
  /**
@@ -359,8 +504,8 @@ export function doctrinesOfDir(d) {
359
504
  *
360
505
  * `[]` still passes through untouched — a marker that reads `none` is an answer, not a silence.
361
506
  */
362
- export function doctrinesForBuild(d) {
363
- return doctrinesOfDir(d) ?? [...PACK_ORDER];
507
+ export function doctrinesForBuild(d, host = null) {
508
+ return doctrinesOfDir(d, host) ?? [...PACK_ORDER];
364
509
  }
365
510
 
366
511
  /** `Excluded rules: process 7, craft 3` — the marker's optional second line. */
@@ -384,7 +529,7 @@ const EXCLUDED_RULES_RE = /^Excluded rules:[ \t]*(.+?)[ \t]*$/m;
384
529
  * same rule as the pack list, same reason, opposite default. Half an exclusion list would
385
530
  * take away rules nobody named.
386
531
  */
387
- export function excludedRulesOfDir(d) {
532
+ export function excludedRulesOfDir(d, host = null) {
388
533
  return firstCarrier(d, (carrierPath) => {
389
534
  const text = readMaybe(carrierPath);
390
535
  if (text === null) return undefined;
@@ -400,12 +545,13 @@ export function excludedRulesOfDir(d) {
400
545
  ? `${parts[0]}.${Number(parts[1])}` : null;
401
546
  });
402
547
  if (!ids.every(Boolean)) return [];
403
- // MIGRATION: the same pre-comms carrier spells the old addresses — see
404
- // `legacyProcessCarrier`. One map, applied once, so `process 8` cannot chain onward.
405
- const MOVED = { 'process.7': 'comms.1', 'process.8': 'process.7' };
406
- const out = legacyProcessCarrier(text) ? ids.map((id) => MOVED[id] ?? id) : ids;
407
- return [...new Set(out)].sort();
408
- }, []);
548
+ // RETIRED MIGRATION (4.0.0): a pre-comms carrier used to have its old addresses
549
+ // (`process.7` -> `comms.1`, `process.8` -> `process.7`) remapped here, once, so
550
+ // `process 8` could not chain onward. That rewrite is gone — the addresses are returned
551
+ // exactly as named, and the owner is warned loudly instead of having them silently moved.
552
+ if (legacyProcessCarrier(text)) warnLegacyProcessCarrier();
553
+ return [...new Set(ids)].sort();
554
+ }, [], host);
409
555
  }
410
556
 
411
557
  /** `Excluded skills: bruno, daydream` — written only when something is excluded. */
@@ -421,9 +567,10 @@ const EXCLUDED_SKILLS_RE = /^Excluded skills:[ \t]*(.+?)[ \t]*$/m;
421
567
  * name drops out (`resolveSkillNames`), so a skill merge does not quietly re-admit everything
422
568
  * else the install left out. Silent unless the caller passes `notify` — status, diff and the
423
569
  * console call this on every read; the replays that rebuild an install (`rebuild-all`,
424
- * `upgrade`, `migrate`) pass it.
570
+ * `upgrade`, `migrate`) pass it. `host` is last, after `notify`, for the same reason
571
+ * `trustOfDir` puts it last: every existing positional call keeps working unchanged.
425
572
  */
426
- export function excludedSkillsOfDir(d, notify = null) {
573
+ export function excludedSkillsOfDir(d, notify = null, host = null) {
427
574
  return firstCarrier(d, (carrierPath) => {
428
575
  const text = readMaybe(carrierPath);
429
576
  if (text === null) return undefined;
@@ -433,7 +580,7 @@ export function excludedSkillsOfDir(d, notify = null) {
433
580
  if (unknown.length) return [];
434
581
  if (notify) for (const n of notices) notify(n);
435
582
  return ids;
436
- }, []);
583
+ }, [], host);
437
584
  }
438
585
 
439
586
  // ---- what host a deployed dir belongs to (`_harness_mcp`) ---------------------------------
@@ -468,8 +615,8 @@ export function emitHostScopeOf(root) {
468
615
  return EMIT_HOST_SCOPE.get(emit.trim()) ?? null;
469
616
  }
470
617
 
471
- /** `_harness_mcp.DISABLED_STASH` — a sibling dir whose presence means "disabled". */
472
- export const DISABLED_STASH = '.geneseed-disabled';
618
+ // `DISABLED_STASH` moved to `hosts.mjs`: the hook stand-down reads it and must not import this.
619
+ export { DISABLED_STASH };
473
620
 
474
621
  /**
475
622
  * `_harness_mcp._claude_cfg` — where a Claude-STYLE install keeps its manifest.
@@ -505,6 +652,33 @@ export function installState(root, host = 'opencode', scope = 'global') {
505
652
  return installKind(root) !== null ? 'active' : 'absent';
506
653
  }
507
654
 
655
+ /**
656
+ * Host-compat verdict I1: an OpenClaude PROJECT install is additive, never exclusive — it loads
657
+ * its own `<repo>/.openclaude/CLAUDE.md` **and** whatever root `CLAUDE.md`/`AGENTS.md` a claude
658
+ * or bob PROJECT install already wrote there (`B/src/utils/claudemd.ts` l.904-928; `CARRIERS`
659
+ * above deliberately leaves the root file untouched for OpenClaude so a Claude/Bob install can
660
+ * share the repo). There is no clean fix: excluding the root file would hide the user's OWN
661
+ * content in it too, so the only thing `status`/`doctor` can do is say the doubling exists.
662
+ *
663
+ * `installState` on the two hosts is enough — a claude/bob PROJECT install is 'active' only
664
+ * once it has actually written its own `.claude`/`.bob` manifest, which is exactly when it has
665
+ * also written the root carrier.
666
+ */
667
+ export function openclaudeDualHarnessRoot(root) {
668
+ if (installState(root, 'openclaude', 'project') !== 'active') return false;
669
+ return installState(root, 'claude', 'project') === 'active'
670
+ || installState(root, 'bob', 'project') === 'active';
671
+ }
672
+
673
+ /** Every PROJECT root `installTargets()` reaches where `openclaudeDualHarnessRoot` is true. */
674
+ export function openclaudeDualHarnessRoots() {
675
+ const roots = new Set();
676
+ for (const [, scope, root] of installTargets()) {
677
+ if (scope === 'project') roots.add(resolvePath(root));
678
+ }
679
+ return [...roots].filter(openclaudeDualHarnessRoot).sort(comparePaths);
680
+ }
681
+
508
682
  /** `_harness_mcp._registered_targets` — (host, scope, root) for every registered root. */
509
683
  export function registeredTargets() {
510
684
  const out = [];
@@ -531,15 +705,15 @@ export function installTargets() {
531
705
  const seen = new Set();
532
706
 
533
707
  const add = (host, scope, root) => {
534
- // A "project" whose marker dir IS this host's global config dir is the global install
535
- // seen from its parent (the daemon's cwd is $HOME, where $HOME/.claude == ~/.claude) —
536
- // not a separate project. Surfacing it would alias the global's files.
708
+ // A "project" whose marker dir IS this host's global (`isHostGlobalDir`: the env-resolved
709
+ // dir, the default `~/<marker>`, or a `-global` emit) is the global install seen from its
710
+ // parent (the daemon's cwd is $HOME, where $HOME/.claude == ~/.claude) — not a separate
711
+ // project. Surfacing it would alias the global's files, and `rebuild-all` would re-emit it
712
+ // as a project rooted at $HOME (final review C2, under `$CLAUDE_CONFIG_DIR`).
537
713
  if (scope !== 'global') {
538
714
  try {
539
715
  const spec = HOSTS.find((h) => h.host === host);
540
- if (resolvePath(path.join(root, spec.projectMarker)) === resolvePath(spec.configDir())) {
541
- return;
542
- }
716
+ if (isHostGlobalDir(host, path.join(root, spec.projectMarker))) return;
543
717
  } catch { /* as the Python's bare `except Exception: pass` */ }
544
718
  }
545
719
  const key = `${host}\0${resolvePath(root)}`;
@@ -557,6 +731,45 @@ export function installTargets() {
557
731
  return out;
558
732
  }
559
733
 
734
+ /**
735
+ * Each Claude install whose emitted hook FORM no longer matches this machine's hook shell
736
+ * (`claudeHookShell`; Task 15) — the "Git Bash removed after the emit" gap, which otherwise
737
+ * reports nothing: hooks signal through stdout and Claude treats a parse error as non-blocking.
738
+ *
739
+ * Bash form with Git Bash now gone is a PROBLEM: `context`/`learn` fail open under PowerShell
740
+ * (no `onFailure`, so a launch failure there is still non-blocking) and, since Task 1, the two
741
+ * gates fail CLOSED instead (`onFailure: "block"` blocks every Bash/PowerShell/Write/Edit/
742
+ * NotebookEdit call until `rebuild-all` re-emits the PowerShell form) — worse for the user
743
+ * either way, so the message below still reports it as drift needing a rebuild; it does not
744
+ * distinguish the two outcomes. PowerShell form with Git Bash now present is a `[note]`: the hooks work,
745
+ * they only pay PowerShell's startup. Read from the recorded claims the settings file still
746
+ * carries (`liveRecordedHooks`) — the form the emit wrote, not re-derived; Windows only, since
747
+ * nowhere else has two hook shells. `targets` and `platform` are parameters so a test can hand
748
+ * it a sandboxed install and `gateSummary` its own config dirs.
749
+ */
750
+ export function hookShellProblems(targets = installTargets(), platform = process.platform) {
751
+ if (platform !== 'win32') return [];
752
+ const now = claudeHookShell(process.env, platform);
753
+ const out = [];
754
+ for (const [host, scope, root] of targets) {
755
+ // A disabled install's manifest still records the hooks it unwired: nothing runs, so
756
+ // nothing drifts.
757
+ if (host !== 'claude' || claudeState(root, scope) !== 'active') continue;
758
+ const cfg = claudeCfg(root, scope);
759
+ const handlers = liveRecordedHooks(cfg);
760
+ if (!handlers.length) continue;
761
+ const was = handlers.some(([, h]) => h.shell === 'powershell') ? 'powershell' : 'bash';
762
+ if (was === now) continue;
763
+ out.push(was === 'bash'
764
+ ? `[hooks] ${cfg}: hooks were emitted for Git Bash, which is no longer found, so Claude `
765
+ + 'Code runs them under PowerShell where context/learn fail open (no effect) and '
766
+ + 'git-gate/rule-gate now BLOCK every call instead of gating it - run: geneseed rebuild-all'
767
+ : `[note] ${cfg}: hooks were emitted for PowerShell, but Git Bash is now found - they work, `
768
+ + 'and pay PowerShell startup on every call; geneseed rebuild-all switches them back');
769
+ }
770
+ return out;
771
+ }
772
+
560
773
  // ---- what this machine installed (`_harness_setup._installed_defaults`) --------------------
561
774
 
562
775
  /**
@@ -150,7 +150,7 @@ function triggerOf(text) {
150
150
  * failure, so the cure is a shorter first trigger sentence in the source, never a wider cap.
151
151
  */
152
152
  const DESCRIPTION_CAP = 320;
153
- function skillDescription(text) {
153
+ export function skillDescription(text) {
154
154
  return withWhen(firstBlockquote(text), triggerOf(text));
155
155
  }
156
156
 
@@ -245,7 +245,7 @@ function isReadonly(text) {
245
245
  * themselves, and the ones gated on an environment the model cannot see (a herdr pane).
246
246
  */
247
247
  const USER_INVOKED_RE = /<!--\s*invocation:\s*user\s*-->/;
248
- function isUserInvokedOnly(text) {
248
+ export function isUserInvokedOnly(text) {
249
249
  return USER_INVOKED_RE.test(text);
250
250
  }
251
251
 
@@ -359,7 +359,11 @@ function claudeAgentFrontmatter(stem, text, overrides) {
359
359
  if (isReadonly(text)) {
360
360
  const list = ['Write', 'Edit', 'NotebookEdit'];
361
361
  if (!text.includes(WEBFETCH_MARKER)) list.push('WebFetch');
362
- if (!text.includes(BASH_MARKER)) list.push('Bash');
362
+ // Bash AND PowerShell: docs `tools-reference.md` says denying Bash turns PowerShell off
363
+ // only for settings rules and `--disallowedTools` — agent frontmatter is not named there,
364
+ // so a "read-only" agent on Windows could still run shell commands via PowerShell
365
+ // (Claude verdict R1). The one opt-in marker lifts both together.
366
+ if (!text.includes(BASH_MARKER)) list.push('Bash', 'PowerShell');
363
367
  fm.push(`disallowedTools: ${list.join(', ')}`);
364
368
  }
365
369
  return fm;
@@ -505,8 +509,10 @@ export function claimer(oldOwned, cfg, manifestExisted = true) {
505
509
  * `opts.claim` — a `claimer` closure the caller shares with its other writers; built from
506
510
  * `oldOwned`/`cfg`/`manifestExisted` when absent.
507
511
  *
508
- * Returns `{ nAgents, nSkills, written }` — Python's 3-tuple, with `written` as absolute
509
- * paths in write order.
512
+ * Returns `{ nAgents, nSkills, written, userOnlySkills }` — Python's 3-tuple, with `written` as
513
+ * absolute paths in write order, plus `userOnlySkills` (the claimed stems carrying the
514
+ * user-only marker; Task 4/O-1's input to OpenCode's `permission.skill` wiring — see the
515
+ * comment above the skill-frontmatter branch).
510
516
  *
511
517
  * That order is NOT observable, and this comment used to claim it was ("it lands in
512
518
  * `owned`, which the prune diffs against"). Measured when a mutation reversing it stayed
@@ -519,6 +525,15 @@ export function claimer(oldOwned, cfg, manifestExisted = true) {
519
525
  export function writeNativeLayer(items, agentsDir, skillsDir, overrides = null, {
520
526
  host = 'opencode', oldOwned = null, cfg = null, manifestExisted = true,
521
527
  theme = null, src = undefined, skillDirOf = null, claim = claimer(oldOwned, cfg, manifestExisted),
528
+ // Bob (host-compat R1): its skills page documents only `name`/`description` — no
529
+ // `disable-model-invocation` — and giving it the Claude-dialect alias SKILL.md (the
530
+ // `host === 'claude'` branch below) would be an unverified key PLUS a visible duplicate
531
+ // in the model's catalogue (exactly the OpenCode problem this file already dodges for
532
+ // aliases). `bobDialect` suppresses both for Bob only, while `host: 'claude'` keeps every
533
+ // OTHER Claude-dialect choice (agent frontmatter, `warnClaudeIgnored`) — Bob still renders
534
+ // those verbatim. The caller (`emit-claude.mjs`) routes the suppressed names through
535
+ // `writeAliasCommands(..., alsoUserOnly: true)` into `.bob/commands/*.md` instead (O2).
536
+ bobDialect = false,
522
537
  } = {}) {
523
538
  const ov = overrides || {};
524
539
  if (host === 'claude') warnClaudeIgnored(ov);
@@ -527,6 +542,7 @@ export function writeNativeLayer(items, agentsDir, skillsDir, overrides = null,
527
542
  const written = [];
528
543
  const sideFiles = []; // [stem, dest, text], written after the loop
529
544
  const claimedSkills = new Set(); // stems whose own SKILL.md claim held
545
+ const userOnlySkills = []; // claimed stems carrying the user-only marker
530
546
 
531
547
  const write = (dest, text) => {
532
548
  mkdirSync(path.dirname(dest), { recursive: true });
@@ -590,6 +606,7 @@ export function writeNativeLayer(items, agentsDir, skillsDir, overrides = null,
590
606
  let fm;
591
607
  let dest;
592
608
  let kind;
609
+ let userOnly = false;
593
610
  if (folder === 'agents') {
594
611
  fm = host === 'claude' ? claudeAgentFrontmatter(stem, text, ov)
595
612
  : opencodeAgentFrontmatter(stem, text, ov, theme);
@@ -597,26 +614,45 @@ export function writeNativeLayer(items, agentsDir, skillsDir, overrides = null,
597
614
  kind = 'agent';
598
615
  } else {
599
616
  // Skills are BYTE-IDENTICAL across hosts: name + description, body link-stripped.
600
- // The user-only key is emitted for every host too — Claude Code and Bob honour it,
601
- // OpenCode ignores an unknown key — so the identity holds. ALIASES are the one
602
- // exception: OpenCode has no user-only skill, so an alias skill there would land in the
617
+ // The user-only key is emitted for Claude Code, which honours it natively
618
+ // (`disable-model-invocation`). OpenCode has no frontmatter equivalent, so the
619
+ // SAME marker also drives `userOnlySkills` below, which the OpenCode emit turns into a
620
+ // `permission.skill: {name: "deny"}` row in `opencode.json` (opencode-verdict.md O-1) —
621
+ // the model loses the skill from its catalogue, `/name` still runs it (O-1's second
622
+ // finding: the command path reads the UNFILTERED skill list, never the permission one).
623
+ // Bob's skills page documents no such key (host-compat R1) — see `bobDialect` above,
624
+ // which skips the SKILL.md for a user-only skill on Bob entirely.
625
+ // ALIASES are the one case every non-Claude dialect shares: no alias skill via
626
+ // frontmatter exists for them either, so an alias skill there would land in the
603
627
  // model's catalogue as a duplicate; it gets a command instead (`writeAliasCommands`).
604
628
  fm = [`name: ${stem}`, `description: ${jsonDumps(skillDescription(text))}`];
605
- if (isUserInvokedOnly(text)) fm.push('disable-model-invocation: true');
629
+ userOnly = isUserInvokedOnly(text);
630
+ if (userOnly && !bobDialect) fm.push('disable-model-invocation: true');
606
631
  body = stripSkillBodyLinks(pointSideFiles(body, stem, skillDirOf ? skillDirOf(stem)
607
632
  : SKILL_DIR_PLACEHOLDER));
608
633
  dest = path.join(skillsDir, stem, 'SKILL.md');
609
634
  kind = 'skill';
610
635
  }
636
+ // Bob, user-only: no SKILL.md at all — the command written by the caller is the only
637
+ // copy, so the model's `use_skill` catalogue never carries it (see `bobDialect` above).
638
+ // The `continue` is BEFORE `claimedSkills.add(stem)` below, so a user-only skill's own
639
+ // side files (none exist today, but the layout allows them) are skipped too — there is
640
+ // no `skills/<stem>/` folder on Bob for one to sit beside.
641
+ if (kind === 'skill' && userOnly && bobDialect) continue;
611
642
  if (!claim(dest)) continue;
612
643
  write(dest, `---\n${fm.join('\n')}\n---\n\n${body}`);
613
- if (kind === 'skill') claimedSkills.add(stem);
644
+ if (kind === 'skill') {
645
+ claimedSkills.add(stem);
646
+ // Only a CLAIMED user-only skill is Geneseed's to deny: a pre-existing file the claim
647
+ // declined belongs to the user, and `userOnly` was computed from OUR text, not theirs.
648
+ if (userOnly) userOnlySkills.push(stem);
649
+ }
614
650
  // An old name keeps answering `/name`: user-only, so the model's catalogue lists the
615
651
  // skill once. Read off the already-filtered items, so excluding the target drops its
616
652
  // aliases; through `claim`, so manifest, prune and uninstall own them; not counted.
617
653
  // Only once the target's own claim held: a user's file at the target gets no alias
618
654
  // pointing at a skill this install never wrote.
619
- if (kind === 'skill' && host === 'claude') {
655
+ if (kind === 'skill' && host === 'claude' && !bobDialect) {
620
656
  // The host announces the ALIAS's folder as the base directory, so a side-file pointer
621
657
  // climbs back to the target's.
622
658
  const aliasBody = skillDirOf ? body : stripSkillBodyLinks(
@@ -634,7 +670,7 @@ export function writeNativeLayer(items, agentsDir, skillsDir, overrides = null,
634
670
  for (const [stem, dest, text] of sideFiles) {
635
671
  if (claimedSkills.has(stem) && claim(dest)) write(dest, stripSkillBodyLinks(text));
636
672
  }
637
- return { nAgents, nSkills, written };
673
+ return { nAgents, nSkills, written, userOnlySkills };
638
674
  }
639
675
 
640
676
  /**