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.
- package/.agents/agents/story-worker.md +24 -23
- package/.agents/audit-checklists/accessibility.md +0 -3
- package/.agents/audit-checklists/mobile.md +0 -4
- package/.agents/docs/agentrc-reference.json +4 -2
- package/.agents/docs/configuration.md +2 -0
- package/.agents/schemas/agentrc.schema.json +15 -1
- package/.agents/schemas/lifecycle/merge.unlanded.schema.json +2 -1
- package/.agents/schemas/story-deliver-terminal.schema.json +1 -0
- package/.agents/scripts/audit-to-stories.js +158 -7
- package/.agents/scripts/check-audit-attribution.js +119 -62
- package/.agents/scripts/check-test-portability.js +512 -0
- package/.agents/scripts/coverage-capture.js +17 -10
- package/.agents/scripts/evidence-gate.js +31 -4
- package/.agents/scripts/generate-workflows-doc.js +65 -14
- package/.agents/scripts/git-cleanup.js +4 -0
- package/.agents/scripts/lib/ITicketingProvider.js +78 -0
- package/.agents/scripts/lib/audit-advisories.js +195 -0
- package/.agents/scripts/lib/audit-attribution.js +22 -0
- package/.agents/scripts/lib/audit-to-stories/dedupe-against-github.js +68 -5
- package/.agents/scripts/lib/audit-to-stories/issue-index.js +83 -0
- package/.agents/scripts/lib/audit-to-stories/ledger-commit.js +60 -114
- package/.agents/scripts/lib/audit-to-stories/ledger-pr.js +347 -0
- package/.agents/scripts/lib/audit-to-stories/parse-audit-md.js +169 -44
- package/.agents/scripts/lib/baselines/merge-envelopes.js +298 -32
- package/.agents/scripts/lib/bootstrap/baseline-merge-driver.js +180 -14
- package/.agents/scripts/lib/cli-args.js +26 -0
- package/.agents/scripts/lib/close-validation/gates.js +113 -7
- package/.agents/scripts/lib/close-validation/process.js +7 -3
- package/.agents/scripts/lib/close-validation/runner.js +62 -11
- package/.agents/scripts/lib/config/ci.js +28 -9
- package/.agents/scripts/lib/config-settings-schema-delivery.js +7 -0
- package/.agents/scripts/lib/config-settings-schema.js +19 -1
- package/.agents/scripts/lib/coverage-capture-fullscope.js +23 -11
- package/.agents/scripts/lib/coverage-capture-incremental.js +22 -16
- package/.agents/scripts/lib/coverage-capture-usage.js +5 -1
- package/.agents/scripts/lib/coverage-capture.js +77 -3
- package/.agents/scripts/lib/findings/route-finding.js +4 -2
- package/.agents/scripts/lib/full-suite-lock.js +232 -6
- package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
- package/.agents/scripts/lib/git/sync-from-base.js +130 -13
- package/.agents/scripts/lib/observability/source-classifier.js +1 -0
- package/.agents/scripts/lib/orchestration/check-baselines/phases/compare.js +10 -2
- package/.agents/scripts/lib/orchestration/check-baselines/phases/refresh-ack.js +75 -15
- package/.agents/scripts/lib/orchestration/deliver-recover.js +82 -43
- package/.agents/scripts/lib/orchestration/dependency-candidates.js +8 -4
- package/.agents/scripts/lib/orchestration/epic-candidates.js +9 -4
- package/.agents/scripts/lib/orchestration/epic-container.js +66 -4
- package/.agents/scripts/lib/orchestration/epic-rollup.js +233 -84
- package/.agents/scripts/lib/orchestration/file-assumptions.js +218 -16
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/branches.js +93 -7
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/git-probes.js +22 -6
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/parse-args.js +26 -5
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/phase-drivers.js +13 -2
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/render.js +35 -5
- package/.agents/scripts/lib/orchestration/merge-block-class.js +18 -3
- package/.agents/scripts/lib/orchestration/merge-poll.js +284 -40
- package/.agents/scripts/lib/orchestration/plan-persist/epic-adoption.js +49 -2
- package/.agents/scripts/lib/orchestration/plan-persist/epic-ops.js +43 -7
- package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +24 -1
- package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +5 -0
- package/.agents/scripts/lib/orchestration/plan-persist/summary.js +3 -0
- package/.agents/scripts/lib/orchestration/plan-persist/supersede-ops.js +63 -0
- package/.agents/scripts/lib/orchestration/plan-persist/wave-serialisation.js +110 -0
- package/.agents/scripts/lib/orchestration/planning/memory-pool-advisory.js +130 -40
- package/.agents/scripts/lib/orchestration/resolve-stories.js +44 -1
- package/.agents/scripts/lib/orchestration/review-providers/native.js +31 -11
- package/.agents/scripts/lib/orchestration/review-providers/scoped-lint.js +27 -24
- package/.agents/scripts/lib/orchestration/run-epilogue.js +59 -38
- package/.agents/scripts/lib/orchestration/single-story-close/close-note.js +81 -0
- package/.agents/scripts/lib/orchestration/single-story-close/failed-terminal.js +40 -51
- package/.agents/scripts/lib/orchestration/single-story-close/phases/auto-merge.js +10 -2
- package/.agents/scripts/lib/orchestration/single-story-close/phases/base-sync.js +101 -0
- package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +351 -28
- package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +27 -6
- package/.agents/scripts/lib/orchestration/single-story-close/runner.js +117 -22
- package/.agents/scripts/lib/orchestration/story-close/baseline-upward-writeback.js +94 -12
- package/.agents/scripts/lib/orchestration/story-close/format-autofix.js +6 -1
- package/.agents/scripts/lib/orchestration/ticket-validator.js +25 -14
- package/.agents/scripts/lib/orchestration/ticketing/bulk.js +30 -0
- package/.agents/scripts/lib/orchestration/verify-credit.js +37 -0
- package/.agents/scripts/lib/pinned-override-notes.js +41 -53
- package/.agents/scripts/lib/pinned-override-resolve.js +212 -0
- package/.agents/scripts/lib/qa/resolve-qa-contract.js +18 -0
- package/.agents/scripts/lib/single-story-sweep/sweep-lock.js +173 -9
- package/.agents/scripts/lib/skills/walk-skill-files.js +24 -7
- package/.agents/scripts/lib/test-temp.js +167 -30
- package/.agents/scripts/lib/validation-evidence.js +37 -0
- package/.agents/scripts/lib/wave-runner/footprint.js +167 -14
- package/.agents/scripts/lib/wave-runner/live-probe.js +7 -1
- package/.agents/scripts/lib/wave-runner/ready-set.js +1 -1
- package/.agents/scripts/merge-baseline.js +175 -21
- package/.agents/scripts/providers/github/errors.js +22 -1
- package/.agents/scripts/providers/github/issues.js +106 -1
- package/.agents/scripts/providers/github/sub-issue-add.js +18 -1
- package/.agents/scripts/providers/github.js +6 -0
- package/.agents/scripts/resolve-stories.js +44 -34
- package/.agents/scripts/single-story-close.js +5 -0
- package/.agents/scripts/stories-wave-tick.js +37 -13
- package/.agents/templates/docs/audit-sweep-runbook.md +41 -7
- package/.agents/workflows/audit-accessibility.md +16 -31
- package/.agents/workflows/audit-mobile.md +20 -37
- package/.agents/workflows/git-cleanup.md +17 -3
- package/.agents/workflows/helpers/audit-lens-core.md +45 -0
- package/.agents/workflows/helpers/deliver-digest.md +7 -6
- package/.agents/workflows/helpers/deliver-reference.md +35 -14
- package/.agents/workflows/helpers/deliver-story-reference.md +7 -4
- package/.agents/workflows/helpers/deliver-story.md +15 -12
- package/.agents/workflows/helpers/plan-reference.md +7 -0
- package/.agents/workflows/mandrel-plan.md +4 -7
- package/.agents/workflows/memory-consolidate.md +14 -9
- package/docs/CHANGELOG.md +27 -0
- package/lib/cli/registry.js +64 -21
- package/lib/cli/sync.js +27 -2
- 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
|
|
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
|
|
129
|
-
* from "never consolidated" — all
|
|
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
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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,
|
|
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
|
|
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,
|
|
204
|
-
*
|
|
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({
|
|
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
|
|
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
|
|
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
|
-
* @
|
|
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
|
-
|
|
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({
|