clud-bug 0.7.0-rc.6 → 0.7.0-rc.8

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.
@@ -1 +1 @@
1
- {"version":3,"file":"main.d.ts","sourceRoot":"","sources":["../../src/cli/main.ts"],"names":[],"mappings":"AAuMA,iBAAe,IAAI,kBA0BlB;AAwzCD,OAAO,EAAE,IAAI,EAAE,CAAC"}
1
+ {"version":3,"file":"main.d.ts","sourceRoot":"","sources":["../../src/cli/main.ts"],"names":[],"mappings":"AA0NA,iBAAe,IAAI,kBA4BlB;AA8yDD,OAAO,EAAE,IAAI,EAAE,CAAC"}
package/dist/cli/main.js CHANGED
@@ -175,6 +175,25 @@ Commands:
175
175
  STRICT_MODE ("true"/"false"). Empty/malformed stdin →
176
176
  exits 0 with "skip" on stdout (no-op; workflow degrades
177
177
  gracefully to the existing comment-only behavior).
178
+ post-inline-threads Post per-finding inline review threads (D.2.X). Reads a
179
+ --stdin structured-output JSON payload from stdin, fetches the
180
+ PR diff via \`gh api\`, posts one batched review with one
181
+ comment per anchorable finding. Each comment body carries
182
+ a hidden \`<!-- finding-id: <hash> --> \`marker so Wave 5b
183
+ auto-resolve can re-match findings on subsequent pushes
184
+ without persistent state. Required env vars: GH_TOKEN,
185
+ REPO ("owner/name"), PR_NUMBER, HEAD_SHA. Output is a
186
+ JSON status report \`{posted, skipped, preexisting, error?}\`.
187
+ resolve-threads Auto-resolve inline review threads when a fix-push
188
+ addresses the original finding (D.2.6). Fetches all
189
+ bot-authored unresolved threads, asks the Anthropic
190
+ Messages API per-thread whether the new commit addressed
191
+ the concern, then resolves (with a marker reply)
192
+ ADDRESSED threads + leaves UNCERTAIN/NOT_ADDRESSED open.
193
+ Fail-closed: verifier errors route to UNCERTAIN.
194
+ Required env vars: GH_TOKEN, ANTHROPIC_API_KEY,
195
+ REPO ("owner/name"), PR_NUMBER. Output: JSON
196
+ \`{actions, verifierCallCount, shouldRequestChanges}\`.
178
197
 
179
198
  Options:
180
199
  --offline Skip skills.sh; pin only the bundled baseline specimens.
@@ -237,6 +256,8 @@ async function main() {
237
256
  case 'render': return runRender(args);
238
257
  case 'update-skill-usage': return runUpdateSkillUsage(args);
239
258
  case 'select-review-event': return runSelectReviewEvent(args);
259
+ case 'post-inline-threads': return runPostInlineThreads(args);
260
+ case 'resolve-threads': return runResolveThreads(args);
240
261
  default:
241
262
  process.stderr.write(`Unknown command: ${cmd || '(none)'}\n\n${HELP}`);
242
263
  process.exit(2);
@@ -492,6 +513,435 @@ async function runSelectReviewEvent(args) {
492
513
  });
493
514
  process.stdout.write(event + '\n');
494
515
  }
516
+ // Wave 5a / 0.7.0-rc.7: post per-finding inline review threads (D.2.X)
517
+ // to the PR. Replaces the legacy "one summary comment listing every
518
+ // finding" UX with first-class GitHub review threads users can reply to
519
+ // and (in Wave 5b) the bot can auto-resolve on fix-push.
520
+ //
521
+ // Pipeline:
522
+ // 1. Read the structured review JSON from stdin (same shape the
523
+ // `render` and `update-skill-usage` verbs consume).
524
+ // 2. Fetch the PR's per-file diff via `gh api repos/.../pulls/N/files`.
525
+ // 3. Call `planInlineThreads(findings, diffFiles)` from
526
+ // `clud-bug/core/inline-threads` to partition into anchored
527
+ // `comments[]` + skipped + preexisting buckets.
528
+ // 4. If any comments survive: POST one batched review via
529
+ // `gh api -X POST repos/.../pulls/N/reviews` with `event: COMMENT`.
530
+ // Each comment body carries a hidden `<!-- finding-id: <hash> -->`
531
+ // marker so Wave 5b auto-resolve can re-derive the same ids on
532
+ // subsequent pushes without a persistent store.
533
+ // 5. Emit a JSON summary on stdout (`{posted, skipped, preexisting}`).
534
+ //
535
+ // Required env vars (the workflow template provides all of them):
536
+ // GH_TOKEN, REPO ("owner/name"), PR_NUMBER, HEAD_SHA
537
+ //
538
+ // Failure posture: any `gh api` failure is logged to stderr + emitted in
539
+ // the stdout JSON as `error`, but the verb exits 0 so the surrounding
540
+ // workflow step's `continue-on-error: true` is the single source of
541
+ // failure semantics. Mirrors the `render` / `update-skill-usage` posture.
542
+ async function runPostInlineThreads(args) {
543
+ const { planInlineThreads } = await import('../core/inline-threads.js');
544
+ if (!args.stdin) {
545
+ process.stderr.write('clud-bug post-inline-threads: --stdin is required.\n');
546
+ process.stdout.write(JSON.stringify({ posted: 0, skipped: [], preexisting: [], error: 'no-stdin' }) + '\n');
547
+ return;
548
+ }
549
+ let raw = '';
550
+ for await (const chunk of process.stdin)
551
+ raw += chunk;
552
+ raw = raw.trim();
553
+ if (!raw) {
554
+ process.stderr.write('clud-bug post-inline-threads: stdin empty — no-op.\n');
555
+ process.stdout.write(JSON.stringify({ posted: 0, skipped: [], preexisting: [], error: 'empty-stdin' }) + '\n');
556
+ return;
557
+ }
558
+ let payload;
559
+ try {
560
+ payload = JSON.parse(raw);
561
+ }
562
+ catch (e) {
563
+ process.stderr.write(`clud-bug post-inline-threads: JSON parse failed: ${e.message} — no-op.\n`);
564
+ process.stdout.write(JSON.stringify({ posted: 0, skipped: [], preexisting: [], error: 'parse-failed' }) + '\n');
565
+ return;
566
+ }
567
+ if (!payload || typeof payload !== 'object') {
568
+ process.stderr.write('clud-bug post-inline-threads: payload must be a JSON object — no-op.\n');
569
+ process.stdout.write(JSON.stringify({ posted: 0, skipped: [], preexisting: [], error: 'not-object' }) + '\n');
570
+ return;
571
+ }
572
+ // Pull findings from the structured_output shape — same field names as
573
+ // the SPEC §1.8.1 review schema the model emits.
574
+ const findings = [];
575
+ for (const f of Array.isArray(payload.critical_findings) ? payload.critical_findings : []) {
576
+ if (f && typeof f === 'object') {
577
+ findings.push({ ...f, severity: 'critical' });
578
+ }
579
+ }
580
+ for (const f of Array.isArray(payload.minor_findings) ? payload.minor_findings : []) {
581
+ if (f && typeof f === 'object') {
582
+ findings.push({ ...f, severity: 'minor' });
583
+ }
584
+ }
585
+ // `preexisting_findings` are intentionally not threaded (informational
586
+ // about prior code; not a reason to block this PR). `planInlineThreads`
587
+ // partitions them into the `preexisting` bucket if any are included, but
588
+ // the workflow path doesn't ship them — keep the payload focused.
589
+ if (findings.length === 0) {
590
+ process.stdout.write(JSON.stringify({ posted: 0, skipped: [], preexisting: [], reason: 'no-findings' }) + '\n');
591
+ return;
592
+ }
593
+ // Required env vars.
594
+ const repo = String(process.env.REPO ?? '').trim();
595
+ const prNumberRaw = String(process.env.PR_NUMBER ?? '').trim();
596
+ const headSha = String(process.env.HEAD_SHA ?? '').trim();
597
+ const prNumber = Number(prNumberRaw);
598
+ if (!repo || !repo.includes('/') || !Number.isInteger(prNumber) || prNumber <= 0 || !headSha) {
599
+ 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`);
600
+ process.stdout.write(JSON.stringify({ posted: 0, skipped: [], preexisting: [], error: 'missing-env' }) + '\n');
601
+ return;
602
+ }
603
+ // Fetch the PR's diff. `gh api repos/.../pulls/N/files --paginate`
604
+ // returns a JSON array of {filename, patch, status, ...}. The single
605
+ // -slurp on multi-page output stitches all pages into one big array.
606
+ const filesResult = spawnSync('gh', [
607
+ 'api',
608
+ `repos/${repo}/pulls/${prNumber}/files`,
609
+ '--paginate',
610
+ '--slurp',
611
+ ], { encoding: 'utf8' });
612
+ if (filesResult.status !== 0) {
613
+ process.stderr.write(`clud-bug post-inline-threads: \`gh api .../files\` failed (exit ${filesResult.status}): ${(filesResult.stderr || '').slice(0, 500)}\n`);
614
+ process.stdout.write(JSON.stringify({ posted: 0, skipped: [], preexisting: [], error: 'diff-fetch-failed' }) + '\n');
615
+ return;
616
+ }
617
+ let diffFiles = [];
618
+ try {
619
+ // --slurp wraps each page in an outer array; flatten one level.
620
+ const pages = JSON.parse(filesResult.stdout);
621
+ if (Array.isArray(pages)) {
622
+ for (const page of pages) {
623
+ if (Array.isArray(page))
624
+ diffFiles.push(...page);
625
+ }
626
+ }
627
+ }
628
+ catch (e) {
629
+ process.stderr.write(`clud-bug post-inline-threads: diff JSON parse failed: ${e.message}\n`);
630
+ process.stdout.write(JSON.stringify({ posted: 0, skipped: [], preexisting: [], error: 'diff-parse-failed' }) + '\n');
631
+ return;
632
+ }
633
+ const plan = planInlineThreads(findings, diffFiles);
634
+ if (plan.comments.length === 0) {
635
+ // Everything fell through to summary-comment fallback (no anchor
636
+ // matches). Not a failure — emit the breakdown so the workflow log
637
+ // explains why no inline threads were posted.
638
+ process.stdout.write(JSON.stringify({
639
+ posted: 0,
640
+ skipped: plan.skipped.map((f) => ({ skill: f.skill, file: f.file, line: f.line, reason: 'not-anchorable' })),
641
+ preexisting: plan.preexisting.map((f) => ({ skill: f.skill })),
642
+ reason: 'no-anchorable-findings',
643
+ }) + '\n');
644
+ return;
645
+ }
646
+ // Post the review. gh accepts the body as a JSON file via --input — we
647
+ // pipe through stdin with `--input -`. Build the body as JSON-on-one-line
648
+ // so stdin is a single write that `gh` reads start-to-finish.
649
+ const reviewBody = JSON.stringify({
650
+ commit_id: headSha,
651
+ event: 'COMMENT',
652
+ body: `Clud-Bug posted ${plan.comments.length} inline finding${plan.comments.length === 1 ? '' : 's'}.`,
653
+ comments: plan.comments,
654
+ });
655
+ const postResult = spawnSync('gh', [
656
+ 'api',
657
+ '--method', 'POST',
658
+ `repos/${repo}/pulls/${prNumber}/reviews`,
659
+ '--input', '-',
660
+ ], { encoding: 'utf8', input: reviewBody });
661
+ if (postResult.status !== 0) {
662
+ process.stderr.write(`clud-bug post-inline-threads: \`gh api -X POST .../reviews\` failed (exit ${postResult.status}): ${(postResult.stderr || '').slice(0, 500)}\n`);
663
+ process.stdout.write(JSON.stringify({
664
+ posted: 0,
665
+ skipped: plan.skipped.map((f) => ({ skill: f.skill, file: f.file, line: f.line, reason: 'not-anchorable' })),
666
+ preexisting: plan.preexisting.map((f) => ({ skill: f.skill })),
667
+ error: 'review-post-failed',
668
+ }) + '\n');
669
+ return;
670
+ }
671
+ process.stdout.write(JSON.stringify({
672
+ posted: plan.comments.length,
673
+ skipped: plan.skipped.map((f) => ({ skill: f.skill, file: f.file, line: f.line, reason: 'not-anchorable' })),
674
+ preexisting: plan.preexisting.map((f) => ({ skill: f.skill })),
675
+ }) + '\n');
676
+ }
677
+ // Wave 5b / 0.7.0-rc.8: D.2.6 auto-resolve on fix-push. Fetches all
678
+ // bot-authored unresolved inline threads, asks the Anthropic Messages
679
+ // API per-thread whether the new commit addressed the original
680
+ // finding, and resolves ADDRESSED threads (with a marker reply) while
681
+ // leaving UNCERTAIN/NOT_ADDRESSED open. Fail-closed throughout —
682
+ // verifier errors route to UNCERTAIN; the surrounding workflow
683
+ // step's `continue-on-error: true` is the single failure gate.
684
+ //
685
+ // Required env: GH_TOKEN, ANTHROPIC_API_KEY, REPO ("owner/name"), PR_NUMBER.
686
+ //
687
+ // No `--stdin` — fetches everything from `gh api graphql` itself.
688
+ // Workflow gates this verb on `github.event.action == 'synchronize'`
689
+ // so it only runs on fix-pushes (not initial PR opens, when there
690
+ // are no prior threads to resolve).
691
+ async function runResolveThreads(_args) {
692
+ const { parseThreadBody, extractAnchorContext, REVIEW_THREADS_QUERY, RESOLVE_THREAD_MUTATION, ADD_REPLY_MUTATION, } = await import('../core/inline-threads.js');
693
+ const { readAutoResolveConfigFromCludBug, runAutoResolve, } = await import('../core/auto-resolve.js');
694
+ const { VERIFIER_SYSTEM, buildVerifierPrompt, parseVerifierResponse, } = await import('../core/resolve-verifier.js');
695
+ // ---- Env validation ----------------------------------------------------
696
+ const repo = String(process.env.REPO ?? '').trim();
697
+ const prNumberRaw = String(process.env.PR_NUMBER ?? '').trim();
698
+ const prNumber = Number(prNumberRaw);
699
+ const ghToken = String(process.env.GH_TOKEN ?? '').trim();
700
+ const anthropicKey = String(process.env.ANTHROPIC_API_KEY ?? '').trim();
701
+ if (!repo || !repo.includes('/') || !Number.isInteger(prNumber) || prNumber <= 0 || !ghToken || !anthropicKey) {
702
+ process.stderr.write(`clud-bug resolve-threads: REPO + PR_NUMBER + GH_TOKEN + ANTHROPIC_API_KEY env vars required.\n`);
703
+ process.stdout.write(JSON.stringify({ actions: [], verifierCallCount: 0, shouldRequestChanges: false, error: 'missing-env' }) + '\n');
704
+ return;
705
+ }
706
+ const [owner, repoName] = repo.split('/', 2);
707
+ // ---- Optional .clud-bug.json config (autoResolve block) ----------------
708
+ // The workflow's working dir is the PR checkout — if .clud-bug.json is
709
+ // present we read autoResolve from it. Otherwise defaults (mode='verified').
710
+ let autoResolveConfig;
711
+ try {
712
+ const cfgPath = join(process.cwd(), '.claude/skills/.clud-bug.json');
713
+ const cfgRaw = await readFile(cfgPath, 'utf8');
714
+ const cfg = JSON.parse(cfgRaw);
715
+ autoResolveConfig = readAutoResolveConfigFromCludBug(cfg, (msg) => {
716
+ process.stderr.write(`clud-bug resolve-threads: config warning: ${msg}\n`);
717
+ });
718
+ }
719
+ catch (err) {
720
+ // Missing OR unparseable .clud-bug.json — use defaults but log the
721
+ // specific reason so an operator with malformed config can diagnose
722
+ // why their `autoResolve.mode = 'off'` is being silently ignored.
723
+ const code = err && typeof err === 'object' && 'code' in err ? err.code : undefined;
724
+ if (code !== 'ENOENT') {
725
+ 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`);
726
+ }
727
+ autoResolveConfig = readAutoResolveConfigFromCludBug(null);
728
+ }
729
+ if (autoResolveConfig.mode === 'off') {
730
+ process.stdout.write(JSON.stringify({ actions: [], verifierCallCount: 0, shouldRequestChanges: false, reason: 'mode-off' }) + '\n');
731
+ return;
732
+ }
733
+ // ---- Fetch threads via GraphQL -----------------------------------------
734
+ // `gh api graphql` uses `-f` for String! variables and `-F` for typed
735
+ // (number / bool) variables. Owner + repo are String! per the query;
736
+ // pr is Int!. Reviewer-flagged: passing owner/repo as `-F` would coerce
737
+ // numeric-looking values incorrectly.
738
+ const threadsResult = spawnSync('gh', [
739
+ 'api', 'graphql',
740
+ '-f', `query=${REVIEW_THREADS_QUERY}`,
741
+ '-f', `owner=${owner}`,
742
+ '-f', `repo=${repoName}`,
743
+ '-F', `pr=${prNumber}`,
744
+ ], { encoding: 'utf8' });
745
+ if (threadsResult.status !== 0) {
746
+ process.stderr.write(`clud-bug resolve-threads: gh api graphql (threads) failed (exit ${threadsResult.status}): ${(threadsResult.stderr || '').slice(0, 500)}\n`);
747
+ process.stdout.write(JSON.stringify({ actions: [], verifierCallCount: 0, shouldRequestChanges: false, error: 'threads-fetch-failed' }) + '\n');
748
+ return;
749
+ }
750
+ let threadNodes;
751
+ try {
752
+ const parsed = JSON.parse(threadsResult.stdout);
753
+ threadNodes = parsed?.data?.repository?.pullRequest?.reviewThreads?.nodes ?? [];
754
+ if (!Array.isArray(threadNodes))
755
+ threadNodes = [];
756
+ }
757
+ catch (e) {
758
+ process.stderr.write(`clud-bug resolve-threads: threads JSON parse failed: ${e.message}\n`);
759
+ process.stdout.write(JSON.stringify({ actions: [], verifierCallCount: 0, shouldRequestChanges: false, error: 'threads-parse-failed' }) + '\n');
760
+ return;
761
+ }
762
+ // ---- Filter to bot-authored unresolved threads with our marker --------
763
+ // The workflow's GITHUB_TOKEN posts as `github-actions[bot]`. Match both
764
+ // the bracketed bot form and the bare `github-actions` form (GraphQL
765
+ // can return either depending on the node-type field requested).
766
+ const BOT_AUTHORS = new Set(['github-actions', 'github-actions[bot]']);
767
+ const candidates = [];
768
+ for (const t of threadNodes) {
769
+ if (!t || t.isResolved)
770
+ continue;
771
+ const c = t.comments?.nodes?.[0];
772
+ if (!c)
773
+ continue;
774
+ const authorLogin = c.author?.login ?? '';
775
+ if (!BOT_AUTHORS.has(authorLogin))
776
+ continue;
777
+ const parsed = parseThreadBody(c.body ?? '');
778
+ if (!parsed)
779
+ continue;
780
+ if (!c.path || (c.line === null && c.originalLine === null))
781
+ continue;
782
+ candidates.push({
783
+ threadId: t.id,
784
+ file: c.path,
785
+ line: c.line ?? c.originalLine,
786
+ parsed,
787
+ });
788
+ }
789
+ if (candidates.length === 0) {
790
+ process.stdout.write(JSON.stringify({ actions: [], verifierCallCount: 0, shouldRequestChanges: false, reason: 'no-bot-threads' }) + '\n');
791
+ return;
792
+ }
793
+ // ---- Fetch diff for anchor context -------------------------------------
794
+ const filesResult = spawnSync('gh', [
795
+ 'api',
796
+ `repos/${repo}/pulls/${prNumber}/files`,
797
+ '--paginate',
798
+ '--slurp',
799
+ ], { encoding: 'utf8' });
800
+ if (filesResult.status !== 0) {
801
+ process.stderr.write(`clud-bug resolve-threads: diff fetch failed (exit ${filesResult.status}): ${(filesResult.stderr || '').slice(0, 500)}\n`);
802
+ process.stdout.write(JSON.stringify({ actions: [], verifierCallCount: 0, shouldRequestChanges: false, error: 'diff-fetch-failed' }) + '\n');
803
+ return;
804
+ }
805
+ let diffFiles = [];
806
+ try {
807
+ const pages = JSON.parse(filesResult.stdout);
808
+ if (Array.isArray(pages)) {
809
+ for (const page of pages) {
810
+ if (Array.isArray(page))
811
+ diffFiles.push(...page);
812
+ }
813
+ }
814
+ }
815
+ catch (e) {
816
+ process.stderr.write(`clud-bug resolve-threads: diff JSON parse failed: ${e.message}\n`);
817
+ process.stdout.write(JSON.stringify({ actions: [], verifierCallCount: 0, shouldRequestChanges: false, error: 'diff-parse-failed' }) + '\n');
818
+ return;
819
+ }
820
+ // ---- Build PriorThread[] for runAutoResolve ----------------------------
821
+ const priorThreads = candidates.map((c) => {
822
+ const ctx = extractAnchorContext({ file: c.file, line: c.line }, diffFiles);
823
+ const findingBody = c.parsed.reasoning
824
+ ? `${c.parsed.summary}\n\n${c.parsed.reasoning}`
825
+ : c.parsed.summary;
826
+ return {
827
+ threadId: c.threadId,
828
+ finding: {
829
+ severity: c.parsed.severity,
830
+ body: findingBody,
831
+ skill: c.parsed.skill,
832
+ file: c.file,
833
+ line: c.line,
834
+ },
835
+ codeBefore: ctx.codeBefore,
836
+ codeAfter: ctx.codeAfter,
837
+ ...(ctx.diffAtAnchor !== undefined ? { diffAtAnchor: ctx.diffAtAnchor } : {}),
838
+ };
839
+ });
840
+ // ---- Define verifier (Anthropic Messages API via raw fetch) -----------
841
+ // Model is hardcoded here — the verifier needs a model that handles
842
+ // structured JSON output reliably. Sonnet 4.6 is the default. Override
843
+ // via CLUD_BUG_VERIFIER_MODEL env var if needed.
844
+ const verifierModel = String(process.env.CLUD_BUG_VERIFIER_MODEL ?? '').trim()
845
+ || 'claude-sonnet-4-6';
846
+ async function verifier(input) {
847
+ const userPrompt = buildVerifierPrompt(input);
848
+ try {
849
+ const resp = await fetch('https://api.anthropic.com/v1/messages', {
850
+ method: 'POST',
851
+ headers: {
852
+ 'content-type': 'application/json',
853
+ 'anthropic-version': '2023-06-01',
854
+ 'x-api-key': anthropicKey,
855
+ },
856
+ body: JSON.stringify({
857
+ model: verifierModel,
858
+ max_tokens: 300,
859
+ system: VERIFIER_SYSTEM,
860
+ messages: [{ role: 'user', content: userPrompt }],
861
+ }),
862
+ });
863
+ if (!resp.ok) {
864
+ const errText = await resp.text().catch(() => '');
865
+ return {
866
+ verdict: 'UNCERTAIN',
867
+ source: 'api-error',
868
+ rationale: `Anthropic API ${resp.status}: ${errText.slice(0, 200)}`,
869
+ };
870
+ }
871
+ const body = await resp.json();
872
+ // Messages API: content is an array of blocks; pick the first text block.
873
+ const text = (body?.content ?? []).find((b) => b?.type === 'text')?.text ?? '';
874
+ return parseVerifierResponse(text);
875
+ }
876
+ catch (err) {
877
+ return {
878
+ verdict: 'UNCERTAIN',
879
+ source: 'api-error',
880
+ rationale: `verifier fetch error: ${err?.message ?? String(err)}`,
881
+ };
882
+ }
883
+ }
884
+ // ---- Run pure orchestration -------------------------------------------
885
+ const result = await runAutoResolve({
886
+ priorThreads,
887
+ config: autoResolveConfig,
888
+ verifier,
889
+ });
890
+ // ---- Execute the actions via GraphQL mutations ------------------------
891
+ const actionsReport = [];
892
+ for (let i = 0; i < result.actions.length; i++) {
893
+ const action = result.actions[i];
894
+ const thread = priorThreads[i];
895
+ if (!action || !thread)
896
+ continue;
897
+ const report = {
898
+ threadId: thread.threadId,
899
+ file: thread.finding.file,
900
+ line: thread.finding.line,
901
+ verdict: action.verdict?.verdict,
902
+ kind: action.kind,
903
+ };
904
+ if (action.kind === 'skipped') {
905
+ actionsReport.push({ ...report, executed: 'skipped' });
906
+ continue;
907
+ }
908
+ // Post the marker reply (all non-skipped paths get a reply).
909
+ const replyResult = spawnSync('gh', [
910
+ 'api', 'graphql',
911
+ '-f', `query=${ADD_REPLY_MUTATION}`,
912
+ '-f', `threadId=${thread.threadId}`,
913
+ '-f', `body=${action.markerBody}`,
914
+ ], { encoding: 'utf8' });
915
+ if (replyResult.status !== 0) {
916
+ process.stderr.write(`clud-bug resolve-threads: ADD_REPLY failed for thread ${thread.threadId}: ${(replyResult.stderr || '').slice(0, 200)}\n`);
917
+ actionsReport.push({ ...report, executed: 'reply-failed' });
918
+ continue;
919
+ }
920
+ // Resolve only ADDRESSED threads.
921
+ if (action.kind === 'resolve') {
922
+ const resolveResult = spawnSync('gh', [
923
+ 'api', 'graphql',
924
+ '-f', `query=${RESOLVE_THREAD_MUTATION}`,
925
+ '-f', `threadId=${thread.threadId}`,
926
+ ], { encoding: 'utf8' });
927
+ if (resolveResult.status !== 0) {
928
+ process.stderr.write(`clud-bug resolve-threads: RESOLVE failed for thread ${thread.threadId}: ${(resolveResult.stderr || '').slice(0, 200)}\n`);
929
+ actionsReport.push({ ...report, executed: 'resolve-failed' });
930
+ continue;
931
+ }
932
+ actionsReport.push({ ...report, executed: 'resolved' });
933
+ }
934
+ else {
935
+ // keep_open or keep_open_request_changes — reply already posted.
936
+ actionsReport.push({ ...report, executed: 'kept-open' });
937
+ }
938
+ }
939
+ process.stdout.write(JSON.stringify({
940
+ actions: actionsReport,
941
+ verifierCallCount: result.verifierCallCount,
942
+ shouldRequestChanges: result.shouldRequestChanges,
943
+ }) + '\n');
944
+ }
495
945
  // 0.0.E (v0.6.17): thin wrapper around the golden-set test file. Devs
496
946
  // who follow the README invoke `clud-bug eval` — this routes to the
497
947
  // same `node --test` runner CI uses, so dev and CI verdicts match.