mandrel 2.25.0 → 2.26.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 (131) hide show
  1. package/.agents/agents/acceptance-critic.md +10 -6
  2. package/.agents/audit-checklists/baselines.md +21 -0
  3. package/.agents/docs/quality-gates.md +80 -18
  4. package/.agents/docs/workflows.md +3 -1
  5. package/.agents/instructions.md +1 -1
  6. package/.agents/schemas/audit-rules.json +15 -0
  7. package/.agents/schemas/baselines/audit-baselines-envelope.schema.json +242 -0
  8. package/.agents/schemas/baselines/baseline-envelope.schema.json +4 -0
  9. package/.agents/schemas/baselines/crap.schema.json +8 -0
  10. package/.agents/schemas/model-attribution.schema.json +4 -0
  11. package/.agents/scripts/acceptance-eval.js +89 -6
  12. package/.agents/scripts/audit-baselines.js +136 -0
  13. package/.agents/scripts/check-arch-cycles.js +12 -93
  14. package/.agents/scripts/check-baseline-drift.js +16 -3
  15. package/.agents/scripts/check-baselines.js +19 -3
  16. package/.agents/scripts/check-cyclomatic.js +214 -0
  17. package/.agents/scripts/check-schema-references.js +392 -0
  18. package/.agents/scripts/check-test-temp-hygiene.js +38 -1
  19. package/.agents/scripts/check-workflow-timeouts.js +291 -0
  20. package/.agents/scripts/diagnose-friction.js +85 -19
  21. package/.agents/scripts/lib/audit-baselines/engine.js +177 -0
  22. package/.agents/scripts/lib/audit-baselines/gate-surface.js +63 -0
  23. package/.agents/scripts/lib/audit-baselines/headroom.js +72 -0
  24. package/.agents/scripts/lib/audit-baselines/hotspots.js +69 -0
  25. package/.agents/scripts/lib/audit-baselines/kinds.js +313 -0
  26. package/.agents/scripts/lib/audit-baselines/outliers.js +100 -0
  27. package/.agents/scripts/lib/audit-baselines/read.js +87 -0
  28. package/.agents/scripts/lib/audit-baselines/staleness.js +123 -0
  29. package/.agents/scripts/lib/audit-baselines/surface-entry.js +106 -0
  30. package/.agents/scripts/lib/audit-baselines/trend.js +125 -0
  31. package/.agents/scripts/lib/audit-baselines/weights.js +193 -0
  32. package/.agents/scripts/lib/audit-suite/index.js +0 -5
  33. package/.agents/scripts/lib/audit-suite/selector.js +9 -62
  34. package/.agents/scripts/lib/audit-to-stories/audit-lenses.js +1 -0
  35. package/.agents/scripts/lib/baseline-schema-registry.js +13 -1
  36. package/.agents/scripts/lib/baselines/diff-scope-cli.js +22 -160
  37. package/.agents/scripts/lib/baselines/duplication-scanner.js +27 -0
  38. package/.agents/scripts/lib/baselines/git-base.js +26 -4
  39. package/.agents/scripts/lib/baselines/kinds/crap.js +112 -15
  40. package/.agents/scripts/lib/baselines/refresh-service.js +69 -11
  41. package/.agents/scripts/lib/baselines/scope.js +39 -90
  42. package/.agents/scripts/lib/baselines/writer.js +16 -11
  43. package/.agents/scripts/lib/changed-files.js +8 -1
  44. package/.agents/scripts/lib/cli-args.js +115 -1
  45. package/.agents/scripts/lib/close-validation/runner.js +70 -25
  46. package/.agents/scripts/lib/crap-engine.js +32 -13
  47. package/.agents/scripts/lib/crap-method-identity.js +153 -0
  48. package/.agents/scripts/lib/crap-utils.js +13 -0
  49. package/.agents/scripts/lib/cyclomatic-ceiling.js +265 -0
  50. package/.agents/scripts/lib/feedback-loop/audit-results-graduator.js +0 -2
  51. package/.agents/scripts/lib/feedback-loop/prior-feedback-fetcher.js +0 -2
  52. package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +0 -2
  53. package/.agents/scripts/lib/git-utils.js +136 -80
  54. package/.agents/scripts/lib/import-graph.js +156 -0
  55. package/.agents/scripts/lib/observability/runtime-friction.js +17 -2
  56. package/.agents/scripts/lib/observability/source-classifier.js +175 -2
  57. package/.agents/scripts/lib/orchestration/ceremony-routing.js +17 -12
  58. package/.agents/scripts/lib/orchestration/check-baselines/phases/compare.js +36 -6
  59. package/.agents/scripts/lib/orchestration/check-baselines/phases/evaluate.js +5 -0
  60. package/.agents/scripts/lib/orchestration/check-baselines/phases/floors.js +12 -1
  61. package/.agents/scripts/lib/orchestration/check-baselines/phases/report.js +8 -1
  62. package/.agents/scripts/lib/orchestration/git-cleanup/phases/phase-drivers.js +10 -5
  63. package/.agents/scripts/lib/orchestration/git-cleanup/phases/render.js +39 -3
  64. package/.agents/scripts/lib/orchestration/plan-context.js +119 -66
  65. package/.agents/scripts/lib/orchestration/plan-persist/fan-out-gate.js +31 -5
  66. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +209 -109
  67. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +48 -12
  68. package/.agents/scripts/lib/orchestration/plan-persist/supersede-ops.js +79 -22
  69. package/.agents/scripts/lib/orchestration/plan-text-hygiene.js +51 -20
  70. package/.agents/scripts/lib/orchestration/planning/authoring-context.js +70 -74
  71. package/.agents/scripts/lib/orchestration/planning/memory-pool-advisory.js +231 -0
  72. package/.agents/scripts/lib/orchestration/resolve-stories.js +18 -17
  73. package/.agents/scripts/lib/orchestration/run-epilogue.js +12 -0
  74. package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +29 -3
  75. package/.agents/scripts/lib/orchestration/single-story-close/phases/normalize-pr-title.js +6 -6
  76. package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +42 -38
  77. package/.agents/scripts/lib/orchestration/single-story-close/phases/push.js +6 -1
  78. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +245 -140
  79. package/.agents/scripts/lib/orchestration/spec-budget.js +16 -5
  80. package/.agents/scripts/lib/orchestration/story-follow-ups.js +182 -95
  81. package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +22 -0
  82. package/.agents/scripts/lib/orchestration/ticket-validator.js +5 -11
  83. package/.agents/scripts/lib/orchestration/ticketing/reads.js +4 -4
  84. package/.agents/scripts/lib/story-adjacency.js +3 -3
  85. package/.agents/scripts/lib/test-runner-contract.js +134 -0
  86. package/.agents/scripts/lib/test-tiers.js +11 -2
  87. package/.agents/scripts/lib/util/concurrent-map.js +17 -0
  88. package/.agents/scripts/lib/util/parse-id-list.js +103 -0
  89. package/.agents/scripts/lib/wave-runner/live-probe.js +24 -14
  90. package/.agents/scripts/lib/wave-runner/ready-set.js +189 -42
  91. package/.agents/scripts/lib/workers/combined-mi-crap-worker.js +4 -10
  92. package/.agents/scripts/lib/workers/crap-worker.js +2 -10
  93. package/.agents/scripts/lib/workers/maintainability-report-worker.js +4 -10
  94. package/.agents/scripts/lib/workers/maintainability-worker.js +4 -10
  95. package/.agents/scripts/lib/workers/serve-worker-messages.js +35 -0
  96. package/.agents/scripts/lib/worktree/git-hooks.js +206 -0
  97. package/.agents/scripts/lib/worktree/lifecycle/creation.js +6 -0
  98. package/.agents/scripts/lib/worktree-manager.js +14 -0
  99. package/.agents/scripts/plan-run-epilogue.js +17 -5
  100. package/.agents/scripts/providers/github/tickets.js +33 -10
  101. package/.agents/scripts/provision-git-hooks.js +85 -0
  102. package/.agents/scripts/quality-preview.js +112 -28
  103. package/.agents/scripts/resolve-stories.js +4 -1
  104. package/.agents/scripts/run-coverage.js +86 -35
  105. package/.agents/scripts/run-lint.js +20 -0
  106. package/.agents/scripts/run-tests.js +26 -36
  107. package/.agents/scripts/single-story-close.js +28 -2
  108. package/.agents/scripts/single-story-confirm-merge.js +22 -6
  109. package/.agents/scripts/stories-wave-tick.js +214 -38
  110. package/.agents/scripts/update-coverage-baseline.js +34 -4
  111. package/.agents/scripts/update-duplication-baseline.js +209 -83
  112. package/.agents/scripts/validate-docs-freshness.js +1 -0
  113. package/.agents/skills/core/diagnose-friction/SKILL.md +4 -1
  114. package/.agents/skills/core/gates-and-baselines/SKILL.md +17 -11
  115. package/.agents/skills/skills.index.json +2 -2
  116. package/.agents/workflows/audit-baselines.md +289 -0
  117. package/.agents/workflows/audit-navigability.md +5 -4
  118. package/.agents/workflows/deliver.md +13 -4
  119. package/.agents/workflows/helpers/acceptance-self-eval.md +47 -10
  120. package/.agents/workflows/helpers/code-quality-guardrails.md +9 -2
  121. package/.agents/workflows/helpers/deliver-digest.md +41 -21
  122. package/.agents/workflows/helpers/deliver-reference.md +77 -1
  123. package/.agents/workflows/helpers/deliver-story-reference.md +47 -6
  124. package/.agents/workflows/helpers/plan-reference.md +15 -5
  125. package/.agents/workflows/memory-consolidate.md +116 -0
  126. package/.agents/workflows/plan.md +3 -0
  127. package/README.md +13 -6
  128. package/docs/CHANGELOG.md +64 -0
  129. package/package.json +9 -4
  130. package/.agents/schemas/friction-event.schema.json +0 -56
  131. package/.agents/scripts/lib/feedback-loop/memory-freshness.js +0 -707
@@ -17,6 +17,9 @@
17
17
  * - `.agentrc.json`
18
18
  * - `.claude/`
19
19
  * - `node .agents/scripts/`
20
+ * …or (Story #4916) names one of the CLI entry points shipped at the
21
+ * top level of `.agents/scripts/` by **bare basename**, so recognition
22
+ * does not depend on how the caller spelled the reference.
20
23
  * - Anything else (or empty input) defaults to `"consumer"`. The default
21
24
  * is intentional — most friction comes from consumer code touching
22
25
  * framework tooling, and we'd rather under-tag than mis-route a
@@ -47,6 +50,133 @@ const FRAMEWORK_PREFIXES = Object.freeze([
47
50
  'node .agents/scripts/',
48
51
  ]);
49
52
 
53
+ /**
54
+ * Basenames of the CLI entry points shipped at the top level of
55
+ * `.agents/scripts/` (Story #4916).
56
+ *
57
+ * The prefix scan above recognises a framework script only when the caller
58
+ * spelled a path — `node .agents/scripts/acceptance-eval.js` classifies
59
+ * `framework`, while the same script named bare (`acceptance-eval.js --story
60
+ * 4901`) matched no prefix and fell through to `consumer`, so a real framework
61
+ * defect was routed as consumer-actionable and discarded. Recognition must not
62
+ * depend on how the caller spelled the reference.
63
+ *
64
+ * Kept as **static data**, not a directory read: `classifySignalSource` is
65
+ * called once per signal and MUST stay pure — no filesystem I/O at classify
66
+ * time. The sync test in
67
+ * `tests/lib/observability/source-classifier.test.js` reads the real directory
68
+ * and fails when a script is added, renamed, or removed without this list
69
+ * being updated, so the set cannot go stale and silently restore the blind
70
+ * spot.
71
+ *
72
+ * Scoped to the top level deliberately: those are the entry points a command
73
+ * line names. Library modules under `.agents/scripts/lib/` carry generic
74
+ * basenames (`index.js`, `config.js`) that a consumer command could plausibly
75
+ * name, and widening to them would trade one mis-route for another.
76
+ *
77
+ * @type {readonly string[]}
78
+ */
79
+ const FRAMEWORK_SCRIPT_BASENAMES = Object.freeze([
80
+ 'acceptance-eval.js',
81
+ 'agents-bootstrap-github.js',
82
+ 'apply-quality-bootstrap.js',
83
+ 'audit-baselines.js',
84
+ 'audit-labels-bootstrap.js',
85
+ 'audit-to-stories.js',
86
+ 'boot-sweep.js',
87
+ 'bootstrap.js',
88
+ 'check-action-pinning.js',
89
+ 'check-arch-cycles.js',
90
+ 'check-baseline-drift.js',
91
+ 'check-baselines.js',
92
+ 'check-context-budget.js',
93
+ 'check-cyclomatic.js',
94
+ 'check-dead-exports.js',
95
+ 'check-doc-links.js',
96
+ 'check-gherkin-placeholders.js',
97
+ 'check-lifecycle-doc-drift.js',
98
+ 'check-lifecycle-lint.js',
99
+ 'check-schema-references.js',
100
+ 'check-test-temp-hygiene.js',
101
+ 'check-windows-git-perf.js',
102
+ 'check-workflow-citations.js',
103
+ 'check-workflow-cli-lint.js',
104
+ 'check-workflow-timeouts.js',
105
+ 'cleanup-repo-test-temp.js',
106
+ 'coverage-capture.js',
107
+ 'deliver-light.js',
108
+ 'deliver-recover.js',
109
+ 'diagnose-friction.js',
110
+ 'diagnose.js',
111
+ 'drain-pending-cleanup.js',
112
+ 'evidence-gate.js',
113
+ 'generate-config-docs.js',
114
+ 'generate-lens-checklists.js',
115
+ 'generate-lifecycle-docs.js',
116
+ 'generate-skills-index.js',
117
+ 'generate-workflows-doc.js',
118
+ 'git-cleanup.js',
119
+ 'install-matrix-assert.js',
120
+ 'lint-baseline.js',
121
+ 'lint-issue-body.js',
122
+ 'lint-label-vocabulary.js',
123
+ 'mandrel-update-preflight.js',
124
+ 'nav-registry-diff.js',
125
+ 'notify.js',
126
+ 'plan-context.js',
127
+ 'plan-critics.js',
128
+ 'plan-persist.js',
129
+ 'plan-run-epilogue.js',
130
+ 'post-structured-comment.js',
131
+ 'pr-watch-with-update.js',
132
+ 'provision-git-hooks.js',
133
+ 'quality-preview.js',
134
+ 'quality-watch.js',
135
+ 'resolve-doc-tiers.js',
136
+ 'resolve-stories.js',
137
+ 'resync-status-column.js',
138
+ 'run-coverage.js',
139
+ 'run-lint.js',
140
+ 'run-test-profile.js',
141
+ 'run-tests.js',
142
+ 'run-verify.js',
143
+ 'signals-view.js',
144
+ 'single-story-close.js',
145
+ 'single-story-confirm-merge.js',
146
+ 'single-story-init.js',
147
+ 'stories-wave-tick.js',
148
+ 'story-plan.js',
149
+ 'sync-agentrc.js',
150
+ 'sync-branch-from-base.js',
151
+ 'sync-claude-agents.js',
152
+ 'sync-claude-commands.js',
153
+ 'test-isolate.js',
154
+ 'test-wrapper.js',
155
+ 'update-coverage-baseline.js',
156
+ 'update-crap-baseline.js',
157
+ 'update-duplication-baseline.js',
158
+ 'update-maintainability-baseline.js',
159
+ 'update-ticket-state.js',
160
+ 'validate-docs-freshness.js',
161
+ 'validate-skills.js',
162
+ ]);
163
+
164
+ /**
165
+ * Membership index over {@link FRAMEWORK_SCRIPT_BASENAMES}, built once at
166
+ * module load so the per-signal scan is a hash lookup.
167
+ *
168
+ * @type {ReadonlySet<string>}
169
+ */
170
+ const FRAMEWORK_SCRIPT_BASENAME_SET = new Set(FRAMEWORK_SCRIPT_BASENAMES);
171
+
172
+ /**
173
+ * Characters a shell-ish command line can wrap a token in. Stripped from both
174
+ * ends before the membership test so `"acceptance-eval.js",` still resolves.
175
+ *
176
+ * @type {RegExp}
177
+ */
178
+ const TOKEN_TRIM = /^[\s'"`(,;:]+|[\s'"`),;:]+$/g;
179
+
50
180
  /**
51
181
  * Normalise a value to a string for prefix scanning. Anything that is not
52
182
  * a string (undefined, null, numbers, objects) becomes the empty string,
@@ -78,6 +208,44 @@ function containsFrameworkPrefix(haystack) {
78
208
  return false;
79
209
  }
80
210
 
211
+ /**
212
+ * Return true when `haystack` names a top-level framework script by **bare
213
+ * basename** — no path segment at all (Story #4916).
214
+ *
215
+ * Whitespace-tokenised and matched whole: only a token that *equals* a known
216
+ * basename counts. A token carrying any path segment is deliberately left to
217
+ * {@link containsFrameworkPrefix} — `./tools/notify.js` is the consumer's own
218
+ * script and must stay `consumer`, while `.agents/scripts/notify.js` already
219
+ * matches a prefix. Likewise `my-notify.js` is not `notify.js`.
220
+ *
221
+ * @param {string} haystack
222
+ * @returns {boolean}
223
+ */
224
+ function containsFrameworkScriptBasename(haystack) {
225
+ if (haystack.length === 0) return false;
226
+ for (const rawToken of haystack.split(/\s+/)) {
227
+ const token = rawToken.replace(TOKEN_TRIM, '');
228
+ if (FRAMEWORK_SCRIPT_BASENAME_SET.has(token)) return true;
229
+ }
230
+ return false;
231
+ }
232
+
233
+ /**
234
+ * True when `haystack` names the framework's own surface, by either
235
+ * recognition route: a path prefix, or a bare framework-script basename.
236
+ *
237
+ * Purely additive over {@link containsFrameworkPrefix} — every string that
238
+ * matched a prefix still matches here, so widening can only ever move a
239
+ * classification from `consumer` to `framework`, never the reverse.
240
+ *
241
+ * @param {string} haystack
242
+ * @returns {boolean}
243
+ */
244
+ function namesFrameworkSurface(haystack) {
245
+ if (containsFrameworkPrefix(haystack)) return true;
246
+ return containsFrameworkScriptBasename(haystack);
247
+ }
248
+
81
249
  /**
82
250
  * Classify a friction signal as `"framework"` or `"consumer"`.
83
251
  *
@@ -92,6 +260,10 @@ function containsFrameworkPrefix(haystack) {
92
260
  * `node .agents/scripts/single-story-init.js` — that's framework friction even
93
261
  * though the failing test path lives under the consumer.
94
262
  *
263
+ * Either input matches on a framework path prefix **or** on a bare framework
264
+ * script basename (Story #4916), so `single-story-close.js --story 4906` is
265
+ * recognised exactly like its fully-pathed spelling.
266
+ *
95
267
  * @param {unknown} failingPath The path of the file or directory the
96
268
  * signal blames (e.g. `"tests/foo.test.js"`).
97
269
  * @param {unknown} command The command line the signal blames
@@ -101,8 +273,8 @@ function containsFrameworkPrefix(haystack) {
101
273
  export function classifyPathSource(failingPath, command) {
102
274
  const path = toScanString(failingPath);
103
275
  const cmd = toScanString(command);
104
- if (containsFrameworkPrefix(path)) return 'framework';
105
- if (containsFrameworkPrefix(cmd)) return 'framework';
276
+ if (namesFrameworkSurface(path)) return 'framework';
277
+ if (namesFrameworkSurface(cmd)) return 'framework';
106
278
  return 'consumer';
107
279
  }
108
280
 
@@ -229,5 +401,6 @@ export function classifySignalSource(record) {
229
401
 
230
402
  export const __testing = Object.freeze({
231
403
  FRAMEWORK_PREFIXES,
404
+ FRAMEWORK_SCRIPT_BASENAMES,
232
405
  TOOL_DEGRADED_CATEGORY,
233
406
  });
@@ -78,7 +78,7 @@
78
78
  * @typedef {'fresh'|'inline'} CeremonyMode
79
79
  * @typedef {'fresh-critic'|'inline-self-eval'} VerdictOwner
80
80
  * @typedef {import('./review-depth.js').ChangeLevel} ChangeLevel
81
- * @typedef {'minimal'|'standard'|'strict'} CeremonyProfile
81
+ * @typedef {(typeof CEREMONY_PROFILES)[number]} CeremonyProfile
82
82
  */
83
83
 
84
84
  /**
@@ -93,12 +93,18 @@ export function verdictOwnerForMode(mode) {
93
93
  return mode === 'fresh' ? 'fresh-critic' : 'inline-self-eval';
94
94
  }
95
95
 
96
- /** @type {readonly CeremonyProfile[]} */
97
- export const CEREMONY_PROFILES = Object.freeze([
98
- 'minimal',
99
- 'standard',
100
- 'strict',
101
- ]);
96
+ /**
97
+ * The ceremony-profile vocabulary — the **single** place the three profile
98
+ * names are written. `normalizeCeremonyProfile` is its reader and the
99
+ * `CeremonyProfile` typedef is derived from it, so adding a profile is a
100
+ * one-line change here (Story #4926).
101
+ *
102
+ * @type {readonly ['minimal', 'standard', 'strict']}
103
+ */
104
+ const CEREMONY_PROFILES = Object.freeze(['minimal', 'standard', 'strict']);
105
+
106
+ /** The profile an absent or unrecognized value degrades to. */
107
+ const DEFAULT_CEREMONY_PROFILE = 'standard';
102
108
 
103
109
  /**
104
110
  * Normalize an operator/config ceremony profile. Unknown values degrade to
@@ -107,11 +113,10 @@ export const CEREMONY_PROFILES = Object.freeze([
107
113
  * @param {unknown} value
108
114
  * @returns {CeremonyProfile}
109
115
  */
110
- export function normalizeCeremonyProfile(value) {
111
- if (value === 'minimal' || value === 'standard' || value === 'strict') {
112
- return value;
113
- }
114
- return 'standard';
116
+ function normalizeCeremonyProfile(value) {
117
+ return CEREMONY_PROFILES.includes(/** @type {CeremonyProfile} */ (value))
118
+ ? /** @type {CeremonyProfile} */ (value)
119
+ : DEFAULT_CEREMONY_PROFILE;
115
120
  }
116
121
 
117
122
  /**
@@ -9,6 +9,7 @@
9
9
  * @module lib/orchestration/check-baselines/phases/compare
10
10
  */
11
11
 
12
+ import { EXIT_CONFIG } from '../../../baselines/exit-codes.js';
12
13
  import { readBaseFromGit } from '../../../baselines/git-base.js';
13
14
  import { getKindModule } from '../../../baselines/kernel.js';
14
15
  import { resolveScope } from '../../../baselines/scope.js';
@@ -30,10 +31,8 @@ export function resolveDispatchScope({ kind, quality, env }) {
30
31
  kind,
31
32
  configScope: cfg.scope,
32
33
  configRef: cfg.diffRef,
33
- cliFlags: {
34
- envScope: env?.BASELINE_SCOPE,
35
- envRef: env?.BASELINE_REF,
36
- },
34
+ envScope: env?.BASELINE_SCOPE,
35
+ envRef: env?.BASELINE_REF,
37
36
  });
38
37
  }
39
38
 
@@ -41,13 +40,44 @@ function emptyCompareResult(baseRef) {
41
40
  return { baseRef, baseRead: false };
42
41
  }
43
42
 
43
+ /**
44
+ * Story #4914 — a base read that FAILS is not a base that is ABSENT.
45
+ *
46
+ * `readBaseFromGit` already draws that line itself: it returns `null` only
47
+ * for git exit 128 ("path does not exist in this revision") and throws on
48
+ * everything else. Swallowing the throw conflated the two, so a broken read
49
+ * silently emptied the whole head-vs-base arm — regressions AND additions —
50
+ * while the floors arm kept the run at exit 0. A gate that fails open is
51
+ * worse than no gate, because it is trusted.
52
+ *
53
+ * So the read failure fails CLOSED as `EXIT_CONFIG` (3) — "the gate could
54
+ * not even start", the same code `assertFloorAxesExist` uses for a
55
+ * misconfigured floor axis. `check-baselines.js#main` maps any throw out of
56
+ * the pipeline onto that code.
57
+ */
58
+ function buildBaseReadError({ kind, ref, file, cause }) {
59
+ const detail = cause?.message ?? String(cause);
60
+ const err = new Error(
61
+ `[check-baselines:${kind}] could not read the base baseline at ` +
62
+ `${ref}:${file} — the head-vs-base compare arm cannot run, so the gate ` +
63
+ `fails closed rather than reporting zero regressions: ${detail}`,
64
+ );
65
+ err.code = 'EXIT_CONFIG';
66
+ err.exitCode = EXIT_CONFIG;
67
+ err.kind = kind;
68
+ err.baseRef = ref;
69
+ err.baselinePath = file;
70
+ err.cause = cause;
71
+ return err;
72
+ }
73
+
44
74
  function readBaseBaselinePayload(scope, kind, gateBlock, cwd) {
45
75
  const rel = baselineRelativePath(kind, gateBlock);
46
76
  let raw;
47
77
  try {
48
78
  raw = readBaseFromGit(scope.ref, rel, { cwd });
49
- } catch {
50
- return null;
79
+ } catch (cause) {
80
+ throw buildBaseReadError({ kind, ref: scope.ref, file: rel, cause });
51
81
  }
52
82
  if (raw === null) return null;
53
83
  try {
@@ -206,6 +206,11 @@ function buildGateReport({
206
206
  additions: compareOutput.additions ?? [],
207
207
  regressionCount: compareOutput.regressions.length,
208
208
  baseRef: cmp.baseRef ?? null,
209
+ // Story #4914 — the compare arm's read status was internal to compare.js,
210
+ // which is why a dead compare arm looked byte-identical to a clean run.
211
+ // Surfacing it makes "the head-vs-base arm did not run" diagnosable from
212
+ // the JSON report alone.
213
+ baseRead: cmp.baseRead === true,
209
214
  generatedAt: baseline.generatedAt,
210
215
  acknowledged,
211
216
  };
@@ -10,7 +10,18 @@
10
10
 
11
11
  import { EXIT_CONFIG } from '../../../baselines/exit-codes.js';
12
12
 
13
- function axisDirection(kind, axis) {
13
+ /**
14
+ * Which way a floor axis is compared: `gte` means the measured value must be
15
+ * at or above the floor, `lte` at or below it. Exported (Story #4902) so the
16
+ * baseline hotspot engine reports floor headroom with the same polarity the
17
+ * gate enforces — a second copy of this table would let the two disagree
18
+ * about which direction is "better" for a given axis.
19
+ *
20
+ * @param {string} kind
21
+ * @param {string} axis
22
+ * @returns {'gte' | 'lte'}
23
+ */
24
+ export function axisDirection(kind, axis) {
14
25
  if (kind === 'lint') return 'lte';
15
26
  if (kind === 'crap') return 'lte';
16
27
  if (kind === 'bundle-size') return 'lte';
@@ -27,8 +27,15 @@ function formatGateLine(g) {
27
27
  ? ''
28
28
  : ` [kernel drift ${g.kernelBaseline} → ${g.kernelCurrent}]`;
29
29
  const baseRef = g.baseRef ? ` [baseRef=${g.baseRef}]` : '';
30
+ // Story #4914 — a compare arm that never read its base reports zero
31
+ // regressions and zero additions, which is indistinguishable from a clean
32
+ // run unless the text report says so out loud.
33
+ const baseRead =
34
+ g.baseRef && g.baseRead === false
35
+ ? ' [baseRead=false — compare skipped]'
36
+ : '';
30
37
  const ack = g.acknowledged ? ' [ACKNOWLEDGED — this run only]' : '';
31
- return ` - ${g.kind}: ${status}${drift}${baseRef}${ack}`;
38
+ return ` - ${g.kind}: ${status}${drift}${baseRef}${baseRead}${ack}`;
32
39
  }
33
40
 
34
41
  function formatViolationLine(component, v) {
@@ -26,8 +26,8 @@ import { buildGlobFilter } from './filters.js';
26
26
  import { promptStashDecision, promptYesNo } from './prompts.js';
27
27
  import { executePrune } from './prune.js';
28
28
  import {
29
+ renderCandidateList,
29
30
  renderDeferredLine,
30
- renderDryRun,
31
31
  renderExecutionLine,
32
32
  renderExecutionSummary,
33
33
  renderPruneLine,
@@ -41,8 +41,9 @@ import {
41
41
  const TAG = '[git-cleanup]';
42
42
 
43
43
  /* node:coverage ignore next */
44
- function emitDryRunHuman(plan, baseBranch) {
45
- for (const line of renderDryRun(plan, { baseBranch })) Logger.info(line);
44
+ function emitCandidateList(plan, opts, baseBranch) {
45
+ for (const l of renderCandidateList({ plan, opts, baseBranch }))
46
+ Logger.info(l);
46
47
  }
47
48
 
48
49
  /* node:coverage ignore next */
@@ -291,7 +292,7 @@ export async function runBranchPhase(opts, cwd, baseBranch) {
291
292
  filter,
292
293
  includeRemoteOnly: true,
293
294
  });
294
- emitDryRunHuman(plan, baseBranch);
295
+ emitCandidateList(plan, opts, baseBranch);
295
296
  const action = decideBranchPhase({ plan, opts, cwd });
296
297
  if (action.kind === 'prompt-then-execute') {
297
298
  const go = await promptYesNo(action.promptMessage);
@@ -375,7 +376,11 @@ export async function executeStashPhase(action) {
375
376
  );
376
377
  }
377
378
 
378
- /* node:coverage ignore next */
379
+ // Story #4922 — the `node:coverage ignore next` directive that used to sit
380
+ // here is gone. It was never justified: `runStashPhase` is drivable end to
381
+ // end (planStashes degrades to an empty list outside a repo, and the
382
+ // decide/execute pair below is pure given an allowlist), so the directive
383
+ // only hid a sequencer nothing exercised.
379
384
  export async function runStashPhase(opts, cwd) {
380
385
  Logger.info(`${TAG} ── phase: stashes ──`);
381
386
  const { stashes } = planStashes({ cwd });
@@ -61,11 +61,28 @@ function contentMergedNote(candidate) {
61
61
  : '';
62
62
  }
63
63
 
64
- /** Pure: render the dry-run plan as the operator-facing text block. */
64
+ /**
65
+ * Pure: render the branch-phase candidate list as the operator-facing text
66
+ * block.
67
+ *
68
+ * The header states the run mode: `execute` opts into the reap wording and
69
+ * defaults to `false`, so a caller that forgets it still gets the harmless
70
+ * preview line. Prefer {@link renderCandidateList}, which derives the mode
71
+ * from the phase's own CLI options rather than making the caller restate
72
+ * it — the header used to be hardcoded to the preview wording, so an
73
+ * `--execute` run announced "nothing deleted" and then reaped.
74
+ *
75
+ * @param {{ candidates: Array, skipped?: Array, ghDegraded?: boolean }} plan
76
+ * @param {{ baseBranch?: string|null, now?: number, execute?: boolean }} [opts]
77
+ * @returns {string[]}
78
+ */
65
79
  export function renderDryRun(plan, opts = {}) {
66
- const { baseBranch = null, now } = opts;
80
+ const { baseBranch = null, now, execute = false } = opts;
81
+ const count = plan.candidates.length;
67
82
  const lines = [
68
- `${TAG} DRY RUN (nothing deleted) — ${plan.candidates.length} candidate(s)`,
83
+ execute
84
+ ? `${TAG} EXECUTE — ${count} candidate(s) to reap`
85
+ : `${TAG} DRY RUN (nothing deleted) — ${count} candidate(s)`,
69
86
  ];
70
87
  if (plan.candidates.length === 0) {
71
88
  lines.push(' (no merged branches to clean up)');
@@ -105,6 +122,25 @@ export function renderDryRun(plan, opts = {}) {
105
122
  return lines;
106
123
  }
107
124
 
125
+ /**
126
+ * Pure: the branch phase's candidate-list block, with the header's run
127
+ * mode derived from the phase's own CLI options — the same `opts.dryRun`
128
+ * the reap path reads. Taking the whole bag (rather than a restated
129
+ * boolean) is the point: the driver cannot get the wording wrong because
130
+ * it never names the flag, so a destructive `--execute` run can no longer
131
+ * announce itself as a `DRY RUN (nothing deleted)` preview and then
132
+ * delete every candidate.
133
+ *
134
+ * @param {object} args
135
+ * @param {{ candidates: Array, skipped?: Array }} args.plan
136
+ * @param {{ dryRun?: boolean }} args.opts Parsed CLI options.
137
+ * @param {string|null} [args.baseBranch]
138
+ * @returns {string[]}
139
+ */
140
+ export function renderCandidateList({ plan, opts = {}, baseBranch = null }) {
141
+ return renderDryRun(plan, { baseBranch, execute: !opts.dryRun });
142
+ }
143
+
108
144
  /**
109
145
  * Pure: render a single latest-PR-state skip line. Returns null when the
110
146
  * skip reason is not one of the latest-PR family — `renderDryRun` filters