@ikunin/sprintpilot 2.0.7 → 2.0.8

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.
@@ -64,7 +64,18 @@ function dependenciesPath(projectRoot) {
64
64
  }
65
65
 
66
66
  function parseEpicFromKey(storyKey) {
67
- const m = String(storyKey).match(/^(\d+)(?:-|$)/);
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 for two-space-indented `<key>:` lines
76
- // under `development_status:` (BMad's canonical shape) and under `stories:`
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)) inStoriesBlock = false;
109
+ if (inStoriesBlock && /^\S/.test(trimmed)) {
110
+ inStoriesBlock = false;
111
+ storyIndent = null;
112
+ }
91
113
  if (!inStoriesBlock) continue;
92
- const m = trimmed.match(/^ ([A-Za-z0-9][A-Za-z0-9-]*):\s*(\S+)?/);
114
+ const m = trimmed.match(/^([\t ]+)([A-Za-z0-9][A-Za-z0-9-]*):\s*(\S+)?/);
93
115
  if (!m) continue;
94
- const key = m[1];
95
- const status = m[2] ? m[2].replace(/^["']|["']$/g, '') : null;
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
- const seq = ov.force_sequential.filter((k) => nodeSet.has(k));
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
- return edges.filter(([a, b]) => !(indep.has(a) || indep.has(b)));
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, '_bmad-output', 'implementation-artifacts', KIND_DIR[kind]);
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, '_bmad-output', 'implementation-artifacts', PENDING_DIR, KIND_DIR[kind]);
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/. Pairs start/end events by (story, phase) in LIFO order.
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
- if (!s.phases[ev.phase]) s.phases[ev.phase] = [];
118
- s.phases[ev.phase].push(duration);
119
- if (!phaseAgg[ev.phase]) phaseAgg[ev.phase] = [];
120
- phaseAgg[ev.phase].push(duration);
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}_`);