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.
- package/.agents/docs/SDLC.md +10 -3
- package/.agents/docs/workflows.md +1 -1
- package/.agents/workflows/deliver.md +87 -26
- package/.agents/workflows/helpers/deliver-epic.md +12 -5
- package/.agents/workflows/helpers/deliver-stories.md +13 -7
- package/.agents/workflows/plan.md +3 -1
- package/docs/CHANGELOG.md +12 -0
- package/lib/cli/registry.js +1 -1
- package/lib/cli/update.js +114 -8
- package/package.json +1 -1
package/.agents/docs/SDLC.md
CHANGED
|
@@ -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
|
|
669
|
-
|
|
670
|
-
|
|
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
|
|
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,
|
|
5
|
-
|
|
6
|
-
the
|
|
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
|
|
11
|
+
# /deliver [Epic IDs...] | [Story IDs...]
|
|
10
12
|
|
|
11
13
|
## Role
|
|
12
14
|
|
|
13
|
-
Router. `/deliver` owns input classification
|
|
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
|
|
36
|
-
|
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
46
|
-
|
|
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.
|
|
65
|
-
never guess a route.
|
|
66
|
-
3. **
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
|
74
|
-
Epic helper's preflight enforces this) or well-formed
|
|
75
|
-
Planning happens in [`/plan`](plan.md); 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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
|
36
|
-
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
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
|
|
40
|
-
Story when you want the leaner one-story path without wave
|
|
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
|
|
package/lib/cli/registry.js
CHANGED
|
@@ -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.
|
|
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:
|
|
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
|
-
|
|
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: '
|
|
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