@ikunin/sprintpilot 2.2.30 → 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 +752 -66
  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 +107 -0
  10. package/_Sprintpilot/lib/orchestrator/user-commands.js +124 -1
  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
@@ -0,0 +1,488 @@
1
+ // _Sprintpilot/lib/orchestrator/sprint-plan.js — orchestrator-side
2
+ // wrappers around the sprint-plan.js script. This module:
3
+ //
4
+ // - knows about autopilot/profile/config concerns (auto_plan_on_start
5
+ // opt-in gate, --no-auto-plan CLI flag);
6
+ // - composes the plan-aware story queue from sprint-plan.yaml;
7
+ // - drives one-shot legacy-file migration on first cmdStart;
8
+ // - computes plan staleness for the auto-derive trigger.
9
+ //
10
+ // It does NOT execute any LLM call; auto-derive emits a `invoke_skill`
11
+ // action that the LLM session handles. By design this layer stays
12
+ // host-agnostic and unit-testable.
13
+
14
+ const fs = require('node:fs');
15
+ const path = require('node:path');
16
+ const { spawnSync } = require('node:child_process');
17
+
18
+ const sprintPlanScript = require('../../scripts/sprint-plan.js');
19
+
20
+ const REPO_BIN = path.join(__dirname, '..', '..', 'scripts');
21
+ const INFER_SCRIPT = path.join(REPO_BIN, 'infer-dependencies.js');
22
+
23
+ // Plan-status values that mean "do not run this story" (queue resolver
24
+ // drops these). 'pending' is the only state the autopilot picks up.
25
+ const NON_PENDING_PLAN_STATUSES = new Set(['done', 'skipped', 'excluded']);
26
+
27
+ // Reasons surfaced by planStaleness().
28
+ const STALENESS_REASONS = {
29
+ missing: 'missing',
30
+ added_stories: 'added_stories',
31
+ removed_stories: 'removed_stories',
32
+ migration_needed: 'migration_needed',
33
+ };
34
+
35
+ // Path to the legacy dependencies.yaml file (pre-v2.3.0). Used by the
36
+ // migration trigger; never read by the live DAG resolver.
37
+ function legacyDependenciesPath(projectRoot) {
38
+ return path.join(projectRoot, '_Sprintpilot', 'sprints', 'dependencies.yaml');
39
+ }
40
+
41
+ // ---------------------------------------------------------------
42
+ // Reading sprint-status (minimal pull — we only need the keys here)
43
+ // ---------------------------------------------------------------
44
+
45
+ function sprintStatusPath(projectRoot) {
46
+ return path.join(
47
+ projectRoot,
48
+ '_bmad-output',
49
+ 'implementation-artifacts',
50
+ 'sprint-status.yaml',
51
+ );
52
+ }
53
+
54
+ // Parse story keys (and their bmad status) out of sprint-status.yaml.
55
+ // Mirrors the pull logic in resolve-dag.js#readStoriesFromStatus — we
56
+ // duplicate here to keep the orchestrator helper independent of the
57
+ // strategy layer (which reads from sprint-plan.yaml).
58
+ function readSprintStatusKeys(projectRoot) {
59
+ const file = sprintStatusPath(projectRoot);
60
+ if (!fs.existsSync(file)) return { exists: false, ordered: [], byKey: {} };
61
+ const raw = fs.readFileSync(file, 'utf8');
62
+ const ordered = [];
63
+ const byKey = {};
64
+ const lines = raw.split(/\r?\n/);
65
+ let inStories = false;
66
+ let storyIndent = null;
67
+ for (const rawLine of lines) {
68
+ const trimmed = rawLine.trimEnd();
69
+ if (/^(development_status|stories):\s*$/.test(trimmed)) {
70
+ inStories = true;
71
+ storyIndent = null;
72
+ continue;
73
+ }
74
+ if (inStories && /^\S/.test(trimmed)) {
75
+ inStories = false;
76
+ storyIndent = null;
77
+ }
78
+ if (!inStories) continue;
79
+ const m = trimmed.match(/^([\t ]+)([A-Za-z0-9][A-Za-z0-9-]*):\s*(\S+)?/);
80
+ if (!m) continue;
81
+ if (storyIndent === null) storyIndent = m[1];
82
+ else if (m[1] !== storyIndent) continue;
83
+ const status = m[3] ? m[3].replace(/^["']|["']$/g, '') : null;
84
+ if (byKey[m[2]] === undefined) {
85
+ ordered.push(m[2]);
86
+ byKey[m[2]] = { key: m[2], status };
87
+ }
88
+ }
89
+ return { exists: true, ordered, byKey };
90
+ }
91
+
92
+ // ---------------------------------------------------------------
93
+ // Staleness detection
94
+ // ---------------------------------------------------------------
95
+
96
+ // Compute whether the current sprint-plan.yaml needs regeneration.
97
+ // Returns:
98
+ // { stale: false } — plan is fresh OR no plan exists yet
99
+ // { stale: true, reason: 'missing' } — plan absent AND legacy file absent
100
+ // { stale: true, reason: 'migration_needed' } — legacy file present, plan absent
101
+ // { stale: true, reason: 'added_stories', missing_keys } — sprint-status has stories not in plan
102
+ // { stale: true, reason: 'removed_stories', removed_keys } — plan stories absent from sprint-status
103
+ function planStaleness({ projectRoot }) {
104
+ const planResult = sprintPlanScript.read({ projectRoot });
105
+
106
+ // Plan present but corrupt — not "stale" per se; callers handle this
107
+ // via the corrupt-recovery user_prompt. We surface it as a sentinel.
108
+ if (planResult && typeof planResult === 'object' && 'error' in planResult) {
109
+ return { stale: true, reason: 'corrupt', error: planResult.error, message: planResult.message };
110
+ }
111
+
112
+ const legacyExists = fs.existsSync(legacyDependenciesPath(projectRoot));
113
+
114
+ if (planResult === null) {
115
+ if (legacyExists) {
116
+ return { stale: true, reason: STALENESS_REASONS.migration_needed };
117
+ }
118
+ return { stale: true, reason: STALENESS_REASONS.missing };
119
+ }
120
+
121
+ // Plan exists — compare against sprint-status keys.
122
+ const ss = readSprintStatusKeys(projectRoot);
123
+ if (!ss.exists) {
124
+ // No sprint-status to compare against. Plan stands alone.
125
+ return { stale: false };
126
+ }
127
+ const planStoryKeys = new Set(
128
+ (planResult.stories || []).map((s) => s && s.key).filter((k) => typeof k === 'string'),
129
+ );
130
+ const ssSet = new Set(ss.ordered);
131
+
132
+ const missingFromPlan = ss.ordered.filter((k) => !planStoryKeys.has(k));
133
+ if (missingFromPlan.length > 0) {
134
+ return {
135
+ stale: true,
136
+ reason: STALENESS_REASONS.added_stories,
137
+ missing_keys: missingFromPlan,
138
+ };
139
+ }
140
+ const removedFromStatus = [...planStoryKeys].filter((k) => !ssSet.has(k));
141
+ if (removedFromStatus.length > 0) {
142
+ return {
143
+ stale: true,
144
+ reason: STALENESS_REASONS.removed_stories,
145
+ removed_keys: removedFromStatus,
146
+ };
147
+ }
148
+ return { stale: false };
149
+ }
150
+
151
+ // ---------------------------------------------------------------
152
+ // Migration trigger
153
+ // ---------------------------------------------------------------
154
+
155
+ // One-shot upgrade path: if a legacy `_Sprintpilot/sprints/dependencies.yaml`
156
+ // exists, invoke `infer-dependencies.js migrate` to import it into
157
+ // sprint-plan.yaml. Idempotent — subsequent calls are no-ops since
158
+ // migrate archives the legacy file on success.
159
+ //
160
+ // Returns the parsed JSON output from migrate, or { skipped: true }
161
+ // when no legacy file is present.
162
+ function bootstrapMigrationIfNeeded({ projectRoot }) {
163
+ if (!fs.existsSync(legacyDependenciesPath(projectRoot))) {
164
+ return { skipped: true, reason: 'no_legacy_file' };
165
+ }
166
+ const r = spawnSync('node', [INFER_SCRIPT, 'migrate', '--project-root', projectRoot], {
167
+ encoding: 'utf8',
168
+ });
169
+ let parsed = null;
170
+ try {
171
+ parsed = JSON.parse(r.stdout);
172
+ } catch {
173
+ parsed = null;
174
+ }
175
+ if (r.status !== 0) {
176
+ return {
177
+ migrated: false,
178
+ reason: 'migrate_failed',
179
+ status: r.status,
180
+ stdout: r.stdout,
181
+ stderr: r.stderr,
182
+ parsed,
183
+ };
184
+ }
185
+ return parsed || { migrated: false, reason: 'unparseable_migrate_output' };
186
+ }
187
+
188
+ // ---------------------------------------------------------------
189
+ // Plan-aware queue composition
190
+ // ---------------------------------------------------------------
191
+
192
+ // Build an ordered story queue from sprint-plan.yaml's pending entries.
193
+ // Returns null when no usable plan exists (caller falls back to
194
+ // sprint-status order). Empty array means plan exists but has no pending
195
+ // stories (queue is exhausted).
196
+ //
197
+ // Ordering: by `priority` ascending. Stories without a priority sink to
198
+ // the end in their array-position order.
199
+ function composePlanQueue({ projectRoot }) {
200
+ const plan = sprintPlanScript.read({ projectRoot });
201
+ if (plan === null) return null;
202
+ if (plan && typeof plan === 'object' && 'error' in plan) return null;
203
+ if (!Array.isArray(plan.stories) || plan.stories.length === 0) {
204
+ return null; // no curation done yet — fall through to legacy
205
+ }
206
+ const pending = plan.stories.filter(
207
+ (s) => s && s.key && !NON_PENDING_PLAN_STATUSES.has(s.plan_status),
208
+ );
209
+ pending.sort((a, b) => {
210
+ const pa = typeof a.priority === 'number' ? a.priority : Number.MAX_SAFE_INTEGER;
211
+ const pb = typeof b.priority === 'number' ? b.priority : Number.MAX_SAFE_INTEGER;
212
+ return pa - pb;
213
+ });
214
+ return pending.map((s) => s.key);
215
+ }
216
+
217
+ // Detect "plan exhausted" — every entry in plan.stories[] has a terminal
218
+ // plan_status (done / skipped / excluded). Returns:
219
+ // { exhausted: true, plan_id, total, terminal_counts }
220
+ // { exhausted: false, reason: '<short tag>' }
221
+ // Reasons:
222
+ // - 'no_plan' — no sprint-plan.yaml on disk
223
+ // - 'corrupt_plan' — file exists but is unreadable
224
+ // - 'empty_stories' — plan.stories is [] (skill didn't curate yet)
225
+ // - 'has_pending' — at least one story has plan_status='pending'
226
+ //
227
+ // Distinct from `plan_fresh` in shouldAutoDerive: exhaustion means the
228
+ // plan WAS curated and every story finished. Caller archives the plan
229
+ // and emits a `plan_exhausted` user_prompt halt.
230
+ function planExhausted({ projectRoot }) {
231
+ const plan = sprintPlanScript.read({ projectRoot });
232
+ if (plan === null) return { exhausted: false, reason: 'no_plan' };
233
+ if (plan && typeof plan === 'object' && 'error' in plan) {
234
+ return { exhausted: false, reason: 'corrupt_plan' };
235
+ }
236
+ if (!Array.isArray(plan.stories) || plan.stories.length === 0) {
237
+ return { exhausted: false, reason: 'empty_stories' };
238
+ }
239
+ const terminal_counts = { done: 0, skipped: 0, excluded: 0 };
240
+ let hasPending = false;
241
+ for (const s of plan.stories) {
242
+ if (!s || !s.key) continue;
243
+ if (s.plan_status === 'pending') {
244
+ hasPending = true;
245
+ break;
246
+ }
247
+ if (s.plan_status in terminal_counts) {
248
+ terminal_counts[s.plan_status] += 1;
249
+ }
250
+ }
251
+ if (hasPending) return { exhausted: false, reason: 'has_pending' };
252
+ return {
253
+ exhausted: true,
254
+ plan_id: plan.plan_id,
255
+ total: plan.stories.length,
256
+ terminal_counts,
257
+ };
258
+ }
259
+
260
+ // Check whether a persisted current_story is plan-terminal. Returns a
261
+ // reason string when the story exists in the plan with terminal
262
+ // plan_status, else null. Used by composeRuntimeState reconciliation
263
+ // alongside the existing sprint-status-based persistedStoryRejectionReason.
264
+ //
265
+ // Distinct from refreshBmadStatus's eager transition — that flow runs
266
+ // for stories whose BMAD status is terminal. This handles the case
267
+ // where the USER manually marked plan_status='skipped' / 'excluded' but
268
+ // sprint-status hasn't caught up yet.
269
+ function planRejectionReason(story_key, { projectRoot }) {
270
+ if (typeof story_key !== 'string' || !story_key) return null;
271
+ const plan = sprintPlanScript.read({ projectRoot });
272
+ if (plan === null) return null;
273
+ if (plan && typeof plan === 'object' && 'error' in plan) return null;
274
+ if (!Array.isArray(plan.stories)) return null;
275
+ const entry = plan.stories.find((s) => s && s.key === story_key);
276
+ if (!entry) return null;
277
+ if (entry.plan_status === 'done') return `sprint-plan.yaml plan_status='done'`;
278
+ if (entry.plan_status === 'skipped') return `sprint-plan.yaml plan_status='skipped'`;
279
+ if (entry.plan_status === 'excluded') return `sprint-plan.yaml plan_status='excluded'`;
280
+ return null;
281
+ }
282
+
283
+ // ---------------------------------------------------------------
284
+ // refreshBmadStatus wrapper (best-effort)
285
+ // ---------------------------------------------------------------
286
+
287
+ // Refresh the plan's bmad_status cache from sprint-status.yaml. Returns
288
+ // the result envelope from sprint-plan.js#refreshBmadStatus. Failures
289
+ // are non-fatal — the caller logs and proceeds. (We never want a stale
290
+ // status cache to wedge cmdStart.)
291
+ function refreshIfPlanExists({ projectRoot }) {
292
+ try {
293
+ return sprintPlanScript.refreshBmadStatus({ projectRoot });
294
+ } catch (e) {
295
+ return { wrote: false, reason: 'refresh_failed', message: e.message };
296
+ }
297
+ }
298
+
299
+ // ---------------------------------------------------------------
300
+ // Auto-derive gating
301
+ // ---------------------------------------------------------------
302
+
303
+ // Decide whether cmdStart should emit an `invoke_skill` action for
304
+ // /sprintpilot-plan-sprint based on:
305
+ // - whether the plan is stale,
306
+ // - whether the user opted into auto-derive (config or env),
307
+ // - whether the user explicitly disabled it via --no-auto-plan,
308
+ // - whether explicit --stories / --epic flags overrode planning.
309
+ //
310
+ // Returns { auto_derive: bool, reason: '<short tag>' }.
311
+ function shouldAutoDerive({ projectRoot, profile, opts }) {
312
+ if (opts && opts['no-auto-plan']) {
313
+ return { auto_derive: false, reason: 'no_auto_plan_flag' };
314
+ }
315
+ if (opts && (Array.isArray(opts.stories) ? opts.stories.length > 0 : opts.stories)) {
316
+ return { auto_derive: false, reason: 'explicit_stories_flag' };
317
+ }
318
+ if (opts && opts.epic !== undefined && opts.epic !== null) {
319
+ return { auto_derive: false, reason: 'explicit_epic_flag' };
320
+ }
321
+
322
+ const staleness = planStaleness({ projectRoot });
323
+ // Plan exists and is stale → ALWAYS re-derive (the user already adopted
324
+ // the plan workflow; we keep it fresh). Spread staleness first so the
325
+ // explicit `reason` (with `stale_` prefix) wins.
326
+ if (staleness.stale && staleness.reason !== STALENESS_REASONS.missing &&
327
+ staleness.reason !== STALENESS_REASONS.migration_needed) {
328
+ return { ...staleness, auto_derive: true, reason: `stale_${staleness.reason}` };
329
+ }
330
+ // Plan missing (greenfield) → only auto-derive if user opted in via config.
331
+ // Default config (per user direction) is auto_plan_on_start: false →
332
+ // greenfield runs in sprint-status order without LLM invocation.
333
+ if (staleness.stale && staleness.reason === STALENESS_REASONS.missing) {
334
+ const enabled = profile && profile.auto_plan_on_start === true;
335
+ if (enabled) {
336
+ return { auto_derive: true, reason: 'opt_in_missing' };
337
+ }
338
+ return { auto_derive: false, reason: 'greenfield_default_no_auto_plan' };
339
+ }
340
+ // Migration needed → migrate is NOT auto-derive (no LLM). The migration
341
+ // bootstrap runs separately; if after migration the plan is fresh, no
342
+ // derive needed.
343
+ if (staleness.stale && staleness.reason === STALENESS_REASONS.migration_needed) {
344
+ return { auto_derive: false, reason: 'migration_only' };
345
+ }
346
+ return { auto_derive: false, reason: 'plan_fresh' };
347
+ }
348
+
349
+ // ---------------------------------------------------------------
350
+ // DAG validation (Phase 5 — reorder_queue)
351
+ // ---------------------------------------------------------------
352
+
353
+ // Collect transitive upstreams of `story_key` from a plan. Walks both
354
+ // plan.dependencies.stories[*].depends_on (intra-epic edges) AND
355
+ // plan.cross_epic_deps (cross-boundary edges). Returns a Set of keys
356
+ // (excluding the story itself).
357
+ function collectUpstreams(story_key, plan) {
358
+ const upstreams = new Set();
359
+ if (!plan || !plan.dependencies || !plan.dependencies.stories) return upstreams;
360
+ const intra = plan.dependencies.stories;
361
+ const cross = Array.isArray(plan.cross_epic_deps) ? plan.cross_epic_deps : [];
362
+
363
+ // `visited` tracks which keys we've already walked (to avoid re-walking
364
+ // shared subtrees and to break cycles). `upstreams` is the result Set —
365
+ // we only add a key to it when it's discovered as a real upstream
366
+ // (excluding the starting story_key itself).
367
+ const visited = new Set();
368
+ const visit = (key) => {
369
+ if (visited.has(key)) return;
370
+ visited.add(key);
371
+ const direct = intra[key]?.depends_on;
372
+ if (Array.isArray(direct)) {
373
+ for (const up of direct) {
374
+ if (up !== story_key) {
375
+ upstreams.add(up);
376
+ visit(up);
377
+ }
378
+ }
379
+ }
380
+ // cross_epic_deps semantics: from_story depends on to_story.
381
+ // So an edge with from_story === key adds to_story as upstream.
382
+ for (const edge of cross) {
383
+ if (!edge) continue;
384
+ if (edge.from_story === key && typeof edge.to_story === 'string') {
385
+ const up = edge.to_story;
386
+ if (up !== story_key) {
387
+ upstreams.add(up);
388
+ visit(up);
389
+ }
390
+ }
391
+ }
392
+ };
393
+
394
+ visit(story_key);
395
+ return upstreams;
396
+ }
397
+
398
+ // Is a story in a plan-terminal state (done / skipped / excluded)?
399
+ function isPlanTerminal(story_key, plan) {
400
+ if (!plan || !Array.isArray(plan.stories)) return false;
401
+ const entry = plan.stories.find((s) => s && s.key === story_key);
402
+ if (!entry) return false;
403
+ return entry.plan_status === 'done' || entry.plan_status === 'skipped' || entry.plan_status === 'excluded';
404
+ }
405
+
406
+ // Is a story terminal in sprint-status.yaml (done/skipped/wont_do/etc)?
407
+ function isTerminalInSprintStatus(story_key, projectRoot) {
408
+ const ss = readSprintStatusKeys(projectRoot);
409
+ if (!ss.exists) return false;
410
+ const entry = ss.byKey[story_key];
411
+ if (!entry) return false;
412
+ const TERMINAL = new Set([
413
+ 'done',
414
+ 'skipped',
415
+ 'wont_do',
416
+ "won't_do",
417
+ 'cancelled',
418
+ 'canceled',
419
+ 'deferred',
420
+ 'abandoned',
421
+ ]);
422
+ return entry.status ? TERMINAL.has(String(entry.status).toLowerCase()) : false;
423
+ }
424
+
425
+ // Validate a proposed reorder against the plan's DAG. For each story in
426
+ // `proposedOrder`, every transitive upstream must be either:
427
+ // - positioned BEFORE the story in proposedOrder, OR
428
+ // - plan-terminal (done/skipped/excluded), OR
429
+ // - terminal in sprint-status.yaml.
430
+ // Returns { valid: bool, violations: [{story, upstream, suggestion}] }.
431
+ // Each violation includes a suggestion ("insert <upstream> before <story>")
432
+ // so the user_prompt can guide the user.
433
+ function validateOrdering(proposedOrder, plan, { projectRoot } = {}) {
434
+ if (!Array.isArray(proposedOrder)) {
435
+ return { valid: false, violations: [{ reason: 'order must be an array' }] };
436
+ }
437
+ const indexOf = Object.create(null);
438
+ for (let i = 0; i < proposedOrder.length; i++) {
439
+ indexOf[proposedOrder[i]] = i;
440
+ }
441
+ const violations = [];
442
+ for (const story of proposedOrder) {
443
+ const ups = collectUpstreams(story, plan);
444
+ for (const up of ups) {
445
+ const planTerminal = isPlanTerminal(up, plan);
446
+ const ssTerminal = projectRoot ? isTerminalInSprintStatus(up, projectRoot) : false;
447
+ const positionedBefore = up in indexOf && indexOf[up] < indexOf[story];
448
+ if (!planTerminal && !ssTerminal && !positionedBefore) {
449
+ violations.push({
450
+ story,
451
+ upstream: up,
452
+ suggestion: `insert ${up} before ${story}`,
453
+ });
454
+ }
455
+ }
456
+ }
457
+ return { valid: violations.length === 0, violations };
458
+ }
459
+
460
+ // ---------------------------------------------------------------
461
+ // Sentinel file for the first-time auto-plan prompt (Phase 3 stub).
462
+ // The full sentinel UX lives in Phase 4.5 wiring; this module exposes
463
+ // just the path so cmdStart can probe it.
464
+ // ---------------------------------------------------------------
465
+
466
+ function autoPlanFirstSeenSentinelPath(projectRoot) {
467
+ return path.join(projectRoot, '.sprintpilot', '.auto-plan-first-seen');
468
+ }
469
+
470
+ module.exports = {
471
+ NON_PENDING_PLAN_STATUSES,
472
+ STALENESS_REASONS,
473
+ legacyDependenciesPath,
474
+ sprintStatusPath,
475
+ readSprintStatusKeys,
476
+ planStaleness,
477
+ bootstrapMigrationIfNeeded,
478
+ composePlanQueue,
479
+ refreshIfPlanExists,
480
+ shouldAutoDerive,
481
+ planExhausted,
482
+ planRejectionReason,
483
+ collectUpstreams,
484
+ isPlanTerminal,
485
+ isTerminalInSprintStatus,
486
+ validateOrdering,
487
+ autoPlanFirstSeenSentinelPath,
488
+ };
@@ -32,6 +32,13 @@ const CRITICAL_KEYS = new Set([
32
32
  // story_key; adapt.advanceState pops the head when a story completes.
33
33
  // When empty, the orchestrator falls back to resolveNextStoryKey.
34
34
  'story_queue',
35
+ // v2.3.0 — verify-loop trackers. These must write through immediately
36
+ // so a crash between verify rejections doesn't reset the
37
+ // consecutive-identical counter. Without write-through, the
38
+ // budget-exhaustion halt would emit the generic prompt instead of
39
+ // the loop-hint enriched prompt, defeating the loop-detection UX.
40
+ 'last_verify_issues_signature',
41
+ 'consecutive_identical_rejections',
35
42
  ]);
36
43
 
37
44
  // In-memory pending buffer. Process-scoped — flushed at story boundary or
@@ -106,11 +113,8 @@ function readStateFile(fs, filePath) {
106
113
  // - item-scalar
107
114
  // - item-key: item-value
108
115
  //
109
- // The block-form array path was added in v2.2.29 pre-2.2.29 the
110
- // parser unconditionally `continue`d on any line without `:`, silently
111
- // dropping every `- item` entry. Hand-edited state files (or any
112
- // roundtrip through a tool that emits block-form YAML) lost their
113
- // `story_queue`, leaving the autopilot's queue mysteriously empty.
116
+ // dumpYaml emits inline JSON for arrays; the block-form path handles
117
+ // hand edits and tools that emit `- item` lines.
114
118
  function parseYamlNarrow(text) {
115
119
  if (!text) return {};
116
120
  const lines = text.split(/\r?\n/);
@@ -36,6 +36,8 @@ function applyOne(state, profile, cmd) {
36
36
  retry_count_this_phase: 0,
37
37
  verify_reject_count: 0,
38
38
  consecutive_test_failures: 0,
39
+ last_verify_issues_signature: null,
40
+ consecutive_identical_rejections: 0,
39
41
  };
40
42
  effects.push({
41
43
  kind: 'state_transition',
@@ -64,11 +66,16 @@ function applyOne(state, profile, cmd) {
64
66
  // looping on a stuck transition. Phase is unchanged. Also clears
65
67
  // any pending_alternative — `force_continue` is the explicit "no,
66
68
  // keep the planned action" answer to a propose_alternative prompt.
69
+ // v2.3.0: also reset verify-loop trackers so the next reject starts
70
+ // fresh — user explicitly accepted that the prior issues are
71
+ // resolved out of band.
67
72
  newState = {
68
73
  ...state,
69
74
  retry_count_this_phase: 0,
70
75
  verify_reject_count: 0,
71
76
  consecutive_test_failures: 0,
77
+ last_verify_issues_signature: null,
78
+ consecutive_identical_rejections: 0,
72
79
  pending_alternative: undefined,
73
80
  };
74
81
  effects.push({
@@ -95,6 +102,14 @@ function applyOne(state, profile, cmd) {
95
102
  // Mark the change so audit can detect it.
96
103
  changed_via_user_command: true,
97
104
  };
105
+ // v2.3.0 — also clear verify-loop trackers. The profile change
106
+ // shifts retry/verify budgets, so prior consecutive-identical
107
+ // counts shouldn't influence the new profile's halt threshold.
108
+ newState = {
109
+ ...state,
110
+ last_verify_issues_signature: null,
111
+ consecutive_identical_rejections: 0,
112
+ };
98
113
  effects.push({
99
114
  kind: 'profile_escalated', // reuse the ledger kind
100
115
  from: profile.name,
@@ -145,6 +160,12 @@ function applyOne(state, profile, cmd) {
145
160
  pending_alternative: undefined,
146
161
  retry_count_this_phase: 0,
147
162
  verify_reject_count: 0,
163
+ // v2.3.0 — accepting an alternative supersedes the prior planned
164
+ // action, so prior verify-loop accumulator should reset too.
165
+ // The next reject under the new action is treated as a fresh
166
+ // signal-identity baseline.
167
+ last_verify_issues_signature: null,
168
+ consecutive_identical_rejections: 0,
148
169
  };
149
170
  effects.push({
150
171
  kind: 'dispatch_action',
@@ -169,6 +190,92 @@ function applyOne(state, profile, cmd) {
169
190
  });
170
191
  break;
171
192
 
193
+ case 'trigger_retrospective':
194
+ // Force-route to RETROSPECTIVE regardless of remaining_stories_in_epic.
195
+ // Used when the user explicitly wants to close out the current epic
196
+ // with deferred stories still showing as non-terminal in sprint-status.
197
+ // Story-bound fields cleared so the retro skill reads from current_epic.
198
+ newState = {
199
+ ...state,
200
+ phase: STATES.RETROSPECTIVE,
201
+ story_key: null,
202
+ story_file_path: null,
203
+ ac_summary: null,
204
+ prior_diagnosis: null,
205
+ patch_findings: null,
206
+ tests_to_rerun: null,
207
+ retry_count_this_phase: 0,
208
+ verify_reject_count: 0,
209
+ consecutive_test_failures: 0,
210
+ last_verify_issues_signature: null,
211
+ consecutive_identical_rejections: 0,
212
+ // current_epic intentionally preserved — retro skill needs it.
213
+ };
214
+ effects.push({
215
+ kind: 'state_transition',
216
+ from: state.phase,
217
+ to: STATES.RETROSPECTIVE,
218
+ reason: 'user_trigger_retrospective',
219
+ epic: state.current_epic || null,
220
+ details: cmd.reason || null,
221
+ });
222
+ break;
223
+
224
+ // v2.3.0 — plan-aware mid-flight commands. Each emits a side-effect
225
+ // record that the CLI dispatcher handles by calling sprint-plan.js
226
+ // primitives. DAG-aware validation lives in the dispatcher (it needs
227
+ // the live plan file). State mutations are minimal here; only the
228
+ // replan_sprint flow touches state to schedule the halt.
229
+ case 'reorder_queue':
230
+ effects.push({
231
+ kind: 'plan_reorder',
232
+ order: cmd.order,
233
+ reason: cmd.reason || null,
234
+ });
235
+ break;
236
+
237
+ case 'add_to_sprint':
238
+ effects.push({
239
+ kind: 'plan_add_stories',
240
+ story_keys: cmd.story_keys,
241
+ position: cmd.position !== undefined ? cmd.position : 'end',
242
+ issue_ids: cmd.issue_ids || null,
243
+ reason: cmd.reason || null,
244
+ });
245
+ break;
246
+
247
+ case 'remove_from_sprint':
248
+ effects.push({
249
+ kind: 'plan_remove_stories',
250
+ story_keys: cmd.story_keys,
251
+ mark_status: cmd.mark_status || 'skipped',
252
+ reason: cmd.reason || null,
253
+ });
254
+ break;
255
+
256
+ case 'replan_sprint':
257
+ // Set replan_requested in state so the next cmdStart picks it up
258
+ // and emits the invoke_skill action. Halt now so the autopilot
259
+ // stops at the current story boundary; the user (or the LLM
260
+ // session) restarts to drive the skill.
261
+ newState = {
262
+ ...state,
263
+ replan_requested: {
264
+ reason: cmd.reason || null,
265
+ requested_at: new Date().toISOString(),
266
+ },
267
+ halt_requested: {
268
+ reason: cmd.reason || 'user_replan_sprint',
269
+ requested_at: new Date().toISOString(),
270
+ },
271
+ };
272
+ effects.push({
273
+ kind: 'halt',
274
+ reason: 'user_replan_sprint',
275
+ details: cmd.reason || null,
276
+ });
277
+ break;
278
+
172
279
  default:
173
280
  effects.push({ kind: 'state_transition', reason: 'unknown_user_command', cmd });
174
281
  }