@heretek-ai/epistemic-swarm 0.7.23 → 0.7.25

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 (108) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/package.json +1 -1
  3. package/plugins/antigravity/README.md +19 -10
  4. package/plugins/antigravity/antigravity_hooks.py +118 -20
  5. package/plugins/antigravity/commands/audit.md +6 -5
  6. package/plugins/antigravity/commands/brainstorm.md +9 -0
  7. package/plugins/antigravity/commands/brainstorming.md +6 -8
  8. package/plugins/antigravity/commands/darkharvest.md +6 -5
  9. package/plugins/antigravity/commands/domainexpansion.md +8 -5
  10. package/plugins/antigravity/commands/factory.md +10 -5
  11. package/plugins/antigravity/commands/grill.md +6 -5
  12. package/plugins/antigravity/commands/scout.md +6 -5
  13. package/plugins/antigravity/commands/swarm-config.md +4 -5
  14. package/plugins/antigravity/commands/swarm.md +6 -3
  15. package/plugins/antigravity/hook_bus_bridge.js +352 -0
  16. package/plugins/antigravity/hooks.json +1 -1
  17. package/plugins/antigravity/plugin.json +1 -1
  18. package/plugins/opencode/analysis-bridge.js +86 -32
  19. package/plugins/opencode/hook-bus.js +81 -19
  20. package/plugins/opencode/index.js +6 -9
  21. package/plugins/opencode/weight-signals.js +2 -2
  22. package/prompts/agent_brainstormer.md +29 -0
  23. package/prompts/agent_darkharvest.md +30 -0
  24. package/runner/__pycache__/__init__.cpython-311.pyc +0 -0
  25. package/runner/__pycache__/auctioneer.cpython-311.pyc +0 -0
  26. package/runner/__pycache__/auditor_engine.cpython-311.pyc +0 -0
  27. package/runner/__pycache__/claim_store.cpython-311.pyc +0 -0
  28. package/runner/__pycache__/claim_witness.cpython-311.pyc +0 -0
  29. package/runner/__pycache__/darkharvest_claims.cpython-311.pyc +0 -0
  30. package/runner/__pycache__/errors.cpython-311.pyc +0 -0
  31. package/runner/__pycache__/living_dossiers.cpython-311.pyc +0 -0
  32. package/runner/__pycache__/mcp_protocol.cpython-311.pyc +0 -0
  33. package/runner/__pycache__/mcp_server.cpython-311.pyc +0 -0
  34. package/runner/__pycache__/path_safety.cpython-311.pyc +0 -0
  35. package/runner/__pycache__/pcrb.cpython-311.pyc +0 -0
  36. package/runner/__pycache__/pcrb_verify.cpython-311.pyc +0 -0
  37. package/runner/__pycache__/preflight.cpython-311.pyc +0 -0
  38. package/runner/__pycache__/refinement.cpython-311.pyc +0 -0
  39. package/runner/__pycache__/research_swarm.cpython-311.pyc +0 -0
  40. package/runner/__pycache__/retrieval_log.cpython-311.pyc +0 -0
  41. package/runner/__pycache__/schema_validate.cpython-311.pyc +0 -0
  42. package/runner/__pycache__/schemas.cpython-311.pyc +0 -0
  43. package/runner/__pycache__/state_machine.cpython-311.pyc +0 -0
  44. package/runner/antigravity_hooks.py +118 -20
  45. package/runner/auditor_engine.py +194 -44
  46. package/runner/claim_store.py +64 -20
  47. package/runner/claim_witness.py +243 -14
  48. package/runner/pcrb.py +34 -11
  49. package/runner/refinement.py +140 -31
  50. package/runner/schemas.py +2 -0
  51. package/runner/tests/__pycache__/test_analysis_bridge.cpython-311.pyc +0 -0
  52. package/runner/tests/__pycache__/test_antigravity_plugin.cpython-311.pyc +0 -0
  53. package/runner/tests/__pycache__/test_auction_order.cpython-311.pyc +0 -0
  54. package/runner/tests/__pycache__/test_backends.cpython-311.pyc +0 -0
  55. package/runner/tests/__pycache__/test_bet1_spike.cpython-311.pyc +0 -0
  56. package/runner/tests/__pycache__/test_claim_store.cpython-311.pyc +0 -0
  57. package/runner/tests/__pycache__/test_claim_witness.cpython-311.pyc +0 -0
  58. package/runner/tests/__pycache__/test_claude_plugin.cpython-311.pyc +0 -0
  59. package/runner/tests/__pycache__/test_config_schema.cpython-311.pyc +0 -0
  60. package/runner/tests/__pycache__/test_contract_tooling.cpython-311.pyc +0 -0
  61. package/runner/tests/__pycache__/test_darkharvest_audit.cpython-311.pyc +0 -0
  62. package/runner/tests/__pycache__/test_dep_health.cpython-311.pyc +0 -0
  63. package/runner/tests/__pycache__/test_domain_packs.cpython-311.pyc +0 -0
  64. package/runner/tests/__pycache__/test_dossier_contract.cpython-311.pyc +0 -0
  65. package/runner/tests/__pycache__/test_factory.cpython-311.pyc +0 -0
  66. package/runner/tests/__pycache__/test_fleet_seam.cpython-311.pyc +0 -0
  67. package/runner/tests/__pycache__/test_frontier_concurrency.cpython-311.pyc +0 -0
  68. package/runner/tests/__pycache__/test_frontier_mirror_standalone.cpython-311.pyc +0 -0
  69. package/runner/tests/__pycache__/test_grounding.cpython-311.pyc +0 -0
  70. package/runner/tests/__pycache__/test_hardening_contract.cpython-311.pyc +0 -0
  71. package/runner/tests/__pycache__/test_hook_bus.cpython-311.pyc +0 -0
  72. package/runner/tests/__pycache__/test_living_dossiers.cpython-311.pyc +0 -0
  73. package/runner/tests/__pycache__/test_manifest_concurrency.cpython-311.pyc +0 -0
  74. package/runner/tests/__pycache__/test_mcp_server.cpython-311.pyc +0 -0
  75. package/runner/tests/__pycache__/test_nk_hardening.cpython-311.pyc +0 -0
  76. package/runner/tests/__pycache__/test_opencode_ux.cpython-311.pyc +0 -0
  77. package/runner/tests/__pycache__/test_opencode_v2_registrars.cpython-311.pyc +0 -0
  78. package/runner/tests/__pycache__/test_pcrb.cpython-311.pyc +0 -0
  79. package/runner/tests/__pycache__/test_phase08_waiver_reopens.cpython-311.pyc +0 -0
  80. package/runner/tests/__pycache__/test_refinement.cpython-311.pyc +0 -0
  81. package/runner/tests/__pycache__/test_search_anomaly.cpython-311.pyc +0 -0
  82. package/runner/tests/__pycache__/test_search_provider.cpython-311.pyc +0 -0
  83. package/runner/tests/__pycache__/test_search_provider_plugin.cpython-311.pyc +0 -0
  84. package/runner/tests/__pycache__/test_swarm.cpython-311.pyc +0 -0
  85. package/runner/tests/__pycache__/test_swarm_config.cpython-311.pyc +0 -0
  86. package/runner/tests/__pycache__/test_sweep_regressions.cpython-311.pyc +0 -0
  87. package/runner/tests/__pycache__/test_webcache.cpython-311.pyc +0 -0
  88. package/runner/tests/__pycache__/test_weight_signals.cpython-311.pyc +0 -0
  89. package/runner/tests/test_analysis_bridge.py +95 -1
  90. package/runner/tests/test_antigravity_plugin.py +164 -9
  91. package/runner/tests/test_dep_health.py +111 -0
  92. package/runner/tests/test_hook_bus.py +161 -7
  93. package/runner/tests/test_nk_hardening.py +1046 -0
  94. package/runner/tests/test_weight_signals.py +40 -0
  95. package/schemas/audit_report.schema.json +3 -0
  96. package/scripts/__pycache__/bet1_advisory_spike.cpython-311.pyc +0 -0
  97. package/scripts/__pycache__/build_adapters.cpython-311.pyc +0 -0
  98. package/scripts/__pycache__/check_docs.cpython-311.pyc +0 -0
  99. package/scripts/__pycache__/divergence_experiment.cpython-311.pyc +0 -0
  100. package/scripts/__pycache__/gen_schemas.cpython-311.pyc +0 -0
  101. package/scripts/build_adapters.py +1 -0
  102. package/skills/epistemic_search/scripts/__pycache__/search.cpython-311.pyc +0 -0
  103. package/skills/grilling/__pycache__/__init__.cpython-311.pyc +0 -0
  104. package/skills/grilling/__pycache__/socratic_tree.cpython-311.pyc +0 -0
  105. package/skills/research_cache/__pycache__/__init__.cpython-311.pyc +0 -0
  106. package/skills/research_cache/__pycache__/hasher.cpython-311.pyc +0 -0
  107. package/skills/swarm_config/__pycache__/__init__.cpython-311.pyc +0 -0
  108. package/skills/swarm_config/__pycache__/configure.cpython-311.pyc +0 -0
@@ -0,0 +1,352 @@
1
+ /**
2
+ * Antigravity Hook Bus & Quality Gates Bridge.
3
+ *
4
+ * Connects Antigravity's lifecycle events (PreToolUse, PostToolUse, PreInvocation, Stop)
5
+ * to IUMBTEMS's 6-event Hook Bus and the three automated Quality Gates:
6
+ * 1. Analysis Bridge (Biome CLI static analysis for JS/TS)
7
+ * 2. Dependency Health Gate (OSV.dev vulnerability & freshness scanner)
8
+ * 3. Weight Signals (esbuild bundle weight & coverage deltas vs baseline)
9
+ *
10
+ * Also provides:
11
+ * - Pre-commit gating on .research/config.json with optimistic concurrency (expected_hash)
12
+ * - Session context & compaction preservation (buildCompactionContext)
13
+ * - Living dossier claim degradation stop-gating
14
+ *
15
+ * Contract:
16
+ * - Zero runtime dependencies; node builtins only.
17
+ * - Fail-open: internal errors log and allow, never throwing into host.
18
+ */
19
+
20
+ import {
21
+ createHookBus,
22
+ gatePreCommit,
23
+ FAST_TIMEOUT_MS,
24
+ SLOW_TIMEOUT_MS,
25
+ normalizeVerdictEx,
26
+ } from '../opencode/hook-bus.js';
27
+ import { registerAnalysisBridge } from '../opencode/analysis-bridge.js';
28
+ import { registerDepHealth } from '../opencode/dep-health.js';
29
+ import { registerWeightSignals } from '../opencode/weight-signals.js';
30
+ import { buildCompactionContext, readLedgerDegraded } from '../opencode/index.js';
31
+ import { loadConfig, validateConfig } from '../opencode/config-io.js';
32
+ import { existsSync, readFileSync, appendFileSync, writeFileSync, unlinkSync } from 'node:fs';
33
+ import path from 'node:path';
34
+ import { fileURLToPath } from 'node:url';
35
+
36
+ const __filename = fileURLToPath(import.meta.url);
37
+ const __dirname = path.dirname(__filename);
38
+ const PROJECT_ROOT = path.resolve(__dirname, '../..');
39
+
40
+ export function resolveWorkspaceRoot(payload = {}) {
41
+ const ws = payload.workspacePaths || [];
42
+ if (Array.isArray(ws) && ws.length > 0 && ws[0]) {
43
+ return path.resolve(ws[0]);
44
+ }
45
+ if (payload.workspaceRoot) {
46
+ return path.resolve(payload.workspaceRoot);
47
+ }
48
+ if (process.env.IUMBTEMS_WORKSPACE) {
49
+ return path.resolve(process.env.IUMBTEMS_WORKSPACE);
50
+ }
51
+ if (process.env.IUMBTEMS_ROOT) {
52
+ return path.resolve(process.env.IUMBTEMS_ROOT);
53
+ }
54
+ let curr = path.resolve(process.cwd());
55
+ while (curr !== path.dirname(curr)) {
56
+ if (existsSync(path.join(curr, '.research')) || existsSync(path.join(curr, '.git'))) {
57
+ return curr;
58
+ }
59
+ curr = path.dirname(curr);
60
+ }
61
+ return path.resolve(process.cwd());
62
+ }
63
+
64
+ /**
65
+ * Instantiate a hook bus populated with all quality gate subscribers.
66
+ */
67
+ export async function createConfiguredBus(root) {
68
+ const bus = createHookBus();
69
+ const mockHost = {
70
+ location: { directory: root },
71
+ session: {
72
+ hook: () => ({ dispose: () => {} }),
73
+ prompt: () => Promise.resolve(),
74
+ },
75
+ };
76
+
77
+ try {
78
+ await registerAnalysisBridge(mockHost, { bus, root });
79
+ } catch {
80
+ /* fail open */
81
+ }
82
+
83
+ try {
84
+ await registerDepHealth(mockHost, { bus, root });
85
+ } catch {
86
+ /* fail open */
87
+ }
88
+
89
+ try {
90
+ await registerWeightSignals(mockHost, { bus });
91
+ } catch {
92
+ /* fail open */
93
+ }
94
+
95
+ return bus;
96
+ }
97
+
98
+ /**
99
+ * Handle pre-tool-use:
100
+ * - Check pre-commit gating on .research/config.json
101
+ * - Emit pre-tool-use on the bus (Analysis Bridge)
102
+ */
103
+ export async function handlePreTool(payload = {}) {
104
+ const root = resolveWorkspaceRoot(payload);
105
+ const toolCall = payload.toolCall || {};
106
+ const toolName = String(toolCall.name || '').toLowerCase();
107
+ const args = toolCall.args || {};
108
+
109
+ // Check if tool is attempting to edit .research/config.json
110
+ const targetFile = String(args.TargetFile || args.file || args.path || '');
111
+ const isConfigTarget =
112
+ targetFile.endsWith('.research/config.json') ||
113
+ targetFile === '.research/config.json' ||
114
+ (targetFile.includes('.research') && targetFile.includes('config.json'));
115
+
116
+ if (isConfigTarget && (toolName === 'write_to_file' || toolName === 'replace_file_content')) {
117
+ const bus = createHookBus();
118
+ const content = args.CodeContent || args.ReplacementContent || '';
119
+ let parsedUpdates = null;
120
+ try {
121
+ parsedUpdates = JSON.parse(content);
122
+ } catch {
123
+ // If content is not full JSON (e.g. replace_file_content chunk), attempt validation if possible
124
+ }
125
+
126
+ if (parsedUpdates && typeof parsedUpdates === 'object') {
127
+ const problems = validateConfig(parsedUpdates);
128
+ if (problems && problems.length > 0) {
129
+ return {
130
+ decision: 'deny',
131
+ allowed: false,
132
+ reason: `Pre-commit validation rejected .research/config.json update: ${problems.join('; ')}`,
133
+ };
134
+ }
135
+
136
+ const preCommitResult = await gatePreCommit(bus, {
137
+ updates: parsedUpdates,
138
+ root,
139
+ expected_hash: args.expected_hash || args.expectedHash || parsedUpdates.expected_hash || parsedUpdates.expectedHash,
140
+ });
141
+
142
+ if (!preCommitResult.allowed) {
143
+ return {
144
+ decision: 'deny',
145
+ allowed: false,
146
+ reason: `Pre-commit gate denied config write: ${preCommitResult.reason || 'optimistic concurrency or validation failure'}`,
147
+ };
148
+ }
149
+ }
150
+ }
151
+
152
+ const bus = await createConfiguredBus(root);
153
+ const busResult = await bus.emit('pre-tool-use', {
154
+ tool: toolName,
155
+ args,
156
+ sessionID: payload.conversationId,
157
+ targetFile,
158
+ });
159
+
160
+ if (!busResult.allowed) {
161
+ return {
162
+ decision: 'deny',
163
+ allowed: false,
164
+ reason: busResult.reason || 'Blocked by Hook Bus pre-tool-use policy',
165
+ annotations: busResult.annotations || [],
166
+ };
167
+ }
168
+
169
+ return {
170
+ decision: 'allow',
171
+ allowed: true,
172
+ annotations: busResult.annotations || [],
173
+ };
174
+ }
175
+
176
+ /**
177
+ * Handle post-tool-use:
178
+ * - Emit post-tool-use on the bus (triggers Analysis Bridge, Dep Health, Weight Signals)
179
+ * - Persist annotations to .research/quality_gates.jsonl
180
+ */
181
+ export async function handlePostTool(payload = {}) {
182
+ const root = resolveWorkspaceRoot(payload);
183
+ const toolCall = payload.toolCall || {};
184
+ const toolName = String(toolCall.name || '').toLowerCase();
185
+ const args = toolCall.args || {};
186
+ const targetFile = String(args.TargetFile || args.file || args.path || '');
187
+
188
+ const bus = await createConfiguredBus(root);
189
+ const busResult = await bus.emit('post-tool-use', {
190
+ tool: toolName,
191
+ args,
192
+ result: payload.result,
193
+ error: payload.error,
194
+ sessionID: payload.conversationId,
195
+ targetFile,
196
+ });
197
+
198
+ const annotations = busResult.annotations || [];
199
+ if (annotations.length > 0) {
200
+ try {
201
+ const qgLog = path.join(root, '.research', 'quality_gates.jsonl');
202
+ const lines = annotations.map((a) =>
203
+ JSON.stringify({
204
+ at: new Date().toISOString(),
205
+ stepIdx: payload.stepIdx,
206
+ tool: toolName,
207
+ annotation: a,
208
+ })
209
+ );
210
+ appendFileSync(qgLog, lines.join('\n') + '\n', 'utf-8');
211
+ } catch {
212
+ /* ignore file log error */
213
+ }
214
+ }
215
+
216
+ return {
217
+ allowed: busResult.allowed !== false,
218
+ annotations,
219
+ };
220
+ }
221
+
222
+ /**
223
+ * Handle pre-invocation:
224
+ * - Return compaction context from buildCompactionContext
225
+ * - Collect and clear any pending quality gate annotations
226
+ */
227
+ export async function handlePreInvocation(payload = {}) {
228
+ const root = resolveWorkspaceRoot(payload);
229
+ const compaction = buildCompactionContext(root);
230
+
231
+ let pendingAnnotations = [];
232
+ const qgLog = path.join(root, '.research', 'quality_gates.jsonl');
233
+ if (existsSync(qgLog)) {
234
+ try {
235
+ const content = readFileSync(qgLog, 'utf-8');
236
+ const lines = content.trim().split('\n').filter(Boolean);
237
+ for (const line of lines) {
238
+ try {
239
+ const entry = JSON.parse(line);
240
+ if (entry.annotation) {
241
+ pendingAnnotations.push(entry.annotation);
242
+ }
243
+ } catch {
244
+ /* ignore bad json line */
245
+ }
246
+ }
247
+ // Truncate the file so annotations are reported once
248
+ writeFileSync(qgLog, '', 'utf-8');
249
+ } catch {
250
+ /* fail open */
251
+ }
252
+ }
253
+
254
+ return {
255
+ compactionContext: compaction,
256
+ annotations: pendingAnnotations,
257
+ };
258
+ }
259
+
260
+ /**
261
+ * Handle stop:
262
+ * - Emit stop on the bus
263
+ * - Check degraded claims in .research/ledger/claim_status.json
264
+ */
265
+ export async function handleStop(payload = {}) {
266
+ const root = resolveWorkspaceRoot(payload);
267
+
268
+ // If a STOP file was placed, allow immediately
269
+ if (existsSync(path.join(root, '.factory', 'STOP'))) {
270
+ return { decision: 'allow', allowed: true };
271
+ }
272
+
273
+ const bus = await createConfiguredBus(root);
274
+ const busResult = await bus.emit('stop', {
275
+ sessionID: payload.conversationId,
276
+ terminationReason: payload.terminationReason,
277
+ });
278
+
279
+ if (!busResult.allowed) {
280
+ return {
281
+ decision: 'continue',
282
+ allowed: false,
283
+ reason: busResult.reason || 'Blocked by Hook Bus stop policy',
284
+ };
285
+ }
286
+
287
+ const degraded = readLedgerDegraded(root);
288
+ const total = (degraded.stale || []).length + (degraded.suspect || []).length;
289
+ if (total > 0) {
290
+ return {
291
+ decision: 'continue',
292
+ allowed: false,
293
+ reason: `Epistemic audit warning: ${total} claims are degraded (${degraded.stale.length} STALE, ${degraded.suspect.length} SUSPECT) due to source retractions. Review before finishing.`,
294
+ };
295
+ }
296
+
297
+ return { decision: 'allow', allowed: true };
298
+ }
299
+
300
+ /**
301
+ * CLI dispatcher for subprocess calls.
302
+ */
303
+ async function main() {
304
+ const action = process.argv[2] || 'pre_tool';
305
+ let inputData = '';
306
+
307
+ if (process.argv[3]) {
308
+ inputData = process.argv[3];
309
+ } else {
310
+ try {
311
+ inputData = readFileSync(0, 'utf-8');
312
+ } catch {
313
+ inputData = '{}';
314
+ }
315
+ }
316
+
317
+ let payload = {};
318
+ try {
319
+ payload = JSON.parse(inputData.trim() || '{}');
320
+ } catch {
321
+ payload = {};
322
+ }
323
+
324
+ let result = {};
325
+ try {
326
+ if (action === 'pre_tool' || action === 'pre-tool-use') {
327
+ result = await handlePreTool(payload);
328
+ } else if (action === 'post_tool' || action === 'post-tool-use') {
329
+ result = await handlePostTool(payload);
330
+ } else if (action === 'pre_invocation' || action === 'session-start') {
331
+ result = await handlePreInvocation(payload);
332
+ } else if (action === 'stop') {
333
+ result = await handleStop(payload);
334
+ } else {
335
+ result = { decision: 'allow', allowed: true };
336
+ }
337
+ } catch (err) {
338
+ result = {
339
+ decision: 'allow',
340
+ allowed: true,
341
+ error: String(err?.message || err),
342
+ };
343
+ }
344
+
345
+ process.stdout.write(JSON.stringify(result));
346
+ }
347
+
348
+ if (process.argv[1] && process.argv[1].endsWith('hook_bus_bridge.js')) {
349
+ main().catch(() => {
350
+ process.stdout.write(JSON.stringify({ decision: 'allow', allowed: true }));
351
+ });
352
+ }
@@ -2,7 +2,7 @@
2
2
  "epistemic-query-interceptor": {
3
3
  "PreToolUse": [
4
4
  {
5
- "matcher": "search_web|read_url_content",
5
+ "matcher": "*",
6
6
  "hooks": [
7
7
  {
8
8
  "type": "command",
@@ -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.23",
4
+ "version": "0.7.25",
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",
@@ -22,8 +22,8 @@
22
22
  * pinned devDependency (Biome, single dep, zero-config, lint+format JS/TS).
23
23
  *
24
24
  * Phase evidence (cited per programmer brief):
25
- * - Bus contract (six blockable events, tiered enforcement, fail-open,
26
- * tool/eventId audit guarantees)
25
+ * - Bus contract (six events, 3 host-enforced / 1 advisory / 2 bus-level,
26
+ * tiered enforcement, fail-open, tool/eventId audit guarantees)
27
27
  * [VERIFIED: sha256:f2c0843b598ba6f19cc0e208f8d02e0250c70615e3c484474d50635d0b5cdef8
28
28
  * `file:///home/john/Projects/IUMBTEMS/plugins/opencode/hook-bus.js`]
29
29
  * - Host emit points (pre/post-tool-use own-tools scope, pre-commit both
@@ -44,7 +44,7 @@
44
44
  */
45
45
 
46
46
  import { spawn } from 'node:child_process';
47
- import { existsSync } from 'node:fs';
47
+ import { existsSync, realpathSync } from 'node:fs';
48
48
  import path from 'node:path';
49
49
  import { fileURLToPath } from 'node:url';
50
50
  import { FAST_TIMEOUT_MS } from './hook-bus.js';
@@ -84,10 +84,32 @@ export function isJsTsFile(p) {
84
84
  return typeof p === 'string' && /\.(m|c)?[jt]sx?$/i.test(p.trim());
85
85
  }
86
86
 
87
- function pushCandidate(out, seen, v) {
87
+ function pushCandidate(out, seen, v, root, skipped) {
88
88
  if (typeof v !== 'string') return;
89
89
  const t = v.trim();
90
- if (!t || !isJsTsFile(t) || seen.has(t)) return;
90
+ if (!t || t.includes('\0') || t.startsWith('-')) return;
91
+ if (!isJsTsFile(t)) return;
92
+ const resolvedRoot = root ? path.resolve(root) : process.cwd();
93
+ const abs = path.resolve(resolvedRoot, t);
94
+ const rel = path.relative(resolvedRoot, abs);
95
+ if (rel.startsWith('..') || path.isAbsolute(rel)) {
96
+ if (skipped) skipped.push(t);
97
+ return;
98
+ }
99
+ try {
100
+ if (existsSync(abs)) {
101
+ const real = realpathSync(abs);
102
+ const realRoot = realpathSync(resolvedRoot);
103
+ const realRel = path.relative(realRoot, real);
104
+ if (realRel.startsWith('..') || path.isAbsolute(realRel)) {
105
+ if (skipped) skipped.push(t);
106
+ return;
107
+ }
108
+ }
109
+ } catch {
110
+ /* fail safe */
111
+ }
112
+ if (seen.has(t)) return;
91
113
  seen.add(t);
92
114
  out.push(t);
93
115
  }
@@ -97,28 +119,31 @@ function pushCandidate(out, seen, v) {
97
119
  * conventional locator fields (`file`, `files`, `path`, `paths`,
98
120
  * `filename`, `filenames`) plus a one-level scan of `args` values (plain
99
121
  * strings or arrays of strings, e.g. an edit tool's file argument).
100
- * De-duplicated, capped at `MAX_TARGET_FILES`. Never throws.
122
+ * Confines targets to the project root, rejects option-like flags (`-x`),
123
+ * de-duplicates, and caps at `MAX_TARGET_FILES`. Never throws.
101
124
  */
102
- export function collectTargets(payload) {
125
+ export function collectTargets(payload, opts = {}) {
103
126
  try {
104
127
  const out = [];
105
128
  const seen = new Set();
129
+ const skipped = Array.isArray(opts?.skipped) ? opts.skipped : null;
130
+ const root = opts?.root || opts?.cwd || process.cwd();
106
131
  if (!payload || typeof payload !== 'object') return out;
107
132
  for (const key of ['file', 'files', 'path', 'paths', 'filename', 'filenames']) {
108
133
  const v = payload[key];
109
134
  if (Array.isArray(v)) {
110
- for (const item of v) pushCandidate(out, seen, item);
135
+ for (const item of v) pushCandidate(out, seen, item, root, skipped);
111
136
  } else {
112
- pushCandidate(out, seen, v);
137
+ pushCandidate(out, seen, v, root, skipped);
113
138
  }
114
139
  }
115
140
  const args = payload.args;
116
141
  if (args && typeof args === 'object') {
117
142
  for (const v of Object.values(args)) {
118
143
  if (Array.isArray(v)) {
119
- for (const item of v) pushCandidate(out, seen, item);
144
+ for (const item of v) pushCandidate(out, seen, item, root, skipped);
120
145
  } else {
121
- pushCandidate(out, seen, v);
146
+ pushCandidate(out, seen, v, root, skipped);
122
147
  }
123
148
  }
124
149
  }
@@ -187,8 +212,16 @@ function errorFinding(tool, eventId, targets, message) {
187
212
  });
188
213
  }
189
214
 
190
- /** Locate the Biome binary: repo devDep first, then PATH fallback. */
191
- export function findAnalyzerBin() {
215
+ /** Locate the Biome binary: project devDep first, then repo devDep, then PATH fallback. */
216
+ export function findAnalyzerBin(root) {
217
+ try {
218
+ if (root) {
219
+ const projectLocal = path.join(root, 'node_modules', '.bin', 'biome');
220
+ if (existsSync(projectLocal)) return projectLocal;
221
+ }
222
+ } catch {
223
+ /* fall through */
224
+ }
192
225
  try {
193
226
  const local = path.join(PKG_ROOT, 'node_modules', '.bin', 'biome');
194
227
  if (existsSync(local)) return local;
@@ -241,7 +274,7 @@ export function defaultRunAnalyzer(files, opts = {}) {
241
274
  let child;
242
275
  try {
243
276
  child = spawn(
244
- findAnalyzerBin(),
277
+ findAnalyzerBin(opts.root || opts.cwd),
245
278
  [
246
279
  'lint',
247
280
  '--reporter=json',
@@ -249,6 +282,7 @@ export function defaultRunAnalyzer(files, opts = {}) {
249
282
  '--diagnostic-level=info',
250
283
  '--colors=off',
251
284
  '--files-ignore-unknown=true',
285
+ '--',
252
286
  ...files,
253
287
  ],
254
288
  { cwd: opts.cwd || process.cwd() },
@@ -319,37 +353,50 @@ export async function analyzePayload(payload, opts = {}) {
319
353
  try {
320
354
  const tool = resolveTool(payload);
321
355
  const eventId = resolveEventId(payload);
322
- const targets = collectTargets(payload);
323
- if (targets.length === 0) return { allowed: true, annotations: [] };
356
+ const root = opts.root || opts.cwd || process.cwd();
357
+ const skipped = [];
358
+ const targets = collectTargets(payload, { root, skipped });
359
+ const skippedAnnotations = skipped.slice(0, 10).map((s) => toFinding({
360
+ tool,
361
+ eventId,
362
+ rule: 'analysis-bridge/skipped-target',
363
+ file: s,
364
+ line: null,
365
+ message: `target path outside project root or invalid: ${s} (skipped)`,
366
+ severity: 'info',
367
+ }));
368
+
369
+ if (targets.length === 0) return { allowed: true, annotations: skippedAnnotations };
324
370
  const timeoutMs = Number(opts.timeoutMs ?? ANALYZER_TIMEOUT_MS);
325
371
  const budget = Number.isFinite(timeoutMs) && timeoutMs >= 0 ? timeoutMs : ANALYZER_TIMEOUT_MS;
326
372
  const runAnalyzer = typeof opts.runAnalyzer === 'function' ? opts.runAnalyzer : defaultRunAnalyzer;
327
373
  const res = await runBounded(
328
- runAnalyzer(targets, { timeoutMs: budget, cwd: opts.cwd }),
374
+ runAnalyzer(targets, { timeoutMs: budget, cwd: opts.cwd, root }),
329
375
  budget,
330
376
  );
331
377
  if (!res || typeof res !== 'object') {
332
- return { allowed: true, annotations: [errorFinding(tool, eventId, targets, 'malformed analyzer result')] };
378
+ return { allowed: true, annotations: [...skippedAnnotations, errorFinding(tool, eventId, targets, 'malformed analyzer result')] };
333
379
  }
334
- if (res.status === 'missing') return { allowed: true, annotations: [] };
380
+ if (res.status === 'missing') return { allowed: true, annotations: skippedAnnotations };
335
381
  if (res.status === 'timeout') {
336
- return { allowed: true, annotations: [timeoutFinding(tool, eventId, targets, budget)] };
382
+ return { allowed: true, annotations: [...skippedAnnotations, timeoutFinding(tool, eventId, targets, budget)] };
337
383
  }
338
384
  if (res.status === 'error') {
339
- return { allowed: true, annotations: [errorFinding(tool, eventId, targets, res.message || 'unknown error')] };
385
+ return { allowed: true, annotations: [...skippedAnnotations, errorFinding(tool, eventId, targets, res.message || 'unknown error')] };
340
386
  }
341
387
  if (res.status === 'ok' && Array.isArray(res.findings)) {
342
388
  const findings = res.findings
389
+ .filter((f) => f && typeof f === 'object' && !Array.isArray(f))
343
390
  .slice(0, MAX_FINDINGS)
344
391
  .map((f) => toFinding({ ...f, tool: f?.tool ?? tool, eventId: f?.eventId ?? eventId }));
345
- return { allowed: true, annotations: findings };
392
+ return { allowed: true, annotations: [...skippedAnnotations, ...findings] };
346
393
  }
347
394
  if (typeof res.stdout === 'string') {
348
395
  // Default-runner raw form: parse Biome JSON reporter output here so
349
396
  // injected mocks may return either parsed findings or raw stdout.
350
- return { allowed: true, annotations: parseBiomeJson(res.stdout, tool, eventId) };
397
+ return { allowed: true, annotations: [...skippedAnnotations, ...parseBiomeJson(res.stdout, tool, eventId)] };
351
398
  }
352
- return { allowed: true, annotations: [errorFinding(tool, eventId, targets, 'malformed analyzer result')] };
399
+ return { allowed: true, annotations: [...skippedAnnotations, errorFinding(tool, eventId, targets, 'malformed analyzer result')] };
353
400
  } catch (err) {
354
401
  // Last resort: fail-open allow with a single error annotation.
355
402
  try {
@@ -433,7 +480,7 @@ export function createBridgeHandler(bus, opts = {}) {
433
480
  const result = await analyzePayload(payload, runOpts);
434
481
  const annotations = Array.isArray(result?.annotations) ? result.annotations : [];
435
482
  if (annotations.length === 0) {
436
- const targets = collectTargets(payload);
483
+ const targets = collectTargets(payload, runOpts);
437
484
  if (targets.length === 0) {
438
485
  return { verdict: 'allow', reason: 'analysis-bridge: no js/ts targets; skipped' };
439
486
  }
@@ -461,14 +508,21 @@ export function createBridgeHandler(bus, opts = {}) {
461
508
  }
462
509
  }
463
510
  }
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
- });
511
+ const nonSkipped = annotations.filter((f) => f.rule !== 'analysis-bridge/skipped-target');
512
+ if (nonSkipped.length > 0) {
513
+ const files = new Set(nonSkipped.map((f) => f.file).filter(Boolean));
514
+ notifySummary(runOpts, {
515
+ title: 'IUMBTEMS analysis',
516
+ message: `analysis-bridge: ${nonSkipped.length} finding(s) in ${files.size} file(s) (advisory; action allowed)`,
517
+ });
518
+ return {
519
+ verdict: 'advisory',
520
+ reason: `analysis-bridge: ${nonSkipped.length} finding(s) in ${files.size} file(s) (advisory; action allowed)`,
521
+ };
522
+ }
469
523
  return {
470
- verdict: 'advisory',
471
- reason: `analysis-bridge: ${annotations.length} finding(s) in ${files.size} file(s) (advisory; action allowed)`,
524
+ verdict: 'allow',
525
+ reason: `analysis-bridge: ${annotations.length} target(s) skipped (advisory; action allowed)`,
472
526
  };
473
527
  } catch {
474
528
  return { verdict: 'allow', reason: 'analysis-bridge: internal fault; fail-open allow' };