mandrel 2.36.0 → 2.37.0

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 (88) hide show
  1. package/.agents/docs/configuration.md +25 -0
  2. package/.agents/rules/testing-standards.md +56 -0
  3. package/.agents/scripts/boot-sweep.js +7 -1
  4. package/.agents/scripts/check-context-budget.js +195 -103
  5. package/.agents/scripts/check-cyclomatic.js +112 -42
  6. package/.agents/scripts/check-generated-validator.js +202 -0
  7. package/.agents/scripts/check-lifecycle-lint.js +2 -68
  8. package/.agents/scripts/check-schema-references.js +2 -28
  9. package/.agents/scripts/drain-pending-cleanup.js +18 -1
  10. package/.agents/scripts/lib/baselines/components.js +32 -2
  11. package/.agents/scripts/lib/baselines/env-overrides.js +1 -1
  12. package/.agents/scripts/lib/bootstrap/branch-protection.js +1 -1
  13. package/.agents/scripts/lib/bootstrap/install-ledger.js +1 -1
  14. package/.agents/scripts/lib/bootstrap/prompt.js +1 -1
  15. package/.agents/scripts/lib/bootstrap/quality-bootstrap.js +2 -2
  16. package/.agents/scripts/lib/checks/loop-health.js +1 -1
  17. package/.agents/scripts/lib/cli-args.js +0 -2
  18. package/.agents/scripts/lib/close-validation/gates.js +1 -1
  19. package/.agents/scripts/lib/close-validation/process.js +1 -1
  20. package/.agents/scripts/lib/config/sync-agentrc.js +1 -1
  21. package/.agents/scripts/lib/config-settings-schema.js +46 -6
  22. package/.agents/scripts/lib/coverage-baseline.js +2 -2
  23. package/.agents/scripts/lib/cpu-pool.js +90 -10
  24. package/.agents/scripts/lib/crap-utils.js +6 -2
  25. package/.agents/scripts/lib/cyclomatic-ceiling.js +28 -6
  26. package/.agents/scripts/lib/cyclomatic-scope.js +144 -0
  27. package/.agents/scripts/lib/dependency-version.js +86 -0
  28. package/.agents/scripts/lib/duplicate-search.js +1 -1
  29. package/.agents/scripts/lib/errors/index.js +1 -1
  30. package/.agents/scripts/lib/feedback-loop/graduator-core.js +2 -2
  31. package/.agents/scripts/lib/feedback-loop/prior-feedback-fetcher.js +1 -1
  32. package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +1 -1
  33. package/.agents/scripts/lib/generated/agentrc-validator.js +16 -0
  34. package/.agents/scripts/lib/install-cmd-parser.js +1 -1
  35. package/.agents/scripts/lib/knip-entry-sync.js +1 -57
  36. package/.agents/scripts/lib/maintainability-utils.js +108 -10
  37. package/.agents/scripts/lib/observability/source-classifier.js +1 -0
  38. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +70 -9
  39. package/.agents/scripts/lib/orchestration/pr-watch.js +1 -30
  40. package/.agents/scripts/lib/orchestration/reassert-status-column.js +2 -2
  41. package/.agents/scripts/lib/orchestration/review-providers/codex.js +3 -3
  42. package/.agents/scripts/lib/orchestration/review-providers/findings-renderer.js +1 -1
  43. package/.agents/scripts/lib/orchestration/review-providers/native.js +9 -2
  44. package/.agents/scripts/lib/orchestration/review-providers/review-depth.js +2 -2
  45. package/.agents/scripts/lib/orchestration/review-providers/security-review.js +3 -3
  46. package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +52 -1
  47. package/.agents/scripts/lib/orchestration/story-close/format-autofix.js +2 -5
  48. package/.agents/scripts/lib/orchestration/story-follow-ups.js +1 -1
  49. package/.agents/scripts/lib/orchestration/ticket-lease.js +34 -9
  50. package/.agents/scripts/lib/orchestration/ticketing/reads.js +3 -1
  51. package/.agents/scripts/lib/preflight-runner.js +3 -3
  52. package/.agents/scripts/lib/qa/qa-session.js +1 -1
  53. package/.agents/scripts/lib/runtime-deps/manifest.js +1 -7
  54. package/.agents/scripts/lib/runtime-deps/scan-imports.js +3 -58
  55. package/.agents/scripts/lib/single-story-sweep/sweep-lock.js +284 -25
  56. package/.agents/scripts/lib/skills/walk-skill-files.js +1 -1
  57. package/.agents/scripts/lib/source-text/strip-js-comments.js +115 -0
  58. package/.agents/scripts/lib/test-isolate/runner.js +3 -3
  59. package/.agents/scripts/lib/test-runner-contract.js +14 -6
  60. package/.agents/scripts/lib/test-tiers.js +135 -18
  61. package/.agents/scripts/lib/transpile.js +16 -3
  62. package/.agents/scripts/lib/worktree/lifecycle/pending-cleanup.js +169 -31
  63. package/.agents/scripts/lib/worktree/node-modules-strategy.js +2 -5
  64. package/.agents/scripts/pr-watch-with-update.js +305 -137
  65. package/.agents/scripts/providers/github/tickets.js +109 -16
  66. package/.agents/scripts/quality-preview.js +162 -70
  67. package/.agents/scripts/run-test-profile.js +8 -5
  68. package/.agents/scripts/run-tests.js +79 -14
  69. package/.agents/scripts/single-story-close.js +0 -2
  70. package/.agents/scripts/single-story-init.js +5 -1
  71. package/docs/CHANGELOG.md +32 -0
  72. package/lib/cli/migrate.js +100 -94
  73. package/lib/migrations/helpers/retire-agentrc-key.js +200 -0
  74. package/lib/migrations/index.js +32 -33
  75. package/lib/migrations/steps/2.1.0-retire-mi-drop-knobs.js +21 -80
  76. package/lib/migrations/steps/2.1.0-retire-verify-concurrency-cap.js +15 -87
  77. package/lib/migrations/steps/2.11.0-retire-max-seed-words.js +13 -71
  78. package/lib/migrations/steps/2.20.0-retire-codebase-snapshot.js +13 -101
  79. package/lib/migrations/steps/2.32.0-retire-lint-baseline-command.js +13 -100
  80. package/package.json +7 -3
  81. package/.agents/scripts/lib/audit-suite/frontmatter-lint.js +0 -32
  82. package/.agents/scripts/lib/baselines/maintainability-baseline-save.js +0 -37
  83. package/.agents/scripts/lib/cli/parse-numeric.js +0 -60
  84. package/.agents/scripts/lib/close-validation/telemetry.js +0 -79
  85. package/.agents/scripts/lib/orchestration/label-transitions.js +0 -44
  86. package/.agents/scripts/lib/orchestration/parked-follow-ons.js +0 -147
  87. package/.agents/scripts/lib/orchestration/phase-runner.js +0 -88
  88. package/.agents/scripts/lib/orchestration/recut.js +0 -56
@@ -487,6 +487,272 @@ async function evaluateGreenWatch({
487
487
  };
488
488
  }
489
489
 
490
+ /**
491
+ * Resolve the knobs, temp root and working directory one watch run needs.
492
+ *
493
+ * Split out of `runPrWatch` because config resolution must not abort the
494
+ * watch: a broken `.agentrc` should degrade to defaults, not turn a CI probe
495
+ * into a crash.
496
+ *
497
+ * @param {{ config?: object, tempRoot?: string, logger: object, flags: object }} params
498
+ * @returns {{ knobs: object, effectiveTempRoot: string, cwd: string }}
499
+ */
500
+ function resolveWatchContext({ config, tempRoot, logger, flags }) {
501
+ const resolvedConfig =
502
+ config !== undefined ? config : safeResolveConfig(logger);
503
+ const knobs = resolveWatchKnobs({ config: resolvedConfig, flags });
504
+ const effectiveTempRoot =
505
+ tempRoot ?? resolvedConfig?.project?.paths?.tempRoot ?? 'temp';
506
+ return { knobs, effectiveTempRoot, cwd: process.cwd() };
507
+ }
508
+
509
+ /**
510
+ * Build the argument bag for the underlying watch port.
511
+ *
512
+ * Each injectable port is spread in only when supplied so the port keeps its
513
+ * own default — passing `undefined` explicitly would override a default with
514
+ * nothing and break every caller that relies on it.
515
+ *
516
+ * @param {object} params
517
+ * @returns {object}
518
+ */
519
+ function buildWatchArgs({
520
+ prRef,
521
+ repo,
522
+ cwd,
523
+ knobs,
524
+ ghPrChecksFn,
525
+ ghPrViewFn,
526
+ ghPrUpdateBranchFn,
527
+ sleepFn,
528
+ logger,
529
+ }) {
530
+ return {
531
+ prUrl: prRef,
532
+ repo,
533
+ cwd,
534
+ maxPolls: knobs.maxPolls,
535
+ maxUpdates: knobs.maxUpdates,
536
+ maxResumes: knobs.maxResumes,
537
+ pollIntervalMs: knobs.pollIntervalMs,
538
+ ...(ghPrChecksFn ? { ghPrChecksFn } : {}),
539
+ ...(ghPrViewFn ? { ghPrViewFn } : {}),
540
+ ...(ghPrUpdateBranchFn ? { ghPrUpdateBranchFn } : {}),
541
+ ...(sleepFn ? { sleepFn } : {}),
542
+ logger,
543
+ };
544
+ }
545
+
546
+ /**
547
+ * One terminal outcome of the watch: the attach window is spent and no
548
+ * required check ever attached, or the pull request could not be read at all.
549
+ *
550
+ * The two are distinguished structurally — never against `gh`'s stderr prose
551
+ * (Story #4890) — because they route oppositely: CI that has not started is
552
+ * slow (exit 2, keep watching), while an unreadable PR is a `gh` / access
553
+ * fault (exit 1).
554
+ *
555
+ * @param {object} params
556
+ * @returns {number} exit code
557
+ */
558
+ function reportUnattachedOrError({
559
+ result,
560
+ envelope,
561
+ prNumber,
562
+ knobs,
563
+ logger,
564
+ print,
565
+ }) {
566
+ const notYetStarted = Boolean(
567
+ result.requiredChecksEmpty && result.prResolvable,
568
+ );
569
+ print(
570
+ JSON.stringify({
571
+ ...envelope,
572
+ requiredChecksEmpty: Boolean(result.requiredChecksEmpty),
573
+ notYetStarted,
574
+ }),
575
+ );
576
+ if (notYetStarted) {
577
+ logger.warn?.(
578
+ `[pr-watch] no required check has attached to PR #${prNumber} within the ` +
579
+ `${Math.round(knobs.attachWindowMs / 1000)}s attach window (${result.attachRetries} re-resolutions), ` +
580
+ 'and the pull request still reads back fine — this is CI that has not started, NOT a red check. ' +
581
+ 'Keep polling natively:',
582
+ );
583
+ logger.warn?.('[pr-watch] gh pr checks <pr> --watch');
584
+ return STILL_RUNNING_EXIT_CODE;
585
+ }
586
+ logger.error?.(
587
+ `[pr-watch] could not resolve required checks: ${result.error} — the pull request itself could ` +
588
+ 'not be read, so this is a `gh` / access fault rather than CI that has not started.',
589
+ );
590
+ return 1;
591
+ }
592
+
593
+ /**
594
+ * Settle a watch whose observed required checks all came back green.
595
+ *
596
+ * Never green on an undercount (Story #4873): the observed set is whatever
597
+ * `gh pr checks --required` returned on the FIRST probe, so a context a
598
+ * ruleset attached later can leave every check we knew about green while
599
+ * GitHub still refuses the merge. An unreconcilable set reports unresolved
600
+ * (exit 2, keep watching) rather than a false green or a false red.
601
+ *
602
+ * @param {object} params
603
+ * @returns {Promise<number>} exit code
604
+ */
605
+ async function settleGreenWatch({
606
+ result,
607
+ envelope,
608
+ readMergeState,
609
+ storyId,
610
+ prNumber,
611
+ guardPrRef,
612
+ effectiveTempRoot,
613
+ cwd,
614
+ readDigestFn,
615
+ retireDigestFn,
616
+ headShaFn,
617
+ reArmAutoMergeFn,
618
+ blockDeliveryFn,
619
+ logger,
620
+ print,
621
+ }) {
622
+ const reconciliation = reconcileGreenVerdict({
623
+ observedRequired: result.requiredChecks,
624
+ mergeStateStatus: await readMergeState(),
625
+ });
626
+ if (!reconciliation.reconciled) {
627
+ print(JSON.stringify({ ...envelope, reconciliation }));
628
+ logger.warn?.(
629
+ `[pr-watch] withholding the green verdict: ${reconciliation.reason}. ` +
630
+ 'Re-run the watch once the repository settles, or inspect branch protection for a context this watch never saw.',
631
+ );
632
+ return STILL_RUNNING_EXIT_CODE;
633
+ }
634
+ const guard = await evaluateGreenWatch({
635
+ storyId,
636
+ prNumber,
637
+ prRef: guardPrRef,
638
+ tempRoot: effectiveTempRoot,
639
+ cwd,
640
+ readDigestFn,
641
+ retireDigestFn,
642
+ headShaFn,
643
+ reArmFn: reArmAutoMergeFn,
644
+ blockFn: blockDeliveryFn,
645
+ logger,
646
+ });
647
+ print(JSON.stringify({ ...envelope, reconciliation, rerunGuard: guard }));
648
+ if (guard.exitCode === 0) {
649
+ logger.info?.('[pr-watch] all required checks green.');
650
+ }
651
+ return guard.exitCode;
652
+ }
653
+
654
+ /**
655
+ * Slow-but-not-red: the poll cap AND the resume budget are exhausted with
656
+ * checks still pending and none failed. Never exit 1, never `timed_out` —
657
+ * hand off to the host's interval loop and exit 2.
658
+ *
659
+ * @param {object} params
660
+ * @returns {number} exit code
661
+ */
662
+ function reportStillRunning({ result, envelope, logger, print }) {
663
+ print(JSON.stringify(envelope));
664
+ const stillPending = Object.entries(result.outcomes)
665
+ .filter(([, v]) => v === 'still-running')
666
+ .map(([k]) => k)
667
+ .join(', ');
668
+ logger.warn?.(
669
+ `[pr-watch] required check(s) still running after ${result.polls} polls + ${result.resumesApplied} resumes: ${stillPending}. Keep polling natively:`,
670
+ );
671
+ logger.warn?.('[pr-watch] gh pr checks <pr> --watch');
672
+ return STILL_RUNNING_EXIT_CODE;
673
+ }
674
+
675
+ /**
676
+ * The failing checks, excluding every non-failing state **and**
677
+ * `still-running`: when the cap fires with a mixed failed+pending map,
678
+ * `promotePendingToStillRunning` has rewritten the pending entries, and a
679
+ * still-running check is slow, not red — including it here would let it become
680
+ * the digest's "primary" failing check and mispoint the diagnosis.
681
+ *
682
+ * @param {Record<string, string>} outcomes
683
+ * @returns {Array<{ name: string, outcome: string }>}
684
+ */
685
+ function collectFailures(outcomes) {
686
+ return Object.entries(outcomes)
687
+ .filter(
688
+ ([, v]) =>
689
+ v !== 'success' &&
690
+ v !== 'neutral' &&
691
+ v !== 'skipped' &&
692
+ v !== 'still-running',
693
+ )
694
+ .map(([name, outcome]) => ({ name, outcome }));
695
+ }
696
+
697
+ /**
698
+ * Genuine red check — exit 1 immediately, disarm auto-merge, write the digest,
699
+ * and surface the fix-loop handoff.
700
+ *
701
+ * @param {object} params
702
+ * @returns {Promise<number>} exit code
703
+ */
704
+ async function settleRedWatch({
705
+ result,
706
+ envelope,
707
+ storyId,
708
+ prNumber,
709
+ guardPrRef,
710
+ effectiveTempRoot,
711
+ cwd,
712
+ writeDigestFn,
713
+ headShaFn,
714
+ disarmAutoMergeFn,
715
+ blockDeliveryFn,
716
+ logger,
717
+ print,
718
+ }) {
719
+ const failures = collectFailures(result.outcomes);
720
+ const red = failures.map((f) => `${f.name}=${f.outcome}`).join(', ');
721
+ logger.error?.(`[pr-watch] required check(s) not green: ${red}`);
722
+ const redOutcome = await handleRedWatch({
723
+ storyId,
724
+ prNumber,
725
+ prRef: guardPrRef,
726
+ failures,
727
+ tempRoot: effectiveTempRoot,
728
+ cwd,
729
+ writeDigestFn,
730
+ headShaFn,
731
+ disarmFn: disarmAutoMergeFn,
732
+ blockFn: blockDeliveryFn,
733
+ logger,
734
+ });
735
+ print(
736
+ JSON.stringify({
737
+ ...envelope,
738
+ classification: classifyFailure(failures[0]?.name),
739
+ rerunGuard: {
740
+ verdict: 'red',
741
+ headSha: redOutcome.headSha,
742
+ autoMergeDisarmed: redOutcome.disarm.disarmed,
743
+ disarmDetail: redOutcome.disarm.detail,
744
+ digestPath: redOutcome.digestPaths?.jsonPath ?? null,
745
+ blocked: redOutcome.blocked,
746
+ },
747
+ }),
748
+ );
749
+ logger.error?.(
750
+ '[pr-watch] a required check failed. Read the digest, reproduce the failure, and apply the smallest fix at source, ' +
751
+ 'then push a new commit — re-running the failed job is forbidden (`.agents/rules/ci-remediation.md` § Verifier).',
752
+ );
753
+ return 1;
754
+ }
755
+
490
756
  /**
491
757
  * Run the watch loop and resolve to the exit code. Exported for tests so
492
758
  * the green / red / still-running / BEHIND paths can be exercised with
@@ -557,21 +823,12 @@ export async function runPrWatch({
557
823
  if (!Number.isInteger(prNumber) || prNumber < 1)
558
824
  throw new TypeError('runPrWatch: --pr requires a positive integer');
559
825
 
560
- const resolvedConfig =
561
- config !== undefined ? config : safeResolveConfig(logger);
562
- const knobs = resolveWatchKnobs({
563
- config: resolvedConfig,
564
- flags: {
565
- pollIntervalMs,
566
- maxPolls,
567
- maxResumes,
568
- maxUpdates,
569
- attachWindowMs,
570
- },
826
+ const { knobs, effectiveTempRoot, cwd } = resolveWatchContext({
827
+ config,
828
+ tempRoot,
829
+ logger,
830
+ flags: { pollIntervalMs, maxPolls, maxResumes, maxUpdates, attachWindowMs },
571
831
  });
572
- const effectiveTempRoot =
573
- tempRoot ?? resolvedConfig?.project?.paths?.tempRoot ?? 'temp';
574
- const cwd = process.cwd();
575
832
 
576
833
  // `gh` has NO `<owner/repo>#<number>` argument form — it parses that string
577
834
  // as a BRANCH NAME, which is why every `--repo` invocation used to fail at
@@ -596,20 +853,17 @@ export async function runPrWatch({
596
853
  probeMergeState({ prRef: guardPrRef, prUrl: prRef, repo, cwd, prNumber });
597
854
 
598
855
  const result = await watchWithAttachWindow({
599
- watchArgs: {
600
- prUrl: prRef,
856
+ watchArgs: buildWatchArgs({
857
+ prRef,
601
858
  repo,
602
859
  cwd,
603
- maxPolls: knobs.maxPolls,
604
- maxUpdates: knobs.maxUpdates,
605
- maxResumes: knobs.maxResumes,
606
- pollIntervalMs: knobs.pollIntervalMs,
607
- ...(ghPrChecksFn ? { ghPrChecksFn } : {}),
608
- ...(ghPrViewFn ? { ghPrViewFn } : {}),
609
- ...(ghPrUpdateBranchFn ? { ghPrUpdateBranchFn } : {}),
610
- ...(sleepFn ? { sleepFn } : {}),
860
+ knobs,
861
+ ghPrChecksFn,
862
+ ghPrViewFn,
863
+ ghPrUpdateBranchFn,
864
+ sleepFn,
611
865
  logger,
612
- },
866
+ }),
613
867
  attachWindowMs: knobs.attachWindowMs,
614
868
  retryIntervalMs: knobs.pollIntervalMs,
615
869
  sleepFn: sleepFn ?? defaultSleep,
@@ -637,141 +891,55 @@ export async function runPrWatch({
637
891
  };
638
892
 
639
893
  if (result.requiredChecksEmpty || result.error) {
640
- // The attach window is spent and the required set is still empty. Classify
641
- // it structurally — never against `gh`'s stderr prose (Story #4890).
642
- const notYetStarted = Boolean(
643
- result.requiredChecksEmpty && result.prResolvable,
644
- );
645
- print(
646
- JSON.stringify({
647
- ...envelope,
648
- requiredChecksEmpty: Boolean(result.requiredChecksEmpty),
649
- notYetStarted,
650
- }),
651
- );
652
- if (notYetStarted) {
653
- logger.warn?.(
654
- `[pr-watch] no required check has attached to PR #${prNumber} within the ` +
655
- `${Math.round(knobs.attachWindowMs / 1000)}s attach window (${result.attachRetries} re-resolutions), ` +
656
- 'and the pull request still reads back fine — this is CI that has not started, NOT a red check. ' +
657
- 'Keep polling natively:',
658
- );
659
- logger.warn?.('[pr-watch] gh pr checks <pr> --watch');
660
- return STILL_RUNNING_EXIT_CODE;
661
- }
662
- logger.error?.(
663
- `[pr-watch] could not resolve required checks: ${result.error} — the pull request itself could ` +
664
- 'not be read, so this is a `gh` / access fault rather than CI that has not started.',
665
- );
666
- return 1;
894
+ return reportUnattachedOrError({
895
+ result,
896
+ envelope,
897
+ prNumber,
898
+ knobs,
899
+ logger,
900
+ print,
901
+ });
667
902
  }
668
903
 
669
904
  if (result.green) {
670
- // Never green on an undercount (Story #4873). The observed required set is
671
- // whatever `gh pr checks --required` returned on the FIRST probe; when a
672
- // ruleset attached a context after that, every check we knew about can be
673
- // green while GitHub still refuses the merge. Reconcile against the
674
- // repository's own verdict before any green is issued — an unreconcilable
675
- // set reports unresolved (exit 2, keep watching) rather than a false green
676
- // or a false red.
677
- const reconciliation = reconcileGreenVerdict({
678
- observedRequired: result.requiredChecks,
679
- mergeStateStatus: await readMergeState(),
680
- });
681
- if (!reconciliation.reconciled) {
682
- print(JSON.stringify({ ...envelope, reconciliation }));
683
- logger.warn?.(
684
- `[pr-watch] withholding the green verdict: ${reconciliation.reason}. ` +
685
- 'Re-run the watch once the repository settles, or inspect branch protection for a context this watch never saw.',
686
- );
687
- return STILL_RUNNING_EXIT_CODE;
688
- }
689
- const guard = await evaluateGreenWatch({
905
+ return await settleGreenWatch({
906
+ result,
907
+ envelope,
908
+ readMergeState,
690
909
  storyId,
691
910
  prNumber,
692
- prRef: guardPrRef,
693
- tempRoot: effectiveTempRoot,
911
+ guardPrRef,
912
+ effectiveTempRoot,
694
913
  cwd,
695
914
  readDigestFn,
696
915
  retireDigestFn,
697
916
  headShaFn,
698
- reArmFn: reArmAutoMergeFn,
699
- blockFn: blockDeliveryFn,
917
+ reArmAutoMergeFn,
918
+ blockDeliveryFn,
700
919
  logger,
920
+ print,
701
921
  });
702
- print(JSON.stringify({ ...envelope, reconciliation, rerunGuard: guard }));
703
- if (guard.exitCode === 0) {
704
- logger.info?.('[pr-watch] all required checks green.');
705
- }
706
- return guard.exitCode;
707
922
  }
708
923
 
709
- // Slow-but-not-red: the cap AND resume budget are exhausted with checks
710
- // still pending and none failed. Never exit 1, never `timed_out` — hand
711
- // off to the host's interval loop and exit 2.
712
924
  if (result.stillRunning) {
713
- print(JSON.stringify(envelope));
714
- const stillPending = Object.entries(result.outcomes)
715
- .filter(([, v]) => v === 'still-running')
716
- .map(([k]) => k)
717
- .join(', ');
718
- logger.warn?.(
719
- `[pr-watch] required check(s) still running after ${result.polls} polls + ${result.resumesApplied} resumes: ${stillPending}. Keep polling natively:`,
720
- );
721
- logger.warn?.('[pr-watch] gh pr checks <pr> --watch');
722
- return STILL_RUNNING_EXIT_CODE;
925
+ return reportStillRunning({ result, envelope, logger, print });
723
926
  }
724
927
 
725
- // Genuine red check — exit 1 immediately, disarm auto-merge, write the
726
- // digest, and surface the fix-loop handoff.
727
- // Exclude 'still-running' as well as the non-failing states: when the cap
728
- // fires with a mixed failed+pending map, promotePendingToStillRunning has
729
- // rewritten the pending entries, and a still-running check is slow, not
730
- // red — including it here would let it become the digest's "primary"
731
- // failing check and mispoint the diagnosis at a slow check.
732
- const failures = Object.entries(result.outcomes)
733
- .filter(
734
- ([, v]) =>
735
- v !== 'success' &&
736
- v !== 'neutral' &&
737
- v !== 'skipped' &&
738
- v !== 'still-running',
739
- )
740
- .map(([name, outcome]) => ({ name, outcome }));
741
- const red = failures.map((f) => `${f.name}=${f.outcome}`).join(', ');
742
- logger.error?.(`[pr-watch] required check(s) not green: ${red}`);
743
- const redOutcome = await handleRedWatch({
928
+ return await settleRedWatch({
929
+ result,
930
+ envelope,
744
931
  storyId,
745
932
  prNumber,
746
- prRef: guardPrRef,
747
- failures,
748
- tempRoot: effectiveTempRoot,
933
+ guardPrRef,
934
+ effectiveTempRoot,
749
935
  cwd,
750
936
  writeDigestFn,
751
937
  headShaFn,
752
- disarmFn: disarmAutoMergeFn,
753
- blockFn: blockDeliveryFn,
938
+ disarmAutoMergeFn,
939
+ blockDeliveryFn,
754
940
  logger,
941
+ print,
755
942
  });
756
- print(
757
- JSON.stringify({
758
- ...envelope,
759
- classification: classifyFailure(failures[0]?.name),
760
- rerunGuard: {
761
- verdict: 'red',
762
- headSha: redOutcome.headSha,
763
- autoMergeDisarmed: redOutcome.disarm.disarmed,
764
- disarmDetail: redOutcome.disarm.detail,
765
- digestPath: redOutcome.digestPaths?.jsonPath ?? null,
766
- blocked: redOutcome.blocked,
767
- },
768
- }),
769
- );
770
- logger.error?.(
771
- '[pr-watch] a required check failed. Read the digest, reproduce the failure, and apply the smallest fix at source, ' +
772
- 'then push a new commit — re-running the failed job is forbidden (`.agents/rules/ci-remediation.md` § Verifier).',
773
- );
774
- return 1;
775
943
  }
776
944
 
777
945
  /** Resolve config without letting a config error abort the watch. */
@@ -267,35 +267,40 @@ export class TicketGateway {
267
267
  * abort `/plan` persist with `1..k-1` already live on the tracker.
268
268
  *
269
269
  * Retry alone is not sufficient for that failure mode — a POST whose
270
- * response is lost would double-create on the retry so the caller
271
- * (`plan-persist`'s `createStoryIssues`) carries the idempotency half via
272
- * a plan fingerprint it looks up before creating. Retry narrows the
273
- * window; the fingerprint closes it.
270
+ * response is lost would double-create on the retry. Story #5112 closed
271
+ * that: `findExisting` is consulted **before every retry POST**, and a hit
272
+ * is adopted instead of re-created, so a response-lost first attempt files
273
+ * exactly one issue. `/plan` persist supplies the plan-fingerprint lookup
274
+ * its resume path already uses (`plan-persist`'s `createStoryIssues`);
275
+ * callers with no content identity omit it and keep the pre-#5112
276
+ * retry-only behaviour.
274
277
  *
275
278
  * @field-manifest POST /repos/{owner}/{repo}/issues: number, id, node_id,
276
279
  * html_url
277
280
  *
278
- * @param {{ title: string, body: string, labels?: string[] }} payload
281
+ * @param {{
282
+ * title: string,
283
+ * body: string,
284
+ * labels?: string[],
285
+ * findExisting?: (() => Promise<object|null>)|null,
286
+ * }} payload
279
287
  * @returns {Promise<{
280
288
  * id: number,
281
289
  * number: number,
282
290
  * internalId: number,
283
291
  * nodeId: string,
284
292
  * url: string,
293
+ * adopted: boolean,
285
294
  * boardAdd: { added: boolean, reason?: string },
286
295
  * }>}
287
296
  */
288
- async createIssue({ title, body, labels = [] }) {
289
- const result = await withTransientRetry(
290
- () =>
291
- this._gh.api({
292
- method: 'POST',
293
- endpoint: `/repos/${this.owner}/${this.repo}/issues`,
294
- body: { title, body, labels },
295
- }),
296
- { label: `createIssue "${title}"`, onRetry: defaultRetryWarn },
297
- );
298
- const issue = parseApiJson(result);
297
+ async createIssue({ title, body, labels = [], findExisting = null }) {
298
+ const { issue, adopted } = await this._createIssueOrAdopt({
299
+ title,
300
+ body,
301
+ labels,
302
+ findExisting,
303
+ });
299
304
  this._listCache.clear();
300
305
 
301
306
  const boardAdd = await addIssueToBoard({
@@ -311,10 +316,86 @@ export class TicketGateway {
311
316
  internalId: issue.id,
312
317
  nodeId: issue.node_id,
313
318
  url: issue.html_url,
319
+ adopted,
314
320
  boardAdd,
315
321
  };
316
322
  }
317
323
 
324
+ /**
325
+ * POST one issue and parse the created resource out of the response.
326
+ *
327
+ * @param {{ title: string, body: string, labels: string[] }} payload
328
+ * @returns {Promise<object>}
329
+ */
330
+ async _postIssue(payload) {
331
+ return parseApiJson(
332
+ await this._gh.api({
333
+ method: 'POST',
334
+ endpoint: `/repos/${this.owner}/${this.repo}/issues`,
335
+ body: payload,
336
+ }),
337
+ );
338
+ }
339
+
340
+ /**
341
+ * Resolve the ambiguity a lost response leaves behind: did attempt 1 land?
342
+ * `ECONNRESET` after the server committed the create looks exactly like
343
+ * `ECONNRESET` before it, so the only authority is the server's own state.
344
+ *
345
+ * @param {(() => Promise<object|null>)|null} findExisting
346
+ * @returns {Promise<object|null>} the already-created issue, or `null`.
347
+ */
348
+ async _findAlreadyCreated(findExisting) {
349
+ return typeof findExisting === 'function' ? await findExisting() : null;
350
+ }
351
+
352
+ /**
353
+ * The idempotent half of {@link TicketGateway#createIssue}: POST once, and
354
+ * on every subsequent attempt look for an already-created issue *before*
355
+ * posting again, so a retry adopts rather than duplicates.
356
+ *
357
+ * @param {{ title: string, body: string, labels: string[], findExisting: (() => Promise<object|null>)|null }} args
358
+ * @returns {Promise<{ issue: object, adopted: boolean }>}
359
+ */
360
+ async _createIssueOrAdopt({ title, body, labels, findExisting }) {
361
+ let posts = 0;
362
+ return withTransientRetry(
363
+ async () => {
364
+ if (posts > 0) {
365
+ const issue = await this._findAlreadyCreated(findExisting);
366
+ if (issue) return { issue, adopted: true };
367
+ }
368
+ posts += 1;
369
+ const issue = await this._postIssue({ title, body, labels });
370
+ return { issue, adopted: false };
371
+ },
372
+ { label: `createIssue "${title}"`, onRetry: defaultRetryWarn },
373
+ );
374
+ }
375
+
376
+ /**
377
+ * Append logins to an issue's assignee set via GitHub's additive assignees
378
+ * endpoint. No read-before-write and no replace semantics, so it cannot
379
+ * evict an assignee another run added between our read and our write. A
380
+ * non-array or empty list is a no-op.
381
+ *
382
+ * @param {number} ticketId
383
+ * @param {string[]|undefined} logins
384
+ */
385
+ async _addAssignees(ticketId, logins) {
386
+ if (!(logins?.length > 0)) return;
387
+ await withTransientRetry(
388
+ () =>
389
+ this._gh.api({
390
+ method: 'POST',
391
+ endpoint: `/repos/${this.owner}/${this.repo}/issues/${ticketId}/assignees`,
392
+ body: { assignees: logins },
393
+ }),
394
+ { label: `addAssignees #${ticketId}`, onRetry: defaultRetryWarn },
395
+ );
396
+ this.invalidateTicket(ticketId);
397
+ }
398
+
318
399
  /**
319
400
  * Add/remove labels on an issue. When the only mutation is "add", uses the
320
401
  * additive labels endpoint (POST /issues/{n}/labels) for atomicity and to
@@ -370,11 +451,23 @@ export class TicketGateway {
370
451
  * {@link TicketGateway#_applyLabelMutations}. Both are the write half of the
371
452
  * fan-outs Story #4952 raised; the reads in this file were already wrapped.
372
453
  *
454
+ * `mutations.addAssignees` (Story #5112) is the **additive** assignee
455
+ * write, on the same shape as the additive label POST above: it appends to
456
+ * the assignee set instead of replacing it. The lease claim
457
+ * (`lib/orchestration/ticket-lease.js`) needs that — a replacing PATCH
458
+ * evicts a simultaneous claimer silently, so both runs read a clean
459
+ * `[self]` on verify and both believe they hold the lease. Appending makes
460
+ * the collision *observable* as a co-assignment, which is exactly what the
461
+ * lease's `lost-race` back-out keys on. `mutations.assignees` (replace) is
462
+ * unchanged and still used by the steal and release paths.
463
+ *
464
+ * @field-manifest POST /repos/{owner}/{repo}/issues/{n}/assignees: assignees
373
465
  * @field-manifest PATCH /repos/{owner}/{repo}/issues/{n}:
374
466
  * body, assignees, state, state_reason, labels
375
467
  */
376
468
  /* node:coverage ignore next */
377
469
  async updateTicket(ticketId, mutations) {
470
+ await this._addAssignees(ticketId, mutations.addAssignees);
378
471
  const patch = {};
379
472
  if (mutations.body !== undefined) patch.body = mutations.body;
380
473
  if (mutations.assignees) patch.assignees = mutations.assignees;