@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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "epistemic-swarm",
3
- "version": "0.7.21",
3
+ "version": "0.7.23",
4
4
  "description": "High-Integrity Dialectic Research Agent Harness for Claude Code enforcing empirical evidence over parametric hallucination.",
5
5
  "author": {
6
6
  "name": "Heretek AI",
package/README.md CHANGED
@@ -224,7 +224,7 @@ The canonical MCP server (`python3 runner/mcp_server.py`, stdio) exposes these t
224
224
  | `iumbtems_oss_scout` | Scout open-source libraries, audit licenses, and build clean-room blueprints. |
225
225
  | `iumbtems_brainstorm` | Lateral ideation portfolio: feature vectors, paradigm moves, falsifiable spikes. Never bug fixes. |
226
226
  | `iumbtems_darkharvest` | Product competitor teardown with per-feature `depend\|vendor\|clean-room\|skip` verdicts and SPDX attribution. |
227
- | `iumbtems_factory` | Drive factory run state: `init` / `phase-add` / `qa-record` / `expansion` / `stop`. State in `<project>/.factory` and `.roadmap`. |
227
+ | `iumbtems_factory` | Drive factory run state: `init` / `phase-add` / `qa-record` / `expansion` / `stop` / `gate`. State in `<project>/.factory` and `.roadmap`. |
228
228
  | `iumbtems_verify_quote` | Audit a verbatim citation against the SHA-256 source cache (`.research/sources/<hash>.md`). |
229
229
  | `iumbtems_socratic_frontier` | Inspect or advance the Socratic decision-tree frontier (`.research/frontier.json`). |
230
230
  | `iumbtems_export_brief` | Export a proof-carrying research brief (PCRB): a self-contained signed bundle of synthesis, claims, quote witnesses, and full source texts. |
package/bin/cli.js CHANGED
@@ -50,7 +50,7 @@ Commands:
50
50
  run "<objective>" Run the dialectic multi-agent research swarm
51
51
  brainstorm "<prompt>" Run lateral brainstorming (feature vectors + spikes)
52
52
  darkharvest "<arena>" Product competitor teardown with harvest verdicts
53
- factory <subcommand> Factory run-state helper (init, phase-add, qa-record, expansion, stop)
53
+ factory <subcommand> Factory run-state helper (init, phase-add, qa-record, expansion, stop, gate)
54
54
  grill Launch interactive Socratic decision tree framing
55
55
  adapters Rebuild harness adapter mirrors (skills -> plugins/*, .agents)
56
56
  install Install skills & MCP servers into ~/.claude/
@@ -183,12 +183,13 @@ switch (command) {
183
183
  // iumbtems factory qa-record --run arena --phase 01-x --seat qa-a --verdict pass
184
184
  if (args[1] === '--help' || !args[1]) {
185
185
  console.log([
186
- 'Usage: iumbtems factory <init|phase-add|qa-record|expansion|stop> [options]',
186
+ 'Usage: iumbtems factory <init|phase-add|qa-record|expansion|stop|gate> [options]',
187
187
  ' init --run <name>',
188
188
  ' phase-add --run <name> --phase <id> --goal "<goal>" --accept "a;b"',
189
189
  ' qa-record --run <name> --phase <id> --seat <qa-a|qa-b> --verdict <pass|fail|conditional> [--reason "..."]',
190
190
  ' expansion --run <name> --loops <n> [--max-loops 10]',
191
191
  ' stop --run <name> (writes .factory/STOP kill-file)',
192
+ ' gate --run <name> --phase <id> --action <open|settle|approve|waive|escalate|count> [--reason "..."]',
192
193
  ].join('\n'));
193
194
  break;
194
195
  }
@@ -319,11 +319,12 @@ export default function initPiExtension(pi) {
319
319
  // Tool: iumbtems_factory (run-state helper, no script paths)
320
320
  pi.registerTool({
321
321
  name: 'iumbtems_factory',
322
- description: 'Drive factory run state: init / phase-add / qa-record / expansion / stop',
322
+ description: 'Drive factory run state: init / phase-add / qa-record / expansion / stop / gate',
323
323
  parameters: {
324
324
  type: 'object',
325
325
  properties: {
326
- command: { type: 'string', enum: ['init', 'phase-add', 'qa-record', 'expansion', 'stop'] },
326
+ command: { type: 'string', enum: ['init', 'phase-add', 'qa-record', 'expansion', 'stop', 'gate'] },
327
+ action: { type: 'string', enum: ['open', 'settle', 'approve', 'waive', 'escalate', 'count'], description: 'Gate action (with command: gate)' },
327
328
  run: { type: 'string', description: 'Factory run name' },
328
329
  phase: { type: 'string' },
329
330
  goal: { type: 'string' },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heretek-ai/epistemic-swarm",
3
- "version": "0.7.21",
3
+ "version": "0.7.23",
4
4
  "description": "IUMBTEMS (Epistemic Swarm) \u2014 High-Integrity Dialectic Research Agent Harness for Claude Code, OpenCode V2, Pi (pi.dev), OMP (oh-my-pi), Gemini CLI, Codex CLI, and AntiGravity",
5
5
  "main": "bin/cli.js",
6
6
  "bin": {
@@ -146,5 +146,9 @@
146
146
  "validate:claude": "claude plugin validate --strict .claude-plugin/marketplace.json && claude plugin validate --strict .",
147
147
  "prepack": "python3 scripts/build_adapters.py && python3 -m unittest discover -s runner/tests",
148
148
  "install-local": "bash install.sh"
149
+ },
150
+ "devDependencies": {
151
+ "@biomejs/biome": "2.5.15",
152
+ "esbuild": "0.28.2"
149
153
  }
150
154
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://antigravity.google/schemas/v1/plugin.json",
3
3
  "name": "epistemic-swarm",
4
- "version": "0.7.21",
4
+ "version": "0.7.23",
5
5
  "description": "IUMBTEMS (Epistemic Swarm) \u2014 High-Integrity Dialectic Research, Architecture Auditing, Competitor Teardowns, and Socratic Planning for Antigravity.",
6
6
  "author": {
7
7
  "name": "Heretek AI",
@@ -21,7 +21,7 @@ npm cache in consuming projects, and relative paths broke live: the manager
21
21
  agent ran `find / -name factory.py`). The tool wraps the same helper and
22
22
  resolves the project via `project_dir` argument, `IUMBTEMS_PROJECT_DIR`, or
23
23
  the session cwd. It supports `init`, `phase-add`, `qa-record`, `expansion`,
24
- `stop`, and returns `status: escalated` (exit 2) on the 3rd QA failure.
24
+ `stop`, `gate` (`--action open|settle|approve|waive|escalate|count`), and returns `status: escalated` (exit 2) on the 3rd QA failure.
25
25
 
26
26
  ## 2. Gate protocol (max 5 swarm cycles per gate)
27
27
 
@@ -0,0 +1,566 @@
1
+ /**
2
+ * IUMBTEMS analysis bridge (Phase 02-lsp-bridge): first hook-bus consumer.
3
+ *
4
+ * CLI-invocation bridge (no persistent LSP servers): subscribes to the
5
+ * Phase-01 six-event bus for `pre-tool-use`, `post-tool-use`, and
6
+ * `pre-commit` with `*.js`/`*.ts` targets, runs the pinned analyzer CLI
7
+ * inside the fast-tier budget, and reports findings as ADVISORY annotations
8
+ * `{ allowed: true, annotations: [...] }`. The bridge NEVER returns deny —
9
+ * even severe findings allow (all-advisory per gate L2; pinned by
10
+ * `test_analysis_bridge.py::TestNeverDeny`).
11
+ *
12
+ * Findings shape: `{ tool, eventId, rule, file, line, message, severity }`.
13
+ * Every finding is audit-logged on the bus (one `notification` record per
14
+ * finding via the internal audit sink, each carrying `tool`/`eventId`); the
15
+ * toast summarizes counts only (no finding text, no log spam).
16
+ *
17
+ * Analyzer-missing (devDep not installed, e.g. fresh checkout): single audit
18
+ * entry (the handler's own fail-open `allow`) + silent skip — never throws,
19
+ * never blocks, no toast.
20
+ *
21
+ * Zero runtime dependencies (node builtins only); the single analyzer is a
22
+ * pinned devDependency (Biome, single dep, zero-config, lint+format JS/TS).
23
+ *
24
+ * Phase evidence (cited per programmer brief):
25
+ * - Bus contract (six blockable events, tiered enforcement, fail-open,
26
+ * tool/eventId audit guarantees)
27
+ * [VERIFIED: sha256:f2c0843b598ba6f19cc0e208f8d02e0250c70615e3c484474d50635d0b5cdef8
28
+ * `file:///home/john/Projects/IUMBTEMS/plugins/opencode/hook-bus.js`]
29
+ * - Host emit points (pre/post-tool-use own-tools scope, pre-commit both
30
+ * legs, session-start advisory; stop/notification bus-level-only)
31
+ * [VERIFIED: sha256:640ab94a0408cef8cc14fb2cbf52f041021a1ce012590b41e8063075a21b2c8f
32
+ * `file:///home/john/Projects/IUMBTEMS/plugins/opencode/index.js`]
33
+ * - Parity table (ENFORCED vs BUS-LEVEL-ONLY split; `tool.execute.before`
34
+ * gap)
35
+ * [VERIFIED: sha256:69cacc4b62c2c1fccd5f5f3cf4d78398eb7310ceeb36e9ef423868e530771d90
36
+ * `file:///home/john/Projects/IUMBTEMS/docs/HOOK_BUS_PARITY.md`]
37
+ * - package.json baseline (no devDependencies; the single approved delta)
38
+ * [VERIFIED: sha256:2b08ae78a2475e7d3b3427ba089542f8bd76635b87ab397066af8c0355651fbd
39
+ * `file:///home/john/Projects/IUMBTEMS/package.json`]
40
+ * - Consumer contract inherited (scoped bus tests incl. shipped-path
41
+ * threading, fallback tags, audit key guarantees, emit-latency bound)
42
+ * [VERIFIED: sha256:7cbceba66852b3d8405d78833d0ce1dad7a25423af59cbacdea2790ba83e4b2a
43
+ * `file:///home/john/Projects/IUMBTEMS/runner/tests/test_hook_bus.py`]
44
+ */
45
+
46
+ import { spawn } from 'node:child_process';
47
+ import { existsSync } from 'node:fs';
48
+ import path from 'node:path';
49
+ import { fileURLToPath } from 'node:url';
50
+ import { FAST_TIMEOUT_MS } from './hook-bus.js';
51
+
52
+ /** Tool tag carried on every finding and audit record from this bridge. */
53
+ export const BRIDGE_TOOL = 'analysis-bridge';
54
+
55
+ /** Bus events this bridge consumes (gate L2/L3: advisory JS/TS analysis). */
56
+ export const BRIDGE_EVENTS = Object.freeze([
57
+ 'pre-tool-use',
58
+ 'post-tool-use',
59
+ 'pre-commit',
60
+ ]);
61
+
62
+ /**
63
+ * Analyzer budget: the fast-tier budget. The analyzer must settle inside it;
64
+ * an overrun degrades to a single `analysis-bridge/timeout` annotation and
65
+ * the caller stays unblocked (fail-open allow).
66
+ */
67
+ export const ANALYZER_TIMEOUT_MS = FAST_TIMEOUT_MS;
68
+
69
+ /** Cap on findings returned per analysis (bounds audit + toast fan-out). */
70
+ export const MAX_FINDINGS = 50;
71
+
72
+ /** Cap on target files per analysis (bounds CLI argv + runtime). */
73
+ export const MAX_TARGET_FILES = 20;
74
+
75
+ /** Marker distinguishing per-finding audit records on `notification`. */
76
+ export const AUDIT_KIND = 'analysis-finding';
77
+
78
+ const __filename = fileURLToPath(import.meta.url);
79
+ const __dirname = path.dirname(__filename);
80
+ const PKG_ROOT = path.resolve(__dirname, '../..');
81
+
82
+ /** JS/TS targets: .js/.jsx/.mjs/.cjs/.ts/.tsx/.mts/.cts (case-insensitive). */
83
+ export function isJsTsFile(p) {
84
+ return typeof p === 'string' && /\.(m|c)?[jt]sx?$/i.test(p.trim());
85
+ }
86
+
87
+ function pushCandidate(out, seen, v) {
88
+ if (typeof v !== 'string') return;
89
+ const t = v.trim();
90
+ if (!t || !isJsTsFile(t) || seen.has(t)) return;
91
+ seen.add(t);
92
+ out.push(t);
93
+ }
94
+
95
+ /**
96
+ * Collect `*.js`/`*.ts` target paths from a bus payload. Inspects the
97
+ * conventional locator fields (`file`, `files`, `path`, `paths`,
98
+ * `filename`, `filenames`) plus a one-level scan of `args` values (plain
99
+ * strings or arrays of strings, e.g. an edit tool's file argument).
100
+ * De-duplicated, capped at `MAX_TARGET_FILES`. Never throws.
101
+ */
102
+ export function collectTargets(payload) {
103
+ try {
104
+ const out = [];
105
+ const seen = new Set();
106
+ if (!payload || typeof payload !== 'object') return out;
107
+ for (const key of ['file', 'files', 'path', 'paths', 'filename', 'filenames']) {
108
+ const v = payload[key];
109
+ if (Array.isArray(v)) {
110
+ for (const item of v) pushCandidate(out, seen, item);
111
+ } else {
112
+ pushCandidate(out, seen, v);
113
+ }
114
+ }
115
+ const args = payload.args;
116
+ if (args && typeof args === 'object') {
117
+ for (const v of Object.values(args)) {
118
+ if (Array.isArray(v)) {
119
+ for (const item of v) pushCandidate(out, seen, item);
120
+ } else {
121
+ pushCandidate(out, seen, v);
122
+ }
123
+ }
124
+ }
125
+ return out.slice(0, MAX_TARGET_FILES);
126
+ } catch {
127
+ return [];
128
+ }
129
+ }
130
+
131
+ function resolveEventId(payload) {
132
+ try {
133
+ const v = payload?.eventId ?? payload?.event_id ?? null;
134
+ return typeof v === 'string' || typeof v === 'number' ? v : null;
135
+ } catch {
136
+ return null;
137
+ }
138
+ }
139
+
140
+ function resolveTool(payload) {
141
+ try {
142
+ const v = payload?.tool;
143
+ return typeof v === 'string' && v.trim() ? v : BRIDGE_TOOL;
144
+ } catch {
145
+ return BRIDGE_TOOL;
146
+ }
147
+ }
148
+
149
+ /**
150
+ * Normalise one finding. Every key is ALWAYS present (`file`/`line` are
151
+ * `null` when unknown, e.g. timeout annotations) so audit consumers can
152
+ * rely on shape — the same guarantee the bus gives `tool`/`eventId`.
153
+ */
154
+ export function toFinding({ tool, eventId, rule, file, line, message, severity }) {
155
+ return {
156
+ tool: typeof tool === 'string' && tool ? tool : BRIDGE_TOOL,
157
+ eventId: eventId ?? null,
158
+ rule: typeof rule === 'string' && rule ? rule : 'analysis-bridge/unknown',
159
+ file: typeof file === 'string' && file ? file : null,
160
+ line: Number.isFinite(Number(line)) && Number(line) > 0 ? Number(line) : null,
161
+ message: typeof message === 'string' && message ? message : '',
162
+ severity: typeof severity === 'string' && severity ? severity.toLowerCase() : 'info',
163
+ };
164
+ }
165
+
166
+ function timeoutFinding(tool, eventId, targets, timeoutMs) {
167
+ return toFinding({
168
+ tool,
169
+ eventId,
170
+ rule: 'analysis-bridge/timeout',
171
+ file: targets.length > 0 ? targets[0] : null,
172
+ line: null,
173
+ message: `analyzer exceeded the ${timeoutMs}ms fast-tier budget; skipped (advisory, action allowed)`,
174
+ severity: 'info',
175
+ });
176
+ }
177
+
178
+ function errorFinding(tool, eventId, targets, message) {
179
+ return toFinding({
180
+ tool,
181
+ eventId,
182
+ rule: 'analysis-bridge/error',
183
+ file: targets.length > 0 ? targets[0] : null,
184
+ line: null,
185
+ message: `analyzer error: ${message} (advisory, action allowed)`,
186
+ severity: 'info',
187
+ });
188
+ }
189
+
190
+ /** Locate the Biome binary: repo devDep first, then PATH fallback. */
191
+ export function findAnalyzerBin() {
192
+ try {
193
+ const local = path.join(PKG_ROOT, 'node_modules', '.bin', 'biome');
194
+ if (existsSync(local)) return local;
195
+ } catch {
196
+ /* fall through to PATH lookup */
197
+ }
198
+ return 'biome';
199
+ }
200
+
201
+ function parseBiomeJson(stdout, tool, eventId) {
202
+ const findings = [];
203
+ try {
204
+ const parsed = JSON.parse(String(stdout || ''));
205
+ const diags = Array.isArray(parsed?.diagnostics) ? parsed.diagnostics : [];
206
+ for (const d of diags) {
207
+ if (!d || typeof d !== 'object') continue;
208
+ findings.push(toFinding({
209
+ tool,
210
+ eventId,
211
+ rule: typeof d.category === 'string' && d.category ? d.category : 'biome/unknown',
212
+ file: d.location?.path ?? null,
213
+ line: d.location?.start?.line ?? null,
214
+ message: typeof d.message === 'string' ? d.message : '',
215
+ severity: typeof d.severity === 'string' ? d.severity : 'info',
216
+ }));
217
+ if (findings.length >= MAX_FINDINGS) break;
218
+ }
219
+ } catch {
220
+ /* unparseable output: no findings (never throws) */
221
+ }
222
+ return findings;
223
+ }
224
+
225
+ /**
226
+ * Default analyzer: `biome lint --reporter=json` over the target files.
227
+ * Resolves `{ status: 'ok', findings }`, `{ status: 'missing' }` (binary
228
+ * absent), `{ status: 'timeout' }` (overrun; child killed), or
229
+ * `{ status: 'error', message }`. Never rejects, never throws.
230
+ */
231
+ export function defaultRunAnalyzer(files, opts = {}) {
232
+ const timeoutMs = Number(opts.timeoutMs ?? ANALYZER_TIMEOUT_MS);
233
+ const budget = Number.isFinite(timeoutMs) && timeoutMs >= 0 ? timeoutMs : ANALYZER_TIMEOUT_MS;
234
+ return new Promise((resolve) => {
235
+ let done = false;
236
+ const finish = (v) => {
237
+ if (done) return;
238
+ done = true;
239
+ resolve(v);
240
+ };
241
+ let child;
242
+ try {
243
+ child = spawn(
244
+ findAnalyzerBin(),
245
+ [
246
+ 'lint',
247
+ '--reporter=json',
248
+ '--max-diagnostics=50',
249
+ '--diagnostic-level=info',
250
+ '--colors=off',
251
+ '--files-ignore-unknown=true',
252
+ ...files,
253
+ ],
254
+ { cwd: opts.cwd || process.cwd() },
255
+ );
256
+ } catch (err) {
257
+ finish({ status: 'error', message: String(err?.message ?? err) });
258
+ return;
259
+ }
260
+ let stdout = '';
261
+ const timer = setTimeout(() => {
262
+ try { child.kill('SIGKILL'); } catch { /* already exited */ }
263
+ finish({ status: 'timeout' });
264
+ }, budget);
265
+ child.on('error', (err) => {
266
+ clearTimeout(timer);
267
+ const code = err?.code;
268
+ if (code === 'ENOENT') {
269
+ finish({ status: 'missing' });
270
+ } else {
271
+ finish({ status: 'error', message: String(err?.message ?? err) });
272
+ }
273
+ });
274
+ child.stdout?.on('data', (d) => { stdout += String(d); });
275
+ child.on('close', () => {
276
+ if (done) return;
277
+ clearTimeout(timer);
278
+ finish({ status: 'ok', stdout });
279
+ });
280
+ });
281
+ }
282
+
283
+ /** Race any runner against the fast-tier budget. Never rejects. */
284
+ function runBounded(runnerPromise, timeoutMs) {
285
+ return new Promise((resolve) => {
286
+ let done = false;
287
+ const timer = setTimeout(() => {
288
+ if (done) return;
289
+ done = true;
290
+ resolve({ status: 'timeout' });
291
+ }, timeoutMs);
292
+ Promise.resolve(runnerPromise).then(
293
+ (v) => {
294
+ if (done) return;
295
+ done = true;
296
+ clearTimeout(timer);
297
+ resolve(v && typeof v === 'object' ? v : { status: 'error', message: 'malformed analyzer result' });
298
+ },
299
+ (err) => {
300
+ if (done) return;
301
+ done = true;
302
+ clearTimeout(timer);
303
+ resolve({ status: 'error', message: String(err?.message ?? err) });
304
+ },
305
+ );
306
+ });
307
+ }
308
+
309
+ /**
310
+ * Analyze one bus payload. ALWAYS resolves `{ allowed: true, annotations }`
311
+ * — this function cannot deny (all-advisory per gate L2) and never throws:
312
+ * internal faults degrade to a single `analysis-bridge/error` annotation.
313
+ * `annotations` are findings in `{ tool, eventId, rule, file, line, message,
314
+ * severity }` shape; empty when clean, targetless, or analyzer-missing
315
+ * (missing is a silent skip — no annotation, no toast; the handler's own
316
+ * fail-open `allow` is the single audit entry).
317
+ */
318
+ export async function analyzePayload(payload, opts = {}) {
319
+ try {
320
+ const tool = resolveTool(payload);
321
+ const eventId = resolveEventId(payload);
322
+ const targets = collectTargets(payload);
323
+ if (targets.length === 0) return { allowed: true, annotations: [] };
324
+ const timeoutMs = Number(opts.timeoutMs ?? ANALYZER_TIMEOUT_MS);
325
+ const budget = Number.isFinite(timeoutMs) && timeoutMs >= 0 ? timeoutMs : ANALYZER_TIMEOUT_MS;
326
+ const runAnalyzer = typeof opts.runAnalyzer === 'function' ? opts.runAnalyzer : defaultRunAnalyzer;
327
+ const res = await runBounded(
328
+ runAnalyzer(targets, { timeoutMs: budget, cwd: opts.cwd }),
329
+ budget,
330
+ );
331
+ if (!res || typeof res !== 'object') {
332
+ return { allowed: true, annotations: [errorFinding(tool, eventId, targets, 'malformed analyzer result')] };
333
+ }
334
+ if (res.status === 'missing') return { allowed: true, annotations: [] };
335
+ if (res.status === 'timeout') {
336
+ return { allowed: true, annotations: [timeoutFinding(tool, eventId, targets, budget)] };
337
+ }
338
+ if (res.status === 'error') {
339
+ return { allowed: true, annotations: [errorFinding(tool, eventId, targets, res.message || 'unknown error')] };
340
+ }
341
+ if (res.status === 'ok' && Array.isArray(res.findings)) {
342
+ const findings = res.findings
343
+ .slice(0, MAX_FINDINGS)
344
+ .map((f) => toFinding({ ...f, tool: f?.tool ?? tool, eventId: f?.eventId ?? eventId }));
345
+ return { allowed: true, annotations: findings };
346
+ }
347
+ if (typeof res.stdout === 'string') {
348
+ // Default-runner raw form: parse Biome JSON reporter output here so
349
+ // injected mocks may return either parsed findings or raw stdout.
350
+ return { allowed: true, annotations: parseBiomeJson(res.stdout, tool, eventId) };
351
+ }
352
+ return { allowed: true, annotations: [errorFinding(tool, eventId, targets, 'malformed analyzer result')] };
353
+ } catch (err) {
354
+ // Last resort: fail-open allow with a single error annotation.
355
+ try {
356
+ return {
357
+ allowed: true,
358
+ annotations: [toFinding({
359
+ tool: BRIDGE_TOOL,
360
+ eventId: null,
361
+ rule: 'analysis-bridge/error',
362
+ file: null,
363
+ line: null,
364
+ message: `analyzer error: ${String(err?.message ?? err)} (advisory, action allowed)`,
365
+ severity: 'info',
366
+ })],
367
+ };
368
+ } catch {
369
+ return { allowed: true, annotations: [] };
370
+ }
371
+ }
372
+ }
373
+
374
+ /**
375
+ * Best-effort toast: counts only (`N finding(s) in M file(s)`), never any
376
+ * finding text. Resolves the notifier from `opts.notify`, else from the
377
+ * host (`ui.toast.show` → `client.app.log`), else a no-op. Never throws.
378
+ */
379
+ export function notifySummary(opts, summary) {
380
+ try {
381
+ const fn = typeof opts?.notify === 'function' ? opts.notify : defaultHostNotify(opts?.host);
382
+ if (typeof fn !== 'function') return;
383
+ try {
384
+ const r = fn(summary);
385
+ if (r && typeof r.catch === 'function') r.catch(() => {});
386
+ } catch {
387
+ /* notify is best-effort */
388
+ }
389
+ } catch {
390
+ /* never throws */
391
+ }
392
+ }
393
+
394
+ function defaultHostNotify(host) {
395
+ if (!host || typeof host !== 'object') return null;
396
+ try {
397
+ const show = host.ui?.toast?.show;
398
+ if (typeof show === 'function') {
399
+ return (s) => show.call(host.ui.toast, {
400
+ title: 'IUMBTEMS analysis',
401
+ message: s.message,
402
+ variant: 'info',
403
+ duration: 4000,
404
+ });
405
+ }
406
+ } catch {
407
+ /* fall through */
408
+ }
409
+ try {
410
+ const sink = host.client?.app?.log;
411
+ if (typeof sink === 'function') {
412
+ return (s) => sink.call(host.client.app, {
413
+ body: { service: 'iumbtems', level: 'info', message: `analysis: ${s.message}` },
414
+ });
415
+ }
416
+ } catch {
417
+ /* fall through */
418
+ }
419
+ return null;
420
+ }
421
+
422
+ /**
423
+ * Build the bus handler. Never throws into the bus and NEVER returns deny:
424
+ * findings → `{ verdict: 'advisory', reason: '<n> finding(s)…' }` (each
425
+ * finding additionally audit-logged via one `notification` record);
426
+ * clean/targetless/missing → fail-open `allow` (missing carries a
427
+ * `reason` so the single audit entry identifies the skip; no toast).
428
+ */
429
+ export function createBridgeHandler(bus, opts = {}) {
430
+ const runOpts = { ...opts };
431
+ return async function analysisBridgeHandler(payload) {
432
+ try {
433
+ const result = await analyzePayload(payload, runOpts);
434
+ const annotations = Array.isArray(result?.annotations) ? result.annotations : [];
435
+ if (annotations.length === 0) {
436
+ const targets = collectTargets(payload);
437
+ if (targets.length === 0) {
438
+ return { verdict: 'allow', reason: 'analysis-bridge: no js/ts targets; skipped' };
439
+ }
440
+ return { verdict: 'allow', reason: 'analysis-bridge: analyzer unavailable or no findings; skipped' };
441
+ }
442
+ // Every finding audit-logged on the bus (one `notification` record
443
+ // each; the audit sink below turns each into exactly one audit entry
444
+ // carrying tool/eventId). Best-effort and awaited: the sink settles
445
+ // synchronously, so this costs microseconds per finding.
446
+ if (bus && typeof bus.emit === 'function') {
447
+ for (const f of annotations) {
448
+ try {
449
+ await bus.emit('notification', {
450
+ tool: f.tool,
451
+ eventId: f.eventId,
452
+ rule: f.rule,
453
+ file: f.file,
454
+ line: f.line,
455
+ message: f.message,
456
+ severity: f.severity,
457
+ auditKind: AUDIT_KIND,
458
+ });
459
+ } catch {
460
+ /* one finding's audit record must not drop the rest */
461
+ }
462
+ }
463
+ }
464
+ const files = new Set(annotations.map((f) => f.file).filter(Boolean));
465
+ notifySummary(runOpts, {
466
+ title: 'IUMBTEMS analysis',
467
+ message: `analysis-bridge: ${annotations.length} finding(s) in ${files.size} file(s) (advisory; action allowed)`,
468
+ });
469
+ return {
470
+ verdict: 'advisory',
471
+ reason: `analysis-bridge: ${annotations.length} finding(s) in ${files.size} file(s) (advisory; action allowed)`,
472
+ };
473
+ } catch {
474
+ return { verdict: 'allow', reason: 'analysis-bridge: internal fault; fail-open allow' };
475
+ }
476
+ };
477
+ }
478
+
479
+ /**
480
+ * Internal audit sink on `notification`: turns each per-finding record into
481
+ * exactly one audit entry (fail-open `allow`, so it never blocks anything).
482
+ * The bus persists only fixed audit fields, so the finding detail (rule /
483
+ * file / line / severity / message, length-capped) rides in the entry's
484
+ * `reason` — `tool`/`eventId` travel in the entry's dedicated keys.
485
+ * Non-bridge notifications pass through as plain `allow` (no reason added).
486
+ * Named distinctly so the ledger stays readable.
487
+ */
488
+ export function analysisBridgeAuditSink(payload) {
489
+ try {
490
+ if (payload && payload.auditKind === AUDIT_KIND) {
491
+ const rule = typeof payload.rule === 'string' && payload.rule ? payload.rule : 'analysis-bridge/unknown';
492
+ const file = typeof payload.file === 'string' && payload.file ? payload.file : '?';
493
+ const line = Number.isFinite(Number(payload.line)) && Number(payload.line) > 0 ? Number(payload.line) : '?';
494
+ const severity = typeof payload.severity === 'string' && payload.severity ? payload.severity : 'info';
495
+ let message = typeof payload.message === 'string' ? payload.message : '';
496
+ if (message.length > 300) message = `${message.slice(0, 297)}...`;
497
+ return { verdict: 'allow', reason: `analysis-finding ${rule} ${file}:${line} [${severity}] ${message}` };
498
+ }
499
+ } catch {
500
+ /* fall through to plain allow */
501
+ }
502
+ return 'allow';
503
+ }
504
+
505
+ /**
506
+ * Adopt the bridge alongside the existing registrations (same
507
+ * adopt/best-effort pattern as `setup` in `index.js`). Subscribes the
508
+ * advisory handler on the three analysis events plus the audit sink on
509
+ * `notification`; attaches the registration to the host for debugging
510
+ * (`host.iumbtemsAnalysisBridge`, best-effort). Returns a disposable
511
+ * registration; returns null (never throws) when there is no bus to adopt.
512
+ */
513
+ export async function registerAnalysisBridge(host, opts = {}) {
514
+ try {
515
+ const bus = opts.bus || null;
516
+ if (!bus || typeof bus.on !== 'function' || typeof bus.emit !== 'function') return null;
517
+ const runOpts = { ...opts, host };
518
+ const handler = createBridgeHandler(bus, runOpts);
519
+ const unsubs = [];
520
+ for (const event of BRIDGE_EVENTS) {
521
+ try {
522
+ unsubs.push(bus.on(event, handler, { tier: 'fast', name: 'analysis-bridge' }));
523
+ } catch {
524
+ /* one failed subscription must not drop the rest */
525
+ }
526
+ }
527
+ try {
528
+ unsubs.push(bus.on('notification', analysisBridgeAuditSink, { tier: 'fast', name: 'analysis-bridge-audit-sink' }));
529
+ } catch {
530
+ /* sink is best-effort */
531
+ }
532
+ if (unsubs.length === 0) return null;
533
+ try {
534
+ if (host && typeof host === 'object') host.iumbtemsAnalysisBridge = { bus, events: [...BRIDGE_EVENTS] };
535
+ } catch {
536
+ /* host attach is best-effort */
537
+ }
538
+ let disposed = false;
539
+ return {
540
+ bus,
541
+ events: [...BRIDGE_EVENTS],
542
+ dispose: () => {
543
+ disposed = true;
544
+ for (const unsub of unsubs) {
545
+ try {
546
+ if (typeof unsub === 'function') unsub();
547
+ } catch {
548
+ /* dispose never throws */
549
+ }
550
+ }
551
+ try {
552
+ if (host && typeof host === 'object' && host.iumbtemsAnalysisBridge?.bus === bus) {
553
+ delete host.iumbtemsAnalysisBridge;
554
+ }
555
+ } catch {
556
+ /* dispose never throws */
557
+ }
558
+ },
559
+ get disposed() {
560
+ return disposed;
561
+ },
562
+ };
563
+ } catch {
564
+ return null;
565
+ }
566
+ }