clud-bug 0.6.34 → 0.7.0-rc.1

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 (95) hide show
  1. package/bin/clud-bug.js +10 -1353
  2. package/dist/cli/agents-md.d.ts +16 -0
  3. package/dist/cli/agents-md.d.ts.map +1 -0
  4. package/dist/cli/agents-md.js +226 -0
  5. package/dist/cli/agents-md.js.map +1 -0
  6. package/dist/cli/audit.d.ts +13 -0
  7. package/dist/cli/audit.d.ts.map +1 -0
  8. package/dist/cli/audit.js +90 -0
  9. package/dist/cli/audit.js.map +1 -0
  10. package/dist/cli/branch-protection.d.ts +57 -0
  11. package/dist/cli/branch-protection.d.ts.map +1 -0
  12. package/dist/cli/branch-protection.js +118 -0
  13. package/dist/cli/branch-protection.js.map +1 -0
  14. package/dist/cli/edit-workflow.d.ts +18 -0
  15. package/dist/cli/edit-workflow.d.ts.map +1 -0
  16. package/dist/cli/edit-workflow.js +43 -0
  17. package/dist/cli/edit-workflow.js.map +1 -0
  18. package/dist/cli/index.d.ts +8 -0
  19. package/dist/cli/index.d.ts.map +1 -0
  20. package/dist/cli/index.js +18 -0
  21. package/dist/cli/index.js.map +1 -0
  22. package/dist/cli/main.d.ts +3 -0
  23. package/dist/cli/main.d.ts.map +1 -0
  24. package/dist/cli/main.js +1336 -0
  25. package/dist/cli/main.js.map +1 -0
  26. package/dist/cli/skill-usage.d.ts +109 -0
  27. package/dist/cli/skill-usage.d.ts.map +1 -0
  28. package/dist/cli/skill-usage.js +380 -0
  29. package/dist/cli/skill-usage.js.map +1 -0
  30. package/dist/cli/skills.d.ts +56 -0
  31. package/dist/cli/skills.d.ts.map +1 -0
  32. package/dist/cli/skills.js +292 -0
  33. package/dist/cli/skills.js.map +1 -0
  34. package/dist/cli/update.d.ts +29 -0
  35. package/dist/cli/update.d.ts.map +1 -0
  36. package/dist/cli/update.js +186 -0
  37. package/dist/cli/update.js.map +1 -0
  38. package/dist/cli/usage.d.ts +142 -0
  39. package/dist/cli/usage.d.ts.map +1 -0
  40. package/dist/cli/usage.js +348 -0
  41. package/dist/cli/usage.js.map +1 -0
  42. package/dist/core/audit.d.ts +8 -0
  43. package/dist/core/audit.d.ts.map +1 -0
  44. package/dist/core/audit.js +47 -0
  45. package/dist/core/audit.js.map +1 -0
  46. package/dist/core/detect.d.ts +77 -0
  47. package/dist/core/detect.d.ts.map +1 -0
  48. package/dist/core/detect.js +262 -0
  49. package/dist/core/detect.js.map +1 -0
  50. package/dist/core/index.d.ts +8 -0
  51. package/dist/core/index.d.ts.map +1 -0
  52. package/dist/core/index.js +14 -0
  53. package/dist/core/index.js.map +1 -0
  54. package/dist/core/prompts.d.ts +9 -0
  55. package/dist/core/prompts.d.ts.map +1 -0
  56. package/dist/core/prompts.js +401 -0
  57. package/dist/core/prompts.js.map +1 -0
  58. package/dist/core/render-review.d.ts +6 -0
  59. package/dist/core/render-review.d.ts.map +1 -0
  60. package/dist/core/render-review.js +219 -0
  61. package/dist/core/render-review.js.map +1 -0
  62. package/dist/core/render.d.ts +13 -0
  63. package/dist/core/render.d.ts.map +1 -0
  64. package/dist/core/render.js +80 -0
  65. package/dist/core/render.js.map +1 -0
  66. package/dist/core/review-schema.d.ts +42 -0
  67. package/dist/core/review-schema.d.ts.map +1 -0
  68. package/dist/core/review-schema.js +156 -0
  69. package/dist/core/review-schema.js.map +1 -0
  70. package/dist/core/skills.d.ts +80 -0
  71. package/dist/core/skills.d.ts.map +1 -0
  72. package/dist/core/skills.js +510 -0
  73. package/dist/core/skills.js.map +1 -0
  74. package/package.json +27 -4
  75. package/{lib/agents-md.js → src/cli/agents-md.ts} +25 -14
  76. package/{lib/audit.js → src/cli/audit.ts} +37 -44
  77. package/{lib/branch-protection.js → src/cli/branch-protection.ts} +75 -11
  78. package/{lib/edit-workflow.js → src/cli/edit-workflow.ts} +32 -11
  79. package/src/cli/index.ts +101 -0
  80. package/src/cli/main.ts +1376 -0
  81. package/{lib/skill-usage.js → src/cli/skill-usage.ts} +168 -94
  82. package/src/cli/skills.ts +386 -0
  83. package/{lib/update.js → src/cli/update.ts} +68 -27
  84. package/{lib/usage.js → src/cli/usage.ts} +167 -76
  85. package/src/core/audit.ts +53 -0
  86. package/{lib/detect.js → src/core/detect.ts} +100 -47
  87. package/src/core/index.ts +70 -0
  88. package/{lib/prompts.js → src/core/prompts.ts} +16 -2
  89. package/{lib/render-review.js → src/core/render-review.ts} +57 -25
  90. package/{lib/render.js → src/core/render.ts} +36 -10
  91. package/{lib/review-schema.js → src/core/review-schema.ts} +68 -5
  92. package/{lib/skills.js → src/core/skills.ts} +172 -343
  93. package/templates/workflow-py.yml.tmpl +2 -2
  94. package/templates/workflow-ts.yml.tmpl +2 -2
  95. package/templates/workflow.yml.tmpl +17 -8
@@ -0,0 +1,1376 @@
1
+ // @ts-nocheck
2
+ //
3
+ // src/cli/main.ts — top-level CLI command dispatch.
4
+ //
5
+ // Lifted verbatim from bin/clud-bug.js during the v0.7.0 TS migration
6
+ // (Wave 3 final commit). Per architect Risk R7, the 1359-LOC bin file
7
+ // would have cost 3-4h of type-annotation churn to convert under
8
+ // NodeNext strict mode; deferring with @ts-nocheck preserves the
9
+ // architectural goal — bin/clud-bug.js becomes a 3-line shim importing
10
+ // the compiled dist/cli/index.js — without the type-checking debt.
11
+ //
12
+ // Future cleanup: strip @ts-nocheck and annotate piece-by-piece in
13
+ // follow-up PRs (most functions take `args: Args` from parseArgs; the
14
+ // shape is stable across the file).
15
+
16
+ import { mkdir, writeFile, readFile } from 'node:fs/promises';
17
+ import { join, dirname } from 'node:path';
18
+ import { fileURLToPath } from 'node:url';
19
+ import { spawnSync, spawn } from 'node:child_process';
20
+ import { createInterface } from 'node:readline/promises';
21
+ import { stdin as input, stdout as output } from 'node:process';
22
+
23
+ import { detect, buildDescriptionLine } from '../core/detect.js';
24
+ import { renderFile, pickTemplate, templateLanguage } from '../core/render.js';
25
+ import { reviewPrompt } from '../core/prompts.js';
26
+ import { SkillsClient, rankAndCap } from '../core/skills.js';
27
+ import {
28
+ writeSkills, writeSkill, loadBaseline,
29
+ readManifest, writeManifest, removeSkill, listInstalled, diffManifest,
30
+ } from './skills.js';
31
+ import { computeAuditFileSet } from './audit.js';
32
+ import { renderAuditHeader } from '../core/audit.js';
33
+ import { runUpdate } from './update.js';
34
+ import { getPendingWorkflowEdits, makeBranchName, git as gitCmd } from './edit-workflow.js';
35
+ import { applyToRepo as applyAgentDocs } from './agents-md.js';
36
+ import { detectRepo, detectDefaultBranch, getProtectionState, enableConversationResolution } from './branch-protection.js';
37
+ import { computeReviewCost, costPerLOC, cacheHitRate, extractTokensFromLog, rollup, formatRollup } from './usage.js';
38
+
39
+ // PKG_ROOT resolution: this file is compiled to dist/cli/main.js, so two
40
+ // dirname() calls climb from `<pkg>/dist/cli/main.js` → `<pkg>/dist` →
41
+ // `<pkg>` (the package root). Previously bin/clud-bug.js used the same
42
+ // two-dirname pattern from `<pkg>/bin/clud-bug.js`.
43
+ const PKG_ROOT = dirname(dirname(dirname(fileURLToPath(import.meta.url))));
44
+ const TEMPLATES = join(PKG_ROOT, 'templates');
45
+ const BASELINE_DIR = join(TEMPLATES, 'skills', 'baseline');
46
+
47
+ function parseArgs(argv) {
48
+ const args = {
49
+ _: [], offline: false, acceptAll: false, commit: false, help: false, version: false,
50
+ since: null, changedIn: null, scopes: [], out: null,
51
+ setProtection: true, quiet: false,
52
+ // 0.0.M.1 (v0.6.13): `clud-bug usage` flags.
53
+ repo: null, pr: null, limit: null, json: false,
54
+ // 0.0.O (v0.6.22): `clud-bug render` reads its payload from stdin.
55
+ stdin: false,
56
+ // v0.6.30: cross-review aggregation read source for `usage --health`.
57
+ // Defaults to true (artifact mode); `--no-artifacts` forces local
58
+ // .clud-bug.json read (matches v0.6.28 behavior).
59
+ artifacts: true,
60
+ // v0.6.33: unified-install mirror — `clud-bug init --with-skdd` also
61
+ // subprocesses to `pip install logmind && logmind init` so Node-first
62
+ // users get the same one-command bootstrap as Python-first users
63
+ // (logmind v0.6.8's --with-skdd is the symmetric counterpart).
64
+ withSkdd: false,
65
+ };
66
+ for (let i = 0; i < argv.length; i++) {
67
+ const a = argv[i];
68
+ if (a === '--offline') args.offline = true;
69
+ else if (a === '--accept-all' || a === '-y') args.acceptAll = true;
70
+ else if (a === '--commit') args.commit = true;
71
+ else if (a === '--help' || a === '-h') args.help = true;
72
+ else if (a === '--version' || a === '-v') args.version = true;
73
+ else if (a === '--quiet' || a === '-q') args.quiet = true;
74
+ else if (a === '--since') args.since = argv[++i];
75
+ else if (a === '--changed-in') args.changedIn = argv[++i];
76
+ else if (a === '--scope') args.scopes.push(argv[++i]);
77
+ else if (a === '--out') args.out = argv[++i];
78
+ else if (a === '--no-set-protection') args.setProtection = false;
79
+ else if (a === '--repo') args.repo = argv[++i];
80
+ else if (a === '--pr') args.pr = Number(argv[++i]);
81
+ else if (a === '--limit') args.limit = Number(argv[++i]);
82
+ else if (a === '--json') args.json = true;
83
+ else if (a === '--stdin') args.stdin = true;
84
+ else if (a === '--health') args.health = true;
85
+ else if (a === '--no-artifacts') args.artifacts = false;
86
+ else if (a === '--with-skdd') args.withSkdd = true;
87
+ else args._.push(a);
88
+ }
89
+ return args;
90
+ }
91
+
92
+ const HELP = `clud-bug 🐛 — a field guide to specimens crawling your code
93
+
94
+ Usage:
95
+ npx clud-bug <command> [options]
96
+
97
+ Commands:
98
+ init Open field season: survey the repo, pin baseline specimens, write the workflows.
99
+ Pass \`--with-skdd\` to also install logmind in one go (requires Python + pip).
100
+ list Show your collection (baseline / from skills.sh / custom).
101
+ add <source/name> Pin one new specimen from skills.sh (e.g. vercel-labs/skills/next-best-practices).
102
+ remove <slug> Unpin a clud-bug-managed specimen (refuses to touch your custom ones).
103
+ refresh Re-survey, diff against your collection, prompt to update.
104
+ audit Walk the whole habitat (or a recent slice) and prepare a report stub.
105
+ Use --since / --changed-in / --scope to narrow.
106
+ update Re-render workflows + refresh baseline specimens to the latest shipped
107
+ templates. Custom and skills.sh-installed specimens left alone.
108
+ edit-workflow Helper for editing .github/workflows/clud-bug-*.yml in an isolated
109
+ PR (the action refuses to review PRs that modify its own workflow).
110
+ usage Read recent clud-bug-review run JSON + normalize cost per LOC.
111
+ Internal Q7-clud-bug enforcement dashboard. Reports cache hit
112
+ rate, 30-day rolling \$/LOC trend, per-repo/per-model
113
+ distributions, and outliers (> 2x org median).
114
+ Use --pr / --repo / --since / --limit / --json to filter.
115
+ usage --health Deterministic skill-health dashboard. Renders archive-
116
+ candidate / stale / new / healthy status per skill, applying
117
+ the v0.6.28 thresholds (citations==0 + loads>=5 → archive
118
+ candidate; last cited >60d → stale; etc.). Read-only —
119
+ humans decide what to prune.
120
+ Read source (v0.6.30): by default, walks
121
+ \`clud-bug-skill-usage-pr-*\` workflow artifacts uploaded
122
+ by every clud-bug-review run and accumulates them into
123
+ one org-level snapshot. Pass \`--repo owner/name\` to
124
+ target a specific repo; otherwise infers from the local
125
+ git remote. \`--no-artifacts\` falls back to reading the
126
+ local \`.claude/skills/.clud-bug.json\` (v0.6.28 behavior).
127
+ eval Run the golden-set regression gate against the rendered review
128
+ prompt (must-contain / must-not-contain / byte-budget). Same as
129
+ \`node --test test/prompts.eval.test.js\` but works from any cwd.
130
+ update-skill-usage Update the .claude/skills/.clud-bug.json usage block from
131
+ a structured-output JSON payload (the action's
132
+ \`outputs.structured_output\`). Called as a workflow
133
+ post-step alongside \`render\` (v0.6.29 / Component 4).
134
+ Pipe the JSON to stdin. Idempotent + atomic write.
135
+ Silent no-op on empty stdin (parity with \`render\`).
136
+ render --stdin Render a structured-output JSON payload (the action's
137
+ \`outputs.structured_output\`, piped via stdin) to the
138
+ GitHub-markdown summary comment shape. Invoked by the
139
+ workflow post-step; output is what \`gh pr comment\`
140
+ receives. Empty stdin or non-object payload exits 2.
141
+
142
+ Options:
143
+ --offline Skip skills.sh; pin only the bundled baseline specimens.
144
+ --accept-all,-y Accept the recommended specimens without prompting.
145
+ --commit git add + commit the generated kit when done (init only).
146
+ --quiet,-q Token-frugal mode for agent invocations. Suppresses
147
+ progress chatter; emits exactly one final
148
+ \`ok <key-value>\` summary line per command. Errors
149
+ and warnings still print. Also honored via the
150
+ CLUD_BUG_QUIET=1 env var.
151
+ --no-set-protection Skip the prompt that offers to enable
152
+ required_conversation_resolution on the default
153
+ branch (init only). Use for repos that manage
154
+ branch protection via ruleset or org policy.
155
+ --repo <owner/name> Restrict \`usage\` to a single repo. Default: all repos
156
+ with clud-bug-review.yml in the gh user's auth scope.
157
+ --pr <N> Restrict \`usage\` to a single PR.
158
+ --limit <N> Max reviews to fetch (default 50; the API caps).
159
+ --json Emit JSON instead of human-readable output.
160
+ Compatible with --quiet for pipeline consumption.
161
+ --since <date> Audit only files changed in commits after <date> (git date string).
162
+ --changed-in <dur> Audit only files changed in the past <dur>: 7d, 2w, 1mo, 1y. (audit only)
163
+ --scope <glob> Limit audit to files matching <glob>; repeatable. (audit only)
164
+ --out <path> Where to write the audit stub. Default: audits/YYYY-MM-DD.md
165
+ --help,-h Show this help.
166
+ --version,-v Show version.
167
+ `;
168
+
169
+ async function readPkgVersion() {
170
+ const pkg = JSON.parse(await readFile(join(PKG_ROOT, 'package.json'), 'utf8'));
171
+ return pkg.version;
172
+ }
173
+
174
+ async function main() {
175
+ const args = parseArgs(process.argv.slice(2));
176
+ if (args.help) { process.stdout.write(HELP); return; }
177
+ if (args.version) { process.stdout.write((await readPkgVersion()) + '\n'); return; }
178
+ if (args.quiet) setQuiet(true);
179
+
180
+ const cmd = args._[0];
181
+ switch (cmd) {
182
+ case 'init': return runInit(args);
183
+ case 'list': return runList(args);
184
+ case 'add': return runAdd(args);
185
+ case 'remove': return runRemove(args);
186
+ case 'refresh': return runRefresh(args);
187
+ case 'audit': return runAudit(args);
188
+ case 'update': return runUpdateCmd(args);
189
+ case 'edit-workflow': return runEditWorkflow(args);
190
+ case 'usage': return runUsage(args);
191
+ case 'eval': return runEval();
192
+ case 'render': return runRender(args);
193
+ case 'update-skill-usage': return runUpdateSkillUsage(args);
194
+ default:
195
+ process.stderr.write(`Unknown command: ${cmd || '(none)'}\n\n${HELP}`);
196
+ process.exit(2);
197
+ }
198
+ }
199
+
200
+ // 0.0.O (v0.6.22): render a structured-output JSON payload to the
201
+ // GitHub-markdown summary comment shape. Called by the post-step
202
+ // in the workflow templates: it reads the action's
203
+ // `outputs.structured_output` (one bundled JSON string), pipes it
204
+ // to stdin here, and we emit the rendered markdown on stdout for
205
+ // the shell to pass to `gh pr comment --body`.
206
+ //
207
+ // Usage: `clud-bug render --stdin` (only input source supported).
208
+ // Exit code: 0 on success, 2 on JSON parse error or non-object payload.
209
+ async function runRender(args) {
210
+ const { renderReview } = await import('../core/render-review.js');
211
+ if (!args.stdin) {
212
+ process.stderr.write('clud-bug render: --stdin is required (the only supported input source).\n');
213
+ process.exit(2);
214
+ }
215
+ let raw = '';
216
+ for await (const chunk of process.stdin) raw += chunk;
217
+ raw = raw.trim();
218
+ if (!raw) {
219
+ // Empty structured_output → post-step is supposed to skip the
220
+ // render. Surface the situation rather than silently producing an
221
+ // empty comment.
222
+ process.stderr.write('clud-bug render: stdin was empty — nothing to render.\n');
223
+ process.exit(2);
224
+ }
225
+ let payload;
226
+ try {
227
+ payload = JSON.parse(raw);
228
+ } catch (e) {
229
+ process.stderr.write(`clud-bug render: JSON parse failed: ${e.message}\n`);
230
+ process.exit(2);
231
+ }
232
+ try {
233
+ process.stdout.write(renderReview(payload));
234
+ } catch (e) {
235
+ process.stderr.write(`clud-bug render: ${e.message}\n`);
236
+ process.exit(2);
237
+ }
238
+ }
239
+
240
+ // v0.6.29 — Component 4. Pipe the action's structured_output through
241
+ // the skill-usage data layer (v0.6.28) + write the merged result back
242
+ // to .claude/skills/.clud-bug.json atomically.
243
+ //
244
+ // Workflow integration (post-step in workflow.yml.tmpl):
245
+ //
246
+ // echo "${{ steps.review.outputs.structured_output }}" \
247
+ // | npx clud-bug@latest update-skill-usage --stdin
248
+ //
249
+ // Runs AFTER the render post-step. Silent no-op on empty stdin
250
+ // (same contract as `render` — preserves the workflow's existing
251
+ // "skip both if empty" branch). Idempotent: running on the same JSON
252
+ // twice produces the same result.
253
+ async function runUpdateSkillUsage(args) {
254
+ const fs = await import('node:fs/promises');
255
+ const path = await import('node:path');
256
+ const {
257
+ computeSkillUsageDelta,
258
+ mergeSkillUsage,
259
+ } = await import('./skill-usage.js');
260
+
261
+ if (!args.stdin) {
262
+ process.stderr.write('clud-bug update-skill-usage: --stdin is required.\n');
263
+ process.exit(2);
264
+ }
265
+
266
+ let raw = '';
267
+ for await (const chunk of process.stdin) raw += chunk;
268
+ raw = raw.trim();
269
+ if (!raw) {
270
+ // Empty structured_output → render is also skipped → nothing to
271
+ // update. Match the render contract: exit 0 with a stderr note.
272
+ process.stderr.write('clud-bug update-skill-usage: stdin empty — no usage update.\n');
273
+ return;
274
+ }
275
+
276
+ let reviewJson;
277
+ try {
278
+ reviewJson = JSON.parse(raw);
279
+ } catch (e) {
280
+ process.stderr.write(`clud-bug update-skill-usage: invalid JSON: ${e.message}\n`);
281
+ process.exit(2);
282
+ }
283
+ if (!reviewJson || typeof reviewJson !== 'object') {
284
+ process.stderr.write('clud-bug update-skill-usage: payload must be a JSON object.\n');
285
+ process.exit(2);
286
+ }
287
+
288
+ // Compute per-review delta. Empty delta is fine — just means no
289
+ // skills loaded or cited (workflow-only PRs, e.g.).
290
+ const delta = computeSkillUsageDelta(reviewJson);
291
+ if (Object.keys(delta).length === 0) {
292
+ process.stderr.write('clud-bug update-skill-usage: no skills in payload — nothing to record.\n');
293
+ return;
294
+ }
295
+
296
+ // Read existing .clud-bug.json. The path is canonical:
297
+ // .claude/skills/.clud-bug.json relative to cwd (the workflow runs
298
+ // from the repo root).
299
+ const jsonPath = path.resolve(process.cwd(), '.claude', 'skills', '.clud-bug.json');
300
+ let parsed;
301
+ try {
302
+ const existingRaw = await fs.readFile(jsonPath, 'utf-8');
303
+ parsed = JSON.parse(existingRaw);
304
+ } catch (err) {
305
+ if (err.code === 'ENOENT') {
306
+ process.stderr.write(
307
+ `clud-bug update-skill-usage: no .clud-bug.json at ${jsonPath} — skipping. ` +
308
+ `Run \`npx clud-bug init\` first.\n`
309
+ );
310
+ return;
311
+ }
312
+ process.stderr.write(`clud-bug update-skill-usage: parse failed: ${err.message}\n`);
313
+ process.exit(2);
314
+ }
315
+ if (!parsed || typeof parsed !== 'object') {
316
+ process.stderr.write('clud-bug update-skill-usage: .clud-bug.json malformed.\n');
317
+ process.exit(2);
318
+ }
319
+
320
+ const existingUsage = parsed.usage || {};
321
+ const timestamp = new Date().toISOString();
322
+ const mergedUsage = mergeSkillUsage(existingUsage, delta, timestamp);
323
+ parsed.usage = mergedUsage;
324
+
325
+ // Write back ATOMICALLY: temp file + rename. Guards against a
326
+ // crashed write leaving the JSON half-written + unparseable on next
327
+ // read (which would brick the entire skill catalog).
328
+ const tmpPath = jsonPath + '.tmp';
329
+ const serialized = JSON.stringify(parsed, null, 2) + '\n';
330
+ await fs.writeFile(tmpPath, serialized, 'utf-8');
331
+ await fs.rename(tmpPath, jsonPath);
332
+
333
+ const skillCount = Object.keys(delta).length;
334
+ ok(`update-skill-usage: merged ${skillCount} skill${skillCount === 1 ? '' : 's'} from review`);
335
+ }
336
+
337
+
338
+ // 0.0.E (v0.6.17): thin wrapper around the golden-set test file. Devs
339
+ // who follow the README invoke `clud-bug eval` — this routes to the
340
+ // same `node --test` runner CI uses, so dev and CI verdicts match.
341
+ //
342
+ // Dev-only: runs against the prompt bundled in PKG_ROOT (the cloned
343
+ // clud-bug repo). `test/` is intentionally not in package.json `files`,
344
+ // so invoking this from a globally installed copy will ENOENT. No args
345
+ // supported yet — the README does not advertise any.
346
+ async function runEval() {
347
+ const result = spawnSync(
348
+ 'node',
349
+ ['--test', join(PKG_ROOT, 'test/prompts.eval.test.js')],
350
+ { stdio: 'inherit' },
351
+ );
352
+ process.exit(result.status ?? 1);
353
+ }
354
+
355
+ async function runInit(args) {
356
+ const cwd = process.cwd();
357
+ log(`🐛 Field season opens in ${cwd}.`);
358
+
359
+ log(' surveying habitat...');
360
+ const signals = await detect(cwd);
361
+ log(` primary language: ${signals.primaryLanguage || '(unknown)'}`);
362
+ log(` search terms: ${signals.searchTerms.join(', ') || '(none)'}`);
363
+
364
+ const baseline = await loadBaseline(BASELINE_DIR);
365
+ const fromAgentSkills = baseline.filter((s) => s._source === 'agent-skills').length;
366
+ const sourceLabel = baseline.length === 0
367
+ ? ''
368
+ : fromAgentSkills === baseline.length ? ' (from thrillmade/agent-skills)'
369
+ : fromAgentSkills === 0 ? ' (bundled fallback)'
370
+ : ` (${fromAgentSkills} from agent-skills, ${baseline.length - fromAgentSkills} bundled)`;
371
+ log(` baseline kit: ${baseline.length} specimens${sourceLabel}`);
372
+
373
+ let curated = [];
374
+ let searched = [];
375
+ if (args.offline) {
376
+ log(' --offline: skipping skills.sh');
377
+ } else {
378
+ const client = new SkillsClient();
379
+ try {
380
+ log(' consulting skills.sh...');
381
+ [curated, searched] = await Promise.all([
382
+ client.curated().catch(err => { warn(`curated query failed: ${err.message}`); return []; }),
383
+ client.search(signals.searchTerms).catch(err => { warn(`search failed: ${err.message}`); return []; }),
384
+ ]);
385
+ log(` curated: ${curated.length}, search hits: ${searched.length}`);
386
+ } catch (err) {
387
+ warn(`skills.sh unreachable (${err.message}); continuing with baseline only`);
388
+ }
389
+ }
390
+
391
+ const recommended = rankAndCap(curated, searched, baseline);
392
+ log('');
393
+ log('Specimens to pin:');
394
+ for (const s of recommended) {
395
+ const tag = s.kind === 'baseline' ? '[baseline]' : `[${s.source}]`;
396
+ log(` • ${s.name} ${tag}`);
397
+ if (s.description && s.kind !== 'baseline') log(` ${s.description}`);
398
+ }
399
+ log('');
400
+
401
+ let chosen = recommended;
402
+ if (!args.acceptAll && recommended.some(s => s.kind !== 'baseline')) {
403
+ chosen = await promptForSkills(recommended);
404
+ }
405
+
406
+ log(' pinning specimens to .claude/skills/...');
407
+ const client = new SkillsClient();
408
+ const written = await writeSkills(join(cwd, '.claude', 'skills'), chosen, client);
409
+ log(` pinned ${written.length} specimens`);
410
+
411
+ // Empty-skills warning: clud-bug shines when paired with project-specific
412
+ // skills. Reviews that load only the three baselines are functional but
413
+ // generic; flag this so users notice.
414
+ const remoteCount = written.filter((w) => w.kind !== 'baseline').length;
415
+ if (remoteCount === 0) {
416
+ warn('Only baseline specimens pinned. Add project-specific skills via `clud-bug add vercel-labs/skills/<name>` or drop your own `.claude/skills/<name>/SKILL.md`.');
417
+ }
418
+
419
+ log(' drafting field kit...');
420
+ const tmplName = pickTemplate(signals.languages);
421
+ const tmplPath = join(TEMPLATES, tmplName);
422
+ // REVIEW_SCHEMA + CCA_VERSION + CLUD_BUG_VERSION come from render.js DEFAULTS.
423
+ const workflow = await renderFile(tmplPath, {
424
+ REVIEW_PROMPT: reviewPrompt({
425
+ projectDescription: buildDescriptionLine(signals),
426
+ language: templateLanguage(tmplName),
427
+ }),
428
+ });
429
+ const workflowPath = join(cwd, '.github', 'workflows', 'clud-bug-review.yml');
430
+ await mkdir(dirname(workflowPath), { recursive: true });
431
+ await writeFile(workflowPath, workflow);
432
+ log(` wrote ${rel(cwd, workflowPath)}`);
433
+
434
+ // Install the audit workflow alongside the per-PR review one.
435
+ // Manual-trigger by default; users opt into the cron by uncommenting.
436
+ // Routed through renderFile so {{CCA_VERSION}} substitution pins
437
+ // claude-code-action consistently with the review workflow.
438
+ const auditTmpl = await renderFile(join(TEMPLATES, 'audit.yml.tmpl'), {});
439
+ const auditPath = join(cwd, '.github', 'workflows', 'clud-bug-audit.yml');
440
+ await writeFile(auditPath, auditTmpl);
441
+ log(` wrote ${rel(cwd, auditPath)}`);
442
+
443
+ // Install the self-update workflow. Cron weekly Mondays 12:00 UTC; opens
444
+ // a PR if a newer clud-bug version is published. Disable by deleting the
445
+ // file or pinning via .claude/skills/.clud-bug.json.
446
+ // Routed through renderFile for parity (no CCA ref today but future
447
+ // tokens should propagate uniformly).
448
+ const selfUpdateTmpl = await renderFile(join(TEMPLATES, 'self-update.yml.tmpl'), {});
449
+ const selfUpdatePath = join(cwd, '.github', 'workflows', 'clud-bug-self-update.yml');
450
+ await writeFile(selfUpdatePath, selfUpdateTmpl);
451
+ log(` wrote ${rel(cwd, selfUpdatePath)}`);
452
+
453
+ // Stamp the manifest. Sets strictMode: true ONLY on fresh installs —
454
+ // a manifest that's never been touched by clud-bug init/update has no
455
+ // lastUpdate field. Existing v0.3.x advisory installs (where strictMode
456
+ // was never written and so == undefined) keep their advisory behavior
457
+ // because lastUpdate IS set; the strictMode default only fires on truly
458
+ // fresh inits. Users opt out by setting strictMode: false.
459
+ const skillsDirPath = join(cwd, '.claude', 'skills');
460
+ const manifest = await readManifest(skillsDirPath);
461
+ const isFreshInstall = manifest.lastUpdate === undefined;
462
+ manifest.lastUpdateVersion = await readPkgVersion();
463
+ manifest.lastUpdate = new Date().toISOString();
464
+ if (isFreshInstall && manifest.strictMode === undefined) {
465
+ manifest.strictMode = true;
466
+ }
467
+ await writeManifest(skillsDirPath, manifest);
468
+
469
+ // Tell other agents what's installed and how to coexist with the bot.
470
+ // Idempotent — re-runs replace the prior block in place. AGENTS.md is the
471
+ // canonical home (cross-tool); CLAUDE.md / GEMINI.md / Cursor / Windsurf
472
+ // / Cline / Continue rules files get the same block appended IF they
473
+ // already exist (we don't proliferate stubs the user didn't ask for).
474
+ log(' briefing other agents (AGENTS.md / CLAUDE.md)...');
475
+ // Pass `=== true` (not `!== false`) so the rendered block matches the
476
+ // workflow's gate predicate exactly. A v0.3 advisory upgrade where
477
+ // strictMode is undefined renders "off" — which is what the workflow
478
+ // actually does on that manifest.
479
+ const agentDocs = await applyAgentDocs(cwd, {
480
+ version: manifest.lastUpdateVersion,
481
+ strictMode: manifest.strictMode === true,
482
+ });
483
+ for (const p of agentDocs.created) log(` created ${p}`);
484
+ for (const p of agentDocs.touched) log(` updated ${p}`);
485
+
486
+ if (args.commit) {
487
+ log(' committing...');
488
+ const toAdd = [
489
+ '.claude',
490
+ '.github/workflows/clud-bug-review.yml',
491
+ '.github/workflows/clud-bug-audit.yml',
492
+ '.github/workflows/clud-bug-self-update.yml',
493
+ ...agentDocs.created,
494
+ ...agentDocs.touched,
495
+ ];
496
+ spawnSync('git', ['add', ...toAdd], { cwd, stdio: 'inherit' });
497
+ spawnSync('git', ['commit', '-m', 'Add clud-bug 🐛 — a field guide to specimens crawling your code'], { cwd, stdio: 'inherit' });
498
+ }
499
+
500
+ // Offer to enable required_conversation_resolution on the default
501
+ // branch. clud-bug auto-resolves its own review threads when fixes
502
+ // land — without this setting, that doesn't gate merges. Skipped on
503
+ // --no-set-protection for repos that manage protection via ruleset
504
+ // or org policy.
505
+ await runInitBranchProtection(args);
506
+
507
+ log('');
508
+ log('Field kit assembled. Next:');
509
+ log(' 1. Set ANTHROPIC_API_KEY in your repo secrets:');
510
+ log(' Settings → Secrets and variables → Actions → New repository secret');
511
+ if (!args.commit) {
512
+ log(' 2. git add .claude .github/workflows/clud-bug-*.yml && git commit && git push');
513
+ log(' 3. Open a PR — the naturalist arrives within ~2 minutes.');
514
+ } else {
515
+ log(' 2. git push, then open a PR — the naturalist arrives within ~2 minutes.');
516
+ }
517
+ log('');
518
+ log('Drop your own .claude/skills/<name>/SKILL.md files anytime — they get pinned automatically.');
519
+ log('For a whole-repo walk: Actions tab → Clud Bug 🐛 Audit → Run workflow.');
520
+ log('Self-update is on (weekly Mondays 12:00 UTC). Pin via "pinVersion" in .claude/skills/.clud-bug.json.');
521
+ log('');
522
+ log('Strict mode is ON by default (clud-bug-review fails the check on critical findings).');
523
+ log(' • Add `clud-bug-review` to your branch protection required checks for full enforcement.');
524
+ log(' • Opt out by setting "strictMode": false in .claude/skills/.clud-bug.json.');
525
+
526
+ // v0.6.33 — opt-in unified install (mirror of logmind v0.6.8). When
527
+ // --with-skdd is passed, subprocess to `pip install logmind` + `logmind init`
528
+ // so Node-first users get the same one-command bootstrap as Python-first
529
+ // users do via `logmind init --with-skdd`.
530
+ // ANTI-LOOP: invoke `logmind init` (NOT `logmind init --with-skdd`).
531
+ // Each opt-in flag only goes one level — no mutual recursion possible.
532
+ if (args.withSkdd) {
533
+ await installLogmindViaPip();
534
+ }
535
+
536
+ // Final agent-friendly summary line (always emitted, even with --quiet).
537
+ const version = await readPkgVersion();
538
+ ok(`initialized: .claude/skills/ ${chosen.length} specimens, workflow @v${version}`);
539
+ }
540
+
541
+ async function installLogmindViaPip() {
542
+ // `spawn` is already imported at module top (line 5). No dynamic
543
+ // re-import needed.
544
+ //
545
+ // Warnings use process.stderr.write directly (always emitted, even
546
+ // under CLUD_BUG_QUIET=1) — recovery hints MUST surface to the user.
547
+ // The standard `log()` is for progress chatter which quiet suppresses.
548
+
549
+ // Find pip via fallback chain (pip → pip3 → python -m pip).
550
+ const pipCmd = await findPipCommand();
551
+ if (!pipCmd) {
552
+ process.stderr.write(
553
+ '\nWarning: --with-skdd requested but no `pip`/`pip3`/`python` found on PATH.\n' +
554
+ ' Install Python 3.10+ (https://python.org), then run:\n' +
555
+ ' pip install logmind && logmind init\n' +
556
+ ' Or skip this flag if you only want clud-bug standalone.\n'
557
+ );
558
+ return;
559
+ }
560
+
561
+ log('');
562
+ log(`→ --with-skdd: installing logmind (${pipCmd.join(' ')} install logmind)`);
563
+
564
+ const installCode = await new Promise((resolve) => {
565
+ const child = spawn(pipCmd[0], [...pipCmd.slice(1), 'install', 'logmind'], { stdio: 'inherit' });
566
+ child.on('error', () => resolve(127));
567
+ child.on('close', (code) => resolve(code ?? 1));
568
+ });
569
+ if (installCode !== 0) {
570
+ process.stderr.write(
571
+ `Warning: \`pip install logmind\` exited ${installCode}.\n` +
572
+ ' clud-bug side succeeded; logmind install is incomplete.\n' +
573
+ ' Inspect output above and re-run manually if needed.\n'
574
+ );
575
+ return;
576
+ }
577
+
578
+ log(`→ --with-skdd: running \`logmind init\` to scaffold the logmind side`);
579
+ const initCode = await new Promise((resolve) => {
580
+ const child = spawn('logmind', ['init'], { stdio: 'inherit' });
581
+ child.on('error', () => resolve(127));
582
+ child.on('close', (code) => resolve(code ?? 1));
583
+ });
584
+ if (initCode !== 0) {
585
+ process.stderr.write(
586
+ `Warning: \`logmind init\` exited ${initCode}. logmind install completed `
587
+ + `but init scaffolding is incomplete. Re-run manually to finish.\n`
588
+ );
589
+ return;
590
+ }
591
+ log('✓ logmind installed via --with-skdd');
592
+ }
593
+
594
+ async function findPipCommand() {
595
+ // Try pip → pip3 → python -m pip → python3 -m pip in order. First one
596
+ // that responds to --version wins. Returns array form for spawn().
597
+ // `spawn` is already imported at module top.
598
+ const candidates = [
599
+ ['pip'],
600
+ ['pip3'],
601
+ ['python', '-m', 'pip'],
602
+ ['python3', '-m', 'pip'],
603
+ ];
604
+ for (const cmd of candidates) {
605
+ const ok = await new Promise((resolve) => {
606
+ const child = spawn(cmd[0], [...cmd.slice(1), '--version'], { stdio: ['ignore', 'ignore', 'ignore'] });
607
+ child.on('error', () => resolve(false));
608
+ child.on('close', (code) => resolve(code === 0));
609
+ });
610
+ if (ok) return cmd;
611
+ }
612
+ return null;
613
+ }
614
+
615
+ async function promptForSkills(recommended) {
616
+ const rl = createInterface({ input, output });
617
+ try {
618
+ const answer = await rl.question('Install all of the above? [Y/n/select] ');
619
+ const a = answer.trim().toLowerCase();
620
+ if (a === '' || a === 'y' || a === 'yes') return recommended;
621
+ if (a === 'n' || a === 'no') return recommended.filter(s => s.kind === 'baseline');
622
+ if (a === 's' || a === 'select') {
623
+ const chosen = [];
624
+ for (const skill of recommended) {
625
+ if (skill.kind === 'baseline') { chosen.push(skill); continue; }
626
+ const ans = await rl.question(` install ${skill.name}? [Y/n] `);
627
+ if (ans.trim().toLowerCase() !== 'n') chosen.push(skill);
628
+ }
629
+ return chosen;
630
+ }
631
+ return recommended;
632
+ } finally {
633
+ rl.close();
634
+ }
635
+ }
636
+
637
+ // Branch-protection setup step at the end of `clud-bug init`.
638
+ // Offers to enable required_conversation_resolution on the default
639
+ // branch via gh API. Skipped cleanly when --no-set-protection is
640
+ // passed. Failure modes (no admin perms, no base protection rule,
641
+ // network error) all degrade to advisory log messages — they never
642
+ // fail the init run.
643
+ //
644
+ // gh and prompt are injectable for tests (defaults to spawning real
645
+ // gh + reading from real stdin).
646
+ async function runInitBranchProtection(args, { gh, prompt } = {}) {
647
+ if (!args.setProtection) {
648
+ log('');
649
+ log('🐛 Branch protection: skipped (--no-set-protection).');
650
+ return;
651
+ }
652
+ log('');
653
+ log('🐛 Branch protection');
654
+
655
+ // Detect repo + default branch. If gh isn't installed or the local
656
+ // dir isn't a github repo, treat as advisory and move on.
657
+ let owner, repo, branch;
658
+ try {
659
+ ({ owner, repo } = await detectRepo({ gh }));
660
+ branch = await detectDefaultBranch({ owner, repo, gh });
661
+ } catch (err) {
662
+ log(` Could not detect repo/branch (${err.message.split('\n')[0]}). Skipping.`);
663
+ log(' You can enable it manually: gh api -X POST repos/<owner>/<repo>/branches/<default>/protection/required_conversation_resolution');
664
+ return;
665
+ }
666
+
667
+ log(` Default branch: ${branch}`);
668
+
669
+ // Inspect current state.
670
+ const current = await getProtectionState({ owner, repo, branch, gh });
671
+ if (current.state === 'enabled') {
672
+ log(' required_conversation_resolution: already on — your repo is all set.');
673
+ return;
674
+ }
675
+ if (current.state === 'forbidden') {
676
+ 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.');
677
+ return;
678
+ }
679
+ if (current.state === 'unknown') {
680
+ log(` Could not read branch protection (${current.reason}). Skipping.`);
681
+ return;
682
+ }
683
+
684
+ // Short-circuit on no-protection BEFORE prompting. The single-flag
685
+ // POST endpoint requires a base protection rule on the branch — if
686
+ // there's none, enableConversationResolution would just 404. Skip
687
+ // the prompt and go straight to the actionable guidance (set up
688
+ // basic protection first, then re-run).
689
+ if (current.state === 'no-protection') {
690
+ log(' required_conversation_resolution: not set (no base protection rule on this branch)');
691
+ log(' Cannot enable yet: this branch has no base protection rule.');
692
+ log(` Set one up first: Settings → Branches → Add rule for ${branch}`);
693
+ log(' Then re-run clud-bug init (or toggle the setting in the GUI).');
694
+ return;
695
+ }
696
+
697
+ // current.state is 'disabled'.
698
+ log(' required_conversation_resolution: not set');
699
+
700
+ // Decide whether to prompt.
701
+ let shouldEnable;
702
+ if (args.acceptAll) {
703
+ // --accept-all is a real side-effect flag here: it flips a
704
+ // merge-gating repo setting. Make that explicit in the log so
705
+ // CI users running `clud-bug init --accept-all` see exactly
706
+ // what's happening instead of silently noticing later.
707
+ log(' --accept-all: will enable required_conversation_resolution. Pass --no-set-protection to skip.');
708
+ shouldEnable = true;
709
+ } else {
710
+ const ask = prompt ?? (async (q) => {
711
+ const rl = createInterface({ input, output });
712
+ try { return await rl.question(q); } finally { rl.close(); }
713
+ });
714
+ log('');
715
+ log(' Clud Bug auto-resolves its own review threads when fixes land.');
716
+ log(' Without required_conversation_resolution, that doesn\'t actually gate merges.');
717
+ const answer = await ask(` Enable required_conversation_resolution on ${branch}? [Y/n] `);
718
+ shouldEnable = !['n', 'no'].includes(answer.trim().toLowerCase());
719
+ }
720
+
721
+ if (!shouldEnable) {
722
+ log(' Skipped. Re-run with --accept-all or set it manually anytime.');
723
+ return;
724
+ }
725
+
726
+ const result = await enableConversationResolution({ owner, repo, branch, gh });
727
+ if (result.ok) {
728
+ log(' ✓ Enabled required_conversation_resolution.');
729
+ return;
730
+ }
731
+ if (result.state === 'no-protection') {
732
+ log(' Cannot enable: this branch has no base protection rule. Set up basic branch protection first:');
733
+ log(` Settings → Branches → Add rule for ${branch}`);
734
+ log(' Then re-run clud-bug init (or just toggle the setting in the GUI).');
735
+ return;
736
+ }
737
+ if (result.state === 'forbidden') {
738
+ log(' Cannot enable: you do not have admin permissions on this repository.');
739
+ log(' Ask the repo owner to enable it, or re-run with --no-set-protection to silence this prompt.');
740
+ return;
741
+ }
742
+ log(` Cannot enable (${result.reason}). You can enable it manually anytime.`);
743
+ }
744
+
745
+ async function runList(_args) {
746
+ const skillsDir = join(process.cwd(), '.claude', 'skills');
747
+ const groups = await listInstalled(skillsDir);
748
+ const total = groups.baseline.length + groups.remote.length + groups.custom.length;
749
+ if (total === 0) {
750
+ log('Empty collection. Run `clud-bug init` to open field season.');
751
+ ok('list: 0 skills installed (run `clud-bug init` first)');
752
+ return;
753
+ }
754
+ log(`🐛 ${total} specimen${total === 1 ? '' : 's'} pinned in .claude/skills/`);
755
+ if (groups.baseline.length) {
756
+ log('');
757
+ log('Baseline (always pinned):');
758
+ for (const s of groups.baseline) log(` • ${s.slug}`);
759
+ }
760
+ if (groups.remote.length) {
761
+ log('');
762
+ log('From skills.sh:');
763
+ for (const s of groups.remote) log(` • ${s.slug} ${s.source ? `[${s.source}]` : ''}`);
764
+ }
765
+ if (groups.custom.length) {
766
+ log('');
767
+ log('Custom (your own — never auto-modified):');
768
+ for (const s of groups.custom) {
769
+ log(` • ${s.slug}${s.description ? ` — ${s.description}` : ''}`);
770
+ }
771
+ }
772
+ ok(`list: ${total} skills (baseline=${groups.baseline.length}, remote=${groups.remote.length}, custom=${groups.custom.length})`);
773
+ }
774
+
775
+ async function runAdd(args) {
776
+ const ref = args._[1];
777
+ if (!ref || !ref.includes('/')) {
778
+ process.stderr.write('Usage: clud-bug add <source/name> (e.g. vercel-labs/skills/next-best-practices)\n');
779
+ process.exit(2);
780
+ }
781
+ // Last segment is the skill name; everything before is the source repo path.
782
+ const lastSlash = ref.lastIndexOf('/');
783
+ const source = ref.slice(0, lastSlash);
784
+ const name = ref.slice(lastSlash + 1);
785
+ const skillsDir = join(process.cwd(), '.claude', 'skills');
786
+ log(` fetching ${source}/${name} from skills.sh...`);
787
+ const client = new SkillsClient();
788
+ const entry = await writeSkill(skillsDir, { source, name, kind: 'remote' }, client);
789
+ const manifest = await readManifest(skillsDir);
790
+ // Mutate in place so caller-set fields on the manifest (pinVersion,
791
+ // lastUpdate, lastUpdateVersion) survive the add. Building a fresh
792
+ // {version, installed} object would silently drop them.
793
+ manifest.installed = [...manifest.installed.filter((e) => e.slug !== entry.slug), entry];
794
+ await writeManifest(skillsDir, manifest);
795
+ log(` ✓ pinned ${entry.slug} → .claude/skills/${entry.slug}/SKILL.md`);
796
+ log(' Commit + push to apply on the next PR.');
797
+ ok(`added: .claude/skills/${entry.slug}/SKILL.md`);
798
+ }
799
+
800
+ async function runRemove(args) {
801
+ const slug = args._[1];
802
+ if (!slug) {
803
+ process.stderr.write('Usage: clud-bug remove <slug> (run `clud-bug list` to see installed slugs)\n');
804
+ process.exit(2);
805
+ }
806
+ const skillsDir = join(process.cwd(), '.claude', 'skills');
807
+ const entry = await removeSkill(skillsDir, slug);
808
+ log(` ✓ unpinned ${entry.slug}${entry.kind === 'baseline' ? ' (baseline — returns on next init)' : ''}`);
809
+ ok(`removed: ${entry.slug}${entry.kind === 'baseline' ? ' (baseline)' : ''}`);
810
+ }
811
+
812
+ async function runRefresh(args) {
813
+ const cwd = process.cwd();
814
+ const skillsDir = join(cwd, '.claude', 'skills');
815
+ const manifest = await readManifest(skillsDir);
816
+ if (manifest.installed.length === 0) {
817
+ log('No clud-bug-managed specimens found. Run `clud-bug init` first.');
818
+ ok('refreshed: 0 skills installed (run `clud-bug init` first)');
819
+ return;
820
+ }
821
+
822
+ log(' re-surveying habitat...');
823
+ const signals = await detect(cwd);
824
+ log(` primary language: ${signals.primaryLanguage || '(unknown)'}`);
825
+ log(` search terms: ${signals.searchTerms.join(', ') || '(none)'}`);
826
+
827
+ const baseline = await loadBaseline(BASELINE_DIR);
828
+ let curated = [];
829
+ let searched = [];
830
+ if (args.offline) {
831
+ log(' --offline: skipping skills.sh — only baseline additions will be diffed; existing remote skills are preserved');
832
+ } else {
833
+ const client = new SkillsClient();
834
+ let curatedErr, searchedErr;
835
+ [curated, searched] = await Promise.all([
836
+ client.curated().catch(err => { curatedErr = err; return []; }),
837
+ client.search(signals.searchTerms).catch(err => { searchedErr = err; return []; }),
838
+ ]);
839
+ if (curatedErr || searchedErr) {
840
+ const err = curatedErr || searchedErr;
841
+ warn(`skills.sh unreachable (${err.message})`);
842
+ warn('refusing to compute removals — an empty API response would look like "delete everything from skills.sh".');
843
+ warn('Try again later, or run with --offline to install only baseline updates.');
844
+ process.exit(1);
845
+ }
846
+ }
847
+ const recommended = rankAndCap(curated, searched, baseline);
848
+ const diff = diffManifest(manifest, recommended);
849
+
850
+ // In --offline mode the recommendation set isn't authoritative (we only have
851
+ // baseline locally), so any "missing from recommendations" entry is a false
852
+ // positive. Suppress removals to avoid mass-deleting the user's remote skills.
853
+ if (args.offline) diff.remove = [];
854
+
855
+ log('');
856
+ log(` add: ${diff.add.length}`);
857
+ log(` remove: ${diff.remove.length} (custom skills untouched)`);
858
+ log(` unchanged: ${diff.unchanged.length}`);
859
+
860
+ if (diff.add.length === 0 && diff.remove.length === 0) {
861
+ log('');
862
+ log('Collection in sync with skills.sh — nothing to update.');
863
+ ok(`refreshed: ${diff.unchanged.length} skills in sync, 0 changes`);
864
+ return;
865
+ }
866
+
867
+ log('');
868
+ for (const s of diff.add) log(` + ${s.name} [${s.source || s.kind}]`);
869
+ for (const s of diff.remove) log(` - ${s.slug} [${s.source || s.kind}]`);
870
+
871
+ if (!args.acceptAll) {
872
+ const rl = createInterface({ input, output });
873
+ const answer = await rl.question('\nApply these changes? [y/N] ');
874
+ rl.close();
875
+ if (answer.trim().toLowerCase() !== 'y') {
876
+ log('Aborted. No files changed.');
877
+ return;
878
+ }
879
+ }
880
+
881
+ const client = new SkillsClient();
882
+ if (diff.add.length) await writeSkills(skillsDir, diff.add, client);
883
+ for (const entry of diff.remove) await removeSkill(skillsDir, entry.slug);
884
+ log(' ✓ collection updated. Commit + push to apply on the next PR.');
885
+ ok(`refreshed: +${diff.add.length} -${diff.remove.length} (${diff.unchanged.length} unchanged)`);
886
+ }
887
+
888
+ async function runEditWorkflow(_args) {
889
+ const cwd = process.cwd();
890
+
891
+ // Validate: must have pending changes, all scoped to clud-bug workflow files.
892
+ let pending;
893
+ try {
894
+ pending = getPendingWorkflowEdits(cwd);
895
+ } catch (err) {
896
+ process.stderr.write(`clud-bug edit-workflow: ${err.message}\n`);
897
+ process.exit(2);
898
+ }
899
+
900
+ if (pending.files.length === 0) {
901
+ log('Nothing to commit. Edit your .github/workflows/clud-bug-*.yml file(s) first, then re-run.');
902
+ ok('branch: (none — no pending workflow edits)');
903
+ return;
904
+ }
905
+ if (!pending.allWorkflow) {
906
+ process.stderr.write(`clud-bug edit-workflow: working tree contains non-workflow changes:\n`);
907
+ for (const f of pending.nonWorkflow) process.stderr.write(` ${f}\n`);
908
+ process.stderr.write(`\nThis command is for isolated workflow-only PRs. Stash or commit the\nnon-workflow changes elsewhere first, then re-run.\n`);
909
+ process.exit(2);
910
+ }
911
+
912
+ log('🐛 Preparing an isolated PR for your workflow edit.');
913
+ const branch = makeBranchName();
914
+ log(` branch: ${branch} (rooted at origin/main)`);
915
+ for (const f of pending.files) log(` • ${f}`);
916
+
917
+ // Stash the pending workflow changes, branch from origin/main explicitly
918
+ // (NOT from HEAD — if the user is on a feature branch with unrelated
919
+ // commits, those would otherwise leak into the "isolated" PR), then
920
+ // restore the changes onto the new branch and commit.
921
+ gitCmd(cwd, ['stash', 'push', '--include-untracked', '-m', 'clud-bug edit-workflow']);
922
+ try {
923
+ gitCmd(cwd, ['fetch', 'origin', 'main', '--depth=1']);
924
+ gitCmd(cwd, ['checkout', '-b', branch, 'origin/main']);
925
+ } catch (err) {
926
+ // Restore the user's stash before bubbling up.
927
+ gitCmd(cwd, ['stash', 'pop'], { allowFail: true });
928
+ throw err;
929
+ }
930
+ const popped = gitCmd(cwd, ['stash', 'pop'], { allowFail: true });
931
+ if (!popped.ok) {
932
+ process.stderr.write(`clud-bug edit-workflow: stash pop conflicted on origin/main — your edits are still in 'git stash'. Resolve manually:\n git stash pop\n`);
933
+ process.exit(1);
934
+ }
935
+ gitCmd(cwd, ['add', ...pending.files]);
936
+ gitCmd(cwd, ['commit', '-m', 'Edit clud-bug workflow']);
937
+ gitCmd(cwd, ['push', '-u', 'origin', branch]);
938
+
939
+ log('');
940
+ log('Done. Open the PR:');
941
+ log(` gh pr create --title "Edit clud-bug workflow" --body "Workflow tweak. The clud-bug-review check on this PR will fail with a 401 (Anthropic's self-protection against PRs that modify the reviewer's own workflow); merge once and subsequent PRs work normally."`);
942
+ ok(`branch: ${branch} (${pending.files.length} file${pending.files.length === 1 ? '' : 's'})`);
943
+ }
944
+
945
+ async function runUpdateCmd(_args) {
946
+ const cwd = process.cwd();
947
+ const ourVersion = await readPkgVersion();
948
+ log(`🐛 Refreshing the field kit (${ourVersion}).`);
949
+
950
+ const result = await runUpdate({
951
+ cwd,
952
+ templatesDir: TEMPLATES,
953
+ baselineDir: BASELINE_DIR,
954
+ ourVersion,
955
+ });
956
+
957
+ if (result.missing === 'init') {
958
+ log(' No clud-bug installation detected. Run `clud-bug init` first.');
959
+ ok('updated: 0 changes (no clud-bug install detected)');
960
+ return;
961
+ }
962
+
963
+ const skipped = result.skipped ?? [];
964
+
965
+ if (result.changed.length === 0 && skipped.length === 0) {
966
+ log(' Already current. Nothing to update.');
967
+ ok(`updated: @v${ourVersion}, 0 changes`);
968
+ return;
969
+ }
970
+
971
+ if (result.changed.length > 0) {
972
+ log(` ✓ Updated ${result.changed.length} file${result.changed.length === 1 ? '' : 's'}:`);
973
+ for (const c of result.changed) {
974
+ const versionNote = c.from && c.to && c.from !== c.to ? ` (${c.label}, ${c.from} → ${c.to})` : ` (${c.label})`;
975
+ log(` • ${rel(cwd, c.path)}${versionNote}`);
976
+ }
977
+ }
978
+ if (result.unchanged.length > 0) {
979
+ log(` ${result.unchanged.length} file${result.unchanged.length === 1 ? ' was' : 's were'} already current.`);
980
+ }
981
+ if (skipped.length > 0) {
982
+ log('');
983
+ log(` ! Skipped ${skipped.length} markerless file${skipped.length === 1 ? '' : 's'} (treated as user-customized):`);
984
+ for (const s of skipped) log(` • ${rel(cwd, s.path)} — ${s.reason}`);
985
+ }
986
+ log('');
987
+ log('Commit + push to apply the refreshed kit on the next PR.');
988
+ ok(`updated: @v${ourVersion}, ${result.changed.length} changed, ${result.unchanged.length} unchanged${skipped.length ? `, ${skipped.length} skipped` : ''}`);
989
+ }
990
+
991
+ async function runAudit(args) {
992
+ const cwd = process.cwd();
993
+ const date = new Date().toISOString().slice(0, 10);
994
+
995
+ let scopeLabel;
996
+ if (args.since) scopeLabel = `commits since ${args.since}`;
997
+ else if (args.changedIn) scopeLabel = `files changed in the past ${args.changedIn}`;
998
+ else if (args.scopes.length) scopeLabel = `glob ${args.scopes.join(', ')}`;
999
+ else scopeLabel = 'all tracked files';
1000
+
1001
+ log(`🐛 Audit walk in ${cwd}.`);
1002
+ log(` scope: ${scopeLabel}`);
1003
+
1004
+ let files;
1005
+ try {
1006
+ files = computeAuditFileSet({
1007
+ cwd,
1008
+ since: args.since,
1009
+ changedIn: args.changedIn,
1010
+ scopes: args.scopes,
1011
+ });
1012
+ } catch (err) {
1013
+ process.stderr.write(`clud-bug audit: ${err.message}\n`);
1014
+ process.exit(2);
1015
+ }
1016
+ log(` surveyed: ${files.length} file${files.length === 1 ? '' : 's'}`);
1017
+
1018
+ if (files.length === 0) {
1019
+ log(' Nothing in scope. Try widening --scope or --changed-in.');
1020
+ ok(`audit: 0 files in scope`);
1021
+ return;
1022
+ }
1023
+
1024
+ const outPath = args.out || join(cwd, 'audits', `${date}.md`);
1025
+ await mkdir(dirname(outPath), { recursive: true });
1026
+ await writeFile(outPath, renderAuditHeader({ date, scopeLabel, files }));
1027
+ log(` ✓ wrote stub: ${rel(cwd, outPath)}`);
1028
+ log('');
1029
+ log('Stub is empty findings — populated by the GitHub Action.');
1030
+ log('Run locally without the workflow if you want — Clud Bug review needs the action runner + ANTHROPIC_API_KEY.');
1031
+ ok(`audit: ${files.length} file${files.length === 1 ? '' : 's'} surveyed; stub at ${rel(cwd, outPath)}`);
1032
+ }
1033
+
1034
+ // 0.0.M.1 (v0.6.13): Q7-clud-bug $/LOC dashboard.
1035
+ //
1036
+ // Reads recent clud-bug-review run JSON via `gh run list` + per-job logs
1037
+ // (which contain the SDK result messages with token counts + model),
1038
+ // joins to `gh pr view --json additions,deletions` for the LOC denominator,
1039
+ // and reports the rollup. Internal-only — not consumer-facing.
1040
+ //
1041
+ // Default scope: 30 days, all repos with clud-bug-review.yml in the gh
1042
+ // user's auth scope. --repo / --pr / --since / --limit narrow.
1043
+ async function runUsage(args) {
1044
+ // v0.6.28 — `clud-bug usage --health`: deterministic skill-health
1045
+ // dashboard. Reads `.claude/skills/.clud-bug.json` usage block,
1046
+ // applies thresholds, renders read-only table. No automation acts
1047
+ // on the output. Per the pragmatic SkDD pivot (2026-05-30).
1048
+ if (args.health) {
1049
+ return runUsageHealth(args);
1050
+ }
1051
+
1052
+ const limit = args.limit ?? 50;
1053
+ const since = args.since ?? '30d';
1054
+
1055
+ // Determine target repos. If --repo specified, just that one. Otherwise
1056
+ // discover repos via the local gh user's auth scope (the org's repos we
1057
+ // own clud-bug-review on).
1058
+ const repos = args.repo
1059
+ ? [args.repo]
1060
+ : await discoverConsumingRepos();
1061
+
1062
+ if (repos.length === 0) {
1063
+ process.stderr.write(
1064
+ 'clud-bug usage: no repos with clud-bug-review.yml found in your gh scope.\n' +
1065
+ 'Pass --repo <owner/name> to point at a specific repo.\n'
1066
+ );
1067
+ process.exit(2);
1068
+ }
1069
+
1070
+ // Per-repo: list recent clud-bug-review runs + extract the per-run job
1071
+ // logs + per-PR LOC counts. Filter to PR runs (drop schedule/dispatch).
1072
+ // PR #104 fix: --pr filter must be applied AFTER resolvePrNumber
1073
+ // (we don't have the PR # until then). prFilter on listRecentRuns was
1074
+ // promised but never applied — bug caught by clud-bug self-review.
1075
+ const reviews = [];
1076
+ for (const repo of repos) {
1077
+ const runs = await listRecentRuns(repo, limit, since, args.pr);
1078
+ if (process.env.CLUD_BUG_DEBUG) process.stderr.write(`DBG: ${repo} runs=${runs.length}\n`);
1079
+ for (const run of runs) {
1080
+ const review = await fetchReviewRecord(repo, run);
1081
+ if (process.env.CLUD_BUG_DEBUG) process.stderr.write(`DBG: ${run.databaseId} ${run.conclusion} → ${review ? 'OK' : 'NULL'}\n`);
1082
+ if (!review) continue;
1083
+ // --pr filter: drop reviews whose PR doesn't match.
1084
+ if (args.pr != null && review.pr !== args.pr) continue;
1085
+ reviews.push(review);
1086
+ }
1087
+ }
1088
+
1089
+ if (reviews.length === 0) {
1090
+ process.stderr.write(
1091
+ `clud-bug usage: no clud-bug-review runs found in scope.\n` +
1092
+ ` scope: ${repos.length} repo${repos.length === 1 ? '' : 's'}, last ${since}, limit ${limit}.\n`
1093
+ );
1094
+ process.exit(2);
1095
+ }
1096
+
1097
+ const summary = rollup(reviews);
1098
+ process.stdout.write(formatRollup(summary, { json: args.json }));
1099
+ if (!args.json) {
1100
+ ok(`usage: ${reviews.length} review${reviews.length === 1 ? '' : 's'} across ${repos.length} repo${repos.length === 1 ? '' : 's'}`);
1101
+ }
1102
+ }
1103
+
1104
+ // `gh repo list` won't filter by workflow file content, so we iterate
1105
+ // repos the user has access to and probe for clud-bug-review.yml. We
1106
+ // v0.6.28 — `clud-bug usage --health` implementation. Reads the local
1107
+ // .claude/skills/.clud-bug.json usage block, applies deterministic
1108
+ // thresholds, renders a read-only dashboard. No I/O beyond the JSON
1109
+ // read.
1110
+ //
1111
+ // v0.6.30 — read accumulated usage from workflow artifacts (uploaded
1112
+ // by v0.6.29's post-step). Defaults to artifact mode when --repo is
1113
+ // passed OR an `owner/name` can be inferred from `git remote`. Falls
1114
+ // back to the local-file path otherwise. The `--no-artifacts` flag
1115
+ // forces the v0.6.28 local-only behavior (handy for tests + offline).
1116
+ async function runUsageHealth(args) {
1117
+ const { assessSkillHealth, formatHealthDashboard } = await import('./skill-usage.js');
1118
+
1119
+ // Decide read source. Priority: explicit --no-artifacts → local;
1120
+ // explicit --repo OR inferred owner/repo → artifacts; else local.
1121
+ const wantArtifacts = args.artifacts !== false;
1122
+ let ownerRepo = null;
1123
+ if (wantArtifacts) {
1124
+ ownerRepo = args.repo || await inferOwnerRepoFromGit();
1125
+ }
1126
+
1127
+ let usage;
1128
+ let source;
1129
+ if (wantArtifacts && ownerRepo) {
1130
+ const result = await loadUsageFromArtifacts(ownerRepo, args);
1131
+ if (result) {
1132
+ usage = result.usage;
1133
+ source = `${result.artifactCount} artifact${result.artifactCount === 1 ? '' : 's'} from ${ownerRepo}`;
1134
+ }
1135
+ }
1136
+
1137
+ // Fallback to local .clud-bug.json (v0.6.28 behavior).
1138
+ if (usage == null) {
1139
+ const localResult = await loadUsageFromLocalFile();
1140
+ if (localResult == null) {
1141
+ // Both paths failed. The local helper has already written its
1142
+ // own stderr explanation; we just exit.
1143
+ process.exit(1);
1144
+ }
1145
+ usage = localResult;
1146
+ source = `local .clud-bug.json`;
1147
+ }
1148
+
1149
+ const rows = assessSkillHealth(usage, new Date());
1150
+ process.stdout.write(formatHealthDashboard(rows) + '\n');
1151
+
1152
+ // Exit code semantics: 0 (informational). The dashboard is read-only;
1153
+ // archive-candidates being present is NOT a failure mode — humans
1154
+ // decide. CI gates should NOT block on this.
1155
+ ok(`skill health: ${rows.length} skill${rows.length === 1 ? '' : 's'} tracked (source: ${source})`);
1156
+ }
1157
+
1158
+ // Helpers split out from runUsageHealth so the two read paths are
1159
+ // independently testable + composable in future commands.
1160
+
1161
+ async function loadUsageFromArtifacts(ownerRepo, args) {
1162
+ const { fetchUsageArtifacts, aggregateUsageStream } = await import('./skill-usage.js');
1163
+ const [owner, repo] = ownerRepo.split('/');
1164
+ if (!owner || !repo) {
1165
+ process.stderr.write(`clud-bug usage --health: --repo must be in owner/name form, got "${ownerRepo}".\n`);
1166
+ return null;
1167
+ }
1168
+ const since = parseSinceArg(args.since);
1169
+ let artifacts;
1170
+ try {
1171
+ artifacts = await fetchUsageArtifacts({ owner, repo, since });
1172
+ } catch (err) {
1173
+ process.stderr.write(`::notice::clud-bug usage --health: artifact fetch failed (${err.message}) — falling back to local .clud-bug.json\n`);
1174
+ return null;
1175
+ }
1176
+ if (artifacts.length === 0) {
1177
+ process.stderr.write(`::notice::clud-bug usage --health: no skill-usage artifacts found in ${ownerRepo} — falling back to local .clud-bug.json\n`);
1178
+ return null;
1179
+ }
1180
+ return {
1181
+ usage: aggregateUsageStream(artifacts),
1182
+ artifactCount: artifacts.length,
1183
+ };
1184
+ }
1185
+
1186
+ async function loadUsageFromLocalFile() {
1187
+ const fs = await import('node:fs/promises');
1188
+ const path = await import('node:path');
1189
+ const jsonPath = path.resolve(process.cwd(), '.claude', 'skills', '.clud-bug.json');
1190
+ try {
1191
+ const raw = await fs.readFile(jsonPath, 'utf-8');
1192
+ const parsed = JSON.parse(raw);
1193
+ return (parsed && parsed.usage) ? parsed.usage : {};
1194
+ } catch (err) {
1195
+ if (err.code === 'ENOENT') {
1196
+ process.stderr.write(
1197
+ `clud-bug usage --health: no .claude/skills/.clud-bug.json found in ${process.cwd()}.\n` +
1198
+ `Run \`npx clud-bug init\` first OR pass --repo owner/name to read from workflow artifacts.\n`
1199
+ );
1200
+ return null;
1201
+ }
1202
+ process.stderr.write(`clud-bug usage --health: failed to parse .clud-bug.json: ${err.message}\n`);
1203
+ return null;
1204
+ }
1205
+ }
1206
+
1207
+ async function inferOwnerRepoFromGit() {
1208
+ // `gh repo view --json nameWithOwner` reads the current dir's git
1209
+ // remote AND respects gh's config. Returns null on non-git dirs.
1210
+ const result = await ghJson(['repo', 'view', '--json', 'nameWithOwner']);
1211
+ return result && result.nameWithOwner ? result.nameWithOwner : null;
1212
+ }
1213
+
1214
+ function parseSinceArg(since) {
1215
+ if (!since) return null;
1216
+ if (since instanceof Date) return since;
1217
+ const m = String(since).match(/^(\d+)([dwmy])$/);
1218
+ if (!m) return null;
1219
+ const n = Number(m[1]);
1220
+ const unitMs = { d: 86400e3, w: 7 * 86400e3, m: 30 * 86400e3, y: 365 * 86400e3 }[m[2]];
1221
+ return new Date(Date.now() - n * unitMs);
1222
+ }
1223
+
1224
+ // limit to 100 to avoid pagination explosions.
1225
+ async function discoverConsumingRepos() {
1226
+ const list = await ghJson(['repo', 'list', '--limit', '100', '--json', 'nameWithOwner']);
1227
+ if (!Array.isArray(list)) return [];
1228
+ const owners = list.map((e) => e.nameWithOwner);
1229
+ const found = [];
1230
+ for (const ownerRepo of owners) {
1231
+ const probe = await gh(['api', `repos/${ownerRepo}/contents/.github/workflows/clud-bug-review.yml`, '-q', '.size']);
1232
+ if (probe.code === 0 && probe.stdout.trim().length > 0) {
1233
+ found.push(ownerRepo);
1234
+ }
1235
+ }
1236
+ return found;
1237
+ }
1238
+
1239
+ // List recent clud-bug-review.yml runs in a repo. Filters to PR events
1240
+ // (drops schedule, workflow_dispatch — those have no PR LOC denominator).
1241
+ //
1242
+ // IMPORTANT (Q7 measurement integrity, fixed during PR #104 review):
1243
+ // We INCLUDE conclusion === 'failure' runs because Anthropic bills for
1244
+ // tokens regardless of GitHub workflow conclusion. A run that hit the
1245
+ // spend cap, errored mid-action, or failed strict-mode still incurred
1246
+ // real API cost — silently excluding it would underreport spend and
1247
+ // fool the Q7-clud-bug "gradient must point down" gate.
1248
+ // extractTokensFromLog() returns ok:false on logs without usable token
1249
+ // totals, which gracefully skips the cancelled/errored-too-early case
1250
+ // without losing accountability for the partially-billed runs.
1251
+ async function listRecentRuns(repo, limit, since, prFilter) {
1252
+ const sinceDate = since.match(/^\d+[dwmy]$/) ? dateAgo(since) : null;
1253
+ const args = [
1254
+ 'run', 'list', '-R', repo,
1255
+ '--workflow', 'clud-bug-review.yml',
1256
+ '--limit', String(limit),
1257
+ '--json', 'databaseId,headSha,createdAt,event,status,conclusion',
1258
+ ];
1259
+ if (sinceDate) args.push('--created', `>=${sinceDate}`);
1260
+ const runs = await ghJson(args);
1261
+ if (!Array.isArray(runs)) return [];
1262
+ return runs
1263
+ .filter((r) => r.event === 'pull_request' && (r.conclusion === 'success' || r.conclusion === 'failure'))
1264
+ .map((r) => ({ ...r, repo }))
1265
+ .slice(0, limit);
1266
+ }
1267
+
1268
+ async function fetchReviewRecord(repo, run) {
1269
+ // Find the clud-bug-review JOB id within the run.
1270
+ const jobs = await ghJson(['api', `repos/${repo}/actions/runs/${run.databaseId}/jobs`, '-q', '.jobs']);
1271
+ if (!Array.isArray(jobs)) return null;
1272
+ const job = jobs.find((j) => j.name === 'clud-bug-review');
1273
+ if (!job) return null;
1274
+
1275
+ // Fetch the job's log dump. May be large.
1276
+ const logs = await gh(['api', `repos/${repo}/actions/jobs/${job.id}/logs`]);
1277
+ if (logs.code !== 0) return null;
1278
+
1279
+ // Extract tokens + model from the SDK result-message JSON in the log.
1280
+ const extracted = extractTokensFromLog(logs.stdout);
1281
+ if (!extracted.ok) return null;
1282
+
1283
+ // Resolve the PR number from the run's pull_requests array or by SHA.
1284
+ const prNumber = await resolvePrNumber(repo, run);
1285
+ if (!prNumber) return null;
1286
+
1287
+ // Pull LOC denominator from the PR.
1288
+ const prMeta = await ghJson(['pr', 'view', String(prNumber), '-R', repo, '--json', 'additions,deletions,number']);
1289
+ if (!prMeta || typeof prMeta.additions !== 'number') return null;
1290
+
1291
+ const tokens = extracted.tokens;
1292
+ const model = extracted.model;
1293
+ const costInfo = computeReviewCost(tokens, model);
1294
+ return {
1295
+ repo,
1296
+ pr: prNumber,
1297
+ createdAt: run.createdAt,
1298
+ model: costInfo.model, // normalized (PRICING key)
1299
+ modelObserved: model, // raw value from log (may be versioned)
1300
+ unknownModel: costInfo.unknownModel, // PR #104 fix: surface for dashboard warn
1301
+ tokens,
1302
+ additions: prMeta.additions,
1303
+ deletions: prMeta.deletions,
1304
+ cost: costInfo.total,
1305
+ costPerLOC: costPerLOC(costInfo.total, prMeta.additions, prMeta.deletions),
1306
+ cacheRate: cacheHitRate(tokens),
1307
+ };
1308
+ }
1309
+
1310
+ async function resolvePrNumber(repo, run) {
1311
+ // gh's run JSON sometimes carries a `pull_requests` array; if not (or
1312
+ // if it's empty because the PR has been merged), look up via the
1313
+ // commits/{sha}/pulls endpoint, which includes merged/closed PRs.
1314
+ const detail = await ghJson(['api', `repos/${repo}/actions/runs/${run.databaseId}`, '-q', '.pull_requests']);
1315
+ if (Array.isArray(detail) && detail[0]?.number) return detail[0].number;
1316
+ // commits/{sha}/pulls returns PRs that contain the commit — works for
1317
+ // open AND merged/closed PRs. The default `gh pr list -S <sha>` does
1318
+ // not search closed PRs and silently returns empty for the merged
1319
+ // case, which made every $/LOC lookup fail on historical PRs.
1320
+ const pulls = await ghJson(['api', `repos/${repo}/commits/${run.headSha}/pulls`, '-q', '[.[].number]']);
1321
+ if (Array.isArray(pulls) && pulls.length > 0) return pulls[0];
1322
+ return null;
1323
+ }
1324
+
1325
+ function dateAgo(spec) {
1326
+ // spec like "30d", "2w", "1m", "1y" → ISO date N units ago.
1327
+ const m = spec.match(/^(\d+)([dwmy])$/);
1328
+ if (!m) return null;
1329
+ const n = Number(m[1]);
1330
+ const unit = m[2];
1331
+ const day = 24 * 60 * 60 * 1000;
1332
+ const ms = n * (unit === 'd' ? day : unit === 'w' ? 7 * day : unit === 'm' ? 30 * day : 365 * day);
1333
+ return new Date(Date.now() - ms).toISOString().slice(0, 10);
1334
+ }
1335
+
1336
+ // gh helpers (reuse pattern from lib/branch-protection.js so callers can
1337
+ // stub `gh` in tests if they want — but for now spawn directly).
1338
+ function gh(args) {
1339
+ return new Promise((resolve) => {
1340
+ const child = spawn('gh', args, { stdio: ['ignore', 'pipe', 'pipe'] });
1341
+ let stdout = '';
1342
+ let stderr = '';
1343
+ child.stdout.on('data', (d) => { stdout += d; });
1344
+ child.stderr.on('data', (d) => { stderr += d; });
1345
+ child.on('error', () => resolve({ code: 1, stdout: '', stderr: 'gh not on PATH' }));
1346
+ child.on('close', (code) => resolve({ code, stdout, stderr }));
1347
+ });
1348
+ }
1349
+
1350
+ async function ghJson(args) {
1351
+ const { code, stdout } = await gh(args);
1352
+ if (code !== 0) return null;
1353
+ try { return JSON.parse(stdout); } catch { return null; }
1354
+ }
1355
+
1356
+ function rel(from, to) {
1357
+ return to.startsWith(from + '/') ? to.slice(from.length + 1) : to;
1358
+ }
1359
+
1360
+ // Quiet-mode mechanism (v0.6.7+):
1361
+ // - Default: log() emits progress to stdout (today's behavior).
1362
+ // - When CLUD_BUG_QUIET=1 OR --quiet/-q is passed: log() is suppressed.
1363
+ // ok() ALWAYS emits its single-line summary so agents get positive
1364
+ // confirmation with a chainable key-value (commit SHA, file count,
1365
+ // branch name) regardless of quiet state.
1366
+ // - warn() / die() emit unconditionally — quiet must not silence real
1367
+ // problems.
1368
+ let QUIET = process.env.CLUD_BUG_QUIET === '1';
1369
+ function setQuiet(flag) { QUIET = !!flag; }
1370
+ function log(msg) { if (!QUIET) process.stdout.write(msg + '\n'); }
1371
+ function ok(msg) { process.stdout.write('ok ' + msg + '\n'); }
1372
+ function warn(msg) { process.stderr.write(` ! ${msg}\n`); }
1373
+
1374
+ // Export `main()` so the entry-point shim at bin/clud-bug.js can drive
1375
+ // the dispatch. The shim wraps the catch + process.exit error path.
1376
+ export { main };