@ikunin/sprintpilot 2.0.7 → 2.0.9
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 +131 -185
- package/_Sprintpilot/manifest.yaml +1 -1
- package/_Sprintpilot/scripts/dispatch-layer.js +121 -16
- package/_Sprintpilot/scripts/log-timing.js +19 -9
- package/_Sprintpilot/scripts/merge-shards.js +252 -52
- package/_Sprintpilot/scripts/resolve-dag.js +55 -9
- package/_Sprintpilot/scripts/state-shard.js +26 -2
- package/_Sprintpilot/scripts/summarize-timings.js +84 -6
- package/_Sprintpilot/skills/sprint-autopilot-on/workflow.md +110 -142
- package/lib/commands/install.js +18 -2
- package/lib/core/bmad-config.js +11 -6
- package/package.json +1 -1
|
@@ -64,7 +64,18 @@ function dependenciesPath(projectRoot) {
|
|
|
64
64
|
}
|
|
65
65
|
|
|
66
66
|
function parseEpicFromKey(storyKey) {
|
|
67
|
-
|
|
67
|
+
// Epic id = first hyphen-delimited segment of the story key. BMad's
|
|
68
|
+
// canonical convention is `<epic-num>-<story-num>-<slug>` (e.g.
|
|
69
|
+
// `1-2-user-auth` → epic "1"), but nothing prevents a project from
|
|
70
|
+
// 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
|
+
const s = String(storyKey);
|
|
77
|
+
if (!s) return null;
|
|
78
|
+
const m = s.match(/^([A-Za-z0-9]+)(?:-|$)/);
|
|
68
79
|
return m ? m[1] : null;
|
|
69
80
|
}
|
|
70
81
|
|
|
@@ -72,27 +83,42 @@ function readStoriesFromStatus(projectRoot, epicFilter) {
|
|
|
72
83
|
const file = sprintStatusPath(projectRoot);
|
|
73
84
|
if (!fs.existsSync(file)) return { ordered: [], byKey: {} };
|
|
74
85
|
const raw = fs.readFileSync(file, 'utf8');
|
|
75
|
-
// Pull out story keys by scanning
|
|
76
|
-
//
|
|
86
|
+
// Pull out story keys by scanning indented `<key>:` lines under
|
|
87
|
+
// `development_status:` (BMad's canonical shape) and under `stories:`
|
|
77
88
|
// (alternate shape some projects use). We intentionally don't parse the
|
|
78
89
|
// 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).
|
|
79
96
|
const ordered = [];
|
|
80
97
|
const byKey = {};
|
|
81
98
|
const lines = raw.split(/\r?\n/);
|
|
82
99
|
let inStoriesBlock = false;
|
|
100
|
+
let storyIndent = null;
|
|
83
101
|
for (const rawLine of lines) {
|
|
84
102
|
const trimmed = rawLine.trimEnd();
|
|
85
103
|
if (/^(development_status|stories):\s*$/.test(trimmed)) {
|
|
86
104
|
inStoriesBlock = true;
|
|
105
|
+
storyIndent = null; // re-detect for each block
|
|
87
106
|
continue;
|
|
88
107
|
}
|
|
89
108
|
// Bail out of the stories block on a top-level key.
|
|
90
|
-
if (inStoriesBlock && /^\S/.test(trimmed))
|
|
109
|
+
if (inStoriesBlock && /^\S/.test(trimmed)) {
|
|
110
|
+
inStoriesBlock = false;
|
|
111
|
+
storyIndent = null;
|
|
112
|
+
}
|
|
91
113
|
if (!inStoriesBlock) continue;
|
|
92
|
-
const m = trimmed.match(/^
|
|
114
|
+
const m = trimmed.match(/^([\t ]+)([A-Za-z0-9][A-Za-z0-9-]*):\s*(\S+)?/);
|
|
93
115
|
if (!m) continue;
|
|
94
|
-
const
|
|
95
|
-
const
|
|
116
|
+
const indent = m[1];
|
|
117
|
+
const key = m[2];
|
|
118
|
+
const statusRaw = m[3];
|
|
119
|
+
if (storyIndent === null) storyIndent = indent;
|
|
120
|
+
else if (indent !== storyIndent) continue; // nested field at deeper indent
|
|
121
|
+
const status = statusRaw ? statusRaw.replace(/^["']|["']$/g, '') : null;
|
|
96
122
|
if (epicFilter !== null && parseEpicFromKey(key) !== epicFilter) continue;
|
|
97
123
|
if (!(key in byKey)) {
|
|
98
124
|
ordered.push(key);
|
|
@@ -317,7 +343,21 @@ function edgesFromExplicit(depsDoc, nodes) {
|
|
|
317
343
|
for (const ov of depsDoc.overrides) {
|
|
318
344
|
if (!ov) continue;
|
|
319
345
|
if (Array.isArray(ov.force_sequential)) {
|
|
320
|
-
|
|
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
|
+
const seen = new Set();
|
|
351
|
+
const seq = [];
|
|
352
|
+
for (const k of ov.force_sequential) {
|
|
353
|
+
if (!nodeSet.has(k)) continue;
|
|
354
|
+
if (seen.has(k)) {
|
|
355
|
+
log.warn(`force_sequential lists '${k}' more than once; ignoring duplicate`);
|
|
356
|
+
continue;
|
|
357
|
+
}
|
|
358
|
+
seen.add(k);
|
|
359
|
+
seq.push(k);
|
|
360
|
+
}
|
|
321
361
|
for (let i = 1; i < seq.length; i++) out.push([seq[i - 1], seq[i]]);
|
|
322
362
|
}
|
|
323
363
|
}
|
|
@@ -340,7 +380,13 @@ function applyForceIndependent(edges, depsDoc) {
|
|
|
340
380
|
}
|
|
341
381
|
}
|
|
342
382
|
if (indep.size === 0) return edges;
|
|
343
|
-
|
|
383
|
+
// Drop INBOUND edges only — `force_independent: [b]` means "let b run
|
|
384
|
+
// 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.
|
|
389
|
+
return edges.filter(([_a, b]) => !indep.has(b));
|
|
344
390
|
}
|
|
345
391
|
|
|
346
392
|
function buildEdges(strategies, nodes, depsDoc) {
|
|
@@ -87,8 +87,32 @@ function validateKind(k) {
|
|
|
87
87
|
return { ok: true, value: kind };
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
+
// Read BMad's `output_folder` config so a project that customized its
|
|
91
|
+
// output location doesn't end up with shards split between
|
|
92
|
+
// `_bmad-output/` (writer hardcoded) and `<output_folder>/` (reader
|
|
93
|
+
// honoring config). Pre-2.0.8 this script ignored output_folder
|
|
94
|
+
// entirely, contradicting sibling scripts (mark-done-stories-tasks.js)
|
|
95
|
+
// that did read it.
|
|
96
|
+
function readOutputFolder(projectRoot) {
|
|
97
|
+
const cfg = path.join(projectRoot, '_bmad', 'bmm', 'config.yaml');
|
|
98
|
+
if (!fs.existsSync(cfg)) return null;
|
|
99
|
+
try {
|
|
100
|
+
const body = fs.readFileSync(cfg, 'utf8');
|
|
101
|
+
const m = body.match(/^output_folder\s*:\s*(\S+)/m);
|
|
102
|
+
if (!m) return null;
|
|
103
|
+
return m[1].replace(/^["']|["']$/g, '').trim();
|
|
104
|
+
} catch {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function implArtifactsDir(projectRoot) {
|
|
110
|
+
const folder = readOutputFolder(projectRoot) || '_bmad-output';
|
|
111
|
+
return path.join(projectRoot, folder, 'implementation-artifacts');
|
|
112
|
+
}
|
|
113
|
+
|
|
90
114
|
function shardDir(projectRoot, kind) {
|
|
91
|
-
return path.join(projectRoot,
|
|
115
|
+
return path.join(implArtifactsDir(projectRoot), KIND_DIR[kind]);
|
|
92
116
|
}
|
|
93
117
|
|
|
94
118
|
function shardPath(projectRoot, story, kind) {
|
|
@@ -349,7 +373,7 @@ function listShardStories(projectRoot, kind) {
|
|
|
349
373
|
// --------------------------------------------------------------------------
|
|
350
374
|
|
|
351
375
|
function pendingDir(projectRoot, kind) {
|
|
352
|
-
return path.join(projectRoot,
|
|
376
|
+
return path.join(implArtifactsDir(projectRoot), PENDING_DIR, KIND_DIR[kind]);
|
|
353
377
|
}
|
|
354
378
|
|
|
355
379
|
function pendingPath(projectRoot, story, kind) {
|
|
@@ -8,11 +8,17 @@
|
|
|
8
8
|
//
|
|
9
9
|
// Behavior:
|
|
10
10
|
// Reads every .jsonl file under _bmad-output/implementation-artifacts/
|
|
11
|
-
// .timings/.
|
|
11
|
+
// .timings/. Two event shapes contribute to phase aggregates:
|
|
12
|
+
// - start/end pairs (matched LIFO by (story, phase))
|
|
13
|
+
// - duration records emitted by the `mark` API (already-paired)
|
|
14
|
+
// Records flagged with `clock_skew` or `over_threshold` are excluded
|
|
15
|
+
// from p50/p95/max so anomalies don't poison the distribution; they
|
|
16
|
+
// are counted separately in the anomalies section.
|
|
12
17
|
// Computes:
|
|
13
18
|
// - Wall-clock per story (min-start to max-end)
|
|
14
19
|
// - Per-phase aggregates: count, sum_ms, p50, p95, max
|
|
15
20
|
// - Hotspots: phases whose sum_ms consumes > 5% of total paired time
|
|
21
|
+
// - Anomalies: per-phase clock_skew / over_threshold counts
|
|
16
22
|
//
|
|
17
23
|
// Output:
|
|
18
24
|
// --format text (default) → stdout, human-readable table
|
|
@@ -82,16 +88,28 @@ function pairEvents(events) {
|
|
|
82
88
|
// Returns { stories: { [story]: { first, last, phases: { [phase]: number[] } } },
|
|
83
89
|
// phaseAgg: { [phase]: number[] },
|
|
84
90
|
// onceCount: { [phase]: number },
|
|
91
|
+
// anomalies: { [phase]: { clock_skew: number, over_threshold: number } },
|
|
85
92
|
// orphans: [{story, phase, event, ts}] }
|
|
86
93
|
const stories = {};
|
|
87
94
|
const phaseAgg = {};
|
|
88
95
|
const onceCount = {};
|
|
96
|
+
const anomalies = {};
|
|
89
97
|
const openByStoryPhase = {}; // key = story::phase → stack of start ms
|
|
90
98
|
|
|
91
99
|
const ensureStory = (s) => {
|
|
92
100
|
if (!stories[s]) stories[s] = { first: null, last: null, phases: {} };
|
|
93
101
|
return stories[s];
|
|
94
102
|
};
|
|
103
|
+
const recordAnomaly = (phase, kind) => {
|
|
104
|
+
if (!anomalies[phase]) anomalies[phase] = { clock_skew: 0, over_threshold: 0 };
|
|
105
|
+
anomalies[phase][kind] += 1;
|
|
106
|
+
};
|
|
107
|
+
const recordDuration = (s, phase, duration) => {
|
|
108
|
+
if (!s.phases[phase]) s.phases[phase] = [];
|
|
109
|
+
s.phases[phase].push(duration);
|
|
110
|
+
if (!phaseAgg[phase]) phaseAgg[phase] = [];
|
|
111
|
+
phaseAgg[phase].push(duration);
|
|
112
|
+
};
|
|
95
113
|
|
|
96
114
|
for (const ev of events) {
|
|
97
115
|
const s = ensureStory(ev.story);
|
|
@@ -114,10 +132,43 @@ function pairEvents(events) {
|
|
|
114
132
|
const startMs = stack.pop();
|
|
115
133
|
const duration = ev._ms - startMs;
|
|
116
134
|
if (duration < 0) continue;
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
135
|
+
recordDuration(s, ev.phase, duration);
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
if (ev.event === 'duration') {
|
|
139
|
+
// mark-API records arrive already paired. Anomalous records
|
|
140
|
+
// (clock_skew / over_threshold) are tallied separately and never
|
|
141
|
+
// contribute to p50/p95/max — otherwise a single backstep or
|
|
142
|
+
// stale marker poisons aggregates the way the v2.0.4 raw clamp
|
|
143
|
+
// did before the split. duration_ms must be a finite non-negative
|
|
144
|
+
// number; defensive against hand-edited shards.
|
|
145
|
+
//
|
|
146
|
+
// Truthy comparison (not `=== true`) so a hand-edited shard with
|
|
147
|
+
// `clock_skew: 1` or any other truthy value is still recognized
|
|
148
|
+
// as an anomaly — symmetric with the defensive number check
|
|
149
|
+
// below.
|
|
150
|
+
//
|
|
151
|
+
// Mutually exclusive tally: per markPhase's contract, one record
|
|
152
|
+
// can only carry ONE flag (rawDelta is either negative OR exceeds
|
|
153
|
+
// the ceiling, never both). If a hand-edited shard carries both,
|
|
154
|
+
// we count clock_skew first since "the clock did something
|
|
155
|
+
// weird" subsumes "duration looked too long".
|
|
156
|
+
const skew = Boolean(ev.clock_skew);
|
|
157
|
+
const over = Boolean(ev.over_threshold);
|
|
158
|
+
if (skew) recordAnomaly(ev.phase, 'clock_skew');
|
|
159
|
+
else if (over) recordAnomaly(ev.phase, 'over_threshold');
|
|
160
|
+
if (skew || over) continue;
|
|
161
|
+
const d = ev.duration_ms;
|
|
162
|
+
if (typeof d !== 'number' || !Number.isFinite(d) || d < 0) continue;
|
|
163
|
+
// Wall-clock attribution: a duration record's `_ms` is the time
|
|
164
|
+
// the phase ENDED (when markPhase emitted the record). To make
|
|
165
|
+
// per-story `wall_ms` meaningful for mark-only stories, expand
|
|
166
|
+
// `s.first` backward by the recorded duration so `first` reflects
|
|
167
|
+
// the actual phase start. Without this, a story with a single
|
|
168
|
+
// mark record has `wall_ms = 0`.
|
|
169
|
+
const phaseStart = ev._ms - d;
|
|
170
|
+
if (s.first === null || phaseStart < s.first) s.first = phaseStart;
|
|
171
|
+
recordDuration(s, ev.phase, d);
|
|
121
172
|
}
|
|
122
173
|
}
|
|
123
174
|
|
|
@@ -129,7 +180,7 @@ function pairEvents(events) {
|
|
|
129
180
|
}
|
|
130
181
|
}
|
|
131
182
|
|
|
132
|
-
return { stories, phaseAgg, onceCount, orphans };
|
|
183
|
+
return { stories, phaseAgg, onceCount, anomalies, orphans };
|
|
133
184
|
}
|
|
134
185
|
|
|
135
186
|
function percentile(sorted, p) {
|
|
@@ -177,6 +228,7 @@ function aggregate(paired) {
|
|
|
177
228
|
hotspots,
|
|
178
229
|
stories,
|
|
179
230
|
once_markers: paired.onceCount,
|
|
231
|
+
anomalies: paired.anomalies,
|
|
180
232
|
orphans: paired.orphans,
|
|
181
233
|
};
|
|
182
234
|
}
|
|
@@ -234,6 +286,17 @@ function renderText(report) {
|
|
|
234
286
|
lines.push(` ${phase} ×${count}`);
|
|
235
287
|
}
|
|
236
288
|
}
|
|
289
|
+
const anomalyEntries = Object.entries(report.anomalies || {});
|
|
290
|
+
if (anomalyEntries.length > 0) {
|
|
291
|
+
lines.push('');
|
|
292
|
+
lines.push('Anomalies (excluded from p50/p95/max):');
|
|
293
|
+
for (const [phase, counts] of anomalyEntries) {
|
|
294
|
+
const parts = [];
|
|
295
|
+
if (counts.clock_skew > 0) parts.push(`clock_skew ×${counts.clock_skew}`);
|
|
296
|
+
if (counts.over_threshold > 0) parts.push(`over_threshold ×${counts.over_threshold}`);
|
|
297
|
+
lines.push(` ${phase} ${parts.join(' ')}`);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
237
300
|
if (report.orphans.length > 0) {
|
|
238
301
|
lines.push('');
|
|
239
302
|
lines.push(`Orphaned starts (no matching end): ${report.orphans.length}`);
|
|
@@ -290,6 +353,21 @@ function renderMarkdown(report) {
|
|
|
290
353
|
lines.push(`- \`${phase}\` ×${count}`);
|
|
291
354
|
}
|
|
292
355
|
}
|
|
356
|
+
const mdAnomalyEntries = Object.entries(report.anomalies || {}).filter(
|
|
357
|
+
([, c]) => c.clock_skew > 0 || c.over_threshold > 0,
|
|
358
|
+
);
|
|
359
|
+
if (mdAnomalyEntries.length > 0) {
|
|
360
|
+
lines.push('');
|
|
361
|
+
lines.push('## Anomalies');
|
|
362
|
+
lines.push('');
|
|
363
|
+
lines.push('_Excluded from p50/p95/max so a single skew/stale-marker doesn\'t poison aggregates._');
|
|
364
|
+
lines.push('');
|
|
365
|
+
lines.push('| Phase | clock_skew | over_threshold |');
|
|
366
|
+
lines.push('|---|---:|---:|');
|
|
367
|
+
for (const [phase, counts] of mdAnomalyEntries) {
|
|
368
|
+
lines.push(`| \`${phase}\` | ${counts.clock_skew} | ${counts.over_threshold} |`);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
293
371
|
if (report.orphans.length > 0) {
|
|
294
372
|
lines.push('');
|
|
295
373
|
lines.push(`_Orphaned starts (no matching end): ${report.orphans.length}_`);
|