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.
- 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 +4 -2
- package/.agents/docs/configuration.md +2 -0
- package/.agents/schemas/agentrc.schema.json +15 -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 +158 -7
- 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/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/dedupe-against-github.js +68 -5
- package/.agents/scripts/lib/audit-to-stories/issue-index.js +83 -0
- package/.agents/scripts/lib/audit-to-stories/ledger-commit.js +60 -114
- package/.agents/scripts/lib/audit-to-stories/ledger-pr.js +347 -0
- package/.agents/scripts/lib/audit-to-stories/parse-audit-md.js +169 -44
- 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 +19 -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/findings/route-finding.js +4 -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/observability/source-classifier.js +1 -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/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 +241 -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/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 +24 -1
- 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 +119 -6
- 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 +59 -38
- 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/ticket-validator.js +25 -14
- package/.agents/scripts/lib/orchestration/ticketing/bulk.js +70 -6
- 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/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/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 +40 -16
- package/.agents/workflows/helpers/deliver-story-reference.md +7 -4
- package/.agents/workflows/helpers/deliver-story.md +15 -12
- package/.agents/workflows/helpers/plan-reference.md +8 -1
- package/.agents/workflows/mandrel-plan.md +4 -7
- package/.agents/workflows/memory-consolidate.md +14 -9
- package/docs/CHANGELOG.md +34 -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
|
|
|
@@ -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
|
-
|
|
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.
|
|
@@ -13,8 +13,12 @@
|
|
|
13
13
|
*
|
|
14
14
|
* The strand shapes the table resolves, and why each is real:
|
|
15
15
|
*
|
|
16
|
-
* - `executing
|
|
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
|
-
|
|
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?.
|
|
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.
|
|
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
|
-
|
|
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?.
|
|
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
|
-
|
|
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?.
|
|
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?.
|
|
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.
|
|
141
|
+
issues = await provider.listTicketsByLabel({
|
|
137
142
|
state: 'open',
|
|
138
143
|
labels: TYPE_LABELS.EPIC,
|
|
139
144
|
});
|