mandrel 2.42.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.
@@ -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,
@@ -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
  }
@@ -2,8 +2,8 @@
2
2
  description: >-
3
3
  Attended consolidation pass over this project's agent memory pool — merge
4
4
  duplicates, verify claims against the current tree, prune with operator
5
- confirmation, rewrite the index, and stamp the pool so the /mandrel-plan advisory
6
- goes quiet.
5
+ confirmation, rewrite the index, and stamp the pool with the date and entry
6
+ count the /mandrel-plan advisory measures its next nudge against.
7
7
  ---
8
8
 
9
9
  # /memory-consolidate [--dry-run]
@@ -93,14 +93,26 @@ pointers only, never memory content.
93
93
  Then write the receipt to `.consolidation-stamp.json` in the pool root:
94
94
 
95
95
  ```json
96
- { "lastConsolidatedAt": "<ISO-8601 timestamp>" }
96
+ { "lastConsolidatedAt": "<ISO-8601 timestamp>", "entryCount": 42 }
97
97
  ```
98
98
 
99
- The `/mandrel-plan` Phase 0 advisory reads this file; until it is written, the nudge
100
- keeps firing. Write it **only** after Gate #2 the stamp asserts an operator
101
- reviewed the pass, so writing it early makes it a lie.
99
+ `entryCount` is the surviving non-index `*.md` count **after** the rewrite
100
+ count the directory, never the plan. It is the baseline the next run measures
101
+ growth against, so a wrong number silently mis-arms the nudge.
102
+
103
+ The `/mandrel-plan` Phase 0 advisory re-arms on exactly two conditions: the
104
+ stamp aging past `planning.memoryPool.staleAfterDays` (30), or
105
+ `planning.memoryPool.growthDelta` (25) entries written since that count. Pool
106
+ size alone never triggers it — a pass that keeps every entry still quiets the
107
+ nudge. A stamp with no `entryCount` leaves growth unmeasured, and only the age
108
+ arm can speak until the next pass writes one.
109
+
110
+ Write it **only** after Gate #2 — the stamp asserts an operator reviewed the
111
+ pass, so writing it early makes it a lie.
102
112
 
103
113
  Close with counts: entries read, corrected, merged, pruned, and the new total.
114
+ Then the forecast the operator would otherwise derive by hand: when the
115
+ advisory next fires, and which arm reaches it first.
104
116
 
105
117
  ## Constraints
106
118
 
package/docs/CHANGELOG.md CHANGED
@@ -15,6 +15,13 @@ All notable changes to this project will be documented in this file.
15
15
  -->
16
16
  <!-- markdownlint-disable-file MD004 MD012 MD037 -->
17
17
 
18
+ ## [2.43.0](https://github.com/dsj1984/mandrel/compare/mandrel-v2.42.0...mandrel-v2.43.0) (2026-09-07)
19
+
20
+
21
+ ### Added
22
+
23
+ * memory-pool advisory measures growth since the last consolidation instead of an absolute entry ceiling ([#5182](https://github.com/dsj1984/mandrel/issues/5182)) ([#5183](https://github.com/dsj1984/mandrel/issues/5183)) ([cccdd69](https://github.com/dsj1984/mandrel/commit/cccdd69546e5dfb4c6d1e205423ff52f07982ade))
24
+
18
25
  ## [2.42.0](https://github.com/dsj1984/mandrel/compare/mandrel-v2.41.0...mandrel-v2.42.0) (2026-09-06)
19
26
 
20
27
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mandrel",
3
- "version": "2.42.0",
3
+ "version": "2.43.0",
4
4
  "description": "Claude Code-first opinionated workflow framework: instructions, skills, rules, and SDLC workflows that govern AI coding assistants.",
5
5
  "files": [
6
6
  ".agents/",