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.
Files changed (46) hide show
  1. package/.agents/docs/configuration.md +2 -2
  2. package/.agents/schemas/agentrc.schema.json +1 -1
  3. package/.agents/schemas/dispatch-manifest.json +1 -1
  4. package/.agents/schemas/validation-evidence.schema.json +2 -1
  5. package/.agents/scripts/audit-to-stories.js +43 -1
  6. package/.agents/scripts/epic-deliver-prepare.js +31 -0
  7. package/.agents/scripts/evidence-gate.js +48 -12
  8. package/.agents/scripts/lib/audit-to-stories/build-story-body.js +141 -34
  9. package/.agents/scripts/lib/cli-args.js +6 -0
  10. package/.agents/scripts/lib/close-validation/runner.js +25 -8
  11. package/.agents/scripts/lib/config/temp-paths.js +1 -1
  12. package/.agents/scripts/lib/config/worktree-isolation.js +18 -3
  13. package/.agents/scripts/lib/config-resolver.js +4 -1
  14. package/.agents/scripts/lib/config-settings-schema-delivery.js +1 -1
  15. package/.agents/scripts/lib/git-branch-lifecycle.js +90 -0
  16. package/.agents/scripts/lib/orchestration/auto-merge-cwd.js +128 -0
  17. package/.agents/scripts/lib/orchestration/column-sync.js +88 -9
  18. package/.agents/scripts/lib/orchestration/lifecycle/listeners/automerge-armer.js +20 -2
  19. package/.agents/scripts/lib/orchestration/project-meta-cache.js +238 -0
  20. package/.agents/scripts/lib/orchestration/reassert-status-column.js +3 -1
  21. package/.agents/scripts/lib/orchestration/single-story-close/phases/auto-merge.js +25 -2
  22. package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +80 -14
  23. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +74 -25
  24. package/.agents/scripts/lib/orchestration/story-close/phases/locked-pipeline.js +10 -1
  25. package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +48 -1
  26. package/.agents/scripts/lib/orchestration/ticket-validator-sizing.js +148 -4
  27. package/.agents/scripts/lib/orchestration/ticketing/transition.js +8 -1
  28. package/.agents/scripts/lib/story-body/story-body.js +76 -7
  29. package/.agents/scripts/lib/story-init/branch-initializer.js +29 -43
  30. package/.agents/scripts/lib/story-init/hierarchy-tracer.js +25 -4
  31. package/.agents/scripts/lib/story-init/task-graph-builder.js +22 -12
  32. package/.agents/scripts/lib/templates/decomposer-prompts.js +23 -0
  33. package/.agents/scripts/lib/validation-evidence.js +63 -25
  34. package/.agents/scripts/lib/worktree/node-modules-strategy.js +239 -31
  35. package/.agents/scripts/resync-status-column.js +5 -0
  36. package/.agents/scripts/run-coverage.js +85 -45
  37. package/.agents/scripts/single-story-init.js +22 -29
  38. package/.agents/scripts/story-init.js +38 -63
  39. package/.agents/scripts/story-phase.js +46 -4
  40. package/.agents/skills/core/epic-plan-decompose-author/SKILL.md +5 -3
  41. package/.agents/workflows/helpers/acceptance-self-eval.md +27 -0
  42. package/.agents/workflows/helpers/deliver-epic.md +19 -2
  43. package/.agents/workflows/helpers/epic-deliver-story.md +50 -14
  44. package/.agents/workflows/helpers/single-story-deliver.md +12 -0
  45. package/docs/CHANGELOG.md +33 -0
  46. 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({ cwd, prNumber, gh, runner }) {
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 Stories have no parent Epic, so there's no per-Epic path to
10
- * scope a `validation-evidence.json` under. Pass `epicId: null` (not `0`)
11
- * so the `evidenceActive` predicate in `runCloseValidation` short-circuits
12
- * cleanly. `0` is rejected downstream by `validation-evidence.evidencePath`
13
- * (which requires a positive integer epicId) and aborts the whole gate
14
- * chain.
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
- * The trade-off is that re-runs of close on the same SHA don't hit the
17
- * evidence cache for standalone Stories; that's acceptable until/unless
18
- * the standalone path warrants its own evidence keyspace.
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 `buildDefaultGates` are accepted as injected
21
- * dependencies so the parent CLI's cache-busted bindings win in tests
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
- epicId: null,
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
- await runPrePushPhases({
240
- ...options,
241
- config,
242
- baseBranch,
243
- storyBranch,
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
- storyBranch,
255
- baseBranch,
256
- provider,
257
- injectedGh,
258
- injectedRunCodeReview,
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
- const tasks = await fetchChildTickets(provider, storyId);
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
- const storyList = stories ?? [];
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);