@ikunin/sprintpilot 2.1.1 → 2.1.2

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.
@@ -128,6 +128,13 @@ function composeRuntimeState(persisted, profile) {
128
128
  user_branch: persisted.user_branch || null,
129
129
  // Land-as-you-go: pending land state survives rebase-conflict halts.
130
130
  land_pending: persisted.land_pending || null,
131
+ // Pending alternative (propose_alternative → user_prompt) survives
132
+ // across halts so the next session re-emits the prompt rather than
133
+ // silently dropping the LLM's proposal.
134
+ pending_alternative: persisted.pending_alternative || null,
135
+ // halt_requested is intentionally NOT carried forward here: cmdStart
136
+ // clears it on each new session (a `pause` cleanly halts THIS session
137
+ // and the next /sprint-autopilot-on resumes normally).
131
138
  };
132
139
  }
133
140
 
@@ -151,6 +158,7 @@ function persistRuntimeState(runtime, profile, projectRoot) {
151
158
  consecutive_test_failures: runtime.consecutive_test_failures,
152
159
  user_branch: runtime.user_branch,
153
160
  land_pending: runtime.land_pending,
161
+ pending_alternative: runtime.pending_alternative || null,
154
162
  };
155
163
  return persistState(updates, profile, projectRoot, runtime.story_key || 'sprint');
156
164
  }
@@ -272,10 +280,12 @@ function applySideEffects(sideEffects, runtime, profile, projectRoot) {
272
280
  },
273
281
  { projectRoot },
274
282
  );
275
- // We log the commands; the CLI caller is responsible for actually
276
- // applying them to the runtime state on the next `next` invocation.
277
- // (e.g. skip_story would update sprint-status; this CLI doesn't
278
- // touch sprint-status directly that's BMad's domain.)
283
+ // adapt.handleUserInput now applies these commands itself (so
284
+ // pause halts on the same turn, accept_alternative dispatches the
285
+ // stored alternative, etc.). This branch is kept purely for the
286
+ // ledger entry — re-applying here would double-mutate state.
287
+ // BMad-owned mutations (e.g. skip_story → sprint-status) still
288
+ // live elsewhere; this CLI never touches sprint-status directly.
279
289
  break;
280
290
  }
281
291
  case 'profile_escalated':
@@ -13,6 +13,7 @@
13
13
  const { STATES, nextAction, nextStateAfterSuccess, nextStoryStart } = require('./state-machine');
14
14
  const { classifyImpact } = require('./impact-classifier');
15
15
  const { escalateOnFailure } = require('./profile-rules');
16
+ const userCommandApplier = require('./user-command-applier');
16
17
 
17
18
  // Threshold for `consecutive_test_failures` — workflow.md:81 says 3.
18
19
  const CONSECUTIVE_TEST_FAILURE_THRESHOLD = 3;
@@ -328,8 +329,20 @@ function handleProposeAlternative(state, signal, profile, sideEffects) {
328
329
  };
329
330
  }
330
331
 
332
+ // Store the proposed alternative on state so a later `accept_alternative`
333
+ // user command can dispatch it. Without this, the alternative would
334
+ // evaporate the moment the prompt is emitted.
335
+ const newState = {
336
+ ...state,
337
+ pending_alternative: {
338
+ action: alternative,
339
+ impact,
340
+ reason: signal.reason || null,
341
+ prompted_at: new Date().toISOString(),
342
+ },
343
+ };
331
344
  return {
332
- newState: state,
345
+ newState,
333
346
  newProfile: profile,
334
347
  nextAction: {
335
348
  type: 'user_prompt',
@@ -346,19 +359,59 @@ function handleProposeAlternative(state, signal, profile, sideEffects) {
346
359
  }
347
360
 
348
361
  function handleUserInput(state, signal, profile, sideEffects) {
349
- // Adapt does not validate commands (that's user-commands.js' job at the CLI
350
- // edge) but it does decide the structural response: a user_input signal
351
- // always triggers a re-emission of nextAction under the new state. The
352
- // CLI edge applies the commands first, then calls adapt with the new state.
362
+ // Apply the user's commands directly so the resulting state changes
363
+ // (halt_requested, cleared pending_alternative, dispatch_action effect)
364
+ // take effect on this same turn. Prior versions only emitted an
365
+ // apply_user_commands side-effect and the CLI never re-dispatched
366
+ // pause never halted, accept_alternative had nowhere to land.
367
+ const commands = signal.commands || [];
368
+ const applied = userCommandApplier.apply(state, profile, commands);
369
+
370
+ // Mirror the legacy apply_user_commands side-effect so the ledger trail
371
+ // stays human-readable (kind: user_commands_applied).
353
372
  sideEffects.push({
354
373
  kind: 'apply_user_commands',
355
- commands: signal.commands || [],
374
+ commands,
356
375
  phase: state.phase,
357
376
  });
377
+ for (const e of applied.sideEffects) sideEffects.push(e);
378
+
379
+ const newState = applied.newState;
380
+ const newProfile = applied.newProfile;
381
+
382
+ // Halt requested? Emit a halt action and let cmdRecord write the
383
+ // resume fingerprint.
384
+ if (newState.halt_requested) {
385
+ return {
386
+ newState,
387
+ newProfile,
388
+ nextAction: {
389
+ type: 'halt',
390
+ phase: newState.phase,
391
+ reason: newState.halt_requested.reason || 'user_pause',
392
+ },
393
+ sideEffects,
394
+ verdict: 'halt',
395
+ };
396
+ }
397
+
398
+ // One-shot dispatch (e.g. accept_alternative resolved a pending alt)?
399
+ // Return the dispatched action in place of the state-machine's default.
400
+ const dispatch = applied.sideEffects.find((e) => e && e.kind === 'dispatch_action');
401
+ if (dispatch && dispatch.action) {
402
+ return {
403
+ newState,
404
+ newProfile,
405
+ nextAction: { ...dispatch.action, _dispatched_via: dispatch.reason || 'user_input' },
406
+ sideEffects,
407
+ verdict: 'advanced',
408
+ };
409
+ }
410
+
358
411
  return {
359
- newState: state,
360
- newProfile: profile,
361
- nextAction: nextAction(state, profile),
412
+ newState,
413
+ newProfile,
414
+ nextAction: nextAction(newState, newProfile),
362
415
  sideEffects,
363
416
  verdict: 'advanced',
364
417
  };
@@ -61,12 +61,15 @@ function applyOne(state, profile, cmd) {
61
61
 
62
62
  case 'force_continue':
63
63
  // Clears verify-reject + retry counters so the orchestrator stops
64
- // looping on a stuck transition. Phase is unchanged.
64
+ // looping on a stuck transition. Phase is unchanged. Also clears
65
+ // any pending_alternative — `force_continue` is the explicit "no,
66
+ // keep the planned action" answer to a propose_alternative prompt.
65
67
  newState = {
66
68
  ...state,
67
69
  retry_count_this_phase: 0,
68
70
  verify_reject_count: 0,
69
71
  consecutive_test_failures: 0,
72
+ pending_alternative: undefined,
70
73
  };
71
74
  effects.push({
72
75
  kind: 'state_transition',
@@ -74,6 +77,7 @@ function applyOne(state, profile, cmd) {
74
77
  to: state.phase,
75
78
  reason: 'user_force_continue',
76
79
  details: cmd.reason || null,
80
+ cleared_pending_alternative: !!state.pending_alternative,
77
81
  });
78
82
  break;
79
83
 
@@ -100,8 +104,19 @@ function applyOne(state, profile, cmd) {
100
104
  break;
101
105
 
102
106
  case 'pause':
103
- // No state change. The CLI is expected to halt the loop and wait
104
- // for the user to /sprint-autopilot-on again. Recorded for audit.
107
+ // Set `halt_requested` so adapt.nextAction returns a halt action
108
+ // on this same turn. Without this flag, prior versions of the
109
+ // applier only logged the halt side-effect and the orchestrator
110
+ // kept emitting the next planned action — the loop never stopped.
111
+ // `halt_requested` is cleared by `start` on the next session
112
+ // (same path that clears stale fingerprints on resume).
113
+ newState = {
114
+ ...state,
115
+ halt_requested: {
116
+ reason: cmd.reason || null,
117
+ requested_at: new Date().toISOString(),
118
+ },
119
+ };
105
120
  effects.push({
106
121
  kind: 'halt',
107
122
  reason: 'user_pause',
@@ -109,6 +124,38 @@ function applyOne(state, profile, cmd) {
109
124
  });
110
125
  break;
111
126
 
127
+ case 'accept_alternative': {
128
+ // Dispatches the orchestrator's stored `pending_alternative` (set
129
+ // when handleProposeAlternative escalated to a user_prompt at
130
+ // medium/high impact). The CLI edge / adapt's handleUserInput
131
+ // looks for this side-effect and uses `action` as the one-shot
132
+ // nextAction in place of the state-machine default.
133
+ const pending = state.pending_alternative;
134
+ if (!pending || !pending.action) {
135
+ effects.push({
136
+ kind: 'validation_error',
137
+ reason: 'accept_alternative: no pending alternative to accept',
138
+ phase: state.phase,
139
+ details: cmd.reason || null,
140
+ });
141
+ break;
142
+ }
143
+ newState = {
144
+ ...state,
145
+ pending_alternative: undefined,
146
+ retry_count_this_phase: 0,
147
+ verify_reject_count: 0,
148
+ };
149
+ effects.push({
150
+ kind: 'dispatch_action',
151
+ action: pending.action,
152
+ impact: pending.impact || null,
153
+ reason: 'user_accept_alternative',
154
+ details: cmd.reason || null,
155
+ });
156
+ break;
157
+ }
158
+
112
159
  case 'override_decision':
113
160
  // We don't apply a state mutation. The CLI records this so a
114
161
  // subsequent verify_override can reference DEC-id.
@@ -7,12 +7,18 @@
7
7
  // Pure module. No I/O.
8
8
  //
9
9
  // Command kinds (initial set; new kinds added via additive PR):
10
- // skip_story { story_key: string, reason?: string }
11
- // abort_sprint { reason?: string }
12
- // force_continue { reason?: string }
13
- // override_decision { decision_id: string, new_value: string }
14
- // change_profile { profile: 'nano'|'small'|'medium'|'large'|'legacy' }
15
- // pause { reason?: string }
10
+ // skip_story { story_key: string, reason?: string }
11
+ // abort_sprint { reason?: string }
12
+ // force_continue { reason?: string }
13
+ // override_decision { decision_id: string, new_value: string }
14
+ // change_profile { profile: 'nano'|'small'|'medium'|'large'|'legacy' }
15
+ // pause { reason?: string }
16
+ // accept_alternative { reason?: string }
17
+ // Accepts the orchestrator's most recent `propose_alternative` that
18
+ // was escalated to a user_prompt at medium/high impact. Dispatches
19
+ // the stored alternative as the next action and clears the pending
20
+ // entry. Validation rejects this kind when no alternative is pending
21
+ // in state — see user-command-applier.js for the runtime check.
16
22
  //
17
23
  // Validation returns { ok: true, command } | { ok: false, errors: string[] }.
18
24
 
@@ -27,6 +33,7 @@ const COMMAND_KINDS = [
27
33
  'override_decision',
28
34
  'change_profile',
29
35
  'pause',
36
+ 'accept_alternative',
30
37
  ];
31
38
 
32
39
  const STORY_KEY_RE = /^[A-Za-z0-9._-]{1,64}$/;
@@ -65,7 +72,8 @@ function validateOne(cmd) {
65
72
  }
66
73
  case 'abort_sprint':
67
74
  case 'force_continue':
68
- case 'pause': {
75
+ case 'pause':
76
+ case 'accept_alternative': {
69
77
  if ('reason' in cmd && cmd.reason !== undefined && typeof cmd.reason !== 'string')
70
78
  errors.push(`${cmd.kind}.reason must be string when present`);
71
79
  break;
@@ -1,6 +1,6 @@
1
1
  addon:
2
2
  name: sprintpilot
3
- version: 2.1.1
3
+ version: 2.1.2
4
4
  description: Sprintpilot — autopilot and multi-agent addon for BMad Method (git workflow, parallel agents, autonomous story execution)
5
5
  bmad_compatibility: ">=6.2.0"
6
6
  modules:
@@ -49,8 +49,8 @@ Wrap everything in `{ "status": "...", ... }` and pass to
49
49
  | `success` | `output?: object` (for `bmad-code-review` MUST include `findings[]` with `action: 'block'\|'patch'\|'defer'`); `next_skill_hint?` |
50
50
  | `failure` | `reason`, `diagnosis` (first-class — fed back into next retry), `recoverable: boolean` |
51
51
  | `blocked` | `blocker_kind` (one of the 5 TRUE BLOCKERS or recoverable kinds), `details`, `user_input_needed`, `consecutive_count?` |
52
- | `propose_alternative` | `reason`, `alternative` (full Action object), `urgency_hint?` (raises impact only) |
53
- | `user_input` | `commands: UserCommand[]` (validated server-side; see user-commands.js) |
52
+ | `propose_alternative` | `reason`, `alternative` (full Action object), `urgency_hint?` (raises impact only). Low impact → auto-accepted; medium / high → orchestrator stores the alternative in `state.pending_alternative` and emits `user_prompt`. The user accepts via `user_input` `{ kind: 'accept_alternative' }` or rejects via `force_continue` (both clear `pending_alternative`). |
53
+ | `user_input` | `commands: UserCommand[]` (validated server-side; see user-commands.js). Kinds: `skip_story`, `abort_sprint`, `force_continue`, `override_decision`, `change_profile`, `pause` (cleanly halts THIS session; next `/sprint-autopilot-on` resumes), `accept_alternative` (dispatches the stored `pending_alternative`). |
54
54
  | `verify_override` | `evidence: { decision_log_ref?, explanation, expected_paths? }` — used when verify.js is wrong |
55
55
 
56
56
  ## TRUE BLOCKER kinds (per AGENTS.md)
@@ -31,6 +31,8 @@ const {
31
31
  } = require('../core/markers');
32
32
  const { renderString, buildContext, isTextFile } = require('../substitute');
33
33
  const { fetchLatestVersion, compareVersions } = require('../core/update-check');
34
+ const { mergeYamlConfig, mergeTemplateFile } = require('../core/config-merger');
35
+ const { scanForLeftoverSnapshots } = require('../core/v2-upgrade-recovery');
34
36
  const prompts = require('../prompts');
35
37
 
36
38
  const execFileAsync = promisify(execFile);
@@ -79,6 +81,37 @@ const RUNTIME_RESOURCES = [
79
81
  'scripts',
80
82
  'templates',
81
83
  ];
84
+
85
+ // Files under _Sprintpilot/ that users edit. Step 6 nukes these along
86
+ // with everything else when copying the bundled tree; we snapshot them
87
+ // BEFORE step 6 and restore them AFTER, using a per-file strategy:
88
+ //
89
+ // strategy: 'yaml' — line-aware merge (config-merger.mergeYamlConfig).
90
+ // User scalars patched into the freshly-copied
91
+ // bundled file. Bundled comments + new keys
92
+ // preserved. Orphan user keys land in a footer
93
+ // `# Preserved from prior install` block.
94
+ //
95
+ // strategy: 'template' — skip-if-exists. If the user file differs from
96
+ // bundled, keep the user version verbatim and
97
+ // write bundled next door as <file>.bundled.
98
+ const USER_OWNED_FILES = [
99
+ { path: 'modules/git/config.yaml', strategy: 'yaml' },
100
+ { path: 'modules/ma/config.yaml', strategy: 'yaml' },
101
+ { path: 'modules/autopilot/config.yaml', strategy: 'yaml' },
102
+ { path: 'modules/git/templates/pr-body.md', strategy: 'template' },
103
+ { path: 'modules/git/templates/commit-story.txt', strategy: 'template' },
104
+ { path: 'modules/git/templates/commit-patch.txt', strategy: 'template' },
105
+ { path: '.secrets-allowlist', strategy: 'template' },
106
+ ];
107
+
108
+ // Explicit dot-path renames the installer maps when reading old user
109
+ // configs. Empty for the 2.1.x baseline; add entries when a future
110
+ // release renames a key so user customizations land at the new path.
111
+ const KEY_RENAMES = Object.freeze({
112
+ // 'old.dotted.path': 'new.dotted.path'
113
+ });
114
+
82
115
  const V1_MODULE_NAMES = ['git', 'ma', 'autopilot'];
83
116
 
84
117
  // Sentinel thrown by evictV1Installation when the user declines migration.
@@ -275,6 +308,99 @@ async function applyV1ModuleConfigs(projectRoot, snapshot) {
275
308
  return applied;
276
309
  }
277
310
 
311
+ // Snapshot user-owned files BEFORE the destructive step-6 copy. For each
312
+ // path in USER_OWNED_FILES that exists under targetAddonDir today, capture
313
+ // its current content along with the strategy. Returns an array; entries
314
+ // for files that don't exist yet (fresh installs) are simply absent.
315
+ async function snapshotUserOwnedFiles(targetAddonDir) {
316
+ const out = [];
317
+ for (const entry of USER_OWNED_FILES) {
318
+ const abs = path.join(targetAddonDir, entry.path);
319
+ if (!(await fs.pathExists(abs))) continue;
320
+ try {
321
+ const buffer = await fs.readFile(abs);
322
+ out.push({ path: entry.path, strategy: entry.strategy, buffer });
323
+ } catch {
324
+ // Unreadable user file — skip; the bundled default will land.
325
+ }
326
+ }
327
+ return out;
328
+ }
329
+
330
+ // Apply the user-owned snapshot back over the freshly-copied bundled files,
331
+ // using the per-file strategy. Writes are atomic via writeAtomic. Returns
332
+ // an array of `{ path, strategy, preserved, orphans, sidecar }` describing
333
+ // what happened for each file — the caller pretty-prints this to the user.
334
+ async function applyUserOwnedFiles(targetAddonDir, snapshot, keyRenames = {}) {
335
+ const results = [];
336
+ for (const entry of snapshot) {
337
+ const abs = path.join(targetAddonDir, entry.path);
338
+ const userText = entry.buffer.toString('utf8');
339
+ let bundledText = '';
340
+ try {
341
+ bundledText = await fs.readFile(abs, 'utf8');
342
+ } catch {
343
+ // Bundled file no longer ships at this path — treat user file as
344
+ // an orphan: keep it as-is, no sidecar (nothing to compare against).
345
+ await writeAtomic(abs, entry.buffer);
346
+ results.push({
347
+ path: entry.path,
348
+ strategy: entry.strategy,
349
+ preserved: [],
350
+ orphans: [],
351
+ sidecar: false,
352
+ note: 'bundled file no longer ships; user copy preserved',
353
+ });
354
+ continue;
355
+ }
356
+
357
+ if (entry.strategy === 'yaml') {
358
+ const r = mergeYamlConfig(bundledText, userText, keyRenames);
359
+ if (r.fallback) {
360
+ // Merge couldn't parse — fall back to template strategy so the
361
+ // user doesn't lose their file. Sidecar the bundled version.
362
+ const t = mergeTemplateFile(bundledText, userText);
363
+ await writeAtomic(abs, t.text);
364
+ if (t.sidecar !== null) {
365
+ await writeAtomic(`${abs}.bundled`, t.sidecar);
366
+ }
367
+ results.push({
368
+ path: entry.path,
369
+ strategy: 'template-fallback',
370
+ preserved: [],
371
+ orphans: [],
372
+ sidecar: t.sidecar !== null,
373
+ note: 'YAML merge fell back to skip-if-exists',
374
+ });
375
+ continue;
376
+ }
377
+ await writeAtomic(abs, r.text);
378
+ results.push({
379
+ path: entry.path,
380
+ strategy: 'yaml',
381
+ preserved: r.preserved,
382
+ orphans: r.orphans,
383
+ sidecar: false,
384
+ });
385
+ } else {
386
+ // template strategy
387
+ const t = mergeTemplateFile(bundledText, userText);
388
+ await writeAtomic(abs, t.text);
389
+ if (t.sidecar !== null) {
390
+ await writeAtomic(`${abs}.bundled`, t.sidecar);
391
+ }
392
+ results.push({
393
+ path: entry.path,
394
+ strategy: 'template',
395
+ preserved: t.kept === 'user' ? ['(verbatim)'] : [],
396
+ orphans: [],
397
+ sidecar: t.sidecar !== null,
398
+ });
399
+ }
400
+ }
401
+ return results;
402
+ }
403
+
278
404
  // Emergency fallback when applyV1ModuleConfigs throws: the in-memory
279
405
  // snapshot is stringified to a recovery file so the user can re-apply
280
406
  // manually after fixing whatever blocked the write. Without this the
@@ -976,6 +1102,31 @@ async function runInstall(options = {}) {
976
1102
  process.stdout.write(pc.cyan(renderBanner(addonVersion)));
977
1103
  console.log('');
978
1104
 
1105
+ // 0a. Recovery banner: surface any leftover backups/snapshots from a
1106
+ // prior installer run that may have silently clobbered user configs
1107
+ // before v2.1.2's preservation logic landed. Read-only; nothing is
1108
+ // deleted — the user decides.
1109
+ try {
1110
+ const leftovers = await scanForLeftoverSnapshots(projectRoot);
1111
+ if (leftovers.length > 0) {
1112
+ console.log(
1113
+ pc.yellow('NOTE: detected leftover config snapshots from a prior install:'),
1114
+ );
1115
+ for (const f of leftovers) {
1116
+ console.log(pc.yellow(` - ${path.relative(projectRoot, f)}`));
1117
+ }
1118
+ console.log(
1119
+ pc.yellow(
1120
+ ' These may contain config you customized but lost during a prior upgrade.',
1121
+ ),
1122
+ );
1123
+ console.log(pc.yellow(' Review before deleting.'));
1124
+ console.log('');
1125
+ }
1126
+ } catch {
1127
+ // Non-fatal — banner is purely informational.
1128
+ }
1129
+
979
1130
  // 1. Verify BMad Method installed
980
1131
  const bmadManifest = await verifyBmadInstalled(projectRoot);
981
1132
  if (!bmadManifest) {
@@ -1216,6 +1367,12 @@ async function runInstall(options = {}) {
1216
1367
  if (dryRun) {
1217
1368
  console.log(pc.dim(`[DRY RUN] Would copy runtime resources to ${targetAddonDir}`));
1218
1369
  } else {
1370
+ // 6-pre. Snapshot user-owned files BEFORE the destructive copy. On a
1371
+ // fresh install the snapshot is empty; on upgrade it captures
1372
+ // config.yaml edits, template customizations, and the secrets
1373
+ // allowlist so they can be re-applied after step 6.
1374
+ const userOwnedSnapshot = await snapshotUserOwnedFiles(targetAddonDir);
1375
+
1219
1376
  await fs.ensureDir(targetAddonDir);
1220
1377
  for (const item of RUNTIME_RESOURCES) {
1221
1378
  const src = path.join(ADDON_DIR, item);
@@ -1279,6 +1436,42 @@ async function runInstall(options = {}) {
1279
1436
  // so the existing upgrade test coverage (readExistingAutopilotConfig /
1280
1437
  // patchAutopilotConfig) is unaffected by the new key.
1281
1438
  await patchComplexityProfile(projectRoot, complexityProfile);
1439
+
1440
+ // 6d. Re-apply the user-owned snapshot taken before step 6. YAML configs
1441
+ // get a line-aware merge (user scalars patched into the freshly
1442
+ // copied bundled file, comments preserved, new bundled keys land).
1443
+ // Templates fall back to skip-if-exists with a .bundled sidecar.
1444
+ // Runs AFTER 6a (v1 reapply) and AFTER 6b / 6c so prompt-resolved
1445
+ // autopilot values are not overwritten by a stale snapshot.
1446
+ if (userOwnedSnapshot.length > 0) {
1447
+ const merged = await applyUserOwnedFiles(targetAddonDir, userOwnedSnapshot, KEY_RENAMES);
1448
+ let anySidecar = false;
1449
+ for (const r of merged) {
1450
+ const detail =
1451
+ r.strategy === 'yaml' && r.preserved.length > 0
1452
+ ? ` (preserved ${r.preserved.length} setting${r.preserved.length === 1 ? '' : 's'})`
1453
+ : r.strategy === 'template' && r.preserved.length > 0
1454
+ ? ' (kept user version)'
1455
+ : '';
1456
+ console.log(` Preserved ${r.path}${detail}`);
1457
+ if (r.note) console.log(pc.dim(` note: ${r.note}`));
1458
+ if (r.orphans && r.orphans.length > 0) {
1459
+ console.log(
1460
+ pc.dim(` orphan keys appended to file footer: ${r.orphans.join(', ')}`),
1461
+ );
1462
+ }
1463
+ if (r.sidecar) anySidecar = true;
1464
+ }
1465
+ if (anySidecar) {
1466
+ console.log('');
1467
+ console.log(
1468
+ pc.yellow(
1469
+ ' Some bundled defaults were written next to user files as .bundled sidecars.',
1470
+ ),
1471
+ );
1472
+ console.log(pc.yellow(' Diff them by hand to pick up new options.'));
1473
+ }
1474
+ }
1282
1475
  }
1283
1476
 
1284
1477
  // 7. Verify git check-ignore
@@ -1380,5 +1573,9 @@ module.exports = {
1380
1573
  COMPLEXITY_PROFILES,
1381
1574
  DEFAULT_COMPLEXITY_PROFILE,
1382
1575
  RUNTIME_RESOURCES,
1576
+ USER_OWNED_FILES,
1577
+ KEY_RENAMES,
1578
+ snapshotUserOwnedFiles,
1579
+ applyUserOwnedFiles,
1383
1580
  },
1384
1581
  };
@@ -0,0 +1,238 @@
1
+ // config-merger.js — preserve user edits when the installer rewrites
2
+ // _Sprintpilot/ on upgrade.
3
+ //
4
+ // Two strategies, picked per-file in `lib/commands/install.js`:
5
+ //
6
+ // mergeYamlConfig(bundledText, userText, keyRenames)
7
+ // For modules/*/config.yaml. Bundled file is freshly copied. We
8
+ // parse both files into flat dot-path → scalar maps, compute the
9
+ // user-customized set (paths whose user value differs from bundled
10
+ // default OR doesn't exist in bundled), and patch each customization
11
+ // back into the bundled text by line-substitution. Bundled inline
12
+ // comments and section structure are preserved verbatim.
13
+ //
14
+ // mergeTemplateFile(bundledText, userText)
15
+ // For free-form templates (.md / .txt / .secrets-allowlist) where
16
+ // line-based YAML merging doesn't apply. If user file exists and
17
+ // differs from bundled, keep the user file; the caller writes the
18
+ // bundled version next door as a .bundled sidecar so the user can
19
+ // diff and merge by hand.
20
+ //
21
+ // Both functions are pure. They never read or write disk; the caller
22
+ // (applyUserOwnedFiles in install.js) owns I/O via writeAtomic.
23
+
24
+ // -----------------------------------------------------------------------------
25
+ // YAML scalar/path parsing
26
+ // -----------------------------------------------------------------------------
27
+
28
+ // One non-empty, non-comment line of the form:
29
+ // <indent><key>: [value][ # trailing-comment]
30
+ // Captures: indent, key, value-with-optional-trailing-comment.
31
+ const KV_RE = /^(?<indent>\s*)(?<key>[A-Za-z_][\w-]*):\s*(?<rest>.*)$/;
32
+ const COMMENT_ONLY_RE = /^\s*#/;
33
+ const BLANK_RE = /^\s*$/;
34
+
35
+ function isBlank(line) {
36
+ return BLANK_RE.test(line);
37
+ }
38
+
39
+ function isCommentOnly(line) {
40
+ return COMMENT_ONLY_RE.test(line);
41
+ }
42
+
43
+ // Split a value-and-maybe-trailing-comment chunk (the `rest` capture above)
44
+ // into { value, trailing } where `trailing` is everything from the first
45
+ // unquoted `#` onward (with its leading whitespace). YAML inline comments
46
+ // require whitespace before the `#`, which `KV_RE` already enforces by
47
+ // matching the colon-then-whitespace before `rest`.
48
+ function splitInlineComment(rest) {
49
+ let inSingle = false;
50
+ let inDouble = false;
51
+ for (let i = 0; i < rest.length; i++) {
52
+ const ch = rest[i];
53
+ if (ch === "'" && !inDouble) inSingle = !inSingle;
54
+ else if (ch === '"' && !inSingle) inDouble = !inDouble;
55
+ else if (ch === '#' && !inSingle && !inDouble) {
56
+ // YAML requires whitespace before `#` for an inline comment.
57
+ if (i === 0 || /\s/.test(rest[i - 1])) {
58
+ return { value: rest.slice(0, i).trimEnd(), trailing: rest.slice(i) };
59
+ }
60
+ }
61
+ }
62
+ return { value: rest.trimEnd(), trailing: '' };
63
+ }
64
+
65
+ // Parse a YAML-ish file into a flat map of dot-paths → string values.
66
+ // Lines that don't match `KV_RE` are ignored. Container lines (`key:`
67
+ // with empty value followed by deeper-indented children) are tracked
68
+ // via an indent stack so dot-paths nest correctly. Throws on malformed
69
+ // indentation; the caller catches and falls back to template strategy.
70
+ function parseFlat(text) {
71
+ const lines = text.split(/\r?\n/);
72
+ const stack = []; // [{ indent: number, key: string }]
73
+ const out = Object.create(null);
74
+ for (let i = 0; i < lines.length; i++) {
75
+ const line = lines[i];
76
+ if (isBlank(line) || isCommentOnly(line)) continue;
77
+ const m = line.match(KV_RE);
78
+ if (!m) continue; // list items, multi-line strings, etc. — not in our scope
79
+ const indent = m.groups.indent.length;
80
+ const key = m.groups.key;
81
+ const { value } = splitInlineComment(m.groups.rest);
82
+ // Pop deeper-or-equal scopes off the stack.
83
+ while (stack.length > 0 && stack[stack.length - 1].indent >= indent) {
84
+ stack.pop();
85
+ }
86
+ const path = [...stack.map((s) => s.key), key].join('.');
87
+ if (value.length === 0) {
88
+ // Container — push and continue. Don't record an empty scalar.
89
+ stack.push({ indent, key });
90
+ } else {
91
+ out[path] = value;
92
+ }
93
+ }
94
+ return out;
95
+ }
96
+
97
+ // Patch a single scalar value at the given dot-path into the bundled
98
+ // text. Returns the new text. If the path can't be found, returns the
99
+ // text unchanged (the caller decides whether to append the orphan).
100
+ function patchScalarInPlace(text, dotPath, newValue) {
101
+ const targetSegments = dotPath.split('.');
102
+ const lines = text.split(/\r?\n/);
103
+ const stack = [];
104
+ for (let i = 0; i < lines.length; i++) {
105
+ const line = lines[i];
106
+ if (isBlank(line) || isCommentOnly(line)) continue;
107
+ const m = line.match(KV_RE);
108
+ if (!m) continue;
109
+ const indent = m.groups.indent.length;
110
+ const key = m.groups.key;
111
+ while (stack.length > 0 && stack[stack.length - 1].indent >= indent) {
112
+ stack.pop();
113
+ }
114
+ const currentPath = [...stack.map((s) => s.key), key];
115
+ const isContainer = m.groups.rest.length === 0;
116
+ if (isContainer) {
117
+ stack.push({ indent, key });
118
+ continue;
119
+ }
120
+ if (
121
+ currentPath.length === targetSegments.length &&
122
+ currentPath.every((seg, idx) => seg === targetSegments[idx])
123
+ ) {
124
+ const { trailing } = splitInlineComment(m.groups.rest);
125
+ const sep = trailing ? ' ' : '';
126
+ lines[i] = `${m.groups.indent}${key}: ${newValue}${sep}${trailing}`;
127
+ return lines.join('\n');
128
+ }
129
+ }
130
+ return text; // not found — caller may append as orphan
131
+ }
132
+
133
+ // -----------------------------------------------------------------------------
134
+ // Public: mergeYamlConfig
135
+ // -----------------------------------------------------------------------------
136
+
137
+ /**
138
+ * Merge user YAML into freshly-copied bundled YAML, preserving the
139
+ * bundled file's structure and inline comments.
140
+ *
141
+ * @param {string} bundledText - the freshly-copied bundled file contents
142
+ * @param {string} userText - the user's pre-upgrade contents
143
+ * @param {Object<string, string>} keyRenames - { 'old.path': 'new.path' }
144
+ * @returns {{ text: string, preserved: string[], orphans: string[], fallback: boolean }}
145
+ * text: merged file text to write
146
+ * preserved: dot-paths whose user value was patched into the bundled text
147
+ * orphans: dot-paths present only in user (appended as a footer block)
148
+ * fallback: true if parse failed and we returned bundledText unchanged
149
+ */
150
+ function mergeYamlConfig(bundledText, userText, keyRenames = {}) {
151
+ if (typeof bundledText !== 'string' || typeof userText !== 'string') {
152
+ return { text: bundledText || '', preserved: [], orphans: [], fallback: true };
153
+ }
154
+ let bundledMap;
155
+ let userMap;
156
+ try {
157
+ bundledMap = parseFlat(bundledText);
158
+ userMap = parseFlat(userText);
159
+ } catch {
160
+ return { text: bundledText, preserved: [], orphans: [], fallback: true };
161
+ }
162
+
163
+ // Apply renames before computing the diff: a user key at `old.path`
164
+ // is treated as if it sat at `new.path`.
165
+ const renamed = Object.create(null);
166
+ for (const [k, v] of Object.entries(userMap)) {
167
+ const remapped = Object.hasOwn(keyRenames, k) ? keyRenames[k] : k;
168
+ renamed[remapped] = v;
169
+ }
170
+
171
+ let text = bundledText;
172
+ const preserved = [];
173
+ const orphans = [];
174
+
175
+ for (const [path, userValue] of Object.entries(renamed)) {
176
+ const bundledValue = bundledMap[path];
177
+ if (bundledValue === userValue) continue; // no customization
178
+ if (bundledValue === undefined) {
179
+ orphans.push(path);
180
+ continue;
181
+ }
182
+ const next = patchScalarInPlace(text, path, userValue);
183
+ if (next === text) {
184
+ // Path exists in bundled map but couldn't be re-located — treat as
185
+ // orphan rather than silently dropping the user value.
186
+ orphans.push(path);
187
+ } else {
188
+ text = next;
189
+ preserved.push(path);
190
+ }
191
+ }
192
+
193
+ if (orphans.length > 0) {
194
+ // Append as a clearly-labeled footer block so the user notices and
195
+ // can re-integrate or delete by hand. Use a `# Preserved` header
196
+ // followed by literal `key: value` lines — these will round-trip
197
+ // through `parseFlat` on the next upgrade.
198
+ const footer = [
199
+ '',
200
+ '# Preserved from prior install — verify these still apply:',
201
+ ...orphans.map((p) => `# ${p}: ${renamed[p]}`),
202
+ '',
203
+ ].join('\n');
204
+ text = text.endsWith('\n') ? text + footer : text + '\n' + footer;
205
+ }
206
+
207
+ return { text, preserved, orphans, fallback: false };
208
+ }
209
+
210
+ // -----------------------------------------------------------------------------
211
+ // Public: mergeTemplateFile
212
+ // -----------------------------------------------------------------------------
213
+
214
+ /**
215
+ * For free-form template files (.md / .txt / .secrets-allowlist).
216
+ *
217
+ * @param {string} bundledText - freshly-copied bundled contents
218
+ * @param {string|null} userText - user's pre-upgrade contents, or null/undefined if absent
219
+ * @returns {{ kept: 'user' | 'bundled', text: string, sidecar: string | null }}
220
+ * When kept === 'user', `text` is the user contents and `sidecar` is the
221
+ * bundled contents (caller writes it as <file>.bundled). When kept ===
222
+ * 'bundled', `text` is the bundled contents and `sidecar` is null.
223
+ */
224
+ function mergeTemplateFile(bundledText, userText) {
225
+ const bundled = typeof bundledText === 'string' ? bundledText : '';
226
+ const user = typeof userText === 'string' ? userText : '';
227
+ if (user.length === 0 || user === bundled) {
228
+ return { kept: 'bundled', text: bundled, sidecar: null };
229
+ }
230
+ return { kept: 'user', text: user, sidecar: bundled };
231
+ }
232
+
233
+ module.exports = {
234
+ mergeYamlConfig,
235
+ mergeTemplateFile,
236
+ // Exported for tests only.
237
+ _internals: { parseFlat, patchScalarInPlace, splitInlineComment },
238
+ };
@@ -0,0 +1,47 @@
1
+ // v2-upgrade-recovery.js — detect leftover snapshot/backup files from
2
+ // prior installer runs that may have silently clobbered user configs.
3
+ //
4
+ // Used at the top of `runInstall` to print a banner pointing the user
5
+ // at recoverable data. Returns paths only; the installer never deletes
6
+ // these — the user decides when they've been restored or are safe to
7
+ // discard.
8
+
9
+ const path = require('node:path');
10
+ const fs = require('fs-extra');
11
+
12
+ // Patterns we scan for, relative to projectRoot:
13
+ // - *.bak-sprintpilot-migration* (legacy marker strip backups)
14
+ // - .sprintpilot-v1-snapshot*.json (v1 module-config recovery dumps)
15
+ //
16
+ // Both are written today by the v1→v2 migration path (lib/commands/install.js
17
+ // pickBackupPath + persistSnapshotForRecovery). The same pattern could end
18
+ // up triggered if a future installer hits a write failure mid-merge.
19
+
20
+ const BACKUP_GLOB = /\.bak-sprintpilot-migration/;
21
+ const SNAPSHOT_GLOB = /^\.sprintpilot-v1-snapshot.*\.json$/;
22
+
23
+ async function scanForLeftoverSnapshots(projectRoot) {
24
+ const out = [];
25
+ // Top-level scan only (non-recursive). The two patterns are always
26
+ // written at the project root or alongside the file they backed up.
27
+ try {
28
+ const entries = await fs.readdir(projectRoot, { withFileTypes: true });
29
+ for (const entry of entries) {
30
+ if (!entry.isFile()) continue;
31
+ if (SNAPSHOT_GLOB.test(entry.name) || BACKUP_GLOB.test(entry.name)) {
32
+ out.push(path.join(projectRoot, entry.name));
33
+ }
34
+ }
35
+ } catch {
36
+ // projectRoot unreadable — caller can't act on the banner anyway.
37
+ return out;
38
+ }
39
+
40
+ // Also scan a couple of well-known directories where backup files
41
+ // accumulate (AGENTS.md.bak-sprintpilot-migration is at root, but
42
+ // .clinerules.bak-... could live wherever the rules file lives).
43
+ // For now, root-only is enough — extend if real reports surface.
44
+ return out;
45
+ }
46
+
47
+ module.exports = { scanForLeftoverSnapshots };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ikunin/sprintpilot",
3
- "version": "2.1.1",
3
+ "version": "2.1.2",
4
4
  "description": "Sprintpilot — autopilot and multi-agent addon for BMad Method v6: git workflow, parallel agents, autonomous story execution",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {