@ikunin/sprintpilot 1.0.5 → 2.0.4
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/_Sprintpilot/Sprintpilot.md +14 -1
- package/_Sprintpilot/manifest.yaml +1 -1
- package/_Sprintpilot/modules/autopilot/config.yaml +22 -0
- package/_Sprintpilot/modules/autopilot/profiles/_base.yaml +45 -0
- package/_Sprintpilot/modules/autopilot/profiles/large.yaml +22 -0
- package/_Sprintpilot/modules/autopilot/profiles/legacy.yaml +35 -0
- package/_Sprintpilot/modules/autopilot/profiles/medium.yaml +5 -0
- package/_Sprintpilot/modules/autopilot/profiles/nano.yaml +35 -0
- package/_Sprintpilot/modules/autopilot/profiles/small.yaml +5 -0
- package/_Sprintpilot/modules/git/config.yaml +8 -0
- package/_Sprintpilot/modules/ma/config.yaml +42 -0
- package/_Sprintpilot/scripts/agent-adapter.js +247 -0
- package/_Sprintpilot/scripts/cached-read.js +238 -0
- package/_Sprintpilot/scripts/check-prereqs.js +139 -0
- package/_Sprintpilot/scripts/dispatch-layer.js +192 -0
- package/_Sprintpilot/scripts/git-portable.js +219 -0
- package/_Sprintpilot/scripts/infer-dependencies.js +594 -0
- package/_Sprintpilot/scripts/inject-tasks-section.js +279 -0
- package/_Sprintpilot/scripts/list-remaining-stories.js +295 -0
- package/_Sprintpilot/scripts/log-timing.js +360 -0
- package/_Sprintpilot/scripts/mark-done-stories-tasks.js +254 -0
- package/_Sprintpilot/scripts/merge-shards.js +339 -0
- package/_Sprintpilot/scripts/preflight-merge.js +235 -0
- package/_Sprintpilot/scripts/resolve-dag.js +559 -0
- package/_Sprintpilot/scripts/resolve-profile.js +355 -0
- package/_Sprintpilot/scripts/state-shard.js +602 -0
- package/_Sprintpilot/scripts/submodule-lock.js +130 -0
- package/_Sprintpilot/scripts/summarize-timings.js +362 -0
- package/_Sprintpilot/scripts/sync-status.js +13 -0
- package/_Sprintpilot/scripts/with-retry.js +145 -0
- package/_Sprintpilot/skills/sprint-autopilot-on/workflow.md +572 -42
- package/bin/sprintpilot.js +4 -0
- package/lib/commands/install.js +157 -1
- package/package.json +1 -1
|
@@ -0,0 +1,559 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// resolve-dag.js — build the story execution DAG for a sprint.
|
|
4
|
+
//
|
|
5
|
+
// Usage:
|
|
6
|
+
// resolve-dag.js graph [--epic <id>] [--project-root <path>] [--strategy <list>]
|
|
7
|
+
// resolve-dag.js layers [--epic <id>] [--project-root <path>] [--strategy <list>]
|
|
8
|
+
// resolve-dag.js width [--epic <id>] [--project-root <path>] [--strategy <list>]
|
|
9
|
+
// resolve-dag.js scaffold --epic <id> [--project-root <path>] [--force]
|
|
10
|
+
//
|
|
11
|
+
// Strategies (default order: explicit,ordering):
|
|
12
|
+
// explicit — read _Sprintpilot/sprints/dependencies.yaml
|
|
13
|
+
// ordering — linear chain from sprint-status.yaml order (safe default)
|
|
14
|
+
// files — (TODO in PR 9.1) infer edges from shared file-path touches
|
|
15
|
+
//
|
|
16
|
+
// Conflict resolution when multiple strategies contribute: explicit > files > ordering.
|
|
17
|
+
// Missing dependencies.yaml is fine; we fall back to the next strategy.
|
|
18
|
+
//
|
|
19
|
+
// Output:
|
|
20
|
+
// graph { "nodes": [...], "edges": [ ["a","b"], ... ], "epic": "1" }
|
|
21
|
+
// layers [[...], [...], ...]
|
|
22
|
+
// width <int>
|
|
23
|
+
//
|
|
24
|
+
// Cycle detection: Kahn's algorithm. Any node remaining after topological
|
|
25
|
+
// pass is part of a cycle — exit 1 with the offending nodes on stderr.
|
|
26
|
+
|
|
27
|
+
const fs = require('node:fs');
|
|
28
|
+
const path = require('node:path');
|
|
29
|
+
|
|
30
|
+
const { parseArgs } = require('../lib/runtime/args');
|
|
31
|
+
const log = require('../lib/runtime/log');
|
|
32
|
+
const shardMod = require('./state-shard.js');
|
|
33
|
+
|
|
34
|
+
const { yamlLoad, yamlDump } = shardMod;
|
|
35
|
+
|
|
36
|
+
const DEFAULT_STRATEGIES = ['explicit', 'ordering'];
|
|
37
|
+
const VALID_STRATEGIES = ['explicit', 'ordering', 'files'];
|
|
38
|
+
const VALID_COMMANDS = ['graph', 'layers', 'width', 'scaffold'];
|
|
39
|
+
|
|
40
|
+
function help() {
|
|
41
|
+
log.out(
|
|
42
|
+
[
|
|
43
|
+
'Usage:',
|
|
44
|
+
' resolve-dag.js graph [--epic <id>] [--strategy explicit,ordering]',
|
|
45
|
+
' resolve-dag.js layers [--epic <id>] [--strategy explicit,ordering]',
|
|
46
|
+
' resolve-dag.js width [--epic <id>] [--strategy explicit,ordering]',
|
|
47
|
+
' resolve-dag.js scaffold --epic <id> [--force]',
|
|
48
|
+
'',
|
|
49
|
+
'Strategies: explicit | ordering | files (opt-in)',
|
|
50
|
+
].join('\n'),
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ------------------------------------------------------------------
|
|
55
|
+
// Reading stories / dependencies
|
|
56
|
+
// ------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
function sprintStatusPath(projectRoot) {
|
|
59
|
+
return path.join(projectRoot, '_bmad-output', 'implementation-artifacts', 'sprint-status.yaml');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function dependenciesPath(projectRoot) {
|
|
63
|
+
return path.join(projectRoot, '_Sprintpilot', 'sprints', 'dependencies.yaml');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function parseEpicFromKey(storyKey) {
|
|
67
|
+
const m = String(storyKey).match(/^(\d+)(?:-|$)/);
|
|
68
|
+
return m ? m[1] : null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function readStoriesFromStatus(projectRoot, epicFilter) {
|
|
72
|
+
const file = sprintStatusPath(projectRoot);
|
|
73
|
+
if (!fs.existsSync(file)) return { ordered: [], byKey: {} };
|
|
74
|
+
const raw = fs.readFileSync(file, 'utf8');
|
|
75
|
+
// Pull out story keys by scanning for two-space-indented `<key>:` lines
|
|
76
|
+
// under `development_status:` (BMad's canonical shape) and under `stories:`
|
|
77
|
+
// (alternate shape some projects use). We intentionally don't parse the
|
|
78
|
+
// whole YAML — sprint-status is BMad-owned and its schema varies.
|
|
79
|
+
const ordered = [];
|
|
80
|
+
const byKey = {};
|
|
81
|
+
const lines = raw.split(/\r?\n/);
|
|
82
|
+
let inStoriesBlock = false;
|
|
83
|
+
for (const rawLine of lines) {
|
|
84
|
+
const trimmed = rawLine.trimEnd();
|
|
85
|
+
if (/^(development_status|stories):\s*$/.test(trimmed)) {
|
|
86
|
+
inStoriesBlock = true;
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
// Bail out of the stories block on a top-level key.
|
|
90
|
+
if (inStoriesBlock && /^\S/.test(trimmed)) inStoriesBlock = false;
|
|
91
|
+
if (!inStoriesBlock) continue;
|
|
92
|
+
const m = trimmed.match(/^ ([A-Za-z0-9][A-Za-z0-9-]*):\s*(\S+)?/);
|
|
93
|
+
if (!m) continue;
|
|
94
|
+
const key = m[1];
|
|
95
|
+
const status = m[2] ? m[2].replace(/^["']|["']$/g, '') : null;
|
|
96
|
+
if (epicFilter !== null && parseEpicFromKey(key) !== epicFilter) continue;
|
|
97
|
+
if (!(key in byKey)) {
|
|
98
|
+
ordered.push(key);
|
|
99
|
+
byKey[key] = { key, status, epic: parseEpicFromKey(key) };
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return { ordered, byKey };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function readDependencies(projectRoot) {
|
|
106
|
+
const file = dependenciesPath(projectRoot);
|
|
107
|
+
if (!fs.existsSync(file)) return null;
|
|
108
|
+
const raw = fs.readFileSync(file, 'utf8');
|
|
109
|
+
try {
|
|
110
|
+
return parseDependenciesYaml(raw);
|
|
111
|
+
} catch (e) {
|
|
112
|
+
log.warn(`failed to parse ${file}: ${e.message}`);
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Purpose-built YAML parser for dependencies.yaml. Supports the hand-
|
|
118
|
+
// authored shape from the PR 9 plan: nested objects, block-form lists
|
|
119
|
+
// (`- item` and `- key: value`), and flow-form arrays (`["a","b"]`) on
|
|
120
|
+
// the value side of a key. Deliberately narrower than a full YAML impl
|
|
121
|
+
// to keep the script dep-free in user projects.
|
|
122
|
+
//
|
|
123
|
+
// Design:
|
|
124
|
+
// One stack frame per "open container" (root + any parent whose
|
|
125
|
+
// pendingKey value is an in-progress object or list). pendingKey names
|
|
126
|
+
// the last key assigned in this container.
|
|
127
|
+
//
|
|
128
|
+
// On a deeper-indent line, if top.pendingKey is set AND points at an
|
|
129
|
+
// object/list that hasn't been "closed" yet, we descend into it by
|
|
130
|
+
// pushing a new frame whose container is top.container[pendingKey].
|
|
131
|
+
//
|
|
132
|
+
// List items attach to the current frame's pendingKey: promote the
|
|
133
|
+
// container[pendingKey] from {} to [] on first list item.
|
|
134
|
+
function parseDependenciesYaml(text) {
|
|
135
|
+
const lines = text.split(/\r?\n/);
|
|
136
|
+
const root = {};
|
|
137
|
+
const stack = [{ indent: -1, container: root, pendingKey: null, pendingKeyIndent: -1 }];
|
|
138
|
+
|
|
139
|
+
const parseScalar = (raw) => {
|
|
140
|
+
if (raw === '' || raw === 'null' || raw === '~') return null;
|
|
141
|
+
if (raw === 'true') return true;
|
|
142
|
+
if (raw === 'false') return false;
|
|
143
|
+
if (raw === '[]') return [];
|
|
144
|
+
if (raw === '{}') return {};
|
|
145
|
+
if (raw.startsWith('[') || raw.startsWith('{')) {
|
|
146
|
+
try {
|
|
147
|
+
return JSON.parse(raw);
|
|
148
|
+
} catch {
|
|
149
|
+
return raw;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
if ((raw.startsWith('"') && raw.endsWith('"')) || (raw.startsWith("'") && raw.endsWith("'"))) {
|
|
153
|
+
try {
|
|
154
|
+
return raw.startsWith('"') ? JSON.parse(raw) : raw.slice(1, -1);
|
|
155
|
+
} catch {
|
|
156
|
+
return raw.slice(1, -1);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
if (/^-?\d+$/.test(raw)) return Number.parseInt(raw, 10);
|
|
160
|
+
if (/^-?\d+\.\d+$/.test(raw)) return Number.parseFloat(raw);
|
|
161
|
+
return raw;
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
const descendIfDeeper = (indent) => {
|
|
165
|
+
// Descend into top.pendingKey's value iff this line is strictly deeper
|
|
166
|
+
// than the line that assigned the pendingKey. Using top.indent here is
|
|
167
|
+
// wrong — a freshly-popped sibling at the same logical depth would be
|
|
168
|
+
// incorrectly absorbed as a child. pendingKeyIndent tracks where the
|
|
169
|
+
// pendingKey was assigned; only indents past that are true descendants.
|
|
170
|
+
const top = stack[stack.length - 1];
|
|
171
|
+
if (top.pendingKey === null) return;
|
|
172
|
+
if (indent <= top.pendingKeyIndent) return;
|
|
173
|
+
const child = top.container[top.pendingKey];
|
|
174
|
+
if (!child || typeof child !== 'object') return;
|
|
175
|
+
stack.push({ indent, container: child, pendingKey: null, pendingKeyIndent: -1 });
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
for (const rawLine of lines) {
|
|
179
|
+
const hashIdx = rawLine.indexOf('#');
|
|
180
|
+
let line = rawLine;
|
|
181
|
+
if (hashIdx !== -1) {
|
|
182
|
+
if (hashIdx === 0 || /\s/.test(rawLine[hashIdx - 1])) line = rawLine.slice(0, hashIdx);
|
|
183
|
+
}
|
|
184
|
+
const trimRight = line.replace(/\s+$/, '');
|
|
185
|
+
if (!trimRight.trim()) continue;
|
|
186
|
+
const indent = (trimRight.match(/^( *)/) || ['', ''])[1].length;
|
|
187
|
+
const rest = trimRight.slice(indent);
|
|
188
|
+
|
|
189
|
+
// Pop frames we've outdented past. List-item frames (fromListItem) are
|
|
190
|
+
// kept while `indent == top.indent` — the list-item's inline key and
|
|
191
|
+
// any sibling keys share the same indent and all belong to that entry.
|
|
192
|
+
while (stack.length > 1) {
|
|
193
|
+
const t = stack[stack.length - 1];
|
|
194
|
+
const strict = t.fromListItem ? t.indent > indent : t.indent >= indent;
|
|
195
|
+
if (!strict) break;
|
|
196
|
+
stack.pop();
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (rest.startsWith('- ') || rest === '-') {
|
|
200
|
+
// List item attaches to current frame's pendingKey.
|
|
201
|
+
const owner = stack[stack.length - 1];
|
|
202
|
+
const key = owner.pendingKey;
|
|
203
|
+
if (!key) continue; // malformed — list item with no owner key
|
|
204
|
+
if (!Array.isArray(owner.container[key])) owner.container[key] = [];
|
|
205
|
+
const arr = owner.container[key];
|
|
206
|
+
const content = rest === '-' ? '' : rest.slice(2).trim();
|
|
207
|
+
const colon = findTopLevelColon(content);
|
|
208
|
+
if (content === '') {
|
|
209
|
+
arr.push(null);
|
|
210
|
+
} else if (colon === -1) {
|
|
211
|
+
arr.push(parseScalar(content));
|
|
212
|
+
} else {
|
|
213
|
+
// Inline mapping: "- k: v" or "- k:" starts a new object item.
|
|
214
|
+
const k = unquoteKey(content.slice(0, colon).trim());
|
|
215
|
+
const v = content.slice(colon + 1).trim();
|
|
216
|
+
const item = {};
|
|
217
|
+
arr.push(item);
|
|
218
|
+
if (v === '' || v === '~') {
|
|
219
|
+
item[k] = {};
|
|
220
|
+
} else {
|
|
221
|
+
item[k] = parseScalar(v);
|
|
222
|
+
}
|
|
223
|
+
// Subsequent deeper-indent lines that describe this item start at
|
|
224
|
+
// indent + 2 (after "- "). Push a frame at indent + 2 whose
|
|
225
|
+
// container is the new item, with pendingKey = k.
|
|
226
|
+
// `fromListItem` tells the pop rule that sibling keys at the same
|
|
227
|
+
// indent are continuations of this list entry, not outdent siblings.
|
|
228
|
+
stack.push({
|
|
229
|
+
indent: indent + 2,
|
|
230
|
+
container: item,
|
|
231
|
+
pendingKey: k,
|
|
232
|
+
pendingKeyIndent: indent + 2,
|
|
233
|
+
fromListItem: true,
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Plain `key: value` line. First descend if we're in a deeper block
|
|
240
|
+
// than the top frame and top has a pendingKey container.
|
|
241
|
+
descendIfDeeper(indent);
|
|
242
|
+
const top = stack[stack.length - 1];
|
|
243
|
+
const colon = findTopLevelColon(rest);
|
|
244
|
+
if (colon === -1) continue;
|
|
245
|
+
const key = unquoteKey(rest.slice(0, colon).trim());
|
|
246
|
+
const value = rest.slice(colon + 1).trim();
|
|
247
|
+
if (value === '' || value === '~') {
|
|
248
|
+
top.container[key] = {};
|
|
249
|
+
top.pendingKey = key;
|
|
250
|
+
top.pendingKeyIndent = indent;
|
|
251
|
+
} else if (value === '[]') {
|
|
252
|
+
top.container[key] = [];
|
|
253
|
+
top.pendingKey = key;
|
|
254
|
+
top.pendingKeyIndent = indent;
|
|
255
|
+
} else {
|
|
256
|
+
top.container[key] = parseScalar(value);
|
|
257
|
+
top.pendingKey = key;
|
|
258
|
+
top.pendingKeyIndent = indent;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
return root;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function unquoteKey(k) {
|
|
265
|
+
if ((k.startsWith('"') && k.endsWith('"')) || (k.startsWith("'") && k.endsWith("'"))) {
|
|
266
|
+
try {
|
|
267
|
+
return k.startsWith('"') ? JSON.parse(k) : k.slice(1, -1);
|
|
268
|
+
} catch {
|
|
269
|
+
return k.slice(1, -1);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
return k;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function findTopLevelColon(s) {
|
|
276
|
+
let quote = null;
|
|
277
|
+
for (let i = 0; i < s.length; i++) {
|
|
278
|
+
const c = s[i];
|
|
279
|
+
if (quote) {
|
|
280
|
+
if (c === '\\') {
|
|
281
|
+
i++;
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
if (c === quote) quote = null;
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
if (c === '"' || c === "'") {
|
|
288
|
+
quote = c;
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
if (c === ':') return i;
|
|
292
|
+
}
|
|
293
|
+
return -1;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// ------------------------------------------------------------------
|
|
297
|
+
// Strategy layer
|
|
298
|
+
// ------------------------------------------------------------------
|
|
299
|
+
|
|
300
|
+
function edgesFromExplicit(depsDoc, nodes) {
|
|
301
|
+
if (!depsDoc) return [];
|
|
302
|
+
const out = [];
|
|
303
|
+
const nodeSet = new Set(nodes);
|
|
304
|
+
if (depsDoc.stories && typeof depsDoc.stories === 'object' && !Array.isArray(depsDoc.stories)) {
|
|
305
|
+
for (const key of Object.keys(depsDoc.stories)) {
|
|
306
|
+
const entry = depsDoc.stories[key];
|
|
307
|
+
const deps = entry && entry.depends_on;
|
|
308
|
+
if (!Array.isArray(deps)) continue;
|
|
309
|
+
for (const dep of deps) {
|
|
310
|
+
if (!nodeSet.has(dep) || !nodeSet.has(key)) continue;
|
|
311
|
+
out.push([dep, key]);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
// Apply overrides.force_sequential — linear chain among the listed keys.
|
|
316
|
+
if (Array.isArray(depsDoc.overrides)) {
|
|
317
|
+
for (const ov of depsDoc.overrides) {
|
|
318
|
+
if (!ov) continue;
|
|
319
|
+
if (Array.isArray(ov.force_sequential)) {
|
|
320
|
+
const seq = ov.force_sequential.filter((k) => nodeSet.has(k));
|
|
321
|
+
for (let i = 1; i < seq.length; i++) out.push([seq[i - 1], seq[i]]);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
return out;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function edgesFromOrdering(nodes) {
|
|
329
|
+
const out = [];
|
|
330
|
+
for (let i = 1; i < nodes.length; i++) out.push([nodes[i - 1], nodes[i]]);
|
|
331
|
+
return out;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function applyForceIndependent(edges, depsDoc) {
|
|
335
|
+
if (!depsDoc || !Array.isArray(depsDoc.overrides)) return edges;
|
|
336
|
+
const indep = new Set();
|
|
337
|
+
for (const ov of depsDoc.overrides) {
|
|
338
|
+
if (ov && Array.isArray(ov.force_independent)) {
|
|
339
|
+
for (const k of ov.force_independent) indep.add(k);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
if (indep.size === 0) return edges;
|
|
343
|
+
return edges.filter(([a, b]) => !(indep.has(a) || indep.has(b)));
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function buildEdges(strategies, nodes, depsDoc) {
|
|
347
|
+
// explicit > ordering. Dedupe while preserving priority insertion order.
|
|
348
|
+
const seen = new Set();
|
|
349
|
+
const out = [];
|
|
350
|
+
const pushEdges = (edges) => {
|
|
351
|
+
for (const [a, b] of edges) {
|
|
352
|
+
const key = `${a}${b}`;
|
|
353
|
+
if (seen.has(key)) continue;
|
|
354
|
+
seen.add(key);
|
|
355
|
+
out.push([a, b]);
|
|
356
|
+
}
|
|
357
|
+
};
|
|
358
|
+
for (const strat of strategies) {
|
|
359
|
+
if (strat === 'explicit') pushEdges(edgesFromExplicit(depsDoc, nodes));
|
|
360
|
+
else if (strat === 'ordering') pushEdges(edgesFromOrdering(nodes));
|
|
361
|
+
else if (strat === 'files') {
|
|
362
|
+
// files strategy is opt-in and not implemented in the PR 9 scope.
|
|
363
|
+
// A future sprintpilot-infer-dependencies skill can populate the
|
|
364
|
+
// explicit sidecar instead.
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
// Respect force_independent last so it removes matches from both strategies.
|
|
368
|
+
return applyForceIndependent(out, depsDoc);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// ------------------------------------------------------------------
|
|
372
|
+
// Topological sort (Kahn's) + cycle detection
|
|
373
|
+
// ------------------------------------------------------------------
|
|
374
|
+
|
|
375
|
+
function topoLayers(nodes, edges) {
|
|
376
|
+
const nodeSet = new Set(nodes);
|
|
377
|
+
const inbound = new Map(nodes.map((n) => [n, 0]));
|
|
378
|
+
const adj = new Map(nodes.map((n) => [n, []]));
|
|
379
|
+
for (const [a, b] of edges) {
|
|
380
|
+
if (!nodeSet.has(a) || !nodeSet.has(b)) continue;
|
|
381
|
+
adj.get(a).push(b);
|
|
382
|
+
inbound.set(b, (inbound.get(b) || 0) + 1);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const layers = [];
|
|
386
|
+
const placed = new Set();
|
|
387
|
+
let frontier = nodes.filter((n) => inbound.get(n) === 0);
|
|
388
|
+
while (frontier.length > 0) {
|
|
389
|
+
// Deterministic ordering within a layer.
|
|
390
|
+
frontier.sort();
|
|
391
|
+
layers.push(frontier.slice());
|
|
392
|
+
const next = [];
|
|
393
|
+
for (const n of frontier) {
|
|
394
|
+
placed.add(n);
|
|
395
|
+
for (const m of adj.get(n) || []) {
|
|
396
|
+
inbound.set(m, inbound.get(m) - 1);
|
|
397
|
+
if (inbound.get(m) === 0) next.push(m);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
frontier = next;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
const unplaced = nodes.filter((n) => !placed.has(n));
|
|
404
|
+
return { layers, cycle: unplaced };
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// ------------------------------------------------------------------
|
|
408
|
+
// Commands
|
|
409
|
+
// ------------------------------------------------------------------
|
|
410
|
+
|
|
411
|
+
function parseStrategies(raw) {
|
|
412
|
+
const list = (raw || DEFAULT_STRATEGIES.join(','))
|
|
413
|
+
.split(',')
|
|
414
|
+
.map((s) => s.trim())
|
|
415
|
+
.filter(Boolean);
|
|
416
|
+
for (const s of list) {
|
|
417
|
+
if (!VALID_STRATEGIES.includes(s)) {
|
|
418
|
+
return { ok: false, error: `unknown strategy '${s}'. Valid: ${VALID_STRATEGIES.join(', ')}` };
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
return { ok: true, value: list };
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function buildDag({ projectRoot, epic, strategies }) {
|
|
425
|
+
const { ordered } = readStoriesFromStatus(projectRoot, epic);
|
|
426
|
+
if (ordered.length === 0) {
|
|
427
|
+
return { nodes: [], edges: [], layers: [], width: 0, cycle: [], epic };
|
|
428
|
+
}
|
|
429
|
+
const depsDoc = readDependencies(projectRoot);
|
|
430
|
+
const edges = buildEdges(strategies, ordered, depsDoc);
|
|
431
|
+
const { layers, cycle } = topoLayers(ordered, edges);
|
|
432
|
+
const width = layers.reduce((m, l) => Math.max(m, l.length), 0);
|
|
433
|
+
return { nodes: ordered, edges, layers, width, cycle, epic };
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function scaffoldDependenciesYaml(projectRoot, epic, { force = false } = {}) {
|
|
437
|
+
const file = dependenciesPath(projectRoot);
|
|
438
|
+
if (fs.existsSync(file) && !force) {
|
|
439
|
+
return { wrote: false, reason: 'exists', file };
|
|
440
|
+
}
|
|
441
|
+
const { ordered } = readStoriesFromStatus(projectRoot, epic);
|
|
442
|
+
if (ordered.length === 0) {
|
|
443
|
+
return { wrote: false, reason: 'no-stories', file };
|
|
444
|
+
}
|
|
445
|
+
const doc = {
|
|
446
|
+
version: 1,
|
|
447
|
+
stories: {},
|
|
448
|
+
overrides: [
|
|
449
|
+
{
|
|
450
|
+
epic: epic || 'unknown',
|
|
451
|
+
force_independent: [],
|
|
452
|
+
force_sequential: [],
|
|
453
|
+
},
|
|
454
|
+
],
|
|
455
|
+
epics: {},
|
|
456
|
+
};
|
|
457
|
+
// Linear chain by default.
|
|
458
|
+
for (let i = 0; i < ordered.length; i++) {
|
|
459
|
+
doc.stories[ordered[i]] = { depends_on: i === 0 ? [] : [ordered[i - 1]] };
|
|
460
|
+
}
|
|
461
|
+
doc.epics[epic || 'unknown'] = { independent: false };
|
|
462
|
+
|
|
463
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
464
|
+
const header = [
|
|
465
|
+
'# Sprintpilot dependency sidecar.',
|
|
466
|
+
'# Authoritative input to resolve-dag.js for parallel execution (PR 11+).',
|
|
467
|
+
'# BMad never reads this file — it is Sprintpilot-owned.',
|
|
468
|
+
'#',
|
|
469
|
+
'# Schema:',
|
|
470
|
+
'# stories.<key>.depends_on: [<key>, ...] — edges: dep → key',
|
|
471
|
+
'# overrides[*].force_sequential: [...] — serialize listed keys',
|
|
472
|
+
'# overrides[*].force_independent: [...] — drop inbound edges on listed keys',
|
|
473
|
+
'# epics.<id>.independent: true — enable cross-epic parallelism (PR 12)',
|
|
474
|
+
'#',
|
|
475
|
+
'# Safe starting point: a linear chain (below). Uncomment force_independent',
|
|
476
|
+
'# entries to unlock parallel layers.',
|
|
477
|
+
'',
|
|
478
|
+
].join('\n');
|
|
479
|
+
const body = header + yamlDump(doc) + '\n';
|
|
480
|
+
fs.writeFileSync(file, body);
|
|
481
|
+
return { wrote: true, file };
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function main() {
|
|
485
|
+
const { opts, positional } = parseArgs(process.argv.slice(2), { booleanFlags: ['force'] });
|
|
486
|
+
if (opts.help || positional.length === 0) {
|
|
487
|
+
help();
|
|
488
|
+
process.exit(opts.help ? 0 : 1);
|
|
489
|
+
}
|
|
490
|
+
const command = positional[0];
|
|
491
|
+
if (!VALID_COMMANDS.includes(command)) {
|
|
492
|
+
log.error(`unknown command '${command}'. Valid: ${VALID_COMMANDS.join(', ')}`);
|
|
493
|
+
process.exit(1);
|
|
494
|
+
}
|
|
495
|
+
const projectRoot = opts['project-root'] || process.cwd();
|
|
496
|
+
const epic = opts.epic !== undefined ? String(opts.epic) : null;
|
|
497
|
+
|
|
498
|
+
if (command === 'scaffold') {
|
|
499
|
+
if (!epic) {
|
|
500
|
+
log.error('scaffold requires --epic');
|
|
501
|
+
process.exit(1);
|
|
502
|
+
}
|
|
503
|
+
const res = scaffoldDependenciesYaml(projectRoot, epic, { force: opts.force === true });
|
|
504
|
+
if (!res.wrote) {
|
|
505
|
+
log.error(`scaffold: ${res.reason}; use --force to overwrite`);
|
|
506
|
+
process.exit(res.reason === 'exists' ? 2 : 1);
|
|
507
|
+
}
|
|
508
|
+
process.stdout.write(`${JSON.stringify(res)}\n`);
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
const strat = parseStrategies(opts.strategy);
|
|
513
|
+
if (!strat.ok) {
|
|
514
|
+
log.error(strat.error);
|
|
515
|
+
process.exit(1);
|
|
516
|
+
}
|
|
517
|
+
const dag = buildDag({ projectRoot, epic, strategies: strat.value });
|
|
518
|
+
|
|
519
|
+
if (dag.cycle.length > 0) {
|
|
520
|
+
log.error(`cycle detected: ${dag.cycle.join(', ')}`);
|
|
521
|
+
process.exit(1);
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
if (command === 'graph') {
|
|
525
|
+
process.stdout.write(`${JSON.stringify({ nodes: dag.nodes, edges: dag.edges, epic: dag.epic })}\n`);
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
if (command === 'layers') {
|
|
529
|
+
process.stdout.write(`${JSON.stringify(dag.layers)}\n`);
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
if (command === 'width') {
|
|
533
|
+
process.stdout.write(`${dag.width}\n`);
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
module.exports = {
|
|
538
|
+
DEFAULT_STRATEGIES,
|
|
539
|
+
VALID_STRATEGIES,
|
|
540
|
+
VALID_COMMANDS,
|
|
541
|
+
parseEpicFromKey,
|
|
542
|
+
parseStrategies,
|
|
543
|
+
readStoriesFromStatus,
|
|
544
|
+
readDependencies,
|
|
545
|
+
parseDependenciesYaml,
|
|
546
|
+
edgesFromExplicit,
|
|
547
|
+
edgesFromOrdering,
|
|
548
|
+
applyForceIndependent,
|
|
549
|
+
buildEdges,
|
|
550
|
+
topoLayers,
|
|
551
|
+
buildDag,
|
|
552
|
+
scaffoldDependenciesYaml,
|
|
553
|
+
sprintStatusPath,
|
|
554
|
+
dependenciesPath,
|
|
555
|
+
};
|
|
556
|
+
|
|
557
|
+
if (require.main === module) {
|
|
558
|
+
main();
|
|
559
|
+
}
|