mandrel 2.54.0 → 2.56.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 (134) 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 +8 -2
  5. package/.agents/docs/configuration.md +5 -0
  6. package/.agents/rules/ci-remediation.md +39 -21
  7. package/.agents/schemas/agentrc.schema.json +34 -1
  8. package/.agents/schemas/lifecycle/merge.unlanded.schema.json +2 -1
  9. package/.agents/schemas/story-deliver-terminal.schema.json +1 -0
  10. package/.agents/scripts/audit-to-stories.js +374 -76
  11. package/.agents/scripts/check-audit-attribution.js +119 -62
  12. package/.agents/scripts/check-test-portability.js +512 -0
  13. package/.agents/scripts/coverage-capture.js +17 -10
  14. package/.agents/scripts/evidence-gate.js +31 -4
  15. package/.agents/scripts/file-ci-gap.js +306 -0
  16. package/.agents/scripts/generate-workflows-doc.js +65 -14
  17. package/.agents/scripts/git-cleanup.js +4 -0
  18. package/.agents/scripts/lib/ITicketingProvider.js +78 -0
  19. package/.agents/scripts/lib/audit-advisories.js +195 -0
  20. package/.agents/scripts/lib/audit-attribution.js +22 -0
  21. package/.agents/scripts/lib/audit-to-stories/audit-label-taxonomy.js +25 -1
  22. package/.agents/scripts/lib/audit-to-stories/dedupe-against-github.js +80 -29
  23. package/.agents/scripts/lib/audit-to-stories/finding-adapter.js +5 -1
  24. package/.agents/scripts/lib/audit-to-stories/issue-corpus.js +162 -0
  25. package/.agents/scripts/lib/audit-to-stories/issue-index.js +83 -0
  26. package/.agents/scripts/lib/audit-to-stories/issues-file.js +121 -0
  27. package/.agents/scripts/lib/audit-to-stories/ledger-commit.js +61 -115
  28. package/.agents/scripts/lib/audit-to-stories/ledger-pr.js +347 -0
  29. package/.agents/scripts/lib/audit-to-stories/ledger-record.js +126 -0
  30. package/.agents/scripts/lib/audit-to-stories/parse-audit-md.js +169 -44
  31. package/.agents/scripts/lib/audit-to-stories/seed-from-findings.js +11 -0
  32. package/.agents/scripts/lib/baselines/merge-envelopes.js +298 -32
  33. package/.agents/scripts/lib/bootstrap/baseline-merge-driver.js +180 -14
  34. package/.agents/scripts/lib/cli-args.js +26 -0
  35. package/.agents/scripts/lib/close-validation/gates.js +113 -7
  36. package/.agents/scripts/lib/close-validation/process.js +7 -3
  37. package/.agents/scripts/lib/close-validation/runner.js +62 -11
  38. package/.agents/scripts/lib/config/ci.js +28 -9
  39. package/.agents/scripts/lib/config-settings-schema-delivery.js +7 -0
  40. package/.agents/scripts/lib/config-settings-schema.js +52 -1
  41. package/.agents/scripts/lib/coverage-capture-fullscope.js +23 -11
  42. package/.agents/scripts/lib/coverage-capture-incremental.js +22 -16
  43. package/.agents/scripts/lib/coverage-capture-usage.js +5 -1
  44. package/.agents/scripts/lib/coverage-capture.js +77 -3
  45. package/.agents/scripts/lib/feedback-loop/graduator-core.js +53 -13
  46. package/.agents/scripts/lib/feedback-loop/prior-feedback-fetcher.js +71 -25
  47. package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +18 -25
  48. package/.agents/scripts/lib/{audit-to-stories/ledger.js → findings/audit-ledger.js} +131 -24
  49. package/.agents/scripts/lib/findings/route-finding.js +42 -2
  50. package/.agents/scripts/lib/full-suite-lock.js +232 -6
  51. package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
  52. package/.agents/scripts/lib/git/sync-from-base.js +130 -13
  53. package/.agents/scripts/lib/github/framework-repo.js +148 -2
  54. package/.agents/scripts/lib/label-constants.js +6 -1
  55. package/.agents/scripts/lib/observability/source-classifier.js +2 -0
  56. package/.agents/scripts/lib/orchestration/check-baselines/phases/compare.js +10 -2
  57. package/.agents/scripts/lib/orchestration/check-baselines/phases/refresh-ack.js +75 -15
  58. package/.agents/scripts/lib/orchestration/ci-gap-intake.js +605 -0
  59. package/.agents/scripts/lib/orchestration/ci-rerun-guard.js +13 -8
  60. package/.agents/scripts/lib/orchestration/deliver-recover.js +82 -43
  61. package/.agents/scripts/lib/orchestration/dependency-candidates.js +8 -4
  62. package/.agents/scripts/lib/orchestration/epic-candidates.js +9 -4
  63. package/.agents/scripts/lib/orchestration/epic-container.js +66 -4
  64. package/.agents/scripts/lib/orchestration/epic-rollup.js +233 -84
  65. package/.agents/scripts/lib/orchestration/file-assumptions.js +218 -16
  66. package/.agents/scripts/lib/orchestration/git-cleanup/phases/branches.js +93 -7
  67. package/.agents/scripts/lib/orchestration/git-cleanup/phases/git-probes.js +22 -6
  68. package/.agents/scripts/lib/orchestration/git-cleanup/phases/parse-args.js +26 -5
  69. package/.agents/scripts/lib/orchestration/git-cleanup/phases/phase-drivers.js +13 -2
  70. package/.agents/scripts/lib/orchestration/git-cleanup/phases/render.js +35 -5
  71. package/.agents/scripts/lib/orchestration/merge-block-class.js +18 -3
  72. package/.agents/scripts/lib/orchestration/merge-poll.js +284 -40
  73. package/.agents/scripts/lib/orchestration/plan-persist/audit-provenance.js +197 -0
  74. package/.agents/scripts/lib/orchestration/plan-persist/epic-adoption.js +49 -2
  75. package/.agents/scripts/lib/orchestration/plan-persist/epic-ops.js +43 -7
  76. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +39 -3
  77. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +5 -0
  78. package/.agents/scripts/lib/orchestration/plan-persist/summary.js +3 -0
  79. package/.agents/scripts/lib/orchestration/plan-persist/supersede-ops.js +63 -0
  80. package/.agents/scripts/lib/orchestration/plan-persist/wave-serialisation.js +110 -0
  81. package/.agents/scripts/lib/orchestration/planning/memory-pool-advisory.js +130 -40
  82. package/.agents/scripts/lib/orchestration/resolve-stories.js +44 -1
  83. package/.agents/scripts/lib/orchestration/review-providers/native.js +31 -11
  84. package/.agents/scripts/lib/orchestration/review-providers/scoped-lint.js +27 -24
  85. package/.agents/scripts/lib/orchestration/run-epilogue.js +63 -42
  86. package/.agents/scripts/lib/orchestration/single-story-close/close-note.js +81 -0
  87. package/.agents/scripts/lib/orchestration/single-story-close/failed-terminal.js +40 -51
  88. package/.agents/scripts/lib/orchestration/single-story-close/phases/auto-merge.js +10 -2
  89. package/.agents/scripts/lib/orchestration/single-story-close/phases/base-sync.js +101 -0
  90. package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +351 -28
  91. package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +27 -6
  92. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +117 -22
  93. package/.agents/scripts/lib/orchestration/story-close/baseline-upward-writeback.js +94 -12
  94. package/.agents/scripts/lib/orchestration/story-close/format-autofix.js +6 -1
  95. package/.agents/scripts/lib/orchestration/story-follow-ups.js +32 -20
  96. package/.agents/scripts/lib/orchestration/ticket-validator.js +25 -14
  97. package/.agents/scripts/lib/orchestration/ticketing/bulk.js +30 -0
  98. package/.agents/scripts/lib/orchestration/verify-credit.js +37 -0
  99. package/.agents/scripts/lib/pinned-override-notes.js +41 -53
  100. package/.agents/scripts/lib/pinned-override-resolve.js +212 -0
  101. package/.agents/scripts/lib/qa/resolve-qa-contract.js +18 -0
  102. package/.agents/scripts/lib/single-story-sweep/sweep-lock.js +173 -9
  103. package/.agents/scripts/lib/skills/walk-skill-files.js +24 -7
  104. package/.agents/scripts/lib/test-temp.js +167 -30
  105. package/.agents/scripts/lib/validation-evidence.js +37 -0
  106. package/.agents/scripts/lib/wave-runner/footprint.js +167 -14
  107. package/.agents/scripts/lib/wave-runner/live-probe.js +7 -1
  108. package/.agents/scripts/lib/wave-runner/ready-set.js +1 -1
  109. package/.agents/scripts/merge-baseline.js +175 -21
  110. package/.agents/scripts/pr-watch-with-update.js +3 -2
  111. package/.agents/scripts/providers/github/errors.js +22 -1
  112. package/.agents/scripts/providers/github/issues.js +106 -1
  113. package/.agents/scripts/providers/github/sub-issue-add.js +18 -1
  114. package/.agents/scripts/providers/github.js +6 -0
  115. package/.agents/scripts/resolve-stories.js +44 -34
  116. package/.agents/scripts/single-story-close.js +5 -0
  117. package/.agents/scripts/stories-wave-tick.js +37 -13
  118. package/.agents/templates/docs/audit-sweep-runbook.md +41 -7
  119. package/.agents/workflows/audit-accessibility.md +16 -31
  120. package/.agents/workflows/audit-mobile.md +20 -37
  121. package/.agents/workflows/audit-to-stories.md +63 -27
  122. package/.agents/workflows/git-cleanup.md +17 -3
  123. package/.agents/workflows/helpers/audit-lens-core.md +45 -0
  124. package/.agents/workflows/helpers/deliver-digest.md +7 -6
  125. package/.agents/workflows/helpers/deliver-reference.md +35 -14
  126. package/.agents/workflows/helpers/deliver-story-reference.md +26 -8
  127. package/.agents/workflows/helpers/deliver-story.md +15 -12
  128. package/.agents/workflows/helpers/plan-reference.md +30 -0
  129. package/.agents/workflows/mandrel-plan.md +10 -13
  130. package/.agents/workflows/memory-consolidate.md +14 -9
  131. package/docs/CHANGELOG.md +37 -0
  132. package/lib/cli/registry.js +64 -21
  133. package/lib/cli/sync.js +27 -2
  134. 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
 
@@ -1,6 +1,152 @@
1
1
  /**
2
- * Canonical framework repository slug used by follow-up / graduator
3
- * paths when the consumer config does not supply owner/repo.
2
+ * framework-repo.js — the follow-up **ownership routing** SSOT: which
3
+ * repository a finding, a retro proposal, or a CI-gap intake issue is filed
4
+ * in, and what to say when that question has no answer.
5
+ *
6
+ * ## Three buckets, not two
7
+ *
8
+ * A defect surfaced by one repository's CI is not necessarily that
9
+ * repository's to fix. Ownership splits three ways:
10
+ *
11
+ * - `consumer` — the repo the run is standing in (`github.owner`/`repo`).
12
+ * - `framework` — the Mandrel framework itself
13
+ * (`github.followUpRepos.framework`, defaulted to the mirror constant).
14
+ * - `platform` — a shared platform / infrastructure repo that neither of
15
+ * the other two owns: a shared base config, a runner fleet, a
16
+ * cross-repo toolchain (`github.followUpRepos.platform`, **no default**
17
+ * — nothing can guess a shared repo's slug).
18
+ *
19
+ * ## Why there is no `?? currentRepo` fallback
20
+ *
21
+ * The two-bucket predecessor resolved a framework-tagged item with
22
+ * `frameworkRepo ? frameworkRepo : currentRepo`. When the config key was
23
+ * absent that expression filed framework-owned work into the **consumer's**
24
+ * repo while the rendered retro claimed it went to the framework repo — a
25
+ * silent mis-file, recorded in `retro-proposals-graduator.js`'s own file-top
26
+ * comment. The failure was invisible in this repository precisely because
27
+ * consumer === framework here.
28
+ *
29
+ * So an unresolvable bucket is a first-class outcome, never a fallback:
30
+ * `routeOwnership` returns `routable: false` plus the `missingKey` that
31
+ * would fix it, and every caller must decide **out loud** what to do with
32
+ * that — file locally and say so in the body (the CI-gap filer), or defer
33
+ * and surface it where an operator will see it (the graduators). What no
34
+ * caller may do is route it somewhere plausible and stay quiet.
4
35
  */
5
36
 
37
+ /**
38
+ * Canonical framework repository slug, used when the consumer config does
39
+ * not supply `github.followUpRepos.framework`. The `framework` bucket is the
40
+ * one bucket with a knowable default: it is this framework.
41
+ */
6
42
  export const DEFAULT_FRAMEWORK_REPO = 'dsj1984/mandrel';
43
+
44
+ /** The closed ownership-bucket set. */
45
+ export const OWNERSHIP_BUCKETS = Object.freeze([
46
+ 'consumer',
47
+ 'framework',
48
+ 'platform',
49
+ ]);
50
+
51
+ /**
52
+ * The `.agentrc.json` key behind each bucket, quoted verbatim when a bucket
53
+ * is unroutable so the operator is told which key to set rather than that
54
+ * "routing failed".
55
+ */
56
+ const OWNERSHIP_CONFIG_KEYS = Object.freeze({
57
+ consumer: 'github.owner / github.repo',
58
+ framework: 'github.followUpRepos.framework',
59
+ platform: 'github.followUpRepos.platform',
60
+ });
61
+
62
+ /**
63
+ * Parse an `"<owner>/<repo>"` slug into `{ owner, repo }`, or `null` when the
64
+ * slug is absent, empty, or malformed. A `null` return is the signal an
65
+ * unroutable bucket is built from — never a reason to substitute another
66
+ * repo.
67
+ *
68
+ * @param {string|null|undefined} slug
69
+ * @returns {{ owner: string, repo: string } | null}
70
+ */
71
+ export function parseRepoSlug(slug) {
72
+ if (typeof slug !== 'string') return null;
73
+ const parts = slug.trim().split('/');
74
+ if (parts.length !== 2) return null;
75
+ const [owner, repo] = parts;
76
+ if (!owner || !repo) return null;
77
+ return { owner, repo };
78
+ }
79
+
80
+ /**
81
+ * Render a `{ owner, repo }` pair back to its slug, or `null` when the pair
82
+ * is absent/malformed.
83
+ *
84
+ * @param {{ owner?: string, repo?: string }|null|undefined} repo
85
+ * @returns {string|null}
86
+ */
87
+ export function formatRepoSlug(repo) {
88
+ if (!repo || typeof repo !== 'object') return null;
89
+ const { owner, repo: name } = /** @type {{owner?: string, repo?: string}} */ (
90
+ repo
91
+ );
92
+ if (typeof owner !== 'string' || typeof name !== 'string') return null;
93
+ if (!owner.trim() || !name.trim()) return null;
94
+ return `${owner.trim()}/${name.trim()}`;
95
+ }
96
+
97
+ /**
98
+ * Resolve every ownership bucket from a resolved `.agentrc` config.
99
+ *
100
+ * `framework` falls back to {@link DEFAULT_FRAMEWORK_REPO}; `platform` has no
101
+ * default and stays `null` when unconfigured; `consumer` is `null` when
102
+ * `github.owner`/`github.repo` are unset. A `null` bucket is an honest
103
+ * "unknown", which {@link routeOwnership} turns into a named, reportable
104
+ * outcome.
105
+ *
106
+ * @param {object} [config] — resolved `.agentrc` config.
107
+ * @returns {{ consumer: ({owner: string, repo: string}|null), framework: ({owner: string, repo: string}|null), platform: ({owner: string, repo: string}|null) }}
108
+ */
109
+ export function resolveOwnershipRepos(config) {
110
+ const github = config?.github ?? {};
111
+ const followUp = github?.followUpRepos ?? {};
112
+ const owner = typeof github.owner === 'string' ? github.owner.trim() : '';
113
+ const repo = typeof github.repo === 'string' ? github.repo.trim() : '';
114
+ return {
115
+ consumer: owner && repo ? { owner, repo } : null,
116
+ framework:
117
+ parseRepoSlug(followUp.framework) ??
118
+ parseRepoSlug(DEFAULT_FRAMEWORK_REPO),
119
+ platform: parseRepoSlug(followUp.platform),
120
+ };
121
+ }
122
+
123
+ /**
124
+ * Route one ownership bucket to the repository its work belongs in.
125
+ *
126
+ * Total: an unknown bucket, an absent repos map, and an unconfigured bucket
127
+ * all resolve to `routable: false` with the `missingKey` that would fix it —
128
+ * never to a substituted repository.
129
+ *
130
+ * @param {object} opts
131
+ * @param {string} opts.bucket — one of {@link OWNERSHIP_BUCKETS}.
132
+ * @param {{consumer?: object|null, framework?: object|null, platform?: object|null}} opts.repos
133
+ * — resolved buckets, from {@link resolveOwnershipRepos} or assembled by a
134
+ * caller that already holds the repo objects.
135
+ * @param {{owner: string, repo: string}|null} [opts.currentRepo] — the repo
136
+ * the run is standing in, used only to report `crossRepo`.
137
+ * @returns {{ bucket: string, routedRepo: ({owner: string, repo: string}|null), routable: boolean, missingKey: (string|null), crossRepo: boolean }}
138
+ */
139
+ export function routeOwnership({ bucket, repos, currentRepo = null } = {}) {
140
+ const known = OWNERSHIP_BUCKETS.includes(bucket);
141
+ const routedRepo = known ? (repos?.[bucket] ?? null) : null;
142
+ const routable = Boolean(routedRepo?.owner && routedRepo?.repo);
143
+ const missingKey = routable
144
+ ? null
145
+ : (OWNERSHIP_CONFIG_KEYS[bucket] ?? `unknown ownership bucket "${bucket}"`);
146
+ const crossRepo =
147
+ routable &&
148
+ Boolean(currentRepo) &&
149
+ (routedRepo.owner !== currentRepo.owner ||
150
+ routedRepo.repo !== currentRepo.repo);
151
+ return { bucket, routedRepo, routable, missingKey, crossRepo };
152
+ }
@@ -119,7 +119,11 @@ export const ACCEPTANCE_NA = ACCEPTANCE_LABELS.N_A;
119
119
  * loop). `meta::framework-gap` is applied to issues that surface a defect or
120
120
  * missing capability in the framework itself; `meta::consumer-improvement`
121
121
  * is applied to issues that surface improvements to a consumer project
122
- * (workflow tweaks, ergonomic asks, doc polish). The `/mandrel-plan` Phase 0
122
+ * (workflow tweaks, ergonomic asks, doc polish); `meta::platform-gap` is
123
+ * applied to issues owned by neither — a shared base config, a runner fleet,
124
+ * a cross-repo toolchain. The three mirror the ownership buckets in
125
+ * `lib/github/framework-repo.js`, so a routed filing's label and its
126
+ * destination repository cannot disagree. The `/mandrel-plan` Phase 0
123
127
  * fetcher (see `lib/feedback-loop/prior-feedback-fetcher.js`) reads open
124
128
  * issues carrying either label and surfaces them to the planner so retro
125
129
  * signals are routed into durable substrates rather than lost in chat.
@@ -127,6 +131,7 @@ export const ACCEPTANCE_NA = ACCEPTANCE_LABELS.N_A;
127
131
  export const META_LABELS = {
128
132
  FRAMEWORK_GAP: 'meta::framework-gap',
129
133
  CONSUMER_IMPROVEMENT: 'meta::consumer-improvement',
134
+ PLATFORM_GAP: 'meta::platform-gap',
130
135
  };
131
136
 
132
137
  /**
@@ -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',
@@ -114,6 +115,7 @@ const FRAMEWORK_SCRIPT_BASENAMES = Object.freeze([
114
115
  'diagnose.js',
115
116
  'drain-pending-cleanup.js',
116
117
  'evidence-gate.js',
118
+ 'file-ci-gap.js',
117
119
  'generate-config-docs.js',
118
120
  'generate-lens-checklists.js',
119
121
  'generate-skills-index.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.