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