mandrel 2.54.0 → 2.55.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 (114) hide show
  1. package/.agents/agents/story-worker.md +24 -23
  2. package/.agents/audit-checklists/accessibility.md +0 -3
  3. package/.agents/audit-checklists/mobile.md +0 -4
  4. package/.agents/docs/agentrc-reference.json +4 -2
  5. package/.agents/docs/configuration.md +2 -0
  6. package/.agents/schemas/agentrc.schema.json +15 -1
  7. package/.agents/schemas/lifecycle/merge.unlanded.schema.json +2 -1
  8. package/.agents/schemas/story-deliver-terminal.schema.json +1 -0
  9. package/.agents/scripts/audit-to-stories.js +158 -7
  10. package/.agents/scripts/check-audit-attribution.js +119 -62
  11. package/.agents/scripts/check-test-portability.js +512 -0
  12. package/.agents/scripts/coverage-capture.js +17 -10
  13. package/.agents/scripts/evidence-gate.js +31 -4
  14. package/.agents/scripts/generate-workflows-doc.js +65 -14
  15. package/.agents/scripts/git-cleanup.js +4 -0
  16. package/.agents/scripts/lib/ITicketingProvider.js +78 -0
  17. package/.agents/scripts/lib/audit-advisories.js +195 -0
  18. package/.agents/scripts/lib/audit-attribution.js +22 -0
  19. package/.agents/scripts/lib/audit-to-stories/dedupe-against-github.js +68 -5
  20. package/.agents/scripts/lib/audit-to-stories/issue-index.js +83 -0
  21. package/.agents/scripts/lib/audit-to-stories/ledger-commit.js +60 -114
  22. package/.agents/scripts/lib/audit-to-stories/ledger-pr.js +347 -0
  23. package/.agents/scripts/lib/audit-to-stories/parse-audit-md.js +169 -44
  24. package/.agents/scripts/lib/baselines/merge-envelopes.js +298 -32
  25. package/.agents/scripts/lib/bootstrap/baseline-merge-driver.js +180 -14
  26. package/.agents/scripts/lib/cli-args.js +26 -0
  27. package/.agents/scripts/lib/close-validation/gates.js +113 -7
  28. package/.agents/scripts/lib/close-validation/process.js +7 -3
  29. package/.agents/scripts/lib/close-validation/runner.js +62 -11
  30. package/.agents/scripts/lib/config/ci.js +28 -9
  31. package/.agents/scripts/lib/config-settings-schema-delivery.js +7 -0
  32. package/.agents/scripts/lib/config-settings-schema.js +19 -1
  33. package/.agents/scripts/lib/coverage-capture-fullscope.js +23 -11
  34. package/.agents/scripts/lib/coverage-capture-incremental.js +22 -16
  35. package/.agents/scripts/lib/coverage-capture-usage.js +5 -1
  36. package/.agents/scripts/lib/coverage-capture.js +77 -3
  37. package/.agents/scripts/lib/findings/route-finding.js +4 -2
  38. package/.agents/scripts/lib/full-suite-lock.js +232 -6
  39. package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
  40. package/.agents/scripts/lib/git/sync-from-base.js +130 -13
  41. package/.agents/scripts/lib/observability/source-classifier.js +1 -0
  42. package/.agents/scripts/lib/orchestration/check-baselines/phases/compare.js +10 -2
  43. package/.agents/scripts/lib/orchestration/check-baselines/phases/refresh-ack.js +75 -15
  44. package/.agents/scripts/lib/orchestration/deliver-recover.js +82 -43
  45. package/.agents/scripts/lib/orchestration/dependency-candidates.js +8 -4
  46. package/.agents/scripts/lib/orchestration/epic-candidates.js +9 -4
  47. package/.agents/scripts/lib/orchestration/epic-container.js +66 -4
  48. package/.agents/scripts/lib/orchestration/epic-rollup.js +233 -84
  49. package/.agents/scripts/lib/orchestration/file-assumptions.js +218 -16
  50. package/.agents/scripts/lib/orchestration/git-cleanup/phases/branches.js +93 -7
  51. package/.agents/scripts/lib/orchestration/git-cleanup/phases/git-probes.js +22 -6
  52. package/.agents/scripts/lib/orchestration/git-cleanup/phases/parse-args.js +26 -5
  53. package/.agents/scripts/lib/orchestration/git-cleanup/phases/phase-drivers.js +13 -2
  54. package/.agents/scripts/lib/orchestration/git-cleanup/phases/render.js +35 -5
  55. package/.agents/scripts/lib/orchestration/merge-block-class.js +18 -3
  56. package/.agents/scripts/lib/orchestration/merge-poll.js +284 -40
  57. package/.agents/scripts/lib/orchestration/plan-persist/epic-adoption.js +49 -2
  58. package/.agents/scripts/lib/orchestration/plan-persist/epic-ops.js +43 -7
  59. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +24 -1
  60. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +5 -0
  61. package/.agents/scripts/lib/orchestration/plan-persist/summary.js +3 -0
  62. package/.agents/scripts/lib/orchestration/plan-persist/supersede-ops.js +63 -0
  63. package/.agents/scripts/lib/orchestration/plan-persist/wave-serialisation.js +110 -0
  64. package/.agents/scripts/lib/orchestration/planning/memory-pool-advisory.js +130 -40
  65. package/.agents/scripts/lib/orchestration/resolve-stories.js +44 -1
  66. package/.agents/scripts/lib/orchestration/review-providers/native.js +31 -11
  67. package/.agents/scripts/lib/orchestration/review-providers/scoped-lint.js +27 -24
  68. package/.agents/scripts/lib/orchestration/run-epilogue.js +59 -38
  69. package/.agents/scripts/lib/orchestration/single-story-close/close-note.js +81 -0
  70. package/.agents/scripts/lib/orchestration/single-story-close/failed-terminal.js +40 -51
  71. package/.agents/scripts/lib/orchestration/single-story-close/phases/auto-merge.js +10 -2
  72. package/.agents/scripts/lib/orchestration/single-story-close/phases/base-sync.js +101 -0
  73. package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +351 -28
  74. package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +27 -6
  75. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +117 -22
  76. package/.agents/scripts/lib/orchestration/story-close/baseline-upward-writeback.js +94 -12
  77. package/.agents/scripts/lib/orchestration/story-close/format-autofix.js +6 -1
  78. package/.agents/scripts/lib/orchestration/ticket-validator.js +25 -14
  79. package/.agents/scripts/lib/orchestration/ticketing/bulk.js +30 -0
  80. package/.agents/scripts/lib/orchestration/verify-credit.js +37 -0
  81. package/.agents/scripts/lib/pinned-override-notes.js +41 -53
  82. package/.agents/scripts/lib/pinned-override-resolve.js +212 -0
  83. package/.agents/scripts/lib/qa/resolve-qa-contract.js +18 -0
  84. package/.agents/scripts/lib/single-story-sweep/sweep-lock.js +173 -9
  85. package/.agents/scripts/lib/skills/walk-skill-files.js +24 -7
  86. package/.agents/scripts/lib/test-temp.js +167 -30
  87. package/.agents/scripts/lib/validation-evidence.js +37 -0
  88. package/.agents/scripts/lib/wave-runner/footprint.js +167 -14
  89. package/.agents/scripts/lib/wave-runner/live-probe.js +7 -1
  90. package/.agents/scripts/lib/wave-runner/ready-set.js +1 -1
  91. package/.agents/scripts/merge-baseline.js +175 -21
  92. package/.agents/scripts/providers/github/errors.js +22 -1
  93. package/.agents/scripts/providers/github/issues.js +106 -1
  94. package/.agents/scripts/providers/github/sub-issue-add.js +18 -1
  95. package/.agents/scripts/providers/github.js +6 -0
  96. package/.agents/scripts/resolve-stories.js +44 -34
  97. package/.agents/scripts/single-story-close.js +5 -0
  98. package/.agents/scripts/stories-wave-tick.js +37 -13
  99. package/.agents/templates/docs/audit-sweep-runbook.md +41 -7
  100. package/.agents/workflows/audit-accessibility.md +16 -31
  101. package/.agents/workflows/audit-mobile.md +20 -37
  102. package/.agents/workflows/git-cleanup.md +17 -3
  103. package/.agents/workflows/helpers/audit-lens-core.md +45 -0
  104. package/.agents/workflows/helpers/deliver-digest.md +7 -6
  105. package/.agents/workflows/helpers/deliver-reference.md +35 -14
  106. package/.agents/workflows/helpers/deliver-story-reference.md +7 -4
  107. package/.agents/workflows/helpers/deliver-story.md +15 -12
  108. package/.agents/workflows/helpers/plan-reference.md +7 -0
  109. package/.agents/workflows/mandrel-plan.md +4 -7
  110. package/.agents/workflows/memory-consolidate.md +14 -9
  111. package/docs/CHANGELOG.md +27 -0
  112. package/lib/cli/registry.js +64 -21
  113. package/lib/cli/sync.js +27 -2
  114. package/package.json +7 -4
@@ -36,6 +36,23 @@ function resolveFlag(paramValue, parsedValue) {
36
36
  return paramValue ?? parsedValue;
37
37
  }
38
38
 
39
+ /**
40
+ * An integer flag at or above `min`, or `undefined` — the shape BOTH numeric
41
+ * close flags share (Story #5266 deduplicated them): absence is preserved so
42
+ * the reader downstream applies its own config default, and a junk value is
43
+ * treated as absent rather than coerced into a bound nobody asked for.
44
+ *
45
+ * `min` is what separates them: `--max-wait-seconds 0` is a typo (a
46
+ * zero-second wait), while `--rerun-advisory 0` is the meaningful default.
47
+ *
48
+ * @param {unknown} value
49
+ * @param {number} min
50
+ * @returns {number|undefined}
51
+ */
52
+ function intAtLeast(value, min) {
53
+ return Number.isInteger(value) && value >= min ? value : undefined;
54
+ }
55
+
39
56
  /**
40
57
  * Resolve whether close lands the PR in-process (`waitForMerge`).
41
58
  *
@@ -139,8 +156,8 @@ function assertNoRetiredFlags(argv) {
139
156
  * (`waitForMergeExplicit` / `noWaitForMerge`) for the runner to resolve once
140
157
  * the config and the arm outcome exist.
141
158
  *
142
- * @param {{ storyIdParam, cwdParam, skipValidationParam, skipSyncParam, noAutoMergeParam, waitForMergeParam, noWaitForMergeParam, maxWaitSecondsParam, mergeWatchModeParam, overrideReviewBlockParam }} raw
143
- * @returns {{ storyId, cwd, skipValidation, skipSync, noAutoMerge, waitForMergeExplicit, noWaitForMerge, maxWaitSeconds, mergeWatchMode, overrideReviewBlock }}
159
+ * @param {{ storyIdParam, cwdParam, skipValidationParam, skipSyncParam, noAutoMergeParam, waitForMergeParam, noWaitForMergeParam, maxWaitSecondsParam, mergeWatchModeParam, rerunAdvisoryParam, overrideReviewBlockParam }} raw
160
+ * @returns {{ storyId, cwd, skipValidation, skipSync, noAutoMerge, waitForMergeExplicit, noWaitForMerge, maxWaitSeconds, mergeWatchMode, rerunAdvisory, overrideReviewBlock }}
144
161
  */
145
162
  export function parseCloseOptions({
146
163
  storyIdParam,
@@ -152,6 +169,7 @@ export function parseCloseOptions({
152
169
  noWaitForMergeParam,
153
170
  maxWaitSecondsParam,
154
171
  mergeWatchModeParam,
172
+ rerunAdvisoryParam,
155
173
  overrideReviewBlockParam,
156
174
  }) {
157
175
  // An injecting caller (`storyIdParam` supplied) is not reading argv at all,
@@ -178,6 +196,7 @@ export function parseCloseOptions({
178
196
  maxWaitSecondsParam,
179
197
  parsed.maxWaitSeconds,
180
198
  );
199
+ const rerunAdvisory = resolveFlag(rerunAdvisoryParam, parsed.rerunAdvisory);
181
200
  return {
182
201
  storyId: resolveFlag(storyIdParam, parsed.storyId),
183
202
  cwd: path.resolve(cwdParam ?? parsed.cwd ?? PROJECT_ROOT),
@@ -185,10 +204,7 @@ export function parseCloseOptions({
185
204
  // `delivery.mergeWatch.maxWaitSeconds`. A per-run override exists so a
186
205
  // headless caller with no host tool-invocation ceiling can keep
187
206
  // single-block semantics without editing the consumer's config.
188
- maxWaitSeconds:
189
- Number.isInteger(maxWaitSeconds) && maxWaitSeconds > 0
190
- ? maxWaitSeconds
191
- : undefined,
207
+ maxWaitSeconds: intAtLeast(maxWaitSeconds, 1),
192
208
  // `undefined` when unsupplied — the merge wait then reads
193
209
  // `delivery.mergeWatch.mode`. The two merge-watch flags stay composable and
194
210
  // mode-agnostic: `--merge-watch-mode async` picks the posture, and an
@@ -196,6 +212,11 @@ export function parseCloseOptions({
196
212
  mergeWatchMode: parseMergeWatchMode(
197
213
  resolveFlag(mergeWatchModeParam, parsed.mergeWatchMode),
198
214
  ),
215
+ // Story #5266 — `undefined` when unsupplied, so the merge wait reads
216
+ // `delivery.ci.rerunAdvisory` (default 0). An explicit 0 is preserved as
217
+ // an explicit 0: it means "spend nothing", which is also the default, but
218
+ // an operator who typed it must not have it read as absent.
219
+ rerunAdvisory: intAtLeast(rerunAdvisory, 0),
199
220
  skipValidation: !!resolveFlag(skipValidationParam, parsed.skipValidation),
200
221
  skipSync: !!resolveFlag(skipSyncParam, parsed.skipSync),
201
222
  noAutoMerge: !!resolveFlag(noAutoMergeParam, parsed.noAutoMerge),
@@ -15,6 +15,7 @@ import { createProvider } from '../../provider-factory.js';
15
15
  import { flipLabelAndNotify } from '../../single-story/story-merged-notify.js';
16
16
  import { WorktreeManager } from '../../worktree-manager.js';
17
17
  import { runCodeReview as runCodeReviewDefault } from '../code-review.js';
18
+ import { MERGED_FLIP_FAILED_BLOCK_CLASS } from '../lifecycle/emit-merge-flip-failed.js';
18
19
  import { resolveRunScopedConfig } from '../run-scoped-config.js';
19
20
  import { releaseStoryLease } from '../single-story-lease-guard.js';
20
21
  import {
@@ -23,6 +24,7 @@ import {
23
24
  NEXT_COMMANDS,
24
25
  terminalFromWaitOutcome,
25
26
  } from '../story-deliver-terminal.js';
27
+ import { deriveCloseNote } from './close-note.js';
26
28
  import { runAutoMergePhase } from './phases/auto-merge.js';
27
29
  import { runBaseSyncPhase } from './phases/base-sync.js';
28
30
  import { runCloseValidationPhase } from './phases/close-validation.js';
@@ -203,6 +205,7 @@ async function runPrePushPhases({
203
205
  injectedSync,
204
206
  injectedGitSpawn,
205
207
  setPhase = () => {},
208
+ setObservedGates = () => {},
206
209
  }) {
207
210
  setPhase('wrong-tree-guard');
208
211
  await runWrongTreeGuardPhase({
@@ -235,18 +238,34 @@ async function runPrePushPhases({
235
238
  return { validationGates: null };
236
239
  }
237
240
  setPhase('close-validation');
238
- const validation = await runCloseValidationPhase({
239
- cwd,
240
- worktreePath,
241
- config,
242
- baseBranch,
243
- storyBranch,
244
- storyId,
245
- progress,
246
- runCloseValidation,
247
- buildDefaultGates,
248
- });
249
- return { validationGates: validation?.gates ?? null };
241
+ let validation;
242
+ try {
243
+ validation = await runCloseValidationPhase({
244
+ cwd,
245
+ worktreePath,
246
+ config,
247
+ baseBranch,
248
+ storyBranch,
249
+ storyId,
250
+ progress,
251
+ runCloseValidation,
252
+ buildDefaultGates,
253
+ });
254
+ } catch (err) {
255
+ // Story #5279 — the failed terminal REPORTS gates rather than
256
+ // reconstructing them, so hand it what this run observed. The phase tags
257
+ // `closeGate` with the entry that died; that one name is the whole of
258
+ // what the run observed about gate outcomes, and therefore the whole of
259
+ // what the envelope may claim. Anything else it might have named is a
260
+ // gate the run never proved ran at all.
261
+ if (typeof err?.closeGate === 'string') {
262
+ setObservedGates({ [err.closeGate]: 'failed' });
263
+ }
264
+ throw err;
265
+ }
266
+ const gates = validation?.gates ?? null;
267
+ setObservedGates(gates);
268
+ return { validationGates: gates };
250
269
  }
251
270
 
252
271
  async function openAndReviewPr({
@@ -402,6 +421,38 @@ async function releaseLeaseOnBlock(run, leaseArgs) {
402
421
  }
403
422
  }
404
423
 
424
+ /**
425
+ * Did this run OBSERVE the PR merge? (Story #5279)
426
+ *
427
+ * `merged` used to be whatever the caller happened to pass, and the two
428
+ * finishers passed different halves of the truth. Three observations say a
429
+ * merge happened, and each of them reported `merged: false` on at least one
430
+ * path:
431
+ *
432
+ * - the confirm phase watched it land (`waitOutcome.confirmed`);
433
+ * - it landed and only the `agent::done` label write failed
434
+ * (`merged-flip-failed`) — the terminal envelope for that same run
435
+ * already reports `pr.state: MERGED` from the probe, so a result denying
436
+ * the merge made the two halves of one run contradict each other;
437
+ * - the arm phase direct-squash-merged it synchronously because the
438
+ * repository has no native auto-merge (`directMerged`), which the
439
+ * no-wait finisher never consulted at all.
440
+ *
441
+ * Deliberately observation-only: it reads what the run saw, never what the
442
+ * run intended. A `null`/absent `waitOutcome` is the no-wait finisher, whose
443
+ * sole observation is the direct merge.
444
+ *
445
+ * @param {{ waitOutcome?: object|null, directMerged?: boolean }} args
446
+ * @returns {boolean}
447
+ */
448
+ function deriveObservedMerge({ waitOutcome = null, directMerged = false }) {
449
+ return (
450
+ waitOutcome?.confirmed === true ||
451
+ waitOutcome?.blockClass === MERGED_FLIP_FAILED_BLOCK_CLASS ||
452
+ directMerged === true
453
+ );
454
+ }
455
+
405
456
  function closeResult({
406
457
  storyId,
407
458
  storyBranch,
@@ -416,6 +467,7 @@ function closeResult({
416
467
  directMerged = false,
417
468
  waitedForMerge = false,
418
469
  merged = false,
470
+ landCompleted = merged,
419
471
  }) {
420
472
  return {
421
473
  storyId,
@@ -440,11 +492,20 @@ function closeResult({
440
492
  directMerged,
441
493
  waitedForMerge,
442
494
  merged,
443
- note: waitedForMerge
444
- ? 'Close-and-land: PR merge confirmed. Story flipped agent::closing → agent::done, the issue closed (confirmStoryMerged), and the post-land tail ran.'
445
- : autoMergeEnabled
446
- ? 'PR open against baseBranch with auto-merge enabled. Story rests at agent::closing (issue stays OPEN and assigned to the operator). GitHub will squash-merge when required checks pass; run single-story-confirm-merge.js after the merge confirms to flip agent::done, release the lease, and close the issue (the Closes #<id> footer also auto-closes it).'
447
- : 'PR open against baseBranch. Story rests at agent::closing (issue stays OPEN and assigned to the operator). Operator merges via GitHub UI; run single-story-confirm-merge.js after the merge confirms to flip agent::done and release the lease (the Closes #<id> footer also auto-closes the issue).',
495
+ // Story #5266 — derived from `merged` / `directMerged` / `autoMergeEnabled`,
496
+ // never from `waitedForMerge`. A wait that expired unmerged used to write
497
+ // "PR merge confirmed" beside `merged: false`, and this log is what the
498
+ // operator reads first. See close-note.js for the invariant.
499
+ // Story #5279 — `landCompleted` keeps that invariant intact now that
500
+ // `merged: true` no longer implies the flip and the tail ran: a direct
501
+ // merge under `--no-wait-merge`, and a `merged-flip-failed` block, are
502
+ // both merged WITHOUT a completed land.
503
+ note: deriveCloseNote({
504
+ merged,
505
+ directMerged,
506
+ autoMergeEnabled,
507
+ landCompleted,
508
+ }),
448
509
  };
449
510
  }
450
511
 
@@ -458,6 +519,7 @@ export async function runSingleStoryClose({
458
519
  noWaitForMerge: noWaitForMergeParam,
459
520
  maxWaitSeconds: maxWaitSecondsParam,
460
521
  mergeWatchMode: mergeWatchModeParam,
522
+ rerunAdvisory: rerunAdvisoryParam,
461
523
  overrideReviewBlock: overrideReviewBlockParam,
462
524
  injectedProvider,
463
525
  injectedConfig,
@@ -478,11 +540,12 @@ export async function runSingleStoryClose({
478
540
  noWaitForMergeParam,
479
541
  maxWaitSecondsParam,
480
542
  mergeWatchModeParam,
543
+ rerunAdvisoryParam,
481
544
  overrideReviewBlockParam,
482
545
  });
483
546
  if (!options.storyId) {
484
547
  throw new Error(
485
- 'Usage: node single-story-close.js --story <STORY_ID> [--cwd <main-repo>] [--skip-validation] [--skip-sync] [--no-auto-merge] [--wait-merge|--no-wait-merge] [--max-wait-seconds <n>] [--merge-watch-mode <sync|async>] [--override-review-block <reason>]',
548
+ 'Usage: node single-story-close.js --story <STORY_ID> [--cwd <main-repo>] [--skip-validation] [--skip-sync] [--no-auto-merge] [--wait-merge|--no-wait-merge] [--max-wait-seconds <n>] [--merge-watch-mode <sync|async>] [--rerun-advisory <n>] [--override-review-block <reason>]',
486
549
  );
487
550
  }
488
551
 
@@ -493,13 +556,23 @@ export async function runSingleStoryClose({
493
556
  // the `failed` terminal envelope. Tagging rather than swallowing is what
494
557
  // lets `failed` name its phase without inventing a second success path.
495
558
  let phase = 'init';
559
+ // Story #5279 — the per-gate outcomes this run OBSERVED, tagged onto a
560
+ // throwing error alongside the phase so `failedTerminalFor` reports gates
561
+ // instead of reconstructing which ones "must have" run. Stays null until
562
+ // close-validation reports, so a run that died before it claims no gates at
563
+ // all rather than inventing names for gates that were never registered.
564
+ let observedGates = null;
496
565
  const setPhase = (next) => {
497
566
  phase = next;
498
567
  };
568
+ const setObservedGates = (gates) => {
569
+ observedGates = gates;
570
+ };
499
571
  try {
500
572
  return await runClosePipeline({
501
573
  options,
502
574
  setPhase,
575
+ setObservedGates,
503
576
  injectedProvider,
504
577
  injectedConfig,
505
578
  injectedNotify,
@@ -510,8 +583,9 @@ export async function runSingleStoryClose({
510
583
  injectedReleaseLease,
511
584
  });
512
585
  } catch (err) {
513
- if (err && typeof err === 'object' && !err.closePhase) {
514
- err.closePhase = phase;
586
+ if (err && typeof err === 'object') {
587
+ if (!err.closePhase) err.closePhase = phase;
588
+ if (!err.closeGates && observedGates) err.closeGates = observedGates;
515
589
  }
516
590
  throw err;
517
591
  }
@@ -596,6 +670,7 @@ async function finishWithMergeWait(prCtx, deps) {
596
670
  config: prCtx.config,
597
671
  maxWaitSeconds: deps.maxWaitSeconds,
598
672
  mergeWatchMode: deps.mergeWatchMode,
673
+ rerunAdvisory: deps.rerunAdvisory,
599
674
  progress,
600
675
  injectedGh: deps.injectedGh,
601
676
  injectedNotify: deps.injectedNotify,
@@ -628,7 +703,15 @@ async function finishWithMergeWait(prCtx, deps) {
628
703
  localCleanupDeferred: prCtx.localCleanupDeferred,
629
704
  directMerged: prCtx.directMerged,
630
705
  waitedForMerge: true,
631
- merged: waitOutcome.confirmed === true,
706
+ // Story #5279 — `confirmed` alone denied the merge behind a
707
+ // `merged-flip-failed` block, whose own envelope (above) reports
708
+ // `pr.state: MERGED` off the probe.
709
+ merged: deriveObservedMerge({
710
+ waitOutcome,
711
+ directMerged: prCtx.directMerged,
712
+ }),
713
+ // Only the confirmed ending runs the flip, the issue close and the tail.
714
+ landCompleted: waitOutcome.confirmed === true,
632
715
  });
633
716
  await emitTerminal({ terminal, result, config: prCtx.config });
634
717
  reportWaitTerminal(terminal, { storyId: prCtx.storyId, prUrl: prCtx.prUrl });
@@ -646,6 +729,11 @@ async function finishWithMergeWait(prCtx, deps) {
646
729
  * @returns {Promise<{ success: boolean, result: object, terminal: object }>}
647
730
  */
648
731
  async function finishWithoutMergeWait(prCtx, waitForMergeReason) {
732
+ // Story #5279 — a repository with no native auto-merge is direct
733
+ // squash-merged synchronously by the arm phase, so this ending can and does
734
+ // hold an OBSERVED merge. It used to report `merged: false` and hard-code
735
+ // `pr.state: 'OPEN'` for a PR the same run had just merged.
736
+ const merged = deriveObservedMerge({ directMerged: prCtx.directMerged });
649
737
  const result = closeResult({
650
738
  storyId: prCtx.storyId,
651
739
  storyBranch: prCtx.storyBranch,
@@ -661,6 +749,10 @@ async function finishWithoutMergeWait(prCtx, waitForMergeReason) {
661
749
  leaseReleased: false,
662
750
  localCleanupDeferred: prCtx.localCleanupDeferred,
663
751
  directMerged: prCtx.directMerged,
752
+ merged,
753
+ // Whatever the merge state, this ending never flips `agent::done`, never
754
+ // closes the issue and never runs the tail — `nextCommand` does.
755
+ landCompleted: false,
664
756
  });
665
757
  const terminal = buildTerminalEnvelope({
666
758
  storyId: prCtx.storyId,
@@ -671,7 +763,7 @@ async function finishWithoutMergeWait(prCtx, waitForMergeReason) {
671
763
  pr: {
672
764
  number: prCtx.prNumber,
673
765
  url: prCtx.prUrl ?? null,
674
- state: 'OPEN',
766
+ state: merged ? 'MERGED' : 'OPEN',
675
767
  autoMergeEnabled: Boolean(prCtx.autoMergeEnabled),
676
768
  },
677
769
  gates: prCtx.gates,
@@ -731,6 +823,7 @@ function reportOperatorMergeSkip({
731
823
  async function runClosePipeline({
732
824
  options,
733
825
  setPhase,
826
+ setObservedGates,
734
827
  injectedProvider,
735
828
  injectedConfig,
736
829
  injectedNotify,
@@ -805,6 +898,7 @@ async function runClosePipeline({
805
898
  injectedSync,
806
899
  injectedGitSpawn,
807
900
  setPhase,
901
+ setObservedGates,
808
902
  }),
809
903
  leaseArgs,
810
904
  );
@@ -947,6 +1041,7 @@ async function runClosePipeline({
947
1041
  provider,
948
1042
  maxWaitSeconds: options.maxWaitSeconds,
949
1043
  mergeWatchMode: options.mergeWatchMode,
1044
+ rerunAdvisory: options.rerunAdvisory,
950
1045
  setPhase,
951
1046
  injectedGh,
952
1047
  injectedNotify,
@@ -64,7 +64,11 @@ import {
64
64
  import { getQuality } from '../../config-resolver.js';
65
65
  import { gitSync as defaultGitSync } from '../../git-utils.js';
66
66
  import { Logger as DefaultLogger } from '../../Logger.js';
67
- import { currentBranch, listChangedFiles } from './format-autofix.js';
67
+ import {
68
+ currentBranch,
69
+ listChangedFiles,
70
+ listDirtyPaths,
71
+ } from './format-autofix.js';
68
72
 
69
73
  const TAG = '[baseline-writeback]';
70
74
 
@@ -92,6 +96,7 @@ const GUARD_REASONS = new Set([
92
96
  'gate-disabled',
93
97
  'no-changed-files',
94
98
  'wrong-branch',
99
+ 'dirty-tree',
95
100
  'no-baseline',
96
101
  'no-scorer',
97
102
  ]);
@@ -267,7 +272,14 @@ function commitBaseline({ cwd, git, relPath, subject, body }) {
267
272
  stdio: ['ignore', 'pipe', 'pipe'],
268
273
  });
269
274
  } catch (err) {
270
- git(['checkout', '--', relPath], {
275
+ // `git checkout -- <path>` restores the WORKTREE from the index — and the
276
+ // index is exactly what the `git add` above just overwrote, so on a
277
+ // rejected commit it restored the file to the value it was meant to be
278
+ // rolled back FROM. The rollback was a no-op that looked like one, and the
279
+ // rewritten row survived as a staged edit into whatever the gates scored
280
+ // next. `restore --staged --worktree` resets both to HEAD, which is what
281
+ // "leave the tree as the close found it" actually means.
282
+ git(['restore', '--staged', '--worktree', '--', relPath], {
271
283
  cwd,
272
284
  stdio: ['ignore', 'pipe', 'pipe'],
273
285
  });
@@ -289,24 +301,84 @@ function commitBaseline({ cwd, git, relPath, subject, body }) {
289
301
  * so a mis-wired `worktreePath` can never leave a modified baseline in a tree
290
302
  * whose history we then refuse to touch.
291
303
  *
292
- * @param {{ gate: object|undefined, workTree: string, storyBranch: string, git: Function, changed: string[] }} ctx
304
+ * The dirty-tree guard is the same refusal `runScopedFormatAutofix` makes and
305
+ * for the same reason, sharpened to one path: this step's only write target is
306
+ * the baseline file, and it commits that file by name. An uncommitted edit
307
+ * sitting on it — a hand-run `maintainability:reanchor`, a half-resolved merge,
308
+ * an operator mid-edit — would be swept into a `baseline-refresh:` commit
309
+ * authored by close and attributed to rows this branch improved. That commit is
310
+ * the one `refresh-ack.js` VOUCHES for, so an absorbed edit is not merely
311
+ * unrelated: it arrives pre-acknowledged, which is the precise laundering this
312
+ * whole Story exists to close.
313
+ *
314
+ * @param {{ gate: object|undefined, workTree: string, storyBranch: string, git: Function, changed: string[], relPath: string }} ctx
293
315
  * @returns {string|null}
294
316
  */
295
- function precheck({ gate, workTree, storyBranch, git, changed }) {
317
+ function precheck({ gate, workTree, storyBranch, git, changed, relPath }) {
296
318
  if (gate?.enabled === false) return 'gate-disabled';
297
319
  if (changed.length === 0) return 'no-changed-files';
298
320
  const onBranch = currentBranch(workTree, git);
299
321
  if (onBranch !== storyBranch) return 'wrong-branch';
322
+ if (isDirty({ workTree, git, relPath })) return 'dirty-tree';
300
323
  return null;
301
324
  }
302
325
 
326
+ /**
327
+ * Is the baseline file already modified in the worktree or the index?
328
+ *
329
+ * Fails CLOSED on an unreadable status: a step that cannot tell whether it is
330
+ * about to absorb someone else's edit must not proceed, and skipping costs
331
+ * only a stale upward row that the nightly full-scope re-score still catches.
332
+ *
333
+ * @param {{ workTree: string, git: Function, relPath: string }} ctx
334
+ * @returns {boolean}
335
+ */
336
+ function isDirty({ workTree, git, relPath }) {
337
+ try {
338
+ return listDirtyPaths(workTree, git).includes(relPath);
339
+ } catch {
340
+ return true;
341
+ }
342
+ }
343
+
344
+ /**
345
+ * The ref the branch's changed-file set is measured against.
346
+ *
347
+ * `origin/<baseBranch>` when the remote-tracking ref exists, the local branch
348
+ * otherwise. A local `main` in a long-lived checkout — and in every Story
349
+ * worktree, which is seeded once and never pulled again — drifts behind the
350
+ * remote, and a stale base widens the three-dot range to include commits that
351
+ * landed on the base after the branch forked. Every file in that widening is
352
+ * then scored and written back by whichever Story happens to close next, which
353
+ * is precisely the "absorb unrelated drift into the next PR" failure
354
+ * constraint 3 in the preamble forbids.
355
+ *
356
+ * @param {{ workTree: string, git: Function, baseBranch: string }} ctx
357
+ * @returns {string}
358
+ */
359
+ function resolveScopeBase({ workTree, git, baseBranch }) {
360
+ try {
361
+ git(
362
+ ['rev-parse', '--verify', '--quiet', `refs/remotes/origin/${baseBranch}`],
363
+ {
364
+ cwd: workTree,
365
+ encoding: 'utf8',
366
+ stdio: ['ignore', 'pipe', 'ignore'],
367
+ },
368
+ );
369
+ return `origin/${baseBranch}`;
370
+ } catch {
371
+ return baseBranch;
372
+ }
373
+ }
374
+
303
375
  /**
304
376
  * Persist improved maintainability rows for the files this branch changed, and
305
377
  * fold them into one `baseline-refresh:` commit on the Story branch.
306
378
  *
307
379
  * Every no-op is reported by name rather than silently: `gate-disabled`,
308
- * `no-changed-files`, `wrong-branch`, `no-baseline`, `no-scored-rows`,
309
- * `no-improvements`, `unchanged`. The caller logs the reason and proceeds —
380
+ * `no-changed-files`, `wrong-branch`, `dirty-tree`, `no-baseline`,
381
+ * `no-scored-rows`, `no-improvements`, `unchanged`. The caller logs the reason and proceeds —
310
382
  * this step is never allowed to fail a close, because `check-baselines` is
311
383
  * still the gate and this is only the refresh half of the loop.
312
384
  *
@@ -364,12 +436,22 @@ export async function runBaselineUpwardWriteback({
364
436
 
365
437
  const changed = listChangedFiles({
366
438
  cwd: workTree,
367
- baseBranch,
439
+ baseBranch: resolveScopeBase({ workTree, git, baseBranch }),
368
440
  storyBranch,
369
441
  git,
370
442
  }).filter((file) => SCORABLE.test(file));
371
443
 
372
- const blocked = precheck({ gate, workTree, storyBranch, git, changed });
444
+ const writePath = resolveWritePath({ cwd: workTree });
445
+ const relPath = path.relative(workTree, writePath).split(path.sep).join('/');
446
+
447
+ const blocked = precheck({
448
+ gate,
449
+ workTree,
450
+ storyBranch,
451
+ git,
452
+ changed,
453
+ relPath,
454
+ });
373
455
  if (blocked) return skip(logger, blocked);
374
456
 
375
457
  const baselineRows = loadBaselineRows({ cwd: workTree });
@@ -399,7 +481,8 @@ export async function runBaselineUpwardWriteback({
399
481
  storyId,
400
482
  logger,
401
483
  refreshBaseline,
402
- resolveWritePath,
484
+ writePath,
485
+ relPath,
403
486
  });
404
487
  }
405
488
 
@@ -420,10 +503,10 @@ async function persist({
420
503
  storyId,
421
504
  logger,
422
505
  refreshBaseline,
423
- resolveWritePath,
506
+ writePath,
507
+ relPath,
424
508
  }) {
425
509
  const improvedPaths = improved.map((row) => row.path);
426
- const writePath = resolveWritePath({ cwd: workTree });
427
510
  const { wrote } = await refreshBaseline({
428
511
  kind: KIND,
429
512
  cwd: workTree,
@@ -436,7 +519,6 @@ async function persist({
436
519
  // nothing worth a log line above debug.
437
520
  if (!wrote) return skip(logger, 'unchanged');
438
521
 
439
- const relPath = path.relative(workTree, writePath).split(path.sep).join('/');
440
522
  const { sha } = commitBaseline({
441
523
  cwd: workTree,
442
524
  git,
@@ -59,11 +59,16 @@ export const FORMAT_AUTOFIX_TIMEOUT_EXIT_CODE = 124;
59
59
  * (e.g. ` M file` for unstaged-modified) so we slice a fixed 3 chars off
60
60
  * the front rather than trimming.
61
61
  *
62
+ * Exported since Story #5277: `baseline-upward-writeback.js` needs the same
63
+ * "is this path already dirty?" test before it rewrites a baseline row, and a
64
+ * second porcelain parser is exactly the near-duplicate the duplication gate
65
+ * exists to refuse.
66
+ *
62
67
  * @param {string} cwd
63
68
  * @param {(args: string[], opts: object) => string} git
64
69
  * @returns {string[]}
65
70
  */
66
- function listDirtyPaths(cwd, git) {
71
+ export function listDirtyPaths(cwd, git) {
67
72
  const out = git(['status', '--porcelain'], {
68
73
  cwd,
69
74
  encoding: 'utf8',
@@ -601,19 +601,28 @@ function assertAcyclic(slugAdjacency) {
601
601
  }
602
602
  }
603
603
 
604
- function attachFindingsAndErrors(tickets, findings, errors) {
605
- Object.defineProperty(tickets, 'findings', {
606
- value: findings,
607
- enumerable: false,
608
- configurable: true,
609
- writable: true,
610
- });
611
- Object.defineProperty(tickets, 'errors', {
612
- value: errors,
613
- enumerable: false,
614
- configurable: true,
615
- writable: true,
616
- });
604
+ function attachFindingsAndErrors(
605
+ tickets,
606
+ findings,
607
+ errors,
608
+ normalizations = [],
609
+ ) {
610
+ for (const [key, value] of [
611
+ ['findings', findings],
612
+ ['errors', errors],
613
+ // Story #5265: the auto-normalizations the assumption gate applied. They
614
+ // used to end at a `Logger.warn` and die with the process, so persist's
615
+ // emitted result reported a plan whose declarations it had silently
616
+ // rewritten as if nothing had been rewritten.
617
+ ['normalizations', normalizations],
618
+ ]) {
619
+ Object.defineProperty(tickets, key, {
620
+ value,
621
+ enumerable: false,
622
+ configurable: true,
623
+ writable: true,
624
+ });
625
+ }
617
626
  }
618
627
 
619
628
  export function validateAndNormalizeTickets(tickets, opts = {}) {
@@ -671,6 +680,7 @@ export function validateAndNormalizeTickets(tickets, opts = {}) {
671
680
  // unit tests keep their semantics; production call-sites always pass
672
681
  // it.
673
682
  let assumptionErrors = [];
683
+ let assumptionNormalizations = [];
674
684
  if (opts.baseBranchRef) {
675
685
  const assumptionReport = validateStoryFileAssumptions({
676
686
  tickets,
@@ -699,6 +709,7 @@ export function validateAndNormalizeTickets(tickets, opts = {}) {
699
709
  );
700
710
  }
701
711
  assumptionErrors = assumptionReport.errors;
712
+ assumptionNormalizations = assumptionReport.normalizations ?? [];
702
713
  }
703
714
 
704
715
  const sizingFindings = computeSizingFindings({
@@ -741,7 +752,7 @@ export function validateAndNormalizeTickets(tickets, opts = {}) {
741
752
  errors.push(`File assumption mismatch: ${e}`);
742
753
  }
743
754
 
744
- attachFindingsAndErrors(tickets, findings, errors);
755
+ attachFindingsAndErrors(tickets, findings, errors, assumptionNormalizations);
745
756
  return tickets;
746
757
  }
747
758
 
@@ -469,6 +469,36 @@ export function deriveParentState(siblings) {
469
469
  return null;
470
470
  }
471
471
 
472
+ /**
473
+ * Did any of these children actually **land**?
474
+ *
475
+ * `deriveParentState` answers `agent::done` for a child set in which every
476
+ * child is done *or closed*, and it is right to: a closed child is finished
477
+ * work as far as the parent's lifecycle goes. But "finished" and "landed" are
478
+ * different claims, and the close path spends the difference. A cohort
479
+ * re-planned out of existence closes every child as superseded, carrying no
480
+ * `agent::done` and having merged nothing — and the container above it then
481
+ * closed as `completed`, over a log line claiming every child Story landed.
482
+ * Both the state reason and the sentence were false.
483
+ *
484
+ * So the two questions are asked separately: `deriveParentState` decides
485
+ * *whether* the parent is finished, this decides *how* it finished. A single
486
+ * landed child is enough — a container that delivered some of its work and
487
+ * superseded the rest completed, partially, and `not_planned` is reserved for
488
+ * the case where nothing was delivered at all.
489
+ *
490
+ * @param {Array<{ labels?: string[] }>} children
491
+ * @returns {boolean} True when at least one child carries `agent::done`.
492
+ */
493
+ export function anyChildLanded(children) {
494
+ if (!Array.isArray(children)) return false;
495
+ return children.some((child) =>
496
+ (Array.isArray(child?.labels) ? child.labels : []).includes(
497
+ STATE_LABELS.DONE,
498
+ ),
499
+ );
500
+ }
501
+
472
502
  /**
473
503
  * Parent-state cascade for non-terminal transitions. Story #2676.
474
504
  *