solidity-argus 0.3.7 → 0.5.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/AGENTS.md +13 -6
  2. package/README.md +24 -12
  3. package/package.json +7 -3
  4. package/skills/checklists/cyfrin-best-practices-runtime/SKILL.md +1 -0
  5. package/skills/checklists/cyfrin-best-practices-upgrades/SKILL.md +1 -0
  6. package/skills/checklists/cyfrin-defi-core/SKILL.md +1 -0
  7. package/skills/checklists/cyfrin-defi-integrations/SKILL.md +1 -0
  8. package/skills/checklists/cyfrin-gas/SKILL.md +1 -0
  9. package/skills/checklists/general-audit/SKILL.md +1 -0
  10. package/skills/methodology/audit-workflow/SKILL.md +1 -0
  11. package/skills/methodology/report-template/SKILL.md +1 -0
  12. package/skills/methodology/severity-classification/SKILL.md +1 -0
  13. package/skills/protocol-patterns/amm-dex/SKILL.md +1 -0
  14. package/skills/protocol-patterns/bridges-cross-chain/SKILL.md +1 -0
  15. package/skills/protocol-patterns/dao-governance/SKILL.md +1 -0
  16. package/skills/protocol-patterns/lending-borrowing/SKILL.md +1 -0
  17. package/skills/protocol-patterns/staking-vesting/SKILL.md +1 -0
  18. package/skills/vulnerability-patterns/flash-loan-attacks/SKILL.md +0 -50
  19. package/skills/vulnerability-patterns/oracle-manipulation/SKILL.md +0 -63
  20. package/src/agents/argus-prompt.ts +98 -33
  21. package/src/agents/pythia-prompt.ts +18 -1
  22. package/src/agents/scribe-prompt.ts +32 -10
  23. package/src/agents/sentinel-prompt.ts +19 -0
  24. package/src/agents/themis-prompt.ts +110 -0
  25. package/src/cli/commands/doctor.ts +29 -17
  26. package/src/config/loader.ts +29 -5
  27. package/src/config/schema.ts +45 -45
  28. package/src/constants/defaults.ts +1 -0
  29. package/src/create-hooks.ts +797 -148
  30. package/src/create-managers.ts +4 -2
  31. package/src/create-tools.ts +5 -1
  32. package/src/features/audit-enforcer/audit-enforcer.ts +1 -11
  33. package/src/features/background-agent/background-manager.ts +32 -5
  34. package/src/features/error-recovery/tool-error-recovery.ts +1 -0
  35. package/src/features/persistent-state/audit-state-manager.ts +272 -29
  36. package/src/features/persistent-state/event-sink.ts +96 -25
  37. package/src/features/persistent-state/findings-materializer.ts +34 -2
  38. package/src/features/persistent-state/global-run-index.ts +86 -8
  39. package/src/features/persistent-state/index.ts +7 -1
  40. package/src/features/persistent-state/run-finalizer.ts +116 -7
  41. package/src/features/persistent-state/run-pruner.ts +93 -0
  42. package/src/hooks/agent-tracker.ts +14 -2
  43. package/src/hooks/compaction-hook.ts +7 -16
  44. package/src/hooks/config-handler.ts +83 -29
  45. package/src/hooks/context-budget.ts +4 -5
  46. package/src/hooks/event-hook.ts +213 -57
  47. package/src/hooks/knowledge-sync-hook.ts +2 -3
  48. package/src/hooks/safe-create-hook.ts +13 -1
  49. package/src/hooks/system-prompt-hook.ts +20 -39
  50. package/src/hooks/tool-tracking-hook.ts +597 -323
  51. package/src/index.ts +15 -1
  52. package/src/knowledge/scvd-client.ts +2 -4
  53. package/src/knowledge/scvd-errors.ts +25 -2
  54. package/src/knowledge/scvd-index.ts +7 -5
  55. package/src/knowledge/scvd-sync.ts +6 -6
  56. package/src/managers/types.ts +20 -2
  57. package/src/shared/agent-names.ts +23 -0
  58. package/src/shared/audit-artifact-resolver.ts +8 -3
  59. package/src/shared/audit-phases.ts +12 -0
  60. package/src/shared/cache-paths.ts +41 -0
  61. package/src/shared/drop-diagnostics.ts +2 -2
  62. package/src/shared/forge-errors.ts +31 -0
  63. package/src/shared/forge-runner.ts +30 -0
  64. package/src/shared/format-error.ts +3 -0
  65. package/src/shared/index.ts +9 -0
  66. package/src/shared/key-tools.ts +39 -0
  67. package/src/shared/logger.ts +7 -7
  68. package/src/shared/path-containment.ts +25 -0
  69. package/src/shared/path-utils.ts +11 -0
  70. package/src/shared/report-path-resolver.ts +4 -2
  71. package/src/shared/safe-emit.ts +24 -0
  72. package/src/shared/token-utils.ts +5 -0
  73. package/src/shared/type-guards.ts +8 -0
  74. package/src/shared/validation-constants.ts +52 -0
  75. package/src/skills/analysis/cluster.ts +1 -114
  76. package/src/skills/analysis/normalize.ts +2 -114
  77. package/src/skills/analysis/stopwords.ts +109 -0
  78. package/src/skills/argus-skill-resolver.ts +6 -3
  79. package/src/solodit-lifecycle.ts +153 -37
  80. package/src/state/adapters.ts +60 -66
  81. package/src/state/finding-aggregation.ts +6 -8
  82. package/src/state/finding-fingerprint.ts +1 -1
  83. package/src/state/finding-store.ts +31 -9
  84. package/src/state/index.ts +1 -1
  85. package/src/state/projectors.ts +27 -19
  86. package/src/state/schemas.ts +8 -32
  87. package/src/state/types.ts +3 -0
  88. package/src/tools/contract-analyzer-tool.ts +4 -6
  89. package/src/tools/forge-coverage-tool.ts +10 -35
  90. package/src/tools/forge-fuzz-tool.ts +21 -51
  91. package/src/tools/forge-test-tool.ts +25 -47
  92. package/src/tools/gas-analysis-tool.ts +12 -41
  93. package/src/tools/pattern-checker-tool.ts +37 -15
  94. package/src/tools/pattern-loader.ts +18 -4
  95. package/src/tools/persist-deduped-tool.ts +94 -0
  96. package/src/tools/proxy-detection-tool.ts +35 -34
  97. package/src/tools/read-findings-tool.ts +390 -0
  98. package/src/tools/record-finding-tool.ts +120 -25
  99. package/src/tools/report-generator-tool.ts +394 -328
  100. package/src/tools/report-preflight.ts +5 -1
  101. package/src/tools/slither-tool.ts +55 -16
  102. package/src/tools/solodit-search-tool.ts +260 -112
  103. package/src/tools/sync-knowledge-tool.ts +2 -3
  104. package/src/utils/solidity-parser.ts +39 -24
  105. package/src/features/migration/index.ts +0 -14
  106. package/src/features/migration/migration-adapter.ts +0 -151
  107. package/src/features/migration/parity-telemetry.ts +0 -133
@@ -1,15 +1,19 @@
1
- import { existsSync } from "node:fs"
1
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"
2
2
  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
6
  import { readEvents } from "../features/persistent-state/event-sink"
7
- import type { DropDiagnostic, DropPolicy } from "../shared/drop-diagnostics"
7
+ import { resolveRunIdFromOpencodeSession } from "../features/persistent-state/global-run-index"
8
+ import { createAuditArtifactResolver } from "../shared/audit-artifact-resolver"
9
+ import type { DropDiagnostic } from "../shared/drop-diagnostics"
8
10
  import { createDropDiagnosticsCollector } from "../shared/drop-diagnostics"
11
+ import { computeMissingKeyTools } from "../shared/key-tools"
9
12
  import { createLogger } from "../shared/logger"
10
13
  import { resolveProjectDir } from "../shared/project-utils"
11
14
  import { resolveReportPath } from "../shared/report-path-resolver"
12
- import { normalizeToCanonicalFinding } from "../state/adapters"
15
+ import { isNonEmptyString } from "../shared/type-guards"
16
+ import { SEVERITY_RANK } from "../shared/validation-constants"
13
17
  import {
14
18
  compareIssueFingerprintSets,
15
19
  dedupeFindingsForFinalOutput,
@@ -21,6 +25,8 @@ import { checkReportPreflight } from "./report-preflight"
21
25
 
22
26
  type SeverityThreshold = "critical" | "high" | "medium" | "low" | "informational"
23
27
 
28
+ type ToolCoveragePolicy = "enforce" | "warn" | "skip"
29
+
24
30
  type ReportGeneratorArgs = {
25
31
  project_name: string
26
32
  scope: string[]
@@ -28,8 +34,9 @@ type ReportGeneratorArgs = {
28
34
  severity_threshold?: SeverityThreshold
29
35
  quality_gate_policy?: QualityGatePolicy
30
36
  report_input?: string
31
- audit_state?: string
32
37
  preflight_policy?: PreflightPolicy
38
+ tool_coverage_policy?: ToolCoveragePolicy
39
+ run_id?: string
33
40
  }
34
41
 
35
42
  type FindingsCount = {
@@ -73,6 +80,7 @@ export type ReportGenerationDependencies = {
73
80
  runId: string,
74
81
  projectDir: string,
75
82
  ) => Promise<import("../state/schemas").AuditEvent[]>
83
+ resolveCanonicalRunId?: (sessionId: string, projectDir: string) => string | null | undefined
76
84
  }
77
85
 
78
86
  export const SINGLE_WRITER_POLICY_VERSION = "1.0.0"
@@ -148,13 +156,8 @@ const FINDING_WEIGHT: Record<FindingSeverity, number> = {
148
156
  Informational: 1,
149
157
  }
150
158
 
151
- const SEVERITY_RANK: Record<FindingSeverity, number> = {
152
- Critical: 0,
153
- High: 1,
154
- Medium: 2,
155
- Low: 3,
156
- Informational: 4,
157
- }
159
+ /** Sentinel for missing/unknown tool execution timestamps (schema requires startTime > 0). */
160
+ const UNKNOWN_TIMESTAMP_SENTINEL = 1
158
161
 
159
162
  const MISSING_IMPACT_TEXT = "Impact details were not provided in the finding payload."
160
163
  const MISSING_RECOMMENDATION_TEXT =
@@ -176,19 +179,6 @@ function emptyCounts(): FindingsCount {
176
179
  }
177
180
  }
178
181
 
179
- function emptyAuditState(findings: Finding[] = []): AuditState {
180
- return {
181
- sessionId: "",
182
- projectDir: "",
183
- contractsReviewed: [],
184
- findings,
185
- toolsExecuted: [],
186
- currentPhase: "complete",
187
- scope: [],
188
- startTime: 0,
189
- }
190
- }
191
-
192
182
  /**
193
183
  * Parse a location string like "File.sol:18-22" or "File.sol:18" into { file, lines }.
194
184
  * Returns undefined if the string doesn't match a recognized format.
@@ -237,11 +227,15 @@ export function normalizeRawFinding(raw: Record<string, unknown>): Record<string
237
227
  }
238
228
  }
239
229
 
240
- // file + lines: accept location string as alias
241
- if (typeof result.file !== "string" && typeof result.location === "string") {
230
+ // file + lines: accept location string as alias.
231
+ // Always attempt to extract lines from location, even when file is already set.
232
+ // LLMs commonly provide both file and location (e.g. file="src/Vault.sol", location="Vault.sol:18-23").
233
+ if (typeof result.location === "string") {
242
234
  const parsed = parseLocationString(result.location as string)
243
235
  if (parsed) {
244
- result.file = parsed.file
236
+ if (typeof result.file !== "string" || (result.file as string).length === 0) {
237
+ result.file = parsed.file
238
+ }
245
239
  if (!Array.isArray(result.lines) || (result.lines as unknown[]).length !== 2) {
246
240
  result.lines = parsed.lines
247
241
  }
@@ -298,79 +292,11 @@ export function normalizeRawFinding(raw: Record<string, unknown>): Record<string
298
292
  result.description = result.check
299
293
  }
300
294
 
301
- return result
302
- }
303
-
304
- function hasMinimumFindingFields(
305
- f: unknown,
306
- ): f is { check: string; file: string; lines: [number, number] } {
307
- if (typeof f !== "object" || f === null) return false
308
- const obj = f as Record<string, unknown>
309
- return (
310
- typeof obj.check === "string" &&
311
- obj.check.length > 0 &&
312
- typeof obj.file === "string" &&
313
- Array.isArray(obj.lines) &&
314
- obj.lines.length === 2
315
- )
316
- }
317
-
318
- const VALID_SEVERITIES: ReadonlySet<string> = new Set([
319
- "Critical",
320
- "High",
321
- "Medium",
322
- "Low",
323
- "Informational",
324
- ])
325
- const VALID_SOURCES: ReadonlySet<string> = new Set([
326
- "slither",
327
- "manual",
328
- "pattern",
329
- "scvd",
330
- "solodit",
331
- "fuzz",
332
- ])
333
-
334
- function normalizeFinding(f: Record<string, unknown>): Finding {
335
- const severity =
336
- typeof f.severity === "string" && VALID_SEVERITIES.has(f.severity)
337
- ? (f.severity as Finding["severity"])
338
- : "Informational"
339
- const confidence =
340
- typeof f.confidence === "string" && ["High", "Medium", "Low"].includes(f.confidence)
341
- ? (f.confidence as Finding["confidence"])
342
- : "Low"
343
- const source =
344
- typeof f.source === "string" && VALID_SOURCES.has(f.source)
345
- ? (f.source as Finding["source"])
346
- : "manual"
347
- const description = typeof f.description === "string" ? f.description : (f.check as string)
348
- const id = typeof f.id === "string" ? f.id : `${f.check}:${f.file}:${(f.lines as number[])[0]}`
349
- return {
350
- id,
351
- check: f.check as string,
352
- severity,
353
- confidence,
354
- description,
355
- file: f.file as string,
356
- lines: f.lines as [number, number],
357
- source,
358
- remediation: typeof f.remediation === "string" ? f.remediation : undefined,
359
- exploitReference: typeof f.exploitReference === "string" ? f.exploitReference : undefined,
360
- ...(typeof f.impact === "string" ? { impact: f.impact } : {}),
361
- ...(typeof f.recommendation === "string" ? { recommendation: f.recommendation } : {}),
362
- ...(typeof f.proofOfConcept === "string" ? { proofOfConcept: f.proofOfConcept } : {}),
363
- ...(typeof f.proof_of_concept === "string" ? { proofOfConcept: f.proof_of_concept } : {}),
364
- } as Finding
365
- }
366
-
367
- export type ParseAuditStateOptions = {
368
- dropPolicy?: DropPolicy
369
- }
295
+ if (!Array.isArray(result.lines) || (result.lines as unknown[]).length !== 2) {
296
+ result.lines = [0, 0]
297
+ }
370
298
 
371
- export type ParseAuditStateResult = {
372
- state: AuditState
373
- diagnostics: DropDiagnostic[]
299
+ return result
374
300
  }
375
301
 
376
302
  type ParseReportInputResult = {
@@ -407,80 +333,193 @@ function reportInputToAuditState(reportInput: ReportInput): AuditState {
407
333
  proxyContracts: reportInput.proxyContracts,
408
334
  patternVersion: reportInput.patternVersion,
409
335
  skillsLoaded: reportInput.skillsLoaded,
336
+ unavailableTools: reportInput.unavailableTools,
410
337
  }
411
338
  }
412
339
 
413
- function buildLegacyCompatibleReportInput(
414
- state: AuditState,
415
- context: ToolContext,
340
+ function normalizeToolsExecutedDefaults(
341
+ parsed: unknown,
342
+ expectedRunId: string | undefined,
416
343
  diagnostics: ReturnType<typeof createDropDiagnosticsCollector>,
417
- ): ReportInput {
418
- diagnostics.warn(
419
- "REPORT_INPUT_DEPRECATED_LEGACY_PAYLOAD",
420
- "Legacy audit_state payload is deprecated; pass report_input with canonical ReportInput schema.",
421
- "audit_state",
422
- )
423
-
424
- const runId = state.sessionId || context.sessionID || "legacy-run"
425
- const sessionId = state.sessionId || context.sessionID || runId
344
+ ): void {
345
+ if (!parsed || typeof parsed !== "object") return
346
+ const obj = parsed as Record<string, unknown>
347
+ if (!Array.isArray(obj.toolsExecuted)) return
348
+
349
+ const runId = (typeof obj.run_id === "string" && obj.run_id) || expectedRunId || "unknown"
350
+ let patched = false
351
+
352
+ for (const entry of obj.toolsExecuted) {
353
+ if (!entry || typeof entry !== "object") continue
354
+ const rec = entry as Record<string, unknown>
355
+ if (typeof rec.startTime !== "number" || rec.startTime <= 0) {
356
+ rec.startTime = UNKNOWN_TIMESTAMP_SENTINEL
357
+ patched = true
358
+ }
359
+ if (typeof rec.success !== "boolean") {
360
+ rec.success = true
361
+ patched = true
362
+ }
363
+ if (typeof rec.findingsCount !== "number" || rec.findingsCount < 0) {
364
+ rec.findingsCount = 0
365
+ patched = true
366
+ }
367
+ if (!isNonEmptyString(rec.run_id)) {
368
+ rec.run_id = runId
369
+ patched = true
370
+ }
371
+ if (!isNonEmptyString(rec.schema_version)) {
372
+ rec.schema_version = SCHEMA_VERSION
373
+ patched = true
374
+ }
375
+ }
426
376
 
427
- if (!state.sessionId) {
377
+ if (patched) {
428
378
  diagnostics.warn(
429
- "REPORT_INPUT_SYNTHESIZED_SESSION",
430
- "Legacy payload missing sessionId; synthesized session_id from tool context/run_id.",
431
- "session_id",
379
+ "REPORT_INPUT_TOOLS_EXECUTED_NORMALIZED",
380
+ "toolsExecuted entries were missing canonical fields (startTime, success, findingsCount, run_id, schema_version); defaults applied.",
381
+ "toolsExecuted",
432
382
  )
433
383
  }
434
- if (!state.projectDir) {
435
- diagnostics.warn(
436
- "REPORT_INPUT_SYNTHESIZED_PROJECT_DIR",
437
- "Legacy payload missing projectDir; synthesized projectDir from tool context.",
438
- "projectDir",
439
- )
384
+ }
385
+
386
+ function resolveExpectedRunId(
387
+ args: ReportGeneratorArgs,
388
+ context: ToolContext,
389
+ deps: ReportGenerationDependencies,
390
+ ): string | undefined {
391
+ // 1. Explicit run_id from LLM args (highest priority)
392
+ if (isNonEmptyString(args.run_id)) {
393
+ return args.run_id.trim()
394
+ }
395
+
396
+ // 2. Global run index lookup by session ID
397
+ const sessionId = context.sessionID
398
+ const projectDir = resolveProjectDir(context)
399
+ if (isNonEmptyString(sessionId)) {
400
+ const resolveCanonicalRunId = deps.resolveCanonicalRunId ?? resolveRunIdFromOpencodeSession
401
+ const resolved = resolveCanonicalRunId(sessionId, projectDir)
402
+ if (isNonEmptyString(resolved)) {
403
+ return resolved
404
+ }
440
405
  }
441
406
 
442
- const canonicalFindings = state.findings
443
- .map((finding, index) => {
444
- const normalized = normalizeToCanonicalFinding(finding, runId, index + 1)
445
- for (const diag of normalized.diagnostics) {
446
- diagnostics.warn(
447
- "REPORT_INPUT_LEGACY_FINDING_NORMALIZED",
448
- `[index:${index}] ${diag.message}`,
449
- diag.field,
450
- )
407
+ // When caller provides inline report_input, skip filesystem discovery —
408
+ // the caller already has their data and filesystem state may belong to a different run.
409
+ if (isNonEmptyString(args.report_input)) {
410
+ return undefined
411
+ }
412
+
413
+ // 3. Per-session state files (per-session managers write to sessions/state-{sessionId}.json)
414
+ const STALE_STATE_TTL_MS = 24 * 60 * 60 * 1000
415
+ const sessionsDir = path.join(projectDir, ".argus", "sessions")
416
+ try {
417
+ const entries = readdirSync(sessionsDir)
418
+ const stateFiles = entries.filter((e) => e.startsWith("state-") && e.endsWith(".json"))
419
+ const ranked = stateFiles
420
+ .map((name) => {
421
+ const filePath = path.join(sessionsDir, name)
422
+ try {
423
+ return { name, path: filePath, mtime: statSync(filePath).mtimeMs }
424
+ } catch {
425
+ return null
426
+ }
427
+ })
428
+ .filter((entry): entry is NonNullable<typeof entry> => entry !== null)
429
+ .sort((a, b) => b.mtime - a.mtime)
430
+
431
+ for (const entry of ranked) {
432
+ try {
433
+ const stateRaw = JSON.parse(readFileSync(entry.path, "utf-8")) as Record<string, unknown>
434
+ const stateSessionId = stateRaw.sessionId
435
+ const savedAt = typeof stateRaw.savedAt === "number" ? stateRaw.savedAt : 0
436
+ const isFresh = Date.now() - savedAt < STALE_STATE_TTL_MS
437
+ if (
438
+ typeof stateSessionId === "string" &&
439
+ stateSessionId.trim().length > 0 &&
440
+ !stateSessionId.startsWith("ses_") &&
441
+ isFresh
442
+ ) {
443
+ const resolver = createAuditArtifactResolver(stateSessionId, projectDir)
444
+ const hasArtifacts =
445
+ existsSync(resolver.paths().reportInputFile) || existsSync(resolver.paths().journalFile)
446
+ if (hasArtifacts) {
447
+ return stateSessionId
448
+ }
449
+ }
450
+ } catch {
451
+ /* skip unreadable session file */
451
452
  }
452
- return normalized.data
453
- })
454
- .filter((finding) => finding.check.length > 0 && finding.file.length > 0)
453
+ }
454
+ } catch {
455
+ /* sessions dir doesn't exist */
456
+ }
455
457
 
456
- return {
457
- run_id: runId,
458
- seq: state.toolsExecuted.length + canonicalFindings.length,
459
- session_id: sessionId,
460
- tool_call_id: "legacy-adapter",
461
- source: "report-generator-legacy-adapter",
462
- schema_version: SCHEMA_VERSION,
463
- projectDir: state.projectDir || resolveProjectDir(context),
464
- findings: canonicalFindings,
465
- toolsExecuted: state.toolsExecuted.map((toolExec) => ({
466
- ...toolExec,
467
- run_id: runId,
468
- schema_version: SCHEMA_VERSION,
469
- })),
470
- scope: state.scope,
471
- soloditResults: state.soloditResults,
472
- fuzzCounterexamples: state.fuzzCounterexamples,
473
- coverageReport: state.coverageReport,
474
- gasHotspots: state.gasHotspots,
475
- proxyContracts: state.proxyContracts,
476
- patternVersion: state.patternVersion,
477
- skillsLoaded: state.skillsLoaded,
458
+ // 4. Shared audit state (legacy fallback)
459
+ try {
460
+ const sharedStatePath = path.join(projectDir, ".argus", "argus-state.json")
461
+ if (existsSync(sharedStatePath)) {
462
+ const stateRaw = JSON.parse(readFileSync(sharedStatePath, "utf-8")) as Record<string, unknown>
463
+ const stateSessionId = stateRaw.sessionId
464
+ const savedAt = typeof stateRaw.savedAt === "number" ? stateRaw.savedAt : 0
465
+ const isFresh = Date.now() - savedAt < STALE_STATE_TTL_MS
466
+ if (
467
+ typeof stateSessionId === "string" &&
468
+ stateSessionId.trim().length > 0 &&
469
+ !stateSessionId.startsWith("ses_") &&
470
+ isFresh
471
+ ) {
472
+ const resolver = createAuditArtifactResolver(stateSessionId, projectDir)
473
+ const hasArtifacts =
474
+ existsSync(resolver.paths().reportInputFile) || existsSync(resolver.paths().journalFile)
475
+ if (hasArtifacts) {
476
+ return stateSessionId
477
+ }
478
+ }
479
+ }
480
+ } catch {
481
+ /* fallback path */
482
+ }
483
+
484
+ return undefined
485
+ }
486
+
487
+ function finalizeReportInputSelection(
488
+ reportInput: ReportInput,
489
+ diagnostics: ReturnType<typeof createDropDiagnosticsCollector>,
490
+ expectedRunId?: string,
491
+ ): ParseReportInputResult {
492
+ if (reportInput.run_id.startsWith("ses_")) {
493
+ diagnostics.error(
494
+ "REPORT_INPUT_RUN_ID_MISMATCH",
495
+ "ReportInput run_id must be a canonical run identifier, not an OpenCode session id (ses_*).",
496
+ "run_id",
497
+ )
498
+ throwContractMismatch(
499
+ "ReportInput contract mismatch: run_id/session_id conflation detected",
500
+ diagnostics.getDiagnostics(),
501
+ )
478
502
  }
503
+
504
+ if (expectedRunId && reportInput.run_id !== expectedRunId) {
505
+ diagnostics.error(
506
+ "REPORT_INPUT_CANONICAL_RUN_MISMATCH",
507
+ `ReportInput run_id ${reportInput.run_id} does not match canonical run_id ${expectedRunId}.`,
508
+ "run_id",
509
+ )
510
+ throwContractMismatch(
511
+ "ReportInput contract mismatch: report_input run_id diverges from canonical run_id",
512
+ diagnostics.getDiagnostics(),
513
+ )
514
+ }
515
+
516
+ return { reportInput, diagnostics: diagnostics.getDiagnostics() }
479
517
  }
480
518
 
481
519
  function parseReportInputPayload(
482
520
  args: ReportGeneratorArgs,
483
521
  context: ToolContext,
522
+ expectedRunId: string | undefined,
484
523
  ): ParseReportInputResult {
485
524
  const diagnostics = createDropDiagnosticsCollector(
486
525
  "warn",
@@ -488,7 +527,7 @@ function parseReportInputPayload(
488
527
  "argus_generate_report",
489
528
  )
490
529
 
491
- if (typeof args.report_input === "string" && args.report_input.trim().length > 0) {
530
+ if (isNonEmptyString(args.report_input)) {
492
531
  let parsed: unknown
493
532
  try {
494
533
  parsed = JSON.parse(args.report_input)
@@ -504,44 +543,109 @@ function parseReportInputPayload(
504
543
  )
505
544
  }
506
545
 
546
+ normalizeToolsExecutedDefaults(parsed, expectedRunId, diagnostics)
547
+
507
548
  const validation = validateReportInput(parsed)
508
549
  if (!validation.success) {
509
550
  for (const error of validation.errors) {
510
- diagnostics.error(
511
- "REPORT_INPUT_CONTRACT_MISMATCH",
551
+ diagnostics.warn(
552
+ "REPORT_INPUT_INLINE_VALIDATION_FAILED",
512
553
  `${error.field}: ${error.message}`,
513
554
  error.field,
514
555
  )
515
556
  }
516
- throwContractMismatch(
517
- "ReportInput contract mismatch: report_input failed schema validation",
518
- diagnostics.getDiagnostics(),
519
- )
520
- }
521
-
522
- if (typeof args.audit_state === "string" && args.audit_state.trim().length > 0) {
523
557
  diagnostics.warn(
524
- "REPORT_INPUT_LEGACY_FIELD_IGNORED",
525
- "Both report_input and audit_state were provided; audit_state is ignored.",
526
- "audit_state",
558
+ "REPORT_INPUT_INLINE_FALLTHROUGH",
559
+ `Inline report_input failed validation (${validation.errors.length} errors). Falling back to disk artifact.`,
560
+ "report_input",
527
561
  )
562
+ } else {
563
+ return finalizeReportInputSelection(validation.data, diagnostics, expectedRunId)
528
564
  }
529
-
530
- return { reportInput: validation.data, diagnostics: diagnostics.getDiagnostics() }
531
565
  }
532
566
 
533
- if (typeof args.audit_state === "string" && args.audit_state.trim().length > 0) {
534
- const legacy = parseAuditStateWithDiagnostics(args.audit_state, { dropPolicy: "warn" })
535
- for (const diagnostic of legacy.diagnostics) {
536
- diagnostics.warn(diagnostic.reason.code, diagnostic.reason.message, diagnostic.reason.field)
567
+ const effectiveRunId =
568
+ (isNonEmptyString(args.run_id) ? args.run_id.trim() : undefined) ?? expectedRunId
569
+
570
+ if (isNonEmptyString(effectiveRunId)) {
571
+ const projectDir = resolveProjectDir(context)
572
+ const resolver = createAuditArtifactResolver(effectiveRunId, projectDir)
573
+
574
+ const dedupedFile = resolver.paths().dedupedFindingsFile
575
+ if (existsSync(dedupedFile)) {
576
+ try {
577
+ const dedupedArtifact = JSON.parse(readFileSync(dedupedFile, "utf-8")) as {
578
+ findings?: unknown[]
579
+ }
580
+ if (Array.isArray(dedupedArtifact.findings) && dedupedArtifact.findings.length > 0) {
581
+ const reportInputFile = resolver.paths().reportInputFile
582
+ let baseInput: Record<string, unknown> = {}
583
+ if (existsSync(reportInputFile)) {
584
+ try {
585
+ baseInput = JSON.parse(readFileSync(reportInputFile, "utf-8")) as Record<
586
+ string,
587
+ unknown
588
+ >
589
+ } catch {
590
+ /* use empty base */
591
+ }
592
+ }
593
+ const merged = {
594
+ ...baseInput,
595
+ run_id: effectiveRunId,
596
+ findings: dedupedArtifact.findings,
597
+ }
598
+ const validation = validateReportInput(merged)
599
+ if (validation.success) {
600
+ return finalizeReportInputSelection(validation.data, diagnostics, expectedRunId)
601
+ }
602
+ }
603
+ } catch {
604
+ /* deduped file unreadable — fall through to report-input.json */
605
+ }
537
606
  }
538
- const reportInput = buildLegacyCompatibleReportInput(legacy.state, context, diagnostics)
539
- return { reportInput, diagnostics: diagnostics.getDiagnostics() }
540
- }
541
607
 
608
+ const reportInputFile = resolver.paths().reportInputFile
609
+ if (existsSync(reportInputFile)) {
610
+ diagnostics.warn(
611
+ "REPORT_INPUT_DISK_FALLBACK",
612
+ `No report_input provided; reading materialized report-input.json from disk for run ${effectiveRunId}.`,
613
+ "report_input",
614
+ )
615
+ let parsed: unknown
616
+ try {
617
+ parsed = JSON.parse(readFileSync(reportInputFile, "utf-8"))
618
+ } catch {
619
+ diagnostics.error(
620
+ "REPORT_INPUT_DISK_CORRUPT",
621
+ `Materialized report-input.json for run ${effectiveRunId} is not valid JSON.`,
622
+ "report_input",
623
+ )
624
+ throwContractMismatch(
625
+ "ReportInput contract mismatch: corrupted disk artifact",
626
+ diagnostics.getDiagnostics(),
627
+ )
628
+ }
629
+ const validation = validateReportInput(parsed)
630
+ if (!validation.success) {
631
+ for (const error of validation.errors) {
632
+ diagnostics.error(
633
+ "REPORT_INPUT_DISK_VALIDATION_FAILED",
634
+ `${error.field}: ${error.message}`,
635
+ error.field,
636
+ )
637
+ }
638
+ throwContractMismatch(
639
+ "ReportInput contract mismatch: disk artifact failed schema validation",
640
+ diagnostics.getDiagnostics(),
641
+ )
642
+ }
643
+ return finalizeReportInputSelection(validation.data, diagnostics, expectedRunId)
644
+ }
645
+ }
542
646
  diagnostics.error(
543
647
  "REPORT_INPUT_MISSING",
544
- "Missing report_input payload. Provide report_input (preferred) or legacy audit_state for transition.",
648
+ `Missing report_input payload. args.run_id=${args.run_id ?? "undefined"}, expectedRunId=${expectedRunId ?? "undefined"}. Provide report_input (preferred) or run_id for disk fallback.`,
545
649
  "report_input",
546
650
  )
547
651
  throwContractMismatch(
@@ -550,135 +654,6 @@ function parseReportInputPayload(
550
654
  )
551
655
  }
552
656
 
553
- function emitDropDiagnosticsForFindings(
554
- rawItems: unknown[],
555
- normalized: Record<string, unknown>[],
556
- validFindings: Finding[],
557
- diag: ReturnType<typeof createDropDiagnosticsCollector>,
558
- ): void {
559
- const droppedCount = rawItems.length - validFindings.length
560
- if (droppedCount <= 0) return
561
-
562
- for (const item of normalized) {
563
- if (hasMinimumFindingFields(item)) continue
564
- const missing: string[] = []
565
- if (typeof item.check !== "string" || (item.check as string).length === 0) missing.push("check")
566
- if (typeof item.file !== "string") missing.push("file")
567
- if (!Array.isArray(item.lines) || (item.lines as unknown[]).length !== 2) missing.push("lines")
568
- diag.error(
569
- "MISSING_REQUIRED_FIELD",
570
- `Finding dropped: missing ${missing.join(", ") || "unknown fields"} after normalization`,
571
- missing[0],
572
- )
573
- }
574
- }
575
-
576
- export function parseAuditState(auditState: string, options?: ParseAuditStateOptions): AuditState {
577
- const policy = options?.dropPolicy ?? "warn"
578
- const diag = createDropDiagnosticsCollector(policy, "report-generator")
579
-
580
- let parsed: unknown
581
- try {
582
- parsed = JSON.parse(auditState)
583
- } catch {
584
- diag.error("MALFORMED_JSON", "audit_state is not valid JSON")
585
- diag.throwIfStrict()
586
- throw new Error(
587
- "audit_state is not valid JSON — expected an AuditState object or Finding[] array",
588
- )
589
- }
590
-
591
- if (Array.isArray(parsed)) {
592
- const rawItems = parsed as unknown[]
593
- const normalized = rawItems
594
- .filter((item): item is Record<string, unknown> => typeof item === "object" && item !== null)
595
- .map((item) => normalizeRawFinding(item))
596
- const validFindings = normalized
597
- .filter(hasMinimumFindingFields)
598
- .map((f) => normalizeFinding(f as Record<string, unknown>))
599
- emitDropDiagnosticsForFindings(rawItems, normalized, validFindings, diag)
600
- diag.throwIfStrict()
601
- return emptyAuditState(validFindings)
602
- }
603
-
604
- if (
605
- typeof parsed === "object" &&
606
- parsed !== null &&
607
- Array.isArray((parsed as AuditState).findings)
608
- ) {
609
- const state = parsed as AuditState
610
- const rawFindings = state.findings as unknown[]
611
- const normalized = rawFindings
612
- .filter((item): item is Record<string, unknown> => typeof item === "object" && item !== null)
613
- .map((item) => normalizeRawFinding(item))
614
- const validFindings = normalized
615
- .filter(hasMinimumFindingFields)
616
- .map((f) => normalizeFinding(f as Record<string, unknown>))
617
- emitDropDiagnosticsForFindings(rawFindings, normalized, validFindings, diag)
618
- diag.throwIfStrict()
619
- return {
620
- ...emptyAuditState(),
621
- ...state,
622
- findings: validFindings,
623
- }
624
- }
625
-
626
- return emptyAuditState()
627
- }
628
-
629
- export function parseAuditStateWithDiagnostics(
630
- auditState: string,
631
- options?: ParseAuditStateOptions,
632
- ): ParseAuditStateResult {
633
- const policy = options?.dropPolicy ?? "warn"
634
- const diag = createDropDiagnosticsCollector(policy, "report-generator")
635
-
636
- let parsed: unknown
637
- try {
638
- parsed = JSON.parse(auditState)
639
- } catch {
640
- diag.error("MALFORMED_JSON", "audit_state is not valid JSON")
641
- diag.throwIfStrict()
642
- return { state: emptyAuditState(), diagnostics: diag.getDiagnostics() }
643
- }
644
-
645
- if (Array.isArray(parsed)) {
646
- const rawItems = parsed as unknown[]
647
- const normalized = rawItems
648
- .filter((item): item is Record<string, unknown> => typeof item === "object" && item !== null)
649
- .map((item) => normalizeRawFinding(item))
650
- const validFindings = normalized
651
- .filter(hasMinimumFindingFields)
652
- .map((f) => normalizeFinding(f as Record<string, unknown>))
653
- emitDropDiagnosticsForFindings(rawItems, normalized, validFindings, diag)
654
- diag.throwIfStrict()
655
- return { state: emptyAuditState(validFindings), diagnostics: diag.getDiagnostics() }
656
- }
657
-
658
- if (
659
- typeof parsed === "object" &&
660
- parsed !== null &&
661
- Array.isArray((parsed as AuditState).findings)
662
- ) {
663
- const auditStateObj = parsed as AuditState
664
- const rawFindings = auditStateObj.findings as unknown[]
665
- const normalized = rawFindings
666
- .filter((item): item is Record<string, unknown> => typeof item === "object" && item !== null)
667
- .map((item) => normalizeRawFinding(item))
668
- const validFindings = normalized
669
- .filter(hasMinimumFindingFields)
670
- .map((f) => normalizeFinding(f as Record<string, unknown>))
671
- emitDropDiagnosticsForFindings(rawFindings, normalized, validFindings, diag)
672
- diag.throwIfStrict()
673
- return {
674
- state: { ...emptyAuditState(), ...auditStateObj, findings: validFindings },
675
- diagnostics: diag.getDiagnostics(),
676
- }
677
- }
678
-
679
- return { state: emptyAuditState(), diagnostics: diag.getDiagnostics() }
680
- }
681
-
682
657
  function normalizeTitle(check: string): string {
683
658
  if (!check || typeof check !== "string") return "Unknown Check"
684
659
  return check
@@ -756,7 +731,7 @@ function getExtendedFinding(finding: Finding): Finding & ReportFindingFields {
756
731
 
757
732
  function getFindingImpact(finding: Finding): string {
758
733
  const extended = getExtendedFinding(finding)
759
- if (typeof extended.impact === "string" && extended.impact.trim().length > 0) {
734
+ if (isNonEmptyString(extended.impact)) {
760
735
  return extended.impact.trim()
761
736
  }
762
737
  return MISSING_IMPACT_TEXT
@@ -764,10 +739,10 @@ function getFindingImpact(finding: Finding): string {
764
739
 
765
740
  function getFindingRecommendation(finding: Finding): string {
766
741
  const extended = getExtendedFinding(finding)
767
- if (typeof extended.recommendation === "string" && extended.recommendation.trim().length > 0) {
742
+ if (isNonEmptyString(extended.recommendation)) {
768
743
  return extended.recommendation.trim()
769
744
  }
770
- if (typeof finding.remediation === "string" && finding.remediation.trim().length > 0) {
745
+ if (isNonEmptyString(finding.remediation)) {
771
746
  return finding.remediation.trim()
772
747
  }
773
748
  return MISSING_RECOMMENDATION_TEXT
@@ -775,10 +750,10 @@ function getFindingRecommendation(finding: Finding): string {
775
750
 
776
751
  function getPocEvidence(finding: Finding): string | undefined {
777
752
  const extended = getExtendedFinding(finding)
778
- if (typeof extended.proofOfConcept === "string" && extended.proofOfConcept.trim().length > 0) {
753
+ if (isNonEmptyString(extended.proofOfConcept)) {
779
754
  return extended.proofOfConcept.trim()
780
755
  }
781
- if (typeof finding.exploitReference === "string" && finding.exploitReference.trim().length > 0) {
756
+ if (isNonEmptyString(finding.exploitReference)) {
782
757
  return finding.exploitReference.trim()
783
758
  }
784
759
  return undefined
@@ -973,17 +948,17 @@ function formatDuration(ms: number): string {
973
948
  export function buildProvenanceAppendix(
974
949
  state: AuditState,
975
950
  threshold: SeverityThreshold,
976
- includedCount: number,
951
+ reportFindings: Finding[],
977
952
  ): string {
978
953
  const lines: string[] = ["## Appendix: Data Provenance"]
979
954
 
980
- lines.push("- Data source: `report_input` payload (legacy `audit_state` supported via adapter)")
955
+ lines.push("- Data source: `report_input` payload")
981
956
  lines.push(`- Severity threshold applied: ${threshold}`)
982
- lines.push(`- Findings included in report: ${includedCount}`)
957
+ lines.push(`- Findings included in report: ${reportFindings.length}`)
983
958
 
984
- if (state.findings.length > 0) {
959
+ if (reportFindings.length > 0) {
985
960
  const sourceCounts: Record<string, number> = {}
986
- for (const f of state.findings) {
961
+ for (const f of reportFindings) {
987
962
  sourceCounts[f.source] = (sourceCounts[f.source] ?? 0) + 1
988
963
  }
989
964
  lines.push("")
@@ -1099,10 +1074,51 @@ export async function executeReportGeneration(
1099
1074
  const includeExecutiveSummary = args.include_executive_summary ?? true
1100
1075
  const threshold = args.severity_threshold ?? "low"
1101
1076
  const qualityGatePolicy = args.quality_gate_policy ?? "warn"
1102
- const { reportInput, diagnostics } = parseReportInputPayload(args, context)
1077
+ const toolCoveragePolicy = args.tool_coverage_policy ?? "enforce"
1078
+ const expectedRunId = resolveExpectedRunId(args, context, deps)
1079
+
1080
+ // Ensure report-input.json is materialized before attempting disk lookup.
1081
+ // Scribe may call generate_report without calling read_findings first,
1082
+ // or read_findings may have materialized under a different run_id.
1083
+ if (typeof expectedRunId === "string" && expectedRunId.length > 0) {
1084
+ const projectDir = resolveProjectDir(context)
1085
+ const resolver = createAuditArtifactResolver(expectedRunId, projectDir)
1086
+ if (!existsSync(resolver.paths().reportInputFile)) {
1087
+ try {
1088
+ const { materializeReportInput } = await import(
1089
+ "../features/persistent-state/findings-materializer"
1090
+ )
1091
+ await materializeReportInput(expectedRunId, projectDir, context.sessionID)
1092
+ } catch {
1093
+ /* Best-effort: parseReportInputPayload will produce a clear error if the file is still missing */
1094
+ }
1095
+ }
1096
+ }
1097
+
1098
+ const { reportInput, diagnostics } = parseReportInputPayload(args, context, expectedRunId)
1099
+
1103
1100
  const preflightPolicy = args.preflight_policy ?? "warn"
1104
1101
  let preflightWarningSection: string | null = null
1105
1102
  const warningBullets: string[] = []
1103
+
1104
+ // Hard gate: refuse to generate a report if key audit tools have not been executed
1105
+ if (toolCoveragePolicy !== "skip") {
1106
+ const missingTools = computeMissingKeyTools(
1107
+ reportInput.toolsExecuted,
1108
+ reportInput.unavailableTools,
1109
+ )
1110
+ if (missingTools.length > 0) {
1111
+ const toolList = missingTools.join(", ")
1112
+ if (toolCoveragePolicy === "enforce") {
1113
+ throw new Error(
1114
+ `Tool coverage gate failed: the following key audit tools have not been executed: ${toolList}. ` +
1115
+ 'Run the missing tools before generating a report, or pass tool_coverage_policy: "warn" to override.',
1116
+ )
1117
+ }
1118
+ warningBullets.push(`- Tool coverage incomplete: ${toolList} not executed`)
1119
+ }
1120
+ }
1121
+
1106
1122
  try {
1107
1123
  const readEventsFn = deps.readEvents ?? readEvents
1108
1124
  const events = await readEventsFn(reportInput.run_id, reportInput.projectDir)
@@ -1183,7 +1199,22 @@ export async function executeReportGeneration(
1183
1199
  )
1184
1200
  }
1185
1201
  const counts = calculateCounts(findings)
1186
- const auditDate = new Date().toISOString().slice(0, 10)
1202
+ // Derive audit date from the run's start time for deterministic output.
1203
+ // Falls back to the earliest toolsExecuted timestamp, then current date as last resort.
1204
+ // Exclude UNKNOWN_TIMESTAMP_SENTINEL (patched-in value for missing timestamps).
1205
+ const runStartTime = reportInput.toolsExecuted.reduce(
1206
+ (earliest, exec) =>
1207
+ typeof exec.startTime === "number" &&
1208
+ exec.startTime > UNKNOWN_TIMESTAMP_SENTINEL &&
1209
+ exec.startTime < earliest
1210
+ ? exec.startTime
1211
+ : earliest,
1212
+ Number.MAX_SAFE_INTEGER,
1213
+ )
1214
+ const auditDate =
1215
+ runStartTime < Number.MAX_SAFE_INTEGER
1216
+ ? new Date(runStartTime).toISOString().slice(0, 10)
1217
+ : new Date().toISOString().slice(0, 10)
1187
1218
 
1188
1219
  context.metadata({ title: `Generate audit report: ${args.project_name}` })
1189
1220
 
@@ -1238,10 +1269,13 @@ export async function executeReportGeneration(
1238
1269
  sections.push(preflightWarningSection)
1239
1270
  }
1240
1271
 
1241
- sections.push(buildProvenanceAppendix(state, threshold, findings.length))
1272
+ sections.push(buildProvenanceAppendix(state, threshold, findings))
1242
1273
 
1243
1274
  // Embed report metadata for single-writer policy enforcement
1244
- const runId = reportInput.run_id || state.sessionId || ""
1275
+ const runId = expectedRunId ?? reportInput.run_id
1276
+ if (runId.startsWith("ses_")) {
1277
+ throw new Error("Report generation requires canonical run_id; received OpenCode session id")
1278
+ }
1245
1279
  if (runId) {
1246
1280
  sections.push(buildReportMetadataComment(runId))
1247
1281
  }
@@ -1269,8 +1303,17 @@ export async function executeReportGeneration(
1269
1303
  const loadConfig = deps.loadConfig ?? loadArgusConfig
1270
1304
  const projectDir = resolveProjectDir(context)
1271
1305
  const config = loadConfig(projectDir)
1272
- const outputDir = config.reporting?.output_dir ?? ".argus/reports/"
1273
- const fullPath = path.join(projectDir, outputDir, canonicalFilename)
1306
+ const rawOutputDir = config.reporting?.output_dir ?? ".argus/reports/"
1307
+ const resolvedOutput = path.resolve(projectDir, rawOutputDir)
1308
+ const projectRoot = projectDir.endsWith(path.sep) ? projectDir : projectDir + path.sep
1309
+ if (resolvedOutput !== projectDir && !resolvedOutput.startsWith(projectRoot)) {
1310
+ result.error = {
1311
+ code: "OUTPUT_DIR_TRAVERSAL",
1312
+ message: `output_dir "${rawOutputDir}" resolves outside the project root. Report not written.`,
1313
+ }
1314
+ return result
1315
+ }
1316
+ const fullPath = path.join(resolvedOutput, canonicalFilename)
1274
1317
 
1275
1318
  // Single-writer policy: check for duplicate writes with same run_id
1276
1319
  if (runId) {
@@ -1287,6 +1330,10 @@ export async function executeReportGeneration(
1287
1330
  const logger = createLogger()
1288
1331
  const message = err instanceof Error ? err.message : String(err)
1289
1332
  logger.warn(`Failed to write report to disk: ${message}`)
1333
+ result.error = {
1334
+ code: "WRITE_FAILED",
1335
+ message,
1336
+ }
1290
1337
  }
1291
1338
 
1292
1339
  return result
@@ -1294,20 +1341,39 @@ export async function executeReportGeneration(
1294
1341
 
1295
1342
  export const reportGeneratorTool = tool({
1296
1343
  description:
1297
- "Generate a professional markdown security audit report from versioned ReportInput payloads with legacy audit_state compatibility.",
1344
+ "Generate a professional markdown security audit report. Pass project_name, scope, and run_id — the tool reads the materialized ReportInput artifact from disk automatically.",
1298
1345
  args: {
1299
1346
  project_name: tool.schema.string(),
1300
1347
  scope: tool.schema.array(tool.schema.string()),
1301
1348
  include_executive_summary: tool.schema.boolean().default(true),
1302
1349
  severity_threshold: tool.schema
1303
1350
  .enum(["critical", "high", "medium", "low", "informational"])
1304
- .default("low"),
1305
- report_input: tool.schema.string().optional(),
1306
- audit_state: tool.schema.string().optional(),
1351
+ .default("informational"),
1307
1352
  preflight_policy: tool.schema.enum(["warn", "strict-fail"]).optional(),
1353
+ tool_coverage_policy: tool.schema
1354
+ .enum(["enforce", "warn", "skip"])
1355
+ .optional()
1356
+ .describe(
1357
+ "Controls whether report generation requires key audit tools to have been executed. " +
1358
+ "Defaults to 'enforce'.",
1359
+ ),
1360
+ run_id: tool.schema
1361
+ .string()
1362
+ .optional()
1363
+ .describe(
1364
+ "The canonical run ID from <argus-context>. The tool reads the materialized report-input.json from disk using this ID.",
1365
+ ),
1308
1366
  },
1309
1367
  async execute(args, context) {
1310
1368
  const result = await executeReportGeneration(args, context)
1311
- return JSON.stringify(result)
1369
+ // Return a slim payload to avoid OpenCode truncating large tool results.
1370
+ // The full markdown is already written to disk at result.filePath.
1371
+ // Truncated JSON breaks tool-tracking-hook parsing, which prevents
1372
+ // reportGenerated from being set and blocks run finalization.
1373
+ const { report, ...slimResult } = result
1374
+ return JSON.stringify({
1375
+ ...slimResult,
1376
+ reportSummary: `Report written to disk (${report.length} bytes, ${report.split("\n").length} lines). See filePath.`,
1377
+ })
1312
1378
  },
1313
1379
  })