solidity-argus 0.3.4 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "solidity-argus",
3
- "version": "0.3.4",
3
+ "version": "0.3.5",
4
4
  "description": "Solidity smart contract security auditing plugin for OpenCode — 4 specialized agents, 12 tools (11 core + optional Solodit), and a curated vulnerability knowledge base",
5
5
  "keywords": [
6
6
  "solidity",
@@ -225,6 +225,49 @@ Task(subagent_type="scribe", prompt="Generate the final audit report for Project
225
225
  \`\`\`
226
226
  - Wait for both to complete before synthesizing their results.
227
227
 
228
+ ### STATE-FIRST SYNTHESIS POLICY
229
+
230
+ **Synthesize and report from durable evidence — not transcript tails.**
231
+
232
+ When building the final report or synthesizing findings:
233
+ 1. **Primary source**: \`toolsExecuted\` records, \`findings\` from state, and event stream data persisted via argus_* tool outputs.
234
+ 2. **Secondary source**: Tool transcript text (use only when durable evidence is unavailable or incomplete).
235
+ 3. **Never** synthesize findings from ephemeral background transcript retrieval alone if durable state evidence exists.
236
+
237
+ **Bounded background fan-out**: For deep audits, limit concurrent high-context background delegations to max 2 at a time. Split larger workloads into sequential waves. This prevents retrieval blind spots from simultaneous long-running tasks.
238
+
239
+ Example — correct fan-out:
240
+ - Wave 1: [Sentinel: slither + pattern check] + [Pythia: solodit search] (2 background tasks)
241
+ - Wait for both. Then Wave 2: [Sentinel: forge tests] (1 background task)
242
+
243
+ ## SYNTHESIS BARRIER: MUST NOT PROCEED WITHOUT DURABLE EVIDENCE
244
+
245
+ You **must not proceed** to synthesis or report generation until required durable evidence is confirmed present:
246
+ - \`toolsExecuted\` records exist for all planned tools
247
+ - Expected findings coverage is populated in state
248
+ - Lifecycle invariants are satisfied (no orphaned tool starts)
249
+
250
+ ### Adaptive Retrieval Budget
251
+
252
+ When waiting for background tasks, use bounded retrieval budgets by workload class:
253
+
254
+ | Class | Budget | Criteria |
255
+ |----------|---------|---------------------------------------------|
256
+ | quick | 60s | Single-tool or single-contract checks |
257
+ | standard | 180s | Multi-tool single-agent batches |
258
+ | deep | 600s | Multi-agent or synthesis-heavy runs |
259
+
260
+ Poll until the task reaches a terminal state: \`completed\`, \`error\`, \`cancelled\`, or \`interrupt\`.
261
+
262
+ ### Re-dispatch (LAST RESORT)
263
+
264
+ Re-dispatch is only justified when ALL of these are true:
265
+ 1. The task has reached terminal state OR retrieval budget has expired
266
+ 2. Required durable evidence is STILL missing from state/events
267
+ 3. The gap is specific and bounded (not a general "redo everything")
268
+
269
+ **When re-dispatching**: Target only missing evidence segments. Use \`run_in_background=false\` (foreground only) for re-dispatch pivots. Do NOT re-dispatch routinely after a single transcript retrieval miss if durable state evidence is already complete.
270
+
228
271
  ## TASK COMPLETION TRACKING
229
272
 
230
273
  You must track which audit phases are complete to avoid redundant work and tool re-execution.
@@ -424,6 +467,8 @@ Tools may fail. You must be resilient.
424
467
 
425
468
  After you have synthesized your findings, build a canonical ReportInput payload and invoke Scribe:
426
469
 
470
+ **State-first requirement**: Before invoking Scribe, verify that \`toolsExecuted\` in your ReportInput contains entries for each tool you ran. Do NOT proceed to report generation if required tool coverage is missing from durable state — re-run the missing tool instead. Use \`preflight_policy: "strict-fail"\` for the final report invocation.
471
+
427
472
  \`\`\`
428
473
  Task(subagent_type="scribe", prompt="Generate the final security audit report.
429
474
 
@@ -454,6 +499,7 @@ Scribe must call argus_generate_report with:
454
499
  - project_name: project name
455
500
  - scope: audited file list
456
501
  - report_input: serialized ReportInput JSON string
502
+ - preflight_policy: "strict-fail" (non-negotiable for final report)
457
503
 
458
504
  Legacy audit_state is transitional-only and deprecated.
459
505
 
@@ -44,10 +44,14 @@ You must adhere to these strict writing standards:
44
44
  Argus passes you structured report data. Use that payload directly and keep it schema-accurate.
45
45
 
46
46
  **Your workflow**:
47
- 1. Validate Argus provided a serialized ReportInput JSON string (schema_version 1.0.0) with required fields: run_id, seq, session_id, tool_call_id, source, schema_version, projectDir, findings, toolsExecuted, scope.
47
+ 1. Validate Argus provided a serialized ReportInput JSON string (schema_version 1.0.0) with required fields: run_id, seq, session_id, tool_call_id, source, schema_version, projectDir, findings, toolsExecuted, scope. **Execution integrity check**: \`toolsExecuted\` must be non-empty for the audit to be considered complete. If \`toolsExecuted\` is empty or missing key tool families (slither, forge, patterns), add a \`## Limitations\` section to the report noting which tool coverage is absent.
48
48
  2. Write the complete report in Markdown following the Report Structure and Output Format sections.
49
49
  3. Call \`argus_generate_report\` with arguments { project_name, scope, report_input }. Use legacy \`audit_state\` only for transitional compatibility and treat it as deprecated.
50
- 4. Confirm the report was generated in your response to Argus: "Report generated via argus_generate_report: {filePath}".
50
+ 4. **Limitations disclosure** (MANDATORY when tools fail): If any tool was unavailable, timed out, or failed, add a \`## Limitations\` section to the report BEFORE \`## Findings\`. Use this format:
51
+ - \`**Tool name**: [reason \u2014 unavailable/failed/timed out]. [Impact on finding coverage if any.]\`
52
+ - Example: \`**argus_solodit_search**: External database was unavailable. Known-vulnerability cross-referencing was performed using local patterns only.\`
53
+ - Never silently omit limitations — incomplete coverage must be disclosed.
54
+ 5. Confirm the report was generated in your response to Argus: "Report generated via argus_generate_report: {filePath}".
51
55
 
52
56
  ## SINGLE-WRITER POLICY
53
57
 
@@ -20,7 +20,7 @@ export const initCommand: CliCommand = {
20
20
  description: "Initialize Argus configuration for this project",
21
21
  async execute(_args: string[]): Promise<number> {
22
22
  const cwd = process.cwd()
23
- const configDir = join(cwd, ".opencode")
23
+ const configDir = join(cwd, ".argus")
24
24
  const configPath = join(configDir, "solidity-argus.json")
25
25
 
26
26
  if (existsSync(configPath)) {
@@ -31,7 +31,7 @@ const ReportingConfigSchema = z.object({
31
31
  format: z.enum(["markdown"]).default("markdown"),
32
32
  severityThreshold: z.enum(["critical", "high", "medium", "low", "informational"]).default("low"),
33
33
  gasAnalysis: z.boolean().default(false),
34
- output_dir: z.string().default(".opencode/reports/"),
34
+ output_dir: z.string().default(".argus/reports/"),
35
35
  })
36
36
 
37
37
  const SoloditConfigSchema = z.object({
@@ -74,7 +74,7 @@ export const ArgusConfigSchema = z.object({
74
74
  format: "markdown",
75
75
  severityThreshold: "low",
76
76
  gasAnalysis: false,
77
- output_dir: ".opencode/reports/",
77
+ output_dir: ".argus/reports/",
78
78
  }),
79
79
  solodit: SoloditConfigSchema.default({
80
80
  enabled: true,
@@ -1,4 +1,3 @@
1
- import { join } from "node:path"
2
1
  import type { Hooks as PluginHooks } from "@opencode-ai/plugin"
3
2
  import type { ArgusConfig } from "./config/types"
4
3
  import { createAuditEnforcer } from "./features/audit-enforcer/audit-enforcer"
@@ -7,9 +6,12 @@ import {
7
6
  createSessionRecoveryHandler,
8
7
  createToolErrorRecoveryHandler,
9
8
  } from "./features/error-recovery"
10
- import { createDebouncedSave } from "./features/persistent-state/audit-state-manager"
11
9
  import { getMigrationMode } from "./features/migration"
10
+ import { adaptLegacyFindings } from "./features/migration/migration-adapter"
11
+ import { computeParityMetrics, formatParityReport } from "./features/migration/parity-telemetry"
12
+ import { createDebouncedSave } from "./features/persistent-state/audit-state-manager"
12
13
  import { createEventSink, type EventSink } from "./features/persistent-state/event-sink"
14
+ import { materializeFindings } from "./features/persistent-state/findings-materializer"
13
15
  import { recordRun } from "./features/persistent-state/global-run-index"
14
16
  import { createRunJournal } from "./features/persistent-state/run-journal"
15
17
  import { createAgentTracker } from "./hooks/agent-tracker"
@@ -24,8 +26,8 @@ import { createSystemPromptHook } from "./hooks/system-prompt-hook"
24
26
  import { createToolTrackingHook } from "./hooks/tool-tracking-hook"
25
27
  import type { HookName } from "./hooks/types"
26
28
  import type { Managers } from "./managers/types"
27
- import { createLogger } from "./shared/logger"
28
29
  import { createAuditArtifactResolver } from "./shared/audit-artifact-resolver"
30
+ import { createLogger } from "./shared/logger"
29
31
  import type { AuditState } from "./state/types"
30
32
  import { detectAuditArtifacts } from "./utils/audit-artifact-detector"
31
33
  import { detectProject, type ProjectConfig } from "./utils/project-detector"
@@ -135,10 +137,8 @@ export function createHooks(args: {
135
137
 
136
138
  const effectiveState = recoveredState ?? auditStateManager.get()
137
139
  if (effectiveState) {
140
+ const resolver = createAuditArtifactResolver(effectiveState.sessionId, projectDir)
138
141
  try {
139
- // createAuditArtifactResolver is the canonical source for all run artifact paths.
140
- // The journal file path is: {projectDir}/.opencode/runs/{runId}/events.jsonl
141
- const resolver = createAuditArtifactResolver(effectiveState.sessionId, projectDir)
142
142
  const journalFile = resolver.paths().journalFile
143
143
  // createEventSink builds the same path internally; the resolver makes it explicit.
144
144
  currentEventSink = createEventSink(effectiveState.sessionId, projectDir)
@@ -149,13 +149,12 @@ export function createHooks(args: {
149
149
  `Failed to create event sink: ${error instanceof Error ? error.message : String(error)}`,
150
150
  )
151
151
  }
152
-
153
152
  void recordRun({
154
153
  runId: effectiveState.sessionId,
155
154
  opencodeSessionId: sessionId,
156
155
  projectDir: effectiveState.projectDir,
157
- statePath: join(effectiveState.projectDir, ".opencode", "argus-state.json"),
158
- journalPath: join(effectiveState.projectDir, ".opencode", "argus-journal.jsonl"),
156
+ statePath: resolver.paths().stateFile,
157
+ journalPath: resolver.paths().journalFile,
159
158
  startedAt: effectiveState.startTime,
160
159
  phase: effectiveState.currentPhase,
161
160
  findingsCount: effectiveState.findings.length,
@@ -188,17 +187,36 @@ export function createHooks(args: {
188
187
  toolsExecutedCount: auditState.toolsExecuted.length,
189
188
  })
190
189
 
190
+ const idleResolver = createAuditArtifactResolver(
191
+ auditState.sessionId,
192
+ auditState.projectDir,
193
+ )
191
194
  void recordRun({
192
195
  runId: auditState.sessionId,
193
196
  opencodeSessionId: sessionId,
194
197
  projectDir: auditState.projectDir,
195
- statePath: join(auditState.projectDir, ".opencode", "argus-state.json"),
196
- journalPath: join(auditState.projectDir, ".opencode", "argus-journal.jsonl"),
198
+ statePath: idleResolver.paths().stateFile,
199
+ journalPath: idleResolver.paths().journalFile,
197
200
  startedAt: auditState.startTime,
198
201
  phase: auditState.currentPhase,
199
202
  findingsCount: auditState.findings.length,
200
203
  })
201
204
 
205
+ if (migrationMode !== "legacy") {
206
+ try {
207
+ const { legacyFindings, canonicalFindings } = adaptLegacyFindings(
208
+ auditState,
209
+ migrationMode,
210
+ auditState.sessionId,
211
+ )
212
+ const parityMetrics = computeParityMetrics(legacyFindings, canonicalFindings)
213
+ logger.debug(formatParityReport(parityMetrics))
214
+ } catch (error) {
215
+ logger.warn(
216
+ `Migration parity check failed: ${error instanceof Error ? error.message : String(error)}`,
217
+ )
218
+ }
219
+ }
202
220
  return
203
221
  }
204
222
 
@@ -302,11 +320,26 @@ export function createHooks(args: {
302
320
  ? safeCreateHook(
303
321
  () => async (input: Parameters<typeof eventHook>[0]) => {
304
322
  const isSessionDeleted = input.event.type === "session.deleted"
323
+ const finalizationBeforeDelete = isSessionDeleted ? getLastFinalizationResult() : null
305
324
 
306
325
  try {
307
326
  await eventHook(input)
308
327
  } finally {
309
328
  if (isSessionDeleted) {
329
+ const finalizationResult = getLastFinalizationResult()
330
+ const hasNewFinalization =
331
+ finalizationResult !== null && finalizationResult !== finalizationBeforeDelete
332
+
333
+ if (hasNewFinalization && finalizationResult.runId.length > 0) {
334
+ try {
335
+ await materializeFindings(finalizationResult.runId, projectDir)
336
+ } catch (error) {
337
+ logger.warn(
338
+ `Failed to materialize findings artifact for run ${finalizationResult.runId}: ${error instanceof Error ? error.message : String(error)}`,
339
+ )
340
+ }
341
+ }
342
+
310
343
  await auditStateManager.archive()
311
344
 
312
345
  const deletedSessionId = input.event.sessionId
@@ -318,7 +351,7 @@ export function createHooks(args: {
318
351
  type: "session.deleted",
319
352
  timestamp: Date.now(),
320
353
  archived: true,
321
- finalizationPassed: getLastFinalizationResult()?.invariantsPassed ?? null,
354
+ finalizationPassed: finalizationResult?.invariantsPassed ?? null,
322
355
  })
323
356
 
324
357
  currentEventSink = null
@@ -11,6 +11,24 @@ const PHASE_ORDER: AuditPhase[] = [
11
11
  "complete",
12
12
  ]
13
13
 
14
+ const REPORTING_PHASES: AuditPhase[] = ["reporting", "complete"]
15
+
16
+
17
+ const KEY_TOOL_FAMILIES: Array<{ family: string; prefixes: string[] }> = [
18
+ { family: "slither", prefixes: ["argus_slither_analyze", "slither"] },
19
+ { family: "forge_test", prefixes: ["argus_forge_test", "forge_test"] },
20
+ { family: "forge_fuzz", prefixes: ["argus_forge_fuzz", "forge_fuzz"] },
21
+ { family: "forge_coverage", prefixes: ["argus_forge_coverage", "forge_coverage"] },
22
+ ]
23
+
24
+ function getMissingToolFamilies(auditState: AuditState): string[] {
25
+ const executedTools = auditState.toolsExecuted.map((t) => t.tool)
26
+ return KEY_TOOL_FAMILIES.filter(
27
+ ({ prefixes }) =>
28
+ !executedTools.some((tool) => prefixes.some((prefix) => tool.startsWith(prefix))),
29
+ ).map(({ family }) => family)
30
+ }
31
+
14
32
  function getNextPhase(current: AuditPhase): AuditPhase | null {
15
33
  const idx = PHASE_ORDER.indexOf(current)
16
34
  if (idx === -1 || idx >= PHASE_ORDER.length - 1) return null
@@ -25,10 +43,21 @@ export function createAuditEnforcer() {
25
43
  const nextPhase = getNextPhase(auditState.currentPhase)
26
44
  if (!nextPhase) return null
27
45
 
28
- return [
46
+ const parts: string[] = [
29
47
  `[Argus Audit Enforcer] Audit in progress — current phase: ${auditState.currentPhase}.`,
30
48
  `Next phase: ${nextPhase}. Do not stop until audit is complete.`,
31
49
  `Progress: ${auditState.findings.length} findings, ${auditState.contractsReviewed.length} contracts reviewed.`,
32
- ].join(" ")
50
+ ]
51
+
52
+ if (REPORTING_PHASES.includes(auditState.currentPhase)) {
53
+ const missing = getMissingToolFamilies(auditState)
54
+ if (missing.length > 0) {
55
+ parts.push(
56
+ `\u26a0\ufe0f Tool coverage incomplete: ${missing.join(", ")} have not been executed. Do not proceed to report generation until required tools are complete.`,
57
+ )
58
+ }
59
+ }
60
+
61
+ return parts.join(" ")
33
62
  }
34
63
  }
@@ -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 {
@@ -1,5 +1,9 @@
1
1
  import { mkdir, rename } 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 type { AuditEvent, AuditEventType } from "../../state/schemas"
4
8
 
5
9
  export type EventSinkErrorCode = "SEQUENCE_CONFLICT" | "INVALID_EVENT" | "IO_ERROR"
@@ -52,8 +56,8 @@ function createMutex() {
52
56
  }
53
57
  }
54
58
 
55
- function buildJournalPath(runId: string, projectDir: string): string {
56
- return join(projectDir, ".opencode", "runs", runId, "events.jsonl")
59
+ function buildJournalPath(runId: string, projectDir: string, resolver: ArgusRootResolver): string {
60
+ return join(resolver.writeRoot(projectDir), "runs", runId, "events.jsonl")
57
61
  }
58
62
 
59
63
  async function readRawContent(path: string): Promise<string> {
@@ -85,8 +89,8 @@ function parseJournalLines(content: string): AuditEvent[] {
85
89
  /**
86
90
  * Replay-safe stateless read — returns all events for a run sorted by seq.
87
91
  */
88
- export async function readEvents(runId: string, projectDir: string): Promise<AuditEvent[]> {
89
- const journalPath = buildJournalPath(runId, projectDir)
92
+ export async function readEvents(runId: string, projectDir: string, resolver: ArgusRootResolver = defaultRootResolver): Promise<AuditEvent[]> {
93
+ const journalPath = buildJournalPath(runId, projectDir, resolver)
90
94
  const content = await readRawContent(journalPath)
91
95
  return parseJournalLines(content)
92
96
  }
@@ -95,8 +99,8 @@ export async function readEvents(runId: string, projectDir: string): Promise<Aud
95
99
  * Append-only event sink with monotonic seq allocation, in-process mutex,
96
100
  * and atomic temp-file-then-rename writes. Restart-safe via journal replay.
97
101
  */
98
- export function createEventSink(runId: string, projectDir: string): EventSink {
99
- const journalPath = buildJournalPath(runId, projectDir)
102
+ export function createEventSink(runId: string, projectDir: string, resolver: ArgusRootResolver = defaultRootResolver): EventSink {
103
+ const journalPath = buildJournalPath(runId, projectDir, resolver)
100
104
  const mutex = createMutex()
101
105
  let lastSeq = 0
102
106
  let initialized = false
@@ -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,3 +1,3 @@
1
1
  export { createAuditStateManager } from "./audit-state-manager"
2
- export { createEventSink, readEvents, EventSinkError } from "./event-sink"
3
2
  export type { EventSink, EventSinkErrorCode } from "./event-sink"
3
+ export { createEventSink, EventSinkError, readEvents } from "./event-sink"
@@ -12,18 +12,23 @@ export type FinalizationResult = {
12
12
  timestamp: number
13
13
  }
14
14
 
15
- function hasSessionCreated(events: AuditEvent[]): boolean {
15
+ export function hasSessionCreated(events: AuditEvent[]): boolean {
16
16
  return events.some((event) => event.type === "session.created")
17
17
  }
18
18
 
19
- function hasSessionDeleted(events: AuditEvent[]): boolean {
19
+ export function hasSessionDeleted(events: AuditEvent[]): boolean {
20
20
  return events.some((event) => event.type === "session.deleted")
21
21
  }
22
22
 
23
- function collectOrphanedToolStarts(events: AuditEvent[]): string[] {
23
+ export type ToolLifecycleCheckResult = {
24
+ orphanedToolCallIds: string[]
25
+ malformedEvents: string[]
26
+ }
27
+
28
+ export function collectToolLifecycleIssues(events: AuditEvent[]): ToolLifecycleCheckResult {
24
29
  const startedCallIds = new Set<string>()
25
30
  const completedCallIds = new Set<string>()
26
- const errors: string[] = []
31
+ const malformedEvents: string[] = []
27
32
 
28
33
  for (const event of events) {
29
34
  if (event.type !== "tool.started" && event.type !== "tool.completed") {
@@ -31,7 +36,7 @@ function collectOrphanedToolStarts(events: AuditEvent[]): string[] {
31
36
  }
32
37
 
33
38
  if (typeof event.tool_call_id !== "string" || event.tool_call_id.length === 0) {
34
- errors.push(`${event.type} at seq ${event.seq} missing tool_call_id`)
39
+ malformedEvents.push(`${event.type} at seq ${event.seq} missing tool_call_id`)
35
40
  continue
36
41
  }
37
42
 
@@ -44,13 +49,25 @@ function collectOrphanedToolStarts(events: AuditEvent[]): string[] {
44
49
  }
45
50
  }
46
51
 
52
+ const orphanedToolCallIds: string[] = []
47
53
  for (const toolCallId of startedCallIds) {
48
54
  if (!completedCallIds.has(toolCallId)) {
49
- errors.push(`orphaned tool.started without matching tool.completed: ${toolCallId}`)
55
+ orphanedToolCallIds.push(toolCallId)
50
56
  }
51
57
  }
52
58
 
53
- return errors
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]
54
71
  }
55
72
 
56
73
  function asRecord(value: unknown): Record<string, unknown> | null {
@@ -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; finalizationPassed: boolean | null }
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
 
@@ -1,6 +1,6 @@
1
1
  import type { EventSink } from "../features/persistent-state/event-sink"
2
- import { finalizeRun } from "../features/persistent-state/run-finalizer"
3
2
  import type { FinalizationResult } from "../features/persistent-state/run-finalizer"
3
+ import { finalizeRun } from "../features/persistent-state/run-finalizer"
4
4
  import { createLogger } from "../shared/logger"
5
5
  import { createAuditState } from "../state/audit-state"
6
6
  import type { AuditEvent } from "../state/schemas"
@@ -12,6 +12,14 @@ const TOOL_SHORT_NAMES: Record<string, string> = {
12
12
  }
13
13
  const KEY_TOOLS = ["slither", "forge-test", "patterns", "solodit", "analyzer"]
14
14
 
15
+
16
+ /** Maps unavailable-tool short names to their KEY_TOOLS counterpart */
17
+ const UNAVAILABLE_TO_KEY_TOOL: Record<string, string> = {
18
+ slither: "slither",
19
+ forge: "forge-test",
20
+ solodit: "solodit",
21
+ }
22
+
15
23
  export interface SystemPromptHookDeps {
16
24
  getAuditState: () => AuditState | null
17
25
  getAgentForSession: (sessionID: string) => string | undefined
@@ -69,8 +77,19 @@ export function buildDynamicContext(
69
77
  (t) => `${t}=${executedToolNames.has(t) ? "done" : "pending"}`,
70
78
  ).join(" ")
71
79
  const unavailable = auditState.unavailableTools ?? []
80
+ const excusedTools = new Set(
81
+ unavailable.map((t) => UNAVAILABLE_TO_KEY_TOOL[t]).filter(Boolean),
82
+ )
83
+ const pendingKeyTools = KEY_TOOLS.filter(
84
+ (t) => !executedToolNames.has(t) && !excusedTools.has(t),
85
+ )
86
+ const gateStatus =
87
+ pendingKeyTools.length > 0
88
+ ? `REPORTING GATE: BLOCKED \u2014 key tools pending: ${pendingKeyTools.join(", ")}`
89
+ : "REPORTING GATE: ALLOWED"
72
90
  const lines: string[] = [
73
91
  `<argus-context agent="${agent}">`,
92
+ gateStatus,
74
93
  `Phase: ${auditState.currentPhase}`,
75
94
  `Contracts: ${auditState.contractsReviewed.length} reviewed`,
76
95
  `Findings: Critical=${severityCounts.Critical} High=${severityCounts.High} Medium=${severityCounts.Medium} Low=${severityCounts.Low} Info=${severityCounts.Informational}`,
@@ -91,6 +110,7 @@ export function buildDynamicContext(
91
110
  const doneCount = KEY_TOOLS.filter((t) => executedToolNames.has(t)).length
92
111
  summary = [
93
112
  `<argus-context agent="${agent}">`,
113
+ gateStatus,
94
114
  `Phase: ${auditState.currentPhase} | Findings: ${auditState.findings.length} | Contracts: ${auditState.contractsReviewed.length} | Tasks: ${doneCount}/${KEY_TOOLS.length} done`,
95
115
  "</argus-context>",
96
116
  ].join("\n")
@@ -1,4 +1,5 @@
1
1
  import { join } from "node:path"
2
+ import { defaultRootResolver } from "./path-root-resolver"
2
3
 
3
4
  export class ArtifactResolverError extends Error {
4
5
  constructor(message: string) {
@@ -8,19 +9,19 @@ export class ArtifactResolverError extends Error {
8
9
  }
9
10
 
10
11
  export interface AuditArtifactPaths {
11
- /** {projectDir}/.opencode/argus-state.json (legacy compat) */
12
+ /** {projectDir}/.argus/argus-state.json */
12
13
  stateFile: string
13
- /** {projectDir}/.opencode/runs/{runId}/events.jsonl */
14
+ /** {projectDir}/.argus/runs/{runId}/events.jsonl */
14
15
  journalFile: string
15
- /** {projectDir}/.opencode/runs/{runId}/findings.json */
16
+ /** {projectDir}/.argus/runs/{runId}/findings.json */
16
17
  findingsFile: string
17
- /** {projectDir}/.opencode/reports */
18
+ /** {projectDir}/.argus/reports */
18
19
  reportDir: string
19
- /** {projectDir}/.opencode/runs/{runId}/evidence */
20
+ /** {projectDir}/.argus/runs/{runId}/evidence */
20
21
  evidenceDir: string
21
- /** {projectDir}/.opencode/archives */
22
+ /** {projectDir}/.argus/archives */
22
23
  archiveDir: string
23
- /** {projectDir}/.opencode/runs/{runId} */
24
+ /** {projectDir}/.argus/runs/{runId} */
24
25
  runDir: string
25
26
  }
26
27
 
@@ -45,16 +46,16 @@ export function createAuditArtifactResolver(
45
46
  throw new ArtifactResolverError("projectDir must not be empty")
46
47
  }
47
48
 
48
- const opencodeDir = join(projectDir, ".opencode")
49
- const runDir = join(opencodeDir, "runs", runId)
49
+ const writeRoot = defaultRootResolver.writeRoot(projectDir)
50
+ const runDir = join(writeRoot, "runs", runId)
50
51
 
51
52
  const cachedPaths: AuditArtifactPaths = {
52
- stateFile: join(opencodeDir, "argus-state.json"),
53
+ stateFile: join(writeRoot, "argus-state.json"),
53
54
  journalFile: join(runDir, "events.jsonl"),
54
55
  findingsFile: join(runDir, "findings.json"),
55
- reportDir: join(opencodeDir, "reports"),
56
+ reportDir: join(writeRoot, "reports"),
56
57
  evidenceDir: join(runDir, "evidence"),
57
- archiveDir: join(opencodeDir, "archives"),
58
+ archiveDir: join(writeRoot, "archives"),
58
59
  runDir,
59
60
  }
60
61
 
@@ -1,6 +1,7 @@
1
1
  import { existsSync, readFileSync } from "node:fs"
2
2
  import { join } from "node:path"
3
3
  import { stripJsoncComments } from "./jsonc-parser"
4
+ import { defaultRootResolver } from "./path-root-resolver"
4
5
 
5
6
  export type ConfigFormat = "json" | "jsonc" | "none"
6
7
 
@@ -10,9 +11,13 @@ export interface ConfigFileInfo {
10
11
  }
11
12
 
12
13
  export function detectConfigFile(basePath: string): ConfigFileInfo {
14
+ const rootCandidates = defaultRootResolver.readRoots(basePath).flatMap((rootPath) => [
15
+ { path: join(rootPath, "solidity-argus.jsonc"), format: "jsonc" as const },
16
+ { path: join(rootPath, "solidity-argus.json"), format: "json" as const },
17
+ ])
18
+
13
19
  const candidates = [
14
- { path: join(basePath, ".opencode", "solidity-argus.jsonc"), format: "jsonc" as const },
15
- { path: join(basePath, ".opencode", "solidity-argus.json"), format: "json" as const },
20
+ ...rootCandidates,
16
21
  { path: join(basePath, "solidity-argus.jsonc"), format: "jsonc" as const },
17
22
  { path: join(basePath, "solidity-argus.json"), format: "json" as const },
18
23
  ]
@@ -1,3 +1,9 @@
1
+ export {
2
+ ArtifactResolverError,
3
+ type AuditArtifactPaths,
4
+ type AuditArtifactResolver,
5
+ createAuditArtifactResolver,
6
+ } from "./audit-artifact-resolver"
1
7
  export { extractContractNames, hasBinary, parseSolcVersion } from "./binary-utils"
2
8
  export { deepMerge } from "./deep-merge"
3
9
  export {
@@ -10,16 +16,10 @@ export { stripJsoncComments } from "./jsonc-parser"
10
16
  export { createLogger, type Logger, type LoggerConfig } from "./logger"
11
17
  export { findFoundryProjectDir, resolveProjectDir } from "./project-utils"
12
18
  export {
13
- ArtifactResolverError,
14
- type AuditArtifactPaths,
15
- type AuditArtifactResolver,
16
- createAuditArtifactResolver,
17
- } from "./audit-artifact-resolver"
18
- export {
19
+ formatReportDate,
19
20
  ReportPathError,
20
21
  type ReportPathOptions,
21
22
  type ResolvedReportPath,
22
- formatReportDate,
23
- sanitizeContractName,
24
23
  resolveReportPath,
24
+ sanitizeContractName,
25
25
  } from "./report-path-resolver"
@@ -0,0 +1,34 @@
1
+ import { existsSync } from "node:fs"
2
+ import { join } from "node:path"
3
+
4
+ export interface ArgusRootResolver {
5
+ writeRoot(projectDir: string): string
6
+ readRoots(projectDir: string): string[]
7
+ resolveReadPath(projectDir: string, relativePath: string): string | null
8
+ }
9
+
10
+ class DefaultArgusRootResolver implements ArgusRootResolver {
11
+ writeRoot(projectDir: string): string {
12
+ return join(projectDir, ".argus")
13
+ }
14
+
15
+ readRoots(projectDir: string): string[] {
16
+ return [this.writeRoot(projectDir), join(projectDir, ".opencode")]
17
+ }
18
+
19
+ resolveReadPath(projectDir: string, relativePath: string): string | null {
20
+ for (const root of this.readRoots(projectDir)) {
21
+ const candidatePath = join(root, relativePath)
22
+ if (existsSync(candidatePath)) {
23
+ return candidatePath
24
+ }
25
+ }
26
+ return null
27
+ }
28
+ }
29
+
30
+ export function createArgusRootResolver(): ArgusRootResolver {
31
+ return new DefaultArgusRootResolver()
32
+ }
33
+
34
+ export const defaultRootResolver: ArgusRootResolver = createArgusRootResolver()
@@ -30,9 +30,9 @@ export interface ResolvedReportPath {
30
30
  }
31
31
 
32
32
  export function formatReportDate(date: Date): string {
33
- const year = date.getFullYear()
34
- const month = String(date.getMonth() + 1).padStart(2, "0")
35
- const day = String(date.getDate()).padStart(2, "0")
33
+ const year = date.getUTCFullYear()
34
+ const month = String(date.getUTCMonth() + 1).padStart(2, "0")
35
+ const day = String(date.getUTCDate()).padStart(2, "0")
36
36
  return `${year}-${month}-${day}`
37
37
  }
38
38
 
@@ -260,6 +260,91 @@ export function validateCanonicalFinding(raw: unknown): ValidationResult<Canonic
260
260
  return { success: true, data: raw as unknown as CanonicalFinding }
261
261
  }
262
262
 
263
+
264
+ export function validateCanonicalToolExecution(
265
+ raw: unknown,
266
+ ): ValidationResult<CanonicalToolExecution> {
267
+ if (!isRecord(raw)) {
268
+ return {
269
+ success: false,
270
+ errors: [
271
+ {
272
+ field: "$root",
273
+ code: "type",
274
+ message: "canonical tool execution must be an object",
275
+ },
276
+ ],
277
+ }
278
+ }
279
+
280
+ const errors: ValidationError[] = []
281
+
282
+ if (typeof raw.tool !== "string" || raw.tool.trim().length === 0) {
283
+ errors.push({
284
+ field: "tool",
285
+ code: "required",
286
+ message: "tool is required and must be a non-empty string",
287
+ })
288
+ }
289
+
290
+ if (typeof raw.startTime !== "number" || !Number.isInteger(raw.startTime) || raw.startTime <= 0) {
291
+ errors.push({
292
+ field: "startTime",
293
+ code: "invalid",
294
+ message: "startTime must be a positive integer",
295
+ })
296
+ }
297
+
298
+ if (raw.endTime != null && (typeof raw.endTime !== "number" || !Number.isInteger(raw.endTime))) {
299
+ errors.push({
300
+ field: "endTime",
301
+ code: "invalid",
302
+ message: "endTime must be an integer when provided",
303
+ })
304
+ }
305
+
306
+ if (typeof raw.success !== "boolean") {
307
+ errors.push({
308
+ field: "success",
309
+ code: "required",
310
+ message: "success is required and must be a boolean",
311
+ })
312
+ }
313
+
314
+ if (
315
+ typeof raw.findingsCount !== "number" ||
316
+ !Number.isInteger(raw.findingsCount) ||
317
+ raw.findingsCount < 0
318
+ ) {
319
+ errors.push({
320
+ field: "findingsCount",
321
+ code: "invalid",
322
+ message: "findingsCount must be a non-negative integer",
323
+ })
324
+ }
325
+
326
+ if (typeof raw.run_id !== "string" || raw.run_id.trim().length === 0) {
327
+ errors.push({
328
+ field: "run_id",
329
+ code: "required",
330
+ message: "run_id is required and must be a non-empty string",
331
+ })
332
+ }
333
+
334
+ if (typeof raw.schema_version !== "string" || raw.schema_version.trim().length === 0) {
335
+ errors.push({
336
+ field: "schema_version",
337
+ code: "required",
338
+ message: "schema_version is required and must be a non-empty string",
339
+ })
340
+ }
341
+
342
+ if (errors.length > 0) {
343
+ return { success: false, errors }
344
+ }
345
+
346
+ return { success: true, data: raw as unknown as CanonicalToolExecution }
347
+ }
263
348
  export function validateReportInput(raw: unknown): ValidationResult<ReportInput> {
264
349
  if (!isRecord(raw)) {
265
350
  return {
@@ -306,6 +391,18 @@ export function validateReportInput(raw: unknown): ValidationResult<ReportInput>
306
391
  code: "invalid",
307
392
  message: "toolsExecuted must be an array",
308
393
  })
394
+ } else {
395
+ for (const [index, entry] of raw.toolsExecuted.entries()) {
396
+ const toolValidation = validateCanonicalToolExecution(entry)
397
+ if (toolValidation.success) continue
398
+ for (const toolError of toolValidation.errors) {
399
+ errors.push({
400
+ field: `toolsExecuted[${index}].${toolError.field}`,
401
+ code: toolError.code,
402
+ message: toolError.message,
403
+ })
404
+ }
405
+ }
309
406
  }
310
407
 
311
408
  if (raw.patternVersion != null && typeof raw.patternVersion !== "string") {
@@ -110,4 +110,10 @@ export interface PersistentAuditState extends AuditState {
110
110
  savedAt: number
111
111
  version: string
112
112
  filePath: string
113
+ /** Whether this snapshot was projected from events or loaded from a prior snapshot */
114
+ source_of_truth?: "events" | "snapshot"
115
+ /** Sequence number of the last event included in this snapshot */
116
+ last_event_seq?: number
117
+ /** Hash of the event stream for staleness detection */
118
+ event_stream_hash?: string
113
119
  }
@@ -3,6 +3,7 @@ import path from "node:path"
3
3
  import { type ToolContext, tool } from "@opencode-ai/plugin"
4
4
  import { loadArgusConfig } from "../config/loader"
5
5
  import type { ArgusConfig } from "../config/types"
6
+ import { readEvents } from "../features/persistent-state/event-sink"
6
7
  import type { DropDiagnostic, DropPolicy } from "../shared/drop-diagnostics"
7
8
  import { createDropDiagnosticsCollector } from "../shared/drop-diagnostics"
8
9
  import { createLogger } from "../shared/logger"
@@ -12,6 +13,7 @@ import { normalizeToCanonicalFinding } from "../state/adapters"
12
13
  import { stableHash } from "../state/projectors"
13
14
  import { type ReportInput, SCHEMA_VERSION, validateReportInput } from "../state/schemas"
14
15
  import type { AuditState, Finding, FindingSeverity } from "../state/types"
16
+ import { checkReportPreflight } from "./report-preflight"
15
17
 
16
18
  type SeverityThreshold = "critical" | "high" | "medium" | "low" | "informational"
17
19
 
@@ -23,6 +25,7 @@ type ReportGeneratorArgs = {
23
25
  quality_gate_policy?: QualityGatePolicy
24
26
  report_input?: string
25
27
  audit_state?: string
28
+ preflight_policy?: PreflightPolicy
26
29
  }
27
30
 
28
31
  type FindingsCount = {
@@ -46,6 +49,8 @@ export type ReportGenerationResult = {
46
49
 
47
50
  type QualityGatePolicy = "warn" | "strict-fail"
48
51
 
52
+ type PreflightPolicy = "warn" | "strict-fail"
53
+
49
54
  type ReportQualityViolation = {
50
55
  findingId: string
51
56
  code: string
@@ -59,6 +64,10 @@ type ReportQualityValidation = {
59
64
 
60
65
  export type ReportGenerationDependencies = {
61
66
  loadConfig?: (projectDir: string) => ArgusConfig
67
+ readEvents?: (
68
+ runId: string,
69
+ projectDir: string,
70
+ ) => Promise<import("../state/schemas").AuditEvent[]>
62
71
  }
63
72
 
64
73
  export const SINGLE_WRITER_POLICY_VERSION = "1.0.0"
@@ -993,9 +1002,25 @@ export function buildProvenanceAppendix(
993
1002
  lines.push("| Tool | Duration | Status | Findings |")
994
1003
  lines.push("| --- | --- | --- | ---: |")
995
1004
  for (const exec of state.toolsExecuted) {
996
- const duration = exec.endTime != null ? formatDuration(exec.endTime - exec.startTime) : ""
997
- const status = exec.success ? "✅ success" : "❌ failure"
998
- lines.push(`| ${exec.tool} | ${duration} | ${status} | ${exec.findingsCount} |`)
1005
+ const toolName = typeof exec.tool === "string" && exec.tool ? exec.tool : "(unknown tool)"
1006
+ const hasTimes =
1007
+ typeof exec.startTime === "number" &&
1008
+ !Number.isNaN(exec.startTime) &&
1009
+ exec.endTime != null &&
1010
+ typeof exec.endTime === "number" &&
1011
+ !Number.isNaN(exec.endTime)
1012
+ const duration = hasTimes ? formatDuration(exec.endTime! - exec.startTime) : "N/A"
1013
+ const status =
1014
+ typeof exec.success === "boolean"
1015
+ ? exec.success
1016
+ ? "\u2705 success"
1017
+ : "\u274C failure"
1018
+ : "\u26A0 malformed"
1019
+ const findings =
1020
+ typeof exec.findingsCount === "number" && !Number.isNaN(exec.findingsCount)
1021
+ ? exec.findingsCount
1022
+ : "N/A"
1023
+ lines.push(`| ${toolName} | ${duration} | ${status} | ${findings} |`)
999
1024
  }
1000
1025
  }
1001
1026
 
@@ -1008,7 +1033,8 @@ export function buildProvenanceAppendix(
1008
1033
  lines.push(`- Pattern pack version: \`${state.patternVersion}\``)
1009
1034
  }
1010
1035
  if (syncExec) {
1011
- lines.push(`- SCVD last synced: ${new Date(syncExec.startTime).toISOString()}`)
1036
+ const syncTime = typeof syncExec.startTime === "number" && !Number.isNaN(syncExec.startTime) ? new Date(syncExec.startTime).toISOString() : "N/A"
1037
+ lines.push(`- SCVD last synced: ${syncTime}`)
1012
1038
  }
1013
1039
  }
1014
1040
 
@@ -1066,6 +1092,48 @@ export async function executeReportGeneration(
1066
1092
  const threshold = args.severity_threshold ?? "low"
1067
1093
  const qualityGatePolicy = args.quality_gate_policy ?? "warn"
1068
1094
  const { reportInput, diagnostics } = parseReportInputPayload(args, context)
1095
+ const preflightPolicy = args.preflight_policy ?? "warn"
1096
+ let preflightWarningSection: string | null = null
1097
+ try {
1098
+ const readEventsFn = deps.readEvents ?? readEvents
1099
+ const events = await readEventsFn(reportInput.run_id, reportInput.projectDir)
1100
+ const preflightResult = checkReportPreflight(events)
1101
+ if (!preflightResult.passed) {
1102
+ if (preflightPolicy === "strict-fail") {
1103
+ const parts: string[] = []
1104
+ if (preflightResult.orphanedTools.length > 0)
1105
+ parts.push(`orphaned tools: ${preflightResult.orphanedTools.join(", ")}`)
1106
+ if (preflightResult.missingLifecycle.length > 0)
1107
+ parts.push(`missing lifecycle: ${preflightResult.missingLifecycle.join(", ")}`)
1108
+ if (preflightResult.missingRequiredTools.length > 0)
1109
+ parts.push(`missing required tools: ${preflightResult.missingRequiredTools.join(", ")}`)
1110
+ throw new Error(`Preflight failed (strict-fail): ${parts.join("; ")}`)
1111
+ }
1112
+ const lines: string[] = [
1113
+ "## \u26A0 Completeness Warning",
1114
+ "",
1115
+ "This report was generated with incomplete orchestration state.",
1116
+ "",
1117
+ ]
1118
+ if (preflightResult.orphanedTools.length > 0)
1119
+ lines.push(`- Orphaned tools: ${preflightResult.orphanedTools.join(", ")}`)
1120
+ if (preflightResult.missingLifecycle.length > 0)
1121
+ lines.push(`- Missing lifecycle: ${preflightResult.missingLifecycle.join(", ")}`)
1122
+ if (preflightResult.missingRequiredTools.length > 0)
1123
+ lines.push(`- Missing required tools: ${preflightResult.missingRequiredTools.join(", ")}`)
1124
+ if (preflightResult.warnings.length > 0)
1125
+ lines.push(`- Warnings: ${preflightResult.warnings.join(", ")}`)
1126
+ preflightWarningSection = lines.join("\n")
1127
+ }
1128
+ } catch (err) {
1129
+ if (err instanceof Error && err.message.startsWith("Preflight failed (strict-fail)")) {
1130
+ throw err
1131
+ }
1132
+ if (preflightPolicy === "strict-fail") {
1133
+ throw new Error("Preflight failed: unable to read event stream for completeness check")
1134
+ }
1135
+ // warn mode: skip preflight when events cannot be read
1136
+ }
1069
1137
  const state = reportInputToAuditState(reportInput)
1070
1138
  const scope = args.scope.length > 0 ? args.scope : reportInput.scope
1071
1139
  const findings = sortFindingsDeterministically(
@@ -1129,6 +1197,10 @@ export async function executeReportGeneration(
1129
1197
  sections.push(`- ${item}`)
1130
1198
  }
1131
1199
 
1200
+ if (preflightWarningSection) {
1201
+ sections.push(preflightWarningSection)
1202
+ }
1203
+
1132
1204
  sections.push(buildProvenanceAppendix(state, threshold, findings.length))
1133
1205
 
1134
1206
  // Embed report metadata for single-writer policy enforcement
@@ -1159,7 +1231,7 @@ export async function executeReportGeneration(
1159
1231
  const loadConfig = deps.loadConfig ?? loadArgusConfig
1160
1232
  const projectDir = resolveProjectDir(context)
1161
1233
  const config = loadConfig(projectDir)
1162
- const outputDir = config.reporting?.output_dir ?? ".opencode/reports/"
1234
+ const outputDir = config.reporting?.output_dir ?? ".argus/reports/"
1163
1235
  const fullPath = path.join(projectDir, outputDir, canonicalFilename)
1164
1236
 
1165
1237
  // Single-writer policy: check for duplicate writes with same run_id
@@ -1194,6 +1266,7 @@ export const reportGeneratorTool = tool({
1194
1266
  .default("low"),
1195
1267
  report_input: tool.schema.string().optional(),
1196
1268
  audit_state: tool.schema.string().optional(),
1269
+ preflight_policy: tool.schema.enum(["warn", "strict-fail"]).optional(),
1197
1270
  },
1198
1271
  async execute(args, context) {
1199
1272
  const result = await executeReportGeneration(args, context)
@@ -0,0 +1,79 @@
1
+ import {
2
+ collectToolLifecycleIssues,
3
+ hasSessionCreated,
4
+ hasSessionDeleted,
5
+ } from "../features/persistent-state/run-finalizer"
6
+ import type { AuditEvent } from "../state/schemas"
7
+
8
+ export interface PreflightResult {
9
+ passed: boolean
10
+ orphanedTools: string[]
11
+ missingLifecycle: string[]
12
+ missingRequiredTools: string[]
13
+ warnings: string[]
14
+ }
15
+
16
+ export interface PreflightOptions {
17
+ requiredTools?: string[]
18
+ }
19
+
20
+ function asRecord(value: unknown): Record<string, unknown> | null {
21
+ if (typeof value === "object" && value !== null && !Array.isArray(value)) {
22
+ return value as Record<string, unknown>
23
+ }
24
+ return null
25
+ }
26
+
27
+ function hasCompletedTool(events: AuditEvent[], toolName: string): boolean {
28
+ for (const event of events) {
29
+ if (event.type !== "tool.completed") {
30
+ continue
31
+ }
32
+
33
+ const payload = asRecord(event.payload)
34
+ if (!payload) {
35
+ continue
36
+ }
37
+
38
+ const name = payload.name
39
+ const tool = payload.tool
40
+ if (name === toolName || tool === toolName) {
41
+ return true
42
+ }
43
+ }
44
+
45
+ return false
46
+ }
47
+
48
+ export function checkReportPreflight(
49
+ events: AuditEvent[],
50
+ options: PreflightOptions = {},
51
+ ): PreflightResult {
52
+ const missingLifecycle: string[] = []
53
+ if (!hasSessionCreated(events)) {
54
+ missingLifecycle.push("session.created")
55
+ }
56
+ if (!hasSessionDeleted(events)) {
57
+ missingLifecycle.push("session.deleted")
58
+ }
59
+
60
+ const { orphanedToolCallIds, malformedEvents } = collectToolLifecycleIssues(events)
61
+
62
+ const missingRequiredTools: string[] = []
63
+ for (const requiredTool of options.requiredTools ?? []) {
64
+ if (!hasCompletedTool(events, requiredTool)) {
65
+ missingRequiredTools.push(requiredTool)
66
+ }
67
+ }
68
+
69
+ return {
70
+ passed:
71
+ orphanedToolCallIds.length === 0 &&
72
+ missingLifecycle.length === 0 &&
73
+ missingRequiredTools.length === 0,
74
+ orphanedTools: orphanedToolCallIds,
75
+ missingLifecycle,
76
+ missingRequiredTools,
77
+ warnings: malformedEvents,
78
+ }
79
+ }
@@ -258,7 +258,9 @@ export async function executeSoloditSearch(
258
258
  let hadMcpError = false
259
259
  for (const toolName of SOLODIT_MCP_TOOLS) {
260
260
  try {
261
- logger.debug(`[solodit] Trying MCP tool '${toolName}' on server '${SOLODIT_MCP_SERVER}' for query: ${query}`)
261
+ logger.debug(
262
+ `[solodit] Trying MCP tool '${toolName}' on server '${SOLODIT_MCP_SERVER}' for query: ${query}`,
263
+ )
262
264
  const response = await mcpCaller(
263
265
  SOLODIT_MCP_SERVER,
264
266
  toolName,
@@ -289,7 +291,9 @@ export async function executeSoloditSearch(
289
291
  }
290
292
 
291
293
  // All MCP tools failed — fall back to HTTP
292
- logger.debug(`[solodit] All MCP tools failed (hadMcpError=${hadMcpError}) — falling back to HTTP for query: ${query}`)
294
+ logger.debug(
295
+ `[solodit] All MCP tools failed (hadMcpError=${hadMcpError}) — falling back to HTTP for query: ${query}`,
296
+ )
293
297
  return callSoloditHttp(query, limit, args.severity, port)
294
298
  }
295
299