mandrel 1.60.0 → 1.62.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/.agents/README.md +74 -32
  2. package/.agents/docs/SDLC.md +18 -12
  3. package/.agents/docs/configuration.md +61 -4
  4. package/.agents/docs/quality-gates.md +796 -0
  5. package/.agents/docs/workflows.md +3 -4
  6. package/.agents/runtime-deps.json +2 -2
  7. package/.agents/scripts/README.md +1 -1
  8. package/.agents/scripts/agents-bootstrap-github.js +23 -119
  9. package/.agents/scripts/lib/bootstrap/ci-workflow-template.js +46 -0
  10. package/.agents/scripts/lib/bootstrap/gh-preflight.js +7 -9
  11. package/.agents/scripts/lib/bootstrap/manifest.js +21 -1
  12. package/.agents/scripts/lib/bootstrap/merge-methods.js +31 -16
  13. package/.agents/scripts/lib/bootstrap/project-bootstrap.js +32 -11
  14. package/.agents/scripts/lib/config/sync-agentrc.js +1 -1
  15. package/.agents/scripts/lib/detect-package-manager.js +72 -0
  16. package/.agents/scripts/lib/errors/index.js +4 -4
  17. package/.agents/scripts/lib/label-taxonomy.js +2 -2
  18. package/.agents/scripts/lib/onboard/detect-stack.js +10 -10
  19. package/.agents/scripts/lib/onboard/init-tail.js +218 -0
  20. package/.agents/scripts/lib/onboard/scaffold-docs.js +18 -3
  21. package/.agents/scripts/lib/runtime-deps/preflight.js +6 -6
  22. package/.agents/scripts/lib/worktree/node-modules-strategy.js +5 -2
  23. package/.agents/workflows/agents-update.md +14 -29
  24. package/.agents/workflows/deliver.md +87 -26
  25. package/.agents/workflows/helpers/agents-sync-config.md +3 -2
  26. package/.agents/workflows/helpers/deliver-epic.md +12 -5
  27. package/.agents/workflows/helpers/deliver-stories.md +13 -7
  28. package/.agents/workflows/plan.md +48 -4
  29. package/README.md +18 -30
  30. package/bin/mandrel.js +235 -16
  31. package/docs/CHANGELOG.md +36 -0
  32. package/lib/cli/doctor.js +45 -3
  33. package/lib/cli/init.js +66 -7
  34. package/lib/cli/registry.js +42 -146
  35. package/lib/cli/sync.js +122 -23
  36. package/lib/cli/uninstall.js +42 -7
  37. package/lib/cli/update.js +257 -198
  38. package/lib/cli/version-helpers.js +59 -0
  39. package/package.json +6 -6
  40. package/.agents/workflows/onboard.md +0 -208
  41. package/lib/cli/__tests__/migrate.test.js +0 -268
  42. package/lib/cli/__tests__/sync-local-zone.test.js +0 -247
  43. package/lib/cli/__tests__/sync.test.js +0 -372
  44. package/lib/cli/__tests__/update-changelog-surface.test.js +0 -357
  45. package/lib/cli/__tests__/update-major.test.js +0 -217
  46. package/lib/cli/__tests__/update-reexec.test.js +0 -513
  47. package/lib/cli/__tests__/update.test.js +0 -696
  48. package/lib/cli/__tests__/version-check.test.js +0 -398
  49. package/lib/migrations/__tests__/index.test.js +0 -216
@@ -26,7 +26,17 @@ import { createRequire } from 'node:module';
26
26
  import path from 'node:path';
27
27
  import { fileURLToPath } from 'node:url';
28
28
 
29
+ import {
30
+ REQUIRED_NODE_CEILING_MAJOR,
31
+ REQUIRED_NODE_FLOOR,
32
+ satisfiesNodeEngine,
33
+ } from '../../.agents/scripts/lib/bootstrap/project-bootstrap.js';
34
+ import {
35
+ defaultResolvePackageRoot,
36
+ listFiles as listPayloadFiles,
37
+ } from './sync.js';
29
38
  import { readCache } from './version-check.js';
39
+ import { compareVersions as compareSemver } from './version-helpers.js';
30
40
 
31
41
  // ---------------------------------------------------------------------------
32
42
  // Internal helpers
@@ -53,32 +63,6 @@ function spawn(cmd, args) {
53
63
  // check: node-version
54
64
  // ---------------------------------------------------------------------------
55
65
 
56
- /**
57
- * The minimum Node version the framework requires (`engines.node`).
58
- * Mirrors `lib/bootstrap/project-bootstrap.js#REQUIRED_NODE_FLOOR`.
59
- */
60
- const REQUIRED_NODE_FLOOR = '22.22.1';
61
- const REQUIRED_NODE_CEILING_MAJOR = 25;
62
-
63
- /**
64
- * Return true when `version` satisfies `>=22.22.1 <25`.
65
- *
66
- * @param {string} version
67
- * @returns {boolean}
68
- */
69
- function satisfiesNodeEngine(version) {
70
- const [majorRaw, minorRaw, patchRaw] = String(version).split('.');
71
- const major = Number.parseInt(majorRaw, 10) || 0;
72
- const minor = Number.parseInt(minorRaw, 10) || 0;
73
- const patch = Number.parseInt(patchRaw, 10) || 0;
74
- if (major >= REQUIRED_NODE_CEILING_MAJOR) return false;
75
- if (major > 22) return true;
76
- if (major < 22) return false;
77
- if (minor > 22) return true;
78
- if (minor < 22) return false;
79
- return patch >= 1;
80
- }
81
-
82
66
  /**
83
67
  * @param {{ nodeVersion?: string }} [opts]
84
68
  * @returns {{ ok: boolean, detail: string, remedy?: string }}
@@ -235,19 +219,6 @@ function runGhAuth({ runner = spawn, env = process.env } = {}) {
235
219
  // check: commands-in-sync
236
220
  // ---------------------------------------------------------------------------
237
221
 
238
- /**
239
- * Resolve the project root — the directory that contains `.agents/` and
240
- * `.claude/`. Walks up from this file's own location.
241
- *
242
- * @returns {string}
243
- */
244
- function resolveProjectRoot() {
245
- // lib/cli/registry.js lives at <root>/lib/cli/registry.js, so walk up two
246
- // levels from __dirname to reach the project root.
247
- const here = path.dirname(fileURLToPath(import.meta.url));
248
- return path.resolve(here, '..', '..');
249
- }
250
-
251
222
  /**
252
223
  * Dry-run the sync-claude-commands logic: compare `.agents/workflows/*.md`
253
224
  * sources to the generated flat command tree `.claude/commands/*.md`
@@ -324,6 +295,14 @@ function runCommandsInSync({ projectRoot, cwd, readDir } = {}) {
324
295
  * Verify that the framework's required runtime dependencies are resolvable
325
296
  * from the project's node_modules.
326
297
  *
298
+ * `projectRoot` defaults to `process.cwd()` (the consumer project root) so
299
+ * the manifest path and the require-resolution context mirror the context in
300
+ * which the framework scripts actually run (Story #4046 A2). Using
301
+ * `resolveProjectRoot()` (the package root inside `node_modules/mandrel/`)
302
+ * breaks the check under pnpm isolated-mode layouts where the consumer's
303
+ * node_modules are hoisted above `node_modules/mandrel/` and are invisible
304
+ * from the package root's resolver.
305
+ *
327
306
  * Injectable seams:
328
307
  * - `resolve(dep)` — replaces the real `require.resolve`; throws when a dep
329
308
  * is missing.
@@ -338,10 +317,15 @@ function runRuntimeDeps({
338
317
  resolve: resolveSeam,
339
318
  manifestRequired,
340
319
  } = {}) {
320
+ // Anchor at process.cwd() (the consumer root), not resolveProjectRoot() (the
321
+ // package root). Under pnpm isolated-mode the consumer's node_modules are not
322
+ // visible from inside node_modules/mandrel/, so the old anchor produced false
323
+ // "missing" reports on a clean install (Story #4046 A2).
324
+ const root = projectRoot ?? process.cwd();
325
+
341
326
  let required = manifestRequired;
342
327
  if (!required) {
343
328
  try {
344
- const root = projectRoot ?? resolveProjectRoot();
345
329
  const manifestPath = path.join(root, '.agents', 'runtime-deps.json');
346
330
  const raw = fs.readFileSync(manifestPath, 'utf8');
347
331
  const parsed = JSON.parse(raw);
@@ -366,9 +350,11 @@ function runRuntimeDeps({
366
350
  }
367
351
  }
368
352
  } else {
369
- // Anchor resolution to the project root so it mirrors the context in which
370
- // the framework scripts run (they free-ride on the consumer's node_modules).
371
- const root = projectRoot ?? resolveProjectRoot();
353
+ // Anchor resolution to the consumer project root so it mirrors the context
354
+ // in which the framework scripts run (they free-ride on the consumer's
355
+ // node_modules). Under pnpm isolated-mode the consumer's node_modules are
356
+ // not reachable from inside node_modules/mandrel/; anchoring at process.cwd()
357
+ // finds them correctly.
372
358
  const req = createRequire(path.join(root, 'package.json'));
373
359
  for (const dep of required) {
374
360
  try {
@@ -467,68 +453,9 @@ function runAgentsMaterialized({ cwd, existsSync, resolvePackage } = {}) {
467
453
  // check: agents-drift
468
454
  // ---------------------------------------------------------------------------
469
455
 
470
- /**
471
- * Package name whose `.agents/` payload is the drift baseline. Mirrors
472
- * `lib/cli/sync.js#PACKAGE_NAME`.
473
- */
474
- const PACKAGE_NAME = 'mandrel';
475
-
476
- /**
477
- * Top-level directory name (relative to `.agents/`) reserved as the
478
- * sync-exempt local-additions zone (Story #3498, f-drift-local-zone).
479
- *
480
- * `.agents/local/` is consumer-owned space that `mandrel sync` never
481
- * materializes nor prunes, so it is excluded from the drift comparison —
482
- * a hand-authored file under `.agents/local/` is sanctioned, not drift.
483
- * Mirrors `lib/cli/sync.js#LOCAL_ZONE_DIR`.
484
- */
485
- const LOCAL_ZONE_DIR = 'local';
486
-
487
- /**
488
- * Default resolver: locate the installed `mandrel` package root by
489
- * resolving its `package.json` and returning the directory that contains it.
490
- * Mirrors `lib/cli/sync.js#defaultResolvePackageRoot` so the drift baseline
491
- * is exactly the payload that `mandrel sync` would copy.
492
- *
493
- * @param {string} fromDir - Directory to resolve from (the consumer project).
494
- * @returns {string} Absolute path to the package root.
495
- */
496
- function defaultResolvePackageRoot(fromDir) {
497
- const requireFrom = createRequire(path.join(fromDir, 'noop.js'));
498
- const pkgJsonPath = requireFrom.resolve(`${PACKAGE_NAME}/package.json`);
499
- return path.dirname(pkgJsonPath);
500
- }
501
-
502
- /**
503
- * Recursively enumerate every regular file under `dir`, returning paths
504
- * relative to `dir` using OS separators. The top-level `local/` subtree is
505
- * skipped entirely — it is the consumer-owned local-additions zone and is
506
- * never part of the package payload (Story #3498). Mirrors
507
- * `lib/cli/sync.js#listFiles` so source enumeration matches the materializer.
508
- *
509
- * @param {string} dir - Absolute directory to walk.
510
- * @param {typeof fs} fsImpl
511
- * @param {string} [prefix] - Accumulated relative prefix (internal).
512
- * @returns {string[]} Relative file paths.
513
- */
514
- function listPayloadFiles(dir, fsImpl, prefix = '') {
515
- const out = [];
516
- for (const ent of fsImpl.readdirSync(dir, { withFileTypes: true })) {
517
- // Scope the local-zone skip to the top-level `.agents/local/` only; a
518
- // deeper directory that happens to be named `local` stays in scope.
519
- if (prefix === '' && ent.name === LOCAL_ZONE_DIR && ent.isDirectory()) {
520
- continue;
521
- }
522
- const rel = prefix ? path.join(prefix, ent.name) : ent.name;
523
- const abs = path.join(dir, ent.name);
524
- if (ent.isDirectory()) {
525
- out.push(...listPayloadFiles(abs, fsImpl, rel));
526
- } else {
527
- out.push(rel);
528
- }
529
- }
530
- return out;
531
- }
456
+ // defaultResolvePackageRoot and listPayloadFiles (re-exported as `listFiles`
457
+ // from sync.js) are imported from `lib/cli/sync.js` at the top of this file
458
+ // (Story #4048 B3 — no mirror copies).
532
459
 
533
460
  /**
534
461
  * Compare the consumer's materialized `./.agents/<f>` bytes against the
@@ -556,7 +483,7 @@ function listPayloadFiles(dir, fsImpl, prefix = '') {
556
483
  * }} [opts]
557
484
  * @returns {{ ok: boolean, detail: string, remedy?: string }}
558
485
  */
559
- function runAgentsDrift({ cwd, fsImpl = fs, resolvePackageRoot } = {}) {
486
+ export function runAgentsDrift({ cwd, fsImpl = fs, resolvePackageRoot } = {}) {
560
487
  const getCwd = cwd ?? (() => process.cwd());
561
488
  const resolveRoot = resolvePackageRoot ?? defaultResolvePackageRoot;
562
489
  const projectRoot = getCwd();
@@ -630,39 +557,9 @@ function runAgentsDrift({ cwd, fsImpl = fs, resolvePackageRoot } = {}) {
630
557
  // check: version-current
631
558
  // ---------------------------------------------------------------------------
632
559
 
633
- /**
634
- * Parse a dotted semver-ish string into a numeric tuple. Non-numeric or
635
- * missing segments coerce to 0 so a partial version still compares sanely.
636
- * Mirrors `lib/cli/update.js#parseVersion`.
637
- *
638
- * @param {string} version
639
- * @returns {[number, number, number]}
640
- */
641
- function parseVersionTuple(version) {
642
- const [major, minor, patch] = String(version).split('.');
643
- return [
644
- Number.parseInt(major, 10) || 0,
645
- Number.parseInt(minor, 10) || 0,
646
- Number.parseInt(patch, 10) || 0,
647
- ];
648
- }
649
-
650
- /**
651
- * Compare two version strings. Negative when `a < b`, zero when equal,
652
- * positive when `a > b`. Mirrors `lib/cli/update.js#compareVersions`.
653
- *
654
- * @param {string} a
655
- * @param {string} b
656
- * @returns {number}
657
- */
658
- function compareSemver(a, b) {
659
- const pa = parseVersionTuple(a);
660
- const pb = parseVersionTuple(b);
661
- for (let i = 0; i < 3; i += 1) {
662
- if (pa[i] !== pb[i]) return pa[i] - pb[i];
663
- }
664
- return 0;
665
- }
560
+ // parseVersion and compareVersions are imported from `lib/cli/version-helpers.js`
561
+ // at the top of this file (Story #4048 B3 — no mirror copies). `compareSemver`
562
+ // is the registry-local alias for the imported `compareVersions`.
666
563
 
667
564
  /**
668
565
  * Resolve the installed `mandrel` version from this package's own
@@ -685,17 +582,16 @@ function defaultInstalledVersion(fsImpl) {
685
582
  const DEFAULT_VERSION_CACHE_FILENAME = 'version-check.json';
686
583
 
687
584
  /**
688
- * Default cache path: `<projectRoot>/temp/version-check.json`, the same daily
689
- * freshness cache that `lib/cli/version-check.js` reads and refreshes.
585
+ * Default cache path: `<consumerRoot>/temp/version-check.json`, anchored at
586
+ * `process.cwd()` (the consumer project root) so the cache file survives
587
+ * npm/pnpm reinstalls that may replace `node_modules/` entirely (Story #4046
588
+ * A2). Mirrors `commands-in-sync` / `agents-materialized` / `agents-drift`
589
+ * which all anchor at `process.cwd()`.
690
590
  *
691
591
  * @returns {string}
692
592
  */
693
593
  function defaultVersionCachePath() {
694
- return path.join(
695
- resolveProjectRoot(),
696
- 'temp',
697
- DEFAULT_VERSION_CACHE_FILENAME,
698
- );
594
+ return path.join(process.cwd(), 'temp', DEFAULT_VERSION_CACHE_FILENAME);
699
595
  }
700
596
 
701
597
  /**
package/lib/cli/sync.js CHANGED
@@ -7,7 +7,8 @@
7
7
  * `./.agents/` directory by **plain file copy** — never a symlink — so
8
8
  * Windows and POSIX behave identically (Tech Spec #3459, Feature #3461).
9
9
  *
10
- * Design contract (per Story #3467 AC and Tech Spec #3459 "API Changes"):
10
+ * Design contract (per Story #3467 AC, Tech Spec #3459 "API Changes", and
11
+ * Story #4046 sync-prune):
11
12
  * - Copy, not symlink. The materialized tree is plain regular files.
12
13
  * - Idempotent. A second run overwrites in place and leaves ./.agents/
13
14
  * byte-identical to the package payload.
@@ -17,12 +18,14 @@
17
18
  * - Exits non-zero with an actionable message when `mandrel` is
18
19
  * not resolvable in node_modules.
19
20
  * - `--dry-run` reports the planned copies and writes nothing.
20
- * - `--force` is accepted; the copy is overwrite-in-place either way, so
21
- * it exists for explicitness/forward-compat and changes no behaviour.
22
21
  * - Local-additions zone. The `.agents/local/` subtree is never copied
23
22
  * into nor pruned from the destination (Story #3498). It is the
24
23
  * consumer-owned space for hand-authored additions that must survive
25
24
  * every re-materialization.
25
+ * - Sync prune (Story #4046). After the copy pass, any file inside the
26
+ * managed `.agents/` zone (everything except `.agents/local/`) that has
27
+ * no counterpart in the package payload is deleted. Consumer additions
28
+ * under `.agents/local/` are never touched.
26
29
  *
27
30
  * Security (Tech Spec #3459 "Postinstall safety"):
28
31
  * - Does nothing beyond a local file copy: no network, no shell, no writes
@@ -42,7 +45,7 @@ import nodeFs from 'node:fs';
42
45
  import { createRequire } from 'node:module';
43
46
  import path from 'node:path';
44
47
 
45
- const PACKAGE_NAME = 'mandrel';
48
+ export const PACKAGE_NAME = 'mandrel';
46
49
 
47
50
  /**
48
51
  * Top-level directory name (relative to `.agents/`) reserved as the
@@ -56,7 +59,16 @@ const PACKAGE_NAME = 'mandrel';
56
59
  * which keeps both the dry-run plan and the real copy free of any
57
60
  * `.agents/local/**` path even if a future payload were to ship one.
58
61
  */
59
- const LOCAL_ZONE_DIR = 'local';
62
+ export const LOCAL_ZONE_DIR = 'local';
63
+
64
+ /**
65
+ * Basename pattern for consumer local-override files (`*.local.<ext>`,
66
+ * e.g. `instructions.local.md`, `foo.local.json`). Matches the gitignore
67
+ * convention (`.agents/*.local.md`, `.agentrc.local.json`) and the override
68
+ * mechanism documented in `.agents/instructions.md` § 1.E. These files never
69
+ * ship in the payload and are exempt from the sync prune pass.
70
+ */
71
+ export const LOCAL_OVERRIDE_RE = /\.local\.[^.]+$/;
60
72
 
61
73
  /**
62
74
  * Default resolver: locate the installed `mandrel` package root by
@@ -68,7 +80,7 @@ const LOCAL_ZONE_DIR = 'local';
68
80
  * @param {string} fromDir - Directory to resolve from (the consumer project).
69
81
  * @returns {string} Absolute path to the package root.
70
82
  */
71
- function defaultResolvePackageRoot(fromDir) {
83
+ export function defaultResolvePackageRoot(fromDir) {
72
84
  // Resolve relative to the consumer project so we find *their* install,
73
85
  // not a copy hoisted next to this CLI module.
74
86
  const requireFrom = createRequire(path.join(fromDir, 'noop.js'));
@@ -88,13 +100,13 @@ function defaultResolvePackageRoot(fromDir) {
88
100
  * from the package payload (Story #3498). See {@link LOCAL_ZONE_DIR}.
89
101
  *
90
102
  * @param {string} dir - Absolute directory to walk.
91
- * @param {typeof nodeFs} fs
103
+ * @param {typeof nodeFs} fsImpl
92
104
  * @param {string} [prefix] - Accumulated relative prefix (internal).
93
105
  * @returns {string[]} Relative file paths.
94
106
  */
95
- function listFiles(dir, fs, prefix = '') {
107
+ export function listFiles(dir, fsImpl, prefix = '') {
96
108
  const out = [];
97
- for (const ent of fs.readdirSync(dir, { withFileTypes: true })) {
109
+ for (const ent of fsImpl.readdirSync(dir, { withFileTypes: true })) {
98
110
  // Never enumerate the sync-exempt local-additions zone. Matching on the
99
111
  // empty prefix scopes the skip to the top-level `.agents/local/` only,
100
112
  // leaving any deeper directory that happens to be named `local` intact.
@@ -104,7 +116,55 @@ function listFiles(dir, fs, prefix = '') {
104
116
  const rel = prefix ? path.join(prefix, ent.name) : ent.name;
105
117
  const abs = path.join(dir, ent.name);
106
118
  if (ent.isDirectory()) {
107
- out.push(...listFiles(abs, fs, rel));
119
+ out.push(...listFiles(abs, fsImpl, rel));
120
+ } else {
121
+ out.push(rel);
122
+ }
123
+ }
124
+ return out;
125
+ }
126
+
127
+ /**
128
+ * Recursively enumerate every regular file under `dir`, returning paths
129
+ * relative to `dir` using OS separators. The top-level `local/` subtree is
130
+ * skipped so consumer additions inside `.agents/local/` are never pruned
131
+ * (Story #3498). Files following the `*.local.*` naming convention (e.g.
132
+ * `.agents/instructions.local.md` — the documented consumer override
133
+ * mechanism, instructions.md § 1.E) are also skipped: they never ship in
134
+ * the payload and must survive every sync.
135
+ *
136
+ * Mirrors `listFiles` but operates on the destination tree so we can
137
+ * identify stale files that have no payload counterpart (Story #4046 A3).
138
+ *
139
+ * @param {string} dir - Absolute directory to walk.
140
+ * @param {typeof nodeFs} fsImpl
141
+ * @param {string} [prefix] - Accumulated relative prefix (internal).
142
+ * @returns {string[]} Relative file paths.
143
+ */
144
+ function listDestFiles(dir, fsImpl, prefix = '') {
145
+ const out = [];
146
+ let entries;
147
+ try {
148
+ entries = fsImpl.readdirSync(dir, { withFileTypes: true });
149
+ } catch {
150
+ // Directory absent — nothing to prune.
151
+ return out;
152
+ }
153
+ for (const ent of entries) {
154
+ // The local-additions zone is never pruned; skip it at the top level.
155
+ if (prefix === '' && ent.name === LOCAL_ZONE_DIR && ent.isDirectory()) {
156
+ continue;
157
+ }
158
+ // Consumer local-override files (`*.local.*`, e.g. instructions.local.md,
159
+ // foo.local.json) are gitignored, never shipped in the payload, and a
160
+ // documented override mechanism (instructions.md § 1.E) — never prune.
161
+ if (!ent.isDirectory() && LOCAL_OVERRIDE_RE.test(ent.name)) {
162
+ continue;
163
+ }
164
+ const rel = prefix ? path.join(prefix, ent.name) : ent.name;
165
+ const abs = path.join(dir, ent.name);
166
+ if (ent.isDirectory()) {
167
+ out.push(...listDestFiles(abs, fsImpl, rel));
108
168
  } else {
109
169
  out.push(rel);
110
170
  }
@@ -115,6 +175,11 @@ function listFiles(dir, fs, prefix = '') {
115
175
  /**
116
176
  * Materialize the package's `.agents/` tree into `./.agents/`.
117
177
  *
178
+ * After the copy pass, any file inside the managed zone of the destination
179
+ * `.agents/` tree (everything outside `.agents/local/`) that has no
180
+ * counterpart in the package payload is deleted (sync-prune, Story #4046 A3).
181
+ * Consumer additions placed under `.agents/local/` are never touched.
182
+ *
118
183
  * @param {{
119
184
  * argv?: string[],
120
185
  * resolvePackageRoot?: (fromDir: string) => string,
@@ -124,8 +189,8 @@ function listFiles(dir, fs, prefix = '') {
124
189
  * writeErr?: (s: string) => void,
125
190
  * exit?: (code: number) => void,
126
191
  * }} [opts]
127
- * @returns {{ copied: number, planned: number, dryRun: boolean }} Summary
128
- * (also returned in dry-run / error paths for testability).
192
+ * @returns {{ copied: number, planned: number, pruned: number, dryRun: boolean }}
193
+ * Summary (also returned in dry-run / error paths for testability).
129
194
  */
130
195
  export function runSync({
131
196
  argv = [],
@@ -137,8 +202,6 @@ export function runSync({
137
202
  exit = (code) => process.exit(code),
138
203
  } = {}) {
139
204
  const dryRun = argv.includes('--dry-run');
140
- // `--force` overwrites local edits. The copy is overwrite-in-place
141
- // regardless, so the flag is accepted but does not branch behaviour.
142
205
  const projectRoot = cwd();
143
206
 
144
207
  let packageRoot;
@@ -150,7 +213,7 @@ export function runSync({
150
213
  ` → Install it first: npm install ${PACKAGE_NAME}\n`,
151
214
  );
152
215
  exit(1);
153
- return { copied: 0, planned: 0, dryRun };
216
+ return { copied: 0, planned: 0, pruned: 0, dryRun };
154
217
  }
155
218
 
156
219
  const sourceRoot = path.join(packageRoot, '.agents');
@@ -160,23 +223,36 @@ export function runSync({
160
223
  ` → Reinstall the package: npm install ${PACKAGE_NAME}\n`,
161
224
  );
162
225
  exit(1);
163
- return { copied: 0, planned: 0, dryRun };
226
+ return { copied: 0, planned: 0, pruned: 0, dryRun };
164
227
  }
165
228
 
166
229
  const destRoot = path.join(projectRoot, '.agents');
167
- const files = listFiles(sourceRoot, fs);
230
+ const payloadFiles = listFiles(sourceRoot, fs);
168
231
 
169
232
  if (dryRun) {
170
- for (const rel of files) {
233
+ for (const rel of payloadFiles) {
171
234
  write(`would copy ${path.join('.agents', rel)}\n`);
172
235
  }
236
+ // Compute stale files (managed-zone destination files with no payload
237
+ // counterpart) for the dry-run plan so operators can preview pruning.
238
+ const payloadSet = new Set(payloadFiles);
239
+ const destFiles = listDestFiles(destRoot, fs);
240
+ const stale = destFiles.filter((f) => !payloadSet.has(f));
241
+ for (const rel of stale) {
242
+ write(`would prune ${path.join('.agents', rel)}\n`);
243
+ }
173
244
  write(
174
- `✅ Dry run: ${files.length} file(s) would be materialized into ./.agents/\n`,
245
+ `✅ Dry run: ${payloadFiles.length} file(s) would be materialized, ${stale.length} stale file(s) would be pruned from ./.agents/\n`,
175
246
  );
176
- return { copied: 0, planned: files.length, dryRun: true };
247
+ return {
248
+ copied: 0,
249
+ planned: payloadFiles.length,
250
+ pruned: 0,
251
+ dryRun: true,
252
+ };
177
253
  }
178
254
 
179
- for (const rel of files) {
255
+ for (const rel of payloadFiles) {
180
256
  const src = path.join(sourceRoot, rel);
181
257
  const dest = path.join(destRoot, rel);
182
258
  fs.mkdirSync(path.dirname(dest), { recursive: true });
@@ -185,8 +261,31 @@ export function runSync({
185
261
  fs.copyFileSync(src, dest);
186
262
  }
187
263
 
188
- write(`✅ Materialized ${files.length} file(s) into ./.agents/\n`);
189
- return { copied: files.length, planned: files.length, dryRun: false };
264
+ // Prune pass (Story #4046 A3): remove managed-zone destination files that
265
+ // have no counterpart in the payload. The local-additions zone
266
+ // (.agents/local/) is never enumerated by listDestFiles and is therefore
267
+ // never pruned — consumer additions there are sanctioned, not stale.
268
+ const payloadSet = new Set(payloadFiles);
269
+ const destFiles = listDestFiles(destRoot, fs);
270
+ const staleFiles = destFiles.filter((f) => !payloadSet.has(f));
271
+ for (const rel of staleFiles) {
272
+ const dest = path.join(destRoot, rel);
273
+ fs.unlinkSync(dest);
274
+ }
275
+
276
+ if (staleFiles.length > 0) {
277
+ write(
278
+ `✅ Materialized ${payloadFiles.length} file(s) into ./.agents/ (pruned ${staleFiles.length} stale file(s))\n`,
279
+ );
280
+ } else {
281
+ write(`✅ Materialized ${payloadFiles.length} file(s) into ./.agents/\n`);
282
+ }
283
+ return {
284
+ copied: payloadFiles.length,
285
+ planned: payloadFiles.length,
286
+ pruned: staleFiles.length,
287
+ dryRun: false,
288
+ };
190
289
  }
191
290
 
192
291
  /**
@@ -54,7 +54,6 @@
54
54
 
55
55
  import fs from 'node:fs';
56
56
  import path from 'node:path';
57
- import { fileURLToPath } from 'node:url';
58
57
 
59
58
  import {
60
59
  LEDGER_SCHEMA_VERSION,
@@ -640,6 +639,7 @@ function formatOutcome(outcome) {
640
639
  * write?: (s: string) => void,
641
640
  * exit?: (code: number) => void,
642
641
  * includeGithub?: boolean,
642
+ * dryRun?: boolean,
643
643
  * }} [opts]
644
644
  * @returns {{ revertedCount: number, manualCount: number, ledgerFound: boolean, parseErrorCount: number }}
645
645
  */
@@ -650,6 +650,7 @@ export function runUninstall({
650
650
  write = (s) => process.stdout.write(s),
651
651
  exit = (code) => process.exit(code),
652
652
  includeGithub = false,
653
+ dryRun = false,
653
654
  } = {}) {
654
655
  const root = projectRoot ?? resolveProjectRoot(cwd);
655
656
 
@@ -695,6 +696,39 @@ export function runUninstall({
695
696
 
696
697
  const { fileTargets, manual, executedActionByTarget } = planUninstall(ledger);
697
698
 
699
+ // --- Dry run — show what would be reversed without touching anything. ---
700
+ if (dryRun) {
701
+ write('mandrel uninstall — planned reversal (dry run)\n');
702
+ for (const target of fileTargets) {
703
+ write(
704
+ formatOutcome({
705
+ kind: 'reverted',
706
+ target,
707
+ detail: '(would be reverted)',
708
+ }),
709
+ );
710
+ }
711
+ for (const entry of manual) {
712
+ const note = includeGithub
713
+ ? `${entry.detail} (acknowledged — reverse manually via the GitHub UI/API)`
714
+ : `${entry.detail} (left untouched — pass --include-github to acknowledge)`;
715
+ write(
716
+ formatOutcome({ kind: 'manual', target: entry.target, detail: note }),
717
+ );
718
+ }
719
+ write(
720
+ `Dry run: ${fileTargets.length} file target(s) would be reverted, ` +
721
+ `${manual.length} manual follow-up(s).\n`,
722
+ );
723
+ exit(0);
724
+ return {
725
+ revertedCount: 0,
726
+ manualCount: manual.length,
727
+ ledgerFound: true,
728
+ parseErrorCount: 0,
729
+ };
730
+ }
731
+
698
732
  let revertedCount = 0;
699
733
  let parseErrorCount = 0;
700
734
  for (const target of fileTargets) {
@@ -782,14 +816,15 @@ export function runUninstall({
782
816
  /**
783
817
  * Default export consumed by `bin/mandrel.js`.
784
818
  *
785
- * @param {string[]} argv — supports the single `--include-github` flag.
819
+ * Supported flags:
820
+ * --include-github Acknowledge GitHub-side manual follow-ups.
821
+ * --dry-run Show what would be reversed without touching anything.
822
+ *
823
+ * @param {string[]} argv
786
824
  * @returns {Promise<void>}
787
825
  */
788
826
  export default async function run(argv = []) {
789
827
  const includeGithub = argv.includes('--include-github');
790
- runUninstall({ includeGithub });
828
+ const dryRun = argv.includes('--dry-run');
829
+ runUninstall({ includeGithub, dryRun });
791
830
  }
792
-
793
- // Re-export so tests and callers can reference the resolved module path
794
- // without re-deriving it.
795
- export const __filenameForTests = fileURLToPath(import.meta.url);