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