mandrel 2.41.0 → 2.43.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 (39) hide show
  1. package/.agents/agents/story-worker.md +24 -14
  2. package/.agents/docs/agentrc-reference.json +11 -2
  3. package/.agents/docs/configuration.md +9 -3
  4. package/.agents/docs/workflows.md +1 -1
  5. package/.agents/schemas/agentrc.schema.json +37 -3
  6. package/.agents/schemas/validation-evidence.schema.json +3 -1
  7. package/.agents/scripts/acceptance-eval.js +68 -3
  8. package/.agents/scripts/coverage-capture.js +25 -8
  9. package/.agents/scripts/lib/baselines/crap-preview-incremental.js +7 -2
  10. package/.agents/scripts/lib/baselines/git-base.js +74 -38
  11. package/.agents/scripts/lib/close-validation/gates.js +153 -25
  12. package/.agents/scripts/lib/close-validation/process.js +30 -1
  13. package/.agents/scripts/lib/close-validation/runner.js +5 -0
  14. package/.agents/scripts/lib/config/gates/crap-incremental-coverage.schema.js +33 -12
  15. package/.agents/scripts/lib/config/quality.js +36 -21
  16. package/.agents/scripts/lib/config-settings-schema-delivery.js +6 -0
  17. package/.agents/scripts/lib/config-settings-schema.js +29 -1
  18. package/.agents/scripts/lib/coverage-capture-incremental.js +12 -6
  19. package/.agents/scripts/lib/crap-baseline-join.js +11 -7
  20. package/.agents/scripts/lib/full-suite-lock.js +311 -0
  21. package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
  22. package/.agents/scripts/lib/orchestration/check-baselines/phases/evaluate.js +11 -104
  23. package/.agents/scripts/lib/orchestration/check-baselines/phases/refresh-ack.js +320 -0
  24. package/.agents/scripts/lib/orchestration/check-baselines/phases/report.js +8 -1
  25. package/.agents/scripts/lib/orchestration/plan-context.js +4 -0
  26. package/.agents/scripts/lib/orchestration/planning/authoring-context.js +9 -1
  27. package/.agents/scripts/lib/orchestration/planning/memory-pool-advisory.js +159 -55
  28. package/.agents/scripts/lib/orchestration/single-story-close/failed-terminal.js +83 -4
  29. package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +39 -7
  30. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +70 -18
  31. package/.agents/scripts/lib/orchestration/verify-credit.js +207 -0
  32. package/.agents/scripts/lib/single-story-sweep/sweep-lock.js +24 -0
  33. package/.agents/workflows/helpers/acceptance-self-eval.md +12 -0
  34. package/.agents/workflows/helpers/deliver-digest.md +31 -10
  35. package/.agents/workflows/helpers/deliver-story-reference.md +50 -30
  36. package/.agents/workflows/helpers/deliver-story.md +23 -21
  37. package/.agents/workflows/memory-consolidate.md +18 -6
  38. package/docs/CHANGELOG.md +25 -0
  39. package/package.json +1 -1
@@ -7,22 +7,16 @@
7
7
  * @module lib/orchestration/check-baselines/phases/evaluate
8
8
  */
9
9
 
10
- import { resolveKindRefreshOverrides } from '../../../baselines/env-overrides.js';
11
- import { readRangeSubjectsTouchingFile } from '../../../baselines/git-base.js';
12
10
  import {
13
11
  checkBaselineSemantics,
14
12
  checkKernelVersion,
15
13
  getKindModule,
16
14
  } from '../../../baselines/kernel.js';
17
15
  import * as reader from '../../../baselines/reader.js';
18
- import { Logger } from '../../../Logger.js';
19
16
  import { isIgnoredByGlobs } from '../../../maintainability-utils.js';
20
17
  import { applyTolerance, evaluateCompare, runCompareStage } from './compare.js';
21
18
  import { applyFloors, flattenBreaches } from './floors.js';
22
- import { DEFAULT_BASELINE_PATHS } from './parse-args.js';
23
-
24
- /** Default refresh-tag substring when the gate omits `refreshTag`. */
25
- const DEFAULT_REFRESH_TAG = 'baseline-refresh:';
19
+ import { applyRefreshAcknowledgment } from './refresh-ack.js';
26
20
 
27
21
  /**
28
22
  * Defense-in-depth against an `ignoreGlobs`-poisoned baseline (Epic #4326
@@ -87,97 +81,6 @@ function loadHeadBaseline(kind, cwd, configPath) {
87
81
  }
88
82
  }
89
83
 
90
- /**
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:
95
- *
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.
99
- * 2. Commit tag: a commit in the compared range `<baseRef>..HEAD` whose
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.
103
- *
104
- * The tag is matched as a plain substring of a conventional commit subject, so
105
- * commitlint stays satisfied (e.g. `chore(baselines): baseline-refresh: …`).
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
- *
111
- * @returns {{ triggered: boolean, reasons: string[] }}
112
- */
113
- function resolveRefreshTrigger({ kind, gateBlock, cmp, cwd, env }) {
114
- const reasons = [];
115
- const { acknowledged: envAck, overrides } = resolveKindRefreshOverrides(
116
- kind,
117
- env,
118
- );
119
- if (envAck) reasons.push(...overrides);
120
-
121
- const baseRef = cmp?.baseRef ?? null;
122
- if (baseRef) {
123
- const refreshTag =
124
- typeof gateBlock?.refreshTag === 'string' && gateBlock.refreshTag.length
125
- ? gateBlock.refreshTag
126
- : DEFAULT_REFRESH_TAG;
127
- const baselinePath =
128
- typeof gateBlock?.baselinePath === 'string' &&
129
- gateBlock.baselinePath.length
130
- ? gateBlock.baselinePath
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
- }
142
- }
143
- }
144
-
145
- return { triggered: reasons.length > 0, reasons };
146
- }
147
-
148
- /**
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.
156
- *
157
- * A no-op absent a trigger, so an unacknowledged run of any kind reports its
158
- * regressions exactly as before.
159
- */
160
- function applyRefreshAcknowledgment(kind, compareOutput, ctx) {
161
- const { triggered, reasons } = resolveRefreshTrigger({ ...ctx, kind });
162
- if (!triggered || compareOutput.regressions.length === 0) {
163
- return { compareOutput, acknowledged: false };
164
- }
165
- Logger.warn(
166
- `[${kind}] ⚠ ${reasons.join('; ')} — ` +
167
- `${compareOutput.regressions.length} regression(s) acknowledged for this run only; ` +
168
- 'floors still enforced. This does not persist: once the refresh is the ' +
169
- 'new base the ratchet re-enforces at full strength.',
170
- );
171
- return {
172
- acknowledged: true,
173
- compareOutput: {
174
- ...compareOutput,
175
- regressions: [],
176
- unchanged: [...compareOutput.unchanged, ...compareOutput.regressions],
177
- },
178
- };
179
- }
180
-
181
84
  function buildGateReport({
182
85
  kind,
183
86
  gateBlock,
@@ -186,7 +89,7 @@ function buildGateReport({
186
89
  breaches,
187
90
  compareOutput,
188
91
  cmp,
189
- acknowledged,
92
+ ack,
190
93
  }) {
191
94
  const kernel = checkKernelVersion(kind, baseline.kernelVersion);
192
95
  return {
@@ -212,7 +115,12 @@ function buildGateReport({
212
115
  // the JSON report alone.
213
116
  baseRead: cmp.baseRead === true,
214
117
  generatedAt: baseline.generatedAt,
215
- acknowledged,
118
+ acknowledged: ack.acknowledged,
119
+ // Story #5179 — `acknowledged` is now a PARTIAL statement: a refresh commit
120
+ // clears only the rows it actually refreshed, so a run can acknowledge some
121
+ // regressions and still fail on others. Naming the acknowledged keys makes
122
+ // which-half-was-which readable from the JSON report without re-walking git.
123
+ acknowledgedKeys: ack.acknowledgedKeys,
216
124
  };
217
125
  }
218
126
 
@@ -258,17 +166,16 @@ export async function evaluateKind({
258
166
  cmp,
259
167
  cwd,
260
168
  env,
169
+ headBaseline: baseline,
261
170
  });
262
- const compareOutput = ack.compareOutput;
263
- const acknowledged = ack.acknowledged;
264
171
  return buildGateReport({
265
172
  kind,
266
173
  gateBlock,
267
174
  baseline,
268
175
  findings,
269
176
  breaches,
270
- compareOutput,
177
+ compareOutput: ack.compareOutput,
271
178
  cmp,
272
- acknowledged,
179
+ ack,
273
180
  });
274
181
  }
@@ -0,0 +1,320 @@
1
+ /**
2
+ * refresh-ack.js — the one-shot baseline refresh acknowledgment (Story #5179).
3
+ *
4
+ * Extracted from `evaluate.js`, where it grew from a bundle-size env flag
5
+ * (Story #151) through a maintainability env-or-commit-tag pair (Story #4731)
6
+ * into the kind-generic trigger (Story #4802). Story #5179 narrowed the
7
+ * commit-tag arm from a whole-run blanket to the rows the tagged commit
8
+ * actually refreshed, which is enough logic to own its own module.
9
+ *
10
+ * @module lib/orchestration/check-baselines/phases/refresh-ack
11
+ */
12
+
13
+ import { resolveKindRefreshOverrides } from '../../../baselines/env-overrides.js';
14
+ import {
15
+ readBaseFromGit,
16
+ readRangeCommitsTouchingFile,
17
+ } from '../../../baselines/git-base.js';
18
+ import { getKindModule } from '../../../baselines/kernel.js';
19
+ import { Logger } from '../../../Logger.js';
20
+ import { applyTolerance } from './compare.js';
21
+ import { DEFAULT_BASELINE_PATHS } from './parse-args.js';
22
+
23
+ /** Default refresh-tag substring when the gate omits `refreshTag`. */
24
+ const DEFAULT_REFRESH_TAG = 'baseline-refresh:';
25
+
26
+ function resolveBaselinePath(kind, gateBlock) {
27
+ const configured =
28
+ typeof gateBlock?.baselinePath === 'string' && gateBlock.baselinePath.length
29
+ ? gateBlock.baselinePath
30
+ : null;
31
+ return configured ?? DEFAULT_BASELINE_PATHS[kind] ?? null;
32
+ }
33
+
34
+ function resolveRefreshTag(gateBlock) {
35
+ return typeof gateBlock?.refreshTag === 'string' &&
36
+ gateBlock.refreshTag.length
37
+ ? gateBlock.refreshTag
38
+ : DEFAULT_REFRESH_TAG;
39
+ }
40
+
41
+ /**
42
+ * Resolve the one-shot refresh trigger for any ratcheted kind. Two paths, either
43
+ * of which acknowledges:
44
+ *
45
+ * 1. Env parity: `<KIND>_REFRESH=1` (the manual override) — upper-snaked, so
46
+ * the two pre-existing names (`BUNDLE_SIZE_REFRESH`,
47
+ * `MAINTAINABILITY_REFRESH`) keep working unchanged.
48
+ * 2. Commit tag: a commit in the compared range `<baseRef>..HEAD` whose
49
+ * subject contains the configured `refreshTag` AND whose diff touches that
50
+ * kind's baseline file. One-shot by construction — once merged, the
51
+ * refreshed baseline becomes the base and the tag leaves the range.
52
+ *
53
+ * The tag is matched as a plain substring of a conventional commit subject, so
54
+ * commitlint stays satisfied (e.g. `chore(baselines): baseline-refresh: …`).
55
+ *
56
+ * The two arms are reported separately because Story #5179 scopes them
57
+ * differently: see `applyRefreshAcknowledgment`.
58
+ *
59
+ * Fails closed: a kind whose baseline path is neither configured nor present in
60
+ * `DEFAULT_BASELINE_PATHS` simply skips the commit-tag path rather than
61
+ * throwing, leaving that arm un-acknowledged.
62
+ *
63
+ * @returns {{ triggered: boolean, reasons: string[], envAcknowledged: boolean,
64
+ * refreshCommits: { sha: string, subject: string }[], baselinePath: string | null }}
65
+ */
66
+ function resolveRefreshTrigger({ kind, gateBlock, cmp, cwd, env }) {
67
+ const reasons = [];
68
+ const { acknowledged: envAcknowledged, overrides } =
69
+ resolveKindRefreshOverrides(kind, env);
70
+ if (envAcknowledged) reasons.push(...overrides);
71
+
72
+ const baselinePath = resolveBaselinePath(kind, gateBlock);
73
+ const refreshCommits = [];
74
+ const baseRef = cmp?.baseRef ?? null;
75
+ if (baseRef && typeof baselinePath === 'string' && baselinePath.length) {
76
+ const refreshTag = resolveRefreshTag(gateBlock);
77
+ const commits = readRangeCommitsTouchingFile(baseRef, baselinePath, {
78
+ cwd,
79
+ });
80
+ for (const commit of commits) {
81
+ if (!commit.subject.includes(refreshTag)) continue;
82
+ refreshCommits.push(commit);
83
+ reasons.push(
84
+ `refresh commit "${commit.subject}" (subject contains ${JSON.stringify(refreshTag)}, touches ${baselinePath})`,
85
+ );
86
+ }
87
+ }
88
+
89
+ return {
90
+ triggered: reasons.length > 0,
91
+ reasons,
92
+ envAcknowledged,
93
+ refreshCommits,
94
+ baselinePath,
95
+ };
96
+ }
97
+
98
+ /**
99
+ * Read the baseline rows as of one refresh commit. Returns `null` — never
100
+ * throws and never a partial row set — when the blob is absent, unreadable or
101
+ * unparseable at that SHA. The caller treats `null` as "this commit
102
+ * acknowledges nothing", which keeps the ratchet at full strength rather than
103
+ * acknowledging on a guess.
104
+ */
105
+ function readRowsAtCommit(sha, baselinePath, cwd) {
106
+ let raw;
107
+ try {
108
+ raw = readBaseFromGit(sha, baselinePath, { cwd });
109
+ } catch {
110
+ return null;
111
+ }
112
+ if (raw === null) return null;
113
+ try {
114
+ const payload = JSON.parse(raw);
115
+ return Array.isArray(payload?.rows) ? payload.rows : null;
116
+ } catch {
117
+ return null;
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Classify the head rows against one refresh commit's rows, using the kind's
123
+ * own classifier and the gate's own tolerance.
124
+ *
125
+ * Row identity is deliberately never derived here. A kind's `keyField` names
126
+ * the row property the kind is *about*, which is not always its compare key:
127
+ * CRAP declares `keyField: 'path'` but keys rows as `path::method@startLine`,
128
+ * because one file holds many methods. Reading `row[keyField]` would produce a
129
+ * key matching no regression, silently acknowledging nothing for that kind.
130
+ * `compare()` is the one thing that knows a kind's key, so every key here comes
131
+ * back out of it — the same reason direction and tolerance are delegated rather
132
+ * than reimplemented.
133
+ *
134
+ * That also collapses both tests into one classification:
135
+ *
136
+ * - `regressions` — present in both, worse at head: post-refresh drift.
137
+ * - `improvements` / `unchanged` — present in both, no worse: the rows this
138
+ * commit vouches for.
139
+ * - `additions` — present at head but absent from the refresh blob, i.e.
140
+ * never refreshed by this commit, so deliberately in neither set.
141
+ *
142
+ * @returns {{ ok: string[], drifted: string[] } | null} null when the
143
+ * classifier is unusable, which acknowledges nothing.
144
+ */
145
+ function classifyAgainstRefresh({ mod, headRows, refreshRows, tolerance }) {
146
+ try {
147
+ const result = mod.compare({ rows: headRows }, { rows: refreshRows });
148
+ const tolerated = applyTolerance(
149
+ {
150
+ regressions: result?.regressions ?? [],
151
+ improvements: result?.improvements ?? [],
152
+ unchanged: result?.unchanged ?? [],
153
+ additions: result?.additions ?? [],
154
+ },
155
+ tolerance ?? null,
156
+ );
157
+ return {
158
+ ok: [...tolerated.improvements, ...tolerated.unchanged].map((r) => r.key),
159
+ drifted: tolerated.regressions.map((r) => r.key),
160
+ };
161
+ } catch {
162
+ return null;
163
+ }
164
+ }
165
+
166
+ /**
167
+ * Which regression keys are acknowledgeable by the tagged commits (Story #5179)?
168
+ *
169
+ * The acknowledgment is a statement about what a refresh commit re-scored, so a
170
+ * key is acknowledgeable only when the commit both covered it and recorded a
171
+ * value the head has not since fallen below. Both tests come out of
172
+ * `classifyAgainstRefresh` above:
173
+ *
174
+ * 1. **Row membership** — a key absent from the refresh blob lands in
175
+ * `additions`, never in `ok`. This was the larger half of the leak: a
176
+ * single tagged commit cleared regressions on rows in directories it never
177
+ * touched.
178
+ * 2. **No post-refresh drift** — a key worse at head than the commit recorded
179
+ * lands in `drifted`. Drift from commits landing AFTER the refresh is what
180
+ * "the baseline commit must be the branch's last score-moving commit" asks
181
+ * for by convention and nothing enforced.
182
+ *
183
+ * Fails closed at every step: an unreadable blob, a missing `compare`, or a
184
+ * classifier that throws contributes nothing, so those regressions stand.
185
+ *
186
+ * @returns {Set<string>}
187
+ */
188
+ function acknowledgeableKeys({
189
+ kind,
190
+ headBaseline,
191
+ refreshCommits,
192
+ baselinePath,
193
+ cwd,
194
+ tolerance,
195
+ }) {
196
+ const acknowledgeable = new Set();
197
+ if (refreshCommits.length === 0) return acknowledgeable;
198
+ if (typeof baselinePath !== 'string' || baselinePath.length === 0)
199
+ return acknowledgeable;
200
+
201
+ let mod;
202
+ try {
203
+ mod = getKindModule(kind);
204
+ } catch {
205
+ return acknowledgeable;
206
+ }
207
+ if (typeof mod?.compare !== 'function') return acknowledgeable;
208
+
209
+ const headRows = Array.isArray(headBaseline?.rows) ? headBaseline.rows : [];
210
+ // `readRangeCommitsTouchingFile` returns newest-first, and a key the newest
211
+ // refresh already ruled on is not reopened by an older one: the newest is the
212
+ // state the branch is asking to be held to.
213
+ const decided = new Set();
214
+ for (const { sha } of refreshCommits) {
215
+ const refreshRows = readRowsAtCommit(sha, baselinePath, cwd);
216
+ if (refreshRows === null) continue;
217
+ const verdict = classifyAgainstRefresh({
218
+ mod,
219
+ headRows,
220
+ refreshRows,
221
+ tolerance,
222
+ });
223
+ if (verdict === null) continue;
224
+ for (const key of verdict.ok) {
225
+ if (decided.has(key)) continue;
226
+ decided.add(key);
227
+ acknowledgeable.add(key);
228
+ }
229
+ for (const key of verdict.drifted) decided.add(key);
230
+ }
231
+ return acknowledgeable;
232
+ }
233
+
234
+ function partitionRegressions(regressions, isAcknowledgeable) {
235
+ const acknowledged = [];
236
+ const kept = [];
237
+ for (const reg of regressions) {
238
+ if (isAcknowledgeable(reg)) acknowledged.push(reg);
239
+ else kept.push(reg);
240
+ }
241
+ return { acknowledged, kept };
242
+ }
243
+
244
+ function logAcknowledgment({ kind, reasons, acknowledged, kept }) {
245
+ const held =
246
+ kept.length > 0
247
+ ? `${kept.length} regression(s) NOT acknowledged (outside the refreshed rows, or drifted further after the refresh) and still fail the gate; `
248
+ : '';
249
+ Logger.warn(
250
+ `[${kind}] ⚠ ${reasons.join('; ')} — ` +
251
+ `${acknowledged.length} regression(s) acknowledged for this run only; ` +
252
+ `${held}floors still enforced. This does not persist: once the refresh is ` +
253
+ 'the new base the ratchet re-enforces at full strength.',
254
+ );
255
+ }
256
+
257
+ /**
258
+ * One-shot baseline refresh/acknowledge for any ratcheted kind. When triggered,
259
+ * demote acknowledged head-vs-base regressions to `unchanged` for this run only
260
+ * — floors still apply, so a row below its floor still breaches. The trigger is
261
+ * read fresh every run and never persisted: post-merge the refreshed baseline is
262
+ * the new base and the tag leaves the range, so the ratchet returns to full
263
+ * strength automatically.
264
+ *
265
+ * The two trigger arms are scoped differently, deliberately:
266
+ *
267
+ * - **Commit tag** — scoped to the rows the tagged commits actually refreshed,
268
+ * per `acknowledgeableKeys`. Before Story #5179 this cleared every regression
269
+ * in the range, so a branch merged carrying a stale row and the ratchet ran
270
+ * loose on that file; the failure recurred six times.
271
+ * - **Env parity** (`<KIND>_REFRESH=1`) — stays whole-run. There is no commit to
272
+ * anchor row-scoping to, and setting the variable is an explicit, deliberate
273
+ * operator act rather than a signal inferred from history.
274
+ *
275
+ * A no-op absent a trigger, so an unacknowledged run of any kind reports its
276
+ * regressions exactly as before.
277
+ *
278
+ * @returns {{ compareOutput: object, acknowledged: boolean, acknowledgedKeys: string[] }}
279
+ */
280
+ export function applyRefreshAcknowledgment(kind, compareOutput, ctx) {
281
+ const trigger = resolveRefreshTrigger({ ...ctx, kind });
282
+ if (!trigger.triggered || compareOutput.regressions.length === 0) {
283
+ return { compareOutput, acknowledged: false, acknowledgedKeys: [] };
284
+ }
285
+
286
+ let isAcknowledgeable;
287
+ if (trigger.envAcknowledged) {
288
+ isAcknowledgeable = () => true;
289
+ } else {
290
+ const keys = acknowledgeableKeys({
291
+ kind,
292
+ headBaseline: ctx.headBaseline,
293
+ refreshCommits: trigger.refreshCommits,
294
+ baselinePath: trigger.baselinePath,
295
+ cwd: ctx.cwd,
296
+ tolerance: ctx.gateBlock?.tolerance ?? null,
297
+ });
298
+ isAcknowledgeable = (reg) => keys.has(reg.key);
299
+ }
300
+
301
+ const { acknowledged, kept } = partitionRegressions(
302
+ compareOutput.regressions,
303
+ isAcknowledgeable,
304
+ );
305
+ if (acknowledged.length === 0) {
306
+ return { compareOutput, acknowledged: false, acknowledgedKeys: [] };
307
+ }
308
+
309
+ logAcknowledgment({ kind, reasons: trigger.reasons, acknowledged, kept });
310
+
311
+ return {
312
+ acknowledged: true,
313
+ acknowledgedKeys: acknowledged.map((reg) => reg.key),
314
+ compareOutput: {
315
+ ...compareOutput,
316
+ regressions: kept,
317
+ unchanged: [...compareOutput.unchanged, ...acknowledged],
318
+ },
319
+ };
320
+ }
@@ -34,7 +34,14 @@ function formatGateLine(g) {
34
34
  g.baseRef && g.baseRead === false
35
35
  ? ' [baseRead=false — compare skipped]'
36
36
  : '';
37
- const ack = g.acknowledged ? ' [ACKNOWLEDGED this run only]' : '';
37
+ // Story #5179 a refresh commit acknowledges only the rows it refreshed, so
38
+ // the count says how much was cleared and the status ahead of it still shows
39
+ // FAIL when regressions outside those rows survived. `buildGateReport` always
40
+ // sets `acknowledgedKeys` alongside `acknowledged` from the same result
41
+ // object, so the array needs no guard of its own here.
42
+ const ack = g.acknowledged
43
+ ? ` [ACKNOWLEDGED ${g.acknowledgedKeys.length} row(s) — this run only]`
44
+ : '';
38
45
  return ` - ${g.kind}: ${status}${drift}${baseRef}${baseRead}${ack}`;
39
46
  }
40
47
 
@@ -912,6 +912,10 @@ async function gatherEnvelopeInputs({
912
912
  epic: { id: 0, title: epicTitle, body: seed },
913
913
  github: config.github ?? null,
914
914
  cwd,
915
+ // Story #5182 — the memory-hygiene advisory's two thresholds.
916
+ // They live on `planning`, not the `project` block `settings`
917
+ // carries, so they ride the opts bag rather than that legacy one.
918
+ memoryPool: config.planning?.memoryPool ?? null,
915
919
  },
916
920
  ),
917
921
  () =>
@@ -165,7 +165,15 @@ export async function buildAuthoringContext(
165
165
  () => buildPlanningDocsContext({ seedIssueId: epic.id, settings, cwd }),
166
166
  () => verifyBddRunnerPendingTag({ cwd: PROJECT_ROOT }),
167
167
  () => scanBddScenariosBestEffort(),
168
- () => buildMemoryPoolAdvisory({ cwd: PROJECT_ROOT }),
168
+ () =>
169
+ buildMemoryPoolAdvisory({
170
+ cwd: PROJECT_ROOT,
171
+ // Story #5182 — `planning.memoryPool` thresholds. Spread so an
172
+ // unset block, or a block setting only one key, leaves the other
173
+ // on its framework default rather than passing `undefined` in as
174
+ // a value the builder would have to re-defaults itself.
175
+ ...(opts.memoryPool ?? {}),
176
+ }),
169
177
  () =>
170
178
  fetchPriorFeedback({
171
179
  owner: githubCfg?.owner,