mandrel 1.80.0 → 1.81.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.
@@ -627,6 +627,12 @@ No tier-specific knobs beyond the common shape.
627
627
  | --------- | -------- | ------- | ---------------------------------------------------------------------------------------- |
628
628
  | `bundles` | No | `[]` | Array of `{ name, path, limit }` entries (e.g. `{ "name": "app", "path": "dist/app.js", "limit": "100kB" }`). |
629
629
 
630
+ Unlike `crap` / `maintainability`, this gate has no `refreshTag` config
631
+ field — there is no scorer to regenerate the baseline from source, so the
632
+ one-shot refresh/acknowledge mechanism is an env var, not a config knob.
633
+ See [`.agents/docs/quality-gates.md` § Bundle-size ratchet](quality-gates.md#bundle-size-ratchet--one-shot-refreshacknowledge-story-151)
634
+ for `BUNDLE_SIZE_REFRESH=1` usage.
635
+
630
636
  #### `delivery.quality.formatAutofix`
631
637
 
632
638
  | Field | Required | Default | Purpose |
@@ -791,6 +797,7 @@ the lint ratchet, and the CRAP/MI gates.
791
797
  | `baselines/lint.json` | `lint-baseline.js` | `node .agents/scripts/lint-baseline.js capture` |
792
798
  | `baselines/crap.json` | `update-crap-baseline.js` | `npm run crap:update` |
793
799
  | `baselines/maintainability.json` | `update-maintainability-baseline.js` | `npm run maintainability:update` |
800
+ | `baselines/bundle-size.json` | consumer's own build/measure step | Commit the build's measured sizes; for an intentional growth, run the check with `BUNDLE_SIZE_REFRESH=1` (see [Bundle-size ratchet](quality-gates.md#bundle-size-ratchet--one-shot-refreshacknowledge-story-151)) |
794
801
 
795
802
  These files are the contract. They are read by every gate (Story close, push
796
803
  hook, CI) and are regenerated only via tagged `baseline-refresh:` commits
@@ -452,6 +452,67 @@ watch loop — an unjustified baseline ratchet is no longer caught by CI.
452
452
 
453
453
  ---
454
454
 
455
+ ## Bundle-size ratchet — one-shot refresh/acknowledge (Story #151)
456
+
457
+ > Baseline envelope, axes, and component model: see the
458
+ > [Baseline reference](#baseline-reference) section below.
459
+
460
+ `check-baselines --gate bundle-size` is a **strict** ratchet: it diffs the
461
+ branch's committed `baselines/bundle-size.json` (head) against the base
462
+ ref's copy (`origin/main` by default) using the gate's configured
463
+ `tolerance`, and separately checks the head aggregate against `floors`.
464
+ Unlike `coverage` / `crap` / `maintainability`, bundle-size has **no
465
+ scorer of its own** — the measured `rawKb` / `gzippedKb` numbers come from
466
+ whatever build step the consumer already runs, not a source-tree rescan —
467
+ so there is no `refreshBaseline({ kind: 'bundle-size', ... })` path to
468
+ regenerate a "corrected" baseline the way `npm run crap:update` does.
469
+
470
+ This makes an **intentional** bundle-size growth (a framework major bump,
471
+ a new dependency, an SSR runtime swap) impossible to land cleanly with the
472
+ usual levers: permanently raising `tolerance` in `.agentrc.json` disables
473
+ the ratchet for every *future* PR too, not just the one that legitimately
474
+ grew.
475
+
476
+ ### `BUNDLE_SIZE_REFRESH=1`
477
+
478
+ Set the environment variable for the one CI/local run that needs to land
479
+ the growth:
480
+
481
+ ```bash
482
+ BUNDLE_SIZE_REFRESH=1 npm run bundle-size:check
483
+ # or, calling the dispatcher directly:
484
+ BUNDLE_SIZE_REFRESH=1 node .agents/scripts/check-baselines.js --gate bundle-size
485
+ ```
486
+
487
+ When set (`1` or `true`, case-insensitive), every `bundle-size`
488
+ head-vs-base regression is demoted to `unchanged` **for that invocation
489
+ only** — the gate compares head-vs-head in effect, so it passes even
490
+ though the committed baseline grew. **Floors still apply**: an
491
+ acknowledged PR can still fail if the head aggregate breaches the
492
+ configured `floors` budget, so a genuinely runaway regression isn't
493
+ silently waved through under the guise of "intentional".
494
+
495
+ Commit the regenerated `baselines/bundle-size.json` (reflecting the real,
496
+ larger sizes) in the same PR so the new numbers become the base for the
497
+ *next* PR's diff.
498
+
499
+ ### The ratchet returns to full strength automatically
500
+
501
+ `BUNDLE_SIZE_REFRESH` is read fresh on every invocation and is **never
502
+ persisted** — no config write, no committed tag, no lingering state. The
503
+ very next `check-baselines --gate bundle-size` run (i.e. the next PR),
504
+ without the env var set, re-enforces the ratchet at full strength against
505
+ the now-larger committed baseline. There is nothing to remember to reset.
506
+
507
+ This mirrors the `CRAP_TOLERANCE` env-override precedent (see
508
+ [CRAP gate — Consumer onboarding](#crap-gate--consumer-onboarding) above),
509
+ but as a true one-shot acknowledgment rather than a run-scoped tolerance
510
+ override: `CRAP_TOLERANCE` changes the *threshold*, `BUNDLE_SIZE_REFRESH`
511
+ demotes the *outcome* of an already-flagged regression, which is the
512
+ correct shape for a gate with no rescoring path of its own.
513
+
514
+ ---
515
+
455
516
  ## HITL blocker escalation
456
517
 
457
518
  `risk::high` is informational/planning metadata only. Runtime execution
@@ -82,6 +82,41 @@ export function resolveCrapEnvOverrides(crapConfig, env) {
82
82
  return { newMethodCeiling, tolerance, refreshTag, overrides };
83
83
  }
84
84
 
85
+ /**
86
+ * Pure helper: resolve the one-shot bundle-size refresh/acknowledge flag
87
+ * (Story #151). Unlike `coverage` / `crap` / `maintainability`, the
88
+ * bundle-size gate has no scorer of its own — the measured sizes come from
89
+ * a build step the operator already runs, not a source-tree rescan — so
90
+ * there is no `refreshBaseline({ kind: 'bundle-size', ... })` path to
91
+ * regenerate a "corrected" baseline. Instead, `BUNDLE_SIZE_REFRESH=1`
92
+ * (mirroring `CRAP_TOLERANCE`'s env-override precedent) tells
93
+ * `check-baselines --gate bundle-size` to treat this run's head
94
+ * measurements as the newly acknowledged baseline: head-vs-base
95
+ * regressions are demoted to `unchanged` for this invocation only. Floors
96
+ * still apply — an acknowledged PR can still fail on an absolute budget
97
+ * breach, only the ratchet-vs-`origin/main` comparison is suspended.
98
+ *
99
+ * The flag is **not persisted** anywhere (no config write, no committed
100
+ * tag): the very next `check-baselines` invocation without the env var —
101
+ * i.e. the next PR — reverts to full strict enforcement automatically, so
102
+ * there is no lingering loosened tolerance to remember to reset (AC-3).
103
+ *
104
+ * Accepted truthy values: `1`, `true` (case-insensitive). Anything else
105
+ * (including unset/empty) resolves to `acknowledged: false`.
106
+ *
107
+ * @param {NodeJS.ProcessEnv} env
108
+ * @returns {{ acknowledged: boolean, overrides: string[] }}
109
+ */
110
+ export function resolveBundleSizeEnvOverrides(env) {
111
+ const raw = env?.BUNDLE_SIZE_REFRESH;
112
+ const acknowledged =
113
+ typeof raw === 'string' && /^(1|true)$/i.test(raw.trim());
114
+ const overrides = acknowledged
115
+ ? [`acknowledged=true (BUNDLE_SIZE_REFRESH=${raw})`]
116
+ : [];
117
+ return { acknowledged, overrides };
118
+ }
119
+
85
120
  /**
86
121
  * Pure helper: resolve the effective MI tolerance by layering precedence:
87
122
  * 1. `CRAP_TOLERANCE` env-var (CI override — the baseline-refresh-
@@ -7,8 +7,10 @@
7
7
  * @module lib/orchestration/check-baselines/phases/evaluate
8
8
  */
9
9
 
10
+ import { resolveBundleSizeEnvOverrides } from '../../../baselines/env-overrides.js';
10
11
  import { checkKernelVersion } from '../../../baselines/kernel.js';
11
12
  import * as reader from '../../../baselines/reader.js';
13
+ import { Logger } from '../../../Logger.js';
12
14
  import { applyTolerance, evaluateCompare, runCompareStage } from './compare.js';
13
15
  import { applyFloors, flattenBreaches } from './floors.js';
14
16
 
@@ -22,6 +24,38 @@ function loadHeadBaseline(kind, cwd, configPath) {
22
24
  }
23
25
  }
24
26
 
27
+ /**
28
+ * One-shot bundle-size refresh/acknowledge (Story #151). When
29
+ * `BUNDLE_SIZE_REFRESH=1` is set, demote every `bundle-size` regression to
30
+ * `unchanged` for this run only — floors still apply, so a genuine budget
31
+ * breach is still caught. The flag is read fresh on every invocation and
32
+ * never persisted, so the ratchet returns to full strength automatically on
33
+ * the very next run (no lingering loosened tolerance to remember to reset).
34
+ *
35
+ * No-op for every other kind.
36
+ */
37
+ function applyBundleSizeAcknowledgment(kind, compareOutput, env) {
38
+ if (kind !== 'bundle-size') return { compareOutput, acknowledged: false };
39
+ const { acknowledged, overrides } = resolveBundleSizeEnvOverrides(env);
40
+ if (!acknowledged || compareOutput.regressions.length === 0) {
41
+ return { compareOutput, acknowledged: false };
42
+ }
43
+ Logger.warn(
44
+ `[bundle-size] ⚠ ${overrides.join(', ')} — ` +
45
+ `${compareOutput.regressions.length} regression(s) acknowledged for this run only; ` +
46
+ 'floors still enforced. This does not persist: the next run without ' +
47
+ 'BUNDLE_SIZE_REFRESH re-enforces the ratchet at full strength.',
48
+ );
49
+ return {
50
+ acknowledged: true,
51
+ compareOutput: {
52
+ ...compareOutput,
53
+ regressions: [],
54
+ unchanged: [...compareOutput.unchanged, ...compareOutput.regressions],
55
+ },
56
+ };
57
+ }
58
+
25
59
  function buildGateReport({
26
60
  kind,
27
61
  gateBlock,
@@ -30,6 +64,7 @@ function buildGateReport({
30
64
  breaches,
31
65
  compareOutput,
32
66
  cmp,
67
+ acknowledged,
33
68
  }) {
34
69
  const kernel = checkKernelVersion(kind, baseline.kernelVersion);
35
70
  return {
@@ -50,6 +85,7 @@ function buildGateReport({
50
85
  regressionCount: compareOutput.regressions.length,
51
86
  baseRef: cmp.baseRef ?? null,
52
87
  generatedAt: baseline.generatedAt,
88
+ acknowledged,
53
89
  };
54
90
  }
55
91
 
@@ -59,6 +95,7 @@ export async function evaluateKind({
59
95
  scope,
60
96
  cwd,
61
97
  configPath,
98
+ env = process.env,
62
99
  }) {
63
100
  const headLoad = loadHeadBaseline(kind, cwd, configPath);
64
101
  if (headLoad.schemaError) return { kind, schemaError: headLoad.schemaError };
@@ -67,7 +104,15 @@ export async function evaluateKind({
67
104
  const breaches = flattenBreaches(findings);
68
105
  const cmp = await evaluateCompare({ kind, gateBlock, scope, cwd });
69
106
  const rawCompare = runCompareStage(baseline, cmp);
70
- const compareOutput = applyTolerance(rawCompare, gateBlock.tolerance ?? null);
107
+ const toleratedCompare = applyTolerance(
108
+ rawCompare,
109
+ gateBlock.tolerance ?? null,
110
+ );
111
+ const { compareOutput, acknowledged } = applyBundleSizeAcknowledgment(
112
+ kind,
113
+ toleratedCompare,
114
+ env,
115
+ );
71
116
  return buildGateReport({
72
117
  kind,
73
118
  gateBlock,
@@ -76,5 +121,6 @@ export async function evaluateKind({
76
121
  breaches,
77
122
  compareOutput,
78
123
  cmp,
124
+ acknowledged,
79
125
  });
80
126
  }
@@ -43,6 +43,13 @@ Unified baseline dispatcher. Per-kind pipeline (schema → floor → tolerance
43
43
  compare) over every configured gate, with centralised friction emission and
44
44
  aggregated exit codes.
45
45
 
46
+ Env vars:
47
+ BUNDLE_SIZE_REFRESH=1 One-shot acknowledge for an intentional bundle-size
48
+ growth: demotes bundle-size regressions to
49
+ "unchanged" for this run only (floors still
50
+ enforced). Never persisted — the next run without
51
+ this flag re-enforces the ratchet.
52
+
46
53
  Exit codes:
47
54
  0 every enabled gate passes
48
55
  1 any floor breach
@@ -59,7 +59,7 @@ function dispatchPerKind({ wanted, quality, env, cwd, configPath }) {
59
59
  wanted.map((kind) => {
60
60
  const gateBlock = quality.gates[kind];
61
61
  const scope = resolveDispatchScope({ kind, quality, env });
62
- return evaluateKind({ kind, gateBlock, scope, cwd, configPath });
62
+ return evaluateKind({ kind, gateBlock, scope, cwd, configPath, env });
63
63
  }),
64
64
  );
65
65
  }
@@ -27,7 +27,8 @@ function formatGateLine(g) {
27
27
  ? ''
28
28
  : ` [kernel drift ${g.kernelBaseline} → ${g.kernelCurrent}]`;
29
29
  const baseRef = g.baseRef ? ` [baseRef=${g.baseRef}]` : '';
30
- return ` - ${g.kind}: ${status}${drift}${baseRef}`;
30
+ const ack = g.acknowledged ? ' [ACKNOWLEDGED — this run only]' : '';
31
+ return ` - ${g.kind}: ${status}${drift}${baseRef}${ack}`;
31
32
  }
32
33
 
33
34
  function formatViolationLine(component, v) {
package/docs/CHANGELOG.md CHANGED
@@ -2,6 +2,13 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [1.81.0](https://github.com/dsj1984/mandrel/compare/mandrel-v1.80.0...mandrel-v1.81.0) (2026-07-01)
6
+
7
+
8
+ ### Fixed
9
+
10
+ * **baselines:** add one-shot BUNDLE_SIZE_REFRESH acknowledge for the bundle-size ratchet ([#4309](https://github.com/dsj1984/mandrel/issues/4309)) ([bc466b9](https://github.com/dsj1984/mandrel/commit/bc466b98f6c7521c3a24bbb4d93ee5169c163839))
11
+
5
12
  ## [1.80.0](https://github.com/dsj1984/mandrel/compare/mandrel-v1.79.0...mandrel-v1.80.0) (2026-07-01)
6
13
 
7
14
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mandrel",
3
- "version": "1.80.0",
3
+ "version": "1.81.0",
4
4
  "description": "Claude Code-first opinionated workflow framework: instructions, personas, skills, and SDLC workflows that govern AI coding assistants.",
5
5
  "files": [
6
6
  ".agents/",