faberun 0.30.0 → 0.32.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 (60) hide show
  1. package/package.json +1 -1
  2. package/src/campaign/campaign-brief.mjs +17 -6
  3. package/src/campaign/chain.mjs +124 -8
  4. package/src/campaign/ledger.mjs +19 -7
  5. package/src/campaign/metrics-command.mjs +68 -9
  6. package/src/campaign/metrics.mjs +75 -45
  7. package/src/campaign/record.mjs +8 -1
  8. package/src/campaign/reserve.mjs +340 -0
  9. package/src/campaign/spend.mjs +229 -0
  10. package/src/campaign/unpark.mjs +54 -1
  11. package/src/campaign/watch.mjs +314 -37
  12. package/src/cli/brand.mjs +2 -1
  13. package/src/cli.mjs +52 -2
  14. package/src/contract/judge-envelope.mjs +17 -2
  15. package/src/contract/scope-findings.mjs +3 -1
  16. package/src/contract/task-packet.mjs +40 -5
  17. package/src/contract/verification.mjs +67 -1
  18. package/src/engine/aux-admission.mjs +124 -0
  19. package/src/engine/capacity.mjs +78 -0
  20. package/src/engine/dispatch.mjs +116 -23
  21. package/src/engine/failure-cause.mjs +59 -0
  22. package/src/engine/lifecycle.mjs +3 -2
  23. package/src/engine/phase-session.mjs +56 -10
  24. package/src/engine/prompts.mjs +57 -2
  25. package/src/engine/reserve-admission.mjs +99 -0
  26. package/src/engine/resume.mjs +3 -1
  27. package/src/engine/review.mjs +21 -0
  28. package/src/engine/run-command.mjs +166 -11
  29. package/src/engine/scheduler.mjs +92 -88
  30. package/src/engine/settle.mjs +15 -8
  31. package/src/engine/settlement-overlap.mjs +143 -0
  32. package/src/engine/verify.mjs +131 -7
  33. package/src/harnesses/session-metrics.mjs +112 -20
  34. package/src/notify/index.mjs +86 -10
  35. package/src/plan/freeze.mjs +7 -2
  36. package/src/plan/human-step.mjs +36 -0
  37. package/src/plan/pipeline.mjs +53 -22
  38. package/src/plan/preflight.mjs +81 -5
  39. package/src/plan/progress.mjs +101 -1
  40. package/src/plan/repo-facts.mjs +403 -49
  41. package/src/plan/resolve.mjs +7 -0
  42. package/src/plan/revise-context.mjs +108 -0
  43. package/src/plan/rounds.mjs +180 -45
  44. package/src/plan/sizing.mjs +33 -1
  45. package/src/plan/template.mjs +134 -22
  46. package/src/repo/integrate.mjs +47 -5
  47. package/src/report/campaign-brief-estimate.mjs +47 -1
  48. package/src/report/campaign-brief.mjs +31 -2
  49. package/src/report/locale.mjs +20 -0
  50. package/src/report/message.mjs +13 -1
  51. package/src/report/metrics-report.mjs +38 -0
  52. package/src/report/progress.mjs +194 -10
  53. package/src/report/render.mjs +1 -1
  54. package/src/report/report-html.mjs +235 -0
  55. package/src/report/report-video.mjs +323 -0
  56. package/src/run/usage-windows.mjs +99 -4
  57. package/src/run/usage.mjs +69 -7
  58. package/src/web/api.mjs +179 -24
  59. package/src/web/app.mjs +27 -2
  60. package/src/web/server.mjs +32 -0
@@ -14,15 +14,34 @@
14
14
  * exists — and its output rides along as `requirementMeasurements`: the draft
15
15
  * reasons from a fact the planner measured, not one it inferred from prose.
16
16
  *
17
+ * A collection may be handed the facts a previous attempt recorded
18
+ * (`previousFacts`): each candidate timing is then reused, not re-paid,
19
+ * exactly while every input it was taken under still matches — the tree, the
20
+ * command line, the manifest and lockfile bytes, the machine — and is re-timed
21
+ * the moment any of them changes. A run that reached its timeout never enters
22
+ * that economy: it is recorded ineligible, and an ineligible measurement is
23
+ * never lent on — a later collection times the command fresh rather than
24
+ * promote the kill to a duration.
25
+ *
17
26
  * Sorting and the absence of any clock in the output itself (only inside an
18
27
  * injected measurer's own numbers) is what makes two calls at the same HEAD
19
28
  * byte-identical.
29
+ *
30
+ * The `paths` array is a byte-limited cut, never the tree. A path a
31
+ * requirement's proof or measure names, a test file, and a module the tree
32
+ * references are always kept; the ceiling bounds only what nothing asks for.
33
+ * What the cut discarded is reported per source kind in `pathOmission`, so a
34
+ * reader can tell a dropped manifest from a dropped historical log instead of
35
+ * reading one `truncated` flag, and the complete index every path was listed
36
+ * from is written beside the artefact (`writePathIndex`) for a discovery node
37
+ * explicitly authorised to enumerate the tree.
20
38
  */
21
- import { existsSync, readFileSync } from "node:fs";
39
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
22
40
  import { join } from "node:path";
23
41
  import { timeVerificationCommands } from "../host/preflight.mjs";
24
42
  import { directReferenceGraph } from "../repo/scope-closure.mjs";
25
43
  import { boundedGitSync, gitHead } from "../repo/worktree.mjs";
44
+ import { contentDigest } from "./freeze.mjs";
26
45
  import { runShellCapture } from "./proof-run.mjs";
27
46
 
28
47
  /** @typedef {import("./spec.mjs").SpecRequirement} SpecRequirement */
@@ -31,11 +50,32 @@ import { runShellCapture } from "./proof-run.mjs";
31
50
  /**
32
51
  * A verification command repo facts measured: a timed Node candidate, the
33
52
  * manifest file it was read from, and its eligibility. It keeps the shape
34
- * `freeze.mjs`'s `MeasuredFacts` reads (`argv` plus a numeric `measuredMs`),
35
- * so a repository's facts stay assignable there while carrying the manifest
36
- * the planner needs.
53
+ * `freeze.mjs`'s `MeasuredFacts` reads (`argv` plus `measuredMs`), so a
54
+ * repository's facts stay assignable there while carrying the manifest the
55
+ * planner needs. A run completed under the ceiling persists its duration; a
56
+ * run killed at the timeout is not a measured duration, so it persists
57
+ * `measuredMs` null with `eligible` false — nothing may plan from it, and no
58
+ * record may lend it.
59
+ *
60
+ * @typedef {{argv: string[], manifest: string, measuredMs: number|null, eligible: boolean}} VerificationCandidate
61
+ */
62
+ /**
63
+ * The machine a timing was taken on. Two collections that differ in any of
64
+ * these measured different machines, whatever else matched.
37
65
  *
38
- * @typedef {{argv: string[], manifest: string, measuredMs: number, eligible: boolean}} VerificationCandidate
66
+ * @typedef {{platform: string, arch: string, node: string}} MeasurementEnvironment
67
+ */
68
+ /**
69
+ * The dependency and environment inputs the candidate timings of one
70
+ * collection were taken under: the bytes of the manifest the commands and
71
+ * their dependencies are declared in, the bytes of the lockfile that resolved
72
+ * those dependencies, and the machine they ran on. Recorded beside the
73
+ * timings they qualify, so a later collection can lend from the record only
74
+ * while every one of them still matches. Absent on a record collected before
75
+ * the field existed — which is one of the reasons `reusableTimings` refuses a
76
+ * record it cannot verify.
77
+ *
78
+ * @typedef {{manifestDigest: string|null, lockfileDigest: string|null, environment: MeasurementEnvironment}} MeasuredUnder
39
79
  */
40
80
  /**
41
81
  * A verification command repo facts detected in a non-Node manifest it never
@@ -48,13 +88,63 @@ import { runShellCapture } from "./proof-run.mjs";
48
88
  * @typedef {{argv: string[], manifest: string, measuredMs: number|null, eligible: boolean}} DetectedVerificationCandidate
49
89
  */
50
90
  /** @typedef {{path: string, covers: string[]}} TestFileEntry */
51
- /** @typedef {{formatVersion: number, gitHead: string|null, paths: string[], truncated: boolean, scripts: Record<string, string>, verificationCandidates: VerificationCandidate[], detectedVerificationCandidates?: DetectedVerificationCandidate[], testFiles: TestFileEntry[], requirementMeasurements: RequirementMeasurement[]}} RepoFacts */
91
+ /**
92
+ * The source kind a discarded path is reported under. `other` is reserved for
93
+ * a path this module cannot name — no manifest basename, no document, log or
94
+ * source suffix — so a known category is never pooled into it.
95
+ *
96
+ * @typedef {"document"|"archived-log"|"manifest"|"code"|"other"} PathKind
97
+ */
98
+ /** @typedef {{paths: number, bytes: number}} PathKindOmission */
99
+ /** @typedef {{paths: number, bytes: number, byKind: Record<PathKind, PathKindOmission>}} PathOmission */
100
+ /** @typedef {{formatVersion: number, gitHead: string|null, measuredUnder?: MeasuredUnder, paths: string[], pathOmission: PathOmission, scripts: Record<string, string>, verificationCandidates: VerificationCandidate[], detectedVerificationCandidates?: DetectedVerificationCandidate[], testFiles: TestFileEntry[], requirementMeasurements: RequirementMeasurement[]}} RepoFacts */
52
101
 
53
102
  const FORMAT_VERSION = 1;
54
- const DEFAULT_MAX_PATHS = 2000;
103
+ /**
104
+ * The byte ceiling on the artefact's `paths` array. Measured 2026-09-21
105
+ * (AGENTS.md, Faberun protocol): a node whose serialized packet passed 64 KiB
106
+ * was killed by the guard that serializes it, and this inventory rides in that
107
+ * same packet — so the cut is held under 48 KiB and the rest of the artefact
108
+ * has the remainder. A count cap (the 2000 paths this replaces) says nothing
109
+ * about size: the same count is a different artefact in every repository.
110
+ */
111
+ export const DEFAULT_MAX_PATH_BYTES = 48 * 1024;
55
112
  const ELIGIBLE_MS_CEILING = 600_000;
56
113
  const CANDIDATE_TIMEOUT_SEC = ELIGIBLE_MS_CEILING / 1_000;
57
114
 
115
+ /** Every kind a discarded path can be reported under, so the omission report
116
+ * carries a full row per kind even when it discarded nothing there. */
117
+ export const PATH_KINDS = Object.freeze(/** @type {PathKind[]} */ (["document", "archived-log", "manifest", "code", "other"]));
118
+
119
+ /** The files commands are declared in: this module reads its own candidates
120
+ * out of the first two, and the rest are the other ecosystems' manifests. */
121
+ export const MANIFEST_BASENAMES = Object.freeze([
122
+ "package.json", "package-lock.json", "tsconfig.json",
123
+ "pyproject.toml", "pytest.ini", "go.mod", "go.sum",
124
+ "Cargo.toml", "Cargo.lock", "build.zig", "build.zig.zon",
125
+ "Makefile", "makefile", "GNUmakefile",
126
+ ]);
127
+
128
+ /** Every TOML file is a manifest, declared name or not (`netlify.toml`). */
129
+ export const MANIFEST_SUFFIX = ".toml";
130
+
131
+ /** Prose a human wrote for a human: a document is never a build input. */
132
+ export const DOCUMENT_SUFFIX = ".md";
133
+
134
+ /** Directories holding historical record: ledgers and superseded runs. */
135
+ export const ARCHIVED_LOG_PREFIXES = Object.freeze([".runs/", "docs/history/"]);
136
+
137
+ /** A machine-written ledger, wherever it lives. */
138
+ export const ARCHIVED_LOG_SUFFIX = ".jsonl";
139
+
140
+ /**
141
+ * The extensions of the ecosystems this module already reasons about (Node,
142
+ * Python, Go, Rust, Zig, shell). A path whose extension is not here is not
143
+ * promoted to `code`: calling a PNG source would be the same lie in the other
144
+ * direction as calling it `other`.
145
+ */
146
+ const CODE_SUFFIXES = Object.freeze([".mjs", ".cjs", ".js", ".jsx", ".ts", ".tsx", ".py", ".go", ".rs", ".zig", ".sh", ".mk", ".c", ".h"]);
147
+
58
148
  /**
59
149
  * Every path git tracks at HEAD, sorted. The bounded spawn is the same
60
150
  * pattern `src/repo/source-identity.mjs` uses for its own git reads: a
@@ -70,12 +160,145 @@ function listTrackedPaths(cwd) {
70
160
  return String(result.stdout).split("\n").filter(Boolean).sort();
71
161
  }
72
162
 
73
- /** @param {string} cwd @returns {Record<string, string>} */
74
- function readScripts(cwd) {
75
- const packagePath = join(cwd, "package.json");
76
- if (!existsSync(packagePath)) return {};
77
- const parsed = JSON.parse(readFileSync(packagePath, "utf8"));
78
- return parsed.scripts && typeof parsed.scripts === "object" ? parsed.scripts : {};
163
+ /**
164
+ * The bytes one path costs the artefact: its own UTF-8 length. That is what
165
+ * the ceiling counts and what the omission report sums, so the number a reader
166
+ * sees for a discarded kind is the number that kind was worth to the budget.
167
+ *
168
+ * @param {string} path
169
+ * @returns {number}
170
+ */
171
+ export function pathBytes(path) {
172
+ return Buffer.byteLength(path, "utf8");
173
+ }
174
+
175
+ /**
176
+ * Which source kind one path belongs to. Total by construction — every path
177
+ * lands in exactly one of `PATH_KINDS` — which is what lets the per-kind
178
+ * counts of a cut sum to the discarded total with no path uncounted.
179
+ *
180
+ * Order matters: a `docs/history/*.md` is record, not live prose, and a
181
+ * `package.json` is a manifest, not a document that happens to be JSON.
182
+ *
183
+ * @param {string} path
184
+ * @returns {PathKind}
185
+ */
186
+ export function pathKindOf(path) {
187
+ const basename = path.slice(path.lastIndexOf("/") + 1);
188
+ if (MANIFEST_BASENAMES.includes(basename) || basename.endsWith(MANIFEST_SUFFIX)) return "manifest";
189
+ if (ARCHIVED_LOG_PREFIXES.some((prefix) => path.startsWith(prefix)) || basename.endsWith(ARCHIVED_LOG_SUFFIX)) return "archived-log";
190
+ if (basename.endsWith(DOCUMENT_SUFFIX)) return "document";
191
+ if (CODE_SUFFIXES.some((suffix) => basename.endsWith(suffix))) return "code";
192
+ return "other";
193
+ }
194
+
195
+ /**
196
+ * The tracked paths the cut may not discard, whatever the byte ceiling: every
197
+ * path a requirement's own proof or measure names, every test file, and every
198
+ * module the tree references. A path a requirement names is the one piece of
199
+ * the tree the plan is already contracted to reason about; dropping it to save
200
+ * bytes would make the artefact cheap and the plan blind. References matter
201
+ * for the same reason: a module nothing names is a leaf, a module something
202
+ * reads is where a change lands.
203
+ *
204
+ * @param {string[]} allPaths every tracked path
205
+ * @param {SpecRequirement[]} requirements
206
+ * @param {Map<string, string[]>} references repo-relative references per file
207
+ * @returns {Set<string>}
208
+ */
209
+ export function requiredPaths(allPaths, requirements, references) {
210
+ const tracked = new Set(allPaths);
211
+ /** @type {Set<string>} */
212
+ const required = new Set();
213
+ for (const requirement of requirements) {
214
+ for (const proof of [requirement.proof, requirement.measure]) {
215
+ if (proof?.kind !== "path" || typeof proof.ref !== "string") continue;
216
+ if (tracked.has(proof.ref)) required.add(proof.ref);
217
+ }
218
+ }
219
+ for (const path of allPaths) {
220
+ if (path.startsWith("test/") && path.endsWith(".test.mjs")) required.add(path);
221
+ }
222
+ for (const referenced of references.values()) {
223
+ for (const path of referenced) if (tracked.has(path)) required.add(path);
224
+ }
225
+ return required;
226
+ }
227
+
228
+ /**
229
+ * The byte-limited cut over the tracked paths. Every required path is kept
230
+ * whatever the ceiling; the rest are kept in sorted order while the path bytes
231
+ * fit. Each discarded path is counted once under the one kind `pathKindOf`
232
+ * gives it, so the per-kind counts and bytes sum to the omitted total by
233
+ * construction — no path counted twice, none uncounted, and a known category
234
+ * never reported as a generic `other`.
235
+ *
236
+ * The ceiling bounds only what nothing requires, so the kept list can exceed
237
+ * `maxPathBytes` on a repository whose requirements name more than the budget
238
+ * holds. That is the intended direction: an artefact cheap and blind is the
239
+ * outcome this rule exists to prevent, and `omitted` says by how much the rest
240
+ * lost.
241
+ *
242
+ * @param {string[]} allPaths every tracked path, sorted
243
+ * @param {Set<string>} required the paths `requiredPaths` selected
244
+ * @param {number} maxPathBytes
245
+ * @returns {{paths: string[], omitted: PathOmission}}
246
+ */
247
+ export function cutPaths(allPaths, required, maxPathBytes) {
248
+ const byKind = /** @type {Record<PathKind, PathKindOmission>} */ (Object.fromEntries(PATH_KINDS.map((kind) => [kind, { paths: 0, bytes: 0 }])));
249
+ /** @type {string[]} */
250
+ const kept = [];
251
+ let optionalBytes = 0;
252
+ for (const path of allPaths) {
253
+ if (required.has(path)) {
254
+ kept.push(path);
255
+ continue;
256
+ }
257
+ const bytes = pathBytes(path);
258
+ if (optionalBytes + bytes > maxPathBytes) {
259
+ const kind = pathKindOf(path);
260
+ byKind[kind].paths += 1;
261
+ byKind[kind].bytes += bytes;
262
+ continue;
263
+ }
264
+ optionalBytes += bytes;
265
+ kept.push(path);
266
+ }
267
+ const kinds = PATH_KINDS.map((kind) => byKind[kind]);
268
+ return {
269
+ paths: [...kept].sort(),
270
+ omitted: {
271
+ paths: kinds.reduce((sum, entry) => sum + entry.paths, 0),
272
+ bytes: kinds.reduce((sum, entry) => sum + entry.bytes, 0),
273
+ byKind,
274
+ },
275
+ };
276
+ }
277
+
278
+ /** The lockfile the Node candidates' dependencies resolve through. */
279
+ const LOCKFILE = "package-lock.json";
280
+
281
+ /**
282
+ * The Node inputs one collection measures under: the declared scripts, and
283
+ * the digests of the two files that qualify them — the manifest the commands
284
+ * and their dependencies are declared in, and the lockfile that resolved
285
+ * those dependencies. Either digest is null when its file is absent; the
286
+ * manifest's absence is also what makes the candidate list empty.
287
+ *
288
+ * @param {string} cwd
289
+ * @returns {{scripts: Record<string, string>, manifestDigest: string|null, lockfileDigest: string|null}}
290
+ */
291
+ function readNodeInputs(cwd) {
292
+ const manifestPath = join(cwd, PACKAGE_MANIFEST);
293
+ if (!existsSync(manifestPath)) return { scripts: {}, manifestDigest: null, lockfileDigest: null };
294
+ const parsed = JSON.parse(readFileSync(manifestPath, "utf8"));
295
+ const scripts = parsed.scripts && typeof parsed.scripts === "object" ? parsed.scripts : {};
296
+ const lockPath = join(cwd, LOCKFILE);
297
+ return {
298
+ scripts,
299
+ manifestDigest: contentDigest(readFileSync(manifestPath, "utf8")),
300
+ lockfileDigest: existsSync(lockPath) ? contentDigest(readFileSync(lockPath, "utf8")) : null,
301
+ };
79
302
  }
80
303
 
81
304
  /**
@@ -201,39 +424,54 @@ function manifestCandidateCommands(cwd) {
201
424
  /**
202
425
  * How a caller watches this stage work. `commands` arrives once, naming every
203
426
  * command about to be timed; `measured` arrives once per command as it
204
- * finishes. Neither reaches the returned facts: two calls at the same HEAD
205
- * stay byte-identical because no event is written into them.
427
+ * finishes; `reused` arrives once per command answered from a previous
428
+ * record, when that record is consulted — before any timing begins — so every
429
+ * command the `commands` list announced is accounted for exactly once,
430
+ * whether or not its duration is paid for again. No event reaches the
431
+ * returned facts: two calls at the same HEAD stay byte-identical because no
432
+ * event is written into them.
206
433
  *
207
434
  * Measured 2026-09-25/26 (AP3): this stage took five to eight minutes and said
208
435
  * nothing while it did, so `faberun plan` was indistinguishable from a hang
209
436
  * and the operator checked the process by hand on six relaunches.
210
437
  *
211
- * @typedef {(event: {kind: "commands", commands: string[][]} | {kind: "measured", argv: string[], measuredMs: number, index: number, total: number}) => void} RepoFactsProgress
438
+ * @typedef {(event: {kind: "commands", commands: string[][]} | {kind: "measured", argv: string[], measuredMs: number|null, index: number, total: number} | {kind: "reused", argv: string[], measuredMs: number, index: number, total: number}) => void} RepoFactsProgress
212
439
  */
213
440
 
214
441
  /**
215
- * Time every candidate through `timeVerificationCommands`'s own probe —
216
- * real `spawnSync` and `Date.now` by default, or the caller's fake — instead
217
- * of re-implementing the spawn, ceiling and ENOENT handling it already owns.
218
- * That function calls `now()` exactly twice per command, in order (start,
219
- * then stop); wrapping it to record every mark it produces is how the real
220
- * elapsed ms is recovered without parsing its human-readable report.
442
+ * Time the candidates no recorded measurement may answer through
443
+ * `timeVerificationCommands`'s own probe — real `spawnSync` and `Date.now` by
444
+ * default, or the caller's fake — instead of re-implementing the spawn,
445
+ * ceiling and ENOENT handling it already owns. That function calls `now()`
446
+ * exactly twice per command, in order (start, then stop); wrapping it to
447
+ * record every mark it produces is how the real elapsed ms is recovered
448
+ * without parsing its human-readable report.
449
+ *
450
+ * `timed` carries each command with its position in the full candidate list,
451
+ * so the progress events keep numbering against every command the stage
452
+ * accounts for, the reused ones included. Eligibility is strict below the
453
+ * ceiling: a command still running when the timeout fires is killed at it,
454
+ * so a span at or past the ceiling is the kill, not a duration — recorded as
455
+ * `measuredMs` null with `eligible` false, it may not be planned from and may
456
+ * not be lent to a later collection, which times it fresh instead of
457
+ * promoting the kill to data.
221
458
  *
222
459
  * @param {string} cwd
223
- * @param {{argv: string[]}[]} commands
460
+ * @param {{argv: string[], index: number}[]} timed
461
+ * @param {number} total
224
462
  * @param {MeasureProbes} probes
225
463
  * @param {RepoFactsProgress} [onProgress]
226
- * @returns {{argv: string[], measuredMs: number, eligible: boolean}[]}
464
+ * @returns {{argv: string[], index: number, measuredMs: number|null, eligible: boolean}[]}
227
465
  */
228
- function measureCandidates(cwd, commands, probes, onProgress) {
229
- if (commands.length === 0) return [];
466
+ function measureCandidates(cwd, timed, total, probes, onProgress) {
467
+ if (timed.length === 0) return [];
230
468
  const now = probes.now ?? (() => Date.now());
231
469
  /** @type {number[]} */
232
470
  const marks = [];
233
471
  const contract = /** @type {import("../contract/index.mjs").ValidatedContract} */ (/** @type {any} */ ({
234
472
  cwd,
235
- nodes: commands.map((command, index) => ({
236
- id: `repo-facts-${index}`,
473
+ nodes: timed.map((command, position) => ({
474
+ id: `repo-facts-${position}`,
237
475
  taskPacket: { verification: [{ argv: command.argv, timeoutSec: CANDIDATE_TIMEOUT_SEC }] },
238
476
  })),
239
477
  }));
@@ -248,14 +486,17 @@ function measureCandidates(cwd, commands, probes, onProgress) {
248
486
  // holds the terminal for minutes.
249
487
  const stopped = marks.length;
250
488
  if (stopped % 2 === 0 && onProgress) {
251
- onProgress({ kind: "measured", argv: commands[stopped / 2 - 1].argv, measuredMs: mark - marks[stopped - 2], index: stopped / 2, total: commands.length });
489
+ const elapsedMs = mark - marks[stopped - 2];
490
+ const finished = timed[stopped / 2 - 1];
491
+ onProgress({ kind: "measured", argv: finished.argv, measuredMs: elapsedMs >= ELIGIBLE_MS_CEILING ? null : elapsedMs, index: finished.index + 1, total });
252
492
  }
253
493
  return mark;
254
494
  },
255
495
  });
256
- return commands.map((command, index) => {
257
- const measuredMs = marks[index * 2 + 1] - marks[index * 2];
258
- return { argv: command.argv, measuredMs, eligible: measuredMs <= ELIGIBLE_MS_CEILING };
496
+ return timed.map((command, position) => {
497
+ const elapsedMs = marks[position * 2 + 1] - marks[position * 2];
498
+ const eligible = elapsedMs < ELIGIBLE_MS_CEILING;
499
+ return { argv: command.argv, index: command.index, measuredMs: eligible ? elapsedMs : null, eligible };
259
500
  });
260
501
  }
261
502
 
@@ -296,36 +537,149 @@ export function measureRequirements(cwd, requirements, probes = {}) {
296
537
  }
297
538
 
298
539
 
540
+ /**
541
+ * The timings a previous collection may lend this one, keyed by the exact
542
+ * command line, or null when it may lend nothing. Every input the recorded
543
+ * durations were taken under has to still hold — the same format version,
544
+ * the same tree (`gitHead`), the same manifest and lockfile bytes and the
545
+ * same machine (`measuredUnder`) — and per candidate the same command line,
546
+ * recorded eligible with a numeric duration. A record failing any of these
547
+ * is invalidated wholesale: nothing from it is reused, and every command it
548
+ * cannot answer is timed fresh. A timed-out entry never qualifies even on an
549
+ * otherwise valid record, because it carries no duration to lend.
550
+ *
551
+ * @param {RepoFacts|undefined} previous
552
+ * @param {string|null} head
553
+ * @param {MeasuredUnder} under
554
+ * @returns {Map<string, number>|null}
555
+ */
556
+ function reusableTimings(previous, head, under) {
557
+ if (!previous || previous.formatVersion !== FORMAT_VERSION) return null;
558
+ if (previous.gitHead !== head) return null;
559
+ const prior = previous.measuredUnder;
560
+ if (!prior
561
+ || prior.manifestDigest !== under.manifestDigest
562
+ || prior.lockfileDigest !== under.lockfileDigest
563
+ || prior.environment?.platform !== under.environment.platform
564
+ || prior.environment?.arch !== under.environment.arch
565
+ || prior.environment?.node !== under.environment.node) return null;
566
+ /** @type {Map<string, number>} */
567
+ const timings = new Map();
568
+ for (const candidate of previous.verificationCandidates) {
569
+ if (candidate.eligible !== true || typeof candidate.measuredMs !== "number") continue;
570
+ timings.set(JSON.stringify(candidate.argv), candidate.measuredMs);
571
+ }
572
+ return timings;
573
+ }
574
+
299
575
  /**
300
576
  * @param {string} cwd
301
- * @param {{measure?: MeasureProbes, maxPaths?: number, requirements?: SpecRequirement[], onProgress?: RepoFactsProgress}} [options]
577
+ * @param {{measure?: MeasureProbes, maxPathBytes?: number, requirements?: SpecRequirement[], previousFacts?: RepoFacts, onProgress?: RepoFactsProgress}} [options]
578
+ * `previousFacts` is what a previous attempt's collection recorded — its
579
+ * persisted `repo-facts.json` read back — and it is an offer, never an
580
+ * instruction: any input a recorded timing cannot prove unchanged sends
581
+ * that command back through the measurer.
302
582
  * @returns {RepoFacts}
303
583
  */
304
584
  export function collectRepoFacts(cwd, options = {}) {
305
- const maxPaths = options.maxPaths ?? DEFAULT_MAX_PATHS;
585
+ const maxPathBytes = options.maxPathBytes ?? DEFAULT_MAX_PATH_BYTES;
306
586
  const allPaths = listTrackedPaths(cwd);
307
587
  const pathSet = new Set(allPaths);
308
- const scripts = readScripts(cwd);
588
+ // One graph for both readers: the cut needs it to know what the tree
589
+ // references, `testFileEntries` to know what each test file covers.
590
+ const references = directReferenceGraph(cwd);
591
+ const head = gitHead(cwd);
592
+ const { scripts, manifestDigest, lockfileDigest } = readNodeInputs(cwd);
593
+ // The inputs every timing below is taken under, recorded beside them so a
594
+ // later collection can check them before lending from the record.
595
+ const measuredUnder = {
596
+ manifestDigest,
597
+ lockfileDigest,
598
+ environment: { platform: process.platform, arch: process.arch, node: process.version },
599
+ };
309
600
  const candidates = nodeCandidateCommands(scripts, allPaths);
310
601
  options.onProgress?.({ kind: "commands", commands: candidates.map((command) => command.argv) });
311
- const measured = measureCandidates(cwd, candidates, options.measure ?? {}, options.onProgress);
602
+ const reusable = reusableTimings(options.previousFacts, head, measuredUnder);
603
+ /** @type {VerificationCandidate[]} */
604
+ const measured = new Array(candidates.length);
605
+ /** @type {{argv: string[], index: number}[]} */
606
+ const timed = [];
607
+ candidates.forEach((candidate, index) => {
608
+ const recorded = reusable?.get(JSON.stringify(candidate.argv));
609
+ if (typeof recorded === "number") {
610
+ measured[index] = { argv: candidate.argv, manifest: PACKAGE_MANIFEST, measuredMs: recorded, eligible: true };
611
+ options.onProgress?.({ kind: "reused", argv: candidate.argv, measuredMs: recorded, index: index + 1, total: candidates.length });
612
+ return;
613
+ }
614
+ timed.push({ argv: candidate.argv, index });
615
+ });
616
+ for (const result of measureCandidates(cwd, timed, candidates.length, options.measure ?? {}, options.onProgress)) {
617
+ measured[result.index] = { argv: result.argv, manifest: PACKAGE_MANIFEST, measuredMs: result.measuredMs, eligible: result.eligible };
618
+ }
312
619
  const detected = manifestCandidateCommands(cwd);
313
- const truncated = allPaths.length > maxPaths;
620
+ const requirements = options.requirements ?? [];
621
+ const cut = cutPaths(allPaths, requiredPaths(allPaths, requirements, references), maxPathBytes);
314
622
  return {
315
623
  formatVersion: FORMAT_VERSION,
316
- gitHead: gitHead(cwd),
317
- paths: truncated ? allPaths.slice(0, maxPaths) : allPaths,
318
- truncated,
624
+ gitHead: head,
625
+ measuredUnder,
626
+ paths: cut.paths,
627
+ pathOmission: cut.omitted,
319
628
  scripts,
320
- // The measured Node candidates first, in their timed order, each naming
321
- // package.json as the manifest it came from; then the manifest-only
322
- // candidates in a fixed ecosystem order, each naming the manifest it was
323
- // read from. Only the measured candidates carry a numeric `measuredMs`:
324
- // a detected command is never run, so it lands in its own array with the
325
- // null "no measurement" sentinel freeze.mjs reads and eligibility true.
326
- verificationCandidates: measured.map((candidate) => ({ ...candidate, manifest: PACKAGE_MANIFEST })),
629
+ // The measured Node candidates first, each naming package.json as the
630
+ // manifest it came from; then the manifest-only candidates in a fixed
631
+ // ecosystem order, each naming the manifest it was read from. A measured
632
+ // candidate carries a duration only while it is eligible: one killed at
633
+ // the timeout persists measuredMs null with eligible false — the kill is
634
+ // the evidence it never completed — and no reader, reuse included, may
635
+ // treat anything about it as how long the command takes.
636
+ verificationCandidates: measured,
327
637
  detectedVerificationCandidates: detected.map((candidate) => ({ ...candidate, measuredMs: null, eligible: true })),
328
- testFiles: testFileEntries(allPaths, pathSet, directReferenceGraph(cwd)),
329
- requirementMeasurements: measureRequirements(cwd, options.requirements ?? [], options.measure ?? {}),
638
+ testFiles: testFileEntries(allPaths, pathSet, references),
639
+ requirementMeasurements: measureRequirements(cwd, requirements, options.measure ?? {}),
330
640
  };
331
641
  }
642
+
643
+ /** The file name the cut artefact is persisted under, both beside the staged
644
+ * planning inputs and durably beside a frozen plan. */
645
+ export const REPO_FACTS_FILE = "repo-facts.json";
646
+ /** The file name the complete path index is staged under, beside that artefact. */
647
+ export const PATH_INDEX_FILE = "repo-paths.txt";
648
+
649
+ /**
650
+ * Write the complete tracked-path index — every path `git ls-files` reports,
651
+ * one per line — beside the cut artefact. The cut is what a planning stage
652
+ * reads; this file is what an authorised discovery packet reads when it must
653
+ * reach a path the cut discarded, which is why it is a separate file and never
654
+ * a field of the artefact itself: the artefact stays byte-bounded, and the
655
+ * whole tree stays reachable.
656
+ *
657
+ * @param {string} cwd
658
+ * @param {string} indexPath
659
+ * @returns {string[]} the indexed paths, in the order written
660
+ */
661
+ export function writePathIndex(cwd, indexPath) {
662
+ const paths = listTrackedPaths(cwd);
663
+ writeFileSync(indexPath, paths.map((path) => `${path}\n`).join(""));
664
+ return paths;
665
+ }
666
+
667
+ /**
668
+ * Persist the two files a later reader needs: the changed artefact — repo
669
+ * facts carrying the byte cut and its per-kind omission report — and the
670
+ * complete path index beside it. `pipeline.mjs` calls this into the scratch
671
+ * relay the draft and review stages read; `resolve.mjs` calls it into the
672
+ * plans directory, so a contested plan resumed without redrafting still leaves
673
+ * the facts it froze with on disk.
674
+ *
675
+ * @param {string} outDir
676
+ * @param {RepoFacts} facts
677
+ * @param {string} cwd the checkout the index is listed from
678
+ * @returns {string} the artefact path
679
+ */
680
+ export function persistRepoFacts(outDir, facts, cwd) {
681
+ const factsPath = join(outDir, REPO_FACTS_FILE);
682
+ writeFileSync(factsPath, `${JSON.stringify(facts, null, 2)}\n`);
683
+ writePathIndex(cwd, join(outDir, PATH_INDEX_FILE));
684
+ return factsPath;
685
+ }
@@ -28,6 +28,7 @@ import { highestOf, modelOf } from "./pipeline-shape.mjs";
28
28
  import { reviewerProvenanceOf } from "./reviewer.mjs";
29
29
  import { PLANNER_SESSION_ID, assembleFrozenPlan, carriesSuites, effectiveVerificationSuites, frozenContractRawOf, invalidPlanFinding, writeFrozenPlan } from "./pipeline.mjs";
30
30
  import { contestPlan } from "./contest.mjs";
31
+ import { persistRepoFacts } from "./repo-facts.mjs";
31
32
  import { parseSpec } from "./spec.mjs";
32
33
  import { RISK_TIERS } from "./template.mjs";
33
34
 
@@ -131,6 +132,12 @@ export async function resolvePlanningPipeline({ plansDir, cwd, answers }) {
131
132
  ]);
132
133
  }
133
134
  logStage("resolve", { round, answered: criticalFindings.length });
135
+ // R4: the facts a --resolve freeze carries are the same shaped artefact a
136
+ // fresh `plan` stages — the byte cut plus its per-kind omission report — and
137
+ // they are written here, with the complete path index beside them, so a plan
138
+ // that reached freeze without redrafting still leaves on disk the inventory
139
+ // and the whole tree a later discovery packet reads.
140
+ persistRepoFacts(plansDir, repoFacts, cwd);
134
141
 
135
142
  // The plan resumed here may carry suites of its own (RM-107), so the
136
143
  // warning is read off the same effective set the freeze below will carry.