mandrel 2.52.0 → 2.54.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.
Files changed (27) hide show
  1. package/.agents/agents/story-worker.md +4 -5
  2. package/.agents/docs/agentrc-reference.json +3 -1
  3. package/.agents/docs/configuration.md +2 -0
  4. package/.agents/schemas/agentrc.schema.json +15 -0
  5. package/.agents/scripts/check-audit-attribution.js +245 -0
  6. package/.agents/scripts/check-pinned-override-notes.js +102 -0
  7. package/.agents/scripts/coverage-capture.js +36 -21
  8. package/.agents/scripts/lib/audit-attribution.js +112 -0
  9. package/.agents/scripts/lib/close-validation/commands.js +27 -1
  10. package/.agents/scripts/lib/close-validation/gates.js +33 -14
  11. package/.agents/scripts/lib/config/commands.js +14 -12
  12. package/.agents/scripts/lib/config-settings-schema-delivery.js +6 -0
  13. package/.agents/scripts/lib/config-settings-schema.js +9 -2
  14. package/.agents/scripts/lib/coverage-capture.js +70 -0
  15. package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
  16. package/.agents/scripts/lib/observability/source-classifier.js +2 -0
  17. package/.agents/scripts/lib/orchestration/epic-container.js +27 -0
  18. package/.agents/scripts/lib/orchestration/epic-rollup.js +23 -4
  19. package/.agents/scripts/lib/orchestration/plan-persist/supersede-ops.js +56 -6
  20. package/.agents/scripts/lib/orchestration/ticketing/bulk.js +40 -6
  21. package/.agents/scripts/lib/pinned-override-notes.js +100 -0
  22. package/.agents/scripts/resolve-stories.js +9 -3
  23. package/.agents/workflows/helpers/deliver-digest.md +4 -6
  24. package/.agents/workflows/helpers/deliver-reference.md +5 -2
  25. package/.agents/workflows/helpers/plan-reference.md +1 -1
  26. package/docs/CHANGELOG.md +20 -0
  27. package/package.json +5 -5
@@ -2,7 +2,7 @@
2
2
  * close-validation/commands.js — Command resolution + formatter file policy.
3
3
  *
4
4
  * Owns the `project.commands.*` resolution helpers used by the close-
5
- * validation gates (typecheck / formatCheck / formatWrite), the Story-diff
5
+ * validation gates (typecheck / lint / formatCheck / formatWrite), the Story-diff
6
6
  * changed-file listing for the format gate, and the formatter
7
7
  * file-eligibility policy (Story #3410).
8
8
  */
@@ -17,6 +17,15 @@ import { getCommands } from '../config/commands.js';
17
17
  */
18
18
  const TYPECHECK_FALLBACK = 'npm run typecheck';
19
19
 
20
+ /**
21
+ * Fallback lint command. The gate is mandatory, and this string is the exact
22
+ * command the gate spawned before `project.commands.lint` existed — an
23
+ * unconfigured consumer must keep getting byte-identical argv, because the
24
+ * gate's `commandConfigHash` is computed over it and any drift would silently
25
+ * invalidate every previously recorded lint evidence record.
26
+ */
27
+ const LINT_FALLBACK = 'npm run lint';
28
+
20
29
  /** Default formatter command when `project.commands.formatCheck` is unset. */
21
30
  export const FORMAT_CHECK_FALLBACK = 'npx biome format .';
22
31
 
@@ -72,6 +81,23 @@ export function resolveTypecheckCommand(config) {
72
81
  return resolveCommandWithFallback(config, 'typecheck', TYPECHECK_FALLBACK);
73
82
  }
74
83
 
84
+ /**
85
+ * Resolve the lint command. Reads `project.commands.lint`; falls back to
86
+ * `npm run lint`. The framework-wide `COMMANDS_DEFAULTS.lint` is `null` — as
87
+ * for typecheck — because the fallback belongs to this mandatory gate rather
88
+ * than to the shared accessor.
89
+ *
90
+ * A consumer sets this to point the gate at the scoped pair its hooks already
91
+ * run: the close-time gate stays real (the diff is still linted) while CI
92
+ * keeps owning whole-repo drift. Exported for testing.
93
+ *
94
+ * @param {{ project?: { commands?: object } } | null | undefined} config
95
+ * @returns {string}
96
+ */
97
+ export function resolveLintCommand(config) {
98
+ return resolveCommandWithFallback(config, 'lint', LINT_FALLBACK);
99
+ }
100
+
75
101
  /**
76
102
  * Resolve the format-check command. Reads `project.commands.formatCheck`;
77
103
  * falls back to `npx biome format .` so existing repos keep working byte-
@@ -17,6 +17,7 @@ import {
17
17
  FORMAT_CHECK_FALLBACK,
18
18
  resolveFormatCheckCommand,
19
19
  resolveFormatWriteCommand,
20
+ resolveLintCommand,
20
21
  resolveTypecheckCommand,
21
22
  } from './commands.js';
22
23
 
@@ -335,6 +336,20 @@ function buildBaselinesGateEntries({ decision, kinds, env }) {
335
336
  ];
336
337
  }
337
338
 
339
+ /**
340
+ * Split a resolved command string into the `{ cmd, args }` pair a gate entry
341
+ * carries. Whitespace-separated, so `npm run lint` yields the argv the gate
342
+ * spawned before any of these commands were configurable — which is what
343
+ * keeps an unconfigured consumer's `commandConfigHash` unchanged.
344
+ *
345
+ * @param {string} commandString
346
+ * @returns {{ cmd: string, args: string[] }}
347
+ */
348
+ function splitCommand(commandString) {
349
+ const [cmd, ...args] = commandString.split(/\s+/).filter(Boolean);
350
+ return { cmd, args };
351
+ }
352
+
338
353
  /**
339
354
  * Build the canonical close-validation gate list.
340
355
  *
@@ -347,8 +362,12 @@ function buildBaselinesGateEntries({ decision, kinds, env }) {
347
362
  * absent, coverage-capture is dropped and the `test` gate is restored so
348
363
  * there is always a working test gate.
349
364
  *
350
- * `typecheck` is mandatory; consumers may customise the command via
351
- * `project.commands.typecheck` (default `npm run typecheck`).
365
+ * `typecheck` and `lint` are mandatory; consumers may customise either
366
+ * command via `project.commands.typecheck` / `project.commands.lint`
367
+ * (defaults `npm run typecheck` / `npm run lint`). Customising `lint` is how
368
+ * a consumer whose hooks and CI already lint the diff stops paying for a
369
+ * third whole-repo pass at close: `lint` runs in the parallel partition, so
370
+ * a slow whole-repo lint sets the floor for that whole phase.
352
371
  *
353
372
  * Story #2210 retired the legacy per-kind in-process regression gates
354
373
  * (`check-maintainability`, `check-crap`, `check-mutation`) and their
@@ -409,14 +428,10 @@ export function buildDefaultGates({
409
428
  const scripts = packageScripts ?? readPackageScripts(cwd);
410
429
  const coverageCaptureActive =
411
430
  isCrapGateEnabled(config) && hasNpmScript(scripts, 'test:coverage');
412
- const typecheckCmdString = resolveTypecheckCommand(config);
413
- const [typecheckCmd, ...typecheckArgs] = typecheckCmdString
414
- .split(/\s+/)
415
- .filter(Boolean);
431
+ const typecheck = splitCommand(resolveTypecheckCommand(config));
432
+ const lint = splitCommand(resolveLintCommand(config));
416
433
  const formatCheckString = resolveFormatCheckCommand(config);
417
- const [formatCmd, ...formatArgs] = formatCheckString
418
- .split(/\s+/)
419
- .filter(Boolean);
434
+ const format = splitCommand(formatCheckString);
420
435
  const formatWriteString = resolveFormatWriteCommand(config);
421
436
  const formatChangedFileScope =
422
437
  formatCheckString === FORMAT_CHECK_FALLBACK
@@ -436,11 +451,15 @@ export function buildDefaultGates({
436
451
  return [
437
452
  {
438
453
  name: 'typecheck',
439
- cmd: typecheckCmd,
440
- args: typecheckArgs,
454
+ cmd: typecheck.cmd,
455
+ args: typecheck.args,
441
456
  hint: TYPECHECK_HINT,
442
457
  },
443
- { name: 'lint', cmd: 'npm', args: ['run', 'lint'] },
458
+ // Gate name kept generic ("lint") for the same reason the format gate's
459
+ // is: the command resolves from config, so a consumer pointing it at a
460
+ // scoped pair does not shift the close-orchestrator log line, the
461
+ // evidence keyspace, or the parallel-partition membership below.
462
+ { name: 'lint', cmd: lint.cmd, args: lint.args },
444
463
  ...buildTestGateEntry(coverageCaptureActive),
445
464
  {
446
465
  // Gate name kept generic ("format") so the close-orchestrator log line
@@ -448,8 +467,8 @@ export function buildDefaultGates({
448
467
  // `project.commands.formatCheck`. The
449
468
  // actual command and the remediation hint resolve from config.
450
469
  name: 'format',
451
- cmd: formatCmd,
452
- args: formatArgs,
470
+ cmd: format.cmd,
471
+ args: format.args,
453
472
  hint: buildFormatHint(formatWriteString),
454
473
  ...(formatChangedFileScope
455
474
  ? { changedFileScope: formatChangedFileScope }
@@ -1,13 +1,14 @@
1
1
  /**
2
2
  * `project.commands` accessor (Epic #1720 Story #1739 — top-level reshape).
3
3
  *
4
- * The surviving four command keys are `test`, `typecheck`,
5
- * `formatCheck`, `formatWrite`.
4
+ * The command keys are `test`, `typecheck`, `lint`, `formatCheck`,
5
+ * `formatWrite`.
6
6
  */
7
7
 
8
8
  export const COMMANDS_DEFAULTS = Object.freeze({
9
9
  test: 'npm test',
10
10
  typecheck: null,
11
+ lint: null,
11
12
  formatCheck: 'npx biome format .',
12
13
  formatWrite: 'npx biome format --write .',
13
14
  });
@@ -18,17 +19,18 @@ export const COMMANDS_DEFAULTS = Object.freeze({
18
19
  * a bare `{ project }` bag.
19
20
  *
20
21
  * @param {object | null | undefined} config
21
- * @returns {{ test: string, typecheck: string|null, formatCheck: string, formatWrite: string }}
22
+ * @returns {{ test: string, typecheck: string|null, lint: string|null, formatCheck: string, formatWrite: string }}
22
23
  */
23
24
  export function getCommands(config) {
24
25
  const commands = config?.project?.commands ?? {};
25
- return {
26
- test: commands.test ?? COMMANDS_DEFAULTS.test,
27
- typecheck:
28
- commands.typecheck === undefined
29
- ? COMMANDS_DEFAULTS.typecheck
30
- : commands.typecheck,
31
- formatCheck: commands.formatCheck ?? COMMANDS_DEFAULTS.formatCheck,
32
- formatWrite: commands.formatWrite ?? COMMANDS_DEFAULTS.formatWrite,
33
- };
26
+ // `??` is uniform across every key: the two nullable ones (`typecheck`,
27
+ // `lint`) default to `null` themselves, so coalescing an explicit `null`
28
+ // onto the default returns that same `null` — the per-key `=== undefined`
29
+ // ladder this replaces drew a distinction that had no observable effect.
30
+ return Object.fromEntries(
31
+ Object.keys(COMMANDS_DEFAULTS).map((key) => [
32
+ key,
33
+ commands[key] ?? COMMANDS_DEFAULTS[key],
34
+ ]),
35
+ );
34
36
  }
@@ -45,6 +45,12 @@ const EXECUTION_SCHEMA = {
45
45
  'Serialize full-suite spawns (`npm test` / `npm run test:coverage`) behind a host-level advisory lock, so two concurrent deliveries on one checkout do not run two suites against the same cores. Best-effort: a wait that expires spawns anyway, so the lock can never fail a delivery. Set false — or export `MANDREL_FULL_SUITE_LOCK=0` for one invocation — to disable.',
46
46
  default: true,
47
47
  },
48
+ requireCreditedCapture: {
49
+ type: 'boolean',
50
+ description:
51
+ 'Refuse a full-suite coverage capture that no committed stamp covers, instead of paying for it. Default false — the run is announced (a warning naming the crediting invocation, emitted before the spawn) and then executed, which is the pre-existing behaviour. Set true when the whole-suite cost is large enough that a close should stop at zero seconds rather than absorb it silently.',
52
+ default: false,
53
+ },
48
54
  },
49
55
  additionalProperties: false,
50
56
  };
@@ -128,9 +128,10 @@ const PATHS_SCHEMA = {
128
128
  };
129
129
 
130
130
  /**
131
- * `project.commands` — names of the test/typecheck/format commands the
131
+ * `project.commands` — names of the test/typecheck/lint/format commands the
132
132
  * close-validation chain spawns. `typecheck` accepts `null` to mean
133
- * "disabled". `validate` and `build` were dropped (no production consumers).
133
+ * "disabled"; `lint` accepts `null` to mean "use the framework default",
134
+ * because that gate is mandatory and cannot be switched off. `validate` and `build` were dropped (no production consumers).
134
135
  */
135
136
  const COMMANDS_SCHEMA = {
136
137
  type: 'object',
@@ -149,6 +150,12 @@ const COMMANDS_SCHEMA = {
149
150
  'Static type-check command. `null` disables the gate for projects with no type layer; the empty string is rejected so a typo cannot silently disable it.',
150
151
  default: COMMANDS_DEFAULTS.typecheck,
151
152
  },
153
+ lint: {
154
+ ...NULLABLE_NONEMPTY_SAFE_STRING,
155
+ description:
156
+ 'Lint command run as a close-validation gate. `null` (the default) uses `npm run lint`; the gate is mandatory, so unlike `typecheck` this key cannot disable it. Point it at the scoped command your hooks already run to stop paying for a third whole-repo lint at close — the gate still lints the diff, and CI still owns whole-repo drift. Like every command here it must be a single argv (no `;`, `&&`, pipes or substitution), so wrap a multi-linter pair in one npm script and name that.',
157
+ default: COMMANDS_DEFAULTS.lint,
158
+ },
152
159
  formatCheck: {
153
160
  ...SAFE_STRING,
154
161
  minLength: 1,
@@ -375,6 +375,76 @@ export function describeFreshness(freshness, targetDirs) {
375
375
  return `${reason} — no scorable source file found under [${dirs}]; if that does not name this project's sources, fix quality.gates.crap.targetDirs`;
376
376
  }
377
377
 
378
+ /**
379
+ * The invocation that deposits capture credit for a Story branch. Named in
380
+ * the uncredited-capture announcement so a reader of a close log sees the
381
+ * command that would have avoided the cost, not just the cost.
382
+ */
383
+ const CREDITING_INVOCATION =
384
+ 'node <main-repo>/.agents/scripts/coverage-capture.js --cwd <workCwd>';
385
+
386
+ /**
387
+ * Announce — and, when the consumer requires credit, refuse — a full-suite
388
+ * capture that no committed stamp covers.
389
+ *
390
+ * Every caller invokes this immediately before it would spawn the suite, and
391
+ * the ordering is the whole point. The capture itself is the most expensive
392
+ * thing a close does; discovering that it ran uncredited is only actionable
393
+ * while it is still ahead of you, not once it is visible as a `durationMs`
394
+ * in `validation-evidence.json` twelve minutes later.
395
+ *
396
+ * The probe is read-only by construction: every caller has already decided
397
+ * to capture by the time it runs, and it writes nothing — so it never takes
398
+ * the full-suite host lock and can never itself be the reason a close waits.
399
+ *
400
+ * @param {{
401
+ * requireCredited?: boolean,
402
+ * logger: { info: Function, warn: Function, error: Function },
403
+ * }} opts `requireCredited` mirrors `delivery.execution.requireCreditedCapture`
404
+ * (default false → announce and run).
405
+ * @returns {number | null} A non-zero exit code the caller MUST return
406
+ * without spawning the suite, or `null` to proceed with the capture.
407
+ */
408
+ function announceUncreditedCapture({ requireCredited = false, logger }) {
409
+ const preamble =
410
+ 'no credited capture stamp covers this change set — ' +
411
+ `the full suite is about to run. Deposit credit before the push with: ${CREDITING_INVOCATION}`;
412
+ if (requireCredited) {
413
+ logger.error(
414
+ `[coverage-capture] ✖ ${preamble} (delivery.execution.requireCreditedCapture is set, so this run is refused instead of paid for).`,
415
+ );
416
+ return 1;
417
+ }
418
+ logger.warn(`[coverage-capture] ⚠ ${preamble}`);
419
+ return null;
420
+ }
421
+
422
+ /**
423
+ * Compose the uncredited-capture probe over a capture runner, so the
424
+ * announcement is structurally inseparable from the spawn it describes.
425
+ *
426
+ * This mirrors `lockedCapture`, and for the same reason: there are two
427
+ * capture paths (full-scope and incremental) and neither should have to
428
+ * remember the policy. Wrapping the runner they share means a third path
429
+ * added later inherits the probe for free, and that the warning can never be
430
+ * emitted for a capture that does not happen — or omitted for one that does.
431
+ *
432
+ * It composes OUTSIDE `lockedCapture`, so a refusal costs nothing: the host
433
+ * lock is never acquired for a run that is about to be declined.
434
+ *
435
+ * @param {(opts: object) => number} runCaptureFn The (possibly already
436
+ * lock-wrapped) capture runner.
437
+ * @param {{ requireCredited?: boolean, logger: object }} policy
438
+ * @returns {(opts?: object) => number} A runner returning the capture's exit
439
+ * code, or a non-zero refusal code without having spawned anything.
440
+ */
441
+ export function creditedCapture(runCaptureFn, { requireCredited, logger }) {
442
+ return (captureOpts = {}) => {
443
+ const refusal = announceUncreditedCapture({ requireCredited, logger });
444
+ return refusal === null ? runCaptureFn(captureOpts) : refusal;
445
+ };
446
+ }
447
+
378
448
  /**
379
449
  * Narrow `changedFiles` to the subset that lives under one of `targetDirs`.
380
450
  *