mandrel 1.83.0 → 1.85.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 (35) 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/testing-standards.md +14 -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/git-pr-quality-gate.js +7 -5
  9. package/.agents/scripts/lib/config/ci.js +24 -3
  10. package/.agents/scripts/lib/config/explain.js +11 -3
  11. package/.agents/scripts/lib/config/github.js +11 -7
  12. package/.agents/scripts/lib/config-settings-schema-delivery.js +21 -0
  13. package/.agents/scripts/lib/config-settings-schema.js +6 -6
  14. package/.agents/scripts/lib/orchestration/finalize/open-or-locate-pr.js +65 -0
  15. package/.agents/scripts/lib/orchestration/lifecycle/listeners/automerge-predicate.js +401 -84
  16. package/.agents/scripts/lib/orchestration/lifecycle/listeners/finalizer.js +48 -3
  17. package/.agents/scripts/lib/orchestration/lifecycle/listeners/index.js +6 -1
  18. package/.agents/scripts/lib/orchestration/lifecycle/listeners/watcher.js +172 -58
  19. package/.agents/scripts/lib/orchestration/single-story-close/phases/auto-merge.js +19 -0
  20. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +2 -0
  21. package/.agents/scripts/lib/orchestration/ticket-validator-sizing.js +17 -16
  22. package/.agents/scripts/lib/templates/decomposer-prompts.js +17 -3
  23. package/.agents/scripts/pr-watch-with-update.js +324 -37
  24. package/.agents/scripts/run-verify.js +18 -3
  25. package/.agents/scripts/single-story-confirm-merge.js +1 -1
  26. package/.agents/skills/core/epic-plan-decompose-author/SKILL.md +32 -1
  27. package/.agents/skills/core/scope-triage/SKILL.md +5 -4
  28. package/.agents/workflows/helpers/code-review.md +70 -5
  29. package/.agents/workflows/helpers/deliver-epic-reference.md +22 -8
  30. package/.agents/workflows/helpers/deliver-epic.md +123 -28
  31. package/.agents/workflows/helpers/deliver-stories.md +2 -2
  32. package/.agents/workflows/helpers/single-story-deliver-reference.md +3 -3
  33. package/.agents/workflows/helpers/single-story-deliver.md +56 -19
  34. package/docs/CHANGELOG.md +16 -0
  35. package/package.json +1 -1
@@ -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
  });
@@ -17,10 +17,12 @@
17
17
  * `.agents/scripts/lib/config-settings-schema.js`.
18
18
  *
19
19
  * Sizing model (Story #3760 — profile-matrix collapse; Story #3874 — one
20
- * uniform relaxed profile):
21
- * - Flat knobs: `softFiles` (~15), `hardFiles` (~30), `maxAcceptance` (~14),
20
+ * uniform relaxed profile; the hard acceptance ceiling was removed after the
21
+ * Epic #4355 decomposition experiment showed it forced fragmentation):
22
+ * - Flat knobs: `softFiles` (~15), `hardFiles` (~30),
22
23
  * `softAcceptanceCount` (~10). No per-profile ceiling map, no parallel
23
- * `testSurface` axis, no selector and no second profile.
24
+ * `testSurface` axis, no selector and no second profile. Acceptance
25
+ * mass is advisory-only.
24
26
  * - The four-profile `sizingProfile` enum is replaced by a single optional
25
27
  * `wide` declaration carrying a one-line human-readable reason. Declaring
26
28
  * `wide` with a reason lifts the `hardFiles` rejection; no Story is
@@ -119,9 +121,12 @@ export const DEFAULT_TASK_SIZING = Object.freeze({
119
121
  // The hard `hardFiles` rejection (30) is unchanged.
120
122
  softFiles: 15,
121
123
  softAcceptanceCount: 10,
122
- // Hard ceilings (rejection unless lifted).
124
+ // Hard ceiling (rejection unless lifted via `wide`). Acceptance mass has
125
+ // no hard ceiling: the former `maxAcceptance` rejection forced careful,
126
+ // fine-grained specs to fragment one coherent capability into dependent
127
+ // slices (observed on Epic #4355), so it was removed — the delivery-
128
+ // schedule simulation in the decomposer prompt owns that judgment now.
123
129
  hardFiles: 30,
124
- maxAcceptance: 14,
125
130
  // Under-size (merge-candidate) thresholds (Story #4312). A Story with a
126
131
  // footprint at or below BOTH ceilings that also carries at least one
127
132
  // `depends_on` edge to a sibling looks like a dependent fragment rather than
@@ -528,17 +533,13 @@ function computeStorySizingFindings(story, sizing) {
528
533
  ),
529
534
  );
530
535
 
531
- // Acceptance ceiling + soft warn.
532
- if (acceptance.length > sizing.maxAcceptance) {
533
- out.push(
534
- makeOversized(
535
- story.slug,
536
- 'acceptance',
537
- acceptance.length,
538
- sizing.maxAcceptance,
539
- ),
540
- );
541
- } else if (acceptance.length > sizing.softAcceptanceCount) {
536
+ // Acceptance mass is advisory-only (Story #4312's under-size heuristic is
537
+ // the merge signal; the former hard `maxAcceptance` rejection is removed).
538
+ // A long binding contract is a re-check-cohesion nudge, never by itself a
539
+ // decomposition error — cohesion and the delivery envelope govern Story
540
+ // size, and the delivery-schedule simulation in the decomposer prompt owns
541
+ // the fragmentation/consolidation judgment.
542
+ if (acceptance.length > sizing.softAcceptanceCount) {
542
543
  out.push(
543
544
  makeSoftWidth(
544
545
  story.slug,
@@ -50,8 +50,7 @@ export function renderDecomposerSystemPrompt({
50
50
  function render2TierPrompt({ maxTickets, maxTokenBudget, epicId = null }) {
51
51
  // Sizing thresholds are sourced from the single DEFAULT_TASK_SIZING constant
52
52
  // (ticket-validator-sizing.js) so the prompt and the validator cannot drift.
53
- const { softFiles, hardFiles, maxAcceptance, softAcceptanceCount } =
54
- DEFAULT_TASK_SIZING;
53
+ const { softFiles, hardFiles, softAcceptanceCount } = DEFAULT_TASK_SIZING;
55
54
  // Deliverable-granularity definition + single-consumer merge rule + the
56
55
  // soft envelope-floor heuristic are sourced from the single
57
56
  // DELIVERABLE_GRANULARITY_GUIDANCE constant (ticket-validator-sizing.js) so
@@ -177,7 +176,22 @@ ${envelopeFloor}
177
176
 
178
177
  - A Story touching more than **${softFiles} files** (\`softFiles\`) emits an advisory width finding — a nudge to check cohesion or declare \`wide\`.
179
178
  - A Story touching more than **${hardFiles} files** (\`hardFiles\`) is **rejected** unless it declares \`wide\` with a reason.
180
- - A Story with more than **${maxAcceptance} acceptance items** (\`maxAcceptance\`) is **rejected**; more than ${softAcceptanceCount} (\`softAcceptanceCount\`) emits an advisory warning.
179
+ - Acceptance mass is **advisory only**: more than **${softAcceptanceCount} acceptance items** (\`softAcceptanceCount\`) emits an advisory warning. There is NO hard acceptance ceiling a long binding contract is a signal to re-check cohesion, never a reason to fragment one coherent capability into dependent slices.
180
+
181
+ #### DELIVERY-SCHEDULE SIMULATION — the story count must earn itself:
182
+
183
+ Before emitting, simulate the delivery schedule your plan implies, and judge the plan by its schedule — not by how tidy the taxonomy looks:
184
+
185
+ 1. **Build the wave schedule.** A Story runs only after every \`depends_on\` completes, and two Stories that name the same file in \`changes[]\` cannot run in the same wave (the scheduler serializes file-overlapping Stories even when no \`depends_on\` edge links them).
186
+ 2. **Compute the parallelism yield**: story count ÷ critical-path length in waves. A yield near 1.0 means the plan is a serial chain — N Stories that deliver no faster than one Story while paying N delivery sessions (hydration, branch, PR, review, CI).
187
+ 3. **Every Story must earn its slot** by at least one of:
188
+ - **(a) parallelism** — it actually runs concurrently with a sibling in the schedule you just built ("logically independent" does not count; *schedule*-independent does);
189
+ - **(b) risk isolation** — it isolates a consumer-facing behavior change or high-risk cutover into its own reviewable, revertable unit;
190
+ - **(c) envelope pressure** — merged into its neighbor it would exceed the one-pass delivery envelope (\`maxTokenBudget\`).
191
+ 4. **A dependent link with none of those justifications merges into its consumer.** This generalizes the single-consumer merge rule from pairs to chains.
192
+ 5. **Hot-file rule.** When one file appears in the \`changes[]\` of more than a third of your Stories, the slicing axis cuts across a shared seam — merge the Stories that co-edit it, or re-slice along the seam so each Story owns its files.
193
+
194
+ End each Story's \`reason_to_exist\` with its justification letter and one clause, e.g. "… (a: runs in wave 1 alongside <slug>)" or "(b: isolates the auto-merge default change)". A reason that names only a topic ("config work", "docs") with no justification is a merge signal.
181
195
 
182
196
  #### \`wide\` DECLARATION (optional — for legitimately broad changes):
183
197