clud-bug 0.6.20 → 0.6.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/clud-bug.js CHANGED
@@ -31,6 +31,8 @@ function parseArgs(argv) {
31
31
  setProtection: true, quiet: false,
32
32
  // 0.0.M.1 (v0.6.13): `clud-bug usage` flags.
33
33
  repo: null, pr: null, limit: null, json: false,
34
+ // 0.0.O (v0.6.22): `clud-bug render` reads its payload from stdin.
35
+ stdin: false,
34
36
  };
35
37
  for (let i = 0; i < argv.length; i++) {
36
38
  const a = argv[i];
@@ -49,6 +51,7 @@ function parseArgs(argv) {
49
51
  else if (a === '--pr') args.pr = Number(argv[++i]);
50
52
  else if (a === '--limit') args.limit = Number(argv[++i]);
51
53
  else if (a === '--json') args.json = true;
54
+ else if (a === '--stdin') args.stdin = true;
52
55
  else args._.push(a);
53
56
  }
54
57
  return args;
@@ -79,6 +82,11 @@ Commands:
79
82
  eval Run the golden-set regression gate against the rendered review
80
83
  prompt (must-contain / must-not-contain / byte-budget). Same as
81
84
  \`node --test test/prompts.eval.test.js\` but works from any cwd.
85
+ render --stdin Render a structured-output JSON payload (the action's
86
+ \`outputs.structured_output\`, piped via stdin) to the
87
+ GitHub-markdown summary comment shape. Invoked by the
88
+ workflow post-step; output is what \`gh pr comment\`
89
+ receives. Empty stdin or non-object payload exits 2.
82
90
 
83
91
  Options:
84
92
  --offline Skip skills.sh; pin only the bundled baseline specimens.
@@ -130,12 +138,53 @@ async function main() {
130
138
  case 'edit-workflow': return runEditWorkflow(args);
131
139
  case 'usage': return runUsage(args);
132
140
  case 'eval': return runEval();
141
+ case 'render': return runRender(args);
133
142
  default:
134
143
  process.stderr.write(`Unknown command: ${cmd || '(none)'}\n\n${HELP}`);
135
144
  process.exit(2);
136
145
  }
137
146
  }
138
147
 
148
+ // 0.0.O (v0.6.22): render a structured-output JSON payload to the
149
+ // GitHub-markdown summary comment shape. Called by the post-step
150
+ // in the workflow templates: it reads the action's
151
+ // `outputs.structured_output` (one bundled JSON string), pipes it
152
+ // to stdin here, and we emit the rendered markdown on stdout for
153
+ // the shell to pass to `gh pr comment --body`.
154
+ //
155
+ // Usage: `clud-bug render --stdin` (only input source supported).
156
+ // Exit code: 0 on success, 2 on JSON parse error or non-object payload.
157
+ async function runRender(args) {
158
+ const { renderReview } = await import('../lib/render-review.js');
159
+ if (!args.stdin) {
160
+ process.stderr.write('clud-bug render: --stdin is required (the only supported input source).\n');
161
+ process.exit(2);
162
+ }
163
+ let raw = '';
164
+ for await (const chunk of process.stdin) raw += chunk;
165
+ raw = raw.trim();
166
+ if (!raw) {
167
+ // Empty structured_output → post-step is supposed to skip the
168
+ // render. Surface the situation rather than silently producing an
169
+ // empty comment.
170
+ process.stderr.write('clud-bug render: stdin was empty — nothing to render.\n');
171
+ process.exit(2);
172
+ }
173
+ let payload;
174
+ try {
175
+ payload = JSON.parse(raw);
176
+ } catch (e) {
177
+ process.stderr.write(`clud-bug render: JSON parse failed: ${e.message}\n`);
178
+ process.exit(2);
179
+ }
180
+ try {
181
+ process.stdout.write(renderReview(payload));
182
+ } catch (e) {
183
+ process.stderr.write(`clud-bug render: ${e.message}\n`);
184
+ process.exit(2);
185
+ }
186
+ }
187
+
139
188
  // 0.0.E (v0.6.17): thin wrapper around the golden-set test file. Devs
140
189
  // who follow the README invoke `clud-bug eval` — this routes to the
141
190
  // same `node --test` runner CI uses, so dev and CI verdicts match.
@@ -220,6 +269,7 @@ async function runInit(args) {
220
269
  log(' drafting field kit...');
221
270
  const tmplName = pickTemplate(signals.languages);
222
271
  const tmplPath = join(TEMPLATES, tmplName);
272
+ // REVIEW_SCHEMA + CCA_VERSION + CLUD_BUG_VERSION come from render.js DEFAULTS.
223
273
  const workflow = await renderFile(tmplPath, {
224
274
  REVIEW_PROMPT: reviewPrompt({
225
275
  projectDescription: buildDescriptionLine(signals),
package/lib/prompts.js CHANGED
@@ -68,20 +68,24 @@ cached system prefix is free at 10%; per-PR fetches are not.
68
68
  state lives in your PRIOR SUMMARY COMMENT as an HTML marker:
69
69
  \`<!-- last-reviewed-sha: <sha> -->\`.
70
70
 
71
- Identifying the PRIOR SUMMARY: claude-code-action posts an
72
- in-progress \`Claude Code is working…\` comment BEFORE this
73
- prompt runs claude[bot]-authored but NOT the summary (no
74
- marker). Anchor on the H2 line \`## 🐛 Clud Bug review\` instead
75
- of "latest claude[bot] comment" same anchor strict-mode uses.
71
+ Identifying the PRIOR SUMMARY (v0.6.22+ identity note):
72
+ From v0.6.22 (0.0.O) onward the summary is posted by a workflow
73
+ post-step under the \`github-actions[bot]\` identity, not
74
+ claude[bot]. Inline review threads are still claude[bot] (the
75
+ MCP tool routes through claude-code-action). Beware: the
76
+ in-progress \`Claude Code is working…\` comment claude-code-action
77
+ posts BEFORE this prompt runs is claude[bot]-authored but is NOT
78
+ your prior summary (no marker). Anchor on the H2 line
79
+ \`## 🐛 Clud Bug review\` — same anchor strict-mode uses.
76
80
 
77
81
  Detection in three steps:
78
82
 
79
- 1. Fetch claude[bot] comments newest-first:
83
+ 1. Fetch github-actions[bot] comments newest-first:
80
84
  \`gh api "repos/$REPO_OWNER/$REPO_NAME/issues/$PR_NUMBER/comments?per_page=100&sort=created&direction=desc"\`
81
- Walk them in order; find the FIRST whose body starts
82
- (after any \`**Claude finished …**\` preamble the action
83
- prepends) with \`## 🐛 Clud Bug review\`. THAT is your prior
84
- summary. In its body, look for \`last-reviewed-sha: <sha>\`.
85
+ Walk them in order; filter to author \`github-actions[bot]\`,
86
+ and find the FIRST whose body starts with
87
+ \`## 🐛 Clud Bug review\`. THAT is your prior summary. In
88
+ its body, look for \`last-reviewed-sha: <sha>\`. (Pre-v0.6.22 summaries are claude[bot]-authored — if you find a recent claude[bot] comment with the H2 anchor and no newer github-actions[bot] match, treat it as the prior summary.)
85
89
 
86
90
  2. If a SHA was found, verify it's still in HEAD's ancestry:
87
91
  \`git merge-base --is-ancestor <prior_sha> $HEAD_SHA\`
@@ -105,7 +109,7 @@ cached system prefix is free at 10%; per-PR fetches are not.
105
109
  per file (default 4,000 bytes). Baseline skills fit easily;
106
110
  bloated user-added skills get truncated.
107
111
 
108
- - PR comments: \`gh api "repos/$REPO_OWNER/$REPO_NAME/issues/$PR_NUMBER/comments?per_page=20" --jq '.[] | select(.user.login != "claude[bot]")' | head -c "$MAX_COMMENT_BYTES"\`
112
+ - PR comments: \`gh api "repos/$REPO_OWNER/$REPO_NAME/issues/$PR_NUMBER/comments?per_page=20" --jq '.[] | select(.user.login != "claude[bot]" and .user.login != "github-actions[bot]")' | head -c "$MAX_COMMENT_BYTES"\`
109
113
  (default 20,000 bytes, 20 most-recent). Skips your own prior
110
114
  comments — the FIX-PUSH FLOW handles those via reviewThreads
111
115
  GraphQL instead.
@@ -148,7 +152,17 @@ Each SKILL.md frontmatter (first \`---\`-delimited block) has a
148
152
  API-contract, test-discipline). Each gets its own H3 section.
149
153
  - Missing → treat as \`shared\`.
150
154
 
151
- Read each capped: \`head -c "$MAX_SKILL_BYTES" .claude/skills/*/SKILL.md\`
155
+ Skill applies_to (v0.6.21 / 0.0.K):
156
+ Frontmatter may also have \`applies_to:\` with \`paths:\` (glob list)
157
+ and/or \`extensions:\` (extension list). Scan each skill's frontmatter
158
+ first (cheap — just the \`---\` block). If applies_to is present and
159
+ the PR's changed files (from \`gh pr diff --name-only\`) match NONE
160
+ of the declared paths or extensions, SKIP that skill's body — don't
161
+ read it, don't reference it. Skills without applies_to load
162
+ unconditionally (back-compat). Net effect: a UI-scoped skill stays
163
+ unread on a backend-only PR.
164
+
165
+ Read each applicable body capped: \`head -c "$MAX_SKILL_BYTES" .claude/skills/<name>/SKILL.md\`
152
166
 
153
167
  At review end, append a single-line footer:
154
168
  Skills referenced: [skill-name-1, skill-name-2, ...]
@@ -175,13 +189,18 @@ subsequent fix-push re-fetches only the delta since this SHA. Omit
175
189
  it and the next review falls back to full \`gh pr diff\`.
176
190
 
177
191
  Strict-mode header (opt-in): if .claude/skills/.clud-bug.json has
178
- \`{ "strictMode": true }\`, the H2 header MUST signal verdict:
179
- - any critical issue flagged → \`## 🐛 Clud Bug review — critical findings\`
180
- - otherwise \`## 🐛 Clud Bug review — clean\`
181
- A post-step greps for "critical findings" and fails the check.
182
-
183
- If strictMode is unset or absent, keep the bare \`## 🐛 Clud Bug review\`
184
- header strict mode is opt-in.
192
+ \`{ "strictMode": true }\`, set the \`status_header\` schema field
193
+ to signal verdict:
194
+ - any critical issue flagged \`status_header: "critical findings"\`
195
+ (renderer emits \`## 🐛 Clud Bug review critical findings\`)
196
+ - otherwise → \`status_header: "clean"\` (renderer emits
197
+ \`## 🐛 Clud Bug review — clean\`)
198
+ The strict-mode gate post-step greps for "critical findings" and
199
+ fails the check.
200
+
201
+ If strictMode is unset or absent, set \`status_header: "bare"\` —
202
+ the renderer emits the bare \`## 🐛 Clud Bug review\` (no suffix),
203
+ matching the v0.6.21- visible behaviour for non-strict-mode repos.
185
204
 
186
205
  Tone: conversational, concise field-naturalist voice (you are Clud
187
206
  Bug examining specimens of code) — never at the cost of clarity,
@@ -204,17 +223,24 @@ Your review lives in TWO surfaces, in this order:
204
223
  Cross-cutting findings (no specific line) stay summary-only —
205
224
  but default to inline whenever you can name file:line.
206
225
 
207
- 2. SUMMARY PR COMMENT — one top-level \`gh pr comment\` carrying
208
- the H2 header, status line, per-skill scan, and per-skill
209
- findings sections. The strict-mode gate greps the H2 for
210
- "— critical findings" keep it intact even when also using a
211
- strict-mode variant.
226
+ 2. SUMMARY PR COMMENT — emitted as STRUCTURED JSON via the
227
+ workflow's \`--json-schema\` flag (0.0.O / v0.6.22+). Do NOT
228
+ post the summary yourself via \`gh pr comment\` — a post-step
229
+ reads your structured output and renders the comment with the
230
+ exact format the strict-mode gate expects. Populate every
231
+ schema field (\`status_header\`, \`summary_counts\`,
232
+ \`per_skill_scan\`, \`critical_findings\`, \`minor_findings\`,
233
+ \`preexisting_findings\`, \`skills_referenced\`,
234
+ \`last_reviewed_sha\`); \`dedicated_sections\` and
235
+ \`diagnostics\` are optional but emit them when applicable.
236
+ See workflow env for the schema; the format docs below describe
237
+ what each field becomes after rendering.
212
238
 
213
239
  The comment body MUST start with:
214
240
 
215
241
  ## 🐛 Clud Bug review
216
242
 
217
- (claude[bot] is the bot login, but the header brands it Clud Bug.)
243
+ (The post-step renders this H2 anchor the strict-mode gate greps it.)
218
244
 
219
245
  On the next non-empty line, emit:
220
246
 
@@ -287,14 +313,16 @@ Shared-mode skill findings stay in the combined "Critical findings"
287
313
  (e.g. a logging-PII issue belongs in both critical-issues-only and
288
314
  pii-and-compliance at once).
289
315
 
290
- Post the summary:
291
- gh pr comment "$PR_NUMBER" --body "<your review>"
316
+ Emit the summary as structured JSON output (the workflow's
317
+ --json-schema captures it; a post-step renders + posts via gh pr
318
+ comment). Do NOT post the summary yourself.
292
319
 
293
- Inline findings post via mcp__github_inline_comment__create_inline_comment
320
+ Inline findings still post via mcp__github_inline_comment__create_inline_comment
294
321
  (with \`confirmed: true\`). Pass ordering: (a) post inline findings,
295
322
  (b) resolve prior threads now fixed (FIX-PUSH FLOW below — feeds
296
- "resolved from prior"), (c) post summary. Step (b) MUST run before
297
- the summary; (a)/(b) order between themselves doesn't matter.
323
+ "resolved_from_prior" counter in the JSON), (c) emit structured
324
+ summary output. Step (b) MUST complete before (c) so the counter
325
+ is accurate; (a)/(b) order between themselves doesn't matter.
298
326
 
299
327
  FIX-PUSH FLOW (when prior claude[bot] threads exist):
300
328
  List prior claude[bot] inline threads, resolve the ones whose issue
@@ -0,0 +1,204 @@
1
+ // Render a clud-bug review's structured-output JSON to the GitHub-markdown
2
+ // summary comment shape the workflow has been posting since v0.6.5.
3
+ //
4
+ // 0.0.O (v0.6.22): introduced as the receiver for `--json-schema` output.
5
+ // The LLM emits structured JSON (one bundled string via the action's
6
+ // `outputs.structured_output`); a workflow post-step pipes that JSON to
7
+ // `clud-bug render --stdin` (CLI subcommand), which calls renderReview()
8
+ // here, then posts the result via `gh pr comment`. Failure mode: if
9
+ // `structured_output` is empty (max retries hit), the post-step is
10
+ // skipped and the LLM's prior free-form behaviour stands (it had already
11
+ // been instructed to post via `gh pr comment` directly as a fallback).
12
+ //
13
+ // Why an outside renderer at all: with --json-schema the LLM can no
14
+ // longer paraphrase the comment format (good for consistency, bad if the
15
+ // rendered shape is wrong). Centralising the markdown shape here means a
16
+ // future format tweak edits one function rather than the prompt.
17
+
18
+ const SEVERITY_EMOJI = { critical: '🔴', minor: '🟡', preexisting: '🟣' };
19
+ const SEVERITY_LABEL = {
20
+ critical: 'important',
21
+ minor: 'nit',
22
+ preexisting: 'pre-existing',
23
+ };
24
+
25
+ // Render the full summary comment markdown. `data` is the parsed JSON
26
+ // matching the schema (see schema.js). Returns a string suitable for
27
+ // `gh pr comment --body`.
28
+ export function renderReview(data) {
29
+ if (!data || typeof data !== 'object') {
30
+ throw new TypeError('renderReview: data must be an object');
31
+ }
32
+ const out = [];
33
+ out.push(renderHeader(data));
34
+ out.push('');
35
+ out.push(renderStatusLine(data.summary_counts));
36
+ out.push('');
37
+ out.push(renderStatsHeader(data.summary_counts));
38
+ out.push('');
39
+ out.push(...renderPerSkillScan(data.per_skill_scan));
40
+ out.push('');
41
+ for (const section of data.dedicated_sections || []) {
42
+ out.push(...renderDedicatedSection(section));
43
+ out.push('');
44
+ }
45
+ if (nonEmpty(data.critical_findings)) {
46
+ out.push('### Critical findings');
47
+ out.push('');
48
+ out.push(...renderFindings(data.critical_findings, 'critical'));
49
+ out.push('');
50
+ }
51
+ if (nonEmpty(data.minor_findings)) {
52
+ out.push('### Minor findings');
53
+ out.push('');
54
+ out.push(...renderFindings(data.minor_findings, 'minor'));
55
+ out.push('');
56
+ }
57
+ if (nonEmpty(data.preexisting_findings)) {
58
+ out.push('### Pre-existing findings');
59
+ out.push('');
60
+ out.push(...renderFindings(data.preexisting_findings, 'preexisting'));
61
+ out.push('');
62
+ }
63
+ if (nonEmpty(data.diagnostics)) {
64
+ out.push('### Diagnostics');
65
+ out.push('');
66
+ for (const line of data.diagnostics) out.push(`- ${line}`);
67
+ out.push('');
68
+ }
69
+ out.push(renderSkillsReferenced(data.skills_referenced));
70
+ out.push('');
71
+ if (data.last_reviewed_sha) {
72
+ out.push(`<!-- last-reviewed-sha: ${data.last_reviewed_sha} -->`);
73
+ }
74
+ // Trim trailing blank lines but always keep a single trailing newline so
75
+ // the comment ends with a final newline (markdown rendering is unchanged
76
+ // either way, but it matches the prior LLM-driven shape).
77
+ return out.join('\n').replace(/\n{3,}/g, '\n\n').replace(/\s+$/, '') + '\n';
78
+ }
79
+
80
+ function renderHeader(data) {
81
+ const verdict = data.status_header;
82
+ const base = '## 🐛 Clud Bug review';
83
+ if (verdict === 'critical findings') return `${base} — critical findings`;
84
+ if (verdict === 'clean') return `${base} — clean`;
85
+ // 'bare' (non-strict-mode default) OR an unexpected verdict — render
86
+ // the unsuffixed H2. Strict-mode gate's anchor stays intact either way.
87
+ return base;
88
+ }
89
+
90
+ function renderStatusLine(counts) {
91
+ const c = sanitizeCounts(counts);
92
+ return `**This round:** ${c.critical} critical · ${c.minor} minor · ${c.resolved_from_prior} resolved from prior · ${c.still_open} still open`;
93
+ }
94
+
95
+ // Severity-emoji stats header. Counts pre-existing in 🟣 even though
96
+ // it's not in summary_counts (the prompt counts pre-existing separately
97
+ // in preexisting_findings.length).
98
+ function renderStatsHeader(counts) {
99
+ const c = sanitizeCounts(counts);
100
+ return `Found: ${c.critical} 🔴 / ${c.minor} 🟡 / ${c.preexisting} 🟣`;
101
+ }
102
+
103
+ function renderPerSkillScan(scan) {
104
+ const out = ['### Per-skill scan'];
105
+ if (!Array.isArray(scan) || scan.length === 0) {
106
+ out.push('- (no skills loaded — review proceeded against the baseline.)');
107
+ return out;
108
+ }
109
+ for (const entry of scan) {
110
+ if (!entry || typeof entry !== 'object') continue;
111
+ const skill = String(entry.skill || '').trim();
112
+ const outcome = String(entry.outcome || '').trim();
113
+ if (!skill) continue;
114
+ out.push(`- [${skill}]: ${outcome || 'scanned (no outcome reported).'}`);
115
+ }
116
+ return out;
117
+ }
118
+
119
+ function renderDedicatedSection(section) {
120
+ if (!section || typeof section !== 'object') return [];
121
+ const name = String(section.section_name || '').trim();
122
+ const skill = String(section.skill || '').trim();
123
+ const header = skill && name
124
+ ? `### ${name} [${skill}]`
125
+ : `### ${name || skill || 'Dedicated section'}`;
126
+ const out = [header, ''];
127
+ if (Array.isArray(section.findings) && section.findings.length > 0) {
128
+ // Dedicated-section findings use the same emoji-prefix block.
129
+ // Default severity for dedicated sections is "critical" — they're
130
+ // domain-specific findings the skill considers important.
131
+ out.push(...renderFindings(section.findings, 'critical'));
132
+ } else {
133
+ out.push('No findings.');
134
+ }
135
+ return out;
136
+ }
137
+
138
+ function renderFindings(findings, severity) {
139
+ const emoji = SEVERITY_EMOJI[severity] || '🔴';
140
+ const out = [];
141
+ for (const f of findings) {
142
+ if (!f || typeof f !== 'object') continue;
143
+ const skill = String(f.skill || '').trim();
144
+ const summary = String(f.summary || '').trim();
145
+ if (!summary) continue;
146
+ const skillPrefix = skill ? `[${skill}]: ` : '';
147
+ const anchor = locationAnchor(f);
148
+ const claim = anchor
149
+ ? `${emoji} ${skillPrefix}${stripTrailingPunctuation(summary)} (${anchor}).`
150
+ : `${emoji} ${skillPrefix}${summary}`;
151
+ out.push(claim);
152
+ if (f.reasoning) {
153
+ out.push('<details><summary>Reasoning</summary>');
154
+ out.push('');
155
+ out.push(String(f.reasoning).trim());
156
+ out.push('');
157
+ out.push('</details>');
158
+ }
159
+ out.push('');
160
+ }
161
+ // Remove the trailing empty line — renderReview adds its own separators.
162
+ if (out[out.length - 1] === '') out.pop();
163
+ return out;
164
+ }
165
+
166
+ function renderSkillsReferenced(skills) {
167
+ if (!Array.isArray(skills) || skills.length === 0) {
168
+ return 'Skills referenced: [none] — no installed skill applied to this diff.';
169
+ }
170
+ return `Skills referenced: [${skills.join(', ')}]`;
171
+ }
172
+
173
+ // --- helpers ---
174
+
175
+ function nonEmpty(arr) {
176
+ return Array.isArray(arr) && arr.length > 0;
177
+ }
178
+
179
+ function sanitizeCounts(counts) {
180
+ const c = counts && typeof counts === 'object' ? counts : {};
181
+ return {
182
+ critical: numOrZero(c.critical),
183
+ minor: numOrZero(c.minor),
184
+ preexisting: numOrZero(c.preexisting),
185
+ resolved_from_prior: numOrZero(c.resolved_from_prior),
186
+ still_open: numOrZero(c.still_open),
187
+ };
188
+ }
189
+
190
+ function numOrZero(v) {
191
+ const n = Number(v);
192
+ return Number.isFinite(n) && n >= 0 ? Math.floor(n) : 0;
193
+ }
194
+
195
+ function locationAnchor(f) {
196
+ const file = String(f.file || '').trim();
197
+ if (!file) return null;
198
+ const line = Number(f.line);
199
+ return Number.isFinite(line) && line > 0 ? `${file}:${line}` : file;
200
+ }
201
+
202
+ function stripTrailingPunctuation(s) {
203
+ return s.replace(/[.!?]+$/, '');
204
+ }
package/lib/render.js CHANGED
@@ -1,7 +1,22 @@
1
1
  import { readFile } from 'node:fs/promises';
2
+ import { readFileSync } from 'node:fs';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { dirname, join } from 'node:path';
5
+ import { serializedReviewSchema } from './review-schema.js';
2
6
 
3
7
  const PLACEHOLDER_RE = /\{\{([A-Z_]+)\}\}/g;
4
8
 
9
+ // CLUD_BUG_VERSION (0.0.O / v0.6.22) — read from this package's
10
+ // package.json at module-load time. The rendered workflow uses
11
+ // `npx --yes clud-bug@<CLUD_BUG_VERSION>` in the post-step that renders
12
+ // structured output to markdown. Pinning to the version that ran
13
+ // `clud-bug init` guarantees the renderer's output shape matches the
14
+ // prompt's expectations.
15
+ const __dirname = dirname(fileURLToPath(import.meta.url));
16
+ const PKG_VERSION = JSON.parse(
17
+ readFileSync(join(__dirname, '..', 'package.json'), 'utf8'),
18
+ ).version;
19
+
5
20
  // Default values for substitution tokens that every template uses.
6
21
  // Callers can override per-render by passing the same key in `vars`.
7
22
  //
@@ -13,6 +28,8 @@ const PLACEHOLDER_RE = /\{\{([A-Z_]+)\}\}/g;
13
28
  // version in their own forked workflow.
14
29
  export const DEFAULTS = {
15
30
  CCA_VERSION: 'v1.0.133',
31
+ CLUD_BUG_VERSION: PKG_VERSION,
32
+ REVIEW_SCHEMA: serializedReviewSchema(),
16
33
  };
17
34
 
18
35
  // Multi-line value substitution preserves YAML/Markdown indentation by
@@ -0,0 +1,159 @@
1
+ // JSON Schema for clud-bug review structured output (0.0.O / v0.6.22).
2
+ //
3
+ // Passed to claude-code-action via `claude_args: --json-schema '<JSON>'`.
4
+ // The Agent SDK validates the LLM's emitted JSON against this schema and
5
+ // re-prompts on mismatch (internal retry; not a clud-bug-level loop).
6
+ //
7
+ // Schema design choices:
8
+ // - Flat top-level — composite-action outputs are a single string, so we
9
+ // fromJSON() once and pluck fields. No deep nesting that would force
10
+ // extra path traversal in the post-step shell.
11
+ // - Word/char caps in `description:` — the SDK doc names these as the
12
+ // primary lever for keeping output cheap. They're advisory (the SDK
13
+ // does not enforce numeric caps; the LLM treats them as instruction).
14
+ // 0.0.X already lives in the prompt, so the schema description here is
15
+ // a complementary belt-and-suspenders signal.
16
+ // - Required minimum — only the fields a renderer absolutely needs to
17
+ // produce a valid summary comment. Counts are always required (the
18
+ // stats header + status block depend on them); finding arrays are
19
+ // required but may be empty (a clean review has 0 findings, not
20
+ // missing arrays).
21
+ // - `additionalProperties: false` on every object — schema-strict mode.
22
+ // Anthropic's structured-outputs doc explicitly recommends this to
23
+ // keep the model from inventing fields.
24
+ //
25
+ // Bumped via deliberate edit; not derived from a TypeScript type. The
26
+ // rendering side (lib/render-review.js) treats unknown fields permissively
27
+ // — schema and renderer can drift up to one minor version safely.
28
+
29
+ const FINDING_ITEM = {
30
+ type: 'object',
31
+ additionalProperties: false,
32
+ properties: {
33
+ skill: {
34
+ type: 'string',
35
+ description: 'The skill name in brackets, e.g. "critical-issues-only". Must match a loaded skill or "(none)".',
36
+ },
37
+ file: {
38
+ type: 'string',
39
+ description: 'Path of the affected file relative to repo root. Optional when the finding is cross-cutting.',
40
+ },
41
+ line: {
42
+ type: 'integer',
43
+ minimum: 1,
44
+ description: 'Line number in the affected file. Required when `file` is set.',
45
+ },
46
+ summary: {
47
+ type: 'string',
48
+ description: 'One sentence stating the claim. Max ~20 words; no trailing period (the renderer adds one).',
49
+ },
50
+ reasoning: {
51
+ type: 'string',
52
+ description: 'Evidence anchor + suggested fix. Max ~80 words. Rendered inside <details> block; can be omitted for self-evident findings.',
53
+ },
54
+ },
55
+ required: ['skill', 'summary'],
56
+ };
57
+
58
+ const PER_SKILL_SCAN_ITEM = {
59
+ type: 'object',
60
+ additionalProperties: false,
61
+ properties: {
62
+ skill: { type: 'string' },
63
+ outcome: {
64
+ type: 'string',
65
+ description: 'One sentence describing what the skill found. Max ~15 words. Examples: "scanned all paths. 2 critical findings below.", "0 findings.", "not applicable to this diff."',
66
+ },
67
+ },
68
+ required: ['skill', 'outcome'],
69
+ };
70
+
71
+ export const REVIEW_SCHEMA = {
72
+ type: 'object',
73
+ additionalProperties: false,
74
+ properties: {
75
+ status_header: {
76
+ type: 'string',
77
+ enum: ['critical findings', 'clean', 'bare'],
78
+ description: 'Strict-mode opt-in repos: `critical findings` when ANY 🔴 finding exists, otherwise `clean`. Non-strict-mode repos (the default): emit `bare` — the renderer produces a `## 🐛 Clud Bug review` H2 with no suffix, matching the v0.6.21- behaviour. Check .claude/skills/.clud-bug.json for `strictMode: true` to pick critical/clean vs bare.',
79
+ },
80
+ summary_counts: {
81
+ type: 'object',
82
+ additionalProperties: false,
83
+ properties: {
84
+ critical: { type: 'integer', minimum: 0 },
85
+ minor: { type: 'integer', minimum: 0 },
86
+ preexisting: { type: 'integer', minimum: 0 },
87
+ resolved_from_prior: { type: 'integer', minimum: 0 },
88
+ still_open: { type: 'integer', minimum: 0 },
89
+ },
90
+ required: ['critical', 'minor', 'preexisting', 'resolved_from_prior', 'still_open'],
91
+ },
92
+ per_skill_scan: {
93
+ type: 'array',
94
+ description: 'One entry per LOADED skill — even silent ones. Empty when no skills are installed.',
95
+ items: PER_SKILL_SCAN_ITEM,
96
+ },
97
+ critical_findings: {
98
+ type: 'array',
99
+ description: 'NEW 🔴 findings (bugs, security, perf, missing test coverage). May be empty.',
100
+ items: FINDING_ITEM,
101
+ },
102
+ minor_findings: {
103
+ type: 'array',
104
+ description: 'NEW 🟡 findings (nits, style, observations). May be empty.',
105
+ items: FINDING_ITEM,
106
+ },
107
+ preexisting_findings: {
108
+ type: 'array',
109
+ description: 'NEW 🟣 findings (issues that pre-date this PR). May be empty.',
110
+ items: FINDING_ITEM,
111
+ },
112
+ dedicated_sections: {
113
+ type: 'array',
114
+ description: 'Per-dedicated-mode-skill section blocks. Each item carries the section header text and its findings.',
115
+ items: {
116
+ type: 'object',
117
+ additionalProperties: false,
118
+ properties: {
119
+ section_name: { type: 'string', description: 'Human-readable section title, e.g. "Brand voice".' },
120
+ skill: { type: 'string', description: 'The dedicated skill name.' },
121
+ findings: { type: 'array', items: FINDING_ITEM },
122
+ },
123
+ required: ['section_name', 'skill', 'findings'],
124
+ },
125
+ },
126
+ diagnostics: {
127
+ type: 'array',
128
+ description: '0.0.T tee-hint diagnostics. One line per `head -c` cap that fired during fetch. Empty when no truncation occurred.',
129
+ items: { type: 'string' },
130
+ },
131
+ skills_referenced: {
132
+ type: 'array',
133
+ description: 'Names of every skill cited in any finding. Empty list (or ["(none)"]) when no skill applied.',
134
+ items: { type: 'string' },
135
+ },
136
+ last_reviewed_sha: {
137
+ type: 'string',
138
+ description: 'The HEAD SHA at review time, used by the incremental-diff handshake. Set to the literal value of the workflow env var $HEAD_SHA.',
139
+ },
140
+ },
141
+ required: [
142
+ 'status_header',
143
+ 'summary_counts',
144
+ 'per_skill_scan',
145
+ 'critical_findings',
146
+ 'minor_findings',
147
+ 'preexisting_findings',
148
+ 'skills_referenced',
149
+ 'last_reviewed_sha',
150
+ ],
151
+ };
152
+
153
+ // Serialize the schema for inclusion in workflow templates as the
154
+ // `--json-schema '<JSON>'` argument. Single-line (the workflow YAML uses
155
+ // the pipe block; single-quoted JSON inside that needs to stay flat to
156
+ // avoid YAML parser surprises with embedded newlines).
157
+ export function serializedReviewSchema() {
158
+ return JSON.stringify(REVIEW_SCHEMA);
159
+ }
package/lib/skills.js CHANGED
@@ -383,6 +383,113 @@ export function readReviewMode(skillContent) {
383
383
  return value === 'dedicated' ? 'dedicated' : 'shared';
384
384
  }
385
385
 
386
+ // 0.0.K (v0.6.21): parse the optional `applies_to:` frontmatter block.
387
+ //
388
+ // Schema:
389
+ // applies_to:
390
+ // paths:
391
+ // - "src/ui/**"
392
+ // - "lib/components/**"
393
+ // extensions: [".tsx", ".jsx"]
394
+ //
395
+ // Returns `{paths: string[], extensions: string[]}` if the field is
396
+ // present (either sub-list optional, both default to empty array), or
397
+ // `null` if absent. Skills without applies_to are scope-universal —
398
+ // the caller should treat null as "load unconditionally."
399
+ //
400
+ // Hand-rolled YAML parser scoped to this exact shape. The frontmatter
401
+ // is otherwise opaque (review_mode is parsed elsewhere with a similar
402
+ // single-key regex), so pulling in a YAML dep would be overkill.
403
+ export function readAppliesTo(skillContent) {
404
+ if (typeof skillContent !== 'string') return null;
405
+ const fm = skillContent.match(/^---\n([\s\S]*?)\n---/);
406
+ if (!fm) return null;
407
+ const block = fm[1];
408
+ // Anchor on `applies_to:` at start of line (the body of a SKILL.md
409
+ // could mention the term in prose; only the frontmatter key fires).
410
+ const head = block.match(/^applies_to:\s*$/m);
411
+ if (!head) return null;
412
+ // Slice from after the `applies_to:` line; the block ends at the
413
+ // next top-level key (a line starting with a word character + `:`)
414
+ // OR end-of-block.
415
+ const startIdx = head.index + head[0].length;
416
+ const rest = block.slice(startIdx);
417
+ const stop = rest.search(/^\w[\w-]*:/m);
418
+ const scoped = stop === -1 ? rest : rest.slice(0, stop);
419
+ const paths = parseYamlList(scoped, 'paths');
420
+ const extensions = parseYamlList(scoped, 'extensions');
421
+ if (paths.length === 0 && extensions.length === 0) return null;
422
+ return { paths, extensions };
423
+ }
424
+
425
+ // Parse a YAML list under `<key>:`, handling both the inline-array
426
+ // form (`extensions: [".tsx", ".jsx"]`) and the block form
427
+ // (`paths:` followed by ` - "src/ui/**"` lines).
428
+ function parseYamlList(block, key) {
429
+ const inline = block.match(new RegExp(`^\\s{2}${key}:\\s*\\[(.*?)\\]\\s*$`, 'm'));
430
+ if (inline) {
431
+ return inline[1]
432
+ .split(',')
433
+ .map((s) => s.trim().replace(/^["']|["']$/g, ''))
434
+ .filter(Boolean);
435
+ }
436
+ const headerRe = new RegExp(`^\\s{2}${key}:\\s*$`, 'm');
437
+ const head = block.match(headerRe);
438
+ if (!head) return [];
439
+ const after = block.slice(head.index + head[0].length);
440
+ const items = [];
441
+ for (const line of after.split('\n')) {
442
+ const item = line.match(/^\s{4,}-\s*(.+?)\s*$/);
443
+ if (item) {
444
+ items.push(item[1].replace(/^["']|["']$/g, ''));
445
+ continue;
446
+ }
447
+ // Anything that isn't a list item (or blank) ends the list.
448
+ if (line.trim() !== '' && !item) break;
449
+ }
450
+ return items;
451
+ }
452
+
453
+ // 0.0.K: does `prPaths` contain at least one file matching the skill's
454
+ // applies_to? Skills without applies_to ALWAYS apply (back-compat).
455
+ //
456
+ // `prPaths` is the list of changed files in the PR (e.g. from
457
+ // `gh pr diff --name-only`). Match semantics:
458
+ // - paths: any glob in `paths` matches any of `prPaths`
459
+ // - extensions: any extension in `extensions` matches any of `prPaths`
460
+ // - paths OR extensions (NOT AND) — a single hit is enough
461
+ //
462
+ // Skill `paths` use the minimal glob set logmind already uses
463
+ // (`*` matches non-slash, `**` matches across slashes, `?` single
464
+ // char). Anything fancier would need a real glob lib.
465
+ export function appliesToPr(skillContent, prPaths) {
466
+ const rule = readAppliesTo(skillContent);
467
+ if (rule === null) return true; // back-compat: no rule → applies
468
+ if (!Array.isArray(prPaths)) return true; // be permissive on bad input
469
+ for (const path of prPaths) {
470
+ if (typeof path !== 'string') continue;
471
+ for (const ext of rule.extensions) {
472
+ if (path.endsWith(ext)) return true;
473
+ }
474
+ for (const glob of rule.paths) {
475
+ if (globMatch(glob, path)) return true;
476
+ }
477
+ }
478
+ return false;
479
+ }
480
+
481
+ // Minimal glob → regex: `**` → `.*`, `*` → `[^/]*`, `?` → `.`,
482
+ // everything else escaped. Anchored full-string match.
483
+ function globMatch(glob, path) {
484
+ const escaped = glob
485
+ .replace(/([.+^${}()|[\]\\])/g, '\\$1')
486
+ .replace(/\*\*/g, '__DOUBLESTAR__')
487
+ .replace(/\*/g, '[^/]*')
488
+ .replace(/__DOUBLESTAR__/g, '.*')
489
+ .replace(/\?/g, '.');
490
+ return new RegExp(`^${escaped}$`).test(path);
491
+ }
492
+
386
493
  // Partition a set of loaded skills into {shared, dedicated} buckets per
387
494
  // each skill's review_mode frontmatter. Expects skills with a `content`
388
495
  // field (SKILL.md text). Skills without content default to `shared`.
package/lib/update.js CHANGED
@@ -45,6 +45,7 @@ export async function runUpdate({
45
45
  // 1. Re-render review workflow with the latest template.
46
46
  const signals = await detect(cwd);
47
47
  const tmplName = pickTemplate(signals.languages);
48
+ // REVIEW_SCHEMA + CCA_VERSION + CLUD_BUG_VERSION come from render.js DEFAULTS.
48
49
  const newReview = await renderFile(join(templatesDir, tmplName), {
49
50
  REVIEW_PROMPT: reviewPrompt({
50
51
  projectDescription: buildDescriptionLine(signals),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clud-bug",
3
- "version": "0.6.20",
3
+ "version": "0.6.22",
4
4
  "description": "Skill-driven Claude PR review. Ship a brand-voice skill, get brand reviews. Each finding cites the skill that motivated it. CLI installs the workflow + a baseline kit; add more from skills.sh.",
5
5
  "homepage": "https://cludbug.dev",
6
6
  "bugs": "https://github.com/thrillmade/clud-bug/issues",
@@ -108,7 +108,7 @@ jobs:
108
108
  if $IS_FORK || $IS_BOT; then
109
109
  REASON=$($IS_BOT && echo "bot author ($PR_AUTHOR)" || echo "fork ($HEAD_REPO)")
110
110
  EXISTING=$(gh api "repos/${BASE_REPO}/issues/${PR_NUMBER}/comments?per_page=100" \
111
- --jq '[.[] | select(.user.login == "claude[bot]" and (.body | startswith("## 🐛 Clud Bug skipped")))] | length')
111
+ --jq '[.[] | select(.user.login == "github-actions[bot]" and (.body | startswith("## 🐛 Clud Bug skipped")))] | length')
112
112
  if [ "${EXISTING:-0}" = "0" ]; then
113
113
  BODY=$(printf '## 🐛 Clud Bug skipped\n\nThis PR is from a %s. GitHub deliberately does not pass repository secrets to such workflows, so Clud Bug could not authenticate against Anthropic. Review the diff manually.' "$REASON")
114
114
  gh pr comment "$PR_NUMBER" --body "$BODY" || true
@@ -143,19 +143,56 @@ jobs:
143
143
  anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
144
144
  track_progress: true
145
145
  show_full_output: true
146
+ # v0.6.22 / 0.0.O: --json-schema captures the summary as
147
+ # structured output; post-step renders + posts. See workflow.yml.tmpl for design notes.
146
148
  claude_args: |
147
149
  --model ${{ needs.paths-check.outputs.model }}
148
150
  --max-turns 15
149
- --allowedTools "mcp__github_inline_comment__create_inline_comment,Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*),Bash(gh api graphql:*),Bash(gh api repos/:*),Bash(git show:*),Bash(git diff:*),Bash(git merge-base:*),Bash(cat .claude/skills/.clud-bug.json),Bash(cat .claude/skills/*/SKILL.md),Bash(head:*)"
151
+ --allowedTools "mcp__github_inline_comment__create_inline_comment,Bash(gh pr diff:*),Bash(gh pr view:*),Bash(gh api graphql:*),Bash(gh api repos/:*),Bash(git show:*),Bash(git diff:*),Bash(git merge-base:*),Bash(cat .claude/skills/.clud-bug.json),Bash(cat .claude/skills/*/SKILL.md),Bash(head:*)"
152
+ --json-schema '{{REVIEW_SCHEMA}}'
150
153
  prompt: |
151
154
  Review this pull request following the discipline in your
152
155
  system prompt — every rule about skill routing, comment
153
156
  format, the strict-mode header, the two-surface review
154
157
  shape, and the FIX-PUSH FLOW applies.
158
+ id: clud-bug-review
159
+
160
+ # v0.6.22 / 0.0.O: render structured output → post via gh pr comment.
161
+ - name: Render + post structured review
162
+ if: success() && steps.clud-bug-review.outputs.structured_output != ''
163
+ env:
164
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
165
+ PR_NUMBER: ${{ github.event.pull_request.number }}
166
+ STRUCTURED: ${{ steps.clud-bug-review.outputs.structured_output }}
167
+ run: |
168
+ set -euo pipefail
169
+ BODY=$(printf '%s\n' "$STRUCTURED" | npx --yes clud-bug@{{CLUD_BUG_VERSION}} render --stdin)
170
+ gh pr comment "$PR_NUMBER" --body "$BODY"
171
+
172
+ # Fallback when structured_output is empty (max-retries hit).
173
+ - name: Fallback summary (structured_output empty)
174
+ if: success() && steps.clud-bug-review.outputs.structured_output == ''
175
+ env:
176
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
177
+ PR_NUMBER: ${{ github.event.pull_request.number }}
178
+ run: |
179
+ set -euo pipefail
180
+ gh pr comment "$PR_NUMBER" --body "## 🐛 Clud Bug review
181
+
182
+ **This round:** 0 critical · 0 minor · 0 resolved from prior · 0 still open
183
+
184
+ Found: 0 🔴 / 0 🟡 / 0 🟣
185
+
186
+ ⚠️ Structured output (\`--json-schema\`) returned empty — likely max-retries hit on schema validation. Investigate the action logs.
187
+
188
+ Skills referenced: [none]"
155
189
 
156
190
  # Strict-mode gate — composite action; see workflow.yml.tmpl for design notes.
157
191
  - name: Strict mode — fail check on critical findings
158
192
  if: success()
159
- uses: thrillmade/clud-bug/.github/actions/strict-mode-gate@v0.6.20
193
+ uses: thrillmade/clud-bug/.github/actions/strict-mode-gate@v0.6.22
160
194
  with:
161
195
  github-token: ${{ secrets.GITHUB_TOKEN }}
196
+ # v0.6.22 / 0.0.O: summary now posted by github-actions[bot].
197
+ # See workflow.yml.tmpl for design notes.
198
+ bot-login: 'github-actions[bot]'
@@ -108,7 +108,7 @@ jobs:
108
108
  if $IS_FORK || $IS_BOT; then
109
109
  REASON=$($IS_BOT && echo "bot author ($PR_AUTHOR)" || echo "fork ($HEAD_REPO)")
110
110
  EXISTING=$(gh api "repos/${BASE_REPO}/issues/${PR_NUMBER}/comments?per_page=100" \
111
- --jq '[.[] | select(.user.login == "claude[bot]" and (.body | startswith("## 🐛 Clud Bug skipped")))] | length')
111
+ --jq '[.[] | select(.user.login == "github-actions[bot]" and (.body | startswith("## 🐛 Clud Bug skipped")))] | length')
112
112
  if [ "${EXISTING:-0}" = "0" ]; then
113
113
  BODY=$(printf '## 🐛 Clud Bug skipped\n\nThis PR is from a %s. GitHub deliberately does not pass repository secrets to such workflows, so Clud Bug could not authenticate against Anthropic. Review the diff manually.' "$REASON")
114
114
  gh pr comment "$PR_NUMBER" --body "$BODY" || true
@@ -143,19 +143,56 @@ jobs:
143
143
  anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
144
144
  track_progress: true
145
145
  show_full_output: true
146
+ # v0.6.22 / 0.0.O: --json-schema captures the summary as
147
+ # structured output; post-step renders + posts. See workflow.yml.tmpl for design notes.
146
148
  claude_args: |
147
149
  --model ${{ needs.paths-check.outputs.model }}
148
150
  --max-turns 15
149
- --allowedTools "mcp__github_inline_comment__create_inline_comment,Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*),Bash(gh api graphql:*),Bash(gh api repos/:*),Bash(git show:*),Bash(git diff:*),Bash(git merge-base:*),Bash(cat .claude/skills/.clud-bug.json),Bash(cat .claude/skills/*/SKILL.md),Bash(head:*)"
151
+ --allowedTools "mcp__github_inline_comment__create_inline_comment,Bash(gh pr diff:*),Bash(gh pr view:*),Bash(gh api graphql:*),Bash(gh api repos/:*),Bash(git show:*),Bash(git diff:*),Bash(git merge-base:*),Bash(cat .claude/skills/.clud-bug.json),Bash(cat .claude/skills/*/SKILL.md),Bash(head:*)"
152
+ --json-schema '{{REVIEW_SCHEMA}}'
150
153
  prompt: |
151
154
  Review this pull request following the discipline in your
152
155
  system prompt — every rule about skill routing, comment
153
156
  format, the strict-mode header, the two-surface review
154
157
  shape, and the FIX-PUSH FLOW applies.
158
+ id: clud-bug-review
159
+
160
+ # v0.6.22 / 0.0.O: render structured output → post via gh pr comment.
161
+ - name: Render + post structured review
162
+ if: success() && steps.clud-bug-review.outputs.structured_output != ''
163
+ env:
164
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
165
+ PR_NUMBER: ${{ github.event.pull_request.number }}
166
+ STRUCTURED: ${{ steps.clud-bug-review.outputs.structured_output }}
167
+ run: |
168
+ set -euo pipefail
169
+ BODY=$(printf '%s\n' "$STRUCTURED" | npx --yes clud-bug@{{CLUD_BUG_VERSION}} render --stdin)
170
+ gh pr comment "$PR_NUMBER" --body "$BODY"
171
+
172
+ # Fallback when structured_output is empty (max-retries hit).
173
+ - name: Fallback summary (structured_output empty)
174
+ if: success() && steps.clud-bug-review.outputs.structured_output == ''
175
+ env:
176
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
177
+ PR_NUMBER: ${{ github.event.pull_request.number }}
178
+ run: |
179
+ set -euo pipefail
180
+ gh pr comment "$PR_NUMBER" --body "## 🐛 Clud Bug review
181
+
182
+ **This round:** 0 critical · 0 minor · 0 resolved from prior · 0 still open
183
+
184
+ Found: 0 🔴 / 0 🟡 / 0 🟣
185
+
186
+ ⚠️ Structured output (\`--json-schema\`) returned empty — likely max-retries hit on schema validation. Investigate the action logs.
187
+
188
+ Skills referenced: [none]"
155
189
 
156
190
  # Strict-mode gate — composite action; see workflow.yml.tmpl for design notes.
157
191
  - name: Strict mode — fail check on critical findings
158
192
  if: success()
159
- uses: thrillmade/clud-bug/.github/actions/strict-mode-gate@v0.6.20
193
+ uses: thrillmade/clud-bug/.github/actions/strict-mode-gate@v0.6.22
160
194
  with:
161
195
  github-token: ${{ secrets.GITHUB_TOKEN }}
196
+ # v0.6.22 / 0.0.O: summary now posted by github-actions[bot].
197
+ # See workflow.yml.tmpl for design notes.
198
+ bot-login: 'github-actions[bot]'
@@ -158,7 +158,7 @@ jobs:
158
158
  # pull_request: synchronize on a long-running Dependabot PR would
159
159
  # otherwise stack one identical comment per rebase.
160
160
  EXISTING=$(gh api "repos/${BASE_REPO}/issues/${PR_NUMBER}/comments?per_page=100" \
161
- --jq '[.[] | select(.user.login == "claude[bot]" and (.body | startswith("## 🐛 Clud Bug skipped")))] | length')
161
+ --jq '[.[] | select(.user.login == "github-actions[bot]" and (.body | startswith("## 🐛 Clud Bug skipped")))] | length')
162
162
  if [ "${EXISTING:-0}" = "0" ]; then
163
163
  BODY=$(printf '## 🐛 Clud Bug skipped\n\nThis PR is from a %s. GitHub deliberately does not pass repository secrets to such workflows, so Clud Bug could not authenticate against Anthropic. Review the diff manually.' "$REASON")
164
164
  gh pr comment "$PR_NUMBER" --body "$BODY" || true
@@ -222,15 +222,58 @@ jobs:
222
222
  # the PR as trivial (Haiku) or default (Sonnet). Override
223
223
  # per-repo by editing the rendered workflow if you want a
224
224
  # specific model for a specific repo.
225
+ # v0.6.22 / 0.0.O: --json-schema captures the summary as
226
+ # structured output (action exposes outputs.structured_output).
227
+ # The model emits findings as schema fields; a post-step
228
+ # renders to markdown and posts. The schema is rendered into
229
+ # this template at `clud-bug init` time via {{REVIEW_SCHEMA}}.
225
230
  claude_args: |
226
231
  --model ${{ needs.paths-check.outputs.model }}
227
232
  --max-turns 15
228
- --allowedTools "mcp__github_inline_comment__create_inline_comment,Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*),Bash(gh api graphql:*),Bash(gh api repos/:*),Bash(git show:*),Bash(git diff:*),Bash(git merge-base:*),Bash(cat .claude/skills/.clud-bug.json),Bash(cat .claude/skills/*/SKILL.md),Bash(head:*)"
233
+ --allowedTools "mcp__github_inline_comment__create_inline_comment,Bash(gh pr diff:*),Bash(gh pr view:*),Bash(gh api graphql:*),Bash(gh api repos/:*),Bash(git show:*),Bash(git diff:*),Bash(git merge-base:*),Bash(cat .claude/skills/.clud-bug.json),Bash(cat .claude/skills/*/SKILL.md),Bash(head:*)"
234
+ --json-schema '{{REVIEW_SCHEMA}}'
229
235
  prompt: |
230
236
  Review this pull request following the discipline in your
231
237
  system prompt — every rule about skill routing, comment
232
238
  format, the strict-mode header, the two-surface review
233
239
  shape, and the FIX-PUSH FLOW applies.
240
+ id: clud-bug-review
241
+
242
+ # v0.6.22 / 0.0.O: render the structured output to the summary
243
+ # comment shape, post via gh pr comment. Guarded so we only run
244
+ # when the action returned a non-empty structured payload
245
+ # (max-retries hit → empty → fall through to the next step).
246
+ - name: Render + post structured review
247
+ if: success() && steps.clud-bug-review.outputs.structured_output != ''
248
+ env:
249
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
250
+ PR_NUMBER: ${{ github.event.pull_request.number }}
251
+ STRUCTURED: ${{ steps.clud-bug-review.outputs.structured_output }}
252
+ run: |
253
+ set -euo pipefail
254
+ BODY=$(printf '%s\n' "$STRUCTURED" | npx --yes clud-bug@{{CLUD_BUG_VERSION}} render --stdin)
255
+ gh pr comment "$PR_NUMBER" --body "$BODY"
256
+
257
+ # Fallback comment when the model couldn't produce schema-valid
258
+ # output after max retries (structured_output is empty). Keeps a
259
+ # bare H2 header so the strict-mode gate sees a comment and falls
260
+ # open (advisory) rather than panicking on a missing summary.
261
+ - name: Fallback summary (structured_output empty)
262
+ if: success() && steps.clud-bug-review.outputs.structured_output == ''
263
+ env:
264
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
265
+ PR_NUMBER: ${{ github.event.pull_request.number }}
266
+ run: |
267
+ set -euo pipefail
268
+ gh pr comment "$PR_NUMBER" --body "## 🐛 Clud Bug review
269
+
270
+ **This round:** 0 critical · 0 minor · 0 resolved from prior · 0 still open
271
+
272
+ Found: 0 🔴 / 0 🟡 / 0 🟣
273
+
274
+ ⚠️ Structured output (\`--json-schema\`) returned empty — likely max-retries hit on schema validation. Investigate the action logs.
275
+
276
+ Skills referenced: [none]"
234
277
 
235
278
  # Strict-mode gate. Fails the check when the BASE ref's manifest
236
279
  # has { "strictMode": true } AND the latest clud-bug review's first
@@ -247,6 +290,13 @@ jobs:
247
290
  # Letting the action's own failure fail the check is louder and right.
248
291
  - name: Strict mode — fail check on critical findings
249
292
  if: success()
250
- uses: thrillmade/clud-bug/.github/actions/strict-mode-gate@v0.6.20
293
+ uses: thrillmade/clud-bug/.github/actions/strict-mode-gate@v0.6.22
251
294
  with:
252
295
  github-token: ${{ secrets.GITHUB_TOKEN }}
296
+ # v0.6.22 / 0.0.O: the summary is now posted by the workflow
297
+ # post-step under the github-actions[bot] identity (was
298
+ # claude[bot] when claude-code-action did the posting).
299
+ # Without this override the gate's default (claude[bot])
300
+ # never matches the new summary author → strict mode falls
301
+ # open advisory for every install that opted in.
302
+ bot-login: 'github-actions[bot]'