mandrel 1.76.0 → 1.77.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/docs/configuration.md +2 -2
- package/.agents/schemas/agentrc.schema.json +1 -1
- package/.agents/schemas/dispatch-manifest.json +1 -1
- package/.agents/schemas/validation-evidence.schema.json +2 -1
- package/.agents/scripts/audit-to-stories.js +43 -1
- package/.agents/scripts/epic-deliver-prepare.js +31 -0
- package/.agents/scripts/evidence-gate.js +48 -12
- package/.agents/scripts/lib/audit-to-stories/build-story-body.js +141 -34
- package/.agents/scripts/lib/cli-args.js +6 -0
- package/.agents/scripts/lib/close-validation/runner.js +25 -8
- package/.agents/scripts/lib/config/temp-paths.js +1 -1
- package/.agents/scripts/lib/config/worktree-isolation.js +18 -3
- package/.agents/scripts/lib/config-resolver.js +4 -1
- package/.agents/scripts/lib/config-settings-schema-delivery.js +1 -1
- package/.agents/scripts/lib/git-branch-lifecycle.js +90 -0
- package/.agents/scripts/lib/orchestration/auto-merge-cwd.js +128 -0
- package/.agents/scripts/lib/orchestration/column-sync.js +88 -9
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/automerge-armer.js +20 -2
- package/.agents/scripts/lib/orchestration/project-meta-cache.js +238 -0
- package/.agents/scripts/lib/orchestration/reassert-status-column.js +3 -1
- package/.agents/scripts/lib/orchestration/single-story-close/phases/auto-merge.js +25 -2
- package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +80 -14
- package/.agents/scripts/lib/orchestration/single-story-close/runner.js +74 -25
- package/.agents/scripts/lib/orchestration/story-close/phases/locked-pipeline.js +10 -1
- package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +48 -1
- package/.agents/scripts/lib/orchestration/ticket-validator-sizing.js +148 -4
- package/.agents/scripts/lib/orchestration/ticketing/transition.js +8 -1
- package/.agents/scripts/lib/story-body/story-body.js +76 -7
- package/.agents/scripts/lib/story-init/branch-initializer.js +29 -43
- package/.agents/scripts/lib/story-init/hierarchy-tracer.js +25 -4
- package/.agents/scripts/lib/story-init/task-graph-builder.js +22 -12
- package/.agents/scripts/lib/templates/decomposer-prompts.js +23 -0
- package/.agents/scripts/lib/validation-evidence.js +63 -25
- package/.agents/scripts/lib/worktree/node-modules-strategy.js +239 -31
- package/.agents/scripts/resync-status-column.js +5 -0
- package/.agents/scripts/run-coverage.js +85 -45
- package/.agents/scripts/single-story-init.js +22 -29
- package/.agents/scripts/story-init.js +38 -63
- package/.agents/scripts/story-phase.js +46 -4
- package/.agents/skills/core/epic-plan-decompose-author/SKILL.md +5 -3
- package/.agents/workflows/helpers/acceptance-self-eval.md +27 -0
- package/.agents/workflows/helpers/deliver-epic.md +19 -2
- package/.agents/workflows/helpers/epic-deliver-story.md +50 -14
- package/.agents/workflows/helpers/single-story-deliver.md +12 -0
- package/docs/CHANGELOG.md +33 -0
- package/package.json +1 -1
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* project-meta-cache — on-disk cache for repo-invariant GitHub Projects v2
|
|
3
|
+
* board metadata (Story #4252).
|
|
4
|
+
*
|
|
5
|
+
* Every `agent::*` label flip routes through `transitionTicketState` →
|
|
6
|
+
* `ColumnSync.sync`, which must resolve the board's `{ projectId, fieldId,
|
|
7
|
+
* options }` before issuing the Status mutation. Single-story delivery
|
|
8
|
+
* performs each flip in a *separate cold CLI process* (init → executing,
|
|
9
|
+
* close → closing, confirm-merge → done, plus `resync-status-column`), so
|
|
10
|
+
* the in-process `_meta` cache is always empty and every flip re-pays
|
|
11
|
+
* `resolveProjectMeta` + an item-id lookup + the mutation (~3 GraphQL
|
|
12
|
+
* round-trips).
|
|
13
|
+
*
|
|
14
|
+
* The board metadata is **repo-invariant** — it changes only when the
|
|
15
|
+
* Status single-select field is reconfigured — so it is a prime candidate
|
|
16
|
+
* for an on-disk cache that survives across the cold processes. This module
|
|
17
|
+
* persists the resolved metadata to a small JSON file under the resolved
|
|
18
|
+
* `tempRoot`, keyed by `owner/projectNumber`, carrying a TTL. Correctness
|
|
19
|
+
* is bounded by "the mutation still validates against the live board": a
|
|
20
|
+
* stale entry at worst causes one failed mutation, which the caller treats
|
|
21
|
+
* as a signal to invalidate the entry and re-resolve — never a wrong write.
|
|
22
|
+
*
|
|
23
|
+
* Layout: `<tempRoot>/cache/project-meta.json`, a single JSON object keyed
|
|
24
|
+
* by `<owner>/<projectNumber>`:
|
|
25
|
+
*
|
|
26
|
+
* {
|
|
27
|
+
* "dsj1984/1": {
|
|
28
|
+
* "cachedAt": 1718000000000,
|
|
29
|
+
* "projectId": "PVT_…",
|
|
30
|
+
* "fieldId": "PVTSSF_…",
|
|
31
|
+
* "options": { "Todo": "abc", "In Progress": "def", "Done": "ghi" }
|
|
32
|
+
* }
|
|
33
|
+
* }
|
|
34
|
+
*
|
|
35
|
+
* The `options` map is serialised as a plain object and re-hydrated into a
|
|
36
|
+
* `Map<name, id>` on read so the ColumnSync call site is identical whether
|
|
37
|
+
* the metadata came from the disk cache or a live resolve.
|
|
38
|
+
*
|
|
39
|
+
* The cache file lives under `tempRoot`, which is gitignored
|
|
40
|
+
* (`temp/` in `.gitignore`), so it introduces no new tracked artifact.
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
import fs from 'node:fs';
|
|
44
|
+
import path from 'node:path';
|
|
45
|
+
import { anchorTempRoot, tempRootFrom } from '../config/temp-paths.js';
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Default time-to-live for a cached board-metadata entry, in milliseconds.
|
|
49
|
+
* Board metadata is repo-invariant (it only changes on a manual Status
|
|
50
|
+
* field reconfiguration), so a generous TTL is safe — and a stale entry is
|
|
51
|
+
* self-healing via invalidate-on-error regardless. One hour comfortably
|
|
52
|
+
* spans a single `/single-story-deliver` run's four cold flips while still
|
|
53
|
+
* forcing a periodic re-resolve.
|
|
54
|
+
*/
|
|
55
|
+
const DEFAULT_META_TTL_MS = 60 * 60 * 1000;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Resolve the path to the on-disk project-meta cache file under the
|
|
59
|
+
* (anchored) resolved `tempRoot`. A relative `tempRoot` is anchored to the
|
|
60
|
+
* main checkout root so a worktree child and the main-checkout host
|
|
61
|
+
* converge on the same file (Story #3900 semantics, reused here).
|
|
62
|
+
*
|
|
63
|
+
* @param {object} [config] Optional resolved config bag. Omitted → framework
|
|
64
|
+
* default (`temp`).
|
|
65
|
+
* @returns {string}
|
|
66
|
+
*/
|
|
67
|
+
function projectMetaCachePath(config) {
|
|
68
|
+
return path.join(
|
|
69
|
+
anchorTempRoot(tempRootFrom(config)),
|
|
70
|
+
'cache',
|
|
71
|
+
'project-meta.json',
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Build the per-board cache key from `(owner, projectNumber)`.
|
|
77
|
+
*
|
|
78
|
+
* @param {string|null|undefined} owner
|
|
79
|
+
* @param {number|string|null|undefined} projectNumber
|
|
80
|
+
* @returns {string|null} `<owner>/<projectNumber>`, or null when either part
|
|
81
|
+
* is missing (an un-keyable board — caller skips the cache).
|
|
82
|
+
*/
|
|
83
|
+
function projectMetaCacheKey(owner, projectNumber) {
|
|
84
|
+
if (!owner || projectNumber === null || projectNumber === undefined) {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
return `${owner}/${projectNumber}`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Read the entire cache file as a plain object. Returns an empty object on
|
|
92
|
+
* any read/parse failure (missing file, malformed JSON) so the caller treats
|
|
93
|
+
* a corrupt cache as a cold miss rather than throwing.
|
|
94
|
+
*
|
|
95
|
+
* @param {string} filePath
|
|
96
|
+
* @returns {Record<string, object>}
|
|
97
|
+
*/
|
|
98
|
+
function readCacheFile(filePath) {
|
|
99
|
+
try {
|
|
100
|
+
const raw = fs.readFileSync(filePath, 'utf8');
|
|
101
|
+
const parsed = JSON.parse(raw);
|
|
102
|
+
return parsed && typeof parsed === 'object' ? parsed : {};
|
|
103
|
+
} catch {
|
|
104
|
+
return {};
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Read a cached metadata entry for `(owner, projectNumber)`.
|
|
110
|
+
*
|
|
111
|
+
* Returns the re-hydrated `{ projectId, fieldId, options: Map }` descriptor
|
|
112
|
+
* when a fresh (within TTL) entry exists, or `null` on a miss / expired /
|
|
113
|
+
* malformed entry. Never throws — a read failure is a cache miss.
|
|
114
|
+
*
|
|
115
|
+
* @param {{
|
|
116
|
+
* owner?: string|null,
|
|
117
|
+
* projectNumber?: number|string|null,
|
|
118
|
+
* config?: object,
|
|
119
|
+
* ttlMs?: number,
|
|
120
|
+
* now?: number,
|
|
121
|
+
* }} opts
|
|
122
|
+
* @returns {{ projectId: string, fieldId: string, options: Map<string,string> } | null}
|
|
123
|
+
*/
|
|
124
|
+
export function readProjectMetaCache(opts = {}) {
|
|
125
|
+
const key = projectMetaCacheKey(opts.owner, opts.projectNumber);
|
|
126
|
+
if (!key) return null;
|
|
127
|
+
const ttlMs = opts.ttlMs ?? DEFAULT_META_TTL_MS;
|
|
128
|
+
const now = opts.now ?? Date.now();
|
|
129
|
+
|
|
130
|
+
const filePath = projectMetaCachePath(opts.config);
|
|
131
|
+
const store = readCacheFile(filePath);
|
|
132
|
+
const entry = store[key];
|
|
133
|
+
if (!entry || typeof entry !== 'object') return null;
|
|
134
|
+
|
|
135
|
+
const { cachedAt, projectId, fieldId, options } = entry;
|
|
136
|
+
if (
|
|
137
|
+
typeof cachedAt !== 'number' ||
|
|
138
|
+
typeof projectId !== 'string' ||
|
|
139
|
+
typeof fieldId !== 'string' ||
|
|
140
|
+
!options ||
|
|
141
|
+
typeof options !== 'object'
|
|
142
|
+
) {
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
// TTL check — an expired entry is a miss so the caller re-resolves.
|
|
146
|
+
if (now - cachedAt > ttlMs) return null;
|
|
147
|
+
|
|
148
|
+
return {
|
|
149
|
+
projectId,
|
|
150
|
+
fieldId,
|
|
151
|
+
options: new Map(Object.entries(options)),
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Persist a resolved metadata descriptor for `(owner, projectNumber)`.
|
|
157
|
+
*
|
|
158
|
+
* The `options` value may be a `Map<name,id>` or a plain object; it is
|
|
159
|
+
* normalised to a plain object on disk. Best-effort: any write failure is
|
|
160
|
+
* swallowed (the cache is a pure optimisation — a failed write just means
|
|
161
|
+
* the next process re-resolves). Writes the rest of the store back intact so
|
|
162
|
+
* sibling board entries are preserved.
|
|
163
|
+
*
|
|
164
|
+
* @param {{
|
|
165
|
+
* owner?: string|null,
|
|
166
|
+
* projectNumber?: number|string|null,
|
|
167
|
+
* meta: { projectId: string, fieldId: string, options: Map<string,string>|Record<string,string> },
|
|
168
|
+
* config?: object,
|
|
169
|
+
* now?: number,
|
|
170
|
+
* }} opts
|
|
171
|
+
* @returns {boolean} true when the entry was written, false on a skip/failure.
|
|
172
|
+
*/
|
|
173
|
+
export function writeProjectMetaCache(opts = {}) {
|
|
174
|
+
const key = projectMetaCacheKey(opts.owner, opts.projectNumber);
|
|
175
|
+
if (!key) return false;
|
|
176
|
+
const meta = opts.meta;
|
|
177
|
+
if (
|
|
178
|
+
!meta ||
|
|
179
|
+
typeof meta.projectId !== 'string' ||
|
|
180
|
+
typeof meta.fieldId !== 'string'
|
|
181
|
+
) {
|
|
182
|
+
return false;
|
|
183
|
+
}
|
|
184
|
+
const now = opts.now ?? Date.now();
|
|
185
|
+
|
|
186
|
+
const options =
|
|
187
|
+
meta.options instanceof Map
|
|
188
|
+
? Object.fromEntries(meta.options)
|
|
189
|
+
: { ...(meta.options ?? {}) };
|
|
190
|
+
|
|
191
|
+
const filePath = projectMetaCachePath(opts.config);
|
|
192
|
+
try {
|
|
193
|
+
const store = readCacheFile(filePath);
|
|
194
|
+
store[key] = {
|
|
195
|
+
cachedAt: now,
|
|
196
|
+
projectId: meta.projectId,
|
|
197
|
+
fieldId: meta.fieldId,
|
|
198
|
+
options,
|
|
199
|
+
};
|
|
200
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
201
|
+
fs.writeFileSync(filePath, `${JSON.stringify(store, null, 2)}\n`, 'utf8');
|
|
202
|
+
return true;
|
|
203
|
+
} catch {
|
|
204
|
+
return false;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Invalidate (delete) the cached entry for `(owner, projectNumber)`.
|
|
210
|
+
*
|
|
211
|
+
* Called when a GraphQL error fires against a board whose metadata came from
|
|
212
|
+
* the cache, so a mid-run board reconfiguration self-heals on the next flip
|
|
213
|
+
* (which re-resolves and re-writes). Best-effort and idempotent: a missing
|
|
214
|
+
* entry or unreadable file is a no-op. Returns true when an entry was
|
|
215
|
+
* actually removed.
|
|
216
|
+
*
|
|
217
|
+
* @param {{
|
|
218
|
+
* owner?: string|null,
|
|
219
|
+
* projectNumber?: number|string|null,
|
|
220
|
+
* config?: object,
|
|
221
|
+
* }} opts
|
|
222
|
+
* @returns {boolean}
|
|
223
|
+
*/
|
|
224
|
+
export function invalidateProjectMetaCache(opts = {}) {
|
|
225
|
+
const key = projectMetaCacheKey(opts.owner, opts.projectNumber);
|
|
226
|
+
if (!key) return false;
|
|
227
|
+
const filePath = projectMetaCachePath(opts.config);
|
|
228
|
+
try {
|
|
229
|
+
const store = readCacheFile(filePath);
|
|
230
|
+
if (!(key in store)) return false;
|
|
231
|
+
delete store[key];
|
|
232
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
233
|
+
fs.writeFileSync(filePath, `${JSON.stringify(store, null, 2)}\n`, 'utf8');
|
|
234
|
+
return true;
|
|
235
|
+
} catch {
|
|
236
|
+
return false;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
@@ -89,6 +89,7 @@ function defaultSleep(ms) {
|
|
|
89
89
|
* pollAttempts?: number,
|
|
90
90
|
* pollDelayMs?: number,
|
|
91
91
|
* sleepFn?: (ms: number) => Promise<void>,
|
|
92
|
+
* config?: object,
|
|
92
93
|
* }} args
|
|
93
94
|
* @returns {Promise<{ status: string, column?: string, reason?: string, attempts?: number }>}
|
|
94
95
|
*/
|
|
@@ -100,6 +101,7 @@ export async function reassertStatusColumn(args) {
|
|
|
100
101
|
pollAttempts = DEFAULT_POLL_ATTEMPTS,
|
|
101
102
|
pollDelayMs = DEFAULT_POLL_DELAY_MS,
|
|
102
103
|
sleepFn = defaultSleep,
|
|
104
|
+
config,
|
|
103
105
|
} = args ?? {};
|
|
104
106
|
if (!provider || typeof provider.getTicket !== 'function') {
|
|
105
107
|
throw new TypeError(
|
|
@@ -128,7 +130,7 @@ export async function reassertStatusColumn(args) {
|
|
|
128
130
|
if (!targetColumn) {
|
|
129
131
|
return { status: 'skipped', reason: 'no-matching-label' };
|
|
130
132
|
}
|
|
131
|
-
const sync = new ColumnSync({ provider, logger: logger ?? console });
|
|
133
|
+
const sync = new ColumnSync({ provider, logger: logger ?? console, config });
|
|
132
134
|
|
|
133
135
|
// First attempt — always fires through ColumnSync.sync so the skip
|
|
134
136
|
// paths (no-project / no-meta / no-option-<col> / not-on-project)
|
|
@@ -12,9 +12,21 @@
|
|
|
12
12
|
* inject a synchronous fake; the default runner delegates to
|
|
13
13
|
* `gh.pr.merge`, which spawns through the classified, typed-error
|
|
14
14
|
* surface instead of a raw `execFileSync('gh', …)` call.
|
|
15
|
+
*
|
|
16
|
+
* Story #4282 made arming robust when the base branch is checked out by a
|
|
17
|
+
* git worktree. The `--delete-branch` flag makes `gh` shell out to local
|
|
18
|
+
* `git` (including a `git checkout <base>`); from a per-Story worktree cwd
|
|
19
|
+
* that collides with the base branch already checked out by the primary
|
|
20
|
+
* worktree (`fatal: '<base>' is already used by worktree`). We now resolve
|
|
21
|
+
* the arm cwd to the **primary worktree root** (which holds the base
|
|
22
|
+
* branch) via `resolveAutoMergeArmCwd`, so `gh`'s local checkout is a
|
|
23
|
+
* no-op. `--delete-branch` is preserved verbatim, so the PR head branch is
|
|
24
|
+
* still deleted on merge without depending on the repo's auto-delete
|
|
25
|
+
* setting. Resolution is non-fatal — it degrades to the original cwd.
|
|
15
26
|
*/
|
|
16
27
|
|
|
17
28
|
import { gh as defaultGh } from '../../../gh-exec.js';
|
|
29
|
+
import { resolveAutoMergeArmCwd } from '../../auto-merge-cwd.js';
|
|
18
30
|
|
|
19
31
|
/**
|
|
20
32
|
* Enable GitHub native auto-merge on the PR. Non-fatal.
|
|
@@ -24,11 +36,22 @@ import { gh as defaultGh } from '../../../gh-exec.js';
|
|
|
24
36
|
* prNumber: number,
|
|
25
37
|
* gh?: ReturnType<typeof import('../../../gh-exec.js').createGh>,
|
|
26
38
|
* runner?: (args: string[], opts: object) => ({ status: number, stdout?: string, stderr?: string } | Promise<{ status: number, stdout?: string, stderr?: string }>),
|
|
39
|
+
* resolveArmCwd?: (cwd: string) => string,
|
|
27
40
|
* }} opts
|
|
28
41
|
* @returns {Promise<{ enabled: boolean, reason?: string }>}
|
|
29
42
|
*/
|
|
30
|
-
export async function enableAutoMergeWith({
|
|
43
|
+
export async function enableAutoMergeWith({
|
|
44
|
+
cwd,
|
|
45
|
+
prNumber,
|
|
46
|
+
gh,
|
|
47
|
+
runner,
|
|
48
|
+
resolveArmCwd = resolveAutoMergeArmCwd,
|
|
49
|
+
}) {
|
|
31
50
|
const exec = runner ?? makeDefaultGhAutoMergeRunner(gh ?? defaultGh);
|
|
51
|
+
// Re-point the arm at the base-branch (primary) worktree so gh's
|
|
52
|
+
// `--delete-branch` local `git checkout <base>` cannot collide with the
|
|
53
|
+
// base branch already checked out by the primary worktree (Story #4282).
|
|
54
|
+
const armCwd = resolveArmCwd(cwd);
|
|
32
55
|
try {
|
|
33
56
|
const result = await exec(
|
|
34
57
|
[
|
|
@@ -39,7 +62,7 @@ export async function enableAutoMergeWith({ cwd, prNumber, gh, runner }) {
|
|
|
39
62
|
'--squash',
|
|
40
63
|
'--delete-branch',
|
|
41
64
|
],
|
|
42
|
-
{ cwd },
|
|
65
|
+
{ cwd: armCwd },
|
|
43
66
|
);
|
|
44
67
|
if (result.status === 0) return { enabled: true };
|
|
45
68
|
return {
|
|
@@ -6,43 +6,60 @@
|
|
|
6
6
|
* Epic-attached Stories so the experience matches — only the baseline
|
|
7
7
|
* ref changes (`main`, not `epic/<id>`).
|
|
8
8
|
*
|
|
9
|
-
* Standalone
|
|
10
|
-
* scope a `validation-evidence.json` under
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
9
|
+
* Standalone evidence keyspace (Story #4250). Standalone Stories have no
|
|
10
|
+
* parent Epic, so they cannot scope a `validation-evidence.json` under a
|
|
11
|
+
* `temp/epic-<id>/` tree. Rather than feed a null `epicId` into the
|
|
12
|
+
* Epic-keyed path (which structurally disabled the evidence cache and
|
|
13
|
+
* forced every re-run — base-sync conflict, review remediation, baseline
|
|
14
|
+
* absorb — to re-execute ALL gates including the coverage suite), the
|
|
15
|
+
* standalone close now passes `standalone: true`. `runCloseValidation`
|
|
16
|
+
* then anchors the cache on the Story id alone at
|
|
17
|
+
* `temp/standalone/stories/story-<id>/validation-evidence.json`, so a
|
|
18
|
+
* second close at unchanged HEAD short-circuits the already-passed gates.
|
|
15
19
|
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
* the
|
|
20
|
+
* Format-autofix self-heal (Story #4250). The Epic path runs
|
|
21
|
+
* `runScopedFormatAutofix` before the check-only gates so benign JSON/YAML
|
|
22
|
+
* drift the formatter can fix is folded into a `fix(story-close):` commit
|
|
23
|
+
* rather than hard-failing the format gate. The standalone path now does
|
|
24
|
+
* the same, with `baseBranch` as the diff anchor and the Story worktree as
|
|
25
|
+
* the commit target.
|
|
19
26
|
*
|
|
20
|
-
* `runCloseValidation` and `
|
|
21
|
-
* dependencies so the parent CLI's cache-busted
|
|
22
|
-
* that mock the upstream module URLs.
|
|
27
|
+
* `runCloseValidation`, `buildDefaultGates`, and `runScopedFormatAutofix`
|
|
28
|
+
* are accepted as injected dependencies so the parent CLI's cache-busted
|
|
29
|
+
* bindings win in tests that mock the upstream module URLs.
|
|
23
30
|
*/
|
|
24
31
|
|
|
25
32
|
import { buildDefaultGates as defaultBuildDefaultGates } from '../../../close-validation/gates.js';
|
|
26
33
|
import { runCloseValidation as defaultRunCloseValidation } from '../../../close-validation/runner.js';
|
|
27
34
|
import { Logger } from '../../../Logger.js';
|
|
35
|
+
import { runScopedFormatAutofix as defaultRunScopedFormatAutofix } from '../../story-close/format-autofix.js';
|
|
28
36
|
|
|
29
37
|
/**
|
|
30
38
|
* Run the close-validation gate chain. Throws on first gate failure.
|
|
31
39
|
*
|
|
40
|
+
* Order (Story #4250): format-autofix self-heal → close-validation gates.
|
|
41
|
+
* The autofix step scopes the formatter to the `baseBranch...storyBranch`
|
|
42
|
+
* diff, commits any fix on the Story branch inside the Story worktree, and
|
|
43
|
+
* is best-effort — a missing `storyBranch` (resume/legacy callers) skips it
|
|
44
|
+
* with a log line rather than failing.
|
|
45
|
+
*
|
|
32
46
|
* Gates are built from the canonical resolved config (`buildDefaultGates`
|
|
33
47
|
* reads `project.commands` and `delivery.quality.gates.crap.enabled`); the
|
|
34
48
|
* `baseBranch` is forwarded as the gate `epicBranch` so the format gate's
|
|
35
|
-
* changed-file scope anchors on it.
|
|
49
|
+
* changed-file scope anchors on it. `standalone: true` routes the evidence
|
|
50
|
+
* cache to the storyId-anchored keyspace.
|
|
36
51
|
*
|
|
37
52
|
* @param {{
|
|
38
53
|
* cwd: string,
|
|
39
54
|
* worktreePath: string|null,
|
|
40
55
|
* config: object,
|
|
41
56
|
* baseBranch: string,
|
|
57
|
+
* storyBranch?: string,
|
|
42
58
|
* storyId: number,
|
|
43
59
|
* progress: (tag: string, msg: string) => void,
|
|
44
60
|
* runCloseValidation?: typeof defaultRunCloseValidation,
|
|
45
61
|
* buildDefaultGates?: typeof defaultBuildDefaultGates,
|
|
62
|
+
* runScopedFormatAutofix?: typeof defaultRunScopedFormatAutofix,
|
|
46
63
|
* }} args
|
|
47
64
|
*/
|
|
48
65
|
export async function runCloseValidationPhase({
|
|
@@ -50,11 +67,57 @@ export async function runCloseValidationPhase({
|
|
|
50
67
|
worktreePath,
|
|
51
68
|
config,
|
|
52
69
|
baseBranch,
|
|
70
|
+
storyBranch,
|
|
53
71
|
storyId,
|
|
54
72
|
progress,
|
|
55
73
|
runCloseValidation = defaultRunCloseValidation,
|
|
56
74
|
buildDefaultGates = defaultBuildDefaultGates,
|
|
75
|
+
runScopedFormatAutofix = defaultRunScopedFormatAutofix,
|
|
57
76
|
}) {
|
|
77
|
+
// Story #4250 — format-autofix self-heal before the check-only gates.
|
|
78
|
+
// Mirrors the Epic path (story-close/phases/gates.js): the formatter is
|
|
79
|
+
// scoped to the baseBranch...storyBranch diff, and any fix is committed on
|
|
80
|
+
// the Story branch in the Story worktree. Skipped (with a log) when no
|
|
81
|
+
// storyBranch is available so resume/legacy callers don't trip a throw.
|
|
82
|
+
if (storyBranch) {
|
|
83
|
+
progress(
|
|
84
|
+
'FORMAT',
|
|
85
|
+
`Running scoped format-autofix on ${baseBranch}...${storyBranch}${worktreePath ? ` in ${worktreePath}` : ''}...`,
|
|
86
|
+
);
|
|
87
|
+
// Best-effort self-heal: a failure to even compute the diff (e.g. a
|
|
88
|
+
// missing ref) must never abort close — the format check gate downstream
|
|
89
|
+
// is the source of truth for "is the tree formatted". We log and proceed.
|
|
90
|
+
try {
|
|
91
|
+
const autofix = runScopedFormatAutofix({
|
|
92
|
+
cwd,
|
|
93
|
+
worktreePath,
|
|
94
|
+
storyId,
|
|
95
|
+
epicBranch: baseBranch,
|
|
96
|
+
storyBranch,
|
|
97
|
+
config,
|
|
98
|
+
logger: Logger,
|
|
99
|
+
});
|
|
100
|
+
if (autofix?.committed) {
|
|
101
|
+
progress(
|
|
102
|
+
'FORMAT',
|
|
103
|
+
`✅ Auto-applied format fix committed as ${autofix.sha} on ${storyBranch}.`,
|
|
104
|
+
);
|
|
105
|
+
} else {
|
|
106
|
+
progress(
|
|
107
|
+
'FORMAT',
|
|
108
|
+
`⏭ No format-autofix commit (${autofix?.reason ?? 'clean'}).`,
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
} catch (err) {
|
|
112
|
+
progress(
|
|
113
|
+
'FORMAT',
|
|
114
|
+
`⚠️ scoped format-autofix failed (close continues; format gate is authoritative): ${err?.message ?? err}`,
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
} else {
|
|
118
|
+
progress('FORMAT', '⏭ Skipped scoped format-autofix (no story branch).');
|
|
119
|
+
}
|
|
120
|
+
|
|
58
121
|
progress(
|
|
59
122
|
'VALIDATE',
|
|
60
123
|
`Running close-validation gates against baseline ${baseBranch}${worktreePath ? ` in ${worktreePath}` : ''}...`,
|
|
@@ -65,7 +128,10 @@ export async function runCloseValidationPhase({
|
|
|
65
128
|
gates: buildDefaultGates({ config, epicBranch: baseBranch }),
|
|
66
129
|
log: (m) => Logger.info(m),
|
|
67
130
|
storyId,
|
|
68
|
-
|
|
131
|
+
// Story #4250 — standalone storyId-anchored evidence keyspace. No
|
|
132
|
+
// epicId; the standalone flag routes the cache to
|
|
133
|
+
// temp/standalone/stories/story-<id>/validation-evidence.json.
|
|
134
|
+
standalone: true,
|
|
69
135
|
});
|
|
70
136
|
if (!validation.ok) {
|
|
71
137
|
const [first] = validation.failed;
|
|
@@ -67,6 +67,7 @@ async function runPrePushPhases({
|
|
|
67
67
|
worktreePath,
|
|
68
68
|
config,
|
|
69
69
|
baseBranch,
|
|
70
|
+
storyBranch,
|
|
70
71
|
storyId,
|
|
71
72
|
progress,
|
|
72
73
|
runCloseValidation,
|
|
@@ -157,6 +158,41 @@ async function releaseLease({
|
|
|
157
158
|
}
|
|
158
159
|
}
|
|
159
160
|
|
|
161
|
+
/**
|
|
162
|
+
* Story #4257 — run a blocked-prone phase and, if it throws, release the
|
|
163
|
+
* assignee-lease best-effort BEFORE re-throwing the original error.
|
|
164
|
+
*
|
|
165
|
+
* The two recoverable-blocked close exits (base-sync conflict in
|
|
166
|
+
* `runBaseSyncPhase`, and a critical-blocker review halt in
|
|
167
|
+
* `openAndReviewPr`) throw before the clean-close lease release at the
|
|
168
|
+
* tail of `runSingleStoryClose`, stranding the operator's lease until its
|
|
169
|
+
* TTL expires. That fail-closed-refuses a different operator who picks up
|
|
170
|
+
* the blocked Story — exactly the hand-off case. Releasing here closes
|
|
171
|
+
* that gap.
|
|
172
|
+
*
|
|
173
|
+
* The original throw is preserved verbatim (per
|
|
174
|
+
* `rules/orchestration-error-handling.md` — throw, never `Logger.fatal`),
|
|
175
|
+
* so the CLI boundary still maps it to a non-zero exit; the lease release
|
|
176
|
+
* must not swallow it. `releaseLease` is itself best-effort and never
|
|
177
|
+
* throws, so it cannot mask the real failure. Fail-closed re-acquire
|
|
178
|
+
* semantics are preserved: `releaseStoryLease` no-ops when the operator no
|
|
179
|
+
* longer holds the claim, and a self-held re-acquire on a re-run still
|
|
180
|
+
* succeeds against the now-unclaimed ticket.
|
|
181
|
+
*
|
|
182
|
+
* @template T
|
|
183
|
+
* @param {() => Promise<T>} run The blocked-prone phase to execute.
|
|
184
|
+
* @param {{ provider: object, storyId: number, config: object, injectedReleaseLease?: Function }} leaseArgs
|
|
185
|
+
* @returns {Promise<T>}
|
|
186
|
+
*/
|
|
187
|
+
async function releaseLeaseOnBlock(run, leaseArgs) {
|
|
188
|
+
try {
|
|
189
|
+
return await run();
|
|
190
|
+
} catch (err) {
|
|
191
|
+
await releaseLease(leaseArgs);
|
|
192
|
+
throw err;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
160
196
|
function closeResult({
|
|
161
197
|
storyId,
|
|
162
198
|
storyBranch,
|
|
@@ -236,27 +272,45 @@ export async function runSingleStoryClose({
|
|
|
236
272
|
config,
|
|
237
273
|
storyId: options.storyId,
|
|
238
274
|
});
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
275
|
+
// Story #4257 — the base-sync conflict and review-critical exits throw
|
|
276
|
+
// before the clean-close lease release at the tail of this function.
|
|
277
|
+
// Wrap both blocked-prone phases so the lease is released best-effort
|
|
278
|
+
// before the throw propagates; the original error is preserved.
|
|
279
|
+
const leaseArgs = {
|
|
244
280
|
provider,
|
|
245
|
-
worktreePath,
|
|
246
|
-
injectedSync,
|
|
247
|
-
injectedGitSpawn,
|
|
248
|
-
});
|
|
249
|
-
|
|
250
|
-
const { prUrl, prNumber } = await openAndReviewPr({
|
|
251
|
-
cwd: options.cwd,
|
|
252
|
-
story,
|
|
253
281
|
storyId: options.storyId,
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
282
|
+
config,
|
|
283
|
+
injectedReleaseLease,
|
|
284
|
+
};
|
|
285
|
+
await releaseLeaseOnBlock(
|
|
286
|
+
() =>
|
|
287
|
+
runPrePushPhases({
|
|
288
|
+
...options,
|
|
289
|
+
config,
|
|
290
|
+
baseBranch,
|
|
291
|
+
storyBranch,
|
|
292
|
+
provider,
|
|
293
|
+
worktreePath,
|
|
294
|
+
injectedSync,
|
|
295
|
+
injectedGitSpawn,
|
|
296
|
+
}),
|
|
297
|
+
leaseArgs,
|
|
298
|
+
);
|
|
299
|
+
|
|
300
|
+
const { prUrl, prNumber } = await releaseLeaseOnBlock(
|
|
301
|
+
() =>
|
|
302
|
+
openAndReviewPr({
|
|
303
|
+
cwd: options.cwd,
|
|
304
|
+
story,
|
|
305
|
+
storyId: options.storyId,
|
|
306
|
+
storyBranch,
|
|
307
|
+
baseBranch,
|
|
308
|
+
provider,
|
|
309
|
+
injectedGh,
|
|
310
|
+
injectedRunCodeReview,
|
|
311
|
+
}),
|
|
312
|
+
leaseArgs,
|
|
313
|
+
);
|
|
260
314
|
const { autoMergeEnabled, autoMergeReason } = await runAutoMergePhase({
|
|
261
315
|
cwd: options.cwd,
|
|
262
316
|
prNumber,
|
|
@@ -284,12 +338,7 @@ export async function runSingleStoryClose({
|
|
|
284
338
|
progress,
|
|
285
339
|
WorktreeManager,
|
|
286
340
|
});
|
|
287
|
-
const leaseReleased = await releaseLease(
|
|
288
|
-
provider,
|
|
289
|
-
storyId: options.storyId,
|
|
290
|
-
config,
|
|
291
|
-
injectedReleaseLease,
|
|
292
|
-
});
|
|
341
|
+
const leaseReleased = await releaseLease(leaseArgs);
|
|
293
342
|
const result = closeResult({
|
|
294
343
|
storyId: options.storyId,
|
|
295
344
|
storyBranch,
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
21
|
import { Logger } from '../../../Logger.js';
|
|
22
|
+
import { hasInlineAcceptance } from '../../../story-init/task-graph-builder.js';
|
|
22
23
|
import { fetchChildTickets } from '../../../story-lifecycle.js';
|
|
23
24
|
import { createPhaseTimer } from '../../../util/phase-timer.js';
|
|
24
25
|
import {
|
|
@@ -197,7 +198,15 @@ export async function runStoryCloseLocked(args) {
|
|
|
197
198
|
logger: Logger,
|
|
198
199
|
});
|
|
199
200
|
|
|
200
|
-
|
|
201
|
+
// Story #4251 — mirror the init-side short-circuit: a 2-tier Story (inline
|
|
202
|
+
// acceptance on its body) has no children, so skip the `fetchChildTickets`
|
|
203
|
+
// probe (empty sub-issues GraphQL query + never-matching `/search/issues`
|
|
204
|
+
// scan) entirely. The cascade target is just the Story itself. A body
|
|
205
|
+
// lacking inline acceptance still enumerates children for legacy / Epic
|
|
206
|
+
// callers.
|
|
207
|
+
const tasks = hasInlineAcceptance(story?.body)
|
|
208
|
+
? []
|
|
209
|
+
: await fetchChildTickets(provider, storyId);
|
|
201
210
|
provider.primeTicketCache([story, ...tasks]);
|
|
202
211
|
progress('TICKETS', `Found ${tasks.length} child ticket(s)`);
|
|
203
212
|
|
|
@@ -1,6 +1,49 @@
|
|
|
1
|
+
import { parse as parseStoryBody } from '../story-body/story-body.js';
|
|
1
2
|
import { collectStoryAssumptionEntries } from './file-assumptions.js';
|
|
2
3
|
import { computeStoryReachability } from './story-reachability.js';
|
|
3
4
|
|
|
5
|
+
/**
|
|
6
|
+
* Normalize a Story so its `body` is the structured object the conflict
|
|
7
|
+
* passes scan, mirroring `validateAcFreshness` /
|
|
8
|
+
* `collectStoryAssumptionEntries` (Story #3302) and the sizing gate's
|
|
9
|
+
* `resolveStoryBody` (Story #4271).
|
|
10
|
+
*
|
|
11
|
+
* The decomposer emits `body` as the canonical serialized **string**, but
|
|
12
|
+
* the conflict passes (`indexConsumers`, `indexAssumptionEntries`,
|
|
13
|
+
* `computeMissingBddScaffoldFindings`, the sibling-create scan in
|
|
14
|
+
* `computeRegistryFindings`, and the legacy-bullet branch of
|
|
15
|
+
* `collectStoryProducerPaths`) historically read `story.body` only when it
|
|
16
|
+
* was already an object — so on the production string shape the
|
|
17
|
+
* `implicit-cross-story-dep`, `fan-out`, registry, and `missing-bdd-scaffold`
|
|
18
|
+
* findings emitted nothing. Parsing the body once at the entry point and
|
|
19
|
+
* threading the normalized Story through every pass restores parity.
|
|
20
|
+
*
|
|
21
|
+
* `collectStoryAssumptionEntries` already parses string bodies itself, so a
|
|
22
|
+
* normalized object body round-trips through it unchanged. The returned Story
|
|
23
|
+
* keeps every other field (notably `slug` and `depends_on`) intact.
|
|
24
|
+
*
|
|
25
|
+
* - **string body** → parsed via `parseStoryBody`; an unparseable string
|
|
26
|
+
* yields `body: null` (the passes degrade to "no structured signal",
|
|
27
|
+
* never throw mid-validation).
|
|
28
|
+
* - **object body** → returned verbatim.
|
|
29
|
+
* - **null / other** → `body: null`.
|
|
30
|
+
*
|
|
31
|
+
* @param {object} story
|
|
32
|
+
* @returns {object} A shallow clone of `story` with a structured `body`.
|
|
33
|
+
*/
|
|
34
|
+
function normalizeStoryBody(story) {
|
|
35
|
+
const body = story?.body;
|
|
36
|
+
if (typeof body === 'string') {
|
|
37
|
+
if (body.trim().length === 0) return { ...story, body: null };
|
|
38
|
+
try {
|
|
39
|
+
return { ...story, body: parseStoryBody(body).body };
|
|
40
|
+
} catch {
|
|
41
|
+
return { ...story, body: null };
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return story;
|
|
45
|
+
}
|
|
46
|
+
|
|
4
47
|
/**
|
|
5
48
|
* Cross-Story path-conflict & implicit-dependency findings.
|
|
6
49
|
*
|
|
@@ -661,7 +704,11 @@ function computeFanOutFindings({
|
|
|
661
704
|
*/
|
|
662
705
|
export function computeConflictFindings({ stories, policy } = {}) {
|
|
663
706
|
const merged = { ...DEFAULT_POLICY, ...(policy ?? {}) };
|
|
664
|
-
|
|
707
|
+
// Story #4271: normalize every Story's body to its structured object form
|
|
708
|
+
// once, up front, so the canonical serialized **string** shape the
|
|
709
|
+
// decomposer emits is scanned at parity with the pre-serialize object
|
|
710
|
+
// shape across every conflict pass.
|
|
711
|
+
const storyList = (stories ?? []).map(normalizeStoryBody);
|
|
665
712
|
const producers = indexProducers(storyList);
|
|
666
713
|
const consumers = indexConsumers(storyList, producers);
|
|
667
714
|
const reach = computeStoryReachability(storyList);
|