faberun 0.6.0 → 0.8.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.
@@ -0,0 +1,119 @@
1
+ /**
2
+ * `spec` argv: validate and scaffold a spec document. Both operations are
3
+ * deterministic — `src/plan/spec.mjs` invokes no model — so this file only
4
+ * owns the wire, the same split every other verb module in this directory
5
+ * uses.
6
+ */
7
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
8
+ import { resolve } from "node:path";
9
+ import { parseArgs as parseFlags } from "node:util";
10
+ import { validateSpec } from "../plan/spec.mjs";
11
+
12
+ /** @typedef {import("../plan/spec.mjs").SpecValidation} SpecValidation */
13
+
14
+ /** Flags are scoped to the operation that declares them; all others are rejected. */
15
+ /** @type {Record<string, import("node:util").ParseArgsOptionsConfig>} */
16
+ const OPERATION_OPTIONS = {
17
+ validate: { "strict-traceability": { type: "boolean" }, json: { type: "boolean" } },
18
+ scaffold: { id: { type: "string" } },
19
+ };
20
+
21
+ const SCAFFOLD_TEMPLATE = `---
22
+ id: <id>
23
+ title: "<title>"
24
+ version: 1.0.0
25
+ status: draft
26
+ date: <yyyy-mm-dd>
27
+ owner: <owner>
28
+ target: <org/repo>
29
+ baseline: <git sha>
30
+ ---
31
+
32
+ # <title>
33
+
34
+ ## Intent
35
+
36
+ <Why this work, what problem, what it unblocks.>
37
+
38
+ ## Requirements
39
+
40
+ ### R1. <title>
41
+
42
+ - **statement:** <the testable claim>
43
+ - **proof:** \`command: <shell command>\`
44
+
45
+ ## Non-goals
46
+
47
+ - <what this spec explicitly excludes>
48
+ `;
49
+
50
+ /**
51
+ * @param {string[]} args
52
+ * @returns {void}
53
+ */
54
+ export function specCli(args) {
55
+ const operation = args[0];
56
+ if (!operation || !Object.hasOwn(OPERATION_OPTIONS, operation)) return usage();
57
+ let parsed;
58
+ try {
59
+ parsed = parseFlags({ args: args.slice(1), options: OPERATION_OPTIONS[operation], allowPositionals: true, strict: true });
60
+ } catch (error) {
61
+ process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
62
+ return usage();
63
+ }
64
+ const target = parsed.positionals[0];
65
+ if (!target || parsed.positionals.length > 1) return usage();
66
+ const values = /** @type {{"strict-traceability"?: boolean, json?: boolean, id?: string}} */ (parsed.values);
67
+ if (operation === "validate") {
68
+ validateSpecFile(resolve(target), { strict: values["strict-traceability"] === true, json: values.json === true });
69
+ return;
70
+ }
71
+ try {
72
+ scaffoldSpec(resolve(target), typeof values.id === "string" ? values.id : undefined);
73
+ } catch (error) {
74
+ process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
75
+ process.exitCode = 1;
76
+ }
77
+ }
78
+
79
+ /**
80
+ * Validate a spec file and print its class, its overall verdict, and one
81
+ * line per finding. Exits `1` when the verdict is not `ok`.
82
+ *
83
+ * @param {string} path
84
+ * @param {{strict: boolean, json: boolean}} options
85
+ * @returns {SpecValidation}
86
+ */
87
+ export function validateSpecFile(path, { strict, json }) {
88
+ const result = validateSpec(readFileSync(path, "utf8"), { cwd: process.cwd(), strict });
89
+ if (json) {
90
+ process.stdout.write(`${JSON.stringify(result)}\n`);
91
+ } else {
92
+ process.stdout.write(`${result.class} · ${result.class === "legacy" ? "accepted" : result.ok ? "ok" : "not ok"}\n`);
93
+ for (const finding of result.findings) process.stdout.write(`[${finding.severity}] ${finding.rule}: ${finding.message}\n`);
94
+ }
95
+ if (!result.ok) process.exitCode = 1;
96
+ return result;
97
+ }
98
+
99
+ /**
100
+ * Write an empty document in the spec format at `path`. Refuses to overwrite
101
+ * an existing file.
102
+ *
103
+ * @param {string} path
104
+ * @param {string} [id]
105
+ * @returns {void}
106
+ */
107
+ export function scaffoldSpec(path, id) {
108
+ if (existsSync(path)) throw new Error(`refusing to overwrite an existing file: ${path}`);
109
+ writeFileSync(path, id ? SCAFFOLD_TEMPLATE.replace("<id>", id) : SCAFFOLD_TEMPLATE);
110
+ process.stdout.write(`scaffolded ${path}\n`);
111
+ }
112
+
113
+ /** @returns {void} */
114
+ function usage() {
115
+ process.stderr.write("usage: faberun spec <validate|scaffold> <path> [--strict-traceability] [--json] [--id <value>]\n");
116
+ process.exitCode = 2;
117
+ }
118
+
119
+ export default OPERATION_OPTIONS;
package/src/cli.mjs CHANGED
@@ -31,6 +31,7 @@ import { setupCommand } from "./cli/setup.mjs";
31
31
  import { skillsCli } from "./cli/skills.mjs";
32
32
  import { updateCommand } from "./cli/update.mjs";
33
33
  import { contractCli, validateContractFile } from "./cli/contract.mjs";
34
+ import { specCli } from "./cli/spec.mjs";
34
35
  import { METRICS_OPTIONS, renderCampaignMetrics } from "./campaign/metrics.mjs";
35
36
  import { runContract } from "./engine/scheduler.mjs";
36
37
  import { resumeRun } from "./engine/resume.mjs";
@@ -87,7 +88,7 @@ export function hasDetachedBootstrapNonce() {
87
88
  }
88
89
 
89
90
  /** @type {Record<string, import("node:util").ParseArgsOptionsConfig>} */
90
- const COMMAND_OPTIONS = {
91
+ export const COMMAND_OPTIONS = {
91
92
  run: { detach: { type: "boolean" }, "base-ref": { type: "string" } },
92
93
  resume: { detach: { type: "boolean" }, node: { type: "string" }, reconcile: { type: "string" }, answer: { type: "string" } },
93
94
  supervise: { detach: { type: "boolean" }, interval: { type: "string" } },
@@ -209,6 +210,7 @@ async function main(argv) {
209
210
  if (argv[0] === "seat") { seatCli(argv.slice(1)); return; }
210
211
  if (argv[0] === "skills") { skillsCli(argv.slice(1)); return; }
211
212
  if (argv[0] === "contract") { contractCli(argv.slice(1)); return; }
213
+ if (argv[0] === "spec") { specCli(argv.slice(1)); return; }
212
214
  const parsed = parseCli(argv);
213
215
  if (!parsed) { usage(); return; }
214
216
  const { command, values } = parsed;
@@ -79,7 +79,7 @@ const GATE_REVIEWS = new Set(["none", "advisory", "blocking"]);
79
79
  /** @typedef {{history: RoutingHistoryEntry[], currentOverride: RoutingOverride|null, assignments?: RuntimeAssignments, availability?: Record<string, RuntimeAvailability>, tierExhaustion?: TierExhaustion, tierExhaustionCycle?: number}} RoutingState */
80
80
  /** @typedef {{revision?: number, heartbeatCount: number, dryHeartbeatCount: number, progressSignature?: string|null, lastHeartbeatAt: string|null, lastProgressAt: string|null, nextCheckAt?: string|null}} ProgressState */
81
81
  /** @typedef {{status: "unassigned"|"provisioning"|"ready"|"failed"|"removed", path: string|null, branch: string|null, commit: string|null, baseSha?: string|null, sealedSha?: string|null, sealError?: string|null, previousAttempt?: number|null}} WorktreeState */
82
- /** @typedef {{schemaVersion: number, contractVersion: string, id: string, type: string, sourceIdentity: SourceIdentity, packetHash: string, status: NodeStatus, phase: NodePhase, attempt: number, revisions: number, judgeFailures?: number, review?: ("none"|"advisory"|"blocking"), runtime: RuntimeSnapshot|null, blockedBy: string[], startedAt: string|null, updatedAt: string, result: unknown, gate: GateResult|null, error: SnapshotError|null, usage?: Usage, costUsd?: number, routing?: RoutingState|null, progress?: ProgressState|null, worktree?: WorktreeState|null, integratedHead?: string|null, invocations?: Invocation[], executionOverrides?: ExecutionOverride[], verification?: VerificationState|null, scope?: BoundedScope|null, scopeFindings?: ScopeFindings|null, previousAttempt?: string, sessionPolicy?: {forceFresh?: boolean}|null}} NodeSnapshot */
82
+ /** @typedef {{schemaVersion: number, contractVersion: string, id: string, type: string, sourceIdentity: SourceIdentity, packetHash: string, status: NodeStatus, phase: NodePhase, attempt: number, revisions: number, judgeFailures?: number, review?: ("none"|"advisory"|"blocking"), runtime: RuntimeSnapshot|null, blockedBy: string[], startedAt: string|null, updatedAt: string, result: unknown, gate: GateResult|null, error: SnapshotError|null, usage?: Usage, costUsd?: number, routing?: RoutingState|null, progress?: ProgressState|null, worktree?: WorktreeState|null, integratedHead?: string|null, invocations?: Invocation[], executionOverrides?: ExecutionOverride[], verification?: VerificationState|null, scope?: BoundedScope|null, scopeFindings?: ScopeFindings|null, previousAttempt?: string, sessionPolicy?: {forceFresh?: boolean}|null, declaredReadBytes?: number|null}} NodeSnapshot */
83
83
  /** @typedef {{path: string, sha: string}} ControllerIdentity */
84
84
  /** @typedef {{schemaVersion: number, contractVersion: string, pid: number, processStartToken: string|null, startedAt: string, sourceIdentity: SourceIdentity, controllerIdentity?: ControllerIdentity, integrationRef?: string, identityWarnings?: string[], relaunchCount?: number, lastRelaunchProgressAt?: string|null, attention?: {code: string, message: string, at: string}|null, contractDigest?: string, scopeDecision?: ScopeDecision, autoRetries?: Record<string, {code: string, at: string}>}} RunMetadata */
85
85
  /** @typedef {{at: string, base: string|null, dirtyTreeFingerprint: string|null}} ScopeDecision */
@@ -129,7 +129,7 @@ export function validateNodeSnapshot(value, expectedNode = null) {
129
129
  "schemaVersion", "contractVersion", "id", "type", "sourceIdentity", "packetHash", "status", "phase",
130
130
  "attempt", "revisions", "judgeFailures", "runtime", "blockedBy", "startedAt", "updatedAt", "result", "gate", "error", "usage",
131
131
  "costUsd", "routing", "progress", "worktree", "invocations", "executionOverrides", "verification", "scope",
132
- "scopeFindings", "review", "previousAttempt", "sessionPolicy", "integratedHead",
132
+ "scopeFindings", "review", "previousAttempt", "sessionPolicy", "integratedHead", "declaredReadBytes",
133
133
  ]), "node snapshot");
134
134
  validateMetadata(value, "node snapshot");
135
135
  requireId(value.id, "node snapshot.id");
@@ -166,6 +166,12 @@ export function validateNodeSnapshot(value, expectedNode = null) {
166
166
  validateSnapshotError(value.error, "node snapshot.error");
167
167
  if (value.usage !== undefined) validateUsage(value.usage, "node snapshot.usage");
168
168
  if (value.costUsd !== undefined) nonNegativeNumber(value.costUsd, "node snapshot.costUsd");
169
+ // The summed byte size of the node's declared readFiles in the attempt
170
+ // worktree at dispatch time -- the one quantity the controller can measure
171
+ // about a packet's reference load, since the worker reads the files itself.
172
+ if (value.declaredReadBytes !== undefined && value.declaredReadBytes !== null) {
173
+ nonNegativeInteger(value.declaredReadBytes, "node snapshot.declaredReadBytes");
174
+ }
169
175
  if (value.routing !== undefined && value.routing !== null) validateRoutingState(value.routing, "node snapshot.routing");
170
176
  if (value.progress !== undefined && value.progress !== null) validateProgressState(value.progress, "node snapshot.progress");
171
177
  if (value.worktree !== undefined && value.worktree !== null) validateWorktreeState(value.worktree, "node snapshot.worktree");
@@ -32,7 +32,7 @@ import { emptyScope, persistedScopeBoundary, workerScope } from "./scope.mjs";
32
32
  import { hasOperationIntent, hasOperationSettlement, operationNeedsRecovery, operationNextState, persistInvocationIntent, providerReceipts, settleInvocation } from "../run/operations.mjs";
33
33
  import { invocationCost, invocationUsage } from "../run/usage.mjs";
34
34
  import { logPaths, readBoundedTail, startProcess } from "./process.mjs";
35
- import { mkdirSync } from "node:fs";
35
+ import { mkdirSync, statSync } from "node:fs";
36
36
  import { READ_LINE_LIMIT, normalizeProviderResult, providerCommand } from "../harnesses/index.mjs";
37
37
  import { readJson, writeJsonAtomic } from "../run/store.mjs";
38
38
  import { judgeReaskInstruction, reviewMode } from "../contract/review-modes.mjs";
@@ -267,6 +267,30 @@ function phaseHandoffPrompt(contract, node, state, runDir, role) {
267
267
  ].join("\n\n");
268
268
  return boundedUtf8(handoff, 60 * 1024);
269
269
  }
270
+ /**
271
+ * The declared weight of a node's readFiles at dispatch time: the sum of the
272
+ * byte sizes of the files that exist in the attempt workspace. This is the
273
+ * one quantity the controller can measure about a packet's reference load --
274
+ * the worker prompt lists readFiles and the worker reads them itself, so what
275
+ * it actually reads is the harness's business. A missing file counts 0 rather
276
+ * than throwing: a declared path can be produced by a dependency that has not
277
+ * run yet or removed by the tree since the packet was authored.
278
+ *
279
+ * @param {string[]} readFiles
280
+ * @param {string} workspace
281
+ * @returns {number}
282
+ */
283
+ export function declaredReadBytes(readFiles, workspace) {
284
+ let total = 0;
285
+ for (const path of readFiles) {
286
+ try {
287
+ total += statSync(join(workspace, path)).size;
288
+ } catch {
289
+ // Missing or unreadable file: contributes no weight.
290
+ }
291
+ }
292
+ return total;
293
+ }
270
294
  /**
271
295
  * The mechanical worker tool policy for the provider boundary: hook settings
272
296
  * on Claude-compatible commands. Only an adapter whose surface can prove
@@ -431,6 +455,7 @@ export function startWorker(contract, node, state, runDir, running, prompt, lock
431
455
  writeJsonAtomic(snapshotPath, baseline);
432
456
  state.phase = "worker";
433
457
  state.runtime = runtime;
458
+ state.declaredReadBytes = declaredReadBytes(node.taskPacket.readFiles ?? [], workspace);
434
459
  // A new worker attempt has no accepted result yet. The canonical result
435
460
  // file is cleared when the previous attempt was explicitly rejected (failed
436
461
  // gate verdict), when no valid canonical file exists, or when the stale file
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Freezing a plan: the boundary between a session's draft and a contract the
3
+ * engine can execute. `freezePlan` writes the plan's nodes as a validated
4
+ * contract.json, plus a plan.json carrying that contract's digest and the
5
+ * full provenance of how it was produced — the two files travel together so
6
+ * a later launch and this record agree on exactly what was reviewed.
7
+ * `verifyFrozenPlan` is the one check that the pair still agree.
8
+ *
9
+ * Nothing here invokes a model or the engine; it only writes and hashes
10
+ * bytes, so freezing a plan can never be mistaken for starting a run.
11
+ */
12
+ import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
13
+ import { join } from "node:path";
14
+ import { fileURLToPath } from "node:url";
15
+ import { CONTRACT_VERSION, PROTOCOL_SCHEMA_VERSION, contractDigest, validateContract } from "../contract/index.mjs";
16
+
17
+ /** @typedef {import("../contract/index.mjs").JsonObject} JsonObject */
18
+
19
+ /** @typedef {{runtimeId: string, model: string}} PlanParticipant */
20
+ /** @typedef {{id: string, severity: "minor"|"major"|"critical", nodeId?: string, text: string}} PlanFinding */
21
+ /** @typedef {{targetGitHead: string|null, planner: PlanParticipant, reviewer: PlanParticipant, sizing: unknown, findings: PlanFinding[]}} PlanProvenanceInput */
22
+ /** @typedef {PlanProvenanceInput & {packageVersion: string, schemaVersion: number, contractVersion: string}} PlanProvenance */
23
+ /** @typedef {{formatVersion: number, contractDigest: string, provenance: PlanProvenance}} FrozenPlan */
24
+ /** @typedef {{ok: boolean, digest: string, expectedDigest: string}} FrozenPlanVerdict */
25
+
26
+ const PLAN_FORMAT_VERSION = 1;
27
+
28
+ /** @returns {string} the installed package's own version, read once per call so a freeze always names the toolchain that produced it */
29
+ function packageVersion() {
30
+ const packageJsonPath = fileURLToPath(new URL("../../package.json", import.meta.url));
31
+ return JSON.parse(readFileSync(packageJsonPath, "utf8")).version;
32
+ }
33
+
34
+ /**
35
+ * Validate `plan` as a contract and, only once it is valid, write it and a
36
+ * sibling plan.json naming its digest and provenance. `plan` supplies
37
+ * `schemaVersion`/`contractVersion` itself; when it does not, this fills in
38
+ * the runner's own current values.
39
+ *
40
+ * contract.json is written before validation runs, because a packet may
41
+ * declare `readFiles: ["contract.json"]` — an execution packet's own file,
42
+ * self-referenced the same way every fixture in this codebase already does.
43
+ * A validation failure removes that file again, so a caller never observes a
44
+ * contract.json that failed its own check.
45
+ *
46
+ * @param {JsonObject} plan
47
+ * @param {{outDir: string, provenance: PlanProvenanceInput}} options
48
+ * @returns {FrozenPlan}
49
+ */
50
+ export function freezePlan(plan, { outDir, provenance }) {
51
+ mkdirSync(outDir, { recursive: true });
52
+ const contractPath = join(outDir, "contract.json");
53
+ const raw = /** @type {JsonObject} */ ({
54
+ schemaVersion: PROTOCOL_SCHEMA_VERSION,
55
+ contractVersion: CONTRACT_VERSION,
56
+ ...plan,
57
+ });
58
+ writeFileSync(contractPath, `${JSON.stringify(raw, null, 2)}\n`);
59
+ try {
60
+ validateContract(raw, contractPath);
61
+ } catch (error) {
62
+ rmSync(contractPath, { force: true });
63
+ throw error;
64
+ }
65
+ const frozen = /** @type {FrozenPlan} */ ({
66
+ formatVersion: PLAN_FORMAT_VERSION,
67
+ contractDigest: contractDigest(raw),
68
+ provenance: {
69
+ packageVersion: packageVersion(),
70
+ schemaVersion: /** @type {number} */ (raw.schemaVersion),
71
+ contractVersion: /** @type {string} */ (raw.contractVersion),
72
+ targetGitHead: provenance.targetGitHead,
73
+ planner: provenance.planner,
74
+ reviewer: provenance.reviewer,
75
+ sizing: provenance.sizing,
76
+ findings: provenance.findings,
77
+ },
78
+ });
79
+ writeFileSync(join(outDir, "plan.json"), `${JSON.stringify(frozen, null, 2)}\n`);
80
+ return frozen;
81
+ }
82
+
83
+ /**
84
+ * Recompute contract.json's digest from the bytes on disk and compare it
85
+ * with the digest plan.json recorded at freeze time. A single byte changed
86
+ * in either file — the contract re-authored after review, or the plan
87
+ * record itself tampered with — is a mismatch.
88
+ *
89
+ * @param {string} outDir
90
+ * @returns {FrozenPlanVerdict}
91
+ */
92
+ export function verifyFrozenPlan(outDir) {
93
+ const raw = JSON.parse(readFileSync(join(outDir, "contract.json"), "utf8"));
94
+ const plan = JSON.parse(readFileSync(join(outDir, "plan.json"), "utf8"));
95
+ const digest = contractDigest(raw);
96
+ return { ok: digest === plan.contractDigest, digest, expectedDigest: plan.contractDigest };
97
+ }
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Repo facts: a deterministic, bounded inventory of the target repository —
3
+ * tracked paths, declared scripts, timed verification candidates, and which
4
+ * test file covers which source module — collected without invoking a model.
5
+ * A planning stage's draft is authored against exactly this JSON instead of
6
+ * the session reading the repository by hand.
7
+ *
8
+ * Sorting and the absence of any clock in the output itself (only inside an
9
+ * injected measurer's own numbers) is what makes two calls at the same HEAD
10
+ * byte-identical.
11
+ */
12
+ import { existsSync, readFileSync } from "node:fs";
13
+ import { join } from "node:path";
14
+ import { timeVerificationCommands } from "../host/preflight.mjs";
15
+ import { boundedGitSync, gitHead } from "../repo/worktree.mjs";
16
+
17
+ /** @typedef {{argv: string[], measuredMs: number, eligible: boolean}} VerificationCandidate */
18
+ /** @typedef {{path: string, covers: string|null}} TestFileEntry */
19
+ /** @typedef {{formatVersion: number, gitHead: string|null, paths: string[], truncated: boolean, scripts: Record<string, string>, verificationCandidates: VerificationCandidate[], testFiles: TestFileEntry[]}} RepoFacts */
20
+ /** @typedef {{now?: () => number, run?: typeof import("node:child_process").spawnSync}} MeasureProbes */
21
+
22
+ const FORMAT_VERSION = 1;
23
+ const DEFAULT_MAX_PATHS = 2000;
24
+ const ELIGIBLE_MS_CEILING = 600_000;
25
+ const CANDIDATE_TIMEOUT_SEC = ELIGIBLE_MS_CEILING / 1_000;
26
+
27
+ /**
28
+ * Every path git tracks at HEAD, sorted. The bounded spawn is the same
29
+ * pattern `src/repo/source-identity.mjs` uses for its own git reads: a
30
+ * `boundedGitSync` call, thrown on a non-zero exit or a killed process,
31
+ * never a raw `spawnSync`.
32
+ *
33
+ * @param {string} cwd
34
+ * @returns {string[]}
35
+ */
36
+ function listTrackedPaths(cwd) {
37
+ const result = boundedGitSync(["-C", cwd, "ls-files"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
38
+ if (result.error || result.status !== 0) throw result.error ?? new Error(`git ls-files exited ${result.status}`);
39
+ return String(result.stdout).split("\n").filter(Boolean).sort();
40
+ }
41
+
42
+ /** @param {string} cwd @returns {Record<string, string>} */
43
+ function readScripts(cwd) {
44
+ const packagePath = join(cwd, "package.json");
45
+ if (!existsSync(packagePath)) return {};
46
+ const parsed = JSON.parse(readFileSync(packagePath, "utf8"));
47
+ return parsed.scripts && typeof parsed.scripts === "object" ? parsed.scripts : {};
48
+ }
49
+
50
+ /**
51
+ * The first-level directories under test/ that any tracked path is nested
52
+ * inside. `node --test <dir>` recurses through everything below it, so one
53
+ * candidate per directory is the whole layout, not one per file.
54
+ *
55
+ * @param {string[]} paths
56
+ * @returns {string[]}
57
+ */
58
+ function testDirectories(paths) {
59
+ /** @type {Set<string>} */
60
+ const directories = new Set();
61
+ for (const path of paths) {
62
+ const match = /^test\/([^/]+)\//u.exec(path);
63
+ if (match) directories.add(`test/${match[1]}`);
64
+ }
65
+ return [...directories].sort();
66
+ }
67
+
68
+ /**
69
+ * @param {string[]} paths
70
+ * @param {Set<string>} pathSet
71
+ * @returns {TestFileEntry[]}
72
+ */
73
+ function testFileEntries(paths, pathSet) {
74
+ return paths
75
+ .filter((path) => path.startsWith("test/") && path.endsWith(".test.mjs"))
76
+ .map((path) => {
77
+ const modulePath = `src/${path.slice("test/".length, -".test.mjs".length)}.mjs`;
78
+ return { path, covers: pathSet.has(modulePath) ? modulePath : null };
79
+ });
80
+ }
81
+
82
+ /**
83
+ * @param {Record<string, string>} scripts
84
+ * @param {string[]} paths
85
+ * @returns {{argv: string[]}[]}
86
+ */
87
+ function candidateCommands(scripts, paths) {
88
+ const commands = testDirectories(paths).map((directory) => ({ argv: ["node", "--test", directory] }));
89
+ for (const name of ["check", "typecheck"]) {
90
+ if (typeof scripts[name] === "string") commands.push({ argv: ["npm", "run", name] });
91
+ }
92
+ return commands;
93
+ }
94
+
95
+ /**
96
+ * Time every candidate through `timeVerificationCommands`'s own probe —
97
+ * real `spawnSync` and `Date.now` by default, or the caller's fake — instead
98
+ * of re-implementing the spawn, ceiling and ENOENT handling it already owns.
99
+ * That function calls `now()` exactly twice per command, in order (start,
100
+ * then stop); wrapping it to record every mark it produces is how the real
101
+ * elapsed ms is recovered without parsing its human-readable report.
102
+ *
103
+ * @param {string} cwd
104
+ * @param {{argv: string[]}[]} commands
105
+ * @param {MeasureProbes} probes
106
+ * @returns {VerificationCandidate[]}
107
+ */
108
+ function measureCandidates(cwd, commands, probes) {
109
+ if (commands.length === 0) return [];
110
+ const now = probes.now ?? (() => Date.now());
111
+ /** @type {number[]} */
112
+ const marks = [];
113
+ const contract = /** @type {import("../contract/index.mjs").ValidatedContract} */ (/** @type {any} */ ({
114
+ cwd,
115
+ nodes: commands.map((command, index) => ({
116
+ id: `repo-facts-${index}`,
117
+ taskPacket: { verification: [{ argv: command.argv, timeoutSec: CANDIDATE_TIMEOUT_SEC }] },
118
+ })),
119
+ }));
120
+ timeVerificationCommands(contract, { ...probes, now: () => { const mark = now(); marks.push(mark); return mark; } });
121
+ return commands.map((command, index) => {
122
+ const measuredMs = marks[index * 2 + 1] - marks[index * 2];
123
+ return { argv: command.argv, measuredMs, eligible: measuredMs <= ELIGIBLE_MS_CEILING };
124
+ });
125
+ }
126
+
127
+ /**
128
+ * @param {string} cwd
129
+ * @param {{measure?: MeasureProbes, maxPaths?: number}} [options]
130
+ * @returns {RepoFacts}
131
+ */
132
+ export function collectRepoFacts(cwd, options = {}) {
133
+ const maxPaths = options.maxPaths ?? DEFAULT_MAX_PATHS;
134
+ const allPaths = listTrackedPaths(cwd);
135
+ const pathSet = new Set(allPaths);
136
+ const scripts = readScripts(cwd);
137
+ const commands = candidateCommands(scripts, allPaths);
138
+ const truncated = allPaths.length > maxPaths;
139
+ return {
140
+ formatVersion: FORMAT_VERSION,
141
+ gitHead: gitHead(cwd),
142
+ paths: truncated ? allPaths.slice(0, maxPaths) : allPaths,
143
+ truncated,
144
+ scripts,
145
+ verificationCandidates: measureCandidates(cwd, commands, options.measure ?? {}),
146
+ testFiles: testFileEntries(allPaths, pathSet),
147
+ };
148
+ }
@@ -0,0 +1,200 @@
1
+ /**
2
+ * Declarative runtime routing: cross a taskKind/riskTier table with live
3
+ * discovery availability to assign a worker and a judge runtime to each of a
4
+ * plan's draft nodes. Separate from runtime-discovery.mjs because that module
5
+ * resolves a *contract's* already-declared runtimes; a plan's draft node
6
+ * never names one (the constitution reserves model choice to runtimes,
7
+ * runtimeDefaults, or an explicit node override) — this module is what turns
8
+ * a taskKind/riskTier classification into one of those three.
9
+ */
10
+
11
+ import { cheapest, strongest } from "../engine/runtime-discovery.mjs";
12
+
13
+ /** @typedef {{available: boolean, exhaustedUntil: string|null, [key: string]: unknown}} RoutingAvailability */
14
+ /** @typedef {{vendor: string, tier?: number|string, costRank?: number, fallback?: string, [key: string]: unknown}} RoutingRuntime */
15
+ /** @typedef {{id: string, taskKind?: string, riskTier?: string}} RoutingNode */
16
+ /** @typedef {{taskKind?: string, riskTier?: string}} RoutingWhen */
17
+ /** @typedef {{name?: string, when: RoutingWhen, prefer: string[], role: "worker"|"judge"}} RoutingRule */
18
+ /** @typedef {{worker?: string, judge?: string}} RoutingRoleMap */
19
+ /** @typedef {{table?: RoutingRule[], runtimes: Record<string, RoutingRuntime>, availability?: Record<string, RoutingAvailability>, runtimeDefaults?: RoutingRoleMap, overrides?: Record<string, RoutingRoleMap>}} RoutingConfig */
20
+ /** @typedef {{worker: string|null, judge: string|null, rule: {worker: string, judge: string}}} RoutingAssignment */
21
+ /** @typedef {{nodeId: string, role: "worker"|"judge", rule: string}} RoutingUnmet */
22
+ /** @typedef {{assignments: Record<string, RoutingAssignment>, unmet: RoutingUnmet[]}} RoutingResult */
23
+
24
+ /**
25
+ * @param {RoutingNode[]} nodes
26
+ * @param {RoutingConfig} config
27
+ * @param {{partial?: boolean}} [options]
28
+ * @returns {RoutingResult}
29
+ */
30
+ export function resolveRuntimes(nodes, config, options = {}) {
31
+ const table = config.table ?? [];
32
+ const runtimes = config.runtimes ?? {};
33
+ const availability = config.availability ?? {};
34
+ const runtimeDefaults = config.runtimeDefaults ?? {};
35
+ const overrides = config.overrides ?? {};
36
+
37
+ /** @type {Record<string, RoutingAssignment>} */
38
+ const assignments = {};
39
+ /** @type {RoutingUnmet[]} */
40
+ const unmet = [];
41
+
42
+ for (const node of nodes) {
43
+ const override = overrides[node.id] ?? {};
44
+ const worker = resolveRole(node, "worker", { runtimes, availability, runtimeDefaults, table, override, forbiddenVendors: EMPTY_VENDORS });
45
+ if (worker.runtimeId === null) unmet.push({ nodeId: node.id, role: "worker", rule: worker.rule });
46
+ // The judge's forbidden vendors follow the worker runtime that was
47
+ // actually chosen, never the row that named it — a worker unmet leaves
48
+ // nothing to conflict with, so the judge resolves without restriction.
49
+ const forbiddenVendors = worker.runtimeId ? forbiddenJudgeVendors(worker.runtimeId, runtimes) : EMPTY_VENDORS;
50
+ const judge = resolveRole(node, "judge", { runtimes, availability, runtimeDefaults, table, override, forbiddenVendors });
51
+ if (judge.runtimeId === null) unmet.push({ nodeId: node.id, role: "judge", rule: judge.rule });
52
+ assignments[node.id] = { worker: worker.runtimeId, judge: judge.runtimeId, rule: { worker: worker.rule, judge: judge.rule } };
53
+ }
54
+
55
+ if (unmet.length && options.partial !== true) {
56
+ const detail = unmet.map(({ nodeId, role, rule }) => `${nodeId}.${role} (rule: ${rule})`).join("; ");
57
+ throw new Error(`runtime_routing_unmet: ${detail}`);
58
+ }
59
+
60
+ return { assignments, unmet };
61
+ }
62
+
63
+ /** @type {ReadonlySet<string>} */
64
+ const EMPTY_VENDORS = Object.freeze(new Set());
65
+
66
+ /**
67
+ * Precedence for one role on one node: an explicit node override, then the
68
+ * operator's runtimeDefaults, then the first table row whose `when` matches
69
+ * this node's classification, then plain discovery. A row or default that
70
+ * names an unavailable or vendor-forbidden runtime is unmet by that rule —
71
+ * it does not fall through to a lower-precedence source, since falling
72
+ * through would silently discard an explicit declaration; only the
73
+ * candidates *within* a row's `prefer` list, and within discovery, are
74
+ * skipped for exhaustion or vendor conflict.
75
+ *
76
+ * @param {RoutingNode} node
77
+ * @param {"worker"|"judge"} role
78
+ * @param {{runtimes: Record<string, RoutingRuntime>, availability: Record<string, RoutingAvailability>, runtimeDefaults: RoutingRoleMap, table: RoutingRule[], override: RoutingRoleMap, forbiddenVendors: ReadonlySet<string>}} context
79
+ * @returns {{runtimeId: string|null, rule: string}}
80
+ */
81
+ function resolveRole(node, role, context) {
82
+ const { runtimes, availability, runtimeDefaults, table, override, forbiddenVendors } = context;
83
+
84
+ if (override[role] !== undefined) {
85
+ const id = override[role];
86
+ return { runtimeId: admits(id, runtimes, availability, forbiddenVendors) ? id : null, rule: "override" };
87
+ }
88
+
89
+ if (runtimeDefaults[role] !== undefined) {
90
+ const id = runtimeDefaults[role];
91
+ return { runtimeId: admits(id, runtimes, availability, forbiddenVendors) ? id : null, rule: "runtimeDefaults" };
92
+ }
93
+
94
+ const row = table.find((candidate) => candidate.role === role
95
+ && (candidate.when.taskKind === undefined || candidate.when.taskKind === node.taskKind)
96
+ && (candidate.when.riskTier === undefined || candidate.when.riskTier === node.riskTier));
97
+ if (row) {
98
+ const rule = ruleLabel(row);
99
+ const id = row.prefer.find((candidate) => admits(candidate, runtimes, availability, forbiddenVendors)) ?? null;
100
+ return { runtimeId: id, rule };
101
+ }
102
+
103
+ const discovered = role === "worker"
104
+ ? cheapestAvailable(runtimes, availability, forbiddenVendors)
105
+ : strongestAvailable(runtimes, availability, forbiddenVendors);
106
+ return { runtimeId: discovered, rule: "discovery" };
107
+ }
108
+
109
+ /**
110
+ * @param {string} id
111
+ * @param {Record<string, RoutingRuntime>} runtimes
112
+ * @param {Record<string, RoutingAvailability>} availability
113
+ * @param {ReadonlySet<string>} forbiddenVendors
114
+ * @returns {boolean}
115
+ */
116
+ function admits(id, runtimes, availability, forbiddenVendors) {
117
+ const runtime = runtimes[id];
118
+ if (!runtime) return false;
119
+ if (forbiddenVendors.has(runtime.vendor)) return false;
120
+ return isAvailable(availability[id]);
121
+ }
122
+
123
+ /** @param {RoutingAvailability|undefined} entry @returns {boolean} */
124
+ function isAvailable(entry) {
125
+ if (!entry) return false;
126
+ if (entry.available === true) return !entry.exhaustedUntil || Date.parse(entry.exhaustedUntil) <= Date.now();
127
+ return Boolean(entry.exhaustedUntil && Date.parse(entry.exhaustedUntil) <= Date.now());
128
+ }
129
+
130
+ /**
131
+ * Every vendor a judge may not carry: the worker's own vendor, plus the
132
+ * vendor of each runtime reachable through the worker's declared `fallback`
133
+ * chain — the same independence the contract validator enforces statically
134
+ * once a worker is actually chosen dynamically here.
135
+ *
136
+ * @param {string} workerId
137
+ * @param {Record<string, RoutingRuntime>} runtimes
138
+ * @returns {Set<string>}
139
+ */
140
+ function forbiddenJudgeVendors(workerId, runtimes) {
141
+ const vendors = new Set();
142
+ const seen = new Set();
143
+ /** @type {string|undefined} */
144
+ let id = workerId;
145
+ while (id !== undefined && runtimes[id] && !seen.has(id)) {
146
+ seen.add(id);
147
+ vendors.add(runtimes[id].vendor);
148
+ id = runtimes[id].fallback;
149
+ }
150
+ return vendors;
151
+ }
152
+
153
+ /** @param {RoutingRule} row @returns {string} */
154
+ function ruleLabel(row) {
155
+ if (row.name) return row.name;
156
+ return `table:${row.role}:${row.when.taskKind ?? "*"}:${row.when.riskTier ?? "*"}`;
157
+ }
158
+
159
+ /**
160
+ * @param {Record<string, RoutingRuntime>} runtimes
161
+ * @param {Record<string, RoutingAvailability>} availability
162
+ * @param {ReadonlySet<string>} forbiddenVendors
163
+ * @returns {{id: string, runtime: RoutingRuntime, order: number}[]}
164
+ */
165
+ function candidateEntries(runtimes, availability, forbiddenVendors) {
166
+ return Object.entries(runtimes)
167
+ .map(([id, runtime], order) => ({ id, runtime, order }))
168
+ .filter(({ id, runtime }) => !forbiddenVendors.has(runtime.vendor) && isAvailable(availability[id]));
169
+ }
170
+
171
+ /**
172
+ * The plain discovery default for a worker: `runtime-discovery.mjs`'s own
173
+ * cheapest-first ranking, over candidates already filtered to what's
174
+ * available and vendor-permitted here. The ranking lives there, not here, so
175
+ * a contract's default and a plan's routed default never drift apart.
176
+ *
177
+ * @param {Record<string, RoutingRuntime>} runtimes
178
+ * @param {Record<string, RoutingAvailability>} availability
179
+ * @param {ReadonlySet<string>} forbiddenVendors
180
+ * @returns {string|null}
181
+ */
182
+ function cheapestAvailable(runtimes, availability, forbiddenVendors) {
183
+ return cheapest(candidateEntries(runtimes, availability, forbiddenVendors))?.id ?? null;
184
+ }
185
+
186
+ /**
187
+ * The plain discovery default for a judge: `runtime-discovery.mjs`'s own
188
+ * strongest-first ranking, over candidates already filtered to exclude the
189
+ * worker's vendor and fallback-chain vendors — `strongest`'s own single-vendor
190
+ * exclusion is passed the empty string, no runtime's actual vendor label, so
191
+ * it is a no-op on top of the filtering already done here.
192
+ *
193
+ * @param {Record<string, RoutingRuntime>} runtimes
194
+ * @param {Record<string, RoutingAvailability>} availability
195
+ * @param {ReadonlySet<string>} forbiddenVendors
196
+ * @returns {string|null}
197
+ */
198
+ function strongestAvailable(runtimes, availability, forbiddenVendors) {
199
+ return strongest(candidateEntries(runtimes, availability, forbiddenVendors), "")?.id ?? null;
200
+ }