mandrel 2.35.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 (108) hide show
  1. package/.agents/docs/agentrc-reference.json +3 -1
  2. package/.agents/docs/configuration.md +27 -0
  3. package/.agents/rules/testing-standards.md +56 -0
  4. package/.agents/schemas/agentrc.schema.json +11 -0
  5. package/.agents/schemas/lifecycle/merge.unlanded.schema.json +2 -1
  6. package/.agents/schemas/story-deliver-terminal.schema.json +1 -0
  7. package/.agents/scripts/boot-sweep.js +7 -1
  8. package/.agents/scripts/check-context-budget.js +195 -103
  9. package/.agents/scripts/check-cyclomatic.js +112 -42
  10. package/.agents/scripts/check-doc-links.js +23 -2
  11. package/.agents/scripts/check-generated-validator.js +202 -0
  12. package/.agents/scripts/check-lifecycle-lint.js +2 -68
  13. package/.agents/scripts/check-schema-references.js +2 -28
  14. package/.agents/scripts/drain-pending-cleanup.js +18 -1
  15. package/.agents/scripts/git-cleanup.js +2 -0
  16. package/.agents/scripts/lib/baselines/components.js +32 -2
  17. package/.agents/scripts/lib/baselines/env-overrides.js +1 -1
  18. package/.agents/scripts/lib/bootstrap/branch-protection.js +1 -1
  19. package/.agents/scripts/lib/bootstrap/install-ledger.js +1 -1
  20. package/.agents/scripts/lib/bootstrap/prompt.js +1 -1
  21. package/.agents/scripts/lib/bootstrap/quality-bootstrap.js +2 -2
  22. package/.agents/scripts/lib/checks/loop-health.js +1 -1
  23. package/.agents/scripts/lib/cli-args.js +0 -2
  24. package/.agents/scripts/lib/close-validation/gates.js +1 -1
  25. package/.agents/scripts/lib/close-validation/process.js +1 -1
  26. package/.agents/scripts/lib/config/ci.js +18 -0
  27. package/.agents/scripts/lib/config/sync-agentrc.js +1 -1
  28. package/.agents/scripts/lib/config-settings-schema-delivery.js +13 -0
  29. package/.agents/scripts/lib/config-settings-schema.js +46 -6
  30. package/.agents/scripts/lib/coverage-baseline.js +2 -2
  31. package/.agents/scripts/lib/cpu-pool.js +90 -10
  32. package/.agents/scripts/lib/crap-utils.js +6 -2
  33. package/.agents/scripts/lib/cyclomatic-ceiling.js +28 -6
  34. package/.agents/scripts/lib/cyclomatic-scope.js +144 -0
  35. package/.agents/scripts/lib/dependency-version.js +86 -0
  36. package/.agents/scripts/lib/duplicate-search.js +1 -1
  37. package/.agents/scripts/lib/errors/index.js +1 -1
  38. package/.agents/scripts/lib/feedback-loop/graduator-core.js +2 -2
  39. package/.agents/scripts/lib/feedback-loop/prior-feedback-fetcher.js +1 -1
  40. package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +1 -1
  41. package/.agents/scripts/lib/generated/agentrc-validator.js +16 -0
  42. package/.agents/scripts/lib/install-cmd-parser.js +1 -1
  43. package/.agents/scripts/lib/knip-entry-sync.js +1 -57
  44. package/.agents/scripts/lib/maintainability-utils.js +108 -10
  45. package/.agents/scripts/lib/observability/source-classifier.js +1 -1
  46. package/.agents/scripts/lib/orchestration/git-cleanup/phases/branches.js +22 -7
  47. package/.agents/scripts/lib/orchestration/git-cleanup/phases/git-probes.js +22 -14
  48. package/.agents/scripts/lib/orchestration/git-cleanup/phases/merged-tip.js +132 -0
  49. package/.agents/scripts/lib/orchestration/git-cleanup/phases/render.js +56 -11
  50. package/.agents/scripts/lib/orchestration/merge-block-class.js +10 -1
  51. package/.agents/scripts/lib/orchestration/merge-poll.js +164 -0
  52. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +70 -9
  53. package/.agents/scripts/lib/orchestration/pr-watch.js +1 -30
  54. package/.agents/scripts/lib/orchestration/reassert-status-column.js +2 -2
  55. package/.agents/scripts/lib/orchestration/review-providers/codex.js +3 -3
  56. package/.agents/scripts/lib/orchestration/review-providers/findings-renderer.js +1 -1
  57. package/.agents/scripts/lib/orchestration/review-providers/native.js +9 -2
  58. package/.agents/scripts/lib/orchestration/review-providers/review-depth.js +2 -2
  59. package/.agents/scripts/lib/orchestration/review-providers/security-review.js +3 -3
  60. package/.agents/scripts/lib/orchestration/single-story-close/phases/auto-merge.js +145 -0
  61. package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +96 -5
  62. package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +52 -1
  63. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +9 -1
  64. package/.agents/scripts/lib/orchestration/story-close/format-autofix.js +2 -5
  65. package/.agents/scripts/lib/orchestration/story-follow-ups.js +1 -1
  66. package/.agents/scripts/lib/orchestration/ticket-lease.js +34 -9
  67. package/.agents/scripts/lib/orchestration/ticketing/reads.js +3 -1
  68. package/.agents/scripts/lib/preflight-runner.js +3 -3
  69. package/.agents/scripts/lib/qa/qa-session.js +1 -1
  70. package/.agents/scripts/lib/runtime-deps/manifest.js +1 -7
  71. package/.agents/scripts/lib/runtime-deps/scan-imports.js +3 -58
  72. package/.agents/scripts/lib/single-story-sweep/sweep-lock.js +284 -25
  73. package/.agents/scripts/lib/skills/walk-skill-files.js +1 -1
  74. package/.agents/scripts/lib/source-text/strip-js-comments.js +115 -0
  75. package/.agents/scripts/lib/test-isolate/runner.js +3 -3
  76. package/.agents/scripts/lib/test-runner-contract.js +14 -6
  77. package/.agents/scripts/lib/test-tiers.js +135 -18
  78. package/.agents/scripts/lib/transpile.js +16 -3
  79. package/.agents/scripts/lib/worktree/lifecycle/pending-cleanup.js +169 -31
  80. package/.agents/scripts/lib/worktree/node-modules-strategy.js +2 -5
  81. package/.agents/scripts/notify.js +4 -10
  82. package/.agents/scripts/pr-watch-with-update.js +305 -137
  83. package/.agents/scripts/providers/github/tickets.js +109 -16
  84. package/.agents/scripts/quality-preview.js +162 -70
  85. package/.agents/scripts/run-test-profile.js +8 -5
  86. package/.agents/scripts/run-tests.js +79 -14
  87. package/.agents/scripts/single-story-close.js +0 -2
  88. package/.agents/scripts/single-story-init.js +5 -1
  89. package/.agents/workflows/audit-documentation.md +5 -6
  90. package/docs/CHANGELOG.md +50 -0
  91. package/lib/cli/migrate.js +100 -94
  92. package/lib/migrations/helpers/retire-agentrc-key.js +200 -0
  93. package/lib/migrations/index.js +32 -33
  94. package/lib/migrations/steps/2.1.0-retire-mi-drop-knobs.js +21 -80
  95. package/lib/migrations/steps/2.1.0-retire-verify-concurrency-cap.js +15 -87
  96. package/lib/migrations/steps/2.11.0-retire-max-seed-words.js +13 -71
  97. package/lib/migrations/steps/2.20.0-retire-codebase-snapshot.js +13 -101
  98. package/lib/migrations/steps/2.32.0-retire-lint-baseline-command.js +13 -100
  99. package/package.json +9 -5
  100. package/.agents/scripts/generate-lifecycle-docs.js +0 -237
  101. package/.agents/scripts/lib/audit-suite/frontmatter-lint.js +0 -32
  102. package/.agents/scripts/lib/baselines/maintainability-baseline-save.js +0 -37
  103. package/.agents/scripts/lib/cli/parse-numeric.js +0 -60
  104. package/.agents/scripts/lib/close-validation/telemetry.js +0 -79
  105. package/.agents/scripts/lib/orchestration/label-transitions.js +0 -44
  106. package/.agents/scripts/lib/orchestration/parked-follow-ons.js +0 -147
  107. package/.agents/scripts/lib/orchestration/phase-runner.js +0 -88
  108. package/.agents/scripts/lib/orchestration/recut.js +0 -56
@@ -90,6 +90,7 @@
90
90
  * confirm.
91
91
  */
92
92
 
93
+ import { getCiDelivery } from '../../../config/ci.js';
93
94
  import { createGh } from '../../../gh-exec.js';
94
95
  import {
95
96
  confirmStoryMerged as defaultConfirmStoryMerged,
@@ -106,8 +107,10 @@ import { classifyMergeBlock as defaultClassifyMergeBlock } from '../../merge-blo
106
107
  import {
107
108
  DEFAULT_INTERVAL_SECONDS,
108
109
  DEFAULT_MAX_BUDGET_SECONDS,
110
+ decideAdvisoryGateBlock,
109
111
  decideMergeWaitFailFast,
110
112
  deriveChecksStatus,
113
+ deriveRedHeadRuns,
111
114
  deriveRequiredRunEvidence,
112
115
  MERGE_WAIT_GH_TIMEOUT_MS,
113
116
  } from '../../merge-poll.js';
@@ -117,6 +120,7 @@ import {
117
120
  STATE_LABELS,
118
121
  transitionTicketState,
119
122
  } from '../../ticketing.js';
123
+ import { disarmAutoMerge } from './auto-merge.js';
120
124
  import { runPostLandTail as defaultRunPostLandTail } from './post-land.js';
121
125
 
122
126
  /**
@@ -256,6 +260,10 @@ export async function readPrWaitProbe({
256
260
  // the aggregate `checksStatus` folds together. `null` when the rollup is
257
261
  // absent/empty — the loop's consecutive-probe fallback owns that path.
258
262
  requiredRunEvidence: deriveRequiredRunEvidence(view?.statusCheckRollup),
263
+ // The named red head runs (Story #5096), so an `advisory-gate-red`
264
+ // verdict can name the offending job and match the allowlist. Same
265
+ // red-ness test as `requiredRunEvidence`, so the two cannot disagree.
266
+ redHeadRuns: deriveRedHeadRuns(view?.statusCheckRollup),
259
267
  };
260
268
  } catch (err) {
261
269
  return {
@@ -263,6 +271,7 @@ export async function readPrWaitProbe({
263
271
  mergedAt: null,
264
272
  createdAt: null,
265
273
  checksStatus: 'pending',
274
+ redHeadRuns: [],
266
275
  error: `PR probe failed: ${err?.message ?? err}`,
267
276
  };
268
277
  }
@@ -541,6 +550,46 @@ async function blockOnFlipFailed({
541
550
  * is best-effort logged rather than thrown — the caller owns surfacing the
542
551
  * non-zero exit once this returns.
543
552
  */
553
+ /**
554
+ * Story #5096 — resolve the advisory-gate terminal for one poll.
555
+ *
556
+ * Takes the poll's current `unlanded` and returns it unchanged when a terminal
557
+ * is already decided, so the caller is a single assignment with NO added
558
+ * branch. `runMergePoll` is already above `check-cyclomatic`'s ceiling; the
559
+ * three decision points this would otherwise cost inline are a real gate
560
+ * regression, and they belong with the policy either way.
561
+ *
562
+ * Disarms BEFORE returning the terminal: an armed PR can merge out from under
563
+ * the block the caller is about to record.
564
+ */
565
+ async function resolveAdvisoryUnlanded({
566
+ unlanded,
567
+ probe,
568
+ blockOnAdvisoryFailure,
569
+ advisoryAllowlist,
570
+ prNumber,
571
+ gh,
572
+ progress,
573
+ disarmAutoMergeFn,
574
+ elapsedSeconds,
575
+ }) {
576
+ if (unlanded) return unlanded;
577
+ const advisory = decideAdvisoryGateBlock({
578
+ probe,
579
+ blockOnAdvisoryFailure,
580
+ advisoryAllowlist,
581
+ });
582
+ if (!advisory) return null;
583
+ progress?.('CONFIRM', `🛑 PR #${prNumber}: ${advisory.reason}`);
584
+ await disarmAutoMergeFn({ prNumber, gh, progress });
585
+ return {
586
+ prProbe: probe,
587
+ budget: { exhausted: false, elapsedSeconds },
588
+ blockClassOverride: 'advisory-gate-red',
589
+ reasonOverride: advisory.reason,
590
+ };
591
+ }
592
+
544
593
  async function blockOnUnlanded({
545
594
  storyId,
546
595
  prNumber,
@@ -552,12 +601,24 @@ async function blockOnUnlanded({
552
601
  progress,
553
602
  classifyMergeBlockFn,
554
603
  emitMergeUnlandedFn,
604
+ blockClassOverride,
605
+ reasonOverride,
555
606
  }) {
556
- const { blockClass, reason } = classifyMergeBlockFn({
557
- armResult,
558
- prProbe,
559
- budget,
560
- });
607
+ // Story #5096 `advisory-gate-red` is emitted DIRECTLY, never derived.
608
+ // `classifyMergeBlock` cannot produce it: by construction GitHub is NOT
609
+ // gating this merge (`mergeStateStatus: UNSTABLE`), which is the entire
610
+ // condition the class names, so every classifier heuristic reads the PR as
611
+ // healthy.
612
+ const { blockClass, reason } = blockClassOverride
613
+ ? {
614
+ blockClass: blockClassOverride,
615
+ reason: reasonOverride ?? blockClassOverride,
616
+ }
617
+ : classifyMergeBlockFn({
618
+ armResult,
619
+ prProbe,
620
+ budget,
621
+ });
561
622
  const elapsedSeconds = budget?.elapsedSeconds ?? 0;
562
623
  // Which evidence path produced a `checks-failed` verdict (Story #4695):
563
624
  // `per-run` (head-anchored required-run evidence) or `consecutive-probe`
@@ -820,6 +881,7 @@ export async function runConfirmMergePhase({
820
881
  prUrl,
821
882
  autoMergeEnabled,
822
883
  autoMergeReason,
884
+ advisoryGate,
823
885
  provider,
824
886
  config,
825
887
  maxWaitSeconds: maxWaitSecondsOverride,
@@ -834,6 +896,7 @@ export async function runConfirmMergePhase({
834
896
  emitMergeUnlandedFn = defaultEmitMergeUnlanded,
835
897
  emitMergeFlipFailedFn = defaultEmitMergeFlipFailed,
836
898
  runPostLandTailFn = defaultRunPostLandTail,
899
+ disarmAutoMergeFn = disarmAutoMerge,
837
900
  sleepFn = defaultSleep,
838
901
  nowMsFn = Date.now,
839
902
  ghTimeoutMs = MERGE_WAIT_GH_TIMEOUT_MS,
@@ -857,6 +920,15 @@ export async function runConfirmMergePhase({
857
920
  progress,
858
921
  classifyMergeBlockFn,
859
922
  emitMergeUnlandedFn,
923
+ // Story #5096 — the arm phase already refused over a red advisory gate;
924
+ // carry its verdict through instead of letting the classifier read this
925
+ // as a generic `arm-failure`.
926
+ ...(autoMergeReason === 'advisory-gate-red'
927
+ ? {
928
+ blockClassOverride: 'advisory-gate-red',
929
+ reasonOverride: advisoryGate?.reason,
930
+ }
931
+ : {}),
860
932
  });
861
933
  }
862
934
 
@@ -871,6 +943,8 @@ export async function runConfirmMergePhase({
871
943
  maxWaitSecondsOverride,
872
944
  mergeWatchModeOverride,
873
945
  );
946
+ // Story #5096 — the advisory-gate knobs, read once for the whole wait.
947
+ const { blockOnAdvisoryFailure, advisoryAllowlist } = getCiDelivery(config);
874
948
  const intervalMs = intervalSeconds * 1000;
875
949
  const startedAtMs = nowMsFn();
876
950
  let anchorMs = startedAtMs;
@@ -1020,6 +1094,23 @@ export async function runConfirmMergePhase({
1020
1094
  },
1021
1095
  };
1022
1096
  }
1097
+
1098
+ // Story #5096 — the ADVISORY counterpart, and the half that catches the
1099
+ // common shape. Close arms immediately after opening the PR, while the
1100
+ // gate is still QUEUED, so the pre-arm refusal in `auto-merge.js` sees
1101
+ // nothing; the gate reddens here, mid-wait, and native auto-merge would
1102
+ // land the PR the moment the REQUIRED contexts go green.
1103
+ unlanded = await resolveAdvisoryUnlanded({
1104
+ unlanded,
1105
+ probe,
1106
+ blockOnAdvisoryFailure,
1107
+ advisoryAllowlist,
1108
+ prNumber,
1109
+ gh: injectedGh,
1110
+ progress,
1111
+ disarmAutoMergeFn,
1112
+ elapsedSeconds: Math.round(waitedMs / 1000),
1113
+ });
1023
1114
  }
1024
1115
 
1025
1116
  if (!unlanded) {
@@ -87,6 +87,50 @@ export function resolveWaitForMerge({
87
87
  };
88
88
  }
89
89
 
90
+ /**
91
+ * Flags this CLI advertised in its `--help` descriptor and never implemented
92
+ * (Story #5100). `--dry-run` was transcribed into the usage block by the
93
+ * #4750 self-describing sweep and read by nothing in this pipeline —
94
+ * `git log -S dryRun` over it is empty in every revision — so an operator who
95
+ * trusted it got a real base-sync merge, an `agent::blocked` Story, and a
96
+ * terminal envelope that then captured `deliver-recover`'s routing.
97
+ * `--no-evidence` names a working flag on the gate wrappers
98
+ * (`evidence-gate.js`), but the slot this CLI parsed had no reader at all.
99
+ *
100
+ * Deleting them from the descriptor is NOT enough. `parseSprintArgs` runs
101
+ * `parseArgs` with `strict: false`, so an unknown flag is silently ignored and
102
+ * the close proceeds for real — the identical injury, now with no help text to
103
+ * explain it. Fail closed instead, exactly as `parseMergeWatchMode` does for an
104
+ * unrecognized mode: throw during option parsing, before the first phase, so
105
+ * the CLI boundary reports `failed` at phase `init` and nothing is mutated.
106
+ */
107
+ const RETIRED_FLAGS = Object.freeze({
108
+ '--dry-run':
109
+ 'this pipeline has never had a dry-run mode; it was advertised in error.',
110
+ '--no-evidence':
111
+ 'per-close evidence control was never wired here; the working flag of that name belongs to the gate wrappers.',
112
+ });
113
+
114
+ /**
115
+ * Reject a retired flag before any phase runs.
116
+ *
117
+ * Matches `--flag` and `--flag=value`; a bare `--` terminator or a positional
118
+ * that merely contains the text is not a flag and does not trip it.
119
+ *
120
+ * @param {string[]} argv argv tail (`process.argv.slice(2)`)
121
+ * @throws {Error} naming the flag, why it is gone, and that nothing was mutated
122
+ */
123
+ function assertNoRetiredFlags(argv) {
124
+ for (const [flag, why] of Object.entries(RETIRED_FLAGS)) {
125
+ const present = argv.some((a) => a === flag || a.startsWith(`${flag}=`));
126
+ if (!present) continue;
127
+ throw new Error(
128
+ `${flag} was retired: ${why} Nothing was mutated — no branch, label, ` +
129
+ `comment, or PR was touched. Re-run without it to close for real.`,
130
+ );
131
+ }
132
+ }
133
+
90
134
  /**
91
135
  * Parse and resolve all CLI / injection options for `runSingleStoryClose`.
92
136
  *
@@ -116,7 +160,14 @@ export function parseCloseOptions({
116
160
  // which is precisely what `resolveFlag` already does below, preferring the
117
161
  // param over the parsed slot. One expression per flag now serves both
118
162
  // callers, so a new flag is added in one place instead of two that can drift.
119
- const parsed = storyIdParam === undefined ? parseSprintArgs() : {};
163
+ // The guard runs on the argv door only: an injecting caller supplies
164
+ // `storyIdParam` and never reads argv, so the host process's flags (a test
165
+ // runner's, say) are none of its business.
166
+ let parsed = {};
167
+ if (storyIdParam === undefined) {
168
+ assertNoRetiredFlags(process.argv.slice(2));
169
+ parsed = parseSprintArgs();
170
+ }
120
171
  // Preserve undefined so resolveWaitForMerge can apply the closeAndLand
121
172
  // config default when neither flag was supplied.
122
173
  const waitForMergeExplicit = resolveFlag(
@@ -485,6 +485,7 @@ async function resolveAutoMergeOutcome({ alreadyMerged, ...phaseArgs }) {
485
485
  autoMergeReason: null,
486
486
  localCleanupDeferred: false,
487
487
  directMerged: false,
488
+ advisoryGate: null,
488
489
  };
489
490
  }
490
491
  return await runAutoMergePhase(phaseArgs);
@@ -541,6 +542,7 @@ async function finishWithMergeWait(prCtx, deps) {
541
542
  prUrl: prCtx.prUrl,
542
543
  autoMergeEnabled: prCtx.autoMergeEnabled,
543
544
  autoMergeReason: prCtx.autoMergeReason,
545
+ advisoryGate: prCtx.advisoryGate,
544
546
  provider: deps.provider,
545
547
  config: prCtx.config,
546
548
  maxWaitSeconds: deps.maxWaitSeconds,
@@ -796,18 +798,23 @@ async function runClosePipeline({
796
798
  WorktreeManager,
797
799
  });
798
800
  setPhase('auto-merge');
801
+ const ciDelivery = getCiDelivery(config);
799
802
  const {
800
803
  autoMergeEnabled,
801
804
  autoMergeReason,
802
805
  localCleanupDeferred,
803
806
  directMerged,
807
+ advisoryGate,
804
808
  } = await resolveAutoMergeOutcome({
805
809
  alreadyMerged,
806
810
  cwd: options.cwd,
807
811
  prNumber,
808
812
  prUrl,
809
813
  noAutoMerge: options.noAutoMerge,
810
- autoMergePolicy: getCiDelivery(config).autoMerge,
814
+ autoMergePolicy: ciDelivery.autoMerge,
815
+ // Story #5096 — the pre-arm advisory-gate refusal.
816
+ blockOnAdvisoryFailure: ciDelivery.blockOnAdvisoryFailure,
817
+ advisoryAllowlist: ciDelivery.advisoryAllowlist,
811
818
  gh: injectedGh,
812
819
  progress,
813
820
  });
@@ -866,6 +873,7 @@ async function runClosePipeline({
866
873
  prUrl,
867
874
  autoMergeEnabled,
868
875
  autoMergeReason,
876
+ advisoryGate,
869
877
  worktreeReaped,
870
878
  localCleanupDeferred,
871
879
  directMerged,
@@ -63,7 +63,7 @@ export const FORMAT_AUTOFIX_TIMEOUT_EXIT_CODE = 124;
63
63
  * @param {(args: string[], opts: object) => string} git
64
64
  * @returns {string[]}
65
65
  */
66
- export function listDirtyPaths(cwd, git) {
66
+ function listDirtyPaths(cwd, git) {
67
67
  const out = git(['status', '--porcelain'], {
68
68
  cwd,
69
69
  encoding: 'utf8',
@@ -91,10 +91,7 @@ export function listDirtyPaths(cwd, git) {
91
91
  * }} [opts]
92
92
  * @returns {{ writeCmdString: string, writeCmd: string, writeArgs: string[] }}
93
93
  */
94
- export function resolveFormatterCmd({
95
- commands,
96
- dropTrailingDot = false,
97
- } = {}) {
94
+ function resolveFormatterCmd({ commands, dropTrailingDot = false } = {}) {
98
95
  // `resolveFormatWriteCommand` reads `config.project.commands`; wrap the
99
96
  // caller-supplied `commands` map into that canonical shape.
100
97
  const writeCmdString = resolveFormatWriteCommand({ project: { commands } });
@@ -24,7 +24,7 @@ import {
24
24
  } from './retro-proposals.js';
25
25
  import { upsertStructuredComment } from './ticketing.js';
26
26
 
27
- export const FOLLOW_UPS_COMMENT_TYPE = 'follow-ups';
27
+ const FOLLOW_UPS_COMMENT_TYPE = 'follow-ups';
28
28
 
29
29
  /** Milliseconds in one day — the unit `frictionWindowDays` is expressed in. */
30
30
  const MS_PER_DAY = 24 * 60 * 60 * 1000;
@@ -29,8 +29,9 @@
29
29
  * a claim back from whoever took over).
30
30
  *
31
31
  * Provider contract (a subset of `ITicketingProvider`):
32
- * - `getTicket(id)` → `{ assignees: string[], ... }`
33
- * - `updateTicket(id, { assignees })` writes the assignee list.
32
+ * - `getTicket(id)` → `{ assignees: string[], ... }`
33
+ * - `updateTicket(id, { assignees })` replaces the assignee list.
34
+ * - `updateTicket(id, { addAssignees })` appends to it (Story #5112).
34
35
  */
35
36
 
36
37
  /**
@@ -128,11 +129,13 @@ function normaliseOpts(op, opts) {
128
129
  * operator out, `acquired: false`,
129
130
  * `owner: <foreign>`, `reason: 'lost-race'`.
130
131
  *
131
- * Every claiming write is verified: GitHub's assignee PATCH is not a
132
+ * Every claiming write is verified: GitHub's assignee write is not a
132
133
  * compare-and-set, so two runs that both read the ticket unassigned will both
133
134
  * write themselves. {@link claimAndVerify} re-reads after the write and refuses
134
135
  * (fail-closed) when a foreign login is present, so the loser of a simultaneous
135
- * claim never proceeds as though it holds the lease.
136
+ * claim never proceeds as though it holds the lease. Story #5112 made the
137
+ * first claim **additive** so that verify can actually see the collision —
138
+ * see {@link claimAndVerify}.
136
139
  *
137
140
  * @param {object} opts
138
141
  * @param {object} opts.provider Ticketing provider.
@@ -199,7 +202,7 @@ export async function acquireLease(opts) {
199
202
  *
200
203
  * The assignee write is not atomic — GitHub offers no compare-and-set on the
201
204
  * assignees surface — so two runs that both observed the ticket unassigned (or
202
- * a stale foreign claim) will both PATCH themselves in. Without a check the
205
+ * a stale foreign claim) will both write themselves in. Without a check the
203
206
  * loser of that race returns `acquired: true` and marches into the worktree
204
207
  * the winner is already building. The verify closes that window: it re-reads
205
208
  * with `fresh: true` (bypassing any provider cache so it sees the other run's
@@ -208,9 +211,16 @@ export async function acquireLease(opts) {
208
211
  * returns `acquired: false` / `reason: 'lost-race'` so the fail-closed caller
209
212
  * refuses. A clean read (assignees exactly `[operator]`) confirms the claim.
210
213
  *
211
- * It does not eliminate the race two writes still happen but it makes the
212
- * outcome deterministic: exactly one operator survives as the sole assignee,
213
- * and the other is told it lost.
214
+ * **The write must be additive for the verify to work (Story #5112).** With
215
+ * the replacing PATCH this used unconditionally, a simultaneous claim
216
+ * *evicted* the other operator rather than joining it, so the co-assignment
217
+ * the `lost-race` branch keys on was a state the PATCH could never produce:
218
+ * each run read a clean `[self]` on verify and both proceeded. Claiming an
219
+ * unowned ticket therefore goes through the additive assignees endpoint
220
+ * (`addAssignees`), which makes the collision observable and lets exactly one
221
+ * claimer survive. The replacing form stays for the two cases that genuinely
222
+ * mean "replace": the `--steal` transfer of a foreign claim, and the loser's
223
+ * own back-out below.
214
224
  *
215
225
  * @param {object} args
216
226
  * @param {object} args.provider Ticketing provider.
@@ -220,6 +230,21 @@ export async function acquireLease(opts) {
220
230
  * @param {string} args.reason Success reason when the claim holds.
221
231
  * @returns {Promise<{ acquired: boolean, owner: string, previousOwner: string|null, reason: string }>}
222
232
  */
233
+ /**
234
+ * The assignee mutation a claim writes. Additive when the ticket has no
235
+ * previous owner — that is what makes a simultaneous claim show up as a
236
+ * co-assignment {@link claimAndVerify} can detect. Replacing only for a
237
+ * steal, where evicting the previous owner *is* the intent.
238
+ *
239
+ * @param {string} operator
240
+ * @param {string|null} previousOwner
241
+ * @returns {{ addAssignees: string[] }|{ assignees: string[] }}
242
+ */
243
+ function claimMutation(operator, previousOwner) {
244
+ if (previousOwner === null) return { addAssignees: [operator] };
245
+ return { assignees: [operator] };
246
+ }
247
+
223
248
  async function claimAndVerify({
224
249
  provider,
225
250
  ticketId,
@@ -227,7 +252,7 @@ async function claimAndVerify({
227
252
  previousOwner,
228
253
  reason,
229
254
  }) {
230
- await provider.updateTicket(ticketId, { assignees: [operator] });
255
+ await provider.updateTicket(ticketId, claimMutation(operator, previousOwner));
231
256
 
232
257
  const after = await provider.getTicket(ticketId, { fresh: true });
233
258
  const assignees = Array.isArray(after?.assignees) ? after.assignees : [];
@@ -81,7 +81,9 @@ export const STRUCTURED_COMMENT_TYPES = Object.freeze([
81
81
  'epic-run-state',
82
82
  'epic-run-progress',
83
83
  'epic-plan-state',
84
- 'parked-follow-ons',
84
+ // `parked-follow-ons` retired in Story #5114 with the module that was its
85
+ // only writer. A kind the reader still recognises but nothing emits is the
86
+ // same dead wiring in a new place.
85
87
  // Story #566 — per-phase wall-clock summary posted by single-story-close.js.
86
88
  'phase-timings',
87
89
  // Story #831 — story-init upserts a `story-init` comment that
@@ -124,7 +124,7 @@ function pick(logger, level) {
124
124
  * @param {Array<object>} fixed
125
125
  * @param {object} [logger]
126
126
  */
127
- export function logFixes(fixed, logger = DEFAULT_LOGGER) {
127
+ function logFixes(fixed, logger = DEFAULT_LOGGER) {
128
128
  const info = pick(logger, 'info');
129
129
  info(`[preflight] auto-fixed ${fixed.length} finding(s):`);
130
130
  for (const f of fixed) {
@@ -141,7 +141,7 @@ export function logFixes(fixed, logger = DEFAULT_LOGGER) {
141
141
  * @param {Array<object>} blockers
142
142
  * @param {object} [logger]
143
143
  */
144
- export function logBlockers(scope, blockers, logger = DEFAULT_LOGGER) {
144
+ function logBlockers(scope, blockers, logger = DEFAULT_LOGGER) {
145
145
  const error = pick(logger, 'error');
146
146
  error(
147
147
  `[preflight] ${scope}: ${blockers.length} blocker finding(s) — refusing to proceed.`,
@@ -180,7 +180,7 @@ export function logBlockers(scope, blockers, logger = DEFAULT_LOGGER) {
180
180
  * @param {Array<object>} findings
181
181
  * @param {object} [logger]
182
182
  */
183
- export function logNonBlockers(findings, logger = DEFAULT_LOGGER) {
183
+ function logNonBlockers(findings, logger = DEFAULT_LOGGER) {
184
184
  const warn = pick(logger, 'warn');
185
185
  for (const f of findings) {
186
186
  warn(`[preflight] ${f.severity}: ${f.id} — ${f.summary ?? ''}`);
@@ -36,7 +36,7 @@ import path from 'node:path';
36
36
  import { tempRootFrom } from '../config/temp-paths.js';
37
37
 
38
38
  /** Directory segment (under `tempRoot`) that holds every QA session ledger. */
39
- export const QA_LEDGER_DIRNAME = 'qa';
39
+ const QA_LEDGER_DIRNAME = 'qa';
40
40
 
41
41
  /**
42
42
  * Triaged dispositions, mirrored from the `disposition` enum in
@@ -26,13 +26,7 @@ const HERE = path.dirname(fileURLToPath(import.meta.url));
26
26
  * `.agents/scripts/lib/runtime-deps/`; the manifest sits at the `.agents/`
27
27
  * root, three directories up.
28
28
  */
29
- export const MANIFEST_PATH = path.resolve(
30
- HERE,
31
- '..',
32
- '..',
33
- '..',
34
- 'runtime-deps.json',
35
- );
29
+ const MANIFEST_PATH = path.resolve(HERE, '..', '..', '..', 'runtime-deps.json');
36
30
 
37
31
  /**
38
32
  * @typedef {object} RuntimeDepsManifest
@@ -26,9 +26,10 @@
26
26
  import fs from 'node:fs';
27
27
  import { builtinModules } from 'node:module';
28
28
  import path from 'node:path';
29
+ import { stripJsComments } from '../source-text/strip-js-comments.js';
29
30
 
30
31
  /** Node builtins, with and without the `node:` prefix. */
31
- export const BUILTIN_MODULES = new Set([
32
+ const BUILTIN_MODULES = new Set([
32
33
  ...builtinModules,
33
34
  ...builtinModules.map((m) => `node:${m}`),
34
35
  ]);
@@ -71,62 +72,6 @@ const SIDE_EFFECT = /^\s*import\s*['"]([^'"]+)['"]/gm;
71
72
  // `require(...)` and dynamic `import(...)` may appear mid-expression.
72
73
  const CALL_FORM = /\b(?:require|import)\s*\(\s*['"]([^'"]+)['"]/g;
73
74
 
74
- /**
75
- * Remove `//` line comments and block comments while preserving string and
76
- * template literals (which carry the import specifiers we extract). A
77
- * char-by-char state machine is used rather than a regex so that comment
78
- * markers inside string literals (e.g. a `https://` URL) are not mistaken
79
- * for comments, and example import syntax inside *comments* (e.g. a
80
- * `// require('x')` doc line) does not register as a phantom dependency.
81
- *
82
- * @param {string} source
83
- * @returns {string}
84
- */
85
- export function stripComments(source) {
86
- let out = '';
87
- let i = 0;
88
- const n = source.length;
89
- while (i < n) {
90
- const ch = source[i];
91
- const next = source[i + 1];
92
- // Enter a string / template literal — copy verbatim until it closes.
93
- if (ch === '"' || ch === "'" || ch === '`') {
94
- const quote = ch;
95
- out += ch;
96
- i += 1;
97
- while (i < n) {
98
- const c = source[i];
99
- out += c;
100
- if (c === '\\') {
101
- // Copy the escaped char too, then continue.
102
- if (i + 1 < n) out += source[i + 1];
103
- i += 2;
104
- continue;
105
- }
106
- i += 1;
107
- if (c === quote) break;
108
- }
109
- continue;
110
- }
111
- // Line comment — drop to end of line (keep the newline).
112
- if (ch === '/' && next === '/') {
113
- i += 2;
114
- while (i < n && source[i] !== '\n') i += 1;
115
- continue;
116
- }
117
- // Block comment — drop until the closing `*/`.
118
- if (ch === '/' && next === '*') {
119
- i += 2;
120
- while (i < n && !(source[i] === '*' && source[i + 1] === '/')) i += 1;
121
- i += 2;
122
- continue;
123
- }
124
- out += ch;
125
- i += 1;
126
- }
127
- return out;
128
- }
129
-
130
75
  /**
131
76
  * Extract the set of third-party top-level package names imported by a
132
77
  * single source string.
@@ -136,7 +81,7 @@ export function stripComments(source) {
136
81
  */
137
82
  export function extractThirdPartyImports(source) {
138
83
  const found = new Set();
139
- const cleaned = stripComments(source);
84
+ const cleaned = stripJsComments(source);
140
85
  for (const re of [STATIC_FROM, SIDE_EFFECT, CALL_FORM]) {
141
86
  re.lastIndex = 0;
142
87
  let match = re.exec(cleaned);