mandrel 2.64.0 → 2.65.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 (67) hide show
  1. package/.agents/agents/acceptance-critic.md +3 -2
  2. package/.agents/agents/auditor.md +3 -2
  3. package/.agents/agents/plan-critic.md +3 -2
  4. package/.agents/agents/story-worker.md +2 -2
  5. package/.agents/audit-checklists/quality.md +3 -0
  6. package/.agents/docs/agentrc-reference.json +1 -9
  7. package/.agents/docs/configuration.md +8 -7
  8. package/.agents/schemas/agentrc.schema.json +6 -13
  9. package/.agents/schemas/audit-rules.schema.json +1 -1
  10. package/.agents/schemas/story-deliver-terminal.schema.json +5 -0
  11. package/.agents/scripts/bootstrap.js +8 -2
  12. package/.agents/scripts/check-context-budget.js +1 -1
  13. package/.agents/scripts/lib/ITicketingProvider.js +1 -3
  14. package/.agents/scripts/lib/audit-suite/findings.js +1 -17
  15. package/.agents/scripts/lib/audit-suite/frontmatter.js +0 -28
  16. package/.agents/scripts/lib/audit-suite/index.js +0 -6
  17. package/.agents/scripts/lib/audit-suite/selector.js +0 -31
  18. package/.agents/scripts/lib/bootstrap/agents-md-fold.js +156 -0
  19. package/.agents/scripts/lib/bootstrap/commit-push.js +1 -1
  20. package/.agents/scripts/lib/bootstrap/manifest.js +2 -2
  21. package/.agents/scripts/lib/bootstrap/project-bootstrap.js +13 -29
  22. package/.agents/scripts/lib/config/review-chain-default.js +13 -0
  23. package/.agents/scripts/lib/config-settings-schema-delivery.js +2 -2
  24. package/.agents/scripts/lib/config-settings-schema-quality.js +11 -13
  25. package/.agents/scripts/lib/doc-tiers.js +25 -6
  26. package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
  27. package/.agents/scripts/lib/observability/metrics-ledger.js +0 -72
  28. package/.agents/scripts/lib/orchestration/code-review.js +11 -6
  29. package/.agents/scripts/lib/orchestration/epic-rollup.js +29 -12
  30. package/.agents/scripts/lib/orchestration/merge-block-class.js +20 -4
  31. package/.agents/scripts/lib/orchestration/merge-poll.js +41 -22
  32. package/.agents/scripts/lib/orchestration/required-checks.js +147 -0
  33. package/.agents/scripts/lib/orchestration/review-providers/code-review.js +203 -0
  34. package/.agents/scripts/lib/orchestration/review-providers/review-provider-factory.js +6 -4
  35. package/.agents/scripts/lib/orchestration/review-providers/security-review.js +3 -2
  36. package/.agents/scripts/lib/orchestration/single-story-close/failed-terminal.js +1 -0
  37. package/.agents/scripts/lib/orchestration/single-story-close/phases/code-review.js +0 -12
  38. package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +27 -11
  39. package/.agents/scripts/lib/orchestration/single-story-close/phases/post-land.js +112 -82
  40. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +72 -5
  41. package/.agents/scripts/lib/orchestration/story-close/phases/review-core.js +12 -87
  42. package/.agents/scripts/lib/orchestration/story-deliver-terminal.js +3 -0
  43. package/.agents/scripts/lib/templates/decomposer-prompts.js +5 -24
  44. package/.agents/scripts/providers/github/issues.js +14 -23
  45. package/.agents/scripts/sync-claude-agents.js +1 -1
  46. package/.agents/workflows/audit-quality.md +42 -7
  47. package/.agents/workflows/helpers/acceptance-self-eval.md +1 -1
  48. package/.agents/workflows/helpers/code-review.md +15 -38
  49. package/.agents/workflows/helpers/deliver-reference.md +4 -2
  50. package/.agents/workflows/helpers/deliver-story.md +3 -0
  51. package/.agents/workflows/helpers/plan-reference.md +9 -8
  52. package/.agents/workflows/mandrel-deliver.md +2 -1
  53. package/.agents/workflows/mandrel-plan.md +10 -7
  54. package/.agents/workflows/mandrel-update.md +5 -3
  55. package/docs/CHANGELOG.md +31 -0
  56. package/lib/cli/claude-code-version.js +73 -0
  57. package/lib/cli/doctor.js +2 -2
  58. package/lib/cli/registry.js +9 -0
  59. package/lib/cli/uninstall.js +37 -9
  60. package/lib/migrations/index.js +2 -0
  61. package/lib/migrations/steps/2.65.0-fold-claude-md-into-agents-md.js +38 -0
  62. package/package.json +2 -1
  63. package/.agents/scripts/lib/audit-suite/lens-diff-floor.js +0 -99
  64. package/.agents/scripts/lib/audit-suite/runner.js +0 -205
  65. package/.agents/scripts/lib/audit-suite/substitutions.js +0 -96
  66. package/.agents/scripts/lib/audit-suite/workflow-loader.js +0 -37
  67. package/.agents/scripts/lib/orchestration/story-close/phases/local-lens-review.js +0 -234
@@ -0,0 +1,203 @@
1
+ /**
2
+ * review-providers/code-review.js — a low-effort model bug review of the
3
+ * Story diff through `claude --print --effort low`. It asks only for
4
+ * merge-blocking problems, so every parsed finding is `critical` and halts
5
+ * close before auto-merge. The reviewer is handed the diff range, the Story
6
+ * id and the diff text — never `acceptance[]` or the self-eval verdict: this
7
+ * is a bug review, not a second acceptance scoring.
8
+ *
9
+ * A missing CLI throws at construction (the default chain entry is
10
+ * `optional: true`, so hosts without it skip). A review that cannot run or
11
+ * whose output does not parse degrades to one non-halting `suggestion`.
12
+ *
13
+ * @typedef {import('./types.js').Finding} Finding
14
+ * @typedef {import('./types.js').ReviewInput} ReviewInput
15
+ * @typedef {import('./types.js').ReviewProvider} ReviewProvider
16
+ */
17
+
18
+ import { spawnCapture } from '../../child-exec.js';
19
+ import { gitSpawn } from '../../git-utils.js';
20
+ import { PROJECT_ROOT } from '../../project-root.js';
21
+ import { parseProviderFindings } from './parse-findings.js';
22
+ import { renderDepthDirective } from './review-depth.js';
23
+ import { probeClaudeCli } from './security-review.js';
24
+
25
+ const CLAUDE_ARGS = Object.freeze(['--print', '--effort', 'low']);
26
+ const INVOKE_TIMEOUT_MS = 10 * 60 * 1000;
27
+ /** Larger diffs are truncated; the reviewer is told so. */
28
+ const MAX_DIFF_CHARS = 200_000;
29
+
30
+ const PROMPT_HEAD =
31
+ 'You are reviewing the diff `{baseRef}...{headRef}` for Story #{ticketId} ' +
32
+ 'before it merges. {depthDirective}\n\n' +
33
+ 'Report ONLY problems you would block this merge for: a bug that makes ' +
34
+ 'the change behave incorrectly, crash, lose data, or break an existing ' +
35
+ 'caller. Do not report style, naming, refactoring ideas, missing tests or ' +
36
+ 'anything you would merge anyway. For each problem give the file, the ' +
37
+ 'line in the new version, why it is wrong, and how to show it fails (an ' +
38
+ 'input, command or test that exposes it).\n\n' +
39
+ 'The diff below is authoritative — files on disk may not reflect it. ' +
40
+ 'Emit ONLY a JSON array on stdout with this exact shape, no prose around ' +
41
+ 'it:\n\n' +
42
+ '[{"title":"...","body":"Why it is wrong: ... How to show it fails: ...",' +
43
+ '"file":"...","line":1,"category":"bug"}]\n\n' +
44
+ 'Emit [] if there is nothing you would block the merge for.\n\n';
45
+
46
+ /**
47
+ * @param {ReviewInput} input
48
+ * @param {string} diff
49
+ * @returns {string}
50
+ */
51
+ function buildPrompt(input, diff) {
52
+ const truncated = diff.length > MAX_DIFF_CHARS;
53
+ const body = truncated ? diff.slice(0, MAX_DIFF_CHARS) : diff;
54
+ const note = truncated
55
+ ? `\n[diff truncated at ${MAX_DIFF_CHARS} characters]\n`
56
+ : '';
57
+ const head = PROMPT_HEAD.replace('{baseRef}', input.baseRef)
58
+ .replace('{headRef}', input.headRef)
59
+ .replace('{ticketId}', String(input.ticketId))
60
+ .replace('{depthDirective}', renderDepthDirective(input.depth));
61
+ return `${head}<diff>\n${body}${note}\n</diff>\n`;
62
+ }
63
+
64
+ /**
65
+ * The prompt rides stdin so no shell ever quotes it.
66
+ *
67
+ * @param {string} prompt
68
+ * @param {Function} [run] - `spawnSync`-shaped seam for tests.
69
+ * @returns {{ status: number, stdout: string, stderr: string }}
70
+ */
71
+ function invokeClaude(prompt, run) {
72
+ return spawnCapture('claude', [...CLAUDE_ARGS], {
73
+ cwd: PROJECT_ROOT,
74
+ input: prompt,
75
+ shell: process.platform === 'win32',
76
+ timeout: INVOKE_TIMEOUT_MS,
77
+ ...(run ? { run } : {}),
78
+ });
79
+ }
80
+
81
+ /** A model often fences its JSON; strip one surrounding fence. */
82
+ function stripFence(text) {
83
+ const match = /^\s*```[a-z]*\s*\n([\s\S]*?)\n\s*```\s*$/i.exec(text ?? '');
84
+ return match ? match[1] : (text ?? '');
85
+ }
86
+
87
+ /**
88
+ * @param {string} title
89
+ * @param {string} detail
90
+ * @returns {Finding}
91
+ */
92
+ function advisory(title, detail) {
93
+ return {
94
+ severity: 'suggestion',
95
+ title,
96
+ body:
97
+ `${detail} The model bug review did not produce a verdict — inspect ` +
98
+ 'the diff manually before merging. Advisory only; the chain did not halt.',
99
+ category: 'bug',
100
+ };
101
+ }
102
+
103
+ /**
104
+ * @param {string} stdout
105
+ * @returns {Finding[]}
106
+ */
107
+ function parseFindings(stdout) {
108
+ try {
109
+ return parseProviderFindings(stripFence(stdout), {
110
+ errorPrefix: '[code-review] Failed to parse reviewer stdout as JSON',
111
+ mapSeverity: () => 'critical',
112
+ defaultCategory: 'bug',
113
+ });
114
+ } catch (err) {
115
+ return [
116
+ advisory(
117
+ 'Code review output not parseable as JSON',
118
+ `The reviewer returned text that did not parse as a JSON findings array (${err.message}).`,
119
+ ),
120
+ ];
121
+ }
122
+ }
123
+
124
+ /**
125
+ * @param {ReviewInput} input
126
+ */
127
+ function assertInput(input) {
128
+ const { baseRef, headRef, ticketId } = input ?? {};
129
+ if (!baseRef || !headRef) {
130
+ throw new TypeError(
131
+ '[code-review] runReview requires baseRef and headRef.',
132
+ );
133
+ }
134
+ if (!Number.isInteger(ticketId) || ticketId <= 0) {
135
+ throw new TypeError(
136
+ '[code-review] runReview requires a positive integer ticketId.',
137
+ );
138
+ }
139
+ }
140
+
141
+ /**
142
+ * @param {{
143
+ * probeFn?: () => boolean,
144
+ * gitSpawnFn?: typeof gitSpawn,
145
+ * spawnFn?: Function,
146
+ * logger?: { info?: Function, warn?: Function },
147
+ * }} [deps] - `spawnFn` replaces `spawnSync` for the `claude` call.
148
+ * @returns {ReviewProvider}
149
+ */
150
+ export function createCodeReviewProviderForRegistry(deps = {}) {
151
+ const probeFn = deps.probeFn ?? probeClaudeCli;
152
+ if (!probeFn()) {
153
+ throw new Error(
154
+ '[ReviewProviderFactory] codeReview provider "code-review" requires ' +
155
+ 'the `claude` CLI on PATH but it was not detected. Install the ' +
156
+ 'Claude Code CLI, or keep the entry `optional: true` so hosts ' +
157
+ 'without it skip the model bug review.',
158
+ );
159
+ }
160
+ const gitSpawnFn = deps.gitSpawnFn ?? gitSpawn;
161
+ const { spawnFn, logger } = deps;
162
+
163
+ return {
164
+ async runReview(input) {
165
+ assertInput(input);
166
+ const { baseRef, headRef, ticketId } = input;
167
+ const diff = gitSpawnFn(
168
+ PROJECT_ROOT,
169
+ 'diff',
170
+ '--no-color',
171
+ `${baseRef}...${headRef}`,
172
+ );
173
+ if (diff.status !== 0) {
174
+ return [
175
+ advisory(
176
+ 'Code review could not read the diff',
177
+ `\`git diff ${baseRef}...${headRef}\` failed: ${diff.stderr || '<no output>'}.`,
178
+ ),
179
+ ];
180
+ }
181
+ if (diff.stdout.trim().length === 0) return [];
182
+
183
+ logger?.info?.(
184
+ `[code-review] Invoking claude --print --effort low for Story #${ticketId} (${baseRef}...${headRef})...`,
185
+ );
186
+ const result = invokeClaude(buildPrompt(input, diff.stdout), spawnFn);
187
+ if (result.status !== 0) {
188
+ logger?.warn?.(
189
+ `[code-review] claude exited ${result.status}; emitting advisory.`,
190
+ );
191
+ return [
192
+ advisory(
193
+ 'Code review did not complete',
194
+ `\`claude --print\` exited with status ${result.status}: ${
195
+ result.stderr || result.stdout || '<no output>'
196
+ }.`,
197
+ ),
198
+ ];
199
+ }
200
+ return parseFindings(result.stdout);
201
+ },
202
+ };
203
+ }
@@ -14,6 +14,8 @@
14
14
  * @typedef {import('./types.js').ProviderChain} ProviderChain
15
15
  */
16
16
 
17
+ import { DEFAULT_REVIEW_PROVIDERS } from '../../config/review-chain-default.js';
18
+ import { createCodeReviewProviderForRegistry } from './code-review.js';
17
19
  import { createCodexProviderForRegistry } from './codex.js';
18
20
  import { mergeChainDegradations } from './degraded-gates.js';
19
21
  import { createNativeProviderForRegistry } from './native.js';
@@ -22,6 +24,7 @@ import { createUltrareviewProviderForRegistry } from './ultrareview.js';
22
24
 
23
25
  /** @type {Readonly<Record<string, () => ReviewProvider>>} */
24
26
  const INLINE_PROVIDERS = Object.freeze({
27
+ 'code-review': createCodeReviewProviderForRegistry,
25
28
  codex: createCodexProviderForRegistry,
26
29
  native: createNativeProviderForRegistry,
27
30
  'security-review': createSecurityReviewProviderForRegistry,
@@ -36,8 +39,6 @@ const PROMPT_PROVIDERS = Object.freeze({
36
39
  ultrareview: createUltrareviewProviderForRegistry,
37
40
  });
38
41
 
39
- export const DEFAULT_PROVIDER_NAME = 'native';
40
-
41
42
  /**
42
43
  * Gate predicate from a `when` clause (`label` / `labelAny`); absent → always true.
43
44
  *
@@ -79,7 +80,8 @@ export function isScopeApplicable(declaredScopes, currentScope) {
79
80
  }
80
81
 
81
82
  /**
82
- * Takes the `codeReview` sub-object; unset/empty `providers` defaults to native.
83
+ * Takes the `codeReview` sub-object; unset/empty `providers` falls back to
84
+ * `DEFAULT_REVIEW_PROVIDERS`.
83
85
  *
84
86
  * @param {{
85
87
  * providers?: Array<object>,
@@ -105,7 +107,7 @@ export function createReviewProvider(codeReviewConfig, opts = {}) {
105
107
  Array.isArray(codeReviewConfig.providers) &&
106
108
  codeReviewConfig.providers.length > 0
107
109
  ? codeReviewConfig.providers
108
- : [{ name: DEFAULT_PROVIDER_NAME }];
110
+ : DEFAULT_REVIEW_PROVIDERS;
109
111
 
110
112
  const chain = buildProviderChain(entries, {
111
113
  inlineRegistry,
@@ -24,11 +24,12 @@ export const SECURITY_REVIEW_REMEDIATIONS = Object.freeze({
24
24
 
25
25
  /**
26
26
  * Synchronous so a missing CLI surfaces at construction, not mid-review.
27
+ * Shared by every `claude --print` provider.
27
28
  *
28
29
  * @param {{ spawnFn?: typeof spawnSync }} [opts]
29
30
  * @returns {boolean}
30
31
  */
31
- function defaultProbeClaudeCli(opts = {}) {
32
+ export function probeClaudeCli(opts = {}) {
32
33
  const spawnFn = opts.spawnFn ?? spawnSync;
33
34
  try {
34
35
  const result = spawnFn('claude', ['--version'], {
@@ -173,7 +174,7 @@ export function buildUnparseableFallbackFinding() {
173
174
  * @returns {ReviewProvider}
174
175
  */
175
176
  export function createSecurityReviewProvider(deps = {}) {
176
- const probeFn = deps.probeFn ?? defaultProbeClaudeCli;
177
+ const probeFn = deps.probeFn ?? probeClaudeCli;
177
178
  if (!probeFn()) {
178
179
  throw buildSecurityReviewUnavailableError();
179
180
  }
@@ -107,6 +107,7 @@ export function failedTerminalFor(err, args = {}) {
107
107
  failure: { reason: String(err?.message ?? err) },
108
108
  nextCommand: NEXT_COMMANDS.recover(storyId),
109
109
  elapsedSeconds: 0,
110
+ phaseDurations: err?.closePhaseDurations ?? undefined,
110
111
  });
111
112
  } catch (buildErr) {
112
113
  Logger.error(
@@ -56,8 +56,6 @@ async function invokeStoryReviewCore({
56
56
  prNumber,
57
57
  provider,
58
58
  runCodeReviewFn,
59
- runLocalLensReviewFn,
60
- appendFindingsYieldFn,
61
59
  gitSpawnFn,
62
60
  progress,
63
61
  }) {
@@ -71,8 +69,6 @@ async function invokeStoryReviewCore({
71
69
  progressTag: 'REVIEW',
72
70
  runCodeReviewFn,
73
71
  gitSpawnFn,
74
- ...(runLocalLensReviewFn ? { runLocalLensReviewFn } : {}),
75
- ...(appendFindingsYieldFn ? { appendFindingsYieldFn } : {}),
76
72
  });
77
73
  }
78
74
 
@@ -131,8 +127,6 @@ async function postStoryReviewCrossRef({
131
127
  * prNumber: number|null,
132
128
  * provider: object,
133
129
  * runCodeReviewFn: Function,
134
- * runLocalLensReviewFn?: Function,
135
- * appendFindingsYieldFn?: Function,
136
130
  * gitSpawnFn?: Function,
137
131
  * progress: (tag: string, msg: string) => void,
138
132
  * }} args
@@ -145,7 +139,6 @@ async function postStoryReviewCrossRef({
145
139
  * degraded?: boolean,
146
140
  * degradations?: Array<object>,
147
141
  * crossRefPosted?: boolean,
148
- * localLensReview?: object,
149
142
  * }>}
150
143
  */
151
144
  export async function runStoryScopeReview({
@@ -157,8 +150,6 @@ export async function runStoryScopeReview({
157
150
  prNumber,
158
151
  provider,
159
152
  runCodeReviewFn,
160
- runLocalLensReviewFn,
161
- appendFindingsYieldFn,
162
153
  gitSpawnFn,
163
154
  progress,
164
155
  }) {
@@ -192,8 +183,6 @@ export async function runStoryScopeReview({
192
183
  prNumber,
193
184
  provider,
194
185
  runCodeReviewFn,
195
- runLocalLensReviewFn,
196
- appendFindingsYieldFn,
197
186
  gitSpawnFn,
198
187
  progress,
199
188
  });
@@ -229,6 +218,5 @@ export async function runStoryScopeReview({
229
218
  postedCommentId: result.postedCommentId ?? null,
230
219
  ...degradationEnvelope(result.degradations),
231
220
  crossRefPosted,
232
- localLensReview: result.localLensReview,
233
221
  };
234
222
  }
@@ -44,14 +44,15 @@ import {
44
44
  decideMergeWaitFailFast,
45
45
  deriveChecksStatus,
46
46
  deriveRedHeadRuns,
47
- deriveRequiredRunEvidence,
48
47
  isPrMerged,
49
48
  MERGE_WAIT_GH_TIMEOUT_MS,
50
49
  parseWorkflowRunId,
50
+ pollIntervalMs,
51
51
  readRunSummary,
52
52
  resolveAdvisoryGateVerdict,
53
53
  } from '../../merge-poll.js';
54
54
  import { readMergeQueueState } from '../../merge-queue.js';
55
+ import { readProbeRunEvidence } from '../../required-checks.js';
55
56
  import { NEXT_COMMANDS } from '../../story-deliver-terminal.js';
56
57
  import {
57
58
  postStructuredComment,
@@ -155,6 +156,7 @@ export async function readPrWaitProbe({
155
156
  gh = defaultGh,
156
157
  ghTimeoutMs = MERGE_WAIT_GH_TIMEOUT_MS,
157
158
  readMergeQueueStateFn = readMergeQueueState,
159
+ readRequiredCheckNamesFn,
158
160
  }) {
159
161
  try {
160
162
  const view = await withGhTimeout(
@@ -188,7 +190,14 @@ export async function readPrWaitProbe({
188
190
  }),
189
191
  // Head-anchored, so superseded/pending runs don't read as red; `null`
190
192
  // when the rollup is empty (the consecutive-probe fallback owns that).
191
- requiredRunEvidence: deriveRequiredRunEvidence(view?.statusCheckRollup),
193
+ requiredRunEvidence: await readProbeRunEvidence({
194
+ view,
195
+ checksStatus,
196
+ prNumber,
197
+ gh,
198
+ ghTimeoutMs,
199
+ readFn: readRequiredCheckNamesFn,
200
+ }),
192
201
  redHeadRuns: deriveRedHeadRuns(view?.statusCheckRollup),
193
202
  headSha: readString(view?.headRefOid),
194
203
  };
@@ -1105,7 +1114,7 @@ export async function runConfirmMergePhase({
1105
1114
  remaining: rerunAllowance,
1106
1115
  issued: new Set(),
1107
1116
  };
1108
- const intervalMs = intervalSeconds * 1000;
1117
+ let intervalMs = intervalSeconds * 1000;
1109
1118
  const startedAtMs = nowMsFn();
1110
1119
  let anchorMs = startedAtMs;
1111
1120
  let updatesUsed = 0;
@@ -1129,6 +1138,7 @@ export async function runConfirmMergePhase({
1129
1138
  ghTimeoutMs,
1130
1139
  });
1131
1140
  polls += 1;
1141
+ intervalMs = pollIntervalMs(probe.checksStatus, intervalSeconds);
1132
1142
 
1133
1143
  anchorMs = resolveBudgetAnchorMs({
1134
1144
  createdAt: probe.createdAt,
@@ -1147,12 +1157,7 @@ export async function runConfirmMergePhase({
1147
1157
  // Heartbeat: a backgrounded close's output-file growth is its liveness signal.
1148
1158
  progress?.(
1149
1159
  'CONFIRM',
1150
- `⏱ poll ${polls}: PR #${prNumber} state=${probe.state ?? 'unknown'} ` +
1151
- `checks=${probe.checksStatus ?? 'unknown'} ` +
1152
- `mergeState=${probe.mergeStateStatus ?? 'unknown'} ` +
1153
- `(${waitBudget.waitedSeconds}s of ${maxWaitSeconds}s this invocation; ` +
1154
- `${waitBudget.cumulativeSeconds}s of ${maxBudgetSeconds}s cumulative)` +
1155
- (probe.error ? ` — probe error: ${probe.error}` : ''),
1160
+ pollHeartbeat({ polls, prNumber, probe, waitBudget }),
1156
1161
  );
1157
1162
 
1158
1163
  if (isPrMerged(probe)) {
@@ -1324,13 +1329,24 @@ export async function runConfirmMergePhase({
1324
1329
  },
1325
1330
  predicate: (result) => result?.done === true,
1326
1331
  intervalMs,
1327
- // No pollUntil timeout: the tick owns both bounds and their classification.
1328
- sleepFn: (ms) => sleepFn(ms),
1332
+ // No pollUntil timeout: the tick owns both bounds and the cadence.
1333
+ sleepFn: () => sleepFn(intervalMs),
1329
1334
  });
1330
1335
  if (tick.thrown) throw tick.thrown;
1331
1336
  return tick.outcome;
1332
1337
  }
1333
1338
 
1339
+ function pollHeartbeat({ polls, prNumber, probe, waitBudget }) {
1340
+ return (
1341
+ `⏱ poll ${polls}: PR #${prNumber} state=${probe.state ?? 'unknown'} ` +
1342
+ `checks=${probe.checksStatus ?? 'unknown'} ` +
1343
+ `mergeState=${probe.mergeStateStatus ?? 'unknown'} ` +
1344
+ `(${waitBudget.waitedSeconds}s of ${waitBudget.maxWaitSeconds}s this invocation; ` +
1345
+ `${waitBudget.cumulativeSeconds}s of ${waitBudget.maxBudgetSeconds}s cumulative)` +
1346
+ (probe.error ? ` — probe error: ${probe.error}` : '')
1347
+ );
1348
+ }
1349
+
1334
1350
  function doneWith(outcome) {
1335
1351
  return { done: true, outcome };
1336
1352
  }
@@ -282,11 +282,61 @@ async function stepPlanRunLabelReap({
282
282
  }
283
283
 
284
284
  /**
285
- * Run the post-land tail, sequentially. The ref reap and fast-forward (the
286
- * local-checkout mutations, reap first) run under a cross-process lock on the
287
- * main checkout, since concurrent closes race on the base ref and worktree
288
- * registry; GitHub steps stay outside it. The lock is best-effort: on
289
- * timeout the mutations run anyway.
285
+ * The local-checkout mutations (ref reap first, then fast-forward) under a
286
+ * cross-process lock on the main checkout, since concurrent closes race on
287
+ * the base ref and worktree registry. Best-effort: on timeout they run anyway.
288
+ */
289
+ async function runLockedLocalSteps({
290
+ storyId,
291
+ storyBranch,
292
+ baseBranch,
293
+ cwd,
294
+ config,
295
+ progress,
296
+ gitSpawnFn,
297
+ planFastForwardFn,
298
+ executeFastForwardFn,
299
+ acquireLockWithWaitFn,
300
+ }) {
301
+ const lockCfg = config?.delivery?.postLandLock ?? {};
302
+ const lock = await acquireLockWithWaitFn({
303
+ lockPath: postLandLockPath(cwd),
304
+ waitMs: lockCfg.waitMs,
305
+ pollMs: lockCfg.pollMs,
306
+ timeoutMs: lockCfg.timeoutMs,
307
+ ownerId: `post-land-${storyId}`,
308
+ });
309
+ if (!lock.acquired) {
310
+ progress?.(
311
+ 'POST-LAND',
312
+ `⚠️ post-land lock not acquired (${lock.reason}); proceeding unserialized.`,
313
+ );
314
+ }
315
+ try {
316
+ const refCleanup = await step(
317
+ () => stepRefCleanup({ cwd, storyBranch, progress, gitSpawnFn }),
318
+ { name: 'local ref cleanup', progress },
319
+ );
320
+ const baseFastForward = await step(
321
+ () =>
322
+ stepBaseFastForward({
323
+ cwd,
324
+ baseBranch,
325
+ progress,
326
+ planFastForwardFn,
327
+ executeFastForwardFn,
328
+ }),
329
+ { name: 'base fast-forward', progress },
330
+ );
331
+ return { refCleanup, baseFastForward };
332
+ } finally {
333
+ if (lock.acquired) lock.release();
334
+ }
335
+ }
336
+
337
+ /**
338
+ * Run the post-land tail: friction markers, then the GitHub steps and the
339
+ * locked local steps concurrently, then temp purge, then lease release.
290
340
  *
291
341
  * @param {object} args
292
342
  * @param {number} args.storyId
@@ -348,87 +398,67 @@ export async function runPostLandTail({
348
398
  config,
349
399
  });
350
400
 
351
- const followUps = await step(
352
- () =>
353
- stepFollowUps({
354
- storyId,
355
- provider,
356
- config,
357
- cwd,
358
- progress,
359
- captureStoryFollowUpsFn,
360
- }),
361
- { name: 'follow-up capture', progress },
362
- );
363
- const statusResync = await step(
364
- () =>
365
- stepStatusResync({
366
- storyId,
367
- provider,
368
- config,
369
- progress,
370
- reassertStatusColumnFn,
371
- }),
372
- { name: 'status-column resync', progress },
373
- );
374
- await step(
375
- () =>
376
- stepPlanRunLabelReap({
377
- storyId,
378
- provider,
379
- progress,
380
- reapPlanRunLabelsForStoryFn,
381
- }),
382
- { name: 'plan-run label reap', progress },
383
- );
384
-
385
- const epicRollup = await step(
386
- () =>
387
- stepEpicRollup({
388
- storyId,
389
- provider,
390
- config,
391
- progress,
392
- rollUpEpicForStoryFn,
393
- }),
394
- { name: 'epic rollup', progress },
395
- );
396
-
397
- const lockCfg = config?.delivery?.postLandLock ?? {};
398
- const lock = await acquireLockWithWaitFn({
399
- lockPath: postLandLockPath(cwd),
400
- waitMs: lockCfg.waitMs,
401
- pollMs: lockCfg.pollMs,
402
- timeoutMs: lockCfg.timeoutMs,
403
- ownerId: `post-land-${storyId}`,
404
- });
405
- if (!lock.acquired) {
406
- progress?.(
407
- 'POST-LAND',
408
- `⚠️ post-land lock not acquired (${lock.reason}); proceeding unserialized.`,
409
- );
410
- }
411
- let refCleanup;
412
- let baseFastForward;
413
- try {
414
- refCleanup = await step(
415
- () => stepRefCleanup({ cwd, storyBranch, progress, gitSpawnFn }),
416
- { name: 'local ref cleanup', progress },
417
- );
418
- baseFastForward = await step(
401
+ // The GitHub steps are independent of each other and of the local
402
+ // lock-held mutations, so all of them run concurrently.
403
+ const [followUps, statusResync, , epicRollup, local] = await Promise.all([
404
+ step(
419
405
  () =>
420
- stepBaseFastForward({
406
+ stepFollowUps({
407
+ storyId,
408
+ provider,
409
+ config,
421
410
  cwd,
422
- baseBranch,
423
411
  progress,
424
- planFastForwardFn,
425
- executeFastForwardFn,
412
+ captureStoryFollowUpsFn,
426
413
  }),
427
- { name: 'base fast-forward', progress },
428
- );
429
- } finally {
430
- if (lock.acquired) lock.release();
431
- }
414
+ { name: 'follow-up capture', progress },
415
+ ),
416
+ step(
417
+ () =>
418
+ stepStatusResync({
419
+ storyId,
420
+ provider,
421
+ config,
422
+ progress,
423
+ reassertStatusColumnFn,
424
+ }),
425
+ { name: 'status-column resync', progress },
426
+ ),
427
+ step(
428
+ () =>
429
+ stepPlanRunLabelReap({
430
+ storyId,
431
+ provider,
432
+ progress,
433
+ reapPlanRunLabelsForStoryFn,
434
+ }),
435
+ { name: 'plan-run label reap', progress },
436
+ ),
437
+ step(
438
+ () =>
439
+ stepEpicRollup({
440
+ storyId,
441
+ provider,
442
+ config,
443
+ progress,
444
+ rollUpEpicForStoryFn,
445
+ }),
446
+ { name: 'epic rollup', progress },
447
+ ),
448
+ runLockedLocalSteps({
449
+ storyId,
450
+ storyBranch,
451
+ baseBranch,
452
+ cwd,
453
+ config,
454
+ progress,
455
+ gitSpawnFn,
456
+ planFastForwardFn,
457
+ executeFastForwardFn,
458
+ acquireLockWithWaitFn,
459
+ }),
460
+ ]);
461
+ const { refCleanup, baseFastForward } = local;
432
462
 
433
463
  // After every step that reads the temp artifacts; `signals.ndjson` survives.
434
464
  const tempPurge = await step(