@ikunin/sprintpilot 2.2.31 → 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 +734 -68
- 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 +78 -0
- package/_Sprintpilot/lib/orchestrator/user-commands.js +114 -0
- 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
|
@@ -2,19 +2,27 @@
|
|
|
2
2
|
|
|
3
3
|
// resolve-dag.js — build the story execution DAG for a sprint.
|
|
4
4
|
//
|
|
5
|
+
// As of v2.3.0 the authoritative dependency source is sprint-plan.yaml
|
|
6
|
+
// (managed by sprint-plan.js). The legacy `_Sprintpilot/sprints/dependencies.yaml`
|
|
7
|
+
// file is no longer read — users with that file must run
|
|
8
|
+
// `infer-dependencies.js migrate` to copy its content into the new plan.
|
|
9
|
+
//
|
|
5
10
|
// Usage:
|
|
6
11
|
// resolve-dag.js graph [--epic <id>] [--project-root <path>] [--strategy <list>]
|
|
7
12
|
// resolve-dag.js layers [--epic <id>] [--project-root <path>] [--strategy <list>]
|
|
8
13
|
// resolve-dag.js width [--epic <id>] [--project-root <path>] [--strategy <list>]
|
|
9
|
-
// resolve-dag.js scaffold --epic <id> [--project-root <path>] [--force]
|
|
10
14
|
//
|
|
11
15
|
// Strategies (default order: explicit,ordering):
|
|
12
|
-
// explicit — read
|
|
16
|
+
// explicit — read sprint-plan.yaml (dependencies, cross_epic_deps, overrides)
|
|
13
17
|
// ordering — linear chain from sprint-status.yaml order (safe default)
|
|
14
|
-
// files — (TODO
|
|
18
|
+
// files — (TODO) infer edges from shared file-path touches
|
|
15
19
|
//
|
|
16
20
|
// Conflict resolution when multiple strategies contribute: explicit > files > ordering.
|
|
17
|
-
// Missing
|
|
21
|
+
// Missing sprint-plan.yaml is fine; we fall back to the next strategy.
|
|
22
|
+
//
|
|
23
|
+
// Cross-epic edges from `plan.cross_epic_deps` are included only when the
|
|
24
|
+
// command runs sprint-wide (no `--epic`). With `--epic <id>` we filter out
|
|
25
|
+
// any edge whose `from_story` or `to_story` does not belong to <id>.
|
|
18
26
|
//
|
|
19
27
|
// Output:
|
|
20
28
|
// graph { "nodes": [...], "edges": [ ["a","b"], ... ], "epic": "1" }
|
|
@@ -26,16 +34,28 @@
|
|
|
26
34
|
|
|
27
35
|
const fs = require('node:fs');
|
|
28
36
|
const path = require('node:path');
|
|
37
|
+
const { spawnSync } = require('node:child_process');
|
|
29
38
|
|
|
30
39
|
const { parseArgs } = require('../lib/runtime/args');
|
|
31
40
|
const log = require('../lib/runtime/log');
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
const { yamlLoad, yamlDump } = shardMod;
|
|
41
|
+
const sprintPlanMod = require('./sprint-plan.js');
|
|
35
42
|
|
|
36
43
|
const DEFAULT_STRATEGIES = ['explicit', 'ordering'];
|
|
37
44
|
const VALID_STRATEGIES = ['explicit', 'ordering', 'files'];
|
|
38
|
-
const VALID_COMMANDS = ['graph', 'layers', 'width', '
|
|
45
|
+
const VALID_COMMANDS = ['graph', 'layers', 'width', 'render'];
|
|
46
|
+
const VALID_RENDER_FORMATS = ['mermaid', 'graphviz'];
|
|
47
|
+
|
|
48
|
+
const { read: readPlan } = sprintPlanMod;
|
|
49
|
+
|
|
50
|
+
// Mermaid + graphviz color palette per plan_status. Greens/grays/yellows
|
|
51
|
+
// chosen for grayscale legibility and adequate color-blind contrast; tests
|
|
52
|
+
// also assert the hex values so changes here are intentional.
|
|
53
|
+
const STATUS_COLORS = {
|
|
54
|
+
pending: { fill: '#7dd87d', text: '#000' },
|
|
55
|
+
done: { fill: '#888888', text: '#ffffff' },
|
|
56
|
+
skipped: { fill: '#e8e864', text: '#000' },
|
|
57
|
+
excluded: { fill: '#444444', text: '#aaa' },
|
|
58
|
+
};
|
|
39
59
|
|
|
40
60
|
function help() {
|
|
41
61
|
log.out(
|
|
@@ -44,9 +64,14 @@ function help() {
|
|
|
44
64
|
' resolve-dag.js graph [--epic <id>] [--strategy explicit,ordering]',
|
|
45
65
|
' resolve-dag.js layers [--epic <id>] [--strategy explicit,ordering]',
|
|
46
66
|
' resolve-dag.js width [--epic <id>] [--strategy explicit,ordering]',
|
|
47
|
-
' resolve-dag.js
|
|
67
|
+
' resolve-dag.js render [--format mermaid|graphviz] [--output <path>] [--epic <id>]',
|
|
48
68
|
'',
|
|
49
|
-
'Strategies: explicit | ordering | files (opt-in)',
|
|
69
|
+
'Strategies: explicit | ordering | files (opt-in, TODO)',
|
|
70
|
+
'Render formats: mermaid (default, GitHub-renderable) | graphviz (requires `dot`)',
|
|
71
|
+
'Default output: _bmad-output/implementation-artifacts/sprint-plan-dag.{mmd,dot}',
|
|
72
|
+
'',
|
|
73
|
+
'Reads sprint-plan.yaml for explicit dependencies. Use',
|
|
74
|
+
'`infer-dependencies.js migrate` if you have a legacy dependencies.yaml.',
|
|
50
75
|
].join('\n'),
|
|
51
76
|
);
|
|
52
77
|
}
|
|
@@ -59,6 +84,8 @@ function sprintStatusPath(projectRoot) {
|
|
|
59
84
|
return path.join(projectRoot, '_bmad-output', 'implementation-artifacts', 'sprint-status.yaml');
|
|
60
85
|
}
|
|
61
86
|
|
|
87
|
+
// Legacy path — retained for the one-shot `infer-dependencies.js migrate`
|
|
88
|
+
// flow. Reads MUST NOT route here; resolve-dag goes through sprint-plan.yaml.
|
|
62
89
|
function dependenciesPath(projectRoot) {
|
|
63
90
|
return path.join(projectRoot, '_Sprintpilot', 'sprints', 'dependencies.yaml');
|
|
64
91
|
}
|
|
@@ -68,11 +95,6 @@ function parseEpicFromKey(storyKey) {
|
|
|
68
95
|
// canonical convention is `<epic-num>-<story-num>-<slug>` (e.g.
|
|
69
96
|
// `1-2-user-auth` → epic "1"), but nothing prevents a project from
|
|
70
97
|
// using non-numeric epic identifiers (`auth-1-login`, `infra-bootstrap`).
|
|
71
|
-
// Pre-2.0.8 this function rejected any non-numeric prefix and returned
|
|
72
|
-
// null, which silently dropped stories from `--epic` filtering AND let
|
|
73
|
-
// `infer-dependencies.js` cross-epic edge guards bypass for keys with
|
|
74
|
-
// no numeric prefix. We now accept any non-empty alphanumeric leading
|
|
75
|
-
// segment.
|
|
76
98
|
const s = String(storyKey);
|
|
77
99
|
if (!s) return null;
|
|
78
100
|
const m = s.match(/^([A-Za-z0-9]+)(?:-|$)/);
|
|
@@ -87,12 +109,6 @@ function readStoriesFromStatus(projectRoot, epicFilter) {
|
|
|
87
109
|
// `development_status:` (BMad's canonical shape) and under `stories:`
|
|
88
110
|
// (alternate shape some projects use). We intentionally don't parse the
|
|
89
111
|
// whole YAML — sprint-status is BMad-owned and its schema varies.
|
|
90
|
-
//
|
|
91
|
-
// Pre-2.0.8 this hardcoded a 2-space indent. A 4-space or tab-indented
|
|
92
|
-
// file silently produced zero stories → empty layer → dispatch never
|
|
93
|
-
// engaged, no warning. Now we detect the FIRST key's indent inside
|
|
94
|
-
// each stories block and accept only that level (so nested per-story
|
|
95
|
-
// fields at deeper indents are still correctly excluded).
|
|
96
112
|
const ordered = [];
|
|
97
113
|
const byKey = {};
|
|
98
114
|
const lines = raw.split(/\r?\n/);
|
|
@@ -128,195 +144,26 @@ function readStoriesFromStatus(projectRoot, epicFilter) {
|
|
|
128
144
|
return { ordered, byKey };
|
|
129
145
|
}
|
|
130
146
|
|
|
147
|
+
// Read the dependencies section of sprint-plan.yaml. Returns a "depsDoc"
|
|
148
|
+
// shape compatible with edgesFromExplicit / applyForceIndependent:
|
|
149
|
+
// { stories: { <key>: { depends_on, rationale } }, overrides, cross_epic_deps }
|
|
150
|
+
// Returns null if no plan exists OR the plan is corrupt. The strategy
|
|
151
|
+
// layer treats null as "no explicit edges" and falls through to ordering.
|
|
131
152
|
function readDependencies(projectRoot) {
|
|
132
|
-
const
|
|
133
|
-
if (
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
return parseDependenciesYaml(raw);
|
|
137
|
-
} catch (e) {
|
|
138
|
-
log.warn(`failed to parse ${file}: ${e.message}`);
|
|
153
|
+
const result = readPlan({ projectRoot });
|
|
154
|
+
if (result === null) return null;
|
|
155
|
+
if (result && typeof result === 'object' && 'error' in result) {
|
|
156
|
+
log.warn(`sprint-plan.yaml unreadable (${result.error}): ${result.message}`);
|
|
139
157
|
return null;
|
|
140
158
|
}
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
//
|
|
149
|
-
// Design:
|
|
150
|
-
// One stack frame per "open container" (root + any parent whose
|
|
151
|
-
// pendingKey value is an in-progress object or list). pendingKey names
|
|
152
|
-
// the last key assigned in this container.
|
|
153
|
-
//
|
|
154
|
-
// On a deeper-indent line, if top.pendingKey is set AND points at an
|
|
155
|
-
// object/list that hasn't been "closed" yet, we descend into it by
|
|
156
|
-
// pushing a new frame whose container is top.container[pendingKey].
|
|
157
|
-
//
|
|
158
|
-
// List items attach to the current frame's pendingKey: promote the
|
|
159
|
-
// container[pendingKey] from {} to [] on first list item.
|
|
160
|
-
function parseDependenciesYaml(text) {
|
|
161
|
-
const lines = text.split(/\r?\n/);
|
|
162
|
-
const root = {};
|
|
163
|
-
const stack = [{ indent: -1, container: root, pendingKey: null, pendingKeyIndent: -1 }];
|
|
164
|
-
|
|
165
|
-
const parseScalar = (raw) => {
|
|
166
|
-
if (raw === '' || raw === 'null' || raw === '~') return null;
|
|
167
|
-
if (raw === 'true') return true;
|
|
168
|
-
if (raw === 'false') return false;
|
|
169
|
-
if (raw === '[]') return [];
|
|
170
|
-
if (raw === '{}') return {};
|
|
171
|
-
if (raw.startsWith('[') || raw.startsWith('{')) {
|
|
172
|
-
try {
|
|
173
|
-
return JSON.parse(raw);
|
|
174
|
-
} catch {
|
|
175
|
-
return raw;
|
|
176
|
-
}
|
|
177
|
-
}
|
|
178
|
-
if ((raw.startsWith('"') && raw.endsWith('"')) || (raw.startsWith("'") && raw.endsWith("'"))) {
|
|
179
|
-
try {
|
|
180
|
-
return raw.startsWith('"') ? JSON.parse(raw) : raw.slice(1, -1);
|
|
181
|
-
} catch {
|
|
182
|
-
return raw.slice(1, -1);
|
|
183
|
-
}
|
|
184
|
-
}
|
|
185
|
-
if (/^-?\d+$/.test(raw)) return Number.parseInt(raw, 10);
|
|
186
|
-
if (/^-?\d+\.\d+$/.test(raw)) return Number.parseFloat(raw);
|
|
187
|
-
return raw;
|
|
188
|
-
};
|
|
189
|
-
|
|
190
|
-
const descendIfDeeper = (indent) => {
|
|
191
|
-
// Descend into top.pendingKey's value iff this line is strictly deeper
|
|
192
|
-
// than the line that assigned the pendingKey. Using top.indent here is
|
|
193
|
-
// wrong — a freshly-popped sibling at the same logical depth would be
|
|
194
|
-
// incorrectly absorbed as a child. pendingKeyIndent tracks where the
|
|
195
|
-
// pendingKey was assigned; only indents past that are true descendants.
|
|
196
|
-
const top = stack[stack.length - 1];
|
|
197
|
-
if (top.pendingKey === null) return;
|
|
198
|
-
if (indent <= top.pendingKeyIndent) return;
|
|
199
|
-
const child = top.container[top.pendingKey];
|
|
200
|
-
if (!child || typeof child !== 'object') return;
|
|
201
|
-
stack.push({ indent, container: child, pendingKey: null, pendingKeyIndent: -1 });
|
|
159
|
+
return {
|
|
160
|
+
stories:
|
|
161
|
+
result.dependencies && typeof result.dependencies.stories === 'object'
|
|
162
|
+
? result.dependencies.stories
|
|
163
|
+
: {},
|
|
164
|
+
overrides: Array.isArray(result.overrides) ? result.overrides : [],
|
|
165
|
+
cross_epic_deps: Array.isArray(result.cross_epic_deps) ? result.cross_epic_deps : [],
|
|
202
166
|
};
|
|
203
|
-
|
|
204
|
-
for (const rawLine of lines) {
|
|
205
|
-
const hashIdx = rawLine.indexOf('#');
|
|
206
|
-
let line = rawLine;
|
|
207
|
-
if (hashIdx !== -1) {
|
|
208
|
-
if (hashIdx === 0 || /\s/.test(rawLine[hashIdx - 1])) line = rawLine.slice(0, hashIdx);
|
|
209
|
-
}
|
|
210
|
-
const trimRight = line.replace(/\s+$/, '');
|
|
211
|
-
if (!trimRight.trim()) continue;
|
|
212
|
-
const indent = (trimRight.match(/^( *)/) || ['', ''])[1].length;
|
|
213
|
-
const rest = trimRight.slice(indent);
|
|
214
|
-
|
|
215
|
-
// Pop frames we've outdented past. List-item frames (fromListItem) are
|
|
216
|
-
// kept while `indent == top.indent` — the list-item's inline key and
|
|
217
|
-
// any sibling keys share the same indent and all belong to that entry.
|
|
218
|
-
while (stack.length > 1) {
|
|
219
|
-
const t = stack[stack.length - 1];
|
|
220
|
-
const strict = t.fromListItem ? t.indent > indent : t.indent >= indent;
|
|
221
|
-
if (!strict) break;
|
|
222
|
-
stack.pop();
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
if (rest.startsWith('- ') || rest === '-') {
|
|
226
|
-
// List item attaches to current frame's pendingKey.
|
|
227
|
-
const owner = stack[stack.length - 1];
|
|
228
|
-
const key = owner.pendingKey;
|
|
229
|
-
if (!key) continue; // malformed — list item with no owner key
|
|
230
|
-
if (!Array.isArray(owner.container[key])) owner.container[key] = [];
|
|
231
|
-
const arr = owner.container[key];
|
|
232
|
-
const content = rest === '-' ? '' : rest.slice(2).trim();
|
|
233
|
-
const colon = findTopLevelColon(content);
|
|
234
|
-
if (content === '') {
|
|
235
|
-
arr.push(null);
|
|
236
|
-
} else if (colon === -1) {
|
|
237
|
-
arr.push(parseScalar(content));
|
|
238
|
-
} else {
|
|
239
|
-
// Inline mapping: "- k: v" or "- k:" starts a new object item.
|
|
240
|
-
const k = unquoteKey(content.slice(0, colon).trim());
|
|
241
|
-
const v = content.slice(colon + 1).trim();
|
|
242
|
-
const item = {};
|
|
243
|
-
arr.push(item);
|
|
244
|
-
if (v === '' || v === '~') {
|
|
245
|
-
item[k] = {};
|
|
246
|
-
} else {
|
|
247
|
-
item[k] = parseScalar(v);
|
|
248
|
-
}
|
|
249
|
-
// Subsequent deeper-indent lines that describe this item start at
|
|
250
|
-
// indent + 2 (after "- "). Push a frame at indent + 2 whose
|
|
251
|
-
// container is the new item, with pendingKey = k.
|
|
252
|
-
// `fromListItem` tells the pop rule that sibling keys at the same
|
|
253
|
-
// indent are continuations of this list entry, not outdent siblings.
|
|
254
|
-
stack.push({
|
|
255
|
-
indent: indent + 2,
|
|
256
|
-
container: item,
|
|
257
|
-
pendingKey: k,
|
|
258
|
-
pendingKeyIndent: indent + 2,
|
|
259
|
-
fromListItem: true,
|
|
260
|
-
});
|
|
261
|
-
}
|
|
262
|
-
continue;
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
// Plain `key: value` line. First descend if we're in a deeper block
|
|
266
|
-
// than the top frame and top has a pendingKey container.
|
|
267
|
-
descendIfDeeper(indent);
|
|
268
|
-
const top = stack[stack.length - 1];
|
|
269
|
-
const colon = findTopLevelColon(rest);
|
|
270
|
-
if (colon === -1) continue;
|
|
271
|
-
const key = unquoteKey(rest.slice(0, colon).trim());
|
|
272
|
-
const value = rest.slice(colon + 1).trim();
|
|
273
|
-
if (value === '' || value === '~') {
|
|
274
|
-
top.container[key] = {};
|
|
275
|
-
top.pendingKey = key;
|
|
276
|
-
top.pendingKeyIndent = indent;
|
|
277
|
-
} else if (value === '[]') {
|
|
278
|
-
top.container[key] = [];
|
|
279
|
-
top.pendingKey = key;
|
|
280
|
-
top.pendingKeyIndent = indent;
|
|
281
|
-
} else {
|
|
282
|
-
top.container[key] = parseScalar(value);
|
|
283
|
-
top.pendingKey = key;
|
|
284
|
-
top.pendingKeyIndent = indent;
|
|
285
|
-
}
|
|
286
|
-
}
|
|
287
|
-
return root;
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
function unquoteKey(k) {
|
|
291
|
-
if ((k.startsWith('"') && k.endsWith('"')) || (k.startsWith("'") && k.endsWith("'"))) {
|
|
292
|
-
try {
|
|
293
|
-
return k.startsWith('"') ? JSON.parse(k) : k.slice(1, -1);
|
|
294
|
-
} catch {
|
|
295
|
-
return k.slice(1, -1);
|
|
296
|
-
}
|
|
297
|
-
}
|
|
298
|
-
return k;
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
function findTopLevelColon(s) {
|
|
302
|
-
let quote = null;
|
|
303
|
-
for (let i = 0; i < s.length; i++) {
|
|
304
|
-
const c = s[i];
|
|
305
|
-
if (quote) {
|
|
306
|
-
if (c === '\\') {
|
|
307
|
-
i++;
|
|
308
|
-
continue;
|
|
309
|
-
}
|
|
310
|
-
if (c === quote) quote = null;
|
|
311
|
-
continue;
|
|
312
|
-
}
|
|
313
|
-
if (c === '"' || c === "'") {
|
|
314
|
-
quote = c;
|
|
315
|
-
continue;
|
|
316
|
-
}
|
|
317
|
-
if (c === ':') return i;
|
|
318
|
-
}
|
|
319
|
-
return -1;
|
|
320
167
|
}
|
|
321
168
|
|
|
322
169
|
// ------------------------------------------------------------------
|
|
@@ -343,10 +190,6 @@ function edgesFromExplicit(depsDoc, nodes) {
|
|
|
343
190
|
for (const ov of depsDoc.overrides) {
|
|
344
191
|
if (!ov) continue;
|
|
345
192
|
if (Array.isArray(ov.force_sequential)) {
|
|
346
|
-
// Filter to known nodes AND dedupe — a duplicate listing like
|
|
347
|
-
// `[a, b, a]` would otherwise produce edges `a→b, b→a` (instant
|
|
348
|
-
// self-cycle) that Kahn's would later reject with an opaque
|
|
349
|
-
// "cycle detected" error. Reject the typo at the source instead.
|
|
350
193
|
const seen = new Set();
|
|
351
194
|
const seq = [];
|
|
352
195
|
for (const k of ov.force_sequential) {
|
|
@@ -365,6 +208,25 @@ function edgesFromExplicit(depsDoc, nodes) {
|
|
|
365
208
|
return out;
|
|
366
209
|
}
|
|
367
210
|
|
|
211
|
+
// Cross-epic edges live in plan.cross_epic_deps as { from_story, to_story,
|
|
212
|
+
// rationale, ... }. Convention: from_story → to_story means "from depends
|
|
213
|
+
// on to" (same direction as per-epic depends_on). Edge tuple emitted is
|
|
214
|
+
// `[to, from]` so it flows as "to runs before from" in topo order.
|
|
215
|
+
function edgesFromCrossEpic(depsDoc, nodes) {
|
|
216
|
+
if (!depsDoc || !Array.isArray(depsDoc.cross_epic_deps)) return [];
|
|
217
|
+
const out = [];
|
|
218
|
+
const nodeSet = new Set(nodes);
|
|
219
|
+
for (const edge of depsDoc.cross_epic_deps) {
|
|
220
|
+
if (!edge) continue;
|
|
221
|
+
const from = edge.from_story;
|
|
222
|
+
const to = edge.to_story;
|
|
223
|
+
if (typeof from !== 'string' || typeof to !== 'string') continue;
|
|
224
|
+
if (!nodeSet.has(from) || !nodeSet.has(to)) continue;
|
|
225
|
+
out.push([to, from]);
|
|
226
|
+
}
|
|
227
|
+
return out;
|
|
228
|
+
}
|
|
229
|
+
|
|
368
230
|
function edgesFromOrdering(nodes) {
|
|
369
231
|
const out = [];
|
|
370
232
|
for (let i = 1; i < nodes.length; i++) out.push([nodes[i - 1], nodes[i]]);
|
|
@@ -382,32 +244,31 @@ function applyForceIndependent(edges, depsDoc) {
|
|
|
382
244
|
if (indep.size === 0) return edges;
|
|
383
245
|
// Drop INBOUND edges only — `force_independent: [b]` means "let b run
|
|
384
246
|
// any time, regardless of its declared deps", not "let everything that
|
|
385
|
-
// depends on b also run any time".
|
|
386
|
-
// directions, so a story c with `depends_on: [b]` would lose its edge
|
|
387
|
-
// and become a free root, then dispatch in the same layer as b — the
|
|
388
|
-
// exact merge-conflict scenario the override was supposed to control.
|
|
247
|
+
// depends on b also run any time".
|
|
389
248
|
return edges.filter(([_a, b]) => !indep.has(b));
|
|
390
249
|
}
|
|
391
250
|
|
|
392
|
-
function buildEdges(strategies, nodes, depsDoc) {
|
|
251
|
+
function buildEdges(strategies, nodes, depsDoc, { includeCrossEpic = false } = {}) {
|
|
393
252
|
// explicit > ordering. Dedupe while preserving priority insertion order.
|
|
394
253
|
const seen = new Set();
|
|
395
254
|
const out = [];
|
|
396
255
|
const pushEdges = (edges) => {
|
|
397
256
|
for (const [a, b] of edges) {
|
|
398
|
-
const key = `${a}
|
|
257
|
+
const key = `${a} ${b}`;
|
|
399
258
|
if (seen.has(key)) continue;
|
|
400
259
|
seen.add(key);
|
|
401
260
|
out.push([a, b]);
|
|
402
261
|
}
|
|
403
262
|
};
|
|
404
263
|
for (const strat of strategies) {
|
|
405
|
-
if (strat === 'explicit')
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
264
|
+
if (strat === 'explicit') {
|
|
265
|
+
pushEdges(edgesFromExplicit(depsDoc, nodes));
|
|
266
|
+
if (includeCrossEpic) pushEdges(edgesFromCrossEpic(depsDoc, nodes));
|
|
267
|
+
} else if (strat === 'ordering') {
|
|
268
|
+
pushEdges(edgesFromOrdering(nodes));
|
|
269
|
+
} else if (strat === 'files') {
|
|
270
|
+
// files strategy opt-in, not implemented yet — a future
|
|
271
|
+
// sprintpilot-infer-dependencies skill populates the explicit sidecar.
|
|
411
272
|
}
|
|
412
273
|
}
|
|
413
274
|
// Respect force_independent last so it removes matches from both strategies.
|
|
@@ -473,62 +334,358 @@ function buildDag({ projectRoot, epic, strategies }) {
|
|
|
473
334
|
return { nodes: [], edges: [], layers: [], width: 0, cycle: [], epic };
|
|
474
335
|
}
|
|
475
336
|
const depsDoc = readDependencies(projectRoot);
|
|
476
|
-
|
|
337
|
+
// Cross-epic edges flow into the graph only when the caller is looking at
|
|
338
|
+
// the whole sprint. Per-epic queries see only intra-epic edges.
|
|
339
|
+
const includeCrossEpic = epic === null;
|
|
340
|
+
const edges = buildEdges(strategies, ordered, depsDoc, { includeCrossEpic });
|
|
477
341
|
const { layers, cycle } = topoLayers(ordered, edges);
|
|
478
342
|
const width = layers.reduce((m, l) => Math.max(m, l.length), 0);
|
|
479
343
|
return { nodes: ordered, edges, layers, width, cycle, epic };
|
|
480
344
|
}
|
|
481
345
|
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
346
|
+
// ------------------------------------------------------------------
|
|
347
|
+
// Render
|
|
348
|
+
// ------------------------------------------------------------------
|
|
349
|
+
|
|
350
|
+
// Build a node → plan_status map from plan.stories[]. Stories absent from
|
|
351
|
+
// the plan default to 'pending' so rendering works even on plans the skill
|
|
352
|
+
// hasn't fully populated (Phase 0 — plan.stories is typically []).
|
|
353
|
+
function planStatusByKey(plan) {
|
|
354
|
+
const map = new Map();
|
|
355
|
+
if (plan && Array.isArray(plan.stories)) {
|
|
356
|
+
for (const s of plan.stories) {
|
|
357
|
+
if (s && typeof s.key === 'string') {
|
|
358
|
+
const status = ['pending', 'done', 'skipped', 'excluded'].includes(s.plan_status)
|
|
359
|
+
? s.plan_status
|
|
360
|
+
: 'pending';
|
|
361
|
+
map.set(s.key, status);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
486
364
|
}
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
365
|
+
return map;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// Build a story_key → issue_id map. Returns only entries with non-empty
|
|
369
|
+
// issue_id strings; absent or null values are silently skipped (the
|
|
370
|
+
// renderer uses the story key as-is when no entry exists). Lets the
|
|
371
|
+
// renderer prefix labels with the tracker ID for at-a-glance
|
|
372
|
+
// cross-reference back to Jira / Linear / GitHub / GitLab tickets.
|
|
373
|
+
function issueIdByStoryKey(plan) {
|
|
374
|
+
const map = new Map();
|
|
375
|
+
if (plan && Array.isArray(plan.stories)) {
|
|
376
|
+
for (const s of plan.stories) {
|
|
377
|
+
if (s && typeof s.key === 'string' && typeof s.issue_id === 'string' && s.issue_id) {
|
|
378
|
+
map.set(s.key, s.issue_id);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
return map;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// Build an epic_id → issue_id map for the epic subgraph labels.
|
|
386
|
+
// Epic ids are stored as strings in plan.epics[].id — accept both
|
|
387
|
+
// strings and numbers for robustness against hand-edited plans.
|
|
388
|
+
function issueIdByEpicId(plan) {
|
|
389
|
+
const map = new Map();
|
|
390
|
+
if (plan && Array.isArray(plan.epics)) {
|
|
391
|
+
for (const e of plan.epics) {
|
|
392
|
+
if (e && (typeof e.id === 'string' || typeof e.id === 'number') &&
|
|
393
|
+
typeof e.issue_id === 'string' && e.issue_id) {
|
|
394
|
+
map.set(String(e.id), e.issue_id);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
return map;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// Compose the visual label for a story node. Returns "<issue_id>: <key>"
|
|
402
|
+
// when an issue is tracked, otherwise just the key. Pure formatter so
|
|
403
|
+
// mermaid + graphviz can share the same convention.
|
|
404
|
+
function composeStoryLabel(storyKey, issueIdMap) {
|
|
405
|
+
const issueId = issueIdMap.get(storyKey);
|
|
406
|
+
return issueId ? `${issueId}: ${storyKey}` : storyKey;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function composeEpicLabel(epicId, issueIdMap) {
|
|
410
|
+
const issueId = issueIdMap.get(String(epicId));
|
|
411
|
+
return issueId ? `${issueId}: Epic ${epicId}` : `Epic ${epicId}`;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// Bucket the resolved edges into intra-epic vs cross-epic. We re-derive
|
|
415
|
+
// "is cross-epic" by inspecting node prefixes — the buildEdges output
|
|
416
|
+
// loses provenance, so we restitch from epic membership at render time.
|
|
417
|
+
function bucketEdges(edges) {
|
|
418
|
+
const intra = [];
|
|
419
|
+
const cross = [];
|
|
420
|
+
for (const [a, b] of edges) {
|
|
421
|
+
const epicA = parseEpicFromKey(a);
|
|
422
|
+
const epicB = parseEpicFromKey(b);
|
|
423
|
+
if (epicA !== null && epicB !== null && epicA !== epicB) cross.push([a, b]);
|
|
424
|
+
else intra.push([a, b]);
|
|
425
|
+
}
|
|
426
|
+
return { intra, cross };
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// Mermaid escaping: replace characters that would break flowchart syntax.
|
|
430
|
+
// We use the [Label] form for node labels which tolerates most characters
|
|
431
|
+
// once double-quoted — BUT several characters still break parsing:
|
|
432
|
+
// - `]` `[` `(` `)` `<` `>` — mermaid scans for matching brackets
|
|
433
|
+
// - `|` — link label syntax (A -->|label| B)
|
|
434
|
+
// - `;` — sometimes used as statement separator
|
|
435
|
+
// - `&` — start of HTML entity (avoid raw `&` to keep entities atomic)
|
|
436
|
+
// - newlines — must use `<br>` tag for explicit line break
|
|
437
|
+
// - ASCII control chars (\x00–\x1f) — undefined rendering behavior
|
|
438
|
+
// - Unicode RTL/LRM marks (U+202A–U+202E, U+2066–U+2069, U+061C) —
|
|
439
|
+
// can visually reorder labels in confusing ways
|
|
440
|
+
//
|
|
441
|
+
// Story keys are pre-validated via STORY_KEY_RE (/^[A-Za-z0-9._-]{1,64}$/)
|
|
442
|
+
// so they're already safe; the attack surface is issue_id (free-text
|
|
443
|
+
// captured during the planning skill's Step 7) which composeStoryLabel
|
|
444
|
+
// concatenates into the label. Escape defensively here so any future
|
|
445
|
+
// label source is also safe, AND hand-edited plans don't corrupt
|
|
446
|
+
// rendering (defense in depth — setIssueId also validates).
|
|
447
|
+
// Single-pass escape map. Each input character is matched ONCE by the
|
|
448
|
+
// regex below and replaced with the entity-encoded form. Output
|
|
449
|
+
// characters (the entity strings themselves) are never re-processed,
|
|
450
|
+
// which avoids the double-encoding trap a multi-pass .replace() chain
|
|
451
|
+
// would hit (e.g., `&` → `&amp;` if `&` is escaped twice).
|
|
452
|
+
const MERMAID_ESCAPE_MAP = {
|
|
453
|
+
'\\': '\',
|
|
454
|
+
'"': '"',
|
|
455
|
+
'&': '&',
|
|
456
|
+
';': ';',
|
|
457
|
+
']': ']',
|
|
458
|
+
'[': '[',
|
|
459
|
+
'(': '(',
|
|
460
|
+
')': ')',
|
|
461
|
+
'<': '<',
|
|
462
|
+
'>': '>',
|
|
463
|
+
'|': '|',
|
|
464
|
+
'\n': '<br>',
|
|
465
|
+
};
|
|
466
|
+
const MERMAID_ESCAPE_CHARS = /[\\"&;\]\[()<>|\n]/g;
|
|
467
|
+
// ASCII control chars (except \n which we map to <br> above) + DEL.
|
|
468
|
+
const STRIP_CONTROL = /[\x00-\x09\x0b-\x1f\x7f]/g;
|
|
469
|
+
// Unicode bidi-override / isolate / embedding marks. These can reorder
|
|
470
|
+
// the visual presentation of a label in confusing ways even when the
|
|
471
|
+
// underlying codepoints are benign — strip them entirely.
|
|
472
|
+
const STRIP_BIDI = /[--]/g;
|
|
473
|
+
|
|
474
|
+
function mermaidEscapeLabel(s) {
|
|
475
|
+
return (
|
|
476
|
+
String(s)
|
|
477
|
+
// Strip carriage returns first so the \n → <br> mapping below
|
|
478
|
+
// doesn't double-emit <br> on \r\n inputs.
|
|
479
|
+
.replace(/\r/g, '')
|
|
480
|
+
// Single-pass entity-encode of all chars that have mermaid-syntax
|
|
481
|
+
// meaning. The replacement function runs ONCE per matched char;
|
|
482
|
+
// the entity output is opaque to the regex.
|
|
483
|
+
.replace(MERMAID_ESCAPE_CHARS, (c) => MERMAID_ESCAPE_MAP[c])
|
|
484
|
+
.replace(STRIP_CONTROL, '')
|
|
485
|
+
.replace(STRIP_BIDI, '')
|
|
486
|
+
);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function renderMermaid(dag, plan) {
|
|
490
|
+
const statusByKey = planStatusByKey(plan);
|
|
491
|
+
const storyIssueIds = issueIdByStoryKey(plan);
|
|
492
|
+
const epicIssueIds = issueIdByEpicId(plan);
|
|
493
|
+
const { intra, cross } = bucketEdges(dag.edges);
|
|
494
|
+
const lines = [];
|
|
495
|
+
lines.push(`%% plan-id: ${plan?.plan_id ?? 'unknown'}`);
|
|
496
|
+
lines.push(`%% generated: ${plan?.generated ?? new Date().toISOString()}`);
|
|
497
|
+
lines.push('%% Sprint plan DAG — node fill encodes plan_status; cross-epic edges are dashed.');
|
|
498
|
+
lines.push('%% Story labels are prefixed with their issue_id when set in plan.stories.');
|
|
499
|
+
lines.push('flowchart LR');
|
|
500
|
+
|
|
501
|
+
// Group nodes by epic (if any). When sprint-wide, emit subgraphs.
|
|
502
|
+
const epicGroups = new Map();
|
|
503
|
+
for (const node of dag.nodes) {
|
|
504
|
+
const epic = parseEpicFromKey(node) ?? 'unknown';
|
|
505
|
+
if (!epicGroups.has(epic)) epicGroups.set(epic, []);
|
|
506
|
+
epicGroups.get(epic).push(node);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
const epicsSorted = [...epicGroups.keys()].sort();
|
|
510
|
+
for (const epic of epicsSorted) {
|
|
511
|
+
const epicLabel = composeEpicLabel(epic, epicIssueIds);
|
|
512
|
+
lines.push(` subgraph epic_${epic} ["${mermaidEscapeLabel(epicLabel)}"]`);
|
|
513
|
+
for (const node of epicGroups.get(epic).sort()) {
|
|
514
|
+
const status = statusByKey.get(node) ?? 'pending';
|
|
515
|
+
const storyLabel = composeStoryLabel(node, storyIssueIds);
|
|
516
|
+
lines.push(` ${node}["${mermaidEscapeLabel(storyLabel)}"]:::${status}`);
|
|
517
|
+
}
|
|
518
|
+
lines.push(' end');
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// Intra-epic edges first (solid), then cross-epic (dashed with label).
|
|
522
|
+
for (const [a, b] of intra) {
|
|
523
|
+
lines.push(` ${a} --> ${b}`);
|
|
524
|
+
}
|
|
525
|
+
for (const [a, b] of cross) {
|
|
526
|
+
lines.push(` ${a} -. cross-epic .-> ${b}`);
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// classDef definitions for plan_status colors. Order matches STATUS_COLORS.
|
|
530
|
+
for (const status of Object.keys(STATUS_COLORS)) {
|
|
531
|
+
const { fill, text } = STATUS_COLORS[status];
|
|
532
|
+
lines.push(` classDef ${status} fill:${fill},color:${text}`);
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
return lines.join('\n') + '\n';
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// Graphviz (dot) label escaping. Double-quoted labels in dot use
|
|
539
|
+
// backslash as the escape character (NOT HTML entities — those only
|
|
540
|
+
// apply to `<...>` HTML-like labels, which we don't use). So most
|
|
541
|
+
// special chars pass through as literals; we only need to escape `\`
|
|
542
|
+
// and `"` plus convert newlines and strip dangerous control chars.
|
|
543
|
+
//
|
|
544
|
+
// `<` and `>` in a double-quoted label render as literal `<` and `>`
|
|
545
|
+
// (no HTML interpretation), so they don't need entity encoding.
|
|
546
|
+
function dotEscapeLabel(s) {
|
|
547
|
+
return (
|
|
548
|
+
String(s)
|
|
549
|
+
.replace(/\r/g, '')
|
|
550
|
+
.replace(/[\\"]/g, '\\$&')
|
|
551
|
+
.replace(/\n/g, '\\n')
|
|
552
|
+
.replace(/[\x00-\x08\x0b-\x1f\x7f]/g, '')
|
|
553
|
+
.replace(/[--]/g, '')
|
|
554
|
+
);
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
function renderGraphviz(dag, plan) {
|
|
558
|
+
const statusByKey = planStatusByKey(plan);
|
|
559
|
+
const storyIssueIds = issueIdByStoryKey(plan);
|
|
560
|
+
const epicIssueIds = issueIdByEpicId(plan);
|
|
561
|
+
const { intra, cross } = bucketEdges(dag.edges);
|
|
562
|
+
const lines = [];
|
|
563
|
+
lines.push('digraph SprintPlan {');
|
|
564
|
+
lines.push(` // plan-id: ${plan?.plan_id ?? 'unknown'}`);
|
|
565
|
+
lines.push(` // generated: ${plan?.generated ?? new Date().toISOString()}`);
|
|
566
|
+
lines.push(' // Story labels are prefixed with their issue_id when set in plan.stories.');
|
|
567
|
+
lines.push(' rankdir=LR;');
|
|
568
|
+
lines.push(' node [style=filled, fontname="Helvetica"];');
|
|
569
|
+
|
|
570
|
+
const epicGroups = new Map();
|
|
571
|
+
for (const node of dag.nodes) {
|
|
572
|
+
const epic = parseEpicFromKey(node) ?? 'unknown';
|
|
573
|
+
if (!epicGroups.has(epic)) epicGroups.set(epic, []);
|
|
574
|
+
epicGroups.get(epic).push(node);
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
const epicsSorted = [...epicGroups.keys()].sort();
|
|
578
|
+
for (const epic of epicsSorted) {
|
|
579
|
+
const epicLabel = composeEpicLabel(epic, epicIssueIds);
|
|
580
|
+
lines.push(` subgraph cluster_${epic} {`);
|
|
581
|
+
lines.push(` label="${dotEscapeLabel(epicLabel)}";`);
|
|
582
|
+
for (const node of epicGroups.get(epic).sort()) {
|
|
583
|
+
const status = statusByKey.get(node) ?? 'pending';
|
|
584
|
+
const { fill, text } = STATUS_COLORS[status];
|
|
585
|
+
const storyLabel = composeStoryLabel(node, storyIssueIds);
|
|
586
|
+
// When the visual label differs from the node id (issue_id is set),
|
|
587
|
+
// emit an explicit `label=` attribute. Otherwise dot uses the node id.
|
|
588
|
+
const labelAttr =
|
|
589
|
+
storyLabel === node ? '' : `, label="${dotEscapeLabel(storyLabel)}"`;
|
|
590
|
+
lines.push(
|
|
591
|
+
` "${dotEscapeLabel(node)}" [fillcolor="${fill}", fontcolor="${text}"${labelAttr}];`,
|
|
592
|
+
);
|
|
593
|
+
}
|
|
594
|
+
lines.push(' }');
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
for (const [a, b] of intra) {
|
|
598
|
+
lines.push(` "${dotEscapeLabel(a)}" -> "${dotEscapeLabel(b)}";`);
|
|
599
|
+
}
|
|
600
|
+
for (const [a, b] of cross) {
|
|
601
|
+
lines.push(
|
|
602
|
+
` "${dotEscapeLabel(a)}" -> "${dotEscapeLabel(b)}" [style=dashed, label="cross-epic"];`,
|
|
603
|
+
);
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
lines.push('}');
|
|
607
|
+
return lines.join('\n') + '\n';
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
// Detect `dot` binary in PATH. Used by graphviz format to decide whether
|
|
611
|
+
// to fall back to mermaid (with stderr notice) when the toolchain is
|
|
612
|
+
// missing on the user's machine.
|
|
613
|
+
function hasGraphvizBinary() {
|
|
614
|
+
try {
|
|
615
|
+
const r = spawnSync('dot', ['-V'], { stdio: 'ignore' });
|
|
616
|
+
return r.status === 0;
|
|
617
|
+
} catch {
|
|
618
|
+
return false;
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
function defaultRenderOutputPath(projectRoot, format) {
|
|
623
|
+
const ext = format === 'graphviz' ? 'dot' : 'mmd';
|
|
624
|
+
return path.join(projectRoot, '_bmad-output', 'implementation-artifacts', `sprint-plan-dag.${ext}`);
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
// Top-level render orchestrator. Returns { wrote, file, format, fallback?, message }.
|
|
628
|
+
// Failure modes (missing graphviz, write error) emit warnings to stderr but
|
|
629
|
+
// fall back gracefully — render NEVER throws under normal use; the caller
|
|
630
|
+
// gets a structured result instead.
|
|
631
|
+
function runRender({ projectRoot, epic, format, output }) {
|
|
632
|
+
const requestedFormat = format;
|
|
633
|
+
let effectiveFormat = format;
|
|
634
|
+
let fallbackReason = null;
|
|
635
|
+
if (effectiveFormat === 'graphviz' && !hasGraphvizBinary()) {
|
|
636
|
+
log.warn("graphviz toolchain ('dot') not found in PATH — falling back to mermaid");
|
|
637
|
+
effectiveFormat = 'mermaid';
|
|
638
|
+
fallbackReason = 'graphviz-missing';
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
const dag = buildDag({ projectRoot, epic, strategies: DEFAULT_STRATEGIES });
|
|
642
|
+
// Refuse to render on a corrupt or cycle-bearing graph; surface clearly.
|
|
643
|
+
if (dag.cycle.length > 0) {
|
|
644
|
+
return {
|
|
645
|
+
wrote: false,
|
|
646
|
+
reason: 'cycle',
|
|
647
|
+
cycle: dag.cycle,
|
|
648
|
+
message: `cycle detected: ${dag.cycle.join(', ')}`,
|
|
649
|
+
};
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
const planResult = readPlan({ projectRoot });
|
|
653
|
+
// readPlan returns null on missing, error obj on parse failure, plan on success.
|
|
654
|
+
let plan = null;
|
|
655
|
+
if (planResult && typeof planResult === 'object' && !('error' in planResult)) {
|
|
656
|
+
plan = planResult;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
const body =
|
|
660
|
+
effectiveFormat === 'graphviz' ? renderGraphviz(dag, plan) : renderMermaid(dag, plan);
|
|
661
|
+
const outputPath = output || defaultRenderOutputPath(projectRoot, effectiveFormat);
|
|
662
|
+
|
|
663
|
+
try {
|
|
664
|
+
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
|
|
665
|
+
fs.writeFileSync(outputPath, body);
|
|
666
|
+
} catch (e) {
|
|
667
|
+
log.warn(`render write failed: ${e.message}`);
|
|
668
|
+
return {
|
|
669
|
+
wrote: false,
|
|
670
|
+
reason: 'write_failed',
|
|
671
|
+
message: e.message,
|
|
672
|
+
file: outputPath,
|
|
673
|
+
};
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
return {
|
|
677
|
+
wrote: true,
|
|
678
|
+
file: outputPath,
|
|
679
|
+
format: effectiveFormat,
|
|
680
|
+
requested_format: requestedFormat,
|
|
681
|
+
...(fallbackReason ? { fallback: fallbackReason } : {}),
|
|
682
|
+
nodes: dag.nodes.length,
|
|
683
|
+
edges: dag.edges.length,
|
|
502
684
|
};
|
|
503
|
-
// Linear chain by default.
|
|
504
|
-
for (let i = 0; i < ordered.length; i++) {
|
|
505
|
-
doc.stories[ordered[i]] = { depends_on: i === 0 ? [] : [ordered[i - 1]] };
|
|
506
|
-
}
|
|
507
|
-
doc.epics[epic || 'unknown'] = { independent: false };
|
|
508
|
-
|
|
509
|
-
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
510
|
-
const header = [
|
|
511
|
-
'# Sprintpilot dependency sidecar.',
|
|
512
|
-
'# Authoritative input to resolve-dag.js for parallel execution (PR 11+).',
|
|
513
|
-
'# BMad never reads this file — it is Sprintpilot-owned.',
|
|
514
|
-
'#',
|
|
515
|
-
'# Schema:',
|
|
516
|
-
'# stories.<key>.depends_on: [<key>, ...] — edges: dep → key',
|
|
517
|
-
'# overrides[*].force_sequential: [...] — serialize listed keys',
|
|
518
|
-
'# overrides[*].force_independent: [...] — drop inbound edges on listed keys',
|
|
519
|
-
'# epics.<id>.independent: true — enable cross-epic parallelism (PR 12)',
|
|
520
|
-
'#',
|
|
521
|
-
'# Safe starting point: a linear chain (below). Uncomment force_independent',
|
|
522
|
-
'# entries to unlock parallel layers.',
|
|
523
|
-
'',
|
|
524
|
-
].join('\n');
|
|
525
|
-
const body = header + yamlDump(doc) + '\n';
|
|
526
|
-
fs.writeFileSync(file, body);
|
|
527
|
-
return { wrote: true, file };
|
|
528
685
|
}
|
|
529
686
|
|
|
530
687
|
function main() {
|
|
531
|
-
const { opts, positional } = parseArgs(process.argv.slice(2)
|
|
688
|
+
const { opts, positional } = parseArgs(process.argv.slice(2));
|
|
532
689
|
if (opts.help || positional.length === 0) {
|
|
533
690
|
help();
|
|
534
691
|
process.exit(opts.help ? 0 : 1);
|
|
@@ -541,18 +698,21 @@ function main() {
|
|
|
541
698
|
const projectRoot = opts['project-root'] || process.cwd();
|
|
542
699
|
const epic = opts.epic !== undefined ? String(opts.epic) : null;
|
|
543
700
|
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
701
|
+
// The `render` subcommand has its own flag set and result handling.
|
|
702
|
+
if (command === 'render') {
|
|
703
|
+
const format = opts.format || 'mermaid';
|
|
704
|
+
if (!VALID_RENDER_FORMATS.includes(format)) {
|
|
705
|
+
log.error(`unknown format '${format}'. Valid: ${VALID_RENDER_FORMATS.join(', ')}`);
|
|
547
706
|
process.exit(1);
|
|
548
707
|
}
|
|
549
|
-
const
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
708
|
+
const result = runRender({
|
|
709
|
+
projectRoot,
|
|
710
|
+
epic,
|
|
711
|
+
format,
|
|
712
|
+
output: opts.output || null,
|
|
713
|
+
});
|
|
714
|
+
process.stdout.write(`${JSON.stringify(result)}\n`);
|
|
715
|
+
process.exit(result.wrote ? 0 : 1);
|
|
556
716
|
}
|
|
557
717
|
|
|
558
718
|
const strat = parseStrategies(opts.strategy);
|
|
@@ -586,20 +746,32 @@ module.exports = {
|
|
|
586
746
|
DEFAULT_STRATEGIES,
|
|
587
747
|
VALID_STRATEGIES,
|
|
588
748
|
VALID_COMMANDS,
|
|
749
|
+
VALID_RENDER_FORMATS,
|
|
750
|
+
STATUS_COLORS,
|
|
589
751
|
parseEpicFromKey,
|
|
590
752
|
parseStrategies,
|
|
591
753
|
readStoriesFromStatus,
|
|
592
754
|
readDependencies,
|
|
593
|
-
parseDependenciesYaml,
|
|
594
755
|
edgesFromExplicit,
|
|
756
|
+
edgesFromCrossEpic,
|
|
595
757
|
edgesFromOrdering,
|
|
596
758
|
applyForceIndependent,
|
|
597
759
|
buildEdges,
|
|
598
760
|
topoLayers,
|
|
599
761
|
buildDag,
|
|
600
|
-
scaffoldDependenciesYaml,
|
|
601
762
|
sprintStatusPath,
|
|
602
763
|
dependenciesPath,
|
|
764
|
+
planStatusByKey,
|
|
765
|
+
issueIdByStoryKey,
|
|
766
|
+
issueIdByEpicId,
|
|
767
|
+
composeStoryLabel,
|
|
768
|
+
composeEpicLabel,
|
|
769
|
+
bucketEdges,
|
|
770
|
+
renderMermaid,
|
|
771
|
+
renderGraphviz,
|
|
772
|
+
hasGraphvizBinary,
|
|
773
|
+
defaultRenderOutputPath,
|
|
774
|
+
runRender,
|
|
603
775
|
};
|
|
604
776
|
|
|
605
777
|
if (require.main === module) {
|