@ikunin/sprintpilot 2.2.31 → 2.3.0

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.
Files changed (34) hide show
  1. package/README.md +232 -413
  2. package/_Sprintpilot/Sprintpilot.md +76 -6
  3. package/_Sprintpilot/bin/autopilot.js +734 -68
  4. package/_Sprintpilot/lib/orchestrator/action-ledger.js +208 -0
  5. package/_Sprintpilot/lib/orchestrator/adapt.js +93 -15
  6. package/_Sprintpilot/lib/orchestrator/profile-rules.js +7 -16
  7. package/_Sprintpilot/lib/orchestrator/sprint-plan.js +488 -0
  8. package/_Sprintpilot/lib/orchestrator/state-store.js +9 -5
  9. package/_Sprintpilot/lib/orchestrator/user-command-applier.js +78 -0
  10. package/_Sprintpilot/lib/orchestrator/user-commands.js +114 -0
  11. package/_Sprintpilot/lib/orchestrator/verify.js +10 -17
  12. package/_Sprintpilot/manifest.yaml +4 -1
  13. package/_Sprintpilot/modules/autopilot/profiles/_base.yaml +18 -4
  14. package/_Sprintpilot/modules/git/config.yaml +15 -9
  15. package/_Sprintpilot/modules/ma/config.yaml +29 -27
  16. package/_Sprintpilot/scripts/dispatch-layer.js +12 -15
  17. package/_Sprintpilot/scripts/infer-dependencies.js +706 -254
  18. package/_Sprintpilot/scripts/log-timing.js +6 -10
  19. package/_Sprintpilot/scripts/merge-shards.js +21 -23
  20. package/_Sprintpilot/scripts/post-green-gates.js +3 -1
  21. package/_Sprintpilot/scripts/resolve-dag.js +452 -280
  22. package/_Sprintpilot/scripts/sprint-plan.js +1068 -0
  23. package/_Sprintpilot/scripts/state-shard.js +13 -5
  24. package/_Sprintpilot/scripts/summarize-timings.js +2 -3
  25. package/_Sprintpilot/skills/sprint-autopilot-on/SKILL.md +30 -2
  26. package/_Sprintpilot/skills/sprint-autopilot-on/workflow.orchestrator.md +36 -10
  27. package/_Sprintpilot/skills/sprintpilot-dependency-graph/SKILL.md +63 -0
  28. package/_Sprintpilot/skills/sprintpilot-dependency-graph/workflow.md +227 -0
  29. package/_Sprintpilot/skills/sprintpilot-plan-sprint/SKILL.md +67 -0
  30. package/_Sprintpilot/skills/sprintpilot-plan-sprint/workflow.md +435 -0
  31. package/_Sprintpilot/skills/sprintpilot-sprint-progress/SKILL.md +53 -0
  32. package/_Sprintpilot/skills/sprintpilot-sprint-progress/workflow.md +169 -0
  33. package/lib/commands/install.js +186 -10
  34. package/package.json +1 -1
@@ -44,6 +44,40 @@ const VALID_KINDS = [
44
44
  // includes `summary` (counts) or `reason` ('disabled' / 'no_worktrees_dir'
45
45
  // / 'script_missing' / 'health_check_error' / 'worktrees_disabled').
46
46
  'worktree_health_check',
47
+ // v2.3.0 — sprint-plan.yaml lifecycle + queue events. Emitted from
48
+ // cmdStart (migration trigger, refresh, queue hydration, auto-derive
49
+ // gate, exhaustion) and cmdRecord (story-done sync to plan).
50
+ 'plan_migrated',
51
+ 'plan_migration_failed',
52
+ 'plan_refreshed',
53
+ 'plan_refresh_failed',
54
+ 'plan_queue_loaded',
55
+ 'plan_queue_failed',
56
+ 'plan_exhausted',
57
+ 'plan_archive_failed',
58
+ 'auto_derive_emitted',
59
+ 'plan_story_done',
60
+ 'plan_story_done_failed',
61
+ 'replan_requested_consumed',
62
+ // v2.3.0 — mid-flight plan mutations applied via applySideEffects.
63
+ 'plan_reordered',
64
+ 'plan_reorder_rejected',
65
+ 'plan_reorder_failed',
66
+ 'plan_stories_added',
67
+ 'plan_add_stories_failed',
68
+ 'plan_stories_removed',
69
+ 'plan_remove_stories_failed',
70
+ // v2.3.0 — planning skill outcomes (emitted by /sprintpilot-plan-sprint
71
+ // via the orchestrator after the skill completes).
72
+ 'plan_built',
73
+ 'cross_epic_edge_rejected',
74
+ 'issue_id_set',
75
+ 'dag_rendered',
76
+ // v2.3.0 — streaming progress (Phase 4.5). Sub-step granularity within
77
+ // a single story so `autopilot progress` can render live status.
78
+ 'story_step_started',
79
+ 'story_step_progress',
80
+ 'story_step_completed',
47
81
  ];
48
82
 
49
83
  function isPlainObject(v) {
@@ -147,11 +181,185 @@ function nextSeq(fs, filePath) {
147
181
  return 1;
148
182
  }
149
183
 
184
+ // readSince — return entries with seq strictly greater than `afterSeq`.
185
+ // Used by the tail iterator and one-shot consumers that want incremental
186
+ // reads without re-parsing the whole file.
187
+ function readSince(context, afterSeq) {
188
+ const entries = read(context);
189
+ if (typeof afterSeq !== 'number') return entries;
190
+ return entries.filter((e) => typeof e.seq === 'number' && e.seq > afterSeq);
191
+ }
192
+
193
+ // tail — async iterator yielding ledger entries as they're appended.
194
+ // Polls every `pollIntervalMs` (default 250ms). Terminates when
195
+ // `signal.aborted` is true OR when `maxIdleMs` elapses without new events
196
+ // (default Infinity).
197
+ //
198
+ // Usage:
199
+ // const ctrl = new AbortController();
200
+ // for await (const event of tail({ projectRoot, signal: ctrl.signal })) {
201
+ // console.log(event.kind, event.seq);
202
+ // if (event.kind === 'halt') ctrl.abort();
203
+ // }
204
+ //
205
+ // CI-safe: no fs.watch (some filesystems don't support it; CI logs can
206
+ // be replayed via the underlying file). Pure polling with offset tracking
207
+ // for cheap incremental reads.
208
+ async function* tail(context, options) {
209
+ if (!context || !context.projectRoot) throw new Error('tail: context.projectRoot required');
210
+ const opts = options || {};
211
+ const pollIntervalMs = typeof opts.pollIntervalMs === 'number' ? opts.pollIntervalMs : 250;
212
+ const maxIdleMs = typeof opts.maxIdleMs === 'number' ? opts.maxIdleMs : Number.POSITIVE_INFINITY;
213
+ const signal = opts.signal;
214
+ let lastSeq = typeof opts.afterSeq === 'number' ? opts.afterSeq : 0;
215
+
216
+ // v2.3.0 — track the ledger file's inode so we detect rotation /
217
+ // truncation. If `> ledger.jsonl` or `mv ledger.jsonl ledger.jsonl.1`
218
+ // happens, the inode changes (or stat throws) and we reset lastSeq
219
+ // to 0 so the next poll picks up entries from the start of the new
220
+ // file. Without this, tail() silently misses every event after a
221
+ // rotation.
222
+ const filePath = resolveLedgerPath(context.projectRoot);
223
+ let lastInode = null;
224
+ let lastSize = 0;
225
+ const captureFileIdentity = () => {
226
+ try {
227
+ const st = nodeFs.lstatSync(filePath);
228
+ lastInode = st.ino;
229
+ lastSize = st.size;
230
+ } catch {
231
+ // File doesn't exist yet — that's fine; on first poll we'll
232
+ // capture the identity when it appears.
233
+ lastInode = null;
234
+ lastSize = 0;
235
+ }
236
+ };
237
+ captureFileIdentity();
238
+
239
+ // If afterSeq isn't supplied, start from the current tail so we don't
240
+ // dump the whole history on every call. Pass afterSeq=0 explicitly to
241
+ // get everything.
242
+ if (typeof opts.afterSeq !== 'number') {
243
+ const existing = read(context);
244
+ if (existing.length > 0) {
245
+ const tailEntry = existing[existing.length - 1];
246
+ if (typeof tailEntry.seq === 'number') lastSeq = tailEntry.seq;
247
+ }
248
+ }
249
+
250
+ const sleep = (ms) => new Promise((resolve) => {
251
+ if (!signal) {
252
+ setTimeout(resolve, ms);
253
+ return;
254
+ }
255
+ const t = setTimeout(resolve, ms);
256
+ if (signal.aborted) {
257
+ clearTimeout(t);
258
+ resolve();
259
+ return;
260
+ }
261
+ signal.addEventListener('abort', () => {
262
+ clearTimeout(t);
263
+ resolve();
264
+ }, { once: true });
265
+ });
266
+
267
+ let idleAccumulatedMs = 0;
268
+ while (!(signal && signal.aborted)) {
269
+ // Rotation / truncation check before each poll. Three cases:
270
+ // - File didn't exist before, now does → capture identity, treat
271
+ // as fresh start; do NOT reset lastSeq (afterSeq semantics still
272
+ // apply).
273
+ // - File existed before, now doesn't → it was deleted; reset
274
+ // identity tracking, on next iteration we'll re-capture.
275
+ // - File exists with a different inode OR smaller size than last
276
+ // time → rotated/truncated; reset lastSeq=0 so we yield from
277
+ // the start of the new file.
278
+ let currentInode = null;
279
+ let currentSize = 0;
280
+ try {
281
+ const st = nodeFs.lstatSync(filePath);
282
+ currentInode = st.ino;
283
+ currentSize = st.size;
284
+ } catch {
285
+ // File missing — wait for it to appear.
286
+ }
287
+ if (lastInode !== null && currentInode !== null) {
288
+ const inodeChanged = currentInode !== lastInode;
289
+ const truncated = currentSize < lastSize;
290
+ if (inodeChanged || truncated) {
291
+ lastSeq = 0; // re-yield from the new file's start
292
+ lastInode = currentInode;
293
+ lastSize = currentSize;
294
+ }
295
+ } else if (currentInode !== null) {
296
+ // File appeared (was missing, now exists).
297
+ lastInode = currentInode;
298
+ lastSize = currentSize;
299
+ }
300
+
301
+ const fresh = readSince(context, lastSeq);
302
+ // v2.3.0 Round 2 — re-check inode AFTER readSince. The file could
303
+ // rotate during the read; without this we'd yield entries from the
304
+ // NEW file as if they were continuations of the old one (or skip
305
+ // them if their seq < lastSeq from the rotated file).
306
+ let postReadInode = null;
307
+ let postReadSize = 0;
308
+ try {
309
+ const st = nodeFs.lstatSync(filePath);
310
+ postReadInode = st.ino;
311
+ postReadSize = st.size;
312
+ } catch {
313
+ /* file gone — handled next iteration */
314
+ }
315
+ if (
316
+ lastInode !== null &&
317
+ postReadInode !== null &&
318
+ (postReadInode !== lastInode || postReadSize < lastSize)
319
+ ) {
320
+ // Rotation/truncation happened during the read. Discard the
321
+ // fresh batch (might be from the OLD inode), reset lastSeq to 0,
322
+ // and let the next iteration re-yield from the new file's start.
323
+ lastSeq = 0;
324
+ lastInode = postReadInode;
325
+ lastSize = postReadSize;
326
+ // Don't yield any of `fresh` since we can't trust which file
327
+ // they came from after the rotation; the next iteration's
328
+ // readSince(0) will pick up the new file's entries.
329
+ await sleep(pollIntervalMs);
330
+ continue;
331
+ }
332
+ if (fresh.length > 0) {
333
+ idleAccumulatedMs = 0;
334
+ for (const event of fresh) {
335
+ if (signal && signal.aborted) return;
336
+ if (typeof event.seq === 'number' && event.seq > lastSeq) {
337
+ lastSeq = event.seq;
338
+ }
339
+ yield event;
340
+ }
341
+ // Refresh size after yielding so the next iteration's truncation
342
+ // check uses the right baseline.
343
+ try {
344
+ lastSize = nodeFs.lstatSync(filePath).size;
345
+ } catch {
346
+ /* file disappeared between yield and stat — handle next loop */
347
+ }
348
+ } else {
349
+ idleAccumulatedMs += pollIntervalMs;
350
+ if (idleAccumulatedMs >= maxIdleMs) return;
351
+ }
352
+ await sleep(pollIntervalMs);
353
+ }
354
+ }
355
+
150
356
  module.exports = {
151
357
  VALID_KINDS,
152
358
  LEDGER_FILENAME,
153
359
  append,
154
360
  read,
361
+ readSince,
155
362
  last,
363
+ tail,
156
364
  resolveLedgerPath,
157
365
  };
@@ -18,6 +18,34 @@ const userCommandApplier = require('./user-command-applier');
18
18
  // Threshold for `consecutive_test_failures` — workflow.md:81 says 3.
19
19
  const CONSECUTIVE_TEST_FAILURE_THRESHOLD = 3;
20
20
 
21
+ // Threshold for the verify-loop diagnostic: when the SAME verify issues
22
+ // repeat this many times in a row, the budget-exhausted halt prompt
23
+ // enriches itself with a loop-detection hint (vs. a generic "rejected N
24
+ // times" message). 3 matches verify_reject_budget for medium/large/legacy
25
+ // profiles, so by the time the budget halts, the diagnostic is guaranteed
26
+ // to fire if and only if the rejections were genuinely identical.
27
+ const VERIFY_LOOP_THRESHOLD = 3;
28
+
29
+ // Stable, order-independent signature of a verify issues array.
30
+ // We compare via sorted JSON so two arrays with the same strings in
31
+ // different order hash to the same signature (the verifier may reorder
32
+ // internally across runs). Returns null for empty or non-array input.
33
+ function verifyIssuesSignature(issues) {
34
+ if (!Array.isArray(issues) || issues.length === 0) return null;
35
+ // Coerce to strings, trim whitespace, then sort. The trim guards
36
+ // against the verifier accidentally producing trailing whitespace
37
+ // on one run but not another — without it, "branch required" and
38
+ // "branch required " would hash differently and silently break the
39
+ // loop detection. Trim is safe: leading/trailing whitespace in a
40
+ // verify-issue string is never load-bearing.
41
+ const strs = issues
42
+ .map((i) => (typeof i === 'string' ? i : JSON.stringify(i)))
43
+ .map((s) => s.trim())
44
+ .slice()
45
+ .sort();
46
+ return JSON.stringify(strs);
47
+ }
48
+
21
49
  // Valid signal statuses.
22
50
  const SIGNAL_STATUSES = [
23
51
  'success',
@@ -73,28 +101,69 @@ function handleSuccess(state, signal, profile, verifyResult, sideEffects) {
73
101
  // Trust boundary: verify.js may reject what the LLM claims as success.
74
102
  if (verifyResult && verifyResult.ok === false) {
75
103
  const rejectCount = (state.verify_reject_count || 0) + 1;
104
+
105
+ // Loop detection: compare the current issues signature against the
106
+ // last one. Identical sets in a row → the LLM is retrying with the
107
+ // same broken signal. This drives the enriched halt prompt below.
108
+ const currentSig = verifyIssuesSignature(verifyResult.issues || []);
109
+ const lastSig = state.last_verify_issues_signature || null;
110
+ const identicalCount =
111
+ currentSig !== null && currentSig === lastSig
112
+ ? (state.consecutive_identical_rejections || 0) + 1
113
+ : 1;
114
+
76
115
  sideEffects.push({
77
116
  kind: 'log_verify_rejection',
78
117
  phase: state.phase,
79
118
  issues: verifyResult.issues || [],
80
119
  consecutive: rejectCount,
120
+ consecutive_identical: identicalCount,
81
121
  });
122
+
123
+ const stateWithLoopTrackers = {
124
+ ...state,
125
+ last_verify_issues_signature: currentSig,
126
+ consecutive_identical_rejections: identicalCount,
127
+ };
128
+
82
129
  if (rejectCount >= profile.verify_reject_budget) {
130
+ // Enriched diagnostic when the same issues recurred. Picks 2 as
131
+ // the threshold for the hint (vs. 3 for a "strong loop") because
132
+ // at budget exhaustion the minimum interesting case is 2 identical
133
+ // rejections in a row; we want the hint to fire whenever the LLM
134
+ // demonstrably wasn't iterating its signal between attempts.
135
+ const issueCount = verifyResult.issues?.length || 0;
136
+ const issuePlural = issueCount === 1 ? 'issue' : 'issues';
137
+ const timePlural = identicalCount === 1 ? 'time' : 'times';
138
+ const loopHint =
139
+ identicalCount >= 2
140
+ ? `\n\n⚠ Verify rejected the SAME ${issueCount} ${issuePlural} ${identicalCount} ${timePlural} in a row — this is a loop, not random noise. ` +
141
+ `The LLM is re-sending an identical broken signal each retry. ` +
142
+ `Action: read each issue text below and fix the underlying cause (e.g., if "git_steps_completed must be true — skipping git push is the most common cause", verify your git_op action actually ran \`git push\` to exit 0); don't just retry the same signal.`
143
+ : '';
83
144
  return {
84
- newState: { ...state, verify_reject_count: 0 },
145
+ newState: {
146
+ ...stateWithLoopTrackers,
147
+ verify_reject_count: 0,
148
+ last_verify_issues_signature: null,
149
+ consecutive_identical_rejections: 0,
150
+ },
85
151
  newProfile: profile,
86
152
  nextAction: {
87
153
  type: 'user_prompt',
88
154
  phase: state.phase,
89
155
  reason: 'verify_reject_budget_exceeded',
90
- prompt: `verify.js rejected ${rejectCount} consecutive success signals on ${state.phase}. Last issues: ${JSON.stringify(verifyResult.issues || [])}`,
156
+ prompt:
157
+ `verify.js rejected ${rejectCount} consecutive success signals on ${state.phase}. ` +
158
+ `Last issues: ${JSON.stringify(verifyResult.issues || [])}${loopHint}`,
159
+ consecutive_identical: identicalCount,
91
160
  },
92
161
  sideEffects,
93
162
  verdict: 'prompted',
94
163
  };
95
164
  }
96
165
  return {
97
- newState: { ...state, verify_reject_count: rejectCount },
166
+ newState: { ...stateWithLoopTrackers, verify_reject_count: rejectCount },
98
167
  newProfile: profile,
99
168
  // Retry the same phase. adapt's caller will re-run nextAction(state, profile).
100
169
  nextAction: nextAction(state, profile),
@@ -275,9 +344,8 @@ function handleBlocked(state, signal, profile, sideEffects) {
275
344
  case 'missing_dependency':
276
345
  // Emit an abstract install action. The CLI edge (autopilot.js
277
346
  // decorateRunScript) detects the project's language(s) from
278
- // manifest files and inlines the concrete `command`. Pre-2.2.19
279
- // this hardcoded `npm install`, which failed on non-Node projects
280
- // (Python, Rust, Go, Ruby, etc.).
347
+ // manifest files (package.json, pyproject.toml, Cargo.toml, etc.)
348
+ // and inlines the concrete `command` per language.
281
349
  return {
282
350
  newState: state,
283
351
  newProfile: profile,
@@ -413,14 +481,12 @@ function handleUserInput(state, signal, profile, sideEffects) {
413
481
  // checks all reference the wrong story.
414
482
  //
415
483
  // Phase advance: when the alternative carries `phase` and it's a
416
- // valid STATES value, also advance state.phase. Pre-v2.2.6 the
417
- // dispatch was one-shot the alternative ran for ONE emission then
418
- // state.phase reverted, defeating use cases like "skip dev_red /
419
- // dev_green / code_review because the work is already done on the
420
- // branch from a prior session." The user explicitly proposes the
421
- // alternative including a target phase; they accept the consequences
422
- // (e.g. verify may reject the new phase if its preconditions aren't
423
- // met). Without this, accept_alternative is useless for cycle skips.
484
+ // valid STATES value, also advance state.phase. The user explicitly
485
+ // proposes the alternative including a target phase; they accept the
486
+ // consequences (e.g. verify may reject the new phase if its
487
+ // preconditions aren't met). This enables cycle skips like "jump to
488
+ // STORY_DONE because the work is already on the branch from a prior
489
+ // session."
424
490
  const dispatch = applied.sideEffects.find((e) => e && e.kind === 'dispatch_action');
425
491
  if (dispatch && dispatch.action) {
426
492
  const a = dispatch.action;
@@ -499,7 +565,17 @@ function handleVerifyOverride(state, signal, profile, verifyResult, sideEffects)
499
565
  // clears patch_findings when leaving step 6; resets per-story counters when
500
566
  // starting a new story.
501
567
  function advanceState(state, profile, newPhase, signal) {
502
- const next = { ...state, phase: newPhase, retry_count_this_phase: 0, verify_reject_count: 0 };
568
+ const next = {
569
+ ...state,
570
+ phase: newPhase,
571
+ retry_count_this_phase: 0,
572
+ verify_reject_count: 0,
573
+ // v2.3.0 — phase transition clears verify-loop trackers so the next
574
+ // phase starts fresh. Without this a stale signature from the prior
575
+ // phase could artificially inflate identicalCount on the next reject.
576
+ last_verify_issues_signature: null,
577
+ consecutive_identical_rejections: 0,
578
+ };
503
579
  // Advancing forward clears the prior diagnosis (the LLM resolved it).
504
580
  next.prior_diagnosis = null;
505
581
 
@@ -626,5 +702,7 @@ module.exports = {
626
702
  interpretSignal,
627
703
  advanceState,
628
704
  CONSECUTIVE_TEST_FAILURE_THRESHOLD,
705
+ VERIFY_LOOP_THRESHOLD,
629
706
  SIGNAL_STATUSES,
707
+ verifyIssuesSignature,
630
708
  };
@@ -98,9 +98,6 @@ function flatToProfile(resolved, profileName) {
98
98
  // and the .timings/<story>.jsonl shards stop receiving events. Set
99
99
  // false on the `legacy` profile (no parallel coordination, no need
100
100
  // for granular timing). Default true on every other profile.
101
- // Pre-2.2.26: flatToProfile didn't include this field, so
102
- // `profile.phase_timings === false` was always false (undefined !==
103
- // false), meaning the legacy override never took effect.
104
101
  phase_timings: coerceBool(get(resolved, 'autopilot.phase_timings'), true),
105
102
  granularity: coerceEnum(get(resolved, 'git.granularity'), VALID_GRANULARITIES, 'story'),
106
103
  worktree_enabled: coerceBool(get(resolved, 'git.worktree.enabled'), true),
@@ -180,22 +177,16 @@ function flatToProfile(resolved, profileName) {
180
177
  // --stale-minutes. 0 disables the auto-takeover entirely (locks are
181
178
  // never considered stale; manual `autopilot off` required).
182
179
  lock_stale_timeout_minutes: coerceInt(get(resolved, 'git.lock.stale_timeout_minutes'), 30),
183
- // git.lint.* — documented in modules/git/config.yaml as a future
184
- // post-DEV_GREEN lint phase. Currently NOT wired into the state
185
- // machine (no LINT_CHECK phase emitted). v2.2.23 plumbs the config
186
- // to the typed Profile so users see the shape and cmdStart emits an
187
- // experimental warning when lint_enabled=true (mirroring
188
- // parallel_stories handling). Full state-machine integration is
189
- // tracked for v2.3.0+.
180
+ // git.lint.* — post-DEV_GREEN lint gate (scripts/post-green-gates.js).
181
+ // verifyDevGreen invokes it when lint_enabled=true; lint_blocking
182
+ // governs whether a failed gate rejects verify or just records.
183
+ // lint_output_limit caps lines of lint output per gate.
190
184
  lint_enabled: coerceBool(get(resolved, 'git.lint.enabled'), false),
191
185
  lint_blocking: coerceBool(get(resolved, 'git.lint.blocking'), false),
192
186
  lint_output_limit: coerceInt(get(resolved, 'git.lint.output_limit'), 100),
193
- // git.lint.linters — per-language preference map. v2.2.28+ forwards
194
- // this to lint-changed.js as --linters-json so users can reorder
195
- // priorities or disable individual linters. The default-shipped
196
- // priority order in lint-changed.js matches the documented config
197
- // defaults, so most users see no behavior change. Setting an empty
198
- // array for a language disables linting for that language entirely.
187
+ // git.lint.linters — per-language preference map. Forwarded to
188
+ // lint-changed.js as --linters-json. Empty list disables a language.
189
+ // javascript + typescript merge into js-ts (shared eslint/biome tooling).
199
190
  lint_linters: (() => {
200
191
  const v = get(resolved, 'git.lint.linters');
201
192
  return v && typeof v === 'object' && !Array.isArray(v) ? v : null;