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
@@ -7,8 +7,8 @@
7
7
  * 2. Rolls up friction follow-ups across every Story in the run and
8
8
  * files/posts them on the primary Story.
9
9
  * 3. Checks sibling Spec/acceptance coherence across Story bodies.
10
- * 4. Reports the container Epics whose children all landed (Story #5139),
11
- * delegating the derivation and the close to `epic-rollup.js`.
10
+ * 4. Reports what the per-Story land tails left the run's container Epics
11
+ * in — closed, or still open (Story #5139; read-only since #5280).
12
12
  *
13
13
  * There is no inert planner-only path: `planRunEpilogue` enumerates steps
14
14
  * and `runPlanRunEpilogue` executes them. Single-Story runs skip the
@@ -21,7 +21,7 @@ import { selectAudits } from '../audit-suite/index.js';
21
21
  import { graduateRetroProposals } from '../feedback-loop/retro-proposals-graduator.js';
22
22
  import { gitSpawn } from '../git-utils.js';
23
23
  import { Logger } from '../Logger.js';
24
- import { rollUpEpicForStory } from './epic-rollup.js';
24
+ import { isEpicTicket } from './epic-container.js';
25
25
  import { composeRoutedProposals } from './retro-proposals.js';
26
26
  import {
27
27
  assessRollupOutcome,
@@ -44,54 +44,51 @@ export const RUN_EPILOGUE_STEP_KINDS = Object.freeze([
44
44
  ]);
45
45
 
46
46
  /**
47
- * Close every container Epic whose children all landed in this run.
47
+ * Report which container Epics this run's Stories left closed, and which are
48
+ * still open.
48
49
  *
49
- * Delegates to `epic-rollup.js` (Story #5205) rather than deriving anything
50
- * itself. That module owns the child→parent scan, the body-checklist-union-
51
- * native-sub-issue child read, and the one-way closure rule, and it is also
52
- * invoked from the per-Story land tail — which is what closes a container
53
- * whose last open child was a single Story, a case this epilogue never
54
- * reaches because a one-Story run reports `applicable: false`.
50
+ * **It derives nothing and writes nothing.** It used to: it walked every Story
51
+ * and re-ran the full rollup, closing containers itself. That made sense while
52
+ * the rollup only fired on the edges someone had wired, and the epilogue was
53
+ * the backstop for the ones that were missed. Every child state change is now
54
+ * an edge — init, post-land, the supersede close — so by the time the last
55
+ * Story of a run has landed, its container has already been derived from a
56
+ * complete child set by that Story's own land tail. Re-deriving here would ask
57
+ * the same question a second time and answer it identically, at the cost of a
58
+ * full re-read per Story and a second writer on the same issue.
55
59
  *
56
- * The step survives for its report: this is the surface an operator reads to
57
- * see which containers a multi-Story run closed and which are still pending.
60
+ * What survives is the report, which is why the step exists at all: one place
61
+ * an operator reads to see what a multi-Story run did to its containers. A
62
+ * pending Epic here is a real signal — it means a land tail's rollup declined
63
+ * to close, and the tail's own outcome says why.
58
64
  *
59
- * Non-fatal throughout: the epilogue is a reporting tail, and a container
60
- * left open costs tidiness, not correctness.
65
+ * Non-fatal throughout: a container it cannot read is simply not reported.
61
66
  *
62
- * @param {{ stories: string[], provider: object, config?: object }} opts
67
+ * @param {{ stories: string[], provider: object }} opts
63
68
  * @returns {Promise<{ kind: string, closed: number[], pending: number[] }>}
64
69
  */
65
- async function executeEpicClose({ stories, provider, config }) {
70
+ async function executeEpicClose({ stories, provider }) {
66
71
  const closed = new Set();
67
72
  const pending = new Set();
68
- // Siblings share a container, so an Epic resolved by one Story's rollup is
69
- // withheld from the next one's — otherwise the second Story would re-derive
70
- // and re-close what the first already closed.
73
+ // Siblings share a container: resolve each distinct Epic once, however many
74
+ // of the run's Stories point at it.
71
75
  const seen = new Set();
72
76
 
73
- // `rollUpEpicForStory` never throws and always returns the full envelope,
74
- // so its three lists are read directly — a `?? []` guard here would be an
75
- // unreachable branch asserting a contract the module already keeps.
76
77
  for (const raw of stories) {
77
78
  const storyId = Number(raw);
78
79
  if (!Number.isInteger(storyId) || storyId <= 0) continue;
79
- const outcome = await rollUpEpicForStory({
80
- storyId,
81
- provider,
82
- config,
83
- skipEpicIds: seen,
84
- });
85
- for (const epic of outcome.epics) seen.add(epic.epicId);
86
- for (const epicId of outcome.closed) closed.add(epicId);
87
- for (const epicId of outcome.pending) pending.add(epicId);
80
+ const epic = await readContainerFor({ storyId, provider });
81
+ if (!epic) continue;
82
+ const epicId = Number(epic.id);
83
+ if (!Number.isInteger(epicId) || seen.has(epicId)) continue;
84
+ seen.add(epicId);
85
+ if (String(epic.state ?? '').toLowerCase() === 'closed') {
86
+ closed.add(epicId);
87
+ } else {
88
+ pending.add(epicId);
89
+ }
88
90
  }
89
91
 
90
- // An Epic this run closed can also have been reported pending by an
91
- // earlier Story's rollup, when a sibling had not landed yet. The close is
92
- // the later, truer answer.
93
- for (const epicId of closed) pending.delete(epicId);
94
-
95
92
  return {
96
93
  kind: 'epic-close',
97
94
  closed: [...closed],
@@ -99,6 +96,30 @@ async function executeEpicClose({ stories, provider, config }) {
99
96
  };
100
97
  }
101
98
 
99
+ /**
100
+ * Read one Story's container Epic, or null.
101
+ *
102
+ * One request per Story via the declared parent port. Degrades to null on any
103
+ * failure and on a provider without the port — this is a report, and a
104
+ * container it could not read is better omitted than guessed at.
105
+ *
106
+ * @param {{ storyId: number, provider: object }} opts
107
+ * @returns {Promise<object|null>}
108
+ */
109
+ async function readContainerFor({ storyId, provider }) {
110
+ if (typeof provider?.getParentIssue !== 'function') return null;
111
+ try {
112
+ const parent = await provider.getParentIssue(storyId);
113
+ return parent && isEpicTicket(parent) ? parent : null;
114
+ } catch (err) {
115
+ Logger.warn(
116
+ `[run-epilogue] could not read the container for Story #${storyId} ` +
117
+ `(${err?.message ?? err}); omitting it from the Epic report.`,
118
+ );
119
+ return null;
120
+ }
121
+ }
122
+
102
123
  /**
103
124
  * @param {string|number|{ id?: string|number, slug?: string }} entry
104
125
  * @returns {string|null}
@@ -185,7 +206,7 @@ export function planRunEpilogue({ planRunId, stories } = {}) {
185
206
  },
186
207
  {
187
208
  kind: 'epic-close',
188
- description: `Close any container Epic whose children all landed in run ${effectiveRunId}`,
209
+ description: `Report the container Epic state the land tails of run ${effectiveRunId} left behind`,
189
210
  stories: ids,
190
211
  },
191
212
  ];
@@ -892,7 +913,7 @@ export async function runPlanRunEpilogue({
892
913
  );
893
914
  } else if (step.kind === 'epic-close') {
894
915
  results.push(
895
- await executeEpicClose({ stories: plan.stories, provider, config }),
916
+ await executeEpicClose({ stories: plan.stories, provider }),
896
917
  );
897
918
  }
898
919
  } catch (err) {
@@ -0,0 +1,81 @@
1
+ /**
2
+ * close-note.js — the human-readable `note` on close's result record
3
+ * (Story #5266).
4
+ *
5
+ * ## Why this is its own module
6
+ *
7
+ * The note used to branch on `waitedForMerge` — whether close *waited* — and
8
+ * not on `merged`. A bounded wait that expired with the PR still open
9
+ * therefore wrote "Close-and-land: PR merge confirmed … the issue closed"
10
+ * into `story-close-result-<id>.log` **beside `merged: false`**, while the
11
+ * schema-validated terminal envelope correctly reported
12
+ * `status: pending, phase: confirm-merge`. That log is what close's summary
13
+ * line points the operator at, so the contradiction is what gets read first:
14
+ * a merge that never happened, reported as confirmed.
15
+ *
16
+ * The invariant this module exists to hold:
17
+ *
18
+ * > **No note may assert a state the same object denies.**
19
+ *
20
+ * Every branch below is therefore derived from the result's OWN
21
+ * `merged` / `directMerged` / `autoMergeEnabled` / `landCompleted` fields —
22
+ * the ones the note ships next to — so no input can produce a note that
23
+ * contradicts them. Story #5279 added the fourth, because `merged: true`
24
+ * used to imply the flip, the issue close and the post-land tail: a direct
25
+ * squash-merge under `--no-wait-merge`, and a merge whose `agent::done` write
26
+ * failed, now reach it with none of the three, and reusing the confirmed
27
+ * wording for them would reintroduce the defect above one field over. The
28
+ * unmerged branches deliberately claim nothing about the Story's label state
29
+ * either: close's ending may be `pending` OR `blocked` with the same
30
+ * `merged: false`, and the terminal envelope is the authority on which. They
31
+ * also avoid the merge-completion vocabulary entirely — no `agent::done`, no
32
+ * "the merge confirms" — so that a reader skimming for those words cannot
33
+ * take a next-step instruction for a report of what happened.
34
+ */
35
+
36
+ /**
37
+ * The one line the note is not allowed to get wrong.
38
+ *
39
+ * @param {{ merged?: boolean, directMerged?: boolean,
40
+ * autoMergeEnabled?: boolean, landCompleted?: boolean }} result
41
+ * `landCompleted` — did THIS run flip `agent::done`, close the issue and
42
+ * run the post-land tail? Defaults to `merged`, the pre-#5279 equivalence.
43
+ * @returns {string}
44
+ */
45
+ export function deriveCloseNote({
46
+ merged = false,
47
+ directMerged = false,
48
+ autoMergeEnabled = false,
49
+ landCompleted = merged,
50
+ } = {}) {
51
+ if (merged) {
52
+ const how = directMerged
53
+ ? 'PR merge confirmed by a DIRECT squash-merge (native auto-merge was ' +
54
+ 'unavailable on this repository).'
55
+ : 'PR merge confirmed.';
56
+ return landCompleted
57
+ ? `Close-and-land: ${how} Story flipped agent::closing → agent::done, ` +
58
+ 'the issue closed (confirmStoryMerged), and the post-land tail ran.'
59
+ : `Close-and-land: ${how} This close did NOT finish the land: ` +
60
+ 'agent::done was not flipped and the post-land tail did not run. ' +
61
+ 'Finish it with single-story-confirm-merge.js, idempotent against an ' +
62
+ 'already-merged PR. The terminal envelope is authoritative.';
63
+ }
64
+ if (autoMergeEnabled) {
65
+ return (
66
+ 'PR open against baseBranch and NOT merged; auto-merge is armed. ' +
67
+ 'GitHub will squash-merge it once the required checks pass — resume ' +
68
+ 'with single-story-confirm-merge.js then, to finish the land and ' +
69
+ 'release the lease this close is still holding. The terminal envelope ' +
70
+ '(status / phase / blocked) is authoritative for what happened here.'
71
+ );
72
+ }
73
+ return (
74
+ 'PR open against baseBranch and NOT merged; auto-merge was not armed ' +
75
+ '(see autoMergeReason). The operator owns the land: merge via the GitHub ' +
76
+ 'UI, then resume with single-story-confirm-merge.js to finish the land ' +
77
+ 'and release the lease this close is still holding. The terminal ' +
78
+ 'envelope (status / phase / blocked) is authoritative for what happened ' +
79
+ 'here.'
80
+ );
81
+ }
@@ -50,8 +50,11 @@ const GATE_PHASES = Object.freeze([
50
50
  ]);
51
51
 
52
52
  /**
53
- * The names the split baselines gate registers under, mirrored from
54
- * `BASELINES_GATE_NAMES` in `lib/close-validation/gates.js` (Story #5172).
53
+ * The names the unified baselines gate can register under, mirrored from
54
+ * `BASELINES_GATE_NAMES` in `lib/close-validation/gates.js` (Story #5172) —
55
+ * all three, matching the projection the SUCCESS path applies in
56
+ * `runner.js#baselinesEnvelopeGates`, so the two endings of one run cannot
57
+ * key the same gate differently.
55
58
  *
56
59
  * Deliberately a local copy rather than an import: several close suites
57
60
  * replace that module wholesale via `t.mock.module`, and a named import here
@@ -61,48 +64,37 @@ const GATE_PHASES = Object.freeze([
61
64
  * against each other so the copy cannot drift.
62
65
  */
63
66
  const BASELINES_ENTRY_NAMES = Object.freeze([
67
+ 'check-baselines',
64
68
  'check-baselines-independent',
65
69
  'check-baselines-coverage',
66
70
  ]);
67
71
 
68
72
  /**
69
- * Outcome for each split baselines entry on a run that died at `phase`.
73
+ * Project the baselines entries out of the per-gate outcomes THIS run
74
+ * observed (Story #5279).
70
75
  *
71
- * The two entries sit in ONE pipeline phase, so the phase walk alone cannot
72
- * separate them — `failedGate` (tagged onto the error by the close-validation
73
- * phase) is what names the entry that actually broke. Rules, in the module's
74
- * house style of never claiming a pass it cannot prove:
75
- * - validation skipped, or the run died before reaching it → both `skipped`.
76
- * - the run cleared validation entirely → both `passed`.
77
- * - the run died IN validation on the coverage-independent entry → that one
78
- * `failed`, the coverage one `skipped` (it runs behind `coverage-capture`,
79
- * which the failure pre-empted).
80
- * - died on the coverage-consuming entry → that one `failed`, and the
81
- * independent one `passed`: it is in the parallel partition that must go
82
- * green before any serial gate starts.
83
- * - died in validation on some other gate → both `skipped`; which of them
84
- * had run is not knowable from the phase alone.
76
+ * This used to RECONSTRUCT them: it inferred, from the phase the run died in
77
+ * plus the name of the failing gate, what the two split entries "must have"
78
+ * done — and it did so over a hardcoded pair, so every failed close reported
79
+ * both split names whether or not the run had ever registered them. A repo
80
+ * whose config resolves to the unsplit `check-baselines`, or to only one half
81
+ * of the pair, got envelope keys for gates that did not exist; a run that
82
+ * died at `init` got them too, reported as `skipped`, which reads as "the
83
+ * gate was turned off" rather than "there was no such gate".
85
84
  *
86
- * @param {string} phase
87
- * @param {{ skipValidation?: boolean, failedGate?: string|null }} args
85
+ * Reporting instead of reconstructing removes the whole class: an outcome
86
+ * appears only for a gate the run actually observed, and the runner tags
87
+ * exactly that set onto the error (`err.closeGates`).
88
+ *
89
+ * @param {Record<string, string>|null|undefined} observedGates
88
90
  * @returns {Record<string, 'passed'|'failed'|'skipped'>}
89
91
  */
90
- function baselinesGatesForFailedPhase(phase, { skipValidation, failedGate }) {
91
- const [independent, coverage] = BASELINES_ENTRY_NAMES;
92
- const both = (outcome) => ({ [independent]: outcome, [coverage]: outcome });
93
- const failedAt = PHASE_ORDER.indexOf(phase);
94
- const validationAt = PHASE_ORDER.indexOf('close-validation');
95
- if (skipValidation || failedAt < 0 || failedAt < validationAt) {
96
- return both('skipped');
97
- }
98
- if (failedAt > validationAt) return both('passed');
99
- if (failedGate === independent) {
100
- return { [independent]: 'failed', [coverage]: 'skipped' };
101
- }
102
- if (failedGate === coverage) {
103
- return { [independent]: 'passed', [coverage]: 'failed' };
92
+ function baselinesGatesObserved(observedGates) {
93
+ const out = {};
94
+ for (const [name, outcome] of Object.entries(observedGates ?? {})) {
95
+ if (BASELINES_ENTRY_NAMES.includes(name)) out[name] = outcome;
104
96
  }
105
- return both('skipped');
97
+ return out;
106
98
  }
107
99
 
108
100
  /**
@@ -119,14 +111,17 @@ function baselinesGatesForFailedPhase(phase, { skipValidation, failedGate }) {
119
111
  * turned off via `--skip-validation` / `--skip-sync` is `skipped` too (it did
120
112
  * not pass — it never ran).
121
113
  *
122
- * Story #5172 — the reported set also carries the two split baselines
123
- * entries under their own names, so a failed close says WHICH half of the
124
- * baselines gate breached instead of a single generic verdict.
114
+ * Story #5172 — the reported set also carries the baselines entries under
115
+ * their own names, so a failed close says WHICH half of the baselines gate
116
+ * breached instead of a single generic verdict. Story #5279 — those names
117
+ * are REPORTED from `observedGates`, never reconstructed, so only a gate the
118
+ * run registered can appear.
125
119
  *
126
120
  * @param {string} phase The phase the run died in.
127
- * @param {{ skipValidation?: boolean, skipSync?: boolean, failedGate?: string|null }} args
128
- * Parsed CLI args, plus the gate name tagged onto the error by the
129
- * close-validation phase.
121
+ * @param {{ skipValidation?: boolean, skipSync?: boolean,
122
+ * observedGates?: Record<string, string>|null }} args
123
+ * Parsed CLI args, plus the per-gate outcomes the runner tagged onto the
124
+ * error.
130
125
  * @returns {Record<string, 'passed'|'failed'|'skipped'>}
131
126
  */
132
127
  export function gatesForFailedPhase(phase, args = {}) {
@@ -139,13 +134,7 @@ export function gatesForFailedPhase(phase, args = {}) {
139
134
  else if (failedAt < 0 || at > failedAt) gates[gate] = 'skipped';
140
135
  else gates[gate] = skipped[gate] ? 'skipped' : 'passed';
141
136
  }
142
- return {
143
- ...gates,
144
- ...baselinesGatesForFailedPhase(phase, {
145
- skipValidation: args.skipValidation,
146
- failedGate: args.failedGate ?? null,
147
- }),
148
- };
137
+ return { ...gates, ...baselinesGatesObserved(args.observedGates) };
149
138
  }
150
139
 
151
140
  /**
@@ -163,9 +152,9 @@ export function gatesForFailedPhase(phase, args = {}) {
163
152
  * holding the script had been reaped mid-run. On failure this returns null
164
153
  * and the caller rethrows the original.
165
154
  *
166
- * `err.closeGate` — tagged by the close-validation phase — names the gate that
167
- * died inside that phase, which is what lets the reported gates separate the
168
- * two split baselines entries (Story #5172).
155
+ * `err.closeGates` — tagged by the runner — carries the per-gate outcomes the
156
+ * run observed, which is what lets the reported gates name the baselines
157
+ * entries that actually ran (Story #5172 / #5279) instead of a hardcoded pair.
169
158
  *
170
159
  * @param {unknown} err
171
160
  * @param {{ storyId?: string|number, skipValidation?: boolean, skipSync?: boolean }} args
@@ -186,7 +175,7 @@ export function failedTerminalFor(err, args = {}) {
186
175
  phase,
187
176
  gates: gatesForFailedPhase(phase, {
188
177
  ...args,
189
- failedGate: err?.closeGate ?? null,
178
+ observedGates: err?.closeGates ?? null,
190
179
  }),
191
180
  failure: { reason: String(err?.message ?? err) },
192
181
  nextCommand: NEXT_COMMANDS.recover(storyId),
@@ -66,7 +66,7 @@ import { resolveAutoMergeArmCwd } from '../../auto-merge-cwd.js';
66
66
  import {
67
67
  advisoryCheckFailedBlocksArm,
68
68
  deriveRedHeadRuns,
69
- formatAdvisoryGateReason,
69
+ resolveAdvisoryGateVerdict,
70
70
  selectBlockingRedRuns,
71
71
  } from '../../merge-poll.js';
72
72
 
@@ -430,10 +430,17 @@ async function evaluateAdvisoryGate({
430
430
  probe.redHeadRuns,
431
431
  advisoryAllowlist,
432
432
  );
433
+ // Story #5266 — the class travels with the reason. This pre-arm gate
434
+ // classifies on the text the ROLLUP carried (a legacy StatusContext's
435
+ // `description`); the merge wait, which owns the common case, additionally
436
+ // reads the check-run output. Either way a run whose failure cannot be read
437
+ // as "never finished" keeps the `advisory-gate-red` verdict.
438
+ const verdict = resolveAdvisoryGateVerdict({ blockingRuns });
433
439
  return {
434
440
  blocked: true,
435
441
  blockingRuns,
436
- reason: formatAdvisoryGateReason(blockingRuns),
442
+ blockClass: verdict.blockClass,
443
+ reason: verdict.reason,
437
444
  };
438
445
  }
439
446
 
@@ -522,6 +529,7 @@ export async function runAutoMergePhase({
522
529
  autoMergeReason: 'advisory-gate-red',
523
530
  advisoryGate: {
524
531
  blockingRuns: advisory.blockingRuns,
532
+ blockClass: advisory.blockClass,
525
533
  reason: advisory.reason,
526
534
  },
527
535
  };
@@ -13,8 +13,18 @@
13
13
  * On a merge conflict the Story is transitioned to `agent::blocked` via
14
14
  * `handleSyncFailure` and the caller throws — the operator resolves in
15
15
  * the worktree and re-runs.
16
+ *
17
+ * Story #5267: a sync that lands tracked content also spends the worker's
18
+ * pre-push credit, because that credit is keyed on the tree. The phase says
19
+ * so out loud — see `buildStampInvalidatedWarning` — instead of leaving
20
+ * close's second full suite looking like a bug. Story #5278 splits the claim
21
+ * in two: gate evidence is spent by any tracked path, the capture stamp only
22
+ * by one under `crap.targetDirs`.
16
23
  */
17
24
 
25
+ import { getQuality } from '../../../config/quality.js';
26
+ import { resolveConfig } from '../../../config-resolver.js';
27
+ import { filterFilesUnderTargets } from '../../../coverage-capture.js';
18
28
  import { syncBranchFromBase } from '../../../git/sync-from-base.js';
19
29
  import { Logger } from '../../../Logger.js';
20
30
  import { AGENT_LABELS } from '../../../label-constants.js';
@@ -40,6 +50,7 @@ import {
40
50
  * storyId: number,
41
51
  * provider: object,
42
52
  * injectedSync?: typeof syncBranchFromBase,
53
+ * resolveConfigImpl?: typeof resolveConfig,
43
54
  * progress: (tag: string, msg: string) => void,
44
55
  * }} args
45
56
  */
@@ -52,6 +63,7 @@ export async function runBaseSyncPhase({
52
63
  storyId,
53
64
  provider,
54
65
  injectedSync,
66
+ resolveConfigImpl = resolveConfig,
55
67
  progress,
56
68
  }) {
57
69
  const syncCwd = worktreePath ?? cwd;
@@ -87,6 +99,95 @@ export async function runBaseSyncPhase({
87
99
  );
88
100
  }
89
101
  progress('SYNC', `✅ Synced from origin/${baseBranch} (${syncResult.kind}).`);
102
+ for (const line of buildStampInvalidatedWarning({
103
+ baseBranch,
104
+ result: syncResult,
105
+ targetDirs: resolveCrapTargetDirs(resolveConfigImpl, syncCwd),
106
+ })) {
107
+ progress('SYNC', line);
108
+ }
109
+ }
110
+
111
+ /**
112
+ * The CRAP scoring scope, or `[]` when it cannot be resolved. A `[]` makes
113
+ * the warning below fall back to naming the capture stamp unconditionally —
114
+ * the pre-#5278 wording — because an unresolvable scope is no evidence that
115
+ * the stamp survived.
116
+ *
117
+ * @param {typeof resolveConfig} resolveConfigImpl
118
+ * @param {string} cwd
119
+ * @returns {string[]}
120
+ */
121
+ function resolveCrapTargetDirs(resolveConfigImpl, cwd) {
122
+ try {
123
+ return getQuality(resolveConfigImpl({ cwd }))?.crap?.targetDirs ?? [];
124
+ } catch {
125
+ return [];
126
+ }
127
+ }
128
+
129
+ /**
130
+ * How many changed paths the warning names before it stops listing.
131
+ * Enough to recognise the change set; short enough that the warning still
132
+ * reads as a warning rather than as a diff.
133
+ */
134
+ const WARNED_PATH_LIMIT = 12;
135
+
136
+ /**
137
+ * The loud "your credit is spent" warning, or `[]` when the sync changed
138
+ * nothing (Story #5267, narrowed by #5278).
139
+ *
140
+ * The worker banks two kinds of credit before the push: gate evidence keyed
141
+ * on the tree (lint, typecheck) and one full-suite capture stamp keyed on the
142
+ * content of `crap.targetDirs`. This sync runs after that, and the two are
143
+ * spent on different conditions — which is why #5267's single blanket
144
+ * sentence was wrong half the time:
145
+ *
146
+ * - **Gate evidence** is spent by any tracked path at all, because the tree
147
+ * hash it is keyed on moves with the first byte.
148
+ * - **The capture stamp** is spent only when a merged path lands under
149
+ * `crap.targetDirs`. A sync that brings in docs, workflows or CI config
150
+ * leaves it perfectly valid, and announcing it as spent taught operators
151
+ * to expect a second full suite that close was never going to run.
152
+ *
153
+ * Quiet by construction on the outcome that cannot spend either: a
154
+ * `noop-already-current` sync never touched the tree. A content-changing
155
+ * fast-forward DOES warn — it moves the tree exactly as a merge commit does,
156
+ * and staying quiet there would be a lie of omission.
157
+ *
158
+ * Pure. Module-private: the phase is the seam tests drive it through
159
+ * (`injectedSync` + a `progress` spy), so it needs no export of its own.
160
+ *
161
+ * @param {{ baseBranch: string, result: { kind?: string, changedPaths?: string[] }, targetDirs?: string[] }} args
162
+ * @returns {string[]} Progress lines, in order. Empty when nothing changed.
163
+ */
164
+ function buildStampInvalidatedWarning({ baseBranch, result, targetDirs }) {
165
+ const changed = Array.isArray(result?.changedPaths)
166
+ ? result.changedPaths
167
+ : [];
168
+ if (changed.length === 0) return [];
169
+ const dirs = Array.isArray(targetDirs) ? targetDirs : [];
170
+ // An unresolvable scope (`[]`) cannot prove the stamp survived, so it fails
171
+ // closed to the unconditional wording.
172
+ const scored =
173
+ dirs.length === 0 ? changed : filterFilesUnderTargets(changed, dirs);
174
+ const shown = changed.slice(0, WARNED_PATH_LIMIT);
175
+ const overflow = changed.length - shown.length;
176
+ return [
177
+ `⚠️ BASE MOVED: the ${result?.kind ?? 'sync'} from origin/${baseBranch} ` +
178
+ `brought ${changed.length} tracked path(s) into this branch, so the tree ` +
179
+ `hash the pre-push lint/typecheck evidence was keyed on has changed. ` +
180
+ `That evidence cannot be credited; those gates re-run below.`,
181
+ scored.length > 0
182
+ ? `⚠️ The full-suite capture stamp is spent too: ${scored.length} of ` +
183
+ `those path(s) fall under the CRAP target dirs [${dirs.join(', ')}], ` +
184
+ `so the suite re-runs against the merged tree. This is expected, not a fault.`
185
+ : `⚠️ The full-suite capture stamp SURVIVES: no merged path falls under ` +
186
+ `the CRAP target dirs [${dirs.join(', ')}], so the coverage artifact still ` +
187
+ `describes this tree and the suite is not re-run.`,
188
+ ...shown.map((f) => `⚠️ ${f}`),
189
+ ...(overflow > 0 ? [`⚠️ …and ${overflow} more`] : []),
190
+ ];
90
191
  }
91
192
 
92
193
  /**