mandrel 2.56.0 → 2.58.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 (114) hide show
  1. package/.agents/agents/plan-critic.md +13 -18
  2. package/.agents/agents/story-worker.md +25 -33
  3. package/.agents/docs/agentrc-reference.json +0 -30
  4. package/.agents/docs/configuration.md +8 -28
  5. package/.agents/docs/execution-reference.md +5 -5
  6. package/.agents/docs/quality-gates.md +8 -7
  7. package/.agents/instructions.md +9 -10
  8. package/.agents/schemas/agentrc.schema.json +9 -185
  9. package/.agents/schemas/story-deliver-terminal.schema.json +1 -1
  10. package/.agents/scripts/acceptance-eval.js +107 -17
  11. package/.agents/scripts/ceremony-derive.js +191 -0
  12. package/.agents/scripts/check-context-budget.js +28 -33
  13. package/.agents/scripts/check-cyclomatic.js +4 -3
  14. package/.agents/scripts/deliver-light.js +31 -94
  15. package/.agents/scripts/evidence-gate.js +17 -1
  16. package/.agents/scripts/lib/audit-suite/checklist-threading.js +15 -2
  17. package/.agents/scripts/lib/baselines/coverage-updater-cli.js +110 -0
  18. package/.agents/scripts/lib/baselines/crap-preview-scan.js +25 -0
  19. package/.agents/scripts/lib/baselines/crap-updater-cli.js +223 -0
  20. package/.agents/scripts/lib/bdd-scenario-budget.js +21 -3
  21. package/.agents/scripts/lib/bootstrap/quality-bootstrap.js +0 -1
  22. package/.agents/scripts/lib/close-validation/gates.js +52 -1
  23. package/.agents/scripts/lib/config/acceptance-eval.js +25 -57
  24. package/.agents/scripts/lib/config/delivery-routing.js +7 -33
  25. package/.agents/scripts/lib/config/explain.js +0 -19
  26. package/.agents/scripts/lib/config/limits.js +18 -78
  27. package/.agents/scripts/lib/config/quality.js +6 -3
  28. package/.agents/scripts/lib/config/runners.js +3 -2
  29. package/.agents/scripts/lib/config-settings-schema-delivery.js +15 -68
  30. package/.agents/scripts/lib/config-settings-schema-quality.js +0 -14
  31. package/.agents/scripts/lib/config-settings-schema.js +16 -143
  32. package/.agents/scripts/lib/crap-engine.js +35 -4
  33. package/.agents/scripts/lib/crap-utils.js +17 -1
  34. package/.agents/scripts/lib/cyclomatic-ceiling.js +19 -7
  35. package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
  36. package/.agents/scripts/lib/observability/runtime-friction.js +1 -1
  37. package/.agents/scripts/lib/observability/source-classifier.js +1 -0
  38. package/.agents/scripts/lib/orchestration/acceptance-eval-decision.js +5 -4
  39. package/.agents/scripts/lib/orchestration/ceremony-routing.js +19 -73
  40. package/.agents/scripts/lib/orchestration/code-review.js +7 -3
  41. package/.agents/scripts/lib/orchestration/complexity-gate.js +46 -212
  42. package/.agents/scripts/lib/orchestration/file-assumptions.js +32 -17
  43. package/.agents/scripts/lib/orchestration/light-escalation.js +3 -3
  44. package/.agents/scripts/lib/orchestration/light-suitability.js +66 -233
  45. package/.agents/scripts/lib/orchestration/pinned-identifier-lint.js +137 -0
  46. package/.agents/scripts/lib/orchestration/plan-context.js +189 -387
  47. package/.agents/scripts/lib/orchestration/plan-critic-conditions.js +42 -153
  48. package/.agents/scripts/lib/orchestration/plan-critics-evaluate.js +14 -70
  49. package/.agents/scripts/lib/orchestration/plan-persist/acceptance-handle-repair.js +107 -0
  50. package/.agents/scripts/lib/orchestration/plan-persist/changes-repair.js +305 -0
  51. package/.agents/scripts/lib/orchestration/plan-persist/persist-helpers.js +138 -170
  52. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +128 -297
  53. package/.agents/scripts/lib/orchestration/plan-persist/soft-findings.js +55 -0
  54. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +16 -65
  55. package/.agents/scripts/lib/orchestration/plan-persist/wave-serialisation.js +22 -35
  56. package/.agents/scripts/lib/orchestration/plan-text-hygiene.js +36 -135
  57. package/.agents/scripts/lib/orchestration/planning/memory-pool-advisory.js +61 -223
  58. package/.agents/scripts/lib/orchestration/review-base-ref.js +138 -0
  59. package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +5 -0
  60. package/.agents/scripts/lib/orchestration/single-story-close/phases/code-review.js +37 -5
  61. package/.agents/scripts/lib/orchestration/single-story-close/phases/pre-gate-steps.js +46 -16
  62. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +6 -1
  63. package/.agents/scripts/lib/orchestration/story-close/context-budget-writeback.js +213 -0
  64. package/.agents/scripts/lib/orchestration/task-body-validator.js +10 -63
  65. package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +33 -539
  66. package/.agents/scripts/lib/orchestration/ticket-validator-sizing.js +21 -414
  67. package/.agents/scripts/lib/orchestration/ticket-validator.js +54 -118
  68. package/.agents/scripts/lib/orchestration/verify-credit.js +69 -24
  69. package/.agents/scripts/lib/story-body/body-format-lints.js +15 -85
  70. package/.agents/scripts/lib/story-body/story-body.js +54 -240
  71. package/.agents/scripts/lib/templates/decomposer-prompts.js +133 -121
  72. package/.agents/scripts/lib/test-isolate/cli-options.js +93 -0
  73. package/.agents/scripts/lib/test-isolate/progress-log.js +45 -0
  74. package/.agents/scripts/lib/test-isolate/render-report.js +97 -0
  75. package/.agents/scripts/lib/test-isolate/run-isolate.js +87 -0
  76. package/.agents/scripts/lib/test-run-credit.js +277 -0
  77. package/.agents/scripts/lib/wave-runner/footprint.js +48 -358
  78. package/.agents/scripts/lib/wave-runner/ready-set.js +6 -5
  79. package/.agents/scripts/lib/workers/crap-worker.js +32 -41
  80. package/.agents/scripts/plan-context.js +7 -9
  81. package/.agents/scripts/plan-critics.js +28 -54
  82. package/.agents/scripts/plan-persist.js +25 -68
  83. package/.agents/scripts/quality-preview.js +51 -0
  84. package/.agents/scripts/run-tests.js +12 -0
  85. package/.agents/scripts/stories-wave-tick.js +23 -45
  86. package/.agents/scripts/test-isolate.js +13 -180
  87. package/.agents/scripts/update-coverage-baseline.js +25 -70
  88. package/.agents/scripts/update-crap-baseline.js +19 -123
  89. package/.agents/skills/core/scope-triage/SKILL.md +3 -3
  90. package/.agents/workflows/audit-clean-code.md +4 -3
  91. package/.agents/workflows/helpers/acceptance-self-eval.md +41 -41
  92. package/.agents/workflows/helpers/code-quality-guardrails.md +4 -4
  93. package/.agents/workflows/helpers/code-review.md +2 -3
  94. package/.agents/workflows/helpers/deliver-digest.md +46 -55
  95. package/.agents/workflows/helpers/deliver-light.md +40 -105
  96. package/.agents/workflows/helpers/deliver-reference.md +1 -1
  97. package/.agents/workflows/helpers/deliver-story-reference.md +54 -55
  98. package/.agents/workflows/helpers/deliver-story.md +10 -13
  99. package/.agents/workflows/helpers/plan-reference.md +163 -221
  100. package/.agents/workflows/mandrel-plan.md +31 -40
  101. package/.agents/workflows/memory-consolidate.md +9 -13
  102. package/docs/CHANGELOG.md +36 -0
  103. package/lib/cli/registry.js +98 -2
  104. package/lib/migrations/index.js +4 -0
  105. package/lib/migrations/steps/2.57.0-retire-delivery-limit-knobs.js +45 -0
  106. package/lib/migrations/steps/2.57.0-retire-planning-limit-knobs.js +59 -0
  107. package/package.json +1 -1
  108. package/.agents/scripts/lib/framework-version.js +0 -39
  109. package/.agents/scripts/lib/orchestration/consolidation-precondition.js +0 -223
  110. package/.agents/scripts/lib/orchestration/plan-persist/fan-out-gate.js +0 -97
  111. package/.agents/scripts/lib/orchestration/planning/decomposer-context.js +0 -26
  112. package/.agents/scripts/lib/orchestration/spec-budget.js +0 -89
  113. package/.agents/scripts/lib/orchestration/spec-spill.js +0 -74
  114. package/.agents/scripts/lib/orchestration/verify-tier-repair.js +0 -107
@@ -13,70 +13,42 @@
13
13
  * verdict nothing read. This emits one advisory the `/mandrel-plan` spine
14
14
  * surfaces at Gate #1, recommending `/memory-consolidate`.
15
15
  *
16
- * It also drops the semantic that made the old scanner unfixable: it renders
17
- * **no per-entry verdict at all**. A memory citing a closed issue is a
18
- * delivery retrospective whose subject is that issue — not a stale entry — and
19
- * only the attended `/memory-consolidate` pass, reading content, can tell the
20
- * difference. This module counts and stats; it never judges an entry.
16
+ * It renders **no per-entry verdict at all**. A memory citing a closed issue
17
+ * is a delivery retrospective whose subject is that issue — not a stale entry
18
+ * — and only the attended `/memory-consolidate` pass, reading content, can
19
+ * tell the difference. This module measures one thing; it never judges an
20
+ * 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
- *
37
- * **The index byte arm (Story #5285).** Age and growth both measure the
38
- * *pool*; neither measures the one artifact a session actually loads. The
39
- * harness reads `MEMORY.md` into every session under a hard byte cap and
40
- * **truncates** past it, so an index over that cap loses its tail entries
41
- * silently — the pointers are on disk, indexed, and unreachable. That is a
42
- * loss in progress, not a hygiene forecast, so this arm is independent of the
43
- * other two: it fires on a fresh, zero-growth pool whose index has simply
44
- * outgrown the cap. It measures the index file's size, never the pool's, and
45
- * a pass that rewrites long index lines short clears it without pruning a
46
- * single entry.
47
- *
48
- * **A future-dated stamp is no stamp.** `lastConsolidatedAt` ahead of `now`
49
- * cannot describe a pass that happened — it is a clock skew, a hand-edit, or
50
- * a timezone bug. Scored as-is it yields a negative age that silences the age
51
- * arm *forever*, which is the loudest possible failure for an advisory whose
52
- * only job is to speak up. It reads as unstamped instead, so the
53
- * never-consolidated reason fires and the next real pass overwrites it.
22
+ * **One arm: the index byte ceiling (Story #5285, sole survivor after Story
23
+ * #5312).** The harness reads `MEMORY.md` into every session under a hard
24
+ * byte cap and **truncates** past it, so an index over that cap loses its
25
+ * tail entries silently — the pointers are on disk, indexed, and unreachable.
26
+ * That is a loss in progress, not a hygiene forecast, and it is the only
27
+ * signal that measures the artifact a session actually loads. The stamp-age
28
+ * and growth-delta arms that used to sit beside it measured the *pool*, fired
29
+ * on every plan once a pool was mature, and were learned-ignored by exactly
30
+ * the operators they nagged; Story #5312 deleted them with their
31
+ * `planning.memoryPool.{staleAfterDays, growthDelta}` knobs. A pass that
32
+ * rewrites long index lines short clears this arm without pruning a single
33
+ * entry.
54
34
  *
55
35
  * Detection is filesystem-only — no child processes, no `gh` probes, no
56
36
  * network. Every failure path fails soft to "no pool, no recommendation": the
57
37
  * advisory can degrade the nudge, never a plan.
58
38
  *
59
39
  * Test seams: `cwd`, `env`, `fsImpl` (node:fs-compatible `statSync` /
60
- * `readdirSync` / `readFileSync`), `now`, and the three thresholds
61
- * (`staleAfterDays`, `growthDelta`, `indexByteCeiling`).
40
+ * `readdirSync`), and the `indexByteCeiling` threshold.
62
41
  *
63
42
  * `buildMemoryPoolAdvisory` is the **only** export: the helpers below have no
64
43
  * caller outside this module, and exporting one solely for a test would add a
65
- * row to the `dead-exports-production` ratchet (the `buildUiSurfaceSignal`
66
- * precedent). Tests reach every branch through the seams above — do not
67
- * "fix" the missing exports.
44
+ * row to the `dead-exports-production` ratchet. Tests reach every branch
45
+ * through the seams above — do not "fix" the missing exports.
68
46
  */
69
47
 
70
48
  import * as defaultFs from 'node:fs';
71
49
  import * as os from 'node:os';
72
50
  import * as path from 'node:path';
73
51
 
74
- /** Recommend a consolidation pass once the stamp is this old. */
75
- const STALE_AFTER_DAYS = 30;
76
-
77
- /** Recommend a pass once this many entries were written since the last one. */
78
- const GROWTH_DELTA = 25;
79
-
80
52
  /**
81
53
  * Recommend a pass once `MEMORY.md` exceeds this many bytes.
82
54
  *
@@ -87,14 +59,9 @@ const GROWTH_DELTA = 25;
87
59
  */
88
60
  const INDEX_BYTE_CEILING = 24_576;
89
61
 
90
- /** Stamp file written by `/memory-consolidate` after its operator gate. */
91
- const STAMP_FILENAME = '.consolidation-stamp.json';
92
-
93
62
  /** The index file is not itself a memory entry. */
94
63
  const INDEX_FILENAME = 'MEMORY.md';
95
64
 
96
- const MS_PER_DAY = 86_400_000;
97
-
98
65
  /**
99
66
  * Slugify an absolute path the way the harness names its per-project
100
67
  * directories: every `/` and `.` becomes `-`. Verified against real
@@ -141,10 +108,8 @@ function resolveMemoryPoolDir({ cwd, env = process.env, homedir } = {}) {
141
108
  * Run one filesystem probe, falling back on any failure.
142
109
  *
143
110
  * Every read here is fail-soft by design — the advisory may degrade its nudge
144
- * but never a plan — so all four probes had the same try/catch shape wrapped
145
- * around one expression. One helper states the rule once; a new probe cannot
146
- * forget it, and a `catch` that ever needs to do more than fall back would
147
- * have to be written out, which is the signal it deserves.
111
+ * but never a plan — so every probe has the same try/catch shape wrapped
112
+ * around one expression. One helper states the rule once.
148
113
  *
149
114
  * @template T
150
115
  * @param {() => T} read
@@ -159,61 +124,14 @@ function probe(read, fallback = null) {
159
124
  }
160
125
  }
161
126
 
162
- /**
163
- * The growth baseline a stamp records: its entry count, or `null` when it
164
- * records none. `null` is *unmeasured*, never zero — a zero baseline would
165
- * score every entry in the pool as newly written.
166
- *
167
- * @param {unknown} count
168
- * @returns {number|null}
169
- */
170
- function readBaseline(count) {
171
- return Number.isInteger(count) && count >= 0 ? count : null;
172
- }
173
-
174
- /**
175
- * Read the consolidation stamp.
176
- *
177
- * `at` is the ISO timestamp of the last pass, or `null` when there was none:
178
- * a missing, unreadable, unparseable, date-less or **future-dated** stamp is
179
- * indistinguishable from "never consolidated" — all five mean the same thing
180
- * to the advisory. A future date is the one that has to be caught here rather
181
- * than downstream: it is arithmetically valid, so the age arm would score it
182
- * as a negative age and stay silent for as long as the clock stays behind it.
183
- * A stamp whose date is unusable carries no baseline either, so `baseline`
184
- * follows it to `null` rather than describing a pass that cannot be dated.
185
- *
186
- * `baseline` is the entry count that pass left behind — the growth arm's
187
- * reference point. It is `null` on a stamp that predates Story #5182 (date
188
- * only) and on a malformed count, which reads as *unmeasured growth*, never
189
- * as zero growth: a `0` baseline would score the whole pool as new.
190
- *
191
- * @param {{ poolDir: string, fsImpl: object, now: Date|string|number }} args
192
- * @returns {{ at: string|null, baseline: number|null }}
193
- */
194
- function readStamp({ poolDir, fsImpl, now }) {
195
- const parsed = probe(() =>
196
- JSON.parse(fsImpl.readFileSync(path.join(poolDir, STAMP_FILENAME), 'utf8')),
197
- );
198
- const at = parsed?.lastConsolidatedAt;
199
- // `Date.parse` rejects the empty string as NaN, so one test covers both an
200
- // absent date and an unusable one; `> now` covers the future-dated stamp.
201
- // Equality is not the future, so a stamp written this instant still counts.
202
- const at_ms = typeof at === 'string' ? Date.parse(at) : Number.NaN;
203
- if (Number.isNaN(at_ms) || at_ms > new Date(now).getTime()) {
204
- return { at: null, baseline: null };
205
- }
206
- return { at, baseline: readBaseline(parsed.entryCount) };
207
- }
208
-
209
127
  /**
210
128
  * The index file's size in bytes.
211
129
  *
212
130
  * `null` when it cannot be stat'd — an absent or unreadable `MEMORY.md`
213
- * leaves the byte arm silent rather than guessing a size, on the same
214
- * fail-soft rule every other probe here follows. Stat'd rather than read:
215
- * the arm needs the length, never the content, and this module deliberately
216
- * never reads a memory's text.
131
+ * leaves the arm silent rather than guessing a size, on the same fail-soft
132
+ * rule every other probe here follows. Stat'd rather than read: the arm needs
133
+ * the length, never the content, and this module deliberately never reads a
134
+ * memory's text.
217
135
  *
218
136
  * @returns {number|null}
219
137
  */
@@ -247,122 +165,40 @@ function countEntries({ poolDir, fsImpl }) {
247
165
  *
248
166
  * @param {object} fields
249
167
  * @returns {{ present: boolean, entryCount: number, indexBytes: number|null,
250
- * lastConsolidatedAt: string|null,
251
- * entriesSinceConsolidation: number|null, recommend: boolean,
252
- * reasons: string[] }}
168
+ * recommend: boolean, reasons: string[] }}
253
169
  */
254
170
  function envelope(fields) {
255
171
  return {
256
172
  present: false,
257
173
  entryCount: 0,
258
174
  indexBytes: null,
259
- lastConsolidatedAt: null,
260
- entriesSinceConsolidation: null,
261
175
  recommend: false,
262
176
  reasons: [],
263
177
  ...fields,
264
178
  };
265
179
  }
266
180
 
267
- /**
268
- * Collect the reasons a pool wants a consolidation pass. An empty array is
269
- * the quiet verdict; the caller turns it into `recommend` and supplies the
270
- * standing-down sentence, so every arm lives in one place.
271
- *
272
- * The three arms are independent and every one that fires is reported.
273
- *
274
- * @param {{ stamp: { at: string|null, baseline: number|null },
275
- * growth: number|null, indexBytes: number|null,
276
- * now: Date|string|number, staleAfterDays: number,
277
- * growthDelta: number, indexByteCeiling: number }} args
278
- * @returns {string[]}
279
- */
280
- function collectReasons({
281
- stamp,
282
- growth,
283
- indexBytes,
284
- now,
285
- staleAfterDays,
286
- growthDelta,
287
- indexByteCeiling,
288
- }) {
289
- const reasons = [];
290
-
291
- if (stamp.at === null) {
292
- reasons.push(
293
- 'no consolidation stamp — this pool has never been consolidated',
294
- );
295
- } else {
296
- const ageDays =
297
- (new Date(now).getTime() - Date.parse(stamp.at)) / MS_PER_DAY;
298
- if (ageDays > staleAfterDays) {
299
- reasons.push(
300
- `last consolidated ${Math.floor(ageDays)} days ago (over the ${staleAfterDays}-day threshold)`,
301
- );
302
- }
303
- }
304
-
305
- // `growth === null` is unmeasured, not zero — a pre-#5182 stamp carries no
306
- // baseline, and guessing one would re-invent the ceiling this arm replaced.
307
- if (growth !== null && growth >= growthDelta) {
308
- reasons.push(
309
- `${growth} entries written since the last consolidation (at or over the ${growthDelta}-entry growth delta)`,
310
- );
311
- }
312
-
313
- // `indexBytes === null` is an unreadable index, not a small one.
314
- if (indexBytes !== null && indexBytes > indexByteCeiling) {
315
- reasons.push(
316
- `${INDEX_FILENAME} is ${indexBytes} bytes, ${indexBytes - indexByteCeiling} over the ${indexByteCeiling}-byte index ceiling — the index is truncated at the cap, so every entry listed after the cut is invisible to every session`,
317
- );
318
- }
319
-
320
- return reasons;
321
- }
322
-
323
- /**
324
- * The sentence a quiet pool explains itself with — one per reason it is quiet,
325
- * so "nothing to do" never reads the same as "nothing measurable".
326
- *
327
- * @param {{ growth: number|null, growthDelta: number }} args
328
- * @returns {string}
329
- */
330
- function quietReason({ growth, growthDelta }) {
331
- if (growth === null) {
332
- return 'memory pool is within the freshness and index-size thresholds; growth is unmeasured until the next /memory-consolidate stamps an entry count';
333
- }
334
- return `memory pool is within every threshold — ${growth} entries written since the last consolidation (under the ${growthDelta}-entry growth delta)`;
335
- }
336
-
337
181
  /**
338
182
  * Build the `memoryPoolAdvisory` envelope field.
339
183
  *
340
- * Advisory only — it carries **no routing authority**, mirroring
341
- * `deliverLightSuggestion`. The `/mandrel-plan` spine surfaces `recommend` at Gate #1;
342
- * nothing auto-runs, and nothing here mutates the operator's memory store.
184
+ * Advisory only — it carries **no routing authority**. The `/mandrel-plan`
185
+ * spine surfaces `recommend` at Gate #1 on one advisory line; nothing
186
+ * auto-runs, and nothing here mutates the operator's memory store.
343
187
  *
344
188
  * @param {object} [opts]
345
189
  * @param {string} [opts.cwd] — defaults to `process.cwd()`
346
190
  * @param {Record<string,string|undefined>} [opts.env]
347
191
  * @param {object} [opts.fsImpl] — node:fs-compatible seam
348
192
  * @param {string} [opts.homedir]
349
- * @param {Date|string|number} [opts.now]
350
- * @param {number} [opts.staleAfterDays]
351
- * @param {number} [opts.growthDelta]
352
193
  * @param {number} [opts.indexByteCeiling]
353
194
  * @returns {{ present: boolean, entryCount: number, indexBytes: number|null,
354
- * lastConsolidatedAt: string|null,
355
- * entriesSinceConsolidation: number|null, recommend: boolean,
356
- * reasons: string[] }}
195
+ * recommend: boolean, reasons: string[] }}
357
196
  */
358
197
  export function buildMemoryPoolAdvisory({
359
198
  cwd = process.cwd(),
360
199
  env = process.env,
361
200
  fsImpl = defaultFs,
362
201
  homedir,
363
- now = new Date(),
364
- staleAfterDays = STALE_AFTER_DAYS,
365
- growthDelta = GROWTH_DELTA,
366
202
  indexByteCeiling = INDEX_BYTE_CEILING,
367
203
  } = {}) {
368
204
  const absent = (reason) => envelope({ reasons: [reason] });
@@ -384,21 +220,10 @@ export function buildMemoryPoolAdvisory({
384
220
  return absent(`memory pool at ${poolDir} could not be listed`);
385
221
  }
386
222
 
387
- const stamp = readStamp({ poolDir, fsImpl, now });
388
- // Reported raw: a pruning pass can leave this negative, and saying the pool
389
- // shrank by 7 is more use to the operator than clamping it to zero.
390
- const growth = stamp.baseline === null ? null : entryCount - stamp.baseline;
391
223
  const indexBytes = readIndexBytes({ poolDir, fsImpl });
224
+ const found = { present: true, entryCount, indexBytes };
392
225
 
393
- const found = {
394
- present: true,
395
- entryCount,
396
- indexBytes,
397
- lastConsolidatedAt: stamp.at,
398
- entriesSinceConsolidation: growth,
399
- };
400
-
401
- // An empty pool has nothing to consolidate, whatever the stamp says.
226
+ // An empty pool has nothing to consolidate, whatever the index says.
402
227
  if (entryCount === 0) {
403
228
  return envelope({
404
229
  ...found,
@@ -406,20 +231,33 @@ export function buildMemoryPoolAdvisory({
406
231
  });
407
232
  }
408
233
 
409
- const reasons = collectReasons({
410
- stamp,
411
- growth,
412
- indexBytes,
413
- now,
414
- staleAfterDays,
415
- growthDelta,
416
- indexByteCeiling,
417
- });
234
+ const { recommend, reason } = judgeIndex(indexBytes, indexByteCeiling);
235
+ return envelope({ ...found, recommend, reasons: [reason] });
236
+ }
418
237
 
419
- return envelope({
420
- ...found,
421
- recommend: reasons.length > 0,
422
- reasons:
423
- reasons.length > 0 ? reasons : [quietReason({ growth, growthDelta })],
424
- });
238
+ /**
239
+ * The index byte arm's verdict. `indexBytes === null` is an unreadable index,
240
+ * not a small one, so it stays quiet and says why.
241
+ *
242
+ * @param {number|null} indexBytes
243
+ * @param {number} indexByteCeiling
244
+ * @returns {{ recommend: boolean, reason: string }}
245
+ */
246
+ function judgeIndex(indexBytes, indexByteCeiling) {
247
+ if (indexBytes === null) {
248
+ return {
249
+ recommend: false,
250
+ reason: `memory pool present but ${INDEX_FILENAME} could not be measured — the index ceiling is the only arm and it is unmeasured`,
251
+ };
252
+ }
253
+ if (indexBytes > indexByteCeiling) {
254
+ return {
255
+ recommend: true,
256
+ reason: `${INDEX_FILENAME} is ${indexBytes} bytes, ${indexBytes - indexByteCeiling} over the ${indexByteCeiling}-byte index ceiling — the index is truncated at the cap, so every entry listed after the cut is invisible to every session`,
257
+ };
258
+ }
259
+ return {
260
+ recommend: false,
261
+ reason: `${INDEX_FILENAME} is ${indexBytes} bytes, within the ${indexByteCeiling}-byte index ceiling`,
262
+ };
425
263
  }
@@ -0,0 +1,138 @@
1
+ /**
2
+ * lib/orchestration/review-base-ref.js — the base ref a Story-scope review is
3
+ * allowed to measure against (Story #5325).
4
+ *
5
+ * ## The defect this closes
6
+ *
7
+ * A close runs base-sync and the Story-scope review against what everyone
8
+ * called "the base branch" — but the two meant different refs. Base-sync
9
+ * fetches and merges `origin/<base>`; the review passed the **bare** branch
10
+ * name, which git resolves to the LOCAL `refs/heads/<base>`, i.e. to whatever
11
+ * that ref last fast-forwarded to. On a checkout whose local base is behind
12
+ * its remote, the review's `<base>...<head>` diff therefore contains every
13
+ * commit the local ref is missing — other people's landed work, scored as if
14
+ * this Story had written it. Those findings gate the land, and the operator's
15
+ * only exit is a `review-block-overridden` on blockers that were never real.
16
+ *
17
+ * ## Contract
18
+ *
19
+ * One resolution, at the review phase boundary, threaded into the change-set
20
+ * enumeration, the provider review and the local lens pass — so both arms of
21
+ * the review agree about what changed and neither can inherit local drift.
22
+ *
23
+ * Resolution **fails safe rather than falling back**. Base-sync normally
24
+ * guarantees the remote ref is present, but a `--skip-sync` close or a
25
+ * remote-less checkout can reach the review with no `origin/<base>` at all.
26
+ * Reviewing the local ref anyway is the defect, so an unresolvable base
27
+ * produces a degradation record — carried on the review's existing
28
+ * `degraded` / `degradations[]` envelope — and no findings whatsoever.
29
+ */
30
+
31
+ import { gitSpawn } from '../git-utils.js';
32
+ import { degradationEnvelope } from './review-providers/degraded-gates.js';
33
+
34
+ /**
35
+ * The remote-tracking spelling of a base branch — `main` → `origin/main`.
36
+ * Already-qualified input passes through, so a caller naming `origin/main`
37
+ * is not double-prefixed.
38
+ *
39
+ * @param {unknown} baseBranch
40
+ * @returns {string|null} the remote-tracking ref, or `null` when unnameable.
41
+ */
42
+ export function remoteBaseRef(baseBranch) {
43
+ const base = typeof baseBranch === 'string' ? baseBranch.trim() : '';
44
+ if (base.length === 0) return null;
45
+ return base.startsWith('origin/') ? base : `origin/${base}`;
46
+ }
47
+
48
+ /**
49
+ * Resolve the base ref base-sync merged from, **verified present** in this
50
+ * clone. See the module header for why there is no local-ref fallback.
51
+ *
52
+ * @param {{
53
+ * baseBranch: string,
54
+ * cwd?: string,
55
+ * gitSpawnFn?: typeof gitSpawn,
56
+ * }} args
57
+ * @returns {{ ref: string|null, resolved: boolean, remoteRef: string|null }}
58
+ * `ref` is non-null only when `resolved`; `remoteRef` is the ref that was
59
+ * probed, for the caller's degradation surface.
60
+ */
61
+ export function resolveSharedBaseRef({
62
+ baseBranch,
63
+ cwd = process.cwd(),
64
+ gitSpawnFn = gitSpawn,
65
+ } = {}) {
66
+ const remoteRef = remoteBaseRef(baseBranch);
67
+ if (remoteRef === null) {
68
+ return { ref: null, resolved: false, remoteRef: null };
69
+ }
70
+ try {
71
+ const probe = gitSpawnFn(
72
+ cwd,
73
+ 'rev-parse',
74
+ '--verify',
75
+ '--quiet',
76
+ `${remoteRef}^{commit}`,
77
+ );
78
+ if (probe?.status === 0) {
79
+ return { ref: remoteRef, resolved: true, remoteRef };
80
+ }
81
+ } catch {
82
+ // A spawn failure and a missing ref are the same answer here: the shared
83
+ // base cannot be vouched for.
84
+ }
85
+ return { ref: null, resolved: false, remoteRef };
86
+ }
87
+
88
+ /**
89
+ * The review outcome for a close that cannot establish the shared base.
90
+ *
91
+ * Shaped as the same envelope a completed review returns — an all-zero
92
+ * severity tally, nothing posted, and the `degraded` / `degradations[]` pair
93
+ * the close and the rendered comment already read — so the surfaces reading
94
+ * it cannot mistake "no findings" for "reviewed and clean". Reported, not
95
+ * blocking, matching the posture of every other degraded review gate.
96
+ *
97
+ * @param {{
98
+ * storyId: number|string,
99
+ * baseBranch: string,
100
+ * remoteRef: string|null,
101
+ * progress: (tag: string, msg: string) => void,
102
+ * progressTag?: string,
103
+ * }} args
104
+ * @returns {object}
105
+ */
106
+ export function unresolvedBaseReviewOutcome({
107
+ storyId,
108
+ baseBranch,
109
+ remoteRef,
110
+ progress,
111
+ progressTag = 'REVIEW',
112
+ }) {
113
+ const surface = remoteRef ?? `origin/${baseBranch}`;
114
+ progress(
115
+ progressTag,
116
+ `⚠️ Story-scope review for Story #${storyId} did not run: cannot resolve ` +
117
+ `${surface}, the base ref base-sync merges from. Diffing the local ` +
118
+ `${baseBranch} instead would score commits this Story never made, so no ` +
119
+ 'findings are raised. Fetch the base ref (or re-run without ' +
120
+ '--skip-sync) to restore the review.',
121
+ );
122
+ return {
123
+ halted: false,
124
+ skipped: true,
125
+ severity: { critical: 0, high: 0, medium: 0, suggestion: 0 },
126
+ posted: false,
127
+ postedCommentId: null,
128
+ ...degradationEnvelope([
129
+ {
130
+ tool: 'story-scope-review',
131
+ gate: 'base-ref-resolution',
132
+ surface,
133
+ reason: 'remote-base-ref-unresolved',
134
+ },
135
+ ]),
136
+ crossRefPosted: false,
137
+ };
138
+ }
@@ -78,6 +78,7 @@ import { runPreGateSteps as defaultRunPreGateSteps } from './pre-gate-steps.js';
78
78
  * runPreGateSteps?: typeof defaultRunPreGateSteps,
79
79
  * runScopedFormatAutofix?: Function,
80
80
  * runBaselineUpwardWriteback?: Function,
81
+ * runContextBudgetWriteback?: Function,
81
82
  * createGateLogSink?: typeof defaultCreateGateLogSink,
82
83
  * }} args
83
84
  * @returns {Promise<{ gates: Record<string, 'passed'|'skipped'> }>} Per-gate
@@ -98,6 +99,7 @@ export async function runCloseValidationPhase({
98
99
  runPreGateSteps = defaultRunPreGateSteps,
99
100
  runScopedFormatAutofix,
100
101
  runBaselineUpwardWriteback,
102
+ runContextBudgetWriteback,
101
103
  createGateLogSink = defaultCreateGateLogSink,
102
104
  }) {
103
105
  await runPreGateSteps({
@@ -110,6 +112,7 @@ export async function runCloseValidationPhase({
110
112
  progress,
111
113
  runScopedFormatAutofix,
112
114
  runBaselineUpwardWriteback,
115
+ runContextBudgetWriteback,
113
116
  });
114
117
 
115
118
  progress(
@@ -124,6 +127,8 @@ export async function runCloseValidationPhase({
124
127
  baseBranch,
125
128
  cwd: worktreePath || cwd,
126
129
  log: gateLog.log,
130
+ storyId, // Story #5313 — a credited bare `npm test` registers `test`.
131
+ evidenceCwd: cwd,
127
132
  });
128
133
  let validation;
129
134
  try {
@@ -25,9 +25,21 @@
25
25
  * shares a single invocation pattern (Story #3653). Review depth needs no
26
26
  * input here: it is derived from this Story's own diff inside `runCodeReview`
27
27
  * (Story #4542).
28
+ *
29
+ * Story #5325 — this phase boundary is where the base ref is resolved, once.
30
+ * The resolved ref threads into `runStoryReviewCore`, which hands it to both
31
+ * the change-set enumeration (and through it the provider review) and the
32
+ * local lens pass, so one resolution corrects both arms. It resolves to
33
+ * `origin/<baseBranch>` — the ref base-sync merged from — rather than the bare
34
+ * branch name git would resolve to the local `refs/heads/<baseBranch>`, whose
35
+ * drift would otherwise be scored as this Story's own change.
28
36
  */
29
37
 
30
38
  import { parsePrNumberFromUrl } from '../../../github-url.js';
39
+ import {
40
+ resolveSharedBaseRef,
41
+ unresolvedBaseReviewOutcome,
42
+ } from '../../review-base-ref.js';
31
43
  import { degradationEnvelope } from '../../review-providers/degraded-gates.js';
32
44
  import { runStoryReviewCore } from '../../story-close/phases/review-core.js';
33
45
  import { postStructuredComment } from '../../ticketing/state.js';
@@ -78,23 +90,25 @@ export function buildStoryReviewCrossRefBody({
78
90
  async function invokeStoryReviewCore({
79
91
  storyId,
80
92
  storyBranch,
81
- baseBranch,
93
+ baseRef,
82
94
  prNumber,
83
95
  provider,
84
96
  runCodeReviewFn,
85
97
  runLocalLensReviewFn,
86
98
  appendFindingsYieldFn,
99
+ gitSpawnFn,
87
100
  progress,
88
101
  }) {
89
102
  return runStoryReviewCore({
90
103
  storyId,
91
- baseRef: baseBranch,
104
+ baseRef,
92
105
  headRef: storyBranch,
93
106
  commentTargetId: prNumber,
94
107
  provider,
95
108
  progress,
96
109
  progressTag: 'REVIEW',
97
110
  runCodeReviewFn,
111
+ gitSpawnFn,
98
112
  // Forward the seams only when the caller injects them; otherwise
99
113
  // `runStoryReviewCore` uses its defaults. `undefined` deep-merges to
100
114
  // the default via the destructuring default there.
@@ -153,6 +167,9 @@ async function postStoryReviewCrossRef({
153
167
  * Failure modes:
154
168
  * - When `prNumber` is null (couldn't parse), the review is skipped
155
169
  * and the function returns `{ halted: false, skipped: true }`.
170
+ * - When `origin/<baseBranch>` cannot be resolved, the review is skipped,
171
+ * a `base-ref-resolution` degradation is recorded on the returned
172
+ * envelope, and no findings are raised (Story #5325).
156
173
  * - When the runner throws, the close fails non-zero (the throw
157
174
  * propagates) — a Story-scope review failure is not silently
158
175
  * ignored.
@@ -170,6 +187,7 @@ async function postStoryReviewCrossRef({
170
187
  * runCodeReviewFn: Function,
171
188
  * runLocalLensReviewFn?: Function,
172
189
  * appendFindingsYieldFn?: Function,
190
+ * gitSpawnFn?: Function,
173
191
  * progress: (tag: string, msg: string) => void,
174
192
  * }} args
175
193
  * @returns {Promise<{
@@ -185,7 +203,7 @@ async function postStoryReviewCrossRef({
185
203
  * }>}
186
204
  */
187
205
  export async function runStoryScopeReview({
188
- cwd: _cwd,
206
+ cwd,
189
207
  storyId,
190
208
  storyBranch,
191
209
  baseBranch,
@@ -195,6 +213,7 @@ export async function runStoryScopeReview({
195
213
  runCodeReviewFn,
196
214
  runLocalLensReviewFn,
197
215
  appendFindingsYieldFn,
216
+ gitSpawnFn,
198
217
  progress,
199
218
  }) {
200
219
  if (prNumber == null) {
@@ -205,20 +224,33 @@ export async function runStoryScopeReview({
205
224
  return { halted: false, skipped: true };
206
225
  }
207
226
 
227
+ // One resolution per close, at the phase boundary: `baseRef` threads from
228
+ // here into the change set, the provider review and the local lens pass.
229
+ const base = resolveSharedBaseRef({ baseBranch, cwd, gitSpawnFn });
230
+ if (!base.resolved) {
231
+ return unresolvedBaseReviewOutcome({
232
+ storyId,
233
+ baseBranch,
234
+ remoteRef: base.remoteRef,
235
+ progress,
236
+ });
237
+ }
238
+
208
239
  progress(
209
240
  'REVIEW',
210
- `Running Story-scope code review for Story #${storyId} (${baseBranch}...${storyBranch}) → PR #${prNumber}...`,
241
+ `Running Story-scope code review for Story #${storyId} (${base.ref}...${storyBranch}) → PR #${prNumber}...`,
211
242
  );
212
243
 
213
244
  const result = await invokeStoryReviewCore({
214
245
  storyId,
215
246
  storyBranch,
216
- baseBranch,
247
+ baseRef: base.ref,
217
248
  prNumber,
218
249
  provider,
219
250
  runCodeReviewFn,
220
251
  runLocalLensReviewFn,
221
252
  appendFindingsYieldFn,
253
+ gitSpawnFn,
222
254
  progress,
223
255
  });
224
256