@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.
- package/_Sprintpilot/bin/autopilot.js +14 -4
- package/_Sprintpilot/lib/orchestrator/adapt.js +62 -9
- package/_Sprintpilot/lib/orchestrator/user-command-applier.js +50 -3
- package/_Sprintpilot/lib/orchestrator/user-commands.js +15 -7
- package/_Sprintpilot/manifest.yaml +1 -1
- package/_Sprintpilot/skills/sprint-autopilot-on/workflow.orchestrator.md +2 -2
- package/lib/commands/install.js +197 -0
- package/lib/core/config-merger.js +238 -0
- package/lib/core/v2-upgrade-recovery.js +47 -0
- package/package.json +1 -1
|
@@ -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
|
-
//
|
|
276
|
-
//
|
|
277
|
-
//
|
|
278
|
-
//
|
|
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
|
|
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
|
-
//
|
|
350
|
-
//
|
|
351
|
-
//
|
|
352
|
-
//
|
|
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
|
|
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
|
|
360
|
-
newProfile
|
|
361
|
-
nextAction: nextAction(
|
|
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
|
-
//
|
|
104
|
-
//
|
|
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
|
|
11
|
-
// abort_sprint
|
|
12
|
-
// force_continue
|
|
13
|
-
// override_decision
|
|
14
|
-
// change_profile
|
|
15
|
-
// pause
|
|
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;
|
|
@@ -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)
|
package/lib/commands/install.js
CHANGED
|
@@ -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