solidity-argus 0.3.7 → 0.5.7

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/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 +24 -2
  22. package/src/agents/scribe-prompt.ts +34 -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/cli/commands/install.ts +74 -33
  27. package/src/config/loader.ts +29 -5
  28. package/src/config/schema.ts +45 -45
  29. package/src/constants/defaults.ts +1 -0
  30. package/src/create-hooks.ts +806 -173
  31. package/src/create-managers.ts +4 -2
  32. package/src/create-tools.ts +5 -1
  33. package/src/features/audit-enforcer/audit-enforcer.ts +1 -11
  34. package/src/features/background-agent/background-manager.ts +32 -5
  35. package/src/features/error-recovery/tool-error-recovery.ts +1 -0
  36. package/src/features/persistent-state/audit-state-manager.ts +272 -29
  37. package/src/features/persistent-state/event-sink.ts +96 -25
  38. package/src/features/persistent-state/findings-materializer.ts +68 -2
  39. package/src/features/persistent-state/global-run-index.ts +86 -8
  40. package/src/features/persistent-state/index.ts +7 -1
  41. package/src/features/persistent-state/run-finalizer.ts +116 -7
  42. package/src/features/persistent-state/run-pruner.ts +93 -0
  43. package/src/hooks/agent-tracker.ts +14 -2
  44. package/src/hooks/compaction-hook.ts +7 -16
  45. package/src/hooks/config-handler.ts +83 -29
  46. package/src/hooks/context-budget.ts +4 -5
  47. package/src/hooks/event-hook.ts +213 -57
  48. package/src/hooks/knowledge-sync-hook.ts +2 -3
  49. package/src/hooks/safe-create-hook.ts +13 -1
  50. package/src/hooks/system-prompt-hook.ts +20 -39
  51. package/src/hooks/tool-tracking-hook.ts +602 -323
  52. package/src/index.ts +15 -1
  53. package/src/knowledge/scvd-client.ts +2 -4
  54. package/src/knowledge/scvd-errors.ts +25 -2
  55. package/src/knowledge/scvd-index.ts +7 -5
  56. package/src/knowledge/scvd-sync.ts +6 -6
  57. package/src/managers/types.ts +20 -2
  58. package/src/shared/agent-names.ts +23 -0
  59. package/src/shared/audit-artifact-resolver.ts +8 -3
  60. package/src/shared/audit-phases.ts +12 -0
  61. package/src/shared/cache-paths.ts +41 -0
  62. package/src/shared/drop-diagnostics.ts +2 -2
  63. package/src/shared/forge-errors.ts +31 -0
  64. package/src/shared/forge-runner.ts +30 -0
  65. package/src/shared/format-error.ts +3 -0
  66. package/src/shared/index.ts +9 -0
  67. package/src/shared/key-tools.ts +39 -0
  68. package/src/shared/logger.ts +7 -7
  69. package/src/shared/path-containment.ts +25 -0
  70. package/src/shared/path-utils.ts +11 -0
  71. package/src/shared/report-path-resolver.ts +4 -2
  72. package/src/shared/safe-emit.ts +24 -0
  73. package/src/shared/token-utils.ts +5 -0
  74. package/src/shared/type-guards.ts +8 -0
  75. package/src/shared/validation-constants.ts +52 -0
  76. package/src/skills/analysis/cluster.ts +1 -114
  77. package/src/skills/analysis/normalize.ts +2 -114
  78. package/src/skills/analysis/stopwords.ts +109 -0
  79. package/src/skills/argus-skill-resolver.ts +6 -3
  80. package/src/solodit-lifecycle.ts +153 -37
  81. package/src/state/adapters.ts +60 -66
  82. package/src/state/finding-aggregation.ts +6 -8
  83. package/src/state/finding-fingerprint.ts +1 -1
  84. package/src/state/finding-store.ts +31 -9
  85. package/src/state/index.ts +1 -1
  86. package/src/state/projectors.ts +27 -19
  87. package/src/state/schemas.ts +8 -32
  88. package/src/state/types.ts +3 -0
  89. package/src/tools/contract-analyzer-tool.ts +4 -6
  90. package/src/tools/forge-coverage-tool.ts +10 -35
  91. package/src/tools/forge-fuzz-tool.ts +21 -51
  92. package/src/tools/forge-test-tool.ts +25 -47
  93. package/src/tools/gas-analysis-tool.ts +12 -41
  94. package/src/tools/pattern-checker-tool.ts +37 -15
  95. package/src/tools/pattern-loader.ts +18 -4
  96. package/src/tools/persist-deduped-tool.ts +94 -0
  97. package/src/tools/proxy-detection-tool.ts +35 -34
  98. package/src/tools/read-findings-tool.ts +390 -0
  99. package/src/tools/record-finding-tool.ts +130 -25
  100. package/src/tools/report-generator-tool.ts +475 -327
  101. package/src/tools/report-preflight.ts +5 -1
  102. package/src/tools/slither-tool.ts +55 -16
  103. package/src/tools/solodit-search-tool.ts +260 -112
  104. package/src/tools/sync-knowledge-tool.ts +2 -3
  105. package/src/utils/solidity-parser.ts +39 -24
  106. package/src/features/migration/index.ts +0 -14
  107. package/src/features/migration/migration-adapter.ts +0 -151
  108. 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,65 @@ 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.impact = typeof item.impact === "string" ? item.impact : undefined
352
+ findingPayload.recommendation =
353
+ typeof item.recommendation === "string" ? item.recommendation : undefined
354
+ findingPayload.proofOfConcept =
355
+ typeof item.proofOfConcept === "string" ? item.proofOfConcept : undefined
356
+ findingPayload.remediation =
357
+ typeof item.remediation === "string" ? item.remediation : undefined
358
+ findingPayload.exploitReference =
359
+ typeof item.exploitReference === "string" ? item.exploitReference : undefined
360
+ findingPayload.issue_fingerprint =
361
+ typeof item.issue_fingerprint === "string" ? item.issue_fingerprint : undefined
362
+ findingPayload.observation_fingerprint =
363
+ typeof item.observation_fingerprint === "string" ? item.observation_fingerprint : undefined
364
+ findingPayload.observation_id =
365
+ typeof item.observation_id === "string" ? item.observation_id : undefined
366
+ }
367
+
368
+ store.addFinding(findingPayload)
380
369
  count++
381
370
  }
382
371
 
@@ -472,21 +461,77 @@ function recordToolExecution(state: AuditState, toolName: string, findingsCount:
472
461
  })
473
462
  }
474
463
 
464
+ const TOOL_PHASE_MAP: Record<string, AuditState["currentPhase"]> = {
465
+ argus_slither_analyze: "scanning",
466
+ argus_check_patterns: "scanning",
467
+ argus_analyze_contract: "scanning",
468
+ argus_proxy_detection: "scanning",
469
+ argus_solodit_search: "research",
470
+ argus_forge_test: "testing",
471
+ argus_forge_fuzz: "testing",
472
+ argus_forge_coverage: "testing",
473
+ argus_gas_analysis: "testing",
474
+ argus_generate_report: "reporting",
475
+ }
476
+
477
+ function inferPhaseAdvancement(
478
+ state: AuditState,
479
+ toolName: string,
480
+ ): AuditState["currentPhase"] | null {
481
+ const inferredPhase = TOOL_PHASE_MAP[toolName]
482
+ if (!inferredPhase) return null
483
+
484
+ const currentIdx = PHASE_ORDER.indexOf(state.currentPhase)
485
+ const inferredIdx = PHASE_ORDER.indexOf(inferredPhase)
486
+ if (inferredIdx <= currentIdx) return null
487
+
488
+ return inferredPhase
489
+ }
490
+
491
+ type OrphanEvent = {
492
+ event: AuditEvent
493
+ failFast: boolean
494
+ bufferedAt: number
495
+ }
496
+
497
+ const ORPHAN_BUFFER_TTL_MS = 60_000
498
+ const MAX_ORPHAN_EVENTS_PER_SESSION = 50
499
+
475
500
  export type ToolTrackingHook = {
476
501
  (input: ToolHookInput): Promise<void>
477
502
  getLastDiagnostics(): DropDiagnostic[]
503
+ flushOrphanEvents(sessionId: string, sink: EventSink): Promise<number>
478
504
  }
479
505
 
480
506
  export function createToolTrackingHook(
481
- getAuditState: () => AuditState | null,
507
+ getAuditState: (sessionId?: string) => AuditState | null,
482
508
  onStateChanged?: (metadata: ToolExecutionMetadata) => void,
483
509
  options?: ToolTrackingOptions,
484
510
  ): ToolTrackingHook {
511
+ const projectDir = options?.projectDir
485
512
  const storesByState = new WeakMap<AuditState, FindingStore>()
486
513
  let lastDiagnostics: DropDiagnostic[] = []
514
+ const orphanBuffer = new Map<string, OrphanEvent[]>()
487
515
 
488
- function resolveStateAndStore(): { state: AuditState; store: FindingStore } | null {
489
- const state = getAuditState()
516
+ function bufferOrphanEvent(sessionId: string, entry: OrphanEvent): void {
517
+ let entries = orphanBuffer.get(sessionId)
518
+ if (!entries) {
519
+ entries = []
520
+ orphanBuffer.set(sessionId, entries)
521
+ }
522
+ if (entries.length >= MAX_ORPHAN_EVENTS_PER_SESSION) {
523
+ logger.warn(
524
+ `Orphan event buffer full for session ${sessionId} (${MAX_ORPHAN_EVENTS_PER_SESSION} events) — dropping oldest`,
525
+ )
526
+ entries.shift()
527
+ }
528
+ entries.push(entry)
529
+ }
530
+
531
+ function resolveStateAndStore(
532
+ sessionId?: string,
533
+ ): { state: AuditState; store: FindingStore } | null {
534
+ const state = getAuditState(sessionId)
490
535
  if (!state) return null
491
536
 
492
537
  let store = storesByState.get(state)
@@ -503,8 +548,7 @@ export function createToolTrackingHook(
503
548
  if (input.tool === "task") {
504
549
  const childSessionId = parseChildSessionId(input.result)
505
550
  const correlationId = randomUUID()
506
- const resolved = resolveStateAndStore()
507
- const sink = options?.getEventSink?.()
551
+ const resolved = resolveStateAndStore(input.sessionID)
508
552
  const sessionId = input.sessionID ?? options?.getSessionId?.() ?? ""
509
553
  const toolCallId = randomUUID()
510
554
 
@@ -512,6 +556,26 @@ export function createToolTrackingHook(
512
556
  options?.onChildSessionDetected?.(sessionId, childSessionId)
513
557
  }
514
558
 
559
+ let sink: EventSink | null =
560
+ (sessionId ? options?.getEventSinkForSession?.(sessionId) : null) ??
561
+ options?.getEventSink?.() ??
562
+ null
563
+
564
+ if (sink && resolved) {
565
+ const runId = resolved.state.sessionId
566
+ if (sink.runId !== runId) {
567
+ const runScopedSink = options?.getEventSinkForRun?.(runId) ?? null
568
+ if (runScopedSink && runScopedSink.runId === runId) {
569
+ sink = runScopedSink
570
+ } else {
571
+ logger.warn(
572
+ `Skipping task sink emission due to run mismatch: state run ${runId}, sink run ${sink.runId}`,
573
+ )
574
+ sink = null
575
+ }
576
+ }
577
+ }
578
+
515
579
  if (sink && resolved) {
516
580
  const runId = resolved.state.sessionId
517
581
  await emitToSink(
@@ -538,7 +602,7 @@ export function createToolTrackingHook(
538
602
 
539
603
  if (resolved) {
540
604
  recordToolExecution(resolved.state, "task", 0)
541
- onStateChanged?.({ tool: "task", findingsCount: 0 })
605
+ onStateChanged?.({ tool: "task", findingsCount: 0, sessionId: input.sessionID })
542
606
  }
543
607
 
544
608
  return
@@ -548,7 +612,7 @@ export function createToolTrackingHook(
548
612
  return
549
613
  }
550
614
 
551
- const resolved = resolveStateAndStore()
615
+ const resolved = resolveStateAndStore(input.sessionID)
552
616
  if (!resolved) {
553
617
  if (input.tool === "argus_record_finding") {
554
618
  throw new Error("argus_record_finding requires active audit state")
@@ -577,9 +641,34 @@ export function createToolTrackingHook(
577
641
  }
578
642
 
579
643
  const { state: auditState, store } = resolved
580
- const sink = options?.getEventSink?.()
581
644
  const runId = auditState.sessionId
582
645
  const sessionId = input.sessionID ?? options?.getSessionId?.() ?? ""
646
+ let sink: EventSink | null =
647
+ (sessionId ? options?.getEventSinkForSession?.(sessionId) : null) ??
648
+ options?.getEventSink?.() ??
649
+ null
650
+ if (sink && sink.runId !== runId) {
651
+ const runScopedSink = options?.getEventSinkForRun?.(runId) ?? null
652
+ if (runScopedSink && runScopedSink.runId === runId) {
653
+ sink = runScopedSink
654
+ } else {
655
+ // Single-run coalescence: if exactly one active (non-finalized) sink
656
+ // exists, use it rather than dropping events silently.
657
+ const activeSinks = options?.getActiveRunSinks?.() ?? []
658
+ const coalescedSink = activeSinks.length === 1 ? activeSinks[0] : undefined
659
+ if (coalescedSink) {
660
+ logger.debug(
661
+ `Coalescing tool ${input.tool} from session ${sessionId} into active run ${coalescedSink.runId} (state run ${runId}, original sink run ${sink.runId})`,
662
+ )
663
+ sink = coalescedSink
664
+ } else {
665
+ logger.warn(
666
+ `Skipping sink emission for ${input.tool} due to run mismatch: state run ${runId}, sink run ${sink.runId}`,
667
+ )
668
+ sink = null
669
+ }
670
+ }
671
+ }
583
672
  const reportedByAgent =
584
673
  (input.sessionID ? options?.getAgentNameForSession?.(input.sessionID) : undefined) ??
585
674
  options?.getAgentName?.() ??
@@ -601,186 +690,376 @@ export function createToolTrackingHook(
601
690
  }),
602
691
  { failFast: input.tool === "argus_record_finding" },
603
692
  )
693
+ } else if (sessionId) {
694
+ const event = buildEvent("tool.started", runId, sessionId, toolCallId, {
695
+ tool: input.tool,
696
+ args: input.args,
697
+ })
698
+ bufferOrphanEvent(sessionId, {
699
+ event,
700
+ failFast: input.tool === "argus_record_finding",
701
+ bufferedAt: Date.now(),
702
+ })
703
+ logger.warn(
704
+ `Buffered orphan tool.started event for ${input.tool} from session ${sessionId} (run_id=${runId})`,
705
+ )
604
706
  }
605
707
 
606
708
  const findingsCountBefore = auditState.findings.length
709
+ let findingsCount = 0
710
+ let completedSuccess = false
711
+ let completionError: string | undefined
607
712
 
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)
713
+ try {
714
+ if (input.tool === "argus_skill_load") {
715
+ const nameMatch = input.result.match(/^##\s+Argus Skill:\s+(.+?)(?:\s+\[|$)/m)
716
+ const skillName = nameMatch?.[1]?.trim()
717
+ if (skillName) {
718
+ auditState.skillsLoaded ??= []
719
+ if (!auditState.skillsLoaded.includes(skillName)) {
720
+ auditState.skillsLoaded.push(skillName)
721
+ }
722
+ }
723
+ findingsCount = 0
724
+ completedSuccess = true
725
+ } else {
726
+ let parsed: unknown
727
+ try {
728
+ parsed = JSON.parse(input.result)
729
+ } catch {
730
+ // For large tool outputs (e.g. argus_check_patterns can produce 3MB+),
731
+ // OpenCode may truncate the result before it reaches this hook.
732
+ // Two truncation modes:
733
+ // 1. Partial JSON — first N bytes of valid JSON (check for "success": true)
734
+ // 2. OpenCode replacement — full output replaced with "...N bytes truncated..."
735
+ const successInPartialJson = input.result.match(/"success"\s*:\s*(true|false)/)
736
+ const opencodeTruncation = input.result.match(
737
+ /bytes truncated|output was truncated|tool call succeeded/i,
738
+ )
739
+ const truncatedSuccess = successInPartialJson?.[1] === "true" || !!opencodeTruncation
740
+ if (truncatedSuccess) {
741
+ diag.error(
742
+ "TRUNCATED_OUTPUT",
743
+ `${input.tool} output was truncated (${input.result.length} chars) — tool likely succeeded`,
744
+ )
745
+ logger.warn(
746
+ `Tool output truncated — findings may be incomplete (${input.tool}, ${input.result.length} chars)`,
747
+ )
748
+ completionError = "Tool output truncated — findings may be incomplete"
749
+ } else {
750
+ diag.error("MALFORMED_JSON", `Failed to parse JSON result from ${input.tool}`)
751
+ if (input.tool === "argus_record_finding") {
752
+ throw new Error("argus_record_finding returned malformed JSON")
753
+ }
754
+ }
755
+ diag.throwIfStrict()
756
+ return
615
757
  }
616
- }
617
- recordToolExecution(auditState, input.tool, 0)
618
- onStateChanged?.({ tool: input.tool, findingsCount: 0 })
619
758
 
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
- }
759
+ const record = toRecord(parsed)
760
+ if (!record) {
761
+ if (input.tool === "argus_record_finding") {
762
+ throw new Error("argus_record_finding response must be a JSON object")
763
+ }
764
+ return
765
+ }
630
766
 
631
- lastDiagnostics = diag.getDiagnostics()
632
- return
633
- }
767
+ switch (input.tool) {
768
+ case "argus_slither_analyze": {
769
+ findingsCount = processToolResult(
770
+ record,
771
+ store,
772
+ diag,
773
+ findingMetadata,
774
+ SLITHER_CONFIG,
775
+ projectDir,
776
+ )
777
+ if (auditState.scope.length === 0 && findingsCount > 0) {
778
+ const slitherFindings = Array.isArray(record.findings) ? record.findings : []
779
+ const files = [
780
+ ...new Set(
781
+ slitherFindings
782
+ .map((f: Record<string, unknown>) => f.file as string)
783
+ .filter(Boolean),
784
+ ),
785
+ ]
786
+ if (files.length > 0) {
787
+ auditState.scope = files
788
+ }
789
+ }
790
+ break
791
+ }
792
+ case "argus_check_patterns":
793
+ findingsCount = processToolResult(
794
+ record,
795
+ store,
796
+ diag,
797
+ findingMetadata,
798
+ PATTERN_CONFIG,
799
+ projectDir,
800
+ )
801
+ if (typeof record.patternVersion === "string") {
802
+ auditState.patternVersion = record.patternVersion
803
+ }
804
+ break
805
+ case "argus_record_finding":
806
+ findingsCount = processToolResult(
807
+ record,
808
+ store,
809
+ diag,
810
+ findingMetadata,
811
+ RECORDED_CONFIG,
812
+ projectDir,
813
+ )
814
+ break
815
+ case "argus_analyze_contract": {
816
+ processContractAnalyzerResult(record, auditState)
817
+ const filePath = (input.args as Record<string, unknown>)?.file_path as string
818
+ if (filePath && !auditState.scope.includes(filePath)) {
819
+ auditState.scope = [...auditState.scope, filePath]
820
+ }
821
+ break
822
+ }
823
+ case "argus_solodit_search":
824
+ processSoloditResult(record, auditState)
825
+ break
826
+ case "argus_forge_test": {
827
+ const summary = toRecord(record.summary)
828
+ if (summary && typeof summary.failed === "number") {
829
+ findingsCount = summary.failed
830
+ }
831
+ break
832
+ }
833
+ case "argus_forge_fuzz":
834
+ processFuzzResult(record, auditState)
835
+ break
836
+ case "argus_generate_report": {
837
+ const reportError = toRecord(record.error)
838
+ const filePath = record.filePath
839
+ if (reportError) {
840
+ const errorMessage =
841
+ typeof reportError.message === "string"
842
+ ? reportError.message
843
+ : "argus_generate_report reported an unknown error"
844
+ throw new Error(`argus_generate_report failed: ${errorMessage}`)
845
+ }
846
+ if (typeof filePath !== "string" || filePath.length === 0) {
847
+ throw new Error("argus_generate_report completed without filePath")
848
+ }
849
+ auditState.reportGenerated = true
850
+ break
851
+ }
852
+ case "argus_sync_knowledge": {
853
+ const success = record.success === true
854
+ auditState.knowledgeSynced = { success, timestamp: Date.now() }
855
+ break
856
+ }
857
+ case "argus_forge_coverage": {
858
+ const reportObj = toRecord(record.report)
859
+ const files = reportObj?.files
860
+ if (Array.isArray(files)) {
861
+ auditState.coverageReport = {
862
+ files: files
863
+ .filter((f): f is Record<string, unknown> => !!f && typeof f === "object")
864
+ .map((f) => ({
865
+ path: typeof f.path === "string" ? f.path : "unknown",
866
+ linesPct: typeof f.linesPct === "number" ? f.linesPct : 0,
867
+ statementsPct: typeof f.statementsPct === "number" ? f.statementsPct : 0,
868
+ branchesPct: typeof f.branchesPct === "number" ? f.branchesPct : 0,
869
+ functionsPct: typeof f.functionsPct === "number" ? f.functionsPct : 0,
870
+ })),
871
+ }
872
+ }
873
+ break
874
+ }
875
+ case "argus_proxy_detection": {
876
+ if (record.isProxy === true) {
877
+ auditState.proxyContracts ??= []
878
+ auditState.proxyContracts.push({
879
+ file: typeof record.file === "string" ? record.file : "unknown",
880
+ proxyType: typeof record.proxyType === "string" ? record.proxyType : "unknown",
881
+ indicators: Array.isArray(record.indicators)
882
+ ? record.indicators.filter((i): i is string => typeof i === "string")
883
+ : [],
884
+ })
885
+ }
886
+ break
887
+ }
888
+ case "argus_gas_analysis": {
889
+ const hotspots = record.hotspots
890
+ if (Array.isArray(hotspots)) {
891
+ auditState.gasHotspots = hotspots
892
+ .filter((h): h is Record<string, unknown> => !!h && typeof h === "object")
893
+ .map((h) => ({
894
+ contract: typeof h.contract === "string" ? h.contract : "unknown",
895
+ function: typeof h.function === "string" ? h.function : "unknown",
896
+ avgGas: typeof h.avgGas === "number" ? h.avgGas : 0,
897
+ }))
898
+ }
899
+ break
900
+ }
901
+ }
634
902
 
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
- }
903
+ diag.throwIfStrict()
647
904
 
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
905
+ if (input.tool === "argus_record_finding" && findingsCount === 0) {
906
+ throw new Error("argus_record_finding did not persist any findings")
907
+ }
658
908
 
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
909
+ if (input.tool === "argus_record_finding" && !sink) {
910
+ const newFindings = auditState.findings.slice(findingsCountBefore)
911
+ if (newFindings.length > 0) {
912
+ throw new Error(
913
+ `argus_record_finding produced ${newFindings.length} finding(s) but no event sink is available — findings would be lost from the report`,
914
+ )
915
+ }
916
+ diag.error(
917
+ "NO_EVENT_SINK",
918
+ "argus_record_finding: no active event sink — no new findings to emit",
919
+ )
679
920
  }
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
- })),
921
+
922
+ if (sink) {
923
+ const failFast = input.tool === "argus_record_finding"
924
+ const newFindings = auditState.findings.slice(findingsCountBefore)
925
+ for (const [index, finding] of newFindings.entries()) {
926
+ const { data: canonical } = normalizeToCanonicalFinding(
927
+ finding,
928
+ runId,
929
+ 0,
930
+ {
931
+ reportedByAgent,
932
+ reportedBySessionId: sessionId,
933
+ toolCallId,
934
+ observationId: `${toolCallId}:${index + 1}`,
935
+ },
936
+ projectDir,
937
+ )
938
+ await emitToSink(
939
+ sink,
940
+ buildEvent("finding.added", runId, sessionId, toolCallId, canonical),
941
+ { failFast },
942
+ )
708
943
  }
709
944
  }
710
- break
945
+
946
+ completedSuccess = true
711
947
  }
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
- })
948
+
949
+ recordToolExecution(auditState, input.tool, findingsCount)
950
+
951
+ const nextPhase = inferPhaseAdvancement(auditState, input.tool)
952
+ if (nextPhase) {
953
+ auditState.currentPhase = nextPhase
954
+ if (sink) {
955
+ await emitToSink(
956
+ sink,
957
+ buildEvent("phase.changed", runId, sessionId, toolCallId, {
958
+ phase: nextPhase,
959
+ trigger: input.tool,
960
+ }),
961
+ )
722
962
  }
723
- break
724
963
  }
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
- }))
964
+
965
+ onStateChanged?.({ tool: input.tool, findingsCount, sessionId: input.sessionID })
966
+ } catch (error) {
967
+ completionError = error instanceof Error ? error.message : String(error)
968
+ throw error
969
+ } finally {
970
+ lastDiagnostics = diag.getDiagnostics()
971
+ if (sink) {
972
+ const failFast = input.tool === "argus_record_finding"
973
+ // Enrichment data for event replay — projector extracts these from payloads
974
+ const enrichment: Record<string, unknown> = {}
975
+ if (completedSuccess) {
976
+ switch (input.tool) {
977
+ case "argus_solodit_search":
978
+ if (auditState.soloditResults) enrichment.soloditResults = auditState.soloditResults
979
+ break
980
+ case "argus_forge_fuzz":
981
+ if (auditState.fuzzCounterexamples)
982
+ enrichment.fuzzCounterexamples = auditState.fuzzCounterexamples
983
+ break
984
+ case "argus_forge_coverage":
985
+ if (auditState.coverageReport) enrichment.coverageReport = auditState.coverageReport
986
+ break
987
+ case "argus_gas_analysis":
988
+ if (auditState.gasHotspots) enrichment.gasHotspots = auditState.gasHotspots
989
+ break
990
+ case "argus_proxy_detection":
991
+ if (auditState.proxyContracts) enrichment.proxyContracts = auditState.proxyContracts
992
+ break
993
+ case "argus_skill_load":
994
+ if (auditState.skillsLoaded) enrichment.skillsLoaded = auditState.skillsLoaded
995
+ break
996
+ case "argus_check_patterns":
997
+ if (auditState.patternVersion) enrichment.patternVersion = auditState.patternVersion
998
+ break
999
+ }
735
1000
  }
736
- break
1001
+ await emitToSink(
1002
+ sink,
1003
+ buildEvent("tool.completed", runId, sessionId, toolCallId, {
1004
+ tool: input.tool,
1005
+ findingsCount,
1006
+ success: completedSuccess,
1007
+ ...(completionError ? { error: completionError } : {}),
1008
+ ...enrichment,
1009
+ }),
1010
+ { failFast },
1011
+ )
1012
+ } else if (sessionId) {
1013
+ const enrichment: Record<string, unknown> = {}
1014
+ const event = buildEvent("tool.completed", runId, sessionId, toolCallId, {
1015
+ tool: input.tool,
1016
+ findingsCount,
1017
+ success: completedSuccess,
1018
+ ...(completionError ? { error: completionError } : {}),
1019
+ ...enrichment,
1020
+ })
1021
+ bufferOrphanEvent(sessionId, {
1022
+ event,
1023
+ failFast: input.tool === "argus_record_finding",
1024
+ bufferedAt: Date.now(),
1025
+ })
1026
+ logger.warn(
1027
+ `Buffered orphan tool.completed event for ${input.tool} from session ${sessionId} (run_id=${runId}, findings=${findingsCount})`,
1028
+ )
737
1029
  }
738
1030
  }
1031
+ }
739
1032
 
740
- lastDiagnostics = diag.getDiagnostics()
741
- diag.throwIfStrict()
1033
+ hookFn.getLastDiagnostics = (): DropDiagnostic[] => lastDiagnostics
742
1034
 
743
- if (input.tool === "argus_record_finding" && findingsCount === 0) {
744
- throw new Error("argus_record_finding did not persist any findings")
1035
+ hookFn.flushOrphanEvents = async (sessionId: string, sink: EventSink): Promise<number> => {
1036
+ const entries = orphanBuffer.get(sessionId)
1037
+ if (!entries || entries.length === 0) {
1038
+ return 0
745
1039
  }
746
1040
 
747
- recordToolExecution(auditState, input.tool, findingsCount)
748
- onStateChanged?.({ tool: input.tool, findingsCount })
1041
+ orphanBuffer.delete(sessionId)
1042
+ const now = Date.now()
1043
+ const fresh = entries.filter((e) => now - e.bufferedAt < ORPHAN_BUFFER_TTL_MS)
749
1044
 
750
- if (input.tool === "argus_record_finding" && !sink) {
751
- throw new Error("argus_record_finding requires an active event sink for durable persistence")
1045
+ if (fresh.length < entries.length) {
1046
+ logger.debug(
1047
+ `Discarded ${entries.length - fresh.length} stale orphan events for session ${sessionId}`,
1048
+ )
752
1049
  }
753
1050
 
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
- }
1051
+ let flushed = 0
1052
+ for (const entry of fresh) {
1053
+ await emitToSink(sink, entry.event, { failFast: entry.failFast })
1054
+ flushed++
1055
+ }
770
1056
 
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
- )
1057
+ if (flushed > 0) {
1058
+ logger.info(`Flushed ${flushed} orphan events for session ${sessionId} to sink ${sink.runId}`)
780
1059
  }
781
- }
782
1060
 
783
- hookFn.getLastDiagnostics = (): DropDiagnostic[] => lastDiagnostics
1061
+ return flushed
1062
+ }
784
1063
 
785
1064
  return hookFn
786
1065
  }