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.
- package/.agents/docs/SDLC.md +1 -1
- package/.agents/scripts/check-doc-links.js +141 -9
- package/.agents/scripts/lib/baselines/env-overrides.js +40 -48
- package/.agents/scripts/lib/orchestration/check-baselines/phases/evaluate.js +51 -77
- package/.agents/scripts/lib/orchestration/check-baselines/phases/parse-args.js +20 -12
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/README.md +2 -1
- package/.agents/workflows/audit-performance.md +2 -2
- package/.agents/workflows/helpers/diagnose.md +1 -1
- package/.agents/workflows/helpers/signals.md +2 -2
- package/.agents/workflows/mandrel-update.md +4 -4
- package/docs/CHANGELOG.md +8 -0
- package/package.json +1 -1
package/.agents/docs/SDLC.md
CHANGED
|
@@ -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](
|
|
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
|
-
|
|
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
|
-
|
|
160
|
-
|
|
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 ?? [
|
|
391
|
-
const
|
|
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
|
|
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:
|
|
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/,
|
|
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
|
-
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
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
|
-
*
|
|
100
|
-
*
|
|
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
|
-
*
|
|
105
|
-
*
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
|
122
|
-
* (Story #
|
|
123
|
-
*
|
|
124
|
-
*
|
|
125
|
-
*
|
|
126
|
-
*
|
|
127
|
-
*
|
|
128
|
-
*
|
|
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
|
-
*
|
|
131
|
-
*
|
|
132
|
-
*
|
|
133
|
-
*
|
|
134
|
-
*
|
|
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)
|
|
138
|
-
*
|
|
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
|
|
144
|
-
const
|
|
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 (
|
|
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
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
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:
|
|
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
|
|
133
|
-
*
|
|
134
|
-
*
|
|
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
|
|
113
|
+
function resolveRefreshTrigger({ kind, gateBlock, cmp, cwd, env }) {
|
|
142
114
|
const reasons = [];
|
|
143
|
-
const { acknowledged: envAck, overrides } =
|
|
144
|
-
|
|
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
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
|
174
|
-
* (env flag OR a `baseline-refresh:`-tagged range commit
|
|
175
|
-
* demote every
|
|
176
|
-
* run only — floors still apply, so a row below its
|
|
177
|
-
* The trigger is read fresh every run and never persisted:
|
|
178
|
-
* refreshed baseline is the new base and the tag leaves 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
|
-
*
|
|
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
|
|
184
|
-
|
|
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
|
-
`[
|
|
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
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
);
|
|
283
|
-
const compareOutput =
|
|
284
|
-
const 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
|
-
|
|
48
|
-
|
|
49
|
-
"unchanged" for this run only
|
|
50
|
-
enforced
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
|
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
|
-
>
|
|
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`](
|
|
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`](
|
|
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`](
|
|
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`](
|
|
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`](
|
|
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`](
|
|
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`](
|
|
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