@ikunin/sprintpilot 2.2.31 → 2.3.1

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 (47) hide show
  1. package/README.md +228 -415
  2. package/_Sprintpilot/Sprintpilot.md +76 -8
  3. package/_Sprintpilot/bin/autopilot.js +734 -68
  4. package/_Sprintpilot/lib/orchestrator/action-ledger.js +208 -0
  5. package/_Sprintpilot/lib/orchestrator/adapt.js +93 -15
  6. package/_Sprintpilot/lib/orchestrator/profile-rules.js +7 -16
  7. package/_Sprintpilot/lib/orchestrator/sprint-plan.js +488 -0
  8. package/_Sprintpilot/lib/orchestrator/state-store.js +9 -5
  9. package/_Sprintpilot/lib/orchestrator/user-command-applier.js +78 -0
  10. package/_Sprintpilot/lib/orchestrator/user-commands.js +114 -0
  11. package/_Sprintpilot/lib/orchestrator/verify.js +10 -17
  12. package/_Sprintpilot/manifest.yaml +4 -3
  13. package/_Sprintpilot/modules/autopilot/profiles/_base.yaml +18 -4
  14. package/_Sprintpilot/modules/git/config.yaml +15 -9
  15. package/_Sprintpilot/modules/ma/config.yaml +29 -27
  16. package/_Sprintpilot/scripts/dispatch-layer.js +12 -15
  17. package/_Sprintpilot/scripts/infer-dependencies.js +706 -254
  18. package/_Sprintpilot/scripts/log-timing.js +6 -10
  19. package/_Sprintpilot/scripts/merge-shards.js +21 -23
  20. package/_Sprintpilot/scripts/post-green-gates.js +3 -1
  21. package/_Sprintpilot/scripts/resolve-dag.js +452 -280
  22. package/_Sprintpilot/scripts/sprint-plan.js +1068 -0
  23. package/_Sprintpilot/scripts/state-shard.js +13 -5
  24. package/_Sprintpilot/scripts/summarize-timings.js +2 -3
  25. package/_Sprintpilot/skills/sprint-autopilot-on/SKILL.md +30 -2
  26. package/_Sprintpilot/skills/sprint-autopilot-on/workflow.orchestrator.md +36 -10
  27. package/_Sprintpilot/skills/sprintpilot-codebase-map/agents/architecture-mapper.md +10 -8
  28. package/_Sprintpilot/skills/sprintpilot-codebase-map/agents/concerns-hunter.md +11 -9
  29. package/_Sprintpilot/skills/sprintpilot-codebase-map/agents/integration-mapper.md +11 -9
  30. package/_Sprintpilot/skills/sprintpilot-codebase-map/agents/quality-assessor.md +10 -8
  31. package/_Sprintpilot/skills/sprintpilot-codebase-map/agents/stack-analyzer.md +11 -9
  32. package/_Sprintpilot/skills/sprintpilot-codebase-map/workflow.md +1 -1
  33. package/_Sprintpilot/skills/sprintpilot-dependency-graph/SKILL.md +63 -0
  34. package/_Sprintpilot/skills/sprintpilot-dependency-graph/workflow.md +227 -0
  35. package/_Sprintpilot/skills/sprintpilot-plan-sprint/SKILL.md +67 -0
  36. package/_Sprintpilot/skills/sprintpilot-plan-sprint/workflow.md +435 -0
  37. package/_Sprintpilot/skills/sprintpilot-sprint-progress/SKILL.md +53 -0
  38. package/_Sprintpilot/skills/sprintpilot-sprint-progress/workflow.md +169 -0
  39. package/lib/commands/install.js +186 -12
  40. package/package.json +1 -1
  41. package/_Sprintpilot/skills/sprintpilot-code-review/SKILL.md +0 -6
  42. package/_Sprintpilot/skills/sprintpilot-code-review/agents/acceptance-auditor.md +0 -51
  43. package/_Sprintpilot/skills/sprintpilot-code-review/agents/blind-hunter.md +0 -39
  44. package/_Sprintpilot/skills/sprintpilot-code-review/agents/edge-case-hunter.md +0 -46
  45. package/_Sprintpilot/skills/sprintpilot-code-review/workflow.md +0 -111
  46. package/_Sprintpilot/skills/sprintpilot-party-mode/SKILL.md +0 -6
  47. package/_Sprintpilot/skills/sprintpilot-party-mode/workflow.md +0 -138
@@ -1,22 +1,27 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // infer-dependencies.js — validates an LLM-produced inter-story dependency
4
- // envelope and writes _Sprintpilot/sprints/dependencies.yaml for resolve-dag.
4
+ // envelope and writes its content into sprint-plan.yaml's
5
+ // `dependencies.stories` block (Sprintpilot v2.3.0+).
5
6
  //
6
7
  // Sprintpilot scripts NEVER call LLMs (architecture rule). The autopilot
7
8
  // session does the inference inline in a workflow.md action, then pipes the
8
9
  // resulting JSON envelope into this script via stdin. The script:
9
10
  // 1. Validates the envelope (schema, unknown keys, self-deps, cross-epic
10
11
  // edges, missing rationales, cycles).
11
- // 2. Merges with any existing dependencies.yaml preserving the user's
12
- // `overrides:` and `epics:` blocks verbatim if present.
13
- // 3. Writes the file with an `# AUTO-INFERRED` marker header so future
14
- // runs (and humans) can distinguish auto vs hand-authored content.
12
+ // 2. Reads the existing plan (or bootstraps an empty one) via sprint-plan.js.
13
+ // 3. Replaces entries for THIS EPIC's stories inside
14
+ // `plan.dependencies.stories` while leaving other-epic entries,
15
+ // cross_epic_deps, and `overrides:` block intact.
16
+ // 4. Writes the plan atomically via sprint-plan.js#write.
17
+ //
18
+ // Cross-epic edges live in a separate top-level block and are populated by
19
+ // the `write-cross-epic` subcommand (added later in Phase 0).
15
20
  //
16
21
  // Usage:
17
22
  // infer-dependencies.js scaffold-prompt --epic <id> [--project-root <path>]
18
23
  // infer-dependencies.js dry-run --epic <id> [--project-root <path>]
19
- // infer-dependencies.js write --epic <id> [--project-root <path>] [--force]
24
+ // infer-dependencies.js write --epic <id> [--project-root <path>]
20
25
  //
21
26
  // Subcommands:
22
27
  // scaffold-prompt — emits the literal LLM prompt with file paths
@@ -24,14 +29,13 @@
24
29
  // and feeds it into the in-conversation reasoning step.
25
30
  // Exit 0 always.
26
31
  // dry-run — accepts LLM JSON via stdin, validates, returns
27
- // `{valid, errors, merged_doc, diff}` envelope on
32
+ // `{valid, errors, merged_plan, diff}` envelope on
28
33
  // stdout. Exit 0 if valid; 1 otherwise.
29
34
  // write — accepts LLM JSON via stdin, validates, writes the
30
- // dependencies.yaml file. Exit 0 on success, 1 on
31
- // validation failure, 2 if a hand-authored file
32
- // (no marker) exists and --force is not set.
35
+ // sprint-plan.yaml file. Exit 0 on success, 1 on
36
+ // validation failure, 2 on plan file read/corrupt error.
33
37
  //
34
- // LLM JSON envelope:
38
+ // LLM JSON envelope (unchanged from prior versions):
35
39
  // { "version": 1, "epic": "1",
36
40
  // "dependencies": { "<key>": ["<dep-key>", ...], ... },
37
41
  // "rationale": { "<key>": "1-sentence justification", ... } }
@@ -40,15 +44,16 @@
40
44
  // "no deps" from "LLM forgot"). Rationale is required for every key in
41
45
  // `dependencies` so reviewers can spot hallucinated edges.
42
46
 
43
- const crypto = require('node:crypto');
44
47
  const fs = require('node:fs');
45
48
  const path = require('node:path');
49
+ const yaml = require('js-yaml');
46
50
 
47
51
  const { parseArgs } = require('../lib/runtime/args');
48
52
  const log = require('../lib/runtime/log');
49
53
 
50
54
  const dagMod = require('./resolve-dag.js');
51
55
  const timing = require('./log-timing.js');
56
+ const sprintPlanMod = require('./sprint-plan.js');
52
57
 
53
58
  function emitTimingEvent(projectRoot, phase, meta) {
54
59
  try {
@@ -61,26 +66,50 @@ function emitTimingEvent(projectRoot, phase, meta) {
61
66
  const {
62
67
  readStoriesFromStatus,
63
68
  parseEpicFromKey,
64
- parseDependenciesYaml,
65
69
  topoLayers,
66
- dependenciesPath,
67
70
  sprintStatusPath,
71
+ dependenciesPath,
68
72
  } = dagMod;
69
73
 
70
- const AUTO_MARKER = '# AUTO-INFERRED — regenerate via infer-dependencies.js';
71
- const VALID_COMMANDS = ['scaffold-prompt', 'dry-run', 'write'];
74
+ const {
75
+ read: readPlan,
76
+ write: writePlan,
77
+ emptyPlan,
78
+ planPath,
79
+ } = sprintPlanMod;
80
+
81
+ const VALID_COMMANDS = ['scaffold-prompt', 'dry-run', 'write', 'migrate', 'write-cross-epic'];
82
+
83
+ // Cross-epic rationale length cap. Matches the prompt instruction
84
+ // (`≤200 chars`) and is validated server-side so misbehaving LLMs can't
85
+ // stuff multi-sentence justifications into the plan.
86
+ const CROSS_EPIC_RATIONALE_MAX = 200;
72
87
 
73
88
  function help() {
74
89
  log.out(
75
90
  [
76
91
  'Usage:',
77
92
  ' infer-dependencies.js scaffold-prompt --epic <id> [--project-root <path>]',
93
+ ' infer-dependencies.js scaffold-prompt --cross-epic [--project-root <path>]',
78
94
  ' infer-dependencies.js dry-run --epic <id> [--project-root <path>]',
79
- ' infer-dependencies.js write --epic <id> [--project-root <path>] [--force]',
95
+ ' infer-dependencies.js dry-run --cross-epic [--project-root <path>]',
96
+ ' infer-dependencies.js write --epic <id> [--project-root <path>]',
97
+ ' infer-dependencies.js write-cross-epic [--project-root <path>]',
98
+ ' infer-dependencies.js migrate [--project-root <path>]',
80
99
  '',
81
100
  'Validates an LLM-produced dependency envelope (read from stdin) and',
82
- 'writes _Sprintpilot/sprints/dependencies.yaml. The autopilot session',
83
- 'is the LLM caller — this script never calls a model itself.',
101
+ 'writes the result into sprint-plan.yaml`s `dependencies.stories` block.',
102
+ 'The autopilot session is the LLM caller — this script never calls a',
103
+ 'model itself.',
104
+ '',
105
+ '--cross-epic mode targets cross-epic dependencies (separate from the',
106
+ 'per-epic envelope). The per-epic prompt continues to REJECT cross-epic',
107
+ 'edges per its existing constraint set; cross-epic detection is a',
108
+ 'distinct prompt + envelope shape.',
109
+ '',
110
+ '`migrate` is a one-shot upgrade path: imports any existing legacy',
111
+ '_Sprintpilot/sprints/dependencies.yaml into sprint-plan.yaml and',
112
+ 'archives the old file. Idempotent (no-op when old file absent).',
84
113
  ].join('\n'),
85
114
  );
86
115
  }
@@ -93,7 +122,7 @@ function scaffoldPrompt(projectRoot, epic) {
93
122
  const ssFile = sprintStatusPath(projectRoot);
94
123
  const epicsFile = path.join(projectRoot, '_bmad-output', 'planning-artifacts', 'epics.md');
95
124
  const archFile = path.join(projectRoot, '_bmad-output', 'planning-artifacts', 'architecture.md');
96
- const depsFile = dependenciesPath(projectRoot);
125
+ const planFile = planPath(projectRoot);
97
126
  const lines = [
98
127
  `You are inferring inter-story execution dependencies for epic ${epic}. Your output controls which stories Sprintpilot runs concurrently vs sequentially. Wrong dependencies just over-serialize the sprint (slower, not broken). Wrong independence claims cause merge conflicts in worktrees.`,
99
128
  '',
@@ -101,7 +130,7 @@ function scaffoldPrompt(projectRoot, epic) {
101
130
  `1. ${ssFile} — the authoritative list of story keys for epic ${epic}. Use ONLY these keys.`,
102
131
  `2. ${epicsFile} — story descriptions and Acceptance Criteria.`,
103
132
  `3. ${archFile} — component map.`,
104
- `4. ${depsFile} if present — existing user overrides (you must NOT modify these; the script preserves them).`,
133
+ `4. ${planFile} if present — the active sprint plan. Inspect the existing dependencies block to avoid reverting prior edits; the script will preserve all other-epic entries and the "overrides:" block.`,
105
134
  '',
106
135
  'RULES — only emit a dependency edge when ONE of the following is concretely true from the read documents:',
107
136
  "- Story B's Acceptance Criteria explicitly reference an artifact (table, endpoint, component, file) that story A creates.",
@@ -113,7 +142,7 @@ function scaffoldPrompt(projectRoot, epic) {
113
142
  '- General "comes later in the sprint" ordering preferences.',
114
143
  '- Vague thematic similarity ("both touch the user feature").',
115
144
  '- Test-only or doc-only relationships.',
116
- `- Different epics — only edges within epic ${epic} are valid.`,
145
+ `- Different epics — only edges within epic ${epic} are valid here. Cross-epic edges go through a separate cross-epic inference pass.`,
117
146
  '',
118
147
  'OUTPUT — exactly one JSON object, no prose, no fences:',
119
148
  '',
@@ -124,8 +153,251 @@ function scaffoldPrompt(projectRoot, epic) {
124
153
  return lines.join('\n');
125
154
  }
126
155
 
156
+ // Cross-epic prompt — separate from the per-epic scaffold-prompt because
157
+ // the per-epic prompt INSTRUCTS the LLM to reject cross-epic edges
158
+ // (preserves narrow-scope inference + bounded prompt size). The
159
+ // cross-epic prompt asks the complementary question: with per-epic
160
+ // dependencies and architecture in hand, which edges cross epic
161
+ // boundaries?
162
+ function scaffoldCrossEpicPrompt(projectRoot) {
163
+ const ssFile = sprintStatusPath(projectRoot);
164
+ const epicsFile = path.join(projectRoot, '_bmad-output', 'planning-artifacts', 'epics.md');
165
+ const archFile = path.join(projectRoot, '_bmad-output', 'planning-artifacts', 'architecture.md');
166
+ const planFile = planPath(projectRoot);
167
+ const lines = [
168
+ 'You are helping infer CROSS-EPIC story dependencies for a Sprintpilot sprint plan.',
169
+ '',
170
+ 'READ in order:',
171
+ `1. ${planFile} — already-inferred per-epic edges live in dependencies.stories. Do not duplicate these.`,
172
+ `2. ${ssFile} — authoritative list of story keys (epic prefix = leading hyphen segment).`,
173
+ `3. ${epicsFile} — story descriptions and Acceptance Criteria.`,
174
+ `4. ${archFile} (truncated to ## headings) — component map for spotting cross-module hard deps.`,
175
+ '',
176
+ 'QUESTION: Which stories in DIFFERENT epics have a hard execution dependency on a story in another epic?',
177
+ '',
178
+ 'Flag ONLY dependencies that would BLOCK correct execution if violated:',
179
+ '- Schema needed before consumer reads from it.',
180
+ '- Shared API contract that one story defines and another consumes.',
181
+ '- Data model needed before downstream story integrates against it.',
182
+ '- Migration/setup story explicitly named as a prerequisite in another story\'s AC.',
183
+ '',
184
+ 'DO NOT flag:',
185
+ '- Weak / soft coupling (sequencing preferences, "would be nice to ship first").',
186
+ '- Edges already present in the per-epic dependency map (would be duplicates).',
187
+ '- Edges within the same epic — those go through the per-epic flow.',
188
+ '- Cosmetic or doc-only relationships.',
189
+ '',
190
+ 'OUTPUT — exactly one JSON object, no prose, no fences:',
191
+ '',
192
+ ` { "version": 1, "cross_epic_deps": [ { "from_story": "<key>", "to_story": "<key>", "rationale": "<≤${CROSS_EPIC_RATIONALE_MAX} chars, 1 sentence>" } ] }`,
193
+ '',
194
+ 'Constraints (will be validated server-side; bad envelope rejected):',
195
+ '- `from_story` and `to_story` MUST belong to different epics (different leading hyphen segments).',
196
+ '- Both keys MUST appear in sprint-status.yaml.',
197
+ `- Rationale REQUIRED, max ${CROSS_EPIC_RATIONALE_MAX} chars, 1 sentence.`,
198
+ '- Do NOT duplicate edges already in plan.dependencies.stories[*].depends_on.',
199
+ '- Return `cross_epic_deps: []` if none detected.',
200
+ ];
201
+ return lines.join('\n');
202
+ }
203
+
204
+ // ---------------------------------------------------------------
205
+ // Cross-epic validation
206
+ // ---------------------------------------------------------------
207
+
208
+ // Validate a cross-epic envelope against sprint-status.yaml AND the
209
+ // already-inferred per-epic dependencies in sprint-plan.yaml.
210
+ // - keys must exist in sprint-status
211
+ // - from/to must be DIFFERENT epics
212
+ // - rationale required, max-length capped
213
+ // - duplicates against per-epic plan edges flagged
214
+ // - cycle in the COMBINED (intra + cross) graph flagged
215
+ function validateCrossEpicEnvelope(envelope, { projectRoot, plan }) {
216
+ const errors = [];
217
+ const push = (e) => errors.push(e);
218
+
219
+ if (envelope === null || typeof envelope !== 'object' || Array.isArray(envelope)) {
220
+ push({ code: 'schema', field: 'root', message: 'envelope must be a JSON object' });
221
+ return { valid: false, errors };
222
+ }
223
+ if (envelope.version !== 1) {
224
+ push({
225
+ code: 'schema',
226
+ field: 'version',
227
+ message: `expected version === 1, got ${JSON.stringify(envelope.version)}`,
228
+ });
229
+ }
230
+ if (!Array.isArray(envelope.cross_epic_deps)) {
231
+ push({
232
+ code: 'schema',
233
+ field: 'cross_epic_deps',
234
+ message: 'must be an array of { from_story, to_story, rationale }',
235
+ });
236
+ }
237
+ if (errors.length > 0) return { valid: false, errors };
238
+
239
+ // Read full sprint-status (no epic filter) — cross-epic spans the whole sprint.
240
+ const { byKey } = readStoriesFromStatus(projectRoot, null);
241
+ const validKeys = new Set(Object.keys(byKey));
242
+
243
+ // Build the set of edges already inferred per-epic so we can flag duplicates.
244
+ const perEpicEdges = new Set();
245
+ const planStories =
246
+ plan && plan.dependencies && plan.dependencies.stories
247
+ ? plan.dependencies.stories
248
+ : {};
249
+ for (const key of Object.keys(planStories)) {
250
+ const depsList = planStories[key]?.depends_on;
251
+ if (!Array.isArray(depsList)) continue;
252
+ for (const dep of depsList) {
253
+ perEpicEdges.add(`${dep}→${key}`);
254
+ }
255
+ }
256
+
257
+ const seenPairs = new Set();
258
+ const crossEdges = []; // for cycle detection later
259
+ for (let i = 0; i < envelope.cross_epic_deps.length; i++) {
260
+ const edge = envelope.cross_epic_deps[i];
261
+ if (!edge || typeof edge !== 'object' || Array.isArray(edge)) {
262
+ push({
263
+ code: 'schema',
264
+ field: `cross_epic_deps[${i}]`,
265
+ message: 'each entry must be an object',
266
+ });
267
+ continue;
268
+ }
269
+ const { from_story, to_story, rationale } = edge;
270
+ if (typeof from_story !== 'string' || from_story.length === 0) {
271
+ push({
272
+ code: 'schema',
273
+ field: `cross_epic_deps[${i}].from_story`,
274
+ message: 'must be a non-empty string',
275
+ });
276
+ }
277
+ if (typeof to_story !== 'string' || to_story.length === 0) {
278
+ push({
279
+ code: 'schema',
280
+ field: `cross_epic_deps[${i}].to_story`,
281
+ message: 'must be a non-empty string',
282
+ });
283
+ }
284
+ if (typeof rationale !== 'string' || rationale.trim() === '') {
285
+ push({
286
+ code: 'schema',
287
+ field: `cross_epic_deps[${i}].rationale`,
288
+ message: 'rationale required (non-empty string)',
289
+ });
290
+ } else if (rationale.length > CROSS_EPIC_RATIONALE_MAX) {
291
+ push({
292
+ code: 'rationale-too-long',
293
+ field: `cross_epic_deps[${i}].rationale`,
294
+ message: `rationale exceeds ${CROSS_EPIC_RATIONALE_MAX} chars (got ${rationale.length})`,
295
+ });
296
+ }
297
+ if (typeof from_story !== 'string' || typeof to_story !== 'string') continue;
298
+
299
+ if (!validKeys.has(from_story)) {
300
+ push({
301
+ code: 'unknown-key',
302
+ key: from_story,
303
+ message: `from_story "${from_story}" not in sprint-status.yaml`,
304
+ });
305
+ }
306
+ if (!validKeys.has(to_story)) {
307
+ push({
308
+ code: 'unknown-key',
309
+ key: to_story,
310
+ message: `to_story "${to_story}" not in sprint-status.yaml`,
311
+ });
312
+ }
313
+ if (from_story === to_story) {
314
+ push({
315
+ code: 'self-dep',
316
+ key: from_story,
317
+ message: `from_story and to_story must differ (got both "${from_story}")`,
318
+ });
319
+ continue;
320
+ }
321
+ const fromEpic = parseEpicFromKey(from_story);
322
+ const toEpic = parseEpicFromKey(to_story);
323
+ if (fromEpic !== null && toEpic !== null && fromEpic === toEpic) {
324
+ push({
325
+ code: 'same-epic',
326
+ from: from_story,
327
+ to: to_story,
328
+ message: `cross_epic edge "${from_story}" → "${to_story}" is within epic ${fromEpic} — use the per-epic write subcommand instead`,
329
+ });
330
+ continue;
331
+ }
332
+ const pairKey = `${to_story}→${from_story}`;
333
+ if (seenPairs.has(pairKey)) {
334
+ push({
335
+ code: 'duplicate-in-envelope',
336
+ from: from_story,
337
+ to: to_story,
338
+ message: `duplicate cross-epic edge "${from_story}" → "${to_story}" in envelope`,
339
+ });
340
+ continue;
341
+ }
342
+ seenPairs.add(pairKey);
343
+
344
+ // Convention: per-epic depends_on encodes "X depends on Y" as edge Y→X.
345
+ // For cross-epic, from_story depends on to_story → edge to_story → from_story.
346
+ const edgeKey = `${to_story}→${from_story}`;
347
+ if (perEpicEdges.has(edgeKey)) {
348
+ push({
349
+ code: 'duplicate-of-per-epic',
350
+ from: from_story,
351
+ to: to_story,
352
+ message: `edge "${from_story}" depends on "${to_story}" is already in per-epic dependencies`,
353
+ });
354
+ continue;
355
+ }
356
+ crossEdges.push([to_story, from_story]);
357
+ }
358
+
359
+ // Cycle check in the COMBINED graph (per-epic + cross-epic).
360
+ if (errors.length === 0) {
361
+ const allKeys = Object.keys(byKey);
362
+ const allEdges = [];
363
+ for (const key of Object.keys(planStories)) {
364
+ const depsList = planStories[key]?.depends_on;
365
+ if (!Array.isArray(depsList)) continue;
366
+ for (const dep of depsList) allEdges.push([dep, key]);
367
+ }
368
+ for (const e of crossEdges) allEdges.push(e);
369
+ const { cycle } = topoLayers(allKeys, allEdges);
370
+ if (cycle.length > 0) {
371
+ push({
372
+ code: 'cycle',
373
+ nodes: cycle.slice().sort(),
374
+ message: `cyclic dependency among: ${cycle.slice().sort().join(', ')} (combined intra + cross-epic graph)`,
375
+ });
376
+ }
377
+ }
378
+
379
+ return { valid: errors.length === 0, errors };
380
+ }
381
+
382
+ // Apply a cross-epic envelope to a plan. Replaces plan.cross_epic_deps with
383
+ // the new edges, stamping `inferred_at` on each one for the AUTO-INFERRED
384
+ // marker semantic (so future runs can distinguish auto vs hand-authored).
385
+ function applyCrossEpicToPlan(envelope, plan) {
386
+ const now = new Date().toISOString();
387
+ const next = {
388
+ ...plan,
389
+ cross_epic_deps: envelope.cross_epic_deps.map((e) => ({
390
+ from_story: e.from_story,
391
+ to_story: e.to_story,
392
+ rationale: e.rationale,
393
+ inferred_at: now,
394
+ })),
395
+ };
396
+ return next;
397
+ }
398
+
127
399
  // ---------------------------------------------------------------
128
- // Validation
400
+ // Validation (unchanged from prior versions)
129
401
  // ---------------------------------------------------------------
130
402
 
131
403
  function validateEnvelope(envelope, { projectRoot, epic }) {
@@ -213,7 +485,7 @@ function validateEnvelope(envelope, { projectRoot, epic }) {
213
485
  code: 'cross-epic-dep',
214
486
  from: key,
215
487
  to: dep,
216
- message: `cross-epic edge "${key}" → "${dep}" (epic ${depEpic} ≠ ${epic}) — declare via overrides[*].epics.<id>.independent instead`,
488
+ message: `cross-epic edge "${key}" → "${dep}" (epic ${depEpic} ≠ ${epic}) — declare via the write-cross-epic subcommand instead`,
217
489
  });
218
490
  continue;
219
491
  }
@@ -267,166 +539,85 @@ function validateEnvelope(envelope, { projectRoot, epic }) {
267
539
  }
268
540
 
269
541
  // ---------------------------------------------------------------
270
- // Existing-file detection + merge
542
+ // Plan read + envelope application
271
543
  // ---------------------------------------------------------------
272
544
 
273
- function readExisting(projectRoot) {
274
- const file = dependenciesPath(projectRoot);
275
- if (!fs.existsSync(file)) return { exists: false, autoMarker: false, doc: null, raw: null };
276
- const raw = fs.readFileSync(file, 'utf8');
277
- const firstNonEmpty = raw.split(/\r?\n/).find((l) => l.trim().length > 0) ?? '';
278
- const autoMarker = firstNonEmpty.trim() === AUTO_MARKER;
279
- let doc = null;
280
- try {
281
- doc = parseDependenciesYaml(raw);
282
- } catch {
283
- doc = null;
545
+ // Read the existing sprint-plan.yaml (or report absence / corruption).
546
+ // Returns { exists, plan, error?, message? }.
547
+ function readExistingPlan(projectRoot) {
548
+ const result = readPlan({ projectRoot });
549
+ if (result === null) {
550
+ return { exists: false, plan: null };
551
+ }
552
+ if (result && typeof result === 'object' && 'error' in result) {
553
+ return {
554
+ exists: true,
555
+ plan: null,
556
+ error: result.error,
557
+ message: result.message,
558
+ ...(result.missing_keys ? { missing_keys: result.missing_keys } : {}),
559
+ };
284
560
  }
285
- return { exists: true, autoMarker, doc, raw };
561
+ return { exists: true, plan: result };
286
562
  }
287
563
 
288
- function mergeDoc(envelope, existing) {
289
- // stories: regenerated entirely from the LLM envelope.
290
- const stories = {};
291
- const sortedKeys = Object.keys(envelope.dependencies).sort();
292
- for (const k of sortedKeys) {
293
- stories[k] = {
564
+ // Apply an LLM envelope to a plan, replacing dependency entries for THIS
565
+ // EPIC's stories. Other-epic entries, cross_epic_deps, overrides, and the
566
+ // `notes:` block are preserved verbatim. Stamps `dependencies.auto_inferred_at`.
567
+ function applyEnvelopeToPlan(envelope, plan, { projectRoot, epic }) {
568
+ const { byKey } = readStoriesFromStatus(projectRoot, String(epic));
569
+ const epicKeys = new Set(Object.keys(byKey));
570
+
571
+ const prevStories =
572
+ plan.dependencies && plan.dependencies.stories && typeof plan.dependencies.stories === 'object'
573
+ ? { ...plan.dependencies.stories }
574
+ : {};
575
+
576
+ // Drop existing entries for this epic's stories — the envelope is
577
+ // authoritative for the epic's edges (stories absent from envelope are
578
+ // declared dependency-free).
579
+ for (const k of Object.keys(prevStories)) {
580
+ if (epicKeys.has(k)) delete prevStories[k];
581
+ }
582
+
583
+ // Add entries from the envelope (sorted depends_on for determinism).
584
+ for (const k of Object.keys(envelope.dependencies).sort()) {
585
+ prevStories[k] = {
294
586
  depends_on: envelope.dependencies[k].slice().sort(),
295
587
  rationale: envelope.rationale[k],
296
588
  };
297
589
  }
298
- // overrides + epics: preserved from existing if present, else empty defaults.
299
- const overrides =
300
- existing && existing.doc && Array.isArray(existing.doc.overrides) ? existing.doc.overrides : [];
301
- const epics =
302
- existing &&
303
- existing.doc &&
304
- existing.doc.epics &&
305
- typeof existing.doc.epics === 'object' &&
306
- !Array.isArray(existing.doc.epics)
307
- ? existing.doc.epics
308
- : {};
309
- return { version: 1, stories, overrides, epics };
310
- }
311
-
312
- // ---------------------------------------------------------------
313
- // Hash + serialization
314
- // ---------------------------------------------------------------
315
590
 
316
- // Content hash covers the structural fields (deps + overrides + epics).
317
- // Rationale text changes do NOT change the hash — they're for human review.
318
- function contentHash(doc) {
319
- const stripped = {
320
- stories: {},
321
- overrides: doc.overrides ?? [],
322
- epics: doc.epics ?? {},
591
+ const nextDependencies = {
592
+ ...(plan.dependencies || { version: 1, stories: {} }),
593
+ version: plan.dependencies?.version ?? 1,
594
+ auto_inferred_at: new Date().toISOString(),
595
+ stories: prevStories,
323
596
  };
324
- for (const k of Object.keys(doc.stories ?? {}).sort()) {
325
- stripped.stories[k] = { depends_on: (doc.stories[k].depends_on ?? []).slice().sort() };
326
- }
327
- return crypto.createHash('sha256').update(JSON.stringify(stripped)).digest('hex').slice(0, 12);
328
- }
329
597
 
330
- // Serialize a value for inline YAML emission. Strings get JSON quoting when
331
- // they contain reserved characters; arrays use JSON flow form (matches the
332
- // shape parseDependenciesYaml accepts).
333
- function inlineScalar(v) {
334
- if (v === null || v === undefined) return 'null';
335
- if (typeof v === 'boolean' || typeof v === 'number') return String(v);
336
- if (Array.isArray(v)) return JSON.stringify(v);
337
- if (typeof v === 'object') return JSON.stringify(v);
338
- const s = String(v);
339
- const needsQuote =
340
- s === '' ||
341
- /[:#\n\r"'\\]/.test(s) ||
342
- /^[\s\-?&*!|>%@`]/.test(s) ||
343
- /^(true|false|null|~|yes|no|on|off)$/i.test(s) ||
344
- /^-?\d/.test(s);
345
- return needsQuote ? JSON.stringify(s) : s;
598
+ return {
599
+ ...plan,
600
+ dependencies: nextDependencies,
601
+ };
346
602
  }
347
603
 
348
- // Small dedicated serializer for dependencies.yaml. Produces nested block
349
- // YAML that parseDependenciesYaml round-trips. Top-level keys: version,
350
- // stories, overrides, epics.
351
- function renderYaml(doc, hash) {
352
- const lines = [
353
- AUTO_MARKER,
354
- '# DO NOT hand-edit `stories:` directly it is regenerated on the next',
355
- '# planning cycle. To pin a relationship, add to `overrides:` instead.',
356
- `# Hash: ${hash}`,
357
- '',
358
- `version: ${doc.version}`,
359
- ];
360
-
361
- // stories: block-form with sorted keys
362
- const storyKeys = Object.keys(doc.stories ?? {}).sort();
363
- if (storyKeys.length === 0) {
364
- lines.push('stories: {}');
365
- } else {
366
- lines.push('stories:');
367
- for (const k of storyKeys) {
368
- const entry = doc.stories[k];
369
- lines.push(` ${k}:`);
370
- lines.push(` depends_on: ${JSON.stringify((entry.depends_on ?? []).slice().sort())}`);
371
- if (entry.rationale !== undefined) {
372
- lines.push(` rationale: ${inlineScalar(entry.rationale)}`);
373
- }
374
- }
375
- }
376
-
377
- // overrides: preserved verbatim from existing doc; emit as block-form list
378
- // of mappings (each entry is an object with epic/force_independent/etc).
379
- const overrides = Array.isArray(doc.overrides) ? doc.overrides : [];
380
- if (overrides.length === 0) {
381
- lines.push('overrides: []');
382
- } else {
383
- lines.push('overrides:');
384
- for (const ov of overrides) {
385
- const ovKeys = Object.keys(ov ?? {});
386
- if (ovKeys.length === 0) {
387
- lines.push(' - {}');
388
- continue;
389
- }
390
- const first = ovKeys[0];
391
- const firstVal = ov[first];
392
- if (Array.isArray(firstVal) || typeof firstVal !== 'object' || firstVal === null) {
393
- lines.push(` - ${first}: ${inlineScalar(firstVal)}`);
394
- } else {
395
- lines.push(` - ${first}:`);
396
- for (const sk of Object.keys(firstVal))
397
- lines.push(` ${sk}: ${inlineScalar(firstVal[sk])}`);
398
- }
399
- for (let i = 1; i < ovKeys.length; i++) {
400
- const k = ovKeys[i];
401
- const v = ov[k];
402
- if (Array.isArray(v) || typeof v !== 'object' || v === null) {
403
- lines.push(` ${k}: ${inlineScalar(v)}`);
404
- } else {
405
- lines.push(` ${k}:`);
406
- for (const sk of Object.keys(v)) lines.push(` ${sk}: ${inlineScalar(v[sk])}`);
407
- }
408
- }
409
- }
410
- }
411
-
412
- // epics: block-form mapping (each id maps to { independent: bool })
413
- const epicIds = Object.keys(doc.epics ?? {});
414
- if (epicIds.length === 0) {
415
- lines.push('epics: {}');
416
- } else {
417
- lines.push('epics:');
418
- for (const id of epicIds.sort()) {
419
- const e = doc.epics[id];
420
- if (!e || typeof e !== 'object' || Array.isArray(e)) {
421
- lines.push(` ${id}: ${inlineScalar(e)}`);
422
- continue;
423
- }
424
- lines.push(` ${id}:`);
425
- for (const sk of Object.keys(e)) lines.push(` ${sk}: ${inlineScalar(e[sk])}`);
604
+ // Count edges added vs removed between two plans, for reporting.
605
+ function diffEdges(prev, next) {
606
+ const collectEdges = (plan) => {
607
+ const set = new Set();
608
+ const s = plan?.dependencies?.stories ?? {};
609
+ for (const k of Object.keys(s)) {
610
+ for (const d of s[k].depends_on ?? []) set.add(`${d}→${k}`);
426
611
  }
427
- }
428
-
429
- return lines.join('\n') + '\n';
612
+ return set;
613
+ };
614
+ const prevEdges = collectEdges(prev);
615
+ const nextEdges = collectEdges(next);
616
+ let added = 0;
617
+ let removed = 0;
618
+ for (const e of nextEdges) if (!prevEdges.has(e)) added++;
619
+ for (const e of prevEdges) if (!nextEdges.has(e)) removed++;
620
+ return { added, removed };
430
621
  }
431
622
 
432
623
  // ---------------------------------------------------------------
@@ -445,64 +636,111 @@ function readStdin() {
445
636
  });
446
637
  }
447
638
 
448
- function atomicWrite(file, body) {
449
- const dir = path.dirname(file);
450
- fs.mkdirSync(dir, { recursive: true });
451
- const tmp = path.join(
452
- dir,
453
- `.${path.basename(file)}.tmp.${process.pid}.${process.hrtime.bigint().toString(36)}`,
454
- );
455
- const fd = fs.openSync(tmp, 'w', 0o644);
456
- try {
457
- fs.writeFileSync(fd, body);
458
- try {
459
- fs.fsyncSync(fd);
460
- } catch {
461
- /* fsync unsupported on some filesystems */
462
- }
463
- } finally {
464
- fs.closeSync(fd);
465
- }
466
- fs.renameSync(tmp, file);
467
- // Skip directory fsync on Windows: fs.openSync(<dir>, 'r') throws there
468
- // and we have no portable Windows equivalent. NTFS rename is atomic;
469
- // it's just not flushed to disk on power loss the way POSIX fsync would.
470
- if (process.platform !== 'win32') {
471
- try {
472
- const dfd = fs.openSync(dir, 'r');
473
- try {
474
- fs.fsyncSync(dfd);
475
- } finally {
476
- fs.closeSync(dfd);
477
- }
478
- } catch {
479
- /* directory fsync unsupported on some filesystems */
480
- }
481
- }
482
- }
483
-
484
639
  // ---------------------------------------------------------------
485
640
  // Subcommands
486
641
  // ---------------------------------------------------------------
487
642
 
488
- function diffCounts(prev, next) {
489
- const prevEdges = new Set();
490
- for (const k of Object.keys(prev?.stories ?? {})) {
491
- for (const d of prev.stories[k].depends_on ?? []) prevEdges.add(`${d}→${k}`);
643
+ async function runScaffoldPrompt(projectRoot, epic) {
644
+ process.stdout.write(scaffoldPrompt(projectRoot, epic) + '\n');
645
+ return 0;
646
+ }
647
+
648
+ async function runScaffoldPromptCrossEpic(projectRoot) {
649
+ process.stdout.write(scaffoldCrossEpicPrompt(projectRoot) + '\n');
650
+ return 0;
651
+ }
652
+
653
+ async function runDryRunCrossEpic(projectRoot) {
654
+ const stdin = await readStdin();
655
+ let envelope;
656
+ try {
657
+ envelope = JSON.parse(stdin);
658
+ } catch (e) {
659
+ process.stdout.write(
660
+ JSON.stringify({
661
+ valid: false,
662
+ errors: [{ code: 'schema', field: 'root', message: `invalid JSON: ${e.message}` }],
663
+ }) + '\n',
664
+ );
665
+ return 1;
492
666
  }
493
- const nextEdges = new Set();
494
- for (const k of Object.keys(next?.stories ?? {})) {
495
- for (const d of next.stories[k].depends_on ?? []) nextEdges.add(`${d}→${k}`);
667
+ const existing = readExistingPlan(projectRoot);
668
+ if (existing.error) {
669
+ process.stdout.write(
670
+ JSON.stringify({
671
+ valid: false,
672
+ errors: [{ code: existing.error, field: 'sprint-plan.yaml', message: existing.message }],
673
+ }) + '\n',
674
+ );
675
+ return 1;
496
676
  }
497
- let added = 0;
498
- let removed = 0;
499
- for (const e of nextEdges) if (!prevEdges.has(e)) added++;
500
- for (const e of prevEdges) if (!nextEdges.has(e)) removed++;
501
- return { added, removed };
677
+ const basePlan = existing.plan ?? emptyPlan({ source: 'cli' });
678
+ const result = validateCrossEpicEnvelope(envelope, { projectRoot, plan: basePlan });
679
+ if (!result.valid) {
680
+ process.stdout.write(JSON.stringify({ valid: false, errors: result.errors }) + '\n');
681
+ return 1;
682
+ }
683
+ const merged = applyCrossEpicToPlan(envelope, basePlan);
684
+ process.stdout.write(
685
+ JSON.stringify({
686
+ valid: true,
687
+ errors: [],
688
+ merged_plan: merged,
689
+ edges_inferred: envelope.cross_epic_deps.length,
690
+ }) + '\n',
691
+ );
692
+ return 0;
502
693
  }
503
694
 
504
- async function runScaffoldPrompt(projectRoot, epic) {
505
- process.stdout.write(scaffoldPrompt(projectRoot, epic) + '\n');
695
+ async function runWriteCrossEpic(projectRoot) {
696
+ const existing = readExistingPlan(projectRoot);
697
+ if (existing.error) {
698
+ process.stdout.write(
699
+ JSON.stringify({
700
+ wrote: false,
701
+ reason: existing.error,
702
+ message: existing.message,
703
+ file: planPath(projectRoot),
704
+ }) + '\n',
705
+ );
706
+ return 2;
707
+ }
708
+
709
+ const stdin = await readStdin();
710
+ let envelope;
711
+ try {
712
+ envelope = JSON.parse(stdin);
713
+ } catch (e) {
714
+ process.stdout.write(
715
+ JSON.stringify({
716
+ valid: false,
717
+ errors: [{ code: 'schema', field: 'root', message: `invalid JSON: ${e.message}` }],
718
+ }) + '\n',
719
+ );
720
+ return 1;
721
+ }
722
+
723
+ const basePlan = existing.plan ?? emptyPlan({ source: 'cli' });
724
+ const result = validateCrossEpicEnvelope(envelope, { projectRoot, plan: basePlan });
725
+ if (!result.valid) {
726
+ process.stdout.write(JSON.stringify({ valid: false, errors: result.errors }) + '\n');
727
+ return 1;
728
+ }
729
+
730
+ const merged = applyCrossEpicToPlan(envelope, basePlan);
731
+ const file = writePlan(merged, { projectRoot });
732
+
733
+ emitTimingEvent(projectRoot, 'planning.infer-cross-epic', {
734
+ edges_inferred: envelope.cross_epic_deps.length,
735
+ });
736
+
737
+ process.stdout.write(
738
+ JSON.stringify({
739
+ wrote: true,
740
+ file,
741
+ edges_inferred: envelope.cross_epic_deps.length,
742
+ }) + '\n',
743
+ );
506
744
  return 0;
507
745
  }
508
746
 
@@ -525,25 +763,40 @@ async function runDryRun(projectRoot, epic) {
525
763
  process.stdout.write(JSON.stringify({ valid: false, errors: result.errors }) + '\n');
526
764
  return 1;
527
765
  }
528
- const existing = readExisting(projectRoot);
529
- const merged = mergeDoc(envelope, existing);
530
- const diff = diffCounts(existing.doc, merged);
766
+ const existing = readExistingPlan(projectRoot);
767
+ if (existing.error) {
768
+ process.stdout.write(
769
+ JSON.stringify({
770
+ valid: false,
771
+ errors: [
772
+ {
773
+ code: existing.error,
774
+ field: 'sprint-plan.yaml',
775
+ message: existing.message,
776
+ },
777
+ ],
778
+ }) + '\n',
779
+ );
780
+ return 1;
781
+ }
782
+ const basePlan = existing.plan ?? emptyPlan({ source: 'cli' });
783
+ const merged = applyEnvelopeToPlan(envelope, basePlan, { projectRoot, epic });
784
+ const diff = diffEdges(basePlan, merged);
531
785
  process.stdout.write(
532
- JSON.stringify({ valid: true, errors: [], merged_doc: merged, diff }) + '\n',
786
+ JSON.stringify({ valid: true, errors: [], merged_plan: merged, diff }) + '\n',
533
787
  );
534
788
  return 0;
535
789
  }
536
790
 
537
- async function runWrite(projectRoot, epic, { force }) {
538
- const existing = readExisting(projectRoot);
539
- if (existing.exists && !existing.autoMarker && !force) {
791
+ async function runWrite(projectRoot, epic) {
792
+ const existing = readExistingPlan(projectRoot);
793
+ if (existing.error) {
540
794
  process.stdout.write(
541
795
  JSON.stringify({
542
796
  wrote: false,
543
- reason: 'existing-hand-authored',
544
- message:
545
- 'Existing dependencies.yaml was hand-authored (no AUTO-INFERRED marker). Re-run with --force to overwrite, or delete it first.',
546
- file: dependenciesPath(projectRoot),
797
+ reason: existing.error,
798
+ message: existing.message,
799
+ file: planPath(projectRoot),
547
800
  }) + '\n',
548
801
  );
549
802
  return 2;
@@ -568,14 +821,11 @@ async function runWrite(projectRoot, epic, { force }) {
568
821
  return 1;
569
822
  }
570
823
 
571
- const merged = mergeDoc(envelope, existing);
572
- const hash = contentHash(merged);
573
- const body = renderYaml(merged, hash);
574
- const file = dependenciesPath(projectRoot);
575
- atomicWrite(file, body);
824
+ const basePlan = existing.plan ?? emptyPlan({ source: 'cli' });
825
+ const merged = applyEnvelopeToPlan(envelope, basePlan, { projectRoot, epic });
826
+ const file = writePlan(merged, { projectRoot });
576
827
 
577
- const diff = diffCounts(existing.doc, merged);
578
- const overridesPreserved = (existing.doc?.overrides?.length ?? 0) > 0;
828
+ const diff = diffEdges(basePlan, merged);
579
829
  const edgesInferred = Object.values(envelope.dependencies).reduce((n, arr) => n + arr.length, 0);
580
830
 
581
831
  emitTimingEvent(projectRoot, 'planning.infer-dependencies', {
@@ -583,7 +833,6 @@ async function runWrite(projectRoot, epic, { force }) {
583
833
  edges_inferred: edgesInferred,
584
834
  edges_added: diff.added,
585
835
  edges_removed: diff.removed,
586
- hash,
587
836
  });
588
837
 
589
838
  process.stdout.write(
@@ -593,8 +842,188 @@ async function runWrite(projectRoot, epic, { force }) {
593
842
  edges_inferred: edgesInferred,
594
843
  edges_added: diff.added,
595
844
  edges_removed: diff.removed,
596
- user_overrides_preserved: overridesPreserved,
597
- hash,
845
+ }) + '\n',
846
+ );
847
+ return 0;
848
+ }
849
+
850
+ // ---------------------------------------------------------------
851
+ // Migration from legacy `_Sprintpilot/sprints/dependencies.yaml`
852
+ // ---------------------------------------------------------------
853
+
854
+ // Read the old dependencies.yaml format. Returns parsed doc or null on
855
+ // missing/corrupt. Old format is standard YAML (js-yaml handles it).
856
+ //
857
+ // v2.3.0 — also enforces schema version. The legacy format we know how
858
+ // to migrate is `version: 1` (or missing `version` key, treated as 1
859
+ // per the legacy convention). A `version: 2` or other unknown value
860
+ // is rejected: silently merging a future schema would lose fields
861
+ // without warning. Caller surfaces the rejection as a clean error.
862
+ function readLegacyDependencies(projectRoot) {
863
+ const file = dependenciesPath(projectRoot);
864
+ if (!fs.existsSync(file)) return { exists: false };
865
+ let raw;
866
+ try {
867
+ raw = fs.readFileSync(file, 'utf8');
868
+ } catch (e) {
869
+ return { exists: true, error: 'read_failed', message: e.message, file };
870
+ }
871
+ let doc;
872
+ try {
873
+ doc = yaml.load(raw);
874
+ } catch (e) {
875
+ return { exists: true, error: 'parse_error', message: e.message, file };
876
+ }
877
+ // Schema-version gate. `null` / `undefined` is treated as 1 (the legacy
878
+ // default before the field was added). Numeric or string `1` is accepted.
879
+ // Anything else is rejected.
880
+ if (doc && typeof doc === 'object' && !Array.isArray(doc)) {
881
+ const v = doc.version;
882
+ const isLegacyV1 = v === undefined || v === null || v === 1 || v === '1';
883
+ if (!isLegacyV1) {
884
+ return {
885
+ exists: true,
886
+ error: 'unsupported_legacy_version',
887
+ message:
888
+ `_Sprintpilot/sprints/dependencies.yaml has version=${JSON.stringify(v)} which this ` +
889
+ `Sprintpilot release does not know how to migrate (expected version=1). ` +
890
+ `Archive the file manually to .archive/ and rebuild via /sprintpilot-plan-sprint.`,
891
+ file,
892
+ legacy_version: v,
893
+ };
894
+ }
895
+ }
896
+ return { exists: true, doc, raw, file };
897
+ }
898
+
899
+ // Merge legacy doc into a plan. Replaces plan.dependencies.stories entirely
900
+ // with the legacy stories (no per-epic scoping — migration is all-or-nothing),
901
+ // appends legacy overrides to plan.overrides (deduped by epic), and stamps
902
+ // auto_inferred_at. The legacy `epics:` block is dropped — its
903
+ // `independent: true` semantic doesn't have a v2.3.0 equivalent (it
904
+ // belongs to v2.4.0's parallel-execution layer).
905
+ function mergeLegacyIntoPlan(legacyDoc, plan) {
906
+ const next = {
907
+ ...plan,
908
+ dependencies: {
909
+ ...(plan.dependencies || {}),
910
+ version: legacyDoc.version || 1,
911
+ stories: { ...(legacyDoc.stories || {}) },
912
+ auto_inferred_at: new Date().toISOString(),
913
+ },
914
+ };
915
+
916
+ // overrides: append legacy entries, dedupe by epic field if present.
917
+ const existingOverrides = Array.isArray(plan.overrides) ? plan.overrides.slice() : [];
918
+ const legacyOverrides = Array.isArray(legacyDoc.overrides) ? legacyDoc.overrides : [];
919
+ const seenEpics = new Set();
920
+ for (const ov of existingOverrides) {
921
+ if (ov && typeof ov.epic === 'string') seenEpics.add(ov.epic);
922
+ }
923
+ for (const ov of legacyOverrides) {
924
+ if (ov && typeof ov.epic === 'string' && seenEpics.has(ov.epic)) continue;
925
+ existingOverrides.push(ov);
926
+ if (ov && typeof ov.epic === 'string') seenEpics.add(ov.epic);
927
+ }
928
+ next.overrides = existingOverrides;
929
+
930
+ return next;
931
+ }
932
+
933
+ // Archive legacy file to .archive/dependencies.yaml.migrated (project-root
934
+ // relative). Existing archives are not overwritten — appends a suffix.
935
+ function archiveLegacyFile(legacyFile, projectRoot) {
936
+ const archiveDir = path.join(projectRoot, '.archive');
937
+ fs.mkdirSync(archiveDir, { recursive: true });
938
+ let archivePath = path.join(archiveDir, 'dependencies.yaml.migrated');
939
+ let counter = 1;
940
+ while (fs.existsSync(archivePath)) {
941
+ archivePath = path.join(archiveDir, `dependencies.yaml.migrated.${counter}`);
942
+ counter += 1;
943
+ }
944
+ fs.renameSync(legacyFile, archivePath);
945
+ return archivePath;
946
+ }
947
+
948
+ async function runMigrate(projectRoot) {
949
+ const legacy = readLegacyDependencies(projectRoot);
950
+
951
+ if (!legacy.exists) {
952
+ process.stdout.write(JSON.stringify({ migrated: false, reason: 'no_legacy_file' }) + '\n');
953
+ return 0;
954
+ }
955
+ if (legacy.error) {
956
+ process.stdout.write(
957
+ JSON.stringify({
958
+ migrated: false,
959
+ reason: legacy.error,
960
+ message: legacy.message,
961
+ file: legacy.file,
962
+ }) + '\n',
963
+ );
964
+ return 1;
965
+ }
966
+
967
+ // Validate legacy doc shape — at minimum it should be an object.
968
+ if (!legacy.doc || typeof legacy.doc !== 'object' || Array.isArray(legacy.doc)) {
969
+ process.stdout.write(
970
+ JSON.stringify({
971
+ migrated: false,
972
+ reason: 'invalid_legacy_shape',
973
+ message: 'legacy dependencies.yaml is not a YAML mapping',
974
+ file: legacy.file,
975
+ }) + '\n',
976
+ );
977
+ return 1;
978
+ }
979
+
980
+ // Read or bootstrap sprint-plan.yaml.
981
+ const existing = readExistingPlan(projectRoot);
982
+ if (existing.error) {
983
+ process.stdout.write(
984
+ JSON.stringify({
985
+ migrated: false,
986
+ reason: existing.error,
987
+ message: existing.message,
988
+ }) + '\n',
989
+ );
990
+ return 1;
991
+ }
992
+ const basePlan = existing.plan ?? emptyPlan({ source: 'migrated' });
993
+
994
+ // Merge + write.
995
+ const nextPlan = mergeLegacyIntoPlan(legacy.doc, basePlan);
996
+ const planFile = writePlan(nextPlan, { projectRoot });
997
+
998
+ // Archive the legacy file. Done LAST so a write-plan failure leaves the
999
+ // legacy file in place for re-tries.
1000
+ const archivedPath = archiveLegacyFile(legacy.file, projectRoot);
1001
+
1002
+ const storyCount = Object.keys(legacy.doc.stories || {}).length;
1003
+ const overrideCount = Array.isArray(legacy.doc.overrides) ? legacy.doc.overrides.length : 0;
1004
+ const droppedEpicsBlock =
1005
+ legacy.doc.epics && typeof legacy.doc.epics === 'object' && Object.keys(legacy.doc.epics).length > 0;
1006
+
1007
+ emitTimingEvent(projectRoot, 'planning.migrate-dependencies', {
1008
+ stories_imported: storyCount,
1009
+ overrides_imported: overrideCount,
1010
+ epics_block_dropped: !!droppedEpicsBlock,
1011
+ });
1012
+
1013
+ process.stdout.write(
1014
+ JSON.stringify({
1015
+ migrated: true,
1016
+ file: planFile,
1017
+ archived: archivedPath,
1018
+ stories_imported: storyCount,
1019
+ overrides_imported: overrideCount,
1020
+ epics_block_dropped: !!droppedEpicsBlock,
1021
+ ...(droppedEpicsBlock
1022
+ ? {
1023
+ warning:
1024
+ 'Legacy epics: block dropped — its `independent: true` semantic has no v2.3.0 equivalent. Re-configure via v2.4.0 mechanisms when available.',
1025
+ }
1026
+ : {}),
598
1027
  }) + '\n',
599
1028
  );
600
1029
  return 0;
@@ -605,7 +1034,9 @@ async function runWrite(projectRoot, epic, { force }) {
605
1034
  // ---------------------------------------------------------------
606
1035
 
607
1036
  async function main() {
608
- const { opts, positional } = parseArgs(process.argv.slice(2), { booleanFlags: ['force'] });
1037
+ const { opts, positional } = parseArgs(process.argv.slice(2), {
1038
+ booleanFlags: ['cross-epic'],
1039
+ });
609
1040
  if (opts.help || positional.length === 0) {
610
1041
  help();
611
1042
  process.exit(opts.help ? 0 : 1);
@@ -617,16 +1048,34 @@ async function main() {
617
1048
  }
618
1049
  const projectRoot = opts['project-root'] || process.cwd();
619
1050
  const epic = opts.epic !== undefined ? String(opts.epic) : null;
620
- if (!epic) {
621
- log.error(`${command} requires --epic`);
1051
+ const crossEpic = opts['cross-epic'] === true;
1052
+
1053
+ // `migrate` and `write-cross-epic` operate sprint-wide; --cross-epic mode
1054
+ // for scaffold-prompt/dry-run also doesn't need --epic. Others require it.
1055
+ const needsEpic =
1056
+ !(command === 'migrate' || command === 'write-cross-epic' || crossEpic);
1057
+ if (needsEpic && !epic) {
1058
+ log.error(`${command} requires --epic (or --cross-epic for sprint-wide mode)`);
1059
+ process.exit(1);
1060
+ }
1061
+ // --cross-epic isn't valid for `write` or `migrate`.
1062
+ if (crossEpic && (command === 'write' || command === 'migrate' || command === 'write-cross-epic')) {
1063
+ log.error(`--cross-epic is not valid for ${command}`);
622
1064
  process.exit(1);
623
1065
  }
624
1066
 
625
1067
  try {
626
- if (command === 'scaffold-prompt') process.exit(await runScaffoldPrompt(projectRoot, epic));
627
- if (command === 'dry-run') process.exit(await runDryRun(projectRoot, epic));
628
- if (command === 'write')
629
- process.exit(await runWrite(projectRoot, epic, { force: opts.force === true }));
1068
+ if (command === 'scaffold-prompt') {
1069
+ if (crossEpic) process.exit(await runScaffoldPromptCrossEpic(projectRoot));
1070
+ else process.exit(await runScaffoldPrompt(projectRoot, epic));
1071
+ }
1072
+ if (command === 'dry-run') {
1073
+ if (crossEpic) process.exit(await runDryRunCrossEpic(projectRoot));
1074
+ else process.exit(await runDryRun(projectRoot, epic));
1075
+ }
1076
+ if (command === 'write') process.exit(await runWrite(projectRoot, epic));
1077
+ if (command === 'write-cross-epic') process.exit(await runWriteCrossEpic(projectRoot));
1078
+ if (command === 'migrate') process.exit(await runMigrate(projectRoot));
630
1079
  } catch (e) {
631
1080
  log.error(`unexpected error: ${e.stack || e.message}`);
632
1081
  process.exit(1);
@@ -634,16 +1083,19 @@ async function main() {
634
1083
  }
635
1084
 
636
1085
  module.exports = {
637
- AUTO_MARKER,
638
1086
  VALID_COMMANDS,
1087
+ CROSS_EPIC_RATIONALE_MAX,
639
1088
  scaffoldPrompt,
1089
+ scaffoldCrossEpicPrompt,
640
1090
  validateEnvelope,
641
- readExisting,
642
- mergeDoc,
643
- contentHash,
644
- renderYaml,
645
- inlineScalar,
646
- diffCounts,
1091
+ validateCrossEpicEnvelope,
1092
+ applyCrossEpicToPlan,
1093
+ readExistingPlan,
1094
+ applyEnvelopeToPlan,
1095
+ diffEdges,
1096
+ readLegacyDependencies,
1097
+ mergeLegacyIntoPlan,
1098
+ archiveLegacyFile,
647
1099
  };
648
1100
 
649
1101
  if (require.main === module) {