solidity-argus 0.5.6 → 0.5.8

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/AGENTS.md CHANGED
@@ -12,33 +12,33 @@ CLI: `argus doctor`, `argus init`, `argus install`.
12
12
 
13
13
  **Role**: Primary security audit orchestrator
14
14
  **Description**: Argus Panoptes, the All-Seeing Guardian. Coordinates full Solidity security audits by dispatching Sentinel (analysis), Pythia (research), Scribe (reporting), and Themis (validation). Follows a rigorous 7-step methodology: Reconnaissance, Automated Scanning, Manual Review, Attack Surface Mapping, Vulnerability Research, Testing & Verification, and Reporting.
15
- **Model**: anthropic/claude-opus-4-6
15
+ **Model**: anthropic/claude-opus-4-7
16
16
  **Tools**: 14 orchestrator-accessible argus_* tools (argus_slither_analyze, argus_analyze_contract, argus_check_patterns, argus_proxy_detection, argus_solodit_search, argus_forge_test, argus_gas_analysis, argus_forge_fuzz, argus_forge_coverage, argus_skill_load, argus_generate_report, argus_record_finding, argus_read_findings, argus_sync_knowledge). `argus_persist_deduped` is reserved for Scribe.
17
17
 
18
18
  ## sentinel
19
19
 
20
20
  **Role**: Static analysis and testing specialist
21
21
  **Description**: Finds vulnerabilities through Slither static analysis, Foundry testing, fuzzing, and pattern matching. The tactical executor — runs tools, writes PoC tests, and verifies findings. Dispatched by Argus during Automated Scanning and Testing & Verification phases.
22
- **Model**: anthropic/claude-sonnet-4-6
22
+ **Model**: anthropic/claude-sonnet-4-7
23
23
  **Tools**: argus_slither_analyze, argus_forge_test, argus_gas_analysis, argus_forge_fuzz, argus_forge_coverage, argus_analyze_contract, argus_check_patterns, argus_proxy_detection, argus_record_finding, skill
24
24
 
25
25
  ## pythia
26
26
 
27
27
  **Role**: Vulnerability researcher
28
28
  **Description**: Consults Solodit, SCVD, and the knowledge base to find historical precedents and known attack vectors. Searches 7,769+ real-world audit findings and 51 curated vulnerability pattern files. Dispatched by Argus during Vulnerability Research phase.
29
- **Model**: anthropic/claude-sonnet-4-6
29
+ **Model**: anthropic/claude-sonnet-4-7
30
30
  **Tools**: argus_solodit_search, argus_check_patterns, argus_record_finding, skill
31
31
 
32
32
  ## scribe
33
33
 
34
34
  **Role**: Audit report writer
35
35
  **Description**: Transforms raw findings into professional markdown audit reports. Produces structured output with severity classifications (Critical/High/Medium/Low/Informational), impact assessments, proof-of-concept steps, and actionable recommendations. Dispatched by Argus only after all analysis is complete.
36
- **Model**: anthropic/claude-sonnet-4-6
36
+ **Model**: anthropic/claude-sonnet-4-7
37
37
  **Tools**: argus_read_findings, argus_persist_deduped, argus_generate_report, skill
38
38
 
39
39
  ## themis
40
40
 
41
41
  **Role**: Audit quality gate
42
- **Description**: Independent cross-validation agent running on GPT-5.4 (different LLM provider for reasoning diversity). Validates pipeline integrity: compares raw findings against Scribe's deduped output and the final report. Performs second-opinion research via Solodit and vulnerability skill checklists. Returns a structured verdict to Argus who makes the final decision. Dispatched by Argus after Scribe completes.
43
- **Model**: openai/gpt-5.4
42
+ **Description**: Independent cross-validation agent running on GPT-5.5 (different LLM provider for reasoning diversity). Validates pipeline integrity: compares raw findings against Scribe's deduped output and the final report. Performs second-opinion research via Solodit and vulnerability skill checklists. Returns a structured verdict to Argus who makes the final decision. Dispatched by Argus after Scribe completes.
43
+ **Model**: openai/gpt-5.5
44
44
  **Tools**: argus_read_findings, argus_solodit_search, argus_check_patterns, argus_skill_load, skill
package/README.md CHANGED
@@ -65,11 +65,11 @@ Argus will automatically:
65
65
 
66
66
  | Agent | Role | Model |
67
67
  |-------|------|-------|
68
- | `@argus` | Orchestrator — coordinates the full audit | claude-opus-4-6 |
69
- | `@sentinel` | Static analysis & testing specialist | claude-sonnet-4-6 |
70
- | `@pythia` | Vulnerability researcher | claude-sonnet-4-6 |
71
- | `@scribe` | Audit report writer | claude-sonnet-4-6 |
72
- | `@themis` | Independent audit quality gate | gpt-5.4 |
68
+ | `@argus` | Orchestrator — coordinates the full audit | claude-opus-4-7 |
69
+ | `@sentinel` | Static analysis & testing specialist | claude-sonnet-4-7 |
70
+ | `@pythia` | Vulnerability researcher | claude-sonnet-4-7 |
71
+ | `@scribe` | Audit report writer | claude-sonnet-4-7 |
72
+ | `@themis` | Independent audit quality gate | gpt-5.5 |
73
73
 
74
74
  ### @argus — The Orchestrator
75
75
  Argus Panoptes is the lead auditor. It follows a 7-step methodology (Reconnaissance, Automated Scanning, Manual Review, Attack Surface Mapping, Vulnerability Research, Testing & Verification, Reporting) and delegates to Sentinel, Pythia, Scribe, and Themis as needed.
@@ -284,11 +284,11 @@ Create `.argus/solidity-argus.jsonc` in your project root. `.opencode/solidity-a
284
284
  ```jsonc
285
285
  {
286
286
  "agents": {
287
- "argus": { "model": "anthropic/claude-opus-4-6" },
288
- "sentinel": { "model": "anthropic/claude-sonnet-4-6" },
289
- "pythia": { "model": "anthropic/claude-sonnet-4-6" },
290
- "scribe": { "model": "anthropic/claude-sonnet-4-6" },
291
- "themis": { "model": "openai/gpt-5.4" }
287
+ "argus": { "model": "anthropic/claude-opus-4-7" },
288
+ "sentinel": { "model": "anthropic/claude-sonnet-4-7" },
289
+ "pythia": { "model": "anthropic/claude-sonnet-4-7" },
290
+ "scribe": { "model": "anthropic/claude-sonnet-4-7" },
291
+ "themis": { "model": "openai/gpt-5.5" }
292
292
  },
293
293
 
294
294
  "tools": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "solidity-argus",
3
- "version": "0.5.6",
3
+ "version": "0.5.8",
4
4
  "description": "Solidity smart contract security auditing plugin for OpenCode — 5 specialized agents, 15 tools (14 core + optional Solodit), and a curated vulnerability knowledge base",
5
5
  "keywords": [
6
6
  "solidity",
@@ -229,7 +229,7 @@ Task(subagent_type="scribe", prompt="Generate the final audit report for Project
229
229
  - **Constraint**: Only invoke Scribe after all analysis and testing are complete.
230
230
 
231
231
  ### **@themis** (The Quality Gate)
232
- - **Role**: Independent audit validation using a different LLM provider (GPT-5.4).
232
+ - **Role**: Independent audit validation using a different LLM provider (GPT-5.5).
233
233
  - **Tools**: \`argus_read_findings\`, \`argus_solodit_search\`, \`argus_check_patterns\`, \`argus_skill_load\`
234
234
  - **Delegation Examples**:
235
235
  \`\`\`
@@ -255,7 +255,7 @@ When building the final report or synthesizing findings:
255
255
  2. **Secondary source**: Tool transcript text (use only when durable evidence is unavailable or incomplete).
256
256
  3. **Never** synthesize findings from ephemeral background transcript retrieval alone if durable state evidence exists.
257
257
  4. **Manual-finding durability**: If Argus, Sentinel, or Pythia identifies a finding outside analyzer tool payloads, they must call \
258
- \`argus_record_finding\` before proceeding. The JSON payload MUST include \`impact\`, \`recommendation\`, and (for Critical/High) \`proofOfConcept\` fields.
258
+ \`argus_record_finding\` before proceeding. The JSON payload should include \`impact\`, \`recommendation\`, and \`proofOfConcept\` fields whenever they are known. Missing enrichment is recorded with warnings rather than rejected, but Scribe must enrich final Critical/High findings before reporting.
259
259
  5. **Report parity rule**: Scribe must not include findings in \`report_input\` unless they are event-backed (recorded via tools/events).
260
260
 
261
261
  **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.
@@ -365,7 +365,7 @@ Your subagents have access to these specialized tools. Know when to delegate eac
365
365
  "proofOfConcept": "Steps to reproduce or reference to PoC test"
366
366
  }
367
367
  \`\`\`
368
- - **CRITICAL**: For Critical and High findings, \`impact\`, \`recommendation\`, and \`proofOfConcept\` are MANDATORY. The quality gate will flag findings missing these fields. Preferred field names: \`check\`, \`file\`, \`lines\`. The aliases \`title\`/\`name\` → \`check\` and \`location\` → \`file\` are accepted but canonical names are preferred. Instruct Sentinel and Pythia accordingly when delegating.
368
+ - **CRITICAL**: For Critical and High final report findings, \`impact\`, \`recommendation\`, and \`proofOfConcept\` are MANDATORY. For any finding with \`source: "slither"\`, preserve the finding even when enrichment is not ready, but add these three fields before final Scribe persistence whenever possible. \`argus_record_finding\` warns on incomplete Slither enrichment instead of dropping the finding. Preferred field names: \`check\`, \`file\`, \`lines\`. The aliases \`title\`/\`name\` → \`check\` and \`location\` → \`file\` are accepted but canonical names are preferred. Instruct Sentinel and Pythia accordingly when delegating.
369
369
 
370
370
  - **\`argus_sync_knowledge\`**:
371
371
  - **Use**: Maintenance.
@@ -103,11 +103,12 @@ You have two primary tools. Master them.
103
103
  "lines": [startLine, endLine],
104
104
  "source": "manual",
105
105
  "impact": "Specific impact based on the historical precedent (e.g., 'Total vault drain via flash loan, similar to $X loss in Protocol Y')",
106
- "recommendation": "Specific mitigation from the precedent audit report"
106
+ "recommendation": "Specific mitigation from the precedent audit report",
107
+ "proofOfConcept": "Steps to reproduce, exploit sketch, or reference to the historical exploit/audit evidence"
107
108
  }
108
109
  \`\`\`
109
110
 
110
- **CRITICAL**: For Critical and High findings, \`impact\` and \`recommendation\` are MANDATORY. The quality gate will flag findings missing these fields. Use your Solodit research to write specific, precedent-backed impact and recommendation text — not generic placeholders.
111
+ **CRITICAL**: For Critical and High final report findings, \`impact\`, \`recommendation\`, and \`proofOfConcept\` are MANDATORY. \`argus_record_finding\` preserves incomplete findings with warnings rather than dropping them, but Scribe must enrich them before final reporting. Use your Solodit research to write specific, precedent-backed impact, recommendation, and proof-of-concept text — not generic placeholders.
111
112
 
112
113
  **Interpretation**:
113
114
  - A finding is not report-ready until it has been recorded through this tool.
@@ -124,7 +125,12 @@ This ensures Pythia always delivers research value, even when Solodit has no dir
124
125
 
125
126
  ## SKILLS SYSTEM
126
127
 
127
- OpenCode has a powerful **Skills** system that allows you to load specialized knowledge modules. The Argus knowledge base includes 75+ curated SKILL.md files, 13 YAML pattern packs, and 15 real-world exploit case studies covering $3B+ in losses.
128
+ The Argus knowledge base includes 75+ curated SKILL.md files, 13 YAML pattern packs, and 15 real-world exploit case studies covering $3B+ in losses. You load them with \`argus_skill_load\`.
129
+
130
+ **CRITICAL — use the right tool**:
131
+ - For ALL vulnerability, protocol, checklist, methodology, and case-study knowledge, use \`argus_skill_load\` with the exact skill name (e.g. \`argus_skill_load({ name: "reentrancy" })\`).
132
+ - **NEVER** call the generic OpenCode \`skill\` tool. It does not know about Argus skills like \`reentrancy\`, \`access-control\`, \`oracle-manipulation\`, etc., and will return "Skill or command not found" errors.
133
+ - If you are unsure whether a name is an Argus skill, default to \`argus_skill_load\` — it is the only correct loader for audit knowledge.
128
134
 
129
135
  **How to use**:
130
136
  - Load a relevant skill before deep research when protocol context is non-trivial.
@@ -65,10 +65,12 @@ Argus provides you with a \`run_id\`. Your job: read findings, deduplicate, enri
65
65
 
66
66
  This writes the source-of-truth JSON to disk at \`.argus/runs/{run_id}/deduped-findings.json\`.
67
67
 
68
- 5. **Generate report**: Call \`argus_generate_report\` with:
68
+ 5. **Generate report**: Call \`argus_generate_report\` with EXACTLY these arguments (and nothing else):
69
69
  - \`project_name\`: the project name
70
70
  - \`scope\`: list of audited files
71
- - \`run_id\`: the run ID (the tool reads your persisted deduped findings from disk)
71
+ - \`run_id\`: the run ID (the tool reads your persisted deduped findings from disk and resolves the canonical envelope automatically)
72
+
73
+ **DO NOT** pass \`report_input\`, \`findings\`, \`toolsExecuted\`, \`session_id\`, or any other field — the tool reads them from durable state on disk. Passing them risks contract-mismatch failures.
72
74
 
73
75
  6. **Limitations disclosure**: If any tool failed or was absent, add a \`## Limitations\` section.
74
76
 
@@ -151,7 +151,7 @@ You have access to a specific set of tools. Use them effectively.
151
151
  }
152
152
  \`\`\`
153
153
 
154
- **CRITICAL**: For Critical and High findings, \`impact\`, \`recommendation\`, and \`proofOfConcept\` are MANDATORY. The quality gate will flag findings missing these fields. Do not use generic placeholders — be specific to the vulnerability.
154
+ **CRITICAL**: For Critical and High findings, \`impact\`, \`recommendation\`, and \`proofOfConcept\` are MANDATORY. For any finding with \`source: "slither"\`, preserve the finding even when enrichment is not ready, but add these three fields before final Scribe persistence whenever possible. \`argus_record_finding\` warns on incomplete Slither enrichment instead of dropping the finding. Do not use generic placeholders — be specific to the vulnerability.
155
155
 
156
156
  **Interpretation**:
157
157
  - Recording is mandatory before handing findings to Argus for final synthesis.
@@ -5,7 +5,7 @@ export const THEMIS_PROMPT = `You are **Themis**, the Quality Gate of Argus Pano
5
5
  You are the final validation and review agent in the audit pipeline. You do not run the full audit from scratch and you do not write the final report. You verify that the pipeline output is complete, consistent, and defensible.
6
6
 
7
7
  Model context:
8
- - You run on **OpenAI GPT-5.4-pro**.
8
+ - You run on **OpenAI GPT-5.5**.
9
9
  - This is intentionally a different provider than the other Argus agents (Claude) to increase reasoning diversity for final quality checks.
10
10
 
11
11
  Your core responsibilities are:
@@ -1,5 +1,6 @@
1
1
  import { existsSync, readdirSync, readFileSync } from "node:fs"
2
- import { basename, dirname, extname, join } from "node:path"
2
+ import { homedir } from "node:os"
3
+ import { basename, dirname, extname, join, resolve } from "node:path"
3
4
  import { loadArgusConfig } from "../../config/loader"
4
5
  import type { ArgusConfig } from "../../config/types"
5
6
  import { createLogger } from "../../shared/logger"
@@ -133,6 +134,143 @@ export function buildSkillHealthReport(
133
134
  }
134
135
  }
135
136
 
137
+ // ─────────────────────────────────────────────────────────────────────────────
138
+ // Install-drift detection
139
+ //
140
+ // OpenCode's plugin resolver walks up the filesystem looking up `node_modules`
141
+ // directories. A stale copy of solidity-argus hoisted to a higher-precedence
142
+ // location (typically `~/.cache/opencode/node_modules/solidity-argus`) will
143
+ // SHADOW the canonical install under `~/.cache/opencode/packages/...`. The
144
+ // shadowing install is loaded silently, leading to confusing failures like
145
+ // `undefined is not an object (evaluating 'result.toLowerCase')` on every MCP
146
+ // call (older versions lacked defensive guards in `tool.execute.after`).
147
+ //
148
+ // This check enumerates known install locations and flags drift.
149
+ // ─────────────────────────────────────────────────────────────────────────────
150
+
151
+ export type ArgusInstallSource =
152
+ | "current"
153
+ | "hoisted-cache"
154
+ | "package-cache"
155
+ | "user-config"
156
+ | "project-local"
157
+
158
+ export type ArgusInstall = {
159
+ source: ArgusInstallSource
160
+ path: string
161
+ version: string | null
162
+ }
163
+
164
+ export type InstallDriftReport = {
165
+ current: ArgusInstall | null
166
+ installs: ArgusInstall[]
167
+ errors: string[]
168
+ warnings: string[]
169
+ }
170
+
171
+ function readPackageVersion(packageRoot: string): string | null {
172
+ try {
173
+ const raw = readFileSync(join(packageRoot, "package.json"), "utf8")
174
+ const parsed = JSON.parse(raw) as { version?: unknown }
175
+ return typeof parsed.version === "string" ? parsed.version : null
176
+ } catch {
177
+ return null
178
+ }
179
+ }
180
+
181
+ function getCurrentArgusInstall(): ArgusInstall | null {
182
+ // doctor.ts lives at <packageRoot>/src/cli/commands/doctor.ts
183
+ const packageRoot = resolve(import.meta.dir, "../../..")
184
+ if (!existsSync(join(packageRoot, "package.json"))) return null
185
+ const version = readPackageVersion(packageRoot)
186
+ return { source: "current", path: packageRoot, version }
187
+ }
188
+
189
+ export function enumerateArgusInstallCandidates(
190
+ cwd: string,
191
+ home: string,
192
+ ): Array<{ source: ArgusInstallSource; path: string }> {
193
+ return [
194
+ {
195
+ source: "hoisted-cache",
196
+ path: join(home, ".cache", "opencode", "node_modules", "solidity-argus"),
197
+ },
198
+ {
199
+ source: "package-cache",
200
+ path: join(
201
+ home,
202
+ ".cache",
203
+ "opencode",
204
+ "packages",
205
+ "solidity-argus@latest",
206
+ "node_modules",
207
+ "solidity-argus",
208
+ ),
209
+ },
210
+ {
211
+ source: "user-config",
212
+ path: join(home, ".config", "opencode", "node_modules", "solidity-argus"),
213
+ },
214
+ {
215
+ source: "project-local",
216
+ path: join(cwd, "node_modules", "solidity-argus"),
217
+ },
218
+ ]
219
+ }
220
+
221
+ function findArgusInstalls(cwd: string, home: string): ArgusInstall[] {
222
+ const installs: ArgusInstall[] = []
223
+ for (const { source, path } of enumerateArgusInstallCandidates(cwd, home)) {
224
+ if (existsSync(path)) {
225
+ installs.push({ source, path, version: readPackageVersion(path) })
226
+ }
227
+ }
228
+ return installs
229
+ }
230
+
231
+ export function detectInstallDrift(
232
+ current: ArgusInstall | null,
233
+ installs: ArgusInstall[],
234
+ ): { errors: string[]; warnings: string[] } {
235
+ const errors: string[] = []
236
+ const warnings: string[] = []
237
+
238
+ const hoisted = installs.find((i) => i.source === "hoisted-cache")
239
+ const pkgCache = installs.find((i) => i.source === "package-cache")
240
+
241
+ // Highest-confidence error: hoisted cache shadows the canonical cache with a
242
+ // DIFFERENT version. OpenCode will load the wrong one.
243
+ if (hoisted && pkgCache && hoisted.version !== pkgCache.version) {
244
+ errors.push(
245
+ `Stale install shadowing canonical version:\n` +
246
+ ` ${hoisted.path} (v${hoisted.version ?? "unknown"})\n` +
247
+ ` shadows ${pkgCache.path} (v${pkgCache.version ?? "unknown"}).\n` +
248
+ ` OpenCode will load v${hoisted.version ?? "unknown"} instead of v${pkgCache.version ?? "unknown"}.\n` +
249
+ ` Fix: rm -rf "${hoisted.path}"`,
250
+ )
251
+ return { errors, warnings }
252
+ }
253
+
254
+ // Lower-confidence: hoisted install drifts from the version the doctor CLI
255
+ // is itself running as (typical when the user upgraded via bunx/opencode).
256
+ if (hoisted && current?.version && hoisted.version && hoisted.version !== current.version) {
257
+ warnings.push(
258
+ `Possible stale install (drift from running version):\n` +
259
+ ` ${hoisted.path} (v${hoisted.version}) differs from current (v${current.version}).\n` +
260
+ ` Fix: rm -rf "${hoisted.path}"`,
261
+ )
262
+ }
263
+
264
+ return { errors, warnings }
265
+ }
266
+
267
+ export function buildInstallDriftReport(cwd: string, home: string): InstallDriftReport {
268
+ const current = getCurrentArgusInstall()
269
+ const installs = findArgusInstalls(cwd, home)
270
+ const { errors, warnings } = detectInstallDrift(current, installs)
271
+ return { current, installs, errors, warnings }
272
+ }
273
+
136
274
  const NON_SKILL_FILENAMES = new Set(["README.md", "INVENTORY.md", "CHANGELOG.md", "LICENSE.md"])
137
275
 
138
276
  function scanMarkdownFiles(dir: string, maxDepth = 8): string[] {
@@ -237,6 +375,22 @@ export const doctorCommand: CliCommand = {
237
375
  cliOutput.log(`${YELLOW}⚠${RESET} Project: no Solidity project detected`)
238
376
  }
239
377
 
378
+ const driftReport = buildInstallDriftReport(cwd, homedir())
379
+ if (driftReport.errors.length === 0 && driftReport.warnings.length === 0) {
380
+ const versionStr = driftReport.current?.version
381
+ ? ` (current: v${driftReport.current.version})`
382
+ : ""
383
+ cliOutput.log(`${GREEN}✓${RESET} Install drift: none detected${versionStr}`)
384
+ } else {
385
+ for (const err of driftReport.errors) {
386
+ cliOutput.log(`${RED}✗${RESET} Install drift: ${err}`)
387
+ hasFailure = true
388
+ }
389
+ for (const warn of driftReport.warnings) {
390
+ cliOutput.log(`${YELLOW}⚠${RESET} Install drift: ${warn}`)
391
+ }
392
+ }
393
+
240
394
  if (projectType === "foundry" && detectViaIr(cwd)) {
241
395
  cliOutput.log(
242
396
  `${YELLOW}⚠${RESET} via_ir: enabled in foundry.toml — Slither will use flatten fallback`,
@@ -1,57 +1,101 @@
1
- import { existsSync, readFileSync, writeFileSync } from "node:fs"
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
2
2
  import { homedir } from "node:os"
3
- import { join } from "node:path"
3
+ import { dirname, join } from "node:path"
4
4
  import { cliOutput } from "../cli-output"
5
+ import { confirm } from "../tui-prompts"
5
6
  import type { CliCommand } from "../types"
6
7
 
7
8
  const GREEN = "\x1b[32m"
8
9
  const YELLOW = "\x1b[33m"
9
10
  const RESET = "\x1b[0m"
10
11
 
12
+ function resolveHome(homeOverride?: string): string {
13
+ if (homeOverride && homeOverride.length > 0) return homeOverride
14
+ const envHome = process.env.HOME ?? process.env.USERPROFILE
15
+ if (envHome && envHome.length > 0) return envHome
16
+ return homedir()
17
+ }
18
+
19
+ function localConfigPath(): string {
20
+ return join(process.cwd(), "opencode.json")
21
+ }
22
+
23
+ function globalConfigPath(homeOverride?: string): string {
24
+ return join(resolveHome(homeOverride), ".config", "opencode", "opencode.json")
25
+ }
26
+
11
27
  export function findOpencodeConfig(homeOverride?: string): string | null {
12
- const cwd = process.cwd()
13
- const localPath = join(cwd, "opencode.json")
14
- if (existsSync(localPath)) return localPath
28
+ const local = localConfigPath()
29
+ if (existsSync(local)) return local
15
30
 
16
- const home = homeOverride ?? homedir()
17
- const globalPath = join(home, ".config", "opencode", "opencode.json")
18
- if (existsSync(globalPath)) return globalPath
31
+ const global = globalConfigPath(homeOverride)
32
+ if (existsSync(global)) return global
19
33
 
20
34
  return null
21
35
  }
22
36
 
37
+ function addPluginToConfig(configPath: string): { added: boolean; ok: boolean } {
38
+ try {
39
+ let config: Record<string, unknown>
40
+ if (existsSync(configPath)) {
41
+ const content = readFileSync(configPath, "utf-8")
42
+ config = JSON.parse(content)
43
+ } else {
44
+ mkdirSync(dirname(configPath), { recursive: true })
45
+ config = {}
46
+ }
47
+
48
+ const plugins = Array.isArray(config.plugin) ? (config.plugin as string[]) : []
49
+ if (plugins.includes("solidity-argus")) {
50
+ cliOutput.log(`${GREEN}✓${RESET} solidity-argus already registered in ${configPath}`)
51
+ return { added: false, ok: true }
52
+ }
53
+
54
+ plugins.push("solidity-argus")
55
+ config.plugin = plugins
56
+ writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`)
57
+ cliOutput.log(`${GREEN}✓${RESET} Added solidity-argus to ${configPath}`)
58
+ return { added: true, ok: true }
59
+ } catch (_error) {
60
+ cliOutput.error(`${YELLOW}⚠${RESET} Failed to update ${configPath}`)
61
+ return { added: false, ok: false }
62
+ }
63
+ }
64
+
23
65
  export const installCommand: CliCommand = {
24
66
  name: "install",
25
- description: "Register solidity-argus in your OpenCode config",
26
- async execute(_args: string[]): Promise<number> {
27
- const configPath = findOpencodeConfig()
28
-
29
- if (!configPath) {
30
- cliOutput.error(
31
- `${YELLOW}⚠${RESET} opencode.json not found — create one first, or run: opencode init`,
32
- )
33
- return 1
34
- }
67
+ description:
68
+ "Register solidity-argus in your OpenCode config (use --global for ~/.config/opencode)",
69
+ async execute(args: string[]): Promise<number> {
70
+ const isGlobal = args.includes("--global") || args.includes("-g")
71
+ const local = localConfigPath()
35
72
 
36
- try {
37
- const content = readFileSync(configPath, "utf-8")
38
- const config = JSON.parse(content)
39
- const plugins: string[] = config.plugin ?? []
73
+ if (existsSync(local) && !isGlobal) {
74
+ return addPluginToConfig(local).ok ? 0 : 1
75
+ }
40
76
 
41
- if (plugins.includes("solidity-argus")) {
42
- cliOutput.log(`${GREEN}✓${RESET} solidity-argus already registered in ${configPath}`)
43
- return 0
44
- }
77
+ if (isGlobal) {
78
+ return addPluginToConfig(globalConfigPath()).ok ? 0 : 1
79
+ }
45
80
 
46
- plugins.push("solidity-argus")
47
- config.plugin = plugins
48
- writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`)
81
+ const global = globalConfigPath()
82
+ cliOutput.warn(
83
+ `${YELLOW}⚠${RESET} No opencode.json found in current directory (${process.cwd()}).`,
84
+ )
85
+ cliOutput.warn(
86
+ ` Installing globally would write to ${global} and load solidity-argus in EVERY OpenCode session.`,
87
+ )
88
+ cliOutput.warn(` To install globally on purpose, re-run with: argus install --global`)
89
+ cliOutput.warn(
90
+ ` To install for this project, first create an opencode.json in this directory.`,
91
+ )
49
92
 
50
- cliOutput.log(`${GREEN}✓${RESET} Added solidity-argus to ${configPath}`)
93
+ const proceed = await confirm("Install globally anyway?", false)
94
+ if (!proceed) {
95
+ cliOutput.log("Aborted. No changes made.")
51
96
  return 0
52
- } catch (_error) {
53
- cliOutput.error(`${YELLOW}⚠${RESET} Failed to update ${configPath}`)
54
- return 1
55
97
  }
98
+
99
+ return addPluginToConfig(global).ok ? 0 : 1
56
100
  },
57
101
  }
@@ -1,9 +1,9 @@
1
1
  export const DEFAULT_MODELS = {
2
- argus: "anthropic/claude-opus-4-6",
3
- sentinel: "anthropic/claude-sonnet-4-6",
4
- pythia: "anthropic/claude-sonnet-4-6",
5
- scribe: "anthropic/claude-sonnet-4-6",
6
- themis: "openai/gpt-5.4",
2
+ argus: "anthropic/claude-opus-4-7",
3
+ sentinel: "anthropic/claude-sonnet-4-7",
4
+ pythia: "anthropic/claude-sonnet-4-7",
5
+ scribe: "anthropic/claude-sonnet-4-7",
6
+ themis: "openai/gpt-5.5",
7
7
  } as const
8
8
 
9
9
  export const DEFAULT_STEPS = 50 as const
@@ -14,7 +14,7 @@ import {
14
14
  releaseEventSink,
15
15
  } from "./features/persistent-state/event-sink"
16
16
  import {
17
- materializeFindings,
17
+ materializeFindingsForRun,
18
18
  materializeReportInput,
19
19
  } from "./features/persistent-state/findings-materializer"
20
20
  import { recordRun, updateRunStatus } from "./features/persistent-state/global-run-index"
@@ -874,34 +874,17 @@ export function createHooks(args: {
874
874
  )
875
875
  : undefined
876
876
 
877
- const materializeFindingsForRun = async (
877
+ const runMaterializeFindings = (
878
878
  runId: string,
879
879
  projectDirForRun: string,
880
880
  sessionIdForRun: string | undefined,
881
881
  trigger: "session.idle" | "session.deleted" | "tool.execute.after",
882
882
  failFast = false,
883
- ): Promise<void> => {
884
- if (!runId || runId.length === 0) {
885
- return
886
- }
887
-
888
- try {
889
- await materializeFindings(runId, projectDirForRun, sessionIdForRun, {
890
- validateSessionId: false,
891
- requireEvents: true,
892
- })
893
- } catch (error) {
894
- if (failFast) {
895
- throw new Error(
896
- `Failed to materialize findings artifact on ${trigger} for run ${runId}: ${error instanceof Error ? error.message : String(error)}`,
897
- )
898
- }
899
-
900
- logger.warn(
901
- `Failed to materialize findings artifact on ${trigger} for run ${runId}: ${error instanceof Error ? error.message : String(error)}`,
902
- )
903
- }
904
- }
883
+ ): Promise<void> =>
884
+ materializeFindingsForRun(runId, projectDirForRun, sessionIdForRun, trigger, {
885
+ failFast,
886
+ warn: (msg) => logger.warn(msg),
887
+ })
905
888
 
906
889
  const safeEventHook = isHookEnabled("event")
907
890
  ? safeCreateHook(
@@ -920,7 +903,7 @@ export function createHooks(args: {
920
903
 
921
904
  if (hasNewFinalization && finalizationResult.runId.length > 0) {
922
905
  try {
923
- await materializeFindingsForRun(
906
+ await runMaterializeFindings(
924
907
  finalizationResult.runId,
925
908
  projectDir,
926
909
  eventSessionId,
@@ -1093,12 +1076,12 @@ export function createHooks(args: {
1093
1076
  )
1094
1077
  }
1095
1078
 
1096
- await materializeFindingsForRun(
1079
+ await runMaterializeFindings(
1097
1080
  state.sessionId,
1098
1081
  state.projectDir,
1099
1082
  input.sessionID,
1100
1083
  "tool.execute.after",
1101
- true,
1084
+ false,
1102
1085
  )
1103
1086
 
1104
1087
  try {
@@ -12,6 +12,13 @@ import type { CanonicalFinding, CanonicalToolExecution, ReportInput } from "../.
12
12
  import { SCHEMA_VERSION } from "../../state/schemas"
13
13
  import { readEvents } from "./event-sink"
14
14
 
15
+ export type MaterializeFindingsTrigger = "session.idle" | "session.deleted" | "tool.execute.after"
16
+
17
+ export interface MaterializeFindingsForRunOptions {
18
+ failFast?: boolean
19
+ warn?: (message: string) => void
20
+ }
21
+
15
22
  export interface FindingsArtifact {
16
23
  run_id: string
17
24
  session_id: string
@@ -78,6 +85,30 @@ export async function materializeFindings(
78
85
  return artifact
79
86
  }
80
87
 
88
+ export async function materializeFindingsForRun(
89
+ runId: string,
90
+ projectDir: string,
91
+ sessionId: string | undefined,
92
+ trigger: MaterializeFindingsTrigger,
93
+ options: MaterializeFindingsForRunOptions = {},
94
+ ): Promise<void> {
95
+ if (!runId || runId.length === 0) return
96
+
97
+ const { failFast = false, warn } = options
98
+ try {
99
+ await materializeFindings(runId, projectDir, sessionId, {
100
+ validateSessionId: false,
101
+ requireEvents: true,
102
+ })
103
+ } catch (error) {
104
+ const message = `Failed to materialize findings artifact on ${trigger} for run ${runId}: ${error instanceof Error ? error.message : String(error)}`
105
+ if (failFast) {
106
+ throw new Error(message)
107
+ }
108
+ warn?.(message)
109
+ }
110
+ }
111
+
81
112
  export async function materializeReportInput(
82
113
  runId: string,
83
114
  projectDir: string,
@@ -83,6 +83,54 @@ function asRecord(value: unknown): Record<string, unknown> | null {
83
83
  return null
84
84
  }
85
85
 
86
+ function isGenerateReportCompletion(event: AuditEvent): boolean {
87
+ if (event.type !== "tool.completed") return false
88
+ const payload = asRecord(event.payload)
89
+ if (!payload) return false
90
+ return payload.tool === "argus_generate_report" || payload.name === "argus_generate_report"
91
+ }
92
+
93
+ async function collectReportCompletenessErrors(events: AuditEvent[]): Promise<string[]> {
94
+ const errors: string[] = []
95
+ const reportEvents = events.filter(isGenerateReportCompletion)
96
+
97
+ for (const event of reportEvents) {
98
+ const payload = asRecord(event.payload)
99
+ const filePath = payload?.filePath
100
+ if (typeof filePath !== "string" || filePath.length === 0) continue
101
+
102
+ try {
103
+ const report = await Bun.file(filePath).text()
104
+ if (report.includes("## ⚠ Completeness Warning")) {
105
+ errors.push("generated report contains Completeness Warning")
106
+ }
107
+ } catch {
108
+ // Missing report files are handled by report-generation/tool-tracking gates.
109
+ }
110
+ }
111
+
112
+ return errors
113
+ }
114
+
115
+ function collectReportQualityGateErrors(events: AuditEvent[]): string[] {
116
+ const errors: string[] = []
117
+ const reportEvents = events.filter(isGenerateReportCompletion)
118
+
119
+ for (const event of reportEvents) {
120
+ const payload = asRecord(event.payload)
121
+ const qualityGates = asRecord(payload?.qualityGates)
122
+ if (qualityGates?.passed !== false) continue
123
+
124
+ const violations = Array.isArray(qualityGates.violations)
125
+ ? qualityGates.violations.filter((entry): entry is string => typeof entry === "string")
126
+ : []
127
+ const details = violations.length > 0 ? `: ${violations.join("; ")}` : ""
128
+ errors.push(`generated report failed quality gates${details}`)
129
+ }
130
+
131
+ return errors
132
+ }
133
+
86
134
  function collectParentChildIntegrityErrors(events: AuditEvent[]): string[] {
87
135
  const errors: string[] = []
88
136
  const parentByChild = new Map<string, string>()
@@ -257,17 +305,25 @@ export async function finalizeRun(
257
305
  const hasEventsAfterExistingFinalization =
258
306
  existingResult !== null && existingResult.finalizedIndex < events.length - 1
259
307
  if (existingResult?.invariantsPassed && !hasEventsAfterExistingFinalization) {
260
- return {
261
- success: existingResult.success,
262
- invariantsPassed: existingResult.invariantsPassed,
263
- errors: existingResult.errors,
264
- warnings: existingResult.warnings,
265
- runId: existingResult.runId,
266
- timestamp: existingResult.timestamp,
308
+ const reportErrors = [
309
+ ...(await collectReportCompletenessErrors(events)),
310
+ ...collectReportQualityGateErrors(events),
311
+ ]
312
+ if (reportErrors.length === 0) {
313
+ return {
314
+ success: existingResult.success,
315
+ invariantsPassed: existingResult.invariantsPassed,
316
+ errors: existingResult.errors,
317
+ warnings: existingResult.warnings,
318
+ runId: existingResult.runId,
319
+ timestamp: existingResult.timestamp,
320
+ }
267
321
  }
268
322
  }
269
323
 
270
324
  const { errors, warnings } = collectInvariantErrors(events)
325
+ errors.push(...(await collectReportCompletenessErrors(events)))
326
+ errors.push(...collectReportQualityGateErrors(events))
271
327
  const invariantsPassed = errors.length === 0
272
328
  const sessionId = events.at(-1)?.session_id ?? ""
273
329
 
@@ -185,7 +185,7 @@ export function createConfigHandler(
185
185
  mode: "subagent",
186
186
  model: argusConfig.agents?.themis?.model ?? DEFAULT_MODELS.themis,
187
187
  steps: argusConfig.agents?.themis?.steps ?? DEFAULT_STEPS,
188
- description: "Audit quality gate — independent cross-validation (GPT-5.4)",
188
+ description: "Audit quality gate — independent cross-validation (GPT-5.5)",
189
189
  prompt: THEMIS_PROMPT,
190
190
  permission: {
191
191
  argus_read_findings: "allow",
@@ -348,6 +348,11 @@ function processToolResult(
348
348
  }
349
349
 
350
350
  if (config.extractOptionalFields) {
351
+ findingPayload.impact = typeof item.impact === "string" ? item.impact : undefined
352
+ findingPayload.recommendation =
353
+ typeof item.recommendation === "string" ? item.recommendation : undefined
354
+ findingPayload.proofOfConcept =
355
+ typeof item.proofOfConcept === "string" ? item.proofOfConcept : undefined
351
356
  findingPayload.remediation =
352
357
  typeof item.remediation === "string" ? item.remediation : undefined
353
358
  findingPayload.exploitReference =
@@ -85,7 +85,7 @@ export const persistDedupedTool = tool({
85
85
  deduped_findings: tool.schema
86
86
  .string()
87
87
  .describe(
88
- "Serialized JSON array of deduplicated and enriched findings. Each finding should have: check, severity, confidence, description, file, lines, source, impact, recommendation.",
88
+ "Serialized JSON array of deduplicated and enriched findings. Each finding should have: check, severity, confidence, description, file, lines, source, impact, recommendation, proofOfConcept.",
89
89
  ),
90
90
  },
91
91
  async execute(args, context) {
@@ -16,13 +16,20 @@ type RecordFindingResponse = {
16
16
  id: string
17
17
  check: string
18
18
  severity: string
19
+ confidence: string
19
20
  file: string
20
21
  description: string
21
22
  lines: [number, number]
22
23
  source: string
24
+ reported_by_agent: string
25
+ impact?: string
26
+ recommendation?: string
27
+ proofOfConcept?: string
23
28
  }>
24
29
  schema_version: string
25
30
  note: string
31
+ enrichment_warnings?: string[]
32
+ enrichment_hint?: string
26
33
  }
27
34
 
28
35
  type ParseResult = { ok: true; data: Record<string, unknown>[] } | { ok: false; error: string }
@@ -74,6 +81,16 @@ function errorResponse(error: string): string {
74
81
  })
75
82
  }
76
83
 
84
+ function collectMissingEnrichmentFields(
85
+ finding: ReturnType<typeof normalizeToCanonicalFinding>["data"],
86
+ ): string[] {
87
+ const missing: string[] = []
88
+ if (!isNonEmptyString(finding.impact)) missing.push("impact")
89
+ if (!isNonEmptyString(finding.recommendation)) missing.push("recommendation")
90
+ if (!isNonEmptyString(finding.proofOfConcept)) missing.push("proofOfConcept")
91
+ return missing
92
+ }
93
+
77
94
  export async function executeRecordFinding(
78
95
  args: RecordFindingArgs,
79
96
  context: ToolContext,
@@ -155,16 +172,21 @@ export async function executeRecordFinding(
155
172
  return errorResponse(`Failed to record finding(s): ${errors.join("; ")}`)
156
173
  }
157
174
 
158
- // Warn when Critical/High findings are missing enrichment fields
175
+ // Warn when report-quality enrichment is missing without dropping findings.
159
176
  const enrichmentWarnings: string[] = []
160
177
  const HIGH_SEVERITIES = new Set(["Critical", "High"])
161
178
  for (const f of findings) {
162
- if (!HIGH_SEVERITIES.has(f.severity)) continue
163
- const missing: string[] = []
164
- if (!f.impact) missing.push("impact")
165
- if (!f.recommendation) missing.push("recommendation")
166
- if (!f.proofOfConcept) missing.push("proofOfConcept")
179
+ const missing = collectMissingEnrichmentFields(f)
167
180
  if (missing.length > 0) {
181
+ if (f.source === "slither") {
182
+ enrichmentWarnings.push(
183
+ `[${f.severity}] Slither finding ${f.check} in ${f.file} is missing: ${missing.join(", ")}. The finding was recorded, but Scribe must enrich it before final reporting.`,
184
+ )
185
+ continue
186
+ }
187
+
188
+ if (!HIGH_SEVERITIES.has(f.severity)) continue
189
+
168
190
  enrichmentWarnings.push(
169
191
  `[${f.severity}] ${f.check} in ${f.file} is missing: ${missing.join(", ")}. Quality gate will flag this.`,
170
192
  )
@@ -178,10 +200,15 @@ export async function executeRecordFinding(
178
200
  id: f.id,
179
201
  check: f.check,
180
202
  severity: f.severity,
203
+ confidence: f.confidence,
181
204
  file: f.file,
182
205
  description: f.description,
183
206
  lines: f.lines,
184
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 } : {}),
185
212
  })),
186
213
  schema_version: SCHEMA_VERSION,
187
214
  note: "Findings recorded to event journal. The system assigns the canonical run_id automatically — use the run_id from <argus-context> for Scribe dispatch.",
@@ -189,7 +216,7 @@ export async function executeRecordFinding(
189
216
  ? {
190
217
  enrichment_warnings: enrichmentWarnings,
191
218
  enrichment_hint:
192
- "Critical and High findings MUST include impact, recommendation, and proofOfConcept fields. Re-submit with these fields to pass the quality gate.",
219
+ "Critical and High findings MUST include impact, recommendation, and proofOfConcept fields. Slither findings should include all three fields before Scribe persists deduped findings; incomplete Slither records are preserved but will be flagged by report quality gates if not enriched downstream.",
193
220
  }
194
221
  : {}),
195
222
  }
@@ -205,13 +232,13 @@ export const recordFindingTool = tool({
205
232
  .string()
206
233
  .optional()
207
234
  .describe(
208
- 'Serialized JSON object for a single finding. Required fields: check (string, e.g. "reentrancy-eth"), severity (Critical|High|Medium|Low|Informational), confidence (High|Medium|Low), description (string), file (relative path, e.g. "src/Vault.sol"), lines ([startLine, endLine] tuple), source ("manual"). Optional: impact, recommendation, proofOfConcept (mandatory for Critical/High).',
235
+ 'Serialized JSON object for a single finding. Required fields: check (string, e.g. "reentrancy-eth"), severity (Critical|High|Medium|Low|Informational), confidence (High|Medium|Low), description (string), file (relative path, e.g. "src/Vault.sol"), lines ([startLine, endLine] tuple), source ("manual"|"slither"|"pattern"|"scvd"|"solodit"|"fuzz"). Optional: impact, recommendation, proofOfConcept (mandatory for Critical/High final report findings; strongly recommended for Slither-source findings before Scribe persistence).',
209
236
  ),
210
237
  findings: tool.schema
211
238
  .string()
212
239
  .optional()
213
240
  .describe(
214
- "Serialized JSON array of finding objects. Each object requires the same fields as the finding parameter: check, severity, confidence, description, file, lines, source. Aliases title/name → check and location → file are accepted but canonical names are preferred.",
241
+ "Serialized JSON array of finding objects. Each object requires the same fields as the finding parameter: check, severity, confidence, description, file, lines, source. impact, recommendation, and proofOfConcept are mandatory for Critical/High final report findings and strongly recommended for Slither-source findings before Scribe persistence. Aliases title/name → check and location → file are accepted but canonical names are preferred.",
215
242
  ),
216
243
  },
217
244
  async execute(args, context) {
@@ -14,13 +14,14 @@ import { resolveProjectDir } from "../shared/project-utils"
14
14
  import { resolveReportPath } from "../shared/report-path-resolver"
15
15
  import { isNonEmptyString } from "../shared/type-guards"
16
16
  import { SEVERITY_RANK } from "../shared/validation-constants"
17
+ import { normalizeToCanonicalFinding } from "../state/adapters"
17
18
  import {
18
19
  compareIssueFingerprintSets,
19
20
  dedupeFindingsForFinalOutput,
20
21
  } from "../state/finding-aggregation"
21
22
  import { projectFindings, stableHash } from "../state/projectors"
22
23
  import { type ReportInput, SCHEMA_VERSION, validateReportInput } from "../state/schemas"
23
- import type { AuditState, Finding, FindingSeverity } from "../state/types"
24
+ import type { ArgusAgentName, AuditState, Finding, FindingSeverity } from "../state/types"
24
25
  import { checkReportPreflight } from "./report-preflight"
25
26
 
26
27
  type SeverityThreshold = "critical" | "high" | "medium" | "low" | "informational"
@@ -304,6 +305,37 @@ type ParseReportInputResult = {
304
305
  diagnostics: DropDiagnostic[]
305
306
  }
306
307
 
308
+ const VALID_AGENT_VALUES = new Set<ArgusAgentName>([
309
+ "argus",
310
+ "sentinel",
311
+ "pythia",
312
+ "scribe",
313
+ "unknown",
314
+ ])
315
+
316
+ function normalizeDedupedFindings(
317
+ rawFindings: unknown[],
318
+ runId: string,
319
+ projectDir: string,
320
+ dedupedBy: string,
321
+ ): Record<string, unknown>[] {
322
+ const reportedByAgent: ArgusAgentName = VALID_AGENT_VALUES.has(dedupedBy as ArgusAgentName)
323
+ ? (dedupedBy as ArgusAgentName)
324
+ : "scribe"
325
+ return rawFindings.map((raw, index) => {
326
+ const input = raw && typeof raw === "object" ? (raw as Record<string, unknown>) : {}
327
+ const normalized = normalizeRawFinding(input)
328
+ const result = normalizeToCanonicalFinding(
329
+ normalized,
330
+ runId,
331
+ index + 1,
332
+ { reportedByAgent },
333
+ projectDir,
334
+ )
335
+ return result.data as unknown as Record<string, unknown>
336
+ })
337
+ }
338
+
307
339
  function diagnosticsSummary(diagnostics: DropDiagnostic[]): string {
308
340
  return diagnostics.map((diag) => `${diag.reason.code}:${diag.reason.message}`).join("; ")
309
341
  }
@@ -576,6 +608,7 @@ function parseReportInputPayload(
576
608
  try {
577
609
  const dedupedArtifact = JSON.parse(readFileSync(dedupedFile, "utf-8")) as {
578
610
  findings?: unknown[]
611
+ deduped_by?: string
579
612
  }
580
613
  if (Array.isArray(dedupedArtifact.findings) && dedupedArtifact.findings.length > 0) {
581
614
  const reportInputFile = resolver.paths().reportInputFile
@@ -590,15 +623,59 @@ function parseReportInputPayload(
590
623
  /* use empty base */
591
624
  }
592
625
  }
593
- const merged = {
626
+ const normalizedFindings = normalizeDedupedFindings(
627
+ dedupedArtifact.findings,
628
+ effectiveRunId,
629
+ projectDir,
630
+ typeof dedupedArtifact.deduped_by === "string" ? dedupedArtifact.deduped_by : "scribe",
631
+ )
632
+ const merged: Record<string, unknown> = {
594
633
  ...baseInput,
595
634
  run_id: effectiveRunId,
596
- findings: dedupedArtifact.findings,
635
+ findings: normalizedFindings,
636
+ }
637
+ normalizeToolsExecutedDefaults(merged, effectiveRunId, diagnostics)
638
+ if (typeof merged.seq !== "number" || (merged.seq as number) < 0) {
639
+ merged.seq = 0
640
+ }
641
+ if (typeof merged.session_id !== "string" || (merged.session_id as string).length === 0) {
642
+ merged.session_id = "unknown"
643
+ }
644
+ if (
645
+ typeof merged.tool_call_id !== "string" ||
646
+ (merged.tool_call_id as string).length === 0
647
+ ) {
648
+ merged.tool_call_id = `deduped:${effectiveRunId}`
649
+ }
650
+ if (typeof merged.source !== "string" || (merged.source as string).length === 0) {
651
+ merged.source = "deduped-findings"
652
+ }
653
+ if (
654
+ typeof merged.schema_version !== "string" ||
655
+ merged.schema_version !== SCHEMA_VERSION
656
+ ) {
657
+ merged.schema_version = SCHEMA_VERSION
658
+ }
659
+ if (typeof merged.projectDir !== "string" || (merged.projectDir as string).length === 0) {
660
+ merged.projectDir = projectDir
661
+ }
662
+ if (!Array.isArray(merged.scope)) {
663
+ merged.scope = []
664
+ }
665
+ if (!Array.isArray(merged.toolsExecuted)) {
666
+ merged.toolsExecuted = []
597
667
  }
598
668
  const validation = validateReportInput(merged)
599
669
  if (validation.success) {
600
670
  return finalizeReportInputSelection(validation.data, diagnostics, expectedRunId)
601
671
  }
672
+ for (const error of validation.errors) {
673
+ diagnostics.warn(
674
+ "REPORT_INPUT_DEDUPED_VALIDATION_FAILED",
675
+ `${error.field}: ${error.message}`,
676
+ error.field,
677
+ )
678
+ }
602
679
  }
603
680
  } catch {
604
681
  /* deduped file unreadable — fall through to report-input.json */
@@ -776,6 +853,13 @@ function sortFindingsDeterministically(findings: Finding[]): Finding[] {
776
853
  return [...findings].sort(compareFindingsDeterministically)
777
854
  }
778
855
 
856
+ function hasDedupLineage(findings: Finding[]): boolean {
857
+ return findings.some((finding) => {
858
+ const observationIds = (finding as { observation_ids?: unknown }).observation_ids
859
+ return Array.isArray(observationIds) && observationIds.length > 0
860
+ })
861
+ }
862
+
779
863
  export function validateReportQuality(
780
864
  findings: Finding[],
781
865
  policy: QualityGatePolicy,
@@ -1072,7 +1156,7 @@ export async function executeReportGeneration(
1072
1156
  deps: ReportGenerationDependencies = {},
1073
1157
  ): Promise<ReportGenerationResult> {
1074
1158
  const includeExecutiveSummary = args.include_executive_summary ?? true
1075
- const threshold = args.severity_threshold ?? "low"
1159
+ const threshold = args.severity_threshold ?? "informational"
1076
1160
  const qualityGatePolicy = args.quality_gate_policy ?? "warn"
1077
1161
  const toolCoveragePolicy = args.tool_coverage_policy ?? "enforce"
1078
1162
  const expectedRunId = resolveExpectedRunId(args, context, deps)
@@ -1148,7 +1232,24 @@ export async function executeReportGeneration(
1148
1232
 
1149
1233
  const eventFindings = dedupeFindingsForFinalOutput(projectFindings(events))
1150
1234
  const inputFindings = dedupeFindingsForFinalOutput(reportInput.findings)
1151
- const parity = compareIssueFingerprintSets(eventFindings, inputFindings)
1235
+ const hasLineage = hasDedupLineage(reportInput.findings)
1236
+ const shouldCheckParity = eventFindings.length === inputFindings.length || hasLineage
1237
+ const parity = shouldCheckParity
1238
+ ? compareIssueFingerprintSets(eventFindings, inputFindings)
1239
+ : { missing: [], extra: [], matches: true }
1240
+
1241
+ if (!shouldCheckParity) {
1242
+ const unverifiableSummary = `event_findings=${eventFindings.length}, report_findings=${inputFindings.length}`
1243
+ if (preflightPolicy === "strict-fail") {
1244
+ throw new Error(
1245
+ `Preflight failed (strict-fail): finding parity not verifiable (${unverifiableSummary}; missing observation_ids)`,
1246
+ )
1247
+ }
1248
+
1249
+ warningBullets.push(
1250
+ `- Finding parity not verifiable: ${unverifiableSummary}; deduped findings must include observation_ids to prove merged observations were preserved`,
1251
+ )
1252
+ }
1152
1253
 
1153
1254
  if (!parity.matches) {
1154
1255
  const mismatchSummary = `missing=${parity.missing.length}, extra=${parity.extra.length}`