@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,383 @@
1
+ // LEAF workflow — repo-cleanup 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
+ // Port lineage (DOMAIN LOGIC only): internal Claude workflow source, adapted to
6
+ // the OpenCode QuickJS guest: no fs/Bash/git, tier-based models, PURE-JS
7
+ // synthesis (no model), and reportMarkdown returned in the envelope (the guest
8
+ // cannot write files, so reportPath is always null — the command wrapper
9
+ // persists the report).
10
+ //
11
+ // Domain stance: this is SAFE CLEANUP, not behavior-changing refactors. Findings
12
+ // describe dead code, unused deps, duplication, stale markers, simplification,
13
+ // best-practice gaps, and doc drift — each a report-only proposal. The high-risk
14
+ // "remove this" categories (dead-code, unused-deps) are adversarially verified
15
+ // because a wrong removal is the most damaging false positive.
16
+ export const meta = {
17
+ name: "repo-cleanup",
18
+ description: "Report-only repo cleanup research: find dead code, unused deps, duplication, stale markers, simplification & best-practice issues, and doc drift; adversarially verify the high-risk 'remove this' findings (dead-code, unused-deps); rank and return a structured envelope. Distinguishes safe cleanup from behavior-changing refactors. Report-only: returns ranked structured findings; the workflow writes no files.",
19
+ profile: "read-only-review",
20
+ maxAgents: 4096,
21
+ concurrency: 16,
22
+ phases: ["recon", "find", "verify", "synthesize"],
23
+ category: "repo-review-leaf",
24
+ notes: "Read-only report-only cleanup leaf. Proposes safe cleanup only, not behavior-changing refactors. Finder lanes use fast tier, high-risk removal verification uses deep tier.",
25
+ examples: [
26
+ { label: "normal cleanup scan", args: { depth: "normal", paths: ["src", "docs"] } },
27
+ { label: "dead code and deps", args: { depth: "quick", categories: ["dead-code", "unused-deps"], paths: ["src"] } },
28
+ ],
29
+ argsSchema: {
30
+ type: ["object", "string", "null"],
31
+ properties: {
32
+ paths: { type: "array", items: { type: "string" } },
33
+ exclude: { type: "array", items: { type: "string" } },
34
+ depth: { type: "string", enum: ["quick", "normal", "thorough"] },
35
+ categories: { type: "array", items: { type: "string", enum: ["dead-code", "unused-deps", "duplication", "stale-markers", "simplification", "best-practice", "doc-drift"] } },
36
+ maxReturnFindings: { type: "integer", minimum: 1 },
37
+ recon: { type: ["object", "string"] },
38
+ },
39
+ },
40
+ };
41
+
42
+ // ---- suite identity ----
43
+ const DOMAIN = "cleanup";
44
+ const SCHEMA_VERSION = 1;
45
+
46
+ // args may arrive as an object (workflow_run args) or, defensively, a JSON string.
47
+ let RT = args;
48
+ if (typeof RT === "string") { try { RT = RT.trim() ? JSON.parse(RT) : {}; } catch (error) { throw new Error(`Invalid repo-cleanup runtime args JSON: ${error.message}`); } }
49
+ if (!RT || typeof RT !== "object" || Array.isArray(RT)) RT = {};
50
+
51
+ const paths = Array.isArray(RT.paths) && RT.paths.length ? RT.paths : ["."];
52
+ const exclude = Array.isArray(RT.exclude) ? RT.exclude : ["node_modules", "dist", "build", ".git", "vendor", "target", "*.min.*", "*.map"];
53
+ const depth = ["quick", "normal", "thorough"].includes(RT.depth) ? RT.depth : "thorough";
54
+
55
+ // Model selection is intent-based: lanes declare a tier; the kernel resolves tier -> concrete model from
56
+ // run.modelTiers (set by the planning agent), falling back to the session-inherited default.
57
+ // recon + finders = bulk work (fast); skeptics = subtle correctness (deep); synth = pure JS (no model).
58
+ const TIER_RECON = "fast";
59
+ const TIER_FINDER = "fast";
60
+ const TIER_VERIFY = "deep";
61
+
62
+ const MAX_RETURN_FINDINGS = Number.isInteger(RT.maxReturnFindings) && RT.maxReturnFindings > 0 ? RT.maxReturnFindings : 1000000;
63
+
64
+ const ALL_CATEGORIES = ["dead-code", "unused-deps", "duplication", "stale-markers", "simplification", "best-practice", "doc-drift"];
65
+ const requestedCategories = Array.isArray(RT.categories) ? RT.categories.filter((c) => typeof c === "string" && c.trim()) : [];
66
+ const categories = requestedCategories.length > 0 ? requestedCategories.filter((c) => ALL_CATEGORIES.includes(c)) : ALL_CATEGORIES;
67
+ if (requestedCategories.length > 0 && categories.length === 0) {
68
+ throw new Error(`No valid repo-cleanup categories supplied. Valid categories: ${ALL_CATEGORIES.join(", ")}`);
69
+ }
70
+
71
+ // Categories where a wrong "remove this" is most damaging — always verified in
72
+ // 'normal', and (with everything else) in 'thorough'. quick verifies nothing.
73
+ const HIGH_RISK = ["dead-code", "unused-deps"];
74
+
75
+ const scope = `Scope: paths = ${JSON.stringify(paths)}. Exclude (do not scan/report): ${JSON.stringify(exclude)}.`;
76
+
77
+ // ---- lane coverage telemetry (iui1.2) ----
78
+ // Tracks expected/completed/dropped lane counts per phase so a dropped finder/verifier lane
79
+ // (onFailure returnNull + filter(Boolean)) surfaces as degraded coverage instead of a silent
80
+ // null. The meta reads laneCoverage.dropped to block materialization on partial coverage.
81
+ const laneCoverage = { expected: 0, completed: 0, dropped: 0, byPhase: {}, droppedLabels: [] };
82
+ function tallyPhase(name, results, labelOf) {
83
+ const expected = results.length;
84
+ let completed = 0;
85
+ for (let i = 0; i < results.length; i++) {
86
+ if (results[i] === null || results[i] === undefined) laneCoverage.droppedLabels.push(labelOf ? labelOf(i) : `${name}:${i + 1}`);
87
+ else completed++;
88
+ }
89
+ const dropped = expected - completed;
90
+ laneCoverage.expected += expected;
91
+ laneCoverage.completed += completed;
92
+ laneCoverage.dropped += dropped;
93
+ const prev = laneCoverage.byPhase[name] || { expected: 0, completed: 0, dropped: 0 };
94
+ laneCoverage.byPhase[name] = { expected: prev.expected + expected, completed: prev.completed + completed, dropped: prev.dropped + dropped };
95
+ return results;
96
+ }
97
+
98
+ // ---- standardized return envelope ----
99
+ function envelope(status, extra) {
100
+ return { domain: DOMAIN, schemaVersion: SCHEMA_VERSION, status, abortReason: null, reportPath: null, laneCoverage, ...extra };
101
+ }
102
+ const emptyCounts = { total: 0, critical: 0, high: 0, medium: 0, low: 0 };
103
+
104
+ // ---- shared recon schema (tolerates a prose string via formatRecon) ----
105
+ const RECON_SCHEMA = {
106
+ type: "object", additionalProperties: false,
107
+ properties: {
108
+ languages: { type: "array", items: { type: "string" } },
109
+ frameworks: { type: "array", items: { type: "string" } },
110
+ packageManagers: { type: "array", items: { type: "string" } },
111
+ entryPoints: { type: "array", items: { type: "string" } },
112
+ testLayout: { type: "string" },
113
+ buildTooling: { type: "string" },
114
+ concurrencyModel: { type: "string" },
115
+ errorHandling: { type: "string" },
116
+ externalResources: { type: "array", items: { type: "string" } },
117
+ notes: { type: "string" },
118
+ },
119
+ required: ["languages", "notes"],
120
+ };
121
+ function formatRecon(r) {
122
+ if (typeof r === "string") return r;
123
+ if (!r || typeof r !== "object") return "No recon available.";
124
+ const L = (k, v) => (v && (Array.isArray(v) ? v.length : true) ? `${k}: ${Array.isArray(v) ? v.join(", ") : v}` : null);
125
+ return [
126
+ L("Languages", r.languages), L("Frameworks", r.frameworks), L("Package managers", r.packageManagers),
127
+ L("Entry points", r.entryPoints), L("Test layout", r.testLayout), L("Build tooling", r.buildTooling),
128
+ L("Concurrency model", r.concurrencyModel), L("Error handling", r.errorHandling),
129
+ L("External resources", r.externalResources), L("Notes", r.notes),
130
+ ].filter(Boolean).join("\n");
131
+ }
132
+
133
+ // ---- stable content fingerprint (djb2; deterministic, no crypto) ----
134
+ // <suite:fingerprintOf>
135
+ function fingerprintOf(f) {
136
+ const norm = (s) => (s || "").toString().toLowerCase().replace(/\s+/g, " ").trim();
137
+ const basis = `${DOMAIN}|${norm(f.file)}|${norm(f.category)}|${norm(f.description).slice(0, 160)}`;
138
+ let h = 5381;
139
+ for (let i = 0; i < basis.length; i++) h = ((h * 33) ^ basis.charCodeAt(i)) >>> 0;
140
+ return `${DOMAIN}-${h.toString(16)}`;
141
+ }
142
+ // </suite:fingerprintOf>
143
+
144
+ // ---- schemas ----
145
+ const FINDINGS_SCHEMA = {
146
+ type: "object", additionalProperties: false,
147
+ properties: {
148
+ findings: {
149
+ type: "array",
150
+ items: {
151
+ type: "object", additionalProperties: false,
152
+ properties: {
153
+ category: { type: "string" },
154
+ file: { type: "string" },
155
+ line: { type: "integer" },
156
+ severity: { type: "string", enum: ["high", "medium", "low"] },
157
+ description: { type: "string" },
158
+ proposedChange: { type: "string" },
159
+ confidence: { type: "integer" },
160
+ effort: { type: "string", enum: ["small", "medium", "large"] },
161
+ docImpact: { type: "string" },
162
+ },
163
+ required: ["category", "file", "line", "severity", "description", "proposedChange", "confidence", "effort", "docImpact"],
164
+ },
165
+ },
166
+ },
167
+ required: ["findings"],
168
+ };
169
+ const VERDICT_SCHEMA = {
170
+ type: "object", additionalProperties: false,
171
+ properties: {
172
+ refuted: { type: "boolean" },
173
+ reasoning: { type: "string" },
174
+ adjustedConfidence: { type: "integer" },
175
+ },
176
+ required: ["refuted", "reasoning", "adjustedConfidence"],
177
+ };
178
+
179
+ // ---- lenses ----
180
+ const LENS = {
181
+ "dead-code": "Find DEAD or UNREACHABLE code: functions/methods/classes never called, unreferenced exports, unreachable branches, and large commented-out blocks. Be CONSERVATIVE — before flagging, check for dynamic dispatch, reflection, string-based references, re-exports, public API surface, and test-only usage. A wrong 'remove this' is the most damaging false positive.",
182
+ "unused-deps": "Find UNUSED DEPENDENCIES: packages declared in the manifest (package.json / pyproject / go.mod / Cargo.toml / etc.) but never imported anywhere. Check transitive/peer/dev distinctions and build-tool / config-only usage before flagging. Be CONSERVATIVE — a wrong removal breaks the build.",
183
+ "duplication": "Find DUPLICATION: copy-pasted logic, near-identical functions, and repeated patterns that should be factored into a shared helper. Report the canonical location and the duplicates; propose the extraction.",
184
+ "stale-markers": "Find STALE MARKERS: old TODO/FIXME/HACK/XXX comments, dated stubs, leftover debug logging (console.log / print / dbg), commented-out experiments, and skipped/xfail tests. Note which look genuinely actionable vs. noise.",
185
+ "simplification": "Find SIMPLIFICATION opportunities: overly complex conditionals, redundant abstractions, dead config flags, needless indirection, code that a clearer standard-library or idiomatic construct would replace. PRESERVE BEHAVIOR — this is safe cleanup, not a refactor: propose equivalent simpler code, do not change semantics.",
186
+ "best-practice": "Find BEST-PRACTICE violations relative to this repo's own conventions and ecosystem norms: error handling gaps, inconsistent patterns, missing null/empty handling, resource leaks, insecure defaults, and deviations from the project's established style. Prefer issues backed by the repo's own AGENTS.md / linters / existing patterns.",
187
+ "doc-drift": "Find DOC DRIFT: README / API docs / AGENTS.md / docstrings / code comments that are out of sync with the current code (renamed symbols, removed flags, changed signatures, stale examples, broken internal links). Report the doc location and the correction needed.",
188
+ };
189
+
190
+ function finderPrompt(cat, recon, roundNote) {
191
+ return [
192
+ `You are the "${cat}" finder for a REPORT-ONLY repo cleanup. Do NOT modify any files.`,
193
+ scope,
194
+ `Repo profile (recon):\n${formatRecon(recon)}`,
195
+ `Your lens: ${LENS[cat]}`,
196
+ roundNote || "",
197
+ `Explore with your read/search tools. For EVERY finding set category to exactly "${cat}".`,
198
+ `This is SAFE CLEANUP, not behavior-changing refactors — for simplification especially, propose behavior-preserving edits only.`,
199
+ `Set confidence honestly (0-100): only high when you have verified the claim. Always fill docImpact (empty string if none).`,
200
+ `Return findings via the structured output. Quality over quantity — a wrong finding is worse than a missed one.`,
201
+ ].filter(Boolean).join("\n\n");
202
+ }
203
+
204
+ function skepticPrompt(f) {
205
+ return [
206
+ "You are a skeptic. Your job is to REFUTE the cleanup finding below — prove it is wrong, unsafe, or a false positive.",
207
+ scope,
208
+ `Finding (${f.category}) at ${f.file}:${f.line}:`,
209
+ `Description: ${f.description}`,
210
+ `Proposed change: ${f.proposedChange}`,
211
+ "Investigate with your tools. For dead-code / unused-deps especially, hunt for ANY usage: dynamic/reflective references, string lookups, re-exports, public API, config, tests, build steps, other languages.",
212
+ "Set refuted=true if acting on this would be wrong or unsafe. Default to refuted=true when genuinely uncertain.",
213
+ ].join("\n\n");
214
+ }
215
+
216
+ // ---- 1. Recon (use injected recon if present, else profile once) ----
217
+ await phase("recon");
218
+ const recon = RT.recon
219
+ ? RT.recon
220
+ : await agent(
221
+ ["Profile this repository for a cleanup pass. Be concise but concrete.", scope,
222
+ "Report: primary language(s) and framework(s); build system & manifest files; entry points / public API surface; where docs live (README, docs/, AGENTS.md, etc.); test layout & runner; linter/formatter config present; anything that would make dead-code or unused-dep analysis tricky (dynamic loading, plugin systems, codegen).",
223
+ "Explore with your tools."].join("\n\n"),
224
+ { label: "recon", schema: RECON_SCHEMA, tier: TIER_RECON, onFailure: "returnNull" },
225
+ );
226
+
227
+ // ---- 2. Find + dedup ----
228
+ function dedup(findings) {
229
+ const seen = new Set(); const out = [];
230
+ for (const f of findings) {
231
+ if (!f) continue;
232
+ const key = `${f.category}::${(f.file || "").trim()}::${f.line || 0}`;
233
+ if (seen.has(key)) continue;
234
+ seen.add(key); out.push(f);
235
+ }
236
+ return out;
237
+ }
238
+
239
+ async function findRound(roundNote) {
240
+ await phase("find");
241
+ // arity-1 thunks (api) => ... so parallel() runs them CONCURRENTLY (zero-arg thunks fail unless { sequential: true } is explicit).
242
+ const results = await parallel(categories.map((cat) => (api) =>
243
+ api.agent(finderPrompt(cat, recon, roundNote), { label: `find:${cat}`, schema: FINDINGS_SCHEMA, tier: TIER_FINDER, onFailure: "returnNull" })));
244
+ tallyPhase("find", results, (i) => `find:${categories[i]}`);
245
+ return results.filter(Boolean).flatMap((r) => (r.findings || []).map((f) => ({ ...f, category: f.category || "unknown" })));
246
+ }
247
+
248
+ let findings = dedup(await findRound(null));
249
+ await log(`Round 1: ${findings.length} findings across ${categories.length} lenses`);
250
+
251
+ if (depth === "thorough") {
252
+ const known = findings.map((f) => `- ${f.category} ${f.file}:${f.line} — ${f.description}`).join("\n");
253
+ const round2 = await findRound(`SECOND pass. Already found below — find only NEW issues, do not repeat:\n${known}`);
254
+ findings = dedup(findings.concat(round2));
255
+ await log(`After round 2: ${findings.length} findings`);
256
+ }
257
+
258
+ // positional id (in-run reference) + stable content fingerprint (cross-run dedupe key)
259
+ findings = findings.map((f, i) => ({ ...f, id: `${f.category}-${i + 1}`, fingerprint: fingerprintOf(f) }));
260
+
261
+ if (findings.length === 0) {
262
+ return envelope("empty", { summary: "No cleanup findings surfaced.", counts: emptyCounts, findings: [], truncatedFindings: false, reportMarkdown: null, staleDocs: [] });
263
+ }
264
+
265
+ // ---- 3. Verify (high-FP profile on the "remove this" categories) ----
266
+ // quick: verify nothing. normal: verify HIGH_RISK categories (dead-code, unused-deps). thorough: verify ALL.
267
+ function shouldVerify(f) {
268
+ if (depth === "quick") return false;
269
+ if (depth === "thorough") return true;
270
+ return HIGH_RISK.includes(f.category);
271
+ }
272
+ const toVerify = findings.filter(shouldVerify);
273
+ const passThrough = findings.filter((f) => !shouldVerify(f));
274
+
275
+ let verified = passThrough;
276
+ if (toVerify.length > 0) {
277
+ await phase("verify");
278
+ const checked = await parallel(toVerify.map((f) => (api) =>
279
+ api.agent(skepticPrompt(f), { label: `verify:${f.id}`, schema: VERDICT_SCHEMA, tier: TIER_VERIFY, onFailure: "returnNull" })
280
+ .then((v) => ({ f, keep: !!(v && !v.refuted), conf: v ? v.adjustedConfidence : undefined }))));
281
+ tallyPhase("verify", checked, (i) => `verify:${toVerify[i].id}`);
282
+ const survivors = checked.filter(Boolean).filter((c) => c.keep)
283
+ .map((c) => ({ ...c.f, confidence: c.conf != null ? c.conf : c.f.confidence }));
284
+ verified = passThrough.concat(survivors);
285
+ await log(`Verified: ${survivors.length}/${toVerify.length} high-risk findings survived; ${verified.length} total`);
286
+ }
287
+
288
+ if (verified.length === 0) {
289
+ return envelope("empty", { summary: "No cleanup findings survived verification.", counts: emptyCounts, findings: [], truncatedFindings: false, reportMarkdown: null, staleDocs: [] });
290
+ }
291
+
292
+ // ---- 4. Synthesize (PURE JS — dedup, rank, render; the host persists the returned object) ----
293
+ await phase("synthesize");
294
+ const SEVW = { high: 3, medium: 2, low: 1 };
295
+ const EFFD = { small: 1, medium: 0.8, large: 0.6 };
296
+ function score(f) { return (SEVW[f.severity] || 1) * ((f.confidence || 0) / 100) * (EFFD[f.effort] || 0.8); }
297
+
298
+ const ranked = verified.map((f) => ({ ...f })).sort((a, b) => score(b) - score(a));
299
+ ranked.forEach((f, i) => { f.rank = i + 1; });
300
+
301
+ const counts = {
302
+ total: ranked.length,
303
+ critical: 0,
304
+ high: ranked.filter((f) => f.severity === "high").length,
305
+ medium: ranked.filter((f) => f.severity === "medium").length,
306
+ low: ranked.filter((f) => f.severity === "low").length,
307
+ };
308
+
309
+ // Per-domain carve-out (SUITE-CONTRACT): staleDocs = doc paths from the
310
+ // surviving doc-drift findings. Empty when no doc-drift finding survives.
311
+ const staleDocs = [];
312
+ const seenDocs = new Set();
313
+ for (const f of ranked) {
314
+ if (f.category !== "doc-drift") continue;
315
+ const doc = (f.file || "").trim();
316
+ if (doc && !seenDocs.has(doc)) { seenDocs.add(doc); staleDocs.push(doc); }
317
+ }
318
+
319
+ function mdCell(s) { return String(s == null ? "" : s).replace(/\|/g, "\\|").replace(/\n+/g, " "); }
320
+ function renderMarkdown(rows, c, docs) {
321
+ const lines = [];
322
+ lines.push(`# Cleanup Report (${DOMAIN})`, "");
323
+ lines.push("> Report-only. No files were modified and nothing was applied — these are proposals, not edits.", "");
324
+ lines.push("## Summary", `- Total: ${c.total} (high: ${c.high}, medium: ${c.medium}, low: ${c.low})`, "");
325
+ if (docs.length > 0) {
326
+ lines.push("## Docs already stale", "Documentation paths out of sync with the current code (from the doc-drift lens):");
327
+ for (const d of docs) lines.push(`- ${mdCell(d)}`);
328
+ lines.push("");
329
+ }
330
+ lines.push("## Ranked findings", "");
331
+ lines.push("| Rank | Category | Severity | Confidence | Effort | Location | Description |");
332
+ lines.push("| ---- | -------- | -------- | ---------- | ------ | -------- | ----------- |");
333
+ for (const f of rows) {
334
+ lines.push(`| ${f.rank} | ${mdCell(f.category)} | ${mdCell(f.severity)} | ${f.confidence} | ${mdCell(f.effort)} | ${mdCell(f.file)}:${f.line || 0} | ${mdCell(f.description).slice(0, 140)} |`);
335
+ }
336
+ lines.push("", "## Detail");
337
+ for (const f of rows) {
338
+ lines.push("", `### ${f.rank}. ${mdCell(f.category)} — ${mdCell(f.file)}:${f.line || 0} (${f.severity}, conf ${f.confidence})`);
339
+ lines.push(`- **What:** ${f.description}`);
340
+ lines.push(`- **Proposed change:** ${f.proposedChange}`);
341
+ if (f.docImpact) lines.push(`- **Doc impact:** ${f.docImpact}`);
342
+ lines.push(`- **Fingerprint:** \`${f.fingerprint}\``);
343
+ }
344
+ return lines.join("\n");
345
+ }
346
+
347
+ // Size-fit to the 256 KB host cap: drop reportMarkdown first, then halve findings, until it fits.
348
+ function utf8ByteLength(value) {
349
+ const s = String(value ?? "");
350
+ let bytes = 0;
351
+ for (let i = 0; i < s.length; i += 1) {
352
+ const code = s.charCodeAt(i);
353
+ if (code <= 0x7f) bytes += 1;
354
+ else if (code <= 0x7ff) bytes += 2;
355
+ else if (code >= 0xd800 && code <= 0xdbff && i + 1 < s.length) {
356
+ const next = s.charCodeAt(i + 1);
357
+ if (next >= 0xdc00 && next <= 0xdfff) {
358
+ bytes += 4;
359
+ i += 1;
360
+ } else bytes += 3;
361
+ } else bytes += 3;
362
+ }
363
+ return bytes;
364
+ }
365
+ function jsonUtf8ByteLength(value) {
366
+ return utf8ByteLength(JSON.stringify(value));
367
+ }
368
+ function fitWithinBudget(status, summary) {
369
+ const LIMIT = 230000; // headroom under MAX_RESULT_BYTES (262144) for the {output:...} wrapper + envelope fields
370
+ let returned = ranked.slice(0, MAX_RETURN_FINDINGS);
371
+ let truncated = ranked.length > returned.length;
372
+ let reportMarkdown = renderMarkdown(ranked, counts, staleDocs);
373
+ const sizeOf = () => jsonUtf8ByteLength(envelope(status, { summary, counts, findings: returned, truncatedFindings: truncated, reportMarkdown, staleDocs }));
374
+ if (sizeOf() > LIMIT) reportMarkdown = null;
375
+ while (sizeOf() > LIMIT && returned.length > 10) {
376
+ returned = returned.slice(0, Math.ceil(returned.length / 2));
377
+ truncated = true;
378
+ }
379
+ return envelope(status, { summary, counts, findings: returned, truncatedFindings: truncated, reportMarkdown, staleDocs });
380
+ }
381
+
382
+ const summary = `Found ${counts.total} cleanup finding(s): ${counts.high} high, ${counts.medium} medium, ${counts.low} low. Report-only — nothing applied.`;
383
+ return fitWithinBudget("ok", summary);