@ikunin/sprintpilot 2.0.4 → 2.0.6

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 CHANGED
@@ -197,6 +197,32 @@ Output files:
197
197
 
198
198
  ---
199
199
 
200
+ ## Adaptive Process Scaling (v2)
201
+
202
+ Sprintpilot v2 introduced **complexity profiles** as a first-class config dimension. The right amount of process for a 2-story bug-fix sprint is different from a 30-story green-field rebuild — and the cost of running the heavy flow on a small change is real (more LLM turns, more context rot, more time). One knob picks the right balance:
203
+
204
+ | Profile | Per-story flow | Branching | Worktrees | Parallel stories | Use it for |
205
+ |---------|---------------|-----------|-----------|------------------|-----------|
206
+ | `nano` | `bmad-quick-dev` (one-shot) | `epic` (one PR per epic) | off | n/a | Tiny patch sprints, hot-fix runs |
207
+ | `small` | Full 7-step BMad cycle | `story` (one PR per story) | on | off | Single-developer projects, ≤10 stories |
208
+ | `medium` *(default)* | Full 7-step BMad cycle | `story` | on | off | Default — balanced for most sprints |
209
+ | `large` | Full 7-step BMad cycle | `story` | on | **on** (Claude Code) | Multi-epic sprints, 20+ stories |
210
+ | `legacy` | Pinned to v1.0.5 behavior byte-for-byte | `story` | on | off | Existing installs that want zero behavior change |
211
+
212
+ Pick the profile at install time — interactive installer asks, non-interactive flag is `--profile <nano|small|medium|large|legacy>`. Missing profile defaults to `medium` with no behavior change vs. v1.0.5.
213
+
214
+ **One knob per feature** — every v2 optimization layer can be disabled in isolation without uninstalling. See [Configuration Reference](docs/CONFIGURATION.md#autopilot-configuration-modulesautopilotconfigyaml).
215
+
216
+ ### What v2 ships on top of the core flow
217
+
218
+ - **Phase timing instrumentation** — `mark` action emits `duration` records per skill phase; auto-emitted on critical paths (no LLM bracket calls to skip). `summarize-timings.js` reports hotspots > 5% of total time.
219
+ - **State sharding** — non-critical writes accumulate in `.pending/` shards, flushed atomically at story boundaries / session checkpoints / sprint complete. Crash-recovery keys still write straight through.
220
+ - **Conditional boot work** — clean-repo sessions skip the slow health-check / branch-reconciliation block (saves 8–30s per session).
221
+ - **Cached reads** — TTL + source-mtime aware file cache; any writer's mtime advance forces a miss without explicit invalidate.
222
+ - **Auto-inferred story DAG** — autopilot infers inter-story dependencies once after `bmad-sprint-planning` and writes `_Sprintpilot/sprints/dependencies.yaml` with an `# AUTO-INFERRED` marker. Hand-authored files are detected and respected silently.
223
+ - **Parallel story dispatch** — when `parallel_stories: true` and the host supports it, layer-aware dispatch runs N stories concurrently in their own worktrees, then merges their state shards. Claude Code today; Gemini CLI experimentally.
224
+ - **Cross-platform** — every workflow.md call site runs under bash, zsh, Git Bash, PowerShell, and cmd. Portable Node.js helpers replace POSIX-shell idioms.
225
+
200
226
  ## Quick Start
201
227
 
202
228
  ```bash
@@ -215,9 +241,12 @@ npx bmad-method install
215
241
  ```
216
242
 
217
243
  ```bash
218
- # 2. Install Sprintpilot (interactive — select your tool when prompted)
244
+ # 2. Install Sprintpilot (interactive — select your tool and complexity profile when prompted)
219
245
  npx @ikunin/sprintpilot@latest
220
246
 
247
+ # 2b. Or pick the profile non-interactively
248
+ npx @ikunin/sprintpilot@latest install --tools claude-code --profile medium --yes
249
+
221
250
  # 3. Start the autopilot in your IDE
222
251
  /sprint-autopilot-on
223
252
  ```
@@ -298,6 +327,19 @@ All settings live in two YAML files — edit after install to customize behavior
298
327
  | `git.lock.stale_timeout_minutes` | `30` | Auto-remove orphaned lock files |
299
328
  | `git.worktree.cleanup_on_merge` | `true` | Delete worktrees after merge |
300
329
 
330
+ ### Autopilot (`_Sprintpilot/modules/autopilot/config.yaml`)
331
+
332
+ | Setting | Default (medium) | Description |
333
+ |---------|------------------|-------------|
334
+ | `complexity_profile` | `medium` | One of `nano`, `small`, `medium`, `large`, `legacy`. Selects the per-story flow + which v2 layers are enabled. |
335
+ | `autopilot.session_story_limit` | `3` (nano: `5`) | Stories per session before checkpoint. `0` = unlimited. |
336
+ | `autopilot.retrospective_mode` | `auto` | `auto` (deterministic artifact) / `stop` (pause for `/bmad-retrospective`) / `skip`. |
337
+ | `autopilot.auto_infer_dependencies` | `true` (nano + legacy: `false`) | Infer story DAG once after `bmad-sprint-planning`. Hand-authored sidecars (no `# AUTO-INFERRED` marker) are respected silently. |
338
+ | `autopilot.phase_timings` | `true` (legacy: `false`) | Emit phase duration records via `log-timing.js mark`. |
339
+ | `autopilot.coalesce_state_writes` | `true` (legacy: `false`) | Buffer non-critical state in `.pending/` shards. |
340
+ | `autopilot.conditional_boot_work` | `true` (large + legacy: `false`) | Skip health-check / branch-reconciliation on clean repos. |
341
+ | `autopilot.cache_shared_reads` | `true` (legacy: `false`) | TTL + mtime-aware file cache for hot reads. |
342
+
301
343
  ### Multi-Agent (`_Sprintpilot/modules/ma/config.yaml`)
302
344
 
303
345
  | Setting | Default | Description |
@@ -305,6 +347,11 @@ All settings live in two YAML files — edit after install to customize behavior
305
347
  | `multi_agent.enabled` | `true` | Enable parallel agent skills |
306
348
  | `multi_agent.max_parallel_research` | `3` | Concurrent research agents per batch |
307
349
  | `multi_agent.max_parallel_analysis` | `5` | Concurrent codebase analysis agents |
350
+ | `ma.state_sharding` | `auto` (large: `always`) | `auto`, `always`, `never` — shards per-story state instead of contending on root YAMLs. |
351
+ | `ma.parallel_stories` | `false` (large: `true`) | Dispatch independent stories from a DAG layer concurrently. Requires Claude Code (or Gemini CLI w/ experimental flag). |
352
+ | `ma.max_parallel_stories` | `2` (large: `3`) | Cap on concurrent stories per layer. |
353
+ | `ma.experimental_parallel_on_gemini` | `false` | Opt-in parallel dispatch under Gemini CLI (worktree-scoped subagents are still upstream). |
354
+ | `ma.parallel_epics` | `false` | EXPERIMENTAL — cross-epic parallelism with merge-conflict preflight. Off on every profile by default. |
308
355
 
309
356
  See the [Configuration Reference](docs/CONFIGURATION.md) for the full list.
310
357
 
@@ -1,6 +1,6 @@
1
1
  addon:
2
2
  name: sprintpilot
3
- version: 2.0.4
3
+ version: 2.0.6
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,7 +46,18 @@ 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
- 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. A wall-clock skip
56
+ // forward of more than this many ms is treated as clock skew rather
57
+ // than a real duration — clamped to 0 with `clock_skew: true` stamped.
58
+ // 24h chosen because no realistic skill phase is longer than that, and
59
+ // it's well above any plausible CI timeout.
60
+ const MAX_PLAUSIBLE_DURATION_MS = 24 * 60 * 60 * 1000;
47
61
 
48
62
  function help() {
49
63
  log.out(
@@ -191,43 +205,81 @@ function buildEntry(action, story, phase, meta) {
191
205
  // `mark` — single-call timing
192
206
  // ---------------------------------------------------------------
193
207
 
194
- function markerPath(projectRoot) {
195
- return path.join(timingsDir(projectRoot), MARKER_FILE);
208
+ function markerPath(projectRoot, story) {
209
+ if (!story) throw new Error('markerPath requires a story key');
210
+ return path.join(timingsDir(projectRoot), `.mark.${story}.json`);
196
211
  }
197
212
 
198
- function readMarker(projectRoot) {
199
- const file = markerPath(projectRoot);
200
- if (!fs.existsSync(file)) return null;
213
+ function readMarker(projectRoot, story) {
214
+ const file = markerPath(projectRoot, story);
215
+ let raw;
201
216
  try {
202
- const raw = fs.readFileSync(file, 'utf8');
203
- const parsed = JSON.parse(raw);
204
- if (
205
- parsed &&
206
- typeof parsed === 'object' &&
207
- typeof parsed.story === 'string' &&
208
- typeof parsed.phase === 'string' &&
209
- typeof parsed.ts === 'string'
210
- ) {
211
- return parsed;
212
- }
213
- } catch {
214
- /* corrupt markertreat as absent */
217
+ raw = fs.readFileSync(file, 'utf8');
218
+ } catch (e) {
219
+ if (e.code === 'ENOENT') return null;
220
+ // EACCES / EISDIR / other I/O — surface to stderr so silent corruption
221
+ // doesn't masquerade as "first mark of session".
222
+ log.error(`timing marker read failed (${file}): ${e.message}`);
223
+ return null;
224
+ }
225
+ let parsed;
226
+ try {
227
+ parsed = JSON.parse(raw);
228
+ } catch (e) {
229
+ log.error(`timing marker corrupt (${file}): ${e.message} treating as absent`);
230
+ return null;
231
+ }
232
+ if (
233
+ !parsed ||
234
+ typeof parsed !== 'object' ||
235
+ typeof parsed.story !== 'string' ||
236
+ typeof parsed.phase !== 'string' ||
237
+ typeof parsed.ts !== 'string'
238
+ ) {
239
+ return null;
215
240
  }
216
- return null;
241
+ // Re-validate `story` and `phase` against their regexes. CLI input is
242
+ // already validated, but a corrupted/hand-edited marker could carry a
243
+ // path-traversing story (e.g. "../../etc") — `parsed.story` flows into
244
+ // `appendLine(projectRoot, prev.story, ...)` which path.joins to
245
+ // `<timingsDir>/<story>.jsonl`. Defense-in-depth: refuse any value that
246
+ // doesn't match STORY_RE / PHASE_RE.
247
+ if (!STORY_RE.test(parsed.story)) {
248
+ log.error(`timing marker (${file}) has invalid story '${parsed.story}'; treating as absent`);
249
+ return null;
250
+ }
251
+ if (parsed.phase !== '_end' && !PHASE_RE.test(parsed.phase)) {
252
+ log.error(`timing marker (${file}) has invalid phase '${parsed.phase}'; treating as absent`);
253
+ return null;
254
+ }
255
+ return parsed;
217
256
  }
218
257
 
219
- function writeMarker(projectRoot, marker) {
258
+ function writeMarker(projectRoot, story, marker) {
220
259
  const dir = timingsDir(projectRoot);
221
260
  fs.mkdirSync(dir, { recursive: true });
222
- const file = markerPath(projectRoot);
261
+ const file = markerPath(projectRoot, story);
223
262
  // Atomic-ish: write tmp + rename. Marker is small, single-line JSON.
224
- const tmp = `${file}.tmp.${process.pid}`;
225
- fs.writeFileSync(tmp, JSON.stringify(marker));
226
- fs.renameSync(tmp, file);
263
+ // Tmp filename includes story + pid + random suffix to avoid collisions
264
+ // between concurrent same-process writers (rare in normal use, common in
265
+ // parallel test runs) and PID-reuse.
266
+ const tmp = `${file}.tmp.${process.pid}.${Math.random().toString(36).slice(2, 10)}`;
267
+ try {
268
+ fs.writeFileSync(tmp, JSON.stringify(marker));
269
+ fs.renameSync(tmp, file);
270
+ } catch (e) {
271
+ // Clean up tmp on rename failure so we don't leak orphan files.
272
+ try {
273
+ fs.unlinkSync(tmp);
274
+ } catch {
275
+ /* ignore — tmp may not exist */
276
+ }
277
+ throw e;
278
+ }
227
279
  }
228
280
 
229
- function clearMarker(projectRoot) {
230
- const file = markerPath(projectRoot);
281
+ function clearMarker(projectRoot, story) {
282
+ const file = markerPath(projectRoot, story);
231
283
  try {
232
284
  fs.unlinkSync(file);
233
285
  } catch {
@@ -238,27 +290,56 @@ function clearMarker(projectRoot) {
238
290
  /**
239
291
  * mark: single-call timing API.
240
292
  *
241
- * Emits a duration record for the PREVIOUS phase (if any) covering the
242
- * interval since the previous mark, then writes a new marker for the
243
- * current phase. The very first mark in a session emits no duration
244
- * record — there's no "previous phase" yet.
293
+ * Emits a duration record for THIS story's PREVIOUS phase (if any),
294
+ * covering the interval since the previous mark for the same story key,
295
+ * then writes a new marker for the current phase. The very first mark
296
+ * for a given story emits no duration record — there's no "previous
297
+ * phase" yet for that story.
298
+ *
299
+ * Pre-2.0.5 used a single global marker file shared across stories,
300
+ * which under parallel dispatch (sub-agents marking different stories
301
+ * concurrently against the same project root) raced on a single file —
302
+ * one rename clobbered the other and durations were attributed to the
303
+ * wrong (story, phase). Per-story markers eliminate the race entirely:
304
+ * each story has its own marker file `.mark.<story>.json`.
305
+ *
306
+ * Use phase = "_end" to close THIS story's last open phase without
307
+ * starting a new one (e.g. at sprint-complete time, or per-story
308
+ * cleanup). `_end` only touches the marker for the named story; other
309
+ * stories' markers are untouched.
310
+ *
311
+ * Order of operations is interrupt-safe: the new marker is written
312
+ * BEFORE the duration record is appended. If the process is killed
313
+ * between the marker rename and the duration append, we lose one
314
+ * duration record but the next mark will read the new marker (not the
315
+ * stale prev) and won't double-count.
245
316
  *
246
- * Use phase = "_end" to close the last open phase without starting a new
247
- * one (e.g. at sprint-complete time).
317
+ * Wall-clock skew: durations are clamped to [0, MAX_PLAUSIBLE_DURATION_MS]
318
+ * with a `clock_skew: true` flag in the entry so aggregators don't get
319
+ * poisoned by NTP backsteps, DST transitions, or container clock skips
320
+ * forward of unrealistic magnitudes (e.g. "this skill ran for 7 hours").
248
321
  *
249
322
  * Returns { duration_ms, prev_phase } so callers can log/inspect.
250
323
  */
251
324
  function markPhase(projectRoot, story, phase, meta) {
252
325
  const now = new Date();
253
- const prev = readMarker(projectRoot);
326
+ const prev = readMarker(projectRoot, story);
327
+
328
+ // Build the duration entry from prev (if any) before mutating marker
329
+ // state. We append AFTER writing the new marker, so an interrupt
330
+ // between the two yields one missed record (acceptable) rather than a
331
+ // stale marker that would double-count on the next call.
332
+ let durationEntry = null;
254
333
  let durationMs = null;
255
334
  let prevPhase = null;
256
335
  if (prev) {
257
336
  const prevTs = Date.parse(prev.ts);
258
337
  if (!Number.isNaN(prevTs)) {
259
- durationMs = now.getTime() - prevTs;
338
+ const rawDelta = now.getTime() - prevTs;
339
+ const clamped = rawDelta < 0 || rawDelta > MAX_PLAUSIBLE_DURATION_MS;
340
+ durationMs = clamped ? 0 : rawDelta;
260
341
  prevPhase = prev.phase;
261
- const durationEntry = {
342
+ durationEntry = {
262
343
  event: 'duration',
263
344
  story: prev.story,
264
345
  phase: prev.phase,
@@ -266,17 +347,26 @@ function markPhase(projectRoot, story, phase, meta) {
266
347
  ended: now.toISOString(),
267
348
  duration_ms: durationMs,
268
349
  };
350
+ if (clamped) durationEntry.clock_skew = true;
269
351
  if (prev.meta !== undefined) durationEntry.meta = prev.meta;
270
- appendLine(projectRoot, prev.story, durationEntry);
271
352
  }
272
353
  }
354
+
355
+ // 1. Commit the marker state transition first.
273
356
  if (phase === '_end') {
274
- clearMarker(projectRoot);
357
+ clearMarker(projectRoot, story);
275
358
  } else {
276
359
  const marker = { story, phase, ts: now.toISOString() };
277
360
  if (meta !== undefined) marker.meta = meta;
278
- writeMarker(projectRoot, marker);
361
+ writeMarker(projectRoot, story, marker);
279
362
  }
363
+
364
+ // 2. Append the duration record after the marker is committed. If
365
+ // this throws, the marker is already correct for the next mark.
366
+ if (durationEntry !== null) {
367
+ appendLine(projectRoot, prev.story, durationEntry);
368
+ }
369
+
280
370
  return { duration_ms: durationMs, prev_phase: prevPhase };
281
371
  }
282
372
 
@@ -337,7 +427,7 @@ module.exports = {
337
427
  PHASE_RE,
338
428
  META_MAX_BYTES,
339
429
  LINE_MAX_BYTES,
340
- MARKER_FILE,
430
+ MAX_PLAUSIBLE_DURATION_MS,
341
431
  VALID_ACTIONS,
342
432
  validateStory,
343
433
  validatePhase,
@@ -639,7 +639,7 @@ Parse stdout as a single JSON object: `{"remaining":[...],"state":"..."}`.
639
639
  - Skill flow (full): bmad-create-story → bmad-check-implementation-readiness → bmad-dev-story → bmad-code-review → apply patch findings → re-run tests → set status=done in {status_file}
640
640
  - Skill flow (quick): bmad-quick-dev (single skill; nano profile)
641
641
  Use {{implementation_flow}} = `{{implementation_flow}}` to pick which flow.
642
- Track timing via `node {{project_root}}/_Sprintpilot/scripts/log-timing.js mark --story K --phase <phase>` after each skill returns.
642
+ Track timing via `node {{project_root}}/_Sprintpilot/scripts/log-timing.js mark --story K --phase <phase> --project-root {{project_root}}` after each skill returns. The explicit `--project-root` is REQUIRED — without it the script falls back to cwd (the worktree), which orphans timing data. With per-story markers (2.0.5+) concurrent sub-agents writing to the same project root no longer race.
643
643
  Return a one-line JSON summary on completion: {"story":"K", "status":"done"|"failed", "tests":"<N/M>", "notes":"<short>"}
644
644
  ```
645
645
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ikunin/sprintpilot",
3
- "version": "2.0.4",
3
+ "version": "2.0.6",
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": {