mandrel 1.69.0 → 1.70.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 (35) hide show
  1. package/.agents/README.md +1 -1
  2. package/.agents/docs/workflows.md +1 -1
  3. package/.agents/scripts/agents-update-preflight.js +235 -0
  4. package/.agents/scripts/apply-quality-bootstrap.js +79 -0
  5. package/.agents/scripts/audit-labels-bootstrap.js +52 -30
  6. package/.agents/scripts/audit-to-stories.js +54 -0
  7. package/.agents/scripts/bootstrap.js +13 -3
  8. package/.agents/scripts/generate-config-docs.js +189 -94
  9. package/.agents/scripts/lib/audit-suite/findings.js +0 -4
  10. package/.agents/scripts/lib/audit-to-stories/audit-lenses.js +99 -0
  11. package/.agents/scripts/lib/audit-to-stories/build-story-body.js +13 -5
  12. package/.agents/scripts/lib/baseline-snapshot.js +163 -4
  13. package/.agents/scripts/lib/baselines/refresh-service.js +0 -4
  14. package/.agents/scripts/lib/config/baselines.js +0 -20
  15. package/.agents/scripts/lib/config/temp-paths.js +0 -31
  16. package/.agents/scripts/lib/crap-utils.js +281 -0
  17. package/.agents/scripts/lib/orchestration/dispatch-engine.js +0 -2
  18. package/.agents/scripts/lib/orchestration/epic-runner/progress-reporter/composition.js +0 -84
  19. package/.agents/scripts/lib/orchestration/epic-runner/progress-reporter/signals.js +3 -4
  20. package/.agents/scripts/lib/orchestration/lifecycle/trace-logger.js +0 -4
  21. package/.agents/scripts/lib/orchestration/retro/phases/compose-body.js +101 -70
  22. package/.agents/scripts/lib/orchestration/spec-renderer.js +42 -14
  23. package/.agents/scripts/lib/orchestration/ticket-lease.js +3 -0
  24. package/.agents/scripts/lib/story-body/story-body.js +110 -65
  25. package/.agents/scripts/lib/test-tiers.js +13 -7
  26. package/.agents/scripts/lib/wave-runner/tick.js +177 -53
  27. package/.agents/scripts/lib/workers/combined-mi-crap-worker.js +226 -0
  28. package/.agents/scripts/providers/github/issues.js +48 -0
  29. package/.agents/scripts/providers/github.js +1 -0
  30. package/.agents/workflows/agents-update.md +205 -28
  31. package/README.md +20 -0
  32. package/docs/CHANGELOG.md +32 -0
  33. package/lib/cli/registry.js +49 -6
  34. package/lib/cli/update.js +335 -332
  35. package/package.json +16 -11
@@ -0,0 +1,226 @@
1
+ /**
2
+ * lib/workers/combined-mi-crap-worker.js — CPU-pool worker entry for the
3
+ * combined MI + CRAP single-pass scan (`scanAndScoreCombined`).
4
+ *
5
+ * One file in, BOTH the maintainability score and the per-method CRAP rows
6
+ * out — derived from a SINGLE `escomplex.analyzeModule` parse via
7
+ * `analyzeOnce`. This collapses the two independent worker-pool passes the
8
+ * full-tree baseline regenerator used to run (the MI worker parsed the AST
9
+ * once for the module score, the CRAP worker parsed the same file's AST
10
+ * again for the method rows) into one parse per file.
11
+ *
12
+ * The MI score and the CRAP rows have independent skip policies, mirroring
13
+ * the two separate passes this worker replaces:
14
+ * - **MI** never requires coverage. The module score is emitted for every
15
+ * file that reads + transpiles + parses. A read failure yields
16
+ * `miScore: null` (the host drops the file from the MI map, matching
17
+ * `calculateAll`'s `score === null` filter). A transpile failure or a
18
+ * parse error yields `miScore: 0` (matching `calculateForFile` /
19
+ * `calculateForSource`, which return 0 on transpile-null / parse-error).
20
+ * - **CRAP** honours `requireCoverage`. A file with no coverage entry is
21
+ * reported as `skippedFileNoCoverage: true` (the host increments its own
22
+ * counter and emits no CRAP rows for it) — but the MI score is STILL
23
+ * computed and returned, because the MI pass would have scored it.
24
+ *
25
+ * Message contract — see lib/cpu-pool.js:
26
+ * IN : { item: { abs: string, relPath: string, requireCoverage: boolean,
27
+ * coverageEntry: object | null } }
28
+ * { exit: true }
29
+ * OUT : { ok: true, result: {
30
+ * relPath,
31
+ * miScore: number | null,
32
+ * skippedFileNoCoverage: boolean,
33
+ * crapRows: Array<{ method, startLine, cyclomatic, coverage, crap }> | null,
34
+ * skippedMethodsNoCoverage: number,
35
+ * } }
36
+ *
37
+ * A read/transpile/parse failure surfaces as `crapRows: null` so the host
38
+ * loop drops the file's CRAP contribution (matching the crap-worker's
39
+ * `rows: null` contract) — never aborts the whole scan. On a read failure
40
+ * `miScore` is `null`; on a transpile/parse failure `miScore` is `0`.
41
+ */
42
+
43
+ import fs from 'node:fs';
44
+ import { parentPort } from 'node:worker_threads';
45
+ import { analyzeOnce } from '../crap-utils.js';
46
+ import { transpileIfNeeded } from '../transpile.js';
47
+
48
+ /**
49
+ * Pure handler for a single inbound worker message. Exported so unit tests
50
+ * can exercise every branch (bad-shape rejection, coverage gate, read /
51
+ * transpile / parse failures, success rows, skipped methods, and the
52
+ * MI-computed-even-when-coverage-skipped invariant) without spawning a real
53
+ * `Worker` thread.
54
+ *
55
+ * Side effects (fs, transpile, analyzeOnce) are wired through `deps` so
56
+ * tests pass deterministic stubs.
57
+ *
58
+ * @param {unknown} msg
59
+ * @param {{
60
+ * readFile?: (abs: string) => string,
61
+ * transpile?: (abs: string, source: string) => string | null,
62
+ * analyze?: (source: string, entry: object|null) => {
63
+ * miScore: number,
64
+ * crapRows: Array<object>,
65
+ * parseError: boolean,
66
+ * },
67
+ * }} [deps]
68
+ * @returns {{kind: 'exit'} | {kind: 'reply', message: object}}
69
+ */
70
+ export function handleCombinedMiCrapWorkerMessage(msg, deps = {}) {
71
+ if (msg && msg.exit === true) return { kind: 'exit' };
72
+
73
+ const item = msg?.item;
74
+ if (
75
+ !item ||
76
+ typeof item.abs !== 'string' ||
77
+ typeof item.relPath !== 'string'
78
+ ) {
79
+ return {
80
+ kind: 'reply',
81
+ message: {
82
+ ok: false,
83
+ error: `bad worker message: ${JSON.stringify(msg)}`,
84
+ },
85
+ };
86
+ }
87
+ const { abs, relPath, requireCoverage } = item;
88
+ const readFile = deps.readFile ?? ((p) => fs.readFileSync(p, 'utf-8'));
89
+ const transpile = deps.transpile ?? transpileIfNeeded;
90
+ const analyze = deps.analyze ?? analyzeOnce;
91
+
92
+ // Coverage entry is pre-resolved on the host and attached to the item.
93
+ // `item.coverageEntry` may be explicitly `null` when the file has no
94
+ // coverage, or `undefined` when the caller did not supply it (treat as null).
95
+ const entry = item.coverageEntry ?? null;
96
+
97
+ // Read the source once. A read failure means neither MI nor CRAP can be
98
+ // computed — MI drops (null), CRAP drops (rows null) — matching the two
99
+ // passes' read-failure contracts (calculateAll → score null; crap worker
100
+ // → rows null).
101
+ let source;
102
+ try {
103
+ source = readFile(abs);
104
+ } catch {
105
+ return {
106
+ kind: 'reply',
107
+ message: {
108
+ ok: true,
109
+ result: {
110
+ relPath,
111
+ miScore: null,
112
+ skippedFileNoCoverage: false,
113
+ crapRows: null,
114
+ skippedMethodsNoCoverage: 0,
115
+ },
116
+ },
117
+ };
118
+ }
119
+
120
+ // TS/TSX → strip-then-analyze. A transpile failure yields miScore 0
121
+ // (calculateForFile returns 0 when transpileIfNeeded returns null) and a
122
+ // null CRAP contribution (crap worker returns rows: null).
123
+ const prepared = transpile(abs, source);
124
+ if (prepared === null) {
125
+ return {
126
+ kind: 'reply',
127
+ message: {
128
+ ok: true,
129
+ result: {
130
+ relPath,
131
+ miScore: 0,
132
+ skippedFileNoCoverage: false,
133
+ crapRows: null,
134
+ skippedMethodsNoCoverage: 0,
135
+ },
136
+ },
137
+ };
138
+ }
139
+
140
+ // ONE parse: analyzeOnce derives both the module MI score and the raw
141
+ // per-method CRAP rows from a single escomplex report. On a parse error it
142
+ // returns miScore 0 and an empty crapRows with parseError true.
143
+ const {
144
+ miScore,
145
+ crapRows: rawCrapRows,
146
+ parseError,
147
+ } = analyze(prepared, entry);
148
+ if (parseError) {
149
+ // Parse error: MI scores 0 (parity with calculateForSource's catch →
150
+ // returns 0), CRAP drops the file (rows null, parity with the crap
151
+ // worker's calculateCrap-throw branch).
152
+ return {
153
+ kind: 'reply',
154
+ message: {
155
+ ok: true,
156
+ result: {
157
+ relPath,
158
+ miScore: 0,
159
+ skippedFileNoCoverage: false,
160
+ crapRows: null,
161
+ skippedMethodsNoCoverage: 0,
162
+ },
163
+ },
164
+ };
165
+ }
166
+
167
+ // CRAP coverage gate runs AFTER the parse so the MI score is always
168
+ // available. When the file has no coverage under requireCoverage, the CRAP
169
+ // pass would have skipped it at the file level (no rows, counted) — but the
170
+ // MI pass would still have scored it, so miScore is returned regardless.
171
+ if (requireCoverage && entry === null) {
172
+ return {
173
+ kind: 'reply',
174
+ message: {
175
+ ok: true,
176
+ result: {
177
+ relPath,
178
+ miScore,
179
+ skippedFileNoCoverage: true,
180
+ crapRows: [],
181
+ skippedMethodsNoCoverage: 0,
182
+ },
183
+ },
184
+ };
185
+ }
186
+
187
+ const crapRows = [];
188
+ let skippedMethodsNoCoverage = 0;
189
+ for (const mr of rawCrapRows) {
190
+ if (mr.crap === null || mr.coverage === null) {
191
+ skippedMethodsNoCoverage += 1;
192
+ continue;
193
+ }
194
+ crapRows.push({
195
+ method: mr.method,
196
+ startLine: mr.startLine,
197
+ cyclomatic: mr.cyclomatic,
198
+ coverage: mr.coverage,
199
+ crap: mr.crap,
200
+ });
201
+ }
202
+ return {
203
+ kind: 'reply',
204
+ message: {
205
+ ok: true,
206
+ result: {
207
+ relPath,
208
+ miScore,
209
+ skippedFileNoCoverage: false,
210
+ crapRows,
211
+ skippedMethodsNoCoverage,
212
+ },
213
+ },
214
+ };
215
+ }
216
+
217
+ if (parentPort) {
218
+ parentPort.on('message', (msg) => {
219
+ const out = handleCombinedMiCrapWorkerMessage(msg);
220
+ if (out.kind === 'exit') {
221
+ parentPort.close();
222
+ return;
223
+ }
224
+ parentPort.postMessage(out.message);
225
+ });
226
+ }
@@ -99,6 +99,54 @@ export class IssuesGateway {
99
99
  return issues.filter((issue) => !issue?.pull_request);
100
100
  }
101
101
 
102
+ /**
103
+ * Search issues by a free-text query via the REST search API
104
+ * (`GET /search/issues`). Deliberately REST, **not** GraphQL: transient
105
+ * GraphQL 401s are a known failure mode in this repo (the dedup port that
106
+ * consumes this method must not silently no-op on an auth blip), so the
107
+ * search rides the same `gh api` REST surface + transient-retry shim as
108
+ * every other read here.
109
+ *
110
+ * The caller (`audit-to-stories.js` `loadProvider()`) passes a 40-char
111
+ * fingerprint sha as the query so the search resolves the handful of
112
+ * issues whose fingerprint footer carries that sha; `route-finding.js`
113
+ * then confirms identity against the footer. Both open and closed issues
114
+ * are returned (no `state:` qualifier is appended) so a closed-fingerprint
115
+ * match can surface as `regression-of-closed`.
116
+ *
117
+ * Returns the trimmed `[{ number, state, body }]` projection the dedup
118
+ * port expects. `state` is normalised to the REST lowercase form
119
+ * (`open` / `closed`).
120
+ *
121
+ * @param {{ query: string, owner?: string, repo?: string }} params
122
+ * @returns {Promise<Array<{ number: number, state: string, body: string }>>}
123
+ * @field-manifest GET /search/issues: total_count, items[number, state, body]
124
+ */
125
+ async searchIssues({ query, owner, repo } = {}) {
126
+ if (typeof query !== 'string' || query.trim().length === 0) {
127
+ throw new Error('searchIssues: a non-empty query string is required');
128
+ }
129
+ const scopeOwner = owner ?? this.owner;
130
+ const scopeRepo = repo ?? this.repo;
131
+ // Constrain the search to this repo and to issues (not PRs). The
132
+ // fingerprint sha is the free-text term; GitHub matches it against the
133
+ // issue body where the `<!-- audit-fingerprints: ... -->` footer lives.
134
+ const qualifiers = [`repo:${scopeOwner}/${scopeRepo}`, 'type:issue'];
135
+ const q = `${query.trim()} ${qualifiers.join(' ')}`;
136
+ const endpoint = `/search/issues?q=${encodeURIComponent(q)}`;
137
+ const result = await withTransientRetry(
138
+ () => this._gh.api({ method: 'GET', endpoint }),
139
+ { label: `searchIssues ${query}`, onRetry: defaultRetryWarn },
140
+ );
141
+ const json = parseApiJson(result);
142
+ const items = Array.isArray(json?.items) ? json.items : [];
143
+ return items.map((item) => ({
144
+ number: item.number,
145
+ state: item.state ?? 'open',
146
+ body: item.body ?? '',
147
+ }));
148
+ }
149
+
102
150
  /**
103
151
  * List Epic-typed issues. Filter shape preserved from the old code.
104
152
  *
@@ -98,6 +98,7 @@ export class GitHubProvider extends ITicketingProvider {
98
98
  */
99
99
  const DELEGATIONS = [
100
100
  ['graphql', 'issues.ghGraphql'],
101
+ ['searchIssues', 'issues.searchIssues'],
101
102
  ['listIssuesByLabel', 'issues.listIssuesByLabel'],
102
103
  ['getEpics', 'issues.getEpics'],
103
104
  ['getEpic', 'issues.getEpic'],
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  description: >-
3
- npm-era upgrade wraparound for a Mandrel consumer. Runs `mandrel update`
3
+ npm-era upgrade wraparound for a Mandrel consumer. Runs `npx mandrel update`
4
4
  (resolve newest published version → install → re-materialize `.agents/` →
5
5
  migrate → doctor → surface changelog) as the single mechanical step, then
6
6
  walks the operator through the judgment wraparound the CLI deliberately
@@ -15,7 +15,7 @@ description: >-
15
15
  > **Upgrade owner.** The mechanical upgrade is owned end to end by the
16
16
  > [`mandrel update`](../../lib/cli/update.js) CLI under the npm distribution
17
17
  > model (`mandrel`, #3436/#3437). This workflow wraps that CLI: it
18
- > runs `mandrel update`, then walks the operator through the
18
+ > runs `npx mandrel update`, then walks the operator through the
19
19
  > **distribution-agnostic judgment steps** the CLI deliberately does **not**
20
20
  > perform — config reconciliation, the Epic #1386 quality-gate installs, the
21
21
  > permission-allowlist refresh, the consumer-side changelog reconciliation,
@@ -60,22 +60,105 @@ The upgrade contract:
60
60
  > **Persona**: `devops-engineer` · **Skills**:
61
61
  > `core/ci-cd-and-automation`, `core/documentation-and-adrs`
62
62
 
63
+ **Invocation form.** In a consumer project `mandrel` is a local
64
+ devDependency at `node_modules/.bin/mandrel` and is **not** on `PATH`, so a
65
+ bare `mandrel <subcommand>` fails with `command not found` before any of the
66
+ hardened CLI logic ([`lib/cli/update.js`](../../lib/cli/update.js)) runs.
67
+ Every **runnable** command in this workflow therefore uses the
68
+ `npx mandrel <subcommand>` form, matching [`README.md`](../../README.md).
69
+ Prose that names the CLI as a noun (e.g. "`mandrel update`'s sync step")
70
+ refers to the binary by name, not as a command to type — run it via the form
71
+ Step 0 selects. The exception is a project where `mandrel` is installed
72
+ **globally**: there, the bare form works and Step 0 says so.
73
+
74
+ ## Step 0 — Detect the install state and pick the invocation form
75
+
76
+ Before running the updater, detect how `mandrel` resolves in this project and
77
+ route to the matching invocation form. Run from the consumer repo root:
78
+
79
+ ```bash
80
+ # 1. Globally installed and on PATH?
81
+ command -v mandrel
82
+ # 2. Installed as a local devDependency?
83
+ ls node_modules/.bin/mandrel 2>/dev/null || npm ls mandrel
84
+ ```
85
+
86
+ Three real states, three routes:
87
+
88
+ | State | Detection | Invocation form |
89
+ | --------------------------------------- | --------------------------------------------------------------------- | ----------------------------------------------------------------- |
90
+ | **Global install (on `PATH`)** | `command -v mandrel` prints a path | Bare `mandrel <subcommand>` works. |
91
+ | **Local devDependency (not on `PATH`)** | `command -v mandrel` is empty; `node_modules/.bin/mandrel` exists | Use `npx mandrel <subcommand>` (resolves the local bin). |
92
+ | **Not installed** | `command -v mandrel` empty **and** `node_modules/.bin/mandrel` absent | Run `npm install -D mandrel` first, then `npx mandrel`. |
93
+
94
+ The common consumer case is **local devDependency** — `npx mandrel` is the
95
+ default form the rest of this workflow uses. On a global install you may drop
96
+ the `npx` prefix; on a fresh project, install the package first. The `npx`
97
+ form is harmless on a global install too (it prefers the local bin and falls
98
+ back to a one-off fetch), so when unsure, use `npx mandrel`.
99
+
100
+ ## Step 0.5 — First-run preflight (before any bump)
101
+
102
+ Before running the updater, run the first-run preflight. It catches three
103
+ day-0 failure modes — **wrong project**, a **dirty git index**, and being
104
+ **offline** — before `npx mandrel update` bumps anything. Run from the
105
+ consumer repo root:
106
+
107
+ ```bash
108
+ node .agents/scripts/agents-update-preflight.js
109
+ ```
110
+
111
+ The preflight runs three checks and prints a JSON envelope
112
+ (`{ ok, blocked, findings[] }`) on stdout plus a human-readable report:
113
+
114
+ | Check | Severity | What it verifies |
115
+ | ------------------ | ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
116
+ | **consumer-shape** | **blocker** (exit 2) | `package.json` lists `mandrel` as a dependency **and** a `.agents/` directory exists. Hard-stops in the framework repo itself or any non-consumer project. |
117
+ | **dirty-index** | warn-only | The git index has no pre-existing staged changes. `mandrel update` leaves the lockfile staged and Step 5 would otherwise sweep unrelated staged files into the commit. |
118
+ | **offline** | warn-only | The npm registry is reachable (`npm ping`), so the version probe in Step 1 will not fail with a confusing offline error. |
119
+
120
+ Severity follows framework preflight conventions (cf.
121
+ [`epic-deliver-preflight.js`](../scripts/epic-deliver-preflight.js), the
122
+ `story-close` preflight): the **consumer-shape** check is a hard stop — the
123
+ script exits `2` and you MUST NOT proceed until it is resolved; **dirty-index**
124
+ and **offline** are warn-only and never block the run.
125
+
126
+ Routing:
127
+
128
+ - **Exit 0, `ok: true`** — all checks passed; proceed to Step 1.
129
+ - **Exit 0 with warnings** (`blocked: false`, non-empty `findings[]`) —
130
+ review the warnings. For **dirty-index**, unstage unrelated changes
131
+ (`git restore --staged <path>`) so they are not swept into the
132
+ `chore: update mandrel` commit. For **offline**, restore connectivity
133
+ before the version probe. Then proceed.
134
+ - **Exit 2, `blocked: true`** — the consumer-shape check failed. Stop. You
135
+ are not in a Mandrel consumer project (wrong directory, the framework repo
136
+ itself, or a project that never ran `mandrel sync`). `cd` into the consumer
137
+ repo, or bootstrap one with `npm install -D mandrel && npx mandrel sync`,
138
+ then re-run the preflight.
139
+
140
+ > **Scope.** The preflight is a **workflow-layer** guard; it deliberately
141
+ > lives outside [`lib/cli/update.js`](../../lib/cli/update.js), which stays
142
+ > git-free and side-effect-scoped. It composes cleanly with the Step 0
143
+ > invocation-form detection above — Step 0 picks *how* to call the updater;
144
+ > Step 0.5 verifies it is *safe* to call it at all.
145
+
63
146
  ## Step 1 — Run the updater
64
147
 
65
148
  Preview first, then apply. From the consumer repo root:
66
149
 
67
150
  ```bash
68
- mandrel update --dry-run
69
- mandrel update
151
+ npx mandrel update --dry-run
152
+ npx mandrel update
70
153
  ```
71
154
 
72
- `mandrel update --dry-run` resolves the newest published version and prints
73
- the ordered step plan (`npm-update → runSync → runMigrations → doctor →
155
+ `npx mandrel update --dry-run` resolves the newest published version and
156
+ prints the ordered step plan (`npm-update → runSync → runMigrations → doctor →
74
157
  surface changelog`) without invoking any effectful seam — no dependency bump,
75
158
  no sync, no migrations, no doctor, nothing written. Read the planned target
76
159
  version before applying.
77
160
 
78
- `mandrel update` (no flags) runs the live cycle:
161
+ `npx mandrel update` (no flags) runs the live cycle:
79
162
 
80
163
  1. **Resolve target** — the newest published `mandrel` version (via
81
164
  the daily freshness cache in `temp/version-check.json`) and the currently
@@ -130,6 +213,86 @@ mandrel update — planned upgrade v1.44.0 → v1.46.0
130
213
  Dry run: no files written, no dependency bumped.
131
214
  ```
132
215
 
216
+ ## Step 2.5 — Partial-upgrade recovery (**blocker — resolve before Step 5**)
217
+
218
+ `mandrel update` runs its post-install phases in order — **install** →
219
+ **sync** → **sync-commands** → **migrate** → **doctor** — and the install
220
+ phase bumps `package.json` / `package-lock.json` and leaves the change
221
+ **staged on disk** *before* any of the later phases run. By deliberate
222
+ design the CLI **never rolls back the install on failure** (the lockfile
223
+ bump is left staged for the operator — see the Out-of-Scope note in
224
+ [`lib/cli/update.js`](../../lib/cli/update.js)). So when a post-install
225
+ phase exits non-zero, you land in a **partially-upgraded state**:
226
+
227
+ - The lockfile bump to the new version is **already staged**, *and*
228
+ - `.agents/` may be **half-materialized** (sync failed midway), the flat
229
+ `.claude/commands/` tree may be **out of sync** (sync-commands failed), a
230
+ version-keyed migration may have **partially applied** (migrate failed), or
231
+ the post-upgrade state failed validation (doctor failed).
232
+
233
+ This is the dangerous case the whole workflow exists to guard: the operator
234
+ is now **one `git commit` away** (Step 5) from recording a broken
235
+ half-upgrade as "done". `mandrel update` prints the per-phase manual remedy
236
+ to **stderr**, but a line buried in stderr is easy to scroll past and commit
237
+ right over. **Treat any post-install phase failure as an explicit blocker:
238
+ do not proceed to Step 5 (commit) until the failed phase is recovered and a
239
+ clean re-run reports success.**
240
+
241
+ When `npx mandrel update` exits non-zero, identify which phase failed (the
242
+ CLI's stderr names it) and run the matching manual remedy from the consumer
243
+ repo root. These commands match the hint strings
244
+ [`lib/cli/update.js`](../../lib/cli/update.js) emits verbatim — it is the
245
+ single source of truth, kept in lockstep with this table by the
246
+ `agents-update-recovery-drift` contract test
247
+ ([`tests/bootstrap/agents-update-recovery-drift.test.js`](../../tests/bootstrap/agents-update-recovery-drift.test.js)):
248
+
249
+ | Failed phase | Manual remedy |
250
+ | ----------------- | ------------------------------------------------------- |
251
+ | **sync** | `npx mandrel sync` |
252
+ | **sync-commands** | `npm run sync:commands` |
253
+ | **migrate** | `npx mandrel migrate --from <cur> --to <target>` |
254
+ | **doctor** | `npx mandrel doctor` (then apply the per-check remedies) |
255
+
256
+ The exact stderr the CLI prints per failed phase — quoted verbatim from
257
+ [`lib/cli/update.js`](../../lib/cli/update.js) so the table above can never
258
+ drift from what the operator actually sees:
259
+
260
+ - **sync** — the .agents/ materialization may be incomplete. Run `mandrel
261
+ sync` manually to restore.
262
+ - **sync-commands** — the .claude/commands/ tree may be out of sync. Run `npm
263
+ run sync:commands` manually to restore.
264
+ - **migrate** — some migrations for v\<cur\> → v\<target\> may not have
265
+ applied. Run `mandrel migrate --from <cur> --to <target>` manually to retry.
266
+ - **doctor** — upgraded to v\<target\> but doctor reported failures. → Run
267
+ `mandrel doctor` for remedies.
268
+
269
+ > **`<cur>` / `<target>`** are the installed and resolved-newest version
270
+ > strings the CLI printed in Step 1 (e.g. `--from 1.44.0 --to 1.46.0`).
271
+ > Substitute the real values the failing run reported.
272
+
273
+ Recovery sequence:
274
+
275
+ 1. **Run the matching remedy** for the failed phase from the table above.
276
+ 2. **Re-run `npx mandrel update`.** It is idempotent — the install already
277
+ landed, so a clean re-run short-circuits the bump and re-drives the
278
+ post-install phases. Repeat the per-phase remedy until the run reports
279
+ `✅ Updated to v<target>. The lockfile bump is staged for review.` (or
280
+ `✅ Already up to date`).
281
+ 3. **Only then proceed** to Step 3. The staged lockfile bump is safe to
282
+ commit (Step 5) once — and only once — the post-install phases have all
283
+ gone green.
284
+
285
+ > **Why not auto-rollback / `mandrel update --resume`?** A `--resume` flag
286
+ > that re-enters the cycle at the failed phase was **evaluated and
287
+ > deferred** (Story #4172, Out of Scope). The per-phase manual remedies
288
+ > above fully cover recovery: each failed phase has an exact, idempotent
289
+ > command, and re-running `npx mandrel update` already short-circuits the
290
+ > completed install and re-drives the remaining phases — so a dedicated
291
+ > resume entrypoint would add a parallel code path without covering any
292
+ > recovery case the manual remedies miss. If a future change makes the
293
+ > phases expensive enough that re-driving completed ones is wasteful,
294
+ > revisit `--resume` then; today it is unnecessary.
295
+
133
296
  ## Step 3 — Reconcile `.agentrc.json` against the new defaults
134
297
 
135
298
  A framework bump can add or reshape fields in
@@ -177,22 +340,18 @@ framework version sees `no-change` everywhere here.
177
340
  Run from the consumer repo root:
178
341
 
179
342
  ```bash
180
- node -e "
181
- Promise.all([
182
- import('./.agents/scripts/lib/bootstrap/quality-bootstrap.js'),
183
- import('./.agents/scripts/lib/bootstrap/baselines-layout-migration.js'),
184
- ]).then(([qb, bm]) => {
185
- const root = process.cwd();
186
- const quality = qb.applyQualityBootstrap({ projectRoot: root });
187
- const baselines = bm.migrateBaselinesLayout({
188
- baselinesDir: require('node:path').join(root, 'baselines'),
189
- repoRoot: root,
190
- });
191
- console.log(JSON.stringify({ quality, baselines }, null, 2));
192
- });
193
- "
343
+ node .agents/scripts/apply-quality-bootstrap.js
194
344
  ```
195
345
 
346
+ The script (Story #4171) replaced the prior inline `node -e` heredoc — a
347
+ shell-fragile, untested block that silently drifted whenever the two helper
348
+ signatures moved (see
349
+ [`apply-quality-bootstrap.js`](../scripts/apply-quality-bootstrap.js)). It
350
+ runs the same two installs in order against `process.cwd()` —
351
+ `applyQualityBootstrap` then `migrateBaselinesLayout` — and prints the same
352
+ `{ quality, baselines }` JSON envelope to stdout. It is idempotent: a second
353
+ run is a no-op beyond reporting `no-change` on every install path.
354
+
196
355
  The four `quality-bootstrap` outcomes:
197
356
 
198
357
  1. **`helper`** — copies
@@ -329,6 +488,14 @@ response."
329
488
 
330
489
  ## Step 5 — Commit the bump
331
490
 
491
+ > **Blocker check before you commit.** The staged lockfile bump is only safe
492
+ > to commit once every post-install phase has gone green. If `npx mandrel
493
+ > update` exited non-zero, you are in a partially-upgraded state — resolve it
494
+ > via [Step 2.5 — Partial-upgrade recovery](#step-25--partial-upgrade-recovery-blocker--resolve-before-step-5)
495
+ > (run the per-phase remedy, re-run the updater to success) **before** running
496
+ > the `git commit` below. Committing over a half-upgrade records a broken
497
+ > state as "done".
498
+
332
499
  `mandrel update` leaves the dependency bump **staged on disk** but never
333
500
  commits. After reviewing the surfaced changelog, any `.agentrc.json`
334
501
  reconciliation diff from Step 3, the `.claude/settings.json` allowlist
@@ -359,7 +526,7 @@ no-op.
359
526
  > distribution `.agents/` is a
360
527
  > materialized directory rebuilt from the installed package — whether the
361
528
  > consumer commits the regenerated `.agents/` tree, or treats it as a
362
- > gitignored install artifact rebuilt by `mandrel sync`, depends on the
529
+ > gitignored install artifact rebuilt by `npx mandrel sync`, depends on the
363
530
  > consumer's own vendoring policy. Stage the `.agents/` / `.claude/`
364
531
  > changes here only if the project commits its materialized tree.
365
532
 
@@ -367,21 +534,31 @@ no-op.
367
534
 
368
535
  - **`doctor reported failures: …`** — the dependency bumped and `.agents/`
369
536
  re-materialized, but a doctor check failed (and the run exited
370
- non-zero). Run `mandrel doctor` for the per-check remedies. The lockfile
371
- bump is already staged; fix the doctor finding (often a missing
372
- bootstrap install Step 3.5 — or a stale `.agentrc.json` — Step 3)
373
- before committing in Step 5.
537
+ non-zero). This is one shape of the **partial-upgrade** failure mode
538
+ the lockfile bump is already staged, so it is a **blocker** you MUST
539
+ resolve before the commit step (see
540
+ [Step 2.5 Partial-upgrade recovery](#step-25--partial-upgrade-recovery-blocker--resolve-before-step-5)).
541
+ Run `npx mandrel doctor` for the per-check remedies; fix the doctor
542
+ finding (often a missing bootstrap install — Step 3.5 — or a stale
543
+ `.agentrc.json` — Step 3), re-run `npx mandrel update` until it reports
544
+ success, and only then commit in Step 5.
545
+
546
+ - **A post-install phase failed (`sync` / `sync-commands` / `migrate`)** —
547
+ the install bumped the lockfile but a later phase exited non-zero, leaving
548
+ a partially-upgraded tree. Do not commit. Run the matching per-phase
549
+ remedy and re-run the updater per
550
+ [Step 2.5 — Partial-upgrade recovery](#step-25--partial-upgrade-recovery-blocker--resolve-before-step-5).
374
551
 
375
552
  - **Install command failed / `npm install … exited <n>`** — the npm
376
553
  install step could not bump the dependency (network hiccup, registry
377
554
  auth gap, or a peer-dependency conflict). Resolve the underlying npm
378
- error and re-run `mandrel update`; it is idempotent — a clean re-run
555
+ error and re-run `npx mandrel update`; it is idempotent — a clean re-run
379
556
  resumes from the resolve step and short-circuits if the install already
380
557
  landed.
381
558
 
382
559
  - **Wrong package manager** — the default install is `npm install`. For a
383
560
  pnpm or yarn workspace, pass the package manager explicitly:
384
- `mandrel update --install-cmd "pnpm add mandrel@<target>"`.
561
+ `npx mandrel update --install-cmd "pnpm add mandrel@<target>"`.
385
562
  The registry probe always stays on `npm view` (a PM-agnostic query); only
386
563
  the install seam honours the override.
387
564
 
package/README.md CHANGED
@@ -69,6 +69,26 @@ explicit `npx mandrel sync` above is the belt-and-suspenders step for
69
69
  `--ignore-scripts` or sandboxed-CI installs. Run `npx mandrel doctor` any
70
70
  time to confirm the install is healthy.
71
71
 
72
+ > **pnpm users — hoist mandrel's runtime deps.** The materialized
73
+ > `./.agents/scripts/*.js` run from your project root and resolve their
74
+ > third-party deps (ajv, js-yaml, …) from your top-level `node_modules`.
75
+ > npm and yarn hoist transitive deps there automatically; pnpm's default
76
+ > isolated layout does **not** — it keeps them in the `.pnpm` virtual store,
77
+ > so the framework scripts (and `mandrel doctor`'s `runtime-deps` check)
78
+ > cannot see them. Add the following to your `.npmrc` before installing:
79
+ >
80
+ > ```ini
81
+ > # Lift mandrel's runtime deps to the top-level node_modules so the
82
+ > # materialized .agents/scripts can resolve them.
83
+ > shamefully-hoist=true
84
+ > ```
85
+ >
86
+ > Prefer a surgical alternative? Replace `shamefully-hoist` with a scoped
87
+ > `public-hoist-pattern[]=` line per package listed in
88
+ > `.agents/runtime-deps.json` (`ajv`, `ajv-formats`, `js-yaml`, `minimatch`,
89
+ > `picomatch`, `string-argv`, `typhonjs-escomplex`). If `mandrel doctor`
90
+ > reports `runtime-deps missing: …`, this is the fix.
91
+
72
92
  `bootstrap.js` is interactive on a TTY and auto-accepts the
73
93
  owner/repo/base branch/operator handle it can infer from your local
74
94
  `git remote` and `git config user.name` — you only get prompted for
package/docs/CHANGELOG.md CHANGED
@@ -2,6 +2,38 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [1.70.0](https://github.com/dsj1984/mandrel/compare/mandrel-v1.69.0...mandrel-v1.70.0) (2026-06-16)
6
+
7
+
8
+ ### Added
9
+
10
+ * **agents-update:** add a first-run preflight before the updater (refs [#4170](https://github.com/dsj1984/mandrel/issues/4170)) ([#4177](https://github.com/dsj1984/mandrel/issues/4177)) ([82d30c6](https://github.com/dsj1984/mandrel/commit/82d30c6877d567d8272e1f433da00797a71bdeb9))
11
+ * **agents-update:** make partial-upgrade recovery a first-class workflow step (refs [#4172](https://github.com/dsj1984/mandrel/issues/4172)) ([#4179](https://github.com/dsj1984/mandrel/issues/4179)) ([9959fd8](https://github.com/dsj1984/mandrel/commit/9959fd8e480067b1c35d25c9e37f8be0908f9a9e))
12
+
13
+
14
+ ### Fixed
15
+
16
+ * /audit-to-stories: fix silent-no-op dedup (GitHubProvider.searchIssues missing) + junk audit:: label derivation ([#4195](https://github.com/dsj1984/mandrel/issues/4195)) ([#4207](https://github.com/dsj1984/mandrel/issues/4207)) ([d4fed9b](https://github.com/dsj1984/mandrel/commit/d4fed9b6a3077c6c9371ec00debae5c422ac89c6))
17
+ * **agents-update:** invoke mandrel via npx and detect install state (refs [#4169](https://github.com/dsj1984/mandrel/issues/4169)) ([#4174](https://github.com/dsj1984/mandrel/issues/4174)) ([9aab7ba](https://github.com/dsj1984/mandrel/commit/9aab7ba3f2c425a5ecaf16e1008078538093a985))
18
+ * **bootstrap:** relax owner/repo under --skip-github so non-interactive init works in a fresh dir ([#4181](https://github.com/dsj1984/mandrel/issues/4181)) ([e560116](https://github.com/dsj1984/mandrel/commit/e560116aa33178f182752558e7d646c2cb7cdeb4))
19
+ * **deps:** bump markdownlint-cli2 0.18.1 -&gt; 0.22.1, force patched js-yaml/markdown-it (refs [#4187](https://github.com/dsj1984/mandrel/issues/4187)) ([#4200](https://github.com/dsj1984/mandrel/issues/4200)) ([9fe1e09](https://github.com/dsj1984/mandrel/commit/9fe1e09daf91fab0ca5d44d5a701cd2576406a5b))
20
+ * **install-matrix:** hoist mandrel runtime deps in pnpm legs so doctor is honest ([#4180](https://github.com/dsj1984/mandrel/issues/4180)) ([5e7aca7](https://github.com/dsj1984/mandrel/commit/5e7aca7d48b9ab9d0ddd5c157a28bea8978fb90a))
21
+
22
+
23
+ ### Performance
24
+
25
+ * **baselines:** collapse the two escomplex passes in full-tree regen into one (refs [#4192](https://github.com/dsj1984/mandrel/issues/4192)) ([#4205](https://github.com/dsj1984/mandrel/issues/4205)) ([4f971c3](https://github.com/dsj1984/mandrel/commit/4f971c36723521058322b346980d6e1822c9d35d))
26
+ * **doctor:** short-circuit payload-drift on statSync size mismatch (refs [#4193](https://github.com/dsj1984/mandrel/issues/4193)) ([#4204](https://github.com/dsj1984/mandrel/issues/4204)) ([a85c050](https://github.com/dsj1984/mandrel/commit/a85c0501a02ff346024fa9d8c9aa48046999f08c))
27
+
28
+
29
+ ### Changed
30
+
31
+ * **agents-update:** extract quality-bootstrap heredoc into a tested script (refs [#4171](https://github.com/dsj1984/mandrel/issues/4171)) ([#4178](https://github.com/dsj1984/mandrel/issues/4178)) ([032ecf7](https://github.com/dsj1984/mandrel/commit/032ecf70f5840ad5291c0bcdb6b11a9d23bec1be))
32
+ * **baselines:** triage dead-export allowlist 198 -&gt; 185 (refs [#4184](https://github.com/dsj1984/mandrel/issues/4184)) ([#4197](https://github.com/dsj1984/mandrel/issues/4197)) ([2a39e61](https://github.com/dsj1984/mandrel/commit/2a39e616bb9a843325bf151d8f057f85a43fcd95))
33
+ * **renderers:** table-drive flat section ladders to lower CC ceilings (refs [#4186](https://github.com/dsj1984/mandrel/issues/4186)) ([#4201](https://github.com/dsj1984/mandrel/issues/4201)) ([36df937](https://github.com/dsj1984/mandrel/commit/36df9370d8e9cdef8f6bc47d0341ef8425314222))
34
+ * **update:** retire No-Shim in-process path and extract pure planUpdate (refs [#4182](https://github.com/dsj1984/mandrel/issues/4182)) ([#4198](https://github.com/dsj1984/mandrel/issues/4198)) ([d5ed325](https://github.com/dsj1984/mandrel/commit/d5ed32560d98fa43d6103789efac530f16244a91))
35
+ * **wave-runner:** decompose tick() into coordinator + phases (refs [#4183](https://github.com/dsj1984/mandrel/issues/4183)) ([#4196](https://github.com/dsj1984/mandrel/issues/4196)) ([0d7a6b4](https://github.com/dsj1984/mandrel/commit/0d7a6b4326b60ec3f2dd58a0798c8a37b6117d9c))
36
+
5
37
  ## [1.69.0](https://github.com/dsj1984/mandrel/compare/mandrel-v1.68.0...mandrel-v1.69.0) (2026-06-16)
6
38
 
7
39