solidity-argus 0.5.10 → 0.6.1

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 (42) hide show
  1. package/AGENTS.md +8 -1
  2. package/README.md +27 -21
  3. package/package.json +2 -2
  4. package/skills/INVENTORY.md +14 -1
  5. package/skills/README.md +4 -2
  6. package/skills/references/attack-vector-deck/SKILL.md +62 -0
  7. package/skills/specialist-profiles/access-control-specialist/SKILL.md +31 -0
  8. package/skills/specialist-profiles/economic-security/SKILL.md +31 -0
  9. package/skills/specialist-profiles/execution-trace/SKILL.md +31 -0
  10. package/skills/specialist-profiles/first-principles/SKILL.md +31 -0
  11. package/skills/specialist-profiles/invariant/SKILL.md +31 -0
  12. package/skills/specialist-profiles/math-precision/SKILL.md +31 -0
  13. package/skills/specialist-profiles/periphery/SKILL.md +31 -0
  14. package/skills/specialist-profiles/vector-scan/SKILL.md +28 -0
  15. package/src/agents/argus-prompt.ts +59 -6
  16. package/src/agents/audit-specialist-prompt.ts +94 -0
  17. package/src/agents/pythia-prompt.ts +7 -4
  18. package/src/agents/scribe-prompt.ts +9 -0
  19. package/src/agents/sentinel-prompt.ts +12 -0
  20. package/src/agents/themis-prompt.ts +4 -0
  21. package/src/config/schema.ts +2 -0
  22. package/src/constants/defaults.ts +1 -0
  23. package/src/create-hooks.ts +9 -1
  24. package/src/features/background-agent/background-manager.ts +85 -2
  25. package/src/features/persistent-state/run-finalizer.ts +37 -3
  26. package/src/hooks/config-handler.ts +23 -0
  27. package/src/hooks/system-prompt-hook.ts +72 -2
  28. package/src/hooks/tool-tracking-hook.ts +50 -6
  29. package/src/managers/types.ts +21 -0
  30. package/src/shared/agent-names.ts +1 -0
  31. package/src/shared/lineage-validator.ts +96 -0
  32. package/src/shared/report-path-resolver.ts +8 -2
  33. package/src/state/adapters.ts +1 -1
  34. package/src/state/projectors.ts +50 -0
  35. package/src/state/schemas.ts +86 -1
  36. package/src/state/types.ts +25 -1
  37. package/src/tools/forge-coverage-tool.ts +41 -5
  38. package/src/tools/persist-deduped-tool.ts +45 -1
  39. package/src/tools/read-findings-tool.ts +46 -5
  40. package/src/tools/record-finding-tool.ts +10 -30
  41. package/src/tools/report-generator-tool.ts +135 -37
  42. package/src/tools/slither-tool.ts +62 -2
@@ -40,6 +40,8 @@ type ForgeCoverageResult = {
40
40
  report: ForgeCoverageReport
41
41
  executionTime: number
42
42
  error?: string
43
+ hint?: string
44
+ suggested_command?: string
43
45
  }
44
46
 
45
47
  export type ForgeCommandRunner = (
@@ -73,6 +75,36 @@ function isStackTooDeep(stderr: string): boolean {
73
75
  return /stack too deep/i.test(stderr)
74
76
  }
75
77
 
78
+ function classifyCoverageFailure(
79
+ stderr: string,
80
+ args: NormalizedForgeCoverageArgs,
81
+ ): Pick<ForgeCoverageResult, "hint" | "suggested_command"> | undefined {
82
+ if (
83
+ !/(optimizerSteps|unsupported optimizer|config parse|failed to parse|instrumentation)/i.test(
84
+ stderr,
85
+ )
86
+ ) {
87
+ return undefined
88
+ }
89
+
90
+ const command = buildCoverageCommand({ ...args, ir_minimum: true }).join(" ")
91
+ return {
92
+ hint:
93
+ `Forge coverage failed for ${args.target} while parsing or instrumenting project configuration. ` +
94
+ "If foundry.toml uses optimizerSteps or unsupported optimizer settings, run a scoped coverage command or temporarily adjust coverage-only config manually; Argus will not edit foundry.toml.",
95
+ suggested_command: command,
96
+ }
97
+ }
98
+
99
+ function shouldRetryWithIrMinimum(stderr: string): boolean {
100
+ return (
101
+ isStackTooDeep(stderr) ||
102
+ /(optimizerSteps|unsupported optimizer|config parse|failed to parse|instrumentation)/i.test(
103
+ stderr,
104
+ )
105
+ )
106
+ }
107
+
76
108
  function parsePercent(input: string): number {
77
109
  const match = input.match(/(\d+(?:\.\d+)?)%/)
78
110
  if (!match?.[1]) {
@@ -165,11 +197,15 @@ export async function executeForgeCoverage(
165
197
  const normalizedArgs = normalizeArgs(args, context)
166
198
  context.metadata({ title: `Run forge coverage: ${normalizedArgs.target}` })
167
199
 
168
- const fail = (error: string): ForgeCoverageResult => ({
200
+ const fail = (
201
+ error: string,
202
+ diagnostics?: Pick<ForgeCoverageResult, "hint" | "suggested_command">,
203
+ ): ForgeCoverageResult => ({
169
204
  success: false,
170
205
  report: { files: [], summary: { ...EMPTY_SUMMARY } },
171
206
  executionTime: Date.now() - startedAt,
172
207
  error,
208
+ ...diagnostics,
173
209
  })
174
210
 
175
211
  try {
@@ -181,7 +217,7 @@ export async function executeForgeCoverage(
181
217
  if (
182
218
  runResult.exitCode !== 0 &&
183
219
  !normalizedArgs.ir_minimum &&
184
- isStackTooDeep(runResult.stderr)
220
+ shouldRetryWithIrMinimum(runResult.stderr)
185
221
  ) {
186
222
  runResult = await runCommand(buildCoverageCommand(normalizedArgs, true), {
187
223
  signal: context.abort,
@@ -190,9 +226,9 @@ export async function executeForgeCoverage(
190
226
  }
191
227
 
192
228
  if (runResult.exitCode !== 0) {
193
- return fail(
194
- runResult.stderr.trim() || `forge coverage exited with code ${runResult.exitCode}`,
195
- )
229
+ const error =
230
+ runResult.stderr.trim() || `forge coverage exited with code ${runResult.exitCode}`
231
+ return fail(error, classifyCoverageFailure(error, normalizedArgs))
196
232
  }
197
233
 
198
234
  let report: ForgeCoverageReport
@@ -1,7 +1,8 @@
1
- import { mkdir, writeFile } from "node:fs/promises"
1
+ import { mkdir, readFile, writeFile } from "node:fs/promises"
2
2
  import { dirname } from "node:path"
3
3
  import { type ToolContext, tool } from "@opencode-ai/plugin"
4
4
  import { createAuditArtifactResolver } from "../shared/audit-artifact-resolver"
5
+ import { validateFindingLineage } from "../shared/lineage-validator"
5
6
  import { createLogger } from "../shared/logger"
6
7
  import { resolveProjectDir } from "../shared/project-utils"
7
8
  import { isNonEmptyString } from "../shared/type-guards"
@@ -22,6 +23,28 @@ export interface DedupedFindingsArtifact {
22
23
  findings: CanonicalFinding[]
23
24
  }
24
25
 
26
+ async function loadRawFindings(
27
+ runId: string,
28
+ projectDir: string,
29
+ ): Promise<CanonicalFinding[] | null> {
30
+ const findingsFile = createAuditArtifactResolver(runId, projectDir).paths().findingsFile
31
+ try {
32
+ const parsed = JSON.parse(await readFile(findingsFile, "utf8"))
33
+ if (!parsed || !Array.isArray(parsed.findings)) return null
34
+ return parsed.findings
35
+ } catch {
36
+ return null
37
+ }
38
+ }
39
+
40
+ function missingRawFindings(runId: string): string {
41
+ return JSON.stringify({
42
+ success: false,
43
+ error: "MissingRawFindingsError",
44
+ message: `Cannot verify deduped lineage because .argus/runs/${runId}/findings.json is missing or invalid`,
45
+ })
46
+ }
47
+
25
48
  export async function executePersistDeduped(
26
49
  args: PersistDedupedArgs,
27
50
  context: ToolContext,
@@ -55,6 +78,27 @@ export async function executePersistDeduped(
55
78
  const projectDir = resolveProjectDir(context)
56
79
  const resolver = createAuditArtifactResolver(args.run_id, projectDir)
57
80
  const dedupedPath = resolver.paths().dedupedFindingsFile
81
+ const rawFindings = await loadRawFindings(args.run_id, projectDir)
82
+
83
+ if (!rawFindings) {
84
+ return missingRawFindings(args.run_id)
85
+ }
86
+
87
+ const lineage = validateFindingLineage(rawFindings, findings)
88
+ if (!lineage.valid) {
89
+ return JSON.stringify({
90
+ success: false,
91
+ error: "LineageError",
92
+ lineage: {
93
+ raw_count: lineage.raw_count,
94
+ mapped_count: lineage.mapped_count,
95
+ duplicate_observation_ids: lineage.duplicate_observation_ids,
96
+ phantom_observation_ids: lineage.phantom_observation_ids,
97
+ missing_observation_ids: lineage.missing_observation_ids,
98
+ count_mismatches: lineage.count_mismatches,
99
+ },
100
+ })
101
+ }
58
102
 
59
103
  const artifact: DedupedFindingsArtifact = {
60
104
  run_id: args.run_id,
@@ -12,6 +12,8 @@ import type { AuditState } from "../state/types"
12
12
 
13
13
  type ReadFindingsArgs = {
14
14
  run_id: string
15
+ findings_offset?: number
16
+ findings_limit?: number
15
17
  }
16
18
 
17
19
  type ReportFinding = Omit<
@@ -42,6 +44,11 @@ type CompactReportInput = Omit<
42
44
  run_id: string
43
45
  findings: ReportFinding[]
44
46
  toolsExecuted: ReportToolExecution[]
47
+ findingsPage?: {
48
+ offset: number
49
+ limit: number
50
+ total: number
51
+ }
45
52
  }
46
53
 
47
54
  type ReadFindingsInlineResult = {
@@ -98,13 +105,31 @@ function stripInternalKeys(obj: object, keysToStrip: ReadonlySet<string>): Recor
98
105
  return result
99
106
  }
100
107
 
101
- function buildCompactInput(reportInput: ReportInput): CompactReportInput {
108
+ function normalizePageArgs(args: ReadFindingsArgs): { offset: number; limit: number } | null {
109
+ if (args.findings_offset == null && args.findings_limit == null) return null
110
+
111
+ const offset = args.findings_offset ?? 0
112
+ const limit = args.findings_limit ?? 50
113
+ if (!Number.isInteger(offset) || offset < 0) {
114
+ throw new Error("findings_offset must be a non-negative integer")
115
+ }
116
+ if (!Number.isInteger(limit) || limit < 1 || limit > 500) {
117
+ throw new Error("findings_limit must be an integer between 1 and 500")
118
+ }
119
+ return { offset, limit }
120
+ }
121
+
122
+ function buildCompactInput(
123
+ reportInput: ReportInput,
124
+ page: { offset: number; limit: number } | null = null,
125
+ ): CompactReportInput {
126
+ const rawFindings = page
127
+ ? reportInput.findings.slice(page.offset, page.offset + page.limit)
128
+ : reportInput.findings
102
129
  return {
103
130
  run_id: reportInput.run_id,
104
131
  projectDir: reportInput.projectDir,
105
- findings: reportInput.findings.map(
106
- (f) => stripInternalKeys(f, FINDING_INTERNAL_KEYS) as ReportFinding,
107
- ),
132
+ findings: rawFindings.map((f) => stripInternalKeys(f, FINDING_INTERNAL_KEYS) as ReportFinding),
108
133
  toolsExecuted: reportInput.toolsExecuted.map(
109
134
  (t) => stripInternalKeys(t, TOOL_EXECUTION_INTERNAL_KEYS) as ReportToolExecution,
110
135
  ),
@@ -118,6 +143,13 @@ function buildCompactInput(reportInput: ReportInput): CompactReportInput {
118
143
  ...(reportInput.proxyContracts && { proxyContracts: reportInput.proxyContracts }),
119
144
  ...(reportInput.patternVersion && { patternVersion: reportInput.patternVersion }),
120
145
  ...(reportInput.skillsLoaded && { skillsLoaded: reportInput.skillsLoaded }),
146
+ ...(page && {
147
+ findingsPage: {
148
+ offset: page.offset,
149
+ limit: page.limit,
150
+ total: reportInput.findings.length,
151
+ },
152
+ }),
121
153
  }
122
154
  }
123
155
 
@@ -339,7 +371,8 @@ export async function executeReadFindings(
339
371
 
340
372
  const projectDir = resolveProjectDir(context)
341
373
  const reportInput = readAuditStateAsReportInput(projectDir, runId)
342
- const compactInput = buildCompactInput(reportInput)
374
+ const page = normalizePageArgs(args)
375
+ const compactInput = buildCompactInput(reportInput, page)
343
376
 
344
377
  const inlineJson = JSON.stringify({
345
378
  success: true,
@@ -383,6 +416,14 @@ export const readFindingsTool = tool({
383
416
  "Read the materialized ReportInput artifact from disk for a given run. Returns the canonical findings, tools executed, scope, and all enrichment data. Scribe should call this before generating the report.",
384
417
  args: {
385
418
  run_id: tool.schema.string().describe("The run ID to read findings for."),
419
+ findings_offset: tool.schema
420
+ .number()
421
+ .optional()
422
+ .describe("Optional zero-based finding offset for paged inline retrieval."),
423
+ findings_limit: tool.schema
424
+ .number()
425
+ .optional()
426
+ .describe("Optional finding page size for inline retrieval (1-500)."),
386
427
  },
387
428
  async execute(args, context) {
388
429
  return executeReadFindings(args, context)
@@ -1,7 +1,7 @@
1
1
  import { type ToolContext, tool } from "@opencode-ai/plugin"
2
2
  import { isNonEmptyString } from "../shared/type-guards"
3
3
  import { normalizeToCanonicalFinding } from "../state/adapters"
4
- import { SCHEMA_VERSION } from "../state/schemas"
4
+ import { type CanonicalFinding, SCHEMA_VERSION } from "../state/schemas"
5
5
  import type { ArgusAgentName } from "../state/types"
6
6
 
7
7
  type RecordFindingArgs = {
@@ -12,20 +12,7 @@ type RecordFindingArgs = {
12
12
  type RecordFindingResponse = {
13
13
  success: boolean
14
14
  count: number
15
- findings: Array<{
16
- id: string
17
- check: string
18
- severity: string
19
- confidence: string
20
- file: string
21
- description: string
22
- lines: [number, number]
23
- source: string
24
- reported_by_agent: string
25
- impact?: string
26
- recommendation?: string
27
- proofOfConcept?: string
28
- }>
15
+ findings: CanonicalFinding[]
29
16
  schema_version: string
30
17
  note: string
31
18
  enrichment_warnings?: string[]
@@ -63,7 +50,13 @@ function parseFindingObject(raw: string, label: "finding" | "findings"): ParseRe
63
50
  }
64
51
 
65
52
  function normalizeAgent(value: string): ArgusAgentName {
66
- if (value === "argus" || value === "sentinel" || value === "pythia" || value === "scribe") {
53
+ if (
54
+ value === "argus" ||
55
+ value === "sentinel" ||
56
+ value === "pythia" ||
57
+ value === "audit-specialist" ||
58
+ value === "scribe"
59
+ ) {
67
60
  return value
68
61
  }
69
62
 
@@ -196,20 +189,7 @@ export async function executeRecordFinding(
196
189
  const response: RecordFindingResponse = {
197
190
  success: true,
198
191
  count: findings.length,
199
- findings: findings.map((f) => ({
200
- id: f.id,
201
- check: f.check,
202
- severity: f.severity,
203
- confidence: f.confidence,
204
- file: f.file,
205
- description: f.description,
206
- lines: f.lines,
207
- source: f.source,
208
- reported_by_agent: f.reported_by_agent,
209
- ...(f.impact !== undefined ? { impact: f.impact } : {}),
210
- ...(f.recommendation !== undefined ? { recommendation: f.recommendation } : {}),
211
- ...(f.proofOfConcept !== undefined ? { proofOfConcept: f.proofOfConcept } : {}),
212
- })),
192
+ findings,
213
193
  schema_version: SCHEMA_VERSION,
214
194
  note: "Findings recorded to event journal. The system assigns the canonical run_id automatically — use the run_id from <argus-context> for Scribe dispatch.",
215
195
  ...(enrichmentWarnings.length > 0
@@ -9,6 +9,7 @@ import { createAuditArtifactResolver } from "../shared/audit-artifact-resolver"
9
9
  import type { DropDiagnostic } from "../shared/drop-diagnostics"
10
10
  import { createDropDiagnosticsCollector } from "../shared/drop-diagnostics"
11
11
  import { computeMissingKeyTools } from "../shared/key-tools"
12
+ import { validateFindingLineage } from "../shared/lineage-validator"
12
13
  import { createLogger } from "../shared/logger"
13
14
  import { resolveProjectDir } from "../shared/project-utils"
14
15
  import { resolveReportPath } from "../shared/report-path-resolver"
@@ -38,6 +39,8 @@ type ReportGeneratorArgs = {
38
39
  preflight_policy?: PreflightPolicy
39
40
  tool_coverage_policy?: ToolCoveragePolicy
40
41
  run_id?: string
42
+ revision?: number
43
+ force?: boolean
41
44
  }
42
45
 
43
46
  type FindingsCount = {
@@ -131,6 +134,30 @@ async function checkDuplicateWrite(
131
134
  return null
132
135
  }
133
136
 
137
+ async function checkSafeForceOverwrite(
138
+ filePath: string,
139
+ runId: string,
140
+ ): Promise<{ code: string; message: string } | null> {
141
+ if (!existsSync(filePath)) return null
142
+ try {
143
+ const existingContent = await Bun.file(filePath).text()
144
+ const existingRunId = extractReportRunId(existingContent)
145
+ if (existingRunId === runId) return null
146
+ return {
147
+ code: "INSECURE_OVERWRITE_REFUSED",
148
+ message:
149
+ existingRunId == null
150
+ ? `Refusing to force overwrite ${filePath}: existing file has no Argus report metadata.`
151
+ : `Refusing to force overwrite ${filePath}: existing report belongs to run_id "${existingRunId}", not "${runId}".`,
152
+ }
153
+ } catch (err) {
154
+ return {
155
+ code: "INSECURE_OVERWRITE_REFUSED",
156
+ message: `Refusing to force overwrite ${filePath}: existing file could not be read (${err instanceof Error ? err.message : String(err)}).`,
157
+ }
158
+ }
159
+ }
160
+
134
161
  const SEVERITY_ORDER: FindingSeverity[] = ["Critical", "High", "Medium", "Low", "Informational"]
135
162
 
136
163
  const SEVERITY_PREFIX: Record<FindingSeverity, string> = {
@@ -309,6 +336,7 @@ const VALID_AGENT_VALUES = new Set<ArgusAgentName>([
309
336
  "argus",
310
337
  "sentinel",
311
338
  "pythia",
339
+ "audit-specialist",
312
340
  "scribe",
313
341
  "unknown",
314
342
  ])
@@ -766,6 +794,23 @@ function shouldIncludeFinding(finding: Finding, threshold: SeverityThreshold): b
766
794
  return FINDING_WEIGHT[finding.severity] >= THRESHOLD_WEIGHT[threshold]
767
795
  }
768
796
 
797
+ function normalizeScopePath(value: string): string {
798
+ return value.replace(/^\.\//, "").replace(/\/+$|\\+$/g, "")
799
+ }
800
+
801
+ function isFindingInScope(finding: Finding, scope: string[]): boolean {
802
+ if (scope.length === 0) return true
803
+ const file = normalizeScopePath(finding.file)
804
+ return scope.some((entry) => {
805
+ const scoped = normalizeScopePath(entry)
806
+ return file === scoped || file.startsWith(`${scoped}/`)
807
+ })
808
+ }
809
+
810
+ function collectOutOfScopeFindings(findings: Finding[], scope: string[]): Finding[] {
811
+ return findings.filter((finding) => !isFindingInScope(finding, scope))
812
+ }
813
+
769
814
  function calculateCounts(findings: Finding[]): FindingsCount {
770
815
  const counts = emptyCounts()
771
816
 
@@ -876,31 +921,6 @@ function hasDedupLineage(findings: Finding[]): boolean {
876
921
  })
877
922
  }
878
923
 
879
- function observationIdsForFinding(finding: Finding): string[] {
880
- const observationIds = (finding as { observation_ids?: unknown }).observation_ids
881
- if (Array.isArray(observationIds)) {
882
- return observationIds.filter((id): id is string => typeof id === "string" && id.length > 0)
883
- }
884
- return typeof finding.observation_id === "string" && finding.observation_id.length > 0
885
- ? [finding.observation_id]
886
- : []
887
- }
888
-
889
- function compareObservationLineage(
890
- eventFindings: Finding[],
891
- reportFindings: Finding[],
892
- ): { missing: string[]; extra: string[]; matches: boolean } {
893
- const expected = new Set(eventFindings.flatMap(observationIdsForFinding))
894
- const actual = new Set(reportFindings.flatMap(observationIdsForFinding))
895
- const missing = Array.from(expected)
896
- .filter((id) => !actual.has(id))
897
- .sort((a, b) => a.localeCompare(b))
898
- const extra = Array.from(actual)
899
- .filter((id) => !expected.has(id))
900
- .sort((a, b) => a.localeCompare(b))
901
- return { missing, extra, matches: missing.length === 0 && extra.length === 0 }
902
- }
903
-
904
924
  export function validateReportQuality(
905
925
  findings: Finding[],
906
926
  policy: QualityGatePolicy,
@@ -1210,6 +1230,18 @@ export async function executeReportGeneration(
1210
1230
  const qualityGatePolicy = args.quality_gate_policy ?? "warn"
1211
1231
  const toolCoveragePolicy = args.tool_coverage_policy ?? "enforce"
1212
1232
  const expectedRunId = resolveExpectedRunId(args, context, deps)
1233
+ const invalidRegenerationOptions =
1234
+ args.force === true && args.revision != null
1235
+ ? {
1236
+ code: "INVALID_REGENERATION_OPTIONS",
1237
+ message: "force and revision must not both be set.",
1238
+ }
1239
+ : args.revision != null && (!Number.isInteger(args.revision) || args.revision < 2)
1240
+ ? {
1241
+ code: "INVALID_REGENERATION_OPTIONS",
1242
+ message: "revision must be an integer greater than or equal to 2.",
1243
+ }
1244
+ : null
1213
1245
 
1214
1246
  // Ensure report-input.json is materialized before attempting disk lookup.
1215
1247
  // Scribe may call generate_report without calling read_findings first,
@@ -1234,6 +1266,18 @@ export async function executeReportGeneration(
1234
1266
  const preflightPolicy = args.preflight_policy ?? "warn"
1235
1267
  let preflightWarningSection: string | null = null
1236
1268
  const warningBullets: string[] = []
1269
+ const state = reportInputToAuditState(reportInput)
1270
+ const scope = args.scope.length > 0 ? args.scope : reportInput.scope
1271
+ const finalFindings = dedupeFindingsForFinalOutput(reportInput.findings)
1272
+ const outOfScopeFindings = collectOutOfScopeFindings(finalFindings, scope)
1273
+ if (outOfScopeFindings.length > 0) {
1274
+ const locations = outOfScopeFindings.map(formatLocation).join(", ")
1275
+ const message = `findings outside audited scope: ${locations}`
1276
+ if (preflightPolicy === "strict-fail") {
1277
+ throw new Error(`Preflight failed (strict-fail): ${message}`)
1278
+ }
1279
+ warningBullets.push(`- ${message}`)
1280
+ }
1237
1281
 
1238
1282
  // Hard gate: refuse to generate a report if key audit tools have not been executed
1239
1283
  if (toolCoveragePolicy !== "skip") {
@@ -1284,11 +1328,24 @@ export async function executeReportGeneration(
1284
1328
  const inputFindings = dedupeFindingsForFinalOutput(reportInput.findings)
1285
1329
  const hasLineage = hasDedupLineage(reportInput.findings)
1286
1330
  const shouldCheckParity = eventFindings.length === inputFindings.length || hasLineage
1331
+ const lineage = hasLineage
1332
+ ? validateFindingLineage(projectFindings(events), reportInput.findings)
1333
+ : null
1287
1334
  const parity = shouldCheckParity
1288
- ? hasLineage
1289
- ? compareObservationLineage(projectFindings(events), reportInput.findings)
1290
- : compareIssueFingerprintSets(eventFindings, inputFindings)
1291
- : { missing: [], extra: [], matches: true }
1335
+ ? lineage
1336
+ ? {
1337
+ missing: lineage.missing_observation_ids,
1338
+ extra: lineage.phantom_observation_ids,
1339
+ duplicates: lineage.duplicate_observation_ids,
1340
+ countMismatches: lineage.count_mismatches,
1341
+ matches: lineage.valid,
1342
+ }
1343
+ : {
1344
+ ...compareIssueFingerprintSets(eventFindings, inputFindings),
1345
+ duplicates: [],
1346
+ countMismatches: [],
1347
+ }
1348
+ : { missing: [], extra: [], duplicates: [], countMismatches: [], matches: true }
1292
1349
 
1293
1350
  if (!shouldCheckParity) {
1294
1351
  const unverifiableSummary = `event_findings=${eventFindings.length}, report_findings=${inputFindings.length}`
@@ -1319,6 +1376,14 @@ export async function executeReportGeneration(
1319
1376
  if (parity.extra.length > 0) {
1320
1377
  warningBullets.push(`- Extra ${parityLabel}: ${parity.extra.join(", ")}`)
1321
1378
  }
1379
+ if (parity.duplicates.length > 0) {
1380
+ warningBullets.push(`- Duplicate ${parityLabel}: ${parity.duplicates.join(", ")}`)
1381
+ }
1382
+ if (parity.countMismatches.length > 0) {
1383
+ warningBullets.push(
1384
+ `- Observation count mismatches: ${parity.countMismatches.map((item) => item.check).join(", ")}`,
1385
+ )
1386
+ }
1322
1387
  }
1323
1388
  } catch (err) {
1324
1389
  if (err instanceof Error && err.message.startsWith("Preflight failed (strict-fail)")) {
@@ -1340,9 +1405,6 @@ export async function executeReportGeneration(
1340
1405
  ].join("\n")
1341
1406
  }
1342
1407
 
1343
- const state = reportInputToAuditState(reportInput)
1344
- const scope = args.scope.length > 0 ? args.scope : reportInput.scope
1345
- const finalFindings = dedupeFindingsForFinalOutput(reportInput.findings)
1346
1408
  const findings = sortFindingsDeterministically(
1347
1409
  finalFindings.filter((finding) => shouldIncludeFinding(finding, threshold)),
1348
1410
  )
@@ -1441,6 +1503,7 @@ export async function executeReportGeneration(
1441
1503
  date: new Date(auditDate),
1442
1504
  outputDir: ".opencode/reports/",
1443
1505
  runId: runId || undefined,
1506
+ revision: args.revision,
1444
1507
  })
1445
1508
 
1446
1509
  const result: ReportGenerationResult = {
@@ -1453,6 +1516,11 @@ export async function executeReportGeneration(
1453
1516
  contractDiagnostics: diagnostics,
1454
1517
  }
1455
1518
 
1519
+ if (invalidRegenerationOptions) {
1520
+ result.error = invalidRegenerationOptions
1521
+ return result
1522
+ }
1523
+
1456
1524
  try {
1457
1525
  const loadConfig = deps.loadConfig ?? loadArgusConfig
1458
1526
  const projectDir = resolveProjectDir(context)
@@ -1467,14 +1535,28 @@ export async function executeReportGeneration(
1467
1535
  }
1468
1536
  return result
1469
1537
  }
1470
- const fullPath = path.join(resolvedOutput, canonicalFilename)
1538
+ const { filePath: fullPath } = resolveReportPath({
1539
+ contractName: args.project_name,
1540
+ date: new Date(auditDate),
1541
+ outputDir: resolvedOutput,
1542
+ runId: runId || undefined,
1543
+ revision: args.revision,
1544
+ })
1471
1545
 
1472
1546
  // Single-writer policy: check for duplicate writes with same run_id
1473
1547
  if (runId) {
1474
- const duplicateError = await checkDuplicateWrite(fullPath, runId)
1475
- if (duplicateError) {
1476
- result.error = duplicateError
1477
- return result
1548
+ if (args.force === true) {
1549
+ const forceError = await checkSafeForceOverwrite(fullPath, runId)
1550
+ if (forceError) {
1551
+ result.error = forceError
1552
+ return result
1553
+ }
1554
+ } else {
1555
+ const duplicateError = await checkDuplicateWrite(fullPath, runId)
1556
+ if (duplicateError) {
1557
+ result.error = duplicateError
1558
+ return result
1559
+ }
1478
1560
  }
1479
1561
  }
1480
1562
 
@@ -1504,6 +1586,10 @@ export const reportGeneratorTool = tool({
1504
1586
  .enum(["critical", "high", "medium", "low", "informational"])
1505
1587
  .default("informational"),
1506
1588
  preflight_policy: tool.schema.enum(["warn", "strict-fail"]).optional(),
1589
+ quality_gate_policy: tool.schema
1590
+ .enum(["warn", "strict-fail"])
1591
+ .optional()
1592
+ .describe("Controls whether report quality gate violations warn or fail generation."),
1507
1593
  tool_coverage_policy: tool.schema
1508
1594
  .enum(["enforce", "warn", "skip"])
1509
1595
  .optional()
@@ -1517,6 +1603,18 @@ export const reportGeneratorTool = tool({
1517
1603
  .describe(
1518
1604
  "The canonical run ID from <argus-context>. The tool reads the materialized report-input.json from disk using this ID.",
1519
1605
  ),
1606
+ revision: tool.schema
1607
+ .number()
1608
+ .optional()
1609
+ .describe(
1610
+ "Caller-supplied report revision. Must be an integer >= 2 and writes a -r{revision} file.",
1611
+ ),
1612
+ force: tool.schema
1613
+ .boolean()
1614
+ .optional()
1615
+ .describe(
1616
+ "Overwrite only the base canonical report path when existing Argus metadata matches the same run_id.",
1617
+ ),
1520
1618
  },
1521
1619
  async execute(args, context) {
1522
1620
  const result = await executeReportGeneration(args, context)