mandrel 2.18.0 → 2.19.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.
@@ -528,7 +528,7 @@ new CI gate**, route the check through a `package.json` script (add it to
528
528
  transitivity. **When a workflow file genuinely must change** (a new job, a
529
529
  trigger change, a runner bump), the edit must be made by an operator with
530
530
  `Workflows: Read and write` PAT permissions — see
531
- [`docs/release-operations.md` § One-time PAT setup](../../docs/release-operations.md#one-time-pat-setup).
531
+ [`docs/release-operations.md` § One-time PAT setup](https://github.com/dsj1984/mandrel/blob/main/docs/release-operations.md#one-time-pat-setup).
532
532
 
533
533
  ### Worktree config shadow
534
534
 
@@ -25,6 +25,10 @@
25
25
  // a retired token is always a non-zero exit even if a stale workflow
26
26
  // file happens to exist.
27
27
  //
28
+ // 4. Story #4801 — every relative link originating under `.agents/**`
29
+ // resolves to a target that still exists once the tree is materialized
30
+ // into a *consumer* project. See `escapesPayload` for the boundary rule.
31
+ //
28
32
  // Exit codes:
29
33
  // 0 every link and slash-command token resolves cleanly.
30
34
  // 1 at least one violation; details are written to stderr (file:line).
@@ -35,6 +39,8 @@
35
39
  import fs from 'node:fs';
36
40
  import path from 'node:path';
37
41
  import { fileURLToPath } from 'node:url';
42
+ import { minimatch } from 'minimatch';
43
+ import { parseStandardCliArgs } from './lib/cli/standard-args.js';
38
44
  import { runAsCli } from './lib/cli-utils.js';
39
45
  import { Logger } from './lib/Logger.js';
40
46
 
@@ -124,6 +130,63 @@ export const SLASH_ALLOWLIST = new Set([
124
130
  'main',
125
131
  ]);
126
132
 
133
+ // --- Payload boundary (Story #4801) ----------------------------------------
134
+
135
+ // `mandrel sync` materializes ONLY the package's `.agents/` payload into a
136
+ // consumer's project, at `<projectRoot>/.agents` (see `lib/cli/sync.js`:
137
+ // `destRoot = path.join(projectRoot, '.agents')`). `bin/` and `lib/` ship
138
+ // inside the npm tarball but stay under `node_modules/mandrel/`, and the
139
+ // framework's own `tests/`, `docs/` (bar the CHANGELOG) and `.claude/` trees
140
+ // ship nowhere at all. So a relative link that escapes `.agents/` resolves
141
+ // cleanly in THIS repo and dangles in every consumer — which is exactly why
142
+ // the checker cannot catch this class by `fs.existsSync` alone.
143
+ //
144
+ // This is why the boundary is `.agents/` and NOT `package.json#files`: the
145
+ // latter lists `lib/` and `bin/`, which are packaged but never materialized
146
+ // at a consumer's repo root.
147
+ export const MATERIALIZED_ROOT = '.agents';
148
+
149
+ // Repo-root-relative paths OUTSIDE `.agents/` that a Mandrel *consumer*
150
+ // legitimately owns, so a doc under `.agents/**` may still link to them.
151
+ // Deliberately explicit rather than pattern-derived: whether a given repo-root
152
+ // path is consumer-owned or framework-only is a judgment per path, not a rule.
153
+ // A new escaping link fails closed until it is justified and added here.
154
+ export const CONSUMER_OWNED_PATHS = new Set([
155
+ 'package.json',
156
+ '.agentrc.json',
157
+ '.c8rc.cjs',
158
+ 'docs/architecture.md',
159
+ 'docs/decisions.md',
160
+ ]);
161
+
162
+ // Directory prefixes (repo-root-relative, trailing slash) whose whole subtree
163
+ // is consumer-owned.
164
+ export const CONSUMER_OWNED_PREFIXES = Object.freeze(['baselines/']);
165
+
166
+ /**
167
+ * True when `relTarget` is unreachable from a materialized consumer tree.
168
+ *
169
+ * Only links whose SOURCE lives under `.agents/**` are subject to the rule —
170
+ * `docs/**` is framework-repo-only, ships nowhere, and keeps today's
171
+ * existence-only semantics.
172
+ *
173
+ * @param {string} relFile repo-relative POSIX path of the linking document
174
+ * @param {string} relTarget repo-relative POSIX path the link resolves to
175
+ */
176
+ export function escapesPayload(relFile, relTarget) {
177
+ if (!relFile.startsWith(`${MATERIALIZED_ROOT}/`)) return false;
178
+ if (
179
+ relTarget === MATERIALIZED_ROOT ||
180
+ relTarget.startsWith(`${MATERIALIZED_ROOT}/`)
181
+ ) {
182
+ return false;
183
+ }
184
+ if (CONSUMER_OWNED_PATHS.has(relTarget)) return false;
185
+ if (CONSUMER_OWNED_PREFIXES.some((p) => relTarget.startsWith(p)))
186
+ return false;
187
+ return true;
188
+ }
189
+
127
190
  // --- File discovery --------------------------------------------------------
128
191
 
129
192
  function isExcludedRelPath(relPath) {
@@ -150,14 +213,28 @@ function walkMarkdown(dirAbs, repoRoot, out) {
150
213
  }
151
214
  }
152
215
 
153
- export function discoverMarkdown(rootAbs, scanRoots) {
216
+ /**
217
+ * Collect every non-excluded `*.md` under each `scanRoots` entry.
218
+ *
219
+ * @param {string} rootAbs absolute repo root
220
+ * @param {string[]} scanRoots repo-relative subtrees to walk
221
+ * @param {string[]} [exclude] minimatch globs; a repo-relative POSIX path
222
+ * matching any of them is dropped from the scan
223
+ */
224
+ export function discoverMarkdown(rootAbs, scanRoots, exclude = []) {
154
225
  const out = [];
155
226
  for (const sub of scanRoots) {
156
227
  const subAbs = path.join(rootAbs, sub);
157
228
  if (fs.existsSync(subAbs)) walkMarkdown(subAbs, rootAbs, out);
158
229
  }
159
- out.sort();
160
- return out;
230
+ const filtered = exclude.length
231
+ ? out.filter((abs) => {
232
+ const rel = path.relative(rootAbs, abs).split(path.sep).join('/');
233
+ return !exclude.some((g) => minimatch(rel, g, { dot: true }));
234
+ })
235
+ : out;
236
+ filtered.sort();
237
+ return filtered;
161
238
  }
162
239
 
163
240
  // --- Region masking --------------------------------------------------------
@@ -326,6 +403,26 @@ export function checkFile(absPath, repoRoot) {
326
403
  } else {
327
404
  resolved = path.resolve(fileDir, pathOnly);
328
405
  }
406
+ // Payload boundary (Story #4801) takes precedence over existence: a link
407
+ // that escapes the materialized tree is a defect even when the target
408
+ // exists here, and reporting both kinds for one link would double-count.
409
+ const relTarget = path
410
+ .relative(repoRoot, resolved)
411
+ .split(path.sep)
412
+ .join('/');
413
+ if (escapesPayload(relFile, relTarget)) {
414
+ violations.push({
415
+ file: relFile,
416
+ line,
417
+ kind: 'payload-boundary',
418
+ message:
419
+ `link escapes the materialized payload: ${target} → ${relTarget}. ` +
420
+ `Only '${MATERIALIZED_ROOT}/' is materialized into a consumer project, ` +
421
+ 'so this resolves here but dangles for every consumer. Use an absolute ' +
422
+ 'GitHub URL or a non-link code span.',
423
+ });
424
+ continue;
425
+ }
329
426
  if (!fs.existsSync(resolved)) {
330
427
  violations.push({
331
428
  file: relFile,
@@ -377,6 +474,8 @@ export function checkFile(absPath, repoRoot) {
377
474
 
378
475
  // --- Public entry point ----------------------------------------------------
379
476
 
477
+ export const DEFAULT_SCAN_ROOTS = Object.freeze(['docs', '.agents']);
478
+
380
479
  /**
381
480
  * Run the checker programmatically. Returns `{ exitCode, violations }`.
382
481
  * `exitCode` is 0 when every doc is clean, 1 otherwise.
@@ -384,11 +483,13 @@ export function checkFile(absPath, repoRoot) {
384
483
  * @param {object} [options]
385
484
  * @param {string} [options.repoRoot] Defaults to the framework repo root.
386
485
  * @param {string[]} [options.scanRoots] Defaults to `['docs', '.agents']`.
486
+ * @param {string[]} [options.exclude] minimatch globs dropped from the scan.
387
487
  */
388
488
  export function runCheck(options = {}) {
389
489
  const repoRoot = options.repoRoot ?? REPO_ROOT;
390
- const scanRoots = options.scanRoots ?? ['docs', '.agents'];
391
- const files = discoverMarkdown(repoRoot, scanRoots);
490
+ const scanRoots = options.scanRoots ?? [...DEFAULT_SCAN_ROOTS];
491
+ const exclude = options.exclude ?? [];
492
+ const files = discoverMarkdown(repoRoot, scanRoots, exclude);
392
493
  const violations = [];
393
494
  for (const abs of files) {
394
495
  const fileViolations = checkFile(abs, repoRoot);
@@ -405,8 +506,28 @@ function formatViolation(v) {
405
506
  return `${v.file}:${v.line}: [${v.kind}] ${v.message}`;
406
507
  }
407
508
 
509
+ /**
510
+ * Translate argv into `runCheck` options. Repeatable `--scan-root` replaces
511
+ * the default scan set entirely; repeatable `--exclude` filters whatever was
512
+ * scanned. Absent flags reproduce the pre-#4801 defaults exactly.
513
+ */
514
+ export function parseArgs(argv) {
515
+ const { values } = parseStandardCliArgs({
516
+ argv,
517
+ extras: {
518
+ 'scan-root': { type: 'string-multi', alias: 'scanRoot' },
519
+ exclude: { type: 'string-multi', alias: 'exclude' },
520
+ },
521
+ });
522
+ const scanRoots = values.scanRoot?.length
523
+ ? values.scanRoot
524
+ : [...DEFAULT_SCAN_ROOTS];
525
+ return { scanRoots, exclude: values.exclude ?? [] };
526
+ }
527
+
408
528
  async function main() {
409
- const result = runCheck();
529
+ const { scanRoots, exclude } = parseArgs(process.argv.slice(2));
530
+ const result = runCheck({ scanRoots, exclude });
410
531
  if (result.violations.length === 0) {
411
532
  Logger.info(
412
533
  `[check-doc-links] OK — scanned ${result.scanned} active markdown file(s); no violations.`,
@@ -426,11 +547,22 @@ async function main() {
426
547
  runAsCli(import.meta.url, main, {
427
548
  source: 'check-doc-links',
428
549
  usage: {
429
- invocation: 'node .agents/scripts/check-doc-links.js',
550
+ invocation:
551
+ 'node .agents/scripts/check-doc-links.js [--scan-root <path>] [--exclude <glob>]',
430
552
  summary:
431
- 'Validate every relative Markdown link and /slash-command token across docs/ and .agents/, and reject mentions of retired commands.',
432
- flags: [],
553
+ 'Validate every relative Markdown link and /slash-command token across docs/ and .agents/, reject mentions of retired commands, and reject links that escape the materialized .agents/ payload.',
554
+ flags: [
555
+ [
556
+ '--scan-root <path>',
557
+ 'Repeatable. Repo-relative subtree to scan. Replaces the default set (docs, .agents).',
558
+ ],
559
+ [
560
+ '--exclude <glob>',
561
+ 'Repeatable. minimatch glob; matching files are dropped from the scan.',
562
+ ],
563
+ ],
433
564
  notes: [
565
+ 'Consumers materialize only .agents/, so a relative link from .agents/**\nto a framework-repo-only path (tests/, lib/, .claude/, framework docs)\nis reported as a payload-boundary violation even though it resolves here.',
434
566
  'Exit codes:\n 0 every link and command token resolves\n 1 at least one violation (file:line on stderr)',
435
567
  ],
436
568
  },
@@ -83,69 +83,61 @@ export function resolveCrapEnvOverrides(crapConfig, env) {
83
83
  }
84
84
 
85
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.
86
+ * The env var that acknowledges a deliberate baseline refresh for `kind`.
87
+ * Upper-snakes the kind name, so `bundle-size` `BUNDLE_SIZE_REFRESH` and
88
+ * `coverage` `COVERAGE_REFRESH`. The two names that predate the generic
89
+ * mechanism (`BUNDLE_SIZE_REFRESH`, Story #151; `MAINTAINABILITY_REFRESH`,
90
+ * Story #4731) are exactly what this rule produces, so generalizing kept
91
+ * both working unchanged.
98
92
  *
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).
93
+ * Module-local: `resolveKindRefreshOverrides` is the public surface, and the
94
+ * naming rule is pinned through it rather than exported for its own sake.
103
95
  *
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[] }}
96
+ * @param {string} kind
97
+ * @returns {string|null} null when `kind` is not a usable kind name
109
98
  */
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 };
99
+ function kindRefreshEnvVar(kind) {
100
+ if (typeof kind !== 'string' || kind.length === 0) return null;
101
+ return `${kind.toUpperCase().replace(/-/g, '_')}_REFRESH`;
118
102
  }
119
103
 
120
104
  /**
121
- * Pure helper: resolve the one-shot maintainability refresh/acknowledge flag
122
- * (Story #4731). This is the env-parity sibling of
123
- * `resolveBundleSizeEnvOverrides`: `MAINTAINABILITY_REFRESH=1` (or `true`,
124
- * case-insensitive) tells `check-baselines --gate maintainability` to demote
125
- * this run's head-vs-base maintainability regressions to `unchanged` for this
126
- * invocation only. Floors still apply — an acknowledged run can still fail on
127
- * an absolute floor breach (e.g. a row below `min` 70); only the
128
- * ratchet-vs-base regression comparison is suspended.
105
+ * Pure helper: resolve the one-shot baseline refresh/acknowledge flag for any
106
+ * ratcheted kind (Story #4802, generalizing Story #151 / Story #4731).
107
+ *
108
+ * `<KIND>_REFRESH=1` tells `check-baselines --gate <kind>` to demote this
109
+ * run's head-vs-base regressions to `unchanged` for this invocation only.
110
+ * Floors still apply — an acknowledged run can still fail on an absolute
111
+ * floor breach; only the ratchet-vs-base comparison is suspended.
112
+ *
113
+ * Why every kind needs this: a diff-scope baseline is an accretion of many
114
+ * partial runs, not one measurement. Replacing it with a single full-scope
115
+ * measurement necessarily produces row deltas in both directions that are
116
+ * arithmetic, not behavioural — so without an acknowledgment path the gate
117
+ * blocks precisely the correction it should encourage.
129
118
  *
130
- * Unlike bundle-size, maintainability also has a **commit-tagged** trigger
131
- * (a `baseline-refresh:`-tagged commit in the compared range that touches the
132
- * maintainability baseline file) resolved in the evaluate phase this env
133
- * flag is the manual override the two share by shape. Neither is persisted:
134
- * the next run without the flag / tag re-enforces the ratchet at full
135
- * strength automatically.
119
+ * The flag is **not persisted** anywhere (no config write, no committed tag):
120
+ * the very next invocation without the env var reverts to full strict
121
+ * enforcement automatically, so there is no lingering loosened tolerance to
122
+ * remember to reset. The evaluate phase pairs this with a commit-tagged
123
+ * trigger that is likewise one-shot by construction.
136
124
  *
137
- * Accepted truthy values: `1`, `true` (case-insensitive). Anything else
138
- * (including unset/empty) resolves to `acknowledged: false`.
125
+ * Accepted truthy values: `1`, `true` (case-insensitive), with surrounding
126
+ * whitespace trimmed. Anything else — including unset, empty, `0`, `false`,
127
+ * and non-string values — resolves to `acknowledged: false`.
139
128
  *
129
+ * @param {string} kind
140
130
  * @param {NodeJS.ProcessEnv} env
141
131
  * @returns {{ acknowledged: boolean, overrides: string[] }}
142
132
  */
143
- export function resolveMaintainabilityRefreshOverrides(env) {
144
- const raw = env?.MAINTAINABILITY_REFRESH;
133
+ export function resolveKindRefreshOverrides(kind, env) {
134
+ const varName = kindRefreshEnvVar(kind);
135
+ if (!varName) return { acknowledged: false, overrides: [] };
136
+ const raw = env?.[varName];
145
137
  const acknowledged =
146
138
  typeof raw === 'string' && /^(1|true)$/i.test(raw.trim());
147
139
  const overrides = acknowledged
148
- ? [`acknowledged=true (MAINTAINABILITY_REFRESH=${raw})`]
140
+ ? [`acknowledged=true (${varName}=${raw})`]
149
141
  : [];
150
142
  return { acknowledged, overrides };
151
143
  }
@@ -7,10 +7,7 @@
7
7
  * @module lib/orchestration/check-baselines/phases/evaluate
8
8
  */
9
9
 
10
- import {
11
- resolveBundleSizeEnvOverrides,
12
- resolveMaintainabilityRefreshOverrides,
13
- } from '../../../baselines/env-overrides.js';
10
+ import { resolveKindRefreshOverrides } from '../../../baselines/env-overrides.js';
14
11
  import { readRangeSubjectsTouchingFile } from '../../../baselines/git-base.js';
15
12
  import {
16
13
  checkBaselineSemantics,
@@ -91,57 +88,34 @@ function loadHeadBaseline(kind, cwd, configPath) {
91
88
  }
92
89
 
93
90
  /**
94
- * One-shot bundle-size refresh/acknowledge (Story #151). When
95
- * `BUNDLE_SIZE_REFRESH=1` is set, demote every `bundle-size` regression to
96
- * `unchanged` for this run only — floors still apply, so a genuine budget
97
- * breach is still caught. The flag is read fresh on every invocation and
98
- * never persisted, so the ratchet returns to full strength automatically on
99
- * the very next run (no lingering loosened tolerance to remember to reset).
100
- *
101
- * No-op for every other kind.
102
- */
103
- function applyBundleSizeAcknowledgment(kind, compareOutput, env) {
104
- if (kind !== 'bundle-size') return { compareOutput, acknowledged: false };
105
- const { acknowledged, overrides } = resolveBundleSizeEnvOverrides(env);
106
- if (!acknowledged || compareOutput.regressions.length === 0) {
107
- return { compareOutput, acknowledged: false };
108
- }
109
- Logger.warn(
110
- `[bundle-size] ⚠ ${overrides.join(', ')} — ` +
111
- `${compareOutput.regressions.length} regression(s) acknowledged for this run only; ` +
112
- 'floors still enforced. This does not persist: the next run without ' +
113
- 'BUNDLE_SIZE_REFRESH re-enforces the ratchet at full strength.',
114
- );
115
- return {
116
- acknowledged: true,
117
- compareOutput: {
118
- ...compareOutput,
119
- regressions: [],
120
- unchanged: [...compareOutput.unchanged, ...compareOutput.regressions],
121
- },
122
- };
123
- }
124
-
125
- /**
126
- * Resolve the maintainability refresh trigger (Story #4731). Two paths, either
127
- * of which acknowledges — mirroring the bundle-size acknowledge but adding the
128
- * commit-tagged trigger the breach message already documents:
91
+ * Resolve the one-shot refresh trigger for any ratcheted kind (Story #4802,
92
+ * generalizing Story #151's bundle-size env flag and Story #4731's
93
+ * maintainability env-or-commit-tag pair). Two paths, either of which
94
+ * acknowledges:
129
95
  *
130
- * 1. Env parity: `MAINTAINABILITY_REFRESH=1` (the manual override).
96
+ * 1. Env parity: `<KIND>_REFRESH=1` (the manual override) — upper-snaked,
97
+ * so the two pre-existing names (`BUNDLE_SIZE_REFRESH`,
98
+ * `MAINTAINABILITY_REFRESH`) keep working unchanged.
131
99
  * 2. Commit tag: a commit in the compared range `<baseRef>..HEAD` whose
132
- * subject contains the configured `refreshTag` AND whose diff touches the
133
- * maintainability baseline file. One-shot by construction — once merged,
134
- * the refreshed baseline becomes the base and the tag leaves the range.
100
+ * subject contains the configured `refreshTag` AND whose diff touches
101
+ * that kind's baseline file. One-shot by construction — once merged, the
102
+ * refreshed baseline becomes the base and the tag leaves the range.
135
103
  *
136
104
  * The tag is matched as a plain substring of a conventional commit subject, so
137
105
  * commitlint stays satisfied (e.g. `chore(baselines): baseline-refresh: …`).
138
106
  *
107
+ * Fails closed: a kind whose baseline path is neither configured nor present
108
+ * in `DEFAULT_BASELINE_PATHS` simply skips the commit-tag path rather than
109
+ * throwing, leaving the run un-acknowledged.
110
+ *
139
111
  * @returns {{ triggered: boolean, reasons: string[] }}
140
112
  */
141
- function resolveMaintainabilityRefreshTrigger({ gateBlock, cmp, cwd, env }) {
113
+ function resolveRefreshTrigger({ kind, gateBlock, cmp, cwd, env }) {
142
114
  const reasons = [];
143
- const { acknowledged: envAck, overrides } =
144
- resolveMaintainabilityRefreshOverrides(env);
115
+ const { acknowledged: envAck, overrides } = resolveKindRefreshOverrides(
116
+ kind,
117
+ env,
118
+ );
145
119
  if (envAck) reasons.push(...overrides);
146
120
 
147
121
  const baseRef = cmp?.baseRef ?? null;
@@ -154,15 +128,17 @@ function resolveMaintainabilityRefreshTrigger({ gateBlock, cmp, cwd, env }) {
154
128
  typeof gateBlock?.baselinePath === 'string' &&
155
129
  gateBlock.baselinePath.length
156
130
  ? gateBlock.baselinePath
157
- : DEFAULT_BASELINE_PATHS.maintainability;
158
- const subjects = readRangeSubjectsTouchingFile(baseRef, baselinePath, {
159
- cwd,
160
- });
161
- const match = subjects.find((s) => s.includes(refreshTag));
162
- if (match) {
163
- reasons.push(
164
- `refresh commit "${match}" (subject contains ${JSON.stringify(refreshTag)}, touches ${baselinePath})`,
165
- );
131
+ : DEFAULT_BASELINE_PATHS[kind];
132
+ if (typeof baselinePath === 'string' && baselinePath.length) {
133
+ const subjects = readRangeSubjectsTouchingFile(baseRef, baselinePath, {
134
+ cwd,
135
+ });
136
+ const match = subjects.find((s) => s.includes(refreshTag));
137
+ if (match) {
138
+ reasons.push(
139
+ `refresh commit "${match}" (subject contains ${JSON.stringify(refreshTag)}, touches ${baselinePath})`,
140
+ );
141
+ }
166
142
  }
167
143
  }
168
144
 
@@ -170,26 +146,24 @@ function resolveMaintainabilityRefreshTrigger({ gateBlock, cmp, cwd, env }) {
170
146
  }
171
147
 
172
148
  /**
173
- * One-shot maintainability refresh/acknowledge (Story #4731). When triggered
174
- * (env flag OR a `baseline-refresh:`-tagged range commit touching the baseline),
175
- * demote every maintainability head-vs-base regression to `unchanged` for this
176
- * run only — floors still apply, so a row below its `min` floor still breaches.
177
- * The trigger is read fresh every run and never persisted: post-merge the
178
- * refreshed baseline is the new base and the tag leaves the range, so the
179
- * ratchet returns to full strength automatically.
149
+ * One-shot baseline refresh/acknowledge for any ratcheted kind (Story #4802).
150
+ * When triggered (env flag OR a `baseline-refresh:`-tagged range commit
151
+ * touching that kind's baseline), demote every head-vs-base regression to
152
+ * `unchanged` for this run only — floors still apply, so a row below its floor
153
+ * still breaches. The trigger is read fresh every run and never persisted:
154
+ * post-merge the refreshed baseline is the new base and the tag leaves the
155
+ * range, so the ratchet returns to full strength automatically.
180
156
  *
181
- * No-op for every other kind.
157
+ * A no-op absent a trigger, so an unacknowledged run of any kind reports its
158
+ * regressions exactly as before.
182
159
  */
183
- function applyMaintainabilityAcknowledgment(kind, compareOutput, ctx) {
184
- if (kind !== 'maintainability') {
185
- return { compareOutput, acknowledged: false };
186
- }
187
- const { triggered, reasons } = resolveMaintainabilityRefreshTrigger(ctx);
160
+ function applyRefreshAcknowledgment(kind, compareOutput, ctx) {
161
+ const { triggered, reasons } = resolveRefreshTrigger({ ...ctx, kind });
188
162
  if (!triggered || compareOutput.regressions.length === 0) {
189
163
  return { compareOutput, acknowledged: false };
190
164
  }
191
165
  Logger.warn(
192
- `[maintainability] ⚠ ${reasons.join('; ')} — ` +
166
+ `[${kind}] ⚠ ${reasons.join('; ')} — ` +
193
167
  `${compareOutput.regressions.length} regression(s) acknowledged for this run only; ` +
194
168
  'floors still enforced. This does not persist: once the refresh is the ' +
195
169
  'new base the ratchet re-enforces at full strength.',
@@ -274,14 +248,14 @@ export async function evaluateKind({
274
248
  rawCompare,
275
249
  gateBlock.tolerance ?? null,
276
250
  );
277
- const bundleAck = applyBundleSizeAcknowledgment(kind, toleratedCompare, env);
278
- const miAck = applyMaintainabilityAcknowledgment(
279
- kind,
280
- bundleAck.compareOutput,
281
- { gateBlock, cmp, cwd, env },
282
- );
283
- const compareOutput = miAck.compareOutput;
284
- const acknowledged = bundleAck.acknowledged || miAck.acknowledged;
251
+ const ack = applyRefreshAcknowledgment(kind, toleratedCompare, {
252
+ gateBlock,
253
+ cmp,
254
+ cwd,
255
+ env,
256
+ });
257
+ const compareOutput = ack.compareOutput;
258
+ const acknowledged = ack.acknowledged;
285
259
  return buildGateReport({
286
260
  kind,
287
261
  gateBlock,
@@ -44,18 +44,26 @@ compare) over every configured gate, with centralised friction emission and
44
44
  aggregated exit codes.
45
45
 
46
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
- MAINTAINABILITY_REFRESH=1
53
- One-shot acknowledge for a deliberate maintainability
54
- baseline refresh: demotes maintainability head-vs-base
55
- regressions to "unchanged" for this run only (floors
56
- still enforced). Env-parity override for the
57
- 'baseline-refresh:'-tagged range commit that touches
58
- baselines/maintainability.json. Never persisted.
47
+ <KIND>_REFRESH=1 One-shot acknowledge for a deliberate baseline
48
+ refresh of that kind: demotes its head-vs-base
49
+ regressions to "unchanged" for this run only. Floors
50
+ are STILL enforced, so a genuine breach is still
51
+ caught. The kind name is upper-snaked, e.g.
52
+ COVERAGE_REFRESH, CRAP_REFRESH, DUPLICATION_REFRESH,
53
+ MAINTAINABILITY_REFRESH, BUNDLE_SIZE_REFRESH.
54
+ Never persisted the next run without the flag
55
+ re-enforces the ratchet at full strength.
56
+
57
+ Equivalent commit-tagged trigger: a commit in the
58
+ compared range whose subject contains the gate's
59
+ 'refreshTag' (default 'baseline-refresh:') AND whose
60
+ diff touches that kind's baseline file. One-shot by
61
+ construction — once merged the refreshed baseline is
62
+ the new base and the tag leaves the range.
63
+
64
+ Use these when replacing a diff-scope baseline with a
65
+ full-scope measurement: the resulting row deltas are
66
+ arithmetic, not behavioural.
59
67
 
60
68
  Exit codes:
61
69
  0 every enabled gate passes
@@ -2,7 +2,8 @@
2
2
 
3
3
  Each listener in this directory subscribes to one or more lifecycle bus
4
4
  events and performs a single side effect. The full close-tail roster and
5
- event taxonomy live in [`docs/LIFECYCLE.md`](../../../../../../docs/LIFECYCLE.md)
5
+ event taxonomy live in
6
+ [`docs/LIFECYCLE.md`](https://github.com/dsj1984/mandrel/blob/main/docs/LIFECYCLE.md)
6
7
  — that document is the SSOT. This README only indexes the **files that still
7
8
  live in this folder**.
8
9
 
@@ -39,8 +39,8 @@ Sequential inline execution is the fallback (see the core's Execution strategy).
39
39
  > orchestrated path grants its measurement agents a `Bash` tool restricted to a
40
40
  > **non-mutating command allowlist** (profilers, timers, bundle-stat and
41
41
  > file-size probes — never a command that writes source, installs, or mutates
42
- > git/labels). See the allowlist in
43
- > [`../../.claude/workflows/audit-performance.workflow.js`](../../.claude/workflows/audit-performance.workflow.js).
42
+ > git/labels). See the allowlist in the harness-generated
43
+ > `.claude/workflows/audit-performance.workflow.js`.
44
44
 
45
45
  ## Step 0: Measure before you judge (mandatory)
46
46
 
@@ -113,5 +113,5 @@ pipelines and CI-step parsing.
113
113
  — the canonical contract every check module must satisfy.
114
114
  - [`.agents/scripts/diagnose.js`](../../scripts/diagnose.js) — the CLI
115
115
  implementation backing this helper.
116
- - [`tests/diagnose-output.test.js`](../../../tests/diagnose-output.test.js)
116
+ - [`tests/diagnose-output.test.js`](https://github.com/dsj1984/mandrel/blob/main/tests/diagnose-output.test.js)
117
117
  — pinned output and exit-code contracts.
@@ -106,7 +106,7 @@ node .agents/scripts/signals-view.js 9999
106
106
  CLI implementation backing this helper.
107
107
  - [`.agents/scripts/lib/signals/`](../../scripts/lib/signals/) — the
108
108
  shared reader + schema + span-tree barrel.
109
- - [`tests/signals-view.test.js`](../../../tests/signals-view.test.js) —
109
+ - [`tests/signals-view.test.js`](https://github.com/dsj1984/mandrel/blob/main/tests/signals-view.test.js) —
110
110
  pinned output and tempRoot-honour contracts.
111
- - [`tests/lib/signals/span-tree.test.js`](../../../tests/lib/signals/span-tree.test.js) —
111
+ - [`tests/lib/signals/span-tree.test.js`](https://github.com/dsj1984/mandrel/blob/main/tests/lib/signals/span-tree.test.js) —
112
112
  pure-function contract for the span-tree builder.
@@ -13,7 +13,7 @@ description: >-
13
13
  # /mandrel-update
14
14
 
15
15
  > **Upgrade owner.** The mechanical upgrade is owned end to end by the
16
- > [`mandrel update`](../../lib/cli/update.js) CLI under the npm distribution
16
+ > [`mandrel update`](https://github.com/dsj1984/mandrel/blob/main/lib/cli/update.js) CLI under the npm distribution
17
17
  > model. This workflow wraps that CLI: it runs
18
18
  > `npx mandrel update`, then walks the operator through the
19
19
  > **distribution-agnostic judgment steps** the CLI deliberately does **not**
@@ -66,7 +66,7 @@ envelope (`{ ok, blocked, findings[] }`) plus a human-readable report:
66
66
  the version probe.
67
67
 
68
68
  The preflight is a workflow-layer guard; it deliberately lives outside
69
- [`lib/cli/update.js`](../../lib/cli/update.js), which stays git-free.
69
+ [`lib/cli/update.js`](https://github.com/dsj1984/mandrel/blob/main/lib/cli/update.js), which stays git-free.
70
70
 
71
71
  ## Step 1 — Run the updater
72
72
 
@@ -102,9 +102,9 @@ recovered and a clean re-run reports success.**
102
102
 
103
103
  Identify the failed phase (the CLI's stderr names it) and run the matching
104
104
  remedy. These commands match the hint strings
105
- [`lib/cli/update.js`](../../lib/cli/update.js) emits verbatim — it is the
105
+ [`lib/cli/update.js`](https://github.com/dsj1984/mandrel/blob/main/lib/cli/update.js) emits verbatim — it is the
106
106
  single source of truth, kept in lockstep with this table by
107
- [`tests/bootstrap/mandrel-update-recovery-drift.test.js`](../../tests/bootstrap/mandrel-update-recovery-drift.test.js):
107
+ [`tests/bootstrap/mandrel-update-recovery-drift.test.js`](https://github.com/dsj1984/mandrel/blob/main/tests/bootstrap/mandrel-update-recovery-drift.test.js):
108
108
 
109
109
  | Failed phase | Manual remedy |
110
110
  | ----------------- | ------------------------------------------------------- |
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.19.0](https://github.com/dsj1984/mandrel/compare/mandrel-v2.18.0...mandrel-v2.19.0) (2026-07-27)
6
+
7
+
8
+ ### Added
9
+
10
+ * check-baselines: generalize the one-shot baseline-refresh acknowledgment across every ratcheted kind ([#4802](https://github.com/dsj1984/mandrel/issues/4802)) ([#4805](https://github.com/dsj1984/mandrel/issues/4805)) ([ec9b066](https://github.com/dsj1984/mandrel/commit/ec9b066a5d4f74eb53f88ab347138b3434ffa47b))
11
+ * **doc-links:** reject links escaping the materialized .agents payload (refs [#4801](https://github.com/dsj1984/mandrel/issues/4801)) ([#4803](https://github.com/dsj1984/mandrel/issues/4803)) ([97a383a](https://github.com/dsj1984/mandrel/commit/97a383a4765a9a617648f6c1b422c17d837b6ad8))
12
+
5
13
  ## [2.18.0](https://github.com/dsj1984/mandrel/compare/mandrel-v2.17.0...mandrel-v2.18.0) (2026-07-26)
6
14
 
7
15
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mandrel",
3
- "version": "2.18.0",
3
+ "version": "2.19.0",
4
4
  "description": "Claude Code-first opinionated workflow framework: instructions, skills, rules, and SDLC workflows that govern AI coding assistants.",
5
5
  "files": [
6
6
  ".agents/",