@ikunin/sprintpilot 2.2.31 → 2.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/README.md +228 -415
  2. package/_Sprintpilot/Sprintpilot.md +76 -8
  3. package/_Sprintpilot/bin/autopilot.js +734 -68
  4. package/_Sprintpilot/lib/orchestrator/action-ledger.js +208 -0
  5. package/_Sprintpilot/lib/orchestrator/adapt.js +93 -15
  6. package/_Sprintpilot/lib/orchestrator/profile-rules.js +7 -16
  7. package/_Sprintpilot/lib/orchestrator/sprint-plan.js +488 -0
  8. package/_Sprintpilot/lib/orchestrator/state-store.js +9 -5
  9. package/_Sprintpilot/lib/orchestrator/user-command-applier.js +78 -0
  10. package/_Sprintpilot/lib/orchestrator/user-commands.js +114 -0
  11. package/_Sprintpilot/lib/orchestrator/verify.js +10 -17
  12. package/_Sprintpilot/manifest.yaml +4 -3
  13. package/_Sprintpilot/modules/autopilot/profiles/_base.yaml +18 -4
  14. package/_Sprintpilot/modules/git/config.yaml +15 -9
  15. package/_Sprintpilot/modules/ma/config.yaml +29 -27
  16. package/_Sprintpilot/scripts/dispatch-layer.js +12 -15
  17. package/_Sprintpilot/scripts/infer-dependencies.js +706 -254
  18. package/_Sprintpilot/scripts/log-timing.js +6 -10
  19. package/_Sprintpilot/scripts/merge-shards.js +21 -23
  20. package/_Sprintpilot/scripts/post-green-gates.js +3 -1
  21. package/_Sprintpilot/scripts/resolve-dag.js +452 -280
  22. package/_Sprintpilot/scripts/sprint-plan.js +1068 -0
  23. package/_Sprintpilot/scripts/state-shard.js +13 -5
  24. package/_Sprintpilot/scripts/summarize-timings.js +2 -3
  25. package/_Sprintpilot/skills/sprint-autopilot-on/SKILL.md +30 -2
  26. package/_Sprintpilot/skills/sprint-autopilot-on/workflow.orchestrator.md +36 -10
  27. package/_Sprintpilot/skills/sprintpilot-codebase-map/agents/architecture-mapper.md +10 -8
  28. package/_Sprintpilot/skills/sprintpilot-codebase-map/agents/concerns-hunter.md +11 -9
  29. package/_Sprintpilot/skills/sprintpilot-codebase-map/agents/integration-mapper.md +11 -9
  30. package/_Sprintpilot/skills/sprintpilot-codebase-map/agents/quality-assessor.md +10 -8
  31. package/_Sprintpilot/skills/sprintpilot-codebase-map/agents/stack-analyzer.md +11 -9
  32. package/_Sprintpilot/skills/sprintpilot-codebase-map/workflow.md +1 -1
  33. package/_Sprintpilot/skills/sprintpilot-dependency-graph/SKILL.md +63 -0
  34. package/_Sprintpilot/skills/sprintpilot-dependency-graph/workflow.md +227 -0
  35. package/_Sprintpilot/skills/sprintpilot-plan-sprint/SKILL.md +67 -0
  36. package/_Sprintpilot/skills/sprintpilot-plan-sprint/workflow.md +435 -0
  37. package/_Sprintpilot/skills/sprintpilot-sprint-progress/SKILL.md +53 -0
  38. package/_Sprintpilot/skills/sprintpilot-sprint-progress/workflow.md +169 -0
  39. package/lib/commands/install.js +186 -12
  40. package/package.json +1 -1
  41. package/_Sprintpilot/skills/sprintpilot-code-review/SKILL.md +0 -6
  42. package/_Sprintpilot/skills/sprintpilot-code-review/agents/acceptance-auditor.md +0 -51
  43. package/_Sprintpilot/skills/sprintpilot-code-review/agents/blind-hunter.md +0 -39
  44. package/_Sprintpilot/skills/sprintpilot-code-review/agents/edge-case-hunter.md +0 -46
  45. package/_Sprintpilot/skills/sprintpilot-code-review/workflow.md +0 -111
  46. package/_Sprintpilot/skills/sprintpilot-party-mode/SKILL.md +0 -6
  47. package/_Sprintpilot/skills/sprintpilot-party-mode/workflow.md +0 -138
@@ -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 _Sprintpilot/sprints/dependencies.yaml
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 in PR 9.1) infer edges from shared file-path touches
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 dependencies.yaml is fine; we fall back to the next strategy.
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 shardMod = require('./state-shard.js');
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', 'scaffold'];
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 scaffold --epic <id> [--force]',
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 file = dependenciesPath(projectRoot);
133
- if (!fs.existsSync(file)) return null;
134
- const raw = fs.readFileSync(file, 'utf8');
135
- try {
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
- // Purpose-built YAML parser for dependencies.yaml. Supports the hand-
144
- // authored shape from the PR 9 plan: nested objects, block-form lists
145
- // (`- item` and `- key: value`), and flow-form arrays (`["a","b"]`) on
146
- // the value side of a key. Deliberately narrower than a full YAML impl
147
- // to keep the script dep-free in user projects.
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". Pre-2.0.8 this stripped both
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}${b}`;
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') pushEdges(edgesFromExplicit(depsDoc, nodes));
406
- else if (strat === 'ordering') pushEdges(edgesFromOrdering(nodes));
407
- else if (strat === 'files') {
408
- // files strategy is opt-in and not implemented in the PR 9 scope.
409
- // A future sprintpilot-infer-dependencies skill can populate the
410
- // explicit sidecar instead.
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
- const edges = buildEdges(strategies, ordered, depsDoc);
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
- function scaffoldDependenciesYaml(projectRoot, epic, { force = false } = {}) {
483
- const file = dependenciesPath(projectRoot);
484
- if (fs.existsSync(file) && !force) {
485
- return { wrote: false, reason: 'exists', file };
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
- const { ordered } = readStoriesFromStatus(projectRoot, epic);
488
- if (ordered.length === 0) {
489
- return { wrote: false, reason: 'no-stories', file };
490
- }
491
- const doc = {
492
- version: 1,
493
- stories: {},
494
- overrides: [
495
- {
496
- epic: epic || 'unknown',
497
- force_independent: [],
498
- force_sequential: [],
499
- },
500
- ],
501
- epics: {},
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;` → `&amp;amp;` if `&` is escaped twice).
452
+ const MERMAID_ESCAPE_MAP = {
453
+ '\\': '&#92;',
454
+ '"': '&quot;',
455
+ '&': '&amp;',
456
+ ';': '&#59;',
457
+ ']': '&#93;',
458
+ '[': '&#91;',
459
+ '(': '&#40;',
460
+ ')': '&#41;',
461
+ '<': '&lt;',
462
+ '>': '&gt;',
463
+ '|': '&#124;',
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), { booleanFlags: ['force'] });
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
- if (command === 'scaffold') {
545
- if (!epic) {
546
- log.error('scaffold requires --epic');
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 res = scaffoldDependenciesYaml(projectRoot, epic, { force: opts.force === true });
550
- if (!res.wrote) {
551
- log.error(`scaffold: ${res.reason}; use --force to overwrite`);
552
- process.exit(res.reason === 'exists' ? 2 : 1);
553
- }
554
- process.stdout.write(`${JSON.stringify(res)}\n`);
555
- return;
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) {