mandrel 1.59.0 → 1.61.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 (267) hide show
  1. package/.agents/README.md +86 -44
  2. package/.agents/docs/SDLC.md +135 -141
  3. package/.agents/docs/configuration.md +77 -20
  4. package/.agents/docs/quality-gates.md +796 -0
  5. package/.agents/docs/workflows.md +6 -9
  6. package/.agents/instructions.md +12 -11
  7. package/.agents/personas/architect.md +1 -1
  8. package/.agents/personas/product.md +1 -1
  9. package/.agents/personas/project-manager.md +14 -14
  10. package/.agents/personas/technical-writer.md +1 -1
  11. package/.agents/rules/changelog-style.md +5 -5
  12. package/.agents/rules/git-conventions.md +3 -3
  13. package/.agents/runtime-deps.json +2 -2
  14. package/.agents/schemas/agentrc.schema.json +3 -3
  15. package/.agents/schemas/dispatch-manifest.json +4 -4
  16. package/.agents/schemas/epic-spec.schema.json +15 -45
  17. package/.agents/schemas/lifecycle/README.md +1 -1
  18. package/.agents/schemas/lifecycle/story.dispatch.end.schema.json +1 -1
  19. package/.agents/schemas/lifecycle/story.dispatch.start.schema.json +1 -1
  20. package/.agents/schemas/lifecycle/story.heartbeat.schema.json +1 -1
  21. package/.agents/schemas/validation-evidence.schema.json +1 -1
  22. package/.agents/scripts/README.md +2 -2
  23. package/.agents/scripts/acceptance-eval.js +1 -1
  24. package/.agents/scripts/acceptance-spec-reconciler.js +2 -2
  25. package/.agents/scripts/agents-bootstrap-github.js +23 -119
  26. package/.agents/scripts/analyze-execution.js +2 -2
  27. package/.agents/scripts/audit-to-stories.js +1 -1
  28. package/.agents/scripts/check-doc-links.js +2 -3
  29. package/.agents/scripts/diagnose-friction.js +1 -1
  30. package/.agents/scripts/dispatcher.js +2 -2
  31. package/.agents/scripts/drain-pending-cleanup.js +1 -1
  32. package/.agents/scripts/epic-audit-prepare.js +3 -3
  33. package/.agents/scripts/epic-deliver-note-intervention.js +2 -2
  34. package/.agents/scripts/epic-deliver-preflight.js +6 -6
  35. package/.agents/scripts/epic-deliver-prepare.js +1 -1
  36. package/.agents/scripts/epic-execute-record-wave.js +4 -4
  37. package/.agents/scripts/epic-plan-healthcheck.js +6 -10
  38. package/.agents/scripts/epic-plan-spec-validate.js +1 -1
  39. package/.agents/scripts/epic-reconcile.js +11 -29
  40. package/.agents/scripts/evidence-gate.js +1 -1
  41. package/.agents/scripts/generate-workflows-doc.js +1 -1
  42. package/.agents/scripts/hierarchy-gate.js +7 -11
  43. package/.agents/scripts/lib/ITicketingProvider.js +1 -1
  44. package/.agents/scripts/lib/audit-suite/selector.js +1 -1
  45. package/.agents/scripts/lib/audit-to-stories/seed-epic-from-findings.js +2 -2
  46. package/.agents/scripts/lib/baseline-snapshot.js +7 -7
  47. package/.agents/scripts/lib/bdd-runner-detect.js +1 -1
  48. package/.agents/scripts/lib/bdd-scenario-scanner.js +3 -3
  49. package/.agents/scripts/lib/bootstrap/baselines-layout-migration.js +1 -1
  50. package/.agents/scripts/lib/bootstrap/branch-protection.js +1 -1
  51. package/.agents/scripts/lib/bootstrap/ci-workflow-template.js +47 -1
  52. package/.agents/scripts/lib/bootstrap/commit-push.js +2 -2
  53. package/.agents/scripts/lib/bootstrap/gh-preflight.js +7 -9
  54. package/.agents/scripts/lib/bootstrap/manifest.js +21 -1
  55. package/.agents/scripts/lib/bootstrap/merge-methods.js +31 -16
  56. package/.agents/scripts/lib/bootstrap/project-bootstrap.js +32 -11
  57. package/.agents/scripts/lib/codebase-snapshot.js +1 -1
  58. package/.agents/scripts/lib/config/explain.js +1 -1
  59. package/.agents/scripts/lib/config/runners.js +2 -2
  60. package/.agents/scripts/lib/config/runtime.js +1 -1
  61. package/.agents/scripts/lib/config/sync-agentrc.js +1 -1
  62. package/.agents/scripts/lib/config/temp-paths.js +2 -2
  63. package/.agents/scripts/lib/config-settings-schema-delivery.js +2 -2
  64. package/.agents/scripts/lib/config-settings-schema-quality.js +1 -1
  65. package/.agents/scripts/lib/config-settings-schema.js +3 -3
  66. package/.agents/scripts/lib/detect-package-manager.js +72 -0
  67. package/.agents/scripts/lib/duplicate-search.js +1 -1
  68. package/.agents/scripts/lib/dynamic-workflow/capability.js +1 -1
  69. package/.agents/scripts/lib/epic-plan-clarity.js +1 -1
  70. package/.agents/scripts/lib/epic-plan-ideation.js +1 -1
  71. package/.agents/scripts/lib/errors/index.js +4 -4
  72. package/.agents/scripts/lib/feedback-loop/memory-freshness.js +1 -1
  73. package/.agents/scripts/lib/feedback-loop/prior-feedback-fetcher.js +1 -1
  74. package/.agents/scripts/lib/findings/classify-finding.js +1 -1
  75. package/.agents/scripts/lib/findings/promote-finding.js +10 -10
  76. package/.agents/scripts/lib/label-constants.js +3 -4
  77. package/.agents/scripts/lib/label-taxonomy.js +5 -10
  78. package/.agents/scripts/lib/onboard/detect-stack.js +10 -10
  79. package/.agents/scripts/lib/onboard/init-tail.js +218 -0
  80. package/.agents/scripts/lib/onboard/scaffold-docs.js +18 -3
  81. package/.agents/scripts/lib/orchestration/acceptance-eval-decision.js +1 -1
  82. package/.agents/scripts/lib/orchestration/code-review.js +5 -5
  83. package/.agents/scripts/lib/orchestration/context-hydration-engine.js +8 -9
  84. package/.agents/scripts/lib/orchestration/dependency-analyzer.js +3 -3
  85. package/.agents/scripts/lib/orchestration/detectors-phase.js +2 -2
  86. package/.agents/scripts/lib/orchestration/dispatch-engine.js +30 -38
  87. package/.agents/scripts/lib/orchestration/dispatch-pipeline.js +9 -25
  88. package/.agents/scripts/lib/orchestration/epic-cleanup.js +1 -1
  89. package/.agents/scripts/lib/orchestration/epic-deliver-lease-guard.js +8 -8
  90. package/.agents/scripts/lib/orchestration/epic-plan-decompose/phases/creation.js +1 -1
  91. package/.agents/scripts/lib/orchestration/epic-plan-decompose/phases/dag.js +7 -21
  92. package/.agents/scripts/lib/orchestration/epic-plan-decompose/phases/diagnostics.js +3 -3
  93. package/.agents/scripts/lib/orchestration/epic-plan-lease-guard.js +26 -13
  94. package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/plan-epic.js +1 -1
  95. package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/prompts.js +1 -1
  96. package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/run-spec-phase.js +2 -2
  97. package/.agents/scripts/lib/orchestration/epic-plan-state-store.js +1 -1
  98. package/.agents/scripts/lib/orchestration/epic-run-state-store.js +3 -3
  99. package/.agents/scripts/lib/orchestration/epic-runner/concurrency-gate.js +4 -4
  100. package/.agents/scripts/lib/orchestration/epic-runner/deliver-phases.js +3 -3
  101. package/.agents/scripts/lib/orchestration/epic-runner/phases/build-wave-dag.js +6 -21
  102. package/.agents/scripts/lib/orchestration/epic-runner/phases/snapshot.js +7 -7
  103. package/.agents/scripts/lib/orchestration/epic-runner/progress-reporter/composition.js +1 -1
  104. package/.agents/scripts/lib/orchestration/epic-runner/progress-reporter/signals.js +2 -2
  105. package/.agents/scripts/lib/orchestration/epic-runner/progress-reporter/transport.js +4 -4
  106. package/.agents/scripts/lib/orchestration/epic-runner/story-launcher.js +4 -4
  107. package/.agents/scripts/lib/orchestration/epic-runner/story-run-progress-writer.js +8 -8
  108. package/.agents/scripts/lib/orchestration/epic-runner/sub-agent-return.js +4 -4
  109. package/.agents/scripts/lib/orchestration/epic-spec-reconciler-apply.js +7 -15
  110. package/.agents/scripts/lib/orchestration/epic-spec-reconciler-diff.js +72 -41
  111. package/.agents/scripts/lib/orchestration/epic-spec-reconciler-ops.js +2 -4
  112. package/.agents/scripts/lib/orchestration/file-assumptions.js +2 -2
  113. package/.agents/scripts/lib/orchestration/finalize/close-planning-tickets.js +1 -1
  114. package/.agents/scripts/lib/orchestration/finalize/open-or-locate-pr.js +2 -2
  115. package/.agents/scripts/lib/orchestration/finalize/sanitize-skip-ci.js +1 -1
  116. package/.agents/scripts/lib/orchestration/lease-guard-shared.js +3 -3
  117. package/.agents/scripts/lib/orchestration/lifecycle/emit-story-dispatch-end.js +1 -1
  118. package/.agents/scripts/lib/orchestration/lifecycle/emit-story-heartbeat.js +1 -1
  119. package/.agents/scripts/lib/orchestration/lifecycle/listeners/README.md +1 -1
  120. package/.agents/scripts/lib/orchestration/lifecycle/listeners/automerge-armer.js +1 -1
  121. package/.agents/scripts/lib/orchestration/lifecycle/listeners/automerge-predicate.js +1 -1
  122. package/.agents/scripts/lib/orchestration/lifecycle/listeners/branch-cleaner.js +1 -1
  123. package/.agents/scripts/lib/orchestration/lifecycle/listeners/finalizer.js +1 -1
  124. package/.agents/scripts/lib/orchestration/lifecycle/listeners/index.js +1 -1
  125. package/.agents/scripts/lib/orchestration/lifecycle/listeners/merge-watcher.js +1 -1
  126. package/.agents/scripts/lib/orchestration/lifecycle/listeners/notify-dispatcher.js +1 -1
  127. package/.agents/scripts/lib/orchestration/lifecycle/listeners/watcher.js +1 -1
  128. package/.agents/scripts/lib/orchestration/manifest-builder.js +5 -5
  129. package/.agents/scripts/lib/orchestration/parked-follow-ons.js +2 -2
  130. package/.agents/scripts/lib/orchestration/plan-runner/plan-router.js +5 -5
  131. package/.agents/scripts/lib/orchestration/post-merge/phases/ticket-closure.js +3 -3
  132. package/.agents/scripts/lib/orchestration/preflight-cache.js +1 -1
  133. package/.agents/scripts/lib/orchestration/recurring-failure-detector.js +1 -1
  134. package/.agents/scripts/lib/orchestration/retro/phases/compose-body.js +1 -1
  135. package/.agents/scripts/lib/orchestration/retro/phases/gather-signals.js +2 -2
  136. package/.agents/scripts/lib/orchestration/retro-runner.js +3 -3
  137. package/.agents/scripts/lib/orchestration/review-depth.js +1 -1
  138. package/.agents/scripts/lib/orchestration/single-story-close/phases/wrong-tree-guard.js +1 -1
  139. package/.agents/scripts/lib/orchestration/spec-freshness.js +1 -1
  140. package/.agents/scripts/lib/orchestration/spec-renderer.js +36 -73
  141. package/.agents/scripts/lib/orchestration/spec-section-validator.js +1 -1
  142. package/.agents/scripts/lib/orchestration/story-close/baseline-friction-body.js +1 -1
  143. package/.agents/scripts/lib/orchestration/story-close/phases/locked-pipeline.js +2 -2
  144. package/.agents/scripts/lib/orchestration/task-body-validator.js +6 -6
  145. package/.agents/scripts/lib/orchestration/ticket-lease.js +1 -1
  146. package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +2 -2
  147. package/.agents/scripts/lib/orchestration/ticket-validator-sizing.js +1 -10
  148. package/.agents/scripts/lib/orchestration/ticket-validator.js +25 -70
  149. package/.agents/scripts/lib/orchestration/ticketing/bulk.js +5 -12
  150. package/.agents/scripts/lib/orchestration/ticketing/reads.js +8 -8
  151. package/.agents/scripts/lib/orchestration/ticketing/state.js +3 -3
  152. package/.agents/scripts/lib/orchestration/wave-record-notifications.js +2 -2
  153. package/.agents/scripts/lib/orchestration/wave-record-projection.js +1 -1
  154. package/.agents/scripts/lib/plan-phase-cleanup.js +1 -1
  155. package/.agents/scripts/lib/preflight-runner.js +1 -1
  156. package/.agents/scripts/lib/presentation/dispatch-manifest-render.js +4 -5
  157. package/.agents/scripts/lib/presentation/manifest-builder.js +28 -34
  158. package/.agents/scripts/lib/presentation/manifest-formatter.js +3 -4
  159. package/.agents/scripts/lib/presentation/manifest-helpers.js +1 -1
  160. package/.agents/scripts/lib/presentation/manifest-procedures.js +4 -4
  161. package/.agents/scripts/lib/presentation/manifest-render-waves.js +4 -23
  162. package/.agents/scripts/lib/presentation/manifest-renderer.js +1 -1
  163. package/.agents/scripts/lib/presentation/manifest-story-views.js +2 -11
  164. package/.agents/scripts/lib/runtime-deps/preflight.js +6 -6
  165. package/.agents/scripts/lib/signals/schema.js +1 -1
  166. package/.agents/scripts/lib/spec/index.js +1 -1
  167. package/.agents/scripts/lib/spec/loader.js +2 -2
  168. package/.agents/scripts/lib/spec/state.js +7 -16
  169. package/.agents/scripts/lib/story-init/context-resolver.js +3 -3
  170. package/.agents/scripts/lib/story-init/state-transitioner.js +2 -2
  171. package/.agents/scripts/lib/story-init/task-graph-builder.js +7 -7
  172. package/.agents/scripts/lib/story-lifecycle.js +8 -8
  173. package/.agents/scripts/lib/story-plan.js +1 -1
  174. package/.agents/scripts/lib/templates/decomposer-prompts.js +59 -52
  175. package/.agents/scripts/lib/wave-runner/tick.js +1 -1
  176. package/.agents/scripts/lib/worktree/node-modules-strategy.js +5 -2
  177. package/.agents/scripts/lifecycle-emit-story-dispatch.js +1 -1
  178. package/.agents/scripts/lifecycle-emit.js +1 -1
  179. package/.agents/scripts/providers/github/board-add.js +1 -1
  180. package/.agents/scripts/providers/github/errors.js +1 -1
  181. package/.agents/scripts/providers/github/mappers.js +2 -2
  182. package/.agents/scripts/providers/github/tickets.js +4 -4
  183. package/.agents/scripts/resync-status-column.js +1 -1
  184. package/.agents/scripts/retro-run.js +2 -2
  185. package/.agents/scripts/run-lint.js +1 -1
  186. package/.agents/scripts/single-story-init.js +1 -1
  187. package/.agents/scripts/stories-wave-tick.js +5 -5
  188. package/.agents/scripts/story-close.js +1 -1
  189. package/.agents/scripts/story-init.js +13 -16
  190. package/.agents/scripts/story-phase.js +5 -5
  191. package/.agents/scripts/story-plan.js +3 -3
  192. package/.agents/scripts/sync-branch-from-base.js +1 -1
  193. package/.agents/scripts/validate-docs-freshness.js +1 -1
  194. package/.agents/scripts/wave-tick.js +1 -1
  195. package/.agents/skills/core/analyze-execution/SKILL.md +2 -2
  196. package/.agents/skills/core/epic-plan-consolidate/SKILL.md +21 -26
  197. package/.agents/skills/core/epic-plan-decompose-author/SKILL.md +23 -56
  198. package/.agents/skills/core/epic-plan-spec-author/SKILL.md +4 -4
  199. package/.agents/skills/core/hydrate-context/SKILL.md +2 -2
  200. package/.agents/skills/core/idea-refinement/SKILL.md +4 -4
  201. package/.agents/skills/core/knowledge-transfer/SKILL.md +2 -2
  202. package/.agents/skills/core/planning-and-task-breakdown/SKILL.md +1 -1
  203. package/.agents/skills/core/scope-triage/SKILL.md +9 -10
  204. package/.agents/skills/core/using-agent-skills/SKILL.md +1 -1
  205. package/.agents/skills/skills.index.json +7 -7
  206. package/.agents/templates/agent-protocol.md +2 -2
  207. package/.agents/workflows/agents-update.md +16 -31
  208. package/.agents/workflows/audit-architecture.md +2 -2
  209. package/.agents/workflows/audit-clean-code.md +2 -2
  210. package/.agents/workflows/audit-dependencies.md +1 -1
  211. package/.agents/workflows/audit-devops.md +1 -1
  212. package/.agents/workflows/audit-documentation.md +2 -2
  213. package/.agents/workflows/audit-lighthouse.md +1 -1
  214. package/.agents/workflows/audit-performance.md +2 -2
  215. package/.agents/workflows/audit-privacy.md +1 -1
  216. package/.agents/workflows/audit-quality.md +2 -2
  217. package/.agents/workflows/audit-security.md +2 -2
  218. package/.agents/workflows/audit-seo.md +1 -1
  219. package/.agents/workflows/audit-sre.md +1 -1
  220. package/.agents/workflows/audit-to-stories.md +10 -10
  221. package/.agents/workflows/audit-ux-ui.md +1 -1
  222. package/.agents/workflows/deliver.md +85 -0
  223. package/.agents/workflows/explain.md +3 -3
  224. package/.agents/workflows/git-merge-pr.md +1 -1
  225. package/.agents/workflows/git-pr-all.md +13 -10
  226. package/.agents/workflows/git-push.md +6 -3
  227. package/.agents/workflows/helpers/_merge-conflict-template.md +1 -1
  228. package/.agents/workflows/helpers/acceptance-self-eval.md +1 -1
  229. package/.agents/workflows/helpers/agents-sync-config.md +3 -2
  230. package/.agents/workflows/helpers/code-review.md +5 -5
  231. package/.agents/workflows/{epic-deliver.md → helpers/deliver-epic.md} +43 -43
  232. package/.agents/workflows/{story-deliver.md → helpers/deliver-stories.md} +25 -25
  233. package/.agents/workflows/helpers/diagnose.md +1 -1
  234. package/.agents/workflows/helpers/epic-audit.md +6 -6
  235. package/.agents/workflows/helpers/epic-deliver-story.md +13 -13
  236. package/.agents/workflows/helpers/epic-plan-decompose.md +23 -23
  237. package/.agents/workflows/helpers/epic-plan-spec.md +6 -6
  238. package/.agents/workflows/helpers/epic-testing.md +3 -3
  239. package/.agents/workflows/helpers/parallel-tooling.md +1 -1
  240. package/.agents/workflows/{epic-plan.md → helpers/plan-epic.md} +84 -84
  241. package/.agents/workflows/{story-plan.md → helpers/plan-story.md} +43 -43
  242. package/.agents/workflows/helpers/signals.md +1 -1
  243. package/.agents/workflows/helpers/single-story-deliver.md +11 -11
  244. package/.agents/workflows/helpers/worktree-lifecycle.md +18 -18
  245. package/.agents/workflows/plan.md +131 -0
  246. package/.agents/workflows/qa-explore.md +1 -1
  247. package/.agents/workflows/qa-run-harness.md +1 -1
  248. package/README.md +19 -39
  249. package/bin/mandrel.js +235 -16
  250. package/docs/CHANGELOG.md +1173 -0
  251. package/lib/cli/doctor.js +45 -3
  252. package/lib/cli/init.js +97 -36
  253. package/lib/cli/registry.js +41 -145
  254. package/lib/cli/sync.js +122 -23
  255. package/lib/cli/uninstall.js +42 -7
  256. package/lib/cli/update.js +524 -210
  257. package/lib/cli/version-helpers.js +59 -0
  258. package/package.json +7 -6
  259. package/.agents/scripts/lib/orchestration/reconciler.js +0 -137
  260. package/.agents/workflows/onboard.md +0 -208
  261. package/lib/cli/__tests__/migrate.test.js +0 -268
  262. package/lib/cli/__tests__/sync-local-zone.test.js +0 -247
  263. package/lib/cli/__tests__/sync.test.js +0 -372
  264. package/lib/cli/__tests__/update-major.test.js +0 -217
  265. package/lib/cli/__tests__/update.test.js +0 -696
  266. package/lib/cli/__tests__/version-check.test.js +0 -398
  267. package/lib/migrations/__tests__/index.test.js +0 -216
package/lib/cli/update.js CHANGED
@@ -3,20 +3,17 @@
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. no-op short-circuitalready on the newest version nothing to do
16
+ * 3. install — bump the dependency (lockfile bump left STAGED).
20
17
  * The package manager is auto-detected from the lockfile in the project
21
18
  * root: `pnpm-lock.yaml` ⇒ `pnpm add -D …` (with `-w` at a
22
19
  * `pnpm-workspace.yaml` root), `yarn.lock` ⇒ `yarn add -D …`, otherwise
@@ -25,10 +22,36 @@
25
22
  * the resolved version so an override can still consume the auto-probed
26
23
  * newest. The registry probe in step 1 always stays on `npm view` (a
27
24
  * PM-agnostic registry query).
28
- * 5. runSync — re-materialize ./.agents/ from the new payload
29
- * 6. runMigrations — apply version-keyed steps for the crossed range
30
- * 7. doctor run the check registry; success all checks pass
31
- * 8. surface the changelog for the target version
25
+ * 4. runSync — re-materialize ./.agents/ **from the newly-installed
26
+ * binary** so the materialized payload is always the target version's.
27
+ * 5. runMigrations apply version-keyed steps for the crossed range,
28
+ * **from the newly-installed binary**.
29
+ * 6. doctor — run the check registry **from the newly-installed
30
+ * binary** so `agents-drift` is never a false-green against stale payload.
31
+ * 7. surface the changelog for the target version
32
+ *
33
+ * ## Re-exec of post-install phases (Story #4034)
34
+ *
35
+ * Steps 4–6 execute as **child processes spawned from the newly-installed
36
+ * binary** (`<cwd>/node_modules/.bin/mandrel`) rather than in the running
37
+ * process. Node cannot hot-swap a `require`d module mid-process, so without
38
+ * re-exec, the still-running old binary's `runSync`/`runMigrations`/`runDoctor`
39
+ * code would materialise the old payload even though the package on disk has
40
+ * already been updated. This produced the silent stale-`.agents/`
41
+ * materialization and `doctor` false-green observed in the v1.58.0 → v1.59.0
42
+ * consumer upgrade.
43
+ *
44
+ * The orchestration (progress messages, step tracking, changelog surface, exit
45
+ * code) stays in the parent process; only the version-sensitive phases run from
46
+ * the new bin. The `spawnPhase` seam makes the child-process boundary fully
47
+ * injectable so tests can verify the re-exec path without a real npm install.
48
+ *
49
+ * Backward compatibility: when `runSync`, `runMigrations`, or `runDoctor`
50
+ * are explicitly injected (the historical test-seam pattern) and `spawnPhase`
51
+ * is NOT injected, the in-process seams are used unchanged (old tests stay
52
+ * green). When `spawnPhase` IS injected, it takes priority over the in-process
53
+ * seams for the live phases — so new tests targeting the re-exec boundary can
54
+ * inject `spawnPhase` without touching the old seam interface.
32
55
  *
33
56
  * ## No git mutation
34
57
  *
@@ -43,17 +66,19 @@
43
66
  * without invoking any effectful seam (no npm update, no sync, no migrations,
44
67
  * no doctor) and writing nothing.
45
68
  *
46
- * ## Major gate
69
+ * ## Changelog surface
70
+ *
71
+ * `defaultSurfaceChangelog` prints the `docs/CHANGELOG.md` section(s) for the
72
+ * applied range `(current, target]`. It resolves the file against the target
73
+ * version's install directory (the freshly bumped `node_modules/mandrel/`),
74
+ * where the changelog is now included in the published tarball
75
+ * (`docs/CHANGELOG.md` in the `files` allowlist — Story #4035).
47
76
  *
48
- * The project sits on the **1.x** line under release-please
49
- * `always-bump-minor` ([AGENTS.md § Major-version policy]); a major release is
50
- * a deliberate manual operator decision, so adopting one must be equally
51
- * deliberate. When the newest version's major exceeds the current major:
52
- * - **without `--major`**: print the available version + the
53
- * `docs/upgrade-major.md` runbook pointer, exit non-zero, and invoke
54
- * **no** npm-update / sync / migration / doctor seam.
55
- * - **with `--major`**: apply the major target and print the runbook inline.
56
- * Routine minor/patch bumps within the 1.x line are never gated.
77
+ * When the packaged file is absent (e.g. an older installed version predating
78
+ * Story #4035), the seam attempts a one-shot HTTP GET of the raw file from
79
+ * GitHub via the injectable `fetchChangelog` seam. If that fetch also fails,
80
+ * the seam degrades gracefully never throwing and emits an actionable
81
+ * message directing the operator to the GitHub Releases page.
57
82
  *
58
83
  * ## Injectable seams (used by lib/cli/__tests__/update*.test.js)
59
84
  *
@@ -62,15 +87,27 @@
62
87
  * - `resolveTargetVersion`— async, returns the newest published version
63
88
  * - `npmUpdate` — async, performs the dependency bump (no git);
64
89
  * receives `(target, { installCmd })`
65
- * - `runSync` re-materializes ./.agents/ (lib/cli/sync.js)
66
- * - `runMigrations` — version-keyed migration runner (lib/migrations)
67
- * - `runDoctor` — async, returns { ok, results } from the registry
90
+ * - `spawnPhase` async, spawns a post-install phase from the new
91
+ * binary; receives `(phase, args, { binPath, cwd })`
92
+ * and returns `{ ok, stdout, stderr }`. When
93
+ * injected, it takes priority over `runSync`,
94
+ * `runMigrations`, and `runDoctor` for the live
95
+ * phases. See § Re-exec of post-install phases.
96
+ * - `runSync` — re-materializes ./.agents/ (lib/cli/sync.js).
97
+ * Used when `spawnPhase` is NOT injected (backward
98
+ * compat for tests that pre-date Story #4034).
99
+ * - `runMigrations` — version-keyed migration runner (lib/migrations).
100
+ * Used when `spawnPhase` is NOT injected.
101
+ * - `runDoctor` — async, returns { ok, results } from the registry.
102
+ * Used when `spawnPhase` is NOT injected.
68
103
  * - `surfaceChangelog` — emits the target changelog section
69
104
  * - `write` / `writeErr` — stdout / stderr sinks
70
105
  * - `exit` — process.exit replacement
106
+ * - `cwd` — process.cwd() replacement (used to resolve the
107
+ * new binary path when `spawnPhase` is absent)
71
108
  *
72
109
  * Security (security-baseline § 5 — Data Leakage & Logging): logs only version
73
- * strings, step names, and the runbook path. No tokens, credentials, or env
110
+ * strings and step names. No tokens, credentials, or env
74
111
  * values are read or logged; no shell-string interpolation occurs here (the
75
112
  * npm bump is delegated to the injected `npmUpdate` seam, which owns transport).
76
113
  *
@@ -86,72 +123,46 @@
86
123
  * the install argv is a tokenized list whose only variable segment is a
87
124
  * resolved semver string — see `lib/install-cmd-parser.js` for the shared
88
125
  * tokenize-and-spawn rationale this module reuses (no duplicated workaround).
126
+ *
127
+ * The `spawnPhase` default (Story #4034) similarly uses `shell: true` only on
128
+ * Windows: the new binary resolves from `node_modules/.bin/mandrel` (a fixed,
129
+ * non-operator-supplied path) and the per-phase argv vector is a constant
130
+ * fixed list (e.g. `['sync']`, `['migrate', '--from', v, '--to', v]`,
131
+ * `['doctor']`) with no injection risk regardless of the shell flag.
89
132
  */
90
133
 
91
134
  import { spawnSync } from 'node:child_process';
92
135
  import nodeFs from 'node:fs';
136
+ import nodeHttps from 'node:https';
93
137
  import path from 'node:path';
94
138
  import { fileURLToPath } from 'node:url';
95
139
 
140
+ import { detectPackageManagerWithWorkspace } from '../../.agents/scripts/lib/detect-package-manager.js';
96
141
  import { runInstallCommand } from '../../.agents/scripts/lib/install-cmd-parser.js';
97
142
  import { runMigrations as defaultRunMigrations } from '../migrations/index.js';
98
143
  import { registry } from './registry.js';
99
144
  import { runSync as defaultRunSync } from './sync.js';
100
145
  import { isStale } from './version-check.js';
101
-
102
- /** Path (relative to project root) of the major-upgrade runbook. */
103
- const RUNBOOK_PATH = 'docs/upgrade-major.md';
146
+ import { compareVersions } from './version-helpers.js';
104
147
 
105
148
  /** The published package whose newest version `mandrel update` advances to. */
106
149
  const PACKAGE_NAME = 'mandrel';
107
150
 
108
- /** Default freshness-cache filename — mirrors version-check.js. */
109
- const DEFAULT_CACHE_FILENAME = 'version-check.json';
110
-
111
151
  /**
112
- * Parse a dotted semver-ish string into a numeric tuple. Non-numeric or
113
- * missing segments coerce to 0 so a partial version still compares sanely.
114
- *
115
- * @param {string} version
116
- * @returns {[number, number, number]}
152
+ * GitHub raw-file base URL for fetching `docs/CHANGELOG.md` when the packaged
153
+ * file is absent (Story #4035 GitHub fallback). Resolves to the tagged
154
+ * release, e.g. `.../mandrel-v1.59.0/docs/CHANGELOG.md`.
117
155
  */
118
- function parseVersion(version) {
119
- const [major, minor, patch] = String(version).split('.');
120
- return [
121
- Number.parseInt(major, 10) || 0,
122
- Number.parseInt(minor, 10) || 0,
123
- Number.parseInt(patch, 10) || 0,
124
- ];
125
- }
156
+ const GITHUB_RAW_BASE = 'https://raw.githubusercontent.com/dsj1984/mandrel/';
126
157
 
127
158
  /**
128
- * Compare two version strings. Negative when `a < b`, zero when equal,
129
- * positive when `a > b` (the standard `Array.sort` comparator contract).
130
- *
131
- * @param {string} a
132
- * @param {string} b
133
- * @returns {number}
159
+ * Human-readable GitHub Releases page surfaced in the actionable fallback
160
+ * message when neither the packaged file nor the GitHub fetch succeeds.
134
161
  */
135
- function compareVersions(a, b) {
136
- const pa = parseVersion(a);
137
- const pb = parseVersion(b);
138
- for (let i = 0; i < 3; i += 1) {
139
- if (pa[i] !== pb[i]) return pa[i] - pb[i];
140
- }
141
- return 0;
142
- }
162
+ const GITHUB_RELEASES_URL = 'https://github.com/dsj1984/mandrel/releases';
143
163
 
144
- /**
145
- * True when `target`'s major axis is strictly greater than `current`'s — the
146
- * gated "crosses a major boundary" condition.
147
- *
148
- * @param {string} current
149
- * @param {string} target
150
- * @returns {boolean}
151
- */
152
- function crossesMajor(current, target) {
153
- return parseVersion(target)[0] > parseVersion(current)[0];
154
- }
164
+ /** Default freshness-cache filename — mirrors version-check.js. */
165
+ const DEFAULT_CACHE_FILENAME = 'version-check.json';
155
166
 
156
167
  /**
157
168
  * Resolve the installed `mandrel` version from this package's own
@@ -183,12 +194,15 @@ function resolveProjectRoot() {
183
194
  * Default `resolveTargetVersion` seam: determine the newest published
184
195
  * `mandrel` version via the daily freshness cache (`version-check.js`).
185
196
  *
186
- * This delegates to `isStale`, which honours the 24h-cache semantics: a fresh
187
- * cache returns the cached version with **zero** network I/O, while a missing,
188
- * corrupt, or stale cache triggers exactly one network probe (`npm view
189
- * mandrel version`) and refreshes `temp/version-check.json`. Wiring the
190
- * production update path through `isStale` is precisely what populates that
191
- * daily cache, which the `version-current` doctor advisory reads.
197
+ * When `bypassCache` is `true` (the default for an explicit `mandrel update`
198
+ * call Story #4046 A1b), the cache freshness window is effectively zeroed by
199
+ * passing `now` as far in the future, which makes `isStale` treat any existing
200
+ * cache as stale and always issue exactly one network probe. The cache is still
201
+ * written so the post-update `version-current` advisory has a fresh baseline.
202
+ *
203
+ * When `bypassCache` is `false` (passive staleness checks only), the normal
204
+ * 24h-cache semantics apply: a fresh cache returns the cached version with
205
+ * zero network I/O.
192
206
  *
193
207
  * The network probe shells `npm view` through `spawnSync` with a fixed argument
194
208
  * vector (no shell-string interpolation; the package name is a constant). On
@@ -202,6 +216,7 @@ function resolveProjectRoot() {
202
216
  * fs?: typeof nodeFs,
203
217
  * runner?: () => string,
204
218
  * now?: Date,
219
+ * bypassCache?: boolean,
205
220
  * log?: (msg: string) => void,
206
221
  * }} [opts]
207
222
  * @returns {Promise<string>} The newest published version string.
@@ -211,9 +226,22 @@ async function defaultResolveTargetVersion({
211
226
  fs = nodeFs,
212
227
  runner = defaultVersionRunner,
213
228
  now = new Date(),
229
+ bypassCache = false,
214
230
  log = () => {},
215
231
  } = {}) {
216
- const result = await isStale({ cachePath, now, runner, fs, log });
232
+ // When bypassCache is true, push `now` far enough into the future that any
233
+ // cached checkedAt value is guaranteed to be older than the STALE_AFTER_MS
234
+ // window, forcing a fresh network probe (Story #4046 A1b).
235
+ const effectiveNow = bypassCache
236
+ ? new Date(now.getTime() + 48 * 60 * 60 * 1000)
237
+ : now;
238
+ const result = await isStale({
239
+ cachePath,
240
+ now: effectiveNow,
241
+ runner,
242
+ fs,
243
+ log,
244
+ });
217
245
  return String(result.latestVersion);
218
246
  }
219
247
 
@@ -280,28 +308,30 @@ function repairInstallCommand(packageManager) {
280
308
  * Detecting the lockfile keeps the bump on the operator's real package manager
281
309
  * so the change lands in the matching lockfile.
282
310
  *
311
+ * Delegates to the shared `detectPackageManagerWithWorkspace` helper
312
+ * (Story #4048 B3 — one implementation per concept). The `fs` seam is adapted
313
+ * to the shared module's `exists` contract; the shared module's `bun` return
314
+ * value coerces to `npm` here because `bun add` is not yet a first-class update
315
+ * path for this orchestrator.
316
+ *
283
317
  * @param {string} [cwd] - Project root to probe (default `process.cwd()`).
284
318
  * @param {typeof nodeFs} [fs]
285
319
  * @returns {{ packageManager: 'pnpm' | 'yarn' | 'npm', workspaceRoot: boolean }}
286
320
  */
287
321
  export function detectPackageManager(cwd = process.cwd(), fs = nodeFs) {
288
- const has = (file) => {
322
+ const exists = (p) => {
289
323
  try {
290
- return fs.existsSync(path.join(cwd, file));
324
+ return fs.existsSync(p);
291
325
  } catch {
292
326
  return false;
293
327
  }
294
328
  };
295
- if (has('pnpm-lock.yaml')) {
296
- return {
297
- packageManager: 'pnpm',
298
- workspaceRoot: has('pnpm-workspace.yaml'),
299
- };
300
- }
301
- if (has('yarn.lock')) {
302
- return { packageManager: 'yarn', workspaceRoot: false };
303
- }
304
- return { packageManager: 'npm', workspaceRoot: false };
329
+ const result = detectPackageManagerWithWorkspace(cwd, exists);
330
+ // Coerce `bun` → `npm` because this orchestrator's install-command builder
331
+ // only handles pnpm / yarn / npm today.
332
+ const packageManager =
333
+ result.packageManager === 'bun' ? 'npm' : result.packageManager;
334
+ return { packageManager, workspaceRoot: result.workspaceRoot };
305
335
  }
306
336
 
307
337
  /**
@@ -408,6 +438,72 @@ export function defaultNpmUpdate(
408
438
  }
409
439
  }
410
440
 
441
+ /**
442
+ * Fetch `docs/CHANGELOG.md` for a specific mandrel tag from GitHub's raw
443
+ * content endpoint. This is the fallback when the packaged file is absent
444
+ * (e.g. an older install predating Story #4035 which added the file to the
445
+ * npm `files` allowlist).
446
+ *
447
+ * Injectable via the `fetchChangelog` seam so tests can verify the fallback
448
+ * path without issuing real network calls.
449
+ *
450
+ * The tag shape follows the `mandrel-vX.Y.Z` namespace (namespaced at
451
+ * `mandrel-v1.44.0`; bare `vX.Y.Z` for earlier releases). This function
452
+ * tries the namespaced tag first, then the bare-tag form, so it covers both
453
+ * tag series without forcing callers to know the boundary.
454
+ *
455
+ * Security (security-baseline § Transport & Headers): the URL is constructed
456
+ * from a constant base and a semver string — no user input, no shell
457
+ * interpolation. The GET is a read-only fetch with no credentials.
458
+ *
459
+ * @param {string} version - The target semver string (e.g. `"1.59.0"`).
460
+ * @param {{
461
+ * https?: typeof nodeHttps,
462
+ * }} [deps]
463
+ * @returns {Promise<string>} The raw changelog text.
464
+ * @throws {Error} When both tag forms return a non-2xx response or the request
465
+ * errors out — the caller handles this gracefully.
466
+ */
467
+ export async function fetchChangelogFromGitHub(
468
+ version,
469
+ { https: httpsImpl = nodeHttps } = {},
470
+ ) {
471
+ const tags = [`mandrel-v${version}`, `v${version}`];
472
+
473
+ /**
474
+ * @param {string} url
475
+ * @returns {Promise<{ status: number, body: string }>}
476
+ */
477
+ const httpGet = (url) =>
478
+ new Promise((resolve, reject) => {
479
+ httpsImpl
480
+ .get(url, (res) => {
481
+ const chunks = [];
482
+ res.on('data', (d) => chunks.push(d));
483
+ res.on('end', () =>
484
+ resolve({
485
+ status: res.statusCode ?? 0,
486
+ body: Buffer.concat(chunks).toString('utf8'),
487
+ }),
488
+ );
489
+ })
490
+ .on('error', reject);
491
+ });
492
+
493
+ for (const tag of tags) {
494
+ const url = `${GITHUB_RAW_BASE}${tag}/docs/CHANGELOG.md`;
495
+ // eslint-disable-next-line no-await-in-loop
496
+ const { status, body } = await httpGet(url);
497
+ if (status >= 200 && status < 300) {
498
+ return body;
499
+ }
500
+ }
501
+
502
+ throw new Error(
503
+ `mandrel update: GitHub fetch for mandrel v${version} docs/CHANGELOG.md returned non-2xx for all tag forms (tried: ${tags.join(', ')})`,
504
+ );
505
+ }
506
+
411
507
  /**
412
508
  * Default `surfaceChangelog` seam: print the `docs/CHANGELOG.md` section(s)
413
509
  * covering the applied version range `(current, target]`. The changelog is
@@ -415,38 +511,61 @@ export function defaultNpmUpdate(
415
511
  * prints every section whose version is newer than `current` and no newer than
416
512
  * `target`.
417
513
  *
418
- * Degrades gracefully (warns, never throws) when the file is absent or no
419
- * matching section is found — surfacing the changelog is best-effort and must
420
- * never fail an otherwise-successful upgrade.
514
+ * Resolution order (Story #4035):
515
+ * 1. Read `docs/CHANGELOG.md` from the target version's install directory
516
+ * (`node_modules/mandrel/docs/CHANGELOG.md` now in the published
517
+ * tarball since `package.json` lists `docs/CHANGELOG.md` in `files`).
518
+ * 2. When the packaged file is absent (older install), fetch it from GitHub
519
+ * via the injectable `fetchChangelog` seam.
520
+ * 3. When both sources fail, emit an actionable warning with a link to the
521
+ * GitHub Releases page — never a bare "not found … skipping".
522
+ *
523
+ * Degrades gracefully (warns, never throws) — surfacing the changelog is
524
+ * best-effort and must never fail an otherwise-successful upgrade.
421
525
  *
422
526
  * @param {string} target - The applied target version.
423
527
  * @param {{
424
528
  * current?: string,
425
529
  * changelogPath?: string,
426
530
  * fs?: typeof nodeFs,
531
+ * fetchChangelog?: (version: string) => Promise<string>,
427
532
  * write?: (s: string) => void,
428
533
  * writeErr?: (s: string) => void,
429
534
  * }} [opts]
430
- * @returns {void}
535
+ * @returns {Promise<void>}
431
536
  */
432
- function defaultSurfaceChangelog(
537
+ async function defaultSurfaceChangelog(
433
538
  target,
434
539
  {
435
540
  current,
436
541
  changelogPath = path.join(resolveProjectRoot(), 'docs', 'CHANGELOG.md'),
437
542
  fs = nodeFs,
543
+ fetchChangelog = fetchChangelogFromGitHub,
438
544
  write = (s) => process.stdout.write(s),
439
545
  writeErr = (s) => process.stderr.write(s),
440
546
  } = {},
441
547
  ) {
442
548
  let raw;
549
+
550
+ // 1. Try the packaged file (present in installs since Story #4035).
443
551
  try {
444
552
  raw = fs.readFileSync(changelogPath, 'utf8');
445
553
  } catch {
446
- writeErr(
447
- `mandrel update: changelog not found at ${changelogPath} — skipping changelog surface.\n`,
448
- );
449
- return;
554
+ // File absent — fall through to GitHub fetch.
555
+ }
556
+
557
+ // 2. Packaged file absent: attempt a GitHub fetch for the target tag.
558
+ if (raw === undefined) {
559
+ try {
560
+ raw = await fetchChangelog(target);
561
+ } catch {
562
+ // Both sources unavailable — emit an actionable message and return.
563
+ writeErr(
564
+ `mandrel update: changelog not available for v${target} — ` +
565
+ `view the release notes at ${GITHUB_RELEASES_URL}\n`,
566
+ );
567
+ return;
568
+ }
450
569
  }
451
570
 
452
571
  const sections = parseChangelogSections(raw);
@@ -458,7 +577,8 @@ function defaultSurfaceChangelog(
458
577
 
459
578
  if (relevant.length === 0) {
460
579
  writeErr(
461
- `mandrel update: no CHANGELOG section found for v${target} — skipping changelog surface.\n`,
580
+ `mandrel update: no CHANGELOG section found for v${target} — ` +
581
+ `view the release notes at ${GITHUB_RELEASES_URL}\n`,
462
582
  );
463
583
  return;
464
584
  }
@@ -509,6 +629,8 @@ function parseChangelogSections(raw) {
509
629
  * report whether all passed. Mirrors lib/cli/doctor.js's pass accounting
510
630
  * without the formatted report (the orchestrator owns its own output).
511
631
  *
632
+ * This is used only when `spawnPhase` is NOT injected (backward-compat path).
633
+ *
512
634
  * @param {{ checks?: typeof registry }} [opts]
513
635
  * @returns {Promise<{ ok: boolean, results: Array<{ name: string, ok: boolean }> }>}
514
636
  */
@@ -522,10 +644,88 @@ async function defaultRunDoctor({ checks = registry } = {}) {
522
644
  }
523
645
 
524
646
  /**
525
- * The ordered step names the orchestrator drives on a non-major bump. Shared
647
+ * Resolve the path to the `mandrel` binary inside `node_modules/.bin/` for the
648
+ * given project root. On Windows the binary is a `.cmd` shim; on POSIX it is a
649
+ * plain executable. The resolved path is used as the target for the post-install
650
+ * phase re-exec (Story #4034).
651
+ *
652
+ * @param {string} projectRoot - Absolute path to the consumer project.
653
+ * @returns {string} Absolute path to the new binary.
654
+ */
655
+ export function resolveNewBinPath(projectRoot) {
656
+ const binName = process.platform === 'win32' ? 'mandrel.cmd' : 'mandrel';
657
+ return path.join(projectRoot, 'node_modules', '.bin', binName);
658
+ }
659
+
660
+ /**
661
+ * Default `spawnPhase` seam (Story #4034): spawn a post-install phase from the
662
+ * newly-installed `mandrel` binary and stream its stdout/stderr through the
663
+ * parent's write sinks. Each phase runs as an isolated child process so the
664
+ * newly-installed module code (not the currently-loaded old module) executes.
665
+ *
666
+ * The spawn uses `shell: true` only on Windows where the binary is a `.cmd`
667
+ * shim (CVE-2024-27980 parity). The argv vector is a fixed constant list
668
+ * per phase — no operator-supplied data enters the vector, so the shell flag
669
+ * carries no injection risk (security-baseline § Output & Rendering).
670
+ *
671
+ * Throws when the child exits non-zero so the orchestrator can surface the
672
+ * failure to the operator.
673
+ *
674
+ * @param {string} phase - The mandrel sub-command to run (e.g. `'sync'`).
675
+ * @param {string[]} args - Additional arguments for the sub-command.
676
+ * @param {{
677
+ * binPath: string,
678
+ * cwd: string,
679
+ * write: (s: string) => void,
680
+ * writeErr: (s: string) => void,
681
+ * spawnFn?: typeof spawnSync,
682
+ * }} opts
683
+ * @returns {{ ok: boolean, stdout: string, stderr: string }}
684
+ */
685
+ export function defaultSpawnPhase(
686
+ phase,
687
+ args,
688
+ { binPath, cwd, write, writeErr, spawnFn = spawnSync },
689
+ ) {
690
+ const argv = [phase, ...args];
691
+ const r = spawnFn(binPath, argv, {
692
+ cwd,
693
+ encoding: 'utf8',
694
+ shell: process.platform === 'win32',
695
+ });
696
+ const stdout = typeof r.stdout === 'string' ? r.stdout : '';
697
+ const stderr = typeof r.stderr === 'string' ? r.stderr : '';
698
+ if (stdout) write(stdout);
699
+ if (stderr) writeErr(stderr);
700
+ if (r.error) {
701
+ throw new Error(
702
+ `mandrel update: failed to spawn \`mandrel ${phase}\` from new binary: ${r.error.message}`,
703
+ );
704
+ }
705
+ const ok = r.status === 0;
706
+ return { ok, stdout, stderr };
707
+ }
708
+
709
+ /**
710
+ * The ordered step names the orchestrator drives on an update. Shared
526
711
  * by the live path and the `--dry-run` plan printout so the two never drift.
712
+ *
713
+ * Step ordering (Story #4046 A1c):
714
+ * 1. npm-update — install the new version
715
+ * 2. runSync — re-materialize .agents/ from the new payload
716
+ * 3. sync-commands — regenerate .claude/commands/ from the new payload
717
+ * 4. runMigrations — apply version-keyed migrations
718
+ * 5. doctor — validate the post-upgrade state
719
+ * 6. surface changelog — print the changelog (always last, best-effort)
527
720
  */
528
- const STEP_PLAN = ['npm-update', 'runSync', 'runMigrations', 'doctor'];
721
+ const STEP_PLAN = [
722
+ 'npm-update',
723
+ 'runSync',
724
+ 'sync-commands',
725
+ 'runMigrations',
726
+ 'doctor',
727
+ 'surface changelog',
728
+ ];
529
729
 
530
730
  /**
531
731
  * Extract the `--install-cmd "<cmd>"` value from the subcommand argv. Accepts
@@ -553,21 +753,6 @@ function parseInstallCmdFlag(argv) {
553
753
  return undefined;
554
754
  }
555
755
 
556
- /**
557
- * Print the major-gate refusal: the available version, the runbook pointer,
558
- * and the re-run hint. No effectful seam runs after this.
559
- *
560
- * @param {string} target
561
- * @param {(s: string) => void} writeErr
562
- */
563
- function emitMajorRefusal(target, writeErr) {
564
- writeErr(
565
- `mandrel update: a newer MAJOR version (${target}) is available; ` +
566
- 'this is a breaking upgrade.\n' +
567
- ` → Review ${RUNBOOK_PATH}, then re-run with --major to apply it.\n`,
568
- );
569
- }
570
-
571
756
  /**
572
757
  * Run the `mandrel update` orchestration cycle.
573
758
  *
@@ -576,6 +761,7 @@ function emitMajorRefusal(target, writeErr) {
576
761
  * currentVersion?: string | (() => string),
577
762
  * resolveTargetVersion?: () => (string | Promise<string>),
578
763
  * npmUpdate?: (version: string, opts: { installCmd?: string }) => unknown | Promise<unknown>,
764
+ * 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 },
579
765
  * runSync?: typeof defaultRunSync,
580
766
  * runMigrations?: typeof defaultRunMigrations,
581
767
  * runDoctor?: typeof defaultRunDoctor,
@@ -583,13 +769,13 @@ function emitMajorRefusal(target, writeErr) {
583
769
  * write?: (s: string) => void,
584
770
  * writeErr?: (s: string) => void,
585
771
  * exit?: (code: number) => void,
772
+ * cwd?: () => string,
586
773
  * }} [opts]
587
774
  * @returns {Promise<{
588
775
  * ok: boolean,
589
- * action: 'updated' | 'declined-major' | 'dry-run' | 'up-to-date' | 'doctor-failed',
776
+ * action: 'updated' | 'dry-run' | 'up-to-date' | 'doctor-failed',
590
777
  * currentVersion: string,
591
778
  * targetVersion: string | null,
592
- * major: boolean,
593
779
  * stepsRun: string[],
594
780
  * dryRun: boolean,
595
781
  * }>}
@@ -599,6 +785,7 @@ export async function runUpdate({
599
785
  currentVersion,
600
786
  resolveTargetVersion,
601
787
  npmUpdate,
788
+ spawnPhase,
602
789
  runSync = defaultRunSync,
603
790
  runMigrations = defaultRunMigrations,
604
791
  runDoctor = defaultRunDoctor,
@@ -606,9 +793,9 @@ export async function runUpdate({
606
793
  write = (s) => process.stdout.write(s),
607
794
  writeErr = (s) => process.stderr.write(s),
608
795
  exit = (code) => process.exit(code),
796
+ cwd = () => process.cwd(),
609
797
  } = {}) {
610
798
  const dryRun = argv.includes('--dry-run');
611
- const allowMajor = argv.includes('--major');
612
799
  const installCmd = parseInstallCmdFlag(argv);
613
800
 
614
801
  const current =
@@ -623,25 +810,6 @@ export async function runUpdate({
623
810
  }
624
811
  const target = String(await resolveTargetVersion());
625
812
 
626
- const major = crossesMajor(current, target);
627
-
628
- // --- Major gate -----------------------------------------------------------
629
- // A major crossing without --major is refused outright: no npm-update, no
630
- // sync, no migration, no doctor — print the runbook pointer and exit non-zero.
631
- if (major && !allowMajor) {
632
- emitMajorRefusal(target, writeErr);
633
- exit(1);
634
- return {
635
- ok: false,
636
- action: 'declined-major',
637
- currentVersion: current,
638
- targetVersion: target,
639
- major: true,
640
- stepsRun: [],
641
- dryRun,
642
- };
643
- }
644
-
645
813
  // --- No-op short-circuit --------------------------------------------------
646
814
  // Already on (or ahead of) the newest version: nothing to apply.
647
815
  if (compareVersions(target, current) <= 0) {
@@ -651,7 +819,6 @@ export async function runUpdate({
651
819
  action: 'up-to-date',
652
820
  currentVersion: current,
653
821
  targetVersion: target,
654
- major,
655
822
  stepsRun: [],
656
823
  dryRun,
657
824
  };
@@ -662,34 +829,21 @@ export async function runUpdate({
662
829
  // write nothing to disk.
663
830
  if (dryRun) {
664
831
  write(`mandrel update — planned upgrade v${current} → v${target}\n`);
665
- if (major) {
666
- write(' (major upgrade — --major supplied)\n');
667
- }
668
832
  STEP_PLAN.forEach((step, i) => {
669
833
  write(` ${i + 1}. ${step}\n`);
670
834
  });
671
- write(' 5. surface changelog\n');
672
835
  write('Dry run: no files written, no dependency bumped.\n');
673
836
  return {
674
837
  ok: true,
675
838
  action: 'dry-run',
676
839
  currentVersion: current,
677
840
  targetVersion: target,
678
- major,
679
841
  stepsRun: [],
680
842
  dryRun: true,
681
843
  };
682
844
  }
683
845
 
684
- // --- Major runbook (inline, when --major applies) -------------------------
685
- if (major) {
686
- write(
687
- `Applying MAJOR upgrade v${current} → v${target} (--major). ` +
688
- `Review the runbook: ${RUNBOOK_PATH}\n`,
689
- );
690
- } else {
691
- write(`Updating v${current} → v${target}…\n`);
692
- }
846
+ write(`Updating v${current} v${target}…\n`);
693
847
 
694
848
  const stepsRun = [];
695
849
 
@@ -703,40 +857,159 @@ export async function runUpdate({
703
857
  await npmUpdate(target, { installCmd });
704
858
  stepsRun.push('npm-update');
705
859
 
706
- // 2. runSync re-materialize ./.agents/ from the freshly installed payload.
707
- runSync({ argv: [] });
708
- stepsRun.push('runSync');
860
+ // Decide whether to use the re-exec path (spawnPhase) or the in-process
861
+ // backward-compat seams (runSync / runMigrations / runDoctor).
862
+ //
863
+ // spawnPhase injected → re-exec path: all post-install phases run from the
864
+ // newly-installed binary. This is the production path and is what fixes
865
+ // the stale-materialization bug (Story #4034).
866
+ //
867
+ // spawnPhase NOT injected → in-process path: the original pre-Story-#4034
868
+ // behaviour. Tests that pre-date this change inject runSync/runMigrations/
869
+ // runDoctor and rely on the in-process path; they stay green without any
870
+ // modification.
871
+ const useReExec = typeof spawnPhase === 'function';
709
872
 
710
- // 3. runMigrations — apply version-keyed steps for the crossed range.
711
- runMigrations({ fromVersion: current, toVersion: target, ctx: {} });
712
- stepsRun.push('runMigrations');
873
+ if (useReExec) {
874
+ // Re-exec path: post-install phases run from the new binary.
875
+ const projectRoot = cwd();
876
+ const binPath = resolveNewBinPath(projectRoot);
713
877
 
714
- // 4. doctorverify the resulting install.
715
- const doctor = await runDoctor();
716
- stepsRun.push('doctor');
878
+ // 2. runSync from new bin re-materialize ./.agents/ from the freshly
879
+ // installed payload. Running from the new bin ensures the copied files
880
+ // come from the new package's .agents/ tree, not the old loaded module.
881
+ const syncResult = await spawnPhase('sync', [], {
882
+ binPath,
883
+ cwd: projectRoot,
884
+ write,
885
+ writeErr,
886
+ });
887
+ if (!syncResult.ok) {
888
+ throw new Error(
889
+ `mandrel update: \`mandrel sync\` from new binary exited non-zero — ` +
890
+ 'the .agents/ materialization may be incomplete. ' +
891
+ 'Run `mandrel sync` manually to restore.',
892
+ );
893
+ }
894
+ stepsRun.push('runSync');
717
895
 
718
- // 5. surface the target changelog (best-effort; optional seam).
719
- if (typeof surfaceChangelog === 'function') {
720
- await surfaceChangelog(target);
721
- }
896
+ // 3. sync-commands from new bin regenerate .claude/commands/ from the
897
+ // freshly-materialized .agents/workflows/. Running from the new bin
898
+ // ensures the command tree is consistent with the new payload; an
899
+ // upstream-renamed workflow will be projected correctly and the old
900
+ // command file will be reaped. This step must follow runSync so the
901
+ // workflow sources are up to date before the command tree is rebuilt
902
+ // (Story #4046 A1c — `commands-in-sync` validates the post-sync state).
903
+ const syncCommandsResult = await spawnPhase('sync-commands', [], {
904
+ binPath,
905
+ cwd: projectRoot,
906
+ write,
907
+ writeErr,
908
+ });
909
+ if (!syncCommandsResult.ok) {
910
+ throw new Error(
911
+ `mandrel update: \`mandrel sync-commands\` from new binary exited non-zero — ` +
912
+ 'the .claude/commands/ tree may be out of sync. ' +
913
+ 'Run `npm run sync:commands` manually to restore.',
914
+ );
915
+ }
916
+ stepsRun.push('sync-commands');
722
917
 
723
- if (!doctor.ok) {
724
- const failed = doctor.results.filter((r) => !r.ok).map((r) => r.name);
725
- writeErr(
726
- `mandrel update: upgraded to v${target} but doctor reported failures: ` +
727
- `${failed.join(', ')}\n` +
728
- ' Run `mandrel doctor` for remedies.\n',
918
+ // 4. runMigrations from new bin — apply version-keyed steps for the
919
+ // crossed range. The new binary's migration registry contains any steps
920
+ // added in the target version; the old process's registry does not.
921
+ const migrateResult = await spawnPhase(
922
+ 'migrate',
923
+ ['--from', current, '--to', target],
924
+ { binPath, cwd: projectRoot, write, writeErr },
729
925
  );
730
- exit(1);
731
- return {
732
- ok: false,
733
- action: 'doctor-failed',
734
- currentVersion: current,
735
- targetVersion: target,
736
- major,
737
- stepsRun,
738
- dryRun: false,
739
- };
926
+ if (!migrateResult.ok) {
927
+ throw new Error(
928
+ `mandrel update: \`mandrel migrate\` from new binary exited non-zero — ` +
929
+ `some migrations for v${current} → v${target} may not have applied. ` +
930
+ `Run \`mandrel migrate --from ${current} --to ${target}\` manually to retry.`,
931
+ );
932
+ }
933
+ stepsRun.push('runMigrations');
934
+
935
+ // 5. doctor from new bin — verify the resulting install. Running from the
936
+ // new bin is critical: the agents-drift check compares the materialized
937
+ // .agents/ against the installed package payload. When the old process
938
+ // runs this check, it resolves the package root to its own (old) install
939
+ // dir, so drift against the new payload is invisible. The new binary
940
+ // resolves the package root to the now-installed new version, producing
941
+ // an accurate result.
942
+ const doctorResult = await spawnPhase('doctor', [], {
943
+ binPath,
944
+ cwd: projectRoot,
945
+ write,
946
+ writeErr,
947
+ });
948
+ stepsRun.push('doctor');
949
+
950
+ // 6. surface the target changelog (best-effort; optional seam).
951
+ if (typeof surfaceChangelog === 'function') {
952
+ await surfaceChangelog(target);
953
+ }
954
+
955
+ if (!doctorResult.ok) {
956
+ writeErr(
957
+ `mandrel update: upgraded to v${target} but doctor reported failures.\n` +
958
+ ' → Run `mandrel doctor` for remedies.\n',
959
+ );
960
+ exit(1);
961
+ return {
962
+ ok: false,
963
+ action: 'doctor-failed',
964
+ currentVersion: current,
965
+ targetVersion: target,
966
+ stepsRun,
967
+ dryRun: false,
968
+ };
969
+ }
970
+ } else {
971
+ // In-process backward-compat path (pre-Story-#4034 behaviour).
972
+ // Used when no `spawnPhase` seam is injected — preserves full backward
973
+ // compatibility with existing tests that inject runSync/runMigrations/
974
+ // runDoctor directly.
975
+
976
+ // 2. runSync — re-materialize ./.agents/ from the new payload.
977
+ runSync({ argv: [] });
978
+ stepsRun.push('runSync');
979
+
980
+ // 3. runMigrations — apply version-keyed steps for the crossed range.
981
+ // Note: the in-process path (pre-Story-#4034) does not run sync-commands
982
+ // here because sync-commands runs as a child process and there is no
983
+ // in-process seam for it. The re-exec path (spawnPhase) handles it.
984
+ runMigrations({ fromVersion: current, toVersion: target, ctx: {} });
985
+ stepsRun.push('runMigrations');
986
+
987
+ // 4. doctor — verify the resulting install.
988
+ const doctor = await runDoctor();
989
+ stepsRun.push('doctor');
990
+
991
+ // 5. surface the target changelog (best-effort; optional seam).
992
+ if (typeof surfaceChangelog === 'function') {
993
+ await surfaceChangelog(target);
994
+ }
995
+
996
+ if (!doctor.ok) {
997
+ const failed = doctor.results.filter((r) => !r.ok).map((r) => r.name);
998
+ writeErr(
999
+ `mandrel update: upgraded to v${target} but doctor reported failures: ` +
1000
+ `${failed.join(', ')}\n` +
1001
+ ' → Run `mandrel doctor` for remedies.\n',
1002
+ );
1003
+ exit(1);
1004
+ return {
1005
+ ok: false,
1006
+ action: 'doctor-failed',
1007
+ currentVersion: current,
1008
+ targetVersion: target,
1009
+ stepsRun,
1010
+ dryRun: false,
1011
+ };
1012
+ }
740
1013
  }
741
1014
 
742
1015
  write(`✅ Updated to v${target}. The lockfile bump is staged for review.\n`);
@@ -745,7 +1018,6 @@ export async function runUpdate({
745
1018
  action: 'updated',
746
1019
  currentVersion: current,
747
1020
  targetVersion: target,
748
- major,
749
1021
  stepsRun,
750
1022
  dryRun: false,
751
1023
  };
@@ -755,26 +1027,37 @@ export async function runUpdate({
755
1027
  * Default export consumed by `bin/mandrel.js`.
756
1028
  *
757
1029
  * Wires the production-default seams that `runUpdate` leaves injectable:
758
- * - `resolveTargetVersion` probes the newest published `mandrel`
759
- * version through the daily freshness cache (`version-check.js#isStale`),
760
- * which ALSO populates `temp/version-check.json` the cache the
761
- * `version-current` doctor advisory reads.
1030
+ * - `resolveTargetVersion` always probes the registry via `isStale` with
1031
+ * `bypassCache: true` the 24h cache is overridden for explicit update
1032
+ * calls so the resolved version is always fresh (Story #4046 A1b). The
1033
+ * cache is still written so the `version-current` doctor advisory reads
1034
+ * a current baseline after the upgrade.
762
1035
  * - `npmUpdate` runs the install command — auto-detected from the project
763
1036
  * lockfile (`pnpm`/`yarn`/`npm`), or the `--install-cmd` override —
764
1037
  * through the shared `runInstallCommand` helper — no git mutation;
765
1038
  * lockfile left staged.
1039
+ * - `spawnPhase` is wired to `defaultSpawnPhase`, which spawns each
1040
+ * post-install phase (sync, sync-commands, migrate, doctor) from the
1041
+ * newly-installed binary (`node_modules/.bin/mandrel`). This is the
1042
+ * Story #4034 fix: the new bin loads the new package's module code and
1043
+ * resolves paths against the new install dir, so these phases can never
1044
+ * observe the old payload.
766
1045
  * - `surfaceChangelog` prints the relevant `docs/CHANGELOG.md` section(s)
767
- * for the applied range, degrading gracefully when the file is absent.
1046
+ * for the applied range. Reads from the packaged file first; falls back to
1047
+ * a GitHub raw-content fetch via the injectable `fetchChangelog` seam when
1048
+ * the packaged file is absent; emits an actionable link to the GitHub
1049
+ * Releases page when both sources fail (Story #4035).
768
1050
  *
769
1051
  * Every seam stays injectable on `runUpdate`; these are merely the
770
1052
  * no-seam-provided fallbacks, so the existing seam-driven tests stay green.
771
- * `--major` / `--dry-run` / `--install-cmd` are parsed from `argv` by
1053
+ * `--dry-run` / `--install-cmd` are parsed from `argv` by
772
1054
  * `runUpdate` itself.
773
1055
  *
774
1056
  * The second `deps` argument exposes the **process boundaries** the production
775
1057
  * defaults shell out across (`versionRunner` = `npm view`, `runInstall` =
776
- * the install spawn) plus `fs` / `cachePath` / `now`, so the entrypoint can be
777
- * driven end-to-end with the network/npm boundary stubbed and no real I/O.
1058
+ * the install spawn, `spawnFn` = the phase-spawn boundary) plus `fs` /
1059
+ * `cachePath` / `now`, so the entrypoint can be driven end-to-end with the
1060
+ * network/npm boundary stubbed and no real I/O.
778
1061
  * `bin/mandrel.js` calls `run(argv)` with no `deps`, getting the production
779
1062
  * wiring; tests pass fakes. The `deps` surface is NOT part of the public
780
1063
  * subcommand contract — `bin/mandrel.js` only ever supplies `argv`.
@@ -787,7 +1070,9 @@ export async function runUpdate({
787
1070
  * now?: Date,
788
1071
  * versionRunner?: () => string,
789
1072
  * runInstall?: (installCmd: string, cwd: string) => { status: number, stderr: string },
1073
+ * spawnFn?: typeof spawnSync,
790
1074
  * changelogPath?: string,
1075
+ * fetchChangelog?: (version: string) => Promise<string>,
791
1076
  * runUpdate?: typeof runUpdate,
792
1077
  * runSync?: typeof defaultRunSync,
793
1078
  * runMigrations?: typeof defaultRunMigrations,
@@ -806,49 +1091,78 @@ export default async function run(argv = [], deps = {}) {
806
1091
  now,
807
1092
  versionRunner,
808
1093
  runInstall,
1094
+ spawnFn,
809
1095
  changelogPath,
1096
+ fetchChangelog,
810
1097
  runUpdate: runUpdateImpl = runUpdate,
811
1098
  runSync,
812
1099
  runMigrations,
813
1100
  runDoctor,
814
- write,
815
- writeErr,
816
- exit,
1101
+ write = (s) => process.stdout.write(s),
1102
+ writeErr = (s) => process.stderr.write(s),
1103
+ exit = (code) => process.exit(code),
817
1104
  log,
818
1105
  } = deps;
819
1106
 
820
1107
  const current = deps.currentVersion ?? defaultCurrentVersion(fs);
821
1108
 
1109
+ // The production spawnPhase default: spawn each post-install phase from
1110
+ // node_modules/.bin/mandrel (the newly-installed binary). spawnFn is
1111
+ // injectable so tests can stub the spawn boundary without running a real
1112
+ // child process.
1113
+ const productionSpawnPhase = (phase, args, opts) =>
1114
+ defaultSpawnPhase(phase, args, {
1115
+ ...opts,
1116
+ ...(spawnFn ? { spawnFn } : {}),
1117
+ });
1118
+
1119
+ // Resolve which seam set to use for post-install phases. If any old-style
1120
+ // in-process seam (runSync/runMigrations/runDoctor) is injected, fall back
1121
+ // to the pre-Story-#4034 in-process path so the entrypoint test stays green.
1122
+ // Otherwise use the re-exec path (spawnPhase). This is a single ternary
1123
+ // rather than stacked optional spreads (tidy, Story #4046).
1124
+ const phaseSeams =
1125
+ runSync || runMigrations || runDoctor
1126
+ ? {
1127
+ ...(runSync ? { runSync } : {}),
1128
+ ...(runMigrations ? { runMigrations } : {}),
1129
+ ...(runDoctor ? { runDoctor } : {}),
1130
+ }
1131
+ : { spawnPhase: productionSpawnPhase };
1132
+
822
1133
  await runUpdateImpl({
823
1134
  argv,
824
1135
  currentVersion: current,
1136
+ // Always bypass the 24h cache on an explicit `mandrel update` so the
1137
+ // resolved target is fresh from the registry (Story #4046 A1b).
825
1138
  resolveTargetVersion: () =>
826
1139
  defaultResolveTargetVersion({
827
- ...(cachePath ? { cachePath } : {}),
1140
+ cachePath:
1141
+ cachePath ?? path.join(process.cwd(), 'temp', DEFAULT_CACHE_FILENAME),
828
1142
  fs,
829
- ...(versionRunner ? { runner: versionRunner } : {}),
830
- ...(now ? { now } : {}),
831
- ...(log ? { log } : {}),
1143
+ runner: versionRunner ?? defaultVersionRunner,
1144
+ now: now ?? new Date(),
1145
+ bypassCache: true,
1146
+ log: log ?? (() => {}),
832
1147
  }),
833
1148
  npmUpdate: (target, { installCmd } = {}) =>
834
1149
  defaultNpmUpdate(target, {
835
1150
  ...(installCmd ? { installCmd } : {}),
836
- ...(runInstall ? { runInstall } : {}),
1151
+ runInstall: runInstall ?? runInstallCommand,
837
1152
  fs,
838
1153
  }),
1154
+ ...phaseSeams,
839
1155
  surfaceChangelog: (target) =>
840
1156
  defaultSurfaceChangelog(target, {
841
1157
  current,
842
1158
  fs,
843
1159
  ...(changelogPath ? { changelogPath } : {}),
844
- ...(write ? { write } : {}),
845
- ...(writeErr ? { writeErr } : {}),
1160
+ fetchChangelog: fetchChangelog ?? fetchChangelogFromGitHub,
1161
+ write,
1162
+ writeErr,
846
1163
  }),
847
- ...(runSync ? { runSync } : {}),
848
- ...(runMigrations ? { runMigrations } : {}),
849
- ...(runDoctor ? { runDoctor } : {}),
850
- ...(write ? { write } : {}),
851
- ...(writeErr ? { writeErr } : {}),
852
- ...(exit ? { exit } : {}),
1164
+ write,
1165
+ writeErr,
1166
+ exit,
853
1167
  });
854
1168
  }