mandrel 2.42.0 → 2.44.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 (25) hide show
  1. package/.agents/docs/agentrc-reference.json +4 -0
  2. package/.agents/docs/configuration.md +4 -1
  3. package/.agents/docs/workflows.md +1 -1
  4. package/.agents/schemas/agentrc.schema.json +20 -1
  5. package/.agents/scripts/lib/ITicketingProvider.js +27 -0
  6. package/.agents/scripts/lib/config-settings-schema.js +29 -1
  7. package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
  8. package/.agents/scripts/lib/observability/source-classifier.js +1 -0
  9. package/.agents/scripts/lib/orchestration/git-cleanup/phases/branches-detect.js +187 -0
  10. package/.agents/scripts/lib/orchestration/git-cleanup/phases/branches.js +37 -90
  11. package/.agents/scripts/lib/orchestration/git-cleanup/phases/git-probes.js +115 -19
  12. package/.agents/scripts/lib/orchestration/git-cleanup/phases/render.js +10 -2
  13. package/.agents/scripts/lib/orchestration/plan-context.js +4 -0
  14. package/.agents/scripts/lib/orchestration/plan-run-labels/reap.js +327 -0
  15. package/.agents/scripts/lib/orchestration/planning/authoring-context.js +9 -1
  16. package/.agents/scripts/lib/orchestration/planning/memory-pool-advisory.js +159 -55
  17. package/.agents/scripts/lib/orchestration/review-providers/scoped-lint.js +117 -36
  18. package/.agents/scripts/lib/orchestration/single-story-close/phases/post-land.js +81 -0
  19. package/.agents/scripts/lib/orchestration/single-story-close/phases/review-outcome.js +12 -7
  20. package/.agents/scripts/providers/github/labels.js +88 -0
  21. package/.agents/scripts/providers/github.js +2 -0
  22. package/.agents/scripts/prune-plan-run-labels.js +218 -0
  23. package/.agents/workflows/memory-consolidate.md +18 -6
  24. package/docs/CHANGELOG.md +20 -0
  25. package/package.json +1 -1
@@ -0,0 +1,327 @@
1
+ /**
2
+ * plan-run-labels/reap.js — the cohort label's end of life (Story #5189).
3
+ *
4
+ * `plan-persist` mints one `plan-run::<id>` label per run so the Stories one
5
+ * plan authored stay filterable in the GitHub UI. That is load-bearing while
6
+ * any Story in the cohort is open, and inert the moment they are all closed —
7
+ * but nothing expressed the second half, so the vocabulary grew one label per
8
+ * persist forever. A consumer repository measured 235 `plan-run::` labels out
9
+ * of 313 total; past that a paged label listing stops seeing the labels that
10
+ * sort after the pile, and every list-then-create caller starts failing.
11
+ *
12
+ * This module is the **one decision engine** behind both surfaces that act on
13
+ * that end of life: the per-Story close tail (automatic, incremental) and
14
+ * `prune-plan-run-labels.js` (manual, whole-repository). Keeping the decision
15
+ * here rather than in either caller is what stops the two from disagreeing
16
+ * about when a label is spent.
17
+ *
18
+ * **The decision.** A cohort label is reapable only when it carries at least
19
+ * one issue and every issue carrying it is closed. The "at least one" clause
20
+ * is the subtle half: a label carrying *zero* issues is indistinguishable from
21
+ * one an in-flight persist has just minted before creating its Stories, and
22
+ * deleting it would break that run. So a zero-issue label is reported under an
23
+ * `unreferenced` reason and reaped only under an explicit opt-in — a default
24
+ * sweep is safe to run concurrently with a persist.
25
+ *
26
+ * **Never load-bearing.** Both surfaces treat a reap failure as a warning.
27
+ * Label hygiene is a chore; nothing downstream reads the cohort label as an
28
+ * input (see `PLAN_RUN_LABEL_PREFIX`'s own docblock), so a failed delete costs
29
+ * a stale label and nothing else.
30
+ *
31
+ * @module lib/orchestration/plan-run-labels/reap
32
+ * @see Story #5189
33
+ */
34
+
35
+ import { PLAN_RUN_LABEL_PREFIX } from '../plan-persist/story-ops.js';
36
+
37
+ /**
38
+ * Why a cohort label was — or was not — judged reapable. One reason per
39
+ * label, so a `--json` report is auditable without re-deriving anything.
40
+ *
41
+ * - `all-closed` — carries issues, every one closed. Reapable.
42
+ * - `open-stories` — at least one issue still open. Not reapable, ever.
43
+ * - `unreferenced` — carries no issues at all. Reapable only under the
44
+ * explicit opt-in, because an in-flight persist looks exactly like this.
45
+ */
46
+ export const REAP_REASONS = Object.freeze({
47
+ ALL_CLOSED: 'all-closed',
48
+ OPEN_STORIES: 'open-stories',
49
+ UNREFERENCED: 'unreferenced',
50
+ });
51
+
52
+ /**
53
+ * Is `name` a plan-run cohort label?
54
+ *
55
+ * The prefix is imported from `plan-persist/story-ops.js` rather than
56
+ * re-declared: minting and reaping must not be able to drift onto two
57
+ * different strings, which is exactly the failure a copied literal invites.
58
+ *
59
+ * @param {unknown} name
60
+ * @returns {boolean}
61
+ */
62
+ function isPlanRunLabel(name) {
63
+ return typeof name === 'string' && name.startsWith(PLAN_RUN_LABEL_PREFIX);
64
+ }
65
+
66
+ /**
67
+ * Project an arbitrary label collection onto the sorted, de-duplicated set of
68
+ * cohort label names. Accepts either bare strings (a ticket's `labels[]`) or
69
+ * `{ name }` rows (the label listing port), so both callers hand this whatever
70
+ * their own read returned.
71
+ *
72
+ * @param {Array<string|{ name?: string }>} [names]
73
+ * @returns {string[]}
74
+ */
75
+ function selectCohortLabels(names) {
76
+ const seen = new Set();
77
+ for (const raw of Array.isArray(names) ? names : []) {
78
+ const name = typeof raw === 'string' ? raw : raw?.name;
79
+ if (isPlanRunLabel(name)) seen.add(name);
80
+ }
81
+ return [...seen].sort();
82
+ }
83
+
84
+ /**
85
+ * Decide one cohort label.
86
+ *
87
+ * Reads through `listIssuesByLabel({ state: 'all' })` — the paginating read
88
+ * port — so the verdict is never a function of how many issues fit on one API
89
+ * page. `state` is compared case-insensitively against `closed` and anything
90
+ * else counts as open: an unknown state must never be read as "safe to
91
+ * delete".
92
+ *
93
+ * @param {{ provider: object, label: string, includeUnreferenced?: boolean }} args
94
+ * @returns {Promise<{
95
+ * label: string,
96
+ * reapable: boolean,
97
+ * reason: string,
98
+ * issueCount: number,
99
+ * openIssues: number[],
100
+ * }>}
101
+ */
102
+ async function decideCohortLabel({
103
+ provider,
104
+ label,
105
+ includeUnreferenced = false,
106
+ }) {
107
+ const result = await provider.listIssuesByLabel({
108
+ state: 'all',
109
+ labels: label,
110
+ });
111
+ const issues = Array.isArray(result) ? result : [];
112
+ const open = issues.filter(
113
+ (issue) => String(issue?.state ?? '').toLowerCase() !== 'closed',
114
+ );
115
+ if (issues.length === 0) {
116
+ return {
117
+ label,
118
+ reapable: includeUnreferenced === true,
119
+ reason: REAP_REASONS.UNREFERENCED,
120
+ issueCount: 0,
121
+ openIssues: [],
122
+ };
123
+ }
124
+ if (open.length > 0) {
125
+ return {
126
+ label,
127
+ reapable: false,
128
+ reason: REAP_REASONS.OPEN_STORIES,
129
+ issueCount: issues.length,
130
+ openIssues: open
131
+ .map((issue) => issue?.number)
132
+ .filter((n) => Number.isInteger(n)),
133
+ };
134
+ }
135
+ return {
136
+ label,
137
+ reapable: true,
138
+ reason: REAP_REASONS.ALL_CLOSED,
139
+ issueCount: issues.length,
140
+ openIssues: [],
141
+ };
142
+ }
143
+
144
+ /**
145
+ * Decide a set of cohort labels.
146
+ *
147
+ * Sequential on purpose. The whole-repository sweep can face hundreds of
148
+ * labels, and a fan-out over a shared REST budget buys wall-clock at the cost
149
+ * of the one property an operator auditing a pile actually needs: a
150
+ * deterministic, label-ordered report.
151
+ *
152
+ * @param {{
153
+ * provider: object,
154
+ * labels: Array<string|{ name?: string }>,
155
+ * includeUnreferenced?: boolean,
156
+ * }} args
157
+ * @returns {Promise<Array<object>>} one decision per cohort label, name-sorted.
158
+ */
159
+ async function evaluateCohortLabels({
160
+ provider,
161
+ labels,
162
+ includeUnreferenced = false,
163
+ }) {
164
+ const decisions = [];
165
+ for (const label of selectCohortLabels(labels)) {
166
+ decisions.push(
167
+ await decideCohortLabel({ provider, label, includeUnreferenced }),
168
+ );
169
+ }
170
+ return decisions;
171
+ }
172
+
173
+ /**
174
+ * Decide, then (unless `check`) delete.
175
+ *
176
+ * A delete that throws is recorded in `failed[]` and warned about rather than
177
+ * propagated: one unreachable label must not abandon the rest of a sweep, and
178
+ * on the close path it must not touch the land. A delete the provider reports
179
+ * as a no-op (`deleted: false` — the label was already gone) is still a
180
+ * success; that is what makes a re-run idempotent.
181
+ *
182
+ * @param {{
183
+ * provider: object,
184
+ * labels: Array<string|{ name?: string }>,
185
+ * includeUnreferenced?: boolean,
186
+ * check?: boolean,
187
+ * onWarn?: ((message: string) => void)|null,
188
+ * }} args
189
+ * @returns {Promise<{
190
+ * check: boolean,
191
+ * decisions: Array<object>,
192
+ * reapable: string[],
193
+ * deleted: Array<{ label: string, existed: boolean }>,
194
+ * failed: Array<{ label: string, detail: string }>,
195
+ * }>}
196
+ */
197
+ async function reapCohortLabels({
198
+ provider,
199
+ labels,
200
+ includeUnreferenced = false,
201
+ check = false,
202
+ onWarn = null,
203
+ }) {
204
+ const decisions = await evaluateCohortLabels({
205
+ provider,
206
+ labels,
207
+ includeUnreferenced,
208
+ });
209
+ const reapable = decisions.filter((d) => d.reapable).map((d) => d.label);
210
+ const deleted = [];
211
+ const failed = [];
212
+ if (check !== true) {
213
+ for (const label of reapable) {
214
+ try {
215
+ const outcome = await provider.deleteLabel(label);
216
+ deleted.push({ label, existed: outcome?.deleted !== false });
217
+ } catch (err) {
218
+ const detail = String(err?.message ?? err);
219
+ failed.push({ label, detail });
220
+ onWarn?.(`could not delete cohort label "${label}": ${detail}`);
221
+ }
222
+ }
223
+ }
224
+ return { check: check === true, decisions, reapable, deleted, failed };
225
+ }
226
+
227
+ /**
228
+ * The automatic surface's entry point: reap the cohort labels carried by the
229
+ * Story that just closed.
230
+ *
231
+ * The Story's own label set comes from `getTicket` (labels are immutable for
232
+ * this purpose, so a cached snapshot is fine); every *state* judgment comes
233
+ * from the fresh `listIssuesByLabel` read inside {@link decideCohortLabel}, so
234
+ * a primed ticket cache cannot make a still-open sibling look closed.
235
+ *
236
+ * A Story whose own issue has not yet registered as closed — the merge webhook
237
+ * that fires `Closes #<id>` is not instantaneous — simply reports
238
+ * `open-stories` and is left alone. Deferring is the safe direction, and the
239
+ * manual sweep is the backstop that collects whatever a race leaves behind.
240
+ *
241
+ * @param {{
242
+ * storyId: number,
243
+ * provider: object,
244
+ * includeUnreferenced?: boolean,
245
+ * onWarn?: ((message: string) => void)|null,
246
+ * }} args
247
+ * @returns {Promise<object>} the {@link reapCohortLabels} envelope, plus
248
+ * `evaluated` — how many cohort labels the Story carried.
249
+ */
250
+ export async function reapPlanRunLabelsForStory({
251
+ storyId,
252
+ provider,
253
+ includeUnreferenced = false,
254
+ onWarn = null,
255
+ }) {
256
+ const ticket = await provider.getTicket(storyId);
257
+ const labels = selectCohortLabels(ticket?.labels);
258
+ if (labels.length === 0) {
259
+ return {
260
+ evaluated: 0,
261
+ check: false,
262
+ decisions: [],
263
+ reapable: [],
264
+ deleted: [],
265
+ failed: [],
266
+ };
267
+ }
268
+ const outcome = await reapCohortLabels({
269
+ provider,
270
+ labels,
271
+ includeUnreferenced,
272
+ onWarn,
273
+ });
274
+ return { evaluated: labels.length, ...outcome };
275
+ }
276
+
277
+ /**
278
+ * The manual surface's entry point: sweep every cohort label in the repository.
279
+ *
280
+ * Reads the whole label vocabulary through the paginating listing port and
281
+ * projects it onto the cohort axis here, so the sweep is bounded by what the
282
+ * repository actually holds rather than by an API page size.
283
+ *
284
+ * @param {{
285
+ * provider: object,
286
+ * includeUnreferenced?: boolean,
287
+ * check?: boolean,
288
+ * onWarn?: ((message: string) => void)|null,
289
+ * }} args
290
+ * @returns {Promise<object>} the {@link reapCohortLabels} envelope, plus
291
+ * `totalLabels` (whole vocabulary) and `evaluated` (the cohort slice).
292
+ */
293
+ export async function sweepCohortLabels({
294
+ provider,
295
+ includeUnreferenced = false,
296
+ check = false,
297
+ onWarn = null,
298
+ }) {
299
+ const all = await provider.listLabels();
300
+ const rows = Array.isArray(all) ? all : [];
301
+ const labels = selectCohortLabels(rows);
302
+ const outcome = await reapCohortLabels({
303
+ provider,
304
+ labels,
305
+ includeUnreferenced,
306
+ check,
307
+ onWarn,
308
+ });
309
+ return { totalLabels: rows.length, evaluated: labels.length, ...outcome };
310
+ }
311
+
312
+ /**
313
+ * Test-only surface. The five helpers below compose the two exported entry
314
+ * points (`reapPlanRunLabelsForStory`, `sweepCohortLabels`) and have no
315
+ * production consumer outside this module, so exporting each one individually
316
+ * would advertise five API surfaces nothing imports — and `dead-exports
317
+ * --production` correctly reports each as dead. They are still worth unit
318
+ * testing per arm, which is what this barrel is for; it follows the same
319
+ * `__testing` idiom `git-probes.js` and `source-classifier.js` use.
320
+ */
321
+ export const __testing = {
322
+ isPlanRunLabel,
323
+ selectCohortLabels,
324
+ decideCohortLabel,
325
+ evaluateCohortLabels,
326
+ reapCohortLabels,
327
+ };
@@ -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,
@@ -19,12 +19,28 @@
19
19
  * only the attended `/memory-consolidate` pass, reading content, can tell the
20
20
  * difference. This module counts and stats; it never judges an entry.
21
21
  *
22
+ * **Growth, never size (Story #5182).** The second arm used to be an absolute
23
+ * ceiling of a hundred entries. A consolidation pass prefers `correct` over
24
+ * `dead` by design, so a pool that crosses a fixed ceiling stays over it
25
+ * forever: the nudge then fired on every plan however fresh the stamp, and a
26
+ * permanent recommendation is one the operator learns to ignore. The arm now
27
+ * measures **entries written since the last pass** — the one quantity a pass
28
+ * actually resets, because Step 6 records the post-rewrite entry count in the
29
+ * stamp as the next run's growth baseline.
30
+ *
31
+ * A stamp carrying a date but no usable `entryCount` (every stamp written
32
+ * before that Story) leaves growth **unmeasured**. That is not
33
+ * "never consolidated" — an operator did review the pool — so the growth arm
34
+ * simply stays silent and only the age arm can speak, until the next pass
35
+ * writes a baseline.
36
+ *
22
37
  * Detection is filesystem-only — no child processes, no `gh` probes, no
23
38
  * network. Every failure path fails soft to "no pool, no recommendation": the
24
39
  * advisory can degrade the nudge, never a plan.
25
40
  *
26
41
  * Test seams: `cwd`, `env`, `fsImpl` (node:fs-compatible `statSync` /
27
- * `readdirSync` / `readFileSync`), `now`, and the two thresholds.
42
+ * `readdirSync` / `readFileSync`), `now`, and the two thresholds
43
+ * (`staleAfterDays`, `growthDelta`).
28
44
  *
29
45
  * `buildMemoryPoolAdvisory` is the **only** export: the helpers below have no
30
46
  * caller outside this module, and exporting one solely for a test would add a
@@ -40,8 +56,8 @@ import * as path from 'node:path';
40
56
  /** Recommend a consolidation pass once the stamp is this old. */
41
57
  const STALE_AFTER_DAYS = 30;
42
58
 
43
- /** Recommend a consolidation pass once the pool holds more entries than this. */
44
- const ENTRY_COUNT_CEILING = 100;
59
+ /** Recommend a pass once this many entries were written since the last one. */
60
+ const GROWTH_DELTA = 25;
45
61
 
46
62
  /** Stamp file written by `/memory-consolidate` after its operator gate. */
47
63
  const STAMP_FILENAME = '.consolidation-stamp.json';
@@ -94,21 +110,47 @@ function resolveMemoryPoolDir({ cwd, env = process.env, homedir } = {}) {
94
110
  }
95
111
 
96
112
  /**
97
- * Read the consolidation stamp, returning its ISO timestamp or `null`.
98
- * A missing, unreadable, unparseable, or malformed stamp is indistinguishable
113
+ * The growth baseline a stamp records: its entry count, or `null` when it
114
+ * records none. `null` is *unmeasured*, never zero — a zero baseline would
115
+ * score every entry in the pool as newly written.
116
+ *
117
+ * @param {unknown} count
118
+ * @returns {number|null}
119
+ */
120
+ function readBaseline(count) {
121
+ return Number.isInteger(count) && count >= 0 ? count : null;
122
+ }
123
+
124
+ /**
125
+ * Read the consolidation stamp.
126
+ *
127
+ * `at` is the ISO timestamp of the last pass, or `null` when there was none:
128
+ * a missing, unreadable, unparseable or date-less stamp is indistinguishable
99
129
  * from "never consolidated" — all four mean the same thing to the advisory.
130
+ * A stamp whose date is unusable carries no baseline either, so `baseline`
131
+ * follows it to `null` rather than describing a pass that cannot be dated.
132
+ *
133
+ * `baseline` is the entry count that pass left behind — the growth arm's
134
+ * reference point. It is `null` on a stamp that predates Story #5182 (date
135
+ * only) and on a malformed count, which reads as *unmeasured growth*, never
136
+ * as zero growth: a `0` baseline would score the whole pool as new.
100
137
  *
101
- * @returns {string|null}
138
+ * @returns {{ at: string|null, baseline: number|null }}
102
139
  */
103
140
  function readStamp({ poolDir, fsImpl }) {
141
+ const unstamped = { at: null, baseline: null };
104
142
  try {
105
143
  const raw = fsImpl.readFileSync(path.join(poolDir, STAMP_FILENAME), 'utf8');
106
144
  const parsed = JSON.parse(raw);
107
- const value = parsed?.lastConsolidatedAt;
108
- if (typeof value !== 'string' || value.length === 0) return null;
109
- return Number.isNaN(Date.parse(value)) ? null : value;
145
+ const at = parsed.lastConsolidatedAt;
146
+ // `Date.parse` rejects the empty string as NaN, so this one test covers
147
+ // both an absent date and an unusable one.
148
+ if (typeof at !== 'string' || Number.isNaN(Date.parse(at))) {
149
+ return unstamped;
150
+ }
151
+ return { at, baseline: readBaseline(parsed.entryCount) };
110
152
  } catch {
111
- return null;
153
+ return unstamped;
112
154
  }
113
155
  }
114
156
 
@@ -127,6 +169,83 @@ function countEntries({ poolDir, fsImpl }) {
127
169
  }
128
170
  }
129
171
 
172
+ /**
173
+ * The advisory's field set, defaulted to the fail-soft "no usable pool"
174
+ * reading. Every return path spreads its own findings over this, so the
175
+ * envelope's shape is declared once — a new field cannot reach some callers
176
+ * and not others, which is the failure mode a per-branch object literal has.
177
+ *
178
+ * @param {object} fields
179
+ * @returns {{ present: boolean, entryCount: number, lastConsolidatedAt: string|null,
180
+ * entriesSinceConsolidation: number|null, recommend: boolean,
181
+ * reasons: string[] }}
182
+ */
183
+ function envelope(fields) {
184
+ return {
185
+ present: false,
186
+ entryCount: 0,
187
+ lastConsolidatedAt: null,
188
+ entriesSinceConsolidation: null,
189
+ recommend: false,
190
+ reasons: [],
191
+ ...fields,
192
+ };
193
+ }
194
+
195
+ /**
196
+ * Collect the reasons a pool wants a consolidation pass. An empty array is
197
+ * the quiet verdict; the caller turns it into `recommend` and supplies the
198
+ * standing-down sentence, so every arm lives in one place.
199
+ *
200
+ * The two arms are independent and both are reported when both fire.
201
+ *
202
+ * @param {{ stamp: { at: string|null, baseline: number|null },
203
+ * growth: number|null, now: Date|string|number,
204
+ * staleAfterDays: number, growthDelta: number }} args
205
+ * @returns {string[]}
206
+ */
207
+ function collectReasons({ stamp, growth, now, staleAfterDays, growthDelta }) {
208
+ const reasons = [];
209
+
210
+ if (stamp.at === null) {
211
+ reasons.push(
212
+ 'no consolidation stamp — this pool has never been consolidated',
213
+ );
214
+ } else {
215
+ const ageDays =
216
+ (new Date(now).getTime() - Date.parse(stamp.at)) / MS_PER_DAY;
217
+ if (ageDays > staleAfterDays) {
218
+ reasons.push(
219
+ `last consolidated ${Math.floor(ageDays)} days ago (over the ${staleAfterDays}-day threshold)`,
220
+ );
221
+ }
222
+ }
223
+
224
+ // `growth === null` is unmeasured, not zero — a pre-#5182 stamp carries no
225
+ // baseline, and guessing one would re-invent the ceiling this arm replaced.
226
+ if (growth !== null && growth >= growthDelta) {
227
+ reasons.push(
228
+ `${growth} entries written since the last consolidation (at or over the ${growthDelta}-entry growth delta)`,
229
+ );
230
+ }
231
+
232
+ return reasons;
233
+ }
234
+
235
+ /**
236
+ * The sentence a quiet pool explains itself with — one per reason it is quiet,
237
+ * so "nothing to do" never reads the same as "nothing measurable".
238
+ *
239
+ * @param {{ growth: number|null, growthDelta: number }} args
240
+ * @returns {string}
241
+ */
242
+ function quietReason({ growth, growthDelta }) {
243
+ if (growth === null) {
244
+ return 'memory pool is within the freshness threshold; growth is unmeasured until the next /memory-consolidate stamps an entry count';
245
+ }
246
+ return `memory pool is within both thresholds — ${growth} entries written since the last consolidation (under the ${growthDelta}-entry growth delta)`;
247
+ }
248
+
130
249
  /**
131
250
  * Build the `memoryPoolAdvisory` envelope field.
132
251
  *
@@ -141,9 +260,10 @@ function countEntries({ poolDir, fsImpl }) {
141
260
  * @param {string} [opts.homedir]
142
261
  * @param {Date|string|number} [opts.now]
143
262
  * @param {number} [opts.staleAfterDays]
144
- * @param {number} [opts.entryCountCeiling]
263
+ * @param {number} [opts.growthDelta]
145
264
  * @returns {{ present: boolean, entryCount: number, lastConsolidatedAt: string|null,
146
- * recommend: boolean, reasons: string[] }}
265
+ * entriesSinceConsolidation: number|null, recommend: boolean,
266
+ * reasons: string[] }}
147
267
  */
148
268
  export function buildMemoryPoolAdvisory({
149
269
  cwd = process.cwd(),
@@ -152,15 +272,9 @@ export function buildMemoryPoolAdvisory({
152
272
  homedir,
153
273
  now = new Date(),
154
274
  staleAfterDays = STALE_AFTER_DAYS,
155
- entryCountCeiling = ENTRY_COUNT_CEILING,
275
+ growthDelta = GROWTH_DELTA,
156
276
  } = {}) {
157
- const absent = (reason) => ({
158
- present: false,
159
- entryCount: 0,
160
- lastConsolidatedAt: null,
161
- recommend: false,
162
- reasons: [reason],
163
- });
277
+ const absent = (reason) => envelope({ reasons: [reason] });
164
278
 
165
279
  const poolDir = resolveMemoryPoolDir({ cwd, env, homedir });
166
280
  if (!poolDir) {
@@ -184,48 +298,38 @@ export function buildMemoryPoolAdvisory({
184
298
  return absent(`memory pool at ${poolDir} could not be listed`);
185
299
  }
186
300
 
187
- const lastConsolidatedAt = readStamp({ poolDir, fsImpl });
188
- const reasons = [];
301
+ const stamp = readStamp({ poolDir, fsImpl });
302
+ // Reported raw: a pruning pass can leave this negative, and saying the pool
303
+ // shrank by 7 is more use to the operator than clamping it to zero.
304
+ const growth = stamp.baseline === null ? null : entryCount - stamp.baseline;
305
+
306
+ const found = {
307
+ present: true,
308
+ entryCount,
309
+ lastConsolidatedAt: stamp.at,
310
+ entriesSinceConsolidation: growth,
311
+ };
189
312
 
190
313
  // An empty pool has nothing to consolidate, whatever the stamp says.
191
314
  if (entryCount === 0) {
192
- return {
193
- present: true,
194
- entryCount: 0,
195
- lastConsolidatedAt,
196
- recommend: false,
315
+ return envelope({
316
+ ...found,
197
317
  reasons: ['memory pool is empty — nothing to consolidate'],
198
- };
318
+ });
199
319
  }
200
320
 
201
- if (lastConsolidatedAt === null) {
202
- reasons.push(
203
- 'no consolidation stamp — this pool has never been consolidated',
204
- );
205
- } else {
206
- const ageDays =
207
- (new Date(now).getTime() - Date.parse(lastConsolidatedAt)) / MS_PER_DAY;
208
- if (ageDays > staleAfterDays) {
209
- reasons.push(
210
- `last consolidated ${Math.floor(ageDays)} days ago (over the ${staleAfterDays}-day threshold)`,
211
- );
212
- }
213
- }
214
-
215
- if (entryCount > entryCountCeiling) {
216
- reasons.push(
217
- `${entryCount} entries (over the ${entryCountCeiling}-entry threshold)`,
218
- );
219
- }
321
+ const reasons = collectReasons({
322
+ stamp,
323
+ growth,
324
+ now,
325
+ staleAfterDays,
326
+ growthDelta,
327
+ });
220
328
 
221
- return {
222
- present: true,
223
- entryCount,
224
- lastConsolidatedAt,
329
+ return envelope({
330
+ ...found,
225
331
  recommend: reasons.length > 0,
226
332
  reasons:
227
- reasons.length > 0
228
- ? reasons
229
- : ['memory pool is within both freshness thresholds'],
230
- };
333
+ reasons.length > 0 ? reasons : [quietReason({ growth, growthDelta })],
334
+ });
231
335
  }