mandrel 2.24.0 → 2.25.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/acceptance-critic.md +13 -19
- package/.agents/agents/auditor.md +16 -19
- package/.agents/agents/story-worker.md +41 -54
- package/.agents/audit-checklists/architecture.md +5 -1
- package/.agents/audit-checklists/quality.md +3 -0
- package/.agents/docs/agentrc-reference.json +2 -1
- package/.agents/docs/configuration.md +2 -1
- package/.agents/docs/workflows.md +1 -1
- package/.agents/rules/ci-remediation.md +68 -3
- package/.agents/schemas/agentrc.schema.json +6 -1
- package/.agents/schemas/baselines/baseline-envelope.schema.json +4 -0
- package/.agents/schemas/baselines/crap.schema.json +5 -1
- package/.agents/schemas/crap-report.schema.json +37 -0
- package/.agents/scripts/acceptance-eval.js +35 -9
- package/.agents/scripts/audit-labels-bootstrap.js +16 -95
- package/.agents/scripts/audit-to-stories.js +33 -3
- package/.agents/scripts/check-context-budget.js +95 -14
- package/.agents/scripts/check-test-temp-hygiene.js +83 -0
- package/.agents/scripts/cleanup-repo-test-temp.js +7 -5
- package/.agents/scripts/deliver-light.js +3 -4
- package/.agents/scripts/deliver-recover.js +13 -0
- package/.agents/scripts/lib/audit-to-stories/audit-label-taxonomy.js +177 -0
- package/.agents/scripts/lib/audit-to-stories/build-story-body.js +35 -3
- package/.agents/scripts/lib/audit-to-stories/group-findings.js +20 -11
- package/.agents/scripts/lib/audit-to-stories/parse-audit-md.js +23 -9
- package/.agents/scripts/lib/audit-to-stories/seed-from-findings.js +9 -2
- package/.agents/scripts/lib/baseline-loader.js +0 -0
- package/.agents/scripts/lib/baselines/git-base.js +0 -0
- package/.agents/scripts/lib/baselines/kinds/crap.js +396 -51
- package/.agents/scripts/lib/baselines/preview-gates.js +58 -15
- package/.agents/scripts/lib/baselines/reader.js +10 -0
- package/.agents/scripts/lib/baselines/writer.js +7 -3
- package/.agents/scripts/lib/config-settings-schema-delivery.js +5 -0
- package/.agents/scripts/lib/crap-engine.js +97 -19
- package/.agents/scripts/lib/crap-utils.js +73 -13
- package/.agents/scripts/lib/env-loader.js +46 -16
- package/.agents/scripts/lib/findings/route-finding.js +123 -17
- package/.agents/scripts/lib/findings/severity.js +80 -2
- package/.agents/scripts/lib/git/cached-fetch.js +0 -0
- package/.agents/scripts/lib/label-constants.js +17 -0
- package/.agents/scripts/lib/orchestration/acceptance-eval-decision.js +110 -17
- package/.agents/scripts/lib/orchestration/ci-rerun-guard.js +548 -0
- package/.agents/scripts/lib/orchestration/deliver-recover.js +2 -2
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/watcher.js +71 -20
- package/.agents/scripts/lib/orchestration/light-backstop.js +20 -3
- package/.agents/scripts/lib/orchestration/light-escalation.js +82 -3
- package/.agents/scripts/lib/orchestration/light-suitability.js +103 -5
- package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +7 -0
- package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +13 -1
- package/.agents/scripts/lib/orchestration/retro-proposals.js +0 -0
- package/.agents/scripts/lib/orchestration/run-scoped-config.js +276 -0
- package/.agents/scripts/lib/orchestration/single-story-close/phases/base-sync.js +41 -10
- package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +96 -34
- package/.agents/scripts/lib/orchestration/single-story-close/phases/pull-request.js +129 -21
- package/.agents/scripts/lib/orchestration/single-story-close/runner.js +50 -13
- package/.agents/scripts/lib/reserved-test-ids.js +77 -0
- package/.agents/scripts/lib/story-plan.js +137 -42
- package/.agents/scripts/lib/templates/decomposer-prompts.js +4 -4
- package/.agents/scripts/lib/ticket-body-sections.js +1 -1
- package/.agents/scripts/lib/util/poll-loop.js +18 -2
- package/.agents/scripts/lib/wave-runner/live-probe.js +7 -1
- package/.agents/scripts/lib/wave-runner/ready-set.js +73 -9
- package/.agents/scripts/lib/workers/combined-mi-crap-worker.js +6 -3
- package/.agents/scripts/lib/workers/crap-worker.js +6 -3
- package/.agents/scripts/pr-watch-with-update.js +568 -213
- package/.agents/scripts/quality-preview.js +25 -0
- package/.agents/scripts/run-tests.js +10 -0
- package/.agents/scripts/single-story-close.js +6 -0
- package/.agents/scripts/single-story-init.js +7 -0
- package/.agents/scripts/stories-wave-tick.js +95 -10
- package/.agents/scripts/story-plan.js +65 -9
- package/.agents/scripts/update-crap-baseline.js +42 -4
- package/.agents/scripts/update-maintainability-baseline.js +31 -4
- package/.agents/templates/single-story-body.md +16 -8
- package/.agents/workflows/audit-architecture.md +69 -13
- package/.agents/workflows/audit-quality.md +26 -0
- package/.agents/workflows/audit-to-stories.md +30 -4
- package/.agents/workflows/deliver.md +81 -95
- package/.agents/workflows/helpers/audit-lens-core.md +25 -4
- package/.agents/workflows/helpers/deliver-reference.md +18 -3
- package/.agents/workflows/helpers/deliver-story-reference.md +127 -46
- package/.agents/workflows/helpers/deliver-story.md +104 -119
- package/.agents/workflows/helpers/plan-reference.md +34 -0
- package/.agents/workflows/plan.md +60 -76
- package/docs/CHANGELOG.md +29 -0
- package/lib/cli/update.js +14 -11
- package/lib/cli/version-check.js +9 -1
- package/package.json +1 -1
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* phases/pull-request.js — open or
|
|
2
|
+
* phases/pull-request.js — open, reuse, or decline to open the PR for a
|
|
3
|
+
* standalone Story.
|
|
3
4
|
*
|
|
4
|
-
* Probes for an existing
|
|
5
|
-
*
|
|
5
|
+
* Probes for an existing PR with `head = storyBranch`; creates one if none
|
|
6
|
+
* exists. Returns `{ url, alreadyMerged, created }`.
|
|
6
7
|
*
|
|
7
8
|
* `gh pr view --head` is not available on all `gh` versions, so we probe
|
|
8
9
|
* with `gh pr list --head <branch>` and fall back to `gh pr create`.
|
|
@@ -13,17 +14,99 @@
|
|
|
13
14
|
* error classification, timeout handling, and JSON parsing surface
|
|
14
15
|
* instead of carrying its own `execFileSync('gh', …)` plumbing.
|
|
15
16
|
*
|
|
17
|
+
* ## An already-merged PR is not an absent PR (Story #4873)
|
|
18
|
+
*
|
|
19
|
+
* The probe used to ask for `--state open` only. On the recovery path that is
|
|
20
|
+
* a false negative with teeth: a resumed close pushes the branch, the push
|
|
21
|
+
* turns the checks green, armed auto-merge lands the ORIGINAL PR server-side,
|
|
22
|
+
* and the resumed close then sees no OPEN PR on the head — so it opened a
|
|
23
|
+
* SECOND PR against a branch that is now byte-identical to base and
|
|
24
|
+
* squash-merged a zero-file commit onto `main`. Both halves of that are now
|
|
25
|
+
* closed:
|
|
26
|
+
*
|
|
27
|
+
* 1. The probe reads `--state all` and reports a MERGED PR on this head as
|
|
28
|
+
* the outcome (`alreadyMerged: true`), so the caller lands on the merge
|
|
29
|
+
* that already happened instead of manufacturing a new one. An OPEN PR
|
|
30
|
+
* still wins over a merged one — a re-opened head is a live PR.
|
|
31
|
+
* 2. Creation is REFUSED outright when the head-versus-base diff contains no
|
|
32
|
+
* files. An empty diff means there is nothing to merge, so a PR opened on
|
|
33
|
+
* it can only ever produce an empty commit.
|
|
34
|
+
*
|
|
35
|
+
* The empty-diff guard fails **open**, not closed: `computeChangeSet` returns
|
|
36
|
+
* `files: null` when it cannot enumerate the diff at all, and absence of
|
|
37
|
+
* evidence must never block a legitimate PR — only a positively-observed empty
|
|
38
|
+
* file list refuses.
|
|
39
|
+
*
|
|
16
40
|
* The function still accepts an injected `gh` facade so tests can wire
|
|
17
41
|
* a fake without spawning real children.
|
|
18
42
|
*/
|
|
19
43
|
|
|
20
44
|
import { gh as defaultGh } from '../../../gh-exec.js';
|
|
21
45
|
import { Logger } from '../../../Logger.js';
|
|
46
|
+
import { computeChangeSet as defaultComputeChangeSet } from '../../change-set.js';
|
|
22
47
|
import { normalizePrTitle } from './normalize-pr-title.js';
|
|
23
48
|
|
|
24
49
|
/**
|
|
25
|
-
*
|
|
26
|
-
*
|
|
50
|
+
* Pick the PR this head branch should resolve to from a `gh pr list
|
|
51
|
+
* --state all` projection. A live PR always wins; otherwise the first MERGED
|
|
52
|
+
* PR is the outcome to report. A head whose only PRs were CLOSED without
|
|
53
|
+
* merging resolves to nothing — there is a new PR to open.
|
|
54
|
+
*
|
|
55
|
+
* A row carrying a url but no recognizable `state` reads as live, not as
|
|
56
|
+
* nothing: the projection this phase asks for always includes `state`, so an
|
|
57
|
+
* absent one means an older/other `gh`, and the old `--state open` probe
|
|
58
|
+
* treated every returned row as a reusable open PR. Guessing "no PR" there is
|
|
59
|
+
* the failure mode with teeth — it opens a duplicate.
|
|
60
|
+
*
|
|
61
|
+
* Pure, and module-private on purpose: `ensurePullRequestWith` is the only
|
|
62
|
+
* caller and the only surface worth pinning, so the precedence is asserted
|
|
63
|
+
* through it rather than through a test-only export the production
|
|
64
|
+
* dead-export ratchet would then flag.
|
|
65
|
+
*
|
|
66
|
+
* @param {Array<{url?: string, state?: string, mergedAt?: string}>} rows
|
|
67
|
+
* @returns {{ url: string, state: 'OPEN'|'MERGED' }|null}
|
|
68
|
+
*/
|
|
69
|
+
function pickHeadPullRequest(rows) {
|
|
70
|
+
if (!Array.isArray(rows)) return null;
|
|
71
|
+
let merged = null;
|
|
72
|
+
for (const row of rows) {
|
|
73
|
+
const url = String(row?.url ?? '').trim();
|
|
74
|
+
if (!url) continue;
|
|
75
|
+
const state = String(row?.state ?? '').toUpperCase();
|
|
76
|
+
if (state === 'MERGED' || (state !== 'OPEN' && row?.mergedAt)) {
|
|
77
|
+
merged ??= { url, state: 'MERGED' };
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
if (state === 'CLOSED') continue;
|
|
81
|
+
return { url, state: 'OPEN' };
|
|
82
|
+
}
|
|
83
|
+
return merged;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Enumerate the head-versus-base diff and report whether it is positively
|
|
88
|
+
* empty. `null` (diff unenumerable) is NOT empty — see the module header.
|
|
89
|
+
*
|
|
90
|
+
* The diff is taken against `origin/<baseBranch>` when that ref resolves,
|
|
91
|
+
* because the local base ref can trail the remote by exactly the merge that
|
|
92
|
+
* makes this diff empty — the very state the guard exists to catch. It falls
|
|
93
|
+
* back to the local ref, and finally to "unknown", when the remote ref cannot
|
|
94
|
+
* be enumerated.
|
|
95
|
+
*
|
|
96
|
+
* @returns {{ empty: boolean, baseRef: string|null }}
|
|
97
|
+
*/
|
|
98
|
+
function probeEmptyDiff({ cwd, baseBranch, storyBranch, computeChangeSet }) {
|
|
99
|
+
for (const baseRef of [`origin/${baseBranch}`, baseBranch]) {
|
|
100
|
+
const set = computeChangeSet({ baseRef, headRef: storyBranch, cwd });
|
|
101
|
+
if (!set.enumerated) continue;
|
|
102
|
+
return { empty: (set.files ?? []).length === 0, baseRef };
|
|
103
|
+
}
|
|
104
|
+
return { empty: false, baseRef: null };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Probe for an existing PR with `head = storyBranch`; create one if none
|
|
109
|
+
* exists. Exported for testing.
|
|
27
110
|
*
|
|
28
111
|
* @param {{
|
|
29
112
|
* cwd: string,
|
|
@@ -32,9 +115,10 @@ import { normalizePrTitle } from './normalize-pr-title.js';
|
|
|
32
115
|
* storyBranch: string,
|
|
33
116
|
* baseBranch: string,
|
|
34
117
|
* gh?: ReturnType<typeof import('../../../gh-exec.js').createGh>,
|
|
118
|
+
* computeChangeSetFn?: typeof defaultComputeChangeSet,
|
|
35
119
|
* progress?: (tag: string, msg: string) => void,
|
|
36
120
|
* }} args
|
|
37
|
-
* @returns {Promise<string>}
|
|
121
|
+
* @returns {Promise<{ url: string, alreadyMerged: boolean, created: boolean }>}
|
|
38
122
|
*/
|
|
39
123
|
export async function ensurePullRequestWith({
|
|
40
124
|
cwd: _cwd,
|
|
@@ -43,6 +127,7 @@ export async function ensurePullRequestWith({
|
|
|
43
127
|
storyBranch,
|
|
44
128
|
baseBranch,
|
|
45
129
|
gh = defaultGh,
|
|
130
|
+
computeChangeSetFn = defaultComputeChangeSet,
|
|
46
131
|
progress = () => {},
|
|
47
132
|
}) {
|
|
48
133
|
// `cwd` is preserved on the call signature for backwards compatibility
|
|
@@ -51,22 +136,26 @@ export async function ensurePullRequestWith({
|
|
|
51
136
|
// before invoking the phase, so the effective cwd matches the legacy
|
|
52
137
|
// `execFileSync('gh', …, { cwd })` shape.
|
|
53
138
|
try {
|
|
54
|
-
// `gh pr list --head <branch> --state
|
|
55
|
-
// JSON array of
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
// `gh.pr.list` facade can stay generic.
|
|
139
|
+
// `gh pr list --head <branch> --state all --json url,state,mergedAt`
|
|
140
|
+
// returns a JSON array of rows; an empty array means this head has never
|
|
141
|
+
// had a PR. `--state all` (not `open`) is load-bearing — see the module
|
|
142
|
+
// header's duplicate-empty-PR note.
|
|
59
143
|
const rows = await gh.pr.list(
|
|
60
|
-
['--head', storyBranch, '--state', '
|
|
61
|
-
['url'],
|
|
144
|
+
['--head', storyBranch, '--state', 'all'],
|
|
145
|
+
['url', 'state', 'mergedAt'],
|
|
62
146
|
);
|
|
63
|
-
const existing =
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
147
|
+
const existing = pickHeadPullRequest(rows);
|
|
148
|
+
if (existing?.state === 'OPEN') {
|
|
149
|
+
progress('PR', `Reusing existing PR: ${existing.url}`);
|
|
150
|
+
return { url: existing.url, alreadyMerged: false, created: false };
|
|
151
|
+
}
|
|
152
|
+
if (existing?.state === 'MERGED') {
|
|
153
|
+
progress(
|
|
154
|
+
'PR',
|
|
155
|
+
`✅ PR for ${storyBranch} is already MERGED: ${existing.url} — ` +
|
|
156
|
+
'reporting that outcome instead of opening a second PR.',
|
|
157
|
+
);
|
|
158
|
+
return { url: existing.url, alreadyMerged: true, created: false };
|
|
70
159
|
}
|
|
71
160
|
} catch (err) {
|
|
72
161
|
// `gh pr list` failure is recoverable — fall through to create. Log
|
|
@@ -76,6 +165,25 @@ export async function ensurePullRequestWith({
|
|
|
76
165
|
);
|
|
77
166
|
}
|
|
78
167
|
|
|
168
|
+
// Nothing to merge → nothing to open. Refused before `gh pr create` so the
|
|
169
|
+
// failure names the empty diff rather than surfacing later as a zero-file
|
|
170
|
+
// squash commit on the base branch.
|
|
171
|
+
const emptyDiff = probeEmptyDiff({
|
|
172
|
+
cwd: _cwd ?? process.cwd(),
|
|
173
|
+
baseBranch,
|
|
174
|
+
storyBranch,
|
|
175
|
+
computeChangeSet: computeChangeSetFn,
|
|
176
|
+
});
|
|
177
|
+
if (emptyDiff.empty) {
|
|
178
|
+
throw new Error(
|
|
179
|
+
`[single-story-close] refusing to open a pull request for ${storyBranch}: ` +
|
|
180
|
+
`the head-versus-base diff (${emptyDiff.baseRef}...${storyBranch}) contains no files. ` +
|
|
181
|
+
'An empty diff has nothing to merge, and a PR opened on it can only squash an empty ' +
|
|
182
|
+
'commit onto the base branch. If the work already landed, confirm the merge instead: ' +
|
|
183
|
+
`node .agents/scripts/single-story-confirm-merge.js --story ${storyId}`,
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
|
|
79
187
|
progress('PR', `Opening PR for ${storyBranch} → ${baseBranch}...`);
|
|
80
188
|
// The repo squash-merges and GitHub uses the PR title as the squash
|
|
81
189
|
// subject on `main`. A raw human issue title is not a Conventional
|
|
@@ -110,7 +218,7 @@ export async function ensurePullRequestWith({
|
|
|
110
218
|
]);
|
|
111
219
|
const url = (createResult?.stdout ?? '').trim();
|
|
112
220
|
progress('PR', `✅ Opened: ${url}`);
|
|
113
|
-
return url;
|
|
221
|
+
return { url, alreadyMerged: false, created: true };
|
|
114
222
|
} catch (err) {
|
|
115
223
|
throw new Error(
|
|
116
224
|
`[single-story-close] \`gh pr create\` failed: ${err?.message ?? err}`,
|
|
@@ -12,6 +12,7 @@ import { createProvider } from '../../provider-factory.js';
|
|
|
12
12
|
import { flipLabelAndNotify } from '../../single-story/story-merged-notify.js';
|
|
13
13
|
import { WorktreeManager } from '../../worktree-manager.js';
|
|
14
14
|
import { runCodeReview as runCodeReviewDefault } from '../code-review.js';
|
|
15
|
+
import { resolveRunScopedConfig } from '../run-scoped-config.js';
|
|
15
16
|
import { releaseStoryLease } from '../single-story-lease-guard.js';
|
|
16
17
|
import {
|
|
17
18
|
buildTerminalEnvelope,
|
|
@@ -144,6 +145,7 @@ async function runPrePushPhases({
|
|
|
144
145
|
worktreePath,
|
|
145
146
|
config,
|
|
146
147
|
baseBranch,
|
|
148
|
+
baseConfirmed,
|
|
147
149
|
storyBranch,
|
|
148
150
|
storyId,
|
|
149
151
|
provider,
|
|
@@ -185,6 +187,7 @@ async function runPrePushPhases({
|
|
|
185
187
|
cwd,
|
|
186
188
|
worktreePath,
|
|
187
189
|
baseBranch,
|
|
190
|
+
baseConfirmed,
|
|
188
191
|
storyBranch,
|
|
189
192
|
storyId,
|
|
190
193
|
provider,
|
|
@@ -210,7 +213,7 @@ async function openAndReviewPr({
|
|
|
210
213
|
setPhase('push');
|
|
211
214
|
pushStoryBranch({ cwd, storyBranch, gitSync, progress });
|
|
212
215
|
setPhase('pull-request');
|
|
213
|
-
const prUrl = await ensurePullRequestWith({
|
|
216
|
+
const { url: prUrl, alreadyMerged } = await ensurePullRequestWith({
|
|
214
217
|
cwd,
|
|
215
218
|
storyId,
|
|
216
219
|
storyTitle: story.title,
|
|
@@ -220,6 +223,13 @@ async function openAndReviewPr({
|
|
|
220
223
|
progress,
|
|
221
224
|
});
|
|
222
225
|
const prNumber = parsePrNumber(prUrl);
|
|
226
|
+
// Story #4873 — the head's PR already merged (an armed PR that landed while
|
|
227
|
+
// a previous close invocation was between phases). There is nothing left to
|
|
228
|
+
// review and nothing left to arm; the confirm phase probes the PR, observes
|
|
229
|
+
// MERGED, and lands the Story on the merge that actually happened.
|
|
230
|
+
if (alreadyMerged) {
|
|
231
|
+
return { prUrl, prNumber, alreadyMerged: true };
|
|
232
|
+
}
|
|
223
233
|
setPhase('code-review');
|
|
224
234
|
const reviewOutcome = await runStoryScopeReview({
|
|
225
235
|
cwd,
|
|
@@ -245,7 +255,7 @@ async function openAndReviewPr({
|
|
|
245
255
|
'Auto-merge was not enabled. Remediate the findings posted to the PR and re-run `/deliver`.',
|
|
246
256
|
);
|
|
247
257
|
}
|
|
248
|
-
return { prUrl, prNumber };
|
|
258
|
+
return { prUrl, prNumber, alreadyMerged: false };
|
|
249
259
|
}
|
|
250
260
|
|
|
251
261
|
async function releaseLease({
|
|
@@ -443,7 +453,6 @@ async function runClosePipeline({
|
|
|
443
453
|
const startedAtMs = Date.now();
|
|
444
454
|
const config = injectedConfig || resolveConfig({ cwd: options.cwd });
|
|
445
455
|
const provider = injectedProvider || createProvider(config);
|
|
446
|
-
const baseBranch = config.project?.baseBranch ?? 'main';
|
|
447
456
|
const storyBranch = getStoryBranch(options.storyId);
|
|
448
457
|
|
|
449
458
|
progress('INIT', `Closing standalone Story #${options.storyId}...`);
|
|
@@ -462,6 +471,22 @@ async function runClosePipeline({
|
|
|
462
471
|
);
|
|
463
472
|
}
|
|
464
473
|
|
|
474
|
+
// Story #4891 — the base branch this run was SEEDED from, read back off the
|
|
475
|
+
// run's `story-init` receipt rather than re-resolved from a config file that
|
|
476
|
+
// may have changed during the whole implementation window. Throws (fail
|
|
477
|
+
// closed, naming both values) when the pin and current config disagree —
|
|
478
|
+
// deliberately here, before the gate chain, format-autofix and base-sync,
|
|
479
|
+
// so a wrong base is never merged into the Story branch. `baseConfirmed`
|
|
480
|
+
// gates the base-merge remediation advice further down.
|
|
481
|
+
const { values: runScoped, confirmed: baseConfirmed } =
|
|
482
|
+
await resolveRunScopedConfig({
|
|
483
|
+
provider,
|
|
484
|
+
storyId: options.storyId,
|
|
485
|
+
config,
|
|
486
|
+
progress,
|
|
487
|
+
});
|
|
488
|
+
const baseBranch = runScoped.baseBranch;
|
|
489
|
+
|
|
465
490
|
const worktreePath = resolveWorktreePath({
|
|
466
491
|
cwd: options.cwd,
|
|
467
492
|
config,
|
|
@@ -483,6 +508,7 @@ async function runClosePipeline({
|
|
|
483
508
|
...options,
|
|
484
509
|
config,
|
|
485
510
|
baseBranch,
|
|
511
|
+
baseConfirmed,
|
|
486
512
|
storyBranch,
|
|
487
513
|
provider,
|
|
488
514
|
worktreePath,
|
|
@@ -493,7 +519,7 @@ async function runClosePipeline({
|
|
|
493
519
|
leaseArgs,
|
|
494
520
|
);
|
|
495
521
|
|
|
496
|
-
const { prUrl, prNumber } = await releaseLeaseOnBlock(
|
|
522
|
+
const { prUrl, prNumber, alreadyMerged } = await releaseLeaseOnBlock(
|
|
497
523
|
() =>
|
|
498
524
|
openAndReviewPr({
|
|
499
525
|
cwd: options.cwd,
|
|
@@ -527,20 +553,31 @@ async function runClosePipeline({
|
|
|
527
553
|
WorktreeManager,
|
|
528
554
|
});
|
|
529
555
|
setPhase('auto-merge');
|
|
556
|
+
// An already-merged head PR (Story #4873) has nothing to arm — `gh pr merge`
|
|
557
|
+
// against it fails, which would report the arm as a fault and block a Story
|
|
558
|
+
// whose work is already on the base branch. Skip the arm and let the confirm
|
|
559
|
+
// phase observe the merge that happened.
|
|
530
560
|
const {
|
|
531
561
|
autoMergeEnabled,
|
|
532
562
|
autoMergeReason,
|
|
533
563
|
localCleanupDeferred,
|
|
534
564
|
directMerged,
|
|
535
|
-
} =
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
565
|
+
} = alreadyMerged
|
|
566
|
+
? {
|
|
567
|
+
autoMergeEnabled: true,
|
|
568
|
+
autoMergeReason: null,
|
|
569
|
+
localCleanupDeferred: false,
|
|
570
|
+
directMerged: false,
|
|
571
|
+
}
|
|
572
|
+
: await runAutoMergePhase({
|
|
573
|
+
cwd: options.cwd,
|
|
574
|
+
prNumber,
|
|
575
|
+
prUrl,
|
|
576
|
+
noAutoMerge: options.noAutoMerge,
|
|
577
|
+
autoMergePolicy: getCiDelivery(config).autoMerge,
|
|
578
|
+
gh: injectedGh,
|
|
579
|
+
progress,
|
|
580
|
+
});
|
|
544
581
|
await flipLabelAndNotify({
|
|
545
582
|
provider,
|
|
546
583
|
notifyFn: injectedNotify,
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/reserved-test-ids.js — the framework's one declaration of which
|
|
3
|
+
* Epic / Story ids are **synthetic** (Story #4892).
|
|
4
|
+
*
|
|
5
|
+
* The band `999000–999999` has been reserved for test fixtures since the
|
|
6
|
+
* post-test temp reaper shipped, but it lived as a private regexp inside
|
|
7
|
+
* `cleanup-repo-test-temp.js` (`/^epic-999\d{3}$/`) and nothing else could
|
|
8
|
+
* consult it. Two independent surfaces need the same rule, and spelling it
|
|
9
|
+
* twice is how they would drift:
|
|
10
|
+
*
|
|
11
|
+
* 1. **Write side.** A test that spawns a real CLI at the repository root
|
|
12
|
+
* inherits the real state directory, so its fixture telemetry can land
|
|
13
|
+
* in the operator's live `temp/` signals tree. The post-run guard in
|
|
14
|
+
* `check-test-temp-hygiene.js` fails the run when a stream file owned by
|
|
15
|
+
* a reserved id survives there.
|
|
16
|
+
* 2. **Read side.** The retro composer renders the distinct Stories a
|
|
17
|
+
* friction bucket spans as recurrence evidence in a body that is filed
|
|
18
|
+
* as a real GitHub issue. A reserved id is synthetic by construction, so
|
|
19
|
+
* it can never be resolved to a real issue and must never be published
|
|
20
|
+
* as evidence (issue #4870 named `#999999` as a contributing Story).
|
|
21
|
+
*
|
|
22
|
+
* Deliberately dependency-free: the write side is a CLI guard and the read
|
|
23
|
+
* side is a pure composer, so the shared rule must not drag config, fs, or
|
|
24
|
+
* git resolution into either.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/** First id in the reserved test-fixture band. */
|
|
28
|
+
const RESERVED_TEST_ID_MIN = 999000;
|
|
29
|
+
|
|
30
|
+
/** Last id in the reserved test-fixture band. */
|
|
31
|
+
const RESERVED_TEST_ID_MAX = 999999;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Human-readable band, for guard failure messages that have to tell an
|
|
35
|
+
* operator which ids they may not use for real work.
|
|
36
|
+
*/
|
|
37
|
+
export const RESERVED_TEST_ID_BAND = `${RESERVED_TEST_ID_MIN}–${RESERVED_TEST_ID_MAX}`;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Is `id` inside the reserved test-fixture band?
|
|
41
|
+
*
|
|
42
|
+
* Exact-band membership, because this is the predicate the temp reaper and
|
|
43
|
+
* the pollution guard share: both classify an on-disk directory that a test
|
|
44
|
+
* created, and over-reaching (treating every large id as reserved) would let
|
|
45
|
+
* one of them delete or condemn a directory the band never claimed.
|
|
46
|
+
*
|
|
47
|
+
* @param {unknown} id
|
|
48
|
+
* @returns {boolean}
|
|
49
|
+
*/
|
|
50
|
+
export function isReservedTestId(id) {
|
|
51
|
+
return (
|
|
52
|
+
Number.isInteger(id) &&
|
|
53
|
+
id >= RESERVED_TEST_ID_MIN &&
|
|
54
|
+
id <= RESERVED_TEST_ID_MAX
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* May `id` be published as ticket evidence in operator-visible output?
|
|
60
|
+
*
|
|
61
|
+
* The floor is the reserved band, not the band itself: an id at or above
|
|
62
|
+
* `RESERVED_TEST_ID_MIN` is synthetic by construction (the framework reserves
|
|
63
|
+
* that space for fixtures, and no repository has minted that many issues), so
|
|
64
|
+
* nothing at or beyond the floor can be resolved to a real issue. A
|
|
65
|
+
* non-integer or non-positive id is not a ticket reference at all.
|
|
66
|
+
*
|
|
67
|
+
* This is a plausibility bound, not an existence probe: it is the strongest
|
|
68
|
+
* statement a caller with no ticket-provider access can make, and it is what
|
|
69
|
+
* keeps the invariant true at every call site instead of only where somebody
|
|
70
|
+
* remembered to wire a probe.
|
|
71
|
+
*
|
|
72
|
+
* @param {unknown} id
|
|
73
|
+
* @returns {boolean}
|
|
74
|
+
*/
|
|
75
|
+
export function isPublishableTicketId(id) {
|
|
76
|
+
return Number.isInteger(id) && id > 0 && id < RESERVED_TEST_ID_MIN;
|
|
77
|
+
}
|
|
@@ -9,16 +9,24 @@
|
|
|
9
9
|
* - DEFAULT_REFINE_THRESHOLD — seed length below which refinement
|
|
10
10
|
* is auto-suggested.
|
|
11
11
|
* - REQUIRED_SECTIONS — canonical section headings the body
|
|
12
|
-
* must carry
|
|
13
|
-
* /
|
|
12
|
+
* must carry itself. Story #4874: the
|
|
13
|
+
* `## Acceptance` / `## Verify` sections
|
|
14
|
+
* are deliberately NOT among them —
|
|
15
|
+
* those lists are the ticket's top-level
|
|
16
|
+
* machine contract and persist
|
|
17
|
+
* synthesizes their sections, exactly as
|
|
18
|
+
* the story-author prompt instructs.
|
|
14
19
|
* - rankDuplicateCandidates({ seed, openStories, maxResults })
|
|
15
20
|
* — Jaccard-overlap ranking of open
|
|
16
21
|
* Stories whose titles fuzzy-match
|
|
17
22
|
* the seed.
|
|
18
|
-
* - validateStoryBody(body)
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
23
|
+
* - validateStoryBody(body, contract)
|
|
24
|
+
* — schema-light shape check against the
|
|
25
|
+
* canonical Story-body contract: `##
|
|
26
|
+
* Goal` / `## Changes` present, no
|
|
27
|
+
* `Epic:` reference, and an
|
|
28
|
+
* acceptance/verify contract resolvable
|
|
29
|
+
* from the top-level arrays or the body.
|
|
22
30
|
* - buildContextEnvelope(opts) — assemble the context envelope the
|
|
23
31
|
* host LLM consumes.
|
|
24
32
|
*/
|
|
@@ -26,21 +34,25 @@
|
|
|
26
34
|
import { readFile } from 'node:fs/promises';
|
|
27
35
|
import path from 'node:path';
|
|
28
36
|
import { overlapScore, tokenize } from './duplicate-search.js';
|
|
37
|
+
import { parse as parseStoryBody } from './story-body/story-body.js';
|
|
29
38
|
|
|
30
39
|
export const DEFAULT_REFINE_THRESHOLD = 200;
|
|
31
40
|
export const DEFAULT_DUPLICATE_MAX_RESULTS = 5;
|
|
32
41
|
export const DEFAULT_DUPLICATE_MIN_SCORE = 0.15;
|
|
33
42
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
43
|
+
/**
|
|
44
|
+
* The sections the authored body must carry itself. Story #4874 reconciled
|
|
45
|
+
* this list with the story-author prompt: the prompt tells the author to
|
|
46
|
+
* write `acceptance[]` / `verify[]` **once** at the ticket's top level and
|
|
47
|
+
* omit the matching body sections, so demanding those sections here made a
|
|
48
|
+
* prompt-faithful body unpersistable and cost a re-author round.
|
|
49
|
+
*/
|
|
50
|
+
export const REQUIRED_SECTIONS = ['Goal', 'Changes'];
|
|
51
|
+
|
|
52
|
+
/** The contract lists persist synthesizes into the body from the top level. */
|
|
53
|
+
const CONTRACT_FIELDS = /** @type {const} */ (['acceptance', 'verify']);
|
|
40
54
|
|
|
41
55
|
const EPIC_REF_PATTERN = /^\s*Epic:\s*#\d+/m;
|
|
42
|
-
const AC_HEADING_PATTERN = /^##\s+Acceptance Criteria\s*$/m;
|
|
43
|
-
const CHECKLIST_PATTERN = /^\s*-\s*\[\s?\]/m;
|
|
44
56
|
|
|
45
57
|
/**
|
|
46
58
|
* Rank open Stories by title-overlap with the seed. Reuses the same
|
|
@@ -115,54 +127,137 @@ export function shouldRefine({
|
|
|
115
127
|
}
|
|
116
128
|
|
|
117
129
|
/**
|
|
118
|
-
*
|
|
119
|
-
*
|
|
120
|
-
*
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
*
|
|
130
|
+
* Collect the violations for one contract list (`acceptance` / `verify`).
|
|
131
|
+
*
|
|
132
|
+
* The list may be authored at the ticket's top level (the shape the
|
|
133
|
+
* story-author prompt prescribes) or written into the body as a section —
|
|
134
|
+
* either satisfies the contract, and the top level is preferred because
|
|
135
|
+
* persist synthesizes the section from it. Only two shapes are violations:
|
|
136
|
+
* the list is nowhere, or it is in both places and the two disagree (fail
|
|
137
|
+
* closed rather than guess which is authoritative, mirroring
|
|
138
|
+
* `plan-persist`'s `syncContractFieldFromTopLevel`).
|
|
139
|
+
*
|
|
140
|
+
* @param {'acceptance'|'verify'} field
|
|
141
|
+
* @param {unknown} bodyList The parsed body's section entries.
|
|
142
|
+
* @param {unknown} topLevel The ticket's top-level array.
|
|
143
|
+
* @returns {string[]}
|
|
144
|
+
*/
|
|
145
|
+
function collectContractErrors(field, bodyList, topLevel) {
|
|
146
|
+
const inBody = (Array.isArray(bodyList) ? bodyList : []).map(String);
|
|
147
|
+
const inTicket = (Array.isArray(topLevel) ? topLevel : []).map(String);
|
|
148
|
+
if (inBody.length === 0 && inTicket.length === 0) {
|
|
149
|
+
return [
|
|
150
|
+
`${field} must list at least one entry — author it as the ticket's top-level ${field}[] array; persist synthesizes the "## ${field[0].toUpperCase()}${field.slice(1)}" section from it`,
|
|
151
|
+
];
|
|
152
|
+
}
|
|
153
|
+
if (
|
|
154
|
+
inBody.length > 0 &&
|
|
155
|
+
inTicket.length > 0 &&
|
|
156
|
+
(inBody.length !== inTicket.length ||
|
|
157
|
+
inBody.some((v, i) => v !== inTicket[i]))
|
|
158
|
+
) {
|
|
159
|
+
return [
|
|
160
|
+
`${field} disagrees between the body section and the top-level ${field}[] array — author it once, at the top level`,
|
|
161
|
+
];
|
|
162
|
+
}
|
|
163
|
+
return [];
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Schema-light validator for a standalone-Story body, stated against the
|
|
168
|
+
* same authoring shape the story-author prompt prescribes (Story #4874).
|
|
169
|
+
*
|
|
170
|
+
* It parses the body with the canonical Story-body parser and asserts:
|
|
171
|
+
* a non-empty `## Goal`, at least one `## Changes` entry, no leaking
|
|
172
|
+
* `Epic: #N` reference (the standalone contract), and an acceptance +
|
|
173
|
+
* verify contract resolvable from the ticket's top-level arrays **or** the
|
|
174
|
+
* body's own sections. It deliberately does not demand the `## Acceptance`
|
|
175
|
+
* / `## Verify` sections of the author — persist synthesizes them.
|
|
124
176
|
*
|
|
125
177
|
* @param {string} body
|
|
178
|
+
* @param {{ acceptance?: string[], verify?: string[] }} [contract]
|
|
179
|
+
* The ticket's top-level contract arrays, when the caller has them.
|
|
126
180
|
* @returns {{ ok:boolean, errors:string[] }}
|
|
127
181
|
*/
|
|
128
|
-
export function validateStoryBody(body) {
|
|
129
|
-
const errors = [];
|
|
182
|
+
export function validateStoryBody(body, contract = {}) {
|
|
130
183
|
if (typeof body !== 'string' || body.trim().length === 0) {
|
|
131
184
|
return { ok: false, errors: ['body is empty'] };
|
|
132
185
|
}
|
|
133
186
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
187
|
+
let parsed;
|
|
188
|
+
try {
|
|
189
|
+
parsed = parseStoryBody(body).body;
|
|
190
|
+
} catch (err) {
|
|
191
|
+
return {
|
|
192
|
+
ok: false,
|
|
193
|
+
errors: [`body is not a parseable Story body: ${err.message}`],
|
|
194
|
+
};
|
|
139
195
|
}
|
|
140
196
|
|
|
197
|
+
const errors = [];
|
|
198
|
+
if (typeof parsed.goal !== 'string' || parsed.goal.trim() === '') {
|
|
199
|
+
errors.push('missing required section: "## Goal"');
|
|
200
|
+
}
|
|
201
|
+
if (!Array.isArray(parsed.changes) || parsed.changes.length === 0) {
|
|
202
|
+
errors.push('missing required section: "## Changes"');
|
|
203
|
+
}
|
|
141
204
|
if (EPIC_REF_PATTERN.test(body)) {
|
|
142
205
|
errors.push(
|
|
143
206
|
'body contains an "Epic: #N" reference — standalone Stories must not link to an Epic',
|
|
144
207
|
);
|
|
145
208
|
}
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
if (acStart !== -1) {
|
|
151
|
-
const rest = body.slice(acStart);
|
|
152
|
-
const nextHeading = rest.slice(1).search(/^##\s+/m);
|
|
153
|
-
const acBlock = nextHeading === -1 ? rest : rest.slice(0, nextHeading + 1);
|
|
154
|
-
if (!CHECKLIST_PATTERN.test(acBlock)) {
|
|
155
|
-
errors.push(
|
|
156
|
-
'Acceptance Criteria section has no unchecked checklist items (`- [ ] ...`)',
|
|
157
|
-
);
|
|
158
|
-
}
|
|
209
|
+
for (const field of CONTRACT_FIELDS) {
|
|
210
|
+
errors.push(
|
|
211
|
+
...collectContractErrors(field, parsed[field], contract?.[field]),
|
|
212
|
+
);
|
|
159
213
|
}
|
|
160
214
|
|
|
161
215
|
return { ok: errors.length === 0, errors };
|
|
162
216
|
}
|
|
163
217
|
|
|
164
|
-
|
|
165
|
-
|
|
218
|
+
/**
|
|
219
|
+
* Render the canonical `## Acceptance` / `## Verify` sections into a body
|
|
220
|
+
* that omits them, from the ticket's top-level arrays (Story #4874).
|
|
221
|
+
*
|
|
222
|
+
* This is the synthesis the story-author prompt promises: the author writes
|
|
223
|
+
* each list once at top level, and the persisted GitHub issue still reads as
|
|
224
|
+
* a complete executable document. A body that already carries a section is
|
|
225
|
+
* returned untouched — `validateStoryBody` has already refused the case
|
|
226
|
+
* where the two disagree.
|
|
227
|
+
*
|
|
228
|
+
* @param {string} body
|
|
229
|
+
* @param {{ acceptance?: string[], verify?: string[] }} [contract]
|
|
230
|
+
* @returns {string}
|
|
231
|
+
*/
|
|
232
|
+
export function synthesizeContractSections(body, contract = {}) {
|
|
233
|
+
let parsed;
|
|
234
|
+
try {
|
|
235
|
+
parsed = parseStoryBody(body).body;
|
|
236
|
+
} catch {
|
|
237
|
+
return body;
|
|
238
|
+
}
|
|
239
|
+
const blocks = [];
|
|
240
|
+
if (
|
|
241
|
+
(parsed.acceptance ?? []).length === 0 &&
|
|
242
|
+
Array.isArray(contract.acceptance) &&
|
|
243
|
+
contract.acceptance.length > 0
|
|
244
|
+
) {
|
|
245
|
+
const items = contract.acceptance
|
|
246
|
+
.map((a, i) => `- [ ] AC-${i + 1}: ${a}`)
|
|
247
|
+
.join('\n');
|
|
248
|
+
blocks.push(`## Acceptance\n${items}`);
|
|
249
|
+
}
|
|
250
|
+
if (
|
|
251
|
+
(parsed.verify ?? []).length === 0 &&
|
|
252
|
+
Array.isArray(contract.verify) &&
|
|
253
|
+
contract.verify.length > 0
|
|
254
|
+
) {
|
|
255
|
+
blocks.push(
|
|
256
|
+
`## Verify\n${contract.verify.map((v) => `- ${v}`).join('\n')}`,
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
if (blocks.length === 0) return body;
|
|
260
|
+
return `${body.trimEnd()}\n\n${blocks.join('\n\n')}\n`;
|
|
166
261
|
}
|
|
167
262
|
|
|
168
263
|
/**
|