mandrel 2.54.0 → 2.55.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/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 +4 -2
  5. package/.agents/docs/configuration.md +2 -0
  6. package/.agents/schemas/agentrc.schema.json +15 -1
  7. package/.agents/schemas/lifecycle/merge.unlanded.schema.json +2 -1
  8. package/.agents/schemas/story-deliver-terminal.schema.json +1 -0
  9. package/.agents/scripts/audit-to-stories.js +158 -7
  10. package/.agents/scripts/check-audit-attribution.js +119 -62
  11. package/.agents/scripts/check-test-portability.js +512 -0
  12. package/.agents/scripts/coverage-capture.js +17 -10
  13. package/.agents/scripts/evidence-gate.js +31 -4
  14. package/.agents/scripts/generate-workflows-doc.js +65 -14
  15. package/.agents/scripts/git-cleanup.js +4 -0
  16. package/.agents/scripts/lib/ITicketingProvider.js +78 -0
  17. package/.agents/scripts/lib/audit-advisories.js +195 -0
  18. package/.agents/scripts/lib/audit-attribution.js +22 -0
  19. package/.agents/scripts/lib/audit-to-stories/dedupe-against-github.js +68 -5
  20. package/.agents/scripts/lib/audit-to-stories/issue-index.js +83 -0
  21. package/.agents/scripts/lib/audit-to-stories/ledger-commit.js +60 -114
  22. package/.agents/scripts/lib/audit-to-stories/ledger-pr.js +347 -0
  23. package/.agents/scripts/lib/audit-to-stories/parse-audit-md.js +169 -44
  24. package/.agents/scripts/lib/baselines/merge-envelopes.js +298 -32
  25. package/.agents/scripts/lib/bootstrap/baseline-merge-driver.js +180 -14
  26. package/.agents/scripts/lib/cli-args.js +26 -0
  27. package/.agents/scripts/lib/close-validation/gates.js +113 -7
  28. package/.agents/scripts/lib/close-validation/process.js +7 -3
  29. package/.agents/scripts/lib/close-validation/runner.js +62 -11
  30. package/.agents/scripts/lib/config/ci.js +28 -9
  31. package/.agents/scripts/lib/config-settings-schema-delivery.js +7 -0
  32. package/.agents/scripts/lib/config-settings-schema.js +19 -1
  33. package/.agents/scripts/lib/coverage-capture-fullscope.js +23 -11
  34. package/.agents/scripts/lib/coverage-capture-incremental.js +22 -16
  35. package/.agents/scripts/lib/coverage-capture-usage.js +5 -1
  36. package/.agents/scripts/lib/coverage-capture.js +77 -3
  37. package/.agents/scripts/lib/findings/route-finding.js +4 -2
  38. package/.agents/scripts/lib/full-suite-lock.js +232 -6
  39. package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
  40. package/.agents/scripts/lib/git/sync-from-base.js +130 -13
  41. package/.agents/scripts/lib/observability/source-classifier.js +1 -0
  42. package/.agents/scripts/lib/orchestration/check-baselines/phases/compare.js +10 -2
  43. package/.agents/scripts/lib/orchestration/check-baselines/phases/refresh-ack.js +75 -15
  44. package/.agents/scripts/lib/orchestration/deliver-recover.js +82 -43
  45. package/.agents/scripts/lib/orchestration/dependency-candidates.js +8 -4
  46. package/.agents/scripts/lib/orchestration/epic-candidates.js +9 -4
  47. package/.agents/scripts/lib/orchestration/epic-container.js +66 -4
  48. package/.agents/scripts/lib/orchestration/epic-rollup.js +233 -84
  49. package/.agents/scripts/lib/orchestration/file-assumptions.js +218 -16
  50. package/.agents/scripts/lib/orchestration/git-cleanup/phases/branches.js +93 -7
  51. package/.agents/scripts/lib/orchestration/git-cleanup/phases/git-probes.js +22 -6
  52. package/.agents/scripts/lib/orchestration/git-cleanup/phases/parse-args.js +26 -5
  53. package/.agents/scripts/lib/orchestration/git-cleanup/phases/phase-drivers.js +13 -2
  54. package/.agents/scripts/lib/orchestration/git-cleanup/phases/render.js +35 -5
  55. package/.agents/scripts/lib/orchestration/merge-block-class.js +18 -3
  56. package/.agents/scripts/lib/orchestration/merge-poll.js +284 -40
  57. package/.agents/scripts/lib/orchestration/plan-persist/epic-adoption.js +49 -2
  58. package/.agents/scripts/lib/orchestration/plan-persist/epic-ops.js +43 -7
  59. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +24 -1
  60. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +5 -0
  61. package/.agents/scripts/lib/orchestration/plan-persist/summary.js +3 -0
  62. package/.agents/scripts/lib/orchestration/plan-persist/supersede-ops.js +63 -0
  63. package/.agents/scripts/lib/orchestration/plan-persist/wave-serialisation.js +110 -0
  64. package/.agents/scripts/lib/orchestration/planning/memory-pool-advisory.js +130 -40
  65. package/.agents/scripts/lib/orchestration/resolve-stories.js +44 -1
  66. package/.agents/scripts/lib/orchestration/review-providers/native.js +31 -11
  67. package/.agents/scripts/lib/orchestration/review-providers/scoped-lint.js +27 -24
  68. package/.agents/scripts/lib/orchestration/run-epilogue.js +59 -38
  69. package/.agents/scripts/lib/orchestration/single-story-close/close-note.js +81 -0
  70. package/.agents/scripts/lib/orchestration/single-story-close/failed-terminal.js +40 -51
  71. package/.agents/scripts/lib/orchestration/single-story-close/phases/auto-merge.js +10 -2
  72. package/.agents/scripts/lib/orchestration/single-story-close/phases/base-sync.js +101 -0
  73. package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +351 -28
  74. package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +27 -6
  75. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +117 -22
  76. package/.agents/scripts/lib/orchestration/story-close/baseline-upward-writeback.js +94 -12
  77. package/.agents/scripts/lib/orchestration/story-close/format-autofix.js +6 -1
  78. package/.agents/scripts/lib/orchestration/ticket-validator.js +25 -14
  79. package/.agents/scripts/lib/orchestration/ticketing/bulk.js +30 -0
  80. package/.agents/scripts/lib/orchestration/verify-credit.js +37 -0
  81. package/.agents/scripts/lib/pinned-override-notes.js +41 -53
  82. package/.agents/scripts/lib/pinned-override-resolve.js +212 -0
  83. package/.agents/scripts/lib/qa/resolve-qa-contract.js +18 -0
  84. package/.agents/scripts/lib/single-story-sweep/sweep-lock.js +173 -9
  85. package/.agents/scripts/lib/skills/walk-skill-files.js +24 -7
  86. package/.agents/scripts/lib/test-temp.js +167 -30
  87. package/.agents/scripts/lib/validation-evidence.js +37 -0
  88. package/.agents/scripts/lib/wave-runner/footprint.js +167 -14
  89. package/.agents/scripts/lib/wave-runner/live-probe.js +7 -1
  90. package/.agents/scripts/lib/wave-runner/ready-set.js +1 -1
  91. package/.agents/scripts/merge-baseline.js +175 -21
  92. package/.agents/scripts/providers/github/errors.js +22 -1
  93. package/.agents/scripts/providers/github/issues.js +106 -1
  94. package/.agents/scripts/providers/github/sub-issue-add.js +18 -1
  95. package/.agents/scripts/providers/github.js +6 -0
  96. package/.agents/scripts/resolve-stories.js +44 -34
  97. package/.agents/scripts/single-story-close.js +5 -0
  98. package/.agents/scripts/stories-wave-tick.js +37 -13
  99. package/.agents/templates/docs/audit-sweep-runbook.md +41 -7
  100. package/.agents/workflows/audit-accessibility.md +16 -31
  101. package/.agents/workflows/audit-mobile.md +20 -37
  102. package/.agents/workflows/git-cleanup.md +17 -3
  103. package/.agents/workflows/helpers/audit-lens-core.md +45 -0
  104. package/.agents/workflows/helpers/deliver-digest.md +7 -6
  105. package/.agents/workflows/helpers/deliver-reference.md +35 -14
  106. package/.agents/workflows/helpers/deliver-story-reference.md +7 -4
  107. package/.agents/workflows/helpers/deliver-story.md +15 -12
  108. package/.agents/workflows/helpers/plan-reference.md +7 -0
  109. package/.agents/workflows/mandrel-plan.md +4 -7
  110. package/.agents/workflows/memory-consolidate.md +14 -9
  111. package/docs/CHANGELOG.md +27 -0
  112. package/lib/cli/registry.js +64 -21
  113. package/lib/cli/sync.js +27 -2
  114. package/package.json +7 -4
@@ -38,7 +38,7 @@
38
38
  */
39
39
 
40
40
  import { rm } from 'node:fs/promises';
41
- import { getLimits, PROJECT_ROOT } from '../../config-resolver.js';
41
+ import { getLimits, getPaths, PROJECT_ROOT } from '../../config-resolver.js';
42
42
  import { gitSpawn } from '../../git-utils.js';
43
43
  import { Logger } from '../../Logger.js';
44
44
  import { sweepTempRetention } from '../../temp-retention.js';
@@ -88,6 +88,7 @@ import {
88
88
  PLAN_SUMMARY_COMMENT_TYPE,
89
89
  } from './summary.js';
90
90
  import { closeSupersededTickets } from './supersede-ops.js';
91
+ import { predictWaveSerialisation } from './wave-serialisation.js';
91
92
 
92
93
  /** Checkpoint schema version written on each Story's story-plan-state. */
93
94
  const PLAN_CHECKPOINT_SCHEMA_VERSION_V2 = 2;
@@ -212,6 +213,7 @@ async function runSupersedePhase(args) {
212
213
  reason: `phase-error: ${err.message}`,
213
214
  closed: [],
214
215
  planned: [],
216
+ epicRollup: { closed: [], pending: [] },
215
217
  skipped: [],
216
218
  failed: (args.sourceTicketIds ?? []).map((ticket) => ({
217
219
  ticket,
@@ -831,6 +833,17 @@ export async function runPlanPersist({
831
833
  })),
832
834
  );
833
835
 
836
+ // Story #5265: the table says which Stories share an order; the dispatcher
837
+ // decides which of those actually run together, and it decides on the
838
+ // evidence-widened footprint. Run its own predicate over the assembled
839
+ // bodies — the exact artifact the tick will read back off GitHub — so the
840
+ // comment names the serialisation instead of promising parallelism the
841
+ // next tick refuses. `tempRoot` is threaded for the same reason the tick
842
+ // threads it: the scrape must ignore this project's scratch root.
843
+ const waveCollisions = predictWaveSerialisation(waveTable, stories, {
844
+ tempRoot: getPaths(config).tempRoot,
845
+ });
846
+
834
847
  // Story #4541: `readPlanMetrics` is declared `(epicId, config)` but was
835
848
  // called with `config` first, so the ledger path resolver received the
836
849
  // config object as an `epicId` and threw its guard on every single run —
@@ -860,6 +873,7 @@ export async function runPlanPersist({
860
873
  // collisions the conflict passes found belong on the same surface, or the
861
874
  // promise is the only half anyone reads.
862
875
  conflictFindings: assembledConflicts,
876
+ waveCollisions,
863
877
  });
864
878
 
865
879
  if (!dryRun) {
@@ -890,6 +904,10 @@ export async function runPlanPersist({
890
904
  stories,
891
905
  created,
892
906
  sourceTicketIds,
907
+ // Story #5280 — closing a source ticket is a child state change, so the
908
+ // phase re-derives the container above it. It needs the config the rollup
909
+ // reads its board and operator handle from.
910
+ config,
893
911
  dryRun,
894
912
  closeSuperseded,
895
913
  });
@@ -915,6 +933,11 @@ export async function runPlanPersist({
915
933
  reachability,
916
934
  freshness,
917
935
  waveTable,
936
+ // Story #5265 AC-2/AC-4: both halves of what persist concluded but used
937
+ // to keep to itself — the `refactors-existing` declarations it rewrote,
938
+ // and the same-order pairs the dispatcher will serialize.
939
+ assumptionNormalizations: validated.normalizations ?? [],
940
+ waveCollisions,
918
941
  supersede,
919
942
  epic: containerEpic,
920
943
  };
@@ -1098,6 +1098,11 @@ export async function createStoryIssues({ provider, stories, opts = {} }) {
1098
1098
  id,
1099
1099
  title: story.title,
1100
1100
  url: result.url,
1101
+ // Kept, not discarded (Story #5280). `createIssue` hands back the
1102
+ // database id, which is the only identifier the native sub-issue write
1103
+ // accepts — and dropping it here is what made the Epic linker re-read
1104
+ // every Story this run had just created to recover it.
1105
+ internalId: result.internalId,
1101
1106
  // True when the provider's retry probe adopted an issue a lost-response
1102
1107
  // first attempt had already filed — pre-existing either way.
1103
1108
  adopted: result.adopted === true,
@@ -14,6 +14,7 @@
14
14
  */
15
15
 
16
16
  import { computeStoryWaves } from '../dependency-analyzer.js';
17
+ import { renderPredictedSerialisationLines } from './wave-serialisation.js';
17
18
 
18
19
  /**
19
20
  * Structured-comment type for the persist summary.
@@ -129,6 +130,7 @@ export function buildPlanSummaryCommentBody({
129
130
  planMetricsLine = null,
130
131
  stories = null,
131
132
  conflictFindings = null,
133
+ waveCollisions = null,
132
134
  // legacy unused knobs kept so older test call sites don't crash mid-migration
133
135
  single = null,
134
136
  amend = null,
@@ -180,6 +182,7 @@ export function buildPlanSummaryCommentBody({
180
182
  '#### Delivery order (`depends_on`)',
181
183
  '',
182
184
  ...renderWaveTableLines(waveTable),
185
+ ...renderPredictedSerialisationLines(waveCollisions),
183
186
  ...renderSharedEditorLines(conflictFindings),
184
187
  '',
185
188
  `_Deliver with \`${deliverCommand}\` — \`/mandrel-deliver\` resolves the dependency graph from live state, so edges may point at Stories from earlier plan runs._`,
@@ -50,6 +50,7 @@ import {
50
50
  concurrentMap,
51
51
  FANOUT_CONCURRENCY,
52
52
  } from '../../util/concurrent-map.js';
53
+ import { rollUpEpicForStory } from '../epic-rollup.js';
53
54
  import { upsertStructuredComment } from '../ticketing.js';
54
55
 
55
56
  /** Structured-comment type marking a source issue as superseded. */
@@ -444,6 +445,9 @@ async function closeOneSupersededTicket({
444
445
  * slug is the only identifier that means anything before the writes land.
445
446
  * @property {Array<{ ticket: number, reason: string }>} skipped
446
447
  * @property {Array<{ ticket: number, reason: string }>} failed
448
+ * @property {{ closed: number[], pending: number[] }} epicRollup Container
449
+ * Epics this phase's closes resolved. Empty on a dry run and on a phase that
450
+ * closed nothing.
447
451
  */
448
452
 
449
453
  function emptyReport(overrides) {
@@ -455,10 +459,53 @@ function emptyReport(overrides) {
455
459
  planned: [],
456
460
  skipped: [],
457
461
  failed: [],
462
+ epicRollup: { closed: [], pending: [] },
458
463
  ...overrides,
459
464
  };
460
465
  }
461
466
 
467
+ /**
468
+ * Re-derive the container Epic above every ticket this phase just closed.
469
+ *
470
+ * Closing a source ticket is a child state change like any other, and it was
471
+ * the one edge with no rollup behind it. Superseding a cohort therefore left
472
+ * its container open indefinitely: every child was closed, so no delivery
473
+ * would ever run and no land tail would ever fire the derivation. The Epic sat
474
+ * open above finished work until someone noticed.
475
+ *
476
+ * Runs **after** the closes land, never alongside them: the rollup reads each
477
+ * child's state back, so racing it against the writes it is meant to observe
478
+ * would derive from a tree half of which has not been written yet.
479
+ *
480
+ * Sequential, sharing one `skipEpicIds` set, because siblings share a
481
+ * container — without it the second ticket re-derives, and re-closes, the Epic
482
+ * the first already closed. `rollUpEpicForStory` never throws, so no guard is
483
+ * needed here beyond the phase-level one the caller already holds.
484
+ *
485
+ * @param {{ closedIds: number[], provider: object, config?: object }} opts
486
+ * @returns {Promise<{ closed: number[], pending: number[] }>}
487
+ */
488
+ async function rollUpContainersFor({ closedIds, provider, config }) {
489
+ const seen = new Set();
490
+ const closed = new Set();
491
+ const pending = new Set();
492
+ for (const storyId of closedIds) {
493
+ const outcome = await rollUpEpicForStory({
494
+ storyId,
495
+ provider,
496
+ config,
497
+ skipEpicIds: seen,
498
+ });
499
+ for (const epic of outcome.epics) seen.add(epic.epicId);
500
+ for (const epicId of outcome.closed) closed.add(epicId);
501
+ for (const epicId of outcome.pending) pending.add(epicId);
502
+ }
503
+ // A container reported pending by one ticket's rollup and closed by a later
504
+ // one is closed: the last answer saw the whole cohort.
505
+ for (const epicId of closed) pending.delete(epicId);
506
+ return { closed: [...closed], pending: [...pending] };
507
+ }
508
+
462
509
  /**
463
510
  * Comment on and close every superseded source ticket.
464
511
  *
@@ -471,6 +518,7 @@ function emptyReport(overrides) {
471
518
  * @param {Array<{ slug: string, supersedes: Array<{ id: number, note: string|null }> }>} args.stories
472
519
  * @param {Array<{ slug: string, id: number, title: string }>} args.created
473
520
  * @param {number[]} args.sourceTicketIds
521
+ * @param {object} [args.config] Resolved `.agentrc.json`, for the Epic rollup.
474
522
  * @param {boolean} [args.dryRun=false]
475
523
  * @param {boolean} [args.closeSuperseded=true]
476
524
  * @returns {Promise<SupersedeReport>}
@@ -480,6 +528,7 @@ export async function closeSupersededTickets({
480
528
  stories,
481
529
  created,
482
530
  sourceTicketIds,
531
+ config,
483
532
  dryRun = false,
484
533
  closeSuperseded = true,
485
534
  }) {
@@ -532,6 +581,14 @@ export async function closeSupersededTickets({
532
581
  recordSupersedeOutcome(report, units[index], result);
533
582
  });
534
583
 
584
+ if (report.closed.length > 0) {
585
+ report.epicRollup = await rollUpContainersFor({
586
+ closedIds: report.closed,
587
+ provider,
588
+ config,
589
+ });
590
+ }
591
+
535
592
  logSupersedeReport(report);
536
593
  return report;
537
594
  }
@@ -613,4 +670,10 @@ function logSupersedeReport(report) {
613
670
  'close it by hand.',
614
671
  );
615
672
  }
673
+ if (report.epicRollup.closed.length > 0) {
674
+ Logger.info(
675
+ `[plan-persist] container Epic(s) closed by the supersede rollup: ` +
676
+ `${report.epicRollup.closed.map((id) => `#${id}`).join(', ')}.`,
677
+ );
678
+ }
616
679
  }
@@ -0,0 +1,110 @@
1
+ /**
2
+ * wave-serialisation.js — what the dispatcher will actually do with the wave
3
+ * table plan-persist prints (Story #5265).
4
+ *
5
+ * Kept out of `summary.js` because it answers a different question. The
6
+ * summary module renders receipts persist already computed; this one runs the
7
+ * *runtime's* collision predicate over the assembled Story bodies to work out
8
+ * which of the table's promises the next tick will refuse to keep.
9
+ *
10
+ * @module lib/orchestration/plan-persist/wave-serialisation
11
+ */
12
+
13
+ import {
14
+ detectCollision,
15
+ OVERLAP_SOURCES,
16
+ renderScrapeAttribution,
17
+ } from '../../wave-runner/footprint.js';
18
+
19
+ /**
20
+ * Predict which same-wave pairs the dispatch guard will actually refuse to
21
+ * co-dispatch (Story #5265).
22
+ *
23
+ * The wave table answers a `depends_on` question, and the runtime answers a
24
+ * different one: `stories-wave-tick.js` withholds on {@link detectCollision},
25
+ * whose footprint is the declared `changes[]` **plus** every path scraped out
26
+ * of the Story's title, spec and serialized body — and that body carries
27
+ * `## Verify`, so two Stories that merely run the same gate script share a
28
+ * path neither will edit. The table therefore promised parallelism the very
29
+ * next tick refused, with nothing anywhere reconciling the two.
30
+ *
31
+ * This runs the runtime's own exported predicate — not a reimplementation of
32
+ * it — pairwise within each wave, so the prediction cannot drift from the
33
+ * behaviour it predicts.
34
+ *
35
+ * @param {ReturnType<typeof buildWaveTable>} waveTable
36
+ * @param {Array<{ slug: string, title?: string, body?: string, spec?: string, changes?: Array }>} stories
37
+ * @param {{ tempRoot?: string }} [options]
38
+ * @returns {Array<{ wave: number, slugs: [string, string], paths: string[], source: string, attribution: object[] }>}
39
+ */
40
+ export function predictWaveSerialisation(waveTable, stories, options = {}) {
41
+ const bySlug = new Map(
42
+ (Array.isArray(stories) ? stories : []).map((s) => [s.slug, s]),
43
+ );
44
+ const out = [];
45
+ for (const { wave, stories: members } of Array.isArray(waveTable)
46
+ ? waveTable
47
+ : []) {
48
+ for (let i = 0; i < members.length; i += 1) {
49
+ for (let j = i + 1; j < members.length; j += 1) {
50
+ const a = bySlug.get(members[i].slug);
51
+ const b = bySlug.get(members[j].slug);
52
+ if (!a || !b) continue;
53
+ const collision = detectCollision(a, b, options);
54
+ if (collision) {
55
+ out.push({
56
+ wave,
57
+ slugs: [members[i].slug, members[j].slug],
58
+ ...collision,
59
+ });
60
+ }
61
+ }
62
+ }
63
+ }
64
+ return out;
65
+ }
66
+
67
+ /**
68
+ * Render the predicted serialisation beside the wave table (Story #5265).
69
+ *
70
+ * Same shape as {@link renderSharedEditorLines} on purpose: both are caveats
71
+ * against the same promise, and an operator should read them the same way.
72
+ * The difference is what they know — the shared-editor pass names paths two
73
+ * Stories both *write*, this one names every pair the dispatcher will refuse
74
+ * to run together whatever the reason, including the pairs whose only shared
75
+ * path was scraped out of a `## Verify` line.
76
+ *
77
+ * @param {ReturnType<typeof predictWaveSerialisation>} collisions
78
+ * @returns {string[]}
79
+ */
80
+ export function renderPredictedSerialisationLines(collisions) {
81
+ const list = Array.isArray(collisions) ? collisions : [];
82
+ if (list.length === 0) return [];
83
+ const rows = list.map((c) => {
84
+ const scraped = renderScrapeAttribution(c.attribution);
85
+ return `| \`${c.slugs[0]}\` + \`${c.slugs[1]}\` | ${c.paths
86
+ .map((p) => `\`${p}\``)
87
+ .join(', ')} | ${c.source} | ${scraped ? `\`${scraped}\`` : '—'} |`;
88
+ });
89
+ const scrapedOnly = list.filter(
90
+ (c) => c.source === OVERLAP_SOURCES.SCRAPED,
91
+ ).length;
92
+ return [
93
+ '',
94
+ `#### ⚠️ Predicted serialisation (${list.length} same-order pair(s))`,
95
+ '',
96
+ '| Stories | Colliding paths | Overlap source | Scraped from |',
97
+ '| --- | --- | --- | --- |',
98
+ ...rows,
99
+ '',
100
+ '_The dispatcher compares the **evidence-widened** footprint — declared ' +
101
+ '`changes[]` plus every path named in the title, `## Spec` and the rest ' +
102
+ 'of the body — so these pairs are shown in one order above but will be ' +
103
+ `dispatched one at a time. ${scrapedOnly} pair(s) collide only on ` +
104
+ 'scraped paths; the "Scraped from" column names the field each such ' +
105
+ 'path was read out of, so a shared `## Verify` command is ' +
106
+ 'distinguishable from a genuine unpredicted edit target. The guard is ' +
107
+ 'deliberately not narrowed to `changes[]`: the declaration is a lower ' +
108
+ 'bound (Story #4875) and under-serialising is the worse failure._',
109
+ ];
110
+ }
@@ -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({