clud-bug 0.5.3 → 0.5.5

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/README.md CHANGED
@@ -36,16 +36,22 @@ The naturalist arrives at your repo, surveys the habitat, and assembles a field
36
36
  4. **Writes** the chosen specimens to `.claude/skills/<name>/SKILL.md` (Claude Code auto-loads them in the GitHub Action).
37
37
  5. **Drafts the field kit** at `.github/workflows/clud-bug-review.yml` with your project description filled in and the right permissions/tool allowlist for `gh pr comment` to actually post.
38
38
  6. **Briefs other agents** by adding a `<!-- clud-bug-start -->` block to `AGENTS.md` (creating it if missing — it's the cross-tool canonical), and idempotently to `CLAUDE.md`, `GEMINI.md`, `.github/copilot-instructions.md`, `.cursorrules`, `.windsurfrules`, `.clinerules`, `.continuerules`, and `.cursor/rules/*.md` where they already exist. Re-runs replace the prior block in place. Files you didn't already have are left uncreated — no proliferating stubs.
39
+ 7. **Offers to enable `required_conversation_resolution`** on your default branch. Clud Bug auto-resolves its own review threads when fixes land — but that only gates merges when conversation-resolution is required. Init detects the current state via `gh`, prompts to enable (auto-yes with `--accept-all`), and degrades to an advisory message if you lack admin perms / `gh` isn't installed / the branch has no base protection rule. Pass `--no-set-protection` to skip the prompt entirely — for repos that manage branch protection via ruleset or org policy.
39
40
 
40
41
  ## CLI options
41
42
 
42
43
  ```
43
44
  npx clud-bug init [options]
44
45
 
45
- --offline Skip skills.sh; install only the bundled baseline skills.
46
- --accept-all,-y Accept the recommended skill set without prompting.
47
- --commit git add + commit the generated files when done.
48
- --help,-h Show help.
46
+ --offline Skip skills.sh; install only the bundled baseline skills.
47
+ --accept-all,-y Accept the recommended skill set (and the
48
+ branch-protection prompt) without prompting.
49
+ --no-set-protection Skip the prompt that offers to enable
50
+ required_conversation_resolution on the default
51
+ branch. For repos that manage branch protection
52
+ via ruleset or org policy.
53
+ --commit git add + commit the generated files when done.
54
+ --help,-h Show help.
49
55
  ```
50
56
 
51
57
  ## Staying up to date
@@ -182,6 +188,26 @@ After install:
182
188
  3. Within ~2 min, Clud Bug should post a comment flagging it.
183
189
  4. If no comment: check the **Actions** tab logs. Look for `gh pr comment` invocations and any "Resource not accessible by integration" errors (usually a permissions issue or a fork PR).
184
190
 
191
+ ### Reading a review
192
+
193
+ Every Clud Bug review opens with a status line that tells you exactly what changed since the previous pass — particularly useful on re-review after you push a fix:
194
+
195
+ ```
196
+ ## 🐛 Clud Bug review
197
+
198
+ **This round:** 0 critical · 1 minor · 3 resolved from prior · 0 still open
199
+
200
+ ### Findings
201
+
202
+ ```
203
+
204
+ - **critical** — new critical findings in this review (these are what strict mode gates on)
205
+ - **minor** — non-critical findings (suggestions / nits)
206
+ - **resolved from prior** — prior unresolved threads the bot just cleared because it verified your fix in the diff
207
+ - **still open** — prior threads whose issue is still standing
208
+
209
+ Same format every time; zero values are always present so the line is easy to scan and parse.
210
+
185
211
  ## Manual install (advanced)
186
212
 
187
213
  If you don't want to use the CLI, you can install a generic workflow by hand:
package/bin/clud-bug.js CHANGED
@@ -16,6 +16,7 @@ import { computeAuditFileSet, renderAuditHeader } from '../lib/audit.js';
16
16
  import { runUpdate } from '../lib/update.js';
17
17
  import { getPendingWorkflowEdits, makeBranchName, git as gitCmd } from '../lib/edit-workflow.js';
18
18
  import { applyToRepo as applyAgentDocs } from '../lib/agents-md.js';
19
+ import { detectRepo, detectDefaultBranch, getProtectionState, enableConversationResolution } from '../lib/branch-protection.js';
19
20
 
20
21
  const PKG_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
21
22
  const TEMPLATES = join(PKG_ROOT, 'templates');
@@ -25,6 +26,7 @@ function parseArgs(argv) {
25
26
  const args = {
26
27
  _: [], offline: false, acceptAll: false, commit: false, help: false, version: false,
27
28
  since: null, changedIn: null, scopes: [], out: null,
29
+ setProtection: true,
28
30
  };
29
31
  for (let i = 0; i < argv.length; i++) {
30
32
  const a = argv[i];
@@ -37,6 +39,7 @@ function parseArgs(argv) {
37
39
  else if (a === '--changed-in') args.changedIn = argv[++i];
38
40
  else if (a === '--scope') args.scopes.push(argv[++i]);
39
41
  else if (a === '--out') args.out = argv[++i];
42
+ else if (a === '--no-set-protection') args.setProtection = false;
40
43
  else args._.push(a);
41
44
  }
42
45
  return args;
@@ -64,6 +67,10 @@ Options:
64
67
  --offline Skip skills.sh; pin only the bundled baseline specimens.
65
68
  --accept-all,-y Accept the recommended specimens without prompting.
66
69
  --commit git add + commit the generated kit when done (init only).
70
+ --no-set-protection Skip the prompt that offers to enable
71
+ required_conversation_resolution on the default
72
+ branch (init only). Use for repos that manage
73
+ branch protection via ruleset or org policy.
67
74
  --since <date> Audit only files changed in commits after <date> (git date string).
68
75
  --changed-in <dur> Audit only files changed in the past <dur>: 7d, 2w, 1mo, 1y. (audit only)
69
76
  --scope <glob> Limit audit to files matching <glob>; repeatable. (audit only)
@@ -236,6 +243,13 @@ async function runInit(args) {
236
243
  spawnSync('git', ['commit', '-m', 'Add clud-bug 🐛 — a field guide to specimens crawling your code'], { cwd, stdio: 'inherit' });
237
244
  }
238
245
 
246
+ // Offer to enable required_conversation_resolution on the default
247
+ // branch. clud-bug auto-resolves its own review threads when fixes
248
+ // land — without this setting, that doesn't gate merges. Skipped on
249
+ // --no-set-protection for repos that manage protection via ruleset
250
+ // or org policy.
251
+ await runInitBranchProtection(args);
252
+
239
253
  log('');
240
254
  log('Field kit assembled. Next:');
241
255
  log(' 1. Set ANTHROPIC_API_KEY in your repo secrets:');
@@ -278,6 +292,114 @@ async function promptForSkills(recommended) {
278
292
  }
279
293
  }
280
294
 
295
+ // Branch-protection setup step at the end of `clud-bug init`.
296
+ // Offers to enable required_conversation_resolution on the default
297
+ // branch via gh API. Skipped cleanly when --no-set-protection is
298
+ // passed. Failure modes (no admin perms, no base protection rule,
299
+ // network error) all degrade to advisory log messages — they never
300
+ // fail the init run.
301
+ //
302
+ // gh and prompt are injectable for tests (defaults to spawning real
303
+ // gh + reading from real stdin).
304
+ async function runInitBranchProtection(args, { gh, prompt } = {}) {
305
+ if (!args.setProtection) {
306
+ log('');
307
+ log('🐛 Branch protection: skipped (--no-set-protection).');
308
+ return;
309
+ }
310
+ log('');
311
+ log('🐛 Branch protection');
312
+
313
+ // Detect repo + default branch. If gh isn't installed or the local
314
+ // dir isn't a github repo, treat as advisory and move on.
315
+ let owner, repo, branch;
316
+ try {
317
+ ({ owner, repo } = await detectRepo({ gh }));
318
+ branch = await detectDefaultBranch({ owner, repo, gh });
319
+ } catch (err) {
320
+ log(` Could not detect repo/branch (${err.message.split('\n')[0]}). Skipping.`);
321
+ log(' You can enable it manually: gh api -X POST repos/<owner>/<repo>/branches/<default>/protection/required_conversation_resolution');
322
+ return;
323
+ }
324
+
325
+ log(` Default branch: ${branch}`);
326
+
327
+ // Inspect current state.
328
+ const current = await getProtectionState({ owner, repo, branch, gh });
329
+ if (current.state === 'enabled') {
330
+ log(' required_conversation_resolution: already on — your repo is all set.');
331
+ return;
332
+ }
333
+ if (current.state === 'forbidden') {
334
+ log(' Could not read branch protection (no admin perms). Ask the repo owner to enable required_conversation_resolution, or re-run with --no-set-protection to silence this prompt.');
335
+ return;
336
+ }
337
+ if (current.state === 'unknown') {
338
+ log(` Could not read branch protection (${current.reason}). Skipping.`);
339
+ return;
340
+ }
341
+
342
+ // Short-circuit on no-protection BEFORE prompting. The single-flag
343
+ // POST endpoint requires a base protection rule on the branch — if
344
+ // there's none, enableConversationResolution would just 404. Skip
345
+ // the prompt and go straight to the actionable guidance (set up
346
+ // basic protection first, then re-run).
347
+ if (current.state === 'no-protection') {
348
+ log(' required_conversation_resolution: not set (no base protection rule on this branch)');
349
+ log(' Cannot enable yet: this branch has no base protection rule.');
350
+ log(` Set one up first: Settings → Branches → Add rule for ${branch}`);
351
+ log(' Then re-run clud-bug init (or toggle the setting in the GUI).');
352
+ return;
353
+ }
354
+
355
+ // current.state is 'disabled'.
356
+ log(' required_conversation_resolution: not set');
357
+
358
+ // Decide whether to prompt.
359
+ let shouldEnable;
360
+ if (args.acceptAll) {
361
+ // --accept-all is a real side-effect flag here: it flips a
362
+ // merge-gating repo setting. Make that explicit in the log so
363
+ // CI users running `clud-bug init --accept-all` see exactly
364
+ // what's happening instead of silently noticing later.
365
+ log(' --accept-all: will enable required_conversation_resolution. Pass --no-set-protection to skip.');
366
+ shouldEnable = true;
367
+ } else {
368
+ const ask = prompt ?? (async (q) => {
369
+ const rl = createInterface({ input, output });
370
+ try { return await rl.question(q); } finally { rl.close(); }
371
+ });
372
+ log('');
373
+ log(' Clud Bug auto-resolves its own review threads when fixes land.');
374
+ log(' Without required_conversation_resolution, that doesn\'t actually gate merges.');
375
+ const answer = await ask(` Enable required_conversation_resolution on ${branch}? [Y/n] `);
376
+ shouldEnable = !['n', 'no'].includes(answer.trim().toLowerCase());
377
+ }
378
+
379
+ if (!shouldEnable) {
380
+ log(' Skipped. Re-run with --accept-all or set it manually anytime.');
381
+ return;
382
+ }
383
+
384
+ const result = await enableConversationResolution({ owner, repo, branch, gh });
385
+ if (result.ok) {
386
+ log(' ✓ Enabled required_conversation_resolution.');
387
+ return;
388
+ }
389
+ if (result.state === 'no-protection') {
390
+ log(' Cannot enable: this branch has no base protection rule. Set up basic branch protection first:');
391
+ log(` Settings → Branches → Add rule for ${branch}`);
392
+ log(' Then re-run clud-bug init (or just toggle the setting in the GUI).');
393
+ return;
394
+ }
395
+ if (result.state === 'forbidden') {
396
+ log(' Cannot enable: you do not have admin permissions on this repository.');
397
+ log(' Ask the repo owner to enable it, or re-run with --no-set-protection to silence this prompt.');
398
+ return;
399
+ }
400
+ log(` Cannot enable (${result.reason}). You can enable it manually anytime.`);
401
+ }
402
+
281
403
  async function runList(_args) {
282
404
  const skillsDir = join(process.cwd(), '.claude', 'skills');
283
405
  const groups = await listInstalled(skillsDir);
@@ -0,0 +1,113 @@
1
+ // Helpers for managing `required_conversation_resolution` on the default
2
+ // branch via the `gh` CLI. Factored out so `runInit` can call this without
3
+ // embedding `spawnSync` boilerplate, and so tests can swap a mock for the
4
+ // real `gh` invocation.
5
+ //
6
+ // Why `gh` rather than direct fetch(): clud-bug already depends on `gh`
7
+ // being installed and authenticated (workflows use `gh pr comment`, edit
8
+ // workflows use `gh pr create`). Reusing it inherits the user's auth
9
+ // instead of asking them to set up another token.
10
+ //
11
+ // API endpoints used:
12
+ // GET /repos/{owner}/{repo}
13
+ // → .default_branch
14
+ // GET /repos/{owner}/{repo}/branches/{branch}/protection
15
+ // → .required_conversation_resolution.enabled (true/false), OR 404 if
16
+ // the branch has no protection rule at all.
17
+ // POST /repos/{owner}/{repo}/branches/{branch}/protection/required_conversation_resolution
18
+ // → enables the single flag without touching other settings. This is
19
+ // a real single-flag endpoint; we don't have to GET-merge-PUT the
20
+ // full protection JSON.
21
+
22
+ import { spawn } from 'node:child_process';
23
+
24
+ // Default `gh` invoker: spawns `gh <args>` and resolves with
25
+ // { code, stdout, stderr }. Tests pass a function with the same shape.
26
+ function defaultGh(args, { stdin } = {}) {
27
+ return new Promise((resolve, reject) => {
28
+ const child = spawn('gh', args, { stdio: ['pipe', 'pipe', 'pipe'] });
29
+ let stdout = '';
30
+ let stderr = '';
31
+ child.stdout.on('data', (d) => { stdout += d; });
32
+ child.stderr.on('data', (d) => { stderr += d; });
33
+ child.on('error', reject);
34
+ child.on('close', (code) => resolve({ code, stdout, stderr }));
35
+ if (stdin) child.stdin.end(stdin);
36
+ else child.stdin.end();
37
+ });
38
+ }
39
+
40
+ // Returns { owner, repo } from the local git remote. Uses
41
+ // `gh repo view --json owner,name` so it doesn't depend on parsing URLs.
42
+ export async function detectRepo({ gh = defaultGh } = {}) {
43
+ const { code, stdout, stderr } = await gh(['repo', 'view', '--json', 'owner,name']);
44
+ if (code !== 0) {
45
+ throw new Error(`gh repo view failed (${code}): ${stderr.trim() || '(no stderr)'}`);
46
+ }
47
+ const parsed = JSON.parse(stdout);
48
+ return { owner: parsed.owner.login, repo: parsed.name };
49
+ }
50
+
51
+ // Returns the default branch name (e.g. "main", "master", "trunk").
52
+ export async function detectDefaultBranch({ owner, repo, gh = defaultGh } = {}) {
53
+ const { code, stdout, stderr } = await gh(['api', `repos/${owner}/${repo}`, '--jq', '.default_branch']);
54
+ if (code !== 0) {
55
+ throw new Error(`Could not read default_branch for ${owner}/${repo}: ${stderr.trim() || stdout.trim()}`);
56
+ }
57
+ return stdout.trim();
58
+ }
59
+
60
+ // Inspects the current required_conversation_resolution state. Returns
61
+ // one of:
62
+ // { state: 'enabled' }
63
+ // { state: 'disabled' }
64
+ // { state: 'no-protection' } // branch has no protection rule at all
65
+ // { state: 'forbidden' } // user lacks admin perms
66
+ // { state: 'unknown', reason } // any other failure mode
67
+ //
68
+ // The reason this returns a discriminated union rather than throwing is
69
+ // that runInit decides what to do based on the state: each value above
70
+ // has a different user-facing message and follow-up action.
71
+ export async function getProtectionState({ owner, repo, branch, gh = defaultGh } = {}) {
72
+ const { code, stdout, stderr } = await gh([
73
+ 'api',
74
+ `repos/${owner}/${repo}/branches/${branch}/protection`,
75
+ '--jq', '.required_conversation_resolution.enabled // false',
76
+ ]);
77
+ if (code === 0) {
78
+ return { state: stdout.trim() === 'true' ? 'enabled' : 'disabled' };
79
+ }
80
+ // gh prints HTTP details to stderr. Look for the markers we recognize.
81
+ // We deliberately key on 403 / 'Forbidden' / 'Resource not accessible'
82
+ // rather than the bare word 'admin' — gh's error vocabulary can mention
83
+ // 'admin' in unrelated contexts (administrator@…, admin api endpoint,
84
+ // future error copy) and we don't want to misclassify those as
85
+ // permission failures.
86
+ const blob = `${stdout}\n${stderr}`;
87
+ if (/404|Branch not protected|Not Found/i.test(blob)) return { state: 'no-protection' };
88
+ if (/403|Forbidden|Resource not accessible/i.test(blob)) return { state: 'forbidden' };
89
+ return { state: 'unknown', reason: stderr.trim() || stdout.trim() || `gh exited ${code}` };
90
+ }
91
+
92
+ // Enables the single flag via the dedicated endpoint. Doesn't touch any
93
+ // other protection settings. Returns { ok: true } on success or
94
+ // { ok: false, state, reason } using the same state taxonomy as
95
+ // getProtectionState() so callers can produce a consistent message.
96
+ export async function enableConversationResolution({ owner, repo, branch, gh = defaultGh } = {}) {
97
+ const { code, stdout, stderr } = await gh([
98
+ 'api', '-X', 'POST',
99
+ `repos/${owner}/${repo}/branches/${branch}/protection/required_conversation_resolution`,
100
+ ]);
101
+ if (code === 0) return { ok: true };
102
+ // Match the same precise alternatives as getProtectionState — no bare
103
+ // 'admin' fallback to avoid misclassifying unrelated error messages
104
+ // that happen to contain the word.
105
+ const blob = `${stdout}\n${stderr}`;
106
+ if (/404|Branch not protected|Not Found/i.test(blob)) {
107
+ return { ok: false, state: 'no-protection', reason: 'Branch has no base protection rule; enable basic branch protection first.' };
108
+ }
109
+ if (/403|Forbidden|Resource not accessible/i.test(blob)) {
110
+ return { ok: false, state: 'forbidden', reason: 'You do not have admin permissions on this repository.' };
111
+ }
112
+ return { ok: false, state: 'unknown', reason: stderr.trim() || stdout.trim() || `gh exited ${code}` };
113
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clud-bug",
3
- "version": "0.5.3",
3
+ "version": "0.5.5",
4
4
  "description": "Claude PR review with project-aware skills. CLI installs a working GitHub Actions workflow and curates skills from skills.sh.",
5
5
  "homepage": "https://cludbug.dev",
6
6
  "bugs": "https://github.com/thrillmot/clud-bug/issues",
@@ -113,6 +113,40 @@ jobs:
113
113
 
114
114
  ## 🐛 Clud Bug review
115
115
 
116
+ Immediately after the H2 header — on the next non-empty
117
+ line — emit a status block in this exact format:
118
+
119
+ **This round:** N critical · N minor · N resolved from prior · N still open
120
+
121
+ This applies to BOTH the bare "## 🐛 Clud Bug review" header
122
+ and the strict-mode variants ("— critical findings" /
123
+ "— clean"). The status line goes on the next non-empty line
124
+ regardless of which header you used. Do not omit the H2
125
+ header variant in strict mode just to fit the status line —
126
+ the strict-mode gate reads the H2 line and would break.
127
+
128
+ The four counters (always include all four, even when 0 —
129
+ fixed format is grep-able and lets agents reading the
130
+ comment parse it deterministically):
131
+ • critical — count of NEW critical findings
132
+ in this review (the ones strict
133
+ mode gates on)
134
+ • minor — count of non-critical findings
135
+ (suggestions / nits / observations)
136
+ • resolved from prior — count of prior unresolved threads
137
+ YOU (claude[bot]) just resolved on
138
+ this pass via resolveReviewThread
139
+ (the loop-closing signal — this
140
+ tells the author the bot read
141
+ their fixes)
142
+ • still open — count of prior unresolved threads
143
+ whose issue still stands AFTER
144
+ this pass
145
+
146
+ On a first-time review, "resolved from prior" and "still
147
+ open" are both 0. On follow-up reviews after a fix-push,
148
+ "resolved from prior" should typically be positive.
149
+
116
150
  Post it via:
117
151
  gh pr comment "$PR_NUMBER" --body "<your review>"
118
152
 
@@ -114,6 +114,40 @@ jobs:
114
114
 
115
115
  ## 🐛 Clud Bug review
116
116
 
117
+ Immediately after the H2 header — on the next non-empty
118
+ line — emit a status block in this exact format:
119
+
120
+ **This round:** N critical · N minor · N resolved from prior · N still open
121
+
122
+ This applies to BOTH the bare "## 🐛 Clud Bug review" header
123
+ and the strict-mode variants ("— critical findings" /
124
+ "— clean"). The status line goes on the next non-empty line
125
+ regardless of which header you used. Do not omit the H2
126
+ header variant in strict mode just to fit the status line —
127
+ the strict-mode gate reads the H2 line and would break.
128
+
129
+ The four counters (always include all four, even when 0 —
130
+ fixed format is grep-able and lets agents reading the
131
+ comment parse it deterministically):
132
+ • critical — count of NEW critical findings
133
+ in this review (the ones strict
134
+ mode gates on)
135
+ • minor — count of non-critical findings
136
+ (suggestions / nits / observations)
137
+ • resolved from prior — count of prior unresolved threads
138
+ YOU (claude[bot]) just resolved on
139
+ this pass via resolveReviewThread
140
+ (the loop-closing signal — this
141
+ tells the author the bot read
142
+ their fixes)
143
+ • still open — count of prior unresolved threads
144
+ whose issue still stands AFTER
145
+ this pass
146
+
147
+ On a first-time review, "resolved from prior" and "still
148
+ open" are both 0. On follow-up reviews after a fix-push,
149
+ "resolved from prior" should typically be positive.
150
+
117
151
  Post it via:
118
152
  gh pr comment "$PR_NUMBER" --body "<your review>"
119
153
 
@@ -134,6 +134,40 @@ jobs:
134
134
 
135
135
  ## 🐛 Clud Bug review
136
136
 
137
+ Immediately after the H2 header — on the next non-empty
138
+ line — emit a status block in this exact format:
139
+
140
+ **This round:** N critical · N minor · N resolved from prior · N still open
141
+
142
+ This applies to BOTH the bare "## 🐛 Clud Bug review" header
143
+ and the strict-mode variants ("— critical findings" /
144
+ "— clean"). The status line goes on the next non-empty line
145
+ regardless of which header you used. Do not omit the H2
146
+ header variant in strict mode just to fit the status line —
147
+ the strict-mode gate reads the H2 line and would break.
148
+
149
+ The four counters (always include all four, even when 0 —
150
+ fixed format is grep-able and lets agents reading the
151
+ comment parse it deterministically):
152
+ • critical — count of NEW critical findings
153
+ in this review (the ones strict
154
+ mode gates on)
155
+ • minor — count of non-critical findings
156
+ (suggestions / nits / observations)
157
+ • resolved from prior — count of prior unresolved threads
158
+ YOU (claude[bot]) just resolved on
159
+ this pass via resolveReviewThread
160
+ (the loop-closing signal — this
161
+ tells the author the bot read
162
+ their fixes)
163
+ • still open — count of prior unresolved threads
164
+ whose issue still stands AFTER
165
+ this pass
166
+
167
+ On a first-time review, "resolved from prior" and "still
168
+ open" are both 0. On follow-up reviews after a fix-push,
169
+ "resolved from prior" should typically be positive.
170
+
137
171
  Post it via:
138
172
  gh pr comment "$PR_NUMBER" --body "<your review>"
139
173