@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.
|
@@ -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
|
|
13
|
-
// marker file (.timings/.mark
|
|
14
|
-
// since the previous mark
|
|
15
|
-
// PREVIOUS phase, and writes a new
|
|
16
|
-
// phase. Designed for LLM-driven
|
|
17
|
-
// may forget to call `end` after a
|
|
18
|
-
// needs to be called ONCE per phase
|
|
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
|
-
//
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
295
|
-
* flag in the entry so aggregators don't get
|
|
296
|
-
*
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
438
|
+
MAX_PLAUSIBLE_DURATION_MS,
|
|
406
439
|
VALID_ACTIONS,
|
|
407
440
|
validateStory,
|
|
408
441
|
validatePhase,
|
package/package.json
CHANGED