clud-bug 0.7.0-rc.2 → 0.7.0-rc.21

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 (147) hide show
  1. package/README.md +20 -0
  2. package/data/canonical-v1.json +37 -0
  3. package/dist/cli/configure-github.d.ts +58 -0
  4. package/dist/cli/configure-github.d.ts.map +1 -0
  5. package/dist/cli/configure-github.js +242 -0
  6. package/dist/cli/configure-github.js.map +1 -0
  7. package/dist/cli/hooks.d.ts +51 -0
  8. package/dist/cli/hooks.d.ts.map +1 -0
  9. package/dist/cli/hooks.js +138 -0
  10. package/dist/cli/hooks.js.map +1 -0
  11. package/dist/cli/index.d.ts +1 -0
  12. package/dist/cli/index.d.ts.map +1 -1
  13. package/dist/cli/index.js +1 -0
  14. package/dist/cli/index.js.map +1 -1
  15. package/dist/cli/main.d.ts.map +1 -1
  16. package/dist/cli/main.js +949 -35
  17. package/dist/cli/main.js.map +1 -1
  18. package/dist/cli/post-check-run.d.ts +16 -0
  19. package/dist/cli/post-check-run.d.ts.map +1 -0
  20. package/dist/cli/post-check-run.js +93 -0
  21. package/dist/cli/post-check-run.js.map +1 -0
  22. package/dist/cli/review-prompt.d.ts +43 -0
  23. package/dist/cli/review-prompt.d.ts.map +1 -0
  24. package/dist/cli/review-prompt.js +287 -0
  25. package/dist/cli/review-prompt.js.map +1 -0
  26. package/dist/cli/skills.d.ts +7 -0
  27. package/dist/cli/skills.d.ts.map +1 -1
  28. package/dist/cli/skills.js +42 -2
  29. package/dist/cli/skills.js.map +1 -1
  30. package/dist/cli/update-notifier.d.ts +17 -0
  31. package/dist/cli/update-notifier.d.ts.map +1 -0
  32. package/dist/cli/update-notifier.js +88 -0
  33. package/dist/cli/update-notifier.js.map +1 -0
  34. package/dist/cli/update.d.ts.map +1 -1
  35. package/dist/cli/update.js +60 -11
  36. package/dist/cli/update.js.map +1 -1
  37. package/dist/core/auto-resolve.d.ts +235 -0
  38. package/dist/core/auto-resolve.d.ts.map +1 -0
  39. package/dist/core/auto-resolve.js +293 -0
  40. package/dist/core/auto-resolve.js.map +1 -0
  41. package/dist/core/budget-plan.d.ts +125 -0
  42. package/dist/core/budget-plan.d.ts.map +1 -0
  43. package/dist/core/budget-plan.js +211 -0
  44. package/dist/core/budget-plan.js.map +1 -0
  45. package/dist/core/check-verdict.d.ts +31 -0
  46. package/dist/core/check-verdict.d.ts.map +1 -0
  47. package/dist/core/check-verdict.js +66 -0
  48. package/dist/core/check-verdict.js.map +1 -0
  49. package/dist/core/configure-github.d.ts +179 -0
  50. package/dist/core/configure-github.d.ts.map +1 -0
  51. package/dist/core/configure-github.js +289 -0
  52. package/dist/core/configure-github.js.map +1 -0
  53. package/dist/core/design.d.ts +37 -0
  54. package/dist/core/design.d.ts.map +1 -0
  55. package/dist/core/design.js +47 -0
  56. package/dist/core/design.js.map +1 -0
  57. package/dist/core/diff-findings.d.ts +96 -0
  58. package/dist/core/diff-findings.d.ts.map +1 -0
  59. package/dist/core/diff-findings.js +261 -0
  60. package/dist/core/diff-findings.js.map +1 -0
  61. package/dist/core/formal-review.d.ts +79 -0
  62. package/dist/core/formal-review.d.ts.map +1 -0
  63. package/dist/core/formal-review.js +71 -0
  64. package/dist/core/formal-review.js.map +1 -0
  65. package/dist/core/index.d.ts +15 -2
  66. package/dist/core/index.d.ts.map +1 -1
  67. package/dist/core/index.js +53 -1
  68. package/dist/core/index.js.map +1 -1
  69. package/dist/core/inline-threads.d.ts +160 -0
  70. package/dist/core/inline-threads.d.ts.map +1 -0
  71. package/dist/core/inline-threads.js +369 -0
  72. package/dist/core/inline-threads.js.map +1 -0
  73. package/dist/core/multi-pass-aggregate.d.ts +161 -0
  74. package/dist/core/multi-pass-aggregate.d.ts.map +1 -0
  75. package/dist/core/multi-pass-aggregate.js +457 -0
  76. package/dist/core/multi-pass-aggregate.js.map +1 -0
  77. package/dist/core/plan-review.d.ts +57 -0
  78. package/dist/core/plan-review.d.ts.map +1 -0
  79. package/dist/core/plan-review.js +77 -0
  80. package/dist/core/plan-review.js.map +1 -0
  81. package/dist/core/prompt-builder.d.ts +18 -0
  82. package/dist/core/prompt-builder.d.ts.map +1 -1
  83. package/dist/core/prompt-builder.js +24 -2
  84. package/dist/core/prompt-builder.js.map +1 -1
  85. package/dist/core/render.d.ts.map +1 -1
  86. package/dist/core/render.js +1 -19
  87. package/dist/core/render.js.map +1 -1
  88. package/dist/core/resolve-verifier.d.ts +53 -0
  89. package/dist/core/resolve-verifier.d.ts.map +1 -0
  90. package/dist/core/resolve-verifier.js +204 -0
  91. package/dist/core/resolve-verifier.js.map +1 -0
  92. package/dist/core/review-context.d.ts +39 -0
  93. package/dist/core/review-context.d.ts.map +1 -0
  94. package/dist/core/review-context.js +99 -0
  95. package/dist/core/review-context.js.map +1 -0
  96. package/dist/core/review-plan.d.ts +219 -0
  97. package/dist/core/review-plan.d.ts.map +1 -0
  98. package/dist/core/review-plan.js +274 -0
  99. package/dist/core/review-plan.js.map +1 -0
  100. package/dist/core/review-writeback.d.ts +74 -0
  101. package/dist/core/review-writeback.d.ts.map +1 -1
  102. package/dist/core/review-writeback.js +71 -4
  103. package/dist/core/review-writeback.js.map +1 -1
  104. package/dist/core/skills.d.ts +25 -0
  105. package/dist/core/skills.d.ts.map +1 -1
  106. package/dist/core/skills.js +100 -4
  107. package/dist/core/skills.js.map +1 -1
  108. package/dist/core/version.d.ts +2 -0
  109. package/dist/core/version.d.ts.map +1 -0
  110. package/dist/core/version.js +4 -0
  111. package/dist/core/version.js.map +1 -0
  112. package/package.json +11 -5
  113. package/src/cli/configure-github.ts +329 -0
  114. package/src/cli/hooks.ts +155 -0
  115. package/src/cli/index.ts +6 -0
  116. package/src/cli/main.ts +1031 -37
  117. package/src/cli/post-check-run.ts +115 -0
  118. package/src/cli/review-prompt.ts +355 -0
  119. package/src/cli/skills.ts +42 -2
  120. package/src/cli/update-notifier.ts +100 -0
  121. package/src/cli/update.ts +60 -11
  122. package/src/core/auto-resolve.ts +488 -0
  123. package/src/core/budget-plan.ts +324 -0
  124. package/src/core/check-verdict.ts +93 -0
  125. package/src/core/configure-github.ts +497 -0
  126. package/src/core/design.ts +73 -0
  127. package/src/core/diff-findings.ts +323 -0
  128. package/src/core/formal-review.ts +168 -0
  129. package/src/core/index.ts +188 -0
  130. package/src/core/inline-threads.ts +471 -0
  131. package/src/core/multi-pass-aggregate.ts +591 -0
  132. package/src/core/plan-review.ts +136 -0
  133. package/src/core/prompt-builder.ts +44 -2
  134. package/src/core/render.ts +10 -16
  135. package/src/core/resolve-verifier.ts +254 -0
  136. package/src/core/review-context.ts +106 -0
  137. package/src/core/review-plan.ts +485 -0
  138. package/src/core/review-writeback.ts +161 -4
  139. package/src/core/skills.ts +128 -5
  140. package/src/core/version.ts +3 -0
  141. package/templates/clud-bug-review.md.tmpl +115 -0
  142. package/templates/skills/design/design-system-consistency.md +41 -0
  143. package/templates/skills/design/frontend-a11y.md +41 -0
  144. package/templates/skills/design/visual-polish.md +41 -0
  145. package/templates/workflow-py.yml.tmpl +92 -2
  146. package/templates/workflow-ts.yml.tmpl +92 -2
  147. package/templates/workflow.yml.tmpl +173 -2
package/src/cli/main.ts CHANGED
@@ -25,12 +25,14 @@ import { renderFile, pickTemplate, templateLanguage } from '../core/render.js';
25
25
  import { reviewPrompt } from '../core/prompts.js';
26
26
  import { SkillsClient, rankAndCap } from '../core/skills.js';
27
27
  import {
28
- writeSkills, writeSkill, loadBaseline,
28
+ writeSkills, writeSkill, loadBaseline, loadDesignKit,
29
29
  readManifest, writeManifest, removeSkill, listInstalled, diffManifest,
30
30
  } from './skills.js';
31
31
  import { computeAuditFileSet } from './audit.js';
32
32
  import { renderAuditHeader } from '../core/audit.js';
33
33
  import { runUpdate } from './update.js';
34
+ import { runReviewPrompt } from './review-prompt.js';
35
+ import { runPostCheckRun } from './post-check-run.js';
34
36
  import { getPendingWorkflowEdits, makeBranchName, git as gitCmd } from './edit-workflow.js';
35
37
  import { applyToRepo as applyAgentDocs } from './agents-md.js';
36
38
  import { detectRepo, detectDefaultBranch, getProtectionState, enableConversationResolution } from './branch-protection.js';
@@ -43,6 +45,7 @@ import { computeReviewCost, costPerLOC, cacheHitRate, extractTokensFromLog, roll
43
45
  const PKG_ROOT = dirname(dirname(dirname(fileURLToPath(import.meta.url))));
44
46
  const TEMPLATES = join(PKG_ROOT, 'templates');
45
47
  const BASELINE_DIR = join(TEMPLATES, 'skills', 'baseline');
48
+ const DESIGN_DIR = join(TEMPLATES, 'skills', 'design');
46
49
 
47
50
  function parseArgs(argv) {
48
51
  const args = {
@@ -62,6 +65,25 @@ function parseArgs(argv) {
62
65
  // users get the same one-command bootstrap as Python-first users
63
66
  // (logmind v0.6.8's --with-skdd is the symmetric counterpart).
64
67
  withSkdd: false,
68
+ // v0.7.0 (Wave 6b): `clud-bug init --with-local-review` also scaffolds
69
+ // .claude/commands/clud-bug-review.md so `/clud-bug-review` works in a
70
+ // Claude Code session (reviews the current PR with the session's tokens).
71
+ withLocalReview: false,
72
+ withHooks: false,
73
+ // rc.16: `clud-bug init --with-design` installs the design-critic kit
74
+ // (3 `kind: design` skills) and flips the off-by-default `design` block to
75
+ // enabled so the visual review lens runs (local recipe + hosted bot).
76
+ withDesign: false,
77
+ // rc.18: `clud-bug init --local-only` installs MAX MODE (skills + slash
78
+ // command + commit hook), but SKIPS the GitHub Action workflows (which run
79
+ // claude-code-action with ANTHROPIC_API_KEY). For repos that review on a
80
+ // Claude subscription via the in-session hook, not a billed CI Action.
81
+ // Implies --with-local-review + --with-hooks.
82
+ localOnly: false,
83
+ // v0.7.0-rc.4: `clud-bug configure-github` flags.
84
+ // --dry-run prints the diff but skips PATCH; --branch overrides "main".
85
+ dryRun: false,
86
+ branch: null,
65
87
  };
66
88
  for (let i = 0; i < argv.length; i++) {
67
89
  const a = argv[i];
@@ -84,6 +106,23 @@ function parseArgs(argv) {
84
106
  else if (a === '--health') args.health = true;
85
107
  else if (a === '--no-artifacts') args.artifacts = false;
86
108
  else if (a === '--with-skdd') args.withSkdd = true;
109
+ else if (a === '--with-local-review') args.withLocalReview = true;
110
+ else if (a === '--with-hooks') args.withHooks = true;
111
+ else if (a === '--with-design') args.withDesign = true;
112
+ else if (a === '--local-only') args.localOnly = true;
113
+ else if (a === '--dry-run') args.dryRun = true;
114
+ else if (a === '--branch') args.branch = argv[++i];
115
+ else if (a === '--trigger') args.trigger = argv[++i];
116
+ else if (a === '--diff-size') args.diffSizeBytes = Number(argv[++i]);
117
+ // H3: `clud-bug post-check-run` flags.
118
+ else if (a === '--sha') args.sha = argv[++i];
119
+ else if (a === '--verdict') args.verdict = argv[++i];
120
+ else if (a === '--critical-count') args.criticalCount = Number(argv[++i]);
121
+ else if (a === '--source') args.source = argv[++i];
122
+ else if (a === '--strict') args.strict = true;
123
+ else if (a === '--no-strict') args.strict = false;
124
+ else if (a === '--owner') args.owner = argv[++i];
125
+ else if (a === '--details-url') args.detailsUrl = argv[++i];
87
126
  else args._.push(a);
88
127
  }
89
128
  return args;
@@ -105,6 +144,12 @@ Commands:
105
144
  Use --since / --changed-in / --scope to narrow.
106
145
  update Re-render workflows + refresh baseline specimens to the latest shipped
107
146
  templates. Custom and skills.sh-installed specimens left alone.
147
+ configure-github <owner>/<repo>
148
+ Apply the SPEC §7 canonical branch protection ruleset to a repo.
149
+ Auth: GITHUB_TOKEN env first, then \`gh auth token\`. Use
150
+ --dry-run to print the diff without PATCH-ing; --branch to
151
+ target a non-main branch. Idempotent — already-canonical
152
+ repos exit 0 with no changes.
108
153
  edit-workflow Helper for editing .github/workflows/clud-bug-*.yml in an isolated
109
154
  PR (the action refuses to review PRs that modify its own workflow).
110
155
  usage Read recent clud-bug-review run JSON + normalize cost per LOC.
@@ -138,11 +183,67 @@ Commands:
138
183
  GitHub-markdown summary comment shape. Invoked by the
139
184
  workflow post-step; output is what \`gh pr comment\`
140
185
  receives. Empty stdin or non-object payload exits 2.
186
+ select-review-event Compute the SPEC §7.2.1 formal-review event (APPROVE /
187
+ --stdin REQUEST_CHANGES / COMMENT / skip) from a structured-output
188
+ JSON payload + a few env-passed PR-author fields. Used by
189
+ the v0.7.0-rc.3 workflow post-step to post a formal
190
+ \`pulls.createReview\` call so the canonical ruleset's
191
+ \`required_approving_review_count: 1\` floor is auto-
192
+ satisfied by clud-bug[bot] on clean reviews.
193
+ Required env vars: PR_AUTHOR_LOGIN, PR_AUTHOR_ASSOCIATION,
194
+ STRICT_MODE ("true"/"false"). Empty/malformed stdin →
195
+ exits 0 with "skip" on stdout (no-op; workflow degrades
196
+ gracefully to the existing comment-only behavior).
197
+ post-inline-threads Post per-finding inline review threads (D.2.X). Reads a
198
+ --stdin structured-output JSON payload from stdin, fetches the
199
+ PR diff via \`gh api\`, posts one batched review with one
200
+ comment per anchorable finding. Each comment body carries
201
+ a hidden \`<!-- finding-id: <hash> --> \`marker so Wave 5b
202
+ auto-resolve can re-match findings on subsequent pushes
203
+ without persistent state. Required env vars: GH_TOKEN,
204
+ REPO ("owner/name"), PR_NUMBER, HEAD_SHA. Output is a
205
+ JSON status report \`{posted, skipped, preexisting, error?}\`.
206
+ resolve-threads Auto-resolve inline review threads when a fix-push
207
+ addresses the original finding (D.2.6). Fetches all
208
+ bot-authored unresolved threads, asks the Anthropic
209
+ Messages API per-thread whether the new commit addressed
210
+ the concern, then resolves (with a marker reply)
211
+ ADDRESSED threads + leaves UNCERTAIN/NOT_ADDRESSED open.
212
+ Fail-closed: verifier errors route to UNCERTAIN.
213
+ Required env vars: GH_TOKEN, ANTHROPIC_API_KEY,
214
+ REPO ("owner/name"), PR_NUMBER. Output: JSON
215
+ \`{actions, verifierCallCount, shouldRequestChanges}\`.
216
+ review-prompt Emit the local-review recipe — the structured prompt a
217
+ Claude Code agent/hook runs to review the current diff on
218
+ the session's own subscription. Plans via the shared
219
+ engine: --trigger commit (default) → a fast single pass;
220
+ push/pr → the full multi-pass plan. Prints to stdout.
221
+ post-check-run Post the \`clud-bug-review\` GitHub check so branch
222
+ protection can gate the merge (H3). --verdict
223
+ clean|critical|failed --sha <sha> [--critical-count N]
224
+ [--source local|ci] [--strict|--no-strict] [--dry-run].
225
+ clean→success, critical+strict→failure, else neutral.
141
226
 
142
227
  Options:
143
228
  --offline Skip skills.sh; pin only the bundled baseline specimens.
144
229
  --accept-all,-y Accept the recommended specimens without prompting.
145
230
  --commit git add + commit the generated kit when done (init only).
231
+ --with-local-review (init) Scaffold .claude/commands/clud-bug-review.md so
232
+ \`/clud-bug-review\` works in a Claude Code session —
233
+ reviews the current branch's PR using that session's
234
+ own tokens (no hosted App, no extra auth).
235
+ --with-hooks (init) Scaffold a native Claude Code \`type: command\`
236
+ commit-review hook into .claude/settings.json — on every
237
+ \`git commit\` / \`logmind log\`, it fetches a review recipe and
238
+ surfaces it to the agent (on this session's subscription)
239
+ via asyncRewake. Implies --with-local-review. Off by default.
240
+ --with-design (init) Install the design-critic kit (3 \`kind: design\`
241
+ skills) and enable the off-by-default visual review
242
+ lens — renders changed UI and critiques it. Off by default.
243
+ --local-only (init) MAX MODE: install the slash command + commit hook
244
+ (implies --with-local-review + --with-hooks) but SKIP the
245
+ GitHub Action workflows. Reviews run in your Claude Code
246
+ session on your subscription — no API key, no per-review bill.
146
247
  --quiet,-q Token-frugal mode for agent invocations. Suppresses
147
248
  progress chatter; emits exactly one final
148
249
  \`ok <key-value>\` summary line per command. Errors
@@ -152,6 +253,10 @@ Options:
152
253
  required_conversation_resolution on the default
153
254
  branch (init only). Use for repos that manage
154
255
  branch protection via ruleset or org policy.
256
+ --dry-run Print the canonical-v1 diff without PATCH-ing
257
+ (configure-github only).
258
+ --branch <name> Target branch for configure-github (default: main).
259
+ --trigger <ctx> review-prompt context: commit (default) | push | pr.
155
260
  --repo <owner/name> Restrict \`usage\` to a single repo. Default: all repos
156
261
  with clud-bug-review.yml in the gh user's auth scope.
157
262
  --pr <N> Restrict \`usage\` to a single PR.
@@ -178,6 +283,17 @@ async function main() {
178
283
  if (args.quiet) setQuiet(true);
179
284
 
180
285
  const cmd = args._[0];
286
+
287
+ // rc.20 — update notifier (brew/gh/vercel pattern). Only on an interactive
288
+ // terminal, and never for the machine-consumed verbs (the hook runs
289
+ // review-prompt in a non-TTY subprocess, so this is doubly skipped there).
290
+ // Best-effort + cache-backed, so it adds no latency to the command.
291
+ const MACHINE_VERBS = new Set(['review-prompt', 'post-check-run', 'render', 'update-skill-usage']);
292
+ if (process.stderr.isTTY && !MACHINE_VERBS.has(cmd)) {
293
+ const { maybeNotifyUpdate } = await import('./update-notifier.js');
294
+ await maybeNotifyUpdate(await readPkgVersion());
295
+ }
296
+
181
297
  switch (cmd) {
182
298
  case 'init': return runInit(args);
183
299
  case 'list': return runList(args);
@@ -187,10 +303,16 @@ async function main() {
187
303
  case 'audit': return runAudit(args);
188
304
  case 'update': return runUpdateCmd(args);
189
305
  case 'edit-workflow': return runEditWorkflow(args);
306
+ case 'configure-github': return runConfigureGithubCmd(args);
190
307
  case 'usage': return runUsage(args);
191
308
  case 'eval': return runEval();
192
309
  case 'render': return runRender(args);
193
310
  case 'update-skill-usage': return runUpdateSkillUsage(args);
311
+ case 'select-review-event': return runSelectReviewEvent(args);
312
+ case 'post-inline-threads': return runPostInlineThreads(args);
313
+ case 'resolve-threads': return runResolveThreads(args);
314
+ case 'review-prompt': return runReviewPrompt(args);
315
+ case 'post-check-run': return runPostCheckRun(args);
194
316
  default:
195
317
  process.stderr.write(`Unknown command: ${cmd || '(none)'}\n\n${HELP}`);
196
318
  process.exit(2);
@@ -335,6 +457,744 @@ async function runUpdateSkillUsage(args) {
335
457
  }
336
458
 
337
459
 
460
+ // v0.7.0-rc.3 — SPEC §7.2.1 formal-review event selector for the npm
461
+ // workflow path. Reads the action's structured_output JSON from stdin,
462
+ // PR-author metadata from env, and emits ONE word on stdout:
463
+ //
464
+ // APPROVE | REQUEST_CHANGES | COMMENT | skip
465
+ //
466
+ // The workflow template post-step pipes the structured_output payload
467
+ // in, reads the verdict, then conditionally calls `gh api ... /reviews`
468
+ // with `event=$VERDICT` (skipping the API call when verdict is "skip").
469
+ //
470
+ // Robustness contract: this command MUST NEVER fail the workflow on
471
+ // caller-side problems (missing/malformed payload, missing env). Any
472
+ // such case prints "skip" to stdout + a warning to stderr and exits 0
473
+ // — the workflow then degrades to the existing comment-only behavior.
474
+ // We only exit non-zero on programmer error (unknown command flags),
475
+ // which the workflow templates don't pass.
476
+ //
477
+ // Required env vars (the workflow templates set all four):
478
+ // PR_AUTHOR_LOGIN — github.event.pull_request.user.login
479
+ // PR_AUTHOR_ASSOCIATION — github.event.pull_request.author_association
480
+ // STRICT_MODE — "true" / "false" from the base-ref manifest
481
+ // (PR_AUTHOR_ASSOCIATION missing → "CONTRIBUTOR" default which preserves
482
+ // pre-§7.2.1 trusted-author semantics; PR_AUTHOR_LOGIN missing → "skip".)
483
+ async function runSelectReviewEvent(args) {
484
+ const { selectReviewEvent } = await import('../core/formal-review.js');
485
+
486
+ if (!args.stdin) {
487
+ process.stderr.write(
488
+ 'clud-bug select-review-event: --stdin is required.\n',
489
+ );
490
+ process.stdout.write('skip\n');
491
+ return;
492
+ }
493
+
494
+ let raw = '';
495
+ for await (const chunk of process.stdin) raw += chunk;
496
+ raw = raw.trim();
497
+ if (!raw) {
498
+ process.stderr.write(
499
+ 'clud-bug select-review-event: stdin empty — emitting "skip".\n',
500
+ );
501
+ process.stdout.write('skip\n');
502
+ return;
503
+ }
504
+
505
+ let payload;
506
+ try {
507
+ payload = JSON.parse(raw);
508
+ } catch (e) {
509
+ process.stderr.write(
510
+ `clud-bug select-review-event: JSON parse failed: ${e.message} — emitting "skip".\n`,
511
+ );
512
+ process.stdout.write('skip\n');
513
+ return;
514
+ }
515
+ if (!payload || typeof payload !== 'object') {
516
+ process.stderr.write(
517
+ 'clud-bug select-review-event: payload must be a JSON object — emitting "skip".\n',
518
+ );
519
+ process.stdout.write('skip\n');
520
+ return;
521
+ }
522
+
523
+ // Count findings from the structured_output array shape. Each is
524
+ // optional — the model may emit absent / null / wrong-type arrays
525
+ // when its output drifts from schema; we tolerate all of those.
526
+ const criticalCount = Array.isArray(payload.critical_findings)
527
+ ? payload.critical_findings.length
528
+ : 0;
529
+ const minorCount = Array.isArray(payload.minor_findings)
530
+ ? payload.minor_findings.length
531
+ : 0;
532
+
533
+ // Env-passed metadata. Empty PR_AUTHOR_LOGIN means we have no idea
534
+ // who opened the PR — safer to skip than to attempt a formal review
535
+ // that might collide with the self-PR guard mis-routed.
536
+ const prAuthorLogin = String(process.env.PR_AUTHOR_LOGIN ?? '').trim();
537
+ if (prAuthorLogin === '') {
538
+ process.stderr.write(
539
+ 'clud-bug select-review-event: PR_AUTHOR_LOGIN unset — emitting "skip".\n',
540
+ );
541
+ process.stdout.write('skip\n');
542
+ return;
543
+ }
544
+
545
+ // author_association resolution — security-critical fallback ladder.
546
+ //
547
+ // SPEC §7.2.1 + clud-bug-review #163 feedback: the §7.2.1 gate exists
548
+ // precisely so a future REST-API rename of an external tier doesn't
549
+ // silently degrade to "trusted → APPROVE". The fallback below splits
550
+ // the two error cases by what they MEAN:
551
+ //
552
+ // 1. EMPTY ("") — caller didn't pass author_association at all.
553
+ // This is the "older webhook payload / non-GH caller" case.
554
+ // Pre-§7.2.1, those callers got APPROVE on clean reviews; we
555
+ // preserve that by defaulting to 'CONTRIBUTOR' (org-trusted).
556
+ // Documented + tested under "missing PR_AUTHOR_ASSOCIATION".
557
+ //
558
+ // 2. NON-EMPTY UNRECOGNIZED ("FUTURE_TIER") — caller passed a
559
+ // token this build doesn't know about. This is the rename
560
+ // scenario the §7.2.1 gate is explicitly defending against.
561
+ // Fail CLOSED: treat as external → 'NONE' → COMMENT, NEVER
562
+ // APPROVE. If the rename was for a still-trusted tier (e.g.
563
+ // 'STAFF'), the cost is one extra COMMENT review pending
564
+ // human approval; if it was a new external tier, we've closed
565
+ // the drive-by exploit. Asymmetric risk → fail closed.
566
+ const rawAssoc = String(process.env.PR_AUTHOR_ASSOCIATION ?? '').trim();
567
+ const validAssociations = new Set([
568
+ 'OWNER', 'MEMBER', 'COLLABORATOR', 'CONTRIBUTOR',
569
+ 'FIRST_TIME_CONTRIBUTOR', 'FIRST_TIMER', 'NONE', 'MANNEQUIN',
570
+ ]);
571
+ let authorAssociation;
572
+ if (rawAssoc === '') {
573
+ // Back-compat for legacy callers without author_association.
574
+ authorAssociation = 'CONTRIBUTOR';
575
+ } else if (validAssociations.has(rawAssoc)) {
576
+ authorAssociation = rawAssoc;
577
+ } else {
578
+ // Fail-closed for unrecognized future tokens. 'NONE' is the
579
+ // weakest external tier and the safest default — selectReviewEvent
580
+ // routes it to COMMENT (never APPROVE, never REQUEST_CHANGES).
581
+ process.stderr.write(
582
+ `clud-bug select-review-event: unrecognized PR_AUTHOR_ASSOCIATION="${rawAssoc}" — treating as external (fail-closed per SPEC §7.2.1).\n`,
583
+ );
584
+ authorAssociation = 'NONE';
585
+ }
586
+
587
+ const strictMode = String(process.env.STRICT_MODE ?? '').trim() === 'true';
588
+
589
+ const event = selectReviewEvent({
590
+ criticalCount,
591
+ minorCount,
592
+ strictMode,
593
+ prAuthorLogin,
594
+ authorAssociation,
595
+ });
596
+ process.stdout.write(event + '\n');
597
+ }
598
+
599
+
600
+ // Wave 5a / 0.7.0-rc.7: post per-finding inline review threads (D.2.X)
601
+ // to the PR. Replaces the legacy "one summary comment listing every
602
+ // finding" UX with first-class GitHub review threads users can reply to
603
+ // and (in Wave 5b) the bot can auto-resolve on fix-push.
604
+ //
605
+ // Pipeline:
606
+ // 1. Read the structured review JSON from stdin (same shape the
607
+ // `render` and `update-skill-usage` verbs consume).
608
+ // 2. Fetch the PR's per-file diff via `gh api repos/.../pulls/N/files`.
609
+ // 3. Call `planInlineThreads(findings, diffFiles)` from
610
+ // `clud-bug/core/inline-threads` to partition into anchored
611
+ // `comments[]` + skipped + preexisting buckets.
612
+ // 4. If any comments survive: POST one batched review via
613
+ // `gh api -X POST repos/.../pulls/N/reviews` with `event: COMMENT`.
614
+ // Each comment body carries a hidden `<!-- finding-id: <hash> -->`
615
+ // marker so Wave 5b auto-resolve can re-derive the same ids on
616
+ // subsequent pushes without a persistent store.
617
+ // 5. Emit a JSON summary on stdout (`{posted, skipped, preexisting}`).
618
+ //
619
+ // Required env vars (the workflow template provides all of them):
620
+ // GH_TOKEN, REPO ("owner/name"), PR_NUMBER, HEAD_SHA
621
+ //
622
+ // Failure posture: any `gh api` failure is logged to stderr + emitted in
623
+ // the stdout JSON as `error`, but the verb exits 0 so the surrounding
624
+ // workflow step's `continue-on-error: true` is the single source of
625
+ // failure semantics. Mirrors the `render` / `update-skill-usage` posture.
626
+ async function runPostInlineThreads(args) {
627
+ const { planInlineThreads } = await import('../core/inline-threads.js');
628
+
629
+ if (!args.stdin) {
630
+ process.stderr.write(
631
+ 'clud-bug post-inline-threads: --stdin is required.\n',
632
+ );
633
+ process.stdout.write(JSON.stringify({ posted: 0, skipped: [], preexisting: [], error: 'no-stdin' }) + '\n');
634
+ return;
635
+ }
636
+
637
+ let raw = '';
638
+ for await (const chunk of process.stdin) raw += chunk;
639
+ raw = raw.trim();
640
+ if (!raw) {
641
+ process.stderr.write(
642
+ 'clud-bug post-inline-threads: stdin empty — no-op.\n',
643
+ );
644
+ process.stdout.write(JSON.stringify({ posted: 0, skipped: [], preexisting: [], error: 'empty-stdin' }) + '\n');
645
+ return;
646
+ }
647
+
648
+ let payload;
649
+ try {
650
+ payload = JSON.parse(raw);
651
+ } catch (e) {
652
+ process.stderr.write(
653
+ `clud-bug post-inline-threads: JSON parse failed: ${e.message} — no-op.\n`,
654
+ );
655
+ process.stdout.write(JSON.stringify({ posted: 0, skipped: [], preexisting: [], error: 'parse-failed' }) + '\n');
656
+ return;
657
+ }
658
+ if (!payload || typeof payload !== 'object') {
659
+ process.stderr.write(
660
+ 'clud-bug post-inline-threads: payload must be a JSON object — no-op.\n',
661
+ );
662
+ process.stdout.write(JSON.stringify({ posted: 0, skipped: [], preexisting: [], error: 'not-object' }) + '\n');
663
+ return;
664
+ }
665
+
666
+ // Pull findings from the structured_output shape — same field names as
667
+ // the SPEC §1.8.1 review schema the model emits.
668
+ const findings = [];
669
+ for (const f of Array.isArray(payload.critical_findings) ? payload.critical_findings : []) {
670
+ if (f && typeof f === 'object') {
671
+ findings.push({ ...f, severity: 'critical' });
672
+ }
673
+ }
674
+ for (const f of Array.isArray(payload.minor_findings) ? payload.minor_findings : []) {
675
+ if (f && typeof f === 'object') {
676
+ findings.push({ ...f, severity: 'minor' });
677
+ }
678
+ }
679
+ // `preexisting_findings` are intentionally not threaded (informational
680
+ // about prior code; not a reason to block this PR). `planInlineThreads`
681
+ // partitions them into the `preexisting` bucket if any are included, but
682
+ // the workflow path doesn't ship them — keep the payload focused.
683
+
684
+ if (findings.length === 0) {
685
+ process.stdout.write(JSON.stringify({ posted: 0, skipped: [], preexisting: [], reason: 'no-findings' }) + '\n');
686
+ return;
687
+ }
688
+
689
+ // Required env vars.
690
+ const repo = String(process.env.REPO ?? '').trim();
691
+ const prNumberRaw = String(process.env.PR_NUMBER ?? '').trim();
692
+ const headSha = String(process.env.HEAD_SHA ?? '').trim();
693
+ const prNumber = Number(prNumberRaw);
694
+ if (!repo || !repo.includes('/') || !Number.isInteger(prNumber) || prNumber <= 0 || !headSha) {
695
+ process.stderr.write(
696
+ `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`,
697
+ );
698
+ process.stdout.write(JSON.stringify({ posted: 0, skipped: [], preexisting: [], error: 'missing-env' }) + '\n');
699
+ return;
700
+ }
701
+
702
+ // Fetch the PR's diff. `gh api repos/.../pulls/N/files --paginate`
703
+ // returns a JSON array of {filename, patch, status, ...}. The single
704
+ // -slurp on multi-page output stitches all pages into one big array.
705
+ const filesResult = spawnSync(
706
+ 'gh',
707
+ [
708
+ 'api',
709
+ `repos/${repo}/pulls/${prNumber}/files`,
710
+ '--paginate',
711
+ '--slurp',
712
+ ],
713
+ { encoding: 'utf8' },
714
+ );
715
+ if (filesResult.status !== 0) {
716
+ process.stderr.write(
717
+ `clud-bug post-inline-threads: \`gh api .../files\` failed (exit ${filesResult.status}): ${(filesResult.stderr || '').slice(0, 500)}\n`,
718
+ );
719
+ process.stdout.write(JSON.stringify({ posted: 0, skipped: [], preexisting: [], error: 'diff-fetch-failed' }) + '\n');
720
+ return;
721
+ }
722
+
723
+ let diffFiles = [];
724
+ try {
725
+ // --slurp wraps each page in an outer array; flatten one level.
726
+ const pages = JSON.parse(filesResult.stdout);
727
+ if (Array.isArray(pages)) {
728
+ for (const page of pages) {
729
+ if (Array.isArray(page)) diffFiles.push(...page);
730
+ }
731
+ }
732
+ } catch (e) {
733
+ process.stderr.write(
734
+ `clud-bug post-inline-threads: diff JSON parse failed: ${e.message}\n`,
735
+ );
736
+ process.stdout.write(JSON.stringify({ posted: 0, skipped: [], preexisting: [], error: 'diff-parse-failed' }) + '\n');
737
+ return;
738
+ }
739
+
740
+ const plan = planInlineThreads(findings, diffFiles);
741
+ if (plan.comments.length === 0) {
742
+ // Everything fell through to summary-comment fallback (no anchor
743
+ // matches). Not a failure — emit the breakdown so the workflow log
744
+ // explains why no inline threads were posted.
745
+ process.stdout.write(JSON.stringify({
746
+ posted: 0,
747
+ skipped: plan.skipped.map((f) => ({ skill: f.skill, file: f.file, line: f.line, reason: 'not-anchorable' })),
748
+ preexisting: plan.preexisting.map((f) => ({ skill: f.skill })),
749
+ reason: 'no-anchorable-findings',
750
+ }) + '\n');
751
+ return;
752
+ }
753
+
754
+ // Post the review. gh accepts the body as a JSON file via --input — we
755
+ // pipe through stdin with `--input -`. Build the body as JSON-on-one-line
756
+ // so stdin is a single write that `gh` reads start-to-finish.
757
+ const reviewBody = JSON.stringify({
758
+ commit_id: headSha,
759
+ event: 'COMMENT',
760
+ body: `Clud-Bug posted ${plan.comments.length} inline finding${plan.comments.length === 1 ? '' : 's'}.`,
761
+ comments: plan.comments,
762
+ });
763
+
764
+ const postResult = spawnSync(
765
+ 'gh',
766
+ [
767
+ 'api',
768
+ '--method', 'POST',
769
+ `repos/${repo}/pulls/${prNumber}/reviews`,
770
+ '--input', '-',
771
+ ],
772
+ { encoding: 'utf8', input: reviewBody },
773
+ );
774
+ if (postResult.status !== 0) {
775
+ process.stderr.write(
776
+ `clud-bug post-inline-threads: \`gh api -X POST .../reviews\` failed (exit ${postResult.status}): ${(postResult.stderr || '').slice(0, 500)}\n`,
777
+ );
778
+ process.stdout.write(JSON.stringify({
779
+ posted: 0,
780
+ skipped: plan.skipped.map((f) => ({ skill: f.skill, file: f.file, line: f.line, reason: 'not-anchorable' })),
781
+ preexisting: plan.preexisting.map((f) => ({ skill: f.skill })),
782
+ error: 'review-post-failed',
783
+ }) + '\n');
784
+ return;
785
+ }
786
+
787
+ process.stdout.write(JSON.stringify({
788
+ posted: plan.comments.length,
789
+ skipped: plan.skipped.map((f) => ({ skill: f.skill, file: f.file, line: f.line, reason: 'not-anchorable' })),
790
+ preexisting: plan.preexisting.map((f) => ({ skill: f.skill })),
791
+ }) + '\n');
792
+ }
793
+
794
+
795
+ // Wave 5b / 0.7.0-rc.8: D.2.6 auto-resolve on fix-push. Fetches all
796
+ // bot-authored unresolved inline threads, asks the Anthropic Messages
797
+ // API per-thread whether the new commit addressed the original
798
+ // finding, and resolves ADDRESSED threads (with a marker reply) while
799
+ // leaving UNCERTAIN/NOT_ADDRESSED open. Fail-closed throughout —
800
+ // verifier errors route to UNCERTAIN; the surrounding workflow
801
+ // step's `continue-on-error: true` is the single failure gate.
802
+ //
803
+ // Required env: GH_TOKEN, ANTHROPIC_API_KEY, REPO ("owner/name"), PR_NUMBER.
804
+ //
805
+ // No `--stdin` — fetches everything from `gh api graphql` itself.
806
+ // Workflow gates this verb on `github.event.action == 'synchronize'`
807
+ // so it only runs on fix-pushes (not initial PR opens, when there
808
+ // are no prior threads to resolve).
809
+ async function runResolveThreads(_args) {
810
+ const {
811
+ parseThreadBody,
812
+ extractAnchorContext,
813
+ REVIEW_THREADS_QUERY,
814
+ RESOLVE_THREAD_MUTATION,
815
+ ADD_REPLY_MUTATION,
816
+ } = await import('../core/inline-threads.js');
817
+ const {
818
+ readAutoResolveConfigFromCludBug,
819
+ runAutoResolve,
820
+ applyResolutionRules,
821
+ selectResolveAuth,
822
+ renderResolveMarkerTag,
823
+ anchorSignature,
824
+ latestResolveMarker,
825
+ } = await import('../core/auto-resolve.js');
826
+ const {
827
+ VERIFIER_SYSTEM,
828
+ buildVerifierPrompt,
829
+ parseVerifierResponse,
830
+ } = await import('../core/resolve-verifier.js');
831
+
832
+ // ---- Env validation ----------------------------------------------------
833
+ const repo = String(process.env.REPO ?? '').trim();
834
+ const prNumberRaw = String(process.env.PR_NUMBER ?? '').trim();
835
+ const prNumber = Number(prNumberRaw);
836
+ const ghToken = String(process.env.GH_TOKEN ?? '').trim();
837
+ const anthropicKey = String(process.env.ANTHROPIC_API_KEY ?? '').trim();
838
+ if (!repo || !repo.includes('/') || !Number.isInteger(prNumber) || prNumber <= 0 || !ghToken || !anthropicKey) {
839
+ process.stderr.write(
840
+ `clud-bug resolve-threads: REPO + PR_NUMBER + GH_TOKEN + ANTHROPIC_API_KEY env vars required.\n`,
841
+ );
842
+ process.stdout.write(JSON.stringify({ actions: [], verifierCallCount: 0, shouldRequestChanges: false, error: 'missing-env' }) + '\n');
843
+ return;
844
+ }
845
+ const [owner, repoName] = repo.split('/', 2);
846
+
847
+ // ---- Optional .clud-bug.json config (autoResolve block) ----------------
848
+ // The workflow's working dir is the PR checkout — if .clud-bug.json is
849
+ // present we read autoResolve from it. Otherwise defaults (mode='verified').
850
+ let autoResolveConfig;
851
+ try {
852
+ const cfgPath = join(process.cwd(), '.claude/skills/.clud-bug.json');
853
+ const cfgRaw = await readFile(cfgPath, 'utf8');
854
+ const cfg = JSON.parse(cfgRaw);
855
+ autoResolveConfig = readAutoResolveConfigFromCludBug(cfg, (msg) => {
856
+ process.stderr.write(`clud-bug resolve-threads: config warning: ${msg}\n`);
857
+ });
858
+ } catch (err) {
859
+ // Missing OR unparseable .clud-bug.json — use defaults but log the
860
+ // specific reason so an operator with malformed config can diagnose
861
+ // why their `autoResolve.mode = 'off'` is being silently ignored.
862
+ const code = err && typeof err === 'object' && 'code' in err ? err.code : undefined;
863
+ if (code !== 'ENOENT') {
864
+ process.stderr.write(
865
+ `clud-bug resolve-threads: .clud-bug.json read/parse error (${err && typeof err === 'object' && 'message' in err ? err.message : String(err)}); using defaults.\n`,
866
+ );
867
+ }
868
+ autoResolveConfig = readAutoResolveConfigFromCludBug(null);
869
+ }
870
+
871
+ if (autoResolveConfig.mode === 'off') {
872
+ process.stdout.write(JSON.stringify({ actions: [], verifierCallCount: 0, shouldRequestChanges: false, reason: 'mode-off' }) + '\n');
873
+ return;
874
+ }
875
+
876
+ // ---- Fetch threads via GraphQL -----------------------------------------
877
+ // `gh api graphql` uses `-f` for String! variables and `-F` for typed
878
+ // (number / bool) variables. Owner + repo are String! per the query;
879
+ // pr is Int!. Reviewer-flagged: passing owner/repo as `-F` would coerce
880
+ // numeric-looking values incorrectly.
881
+ const threadsResult = spawnSync(
882
+ 'gh',
883
+ [
884
+ 'api', 'graphql',
885
+ '-f', `query=${REVIEW_THREADS_QUERY}`,
886
+ '-f', `owner=${owner}`,
887
+ '-f', `repo=${repoName}`,
888
+ '-F', `pr=${prNumber}`,
889
+ ],
890
+ { encoding: 'utf8' },
891
+ );
892
+ if (threadsResult.status !== 0) {
893
+ process.stderr.write(`clud-bug resolve-threads: gh api graphql (threads) failed (exit ${threadsResult.status}): ${(threadsResult.stderr || '').slice(0, 500)}\n`);
894
+ process.stdout.write(JSON.stringify({ actions: [], verifierCallCount: 0, shouldRequestChanges: false, error: 'threads-fetch-failed' }) + '\n');
895
+ return;
896
+ }
897
+
898
+ let threadNodes;
899
+ try {
900
+ const parsed = JSON.parse(threadsResult.stdout);
901
+ threadNodes = parsed?.data?.repository?.pullRequest?.reviewThreads?.nodes ?? [];
902
+ if (!Array.isArray(threadNodes)) threadNodes = [];
903
+ } catch (e) {
904
+ process.stderr.write(`clud-bug resolve-threads: threads JSON parse failed: ${e.message}\n`);
905
+ process.stdout.write(JSON.stringify({ actions: [], verifierCallCount: 0, shouldRequestChanges: false, error: 'threads-parse-failed' }) + '\n');
906
+ return;
907
+ }
908
+
909
+ // ---- Filter to bot-authored unresolved threads with our marker --------
910
+ // The workflow's GITHUB_TOKEN posts as `github-actions[bot]`. Match both
911
+ // the bracketed bot form and the bare `github-actions` form (GraphQL
912
+ // can return either depending on the node-type field requested).
913
+ const BOT_AUTHORS = new Set(['github-actions', 'github-actions[bot]']);
914
+ const candidates = [];
915
+ for (const t of threadNodes) {
916
+ if (!t || t.isResolved) continue;
917
+ const c = t.comments?.nodes?.[0];
918
+ if (!c) continue;
919
+ const authorLogin = c.author?.login ?? '';
920
+ if (!BOT_AUTHORS.has(authorLogin)) continue;
921
+ const parsed = parseThreadBody(c.body ?? '');
922
+ if (!parsed) continue;
923
+ if (!c.path || (c.line === null && c.originalLine === null)) continue;
924
+ candidates.push({
925
+ threadId: t.id,
926
+ file: c.path,
927
+ line: c.line ?? c.originalLine,
928
+ parsed,
929
+ // Wave 5b.1: full comment list so idempotency can spot a prior
930
+ // bot reply's resolve marker (verdict + anchor sig) on this thread.
931
+ comments: t.comments?.nodes ?? [],
932
+ });
933
+ }
934
+
935
+ if (candidates.length === 0) {
936
+ process.stdout.write(JSON.stringify({ actions: [], verifierCallCount: 0, shouldRequestChanges: false, reason: 'no-bot-threads' }) + '\n');
937
+ return;
938
+ }
939
+
940
+ // ---- Fetch diff for anchor context -------------------------------------
941
+ const filesResult = spawnSync(
942
+ 'gh',
943
+ [
944
+ 'api',
945
+ `repos/${repo}/pulls/${prNumber}/files`,
946
+ '--paginate',
947
+ '--slurp',
948
+ ],
949
+ { encoding: 'utf8' },
950
+ );
951
+ if (filesResult.status !== 0) {
952
+ process.stderr.write(`clud-bug resolve-threads: diff fetch failed (exit ${filesResult.status}): ${(filesResult.stderr || '').slice(0, 500)}\n`);
953
+ process.stdout.write(JSON.stringify({ actions: [], verifierCallCount: 0, shouldRequestChanges: false, error: 'diff-fetch-failed' }) + '\n');
954
+ return;
955
+ }
956
+ let diffFiles = [];
957
+ try {
958
+ const pages = JSON.parse(filesResult.stdout);
959
+ if (Array.isArray(pages)) {
960
+ for (const page of pages) {
961
+ if (Array.isArray(page)) diffFiles.push(...page);
962
+ }
963
+ }
964
+ } catch (e) {
965
+ process.stderr.write(`clud-bug resolve-threads: diff JSON parse failed: ${e.message}\n`);
966
+ process.stdout.write(JSON.stringify({ actions: [], verifierCallCount: 0, shouldRequestChanges: false, error: 'diff-parse-failed' }) + '\n');
967
+ return;
968
+ }
969
+
970
+ // ---- Build PriorThread[] for runAutoResolve ----------------------------
971
+ const priorThreads = candidates.map((c) => {
972
+ const ctx = extractAnchorContext({ file: c.file, line: c.line }, diffFiles);
973
+ const findingBody = c.parsed.reasoning
974
+ ? `${c.parsed.summary}\n\n${c.parsed.reasoning}`
975
+ : c.parsed.summary;
976
+ return {
977
+ threadId: c.threadId,
978
+ finding: {
979
+ severity: c.parsed.severity,
980
+ body: findingBody,
981
+ skill: c.parsed.skill,
982
+ file: c.file,
983
+ line: c.line,
984
+ },
985
+ codeBefore: ctx.codeBefore,
986
+ codeAfter: ctx.codeAfter,
987
+ ...(ctx.diffAtAnchor !== undefined ? { diffAtAnchor: ctx.diffAtAnchor } : {}),
988
+ };
989
+ });
990
+
991
+ // ---- Define verifier (Anthropic Messages API via raw fetch) -----------
992
+ // Model is hardcoded here — the verifier needs a model that handles
993
+ // structured JSON output reliably. Sonnet 4.6 is the default. Override
994
+ // via CLUD_BUG_VERIFIER_MODEL env var if needed.
995
+ const verifierModel = String(process.env.CLUD_BUG_VERIFIER_MODEL ?? '').trim()
996
+ || 'claude-sonnet-4-6';
997
+
998
+ async function verifier(input) {
999
+ const userPrompt = buildVerifierPrompt(input);
1000
+ try {
1001
+ const resp = await fetch('https://api.anthropic.com/v1/messages', {
1002
+ method: 'POST',
1003
+ headers: {
1004
+ 'content-type': 'application/json',
1005
+ 'anthropic-version': '2023-06-01',
1006
+ 'x-api-key': anthropicKey,
1007
+ },
1008
+ body: JSON.stringify({
1009
+ model: verifierModel,
1010
+ max_tokens: 300,
1011
+ system: VERIFIER_SYSTEM,
1012
+ messages: [{ role: 'user', content: userPrompt }],
1013
+ }),
1014
+ });
1015
+ if (!resp.ok) {
1016
+ const errText = await resp.text().catch(() => '');
1017
+ return {
1018
+ verdict: 'UNCERTAIN',
1019
+ source: 'api-error',
1020
+ rationale: `Anthropic API ${resp.status}: ${errText.slice(0, 200)}`,
1021
+ };
1022
+ }
1023
+ const body = await resp.json();
1024
+ // Messages API: content is an array of blocks; pick the first text block.
1025
+ const text = (body?.content ?? []).find((b) => b?.type === 'text')?.text ?? '';
1026
+ return parseVerifierResponse(text);
1027
+ } catch (err) {
1028
+ return {
1029
+ verdict: 'UNCERTAIN',
1030
+ source: 'api-error',
1031
+ rationale: `verifier fetch error: ${err?.message ?? String(err)}`,
1032
+ };
1033
+ }
1034
+ }
1035
+
1036
+ // ---- Resolve auth (Wave 5b.1: PAT optional + graceful fallback) -------
1037
+ // A dedicated PAT lets us actually close threads; without one the Actions
1038
+ // GITHUB_TOKEN can't resolve, so we post a "verified fixed" reply only.
1039
+ const auth = selectResolveAuth(process.env);
1040
+
1041
+ // ---- Idempotency: skip threads whose anchor is unchanged --------------
1042
+ // For each candidate, hash the post-fix anchor and look for a prior bot
1043
+ // reply carrying the same (verdict, sig) marker. If the anchor hasn't moved
1044
+ // since that marker, we re-use the cached verdict — NO verifier call, NO
1045
+ // re-post — which kills repeat replies on multi-push PRs and saves spend.
1046
+ const verifyItems = []; // { thread, sig } — need (re)verification
1047
+ const cachedItems = []; // { thread, action } — anchor unchanged, no I/O
1048
+ for (let i = 0; i < priorThreads.length; i++) {
1049
+ const thread = priorThreads[i];
1050
+ const candidate = candidates[i];
1051
+ if (!thread) continue;
1052
+ const sig = anchorSignature(thread.finding, thread.codeAfter);
1053
+ const prior = latestResolveMarker(candidate?.comments ?? [], BOT_AUTHORS);
1054
+ if (prior && prior.sig === sig) {
1055
+ const action = applyResolutionRules({
1056
+ thread,
1057
+ verdict: {
1058
+ verdict: prior.verdict,
1059
+ source: 'model',
1060
+ rationale: '(unchanged since last check)',
1061
+ },
1062
+ config: autoResolveConfig,
1063
+ canResolve: auth.hasDedicatedPat,
1064
+ });
1065
+ cachedItems.push({ thread, action });
1066
+ } else {
1067
+ verifyItems.push({ thread, sig });
1068
+ }
1069
+ }
1070
+
1071
+ // ---- Run pure orchestration on the threads that need (re)verification --
1072
+ const result = await runAutoResolve({
1073
+ priorThreads: verifyItems.map((v) => v.thread),
1074
+ config: autoResolveConfig,
1075
+ verifier,
1076
+ canResolve: auth.hasDedicatedPat,
1077
+ });
1078
+
1079
+ let shouldRequestChanges = result.shouldRequestChanges;
1080
+ for (const c of cachedItems) {
1081
+ if (c.action.kind === 'keep_open_request_changes') shouldRequestChanges = true;
1082
+ }
1083
+
1084
+ // ---- Execute the actions via GraphQL mutations ------------------------
1085
+ const actionsReport = [];
1086
+
1087
+ // Unchanged threads: never re-post the reply (idempotent). BUT if the
1088
+ // thread is verified ADDRESSED and a PAT is now available, resolve it —
1089
+ // the marker reply already exists, so this closes a thread that couldn't
1090
+ // be resolved before (no-PAT → PAT upgrade, or a prior transient
1091
+ // resolve-failure) WITHOUT re-verifying or re-replying.
1092
+ for (const c of cachedItems) {
1093
+ const baseReport = {
1094
+ threadId: c.thread.threadId,
1095
+ file: c.thread.finding.file,
1096
+ line: c.thread.finding.line,
1097
+ verdict: c.action.verdict?.verdict,
1098
+ kind: c.action.kind,
1099
+ };
1100
+ if (c.action.kind === 'resolve' && auth.hasDedicatedPat) {
1101
+ const resolveResult = spawnSync(
1102
+ 'gh',
1103
+ [
1104
+ 'api', 'graphql',
1105
+ '-f', `query=${RESOLVE_THREAD_MUTATION}`,
1106
+ '-f', `threadId=${c.thread.threadId}`,
1107
+ ],
1108
+ { encoding: 'utf8', env: { ...process.env, GH_TOKEN: auth.token } },
1109
+ );
1110
+ if (resolveResult.status !== 0) {
1111
+ process.stderr.write(`clud-bug resolve-threads: RESOLVE (cached) failed for thread ${c.thread.threadId}: ${(resolveResult.stderr || '').slice(0, 200)}\n`);
1112
+ actionsReport.push({ ...baseReport, executed: 'resolve-failed' });
1113
+ } else {
1114
+ actionsReport.push({ ...baseReport, executed: 'resolved-from-cache' });
1115
+ }
1116
+ } else {
1117
+ actionsReport.push({ ...baseReport, executed: 'unchanged' });
1118
+ }
1119
+ }
1120
+
1121
+ for (let i = 0; i < result.actions.length; i++) {
1122
+ const action = result.actions[i];
1123
+ const item = verifyItems[i];
1124
+ if (!action || !item) continue;
1125
+ const thread = item.thread;
1126
+ const report = {
1127
+ threadId: thread.threadId,
1128
+ file: thread.finding.file,
1129
+ line: thread.finding.line,
1130
+ verdict: action.verdict?.verdict,
1131
+ kind: action.kind,
1132
+ };
1133
+
1134
+ if (action.kind === 'skipped') {
1135
+ actionsReport.push({ ...report, executed: 'skipped' });
1136
+ continue;
1137
+ }
1138
+
1139
+ // Reply body carries a hidden idempotency marker (verdict + anchor sig)
1140
+ // so a later fix-push won't re-reply while the anchor is unchanged.
1141
+ const tag = renderResolveMarkerTag(action.verdict.verdict, item.sig);
1142
+ const replyBody = `${action.markerBody}\n\n${tag}`;
1143
+ const replyResult = spawnSync(
1144
+ 'gh',
1145
+ [
1146
+ 'api', 'graphql',
1147
+ '-f', `query=${ADD_REPLY_MUTATION}`,
1148
+ '-f', `threadId=${thread.threadId}`,
1149
+ '-f', `body=${replyBody}`,
1150
+ ],
1151
+ { encoding: 'utf8' },
1152
+ );
1153
+ if (replyResult.status !== 0) {
1154
+ process.stderr.write(`clud-bug resolve-threads: ADD_REPLY failed for thread ${thread.threadId}: ${(replyResult.stderr || '').slice(0, 200)}\n`);
1155
+ actionsReport.push({ ...report, executed: 'reply-failed' });
1156
+ continue;
1157
+ }
1158
+
1159
+ // Resolve only ADDRESSED threads, and only with a dedicated PAT — the
1160
+ // Actions GITHUB_TOKEN can't resolve ("Resource not accessible by
1161
+ // integration"). No PAT → marker-reply-only (graceful, no error).
1162
+ if (action.kind === 'resolve') {
1163
+ if (!auth.hasDedicatedPat) {
1164
+ actionsReport.push({ ...report, executed: 'reply-only-no-pat' });
1165
+ continue;
1166
+ }
1167
+ const resolveResult = spawnSync(
1168
+ 'gh',
1169
+ [
1170
+ 'api', 'graphql',
1171
+ '-f', `query=${RESOLVE_THREAD_MUTATION}`,
1172
+ '-f', `threadId=${thread.threadId}`,
1173
+ ],
1174
+ // Scope the PAT to JUST this call; all other gh calls keep GH_TOKEN.
1175
+ { encoding: 'utf8', env: { ...process.env, GH_TOKEN: auth.token } },
1176
+ );
1177
+ if (resolveResult.status !== 0) {
1178
+ process.stderr.write(`clud-bug resolve-threads: RESOLVE failed for thread ${thread.threadId}: ${(resolveResult.stderr || '').slice(0, 200)}\n`);
1179
+ actionsReport.push({ ...report, executed: 'resolve-failed' });
1180
+ continue;
1181
+ }
1182
+ actionsReport.push({ ...report, executed: 'resolved' });
1183
+ } else {
1184
+ // keep_open or keep_open_request_changes — reply already posted.
1185
+ actionsReport.push({ ...report, executed: 'kept-open' });
1186
+ }
1187
+ }
1188
+
1189
+ process.stdout.write(JSON.stringify({
1190
+ actions: actionsReport,
1191
+ verifierCallCount: result.verifierCallCount,
1192
+ shouldRequestChanges,
1193
+ resolveTokenSource: auth.source,
1194
+ }) + '\n');
1195
+ }
1196
+
1197
+
338
1198
  // 0.0.E (v0.6.17): thin wrapper around the golden-set test file. Devs
339
1199
  // who follow the README invoke `clud-bug eval` — this routes to the
340
1200
  // same `node --test` runner CI uses, so dev and CI verdicts match.
@@ -426,29 +1286,114 @@ async function runInit(args) {
426
1286
  language: templateLanguage(tmplName),
427
1287
  }),
428
1288
  });
429
- const workflowPath = join(cwd, '.github', 'workflows', 'clud-bug-review.yml');
430
- await mkdir(dirname(workflowPath), { recursive: true });
431
- await writeFile(workflowPath, workflow);
432
- log(` wrote ${rel(cwd, workflowPath)}`);
433
-
434
- // Install the audit workflow alongside the per-PR review one.
435
- // Manual-trigger by default; users opt into the cron by uncommenting.
436
- // Routed through renderFile so {{CCA_VERSION}} substitution pins
437
- // claude-code-action consistently with the review workflow.
438
- const auditTmpl = await renderFile(join(TEMPLATES, 'audit.yml.tmpl'), {});
439
- const auditPath = join(cwd, '.github', 'workflows', 'clud-bug-audit.yml');
440
- await writeFile(auditPath, auditTmpl);
441
- log(` wrote ${rel(cwd, auditPath)}`);
442
-
443
- // Install the self-update workflow. Cron weekly Mondays 12:00 UTC; opens
444
- // a PR if a newer clud-bug version is published. Disable by deleting the
445
- // file or pinning via .claude/skills/.clud-bug.json.
446
- // Routed through renderFile for parity (no CCA ref today but future
447
- // tokens should propagate uniformly).
448
- const selfUpdateTmpl = await renderFile(join(TEMPLATES, 'self-update.yml.tmpl'), {});
449
- const selfUpdatePath = join(cwd, '.github', 'workflows', 'clud-bug-self-update.yml');
450
- await writeFile(selfUpdatePath, selfUpdateTmpl);
451
- log(` wrote ${rel(cwd, selfUpdatePath)}`);
1289
+ // --local-only (max mode): SKIP the GitHub Action workflows entirely. The
1290
+ // review + audit workflows run `claude-code-action` with ANTHROPIC_API_KEY
1291
+ // (per-token cost); max mode reviews on the session's own subscription, so a
1292
+ // local-only install must never add a token-billing Action. (self-update is
1293
+ // benign but skipped too — local-only means no clud-bug CI workflows at all.)
1294
+ if (args.localOnly) {
1295
+ log(' skipped GitHub Action workflows (--local-only: max mode runs on the session subscription, no API key)');
1296
+ } else {
1297
+ const workflowPath = join(cwd, '.github', 'workflows', 'clud-bug-review.yml');
1298
+ await mkdir(dirname(workflowPath), { recursive: true });
1299
+ await writeFile(workflowPath, workflow);
1300
+ log(` wrote ${rel(cwd, workflowPath)}`);
1301
+
1302
+ // Install the audit workflow alongside the per-PR review one.
1303
+ // Manual-trigger by default; users opt into the cron by uncommenting.
1304
+ // Routed through renderFile so {{CCA_VERSION}} substitution pins
1305
+ // claude-code-action consistently with the review workflow.
1306
+ const auditTmpl = await renderFile(join(TEMPLATES, 'audit.yml.tmpl'), {});
1307
+ const auditPath = join(cwd, '.github', 'workflows', 'clud-bug-audit.yml');
1308
+ await writeFile(auditPath, auditTmpl);
1309
+ log(` wrote ${rel(cwd, auditPath)}`);
1310
+
1311
+ // Install the self-update workflow. Cron weekly Mondays 12:00 UTC; opens
1312
+ // a PR if a newer clud-bug version is published. Disable by deleting the
1313
+ // file or pinning via .claude/skills/.clud-bug.json.
1314
+ // Routed through renderFile for parity (no CCA ref today but future
1315
+ // tokens should propagate uniformly).
1316
+ const selfUpdateTmpl = await renderFile(join(TEMPLATES, 'self-update.yml.tmpl'), {});
1317
+ const selfUpdatePath = join(cwd, '.github', 'workflows', 'clud-bug-self-update.yml');
1318
+ await writeFile(selfUpdatePath, selfUpdateTmpl);
1319
+ log(` wrote ${rel(cwd, selfUpdatePath)}`);
1320
+ }
1321
+
1322
+ // v0.7.0 (Wave 6b): optional local-review slash command. Scaffolds
1323
+ // `.claude/commands/clud-bug-review.md` so `/clud-bug-review` works inside a
1324
+ // Claude Code session — the agent reviews the current branch's open PR using
1325
+ // THAT session's own tokens (Max or API), no hosted App or new auth required.
1326
+ // `.claude/` is already in the --commit add-list below, so it's staged too.
1327
+ // `--with-hooks` implies `--with-local-review` — the auto hook and the manual
1328
+ // `/clud-bug-review` command are complementary (the hook auto-runs the engine
1329
+ // recipe on each commit; the command runs it on demand against the PR).
1330
+ // --local-only IS the max-mode install: the slash command + the commit hook,
1331
+ // minus the GitHub Action workflows (gated above).
1332
+ if (args.localOnly) {
1333
+ args.withLocalReview = true;
1334
+ args.withHooks = true;
1335
+ }
1336
+ if (args.withHooks) args.withLocalReview = true;
1337
+ if (args.withLocalReview) {
1338
+ const commandPath = join(cwd, '.claude', 'commands', 'clud-bug-review.md');
1339
+ await mkdir(dirname(commandPath), { recursive: true });
1340
+ const commandContent = await renderFile(join(TEMPLATES, 'clud-bug-review.md.tmpl'), {});
1341
+ await writeFile(commandPath, commandContent);
1342
+ log(` wrote ${rel(cwd, commandPath)}`);
1343
+ }
1344
+
1345
+ // v0.7.0 (Wave 6b; rc.17 fix): optional native commit-review hook. Merges a
1346
+ // Claude Code `type: command` PostToolUse hook into `.claude/settings.json`
1347
+ // (preserving any existing settings + hooks) that, on every `git commit`, runs
1348
+ // `clud-bug review-prompt` and surfaces the recipe back to the session so the
1349
+ // agent reviews the commit on the session's subscription, in the background.
1350
+ // (`type: command`, not `type: agent` — agent hooks get no Bash, so they could
1351
+ // never run the CLI; see hooks.ts.)
1352
+ if (args.withHooks) {
1353
+ const { mergeLocalReviewHook, buildCommitReviewCommand } = await import('./hooks.js');
1354
+ // Floating @next pin (default) — the hook auto-fetches the latest recipe.
1355
+ const hookCommand = buildCommitReviewCommand();
1356
+ const settingsPath = join(cwd, '.claude', 'settings.json');
1357
+ await mkdir(dirname(settingsPath), { recursive: true });
1358
+ // Read-then-parse so we can tell "no file yet" (fresh merge) from "file
1359
+ // exists but is invalid JSON" (leave it ALONE — never clobber user content).
1360
+ let raw: string | undefined;
1361
+ try {
1362
+ raw = await readFile(settingsPath, 'utf8');
1363
+ } catch {
1364
+ raw = undefined; // no settings.json yet → fresh
1365
+ }
1366
+ let existing: unknown;
1367
+ let proceed = true;
1368
+ if (raw !== undefined) {
1369
+ try {
1370
+ existing = JSON.parse(raw);
1371
+ } catch {
1372
+ proceed = false;
1373
+ log(` skipped ${rel(cwd, settingsPath)} (existing file is not valid JSON; left untouched)`);
1374
+ }
1375
+ }
1376
+ if (proceed) {
1377
+ const merged = mergeLocalReviewHook(existing, hookCommand);
1378
+ await writeFile(settingsPath, JSON.stringify(merged, null, 2) + '\n');
1379
+ log(` wrote ${rel(cwd, settingsPath)} (commit-review hook)`);
1380
+ }
1381
+ }
1382
+
1383
+ // rc.16: --with-design installs the bundled design-critic kit (3 `kind:
1384
+ // design` skills) and enables the off-by-default design lens. writeSkills
1385
+ // writes the SKILL.md files + merges the manifest entries; the `design` block
1386
+ // is flipped on the manifest read below so the local recipe + hosted bot both
1387
+ // run the visual pass. Idempotent — re-runs replace the skills in place.
1388
+ if (args.withDesign) {
1389
+ const designKit = await loadDesignKit(DESIGN_DIR);
1390
+ if (designKit.length > 0) {
1391
+ await writeSkills(join(cwd, '.claude', 'skills'), designKit, client);
1392
+ log(` pinned ${designKit.length} design specimens (visual review lens enabled)`);
1393
+ } else {
1394
+ warn('No design-kit skills found to install (templates/skills/design/ empty?).');
1395
+ }
1396
+ }
452
1397
 
453
1398
  // Stamp the manifest. Sets strictMode: true ONLY on fresh installs —
454
1399
  // a manifest that's never been touched by clud-bug init/update has no
@@ -458,6 +1403,12 @@ async function runInit(args) {
458
1403
  // fresh inits. Users opt out by setting strictMode: false.
459
1404
  const skillsDirPath = join(cwd, '.claude', 'skills');
460
1405
  const manifest = await readManifest(skillsDirPath);
1406
+ if (args.withDesign) {
1407
+ // Flip the off-by-default design lens on; preserve any existing knobs
1408
+ // (gate / themes / viewports) the user already set.
1409
+ const prior = (manifest.design as Record<string, unknown> | undefined) ?? {};
1410
+ manifest.design = { ...prior, enabled: true };
1411
+ }
461
1412
  const isFreshInstall = manifest.lastUpdate === undefined;
462
1413
  manifest.lastUpdateVersion = await readPkgVersion();
463
1414
  manifest.lastUpdate = new Date().toISOString();
@@ -487,9 +1438,14 @@ async function runInit(args) {
487
1438
  log(' committing...');
488
1439
  const toAdd = [
489
1440
  '.claude',
490
- '.github/workflows/clud-bug-review.yml',
491
- '.github/workflows/clud-bug-audit.yml',
492
- '.github/workflows/clud-bug-self-update.yml',
1441
+ // No GitHub Action workflows under --local-only (none were written).
1442
+ ...(args.localOnly
1443
+ ? []
1444
+ : [
1445
+ '.github/workflows/clud-bug-review.yml',
1446
+ '.github/workflows/clud-bug-audit.yml',
1447
+ '.github/workflows/clud-bug-self-update.yml',
1448
+ ]),
493
1449
  ...agentDocs.created,
494
1450
  ...agentDocs.touched,
495
1451
  ];
@@ -506,21 +1462,40 @@ async function runInit(args) {
506
1462
 
507
1463
  log('');
508
1464
  log('Field kit assembled. Next:');
509
- log(' 1. Set ANTHROPIC_API_KEY in your repo secrets:');
510
- log(' Settings Secrets and variables Actions New repository secret');
511
- if (!args.commit) {
512
- log(' 2. git add .claude .github/workflows/clud-bug-*.yml && git commit && git push');
513
- log(' 3. Open a PR — the naturalist arrives within ~2 minutes.');
1465
+ if (args.localOnly) {
1466
+ log(' Max mode reviews run inside your Claude Code session on your own');
1467
+ log(' subscription. No API key, no GitHub Action, no per-review bill. On every');
1468
+ log(' commit the hook surfaces a review recipe; or run /clud-bug-review on demand.');
1469
+ if (!args.commit) {
1470
+ log(' → git add .claude && git commit && git push');
1471
+ } else {
1472
+ log(' → git push.');
1473
+ }
514
1474
  } else {
515
- log(' 2. git push, then open a PR — the naturalist arrives within ~2 minutes.');
1475
+ log(' 1. Set ANTHROPIC_API_KEY in your repo secrets:');
1476
+ log(' Settings → Secrets and variables → Actions → New repository secret');
1477
+ if (!args.commit) {
1478
+ log(' 2. git add .claude .github/workflows/clud-bug-*.yml && git commit && git push');
1479
+ log(' 3. Open a PR — the naturalist arrives within ~2 minutes.');
1480
+ } else {
1481
+ log(' 2. git push, then open a PR — the naturalist arrives within ~2 minutes.');
1482
+ }
516
1483
  }
517
1484
  log('');
518
1485
  log('Drop your own .claude/skills/<name>/SKILL.md files anytime — they get pinned automatically.');
519
- log('For a whole-repo walk: Actions tab Clud Bug 🐛 Audit → Run workflow.');
520
- log('Self-update is on (weekly Mondays 12:00 UTC). Pin via "pinVersion" in .claude/skills/.clud-bug.json.');
1486
+ // These reference the GitHub Action workflows, which --local-only does not write.
1487
+ if (!args.localOnly) {
1488
+ log('For a whole-repo walk: Actions tab → Clud Bug 🐛 Audit → Run workflow.');
1489
+ log('Self-update is on (weekly Mondays 12:00 UTC). Pin via "pinVersion" in .claude/skills/.clud-bug.json.');
1490
+ }
521
1491
  log('');
522
- log('Strict mode is ON by default (clud-bug-review fails the check on critical findings).');
523
- log(' Add `clud-bug-review` to your branch protection required checks for full enforcement.');
1492
+ if (args.localOnly) {
1493
+ log('Strict mode + branch-protection enforcement apply to the GitHub Action path; max mode is');
1494
+ log('advisory by design — findings surface in your session for you to act on.');
1495
+ } else {
1496
+ log('Strict mode is ON by default (clud-bug-review fails the check on critical findings).');
1497
+ log(' • Add `clud-bug-review` to your branch protection required checks for full enforcement.');
1498
+ }
524
1499
  log(' • Opt out by setting "strictMode": false in .claude/skills/.clud-bug.json.');
525
1500
 
526
1501
  // v0.6.33 — opt-in unified install (mirror of logmind v0.6.8). When
@@ -988,6 +1963,25 @@ async function runUpdateCmd(_args) {
988
1963
  ok(`updated: @v${ourVersion}, ${result.changed.length} changed, ${result.unchanged.length} unchanged${skipped.length ? `, ${skipped.length} skipped` : ''}`);
989
1964
  }
990
1965
 
1966
+ // v0.7.0-rc.4 — `clud-bug configure-github <owner>/<repo>`. Pulls in the
1967
+ // SPEC §7 canonical ruleset applier from src/cli/configure-github.ts;
1968
+ // thin wrapper here just maps CLI args into the typed entry point. The
1969
+ // command is idempotent — re-runs on a converged repo exit 0 with no
1970
+ // PATCH calls. See `src/core/configure-github.ts` for the diff + rule
1971
+ // table.
1972
+ async function runConfigureGithubCmd(args) {
1973
+ const { runConfigureGithub } = await import('./configure-github.js');
1974
+ const target = args._[1] ?? null;
1975
+ const code = await runConfigureGithub({
1976
+ target,
1977
+ branch: args.branch || 'main',
1978
+ dryRun: Boolean(args.dryRun),
1979
+ quiet: QUIET,
1980
+ json: Boolean(args.json),
1981
+ });
1982
+ if (code !== 0) process.exit(code);
1983
+ }
1984
+
991
1985
  async function runAudit(args) {
992
1986
  const cwd = process.cwd();
993
1987
  const date = new Date().toISOString().slice(0, 10);