@ikunin/sprintpilot 2.2.30 → 2.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +232 -413
- package/_Sprintpilot/Sprintpilot.md +76 -6
- package/_Sprintpilot/bin/autopilot.js +752 -66
- package/_Sprintpilot/lib/orchestrator/action-ledger.js +208 -0
- package/_Sprintpilot/lib/orchestrator/adapt.js +93 -15
- package/_Sprintpilot/lib/orchestrator/profile-rules.js +7 -16
- package/_Sprintpilot/lib/orchestrator/sprint-plan.js +488 -0
- package/_Sprintpilot/lib/orchestrator/state-store.js +9 -5
- package/_Sprintpilot/lib/orchestrator/user-command-applier.js +107 -0
- package/_Sprintpilot/lib/orchestrator/user-commands.js +124 -1
- package/_Sprintpilot/lib/orchestrator/verify.js +10 -17
- package/_Sprintpilot/manifest.yaml +4 -1
- package/_Sprintpilot/modules/autopilot/profiles/_base.yaml +18 -4
- package/_Sprintpilot/modules/git/config.yaml +15 -9
- package/_Sprintpilot/modules/ma/config.yaml +29 -27
- package/_Sprintpilot/scripts/dispatch-layer.js +12 -15
- package/_Sprintpilot/scripts/infer-dependencies.js +706 -254
- package/_Sprintpilot/scripts/log-timing.js +6 -10
- package/_Sprintpilot/scripts/merge-shards.js +21 -23
- package/_Sprintpilot/scripts/post-green-gates.js +3 -1
- package/_Sprintpilot/scripts/resolve-dag.js +452 -280
- package/_Sprintpilot/scripts/sprint-plan.js +1068 -0
- package/_Sprintpilot/scripts/state-shard.js +13 -5
- package/_Sprintpilot/scripts/summarize-timings.js +2 -3
- package/_Sprintpilot/skills/sprint-autopilot-on/SKILL.md +30 -2
- package/_Sprintpilot/skills/sprint-autopilot-on/workflow.orchestrator.md +36 -10
- package/_Sprintpilot/skills/sprintpilot-dependency-graph/SKILL.md +63 -0
- package/_Sprintpilot/skills/sprintpilot-dependency-graph/workflow.md +227 -0
- package/_Sprintpilot/skills/sprintpilot-plan-sprint/SKILL.md +67 -0
- package/_Sprintpilot/skills/sprintpilot-plan-sprint/workflow.md +435 -0
- package/_Sprintpilot/skills/sprintpilot-sprint-progress/SKILL.md +53 -0
- package/_Sprintpilot/skills/sprintpilot-sprint-progress/workflow.md +169 -0
- package/lib/commands/install.js +186 -10
- package/package.json +1 -1
|
@@ -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
|
|
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.
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
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>]
|
|
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,
|
|
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
|
-
//
|
|
31
|
-
// validation failure, 2
|
|
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
|
|
71
|
-
|
|
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
|
|
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
|
|
83
|
-
'is the LLM caller — this script never calls a
|
|
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
|
|
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. ${
|
|
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
|
|
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
|
-
//
|
|
542
|
+
// Plan read + envelope application
|
|
271
543
|
// ---------------------------------------------------------------
|
|
272
544
|
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
const
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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,
|
|
561
|
+
return { exists: true, plan: result };
|
|
286
562
|
}
|
|
287
563
|
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
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
|
-
//
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
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
|
-
|
|
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
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
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
|
|
494
|
-
|
|
495
|
-
|
|
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
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
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
|
|
505
|
-
|
|
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 =
|
|
529
|
-
|
|
530
|
-
|
|
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: [],
|
|
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
|
|
538
|
-
const existing =
|
|
539
|
-
if (existing.
|
|
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:
|
|
544
|
-
message:
|
|
545
|
-
|
|
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
|
|
572
|
-
const
|
|
573
|
-
const
|
|
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 =
|
|
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
|
-
|
|
597
|
-
|
|
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), {
|
|
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
|
-
|
|
621
|
-
|
|
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')
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
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
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
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) {
|