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
@@ -12,18 +12,38 @@
12
12
  * merge and reduces (does not eliminate) the residual race-with-merge
13
13
  * window. The merge queue is the proper fix for the residual race.
14
14
  *
15
- * Why merge (not rebase): Story branches are pushed and reviewed across
16
- * iterations of the watch + fix loop; a rebase would force-push and risk
17
- * losing in-flight reviewer context. The merge commit is squashed away
18
- * when the PR lands, so the cosmetic cost is zero.
15
+ * Why merge, not rebase (Story #5267 — settled, do not re-open):
16
+ *
17
+ * 1. Mandrel states no rebase rule anywhere. `git-conventions.md`
18
+ * mandates branch shapes and commit subjects and is silent on how a
19
+ * branch takes on base commits, so there is no convention to honour
20
+ * here — only a trade-off to pick.
21
+ * 2. Story branches are pushed and reviewed across iterations of the
22
+ * watch + fix loop. A rebase force-pushes, discarding in-flight
23
+ * reviewer context and any review state pinned to the old SHAs.
24
+ * 3. A rebase would NOT save the pre-push capture stamp people reach
25
+ * for it to save. The stamp is keyed on the tree, and rebasing onto
26
+ * a moved base changes the tree exactly as merging it does — both
27
+ * invalidate the stamp, so the credit argument is a wash. The real
28
+ * remedy is the `changedPaths` reporting below: say out loud when
29
+ * the sync spent the stamp, rather than change how it is spent.
30
+ *
31
+ * The merge commit is squashed away when the PR lands, so the cosmetic
32
+ * cost is zero.
19
33
  *
20
34
  * Outcomes (`{ synced, kind, ... }`):
21
35
  *
22
- * - `{ synced: true, kind: 'noop-already-current' }` — `origin/<base>`
23
- * is already an ancestor of HEAD; nothing to do.
24
- * - `{ synced: true, kind: 'fast-forward' }` — merge fast-forwarded.
25
- * - `{ synced: true, kind: 'merge-commit' }` — non-trivial merge
26
- * succeeded; a merge commit landed on the active branch.
36
+ * - `{ synced: true, kind: 'noop-already-current', changedPaths: [] }`
37
+ * — `origin/<base>` is already an ancestor of HEAD; nothing to do.
38
+ * - `{ synced: true, kind: 'fast-forward', changedPaths }` — merge
39
+ * fast-forwarded.
40
+ * - `{ synced: true, kind: 'merge-commit', changedPaths }` — non-trivial
41
+ * merge succeeded; a merge commit landed on the active branch.
42
+ *
43
+ * `changedPaths` is the tracked paths the sync brought into the branch —
44
+ * `git diff --name-only <pre-merge HEAD> HEAD` — and is what lets a caller
45
+ * tell a sync that spent a pre-push capture stamp from one that did not.
46
+ * It is `[]` for every non-mutating and every failing outcome.
27
47
  * - `{ synced: false, kind: 'fetch-failed', stderr }` — `git fetch`
28
48
  * could not retrieve `origin/<base>`. No mutation occurred.
29
49
  * - `{ synced: false, kind: 'conflict', conflictFiles }` — merge
@@ -32,17 +52,102 @@
32
52
  * - `{ synced: false, kind: 'merge-failed', stderr }` — merge exited
33
53
  * non-zero for a reason other than a parseable conflict (rare; treat
34
54
  * as a hard blocker on the caller's side).
55
+ * - `{ synced: false, kind: 'merge-driver-missing', stderr }` — the
56
+ * worktree's `.gitattributes` routes `baselines/*.json` through the
57
+ * `mandrel-baseline` merge driver and this clone has no
58
+ * `merge.mandrel-baseline.driver` config, so git would text-merge
59
+ * generated baselines. Nothing was fetched or merged (Story #5277).
35
60
  *
36
61
  * The helper does not mutate any ticket state, post comments, or write
37
62
  * to anything other than the worktree's git refs / index. Callers own
38
63
  * the recovery surface.
39
64
  */
40
65
 
66
+ import {
67
+ BASELINE_MERGE_DRIVER_CONFIG_KEY,
68
+ BASELINE_MERGE_DRIVER_REMEDY,
69
+ probeBaselineMergeDriver,
70
+ } from '../bootstrap/baseline-merge-driver.js';
41
71
  import {
42
72
  gitFetchWithRetry as defaultGitFetchWithRetry,
43
73
  gitSpawn as defaultGitSpawn,
44
74
  } from '../git-utils.js';
45
75
 
76
+ /**
77
+ * Refuse the sync when the baseline merge driver is declared but unregistered
78
+ * (Story #5277). `probeBaselineMergeDriver` owns the two-part question and why
79
+ * each half matters; this is where the answer becomes a refusal.
80
+ *
81
+ * Base-sync is the one place the failure is catchable before it happens: it is
82
+ * the merge that runs unattended, immediately before the push, on exactly the
83
+ * files a concurrent sibling Story most often also refreshed. Refusing costs
84
+ * one operator command; proceeding costs a silently wrong baseline on `main`
85
+ * that nothing downstream re-derives.
86
+ *
87
+ * @param {string} cwd
88
+ * @param {typeof defaultGitSpawn} gitSpawn
89
+ * @returns {{ synced: false, kind: 'merge-driver-missing', stderr: string, remedy: string }|null}
90
+ */
91
+ function refuseWithoutMergeDriver(cwd, gitSpawn) {
92
+ const { declared, command } = probeBaselineMergeDriver({
93
+ projectRoot: cwd,
94
+ runGit: (args) => gitSpawn(cwd, ...args),
95
+ });
96
+ if (!declared || command.length > 0) return null;
97
+ return {
98
+ synced: false,
99
+ kind: 'merge-driver-missing',
100
+ stderr:
101
+ `.gitattributes routes baselines/*.json through the mandrel-baseline merge ` +
102
+ `driver, but ${BASELINE_MERGE_DRIVER_CONFIG_KEY} is unset in this clone. ` +
103
+ 'Merging origin now would text-merge generated baselines, which conflicts on ' +
104
+ 'the generatedAt stamp and can splice rows neither branch scored. Register the ' +
105
+ `driver, then re-run:\n ${BASELINE_MERGE_DRIVER_REMEDY}\n` +
106
+ ' (or: npm run baselines:merge-driver)',
107
+ remedy: BASELINE_MERGE_DRIVER_REMEDY,
108
+ };
109
+ }
110
+
111
+ /**
112
+ * Resolve the current HEAD SHA, or `null` when git cannot answer.
113
+ *
114
+ * @param {typeof defaultGitSpawn} gitSpawn
115
+ * @param {string} cwd
116
+ * @returns {string|null}
117
+ */
118
+ function readHead(gitSpawn, cwd) {
119
+ const head = gitSpawn(cwd, 'rev-parse', 'HEAD');
120
+ if (head.status !== 0) return null;
121
+ const sha = (head.stdout ?? '').toString().trim();
122
+ return sha.length > 0 ? sha : null;
123
+ }
124
+
125
+ /**
126
+ * The tracked paths that differ between `fromSha` and the current HEAD.
127
+ *
128
+ * Returns `[]` when the pre-merge SHA could not be read or the diff itself
129
+ * failed. That is deliberately the quiet answer: the only consumer is the
130
+ * caller's "your capture stamp may be dead" warning, and manufacturing that
131
+ * warning out of a failed probe would cry wolf on every sync in a repo where
132
+ * `rev-parse` is broken — a condition the merge one line earlier would
133
+ * already have failed on.
134
+ *
135
+ * @param {typeof defaultGitSpawn} gitSpawn
136
+ * @param {string} cwd
137
+ * @param {string|null} fromSha
138
+ * @returns {string[]}
139
+ */
140
+ function diffPaths(gitSpawn, cwd, fromSha) {
141
+ if (!fromSha) return [];
142
+ const diff = gitSpawn(cwd, 'diff', '--name-only', fromSha, 'HEAD');
143
+ if (diff.status !== 0) return [];
144
+ return (diff.stdout ?? '')
145
+ .toString()
146
+ .split(/\r?\n/)
147
+ .map((s) => s.trim())
148
+ .filter((s) => s.length > 0);
149
+ }
150
+
46
151
  /**
47
152
  * Sync the active branch in `cwd` against `origin/<baseBranch>`. See
48
153
  * the module docstring for the full outcome envelope.
@@ -64,9 +169,9 @@ import {
64
169
  * tests.
65
170
  *
66
171
  * @returns {Promise<
67
- * | { synced: true, kind: 'noop-already-current' }
68
- * | { synced: true, kind: 'fast-forward' }
69
- * | { synced: true, kind: 'merge-commit' }
172
+ * | { synced: true, kind: 'noop-already-current', changedPaths: string[] }
173
+ * | { synced: true, kind: 'fast-forward', changedPaths: string[] }
174
+ * | { synced: true, kind: 'merge-commit', changedPaths: string[] }
70
175
  * | { synced: false, kind: 'fetch-failed', stderr: string }
71
176
  * | { synced: false, kind: 'conflict', conflictFiles: string[] }
72
177
  * | { synced: false, kind: 'merge-failed', stderr: string }
@@ -88,6 +193,12 @@ export async function syncBranchFromBase({
88
193
  );
89
194
  }
90
195
 
196
+ const driverGap = refuseWithoutMergeDriver(cwd, gitSpawn);
197
+ if (driverGap) {
198
+ log('SYNC', driverGap.stderr);
199
+ return driverGap;
200
+ }
201
+
91
202
  log('SYNC', `Fetching origin/${baseBranch}...`);
92
203
  const fetch = await gitFetchWithRetry(cwd, 'origin', baseBranch);
93
204
  if (fetch.status !== 0) {
@@ -111,9 +222,14 @@ export async function syncBranchFromBase({
111
222
  );
112
223
  if (originAlreadyMerged.status === 0) {
113
224
  log('SYNC', `origin/${baseBranch} already merged into HEAD — no-op.`);
114
- return { synced: true, kind: 'noop-already-current' };
225
+ return { synced: true, kind: 'noop-already-current', changedPaths: [] };
115
226
  }
116
227
 
228
+ // Pin the pre-merge HEAD so a successful sync can name what it brought
229
+ // in. Read BEFORE the merge, because afterwards the only handle on the
230
+ // old tree is this SHA.
231
+ const preMergeHead = readHead(gitSpawn, cwd);
232
+
117
233
  const headBehindOrigin = gitSpawn(
118
234
  cwd,
119
235
  'merge-base',
@@ -134,6 +250,7 @@ export async function syncBranchFromBase({
134
250
  return {
135
251
  synced: true,
136
252
  kind: willFastForward ? 'fast-forward' : 'merge-commit',
253
+ changedPaths: diffPaths(gitSpawn, cwd, preMergeHead),
137
254
  };
138
255
  }
139
256
 
@@ -101,6 +101,7 @@ const FRAMEWORK_SCRIPT_BASENAMES = Object.freeze([
101
101
  'check-lifecycle-lint.js',
102
102
  'check-pinned-override-notes.js',
103
103
  'check-schema-references.js',
104
+ 'check-test-portability.js',
104
105
  'check-test-temp-hygiene.js',
105
106
  'check-windows-git-perf.js',
106
107
  'check-workflow-citations.js',
@@ -82,8 +82,16 @@ function readBaseBaselinePayload(scope, kind, gateBlock, cwd) {
82
82
  if (raw === null) return null;
83
83
  try {
84
84
  return JSON.parse(raw);
85
- } catch {
86
- return null;
85
+ } catch (cause) {
86
+ // Story #5277 — the same distinction one line above, applied to the
87
+ // second way a base read fails. `null` here means "no baseline at the base
88
+ // ref", which empties the head-vs-base arm on purpose; an UNPARSEABLE base
89
+ // blob is a read that failed, and reporting it as "no base" made a
90
+ // corrupted or half-merged `baselines/*.json` on the base branch report
91
+ // zero regressions at exit 0. Text-merged baselines are exactly how such a
92
+ // blob gets onto the base branch, which is the failure this Story removes
93
+ // upstream — this arm is what stops it being silent when it happens anyway.
94
+ throw buildBaseReadError({ kind, ref: scope.ref, file: rel, cause });
87
95
  }
88
96
  }
89
97
 
@@ -96,23 +96,69 @@ function resolveRefreshTrigger({ kind, gateBlock, cmp, cwd, env }) {
96
96
  }
97
97
 
98
98
  /**
99
- * Read the baseline rows as of one refresh commit. Returns `null` — never
100
- * throws and never a partial row set — when the blob is absent, unreadable or
101
- * unparseable at that SHA. The caller treats `null` as "this commit
102
- * acknowledges nothing", which keeps the ratchet at full strength rather than
103
- * acknowledging on a guess.
99
+ * Read the baseline rows as of one git ref.
100
+ *
101
+ * Returns `null` — never throws and never a partial row set — when the blob is
102
+ * unreadable or unparseable at that ref. The caller treats `null` as "this
103
+ * commit acknowledges nothing", which keeps the ratchet at full strength rather
104
+ * than acknowledging on a guess.
105
+ *
106
+ * A blob that is genuinely ABSENT at the ref reads as `{ rows: [] }`, not
107
+ * `null`. The distinction only matters for the `sha^` read below: the commit
108
+ * that CREATES a baseline has no blob at its parent, and treating that as
109
+ * unreadable would make the first refresh of any kind acknowledge nothing.
110
+ * `readBaseFromGit` already draws exactly this line — `null` for git's exit
111
+ * 128 "path does not exist in this revision", a throw for everything else — so
112
+ * the two cases are distinguishable rather than guessed at.
113
+ *
114
+ * @returns {{ rows: Array<object> }|null}
104
115
  */
105
- function readRowsAtCommit(sha, baselinePath, cwd) {
116
+ function readRowsAtRef(ref, baselinePath, cwd) {
106
117
  let raw;
107
118
  try {
108
- raw = readBaseFromGit(sha, baselinePath, { cwd });
119
+ raw = readBaseFromGit(ref, baselinePath, { cwd });
109
120
  } catch {
110
121
  return null;
111
122
  }
112
- if (raw === null) return null;
123
+ if (raw === null) return { rows: [] };
113
124
  try {
114
125
  const payload = JSON.parse(raw);
115
- return Array.isArray(payload?.rows) ? payload.rows : null;
126
+ return Array.isArray(payload?.rows) ? { rows: payload.rows } : null;
127
+ } catch {
128
+ return null;
129
+ }
130
+ }
131
+
132
+ /**
133
+ * Which row identities did the tagged commit itself REWRITE (Story #5277)?
134
+ *
135
+ * This is the half Story #5179 left open. It scoped the acknowledgment to the
136
+ * rows present in the baseline blob *at* the tagged commit — but a blob is a
137
+ * whole-file snapshot, so it also contains every row an EARLIER, untagged
138
+ * commit on the same branch lowered. Such a row is present at the refresh
139
+ * commit and unchanged at head, so it classified as `ok` and was acknowledged
140
+ * by a commit that never touched it. The observed shape: a branch lowers a
141
+ * maintainability row while refactoring, later refreshes an unrelated CRAP row
142
+ * with a `baseline-refresh:` commit, and the first regression is waved through.
143
+ *
144
+ * Diffing `sha^ → sha` is what makes the acknowledgment a statement about the
145
+ * COMMIT rather than about the branch's accumulated state. No tolerance is
146
+ * applied: any movement at all means the commit re-scored that identity, which
147
+ * is the only question being asked here.
148
+ *
149
+ * @returns {Set<string>|null} null when the diff is unusable, which
150
+ * acknowledges nothing from this commit.
151
+ */
152
+ function keysTouchedByCommit({ mod, rowsAtSha, rowsAtParent }) {
153
+ try {
154
+ const result = mod.compare({ rows: rowsAtSha }, { rows: rowsAtParent });
155
+ return new Set(
156
+ [
157
+ ...(result?.regressions ?? []),
158
+ ...(result?.improvements ?? []),
159
+ ...(result?.additions ?? []),
160
+ ].map((entry) => entry.key),
161
+ );
116
162
  } catch {
117
163
  return null;
118
164
  }
@@ -179,6 +225,11 @@ function classifyAgainstRefresh({ mod, headRows, refreshRows, tolerance }) {
179
225
  * lands in `drifted`. Drift from commits landing AFTER the refresh is what
180
226
  * "the baseline commit must be the branch's last score-moving commit" asks
181
227
  * for by convention and nothing enforced.
228
+ * 3. **The commit actually rewrote it** (Story #5277) — `keysTouchedByCommit`
229
+ * diffs `sha^ → sha`. Tests 1 and 2 both read the blob AT the commit,
230
+ * which is a whole-file snapshot and therefore also carries rows an
231
+ * earlier untagged commit lowered; those rows passed both tests without
232
+ * the tagged commit having touched them.
182
233
  *
183
234
  * Fails closed at every step: an unreadable blob, a missing `compare`, or a
184
235
  * classifier that throws contributes nothing, so those regressions stand.
@@ -212,17 +263,24 @@ function acknowledgeableKeys({
212
263
  // state the branch is asking to be held to.
213
264
  const decided = new Set();
214
265
  for (const { sha } of refreshCommits) {
215
- const refreshRows = readRowsAtCommit(sha, baselinePath, cwd);
216
- if (refreshRows === null) continue;
266
+ const atSha = readRowsAtRef(sha, baselinePath, cwd);
267
+ const atParent = readRowsAtRef(`${sha}^`, baselinePath, cwd);
268
+ if (atSha === null || atParent === null) continue;
269
+ const touched = keysTouchedByCommit({
270
+ mod,
271
+ rowsAtSha: atSha.rows,
272
+ rowsAtParent: atParent.rows,
273
+ });
274
+ if (touched === null) continue;
217
275
  const verdict = classifyAgainstRefresh({
218
276
  mod,
219
277
  headRows,
220
- refreshRows,
278
+ refreshRows: atSha.rows,
221
279
  tolerance,
222
280
  });
223
281
  if (verdict === null) continue;
224
282
  for (const key of verdict.ok) {
225
- if (decided.has(key)) continue;
283
+ if (decided.has(key) || !touched.has(key)) continue;
226
284
  decided.add(key);
227
285
  acknowledgeable.add(key);
228
286
  }
@@ -264,10 +322,12 @@ function logAcknowledgment({ kind, reasons, acknowledged, kept }) {
264
322
  *
265
323
  * The two trigger arms are scoped differently, deliberately:
266
324
  *
267
- * - **Commit tag** — scoped to the rows the tagged commits actually refreshed,
325
+ * - **Commit tag** — scoped to the rows the tagged commits actually rewrote,
268
326
  * per `acknowledgeableKeys`. Before Story #5179 this cleared every regression
269
327
  * in the range, so a branch merged carrying a stale row and the ratchet ran
270
- * loose on that file; the failure recurred six times.
328
+ * loose on that file; the failure recurred six times. Story #5277 closed the
329
+ * remainder: the scoping read the blob at the tagged commit, which still
330
+ * carried rows an earlier untagged commit had lowered.
271
331
  * - **Env parity** (`<KIND>_REFRESH=1`) — stays whole-run. There is no commit to
272
332
  * anchor row-scoping to, and setting the variable is an explicit, deliberate
273
333
  * operator act rather than a signal inferred from history.
@@ -13,8 +13,12 @@
13
13
  *
14
14
  * The strand shapes the table resolves, and why each is real:
15
15
  *
16
- * - `executing` with no PR → resume implementation. The work never reached
17
- * close.
16
+ * - `executing`, branch UNPUSHED, no PR → resume implementation. The work
17
+ * never reached close. Since Story #5267 the worker pushes before its
18
+ * creditable capture, so an unpushed branch means this and nothing else.
19
+ * - `executing`, branch PUSHED, no PR → run close. The worker's hand-off
20
+ * landed; only the close-and-land tail is owed, and re-initializing here
21
+ * would re-open finished work.
18
22
  * - `closing` with a pending PR → resume the land. The overwhelmingly
19
23
  * common shape now that the merge wait is bounded: the wait returned
20
24
  * `pending` and something has to pick it back up.
@@ -315,6 +319,80 @@ function closeInFlightVerdict({ storyId, artifacts, evidence }) {
315
319
  };
316
320
  }
317
321
 
322
+ /**
323
+ * The `agent::executing` rows of the table (Story #4543; split on push state
324
+ * by Story #5267).
325
+ *
326
+ * Lifted out of {@link decideRecovery} because this label alone fans out into
327
+ * five distinct strands, and because the push-state split below only reads
328
+ * correctly next to the artifact probes it is ordered after.
329
+ *
330
+ * @param {{ storyId: number, branch: object, pr: object|null, closeArtifacts?: object, evidence: string[] }} args
331
+ * @returns {{ shape: string, nextCommand: string|null, detail: string, evidence: string[] }}
332
+ */
333
+ function decideExecuting({ storyId, branch, pr, closeArtifacts, evidence }) {
334
+ if (pr?.number) {
335
+ return {
336
+ shape: 'executing-with-pr',
337
+ nextCommand: NEXT_COMMANDS.close(storyId),
338
+ detail:
339
+ `PR #${pr.number} exists but the Story is still \`agent::executing\` — the close ` +
340
+ `opened the PR and then died before the label flip. Re-run close; it reuses the ` +
341
+ `open PR rather than opening a duplicate.`,
342
+ evidence,
343
+ };
344
+ }
345
+ // Story #4816 — the close artifacts get the first word here, and ONLY
346
+ // here. Every other row of this table describes a state whose evidence is
347
+ // already unambiguous; `executing` + no PR is the one row that reads
348
+ // identically for a dead implementation and for a close that is halfway
349
+ // through its gate chain, and answering it from labels alone is what sent
350
+ // operators to re-init on top of a live close.
351
+ if (closeLooksLive(closeArtifacts)) {
352
+ return closeInFlightVerdict({
353
+ storyId,
354
+ artifacts: closeArtifacts,
355
+ evidence,
356
+ });
357
+ }
358
+ if (closeArtifacts?.envelope) {
359
+ return envelopeOnDiskVerdict({
360
+ storyId,
361
+ artifacts: closeArtifacts,
362
+ evidence,
363
+ });
364
+ }
365
+ // Story #5267 — push state is what separates the two remaining strands, and
366
+ // it separates them cleanly now that the worker pushes BEFORE its creditable
367
+ // capture. Before that ordering, a worker whose turn ended on the
368
+ // backgrounded capture left an unpushed branch that was indistinguishable
369
+ // from work that never got started; now an unpushed branch means exactly one
370
+ // thing, and a pushed one means the hand-off happened and only close is
371
+ // owed.
372
+ if (branch?.remote) {
373
+ return {
374
+ shape: 'executing-pushed-no-pr',
375
+ nextCommand: NEXT_COMMANDS.close(storyId),
376
+ detail:
377
+ `\`story-${storyId}\` is PUSHED to origin but no PR exists and no close left an ` +
378
+ `artifact behind — the worker finished and handed off, and the close never ran (or ` +
379
+ `died before its first gate). Nothing needs re-implementing: run close, which is ` +
380
+ `idempotent. Do NOT re-init — the branch already carries the finished work.`,
381
+ evidence,
382
+ };
383
+ }
384
+ return {
385
+ shape: 'executing-no-pr',
386
+ nextCommand: NEXT_COMMANDS.implement(storyId),
387
+ detail:
388
+ `Story is \`agent::executing\`, \`story-${storyId}\` is UNPUSHED, there is no PR, and ` +
389
+ `no close left an artifact behind (no persisted terminal envelope, no recent gate ` +
390
+ `log) — implementation never finished. Re-init (idempotent — it reuses the existing ` +
391
+ `branch and worktree) and resume in the worktree it prints.`,
392
+ evidence,
393
+ };
394
+ }
395
+
318
396
  /**
319
397
  * The decision table. Pure: every input is an already-observed probe, so the
320
398
  * mapping is testable without git, GitHub, or a clock.
@@ -434,47 +512,7 @@ export function decideRecovery({
434
512
  }
435
513
 
436
514
  if (label === STATE_LABELS.EXECUTING) {
437
- if (pr?.number) {
438
- return {
439
- shape: 'executing-with-pr',
440
- nextCommand: NEXT_COMMANDS.close(storyId),
441
- detail:
442
- `PR #${pr.number} exists but the Story is still \`agent::executing\` — the close ` +
443
- `opened the PR and then died before the label flip. Re-run close; it reuses the ` +
444
- `open PR rather than opening a duplicate.`,
445
- evidence,
446
- };
447
- }
448
- // Story #4816 — the close artifacts get the first word here, and ONLY
449
- // here. Every other row of this table describes a state whose evidence is
450
- // already unambiguous; `executing` + no PR is the one row that reads
451
- // identically for a dead implementation and for a close that is halfway
452
- // through its gate chain, and answering it from labels alone is what sent
453
- // operators to re-init on top of a live close.
454
- if (closeLooksLive(closeArtifacts)) {
455
- return closeInFlightVerdict({
456
- storyId,
457
- artifacts: closeArtifacts,
458
- evidence,
459
- });
460
- }
461
- if (closeArtifacts?.envelope) {
462
- return envelopeOnDiskVerdict({
463
- storyId,
464
- artifacts: closeArtifacts,
465
- evidence,
466
- });
467
- }
468
- return {
469
- shape: 'executing-no-pr',
470
- nextCommand: NEXT_COMMANDS.implement(storyId),
471
- detail:
472
- `Story is \`agent::executing\` with no PR, and no close left an artifact behind (no ` +
473
- `persisted terminal envelope, no recent gate log) — implementation never finished. ` +
474
- `Re-init (idempotent — it reuses the existing branch and worktree) and resume in the ` +
475
- `worktree it prints.`,
476
- evidence,
477
- };
515
+ return decideExecuting({ storyId, branch, pr, closeArtifacts, evidence });
478
516
  }
479
517
 
480
518
  return {
@@ -500,6 +538,7 @@ export function decideRecovery({
500
538
  */
501
539
  const TRANSIENT_SHAPES = new Set([
502
540
  'executing-no-pr',
541
+ 'executing-pushed-no-pr',
503
542
  'executing-with-pr',
504
543
  'closing-no-pr',
505
544
  'closing-pr-pending',
@@ -92,7 +92,7 @@ export async function findDependencyCandidates({
92
92
  (p) => typeof p === 'string' && p.trim() !== '',
93
93
  );
94
94
  if (wanted.length === 0) return [];
95
- if (typeof provider?.listIssuesByLabel !== 'function') return [];
95
+ if (typeof provider?.listTicketsByLabel !== 'function') return [];
96
96
 
97
97
  const excluded = new Set(
98
98
  [...excludeIds].map((id) => Number(id)).filter((n) => Number.isFinite(n)),
@@ -100,7 +100,7 @@ export async function findDependencyCandidates({
100
100
 
101
101
  let issues;
102
102
  try {
103
- issues = await provider.listIssuesByLabel({
103
+ issues = await provider.listTicketsByLabel({
104
104
  state: 'open',
105
105
  labels: TYPE_LABELS.STORY,
106
106
  });
@@ -113,7 +113,11 @@ export async function findDependencyCandidates({
113
113
 
114
114
  const out = [];
115
115
  for (const issue of Array.isArray(issues) ? issues : []) {
116
- const id = Number(issue?.number ?? issue?.id);
116
+ // The declared ticket shape: `id` is the issue number. Reading it through
117
+ // the old `number`-then-`id` fallback was the bug in waiting — on this
118
+ // shape the fallback never fires, and on a raw REST payload it silently
119
+ // produced database ids for every candidate the planner offered.
120
+ const id = Number(issue?.id);
117
121
  if (!Number.isInteger(id) || id <= 0 || excluded.has(id)) continue;
118
122
 
119
123
  const footprint = footprintOf(issue);
@@ -125,7 +129,7 @@ export async function findDependencyCandidates({
125
129
  out.push({
126
130
  id,
127
131
  title: typeof issue?.title === 'string' ? issue.title : '',
128
- url: issue?.html_url ?? issue?.url ?? buildStoryUrl(id, { owner, repo }),
132
+ url: issue?.url ?? buildStoryUrl(id, { owner, repo }),
129
133
  state: typeof issue?.state === 'string' ? issue.state : 'open',
130
134
  overlappingPaths,
131
135
  });
@@ -81,7 +81,12 @@ async function readChildTitles({ childIds, provider }) {
81
81
  * @returns {Promise<{ id: number, title: string, url: string, score: number, childIds: number[] }|null>}
82
82
  */
83
83
  async function scoreEpic({ epic, seedTokens, provider, owner, repo }) {
84
- const id = Number(epic?.number ?? epic?.id);
84
+ // `listTicketsByLabel` hands back the declared ticket shape, in which `id`
85
+ // is the issue number. The `number`-then-`id` fallback this replaced read as
86
+ // defensive and was not: on that shape it never fired, and on a raw REST
87
+ // payload it was the only thing standing between this and scoring an Epic
88
+ // under its database id.
89
+ const id = Number(epic?.id);
85
90
  if (!Number.isInteger(id) || id <= 0) return null;
86
91
 
87
92
  const title = typeof epic?.title === 'string' ? epic.title : '';
@@ -98,7 +103,7 @@ async function scoreEpic({ epic, seedTokens, provider, owner, repo }) {
98
103
  return {
99
104
  id,
100
105
  title,
101
- url: epic?.html_url ?? epic?.url ?? buildEpicUrl(id, { owner, repo }),
106
+ url: epic?.url ?? buildEpicUrl(id, { owner, repo }),
102
107
  score: Number(score.toFixed(4)),
103
108
  childIds,
104
109
  };
@@ -126,14 +131,14 @@ async function scoreEpic({ epic, seedTokens, provider, owner, repo }) {
126
131
  */
127
132
  export async function findOpenEpicCandidates({ seed, provider, owner, repo }) {
128
133
  if (typeof seed !== 'string' || seed.trim() === '') return [];
129
- if (typeof provider?.listIssuesByLabel !== 'function') return [];
134
+ if (typeof provider?.listTicketsByLabel !== 'function') return [];
130
135
 
131
136
  const seedTokens = tokenize(seed);
132
137
  if (seedTokens.size === 0) return [];
133
138
 
134
139
  let issues;
135
140
  try {
136
- issues = await provider.listIssuesByLabel({
141
+ issues = await provider.listTicketsByLabel({
137
142
  state: 'open',
138
143
  labels: TYPE_LABELS.EPIC,
139
144
  });