clud-bug 0.6.34 → 0.7.0-rc.11

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 (153) hide show
  1. package/README.md +20 -0
  2. package/bin/clud-bug.js +10 -1353
  3. package/data/canonical-v1.json +37 -0
  4. package/dist/cli/agents-md.d.ts +16 -0
  5. package/dist/cli/agents-md.d.ts.map +1 -0
  6. package/dist/cli/agents-md.js +226 -0
  7. package/dist/cli/agents-md.js.map +1 -0
  8. package/dist/cli/audit.d.ts +13 -0
  9. package/dist/cli/audit.d.ts.map +1 -0
  10. package/dist/cli/audit.js +90 -0
  11. package/dist/cli/audit.js.map +1 -0
  12. package/dist/cli/branch-protection.d.ts +57 -0
  13. package/dist/cli/branch-protection.d.ts.map +1 -0
  14. package/dist/cli/branch-protection.js +118 -0
  15. package/dist/cli/branch-protection.js.map +1 -0
  16. package/dist/cli/configure-github.d.ts +58 -0
  17. package/dist/cli/configure-github.d.ts.map +1 -0
  18. package/dist/cli/configure-github.js +242 -0
  19. package/dist/cli/configure-github.js.map +1 -0
  20. package/dist/cli/edit-workflow.d.ts +18 -0
  21. package/dist/cli/edit-workflow.d.ts.map +1 -0
  22. package/dist/cli/edit-workflow.js +43 -0
  23. package/dist/cli/edit-workflow.js.map +1 -0
  24. package/dist/cli/index.d.ts +9 -0
  25. package/dist/cli/index.d.ts.map +1 -0
  26. package/dist/cli/index.js +19 -0
  27. package/dist/cli/index.js.map +1 -0
  28. package/dist/cli/main.d.ts +3 -0
  29. package/dist/cli/main.d.ts.map +1 -0
  30. package/dist/cli/main.js +2067 -0
  31. package/dist/cli/main.js.map +1 -0
  32. package/dist/cli/skill-usage.d.ts +109 -0
  33. package/dist/cli/skill-usage.d.ts.map +1 -0
  34. package/dist/cli/skill-usage.js +380 -0
  35. package/dist/cli/skill-usage.js.map +1 -0
  36. package/dist/cli/skills.d.ts +56 -0
  37. package/dist/cli/skills.d.ts.map +1 -0
  38. package/dist/cli/skills.js +292 -0
  39. package/dist/cli/skills.js.map +1 -0
  40. package/dist/cli/update.d.ts +29 -0
  41. package/dist/cli/update.d.ts.map +1 -0
  42. package/dist/cli/update.js +205 -0
  43. package/dist/cli/update.js.map +1 -0
  44. package/dist/cli/usage.d.ts +142 -0
  45. package/dist/cli/usage.d.ts.map +1 -0
  46. package/dist/cli/usage.js +348 -0
  47. package/dist/cli/usage.js.map +1 -0
  48. package/dist/core/audit.d.ts +8 -0
  49. package/dist/core/audit.d.ts.map +1 -0
  50. package/dist/core/audit.js +47 -0
  51. package/dist/core/audit.js.map +1 -0
  52. package/dist/core/auto-resolve.d.ts +235 -0
  53. package/dist/core/auto-resolve.d.ts.map +1 -0
  54. package/dist/core/auto-resolve.js +293 -0
  55. package/dist/core/auto-resolve.js.map +1 -0
  56. package/dist/core/configure-github.d.ts +179 -0
  57. package/dist/core/configure-github.d.ts.map +1 -0
  58. package/dist/core/configure-github.js +289 -0
  59. package/dist/core/configure-github.js.map +1 -0
  60. package/dist/core/detect.d.ts +77 -0
  61. package/dist/core/detect.d.ts.map +1 -0
  62. package/dist/core/detect.js +262 -0
  63. package/dist/core/detect.js.map +1 -0
  64. package/dist/core/diff-findings.d.ts +96 -0
  65. package/dist/core/diff-findings.d.ts.map +1 -0
  66. package/dist/core/diff-findings.js +261 -0
  67. package/dist/core/diff-findings.js.map +1 -0
  68. package/dist/core/formal-review.d.ts +79 -0
  69. package/dist/core/formal-review.d.ts.map +1 -0
  70. package/dist/core/formal-review.js +71 -0
  71. package/dist/core/formal-review.js.map +1 -0
  72. package/dist/core/index.d.ts +17 -0
  73. package/dist/core/index.d.ts.map +1 -0
  74. package/dist/core/index.js +66 -0
  75. package/dist/core/index.js.map +1 -0
  76. package/dist/core/inline-threads.d.ts +160 -0
  77. package/dist/core/inline-threads.d.ts.map +1 -0
  78. package/dist/core/inline-threads.js +369 -0
  79. package/dist/core/inline-threads.js.map +1 -0
  80. package/dist/core/prompt-builder.d.ts +164 -0
  81. package/dist/core/prompt-builder.d.ts.map +1 -0
  82. package/dist/core/prompt-builder.js +419 -0
  83. package/dist/core/prompt-builder.js.map +1 -0
  84. package/dist/core/prompts.d.ts +9 -0
  85. package/dist/core/prompts.d.ts.map +1 -0
  86. package/dist/core/prompts.js +401 -0
  87. package/dist/core/prompts.js.map +1 -0
  88. package/dist/core/render-review.d.ts +6 -0
  89. package/dist/core/render-review.d.ts.map +1 -0
  90. package/dist/core/render-review.js +219 -0
  91. package/dist/core/render-review.js.map +1 -0
  92. package/dist/core/render.d.ts +13 -0
  93. package/dist/core/render.d.ts.map +1 -0
  94. package/dist/core/render.js +62 -0
  95. package/dist/core/render.js.map +1 -0
  96. package/dist/core/resolve-verifier.d.ts +46 -0
  97. package/dist/core/resolve-verifier.d.ts.map +1 -0
  98. package/dist/core/resolve-verifier.js +187 -0
  99. package/dist/core/resolve-verifier.js.map +1 -0
  100. package/dist/core/review-schema-zod.d.ts +240 -0
  101. package/dist/core/review-schema-zod.d.ts.map +1 -0
  102. package/dist/core/review-schema-zod.js +218 -0
  103. package/dist/core/review-schema-zod.js.map +1 -0
  104. package/dist/core/review-schema.d.ts +42 -0
  105. package/dist/core/review-schema.d.ts.map +1 -0
  106. package/dist/core/review-schema.js +156 -0
  107. package/dist/core/review-schema.js.map +1 -0
  108. package/dist/core/review-writeback.d.ts +193 -0
  109. package/dist/core/review-writeback.d.ts.map +1 -0
  110. package/dist/core/review-writeback.js +364 -0
  111. package/dist/core/review-writeback.js.map +1 -0
  112. package/dist/core/skills.d.ts +147 -0
  113. package/dist/core/skills.d.ts.map +1 -0
  114. package/dist/core/skills.js +726 -0
  115. package/dist/core/skills.js.map +1 -0
  116. package/dist/core/version.d.ts +2 -0
  117. package/dist/core/version.d.ts.map +1 -0
  118. package/dist/core/version.js +4 -0
  119. package/dist/core/version.js.map +1 -0
  120. package/package.json +37 -5
  121. package/{lib/agents-md.js → src/cli/agents-md.ts} +25 -14
  122. package/{lib/audit.js → src/cli/audit.ts} +37 -44
  123. package/{lib/branch-protection.js → src/cli/branch-protection.ts} +75 -11
  124. package/src/cli/configure-github.ts +329 -0
  125. package/{lib/edit-workflow.js → src/cli/edit-workflow.ts} +32 -11
  126. package/src/cli/index.ts +107 -0
  127. package/src/cli/main.ts +2204 -0
  128. package/{lib/skill-usage.js → src/cli/skill-usage.ts} +168 -94
  129. package/src/cli/skills.ts +386 -0
  130. package/{lib/update.js → src/cli/update.ts} +87 -27
  131. package/{lib/usage.js → src/cli/usage.ts} +167 -76
  132. package/src/core/audit.ts +53 -0
  133. package/src/core/auto-resolve.ts +488 -0
  134. package/src/core/configure-github.ts +497 -0
  135. package/{lib/detect.js → src/core/detect.ts} +100 -47
  136. package/src/core/diff-findings.ts +323 -0
  137. package/src/core/formal-review.ts +168 -0
  138. package/src/core/index.ts +250 -0
  139. package/src/core/inline-threads.ts +471 -0
  140. package/src/core/prompt-builder.ts +561 -0
  141. package/{lib/prompts.js → src/core/prompts.ts} +16 -2
  142. package/{lib/render-review.js → src/core/render-review.ts} +57 -25
  143. package/{lib/render.js → src/core/render.ts} +37 -17
  144. package/src/core/resolve-verifier.ts +228 -0
  145. package/src/core/review-schema-zod.ts +262 -0
  146. package/{lib/review-schema.js → src/core/review-schema.ts} +68 -5
  147. package/src/core/review-writeback.ts +565 -0
  148. package/{lib/skills.js → src/core/skills.ts} +456 -342
  149. package/src/core/version.ts +3 -0
  150. package/templates/clud-bug-review.md.tmpl +115 -0
  151. package/templates/workflow-py.yml.tmpl +80 -3
  152. package/templates/workflow-ts.yml.tmpl +80 -3
  153. package/templates/workflow.yml.tmpl +175 -8
@@ -0,0 +1,565 @@
1
+ // SPEC §1.8.1 doc-file renderer for `docs/reviews/PR-<n>.md`.
2
+ //
3
+ // This is the PURE rendering half of the App's `lib/review-writeback.ts`.
4
+ // The Octokit-side WRITEBACK (branch detection, idempotent rewrite,
5
+ // Contents-API commit) stays App-side — that surface depends on Octokit
6
+ // which we don't want to pull into core.
7
+ //
8
+ // Renamed from the App's `renderReview` to `renderReviewFile` to avoid
9
+ // colliding with the CLI's existing `renderReview` (which renders the
10
+ // summary-PR-comment shape, not the doc-file). Both renderers coexist:
11
+ // - `renderReview` (./render-review.ts) → `## 🐛 Clud Bug review` PR comment
12
+ // - `renderReviewFile` (this module) → `# clud-bug review — PR #N` doc file
13
+ //
14
+ // SPEC pins honored here:
15
+ // - `<!-- protocol-version: 0.1.0 -->` — SPEC version.
16
+ // - `<!-- written-by: clud-bug[bot] -->` — App identity, not Action.
17
+ // - `<!-- review-sha: <40-char-head-sha> -->` — pinned to review-time HEAD.
18
+ // - Severity bucket order: red → yellow → purple.
19
+ // - Empty buckets omitted entirely (no empty headers).
20
+ // - "Resolved this round:" and "Still open:" blocks omitted when empty
21
+ // (D.2.0 always omits — multi-pass is D.2.5).
22
+ // - Trailing `---\n[Link to PR](<url>)` line preserved verbatim.
23
+ // - Emoji codepoints: U+1F534, U+1F7E1, U+1F7E3, NFC-normalized.
24
+
25
+ import {
26
+ deriveSkillsReferenced,
27
+ deriveSummaryCounts,
28
+ flattenFindings,
29
+ type Finding,
30
+ type Review,
31
+ } from './review-schema-zod.js';
32
+
33
+ /** Protocol version this implementation emits. */
34
+ export const PROTOCOL_VERSION = '0.1.0';
35
+
36
+ /** "Written by" tag for the App writeback (SPEC §6.1). */
37
+ export const WRITTEN_BY = 'clud-bug[bot]';
38
+
39
+ // Severity emoji per SPEC §1.8.1. We define them by codepoint so a stray
40
+ // editor that switches encoding can't silently break byte-equality with
41
+ // the Action-runner output. Same constants as render-review.ts but kept
42
+ // local so this module is independent of the CLI renderer.
43
+ export const SEVERITY_EMOJI = {
44
+ critical: '\u{1F534}', // U+1F534 RED CIRCLE
45
+ minor: '\u{1F7E1}', // U+1F7E1 YELLOW CIRCLE
46
+ preexisting: '\u{1F7E3}', // U+1F7E3 PURPLE CIRCLE
47
+ } as const;
48
+
49
+ /**
50
+ * One parsed finding suitable for the SPEC §1.8.1 Resolved / Still-open
51
+ * blocks. Mirrors `ParsedFinding` from `./diff-findings.ts`; declared
52
+ * here as a structural interface (not re-imported) so callers without
53
+ * `diff-findings` in scope can still construct the input.
54
+ */
55
+ export interface RenderedFindingRef {
56
+ file: string;
57
+ line: number;
58
+ severity: 'critical' | 'minor' | 'preexisting';
59
+ skillName: string;
60
+ summary: string;
61
+ }
62
+
63
+ /**
64
+ * SPEC §6.7.3 cache telemetry — per-invocation token counts from the
65
+ * Anthropic-family `usage` block. When present, the renderer emits a
66
+ * `<!-- cache: ... -->` HTML comment immediately below the
67
+ * `<!-- review-sha: ... -->` metadata line so downstream cost analysis
68
+ * tooling can audit cache behaviour from the committed doc-file.
69
+ */
70
+ export interface CacheStats {
71
+ /** `cache_read_input_tokens` from the Anthropic usage block. */
72
+ cachedInputTokens: number;
73
+ /** `cache_creation_input_tokens` from the Anthropic usage block. */
74
+ cacheCreationInputTokens: number;
75
+ }
76
+
77
+ export interface RenderReviewFileInput {
78
+ review: Review;
79
+ prNumber: number;
80
+ /** 40-char head SHA. Pinned to `<!-- review-sha: ... -->`. */
81
+ headSha: string;
82
+ /** GitHub PR URL — appended verbatim to the trailing rule. */
83
+ prUrl: string;
84
+ /**
85
+ * Findings present in the prior `docs/reviews/PR-<n>.md` that are NOT
86
+ * present in this round's `review`. When non-empty, emits the SPEC
87
+ * §1.8.1 `**Resolved this round:**` block AFTER the severity buckets
88
+ * and BEFORE the trailing `---` separator. Omitted entirely when
89
+ * empty/undefined (SPEC §1.8.1: blocks MUST be omitted when empty).
90
+ *
91
+ * Produced by `diffFindings()` from `./diff-findings.ts`.
92
+ */
93
+ resolvedFindings?: RenderedFindingRef[];
94
+ /**
95
+ * Findings present in BOTH the prior `docs/reviews/PR-<n>.md` AND
96
+ * this round's `review` — the persistent ones. When non-empty, emits
97
+ * the SPEC §1.8.1 `**Still open:**` block AFTER `**Resolved this
98
+ * round:**` and before the trailing `---` separator. Omitted entirely
99
+ * when empty/undefined.
100
+ *
101
+ * Produced by `diffFindings()` from `./diff-findings.ts`.
102
+ */
103
+ stillOpenFindings?: RenderedFindingRef[];
104
+ /**
105
+ * SPEC §6.7.3 cache telemetry. When present, emits a
106
+ * `<!-- cache: <read> read · <created> created -->` HTML comment
107
+ * immediately below the `<!-- review-sha: ... -->` metadata line.
108
+ * Omitted entirely when undefined (this comment is OPTIONAL per the
109
+ * SPEC, but SHOULD be implemented for cost-analysis tooling).
110
+ */
111
+ cacheStats?: CacheStats;
112
+ }
113
+
114
+ /**
115
+ * Renders the review object to the SPEC §1.8.1 markdown template.
116
+ *
117
+ * Pure: no I/O, no time-of-day, no provider info — fixture-stable.
118
+ *
119
+ * NB: This produces the doc-file shape (`# clud-bug review — PR #N` H1).
120
+ * The CLI's `renderReview` produces the PR-comment shape (`## 🐛 Clud Bug
121
+ * review` H2). Both are valid review outputs; SPEC §6.2 says they share
122
+ * the underlying finding data but differ in container.
123
+ */
124
+ export function renderReviewFile(input: RenderReviewFileInput): string {
125
+ const {
126
+ review,
127
+ prNumber,
128
+ headSha,
129
+ prUrl,
130
+ resolvedFindings,
131
+ stillOpenFindings,
132
+ cacheStats,
133
+ } = input;
134
+ // Wire-shape Review carries findings in 3 severity arrays. Flatten to
135
+ // internal `Finding[]` so the renderer's bucketing, count-derivation,
136
+ // and per-skill aggregation can work uniformly.
137
+ const findings = flattenFindings(review);
138
+
139
+ // Always derive these from findings to guarantee they match what we
140
+ // actually render. The model can drift on counts; we don't trust it.
141
+ const counts = deriveSummaryCounts(findings);
142
+ const skillsReferenced = deriveSkillsReferenced(findings);
143
+
144
+ const lines: string[] = [];
145
+
146
+ lines.push(`# clud-bug review — PR #${prNumber}`);
147
+ lines.push(`<!-- protocol-version: ${PROTOCOL_VERSION} -->`);
148
+ lines.push(`<!-- written-by: ${WRITTEN_BY} -->`);
149
+ lines.push(`<!-- review-sha: ${headSha} -->`);
150
+ // SPEC §6.7.3: cache telemetry comment goes immediately below the
151
+ // review-sha so a single grep can pull review-sha + cache stats out
152
+ // of a doc-file without re-parsing the whole block. Omitted entirely
153
+ // when cacheStats is undefined.
154
+ if (cacheStats !== undefined) {
155
+ lines.push(
156
+ `<!-- cache: ${cacheStats.cachedInputTokens} read · ${cacheStats.cacheCreationInputTokens} created -->`,
157
+ );
158
+ }
159
+ lines.push('');
160
+
161
+ // Summary line — SPEC §1.8.1 wording.
162
+ lines.push(
163
+ `**Summary:** ${counts.critical} critical · ${counts.minor} minor · ${counts.preexisting} preexisting · ${counts.resolved_from_prior} resolved-from-prior · ${counts.still_open} still-open`,
164
+ );
165
+ lines.push('');
166
+
167
+ // Skills cited block — group findings per skill for citation counts.
168
+ lines.push('**Skills cited:**');
169
+ if (skillsReferenced.length === 0) {
170
+ lines.push('- _(none — see summary above)_');
171
+ } else {
172
+ for (const slug of skillsReferenced) {
173
+ const count = findings.filter((f) => f.skill === slug).length;
174
+ lines.push(`- ${slug} (${count} finding${count === 1 ? '' : 's'})`);
175
+ }
176
+ }
177
+ lines.push('');
178
+
179
+ lines.push('**Findings:**');
180
+ lines.push('');
181
+
182
+ // Severity buckets in SPEC order; empty buckets are omitted entirely.
183
+ const bucketed = bucketBySeverity(findings);
184
+ if (bucketed.critical.length > 0) {
185
+ lines.push(`### ${SEVERITY_EMOJI.critical} Critical`);
186
+ for (const f of bucketed.critical) lines.push(renderFinding(f, true));
187
+ lines.push('');
188
+ }
189
+ if (bucketed.minor.length > 0) {
190
+ lines.push(`### ${SEVERITY_EMOJI.minor} Minor`);
191
+ for (const f of bucketed.minor) lines.push(renderFinding(f, false));
192
+ lines.push('');
193
+ }
194
+ if (bucketed.preexisting.length > 0) {
195
+ lines.push(`### ${SEVERITY_EMOJI.preexisting} Preexisting (informational)`);
196
+ for (const f of bucketed.preexisting) lines.push(renderFinding(f, false));
197
+ lines.push('');
198
+ }
199
+
200
+ // SPEC §1.8.1 Resolved this round / Still open blocks. Omitted
201
+ // entirely when the corresponding input list is empty/undefined.
202
+ //
203
+ // Bullet shape (one finding per line):
204
+ // - `path/file.ts:42` — `critical-issues-only`: null-deref ... (was 🔴 Critical)
205
+ //
206
+ // The `(was <emoji> <Severity>)` suffix carries the SEVERITY at the
207
+ // time the finding was last seen so a downgrade across rounds is
208
+ // visible without diffing the two doc files. Note: backticks around
209
+ // file and skill render in markdown viewers as inline-code, which
210
+ // makes the file path scannable in the GitHub UI.
211
+ if (resolvedFindings !== undefined && resolvedFindings.length > 0) {
212
+ lines.push('**Resolved this round:**');
213
+ for (const f of resolvedFindings) {
214
+ lines.push(renderDiffFinding(f));
215
+ }
216
+ lines.push('');
217
+ }
218
+ if (stillOpenFindings !== undefined && stillOpenFindings.length > 0) {
219
+ lines.push('**Still open:**');
220
+ for (const f of stillOpenFindings) {
221
+ lines.push(renderDiffFinding(f));
222
+ }
223
+ lines.push('');
224
+ }
225
+
226
+ lines.push('---');
227
+ lines.push('');
228
+ lines.push(`[Link to PR](${prUrl})`);
229
+
230
+ // Final NFC normalization — guarantees the emoji codepoints stay
231
+ // composed even if a future renderer step decomposes them.
232
+ return lines.join('\n').normalize('NFC') + '\n';
233
+ }
234
+
235
+ function bucketBySeverity(findings: Finding[]): Record<
236
+ 'critical' | 'minor' | 'preexisting',
237
+ Finding[]
238
+ > {
239
+ return {
240
+ critical: findings.filter((f) => f.severity === 'critical'),
241
+ minor: findings.filter((f) => f.severity === 'minor'),
242
+ preexisting: findings.filter((f) => f.severity === 'preexisting'),
243
+ };
244
+ }
245
+
246
+ /**
247
+ * Renders one finding for the SPEC §1.8.1 Resolved / Still-open blocks.
248
+ * Output shape:
249
+ *
250
+ * - `path/file.ts:42` — `critical-issues-only`: null-deref ... (was 🔴 Critical)
251
+ *
252
+ * Line `0` is treated as "no anchor" (cross-cutting / missing) and the
253
+ * `:N` suffix is omitted. The severity label uses the SPEC §1.8.1
254
+ * emoji + label so a reader can see at a glance whether the finding
255
+ * was downgraded (different severity in current round) or merely
256
+ * resolved.
257
+ */
258
+ function renderDiffFinding(f: RenderedFindingRef): string {
259
+ const location = f.line > 0 ? `${f.file}:${f.line}` : f.file;
260
+ const label =
261
+ f.severity === 'critical'
262
+ ? 'Critical'
263
+ : f.severity === 'minor'
264
+ ? 'Minor'
265
+ : 'Preexisting';
266
+ const emoji = SEVERITY_EMOJI[f.severity];
267
+ return `- \`${location}\` — \`${f.skillName}\`: ${f.summary} (was ${emoji} ${label})`;
268
+ }
269
+
270
+ function renderFinding(f: Finding, includeReasoning: boolean): string {
271
+ // findingItemSchema.file is z.string().optional() — the model is
272
+ // instructed to always provide it, but Zod doesn't enforce. Without
273
+ // the fallback an absent file would emit literal "undefined" in the
274
+ // committed SPEC §1.8.1 doc file. clud-bug-review #158 flagged this.
275
+ const fileLabel = f.file ?? '(unknown file)';
276
+ const location = f.line ? `${fileLabel}:${f.line}` : fileLabel;
277
+ // Per SPEC §1.8.1: "**<file>:<line>** — <skill-name>: <one-line summary>".
278
+ const head = `- **${location}** — ${f.skill}: ${f.summary}`;
279
+ // Reasoning line is documented for the Critical bucket; we follow the
280
+ // SPEC template strictly. For Minor/Preexisting, no reasoning line.
281
+ if (includeReasoning && f.reasoning) {
282
+ return `${head}\n Reasoning: ${f.reasoning}`;
283
+ }
284
+ return head;
285
+ }
286
+
287
+ // ---------------------------------------------------------------------------
288
+ // D.2.5 multi-pass renderer
289
+ // ---------------------------------------------------------------------------
290
+
291
+ /**
292
+ * Provenance label for a single finding's attribution from one pass.
293
+ * Mirrors the App's `PassSource` discriminator.
294
+ *
295
+ * `mode === 'cross-check'`:
296
+ * - 'first' → finding raised by Pass 1
297
+ * - 'agreed' → later pass agreed
298
+ * - 'disagreed' → later pass disagreed
299
+ * - 'independent' → later pass surfaced this finding independently
300
+ *
301
+ * `mode === 'consensus'`:
302
+ * - 'first' → finding tuple unique to Pass 1
303
+ * - 'independent' → finding tuple unique to Pass N (N > 1)
304
+ * - 'agreed' → finding tuple appeared in 2+ passes (consensus)
305
+ */
306
+ export type PassSource = 'first' | 'agreed' | 'disagreed' | 'independent';
307
+
308
+ export interface PassAttribution {
309
+ /** 1-indexed pass number — matches the spec's "[Pass N]" label. */
310
+ passNumber: number;
311
+ /** Role display name, e.g. "Beetle". */
312
+ roleName: string;
313
+ /** Model slug for this pass — used by the renderer for the "· Sonnet 4.6" tail. */
314
+ model: string;
315
+ /** Provenance — see PassSource doc. */
316
+ source: PassSource;
317
+ /** Optional one-line note from the pass, e.g. cross-check rationale. */
318
+ note?: string;
319
+ }
320
+
321
+ export interface UnifiedFinding extends Finding {
322
+ /** One PassAttribution per pass involved with this finding. Order: by passNumber. */
323
+ attributions: PassAttribution[];
324
+ }
325
+
326
+ /** Effective resolution verdict the multi-pass orchestrator emits. */
327
+ export type MultiPassVerdict = 'request_changes' | 'review_only' | 'clean';
328
+
329
+ /** Effective multi-pass mode. */
330
+ export type ReviewPassMode = 'cross-check' | 'consensus' | 'independent';
331
+
332
+ export interface MultiPassReview {
333
+ /** Status header — derived from aggregated findings + mode resolution rules. */
334
+ status_header: Review['status_header'];
335
+ /** Summary counts, derived from the unified findings list. */
336
+ summary_counts: Review['summary_counts'];
337
+ /** Skills cited at least once across any pass. */
338
+ skills_referenced: string[];
339
+ /** Unified findings, with per-pass attribution. */
340
+ findings: UnifiedFinding[];
341
+ /** Effective mode (for the renderer's "(N passes · mode)" header line). */
342
+ mode: ReviewPassMode;
343
+ /** Number of passes that actually ran. */
344
+ passCount: number;
345
+ /** Role labels per pass, parallel to passCount. Used by the renderer. */
346
+ roles: Array<{ passNumber: number; roleName: string; model: string }>;
347
+ /** Resolution verdict — see App's multi-pass-aggregator for derivation. */
348
+ verdict: MultiPassVerdict;
349
+ }
350
+
351
+ export interface RenderMultiPassMarkdownInput {
352
+ review: MultiPassReview;
353
+ prNumber: number;
354
+ /** 40-char head SHA. Pinned to `<!-- review-sha: ... -->`. */
355
+ headSha: string;
356
+ /** GitHub PR URL — appended verbatim to the trailing rule. */
357
+ prUrl: string;
358
+ }
359
+
360
+ /**
361
+ * Renders a multi-pass review with per-pass attribution lines.
362
+ *
363
+ * Output layout (SPEC §1.8.5):
364
+ *
365
+ * # clud-bug review — PR #N (M passes · mode)
366
+ * <!-- protocol-version: ... -->
367
+ * <!-- written-by: clud-bug[bot] -->
368
+ * <!-- review-sha: ... -->
369
+ * <!-- passes: M -->
370
+ * <!-- mode: cross-check|consensus|independent -->
371
+ *
372
+ * **Summary:** ... · **Verdict:** request_changes / review_only / clean
373
+ *
374
+ * **Reviewers:**
375
+ * - Pass 1 — Beetle · anthropic/claude-sonnet-4.6
376
+ * - Pass 2 — Wasp · anthropic/claude-opus-4.7
377
+ *
378
+ * **Skills cited:** ...
379
+ *
380
+ * **Findings:**
381
+ *
382
+ * ### (red) Critical
383
+ * - [Pass 1 — Beetle · Sonnet 4.6] auth.ts:42 — race-conditions: Race condition
384
+ * Reasoning: ...
385
+ * [Pass 2 — Wasp · Opus 4.7]: (check) AGREED — same finding identified independently.
386
+ *
387
+ * ...
388
+ *
389
+ * ---
390
+ * [Link to PR](...)
391
+ *
392
+ * Pure: no I/O, no time-of-day, fixture-stable.
393
+ */
394
+ export function renderMultiPassMarkdown(
395
+ input: RenderMultiPassMarkdownInput,
396
+ ): string {
397
+ const { review, prNumber, headSha, prUrl } = input;
398
+ const lines: string[] = [];
399
+
400
+ lines.push(
401
+ `# clud-bug review — PR #${prNumber} (${review.passCount} ${
402
+ review.passCount === 1 ? 'pass' : 'passes'
403
+ } · ${review.mode})`,
404
+ );
405
+ lines.push(`<!-- protocol-version: ${PROTOCOL_VERSION} -->`);
406
+ lines.push(`<!-- written-by: ${WRITTEN_BY} -->`);
407
+ lines.push(`<!-- review-sha: ${headSha} -->`);
408
+ lines.push(`<!-- passes: ${review.passCount} -->`);
409
+ lines.push(`<!-- mode: ${review.mode} -->`);
410
+ lines.push('');
411
+
412
+ const counts = review.summary_counts;
413
+ lines.push(
414
+ `**Summary:** ${counts.critical} critical · ${counts.minor} minor · ${counts.preexisting} preexisting · ${counts.resolved_from_prior} resolved-from-prior · ${counts.still_open} still-open · **Verdict:** ${review.verdict}`,
415
+ );
416
+ lines.push('');
417
+
418
+ lines.push('**Reviewers:**');
419
+ for (const r of review.roles) {
420
+ lines.push(`- Pass ${r.passNumber} — ${r.roleName} · ${r.model}`);
421
+ }
422
+ lines.push('');
423
+
424
+ lines.push('**Skills cited:**');
425
+ if (review.skills_referenced.length === 0) {
426
+ lines.push('- _(none — see summary above)_');
427
+ } else {
428
+ for (const slug of review.skills_referenced) {
429
+ const count = review.findings.filter((f) => f.skill === slug).length;
430
+ lines.push(`- ${slug} (${count} finding${count === 1 ? '' : 's'})`);
431
+ }
432
+ }
433
+ lines.push('');
434
+
435
+ lines.push('**Findings:**');
436
+ lines.push('');
437
+
438
+ // Severity buckets in SPEC order; empty buckets are omitted entirely.
439
+ const bucketed = bucketUnifiedBySeverity(review.findings);
440
+ if (bucketed.critical.length > 0) {
441
+ lines.push(`### ${SEVERITY_EMOJI.critical} Critical`);
442
+ for (const f of bucketed.critical)
443
+ lines.push(renderUnifiedFinding(f, /* includeReasoning */ true));
444
+ lines.push('');
445
+ }
446
+ if (bucketed.minor.length > 0) {
447
+ lines.push(`### ${SEVERITY_EMOJI.minor} Minor`);
448
+ for (const f of bucketed.minor) lines.push(renderUnifiedFinding(f, false));
449
+ lines.push('');
450
+ }
451
+ if (bucketed.preexisting.length > 0) {
452
+ lines.push(`### ${SEVERITY_EMOJI.preexisting} Preexisting (informational)`);
453
+ for (const f of bucketed.preexisting)
454
+ lines.push(renderUnifiedFinding(f, false));
455
+ lines.push('');
456
+ }
457
+
458
+ lines.push('---');
459
+ lines.push('');
460
+ lines.push(`[Link to PR](${prUrl})`);
461
+
462
+ return lines.join('\n').normalize('NFC') + '\n';
463
+ }
464
+
465
+ function bucketUnifiedBySeverity(findings: UnifiedFinding[]): Record<
466
+ 'critical' | 'minor' | 'preexisting',
467
+ UnifiedFinding[]
468
+ > {
469
+ return {
470
+ critical: findings.filter((f) => f.severity === 'critical'),
471
+ minor: findings.filter((f) => f.severity === 'minor'),
472
+ preexisting: findings.filter((f) => f.severity === 'preexisting'),
473
+ };
474
+ }
475
+
476
+ /**
477
+ * Renders one finding with inline per-pass attribution. The headline carries
478
+ * the FIRST attribution (whichever pass raised the issue); subsequent
479
+ * attributions appear on indented sub-lines:
480
+ *
481
+ * - [Pass 1 — Beetle · sonnet] auth.ts:42 — race: Race condition
482
+ * Reasoning: ...
483
+ * [Pass 2 — Wasp · opus]: (check) AGREED — confirmed by independent review.
484
+ * [Pass 3 — Mantis · opus]: (x) DISAGREED — guarded by the surrounding lock.
485
+ *
486
+ * The headline always tracks the FIRST attribution to preserve the SPEC
487
+ * §1.8.1 grep pattern (`**<file>:<line>** — <skill>: <summary>`).
488
+ */
489
+ function renderUnifiedFinding(
490
+ f: UnifiedFinding,
491
+ includeReasoning: boolean,
492
+ ): string {
493
+ // Same guard as renderFinding — findingItemSchema.file is optional.
494
+ const fileLabel = f.file ?? '(unknown file)';
495
+ const location = f.line ? `${fileLabel}:${f.line}` : fileLabel;
496
+ const head = f.attributions[0];
497
+ if (!head) {
498
+ // Defensive — shouldn't happen; the aggregator guarantees ≥1 attribution.
499
+ return `- **${location}** — ${f.skill}: ${f.summary}`;
500
+ }
501
+ const headLabel = formatAttributionLabel(head);
502
+ const lines: string[] = [];
503
+ lines.push(`- ${headLabel} **${location}** — ${f.skill}: ${f.summary}`);
504
+ if (includeReasoning && f.reasoning) {
505
+ lines.push(` Reasoning: ${f.reasoning}`);
506
+ }
507
+ for (let i = 1; i < f.attributions.length; i++) {
508
+ const a = f.attributions[i];
509
+ if (!a) continue;
510
+ lines.push(` ${formatFollowupAttribution(a)}`);
511
+ }
512
+ return lines.join('\n');
513
+ }
514
+
515
+ /**
516
+ * The leading bracket on the head line. Example:
517
+ *
518
+ * [Pass 1 — Beetle · anthropic/claude-sonnet-4.6]
519
+ * [Pass 2 — Wasp · anthropic/claude-opus-4.7 — found independently]
520
+ *
521
+ * "found independently" only fires when the head attribution is NOT
522
+ * `source: 'first'` — i.e. when a later pass surfaced this finding without
523
+ * Pass 1 raising it.
524
+ */
525
+ function formatAttributionLabel(a: PassAttribution): string {
526
+ const independent =
527
+ a.source === 'independent' && a.passNumber > 1
528
+ ? ' — found independently'
529
+ : '';
530
+ return `[Pass ${a.passNumber} — ${a.roleName} · ${a.model}${independent}]`;
531
+ }
532
+
533
+ /**
534
+ * Subsequent-line attribution. Example:
535
+ *
536
+ * [Pass 2 — Wasp · opus]: ✅ AGREED — confirmed.
537
+ * [Pass 3 — Mantis · opus]: ❌ DISAGREED — guarded by surrounding lock.
538
+ */
539
+ function formatFollowupAttribution(a: PassAttribution): string {
540
+ const verdictSymbol =
541
+ a.source === 'agreed'
542
+ ? '✅ AGREED' // U+2705 WHITE HEAVY CHECK MARK
543
+ : a.source === 'disagreed'
544
+ ? '❌ DISAGREED' // U+274C CROSS MARK
545
+ : a.source === 'independent'
546
+ ? 'INDEPENDENTLY FLAGGED'
547
+ : 'NOTED';
548
+ const note = a.note ? ` — ${a.note}` : '';
549
+ const disputed = a.source === 'disagreed' ? ' (Disputed — human decides.)' : '';
550
+ return `[Pass ${a.passNumber} — ${a.roleName} · ${a.model}]: ${verdictSymbol}${note}${disputed}`;
551
+ }
552
+
553
+ /** SPEC §6.1 / §1.8 path. The App's Octokit writeback uses this. */
554
+ export function reviewFilePath(prNumber: number): string {
555
+ return `docs/reviews/PR-${prNumber}.md`;
556
+ }
557
+
558
+ /**
559
+ * SPEC §6.1 commit message: exactly `[skip-logmind] clud-bug review: PR #<n>`.
560
+ * The `[skip-logmind]` prefix tells `check-decisions.yml` to ignore this
561
+ * commit (SPEC §6.4). The App's Octokit writeback uses this.
562
+ */
563
+ export function reviewCommitMessage(prNumber: number): string {
564
+ return `[skip-logmind] clud-bug review: PR #${prNumber}`;
565
+ }