clud-bug 0.6.34 → 0.7.0-rc.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (153) hide show
  1. package/README.md +20 -0
  2. package/bin/clud-bug.js +10 -1353
  3. package/data/canonical-v1.json +37 -0
  4. package/dist/cli/agents-md.d.ts +16 -0
  5. package/dist/cli/agents-md.d.ts.map +1 -0
  6. package/dist/cli/agents-md.js +226 -0
  7. package/dist/cli/agents-md.js.map +1 -0
  8. package/dist/cli/audit.d.ts +13 -0
  9. package/dist/cli/audit.d.ts.map +1 -0
  10. package/dist/cli/audit.js +90 -0
  11. package/dist/cli/audit.js.map +1 -0
  12. package/dist/cli/branch-protection.d.ts +57 -0
  13. package/dist/cli/branch-protection.d.ts.map +1 -0
  14. package/dist/cli/branch-protection.js +118 -0
  15. package/dist/cli/branch-protection.js.map +1 -0
  16. package/dist/cli/configure-github.d.ts +58 -0
  17. package/dist/cli/configure-github.d.ts.map +1 -0
  18. package/dist/cli/configure-github.js +242 -0
  19. package/dist/cli/configure-github.js.map +1 -0
  20. package/dist/cli/edit-workflow.d.ts +18 -0
  21. package/dist/cli/edit-workflow.d.ts.map +1 -0
  22. package/dist/cli/edit-workflow.js +43 -0
  23. package/dist/cli/edit-workflow.js.map +1 -0
  24. package/dist/cli/index.d.ts +9 -0
  25. package/dist/cli/index.d.ts.map +1 -0
  26. package/dist/cli/index.js +19 -0
  27. package/dist/cli/index.js.map +1 -0
  28. package/dist/cli/main.d.ts +3 -0
  29. package/dist/cli/main.d.ts.map +1 -0
  30. package/dist/cli/main.js +2067 -0
  31. package/dist/cli/main.js.map +1 -0
  32. package/dist/cli/skill-usage.d.ts +109 -0
  33. package/dist/cli/skill-usage.d.ts.map +1 -0
  34. package/dist/cli/skill-usage.js +380 -0
  35. package/dist/cli/skill-usage.js.map +1 -0
  36. package/dist/cli/skills.d.ts +56 -0
  37. package/dist/cli/skills.d.ts.map +1 -0
  38. package/dist/cli/skills.js +292 -0
  39. package/dist/cli/skills.js.map +1 -0
  40. package/dist/cli/update.d.ts +29 -0
  41. package/dist/cli/update.d.ts.map +1 -0
  42. package/dist/cli/update.js +205 -0
  43. package/dist/cli/update.js.map +1 -0
  44. package/dist/cli/usage.d.ts +142 -0
  45. package/dist/cli/usage.d.ts.map +1 -0
  46. package/dist/cli/usage.js +348 -0
  47. package/dist/cli/usage.js.map +1 -0
  48. package/dist/core/audit.d.ts +8 -0
  49. package/dist/core/audit.d.ts.map +1 -0
  50. package/dist/core/audit.js +47 -0
  51. package/dist/core/audit.js.map +1 -0
  52. package/dist/core/auto-resolve.d.ts +235 -0
  53. package/dist/core/auto-resolve.d.ts.map +1 -0
  54. package/dist/core/auto-resolve.js +293 -0
  55. package/dist/core/auto-resolve.js.map +1 -0
  56. package/dist/core/configure-github.d.ts +179 -0
  57. package/dist/core/configure-github.d.ts.map +1 -0
  58. package/dist/core/configure-github.js +289 -0
  59. package/dist/core/configure-github.js.map +1 -0
  60. package/dist/core/detect.d.ts +77 -0
  61. package/dist/core/detect.d.ts.map +1 -0
  62. package/dist/core/detect.js +262 -0
  63. package/dist/core/detect.js.map +1 -0
  64. package/dist/core/diff-findings.d.ts +96 -0
  65. package/dist/core/diff-findings.d.ts.map +1 -0
  66. package/dist/core/diff-findings.js +261 -0
  67. package/dist/core/diff-findings.js.map +1 -0
  68. package/dist/core/formal-review.d.ts +79 -0
  69. package/dist/core/formal-review.d.ts.map +1 -0
  70. package/dist/core/formal-review.js +71 -0
  71. package/dist/core/formal-review.js.map +1 -0
  72. package/dist/core/index.d.ts +17 -0
  73. package/dist/core/index.d.ts.map +1 -0
  74. package/dist/core/index.js +66 -0
  75. package/dist/core/index.js.map +1 -0
  76. package/dist/core/inline-threads.d.ts +160 -0
  77. package/dist/core/inline-threads.d.ts.map +1 -0
  78. package/dist/core/inline-threads.js +369 -0
  79. package/dist/core/inline-threads.js.map +1 -0
  80. package/dist/core/prompt-builder.d.ts +164 -0
  81. package/dist/core/prompt-builder.d.ts.map +1 -0
  82. package/dist/core/prompt-builder.js +419 -0
  83. package/dist/core/prompt-builder.js.map +1 -0
  84. package/dist/core/prompts.d.ts +9 -0
  85. package/dist/core/prompts.d.ts.map +1 -0
  86. package/dist/core/prompts.js +401 -0
  87. package/dist/core/prompts.js.map +1 -0
  88. package/dist/core/render-review.d.ts +6 -0
  89. package/dist/core/render-review.d.ts.map +1 -0
  90. package/dist/core/render-review.js +219 -0
  91. package/dist/core/render-review.js.map +1 -0
  92. package/dist/core/render.d.ts +13 -0
  93. package/dist/core/render.d.ts.map +1 -0
  94. package/dist/core/render.js +62 -0
  95. package/dist/core/render.js.map +1 -0
  96. package/dist/core/resolve-verifier.d.ts +46 -0
  97. package/dist/core/resolve-verifier.d.ts.map +1 -0
  98. package/dist/core/resolve-verifier.js +187 -0
  99. package/dist/core/resolve-verifier.js.map +1 -0
  100. package/dist/core/review-schema-zod.d.ts +240 -0
  101. package/dist/core/review-schema-zod.d.ts.map +1 -0
  102. package/dist/core/review-schema-zod.js +218 -0
  103. package/dist/core/review-schema-zod.js.map +1 -0
  104. package/dist/core/review-schema.d.ts +42 -0
  105. package/dist/core/review-schema.d.ts.map +1 -0
  106. package/dist/core/review-schema.js +156 -0
  107. package/dist/core/review-schema.js.map +1 -0
  108. package/dist/core/review-writeback.d.ts +193 -0
  109. package/dist/core/review-writeback.d.ts.map +1 -0
  110. package/dist/core/review-writeback.js +364 -0
  111. package/dist/core/review-writeback.js.map +1 -0
  112. package/dist/core/skills.d.ts +147 -0
  113. package/dist/core/skills.d.ts.map +1 -0
  114. package/dist/core/skills.js +726 -0
  115. package/dist/core/skills.js.map +1 -0
  116. package/dist/core/version.d.ts +2 -0
  117. package/dist/core/version.d.ts.map +1 -0
  118. package/dist/core/version.js +4 -0
  119. package/dist/core/version.js.map +1 -0
  120. package/package.json +37 -5
  121. package/{lib/agents-md.js → src/cli/agents-md.ts} +25 -14
  122. package/{lib/audit.js → src/cli/audit.ts} +37 -44
  123. package/{lib/branch-protection.js → src/cli/branch-protection.ts} +75 -11
  124. package/src/cli/configure-github.ts +329 -0
  125. package/{lib/edit-workflow.js → src/cli/edit-workflow.ts} +32 -11
  126. package/src/cli/index.ts +107 -0
  127. package/src/cli/main.ts +2204 -0
  128. package/{lib/skill-usage.js → src/cli/skill-usage.ts} +168 -94
  129. package/src/cli/skills.ts +386 -0
  130. package/{lib/update.js → src/cli/update.ts} +87 -27
  131. package/{lib/usage.js → src/cli/usage.ts} +167 -76
  132. package/src/core/audit.ts +53 -0
  133. package/src/core/auto-resolve.ts +488 -0
  134. package/src/core/configure-github.ts +497 -0
  135. package/{lib/detect.js → src/core/detect.ts} +100 -47
  136. package/src/core/diff-findings.ts +323 -0
  137. package/src/core/formal-review.ts +168 -0
  138. package/src/core/index.ts +250 -0
  139. package/src/core/inline-threads.ts +471 -0
  140. package/src/core/prompt-builder.ts +561 -0
  141. package/{lib/prompts.js → src/core/prompts.ts} +16 -2
  142. package/{lib/render-review.js → src/core/render-review.ts} +57 -25
  143. package/{lib/render.js → src/core/render.ts} +37 -17
  144. package/src/core/resolve-verifier.ts +228 -0
  145. package/src/core/review-schema-zod.ts +262 -0
  146. package/{lib/review-schema.js → src/core/review-schema.ts} +68 -5
  147. package/src/core/review-writeback.ts +565 -0
  148. package/{lib/skills.js → src/core/skills.ts} +456 -342
  149. package/src/core/version.ts +3 -0
  150. package/templates/clud-bug-review.md.tmpl +115 -0
  151. package/templates/workflow-py.yml.tmpl +80 -3
  152. package/templates/workflow-ts.yml.tmpl +80 -3
  153. package/templates/workflow.yml.tmpl +175 -8
@@ -0,0 +1,2204 @@
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
+ // v0.7.0 (Wave 6b): `clud-bug init --with-local-review` also scaffolds
66
+ // .claude/commands/clud-bug-review.md so `/clud-bug-review` works in a
67
+ // Claude Code session (reviews the current PR with the session's tokens).
68
+ withLocalReview: false,
69
+ // v0.7.0-rc.4: `clud-bug configure-github` flags.
70
+ // --dry-run prints the diff but skips PATCH; --branch overrides "main".
71
+ dryRun: false,
72
+ branch: null,
73
+ };
74
+ for (let i = 0; i < argv.length; i++) {
75
+ const a = argv[i];
76
+ if (a === '--offline') args.offline = true;
77
+ else if (a === '--accept-all' || a === '-y') args.acceptAll = true;
78
+ else if (a === '--commit') args.commit = true;
79
+ else if (a === '--help' || a === '-h') args.help = true;
80
+ else if (a === '--version' || a === '-v') args.version = true;
81
+ else if (a === '--quiet' || a === '-q') args.quiet = true;
82
+ else if (a === '--since') args.since = argv[++i];
83
+ else if (a === '--changed-in') args.changedIn = argv[++i];
84
+ else if (a === '--scope') args.scopes.push(argv[++i]);
85
+ else if (a === '--out') args.out = argv[++i];
86
+ else if (a === '--no-set-protection') args.setProtection = false;
87
+ else if (a === '--repo') args.repo = argv[++i];
88
+ else if (a === '--pr') args.pr = Number(argv[++i]);
89
+ else if (a === '--limit') args.limit = Number(argv[++i]);
90
+ else if (a === '--json') args.json = true;
91
+ else if (a === '--stdin') args.stdin = true;
92
+ else if (a === '--health') args.health = true;
93
+ else if (a === '--no-artifacts') args.artifacts = false;
94
+ else if (a === '--with-skdd') args.withSkdd = true;
95
+ else if (a === '--with-local-review') args.withLocalReview = true;
96
+ else if (a === '--dry-run') args.dryRun = true;
97
+ else if (a === '--branch') args.branch = argv[++i];
98
+ else args._.push(a);
99
+ }
100
+ return args;
101
+ }
102
+
103
+ const HELP = `clud-bug 🐛 — a field guide to specimens crawling your code
104
+
105
+ Usage:
106
+ npx clud-bug <command> [options]
107
+
108
+ Commands:
109
+ init Open field season: survey the repo, pin baseline specimens, write the workflows.
110
+ Pass \`--with-skdd\` to also install logmind in one go (requires Python + pip).
111
+ list Show your collection (baseline / from skills.sh / custom).
112
+ add <source/name> Pin one new specimen from skills.sh (e.g. vercel-labs/skills/next-best-practices).
113
+ remove <slug> Unpin a clud-bug-managed specimen (refuses to touch your custom ones).
114
+ refresh Re-survey, diff against your collection, prompt to update.
115
+ audit Walk the whole habitat (or a recent slice) and prepare a report stub.
116
+ Use --since / --changed-in / --scope to narrow.
117
+ update Re-render workflows + refresh baseline specimens to the latest shipped
118
+ templates. Custom and skills.sh-installed specimens left alone.
119
+ configure-github <owner>/<repo>
120
+ Apply the SPEC §7 canonical branch protection ruleset to a repo.
121
+ Auth: GITHUB_TOKEN env first, then \`gh auth token\`. Use
122
+ --dry-run to print the diff without PATCH-ing; --branch to
123
+ target a non-main branch. Idempotent — already-canonical
124
+ repos exit 0 with no changes.
125
+ edit-workflow Helper for editing .github/workflows/clud-bug-*.yml in an isolated
126
+ PR (the action refuses to review PRs that modify its own workflow).
127
+ usage Read recent clud-bug-review run JSON + normalize cost per LOC.
128
+ Internal Q7-clud-bug enforcement dashboard. Reports cache hit
129
+ rate, 30-day rolling \$/LOC trend, per-repo/per-model
130
+ distributions, and outliers (> 2x org median).
131
+ Use --pr / --repo / --since / --limit / --json to filter.
132
+ usage --health Deterministic skill-health dashboard. Renders archive-
133
+ candidate / stale / new / healthy status per skill, applying
134
+ the v0.6.28 thresholds (citations==0 + loads>=5 → archive
135
+ candidate; last cited >60d → stale; etc.). Read-only —
136
+ humans decide what to prune.
137
+ Read source (v0.6.30): by default, walks
138
+ \`clud-bug-skill-usage-pr-*\` workflow artifacts uploaded
139
+ by every clud-bug-review run and accumulates them into
140
+ one org-level snapshot. Pass \`--repo owner/name\` to
141
+ target a specific repo; otherwise infers from the local
142
+ git remote. \`--no-artifacts\` falls back to reading the
143
+ local \`.claude/skills/.clud-bug.json\` (v0.6.28 behavior).
144
+ eval Run the golden-set regression gate against the rendered review
145
+ prompt (must-contain / must-not-contain / byte-budget). Same as
146
+ \`node --test test/prompts.eval.test.js\` but works from any cwd.
147
+ update-skill-usage Update the .claude/skills/.clud-bug.json usage block from
148
+ a structured-output JSON payload (the action's
149
+ \`outputs.structured_output\`). Called as a workflow
150
+ post-step alongside \`render\` (v0.6.29 / Component 4).
151
+ Pipe the JSON to stdin. Idempotent + atomic write.
152
+ Silent no-op on empty stdin (parity with \`render\`).
153
+ render --stdin Render a structured-output JSON payload (the action's
154
+ \`outputs.structured_output\`, piped via stdin) to the
155
+ GitHub-markdown summary comment shape. Invoked by the
156
+ workflow post-step; output is what \`gh pr comment\`
157
+ receives. Empty stdin or non-object payload exits 2.
158
+ select-review-event Compute the SPEC §7.2.1 formal-review event (APPROVE /
159
+ --stdin REQUEST_CHANGES / COMMENT / skip) from a structured-output
160
+ JSON payload + a few env-passed PR-author fields. Used by
161
+ the v0.7.0-rc.3 workflow post-step to post a formal
162
+ \`pulls.createReview\` call so the canonical ruleset's
163
+ \`required_approving_review_count: 1\` floor is auto-
164
+ satisfied by clud-bug[bot] on clean reviews.
165
+ Required env vars: PR_AUTHOR_LOGIN, PR_AUTHOR_ASSOCIATION,
166
+ STRICT_MODE ("true"/"false"). Empty/malformed stdin →
167
+ exits 0 with "skip" on stdout (no-op; workflow degrades
168
+ gracefully to the existing comment-only behavior).
169
+ post-inline-threads Post per-finding inline review threads (D.2.X). Reads a
170
+ --stdin structured-output JSON payload from stdin, fetches the
171
+ PR diff via \`gh api\`, posts one batched review with one
172
+ comment per anchorable finding. Each comment body carries
173
+ a hidden \`<!-- finding-id: <hash> --> \`marker so Wave 5b
174
+ auto-resolve can re-match findings on subsequent pushes
175
+ without persistent state. Required env vars: GH_TOKEN,
176
+ REPO ("owner/name"), PR_NUMBER, HEAD_SHA. Output is a
177
+ JSON status report \`{posted, skipped, preexisting, error?}\`.
178
+ resolve-threads Auto-resolve inline review threads when a fix-push
179
+ addresses the original finding (D.2.6). Fetches all
180
+ bot-authored unresolved threads, asks the Anthropic
181
+ Messages API per-thread whether the new commit addressed
182
+ the concern, then resolves (with a marker reply)
183
+ ADDRESSED threads + leaves UNCERTAIN/NOT_ADDRESSED open.
184
+ Fail-closed: verifier errors route to UNCERTAIN.
185
+ Required env vars: GH_TOKEN, ANTHROPIC_API_KEY,
186
+ REPO ("owner/name"), PR_NUMBER. Output: JSON
187
+ \`{actions, verifierCallCount, shouldRequestChanges}\`.
188
+
189
+ Options:
190
+ --offline Skip skills.sh; pin only the bundled baseline specimens.
191
+ --accept-all,-y Accept the recommended specimens without prompting.
192
+ --commit git add + commit the generated kit when done (init only).
193
+ --with-local-review (init) Scaffold .claude/commands/clud-bug-review.md so
194
+ \`/clud-bug-review\` works in a Claude Code session —
195
+ reviews the current branch's PR using that session's
196
+ own tokens (no hosted App, no extra auth).
197
+ --quiet,-q Token-frugal mode for agent invocations. Suppresses
198
+ progress chatter; emits exactly one final
199
+ \`ok <key-value>\` summary line per command. Errors
200
+ and warnings still print. Also honored via the
201
+ CLUD_BUG_QUIET=1 env var.
202
+ --no-set-protection Skip the prompt that offers to enable
203
+ required_conversation_resolution on the default
204
+ branch (init only). Use for repos that manage
205
+ branch protection via ruleset or org policy.
206
+ --dry-run Print the canonical-v1 diff without PATCH-ing
207
+ (configure-github only).
208
+ --branch <name> Target branch for configure-github (default: main).
209
+ --repo <owner/name> Restrict \`usage\` to a single repo. Default: all repos
210
+ with clud-bug-review.yml in the gh user's auth scope.
211
+ --pr <N> Restrict \`usage\` to a single PR.
212
+ --limit <N> Max reviews to fetch (default 50; the API caps).
213
+ --json Emit JSON instead of human-readable output.
214
+ Compatible with --quiet for pipeline consumption.
215
+ --since <date> Audit only files changed in commits after <date> (git date string).
216
+ --changed-in <dur> Audit only files changed in the past <dur>: 7d, 2w, 1mo, 1y. (audit only)
217
+ --scope <glob> Limit audit to files matching <glob>; repeatable. (audit only)
218
+ --out <path> Where to write the audit stub. Default: audits/YYYY-MM-DD.md
219
+ --help,-h Show this help.
220
+ --version,-v Show version.
221
+ `;
222
+
223
+ async function readPkgVersion() {
224
+ const pkg = JSON.parse(await readFile(join(PKG_ROOT, 'package.json'), 'utf8'));
225
+ return pkg.version;
226
+ }
227
+
228
+ async function main() {
229
+ const args = parseArgs(process.argv.slice(2));
230
+ if (args.help) { process.stdout.write(HELP); return; }
231
+ if (args.version) { process.stdout.write((await readPkgVersion()) + '\n'); return; }
232
+ if (args.quiet) setQuiet(true);
233
+
234
+ const cmd = args._[0];
235
+ switch (cmd) {
236
+ case 'init': return runInit(args);
237
+ case 'list': return runList(args);
238
+ case 'add': return runAdd(args);
239
+ case 'remove': return runRemove(args);
240
+ case 'refresh': return runRefresh(args);
241
+ case 'audit': return runAudit(args);
242
+ case 'update': return runUpdateCmd(args);
243
+ case 'edit-workflow': return runEditWorkflow(args);
244
+ case 'configure-github': return runConfigureGithubCmd(args);
245
+ case 'usage': return runUsage(args);
246
+ case 'eval': return runEval();
247
+ case 'render': return runRender(args);
248
+ case 'update-skill-usage': return runUpdateSkillUsage(args);
249
+ case 'select-review-event': return runSelectReviewEvent(args);
250
+ case 'post-inline-threads': return runPostInlineThreads(args);
251
+ case 'resolve-threads': return runResolveThreads(args);
252
+ default:
253
+ process.stderr.write(`Unknown command: ${cmd || '(none)'}\n\n${HELP}`);
254
+ process.exit(2);
255
+ }
256
+ }
257
+
258
+ // 0.0.O (v0.6.22): render a structured-output JSON payload to the
259
+ // GitHub-markdown summary comment shape. Called by the post-step
260
+ // in the workflow templates: it reads the action's
261
+ // `outputs.structured_output` (one bundled JSON string), pipes it
262
+ // to stdin here, and we emit the rendered markdown on stdout for
263
+ // the shell to pass to `gh pr comment --body`.
264
+ //
265
+ // Usage: `clud-bug render --stdin` (only input source supported).
266
+ // Exit code: 0 on success, 2 on JSON parse error or non-object payload.
267
+ async function runRender(args) {
268
+ const { renderReview } = await import('../core/render-review.js');
269
+ if (!args.stdin) {
270
+ process.stderr.write('clud-bug render: --stdin is required (the only supported input source).\n');
271
+ process.exit(2);
272
+ }
273
+ let raw = '';
274
+ for await (const chunk of process.stdin) raw += chunk;
275
+ raw = raw.trim();
276
+ if (!raw) {
277
+ // Empty structured_output → post-step is supposed to skip the
278
+ // render. Surface the situation rather than silently producing an
279
+ // empty comment.
280
+ process.stderr.write('clud-bug render: stdin was empty — nothing to render.\n');
281
+ process.exit(2);
282
+ }
283
+ let payload;
284
+ try {
285
+ payload = JSON.parse(raw);
286
+ } catch (e) {
287
+ process.stderr.write(`clud-bug render: JSON parse failed: ${e.message}\n`);
288
+ process.exit(2);
289
+ }
290
+ try {
291
+ process.stdout.write(renderReview(payload));
292
+ } catch (e) {
293
+ process.stderr.write(`clud-bug render: ${e.message}\n`);
294
+ process.exit(2);
295
+ }
296
+ }
297
+
298
+ // v0.6.29 — Component 4. Pipe the action's structured_output through
299
+ // the skill-usage data layer (v0.6.28) + write the merged result back
300
+ // to .claude/skills/.clud-bug.json atomically.
301
+ //
302
+ // Workflow integration (post-step in workflow.yml.tmpl):
303
+ //
304
+ // echo "${{ steps.review.outputs.structured_output }}" \
305
+ // | npx clud-bug@latest update-skill-usage --stdin
306
+ //
307
+ // Runs AFTER the render post-step. Silent no-op on empty stdin
308
+ // (same contract as `render` — preserves the workflow's existing
309
+ // "skip both if empty" branch). Idempotent: running on the same JSON
310
+ // twice produces the same result.
311
+ async function runUpdateSkillUsage(args) {
312
+ const fs = await import('node:fs/promises');
313
+ const path = await import('node:path');
314
+ const {
315
+ computeSkillUsageDelta,
316
+ mergeSkillUsage,
317
+ } = await import('./skill-usage.js');
318
+
319
+ if (!args.stdin) {
320
+ process.stderr.write('clud-bug update-skill-usage: --stdin is required.\n');
321
+ process.exit(2);
322
+ }
323
+
324
+ let raw = '';
325
+ for await (const chunk of process.stdin) raw += chunk;
326
+ raw = raw.trim();
327
+ if (!raw) {
328
+ // Empty structured_output → render is also skipped → nothing to
329
+ // update. Match the render contract: exit 0 with a stderr note.
330
+ process.stderr.write('clud-bug update-skill-usage: stdin empty — no usage update.\n');
331
+ return;
332
+ }
333
+
334
+ let reviewJson;
335
+ try {
336
+ reviewJson = JSON.parse(raw);
337
+ } catch (e) {
338
+ process.stderr.write(`clud-bug update-skill-usage: invalid JSON: ${e.message}\n`);
339
+ process.exit(2);
340
+ }
341
+ if (!reviewJson || typeof reviewJson !== 'object') {
342
+ process.stderr.write('clud-bug update-skill-usage: payload must be a JSON object.\n');
343
+ process.exit(2);
344
+ }
345
+
346
+ // Compute per-review delta. Empty delta is fine — just means no
347
+ // skills loaded or cited (workflow-only PRs, e.g.).
348
+ const delta = computeSkillUsageDelta(reviewJson);
349
+ if (Object.keys(delta).length === 0) {
350
+ process.stderr.write('clud-bug update-skill-usage: no skills in payload — nothing to record.\n');
351
+ return;
352
+ }
353
+
354
+ // Read existing .clud-bug.json. The path is canonical:
355
+ // .claude/skills/.clud-bug.json relative to cwd (the workflow runs
356
+ // from the repo root).
357
+ const jsonPath = path.resolve(process.cwd(), '.claude', 'skills', '.clud-bug.json');
358
+ let parsed;
359
+ try {
360
+ const existingRaw = await fs.readFile(jsonPath, 'utf-8');
361
+ parsed = JSON.parse(existingRaw);
362
+ } catch (err) {
363
+ if (err.code === 'ENOENT') {
364
+ process.stderr.write(
365
+ `clud-bug update-skill-usage: no .clud-bug.json at ${jsonPath} — skipping. ` +
366
+ `Run \`npx clud-bug init\` first.\n`
367
+ );
368
+ return;
369
+ }
370
+ process.stderr.write(`clud-bug update-skill-usage: parse failed: ${err.message}\n`);
371
+ process.exit(2);
372
+ }
373
+ if (!parsed || typeof parsed !== 'object') {
374
+ process.stderr.write('clud-bug update-skill-usage: .clud-bug.json malformed.\n');
375
+ process.exit(2);
376
+ }
377
+
378
+ const existingUsage = parsed.usage || {};
379
+ const timestamp = new Date().toISOString();
380
+ const mergedUsage = mergeSkillUsage(existingUsage, delta, timestamp);
381
+ parsed.usage = mergedUsage;
382
+
383
+ // Write back ATOMICALLY: temp file + rename. Guards against a
384
+ // crashed write leaving the JSON half-written + unparseable on next
385
+ // read (which would brick the entire skill catalog).
386
+ const tmpPath = jsonPath + '.tmp';
387
+ const serialized = JSON.stringify(parsed, null, 2) + '\n';
388
+ await fs.writeFile(tmpPath, serialized, 'utf-8');
389
+ await fs.rename(tmpPath, jsonPath);
390
+
391
+ const skillCount = Object.keys(delta).length;
392
+ ok(`update-skill-usage: merged ${skillCount} skill${skillCount === 1 ? '' : 's'} from review`);
393
+ }
394
+
395
+
396
+ // v0.7.0-rc.3 — SPEC §7.2.1 formal-review event selector for the npm
397
+ // workflow path. Reads the action's structured_output JSON from stdin,
398
+ // PR-author metadata from env, and emits ONE word on stdout:
399
+ //
400
+ // APPROVE | REQUEST_CHANGES | COMMENT | skip
401
+ //
402
+ // The workflow template post-step pipes the structured_output payload
403
+ // in, reads the verdict, then conditionally calls `gh api ... /reviews`
404
+ // with `event=$VERDICT` (skipping the API call when verdict is "skip").
405
+ //
406
+ // Robustness contract: this command MUST NEVER fail the workflow on
407
+ // caller-side problems (missing/malformed payload, missing env). Any
408
+ // such case prints "skip" to stdout + a warning to stderr and exits 0
409
+ // — the workflow then degrades to the existing comment-only behavior.
410
+ // We only exit non-zero on programmer error (unknown command flags),
411
+ // which the workflow templates don't pass.
412
+ //
413
+ // Required env vars (the workflow templates set all four):
414
+ // PR_AUTHOR_LOGIN — github.event.pull_request.user.login
415
+ // PR_AUTHOR_ASSOCIATION — github.event.pull_request.author_association
416
+ // STRICT_MODE — "true" / "false" from the base-ref manifest
417
+ // (PR_AUTHOR_ASSOCIATION missing → "CONTRIBUTOR" default which preserves
418
+ // pre-§7.2.1 trusted-author semantics; PR_AUTHOR_LOGIN missing → "skip".)
419
+ async function runSelectReviewEvent(args) {
420
+ const { selectReviewEvent } = await import('../core/formal-review.js');
421
+
422
+ if (!args.stdin) {
423
+ process.stderr.write(
424
+ 'clud-bug select-review-event: --stdin is required.\n',
425
+ );
426
+ process.stdout.write('skip\n');
427
+ return;
428
+ }
429
+
430
+ let raw = '';
431
+ for await (const chunk of process.stdin) raw += chunk;
432
+ raw = raw.trim();
433
+ if (!raw) {
434
+ process.stderr.write(
435
+ 'clud-bug select-review-event: stdin empty — emitting "skip".\n',
436
+ );
437
+ process.stdout.write('skip\n');
438
+ return;
439
+ }
440
+
441
+ let payload;
442
+ try {
443
+ payload = JSON.parse(raw);
444
+ } catch (e) {
445
+ process.stderr.write(
446
+ `clud-bug select-review-event: JSON parse failed: ${e.message} — emitting "skip".\n`,
447
+ );
448
+ process.stdout.write('skip\n');
449
+ return;
450
+ }
451
+ if (!payload || typeof payload !== 'object') {
452
+ process.stderr.write(
453
+ 'clud-bug select-review-event: payload must be a JSON object — emitting "skip".\n',
454
+ );
455
+ process.stdout.write('skip\n');
456
+ return;
457
+ }
458
+
459
+ // Count findings from the structured_output array shape. Each is
460
+ // optional — the model may emit absent / null / wrong-type arrays
461
+ // when its output drifts from schema; we tolerate all of those.
462
+ const criticalCount = Array.isArray(payload.critical_findings)
463
+ ? payload.critical_findings.length
464
+ : 0;
465
+ const minorCount = Array.isArray(payload.minor_findings)
466
+ ? payload.minor_findings.length
467
+ : 0;
468
+
469
+ // Env-passed metadata. Empty PR_AUTHOR_LOGIN means we have no idea
470
+ // who opened the PR — safer to skip than to attempt a formal review
471
+ // that might collide with the self-PR guard mis-routed.
472
+ const prAuthorLogin = String(process.env.PR_AUTHOR_LOGIN ?? '').trim();
473
+ if (prAuthorLogin === '') {
474
+ process.stderr.write(
475
+ 'clud-bug select-review-event: PR_AUTHOR_LOGIN unset — emitting "skip".\n',
476
+ );
477
+ process.stdout.write('skip\n');
478
+ return;
479
+ }
480
+
481
+ // author_association resolution — security-critical fallback ladder.
482
+ //
483
+ // SPEC §7.2.1 + clud-bug-review #163 feedback: the §7.2.1 gate exists
484
+ // precisely so a future REST-API rename of an external tier doesn't
485
+ // silently degrade to "trusted → APPROVE". The fallback below splits
486
+ // the two error cases by what they MEAN:
487
+ //
488
+ // 1. EMPTY ("") — caller didn't pass author_association at all.
489
+ // This is the "older webhook payload / non-GH caller" case.
490
+ // Pre-§7.2.1, those callers got APPROVE on clean reviews; we
491
+ // preserve that by defaulting to 'CONTRIBUTOR' (org-trusted).
492
+ // Documented + tested under "missing PR_AUTHOR_ASSOCIATION".
493
+ //
494
+ // 2. NON-EMPTY UNRECOGNIZED ("FUTURE_TIER") — caller passed a
495
+ // token this build doesn't know about. This is the rename
496
+ // scenario the §7.2.1 gate is explicitly defending against.
497
+ // Fail CLOSED: treat as external → 'NONE' → COMMENT, NEVER
498
+ // APPROVE. If the rename was for a still-trusted tier (e.g.
499
+ // 'STAFF'), the cost is one extra COMMENT review pending
500
+ // human approval; if it was a new external tier, we've closed
501
+ // the drive-by exploit. Asymmetric risk → fail closed.
502
+ const rawAssoc = String(process.env.PR_AUTHOR_ASSOCIATION ?? '').trim();
503
+ const validAssociations = new Set([
504
+ 'OWNER', 'MEMBER', 'COLLABORATOR', 'CONTRIBUTOR',
505
+ 'FIRST_TIME_CONTRIBUTOR', 'FIRST_TIMER', 'NONE', 'MANNEQUIN',
506
+ ]);
507
+ let authorAssociation;
508
+ if (rawAssoc === '') {
509
+ // Back-compat for legacy callers without author_association.
510
+ authorAssociation = 'CONTRIBUTOR';
511
+ } else if (validAssociations.has(rawAssoc)) {
512
+ authorAssociation = rawAssoc;
513
+ } else {
514
+ // Fail-closed for unrecognized future tokens. 'NONE' is the
515
+ // weakest external tier and the safest default — selectReviewEvent
516
+ // routes it to COMMENT (never APPROVE, never REQUEST_CHANGES).
517
+ process.stderr.write(
518
+ `clud-bug select-review-event: unrecognized PR_AUTHOR_ASSOCIATION="${rawAssoc}" — treating as external (fail-closed per SPEC §7.2.1).\n`,
519
+ );
520
+ authorAssociation = 'NONE';
521
+ }
522
+
523
+ const strictMode = String(process.env.STRICT_MODE ?? '').trim() === 'true';
524
+
525
+ const event = selectReviewEvent({
526
+ criticalCount,
527
+ minorCount,
528
+ strictMode,
529
+ prAuthorLogin,
530
+ authorAssociation,
531
+ });
532
+ process.stdout.write(event + '\n');
533
+ }
534
+
535
+
536
+ // Wave 5a / 0.7.0-rc.7: post per-finding inline review threads (D.2.X)
537
+ // to the PR. Replaces the legacy "one summary comment listing every
538
+ // finding" UX with first-class GitHub review threads users can reply to
539
+ // and (in Wave 5b) the bot can auto-resolve on fix-push.
540
+ //
541
+ // Pipeline:
542
+ // 1. Read the structured review JSON from stdin (same shape the
543
+ // `render` and `update-skill-usage` verbs consume).
544
+ // 2. Fetch the PR's per-file diff via `gh api repos/.../pulls/N/files`.
545
+ // 3. Call `planInlineThreads(findings, diffFiles)` from
546
+ // `clud-bug/core/inline-threads` to partition into anchored
547
+ // `comments[]` + skipped + preexisting buckets.
548
+ // 4. If any comments survive: POST one batched review via
549
+ // `gh api -X POST repos/.../pulls/N/reviews` with `event: COMMENT`.
550
+ // Each comment body carries a hidden `<!-- finding-id: <hash> -->`
551
+ // marker so Wave 5b auto-resolve can re-derive the same ids on
552
+ // subsequent pushes without a persistent store.
553
+ // 5. Emit a JSON summary on stdout (`{posted, skipped, preexisting}`).
554
+ //
555
+ // Required env vars (the workflow template provides all of them):
556
+ // GH_TOKEN, REPO ("owner/name"), PR_NUMBER, HEAD_SHA
557
+ //
558
+ // Failure posture: any `gh api` failure is logged to stderr + emitted in
559
+ // the stdout JSON as `error`, but the verb exits 0 so the surrounding
560
+ // workflow step's `continue-on-error: true` is the single source of
561
+ // failure semantics. Mirrors the `render` / `update-skill-usage` posture.
562
+ async function runPostInlineThreads(args) {
563
+ const { planInlineThreads } = await import('../core/inline-threads.js');
564
+
565
+ if (!args.stdin) {
566
+ process.stderr.write(
567
+ 'clud-bug post-inline-threads: --stdin is required.\n',
568
+ );
569
+ process.stdout.write(JSON.stringify({ posted: 0, skipped: [], preexisting: [], error: 'no-stdin' }) + '\n');
570
+ return;
571
+ }
572
+
573
+ let raw = '';
574
+ for await (const chunk of process.stdin) raw += chunk;
575
+ raw = raw.trim();
576
+ if (!raw) {
577
+ process.stderr.write(
578
+ 'clud-bug post-inline-threads: stdin empty — no-op.\n',
579
+ );
580
+ process.stdout.write(JSON.stringify({ posted: 0, skipped: [], preexisting: [], error: 'empty-stdin' }) + '\n');
581
+ return;
582
+ }
583
+
584
+ let payload;
585
+ try {
586
+ payload = JSON.parse(raw);
587
+ } catch (e) {
588
+ process.stderr.write(
589
+ `clud-bug post-inline-threads: JSON parse failed: ${e.message} — no-op.\n`,
590
+ );
591
+ process.stdout.write(JSON.stringify({ posted: 0, skipped: [], preexisting: [], error: 'parse-failed' }) + '\n');
592
+ return;
593
+ }
594
+ if (!payload || typeof payload !== 'object') {
595
+ process.stderr.write(
596
+ 'clud-bug post-inline-threads: payload must be a JSON object — no-op.\n',
597
+ );
598
+ process.stdout.write(JSON.stringify({ posted: 0, skipped: [], preexisting: [], error: 'not-object' }) + '\n');
599
+ return;
600
+ }
601
+
602
+ // Pull findings from the structured_output shape — same field names as
603
+ // the SPEC §1.8.1 review schema the model emits.
604
+ const findings = [];
605
+ for (const f of Array.isArray(payload.critical_findings) ? payload.critical_findings : []) {
606
+ if (f && typeof f === 'object') {
607
+ findings.push({ ...f, severity: 'critical' });
608
+ }
609
+ }
610
+ for (const f of Array.isArray(payload.minor_findings) ? payload.minor_findings : []) {
611
+ if (f && typeof f === 'object') {
612
+ findings.push({ ...f, severity: 'minor' });
613
+ }
614
+ }
615
+ // `preexisting_findings` are intentionally not threaded (informational
616
+ // about prior code; not a reason to block this PR). `planInlineThreads`
617
+ // partitions them into the `preexisting` bucket if any are included, but
618
+ // the workflow path doesn't ship them — keep the payload focused.
619
+
620
+ if (findings.length === 0) {
621
+ process.stdout.write(JSON.stringify({ posted: 0, skipped: [], preexisting: [], reason: 'no-findings' }) + '\n');
622
+ return;
623
+ }
624
+
625
+ // Required env vars.
626
+ const repo = String(process.env.REPO ?? '').trim();
627
+ const prNumberRaw = String(process.env.PR_NUMBER ?? '').trim();
628
+ const headSha = String(process.env.HEAD_SHA ?? '').trim();
629
+ const prNumber = Number(prNumberRaw);
630
+ if (!repo || !repo.includes('/') || !Number.isInteger(prNumber) || prNumber <= 0 || !headSha) {
631
+ process.stderr.write(
632
+ `clud-bug post-inline-threads: REPO + PR_NUMBER + HEAD_SHA env vars required (got REPO=${repo}, PR_NUMBER=${prNumberRaw}, HEAD_SHA=${headSha ? '<set>' : '<unset>'}).\n`,
633
+ );
634
+ process.stdout.write(JSON.stringify({ posted: 0, skipped: [], preexisting: [], error: 'missing-env' }) + '\n');
635
+ return;
636
+ }
637
+
638
+ // Fetch the PR's diff. `gh api repos/.../pulls/N/files --paginate`
639
+ // returns a JSON array of {filename, patch, status, ...}. The single
640
+ // -slurp on multi-page output stitches all pages into one big array.
641
+ const filesResult = spawnSync(
642
+ 'gh',
643
+ [
644
+ 'api',
645
+ `repos/${repo}/pulls/${prNumber}/files`,
646
+ '--paginate',
647
+ '--slurp',
648
+ ],
649
+ { encoding: 'utf8' },
650
+ );
651
+ if (filesResult.status !== 0) {
652
+ process.stderr.write(
653
+ `clud-bug post-inline-threads: \`gh api .../files\` failed (exit ${filesResult.status}): ${(filesResult.stderr || '').slice(0, 500)}\n`,
654
+ );
655
+ process.stdout.write(JSON.stringify({ posted: 0, skipped: [], preexisting: [], error: 'diff-fetch-failed' }) + '\n');
656
+ return;
657
+ }
658
+
659
+ let diffFiles = [];
660
+ try {
661
+ // --slurp wraps each page in an outer array; flatten one level.
662
+ const pages = JSON.parse(filesResult.stdout);
663
+ if (Array.isArray(pages)) {
664
+ for (const page of pages) {
665
+ if (Array.isArray(page)) diffFiles.push(...page);
666
+ }
667
+ }
668
+ } catch (e) {
669
+ process.stderr.write(
670
+ `clud-bug post-inline-threads: diff JSON parse failed: ${e.message}\n`,
671
+ );
672
+ process.stdout.write(JSON.stringify({ posted: 0, skipped: [], preexisting: [], error: 'diff-parse-failed' }) + '\n');
673
+ return;
674
+ }
675
+
676
+ const plan = planInlineThreads(findings, diffFiles);
677
+ if (plan.comments.length === 0) {
678
+ // Everything fell through to summary-comment fallback (no anchor
679
+ // matches). Not a failure — emit the breakdown so the workflow log
680
+ // explains why no inline threads were posted.
681
+ process.stdout.write(JSON.stringify({
682
+ posted: 0,
683
+ skipped: plan.skipped.map((f) => ({ skill: f.skill, file: f.file, line: f.line, reason: 'not-anchorable' })),
684
+ preexisting: plan.preexisting.map((f) => ({ skill: f.skill })),
685
+ reason: 'no-anchorable-findings',
686
+ }) + '\n');
687
+ return;
688
+ }
689
+
690
+ // Post the review. gh accepts the body as a JSON file via --input — we
691
+ // pipe through stdin with `--input -`. Build the body as JSON-on-one-line
692
+ // so stdin is a single write that `gh` reads start-to-finish.
693
+ const reviewBody = JSON.stringify({
694
+ commit_id: headSha,
695
+ event: 'COMMENT',
696
+ body: `Clud-Bug posted ${plan.comments.length} inline finding${plan.comments.length === 1 ? '' : 's'}.`,
697
+ comments: plan.comments,
698
+ });
699
+
700
+ const postResult = spawnSync(
701
+ 'gh',
702
+ [
703
+ 'api',
704
+ '--method', 'POST',
705
+ `repos/${repo}/pulls/${prNumber}/reviews`,
706
+ '--input', '-',
707
+ ],
708
+ { encoding: 'utf8', input: reviewBody },
709
+ );
710
+ if (postResult.status !== 0) {
711
+ process.stderr.write(
712
+ `clud-bug post-inline-threads: \`gh api -X POST .../reviews\` failed (exit ${postResult.status}): ${(postResult.stderr || '').slice(0, 500)}\n`,
713
+ );
714
+ process.stdout.write(JSON.stringify({
715
+ posted: 0,
716
+ skipped: plan.skipped.map((f) => ({ skill: f.skill, file: f.file, line: f.line, reason: 'not-anchorable' })),
717
+ preexisting: plan.preexisting.map((f) => ({ skill: f.skill })),
718
+ error: 'review-post-failed',
719
+ }) + '\n');
720
+ return;
721
+ }
722
+
723
+ process.stdout.write(JSON.stringify({
724
+ posted: plan.comments.length,
725
+ skipped: plan.skipped.map((f) => ({ skill: f.skill, file: f.file, line: f.line, reason: 'not-anchorable' })),
726
+ preexisting: plan.preexisting.map((f) => ({ skill: f.skill })),
727
+ }) + '\n');
728
+ }
729
+
730
+
731
+ // Wave 5b / 0.7.0-rc.8: D.2.6 auto-resolve on fix-push. Fetches all
732
+ // bot-authored unresolved inline threads, asks the Anthropic Messages
733
+ // API per-thread whether the new commit addressed the original
734
+ // finding, and resolves ADDRESSED threads (with a marker reply) while
735
+ // leaving UNCERTAIN/NOT_ADDRESSED open. Fail-closed throughout —
736
+ // verifier errors route to UNCERTAIN; the surrounding workflow
737
+ // step's `continue-on-error: true` is the single failure gate.
738
+ //
739
+ // Required env: GH_TOKEN, ANTHROPIC_API_KEY, REPO ("owner/name"), PR_NUMBER.
740
+ //
741
+ // No `--stdin` — fetches everything from `gh api graphql` itself.
742
+ // Workflow gates this verb on `github.event.action == 'synchronize'`
743
+ // so it only runs on fix-pushes (not initial PR opens, when there
744
+ // are no prior threads to resolve).
745
+ async function runResolveThreads(_args) {
746
+ const {
747
+ parseThreadBody,
748
+ extractAnchorContext,
749
+ REVIEW_THREADS_QUERY,
750
+ RESOLVE_THREAD_MUTATION,
751
+ ADD_REPLY_MUTATION,
752
+ } = await import('../core/inline-threads.js');
753
+ const {
754
+ readAutoResolveConfigFromCludBug,
755
+ runAutoResolve,
756
+ applyResolutionRules,
757
+ selectResolveAuth,
758
+ renderResolveMarkerTag,
759
+ anchorSignature,
760
+ latestResolveMarker,
761
+ } = await import('../core/auto-resolve.js');
762
+ const {
763
+ VERIFIER_SYSTEM,
764
+ buildVerifierPrompt,
765
+ parseVerifierResponse,
766
+ } = await import('../core/resolve-verifier.js');
767
+
768
+ // ---- Env validation ----------------------------------------------------
769
+ const repo = String(process.env.REPO ?? '').trim();
770
+ const prNumberRaw = String(process.env.PR_NUMBER ?? '').trim();
771
+ const prNumber = Number(prNumberRaw);
772
+ const ghToken = String(process.env.GH_TOKEN ?? '').trim();
773
+ const anthropicKey = String(process.env.ANTHROPIC_API_KEY ?? '').trim();
774
+ if (!repo || !repo.includes('/') || !Number.isInteger(prNumber) || prNumber <= 0 || !ghToken || !anthropicKey) {
775
+ process.stderr.write(
776
+ `clud-bug resolve-threads: REPO + PR_NUMBER + GH_TOKEN + ANTHROPIC_API_KEY env vars required.\n`,
777
+ );
778
+ process.stdout.write(JSON.stringify({ actions: [], verifierCallCount: 0, shouldRequestChanges: false, error: 'missing-env' }) + '\n');
779
+ return;
780
+ }
781
+ const [owner, repoName] = repo.split('/', 2);
782
+
783
+ // ---- Optional .clud-bug.json config (autoResolve block) ----------------
784
+ // The workflow's working dir is the PR checkout — if .clud-bug.json is
785
+ // present we read autoResolve from it. Otherwise defaults (mode='verified').
786
+ let autoResolveConfig;
787
+ try {
788
+ const cfgPath = join(process.cwd(), '.claude/skills/.clud-bug.json');
789
+ const cfgRaw = await readFile(cfgPath, 'utf8');
790
+ const cfg = JSON.parse(cfgRaw);
791
+ autoResolveConfig = readAutoResolveConfigFromCludBug(cfg, (msg) => {
792
+ process.stderr.write(`clud-bug resolve-threads: config warning: ${msg}\n`);
793
+ });
794
+ } catch (err) {
795
+ // Missing OR unparseable .clud-bug.json — use defaults but log the
796
+ // specific reason so an operator with malformed config can diagnose
797
+ // why their `autoResolve.mode = 'off'` is being silently ignored.
798
+ const code = err && typeof err === 'object' && 'code' in err ? err.code : undefined;
799
+ if (code !== 'ENOENT') {
800
+ process.stderr.write(
801
+ `clud-bug resolve-threads: .clud-bug.json read/parse error (${err && typeof err === 'object' && 'message' in err ? err.message : String(err)}); using defaults.\n`,
802
+ );
803
+ }
804
+ autoResolveConfig = readAutoResolveConfigFromCludBug(null);
805
+ }
806
+
807
+ if (autoResolveConfig.mode === 'off') {
808
+ process.stdout.write(JSON.stringify({ actions: [], verifierCallCount: 0, shouldRequestChanges: false, reason: 'mode-off' }) + '\n');
809
+ return;
810
+ }
811
+
812
+ // ---- Fetch threads via GraphQL -----------------------------------------
813
+ // `gh api graphql` uses `-f` for String! variables and `-F` for typed
814
+ // (number / bool) variables. Owner + repo are String! per the query;
815
+ // pr is Int!. Reviewer-flagged: passing owner/repo as `-F` would coerce
816
+ // numeric-looking values incorrectly.
817
+ const threadsResult = spawnSync(
818
+ 'gh',
819
+ [
820
+ 'api', 'graphql',
821
+ '-f', `query=${REVIEW_THREADS_QUERY}`,
822
+ '-f', `owner=${owner}`,
823
+ '-f', `repo=${repoName}`,
824
+ '-F', `pr=${prNumber}`,
825
+ ],
826
+ { encoding: 'utf8' },
827
+ );
828
+ if (threadsResult.status !== 0) {
829
+ process.stderr.write(`clud-bug resolve-threads: gh api graphql (threads) failed (exit ${threadsResult.status}): ${(threadsResult.stderr || '').slice(0, 500)}\n`);
830
+ process.stdout.write(JSON.stringify({ actions: [], verifierCallCount: 0, shouldRequestChanges: false, error: 'threads-fetch-failed' }) + '\n');
831
+ return;
832
+ }
833
+
834
+ let threadNodes;
835
+ try {
836
+ const parsed = JSON.parse(threadsResult.stdout);
837
+ threadNodes = parsed?.data?.repository?.pullRequest?.reviewThreads?.nodes ?? [];
838
+ if (!Array.isArray(threadNodes)) threadNodes = [];
839
+ } catch (e) {
840
+ process.stderr.write(`clud-bug resolve-threads: threads JSON parse failed: ${e.message}\n`);
841
+ process.stdout.write(JSON.stringify({ actions: [], verifierCallCount: 0, shouldRequestChanges: false, error: 'threads-parse-failed' }) + '\n');
842
+ return;
843
+ }
844
+
845
+ // ---- Filter to bot-authored unresolved threads with our marker --------
846
+ // The workflow's GITHUB_TOKEN posts as `github-actions[bot]`. Match both
847
+ // the bracketed bot form and the bare `github-actions` form (GraphQL
848
+ // can return either depending on the node-type field requested).
849
+ const BOT_AUTHORS = new Set(['github-actions', 'github-actions[bot]']);
850
+ const candidates = [];
851
+ for (const t of threadNodes) {
852
+ if (!t || t.isResolved) continue;
853
+ const c = t.comments?.nodes?.[0];
854
+ if (!c) continue;
855
+ const authorLogin = c.author?.login ?? '';
856
+ if (!BOT_AUTHORS.has(authorLogin)) continue;
857
+ const parsed = parseThreadBody(c.body ?? '');
858
+ if (!parsed) continue;
859
+ if (!c.path || (c.line === null && c.originalLine === null)) continue;
860
+ candidates.push({
861
+ threadId: t.id,
862
+ file: c.path,
863
+ line: c.line ?? c.originalLine,
864
+ parsed,
865
+ // Wave 5b.1: full comment list so idempotency can spot a prior
866
+ // bot reply's resolve marker (verdict + anchor sig) on this thread.
867
+ comments: t.comments?.nodes ?? [],
868
+ });
869
+ }
870
+
871
+ if (candidates.length === 0) {
872
+ process.stdout.write(JSON.stringify({ actions: [], verifierCallCount: 0, shouldRequestChanges: false, reason: 'no-bot-threads' }) + '\n');
873
+ return;
874
+ }
875
+
876
+ // ---- Fetch diff for anchor context -------------------------------------
877
+ const filesResult = spawnSync(
878
+ 'gh',
879
+ [
880
+ 'api',
881
+ `repos/${repo}/pulls/${prNumber}/files`,
882
+ '--paginate',
883
+ '--slurp',
884
+ ],
885
+ { encoding: 'utf8' },
886
+ );
887
+ if (filesResult.status !== 0) {
888
+ process.stderr.write(`clud-bug resolve-threads: diff fetch failed (exit ${filesResult.status}): ${(filesResult.stderr || '').slice(0, 500)}\n`);
889
+ process.stdout.write(JSON.stringify({ actions: [], verifierCallCount: 0, shouldRequestChanges: false, error: 'diff-fetch-failed' }) + '\n');
890
+ return;
891
+ }
892
+ let diffFiles = [];
893
+ try {
894
+ const pages = JSON.parse(filesResult.stdout);
895
+ if (Array.isArray(pages)) {
896
+ for (const page of pages) {
897
+ if (Array.isArray(page)) diffFiles.push(...page);
898
+ }
899
+ }
900
+ } catch (e) {
901
+ process.stderr.write(`clud-bug resolve-threads: diff JSON parse failed: ${e.message}\n`);
902
+ process.stdout.write(JSON.stringify({ actions: [], verifierCallCount: 0, shouldRequestChanges: false, error: 'diff-parse-failed' }) + '\n');
903
+ return;
904
+ }
905
+
906
+ // ---- Build PriorThread[] for runAutoResolve ----------------------------
907
+ const priorThreads = candidates.map((c) => {
908
+ const ctx = extractAnchorContext({ file: c.file, line: c.line }, diffFiles);
909
+ const findingBody = c.parsed.reasoning
910
+ ? `${c.parsed.summary}\n\n${c.parsed.reasoning}`
911
+ : c.parsed.summary;
912
+ return {
913
+ threadId: c.threadId,
914
+ finding: {
915
+ severity: c.parsed.severity,
916
+ body: findingBody,
917
+ skill: c.parsed.skill,
918
+ file: c.file,
919
+ line: c.line,
920
+ },
921
+ codeBefore: ctx.codeBefore,
922
+ codeAfter: ctx.codeAfter,
923
+ ...(ctx.diffAtAnchor !== undefined ? { diffAtAnchor: ctx.diffAtAnchor } : {}),
924
+ };
925
+ });
926
+
927
+ // ---- Define verifier (Anthropic Messages API via raw fetch) -----------
928
+ // Model is hardcoded here — the verifier needs a model that handles
929
+ // structured JSON output reliably. Sonnet 4.6 is the default. Override
930
+ // via CLUD_BUG_VERIFIER_MODEL env var if needed.
931
+ const verifierModel = String(process.env.CLUD_BUG_VERIFIER_MODEL ?? '').trim()
932
+ || 'claude-sonnet-4-6';
933
+
934
+ async function verifier(input) {
935
+ const userPrompt = buildVerifierPrompt(input);
936
+ try {
937
+ const resp = await fetch('https://api.anthropic.com/v1/messages', {
938
+ method: 'POST',
939
+ headers: {
940
+ 'content-type': 'application/json',
941
+ 'anthropic-version': '2023-06-01',
942
+ 'x-api-key': anthropicKey,
943
+ },
944
+ body: JSON.stringify({
945
+ model: verifierModel,
946
+ max_tokens: 300,
947
+ system: VERIFIER_SYSTEM,
948
+ messages: [{ role: 'user', content: userPrompt }],
949
+ }),
950
+ });
951
+ if (!resp.ok) {
952
+ const errText = await resp.text().catch(() => '');
953
+ return {
954
+ verdict: 'UNCERTAIN',
955
+ source: 'api-error',
956
+ rationale: `Anthropic API ${resp.status}: ${errText.slice(0, 200)}`,
957
+ };
958
+ }
959
+ const body = await resp.json();
960
+ // Messages API: content is an array of blocks; pick the first text block.
961
+ const text = (body?.content ?? []).find((b) => b?.type === 'text')?.text ?? '';
962
+ return parseVerifierResponse(text);
963
+ } catch (err) {
964
+ return {
965
+ verdict: 'UNCERTAIN',
966
+ source: 'api-error',
967
+ rationale: `verifier fetch error: ${err?.message ?? String(err)}`,
968
+ };
969
+ }
970
+ }
971
+
972
+ // ---- Resolve auth (Wave 5b.1: PAT optional + graceful fallback) -------
973
+ // A dedicated PAT lets us actually close threads; without one the Actions
974
+ // GITHUB_TOKEN can't resolve, so we post a "verified fixed" reply only.
975
+ const auth = selectResolveAuth(process.env);
976
+
977
+ // ---- Idempotency: skip threads whose anchor is unchanged --------------
978
+ // For each candidate, hash the post-fix anchor and look for a prior bot
979
+ // reply carrying the same (verdict, sig) marker. If the anchor hasn't moved
980
+ // since that marker, we re-use the cached verdict — NO verifier call, NO
981
+ // re-post — which kills repeat replies on multi-push PRs and saves spend.
982
+ const verifyItems = []; // { thread, sig } — need (re)verification
983
+ const cachedItems = []; // { thread, action } — anchor unchanged, no I/O
984
+ for (let i = 0; i < priorThreads.length; i++) {
985
+ const thread = priorThreads[i];
986
+ const candidate = candidates[i];
987
+ if (!thread) continue;
988
+ const sig = anchorSignature(thread.finding, thread.codeAfter);
989
+ const prior = latestResolveMarker(candidate?.comments ?? [], BOT_AUTHORS);
990
+ if (prior && prior.sig === sig) {
991
+ const action = applyResolutionRules({
992
+ thread,
993
+ verdict: {
994
+ verdict: prior.verdict,
995
+ source: 'model',
996
+ rationale: '(unchanged since last check)',
997
+ },
998
+ config: autoResolveConfig,
999
+ canResolve: auth.hasDedicatedPat,
1000
+ });
1001
+ cachedItems.push({ thread, action });
1002
+ } else {
1003
+ verifyItems.push({ thread, sig });
1004
+ }
1005
+ }
1006
+
1007
+ // ---- Run pure orchestration on the threads that need (re)verification --
1008
+ const result = await runAutoResolve({
1009
+ priorThreads: verifyItems.map((v) => v.thread),
1010
+ config: autoResolveConfig,
1011
+ verifier,
1012
+ canResolve: auth.hasDedicatedPat,
1013
+ });
1014
+
1015
+ let shouldRequestChanges = result.shouldRequestChanges;
1016
+ for (const c of cachedItems) {
1017
+ if (c.action.kind === 'keep_open_request_changes') shouldRequestChanges = true;
1018
+ }
1019
+
1020
+ // ---- Execute the actions via GraphQL mutations ------------------------
1021
+ const actionsReport = [];
1022
+
1023
+ // Unchanged threads: never re-post the reply (idempotent). BUT if the
1024
+ // thread is verified ADDRESSED and a PAT is now available, resolve it —
1025
+ // the marker reply already exists, so this closes a thread that couldn't
1026
+ // be resolved before (no-PAT → PAT upgrade, or a prior transient
1027
+ // resolve-failure) WITHOUT re-verifying or re-replying.
1028
+ for (const c of cachedItems) {
1029
+ const baseReport = {
1030
+ threadId: c.thread.threadId,
1031
+ file: c.thread.finding.file,
1032
+ line: c.thread.finding.line,
1033
+ verdict: c.action.verdict?.verdict,
1034
+ kind: c.action.kind,
1035
+ };
1036
+ if (c.action.kind === 'resolve' && auth.hasDedicatedPat) {
1037
+ const resolveResult = spawnSync(
1038
+ 'gh',
1039
+ [
1040
+ 'api', 'graphql',
1041
+ '-f', `query=${RESOLVE_THREAD_MUTATION}`,
1042
+ '-f', `threadId=${c.thread.threadId}`,
1043
+ ],
1044
+ { encoding: 'utf8', env: { ...process.env, GH_TOKEN: auth.token } },
1045
+ );
1046
+ if (resolveResult.status !== 0) {
1047
+ process.stderr.write(`clud-bug resolve-threads: RESOLVE (cached) failed for thread ${c.thread.threadId}: ${(resolveResult.stderr || '').slice(0, 200)}\n`);
1048
+ actionsReport.push({ ...baseReport, executed: 'resolve-failed' });
1049
+ } else {
1050
+ actionsReport.push({ ...baseReport, executed: 'resolved-from-cache' });
1051
+ }
1052
+ } else {
1053
+ actionsReport.push({ ...baseReport, executed: 'unchanged' });
1054
+ }
1055
+ }
1056
+
1057
+ for (let i = 0; i < result.actions.length; i++) {
1058
+ const action = result.actions[i];
1059
+ const item = verifyItems[i];
1060
+ if (!action || !item) continue;
1061
+ const thread = item.thread;
1062
+ const report = {
1063
+ threadId: thread.threadId,
1064
+ file: thread.finding.file,
1065
+ line: thread.finding.line,
1066
+ verdict: action.verdict?.verdict,
1067
+ kind: action.kind,
1068
+ };
1069
+
1070
+ if (action.kind === 'skipped') {
1071
+ actionsReport.push({ ...report, executed: 'skipped' });
1072
+ continue;
1073
+ }
1074
+
1075
+ // Reply body carries a hidden idempotency marker (verdict + anchor sig)
1076
+ // so a later fix-push won't re-reply while the anchor is unchanged.
1077
+ const tag = renderResolveMarkerTag(action.verdict.verdict, item.sig);
1078
+ const replyBody = `${action.markerBody}\n\n${tag}`;
1079
+ const replyResult = spawnSync(
1080
+ 'gh',
1081
+ [
1082
+ 'api', 'graphql',
1083
+ '-f', `query=${ADD_REPLY_MUTATION}`,
1084
+ '-f', `threadId=${thread.threadId}`,
1085
+ '-f', `body=${replyBody}`,
1086
+ ],
1087
+ { encoding: 'utf8' },
1088
+ );
1089
+ if (replyResult.status !== 0) {
1090
+ process.stderr.write(`clud-bug resolve-threads: ADD_REPLY failed for thread ${thread.threadId}: ${(replyResult.stderr || '').slice(0, 200)}\n`);
1091
+ actionsReport.push({ ...report, executed: 'reply-failed' });
1092
+ continue;
1093
+ }
1094
+
1095
+ // Resolve only ADDRESSED threads, and only with a dedicated PAT — the
1096
+ // Actions GITHUB_TOKEN can't resolve ("Resource not accessible by
1097
+ // integration"). No PAT → marker-reply-only (graceful, no error).
1098
+ if (action.kind === 'resolve') {
1099
+ if (!auth.hasDedicatedPat) {
1100
+ actionsReport.push({ ...report, executed: 'reply-only-no-pat' });
1101
+ continue;
1102
+ }
1103
+ const resolveResult = spawnSync(
1104
+ 'gh',
1105
+ [
1106
+ 'api', 'graphql',
1107
+ '-f', `query=${RESOLVE_THREAD_MUTATION}`,
1108
+ '-f', `threadId=${thread.threadId}`,
1109
+ ],
1110
+ // Scope the PAT to JUST this call; all other gh calls keep GH_TOKEN.
1111
+ { encoding: 'utf8', env: { ...process.env, GH_TOKEN: auth.token } },
1112
+ );
1113
+ if (resolveResult.status !== 0) {
1114
+ process.stderr.write(`clud-bug resolve-threads: RESOLVE failed for thread ${thread.threadId}: ${(resolveResult.stderr || '').slice(0, 200)}\n`);
1115
+ actionsReport.push({ ...report, executed: 'resolve-failed' });
1116
+ continue;
1117
+ }
1118
+ actionsReport.push({ ...report, executed: 'resolved' });
1119
+ } else {
1120
+ // keep_open or keep_open_request_changes — reply already posted.
1121
+ actionsReport.push({ ...report, executed: 'kept-open' });
1122
+ }
1123
+ }
1124
+
1125
+ process.stdout.write(JSON.stringify({
1126
+ actions: actionsReport,
1127
+ verifierCallCount: result.verifierCallCount,
1128
+ shouldRequestChanges,
1129
+ resolveTokenSource: auth.source,
1130
+ }) + '\n');
1131
+ }
1132
+
1133
+
1134
+ // 0.0.E (v0.6.17): thin wrapper around the golden-set test file. Devs
1135
+ // who follow the README invoke `clud-bug eval` — this routes to the
1136
+ // same `node --test` runner CI uses, so dev and CI verdicts match.
1137
+ //
1138
+ // Dev-only: runs against the prompt bundled in PKG_ROOT (the cloned
1139
+ // clud-bug repo). `test/` is intentionally not in package.json `files`,
1140
+ // so invoking this from a globally installed copy will ENOENT. No args
1141
+ // supported yet — the README does not advertise any.
1142
+ async function runEval() {
1143
+ const result = spawnSync(
1144
+ 'node',
1145
+ ['--test', join(PKG_ROOT, 'test/prompts.eval.test.js')],
1146
+ { stdio: 'inherit' },
1147
+ );
1148
+ process.exit(result.status ?? 1);
1149
+ }
1150
+
1151
+ async function runInit(args) {
1152
+ const cwd = process.cwd();
1153
+ log(`🐛 Field season opens in ${cwd}.`);
1154
+
1155
+ log(' surveying habitat...');
1156
+ const signals = await detect(cwd);
1157
+ log(` primary language: ${signals.primaryLanguage || '(unknown)'}`);
1158
+ log(` search terms: ${signals.searchTerms.join(', ') || '(none)'}`);
1159
+
1160
+ const baseline = await loadBaseline(BASELINE_DIR);
1161
+ const fromAgentSkills = baseline.filter((s) => s._source === 'agent-skills').length;
1162
+ const sourceLabel = baseline.length === 0
1163
+ ? ''
1164
+ : fromAgentSkills === baseline.length ? ' (from thrillmade/agent-skills)'
1165
+ : fromAgentSkills === 0 ? ' (bundled fallback)'
1166
+ : ` (${fromAgentSkills} from agent-skills, ${baseline.length - fromAgentSkills} bundled)`;
1167
+ log(` baseline kit: ${baseline.length} specimens${sourceLabel}`);
1168
+
1169
+ let curated = [];
1170
+ let searched = [];
1171
+ if (args.offline) {
1172
+ log(' --offline: skipping skills.sh');
1173
+ } else {
1174
+ const client = new SkillsClient();
1175
+ try {
1176
+ log(' consulting skills.sh...');
1177
+ [curated, searched] = await Promise.all([
1178
+ client.curated().catch(err => { warn(`curated query failed: ${err.message}`); return []; }),
1179
+ client.search(signals.searchTerms).catch(err => { warn(`search failed: ${err.message}`); return []; }),
1180
+ ]);
1181
+ log(` curated: ${curated.length}, search hits: ${searched.length}`);
1182
+ } catch (err) {
1183
+ warn(`skills.sh unreachable (${err.message}); continuing with baseline only`);
1184
+ }
1185
+ }
1186
+
1187
+ const recommended = rankAndCap(curated, searched, baseline);
1188
+ log('');
1189
+ log('Specimens to pin:');
1190
+ for (const s of recommended) {
1191
+ const tag = s.kind === 'baseline' ? '[baseline]' : `[${s.source}]`;
1192
+ log(` • ${s.name} ${tag}`);
1193
+ if (s.description && s.kind !== 'baseline') log(` ${s.description}`);
1194
+ }
1195
+ log('');
1196
+
1197
+ let chosen = recommended;
1198
+ if (!args.acceptAll && recommended.some(s => s.kind !== 'baseline')) {
1199
+ chosen = await promptForSkills(recommended);
1200
+ }
1201
+
1202
+ log(' pinning specimens to .claude/skills/...');
1203
+ const client = new SkillsClient();
1204
+ const written = await writeSkills(join(cwd, '.claude', 'skills'), chosen, client);
1205
+ log(` pinned ${written.length} specimens`);
1206
+
1207
+ // Empty-skills warning: clud-bug shines when paired with project-specific
1208
+ // skills. Reviews that load only the three baselines are functional but
1209
+ // generic; flag this so users notice.
1210
+ const remoteCount = written.filter((w) => w.kind !== 'baseline').length;
1211
+ if (remoteCount === 0) {
1212
+ 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`.');
1213
+ }
1214
+
1215
+ log(' drafting field kit...');
1216
+ const tmplName = pickTemplate(signals.languages);
1217
+ const tmplPath = join(TEMPLATES, tmplName);
1218
+ // REVIEW_SCHEMA + CCA_VERSION + CLUD_BUG_VERSION come from render.js DEFAULTS.
1219
+ const workflow = await renderFile(tmplPath, {
1220
+ REVIEW_PROMPT: reviewPrompt({
1221
+ projectDescription: buildDescriptionLine(signals),
1222
+ language: templateLanguage(tmplName),
1223
+ }),
1224
+ });
1225
+ const workflowPath = join(cwd, '.github', 'workflows', 'clud-bug-review.yml');
1226
+ await mkdir(dirname(workflowPath), { recursive: true });
1227
+ await writeFile(workflowPath, workflow);
1228
+ log(` wrote ${rel(cwd, workflowPath)}`);
1229
+
1230
+ // Install the audit workflow alongside the per-PR review one.
1231
+ // Manual-trigger by default; users opt into the cron by uncommenting.
1232
+ // Routed through renderFile so {{CCA_VERSION}} substitution pins
1233
+ // claude-code-action consistently with the review workflow.
1234
+ const auditTmpl = await renderFile(join(TEMPLATES, 'audit.yml.tmpl'), {});
1235
+ const auditPath = join(cwd, '.github', 'workflows', 'clud-bug-audit.yml');
1236
+ await writeFile(auditPath, auditTmpl);
1237
+ log(` wrote ${rel(cwd, auditPath)}`);
1238
+
1239
+ // Install the self-update workflow. Cron weekly Mondays 12:00 UTC; opens
1240
+ // a PR if a newer clud-bug version is published. Disable by deleting the
1241
+ // file or pinning via .claude/skills/.clud-bug.json.
1242
+ // Routed through renderFile for parity (no CCA ref today but future
1243
+ // tokens should propagate uniformly).
1244
+ const selfUpdateTmpl = await renderFile(join(TEMPLATES, 'self-update.yml.tmpl'), {});
1245
+ const selfUpdatePath = join(cwd, '.github', 'workflows', 'clud-bug-self-update.yml');
1246
+ await writeFile(selfUpdatePath, selfUpdateTmpl);
1247
+ log(` wrote ${rel(cwd, selfUpdatePath)}`);
1248
+
1249
+ // v0.7.0 (Wave 6b): optional local-review slash command. Scaffolds
1250
+ // `.claude/commands/clud-bug-review.md` so `/clud-bug-review` works inside a
1251
+ // Claude Code session — the agent reviews the current branch's open PR using
1252
+ // THAT session's own tokens (Max or API), no hosted App or new auth required.
1253
+ // `.claude/` is already in the --commit add-list below, so it's staged too.
1254
+ if (args.withLocalReview) {
1255
+ const commandPath = join(cwd, '.claude', 'commands', 'clud-bug-review.md');
1256
+ await mkdir(dirname(commandPath), { recursive: true });
1257
+ const commandContent = await renderFile(join(TEMPLATES, 'clud-bug-review.md.tmpl'), {});
1258
+ await writeFile(commandPath, commandContent);
1259
+ log(` wrote ${rel(cwd, commandPath)}`);
1260
+ }
1261
+
1262
+ // Stamp the manifest. Sets strictMode: true ONLY on fresh installs —
1263
+ // a manifest that's never been touched by clud-bug init/update has no
1264
+ // lastUpdate field. Existing v0.3.x advisory installs (where strictMode
1265
+ // was never written and so == undefined) keep their advisory behavior
1266
+ // because lastUpdate IS set; the strictMode default only fires on truly
1267
+ // fresh inits. Users opt out by setting strictMode: false.
1268
+ const skillsDirPath = join(cwd, '.claude', 'skills');
1269
+ const manifest = await readManifest(skillsDirPath);
1270
+ const isFreshInstall = manifest.lastUpdate === undefined;
1271
+ manifest.lastUpdateVersion = await readPkgVersion();
1272
+ manifest.lastUpdate = new Date().toISOString();
1273
+ if (isFreshInstall && manifest.strictMode === undefined) {
1274
+ manifest.strictMode = true;
1275
+ }
1276
+ await writeManifest(skillsDirPath, manifest);
1277
+
1278
+ // Tell other agents what's installed and how to coexist with the bot.
1279
+ // Idempotent — re-runs replace the prior block in place. AGENTS.md is the
1280
+ // canonical home (cross-tool); CLAUDE.md / GEMINI.md / Cursor / Windsurf
1281
+ // / Cline / Continue rules files get the same block appended IF they
1282
+ // already exist (we don't proliferate stubs the user didn't ask for).
1283
+ log(' briefing other agents (AGENTS.md / CLAUDE.md)...');
1284
+ // Pass `=== true` (not `!== false`) so the rendered block matches the
1285
+ // workflow's gate predicate exactly. A v0.3 advisory upgrade where
1286
+ // strictMode is undefined renders "off" — which is what the workflow
1287
+ // actually does on that manifest.
1288
+ const agentDocs = await applyAgentDocs(cwd, {
1289
+ version: manifest.lastUpdateVersion,
1290
+ strictMode: manifest.strictMode === true,
1291
+ });
1292
+ for (const p of agentDocs.created) log(` created ${p}`);
1293
+ for (const p of agentDocs.touched) log(` updated ${p}`);
1294
+
1295
+ if (args.commit) {
1296
+ log(' committing...');
1297
+ const toAdd = [
1298
+ '.claude',
1299
+ '.github/workflows/clud-bug-review.yml',
1300
+ '.github/workflows/clud-bug-audit.yml',
1301
+ '.github/workflows/clud-bug-self-update.yml',
1302
+ ...agentDocs.created,
1303
+ ...agentDocs.touched,
1304
+ ];
1305
+ spawnSync('git', ['add', ...toAdd], { cwd, stdio: 'inherit' });
1306
+ spawnSync('git', ['commit', '-m', 'Add clud-bug 🐛 — a field guide to specimens crawling your code'], { cwd, stdio: 'inherit' });
1307
+ }
1308
+
1309
+ // Offer to enable required_conversation_resolution on the default
1310
+ // branch. clud-bug auto-resolves its own review threads when fixes
1311
+ // land — without this setting, that doesn't gate merges. Skipped on
1312
+ // --no-set-protection for repos that manage protection via ruleset
1313
+ // or org policy.
1314
+ await runInitBranchProtection(args);
1315
+
1316
+ log('');
1317
+ log('Field kit assembled. Next:');
1318
+ log(' 1. Set ANTHROPIC_API_KEY in your repo secrets:');
1319
+ log(' Settings → Secrets and variables → Actions → New repository secret');
1320
+ if (!args.commit) {
1321
+ log(' 2. git add .claude .github/workflows/clud-bug-*.yml && git commit && git push');
1322
+ log(' 3. Open a PR — the naturalist arrives within ~2 minutes.');
1323
+ } else {
1324
+ log(' 2. git push, then open a PR — the naturalist arrives within ~2 minutes.');
1325
+ }
1326
+ log('');
1327
+ log('Drop your own .claude/skills/<name>/SKILL.md files anytime — they get pinned automatically.');
1328
+ log('For a whole-repo walk: Actions tab → Clud Bug 🐛 Audit → Run workflow.');
1329
+ log('Self-update is on (weekly Mondays 12:00 UTC). Pin via "pinVersion" in .claude/skills/.clud-bug.json.');
1330
+ log('');
1331
+ log('Strict mode is ON by default (clud-bug-review fails the check on critical findings).');
1332
+ log(' • Add `clud-bug-review` to your branch protection required checks for full enforcement.');
1333
+ log(' • Opt out by setting "strictMode": false in .claude/skills/.clud-bug.json.');
1334
+
1335
+ // v0.6.33 — opt-in unified install (mirror of logmind v0.6.8). When
1336
+ // --with-skdd is passed, subprocess to `pip install logmind` + `logmind init`
1337
+ // so Node-first users get the same one-command bootstrap as Python-first
1338
+ // users do via `logmind init --with-skdd`.
1339
+ // ANTI-LOOP: invoke `logmind init` (NOT `logmind init --with-skdd`).
1340
+ // Each opt-in flag only goes one level — no mutual recursion possible.
1341
+ if (args.withSkdd) {
1342
+ await installLogmindViaPip();
1343
+ }
1344
+
1345
+ // Final agent-friendly summary line (always emitted, even with --quiet).
1346
+ const version = await readPkgVersion();
1347
+ ok(`initialized: .claude/skills/ ${chosen.length} specimens, workflow @v${version}`);
1348
+ }
1349
+
1350
+ async function installLogmindViaPip() {
1351
+ // `spawn` is already imported at module top (line 5). No dynamic
1352
+ // re-import needed.
1353
+ //
1354
+ // Warnings use process.stderr.write directly (always emitted, even
1355
+ // under CLUD_BUG_QUIET=1) — recovery hints MUST surface to the user.
1356
+ // The standard `log()` is for progress chatter which quiet suppresses.
1357
+
1358
+ // Find pip via fallback chain (pip → pip3 → python -m pip).
1359
+ const pipCmd = await findPipCommand();
1360
+ if (!pipCmd) {
1361
+ process.stderr.write(
1362
+ '\nWarning: --with-skdd requested but no `pip`/`pip3`/`python` found on PATH.\n' +
1363
+ ' Install Python 3.10+ (https://python.org), then run:\n' +
1364
+ ' pip install logmind && logmind init\n' +
1365
+ ' Or skip this flag if you only want clud-bug standalone.\n'
1366
+ );
1367
+ return;
1368
+ }
1369
+
1370
+ log('');
1371
+ log(`→ --with-skdd: installing logmind (${pipCmd.join(' ')} install logmind)`);
1372
+
1373
+ const installCode = await new Promise((resolve) => {
1374
+ const child = spawn(pipCmd[0], [...pipCmd.slice(1), 'install', 'logmind'], { stdio: 'inherit' });
1375
+ child.on('error', () => resolve(127));
1376
+ child.on('close', (code) => resolve(code ?? 1));
1377
+ });
1378
+ if (installCode !== 0) {
1379
+ process.stderr.write(
1380
+ `Warning: \`pip install logmind\` exited ${installCode}.\n` +
1381
+ ' clud-bug side succeeded; logmind install is incomplete.\n' +
1382
+ ' Inspect output above and re-run manually if needed.\n'
1383
+ );
1384
+ return;
1385
+ }
1386
+
1387
+ log(`→ --with-skdd: running \`logmind init\` to scaffold the logmind side`);
1388
+ const initCode = await new Promise((resolve) => {
1389
+ const child = spawn('logmind', ['init'], { stdio: 'inherit' });
1390
+ child.on('error', () => resolve(127));
1391
+ child.on('close', (code) => resolve(code ?? 1));
1392
+ });
1393
+ if (initCode !== 0) {
1394
+ process.stderr.write(
1395
+ `Warning: \`logmind init\` exited ${initCode}. logmind install completed `
1396
+ + `but init scaffolding is incomplete. Re-run manually to finish.\n`
1397
+ );
1398
+ return;
1399
+ }
1400
+ log('✓ logmind installed via --with-skdd');
1401
+ }
1402
+
1403
+ async function findPipCommand() {
1404
+ // Try pip → pip3 → python -m pip → python3 -m pip in order. First one
1405
+ // that responds to --version wins. Returns array form for spawn().
1406
+ // `spawn` is already imported at module top.
1407
+ const candidates = [
1408
+ ['pip'],
1409
+ ['pip3'],
1410
+ ['python', '-m', 'pip'],
1411
+ ['python3', '-m', 'pip'],
1412
+ ];
1413
+ for (const cmd of candidates) {
1414
+ const ok = await new Promise((resolve) => {
1415
+ const child = spawn(cmd[0], [...cmd.slice(1), '--version'], { stdio: ['ignore', 'ignore', 'ignore'] });
1416
+ child.on('error', () => resolve(false));
1417
+ child.on('close', (code) => resolve(code === 0));
1418
+ });
1419
+ if (ok) return cmd;
1420
+ }
1421
+ return null;
1422
+ }
1423
+
1424
+ async function promptForSkills(recommended) {
1425
+ const rl = createInterface({ input, output });
1426
+ try {
1427
+ const answer = await rl.question('Install all of the above? [Y/n/select] ');
1428
+ const a = answer.trim().toLowerCase();
1429
+ if (a === '' || a === 'y' || a === 'yes') return recommended;
1430
+ if (a === 'n' || a === 'no') return recommended.filter(s => s.kind === 'baseline');
1431
+ if (a === 's' || a === 'select') {
1432
+ const chosen = [];
1433
+ for (const skill of recommended) {
1434
+ if (skill.kind === 'baseline') { chosen.push(skill); continue; }
1435
+ const ans = await rl.question(` install ${skill.name}? [Y/n] `);
1436
+ if (ans.trim().toLowerCase() !== 'n') chosen.push(skill);
1437
+ }
1438
+ return chosen;
1439
+ }
1440
+ return recommended;
1441
+ } finally {
1442
+ rl.close();
1443
+ }
1444
+ }
1445
+
1446
+ // Branch-protection setup step at the end of `clud-bug init`.
1447
+ // Offers to enable required_conversation_resolution on the default
1448
+ // branch via gh API. Skipped cleanly when --no-set-protection is
1449
+ // passed. Failure modes (no admin perms, no base protection rule,
1450
+ // network error) all degrade to advisory log messages — they never
1451
+ // fail the init run.
1452
+ //
1453
+ // gh and prompt are injectable for tests (defaults to spawning real
1454
+ // gh + reading from real stdin).
1455
+ async function runInitBranchProtection(args, { gh, prompt } = {}) {
1456
+ if (!args.setProtection) {
1457
+ log('');
1458
+ log('🐛 Branch protection: skipped (--no-set-protection).');
1459
+ return;
1460
+ }
1461
+ log('');
1462
+ log('🐛 Branch protection');
1463
+
1464
+ // Detect repo + default branch. If gh isn't installed or the local
1465
+ // dir isn't a github repo, treat as advisory and move on.
1466
+ let owner, repo, branch;
1467
+ try {
1468
+ ({ owner, repo } = await detectRepo({ gh }));
1469
+ branch = await detectDefaultBranch({ owner, repo, gh });
1470
+ } catch (err) {
1471
+ log(` Could not detect repo/branch (${err.message.split('\n')[0]}). Skipping.`);
1472
+ log(' You can enable it manually: gh api -X POST repos/<owner>/<repo>/branches/<default>/protection/required_conversation_resolution');
1473
+ return;
1474
+ }
1475
+
1476
+ log(` Default branch: ${branch}`);
1477
+
1478
+ // Inspect current state.
1479
+ const current = await getProtectionState({ owner, repo, branch, gh });
1480
+ if (current.state === 'enabled') {
1481
+ log(' required_conversation_resolution: already on — your repo is all set.');
1482
+ return;
1483
+ }
1484
+ if (current.state === 'forbidden') {
1485
+ 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.');
1486
+ return;
1487
+ }
1488
+ if (current.state === 'unknown') {
1489
+ log(` Could not read branch protection (${current.reason}). Skipping.`);
1490
+ return;
1491
+ }
1492
+
1493
+ // Short-circuit on no-protection BEFORE prompting. The single-flag
1494
+ // POST endpoint requires a base protection rule on the branch — if
1495
+ // there's none, enableConversationResolution would just 404. Skip
1496
+ // the prompt and go straight to the actionable guidance (set up
1497
+ // basic protection first, then re-run).
1498
+ if (current.state === 'no-protection') {
1499
+ log(' required_conversation_resolution: not set (no base protection rule on this branch)');
1500
+ log(' Cannot enable yet: this branch has no base protection rule.');
1501
+ log(` Set one up first: Settings → Branches → Add rule for ${branch}`);
1502
+ log(' Then re-run clud-bug init (or toggle the setting in the GUI).');
1503
+ return;
1504
+ }
1505
+
1506
+ // current.state is 'disabled'.
1507
+ log(' required_conversation_resolution: not set');
1508
+
1509
+ // Decide whether to prompt.
1510
+ let shouldEnable;
1511
+ if (args.acceptAll) {
1512
+ // --accept-all is a real side-effect flag here: it flips a
1513
+ // merge-gating repo setting. Make that explicit in the log so
1514
+ // CI users running `clud-bug init --accept-all` see exactly
1515
+ // what's happening instead of silently noticing later.
1516
+ log(' --accept-all: will enable required_conversation_resolution. Pass --no-set-protection to skip.');
1517
+ shouldEnable = true;
1518
+ } else {
1519
+ const ask = prompt ?? (async (q) => {
1520
+ const rl = createInterface({ input, output });
1521
+ try { return await rl.question(q); } finally { rl.close(); }
1522
+ });
1523
+ log('');
1524
+ log(' Clud Bug auto-resolves its own review threads when fixes land.');
1525
+ log(' Without required_conversation_resolution, that doesn\'t actually gate merges.');
1526
+ const answer = await ask(` Enable required_conversation_resolution on ${branch}? [Y/n] `);
1527
+ shouldEnable = !['n', 'no'].includes(answer.trim().toLowerCase());
1528
+ }
1529
+
1530
+ if (!shouldEnable) {
1531
+ log(' Skipped. Re-run with --accept-all or set it manually anytime.');
1532
+ return;
1533
+ }
1534
+
1535
+ const result = await enableConversationResolution({ owner, repo, branch, gh });
1536
+ if (result.ok) {
1537
+ log(' ✓ Enabled required_conversation_resolution.');
1538
+ return;
1539
+ }
1540
+ if (result.state === 'no-protection') {
1541
+ log(' Cannot enable: this branch has no base protection rule. Set up basic branch protection first:');
1542
+ log(` Settings → Branches → Add rule for ${branch}`);
1543
+ log(' Then re-run clud-bug init (or just toggle the setting in the GUI).');
1544
+ return;
1545
+ }
1546
+ if (result.state === 'forbidden') {
1547
+ log(' Cannot enable: you do not have admin permissions on this repository.');
1548
+ log(' Ask the repo owner to enable it, or re-run with --no-set-protection to silence this prompt.');
1549
+ return;
1550
+ }
1551
+ log(` Cannot enable (${result.reason}). You can enable it manually anytime.`);
1552
+ }
1553
+
1554
+ async function runList(_args) {
1555
+ const skillsDir = join(process.cwd(), '.claude', 'skills');
1556
+ const groups = await listInstalled(skillsDir);
1557
+ const total = groups.baseline.length + groups.remote.length + groups.custom.length;
1558
+ if (total === 0) {
1559
+ log('Empty collection. Run `clud-bug init` to open field season.');
1560
+ ok('list: 0 skills installed (run `clud-bug init` first)');
1561
+ return;
1562
+ }
1563
+ log(`🐛 ${total} specimen${total === 1 ? '' : 's'} pinned in .claude/skills/`);
1564
+ if (groups.baseline.length) {
1565
+ log('');
1566
+ log('Baseline (always pinned):');
1567
+ for (const s of groups.baseline) log(` • ${s.slug}`);
1568
+ }
1569
+ if (groups.remote.length) {
1570
+ log('');
1571
+ log('From skills.sh:');
1572
+ for (const s of groups.remote) log(` • ${s.slug} ${s.source ? `[${s.source}]` : ''}`);
1573
+ }
1574
+ if (groups.custom.length) {
1575
+ log('');
1576
+ log('Custom (your own — never auto-modified):');
1577
+ for (const s of groups.custom) {
1578
+ log(` • ${s.slug}${s.description ? ` — ${s.description}` : ''}`);
1579
+ }
1580
+ }
1581
+ ok(`list: ${total} skills (baseline=${groups.baseline.length}, remote=${groups.remote.length}, custom=${groups.custom.length})`);
1582
+ }
1583
+
1584
+ async function runAdd(args) {
1585
+ const ref = args._[1];
1586
+ if (!ref || !ref.includes('/')) {
1587
+ process.stderr.write('Usage: clud-bug add <source/name> (e.g. vercel-labs/skills/next-best-practices)\n');
1588
+ process.exit(2);
1589
+ }
1590
+ // Last segment is the skill name; everything before is the source repo path.
1591
+ const lastSlash = ref.lastIndexOf('/');
1592
+ const source = ref.slice(0, lastSlash);
1593
+ const name = ref.slice(lastSlash + 1);
1594
+ const skillsDir = join(process.cwd(), '.claude', 'skills');
1595
+ log(` fetching ${source}/${name} from skills.sh...`);
1596
+ const client = new SkillsClient();
1597
+ const entry = await writeSkill(skillsDir, { source, name, kind: 'remote' }, client);
1598
+ const manifest = await readManifest(skillsDir);
1599
+ // Mutate in place so caller-set fields on the manifest (pinVersion,
1600
+ // lastUpdate, lastUpdateVersion) survive the add. Building a fresh
1601
+ // {version, installed} object would silently drop them.
1602
+ manifest.installed = [...manifest.installed.filter((e) => e.slug !== entry.slug), entry];
1603
+ await writeManifest(skillsDir, manifest);
1604
+ log(` ✓ pinned ${entry.slug} → .claude/skills/${entry.slug}/SKILL.md`);
1605
+ log(' Commit + push to apply on the next PR.');
1606
+ ok(`added: .claude/skills/${entry.slug}/SKILL.md`);
1607
+ }
1608
+
1609
+ async function runRemove(args) {
1610
+ const slug = args._[1];
1611
+ if (!slug) {
1612
+ process.stderr.write('Usage: clud-bug remove <slug> (run `clud-bug list` to see installed slugs)\n');
1613
+ process.exit(2);
1614
+ }
1615
+ const skillsDir = join(process.cwd(), '.claude', 'skills');
1616
+ const entry = await removeSkill(skillsDir, slug);
1617
+ log(` ✓ unpinned ${entry.slug}${entry.kind === 'baseline' ? ' (baseline — returns on next init)' : ''}`);
1618
+ ok(`removed: ${entry.slug}${entry.kind === 'baseline' ? ' (baseline)' : ''}`);
1619
+ }
1620
+
1621
+ async function runRefresh(args) {
1622
+ const cwd = process.cwd();
1623
+ const skillsDir = join(cwd, '.claude', 'skills');
1624
+ const manifest = await readManifest(skillsDir);
1625
+ if (manifest.installed.length === 0) {
1626
+ log('No clud-bug-managed specimens found. Run `clud-bug init` first.');
1627
+ ok('refreshed: 0 skills installed (run `clud-bug init` first)');
1628
+ return;
1629
+ }
1630
+
1631
+ log(' re-surveying habitat...');
1632
+ const signals = await detect(cwd);
1633
+ log(` primary language: ${signals.primaryLanguage || '(unknown)'}`);
1634
+ log(` search terms: ${signals.searchTerms.join(', ') || '(none)'}`);
1635
+
1636
+ const baseline = await loadBaseline(BASELINE_DIR);
1637
+ let curated = [];
1638
+ let searched = [];
1639
+ if (args.offline) {
1640
+ log(' --offline: skipping skills.sh — only baseline additions will be diffed; existing remote skills are preserved');
1641
+ } else {
1642
+ const client = new SkillsClient();
1643
+ let curatedErr, searchedErr;
1644
+ [curated, searched] = await Promise.all([
1645
+ client.curated().catch(err => { curatedErr = err; return []; }),
1646
+ client.search(signals.searchTerms).catch(err => { searchedErr = err; return []; }),
1647
+ ]);
1648
+ if (curatedErr || searchedErr) {
1649
+ const err = curatedErr || searchedErr;
1650
+ warn(`skills.sh unreachable (${err.message})`);
1651
+ warn('refusing to compute removals — an empty API response would look like "delete everything from skills.sh".');
1652
+ warn('Try again later, or run with --offline to install only baseline updates.');
1653
+ process.exit(1);
1654
+ }
1655
+ }
1656
+ const recommended = rankAndCap(curated, searched, baseline);
1657
+ const diff = diffManifest(manifest, recommended);
1658
+
1659
+ // In --offline mode the recommendation set isn't authoritative (we only have
1660
+ // baseline locally), so any "missing from recommendations" entry is a false
1661
+ // positive. Suppress removals to avoid mass-deleting the user's remote skills.
1662
+ if (args.offline) diff.remove = [];
1663
+
1664
+ log('');
1665
+ log(` add: ${diff.add.length}`);
1666
+ log(` remove: ${diff.remove.length} (custom skills untouched)`);
1667
+ log(` unchanged: ${diff.unchanged.length}`);
1668
+
1669
+ if (diff.add.length === 0 && diff.remove.length === 0) {
1670
+ log('');
1671
+ log('Collection in sync with skills.sh — nothing to update.');
1672
+ ok(`refreshed: ${diff.unchanged.length} skills in sync, 0 changes`);
1673
+ return;
1674
+ }
1675
+
1676
+ log('');
1677
+ for (const s of diff.add) log(` + ${s.name} [${s.source || s.kind}]`);
1678
+ for (const s of diff.remove) log(` - ${s.slug} [${s.source || s.kind}]`);
1679
+
1680
+ if (!args.acceptAll) {
1681
+ const rl = createInterface({ input, output });
1682
+ const answer = await rl.question('\nApply these changes? [y/N] ');
1683
+ rl.close();
1684
+ if (answer.trim().toLowerCase() !== 'y') {
1685
+ log('Aborted. No files changed.');
1686
+ return;
1687
+ }
1688
+ }
1689
+
1690
+ const client = new SkillsClient();
1691
+ if (diff.add.length) await writeSkills(skillsDir, diff.add, client);
1692
+ for (const entry of diff.remove) await removeSkill(skillsDir, entry.slug);
1693
+ log(' ✓ collection updated. Commit + push to apply on the next PR.');
1694
+ ok(`refreshed: +${diff.add.length} -${diff.remove.length} (${diff.unchanged.length} unchanged)`);
1695
+ }
1696
+
1697
+ async function runEditWorkflow(_args) {
1698
+ const cwd = process.cwd();
1699
+
1700
+ // Validate: must have pending changes, all scoped to clud-bug workflow files.
1701
+ let pending;
1702
+ try {
1703
+ pending = getPendingWorkflowEdits(cwd);
1704
+ } catch (err) {
1705
+ process.stderr.write(`clud-bug edit-workflow: ${err.message}\n`);
1706
+ process.exit(2);
1707
+ }
1708
+
1709
+ if (pending.files.length === 0) {
1710
+ log('Nothing to commit. Edit your .github/workflows/clud-bug-*.yml file(s) first, then re-run.');
1711
+ ok('branch: (none — no pending workflow edits)');
1712
+ return;
1713
+ }
1714
+ if (!pending.allWorkflow) {
1715
+ process.stderr.write(`clud-bug edit-workflow: working tree contains non-workflow changes:\n`);
1716
+ for (const f of pending.nonWorkflow) process.stderr.write(` ${f}\n`);
1717
+ process.stderr.write(`\nThis command is for isolated workflow-only PRs. Stash or commit the\nnon-workflow changes elsewhere first, then re-run.\n`);
1718
+ process.exit(2);
1719
+ }
1720
+
1721
+ log('🐛 Preparing an isolated PR for your workflow edit.');
1722
+ const branch = makeBranchName();
1723
+ log(` branch: ${branch} (rooted at origin/main)`);
1724
+ for (const f of pending.files) log(` • ${f}`);
1725
+
1726
+ // Stash the pending workflow changes, branch from origin/main explicitly
1727
+ // (NOT from HEAD — if the user is on a feature branch with unrelated
1728
+ // commits, those would otherwise leak into the "isolated" PR), then
1729
+ // restore the changes onto the new branch and commit.
1730
+ gitCmd(cwd, ['stash', 'push', '--include-untracked', '-m', 'clud-bug edit-workflow']);
1731
+ try {
1732
+ gitCmd(cwd, ['fetch', 'origin', 'main', '--depth=1']);
1733
+ gitCmd(cwd, ['checkout', '-b', branch, 'origin/main']);
1734
+ } catch (err) {
1735
+ // Restore the user's stash before bubbling up.
1736
+ gitCmd(cwd, ['stash', 'pop'], { allowFail: true });
1737
+ throw err;
1738
+ }
1739
+ const popped = gitCmd(cwd, ['stash', 'pop'], { allowFail: true });
1740
+ if (!popped.ok) {
1741
+ 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`);
1742
+ process.exit(1);
1743
+ }
1744
+ gitCmd(cwd, ['add', ...pending.files]);
1745
+ gitCmd(cwd, ['commit', '-m', 'Edit clud-bug workflow']);
1746
+ gitCmd(cwd, ['push', '-u', 'origin', branch]);
1747
+
1748
+ log('');
1749
+ log('Done. Open the PR:');
1750
+ 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."`);
1751
+ ok(`branch: ${branch} (${pending.files.length} file${pending.files.length === 1 ? '' : 's'})`);
1752
+ }
1753
+
1754
+ async function runUpdateCmd(_args) {
1755
+ const cwd = process.cwd();
1756
+ const ourVersion = await readPkgVersion();
1757
+ log(`🐛 Refreshing the field kit (${ourVersion}).`);
1758
+
1759
+ const result = await runUpdate({
1760
+ cwd,
1761
+ templatesDir: TEMPLATES,
1762
+ baselineDir: BASELINE_DIR,
1763
+ ourVersion,
1764
+ });
1765
+
1766
+ if (result.missing === 'init') {
1767
+ log(' No clud-bug installation detected. Run `clud-bug init` first.');
1768
+ ok('updated: 0 changes (no clud-bug install detected)');
1769
+ return;
1770
+ }
1771
+
1772
+ const skipped = result.skipped ?? [];
1773
+
1774
+ if (result.changed.length === 0 && skipped.length === 0) {
1775
+ log(' Already current. Nothing to update.');
1776
+ ok(`updated: @v${ourVersion}, 0 changes`);
1777
+ return;
1778
+ }
1779
+
1780
+ if (result.changed.length > 0) {
1781
+ log(` ✓ Updated ${result.changed.length} file${result.changed.length === 1 ? '' : 's'}:`);
1782
+ for (const c of result.changed) {
1783
+ const versionNote = c.from && c.to && c.from !== c.to ? ` (${c.label}, ${c.from} → ${c.to})` : ` (${c.label})`;
1784
+ log(` • ${rel(cwd, c.path)}${versionNote}`);
1785
+ }
1786
+ }
1787
+ if (result.unchanged.length > 0) {
1788
+ log(` ${result.unchanged.length} file${result.unchanged.length === 1 ? ' was' : 's were'} already current.`);
1789
+ }
1790
+ if (skipped.length > 0) {
1791
+ log('');
1792
+ log(` ! Skipped ${skipped.length} markerless file${skipped.length === 1 ? '' : 's'} (treated as user-customized):`);
1793
+ for (const s of skipped) log(` • ${rel(cwd, s.path)} — ${s.reason}`);
1794
+ }
1795
+ log('');
1796
+ log('Commit + push to apply the refreshed kit on the next PR.');
1797
+ ok(`updated: @v${ourVersion}, ${result.changed.length} changed, ${result.unchanged.length} unchanged${skipped.length ? `, ${skipped.length} skipped` : ''}`);
1798
+ }
1799
+
1800
+ // v0.7.0-rc.4 — `clud-bug configure-github <owner>/<repo>`. Pulls in the
1801
+ // SPEC §7 canonical ruleset applier from src/cli/configure-github.ts;
1802
+ // thin wrapper here just maps CLI args into the typed entry point. The
1803
+ // command is idempotent — re-runs on a converged repo exit 0 with no
1804
+ // PATCH calls. See `src/core/configure-github.ts` for the diff + rule
1805
+ // table.
1806
+ async function runConfigureGithubCmd(args) {
1807
+ const { runConfigureGithub } = await import('./configure-github.js');
1808
+ const target = args._[1] ?? null;
1809
+ const code = await runConfigureGithub({
1810
+ target,
1811
+ branch: args.branch || 'main',
1812
+ dryRun: Boolean(args.dryRun),
1813
+ quiet: QUIET,
1814
+ json: Boolean(args.json),
1815
+ });
1816
+ if (code !== 0) process.exit(code);
1817
+ }
1818
+
1819
+ async function runAudit(args) {
1820
+ const cwd = process.cwd();
1821
+ const date = new Date().toISOString().slice(0, 10);
1822
+
1823
+ let scopeLabel;
1824
+ if (args.since) scopeLabel = `commits since ${args.since}`;
1825
+ else if (args.changedIn) scopeLabel = `files changed in the past ${args.changedIn}`;
1826
+ else if (args.scopes.length) scopeLabel = `glob ${args.scopes.join(', ')}`;
1827
+ else scopeLabel = 'all tracked files';
1828
+
1829
+ log(`🐛 Audit walk in ${cwd}.`);
1830
+ log(` scope: ${scopeLabel}`);
1831
+
1832
+ let files;
1833
+ try {
1834
+ files = computeAuditFileSet({
1835
+ cwd,
1836
+ since: args.since,
1837
+ changedIn: args.changedIn,
1838
+ scopes: args.scopes,
1839
+ });
1840
+ } catch (err) {
1841
+ process.stderr.write(`clud-bug audit: ${err.message}\n`);
1842
+ process.exit(2);
1843
+ }
1844
+ log(` surveyed: ${files.length} file${files.length === 1 ? '' : 's'}`);
1845
+
1846
+ if (files.length === 0) {
1847
+ log(' Nothing in scope. Try widening --scope or --changed-in.');
1848
+ ok(`audit: 0 files in scope`);
1849
+ return;
1850
+ }
1851
+
1852
+ const outPath = args.out || join(cwd, 'audits', `${date}.md`);
1853
+ await mkdir(dirname(outPath), { recursive: true });
1854
+ await writeFile(outPath, renderAuditHeader({ date, scopeLabel, files }));
1855
+ log(` ✓ wrote stub: ${rel(cwd, outPath)}`);
1856
+ log('');
1857
+ log('Stub is empty findings — populated by the GitHub Action.');
1858
+ log('Run locally without the workflow if you want — Clud Bug review needs the action runner + ANTHROPIC_API_KEY.');
1859
+ ok(`audit: ${files.length} file${files.length === 1 ? '' : 's'} surveyed; stub at ${rel(cwd, outPath)}`);
1860
+ }
1861
+
1862
+ // 0.0.M.1 (v0.6.13): Q7-clud-bug $/LOC dashboard.
1863
+ //
1864
+ // Reads recent clud-bug-review run JSON via `gh run list` + per-job logs
1865
+ // (which contain the SDK result messages with token counts + model),
1866
+ // joins to `gh pr view --json additions,deletions` for the LOC denominator,
1867
+ // and reports the rollup. Internal-only — not consumer-facing.
1868
+ //
1869
+ // Default scope: 30 days, all repos with clud-bug-review.yml in the gh
1870
+ // user's auth scope. --repo / --pr / --since / --limit narrow.
1871
+ async function runUsage(args) {
1872
+ // v0.6.28 — `clud-bug usage --health`: deterministic skill-health
1873
+ // dashboard. Reads `.claude/skills/.clud-bug.json` usage block,
1874
+ // applies thresholds, renders read-only table. No automation acts
1875
+ // on the output. Per the pragmatic SkDD pivot (2026-05-30).
1876
+ if (args.health) {
1877
+ return runUsageHealth(args);
1878
+ }
1879
+
1880
+ const limit = args.limit ?? 50;
1881
+ const since = args.since ?? '30d';
1882
+
1883
+ // Determine target repos. If --repo specified, just that one. Otherwise
1884
+ // discover repos via the local gh user's auth scope (the org's repos we
1885
+ // own clud-bug-review on).
1886
+ const repos = args.repo
1887
+ ? [args.repo]
1888
+ : await discoverConsumingRepos();
1889
+
1890
+ if (repos.length === 0) {
1891
+ process.stderr.write(
1892
+ 'clud-bug usage: no repos with clud-bug-review.yml found in your gh scope.\n' +
1893
+ 'Pass --repo <owner/name> to point at a specific repo.\n'
1894
+ );
1895
+ process.exit(2);
1896
+ }
1897
+
1898
+ // Per-repo: list recent clud-bug-review runs + extract the per-run job
1899
+ // logs + per-PR LOC counts. Filter to PR runs (drop schedule/dispatch).
1900
+ // PR #104 fix: --pr filter must be applied AFTER resolvePrNumber
1901
+ // (we don't have the PR # until then). prFilter on listRecentRuns was
1902
+ // promised but never applied — bug caught by clud-bug self-review.
1903
+ const reviews = [];
1904
+ for (const repo of repos) {
1905
+ const runs = await listRecentRuns(repo, limit, since, args.pr);
1906
+ if (process.env.CLUD_BUG_DEBUG) process.stderr.write(`DBG: ${repo} runs=${runs.length}\n`);
1907
+ for (const run of runs) {
1908
+ const review = await fetchReviewRecord(repo, run);
1909
+ if (process.env.CLUD_BUG_DEBUG) process.stderr.write(`DBG: ${run.databaseId} ${run.conclusion} → ${review ? 'OK' : 'NULL'}\n`);
1910
+ if (!review) continue;
1911
+ // --pr filter: drop reviews whose PR doesn't match.
1912
+ if (args.pr != null && review.pr !== args.pr) continue;
1913
+ reviews.push(review);
1914
+ }
1915
+ }
1916
+
1917
+ if (reviews.length === 0) {
1918
+ process.stderr.write(
1919
+ `clud-bug usage: no clud-bug-review runs found in scope.\n` +
1920
+ ` scope: ${repos.length} repo${repos.length === 1 ? '' : 's'}, last ${since}, limit ${limit}.\n`
1921
+ );
1922
+ process.exit(2);
1923
+ }
1924
+
1925
+ const summary = rollup(reviews);
1926
+ process.stdout.write(formatRollup(summary, { json: args.json }));
1927
+ if (!args.json) {
1928
+ ok(`usage: ${reviews.length} review${reviews.length === 1 ? '' : 's'} across ${repos.length} repo${repos.length === 1 ? '' : 's'}`);
1929
+ }
1930
+ }
1931
+
1932
+ // `gh repo list` won't filter by workflow file content, so we iterate
1933
+ // repos the user has access to and probe for clud-bug-review.yml. We
1934
+ // v0.6.28 — `clud-bug usage --health` implementation. Reads the local
1935
+ // .claude/skills/.clud-bug.json usage block, applies deterministic
1936
+ // thresholds, renders a read-only dashboard. No I/O beyond the JSON
1937
+ // read.
1938
+ //
1939
+ // v0.6.30 — read accumulated usage from workflow artifacts (uploaded
1940
+ // by v0.6.29's post-step). Defaults to artifact mode when --repo is
1941
+ // passed OR an `owner/name` can be inferred from `git remote`. Falls
1942
+ // back to the local-file path otherwise. The `--no-artifacts` flag
1943
+ // forces the v0.6.28 local-only behavior (handy for tests + offline).
1944
+ async function runUsageHealth(args) {
1945
+ const { assessSkillHealth, formatHealthDashboard } = await import('./skill-usage.js');
1946
+
1947
+ // Decide read source. Priority: explicit --no-artifacts → local;
1948
+ // explicit --repo OR inferred owner/repo → artifacts; else local.
1949
+ const wantArtifacts = args.artifacts !== false;
1950
+ let ownerRepo = null;
1951
+ if (wantArtifacts) {
1952
+ ownerRepo = args.repo || await inferOwnerRepoFromGit();
1953
+ }
1954
+
1955
+ let usage;
1956
+ let source;
1957
+ if (wantArtifacts && ownerRepo) {
1958
+ const result = await loadUsageFromArtifacts(ownerRepo, args);
1959
+ if (result) {
1960
+ usage = result.usage;
1961
+ source = `${result.artifactCount} artifact${result.artifactCount === 1 ? '' : 's'} from ${ownerRepo}`;
1962
+ }
1963
+ }
1964
+
1965
+ // Fallback to local .clud-bug.json (v0.6.28 behavior).
1966
+ if (usage == null) {
1967
+ const localResult = await loadUsageFromLocalFile();
1968
+ if (localResult == null) {
1969
+ // Both paths failed. The local helper has already written its
1970
+ // own stderr explanation; we just exit.
1971
+ process.exit(1);
1972
+ }
1973
+ usage = localResult;
1974
+ source = `local .clud-bug.json`;
1975
+ }
1976
+
1977
+ const rows = assessSkillHealth(usage, new Date());
1978
+ process.stdout.write(formatHealthDashboard(rows) + '\n');
1979
+
1980
+ // Exit code semantics: 0 (informational). The dashboard is read-only;
1981
+ // archive-candidates being present is NOT a failure mode — humans
1982
+ // decide. CI gates should NOT block on this.
1983
+ ok(`skill health: ${rows.length} skill${rows.length === 1 ? '' : 's'} tracked (source: ${source})`);
1984
+ }
1985
+
1986
+ // Helpers split out from runUsageHealth so the two read paths are
1987
+ // independently testable + composable in future commands.
1988
+
1989
+ async function loadUsageFromArtifacts(ownerRepo, args) {
1990
+ const { fetchUsageArtifacts, aggregateUsageStream } = await import('./skill-usage.js');
1991
+ const [owner, repo] = ownerRepo.split('/');
1992
+ if (!owner || !repo) {
1993
+ process.stderr.write(`clud-bug usage --health: --repo must be in owner/name form, got "${ownerRepo}".\n`);
1994
+ return null;
1995
+ }
1996
+ const since = parseSinceArg(args.since);
1997
+ let artifacts;
1998
+ try {
1999
+ artifacts = await fetchUsageArtifacts({ owner, repo, since });
2000
+ } catch (err) {
2001
+ process.stderr.write(`::notice::clud-bug usage --health: artifact fetch failed (${err.message}) — falling back to local .clud-bug.json\n`);
2002
+ return null;
2003
+ }
2004
+ if (artifacts.length === 0) {
2005
+ process.stderr.write(`::notice::clud-bug usage --health: no skill-usage artifacts found in ${ownerRepo} — falling back to local .clud-bug.json\n`);
2006
+ return null;
2007
+ }
2008
+ return {
2009
+ usage: aggregateUsageStream(artifacts),
2010
+ artifactCount: artifacts.length,
2011
+ };
2012
+ }
2013
+
2014
+ async function loadUsageFromLocalFile() {
2015
+ const fs = await import('node:fs/promises');
2016
+ const path = await import('node:path');
2017
+ const jsonPath = path.resolve(process.cwd(), '.claude', 'skills', '.clud-bug.json');
2018
+ try {
2019
+ const raw = await fs.readFile(jsonPath, 'utf-8');
2020
+ const parsed = JSON.parse(raw);
2021
+ return (parsed && parsed.usage) ? parsed.usage : {};
2022
+ } catch (err) {
2023
+ if (err.code === 'ENOENT') {
2024
+ process.stderr.write(
2025
+ `clud-bug usage --health: no .claude/skills/.clud-bug.json found in ${process.cwd()}.\n` +
2026
+ `Run \`npx clud-bug init\` first OR pass --repo owner/name to read from workflow artifacts.\n`
2027
+ );
2028
+ return null;
2029
+ }
2030
+ process.stderr.write(`clud-bug usage --health: failed to parse .clud-bug.json: ${err.message}\n`);
2031
+ return null;
2032
+ }
2033
+ }
2034
+
2035
+ async function inferOwnerRepoFromGit() {
2036
+ // `gh repo view --json nameWithOwner` reads the current dir's git
2037
+ // remote AND respects gh's config. Returns null on non-git dirs.
2038
+ const result = await ghJson(['repo', 'view', '--json', 'nameWithOwner']);
2039
+ return result && result.nameWithOwner ? result.nameWithOwner : null;
2040
+ }
2041
+
2042
+ function parseSinceArg(since) {
2043
+ if (!since) return null;
2044
+ if (since instanceof Date) return since;
2045
+ const m = String(since).match(/^(\d+)([dwmy])$/);
2046
+ if (!m) return null;
2047
+ const n = Number(m[1]);
2048
+ const unitMs = { d: 86400e3, w: 7 * 86400e3, m: 30 * 86400e3, y: 365 * 86400e3 }[m[2]];
2049
+ return new Date(Date.now() - n * unitMs);
2050
+ }
2051
+
2052
+ // limit to 100 to avoid pagination explosions.
2053
+ async function discoverConsumingRepos() {
2054
+ const list = await ghJson(['repo', 'list', '--limit', '100', '--json', 'nameWithOwner']);
2055
+ if (!Array.isArray(list)) return [];
2056
+ const owners = list.map((e) => e.nameWithOwner);
2057
+ const found = [];
2058
+ for (const ownerRepo of owners) {
2059
+ const probe = await gh(['api', `repos/${ownerRepo}/contents/.github/workflows/clud-bug-review.yml`, '-q', '.size']);
2060
+ if (probe.code === 0 && probe.stdout.trim().length > 0) {
2061
+ found.push(ownerRepo);
2062
+ }
2063
+ }
2064
+ return found;
2065
+ }
2066
+
2067
+ // List recent clud-bug-review.yml runs in a repo. Filters to PR events
2068
+ // (drops schedule, workflow_dispatch — those have no PR LOC denominator).
2069
+ //
2070
+ // IMPORTANT (Q7 measurement integrity, fixed during PR #104 review):
2071
+ // We INCLUDE conclusion === 'failure' runs because Anthropic bills for
2072
+ // tokens regardless of GitHub workflow conclusion. A run that hit the
2073
+ // spend cap, errored mid-action, or failed strict-mode still incurred
2074
+ // real API cost — silently excluding it would underreport spend and
2075
+ // fool the Q7-clud-bug "gradient must point down" gate.
2076
+ // extractTokensFromLog() returns ok:false on logs without usable token
2077
+ // totals, which gracefully skips the cancelled/errored-too-early case
2078
+ // without losing accountability for the partially-billed runs.
2079
+ async function listRecentRuns(repo, limit, since, prFilter) {
2080
+ const sinceDate = since.match(/^\d+[dwmy]$/) ? dateAgo(since) : null;
2081
+ const args = [
2082
+ 'run', 'list', '-R', repo,
2083
+ '--workflow', 'clud-bug-review.yml',
2084
+ '--limit', String(limit),
2085
+ '--json', 'databaseId,headSha,createdAt,event,status,conclusion',
2086
+ ];
2087
+ if (sinceDate) args.push('--created', `>=${sinceDate}`);
2088
+ const runs = await ghJson(args);
2089
+ if (!Array.isArray(runs)) return [];
2090
+ return runs
2091
+ .filter((r) => r.event === 'pull_request' && (r.conclusion === 'success' || r.conclusion === 'failure'))
2092
+ .map((r) => ({ ...r, repo }))
2093
+ .slice(0, limit);
2094
+ }
2095
+
2096
+ async function fetchReviewRecord(repo, run) {
2097
+ // Find the clud-bug-review JOB id within the run.
2098
+ const jobs = await ghJson(['api', `repos/${repo}/actions/runs/${run.databaseId}/jobs`, '-q', '.jobs']);
2099
+ if (!Array.isArray(jobs)) return null;
2100
+ const job = jobs.find((j) => j.name === 'clud-bug-review');
2101
+ if (!job) return null;
2102
+
2103
+ // Fetch the job's log dump. May be large.
2104
+ const logs = await gh(['api', `repos/${repo}/actions/jobs/${job.id}/logs`]);
2105
+ if (logs.code !== 0) return null;
2106
+
2107
+ // Extract tokens + model from the SDK result-message JSON in the log.
2108
+ const extracted = extractTokensFromLog(logs.stdout);
2109
+ if (!extracted.ok) return null;
2110
+
2111
+ // Resolve the PR number from the run's pull_requests array or by SHA.
2112
+ const prNumber = await resolvePrNumber(repo, run);
2113
+ if (!prNumber) return null;
2114
+
2115
+ // Pull LOC denominator from the PR.
2116
+ const prMeta = await ghJson(['pr', 'view', String(prNumber), '-R', repo, '--json', 'additions,deletions,number']);
2117
+ if (!prMeta || typeof prMeta.additions !== 'number') return null;
2118
+
2119
+ const tokens = extracted.tokens;
2120
+ const model = extracted.model;
2121
+ const costInfo = computeReviewCost(tokens, model);
2122
+ return {
2123
+ repo,
2124
+ pr: prNumber,
2125
+ createdAt: run.createdAt,
2126
+ model: costInfo.model, // normalized (PRICING key)
2127
+ modelObserved: model, // raw value from log (may be versioned)
2128
+ unknownModel: costInfo.unknownModel, // PR #104 fix: surface for dashboard warn
2129
+ tokens,
2130
+ additions: prMeta.additions,
2131
+ deletions: prMeta.deletions,
2132
+ cost: costInfo.total,
2133
+ costPerLOC: costPerLOC(costInfo.total, prMeta.additions, prMeta.deletions),
2134
+ cacheRate: cacheHitRate(tokens),
2135
+ };
2136
+ }
2137
+
2138
+ async function resolvePrNumber(repo, run) {
2139
+ // gh's run JSON sometimes carries a `pull_requests` array; if not (or
2140
+ // if it's empty because the PR has been merged), look up via the
2141
+ // commits/{sha}/pulls endpoint, which includes merged/closed PRs.
2142
+ const detail = await ghJson(['api', `repos/${repo}/actions/runs/${run.databaseId}`, '-q', '.pull_requests']);
2143
+ if (Array.isArray(detail) && detail[0]?.number) return detail[0].number;
2144
+ // commits/{sha}/pulls returns PRs that contain the commit — works for
2145
+ // open AND merged/closed PRs. The default `gh pr list -S <sha>` does
2146
+ // not search closed PRs and silently returns empty for the merged
2147
+ // case, which made every $/LOC lookup fail on historical PRs.
2148
+ const pulls = await ghJson(['api', `repos/${repo}/commits/${run.headSha}/pulls`, '-q', '[.[].number]']);
2149
+ if (Array.isArray(pulls) && pulls.length > 0) return pulls[0];
2150
+ return null;
2151
+ }
2152
+
2153
+ function dateAgo(spec) {
2154
+ // spec like "30d", "2w", "1m", "1y" → ISO date N units ago.
2155
+ const m = spec.match(/^(\d+)([dwmy])$/);
2156
+ if (!m) return null;
2157
+ const n = Number(m[1]);
2158
+ const unit = m[2];
2159
+ const day = 24 * 60 * 60 * 1000;
2160
+ const ms = n * (unit === 'd' ? day : unit === 'w' ? 7 * day : unit === 'm' ? 30 * day : 365 * day);
2161
+ return new Date(Date.now() - ms).toISOString().slice(0, 10);
2162
+ }
2163
+
2164
+ // gh helpers (reuse pattern from lib/branch-protection.js so callers can
2165
+ // stub `gh` in tests if they want — but for now spawn directly).
2166
+ function gh(args) {
2167
+ return new Promise((resolve) => {
2168
+ const child = spawn('gh', args, { stdio: ['ignore', 'pipe', 'pipe'] });
2169
+ let stdout = '';
2170
+ let stderr = '';
2171
+ child.stdout.on('data', (d) => { stdout += d; });
2172
+ child.stderr.on('data', (d) => { stderr += d; });
2173
+ child.on('error', () => resolve({ code: 1, stdout: '', stderr: 'gh not on PATH' }));
2174
+ child.on('close', (code) => resolve({ code, stdout, stderr }));
2175
+ });
2176
+ }
2177
+
2178
+ async function ghJson(args) {
2179
+ const { code, stdout } = await gh(args);
2180
+ if (code !== 0) return null;
2181
+ try { return JSON.parse(stdout); } catch { return null; }
2182
+ }
2183
+
2184
+ function rel(from, to) {
2185
+ return to.startsWith(from + '/') ? to.slice(from.length + 1) : to;
2186
+ }
2187
+
2188
+ // Quiet-mode mechanism (v0.6.7+):
2189
+ // - Default: log() emits progress to stdout (today's behavior).
2190
+ // - When CLUD_BUG_QUIET=1 OR --quiet/-q is passed: log() is suppressed.
2191
+ // ok() ALWAYS emits its single-line summary so agents get positive
2192
+ // confirmation with a chainable key-value (commit SHA, file count,
2193
+ // branch name) regardless of quiet state.
2194
+ // - warn() / die() emit unconditionally — quiet must not silence real
2195
+ // problems.
2196
+ let QUIET = process.env.CLUD_BUG_QUIET === '1';
2197
+ function setQuiet(flag) { QUIET = !!flag; }
2198
+ function log(msg) { if (!QUIET) process.stdout.write(msg + '\n'); }
2199
+ function ok(msg) { process.stdout.write('ok ' + msg + '\n'); }
2200
+ function warn(msg) { process.stderr.write(` ! ${msg}\n`); }
2201
+
2202
+ // Export `main()` so the entry-point shim at bin/clud-bug.js can drive
2203
+ // the dispatch. The shim wraps the catch + process.exit error path.
2204
+ export { main };