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.
- package/.agents/README.md +1 -1
- package/.agents/docs/workflows.md +1 -1
- package/.agents/scripts/agents-update-preflight.js +235 -0
- package/.agents/scripts/apply-quality-bootstrap.js +79 -0
- package/.agents/scripts/audit-labels-bootstrap.js +52 -30
- package/.agents/scripts/audit-to-stories.js +54 -0
- package/.agents/scripts/bootstrap.js +13 -3
- package/.agents/scripts/generate-config-docs.js +189 -94
- package/.agents/scripts/lib/audit-suite/findings.js +0 -4
- package/.agents/scripts/lib/audit-to-stories/audit-lenses.js +99 -0
- package/.agents/scripts/lib/audit-to-stories/build-story-body.js +13 -5
- package/.agents/scripts/lib/baseline-snapshot.js +163 -4
- package/.agents/scripts/lib/baselines/refresh-service.js +0 -4
- package/.agents/scripts/lib/config/baselines.js +0 -20
- package/.agents/scripts/lib/config/temp-paths.js +0 -31
- package/.agents/scripts/lib/crap-utils.js +281 -0
- package/.agents/scripts/lib/orchestration/dispatch-engine.js +0 -2
- package/.agents/scripts/lib/orchestration/epic-runner/progress-reporter/composition.js +0 -84
- package/.agents/scripts/lib/orchestration/epic-runner/progress-reporter/signals.js +3 -4
- package/.agents/scripts/lib/orchestration/lifecycle/trace-logger.js +0 -4
- package/.agents/scripts/lib/orchestration/retro/phases/compose-body.js +101 -70
- package/.agents/scripts/lib/orchestration/spec-renderer.js +42 -14
- package/.agents/scripts/lib/orchestration/ticket-lease.js +3 -0
- package/.agents/scripts/lib/story-body/story-body.js +110 -65
- package/.agents/scripts/lib/test-tiers.js +13 -7
- package/.agents/scripts/lib/wave-runner/tick.js +177 -53
- package/.agents/scripts/lib/workers/combined-mi-crap-worker.js +226 -0
- package/.agents/scripts/providers/github/issues.js +48 -0
- package/.agents/scripts/providers/github.js +1 -0
- package/.agents/workflows/agents-update.md +205 -28
- package/README.md +20 -0
- package/docs/CHANGELOG.md +32 -0
- package/lib/cli/registry.js +49 -6
- package/lib/cli/update.js +335 -332
- 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
|
|
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 -
|
|
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).
|
|
371
|
-
bump is already staged
|
|
372
|
-
|
|
373
|
-
|
|
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 -> 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 -> 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
|
|