@ikunin/sprintpilot 2.1.1 → 2.1.3

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.
@@ -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.3",
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": {