mandrel 1.79.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
@@ -107,6 +107,18 @@ rejected by `pre-push` hooks):
107
107
  4. **Never bypass hooks**: Do not use `--no-verify`, `--no-gpg-sign`, or
108
108
  other hook-skipping flags unless the operator explicitly authorizes it.
109
109
  If a hook fails, investigate the underlying cause.
110
+ - **Known false-negative signature**: a `pre-push`/`pre-commit` failure
111
+ whose message is a _zero-match_ error (e.g. Biome's
112
+ `No files were processed in the specified paths`) rather than a
113
+ reported violation, combined with an agent CWD under a harness-managed
114
+ worktree path a consumer's lint config ignores (e.g.
115
+ `.claude/worktrees/<name>/` against a `files.includes` glob like
116
+ `"!**/.claude"`), is a **consumer-tooling gap**, not a real lint
117
+ failure. It does not authorize `--no-verify`. See
118
+ [`worktree-lifecycle.md` § Harness-worktree ⇄ consumer-lint-ignore interaction](../workflows/helpers/worktree-lifecycle.md#harness-worktree-consumer-lint-ignore-interaction-story-152)
119
+ for the recognition signature and the sanctioned consumer-side fix
120
+ (`--no-errors-on-unmatched` or equivalent) before escalating via
121
+ `agent::blocked`.
110
122
 
111
123
  ## Meta Labels (Retrospective Signal Routing)
112
124
 
@@ -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) {
@@ -260,6 +260,74 @@ Symlink strategy:
260
260
  specific failure up to 3 times with 250/500/1000 ms backoff. Unrelated fetch
261
261
  failures surface immediately — no retry.
262
262
 
263
+ ## Harness-worktree ⇄ consumer-lint-ignore interaction (Story #152)
264
+
265
+ Mandrel's own worktree isolation (above) always roots story worktrees at
266
+ `delivery.worktreeIsolation.root` (default `.worktrees/` at the repo root).
267
+ That path is separate from **the host IDE/CLI harness's own worktree
268
+ mechanism** — for example Claude Code, when it manages an agent session as a
269
+ git worktree, nests it at `.claude/worktrees/<name>/`. A mandrel delivery
270
+ agent can be invoked from *either* location depending on how the operator's
271
+ harness composes with `/deliver`: mandrel's own `.worktrees/story-<id>/` when
272
+ `worktreeIsolation.enabled` drives the checkout, or a harness-level
273
+ `.claude/worktrees/<name>/` when the harness itself provides the isolated
274
+ working directory mandrel runs inside.
275
+
276
+ This matters because a consumer's `pre-push` (or `pre-commit`) lint step is
277
+ commonly configured with an ignore glob that excludes noisy agent-tooling
278
+ directories, e.g. a Biome `files.includes` entry like `"!**/.claude"`. When
279
+ the *agent's CWD itself* resolves under `.claude/worktrees/<name>/`, a
280
+ lint invocation scoped to `.` (`biome check .`, or equivalent) resolves
281
+ every candidate path as living under the ignored `.claude` prefix — the glob
282
+ matches zero files, and tools that treat zero-match as failure (Biome's
283
+ default `check` behavior without `--no-errors-on-unmatched`) exit non-zero
284
+ with something like `No files were processed in the specified paths`. This
285
+ is a **false negative**: the changed files were never actually linted
286
+ against, and the hook is not reporting a real defect. It is functionally
287
+ distinct from a `pre-push` rejection caused by a genuine lint violation, and
288
+ must not be treated the same way.
289
+
290
+ **Do not resolve this by bypassing the push hook.**
291
+ [`rules/git-conventions.md`](../../rules/git-conventions.md) § "Push
292
+ Validation & Reliability" prohibits skipping hooks without explicit operator
293
+ authorization, and that prohibition is not weakened by this interaction —
294
+ the zero-match failure is a **consumer-tooling gap**, not a framework
295
+ authorization the agent gets to grant itself.
296
+
297
+ **Sanctioned resolution path:**
298
+
299
+ 1. **Recognize the signature.** A `pre-push`/`pre-commit` failure whose
300
+ message is a zero-match error (`No files were processed`, `0 files
301
+ matched`, or equivalent for the consumer's linter) — not a reported
302
+ violation in a specific file — combined with an agent CWD under
303
+ `.claude/worktrees/` (or any other harness-managed path a consumer's lint
304
+ config ignores) is this known interaction, not a real lint failure.
305
+ 2. **Fix it in the consumer, not the agent invocation.** The remedy lives in
306
+ the consumer's own lint command, mirroring what its `lint-staged` config
307
+ (if present) likely already does for the same reason: make the zero-match
308
+ case a no-op instead of a failure. For Biome:
309
+ `biome check --no-errors-on-unmatched .`. Other linters have an
310
+ equivalent flag (e.g. ESLint's `--no-error-on-unmatched-pattern`). This is
311
+ a one-line consumer-side change, typically to `.husky/pre-push` or the
312
+ `package.json` script it invokes.
313
+ 3. **Escalate through the normal HITL path**, per
314
+ [`.agents/instructions.md` § 1.J](../../instructions.md), if the agent
315
+ cannot edit the consumer's hook/lint config directly (e.g. it sits outside
316
+ the Story's scope). Transition to `agent::blocked`, name the zero-match
317
+ signature and the one-line remedy in the blocker summary, and let the
318
+ operator apply the consumer-side fix or explicitly authorize a one-time
319
+ hook-skip per [`rules/git-conventions.md`](../../rules/git-conventions.md)
320
+ § "Push Validation & Reliability". Explicit operator authorization is the
321
+ *only* circumstance under which a hook may be skipped — never as an
322
+ agent's unilateral default when this signature is recognized.
323
+ 4. **Do not relocate mandrel's own worktrees to work around a harness-level
324
+ path.** `delivery.worktreeIsolation.root` controls where *mandrel*
325
+ materializes `story-<id>` worktrees (default `.worktrees/`, already
326
+ outside `.claude/`) and is unrelated to where the host harness places its
327
+ own session worktree. Changing `worktreeIsolation.root` does not fix this
328
+ interaction when the false negative originates from the harness's path,
329
+ not mandrel's.
330
+
263
331
  ## Fallback: single-tree mode
264
332
 
265
333
  Set `delivery.worktreeIsolation.enabled: false` (or omit the block) to
package/docs/CHANGELOG.md CHANGED
@@ -2,6 +2,20 @@
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
+
12
+ ## [1.80.0](https://github.com/dsj1984/mandrel/compare/mandrel-v1.79.0...mandrel-v1.80.0) (2026-07-01)
13
+
14
+
15
+ ### Chores
16
+
17
+ * **release:** force a release to ship the [#4306](https://github.com/dsj1984/mandrel/issues/4306) worktree-lifecycle doc fix ([#4307](https://github.com/dsj1984/mandrel/issues/4307)) ([fa54ebb](https://github.com/dsj1984/mandrel/commit/fa54ebb52fc8fe264bab2f4c6dea180973023c28))
18
+
5
19
  ## [1.79.0](https://github.com/dsj1984/mandrel/compare/mandrel-v1.78.0...mandrel-v1.79.0) (2026-06-30)
6
20
 
7
21
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mandrel",
3
- "version": "1.79.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/",