mandrel 2.54.0 → 2.56.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 (134) hide show
  1. package/.agents/agents/story-worker.md +24 -23
  2. package/.agents/audit-checklists/accessibility.md +0 -3
  3. package/.agents/audit-checklists/mobile.md +0 -4
  4. package/.agents/docs/agentrc-reference.json +8 -2
  5. package/.agents/docs/configuration.md +5 -0
  6. package/.agents/rules/ci-remediation.md +39 -21
  7. package/.agents/schemas/agentrc.schema.json +34 -1
  8. package/.agents/schemas/lifecycle/merge.unlanded.schema.json +2 -1
  9. package/.agents/schemas/story-deliver-terminal.schema.json +1 -0
  10. package/.agents/scripts/audit-to-stories.js +374 -76
  11. package/.agents/scripts/check-audit-attribution.js +119 -62
  12. package/.agents/scripts/check-test-portability.js +512 -0
  13. package/.agents/scripts/coverage-capture.js +17 -10
  14. package/.agents/scripts/evidence-gate.js +31 -4
  15. package/.agents/scripts/file-ci-gap.js +306 -0
  16. package/.agents/scripts/generate-workflows-doc.js +65 -14
  17. package/.agents/scripts/git-cleanup.js +4 -0
  18. package/.agents/scripts/lib/ITicketingProvider.js +78 -0
  19. package/.agents/scripts/lib/audit-advisories.js +195 -0
  20. package/.agents/scripts/lib/audit-attribution.js +22 -0
  21. package/.agents/scripts/lib/audit-to-stories/audit-label-taxonomy.js +25 -1
  22. package/.agents/scripts/lib/audit-to-stories/dedupe-against-github.js +80 -29
  23. package/.agents/scripts/lib/audit-to-stories/finding-adapter.js +5 -1
  24. package/.agents/scripts/lib/audit-to-stories/issue-corpus.js +162 -0
  25. package/.agents/scripts/lib/audit-to-stories/issue-index.js +83 -0
  26. package/.agents/scripts/lib/audit-to-stories/issues-file.js +121 -0
  27. package/.agents/scripts/lib/audit-to-stories/ledger-commit.js +61 -115
  28. package/.agents/scripts/lib/audit-to-stories/ledger-pr.js +347 -0
  29. package/.agents/scripts/lib/audit-to-stories/ledger-record.js +126 -0
  30. package/.agents/scripts/lib/audit-to-stories/parse-audit-md.js +169 -44
  31. package/.agents/scripts/lib/audit-to-stories/seed-from-findings.js +11 -0
  32. package/.agents/scripts/lib/baselines/merge-envelopes.js +298 -32
  33. package/.agents/scripts/lib/bootstrap/baseline-merge-driver.js +180 -14
  34. package/.agents/scripts/lib/cli-args.js +26 -0
  35. package/.agents/scripts/lib/close-validation/gates.js +113 -7
  36. package/.agents/scripts/lib/close-validation/process.js +7 -3
  37. package/.agents/scripts/lib/close-validation/runner.js +62 -11
  38. package/.agents/scripts/lib/config/ci.js +28 -9
  39. package/.agents/scripts/lib/config-settings-schema-delivery.js +7 -0
  40. package/.agents/scripts/lib/config-settings-schema.js +52 -1
  41. package/.agents/scripts/lib/coverage-capture-fullscope.js +23 -11
  42. package/.agents/scripts/lib/coverage-capture-incremental.js +22 -16
  43. package/.agents/scripts/lib/coverage-capture-usage.js +5 -1
  44. package/.agents/scripts/lib/coverage-capture.js +77 -3
  45. package/.agents/scripts/lib/feedback-loop/graduator-core.js +53 -13
  46. package/.agents/scripts/lib/feedback-loop/prior-feedback-fetcher.js +71 -25
  47. package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +18 -25
  48. package/.agents/scripts/lib/{audit-to-stories/ledger.js → findings/audit-ledger.js} +131 -24
  49. package/.agents/scripts/lib/findings/route-finding.js +42 -2
  50. package/.agents/scripts/lib/full-suite-lock.js +232 -6
  51. package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
  52. package/.agents/scripts/lib/git/sync-from-base.js +130 -13
  53. package/.agents/scripts/lib/github/framework-repo.js +148 -2
  54. package/.agents/scripts/lib/label-constants.js +6 -1
  55. package/.agents/scripts/lib/observability/source-classifier.js +2 -0
  56. package/.agents/scripts/lib/orchestration/check-baselines/phases/compare.js +10 -2
  57. package/.agents/scripts/lib/orchestration/check-baselines/phases/refresh-ack.js +75 -15
  58. package/.agents/scripts/lib/orchestration/ci-gap-intake.js +605 -0
  59. package/.agents/scripts/lib/orchestration/ci-rerun-guard.js +13 -8
  60. package/.agents/scripts/lib/orchestration/deliver-recover.js +82 -43
  61. package/.agents/scripts/lib/orchestration/dependency-candidates.js +8 -4
  62. package/.agents/scripts/lib/orchestration/epic-candidates.js +9 -4
  63. package/.agents/scripts/lib/orchestration/epic-container.js +66 -4
  64. package/.agents/scripts/lib/orchestration/epic-rollup.js +233 -84
  65. package/.agents/scripts/lib/orchestration/file-assumptions.js +218 -16
  66. package/.agents/scripts/lib/orchestration/git-cleanup/phases/branches.js +93 -7
  67. package/.agents/scripts/lib/orchestration/git-cleanup/phases/git-probes.js +22 -6
  68. package/.agents/scripts/lib/orchestration/git-cleanup/phases/parse-args.js +26 -5
  69. package/.agents/scripts/lib/orchestration/git-cleanup/phases/phase-drivers.js +13 -2
  70. package/.agents/scripts/lib/orchestration/git-cleanup/phases/render.js +35 -5
  71. package/.agents/scripts/lib/orchestration/merge-block-class.js +18 -3
  72. package/.agents/scripts/lib/orchestration/merge-poll.js +284 -40
  73. package/.agents/scripts/lib/orchestration/plan-persist/audit-provenance.js +197 -0
  74. package/.agents/scripts/lib/orchestration/plan-persist/epic-adoption.js +49 -2
  75. package/.agents/scripts/lib/orchestration/plan-persist/epic-ops.js +43 -7
  76. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +39 -3
  77. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +5 -0
  78. package/.agents/scripts/lib/orchestration/plan-persist/summary.js +3 -0
  79. package/.agents/scripts/lib/orchestration/plan-persist/supersede-ops.js +63 -0
  80. package/.agents/scripts/lib/orchestration/plan-persist/wave-serialisation.js +110 -0
  81. package/.agents/scripts/lib/orchestration/planning/memory-pool-advisory.js +130 -40
  82. package/.agents/scripts/lib/orchestration/resolve-stories.js +44 -1
  83. package/.agents/scripts/lib/orchestration/review-providers/native.js +31 -11
  84. package/.agents/scripts/lib/orchestration/review-providers/scoped-lint.js +27 -24
  85. package/.agents/scripts/lib/orchestration/run-epilogue.js +63 -42
  86. package/.agents/scripts/lib/orchestration/single-story-close/close-note.js +81 -0
  87. package/.agents/scripts/lib/orchestration/single-story-close/failed-terminal.js +40 -51
  88. package/.agents/scripts/lib/orchestration/single-story-close/phases/auto-merge.js +10 -2
  89. package/.agents/scripts/lib/orchestration/single-story-close/phases/base-sync.js +101 -0
  90. package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +351 -28
  91. package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +27 -6
  92. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +117 -22
  93. package/.agents/scripts/lib/orchestration/story-close/baseline-upward-writeback.js +94 -12
  94. package/.agents/scripts/lib/orchestration/story-close/format-autofix.js +6 -1
  95. package/.agents/scripts/lib/orchestration/story-follow-ups.js +32 -20
  96. package/.agents/scripts/lib/orchestration/ticket-validator.js +25 -14
  97. package/.agents/scripts/lib/orchestration/ticketing/bulk.js +30 -0
  98. package/.agents/scripts/lib/orchestration/verify-credit.js +37 -0
  99. package/.agents/scripts/lib/pinned-override-notes.js +41 -53
  100. package/.agents/scripts/lib/pinned-override-resolve.js +212 -0
  101. package/.agents/scripts/lib/qa/resolve-qa-contract.js +18 -0
  102. package/.agents/scripts/lib/single-story-sweep/sweep-lock.js +173 -9
  103. package/.agents/scripts/lib/skills/walk-skill-files.js +24 -7
  104. package/.agents/scripts/lib/test-temp.js +167 -30
  105. package/.agents/scripts/lib/validation-evidence.js +37 -0
  106. package/.agents/scripts/lib/wave-runner/footprint.js +167 -14
  107. package/.agents/scripts/lib/wave-runner/live-probe.js +7 -1
  108. package/.agents/scripts/lib/wave-runner/ready-set.js +1 -1
  109. package/.agents/scripts/merge-baseline.js +175 -21
  110. package/.agents/scripts/pr-watch-with-update.js +3 -2
  111. package/.agents/scripts/providers/github/errors.js +22 -1
  112. package/.agents/scripts/providers/github/issues.js +106 -1
  113. package/.agents/scripts/providers/github/sub-issue-add.js +18 -1
  114. package/.agents/scripts/providers/github.js +6 -0
  115. package/.agents/scripts/resolve-stories.js +44 -34
  116. package/.agents/scripts/single-story-close.js +5 -0
  117. package/.agents/scripts/stories-wave-tick.js +37 -13
  118. package/.agents/templates/docs/audit-sweep-runbook.md +41 -7
  119. package/.agents/workflows/audit-accessibility.md +16 -31
  120. package/.agents/workflows/audit-mobile.md +20 -37
  121. package/.agents/workflows/audit-to-stories.md +63 -27
  122. package/.agents/workflows/git-cleanup.md +17 -3
  123. package/.agents/workflows/helpers/audit-lens-core.md +45 -0
  124. package/.agents/workflows/helpers/deliver-digest.md +7 -6
  125. package/.agents/workflows/helpers/deliver-reference.md +35 -14
  126. package/.agents/workflows/helpers/deliver-story-reference.md +26 -8
  127. package/.agents/workflows/helpers/deliver-story.md +15 -12
  128. package/.agents/workflows/helpers/plan-reference.md +30 -0
  129. package/.agents/workflows/mandrel-plan.md +10 -13
  130. package/.agents/workflows/memory-consolidate.md +14 -9
  131. package/docs/CHANGELOG.md +37 -0
  132. package/lib/cli/registry.js +64 -21
  133. package/lib/cli/sync.js +27 -2
  134. package/package.json +7 -4
@@ -34,13 +34,31 @@
34
34
  * simply stays silent and only the age arm can speak, until the next pass
35
35
  * writes a baseline.
36
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.
54
+ *
37
55
  * Detection is filesystem-only — no child processes, no `gh` probes, no
38
56
  * network. Every failure path fails soft to "no pool, no recommendation": the
39
57
  * advisory can degrade the nudge, never a plan.
40
58
  *
41
59
  * Test seams: `cwd`, `env`, `fsImpl` (node:fs-compatible `statSync` /
42
- * `readdirSync` / `readFileSync`), `now`, and the two thresholds
43
- * (`staleAfterDays`, `growthDelta`).
60
+ * `readdirSync` / `readFileSync`), `now`, and the three thresholds
61
+ * (`staleAfterDays`, `growthDelta`, `indexByteCeiling`).
44
62
  *
45
63
  * `buildMemoryPoolAdvisory` is the **only** export: the helpers below have no
46
64
  * caller outside this module, and exporting one solely for a test would add a
@@ -59,6 +77,16 @@ const STALE_AFTER_DAYS = 30;
59
77
  /** Recommend a pass once this many entries were written since the last one. */
60
78
  const GROWTH_DELTA = 25;
61
79
 
80
+ /**
81
+ * Recommend a pass once `MEMORY.md` exceeds this many bytes.
82
+ *
83
+ * 24576 (24 KiB) is the harness's own index cap — the point past which it
84
+ * truncates the file it loads into a session, making every entry after the
85
+ * cut unreachable. The default is the cap itself rather than a margin under
86
+ * it: the arm reports a loss that has already started, not one approaching.
87
+ */
88
+ const INDEX_BYTE_CEILING = 24_576;
89
+
62
90
  /** Stamp file written by `/memory-consolidate` after its operator gate. */
63
91
  const STAMP_FILENAME = '.consolidation-stamp.json';
64
92
 
@@ -109,6 +137,28 @@ function resolveMemoryPoolDir({ cwd, env = process.env, homedir } = {}) {
109
137
  );
110
138
  }
111
139
 
140
+ /**
141
+ * Run one filesystem probe, falling back on any failure.
142
+ *
143
+ * 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.
148
+ *
149
+ * @template T
150
+ * @param {() => T} read
151
+ * @param {T|null} [fallback]
152
+ * @returns {T|null}
153
+ */
154
+ function probe(read, fallback = null) {
155
+ try {
156
+ return read();
157
+ } catch {
158
+ return fallback;
159
+ }
160
+ }
161
+
112
162
  /**
113
163
  * The growth baseline a stamp records: its entry count, or `null` when it
114
164
  * records none. `null` is *unmeasured*, never zero — a zero baseline would
@@ -125,8 +175,11 @@ function readBaseline(count) {
125
175
  * Read the consolidation stamp.
126
176
  *
127
177
  * `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
129
- * from "never consolidated" — all four mean the same thing to the advisory.
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.
130
183
  * A stamp whose date is unusable carries no baseline either, so `baseline`
131
184
  * follows it to `null` rather than describing a pass that cannot be dated.
132
185
  *
@@ -135,23 +188,40 @@ function readBaseline(count) {
135
188
  * only) and on a malformed count, which reads as *unmeasured growth*, never
136
189
  * as zero growth: a `0` baseline would score the whole pool as new.
137
190
  *
191
+ * @param {{ poolDir: string, fsImpl: object, now: Date|string|number }} args
138
192
  * @returns {{ at: string|null, baseline: number|null }}
139
193
  */
140
- function readStamp({ poolDir, fsImpl }) {
141
- const unstamped = { at: null, baseline: null };
142
- try {
143
- const raw = fsImpl.readFileSync(path.join(poolDir, STAMP_FILENAME), 'utf8');
144
- const parsed = JSON.parse(raw);
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) };
152
- } catch {
153
- return unstamped;
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 };
154
205
  }
206
+ return { at, baseline: readBaseline(parsed.entryCount) };
207
+ }
208
+
209
+ /**
210
+ * The index file's size in bytes.
211
+ *
212
+ * `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.
217
+ *
218
+ * @returns {number|null}
219
+ */
220
+ function readIndexBytes({ poolDir, fsImpl }) {
221
+ const size = probe(
222
+ () => fsImpl.statSync(path.join(poolDir, INDEX_FILENAME)).size,
223
+ );
224
+ return Number.isFinite(size) ? size : null;
155
225
  }
156
226
 
157
227
  /**
@@ -160,13 +230,13 @@ function readStamp({ poolDir, fsImpl }) {
160
230
  * @returns {number|null} `null` when the directory cannot be listed
161
231
  */
162
232
  function countEntries({ poolDir, fsImpl }) {
163
- try {
164
- return fsImpl
165
- .readdirSync(poolDir)
166
- .filter((name) => name.endsWith('.md') && name !== INDEX_FILENAME).length;
167
- } catch {
168
- return null;
169
- }
233
+ return probe(
234
+ () =>
235
+ fsImpl
236
+ .readdirSync(poolDir)
237
+ .filter((name) => name.endsWith('.md') && name !== INDEX_FILENAME)
238
+ .length,
239
+ );
170
240
  }
171
241
 
172
242
  /**
@@ -176,7 +246,8 @@ function countEntries({ poolDir, fsImpl }) {
176
246
  * and not others, which is the failure mode a per-branch object literal has.
177
247
  *
178
248
  * @param {object} fields
179
- * @returns {{ present: boolean, entryCount: number, lastConsolidatedAt: string|null,
249
+ * @returns {{ present: boolean, entryCount: number, indexBytes: number|null,
250
+ * lastConsolidatedAt: string|null,
180
251
  * entriesSinceConsolidation: number|null, recommend: boolean,
181
252
  * reasons: string[] }}
182
253
  */
@@ -184,6 +255,7 @@ function envelope(fields) {
184
255
  return {
185
256
  present: false,
186
257
  entryCount: 0,
258
+ indexBytes: null,
187
259
  lastConsolidatedAt: null,
188
260
  entriesSinceConsolidation: null,
189
261
  recommend: false,
@@ -197,14 +269,23 @@ function envelope(fields) {
197
269
  * the quiet verdict; the caller turns it into `recommend` and supplies the
198
270
  * standing-down sentence, so every arm lives in one place.
199
271
  *
200
- * The two arms are independent and both are reported when both fire.
272
+ * The three arms are independent and every one that fires is reported.
201
273
  *
202
274
  * @param {{ stamp: { at: string|null, baseline: number|null },
203
- * growth: number|null, now: Date|string|number,
204
- * staleAfterDays: number, growthDelta: number }} args
275
+ * growth: number|null, indexBytes: number|null,
276
+ * now: Date|string|number, staleAfterDays: number,
277
+ * growthDelta: number, indexByteCeiling: number }} args
205
278
  * @returns {string[]}
206
279
  */
207
- function collectReasons({ stamp, growth, now, staleAfterDays, growthDelta }) {
280
+ function collectReasons({
281
+ stamp,
282
+ growth,
283
+ indexBytes,
284
+ now,
285
+ staleAfterDays,
286
+ growthDelta,
287
+ indexByteCeiling,
288
+ }) {
208
289
  const reasons = [];
209
290
 
210
291
  if (stamp.at === null) {
@@ -229,6 +310,13 @@ function collectReasons({ stamp, growth, now, staleAfterDays, growthDelta }) {
229
310
  );
230
311
  }
231
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
+
232
320
  return reasons;
233
321
  }
234
322
 
@@ -241,9 +329,9 @@ function collectReasons({ stamp, growth, now, staleAfterDays, growthDelta }) {
241
329
  */
242
330
  function quietReason({ growth, growthDelta }) {
243
331
  if (growth === null) {
244
- return 'memory pool is within the freshness threshold; growth is unmeasured until the next /memory-consolidate stamps an entry count';
332
+ return 'memory pool is within the freshness and index-size thresholds; growth is unmeasured until the next /memory-consolidate stamps an entry count';
245
333
  }
246
- return `memory pool is within both thresholds — ${growth} entries written since the last consolidation (under the ${growthDelta}-entry growth delta)`;
334
+ return `memory pool is within every threshold — ${growth} entries written since the last consolidation (under the ${growthDelta}-entry growth delta)`;
247
335
  }
248
336
 
249
337
  /**
@@ -261,7 +349,9 @@ function quietReason({ growth, growthDelta }) {
261
349
  * @param {Date|string|number} [opts.now]
262
350
  * @param {number} [opts.staleAfterDays]
263
351
  * @param {number} [opts.growthDelta]
264
- * @returns {{ present: boolean, entryCount: number, lastConsolidatedAt: string|null,
352
+ * @param {number} [opts.indexByteCeiling]
353
+ * @returns {{ present: boolean, entryCount: number, indexBytes: number|null,
354
+ * lastConsolidatedAt: string|null,
265
355
  * entriesSinceConsolidation: number|null, recommend: boolean,
266
356
  * reasons: string[] }}
267
357
  */
@@ -273,6 +363,7 @@ export function buildMemoryPoolAdvisory({
273
363
  now = new Date(),
274
364
  staleAfterDays = STALE_AFTER_DAYS,
275
365
  growthDelta = GROWTH_DELTA,
366
+ indexByteCeiling = INDEX_BYTE_CEILING,
276
367
  } = {}) {
277
368
  const absent = (reason) => envelope({ reasons: [reason] });
278
369
 
@@ -283,12 +374,7 @@ export function buildMemoryPoolAdvisory({
283
374
  );
284
375
  }
285
376
 
286
- let isDir = false;
287
- try {
288
- isDir = fsImpl.statSync(poolDir).isDirectory();
289
- } catch {
290
- isDir = false;
291
- }
377
+ const isDir = probe(() => fsImpl.statSync(poolDir).isDirectory(), false);
292
378
  if (!isDir) {
293
379
  return absent(`no memory pool at ${poolDir} — nothing to consolidate`);
294
380
  }
@@ -298,14 +384,16 @@ export function buildMemoryPoolAdvisory({
298
384
  return absent(`memory pool at ${poolDir} could not be listed`);
299
385
  }
300
386
 
301
- const stamp = readStamp({ poolDir, fsImpl });
387
+ const stamp = readStamp({ poolDir, fsImpl, now });
302
388
  // Reported raw: a pruning pass can leave this negative, and saying the pool
303
389
  // shrank by 7 is more use to the operator than clamping it to zero.
304
390
  const growth = stamp.baseline === null ? null : entryCount - stamp.baseline;
391
+ const indexBytes = readIndexBytes({ poolDir, fsImpl });
305
392
 
306
393
  const found = {
307
394
  present: true,
308
395
  entryCount,
396
+ indexBytes,
309
397
  lastConsolidatedAt: stamp.at,
310
398
  entriesSinceConsolidation: growth,
311
399
  };
@@ -321,9 +409,11 @@ export function buildMemoryPoolAdvisory({
321
409
  const reasons = collectReasons({
322
410
  stamp,
323
411
  growth,
412
+ indexBytes,
324
413
  now,
325
414
  staleAfterDays,
326
415
  growthDelta,
416
+ indexByteCeiling,
327
417
  });
328
418
 
329
419
  return envelope({
@@ -40,6 +40,13 @@ import { resolveStoryDispatchMode } from './complexity-gate.js';
40
40
  /** Labels/state that mean a blocker no longer gates its dependents. */
41
41
  const DONE_LABEL = 'agent::done';
42
42
 
43
+ /**
44
+ * The lifecycle-label prefix a deliverable Story carries. Any `agent::*` label
45
+ * will do — the resolver is not a state machine and does not care WHICH state a
46
+ * Story is in, only that it has been through the step that assigns one.
47
+ */
48
+ const AGENT_LABEL_PREFIX = 'agent::';
49
+
43
50
  /**
44
51
  * Module-private: `toStoryRecord` and `isSatisfiedBlocker` are its only
45
52
  * callers. The ancestor exported it with no external consumer, which is how
@@ -64,9 +71,12 @@ function normalizeIssueLabels(issue) {
64
71
  *
65
72
  * @param {object} issue
66
73
  * @param {number} [requestedId] The id the operator asked for, for error text.
74
+ * @param {{ allowUnlabelled?: boolean }} [options] `allowUnlabelled` waives the
75
+ * `agent::*` guard below — the deliberate escape hatch for delivering a Story
76
+ * whose state label is absent for a reason the operator knows about.
67
77
  * @returns {{ id, title, body, url, labels, state, assignees }}
68
78
  */
69
- export function toStoryRecord(issue, requestedId) {
79
+ export function toStoryRecord(issue, requestedId, { allowUnlabelled } = {}) {
70
80
  const id = Number(issue?.number ?? issue?.id ?? requestedId);
71
81
  if (!Number.isInteger(id) || id <= 0) {
72
82
  throw new Error(
@@ -88,6 +98,9 @@ export function toStoryRecord(issue, requestedId) {
88
98
  `v2 is Story-only — re-plan it as a v2 Story or finish it on a pre-v2 checkout.`,
89
99
  );
90
100
  }
101
+ // Last, so the two shape refusals above — not a Story at all, and a v1 body —
102
+ // keep naming their own remedy rather than being masked by a missing label.
103
+ assertDispatchable(id, labels, allowUnlabelled);
91
104
  return {
92
105
  id,
93
106
  title: String(issue?.title ?? ''),
@@ -106,6 +119,36 @@ export function toStoryRecord(issue, requestedId) {
106
119
  };
107
120
  }
108
121
 
122
+ /**
123
+ * Refuse a Story that has never been through planning.
124
+ *
125
+ * The audit sweep files Stories deliberately WITHOUT an `agent::*` label: their
126
+ * bodies are audit prose — a symptom and a recommendation — not a scoped change
127
+ * with acceptance criteria a worker can verify against, and the sweep's runbook
128
+ * says so. But `/mandrel-deliver` takes ids, and nothing downstream re-checked the
129
+ * label, so naming a freshly-filed audit Story dispatched a worker at an
130
+ * unenriched body: the run then either invented its own acceptance criteria or
131
+ * blocked several minutes in, having taken the Story's lease and flipped it to
132
+ * `agent::executing` on the way.
133
+ *
134
+ * The label is the cheap, honest signal that the enrich step ran — no state
135
+ * machine is consulted, only that SOME `agent::*` label exists.
136
+ *
137
+ * @param {number} id
138
+ * @param {string[]} labels
139
+ * @param {boolean} [allowUnlabelled]
140
+ */
141
+ function assertDispatchable(id, labels, allowUnlabelled) {
142
+ if (allowUnlabelled) return;
143
+ if (labels.some((l) => l.startsWith(AGENT_LABEL_PREFIX))) return;
144
+ throw new Error(
145
+ `[resolve-stories] Issue #${id} carries no "${AGENT_LABEL_PREFIX}*" label, so it has not been ` +
146
+ `through planning — an audit sweep files Stories without one on purpose (its runbook's ` +
147
+ `"Enrich before you deliver" step). Route it through /mandrel-plan first, which applies ` +
148
+ `agent::ready once the finding is a scoped slice. Pass --allow-unlabelled to deliver it as-is.`,
149
+ );
150
+ }
151
+
109
152
  /**
110
153
  * A blocker stops gating once its issue is closed or carries `agent::done`.
111
154
  *
@@ -372,11 +372,25 @@ export async function analyzeChangedFiles(
372
372
  /**
373
373
  * Pure: turn a lint summary into Finding(s). Lint errors collapse into a
374
374
  * single high-risk finding (the structured comment shows the count); lint
375
- * warnings collapse into a single suggestion. An `executionFailed` summary
376
- * produces **zero** findings (Story #4699): a runner that could not execute
377
- * is an operational degradation, not a code finding — the provider routes it
378
- * to friction telemetry instead so severity counts reflect code findings
379
- * only.
375
+ * warnings collapse into a single suggestion.
376
+ *
377
+ * **Findings come from the parsed counts, never from the execution flag**
378
+ * (Story #5282). `executionFailed` is the OR across the biome and markdownlint
379
+ * surfaces, so gating findings on it let *one* absent runner discard the
380
+ * *other* surface's real errors — and since the code surface's disk probe
381
+ * (#5193) degrades in every checkout without `node_modules/.bin/biome`, that
382
+ * was the default state of a consumer checkout: markdownlint errors reached
383
+ * neither the findings nor the severity tally while the outcome read clean
384
+ * apart from a degradation line.
385
+ *
386
+ * Story #4699's intent is preserved exactly, because it was never about the
387
+ * flag: a degradation is still not a `Finding` — it has no counts to report,
388
+ * so a surface that could not execute contributes `parsed: false` and zero
389
+ * counts and produces nothing here, while travelling on the degradation and
390
+ * friction-telemetry channels under its own name. A summary that explicitly
391
+ * reports `parsed: false` therefore yields no findings whatever its counts
392
+ * claim; a summary omitting `parsed` (an injected or pre-#4839 shape) is
393
+ * scored on its counts as before.
380
394
  *
381
395
  * @param {{ errors: number, warnings: number, parsed?: boolean, skipped?: boolean, mode?: string, executionFailed?: boolean, evidenceSkipped?: boolean }} lintSummary
382
396
  * @returns {Finding[]}
@@ -385,7 +399,7 @@ export function buildLintFindings(lintSummary) {
385
399
  if (lintSummary.mode === 'off') return [];
386
400
  if (lintSummary.evidenceSkipped) return [];
387
401
  if (lintSummary.skipped) return [];
388
- if (lintSummary.executionFailed) return [];
402
+ if (lintSummary.parsed === false) return [];
389
403
  const findings = [];
390
404
  if (lintSummary.errors > 0) {
391
405
  findings.push({
@@ -428,6 +442,7 @@ async function runLintPhase({
428
442
  mode: 'off',
429
443
  executionFailed: false,
430
444
  degradations: [],
445
+ surfaces: [],
431
446
  };
432
447
  }
433
448
  logger?.info?.(
@@ -592,15 +607,19 @@ export function createNativeProvider(deps = {}) {
592
607
  // Story #4839 — telemetry alone left the review's own verdict unable to
593
608
  // distinguish "lint ran and found nothing" from "lint never ran", so
594
609
  // the same degradation is also recorded on the outcome channel. It is
595
- // still never a `Finding`: the friction emission below is unchanged and
596
- // severity counts remain code-findings-only.
610
+ // still never a `Finding`: the friction emission below is unchanged.
611
+ //
612
+ // Story #5282 — this branch is about the degraded surface only. A
613
+ // sibling surface that *did* run still contributes its parsed counts
614
+ // to `buildLintFindings` below, so a degradation here no longer
615
+ // suppresses the other surface's errors.
597
616
  recordedDegradations = buildLintDegradations(lintSummary);
598
617
  logger?.warn?.(
599
618
  `[native-review] Lint runner could not execute (${recordedDegradations
600
619
  .map((d) => `${d.surface}: ${d.reason}`)
601
620
  .join(
602
621
  '; ',
603
- )}) — reported as a degraded gate on the review outcome and recorded as friction telemetry; no finding emitted. Verify with the canonical \`npm run lint\` before merging.`,
622
+ )}) — reported as a degraded gate on the review outcome and recorded as friction telemetry; the degradation itself is never a finding, and any surface that did run still reports its own errors. Verify with the canonical \`npm run lint\` before merging.`,
604
623
  );
605
624
  try {
606
625
  await emitToolDegradationFn({
@@ -622,8 +641,9 @@ export function createNativeProvider(deps = {}) {
622
641
 
623
642
  // Canonical ordering: critical (maintainability) first, then high
624
643
  // (lint errors), then medium (size/volume warnings), then suggestion
625
- // (lint warnings). An execution failure contributes to none of these
626
- // tiers — it travels on the degradation channel. The renderer
644
+ // (lint warnings). An execution failure contributes no counts of its
645
+ // own to these tiers — it travels on the degradation channel — but it
646
+ // no longer suppresses a sibling surface's. The renderer
627
647
  // re-bucketizes by severity tier, so this order only matters for
628
648
  // stability of fixture outputs.
629
649
  return [
@@ -289,36 +289,38 @@ export function parseLintOutput(result) {
289
289
  * one degradation record naming itself. Merging *summaries* rather than raw
290
290
  * output is what stops one runner's failure from becoming the other's verdict.
291
291
  *
292
+ * The OR is deliberately lossy — it answers "did any surface fail?", which is
293
+ * the only question the degradation channel asks. Story #5282 added the
294
+ * `surfaces[]` rows so a consumer can ask the *other* questions the OR cannot
295
+ * answer: which surface the merged counts came from, and — via each row's
296
+ * `parsed` and `executionFailed` — whether an absent biome or a biome run that
297
+ * simply reported nothing is behind a zero. Consumers that read the flat
298
+ * counts are unaffected; the rows are additive.
299
+ *
292
300
  * @param {Array<{ surface: string, summary: ReturnType<typeof parseLintOutput> }>} surfaces
293
301
  */
294
302
  function mergeSurfaceSummaries(surfaces) {
295
- let errors = 0;
296
- let warnings = 0;
297
- let parsed = false;
298
- let executionFailed = false;
299
- const degradations = [];
300
-
301
- for (const { surface, summary } of surfaces) {
302
- errors += summary.errors;
303
- warnings += summary.warnings;
304
- if (summary.parsed) parsed = true;
305
- if (summary.executionFailed) {
306
- executionFailed = true;
307
- degradations.push({
308
- surface,
309
- reason: summary.reason ?? DEGRADATION_REASONS.UNPARSEABLE_OUTPUT,
310
- });
311
- }
312
- }
303
+ const rows = surfaces.map(({ surface, summary }) => ({
304
+ surface,
305
+ parsed: summary.parsed,
306
+ errors: summary.errors,
307
+ warnings: summary.warnings,
308
+ executionFailed: summary.executionFailed,
309
+ reason: summary.reason ?? DEGRADATION_REASONS.UNPARSEABLE_OUTPUT,
310
+ }));
311
+ const total = (field) => rows.reduce((sum, row) => sum + row[field], 0);
313
312
 
314
313
  return {
315
- errors,
316
- warnings,
317
- parsed,
318
- executionFailed,
314
+ errors: total('errors'),
315
+ warnings: total('warnings'),
316
+ parsed: rows.some((row) => row.parsed),
317
+ executionFailed: rows.some((row) => row.executionFailed),
319
318
  skipped: false,
320
319
  mode: 'changed-only',
321
- degradations,
320
+ degradations: rows
321
+ .filter((row) => row.executionFailed)
322
+ .map(({ surface, reason }) => ({ surface, reason })),
323
+ surfaces: rows.map(({ reason, ...row }) => row),
322
324
  };
323
325
  }
324
326
 
@@ -329,7 +331,7 @@ function mergeSurfaceSummaries(surfaces) {
329
331
  * @param {string} cwd
330
332
  * @param {typeof spawnLintRunner} [runnerFn]
331
333
  * @param {{ existsFn?: (p: string) => boolean }} [deps] Test seam for runner resolution.
332
- * @returns {{ errors: number, warnings: number, parsed: boolean, skipped: boolean, mode: 'changed-only'|'off', executionFailed: boolean, degradations: Array<{ surface: string, reason: string }> }}
334
+ * @returns {{ errors: number, warnings: number, parsed: boolean, skipped: boolean, mode: 'changed-only'|'off', executionFailed: boolean, degradations: Array<{ surface: string, reason: string }>, surfaces: Array<{ surface: string, parsed: boolean, errors: number, warnings: number, executionFailed: boolean }> }}
333
335
  */
334
336
  export function runScopedLint(
335
337
  changedFiles,
@@ -348,6 +350,7 @@ export function runScopedLint(
348
350
  mode: 'changed-only',
349
351
  executionFailed: false,
350
352
  degradations: [],
353
+ surfaces: [],
351
354
  };
352
355
  }
353
356