@ikunin/sprintpilot 2.0.4 → 2.0.5

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.5
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:
@@ -43,6 +43,13 @@ const PHASE_RE = /^[a-z][a-z0-9-.]*$/;
43
43
  const META_MAX_BYTES = 2048;
44
44
  const LINE_MAX_BYTES = 4096; // POSIX PIPE_BUF floor — single write() is atomic
45
45
  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)`.
46
53
  const MARKER_FILE = '.mark.json';
47
54
 
48
55
  function help() {
@@ -191,15 +198,24 @@ function buildEntry(action, story, phase, meta) {
191
198
  // `mark` — single-call timing
192
199
  // ---------------------------------------------------------------
193
200
 
194
- function markerPath(projectRoot) {
195
- return path.join(timingsDir(projectRoot), MARKER_FILE);
201
+ function markerPath(projectRoot, story) {
202
+ if (!story) throw new Error('markerPath requires a story key');
203
+ return path.join(timingsDir(projectRoot), `.mark.${story}.json`);
196
204
  }
197
205
 
198
- function readMarker(projectRoot) {
199
- const file = markerPath(projectRoot);
200
- if (!fs.existsSync(file)) return null;
206
+ function readMarker(projectRoot, story) {
207
+ const file = markerPath(projectRoot, story);
208
+ let raw;
209
+ try {
210
+ raw = fs.readFileSync(file, 'utf8');
211
+ } catch (e) {
212
+ if (e.code === 'ENOENT') return null;
213
+ // EACCES / EISDIR / other I/O — surface to stderr so silent corruption
214
+ // doesn't masquerade as "first mark of session".
215
+ log.error(`timing marker read failed (${file}): ${e.message}`);
216
+ return null;
217
+ }
201
218
  try {
202
- const raw = fs.readFileSync(file, 'utf8');
203
219
  const parsed = JSON.parse(raw);
204
220
  if (
205
221
  parsed &&
@@ -210,24 +226,37 @@ function readMarker(projectRoot) {
210
226
  ) {
211
227
  return parsed;
212
228
  }
213
- } catch {
214
- /* corrupt markertreat as absent */
229
+ } catch (e) {
230
+ log.error(`timing marker corrupt (${file}): ${e.message} treating as absent`);
215
231
  }
216
232
  return null;
217
233
  }
218
234
 
219
- function writeMarker(projectRoot, marker) {
235
+ function writeMarker(projectRoot, story, marker) {
220
236
  const dir = timingsDir(projectRoot);
221
237
  fs.mkdirSync(dir, { recursive: true });
222
- const file = markerPath(projectRoot);
238
+ const file = markerPath(projectRoot, story);
223
239
  // 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);
240
+ // Tmp filename includes story + pid + random suffix to avoid collisions
241
+ // between concurrent same-process writers (rare in normal use, common in
242
+ // parallel test runs) and PID-reuse.
243
+ const tmp = `${file}.tmp.${process.pid}.${Math.random().toString(36).slice(2, 10)}`;
244
+ try {
245
+ fs.writeFileSync(tmp, JSON.stringify(marker));
246
+ fs.renameSync(tmp, file);
247
+ } catch (e) {
248
+ // Clean up tmp on rename failure so we don't leak orphan files.
249
+ try {
250
+ fs.unlinkSync(tmp);
251
+ } catch {
252
+ /* ignore — tmp may not exist */
253
+ }
254
+ throw e;
255
+ }
227
256
  }
228
257
 
229
- function clearMarker(projectRoot) {
230
- const file = markerPath(projectRoot);
258
+ function clearMarker(projectRoot, story) {
259
+ const file = markerPath(projectRoot, story);
231
260
  try {
232
261
  fs.unlinkSync(file);
233
262
  } catch {
@@ -238,27 +267,54 @@ function clearMarker(projectRoot) {
238
267
  /**
239
268
  * mark: single-call timing API.
240
269
  *
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.
270
+ * Emits a duration record for THIS story's PREVIOUS phase (if any),
271
+ * covering the interval since the previous mark for the same story key,
272
+ * then writes a new marker for the current phase. The very first mark
273
+ * for a given story emits no duration record — there's no "previous
274
+ * phase" yet for that story.
245
275
  *
246
- * Use phase = "_end" to close the last open phase without starting a new
247
- * one (e.g. at sprint-complete time).
276
+ * Pre-2.0.5 used a single global marker file shared across stories,
277
+ * which under parallel dispatch (sub-agents marking different stories
278
+ * concurrently against the same project root) raced on a single file —
279
+ * one rename clobbered the other and durations were attributed to the
280
+ * wrong (story, phase). Per-story markers eliminate the race entirely:
281
+ * each story has its own marker file `.mark.<story>.json`.
282
+ *
283
+ * Use phase = "_end" to close THIS story's last open phase without
284
+ * starting a new one (e.g. at sprint-complete time, or per-story
285
+ * cleanup). `_end` only touches the marker for the named story; other
286
+ * stories' markers are untouched.
287
+ *
288
+ * Order of operations is interrupt-safe: the new marker is written
289
+ * BEFORE the duration record is appended. If the process is killed
290
+ * between the marker rename and the duration append, we lose one
291
+ * duration record but the next mark will read the new marker (not the
292
+ * stale prev) and won't double-count.
293
+ *
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.
248
297
  *
249
298
  * Returns { duration_ms, prev_phase } so callers can log/inspect.
250
299
  */
251
300
  function markPhase(projectRoot, story, phase, meta) {
252
301
  const now = new Date();
253
- const prev = readMarker(projectRoot);
302
+ const prev = readMarker(projectRoot, story);
303
+
304
+ // Build the duration entry from prev (if any) before mutating marker
305
+ // state. We append AFTER writing the new marker, so an interrupt
306
+ // between the two yields one missed record (acceptable) rather than a
307
+ // stale marker that would double-count on the next call.
308
+ let durationEntry = null;
254
309
  let durationMs = null;
255
310
  let prevPhase = null;
256
311
  if (prev) {
257
312
  const prevTs = Date.parse(prev.ts);
258
313
  if (!Number.isNaN(prevTs)) {
259
- durationMs = now.getTime() - prevTs;
314
+ const rawDelta = now.getTime() - prevTs;
315
+ durationMs = Math.max(0, rawDelta);
260
316
  prevPhase = prev.phase;
261
- const durationEntry = {
317
+ durationEntry = {
262
318
  event: 'duration',
263
319
  story: prev.story,
264
320
  phase: prev.phase,
@@ -266,17 +322,26 @@ function markPhase(projectRoot, story, phase, meta) {
266
322
  ended: now.toISOString(),
267
323
  duration_ms: durationMs,
268
324
  };
325
+ if (rawDelta < 0) durationEntry.clock_skew = true;
269
326
  if (prev.meta !== undefined) durationEntry.meta = prev.meta;
270
- appendLine(projectRoot, prev.story, durationEntry);
271
327
  }
272
328
  }
329
+
330
+ // 1. Commit the marker state transition first.
273
331
  if (phase === '_end') {
274
- clearMarker(projectRoot);
332
+ clearMarker(projectRoot, story);
275
333
  } else {
276
334
  const marker = { story, phase, ts: now.toISOString() };
277
335
  if (meta !== undefined) marker.meta = meta;
278
- writeMarker(projectRoot, marker);
336
+ writeMarker(projectRoot, story, marker);
337
+ }
338
+
339
+ // 2. Append the duration record after the marker is committed. If
340
+ // this throws, the marker is already correct for the next mark.
341
+ if (durationEntry !== null) {
342
+ appendLine(projectRoot, prev.story, durationEntry);
279
343
  }
344
+
280
345
  return { duration_ms: durationMs, prev_phase: prevPhase };
281
346
  }
282
347
 
@@ -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.5",
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": {