mandrel 2.67.0 → 2.69.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 (61) hide show
  1. package/.agents/agents/story-worker.md +15 -11
  2. package/.agents/docs/agentrc-reference.json +3 -1
  3. package/.agents/docs/configuration.md +36 -1
  4. package/.agents/schemas/agentrc.schema.json +14 -1
  5. package/.agents/schemas/story-deliver-terminal.schema.json +23 -1
  6. package/.agents/schemas/validation-evidence.schema.json +3 -1
  7. package/.agents/scripts/coverage-capture.js +65 -9
  8. package/.agents/scripts/evidence-gate.js +106 -8
  9. package/.agents/scripts/lib/baselines/coverage-refresh-scope.js +60 -0
  10. package/.agents/scripts/lib/baselines/crap-updater-cli.js +101 -4
  11. package/.agents/scripts/lib/baselines/refresh-service.js +1 -1
  12. package/.agents/scripts/lib/baselines/seat-missing.js +228 -0
  13. package/.agents/scripts/lib/child-exec.js +39 -1
  14. package/.agents/scripts/lib/close-validation/gates.js +59 -19
  15. package/.agents/scripts/lib/close-validation/process.js +23 -24
  16. package/.agents/scripts/lib/close-validation/runner.js +71 -40
  17. package/.agents/scripts/lib/config/gates/coverage.schema.js +21 -0
  18. package/.agents/scripts/lib/config/quality.js +7 -1
  19. package/.agents/scripts/lib/config/temp-paths.js +15 -0
  20. package/.agents/scripts/lib/config-settings-schema-delivery.js +1 -1
  21. package/.agents/scripts/lib/coverage-baseline.js +78 -5
  22. package/.agents/scripts/lib/coverage-capture-affected.js +345 -0
  23. package/.agents/scripts/lib/coverage-capture-delta.js +180 -0
  24. package/.agents/scripts/lib/coverage-capture-fullscope.js +53 -32
  25. package/.agents/scripts/lib/coverage-capture-incremental.js +49 -26
  26. package/.agents/scripts/lib/coverage-capture-usage.js +1 -1
  27. package/.agents/scripts/lib/coverage-capture.js +121 -81
  28. package/.agents/scripts/lib/full-suite-lock.js +49 -46
  29. package/.agents/scripts/lib/full-suite-queue.js +83 -8
  30. package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
  31. package/.agents/scripts/lib/observability/source-classifier.js +1 -0
  32. package/.agents/scripts/lib/orchestration/code-review.js +15 -3
  33. package/.agents/scripts/lib/orchestration/merge-poll.js +5 -0
  34. package/.agents/scripts/lib/orchestration/review-deposit.js +219 -0
  35. package/.agents/scripts/lib/orchestration/review-providers/code-review.js +11 -7
  36. package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +29 -10
  37. package/.agents/scripts/lib/orchestration/single-story-close/phases/code-review.js +124 -73
  38. package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +38 -20
  39. package/.agents/scripts/lib/orchestration/single-story-close/phases/lock-wait-pending.js +8 -2
  40. package/.agents/scripts/lib/orchestration/single-story-close/review-overlap.js +161 -0
  41. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +47 -7
  42. package/.agents/scripts/lib/orchestration/story-deliver-terminal.js +8 -16
  43. package/.agents/scripts/lib/process-group.js +1 -1
  44. package/.agents/scripts/lib/supervised-suite.js +247 -0
  45. package/.agents/scripts/lib/wave-runner/cross-run-overlap.js +120 -0
  46. package/.agents/scripts/lib/wave-runner/live-probe.js +5 -1
  47. package/.agents/scripts/quality-preview.js +112 -14
  48. package/.agents/scripts/stories-wave-tick.js +47 -0
  49. package/.agents/scripts/story-review-compute.js +207 -0
  50. package/.agents/scripts/update-coverage-baseline.js +15 -10
  51. package/.agents/scripts/update-crap-baseline.js +12 -2
  52. package/.agents/scripts/update-maintainability-baseline.js +12 -2
  53. package/.agents/workflows/audit-security.md +0 -1
  54. package/.agents/workflows/helpers/code-review.md +7 -5
  55. package/.agents/workflows/helpers/deliver-digest.md +39 -36
  56. package/.agents/workflows/helpers/deliver-reference.md +115 -5
  57. package/.agents/workflows/helpers/deliver-story.md +2 -1
  58. package/docs/CHANGELOG.md +33 -0
  59. package/lib/cli/registry.js +125 -18
  60. package/lib/migrations/steps/strip-removed-agentrc-keys.js +0 -5
  61. package/package.json +1 -1
@@ -7,8 +7,8 @@
7
7
  * resumable `pending` with no mutation; `maxBudgetSeconds` bounds the
8
8
  * cumulative wait, anchored at the PR's `createdAt` so resumes don't restart
9
9
  * it, and exhausting it is the real give-up (classify, emit, block). The
10
- * poll is stateless and re-entrant. Async mode only shortens the
11
- * per-invocation window. Checks are probed every iteration so a red required
10
+ * poll is stateless and re-entrant. Async mode probes once (see
11
+ * ASYNC_PROBE_WINDOW_SECONDS). Checks are probed every iteration so a red required
12
12
  * check fails fast instead of burning the budget.
13
13
  *
14
14
  * Terminals: `landed` (confirmed, post-land tail ran); `pending` (nothing
@@ -48,6 +48,7 @@ import {
48
48
  MERGE_WAIT_GH_TIMEOUT_MS,
49
49
  parseWorkflowRunId,
50
50
  pollIntervalMs,
51
+ probeSettlesAsyncWait,
51
52
  readRunSummary,
52
53
  resolveAdvisoryGateVerdict,
53
54
  } from '../../merge-poll.js';
@@ -65,7 +66,7 @@ import { runPostLandTail as defaultRunPostLandTail } from './post-land.js';
65
66
  /** Per-invocation bound; fits the host's ~10-min tool ceiling after the gates. */
66
67
  export const DEFAULT_MAX_WAIT_SECONDS = 300;
67
68
 
68
- /** Async-mode per-invocation cap: long enough to catch an instant merge or red check. */
69
+ /** Async cap; only a green or red first probe polls on inside it. */
69
70
  export const ASYNC_PROBE_WINDOW_SECONDS = 60;
70
71
 
71
72
  export const DEFAULT_UPDATE_ATTEMPTS = 3;
@@ -223,7 +224,7 @@ export async function readPrWaitProbe({
223
224
  * @param {object} [config]
224
225
  * @param {number} [maxWaitSecondsOverride]
225
226
  * @param {'sync'|'async'} [modeOverride]
226
- * @returns {{ mode: 'sync'|'async', intervalSeconds: number, maxWaitSeconds: number, maxBudgetSeconds: number, updateAttempts: number }}
227
+ * @returns {{ mode: 'sync'|'async', singleProbe: boolean, intervalSeconds: number, maxWaitSeconds: number, maxBudgetSeconds: number, updateAttempts: number }}
227
228
  */
228
229
  export function resolveMergeWaitConfig(
229
230
  config,
@@ -239,15 +240,16 @@ export function resolveMergeWaitConfig(
239
240
  maxWaitSecondsOverride,
240
241
  int(mergeWatch.maxWaitSeconds, DEFAULT_MAX_WAIT_SECONDS),
241
242
  );
242
- const maxWaitSeconds =
243
- mode === 'async' && maxWaitSecondsOverride == null
244
- ? Math.min(configuredMaxWait, ASYNC_PROBE_WINDOW_SECONDS)
245
- : configuredMaxWait;
243
+ const singleProbe = mode === 'async' && maxWaitSecondsOverride == null;
244
+ const maxWaitSeconds = singleProbe
245
+ ? Math.min(configuredMaxWait, ASYNC_PROBE_WINDOW_SECONDS)
246
+ : configuredMaxWait;
246
247
  // An interval longer than the bound would expire every invocation on poll
247
248
  // 1, so MIN_POLLS_BEFORE_BUDGET_BLOCK and the budget could never be reached.
248
249
  const intervalSeconds = Math.min(DEFAULT_INTERVAL_SECONDS, maxWaitSeconds);
249
250
  return {
250
251
  mode,
252
+ singleProbe,
251
253
  intervalSeconds,
252
254
  maxWaitSeconds,
253
255
  maxBudgetSeconds: int(
@@ -1015,25 +1017,30 @@ export function createMergeWaitState({ startedAtMs, intervalSeconds }) {
1015
1017
  };
1016
1018
  }
1017
1019
 
1018
- /** Budget first — it is the real give-up. */
1020
+ /** Budget first — it is the real give-up; the single probe yields to it. */
1019
1021
  function provisionalVerdict(
1020
1022
  { polls, intervalMs, waitedMs, cumulativeMs },
1021
1023
  limits,
1024
+ checksStatus,
1022
1025
  ) {
1023
- if (
1024
- polls >= MIN_POLLS_BEFORE_BUDGET_BLOCK &&
1025
- cumulativeMs + intervalMs > limits.maxBudgetSeconds * 1000
1026
- ) {
1026
+ const overBudget = cumulativeMs + intervalMs > limits.maxBudgetSeconds * 1000;
1027
+ if (polls >= MIN_POLLS_BEFORE_BUDGET_BLOCK && overBudget) {
1027
1028
  return 'budget-exhausted';
1028
1029
  }
1029
1030
  if (waitedMs + intervalMs > limits.maxWaitSeconds * 1000) return 'wait-bound';
1030
- return 'continue';
1031
+ return singleProbeOrContinue(limits, overBudget, checksStatus);
1032
+ }
1033
+
1034
+ function singleProbeOrContinue(limits, overBudget, checksStatus) {
1035
+ const settles =
1036
+ limits.singleProbe && !overBudget && probeSettlesAsyncWait(checksStatus);
1037
+ return settles ? 'single-probe' : 'continue';
1031
1038
  }
1032
1039
 
1033
1040
  /**
1034
1041
  * Pure: state + probe + clock → next state and verdict. `merged`, `closed`
1035
- * and `checks-failed` are definitive; `budget-exhausted`, `wait-bound` and
1036
- * `continue` hold only if the loop's advisory check does not block first.
1042
+ * and `checks-failed` are definitive; `budget-exhausted`, `wait-bound`,
1043
+ * `single-probe` and `continue` hold only if the loop's advisory check does not block first.
1037
1044
  * The two clock reads (wait bound, then budget) keep a stepping test clock's
1038
1045
  * call sequence.
1039
1046
  *
@@ -1090,7 +1097,11 @@ export function decideMergeWaitPoll({
1090
1097
  }
1091
1098
  return {
1092
1099
  state: next,
1093
- verdict: provisionalVerdict({ ...next, waitedMs, cumulativeMs }, limits),
1100
+ verdict: provisionalVerdict(
1101
+ { ...next, waitedMs, cumulativeMs },
1102
+ limits,
1103
+ probe.checksStatus,
1104
+ ),
1094
1105
  ...timing,
1095
1106
  };
1096
1107
  }
@@ -1158,13 +1169,20 @@ const DEFINITIVE_SETTLERS = Object.freeze({
1158
1169
  'checks-failed': settleChecksFailed,
1159
1170
  });
1160
1171
 
1161
- /** Invocation bound reached: resumable `pending`, no mutation. */
1172
+ /** The verdicts that settle a resumable `pending`, keyed to their headline. */
1173
+ const PENDING_HEADLINES = Object.freeze({
1174
+ 'wait-bound': 'Merge wait bound reached',
1175
+ 'single-probe': 'Async single probe settled',
1176
+ });
1177
+
1178
+ /** Invocation bound or single probe: resumable `pending`, no mutation. */
1162
1179
  function pendingAtWaitBound(ctx, decision, probe) {
1163
1180
  const { waitBudget } = decision;
1164
1181
  const checks = probe.checksStatus ?? 'unknown';
1182
+ const why = PENDING_HEADLINES[decision.verdict];
1165
1183
  ctx.progress?.(
1166
1184
  'CONFIRM',
1167
- `⏸ Merge wait bound reached (${waitBudget.waitedSeconds}s of ${waitBudget.maxWaitSeconds}s this invocation; ` +
1185
+ `⏸ ${why} (${waitBudget.waitedSeconds}s of ${waitBudget.maxWaitSeconds}s this invocation; ` +
1168
1186
  `${waitBudget.cumulativeSeconds}s of ${waitBudget.maxBudgetSeconds}s cumulative). PR #${ctx.prNumber} still in flight ` +
1169
1187
  `(checks=${checks}). Story stays at agent::closing — resumable.`,
1170
1188
  );
@@ -1223,7 +1241,7 @@ async function settleProvisional(ctx, decision, probe) {
1223
1241
  });
1224
1242
  return { state, outcome };
1225
1243
  }
1226
- if (decision.verdict === 'wait-bound') {
1244
+ if (Object.hasOwn(PENDING_HEADLINES, decision.verdict)) {
1227
1245
  return { state, outcome: pendingAtWaitBound(ctx, decision, probe) };
1228
1246
  }
1229
1247
  return { state, outcome: null };
@@ -13,7 +13,7 @@ import {
13
13
  * storyId: number,
14
14
  * storyBranch: string,
15
15
  * baseBranch: string,
16
- * lockWait: { waitedSeconds: number, expired: boolean }|null,
16
+ * lockWait: { waitedSeconds: number, expired: boolean, holder?: object }|null,
17
17
  * elapsedSeconds: number,
18
18
  * }} args
19
19
  * @returns {{ result: object, terminal: object, note: string }} The close
@@ -44,6 +44,12 @@ export function lockWaitPending({
44
44
  nextCommand,
45
45
  elapsedSeconds,
46
46
  }),
47
- note: `⏸ Story #${storyId}: another full suite held the host lock past the wait budget — resume with: ${nextCommand}`,
47
+ note: `⏸ Story #${storyId}: another full suite${describeHolder(lockWait?.holder)} held the host lock past the wait budget — resume with: ${nextCommand}`,
48
48
  };
49
49
  }
50
+
51
+ /** @param {{ ownerId: string|null, pid: number|null, ageSeconds: number|null }} [holder] */
52
+ function describeHolder(holder) {
53
+ if (!holder) return '';
54
+ return ` (${holder.ownerId ?? 'unknown owner'}, pid ${holder.pid ?? 'unknown'}, lock age ${holder.ageSeconds ?? 'unknown'}s)`;
55
+ }
@@ -0,0 +1,161 @@
1
+ /**
2
+ * The Story-scope review, overlapped with the close-validation gates: started
3
+ * once the pre-gate self-heal commits land and held (computed, never posted)
4
+ * until the PR exists. A worker deposit whose diff digest matches the diff at
5
+ * that point **is** the held result, so nothing is computed. After PR-open a
6
+ * pushed diff with a different digest discards it for a serial review — a
7
+ * clean base-sync merge moves HEAD without changing the digest. The held
8
+ * promise never rejects, so an abandoned review leaves no unhandled rejection.
9
+ */
10
+
11
+ import { postReviewComment } from '../code-review.js';
12
+ import {
13
+ computeReviewDiffDigest,
14
+ depositAsComputedReview,
15
+ probeHeldReviewDiff,
16
+ resolveRefSha,
17
+ } from '../review-deposit.js';
18
+ import { upsertStructuredComment } from '../ticketing.js';
19
+ import {
20
+ computeStoryScopeReview,
21
+ runStoryScopeReview,
22
+ settleStoryScopeReview,
23
+ } from './phases/code-review.js';
24
+
25
+ /** @typedef {{ sha: string, baseRef: string|null, diffDigest: string|null, adopted: boolean, startedAtMs: number, settled: Promise<object> }} HeldReview */
26
+
27
+ /** @returns {string|null} the commit `storyBranch` points at. */
28
+ function resolveBranchSha({ cwd, storyBranch, gitSpawnFn }) {
29
+ return resolveRefSha({ cwd, ref: storyBranch, gitSpawnFn });
30
+ }
31
+
32
+ /** A held result already in hand: the worker deposit. */
33
+ function adoptedSettlement(deposit, atMs) {
34
+ const value = depositAsComputedReview(deposit);
35
+ return Promise.resolve({ ok: true, value, endedAtMs: atMs });
36
+ }
37
+
38
+ /** Compute the review of `sha`, held unposted; never rejects. */
39
+ function computeHeldReview({ sha, nowMs, ...args }) {
40
+ return computeStoryScopeReview({
41
+ ...args,
42
+ headRef: sha,
43
+ deferPost: true,
44
+ }).then(
45
+ (value) => ({ ok: true, value, endedAtMs: nowMs() }),
46
+ (error) => ({ ok: false, error, endedAtMs: nowMs() }),
47
+ );
48
+ }
49
+
50
+ /**
51
+ * @param {{ cwd: string, storyId: number, storyBranch: string,
52
+ * baseBranch: string, provider: object, runCodeReviewFn: Function,
53
+ * gitSpawnFn: Function, progress: Function, config?: object,
54
+ * readDepositFn?: Function, nowMs?: () => number }} args
55
+ * @returns {HeldReview|null} null when the SHA is unresolvable.
56
+ */
57
+ export function startHeldReview(args) {
58
+ const { cwd, storyBranch, gitSpawnFn, progress, nowMs = Date.now } = args;
59
+ const sha = resolveBranchSha({ cwd, storyBranch, gitSpawnFn });
60
+ if (!sha) {
61
+ progress(
62
+ 'REVIEW',
63
+ `⏭ Could not resolve ${storyBranch}; the Story-scope review runs after PR-open.`,
64
+ );
65
+ return null;
66
+ }
67
+ const { baseRef, diffDigest, deposit } = probeHeldReviewDiff({
68
+ ...args,
69
+ sha,
70
+ });
71
+ const startedAtMs = nowMs();
72
+ const held = { sha, baseRef, diffDigest, adopted: !!deposit, startedAtMs };
73
+ if (deposit) {
74
+ progress('REVIEW', `♻️ Adopting the worker's held review; none computed.`);
75
+ return { ...held, settled: adoptedSettlement(deposit, startedAtMs) };
76
+ }
77
+ const settled = computeHeldReview({ ...args, sha, nowMs });
78
+ return { ...held, settled };
79
+ }
80
+
81
+ /** Drop a held review unposted. */
82
+ export function discardHeldReview(held, reason, progress) {
83
+ if (!held) return;
84
+ progress('REVIEW', `🗑 Held Story-scope review discarded (${reason}).`);
85
+ }
86
+
87
+ /** Post the held review; records compute + post time, never the idle gap. */
88
+ async function postHeldReview(held, args) {
89
+ const { recordDuration, nowMs = Date.now, postReportFn } = args;
90
+ const settledReview = await held.settled;
91
+ const computeMs = settledReview.endedAtMs - held.startedAtMs;
92
+ if (!settledReview.ok) {
93
+ recordDuration('code-review', computeMs);
94
+ throw settledReview.error;
95
+ }
96
+ const postStartedAtMs = nowMs();
97
+ try {
98
+ return await settleStoryScopeReview({
99
+ computed: settledReview.value,
100
+ storyId: args.storyId,
101
+ prUrl: args.prUrl,
102
+ prNumber: args.prNumber,
103
+ provider: args.provider,
104
+ progress: args.progress,
105
+ postReportFn:
106
+ postReportFn ??
107
+ ((post) =>
108
+ postReviewComment({
109
+ ...post,
110
+ upsertCommentFn: upsertStructuredComment,
111
+ })),
112
+ });
113
+ } finally {
114
+ recordDuration('code-review', computeMs + (nowMs() - postStartedAtMs));
115
+ }
116
+ }
117
+
118
+ /**
119
+ * Post a held review whose diff digest matches the pushed diff (phase
120
+ * untimed: it records its own wall time), else run the serial review.
121
+ *
122
+ * @returns {Promise<object>} the review outcome.
123
+ */
124
+ export async function reviewAfterPrOpen(args) {
125
+ const { held, setPhase, progress } = args;
126
+ if (held && args.prNumber != null) {
127
+ const pushedDigest = computeReviewDiffDigest({
128
+ cwd: args.cwd,
129
+ baseRef: held.baseRef,
130
+ headRef: resolveBranchSha(args),
131
+ gitSpawnFn: args.gitSpawnFn,
132
+ });
133
+ if (pushedDigest !== null && pushedDigest === held.diffDigest) {
134
+ setPhase('code-review');
135
+ args.pauseTimer();
136
+ progress(
137
+ 'REVIEW',
138
+ `Posting the held Story-scope review → PR #${args.prNumber}...`,
139
+ );
140
+ return await postHeldReview(held, args);
141
+ }
142
+ discardHeldReview(
143
+ held,
144
+ 'the pushed diff is not the reviewed diff; re-running serially',
145
+ progress,
146
+ );
147
+ }
148
+ setPhase('code-review');
149
+ return await runStoryScopeReview({
150
+ cwd: args.cwd,
151
+ storyId: args.storyId,
152
+ storyBranch: args.storyBranch,
153
+ baseBranch: args.baseBranch,
154
+ prUrl: args.prUrl,
155
+ prNumber: args.prNumber,
156
+ provider: args.provider,
157
+ runCodeReviewFn: args.runCodeReviewFn,
158
+ gitSpawnFn: args.gitSpawnFn,
159
+ progress,
160
+ });
161
+ }
@@ -33,7 +33,7 @@ import { deriveCloseNote } from './close-note.js';
33
33
  import { runAutoMergePhase } from './phases/auto-merge.js';
34
34
  import { runBaseSyncPhase } from './phases/base-sync.js';
35
35
  import { runCloseValidationPhase } from './phases/close-validation.js';
36
- import { parsePrNumber, runStoryScopeReview } from './phases/code-review.js';
36
+ import { parsePrNumber } from './phases/code-review.js';
37
37
  import { runConfirmMergePhase } from './phases/confirm-merge.js';
38
38
  import { runGraphqlPreflight } from './phases/graphql-preflight.js';
39
39
  import { lockWaitPending } from './phases/lock-wait-pending.js';
@@ -45,11 +45,17 @@ import { handleCriticalReviewBlock } from './phases/review-block.js';
45
45
  import { handleOverriddenReviewBlock } from './phases/review-override.js';
46
46
  import { reapWorktreePhase } from './phases/worktree-reap.js';
47
47
  import { runWrongTreeGuardPhase } from './phases/wrong-tree-guard.js';
48
+ import {
49
+ discardHeldReview,
50
+ reviewAfterPrOpen,
51
+ startHeldReview,
52
+ } from './review-overlap.js';
48
53
 
49
54
  const progress = Logger.createProgress('single-story-close', { stderr: true });
50
55
 
51
56
  /**
52
57
  * Wall-clock seconds per named phase; each transition logs the phase it ends.
58
+ * Overlapping work `pause`s the clock and `record`s its own time.
53
59
  *
54
60
  * @param {() => number} [nowMs]
55
61
  */
@@ -57,11 +63,14 @@ function createPhaseTimer(nowMs = Date.now) {
57
63
  const durations = {};
58
64
  let current = null;
59
65
  let since = 0;
66
+ const record = (phase, ms) => {
67
+ const seconds = Math.round(Math.max(0, ms) / 100) / 10;
68
+ durations[phase] = (durations[phase] ?? 0) + seconds;
69
+ progress('TIMING', `⏱ ${phase}: ${seconds}s`);
70
+ };
60
71
  const end = () => {
61
72
  if (current === null) return;
62
- const seconds = Math.round((nowMs() - since) / 100) / 10;
63
- durations[current] = (durations[current] ?? 0) + seconds;
64
- progress('TIMING', `⏱ ${current}: ${seconds}s`);
73
+ record(current, nowMs() - since);
65
74
  current = null;
66
75
  };
67
76
  return {
@@ -71,6 +80,8 @@ function createPhaseTimer(nowMs = Date.now) {
71
80
  current = phase;
72
81
  since = nowMs();
73
82
  },
83
+ record,
84
+ pause: end,
74
85
  finish() {
75
86
  end();
76
87
  return Object.keys(durations).length > 0 ? { ...durations } : null;
@@ -257,6 +268,7 @@ function resolveWorktreePath({ cwd, config, storyId }) {
257
268
  * @returns {Promise<{
258
269
  * validationGates: Record<string, string>|null,
259
270
  * lockWait: { waitedSeconds: number, expired: boolean }|null,
271
+ * suiteTimings: object|null,
260
272
  * pending: boolean,
261
273
  * }>} `validationGates` is null when skipped; `pending` means a full-suite
262
274
  * lock wait expired and the gates deferred.
@@ -293,7 +305,12 @@ async function runPrePushPhases(ctx, deps) {
293
305
  }
294
306
  if (options.skipValidation) {
295
307
  progress('VALIDATE', '⏭ Skipped (--skip-validation).');
296
- return { validationGates: null, lockWait: null, pending: false };
308
+ return {
309
+ validationGates: null,
310
+ lockWait: null,
311
+ suiteTimings: null,
312
+ pending: false,
313
+ };
297
314
  }
298
315
  setPhase('close-validation');
299
316
  let validation;
@@ -308,8 +325,22 @@ async function runPrePushPhases(ctx, deps) {
308
325
  progress,
309
326
  runCloseValidation,
310
327
  buildDefaultGates,
328
+ onPreGateStepsDone: () => {
329
+ ctx.heldReview = startHeldReview({
330
+ cwd,
331
+ storyId,
332
+ storyBranch,
333
+ baseBranch,
334
+ provider: deps.provider,
335
+ runCodeReviewFn: deps.runCodeReview,
336
+ gitSpawnFn: gitSpawn,
337
+ progress,
338
+ config: deps.config,
339
+ });
340
+ },
311
341
  });
312
342
  } catch (err) {
343
+ discardHeldReview(ctx.heldReview, 'validation failed', progress);
313
344
  // The gate that died is all this run observed; the envelope claims no more.
314
345
  if (typeof err?.closeGate === 'string') {
315
346
  ctx.setObservedGates({ [err.closeGate]: 'failed' });
@@ -321,6 +352,7 @@ async function runPrePushPhases(ctx, deps) {
321
352
  return {
322
353
  validationGates: gates,
323
354
  lockWait: validation?.lockWait ?? null,
355
+ suiteTimings: validation?.suiteTimings ?? null,
324
356
  pending: validation?.pending === true,
325
357
  };
326
358
  }
@@ -396,10 +428,11 @@ async function openAndReviewPr(ctx, deps) {
396
428
  const prNumber = parsePrNumber(prUrl);
397
429
  // Already merged (landed between invocations): skip review and arm; confirm observes it.
398
430
  if (alreadyMerged) {
431
+ discardHeldReview(ctx.heldReview, 'PR already merged', progress);
399
432
  return { prUrl, prNumber, alreadyMerged: true };
400
433
  }
401
- ctx.setPhase('code-review');
402
- const reviewOutcome = await runStoryScopeReview({
434
+ const reviewOutcome = await reviewAfterPrOpen({
435
+ held: ctx.heldReview,
403
436
  cwd,
404
437
  storyId,
405
438
  storyBranch,
@@ -410,6 +443,9 @@ async function openAndReviewPr(ctx, deps) {
410
443
  runCodeReviewFn: deps.runCodeReview,
411
444
  gitSpawnFn: gitSpawn,
412
445
  progress,
446
+ setPhase: ctx.setPhase,
447
+ pauseTimer: ctx.phaseTimer.pause,
448
+ recordDuration: ctx.phaseTimer.record,
413
449
  });
414
450
  const reviewOverride = reviewOutcome.halted
415
451
  ? await resolveReviewHalt(ctx, deps, { prUrl, prNumber, reviewOutcome })
@@ -690,6 +726,7 @@ async function finishWithMergeWait(prCtx, deps) {
690
726
  autoMergeEnabled: prCtx.autoMergeEnabled,
691
727
  gates: prCtx.gates,
692
728
  lockWait: prCtx.lockWait,
729
+ suiteTimings: prCtx.suiteTimings,
693
730
  elapsedSeconds: elapsedSecondsSince(prCtx.startedAtMs),
694
731
  });
695
732
  const result = closeResult({
@@ -763,6 +800,7 @@ async function finishWithoutMergeWait(prCtx, waitForMergeReason) {
763
800
  },
764
801
  gates: prCtx.gates,
765
802
  lockWait: prCtx.lockWait,
803
+ suiteTimings: prCtx.suiteTimings,
766
804
  nextCommand: NEXT_COMMANDS.confirmMerge(prCtx.storyId),
767
805
  elapsedSeconds: elapsedSecondsSince(prCtx.startedAtMs),
768
806
  });
@@ -891,6 +929,7 @@ async function prePushPhase(ctx, deps) {
891
929
  deps,
892
930
  );
893
931
  if (!ctx.prePush.pending) return null;
932
+ discardHeldReview(ctx.heldReview, 'validation pending', progress);
894
933
  return await finishDeferred(ctx.prePush.lockWait, {
895
934
  storyId: ctx.storyId,
896
935
  storyBranch: ctx.storyBranch,
@@ -991,6 +1030,7 @@ async function finishPhase(ctx, deps) {
991
1030
  phaseTimer: ctx.phaseTimer,
992
1031
  workerTokens: ctx.workerTokens,
993
1032
  lockWait: ctx.prePush.lockWait,
1033
+ suiteTimings: ctx.prePush.suiteTimings,
994
1034
  gates: closeEnvelopeGates(
995
1035
  options,
996
1036
  ctx.prePush.validationGates,
@@ -114,8 +114,9 @@ function compact(obj) {
114
114
  * @param {string|null} [args.nextCommand]
115
115
  * @param {number} args.elapsedSeconds
116
116
  * @param {object|null} [args.waitBudget]
117
- * @param {{ waitedSeconds: number, expired: boolean }|null} [args.lockWait]
117
+ * @param {{ waitedSeconds: number, expired: boolean, holder?: object }|null} [args.lockWait]
118
118
  * Full-suite lock wait; `waitBudget` is merge-wait only.
119
+ * @param {{ lockWaitMs: number, hostWaitMs: number|null, testRunMs: number }|null} [args.suiteTimings]
119
120
  * @param {Record<string, number>|null} [args.phaseDurations] Seconds per phase.
120
121
  * @param {string} [args.timestamp]
121
122
  * @param {{ schema: object|null, error: string|null }} [args.schemaSource]
@@ -138,6 +139,7 @@ export function buildTerminalEnvelope({
138
139
  elapsedSeconds = 0,
139
140
  waitBudget,
140
141
  lockWait,
142
+ suiteTimings,
141
143
  phaseDurations,
142
144
  timestamp = new Date().toISOString(),
143
145
  schemaSource,
@@ -160,6 +162,7 @@ export function buildTerminalEnvelope({
160
162
  elapsedSeconds: Math.max(0, Number(elapsedSeconds) || 0),
161
163
  waitBudget: waitBudget ?? null,
162
164
  lockWait: lockWait ?? null,
165
+ suiteTimings: suiteTimings ?? null,
163
166
  phaseDurations,
164
167
  timestamp,
165
168
  });
@@ -324,35 +327,24 @@ export function emitTerminalEnvelope(
324
327
  }
325
328
 
326
329
  /**
327
- * Map a `runConfirmMergePhase` outcome onto the terminal envelope.
330
+ * Map a `runConfirmMergePhase` outcome onto the terminal envelope; the other
331
+ * fields pass through.
328
332
  *
329
333
  * @returns {object} A validated `story-deliver-terminal` envelope.
330
334
  */
331
335
  export function terminalFromWaitOutcome({
332
336
  waitOutcome,
333
- storyId,
334
- storyBranch,
335
- baseBranch,
336
337
  prNumber,
337
338
  prUrl,
338
339
  autoMergeEnabled,
339
- gates,
340
- lockWait,
341
- elapsedSeconds,
340
+ ...common
342
341
  }) {
342
+ const { storyId } = common;
343
343
  const prBase = {
344
344
  number: prNumber,
345
345
  url: prUrl ?? null,
346
346
  autoMergeEnabled: Boolean(autoMergeEnabled),
347
347
  };
348
- const common = {
349
- storyId,
350
- storyBranch,
351
- baseBranch,
352
- gates,
353
- lockWait,
354
- elapsedSeconds,
355
- };
356
348
 
357
349
  if (waitOutcome.terminal === 'landed') {
358
350
  return buildTerminalEnvelope({
@@ -27,7 +27,7 @@ export function groupSpawnOptions(platform = process.platform) {
27
27
  * @param {{ platform?: string, killFn?: (pid: number, signal: string) => void }} [opts]
28
28
  * @returns {boolean}
29
29
  */
30
- function killProcessGroup(
30
+ export function killProcessGroup(
31
31
  child,
32
32
  signal = 'SIGKILL',
33
33
  { platform = process.platform, killFn = process.kill.bind(process) } = {},