@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,1068 @@
1
+ #!/usr/bin/env node
2
+
3
+ // sprint-plan.js — read/write Sprintpilot's unified sprint plan file.
4
+ //
5
+ // The plan file at _bmad-output/implementation-artifacts/sprint-plan.yaml
6
+ // is Sprintpilot-owned and holds:
7
+ // - dependency graph (per-epic + cross-epic edges)
8
+ // - story execution plan (priorities, plan_status, issue_ids)
9
+ // - per-entity bmad_status CACHE (refreshed from sprint-status.yaml on read)
10
+ // - auto-derive lifecycle status block
11
+ // - free-form user notes from the planning skill
12
+ //
13
+ // All writes are atomic (tmp+rename). Callers that hold cross-operation
14
+ // invariants (skill curation, autopilot markDone-then-render) should
15
+ // acquire `.sprintpilot/plan.lock` via lock.js around their session.
16
+ //
17
+ // Phase 0 minimum: read + write + emptyPlan + validatePlan. The richer
18
+ // primitives (markDone, addStories, reorder, refreshBmadStatus, etc.)
19
+ // land in Phase 2.
20
+
21
+ const fs = require('node:fs');
22
+ const path = require('node:path');
23
+ const crypto = require('node:crypto');
24
+ const { spawnSync } = require('node:child_process');
25
+ const yaml = require('js-yaml');
26
+
27
+ const { parseArgs } = require('../lib/runtime/args');
28
+ const log = require('../lib/runtime/log');
29
+
30
+ const LOCK_SCRIPT_PATH = path.join(__dirname, 'lock.js');
31
+
32
+ const SCHEMA_VERSION = 1;
33
+ const PLAN_FILE_REL = path.join(
34
+ '_bmad-output',
35
+ 'implementation-artifacts',
36
+ 'sprint-plan.yaml',
37
+ );
38
+ const LOCK_FILE_REL = path.join('.sprintpilot', 'plan.lock');
39
+ const LOCK_STALE_MINUTES = 5;
40
+
41
+ const VALID_COMMANDS = ['read', 'write', 'validate', 'empty'];
42
+ const VALID_SOURCES = ['skill', 'auto', 'cli', 'migrated'];
43
+
44
+ function planPath(projectRoot) {
45
+ return path.join(projectRoot, PLAN_FILE_REL);
46
+ }
47
+
48
+ function lockPath(projectRoot) {
49
+ return path.join(projectRoot, LOCK_FILE_REL);
50
+ }
51
+
52
+ // Build a valid empty plan suitable as a starting point for skill / migrate /
53
+ // bootstrap flows. status.last_run_outcome reflects the bootstrap intent.
54
+ function emptyPlan({ source = 'skill' } = {}) {
55
+ if (!VALID_SOURCES.includes(source)) {
56
+ throw new Error(`invalid source ${JSON.stringify(source)}; expected one of ${VALID_SOURCES.join(', ')}`);
57
+ }
58
+ const now = new Date().toISOString();
59
+ return {
60
+ schema_version: SCHEMA_VERSION,
61
+ generated: now,
62
+ source,
63
+ plan_id: crypto.randomUUID(),
64
+ deps_inferred_at: null,
65
+ status: {
66
+ last_run_outcome: 'success',
67
+ last_run_at: now,
68
+ last_error: null,
69
+ },
70
+ issue_tracker: null,
71
+ epics: [],
72
+ stories: [],
73
+ dependencies: {
74
+ version: 1,
75
+ auto_inferred_at: null,
76
+ stories: {},
77
+ },
78
+ cross_epic_deps: [],
79
+ overrides: [],
80
+ notes: '',
81
+ };
82
+ }
83
+
84
+ // Schema validation. Returns null when valid, else { code, message, ...details }.
85
+ // All errors are recoverable — callers never see a thrown exception from here.
86
+ function validatePlan(plan) {
87
+ if (plan === null || plan === undefined || typeof plan !== 'object' || Array.isArray(plan)) {
88
+ return { code: 'invalid_root', message: 'plan must be a YAML mapping' };
89
+ }
90
+ if (plan.schema_version === undefined || plan.schema_version === null) {
91
+ return { code: 'missing_schema_version', message: 'plan is missing schema_version' };
92
+ }
93
+ if (plan.schema_version !== SCHEMA_VERSION) {
94
+ return {
95
+ code: 'unsupported_version',
96
+ message: `expected schema_version=${SCHEMA_VERSION}, got ${JSON.stringify(plan.schema_version)} — upgrade Sprintpilot`,
97
+ };
98
+ }
99
+ const required = [
100
+ 'status',
101
+ 'epics',
102
+ 'stories',
103
+ 'dependencies',
104
+ 'cross_epic_deps',
105
+ 'overrides',
106
+ ];
107
+ const missing = required.filter((k) => !(k in plan));
108
+ if (missing.length > 0) {
109
+ return {
110
+ code: 'incomplete_schema',
111
+ message: `missing required top-level keys: ${missing.join(', ')}`,
112
+ missing_keys: missing,
113
+ };
114
+ }
115
+ if (!plan.status || typeof plan.status !== 'object' || Array.isArray(plan.status)) {
116
+ return { code: 'invalid_status', message: 'status must be a mapping' };
117
+ }
118
+ if (!Array.isArray(plan.epics)) {
119
+ return { code: 'invalid_epics', message: 'epics must be a list' };
120
+ }
121
+ if (!Array.isArray(plan.stories)) {
122
+ return { code: 'invalid_stories', message: 'stories must be a list' };
123
+ }
124
+ if (!plan.dependencies || typeof plan.dependencies !== 'object' || Array.isArray(plan.dependencies)) {
125
+ return { code: 'invalid_dependencies', message: 'dependencies must be a mapping' };
126
+ }
127
+ if (!plan.dependencies.stories || typeof plan.dependencies.stories !== 'object' || Array.isArray(plan.dependencies.stories)) {
128
+ return {
129
+ code: 'invalid_dependencies_stories',
130
+ message: 'dependencies.stories must be a mapping',
131
+ };
132
+ }
133
+ if (!Array.isArray(plan.cross_epic_deps)) {
134
+ return { code: 'invalid_cross_epic_deps', message: 'cross_epic_deps must be a list' };
135
+ }
136
+ if (!Array.isArray(plan.overrides)) {
137
+ return { code: 'invalid_overrides', message: 'overrides must be a list' };
138
+ }
139
+ return null;
140
+ }
141
+
142
+ // Read plan from disk.
143
+ // Returns:
144
+ // null — file does not exist (caller may bootstrap)
145
+ // { error, path, message } — file exists but parse / schema failed
146
+ // plan object — valid plan loaded
147
+ function read({ projectRoot }) {
148
+ const file = planPath(projectRoot);
149
+ if (!fs.existsSync(file)) return null;
150
+ let raw;
151
+ try {
152
+ raw = fs.readFileSync(file, 'utf8');
153
+ } catch (e) {
154
+ return { error: 'read_failed', path: file, message: e.message };
155
+ }
156
+ let plan;
157
+ try {
158
+ plan = yaml.load(raw);
159
+ } catch (e) {
160
+ return { error: 'parse_error', path: file, message: e.message };
161
+ }
162
+ const err = validatePlan(plan);
163
+ if (err) {
164
+ return {
165
+ error: err.code,
166
+ path: file,
167
+ message: err.message,
168
+ ...(err.missing_keys ? { missing_keys: err.missing_keys } : {}),
169
+ };
170
+ }
171
+ return plan;
172
+ }
173
+
174
+ // Atomic write. Caller is responsible for serialization (plan.lock) when
175
+ // concurrent writers may interleave (skill rewrite vs autopilot markDone).
176
+ // Single-write callers can rely on tmp+rename atomicity alone.
177
+ function writeAtomic(file, body) {
178
+ const dir = path.dirname(file);
179
+ fs.mkdirSync(dir, { recursive: true });
180
+ const tmp = path.join(
181
+ dir,
182
+ `.${path.basename(file)}.tmp.${process.pid}.${process.hrtime.bigint().toString(36)}`,
183
+ );
184
+ const fd = fs.openSync(tmp, 'w', 0o644);
185
+ try {
186
+ fs.writeFileSync(fd, body);
187
+ try {
188
+ fs.fsyncSync(fd);
189
+ } catch {
190
+ /* fsync unsupported on some filesystems */
191
+ }
192
+ } finally {
193
+ fs.closeSync(fd);
194
+ }
195
+ fs.renameSync(tmp, file);
196
+ if (process.platform !== 'win32') {
197
+ try {
198
+ const dfd = fs.openSync(dir, 'r');
199
+ try {
200
+ fs.fsyncSync(dfd);
201
+ } finally {
202
+ fs.closeSync(dfd);
203
+ }
204
+ } catch {
205
+ /* directory fsync unsupported on some filesystems */
206
+ }
207
+ }
208
+ }
209
+
210
+ // Write plan. Validates schema, stamps `generated`, serializes via js-yaml,
211
+ // atomic tmp+rename. Throws (with a descriptive message) on validation
212
+ // failure — write() is for callers that know they have a valid plan; for
213
+ // LLM-produced or user-edited content, validate via validatePlan() first.
214
+ function write(plan, { projectRoot }) {
215
+ const err = validatePlan(plan);
216
+ if (err) {
217
+ throw new Error(`invalid plan: ${err.message}`);
218
+ }
219
+ // Stamp generated timestamp on every successful write — provides a
220
+ // monotonic "last written at" for staleness detection.
221
+ const stamped = { ...plan, generated: new Date().toISOString() };
222
+ const file = planPath(projectRoot);
223
+ const body = yaml.dump(stamped, {
224
+ lineWidth: 120,
225
+ noRefs: true,
226
+ sortKeys: false,
227
+ quotingType: '"',
228
+ });
229
+ writeAtomic(file, body);
230
+ return file;
231
+ }
232
+
233
+ // ---------------------------------------------------------------
234
+ // Mutators (Phase 2)
235
+ // ---------------------------------------------------------------
236
+
237
+ // Parse the epic prefix from a story key. Duplicated from resolve-dag.js
238
+ // (kept here to avoid a circular import — resolve-dag.js requires
239
+ // sprint-plan.js).
240
+ function parseEpicFromKey(storyKey) {
241
+ const s = String(storyKey);
242
+ if (!s) return null;
243
+ const m = s.match(/^([A-Za-z0-9]+)(?:-|$)/);
244
+ return m ? m[1] : null;
245
+ }
246
+
247
+ // BMad statuses that should eagerly transition a story to plan_status=done.
248
+ // Mirrors TERMINAL_STATUSES in autopilot.js (kept in sync via tests).
249
+ const TERMINAL_BMAD_STATUSES = new Set([
250
+ 'done',
251
+ 'skipped',
252
+ 'wont_do',
253
+ "won't_do",
254
+ 'cancelled',
255
+ 'canceled',
256
+ 'deferred',
257
+ 'abandoned',
258
+ ]);
259
+
260
+ function findStoryIdx(plan, key) {
261
+ if (!plan || !Array.isArray(plan.stories)) return -1;
262
+ return plan.stories.findIndex((s) => s && s.key === key);
263
+ }
264
+
265
+ function findEpicIdx(plan, id) {
266
+ if (!plan || !Array.isArray(plan.epics)) return -1;
267
+ return plan.epics.findIndex((e) => e && String(e.id) === String(id));
268
+ }
269
+
270
+ // v2.3.0 — acquire .sprintpilot/plan.lock for the duration of a
271
+ // read-modify-write cycle. Mutual exclusion between:
272
+ // - concurrent autopilot sessions calling markDone after STORY_DONE
273
+ // - the /sprintpilot-plan-sprint skill doing a full plan rewrite
274
+ // - any CLI invocation of `sprint-plan.js write` (skill shell-out)
275
+ //
276
+ // Implementation shells out to lock.js (same primitive used by
277
+ // preflight-merge.js and submodule-lock.js). 30s timeout, 200ms retry
278
+ // interval. Throws when contention exceeds the timeout so the caller
279
+ // can surface a clear "another session is rewriting the plan" error.
280
+ function acquirePlanLock(projectRoot, timeoutSec = 30) {
281
+ const lockFile = path.join(projectRoot, LOCK_FILE_REL);
282
+ fs.mkdirSync(path.dirname(lockFile), { recursive: true });
283
+ const deadline = Date.now() + Math.max(1, timeoutSec) * 1000;
284
+ while (Date.now() < deadline) {
285
+ const res = spawnSync(
286
+ process.execPath,
287
+ [LOCK_SCRIPT_PATH, 'acquire', '--file', lockFile, '--stale-minutes', String(LOCK_STALE_MINUTES)],
288
+ { encoding: 'utf8' },
289
+ );
290
+ const stdout = (res.stdout || '').trim();
291
+ if (res.status === 0 && stdout.startsWith('ACQUIRED')) {
292
+ return lockFile;
293
+ }
294
+ // Brief pause before retrying — keeps the busy-loop friendly.
295
+ spawnSync(process.execPath, ['-e', 'setTimeout(()=>process.exit(0), 200)'], {
296
+ stdio: 'ignore',
297
+ });
298
+ }
299
+ const err = new Error(
300
+ `sprint-plan.yaml lock not acquired within ${timeoutSec}s — another session is mutating the plan`,
301
+ );
302
+ err.code = 'lock_timeout';
303
+ throw err;
304
+ }
305
+
306
+ function releasePlanLock(lockFile) {
307
+ if (!lockFile) return;
308
+ spawnSync(process.execPath, [LOCK_SCRIPT_PATH, 'release', '--file', lockFile], {
309
+ encoding: 'utf8',
310
+ stdio: 'ignore',
311
+ });
312
+ }
313
+
314
+ // Read-modify-write helper used by every mutator. The mutator function
315
+ // receives a structuredClone of the plan and returns the mutated plan;
316
+ // validatePlan + atomic write are handled here. Throws cleanly on:
317
+ // - missing plan file (cannot mutate what does not exist)
318
+ // - corrupt plan file
319
+ // - schema-invalid mutator output
320
+ //
321
+ // v2.3.0 — acquires .sprintpilot/plan.lock for the full read→fn→write
322
+ // cycle so concurrent mutators (skill rewrite vs autopilot markDone)
323
+ // can't lose each other's updates.
324
+ function mutate(projectRoot, fn) {
325
+ const lockFile = acquirePlanLock(projectRoot);
326
+ try {
327
+ const result = read({ projectRoot });
328
+ if (result === null) {
329
+ const err = new Error(
330
+ `no sprint-plan.yaml at ${planPath(projectRoot)} — bootstrap via sprint-plan.js write or the planning skill first`,
331
+ );
332
+ err.code = 'no_plan';
333
+ throw err;
334
+ }
335
+ if (result && typeof result === 'object' && 'error' in result) {
336
+ const err = new Error(`cannot mutate corrupt plan: ${result.message}`);
337
+ err.code = result.error;
338
+ throw err;
339
+ }
340
+ const clone = JSON.parse(JSON.stringify(result));
341
+ const next = fn(clone);
342
+ return write(next, { projectRoot });
343
+ } finally {
344
+ releasePlanLock(lockFile);
345
+ }
346
+ }
347
+
348
+ // Ensure a story entry exists in plan.stories[]. If the story is absent,
349
+ // adds a minimal entry with sensible defaults (this is a no-op for callers
350
+ // that already created the entry via the skill curation flow).
351
+ function ensureStoryEntry(plan, story_key, { added_by = 'auto' } = {}) {
352
+ const idx = findStoryIdx(plan, story_key);
353
+ if (idx !== -1) return idx;
354
+ plan.stories.push({
355
+ key: story_key,
356
+ epic: parseEpicFromKey(story_key),
357
+ title: null,
358
+ bmad_status: null,
359
+ plan_status: 'pending',
360
+ issue_id: null,
361
+ priority: plan.stories.length + 1,
362
+ upstream: [],
363
+ cross_epic_upstream: [],
364
+ rationale: null,
365
+ added_by,
366
+ added_at: new Date().toISOString(),
367
+ completed_at: null,
368
+ });
369
+ return plan.stories.length - 1;
370
+ }
371
+
372
+ // Mark a story done. Sets plan_status=done + completed_at + clears
373
+ // current_step (a streaming-progress field set by markRunning).
374
+ function markDone(story_key, { projectRoot }) {
375
+ return mutate(projectRoot, (plan) => {
376
+ const idx = ensureStoryEntry(plan, story_key, { added_by: 'auto' });
377
+ plan.stories[idx] = {
378
+ ...plan.stories[idx],
379
+ plan_status: 'done',
380
+ completed_at: new Date().toISOString(),
381
+ current_step: null,
382
+ };
383
+ return plan;
384
+ });
385
+ }
386
+
387
+ // Mark a story skipped. The `reason` is recorded in a new top-level
388
+ // `skip_reason` field on the story entry; it's free-text user input
389
+ // (e.g., "blocked on external service" or "dropped from sprint").
390
+ function markSkipped(story_key, reason, { projectRoot }) {
391
+ return mutate(projectRoot, (plan) => {
392
+ const idx = ensureStoryEntry(plan, story_key, { added_by: 'auto' });
393
+ plan.stories[idx] = {
394
+ ...plan.stories[idx],
395
+ plan_status: 'skipped',
396
+ skip_reason: typeof reason === 'string' ? reason : null,
397
+ current_step: null,
398
+ };
399
+ return plan;
400
+ });
401
+ }
402
+
403
+ // Mark several stories excluded (not in the active plan scope but kept
404
+ // for context — e.g., upstream stories already done in sprint-status).
405
+ function markExcluded(story_keys, { projectRoot }) {
406
+ if (!Array.isArray(story_keys)) {
407
+ throw new Error('markExcluded expects an array of story keys');
408
+ }
409
+ return mutate(projectRoot, (plan) => {
410
+ for (const key of story_keys) {
411
+ const idx = ensureStoryEntry(plan, key, { added_by: 'auto' });
412
+ plan.stories[idx] = {
413
+ ...plan.stories[idx],
414
+ plan_status: 'excluded',
415
+ current_step: null,
416
+ };
417
+ }
418
+ return plan;
419
+ });
420
+ }
421
+
422
+ // Mark a story "running step_name" — transient streaming-progress signal
423
+ // consumed by `autopilot progress` (Phase 4.5). step_name=null clears the
424
+ // field. Cleared automatically by markDone/markSkipped.
425
+ function markRunning(story_key, step_name, { projectRoot }) {
426
+ return mutate(projectRoot, (plan) => {
427
+ const idx = ensureStoryEntry(plan, story_key, { added_by: 'auto' });
428
+ plan.stories[idx] = {
429
+ ...plan.stories[idx],
430
+ current_step: step_name || null,
431
+ };
432
+ return plan;
433
+ });
434
+ }
435
+
436
+ // Determine an insertion index from a position spec:
437
+ // 'end' → after the last existing entry
438
+ // 'after:<key>' → immediately after the entry with key=<key>; appends if missing
439
+ // <integer> → 0-based index (negative = from end; clamped)
440
+ function resolveInsertIdx(stories, position) {
441
+ if (position === undefined || position === null || position === 'end') {
442
+ return stories.length;
443
+ }
444
+ if (typeof position === 'number' && Number.isFinite(position)) {
445
+ const i = Math.trunc(position);
446
+ if (i < 0) return Math.max(0, stories.length + i + 1);
447
+ return Math.min(stories.length, i);
448
+ }
449
+ if (typeof position === 'string' && position.startsWith('after:')) {
450
+ const key = position.slice('after:'.length);
451
+ const idx = stories.findIndex((s) => s && s.key === key);
452
+ return idx === -1 ? stories.length : idx + 1;
453
+ }
454
+ throw new Error(`unknown position spec: ${JSON.stringify(position)}`);
455
+ }
456
+
457
+ // Rewrite priority field for all stories to match their list-index order.
458
+ // 1-indexed per the schema (smaller = runs first).
459
+ function reassignPriorities(stories) {
460
+ for (let i = 0; i < stories.length; i++) {
461
+ if (stories[i]) stories[i].priority = i + 1;
462
+ }
463
+ }
464
+
465
+ // Add story entries to the plan.
466
+ // entries: array of partial story objects ({key, title?, epic?, ...}).
467
+ // `key` is required. Other fields default to sensible empties.
468
+ // position: 'end' | 'after:<key>' | integer (see resolveInsertIdx).
469
+ // Throws if any key is missing, already present in stories[], or position
470
+ // is malformed. Re-assigns priorities across the entire list.
471
+ function addStories(entries, { projectRoot, position = 'end' }) {
472
+ if (!Array.isArray(entries) || entries.length === 0) {
473
+ throw new Error('addStories requires a non-empty array of entries');
474
+ }
475
+ return mutate(projectRoot, (plan) => {
476
+ const seenKeys = new Set(plan.stories.map((s) => s && s.key));
477
+ const now = new Date().toISOString();
478
+ const newEntries = [];
479
+ for (const raw of entries) {
480
+ if (!raw || typeof raw !== 'object' || typeof raw.key !== 'string' || raw.key === '') {
481
+ throw new Error('each entry must be an object with a non-empty `key`');
482
+ }
483
+ if (seenKeys.has(raw.key)) {
484
+ throw new Error(`story ${raw.key} is already in the plan`);
485
+ }
486
+ seenKeys.add(raw.key);
487
+ newEntries.push({
488
+ key: raw.key,
489
+ epic: raw.epic ?? parseEpicFromKey(raw.key),
490
+ title: raw.title ?? null,
491
+ bmad_status: raw.bmad_status ?? null,
492
+ plan_status: raw.plan_status ?? 'pending',
493
+ issue_id: raw.issue_id ?? null,
494
+ priority: null,
495
+ upstream: Array.isArray(raw.upstream) ? raw.upstream.slice() : [],
496
+ cross_epic_upstream: Array.isArray(raw.cross_epic_upstream)
497
+ ? raw.cross_epic_upstream.slice()
498
+ : [],
499
+ rationale: raw.rationale ?? null,
500
+ added_by: raw.added_by ?? 'user',
501
+ added_at: raw.added_at ?? now,
502
+ completed_at: null,
503
+ });
504
+ }
505
+ const insertAt = resolveInsertIdx(plan.stories, position);
506
+ plan.stories = [
507
+ ...plan.stories.slice(0, insertAt),
508
+ ...newEntries,
509
+ ...plan.stories.slice(insertAt),
510
+ ];
511
+ reassignPriorities(plan.stories);
512
+ return plan;
513
+ });
514
+ }
515
+
516
+ // Remove (status-mark) several stories in one shot.
517
+ // keys: array of story_keys to mark
518
+ // status: 'skipped' | 'deferred' — the plan_status to apply
519
+ // Note: this does NOT physically remove entries — it sets plan_status so
520
+ // the queue resolver skips them. Physical removal is left to the user
521
+ // editing sprint-plan.yaml directly.
522
+ function removeStories(keys, { projectRoot, status = 'skipped' }) {
523
+ if (!Array.isArray(keys) || keys.length === 0) {
524
+ throw new Error('removeStories requires a non-empty array of keys');
525
+ }
526
+ if (status !== 'skipped' && status !== 'deferred') {
527
+ throw new Error(`removeStories status must be 'skipped' or 'deferred' (got ${JSON.stringify(status)})`);
528
+ }
529
+ return mutate(projectRoot, (plan) => {
530
+ const missing = [];
531
+ for (const key of keys) {
532
+ const idx = findStoryIdx(plan, key);
533
+ if (idx === -1) {
534
+ missing.push(key);
535
+ continue;
536
+ }
537
+ plan.stories[idx] = {
538
+ ...plan.stories[idx],
539
+ plan_status: status,
540
+ current_step: null,
541
+ };
542
+ }
543
+ if (missing.length > 0) {
544
+ const err = new Error(`stories not in plan: ${missing.join(', ')}`);
545
+ err.code = 'missing_keys';
546
+ err.missing_keys = missing;
547
+ throw err;
548
+ }
549
+ return plan;
550
+ });
551
+ }
552
+
553
+ // Characters that break mermaid label parsing (and graphviz label
554
+ // quoting) when concatenated into a node label by composeStoryLabel /
555
+ // composeEpicLabel. Reject at capture time so the plan never holds
556
+ // values that would corrupt the rendered DAG. Mirrors the defensive
557
+ // escape in resolve-dag.js#mermaidEscapeLabel — we want the validation
558
+ // at the data boundary rather than relying solely on escape-at-render.
559
+ //
560
+ // Round 2: expanded to match the renderer's escape set:
561
+ // - mermaid bracket-syntax: [ ] ( ) < >
562
+ // - mermaid link-label syntax: |
563
+ // - statement separator: ;
564
+ // - HTML-entity start: &
565
+ // - newlines, carriage returns, ASCII control chars
566
+ // - Unicode RTL/LTR override marks (visual-reorder attack)
567
+ // Tracker IDs from Jira/Linear/GitHub/GitLab don't legitimately use
568
+ // any of these.
569
+ const ISSUE_ID_REJECT_CHARS = /[\[\]<>|;&\n\r\x00-\x1f\x7f‪-‮⁦-⁩؜]/;
570
+
571
+ // Set issue_id on either an epic or a story entity. Looks up the entity
572
+ // by key/id (epic first since epic ids are typically shorter strings).
573
+ // Creates a story entry if missing (the issue_id is preserved even when
574
+ // the story isn't yet curated into the plan). Returns the entity-kind +
575
+ // index so callers can confirm what was updated.
576
+ function setIssueId(entity_key, issue_id, { projectRoot }) {
577
+ if (typeof entity_key !== 'string' || entity_key === '') {
578
+ throw new Error('setIssueId requires a non-empty entity_key');
579
+ }
580
+ if (issue_id !== null && typeof issue_id !== 'string') {
581
+ throw new Error('setIssueId requires issue_id to be a string or null');
582
+ }
583
+ // v2.3.0 — reject characters that would corrupt rendered DAG labels
584
+ // even if the renderer escapes them. Defense in depth: bad data
585
+ // doesn't reach the plan file. Tracker IDs like Jira/Linear/GitHub
586
+ // don't legitimately contain these characters.
587
+ if (typeof issue_id === 'string' && ISSUE_ID_REJECT_CHARS.test(issue_id)) {
588
+ throw new Error(
589
+ `setIssueId rejected issue_id ${JSON.stringify(issue_id)}: contains forbidden character ` +
590
+ `([ ] < > newline). Tracker IDs (Jira/Linear/GitHub/GitLab) don't legitimately contain these.`,
591
+ );
592
+ }
593
+ // Also reject length over 200 — same cap as cross-epic rationale,
594
+ // chosen to prevent runaway labels from making the DAG render unreadable.
595
+ if (typeof issue_id === 'string' && issue_id.length > 200) {
596
+ throw new Error(
597
+ `setIssueId rejected issue_id of length ${issue_id.length}: max is 200 chars`,
598
+ );
599
+ }
600
+ let result = null;
601
+ mutate(projectRoot, (plan) => {
602
+ const epicIdx = findEpicIdx(plan, entity_key);
603
+ if (epicIdx !== -1) {
604
+ plan.epics[epicIdx] = { ...plan.epics[epicIdx], issue_id };
605
+ result = { kind: 'epic', index: epicIdx };
606
+ return plan;
607
+ }
608
+ const storyIdx = ensureStoryEntry(plan, entity_key, { added_by: 'auto' });
609
+ plan.stories[storyIdx] = { ...plan.stories[storyIdx], issue_id };
610
+ result = { kind: 'story', index: storyIdx };
611
+ return plan;
612
+ });
613
+ return result;
614
+ }
615
+
616
+ // Write the top-level issue_tracker block. Accepts a partial config; null
617
+ // fields clear the tracker entirely. Returns the file path.
618
+ function setIssueTracker(config, { projectRoot }) {
619
+ if (config !== null && (typeof config !== 'object' || Array.isArray(config))) {
620
+ throw new Error('setIssueTracker requires an object or null');
621
+ }
622
+ return mutate(projectRoot, (plan) => {
623
+ if (config === null) {
624
+ plan.issue_tracker = null;
625
+ } else {
626
+ plan.issue_tracker = {
627
+ provider: config.provider ?? null,
628
+ base_url: config.base_url ?? null,
629
+ project_key: config.project_key ?? null,
630
+ };
631
+ }
632
+ return plan;
633
+ });
634
+ }
635
+
636
+ // Read story keys + their bmad status from sprint-status.yaml. Mirrors the
637
+ // pull logic in resolve-dag.js#readStoriesFromStatus but keeps this module
638
+ // independent (so sprint-plan.js doesn't depend on resolve-dag.js — that
639
+ // import direction would create a cycle). Returns Map<key, status>.
640
+ function readBmadStatuses(projectRoot) {
641
+ const ssFile = path.join(
642
+ projectRoot,
643
+ '_bmad-output',
644
+ 'implementation-artifacts',
645
+ 'sprint-status.yaml',
646
+ );
647
+ const out = new Map();
648
+ if (!fs.existsSync(ssFile)) return out;
649
+ const raw = fs.readFileSync(ssFile, 'utf8');
650
+ const lines = raw.split(/\r?\n/);
651
+ let inStories = false;
652
+ let storyIndent = null;
653
+ for (const rawLine of lines) {
654
+ const trimmed = rawLine.trimEnd();
655
+ if (/^(development_status|stories):\s*$/.test(trimmed)) {
656
+ inStories = true;
657
+ storyIndent = null;
658
+ continue;
659
+ }
660
+ if (inStories && /^\S/.test(trimmed)) {
661
+ inStories = false;
662
+ storyIndent = null;
663
+ }
664
+ if (!inStories) continue;
665
+ const m = trimmed.match(/^([\t ]+)([A-Za-z0-9][A-Za-z0-9-]*):\s*(\S+)?/);
666
+ if (!m) continue;
667
+ if (storyIndent === null) storyIndent = m[1];
668
+ else if (m[1] !== storyIndent) continue;
669
+ const status = m[3] ? m[3].replace(/^["']|["']$/g, '') : null;
670
+ out.set(m[2], status);
671
+ }
672
+ return out;
673
+ }
674
+
675
+ // Refresh cached bmad_status fields on every plan entry from
676
+ // sprint-status.yaml. For stories whose bmad_status is in
677
+ // TERMINAL_BMAD_STATUSES, eagerly transition plan_status to 'done' so
678
+ // the queue resolver doesn't pick them up.
679
+ //
680
+ // Skips the disk write when nothing changed (mitigates Risk #23 disk
681
+ // thrashing). Returns { wrote, changed: { stories, epics, transitions } }.
682
+ //
683
+ // v2.3.0 Round 2 — acquires plan.lock for the full read→diff→write
684
+ // cycle. Two concurrent autopilot sessions calling refreshBmadStatus
685
+ // (e.g., during cmdStart) would otherwise race on the read+write,
686
+ // losing each other's bmad_status updates. The lock matches the one
687
+ // used by mutate() + archive().
688
+ function refreshBmadStatus({ projectRoot }) {
689
+ const bmad = readBmadStatuses(projectRoot);
690
+ let storyChanges = 0;
691
+ let epicChanges = 0;
692
+ let transitions = 0;
693
+
694
+ // Read-only fast path before acquiring the lock — if the plan
695
+ // doesn't exist or is corrupt, we don't need exclusive access.
696
+ // Avoids unnecessary lock acquisition on greenfield projects.
697
+ const probe = read({ projectRoot });
698
+ if (probe === null) {
699
+ return { wrote: false, changed: { stories: 0, epics: 0, transitions: 0 }, reason: 'no_plan' };
700
+ }
701
+ if (probe && typeof probe === 'object' && 'error' in probe) {
702
+ return {
703
+ wrote: false,
704
+ changed: { stories: 0, epics: 0, transitions: 0 },
705
+ reason: probe.error,
706
+ message: probe.message,
707
+ };
708
+ }
709
+
710
+ const lockFile = acquirePlanLock(projectRoot);
711
+ try {
712
+ // Re-read inside the lock — the plan might have changed between
713
+ // the unlocked probe and lock acquisition. Without this we'd diff
714
+ // against stale data.
715
+ const result = read({ projectRoot });
716
+ if (result === null) {
717
+ return { wrote: false, changed: { stories: 0, epics: 0, transitions: 0 }, reason: 'no_plan' };
718
+ }
719
+ if (result && typeof result === 'object' && 'error' in result) {
720
+ return {
721
+ wrote: false,
722
+ changed: { stories: 0, epics: 0, transitions: 0 },
723
+ reason: result.error,
724
+ message: result.message,
725
+ };
726
+ }
727
+ const next = JSON.parse(JSON.stringify(result));
728
+
729
+ // Stories
730
+ for (let i = 0; i < next.stories.length; i++) {
731
+ const entry = next.stories[i];
732
+ if (!entry || !entry.key) continue;
733
+ const observed = bmad.has(entry.key) ? bmad.get(entry.key) : null;
734
+ if (observed !== entry.bmad_status) {
735
+ entry.bmad_status = observed;
736
+ storyChanges += 1;
737
+ }
738
+ if (
739
+ observed !== null &&
740
+ TERMINAL_BMAD_STATUSES.has(observed) &&
741
+ entry.plan_status !== 'done' &&
742
+ entry.plan_status !== 'skipped' &&
743
+ entry.plan_status !== 'excluded'
744
+ ) {
745
+ entry.plan_status = 'done';
746
+ entry.completed_at = entry.completed_at || new Date().toISOString();
747
+ entry.current_step = null;
748
+ transitions += 1;
749
+ }
750
+ }
751
+
752
+ // Epics: aggregate bmad_status from contained stories. backlog if any
753
+ // story is non-terminal; done if every story is terminal; in-progress
754
+ // otherwise. This is a heuristic; users can override via direct YAML edit.
755
+ for (let i = 0; i < next.epics.length; i++) {
756
+ const epic = next.epics[i];
757
+ if (!epic || !epic.id) continue;
758
+ const epicStories = next.stories.filter(
759
+ (s) => s && (s.epic === epic.id || String(s.epic) === String(epic.id)),
760
+ );
761
+ let aggregate = null;
762
+ if (epicStories.length === 0) {
763
+ aggregate = epic.bmad_status; // preserve whatever was set
764
+ } else {
765
+ const allTerminal = epicStories.every(
766
+ (s) => s.bmad_status && TERMINAL_BMAD_STATUSES.has(s.bmad_status),
767
+ );
768
+ const anyTerminal = epicStories.some(
769
+ (s) => s.bmad_status && TERMINAL_BMAD_STATUSES.has(s.bmad_status),
770
+ );
771
+ aggregate = allTerminal ? 'done' : anyTerminal ? 'in-progress' : 'backlog';
772
+ }
773
+ if (aggregate !== epic.bmad_status) {
774
+ epic.bmad_status = aggregate;
775
+ epicChanges += 1;
776
+ }
777
+ }
778
+
779
+ const noOp = storyChanges === 0 && epicChanges === 0 && transitions === 0;
780
+ if (noOp) {
781
+ return { wrote: false, changed: { stories: 0, epics: 0, transitions: 0 } };
782
+ }
783
+ const file = write(next, { projectRoot });
784
+ return {
785
+ wrote: true,
786
+ file,
787
+ changed: { stories: storyChanges, epics: epicChanges, transitions },
788
+ };
789
+ } finally {
790
+ releasePlanLock(lockFile);
791
+ }
792
+ }
793
+
794
+ // Archive the current sprint-plan.yaml to .archive/sprint-plan-<plan_id>.yaml
795
+ // then delete the live file. Idempotent if already archived (no-op when the
796
+ // live file doesn't exist). Returns the archive path.
797
+ // v2.3.0 — wrapped in plan.lock so a concurrent skill/markDone can't
798
+ // write to sprint-plan.yaml between our copyFile + unlink. Same lock
799
+ // as mutate() — serializes against all sprint-plan.js writers.
800
+ function archive(plan_id, { projectRoot }) {
801
+ const lockFile = acquirePlanLock(projectRoot);
802
+ try {
803
+ const livePath = planPath(projectRoot);
804
+ if (!fs.existsSync(livePath)) {
805
+ return { archived: false, reason: 'no_live_plan' };
806
+ }
807
+ const id =
808
+ typeof plan_id === 'string' && plan_id !== ''
809
+ ? plan_id
810
+ : `unknown-${Date.now().toString(36)}`;
811
+ const archiveDir = path.join(projectRoot, '.archive');
812
+ fs.mkdirSync(archiveDir, { recursive: true });
813
+ let archivePath = path.join(archiveDir, `sprint-plan-${id}.yaml`);
814
+ let counter = 1;
815
+ while (fs.existsSync(archivePath)) {
816
+ archivePath = path.join(archiveDir, `sprint-plan-${id}.${counter}.yaml`);
817
+ counter += 1;
818
+ }
819
+ fs.copyFileSync(livePath, archivePath);
820
+ fs.unlinkSync(livePath);
821
+ return { archived: true, file: archivePath };
822
+ } finally {
823
+ releasePlanLock(lockFile);
824
+ }
825
+ }
826
+
827
+ // Reorder stories according to newOrder (an array of story_keys). The
828
+ // resulting list contains exactly the stories in newOrder (any plan
829
+ // stories NOT mentioned are appended at the end in their original
830
+ // relative order, so the caller can omit excluded/deferred entries
831
+ // without losing them). Priorities are rewritten 1-indexed.
832
+ //
833
+ // Caller is responsible for DAG validation (sprint-plan.js doesn't know
834
+ // about dependencies semantics — that lives in the orchestrator-side
835
+ // helper in Phase 5).
836
+ function reorder(newOrder, { projectRoot }) {
837
+ // M2 (v2.3.0) — empty input rejected for consistency with addStories
838
+ // and removeStories. Previously was a silent no-op which masked
839
+ // upstream bugs (caller computed an empty list and didn't notice).
840
+ if (!Array.isArray(newOrder) || newOrder.length === 0) {
841
+ throw new Error('reorder requires a non-empty array of story keys');
842
+ }
843
+ return mutate(projectRoot, (plan) => {
844
+ const byKey = new Map();
845
+ for (const s of plan.stories) if (s && s.key) byKey.set(s.key, s);
846
+ const unknown = [];
847
+ // M1 (v2.3.0) — keys whose plan_status is terminal (done / skipped /
848
+ // excluded) are rejected upfront. Reordering them is a UX trap: the
849
+ // user thinks their reorder placed the story but composePlanQueue
850
+ // filters non-pending entries out anyway, so the request silently
851
+ // does nothing. Surface it as an explicit error.
852
+ const terminalStatuses = new Set(['done', 'skipped', 'excluded']);
853
+ const terminal = [];
854
+ const seen = new Set();
855
+ const ordered = [];
856
+ for (const key of newOrder) {
857
+ if (seen.has(key)) continue; // dedupe silently
858
+ seen.add(key);
859
+ const entry = byKey.get(key);
860
+ if (!entry) {
861
+ unknown.push(key);
862
+ continue;
863
+ }
864
+ if (terminalStatuses.has(entry.plan_status)) {
865
+ terminal.push({ key, plan_status: entry.plan_status });
866
+ continue;
867
+ }
868
+ ordered.push(entry);
869
+ }
870
+ if (unknown.length > 0) {
871
+ const err = new Error(`reorder references stories not in plan: ${unknown.join(', ')}`);
872
+ err.code = 'unknown_keys';
873
+ err.unknown_keys = unknown;
874
+ throw err;
875
+ }
876
+ if (terminal.length > 0) {
877
+ const labelled = terminal.map((t) => `${t.key} (${t.plan_status})`).join(', ');
878
+ const err = new Error(
879
+ `reorder includes stories whose plan_status is terminal: ${labelled} — ` +
880
+ `terminal stories are not in the queue and can't be reordered`,
881
+ );
882
+ err.code = 'terminal_keys';
883
+ err.terminal_keys = terminal;
884
+ throw err;
885
+ }
886
+ // Append any plan entries the caller omitted, preserving relative order.
887
+ const appended = plan.stories.filter((s) => s && s.key && !seen.has(s.key));
888
+ plan.stories = [...ordered, ...appended];
889
+ reassignPriorities(plan.stories);
890
+ return plan;
891
+ });
892
+ }
893
+
894
+ // ---------------------------------------------------------------
895
+ // CLI
896
+ // ---------------------------------------------------------------
897
+
898
+ function help() {
899
+ log.out(
900
+ [
901
+ 'Usage:',
902
+ ' sprint-plan.js read [--project-root <path>]',
903
+ ' sprint-plan.js write [--project-root <path>] (plan via stdin as YAML or JSON)',
904
+ ' sprint-plan.js validate [--project-root <path>] (plan via stdin)',
905
+ ' sprint-plan.js empty [--source <skill|auto|cli|migrated>]',
906
+ '',
907
+ 'Phase-0 primitive: read + write + validate + empty. Richer mutators',
908
+ '(markDone, addStories, reorder, refreshBmadStatus) land in Phase 2.',
909
+ ].join('\n'),
910
+ );
911
+ }
912
+
913
+ function readStdin() {
914
+ return new Promise((resolve, reject) => {
915
+ let buf = '';
916
+ process.stdin.setEncoding('utf8');
917
+ process.stdin.on('data', (c) => {
918
+ buf += c;
919
+ });
920
+ process.stdin.on('end', () => resolve(buf));
921
+ process.stdin.on('error', reject);
922
+ });
923
+ }
924
+
925
+ function parseStdinPlan(text) {
926
+ const trimmed = text.trim();
927
+ if (trimmed === '') {
928
+ return { ok: false, error: 'empty stdin' };
929
+ }
930
+ // Try JSON first (LLM envelopes often arrive as JSON); fall back to YAML.
931
+ try {
932
+ return { ok: true, plan: JSON.parse(trimmed) };
933
+ } catch {
934
+ /* fall through to YAML */
935
+ }
936
+ try {
937
+ return { ok: true, plan: yaml.load(trimmed) };
938
+ } catch (e) {
939
+ return { ok: false, error: `parse failed: ${e.message}` };
940
+ }
941
+ }
942
+
943
+ async function runRead(projectRoot) {
944
+ const result = read({ projectRoot });
945
+ if (result === null) {
946
+ process.stdout.write(JSON.stringify({ exists: false, plan: null }) + '\n');
947
+ return 0;
948
+ }
949
+ if (result && result.error) {
950
+ process.stdout.write(JSON.stringify({ exists: true, plan: null, ...result }) + '\n');
951
+ return 1;
952
+ }
953
+ process.stdout.write(JSON.stringify({ exists: true, plan: result }) + '\n');
954
+ return 0;
955
+ }
956
+
957
+ async function runValidate(projectRoot) {
958
+ const stdin = await readStdin();
959
+ const parsed = parseStdinPlan(stdin);
960
+ if (!parsed.ok) {
961
+ process.stdout.write(JSON.stringify({ valid: false, error: parsed.error }) + '\n');
962
+ return 1;
963
+ }
964
+ const err = validatePlan(parsed.plan);
965
+ if (err) {
966
+ process.stdout.write(JSON.stringify({ valid: false, ...err }) + '\n');
967
+ return 1;
968
+ }
969
+ process.stdout.write(JSON.stringify({ valid: true }) + '\n');
970
+ return 0;
971
+ }
972
+
973
+ async function runWrite(projectRoot) {
974
+ const stdin = await readStdin();
975
+ const parsed = parseStdinPlan(stdin);
976
+ if (!parsed.ok) {
977
+ process.stdout.write(JSON.stringify({ wrote: false, error: parsed.error }) + '\n');
978
+ return 1;
979
+ }
980
+ const err = validatePlan(parsed.plan);
981
+ if (err) {
982
+ process.stdout.write(JSON.stringify({ wrote: false, ...err }) + '\n');
983
+ return 1;
984
+ }
985
+ const file = write(parsed.plan, { projectRoot });
986
+ process.stdout.write(JSON.stringify({ wrote: true, file }) + '\n');
987
+ return 0;
988
+ }
989
+
990
+ async function runEmpty(source) {
991
+ let plan;
992
+ try {
993
+ plan = emptyPlan({ source });
994
+ } catch (e) {
995
+ process.stdout.write(JSON.stringify({ ok: false, error: e.message }) + '\n');
996
+ return 1;
997
+ }
998
+ process.stdout.write(yaml.dump(plan, { lineWidth: 120, noRefs: true, sortKeys: false }));
999
+ return 0;
1000
+ }
1001
+
1002
+ async function main() {
1003
+ const { opts, positional } = parseArgs(process.argv.slice(2));
1004
+ if (opts.help || positional.length === 0) {
1005
+ help();
1006
+ process.exit(opts.help ? 0 : 1);
1007
+ }
1008
+ const command = positional[0];
1009
+ if (!VALID_COMMANDS.includes(command)) {
1010
+ log.error(`unknown command '${command}'. Valid: ${VALID_COMMANDS.join(', ')}`);
1011
+ process.exit(1);
1012
+ }
1013
+ const projectRoot = opts['project-root'] || process.cwd();
1014
+
1015
+ try {
1016
+ if (command === 'read') process.exit(await runRead(projectRoot));
1017
+ if (command === 'write') process.exit(await runWrite(projectRoot));
1018
+ if (command === 'validate') process.exit(await runValidate(projectRoot));
1019
+ if (command === 'empty') {
1020
+ const source = opts.source || 'skill';
1021
+ process.exit(await runEmpty(source));
1022
+ }
1023
+ } catch (e) {
1024
+ log.error(`unexpected error: ${e.stack || e.message}`);
1025
+ process.exit(1);
1026
+ }
1027
+ }
1028
+
1029
+ module.exports = {
1030
+ SCHEMA_VERSION,
1031
+ PLAN_FILE_REL,
1032
+ LOCK_FILE_REL,
1033
+ LOCK_STALE_MINUTES,
1034
+ VALID_SOURCES,
1035
+ TERMINAL_BMAD_STATUSES,
1036
+ planPath,
1037
+ lockPath,
1038
+ emptyPlan,
1039
+ validatePlan,
1040
+ read,
1041
+ write,
1042
+ writeAtomic,
1043
+ parseEpicFromKey,
1044
+ findStoryIdx,
1045
+ findEpicIdx,
1046
+ ensureStoryEntry,
1047
+ acquirePlanLock,
1048
+ releasePlanLock,
1049
+ mutate,
1050
+ markDone,
1051
+ markSkipped,
1052
+ markExcluded,
1053
+ markRunning,
1054
+ resolveInsertIdx,
1055
+ reassignPriorities,
1056
+ addStories,
1057
+ removeStories,
1058
+ reorder,
1059
+ setIssueId,
1060
+ setIssueTracker,
1061
+ readBmadStatuses,
1062
+ refreshBmadStatus,
1063
+ archive,
1064
+ };
1065
+
1066
+ if (require.main === module) {
1067
+ main();
1068
+ }