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.
- package/.agents/agents/story-worker.md +24 -23
- package/.agents/audit-checklists/accessibility.md +0 -3
- package/.agents/audit-checklists/mobile.md +0 -4
- package/.agents/docs/agentrc-reference.json +8 -2
- package/.agents/docs/configuration.md +5 -0
- package/.agents/rules/ci-remediation.md +39 -21
- package/.agents/schemas/agentrc.schema.json +34 -1
- package/.agents/schemas/lifecycle/merge.unlanded.schema.json +2 -1
- package/.agents/schemas/story-deliver-terminal.schema.json +1 -0
- package/.agents/scripts/audit-to-stories.js +374 -76
- package/.agents/scripts/check-audit-attribution.js +119 -62
- package/.agents/scripts/check-test-portability.js +512 -0
- package/.agents/scripts/coverage-capture.js +17 -10
- package/.agents/scripts/evidence-gate.js +31 -4
- package/.agents/scripts/file-ci-gap.js +306 -0
- package/.agents/scripts/generate-workflows-doc.js +65 -14
- package/.agents/scripts/git-cleanup.js +4 -0
- package/.agents/scripts/lib/ITicketingProvider.js +78 -0
- package/.agents/scripts/lib/audit-advisories.js +195 -0
- package/.agents/scripts/lib/audit-attribution.js +22 -0
- package/.agents/scripts/lib/audit-to-stories/audit-label-taxonomy.js +25 -1
- package/.agents/scripts/lib/audit-to-stories/dedupe-against-github.js +80 -29
- package/.agents/scripts/lib/audit-to-stories/finding-adapter.js +5 -1
- package/.agents/scripts/lib/audit-to-stories/issue-corpus.js +162 -0
- package/.agents/scripts/lib/audit-to-stories/issue-index.js +83 -0
- package/.agents/scripts/lib/audit-to-stories/issues-file.js +121 -0
- package/.agents/scripts/lib/audit-to-stories/ledger-commit.js +61 -115
- package/.agents/scripts/lib/audit-to-stories/ledger-pr.js +347 -0
- package/.agents/scripts/lib/audit-to-stories/ledger-record.js +126 -0
- package/.agents/scripts/lib/audit-to-stories/parse-audit-md.js +169 -44
- package/.agents/scripts/lib/audit-to-stories/seed-from-findings.js +11 -0
- package/.agents/scripts/lib/baselines/merge-envelopes.js +298 -32
- package/.agents/scripts/lib/bootstrap/baseline-merge-driver.js +180 -14
- package/.agents/scripts/lib/cli-args.js +26 -0
- package/.agents/scripts/lib/close-validation/gates.js +113 -7
- package/.agents/scripts/lib/close-validation/process.js +7 -3
- package/.agents/scripts/lib/close-validation/runner.js +62 -11
- package/.agents/scripts/lib/config/ci.js +28 -9
- package/.agents/scripts/lib/config-settings-schema-delivery.js +7 -0
- package/.agents/scripts/lib/config-settings-schema.js +52 -1
- package/.agents/scripts/lib/coverage-capture-fullscope.js +23 -11
- package/.agents/scripts/lib/coverage-capture-incremental.js +22 -16
- package/.agents/scripts/lib/coverage-capture-usage.js +5 -1
- package/.agents/scripts/lib/coverage-capture.js +77 -3
- package/.agents/scripts/lib/feedback-loop/graduator-core.js +53 -13
- package/.agents/scripts/lib/feedback-loop/prior-feedback-fetcher.js +71 -25
- package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +18 -25
- package/.agents/scripts/lib/{audit-to-stories/ledger.js → findings/audit-ledger.js} +131 -24
- package/.agents/scripts/lib/findings/route-finding.js +42 -2
- package/.agents/scripts/lib/full-suite-lock.js +232 -6
- package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
- package/.agents/scripts/lib/git/sync-from-base.js +130 -13
- package/.agents/scripts/lib/github/framework-repo.js +148 -2
- package/.agents/scripts/lib/label-constants.js +6 -1
- package/.agents/scripts/lib/observability/source-classifier.js +2 -0
- package/.agents/scripts/lib/orchestration/check-baselines/phases/compare.js +10 -2
- package/.agents/scripts/lib/orchestration/check-baselines/phases/refresh-ack.js +75 -15
- package/.agents/scripts/lib/orchestration/ci-gap-intake.js +605 -0
- package/.agents/scripts/lib/orchestration/ci-rerun-guard.js +13 -8
- package/.agents/scripts/lib/orchestration/deliver-recover.js +82 -43
- package/.agents/scripts/lib/orchestration/dependency-candidates.js +8 -4
- package/.agents/scripts/lib/orchestration/epic-candidates.js +9 -4
- package/.agents/scripts/lib/orchestration/epic-container.js +66 -4
- package/.agents/scripts/lib/orchestration/epic-rollup.js +233 -84
- package/.agents/scripts/lib/orchestration/file-assumptions.js +218 -16
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/branches.js +93 -7
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/git-probes.js +22 -6
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/parse-args.js +26 -5
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/phase-drivers.js +13 -2
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/render.js +35 -5
- package/.agents/scripts/lib/orchestration/merge-block-class.js +18 -3
- package/.agents/scripts/lib/orchestration/merge-poll.js +284 -40
- package/.agents/scripts/lib/orchestration/plan-persist/audit-provenance.js +197 -0
- package/.agents/scripts/lib/orchestration/plan-persist/epic-adoption.js +49 -2
- package/.agents/scripts/lib/orchestration/plan-persist/epic-ops.js +43 -7
- package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +39 -3
- package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +5 -0
- package/.agents/scripts/lib/orchestration/plan-persist/summary.js +3 -0
- package/.agents/scripts/lib/orchestration/plan-persist/supersede-ops.js +63 -0
- package/.agents/scripts/lib/orchestration/plan-persist/wave-serialisation.js +110 -0
- package/.agents/scripts/lib/orchestration/planning/memory-pool-advisory.js +130 -40
- package/.agents/scripts/lib/orchestration/resolve-stories.js +44 -1
- package/.agents/scripts/lib/orchestration/review-providers/native.js +31 -11
- package/.agents/scripts/lib/orchestration/review-providers/scoped-lint.js +27 -24
- package/.agents/scripts/lib/orchestration/run-epilogue.js +63 -42
- package/.agents/scripts/lib/orchestration/single-story-close/close-note.js +81 -0
- package/.agents/scripts/lib/orchestration/single-story-close/failed-terminal.js +40 -51
- package/.agents/scripts/lib/orchestration/single-story-close/phases/auto-merge.js +10 -2
- package/.agents/scripts/lib/orchestration/single-story-close/phases/base-sync.js +101 -0
- package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +351 -28
- package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +27 -6
- package/.agents/scripts/lib/orchestration/single-story-close/runner.js +117 -22
- package/.agents/scripts/lib/orchestration/story-close/baseline-upward-writeback.js +94 -12
- package/.agents/scripts/lib/orchestration/story-close/format-autofix.js +6 -1
- package/.agents/scripts/lib/orchestration/story-follow-ups.js +32 -20
- package/.agents/scripts/lib/orchestration/ticket-validator.js +25 -14
- package/.agents/scripts/lib/orchestration/ticketing/bulk.js +30 -0
- package/.agents/scripts/lib/orchestration/verify-credit.js +37 -0
- package/.agents/scripts/lib/pinned-override-notes.js +41 -53
- package/.agents/scripts/lib/pinned-override-resolve.js +212 -0
- package/.agents/scripts/lib/qa/resolve-qa-contract.js +18 -0
- package/.agents/scripts/lib/single-story-sweep/sweep-lock.js +173 -9
- package/.agents/scripts/lib/skills/walk-skill-files.js +24 -7
- package/.agents/scripts/lib/test-temp.js +167 -30
- package/.agents/scripts/lib/validation-evidence.js +37 -0
- package/.agents/scripts/lib/wave-runner/footprint.js +167 -14
- package/.agents/scripts/lib/wave-runner/live-probe.js +7 -1
- package/.agents/scripts/lib/wave-runner/ready-set.js +1 -1
- package/.agents/scripts/merge-baseline.js +175 -21
- package/.agents/scripts/pr-watch-with-update.js +3 -2
- package/.agents/scripts/providers/github/errors.js +22 -1
- package/.agents/scripts/providers/github/issues.js +106 -1
- package/.agents/scripts/providers/github/sub-issue-add.js +18 -1
- package/.agents/scripts/providers/github.js +6 -0
- package/.agents/scripts/resolve-stories.js +44 -34
- package/.agents/scripts/single-story-close.js +5 -0
- package/.agents/scripts/stories-wave-tick.js +37 -13
- package/.agents/templates/docs/audit-sweep-runbook.md +41 -7
- package/.agents/workflows/audit-accessibility.md +16 -31
- package/.agents/workflows/audit-mobile.md +20 -37
- package/.agents/workflows/audit-to-stories.md +63 -27
- package/.agents/workflows/git-cleanup.md +17 -3
- package/.agents/workflows/helpers/audit-lens-core.md +45 -0
- package/.agents/workflows/helpers/deliver-digest.md +7 -6
- package/.agents/workflows/helpers/deliver-reference.md +35 -14
- package/.agents/workflows/helpers/deliver-story-reference.md +26 -8
- package/.agents/workflows/helpers/deliver-story.md +15 -12
- package/.agents/workflows/helpers/plan-reference.md +30 -0
- package/.agents/workflows/mandrel-plan.md +10 -13
- package/.agents/workflows/memory-consolidate.md +14 -9
- package/docs/CHANGELOG.md +37 -0
- package/lib/cli/registry.js +64 -21
- package/lib/cli/sync.js +27 -2
- 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
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
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'
|
|
23
|
-
* is already an ancestor of HEAD; nothing to do.
|
|
24
|
-
* - `{ synced: true, kind: 'fast-forward' }` — merge
|
|
25
|
-
*
|
|
26
|
-
*
|
|
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
|
-
*
|
|
3
|
-
*
|
|
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)
|
|
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
|
-
|
|
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
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
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
|
|
116
|
+
function readRowsAtRef(ref, baselinePath, cwd) {
|
|
106
117
|
let raw;
|
|
107
118
|
try {
|
|
108
|
-
raw = readBaseFromGit(
|
|
119
|
+
raw = readBaseFromGit(ref, baselinePath, { cwd });
|
|
109
120
|
} catch {
|
|
110
121
|
return null;
|
|
111
122
|
}
|
|
112
|
-
if (raw === null) return
|
|
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
|
|
216
|
-
|
|
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
|
|
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.
|