geneseed 3.20.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 (88) hide show
  1. package/CHANGELOG.md +35 -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/rules.md +1 -1
  18. package/docs/design-history.md +59 -0
  19. package/docs/extending.md +11 -3
  20. package/docs/guides/install-opencode.md +1 -1
  21. package/docs/guides/loop-running.md +1 -1
  22. package/docs/reference/env-context.md +3 -2
  23. package/docs/reference/environment.md +2 -1
  24. package/docs/reference/plugin-guard.md +3 -3
  25. package/docs/reference/troubleshoot-hooks.md +34 -0
  26. package/docs/understand/design-choices-engineering.md +29 -0
  27. package/docs/understand/design-choices-gates.md +23 -0
  28. package/docs/understand/design-choices-hosts.md +21 -0
  29. package/docs/understand/design-choices-install.md +19 -0
  30. package/docs/understand/design-choices-shape.md +21 -0
  31. package/docs/understand/design-choices.md +34 -0
  32. package/docs/understand/enforced-vs-asked.md +5 -4
  33. package/docs/understand/machine-bob.md +1 -1
  34. package/docs/understand/machine-opencode.md +2 -2
  35. package/harness.config.json +1 -1
  36. package/js/build/driver.mjs +42 -16
  37. package/js/build/emit-claude.mjs +24 -8
  38. package/js/build/emit-opencode.mjs +33 -15
  39. package/js/build/generate.mjs +38 -9
  40. package/js/build/source.mjs +39 -0
  41. package/js/cli-table.json +6 -6
  42. package/js/hosts/gitref.mjs +3 -3
  43. package/js/hosts/hooks-context.mjs +11 -30
  44. package/js/hosts/hooks-learn.mjs +89 -8
  45. package/js/hosts/hooks.mjs +212 -18
  46. package/js/hosts/hosts.mjs +217 -13
  47. package/js/hosts/installs.mjs +259 -46
  48. package/js/hosts/native.mjs +48 -12
  49. package/js/hosts/opencode.mjs +47 -17
  50. package/js/hosts/settings.mjs +366 -42
  51. package/js/hosts/shim.mjs +37 -0
  52. package/js/inspect/checks-authoring.mjs +81 -1
  53. package/js/inspect/checks-constitution.mjs +2 -1
  54. package/js/inspect/checks-repo.mjs +68 -4
  55. package/js/inspect/doctor.mjs +40 -9
  56. package/js/inspect/scan.mjs +5 -4
  57. package/js/inspect/status.mjs +62 -12
  58. package/js/lib/text.mjs +23 -0
  59. package/js/loop/catalog.mjs +7 -2
  60. package/js/maintain/migrate.mjs +42 -5
  61. package/js/maintain/uninstall.mjs +150 -38
  62. package/js/maintain/update.mjs +22 -2
  63. package/js/web/actions.mjs +28 -10
  64. package/js/web/api.mjs +5 -3
  65. package/js/web/state.mjs +28 -15
  66. package/package.json +1 -1
  67. package/src/agents/_template.md +1 -1
  68. package/src/bricks/_template.md +1 -1
  69. package/src/doctrines/ops.md +9 -7
  70. package/src/laws/universal.md +1 -1
  71. package/src/skills/_template.md +3 -3
  72. package/src/skills/herdr.md +1 -1
  73. package/web/dist/assets/{Activity-IbwdHHyx.js → Activity-BCtqxjfy.js} +1 -1
  74. package/web/dist/assets/{ActivityDetail-B5uivdWI.js → ActivityDetail-Ba3Oo4dE.js} +1 -1
  75. package/web/dist/assets/{Dashboard-v6WQ37bD.js → Dashboard-DpHza68-.js} +2 -2
  76. package/web/dist/assets/{Docs-CypIfDBX.js → Docs-Bw_-UK0O.js} +1 -1
  77. package/web/dist/assets/{GreenhouseView-ClxP0hQG.js → GreenhouseView-DnJ1Ggp1.js} +1 -1
  78. package/web/dist/assets/{Installs-CPkQeFjO.js → Installs-CWoNKFBk.js} +1 -1
  79. package/web/dist/assets/{JournalView-BeEInhUf.js → JournalView-g6-bw4c8.js} +1 -1
  80. package/web/dist/assets/{Laws-Ds6U5xBQ.js → Laws-DkVWR1tb.js} +1 -1
  81. package/web/dist/assets/{Library-BraUKPyQ.js → Library-CK9HZzjV.js} +1 -1
  82. package/web/dist/assets/{Loops-DWcy3mwK.js → Loops-BP98SMOH.js} +1 -1
  83. package/web/dist/assets/{OperatorHudView-CQyrJQ9m.js → OperatorHudView-DI_n12M9.js} +1 -1
  84. package/web/dist/assets/{Personal-YWqs6qFL.js → Personal-Bk89f13a.js} +1 -1
  85. package/web/dist/assets/{Tabs-EhzEC-Xh.js → Tabs-3WuYIE1b.js} +1 -1
  86. package/web/dist/assets/{drawable-CEKPNV7X.js → drawable-oCUeq9e5.js} +1 -1
  87. package/web/dist/assets/{index-CPi22b7y.js → index-CiXwChvf.js} +2 -2
  88. package/web/dist/index.html +1 -1
@@ -17,7 +17,7 @@
17
17
  import { realpathSync } from 'node:fs';
18
18
  import path from 'node:path';
19
19
  import os from 'node:os';
20
- import { printErr, withDiscardableStderr, isDir, readText } from '../lib/fs.mjs';
20
+ import { printErr, withDiscardableStderr, isDir, isFile, readText } from '../lib/fs.mjs';
21
21
  import { toPlatformPath, normcase } from '../lib/paths.mjs';
22
22
 
23
23
  /** `_build_global.GLOBAL_MANIFEST` — the file whose presence means "a global install". */
@@ -84,6 +84,174 @@ export function sovereignBypass(root) {
84
84
  return false;
85
85
  }
86
86
 
87
+ /**
88
+ * `_global_hook_standing_down` — project-bypasses-global, for `context`, `git-gate` and `learn`.
89
+ *
90
+ * A GLOBAL install's hook knows its own dir (`--root`, or `learn`'s `--memory` parent); when a
91
+ * Geneseed PROJECT install of the SAME host sits IN the session's project dir, that project's
92
+ * hook runs too,
93
+ * and Claude runs every matching hook with the strictest verdict winning — so the global's
94
+ * second injection, its process-5 ask over a project built `--no-consent`, and its second LLM
95
+ * call are all doubles (host-compat Claude B4). `$GENESEED_STACK_GLOBAL` stacks them on purpose.
96
+ *
97
+ * THE MARKER COMES FROM `host`, NOT THE FOLDER NAME. It once came from `basename(hookRoot)`,
98
+ * so a global moved by `$CLAUDE_CONFIG_DIR`/`$BOB_CONFIG_DIR`/`$OPENCLAUDE_CONFIG_DIR` matched
99
+ * no marker and never stood down (Claude B9, OpenClaude I2, Bob B4). With no `host` (Claude's
100
+ * commands, and any emitted before the gates carried one), a folder named like a marker is that
101
+ * marker, else the root's own `.geneseed-emit` names it; neither = never stand down.
102
+ *
103
+ * `<projectDir>/<marker>` ONLY — NO WALK UP. Claude reads a project's `.claude/settings.json`
104
+ * from the session's primary working directory (`settings.md`), not from its ancestors, so an
105
+ * install above the project dir proves no project gate fired (round 3 of the Task 11 review:
106
+ * `~/mono/.claude` silenced the global gate for a session in `~/mono/pkg`). One known
107
+ * consequence is DELIBERATE: outside Windows Claude may also read `settings.local.json` at the
108
+ * git repo root when started in a subdirectory, so that case asks twice. A doubled ask is
109
+ * safe; a silenced gate is not. Bob's own project-hook lookup is undocumented, so its cwd
110
+ * fallback does not walk either. `projectDir` is `hookProjectDir()`: the cwd follows the
111
+ * agent's `cd` into repos whose hooks were never loaded.
112
+ *
113
+ * ONLY A PROJECT INSTALL SILENCES, NEVER ANOTHER GLOBAL. With `CLAUDE_CONFIG_DIR=~/.claude-work`
114
+ * (the docs' multi-account example) a session in `~` finds the OTHER global `~/.claude`, whose
115
+ * hooks this session never loads. `isHostGlobalDir` (below) decides, the same predicate the
116
+ * lifecycle verbs use: a candidate that is any host's global is never a project install.
117
+ * * M-3, DELIBERATE: a hand-written `-global` marker in a PROJECT's `.claude/` therefore reads
118
+ * as a global, and the gate asks twice. Never "fix" that into silence — it would turn a
119
+ * user-editable file into a switch that turns a gate off.
120
+ * * M-2: a `configDir()` that throws (a `~user` relocation variable `resolvePath` refuses)
121
+ * lands in the catch below, which is false: the gate stays loud.
122
+ *
123
+ * ONLY A LIVE, WIRED PROJECT INSTALL SILENCES (final review C1). A manifest alone proves no
124
+ * project gate fires: `deactivate` unwires every hook and keeps the manifest, and a project
125
+ * emit beside a commented `settings.local.json` writes a manifest but refuses to wire. So the
126
+ * candidate must carry no `.geneseed-disabled/<host>` stash (the test `claudeState` uses) and
127
+ * its manifest's `managed.settings_hooks` must record a group running `verb`, still present in
128
+ * the settings file the manifest names (`projectWiresVerb`). A manifest or settings file that
129
+ * does not parse, or carries no such group, is false: the gate stays loud.
130
+ *
131
+ * LIVES HERE, beside `sovereignBypass`, for the same reason: the gates must not import
132
+ * `hooks-context.mjs`. Any failure (a `~user` root `resolvePath` refuses) is false: a gate that
133
+ * cannot tell keeps gating.
134
+ */
135
+ export function globalHookStandingDown(hookRoot, projectDir, host, verb) {
136
+ if (!hookRoot || process.env.GENESEED_STACK_GLOBAL) return false;
137
+ const markers = STAND_DOWN_MARKERS;
138
+ const own = path.basename(hookRoot);
139
+ try {
140
+ return withDiscardableStderr(() => {
141
+ let marker = markers[host] || (Object.values(markers).includes(own) ? own : null);
142
+ if (!marker) {
143
+ // A relocated root with no `--host`: its own `.geneseed-emit` (`claude-global`, …) names
144
+ // the host. Absent or unknown = false — guessing Claude would silence a pre-`--host`
145
+ // OpenClaude gate beside a project `.claude`, and OpenClaude loads no `.claude` hooks.
146
+ const emit = emitMarkerOf(hookRoot);
147
+ marker = emit.endsWith('-global') ? markers[emit.slice(0, -'-global'.length)] : null;
148
+ if (!marker) return false;
149
+ }
150
+ const markerHost = Object.keys(markers).find((h) => markers[h] === marker);
151
+ const cand = path.join(resolvePath(projectDir), marker);
152
+ // Path equality, case-folded on Windows — `~/.claude` and `~/.Claude` are the same
153
+ // install there and two different ones on Linux.
154
+ if (normcase(resolvePath(cand)) === normcase(resolvePath(hookRoot))) return false;
155
+ if (isHostGlobalDir(null, cand)) return false;
156
+ if (isDir(path.join(cand, DISABLED_STASH, markerHost))) return false;
157
+ return projectWiresVerb(cand, verb);
158
+ });
159
+ } catch {
160
+ return false;
161
+ }
162
+ }
163
+
164
+ /**
165
+ * The hook handlers the install at `cfg` both RECORDS and still RUNS: `[event, handler]` pairs
166
+ * from its manifest's `managed.settings_hooks` whose command the settings file that manifest
167
+ * names still carries under the same event — the manifest is a claim, the settings file is what
168
+ * the host runs. A user who deletes `hooks` by hand keeps the manifest (re-review, Important).
169
+ * Absent, unparseable or COMMENTED settings fail `JSON.parse` and answer `[]`. Shared by
170
+ * `projectWiresVerb` and the hook-shell drift check (`hookShellProblems`).
171
+ */
172
+ export function liveRecordedHooks(cfg) {
173
+ const readJson = (p) => { try { return JSON.parse(readText(p)); } catch { return null; } };
174
+ const managed = readJson(path.join(cfg, GLOBAL_MANIFEST))?.managed;
175
+ const recorded = Array.isArray(managed?.settings_hooks) ? managed.settings_hooks : [];
176
+ const wanted = recorded.flatMap((r) => (Array.isArray(r?.group?.hooks) ? r.group.hooks : [])
177
+ .filter((h) => typeof h?.command === 'string').map((h) => [r.event, h]));
178
+ if (!wanted.length) return [];
179
+ const hooks = readJson(settingsFile(cfg, managed))?.hooks;
180
+ const live = (event, command) => Array.isArray(hooks?.[event]) && hooks[event]
181
+ .some((g) => Array.isArray(g?.hooks) && g.hooks.some((h) => h?.command === command));
182
+ return wanted.filter(([event, h]) => live(event, h.command));
183
+ }
184
+
185
+ /**
186
+ * Does the project install at `cand` really run `verb`? A live recorded handler
187
+ * (`liveRecordedHooks`) must run it; what cannot be verified does not silence a gate.
188
+ */
189
+ function projectWiresVerb(cand, verb) {
190
+ // Every emitted command is `<runner> <verb> --root|--memory …` (`claudeHookGroups`), behind a
191
+ // leading `& ` in the PowerShell form.
192
+ return liveRecordedHooks(cand).some(([, h]) => h.command.includes(` ${verb} --`));
193
+ }
194
+
195
+ /**
196
+ * `_harness_mcp._settings_file` — the file this install's hooks were actually wired into.
197
+ *
198
+ * `settings.local.json` for a Claude or OpenClaude PROJECT install (personal, untracked),
199
+ * `settings.json` everywhere else (including Bob's, which documents no local variant), and
200
+ * the manifest is the authority — this reads `managed.settings_file`, it does not re-derive
201
+ * the rule. Every lifecycle path must target the file the EMIT wrote, or the hooks linger in
202
+ * one file while the claims chase another.
203
+ *
204
+ * EXPORTED for `migrate` (host-compat B1 round, `js/maintain/migrate.mjs`'s `hookSettingsFile`):
205
+ * reading a pre-migration install's CURRENT wiring is the same question this answers, and a
206
+ * fresh install with no `managed.settings_file` yet recorded (no manifest, or a manifest from
207
+ * before this field existed) must fall back to the bare, pre-nesting `settings.json` — the
208
+ * shape a legacy `bob-global` install actually carries — not to wherever the NEXT emit would
209
+ * write. `claudeWire`'s own `get(old, 'settings_file') || 'settings.json'` (`emit-claude.mjs`)
210
+ * makes the same choice for the same reason.
211
+ */
212
+ export function settingsFile(cfg, managed) {
213
+ return path.join(cfg, (managed && managed.settings_file) || 'settings.json');
214
+ }
215
+
216
+ /** A dir's own `.geneseed-emit`, trimmed, or `''`. */
217
+ function emitMarkerOf(dir) {
218
+ try { return readText(path.join(dir, '.geneseed-emit')).trim(); } catch { return ''; }
219
+ }
220
+
221
+ /** `_harness_mcp.DISABLED_STASH` — a sibling dir whose presence means "disabled". */
222
+ export const DISABLED_STASH = '.geneseed-disabled';
223
+
224
+ /**
225
+ * Is `dir` a GLOBAL install of `host` (any host when `null`), never a project's marker dir?
226
+ *
227
+ * THE ONE ANSWER (final review C2), used by `installTargets`, `projectQualifies`,
228
+ * `uninstallResolve`, `themeOfDir` and `globalHookStandingDown`. Three ways to be one:
229
+ * * it is the env-resolved `configDir()`;
230
+ * * it is the DEFAULT `~/<projectMarker>` of a Claude-family host. Under
231
+ * `CLAUDE_CONFIG_DIR=~/.claude-work` the old `~/.claude` is still a global — comparing
232
+ * only against `configDir()` made `rebuild-all` re-emit it as a PROJECT rooted at `$HOME`;
233
+ * * its own `.geneseed-emit` names a `-global` emit (`<host>-global` when `host` is given).
234
+ * Case-folded on Windows. A `configDir()` that throws propagates: each caller already has the
235
+ * catch that says what "cannot tell" means for it (the hook stays loud).
236
+ */
237
+ export function isHostGlobalDir(host, dir) {
238
+ const emit = emitMarkerOf(dir);
239
+ if (host === null ? emit.endsWith('-global') : emit === `${host}-global`) return true;
240
+ const c = normcase(resolvePath(dir));
241
+ return HOSTS.some((h) => (host === null || h.host === host)
242
+ && (normcase(h.configDir()) === c || (h.family === 'claude'
243
+ && normcase(resolvePath(path.join(os.homedir(), h.projectMarker))) === c)));
244
+ }
245
+
246
+ /**
247
+ * The project root a hook judges against: `$CLAUDE_PROJECT_DIR` (Claude Code and OpenClaude set
248
+ * it for every hook — the session's root, where its project hooks were loaded from), else the
249
+ * cwd. Bob documents no such variable, so under Bob this is the cwd.
250
+ */
251
+ export function hookProjectDir() {
252
+ return process.env.CLAUDE_PROJECT_DIR || process.cwd();
253
+ }
254
+
87
255
  /**
88
256
  * `_build_core.VERSION_MARKER`. Third caller, so it moves here — `js/build/version.mjs` and
89
257
  * `js/inspect/diff.mjs` each had a private const of their own and `js/maintain/uninstall.mjs` would have made
@@ -163,29 +331,53 @@ export function resolvePath(p) {
163
331
  *
164
332
  * P3c's rule was "the child must never resolve this": a render child that did would write
165
333
  * 135 files into the developer's real `~/.config/opencode`. A driver is the PARENT, so the
166
- * rule inverts — resolving it there is exactly the job. Precedence is the env var (which
167
- * relocates the whole dir), then `$XDG_CONFIG_HOME/opencode`, then `~/.config/opencode`.
334
+ * rule inverts — resolving it there is exactly the job. Precedence is the env var, then
335
+ * `$XDG_CONFIG_HOME/opencode`, then `~/.config/opencode`.
336
+ *
337
+ * The env var does NOT relocate the whole dir upstream, it ADDS one: agents, commands,
338
+ * plugins, skills and `opencode.json` still load from the xdg dir as well, and only the global
339
+ * `AGENTS.md` and the skills home follow the env var alone (`Global.Service.config`). Emitting
340
+ * into the env-var dir is still right — it is the one both lists include — but an older
341
+ * install left in the xdg dir keeps loading beside it; `opencodeShadowedInstall` reports that.
168
342
  */
169
343
  export function opencodeConfigDir() {
170
344
  const env = process.env.OPENCODE_CONFIG_DIR;
171
345
  if (env) return resolvePath(env);
346
+ return opencodeXdgDir();
347
+ }
348
+
349
+ /** The dir OpenCode ALWAYS loads, `OPENCODE_CONFIG_DIR` or not (`Global.Path.config`). */
350
+ function opencodeXdgDir() {
172
351
  const xdg = process.env.XDG_CONFIG_HOME;
173
352
  const base = xdg ? expanduser(xdg) : path.join(os.homedir(), '.config');
174
353
  return resolvePath(path.join(base, 'opencode'));
175
354
  }
176
355
 
177
356
  /**
178
- * `_build_core._claude_config_dir` — `~/.claude`, and there is NO env branch BY DESIGN.
179
- *
180
- * Its three siblings all check a `*_CONFIG_DIR` variable first; this one does not, because
181
- * Claude Code documents none and inventing one here would make the two CLIs disagree about
182
- * where a global install lives. The absence is the specification, so it is asserted rather
183
- * than merely not implemented — `test_every_relocation_var_moves_its_global_target` carries
184
- * an INVERSE row for this host: setting `$CLAUDE_CONFIG_DIR` must NOT move the target.
185
- * Without that row the table could only say "no cell covers Claude", which reads the same
186
- * as an omission.
357
+ * The xdg dir when it holds a Geneseed install BESIDE the one at `$OPENCODE_CONFIG_DIR` —
358
+ * both load, so every plugin (learn, activity, notify, ponytail) runs twice — else null.
359
+ */
360
+ export function opencodeShadowedInstall() {
361
+ if (!process.env.OPENCODE_CONFIG_DIR) return null;
362
+ const env = opencodeConfigDir();
363
+ const xdg = opencodeXdgDir();
364
+ if (env === xdg) return null;
365
+ const both = [env, xdg].every((d) => isFile(path.join(d, GLOBAL_MANIFEST)));
366
+ return both ? xdg : null;
367
+ }
368
+
369
+ /**
370
+ * `_build_core._claude_config_dir` — `~/.claude`, relocatable via `$CLAUDE_CONFIG_DIR`.
371
+ *
372
+ * Claude Code's own documented variable (`env-vars.md`; `claude-directory.md`: "every
373
+ * `~/.claude` path … lives under that directory instead"), so it moves CLAUDE.md, skills,
374
+ * agents, rules and settings together. This once had NO env branch, on the false premise that
375
+ * Claude documents none — and a user who set it got a global install their Claude never read
376
+ * (host-compat Claude B9). `~/.claude.json` is NOT moved here: `mcpConfigFor` keeps its own rule.
187
377
  */
188
378
  export function claudeConfigDir() {
379
+ const env = process.env.CLAUDE_CONFIG_DIR;
380
+ if (env) return resolvePath(env);
189
381
  return resolvePath(path.join(os.homedir(), '.claude'));
190
382
  }
191
383
 
@@ -237,14 +429,26 @@ export function openclaudeConfigDir() {
237
429
  *
238
430
  * `catalog` — does the host list every skill and agent to the model by itself? See
239
431
  * `hostCatalogsNatively` below for what it decides and why it is split per kind.
432
+ *
433
+ * `carrierInLayer` joined for host-compat B1 (2026-10) — THE ONE SOURCE for whether a host's
434
+ * PROJECT carrier sits at `<repo>/<projectMarker>/<agentFile>` rather than
435
+ * `<repo>/<agentFile>`. Only OpenClaude has it: its root `CLAUDE.md` is deliberately left
436
+ * unwritten so a Claude Code install can share the repo. Two readers, both DERIVED rather than
437
+ * restated: `js/build/driver.mjs`'s `CLAUDE_SHAPED.openclaude.carrierInLayer` reads this
438
+ * column (not a second literal `true`), and `installs.mjs`'s `carriersFor` reads it too — if a
439
+ * future host also nests its carrier, this is the one place that has to say so.
240
440
  */
241
441
  export const HOSTS = [
242
442
  { host: 'opencode', family: 'opencode', configDir: opencodeConfigDir, projectMarker: '.opencode', agentFile: 'AGENT.md', catalog: { skills: true, agents: true } },
243
443
  { host: 'claude', family: 'claude', configDir: claudeConfigDir, projectMarker: '.claude', agentFile: 'CLAUDE.md', catalog: { skills: true, agents: true } },
244
444
  { host: 'bob', family: 'claude', configDir: bobConfigDir, projectMarker: '.bob', agentFile: 'AGENTS.md', catalog: { skills: true, agents: false } },
245
- { host: 'openclaude', family: 'claude', configDir: openclaudeConfigDir, projectMarker: '.openclaude', agentFile: 'CLAUDE.md', catalog: { skills: true, agents: true } },
445
+ { host: 'openclaude', family: 'claude', configDir: openclaudeConfigDir, projectMarker: '.openclaude', agentFile: 'CLAUDE.md', catalog: { skills: true, agents: true }, carrierInLayer: true },
246
446
  ];
247
447
 
448
+ /** Each Claude-family host's project marker, keyed by host — `globalHookStandingDown`'s table. */
449
+ const STAND_DOWN_MARKERS = Object.fromEntries(HOSTS.filter((h) => h.family === 'claude')
450
+ .map((h) => [h.host, h.projectMarker]));
451
+
248
452
  /** The Claude-STYLE hosts (`family: 'claude'`), in `HOSTS` order. Test with `.includes(host)`. */
249
453
  export const CLAUDE_STYLE = HOSTS.filter((h) => h.family === 'claude').map((h) => h.host);
250
454