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
package/lib/cli/update.js CHANGED
@@ -3,20 +3,20 @@
3
3
  * `mandrel update` subcommand — the auto-update orchestrator (f-update-command,
4
4
  * Story #3503, Epic #3437 — Auto-Update & Version Lifecycle).
5
5
  *
6
- * Advances `mandrel` to the newest **non-major** published version,
7
- * re-materializes `.agents/`, runs applicable version-keyed migrations,
8
- * surfaces the target changelog, and verifies the result via the doctor
9
- * registry. A **major** crossing (e.g. `1.x 2.0`) is gated: the orchestrator
10
- * refuses to apply it without `--major`, prints a pointer to
11
- * `docs/upgrade-major.md`, and exits non-zero without touching anything.
6
+ * Advances `mandrel` to the newest published version, re-materializes
7
+ * `.agents/`, runs applicable version-keyed migrations, surfaces the target
8
+ * changelog, and verifies the result via the doctor registry. Major crossings
9
+ * are applied like any other bump (hard-cutover doctrine
10
+ * `.agents/rules/git-conventions.md` § Contract Cutovers).
12
11
  *
13
- * ## Ordered cycle (happy path, non-major bump)
12
+ * ## Ordered cycle (happy path)
14
13
  *
15
14
  * 1. resolve target version (newest published) and the current version
16
- * 2. **major gate**decline + non-zero exit when the target crosses a
17
- * major boundary and `--major` is absent
18
- * 3. no-op short-circuit already on the newest version ⇒ nothing to do
19
- * 4. install — bump the dependency (lockfile bump left STAGED).
15
+ * 2. drift-aware short-circuitversion check gates on installed version AND
16
+ * materialized `.agents/` state (Story #4065). When already on newest AND
17
+ * no drift: nothing to do (true no-op). When already on newest BUT drift
18
+ * detected: skip npm-update/migrations, run sync + sync-commands to heal.
19
+ * 3. install — bump the dependency (lockfile bump left STAGED).
20
20
  * The package manager is auto-detected from the lockfile in the project
21
21
  * root: `pnpm-lock.yaml` ⇒ `pnpm add -D …` (with `-w` at a
22
22
  * `pnpm-workspace.yaml` root), `yarn.lock` ⇒ `yarn add -D …`, otherwise
@@ -25,17 +25,17 @@
25
25
  * the resolved version so an override can still consume the auto-probed
26
26
  * newest. The registry probe in step 1 always stays on `npm view` (a
27
27
  * PM-agnostic registry query).
28
- * 5. runSync — re-materialize ./.agents/ **from the newly-installed
28
+ * 4. runSync — re-materialize ./.agents/ **from the newly-installed
29
29
  * binary** so the materialized payload is always the target version's.
30
- * 6. runMigrations — apply version-keyed steps for the crossed range,
30
+ * 5. runMigrations — apply version-keyed steps for the crossed range,
31
31
  * **from the newly-installed binary**.
32
- * 7. doctor — run the check registry **from the newly-installed
32
+ * 6. doctor — run the check registry **from the newly-installed
33
33
  * binary** so `agents-drift` is never a false-green against stale payload.
34
- * 8. surface the changelog for the target version
34
+ * 7. surface the changelog for the target version
35
35
  *
36
36
  * ## Re-exec of post-install phases (Story #4034)
37
37
  *
38
- * Steps 57 execute as **child processes spawned from the newly-installed
38
+ * Steps 46 execute as **child processes spawned from the newly-installed
39
39
  * binary** (`<cwd>/node_modules/.bin/mandrel`) rather than in the running
40
40
  * process. Node cannot hot-swap a `require`d module mid-process, so without
41
41
  * re-exec, the still-running old binary's `runSync`/`runMigrations`/`runDoctor`
@@ -69,18 +69,6 @@
69
69
  * without invoking any effectful seam (no npm update, no sync, no migrations,
70
70
  * no doctor) and writing nothing.
71
71
  *
72
- * ## Major gate
73
- *
74
- * The project sits on the **1.x** line under release-please
75
- * `always-bump-minor` ([AGENTS.md § Major-version policy]); a major release is
76
- * a deliberate manual operator decision, so adopting one must be equally
77
- * deliberate. When the newest version's major exceeds the current major:
78
- * - **without `--major`**: print the available version + the
79
- * `docs/upgrade-major.md` runbook pointer, exit non-zero, and invoke
80
- * **no** npm-update / sync / migration / doctor seam.
81
- * - **with `--major`**: apply the major target and print the runbook inline.
82
- * Routine minor/patch bumps within the 1.x line are never gated.
83
- *
84
72
  * ## Changelog surface
85
73
  *
86
74
  * `defaultSurfaceChangelog` prints the `docs/CHANGELOG.md` section(s) for the
@@ -100,6 +88,11 @@
100
88
  * - `argv` — subcommand args (after `mandrel update`)
101
89
  * - `currentVersion` — the installed `mandrel` version string
102
90
  * - `resolveTargetVersion`— async, returns the newest published version
91
+ * - `checkDrift` — sync or async, returns `true` when `.agents/`
92
+ * differs from the installed payload. Used by the
93
+ * drift-aware no-op short-circuit (Story #4065).
94
+ * Defaults to `() => !runAgentsDrift().ok`, which
95
+ * reuses the same `agents-drift` doctor signal.
103
96
  * - `npmUpdate` — async, performs the dependency bump (no git);
104
97
  * receives `(target, { installCmd })`
105
98
  * - `spawnPhase` — async, spawns a post-install phase from the new
@@ -122,7 +115,7 @@
122
115
  * new binary path when `spawnPhase` is absent)
123
116
  *
124
117
  * Security (security-baseline § 5 — Data Leakage & Logging): logs only version
125
- * strings, step names, and the runbook path. No tokens, credentials, or env
118
+ * strings and step names. No tokens, credentials, or env
126
119
  * values are read or logged; no shell-string interpolation occurs here (the
127
120
  * npm bump is delegated to the injected `npmUpdate` seam, which owns transport).
128
121
  *
@@ -152,14 +145,13 @@ import nodeHttps from 'node:https';
152
145
  import path from 'node:path';
153
146
  import { fileURLToPath } from 'node:url';
154
147
 
148
+ import { detectPackageManagerWithWorkspace } from '../../.agents/scripts/lib/detect-package-manager.js';
155
149
  import { runInstallCommand } from '../../.agents/scripts/lib/install-cmd-parser.js';
156
150
  import { runMigrations as defaultRunMigrations } from '../migrations/index.js';
157
- import { registry } from './registry.js';
151
+ import { registry, runAgentsDrift } from './registry.js';
158
152
  import { runSync as defaultRunSync } from './sync.js';
159
153
  import { isStale } from './version-check.js';
160
-
161
- /** Path (relative to project root) of the major-upgrade runbook. */
162
- const RUNBOOK_PATH = 'docs/upgrade-major.md';
154
+ import { compareVersions } from './version-helpers.js';
163
155
 
164
156
  /** The published package whose newest version `mandrel update` advances to. */
165
157
  const PACKAGE_NAME = 'mandrel';
@@ -180,51 +172,6 @@ const GITHUB_RELEASES_URL = 'https://github.com/dsj1984/mandrel/releases';
180
172
  /** Default freshness-cache filename — mirrors version-check.js. */
181
173
  const DEFAULT_CACHE_FILENAME = 'version-check.json';
182
174
 
183
- /**
184
- * Parse a dotted semver-ish string into a numeric tuple. Non-numeric or
185
- * missing segments coerce to 0 so a partial version still compares sanely.
186
- *
187
- * @param {string} version
188
- * @returns {[number, number, number]}
189
- */
190
- function parseVersion(version) {
191
- const [major, minor, patch] = String(version).split('.');
192
- return [
193
- Number.parseInt(major, 10) || 0,
194
- Number.parseInt(minor, 10) || 0,
195
- Number.parseInt(patch, 10) || 0,
196
- ];
197
- }
198
-
199
- /**
200
- * Compare two version strings. Negative when `a < b`, zero when equal,
201
- * positive when `a > b` (the standard `Array.sort` comparator contract).
202
- *
203
- * @param {string} a
204
- * @param {string} b
205
- * @returns {number}
206
- */
207
- function compareVersions(a, b) {
208
- const pa = parseVersion(a);
209
- const pb = parseVersion(b);
210
- for (let i = 0; i < 3; i += 1) {
211
- if (pa[i] !== pb[i]) return pa[i] - pb[i];
212
- }
213
- return 0;
214
- }
215
-
216
- /**
217
- * True when `target`'s major axis is strictly greater than `current`'s — the
218
- * gated "crosses a major boundary" condition.
219
- *
220
- * @param {string} current
221
- * @param {string} target
222
- * @returns {boolean}
223
- */
224
- function crossesMajor(current, target) {
225
- return parseVersion(target)[0] > parseVersion(current)[0];
226
- }
227
-
228
175
  /**
229
176
  * Resolve the installed `mandrel` version from this package's own
230
177
  * `package.json`. The module lives at `<root>/lib/cli/update.js`, so the
@@ -255,12 +202,15 @@ function resolveProjectRoot() {
255
202
  * Default `resolveTargetVersion` seam: determine the newest published
256
203
  * `mandrel` version via the daily freshness cache (`version-check.js`).
257
204
  *
258
- * This delegates to `isStale`, which honours the 24h-cache semantics: a fresh
259
- * cache returns the cached version with **zero** network I/O, while a missing,
260
- * corrupt, or stale cache triggers exactly one network probe (`npm view
261
- * mandrel version`) and refreshes `temp/version-check.json`. Wiring the
262
- * production update path through `isStale` is precisely what populates that
263
- * daily cache, which the `version-current` doctor advisory reads.
205
+ * When `bypassCache` is `true` (the default for an explicit `mandrel update`
206
+ * call Story #4046 A1b), the cache freshness window is effectively zeroed by
207
+ * passing `now` as far in the future, which makes `isStale` treat any existing
208
+ * cache as stale and always issue exactly one network probe. The cache is still
209
+ * written so the post-update `version-current` advisory has a fresh baseline.
210
+ *
211
+ * When `bypassCache` is `false` (passive staleness checks only), the normal
212
+ * 24h-cache semantics apply: a fresh cache returns the cached version with
213
+ * zero network I/O.
264
214
  *
265
215
  * The network probe shells `npm view` through `spawnSync` with a fixed argument
266
216
  * vector (no shell-string interpolation; the package name is a constant). On
@@ -274,6 +224,7 @@ function resolveProjectRoot() {
274
224
  * fs?: typeof nodeFs,
275
225
  * runner?: () => string,
276
226
  * now?: Date,
227
+ * bypassCache?: boolean,
277
228
  * log?: (msg: string) => void,
278
229
  * }} [opts]
279
230
  * @returns {Promise<string>} The newest published version string.
@@ -283,9 +234,22 @@ async function defaultResolveTargetVersion({
283
234
  fs = nodeFs,
284
235
  runner = defaultVersionRunner,
285
236
  now = new Date(),
237
+ bypassCache = false,
286
238
  log = () => {},
287
239
  } = {}) {
288
- const result = await isStale({ cachePath, now, runner, fs, log });
240
+ // When bypassCache is true, push `now` far enough into the future that any
241
+ // cached checkedAt value is guaranteed to be older than the STALE_AFTER_MS
242
+ // window, forcing a fresh network probe (Story #4046 A1b).
243
+ const effectiveNow = bypassCache
244
+ ? new Date(now.getTime() + 48 * 60 * 60 * 1000)
245
+ : now;
246
+ const result = await isStale({
247
+ cachePath,
248
+ now: effectiveNow,
249
+ runner,
250
+ fs,
251
+ log,
252
+ });
289
253
  return String(result.latestVersion);
290
254
  }
291
255
 
@@ -352,28 +316,30 @@ function repairInstallCommand(packageManager) {
352
316
  * Detecting the lockfile keeps the bump on the operator's real package manager
353
317
  * so the change lands in the matching lockfile.
354
318
  *
319
+ * Delegates to the shared `detectPackageManagerWithWorkspace` helper
320
+ * (Story #4048 B3 — one implementation per concept). The `fs` seam is adapted
321
+ * to the shared module's `exists` contract; the shared module's `bun` return
322
+ * value coerces to `npm` here because `bun add` is not yet a first-class update
323
+ * path for this orchestrator.
324
+ *
355
325
  * @param {string} [cwd] - Project root to probe (default `process.cwd()`).
356
326
  * @param {typeof nodeFs} [fs]
357
327
  * @returns {{ packageManager: 'pnpm' | 'yarn' | 'npm', workspaceRoot: boolean }}
358
328
  */
359
329
  export function detectPackageManager(cwd = process.cwd(), fs = nodeFs) {
360
- const has = (file) => {
330
+ const exists = (p) => {
361
331
  try {
362
- return fs.existsSync(path.join(cwd, file));
332
+ return fs.existsSync(p);
363
333
  } catch {
364
334
  return false;
365
335
  }
366
336
  };
367
- if (has('pnpm-lock.yaml')) {
368
- return {
369
- packageManager: 'pnpm',
370
- workspaceRoot: has('pnpm-workspace.yaml'),
371
- };
372
- }
373
- if (has('yarn.lock')) {
374
- return { packageManager: 'yarn', workspaceRoot: false };
375
- }
376
- return { packageManager: 'npm', workspaceRoot: false };
337
+ const result = detectPackageManagerWithWorkspace(cwd, exists);
338
+ // Coerce `bun` → `npm` because this orchestrator's install-command builder
339
+ // only handles pnpm / yarn / npm today.
340
+ const packageManager =
341
+ result.packageManager === 'bun' ? 'npm' : result.packageManager;
342
+ return { packageManager, workspaceRoot: result.workspaceRoot };
377
343
  }
378
344
 
379
345
  /**
@@ -749,10 +715,25 @@ export function defaultSpawnPhase(
749
715
  }
750
716
 
751
717
  /**
752
- * The ordered step names the orchestrator drives on a non-major bump. Shared
718
+ * The ordered step names the orchestrator drives on an update. Shared
753
719
  * by the live path and the `--dry-run` plan printout so the two never drift.
720
+ *
721
+ * Step ordering (Story #4046 A1c):
722
+ * 1. npm-update — install the new version
723
+ * 2. runSync — re-materialize .agents/ from the new payload
724
+ * 3. sync-commands — regenerate .claude/commands/ from the new payload
725
+ * 4. runMigrations — apply version-keyed migrations
726
+ * 5. doctor — validate the post-upgrade state
727
+ * 6. surface changelog — print the changelog (always last, best-effort)
754
728
  */
755
- const STEP_PLAN = ['npm-update', 'runSync', 'runMigrations', 'doctor'];
729
+ const STEP_PLAN = [
730
+ 'npm-update',
731
+ 'runSync',
732
+ 'sync-commands',
733
+ 'runMigrations',
734
+ 'doctor',
735
+ 'surface changelog',
736
+ ];
756
737
 
757
738
  /**
758
739
  * Extract the `--install-cmd "<cmd>"` value from the subcommand argv. Accepts
@@ -780,21 +761,6 @@ function parseInstallCmdFlag(argv) {
780
761
  return undefined;
781
762
  }
782
763
 
783
- /**
784
- * Print the major-gate refusal: the available version, the runbook pointer,
785
- * and the re-run hint. No effectful seam runs after this.
786
- *
787
- * @param {string} target
788
- * @param {(s: string) => void} writeErr
789
- */
790
- function emitMajorRefusal(target, writeErr) {
791
- writeErr(
792
- `mandrel update: a newer MAJOR version (${target}) is available; ` +
793
- 'this is a breaking upgrade.\n' +
794
- ` → Review ${RUNBOOK_PATH}, then re-run with --major to apply it.\n`,
795
- );
796
- }
797
-
798
764
  /**
799
765
  * Run the `mandrel update` orchestration cycle.
800
766
  *
@@ -803,6 +769,7 @@ function emitMajorRefusal(target, writeErr) {
803
769
  * currentVersion?: string | (() => string),
804
770
  * resolveTargetVersion?: () => (string | Promise<string>),
805
771
  * npmUpdate?: (version: string, opts: { installCmd?: string }) => unknown | Promise<unknown>,
772
+ * checkDrift?: () => (boolean | Promise<boolean>),
806
773
  * spawnPhase?: (phase: string, args: string[], opts: { binPath: string, cwd: string, write: (s: string) => void, writeErr: (s: string) => void }) => Promise<{ ok: boolean, stdout: string, stderr: string }> | { ok: boolean, stdout: string, stderr: string },
807
774
  * runSync?: typeof defaultRunSync,
808
775
  * runMigrations?: typeof defaultRunMigrations,
@@ -815,10 +782,9 @@ function emitMajorRefusal(target, writeErr) {
815
782
  * }} [opts]
816
783
  * @returns {Promise<{
817
784
  * ok: boolean,
818
- * action: 'updated' | 'declined-major' | 'dry-run' | 'up-to-date' | 'doctor-failed',
785
+ * action: 'updated' | 'resynced' | 'dry-run' | 'up-to-date' | 'doctor-failed',
819
786
  * currentVersion: string,
820
787
  * targetVersion: string | null,
821
- * major: boolean,
822
788
  * stepsRun: string[],
823
789
  * dryRun: boolean,
824
790
  * }>}
@@ -828,6 +794,7 @@ export async function runUpdate({
828
794
  currentVersion,
829
795
  resolveTargetVersion,
830
796
  npmUpdate,
797
+ checkDrift,
831
798
  spawnPhase,
832
799
  runSync = defaultRunSync,
833
800
  runMigrations = defaultRunMigrations,
@@ -839,7 +806,6 @@ export async function runUpdate({
839
806
  cwd = () => process.cwd(),
840
807
  } = {}) {
841
808
  const dryRun = argv.includes('--dry-run');
842
- const allowMajor = argv.includes('--major');
843
809
  const installCmd = parseInstallCmdFlag(argv);
844
810
 
845
811
  const current =
@@ -854,37 +820,113 @@ export async function runUpdate({
854
820
  }
855
821
  const target = String(await resolveTargetVersion());
856
822
 
857
- const major = crossesMajor(current, target);
858
-
859
- // --- Major gate -----------------------------------------------------------
860
- // A major crossing without --major is refused outright: no npm-update, no
861
- // sync, no migration, no doctor — print the runbook pointer and exit non-zero.
862
- if (major && !allowMajor) {
863
- emitMajorRefusal(target, writeErr);
864
- exit(1);
865
- return {
866
- ok: false,
867
- action: 'declined-major',
868
- currentVersion: current,
869
- targetVersion: target,
870
- major: true,
871
- stepsRun: [],
872
- dryRun,
873
- };
874
- }
875
-
876
823
  // --- No-op short-circuit --------------------------------------------------
877
- // Already on (or ahead of) the newest version: nothing to apply.
824
+ // Already on (or ahead of) the newest version: check whether .agents/ is
825
+ // actually materialized to the installed payload (agents-drift). When drift
826
+ // is present, fall through to a sync-only heal path even though the package
827
+ // version is unchanged. Only emit "Already up to date" when the version is
828
+ // current AND the payload matches (Story #4065).
878
829
  if (compareVersions(target, current) <= 0) {
879
- write(`✅ Already up to date (v${current} is the newest version).\n`);
830
+ // Resolve the drift probe: prefer the injected checkDrift seam (unit-test
831
+ // friendly); fall back to the production runAgentsDrift helper.
832
+ const driftProbe =
833
+ typeof checkDrift === 'function'
834
+ ? checkDrift
835
+ : () => !runAgentsDrift().ok;
836
+ const hasDrift = await driftProbe();
837
+
838
+ if (!hasDrift) {
839
+ write(`✅ Already up to date (v${current} is the newest version).\n`);
840
+ return {
841
+ ok: true,
842
+ action: 'up-to-date',
843
+ currentVersion: current,
844
+ targetVersion: target,
845
+ stepsRun: [],
846
+ dryRun,
847
+ };
848
+ }
849
+
850
+ // Drift detected while version is already current — heal by re-syncing
851
+ // without bumping the package (no npm-update, no migrations needed since
852
+ // the installed version did not change).
853
+ if (dryRun) {
854
+ write(
855
+ `mandrel update — drift detected, sync heal planned (v${current} is already current)\n`,
856
+ );
857
+ write(
858
+ ' 1. runSync — re-materialize .agents/ from installed payload\n',
859
+ );
860
+ write(
861
+ ' 2. sync-commands — regenerate .claude/commands/ from .agents/workflows/\n',
862
+ );
863
+ write('Dry run: no files written.\n');
864
+ return {
865
+ ok: true,
866
+ action: 'dry-run',
867
+ currentVersion: current,
868
+ targetVersion: target,
869
+ stepsRun: [],
870
+ dryRun: true,
871
+ };
872
+ }
873
+
874
+ write(
875
+ `Healing .agents/ drift (v${current} is already current, but .agents/ is stale)…\n`,
876
+ );
877
+ const stepsRun = [];
878
+
879
+ const useReExec = typeof spawnPhase === 'function';
880
+
881
+ if (useReExec) {
882
+ const projectRoot = cwd();
883
+ const binPath = resolveNewBinPath(projectRoot);
884
+
885
+ const syncResult = await spawnPhase('sync', [], {
886
+ binPath,
887
+ cwd: projectRoot,
888
+ write,
889
+ writeErr,
890
+ });
891
+ if (!syncResult.ok) {
892
+ throw new Error(
893
+ `mandrel update: \`mandrel sync\` from installed binary exited non-zero — ` +
894
+ 'the .agents/ materialization may be incomplete. ' +
895
+ 'Run `mandrel sync` manually to restore.',
896
+ );
897
+ }
898
+ stepsRun.push('runSync');
899
+
900
+ const syncCommandsResult = await spawnPhase('sync-commands', [], {
901
+ binPath,
902
+ cwd: projectRoot,
903
+ write,
904
+ writeErr,
905
+ });
906
+ if (!syncCommandsResult.ok) {
907
+ throw new Error(
908
+ `mandrel update: \`mandrel sync-commands\` from installed binary exited non-zero — ` +
909
+ 'the .claude/commands/ tree may be out of sync. ' +
910
+ 'Run `npm run sync:commands` manually to restore.',
911
+ );
912
+ }
913
+ stepsRun.push('sync-commands');
914
+ } else {
915
+ // In-process backward-compat path.
916
+ runSync({ argv: [] });
917
+ stepsRun.push('runSync');
918
+ }
919
+
920
+ write(
921
+ `✅ Healed .agents/ drift (v${current}). The materialized payload is now current.\n`,
922
+ );
880
923
  return {
881
924
  ok: true,
882
- action: 'up-to-date',
925
+ action: 'resynced',
883
926
  currentVersion: current,
884
927
  targetVersion: target,
885
- major,
886
- stepsRun: [],
887
- dryRun,
928
+ stepsRun,
929
+ dryRun: false,
888
930
  };
889
931
  }
890
932
 
@@ -893,34 +935,21 @@ export async function runUpdate({
893
935
  // write nothing to disk.
894
936
  if (dryRun) {
895
937
  write(`mandrel update — planned upgrade v${current} → v${target}\n`);
896
- if (major) {
897
- write(' (major upgrade — --major supplied)\n');
898
- }
899
938
  STEP_PLAN.forEach((step, i) => {
900
939
  write(` ${i + 1}. ${step}\n`);
901
940
  });
902
- write(' 5. surface changelog\n');
903
941
  write('Dry run: no files written, no dependency bumped.\n');
904
942
  return {
905
943
  ok: true,
906
944
  action: 'dry-run',
907
945
  currentVersion: current,
908
946
  targetVersion: target,
909
- major,
910
947
  stepsRun: [],
911
948
  dryRun: true,
912
949
  };
913
950
  }
914
951
 
915
- // --- Major runbook (inline, when --major applies) -------------------------
916
- if (major) {
917
- write(
918
- `Applying MAJOR upgrade v${current} → v${target} (--major). ` +
919
- `Review the runbook: ${RUNBOOK_PATH}\n`,
920
- );
921
- } else {
922
- write(`Updating v${current} → v${target}…\n`);
923
- }
952
+ write(`Updating v${current} v${target}…\n`);
924
953
 
925
954
  const stepsRun = [];
926
955
 
@@ -970,7 +999,29 @@ export async function runUpdate({
970
999
  }
971
1000
  stepsRun.push('runSync');
972
1001
 
973
- // 3. runMigrations from new bin — apply version-keyed steps for the
1002
+ // 3. sync-commands from new bin — regenerate .claude/commands/ from the
1003
+ // freshly-materialized .agents/workflows/. Running from the new bin
1004
+ // ensures the command tree is consistent with the new payload; an
1005
+ // upstream-renamed workflow will be projected correctly and the old
1006
+ // command file will be reaped. This step must follow runSync so the
1007
+ // workflow sources are up to date before the command tree is rebuilt
1008
+ // (Story #4046 A1c — `commands-in-sync` validates the post-sync state).
1009
+ const syncCommandsResult = await spawnPhase('sync-commands', [], {
1010
+ binPath,
1011
+ cwd: projectRoot,
1012
+ write,
1013
+ writeErr,
1014
+ });
1015
+ if (!syncCommandsResult.ok) {
1016
+ throw new Error(
1017
+ `mandrel update: \`mandrel sync-commands\` from new binary exited non-zero — ` +
1018
+ 'the .claude/commands/ tree may be out of sync. ' +
1019
+ 'Run `npm run sync:commands` manually to restore.',
1020
+ );
1021
+ }
1022
+ stepsRun.push('sync-commands');
1023
+
1024
+ // 4. runMigrations from new bin — apply version-keyed steps for the
974
1025
  // crossed range. The new binary's migration registry contains any steps
975
1026
  // added in the target version; the old process's registry does not.
976
1027
  const migrateResult = await spawnPhase(
@@ -987,7 +1038,7 @@ export async function runUpdate({
987
1038
  }
988
1039
  stepsRun.push('runMigrations');
989
1040
 
990
- // 4. doctor from new bin — verify the resulting install. Running from the
1041
+ // 5. doctor from new bin — verify the resulting install. Running from the
991
1042
  // new bin is critical: the agents-drift check compares the materialized
992
1043
  // .agents/ against the installed package payload. When the old process
993
1044
  // runs this check, it resolves the package root to its own (old) install
@@ -1002,7 +1053,7 @@ export async function runUpdate({
1002
1053
  });
1003
1054
  stepsRun.push('doctor');
1004
1055
 
1005
- // 5. surface the target changelog (best-effort; optional seam).
1056
+ // 6. surface the target changelog (best-effort; optional seam).
1006
1057
  if (typeof surfaceChangelog === 'function') {
1007
1058
  await surfaceChangelog(target);
1008
1059
  }
@@ -1018,7 +1069,6 @@ export async function runUpdate({
1018
1069
  action: 'doctor-failed',
1019
1070
  currentVersion: current,
1020
1071
  targetVersion: target,
1021
- major,
1022
1072
  stepsRun,
1023
1073
  dryRun: false,
1024
1074
  };
@@ -1034,6 +1084,9 @@ export async function runUpdate({
1034
1084
  stepsRun.push('runSync');
1035
1085
 
1036
1086
  // 3. runMigrations — apply version-keyed steps for the crossed range.
1087
+ // Note: the in-process path (pre-Story-#4034) does not run sync-commands
1088
+ // here because sync-commands runs as a child process and there is no
1089
+ // in-process seam for it. The re-exec path (spawnPhase) handles it.
1037
1090
  runMigrations({ fromVersion: current, toVersion: target, ctx: {} });
1038
1091
  stepsRun.push('runMigrations');
1039
1092
 
@@ -1059,7 +1112,6 @@ export async function runUpdate({
1059
1112
  action: 'doctor-failed',
1060
1113
  currentVersion: current,
1061
1114
  targetVersion: target,
1062
- major,
1063
1115
  stepsRun,
1064
1116
  dryRun: false,
1065
1117
  };
@@ -1072,7 +1124,6 @@ export async function runUpdate({
1072
1124
  action: 'updated',
1073
1125
  currentVersion: current,
1074
1126
  targetVersion: target,
1075
- major,
1076
1127
  stepsRun,
1077
1128
  dryRun: false,
1078
1129
  };
@@ -1082,20 +1133,21 @@ export async function runUpdate({
1082
1133
  * Default export consumed by `bin/mandrel.js`.
1083
1134
  *
1084
1135
  * Wires the production-default seams that `runUpdate` leaves injectable:
1085
- * - `resolveTargetVersion` probes the newest published `mandrel`
1086
- * version through the daily freshness cache (`version-check.js#isStale`),
1087
- * which ALSO populates `temp/version-check.json` the cache the
1088
- * `version-current` doctor advisory reads.
1136
+ * - `resolveTargetVersion` always probes the registry via `isStale` with
1137
+ * `bypassCache: true` the 24h cache is overridden for explicit update
1138
+ * calls so the resolved version is always fresh (Story #4046 A1b). The
1139
+ * cache is still written so the `version-current` doctor advisory reads
1140
+ * a current baseline after the upgrade.
1089
1141
  * - `npmUpdate` runs the install command — auto-detected from the project
1090
1142
  * lockfile (`pnpm`/`yarn`/`npm`), or the `--install-cmd` override —
1091
1143
  * through the shared `runInstallCommand` helper — no git mutation;
1092
1144
  * lockfile left staged.
1093
1145
  * - `spawnPhase` is wired to `defaultSpawnPhase`, which spawns each
1094
- * post-install phase (sync, migrate, doctor) from the newly-installed
1095
- * binary (`node_modules/.bin/mandrel`). This is the Story #4034 fix:
1096
- * the new bin loads the new package's module code and resolves paths
1097
- * against the new install dir, so sync/migrate/doctor can never observe
1098
- * the old payload.
1146
+ * post-install phase (sync, sync-commands, migrate, doctor) from the
1147
+ * newly-installed binary (`node_modules/.bin/mandrel`). This is the
1148
+ * Story #4034 fix: the new bin loads the new package's module code and
1149
+ * resolves paths against the new install dir, so these phases can never
1150
+ * observe the old payload.
1099
1151
  * - `surfaceChangelog` prints the relevant `docs/CHANGELOG.md` section(s)
1100
1152
  * for the applied range. Reads from the packaged file first; falls back to
1101
1153
  * a GitHub raw-content fetch via the injectable `fetchChangelog` seam when
@@ -1104,7 +1156,7 @@ export async function runUpdate({
1104
1156
  *
1105
1157
  * Every seam stays injectable on `runUpdate`; these are merely the
1106
1158
  * no-seam-provided fallbacks, so the existing seam-driven tests stay green.
1107
- * `--major` / `--dry-run` / `--install-cmd` are parsed from `argv` by
1159
+ * `--dry-run` / `--install-cmd` are parsed from `argv` by
1108
1160
  * `runUpdate` itself.
1109
1161
  *
1110
1162
  * The second `deps` argument exposes the **process boundaries** the production
@@ -1152,9 +1204,9 @@ export default async function run(argv = [], deps = {}) {
1152
1204
  runSync,
1153
1205
  runMigrations,
1154
1206
  runDoctor,
1155
- write,
1156
- writeErr,
1157
- exit,
1207
+ write = (s) => process.stdout.write(s),
1208
+ writeErr = (s) => process.stderr.write(s),
1209
+ exit = (code) => process.exit(code),
1158
1210
  log,
1159
1211
  } = deps;
1160
1212
 
@@ -1170,46 +1222,53 @@ export default async function run(argv = [], deps = {}) {
1170
1222
  ...(spawnFn ? { spawnFn } : {}),
1171
1223
  });
1172
1224
 
1225
+ // Resolve which seam set to use for post-install phases. If any old-style
1226
+ // in-process seam (runSync/runMigrations/runDoctor) is injected, fall back
1227
+ // to the pre-Story-#4034 in-process path so the entrypoint test stays green.
1228
+ // Otherwise use the re-exec path (spawnPhase). This is a single ternary
1229
+ // rather than stacked optional spreads (tidy, Story #4046).
1230
+ const phaseSeams =
1231
+ runSync || runMigrations || runDoctor
1232
+ ? {
1233
+ ...(runSync ? { runSync } : {}),
1234
+ ...(runMigrations ? { runMigrations } : {}),
1235
+ ...(runDoctor ? { runDoctor } : {}),
1236
+ }
1237
+ : { spawnPhase: productionSpawnPhase };
1238
+
1173
1239
  await runUpdateImpl({
1174
1240
  argv,
1175
1241
  currentVersion: current,
1242
+ // Always bypass the 24h cache on an explicit `mandrel update` so the
1243
+ // resolved target is fresh from the registry (Story #4046 A1b).
1176
1244
  resolveTargetVersion: () =>
1177
1245
  defaultResolveTargetVersion({
1178
- ...(cachePath ? { cachePath } : {}),
1246
+ cachePath:
1247
+ cachePath ?? path.join(process.cwd(), 'temp', DEFAULT_CACHE_FILENAME),
1179
1248
  fs,
1180
- ...(versionRunner ? { runner: versionRunner } : {}),
1181
- ...(now ? { now } : {}),
1182
- ...(log ? { log } : {}),
1249
+ runner: versionRunner ?? defaultVersionRunner,
1250
+ now: now ?? new Date(),
1251
+ bypassCache: true,
1252
+ log: log ?? (() => {}),
1183
1253
  }),
1184
1254
  npmUpdate: (target, { installCmd } = {}) =>
1185
1255
  defaultNpmUpdate(target, {
1186
1256
  ...(installCmd ? { installCmd } : {}),
1187
- ...(runInstall ? { runInstall } : {}),
1257
+ runInstall: runInstall ?? runInstallCommand,
1188
1258
  fs,
1189
1259
  }),
1190
- // In the production default export, always wire spawnPhase unless the
1191
- // caller injects the old-style in-process seams (runSync/runMigrations/
1192
- // runDoctor). When any old-style seam is present, fall back to in-process
1193
- // behavior to preserve the full backward-compat surface that the
1194
- // entrypoint test (update-entrypoint.test.js) relies on.
1195
- ...(runSync || runMigrations || runDoctor
1196
- ? {
1197
- ...(runSync ? { runSync } : {}),
1198
- ...(runMigrations ? { runMigrations } : {}),
1199
- ...(runDoctor ? { runDoctor } : {}),
1200
- }
1201
- : { spawnPhase: productionSpawnPhase }),
1260
+ ...phaseSeams,
1202
1261
  surfaceChangelog: (target) =>
1203
1262
  defaultSurfaceChangelog(target, {
1204
1263
  current,
1205
1264
  fs,
1206
1265
  ...(changelogPath ? { changelogPath } : {}),
1207
- ...(fetchChangelog ? { fetchChangelog } : {}),
1208
- ...(write ? { write } : {}),
1209
- ...(writeErr ? { writeErr } : {}),
1266
+ fetchChangelog: fetchChangelog ?? fetchChangelogFromGitHub,
1267
+ write,
1268
+ writeErr,
1210
1269
  }),
1211
- ...(write ? { write } : {}),
1212
- ...(writeErr ? { writeErr } : {}),
1213
- ...(exit ? { exit } : {}),
1270
+ write,
1271
+ writeErr,
1272
+ exit,
1214
1273
  });
1215
1274
  }