mandrel 1.61.0 → 1.62.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -143,6 +143,11 @@ From zero to shipped:
143
143
  a halt), re-run `/deliver <epicId>` — the wave loop picks up
144
144
  incomplete Stories from the dispatch manifest automatically. Standalone
145
145
  Stories (no `Epic: #N` reference) use `/deliver <storyId>` instead.
146
+ Mixed input — several Epics, or Epics plus standalone Stories — is
147
+ accepted in one invocation: `/deliver` composes a **sequential segment
148
+ plan** (the standalone-Story set as one segment, delivered first, then
149
+ each Epic as its own segment in input order) and executes the segments
150
+ one at a time through the same two path helpers, never interleaved.
146
151
 
147
152
  That is the whole happy path. Everything below is **detail** — branching
148
153
  conventions, HITL escalation, audit gates — that you only need when the
@@ -664,10 +669,12 @@ side-effects rather than inline calls at phase boundaries; the
664
669
  | **Standalone Story — plan** | `/plan` | Plan a one-off Story that does not belong to an Epic backlog. |
665
670
  | **Standalone Story — deliver** | `/deliver <storyId> [<storyId>...]` | Deliver one or more standalone Stories authored by `/plan`. |
666
671
  | **Standalone Story (worker)** | *helper* `helpers/single-story-deliver <storyId>` | Per-Story sub-agent called internally by `/deliver`; not an operator slash command. |
672
+ | **Mixed set** | `/deliver <ids...>` | Any mix of ≥1 Epics and standalone Stories. The router composes a sequential segment plan — standalone segment first, then Epic segments in input order — delegating each segment to the path helpers above. |
667
673
 
668
- The operator-facing entry points are `/deliver` (for Epics) and
669
- `/deliver` (for standalone Stories). The `helpers/` layer sits below
670
- both and is never invoked directly by the operator.
674
+ The single operator-facing entry point is `/deliver` it routes a lone
675
+ Epic, a standalone-Story set, or a mixed set (via the sequential segment
676
+ plan) to the right path helper(s). The `helpers/` layer sits below it and
677
+ is never invoked directly by the operator.
671
678
 
672
679
  ### Story-centric branching
673
680
 
@@ -44,7 +44,7 @@ description, edit the workflow file’s front-matter and regenerate.
44
44
  | `/audit-sre` | "Audit production-readiness for a release candidate: SLOs, observability, runbooks, error budgets, and rollback paths." |
45
45
  | `/audit-to-stories` | Convert findings produced by the audit-\* workflows into actionable GitHub Stories. Reads temp/audits/audit-\*-results.md, groups findings cross-audit, deduplicates against existing Issues by fingerprint, and either chains into /plan --idea or opens standalone Stories. |
46
46
  | `/audit-ux-ui` | Audit UX/UI consistency and design system adherence |
47
- | `/deliver` | Unified delivery entry point. Inspects the ticket type(s) and Epic-reference state of the supplied IDs, then routes to the Epic wave loop or the standalone multi-Story fan-out — preserving every flag and the parallel-delivery contract of the retired commands. |
47
+ | `/deliver` | Unified delivery entry point. Inspects the ticket type(s) and Epic-reference state of the supplied IDs, composes a sequential segment plan over any mix of Epics and standalone Stories, then delegates each segment to the Epic wave loop or the standalone multi-Story fan-out — preserving every flag and the parallel-delivery contract of the retired commands. |
48
48
  | `/explain` | Walk the operator through a code change until they genuinely understand it. Targets a PR, a branch, or the working-tree diff, then drives the `core/knowledge-transfer` skill (restate-first, why-ladder, mastery gates, persistent checklist) with an operator-controlled stop at every checkpoint. |
49
49
  | `/git-cleanup` | Tidy the local checkout in four phases: fast-forward `main`, prune stale remote-tracking refs, sweep merged branches (squash-aware), and triage `git stash` entries — each step gated by operator confirmation. |
50
50
  | `/git-commit-all` | Stage every untracked and modified file, then create a single conventional-commit on the current branch (no push). |
@@ -1,17 +1,19 @@
1
1
  ---
2
2
  description:
3
3
  Unified delivery entry point. Inspects the ticket type(s) and
4
- Epic-reference state of the supplied IDs, then routes to the Epic wave
5
- loop or the standalone multi-Story fan-out preserving every flag and
6
- the parallel-delivery contract of the retired commands.
4
+ Epic-reference state of the supplied IDs, composes a sequential segment
5
+ plan over any mix of Epics and standalone Stories, then delegates each
6
+ segment to the Epic wave loop or the standalone multi-Story fan-out —
7
+ preserving every flag and the parallel-delivery contract of the retired
8
+ commands.
7
9
  ---
8
10
 
9
- # /deliver [Epic ID] | [Story IDs...]
11
+ # /deliver [Epic IDs...] | [Story IDs...]
10
12
 
11
13
  ## Role
12
14
 
13
- Router. `/deliver` owns input classification and path selection only — all
14
- phase content lives in the two path helpers:
15
+ Router. `/deliver` owns input classification, segment-plan composition, and
16
+ path selection only — all phase content lives in the two path helpers:
15
17
 
16
18
  - [`helpers/deliver-epic.md`](helpers/deliver-epic.md) — the full Epic
17
19
  delivery loop (preflight, wave loop fanning out
@@ -30,20 +32,67 @@ reference) before routing:
30
32
 
31
33
  | Input | Route |
32
34
  | --- | --- |
33
- | Exactly one `type::epic` ID | **Epic path** — run [`helpers/deliver-epic.md`](helpers/deliver-epic.md) Phases 1–9 unchanged. |
34
- | One or more `type::story` IDs, none carrying an `Epic: #N` reference | **Standalone path** — run [`helpers/deliver-stories.md`](helpers/deliver-stories.md) Phases 0–3. |
35
- | Any Story carrying an `Epic: #N` reference | **Error**, naming the fix: `Story #<id> belongs to Epic #<n> run /deliver <n>`. |
36
- | Mixed Epic + Story IDs, or more than one Epic | **Error**: separate invocations one `/deliver <epicId>` per Epic, one `/deliver <id> [<id>...]` for the standalone set. |
37
-
38
- ## Flags (forwarded per path)
35
+ | Exactly one `type::epic` ID | **Epic path** — run [`helpers/deliver-epic.md`](helpers/deliver-epic.md) Phases 1–9 unchanged (single-segment plan; no confirmation prompt). |
36
+ | One or more `type::story` IDs, none carrying an `Epic: #N` reference | **Standalone path** — run [`helpers/deliver-stories.md`](helpers/deliver-stories.md) Phases 0–3 (single-segment plan; no confirmation prompt). |
37
+ | Any combination of ≥1 `type::epic` IDs and ≥0 standalone `type::story` IDs | **Segment plan** compose and execute the sequential segment plan below. |
38
+ | Any Story carrying an `Epic: #N` reference (alone or mixed into an otherwise-valid set) | **Error**, naming every affected ID and the fix: `Story #<id> belongs to Epic #<n> run /deliver <n>`. |
39
+
40
+ Per-ID classification is unchanged: fetch the `type::*` label and probe the
41
+ body for an `Epic: #N` reference before routing. Never guess a route.
42
+
43
+ ## Segment plan (mixed / multi-Epic input)
44
+
45
+ When the supplied IDs span more than one Epic, or mix Epics with standalone
46
+ Stories, the router composes a **segment plan** and executes the segments
47
+ **strictly sequentially**:
48
+
49
+ 1. **Standalone segment first** (when any standalone Story IDs are
50
+ present): the full standalone-Story set forms **one** segment,
51
+ delivered via [`helpers/deliver-stories.md`](helpers/deliver-stories.md)
52
+ Phases 0–3 unchanged. It runs first because it is fast, each Story
53
+ merges to `main` independently, and each subsequent Epic segment's
54
+ Phase 7.0 base-sync then integrates those merges naturally instead of
55
+ the Epic PR opening behind base.
56
+ 2. **Epic segments in input order**: each `type::epic` ID forms its own
57
+ segment, delivered via
58
+ [`helpers/deliver-epic.md`](helpers/deliver-epic.md) Phases 1–9
59
+ unchanged.
60
+
61
+ Sequential execution is a deliberate design decision: the Epic path assumes
62
+ a single main checkout (prepare's checkout guard, Phase 7.0
63
+ `git checkout epic/<id>`), holds a per-Epic lease, serializes same-machine
64
+ sessions via `epic-merge-lock.js`, and constrains dispatch to one wave at a
65
+ time. Segments are never interleaved or parallelized; running them one at a
66
+ time keeps both helpers' machinery untouched.
67
+
68
+ **Confirmation gate.** When the composed plan has more than one segment,
69
+ present it to the operator before dispatching — the segments, the IDs in
70
+ each, and the execution order — and wait for confirmation. `--yes`
71
+ suppresses this prompt. Single-segment plans route directly with today's
72
+ behavior (no new prompt; the standalone path's own Phase 1 confirmation
73
+ still applies as before).
74
+
75
+ **Failure policy.** A segment that ends non-complete (blocked, failed, or
76
+ halted at a gate) **stops the run** — no subsequent segment dispatches.
77
+ Report the terminal state: which segments completed, which segment halted
78
+ (and why), and which segments never started. Name the resume command:
79
+ re-running `/deliver` with the same IDs — both path helpers short-circuit
80
+ already-done work (the Epic path resumes idempotently from its checkpoint;
81
+ merged standalone Stories no-op).
82
+
83
+ ## Flags (scoped per segment)
39
84
 
40
85
  | Path | Flags |
41
86
  | --- | --- |
42
87
  | Epic | `--skip-epic-audit`, `--skip-code-review`, `--skip-retro`, `--full-retro`, `--steal`, `--as <handle>` |
43
88
  | Story | `--dep <from>:<to>`, `--yes`, `--concurrency <n>` |
44
89
 
45
- A flag passed to the wrong path is reported once as a no-op warning and
46
- ignored never an error.
90
+ In a segment plan, Epic-path flags apply to **every** Epic segment;
91
+ Story-path flags apply to the standalone segment. `--yes` additionally
92
+ suppresses the router's segment-plan confirmation gate above. A flag with
93
+ no applicable segment in the plan is reported once as a no-op warning and
94
+ ignored — never an error (the existing convention, restated for segment
95
+ plans).
47
96
 
48
97
  **Multi-Story parallel contract (preserved verbatim).**
49
98
 
@@ -55,31 +104,43 @@ behaves exactly as the retired multi-Story command did: the same
55
104
  `stories-wave-tick.js` wave plan, the same operator confirmation gate
56
105
  (suppressed by `--yes`), and the same parallel fan-out — one Agent call per
57
106
  Story per wave, capped by the resolved `concurrencyCap` — to
58
- [`helpers/single-story-deliver`](helpers/single-story-deliver.md).
107
+ [`helpers/single-story-deliver`](helpers/single-story-deliver.md). The
108
+ parallelism lives **inside** the standalone segment; segments themselves
109
+ remain strictly sequential.
59
110
 
60
111
  ## Procedure
61
112
 
62
113
  1. **Parse args.** At least one positive-integer ID is required.
63
114
  2. **Classify.** Fetch each ticket's labels + body and apply the input
64
- matrix above. Refuse ambiguous input with the matrix's error messages
65
- never guess a route.
66
- 3. **Delegate.** Read the selected path helper **in full** and execute it
67
- from its entry phase, forwarding the absorbed flags. The helper's phase
68
- numbering, watchdogs, gates, and scripts are unchanged — this router
69
- adds no phase content.
115
+ matrix above. Any Epic-attached Story ID is a hard error naming the
116
+ affected IDs and the fix — never guess a route.
117
+ 3. **Compose the segment plan.** Standalone-Story set (when present) as
118
+ one segment, then one segment per Epic ID in input order. For a
119
+ multi-segment plan, present it and wait for operator confirmation
120
+ (`--yes` suppresses).
121
+ 4. **Execute segments sequentially.** For each segment in order, read the
122
+ selected path helper **in full** and execute it from its entry phase,
123
+ forwarding the segment's scoped flags. The helper's phase numbering,
124
+ watchdogs, gates, and scripts are unchanged — this router adds no phase
125
+ content. Stop on the first non-complete segment per the failure policy.
126
+ 5. **Report.** On completion (or halt), summarize per-segment outcomes and,
127
+ when halted, the resume command.
70
128
 
71
129
  ## Constraints
72
130
 
73
- - `/deliver` requires a planned ticket: an Epic at `agent::ready` (the
74
- Epic helper's preflight enforces this) or well-formed standalone Stories.
75
- Planning happens in [`/plan`](plan.md); the plan-review gate between the
76
- two commands is a hard boundary.
131
+ - `/deliver` requires planned tickets: Epics at `agent::ready` (the
132
+ Epic helper's preflight enforces this, per segment) or well-formed
133
+ standalone Stories. Planning happens in [`/plan`](plan.md); the
134
+ plan-review gate between the two commands is a hard boundary.
77
135
  - The router performs no git or label mutations itself; the path helpers
78
136
  own every script invocation.
137
+ - Segments execute strictly sequentially — never interleave a standalone
138
+ Story fan-out with an Epic wave loop, and never run two Epic segments
139
+ concurrently.
79
140
 
80
141
  ## See also
81
142
 
82
143
  - [`/plan`](plan.md) — the unified planning entry point.
83
144
  - [`helpers/deliver-epic.md`](helpers/deliver-epic.md) /
84
145
  [`helpers/deliver-stories.md`](helpers/deliver-stories.md) — the path
85
- helpers.
146
+ helpers, delegated to per segment.
@@ -14,9 +14,14 @@ description: >-
14
14
 
15
15
  ## Overview
16
16
 
17
- `/deliver` is the **single SDL execution command** in the 5.40 surface.
18
- It opens a PR against `main` and auto-merges when every signal certifies a
19
- clean run; otherwise it falls back to the operator-merges-button path.
17
+ This helper is the **Epic delivery path** behind `/deliver` — the router
18
+ delegates to it once per Epic ID, either as the sole route (single-Epic
19
+ input) or as one **Epic segment** of the sequential segment plan `/deliver`
20
+ composes over mixed Epic / standalone-Story input (Epic segments run in
21
+ input order, after the standalone segment; see
22
+ [`deliver.md`](../deliver.md)). Each invocation opens a PR against `main`
23
+ and auto-merges when every signal certifies a clean run; otherwise it falls
24
+ back to the operator-merges-button path.
20
25
 
21
26
  ```text
22
27
  /deliver <epicId>
@@ -32,8 +37,10 @@ clean run; otherwise it falls back to the operator-merges-button path.
32
37
  → Phase 9 — cleanup (BranchCleaner + Cleaner lifecycle listeners on epic.cleanup.start / epic.merge.armed; fire via lifecycle-emit → epic.merge.armed)
33
38
  ```
34
39
 
35
- The argument is always an Epic ID (`type::epic`). Story IDs go to
36
- [`/deliver`](deliver-stories.md) (standalone) or the
40
+ The argument is always a single Epic ID (`type::epic`) multi-Epic or
41
+ mixed input is segmented by the `/deliver` router before this helper runs.
42
+ Story IDs go to
43
+ [`helpers/deliver-stories`](deliver-stories.md) (standalone) or the
37
44
  [`helpers/epic-deliver-story`](epic-deliver-story.md) helper
38
45
  (Epic-attached, invoked by this workflow's fan-out); Tasks are not directly
39
46
  executable.
@@ -11,10 +11,14 @@ description: >-
11
11
 
12
12
  ## Overview
13
13
 
14
- `/deliver` is the **operator-facing multi-Story delivery command**. It
15
- takes one or more Story IDs, builds a dependency-aware wave plan, optionally
16
- confirms it with the operator, and fans out one Agent call per Story per wave
17
- parallel within each wave, serialised across waves.
14
+ This helper is the **standalone multi-Story delivery path** behind
15
+ `/deliver`. The router delegates to it whenever the supplied IDs include
16
+ standalone Stories either as the sole route (Story-only input) or as the
17
+ **standalone segment** of a mixed segment plan (run first, before any Epic
18
+ segments; see [`deliver.md`](../deliver.md)). It takes one or more Story
19
+ IDs, builds a dependency-aware wave plan, optionally confirms it with the
20
+ operator, and fans out one Agent call per Story per wave — parallel within
21
+ each wave, serialised across waves.
18
22
 
19
23
  ```text
20
24
  /deliver 101 102 103
@@ -33,11 +37,13 @@ confirms it with the operator, and fans out one Agent call per Story per wave
33
37
  | 1+ standalone Stories (no `Epic: #N` in body) | `/deliver <id> [<id>...]` |
34
38
  | Exactly one standalone Story (lighter path) | `/single-story-deliver <id>` |
35
39
  | Epic-attached Stories (have `Epic: #N`) | `/deliver <epicId>` |
40
+ | Mixed Epics + standalone Stories | `/deliver <ids...>` — the router composes a sequential segment plan; this helper delivers the standalone segment first |
36
41
 
37
- `/deliver` **refuses** Stories that carry an `Epic: #N` reference in
42
+ This helper **refuses** Stories that carry an `Epic: #N` reference in
38
43
  their body. Those Stories belong to an Epic's dispatch manifest and must flow
39
- through `/deliver`. Use `/single-story-deliver` for a single Epic-free
40
- Story when you want the leaner one-story path without wave machinery.
44
+ through `/deliver <epicId>`. Use `/single-story-deliver` for a single
45
+ Epic-free Story when you want the leaner one-story path without wave
46
+ machinery.
41
47
 
42
48
  > **Concurrency cap.** The cap is resolved **deterministically in code** by
43
49
  > `stories-wave-tick.js` (Phase 1a) — the same `resolveConfig` + `getRunners`
@@ -126,6 +126,8 @@ stubbed docs, or an unready doctor verdict).
126
126
 
127
127
  ## See also
128
128
 
129
- - [`/deliver`](deliver.md) — the unified delivery entry point.
129
+ - [`/deliver`](deliver.md) — the unified delivery entry point. Accepts a
130
+ single Epic, one or more standalone Stories, or any mix of ≥1 Epics and
131
+ standalone Stories — mixed sets compose a sequential segment plan.
130
132
  - [`helpers/plan-epic.md`](helpers/plan-epic.md) /
131
133
  [`helpers/plan-story.md`](helpers/plan-story.md) — the path helpers.
package/docs/CHANGELOG.md CHANGED
@@ -2,6 +2,18 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [1.62.0](https://github.com/dsj1984/mandrel/compare/mandrel-v1.61.0...mandrel-v1.62.0) (2026-06-12)
6
+
7
+
8
+ ### Added
9
+
10
+ * /deliver composes a sequential segment plan over mixed Epic and standalone-Story ID sets (refs [#4062](https://github.com/dsj1984/mandrel/issues/4062)) ([#4063](https://github.com/dsj1984/mandrel/issues/4063)) ([a83d29e](https://github.com/dsj1984/mandrel/commit/a83d29e032b7f6b6846d7988f071d6b6416df2f9))
11
+
12
+
13
+ ### Fixed
14
+
15
+ * make mandrel update no-op short-circuit drift-aware (refs [#4065](https://github.com/dsj1984/mandrel/issues/4065)) ([#4066](https://github.com/dsj1984/mandrel/issues/4066)) ([693f1cf](https://github.com/dsj1984/mandrel/commit/693f1cf5bf8fd7d29fed910659708ffceb7a54cb))
16
+
5
17
  ## [1.61.0](https://github.com/dsj1984/mandrel/compare/mandrel-v1.60.0...mandrel-v1.61.0) (2026-06-12)
6
18
 
7
19
 
@@ -483,7 +483,7 @@ function runAgentsMaterialized({ cwd, existsSync, resolvePackage } = {}) {
483
483
  * }} [opts]
484
484
  * @returns {{ ok: boolean, detail: string, remedy?: string }}
485
485
  */
486
- function runAgentsDrift({ cwd, fsImpl = fs, resolvePackageRoot } = {}) {
486
+ export function runAgentsDrift({ cwd, fsImpl = fs, resolvePackageRoot } = {}) {
487
487
  const getCwd = cwd ?? (() => process.cwd());
488
488
  const resolveRoot = resolvePackageRoot ?? defaultResolvePackageRoot;
489
489
  const projectRoot = getCwd();
package/lib/cli/update.js CHANGED
@@ -12,7 +12,10 @@
12
12
  * ## Ordered cycle (happy path)
13
13
  *
14
14
  * 1. resolve target version (newest published) and the current version
15
- * 2. no-op short-circuit — already on the newest version ⇒ nothing to do
15
+ * 2. drift-aware short-circuit — version check gates on installed version AND
16
+ * materialized `.agents/` state (Story #4065). When already on newest AND
17
+ * no drift: nothing to do (true no-op). When already on newest BUT drift
18
+ * detected: skip npm-update/migrations, run sync + sync-commands to heal.
16
19
  * 3. install — bump the dependency (lockfile bump left STAGED).
17
20
  * The package manager is auto-detected from the lockfile in the project
18
21
  * root: `pnpm-lock.yaml` ⇒ `pnpm add -D …` (with `-w` at a
@@ -85,6 +88,11 @@
85
88
  * - `argv` — subcommand args (after `mandrel update`)
86
89
  * - `currentVersion` — the installed `mandrel` version string
87
90
  * - `resolveTargetVersion`— async, returns the newest published version
91
+ * - `checkDrift` — sync or async, returns `true` when `.agents/`
92
+ * differs from the installed payload. Used by the
93
+ * drift-aware no-op short-circuit (Story #4065).
94
+ * Defaults to `() => !runAgentsDrift().ok`, which
95
+ * reuses the same `agents-drift` doctor signal.
88
96
  * - `npmUpdate` — async, performs the dependency bump (no git);
89
97
  * receives `(target, { installCmd })`
90
98
  * - `spawnPhase` — async, spawns a post-install phase from the new
@@ -140,7 +148,7 @@ import { fileURLToPath } from 'node:url';
140
148
  import { detectPackageManagerWithWorkspace } from '../../.agents/scripts/lib/detect-package-manager.js';
141
149
  import { runInstallCommand } from '../../.agents/scripts/lib/install-cmd-parser.js';
142
150
  import { runMigrations as defaultRunMigrations } from '../migrations/index.js';
143
- import { registry } from './registry.js';
151
+ import { registry, runAgentsDrift } from './registry.js';
144
152
  import { runSync as defaultRunSync } from './sync.js';
145
153
  import { isStale } from './version-check.js';
146
154
  import { compareVersions } from './version-helpers.js';
@@ -761,6 +769,7 @@ function parseInstallCmdFlag(argv) {
761
769
  * currentVersion?: string | (() => string),
762
770
  * resolveTargetVersion?: () => (string | Promise<string>),
763
771
  * npmUpdate?: (version: string, opts: { installCmd?: string }) => unknown | Promise<unknown>,
772
+ * checkDrift?: () => (boolean | Promise<boolean>),
764
773
  * spawnPhase?: (phase: string, args: string[], opts: { binPath: string, cwd: string, write: (s: string) => void, writeErr: (s: string) => void }) => Promise<{ ok: boolean, stdout: string, stderr: string }> | { ok: boolean, stdout: string, stderr: string },
765
774
  * runSync?: typeof defaultRunSync,
766
775
  * runMigrations?: typeof defaultRunMigrations,
@@ -773,7 +782,7 @@ function parseInstallCmdFlag(argv) {
773
782
  * }} [opts]
774
783
  * @returns {Promise<{
775
784
  * ok: boolean,
776
- * action: 'updated' | 'dry-run' | 'up-to-date' | 'doctor-failed',
785
+ * action: 'updated' | 'resynced' | 'dry-run' | 'up-to-date' | 'doctor-failed',
777
786
  * currentVersion: string,
778
787
  * targetVersion: string | null,
779
788
  * stepsRun: string[],
@@ -785,6 +794,7 @@ export async function runUpdate({
785
794
  currentVersion,
786
795
  resolveTargetVersion,
787
796
  npmUpdate,
797
+ checkDrift,
788
798
  spawnPhase,
789
799
  runSync = defaultRunSync,
790
800
  runMigrations = defaultRunMigrations,
@@ -811,16 +821,112 @@ export async function runUpdate({
811
821
  const target = String(await resolveTargetVersion());
812
822
 
813
823
  // --- No-op short-circuit --------------------------------------------------
814
- // Already on (or ahead of) the newest version: nothing to apply.
824
+ // Already on (or ahead of) the newest version: check whether .agents/ is
825
+ // actually materialized to the installed payload (agents-drift). When drift
826
+ // is present, fall through to a sync-only heal path even though the package
827
+ // version is unchanged. Only emit "Already up to date" when the version is
828
+ // current AND the payload matches (Story #4065).
815
829
  if (compareVersions(target, current) <= 0) {
816
- write(`✅ Already up to date (v${current} is the newest version).\n`);
830
+ // Resolve the drift probe: prefer the injected checkDrift seam (unit-test
831
+ // friendly); fall back to the production runAgentsDrift helper.
832
+ const driftProbe =
833
+ typeof checkDrift === 'function'
834
+ ? checkDrift
835
+ : () => !runAgentsDrift().ok;
836
+ const hasDrift = await driftProbe();
837
+
838
+ if (!hasDrift) {
839
+ write(`✅ Already up to date (v${current} is the newest version).\n`);
840
+ return {
841
+ ok: true,
842
+ action: 'up-to-date',
843
+ currentVersion: current,
844
+ targetVersion: target,
845
+ stepsRun: [],
846
+ dryRun,
847
+ };
848
+ }
849
+
850
+ // Drift detected while version is already current — heal by re-syncing
851
+ // without bumping the package (no npm-update, no migrations needed since
852
+ // the installed version did not change).
853
+ if (dryRun) {
854
+ write(
855
+ `mandrel update — drift detected, sync heal planned (v${current} is already current)\n`,
856
+ );
857
+ write(
858
+ ' 1. runSync — re-materialize .agents/ from installed payload\n',
859
+ );
860
+ write(
861
+ ' 2. sync-commands — regenerate .claude/commands/ from .agents/workflows/\n',
862
+ );
863
+ write('Dry run: no files written.\n');
864
+ return {
865
+ ok: true,
866
+ action: 'dry-run',
867
+ currentVersion: current,
868
+ targetVersion: target,
869
+ stepsRun: [],
870
+ dryRun: true,
871
+ };
872
+ }
873
+
874
+ write(
875
+ `Healing .agents/ drift (v${current} is already current, but .agents/ is stale)…\n`,
876
+ );
877
+ const stepsRun = [];
878
+
879
+ const useReExec = typeof spawnPhase === 'function';
880
+
881
+ if (useReExec) {
882
+ const projectRoot = cwd();
883
+ const binPath = resolveNewBinPath(projectRoot);
884
+
885
+ const syncResult = await spawnPhase('sync', [], {
886
+ binPath,
887
+ cwd: projectRoot,
888
+ write,
889
+ writeErr,
890
+ });
891
+ if (!syncResult.ok) {
892
+ throw new Error(
893
+ `mandrel update: \`mandrel sync\` from installed binary exited non-zero — ` +
894
+ 'the .agents/ materialization may be incomplete. ' +
895
+ 'Run `mandrel sync` manually to restore.',
896
+ );
897
+ }
898
+ stepsRun.push('runSync');
899
+
900
+ const syncCommandsResult = await spawnPhase('sync-commands', [], {
901
+ binPath,
902
+ cwd: projectRoot,
903
+ write,
904
+ writeErr,
905
+ });
906
+ if (!syncCommandsResult.ok) {
907
+ throw new Error(
908
+ `mandrel update: \`mandrel sync-commands\` from installed binary exited non-zero — ` +
909
+ 'the .claude/commands/ tree may be out of sync. ' +
910
+ 'Run `npm run sync:commands` manually to restore.',
911
+ );
912
+ }
913
+ stepsRun.push('sync-commands');
914
+ } else {
915
+ // In-process backward-compat path.
916
+ runSync({ argv: [] });
917
+ stepsRun.push('runSync');
918
+ }
919
+
920
+ write(
921
+ `✅ Healed .agents/ drift (v${current}). The materialized payload is now current.\n`,
922
+ );
817
923
  return {
818
924
  ok: true,
819
- action: 'up-to-date',
925
+ action: 'resynced',
820
926
  currentVersion: current,
821
927
  targetVersion: target,
822
- stepsRun: [],
823
- dryRun,
928
+ stepsRun,
929
+ dryRun: false,
824
930
  };
825
931
  }
826
932
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mandrel",
3
- "version": "1.61.0",
3
+ "version": "1.62.0",
4
4
  "description": "Claude Code-first opinionated workflow framework: instructions, personas, skills, and SDLC workflows that govern AI coding assistants.",
5
5
  "files": [
6
6
  ".agents/",