@mcrescenzo/opencode-workflows 0.1.0

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 (71) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/CODE_OF_CONDUCT.md +134 -0
  3. package/CONTRIBUTING.md +95 -0
  4. package/LICENSE +21 -0
  5. package/README.md +746 -0
  6. package/SECURITY.md +38 -0
  7. package/commands/repo-bughunt.md +94 -0
  8. package/commands/repo-review.md +148 -0
  9. package/commands/workflow-live-gates-release-check.md +143 -0
  10. package/docs/workflow-plugin.md +400 -0
  11. package/opencode-workflows.js +5 -0
  12. package/package.json +86 -0
  13. package/skills/opencode-workflow-authoring/SKILL.md +180 -0
  14. package/skills/repo-review-command-protocol/SKILL.md +56 -0
  15. package/skills/workflow-model-tiering/SKILL.md +57 -0
  16. package/skills/workflow-plan-review/SKILL.md +91 -0
  17. package/workflow-kernel/approval-hashing.js +39 -0
  18. package/workflow-kernel/async-util.js +33 -0
  19. package/workflow-kernel/audited-shell-policy.js +200 -0
  20. package/workflow-kernel/authority-policy.js +670 -0
  21. package/workflow-kernel/budget-accounting.js +142 -0
  22. package/workflow-kernel/capability-adapter.js +753 -0
  23. package/workflow-kernel/child-agent-runner.js +1264 -0
  24. package/workflow-kernel/constants.js +117 -0
  25. package/workflow-kernel/diagnostics.js +152 -0
  26. package/workflow-kernel/drain-runtime.js +421 -0
  27. package/workflow-kernel/errors.js +181 -0
  28. package/workflow-kernel/event-journal.js +487 -0
  29. package/workflow-kernel/extension-registry.js +144 -0
  30. package/workflow-kernel/free-text-redactor.js +91 -0
  31. package/workflow-kernel/gate-shapes.js +82 -0
  32. package/workflow-kernel/git-util.js +45 -0
  33. package/workflow-kernel/index.js +72 -0
  34. package/workflow-kernel/integration-mode.js +155 -0
  35. package/workflow-kernel/lane-effort-policy.js +134 -0
  36. package/workflow-kernel/lifecycle-control.js +608 -0
  37. package/workflow-kernel/live-gate-probes.js +916 -0
  38. package/workflow-kernel/notification-toast-cards.js +393 -0
  39. package/workflow-kernel/notification-toast-policy.js +179 -0
  40. package/workflow-kernel/notification-toast-scope.js +100 -0
  41. package/workflow-kernel/notification-toast.js +287 -0
  42. package/workflow-kernel/path-policy.js +219 -0
  43. package/workflow-kernel/result-readback.js +106 -0
  44. package/workflow-kernel/role-template-loading.js +606 -0
  45. package/workflow-kernel/run-context.js +139 -0
  46. package/workflow-kernel/run-observability.js +43 -0
  47. package/workflow-kernel/run-store-fs.js +231 -0
  48. package/workflow-kernel/run-store-locks.js +180 -0
  49. package/workflow-kernel/run-store-projections.js +421 -0
  50. package/workflow-kernel/run-store-rehydrate.js +68 -0
  51. package/workflow-kernel/run-store-state.js +147 -0
  52. package/workflow-kernel/run-store-status-format.js +1154 -0
  53. package/workflow-kernel/run-store-status.js +107 -0
  54. package/workflow-kernel/sandbox-executor.js +1131 -0
  55. package/workflow-kernel/session-access.js +56 -0
  56. package/workflow-kernel/structured-output.js +143 -0
  57. package/workflow-kernel/test-fix-drain-adapter.js +119 -0
  58. package/workflow-kernel/text-json.js +136 -0
  59. package/workflow-kernel/workflow-plugin.js +3017 -0
  60. package/workflow-kernel/workflow-source.js +444 -0
  61. package/workflow-kernel/worktree-adapter.js +256 -0
  62. package/workflow-kernel/worktree-git.js +147 -0
  63. package/workflows/repo-bughunt.js +362 -0
  64. package/workflows/repo-cleanup.js +383 -0
  65. package/workflows/repo-complexity.js +404 -0
  66. package/workflows/repo-deps.js +457 -0
  67. package/workflows/repo-modernize.js +395 -0
  68. package/workflows/repo-perf.js +394 -0
  69. package/workflows/repo-review.js +831 -0
  70. package/workflows/repo-security-audit.js +466 -0
  71. package/workflows/repo-test-gaps.js +377 -0
@@ -0,0 +1,457 @@
1
+ // LEAF workflow — repo-deps engine (OpenCode port).
2
+ //
3
+ // Conforms to docs/repo-review-leaf-contract.md (the shared leaf contract).
4
+ // Canonical exemplar: workflows/repo-bughunt.js + tests/repo-bughunt.test.mjs.
5
+ // Sibling analog: workflows/repo-cleanup.js (same shape, top-level carve-out).
6
+ // Port lineage (DOMAIN LOGIC only): internal Claude workflow source, adapted to
7
+ // the OpenCode QuickJS guest: no fs/Bash/git, tier-based models (never hard-coded
8
+ // provider models), PURE-JS synthesis (no model), and reportMarkdown returned in
9
+ // the envelope (the guest cannot write files, so reportPath is always null — the
10
+ // command wrapper persists the report). The Claude source delegated synthesis and
11
+ // report-writing to a model lane and instructed lanes to run package-manager
12
+ // audit/outdated tools; both are reversed here: synthesis is pure JS and the
13
+ // DEPENDENCY INSPECTION POLICY is read-only lockfile/manifest inspection with NO
14
+ // network, installs, or package-manager mutation.
15
+ //
16
+ // DOMAIN: deps. This is a NON-SECURITY domain, so counts.critical is ALWAYS 0 and
17
+ // findings never carry severity "critical" (contract §6). CVE findings are still
18
+ // reported (category "cve") at high/medium/low severity.
19
+ //
20
+ // POLICY (the defining constraint of this leaf): NO network, NO installs, NO
21
+ // package-manager mutation. Lanes inspect lockfiles and manifests READ-ONLY and
22
+ // derive versions/findings from local files. CVE/outdated claims that cannot be
23
+ // proven from local files alone are reported at REDUCED confidence with the gap
24
+ // noted. See DEPS_POLICY below.
25
+ //
26
+ // SHELL-INSPECTION DEFERRAL: the contract (§2/§12) and authority-policy.js reserve
27
+ // the "inspect-with-shell" profile for the documented deps/complexity parity gaps,
28
+ // where a lane could run a read-only command allowlist (e.g. `npm ls`, `cargo tree`)
29
+ // to ground version trees and populate shellCoverage/coverageLimitations. That
30
+ // profile requires verified `permissionEnforcement` + `commandScopedBash` live gates
31
+ // (authority-policy.js:21-24), which are currently UNVERIFIED in this runtime
32
+ // (Stage-0 gate rrev.1: FALLBACK-ACCEPTED, inspect-with-shell NOT verified). This
33
+ // leaf therefore ships the DEFAULT "read-only-review" profile (requiredGates: []) and
34
+ // the shell lens is DEFERRED until those gates are verified. When shell inspection is
35
+ // later enabled, define an explicit read-only command allowlist (deny install/audit
36
+ // network commands) and populate shellCoverage/coverageLimitations; until then, keep
37
+ // the reduced-confidence policy below.
38
+ export const meta = {
39
+ name: "repo-deps",
40
+ description: "Report-only dependency health audit: outdated packages, known CVEs (from local-file analysis), unused/undeclared deps, license risk, version conflicts, and deprecated packages. Read-only lockfile/manifest inspection — no network, no installs, no package-manager mutation. Adversarially verifies the high-risk 'unused/undeclared' findings, ranks them, and returns a structured envelope plus a sequenced upgradePlan. Report-only: returns ranked structured findings; the workflow writes no files.",
41
+ profile: "read-only-review",
42
+ maxAgents: 4096,
43
+ concurrency: 16,
44
+ phases: ["recon", "find", "verify", "synthesize"],
45
+ category: "repo-review-leaf",
46
+ notes: "Read-only report-only dependency leaf. Inspects local manifests/lockfiles only: no network, installs, audit, or package-manager mutation. Finder lanes use fast tier, high-risk verification uses deep tier.",
47
+ examples: [
48
+ { label: "normal dependency scan", args: { depth: "normal", paths: ["."] } },
49
+ { label: "unused and undeclared deps", args: { depth: "quick", categories: ["unused", "undeclared"], paths: ["."] } },
50
+ ],
51
+ argsSchema: {
52
+ type: ["object", "string", "null"],
53
+ properties: {
54
+ paths: { type: "array", items: { type: "string" } },
55
+ exclude: { type: "array", items: { type: "string" } },
56
+ depth: { type: "string", enum: ["quick", "normal", "thorough"] },
57
+ categories: { type: "array", items: { type: "string", enum: ["outdated", "cve", "unused", "undeclared", "license", "version-conflict", "deprecated"] } },
58
+ maxReturnFindings: { type: "integer", minimum: 1 },
59
+ recon: { type: ["object", "string"] },
60
+ },
61
+ },
62
+ };
63
+
64
+ // ---- suite identity ----
65
+ const DOMAIN = "deps";
66
+ const SCHEMA_VERSION = 1;
67
+
68
+ // args may arrive as an object (workflow_run args) or, defensively, a JSON string.
69
+ let RT = args;
70
+ if (typeof RT === "string") { try { RT = RT.trim() ? JSON.parse(RT) : {}; } catch (error) { throw new Error(`Invalid repo-deps runtime args JSON: ${error.message}`); } }
71
+ if (!RT || typeof RT !== "object" || Array.isArray(RT)) RT = {};
72
+
73
+ const paths = Array.isArray(RT.paths) && RT.paths.length ? RT.paths : ["."];
74
+ const exclude = Array.isArray(RT.exclude) ? RT.exclude : ["node_modules", "dist", "build", ".git", "vendor", "target", "*.min.*", "*.map"];
75
+ const depth = ["quick", "normal", "thorough"].includes(RT.depth) ? RT.depth : "thorough";
76
+
77
+ // Model selection is intent-based: lanes declare a tier; the kernel resolves tier -> concrete model from
78
+ // run.modelTiers (set by the planning agent), falling back to the session-inherited default.
79
+ // recon + finders = bulk work (fast); skeptics = subtle correctness (deep); synth = pure JS (no model).
80
+ const TIER_RECON = "fast";
81
+ const TIER_FINDER = "fast";
82
+ const TIER_VERIFY = "deep";
83
+
84
+ const MAX_RETURN_FINDINGS = Number.isInteger(RT.maxReturnFindings) && RT.maxReturnFindings > 0 ? RT.maxReturnFindings : 1000000;
85
+
86
+ const ALL_CATEGORIES = ["outdated", "cve", "unused", "undeclared", "license", "version-conflict", "deprecated"];
87
+ const requestedCategories = Array.isArray(RT.categories) ? RT.categories.filter((c) => typeof c === "string" && c.trim()) : [];
88
+ const categories = requestedCategories.length > 0 ? requestedCategories.filter((c) => ALL_CATEGORIES.includes(c)) : ALL_CATEGORIES;
89
+ if (requestedCategories.length > 0 && categories.length === 0) {
90
+ throw new Error(`No valid repo-deps categories supplied. Valid categories: ${ALL_CATEGORIES.join(", ")}`);
91
+ }
92
+
93
+ // Categories where a wrong "remove/declare this" is most damaging — always verified
94
+ // in 'normal', and (with everything else) in 'thorough'. quick verifies nothing.
95
+ const HIGH_RISK = ["unused", "undeclared"];
96
+
97
+ // DOMAIN-SPECIFIC SCOPE (iui1.3): deps ALWAYS inspects manifests AND lockfiles — lockfiles carry the
98
+ // pinned/transitive version truth that manifests lack. *.lock is intentionally NOT in this domain's
99
+ // default exclude. The shared meta exclude no longer drops *.lock globally either; lockfile inclusion
100
+ // is a per-domain decision and for deps it is a hard include. (User-supplied excludes still win and
101
+ // are disclosed as a coverage gap if they remove a lockfile/manifest from scope.)
102
+ const DOMAIN_SCOPE_INCLUDES = "In scope for deps (always inspect): manifests (package.json, requirements.txt/pyproject.toml, go.mod, Cargo.toml, Gemfile, pom.xml, composer.json) AND lockfiles (yarn.lock, package-lock.json, pnpm-lock.yaml, composer.lock, Gemfile.lock, Podfile.lock, Cargo.lock, go.sum) — derive pinned/transitive versions and findings from these.";
103
+ const scope = `Scope: paths = ${JSON.stringify(paths)}. Exclude (do not scan/report): ${JSON.stringify(exclude)}. ${DOMAIN_SCOPE_INCLUDES}`;
104
+
105
+ // DEPENDENCY INSPECTION POLICY — the defining constraint of this leaf.
106
+ // Injected into every finder and the recon lane. Note: it deliberately uses the
107
+ // generic words "installs"/"mutation"/"network" rather than literal command tokens
108
+ // like "npm install" so policy checks can cleanly prove no positive mutation command
109
+ // is ever instructed.
110
+ const DEPS_POLICY = [
111
+ "DEPENDENCY INSPECTION POLICY (read-only):",
112
+ "- Inspect manifests and lockfiles ONLY. Derive versions and findings from local files.",
113
+ "- Never run installs, upgrades, or any package-manager mutation.",
114
+ "- Never fetch from the network or any advisory API/database.",
115
+ "- Never run audit/outdated tooling that mutates state or reaches the network.",
116
+ "- For CVE/outdated claims not provable from local files, set REDUCED confidence and note the gap.",
117
+ ].join("\n");
118
+
119
+ // ---- lane coverage telemetry (iui1.2) ----
120
+ // Tracks expected/completed/dropped lane counts per phase so a dropped finder/verifier lane
121
+ // (onFailure returnNull + filter(Boolean)) surfaces as degraded coverage instead of a silent
122
+ // null. The meta reads laneCoverage.dropped to block materialization on partial coverage.
123
+ const laneCoverage = { expected: 0, completed: 0, dropped: 0, byPhase: {}, droppedLabels: [] };
124
+ function tallyPhase(name, results, labelOf) {
125
+ const expected = results.length;
126
+ let completed = 0;
127
+ for (let i = 0; i < results.length; i++) {
128
+ if (results[i] === null || results[i] === undefined) laneCoverage.droppedLabels.push(labelOf ? labelOf(i) : `${name}:${i + 1}`);
129
+ else completed++;
130
+ }
131
+ const dropped = expected - completed;
132
+ laneCoverage.expected += expected;
133
+ laneCoverage.completed += completed;
134
+ laneCoverage.dropped += dropped;
135
+ const prev = laneCoverage.byPhase[name] || { expected: 0, completed: 0, dropped: 0 };
136
+ laneCoverage.byPhase[name] = { expected: prev.expected + expected, completed: prev.completed + completed, dropped: prev.dropped + dropped };
137
+ return results;
138
+ }
139
+
140
+ // ---- standardized return envelope ----
141
+ function envelope(status, extra) {
142
+ return { domain: DOMAIN, schemaVersion: SCHEMA_VERSION, status, abortReason: null, reportPath: null, laneCoverage, ...extra };
143
+ }
144
+ const emptyCounts = { total: 0, critical: 0, high: 0, medium: 0, low: 0 };
145
+ const emptyUpgradePlan = { safeBatch: [], breakingChanges: [] };
146
+
147
+ // ---- shared recon schema (tolerates a prose string via formatRecon) ----
148
+ const RECON_SCHEMA = {
149
+ type: "object", additionalProperties: false,
150
+ properties: {
151
+ languages: { type: "array", items: { type: "string" } },
152
+ frameworks: { type: "array", items: { type: "string" } },
153
+ packageManagers: { type: "array", items: { type: "string" } },
154
+ entryPoints: { type: "array", items: { type: "string" } },
155
+ testLayout: { type: "string" },
156
+ buildTooling: { type: "string" },
157
+ concurrencyModel: { type: "string" },
158
+ errorHandling: { type: "string" },
159
+ externalResources: { type: "array", items: { type: "string" } },
160
+ notes: { type: "string" },
161
+ },
162
+ required: ["languages", "notes"],
163
+ };
164
+ function formatRecon(r) {
165
+ if (typeof r === "string") return r;
166
+ if (!r || typeof r !== "object") return "No recon available.";
167
+ const L = (k, v) => (v && (Array.isArray(v) ? v.length : true) ? `${k}: ${Array.isArray(v) ? v.join(", ") : v}` : null);
168
+ return [
169
+ L("Languages", r.languages), L("Frameworks", r.frameworks), L("Package managers", r.packageManagers),
170
+ L("Entry points", r.entryPoints), L("Test layout", r.testLayout), L("Build tooling", r.buildTooling),
171
+ L("Concurrency model", r.concurrencyModel), L("Error handling", r.errorHandling),
172
+ L("External resources", r.externalResources), L("Notes", r.notes),
173
+ ].filter(Boolean).join("\n");
174
+ }
175
+
176
+ // ---- stable content fingerprint (djb2; deterministic, no crypto) ----
177
+ // <suite:fingerprintOf>
178
+ function fingerprintOf(f) {
179
+ const norm = (s) => (s || "").toString().toLowerCase().replace(/\s+/g, " ").trim();
180
+ const basis = `${DOMAIN}|${norm(f.file)}|${norm(f.category)}|${norm(f.description).slice(0, 160)}`;
181
+ let h = 5381;
182
+ for (let i = 0; i < basis.length; i++) h = ((h * 33) ^ basis.charCodeAt(i)) >>> 0;
183
+ return `${DOMAIN}-${h.toString(16)}`;
184
+ }
185
+ // </suite:fingerprintOf>
186
+
187
+ // ---- schemas ----
188
+ // severity excludes "critical": deps is a NON-SECURITY domain (contract §6).
189
+ const FINDINGS_SCHEMA = {
190
+ type: "object", additionalProperties: false,
191
+ properties: {
192
+ findings: {
193
+ type: "array",
194
+ items: {
195
+ type: "object", additionalProperties: false,
196
+ properties: {
197
+ category: { type: "string" },
198
+ package: { type: "string" },
199
+ file: { type: "string", description: "manifest/lockfile path" },
200
+ line: { type: "integer" },
201
+ severity: { type: "string", enum: ["high", "medium", "low"] },
202
+ description: { type: "string" },
203
+ currentVersion: { type: "string" },
204
+ targetVersion: { type: "string", description: "recommended version, or empty string if none" },
205
+ breaking: { type: "boolean", description: "is the recommended upgrade a breaking (major) change" },
206
+ cve: { type: "array", items: { type: "string" } },
207
+ advisory: { type: "string" },
208
+ proposedChange: { type: "string" },
209
+ confidence: { type: "integer" },
210
+ effort: { type: "string", enum: ["small", "medium", "large"] },
211
+ docImpact: { type: "string" },
212
+ },
213
+ required: ["category", "package", "file", "line", "severity", "description", "currentVersion", "targetVersion", "breaking", "cve", "advisory", "proposedChange", "confidence", "effort", "docImpact"],
214
+ },
215
+ },
216
+ },
217
+ required: ["findings"],
218
+ };
219
+ const VERDICT_SCHEMA = {
220
+ type: "object", additionalProperties: false,
221
+ properties: {
222
+ refuted: { type: "boolean" },
223
+ reasoning: { type: "string" },
224
+ adjustedConfidence: { type: "integer" },
225
+ },
226
+ required: ["refuted", "reasoning", "adjustedConfidence"],
227
+ };
228
+
229
+ // ---- lenses (read-only inspection; no command execution) ----
230
+ const LENS = {
231
+ "outdated": "Outdated packages: compare the versions DECLARED in manifests/lockfiles against your knowledge of newer releases. Classify each bump as patch/minor/major and set breaking=true for majors. Derive current/target versions from the local files only.",
232
+ "cve": "Known vulnerabilities: read the pinned versions in the lockfile/manifest and flag packages fixed at versions you know to be vulnerable from advisories in your training data. Do NOT fetch advisory databases. For any CVE claim you cannot confirm from local files alone, set REDUCED confidence and note the gap in the description.",
233
+ "unused": "Declared dependencies never imported/used anywhere in the code. Cross-check the manifest against actual imports/requires. Be CONSERVATIVE — check build-only, type-only, plugin, config-only, and CLI-invoked deps before flagging. A wrong removal breaks the build.",
234
+ "undeclared": "Imported/required packages that are NOT declared in the manifest (relying on transitive resolution). Flag each, citing the importing file.",
235
+ "license": "License risk: copyleft (GPL/AGPL) or unknown/missing licenses among dependencies that may conflict with the project's own license. Read license metadata/files locally only.",
236
+ "version-conflict": "Duplicate or conflicting versions of the same package across the tree; peer-dependency conflicts; lockfile drift vs manifest. Cite both manifest and lockfile evidence.",
237
+ "deprecated": "Dependencies marked deprecated by their maintainers, or unmaintained (no releases in a long time, archived repos). Note the deprecation source if visible in local metadata.",
238
+ };
239
+
240
+ function finderPrompt(cat, recon, roundNote) {
241
+ return [
242
+ `You are the "${cat}" dependency analyst for a REPORT-ONLY audit. Do NOT modify any files.`,
243
+ DEPS_POLICY,
244
+ scope,
245
+ `Repo profile (recon):\n${formatRecon(recon)}`,
246
+ `Your track: ${LENS[cat]}`,
247
+ roundNote || "",
248
+ `For EVERY finding set category to exactly "${cat}". Fill package, currentVersion, targetVersion (empty string if none), breaking, cve (list, possibly empty), advisory, proposedChange, confidence, effort, docImpact (empty string if none).`,
249
+ `Set confidence honestly (0-100): high only when the claim is provable from local files. Quality over quantity — a wrong finding is worse than a missed one.`,
250
+ `Return findings via the structured output.`,
251
+ ].filter(Boolean).join("\n\n");
252
+ }
253
+
254
+ function skepticPrompt(f) {
255
+ return [
256
+ "You are a skeptic. Your job is to REFUTE the dependency finding below — prove it is wrong or a false positive.",
257
+ DEPS_POLICY,
258
+ scope,
259
+ `Finding (${f.category}) for package "${f.package}" in ${f.file}:${f.line}:`,
260
+ `Description: ${f.description}`,
261
+ `Proposed change: ${f.proposedChange}`,
262
+ "Investigate with your read/search tools. For 'unused': hunt exhaustively for ANY usage including dynamic imports, config refs, build/CLI usage, type-only imports. For upgrades: confirm whether the recommended target is genuinely breaking. For CVEs: confirm the pinned version actually falls in the vulnerable range.",
263
+ "Set refuted=true if the finding is wrong. Default to refuted=true when genuinely uncertain.",
264
+ ].join("\n\n");
265
+ }
266
+
267
+ // ---- 1. Recon (use injected recon if present, else profile once) ----
268
+ await phase("recon");
269
+ const recon = RT.recon
270
+ ? RT.recon
271
+ : await agent(
272
+ ["Profile this repository's dependency setup for a READ-ONLY audit.", scope,
273
+ DEPS_POLICY,
274
+ "Report: languages/frameworks; package managers; manifest files (package.json, pyproject.toml, requirements.txt, go.mod, Cargo.toml, Gemfile, etc.) and their paths; lockfiles present; the project's own license.",
275
+ "Also note: entry points, test layout, build tooling, concurrency model, error-handling conventions, external resources.",
276
+ "Explore with your read/search tools only. Return the structured recon fields."].join("\n\n"),
277
+ { label: "recon", schema: RECON_SCHEMA, tier: TIER_RECON, onFailure: "returnNull" },
278
+ );
279
+
280
+ // ---- 2. Find + dedup ----
281
+ function dedup(findings) {
282
+ const seen = new Set(); const out = [];
283
+ for (const f of findings) {
284
+ if (!f) continue;
285
+ const key = `${f.category}::${(f.package || "").trim()}::${(f.file || "").trim()}`;
286
+ if (seen.has(key)) continue;
287
+ seen.add(key); out.push(f);
288
+ }
289
+ return out;
290
+ }
291
+
292
+ async function findRound(roundNote) {
293
+ await phase("find");
294
+ // arity-1 thunks (api) => ... so parallel() runs them CONCURRENTLY (zero-arg thunks fail unless { sequential: true } is explicit).
295
+ const results = await parallel(categories.map((cat) => (api) =>
296
+ api.agent(finderPrompt(cat, recon, roundNote), { label: `find:${cat}`, schema: FINDINGS_SCHEMA, tier: TIER_FINDER, onFailure: "returnNull" })));
297
+ tallyPhase("find", results, (i) => `find:${categories[i]}`);
298
+ return results.filter(Boolean).flatMap((r) => (r.findings || []).map((f) => ({ ...f, category: f.category || "unknown" })));
299
+ }
300
+
301
+ let findings = dedup(await findRound(null));
302
+ await log(`Round 1: ${findings.length} dependency findings across ${categories.length} tracks`);
303
+
304
+ if (depth === "thorough") {
305
+ const known = findings.map((f) => `- ${f.category} ${f.package} — ${f.description}`).join("\n");
306
+ const round2 = await findRound(`SECOND pass. Already found below — find only NEW issues, do not repeat:\n${known}`);
307
+ findings = dedup(findings.concat(round2));
308
+ await log(`After round 2: ${findings.length} findings`);
309
+ }
310
+
311
+ // positional id (in-run reference) + stable content fingerprint (cross-run dedupe key)
312
+ findings = findings.map((f, i) => ({ ...f, id: `${f.category}-${i + 1}`, fingerprint: fingerprintOf(f) }));
313
+
314
+ if (findings.length === 0) {
315
+ return envelope("empty", { summary: "Dependencies look healthy.", counts: emptyCounts, findings: [], truncatedFindings: false, reportMarkdown: null, upgradePlan: emptyUpgradePlan });
316
+ }
317
+
318
+ // ---- 3. Verify (high-FP profile on the "remove/declare this" categories) ----
319
+ // quick: verify nothing. normal: verify HIGH_RISK categories (unused, undeclared). thorough: verify ALL.
320
+ function shouldVerify(f) {
321
+ if (depth === "quick") return false;
322
+ if (depth === "thorough") return true;
323
+ return HIGH_RISK.includes(f.category);
324
+ }
325
+ const toVerify = findings.filter(shouldVerify);
326
+ const passThrough = findings.filter((f) => !shouldVerify(f));
327
+
328
+ let verified = passThrough;
329
+ if (toVerify.length > 0) {
330
+ await phase("verify");
331
+ const checked = await parallel(toVerify.map((f) => (api) =>
332
+ api.agent(skepticPrompt(f), { label: `verify:${f.id}`, schema: VERDICT_SCHEMA, tier: TIER_VERIFY, onFailure: "returnNull" })
333
+ .then((v) => ({ f, keep: !!(v && !v.refuted), conf: v ? v.adjustedConfidence : undefined }))));
334
+ tallyPhase("verify", checked, (i) => `verify:${toVerify[i].id}`);
335
+ const survivors = checked.filter(Boolean).filter((c) => c.keep)
336
+ .map((c) => ({ ...c.f, confidence: c.conf != null ? c.conf : c.f.confidence }));
337
+ verified = passThrough.concat(survivors);
338
+ await log(`Verified: ${survivors.length}/${toVerify.length} high-risk findings survived; ${verified.length} total`);
339
+ }
340
+
341
+ if (verified.length === 0) {
342
+ return envelope("empty", { summary: "No dependency issues survived verification.", counts: emptyCounts, findings: [], truncatedFindings: false, reportMarkdown: null, upgradePlan: emptyUpgradePlan });
343
+ }
344
+
345
+ // ---- 4. Synthesize (PURE JS — dedup, rank, build upgradePlan, render; the host persists the returned object) ----
346
+ await phase("synthesize");
347
+ const SEVW = { high: 3, medium: 2, low: 1 };
348
+ const EFFD = { small: 1, medium: 0.8, large: 0.6 };
349
+ function score(f) { return (SEVW[f.severity] || 1) * ((f.confidence || 0) / 100) * (EFFD[f.effort] || 0.8); }
350
+
351
+ const ranked = verified.map((f) => ({ ...f })).sort((a, b) => score(b) - score(a));
352
+ ranked.forEach((f, i) => { f.rank = i + 1; });
353
+
354
+ const counts = {
355
+ total: ranked.length,
356
+ critical: 0,
357
+ high: ranked.filter((f) => f.severity === "high").length,
358
+ medium: ranked.filter((f) => f.severity === "medium").length,
359
+ low: ranked.filter((f) => f.severity === "low").length,
360
+ };
361
+
362
+ // Per-domain carve-out (SUITE-CONTRACT § Per-domain carve-outs; contract §2):
363
+ // upgradePlan sequences the surviving findings into a non-breaking safe batch and a
364
+ // breaking-changes list. Built in PURE JS (the Claude source delegated this to a model
365
+ // lane; OpenCode synthesis is model-free). safeBatch = non-breaking bumps safe to apply
366
+ // together; breakingChanges = major/breaking upgrades each with a one-line migration note.
367
+ function buildUpgradePlan(rows) {
368
+ const safeBatch = [];
369
+ const breakingChanges = [];
370
+ for (const f of rows) {
371
+ const cur = f.currentVersion || "?";
372
+ const tgt = f.targetVersion || "latest";
373
+ const label = `${f.package}: ${cur} -> ${tgt}`;
374
+ if (f.breaking) {
375
+ const note = f.advisory || f.description || "breaking upgrade; migrate before applying";
376
+ breakingChanges.push(`${label} — ${note}`);
377
+ } else {
378
+ safeBatch.push(label);
379
+ }
380
+ }
381
+ return { safeBatch, breakingChanges };
382
+ }
383
+ const upgradePlan = buildUpgradePlan(ranked);
384
+
385
+ function mdCell(s) { return String(s == null ? "" : s).replace(/\|/g, "\\|").replace(/\n+/g, " "); }
386
+ function renderMarkdown(rows, c, plan) {
387
+ const lines = [];
388
+ lines.push(`# Dependency Audit Report (${DOMAIN})`, "");
389
+ lines.push("> Report-only. No files were modified, nothing was installed or upgraded.", "");
390
+ lines.push("## Summary", `- Total: ${c.total} (high: ${c.high}, medium: ${c.medium}, low: ${c.low})`, "");
391
+ lines.push("## Upgrade plan", "");
392
+ lines.push("### Safe batch (non-breaking)");
393
+ if (plan.safeBatch.length) for (const s of plan.safeBatch) lines.push(`- ${mdCell(s)}`);
394
+ else lines.push("- (none)");
395
+ lines.push("### Breaking changes");
396
+ if (plan.breakingChanges.length) for (const s of plan.breakingChanges) lines.push(`- ${mdCell(s)}`);
397
+ else lines.push("- (none)");
398
+ lines.push("");
399
+ lines.push("## Ranked findings", "");
400
+ lines.push("| Rank | Category | Package | Severity | Current -> Target | Breaking | CVE |");
401
+ lines.push("| ---- | -------- | ------- | -------- | ----------------- | -------- | --- |");
402
+ for (const f of rows) {
403
+ lines.push(`| ${f.rank} | ${mdCell(f.category)} | ${mdCell(f.package)} | ${mdCell(f.severity)} | ${mdCell(f.currentVersion)} -> ${mdCell(f.targetVersion)} | ${f.breaking ? "yes" : "no"} | ${mdCell((f.cve || []).join(", "))} |`);
404
+ }
405
+ lines.push("", "## Detail");
406
+ for (const f of rows) {
407
+ lines.push("", `### ${f.rank}. ${mdCell(f.category)} — ${mdCell(f.package)} (${f.severity}, conf ${f.confidence})`);
408
+ lines.push(`- **What:** ${f.description}`);
409
+ lines.push(`- **Manifest:** ${mdCell(f.file)}:${f.line || 0}`);
410
+ lines.push(`- **Current -> target:** ${f.currentVersion || "?"} -> ${f.targetVersion || "(none)"}`);
411
+ lines.push(`- **Breaking:** ${f.breaking ? "yes" : "no"}`);
412
+ if (f.advisory) lines.push(`- **Advisory:** ${f.advisory}`);
413
+ if (f.cve && f.cve.length) lines.push(`- **CVE:** ${(f.cve || []).join(", ")}`);
414
+ lines.push(`- **Proposed change:** ${f.proposedChange}`);
415
+ if (f.docImpact) lines.push(`- **Doc impact:** ${f.docImpact}`);
416
+ lines.push(`- **Fingerprint:** \`${f.fingerprint}\``);
417
+ }
418
+ return lines.join("\n");
419
+ }
420
+
421
+ // Size-fit to the 256 KB host cap: drop reportMarkdown first, then halve findings, until it fits.
422
+ function utf8ByteLength(value) {
423
+ const s = String(value ?? "");
424
+ let bytes = 0;
425
+ for (let i = 0; i < s.length; i += 1) {
426
+ const code = s.charCodeAt(i);
427
+ if (code <= 0x7f) bytes += 1;
428
+ else if (code <= 0x7ff) bytes += 2;
429
+ else if (code >= 0xd800 && code <= 0xdbff && i + 1 < s.length) {
430
+ const next = s.charCodeAt(i + 1);
431
+ if (next >= 0xdc00 && next <= 0xdfff) {
432
+ bytes += 4;
433
+ i += 1;
434
+ } else bytes += 3;
435
+ } else bytes += 3;
436
+ }
437
+ return bytes;
438
+ }
439
+ function jsonUtf8ByteLength(value) {
440
+ return utf8ByteLength(JSON.stringify(value));
441
+ }
442
+ function fitWithinBudget(status, summary) {
443
+ const LIMIT = 230000; // headroom under MAX_RESULT_BYTES (262144) for the {output:...} wrapper + envelope fields
444
+ let returned = ranked.slice(0, MAX_RETURN_FINDINGS);
445
+ let truncated = ranked.length > returned.length;
446
+ let reportMarkdown = renderMarkdown(ranked, counts, upgradePlan);
447
+ const sizeOf = () => jsonUtf8ByteLength(envelope(status, { summary, counts, findings: returned, truncatedFindings: truncated, reportMarkdown, upgradePlan }));
448
+ if (sizeOf() > LIMIT) reportMarkdown = null;
449
+ while (sizeOf() > LIMIT && returned.length > 10) {
450
+ returned = returned.slice(0, Math.ceil(returned.length / 2));
451
+ truncated = true;
452
+ }
453
+ return envelope(status, { summary, counts, findings: returned, truncatedFindings: truncated, reportMarkdown, upgradePlan });
454
+ }
455
+
456
+ const summary = `Found ${counts.total} dependency finding(s): ${counts.high} high, ${counts.medium} medium, ${counts.low} low. Report-only — nothing installed or upgraded.`;
457
+ return fitWithinBudget("ok", summary);