@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
@@ -0,0 +1,434 @@
1
+ /**
2
+ * IUMBTEMS hook bus (Phase 01-hook-bus-spec): minimal six-event blockable
3
+ * event bus with tiered enforcement + audit log.
4
+ *
5
+ * Zero runtime dependencies; host-agnostic (no OpenCode host import) so the
6
+ * mock-host suites can drive it without a live TUI. Fail-open everywhere:
7
+ * bus-internal faults audit-log and allow — never throw into the host.
8
+ *
9
+ * Phase evidence (cited per programmer brief):
10
+ * - Transform-shaped registrations today: commands via
11
+ * `host.command.transform` [VERIFIED: sha256:52ac5b4d26062cfc6093440faa415ab152f8acd0d18c4496eb0087ed18a27d76
12
+ * `file:///home/john/Projects/IUMBTEMS/plugins/opencode/index.js`:1038-1117],
13
+ * tools via `host.tool.transform` [VERIFIED: same hash, `:1119-1158`].
14
+ * - Documented asymmetry: no `tool.execute.before` registration point, so host
15
+ * webfetch/websearch calls cannot be intercepted the way Claude Code's
16
+ * hooks/hooks.json does [VERIFIED: same hash, `:1121-1126`].
17
+ * - Session hooks are best-effort: compaction via `session.hook("compaction")`
18
+ * [VERIFIED: same hash, `:1750-1775`], per-role temperature via
19
+ * `session.hook("context")` [VERIFIED: same hash, `:2615-2710`].
20
+ * - Control-plane precedent: `iumbtems.settings` RPC via `ctx.rpc.register`
21
+ * [VERIFIED: same hash, `:2072-2510`]; MCP toggles via `ctx.mcp.transform`
22
+ * [VERIFIED: same hash, `:2579-2610`]; setup adopts all four registrations
23
+ * [VERIFIED: same hash, `:2714-2750`].
24
+ * - QA home: mock-host harness for the plugin transforms
25
+ * [VERIFIED: sha256:06fff10d7c9d77a86c42d6f412b22de46a6f6bba89dea23a863075bdd3d98bda
26
+ * `file:///home/john/Projects/IUMBTEMS/runner/tests/test_opencode_ux.py`].
27
+ * - Gate-0 parents: brainstorm manifest
28
+ * `08229740d0a7f752b0c388876ec1588e513f2b3bc8130a72771bee9593d92665`,
29
+ * darkharvest manifest
30
+ * `131bcb343879bfadcba9a40719ddd0b77540eb21f4cdc937d18b1b2943579afa`.
31
+ */
32
+
33
+ // ---------------------------------------------------------------------------
34
+ // Event registry + tier table.
35
+ // ---------------------------------------------------------------------------
36
+
37
+ /** The six blockable bus events (H1+H5). All six deny-block per tier table. */
38
+ export const HOOK_EVENTS = Object.freeze([
39
+ 'pre-tool-use',
40
+ 'post-tool-use',
41
+ 'pre-commit',
42
+ 'stop',
43
+ 'notification',
44
+ 'session-start',
45
+ ]);
46
+
47
+ /**
48
+ * Tier timeouts (H2+H6). Fast checks get a short budget and block on deny;
49
+ * slow checks get a longer budget and a deny blocks only when it lands inside
50
+ * that budget — a slow overrun degrades to an advisory annotation (fail-open
51
+ * allow) with an audit entry, so a hung slow check can never brick the fast
52
+ * path.
53
+ */
54
+ export const FAST_TIMEOUT_MS = 500;
55
+ export const SLOW_TIMEOUT_MS = 5000;
56
+
57
+ /** Handler tiers. Unknown/missing tier normalises to `slow` (fail-open side). */
58
+ export const HOOK_TIERS = Object.freeze(['fast', 'slow']);
59
+
60
+ /** Max audit entries retained per bus (ring cap; oldest dropped first). */
61
+ export const MAX_AUDIT_ENTRIES = 1000;
62
+
63
+ function normalizeTier(tier) {
64
+ return tier === 'fast' ? 'fast' : 'slow';
65
+ }
66
+
67
+ function timeoutForTier(tier, overrides = {}) {
68
+ if (tier === 'fast') {
69
+ const v = Number(overrides.fastTimeoutMs ?? FAST_TIMEOUT_MS);
70
+ return Number.isFinite(v) && v >= 0 ? v : FAST_TIMEOUT_MS;
71
+ }
72
+ const v = Number(overrides.slowTimeoutMs ?? SLOW_TIMEOUT_MS);
73
+ return Number.isFinite(v) && v >= 0 ? v : SLOW_TIMEOUT_MS;
74
+ }
75
+
76
+ /**
77
+ * Normalise a handler's return into `allow` | `deny` | `advisory`.
78
+ * `undefined`/`null`/`true` mean allow (non-blocking default); `false` and
79
+ * `'deny'` mean deny; `'advisory'` (or `{verdict:'advisory'}`) annotates
80
+ * without blocking.
81
+ *
82
+ * Fix-round (FAIL 3a): the detailed form reports whether the input was a
83
+ * recognised spelling. Anything unrecognised still degrades to fail-open
84
+ * `allow`, but the caller MUST tag the audit entry with `fallback: true` +
85
+ * a `note` — silent normalisation is what made malformed verdicts invisible.
86
+ */
87
+ export function normalizeVerdictEx(value) {
88
+ if (value === undefined || value === null || value === true) {
89
+ return { verdict: 'allow', recognized: true };
90
+ }
91
+ if (value === false) return { verdict: 'deny', recognized: true };
92
+ if (typeof value === 'string') {
93
+ const v = value.trim().toLowerCase();
94
+ if (v === 'deny' || v === 'block') return { verdict: 'deny', recognized: true };
95
+ if (v === 'advisory' || v === 'warn') return { verdict: 'advisory', recognized: true };
96
+ if (v === 'allow' || v === 'ok' || v === '') return { verdict: 'allow', recognized: true };
97
+ // Non-empty unlisted string: fail-open allow (kept as `reason` by the
98
+ // caller), flagged — not silent.
99
+ return { verdict: 'allow', recognized: false };
100
+ }
101
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
102
+ // Narrow fix round (qa-b): the empty object carries no verdict and no
103
+ // reason — it is NOT a reason-only object. Treat it as unrecognized so
104
+ // the caller tags fallback:true + note, consistent with 42/{verdict:BOGUS}/
105
+ // "gibberish"/0/[].
106
+ if (Object.keys(value).length === 0) {
107
+ return { verdict: 'allow', recognized: false };
108
+ }
109
+ const raw = value.verdict;
110
+ if (raw === undefined || raw === null || String(raw).trim() === '') {
111
+ return { verdict: 'allow', recognized: true }; // reason-only object
112
+ }
113
+ const v = String(raw).trim().toLowerCase();
114
+ if (v === 'deny') return { verdict: 'deny', recognized: true };
115
+ if (v === 'advisory') return { verdict: 'advisory', recognized: true };
116
+ if (v === 'allow') return { verdict: 'allow', recognized: true };
117
+ return { verdict: 'allow', recognized: false };
118
+ }
119
+ return { verdict: 'allow', recognized: false };
120
+ }
121
+
122
+ export function normalizeVerdict(value) {
123
+ return normalizeVerdictEx(value).verdict;
124
+ }
125
+
126
+ function reasonOf(value) {
127
+ if (value && typeof value === 'object' && typeof value.reason === 'string') {
128
+ return value.reason;
129
+ }
130
+ if (typeof value === 'string' && value.trim() && !['allow', 'deny', 'advisory', 'block', 'ok', 'warn'].includes(value.trim().toLowerCase())) {
131
+ return value.trim();
132
+ }
133
+ return null;
134
+ }
135
+
136
+ /** Run one handler with a timeout bound. Never rejects: resolves a record.
137
+ *
138
+ * Caller-latency contract (GOAL §5/§6 fix-round note): this promise always
139
+ * settles within ~`ms` — the timer fires even for a handler that never
140
+ * settles, so `emit` (tiers concurrent) resolves within
141
+ * max(fastBudget, slowBudget) + scheduling epsilon. A hung slow handler
142
+ * delays the caller by at most the slow budget (default `SLOW_TIMEOUT_MS`),
143
+ * never indefinitely. Pinned by `test_emit_latency_bounded_by_slow_budget`.
144
+ */
145
+ function runHandlerBounded(handler, payload, ms) {
146
+ const started = Date.now();
147
+ return new Promise((resolve) => {
148
+ let done = false;
149
+ const finish = (record) => {
150
+ if (done) return;
151
+ done = true;
152
+ clearTimeout(timer);
153
+ resolve({ ...record, latencyMs: Date.now() - started });
154
+ };
155
+ const timer = setTimeout(() => {
156
+ finish({ status: 'timeout', verdict: 'allow', fallback: true });
157
+ }, ms);
158
+ // NOTE: intentionally NOT unref'd — an unref'd timer lets a node process
159
+ // drain while a hung handler settles, so the timeout would never fire.
160
+ let result;
161
+ try {
162
+ result = handler(payload);
163
+ } catch (err) {
164
+ finish({ status: 'error', verdict: 'allow', fallback: true, error: String(err?.message ?? err) });
165
+ return;
166
+ }
167
+ Promise.resolve(result).then(
168
+ (value) => {
169
+ const norm = normalizeVerdictEx(value);
170
+ finish({
171
+ status: 'ok',
172
+ verdict: norm.verdict,
173
+ reason: reasonOf(value),
174
+ // FAIL 3a: an unrecognised verdict is NOT silently normalised —
175
+ // the audit entry carries fallback + note (verdict stays fail-open
176
+ // allow).
177
+ ...(norm.recognized ? {} : {
178
+ fallback: true,
179
+ note: 'unrecognized verdict; degraded to fail-open allow',
180
+ }),
181
+ });
182
+ },
183
+ (err) => finish({ status: 'error', verdict: 'allow', fallback: true, error: String(err?.message ?? err) }),
184
+ );
185
+ });
186
+ }
187
+
188
+ // ---------------------------------------------------------------------------
189
+ // Bus.
190
+ // ---------------------------------------------------------------------------
191
+
192
+ /**
193
+ * Create a hook bus. `on(event, handler, {tier})` registers; `emit(event,
194
+ * payload)` runs fast-tier and slow-tier handlers concurrently (slow overruns
195
+ * never brick the fast path) and resolves `{allowed, verdicts, annotations}`.
196
+ * Every handler outcome — allow/deny/timeout/fallback — appends one audit
197
+ * entry. Neither `on` nor `emit` ever throws.
198
+ */
199
+ export function createHookBus(opts = {}) {
200
+ const handlers = new Map(HOOK_EVENTS.map((e) => [e, []]));
201
+ const audit = [];
202
+ let seq = 0;
203
+ // FAIL 3c: ring-cap evictions are counted (never silent). `dropped` counts
204
+ // capacity evictions only; `clearAuditLog()` writes a tombstone entry whose
205
+ // note carries the cleared count. Both are surfaced via `getAuditStats()`.
206
+ let droppedAuditEntries = 0;
207
+ const clock = typeof opts.clock === 'function' ? opts.clock : () => new Date().toISOString();
208
+
209
+ function appendAudit(entry) {
210
+ try {
211
+ audit.push({ seq: seq += 1, ts: clock(), ...entry });
212
+ while (audit.length > MAX_AUDIT_ENTRIES) {
213
+ audit.shift();
214
+ droppedAuditEntries += 1;
215
+ }
216
+ } catch {
217
+ /* audit must never break the bus */
218
+ }
219
+ }
220
+
221
+ function on(event, handler, runOpts = {}) {
222
+ try {
223
+ if (!handlers.has(event) || typeof handler !== 'function') {
224
+ appendAudit({
225
+ event: String(event), tier: normalizeTier(runOpts?.tier),
226
+ handler: 'n/a', verdict: 'allow', outcome: 'fallback',
227
+ latencyMs: 0, fallback: true, note: 'on(): unknown event or non-function handler; ignored',
228
+ tool: null, eventId: null, // FAIL 3b: keys always present (null when absent)
229
+ });
230
+ return () => {};
231
+ }
232
+ const tier = normalizeTier(runOpts?.tier);
233
+ const name = runOpts?.name || handler.name || 'anonymous';
234
+ const record = { handler, tier, name };
235
+ handlers.get(event).push(record);
236
+ return () => {
237
+ try {
238
+ const list = handlers.get(event) || [];
239
+ const idx = list.indexOf(record);
240
+ if (idx >= 0) list.splice(idx, 1);
241
+ } catch {
242
+ /* unsubscribe never throws */
243
+ }
244
+ };
245
+ } catch {
246
+ return () => {};
247
+ }
248
+ }
249
+
250
+ async function emit(event, payload = {}, emitOpts = {}) {
251
+ try {
252
+ if (!handlers.has(event)) {
253
+ appendAudit({
254
+ event: String(event), tier: 'n/a', handler: 'n/a',
255
+ verdict: 'allow', outcome: 'fallback', latencyMs: 0,
256
+ fallback: true, note: 'emit(): unknown event; fail-open allow',
257
+ tool: null, eventId: null, // FAIL 3b: keys always present (null when absent)
258
+ });
259
+ return { allowed: true, verdicts: [], annotations: [{ event, outcome: 'fallback', note: 'unknown event' }] };
260
+ }
261
+ const list = [...(handlers.get(event) || [])];
262
+ const fastBudget = timeoutForTier('fast', emitOpts);
263
+ const slowBudget = timeoutForTier('slow', emitOpts);
264
+
265
+ // Tiers run concurrently so a slow overrun never bricks the fast path;
266
+ // order is preserved within each tier (registration order).
267
+ const runTier = async (tier) => {
268
+ const out = [];
269
+ for (const h of list.filter((r) => r.tier === tier)) {
270
+ const budget = tier === 'fast' ? fastBudget : slowBudget;
271
+ const rec = await runHandlerBounded(h.handler, payload, budget);
272
+ const outcome = rec.status === 'ok'
273
+ ? (rec.verdict === 'deny' ? 'deny' : rec.verdict === 'advisory' ? 'advisory' : 'allow')
274
+ : rec.status; // 'timeout' | 'error'
275
+ appendAudit({
276
+ event, tier, handler: h.name, verdict: rec.verdict, outcome,
277
+ latencyMs: rec.latencyMs,
278
+ // FAIL 3b: `tool` / `eventId` keys are ALWAYS present (null when
279
+ // the payload carries none) so audit consumers can rely on shape.
280
+ tool: payload?.tool ?? null,
281
+ eventId: payload?.eventId ?? null,
282
+ ...(rec.fallback ? { fallback: true } : {}),
283
+ ...(rec.note ? { note: rec.note } : {}),
284
+ ...(rec.error ? { error: rec.error } : {}),
285
+ ...(rec.reason ? { reason: rec.reason } : {}),
286
+ });
287
+ out.push({ tier, handler: h.name, ...rec, outcome });
288
+ }
289
+ return out;
290
+ };
291
+
292
+ const [fastRecs, slowRecs] = await Promise.all([runTier('fast'), runTier('slow')]);
293
+ // Report in registration order (fast/slow interleave preserved by seq).
294
+ const ordered = [...fastRecs, ...slowRecs].sort(
295
+ (a, b) => list.findIndex((r) => r.name === a.handler) - list.findIndex((r) => r.name === b.handler),
296
+ );
297
+ const denied = ordered.some((r) => r.outcome === 'deny');
298
+ const annotations = ordered
299
+ .filter((r) => r.outcome === 'advisory' || r.status === 'timeout' || r.status === 'error')
300
+ .map((r) => ({
301
+ event, tier: r.tier, handler: r.handler, outcome: r.outcome,
302
+ ...(r.reason ? { reason: r.reason } : {}),
303
+ ...(r.error ? { error: r.error } : {}),
304
+ }));
305
+ return { allowed: !denied, verdicts: ordered, annotations };
306
+ } catch (err) {
307
+ try {
308
+ appendAudit({
309
+ event: String(event), tier: 'n/a', handler: 'n/a',
310
+ verdict: 'allow', outcome: 'fallback', latencyMs: 0,
311
+ fallback: true, error: String(err?.message ?? err),
312
+ tool: payload?.tool ?? null, eventId: payload?.eventId ?? null,
313
+ });
314
+ } catch {
315
+ /* last-resort: never throw */
316
+ }
317
+ return { allowed: true, verdicts: [], annotations: [{ event, outcome: 'fallback' }] };
318
+ }
319
+ }
320
+
321
+ function getAuditLog() {
322
+ try {
323
+ return audit.map((e) => ({ ...e }));
324
+ } catch {
325
+ return [];
326
+ }
327
+ }
328
+
329
+ function clearAuditLog() {
330
+ // FAIL 3c: clearing leaves a tombstone entry (the clear itself is
331
+ // auditable) whose note carries the discarded count. Never throws.
332
+ try {
333
+ const cleared = audit.length;
334
+ audit.length = 0;
335
+ audit.push({
336
+ seq: seq += 1,
337
+ ts: clock(),
338
+ event: 'audit-clear',
339
+ tier: 'n/a',
340
+ handler: 'n/a',
341
+ verdict: 'allow',
342
+ outcome: 'fallback',
343
+ latencyMs: 0,
344
+ fallback: true,
345
+ note: `audit log cleared; ${cleared} entries discarded`,
346
+ tool: null,
347
+ eventId: null,
348
+ });
349
+ } catch {
350
+ /* never throws */
351
+ }
352
+ }
353
+
354
+ /** Ring-cap ledger: live entry count, capacity evictions, cap size. */
355
+ function getAuditStats() {
356
+ try {
357
+ return { entries: audit.length, dropped: droppedAuditEntries, capacity: MAX_AUDIT_ENTRIES };
358
+ } catch {
359
+ return { entries: 0, dropped: 0, capacity: MAX_AUDIT_ENTRIES };
360
+ }
361
+ }
362
+
363
+ function handlerCount(event) {
364
+ try {
365
+ if (event === undefined) {
366
+ return [...handlers.values()].reduce((n, l) => n + l.length, 0);
367
+ }
368
+ return (handlers.get(event) || []).length;
369
+ } catch {
370
+ return 0;
371
+ }
372
+ }
373
+
374
+ return { on, emit, getAuditLog, clearAuditLog, getAuditStats, handlerCount, events: [...HOOK_EVENTS] };
375
+ }
376
+
377
+ /**
378
+ * Pre-commit gate helper for the settings-RPC write path: emits `pre-commit`
379
+ * and maps the verdict to `{allowed, result}`. Fail-open on any bus fault.
380
+ * Stale-write guards are NOT replaced — the caller still runs the
381
+ * expected-hash check / MCP write after an allow (see `index.js`
382
+ * `createSettingsHandlers().set`).
383
+ */
384
+ export async function gatePreCommit(bus, payload = {}, emitOpts = {}) {
385
+ try {
386
+ if (!bus || typeof bus.emit !== 'function') return { allowed: true };
387
+ const res = await bus.emit('pre-commit', payload, emitOpts);
388
+ return res && res.allowed === false ? { allowed: false, result: res } : { allowed: true, result: res };
389
+ } catch {
390
+ return { allowed: true };
391
+ }
392
+ }
393
+
394
+ /**
395
+ * Adopt the bus alongside the existing transform/hook/rpc registrations
396
+ * (same adopt/best-effort pattern as `setup` in `index.js`). Attaches the bus
397
+ * to the host for debugging (`host.iumbtemsHookBus`, best-effort) and returns
398
+ * a disposable registration; returns null (never throws) when there is no
399
+ * bus to adopt.
400
+ */
401
+ export async function registerHookBus(host, opts = {}) {
402
+ try {
403
+ const bus = opts.bus || null;
404
+ if (!bus || typeof bus.emit !== 'function' || typeof bus.on !== 'function') return null;
405
+ try {
406
+ if (host && typeof host === 'object') host.iumbtemsHookBus = bus;
407
+ } catch {
408
+ /* host attach is best-effort */
409
+ }
410
+ let disposed = false;
411
+ return {
412
+ bus,
413
+ // Synchronous on purpose: the setup() disposer stays fully synchronous
414
+ // when every adopted disposal is sync, so a non-awaited cleanup() still
415
+ // stops nudge streams before returning (pinned by
416
+ // test_setup_cleanup_aborts_nudge_streams).
417
+ dispose: () => {
418
+ disposed = true;
419
+ try {
420
+ if (host && typeof host === 'object' && host.iumbtemsHookBus === bus) {
421
+ delete host.iumbtemsHookBus;
422
+ }
423
+ } catch {
424
+ /* dispose never throws */
425
+ }
426
+ },
427
+ get disposed() {
428
+ return disposed;
429
+ },
430
+ };
431
+ } catch {
432
+ return null;
433
+ }
434
+ }