mandrel 1.93.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 (135) 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/lifecycle/emit-slice-lifecycle.js +270 -0
  44. package/.agents/scripts/lib/orchestration/lifecycle/listeners/acceptance-reconciler.js +83 -2
  45. package/.agents/scripts/lib/orchestration/lifecycle/listeners/checkpoint-pointer-writer.js +6 -0
  46. package/.agents/scripts/lib/orchestration/plan-critic-conditions.js +3 -2
  47. package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +1 -0
  48. package/.agents/scripts/lib/orchestration/story-close/pre-merge-validation.js +1 -0
  49. package/.agents/scripts/lib/orchestration/ticket-validator.js +1 -1
  50. package/.agents/scripts/lib/provider-factory.js +1 -1
  51. package/.agents/scripts/lib/templates/decomposer-prompts.js +1 -1
  52. package/.agents/scripts/post-structured-comment.js +38 -0
  53. package/.agents/scripts/slice-phase.js +361 -0
  54. package/.agents/scripts/sync-claude-agents.js +165 -0
  55. package/.agents/scripts/update-ticket-state.js +31 -0
  56. package/.agents/scripts/wave-tick.js +138 -9
  57. package/.agents/skills/core/api-and-interface-design/SKILL.md +5 -3
  58. package/.agents/skills/core/code-review-and-quality/SKILL.md +63 -7
  59. package/.agents/skills/core/debugging-and-error-recovery/SKILL.md +1 -1
  60. package/.agents/skills/core/epic-plan-consolidate/SKILL.md +5 -5
  61. package/.agents/skills/core/epic-plan-decompose-author/SKILL.md +8 -8
  62. package/.agents/skills/core/epic-plan-premortem/SKILL.md +4 -4
  63. package/.agents/skills/core/epic-plan-spec-author/SKILL.md +26 -56
  64. package/.agents/skills/core/gates-and-baselines/SKILL.md +149 -0
  65. package/.agents/skills/core/idea-refinement/SKILL.md +2 -8
  66. package/.agents/skills/core/qa-coverage-mapping/SKILL.md +7 -7
  67. package/.agents/skills/skills.index.json +11 -381
  68. package/.agents/workflows/deliver.md +47 -4
  69. package/.agents/workflows/helpers/acceptance-self-eval.md +38 -13
  70. package/.agents/workflows/helpers/deliver-epic-reference.md +18 -5
  71. package/.agents/workflows/helpers/deliver-epic-single.md +331 -0
  72. package/.agents/workflows/helpers/deliver-epic.md +51 -8
  73. package/.agents/workflows/helpers/deliver-stories.md +15 -5
  74. package/.agents/workflows/helpers/epic-deliver-story.md +12 -3
  75. package/.agents/workflows/helpers/mandrel-sync-config.md +1 -1
  76. package/.agents/workflows/helpers/plan-epic.md +25 -23
  77. package/.agents/workflows/mandrel-update.md +1 -1
  78. package/docs/CHANGELOG.md +16 -0
  79. package/lib/cli/registry.js +95 -0
  80. package/package.json +4 -2
  81. package/.agents/personas/engineer-mobile.md +0 -120
  82. package/.agents/personas/engineer-web.md +0 -111
  83. package/.agents/personas/product.md +0 -94
  84. package/.agents/personas/refactorer.md +0 -113
  85. package/.agents/personas/sre.md +0 -86
  86. package/.agents/personas/ux-designer.md +0 -95
  87. package/.agents/scripts/epic-plan-decompose.js +0 -54
  88. package/.agents/scripts/epic-plan-spec.js +0 -64
  89. package/.agents/scripts/lib/orchestration/skill-capsule-loader.js +0 -109
  90. package/.agents/scripts/plan-critics.js +0 -199
  91. package/.agents/skills/core/baseline-refresh/SKILL.md +0 -181
  92. package/.agents/skills/core/ci-cd-and-automation/SKILL.md +0 -274
  93. package/.agents/skills/core/ci-cd-and-automation/examples.md +0 -211
  94. package/.agents/skills/core/code-simplification/SKILL.md +0 -389
  95. package/.agents/skills/core/context-engineering/SKILL.md +0 -309
  96. package/.agents/skills/core/context-engineering/examples.md +0 -58
  97. package/.agents/skills/core/deprecation-and-migration/SKILL.md +0 -250
  98. package/.agents/skills/core/frontend-ui-engineering/SKILL.md +0 -357
  99. package/.agents/skills/core/hydrate-context/SKILL.md +0 -123
  100. package/.agents/skills/core/idea-refinement/examples.md +0 -437
  101. package/.agents/skills/core/idea-refinement/frameworks.md +0 -135
  102. package/.agents/skills/core/incremental-implementation/SKILL.md +0 -271
  103. package/.agents/skills/core/introducing-a-baseline-gate/SKILL.md +0 -213
  104. package/.agents/skills/core/knowledge-transfer/SKILL.md +0 -180
  105. package/.agents/skills/core/mutation-survivor-remediation/SKILL.md +0 -117
  106. package/.agents/skills/core/performance-optimization/SKILL.md +0 -314
  107. package/.agents/skills/core/planning-and-task-breakdown/SKILL.md +0 -277
  108. package/.agents/skills/core/property-based-testing/SKILL.md +0 -148
  109. package/.agents/skills/core/refactoring-discipline/SKILL.md +0 -111
  110. package/.agents/skills/core/shipping-and-launch/SKILL.md +0 -328
  111. package/.agents/skills/core/spec-driven-development/SKILL.md +0 -252
  112. package/.agents/skills/core/test-driven-development/SKILL.md +0 -475
  113. package/.agents/skills/core/using-agent-skills/SKILL.md +0 -232
  114. package/.agents/skills/stack/architecture/monorepo-path-strategist/SKILL.md +0 -31
  115. package/.agents/skills/stack/architecture/structured-output-zod/SKILL.md +0 -51
  116. package/.agents/skills/stack/architecture/subagent-orchestration/SKILL.md +0 -76
  117. package/.agents/skills/stack/backend/cloudflare-hono-architect/SKILL.md +0 -31
  118. package/.agents/skills/stack/backend/cloudflare-hono-architect/examples/route-template.ts +0 -33
  119. package/.agents/skills/stack/backend/cloudflare-queue-manager/SKILL.md +0 -31
  120. package/.agents/skills/stack/backend/cloudflare-workers/SKILL.md +0 -51
  121. package/.agents/skills/stack/backend/highlevel-crm/SKILL.md +0 -54
  122. package/.agents/skills/stack/backend/sqlite-drizzle-expert/SKILL.md +0 -29
  123. package/.agents/skills/stack/backend/sqlite-drizzle-expert/examples/schema-template.ts +0 -30
  124. package/.agents/skills/stack/backend/stripe-integration/SKILL.md +0 -57
  125. package/.agents/skills/stack/backend/stripe-integration/scripts/listen-stripe.sh +0 -9
  126. package/.agents/skills/stack/backend/turso-sqlite/SKILL.md +0 -48
  127. package/.agents/skills/stack/frontend/astro/SKILL.md +0 -62
  128. package/.agents/skills/stack/frontend/astro-react-island-strategist/SKILL.md +0 -30
  129. package/.agents/skills/stack/frontend/expo-react-native-developer/SKILL.md +0 -29
  130. package/.agents/skills/stack/frontend/google-analytics-v4/SKILL.md +0 -50
  131. package/.agents/skills/stack/frontend/tailwind-v4/SKILL.md +0 -58
  132. package/.agents/skills/stack/frontend/ui-accessibility-engineer/SKILL.md +0 -34
  133. package/.agents/skills/stack/qa/audit-accessibility/SKILL.md +0 -51
  134. package/.agents/skills/stack/qa/lighthouse-baseline/SKILL.md +0 -199
  135. 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`
@@ -0,0 +1,270 @@
1
+ /**
2
+ * emit-slice-lifecycle.js — Epic #4475 (M4-A).
3
+ *
4
+ * Programmatic emitters for the three single-delivery slice lifecycle events
5
+ * (`slice.start`, `slice.end`, `slice.heartbeat`) — the single-delivery
6
+ * analogues of the per-Story `story.dispatch.start` / `story.dispatch.end` /
7
+ * `story.heartbeat` events. They append one schema-validated NDJSON record to
8
+ * `temp/epic-<id>/lifecycle.ndjson`, mirroring `emit-story-heartbeat.js`.
9
+ *
10
+ * A single-delivery run collapses the whole Epic into one long guarded
11
+ * session walking the `## Delivery Slicing` table on `epic/<id>`. Without a
12
+ * per-Story heartbeat the `/deliver` idle watchdog cannot tell a live session
13
+ * from a dead one, so these events give the long session an inspectable
14
+ * forward-progress signal (`slice.heartbeat`) plus per-slice boundaries
15
+ * (`slice.start` / `slice.end`) the resume path reads back.
16
+ *
17
+ * Introduced INERT in M4-A: the executor that emits these lands in M4-B
18
+ * (`deliver-epic-single.md`). Shipping the emitters + schemas now keeps that
19
+ * PR the flip-only change.
20
+ *
21
+ * The emit is best-effort at the call site (a failure to append MUST NOT
22
+ * block the slice transition itself); callers catch and log via the script's
23
+ * Logger, exactly as the story-heartbeat callers do. These functions still
24
+ * throw on a programming error (bad argument / schema mismatch) so the bug
25
+ * surfaces in tests.
26
+ */
27
+
28
+ import { appendFileSync, mkdirSync, readFileSync } from 'node:fs';
29
+ import path from 'node:path';
30
+ import { fileURLToPath } from 'node:url';
31
+
32
+ import Ajv2020 from 'ajv/dist/2020.js';
33
+ import addFormats from 'ajv-formats';
34
+
35
+ import { epicLedgerPath } from '../../config/temp-paths.js';
36
+
37
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
38
+ const SCHEMA_DIR = path.resolve(
39
+ __dirname,
40
+ '..',
41
+ '..',
42
+ '..',
43
+ '..',
44
+ 'schemas',
45
+ 'lifecycle',
46
+ );
47
+
48
+ const VALID_PHASES = new Set([
49
+ 'init',
50
+ 'implementing',
51
+ 'closing',
52
+ 'blocked',
53
+ 'done',
54
+ ]);
55
+
56
+ const VALID_OUTCOMES = new Set(['done', 'blocked', 'failed', 'skipped']);
57
+
58
+ /** Lazily-compiled AJV validators, one per event schema. */
59
+ const _validators = new Map();
60
+
61
+ function getValidator(event) {
62
+ let validator = _validators.get(event);
63
+ if (!validator) {
64
+ const schema = JSON.parse(
65
+ readFileSync(path.join(SCHEMA_DIR, `${event}.schema.json`), 'utf8'),
66
+ );
67
+ const ajv = new Ajv2020({ allErrors: true, strict: false });
68
+ addFormats(ajv);
69
+ validator = ajv.compile(schema);
70
+ _validators.set(event, validator);
71
+ }
72
+ return validator;
73
+ }
74
+
75
+ function assertEpicId(epicId) {
76
+ if (!Number.isInteger(epicId) || epicId < 1) {
77
+ throw new Error('emit-slice-lifecycle: epicId must be a positive integer');
78
+ }
79
+ }
80
+
81
+ function assertSliceId(sliceId) {
82
+ if (typeof sliceId !== 'string' || sliceId.length === 0) {
83
+ throw new Error('emit-slice-lifecycle: sliceId must be a non-empty string');
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Validate `payload` against `<event>.schema.json`, then append it as a single
89
+ * NDJSON `emitted` record to the Epic ledger. Shared tail of all three
90
+ * emitters.
91
+ *
92
+ * @param {string} event
93
+ * @param {object} payload
94
+ * @param {{ epicId: number, timestamp: string, config?: object, ledgerPath?: string }} ctx
95
+ * @returns {{ ledgerPath: string, record: object }}
96
+ */
97
+ function appendSliceEvent(
98
+ event,
99
+ payload,
100
+ { epicId, timestamp, config, ledgerPath: ledgerPathOverride },
101
+ ) {
102
+ const validator = getValidator(event);
103
+ if (!validator(payload)) {
104
+ const detail = (validator.errors ?? [])
105
+ .map((e) => `${e.instancePath || '/'} ${e.message}`)
106
+ .join('; ');
107
+ throw new Error(
108
+ `emit-slice-lifecycle: ${event} payload failed schema validation: ${detail}`,
109
+ );
110
+ }
111
+
112
+ const ledgerPath = ledgerPathOverride ?? epicLedgerPath(epicId, config);
113
+ mkdirSync(path.dirname(ledgerPath), { recursive: true });
114
+ const record = { kind: 'emitted', ts: timestamp, event, payload };
115
+ appendFileSync(ledgerPath, `${JSON.stringify(record)}\n`, 'utf8');
116
+ return { ledgerPath, record };
117
+ }
118
+
119
+ /**
120
+ * Append one `slice.start` record as the executor begins implementing a slice.
121
+ *
122
+ * @param {object} opts
123
+ * @param {number} opts.epicId
124
+ * @param {string} opts.sliceId Stable slice-map key (e.g. `slice-1`).
125
+ * @param {number} [opts.sliceIndex] Zero-based position in the Delivery
126
+ * Slicing table.
127
+ * @param {string} [opts.title] Human-readable slice label.
128
+ * @param {string} [opts.timestamp] ISO-8601 wall clock. Defaults to now().
129
+ * @param {object} [opts.config]
130
+ * @param {string} [opts.ledgerPath] Override for tests.
131
+ * @returns {{ ledgerPath: string, record: object }}
132
+ */
133
+ export function emitSliceStart(opts) {
134
+ const {
135
+ epicId,
136
+ sliceId,
137
+ sliceIndex,
138
+ title,
139
+ timestamp = new Date().toISOString(),
140
+ config,
141
+ ledgerPath,
142
+ } = opts ?? {};
143
+ assertEpicId(epicId);
144
+ assertSliceId(sliceId);
145
+
146
+ const payload = {
147
+ event: 'slice.start',
148
+ epicId,
149
+ sliceId,
150
+ ...(Number.isInteger(sliceIndex) ? { sliceIndex } : {}),
151
+ ...(typeof title === 'string' ? { title } : {}),
152
+ timestamp,
153
+ };
154
+ return appendSliceEvent('slice.start', payload, {
155
+ epicId,
156
+ timestamp,
157
+ config,
158
+ ledgerPath,
159
+ });
160
+ }
161
+
162
+ /**
163
+ * Append one `slice.end` record when a slice finishes. A `done` outcome is
164
+ * what the slice-map checkpoint flips `slices[id].status` to before the walk
165
+ * advances.
166
+ *
167
+ * @param {object} opts
168
+ * @param {number} opts.epicId
169
+ * @param {string} opts.sliceId
170
+ * @param {'done'|'blocked'|'failed'|'skipped'} opts.outcome
171
+ * @param {number} [opts.sliceIndex]
172
+ * @param {number} [opts.durationMs]
173
+ * @param {string} [opts.timestamp]
174
+ * @param {object} [opts.config]
175
+ * @param {string} [opts.ledgerPath]
176
+ * @returns {{ ledgerPath: string, record: object }}
177
+ */
178
+ export function emitSliceEnd(opts) {
179
+ const {
180
+ epicId,
181
+ sliceId,
182
+ outcome,
183
+ sliceIndex,
184
+ durationMs,
185
+ timestamp = new Date().toISOString(),
186
+ config,
187
+ ledgerPath,
188
+ } = opts ?? {};
189
+ assertEpicId(epicId);
190
+ assertSliceId(sliceId);
191
+ if (!VALID_OUTCOMES.has(outcome)) {
192
+ throw new Error(
193
+ `emit-slice-lifecycle: slice.end outcome "${outcome}" must be one of: ${[...VALID_OUTCOMES].join(', ')}`,
194
+ );
195
+ }
196
+
197
+ const payload = {
198
+ event: 'slice.end',
199
+ epicId,
200
+ sliceId,
201
+ ...(Number.isInteger(sliceIndex) ? { sliceIndex } : {}),
202
+ outcome,
203
+ ...(Number.isInteger(durationMs) ? { durationMs } : {}),
204
+ timestamp,
205
+ };
206
+ return appendSliceEvent('slice.end', payload, {
207
+ epicId,
208
+ timestamp,
209
+ config,
210
+ ledgerPath,
211
+ });
212
+ }
213
+
214
+ /**
215
+ * Append one `slice.heartbeat` record from inside a slice's implementation
216
+ * loop — the forward-progress signal the idle watchdog reads for the single
217
+ * long session.
218
+ *
219
+ * @param {object} opts
220
+ * @param {number} opts.epicId
221
+ * @param {string} opts.sliceId
222
+ * @param {string} [opts.phase='implementing'] init|implementing|closing|blocked|done.
223
+ * @param {string} [opts.timestamp]
224
+ * @param {string} [opts.operator] Optional lease-owner handle; included only
225
+ * when a non-empty string is supplied.
226
+ * @param {object} [opts.config]
227
+ * @param {string} [opts.ledgerPath]
228
+ * @returns {{ ledgerPath: string, record: object }}
229
+ */
230
+ export function emitSliceHeartbeat(opts) {
231
+ const {
232
+ epicId,
233
+ sliceId,
234
+ phase = 'implementing',
235
+ timestamp = new Date().toISOString(),
236
+ operator,
237
+ config,
238
+ ledgerPath,
239
+ } = opts ?? {};
240
+ assertEpicId(epicId);
241
+ assertSliceId(sliceId);
242
+ if (!VALID_PHASES.has(phase)) {
243
+ throw new Error(
244
+ `emit-slice-lifecycle: slice.heartbeat phase "${phase}" must be one of: ${[...VALID_PHASES].join(', ')}`,
245
+ );
246
+ }
247
+ if (
248
+ operator !== undefined &&
249
+ (typeof operator !== 'string' || operator.length === 0)
250
+ ) {
251
+ throw new Error(
252
+ 'emit-slice-lifecycle: operator, when supplied, must be a non-empty string',
253
+ );
254
+ }
255
+
256
+ const payload = {
257
+ event: 'slice.heartbeat',
258
+ epicId,
259
+ sliceId,
260
+ phase,
261
+ timestamp,
262
+ ...(operator !== undefined ? { operator } : {}),
263
+ };
264
+ return appendSliceEvent('slice.heartbeat', payload, {
265
+ epicId,
266
+ timestamp,
267
+ config,
268
+ ledgerPath,
269
+ });
270
+ }