mandrel 1.92.0 → 1.94.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 (144) hide show
  1. package/.agents/agents/acceptance-critic.md +129 -0
  2. package/.agents/agents/retro.md +42 -0
  3. package/.agents/agents/story-worker.md +162 -0
  4. package/.agents/docs/configuration.md +7 -1
  5. package/.agents/docs/execution-reference.md +27 -2
  6. package/.agents/instructions.md +43 -33
  7. package/.agents/personas/engineer.md +26 -112
  8. package/.agents/personas/security-engineer.md +1 -2
  9. package/.agents/rules/git-conventions-reference.md +225 -0
  10. package/.agents/rules/git-conventions.md +25 -200
  11. package/.agents/rules/security-baseline.md +5 -0
  12. package/.agents/rules/testing-standards.md +106 -13
  13. package/.agents/schemas/agentrc.schema.json +31 -1
  14. package/.agents/schemas/lifecycle/slice.end.schema.json +21 -0
  15. package/.agents/schemas/lifecycle/slice.heartbeat.schema.json +20 -0
  16. package/.agents/schemas/lifecycle/slice.start.schema.json +17 -0
  17. package/.agents/scripts/acceptance-eval.js +62 -18
  18. package/.agents/scripts/agents-bootstrap-github.js +1 -1
  19. package/.agents/scripts/bookkeeping-reconcile.js +117 -0
  20. package/.agents/scripts/check-context-budget.js +62 -5
  21. package/.agents/scripts/diagnose-friction.js +0 -6
  22. package/.agents/scripts/epic-deliver-prepare.js +272 -10
  23. package/.agents/scripts/lib/bootstrap/project-bootstrap.js +56 -18
  24. package/.agents/scripts/lib/close-validation/gates.js +159 -21
  25. package/.agents/scripts/lib/config/acceptance-eval.js +52 -5
  26. package/.agents/scripts/lib/config/delivery-routing.js +87 -0
  27. package/.agents/scripts/lib/config/explain.js +2 -0
  28. package/.agents/scripts/lib/config-resolver.js +1 -1
  29. package/.agents/scripts/lib/config-settings-schema-delivery.js +37 -3
  30. package/.agents/scripts/lib/config-settings-schema-quality.js +9 -0
  31. package/.agents/scripts/lib/doc-tiers.js +37 -2
  32. package/.agents/scripts/lib/observability/active-story-env.js +111 -2
  33. package/.agents/scripts/lib/observability/hook-heartbeat.js +219 -0
  34. package/.agents/scripts/lib/observability/tool-trace-hook.js +15 -4
  35. package/.agents/scripts/lib/orchestration/acceptance-clusters.js +111 -0
  36. package/.agents/scripts/lib/orchestration/acceptance-eval-decision.js +32 -4
  37. package/.agents/scripts/lib/orchestration/bookkeeping-outbox.js +270 -0
  38. package/.agents/scripts/lib/orchestration/ceremony-routing.js +141 -0
  39. package/.agents/scripts/lib/orchestration/context-hydration-engine.js +3 -124
  40. package/.agents/scripts/lib/orchestration/deliver-route.js +173 -0
  41. package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/authoring-context.js +1 -1
  42. package/.agents/scripts/lib/orchestration/epic-run-state-store.js +233 -0
  43. package/.agents/scripts/lib/orchestration/file-assumptions.js +68 -7
  44. package/.agents/scripts/lib/orchestration/lifecycle/emit-slice-lifecycle.js +270 -0
  45. package/.agents/scripts/lib/orchestration/lifecycle/listeners/acceptance-reconciler.js +83 -2
  46. package/.agents/scripts/lib/orchestration/lifecycle/listeners/checkpoint-pointer-writer.js +6 -0
  47. package/.agents/scripts/lib/orchestration/plan-context.js +189 -3
  48. package/.agents/scripts/lib/orchestration/plan-critic-conditions.js +3 -2
  49. package/.agents/scripts/lib/orchestration/plan-critics-evaluate.js +99 -0
  50. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +38 -1
  51. package/.agents/scripts/lib/orchestration/plan-persist/summary.js +16 -1
  52. package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +1 -0
  53. package/.agents/scripts/lib/orchestration/story-close/pre-merge-validation.js +1 -0
  54. package/.agents/scripts/lib/orchestration/ticket-validator.js +19 -2
  55. package/.agents/scripts/lib/provider-factory.js +1 -1
  56. package/.agents/scripts/lib/templates/decomposer-prompts.js +1 -1
  57. package/.agents/scripts/plan-context.js +28 -10
  58. package/.agents/scripts/post-structured-comment.js +38 -0
  59. package/.agents/scripts/slice-phase.js +361 -0
  60. package/.agents/scripts/sync-claude-agents.js +165 -0
  61. package/.agents/scripts/update-ticket-state.js +31 -0
  62. package/.agents/scripts/wave-tick.js +138 -9
  63. package/.agents/skills/core/api-and-interface-design/SKILL.md +5 -3
  64. package/.agents/skills/core/code-review-and-quality/SKILL.md +63 -7
  65. package/.agents/skills/core/debugging-and-error-recovery/SKILL.md +1 -1
  66. package/.agents/skills/core/epic-plan-consolidate/SKILL.md +5 -5
  67. package/.agents/skills/core/epic-plan-decompose-author/SKILL.md +8 -8
  68. package/.agents/skills/core/epic-plan-premortem/SKILL.md +4 -4
  69. package/.agents/skills/core/epic-plan-spec-author/SKILL.md +26 -56
  70. package/.agents/skills/core/gates-and-baselines/SKILL.md +149 -0
  71. package/.agents/skills/core/idea-refinement/SKILL.md +2 -8
  72. package/.agents/skills/core/qa-coverage-mapping/SKILL.md +7 -7
  73. package/.agents/skills/skills.index.json +11 -381
  74. package/.agents/workflows/deliver.md +47 -4
  75. package/.agents/workflows/helpers/acceptance-self-eval.md +38 -13
  76. package/.agents/workflows/helpers/deliver-epic-reference.md +18 -5
  77. package/.agents/workflows/helpers/deliver-epic-single.md +331 -0
  78. package/.agents/workflows/helpers/deliver-epic.md +51 -8
  79. package/.agents/workflows/helpers/deliver-stories.md +15 -5
  80. package/.agents/workflows/helpers/epic-deliver-story.md +12 -3
  81. package/.agents/workflows/helpers/mandrel-sync-config.md +1 -1
  82. package/.agents/workflows/helpers/plan-epic-reference.md +19 -8
  83. package/.agents/workflows/helpers/plan-epic.md +95 -27
  84. package/.agents/workflows/helpers/scope-triage-gate.md +9 -0
  85. package/.agents/workflows/mandrel-update.md +1 -1
  86. package/.agents/workflows/plan.md +16 -4
  87. package/docs/CHANGELOG.md +23 -0
  88. package/lib/cli/registry.js +95 -0
  89. package/package.json +4 -2
  90. package/.agents/personas/engineer-mobile.md +0 -120
  91. package/.agents/personas/engineer-web.md +0 -111
  92. package/.agents/personas/product.md +0 -94
  93. package/.agents/personas/refactorer.md +0 -113
  94. package/.agents/personas/sre.md +0 -86
  95. package/.agents/personas/ux-designer.md +0 -95
  96. package/.agents/scripts/epic-plan-decompose.js +0 -54
  97. package/.agents/scripts/epic-plan-spec.js +0 -64
  98. package/.agents/scripts/lib/orchestration/skill-capsule-loader.js +0 -109
  99. package/.agents/scripts/plan-critics.js +0 -227
  100. package/.agents/skills/core/baseline-refresh/SKILL.md +0 -181
  101. package/.agents/skills/core/ci-cd-and-automation/SKILL.md +0 -274
  102. package/.agents/skills/core/ci-cd-and-automation/examples.md +0 -211
  103. package/.agents/skills/core/code-simplification/SKILL.md +0 -389
  104. package/.agents/skills/core/context-engineering/SKILL.md +0 -309
  105. package/.agents/skills/core/context-engineering/examples.md +0 -58
  106. package/.agents/skills/core/deprecation-and-migration/SKILL.md +0 -250
  107. package/.agents/skills/core/frontend-ui-engineering/SKILL.md +0 -357
  108. package/.agents/skills/core/hydrate-context/SKILL.md +0 -123
  109. package/.agents/skills/core/idea-refinement/examples.md +0 -437
  110. package/.agents/skills/core/idea-refinement/frameworks.md +0 -135
  111. package/.agents/skills/core/incremental-implementation/SKILL.md +0 -271
  112. package/.agents/skills/core/introducing-a-baseline-gate/SKILL.md +0 -213
  113. package/.agents/skills/core/knowledge-transfer/SKILL.md +0 -180
  114. package/.agents/skills/core/mutation-survivor-remediation/SKILL.md +0 -117
  115. package/.agents/skills/core/performance-optimization/SKILL.md +0 -314
  116. package/.agents/skills/core/planning-and-task-breakdown/SKILL.md +0 -277
  117. package/.agents/skills/core/property-based-testing/SKILL.md +0 -148
  118. package/.agents/skills/core/refactoring-discipline/SKILL.md +0 -111
  119. package/.agents/skills/core/shipping-and-launch/SKILL.md +0 -328
  120. package/.agents/skills/core/spec-driven-development/SKILL.md +0 -252
  121. package/.agents/skills/core/test-driven-development/SKILL.md +0 -475
  122. package/.agents/skills/core/using-agent-skills/SKILL.md +0 -232
  123. package/.agents/skills/stack/architecture/monorepo-path-strategist/SKILL.md +0 -31
  124. package/.agents/skills/stack/architecture/structured-output-zod/SKILL.md +0 -51
  125. package/.agents/skills/stack/architecture/subagent-orchestration/SKILL.md +0 -76
  126. package/.agents/skills/stack/backend/cloudflare-hono-architect/SKILL.md +0 -31
  127. package/.agents/skills/stack/backend/cloudflare-hono-architect/examples/route-template.ts +0 -33
  128. package/.agents/skills/stack/backend/cloudflare-queue-manager/SKILL.md +0 -31
  129. package/.agents/skills/stack/backend/cloudflare-workers/SKILL.md +0 -51
  130. package/.agents/skills/stack/backend/highlevel-crm/SKILL.md +0 -54
  131. package/.agents/skills/stack/backend/sqlite-drizzle-expert/SKILL.md +0 -29
  132. package/.agents/skills/stack/backend/sqlite-drizzle-expert/examples/schema-template.ts +0 -30
  133. package/.agents/skills/stack/backend/stripe-integration/SKILL.md +0 -57
  134. package/.agents/skills/stack/backend/stripe-integration/scripts/listen-stripe.sh +0 -9
  135. package/.agents/skills/stack/backend/turso-sqlite/SKILL.md +0 -48
  136. package/.agents/skills/stack/frontend/astro/SKILL.md +0 -62
  137. package/.agents/skills/stack/frontend/astro-react-island-strategist/SKILL.md +0 -30
  138. package/.agents/skills/stack/frontend/expo-react-native-developer/SKILL.md +0 -29
  139. package/.agents/skills/stack/frontend/google-analytics-v4/SKILL.md +0 -50
  140. package/.agents/skills/stack/frontend/tailwind-v4/SKILL.md +0 -58
  141. package/.agents/skills/stack/frontend/ui-accessibility-engineer/SKILL.md +0 -34
  142. package/.agents/skills/stack/qa/audit-accessibility/SKILL.md +0 -51
  143. package/.agents/skills/stack/qa/lighthouse-baseline/SKILL.md +0 -199
  144. package/.agents/skills/stack/security/backend-security-patterns/SKILL.md +0 -68
@@ -0,0 +1,173 @@
1
+ /**
2
+ * lib/orchestration/deliver-route.js — the deliver-side reader for the
3
+ * plan-time single-delivery seam (Epic #4475, M4-A).
4
+ *
5
+ * The plan path already ships the seam inert (Epic #4474): the risk verdict's
6
+ * `deliveryShape`, the `delivery::single` label, and the
7
+ * `epic-plan-state.decompose = { ticketCount: 0, shape: "single" }`
8
+ * checkpoint. Nothing on the deliver side read them — until this module. It
9
+ * folds those two markers (plus the global kill-switch) into a single
10
+ * delivery-route verdict for `/deliver`.
11
+ *
12
+ * Pure and total — inputs in, verdict out. No GitHub calls, no I/O, no
13
+ * throws. It mirrors how `review-depth.js#resolveDepth` and
14
+ * `epic-audit-prepare.js` read `planningRisk` / `decompose` off the
15
+ * `epic-plan-state` checkpoint: the caller fetches the Epic labels + the
16
+ * checkpoint, then hands both here.
17
+ *
18
+ * ── Routing precedence (highest wins) ──────────────────────────────────
19
+ * 1. Kill-switch. `delivery.routing.singleDelivery === false` forces
20
+ * `fan-out` for EVERY Epic, even a single-labelled one — the instant
21
+ * global revert (`lib/config/delivery-routing.js`). Overrides all.
22
+ * 2. Single marker. The `delivery::single` label (primary) OR
23
+ * `checkpoint.decompose.shape === "single"` (secondary) → `single`.
24
+ * 3. Wide-DAG advisory. A fan-out-shaped Epic whose ready-width > 1 is
25
+ * genuine parallelism → `fan-out`. Deliver-time width only ADVISES —
26
+ * it never reroutes a single-marked Epic (that would orphan a Story
27
+ * tree that does not exist; re-route = re-plan).
28
+ * 4. Legacy / no marker → `fan-out` (safe backward-compat: a legacy Epic
29
+ * has an authored Story tree the fan-out engine consumes).
30
+ *
31
+ * BEHAVIOR-PRESERVING in M4-A: the router consumes a `single` verdict via a
32
+ * stub that continues at `deliver-epic.md` (fan-out), so no `/deliver`
33
+ * observably changes until M4-B lands `deliver-epic-single.md`.
34
+ *
35
+ * @typedef {'single'|'fan-out'} DeliveryRoute
36
+ */
37
+
38
+ import { getDeliveryRouting } from '../config/delivery-routing.js';
39
+
40
+ /** The canonical single-delivery routing marker label. */
41
+ export const DELIVERY_SINGLE_LABEL = 'delivery::single';
42
+
43
+ /**
44
+ * True when the Epic carries the `delivery::single` routing label. Tolerant
45
+ * of the two label shapes callers hold: an array of label-name strings, or an
46
+ * array of `{ name }` objects (the GitHub REST label shape).
47
+ *
48
+ * @param {{ labels?: Array<string | { name?: string }> } | null | undefined} epic
49
+ * @returns {boolean}
50
+ */
51
+ function hasSingleLabel(epic) {
52
+ const labels = Array.isArray(epic?.labels) ? epic.labels : [];
53
+ return labels.some((l) => {
54
+ const name = typeof l === 'string' ? l : l?.name;
55
+ return name === DELIVERY_SINGLE_LABEL;
56
+ });
57
+ }
58
+
59
+ /**
60
+ * True when the plan-state checkpoint's `decompose.shape` is the deliberate
61
+ * single shape. `plan-persist.js` writes `decompose = { ticketCount: 0,
62
+ * shape: "single" }` for a spec-only plan; every other value (including a
63
+ * legacy checkpoint with no `shape` field) is not-single.
64
+ *
65
+ * @param {{ decompose?: { shape?: string } } | null | undefined} checkpoint
66
+ * @returns {boolean}
67
+ */
68
+ function hasSingleDecomposeShape(checkpoint) {
69
+ return checkpoint?.decompose?.shape === 'single';
70
+ }
71
+
72
+ /**
73
+ * Resolve the delivery route for one Epic from its labels, its plan-state
74
+ * checkpoint, and the resolved config. See the module header for the full
75
+ * precedence. Pure — never throws; malformed / absent inputs degrade to the
76
+ * safe `fan-out` default.
77
+ *
78
+ * @param {{ labels?: Array<string | { name?: string }> } | null | undefined} epic
79
+ * The Epic ticket snapshot (only `labels` is read here).
80
+ * @param {{ decompose?: { shape?: string } } | null | undefined} checkpoint
81
+ * The `epic-plan-state` structured comment, parsed (or `null` on a legacy
82
+ * Epic that predates the checkpoint — treated as no marker).
83
+ * @param {object | null | undefined} config
84
+ * The resolved config; `delivery.routing.singleDelivery` is the kill-switch.
85
+ * @returns {{ route: DeliveryRoute, reason: string, singleDeliveryEnabled: boolean }}
86
+ */
87
+ export function resolveEpicDeliveryRoute(epic, checkpoint, config) {
88
+ const { singleDelivery } = getDeliveryRouting(config);
89
+
90
+ // 1. Kill-switch — overrides all. A `false` value forces fan-out even for
91
+ // an Epic the planner marked single (the instant global revert).
92
+ if (singleDelivery === false) {
93
+ return {
94
+ route: 'fan-out',
95
+ reason:
96
+ 'kill-switch: delivery.routing.singleDelivery=false forces fan-out for every Epic',
97
+ singleDeliveryEnabled: false,
98
+ };
99
+ }
100
+
101
+ // 2. Single marker — label (primary) OR checkpoint decompose shape
102
+ // (secondary). Either alone routes single.
103
+ const labelled = hasSingleLabel(epic);
104
+ const shaped = hasSingleDecomposeShape(checkpoint);
105
+ if (labelled || shaped) {
106
+ const via =
107
+ labelled && shaped
108
+ ? `${DELIVERY_SINGLE_LABEL} label + decompose.shape="single"`
109
+ : labelled
110
+ ? `${DELIVERY_SINGLE_LABEL} label`
111
+ : 'decompose.shape="single"';
112
+ return {
113
+ route: 'single',
114
+ reason: `single marker present (${via})`,
115
+ singleDeliveryEnabled: true,
116
+ };
117
+ }
118
+
119
+ // 3/4. No single marker → fan-out (a wide DAG or a legacy Story tree; the
120
+ // deliver-time width only advises and never reroutes here).
121
+ return {
122
+ route: 'fan-out',
123
+ reason: 'no single marker — fan-out (legacy Story tree or wide DAG)',
124
+ singleDeliveryEnabled: true,
125
+ };
126
+ }
127
+
128
+ /**
129
+ * Compute the ready-width of a delivery plan — the count of work units that
130
+ * could dispatch on the first beat. It is the mechanical parallelism signal
131
+ * the planner uses at plan time to mark a genuinely-wide Epic `fan-out`, and
132
+ * that `/deliver` may surface as an advisory at deliver time (it never
133
+ * reroutes — see the module header).
134
+ *
135
+ * Accepts either input shape the framework already produces:
136
+ * - Delivery-Slicing rows — `[{ independent: boolean }, …]` from
137
+ * `parseDeliverySlicingTable`. A serial chain (every row
138
+ * `Independent: No`, or a lone independent slice) has width 1; two or
139
+ * more independent slices yield a width equal to that independent count
140
+ * (> 1) — genuine parallelism.
141
+ * - A wave DAG — `[[…], […], …]` (the array-of-waves `state.waves` shape
142
+ * `runBuildWaveDagPhase` produces). The width is the largest wave — the
143
+ * most Stories ready to dispatch on a single beat.
144
+ *
145
+ * Pure and total: an empty / malformed / unrecognized input degrades to
146
+ * width 1 (the safe "not wide" signal). Never throws.
147
+ *
148
+ * @param {Array<{ independent?: boolean }> | Array<Array<unknown>> | null | undefined} deliverySlicingRowsOrDag
149
+ * @returns {number} an integer ≥ 1.
150
+ */
151
+ export function computeReadyWidth(deliverySlicingRowsOrDag) {
152
+ const input = deliverySlicingRowsOrDag;
153
+ if (!Array.isArray(input) || input.length === 0) return 1;
154
+
155
+ // Wave-DAG shape: an array whose entries are themselves arrays.
156
+ if (input.every((entry) => Array.isArray(entry))) {
157
+ let widest = 1;
158
+ for (const wave of input) {
159
+ if (wave.length > widest) widest = wave.length;
160
+ }
161
+ return widest;
162
+ }
163
+
164
+ // Delivery-Slicing rows: entries are `{ independent }` records.
165
+ const independentCount = input.reduce(
166
+ (acc, row) =>
167
+ row && typeof row === 'object' && row.independent === true
168
+ ? acc + 1
169
+ : acc,
170
+ 0,
171
+ );
172
+ return independentCount >= 2 ? independentCount : 1;
173
+ }
@@ -41,7 +41,7 @@ import { buildAuthoringGrounding } from './spec-authoring-grounding.js';
41
41
  * @param {{ github?: { owner?: string, repo?: string }|null }} opts
42
42
  * @returns {string|null}
43
43
  */
44
- export function resolveMemoryDir({ github } = {}) {
44
+ function resolveMemoryDir({ github } = {}) {
45
45
  if (
46
46
  typeof process.env.MANDREL_MEMORY_DIR === 'string' &&
47
47
  process.env.MANDREL_MEMORY_DIR.length > 0
@@ -326,6 +326,239 @@ export async function recordStoryStatus({
326
326
  });
327
327
  }
328
328
 
329
+ /**
330
+ * Terminal / in-progress per-slice statuses persisted on a single-delivery
331
+ * checkpoint. The single-delivery analogue of `STORY_STATUSES` — a spec-only
332
+ * plan authors no Story tickets, so the leaf work unit is a `## Delivery
333
+ * Slicing` slice walked in-session on `epic/<id>` (Epic #4475). The design's
334
+ * durable contract is `{pending, done}`; `blocked`/`failed` mirror the story
335
+ * map so the M4-B executor can record a stalled slice without a schema bump.
336
+ */
337
+ export const SLICE_STATUSES = Object.freeze([
338
+ 'pending',
339
+ 'done',
340
+ 'blocked',
341
+ 'failed',
342
+ ]);
343
+
344
+ /**
345
+ * Build the initial per-slice status map from parsed `## Delivery Slicing`
346
+ * rows (`parseDeliverySlicingTable` output — `[{ slice, independent }]`).
347
+ * Every slice seeds at `status: 'pending'`, keyed by a **stable, position-
348
+ * derived** id `slice-<n>` (1-based) so a re-run over the same table
349
+ * round-trips its keys. The slice's human label is carried through as `title`
350
+ * for the operator rollup. Pure — exported for unit tests.
351
+ *
352
+ * @param {Array<{ slice?: string, independent?: boolean } | string>} slices
353
+ * @returns {Record<string, { status: string, title?: string }>}
354
+ */
355
+ export function buildSliceStatusMap(slices) {
356
+ const out = {};
357
+ const rows = Array.isArray(slices) ? slices : [];
358
+ rows.forEach((entry, i) => {
359
+ const sliceId = `slice-${i + 1}`;
360
+ const record = { status: 'pending' };
361
+ const title =
362
+ typeof entry === 'string'
363
+ ? entry
364
+ : entry && typeof entry === 'object' && typeof entry.slice === 'string'
365
+ ? entry.slice
366
+ : undefined;
367
+ if (title) record.title = title;
368
+ out[sliceId] = record;
369
+ });
370
+ return out;
371
+ }
372
+
373
+ /**
374
+ * Merge a freshly-seeded slice status map onto a persisted one. Every slice
375
+ * present in either map appears in the result; when a slice exists in the
376
+ * prior map its recorded `status` wins (recorded progress — a `done` slice
377
+ * whose work already sits on `epic/<id>` — is never lost), while its `title`
378
+ * is refreshed from the incoming seed. Slices present only in the incoming
379
+ * seed are added at `pending`. Pure — exported for unit tests.
380
+ *
381
+ * @param {Record<string, object>|undefined} prior
382
+ * @param {Record<string, object>} incoming
383
+ * @returns {Record<string, object>}
384
+ */
385
+ export function mergeSliceStatuses(prior, incoming) {
386
+ const priorMap = prior && typeof prior === 'object' ? prior : {};
387
+ const seedMap = incoming && typeof incoming === 'object' ? incoming : {};
388
+ const out = {};
389
+ for (const key of new Set([
390
+ ...Object.keys(priorMap),
391
+ ...Object.keys(seedMap),
392
+ ])) {
393
+ const priorEntry = priorMap[key];
394
+ const seedEntry = seedMap[key];
395
+ if (priorEntry && typeof priorEntry === 'object') {
396
+ const merged = { ...priorEntry };
397
+ if (seedEntry && typeof seedEntry.title === 'string') {
398
+ merged.title = seedEntry.title;
399
+ }
400
+ out[key] = merged;
401
+ } else {
402
+ out[key] = seedEntry;
403
+ }
404
+ }
405
+ return out;
406
+ }
407
+
408
+ /**
409
+ * Initialize (or resume) the single-delivery checkpoint for an Epic. The
410
+ * single-delivery analogue of `initialize` (Story #4155's per-Story map),
411
+ * writing the slice-map shape the M4-B executor walks:
412
+ *
413
+ * ```json
414
+ * {
415
+ * "epicId": 4475,
416
+ * "deliveryShape": "single",
417
+ * "slices": { "slice-1": { "status": "pending", "title": "…" } },
418
+ * "concurrencyCap": 1,
419
+ * "storyCount": 0
420
+ * }
421
+ * ```
422
+ *
423
+ * Idempotent + resume-preserving: on a re-run an existing single checkpoint
424
+ * has its slice map **merged** (every already-`done` slice keeps its status
425
+ * so the executor skips it — the work already sits on `epic/<id>`), while
426
+ * newly-authored slices seed at `pending`. `startedAt` is preserved. A
427
+ * checkpoint written for the fan-out shape is NOT reinterpreted here — the
428
+ * caller (`epic-deliver-prepare.js --single`) owns route selection.
429
+ *
430
+ * @param {{
431
+ * provider: import('../ITicketingProvider.js').ITicketingProvider,
432
+ * epicId: number,
433
+ * slices: Array<{ slice?: string, independent?: boolean } | string>,
434
+ * concurrencyCap?: number,
435
+ * }} opts
436
+ * @returns {Promise<object>} the persisted (or preserved) state
437
+ */
438
+ export async function initializeSingle({
439
+ provider,
440
+ epicId,
441
+ slices,
442
+ concurrencyCap = 1,
443
+ } = {}) {
444
+ assertProvider(provider);
445
+ assertEpicId(epicId);
446
+ const seededSlices = buildSliceStatusMap(slices);
447
+ const storyCount = 0;
448
+ const existing = await read({ provider, epicId });
449
+ if (existing && existing.deliveryShape === 'single') {
450
+ const mergedSlices = mergeSliceStatuses(existing.slices, seededSlices);
451
+ if (
452
+ existing.concurrencyCap === concurrencyCap &&
453
+ sliceMapsEqual(existing.slices, mergedSlices)
454
+ ) {
455
+ return existing;
456
+ }
457
+ return write({
458
+ provider,
459
+ epicId,
460
+ state: {
461
+ ...existing,
462
+ deliveryShape: 'single',
463
+ concurrencyCap,
464
+ storyCount,
465
+ slices: mergedSlices,
466
+ },
467
+ });
468
+ }
469
+ return write({
470
+ provider,
471
+ epicId,
472
+ state: {
473
+ epicId,
474
+ startedAt: new Date().toISOString(),
475
+ deliveryShape: 'single',
476
+ concurrencyCap,
477
+ storyCount,
478
+ phase: 'prepare',
479
+ slices: seededSlices,
480
+ manualInterventions: [],
481
+ },
482
+ });
483
+ }
484
+
485
+ /**
486
+ * Record a per-slice terminal (or in-progress) status on a single-delivery
487
+ * checkpoint. The single-delivery analogue of `recordStoryStatus`: the M4-B
488
+ * executor calls this after each Delivery-Slicing slice commits to
489
+ * `epic/<id>`, flipping `slices[sliceId].status` `pending → done` so a resumed
490
+ * run skips the already-landed slice. Reads the current state first, splices
491
+ * the single slice's record into the `slices` map, and re-writes; other slices
492
+ * and all run-level fields are preserved verbatim. Tolerant of a
493
+ * legacy/absent `slices` map (treated as empty).
494
+ *
495
+ * @param {{
496
+ * provider: import('../ITicketingProvider.js').ITicketingProvider,
497
+ * epicId: number,
498
+ * sliceId: string,
499
+ * status: string,
500
+ * title?: string,
501
+ * }} opts
502
+ * @returns {Promise<object>} the persisted state
503
+ */
504
+ export async function recordSliceStatus({
505
+ provider,
506
+ epicId,
507
+ sliceId,
508
+ status,
509
+ title,
510
+ } = {}) {
511
+ assertProvider(provider);
512
+ assertEpicId(epicId);
513
+ if (typeof sliceId !== 'string' || sliceId.length === 0) {
514
+ throw new TypeError(
515
+ 'recordSliceStatus: sliceId must be a non-empty string',
516
+ );
517
+ }
518
+ if (!SLICE_STATUSES.includes(status)) {
519
+ throw new RangeError(
520
+ `recordSliceStatus: status "${status}" must be one of: ${SLICE_STATUSES.join(', ')}`,
521
+ );
522
+ }
523
+ const existing = (await read({ provider, epicId })) ?? {};
524
+ const slices =
525
+ existing.slices && typeof existing.slices === 'object'
526
+ ? { ...existing.slices }
527
+ : {};
528
+ const prior = slices[sliceId] ?? {};
529
+ const record = { ...prior, status };
530
+ if (typeof title === 'string' && title) record.title = title;
531
+ slices[sliceId] = record;
532
+ return write({
533
+ provider,
534
+ epicId,
535
+ state: { ...existing, slices },
536
+ });
537
+ }
538
+
539
+ /**
540
+ * Structural equality on two slice status maps — same key set and, per key,
541
+ * the same `status` and `title`. Used by `initializeSingle` to decide whether
542
+ * an idempotent re-prepare needs a rewrite. Pure.
543
+ *
544
+ * @param {Record<string, object>|undefined} a
545
+ * @param {Record<string, object>|undefined} b
546
+ * @returns {boolean}
547
+ */
548
+ function sliceMapsEqual(a, b) {
549
+ const left = a && typeof a === 'object' ? a : {};
550
+ const right = b && typeof b === 'object' ? b : {};
551
+ const keys = Object.keys(left);
552
+ if (keys.length !== Object.keys(right).length) return false;
553
+ for (const key of keys) {
554
+ const l = left[key] ?? {};
555
+ const r = right[key];
556
+ if (!r) return false;
557
+ if (l.status !== r.status || l.title !== r.title) return false;
558
+ }
559
+ return true;
560
+ }
561
+
329
562
  /**
330
563
  * Append a manual-intervention record to the checkpoint. Out-of-band
331
564
  * recovery steps the host LLM performs during a delivery — `AskUserQuestion`
@@ -15,7 +15,13 @@
15
15
  *
16
16
  * Rules (one error per mismatched path):
17
17
  * - `creates` + path **exists** → error (Story would clobber).
18
- * - `refactors-existing` + path **absent** → error (no target to refactor).
18
+ * - `refactors-existing` (via `changes`) + path **absent** →
19
+ * auto-normalized to `creates` with a logged warning (#4496 fix 5):
20
+ * a refactor declaration against a base-untracked path is
21
+ * deterministically a create, so rejecting it only forces a
22
+ * reject→amend→re-persist cycle for a mechanical rewrite. Genuine
23
+ * mismatches keep failing — a `references`-sourced `refactors-existing`
24
+ * on an absent path is a missing read dependency and stays an error.
19
25
  * - `exists` + path **absent** → error (read dependency missing).
20
26
  * - `deletes` + path **absent** → error (nothing to delete).
21
27
  *
@@ -185,6 +191,18 @@ function renderMismatch({
185
191
  return `"${slug}" → body.${source} declares assumption="${assumption}" for ${path} but the path already exists at the base branch.`;
186
192
  }
187
193
 
194
+ /**
195
+ * Render an auto-normalization (#4496 fix 5) into a stable warning string.
196
+ * Kept pure and exported through the report so callers log a
197
+ * self-explanatory line rather than re-deriving the rationale.
198
+ *
199
+ * @param {{ slug: string, source: string, path: string, assumption: string }} normalization
200
+ * @returns {string}
201
+ */
202
+ function renderNormalization({ slug, source, path, assumption }) {
203
+ return `"${slug}" → body.${source} declares assumption="${assumption}" for ${path} but the path is untracked at the base branch — auto-normalized to "creates" (a refactor of a base-untracked path is deterministically a create). Declare assumption="creates" in the plan to silence this warning.`;
204
+ }
205
+
188
206
  /**
189
207
  * Index, across every Story, which Stories declare a `creates` (and which
190
208
  * declare a `deletes`) for each `changes`-sourced path. The maps drive the
@@ -251,8 +269,11 @@ function predecessorMutator(index, path, predecessors) {
251
269
  *
252
270
  * {
253
271
  * errors: string[] // one entry per mismatch, batched per Story
254
- * warnings: string[] // legacy/no-assumption deprecation nudges
272
+ * warnings: string[] // legacy/no-assumption deprecation nudges +
273
+ * // auto-normalization notices (#4496 fix 5)
255
274
  * mismatches: object[] // structured payload for downstream tooling
275
+ * normalizations: object[] // `refactors-existing`→`creates`
276
+ * // auto-normalizations on base-untracked paths
256
277
  * }
257
278
  *
258
279
  * Under the 2-tier hierarchy the Story is the implementation unit, so the
@@ -283,6 +304,7 @@ export function validateStoryFileAssumptions(opts) {
283
304
  const errors = [];
284
305
  const warnings = [];
285
306
  const mismatches = [];
307
+ const normalizations = [];
286
308
  const probeCache = new Map();
287
309
 
288
310
  // Wave-aware setup (Story #3960): transitive predecessor sets over the
@@ -350,6 +372,14 @@ export function validateStoryFileAssumptions(opts) {
350
372
  predecessorCreator,
351
373
  });
352
374
  if (mismatch !== null) {
375
+ // Auto-normalization (#4496 fix 5): a deterministic
376
+ // `refactors-existing`→`creates` rewrite is a warning, never a
377
+ // rejection — genuine mismatches keep flowing to `errors`.
378
+ if (mismatch.normalizedTo === 'creates') {
379
+ normalizations.push(mismatch);
380
+ warnings.push(renderNormalization(mismatch));
381
+ continue;
382
+ }
353
383
  mismatches.push(mismatch);
354
384
  errors.push(renderMismatch(mismatch));
355
385
  continue;
@@ -384,7 +414,7 @@ export function validateStoryFileAssumptions(opts) {
384
414
  }
385
415
  }
386
416
  }
387
- return { errors, warnings, mismatches };
417
+ return { errors, warnings, mismatches, normalizations };
388
418
  }
389
419
 
390
420
  /**
@@ -468,12 +498,43 @@ function checkAssumption({
468
498
  }
469
499
  return null;
470
500
  case 'refactors-existing':
501
+ // Validate against the simulated tree: a predecessor `creates` makes
502
+ // an otherwise-absent base path present, so `refactors-existing`
503
+ // against it is no longer a false-positive mismatch (Story #3960).
504
+ if (!simulatedExists) {
505
+ // Auto-normalization (#4496 fix 5): a `changes`-sourced refactor
506
+ // declaration on a path untracked at the base branch (and not
507
+ // produced by any predecessor) is deterministically a create —
508
+ // downgrade to a normalization warning instead of rejecting. Two
509
+ // genuine mismatches stay hard errors: a `references`-sourced entry
510
+ // (a read dependency this Story does not author cannot be "created"
511
+ // here), and a base-TRACKED path a predecessor deletes (the absence
512
+ // is a plan-shape conflict, not an untracked-path misdeclaration).
513
+ if (source === 'changes' && !baseExists) {
514
+ return {
515
+ slug,
516
+ source,
517
+ path,
518
+ assumption,
519
+ expected: 'creates',
520
+ actual: 'absent',
521
+ normalizedTo: 'creates',
522
+ };
523
+ }
524
+ return {
525
+ slug,
526
+ source,
527
+ path,
528
+ assumption,
529
+ expected: 'present',
530
+ actual: 'absent',
531
+ };
532
+ }
533
+ return null;
471
534
  case 'exists':
472
535
  case 'deletes':
473
- // Validate against the simulated tree: a predecessor `creates` makes
474
- // an otherwise-absent base path present, so `refactors-existing` /
475
- // `exists` / `deletes` against it is no longer a false-positive
476
- // mismatch (Story #3960).
536
+ // Same simulated-tree overlay as above (Story #3960); an absent path
537
+ // remains a genuine mismatch for both assumptions.
477
538
  if (!simulatedExists) {
478
539
  return {
479
540
  slug,