mandrel 2.53.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 +241 -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 +119 -6
  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 +70 -6
  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 +40 -16
  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 +8 -1
  109. package/.agents/workflows/mandrel-plan.md +4 -7
  110. package/.agents/workflows/memory-consolidate.md +14 -9
  111. package/docs/CHANGELOG.md +34 -0
  112. package/lib/cli/registry.js +64 -21
  113. package/lib/cli/sync.js +27 -2
  114. package/package.json +7 -4
@@ -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
 
@@ -401,25 +401,59 @@ async function cascadeCompletion(provider, ticketId, opts = {}) {
401
401
  * Derive the parent `agent::*` state from the composition of its children.
402
402
  *
403
403
  * Rules (Story #2676):
404
- * - Any child carrying `agent::blocked` → parent should be `agent::blocked`.
404
+ * - Any **open** child carrying `agent::blocked` → parent should be
405
+ * `agent::blocked`.
405
406
  * - Otherwise, every child is `agent::done` (or closed) → parent should be
406
407
  * `agent::done`.
407
- * - Otherwise, any child carrying `agent::executing` or `agent::closing` →
408
- * parent should be `agent::executing`.
408
+ * - Otherwise, any **open** child carrying `agent::executing` or
409
+ * `agent::closing` → parent should be `agent::executing`.
409
410
  * - Otherwise (e.g. all children still `agent::ready`) → return `null` to
410
411
  * signal "leave the parent unchanged". A parent already partway through
411
412
  * the lifecycle MUST NOT be downgraded just because one child reverted.
412
413
  *
414
+ * **A closed child contributes no `agent::*` state** (Story #5255). Its label
415
+ * records the state it was in when it stopped, not outstanding work, and
416
+ * nothing clears it on the way out: a Story re-planned out of `agent::blocked`
417
+ * is closed as superseded still wearing that label, and the blocked rule then
418
+ * pinned its container Epic open forever — `epic-rollup.js` bails before its
419
+ * close path on any derived state other than `agent::done`, so the Epic
420
+ * reported `pending` every run with every child long since closed. Filtering
421
+ * here rather than at that one call site is what also covers a child closed by
422
+ * hand with a stale state label attached. The all-done branch already counted
423
+ * `state === 'closed'` as done, so a closed child keeps exactly that meaning
424
+ * and loses only its vote on the other two.
425
+ *
413
426
  * The function is pure and exported so the rule can be exercised in
414
427
  * isolation by unit tests without dragging the cascade I/O surface in.
415
428
  *
416
429
  * @param {Array<{ labels?: string[], state?: string }>} siblings
417
430
  * @returns {string|null} A `STATE_LABELS.*` value, or `null` for no-op.
418
431
  */
432
+ /**
433
+ * The labels that still describe **live** work on this child.
434
+ *
435
+ * Empty for a closed child: its `agent::*` label records the state it stopped
436
+ * in, not outstanding work, and the two live-state rules in
437
+ * {@link deriveParentState} must not read it. The all-done rule reads the
438
+ * child's labels directly, so a closed child keeps counting as done.
439
+ *
440
+ * Module-level rather than another local arrow inside `deriveParentState`:
441
+ * the CRAP baseline keys anonymous functions positionally within their
442
+ * enclosing scope, so adding or removing one there renumbers every later
443
+ * arrow and reports the shift as drift on code that did not change.
444
+ *
445
+ * @param {{ labels?: string[], state?: string }} sibling
446
+ * @returns {string[]}
447
+ */
448
+ function liveChildLabels(sibling) {
449
+ if (sibling?.state === 'closed') return [];
450
+ return Array.isArray(sibling?.labels) ? sibling.labels : [];
451
+ }
452
+
419
453
  export function deriveParentState(siblings) {
420
454
  if (!Array.isArray(siblings) || siblings.length === 0) return null;
421
455
  const labelsOf = (s) => (Array.isArray(s?.labels) ? s.labels : []);
422
- if (siblings.some((s) => labelsOf(s).includes(STATE_LABELS.BLOCKED))) {
456
+ if (siblings.some((s) => liveChildLabels(s).includes(STATE_LABELS.BLOCKED))) {
423
457
  return STATE_LABELS.BLOCKED;
424
458
  }
425
459
  const allDone = siblings.every(
@@ -428,13 +462,43 @@ export function deriveParentState(siblings) {
428
462
  if (allDone) return STATE_LABELS.DONE;
429
463
  const anyActive = siblings.some(
430
464
  (s) =>
431
- labelsOf(s).includes(STATE_LABELS.EXECUTING) ||
432
- labelsOf(s).includes(STATE_LABELS.CLOSING),
465
+ liveChildLabels(s).includes(STATE_LABELS.EXECUTING) ||
466
+ liveChildLabels(s).includes(STATE_LABELS.CLOSING),
433
467
  );
434
468
  if (anyActive) return STATE_LABELS.EXECUTING;
435
469
  return null;
436
470
  }
437
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
+
438
502
  /**
439
503
  * Parent-state cascade for non-terminal transitions. Story #2676.
440
504
  *