faberun 0.7.0 → 0.9.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/package.json +1 -1
- package/skills/faberun/SKILL.md +1 -0
- package/skills/faberun/references/spec-format.md +87 -0
- package/src/campaign/index.mjs +46 -3
- package/src/campaign/journal.mjs +48 -0
- package/src/cli/brand.mjs +3 -0
- package/src/cli/campaign.mjs +16 -2
- package/src/cli/manual.mjs +3 -1
- package/src/cli/plan.mjs +142 -0
- package/src/cli/spec.mjs +119 -0
- package/src/cli.mjs +17 -0
- package/src/contract/task-packet.mjs +1 -1
- package/src/contract/worker-result.mjs +40 -8
- package/src/engine/lifecycle.mjs +11 -0
- package/src/engine/result-file.mjs +18 -4
- package/src/harnesses/protocol.mjs +91 -11
- package/src/plan/freeze.mjs +101 -0
- package/src/plan/pipeline.mjs +371 -0
- package/src/plan/repo-facts.mjs +148 -0
- package/src/plan/routing.mjs +200 -0
- package/src/plan/sizing.mjs +0 -0
- package/src/plan/spec.mjs +320 -0
- package/src/plan/template.mjs +278 -0
- package/src/seat/allowance.mjs +177 -0
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The planning pipeline: `faberun plan` as successive ordinary runs (draft,
|
|
3
|
+
* review, revise up to a round budget) followed by the deterministic stages
|
|
4
|
+
* (sizing, routing, freeze), never as one long-lived process. Separate from
|
|
5
|
+
* `template.mjs` (which only builds the one-node contracts) and from
|
|
6
|
+
* `freeze.mjs` (which only turns a plan into a validated contract on disk):
|
|
7
|
+
* this module is the one place that sequences those runs, decides when a
|
|
8
|
+
* plan is contested instead of frozen, and records the operator-approval
|
|
9
|
+
* open-question. `launch` and `wait` are the only two seams that touch a
|
|
10
|
+
* process or the wall clock, so a test drives the whole pipeline through
|
|
11
|
+
* `runContract` in-process, deterministically.
|
|
12
|
+
*/
|
|
13
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
14
|
+
import { dirname, join, relative, resolve } from "node:path";
|
|
15
|
+
import { validateContract } from "../contract/index.mjs";
|
|
16
|
+
import { discoveryOutput } from "../contract/worker-result.mjs";
|
|
17
|
+
import { readWorkerResultFile } from "../engine/result-file.mjs";
|
|
18
|
+
import { classifyRunProgress } from "../campaign/chain.mjs";
|
|
19
|
+
import { campaignDir } from "../campaign/layout.mjs";
|
|
20
|
+
import { appendSeatAllowanceEvent, readJournal } from "../campaign/journal.mjs";
|
|
21
|
+
import { readCampaign } from "../campaign/record.mjs";
|
|
22
|
+
import { campaignCli } from "../cli/campaign.mjs";
|
|
23
|
+
import { appendJsonl, writeJsonAtomic } from "../run/store.mjs";
|
|
24
|
+
import { allowanceDelta, allowanceEventFields, sampleAllowance } from "../seat/allowance.mjs";
|
|
25
|
+
import { validateSpec } from "./spec.mjs";
|
|
26
|
+
import { collectRepoFacts } from "./repo-facts.mjs";
|
|
27
|
+
import { RISK_TIERS, buildPlanningContract, validateFindings, validatePlanOutput } from "./template.mjs";
|
|
28
|
+
import { applySizingRules } from "./sizing.mjs";
|
|
29
|
+
import { resolveRuntimes } from "./routing.mjs";
|
|
30
|
+
import { freezePlan } from "./freeze.mjs";
|
|
31
|
+
|
|
32
|
+
/** @typedef {import("../contract/index.mjs").JsonObject} JsonObject */
|
|
33
|
+
/** @typedef {import("../contract/index.mjs").ValidatedContract} ValidatedContract */
|
|
34
|
+
/** @typedef {import("./template.mjs").PlanOutput} PlanOutput */
|
|
35
|
+
/** @typedef {import("./template.mjs").PlanFindingOutput} PlanFindingOutput */
|
|
36
|
+
/** @typedef {import("./sizing.mjs").PlanNode & {objective: string}} SizedPlanNode */
|
|
37
|
+
/** @typedef {"standard"|"high"|"none"} ApproveBelow */
|
|
38
|
+
/** @typedef {(contractPath: string, contract: ValidatedContract) => Promise<void>|void} LaunchFn */
|
|
39
|
+
/** @typedef {(runDir: string) => Promise<import("../engine/supervise.mjs").RunProgress>|import("../engine/supervise.mjs").RunProgress} WaitFn */
|
|
40
|
+
/** @typedef {{status: "frozen", plansDir: string, planPath: string, contractPath: string, approved: boolean, findings: PlanFindingOutput[]}} FrozenPipelineResult */
|
|
41
|
+
/** @typedef {{status: "contested", plansDir: string, planPath: string, findings: PlanFindingOutput[], round: number}} ContestedPipelineResult */
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The session id every automated journal entry this pipeline writes carries.
|
|
45
|
+
* There is no human session behind a `plan` invocation, so a fixed id names
|
|
46
|
+
* the writer the same way `src/web/api.mjs`'s `WEB_SESSION_ID` names the web
|
|
47
|
+
* surface's own automated writes.
|
|
48
|
+
*/
|
|
49
|
+
export const PLANNER_SESSION_ID = "planner";
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* No taskKind/riskTier row is opinionated by default: absent an operator
|
|
53
|
+
* `--runtime-defaults` instruction, every sized node routes through plain
|
|
54
|
+
* availability discovery (`resolveRuntimes`'s cheapest worker, strongest
|
|
55
|
+
* cross-vendor judge). A default table cannot safely name a `prefer` runtime
|
|
56
|
+
* id without knowing the operator's own catalogue, so "small default" here
|
|
57
|
+
* means empty rather than guessed.
|
|
58
|
+
*/
|
|
59
|
+
export const DEFAULT_ROUTING_TABLE = /** @type {import("./routing.mjs").RoutingRule[]} */ ([]);
|
|
60
|
+
|
|
61
|
+
/** The sizing budget a frozen node's verification is measured against, absent a project-specific one. */
|
|
62
|
+
export const DEFAULT_NODE_BUDGET_MS = 600_000;
|
|
63
|
+
|
|
64
|
+
const APPROVE_BELOW_VALUES = new Set(["standard", "high", "none"]);
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* @param {{specPath: string, campaignId: string, phase: string, cwd?: string, reviewRounds?: number, approveBelow?: ApproveBelow, runtimeDefaults?: {worker?: string, judge?: string}, runtimes: Record<string, JsonObject>, launch: LaunchFn, wait: WaitFn}} options
|
|
68
|
+
* @returns {Promise<FrozenPipelineResult|ContestedPipelineResult>}
|
|
69
|
+
*/
|
|
70
|
+
export async function runPlanningPipeline(options) {
|
|
71
|
+
const {
|
|
72
|
+
specPath, campaignId, phase, runtimes, launch, wait,
|
|
73
|
+
reviewRounds = 2, runtimeDefaults = {},
|
|
74
|
+
} = options;
|
|
75
|
+
const approveBelow = /** @type {ApproveBelow} */ (options.approveBelow ?? "standard");
|
|
76
|
+
if (!APPROVE_BELOW_VALUES.has(approveBelow)) throw new TypeError(`approveBelow must be one of ${[...APPROVE_BELOW_VALUES].join(", ")}`);
|
|
77
|
+
if (typeof launch !== "function") throw new TypeError("runPlanningPipeline requires a launch seam");
|
|
78
|
+
if (typeof wait !== "function") throw new TypeError("runPlanningPipeline requires a wait seam");
|
|
79
|
+
const cwd = resolve(options.cwd ?? ".");
|
|
80
|
+
|
|
81
|
+
const campaignPath = campaignDir(join(cwd, ".runs"), campaignId);
|
|
82
|
+
const campaign = readCampaign(campaignPath);
|
|
83
|
+
if (campaign.status !== "active") throw new Error(`campaign is closed: ${campaignId}`);
|
|
84
|
+
|
|
85
|
+
const relativeSpecPath = repoRelativePath(cwd, specPath, "specPath");
|
|
86
|
+
const specText = readFileSync(resolve(cwd, relativeSpecPath), "utf8");
|
|
87
|
+
const specValidation = validateSpec(specText, { cwd, strict: true });
|
|
88
|
+
if (specValidation.class === "structured" && !specValidation.ok) {
|
|
89
|
+
const detail = specValidation.findings.map((finding) => `${finding.rule}: ${finding.message}`).join("; ");
|
|
90
|
+
throw new Error(`spec ${specPath} fails strict traceability: ${detail}`);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const plansDir = join(cwd, ".runs", "campaigns", campaignId, "plans", phase);
|
|
94
|
+
mkdirSync(plansDir, { recursive: true });
|
|
95
|
+
const pipelineLog = join(plansDir, "pipeline.jsonl");
|
|
96
|
+
/** @param {string} stage @param {Record<string, unknown>} [extra] */
|
|
97
|
+
const logStage = (stage, extra = {}) => appendJsonl(pipelineLog, {
|
|
98
|
+
type: "plan.stage", at: new Date().toISOString(), campaignId, phase, stage, ...extra,
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
const repoFacts = collectRepoFacts(cwd);
|
|
102
|
+
const repoFactsPath = join(plansDir, "repo-facts.json");
|
|
103
|
+
writeFileSync(repoFactsPath, `${JSON.stringify(repoFacts, null, 2)}\n`);
|
|
104
|
+
const relativeRepoFactsPath = relative(cwd, repoFactsPath);
|
|
105
|
+
logStage("repo-facts", { gitHead: repoFacts.gitHead });
|
|
106
|
+
|
|
107
|
+
let n = 0;
|
|
108
|
+
const nextN = () => { n += 1; return n; };
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Build, validate, persist, launch and wait for one planning contract, and
|
|
112
|
+
* return the discovery `output` its worker recorded. Every stage the
|
|
113
|
+
* pipeline runs is an ordinary run: it lands in `usage.jsonl` exactly like
|
|
114
|
+
* any other node, and this is the only place that reads its result back.
|
|
115
|
+
*
|
|
116
|
+
* @param {import("./template.mjs").PlanningKind} kind
|
|
117
|
+
* @param {Record<string, unknown>} inputs
|
|
118
|
+
* @returns {Promise<{contract: ValidatedContract, output: Record<string, unknown>}>}
|
|
119
|
+
*/
|
|
120
|
+
const runStage = async (kind, inputs) => {
|
|
121
|
+
const stageN = nextN();
|
|
122
|
+
const contractPath = join(plansDir, "nodes", `${kind}-${stageN}.contract.json`);
|
|
123
|
+
mkdirSync(dirname(contractPath), { recursive: true });
|
|
124
|
+
const relativeCwd = relative(dirname(contractPath), cwd) || ".";
|
|
125
|
+
const raw = buildPlanningContract(kind, {
|
|
126
|
+
campaignId, phase, n: stageN, runtimes, runtimeDefaults, cwd: relativeCwd, ...inputs,
|
|
127
|
+
});
|
|
128
|
+
const validated = validateContract(raw, contractPath);
|
|
129
|
+
writeFileSync(contractPath, `${JSON.stringify(raw, null, 2)}\n`);
|
|
130
|
+
await launch(contractPath, validated);
|
|
131
|
+
const runDir = join(validated.cwd, ".runs", validated.id);
|
|
132
|
+
const progress = await wait(runDir);
|
|
133
|
+
const classification = classifyRunProgress(progress);
|
|
134
|
+
if (classification !== "succeeded") {
|
|
135
|
+
throw new Error(`planning stage ${kind} did not succeed: run ${validated.id} ${classification}`);
|
|
136
|
+
}
|
|
137
|
+
const result = readWorkerResultFile(runDir, kind);
|
|
138
|
+
const output = result ? discoveryOutput(result) : null;
|
|
139
|
+
if (!output) throw new Error(`planning stage ${kind}: run ${validated.id} recorded no discovery output`);
|
|
140
|
+
return { contract: validated, output };
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
const draft = await runStage("draft", { specPath: relativeSpecPath, repoFactsPath: relativeRepoFactsPath });
|
|
144
|
+
let plan = validatePlanOutput(draft.output.plan);
|
|
145
|
+
logStage("draft", { runId: draft.contract.id, nodeCount: plan.nodes.length });
|
|
146
|
+
|
|
147
|
+
const workingPlanPath = join(plansDir, "plan.working.json");
|
|
148
|
+
writeJsonAtomic(workingPlanPath, plan);
|
|
149
|
+
const relativeWorkingPlanPath = relative(cwd, workingPlanPath);
|
|
150
|
+
|
|
151
|
+
/** @type {PlanFindingOutput[]} */
|
|
152
|
+
let findings = [];
|
|
153
|
+
for (let round = 1; round <= reviewRounds; round += 1) {
|
|
154
|
+
const review = await runStage("review", { specPath: relativeSpecPath, repoFactsPath: relativeRepoFactsPath, planPath: relativeWorkingPlanPath });
|
|
155
|
+
findings = validateFindings(review.output.findings);
|
|
156
|
+
const criticalFindings = findings.filter((finding) => finding.severity === "critical");
|
|
157
|
+
logStage("review", { round, runId: review.contract.id, findingsCount: findings.length, criticalCount: criticalFindings.length });
|
|
158
|
+
if (criticalFindings.length === 0) break;
|
|
159
|
+
if (round === reviewRounds) {
|
|
160
|
+
const planPath = join(plansDir, "plan.json");
|
|
161
|
+
writeJsonAtomic(planPath, { formatVersion: 1, status: "contested", rounds: round, findings });
|
|
162
|
+
logStage("contested", { round, criticalCount: criticalFindings.length });
|
|
163
|
+
await campaignCli([
|
|
164
|
+
"note", campaignId, "--cwd", cwd, "--session-id", PLANNER_SESSION_ID,
|
|
165
|
+
"--kind", "open-question", "--question-id", `plan-${phase}-contested`,
|
|
166
|
+
"--text", `Plan for phase ${phase} is contested after ${round} review round(s): ${criticalFindings.map((finding) => finding.text).join("; ")}`,
|
|
167
|
+
]);
|
|
168
|
+
return { status: "contested", plansDir, planPath, findings, round };
|
|
169
|
+
}
|
|
170
|
+
const findingsPath = join(plansDir, `findings-round-${round}.json`);
|
|
171
|
+
writeJsonAtomic(findingsPath, findings);
|
|
172
|
+
const revise = await runStage("revise", {
|
|
173
|
+
specPath: relativeSpecPath, repoFactsPath: relativeRepoFactsPath, findingsPath: relative(cwd, findingsPath),
|
|
174
|
+
});
|
|
175
|
+
plan = validatePlanOutput(revise.output.plan);
|
|
176
|
+
writeJsonAtomic(workingPlanPath, plan);
|
|
177
|
+
logStage("revise", { round, runId: revise.contract.id });
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const sizing = applySizingRules(
|
|
181
|
+
{ nodes: plan.nodes.map(toSizingNode), justification: plan.justification },
|
|
182
|
+
{ nodeBudgetMs: DEFAULT_NODE_BUDGET_MS, facts: repoFacts },
|
|
183
|
+
);
|
|
184
|
+
logStage("sizing", { transformations: sizing.transformations.length, nodeCount: sizing.plan.nodes.length });
|
|
185
|
+
|
|
186
|
+
const routingRuntimes = /** @type {Record<string, import("./routing.mjs").RoutingRuntime>} */ (runtimes);
|
|
187
|
+
const routing = resolveRuntimes(sizing.plan.nodes, {
|
|
188
|
+
table: [...DEFAULT_ROUTING_TABLE],
|
|
189
|
+
runtimes: routingRuntimes,
|
|
190
|
+
availability: availabilityOf(runtimes),
|
|
191
|
+
runtimeDefaults,
|
|
192
|
+
});
|
|
193
|
+
logStage("routing", { assignments: Object.keys(routing.assignments).length });
|
|
194
|
+
|
|
195
|
+
const highestRiskTier = highestOf(sizing.plan.nodes.map((node) => node.riskTier ?? RISK_TIERS[0]));
|
|
196
|
+
const nodes = sizing.plan.nodes.map((node) => toContractNode(/** @type {SizedPlanNode} */ (node), phase, routing.assignments[node.id]));
|
|
197
|
+
const frozen = freezePlan({
|
|
198
|
+
id: `${campaignId}-${phase}`,
|
|
199
|
+
campaignId,
|
|
200
|
+
goal: campaign.goal,
|
|
201
|
+
// `freezePlan` writes contract.json inside `outDir` (`plansDir`), so `cwd`
|
|
202
|
+
// has to point back at the repo root from there, exactly like `runStage`
|
|
203
|
+
// computes it for the nodes it writes under `plansDir/nodes/`.
|
|
204
|
+
cwd: relative(plansDir, cwd) || ".",
|
|
205
|
+
runtimes,
|
|
206
|
+
runtimeDefaults,
|
|
207
|
+
nodes,
|
|
208
|
+
}, {
|
|
209
|
+
outDir: plansDir,
|
|
210
|
+
provenance: {
|
|
211
|
+
targetGitHead: repoFacts.gitHead,
|
|
212
|
+
planner: { runtimeId: runtimeDefaults.worker ?? "", model: modelOf(runtimes, runtimeDefaults.worker) },
|
|
213
|
+
reviewer: { runtimeId: runtimeDefaults.judge ?? "", model: modelOf(runtimes, runtimeDefaults.judge) },
|
|
214
|
+
sizing: sizing.transformations,
|
|
215
|
+
findings,
|
|
216
|
+
},
|
|
217
|
+
});
|
|
218
|
+
logStage("freeze", { contractId: `${campaignId}-${phase}`, highestRiskTier });
|
|
219
|
+
|
|
220
|
+
// A delta only means something between two samples of the same seat: freeze
|
|
221
|
+
// re-samples the exact harness `campaign init` recorded at `sample: "start"`
|
|
222
|
+
// (the operator's own seat), not the plan's worker runtime, which is very
|
|
223
|
+
// often a different harness entirely (codex, dsh, agy, zcode workers under
|
|
224
|
+
// a claude operator) and would make the delta null in the common case
|
|
225
|
+
// instead of the rare one. With no start entry at all (a pipeline run with
|
|
226
|
+
// no preceding `campaign init`, as in every replay-driven pipeline test)
|
|
227
|
+
// there is no seat to re-sample, so freeze samples nothing and spends no
|
|
228
|
+
// call.
|
|
229
|
+
const journalEntries = /** @type {any[]} */ (readJournal(campaignPath));
|
|
230
|
+
const startEntry = journalEntries.findLast((entry) => entry.type === "seat.allowance" && entry.sample === "start");
|
|
231
|
+
const freezeHarness = startEntry?.harness ?? null;
|
|
232
|
+
const freezeAllowance = await sampleAllowance({ harness: freezeHarness });
|
|
233
|
+
const startAllowance = startEntry
|
|
234
|
+
? { remaining: startEntry.remaining ?? null, limit: startEntry.limit ?? null, resetsAt: startEntry.resetsAt ?? null, window: startEntry.window ?? null }
|
|
235
|
+
: null;
|
|
236
|
+
appendSeatAllowanceEvent(campaignPath, {
|
|
237
|
+
sample: "freeze",
|
|
238
|
+
harness: freezeHarness,
|
|
239
|
+
delta: allowanceDelta(startAllowance, freezeAllowance),
|
|
240
|
+
...allowanceEventFields(freezeAllowance),
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
const approved = approveBelow === "high" ? true : approveBelow === "none" ? false : highestRiskTier !== "high";
|
|
244
|
+
const planPath = join(plansDir, "plan.json");
|
|
245
|
+
writeJsonAtomic(planPath, { ...frozen, status: "frozen", approved });
|
|
246
|
+
if (!approved) {
|
|
247
|
+
await campaignCli([
|
|
248
|
+
"note", campaignId, "--cwd", cwd, "--session-id", PLANNER_SESSION_ID,
|
|
249
|
+
"--kind", "open-question", "--question-id", `plan-${phase}-approval`,
|
|
250
|
+
"--text", `Plan for phase ${phase} carries a ${highestRiskTier}-risk node; approval is required under --approve-below ${approveBelow}.`,
|
|
251
|
+
]);
|
|
252
|
+
}
|
|
253
|
+
logStage("approval", { approved, approveBelow, highestRiskTier });
|
|
254
|
+
|
|
255
|
+
return { status: "frozen", plansDir, planPath, contractPath: join(plansDir, "contract.json"), approved, findings };
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* `path` made relative to `cwd`, refused when it escapes it: every planning
|
|
260
|
+
* contract's readFiles must resolve inside the same cwd a run validates
|
|
261
|
+
* against, so a spec outside the target repository can never be named there.
|
|
262
|
+
*
|
|
263
|
+
* @param {string} cwd
|
|
264
|
+
* @param {string} path
|
|
265
|
+
* @param {string} label
|
|
266
|
+
* @returns {string}
|
|
267
|
+
*/
|
|
268
|
+
function repoRelativePath(cwd, path, label) {
|
|
269
|
+
const relativePath = relative(cwd, resolve(cwd, path));
|
|
270
|
+
if (relativePath.startsWith("..")) throw new Error(`${label} must be inside ${cwd}: ${path}`);
|
|
271
|
+
return relativePath;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Every declared runtime treated as available. Live discovery (probing a
|
|
276
|
+
* harness for real exhaustion) is a separate concern this pipeline does not
|
|
277
|
+
* take on; a campaign that needs it can inject a table row and prune its
|
|
278
|
+
* `runtimes` catalogue instead.
|
|
279
|
+
*
|
|
280
|
+
* @param {Record<string, JsonObject>} runtimes
|
|
281
|
+
* @returns {Record<string, {available: true, exhaustedUntil: null}>}
|
|
282
|
+
*/
|
|
283
|
+
function availabilityOf(runtimes) {
|
|
284
|
+
return Object.fromEntries(Object.keys(runtimes).map((id) => [id, { available: true, exhaustedUntil: null }]));
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* @param {Record<string, JsonObject>} runtimes
|
|
289
|
+
* @param {string|undefined} id
|
|
290
|
+
* @returns {string}
|
|
291
|
+
*/
|
|
292
|
+
function modelOf(runtimes, id) {
|
|
293
|
+
const model = id ? runtimes[id]?.model : undefined;
|
|
294
|
+
return typeof model === "string" ? model : "";
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* @param {string[]} riskTiers
|
|
299
|
+
* @returns {string}
|
|
300
|
+
*/
|
|
301
|
+
function highestOf(riskTiers) {
|
|
302
|
+
return riskTiers.reduce((highest, tier) => (RISK_TIERS.indexOf(tier) > RISK_TIERS.indexOf(highest) ? tier : highest), RISK_TIERS[0]);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* A draft or revise output node (flat `readFiles`/`writeFiles`/`verification`)
|
|
307
|
+
* turned into the shape `applySizingRules` merges and splits: those fields move
|
|
308
|
+
* under `taskPacket`, alongside `sizing.mjs`'s own `writeFiles`/`verification`
|
|
309
|
+
* expectations, while `objective` rides along as a passthrough field a merge
|
|
310
|
+
* never touches.
|
|
311
|
+
*
|
|
312
|
+
* @param {import("./template.mjs").PlanOutputNode} node
|
|
313
|
+
* @returns {SizedPlanNode}
|
|
314
|
+
*/
|
|
315
|
+
function toSizingNode(node) {
|
|
316
|
+
return /** @type {SizedPlanNode} */ ({
|
|
317
|
+
id: node.id,
|
|
318
|
+
dependsOn: node.dependsOn,
|
|
319
|
+
taskKind: node.taskKind,
|
|
320
|
+
riskTier: node.riskTier,
|
|
321
|
+
objective: node.objective,
|
|
322
|
+
definitionOfDone: node.definitionOfDone,
|
|
323
|
+
taskPacket: {
|
|
324
|
+
readFiles: node.readFiles,
|
|
325
|
+
writeFiles: node.writeFiles,
|
|
326
|
+
verification: node.verification,
|
|
327
|
+
},
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* A sized plan node's classification and shape, turned into the contract node
|
|
333
|
+
* `freezePlan` validates. `riskTier: "low"` gets no gate; `standard` an
|
|
334
|
+
* advisory one; `high` a blocking one, which `validateGate` requires `major`
|
|
335
|
+
* in `failOn` for.
|
|
336
|
+
*
|
|
337
|
+
* @param {SizedPlanNode} node
|
|
338
|
+
* @param {string} phase
|
|
339
|
+
* @param {{worker: string|null, judge: string|null}|undefined} assignment
|
|
340
|
+
* @returns {JsonObject}
|
|
341
|
+
*/
|
|
342
|
+
function toContractNode(node, phase, assignment) {
|
|
343
|
+
const riskTier = /** @type {string} */ (node.riskTier);
|
|
344
|
+
const gate = riskTier === "low"
|
|
345
|
+
? false
|
|
346
|
+
: {
|
|
347
|
+
review: riskTier === "high" ? "blocking" : "advisory",
|
|
348
|
+
failOn: riskTier === "high" ? ["major", "critical"] : ["critical"],
|
|
349
|
+
...(assignment?.judge ? { runtime: assignment.judge } : {}),
|
|
350
|
+
};
|
|
351
|
+
return {
|
|
352
|
+
id: node.id,
|
|
353
|
+
type: node.taskKind,
|
|
354
|
+
phase,
|
|
355
|
+
dependsOn: node.dependsOn ?? [],
|
|
356
|
+
...(assignment?.worker ? { runtime: assignment.worker } : {}),
|
|
357
|
+
taskPacket: {
|
|
358
|
+
mode: "execution",
|
|
359
|
+
objective: node.objective,
|
|
360
|
+
instructions: [node.objective],
|
|
361
|
+
readFiles: node.taskPacket.readFiles ?? [],
|
|
362
|
+
writeFiles: node.taskPacket.writeFiles ?? [],
|
|
363
|
+
symbols: [],
|
|
364
|
+
decisions: [],
|
|
365
|
+
nonGoals: [],
|
|
366
|
+
verification: node.taskPacket.verification,
|
|
367
|
+
},
|
|
368
|
+
definitionOfDone: node.definitionOfDone ?? [],
|
|
369
|
+
gate,
|
|
370
|
+
};
|
|
371
|
+
}
|
|
@@ -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
|
+
}
|