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,9 +1,11 @@
1
- import { mkdir, rename } from "node:fs/promises"
1
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs"
2
+ import { appendFile, mkdir } from "node:fs/promises"
2
3
  import { dirname, join } from "node:path"
4
+ import { createLogger, type Logger } from "../../shared/logger"
3
5
  import { type ArgusRootResolver, defaultRootResolver } from "../../shared/path-root-resolver"
4
6
  import type { AuditEvent, AuditEventType } from "../../state/schemas"
5
7
 
6
- export type EventSinkErrorCode = "SEQUENCE_CONFLICT" | "INVALID_EVENT" | "IO_ERROR"
8
+ export type EventSinkErrorCode = "INVALID_EVENT" | "IO_ERROR"
7
9
 
8
10
  export class EventSinkError extends Error {
9
11
  readonly code: EventSinkErrorCode
@@ -16,8 +18,13 @@ export class EventSinkError extends Error {
16
18
  }
17
19
 
18
20
  export interface EventSink {
21
+ readonly runId: string
22
+ /** Whether this sink has been marked as finalized. Post-finalization appends are silently dropped. */
23
+ readonly isFinalized: boolean
19
24
  append(event: AuditEvent): Promise<void>
20
25
  readAll(): Promise<AuditEvent[]>
26
+ /** Mark this sink as finalized. Subsequent appends (except run.finalized) are silently dropped. */
27
+ markFinalized(): void
21
28
  }
22
29
 
23
30
  const VALID_EVENT_TYPES: ReadonlySet<string> = new Set<AuditEventType>([
@@ -31,7 +38,15 @@ const VALID_EVENT_TYPES: ReadonlySet<string> = new Set<AuditEventType>([
31
38
  "run.finalized",
32
39
  ])
33
40
 
34
- function createMutex() {
41
+ export const MUTEX_TIMEOUT_MS = 30_000
42
+
43
+ export interface MutexOptions {
44
+ timeoutMs?: number
45
+ logger?: Logger
46
+ }
47
+
48
+ export function createMutex(options: MutexOptions = {}) {
49
+ const { timeoutMs = MUTEX_TIMEOUT_MS, logger } = options
35
50
  let chain = Promise.resolve()
36
51
 
37
52
  return {
@@ -42,8 +57,14 @@ function createMutex() {
42
57
  release = r
43
58
  })
44
59
 
60
+ const timer = setTimeout(() => {
61
+ logger?.error("EventSink mutex held >30s — possible deadlock, still waiting")
62
+ }, timeoutMs)
63
+
45
64
  await prev
46
65
 
66
+ clearTimeout(timer)
67
+
47
68
  try {
48
69
  return await fn()
49
70
  } finally {
@@ -79,7 +100,22 @@ function parseJournalLines(content: string): AuditEvent[] {
79
100
  }
80
101
  }
81
102
 
82
- events.sort((a, b) => a.seq - b.seq)
103
+ // Canonical ordering: sort by timestamp (primary), written seq hint (secondary tiebreaker).
104
+ // This produces a stable, deterministic order even when multiple writers assign
105
+ // overlapping seq values — the written seq is a best-effort hint, not authoritative.
106
+ events.sort((a, b) => {
107
+ const tsDiff = a.timestamp - b.timestamp
108
+ if (tsDiff !== 0) return tsDiff
109
+ return a.seq - b.seq
110
+ })
111
+
112
+ // Assign canonical sequential seq numbers starting from 1.
113
+ // All downstream consumers see clean, gap-free sequences regardless of
114
+ // how many independent writers appended to the journal.
115
+ for (let i = 0; i < events.length; i++) {
116
+ ;(events[i] as AuditEvent).seq = i + 1
117
+ }
118
+
83
119
  return events
84
120
  }
85
121
 
@@ -96,19 +132,39 @@ export async function readEvents(
96
132
  return parseJournalLines(content)
97
133
  }
98
134
 
99
- /**
100
- * Append-only event sink with monotonic seq allocation, in-process mutex,
101
- * and atomic temp-file-then-rename writes. Restart-safe via journal replay.
102
- */
135
+ const sinkRegistry = new Map<string, EventSink>()
136
+ export function releaseEventSink(runId: string): void {
137
+ sinkRegistry.delete(runId)
138
+ }
139
+ export function resetSinkRegistry(): void {
140
+ sinkRegistry.clear()
141
+ }
142
+
103
143
  export function createEventSink(
104
144
  runId: string,
105
145
  projectDir: string,
106
146
  resolver: ArgusRootResolver = defaultRootResolver,
107
147
  ): EventSink {
148
+ const existing = sinkRegistry.get(runId)
149
+ if (existing) {
150
+ return existing
151
+ }
152
+
153
+ const logger = createLogger()
108
154
  const journalPath = buildJournalPath(runId, projectDir, resolver)
109
- const mutex = createMutex()
155
+ const markerPath = `${journalPath}.finalized`
156
+ const mutex = createMutex({ logger })
110
157
  let lastSeq = 0
111
158
  let initialized = false
159
+ const sinkState = { finalized: false }
160
+
161
+ try {
162
+ if (existsSync(markerPath)) {
163
+ sinkState.finalized = true
164
+ }
165
+ } catch (err) {
166
+ logger.warn(`Failed to check finalization marker: ${String(err)}`)
167
+ }
112
168
 
113
169
  async function ensureInitialized(): Promise<void> {
114
170
  if (initialized) return
@@ -127,11 +183,32 @@ export function createEventSink(
127
183
  initialized = true
128
184
  }
129
185
 
130
- return {
186
+ const sink: EventSink = {
187
+ runId,
188
+
189
+ get isFinalized() {
190
+ return sinkState.finalized
191
+ },
192
+
193
+ markFinalized() {
194
+ sinkState.finalized = true
195
+ try {
196
+ mkdirSync(dirname(markerPath), { recursive: true })
197
+ writeFileSync(markerPath, "")
198
+ } catch (err) {
199
+ logger.warn(`Failed to write finalization marker: ${String(err)}`)
200
+ }
201
+ },
202
+
131
203
  async append(event: AuditEvent): Promise<void> {
132
204
  return mutex.run(async () => {
133
205
  await ensureInitialized()
134
206
 
207
+ if (sinkState.finalized && event.type !== "run.finalized") {
208
+ logger.debug(`Dropping ${event.type} for finalized run ${runId}`)
209
+ return
210
+ }
211
+
135
212
  if (event.run_id !== runId) {
136
213
  throw new EventSinkError(
137
214
  "INVALID_EVENT",
@@ -143,27 +220,18 @@ export function createEventSink(
143
220
  throw new EventSinkError("INVALID_EVENT", `Invalid event type "${String(event.type)}"`)
144
221
  }
145
222
 
146
- if (event.seq > 0 && event.seq <= lastSeq) {
147
- throw new EventSinkError(
148
- "SEQUENCE_CONFLICT",
149
- `Event seq ${event.seq} conflicts with last assigned seq ${lastSeq}; must be > ${lastSeq}`,
150
- )
151
- }
152
-
223
+ // Best-effort seq hint may have duplicates across isolated writer instances.
224
+ // Canonical seq is assigned at read time by parseJournalLines().
153
225
  const nextSeq = lastSeq + 1
154
226
  const eventToWrite: AuditEvent = { ...event, seq: nextSeq }
155
227
 
156
- const currentContent = await readRawContent(journalPath)
157
- const newContent = `${currentContent}${JSON.stringify(eventToWrite)}\n`
158
-
159
228
  await mkdir(dirname(journalPath), { recursive: true })
160
229
 
161
- const suffix = `${Date.now()}.${Math.random().toString(36).slice(2)}`
162
- const tempPath = `${journalPath}.${suffix}.tmp`
163
-
230
+ // O_APPEND atomic write — the OS guarantees that seek-to-end + write is atomic
231
+ // for regular files opened with O_APPEND, so concurrent appends from isolated
232
+ // writer instances won't interleave or overwrite each other.
164
233
  try {
165
- await Bun.write(tempPath, newContent)
166
- await rename(tempPath, journalPath)
234
+ await appendFile(journalPath, `${JSON.stringify(eventToWrite)}\n`)
167
235
  } catch (err) {
168
236
  throw new EventSinkError("IO_ERROR", `Failed to write event to journal: ${String(err)}`)
169
237
  }
@@ -177,4 +245,7 @@ export function createEventSink(
177
245
  return parseJournalLines(content)
178
246
  },
179
247
  }
248
+
249
+ sinkRegistry.set(runId, sink)
250
+ return sink
180
251
  }
@@ -2,8 +2,13 @@ import { mkdir, writeFile } from "node:fs/promises"
2
2
  import { dirname } from "node:path"
3
3
  import { createAuditArtifactResolver } from "../../shared/audit-artifact-resolver"
4
4
  import { dedupeFindingsForFinalOutput } from "../../state/finding-aggregation"
5
- import { projectFindings, projectToolExecutions, stableHash } from "../../state/projectors"
6
- import type { CanonicalFinding, CanonicalToolExecution } from "../../state/schemas"
5
+ import {
6
+ projectFindings,
7
+ projectReportInput,
8
+ projectToolExecutions,
9
+ stableHash,
10
+ } from "../../state/projectors"
11
+ import type { CanonicalFinding, CanonicalToolExecution, ReportInput } from "../../state/schemas"
7
12
  import { SCHEMA_VERSION } from "../../state/schemas"
8
13
  import { readEvents } from "./event-sink"
9
14
 
@@ -72,3 +77,30 @@ export async function materializeFindings(
72
77
 
73
78
  return artifact
74
79
  }
80
+
81
+ export async function materializeReportInput(
82
+ runId: string,
83
+ projectDir: string,
84
+ _sessionId?: string,
85
+ ): Promise<ReportInput> {
86
+ const events = await readEvents(runId, projectDir)
87
+ if (events.length === 0) {
88
+ throw new Error(`No events found for run ${runId}`)
89
+ }
90
+
91
+ const reportInput = projectReportInput(events, runId, projectDir)
92
+
93
+ if (reportInput.scope.length === 0 && reportInput.findings.length > 0) {
94
+ reportInput.scope = [...new Set(reportInput.findings.map((f) => f.file).filter(Boolean))]
95
+ }
96
+
97
+ // Cross-run finding import removed: importing findings from sibling runs
98
+ // risks contaminating fresh audits with stale data and breaks per-run provenance.
99
+ // If the primary run has zero findings, the report reflects that accurately.
100
+
101
+ const reportInputFile = createAuditArtifactResolver(runId, projectDir).paths().reportInputFile
102
+ await mkdir(dirname(reportInputFile), { recursive: true })
103
+ await writeFile(reportInputFile, JSON.stringify(reportInput, null, 2))
104
+
105
+ return reportInput
106
+ }
@@ -1,13 +1,11 @@
1
- import { appendFileSync } from "node:fs"
2
- import { mkdir } from "node:fs/promises"
3
- import { homedir } from "node:os"
4
- import { join } from "node:path"
1
+ import { existsSync, readFileSync } from "node:fs"
2
+ import { appendFile, mkdir } from "node:fs/promises"
3
+ import { getGlobalRunIndexDir, getGlobalRunIndexFile } from "../../shared/cache-paths"
5
4
  import { createLogger } from "../../shared/logger"
6
5
 
7
6
  const logger = createLogger()
8
7
 
9
- const CACHE_DIR = join(homedir(), ".cache", "solidity-argus", "runs")
10
- const INDEX_FILE = join(CACHE_DIR, "index.jsonl")
8
+ export type RunStatus = "active" | "finalized" | "failed"
11
9
 
12
10
  export type RunIndexEntry = {
13
11
  runId: string
@@ -18,21 +16,101 @@ export type RunIndexEntry = {
18
16
  startedAt: number
19
17
  phase: string
20
18
  findingsCount: number
19
+ status?: RunStatus
20
+ finalizedAt?: number
21
21
  }
22
22
 
23
23
  let dirEnsured = false
24
24
 
25
25
  async function ensureDir(): Promise<void> {
26
26
  if (dirEnsured) return
27
- await mkdir(CACHE_DIR, { recursive: true })
27
+ await mkdir(getGlobalRunIndexDir(), { recursive: true })
28
28
  dirEnsured = true
29
29
  }
30
30
 
31
31
  export async function recordRun(entry: RunIndexEntry): Promise<void> {
32
32
  try {
33
33
  await ensureDir()
34
- appendFileSync(INDEX_FILE, `${JSON.stringify(entry)}\n`)
34
+ await appendFile(getGlobalRunIndexFile(), `${JSON.stringify(entry)}\n`)
35
35
  } catch {
36
36
  logger.debug("Failed to write global run index entry")
37
37
  }
38
38
  }
39
+
40
+ export async function updateRunStatus(runId: string, status: RunStatus): Promise<void> {
41
+ try {
42
+ await ensureDir()
43
+ const update = { runId, status, finalizedAt: status === "finalized" ? Date.now() : undefined }
44
+ await appendFile(getGlobalRunIndexFile(), `${JSON.stringify(update)}\n`)
45
+ } catch {
46
+ logger.debug("Failed to write run status update")
47
+ }
48
+ }
49
+
50
+ const STALE_RUN_TTL_MS = 24 * 60 * 60 * 1000
51
+
52
+ export function resolveRunIdFromOpencodeSession(
53
+ opencodeSessionId: string,
54
+ projectDir?: string,
55
+ ): string | null {
56
+ if (typeof opencodeSessionId !== "string" || opencodeSessionId.length === 0) {
57
+ return null
58
+ }
59
+
60
+ const indexFile = getGlobalRunIndexFile()
61
+
62
+ if (!existsSync(indexFile)) {
63
+ return null
64
+ }
65
+
66
+ try {
67
+ const raw = readFileSync(indexFile, "utf-8")
68
+ const lines = raw.split("\n")
69
+ const now = Date.now()
70
+
71
+ const terminatedRunIds = new Set<string>()
72
+
73
+ for (let idx = lines.length - 1; idx >= 0; idx--) {
74
+ const line = lines[idx]
75
+ if (!line || line.trim().length === 0) continue
76
+
77
+ try {
78
+ const parsed = JSON.parse(line) as Partial<RunIndexEntry> & { status?: RunStatus }
79
+
80
+ if (parsed.status === "finalized" || parsed.status === "failed") {
81
+ if (typeof parsed.runId === "string") {
82
+ terminatedRunIds.add(parsed.runId)
83
+ }
84
+ continue
85
+ }
86
+
87
+ if (
88
+ parsed.opencodeSessionId === opencodeSessionId &&
89
+ typeof parsed.runId === "string" &&
90
+ parsed.runId.length > 0 &&
91
+ (!projectDir || parsed.projectDir === projectDir)
92
+ ) {
93
+ if (terminatedRunIds.has(parsed.runId)) {
94
+ logger.debug(`Skipping terminated run ${parsed.runId}`)
95
+ continue
96
+ }
97
+
98
+ if (typeof parsed.startedAt === "number" && now - parsed.startedAt > STALE_RUN_TTL_MS) {
99
+ logger.debug(
100
+ `Skipping stale run ${parsed.runId} (age: ${Math.round((now - parsed.startedAt) / 3600000)}h)`,
101
+ )
102
+ continue
103
+ }
104
+
105
+ return parsed.runId
106
+ }
107
+ } catch {
108
+ logger.debug("Skipping malformed run index line")
109
+ }
110
+ }
111
+ } catch {
112
+ logger.debug("Failed to read global run index")
113
+ }
114
+
115
+ return null
116
+ }
@@ -1,3 +1,9 @@
1
1
  export { createAuditStateManager } from "./audit-state-manager"
2
2
  export type { EventSink, EventSinkErrorCode } from "./event-sink"
3
- export { createEventSink, EventSinkError, readEvents } from "./event-sink"
3
+ export {
4
+ createEventSink,
5
+ EventSinkError,
6
+ readEvents,
7
+ releaseEventSink,
8
+ resetSinkRegistry,
9
+ } from "./event-sink"
@@ -9,10 +9,15 @@ export type FinalizationResult = {
9
9
  success: boolean
10
10
  invariantsPassed: boolean
11
11
  errors: string[]
12
+ warnings: string[]
12
13
  runId: string
13
14
  timestamp: number
14
15
  }
15
16
 
17
+ type ExistingFinalizationResult = FinalizationResult & {
18
+ finalizedIndex: number
19
+ }
20
+
16
21
  export function hasSessionCreated(events: AuditEvent[]): boolean {
17
22
  return events.some((event) => event.type === "session.created")
18
23
  }
@@ -132,8 +137,52 @@ function collectParentChildIntegrityErrors(events: AuditEvent[]): string[] {
132
137
  return errors
133
138
  }
134
139
 
135
- function collectInvariantErrors(events: AuditEvent[]): string[] {
140
+ function collectMultiSessionErrors(events: AuditEvent[]): string[] {
141
+ const allSessionIds = new Set(events.map((e) => e.session_id).filter(Boolean))
142
+ if (allSessionIds.size <= 1) return []
143
+
144
+ const knownIds = new Set<string>()
145
+
146
+ for (const event of events) {
147
+ const payload = asRecord(event.payload)
148
+ if (!payload) continue
149
+ const childSessionId = payload.child_session_id
150
+ if (typeof childSessionId === "string" && childSessionId.length > 0) {
151
+ knownIds.add(childSessionId)
152
+ if (event.session_id) {
153
+ knownIds.add(event.session_id)
154
+ }
155
+ }
156
+ }
157
+
158
+ for (const event of events) {
159
+ if (event.type !== "session.created") {
160
+ continue
161
+ }
162
+ if (event.session_id && event.session_id.length > 0) {
163
+ knownIds.add(event.session_id)
164
+ }
165
+ }
166
+
167
+ const firstSessionId = events.find((e) => e.session_id)?.session_id ?? ""
168
+ const unexplained: string[] = []
169
+ for (const id of allSessionIds) {
170
+ if (id === firstSessionId) continue
171
+ if (knownIds.has(id)) continue
172
+ unexplained.push(id)
173
+ }
174
+
175
+ if (unexplained.length > 0) {
176
+ return [
177
+ `unexpected session writers detected (not in parent-child graph): ${unexplained.join(", ")}`,
178
+ ]
179
+ }
180
+ return []
181
+ }
182
+
183
+ function collectInvariantErrors(events: AuditEvent[]): { errors: string[]; warnings: string[] } {
136
184
  const errors: string[] = []
185
+ const warnings: string[] = []
137
186
 
138
187
  try {
139
188
  validateEventSequence(events)
@@ -145,13 +194,56 @@ function collectInvariantErrors(events: AuditEvent[]): string[] {
145
194
  errors.push("missing required lifecycle event: session.created")
146
195
  }
147
196
 
148
- if (!hasSessionDeleted(events)) {
149
- errors.push("missing required lifecycle event: session.deleted")
197
+ warnings.push(...collectOrphanedToolStarts(events))
198
+ errors.push(...collectParentChildIntegrityErrors(events))
199
+ errors.push(...collectMultiSessionErrors(events))
200
+ return { errors, warnings }
201
+ }
202
+
203
+ function parseExistingFinalizationResult(
204
+ events: AuditEvent[],
205
+ runId: string,
206
+ ): ExistingFinalizationResult | null {
207
+ const reversedIndex = [...events].reverse().findIndex((event) => event.type === "run.finalized")
208
+ if (reversedIndex < 0) {
209
+ return null
210
+ }
211
+
212
+ const finalizedIndex = events.length - 1 - reversedIndex
213
+ const finalized = events[finalizedIndex]
214
+ if (!finalized) {
215
+ return null
150
216
  }
151
217
 
152
- errors.push(...collectOrphanedToolStarts(events))
153
- errors.push(...collectParentChildIntegrityErrors(events))
154
- return errors
218
+ const payload =
219
+ typeof finalized.payload === "object" &&
220
+ finalized.payload !== null &&
221
+ !Array.isArray(finalized.payload)
222
+ ? (finalized.payload as Record<string, unknown>)
223
+ : null
224
+
225
+ const errors = Array.isArray(payload?.errors)
226
+ ? payload.errors.filter((entry): entry is string => typeof entry === "string")
227
+ : []
228
+ const warnings = Array.isArray(payload?.warnings)
229
+ ? payload.warnings.filter((entry): entry is string => typeof entry === "string")
230
+ : []
231
+ const invariantsPassed =
232
+ typeof payload?.invariantsPassed === "boolean"
233
+ ? payload.invariantsPassed
234
+ : typeof payload?.finalized === "boolean"
235
+ ? payload.finalized
236
+ : errors.length === 0
237
+
238
+ return {
239
+ success: invariantsPassed,
240
+ invariantsPassed,
241
+ errors,
242
+ warnings,
243
+ runId,
244
+ timestamp: finalized.timestamp,
245
+ finalizedIndex,
246
+ }
155
247
  }
156
248
 
157
249
  export async function finalizeRun(
@@ -161,7 +253,21 @@ export async function finalizeRun(
161
253
  ): Promise<FinalizationResult> {
162
254
  const timestamp = Date.now()
163
255
  const events = sink ? await sink.readAll() : await readEvents(runId, projectDir)
164
- const errors = collectInvariantErrors(events)
256
+ const existingResult = parseExistingFinalizationResult(events, runId)
257
+ const hasEventsAfterExistingFinalization =
258
+ existingResult !== null && existingResult.finalizedIndex < events.length - 1
259
+ if (existingResult?.invariantsPassed && !hasEventsAfterExistingFinalization) {
260
+ return {
261
+ success: existingResult.success,
262
+ invariantsPassed: existingResult.invariantsPassed,
263
+ errors: existingResult.errors,
264
+ warnings: existingResult.warnings,
265
+ runId: existingResult.runId,
266
+ timestamp: existingResult.timestamp,
267
+ }
268
+ }
269
+
270
+ const { errors, warnings } = collectInvariantErrors(events)
165
271
  const invariantsPassed = errors.length === 0
166
272
  const sessionId = events.at(-1)?.session_id ?? ""
167
273
 
@@ -178,16 +284,19 @@ export async function finalizeRun(
178
284
  finalized: invariantsPassed,
179
285
  invariantsPassed,
180
286
  errors,
287
+ warnings,
181
288
  status: invariantsPassed ? "finalized" : "failed-finalization",
182
289
  plugin_version: ARGUS_PLUGIN_VERSION,
183
290
  },
184
291
  })
292
+ sink.markFinalized()
185
293
  }
186
294
 
187
295
  return {
188
296
  success: invariantsPassed,
189
297
  invariantsPassed,
190
298
  errors,
299
+ warnings,
191
300
  runId,
192
301
  timestamp,
193
302
  }
@@ -0,0 +1,93 @@
1
+ import { readdir, rm, stat } from "node:fs/promises"
2
+ import { join } from "node:path"
3
+ import { createLogger } from "../../shared/logger"
4
+ import { type ArgusRootResolver, defaultRootResolver } from "../../shared/path-root-resolver"
5
+ import { readEvents } from "./event-sink"
6
+
7
+ const logger = createLogger()
8
+
9
+ const DEFAULT_STALE_TTL_MS = 24 * 60 * 60 * 1000 // 24 hours
10
+ const DEFAULT_FINALIZED_RETENTION_MS = 7 * 24 * 60 * 60 * 1000 // 7 days
11
+
12
+ export type PruneResult = {
13
+ pruned: string[]
14
+ kept: string[]
15
+ errors: string[]
16
+ }
17
+
18
+ function isRunFinalized(events: Array<{ type: string }>): boolean {
19
+ return events.some((e) => e.type === "run.finalized")
20
+ }
21
+
22
+ export async function pruneStaleRuns(
23
+ projectDir: string,
24
+ options: {
25
+ staleTtlMs?: number
26
+ finalizedRetentionMs?: number
27
+ dryRun?: boolean
28
+ resolver?: ArgusRootResolver
29
+ } = {},
30
+ ): Promise<PruneResult> {
31
+ const {
32
+ staleTtlMs = DEFAULT_STALE_TTL_MS,
33
+ finalizedRetentionMs = DEFAULT_FINALIZED_RETENTION_MS,
34
+ dryRun = false,
35
+ resolver = defaultRootResolver,
36
+ } = options
37
+
38
+ const result: PruneResult = { pruned: [], kept: [], errors: [] }
39
+ const runsDir = join(resolver.writeRoot(projectDir), "runs")
40
+
41
+ let entries: string[]
42
+ try {
43
+ entries = await readdir(runsDir)
44
+ } catch {
45
+ return result
46
+ }
47
+
48
+ const now = Date.now()
49
+
50
+ for (const entry of entries) {
51
+ const runDir = join(runsDir, entry)
52
+ try {
53
+ const dirStat = await stat(runDir)
54
+ if (!dirStat.isDirectory()) continue
55
+
56
+ const journalPath = join(runDir, "events.jsonl")
57
+ let journalMtime: number
58
+ try {
59
+ const journalStat = await stat(journalPath)
60
+ journalMtime = journalStat.mtimeMs
61
+ } catch {
62
+ journalMtime = dirStat.mtimeMs
63
+ }
64
+
65
+ const age = now - journalMtime
66
+ const events = await readEvents(entry, projectDir, resolver)
67
+ const finalized = isRunFinalized(events)
68
+
69
+ const shouldPrune =
70
+ (finalized && age > finalizedRetentionMs) || (!finalized && age > staleTtlMs)
71
+
72
+ if (shouldPrune) {
73
+ if (!dryRun) {
74
+ await rm(runDir, { recursive: true, force: true })
75
+ }
76
+ result.pruned.push(entry)
77
+ logger.debug(
78
+ `Pruned ${finalized ? "finalized" : "stale"} run ${entry} (age: ${Math.round(age / 3600000)}h)`,
79
+ )
80
+ } else {
81
+ result.kept.push(entry)
82
+ }
83
+ } catch (err) {
84
+ result.errors.push(`${entry}: ${String(err)}`)
85
+ }
86
+ }
87
+
88
+ if (result.pruned.length > 0) {
89
+ logger.debug(`Pruned ${result.pruned.length} run(s), kept ${result.kept.length}`)
90
+ }
91
+
92
+ return result
93
+ }