mandrel 1.84.0 → 1.86.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 (46) hide show
  1. package/.agents/docs/agentrc-reference.json +8 -2
  2. package/.agents/docs/configuration.md +7 -2
  3. package/.agents/instructions.md +4 -0
  4. package/.agents/rules/ci-remediation.md +131 -0
  5. package/.agents/rules/git-conventions.md +33 -0
  6. package/.agents/schemas/agentrc.schema.json +29 -6
  7. package/.agents/schemas/lifecycle/epic.watch.end.schema.json +2 -1
  8. package/.agents/scripts/boot-sweep.js +183 -0
  9. package/.agents/scripts/epic-deliver-prepare.js +55 -0
  10. package/.agents/scripts/git-pr-quality-gate.js +7 -5
  11. package/.agents/scripts/lib/config/ci.js +24 -3
  12. package/.agents/scripts/lib/config/explain.js +11 -3
  13. package/.agents/scripts/lib/config/github.js +11 -7
  14. package/.agents/scripts/lib/config-settings-schema-delivery.js +21 -0
  15. package/.agents/scripts/lib/config-settings-schema.js +6 -6
  16. package/.agents/scripts/lib/orchestration/epic-cleanup.js +289 -1
  17. package/.agents/scripts/lib/orchestration/finalize/open-or-locate-pr.js +65 -0
  18. package/.agents/scripts/lib/orchestration/git-cleanup/phases/git-probes-ff.js +83 -30
  19. package/.agents/scripts/lib/orchestration/lifecycle/listeners/automerge-predicate.js +401 -84
  20. package/.agents/scripts/lib/orchestration/lifecycle/listeners/branch-cleaner.js +8 -3
  21. package/.agents/scripts/lib/orchestration/lifecycle/listeners/finalizer.js +48 -3
  22. package/.agents/scripts/lib/orchestration/lifecycle/listeners/index.js +6 -1
  23. package/.agents/scripts/lib/orchestration/lifecycle/listeners/watcher.js +172 -58
  24. package/.agents/scripts/lib/orchestration/single-story-close/phases/auto-merge.js +19 -0
  25. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +2 -0
  26. package/.agents/scripts/lib/orchestration/story-close/baseline-attribution/phases/gate-failure.js +54 -6
  27. package/.agents/scripts/lib/orchestration/story-close/baseline-attribution/phases/regression-projection.js +35 -4
  28. package/.agents/scripts/lib/orchestration/ticket-validator-sizing.js +17 -16
  29. package/.agents/scripts/lib/single-story-sweep/protection-ctx.js +75 -0
  30. package/.agents/scripts/lib/single-story-sweep.js +181 -54
  31. package/.agents/scripts/lib/templates/decomposer-prompts.js +17 -3
  32. package/.agents/scripts/pr-watch-with-update.js +324 -37
  33. package/.agents/scripts/run-verify.js +18 -3
  34. package/.agents/scripts/single-story-confirm-merge.js +1 -1
  35. package/.agents/scripts/single-story-init.js +7 -51
  36. package/.agents/skills/core/epic-plan-decompose-author/SKILL.md +32 -1
  37. package/.agents/skills/core/scope-triage/SKILL.md +5 -4
  38. package/.agents/workflows/git-deliver.md +36 -0
  39. package/.agents/workflows/helpers/deliver-epic-reference.md +41 -21
  40. package/.agents/workflows/helpers/deliver-epic.md +148 -28
  41. package/.agents/workflows/helpers/deliver-stories.md +18 -5
  42. package/.agents/workflows/helpers/single-story-deliver-reference.md +3 -3
  43. package/.agents/workflows/helpers/single-story-deliver.md +56 -19
  44. package/.agents/workflows/plan.md +32 -4
  45. package/docs/CHANGELOG.md +21 -0
  46. package/package.json +1 -1
@@ -40,7 +40,11 @@
40
40
  * - runs `git remote prune` to drop stale `<remote>/...` tracking
41
41
  * refs left behind by `gh pr merge --delete-branch`;
42
42
  * - deletes the `wt-branch` scratch ref left by `story-close.js`'s
43
- * internal merge worktree when it is no longer checked out.
43
+ * internal merge worktree when it is no longer checked out;
44
+ * - fast-forwards the base branch to `<remote>/<baseBranch>` after the
45
+ * confirmed merge so the local checkout converges to origin with no
46
+ * manual `git pull` (skipped when the epic branch is kept for an
47
+ * open PR).
44
48
  * 3. Record one classification entry per invocation (`reaped`,
45
49
  * `no-state`, `failed`, or `skipped-duplicate`) so failures surface
46
50
  * in the lifecycle ledger alongside Cleaner's archival outcome.
@@ -243,8 +247,8 @@ export class BranchCleaner {
243
247
  * Pure: condense a `reapEpicBranches()` result into the counts that the
244
248
  * classification log carries. Exported for tests.
245
249
  *
246
- * @param {{ reaped: Array<object>, pruned: { pruned: string[] }|null, wtBranch: { deleted: boolean }|null }} result
247
- * @returns {{ branchesDeleted: number, worktreesRemoved: number, tracksPruned: number, wtBranchDeleted: boolean }}
250
+ * @param {{ reaped: Array<object>, pruned: { pruned: string[] }|null, wtBranch: { deleted: boolean }|null, fastForward: { applied: boolean }|null }} result
251
+ * @returns {{ branchesDeleted: number, worktreesRemoved: number, tracksPruned: number, wtBranchDeleted: boolean, fastForwarded: boolean }}
248
252
  */
249
253
  export function summarizeReap(result) {
250
254
  const reaped = Array.isArray(result?.reaped) ? result.reaped : [];
@@ -255,5 +259,6 @@ export function summarizeReap(result) {
255
259
  ).length,
256
260
  tracksPruned: result?.pruned?.pruned?.length ?? 0,
257
261
  wtBranchDeleted: result?.wtBranch?.deleted === true,
262
+ fastForwarded: result?.fastForward?.applied === true,
258
263
  };
259
264
  }
@@ -24,7 +24,11 @@
24
24
  * 4. Otherwise, invoke `runFinalizeFn`. The production default
25
25
  * (`composeBusOwnedFinalize`) chains
26
26
  * a. `openOrLocatePr({ epicId, headBranch, baseBranch })`
27
- * b. `postHandoffComment({ epicId, prNumber, prUrl, provider })`
27
+ * b. `markPrReady({ pr })` when `delivery.ci.earlyPr` is on
28
+ * (Story #4359) — the wave-1 draft is flipped ready-for-review;
29
+ * skipped when `earlyPr` is off (the PR was opened here, never a
30
+ * draft).
31
+ * c. `postHandoffComment({ epicId, prNumber, prUrl, provider })`
28
32
  * and returns `{ prNumber, prUrl, handoff }`. (Story #4324 retired
29
33
  * the `closePlanningTickets` sweep with the context-ticket classes —
30
34
  * there are no planning tickets to close.)
@@ -60,13 +64,17 @@
60
64
  */
61
65
 
62
66
  import { spawnSync } from 'node:child_process';
67
+ import { getCiDelivery } from '../../../config/ci.js';
63
68
  import {
64
69
  graduateAuditResults as defaultGraduateAuditResults,
65
70
  isAutoFileEnabled as isAuditResultsAutoFileEnabled,
66
71
  } from '../../../feedback-loop/audit-results-graduator.js';
67
72
  import { graduateFindings as defaultGraduateFindings } from '../../../feedback-loop/code-review-graduator.js';
68
73
  import { parsePrNumberFromUrl } from '../../../github-url.js';
69
- import { openOrLocatePr as defaultOpenOrLocatePr } from '../../finalize/open-or-locate-pr.js';
74
+ import {
75
+ markPrReady as defaultMarkPrReady,
76
+ openOrLocatePr as defaultOpenOrLocatePr,
77
+ } from '../../finalize/open-or-locate-pr.js';
70
78
  import { postHandoffComment as defaultPostHandoffComment } from '../../finalize/post-handoff-comment.js';
71
79
 
72
80
  /**
@@ -79,17 +87,31 @@ import { postHandoffComment as defaultPostHandoffComment } from '../../finalize/
79
87
  * `{ blocker: { reason, detail } }` when a step fails with an
80
88
  * unrecoverable error that should keep the Epic at `agent::blocked`.
81
89
  *
90
+ * Story #4359 (Epic #4355) — early-PR draft mode. When `earlyPr` is on
91
+ * (the default resolved via `getCiDelivery`), the Epic PR already exists
92
+ * as a draft (opened at wave 1). Finalize then **locates** it (the
93
+ * `openOrLocatePr` probe short-circuits to `created: false`) and flips it
94
+ * ready-for-review via `markPrReady` rather than opening the draft as a
95
+ * `--draft`. When `earlyPr` is off, finalize opens the PR here with no
96
+ * draft — the pre-Story close-time timing. In both modes the title/body
97
+ * contract is identical and `markPrReady` is a no-op on an already-ready
98
+ * PR, so replay stays idempotent.
99
+ *
82
100
  * @param {{
83
101
  * provider?: object|null,
102
+ * earlyPr?: boolean,
84
103
  * openOrLocatePrFn?: typeof defaultOpenOrLocatePr,
104
+ * markPrReadyFn?: typeof defaultMarkPrReady,
85
105
  * postHandoffCommentFn?: typeof defaultPostHandoffComment,
86
106
  * }} deps
87
107
  */
88
108
  export function composeBusOwnedFinalize(deps = {}) {
89
109
  const openOrLocatePrFn = deps.openOrLocatePrFn ?? defaultOpenOrLocatePr;
110
+ const markPrReadyFn = deps.markPrReadyFn ?? defaultMarkPrReady;
90
111
  const postHandoffCommentFn =
91
112
  deps.postHandoffCommentFn ?? defaultPostHandoffComment;
92
113
  const provider = deps.provider ?? null;
114
+ const earlyPr = deps.earlyPr !== false;
93
115
 
94
116
  return async function runBusOwnedFinalize({ epicId, cwd } = {}) {
95
117
  if (!Number.isInteger(epicId) || epicId < 1) {
@@ -129,6 +151,24 @@ export function composeBusOwnedFinalize(deps = {}) {
129
151
  };
130
152
  }
131
153
 
154
+ // Story #4359: when earlyPr is on, the located PR is the wave-1 draft —
155
+ // flip it ready-for-review. `gh pr ready` on an already-ready PR is a
156
+ // no-op, so a replay (or an earlyPr-off PR that was never a draft) is
157
+ // safe. A failure here is a hard blocker: leaving the PR a draft would
158
+ // silently park the merge gate.
159
+ if (earlyPr) {
160
+ try {
161
+ await markPrReadyFn({ pr: openResult.url, cwd });
162
+ } catch (err) {
163
+ return {
164
+ blocker: {
165
+ reason: 'mark-pr-ready-failed',
166
+ detail: err?.message ?? String(err),
167
+ },
168
+ };
169
+ }
170
+ }
171
+
132
172
  // The handoff comment requires a provider. The lifecycle-emit CLI
133
173
  // may construct the Finalizer without one; in that case the step
134
174
  // short-circuits (the run itself still succeeds — the PR is open and
@@ -256,9 +296,14 @@ export class Finalizer {
256
296
  this.cwd = opts.cwd ?? process.cwd();
257
297
  this.fullScope = opts.fullScope === true;
258
298
  this.provider = opts.provider ?? null;
299
+ // Story #4359: gate the finalize PR-open/ready branch on
300
+ // `delivery.ci.earlyPr` (default true) resolved via getCiDelivery.
301
+ // On → the wave-1 draft is located and marked ready; off → the PR is
302
+ // opened here at close time (pre-Story timing).
303
+ const earlyPr = getCiDelivery(opts.config ?? null).earlyPr;
259
304
  this.runFinalizeFn =
260
305
  opts.runFinalizeFn ??
261
- composeBusOwnedFinalize({ provider: this.provider });
306
+ composeBusOwnedFinalize({ provider: this.provider, earlyPr });
262
307
  this.ghPrListHeadFn = opts.ghPrListHeadFn ?? ghPrListHead;
263
308
  // ultrareview bug_007: the existing-PR short-circuit must run the
264
309
  // handoff-comment upsert (idempotent) so crash-recovery replays
@@ -229,13 +229,18 @@ export async function buildDefaultListenerChain(opts = {}) {
229
229
  // 5. AutomergePredicate — emits epic.merge.{ready,blocked} based on
230
230
  // the runtime predicate evaluation. Requires a truthy `provider`;
231
231
  // skip cleanly when the caller omitted one (lifecycle-emit CLI
232
- // has no provider wired in by default).
232
+ // has no provider wired in by default). `config` selects the
233
+ // `delivery.ci.autoMerge` posture (trust-ci default vs strict) and
234
+ // `cwd` (repoRoot) is where the live `gh pr checks --required`
235
+ // probe shells out (Story #4361).
233
236
  let automergePredicate = null;
234
237
  if (provider) {
235
238
  automergePredicate = new AutomergePredicate({
236
239
  bus,
237
240
  epicId,
238
241
  provider,
242
+ config,
243
+ cwd: repoRoot,
239
244
  logger,
240
245
  });
241
246
  automergePredicate.register();
@@ -55,6 +55,35 @@ import { parsePrNumberFromUrl } from '../../../github-url.js';
55
55
  * Unknown / non-pending unrecognized values collapse to `'skipped'`
56
56
  * so any future GitHub state we haven't enumerated still validates.
57
57
  */
58
+ /**
59
+ * The raw check-state tokens `normalizeCheckState` recognizes (lowercased).
60
+ * A token absent from this set is one we have NOT enumerated — the watch
61
+ * path collapses it to `'skipped'` (validate-anything), but a fail-closed
62
+ * consumer (the auto-merge arming probe) must treat it as unknown-therefore-
63
+ * blocking rather than trust the `'skipped'` collapse. Exported so that
64
+ * stricter consumer lives here as the single vocabulary owner.
65
+ */
66
+ export const RECOGNIZED_CHECK_STATES = Object.freeze(
67
+ new Set([
68
+ '',
69
+ 'pending',
70
+ 'queued',
71
+ 'in_progress',
72
+ 'requested',
73
+ 'waiting',
74
+ 'success',
75
+ 'completed',
76
+ 'failure',
77
+ 'startup_failure',
78
+ 'neutral',
79
+ 'cancelled',
80
+ 'timed_out',
81
+ 'action_required',
82
+ 'stale',
83
+ 'skipped',
84
+ ]),
85
+ );
86
+
58
87
  export function normalizeCheckState(raw) {
59
88
  const v = String(raw ?? '')
60
89
  .trim()
@@ -266,19 +295,45 @@ export function allTerminal(outcomes) {
266
295
  }
267
296
 
268
297
  /**
269
- * Promote any `'pending'` outcomes to the schema-valid `'timed_out'`
270
- * sentinel before emit. Pureexported for tests so the cap-fire
271
- * behaviour is reviewable. Called only when the poll loop exits via
272
- * the iteration cap.
298
+ * Sentinel outcome for a required check that never went terminal within
299
+ * the poll cap AND the resume budget the CI job is genuinely slow, not
300
+ * red. Story #4358 made this a first-class outcome distinct from
301
+ * `'timed_out'` (a GitHub-reported terminal timeout) and `'failure'` (a
302
+ * red check): a `'still-running'` map means "re-arm the watch / hand off
303
+ * to `/loop`," never "the change is broken."
273
304
  */
274
- function promotePendingToTimedOut(outcomes) {
305
+ export const STILL_RUNNING = 'still-running';
306
+
307
+ /**
308
+ * Promote any leftover `'pending'` outcomes to the schema-valid
309
+ * `'still-running'` sentinel before emit. Pure — exported for tests so
310
+ * the cap-fire behaviour is reviewable. Called only when the poll loop
311
+ * (and its resume budget) exits with checks still pending and none
312
+ * failed — the slow-but-not-red terminal state.
313
+ */
314
+ export function promotePendingToStillRunning(outcomes) {
275
315
  const out = {};
276
316
  for (const [k, v] of Object.entries(outcomes)) {
277
- out[k] = v === 'pending' ? 'timed_out' : v;
317
+ out[k] = v === 'pending' ? STILL_RUNNING : v;
278
318
  }
279
319
  return out;
280
320
  }
281
321
 
322
+ /**
323
+ * True when at least one required check has genuinely failed — the hard
324
+ * stop that consumes NO resume budget and exits 1 immediately. A
325
+ * `'pending'` check is not a failure (it is still running); anything
326
+ * outside the non-failing set AND outside `'pending'` is a red block.
327
+ * Pure — exported for tests.
328
+ */
329
+ export function hasFailingCheck(outcomes) {
330
+ for (const v of Object.values(outcomes)) {
331
+ if (v === 'pending') continue;
332
+ if (!GREEN_CHECK_OUTCOMES.has(v)) return true;
333
+ }
334
+ return false;
335
+ }
336
+
282
337
  /**
283
338
  * Default sleeper. Hoisted so tests can stub without faking timers.
284
339
  */
@@ -355,8 +410,13 @@ export async function pollUntilTerminal({
355
410
  * @param {object} opts
356
411
  * @param {string} opts.prUrl PR URL or number (passed to `gh` verbatim).
357
412
  * @param {string} opts.cwd
358
- * @param {number} opts.maxPolls Hard cap on total poll iterations.
413
+ * @param {number} opts.maxPolls Hard cap on total poll iterations per arm.
359
414
  * @param {number} opts.maxUpdates Cap on `gh pr update-branch` recovery calls.
415
+ * @param {number} [opts.maxResumes] Story #4358: after the poll cap fires with
416
+ * one or more required checks still pending (and NONE failed), re-arm the poll
417
+ * loop up to this many times before giving up with a `still-running` verdict.
418
+ * A genuinely red check short-circuits immediately and consumes no resume
419
+ * budget. Defaults to 0 (no resume) so existing callers are unchanged.
360
420
  * @param {number} opts.pollIntervalMs Delay between poll ticks.
361
421
  * @param {Function} [opts.ghPrChecksFn] `gh pr checks` invoker. Defaults
362
422
  * to the real `gh pr checks` spawn so the CLI path (which injects no
@@ -379,19 +439,24 @@ export async function pollUntilTerminal({
379
439
  * requiredChecks: string[],
380
440
  * polls: number,
381
441
  * updatesApplied: number,
442
+ * resumesApplied: number,
382
443
  * terminal: boolean,
383
444
  * green: boolean,
445
+ * stillRunning: boolean,
384
446
  * error?: string,
385
447
  * }>}
386
448
  * `outcomes` is schema-valid (no `'pending'` — leftover pending is
387
- * promoted to `'timed_out'` when the cap fires). `error` is set only
388
- * when the first probe could not resolve the required-check set.
449
+ * promoted to `'still-running'` when the cap and resume budget are both
450
+ * exhausted with no failed check). `stillRunning` is true in exactly
451
+ * that case (slow CI, not red). `error` is set only when the first
452
+ * probe could not resolve the required-check set.
389
453
  */
390
454
  export async function watchPrToTerminal({
391
455
  prUrl,
392
456
  cwd,
393
457
  maxPolls,
394
458
  maxUpdates,
459
+ maxResumes = 0,
395
460
  pollIntervalMs,
396
461
  ghPrChecksFn = ghPrChecks,
397
462
  ghPrViewFn = ghPrView,
@@ -417,8 +482,10 @@ export async function watchPrToTerminal({
417
482
  requiredChecks: [],
418
483
  polls: 0,
419
484
  updatesApplied: 0,
485
+ resumesApplied: 0,
420
486
  terminal: false,
421
487
  green: false,
488
+ stillRunning: false,
422
489
  error: `gh-checks-failed:status=${first.status}`,
423
490
  };
424
491
  }
@@ -433,68 +500,95 @@ export async function watchPrToTerminal({
433
500
  let outcomes = reduceOutcomes(firstEntries);
434
501
  let polls = 0;
435
502
  let updatesApplied = 0;
436
- while (polls < maxPolls) {
437
- ({ outcomes, polls } = await pollUntilTerminal({
438
- prUrl,
439
- cwd,
440
- outcomes,
441
- polls,
442
- maxPolls,
443
- ghPrChecksFn,
444
- pollIntervalMs,
445
- sleepFn,
446
- logger,
447
- }));
448
- // Checks have either all gone terminal or we hit the iteration cap.
449
- // BEHIND-recovery (Story #2327): when every required check is green
450
- // AND the PR is BEHIND its base, issue ONE `gh pr update-branch`
451
- // call and re-poll the checks against the freshly-rebased commit. A
452
- // red check is a hard block — stop here regardless of merge state.
453
- // Bounded by `maxUpdates` so a racing base branch can't ping-pong
454
- // indefinitely.
455
- if (!allTerminal(outcomes) || !allGreen(outcomes)) break;
456
- if (updatesApplied >= maxUpdates) break;
457
- const view = ghPrViewFn({ prUrl, cwd });
458
- if (view.status !== 0) {
459
- logger.warn?.(
460
- `[Watcher] gh pr view failed (status=${view.status}): ${view.stderr}`,
461
- );
462
- break;
463
- }
464
- const mergeStateStatus = parseMergeStateStatus(view.stdout);
465
- if (mergeStateStatus !== 'BEHIND') break;
466
- const update = ghPrUpdateBranchFn({ prUrl, cwd });
467
- if (update.status !== 0) {
468
- logger.warn?.(
469
- `[Watcher] gh pr update-branch failed (status=${update.status}): ${update.stderr}`,
503
+ let resumesApplied = 0;
504
+ // Outer resume loop (Story #4358). Each iteration runs one full
505
+ // poll-to-cap + BEHIND-recovery arm. When the arm ends with checks
506
+ // still pending but NONE failed, we re-arm (reset the poll counter)
507
+ // up to `maxResumes` times before declaring `still-running`. A red
508
+ // check breaks out immediately without consuming resume budget.
509
+ for (;;) {
510
+ while (polls < maxPolls) {
511
+ ({ outcomes, polls } = await pollUntilTerminal({
512
+ prUrl,
513
+ cwd,
514
+ outcomes,
515
+ polls,
516
+ maxPolls,
517
+ ghPrChecksFn,
518
+ pollIntervalMs,
519
+ sleepFn,
520
+ logger,
521
+ }));
522
+ // Checks have either all gone terminal or we hit the iteration cap.
523
+ // BEHIND-recovery (Story #2327): when every required check is green
524
+ // AND the PR is BEHIND its base, issue ONE `gh pr update-branch`
525
+ // call and re-poll the checks against the freshly-rebased commit. A
526
+ // red check is a hard block — stop here regardless of merge state.
527
+ // Bounded by `maxUpdates` so a racing base branch can't ping-pong
528
+ // indefinitely.
529
+ if (!allTerminal(outcomes) || !allGreen(outcomes)) break;
530
+ if (updatesApplied >= maxUpdates) break;
531
+ const view = ghPrViewFn({ prUrl, cwd });
532
+ if (view.status !== 0) {
533
+ logger.warn?.(
534
+ `[Watcher] gh pr view failed (status=${view.status}): ${view.stderr}`,
535
+ );
536
+ break;
537
+ }
538
+ const mergeStateStatus = parseMergeStateStatus(view.stdout);
539
+ if (mergeStateStatus !== 'BEHIND') break;
540
+ const update = ghPrUpdateBranchFn({ prUrl, cwd });
541
+ if (update.status !== 0) {
542
+ logger.warn?.(
543
+ `[Watcher] gh pr update-branch failed (status=${update.status}): ${update.stderr}`,
544
+ );
545
+ break;
546
+ }
547
+ updatesApplied += 1;
548
+ logger.info?.(
549
+ `[Watcher] PR BEHIND base — issued gh pr update-branch (#${updatesApplied}/${maxUpdates}); re-polling required checks.`,
470
550
  );
471
- break;
551
+ await sleepFn(pollIntervalMs);
552
+ // After update-branch, the freshly-rebased commit invalidates the
553
+ // previous terminal outcomes. Reset to force the inner poll loop to
554
+ // re-evaluate the new CI cycle.
555
+ outcomes = {};
556
+ for (const name of requiredChecks) outcomes[name] = 'pending';
472
557
  }
473
- updatesApplied += 1;
558
+
559
+ // Arm complete. Decide whether to re-arm. A genuinely red check is a
560
+ // hard stop that consumes NO resume budget — the change is broken,
561
+ // resuming would only burn wall-clock. Only re-arm when the arm timed
562
+ // out with pending-but-not-failed checks and resume budget remains.
563
+ if (allTerminal(outcomes) || hasFailingCheck(outcomes)) break;
564
+ if (resumesApplied >= maxResumes) break;
565
+ resumesApplied += 1;
566
+ polls = 0;
474
567
  logger.info?.(
475
- `[Watcher] PR BEHIND base issued gh pr update-branch (#${updatesApplied}/${maxUpdates}); re-polling required checks.`,
568
+ `[Watcher] poll cap reached with checks still pending; re-arming watch (resume #${resumesApplied}/${maxResumes}).`,
476
569
  );
477
- await sleepFn(pollIntervalMs);
478
- // After update-branch, the freshly-rebased commit invalidates the
479
- // previous terminal outcomes. Reset to force the inner poll loop to
480
- // re-evaluate the new CI cycle.
481
- outcomes = {};
482
- for (const name of requiredChecks) outcomes[name] = 'pending';
483
570
  }
484
571
 
485
572
  const terminal = allTerminal(outcomes);
486
- // The schema enum forbids `'pending'`; promote any leftover pending
487
- // entries (cap-fire path) to `'timed_out'` for the returned map.
573
+ const failing = hasFailingCheck(outcomes);
574
+ // Slow-but-not-red: the poll cap AND resume budget are exhausted with
575
+ // one or more checks still pending and NONE failed. The schema enum
576
+ // forbids `'pending'`; promote leftover pending entries to the
577
+ // `'still-running'` sentinel (never `'timed_out'` — that would read as
578
+ // a genuine terminal failure to the auto-merge predicate).
579
+ const stillRunning = !terminal && !failing;
488
580
  const finalOutcomes = terminal
489
581
  ? outcomes
490
- : promotePendingToTimedOut(outcomes);
582
+ : promotePendingToStillRunning(outcomes);
491
583
  return {
492
584
  outcomes: finalOutcomes,
493
585
  requiredChecks,
494
586
  polls,
495
587
  updatesApplied,
588
+ resumesApplied,
496
589
  terminal,
497
590
  green: terminal && allGreen(finalOutcomes),
591
+ stillRunning,
498
592
  };
499
593
  }
500
594
 
@@ -513,6 +607,9 @@ export class Watcher {
513
607
  * recovery calls per `pr.created` event; default 3. Mirrors the
514
608
  * legacy `pr-watch-with-update` cap so a racing base branch
515
609
  * can't induce an infinite update-branch ping-pong.
610
+ * @param {number} [opts.maxResumes] Story #4358: how many times to
611
+ * re-arm the poll loop after the cap fires with checks still pending
612
+ * (and none failed) before declaring `still-running`; default 0.
516
613
  * @param {Function} [opts.ghPrChecksFn] override for tests.
517
614
  * @param {Function} [opts.ghPrViewFn] override for tests; resolves
518
615
  * `mergeStateStatus` for the BEHIND-recovery gate.
@@ -539,6 +636,10 @@ export class Watcher {
539
636
  Number.isInteger(opts.maxUpdates) && opts.maxUpdates >= 0
540
637
  ? opts.maxUpdates
541
638
  : 3;
639
+ this.maxResumes =
640
+ Number.isInteger(opts.maxResumes) && opts.maxResumes >= 0
641
+ ? opts.maxResumes
642
+ : 0;
542
643
  this.ghPrChecksFn = opts.ghPrChecksFn ?? ghPrChecks;
543
644
  this.ghPrViewFn = opts.ghPrViewFn ?? ghPrView;
544
645
  this.ghPrUpdateBranchFn = opts.ghPrUpdateBranchFn ?? ghPrUpdateBranch;
@@ -549,8 +650,8 @@ export class Watcher {
549
650
  /**
550
651
  * Classification log — every `pr.created` we observe lands here
551
652
  * with the outcome (`watched`, `failed`, `skipped-duplicate`,
552
- * `timed-out`). Mirrors the Finalizer / Reconciler "no silent skip"
553
- * surface.
653
+ * `still-running`, `timed-out`). Mirrors the Finalizer / Reconciler
654
+ * "no silent skip" surface.
554
655
  */
555
656
  this.classifications = [];
556
657
  this.events = Object.freeze(['pr.created']);
@@ -631,12 +732,15 @@ export class Watcher {
631
732
  outcomes: emitOutcomes,
632
733
  polls,
633
734
  updatesApplied,
735
+ resumesApplied,
634
736
  terminal,
737
+ stillRunning,
635
738
  } = await watchPrToTerminal({
636
739
  prUrl,
637
740
  cwd: this.cwd,
638
741
  maxPolls: this.maxPolls,
639
742
  maxUpdates: this.maxUpdates,
743
+ maxResumes: this.maxResumes,
640
744
  pollIntervalMs: this.pollIntervalMs,
641
745
  ghPrChecksFn: this.ghPrChecksFn,
642
746
  ghPrViewFn: this.ghPrViewFn,
@@ -646,12 +750,22 @@ export class Watcher {
646
750
  firstProbe: first,
647
751
  });
648
752
 
753
+ // `still-running` (slow CI, not red) is a distinct classification from
754
+ // a genuine `timed-out` — reserved for a check that never went
755
+ // terminal within the poll cap AND the resume budget while none
756
+ // failed. `watched` covers every terminal arm (green or red).
757
+ const outcome = terminal
758
+ ? 'watched'
759
+ : stillRunning
760
+ ? 'still-running'
761
+ : 'timed-out';
649
762
  this.classifications.push({
650
763
  event,
651
764
  seqId,
652
- outcome: terminal ? 'watched' : 'timed-out',
765
+ outcome,
653
766
  polls,
654
767
  updatesApplied,
768
+ resumesApplied,
655
769
  requiredChecks: requiredChecks.length,
656
770
  });
657
771
  try {
@@ -139,6 +139,7 @@ function makeDefaultGhAutoMergeRunner(gh) {
139
139
  * prNumber: number|null,
140
140
  * prUrl: string,
141
141
  * noAutoMerge: boolean,
142
+ * autoMergePolicy?: 'trust-ci'|'strict',
142
143
  * gh?: ReturnType<typeof import('../../../gh-exec.js').createGh>,
143
144
  * progress: (tag: string, msg: string) => void,
144
145
  * }} args
@@ -149,6 +150,7 @@ export async function runAutoMergePhase({
149
150
  prNumber,
150
151
  prUrl,
151
152
  noAutoMerge,
153
+ autoMergePolicy = 'trust-ci',
152
154
  gh,
153
155
  progress,
154
156
  }) {
@@ -156,6 +158,23 @@ export async function runAutoMergePhase({
156
158
  progress('PR', '⏭ Auto-merge disabled (--no-auto-merge).');
157
159
  return { autoMergeEnabled: false, autoMergeReason: 'disabled-by-flag' };
158
160
  }
161
+ // `delivery.ci.autoMerge: "strict"` opts standalone Stories out of
162
+ // auto-merge (parallel to the Epic path's strict predicate): the PR opens
163
+ // and waits for an operator merge instead of arming native auto-merge.
164
+ // The default `"trust-ci"` keeps arming on green required CI — GitHub's
165
+ // native `--auto` is the required-check gate, so no client-side predicate
166
+ // is needed here (unlike the Epic path, which gates on local
167
+ // audit/review/retro signals a standalone Story does not produce).
168
+ if (autoMergePolicy === 'strict') {
169
+ progress(
170
+ 'PR',
171
+ '⏭ Auto-merge skipped (delivery.ci.autoMerge="strict") — operator merges.',
172
+ );
173
+ return {
174
+ autoMergeEnabled: false,
175
+ autoMergeReason: 'disabled-by-policy-strict',
176
+ };
177
+ }
159
178
  if (prNumber == null) {
160
179
  progress(
161
180
  'PR',
@@ -2,6 +2,7 @@ import nodeFs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { buildDefaultGates } from '../../close-validation/gates.js';
4
4
  import { runCloseValidation } from '../../close-validation/runner.js';
5
+ import { getCiDelivery } from '../../config/ci.js';
5
6
  import { resolveConfig } from '../../config-resolver.js';
6
7
  import { getStoryBranch, gitSync } from '../../git-utils.js';
7
8
  import { Logger } from '../../Logger.js';
@@ -316,6 +317,7 @@ export async function runSingleStoryClose({
316
317
  prNumber,
317
318
  prUrl,
318
319
  noAutoMerge: options.noAutoMerge,
320
+ autoMergePolicy: getCiDelivery(config).autoMerge,
319
321
  gh: injectedGh,
320
322
  progress,
321
323
  });
@@ -47,6 +47,51 @@ export const DEFAULT_GATE_REGISTRY = {
47
47
  },
48
48
  };
49
49
 
50
+ /** Gate names that fan out to per-kind refreshers via `_gateKind` tags. */
51
+ const COMPOSITE_GATE_KINDS = new Set(['check-baselines']);
52
+
53
+ /**
54
+ * Resolve the refresh metadata + the regression subset a single failure
55
+ * cycle should act on.
56
+ *
57
+ * A direct per-kind gate (`check-maintainability` / `check-crap`) maps
58
+ * straight through. The unified `check-baselines` gate is a **composite**:
59
+ * its projected regressions are tagged with `_gateKind`, and each attribution
60
+ * cycle picks the first regressed kind not already refreshed this cycle
61
+ * (`cycleState.refreshedKinds`), scoping classify + refresh to that one kind.
62
+ * The retry loop re-drives for any remaining kinds — so a close that regresses
63
+ * both maintainability and CRAP converges in two cycles instead of dead-ending
64
+ * on an unrecognised gate name (framework-gap #4377).
65
+ *
66
+ * @returns {{ meta: object, regressions: Array } | null}
67
+ */
68
+ export function resolveGateMeta({
69
+ gateName,
70
+ regressions,
71
+ cycleState = null,
72
+ gateRegistry = DEFAULT_GATE_REGISTRY,
73
+ }) {
74
+ if (!Array.isArray(regressions) || regressions.length === 0) return null;
75
+
76
+ const direct = gateRegistry[gateName];
77
+ if (direct) return { meta: direct, regressions };
78
+
79
+ if (COMPOSITE_GATE_KINDS.has(gateName)) {
80
+ const refreshed = cycleState?.refreshedKinds ?? new Set();
81
+ for (const row of regressions) {
82
+ const kind = row?._gateKind;
83
+ const subMeta = kind ? gateRegistry[`check-${kind}`] : null;
84
+ if (subMeta && !refreshed.has(subMeta.kind)) {
85
+ return {
86
+ meta: subMeta,
87
+ regressions: regressions.filter((r) => r?._gateKind === kind),
88
+ };
89
+ }
90
+ }
91
+ }
92
+ return null;
93
+ }
94
+
50
95
  /**
51
96
  * Top-level: handle a baseline gate failure by classifying drift and
52
97
  * either auto-refreshing (attributable-only) or posting friction (any
@@ -88,11 +133,14 @@ export async function handleBaselineGateFailure({
88
133
  gateRegistry = DEFAULT_GATE_REGISTRY,
89
134
  deps = {},
90
135
  } = {}) {
91
- const meta = gateRegistry[gateName];
92
- if (!meta) return { action: 'rethrow' };
93
- if (!Array.isArray(regressions) || regressions.length === 0) {
94
- return { action: 'rethrow' };
95
- }
136
+ const resolved = resolveGateMeta({
137
+ gateName,
138
+ regressions,
139
+ cycleState,
140
+ gateRegistry,
141
+ });
142
+ if (!resolved) return { action: 'rethrow' };
143
+ const { meta, regressions: scopedRegressions } = resolved;
96
144
 
97
145
  const classify = deps.classifyBaselineDrift ?? defaultClassifyBaselineDrift;
98
146
  const renderBody =
@@ -114,7 +162,7 @@ export async function handleBaselineGateFailure({
114
162
 
115
163
  const epicRef = `origin/${epicBranch}`;
116
164
  const { attributable, nonAttributable } = classify({
117
- regressions,
165
+ regressions: scopedRegressions,
118
166
  storyDiffPaths,
119
167
  epicRef,
120
168
  cwd,