mandrel 1.91.0 → 1.93.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 (112) hide show
  1. package/.agents/README.md +5 -5
  2. package/.agents/docs/SDLC.md +75 -107
  3. package/.agents/docs/configuration.md +1 -1
  4. package/.agents/docs/quality-gates.md +8 -7
  5. package/.agents/docs/workflows.md +4 -10
  6. package/.agents/instructions.md +2 -2
  7. package/.agents/schemas/agentrc.schema.json +1 -1
  8. package/.agents/schemas/lifecycle/README.md +2 -1
  9. package/.agents/schemas/lifecycle/ledger-record.schema.json +1 -1
  10. package/.agents/schemas/risk-verdict.schema.json +14 -1
  11. package/.agents/scripts/README.md +0 -46
  12. package/.agents/scripts/analyze-execution.js +76 -1
  13. package/.agents/scripts/check-doc-links.js +3 -0
  14. package/.agents/scripts/check-workflow-cli-lint.js +208 -0
  15. package/.agents/scripts/epic-audit-recheck.js +1 -1
  16. package/.agents/scripts/epic-plan-clarity.js +16 -6
  17. package/.agents/scripts/epic-plan-decompose.js +37 -59
  18. package/.agents/scripts/epic-plan-healthcheck.js +39 -6
  19. package/.agents/scripts/epic-plan-spec.js +41 -168
  20. package/.agents/scripts/epic-reconcile.js +7 -1
  21. package/.agents/scripts/lib/Logger.js +4 -4
  22. package/.agents/scripts/lib/audit-suite/index.js +4 -5
  23. package/.agents/scripts/lib/audit-suite/runner.js +6 -4
  24. package/.agents/scripts/lib/audit-suite/selector.js +2 -3
  25. package/.agents/scripts/lib/bdd-runner-detect.js +1 -1
  26. package/.agents/scripts/lib/command-header.js +20 -0
  27. package/.agents/scripts/lib/config/github.js +1 -3
  28. package/.agents/scripts/lib/config-settings-schema.js +4 -3
  29. package/.agents/scripts/lib/degraded-mode.js +1 -1
  30. package/.agents/scripts/lib/label-constants.js +18 -3
  31. package/.agents/scripts/lib/label-taxonomy.js +14 -3
  32. package/.agents/scripts/lib/mutation/baseline-snapshot.js +7 -6
  33. package/.agents/scripts/lib/orchestration/consolidation-precondition.js +18 -8
  34. package/.agents/scripts/lib/orchestration/context-hydration-engine.js +3 -4
  35. package/.agents/scripts/lib/orchestration/docs-digest.js +2 -2
  36. package/.agents/scripts/lib/orchestration/epic-plan-decompose/phases/persist-helpers.js +4 -0
  37. package/.agents/scripts/lib/orchestration/epic-plan-decompose/phases/persist.js +22 -12
  38. package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/authoring-context.js +6 -1
  39. package/.agents/scripts/lib/orchestration/epic-spec-reconciler-diff.js +18 -2
  40. package/.agents/scripts/lib/orchestration/file-assumptions.js +68 -7
  41. package/.agents/scripts/{lifecycle-diff.js → lib/orchestration/lifecycle/ledger-diff.js} +10 -76
  42. package/.agents/scripts/lib/orchestration/lifecycle/listeners/acceptance-reconciler.js +1 -1
  43. package/.agents/scripts/lib/orchestration/plan-context.js +696 -0
  44. package/.agents/scripts/lib/orchestration/plan-critic-conditions.js +177 -0
  45. package/.agents/scripts/lib/orchestration/plan-critics-evaluate.js +99 -0
  46. package/.agents/scripts/lib/orchestration/plan-metrics.js +445 -0
  47. package/.agents/scripts/lib/orchestration/plan-persist/amend.js +359 -0
  48. package/.agents/scripts/lib/orchestration/plan-persist/delivery-mode.js +127 -0
  49. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +1015 -0
  50. package/.agents/scripts/lib/orchestration/plan-persist/summary.js +206 -0
  51. package/.agents/scripts/lib/orchestration/plan-reachability.js +160 -0
  52. package/.agents/scripts/lib/orchestration/plan-runner/worktree-sweep.js +3 -3
  53. package/.agents/scripts/lib/orchestration/planning-risk.js +1 -1
  54. package/.agents/scripts/lib/orchestration/spec-freshness.js +1 -1
  55. package/.agents/scripts/lib/orchestration/ticket-validator.js +18 -1
  56. package/.agents/scripts/lib/orchestration/ticketing/bulk.js +1 -1
  57. package/.agents/scripts/lib/orchestration/ticketing/reads.js +11 -2
  58. package/.agents/scripts/lib/plan-phase-cleanup.js +16 -0
  59. package/.agents/scripts/lib/presentation/manifest-persistence.js +2 -3
  60. package/.agents/scripts/lib/templates/spec-author-prompts.js +74 -0
  61. package/.agents/scripts/plan-context.js +204 -0
  62. package/.agents/scripts/plan-critics.js +199 -0
  63. package/.agents/scripts/plan-persist.js +383 -0
  64. package/.agents/scripts/pr-watch-with-update.js +8 -7
  65. package/.agents/scripts/run-lint.js +10 -11
  66. package/.agents/scripts/story-plan.js +19 -7
  67. package/.agents/scripts/sync-claude-commands.js +10 -5
  68. package/.agents/skills/core/knowledge-transfer/SKILL.md +11 -7
  69. package/.agents/workflows/audit-documentation.md +5 -7
  70. package/.agents/workflows/audit-lighthouse.md +1 -0
  71. package/.agents/workflows/audit-security.md +1 -0
  72. package/.agents/workflows/audit-to-stories.md +16 -5
  73. package/.agents/workflows/git-deliver.md +76 -243
  74. package/.agents/workflows/helpers/_merge-conflict-template.md +1 -1
  75. package/.agents/workflows/helpers/code-review.md +7 -6
  76. package/.agents/workflows/helpers/deliver-epic.md +5 -4
  77. package/.agents/workflows/helpers/plan-epic-reference.md +120 -96
  78. package/.agents/workflows/helpers/plan-epic.md +307 -994
  79. package/.agents/workflows/helpers/plan-story.md +14 -14
  80. package/.agents/workflows/helpers/scope-triage-gate.md +15 -4
  81. package/.agents/workflows/helpers/single-story-deliver.md +5 -4
  82. package/.agents/workflows/helpers/worktree-lifecycle.md +9 -7
  83. package/.agents/workflows/mandrel-update.md +144 -466
  84. package/.agents/workflows/plan.md +87 -145
  85. package/docs/CHANGELOG.md +20 -0
  86. package/lib/cli/registry.js +21 -3
  87. package/package.json +1 -1
  88. package/.agents/schemas/loop-unit.schema.json +0 -70
  89. package/.agents/scripts/assert-branch.js +0 -81
  90. package/.agents/scripts/check-loop-units.js +0 -204
  91. package/.agents/scripts/detect-merges.js +0 -111
  92. package/.agents/scripts/git-pr-quality-gate.js +0 -205
  93. package/.agents/scripts/git-rebase-and-resolve.js +0 -234
  94. package/.agents/scripts/hierarchy-gate.js +0 -192
  95. package/.agents/scripts/hydrate-context.js +0 -179
  96. package/.agents/scripts/lib/loop-units/validate-loop-unit.js +0 -197
  97. package/.agents/scripts/lib/orchestration/epic-plan-decompose/phases/cli.js +0 -167
  98. package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/cli-args.js +0 -69
  99. package/.agents/scripts/lib/orchestration/plan-runner/plan-router.js +0 -86
  100. package/.agents/scripts/loc-delta.js +0 -205
  101. package/.agents/scripts/render-manifest.js +0 -143
  102. package/.agents/scripts/run-audit-suite.js +0 -97
  103. package/.agents/scripts/select-audits.js +0 -155
  104. package/.agents/scripts/update-mutation-baseline.js +0 -189
  105. package/.agents/workflows/explain.md +0 -118
  106. package/.agents/workflows/git-merge-pr.md +0 -377
  107. package/.agents/workflows/helpers/epic-plan-decompose.md +0 -22
  108. package/.agents/workflows/helpers/epic-plan-spec.md +0 -22
  109. package/.agents/workflows/loops/README.md +0 -65
  110. package/.agents/workflows/loops/fix-failing-tests.md +0 -74
  111. package/.agents/workflows/loops/nightly-audit.md +0 -81
  112. package/.agents/workflows/loops/watch-ci.md +0 -68
@@ -0,0 +1,359 @@
1
+ /**
2
+ * amend.js — the `--amend` change-request delta path of the collapsed
3
+ * persist surface (Epic #4474 PR4, design §2 mode matrix, amend row).
4
+ *
5
+ * An amend run receives the full ticket set with every ticket carrying an
6
+ * `op` field:
7
+ *
8
+ * - `add` — a new Story; created fresh.
9
+ * - `modify` — an existing Story whose contract changed; closed and
10
+ * recreated (close-and-recreate is scoped to modify/close
11
+ * slugs ONLY — never the whole tree, which is `--force`'s
12
+ * semantics).
13
+ * - `keep` — an existing Story left byte-untouched; it participates in
14
+ * the merged-set DAG validation but no provider mutation
15
+ * ever targets it.
16
+ * - `close` — an existing Story dropped from the plan; closed.
17
+ *
18
+ * The DAG (and the full ticket validator) runs over the MERGED set —
19
+ * keeps + adds + modifies, closes excluded — so a cycle introduced by an
20
+ * add that depends on a kept Story that depends on a modify is caught
21
+ * before any GitHub call, and a dependency pointing at a closed slug fails
22
+ * the unknown-slug check.
23
+ *
24
+ * Close ops are destructive, so they are gated behind the same explicit
25
+ * confirmation contract as `epic-reconcile.js`: without `--explicit-delete`
26
+ * the run stops BEFORE any mutation, prints the dry-run op diff, and exits
27
+ * 2 ({@link AmendExplicitCloseError} carries the rendered diff; the CLI
28
+ * maps it onto the exit code). Non-interactive callers pass the flag.
29
+ *
30
+ * Slug → issue resolution: an explicit `id` on the ticket (the authoring
31
+ * envelope's open-children listing carries issue numbers) wins; otherwise
32
+ * the reconciler state ledger (`.agents/epics/<id>.state.json`) resolves
33
+ * the slug. A modify/keep/close slug that resolves nowhere is a hard error
34
+ * — the amend never guesses which Story it is about to close.
35
+ *
36
+ * @module lib/orchestration/plan-persist/amend
37
+ */
38
+
39
+ import { Logger } from '../../Logger.js';
40
+
41
+ /** Valid `op` values on an amend ticket. */
42
+ const AMEND_OPS = Object.freeze(['add', 'modify', 'keep', 'close']);
43
+
44
+ /**
45
+ * Error carrying the rendered dry-run diff for the exit-2 confirmation
46
+ * path (mirrors `epic-reconcile.js` EXIT_CODES.EXPLICIT_DELETE_REQUIRED).
47
+ */
48
+ export class AmendExplicitCloseError extends Error {
49
+ /**
50
+ * @param {string} message
51
+ * @param {{ diff: string }} detail
52
+ */
53
+ constructor(message, { diff }) {
54
+ super(message);
55
+ this.name = 'AmendExplicitCloseError';
56
+ this.code = 'PLAN_AMEND_EXPLICIT_DELETE_REQUIRED';
57
+ this.diff = diff;
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Partition an amend ticket set by `op`, validating the op vocabulary and
63
+ * slug uniqueness. Every ticket MUST carry an op — a missing op on one
64
+ * ticket of an amend payload is an authoring defect, not a default.
65
+ *
66
+ * @param {Array<{ slug?: string, op?: string }>} tickets
67
+ * @returns {{ add: object[], modify: object[], keep: object[], close: object[] }}
68
+ */
69
+ export function partitionAmendTickets(tickets) {
70
+ const partition = { add: [], modify: [], keep: [], close: [] };
71
+ const seen = new Set();
72
+ for (const ticket of tickets) {
73
+ const slug = ticket?.slug;
74
+ if (typeof slug !== 'string' || slug.length === 0) {
75
+ throw new Error(
76
+ '[plan-persist] amend ticket without a slug — every amend ticket ' +
77
+ 'must carry both slug and op.',
78
+ );
79
+ }
80
+ if (seen.has(slug)) {
81
+ throw new Error(
82
+ `[plan-persist] duplicate slug "${slug}" in the amend payload.`,
83
+ );
84
+ }
85
+ seen.add(slug);
86
+ const op = ticket?.op;
87
+ if (!AMEND_OPS.includes(op)) {
88
+ throw new Error(
89
+ `[plan-persist] amend ticket "${slug}" carries invalid op ` +
90
+ `"${op}" — expected one of ${AMEND_OPS.join(', ')}.`,
91
+ );
92
+ }
93
+ partition[op].push(ticket);
94
+ }
95
+ return partition;
96
+ }
97
+
98
+ /**
99
+ * Project the MERGED ticket set for validation and spec rendering:
100
+ * keeps + adds + modifies in original authoring order, closes excluded,
101
+ * with the amend-only `op`/`id` carrier fields stripped so the ticket
102
+ * validator and `renderSpec` see the exact fan-out ticket shape.
103
+ *
104
+ * @param {Array<{ op?: string }>} tickets the full amend payload
105
+ * @returns {object[]} merged set, op/id stripped
106
+ */
107
+ export function buildMergedTicketSet(tickets) {
108
+ return tickets
109
+ .filter((t) => t?.op !== 'close')
110
+ .map(({ op: _op, id: _id, ...rest }) => rest);
111
+ }
112
+
113
+ /**
114
+ * Resolve every modify/keep/close slug to its live issue number and
115
+ * hard-error on any ambiguity (the "closing the wrong stories" risk):
116
+ *
117
+ * - modify/keep/close must resolve via explicit `id` or the state
118
+ * ledger mapping — no resolution is a hard error;
119
+ * - an add slug must NOT already resolve to an issue (a collision means
120
+ * the author meant modify, or picked a stale slug);
121
+ * - resolved modify/close targets are fetched and must exist; a target
122
+ * already closed on GitHub is surfaced (skipped close, not an error —
123
+ * re-running an interrupted amend must stay safe).
124
+ *
125
+ * @param {{
126
+ * partition: ReturnType<typeof partitionAmendTickets>,
127
+ * stateMapping: Record<string, { issueNumber?: number }>,
128
+ * provider: { getTicket: (id: number) => Promise<object|null> },
129
+ * }} args
130
+ * @returns {Promise<{
131
+ * modify: Array<{ ticket: object, issueNumber: number, alreadyClosed: boolean }>,
132
+ * keep: Array<{ ticket: object, issueNumber: number }>,
133
+ * close: Array<{ ticket: object, issueNumber: number, alreadyClosed: boolean }>,
134
+ * }>}
135
+ */
136
+ export async function resolveAmendTargets({
137
+ partition,
138
+ stateMapping,
139
+ provider,
140
+ }) {
141
+ const mapping = stateMapping ?? {};
142
+ const resolveSlug = (ticket) => {
143
+ if (Number.isInteger(ticket.id)) return ticket.id;
144
+ const entry = mapping[ticket.slug];
145
+ if (Number.isInteger(entry?.issueNumber)) return entry.issueNumber;
146
+ return null;
147
+ };
148
+
149
+ for (const ticket of partition.add) {
150
+ const collision = resolveSlug(ticket);
151
+ if (collision !== null) {
152
+ throw new Error(
153
+ `[plan-persist] amend op "add" for slug "${ticket.slug}" collides ` +
154
+ `with existing issue #${collision} — use op "modify" to replace ` +
155
+ 'it, or pick a fresh slug.',
156
+ );
157
+ }
158
+ }
159
+
160
+ const resolveExisting = async (ticket, op) => {
161
+ const issueNumber = resolveSlug(ticket);
162
+ if (issueNumber === null) {
163
+ throw new Error(
164
+ `[plan-persist] amend op "${op}" for slug "${ticket.slug}" resolves ` +
165
+ 'to no existing issue (no `id` on the ticket, no state-ledger ' +
166
+ 'mapping). Refusing to guess — carry the issue number as `id`.',
167
+ );
168
+ }
169
+ const issue = await provider.getTicket(issueNumber);
170
+ if (!issue) {
171
+ throw new Error(
172
+ `[plan-persist] amend op "${op}" for slug "${ticket.slug}" points ` +
173
+ `at issue #${issueNumber}, which does not exist.`,
174
+ );
175
+ }
176
+ return { ticket, issueNumber, alreadyClosed: issue.state === 'closed' };
177
+ };
178
+
179
+ const modify = [];
180
+ for (const t of partition.modify)
181
+ modify.push(await resolveExisting(t, 'modify'));
182
+ const close = [];
183
+ for (const t of partition.close)
184
+ close.push(await resolveExisting(t, 'close'));
185
+ const keep = [];
186
+ for (const t of partition.keep) {
187
+ const { ticket, issueNumber } = await resolveExisting(t, 'keep');
188
+ keep.push({ ticket, issueNumber });
189
+ }
190
+ return { modify, keep, close };
191
+ }
192
+
193
+ /**
194
+ * Render the operator-facing dry-run diff of the amend plan — printed on
195
+ * the exit-2 confirmation path and logged before an `--explicit-delete`
196
+ * apply so the destructive set is always visible.
197
+ *
198
+ * @param {{
199
+ * epicId: number,
200
+ * targets: Awaited<ReturnType<typeof resolveAmendTargets>>,
201
+ * adds: Array<{ slug: string, title?: string }>,
202
+ * }} args
203
+ * @returns {string}
204
+ */
205
+ export function renderAmendPlanDiff({ epicId, targets, adds }) {
206
+ const rows = [
207
+ ...targets.close.map(
208
+ (t) =>
209
+ `| close | \`${t.ticket.slug}\` | #${t.issueNumber} | closed${t.alreadyClosed ? ' (already closed)' : ''} |`,
210
+ ),
211
+ ...targets.modify.map(
212
+ (t) =>
213
+ `| modify | \`${t.ticket.slug}\` | #${t.issueNumber} | closed + recreated |`,
214
+ ),
215
+ ...adds.map((t) => `| add | \`${t.slug}\` | — | created |`),
216
+ ...targets.keep.map(
217
+ (t) => `| keep | \`${t.ticket.slug}\` | #${t.issueNumber} | untouched |`,
218
+ ),
219
+ ];
220
+ return [
221
+ `Amend plan for Epic #${epicId} (dry-run):`,
222
+ '',
223
+ '| Op | Slug | Issue | Effect |',
224
+ '| --- | --- | --- | --- |',
225
+ ...rows,
226
+ ].join('\n');
227
+ }
228
+
229
+ /**
230
+ * Enforce the close-op confirmation gate: when the amend plan carries
231
+ * close ops (including the close half of modify) affecting live issues and
232
+ * `--explicit-delete` was not passed, throw {@link AmendExplicitCloseError}
233
+ * with the rendered diff — BEFORE any mutation. Mirrors the
234
+ * `epic-reconcile.js` exit-2 contract.
235
+ *
236
+ * @param {{
237
+ * epicId: number,
238
+ * targets: Awaited<ReturnType<typeof resolveAmendTargets>>,
239
+ * adds: object[],
240
+ * explicitDelete: boolean,
241
+ * }} args
242
+ */
243
+ export function enforceAmendCloseGate({
244
+ epicId,
245
+ targets,
246
+ adds,
247
+ explicitDelete,
248
+ }) {
249
+ const liveCloses = [
250
+ ...targets.close.filter((t) => !t.alreadyClosed),
251
+ ...targets.modify.filter((t) => !t.alreadyClosed),
252
+ ];
253
+ if (liveCloses.length === 0 || explicitDelete) return;
254
+ const diff = renderAmendPlanDiff({ epicId, targets, adds });
255
+ const named = liveCloses
256
+ .map((t) => `#${t.issueNumber} (\`${t.ticket.slug}\`)`)
257
+ .join(', ');
258
+ throw new AmendExplicitCloseError(
259
+ `[plan-persist] amend plan would close ${liveCloses.length} live ` +
260
+ `issue(s): ${named}. Review the dry-run diff and re-run with ` +
261
+ '--explicit-delete to apply.',
262
+ { diff },
263
+ );
264
+ }
265
+
266
+ /**
267
+ * Apply the amend ops against the provider, in destructive-last order per
268
+ * slug class: closes first (their disappearance is what the operator
269
+ * confirmed), then modify (close old + create new), then adds. Keeps are
270
+ * never touched by construction — no code path here receives them.
271
+ *
272
+ * @param {{
273
+ * epicId: number,
274
+ * provider: {
275
+ * updateTicket: (id: number, patch: object) => Promise<object>,
276
+ * createTicket: (parentId: number, payload: object) => Promise<{ id: number }>,
277
+ * },
278
+ * targets: Awaited<ReturnType<typeof resolveAmendTargets>>,
279
+ * validatedBySlug: Map<string, { slug: string, title: string, body?: string, labels?: string[] }>,
280
+ * }} args
281
+ * @returns {Promise<{
282
+ * closed: Array<{ slug: string, issueNumber: number }>,
283
+ * recreated: Array<{ slug: string, oldIssueNumber: number, issueNumber: number }>,
284
+ * created: Array<{ slug: string, issueNumber: number }>,
285
+ * mapping: Record<string, { entity: 'story', issueNumber: number }>,
286
+ * closedSlugs: string[],
287
+ * }>}
288
+ */
289
+ export async function applyAmendOps({
290
+ epicId,
291
+ provider,
292
+ targets,
293
+ validatedBySlug,
294
+ }) {
295
+ const closed = [];
296
+ const recreated = [];
297
+ const created = [];
298
+ const mapping = {};
299
+ const closedSlugs = [];
300
+
301
+ const createFromValidated = async (slug) => {
302
+ const validated = validatedBySlug.get(slug);
303
+ if (!validated) {
304
+ throw new Error(
305
+ `[plan-persist] amend apply: no validated ticket for slug "${slug}".`,
306
+ );
307
+ }
308
+ const result = await provider.createTicket(epicId, {
309
+ title: validated.title,
310
+ body: validated.body ?? '',
311
+ labels: validated.labels ?? [],
312
+ });
313
+ return result.id;
314
+ };
315
+
316
+ for (const target of targets.close) {
317
+ if (!target.alreadyClosed) {
318
+ await provider.updateTicket(target.issueNumber, { state: 'closed' });
319
+ } else {
320
+ Logger.info(
321
+ `[plan-persist] amend close: #${target.issueNumber} ` +
322
+ `(${target.ticket.slug}) already closed — skipping.`,
323
+ );
324
+ }
325
+ closed.push({ slug: target.ticket.slug, issueNumber: target.issueNumber });
326
+ closedSlugs.push(target.ticket.slug);
327
+ }
328
+
329
+ for (const target of targets.modify) {
330
+ if (!target.alreadyClosed) {
331
+ await provider.updateTicket(target.issueNumber, { state: 'closed' });
332
+ }
333
+ const newId = await createFromValidated(target.ticket.slug);
334
+ recreated.push({
335
+ slug: target.ticket.slug,
336
+ oldIssueNumber: target.issueNumber,
337
+ issueNumber: newId,
338
+ });
339
+ mapping[target.ticket.slug] = { entity: 'story', issueNumber: newId };
340
+ }
341
+
342
+ for (const [slug] of validatedBySlug) {
343
+ const isModify = targets.modify.some((t) => t.ticket.slug === slug);
344
+ const isKeep = targets.keep.some((t) => t.ticket.slug === slug);
345
+ if (isModify || isKeep) continue;
346
+ const newId = await createFromValidated(slug);
347
+ created.push({ slug, issueNumber: newId });
348
+ mapping[slug] = { entity: 'story', issueNumber: newId };
349
+ }
350
+
351
+ for (const target of targets.keep) {
352
+ mapping[target.ticket.slug] = {
353
+ entity: 'story',
354
+ issueNumber: target.issueNumber,
355
+ };
356
+ }
357
+
358
+ return { closed, recreated, created, mapping, closedSlugs };
359
+ }
@@ -0,0 +1,127 @@
1
+ /**
2
+ * delivery-mode.js — delivery-shape mode resolution for the collapsed
3
+ * persist surface (Epic #4474 PR4, design §2 mode matrix).
4
+ *
5
+ * The risk verdict's optional `deliveryShape` field ("fan-out" | "single",
6
+ * absent → fan-out so every existing verdict stays valid) selects between
7
+ * the three persist modes:
8
+ *
9
+ * - `fan-out` — the full mode: ticket validator + DAG + budget gates,
10
+ * Story-tree creation via the structural reconciler.
11
+ * - `single` — the spec-only single-delivery mode: NO tickets are
12
+ * authored, the ticket validator + DAG are skipped (fenced by
13
+ * construction — {@link resolveDeliveryMode} hard-refuses the
14
+ * combination of `deliveryShape: "single"` with a tickets payload, so
15
+ * the skip branch is unreachable when tickets are present), and the
16
+ * persist applies the `delivery::single` routing marker instead of a
17
+ * Story tree. Inert until #4475 lands the deliver-side reader.
18
+ * - `amend` — the change-request delta path (`--amend`): tickets carry
19
+ * `op: add|modify|keep|close` and the persist maps the ops onto the
20
+ * existing Story tree (see `plan-persist/amend.js`).
21
+ *
22
+ * @module lib/orchestration/plan-persist/delivery-mode
23
+ */
24
+
25
+ import { parseDeliverySlicingTable } from '../consolidation-precondition.js';
26
+
27
+ /** Canonical delivery-shape values the risk-verdict schema admits. */
28
+ const DELIVERY_SHAPES = Object.freeze(['fan-out', 'single']);
29
+
30
+ /**
31
+ * Mode-coherence hard error (design §1 step 3 item 3, extended for PR4).
32
+ *
33
+ * Resolves the persist mode from the risk verdict's `deliveryShape` (absent
34
+ * → `fan-out`), the tickets payload, and the `--amend` flag — and refuses
35
+ * every incoherent combination loudly instead of silently coercing:
36
+ *
37
+ * - unknown `deliveryShape` values;
38
+ * - `deliveryShape: "single"` WITH a tickets payload (the DAG-skip
39
+ * leakage risk — the single mode's validator/DAG skip is only sound
40
+ * when there are no tickets to validate);
41
+ * - fan-out without a non-empty tickets array;
42
+ * - `--amend` combined with `deliveryShape: "single"` (the delta path is
43
+ * fan-out-shaped by definition — a single-delivery re-plan is a
44
+ * `--force` re-persist);
45
+ * - `--amend` without tickets (the delta IS the tickets-with-ops
46
+ * payload);
47
+ * - `op` fields present without `--amend` (a full persist must never
48
+ * silently reinterpret an amend delta as a fresh tree).
49
+ *
50
+ * @param {{ deliveryShape?: string }} riskVerdict schema-validated verdict
51
+ * @param {unknown} tickets parsed tickets payload (null when no file)
52
+ * @param {{ amend?: boolean }} [opts]
53
+ * @returns {'fan-out'|'single'|'amend'} the resolved persist mode
54
+ */
55
+ export function resolveDeliveryMode(riskVerdict, tickets, opts = {}) {
56
+ const { amend = false } = opts;
57
+ const shape = riskVerdict?.deliveryShape ?? 'fan-out';
58
+ if (!DELIVERY_SHAPES.includes(shape)) {
59
+ throw new Error(
60
+ `[plan-persist] unknown deliveryShape "${shape}" — expected one of ` +
61
+ `${DELIVERY_SHAPES.join(', ')} (or omit the field for fan-out).`,
62
+ );
63
+ }
64
+ const hasTickets = Array.isArray(tickets) && tickets.length > 0;
65
+
66
+ if (amend) {
67
+ if (shape === 'single') {
68
+ throw new Error(
69
+ '[plan-persist] --amend is incoherent with deliveryShape "single" — ' +
70
+ 'the amend delta maps ops onto an existing Story tree, and a ' +
71
+ 'single-delivery plan has none. Re-author the verdict as fan-out, ' +
72
+ 'or re-persist the single plan with --force.',
73
+ );
74
+ }
75
+ if (!hasTickets) {
76
+ throw new Error(
77
+ '[plan-persist] --amend requires a tickets payload carrying ' +
78
+ '`op: add|modify|keep|close` on every ticket — the delta IS the ' +
79
+ 'tickets file (--tickets <file>).',
80
+ );
81
+ }
82
+ return 'amend';
83
+ }
84
+
85
+ if (shape === 'single') {
86
+ if (hasTickets) {
87
+ throw new Error(
88
+ '[plan-persist] mode-coherence: deliveryShape "single" with a ' +
89
+ 'tickets payload is contradictory — the single-delivery mode ' +
90
+ 'authors NO tickets (the Delivery Slicing table is the audit ' +
91
+ 'trail). Remove the tickets file, or re-author the risk verdict ' +
92
+ 'as fan-out.',
93
+ );
94
+ }
95
+ return 'single';
96
+ }
97
+
98
+ if (!hasTickets) {
99
+ throw new Error(
100
+ '[plan-persist] fan-out persist requires a non-empty tickets array ' +
101
+ '(--tickets <file>). A ticket-less spec-only plan must declare ' +
102
+ 'deliveryShape: "single" in the risk verdict.',
103
+ );
104
+ }
105
+ if (tickets.some((t) => t && typeof t === 'object' && 'op' in t)) {
106
+ throw new Error(
107
+ '[plan-persist] tickets carry `op` fields but --amend was not passed ' +
108
+ '— refusing to reinterpret an amend delta as a full persist. Pass ' +
109
+ '--amend, or strip the op fields for a fresh fan-out.',
110
+ );
111
+ }
112
+ return 'fan-out';
113
+ }
114
+
115
+ /**
116
+ * Count the slices of the authored Tech Spec's `## Delivery Slicing` table
117
+ * — the single-mode plan summary's `sliceCount` (design §2: the slicing
118
+ * table IS the single mode's audit trail). Returns `null` when the table
119
+ * is absent or unparseable (fail-open, mirroring the parser's contract).
120
+ *
121
+ * @param {string} techSpecContent
122
+ * @returns {number|null}
123
+ */
124
+ export function countDeliverySlices(techSpecContent) {
125
+ const rows = parseDeliverySlicingTable(techSpecContent ?? '');
126
+ return Array.isArray(rows) ? rows.length : null;
127
+ }