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.
- package/.agents/docs/agentrc-reference.json +4 -0
- package/.agents/docs/configuration.md +4 -1
- package/.agents/docs/workflows.md +1 -1
- package/.agents/schemas/agentrc.schema.json +20 -1
- package/.agents/scripts/lib/config-settings-schema.js +29 -1
- package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
- package/.agents/scripts/lib/orchestration/plan-context.js +4 -0
- package/.agents/scripts/lib/orchestration/planning/authoring-context.js +9 -1
- package/.agents/scripts/lib/orchestration/planning/memory-pool-advisory.js +159 -55
- package/.agents/workflows/memory-consolidate.md +18 -6
- package/docs/CHANGELOG.md +7 -0
- package/package.json +1 -1
|
@@ -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
|
-
() =>
|
|
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
|
|
44
|
-
const
|
|
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
|
-
*
|
|
98
|
-
*
|
|
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
|
|
108
|
-
|
|
109
|
-
|
|
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
|
|
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.
|
|
263
|
+
* @param {number} [opts.growthDelta]
|
|
145
264
|
* @returns {{ present: boolean, entryCount: number, lastConsolidatedAt: string|null,
|
|
146
|
-
*
|
|
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
|
-
|
|
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
|
|
188
|
-
|
|
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
|
-
|
|
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
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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
|
-
|
|
223
|
-
entryCount,
|
|
224
|
-
lastConsolidatedAt,
|
|
329
|
+
return envelope({
|
|
330
|
+
...found,
|
|
225
331
|
recommend: reasons.length > 0,
|
|
226
332
|
reasons:
|
|
227
|
-
reasons.length > 0
|
|
228
|
-
|
|
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
|
|
6
|
-
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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