solidity-argus 0.3.3 → 0.3.5

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 (37) hide show
  1. package/package.json +1 -1
  2. package/src/agents/argus-prompt.ts +67 -8
  3. package/src/agents/scribe-prompt.ts +13 -5
  4. package/src/cli/commands/init.ts +1 -1
  5. package/src/cli/index.ts +0 -0
  6. package/src/config/schema.ts +7 -2
  7. package/src/create-hooks.ts +116 -27
  8. package/src/features/audit-enforcer/audit-enforcer.ts +31 -2
  9. package/src/features/migration/index.ts +14 -0
  10. package/src/features/migration/migration-adapter.ts +151 -0
  11. package/src/features/migration/parity-telemetry.ts +133 -0
  12. package/src/features/persistent-state/audit-state-manager.ts +28 -6
  13. package/src/features/persistent-state/event-sink.ts +175 -0
  14. package/src/features/persistent-state/findings-materializer.ts +51 -0
  15. package/src/features/persistent-state/index.ts +2 -0
  16. package/src/features/persistent-state/run-finalizer.ts +192 -0
  17. package/src/features/persistent-state/run-journal.ts +15 -4
  18. package/src/hooks/agent-tracker.ts +15 -0
  19. package/src/hooks/event-hook.ts +93 -1
  20. package/src/hooks/system-prompt-hook.ts +20 -0
  21. package/src/hooks/tool-tracking-hook.ts +263 -33
  22. package/src/shared/audit-artifact-resolver.ts +75 -0
  23. package/src/shared/drop-diagnostics.ts +108 -0
  24. package/src/shared/file-utils.ts +7 -2
  25. package/src/shared/index.ts +14 -0
  26. package/src/shared/path-root-resolver.ts +34 -0
  27. package/src/shared/report-path-resolver.ts +70 -0
  28. package/src/solodit-lifecycle.ts +86 -7
  29. package/src/state/adapters.ts +262 -0
  30. package/src/state/index.ts +15 -0
  31. package/src/state/projectors.ts +437 -0
  32. package/src/state/schemas.ts +453 -0
  33. package/src/state/types.ts +6 -0
  34. package/src/tools/report-generator-tool.ts +647 -36
  35. package/src/tools/report-preflight.ts +79 -0
  36. package/src/tools/solodit-search-tool.ts +15 -24
  37. package/src/utils/solodit-health.ts +18 -0
@@ -0,0 +1,133 @@
1
+ import { stableHash } from "../../state/projectors"
2
+ import type { CanonicalFinding } from "../../state/schemas"
3
+ import type { Finding, FindingSeverity } from "../../state/types"
4
+
5
+ const SEVERITIES: readonly FindingSeverity[] = [
6
+ "Critical",
7
+ "High",
8
+ "Medium",
9
+ "Low",
10
+ "Informational",
11
+ ] as const
12
+
13
+ export interface SeverityDistribution {
14
+ Critical: number
15
+ High: number
16
+ Medium: number
17
+ Low: number
18
+ Informational: number
19
+ }
20
+
21
+ export interface ParityMetrics {
22
+ legacyFindingCount: number
23
+ canonicalFindingCount: number
24
+ findingCountDiff: number
25
+ legacySeverityDistribution: SeverityDistribution
26
+ canonicalSeverityDistribution: SeverityDistribution
27
+ severityDiffs: Partial<Record<FindingSeverity, number>>
28
+ legacyContentHash: string
29
+ canonicalContentHash: string
30
+ hashMatch: boolean
31
+ onlyInLegacy: string[]
32
+ onlyInCanonical: string[]
33
+ timestamp: number
34
+ }
35
+
36
+ function computeSeverityDistribution(
37
+ findings: Array<{ severity: FindingSeverity }>,
38
+ ): SeverityDistribution {
39
+ const dist: SeverityDistribution = {
40
+ Critical: 0,
41
+ High: 0,
42
+ Medium: 0,
43
+ Low: 0,
44
+ Informational: 0,
45
+ }
46
+ for (const f of findings) {
47
+ if (f.severity in dist) {
48
+ dist[f.severity]++
49
+ }
50
+ }
51
+ return dist
52
+ }
53
+
54
+ function findingIds(findings: Array<{ id: string }>): Set<string> {
55
+ return new Set(findings.map((f) => f.id))
56
+ }
57
+
58
+ export function computeParityMetrics(
59
+ legacyFindings: Finding[],
60
+ canonicalFindings: CanonicalFinding[],
61
+ ): ParityMetrics {
62
+ const legacySeverity = computeSeverityDistribution(legacyFindings)
63
+ const canonicalSeverity = computeSeverityDistribution(canonicalFindings)
64
+
65
+ const severityDiffs: Partial<Record<FindingSeverity, number>> = {}
66
+ for (const sev of SEVERITIES) {
67
+ const diff = canonicalSeverity[sev] - legacySeverity[sev]
68
+ if (diff !== 0) {
69
+ severityDiffs[sev] = diff
70
+ }
71
+ }
72
+
73
+ const legacyIds = findingIds(legacyFindings)
74
+ const canonicalIds = findingIds(canonicalFindings)
75
+
76
+ const onlyInLegacy = [...legacyIds].filter((id) => !canonicalIds.has(id))
77
+ const onlyInCanonical = [...canonicalIds].filter((id) => !legacyIds.has(id))
78
+
79
+ const legacyContentHash = stableHash(
80
+ legacyFindings.map((f) => ({ id: f.id, check: f.check, severity: f.severity, file: f.file })),
81
+ )
82
+ const canonicalContentHash = stableHash(
83
+ canonicalFindings.map((f) => ({
84
+ id: f.id,
85
+ check: f.check,
86
+ severity: f.severity,
87
+ file: f.file,
88
+ })),
89
+ )
90
+
91
+ return {
92
+ legacyFindingCount: legacyFindings.length,
93
+ canonicalFindingCount: canonicalFindings.length,
94
+ findingCountDiff: canonicalFindings.length - legacyFindings.length,
95
+ legacySeverityDistribution: legacySeverity,
96
+ canonicalSeverityDistribution: canonicalSeverity,
97
+ severityDiffs,
98
+ legacyContentHash,
99
+ canonicalContentHash,
100
+ hashMatch: legacyContentHash === canonicalContentHash,
101
+ onlyInLegacy,
102
+ onlyInCanonical,
103
+ timestamp: Date.now(),
104
+ }
105
+ }
106
+
107
+ export function formatParityReport(metrics: ParityMetrics): string {
108
+ const lines: string[] = [
109
+ "=== Migration Parity Report ===",
110
+ `Finding count: legacy=${metrics.legacyFindingCount} canonical=${metrics.canonicalFindingCount} diff=${metrics.findingCountDiff}`,
111
+ `Content hash match: ${metrics.hashMatch}`,
112
+ ]
113
+
114
+ const sevDiffs = Object.entries(metrics.severityDiffs)
115
+ if (sevDiffs.length > 0) {
116
+ lines.push(
117
+ `Severity diffs: ${sevDiffs.map(([k, v]) => `${k}=${v > 0 ? "+" : ""}${v}`).join(", ")}`,
118
+ )
119
+ }
120
+
121
+ if (metrics.onlyInLegacy.length > 0) {
122
+ lines.push(
123
+ `Only in legacy (${metrics.onlyInLegacy.length}): ${metrics.onlyInLegacy.join(", ")}`,
124
+ )
125
+ }
126
+ if (metrics.onlyInCanonical.length > 0) {
127
+ lines.push(
128
+ `Only in canonical (${metrics.onlyInCanonical.length}): ${metrics.onlyInCanonical.join(", ")}`,
129
+ )
130
+ }
131
+
132
+ return lines.join("\n")
133
+ }
@@ -2,10 +2,10 @@ import { mkdir, rename } from "node:fs/promises"
2
2
  import { dirname, join } from "node:path"
3
3
  import type { AuditStateManager } from "../../managers/types"
4
4
  import { createLogger } from "../../shared/logger"
5
+ import { type ArgusRootResolver, defaultRootResolver } from "../../shared/path-root-resolver"
5
6
  import { createAuditState } from "../../state/audit-state"
6
7
  import type { AuditState, PersistentAuditState } from "../../state/types"
7
8
 
8
- const STATE_FILE_DIR = ".opencode"
9
9
  const STATE_FILE_NAME = "argus-state.json"
10
10
  const STATE_VERSION = "2"
11
11
 
@@ -95,14 +95,21 @@ export function createDebouncedSave(
95
95
  }
96
96
  }
97
97
 
98
- export function createAuditStateManager(projectDir: string): AuditStateManager {
98
+ export function createAuditStateManager(
99
+ projectDir: string,
100
+ resolver: ArgusRootResolver = defaultRootResolver,
101
+ ): AuditStateManager {
99
102
  const logger = createLogger()
100
- const stateFilePath = join(projectDir, STATE_FILE_DIR, STATE_FILE_NAME)
103
+
104
+ const stateFilePath = join(resolver.writeRoot(projectDir), STATE_FILE_NAME)
101
105
  let currentState: AuditState = createAuditState(projectDir).state
102
106
 
103
107
  async function load(): Promise<AuditState | null> {
104
108
  try {
105
- const file = Bun.file(stateFilePath)
109
+ const resolvedPath = resolver.resolveReadPath(projectDir, STATE_FILE_NAME)
110
+ const readPath = resolvedPath ?? stateFilePath
111
+
112
+ const file = Bun.file(readPath)
106
113
  if (!(await file.exists())) {
107
114
  return null
108
115
  }
@@ -114,11 +121,19 @@ export function createAuditStateManager(projectDir: string): AuditStateManager {
114
121
 
115
122
  const parsed: unknown = JSON.parse(content)
116
123
  if (!isPersistentAuditState(parsed)) {
117
- logger.warn("Persistent audit state is invalid, ignoring", stateFilePath)
124
+ logger.warn("Persistent audit state is invalid, ignoring", readPath)
118
125
  return null
119
126
  }
120
127
 
121
- const { savedAt: _savedAt, version, filePath: _filePath, ...state } = parsed
128
+ const {
129
+ savedAt: _savedAt,
130
+ version,
131
+ filePath: _filePath,
132
+ source_of_truth: _sourceOfTruth,
133
+ last_event_seq: snapshotSeq,
134
+ event_stream_hash: _eventStreamHash,
135
+ ...state
136
+ } = parsed
122
137
 
123
138
  if (version === "1") {
124
139
  if (!state.soloditResults) {
@@ -129,6 +144,11 @@ export function createAuditStateManager(projectDir: string): AuditStateManager {
129
144
  }
130
145
  }
131
146
 
147
+
148
+ if (snapshotSeq !== undefined) {
149
+ logger.debug(`Loaded snapshot with last_event_seq=${snapshotSeq} from ${readPath}`)
150
+ }
151
+
132
152
  currentState = state
133
153
  return currentState
134
154
  } catch (err) {
@@ -154,6 +174,7 @@ export function createAuditStateManager(projectDir: string): AuditStateManager {
154
174
  savedAt: Date.now(),
155
175
  version: STATE_VERSION,
156
176
  filePath: stateFilePath,
177
+ source_of_truth: "events",
157
178
  }
158
179
 
159
180
  const tempFilePath = `${stateFilePath}.${Date.now()}.tmp`
@@ -205,6 +226,7 @@ export function createAuditStateManager(projectDir: string): AuditStateManager {
205
226
  savedAt: Date.now(),
206
227
  version: STATE_VERSION,
207
228
  filePath: archivePath,
229
+ source_of_truth: "events",
208
230
  }
209
231
  await Bun.write(archivePath, `${JSON.stringify(persistentState, null, 2)}\n`)
210
232
  } catch {
@@ -0,0 +1,175 @@
1
+ import { mkdir, rename } from "node:fs/promises"
2
+ import { dirname, join } from "node:path"
3
+ import {
4
+ type ArgusRootResolver,
5
+ defaultRootResolver,
6
+ } from "../../shared/path-root-resolver"
7
+ import type { AuditEvent, AuditEventType } from "../../state/schemas"
8
+
9
+ export type EventSinkErrorCode = "SEQUENCE_CONFLICT" | "INVALID_EVENT" | "IO_ERROR"
10
+
11
+ export class EventSinkError extends Error {
12
+ readonly code: EventSinkErrorCode
13
+
14
+ constructor(code: EventSinkErrorCode, message: string) {
15
+ super(message)
16
+ this.name = "EventSinkError"
17
+ this.code = code
18
+ }
19
+ }
20
+
21
+ export interface EventSink {
22
+ append(event: AuditEvent): Promise<void>
23
+ readAll(): Promise<AuditEvent[]>
24
+ }
25
+
26
+ const VALID_EVENT_TYPES: ReadonlySet<string> = new Set<AuditEventType>([
27
+ "session.created",
28
+ "session.idle",
29
+ "session.deleted",
30
+ "tool.started",
31
+ "tool.completed",
32
+ "finding.added",
33
+ "phase.changed",
34
+ "run.finalized",
35
+ ])
36
+
37
+ function createMutex() {
38
+ let chain = Promise.resolve()
39
+
40
+ return {
41
+ async run<T>(fn: () => Promise<T>): Promise<T> {
42
+ const prev = chain
43
+ let release!: () => void
44
+ chain = new Promise<void>((r) => {
45
+ release = r
46
+ })
47
+
48
+ await prev
49
+
50
+ try {
51
+ return await fn()
52
+ } finally {
53
+ release()
54
+ }
55
+ },
56
+ }
57
+ }
58
+
59
+ function buildJournalPath(runId: string, projectDir: string, resolver: ArgusRootResolver): string {
60
+ return join(resolver.writeRoot(projectDir), "runs", runId, "events.jsonl")
61
+ }
62
+
63
+ async function readRawContent(path: string): Promise<string> {
64
+ const file = Bun.file(path)
65
+ if (!(await file.exists())) {
66
+ return ""
67
+ }
68
+ return file.text()
69
+ }
70
+
71
+ function parseJournalLines(content: string): AuditEvent[] {
72
+ if (!content.trim()) return []
73
+
74
+ const lines = content.split("\n").filter(Boolean)
75
+ const events: AuditEvent[] = []
76
+
77
+ for (const line of lines) {
78
+ try {
79
+ events.push(JSON.parse(line) as AuditEvent)
80
+ } catch {
81
+ /* skip malformed lines */
82
+ }
83
+ }
84
+
85
+ events.sort((a, b) => a.seq - b.seq)
86
+ return events
87
+ }
88
+
89
+ /**
90
+ * Replay-safe stateless read — returns all events for a run sorted by seq.
91
+ */
92
+ export async function readEvents(runId: string, projectDir: string, resolver: ArgusRootResolver = defaultRootResolver): Promise<AuditEvent[]> {
93
+ const journalPath = buildJournalPath(runId, projectDir, resolver)
94
+ const content = await readRawContent(journalPath)
95
+ return parseJournalLines(content)
96
+ }
97
+
98
+ /**
99
+ * Append-only event sink with monotonic seq allocation, in-process mutex,
100
+ * and atomic temp-file-then-rename writes. Restart-safe via journal replay.
101
+ */
102
+ export function createEventSink(runId: string, projectDir: string, resolver: ArgusRootResolver = defaultRootResolver): EventSink {
103
+ const journalPath = buildJournalPath(runId, projectDir, resolver)
104
+ const mutex = createMutex()
105
+ let lastSeq = 0
106
+ let initialized = false
107
+
108
+ async function ensureInitialized(): Promise<void> {
109
+ if (initialized) return
110
+
111
+ try {
112
+ const content = await readRawContent(journalPath)
113
+ const events = parseJournalLines(content)
114
+ const lastEvent = events.at(-1)
115
+ if (lastEvent) {
116
+ lastSeq = lastEvent.seq
117
+ }
118
+ } catch (err) {
119
+ throw new EventSinkError("IO_ERROR", `Failed to initialize event sink: ${String(err)}`)
120
+ }
121
+
122
+ initialized = true
123
+ }
124
+
125
+ return {
126
+ async append(event: AuditEvent): Promise<void> {
127
+ return mutex.run(async () => {
128
+ await ensureInitialized()
129
+
130
+ if (event.run_id !== runId) {
131
+ throw new EventSinkError(
132
+ "INVALID_EVENT",
133
+ `Event run_id "${event.run_id}" does not match sink run_id "${runId}"`,
134
+ )
135
+ }
136
+
137
+ if (!event.type || !VALID_EVENT_TYPES.has(event.type)) {
138
+ throw new EventSinkError("INVALID_EVENT", `Invalid event type "${String(event.type)}"`)
139
+ }
140
+
141
+ if (event.seq > 0 && event.seq <= lastSeq) {
142
+ throw new EventSinkError(
143
+ "SEQUENCE_CONFLICT",
144
+ `Event seq ${event.seq} conflicts with last assigned seq ${lastSeq}; must be > ${lastSeq}`,
145
+ )
146
+ }
147
+
148
+ const nextSeq = lastSeq + 1
149
+ const eventToWrite: AuditEvent = { ...event, seq: nextSeq }
150
+
151
+ const currentContent = await readRawContent(journalPath)
152
+ const newContent = `${currentContent}${JSON.stringify(eventToWrite)}\n`
153
+
154
+ await mkdir(dirname(journalPath), { recursive: true })
155
+
156
+ const suffix = `${Date.now()}.${Math.random().toString(36).slice(2)}`
157
+ const tempPath = `${journalPath}.${suffix}.tmp`
158
+
159
+ try {
160
+ await Bun.write(tempPath, newContent)
161
+ await rename(tempPath, journalPath)
162
+ } catch (err) {
163
+ throw new EventSinkError("IO_ERROR", `Failed to write event to journal: ${String(err)}`)
164
+ }
165
+
166
+ lastSeq = nextSeq
167
+ })
168
+ },
169
+
170
+ async readAll(): Promise<AuditEvent[]> {
171
+ const content = await readRawContent(journalPath)
172
+ return parseJournalLines(content)
173
+ },
174
+ }
175
+ }
@@ -0,0 +1,51 @@
1
+ import { mkdir, writeFile } from "node:fs/promises"
2
+ import { dirname } from "node:path"
3
+ import { createAuditArtifactResolver } from "../../shared/audit-artifact-resolver"
4
+ import { projectFindings, projectToolExecutions, stableHash } from "../../state/projectors"
5
+ import type { CanonicalFinding, CanonicalToolExecution } from "../../state/schemas"
6
+ import { SCHEMA_VERSION } from "../../state/schemas"
7
+ import { readEvents } from "./event-sink"
8
+
9
+ export interface FindingsArtifact {
10
+ run_id: string
11
+ session_id: string
12
+ schema_version: string
13
+ seq_first: number
14
+ seq_last: number
15
+ event_count: number
16
+ content_hash: string
17
+ generated_at: number
18
+ findings: CanonicalFinding[]
19
+ toolsExecuted: CanonicalToolExecution[]
20
+ }
21
+
22
+ export async function materializeFindings(
23
+ runId: string,
24
+ projectDir: string,
25
+ sessionId?: string,
26
+ ): Promise<FindingsArtifact> {
27
+ const events = await readEvents(runId, projectDir)
28
+ const findings = projectFindings(events)
29
+ const toolsExecuted = projectToolExecutions(events)
30
+ const contentHash = stableHash(JSON.stringify(findings))
31
+ const generatedAt = events.at(-1)?.timestamp ?? 0
32
+
33
+ const artifact: FindingsArtifact = {
34
+ run_id: runId,
35
+ session_id: sessionId ?? events[0]?.session_id ?? "",
36
+ schema_version: SCHEMA_VERSION,
37
+ seq_first: events[0]?.seq ?? 0,
38
+ seq_last: events.at(-1)?.seq ?? 0,
39
+ event_count: events.length,
40
+ content_hash: contentHash,
41
+ generated_at: generatedAt,
42
+ findings,
43
+ toolsExecuted,
44
+ }
45
+
46
+ const findingsFile = createAuditArtifactResolver(runId, projectDir).paths().findingsFile
47
+ await mkdir(dirname(findingsFile), { recursive: true })
48
+ await writeFile(findingsFile, JSON.stringify(artifact, null, 2))
49
+
50
+ return artifact
51
+ }
@@ -1 +1,3 @@
1
1
  export { createAuditStateManager } from "./audit-state-manager"
2
+ export type { EventSink, EventSinkErrorCode } from "./event-sink"
3
+ export { createEventSink, EventSinkError, readEvents } from "./event-sink"
@@ -0,0 +1,192 @@
1
+ import { validateEventSequence } from "../../state/projectors"
2
+ import type { AuditEvent } from "../../state/schemas"
3
+ import { SCHEMA_VERSION } from "../../state/schemas"
4
+ import type { EventSink } from "./event-sink"
5
+ import { readEvents } from "./event-sink"
6
+
7
+ export type FinalizationResult = {
8
+ success: boolean
9
+ invariantsPassed: boolean
10
+ errors: string[]
11
+ runId: string
12
+ timestamp: number
13
+ }
14
+
15
+ export function hasSessionCreated(events: AuditEvent[]): boolean {
16
+ return events.some((event) => event.type === "session.created")
17
+ }
18
+
19
+ export function hasSessionDeleted(events: AuditEvent[]): boolean {
20
+ return events.some((event) => event.type === "session.deleted")
21
+ }
22
+
23
+ export type ToolLifecycleCheckResult = {
24
+ orphanedToolCallIds: string[]
25
+ malformedEvents: string[]
26
+ }
27
+
28
+ export function collectToolLifecycleIssues(events: AuditEvent[]): ToolLifecycleCheckResult {
29
+ const startedCallIds = new Set<string>()
30
+ const completedCallIds = new Set<string>()
31
+ const malformedEvents: string[] = []
32
+
33
+ for (const event of events) {
34
+ if (event.type !== "tool.started" && event.type !== "tool.completed") {
35
+ continue
36
+ }
37
+
38
+ if (typeof event.tool_call_id !== "string" || event.tool_call_id.length === 0) {
39
+ malformedEvents.push(`${event.type} at seq ${event.seq} missing tool_call_id`)
40
+ continue
41
+ }
42
+
43
+ if (event.type === "tool.started") {
44
+ startedCallIds.add(event.tool_call_id)
45
+ }
46
+
47
+ if (event.type === "tool.completed") {
48
+ completedCallIds.add(event.tool_call_id)
49
+ }
50
+ }
51
+
52
+ const orphanedToolCallIds: string[] = []
53
+ for (const toolCallId of startedCallIds) {
54
+ if (!completedCallIds.has(toolCallId)) {
55
+ orphanedToolCallIds.push(toolCallId)
56
+ }
57
+ }
58
+
59
+ return {
60
+ orphanedToolCallIds,
61
+ malformedEvents,
62
+ }
63
+ }
64
+
65
+ function collectOrphanedToolStarts(events: AuditEvent[]): string[] {
66
+ const { orphanedToolCallIds, malformedEvents } = collectToolLifecycleIssues(events)
67
+ const orphanedErrors = orphanedToolCallIds.map(
68
+ (toolCallId) => `orphaned tool.started without matching tool.completed: ${toolCallId}`,
69
+ )
70
+ return [...malformedEvents, ...orphanedErrors]
71
+ }
72
+
73
+ function asRecord(value: unknown): Record<string, unknown> | null {
74
+ if (typeof value === "object" && value !== null && !Array.isArray(value)) {
75
+ return value as Record<string, unknown>
76
+ }
77
+ return null
78
+ }
79
+
80
+ function collectParentChildIntegrityErrors(events: AuditEvent[]): string[] {
81
+ const errors: string[] = []
82
+ const parentByChild = new Map<string, string>()
83
+ const correlationByChild = new Map<string, string>()
84
+
85
+ for (const event of events) {
86
+ const payload = asRecord(event.payload)
87
+ if (!payload) {
88
+ continue
89
+ }
90
+
91
+ const childSessionId = payload.child_session_id
92
+ if (typeof childSessionId !== "string" || childSessionId.length === 0) {
93
+ continue
94
+ }
95
+
96
+ const parentSessionId = event.session_id
97
+ if (!parentSessionId) {
98
+ errors.push(`child session edge at seq ${event.seq} missing parent session_id`)
99
+ continue
100
+ }
101
+
102
+ if (parentSessionId === childSessionId) {
103
+ errors.push(`child session edge at seq ${event.seq} is self-referential`)
104
+ }
105
+
106
+ const correlationId = payload.correlation_id
107
+ if (typeof correlationId !== "string" || correlationId.length === 0) {
108
+ errors.push(`child session edge at seq ${event.seq} missing correlation_id`)
109
+ continue
110
+ }
111
+
112
+ const existingParent = parentByChild.get(childSessionId)
113
+ if (existingParent && existingParent !== parentSessionId) {
114
+ errors.push(
115
+ `child session ${childSessionId} mapped to multiple parents: ${existingParent}, ${parentSessionId}`,
116
+ )
117
+ } else {
118
+ parentByChild.set(childSessionId, parentSessionId)
119
+ }
120
+
121
+ const existingCorrelation = correlationByChild.get(childSessionId)
122
+ if (existingCorrelation && existingCorrelation !== correlationId) {
123
+ errors.push(
124
+ `child session ${childSessionId} has inconsistent correlation_id: ${existingCorrelation}, ${correlationId}`,
125
+ )
126
+ } else {
127
+ correlationByChild.set(childSessionId, correlationId)
128
+ }
129
+ }
130
+
131
+ return errors
132
+ }
133
+
134
+ function collectInvariantErrors(events: AuditEvent[]): string[] {
135
+ const errors: string[] = []
136
+
137
+ try {
138
+ validateEventSequence(events)
139
+ } catch (error) {
140
+ errors.push(`invalid event sequence: ${error instanceof Error ? error.message : String(error)}`)
141
+ }
142
+
143
+ if (!hasSessionCreated(events)) {
144
+ errors.push("missing required lifecycle event: session.created")
145
+ }
146
+
147
+ if (!hasSessionDeleted(events)) {
148
+ errors.push("missing required lifecycle event: session.deleted")
149
+ }
150
+
151
+ errors.push(...collectOrphanedToolStarts(events))
152
+ errors.push(...collectParentChildIntegrityErrors(events))
153
+ return errors
154
+ }
155
+
156
+ export async function finalizeRun(
157
+ runId: string,
158
+ projectDir: string,
159
+ sink: EventSink | null,
160
+ ): Promise<FinalizationResult> {
161
+ const timestamp = Date.now()
162
+ const events = sink ? await sink.readAll() : await readEvents(runId, projectDir)
163
+ const errors = collectInvariantErrors(events)
164
+ const invariantsPassed = errors.length === 0
165
+ const sessionId = events.at(-1)?.session_id ?? ""
166
+
167
+ if (sink) {
168
+ await sink.append({
169
+ type: "run.finalized",
170
+ run_id: runId,
171
+ seq: 0,
172
+ session_id: sessionId,
173
+ source: "run-finalizer",
174
+ schema_version: SCHEMA_VERSION,
175
+ timestamp,
176
+ payload: {
177
+ finalized: invariantsPassed,
178
+ invariantsPassed,
179
+ errors,
180
+ status: invariantsPassed ? "finalized" : "failed-finalization",
181
+ },
182
+ })
183
+ }
184
+
185
+ return {
186
+ success: invariantsPassed,
187
+ invariantsPassed,
188
+ errors,
189
+ runId,
190
+ timestamp,
191
+ }
192
+ }
@@ -1,10 +1,13 @@
1
1
  import { appendFile, mkdir } from "node:fs/promises"
2
2
  import { dirname, join } from "node:path"
3
+ import {
4
+ type ArgusRootResolver,
5
+ defaultRootResolver,
6
+ } from "../../shared/path-root-resolver"
3
7
  import { createLogger } from "../../shared/logger"
4
8
 
5
9
  const logger = createLogger()
6
10
 
7
- const JOURNAL_DIR = ".opencode"
8
11
  const JOURNAL_FILE = "argus-journal.jsonl"
9
12
 
10
13
  export type JournalEvent =
@@ -15,7 +18,12 @@ export type JournalEvent =
15
18
  findingsCount: number
16
19
  toolsExecutedCount: number
17
20
  }
18
- | { type: "session.deleted"; timestamp: number; archived: boolean }
21
+ | {
22
+ type: "session.deleted"
23
+ timestamp: number
24
+ archived: boolean
25
+ finalizationPassed: boolean | null
26
+ }
19
27
  | {
20
28
  type: "tool.executed"
21
29
  tool: string
@@ -30,12 +38,15 @@ export type JournalEvent =
30
38
  findingsCount: number
31
39
  }
32
40
 
33
- export function createRunJournal(projectDir: string): {
41
+ export function createRunJournal(
42
+ projectDir: string,
43
+ resolver: ArgusRootResolver = defaultRootResolver,
44
+ ): {
34
45
  log(event: JournalEvent): void
35
46
  close(): Promise<void>
36
47
  getPath(): string
37
48
  } {
38
- const journalPath = join(projectDir, JOURNAL_DIR, JOURNAL_FILE)
49
+ const journalPath = join(resolver.writeRoot(projectDir), JOURNAL_FILE)
39
50
  let ensureDirPromise: Promise<void> | null = null
40
51
  const pendingWrites = new Set<Promise<void>>()
41
52