mandrel 1.76.0 → 1.78.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 (68) hide show
  1. package/.agents/docs/configuration.md +2 -2
  2. package/.agents/docs/workflows.md +19 -0
  3. package/.agents/schemas/agentrc.schema.json +1 -1
  4. package/.agents/schemas/dispatch-manifest.json +1 -1
  5. package/.agents/schemas/lifecycle/loop.tick.schema.json +20 -0
  6. package/.agents/schemas/loop-unit.schema.json +70 -0
  7. package/.agents/schemas/validation-evidence.schema.json +2 -1
  8. package/.agents/scripts/audit-to-stories.js +43 -1
  9. package/.agents/scripts/check-doc-links.js +24 -1
  10. package/.agents/scripts/check-loop-units.js +204 -0
  11. package/.agents/scripts/epic-deliver-prepare.js +31 -0
  12. package/.agents/scripts/evidence-gate.js +48 -12
  13. package/.agents/scripts/generate-workflows-doc.js +37 -4
  14. package/.agents/scripts/lib/audit-to-stories/build-story-body.js +141 -34
  15. package/.agents/scripts/lib/cli-args.js +6 -0
  16. package/.agents/scripts/lib/close-validation/process.js +61 -5
  17. package/.agents/scripts/lib/close-validation/runner.js +42 -9
  18. package/.agents/scripts/lib/config/temp-paths.js +1 -1
  19. package/.agents/scripts/lib/config/worktree-isolation.js +18 -3
  20. package/.agents/scripts/lib/config-resolver.js +4 -1
  21. package/.agents/scripts/lib/config-settings-schema-delivery.js +1 -1
  22. package/.agents/scripts/lib/git-branch-lifecycle.js +90 -0
  23. package/.agents/scripts/lib/loop-units/validate-loop-unit.js +197 -0
  24. package/.agents/scripts/lib/mandrel-catalog.js +36 -0
  25. package/.agents/scripts/lib/orchestration/auto-merge-cwd.js +128 -0
  26. package/.agents/scripts/lib/orchestration/column-sync.js +88 -9
  27. package/.agents/scripts/lib/orchestration/lifecycle/emit-loop-tick.js +183 -0
  28. package/.agents/scripts/lib/orchestration/lifecycle/listeners/automerge-armer.js +20 -2
  29. package/.agents/scripts/lib/orchestration/project-meta-cache.js +238 -0
  30. package/.agents/scripts/lib/orchestration/reassert-status-column.js +3 -1
  31. package/.agents/scripts/lib/orchestration/single-story-close/phases/auto-merge.js +25 -2
  32. package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +80 -14
  33. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +74 -25
  34. package/.agents/scripts/lib/orchestration/story-close/phases/locked-pipeline.js +10 -1
  35. package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +48 -1
  36. package/.agents/scripts/lib/orchestration/ticket-validator-sizing.js +148 -4
  37. package/.agents/scripts/lib/orchestration/ticketing/transition.js +8 -1
  38. package/.agents/scripts/lib/story-body/story-body.js +76 -7
  39. package/.agents/scripts/lib/story-init/branch-initializer.js +29 -43
  40. package/.agents/scripts/lib/story-init/hierarchy-tracer.js +25 -4
  41. package/.agents/scripts/lib/story-init/task-graph-builder.js +22 -12
  42. package/.agents/scripts/lib/templates/decomposer-prompts.js +23 -0
  43. package/.agents/scripts/lib/validation-evidence.js +63 -25
  44. package/.agents/scripts/lib/worktree/node-modules-strategy.js +239 -31
  45. package/.agents/scripts/providers/github/branch-protection.js +1 -1
  46. package/.agents/scripts/providers/github/errors.js +53 -2
  47. package/.agents/scripts/providers/github/labels.js +1 -1
  48. package/.agents/scripts/providers/github/projects-v2-graphql.js +1 -1
  49. package/.agents/scripts/resync-status-column.js +5 -0
  50. package/.agents/scripts/run-coverage.js +85 -45
  51. package/.agents/scripts/run-lint.js +11 -0
  52. package/.agents/scripts/single-story-init.js +22 -29
  53. package/.agents/scripts/story-init.js +38 -63
  54. package/.agents/scripts/story-phase.js +46 -4
  55. package/.agents/scripts/sync-claude-commands.js +112 -29
  56. package/.agents/scripts/update-maintainability-baseline.js +19 -76
  57. package/.agents/skills/core/epic-plan-decompose-author/SKILL.md +5 -3
  58. package/.agents/workflows/helpers/acceptance-self-eval.md +27 -0
  59. package/.agents/workflows/helpers/deliver-epic.md +19 -2
  60. package/.agents/workflows/helpers/epic-deliver-story.md +50 -14
  61. package/.agents/workflows/helpers/single-story-deliver.md +12 -0
  62. package/.agents/workflows/loops/README.md +65 -0
  63. package/.agents/workflows/loops/fix-failing-tests.md +74 -0
  64. package/.agents/workflows/loops/nightly-audit.md +71 -0
  65. package/.agents/workflows/loops/watch-ci.md +68 -0
  66. package/docs/CHANGELOG.md +51 -0
  67. package/package.json +1 -1
  68. package/.agents/scripts/providers/github/transient-retry.js +0 -62
@@ -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);
@@ -30,6 +30,62 @@
30
30
  * decomposer prompt and authoring SKILL.
31
31
  */
32
32
 
33
+ import { parse as parseStoryBody } from '../story-body/story-body.js';
34
+
35
+ /**
36
+ * Normalize a Story's `body` to the structured object the sizing layers
37
+ * score, mirroring `validateAcFreshness` / `collectStoryAssumptionEntries`
38
+ * (Story #3302) and `resolveStructuredBody` in `task-body-validator.js`.
39
+ *
40
+ * The decomposer emits `body` as the canonical serialized **string**
41
+ * (`decomposer-prompts.js`), but the sizing layers historically read
42
+ * `story.body` only when it was already an object — so on the production
43
+ * string shape `changes` / `wide` fell through to empty and the `hardFiles`
44
+ * / unanchored-constant backstops emitted nothing. A defensive parse here
45
+ * restores parity:
46
+ * - **string body** → parsed via `parseStoryBody`; an unparseable string
47
+ * yields `null` (the gate degrades to "no structured signal", never
48
+ * throws mid-validation).
49
+ * - **object body** → returned verbatim (a caller may pass the
50
+ * pre-serialize shape directly; `parse` round-trips it).
51
+ * - **null / other** → `null`.
52
+ *
53
+ * @param {object} story
54
+ * @returns {object|null}
55
+ */
56
+ function resolveStoryBody(story) {
57
+ const body = story?.body;
58
+ if (typeof body === 'string') {
59
+ if (body.trim().length === 0) return null;
60
+ try {
61
+ return parseStoryBody(body).body;
62
+ } catch {
63
+ return null;
64
+ }
65
+ }
66
+ if (body !== null && typeof body === 'object') return body;
67
+ return null;
68
+ }
69
+
70
+ /**
71
+ * Resolve the acceptance-criteria array for a Story, preferring the
72
+ * authoritative top-level `story.acceptance` (the binding contract the
73
+ * validator already requires every Story to carry inline) over the
74
+ * structured body's `acceptance`. Reading the top-level array makes the
75
+ * acceptance ceiling correct regardless of body shape — a string body whose
76
+ * structured `acceptance` is only reachable after a parse, or an object body
77
+ * (Story #4271). Falls back to `resolveStoryBody(story).acceptance` only when
78
+ * the top-level array is absent.
79
+ *
80
+ * @param {object} story
81
+ * @returns {unknown[]}
82
+ */
83
+ function resolveAcceptance(story) {
84
+ if (Array.isArray(story?.acceptance)) return story.acceptance;
85
+ const body = resolveStoryBody(story);
86
+ return Array.isArray(body?.acceptance) ? body.acceptance : [];
87
+ }
88
+
33
89
  export const DEFAULT_TASK_SIZING = Object.freeze({
34
90
  // Typical-Story warning thresholds (soft — emit advisory findings).
35
91
  // Story #4162 raised `softFiles` 8 → 15: a capability-sized Story routinely
@@ -71,6 +127,39 @@ export const DELIVERABLE_GRANULARITY_GUIDANCE = Object.freeze({
71
127
  '**Single-consumer merge rule.** A Story whose only consumer is one sibling Story should be **merged into that sibling** rather than emitted separately — a single-consumer downstream slice is not its own unit of work.',
72
128
  });
73
129
 
130
+ /**
131
+ * `AUTHORING_ALTITUDE_GUIDANCE` is the **single source of truth** for the
132
+ * binding-vs-advisory authoring altitude (Epic #4131 F8) and the New-File
133
+ * Contract (Story #4272). It is stated ONCE here and consumed by BOTH the
134
+ * decomposer prompt template
135
+ * (`.agents/scripts/lib/templates/decomposer-prompts.js`, which interpolates
136
+ * the strings verbatim into the rendered system prompt) AND the authoring
137
+ * SKILL (`.agents/skills/core/epic-plan-decompose-author/SKILL.md`, whose
138
+ * prose mirrors these sentences). The SKILL cannot import JS, so the
139
+ * `ticket-decomposer` prompt test asserts the canonical phrasing on both
140
+ * surfaces — a divergent restatement fails that gate. This reuses the #3777
141
+ * single-source mechanism (one constant, two surfaces, drift-gated by tests).
142
+ *
143
+ * The altitude: `acceptance[]` / `verify[]` are the **binding contract** (the
144
+ * sole definition of "done"); `changes[]` / `references[]` are an **advisory
145
+ * implementation sketch** the executor MAY revise. Author acceptance to assert
146
+ * the **outcome** independent of file layout — never pin an incidental helper
147
+ * name or private path into an acceptance item. The advisory sketch is still
148
+ * validated (base-branch probes, New-File Contract) and never licenses
149
+ * skipping `acceptance[]` / `verify[]` or any `rules/security-baseline.md` MUST.
150
+ */
151
+ export const AUTHORING_ALTITUDE_GUIDANCE = Object.freeze({
152
+ // The binding-vs-advisory altitude statement.
153
+ altitude:
154
+ '**Binding contract vs advisory sketch.** `acceptance[]` and `verify[]` are the Story\'s **binding contract** — the executor MUST satisfy them exactly, and they are the only definition of "done." `changes[]` and `references[]` are an **advisory implementation sketch**: your best prediction of the file footprint, which the executor MAY revise when the real codebase diverges from the sketch. Author `acceptance[]` / `verify[]` to assert the **outcome** independent of any one file layout — never pin an incidental implementation detail (an internal helper name, a private file path) into an acceptance item that the advisory `changes[]` is free to reshape; assert the observable behaviour instead.',
155
+ // The advisory-does-not-mean-unvalidated caveat.
156
+ advisoryCaveat:
157
+ "**Advisory does not mean unvalidated.** `changes[]` paths still pass the base-branch file-assumption probes (a `creates` against an existing path still fails), the New-File Contract still holds, and the executor's latitude to revise the approach never licenses skipping `acceptance[]` / `verify[]` or relaxing any `rules/security-baseline.md` MUST.",
158
+ // The New-File Contract.
159
+ newFileContract:
160
+ '**New-File Contract.** Any path named in a Story\'s `goal`, `acceptance`, or `verify` that does NOT already exist on `main` MUST also appear in that Story\'s `changes[]` with `assumption: "creates"`; otherwise the freshness validator rejects the decompose — even when the Story is the one authoring the file.',
161
+ });
162
+
74
163
  /**
75
164
  * Configuration-constant phrase patterns the `unanchored-constant` heuristic
76
165
  * scans Story acceptance criteria for. Each entry matches the *kind* of
@@ -143,8 +232,11 @@ function makeUnanchoredConstant(slug, criterion) {
143
232
  */
144
233
  function computeUnanchoredConstantFindings(story) {
145
234
  const out = [];
146
- const body = story.body && typeof story.body === 'object' ? story.body : null;
147
- const acceptance = Array.isArray(body?.acceptance) ? body.acceptance : [];
235
+ // Read the authoritative top-level `story.acceptance` (the binding
236
+ // contract), falling back to the structured body's acceptance only when the
237
+ // top-level array is absent. This is correct regardless of body shape —
238
+ // string or object (Story #4271).
239
+ const acceptance = resolveAcceptance(story);
148
240
  for (const item of acceptance) {
149
241
  const criterion = String(item ?? '');
150
242
  if (CONCRETE_VALUE_RE.test(criterion)) continue;
@@ -158,6 +250,47 @@ function computeUnanchoredConstantFindings(story) {
158
250
  return out;
159
251
  }
160
252
 
253
+ /**
254
+ * Soft, advisory `missing-reason-to-exist` finding (Story #4273). Surfaces a
255
+ * Story whose body carries no non-empty `reason_to_exist` — the
256
+ * machine-checkable form of the cohesion rule (**one Story = one coherent
257
+ * change with one reason to exist**). `reason_to_exist` is marked REQUIRED by
258
+ * the decomposer prompt and is the field the `epic-plan-consolidate` critic
259
+ * gates on, but that critic is an honor-system LLM check with no runtime
260
+ * backstop. This deterministic finding is the cheap backstop.
261
+ *
262
+ * Severity is `soft` (not a hard reject) so existing `reason_to_exist`-less
263
+ * standalone / audit Stories are surfaced as an advisory nudge rather than
264
+ * blocked — matching the `unanchored-constant` finding's advisory contract.
265
+ */
266
+ function makeMissingReasonToExist(slug) {
267
+ return {
268
+ kind: 'missing-reason-to-exist',
269
+ severity: 'soft',
270
+ ticketSlug: slug,
271
+ message:
272
+ 'Story body carries no non-empty `reason_to_exist`. State the single coherent reason this Story exists in one sentence (the machine-checkable form of "one Story = one coherent change with one reason to exist"), encoded as the `reason_to_exist` field of the body meta comment.',
273
+ };
274
+ }
275
+
276
+ /**
277
+ * Emit a soft `missing-reason-to-exist` finding when the Story body resolves
278
+ * to no non-empty `reason_to_exist`. The body parser
279
+ * (`story-body/story-body.js`) already normalizes `reason_to_exist` to a
280
+ * non-empty trimmed string or `null`, so reading `body.reason_to_exist` after
281
+ * `resolveStoryBody` is correct regardless of body shape — a serialized
282
+ * **string** body (the production decomposer shape) or an object body
283
+ * (Story #4271). A body that fails to parse resolves to `null` and trips the
284
+ * finding, which is the right advisory signal: the author should re-emit a
285
+ * parseable body carrying the field. One finding per Story.
286
+ */
287
+ function computeMissingReasonToExistFinding(story) {
288
+ const body = resolveStoryBody(story);
289
+ const reason = body?.reason_to_exist;
290
+ const hasReason = typeof reason === 'string' && reason.trim().length > 0;
291
+ return hasReason ? [] : [makeMissingReasonToExist(story.slug)];
292
+ }
293
+
161
294
  /**
162
295
  * Returns true when a `changes[]` entry is a glob pattern. Handles both the
163
296
  * canonical PathEntry object form `{ path, assumption }` and legacy strings.
@@ -253,8 +386,12 @@ function isDeclaredWide(wide) {
253
386
  */
254
387
  function computeStorySizingFindings(story, sizing) {
255
388
  const out = [];
256
- const body = story.body && typeof story.body === 'object' ? story.body : null;
257
- const acceptance = Array.isArray(body?.acceptance) ? body.acceptance : [];
389
+ // Story #4271: normalize the body so the canonical serialized **string**
390
+ // shape the decomposer emits is scored at parity with the pre-serialize
391
+ // object shape. The acceptance ceiling reads the authoritative top-level
392
+ // `story.acceptance` (the binding contract), not `body.acceptance`.
393
+ const body = resolveStoryBody(story);
394
+ const acceptance = resolveAcceptance(story);
258
395
  const changes = Array.isArray(body?.changes) ? body.changes : [];
259
396
  const declaredWide = isDeclaredWide(body?.wide ?? null);
260
397
 
@@ -263,6 +400,13 @@ function computeStorySizingFindings(story, sizing) {
263
400
  // the numeric sizing layers below — purely an authoring nudge.
264
401
  out.push(...computeUnanchoredConstantFindings(story));
265
402
 
403
+ // Soft, advisory: flag a Story body that carries no non-empty
404
+ // `reason_to_exist` (Story #4273). The decomposer prompt marks the field
405
+ // REQUIRED and the consolidate critic gates on it, but that critic has no
406
+ // runtime backstop — this deterministic finding is the cheap backstop.
407
+ // Independent of the numeric sizing layers below.
408
+ out.push(...computeMissingReasonToExistFinding(story));
409
+
266
410
  // Acceptance ceiling + soft warn.
267
411
  if (acceptance.length > sizing.maxAcceptance) {
268
412
  out.push(
@@ -179,6 +179,7 @@ async function syncProjectStatusColumn(
179
179
  ticketId,
180
180
  newState,
181
181
  _makeColumnSync,
182
+ config,
182
183
  ) {
183
184
  try {
184
185
  let sync;
@@ -191,10 +192,15 @@ async function syncProjectStatusColumn(
191
192
  // The instance's `_meta` cache survives across label transitions
192
193
  // so the invariant project metadata (projectId, fieldId, options)
193
194
  // is only fetched once per process run. Story #3661.
195
+ //
196
+ // Story #4252 — `config` is threaded so the on-disk board-metadata
197
+ // cache lands under the project's configured tempRoot. It is read at
198
+ // construction only; the registry caches the first instance per
199
+ // provider, so a later transition's config is intentionally ignored.
194
200
  if (!_columnSyncRegistry.has(provider)) {
195
201
  _columnSyncRegistry.set(
196
202
  provider,
197
- new ColumnSync({ provider, logger: Logger }),
203
+ new ColumnSync({ provider, logger: Logger, config }),
198
204
  );
199
205
  }
200
206
  sync = _columnSyncRegistry.get(provider);
@@ -363,6 +369,7 @@ export async function transitionTicketState(
363
369
  ticketId,
364
370
  newState,
365
371
  opts._makeColumnSync,
372
+ opts.config,
366
373
  );
367
374
 
368
375
  // Automatically trigger upward cascade on every transition (Story