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.
- package/.agents/agents/acceptance-critic.md +129 -0
- package/.agents/agents/retro.md +42 -0
- package/.agents/agents/story-worker.md +162 -0
- package/.agents/docs/configuration.md +7 -1
- package/.agents/docs/execution-reference.md +27 -2
- package/.agents/instructions.md +43 -33
- package/.agents/personas/engineer.md +26 -112
- package/.agents/personas/security-engineer.md +1 -2
- package/.agents/rules/git-conventions-reference.md +225 -0
- package/.agents/rules/git-conventions.md +25 -200
- package/.agents/rules/security-baseline.md +5 -0
- package/.agents/rules/testing-standards.md +106 -13
- package/.agents/schemas/agentrc.schema.json +31 -1
- package/.agents/schemas/lifecycle/slice.end.schema.json +21 -0
- package/.agents/schemas/lifecycle/slice.heartbeat.schema.json +20 -0
- package/.agents/schemas/lifecycle/slice.start.schema.json +17 -0
- package/.agents/scripts/acceptance-eval.js +62 -18
- package/.agents/scripts/agents-bootstrap-github.js +1 -1
- package/.agents/scripts/bookkeeping-reconcile.js +117 -0
- package/.agents/scripts/check-context-budget.js +62 -5
- package/.agents/scripts/diagnose-friction.js +0 -6
- package/.agents/scripts/epic-deliver-prepare.js +272 -10
- package/.agents/scripts/lib/bootstrap/project-bootstrap.js +56 -18
- package/.agents/scripts/lib/close-validation/gates.js +159 -21
- package/.agents/scripts/lib/config/acceptance-eval.js +52 -5
- package/.agents/scripts/lib/config/delivery-routing.js +87 -0
- package/.agents/scripts/lib/config/explain.js +2 -0
- package/.agents/scripts/lib/config-resolver.js +1 -1
- package/.agents/scripts/lib/config-settings-schema-delivery.js +37 -3
- package/.agents/scripts/lib/config-settings-schema-quality.js +9 -0
- package/.agents/scripts/lib/doc-tiers.js +37 -2
- package/.agents/scripts/lib/observability/active-story-env.js +111 -2
- package/.agents/scripts/lib/observability/hook-heartbeat.js +219 -0
- package/.agents/scripts/lib/observability/tool-trace-hook.js +15 -4
- package/.agents/scripts/lib/orchestration/acceptance-clusters.js +111 -0
- package/.agents/scripts/lib/orchestration/acceptance-eval-decision.js +32 -4
- package/.agents/scripts/lib/orchestration/bookkeeping-outbox.js +270 -0
- package/.agents/scripts/lib/orchestration/ceremony-routing.js +141 -0
- package/.agents/scripts/lib/orchestration/context-hydration-engine.js +3 -124
- package/.agents/scripts/lib/orchestration/deliver-route.js +173 -0
- package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/authoring-context.js +1 -1
- package/.agents/scripts/lib/orchestration/epic-run-state-store.js +233 -0
- package/.agents/scripts/lib/orchestration/lifecycle/emit-slice-lifecycle.js +270 -0
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/acceptance-reconciler.js +83 -2
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/checkpoint-pointer-writer.js +6 -0
- package/.agents/scripts/lib/orchestration/plan-critic-conditions.js +3 -2
- package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +1 -0
- package/.agents/scripts/lib/orchestration/story-close/pre-merge-validation.js +1 -0
- package/.agents/scripts/lib/orchestration/ticket-validator.js +1 -1
- package/.agents/scripts/lib/provider-factory.js +1 -1
- package/.agents/scripts/lib/templates/decomposer-prompts.js +1 -1
- package/.agents/scripts/post-structured-comment.js +38 -0
- package/.agents/scripts/slice-phase.js +361 -0
- package/.agents/scripts/sync-claude-agents.js +165 -0
- package/.agents/scripts/update-ticket-state.js +31 -0
- package/.agents/scripts/wave-tick.js +138 -9
- package/.agents/skills/core/api-and-interface-design/SKILL.md +5 -3
- package/.agents/skills/core/code-review-and-quality/SKILL.md +63 -7
- package/.agents/skills/core/debugging-and-error-recovery/SKILL.md +1 -1
- package/.agents/skills/core/epic-plan-consolidate/SKILL.md +5 -5
- package/.agents/skills/core/epic-plan-decompose-author/SKILL.md +8 -8
- package/.agents/skills/core/epic-plan-premortem/SKILL.md +4 -4
- package/.agents/skills/core/epic-plan-spec-author/SKILL.md +26 -56
- package/.agents/skills/core/gates-and-baselines/SKILL.md +149 -0
- package/.agents/skills/core/idea-refinement/SKILL.md +2 -8
- package/.agents/skills/core/qa-coverage-mapping/SKILL.md +7 -7
- package/.agents/skills/skills.index.json +11 -381
- package/.agents/workflows/deliver.md +47 -4
- package/.agents/workflows/helpers/acceptance-self-eval.md +38 -13
- package/.agents/workflows/helpers/deliver-epic-reference.md +18 -5
- package/.agents/workflows/helpers/deliver-epic-single.md +331 -0
- package/.agents/workflows/helpers/deliver-epic.md +51 -8
- package/.agents/workflows/helpers/deliver-stories.md +15 -5
- package/.agents/workflows/helpers/epic-deliver-story.md +12 -3
- package/.agents/workflows/helpers/mandrel-sync-config.md +1 -1
- package/.agents/workflows/helpers/plan-epic.md +25 -23
- package/.agents/workflows/mandrel-update.md +1 -1
- package/docs/CHANGELOG.md +16 -0
- package/lib/cli/registry.js +95 -0
- package/package.json +4 -2
- package/.agents/personas/engineer-mobile.md +0 -120
- package/.agents/personas/engineer-web.md +0 -111
- package/.agents/personas/product.md +0 -94
- package/.agents/personas/refactorer.md +0 -113
- package/.agents/personas/sre.md +0 -86
- package/.agents/personas/ux-designer.md +0 -95
- package/.agents/scripts/epic-plan-decompose.js +0 -54
- package/.agents/scripts/epic-plan-spec.js +0 -64
- package/.agents/scripts/lib/orchestration/skill-capsule-loader.js +0 -109
- package/.agents/scripts/plan-critics.js +0 -199
- package/.agents/skills/core/baseline-refresh/SKILL.md +0 -181
- package/.agents/skills/core/ci-cd-and-automation/SKILL.md +0 -274
- package/.agents/skills/core/ci-cd-and-automation/examples.md +0 -211
- package/.agents/skills/core/code-simplification/SKILL.md +0 -389
- package/.agents/skills/core/context-engineering/SKILL.md +0 -309
- package/.agents/skills/core/context-engineering/examples.md +0 -58
- package/.agents/skills/core/deprecation-and-migration/SKILL.md +0 -250
- package/.agents/skills/core/frontend-ui-engineering/SKILL.md +0 -357
- package/.agents/skills/core/hydrate-context/SKILL.md +0 -123
- package/.agents/skills/core/idea-refinement/examples.md +0 -437
- package/.agents/skills/core/idea-refinement/frameworks.md +0 -135
- package/.agents/skills/core/incremental-implementation/SKILL.md +0 -271
- package/.agents/skills/core/introducing-a-baseline-gate/SKILL.md +0 -213
- package/.agents/skills/core/knowledge-transfer/SKILL.md +0 -180
- package/.agents/skills/core/mutation-survivor-remediation/SKILL.md +0 -117
- package/.agents/skills/core/performance-optimization/SKILL.md +0 -314
- package/.agents/skills/core/planning-and-task-breakdown/SKILL.md +0 -277
- package/.agents/skills/core/property-based-testing/SKILL.md +0 -148
- package/.agents/skills/core/refactoring-discipline/SKILL.md +0 -111
- package/.agents/skills/core/shipping-and-launch/SKILL.md +0 -328
- package/.agents/skills/core/spec-driven-development/SKILL.md +0 -252
- package/.agents/skills/core/test-driven-development/SKILL.md +0 -475
- package/.agents/skills/core/using-agent-skills/SKILL.md +0 -232
- package/.agents/skills/stack/architecture/monorepo-path-strategist/SKILL.md +0 -31
- package/.agents/skills/stack/architecture/structured-output-zod/SKILL.md +0 -51
- package/.agents/skills/stack/architecture/subagent-orchestration/SKILL.md +0 -76
- package/.agents/skills/stack/backend/cloudflare-hono-architect/SKILL.md +0 -31
- package/.agents/skills/stack/backend/cloudflare-hono-architect/examples/route-template.ts +0 -33
- package/.agents/skills/stack/backend/cloudflare-queue-manager/SKILL.md +0 -31
- package/.agents/skills/stack/backend/cloudflare-workers/SKILL.md +0 -51
- package/.agents/skills/stack/backend/highlevel-crm/SKILL.md +0 -54
- package/.agents/skills/stack/backend/sqlite-drizzle-expert/SKILL.md +0 -29
- package/.agents/skills/stack/backend/sqlite-drizzle-expert/examples/schema-template.ts +0 -30
- package/.agents/skills/stack/backend/stripe-integration/SKILL.md +0 -57
- package/.agents/skills/stack/backend/stripe-integration/scripts/listen-stripe.sh +0 -9
- package/.agents/skills/stack/backend/turso-sqlite/SKILL.md +0 -48
- package/.agents/skills/stack/frontend/astro/SKILL.md +0 -62
- package/.agents/skills/stack/frontend/astro-react-island-strategist/SKILL.md +0 -30
- package/.agents/skills/stack/frontend/expo-react-native-developer/SKILL.md +0 -29
- package/.agents/skills/stack/frontend/google-analytics-v4/SKILL.md +0 -50
- package/.agents/skills/stack/frontend/tailwind-v4/SKILL.md +0 -58
- package/.agents/skills/stack/frontend/ui-accessibility-engineer/SKILL.md +0 -34
- package/.agents/skills/stack/qa/audit-accessibility/SKILL.md +0 -51
- package/.agents/skills/stack/qa/lighthouse-baseline/SKILL.md +0 -199
- 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
|
-
|
|
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
|
+
}
|