mandrel 2.20.0 → 2.21.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/agents/story-worker.md +10 -0
- package/.agents/scripts/deliver-light.js +72 -8
- package/.agents/scripts/lib/config/temp-paths.js +62 -0
- package/.agents/scripts/lib/orchestration/complexity-gate.js +68 -6
- package/.agents/scripts/lib/orchestration/deliver-recover.js +253 -6
- package/.agents/scripts/lib/orchestration/light-suitability.js +194 -11
- package/.agents/scripts/lib/orchestration/single-story-close/gate-log.js +11 -1
- package/.agents/scripts/lib/orchestration/single-story-close/runner.js +1 -1
- package/.agents/scripts/lib/orchestration/story-deliver-terminal.js +117 -4
- package/.agents/scripts/lib/temp-retention.js +23 -8
- package/.agents/scripts/single-story-confirm-merge.js +1 -1
- package/.agents/workflows/helpers/deliver-light.md +45 -5
- package/.agents/workflows/helpers/deliver-story-reference.md +33 -0
- package/.agents/workflows/helpers/deliver-story.md +6 -3
- package/docs/CHANGELOG.md +8 -0
- package/package.json +1 -1
|
@@ -28,6 +28,10 @@
|
|
|
28
28
|
* pre-#4543 pipeline collapsed by treating budget exhaustion as a block.
|
|
29
29
|
*/
|
|
30
30
|
|
|
31
|
+
import nodeFs from 'node:fs';
|
|
32
|
+
import path from 'node:path';
|
|
33
|
+
import { storyTerminalEnvelopePath } from '../config/temp-paths.js';
|
|
34
|
+
import { resolveConfig } from '../config-resolver.js';
|
|
31
35
|
import { validateTerminalEnvelope } from './story-deliver-terminal-schema.js';
|
|
32
36
|
|
|
33
37
|
// Re-exported so the schema split stays an implementation detail: every
|
|
@@ -327,7 +331,95 @@ export const TERMINAL_BEGIN_MARKER = '--- STORY DELIVER TERMINAL ---';
|
|
|
327
331
|
export const TERMINAL_END_MARKER = '--- END TERMINAL ---';
|
|
328
332
|
|
|
329
333
|
/**
|
|
330
|
-
*
|
|
334
|
+
* Resolve the repo config, or `undefined` when it cannot be read.
|
|
335
|
+
*
|
|
336
|
+
* An unreadable `.agentrc.json` must not cost the run its envelope copy: the
|
|
337
|
+
* path helpers fall back to the framework-default temp root, which is still a
|
|
338
|
+
* far better outcome than no artifact at all.
|
|
339
|
+
*
|
|
340
|
+
* @param {typeof resolveConfig} resolveConfigImpl
|
|
341
|
+
* @returns {object|undefined}
|
|
342
|
+
*/
|
|
343
|
+
function tolerantConfig(resolveConfigImpl) {
|
|
344
|
+
try {
|
|
345
|
+
return resolveConfigImpl();
|
|
346
|
+
} catch {
|
|
347
|
+
return undefined;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Persist a terminal envelope beside its Story's gate log (Story #4816).
|
|
353
|
+
*
|
|
354
|
+
* Stdout is a channel with exactly one reader — the turn that launched the
|
|
355
|
+
* close — and that reader is not always still listening. A `story-worker`
|
|
356
|
+
* that reports progress and ends its turn while the close it started is
|
|
357
|
+
* mid-gate-chain is behaving reasonably, but the envelope it never relayed is
|
|
358
|
+
* gone: the router then has to reconstruct the Story's state from labels, and
|
|
359
|
+
* `deliver-recover.js` used to answer that reconstruction with
|
|
360
|
+
* "Implementation never finished" — false, and its re-init suggestion can put
|
|
361
|
+
* a second close on one PR. Observed four times across three workers in one
|
|
362
|
+
* consumer run. The file is the second channel, and it outlives the turn.
|
|
363
|
+
*
|
|
364
|
+
* **Best-effort by construction.** Every failure path returns `null`: a
|
|
365
|
+
* delivery must never turn a landed PR into a crash because a temp directory
|
|
366
|
+
* was unwritable. The stdout envelope above is the contract; this is a copy.
|
|
367
|
+
*
|
|
368
|
+
* **Atomic by construction.** The payload is written to a pid-scoped
|
|
369
|
+
* temporary name and renamed into place, because the reader that matters most
|
|
370
|
+
* is a router polling during a live close — a half-written file would hand it
|
|
371
|
+
* a parse error at exactly the moment it is trying to avoid guessing.
|
|
372
|
+
*
|
|
373
|
+
* A null `storyId` (the `escalated` terminal, which by construction never
|
|
374
|
+
* authored a Story) has nowhere to be filed and writes nothing.
|
|
375
|
+
*
|
|
376
|
+
* `config` is optional and resolved lazily when absent. Two of the emit sites
|
|
377
|
+
* are `catch` blocks that crashed before any config was resolved, and those
|
|
378
|
+
* are precisely the runs whose envelope is most worth keeping — so the temp
|
|
379
|
+
* root is looked up here rather than left at the framework default, which
|
|
380
|
+
* would file the artifact where a consumer's router never looks (and where
|
|
381
|
+
* the retention purge would never reap it).
|
|
382
|
+
*
|
|
383
|
+
* @param {object} envelope A validated terminal envelope.
|
|
384
|
+
* @param {{
|
|
385
|
+
* config?: object,
|
|
386
|
+
* fsImpl?: typeof nodeFs,
|
|
387
|
+
* resolveConfigImpl?: typeof resolveConfig,
|
|
388
|
+
* }} [deps]
|
|
389
|
+
* @returns {string|null} The path written, or `null` when nothing was.
|
|
390
|
+
*/
|
|
391
|
+
export function persistTerminalEnvelope(
|
|
392
|
+
envelope,
|
|
393
|
+
{ config, fsImpl = nodeFs, resolveConfigImpl = resolveConfig } = {},
|
|
394
|
+
) {
|
|
395
|
+
const storyId = envelope?.storyId;
|
|
396
|
+
if (!Number.isInteger(storyId) || storyId <= 0) return null;
|
|
397
|
+
let tmpPath = null;
|
|
398
|
+
try {
|
|
399
|
+
const resolved = config ?? tolerantConfig(resolveConfigImpl);
|
|
400
|
+
const target = storyTerminalEnvelopePath(storyId, resolved);
|
|
401
|
+
fsImpl.mkdirSync(path.dirname(target), { recursive: true });
|
|
402
|
+
tmpPath = `${target}.${process.pid}.tmp`;
|
|
403
|
+
fsImpl.writeFileSync(tmpPath, `${JSON.stringify(envelope)}\n`, 'utf8');
|
|
404
|
+
fsImpl.renameSync(tmpPath, target);
|
|
405
|
+
return target;
|
|
406
|
+
} catch {
|
|
407
|
+
// A rename that never ran leaves the scratch file behind; drop it rather
|
|
408
|
+
// than accumulating one per failed close.
|
|
409
|
+
if (tmpPath) {
|
|
410
|
+
try {
|
|
411
|
+
fsImpl.rmSync(tmpPath, { force: true });
|
|
412
|
+
} catch {
|
|
413
|
+
// Nothing left to try — this whole path is already best-effort.
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
return null;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Write a terminal envelope to stdout, between its markers, and persist a
|
|
422
|
+
* copy to disk.
|
|
331
423
|
*
|
|
332
424
|
* **Deliberately not `Logger.info`.** The envelope is this CLI's
|
|
333
425
|
* machine-readable contract — every invocation emits exactly ONE, and a
|
|
@@ -340,16 +432,37 @@ export const TERMINAL_END_MARKER = '--- END TERMINAL ---';
|
|
|
340
432
|
*
|
|
341
433
|
* Single home for the marker format so the four emit sites (the runner's
|
|
342
434
|
* terminal, the close CLI's failed-terminal catch, and both confirm-CLI
|
|
343
|
-
* paths) cannot drift apart
|
|
435
|
+
* paths) cannot drift apart — and, since Story #4816, the single home for the
|
|
436
|
+
* on-disk copy too, so no emit path can persist and another forget.
|
|
437
|
+
* {@link persistTerminalEnvelope} runs **first**: a caller that has read the
|
|
438
|
+
* markers off stdout can then rely on the file already being there.
|
|
344
439
|
*
|
|
345
440
|
* @param {object} envelope
|
|
346
|
-
* @param {{
|
|
441
|
+
* @param {{
|
|
442
|
+
* write?: (s: string) => void,
|
|
443
|
+
* config?: object,
|
|
444
|
+
* persist?: typeof persistTerminalEnvelope,
|
|
445
|
+
* }} [opts] `write` and `persist` are test seams; `config` resolves the
|
|
446
|
+
* artifact's temp root and is threaded from whichever emit site holds one.
|
|
347
447
|
* @returns {void}
|
|
348
448
|
*/
|
|
349
449
|
export function emitTerminalEnvelope(
|
|
350
450
|
envelope,
|
|
351
|
-
{
|
|
451
|
+
{
|
|
452
|
+
write = (s) => process.stdout.write(s),
|
|
453
|
+
config,
|
|
454
|
+
persist = persistTerminalEnvelope,
|
|
455
|
+
} = {},
|
|
352
456
|
) {
|
|
457
|
+
// Belt and braces around a copy: `persistTerminalEnvelope` already swallows
|
|
458
|
+
// its own failures, but the stdout envelope is the CONTRACT and the disk
|
|
459
|
+
// copy is a convenience. Nothing in the secondary path — including a future
|
|
460
|
+
// injected `persist` — may cost the caller the primary one.
|
|
461
|
+
try {
|
|
462
|
+
persist(envelope, { config });
|
|
463
|
+
} catch {
|
|
464
|
+
// Intentionally silent: see above.
|
|
465
|
+
}
|
|
353
466
|
// Story #4685 — compact (not 2-space pretty) JSON. The envelope is a
|
|
354
467
|
// machine contract callers recover with `JSON.parse`, so pretty-printing
|
|
355
468
|
// only adds turn-resident bytes without helping any consumer.
|
|
@@ -210,28 +210,43 @@ async function makeEntry(fsp, target, className, storyId, keep = false) {
|
|
|
210
210
|
}
|
|
211
211
|
|
|
212
212
|
/**
|
|
213
|
-
*
|
|
214
|
-
*
|
|
215
|
-
*
|
|
213
|
+
* Extensions this class owns inside `orchestration/`. Story #4816 added
|
|
214
|
+
* `.json`: the persisted terminal envelope lands beside the gate log, and a
|
|
215
|
+
* `.log`-only scan would have left one immortal file per delivered Story in a
|
|
216
|
+
* directory the purge otherwise keeps clean.
|
|
217
|
+
*/
|
|
218
|
+
const ORCHESTRATION_EXTENSIONS = Object.freeze(['.log', '.json']);
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Recover the Story id a run-artifact basename carries. Every writer that
|
|
222
|
+
* lands in `orchestration/` ends its name with the scope: `close-gates-4794.log`
|
|
223
|
+
* from the gate sink, `sync-result-story-4794.log` from the terse-result dump,
|
|
224
|
+
* `story-deliver-terminal-4794.json` from the terminal-envelope persist.
|
|
216
225
|
*
|
|
217
226
|
* @param {string} name
|
|
218
227
|
* @returns {number|null}
|
|
219
228
|
*/
|
|
220
229
|
function storyIdFromLogName(name) {
|
|
221
|
-
const match = TRAILING_ID_PATTERN.exec(name.replace(/\.log$/, ''));
|
|
230
|
+
const match = TRAILING_ID_PATTERN.exec(name.replace(/\.(log|json)$/, ''));
|
|
222
231
|
return match ? Number(match[1]) : null;
|
|
223
232
|
}
|
|
224
233
|
|
|
225
234
|
/**
|
|
226
|
-
* `<tempRoot>/orchestration/*.log` — close gate transcripts
|
|
227
|
-
* detail dumps
|
|
228
|
-
*
|
|
235
|
+
* `<tempRoot>/orchestration/*.{log,json}` — close gate transcripts,
|
|
236
|
+
* terse-result detail dumps, and persisted terminal envelopes. An artifact
|
|
237
|
+
* whose name carries no id (there are none today, but the class owns the
|
|
238
|
+
* directory) is age-floored rather than dropped from the class.
|
|
229
239
|
*/
|
|
230
240
|
async function scanOrchestrationLogs(tempRoot, fsp) {
|
|
231
241
|
const dir = path.join(tempRoot, ORCHESTRATION_DIRNAME);
|
|
232
242
|
const entries = [];
|
|
233
243
|
for (const dirent of await safeReaddir(fsp, dir)) {
|
|
234
|
-
if (
|
|
244
|
+
if (
|
|
245
|
+
!dirent.isFile() ||
|
|
246
|
+
!ORCHESTRATION_EXTENSIONS.some((ext) => dirent.name.endsWith(ext))
|
|
247
|
+
) {
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
235
250
|
const entry = await makeEntry(
|
|
236
251
|
fsp,
|
|
237
252
|
path.join(dir, dirent.name),
|
|
@@ -204,7 +204,7 @@ async function logConfirmResult(result, terminal, config) {
|
|
|
204
204
|
status: terminal?.status,
|
|
205
205
|
},
|
|
206
206
|
});
|
|
207
|
-
emitTerminalEnvelope(terminal);
|
|
207
|
+
emitTerminalEnvelope(terminal, { config });
|
|
208
208
|
await emitTerminalFriction({ envelope: terminal, config });
|
|
209
209
|
return { success: terminal.status !== 'failed', result, terminal };
|
|
210
210
|
}
|
|
@@ -49,7 +49,9 @@ multi-capability enumeration). Size is enforced where ground truth is available:
|
|
|
49
49
|
the diff backstop in step 4. Do not talk yourself past that one.
|
|
50
50
|
|
|
51
51
|
Sensitivity is the exception and stays absolute: a footprint touching an auth,
|
|
52
|
-
crypto, billing, or migration class routes `full` however small or mechanical
|
|
52
|
+
crypto, billing, or migration class routes `full` however small or mechanical —
|
|
53
|
+
and unlike a ceiling, it is **not overridable** (§ Recording a proceed-light
|
|
54
|
+
answer).
|
|
53
55
|
|
|
54
56
|
## Four invariants (do not skip one)
|
|
55
57
|
|
|
@@ -58,9 +60,11 @@ crypto, billing, or migration class routes `full` however small or mechanical.
|
|
|
58
60
|
**and** a ledgered model verdict with a recorded reason. Both must agree on
|
|
59
61
|
`lite`.
|
|
60
62
|
2. **Over-scope stops — it never hard-fails.** An over-ceiling prompt STOPS and
|
|
61
|
-
asks the operator to escalate to `/plan` or proceed light.
|
|
62
|
-
|
|
63
|
-
(§
|
|
63
|
+
asks the operator to escalate to `/plan` or proceed light. **Both answers
|
|
64
|
+
are executable** — `--operator-proceed-light` records the second one
|
|
65
|
+
(§ Recording a proceed-light answer). Under `--yes` it fails closed to an
|
|
66
|
+
**`escalated` terminal envelope** that ends the session (§ Escalation is
|
|
67
|
+
terminal).
|
|
64
68
|
3. **Diff-derived backstop.** After implementation the ACTUAL change set is
|
|
65
69
|
re-checked — the diff is the real scope signal — and an over-ceiling diff is
|
|
66
70
|
blocked rather than landed.
|
|
@@ -89,7 +93,10 @@ crypto, billing, or migration class routes `full` however small or mechanical.
|
|
|
89
93
|
`nextCommands`. Continue to step 2.
|
|
90
94
|
- **`ask-operator`** — predicted scope exceeds the light ceilings. STOP and
|
|
91
95
|
ask the operator to escalate to `/plan` or proceed light. Do not proceed
|
|
92
|
-
on your own. This is a **question, not a terminal** — wait for the answer
|
|
96
|
+
on your own. This is a **question, not a terminal** — wait for the answer,
|
|
97
|
+
then act on it: *escalate* leaves for `/plan`, *proceed light* re-runs the
|
|
98
|
+
same command with `--operator-proceed-light "<their reason>"`
|
|
99
|
+
(§ Recording a proceed-light answer).
|
|
93
100
|
- **over-scope under `--yes`** — no `action` to branch on: the gate emits an
|
|
94
101
|
**`escalated` terminal envelope** instead (exit 2). § Escalation is
|
|
95
102
|
terminal governs; you are finished.
|
|
@@ -144,6 +151,39 @@ crypto, billing, or migration class routes `full` however small or mechanical.
|
|
|
144
151
|
[`deliver-digest.md`](deliver-digest.md) § 5 — every close
|
|
145
152
|
gate runs byte-identical to the full path.
|
|
146
153
|
|
|
154
|
+
## Recording a proceed-light answer {#recording-a-proceed-light-answer}
|
|
155
|
+
|
|
156
|
+
The gate offers the operator two options, so **both** have to be executable.
|
|
157
|
+
Re-run the identical gate command with their answer appended:
|
|
158
|
+
|
|
159
|
+
```bash
|
|
160
|
+
node .agents/scripts/deliver-light.js --prompt "<prompt>" … \
|
|
161
|
+
--operator-proceed-light "<the operator's reason, in their words>"
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
The gate then proceeds light, records the decision in the receipt Story, and
|
|
165
|
+
returns it on the envelope's `override`. Do **not** instead re-shape the
|
|
166
|
+
prediction — shrinking `--refactors` until the gate stops objecting is
|
|
167
|
+
under-declaring the footprint, which is the one thing the coarse design must
|
|
168
|
+
not reward.
|
|
169
|
+
|
|
170
|
+
It is deliberately narrow, and a refusal is printed rather than silent:
|
|
171
|
+
|
|
172
|
+
- **Only a size prediction is waivable** — change kinds, magnitude,
|
|
173
|
+
uncertainty, deployable span. A sensitive-path class, a
|
|
174
|
+
migration-with-consumers span, and an unknown footprint (undeclared, glob,
|
|
175
|
+
no acceptance, unclassifiable) are refused: those are risk, not size, and
|
|
176
|
+
§ Scope by effort keeps them absolute.
|
|
177
|
+
- **The ledgered verdict still stands on its own.** The override substitutes
|
|
178
|
+
for the predicted *shape* only; `--route lite --reason "<why>"` is still
|
|
179
|
+
required.
|
|
180
|
+
- **Attended-only.** With `--yes` it is a usage error, not a quiet no-op —
|
|
181
|
+
an unattended run has no operator whose answer this could be, and over-scope
|
|
182
|
+
there still fails closed (§ Escalation is terminal).
|
|
183
|
+
|
|
184
|
+
What licenses this at all is step 4: the operator waives a *guess*, never the
|
|
185
|
+
diff backstop, which re-checks the actual change set against ground truth.
|
|
186
|
+
|
|
147
187
|
## Escalation is terminal {#escalation-is-terminal}
|
|
148
188
|
|
|
149
189
|
Over-scope under `--yes` emits a schema-validated `story-deliver-terminal`
|
|
@@ -745,6 +745,39 @@ unconfirmed merge is a **contract violation** — the parent cannot distinguish
|
|
|
745
745
|
"still working" from "done but silent". `pending` is the honest,
|
|
746
746
|
machine-readable alternative: "not finished, here is exactly how to continue."
|
|
747
747
|
|
|
748
|
+
### The envelope also lands on disk
|
|
749
|
+
|
|
750
|
+
Stdout has exactly one reader — the turn that launched the close — and that
|
|
751
|
+
reader is not always still listening. A child that reports progress and ends
|
|
752
|
+
its turn while its close is mid-gate-chain is behaving reasonably, but the
|
|
753
|
+
envelope it never relayed is gone, and reconstructing the Story's state from
|
|
754
|
+
labels costs a recovery round trip plus a full resume of the child. Observed
|
|
755
|
+
four times across three workers in a single consumer run, on unrelated
|
|
756
|
+
footprints, and not new to that run.
|
|
757
|
+
|
|
758
|
+
So `emitTerminalEnvelope` — the one writer behind every emit site — also
|
|
759
|
+
persists the validated envelope to
|
|
760
|
+
`<tempRoot>/orchestration/story-deliver-terminal-<storyId>.json`:
|
|
761
|
+
|
|
762
|
+
- **It is the same object**, not a summary. Read it and branch exactly as you
|
|
763
|
+
would on stdout; the copy is written before the markers are, so a caller
|
|
764
|
+
that saw them can rely on the file.
|
|
765
|
+
- **It is best-effort.** A failed write returns null and changes nothing about
|
|
766
|
+
the emitted envelope or the exit code — a landed PR must never become a
|
|
767
|
+
crash because a temp directory was unwritable.
|
|
768
|
+
- **It is a fallback, not a licence.** A worker still holds its turn until the
|
|
769
|
+
envelope arrives; see [`agents/story-worker.md`](../../agents/story-worker.md).
|
|
770
|
+
|
|
771
|
+
`deliver-recover.js` reads the same artifact, plus the freshness of
|
|
772
|
+
`close-gates-<storyId>.log`, to split the one genuinely ambiguous row of its
|
|
773
|
+
table. `agent::executing` with no PR used to answer "Implementation never
|
|
774
|
+
finished" — false for the whole duration of a close, whose gates and push
|
|
775
|
+
happen before any PR exists, and actively hazardous, because acting on its
|
|
776
|
+
re-init suggestion can put a second close on one PR. It now answers
|
|
777
|
+
`close-in-flight` (a gate log touched inside the window: wait, then re-probe)
|
|
778
|
+
or `close-envelope-on-disk` (the close already reached a verdict: relay it),
|
|
779
|
+
and falls back to the original verdict only when neither artifact exists.
|
|
780
|
+
|
|
748
781
|
### Exit-code compatibility note (`--no-wait-merge`)
|
|
749
782
|
|
|
750
783
|
Every close flag keeps its meaning, but the **exit code** of a
|
|
@@ -148,10 +148,13 @@ budget is exhausted. Reference § Step 7.
|
|
|
148
148
|
|
|
149
149
|
## Recovering a stranded Story {#recover}
|
|
150
150
|
|
|
151
|
-
|
|
152
|
-
|
|
151
|
+
**Lost envelope first: read it off disk.** Close persists each to
|
|
152
|
+
`temp/orchestration/story-deliver-terminal-<storyId>.json`; branch on it per
|
|
153
|
+
digest § 5. Otherwise (killed run, re-run refusal, merged-but-label-stale)
|
|
154
|
+
do not guess — probe **read-only** with
|
|
153
155
|
`node .agents/scripts/deliver-recover.js --story <storyId>`; it prints the
|
|
154
|
-
**one** next command with its evidence, never a menu.
|
|
156
|
+
**one** next command with its evidence, never a menu. A live close answers
|
|
157
|
+
`close-in-flight`: wait, never re-init underneath it.
|
|
155
158
|
|
|
156
159
|
## Idempotence & constraints
|
|
157
160
|
|
package/docs/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## [2.21.0](https://github.com/dsj1984/mandrel/compare/mandrel-v2.20.0...mandrel-v2.21.0) (2026-07-28)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
### Added
|
|
9
|
+
|
|
10
|
+
* deliver-light: make the gate's `proceed-light` answer representable, attended-only, and auditable ([#4815](https://github.com/dsj1984/mandrel/issues/4815)) ([#4817](https://github.com/dsj1984/mandrel/issues/4817)) ([74aa28e](https://github.com/dsj1984/mandrel/commit/74aa28e57d0192bcad698461d600806a77de092f))
|
|
11
|
+
* persist the close terminal envelope and teach recovery a live-close state (refs [#4816](https://github.com/dsj1984/mandrel/issues/4816)) ([#4819](https://github.com/dsj1984/mandrel/issues/4819)) ([ffa61d8](https://github.com/dsj1984/mandrel/commit/ffa61d8f56f39c33705bfa379cefdcaaeb523115))
|
|
12
|
+
|
|
5
13
|
## [2.20.0](https://github.com/dsj1984/mandrel/compare/mandrel-v2.19.0...mandrel-v2.20.0) (2026-07-27)
|
|
6
14
|
|
|
7
15
|
|
package/package.json
CHANGED