mandrel 1.67.0 → 1.69.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 (50) hide show
  1. package/.agents/docs/agentrc-reference.json +1 -2
  2. package/.agents/docs/configuration.md +2 -4
  3. package/.agents/schemas/agentrc.schema.json +1 -5
  4. package/.agents/schemas/lifecycle/epic.automerge.end.schema.json +2 -1
  5. package/.agents/scripts/epic-deliver-preflight.js +30 -13
  6. package/.agents/scripts/epic-deliver-prepare.js +40 -53
  7. package/.agents/scripts/epic-execute-record-wave.js +119 -133
  8. package/.agents/scripts/lib/baselines/refresh-service.js +13 -1
  9. package/.agents/scripts/lib/config/explain.js +0 -2
  10. package/.agents/scripts/lib/config/limits.js +19 -8
  11. package/.agents/scripts/lib/config-settings-schema-delivery.js +17 -0
  12. package/.agents/scripts/lib/config-settings-schema.js +12 -2
  13. package/.agents/scripts/lib/maintainability-utils.js +32 -9
  14. package/.agents/scripts/lib/orchestration/epic-cleanup.js +11 -7
  15. package/.agents/scripts/lib/orchestration/epic-plan-decompose/phases/cli.js +6 -6
  16. package/.agents/scripts/lib/orchestration/epic-plan-decompose/phases/context.js +11 -5
  17. package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/run-spec-phase.js +32 -1
  18. package/.agents/scripts/lib/orchestration/epic-run-state-store.js +203 -110
  19. package/.agents/scripts/lib/orchestration/epic-runner/progress-reporter/composition.js +38 -78
  20. package/.agents/scripts/lib/orchestration/epic-runner/progress-reporter/transport.js +16 -13
  21. package/.agents/scripts/lib/orchestration/epic-runner/sub-agent-return.js +10 -7
  22. package/.agents/scripts/lib/orchestration/lifecycle/listeners/automerge-predicate.js +37 -24
  23. package/.agents/scripts/lib/orchestration/lifecycle/listeners/watcher.js +13 -8
  24. package/.agents/scripts/lib/orchestration/manifest-builder.js +6 -0
  25. package/.agents/scripts/lib/orchestration/planning-risk.js +45 -6
  26. package/.agents/scripts/lib/orchestration/ticket-validator-sizing.js +6 -2
  27. package/.agents/scripts/lib/orchestration/wave-record-io.js +18 -77
  28. package/.agents/scripts/lib/orchestration/wave-record-notifications.js +78 -122
  29. package/.agents/scripts/lib/orchestration/wave-record-projection.js +21 -226
  30. package/.agents/scripts/lib/presentation/dispatch-manifest-render.js +18 -1
  31. package/.agents/scripts/lib/presentation/manifest-render-waves.js +77 -4
  32. package/.agents/scripts/lib/story-adjacency.js +14 -10
  33. package/.agents/scripts/lib/story-body/story-body.js +36 -4
  34. package/.agents/scripts/lib/templates/decomposer-prompts.js +23 -3
  35. package/.agents/scripts/lib/wave-runner/ready-set.js +295 -0
  36. package/.agents/scripts/lib/wave-runner/tick.js +312 -206
  37. package/.agents/scripts/lib/wave-runner/wave-runner-error.js +2 -1
  38. package/.agents/scripts/lint-label-vocabulary.js +1 -1
  39. package/.agents/scripts/stories-wave-tick.js +262 -161
  40. package/.agents/skills/core/epic-plan-consolidate/SKILL.md +6 -0
  41. package/.agents/skills/core/epic-plan-decompose-author/SKILL.md +108 -101
  42. package/.agents/skills/skills.index.json +2 -2
  43. package/.agents/workflows/deliver.md +12 -9
  44. package/.agents/workflows/helpers/deliver-epic.md +126 -90
  45. package/.agents/workflows/helpers/deliver-stories.md +131 -85
  46. package/.agents/workflows/helpers/plan-epic.md +13 -10
  47. package/.agents/workflows/plan.md +1 -1
  48. package/docs/CHANGELOG.md +26 -0
  49. package/package.json +1 -1
  50. package/.agents/scripts/lib/wave-runner/wave-checkpoint.js +0 -91
@@ -89,13 +89,13 @@ async function runEmitContextPath({ epicId, provider, config, values }) {
89
89
  const ctx = await buildDecompositionContext(epicId, provider, config, {
90
90
  fullContext: values['full-context'],
91
91
  });
92
- // Surface the resolved budget on stderr so a misconfigured `.agentrc.json`
93
- // (e.g. flat-key `maxTickets` instead of grouped `planning.maxTickets`) is
94
- // visible to the operator. The decomposer prompt embeds the same value.
95
- // Story #2798 — language changed from "prompt cap" to "reviewability
96
- // budget" to match the new prompt/skill contract.
92
+ // Surface the resolved reviewability budget on stderr so the operator
93
+ // sees the same value the decomposer prompt embeds. `maxTickets` is a
94
+ // framework constant (LIMITS_DEFAULTS.maxTickets), no longer an
95
+ // operator-configurable key. Story #2798 — language is "reviewability
96
+ // budget", not "prompt cap".
97
97
  Logger.error(
98
- `[epic-plan-decompose] Resolved planning.maxTickets = ${ctx.maxTickets} (reviewability budget).`,
98
+ `[epic-plan-decompose] Resolved maxTickets reviewability budget = ${ctx.maxTickets} (framework constant).`,
99
99
  );
100
100
  const json = values.pretty
101
101
  ? JSON.stringify(ctx, null, 2)
@@ -22,9 +22,9 @@ import { applyBudget } from '../../planning-context-budget.js';
22
22
 
23
23
  export function buildDecomposerSystemPrompt(
24
24
  heuristics = [],
25
- { maxTickets } = {},
25
+ { maxTickets, maxTokenBudget } = {},
26
26
  ) {
27
- const base = renderDecomposerSystemPrompt({ maxTickets });
27
+ const base = renderDecomposerSystemPrompt({ maxTickets, maxTokenBudget });
28
28
  const heuristicsStr =
29
29
  heuristics.length > 0
30
30
  ? `### RISK HEURISTICS (planning metadata if any apply):\n- ${heuristics.join('\n- ')}`
@@ -115,9 +115,13 @@ export async function buildDecompositionContext(
115
115
  const heuristics = resolveHeuristics(config);
116
116
  const limits = getLimits(config);
117
117
  const maxTickets = limits.maxTickets;
118
+ const maxTokenBudget = limits.maxTokenBudget;
118
119
  const planningLimits = limits.planningContext;
119
120
  const { fullContext = false } = opts;
120
- const systemPrompt = buildDecomposerSystemPrompt(heuristics, { maxTickets });
121
+ const systemPrompt = buildDecomposerSystemPrompt(heuristics, {
122
+ maxTickets,
123
+ maxTokenBudget,
124
+ });
121
125
 
122
126
  const budgeted = applyBudget(
123
127
  [
@@ -137,8 +141,10 @@ export async function buildDecompositionContext(
137
141
  maxTickets,
138
142
  // Story #3875 — surface the real delivery envelope to the decomposer
139
143
  // so Stories are sized against the hydration budget and the
140
- // configured preflight ceilings rather than guessed.
141
- maxTokenBudget: limits.maxTokenBudget,
144
+ // configured preflight ceilings rather than guessed. Story #4162 also
145
+ // threads this value into the rendered systemPrompt above as a sizing
146
+ // input so the prompt itself names the budget.
147
+ maxTokenBudget,
142
148
  preflightCeilings: resolvePreflightCeilings(config),
143
149
  contextMode: budgeted.mode,
144
150
  // Story #2801 — surface the Phase 7 planning decision so the
@@ -7,6 +7,7 @@
7
7
  */
8
8
 
9
9
  import path from 'node:path';
10
+ import { verifyBddRunnerPendingTag } from '../../../bdd-runner-detect.js';
10
11
  import { Logger } from '../../../Logger.js';
11
12
  import { AGENT_LABELS, TYPE_LABELS } from '../../../label-constants.js';
12
13
  import { cleanupPhaseTempFiles } from '../../../plan-phase-cleanup.js';
@@ -45,12 +46,20 @@ function buildRiskVerdictCommentBody({ epicId, riskVerdict, planningRisk }) {
45
46
  verdict: riskVerdict,
46
47
  planningRisk,
47
48
  };
49
+ // Story #4145 — when the disposition was forced to not-applicable because
50
+ // no BDD runner exists, make the waiver operator-visible in the rendered
51
+ // comment (not just the fenced JSON record) so a reviewer sees why an
52
+ // otherwise-required AC table was waived.
53
+ const waiverNote = planningRisk.acceptanceWaivedReason
54
+ ? ['', `> ⚠️ **Acceptance waived** — ${planningRisk.acceptanceWaivedReason}`]
55
+ : [];
48
56
  return [
49
57
  `### 🧭 Planning Risk Verdict — ${planningRisk.overallLevel} · ${planningRisk.gateDecision}`,
50
58
  '',
51
59
  riskVerdict.summary,
52
60
  '',
53
61
  ...axisTable,
62
+ ...waiverNote,
54
63
  '',
55
64
  '```json',
56
65
  JSON.stringify(record, null, 2),
@@ -99,7 +108,29 @@ export async function runSpecPhase(
99
108
  '[epic-plan-spec] risk verdict is required — author risk-verdict.json via the epic-plan-spec-author Skill and pass it with --risk-verdict.',
100
109
  );
101
110
  }
102
- const planningRisk = deriveRiskEnvelope(riskVerdict);
111
+
112
+ // Story #4145 — probe the project's BDD runner. When none is detected
113
+ // (`fallback === true`, e.g. a node:test repo with no tests/features/**),
114
+ // the acceptance disposition is forced to not-applicable inside
115
+ // deriveRiskEnvelope: an authored AC table could never be reconciled by
116
+ // `@epic-<id>-ac-*` feature tags, so /deliver finalize would otherwise
117
+ // abort and require a manual `acceptance::n-a`. The probe is static and
118
+ // best-effort — a detection failure degrades to "runner present" (no
119
+ // forced waiver), preserving the BDD-repo path, and never blocks Phase 7.
120
+ let bddRunner = null;
121
+ try {
122
+ bddRunner = await verifyBddRunnerPendingTag({ cwd: PROJECT_ROOT });
123
+ } catch (err) {
124
+ Logger.warn(
125
+ `[epic-plan-spec] BDD runner probe skipped (${err.message}); acceptance disposition derived from risk axes only.`,
126
+ );
127
+ }
128
+ const planningRisk = deriveRiskEnvelope(riskVerdict, { bddRunner });
129
+ if (planningRisk.acceptanceWaivedReason) {
130
+ Logger.info(
131
+ `[epic-plan-spec] Acceptance disposition forced to not-applicable for Epic #${epicId}: ${planningRisk.acceptanceWaivedReason}`,
132
+ );
133
+ }
103
134
 
104
135
  const epic = await provider.getEpic(epicId);
105
136
  if (!epic) {
@@ -2,17 +2,28 @@
2
2
  * epic-run-state-store — stateless functions for reading and writing the
3
3
  * `epic-run-state` structured comment used by `/deliver`.
4
4
  *
5
+ * Story #4155 (Epic #4151) — the Epic `/deliver` runtime cut over from
6
+ * the wave-batch scheduler to the continuous ready-set core
7
+ * (`lib/wave-runner/ready-set.js`). The checkpoint shrank with it: it no
8
+ * longer carries `currentWave`, `plan[][]`, `totalWaves`, or the
9
+ * per-wave `waves[]` aggregation. The durable run state is now a flat
10
+ * **per-Story status map** (`stories: { [storyId]: { status, title?,
11
+ * blockerCommentId? } }`) plus the run-level `concurrencyCap` (the
12
+ * GLOBAL in-flight cap the ready-set selector honours), `phase`,
13
+ * `startedAt`, and `manualInterventions[]`. There is no resume-pointer
14
+ * to reconcile — the ready-set core re-derives adjacency and readiness
15
+ * from live Story bodies/labels on every tick, so the checkpoint only
16
+ * records terminal Story outcomes (for the auto-merge predicate, branch
17
+ * cleanup, and the operator rollup) and the run-level knobs.
18
+ *
5
19
  * This module is the function-based replacement for the legacy
6
20
  * `Checkpointer` class that previously lived at
7
- * `./epic-runner/checkpointer.js`. Bodies were lifted verbatim from the
8
- * corresponding `Checkpointer` methods so the structured-comment shape is
9
- * preserved byte-for-byte. Story #2423 (Epic #2307) deleted the class
10
- * file; the class API survives as a tests-only fixture at
21
+ * `./epic-runner/checkpointer.js`. Story #2423 (Epic #2307) deleted the
22
+ * class file; the class API survives as a tests-only fixture at
11
23
  * `tests/fixtures/epic-run-state-store.js`.
12
24
  *
13
25
  * The comment is identified by a stable HTML marker so it can be overwritten
14
- * idempotently across orchestrator restarts. The body is a fenced JSON block
15
- * following the schema in tech spec #323.
26
+ * idempotently across orchestrator restarts. The body is a fenced JSON block.
16
27
  */
17
28
 
18
29
  import { assertValidDeliverPhase } from './epic-runner/deliver-phases.js';
@@ -20,7 +31,15 @@ import { parseFencedJsonComment } from './structured-comment-parser.js';
20
31
  import { findStructuredComment, upsertStructuredComment } from './ticketing.js';
21
32
 
22
33
  export const EPIC_RUN_STATE_TYPE = 'epic-run-state';
23
- export const CHECKPOINT_SCHEMA_VERSION = 1;
34
+ export const CHECKPOINT_SCHEMA_VERSION = 2;
35
+
36
+ /** Terminal / in-progress per-Story statuses persisted on the checkpoint. */
37
+ export const STORY_STATUSES = Object.freeze([
38
+ 'pending',
39
+ 'done',
40
+ 'blocked',
41
+ 'failed',
42
+ ]);
24
43
 
25
44
  // Re-export the phase enum + index helper so downstream importers continue
26
45
  // to use this module as a single import target.
@@ -40,6 +59,56 @@ function assertEpicId(epicId) {
40
59
  }
41
60
  }
42
61
 
62
+ /**
63
+ * Normalize an inbound Story id (accepts the ticket `id` shape, the raw
64
+ * GitHub `number` shape, and a bare integer) to a positive integer, or
65
+ * `null` when it is absent / non-positive / non-integer.
66
+ *
67
+ * @param {object|number|string} entry
68
+ * @returns {number|null}
69
+ */
70
+ function storyIdOf(entry) {
71
+ if (typeof entry === 'number') {
72
+ return Number.isInteger(entry) && entry > 0 ? entry : null;
73
+ }
74
+ if (!entry || typeof entry !== 'object') {
75
+ const n = Number(entry);
76
+ return Number.isInteger(n) && n > 0 ? n : null;
77
+ }
78
+ const raw = entry.id ?? entry.storyId ?? entry.number;
79
+ const id = Number(raw);
80
+ return Number.isInteger(id) && id > 0 ? id : null;
81
+ }
82
+
83
+ /**
84
+ * Build the initial per-Story status map from a list of Story records (or
85
+ * ids). Every Story seeds at `status: 'pending'`; an optional `title` is
86
+ * carried through when the record supplies one so the operator rollup and
87
+ * branch-cleanup surfaces have a label without a second fetch. Keys are the
88
+ * positive-integer Story ids as strings (JSON object keys are strings);
89
+ * shapeless / non-positive entries are dropped.
90
+ *
91
+ * Pure helper — exported for unit tests.
92
+ *
93
+ * @param {Array<object|number>} stories
94
+ * @returns {Record<string, { status: string, title?: string }>}
95
+ */
96
+ export function buildStoryStatusMap(stories) {
97
+ const out = {};
98
+ for (const entry of Array.isArray(stories) ? stories : []) {
99
+ const id = storyIdOf(entry);
100
+ if (id === null) continue;
101
+ const record = { status: 'pending' };
102
+ const title =
103
+ entry && typeof entry === 'object' && typeof entry.title === 'string'
104
+ ? entry.title
105
+ : undefined;
106
+ if (title) record.title = title;
107
+ out[String(id)] = record;
108
+ }
109
+ return out;
110
+ }
111
+
43
112
  /**
44
113
  * Read and parse the checkpoint. Returns null if the comment is missing or
45
114
  * unparseable (callers treat null as "start fresh").
@@ -60,7 +129,7 @@ export async function read({ provider, epicId } = {}) {
60
129
 
61
130
  /**
62
131
  * Overwrite the checkpoint with `state`. Idempotent — callers may invoke
63
- * freely per wave; the marker-scoped upsert deletes the prior comment.
132
+ * freely per tick; the marker-scoped upsert deletes the prior comment.
64
133
  *
65
134
  * @param {{ provider: import('../ITicketingProvider.js').ITicketingProvider, epicId: number, state: object }} opts
66
135
  */
@@ -78,38 +147,48 @@ export async function write({ provider, epicId, state } = {}) {
78
147
  }
79
148
 
80
149
  /**
81
- * Initial checkpoint for a brand-new run. Idempotent against re-dispatch
82
- * when the wave shape is unchanged. When an existing checkpoint is found
83
- * but the incoming `totalWaves` or `concurrencyCap` differs from the
84
- * persisted values, refresh those fields in place preserving
85
- * `currentWave`, `waves[]`, `blockerHistory`, `manualInterventions`,
86
- * `startedAt`, and any other already-persisted fields (e.g., `plan`,
87
- * `phase`). The `plan` field is owned by the prepare caller, which
88
- * overwrites it on every prepare run, so it does not need a delta check
89
- * here.
150
+ * Initial checkpoint for a brand-new run. Idempotent against re-dispatch:
151
+ * when an existing checkpoint is found and the persisted `concurrencyCap`
152
+ * matches the incoming value, the existing state is returned verbatim (no
153
+ * rewrite) so a re-prepare preserves `startedAt`, prior Story statuses, and
154
+ * `manualInterventions`. When the cap differs (an operator re-tuned the
155
+ * global in-flight cap) it is refreshed in place; the Story status map is
156
+ * **merged** so any Story that already reached a terminal status keeps it
157
+ * while newly-discovered Stories are seeded at `pending`. Prepare owns the
158
+ * Story set (it overwrites it on every run) but never clobbers recorded
159
+ * progress.
90
160
  *
91
- * @param {{ provider: import('../ITicketingProvider.js').ITicketingProvider, epicId: number, totalWaves: number, concurrencyCap: number }} opts
161
+ * @param {{
162
+ * provider: import('../ITicketingProvider.js').ITicketingProvider,
163
+ * epicId: number,
164
+ * storyIds: Array<object|number>,
165
+ * concurrencyCap: number,
166
+ * }} opts
167
+ * `concurrencyCap` is the GLOBAL in-flight cap the ready-set selector
168
+ * honours (`selectReadySet({ globalCap })`).
92
169
  */
93
170
  export async function initialize({
94
171
  provider,
95
172
  epicId,
96
- totalWaves,
173
+ storyIds,
97
174
  concurrencyCap,
98
175
  } = {}) {
99
176
  assertProvider(provider);
100
177
  assertEpicId(epicId);
178
+ const seededStories = buildStoryStatusMap(storyIds);
101
179
  const existing = await read({ provider, epicId });
102
180
  if (existing) {
181
+ const mergedStories = mergeStoryStatuses(existing.stories, seededStories);
103
182
  if (
104
- existing.totalWaves === totalWaves &&
105
- existing.concurrencyCap === concurrencyCap
183
+ existing.concurrencyCap === concurrencyCap &&
184
+ storyMapsEqual(existing.stories, mergedStories)
106
185
  ) {
107
186
  return existing;
108
187
  }
109
188
  return write({
110
189
  provider,
111
190
  epicId,
112
- state: { ...existing, totalWaves, concurrencyCap },
191
+ state: { ...existing, concurrencyCap, stories: mergedStories },
113
192
  });
114
193
  }
115
194
  return write({
@@ -118,119 +197,133 @@ export async function initialize({
118
197
  state: {
119
198
  epicId,
120
199
  startedAt: new Date().toISOString(),
121
- currentWave: 0,
122
- totalWaves,
123
200
  concurrencyCap,
124
201
  phase: 'prepare',
125
- waves: [],
126
- blockerHistory: [],
202
+ stories: seededStories,
127
203
  manualInterventions: [],
128
204
  },
129
205
  });
130
206
  }
131
207
 
132
208
  /**
133
- * Reconcile the resume pointer (`currentWave` + `waves[]` history) against
134
- * a freshly-recomputed wave plan.
135
- *
136
- * Story #3358 when `/deliver` is resumed on a partially-complete
137
- * Epic, `epic-deliver-prepare.js` recomputes the wave DAG over only the
138
- * **not-done** Stories (`build-wave-dag.js#discoverOpenStories` drops the
139
- * closed/merged Stories). The recomputed plan is therefore *shorter* and
140
- * **re-indexed from 0** — `plan[0]` is the next ready wave. The preserved
141
- * checkpoint, however, still carries the prior `currentWave` (e.g. `2`)
142
- * and a `waves[]` history keyed to the *old* index space. `wave-tick.js`
143
- * then indexes `plan[currentWave]` into the new plan and dispatches the
144
- * wrong wave — silently skipping the Stories that are actually ready.
145
- *
146
- * Prepare already owns the `plan` field (it overwrites it on every run),
147
- * so it must equally own the pointer that indexes into that plan. This
148
- * helper is the single point of reconciliation:
149
- *
150
- * - When the recomputed `nextPlan` is **structurally identical** to the
151
- * persisted `priorPlan` (an idempotent re-prepare with no Story
152
- * completed since the last run), the pointer is preserved verbatim so
153
- * in-flight wave progress is not lost.
154
- * - When the recomputed `nextPlan` **differs** (a Story merged → the
155
- * plan got shorter / re-indexed), the pointer is reset: `currentWave`
156
- * to `0` (the new plan's index space starts at the first not-done
157
- * wave) and `waves[]` to `[]` (the prior history references the old
158
- * index space and would mis-key `readGateFailures`).
159
- *
160
- * Plan equality is compared on the Story-id matrix only — `title` /
161
- * `worktree` churn on an otherwise-identical plan must not trip a reset.
209
+ * Merge a freshly-seeded Story status map onto a persisted one. Every Story
210
+ * present in either map appears in the result; when a Story exists in the
211
+ * prior map its recorded status / blockerCommentId win (recorded progress is
212
+ * never lost), while its `title` is refreshed from the incoming seed when the
213
+ * seed supplies one. Stories present only in the incoming seed are added at
214
+ * their seeded (`pending`) status. Pure — exported for unit tests.
162
215
  *
163
- * Pure function — no I/O, no provider, no side effects.
164
- *
165
- * @param {{
166
- * currentWave?: number,
167
- * waves?: Array<unknown>,
168
- * }} checkpoint The persisted checkpoint fields to reconcile.
169
- * @param {Array<Array<{ id?: number, storyId?: number, number?: number }>>} priorPlan
170
- * The plan currently persisted on the checkpoint (may be undefined on a
171
- * first run).
172
- * @param {Array<Array<{ id?: number, storyId?: number, number?: number }>>} nextPlan
173
- * The freshly-recomputed plan prepare is about to persist.
174
- * @returns {{ currentWave: number, waves: Array<unknown> }} The reconciled
175
- * pointer fields. Always returns concrete values so the caller can spread
176
- * them onto the checkpoint payload unconditionally.
216
+ * @param {Record<string, object>|undefined} prior
217
+ * @param {Record<string, object>} incoming
218
+ * @returns {Record<string, object>}
177
219
  */
178
- export function reconcileResumePointer(checkpoint, priorPlan, nextPlan) {
179
- const safeWaves = Array.isArray(checkpoint?.waves) ? checkpoint.waves : [];
180
- const currentWave = Number.isInteger(checkpoint?.currentWave)
181
- ? checkpoint.currentWave
182
- : 0;
183
- if (planStoryMatrixEqual(priorPlan, nextPlan)) {
184
- return { currentWave, waves: safeWaves };
220
+ export function mergeStoryStatuses(prior, incoming) {
221
+ const priorMap = prior && typeof prior === 'object' ? prior : {};
222
+ const seedMap = incoming && typeof incoming === 'object' ? incoming : {};
223
+ const out = {};
224
+ for (const key of new Set([
225
+ ...Object.keys(priorMap),
226
+ ...Object.keys(seedMap),
227
+ ])) {
228
+ const priorEntry = priorMap[key];
229
+ const seedEntry = seedMap[key];
230
+ if (priorEntry && typeof priorEntry === 'object') {
231
+ const merged = { ...priorEntry };
232
+ if (seedEntry && typeof seedEntry.title === 'string') {
233
+ merged.title = seedEntry.title;
234
+ }
235
+ out[key] = merged;
236
+ } else {
237
+ out[key] = seedEntry;
238
+ }
185
239
  }
186
- // Plan was recomputed (resume after a completed wave): the new plan is
187
- // 0-indexed over the remaining not-done waves. Reset the pointer and
188
- // drop the stale history so `wave-tick.js` reads `plan[0]`.
189
- return { currentWave: 0, waves: [] };
240
+ return out;
190
241
  }
191
242
 
192
243
  /**
193
- * Compare two wave plans on their Story-id matrix only. Each plan is
194
- * `Array<Array<{ id|storyId|number }>>`; equality requires the same wave
195
- * count, the same per-wave Story count, and the same Story ids in the same
196
- * positions. `title` / `worktree` fields are ignored so cosmetic churn on
197
- * an otherwise-identical plan does not register as a change.
244
+ * Structural equality on two Story status maps same key set and, per key,
245
+ * the same `status`, `title`, and `blockerCommentId`. Used by `initialize`
246
+ * to decide whether an idempotent re-prepare needs a rewrite. Pure.
198
247
  *
199
- * Pure helper for {@link reconcileResumePointer}.
200
- *
201
- * @param {Array<Array<object>>|undefined} a
202
- * @param {Array<Array<object>>|undefined} b
248
+ * @param {Record<string, object>|undefined} a
249
+ * @param {Record<string, object>|undefined} b
203
250
  * @returns {boolean}
204
251
  */
205
- function planStoryMatrixEqual(a, b) {
206
- const left = Array.isArray(a) ? a : [];
207
- const right = Array.isArray(b) ? b : [];
208
- if (left.length !== right.length) return false;
209
- for (let i = 0; i < left.length; i += 1) {
210
- const wl = Array.isArray(left[i]) ? left[i] : [];
211
- const wr = Array.isArray(right[i]) ? right[i] : [];
212
- if (wl.length !== wr.length) return false;
213
- for (let j = 0; j < wl.length; j += 1) {
214
- if (storyIdOf(wl[j]) !== storyIdOf(wr[j])) return false;
252
+ function storyMapsEqual(a, b) {
253
+ const left = a && typeof a === 'object' ? a : {};
254
+ const right = b && typeof b === 'object' ? b : {};
255
+ const keys = Object.keys(left);
256
+ if (keys.length !== Object.keys(right).length) return false;
257
+ for (const key of keys) {
258
+ const l = left[key] ?? {};
259
+ const r = right[key];
260
+ if (!r) return false;
261
+ if (
262
+ l.status !== r.status ||
263
+ l.title !== r.title ||
264
+ l.blockerCommentId !== r.blockerCommentId
265
+ ) {
266
+ return false;
215
267
  }
216
268
  }
217
269
  return true;
218
270
  }
219
271
 
220
272
  /**
221
- * Extract the Story id from a plan entry. Mirrors the resolution order
222
- * `wave-runner/tick.js#storyIdOf` uses so the equality check keys on the
223
- * same identity the tick dispatches against. Returns `null` for shapeless
224
- * entries so two `null`s never compare equal by accident.
273
+ * Record a per-Story terminal (or in-progress) status on the checkpoint.
274
+ * Reads the current state first, splices the single Story's record into the
275
+ * `stories` map, and re-writes. Other Stories and all run-level fields are
276
+ * preserved verbatim. Tolerant of a legacy/absent `stories` map (treated as
277
+ * empty) so a checkpoint that predates a field is upgraded in place.
225
278
  *
226
- * @param {object|number|null|undefined} entry
227
- * @returns {number|null}
279
+ * @param {{
280
+ * provider: import('../ITicketingProvider.js').ITicketingProvider,
281
+ * epicId: number,
282
+ * storyId: number,
283
+ * status: string,
284
+ * title?: string,
285
+ * blockerCommentId?: string|number|null,
286
+ * }} opts
287
+ * @returns {Promise<object>} the persisted state
228
288
  */
229
- function storyIdOf(entry) {
230
- if (typeof entry === 'number') return entry;
231
- if (!entry || typeof entry !== 'object') return null;
232
- const id = entry.id ?? entry.storyId ?? entry.number;
233
- return Number.isInteger(id) ? id : null;
289
+ export async function recordStoryStatus({
290
+ provider,
291
+ epicId,
292
+ storyId,
293
+ status,
294
+ title,
295
+ blockerCommentId,
296
+ } = {}) {
297
+ assertProvider(provider);
298
+ assertEpicId(epicId);
299
+ const id = storyIdOf(storyId);
300
+ if (id === null) {
301
+ throw new TypeError(
302
+ 'recordStoryStatus: storyId must be a positive integer',
303
+ );
304
+ }
305
+ if (!STORY_STATUSES.includes(status)) {
306
+ throw new RangeError(
307
+ `recordStoryStatus: status "${status}" must be one of: ${STORY_STATUSES.join(', ')}`,
308
+ );
309
+ }
310
+ const existing = (await read({ provider, epicId })) ?? {};
311
+ const stories =
312
+ existing.stories && typeof existing.stories === 'object'
313
+ ? { ...existing.stories }
314
+ : {};
315
+ const prior = stories[String(id)] ?? {};
316
+ const record = { ...prior, status };
317
+ if (typeof title === 'string' && title) record.title = title;
318
+ if (status === 'blocked' && blockerCommentId != null) {
319
+ record.blockerCommentId = String(blockerCommentId);
320
+ }
321
+ stories[String(id)] = record;
322
+ return write({
323
+ provider,
324
+ epicId,
325
+ state: { ...existing, stories },
326
+ });
234
327
  }
235
328
 
236
329
  /**