geneseed 3.20.0 → 4.1.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 (125) hide show
  1. package/CHANGELOG.md +71 -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/HOW-OPENCODE-LOADS.md +35 -0
  9. package/adapters/opencode/plugins/geneseed-activity.js +105 -2
  10. package/adapters/opencode/plugins/geneseed-context.js +96 -1
  11. package/adapters/opencode/plugins/geneseed-guard.js +324 -175
  12. package/adapters/opencode/plugins/geneseed-learn.js +127 -14
  13. package/adapters/opencode/plugins/geneseed-notify.js +46 -1
  14. package/adapters/opencode/plugins/geneseed-ponytail.js +44 -2
  15. package/adapters/opencode/plugins/geneseed-workflow.js +124 -24
  16. package/bin/geneseed-hook.mjs +36 -5
  17. package/docs/README.md +3 -1
  18. package/docs/candidate-hosts.md +0 -8
  19. package/docs/concepts/enforcement-table.md +66 -0
  20. package/docs/concepts/hook-gates.md +2 -2
  21. package/docs/concepts/hooks.md +6 -6
  22. package/docs/concepts/host-matrix.md +4 -3
  23. package/docs/concepts/loop-branch.md +6 -1
  24. package/docs/concepts/rules.md +1 -1
  25. package/docs/design-history.md +59 -0
  26. package/docs/extending.md +11 -3
  27. package/docs/guides/install-opencode.md +1 -1
  28. package/docs/guides/loop-running.md +1 -1
  29. package/docs/guides/mcp-filesystem.md +2 -2
  30. package/docs/guides/mcp-gitlab.md +2 -2
  31. package/docs/guides/mcp-markitdown.md +3 -3
  32. package/docs/guides/mcp.md +7 -7
  33. package/docs/guides/project-context.md +1 -1
  34. package/docs/reference/env-context.md +3 -2
  35. package/docs/reference/environment.md +2 -1
  36. package/docs/reference/opencode-plugins.md +9 -0
  37. package/docs/reference/plugin-guard.md +3 -3
  38. package/docs/reference/troubleshoot-hooks.md +34 -0
  39. package/docs/understand/design-choices-engineering.md +29 -0
  40. package/docs/understand/design-choices-gates.md +23 -0
  41. package/docs/understand/design-choices-hosts.md +21 -0
  42. package/docs/understand/design-choices-install.md +19 -0
  43. package/docs/understand/design-choices-shape.md +21 -0
  44. package/docs/understand/design-choices.md +34 -0
  45. package/docs/understand/enforced-vs-asked.md +5 -4
  46. package/docs/understand/machine-bob.md +1 -1
  47. package/docs/understand/machine-opencode.md +2 -2
  48. package/harness.config.json +1 -1
  49. package/js/build/driver.mjs +42 -16
  50. package/js/build/emit-claude.mjs +24 -8
  51. package/js/build/emit-opencode.mjs +33 -15
  52. package/js/build/generate.mjs +38 -9
  53. package/js/build/source.mjs +39 -0
  54. package/js/cli-table.json +7 -7
  55. package/js/hosts/gitref.mjs +3 -3
  56. package/js/hosts/hooks-context.mjs +11 -30
  57. package/js/hosts/hooks-learn.mjs +89 -8
  58. package/js/hosts/hooks.mjs +212 -18
  59. package/js/hosts/hosts.mjs +217 -13
  60. package/js/hosts/installs.mjs +259 -46
  61. package/js/hosts/mcp.mjs +23 -152
  62. package/js/hosts/native.mjs +48 -12
  63. package/js/hosts/opencode.mjs +48 -18
  64. package/js/hosts/settings.mjs +366 -42
  65. package/js/hosts/shim.mjs +37 -0
  66. package/js/inspect/checks-authoring.mjs +81 -1
  67. package/js/inspect/checks-constitution.mjs +2 -1
  68. package/js/inspect/checks-repo.mjs +68 -4
  69. package/js/inspect/doctor.mjs +40 -9
  70. package/js/inspect/scan.mjs +5 -4
  71. package/js/inspect/status.mjs +62 -12
  72. package/js/lib/text.mjs +23 -0
  73. package/js/loop/catalog.mjs +7 -2
  74. package/js/maintain/migrate.mjs +42 -5
  75. package/js/maintain/uninstall.mjs +150 -38
  76. package/js/maintain/update.mjs +22 -2
  77. package/js/web/actions.mjs +43 -87
  78. package/js/web/api.mjs +13 -4
  79. package/js/web/handler.mjs +2 -2
  80. package/js/web/routes.mjs +1 -2
  81. package/js/web/state.mjs +28 -15
  82. package/package.json +1 -1
  83. package/src/agents/_template.md +3 -2
  84. package/src/agents/researcher.md +2 -1
  85. package/src/agents/reviewer.md +9 -4
  86. package/src/agents/tester.md +3 -3
  87. package/src/bricks/_template.md +1 -1
  88. package/src/doctrines/ops.md +9 -7
  89. package/src/doctrines/rigor.md +8 -7
  90. package/src/laws/universal.md +1 -1
  91. package/src/skills/_template.md +3 -3
  92. package/src/skills/brainstorm/builder-check.md +16 -0
  93. package/src/skills/brainstorm.md +19 -11
  94. package/src/skills/daydream/SKILL.md +1 -1
  95. package/src/skills/daydream/VENDOR.md +4 -0
  96. package/src/skills/debug/ci.md +4 -4
  97. package/src/skills/debug.md +4 -4
  98. package/src/skills/develop.md +6 -4
  99. package/src/skills/fresh-eyes.md +1 -1
  100. package/src/skills/geneseed-code-review.md +8 -2
  101. package/src/skills/handoff.md +1 -1
  102. package/src/skills/herdr.md +1 -1
  103. package/src/skills/parallel-agents.md +4 -4
  104. package/src/skills/pipeline.md +3 -3
  105. package/src/skills/plan.md +18 -6
  106. package/src/skills/review-response.md +5 -2
  107. package/src/skills/ship.md +2 -1
  108. package/src/skills/worktree.md +2 -0
  109. package/web/dist/assets/{Activity-IbwdHHyx.js → Activity-CqVt2u9s.js} +1 -1
  110. package/web/dist/assets/{ActivityDetail-B5uivdWI.js → ActivityDetail-Bui5Gomc.js} +1 -1
  111. package/web/dist/assets/{Dashboard-v6WQ37bD.js → Dashboard-VlIx1-At.js} +2 -2
  112. package/web/dist/assets/{Docs-CypIfDBX.js → Docs-6cYYkVeA.js} +1 -1
  113. package/web/dist/assets/{GreenhouseView-ClxP0hQG.js → GreenhouseView-DgNY8QKU.js} +1 -1
  114. package/web/dist/assets/{Installs-CPkQeFjO.js → Installs-DZwZOUZJ.js} +1 -1
  115. package/web/dist/assets/{JournalView-BeEInhUf.js → JournalView-DXpFbKOG.js} +1 -1
  116. package/web/dist/assets/{Laws-Ds6U5xBQ.js → Laws-Bh7ztekk.js} +1 -1
  117. package/web/dist/assets/{Library-BraUKPyQ.js → Library-DBLX-1SD.js} +1 -1
  118. package/web/dist/assets/{Loops-DWcy3mwK.js → Loops-CvFMZ7_P.js} +1 -1
  119. package/web/dist/assets/{OperatorHudView-CQyrJQ9m.js → OperatorHudView-FPTUH1CB.js} +1 -1
  120. package/web/dist/assets/{Personal-YWqs6qFL.js → Personal-DcVA502F.js} +1 -1
  121. package/web/dist/assets/{Tabs-EhzEC-Xh.js → Tabs-DtyR4h75.js} +1 -1
  122. package/web/dist/assets/{api-CxgfYu18.js → api-CRqWld2H.js} +1 -1
  123. package/web/dist/assets/{drawable-CEKPNV7X.js → drawable-CNSPVZxX.js} +1 -1
  124. package/web/dist/assets/{index-CPi22b7y.js → index-BmjcLIOr.js} +2 -2
  125. package/web/dist/index.html +2 -2
@@ -25,9 +25,88 @@ import {
25
25
  import { constitutionProblems, leanBlockProblems } from './checks-constitution.mjs';
26
26
  import { countTableProblems } from './checks-counts.mjs';
27
27
  import { catalogProblems } from '../loop/catalog.mjs';
28
- import { globSorted, isDir } from './scan.mjs';
28
+ import { blankCodeSpans, globSorted, isDir, isFile, rglob } from './scan.mjs';
29
29
  import { spawnSync } from 'node:child_process';
30
30
 
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';
37
+
38
+ /**
39
+ * The comment bodies (trimmed) that are known to be FUNCTIONAL — consumed by the render, a
40
+ * host emit, or the docs skill — rather than a maintainer's own note, so a bare, un-prefixed
41
+ * comment matching one of these is left alone. `<!-- note: ... -->` is the escape hatch itself:
42
+ * `resolveRuleIds` in `js/build/source.mjs` strips it before any render sees it, so it is
43
+ * already quiet by the time an agent would read it — this just stops the doctor re-flagging it.
44
+ */
45
+ const ALLOWED_COMMENT_RES = [
46
+ /^note:[\s\S]*$/, // the author-note prefix itself
47
+ /^LEAN:(begin|else|end)$/, // js/build/render.mjs LEAN_BLOCK_RE
48
+ /^CATALOG:(begin (agents|skills)|else|end)$/, // js/build/render.mjs CATALOG_BLOCK_RE
49
+ /^INCLUDE:\s*\S+$/, // js/build/render.mjs INCLUDE_RE
50
+ /^invocation:\s*user$/, // js/hosts/native.mjs: disable-model-invocation
51
+ /^aliases:\s*\S.*$/, // js/build/source.mjs ALIASES_RE
52
+ /^webfetch:\s*allow$/, // js/hosts/* webfetch gate
53
+ // js/web/docs.mjs HARNESS_OPEN_RE: `harness:` then one or more of its four ids, comma-separated.
54
+ new RegExp(`^harness:\\s*(?:${HARNESS_IDS})(?:\\s*,\\s*(?:${HARNESS_IDS}))*$`),
55
+ /^bash:\s*allow$/, // js/hosts/native.mjs BASH_MARKER
56
+ ];
57
+
58
+ /**
59
+ * Every bare HTML comment under `src` that is neither the `<!-- note: ... -->` author-note
60
+ * prefix nor a known functional marker — the half of the pair `resolveRuleIds`' strip does not
61
+ * cover: that function only ever sees a file once it is already being rendered, so a note
62
+ * someone forgot to prefix would ship verbatim rather than fail loudly. Comments inside a
63
+ * fenced code block or an inline code span are exempt — a skill explaining the managed-block
64
+ * syntax (`` `<!-- BEGIN GENESEED -->` ``) to the agent is literal text, not live markup, and
65
+ * `blankCodeSpans` (shared with `stripAuthorNotes`'s own fence/code-span exemption, so the two
66
+ * can never disagree about what counts as "shown as an example") is what tells the two apart
67
+ * without a full Markdown parser.
68
+ *
69
+ * A SECOND CHECK, on the SAME `note:` matches, named after the landmine `src/skills/_template.md`
70
+ * actually hit: a `note:` comment whose prose quotes a literal marker (`` `<!-- x -->` ``, even
71
+ * inside backticks) closes AT ITS OWN inner `-->`, because HTML comments do not nest — the rest
72
+ * of the note then leaks as live text. This is checked against the RAW slice of the match, BEFORE
73
+ * blanking: `blankCodeSpans` is what lets `stripAuthorNotes` run to the real closing `-->` when
74
+ * the inner one sits inside a fence or a span, but that same blanking would hide the inner
75
+ * `<!--`/`-->` from a check run on the blanked text — the raw body is the only place the nesting
76
+ * is still visible. `src = SRC` is a parameter, not a constant read, so a test can point it at a
77
+ * throwaway tree instead of planting a fault in the real one.
78
+ */
79
+ export function authorNoteProblems(src = SRC) {
80
+ const problems = [];
81
+ for (const p of rglob(src)) {
82
+ if (!isFile(p) || !p.endsWith('.md')) continue;
83
+ let raw;
84
+ try { raw = readText(p); } catch { continue; }
85
+ const scan = blankCodeSpans(raw);
86
+ const rel = `src/${path.relative(src, p).split(path.sep).join('/')}`;
87
+ for (const m of scan.matchAll(/<!--([\s\S]*?)-->/g)) {
88
+ const body = m[1].trim();
89
+ const line = scan.slice(0, m.index).split('\n').length;
90
+ if (/^note:/.test(body)) {
91
+ const rawInner = raw.slice(m.index + 4, m.index + m[0].length - 3);
92
+ if (rawInner.includes('<!--') || rawInner.includes('-->')) {
93
+ problems.push(`[authoring] ${rel}:${line} note: comment contains a nested HTML `
94
+ + 'comment marker (<!-- or -->) — comments do not nest, so it closes early and '
95
+ + 'leaks the rest of the note as live text; rephrase the example without the '
96
+ + 'literal marker (prose instead of a quoted <!-- ... -->)');
97
+ continue;
98
+ }
99
+ }
100
+ if (ALLOWED_COMMENT_RES.some((re) => re.test(body))) continue;
101
+ problems.push(`[authoring] ${rel}:${line} has an HTML comment that is neither the `
102
+ + "'note:' author-note prefix nor a known functional marker (" + JSON.stringify(body) + ') — '
103
+ + "prefix it with 'note:' if it is maintainer-only, or the doctor's allow-list if the "
104
+ + 'agent must read it literally');
105
+ }
106
+ }
107
+ return problems;
108
+ }
109
+
31
110
  // Moved to the two sibling modules; re-exported so callers that import the authoring group as
32
111
  // one module keep working.
33
112
  export {
@@ -115,6 +194,7 @@ export function authoringProblems() {
115
194
  problems.push(...constitutionProblems());
116
195
  problems.push(...leanBlockProblems());
117
196
  problems.push(...countTableProblems());
197
+ problems.push(...authorNoteProblems());
118
198
  return problems;
119
199
  }
120
200
 
@@ -169,7 +169,7 @@ const DOCTRINE_TOKEN_RE = /\{\{DOCTRINE(?:\}\}|:)/;
169
169
  /**
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
- * (`law-1`, `law-4`, `process-1`, `process-5`, `rigor-5` in `js/hosts/hooks.mjs` and the OpenCode guard
172
+ * (`law-1`, `law-4`, `process-1`, `process-5`, `rigor-5`, `ops-2` in `js/hosts/hooks.mjs` and the OpenCode guard
173
173
  * plugin), `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.
@@ -177,6 +177,7 @@ const DOCTRINE_TOKEN_RE = /\{\{DOCTRINE(?:\}\}|:)/;
177
177
  export const HOOK_PINNED = {
178
178
  'sealed-secrets': 'I', 'deletion-is-deliberate': 'IV',
179
179
  'persist-insight': 'process 1', 'consent-before-push': 'process 5', 'external-gate': 'rigor 5',
180
+ 'commands-must-return': 'ops 2',
180
181
  };
181
182
 
182
183
  /**
@@ -10,6 +10,8 @@ import path from 'node:path';
10
10
  import {
11
11
  ALIASES_RE, PLUGIN_SRC, RETIRED_SKILL_IDS, ROOT, SRC, THEMES, WORKFLOW_SRC, aliasesOf, knownSkillIds,
12
12
  } from '../build/source.mjs';
13
+ import { CLAUDE_STYLE, liveRecordedHooks } from '../hosts/hosts.mjs';
14
+ import { claudeCfg, installTargets } from '../hosts/installs.mjs';
13
15
  import { VENDORED_SKILL_DIRS } from '../hosts/native.mjs';
14
16
  import { COMMAND_SET } from '../hosts/opencode.mjs';
15
17
  import { hookShimPath, shimDeadPaths } from '../hosts/shim.mjs';
@@ -165,6 +167,44 @@ export function secretProblems() {
165
167
  return problems;
166
168
  }
167
169
 
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
+
168
208
  /**
169
209
  * `_harness_build._shim_problems` — the hook shim exists and points at something real.
170
210
  *
@@ -176,12 +216,36 @@ export function secretProblems() {
176
216
  * every gate silently dead: the hooks still fire, the shim still runs, and the interpreter
177
217
  * reports "no such file" into a channel nobody reads.
178
218
  *
179
- * An ABSENT shim is not a problem: a source checkout that has never emitted has no reason to
180
- * own one.
219
+ * An ABSENT shim is not ALWAYS a problem: a source checkout that has never emitted has no
220
+ * reason to own one. But one deleted by hand (or by a cleanup script) out from under an
221
+ * install that still names it is the same dead-hooks failure as a moved checkout, newly worth
222
+ * 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
224
+ * running is otherwise invisible until the user notices nothing works.
225
+ * `installsReferencingShim` answers that half; this still returns `[]` when nothing live
226
+ * names the missing shim.
227
+ *
228
+ * NOT CHECKED: whether the runner (`node`) on the emitted command line can still be found.
229
+ * Every command this repo emits bakes an ABSOLUTE interpreter path at emit time
230
+ * (`hookRunnerEntry`'s `process.execPath`) — never a bare `node` resolved against the live
231
+ * PATH — so there is no PATH-lookup fact to check here, and confirming the absolute path
232
+ * still exists would mean spawning nothing (a plain `existsSync`), which `shimDeadPaths`
233
+ * already does for every runner path baked into a shim BODY that still exists. There is no
234
+ * second runner path to check in the "shim file itself is gone" branch below: the only thing
235
+ * the live install's settings.json names at that point is the shim's own (missing) path.
236
+ *
237
+ * `targets` is a parameter (default: the real `installTargets()`) for the same reason
238
+ * `installsReferencingShim` takes one: a test exercises the "missing but referenced" branch
239
+ * with a sandboxed install instead of this machine's.
181
240
  */
182
- export function shimProblems() {
241
+ export function shimProblems(targets = installTargets()) {
183
242
  const p = hookShimPath();
184
- if (!isFile(p)) return [];
243
+ if (!isFile(p)) {
244
+ return installsReferencingShim(p, targets).map((cfg) => `[shim] ${p} does not exist, but ${cfg} `
245
+ + '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: "
247
+ + 'geneseed rebuild-all');
248
+ }
185
249
  let body;
186
250
  try { body = readText(p); } catch (e) {
187
251
  return [`[shim] ${p} exists but cannot be read (${e.message}) — hooks may be dead`];
@@ -32,8 +32,10 @@
32
32
  import path from 'node:path';
33
33
  import { emitGlobalInto, emitProjectInto, main as driverMain } from '../build/driver.mjs';
34
34
  import { ROOT } from '../build/source.mjs';
35
- import { CLAUDE_STYLE, resolvePath } from '../hosts/hosts.mjs';
36
- import { installedDefaults, themeFiles } from '../hosts/installs.mjs';
35
+ import { CLAUDE_STYLE, opencodeShadowedInstall, resolvePath } from '../hosts/hosts.mjs';
36
+ import {
37
+ hookShellProblems, installedDefaults, openclaudeDualHarnessRoots, themeFiles,
38
+ } from '../hosts/installs.mjs';
37
39
  import { validateIsVendored } from '../hosts/native.mjs';
38
40
  import { printOut } from '../lib/fs.mjs';
39
41
  import { authoringProblems, loopProblems } from './checks-authoring.mjs';
@@ -41,7 +43,7 @@ import {
41
43
  checkBuild, colorThemeProblems, renderedProblems, themeParityProblems,
42
44
  } from './checks-build.mjs';
43
45
  import { moduleMapProblems, scorecardProblems, shimProblems } from './checks-repo.mjs';
44
- import { isDoctorNote, sortedProblems, sortedUnique, stemOf, withTempDir } from './scan.mjs';
46
+ import { NOTE, isDoctorNote, sortedProblems, sortedUnique, stemOf, withTempDir } from './scan.mjs';
45
47
 
46
48
  /**
47
49
  * `_harness_build._themes_to_check` — which themes doctor validates.
@@ -58,6 +60,18 @@ export function themesToCheck(theme, allThemes, detected, available) {
58
60
  return [...available].sort();
59
61
  }
60
62
 
63
+ /**
64
+ * Host-compat I1: one `[note]`-prefixed line per root `openclaudeDualHarnessRoots` finds —
65
+ * extracted to its own function (rather than inlined in `doctorCollect`) so a test can pin the
66
+ * exact text without running a full doctor sweep.
67
+ */
68
+ export function openclaudeDualHarnessNotes() {
69
+ return openclaudeDualHarnessRoots().map((root) => `${NOTE}[openclaude] ${root} also carries `
70
+ + 'a claude/bob per-repo install — OpenClaude loads its own .openclaude/CLAUDE.md AND that '
71
+ + 'root CLAUDE.md/AGENTS.md (every always-on doc doubles) — uninstall the one you no '
72
+ + 'longer use if this was not intended');
73
+ }
74
+
61
75
  /**
62
76
  * `swallowStdout` and `captureBoth` below are the same operation over a different stream
63
77
  * list: replace each stream's `write` with a no-op for the duration of `fn`, then restore
@@ -219,6 +233,21 @@ export function doctorCollect({
219
233
  problems = problems.concat(ran('authoring', 'Authoring gates', authoringProblems()));
220
234
  problems = problems.concat(ran('loops', 'Loop catalogue', loopProblems()));
221
235
  problems = problems.concat(ran('shim', 'Hook shim', shimProbs));
236
+ // Windows: a Claude install's hooks emitted for Git Bash on a machine that no longer has it
237
+ // misbehave under PowerShell (Task 15) — context/learn fail open, and since Task 1 the two
238
+ // gates fail CLOSED instead (`onFailure: "block"`). The reverse direction is a note.
239
+ problems = problems.concat(ran('hook_shell', 'Hook shell', hookShellProblems()));
240
+ // A machine check like the shim's: OPENCODE_CONFIG_DIR adds a dir, it does not replace one.
241
+ const shadow = opencodeShadowedInstall();
242
+ problems = problems.concat(ran('opencode_dirs', 'OpenCode config dirs', shadow
243
+ ? [`[opencode] ${shadow} holds a Geneseed install beside $OPENCODE_CONFIG_DIR's, and OpenCode `
244
+ + 'loads both (every plugin runs twice) — uninstall the one you no longer use'] : []));
245
+ // Host-compat I1: OpenClaude's project carrier is additive, not exclusive — there is no
246
+ // clean exclusion, so unlike `opencode_dirs` above (an accident of an env var) this is a
247
+ // NOTE, not a problem: a repo that deliberately runs claude/bob AND openclaude is a legal
248
+ // configuration its owner chose, and doctor must not fail a legal configuration.
249
+ problems = problems.concat(ran('openclaude_dual', 'OpenClaude dual harness',
250
+ openclaudeDualHarnessNotes()));
222
251
  problems = problems.concat(ran('map', 'Module map', moduleMapProblems()));
223
252
  problems = problems.concat(ran('scorecard', 'Scorecard floor', scorecardProblems()));
224
253
  // P10c's `cli` check is GONE, and the reason is not that it stopped mattering. It hashed
@@ -259,12 +288,14 @@ export function cmdDoctor(args) {
259
288
  printOut(`${collected.length ? collected[0] : '[doctor] no themes found'}\n`);
260
289
  return 1;
261
290
  }
262
- // ⚠ NOTES ARE PRINTED AND NOT COUNTED. The only producer is the pack-off citation report in
263
- // `constitutionProblems`, and the state it describes is one the install's owner chose: a
264
- // build that leaves body prose citing a pack it did not render. Silence there is how prose
265
- // and boundary drift apart, and a non-zero exit there is doctor failing a legal
266
- // configuration. Nothing in the recorded CLI corpus produces one — the checkout's own
267
- // `harness.config.json` names no packs — so the byte-compared output is unmoved.
291
+ // ⚠ NOTES ARE PRINTED AND NOT COUNTED. Two producers: the pack-off citation report in
292
+ // `constitutionProblems` (a build that leaves body prose citing a pack it did not render),
293
+ // and `openclaudeDualHarnessRoots` above (a repo deliberately running claude/bob AND
294
+ // openclaude). Both describe a state the install's owner chose — silence is how prose and
295
+ // boundary drift apart unnoticed, or how a dual-harness repo stays a surprise, but a
296
+ // non-zero exit for either would be doctor failing a legal configuration. Nothing in the
297
+ // recorded CLI corpus produces either — the checkout's own `harness.config.json` names no
298
+ // packs, and it carries no dual-harness install — so the byte-compared output is unmoved.
268
299
  const notes = collected.filter(isDoctorNote);
269
300
  const problems = collected.filter((p) => !isDoctorNote(p));
270
301
  const scoped = !args.theme && !args.all && themes.length === 1;
@@ -14,12 +14,15 @@ 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
18
  import { mkdtempSync, readdirSync, rmSync } from 'node:fs';
18
19
 
19
20
  // `isDir`/`isFile`/`has` are owned by `js/lib` now (single owner across `hosts/`, `build/`,
20
21
  // `inspect/` and `web/`) — re-exported here so this module's own existing callers keep
21
- // importing them from `./scan.mjs` unchanged.
22
- export { isDir, isFile, has };
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 };
23
26
 
24
27
  // --------------------------------------------------------------------------------------
25
28
  // _harness_core's scanning primitives
@@ -32,8 +35,6 @@ export { isDir, isFile, has };
32
35
  export const TOKEN_RE = /\{\{[A-Z_][A-Z0-9_]*\}\}/g;
33
36
  export const LINK_RE = /\]\((?!https?:\/\/|#)([^)]+)\)/g;
34
37
  export const ABS_LINK_RE = /^([A-Za-z]:[\\/]|\/|~)/;
35
- const FENCE_RE = /```[\s\S]*?```/g;
36
- const INLINE_CODE_RE = /`[^`]*`/g;
37
38
  const COMMENT_RE = /<!--[\s\S]*?-->/g;
38
39
 
39
40
  /** `_harness_core.strip_code` — fenced blocks, then HTML comments, then inline code, in that order. */
@@ -62,17 +62,19 @@ import { THEMES, ROOT, makeCfg } from '../build/source.mjs';
62
62
  import { tuiInventory } from './inventory.mjs';
63
63
  import { readVersion, sourceFingerprint } from '../build/version.mjs';
64
64
  import {
65
- claudeConfigDir, bobConfigDir, openclaudeConfigDir, opencodeConfigDir,
66
- resolvePath, resolveMemoryDir, sovereignBypass, GATE_LEDGER,
65
+ claudeConfigDir, bobConfigDir, openclaudeConfigDir, opencodeConfigDir, opencodeShadowedInstall,
66
+ isHostGlobalDir, resolvePath, resolveMemoryDir, sovereignBypass, CLAUDE_STYLE, GATE_LEDGER,
67
67
  } from '../hosts/hosts.mjs';
68
68
  // P5f moved the install DETECTORS out of this file — `diff` renders its expected copy in the
69
69
  // deployed theme and footprint, and `rebuild-all` re-emits in the deployed everything, so
70
70
  // three verbs now read them.
71
71
  import {
72
- defaultTheme, installedDefaults, installTargets, readJsonMaybe, readMaybe,
72
+ defaultTheme, hookShellProblems, installedDefaults, installTargets, legacyProcessCarrierOfDir,
73
+ openclaudeDualHarnessRoots, readJsonMaybe, readMaybe,
73
74
  } from '../hosts/installs.mjs';
74
75
  import { installProfile, rebuildCommand } from '../build/generate.mjs';
75
- import { shimDead } from '../hosts/shim.mjs';
76
+ import { installsReferencingShim } from './checks-repo.mjs';
77
+ import { hookShimPath, shimDead } from '../hosts/shim.mjs';
76
78
  import { printOut } from '../lib/fs.mjs';
77
79
  import { codePointLength, padEndToWidth } from '../lib/text.mjs';
78
80
 
@@ -185,7 +187,7 @@ export function accentFor(theme) {
185
187
  * do anything, and the input to the decision of whether to build more of them. Unreadable
186
188
  * ledgers and malformed lines are skipped, not reported: this is a display read.
187
189
  */
188
- export function gateSummary(cfgDirs) {
190
+ export function gateSummary(cfgDirs, platform = process.platform) {
189
191
  const standingDown = [];
190
192
  const asks = {};
191
193
  let total = 0;
@@ -207,8 +209,31 @@ export function gateSummary(cfgDirs) {
207
209
  }
208
210
  }
209
211
  // `dead` is the machine shim's missing targets: every gate below is armed on paper and never
210
- // runs, so it outranks `armed` in the row.
211
- return { standing_down: standingDown, asks, total, dead: shimDead() };
212
+ // runs, so it outranks `armed` in the row. A shim that exists but names a moved checkout
213
+ // (`shimDead`) and a shim that is simply GONE while a live install still names it
214
+ // (`installsReferencingShim`, Task 1's fix round — `onFailure: "block"` makes a dead gate
215
+ // block instead of silently passing, so this stopped being a quiet failure) are the same
216
+ // row here: both mean "every hook of every install naming this shim cannot run".
217
+ // `fail_open` (Task 15): Claude installs whose bash-form hooks now run under PowerShell. Only
218
+ // present when non-empty, so every recorded `--json` panel keeps its shape.
219
+ // Only the dirs it was handed, and only those that are Claude's global (`isHostGlobalDir`);
220
+ // `platform` is injectable so a test on Linux still exercises the win32 branch.
221
+ const isClaude = (c) => { try { return isHostGlobalDir('claude', c); } catch { return false; } };
222
+ const claude = cfgDirs.filter(isClaude).map((c) => ['claude', 'global', c]);
223
+ const failOpen = hookShellProblems(claude, platform).filter((p) => !p.startsWith('[note] '));
224
+ // Same `cfgDirs`, widened to every Claude-STYLE host (`CLAUDE_STYLE`: claude, bob,
225
+ // openclaude — all three share this one machine-wide shim), for `installsReferencingShim`.
226
+ const styleTargets = cfgDirs.flatMap((c) => CLAUDE_STYLE
227
+ .filter((host) => { try { return isHostGlobalDir(host, c); } catch { return false; } })
228
+ .map((host) => [host, 'global', c]));
229
+ const shimPath = hookShimPath();
230
+ const dead = shimDead();
231
+ const missing = !dead.length && !existsSync(shimPath)
232
+ && installsReferencingShim(shimPath, styleTargets).length ? [shimPath] : dead;
233
+ return {
234
+ standing_down: standingDown, asks, total, dead: missing,
235
+ ...(failOpen.length ? { fail_open: failOpen } : {}),
236
+ };
212
237
  }
213
238
 
214
239
  /** `_harness_status._status_data`. */
@@ -263,16 +288,25 @@ export function statusData() {
263
288
  agent_md: agentMd ? String(agentMd) : null,
264
289
  agent_md_present: Boolean(agentMd && existsSync(agentMd)),
265
290
  gates: gateSummary([...(cfgDir ? [cfgDir] : []), ...otherCfg]),
291
+ opencode_shadow: opencodeShadowedInstall(),
292
+ openclaude_dual_harness: openclaudeDualHarnessRoots(),
266
293
  // EVERY install, not just the one `installedDefaults` settles on — and read through the
267
294
  // same `installProfile` `rebuild-all` uses, so the settings shown are the ones a rebuild
268
- // keeps. `rebuild` is the pasteable command an agent edits one flag of. A broken install
269
- // must not sink the panel, so it is reported rather than thrown. `absent` slots are
270
- // filtered out here: an absent slot has no settings, so a row of defaults for it reads as
271
- // an install that does not exist — noise in the panel and misinformation in `--json`.
295
+ // keeps. `rebuild` is the pasteable command an agent edits one flag of — EXCEPT for a
296
+ // pre-comms carrier, where `p.argv` already carries the under-specified read-back
297
+ // (`doctrinesOfDir`/`excludedRulesOfDir` no longer migrate it) and pasting it would cause
298
+ // the exact permanent loss `rebuild-all`/`migrate`/the web console now refuse to cause, with
299
+ // no guard left to stop an agent or owner from running it by hand. A broken install must not
300
+ // sink the panel, so it is reported rather than thrown. `absent` slots are filtered out
301
+ // here: an absent slot has no settings, so a row of defaults for it reads as an install that
302
+ // does not exist — noise in the panel and misinformation in `--json`.
272
303
  installs: installTargets().map(([host, scope, root]) => {
273
304
  try {
274
305
  const p = installProfile(host, scope, root);
275
- return { ...p, rebuild: rebuildCommand(p.argv) };
306
+ const rebuild = legacyProcessCarrierOfDir(root, host)
307
+ ? "geneseed setup # pre-comms carrier (names 'process 8') — run setup for this install, not a rebuild"
308
+ : rebuildCommand(p.argv);
309
+ return { ...p, rebuild };
276
310
  } catch (e) {
277
311
  return { host, scope, root, state: 'unreadable', error: String(e?.message ?? e) };
278
312
  }
@@ -352,6 +386,9 @@ export function statusLines(d, color = false) {
352
386
  const g = d.gates;
353
387
  const armed = g.dead?.length
354
388
  ? `DEAD — hook shim points at ${g.dead.join(', ')}; run: geneseed rebuild-all`
389
+ : g.fail_open?.length
390
+ ? `FAIL OPEN — ${g.fail_open.length} Claude install(s) emitted for Git Bash, now absent; `
391
+ + 'run: geneseed rebuild-all'
355
392
  : g.standing_down.length
356
393
  ? `STANDING DOWN for this cwd (excludes.json in ${g.standing_down.join(', ')})`
357
394
  : 'armed';
@@ -360,6 +397,19 @@ export function statusLines(d, color = false) {
360
397
  rows.push(['gates', `${armed} ${DOT} ${g.total} ask${g.total === 1 ? '' : 's'}`
361
398
  + (byRule ? ` (${byRule})` : '')]);
362
399
  }
400
+ // Conditional: null on every machine that has not set `OPENCODE_CONFIG_DIR` over an old install.
401
+ if (d.opencode_shadow) {
402
+ rows.push(['opencode', `ALSO LOADED: a second Geneseed install in ${d.opencode_shadow} (OpenCode `
403
+ + 'reads it beside OPENCODE_CONFIG_DIR, so every plugin runs twice) - uninstall one']);
404
+ }
405
+ // Conditional: empty on every machine without a claude/bob AND openclaude project install
406
+ // sharing one repo. There is no clean exclusion (see `openclaudeDualHarnessRoot`), so this
407
+ // only names the roots where it is happening.
408
+ for (const root of d.openclaude_dual_harness ?? []) {
409
+ rows.push(['openclaude', `ALSO LOADED: ${root} also carries a claude/bob per-repo install — `
410
+ + 'OpenClaude reads its own .openclaude/CLAUDE.md AND that root CLAUDE.md/AGENTS.md '
411
+ + '(every always-on doc doubles) - no clean exclusion, uninstall one']);
412
+ }
363
413
  // Conditional for the recording reason again: no recorded panel carries `installs`.
364
414
  for (const p of d.installs ?? []) {
365
415
  const where = p.scope === 'project' ? ` (${p.root})` : '';
package/js/lib/text.mjs CHANGED
@@ -193,3 +193,26 @@ export function percentDecode(s) {
193
193
  export function relPosix(base, target) {
194
194
  return path.relative(base, target).split(path.sep).join('/');
195
195
  }
196
+
197
+ /**
198
+ * Markdown's two ways to show syntax as a literal example rather than live markup: a fenced
199
+ * block (```` ``` ````) and an inline span (`` ` ``). Shared here, not duplicated in
200
+ * `js/inspect/scan.mjs` and `js/build/source.mjs`, because both need the SAME answer to "is this
201
+ * text code" and a second regex drifting from the first would silently stop agreeing on it.
202
+ */
203
+ export const FENCE_RE = /```[\s\S]*?```/g;
204
+ export const INLINE_CODE_RE = /`[^`]*`/g;
205
+
206
+ /**
207
+ * Fenced and inline code, blanked to same-length whitespace with every newline kept — so a scan
208
+ * for a BARE `<!-- ... -->` (one a reader actually sees as live markup) never trips on one that
209
+ * is only a literal example inside a fence or a code span, while every match's INDEX still lands
210
+ * on the right source offset in the ORIGINAL text. Two callers: `checks-authoring.mjs`'s
211
+ * `authorNoteProblems` (which needs comments that survive, unlike `js/inspect/scan.mjs`'s
212
+ * `stripCode`, which drops them) and `js/build/source.mjs`'s `stripAuthorNotes` (which needs a
213
+ * `note:` comment show ONLY as a literal example left un-stripped).
214
+ */
215
+ export function blankCodeSpans(text) {
216
+ const blank = (s) => s.replace(/[^\n]/g, ' ');
217
+ return text.replace(FENCE_RE, blank).replace(INLINE_CODE_RE, blank);
218
+ }
@@ -20,7 +20,7 @@ import os from 'node:os';
20
20
  import path from 'node:path';
21
21
  import { readText, isFile, isDir } from '../lib/fs.mjs';
22
22
  import { frontmatter } from '../hosts/memory-files.mjs';
23
- import { SRC } from '../build/source.mjs';
23
+ import { SRC, stripAuthorNotes } from '../build/source.mjs';
24
24
  import { checkGraph } from './graph.mjs';
25
25
 
26
26
  export function userLoopsDir() {
@@ -31,7 +31,12 @@ export function userLoopsDir() {
31
31
  const KEBAB = /^[a-z0-9][a-z0-9-]*$/;
32
32
 
33
33
  export function parseBrick(text, origin) {
34
- const [fm, body] = frontmatter(text);
34
+ // A brick's body never goes through `renderFile` — the module header above is explicit that
35
+ // the loop CLI hands it to the model raw — so this is the one place that can strip a `note:`
36
+ // comment before it reaches the prompt, through the same `stripAuthorNotes` every other
37
+ // reader of `src/` text goes through (`js/build/source.mjs`).
38
+ const [fm, rawBody] = frontmatter(text);
39
+ const body = stripAuthorNotes(rawBody);
35
40
  const get = (k) => (fm.get(k) ?? '').trim();
36
41
  const name = get('name');
37
42
  const problems = [];
@@ -38,8 +38,12 @@ import path from 'node:path';
38
38
  import { main as driverMain } from '../build/driver.mjs';
39
39
  import { ROOT } from '../build/source.mjs';
40
40
  import { CLAUDE_STYLE } from '../hosts/hosts.mjs';
41
- import { EMIT_HOST_SCOPE, installState, installTargets, readMaybe } from '../hosts/installs.mjs';
41
+ import {
42
+ claudeCfg, claudeReadManifest, EMIT_HOST_SCOPE, installState, installTargets,
43
+ legacyProcessCarrierOfDir, readMaybe,
44
+ } from '../hosts/installs.mjs';
42
45
  import { installProfile } from '../build/generate.mjs';
46
+ import { settingsFile } from './uninstall.mjs';
43
47
  import {
44
48
  autostartPaths, autostartStale, hookShimPath, migrateShape, shimHome,
45
49
  } from '../hosts/shim.mjs';
@@ -57,11 +61,23 @@ const KNOWN_EMITS = new Set(EMIT_HOST_SCOPE.keys());
57
61
  * shim nor `harness.py`), so it classifies as `none` and its whole migration is the re-emit —
58
62
  * the same re-emit, in the same change, on every host, which is what the project's
59
63
  * host-parity rule asks for.
64
+ *
65
+ * BOTH HALVES READ BACK AN EXISTING RESOLVER rather than a second hand-rolled copy
66
+ * (host-compat B1/B2): `claudeCfg` (`js/hosts/installs.mjs`) is the cfg dir — `root` itself
67
+ * for a global install, `<root>/<projectMarker>` (`.claude`/`.bob`/`.openclaude`) for a
68
+ * project one — and `settingsFile` (`js/maintain/uninstall.mjs`) is the file name inside it,
69
+ * read off the install's OWN manifest. The manifest is the authority, not a guess at the
70
+ * canonical name: a pre-existing `bob-global` install with no `managed.settings_file` yet
71
+ * recorded carries the bare, pre-nesting `settings.json` it was actually written with, not
72
+ * the nested `settings/settings.json` a brand new one gets — exactly the distinction
73
+ * `claudeWire`'s own `get(old, 'settings_file') || 'settings.json'` makes before rewiring. A
74
+ * literal `path.join(root, name)` here, as before, skipped the project subfolder entirely and
75
+ * guessed the canonical name instead of reading what the install actually has.
60
76
  */
61
- function hookSettingsFile(root, host, scope) {
77
+ export function hookSettingsFile(root, host, scope) {
62
78
  if (!CLAUDE_STYLE.includes(host)) return null;
63
- const name = scope === 'project' && host !== 'bob' ? 'settings.local.json' : 'settings.json';
64
- return path.join(root, name);
79
+ const cfg = claudeCfg(root, scope, host);
80
+ return settingsFile(cfg, claudeReadManifest(cfg).managed);
65
81
  }
66
82
 
67
83
  /** Every hook command string in a settings file, or [] when it cannot be read or parsed. */
@@ -212,8 +228,23 @@ export function cmdMigrate(args = {}) {
212
228
  // passed the excluded rules as `null` (re-admitting every rule an owner switched off,
213
229
  // `process 5` among them) and dropped `--config-dir`, so a global found through
214
230
  // `CLAUDE_CONFIG_DIR`/`OPENCODE_CONFIG_DIR` re-emitted into the DEFAULT dir instead.
231
+ //
232
+ // A LEGACY CARRIER IS SKIPPED, NOT ROLLED BACK — the one exception to Step 4's ALL OR
233
+ // NOTHING, same reasoning as `rebuild-all` (`js/build/generate.mjs`): `installProfile` would
234
+ // read a pre-comms carrier exactly as written now (`comms` missing, `process 7`/`process 8`
235
+ // unaddressed) and `driverMain` would write that back PERMANENTLY — the fresh carrier carries
236
+ // no `process 8` text, so nothing could ever catch it again. Rolling back everything else on
237
+ // the machine over an install that merely needs `geneseed setup` would be a worse outcome
238
+ // than leaving it as it was found.
239
+ const skipped = [];
215
240
  for (const r of rows) {
216
241
  const label = `${r.host}:${r.scope} (${r.root})`;
242
+ if (legacyProcessCarrierOfDir(r.root, r.host)) {
243
+ skipped.push(label);
244
+ printOut(`[migrate] SKIPPED ${label}: pre-comms carrier (names 'process 8') — `
245
+ + 'run `geneseed setup` for it.\n');
246
+ continue;
247
+ }
217
248
  const { emit, theme, argv } = installProfile(r.host, r.scope, r.root,
218
249
  (n) => printErr(`[migrate] ${label}: ${n}\n`));
219
250
  printOut(`[migrate] re-emitting ${label}: theme=${theme} emit=${emit}\n`);
@@ -235,7 +266,13 @@ export function cmdMigrate(args = {}) {
235
266
  writeText(stampPath(), `${ROOT}\n`);
236
267
  } catch { /* an unwritable stamp costs a redundant re-emit next run, never correctness */ }
237
268
 
238
- printOut(`[migrate] migrated ${rows.length} install(s).\n`);
269
+ const migrated = rows.length - skipped.length;
270
+ if (skipped.length) {
271
+ printOut(`[migrate] migrated ${migrated} install(s), skipped ${skipped.length}: `
272
+ + `${skipped.join(', ')}.\n`);
273
+ } else {
274
+ printOut(`[migrate] migrated ${rows.length} install(s).\n`);
275
+ }
239
276
 
240
277
  // Step 5 — what could NOT be fixed automatically. Reported, never rewritten: nothing in
241
278
  // this repository has ever written an autostart entry, so migrate does not own the file.