mandrel 1.69.0 → 1.71.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 (57) hide show
  1. package/.agents/README.md +7 -7
  2. package/.agents/docs/SDLC.md +4 -5
  3. package/.agents/docs/configuration.md +9 -9
  4. package/.agents/docs/workflows.md +4 -6
  5. package/.agents/schemas/qa-finding.schema.json +1 -1
  6. package/.agents/scripts/apply-quality-bootstrap.js +79 -0
  7. package/.agents/scripts/audit-labels-bootstrap.js +52 -30
  8. package/.agents/scripts/audit-to-stories.js +54 -0
  9. package/.agents/scripts/bootstrap.js +13 -3
  10. package/.agents/scripts/generate-config-docs.js +189 -94
  11. package/.agents/scripts/lib/audit-suite/findings.js +0 -4
  12. package/.agents/scripts/lib/audit-to-stories/audit-lenses.js +99 -0
  13. package/.agents/scripts/lib/audit-to-stories/build-story-body.js +13 -5
  14. package/.agents/scripts/lib/baseline-snapshot.js +163 -4
  15. package/.agents/scripts/lib/baselines/refresh-service.js +0 -4
  16. package/.agents/scripts/lib/bootstrap/ci-workflow-template.js +1 -1
  17. package/.agents/scripts/lib/bootstrap/quality-bootstrap.js +1 -1
  18. package/.agents/scripts/lib/config/baselines.js +0 -20
  19. package/.agents/scripts/lib/config/defaults.js +1 -1
  20. package/.agents/scripts/lib/config/sync-agentrc.js +1 -1
  21. package/.agents/scripts/lib/config/temp-paths.js +0 -31
  22. package/.agents/scripts/lib/config-resolver.js +1 -1
  23. package/.agents/scripts/lib/crap-utils.js +281 -0
  24. package/.agents/scripts/lib/orchestration/dispatch-engine.js +0 -2
  25. package/.agents/scripts/lib/orchestration/epic-runner/progress-reporter/composition.js +0 -84
  26. package/.agents/scripts/lib/orchestration/epic-runner/progress-reporter/signals.js +3 -4
  27. package/.agents/scripts/lib/orchestration/lifecycle/trace-logger.js +0 -4
  28. package/.agents/scripts/lib/orchestration/retro/phases/compose-body.js +101 -70
  29. package/.agents/scripts/lib/orchestration/spec-renderer.js +42 -14
  30. package/.agents/scripts/lib/orchestration/ticket-lease.js +3 -0
  31. package/.agents/scripts/lib/qa/qa-context-hydrator.js +1 -1
  32. package/.agents/scripts/lib/qa/resolve-qa-contract.js +1 -1
  33. package/.agents/scripts/lib/story-body/story-body.js +110 -65
  34. package/.agents/scripts/lib/test-tiers.js +13 -7
  35. package/.agents/scripts/lib/wave-runner/tick.js +177 -53
  36. package/.agents/scripts/lib/workers/combined-mi-crap-worker.js +226 -0
  37. package/.agents/scripts/mandrel-update-preflight.js +235 -0
  38. package/.agents/scripts/providers/github/issues.js +48 -0
  39. package/.agents/scripts/providers/github.js +1 -0
  40. package/.agents/scripts/sync-agentrc.js +2 -2
  41. package/.agents/skills/skills.index.json +2 -2
  42. package/.agents/skills/stack/qa/playwright-bdd/SKILL.md +3 -3
  43. package/.agents/skills/stack/qa/qa-harness/SKILL.md +4 -4
  44. package/.agents/workflows/git-deliver.md +298 -0
  45. package/.agents/workflows/helpers/epic-testing.md +6 -6
  46. package/.agents/workflows/helpers/{agents-sync-config.md → mandrel-sync-config.md} +5 -4
  47. package/.agents/workflows/{agents-update.md → mandrel-update.md} +210 -33
  48. package/.agents/workflows/qa-explore.md +1 -1
  49. package/.agents/workflows/{qa-run-harness.md → qa-run.md} +5 -5
  50. package/README.md +40 -0
  51. package/docs/CHANGELOG.md +43 -0
  52. package/lib/cli/registry.js +49 -6
  53. package/lib/cli/update.js +335 -332
  54. package/package.json +16 -11
  55. package/.agents/workflows/git-commit-all.md +0 -15
  56. package/.agents/workflows/git-pr-all.md +0 -281
  57. package/.agents/workflows/git-push.md +0 -63
package/lib/cli/update.js CHANGED
@@ -48,13 +48,9 @@
48
48
  * code) stays in the parent process; only the version-sensitive phases run from
49
49
  * the new bin. The `spawnPhase` seam makes the child-process boundary fully
50
50
  * injectable so tests can verify the re-exec path without a real npm install.
51
- *
52
- * Backward compatibility: when `runSync`, `runMigrations`, or `runDoctor`
53
- * are explicitly injected (the historical test-seam pattern) and `spawnPhase`
54
- * is NOT injected, the in-process seams are used unchanged (old tests stay
55
- * green). When `spawnPhase` IS injected, it takes priority over the in-process
56
- * seams for the live phases — so new tests targeting the re-exec boundary can
57
- * inject `spawnPhase` without touching the old seam interface.
51
+ * It is the **only** post-install execution path — tests stub the spawn
52
+ * boundary rather than swapping in an in-process implementation (No-Shim:
53
+ * `.agents/rules/git-conventions.md` § Contract Cutovers).
58
54
  *
59
55
  * ## No git mutation
60
56
  *
@@ -97,22 +93,14 @@
97
93
  * receives `(target, { installCmd })`
98
94
  * - `spawnPhase` — async, spawns a post-install phase from the new
99
95
  * binary; receives `(phase, args, { binPath, cwd })`
100
- * and returns `{ ok, stdout, stderr }`. When
101
- * injected, it takes priority over `runSync`,
102
- * `runMigrations`, and `runDoctor` for the live
103
- * phases. See § Re-exec of post-install phases.
104
- * - `runSync` — re-materializes ./.agents/ (lib/cli/sync.js).
105
- * Used when `spawnPhase` is NOT injected (backward
106
- * compat for tests that pre-date Story #4034).
107
- * - `runMigrations` — version-keyed migration runner (lib/migrations).
108
- * Used when `spawnPhase` is NOT injected.
109
- * - `runDoctor` — async, returns { ok, results } from the registry.
110
- * Used when `spawnPhase` is NOT injected.
96
+ * and returns `{ ok, stdout, stderr }`. This is the
97
+ * sole post-install execution path. See § Re-exec of
98
+ * post-install phases.
111
99
  * - `surfaceChangelog` — emits the target changelog section
112
100
  * - `write` / `writeErr` — stdout / stderr sinks
113
101
  * - `exit` — process.exit replacement
114
102
  * - `cwd` — process.cwd() replacement (used to resolve the
115
- * new binary path when `spawnPhase` is absent)
103
+ * new binary path the post-install phases spawn from)
116
104
  *
117
105
  * Security (security-baseline § 5 — Data Leakage & Logging): logs only version
118
106
  * strings and step names. No tokens, credentials, or env
@@ -147,9 +135,7 @@ import { fileURLToPath } from 'node:url';
147
135
 
148
136
  import { detectPackageManagerWithWorkspace } from '../../.agents/scripts/lib/detect-package-manager.js';
149
137
  import { runInstallCommand } from '../../.agents/scripts/lib/install-cmd-parser.js';
150
- import { runMigrations as defaultRunMigrations } from '../migrations/index.js';
151
- import { registry, runAgentsDrift } from './registry.js';
152
- import { runSync as defaultRunSync } from './sync.js';
138
+ import { runAgentsDrift } from './registry.js';
153
139
  import { isStale } from './version-check.js';
154
140
  import { compareVersions } from './version-helpers.js';
155
141
 
@@ -632,25 +618,6 @@ function parseChangelogSections(raw) {
632
618
  return sections;
633
619
  }
634
620
 
635
- /**
636
- * Default doctor seam: run every check in the registry sequentially and
637
- * report whether all passed. Mirrors lib/cli/doctor.js's pass accounting
638
- * without the formatted report (the orchestrator owns its own output).
639
- *
640
- * This is used only when `spawnPhase` is NOT injected (backward-compat path).
641
- *
642
- * @param {{ checks?: typeof registry }} [opts]
643
- * @returns {Promise<{ ok: boolean, results: Array<{ name: string, ok: boolean }> }>}
644
- */
645
- async function defaultRunDoctor({ checks = registry } = {}) {
646
- const results = [];
647
- for (const check of checks) {
648
- const r = await check.run();
649
- results.push({ name: check.name, ok: Boolean(r.ok) });
650
- }
651
- return { ok: results.every((r) => r.ok), results };
652
- }
653
-
654
621
  /**
655
622
  * Resolve the path to the `mandrel` binary inside `node_modules/.bin/` for the
656
623
  * given project root. On Windows the binary is a `.cmd` shim; on POSIX it is a
@@ -735,6 +702,144 @@ const STEP_PLAN = [
735
702
  'surface changelog',
736
703
  ];
737
704
 
705
+ /**
706
+ * The ordered post-install phase descriptors for a full upgrade. Each entry is
707
+ * a plain value (no I/O) describing one step the executor drives:
708
+ *
709
+ * - `kind: 'npm-update'` — bump the dependency via the `npmUpdate` seam.
710
+ * - `kind: 'spawn'` — spawn `phase`/`args` from the new binary; a
711
+ * non-zero exit is fatal and throws `failMessage`.
712
+ * - `kind: 'doctor'` — spawn `doctor` from the new binary; a non-zero
713
+ * exit is *soft* (maps to `action: 'doctor-failed'`
714
+ * + exit 1), so it carries no `failMessage`.
715
+ *
716
+ * `label` is the name pushed into the run's `stepsRun[]` (the external return
717
+ * contract). `migrate` is the only phase whose argv depends on the version
718
+ * range, so its descriptor is built per-plan in `planUpdate`.
719
+ *
720
+ * @param {string} current
721
+ * @param {string} target
722
+ * @returns {Array<{ kind: 'npm-update' | 'spawn' | 'doctor', label: string, phase?: string, args?: string[], failMessage?: string }>}
723
+ */
724
+ function fullUpgradeSteps(current, target) {
725
+ return [
726
+ { kind: 'npm-update', label: 'npm-update' },
727
+ {
728
+ kind: 'spawn',
729
+ phase: 'sync',
730
+ args: [],
731
+ label: 'runSync',
732
+ failMessage:
733
+ 'mandrel update: `mandrel sync` from new binary exited non-zero — ' +
734
+ 'the .agents/ materialization may be incomplete. ' +
735
+ 'Run `mandrel sync` manually to restore.',
736
+ },
737
+ {
738
+ kind: 'spawn',
739
+ phase: 'sync-commands',
740
+ args: [],
741
+ label: 'sync-commands',
742
+ failMessage:
743
+ 'mandrel update: `mandrel sync-commands` from new binary exited non-zero — ' +
744
+ 'the .claude/commands/ tree may be out of sync. ' +
745
+ 'Run `npm run sync:commands` manually to restore.',
746
+ },
747
+ {
748
+ kind: 'spawn',
749
+ phase: 'migrate',
750
+ args: ['--from', current, '--to', target],
751
+ label: 'runMigrations',
752
+ failMessage:
753
+ 'mandrel update: `mandrel migrate` from new binary exited non-zero — ' +
754
+ `some migrations for v${current} → v${target} may not have applied. ` +
755
+ `Run \`mandrel migrate --from ${current} --to ${target}\` manually to retry.`,
756
+ },
757
+ { kind: 'doctor', phase: 'doctor', args: [], label: 'doctor' },
758
+ ];
759
+ }
760
+
761
+ /**
762
+ * The ordered phase descriptors for a drift-heal (version already current, but
763
+ * `.agents/` is stale). No npm-update, no migrations, no doctor — only the two
764
+ * sync phases re-materialize the payload from the already-installed binary.
765
+ *
766
+ * @returns {Array<{ kind: 'spawn', phase: string, args: string[], label: string, failMessage: string }>}
767
+ */
768
+ function driftHealSteps() {
769
+ return [
770
+ {
771
+ kind: 'spawn',
772
+ phase: 'sync',
773
+ args: [],
774
+ label: 'runSync',
775
+ failMessage:
776
+ 'mandrel update: `mandrel sync` from installed binary exited non-zero — ' +
777
+ 'the .agents/ materialization may be incomplete. ' +
778
+ 'Run `mandrel sync` manually to restore.',
779
+ },
780
+ {
781
+ kind: 'spawn',
782
+ phase: 'sync-commands',
783
+ args: [],
784
+ label: 'sync-commands',
785
+ failMessage:
786
+ 'mandrel update: `mandrel sync-commands` from installed binary exited non-zero — ' +
787
+ 'the .claude/commands/ tree may be out of sync. ' +
788
+ 'Run `npm run sync:commands` manually to restore.',
789
+ },
790
+ ];
791
+ }
792
+
793
+ /**
794
+ * Pure decision function for `mandrel update`: given the resolved version
795
+ * inputs and the two flags, decide which of the four actions to take and the
796
+ * ordered phase plan for that action. **No I/O** — no filesystem, child
797
+ * process, network, `write`, or `exit`. This isolates the scheduler-style
798
+ * branch-selection and step-sequencing logic (the surface under review in
799
+ * Story #4182 / audit::architecture) so it can be exercised as a table over
800
+ * plain inputs rather than by running the whole async orchestration with every
801
+ * seam stubbed.
802
+ *
803
+ * The four actions:
804
+ *
805
+ * - `up-to-date` — version is current and no drift. True no-op; `steps: []`.
806
+ * - `dry-run` — `dryRun` is set. `steps: []` (nothing is executed); the
807
+ * `variant` distinguishes the drift-heal preview from the
808
+ * full-upgrade preview so the executor prints the right plan.
809
+ * - `resynced` — version is current but drift detected. Heal via the two
810
+ * sync phases (`driftHealSteps()`).
811
+ * - `updated` — a newer version is available. Full upgrade
812
+ * (`fullUpgradeSteps(current, target)`).
813
+ *
814
+ * @param {{ current: string, target: string, dryRun: boolean, hasDrift: boolean }} input
815
+ * @returns {{
816
+ * action: 'up-to-date' | 'dry-run' | 'resynced' | 'updated',
817
+ * steps: Array<{ kind: 'npm-update' | 'spawn' | 'doctor', label: string, phase?: string, args?: string[], failMessage?: string }>,
818
+ * variant?: 'drift-heal' | 'full-upgrade',
819
+ * }}
820
+ */
821
+ export function planUpdate({ current, target, dryRun, hasDrift }) {
822
+ const versionCurrent = compareVersions(target, current) <= 0;
823
+
824
+ if (versionCurrent) {
825
+ // Version is already newest. The only remaining question is drift.
826
+ if (!hasDrift) {
827
+ return { action: 'up-to-date', steps: [] };
828
+ }
829
+ if (dryRun) {
830
+ return { action: 'dry-run', steps: [], variant: 'drift-heal' };
831
+ }
832
+ return { action: 'resynced', steps: driftHealSteps() };
833
+ }
834
+
835
+ // A newer version is available — full upgrade (drift is irrelevant here; the
836
+ // post-upgrade doctor phase re-checks materialization).
837
+ if (dryRun) {
838
+ return { action: 'dry-run', steps: [], variant: 'full-upgrade' };
839
+ }
840
+ return { action: 'updated', steps: fullUpgradeSteps(current, target) };
841
+ }
842
+
738
843
  /**
739
844
  * Extract the `--install-cmd "<cmd>"` value from the subcommand argv. Accepts
740
845
  * both the space form (`--install-cmd npm install …`, captured as the single
@@ -761,9 +866,125 @@ function parseInstallCmdFlag(argv) {
761
866
  return undefined;
762
867
  }
763
868
 
869
+ /**
870
+ * Resolve the drift signal for the no-op short-circuit. Prefers the injected
871
+ * `checkDrift` seam (unit-test friendly); falls back to the production
872
+ * `runAgentsDrift` helper. Only consulted when the installed version is already
873
+ * the newest (Story #4065) — a real version bump skips drift entirely (the
874
+ * post-upgrade doctor phase re-checks materialization).
875
+ *
876
+ * @param {(() => boolean | Promise<boolean>) | undefined} checkDrift
877
+ * @returns {Promise<boolean>}
878
+ */
879
+ async function resolveDrift(checkDrift) {
880
+ const driftProbe =
881
+ typeof checkDrift === 'function' ? checkDrift : () => !runAgentsDrift().ok;
882
+ return Boolean(await driftProbe());
883
+ }
884
+
885
+ /**
886
+ * Execute the ordered phase plan returned by `planUpdate` for the `updated` /
887
+ * `resynced` actions. This is the thin side-effecting shell: it owns the
888
+ * `npmUpdate` seam call, the `spawnPhase` re-exec boundary, the per-step
889
+ * `stepsRun` accounting, and the soft doctor-fail (`exit(1)` +
890
+ * `action: 'doctor-failed'`). The branch-selection logic that produced `steps`
891
+ * lives in the pure `planUpdate`.
892
+ *
893
+ * @param {{
894
+ * steps: Array<{ kind: 'npm-update' | 'spawn' | 'doctor', label: string, phase?: string, args?: string[], failMessage?: string }>,
895
+ * target: string,
896
+ * installCmd: string | undefined,
897
+ * npmUpdate: ((version: string, opts: { installCmd?: string }) => unknown | Promise<unknown>) | undefined,
898
+ * spawnPhase: ((phase: string, args: string[], opts: object) => { ok: boolean } | Promise<{ ok: boolean }>) | undefined,
899
+ * surfaceChangelog: ((version: string) => unknown | Promise<unknown>) | undefined,
900
+ * binPath: string,
901
+ * projectRoot: string,
902
+ * write: (s: string) => void,
903
+ * writeErr: (s: string) => void,
904
+ * exit: (code: number) => void,
905
+ * }} ctx
906
+ * @returns {Promise<{ stepsRun: string[], doctorOk: boolean }>}
907
+ */
908
+ async function executePlan({
909
+ steps,
910
+ target,
911
+ installCmd,
912
+ npmUpdate,
913
+ spawnPhase,
914
+ surfaceChangelog,
915
+ binPath,
916
+ projectRoot,
917
+ write,
918
+ writeErr,
919
+ exit,
920
+ }) {
921
+ const stepsRun = [];
922
+ let doctorOk = true;
923
+
924
+ for (const step of steps) {
925
+ if (step.kind === 'npm-update') {
926
+ // Bump the dependency. The lockfile change is left STAGED on disk; this
927
+ // module never commits.
928
+ if (typeof npmUpdate !== 'function') {
929
+ throw new Error(
930
+ 'mandrel update: npmUpdate seam is required to bump the dependency',
931
+ );
932
+ }
933
+ // eslint-disable-next-line no-await-in-loop
934
+ await npmUpdate(target, { installCmd });
935
+ stepsRun.push(step.label);
936
+ continue;
937
+ }
938
+
939
+ // Both 'spawn' and 'doctor' kinds run a post-install phase from the
940
+ // newly-installed binary (the Story #4034 re-exec boundary), so the new
941
+ // package's module code — not the old loaded module — executes.
942
+ // eslint-disable-next-line no-await-in-loop
943
+ const result = await spawnPhase(step.phase, step.args, {
944
+ binPath,
945
+ cwd: projectRoot,
946
+ write,
947
+ writeErr,
948
+ });
949
+ stepsRun.push(step.label);
950
+
951
+ if (step.kind === 'doctor') {
952
+ // Doctor failure is SOFT: record it, keep going to surface the changelog,
953
+ // then map to exit(1) + doctor-failed by the caller.
954
+ doctorOk = result.ok;
955
+ } else if (!result.ok) {
956
+ // sync / sync-commands / migrate failures are FATAL.
957
+ throw new Error(step.failMessage);
958
+ }
959
+ }
960
+
961
+ // Surface the target changelog (best-effort; optional seam). Runs even when
962
+ // doctor failed, so the operator still sees the changelog for the version
963
+ // that landed on disk.
964
+ if (typeof surfaceChangelog === 'function') {
965
+ await surfaceChangelog(target);
966
+ }
967
+
968
+ if (!doctorOk) {
969
+ writeErr(
970
+ `mandrel update: upgraded to v${target} but doctor reported failures.\n` +
971
+ ' → Run `mandrel doctor` for remedies.\n',
972
+ );
973
+ exit(1);
974
+ }
975
+
976
+ return { stepsRun, doctorOk };
977
+ }
978
+
764
979
  /**
765
980
  * Run the `mandrel update` orchestration cycle.
766
981
  *
982
+ * The cycle is split into a pure decision (`planUpdate`) and a side-effecting
983
+ * shell (this function + `executePlan`). `runUpdate` resolves the inputs
984
+ * (current / target / drift) through the injectable seams, calls `planUpdate`
985
+ * to select the action and its ordered phase plan, then drives the plan through
986
+ * the `spawnPhase` / `write` / `exit` shell.
987
+ *
767
988
  * @param {{
768
989
  * argv?: string[],
769
990
  * currentVersion?: string | (() => string),
@@ -771,9 +992,6 @@ function parseInstallCmdFlag(argv) {
771
992
  * npmUpdate?: (version: string, opts: { installCmd?: string }) => unknown | Promise<unknown>,
772
993
  * checkDrift?: () => (boolean | Promise<boolean>),
773
994
  * 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 },
774
- * runSync?: typeof defaultRunSync,
775
- * runMigrations?: typeof defaultRunMigrations,
776
- * runDoctor?: typeof defaultRunDoctor,
777
995
  * surfaceChangelog?: (version: string) => unknown | Promise<unknown>,
778
996
  * write?: (s: string) => void,
779
997
  * writeErr?: (s: string) => void,
@@ -796,9 +1014,6 @@ export async function runUpdate({
796
1014
  npmUpdate,
797
1015
  checkDrift,
798
1016
  spawnPhase,
799
- runSync = defaultRunSync,
800
- runMigrations = defaultRunMigrations,
801
- runDoctor = defaultRunDoctor,
802
1017
  surfaceChangelog,
803
1018
  write = (s) => process.stdout.write(s),
804
1019
  writeErr = (s) => process.stderr.write(s),
@@ -820,37 +1035,32 @@ export async function runUpdate({
820
1035
  }
821
1036
  const target = String(await resolveTargetVersion());
822
1037
 
823
- // --- No-op short-circuit --------------------------------------------------
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).
829
- if (compareVersions(target, current) <= 0) {
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();
1038
+ // Drift only matters when the installed version is already the newest. Probe
1039
+ // it solely on that branch so a real version bump never calls the drift seam
1040
+ // (Story #4065).
1041
+ const hasDrift =
1042
+ compareVersions(target, current) <= 0
1043
+ ? await resolveDrift(checkDrift)
1044
+ : false;
837
1045
 
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
- }
1046
+ const plan = planUpdate({ current, target, dryRun, hasDrift });
849
1047
 
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) {
1048
+ // --- up-to-date: true no-op ----------------------------------------------
1049
+ if (plan.action === 'up-to-date') {
1050
+ write(`✅ Already up to date (v${current} is the newest version).\n`);
1051
+ return {
1052
+ ok: true,
1053
+ action: 'up-to-date',
1054
+ currentVersion: current,
1055
+ targetVersion: target,
1056
+ stepsRun: [],
1057
+ dryRun,
1058
+ };
1059
+ }
1060
+
1061
+ // --- dry-run: print the plan, execute nothing -----------------------------
1062
+ if (plan.action === 'dry-run') {
1063
+ if (plan.variant === 'drift-heal') {
854
1064
  write(
855
1065
  `mandrel update — drift detected, sync heal planned (v${current} is already current)\n`,
856
1066
  );
@@ -861,62 +1071,50 @@ export async function runUpdate({
861
1071
  ' 2. sync-commands — regenerate .claude/commands/ from .agents/workflows/\n',
862
1072
  );
863
1073
  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
- };
1074
+ } else {
1075
+ write(`mandrel update — planned upgrade v${current} → v${target}\n`);
1076
+ STEP_PLAN.forEach((step, i) => {
1077
+ write(` ${i + 1}. ${step}\n`);
1078
+ });
1079
+ write('Dry run: no files written, no dependency bumped.\n');
872
1080
  }
1081
+ return {
1082
+ ok: true,
1083
+ action: 'dry-run',
1084
+ currentVersion: current,
1085
+ targetVersion: target,
1086
+ stepsRun: [],
1087
+ dryRun: true,
1088
+ };
1089
+ }
1090
+
1091
+ // --- resynced / updated: execute the phase plan ---------------------------
1092
+ const projectRoot = cwd();
1093
+ const binPath = resolveNewBinPath(projectRoot);
873
1094
 
1095
+ if (plan.action === 'resynced') {
874
1096
  write(
875
1097
  `Healing .agents/ drift (v${current} is already current, but .agents/ is stale)…\n`,
876
1098
  );
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');
1099
+ } else {
1100
+ write(`Updating v${current} → v${target}…\n`);
1101
+ }
899
1102
 
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
- }
1103
+ const { stepsRun, doctorOk } = await executePlan({
1104
+ steps: plan.steps,
1105
+ target,
1106
+ installCmd,
1107
+ npmUpdate,
1108
+ spawnPhase,
1109
+ surfaceChangelog,
1110
+ binPath,
1111
+ projectRoot,
1112
+ write,
1113
+ writeErr,
1114
+ exit,
1115
+ });
919
1116
 
1117
+ if (plan.action === 'resynced') {
920
1118
  write(
921
1119
  `✅ Healed .agents/ drift (v${current}). The materialized payload is now current.\n`,
922
1120
  );
@@ -930,194 +1128,18 @@ export async function runUpdate({
930
1128
  };
931
1129
  }
932
1130
 
933
- // --- Dry run --------------------------------------------------------------
934
- // Print the resolved target and the ordered step plan; invoke no seam and
935
- // write nothing to disk.
936
- if (dryRun) {
937
- write(`mandrel update — planned upgrade v${current} → v${target}\n`);
938
- STEP_PLAN.forEach((step, i) => {
939
- write(` ${i + 1}. ${step}\n`);
940
- });
941
- write('Dry run: no files written, no dependency bumped.\n');
1131
+ // plan.action === 'updated'
1132
+ if (!doctorOk) {
942
1133
  return {
943
- ok: true,
944
- action: 'dry-run',
1134
+ ok: false,
1135
+ action: 'doctor-failed',
945
1136
  currentVersion: current,
946
1137
  targetVersion: target,
947
- stepsRun: [],
948
- dryRun: true,
1138
+ stepsRun,
1139
+ dryRun: false,
949
1140
  };
950
1141
  }
951
1142
 
952
- write(`Updating v${current} → v${target}…\n`);
953
-
954
- const stepsRun = [];
955
-
956
- // 1. npm update — bump the dependency. The lockfile change is left STAGED
957
- // on disk; this module never commits.
958
- if (typeof npmUpdate !== 'function') {
959
- throw new Error(
960
- 'mandrel update: npmUpdate seam is required to bump the dependency',
961
- );
962
- }
963
- await npmUpdate(target, { installCmd });
964
- stepsRun.push('npm-update');
965
-
966
- // Decide whether to use the re-exec path (spawnPhase) or the in-process
967
- // backward-compat seams (runSync / runMigrations / runDoctor).
968
- //
969
- // spawnPhase injected → re-exec path: all post-install phases run from the
970
- // newly-installed binary. This is the production path and is what fixes
971
- // the stale-materialization bug (Story #4034).
972
- //
973
- // spawnPhase NOT injected → in-process path: the original pre-Story-#4034
974
- // behaviour. Tests that pre-date this change inject runSync/runMigrations/
975
- // runDoctor and rely on the in-process path; they stay green without any
976
- // modification.
977
- const useReExec = typeof spawnPhase === 'function';
978
-
979
- if (useReExec) {
980
- // Re-exec path: post-install phases run from the new binary.
981
- const projectRoot = cwd();
982
- const binPath = resolveNewBinPath(projectRoot);
983
-
984
- // 2. runSync from new bin — re-materialize ./.agents/ from the freshly
985
- // installed payload. Running from the new bin ensures the copied files
986
- // come from the new package's .agents/ tree, not the old loaded module.
987
- const syncResult = await spawnPhase('sync', [], {
988
- binPath,
989
- cwd: projectRoot,
990
- write,
991
- writeErr,
992
- });
993
- if (!syncResult.ok) {
994
- throw new Error(
995
- `mandrel update: \`mandrel sync\` from new binary exited non-zero — ` +
996
- 'the .agents/ materialization may be incomplete. ' +
997
- 'Run `mandrel sync` manually to restore.',
998
- );
999
- }
1000
- stepsRun.push('runSync');
1001
-
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
1025
- // crossed range. The new binary's migration registry contains any steps
1026
- // added in the target version; the old process's registry does not.
1027
- const migrateResult = await spawnPhase(
1028
- 'migrate',
1029
- ['--from', current, '--to', target],
1030
- { binPath, cwd: projectRoot, write, writeErr },
1031
- );
1032
- if (!migrateResult.ok) {
1033
- throw new Error(
1034
- `mandrel update: \`mandrel migrate\` from new binary exited non-zero — ` +
1035
- `some migrations for v${current} → v${target} may not have applied. ` +
1036
- `Run \`mandrel migrate --from ${current} --to ${target}\` manually to retry.`,
1037
- );
1038
- }
1039
- stepsRun.push('runMigrations');
1040
-
1041
- // 5. doctor from new bin — verify the resulting install. Running from the
1042
- // new bin is critical: the agents-drift check compares the materialized
1043
- // .agents/ against the installed package payload. When the old process
1044
- // runs this check, it resolves the package root to its own (old) install
1045
- // dir, so drift against the new payload is invisible. The new binary
1046
- // resolves the package root to the now-installed new version, producing
1047
- // an accurate result.
1048
- const doctorResult = await spawnPhase('doctor', [], {
1049
- binPath,
1050
- cwd: projectRoot,
1051
- write,
1052
- writeErr,
1053
- });
1054
- stepsRun.push('doctor');
1055
-
1056
- // 6. surface the target changelog (best-effort; optional seam).
1057
- if (typeof surfaceChangelog === 'function') {
1058
- await surfaceChangelog(target);
1059
- }
1060
-
1061
- if (!doctorResult.ok) {
1062
- writeErr(
1063
- `mandrel update: upgraded to v${target} but doctor reported failures.\n` +
1064
- ' → Run `mandrel doctor` for remedies.\n',
1065
- );
1066
- exit(1);
1067
- return {
1068
- ok: false,
1069
- action: 'doctor-failed',
1070
- currentVersion: current,
1071
- targetVersion: target,
1072
- stepsRun,
1073
- dryRun: false,
1074
- };
1075
- }
1076
- } else {
1077
- // In-process backward-compat path (pre-Story-#4034 behaviour).
1078
- // Used when no `spawnPhase` seam is injected — preserves full backward
1079
- // compatibility with existing tests that inject runSync/runMigrations/
1080
- // runDoctor directly.
1081
-
1082
- // 2. runSync — re-materialize ./.agents/ from the new payload.
1083
- runSync({ argv: [] });
1084
- stepsRun.push('runSync');
1085
-
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.
1090
- runMigrations({ fromVersion: current, toVersion: target, ctx: {} });
1091
- stepsRun.push('runMigrations');
1092
-
1093
- // 4. doctor — verify the resulting install.
1094
- const doctor = await runDoctor();
1095
- stepsRun.push('doctor');
1096
-
1097
- // 5. surface the target changelog (best-effort; optional seam).
1098
- if (typeof surfaceChangelog === 'function') {
1099
- await surfaceChangelog(target);
1100
- }
1101
-
1102
- if (!doctor.ok) {
1103
- const failed = doctor.results.filter((r) => !r.ok).map((r) => r.name);
1104
- writeErr(
1105
- `mandrel update: upgraded to v${target} but doctor reported failures: ` +
1106
- `${failed.join(', ')}\n` +
1107
- ' → Run `mandrel doctor` for remedies.\n',
1108
- );
1109
- exit(1);
1110
- return {
1111
- ok: false,
1112
- action: 'doctor-failed',
1113
- currentVersion: current,
1114
- targetVersion: target,
1115
- stepsRun,
1116
- dryRun: false,
1117
- };
1118
- }
1119
- }
1120
-
1121
1143
  write(`✅ Updated to v${target}. The lockfile bump is staged for review.\n`);
1122
1144
  return {
1123
1145
  ok: true,
@@ -1180,9 +1202,6 @@ export async function runUpdate({
1180
1202
  * changelogPath?: string,
1181
1203
  * fetchChangelog?: (version: string) => Promise<string>,
1182
1204
  * runUpdate?: typeof runUpdate,
1183
- * runSync?: typeof defaultRunSync,
1184
- * runMigrations?: typeof defaultRunMigrations,
1185
- * runDoctor?: typeof defaultRunDoctor,
1186
1205
  * write?: (s: string) => void,
1187
1206
  * writeErr?: (s: string) => void,
1188
1207
  * exit?: (code: number) => void,
@@ -1201,9 +1220,6 @@ export default async function run(argv = [], deps = {}) {
1201
1220
  changelogPath,
1202
1221
  fetchChangelog,
1203
1222
  runUpdate: runUpdateImpl = runUpdate,
1204
- runSync,
1205
- runMigrations,
1206
- runDoctor,
1207
1223
  write = (s) => process.stdout.write(s),
1208
1224
  writeErr = (s) => process.stderr.write(s),
1209
1225
  exit = (code) => process.exit(code),
@@ -1212,30 +1228,17 @@ export default async function run(argv = [], deps = {}) {
1212
1228
 
1213
1229
  const current = deps.currentVersion ?? defaultCurrentVersion(fs);
1214
1230
 
1215
- // The production spawnPhase default: spawn each post-install phase from
1216
- // node_modules/.bin/mandrel (the newly-installed binary). spawnFn is
1217
- // injectable so tests can stub the spawn boundary without running a real
1218
- // child process.
1231
+ // The production spawnPhase: spawn each post-install phase from
1232
+ // node_modules/.bin/mandrel (the newly-installed binary). This is the sole
1233
+ // post-install execution path (No-Shim Story #4182 retired the in-process
1234
+ // runSync/runMigrations/runDoctor seam set). spawnFn is injectable so tests
1235
+ // can stub the spawn boundary without running a real child process.
1219
1236
  const productionSpawnPhase = (phase, args, opts) =>
1220
1237
  defaultSpawnPhase(phase, args, {
1221
1238
  ...opts,
1222
1239
  ...(spawnFn ? { spawnFn } : {}),
1223
1240
  });
1224
1241
 
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
-
1239
1242
  await runUpdateImpl({
1240
1243
  argv,
1241
1244
  currentVersion: current,
@@ -1257,7 +1260,7 @@ export default async function run(argv = [], deps = {}) {
1257
1260
  runInstall: runInstall ?? runInstallCommand,
1258
1261
  fs,
1259
1262
  }),
1260
- ...phaseSeams,
1263
+ spawnPhase: productionSpawnPhase,
1261
1264
  surfaceChangelog: (target) =>
1262
1265
  defaultSurfaceChangelog(target, {
1263
1266
  current,