@ikunin/sprintpilot 2.0.5 → 2.0.7

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.
@@ -1,6 +1,6 @@
1
1
  addon:
2
2
  name: sprintpilot
3
- version: 2.0.5
3
+ version: 2.0.7
4
4
  description: Sprintpilot — autopilot and multi-agent addon for BMad Method (git workflow, parallel agents, autonomous story execution)
5
5
  bmad_compatibility: ">=6.2.0"
6
6
  modules:
@@ -222,6 +222,11 @@ function main() {
222
222
  const acSectionName = opts['ac-section'] || 'Acceptance Criteria';
223
223
  const projectRoot = opts['project-root'] || process.cwd();
224
224
  const storyKey = storyKeyFromFile(storyFile);
225
+ if (storyKey === null && timing.isEnabled(projectRoot)) {
226
+ log.error(
227
+ `inject-tasks-section: cannot derive a STORY_RE-compatible key from '${path.basename(storyFile)}' (must lower-case to /^[a-z0-9][a-z0-9-]*$/ after stripping leading 'story-' and trailing '.md'); skipping timing emit`,
228
+ );
229
+ }
225
230
 
226
231
  const body = fs.readFileSync(storyFile, 'utf8');
227
232
  const info = inspectTasksSection(body);
@@ -9,13 +9,16 @@
9
9
  // start Emit {event:"start", story, phase, ts:<iso8601>}
10
10
  // end Emit {event:"end", story, phase, ts:<iso8601>}
11
11
  // once Emit a single-event marker (for things like health-check-run)
12
- // mark Single-call replacement for start/end pairs. Reads a tiny
13
- // marker file (.timings/.mark.json), computes the duration
14
- // since the previous mark, emits one duration record for the
15
- // PREVIOUS phase, and writes a new marker for the current
16
- // phase. Designed for LLM-driven workflows where the agent
17
- // may forget to call `end` after a long skill — `mark` only
18
- // needs to be called ONCE per phase transition.
12
+ // mark Single-call replacement for start/end pairs. Reads a per-story
13
+ // marker file (.timings/.mark.<story>.json), computes the duration
14
+ // since the previous mark for the same story key, emits one
15
+ // duration record for the PREVIOUS phase, and writes a new
16
+ // marker for the current phase. Designed for LLM-driven
17
+ // workflows where the agent may forget to call `end` after a
18
+ // long skill — `mark` only needs to be called ONCE per phase
19
+ // transition. Per-story markers (added in 2.0.5) make
20
+ // concurrent sub-agents marking different stories race-free
21
+ // against the same project root.
19
22
  //
20
23
  // Output path:
21
24
  // <project-root>/_bmad-output/implementation-artifacts/.timings/<story>.jsonl
@@ -43,14 +46,19 @@ const PHASE_RE = /^[a-z][a-z0-9-.]*$/;
43
46
  const META_MAX_BYTES = 2048;
44
47
  const LINE_MAX_BYTES = 4096; // POSIX PIPE_BUF floor — single write() is atomic
45
48
  const VALID_ACTIONS = ['start', 'end', 'once', 'mark'];
46
- // MARKER_FILE: per-story marker file template. The actual file is
47
- // `.mark.<story>.json` so concurrent writers for different stories never
48
- // race on the same path. Pre-2.0.5 used a single global `.mark.json`
49
- // which corrupted timing data when sub-agents in the same project root
50
- // marked phases concurrently (e.g. parallel story dispatch). Kept as
51
- // `.mark.json` only as a back-compat constant; runtime always uses the
52
- // per-story path via `markerPath(root, story)`.
53
- const MARKER_FILE = '.mark.json';
49
+ // Marker filenames are `.mark.<story>.json` built by `markerPath()`.
50
+ // Pre-2.0.5 used a single global `.mark.json`, which corrupted timing
51
+ // data under parallel dispatch (concurrent sub-agents racing on one
52
+ // rename target). The constant is gone; runtime always uses per-story
53
+ // paths.
54
+ //
55
+ // Sanity ceiling for a single duration record. Phase durations longer
56
+ // than this are treated as overflow (likely a forgotten _end across
57
+ // sessions or a long-paused autopilot run) and clamped to 0 with
58
+ // `over_threshold: true` stamped. 7 days chosen so legitimate
59
+ // weekend-spanning sprint-level phases (sprint, dispatch.layer-X) are
60
+ // preserved; only genuinely stale markers get clamped.
61
+ const MAX_PLAUSIBLE_DURATION_MS = 7 * 24 * 60 * 60 * 1000;
54
62
 
55
63
  function help() {
56
64
  log.out(
@@ -215,21 +223,37 @@ function readMarker(projectRoot, story) {
215
223
  log.error(`timing marker read failed (${file}): ${e.message}`);
216
224
  return null;
217
225
  }
226
+ let parsed;
218
227
  try {
219
- const parsed = JSON.parse(raw);
220
- if (
221
- parsed &&
222
- typeof parsed === 'object' &&
223
- typeof parsed.story === 'string' &&
224
- typeof parsed.phase === 'string' &&
225
- typeof parsed.ts === 'string'
226
- ) {
227
- return parsed;
228
- }
228
+ parsed = JSON.parse(raw);
229
229
  } catch (e) {
230
230
  log.error(`timing marker corrupt (${file}): ${e.message} — treating as absent`);
231
+ return null;
232
+ }
233
+ if (
234
+ !parsed ||
235
+ typeof parsed !== 'object' ||
236
+ typeof parsed.story !== 'string' ||
237
+ typeof parsed.phase !== 'string' ||
238
+ typeof parsed.ts !== 'string'
239
+ ) {
240
+ return null;
241
+ }
242
+ // Re-validate `story` and `phase` against their regexes. CLI input is
243
+ // already validated, but a corrupted/hand-edited marker could carry a
244
+ // path-traversing story (e.g. "../../etc") — `parsed.story` flows into
245
+ // `appendLine(projectRoot, prev.story, ...)` which path.joins to
246
+ // `<timingsDir>/<story>.jsonl`. Defense-in-depth: refuse any value that
247
+ // doesn't match STORY_RE / PHASE_RE.
248
+ if (!STORY_RE.test(parsed.story)) {
249
+ log.error(`timing marker (${file}) has invalid story '${parsed.story}'; treating as absent`);
250
+ return null;
251
+ }
252
+ if (parsed.phase !== '_end' && !PHASE_RE.test(parsed.phase)) {
253
+ log.error(`timing marker (${file}) has invalid phase '${parsed.phase}'; treating as absent`);
254
+ return null;
231
255
  }
232
- return null;
256
+ return parsed;
233
257
  }
234
258
 
235
259
  function writeMarker(projectRoot, story, marker) {
@@ -291,9 +315,10 @@ function clearMarker(projectRoot, story) {
291
315
  * duration record but the next mark will read the new marker (not the
292
316
  * stale prev) and won't double-count.
293
317
  *
294
- * Wall-clock skew: durations are clamped at 0 with a `clock_skew: true`
295
- * flag in the entry so aggregators don't get poisoned by NTP backsteps
296
- * or DST transitions.
318
+ * Wall-clock skew: durations are clamped to [0, MAX_PLAUSIBLE_DURATION_MS]
319
+ * with a `clock_skew: true` flag in the entry so aggregators don't get
320
+ * poisoned by NTP backsteps, DST transitions, or container clock skips
321
+ * forward of unrealistic magnitudes (e.g. "this skill ran for 7 hours").
297
322
  *
298
323
  * Returns { duration_ms, prev_phase } so callers can log/inspect.
299
324
  */
@@ -312,7 +337,14 @@ function markPhase(projectRoot, story, phase, meta) {
312
337
  const prevTs = Date.parse(prev.ts);
313
338
  if (!Number.isNaN(prevTs)) {
314
339
  const rawDelta = now.getTime() - prevTs;
315
- durationMs = Math.max(0, rawDelta);
340
+ // Two distinct anomalies — flagged separately so consumers can
341
+ // treat them differently. clock_skew = wall-clock went backwards
342
+ // (NTP backstep, DST, manual change). over_threshold = elapsed
343
+ // time exceeds the sanity ceiling (likely a stale marker, not
344
+ // genuine clock skew). Both clamp duration_ms to 0.
345
+ const clockSkew = rawDelta < 0;
346
+ const overThreshold = rawDelta > MAX_PLAUSIBLE_DURATION_MS;
347
+ durationMs = clockSkew || overThreshold ? 0 : rawDelta;
316
348
  prevPhase = prev.phase;
317
349
  durationEntry = {
318
350
  event: 'duration',
@@ -322,7 +354,8 @@ function markPhase(projectRoot, story, phase, meta) {
322
354
  ended: now.toISOString(),
323
355
  duration_ms: durationMs,
324
356
  };
325
- if (rawDelta < 0) durationEntry.clock_skew = true;
357
+ if (clockSkew) durationEntry.clock_skew = true;
358
+ if (overThreshold) durationEntry.over_threshold = true;
326
359
  if (prev.meta !== undefined) durationEntry.meta = prev.meta;
327
360
  }
328
361
  }
@@ -402,7 +435,7 @@ module.exports = {
402
435
  PHASE_RE,
403
436
  META_MAX_BYTES,
404
437
  LINE_MAX_BYTES,
405
- MARKER_FILE,
438
+ MAX_PLAUSIBLE_DURATION_MS,
406
439
  VALID_ACTIONS,
407
440
  validateStory,
408
441
  validatePhase,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ikunin/sprintpilot",
3
- "version": "2.0.5",
3
+ "version": "2.0.7",
4
4
  "description": "Sprintpilot — autopilot and multi-agent addon for BMad Method v6: git workflow, parallel agents, autonomous story execution",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {