mandrel 2.23.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/schemas/story-deliver-terminal.schema.json +6 -1
- 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 +22 -45
- package/.agents/scripts/deliver-recover.js +13 -0
- package/.agents/scripts/lib/audit-suite/lens-diff-floor.js +10 -25
- 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/kinds/maintainability.js +20 -32
- 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/escomplex-ast-compat.js +360 -0
- 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/maintainability-engine.js +83 -11
- package/.agents/scripts/lib/maintainability-unscorable.js +60 -0
- package/.agents/scripts/lib/maintainability-utils.js +14 -5
- package/.agents/scripts/lib/observability/runtime-friction.js +8 -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/diff-magnitude.js +283 -0
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/watcher.js +71 -20
- package/.agents/scripts/lib/orchestration/light-backstop.js +124 -0
- package/.agents/scripts/lib/orchestration/light-escalation.js +248 -0
- package/.agents/scripts/lib/orchestration/light-suitability.js +254 -51
- package/.agents/scripts/lib/orchestration/plan-context.js +12 -13
- 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/post-land.js +70 -2
- package/.agents/scripts/lib/orchestration/single-story-close/phases/pull-request.js +129 -21
- package/.agents/scripts/lib/orchestration/single-story-close/runner.js +73 -18
- 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/lib/workers/maintainability-worker.js +14 -9
- 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-light.md +21 -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 +38 -0
- package/lib/cli/update.js +14 -11
- package/lib/cli/version-check.js +9 -1
- package/package.json +1 -1
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* Story is already `agent::done`, so confirm short-circuits `noop` and the
|
|
12
12
|
* capture's `action === 'done'` gate never opens).
|
|
13
13
|
*
|
|
14
|
-
* This module folds
|
|
14
|
+
* This module folds every such step into one phase both landing surfaces
|
|
15
15
|
* reach — the in-close wait (`phases/confirm-merge.js`) and the standalone
|
|
16
16
|
* `single-story-confirm-merge.js` CLI — so the two paths cannot diverge and
|
|
17
17
|
* "landed" means the whole tail ran.
|
|
@@ -45,6 +45,7 @@ import {
|
|
|
45
45
|
planFastForward as defaultPlanFastForward,
|
|
46
46
|
} from '../../git-cleanup/phases/fast-forward.js';
|
|
47
47
|
import { reassertStatusColumn as defaultReassertStatusColumn } from '../../reassert-status-column.js';
|
|
48
|
+
import { releaseStoryLease as defaultReleaseStoryLease } from '../../single-story-lease-guard.js';
|
|
48
49
|
import { captureStoryFollowUps as defaultCaptureStoryFollowUps } from '../../story-follow-ups.js';
|
|
49
50
|
|
|
50
51
|
/**
|
|
@@ -241,6 +242,54 @@ async function stepTempPurge({ storyId, config, purgeStoryTempArtifactsFn }) {
|
|
|
241
242
|
return { ok: errors.length === 0, detail: errors.join('; ') || null };
|
|
242
243
|
}
|
|
243
244
|
|
|
245
|
+
/**
|
|
246
|
+
* Release the Story's assignee-lease now that the merge is confirmed
|
|
247
|
+
* (Story #4860).
|
|
248
|
+
*
|
|
249
|
+
* The close used to release here-ish — immediately after the PR was opened
|
|
250
|
+
* and armed, before the merge wait ran — so a Story's ticket read *unassigned*
|
|
251
|
+
* for the entire window its PR was open, and indefinitely on the
|
|
252
|
+
* operator-merge path where a human owns the land. The claim is the only
|
|
253
|
+
* ticket-visible record of who owns in-flight work, so dropping it at PR
|
|
254
|
+
* creation is dropping it at exactly the wrong moment.
|
|
255
|
+
*
|
|
256
|
+
* It lives in the tail rather than in either landing surface because the tail
|
|
257
|
+
* is the one seam **both** reach — the in-close merge wait
|
|
258
|
+
* (`phases/confirm-merge.js`) and the standalone
|
|
259
|
+
* `single-story-confirm-merge.js` CLI. Homing it anywhere else re-opens the
|
|
260
|
+
* surface divergence this module exists to close.
|
|
261
|
+
*
|
|
262
|
+
* Idempotent by construction: `releaseStoryLease` no-ops when the resolved
|
|
263
|
+
* operator is no longer the recorded owner, so a re-run (or the belated
|
|
264
|
+
* manual confirm that backfills an already-`agent::done` Story) reports the
|
|
265
|
+
* no-op reason rather than yanking a claim someone else has since taken.
|
|
266
|
+
*
|
|
267
|
+
* A `released: false` no-op is **not** a step failure: an already-released
|
|
268
|
+
* ticket is the desired end state, and reporting it as `false` would train
|
|
269
|
+
* readers to ignore the field. Only a throw — an unreachable API, an
|
|
270
|
+
* unresolvable operator identity — degrades the step, and like every tail
|
|
271
|
+
* step that degrades the report, never the land.
|
|
272
|
+
*/
|
|
273
|
+
async function stepLeaseRelease({
|
|
274
|
+
storyId,
|
|
275
|
+
provider,
|
|
276
|
+
config,
|
|
277
|
+
progress,
|
|
278
|
+
releaseStoryLeaseFn,
|
|
279
|
+
}) {
|
|
280
|
+
const outcome = await releaseStoryLeaseFn({ provider, storyId, config });
|
|
281
|
+
progress?.(
|
|
282
|
+
'POST-LAND',
|
|
283
|
+
outcome?.released
|
|
284
|
+
? `🔓 Story #${storyId} lease released (merge confirmed).`
|
|
285
|
+
: `⏭ Story #${storyId} lease not released (${outcome?.reason ?? 'unknown'}).`,
|
|
286
|
+
);
|
|
287
|
+
return {
|
|
288
|
+
ok: true,
|
|
289
|
+
detail: outcome?.released ? null : (outcome?.reason ?? null),
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
|
|
244
293
|
/**
|
|
245
294
|
* Run the whole post-land tail. Never throws.
|
|
246
295
|
*
|
|
@@ -280,7 +329,8 @@ async function stepTempPurge({ storyId, config, purgeStoryTempArtifactsFn }) {
|
|
|
280
329
|
* @param {Function} [args.executeFastForwardFn] Test seam.
|
|
281
330
|
* @param {Function} [args.acquireLockWithWaitFn] Test seam.
|
|
282
331
|
* @param {Function} [args.purgeStoryTempArtifactsFn] Test seam.
|
|
283
|
-
* @
|
|
332
|
+
* @param {Function} [args.releaseStoryLeaseFn] Test seam.
|
|
333
|
+
* @returns {Promise<{ followUps: boolean, statusResync: boolean, refCleanup: boolean, baseFastForward: boolean, tempPurge: boolean, leaseRelease: boolean, details: Record<string, string|null> }>}
|
|
284
334
|
*/
|
|
285
335
|
export async function runPostLandTail({
|
|
286
336
|
storyId,
|
|
@@ -299,6 +349,7 @@ export async function runPostLandTail({
|
|
|
299
349
|
executeFastForwardFn = defaultExecuteFastForward,
|
|
300
350
|
acquireLockWithWaitFn = defaultAcquireLockWithWait,
|
|
301
351
|
purgeStoryTempArtifactsFn = defaultPurgeStoryTempArtifacts,
|
|
352
|
+
releaseStoryLeaseFn = defaultReleaseStoryLease,
|
|
302
353
|
}) {
|
|
303
354
|
progress?.('POST-LAND', `🧾 Running land tail for Story #${storyId}...`);
|
|
304
355
|
|
|
@@ -401,18 +452,35 @@ export async function runPostLandTail({
|
|
|
401
452
|
{ name: 'temp purge', progress },
|
|
402
453
|
);
|
|
403
454
|
|
|
455
|
+
// Story #4860 — the merge is confirmed, so the operator's claim on this
|
|
456
|
+
// Story has finally done its job. Released here rather than at PR creation
|
|
457
|
+
// so the ticket stays assigned for the whole time its PR is open.
|
|
458
|
+
const leaseRelease = await step(
|
|
459
|
+
() =>
|
|
460
|
+
stepLeaseRelease({
|
|
461
|
+
storyId,
|
|
462
|
+
provider,
|
|
463
|
+
config,
|
|
464
|
+
progress,
|
|
465
|
+
releaseStoryLeaseFn,
|
|
466
|
+
}),
|
|
467
|
+
{ name: 'lease release', progress },
|
|
468
|
+
);
|
|
469
|
+
|
|
404
470
|
const tail = {
|
|
405
471
|
followUps: followUps.ok,
|
|
406
472
|
statusResync: statusResync.ok,
|
|
407
473
|
refCleanup: refCleanup.ok,
|
|
408
474
|
baseFastForward: baseFastForward.ok,
|
|
409
475
|
tempPurge: tempPurge.ok,
|
|
476
|
+
leaseRelease: leaseRelease.ok,
|
|
410
477
|
details: {
|
|
411
478
|
followUps: followUps.detail,
|
|
412
479
|
statusResync: statusResync.detail,
|
|
413
480
|
refCleanup: refCleanup.detail,
|
|
414
481
|
baseFastForward: baseFastForward.detail,
|
|
415
482
|
tempPurge: tempPurge.detail,
|
|
483
|
+
leaseRelease: leaseRelease.detail,
|
|
416
484
|
},
|
|
417
485
|
};
|
|
418
486
|
const degraded = Object.entries(tail)
|
|
@@ -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({
|
|
@@ -353,8 +363,8 @@ function closeResult({
|
|
|
353
363
|
note: waitedForMerge
|
|
354
364
|
? 'Close-and-land: PR merge confirmed. Story flipped agent::closing → agent::done, the issue closed (confirmStoryMerged), and the post-land tail ran.'
|
|
355
365
|
: autoMergeEnabled
|
|
356
|
-
? 'PR open against baseBranch with auto-merge enabled. Story rests at agent::closing (issue stays OPEN). GitHub will squash-merge when required checks pass; run single-story-confirm-merge.js after the merge confirms to flip agent::done and close the issue (the Closes #<id> footer also auto-closes it).'
|
|
357
|
-
: 'PR open against baseBranch. Story rests at agent::closing (issue stays OPEN). Operator merges via GitHub UI; run single-story-confirm-merge.js after the merge confirms to flip agent::done (the Closes #<id> footer also auto-closes the issue).',
|
|
366
|
+
? 'PR open against baseBranch with auto-merge enabled. Story rests at agent::closing (issue stays OPEN and assigned to the operator). GitHub will squash-merge when required checks pass; run single-story-confirm-merge.js after the merge confirms to flip agent::done, release the lease, and close the issue (the Closes #<id> footer also auto-closes it).'
|
|
367
|
+
: 'PR open against baseBranch. Story rests at agent::closing (issue stays OPEN and assigned to the operator). Operator merges via GitHub UI; run single-story-confirm-merge.js after the merge confirms to flip agent::done and release the lease (the Closes #<id> footer also auto-closes the issue).',
|
|
358
368
|
};
|
|
359
369
|
}
|
|
360
370
|
|
|
@@ -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,
|
|
@@ -552,7 +589,18 @@ async function runClosePipeline({
|
|
|
552
589
|
config,
|
|
553
590
|
progress,
|
|
554
591
|
});
|
|
555
|
-
|
|
592
|
+
// Story #4860 — the clean-path lease release USED to sit here, immediately
|
|
593
|
+
// after the arm and the `agent::closing` flip. That dropped the operator's
|
|
594
|
+
// claim the moment the PR opened, so a ticket read unassigned for the whole
|
|
595
|
+
// time its PR was in flight — and forever on the operator-merge path, where
|
|
596
|
+
// nothing downstream ever re-claimed it. The release now belongs to the
|
|
597
|
+
// post-land tail, which runs only on a CONFIRMED merge and which both
|
|
598
|
+
// landing surfaces reach. Every non-merged ending below — the
|
|
599
|
+
// `merge.unlanded` block, an exhausted wait budget, `--no-wait-merge`,
|
|
600
|
+
// `--no-auto-merge` — deliberately RETAINS the claim: the PR is open and the
|
|
601
|
+
// work still has an owner. The only releases that survive here are
|
|
602
|
+
// `releaseLeaseOnBlock`'s two throwing exits above, which fire before the PR
|
|
603
|
+
// is ever armed (Story #4257's hand-off property).
|
|
556
604
|
|
|
557
605
|
// Close-and-land (Story #4428; default since `delivery.routing.closeAndLand`
|
|
558
606
|
// — Story #4539): poll the just-armed PR to merge confirmation, or block
|
|
@@ -625,7 +673,11 @@ async function runClosePipeline({
|
|
|
625
673
|
autoMergeEnabled,
|
|
626
674
|
autoMergeReason,
|
|
627
675
|
worktreeReaped,
|
|
628
|
-
|
|
676
|
+
// Story #4860 — the release is a post-land tail step now, so the tail's
|
|
677
|
+
// own per-step boolean IS the answer. A wait that ended anything other
|
|
678
|
+
// than landed never ran the tail, and correctly reports `false`: the
|
|
679
|
+
// claim is still held, by design.
|
|
680
|
+
leaseReleased: waitOutcome.tail?.leaseRelease === true,
|
|
629
681
|
localCleanupDeferred,
|
|
630
682
|
directMerged,
|
|
631
683
|
waitedForMerge: true,
|
|
@@ -663,7 +715,10 @@ async function runClosePipeline({
|
|
|
663
715
|
autoMergeEnabled,
|
|
664
716
|
autoMergeReason,
|
|
665
717
|
worktreeReaped,
|
|
666
|
-
|
|
718
|
+
// Story #4860 — this is the no-wait ending: the PR is open and a human
|
|
719
|
+
// owns the merge, so the Story stays assigned until the confirm-merge
|
|
720
|
+
// surface lands it and runs the tail.
|
|
721
|
+
leaseReleased: false,
|
|
667
722
|
localCleanupDeferred,
|
|
668
723
|
directMerged,
|
|
669
724
|
});
|
|
@@ -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
|
+
}
|