@heretek-ai/epistemic-swarm 0.7.21 → 0.7.23

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 (104) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +1 -1
  3. package/bin/cli.js +3 -2
  4. package/extensions/pi/index.js +3 -2
  5. package/package.json +5 -1
  6. package/plugins/antigravity/plugin.json +1 -1
  7. package/plugins/factory/skills/factory/SKILL.md +1 -1
  8. package/plugins/opencode/analysis-bridge.js +566 -0
  9. package/plugins/opencode/config-io.js +598 -31
  10. package/plugins/opencode/dep-health.js +1039 -0
  11. package/plugins/opencode/hook-bus.js +434 -0
  12. package/plugins/opencode/index.js +252 -19
  13. package/plugins/opencode/tui.js +230 -17
  14. package/plugins/opencode/weight-baseline.json +16 -0
  15. package/plugins/opencode/weight-signals.js +769 -0
  16. package/plugins/research-cache/skills/research_cache/hasher.py +33 -8
  17. package/runner/__pycache__/__init__.cpython-311.pyc +0 -0
  18. package/runner/__pycache__/auctioneer.cpython-311.pyc +0 -0
  19. package/runner/__pycache__/auditor_engine.cpython-311.pyc +0 -0
  20. package/runner/__pycache__/claim_store.cpython-311.pyc +0 -0
  21. package/runner/__pycache__/claim_witness.cpython-311.pyc +0 -0
  22. package/runner/__pycache__/darkharvest_claims.cpython-311.pyc +0 -0
  23. package/runner/__pycache__/errors.cpython-311.pyc +0 -0
  24. package/runner/__pycache__/living_dossiers.cpython-311.pyc +0 -0
  25. package/runner/__pycache__/mcp_protocol.cpython-311.pyc +0 -0
  26. package/runner/__pycache__/mcp_server.cpython-311.pyc +0 -0
  27. package/runner/__pycache__/path_safety.cpython-311.pyc +0 -0
  28. package/runner/__pycache__/pcrb.cpython-311.pyc +0 -0
  29. package/runner/__pycache__/pcrb_verify.cpython-311.pyc +0 -0
  30. package/runner/__pycache__/preflight.cpython-311.pyc +0 -0
  31. package/runner/__pycache__/refinement.cpython-311.pyc +0 -0
  32. package/runner/__pycache__/research_swarm.cpython-311.pyc +0 -0
  33. package/runner/__pycache__/retrieval_log.cpython-311.pyc +0 -0
  34. package/runner/__pycache__/schema_validate.cpython-311.pyc +0 -0
  35. package/runner/__pycache__/schemas.cpython-311.pyc +0 -0
  36. package/runner/__pycache__/state_machine.cpython-311.pyc +0 -0
  37. package/runner/mcp_server.py +249 -81
  38. package/runner/refinement.py +5 -0
  39. package/runner/schema_validate.py +63 -1
  40. package/runner/schemas.py +9 -4
  41. package/runner/tests/__pycache__/test_analysis_bridge.cpython-311.pyc +0 -0
  42. package/runner/tests/__pycache__/test_antigravity_plugin.cpython-311.pyc +0 -0
  43. package/runner/tests/__pycache__/test_auction_order.cpython-311.pyc +0 -0
  44. package/runner/tests/__pycache__/test_backends.cpython-311.pyc +0 -0
  45. package/runner/tests/__pycache__/test_bet1_spike.cpython-311.pyc +0 -0
  46. package/runner/tests/__pycache__/test_claim_store.cpython-311.pyc +0 -0
  47. package/runner/tests/__pycache__/test_claim_witness.cpython-311.pyc +0 -0
  48. package/runner/tests/__pycache__/test_claude_plugin.cpython-311.pyc +0 -0
  49. package/runner/tests/__pycache__/test_config_schema.cpython-311.pyc +0 -0
  50. package/runner/tests/__pycache__/test_contract_tooling.cpython-311.pyc +0 -0
  51. package/runner/tests/__pycache__/test_darkharvest_audit.cpython-311.pyc +0 -0
  52. package/runner/tests/__pycache__/test_dep_health.cpython-311.pyc +0 -0
  53. package/runner/tests/__pycache__/test_domain_packs.cpython-311.pyc +0 -0
  54. package/runner/tests/__pycache__/test_dossier_contract.cpython-311.pyc +0 -0
  55. package/runner/tests/__pycache__/test_factory.cpython-311.pyc +0 -0
  56. package/runner/tests/__pycache__/test_fleet_seam.cpython-311.pyc +0 -0
  57. package/runner/tests/__pycache__/test_frontier_concurrency.cpython-311.pyc +0 -0
  58. package/runner/tests/__pycache__/test_frontier_mirror_standalone.cpython-311.pyc +0 -0
  59. package/runner/tests/__pycache__/test_grounding.cpython-311.pyc +0 -0
  60. package/runner/tests/__pycache__/test_hardening_contract.cpython-311.pyc +0 -0
  61. package/runner/tests/__pycache__/test_hook_bus.cpython-311.pyc +0 -0
  62. package/runner/tests/__pycache__/test_living_dossiers.cpython-311.pyc +0 -0
  63. package/runner/tests/__pycache__/test_manifest_concurrency.cpython-311.pyc +0 -0
  64. package/runner/tests/__pycache__/test_mcp_server.cpython-311.pyc +0 -0
  65. package/runner/tests/__pycache__/test_opencode_ux.cpython-311.pyc +0 -0
  66. package/runner/tests/__pycache__/test_opencode_v2_registrars.cpython-311.pyc +0 -0
  67. package/runner/tests/__pycache__/test_pcrb.cpython-311.pyc +0 -0
  68. package/runner/tests/__pycache__/test_phase08_waiver_reopens.cpython-311.pyc +0 -0
  69. package/runner/tests/__pycache__/test_refinement.cpython-311.pyc +0 -0
  70. package/runner/tests/__pycache__/test_search_anomaly.cpython-311.pyc +0 -0
  71. package/runner/tests/__pycache__/test_search_provider.cpython-311.pyc +0 -0
  72. package/runner/tests/__pycache__/test_search_provider_plugin.cpython-311.pyc +0 -0
  73. package/runner/tests/__pycache__/test_swarm.cpython-311.pyc +0 -0
  74. package/runner/tests/__pycache__/test_swarm_config.cpython-311.pyc +0 -0
  75. package/runner/tests/__pycache__/test_sweep_regressions.cpython-311.pyc +0 -0
  76. package/runner/tests/__pycache__/test_webcache.cpython-311.pyc +0 -0
  77. package/runner/tests/__pycache__/test_weight_signals.cpython-311.pyc +0 -0
  78. package/runner/tests/fixtures/config_validation_cases.json +60 -0
  79. package/runner/tests/test_analysis_bridge.py +356 -0
  80. package/runner/tests/test_config_schema.py +119 -15
  81. package/runner/tests/test_dep_health.py +484 -0
  82. package/runner/tests/test_hardening_contract.py +391 -0
  83. package/runner/tests/test_hook_bus.py +802 -0
  84. package/runner/tests/test_opencode_ux.py +232 -0
  85. package/runner/tests/test_opencode_v2_registrars.py +548 -16
  86. package/runner/tests/test_phase08_waiver_reopens.py +539 -0
  87. package/runner/tests/test_weight_signals.py +361 -0
  88. package/schemas/config.schema.json +4 -3
  89. package/scripts/__pycache__/bet1_advisory_spike.cpython-311.pyc +0 -0
  90. package/scripts/__pycache__/build_adapters.cpython-311.pyc +0 -0
  91. package/scripts/__pycache__/check_docs.cpython-311.pyc +0 -0
  92. package/scripts/__pycache__/divergence_experiment.cpython-311.pyc +0 -0
  93. package/scripts/__pycache__/gen_schemas.cpython-311.pyc +0 -0
  94. package/skills/epistemic_search/scripts/__pycache__/search.cpython-311.pyc +0 -0
  95. package/skills/factory/SKILL.md +1 -1
  96. package/skills/grilling/__pycache__/__init__.cpython-311.pyc +0 -0
  97. package/skills/grilling/__pycache__/socratic_tree.cpython-311.pyc +0 -0
  98. package/skills/research_cache/__pycache__/__init__.cpython-311.pyc +0 -0
  99. package/skills/research_cache/__pycache__/hasher.cpython-311.pyc +0 -0
  100. package/skills/research_cache/hasher.py +33 -8
  101. package/skills/swarm_config/SKILL.md +6 -1
  102. package/skills/swarm_config/__pycache__/__init__.cpython-311.pyc +0 -0
  103. package/skills/swarm_config/__pycache__/configure.cpython-311.pyc +0 -0
  104. package/skills/swarm_config/configure.py +254 -17
@@ -34,6 +34,52 @@ import {
34
34
  resolveExpectedHash,
35
35
  validateConfig,
36
36
  } from './config-io.js';
37
+ import {
38
+ createHookBus,
39
+ gatePreCommit,
40
+ registerHookBus,
41
+ } from './hook-bus.js';
42
+ import {
43
+ registerAnalysisBridge,
44
+ } from './analysis-bridge.js';
45
+ import {
46
+ registerDepHealth,
47
+ } from './dep-health.js';
48
+ import {
49
+ registerWeightSignals,
50
+ } from './weight-signals.js';
51
+
52
+ /**
53
+ * Shared hook bus (Phase 01-hook-bus-spec). Six blockable events with tiered
54
+ * fast-short/slow-long enforcement + audit log; fail-open, never throws.
55
+ *
56
+ * Phase evidence: transform-shaped registrations + the `tool.execute.before`
57
+ * gap [VERIFIED: sha256:52ac5b4d26062cfc6093440faa415ab152f8acd0d18c4496eb0087ed18a27d76
58
+ * `file:///home/john/Projects/IUMBTEMS/plugins/opencode/index.js`:1038-1126];
59
+ * setup adopt pattern [VERIFIED: same hash, `:2714-2750`].
60
+ *
61
+ * Per-event enforcement points (fix-round docs; see docs/HOOK_BUS_PARITY.md):
62
+ * - `pre-tool-use` / `post-tool-use`: ENFORCED in `registerHostTools`
63
+ * `execute` for this plugin's OWN tools — a fast/slow tier-table deny
64
+ * blocks execution (pre) or suppresses delivery (post). Host-NATIVE tools
65
+ * (webfetch/websearch) have no `tool.execute.before` point, so they are
66
+ * NOT gated — same gap class as the upstream ask, documented not implied.
67
+ * - `pre-commit`: ENFORCED on both write legs — the settings-RPC `set`
68
+ * (`createSettingsHandlers().set`) and the TUI direct-fs fallback
69
+ * (`gateDirectFsPreCommit` in `tui.js`). Stale-write guards stay downstream.
70
+ * - `session-start`: ADVISORY BY DESIGN — emitted in the compaction hook, a
71
+ * deny is audit-logged but never drops the state push. Never counted as a
72
+ * deny-blocks host path.
73
+ * - `stop` / `notification`: BUS-LEVEL ONLY — no host interception point
74
+ * exists yet, so deny-blocks holds at `bus.emit` (direct subscribers) and
75
+ * no host action is gated. No claim here implies host blocking.
76
+ *
77
+ * Caller-latency contract (GOAL §5/§6): `emit` runs tiers concurrently with
78
+ * each handler bounded by its tier budget, so a call resolves within
79
+ * max(fastBudget, slowBudget) + epsilon — a hung slow handler costs the
80
+ * caller at most the slow budget (default 5000 ms), never indefinitely.
81
+ */
82
+ export const hookBus = createHookBus();
37
83
 
38
84
  const __filename = fileURLToPath(import.meta.url);
39
85
  const __dirname = path.dirname(__filename);
@@ -152,13 +198,13 @@ export const TOOL_CATALOG = [
152
198
  show: {
153
199
  type: 'boolean',
154
200
  description:
155
- 'Inspect only; equivalent to calling with no update keys (reads are always safe).',
201
+ 'Inspect only; equivalent to calling with no update keys (reads are always safe). When true, the call is read-only even if update keys are present (they are reported as ignored and nothing is written; the expected_hash guard, including the dual-spelling conflict check, is not evaluated).',
156
202
  default: false,
157
203
  },
158
204
  expected_hash: {
159
205
  type: 'string',
160
206
  description:
161
- 'Optimistic-concurrency guard: SHA-256 (or a unique prefix of at least 8 hex chars) of the config file you read. A mismatch returns a structured stale error with a fresh snapshot and writes nothing. Alias: `expectedHash`; if both spellings are present they must be equal or the call is rejected.',
207
+ 'Optimistic-concurrency guard: SHA-256 (or a unique prefix of at least 8 hex chars) of the config file you read. A mismatch returns a structured stale error with a fresh snapshot and writes nothing. Empty/whitespace-only spellings count as absent. Alias: `expectedHash`; if both spellings are present they must be equal after normalization (strip + lowercase) or the call is rejected.',
162
208
  },
163
209
  expectedHash: {
164
210
  type: 'string',
@@ -207,8 +253,8 @@ export const TOOL_CATALOG = [
207
253
  },
208
254
  searxng_url: {
209
255
  type: ['string', 'null'],
210
- pattern: '^https?://',
211
- description: 'Base URL of a self-hosted SearXNG, exported as SEARXNG_URL when set; must include an http:// or https:// scheme; null leaves the environment untouched.',
256
+ pattern: '^https?://[^\\s/]+\\S*$',
257
+ description: 'Base URL of a self-hosted SearXNG, exported as SEARXNG_URL when set; must include an http:// or https:// scheme and a host, with no whitespace anywhere; null leaves the environment untouched.',
212
258
  },
213
259
  license_whitelist: {
214
260
  type: 'array',
@@ -227,7 +273,8 @@ export const TOOL_CATALOG = [
227
273
  domain_pack: {
228
274
  type: ['string', 'null'],
229
275
  minLength: 1,
230
- description: 'Regulated Domain Pack id (config/domain_packs/) or null for the legacy constitution; the empty string is rejected.',
276
+ pattern: '^\\S(.*\\S)?$',
277
+ description: 'Regulated Domain Pack id (config/domain_packs/) or null for the legacy constitution; the empty string, whitespace-only strings, and ids with leading/trailing whitespace are rejected.',
231
278
  },
232
279
  verify: {
233
280
  type: 'object',
@@ -440,13 +487,18 @@ export const TOOL_CATALOG = [
440
487
  {
441
488
  name: 'iumbtems_factory',
442
489
  description:
443
- 'Drive factory run state: init / phase-add / qa-record / expansion / stop. State goes to <project>/.factory and <project>/.roadmap; no helper-script path needed.',
490
+ 'Drive factory run state: init / phase-add / qa-record / expansion / stop / gate. State goes to <project>/.factory and <project>/.roadmap; no helper-script path needed.',
444
491
  input: {
445
492
  type: 'object',
446
493
  properties: {
447
494
  command: {
448
495
  type: 'string',
449
- enum: ['init', 'phase-add', 'qa-record', 'expansion', 'stop'],
496
+ enum: ['init', 'phase-add', 'qa-record', 'expansion', 'stop', 'gate'],
497
+ },
498
+ action: {
499
+ type: 'string',
500
+ enum: ['open', 'settle', 'approve', 'waive', 'escalate', 'count'],
501
+ description: 'Gate action (with command: gate)',
450
502
  },
451
503
  run: { type: 'string', description: 'Factory run name' },
452
504
  phase: { type: 'string', description: 'Phase id (e.g. 01-auth)' },
@@ -1132,11 +1184,55 @@ async function registerHostTools(host) {
1132
1184
  input: spec.input,
1133
1185
  options: { codemode: false },
1134
1186
  execute: async (args = {}, toolContext = undefined) => {
1187
+ const normArgs = normalizeArgs(name, args);
1188
+ const cwd = toolContext?.cwd || hostRoot;
1189
+ // Phase 01-hook-bus-spec (fix round): `pre-tool-use` gates this
1190
+ // plugin's OWN tool executions. A tier-table deny BLOCKS the
1191
+ // `callMcp` dispatch (structured `{content}` denial, same shape as
1192
+ // a result, so the host contract is unchanged); bus faults fail
1193
+ // open. Host-NATIVE tools are NOT gated here — there is no
1194
+ // `tool.execute.before` point (see the accepted-asymmetry note
1195
+ // above); that gap is documented in docs/HOOK_BUS_PARITY.md, never
1196
+ // implied away.
1197
+ let pre = { allowed: true };
1198
+ try {
1199
+ const gated = await hookBus.emit('pre-tool-use', {
1200
+ tool: name,
1201
+ args: normArgs,
1202
+ sessionID: toolContext?.sessionID ?? null,
1203
+ eventId: toolContext?.eventId ?? null,
1204
+ });
1205
+ if (gated && gated.allowed === false) pre = { allowed: false };
1206
+ } catch {
1207
+ pre = { allowed: true };
1208
+ }
1209
+ if (pre.allowed === false) {
1210
+ return { content: `Tool execution denied by pre-tool-use hook (tool: ${name}).` };
1211
+ }
1135
1212
  const r = await callMcp(
1136
1213
  name,
1137
- normalizeArgs(name, args),
1138
- toolContext?.cwd || hostRoot
1214
+ normArgs,
1215
+ cwd
1139
1216
  );
1217
+ // `post-tool-use`: the call already ran, so a tier-table deny
1218
+ // blocks DELIVERY — the real result is suppressed and a structured
1219
+ // `{content}` notice is returned instead (audit-logged either way).
1220
+ let post = { allowed: true };
1221
+ try {
1222
+ const gated = await hookBus.emit('post-tool-use', {
1223
+ tool: name,
1224
+ args: normArgs,
1225
+ result: (r && r.content) ?? null,
1226
+ sessionID: toolContext?.sessionID ?? null,
1227
+ eventId: toolContext?.eventId ?? null,
1228
+ });
1229
+ if (gated && gated.allowed === false) post = { allowed: false };
1230
+ } catch {
1231
+ post = { allowed: true };
1232
+ }
1233
+ if (post.allowed === false) {
1234
+ return { content: `Tool result suppressed by post-tool-use hook (tool: ${name}).` };
1235
+ }
1140
1236
  return { content: r.content };
1141
1237
  },
1142
1238
  });
@@ -1750,6 +1846,16 @@ async function registerCompactionHook(host, context) {
1750
1846
  const root =
1751
1847
  event?.directory || host.location?.directory || context.location?.directory || event?.cwd;
1752
1848
  if (!root) return;
1849
+ // Phase 01-hook-bus-spec: `session-start(compaction)` restores
1850
+ // `.research` state path. The bus verdict is advisory here — a deny
1851
+ // must not drop the compaction context push (fail-open); the emit is
1852
+ // still audit-logged on the shared bus.
1853
+ // [VERIFIED: sha256:52ac5b4d26062cfc6093440faa415ab152f8acd0d18c4496eb0087ed18a27d76 `:1750-1775`]
1854
+ try {
1855
+ await hookBus.emit('session-start', { root, eventId: 'compaction' });
1856
+ } catch {
1857
+ /* bus faults fail open; the state push below still runs */
1858
+ }
1753
1859
  const text = buildCompactionContext(root);
1754
1860
  if (!text) return;
1755
1861
  const system = Array.isArray(event?.system) ? event.system : null;
@@ -2227,13 +2333,16 @@ function rpcFail(context, type, message, data) {
2227
2333
  const SETTINGS_CONTROL_ARGS = new Set(['base_dir', 'dir', 'show', 'expected_hash', 'expectedHash']);
2228
2334
 
2229
2335
  /**
2230
- * Leak signatures that must never reach the RPC transport (phase-03 R7/R9):
2231
- * tracebacks, `File "…", line N` frames, interpreter failure lines, and
2232
- * absolute filesystem paths of ANY root — POSIX (e.g. `/workspace/…`,
2233
- * `/srv/…`, `/Users/…`, `/app/…`), Windows drive (`C:\Users\…`), and UNC
2234
- * (`\\server\share`). Structured validation problems (e.g. `mode: value … not
2235
- * in enum …`, type/range violations carrying ordinary values) match none of
2236
- * these and pass through intact.
2336
+ * Leak signatures that must never reach the RPC transport (phase-03 R7/R9,
2337
+ * phase-08 R1): tracebacks, `File "…", line N` frames, interpreter failure
2338
+ * lines, `file://` URIs, and absolute filesystem paths of ANY root — POSIX
2339
+ * multi-segment (e.g. `/workspace/…`, `/srv/…`, `/Users/…`, `/app/…`),
2340
+ * single-segment system roots (`/etc`, `/tmp`, `/lib`, `/lib64`, `/boot`,
2341
+ * `/snap`, macOS `/System`, `/Library`, `/Volumes`, `/Applications`, …),
2342
+ * Windows drive
2343
+ * (`C:\Users\…`), and UNC (`\\server\share`). Structured validation problems
2344
+ * (e.g. `mode: value … not in enum …`, type/range violations carrying
2345
+ * ordinary values) match none of these and pass through intact.
2237
2346
  */
2238
2347
  const SETTINGS_ERROR_LEAK_PATTERNS = [
2239
2348
  // Tracebacks and Python/Node interpreter failure lines.
@@ -2241,7 +2350,39 @@ const SETTINGS_ERROR_LEAK_PATTERNS = [
2241
2350
  /File\s+["'][^"']+["']\s*,\s*line\s+\d+/i,
2242
2351
  /\b(?:ModuleNotFoundError|ImportError|SyntaxError|NameError|OSError|Errno \d+)\b/,
2243
2352
  // Absolute POSIX path of any root: a bounded leading slash + >=2 segments.
2244
- /(?:^|[\s"'(=:,])\/(?:[A-Za-z0-9._-]+\/)+[A-Za-z0-9._-]+/,
2353
+ // Phase-08 R1 (W9) REWORK-2 L2: prefix class gains `[`, `{`, `-` so
2354
+ // bracket/brace/dash-delimited absolutes (`[/etc]`, `{/etc}`, `-/etc`,
2355
+ // `x-/lib64,y`) redact. Canonical values contain no `/`-root tokens
2356
+ // (proven by the canonical-value corpus passing verbatim), so `-`-prefixed
2357
+ // path tokens are disclosures, not user echoes.
2358
+ /(?:^|[\s"'(=:,\[{-])\/(?:[A-Za-z0-9._-]+\/)+[A-Za-z0-9._-]+/,
2359
+ // Phase-08 R1 (W9) REWORK-1: single-segment system-root absolutes
2360
+ // (`/etc`, `/tmp`, plus `/lib`, `/lib64`, `/boot`, `/snap`, macOS `/System`,
2361
+ // `/Library`, `/Volumes`, `/Applications`).
2362
+ // The >=2-segment arm above lets these through, but a bare system root in
2363
+ // a failure item is a local-filesystem disclosure (interpreter stderr
2364
+ // echoes absolute paths of any depth). The root allowlist keeps ordinary
2365
+ // user echoes intact: single-segment non-system values (`/v1`, `/nope`)
2366
+ // still pass through, and sanitization stays per-item (R9).
2367
+ // RESIDUAL (documented): truly exotic single-segment roots outside the
2368
+ // mainstream Linux + macOS set (e.g. `/net`, `/nfs`, future mount points)
2369
+ // still pass through by design to avoid over-redacting user echoes.
2370
+ // TRIGGER (consumed for this class): qa-b repro `["error at /lib nope"]`
2371
+ // passthrough + inspection-named `/lib64`, `/boot`, `/snap`, `/System`,
2372
+ // `/Library`, `/Volumes`, `/Applications` are now covered. Revisit only
2373
+ // with a NEW real leak repro naming the still-uncovered root.
2374
+ // Phase-08 R1 (W9) REWORK-2 L1: suffix lookahead gains `.!?]}` so
2375
+ // bare-root + sentence punctuation (`/lib.`, `/etc.`, `/lib!`, `/tmp?`,
2376
+ // `/lib]`, `/lib}`) redacts. The >=2-segment arm is untouched (its trailing
2377
+ // `[A-Za-z0-9._-]+` already consumes `/etc/foo.`). L2: prefix class gains
2378
+ // `[`, `{`, `-` (same `-` safety proof as above) so `[/etc]`, `[/lib]`,
2379
+ // `[/lib64]`, `{/etc}`, `-/etc`, `x-/lib64,y` redact.
2380
+ /(?:^|[\s"'(=:,\[{-])\/(?:etc|var|tmp|srv|app|home|root|usr|opt|bin|sbin|proc|sys|dev|run|mnt|media|private|Users|workspace|lib64|lib|boot|snap|System|Library|Volumes|Applications)(?=\/|$|[\s"'<>|,;:().!?\]}])/,
2381
+ // Phase-08 R1 (W9): `file://` URIs always disclose local filesystem
2382
+ // layout, and no canonical config value is a file:// URI (`searxng_url`
2383
+ // requires an http(s) scheme) — so a file:// URI in a failure item is
2384
+ // never a legitimate user echo. Fail closed.
2385
+ /file:\/\/[^\s"'<>|]+/i,
2245
2386
  // Windows drive-letter absolute path (`C:\Users\…`) or UNC share (`\\srv\share`).
2246
2387
  /\b[A-Za-z]:[\\/](?:[^\\/\s"'<>|]+[\\/])*[^\\/\s"'<>|]+/,
2247
2388
  /\\\\[^\\/\s"'<>|]+[\\/][^\\/\s"'<>|]+/,
@@ -2289,6 +2430,11 @@ export function createSettingsHandlers(opts = {}) {
2289
2430
  const storage = opts.storage || null;
2290
2431
  const emitChanged = opts.emitChanged || null;
2291
2432
  const reloadMcp = opts.reloadMcp || null;
2433
+ // Phase 01-hook-bus-spec: `pre-commit` gates config writes via this
2434
+ // settings-RPC path. Defaults to the shared bus; stale-write guards below
2435
+ // are preserved (a bus allow still runs the expected-hash MCP write, which
2436
+ // enforces staleness).
2437
+ const settingsBus = opts.hookBus || hookBus;
2292
2438
 
2293
2439
  return {
2294
2440
  get: async () => {
@@ -2377,6 +2523,29 @@ export function createSettingsHandlers(opts = {}) {
2377
2523
  }
2378
2524
  const args = { ...sanitized, base_dir: researchDir };
2379
2525
  if (expected !== null) args.expected_hash = expected;
2526
+ // Phase 01-hook-bus-spec `pre-commit` gate: a tier-table deny blocks the
2527
+ // write (structured `invalid`, nothing written); bus faults fail open.
2528
+ // The expected-hash stale guard below still runs on every allow, so
2529
+ // stale-write preservation is unchanged. NOTE: the deny-block `rpcFail`
2530
+ // stays OUTSIDE the fail-open try — `rpcFail` throws when the host
2531
+ // supplies no `context.error`, and catching our own block would
2532
+ // silently fail open past a deny.
2533
+ let gate = null;
2534
+ try {
2535
+ gate = await gatePreCommit(settingsBus, {
2536
+ updates: sanitized,
2537
+ root,
2538
+ ...(expected !== null ? { expected_hash: expected } : {}),
2539
+ });
2540
+ } catch {
2541
+ gate = { allowed: true };
2542
+ }
2543
+ if (gate && gate.allowed === false) {
2544
+ return rpcFail(context, 'invalid', 'Config write denied by pre-commit hook.', {
2545
+ written: false,
2546
+ errors: ['pre-commit hook denied the write'],
2547
+ });
2548
+ }
2380
2549
  const res = await callMcpImpl('iumbtems_config', args, root);
2381
2550
  const payload = parseMcpContent(res);
2382
2551
  if (payload && payload.status === 'stale') {
@@ -2461,6 +2630,7 @@ export async function registerSettingsRpc(host, opts = {}) {
2461
2630
  storage,
2462
2631
  emitChanged,
2463
2632
  reloadMcp,
2633
+ hookBus: opts.hookBus || hookBus,
2464
2634
  });
2465
2635
  registration = await register.call(host.rpc, SettingsRpc, handlers);
2466
2636
  if (!registration) return null;
@@ -2716,6 +2886,52 @@ export function createOpenCodePlugin(context = {}) {
2716
2886
  } catch (err) {
2717
2887
  log(host, 'error', 'registering temperature hook failed', errDetail(err));
2718
2888
  }
2889
+ // Phase 01-hook-bus-spec: adopt the six-event hook bus alongside the
2890
+ // existing registrations (same adopt/best-effort pattern). The bus
2891
+ // registrar returns a single registration (or null), so wrap it for
2892
+ // `adopt`, which takes a list. Never throws.
2893
+ try {
2894
+ adopt([await registerHookBus(host, { bus: hookBus })]);
2895
+ } catch (err) {
2896
+ log(host, 'error', 'registering hook bus failed', errDetail(err));
2897
+ }
2898
+ // Phase 02-lsp-bridge: adopt the first bus consumer (CLI-based JS/TS
2899
+ // analysis, all-advisory) alongside the existing registrations (same
2900
+ // adopt/best-effort pattern). Analyzer-missing degrades to a silent
2901
+ // skip inside the bridge; this adoption never throws.
2902
+ try {
2903
+ adopt([await registerAnalysisBridge(host, { bus: hookBus })]);
2904
+ } catch (err) {
2905
+ log(host, 'error', 'registering analysis bridge failed', errDetail(err));
2906
+ }
2907
+ // Phase 03-dep-health-gate: adopt the second bus consumer
2908
+ // (outdated/vulnerable-dependency warnings, all-advisory + mechanical
2909
+ // QA coverage gate) alongside the existing registrations (same
2910
+ // adopt/best-effort pattern). Feed outages degrade to recorded
2911
+ // `unavailable` entries inside the module; this adoption never throws.
2912
+ try {
2913
+ adopt([await registerDepHealth(host, {
2914
+ bus: hookBus,
2915
+ // Host-reported project directory; the module falls back to the
2916
+ // process cwd when the host reports none (see checkDeps).
2917
+ root: host.location?.directory || undefined,
2918
+ })]);
2919
+ } catch (err) {
2920
+ log(host, 'error', 'registering dep-health failed', errDetail(err));
2921
+ }
2922
+ // Phase 04-coverage-treeshake-signals: adopt the third bus consumer
2923
+ // (coverage deltas + esbuild-measured bundle weight, all-advisory,
2924
+ // never-blocking) alongside the existing registrations (same
2925
+ // adopt/best-effort pattern). esbuild missing/failing degrades to a
2926
+ // recorded `unavailable` entry inside the module; this adoption never
2927
+ // throws.
2928
+ try {
2929
+ adopt([await registerWeightSignals(host, {
2930
+ bus: hookBus,
2931
+ })]);
2932
+ } catch (err) {
2933
+ log(host, 'error', 'registering weight-signals failed', errDetail(err));
2934
+ }
2719
2935
  // Slow work AFTER registration.
2720
2936
  try {
2721
2937
  await applyConfigOptions(host, opts, context);
@@ -2733,14 +2949,31 @@ export function createOpenCodePlugin(context = {}) {
2733
2949
  // `TypeError: de is not a function`, dropping every registration.
2734
2950
  return async () => {
2735
2951
  let disposed = 0;
2952
+ const pending = [];
2736
2953
  for (const registration of registrations) {
2737
2954
  try {
2738
- await registration.dispose();
2739
- disposed += 1;
2955
+ const done = registration.dispose();
2956
+ if (done && typeof done.then === 'function') {
2957
+ // Async teardown settles alongside the rest; a rejection logs
2958
+ // exactly like a synchronous throw below.
2959
+ pending.push(
2960
+ done.then(
2961
+ () => { disposed += 1; },
2962
+ (err) => log(host, 'warn', 'registration.dispose() failed', errDetail(err)),
2963
+ ),
2964
+ );
2965
+ } else {
2966
+ disposed += 1;
2967
+ }
2740
2968
  } catch (err) {
2741
2969
  log(host, 'warn', 'registration.dispose() failed', errDetail(err));
2742
2970
  }
2743
2971
  }
2972
+ // Await only when at least one disposal is asynchronous: awaiting an
2973
+ // already-settled promise still yields, which would defer the
2974
+ // synchronous nudge-stop loop below past a non-awaited cleanup() call
2975
+ // (pinned by test_setup_cleanup_aborts_nudge_streams).
2976
+ if (pending.length > 0) await Promise.all(pending);
2744
2977
  registrations.length = 0;
2745
2978
  let stopped = 0;
2746
2979
  for (const stop of setupCleanups) {