solidity-argus 0.3.7 → 0.5.6

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 (107) hide show
  1. package/AGENTS.md +13 -6
  2. package/README.md +24 -12
  3. package/package.json +7 -3
  4. package/skills/checklists/cyfrin-best-practices-runtime/SKILL.md +1 -0
  5. package/skills/checklists/cyfrin-best-practices-upgrades/SKILL.md +1 -0
  6. package/skills/checklists/cyfrin-defi-core/SKILL.md +1 -0
  7. package/skills/checklists/cyfrin-defi-integrations/SKILL.md +1 -0
  8. package/skills/checklists/cyfrin-gas/SKILL.md +1 -0
  9. package/skills/checklists/general-audit/SKILL.md +1 -0
  10. package/skills/methodology/audit-workflow/SKILL.md +1 -0
  11. package/skills/methodology/report-template/SKILL.md +1 -0
  12. package/skills/methodology/severity-classification/SKILL.md +1 -0
  13. package/skills/protocol-patterns/amm-dex/SKILL.md +1 -0
  14. package/skills/protocol-patterns/bridges-cross-chain/SKILL.md +1 -0
  15. package/skills/protocol-patterns/dao-governance/SKILL.md +1 -0
  16. package/skills/protocol-patterns/lending-borrowing/SKILL.md +1 -0
  17. package/skills/protocol-patterns/staking-vesting/SKILL.md +1 -0
  18. package/skills/vulnerability-patterns/flash-loan-attacks/SKILL.md +0 -50
  19. package/skills/vulnerability-patterns/oracle-manipulation/SKILL.md +0 -63
  20. package/src/agents/argus-prompt.ts +98 -33
  21. package/src/agents/pythia-prompt.ts +18 -1
  22. package/src/agents/scribe-prompt.ts +32 -10
  23. package/src/agents/sentinel-prompt.ts +19 -0
  24. package/src/agents/themis-prompt.ts +110 -0
  25. package/src/cli/commands/doctor.ts +29 -17
  26. package/src/config/loader.ts +29 -5
  27. package/src/config/schema.ts +45 -45
  28. package/src/constants/defaults.ts +1 -0
  29. package/src/create-hooks.ts +797 -148
  30. package/src/create-managers.ts +4 -2
  31. package/src/create-tools.ts +5 -1
  32. package/src/features/audit-enforcer/audit-enforcer.ts +1 -11
  33. package/src/features/background-agent/background-manager.ts +32 -5
  34. package/src/features/error-recovery/tool-error-recovery.ts +1 -0
  35. package/src/features/persistent-state/audit-state-manager.ts +272 -29
  36. package/src/features/persistent-state/event-sink.ts +96 -25
  37. package/src/features/persistent-state/findings-materializer.ts +34 -2
  38. package/src/features/persistent-state/global-run-index.ts +86 -8
  39. package/src/features/persistent-state/index.ts +7 -1
  40. package/src/features/persistent-state/run-finalizer.ts +116 -7
  41. package/src/features/persistent-state/run-pruner.ts +93 -0
  42. package/src/hooks/agent-tracker.ts +14 -2
  43. package/src/hooks/compaction-hook.ts +7 -16
  44. package/src/hooks/config-handler.ts +83 -29
  45. package/src/hooks/context-budget.ts +4 -5
  46. package/src/hooks/event-hook.ts +213 -57
  47. package/src/hooks/knowledge-sync-hook.ts +2 -3
  48. package/src/hooks/safe-create-hook.ts +13 -1
  49. package/src/hooks/system-prompt-hook.ts +20 -39
  50. package/src/hooks/tool-tracking-hook.ts +597 -323
  51. package/src/index.ts +15 -1
  52. package/src/knowledge/scvd-client.ts +2 -4
  53. package/src/knowledge/scvd-errors.ts +25 -2
  54. package/src/knowledge/scvd-index.ts +7 -5
  55. package/src/knowledge/scvd-sync.ts +6 -6
  56. package/src/managers/types.ts +20 -2
  57. package/src/shared/agent-names.ts +23 -0
  58. package/src/shared/audit-artifact-resolver.ts +8 -3
  59. package/src/shared/audit-phases.ts +12 -0
  60. package/src/shared/cache-paths.ts +41 -0
  61. package/src/shared/drop-diagnostics.ts +2 -2
  62. package/src/shared/forge-errors.ts +31 -0
  63. package/src/shared/forge-runner.ts +30 -0
  64. package/src/shared/format-error.ts +3 -0
  65. package/src/shared/index.ts +9 -0
  66. package/src/shared/key-tools.ts +39 -0
  67. package/src/shared/logger.ts +7 -7
  68. package/src/shared/path-containment.ts +25 -0
  69. package/src/shared/path-utils.ts +11 -0
  70. package/src/shared/report-path-resolver.ts +4 -2
  71. package/src/shared/safe-emit.ts +24 -0
  72. package/src/shared/token-utils.ts +5 -0
  73. package/src/shared/type-guards.ts +8 -0
  74. package/src/shared/validation-constants.ts +52 -0
  75. package/src/skills/analysis/cluster.ts +1 -114
  76. package/src/skills/analysis/normalize.ts +2 -114
  77. package/src/skills/analysis/stopwords.ts +109 -0
  78. package/src/skills/argus-skill-resolver.ts +6 -3
  79. package/src/solodit-lifecycle.ts +153 -37
  80. package/src/state/adapters.ts +60 -66
  81. package/src/state/finding-aggregation.ts +6 -8
  82. package/src/state/finding-fingerprint.ts +1 -1
  83. package/src/state/finding-store.ts +31 -9
  84. package/src/state/index.ts +1 -1
  85. package/src/state/projectors.ts +27 -19
  86. package/src/state/schemas.ts +8 -32
  87. package/src/state/types.ts +3 -0
  88. package/src/tools/contract-analyzer-tool.ts +4 -6
  89. package/src/tools/forge-coverage-tool.ts +10 -35
  90. package/src/tools/forge-fuzz-tool.ts +21 -51
  91. package/src/tools/forge-test-tool.ts +25 -47
  92. package/src/tools/gas-analysis-tool.ts +12 -41
  93. package/src/tools/pattern-checker-tool.ts +37 -15
  94. package/src/tools/pattern-loader.ts +18 -4
  95. package/src/tools/persist-deduped-tool.ts +94 -0
  96. package/src/tools/proxy-detection-tool.ts +35 -34
  97. package/src/tools/read-findings-tool.ts +390 -0
  98. package/src/tools/record-finding-tool.ts +120 -25
  99. package/src/tools/report-generator-tool.ts +394 -328
  100. package/src/tools/report-preflight.ts +5 -1
  101. package/src/tools/slither-tool.ts +55 -16
  102. package/src/tools/solodit-search-tool.ts +260 -112
  103. package/src/tools/sync-knowledge-tool.ts +2 -3
  104. package/src/utils/solidity-parser.ts +39 -24
  105. package/src/features/migration/index.ts +0 -14
  106. package/src/features/migration/migration-adapter.ts +0 -151
  107. package/src/features/migration/parity-telemetry.ts +0 -133
@@ -1,5 +1,7 @@
1
1
  import { randomUUID } from "node:crypto"
2
2
  import type { EventSink } from "../features/persistent-state/event-sink"
3
+ import { isArgusFamily } from "../shared/agent-names"
4
+ import { PHASE_ORDER } from "../shared/audit-phases"
3
5
  import type {
4
6
  DropDiagnostic,
5
7
  DropDiagnosticsCollector,
@@ -7,6 +9,8 @@ import type {
7
9
  } from "../shared/drop-diagnostics"
8
10
  import { createDropDiagnosticsCollector } from "../shared/drop-diagnostics"
9
11
  import { createLogger } from "../shared/logger"
12
+ import { normalizeFilePath } from "../shared/path-utils"
13
+ import { safeEmitToSink } from "../shared/safe-emit"
10
14
  import { normalizeToCanonicalFinding } from "../state/adapters"
11
15
  import type { FindingStore } from "../state/finding-store"
12
16
  import { createFindingStore } from "../state/finding-store"
@@ -34,15 +38,20 @@ type ToolHookInput = {
34
38
  type ToolExecutionMetadata = {
35
39
  tool: string
36
40
  findingsCount: number
41
+ sessionId?: string
37
42
  }
38
43
 
39
44
  export type ToolTrackingOptions = {
40
45
  getEventSink?: () => EventSink | null
46
+ getEventSinkForSession?: (sessionId: string) => EventSink | null
47
+ getEventSinkForRun?: (runId: string) => EventSink | null
48
+ getActiveRunSinks?: () => EventSink[]
41
49
  getSessionId?: () => string
42
50
  getAgentName?: () => ArgusAgentName | undefined
43
51
  getAgentNameForSession?: (sessionId: string) => ArgusAgentName | undefined
44
52
  dropPolicy?: DropPolicy
45
53
  onChildSessionDetected?: (parentSessionId: string, childSessionId: string) => void
54
+ projectDir?: string
46
55
  }
47
56
 
48
57
  const VALID_SEVERITIES: ReadonlySet<string> = new Set([
@@ -103,22 +112,7 @@ function toFindingSource(value: unknown): Finding["source"] {
103
112
  return "manual"
104
113
  }
105
114
 
106
- async function emitToSink(
107
- sink: EventSink,
108
- event: AuditEvent,
109
- options?: { failFast?: boolean },
110
- ): Promise<void> {
111
- try {
112
- await sink.append(event)
113
- } catch (error) {
114
- const message = `Failed to emit ${event.type} event to sink: ${error instanceof Error ? error.message : String(error)}`
115
- logger.error(message)
116
-
117
- if (options?.failFast) {
118
- throw new Error(message)
119
- }
120
- }
121
- }
115
+ const emitToSink = safeEmitToSink
122
116
 
123
117
  function buildEvent(
124
118
  type: AuditEvent["type"],
@@ -146,6 +140,7 @@ function buildEvent(
146
140
  * or plain text with an embedded JSON fragment.
147
141
  */
148
142
  function parseChildSessionId(result: string): string | null {
143
+ // Strategy 1: Full JSON parse (structured tool output)
149
144
  try {
150
145
  const parsed = JSON.parse(result)
151
146
  if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
@@ -163,11 +158,32 @@ function parseChildSessionId(result: string): string | null {
163
158
  }
164
159
  }
165
160
  } catch {
166
- const match = result.match(/"session_id"\s*:\s*"([^"]+)"/)
167
- if (match?.[1]) {
168
- return match[1]
169
- }
161
+ // Not valid JSON — fall through to regex strategies
162
+ }
163
+
164
+ // Strategy 2: OpenCode task tool XML format
165
+ // <task_metadata>
166
+ // session_id: ses_xxx
167
+ // </task_metadata>
168
+ const xmlMatch = result.match(
169
+ /<task_metadata>[\s\S]*?session_id:\s*(ses_\S+)[\s\S]*?<\/task_metadata>/,
170
+ )
171
+ if (xmlMatch?.[1]) {
172
+ return xmlMatch[1]
173
+ }
174
+
175
+ // Strategy 3: JSON fragment in plain text
176
+ const jsonFragmentMatch = result.match(/"session_id"\s*:\s*"([^"]+)"/)
177
+ if (jsonFragmentMatch?.[1]) {
178
+ return jsonFragmentMatch[1]
179
+ }
180
+
181
+ // Strategy 4: Bare session_id line (e.g. "session_id: ses_xxx" outside XML tags)
182
+ const bareMatch = result.match(/session_id:\s*(ses_\S+)/)
183
+ if (bareMatch?.[1]) {
184
+ return bareMatch[1]
170
185
  }
186
+
171
187
  return null
172
188
  }
173
189
 
@@ -190,141 +206,100 @@ const SLITHER_REQUIRED = ["check", "description", "file", "lines"] as const
190
206
  const PATTERN_REQUIRED = ["pattern", "description", "file", "lines"] as const
191
207
  const MANUAL_REQUIRED = ["check", "description", "file", "lines"] as const
192
208
 
193
- function processSlitherResult(
194
- parsed: Record<string, unknown>,
195
- store: FindingStore,
196
- diag: DropDiagnosticsCollector,
197
- metadata: { reportedByAgent: ArgusAgentName; reportedBySessionId: string },
198
- ): number {
199
- const findings = parsed.findings
200
- if (!Array.isArray(findings)) return 0
201
-
202
- let count = 0
203
- for (const raw of findings) {
204
- const finding = toRecord(raw)
205
- if (!finding) continue
206
-
207
- const check = finding.check
208
- const description = finding.description
209
- const file = finding.file
210
- const lines = toLines(finding.lines)
209
+ type ProcessorConfig = {
210
+ toolLabel: string
211
+ arrayKey: string
212
+ nestedArrayKey?: string
213
+ primaryIdField: string
214
+ requiredFields: readonly string[]
215
+ sourceLabel: string | "dynamic"
216
+ confidenceMode: "read" | "fixed"
217
+ confidenceDefault?: string
218
+ extractOptionalFields: boolean
219
+ allowReportedByOverride: boolean
220
+ }
211
221
 
212
- if (
213
- typeof check !== "string" ||
214
- typeof description !== "string" ||
215
- typeof file !== "string" ||
216
- !lines
217
- ) {
218
- const missing = identifyMissingFields(finding, SLITHER_REQUIRED)
219
- diag.error(
220
- "MISSING_REQUIRED_FIELD",
221
- `Slither finding skipped: missing ${missing.join(", ")}`,
222
- missing[0],
223
- )
224
- continue
225
- }
222
+ const SLITHER_CONFIG: ProcessorConfig = {
223
+ toolLabel: "Slither",
224
+ arrayKey: "findings",
225
+ primaryIdField: "check",
226
+ requiredFields: SLITHER_REQUIRED,
227
+ sourceLabel: "slither",
228
+ confidenceMode: "read",
229
+ extractOptionalFields: false,
230
+ allowReportedByOverride: false,
231
+ }
226
232
 
227
- store.addFinding({
228
- check,
229
- severity: toSeverity(finding.severity),
230
- confidence: toConfidence(finding.confidence),
231
- description,
232
- file,
233
- lines,
234
- source: "slither",
235
- reported_by_agent: metadata.reportedByAgent,
236
- reported_by_session_id: metadata.reportedBySessionId,
237
- })
238
- count++
239
- }
233
+ const PATTERN_CONFIG: ProcessorConfig = {
234
+ toolLabel: "Pattern",
235
+ arrayKey: "sources",
236
+ nestedArrayKey: "matches",
237
+ primaryIdField: "pattern",
238
+ requiredFields: PATTERN_REQUIRED,
239
+ sourceLabel: "pattern",
240
+ confidenceMode: "fixed",
241
+ confidenceDefault: "Medium",
242
+ extractOptionalFields: false,
243
+ allowReportedByOverride: false,
244
+ }
240
245
 
241
- return count
246
+ const RECORDED_CONFIG: ProcessorConfig = {
247
+ toolLabel: "Recorded",
248
+ arrayKey: "findings",
249
+ primaryIdField: "check",
250
+ requiredFields: MANUAL_REQUIRED,
251
+ sourceLabel: "dynamic",
252
+ confidenceMode: "read",
253
+ extractOptionalFields: true,
254
+ allowReportedByOverride: true,
242
255
  }
243
256
 
244
- function processPatternResult(
257
+ function processToolResult(
245
258
  parsed: Record<string, unknown>,
246
259
  store: FindingStore,
247
260
  diag: DropDiagnosticsCollector,
248
261
  metadata: { reportedByAgent: ArgusAgentName; reportedBySessionId: string },
262
+ config: ProcessorConfig,
263
+ projectDir?: string,
249
264
  ): number {
250
- const sources = parsed.sources
251
- if (!Array.isArray(sources)) return 0
252
-
253
- let count = 0
254
- for (const rawSource of sources) {
255
- const source = toRecord(rawSource)
256
- if (!source) continue
257
-
258
- const matches = source.matches
259
- if (!Array.isArray(matches)) continue
260
-
261
- for (const rawMatch of matches) {
262
- const match = toRecord(rawMatch)
263
- if (!match) continue
264
-
265
- const pattern = match.pattern
266
- const description = match.description
267
- const file = match.file
268
- const lines = toLines(match.lines)
269
-
270
- if (
271
- typeof pattern !== "string" ||
272
- typeof description !== "string" ||
273
- typeof file !== "string" ||
274
- !lines
275
- ) {
276
- const missing = identifyMissingFields(match, PATTERN_REQUIRED)
277
- diag.error(
278
- "MISSING_REQUIRED_FIELD",
279
- `Pattern finding skipped: missing ${missing.join(", ")}`,
280
- missing[0],
281
- )
282
- continue
283
- }
284
-
285
- store.addFinding({
286
- check: pattern,
287
- severity: toSeverity(match.severity),
288
- confidence: "Medium",
289
- description,
290
- file,
291
- lines,
292
- source: "pattern",
293
- reported_by_agent: metadata.reportedByAgent,
294
- reported_by_session_id: metadata.reportedBySessionId,
295
- })
296
- count++
265
+ const topLevel = parsed[config.arrayKey]
266
+ if (!Array.isArray(topLevel)) {
267
+ if (config.toolLabel === "Recorded") {
268
+ diag.error(
269
+ "MISSING_REQUIRED_FIELD",
270
+ "argus_record_finding result missing findings array",
271
+ "findings",
272
+ )
297
273
  }
274
+ return 0
298
275
  }
299
276
 
300
- return count
301
- }
277
+ const items: unknown[] = []
278
+ if (config.nestedArrayKey) {
279
+ for (const rawOuter of topLevel) {
280
+ const outer = toRecord(rawOuter)
281
+ if (!outer) continue
302
282
 
303
- function processRecordedFindingResult(
304
- parsed: Record<string, unknown>,
305
- store: FindingStore,
306
- diag: DropDiagnosticsCollector,
307
- metadata: { reportedByAgent: ArgusAgentName; reportedBySessionId: string },
308
- ): number {
309
- const findings = parsed.findings
310
- if (!Array.isArray(findings)) {
311
- diag.error(
312
- "MISSING_REQUIRED_FIELD",
313
- "argus_record_finding result missing findings array",
314
- "findings",
315
- )
316
- return 0
283
+ const nested = outer[config.nestedArrayKey]
284
+ if (!Array.isArray(nested)) continue
285
+
286
+ items.push(...nested)
287
+ }
288
+ } else {
289
+ items.push(...topLevel)
317
290
  }
318
291
 
319
292
  let count = 0
320
- for (const raw of findings) {
321
- const finding = toRecord(raw)
322
- if (!finding) continue
293
+ for (const rawItem of items) {
294
+ const item = toRecord(rawItem)
295
+ if (!item) continue
323
296
 
324
- const check = finding.check
325
- const description = finding.description
326
- const file = finding.file
327
- const lines = toLines(finding.lines)
297
+ const check = item[config.primaryIdField]
298
+ const description = item.description
299
+ const rawFile = item.file
300
+ const file =
301
+ typeof rawFile === "string" && projectDir ? normalizeFilePath(rawFile, projectDir) : rawFile
302
+ const lines = toLines(item.lines)
328
303
 
329
304
  if (
330
305
  typeof check !== "string" ||
@@ -332,51 +307,60 @@ function processRecordedFindingResult(
332
307
  typeof file !== "string" ||
333
308
  !lines
334
309
  ) {
335
- const missing = identifyMissingFields(finding, MANUAL_REQUIRED)
310
+ const missing = identifyMissingFields(item, config.requiredFields)
336
311
  diag.error(
337
312
  "MISSING_REQUIRED_FIELD",
338
- `Recorded finding skipped: missing ${missing.join(", ")}`,
313
+ `${config.toolLabel} finding skipped: missing ${missing.join(", ")}`,
339
314
  missing[0],
340
315
  )
341
316
  continue
342
317
  }
343
318
 
344
- const reportedByAgentRaw = finding.reported_by_agent
319
+ const reportedByAgentRaw = item.reported_by_agent
345
320
  const reportedByAgent =
346
- reportedByAgentRaw === "argus" ||
347
- reportedByAgentRaw === "sentinel" ||
348
- reportedByAgentRaw === "pythia" ||
349
- reportedByAgentRaw === "scribe" ||
350
- reportedByAgentRaw === "unknown"
321
+ config.allowReportedByOverride &&
322
+ typeof reportedByAgentRaw === "string" &&
323
+ (isArgusFamily(reportedByAgentRaw) || reportedByAgentRaw === "unknown")
351
324
  ? (reportedByAgentRaw as ArgusAgentName)
352
325
  : metadata.reportedByAgent
353
326
 
354
- store.addFinding({
327
+ const findingPayload: Parameters<FindingStore["addFinding"]>[0] = {
355
328
  check,
356
- severity: toSeverity(finding.severity),
357
- confidence: toConfidence(finding.confidence),
329
+ severity: toSeverity(item.severity),
330
+ confidence:
331
+ config.confidenceMode === "read"
332
+ ? toConfidence(item.confidence)
333
+ : toConfidence(config.confidenceDefault),
358
334
  description,
359
335
  file,
360
336
  lines,
361
- source: toFindingSource(finding.source),
362
- remediation: typeof finding.remediation === "string" ? finding.remediation : undefined,
363
- exploitReference:
364
- typeof finding.exploitReference === "string" ? finding.exploitReference : undefined,
337
+ source:
338
+ config.sourceLabel === "dynamic"
339
+ ? toFindingSource(item.source)
340
+ : toFindingSource(config.sourceLabel),
365
341
  reported_by_agent: reportedByAgent,
366
342
  reported_by_session_id:
367
- typeof finding.reported_by_session_id === "string" &&
368
- finding.reported_by_session_id.length > 0
369
- ? finding.reported_by_session_id
343
+ config.allowReportedByOverride &&
344
+ typeof item.reported_by_session_id === "string" &&
345
+ item.reported_by_session_id.length > 0
346
+ ? item.reported_by_session_id
370
347
  : metadata.reportedBySessionId,
371
- issue_fingerprint:
372
- typeof finding.issue_fingerprint === "string" ? finding.issue_fingerprint : undefined,
373
- observation_fingerprint:
374
- typeof finding.observation_fingerprint === "string"
375
- ? finding.observation_fingerprint
376
- : undefined,
377
- observation_id:
378
- typeof finding.observation_id === "string" ? finding.observation_id : undefined,
379
- })
348
+ }
349
+
350
+ if (config.extractOptionalFields) {
351
+ findingPayload.remediation =
352
+ typeof item.remediation === "string" ? item.remediation : undefined
353
+ findingPayload.exploitReference =
354
+ typeof item.exploitReference === "string" ? item.exploitReference : undefined
355
+ findingPayload.issue_fingerprint =
356
+ typeof item.issue_fingerprint === "string" ? item.issue_fingerprint : undefined
357
+ findingPayload.observation_fingerprint =
358
+ typeof item.observation_fingerprint === "string" ? item.observation_fingerprint : undefined
359
+ findingPayload.observation_id =
360
+ typeof item.observation_id === "string" ? item.observation_id : undefined
361
+ }
362
+
363
+ store.addFinding(findingPayload)
380
364
  count++
381
365
  }
382
366
 
@@ -472,21 +456,77 @@ function recordToolExecution(state: AuditState, toolName: string, findingsCount:
472
456
  })
473
457
  }
474
458
 
459
+ const TOOL_PHASE_MAP: Record<string, AuditState["currentPhase"]> = {
460
+ argus_slither_analyze: "scanning",
461
+ argus_check_patterns: "scanning",
462
+ argus_analyze_contract: "scanning",
463
+ argus_proxy_detection: "scanning",
464
+ argus_solodit_search: "research",
465
+ argus_forge_test: "testing",
466
+ argus_forge_fuzz: "testing",
467
+ argus_forge_coverage: "testing",
468
+ argus_gas_analysis: "testing",
469
+ argus_generate_report: "reporting",
470
+ }
471
+
472
+ function inferPhaseAdvancement(
473
+ state: AuditState,
474
+ toolName: string,
475
+ ): AuditState["currentPhase"] | null {
476
+ const inferredPhase = TOOL_PHASE_MAP[toolName]
477
+ if (!inferredPhase) return null
478
+
479
+ const currentIdx = PHASE_ORDER.indexOf(state.currentPhase)
480
+ const inferredIdx = PHASE_ORDER.indexOf(inferredPhase)
481
+ if (inferredIdx <= currentIdx) return null
482
+
483
+ return inferredPhase
484
+ }
485
+
486
+ type OrphanEvent = {
487
+ event: AuditEvent
488
+ failFast: boolean
489
+ bufferedAt: number
490
+ }
491
+
492
+ const ORPHAN_BUFFER_TTL_MS = 60_000
493
+ const MAX_ORPHAN_EVENTS_PER_SESSION = 50
494
+
475
495
  export type ToolTrackingHook = {
476
496
  (input: ToolHookInput): Promise<void>
477
497
  getLastDiagnostics(): DropDiagnostic[]
498
+ flushOrphanEvents(sessionId: string, sink: EventSink): Promise<number>
478
499
  }
479
500
 
480
501
  export function createToolTrackingHook(
481
- getAuditState: () => AuditState | null,
502
+ getAuditState: (sessionId?: string) => AuditState | null,
482
503
  onStateChanged?: (metadata: ToolExecutionMetadata) => void,
483
504
  options?: ToolTrackingOptions,
484
505
  ): ToolTrackingHook {
506
+ const projectDir = options?.projectDir
485
507
  const storesByState = new WeakMap<AuditState, FindingStore>()
486
508
  let lastDiagnostics: DropDiagnostic[] = []
509
+ const orphanBuffer = new Map<string, OrphanEvent[]>()
487
510
 
488
- function resolveStateAndStore(): { state: AuditState; store: FindingStore } | null {
489
- const state = getAuditState()
511
+ function bufferOrphanEvent(sessionId: string, entry: OrphanEvent): void {
512
+ let entries = orphanBuffer.get(sessionId)
513
+ if (!entries) {
514
+ entries = []
515
+ orphanBuffer.set(sessionId, entries)
516
+ }
517
+ if (entries.length >= MAX_ORPHAN_EVENTS_PER_SESSION) {
518
+ logger.warn(
519
+ `Orphan event buffer full for session ${sessionId} (${MAX_ORPHAN_EVENTS_PER_SESSION} events) — dropping oldest`,
520
+ )
521
+ entries.shift()
522
+ }
523
+ entries.push(entry)
524
+ }
525
+
526
+ function resolveStateAndStore(
527
+ sessionId?: string,
528
+ ): { state: AuditState; store: FindingStore } | null {
529
+ const state = getAuditState(sessionId)
490
530
  if (!state) return null
491
531
 
492
532
  let store = storesByState.get(state)
@@ -503,8 +543,7 @@ export function createToolTrackingHook(
503
543
  if (input.tool === "task") {
504
544
  const childSessionId = parseChildSessionId(input.result)
505
545
  const correlationId = randomUUID()
506
- const resolved = resolveStateAndStore()
507
- const sink = options?.getEventSink?.()
546
+ const resolved = resolveStateAndStore(input.sessionID)
508
547
  const sessionId = input.sessionID ?? options?.getSessionId?.() ?? ""
509
548
  const toolCallId = randomUUID()
510
549
 
@@ -512,6 +551,26 @@ export function createToolTrackingHook(
512
551
  options?.onChildSessionDetected?.(sessionId, childSessionId)
513
552
  }
514
553
 
554
+ let sink: EventSink | null =
555
+ (sessionId ? options?.getEventSinkForSession?.(sessionId) : null) ??
556
+ options?.getEventSink?.() ??
557
+ null
558
+
559
+ if (sink && resolved) {
560
+ const runId = resolved.state.sessionId
561
+ if (sink.runId !== runId) {
562
+ const runScopedSink = options?.getEventSinkForRun?.(runId) ?? null
563
+ if (runScopedSink && runScopedSink.runId === runId) {
564
+ sink = runScopedSink
565
+ } else {
566
+ logger.warn(
567
+ `Skipping task sink emission due to run mismatch: state run ${runId}, sink run ${sink.runId}`,
568
+ )
569
+ sink = null
570
+ }
571
+ }
572
+ }
573
+
515
574
  if (sink && resolved) {
516
575
  const runId = resolved.state.sessionId
517
576
  await emitToSink(
@@ -538,7 +597,7 @@ export function createToolTrackingHook(
538
597
 
539
598
  if (resolved) {
540
599
  recordToolExecution(resolved.state, "task", 0)
541
- onStateChanged?.({ tool: "task", findingsCount: 0 })
600
+ onStateChanged?.({ tool: "task", findingsCount: 0, sessionId: input.sessionID })
542
601
  }
543
602
 
544
603
  return
@@ -548,7 +607,7 @@ export function createToolTrackingHook(
548
607
  return
549
608
  }
550
609
 
551
- const resolved = resolveStateAndStore()
610
+ const resolved = resolveStateAndStore(input.sessionID)
552
611
  if (!resolved) {
553
612
  if (input.tool === "argus_record_finding") {
554
613
  throw new Error("argus_record_finding requires active audit state")
@@ -577,9 +636,34 @@ export function createToolTrackingHook(
577
636
  }
578
637
 
579
638
  const { state: auditState, store } = resolved
580
- const sink = options?.getEventSink?.()
581
639
  const runId = auditState.sessionId
582
640
  const sessionId = input.sessionID ?? options?.getSessionId?.() ?? ""
641
+ let sink: EventSink | null =
642
+ (sessionId ? options?.getEventSinkForSession?.(sessionId) : null) ??
643
+ options?.getEventSink?.() ??
644
+ null
645
+ if (sink && sink.runId !== runId) {
646
+ const runScopedSink = options?.getEventSinkForRun?.(runId) ?? null
647
+ if (runScopedSink && runScopedSink.runId === runId) {
648
+ sink = runScopedSink
649
+ } else {
650
+ // Single-run coalescence: if exactly one active (non-finalized) sink
651
+ // exists, use it rather than dropping events silently.
652
+ const activeSinks = options?.getActiveRunSinks?.() ?? []
653
+ const coalescedSink = activeSinks.length === 1 ? activeSinks[0] : undefined
654
+ if (coalescedSink) {
655
+ logger.debug(
656
+ `Coalescing tool ${input.tool} from session ${sessionId} into active run ${coalescedSink.runId} (state run ${runId}, original sink run ${sink.runId})`,
657
+ )
658
+ sink = coalescedSink
659
+ } else {
660
+ logger.warn(
661
+ `Skipping sink emission for ${input.tool} due to run mismatch: state run ${runId}, sink run ${sink.runId}`,
662
+ )
663
+ sink = null
664
+ }
665
+ }
666
+ }
583
667
  const reportedByAgent =
584
668
  (input.sessionID ? options?.getAgentNameForSession?.(input.sessionID) : undefined) ??
585
669
  options?.getAgentName?.() ??
@@ -601,186 +685,376 @@ export function createToolTrackingHook(
601
685
  }),
602
686
  { failFast: input.tool === "argus_record_finding" },
603
687
  )
688
+ } else if (sessionId) {
689
+ const event = buildEvent("tool.started", runId, sessionId, toolCallId, {
690
+ tool: input.tool,
691
+ args: input.args,
692
+ })
693
+ bufferOrphanEvent(sessionId, {
694
+ event,
695
+ failFast: input.tool === "argus_record_finding",
696
+ bufferedAt: Date.now(),
697
+ })
698
+ logger.warn(
699
+ `Buffered orphan tool.started event for ${input.tool} from session ${sessionId} (run_id=${runId})`,
700
+ )
604
701
  }
605
702
 
606
703
  const findingsCountBefore = auditState.findings.length
704
+ let findingsCount = 0
705
+ let completedSuccess = false
706
+ let completionError: string | undefined
607
707
 
608
- if (input.tool === "argus_skill_load") {
609
- const nameMatch = input.result.match(/^##\s+Argus Skill:\s+(.+?)(?:\s+\[|$)/m)
610
- const skillName = nameMatch?.[1]?.trim()
611
- if (skillName) {
612
- auditState.skillsLoaded ??= []
613
- if (!auditState.skillsLoaded.includes(skillName)) {
614
- auditState.skillsLoaded.push(skillName)
708
+ try {
709
+ if (input.tool === "argus_skill_load") {
710
+ const nameMatch = input.result.match(/^##\s+Argus Skill:\s+(.+?)(?:\s+\[|$)/m)
711
+ const skillName = nameMatch?.[1]?.trim()
712
+ if (skillName) {
713
+ auditState.skillsLoaded ??= []
714
+ if (!auditState.skillsLoaded.includes(skillName)) {
715
+ auditState.skillsLoaded.push(skillName)
716
+ }
717
+ }
718
+ findingsCount = 0
719
+ completedSuccess = true
720
+ } else {
721
+ let parsed: unknown
722
+ try {
723
+ parsed = JSON.parse(input.result)
724
+ } catch {
725
+ // For large tool outputs (e.g. argus_check_patterns can produce 3MB+),
726
+ // OpenCode may truncate the result before it reaches this hook.
727
+ // Two truncation modes:
728
+ // 1. Partial JSON — first N bytes of valid JSON (check for "success": true)
729
+ // 2. OpenCode replacement — full output replaced with "...N bytes truncated..."
730
+ const successInPartialJson = input.result.match(/"success"\s*:\s*(true|false)/)
731
+ const opencodeTruncation = input.result.match(
732
+ /bytes truncated|output was truncated|tool call succeeded/i,
733
+ )
734
+ const truncatedSuccess = successInPartialJson?.[1] === "true" || !!opencodeTruncation
735
+ if (truncatedSuccess) {
736
+ diag.error(
737
+ "TRUNCATED_OUTPUT",
738
+ `${input.tool} output was truncated (${input.result.length} chars) — tool likely succeeded`,
739
+ )
740
+ logger.warn(
741
+ `Tool output truncated — findings may be incomplete (${input.tool}, ${input.result.length} chars)`,
742
+ )
743
+ completionError = "Tool output truncated — findings may be incomplete"
744
+ } else {
745
+ diag.error("MALFORMED_JSON", `Failed to parse JSON result from ${input.tool}`)
746
+ if (input.tool === "argus_record_finding") {
747
+ throw new Error("argus_record_finding returned malformed JSON")
748
+ }
749
+ }
750
+ diag.throwIfStrict()
751
+ return
615
752
  }
616
- }
617
- recordToolExecution(auditState, input.tool, 0)
618
- onStateChanged?.({ tool: input.tool, findingsCount: 0 })
619
753
 
620
- if (sink) {
621
- await emitToSink(
622
- sink,
623
- buildEvent("tool.completed", runId, sessionId, toolCallId, {
624
- tool: input.tool,
625
- findingsCount: 0,
626
- success: true,
627
- }),
628
- )
629
- }
754
+ const record = toRecord(parsed)
755
+ if (!record) {
756
+ if (input.tool === "argus_record_finding") {
757
+ throw new Error("argus_record_finding response must be a JSON object")
758
+ }
759
+ return
760
+ }
630
761
 
631
- lastDiagnostics = diag.getDiagnostics()
632
- return
633
- }
762
+ switch (input.tool) {
763
+ case "argus_slither_analyze": {
764
+ findingsCount = processToolResult(
765
+ record,
766
+ store,
767
+ diag,
768
+ findingMetadata,
769
+ SLITHER_CONFIG,
770
+ projectDir,
771
+ )
772
+ if (auditState.scope.length === 0 && findingsCount > 0) {
773
+ const slitherFindings = Array.isArray(record.findings) ? record.findings : []
774
+ const files = [
775
+ ...new Set(
776
+ slitherFindings
777
+ .map((f: Record<string, unknown>) => f.file as string)
778
+ .filter(Boolean),
779
+ ),
780
+ ]
781
+ if (files.length > 0) {
782
+ auditState.scope = files
783
+ }
784
+ }
785
+ break
786
+ }
787
+ case "argus_check_patterns":
788
+ findingsCount = processToolResult(
789
+ record,
790
+ store,
791
+ diag,
792
+ findingMetadata,
793
+ PATTERN_CONFIG,
794
+ projectDir,
795
+ )
796
+ if (typeof record.patternVersion === "string") {
797
+ auditState.patternVersion = record.patternVersion
798
+ }
799
+ break
800
+ case "argus_record_finding":
801
+ findingsCount = processToolResult(
802
+ record,
803
+ store,
804
+ diag,
805
+ findingMetadata,
806
+ RECORDED_CONFIG,
807
+ projectDir,
808
+ )
809
+ break
810
+ case "argus_analyze_contract": {
811
+ processContractAnalyzerResult(record, auditState)
812
+ const filePath = (input.args as Record<string, unknown>)?.file_path as string
813
+ if (filePath && !auditState.scope.includes(filePath)) {
814
+ auditState.scope = [...auditState.scope, filePath]
815
+ }
816
+ break
817
+ }
818
+ case "argus_solodit_search":
819
+ processSoloditResult(record, auditState)
820
+ break
821
+ case "argus_forge_test": {
822
+ const summary = toRecord(record.summary)
823
+ if (summary && typeof summary.failed === "number") {
824
+ findingsCount = summary.failed
825
+ }
826
+ break
827
+ }
828
+ case "argus_forge_fuzz":
829
+ processFuzzResult(record, auditState)
830
+ break
831
+ case "argus_generate_report": {
832
+ const reportError = toRecord(record.error)
833
+ const filePath = record.filePath
834
+ if (reportError) {
835
+ const errorMessage =
836
+ typeof reportError.message === "string"
837
+ ? reportError.message
838
+ : "argus_generate_report reported an unknown error"
839
+ throw new Error(`argus_generate_report failed: ${errorMessage}`)
840
+ }
841
+ if (typeof filePath !== "string" || filePath.length === 0) {
842
+ throw new Error("argus_generate_report completed without filePath")
843
+ }
844
+ auditState.reportGenerated = true
845
+ break
846
+ }
847
+ case "argus_sync_knowledge": {
848
+ const success = record.success === true
849
+ auditState.knowledgeSynced = { success, timestamp: Date.now() }
850
+ break
851
+ }
852
+ case "argus_forge_coverage": {
853
+ const reportObj = toRecord(record.report)
854
+ const files = reportObj?.files
855
+ if (Array.isArray(files)) {
856
+ auditState.coverageReport = {
857
+ files: files
858
+ .filter((f): f is Record<string, unknown> => !!f && typeof f === "object")
859
+ .map((f) => ({
860
+ path: typeof f.path === "string" ? f.path : "unknown",
861
+ linesPct: typeof f.linesPct === "number" ? f.linesPct : 0,
862
+ statementsPct: typeof f.statementsPct === "number" ? f.statementsPct : 0,
863
+ branchesPct: typeof f.branchesPct === "number" ? f.branchesPct : 0,
864
+ functionsPct: typeof f.functionsPct === "number" ? f.functionsPct : 0,
865
+ })),
866
+ }
867
+ }
868
+ break
869
+ }
870
+ case "argus_proxy_detection": {
871
+ if (record.isProxy === true) {
872
+ auditState.proxyContracts ??= []
873
+ auditState.proxyContracts.push({
874
+ file: typeof record.file === "string" ? record.file : "unknown",
875
+ proxyType: typeof record.proxyType === "string" ? record.proxyType : "unknown",
876
+ indicators: Array.isArray(record.indicators)
877
+ ? record.indicators.filter((i): i is string => typeof i === "string")
878
+ : [],
879
+ })
880
+ }
881
+ break
882
+ }
883
+ case "argus_gas_analysis": {
884
+ const hotspots = record.hotspots
885
+ if (Array.isArray(hotspots)) {
886
+ auditState.gasHotspots = hotspots
887
+ .filter((h): h is Record<string, unknown> => !!h && typeof h === "object")
888
+ .map((h) => ({
889
+ contract: typeof h.contract === "string" ? h.contract : "unknown",
890
+ function: typeof h.function === "string" ? h.function : "unknown",
891
+ avgGas: typeof h.avgGas === "number" ? h.avgGas : 0,
892
+ }))
893
+ }
894
+ break
895
+ }
896
+ }
634
897
 
635
- let parsed: unknown
636
- try {
637
- parsed = JSON.parse(input.result)
638
- } catch {
639
- diag.error("MALFORMED_JSON", `Failed to parse JSON result from ${input.tool}`)
640
- lastDiagnostics = diag.getDiagnostics()
641
- if (input.tool === "argus_record_finding") {
642
- throw new Error("argus_record_finding returned malformed JSON")
643
- }
644
- diag.throwIfStrict()
645
- return
646
- }
898
+ diag.throwIfStrict()
647
899
 
648
- const record = toRecord(parsed)
649
- if (!record) {
650
- lastDiagnostics = diag.getDiagnostics()
651
- if (input.tool === "argus_record_finding") {
652
- throw new Error("argus_record_finding response must be a JSON object")
653
- }
654
- return
655
- }
656
-
657
- let findingsCount = 0
900
+ if (input.tool === "argus_record_finding" && findingsCount === 0) {
901
+ throw new Error("argus_record_finding did not persist any findings")
902
+ }
658
903
 
659
- switch (input.tool) {
660
- case "argus_slither_analyze":
661
- findingsCount = processSlitherResult(record, store, diag, findingMetadata)
662
- break
663
- case "argus_check_patterns":
664
- findingsCount = processPatternResult(record, store, diag, findingMetadata)
665
- break
666
- case "argus_record_finding":
667
- findingsCount = processRecordedFindingResult(record, store, diag, findingMetadata)
668
- break
669
- case "argus_analyze_contract":
670
- processContractAnalyzerResult(record, auditState)
671
- break
672
- case "argus_solodit_search":
673
- processSoloditResult(record, auditState)
674
- break
675
- case "argus_forge_test": {
676
- const summary = toRecord(record.summary)
677
- if (summary && typeof summary.failed === "number") {
678
- findingsCount = summary.failed
904
+ if (input.tool === "argus_record_finding" && !sink) {
905
+ const newFindings = auditState.findings.slice(findingsCountBefore)
906
+ if (newFindings.length > 0) {
907
+ throw new Error(
908
+ `argus_record_finding produced ${newFindings.length} finding(s) but no event sink is available — findings would be lost from the report`,
909
+ )
910
+ }
911
+ diag.error(
912
+ "NO_EVENT_SINK",
913
+ "argus_record_finding: no active event sink — no new findings to emit",
914
+ )
679
915
  }
680
- break
681
- }
682
- case "argus_forge_fuzz":
683
- processFuzzResult(record, auditState)
684
- break
685
- case "argus_generate_report": {
686
- auditState.reportGenerated = true
687
- break
688
- }
689
- case "argus_sync_knowledge": {
690
- const success = record.success === true
691
- auditState.knowledgeSynced = { success, timestamp: Date.now() }
692
- break
693
- }
694
- case "argus_forge_coverage": {
695
- const reportObj = toRecord(record.report)
696
- const files = reportObj?.files
697
- if (Array.isArray(files)) {
698
- auditState.coverageReport = {
699
- files: files
700
- .filter((f): f is Record<string, unknown> => !!f && typeof f === "object")
701
- .map((f) => ({
702
- path: typeof f.path === "string" ? f.path : "unknown",
703
- linesPct: typeof f.linesPct === "number" ? f.linesPct : 0,
704
- statementsPct: typeof f.statementsPct === "number" ? f.statementsPct : 0,
705
- branchesPct: typeof f.branchesPct === "number" ? f.branchesPct : 0,
706
- functionsPct: typeof f.functionsPct === "number" ? f.functionsPct : 0,
707
- })),
916
+
917
+ if (sink) {
918
+ const failFast = input.tool === "argus_record_finding"
919
+ const newFindings = auditState.findings.slice(findingsCountBefore)
920
+ for (const [index, finding] of newFindings.entries()) {
921
+ const { data: canonical } = normalizeToCanonicalFinding(
922
+ finding,
923
+ runId,
924
+ 0,
925
+ {
926
+ reportedByAgent,
927
+ reportedBySessionId: sessionId,
928
+ toolCallId,
929
+ observationId: `${toolCallId}:${index + 1}`,
930
+ },
931
+ projectDir,
932
+ )
933
+ await emitToSink(
934
+ sink,
935
+ buildEvent("finding.added", runId, sessionId, toolCallId, canonical),
936
+ { failFast },
937
+ )
708
938
  }
709
939
  }
710
- break
940
+
941
+ completedSuccess = true
711
942
  }
712
- case "argus_proxy_detection": {
713
- if (record.isProxy === true) {
714
- auditState.proxyContracts ??= []
715
- auditState.proxyContracts.push({
716
- file: typeof record.file === "string" ? record.file : "unknown",
717
- proxyType: typeof record.proxyType === "string" ? record.proxyType : "unknown",
718
- indicators: Array.isArray(record.indicators)
719
- ? record.indicators.filter((i): i is string => typeof i === "string")
720
- : [],
721
- })
943
+
944
+ recordToolExecution(auditState, input.tool, findingsCount)
945
+
946
+ const nextPhase = inferPhaseAdvancement(auditState, input.tool)
947
+ if (nextPhase) {
948
+ auditState.currentPhase = nextPhase
949
+ if (sink) {
950
+ await emitToSink(
951
+ sink,
952
+ buildEvent("phase.changed", runId, sessionId, toolCallId, {
953
+ phase: nextPhase,
954
+ trigger: input.tool,
955
+ }),
956
+ )
722
957
  }
723
- break
724
958
  }
725
- case "argus_gas_analysis": {
726
- const hotspots = record.hotspots
727
- if (Array.isArray(hotspots)) {
728
- auditState.gasHotspots = hotspots
729
- .filter((h): h is Record<string, unknown> => !!h && typeof h === "object")
730
- .map((h) => ({
731
- contract: typeof h.contract === "string" ? h.contract : "unknown",
732
- function: typeof h.function === "string" ? h.function : "unknown",
733
- avgGas: typeof h.avgGas === "number" ? h.avgGas : 0,
734
- }))
959
+
960
+ onStateChanged?.({ tool: input.tool, findingsCount, sessionId: input.sessionID })
961
+ } catch (error) {
962
+ completionError = error instanceof Error ? error.message : String(error)
963
+ throw error
964
+ } finally {
965
+ lastDiagnostics = diag.getDiagnostics()
966
+ if (sink) {
967
+ const failFast = input.tool === "argus_record_finding"
968
+ // Enrichment data for event replay — projector extracts these from payloads
969
+ const enrichment: Record<string, unknown> = {}
970
+ if (completedSuccess) {
971
+ switch (input.tool) {
972
+ case "argus_solodit_search":
973
+ if (auditState.soloditResults) enrichment.soloditResults = auditState.soloditResults
974
+ break
975
+ case "argus_forge_fuzz":
976
+ if (auditState.fuzzCounterexamples)
977
+ enrichment.fuzzCounterexamples = auditState.fuzzCounterexamples
978
+ break
979
+ case "argus_forge_coverage":
980
+ if (auditState.coverageReport) enrichment.coverageReport = auditState.coverageReport
981
+ break
982
+ case "argus_gas_analysis":
983
+ if (auditState.gasHotspots) enrichment.gasHotspots = auditState.gasHotspots
984
+ break
985
+ case "argus_proxy_detection":
986
+ if (auditState.proxyContracts) enrichment.proxyContracts = auditState.proxyContracts
987
+ break
988
+ case "argus_skill_load":
989
+ if (auditState.skillsLoaded) enrichment.skillsLoaded = auditState.skillsLoaded
990
+ break
991
+ case "argus_check_patterns":
992
+ if (auditState.patternVersion) enrichment.patternVersion = auditState.patternVersion
993
+ break
994
+ }
735
995
  }
736
- break
996
+ await emitToSink(
997
+ sink,
998
+ buildEvent("tool.completed", runId, sessionId, toolCallId, {
999
+ tool: input.tool,
1000
+ findingsCount,
1001
+ success: completedSuccess,
1002
+ ...(completionError ? { error: completionError } : {}),
1003
+ ...enrichment,
1004
+ }),
1005
+ { failFast },
1006
+ )
1007
+ } else if (sessionId) {
1008
+ const enrichment: Record<string, unknown> = {}
1009
+ const event = buildEvent("tool.completed", runId, sessionId, toolCallId, {
1010
+ tool: input.tool,
1011
+ findingsCount,
1012
+ success: completedSuccess,
1013
+ ...(completionError ? { error: completionError } : {}),
1014
+ ...enrichment,
1015
+ })
1016
+ bufferOrphanEvent(sessionId, {
1017
+ event,
1018
+ failFast: input.tool === "argus_record_finding",
1019
+ bufferedAt: Date.now(),
1020
+ })
1021
+ logger.warn(
1022
+ `Buffered orphan tool.completed event for ${input.tool} from session ${sessionId} (run_id=${runId}, findings=${findingsCount})`,
1023
+ )
737
1024
  }
738
1025
  }
1026
+ }
739
1027
 
740
- lastDiagnostics = diag.getDiagnostics()
741
- diag.throwIfStrict()
1028
+ hookFn.getLastDiagnostics = (): DropDiagnostic[] => lastDiagnostics
742
1029
 
743
- if (input.tool === "argus_record_finding" && findingsCount === 0) {
744
- throw new Error("argus_record_finding did not persist any findings")
1030
+ hookFn.flushOrphanEvents = async (sessionId: string, sink: EventSink): Promise<number> => {
1031
+ const entries = orphanBuffer.get(sessionId)
1032
+ if (!entries || entries.length === 0) {
1033
+ return 0
745
1034
  }
746
1035
 
747
- recordToolExecution(auditState, input.tool, findingsCount)
748
- onStateChanged?.({ tool: input.tool, findingsCount })
1036
+ orphanBuffer.delete(sessionId)
1037
+ const now = Date.now()
1038
+ const fresh = entries.filter((e) => now - e.bufferedAt < ORPHAN_BUFFER_TTL_MS)
749
1039
 
750
- if (input.tool === "argus_record_finding" && !sink) {
751
- throw new Error("argus_record_finding requires an active event sink for durable persistence")
1040
+ if (fresh.length < entries.length) {
1041
+ logger.debug(
1042
+ `Discarded ${entries.length - fresh.length} stale orphan events for session ${sessionId}`,
1043
+ )
752
1044
  }
753
1045
 
754
- if (sink) {
755
- const failFast = input.tool === "argus_record_finding"
756
- const newFindings = auditState.findings.slice(findingsCountBefore)
757
- for (const [index, finding] of newFindings.entries()) {
758
- const { data: canonical } = normalizeToCanonicalFinding(finding, runId, 0, {
759
- reportedByAgent,
760
- reportedBySessionId: sessionId,
761
- toolCallId,
762
- observationId: `${toolCallId}:${index + 1}`,
763
- })
764
- await emitToSink(
765
- sink,
766
- buildEvent("finding.added", runId, sessionId, toolCallId, canonical),
767
- { failFast },
768
- )
769
- }
1046
+ let flushed = 0
1047
+ for (const entry of fresh) {
1048
+ await emitToSink(sink, entry.event, { failFast: entry.failFast })
1049
+ flushed++
1050
+ }
770
1051
 
771
- await emitToSink(
772
- sink,
773
- buildEvent("tool.completed", runId, sessionId, toolCallId, {
774
- tool: input.tool,
775
- findingsCount,
776
- success: true,
777
- }),
778
- { failFast },
779
- )
1052
+ if (flushed > 0) {
1053
+ logger.info(`Flushed ${flushed} orphan events for session ${sessionId} to sink ${sink.runId}`)
780
1054
  }
781
- }
782
1055
 
783
- hookFn.getLastDiagnostics = (): DropDiagnostic[] => lastDiagnostics
1056
+ return flushed
1057
+ }
784
1058
 
785
1059
  return hookFn
786
1060
  }