faberun 0.20.0 → 0.22.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 +2 -1
- package/skills/faberun/references/operations.md +7 -7
- package/src/campaign/brief-cli.mjs +174 -0
- package/src/campaign/brief-text.mjs +96 -0
- package/src/campaign/campaign-brief.mjs +772 -0
- package/src/campaign/chain.mjs +30 -1
- package/src/campaign/projection.mjs +34 -0
- package/src/cli/campaign.mjs +22 -2
- package/src/contract/index.mjs +5 -3
- package/src/contract/runtime.mjs +22 -1
- package/src/contract/scope-findings.mjs +12 -0
- package/src/contract/snapshot.mjs +16 -5
- package/src/engine/dispatch.mjs +6 -4
- package/src/engine/process.mjs +1 -0
- package/src/engine/result-file.mjs +13 -1
- package/src/engine/settle.mjs +1 -0
- package/src/engine/verify.mjs +37 -2
- package/src/harnesses/codex/index.mjs +1 -0
- package/src/harnesses/index.mjs +18 -1
- package/src/plan/freeze.mjs +240 -35
- package/src/plan/pipeline-shape.mjs +100 -0
- package/src/plan/pipeline.mjs +68 -116
- package/src/plan/template.mjs +88 -25
- package/src/repo/declared-paths.mjs +16 -0
- package/src/repo/workspace.mjs +42 -3
- package/src/repo/worktree.mjs +34 -2
- package/src/report/campaign-brief-estimate.mjs +450 -0
- package/src/report/campaign-brief-html.mjs +439 -0
- package/src/report/campaign-brief.mjs +409 -0
- package/src/report/final.mjs +5 -4
- package/src/report/mdhtml-release.json +30 -0
- package/src/report/render.mjs +4 -3
- package/src/run/usage.mjs +265 -0
- package/src/web/campaign-brief-server.mjs +401 -0
package/src/plan/freeze.mjs
CHANGED
|
@@ -1,21 +1,25 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Freezing a plan: the boundary between a session's draft and a contract the
|
|
3
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, the
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* it
|
|
8
|
-
*
|
|
4
|
+
* contract.json, plus a plan.json carrying that contract's digest, the
|
|
5
|
+
* structured spec's path and content digest, the plan's per-phase requirement
|
|
6
|
+
* declarations (which requirement ids each phase satisfies, which planned
|
|
7
|
+
* nodes it assigns, and its one-sentence deliverable), and the full provenance
|
|
8
|
+
* of how it was produced — the two files travel together so a later launch and
|
|
9
|
+
* this record agree on exactly what was reviewed.
|
|
9
10
|
* `verifyFrozenPlan` is the one check that the pair still agree.
|
|
10
11
|
*
|
|
11
12
|
* Nothing here invokes a model or the engine; it only writes and hashes
|
|
12
13
|
* bytes, so freezing a plan can never be mistaken for starting a run.
|
|
13
14
|
*/
|
|
15
|
+
import { createHash } from "node:crypto";
|
|
14
16
|
import { mkdirSync, readFileSync, rmSync } from "node:fs";
|
|
15
17
|
import { join } from "node:path";
|
|
16
18
|
import { fileURLToPath } from "node:url";
|
|
17
19
|
import { CONTRACT_VERSION, PROTOCOL_SCHEMA_VERSION, contractDigest, validateContract } from "../contract/index.mjs";
|
|
18
|
-
import {
|
|
20
|
+
import { assertObject, rejectUnknown, requirePacketHash, requireString } from "../contract/assert.mjs";
|
|
21
|
+
import { writeJsonAtomic, writeTextAtomic } from "../run/store.mjs";
|
|
22
|
+
import { VERIFICATION_LIMITS } from "../contract/verification.mjs";
|
|
19
23
|
import { validatePlanPhases } from "./template.mjs";
|
|
20
24
|
|
|
21
25
|
/** @typedef {import("../contract/index.mjs").JsonObject} JsonObject */
|
|
@@ -24,12 +28,112 @@ import { validatePlanPhases } from "./template.mjs";
|
|
|
24
28
|
/** @typedef {{id: string, severity: "minor"|"major"|"critical", nodeId?: string, text: string}} PlanFinding */
|
|
25
29
|
/** @typedef {{targetGitHead: string|null, planner: PlanParticipant, reviewer: PlanParticipant, sizing: unknown, findings: PlanFinding[]}} PlanProvenanceInput */
|
|
26
30
|
/** @typedef {PlanProvenanceInput & {packageVersion: string, schemaVersion: number, contractVersion: string}} PlanProvenance */
|
|
27
|
-
/** @typedef {{id: string, requirementIds: string[], deliverable: string}} PlanPhase */
|
|
28
|
-
/** @typedef {{
|
|
31
|
+
/** @typedef {{id: string, requirementIds: string[], nodeIds?: string[], deliverable: string}} PlanPhase */
|
|
32
|
+
/** @typedef {{path: string, digest: string}} PlanSpecIdentity */
|
|
33
|
+
/** @typedef {{formatVersion: number, contractDigest: string, spec?: PlanSpecIdentity, phases?: PlanPhase[], provenance: PlanProvenance}} FrozenPlan */
|
|
29
34
|
/** @typedef {{ok: boolean, digest: string, expectedDigest: string}} FrozenPlanVerdict */
|
|
35
|
+
/** @typedef {{scripts?: Record<string, string>, verificationCandidates: {argv: string[], measuredMs: number}[]}} MeasuredFacts */
|
|
30
36
|
|
|
31
37
|
const PLAN_FORMAT_VERSION = 1;
|
|
32
38
|
|
|
39
|
+
/**
|
|
40
|
+
* The margin a frozen verification timeout keeps over its measured duration.
|
|
41
|
+
* No measurement behind the number itself: it is the spec's (RM-057), and a
|
|
42
|
+
* timeout only bounds a failure, so a passing command never waits for it.
|
|
43
|
+
*/
|
|
44
|
+
const MEASURED_TIMEOUT_MARGIN = 1.5;
|
|
45
|
+
|
|
46
|
+
/** `node --test` options that run a subset of the files they name. */
|
|
47
|
+
const FILTER_OPTIONS = ["--test-name-pattern", "--test-skip-pattern", "--test-only", "--test-shard"];
|
|
48
|
+
|
|
49
|
+
/** `node` options whose value is the next argument, so it is not a path. */
|
|
50
|
+
const NODE_VALUE_OPTIONS = new Set(["--import", "--require", "-r", "--loader", "--experimental-loader", "--env-file", "--test-reporter", "--test-reporter-destination", "--test-name-pattern", "--test-skip-pattern", "--test-concurrency", "--test-timeout"]);
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The `node --test <dir>` candidates one path argument includes: a directory
|
|
54
|
+
* includes itself and everything below it, and a glob includes the
|
|
55
|
+
* directories below its literal prefix only when it descends (`test/*` +
|
|
56
|
+
* `/…`); `test/*.test.mjs` names top-level files no candidate measured.
|
|
57
|
+
*
|
|
58
|
+
* @param {string} arg
|
|
59
|
+
* @param {string[]} directories
|
|
60
|
+
* @returns {string[]}
|
|
61
|
+
*/
|
|
62
|
+
function includedDirectories(arg, directories) {
|
|
63
|
+
const path = arg.replace(/^\.\//u, "").replace(/\/+$/u, "");
|
|
64
|
+
const wildcard = path.search(/[*?[]/u);
|
|
65
|
+
if (wildcard < 0) return directories.filter((directory) => directory === path || directory.startsWith(`${path}/`));
|
|
66
|
+
const base = path.slice(0, path.lastIndexOf("/", wildcard));
|
|
67
|
+
if (!path.slice(wildcard).includes("/")) return [];
|
|
68
|
+
return directories.filter((directory) => base === "" || directory.startsWith(`${base}/`));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* What repo facts measured for a verification command: its own candidate, or
|
|
73
|
+
* the sum of the `node --test <dir>` candidates it includes, `npm test`
|
|
74
|
+
* resolved through the `test` script. A lower bound when the command also
|
|
75
|
+
* runs files no candidate measured; null when it includes nothing measured.
|
|
76
|
+
*
|
|
77
|
+
* @param {string[]} argv
|
|
78
|
+
* @param {MeasuredFacts} facts
|
|
79
|
+
* @returns {number|null}
|
|
80
|
+
*/
|
|
81
|
+
function measuredMsFor(argv, facts) {
|
|
82
|
+
const candidates = facts.verificationCandidates;
|
|
83
|
+
const exact = candidates.find((candidate) => candidate.argv.join(" ") === argv.join(" "));
|
|
84
|
+
if (exact) return exact.measuredMs;
|
|
85
|
+
const npmTest = argv[0] === "npm" && ["test", "run test"].includes(argv.slice(1).join(" "));
|
|
86
|
+
if (npmTest && typeof facts.scripts?.test === "string") return measuredMsFor(facts.scripts.test.trim().split(/\s+/u), facts);
|
|
87
|
+
if (argv[0] !== "node" || argv[1] !== "--test") return null;
|
|
88
|
+
// A filtered run measures nothing a directory candidate measured.
|
|
89
|
+
if (argv.some((arg) => FILTER_OPTIONS.some((option) => arg === option || arg.startsWith(`${option}=`)))) return null;
|
|
90
|
+
const measured = new Map(candidates
|
|
91
|
+
.filter((candidate) => candidate.argv.length === 3 && candidate.argv[0] === "node" && candidate.argv[1] === "--test")
|
|
92
|
+
.map((candidate) => [candidate.argv[2].replace(/\/+$/u, ""), candidate.measuredMs]));
|
|
93
|
+
/** @type {string[]} */
|
|
94
|
+
const paths = [];
|
|
95
|
+
for (let index = 2; index < argv.length; index += 1) {
|
|
96
|
+
if (NODE_VALUE_OPTIONS.has(argv[index])) index += 1;
|
|
97
|
+
else if (!argv[index].startsWith("-")) paths.push(argv[index]);
|
|
98
|
+
}
|
|
99
|
+
const included = new Set((paths.length ? paths : ["test"]).flatMap((path) => includedDirectories(path, [...measured.keys()])));
|
|
100
|
+
if (!included.size) return null;
|
|
101
|
+
return [...included].reduce((sum, directory) => sum + /** @type {number} */ (measured.get(directory)), 0);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Refuse a contract whose verification timeout sits under
|
|
106
|
+
* `MEASURED_TIMEOUT_MARGIN` times what repo facts measured for the command,
|
|
107
|
+
* and name a command no legal timeout can cover, so the plan is contested
|
|
108
|
+
* with the advice to split it. RM-057, measured on the Campaign Brief run:
|
|
109
|
+
* a gate frozen at 120s against parts measured at 178,904 ms and 246,955 ms.
|
|
110
|
+
*
|
|
111
|
+
* @param {import("../contract/index.mjs").ValidatedContract} contract
|
|
112
|
+
* @param {MeasuredFacts} facts
|
|
113
|
+
* @returns {void}
|
|
114
|
+
*/
|
|
115
|
+
export function assertTimeoutsCoverMeasured(contract, facts) {
|
|
116
|
+
const commands = [
|
|
117
|
+
...contract.nodes.flatMap((node) => node.taskPacket.verification ?? []),
|
|
118
|
+
...contract.sharedVerification ?? [],
|
|
119
|
+
...contract.finalVerification ?? [],
|
|
120
|
+
];
|
|
121
|
+
const problems = new Set();
|
|
122
|
+
for (const command of commands) {
|
|
123
|
+
const measuredMs = measuredMsFor(command.argv, facts);
|
|
124
|
+
if (measuredMs === null) continue;
|
|
125
|
+
const timeoutSec = command.timeoutSec ?? 120;
|
|
126
|
+
const requiredSec = Math.ceil((measuredMs * MEASURED_TIMEOUT_MARGIN) / 1_000);
|
|
127
|
+
const shown = `${command.argv.join(" ")} measured ${(measuredMs / 1_000).toFixed(1)}s`;
|
|
128
|
+
if (requiredSec > VERIFICATION_LIMITS.maxTimeoutSec) {
|
|
129
|
+
problems.add(`${shown}, and ${MEASURED_TIMEOUT_MARGIN} times that passes the ${VERIFICATION_LIMITS.maxTimeoutSec}s maxTimeoutSec: split it into commands that each fit`);
|
|
130
|
+
} else if (timeoutSec < requiredSec) {
|
|
131
|
+
problems.add(`verification ${command.argv.join(" ")} has timeoutSec ${timeoutSec}s, under ${MEASURED_TIMEOUT_MARGIN} times its measured ${(measuredMs / 1_000).toFixed(1)}s: raise it to at least ${requiredSec}s`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
if (problems.size) throw new TypeError(`verification timeouts do not cover their measured durations: ${[...problems].join("; ")}`);
|
|
135
|
+
}
|
|
136
|
+
|
|
33
137
|
/** @returns {string} the installed package's own version, read once per call so a freeze always names the toolchain that produced it */
|
|
34
138
|
function packageVersion() {
|
|
35
139
|
const packageJsonPath = fileURLToPath(new URL("../../package.json", import.meta.url));
|
|
@@ -37,12 +141,16 @@ function packageVersion() {
|
|
|
37
141
|
}
|
|
38
142
|
|
|
39
143
|
/**
|
|
40
|
-
* Nodes inherit the requirement ids of the phase
|
|
41
|
-
* contract preserves them per node, so the engine can stamp them
|
|
42
|
-
* node's accepted result without the worker packet or the worker ever
|
|
43
|
-
* declaring one.
|
|
44
|
-
*
|
|
45
|
-
*
|
|
144
|
+
* Nodes inherit the requirement ids of the phase declaration that names them.
|
|
145
|
+
* The frozen contract preserves them per node, so the engine can stamp them
|
|
146
|
+
* onto the node's accepted result without the worker packet or the worker ever
|
|
147
|
+
* declaring one. The current `{id, requirementIds, nodeIds, deliverable}`
|
|
148
|
+
* declaration names its nodes outright; the legacy
|
|
149
|
+
* `{id, requirementIds, deliverable}` shape still resolves by the node's
|
|
150
|
+
* execution `phase`, so a record frozen before node assignment existed keeps
|
|
151
|
+
* reading. A node no declaration names, or a declaration that declares no ids,
|
|
152
|
+
* leaves it unstamped. Nodes are re-listed rather than mutated in place, so
|
|
153
|
+
* the caller's plan keeps the shape it was reviewed with.
|
|
46
154
|
*
|
|
47
155
|
* @param {unknown} nodes
|
|
48
156
|
* @param {PlanPhase[]} phases
|
|
@@ -50,23 +158,108 @@ function packageVersion() {
|
|
|
50
158
|
*/
|
|
51
159
|
function stampPhaseRequirementIds(nodes, phases) {
|
|
52
160
|
if (!Array.isArray(nodes)) return nodes;
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
)
|
|
161
|
+
/** @type {Map<string, string[]>} */
|
|
162
|
+
const byNodeId = new Map();
|
|
163
|
+
/** @type {Map<string, string[]>} */
|
|
164
|
+
const byExecutionPhase = new Map();
|
|
165
|
+
for (const phase of phases) {
|
|
166
|
+
if (phase.requirementIds.length === 0) continue;
|
|
167
|
+
if (phase.nodeIds === undefined) {
|
|
168
|
+
byExecutionPhase.set(phase.id, phase.requirementIds);
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
for (const nodeId of phase.nodeIds) byNodeId.set(nodeId, phase.requirementIds);
|
|
172
|
+
}
|
|
58
173
|
return nodes.map((node) => {
|
|
59
|
-
const
|
|
60
|
-
const
|
|
61
|
-
|
|
174
|
+
const record = /** @type {Record<string, unknown>} */ (node && typeof node === "object" ? node : {});
|
|
175
|
+
const byId = typeof record.id === "string" ? byNodeId.get(record.id) : undefined;
|
|
176
|
+
const inherited = byId ?? (typeof record.phase === "string" ? byExecutionPhase.get(record.phase) : undefined);
|
|
177
|
+
return inherited ? { ...record, requirementIds: [...inherited] } : node;
|
|
62
178
|
});
|
|
63
179
|
}
|
|
64
180
|
|
|
181
|
+
/**
|
|
182
|
+
* The planned node ids a declaration set is checked against, when `plan.nodes`
|
|
183
|
+
* is a usable list. A non-array nodes field yields undefined, which leaves the
|
|
184
|
+
* assignment checks to `validateContract`'s own refusal.
|
|
185
|
+
*
|
|
186
|
+
* @param {unknown} nodes
|
|
187
|
+
* @returns {string[]|undefined}
|
|
188
|
+
*/
|
|
189
|
+
function plannedNodeIdsOf(nodes) {
|
|
190
|
+
if (!Array.isArray(nodes)) return undefined;
|
|
191
|
+
/** @type {string[]} */
|
|
192
|
+
const ids = [];
|
|
193
|
+
for (const node of nodes) {
|
|
194
|
+
const id = /** @type {Record<string, unknown>|undefined} */ (node && typeof node === "object" ? node : undefined)?.id;
|
|
195
|
+
if (typeof id === "string") ids.push(id);
|
|
196
|
+
}
|
|
197
|
+
return ids;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* The spec identity a frozen plan records, shape-checked before anything is
|
|
202
|
+
* written. Absent is legal so a caller that predates spec identity keeps
|
|
203
|
+
* freezing; a plan without it is refused by the Campaign Brief instead.
|
|
204
|
+
*
|
|
205
|
+
* @param {unknown} spec
|
|
206
|
+
* @returns {PlanSpecIdentity|undefined}
|
|
207
|
+
*/
|
|
208
|
+
function specIdentityOf(spec) {
|
|
209
|
+
if (spec === undefined) return undefined;
|
|
210
|
+
assertObject(spec, "spec");
|
|
211
|
+
const record = /** @type {Record<string, unknown>} */ (spec);
|
|
212
|
+
rejectUnknown(record, new Set(["path", "digest"]), "spec");
|
|
213
|
+
requireString(record.path, "spec.path");
|
|
214
|
+
requirePacketHash(record.digest, "spec.digest");
|
|
215
|
+
return { path: /** @type {string} */ (record.path), digest: /** @type {string} */ (record.digest) };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* The SHA-256 of a file's exact bytes: the independent digest a frozen plan's
|
|
220
|
+
* `plan.json.sha256` sidecar carries, recomputable by a reader without parsing
|
|
221
|
+
* the JSON.
|
|
222
|
+
*
|
|
223
|
+
* @param {string} path
|
|
224
|
+
* @returns {string}
|
|
225
|
+
*/
|
|
226
|
+
export function fileDigest(path) {
|
|
227
|
+
return createHash("sha256").update(readFileSync(path)).digest("hex");
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* The SHA-256 of a UTF-8 string, the same digest `fileDigest` computes for the
|
|
232
|
+
* exact bytes a reader sees. Used for the structured spec's content digest.
|
|
233
|
+
*
|
|
234
|
+
* @param {string} text
|
|
235
|
+
* @returns {string}
|
|
236
|
+
*/
|
|
237
|
+
export function contentDigest(text) {
|
|
238
|
+
return createHash("sha256").update(text, "utf8").digest("hex");
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Write the final frozen plan record and the `plan.json.sha256` sidecar over
|
|
243
|
+
* those exact bytes. The digest is taken from the file after it is written, so
|
|
244
|
+
* it always covers what a reader will read; the caller must not rewrite
|
|
245
|
+
* plan.json once this returns.
|
|
246
|
+
*
|
|
247
|
+
* @param {string} outDir
|
|
248
|
+
* @param {FrozenPlan} record the final record, status and approval included
|
|
249
|
+
* @returns {FrozenPlan}
|
|
250
|
+
*/
|
|
251
|
+
export function writeFrozenPlanRecord(outDir, record) {
|
|
252
|
+
const planPath = join(outDir, "plan.json");
|
|
253
|
+
writeJsonAtomic(planPath, record);
|
|
254
|
+
writeTextAtomic(join(outDir, "plan.json.sha256"), `${fileDigest(planPath)}\n`);
|
|
255
|
+
return record;
|
|
256
|
+
}
|
|
257
|
+
|
|
65
258
|
/**
|
|
66
259
|
* Validate `plan` as a contract and, only once it is valid, write it and a
|
|
67
|
-
* sibling plan.json naming its digest and provenance.
|
|
68
|
-
* `schemaVersion`/`contractVersion` itself; when it does not,
|
|
69
|
-
* the runner's own current values.
|
|
260
|
+
* sibling plan.json naming its digest, its spec identity and its provenance.
|
|
261
|
+
* `plan` supplies `schemaVersion`/`contractVersion` itself; when it does not,
|
|
262
|
+
* this fills in the runner's own current values.
|
|
70
263
|
*
|
|
71
264
|
* contract.json is written before validation runs, because a packet may
|
|
72
265
|
* declare `readFiles: ["contract.json"]` — an execution packet's own file,
|
|
@@ -74,21 +267,29 @@ function stampPhaseRequirementIds(nodes, phases) {
|
|
|
74
267
|
* A validation failure removes that file again, so a caller never observes a
|
|
75
268
|
* contract.json that failed its own check.
|
|
76
269
|
*
|
|
77
|
-
* `options.phases` carries the plan's per-phase
|
|
78
|
-
*
|
|
270
|
+
* `options.phases` carries the plan's per-phase declarations — each phase's
|
|
271
|
+
* requirementIds, nodeIds and deliverable — validated by the same check
|
|
79
272
|
* validatePlanOutput applies, and recorded on plan.json verbatim.
|
|
273
|
+
* `options.spec` carries the structured spec's path and content digest; when
|
|
274
|
+
* given, both are recorded so a reader can pin the plan to the exact spec it
|
|
275
|
+
* was planned from.
|
|
80
276
|
*
|
|
81
277
|
* @param {JsonObject} plan
|
|
82
|
-
*
|
|
278
|
+
* `options.facts` carries repo facts' measured durations; given, a
|
|
279
|
+
* verification timeout that does not cover one is refused.
|
|
280
|
+
*
|
|
281
|
+
* @param {{outDir: string, provenance: PlanProvenanceInput, phases?: import("./template.mjs").PlanPhase[], spec?: PlanSpecIdentity, facts?: MeasuredFacts}} options
|
|
83
282
|
* @returns {FrozenPlan}
|
|
84
283
|
*/
|
|
85
|
-
export function freezePlan(plan, { outDir, provenance, phases }) {
|
|
284
|
+
export function freezePlan(plan, { outDir, provenance, phases, spec, facts }) {
|
|
86
285
|
// Shape-checked before anything is written, so a malformed declaration
|
|
87
286
|
// leaves the outDir exactly as it was — the same failure discipline as the
|
|
88
|
-
// validateContract rollback below. A
|
|
89
|
-
//
|
|
90
|
-
//
|
|
91
|
-
|
|
287
|
+
// validateContract rollback below. A declaration in the nodeIds shape must
|
|
288
|
+
// cover every planned node exactly once; a legacy declaration that names no
|
|
289
|
+
// requirements passes here, its gap being validatePlanOutput's finding to
|
|
290
|
+
// report rather than a reason to refuse the freeze.
|
|
291
|
+
const phaseDeclarations = validatePlanPhases(phases, plannedNodeIdsOf(plan.nodes));
|
|
292
|
+
const specIdentity = specIdentityOf(spec);
|
|
92
293
|
mkdirSync(outDir, { recursive: true });
|
|
93
294
|
const contractPath = join(outDir, "contract.json");
|
|
94
295
|
const raw = /** @type {JsonObject} */ ({
|
|
@@ -101,7 +302,8 @@ export function freezePlan(plan, { outDir, provenance, phases }) {
|
|
|
101
302
|
});
|
|
102
303
|
writeJsonAtomic(contractPath, raw);
|
|
103
304
|
try {
|
|
104
|
-
validateContract(raw, contractPath);
|
|
305
|
+
const validated = validateContract(raw, contractPath);
|
|
306
|
+
if (facts) assertTimeoutsCoverMeasured(validated, facts);
|
|
105
307
|
} catch (error) {
|
|
106
308
|
rmSync(contractPath, { force: true });
|
|
107
309
|
throw error;
|
|
@@ -109,9 +311,12 @@ export function freezePlan(plan, { outDir, provenance, phases }) {
|
|
|
109
311
|
const frozen = /** @type {FrozenPlan} */ ({
|
|
110
312
|
formatVersion: PLAN_FORMAT_VERSION,
|
|
111
313
|
contractDigest: contractDigest(raw),
|
|
314
|
+
// The spec's path and digest pin the plan to the exact structured spec it
|
|
315
|
+
// was drafted from, so the brief can verify the pair before reading facts.
|
|
316
|
+
...(specIdentity === undefined ? {} : { spec: specIdentity }),
|
|
112
317
|
// The declarations ride on the record rather than the contract (the
|
|
113
318
|
// contract schema takes no extra field), so the traceability a reviewer
|
|
114
|
-
// saw is readable straight off plan.json.
|
|
319
|
+
// saw is readable straight off plan.json, nodeIds included.
|
|
115
320
|
...(phaseDeclarations === undefined ? {} : { phases: phaseDeclarations }),
|
|
116
321
|
provenance: {
|
|
117
322
|
packageVersion: packageVersion(),
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Planning pipeline shape adapters: runtime availability, sizing-node input,
|
|
3
|
+
* frozen contract-node output and read-volume measurement. Separate from the
|
|
4
|
+
* pipeline loop so orchestration rounds remain about sequencing and findings.
|
|
5
|
+
*/
|
|
6
|
+
import { readFileSync } from "node:fs";
|
|
7
|
+
import { RISK_TIERS } from "./template.mjs";
|
|
8
|
+
|
|
9
|
+
/** @typedef {import("../contract/index.mjs").JsonObject} JsonObject */
|
|
10
|
+
/** @typedef {import("./pipeline.mjs").SizedPlanNode} SizedPlanNode */
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @param {Record<string, JsonObject>} runtimes
|
|
14
|
+
* @returns {Record<string, {available: true, exhaustedUntil: null}>}
|
|
15
|
+
*/
|
|
16
|
+
export function availabilityOf(runtimes) {
|
|
17
|
+
return Object.fromEntries(Object.keys(runtimes).map((id) => [id, { available: true, exhaustedUntil: null }]));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** @param {Record<string, JsonObject>} runtimes @param {string|undefined} id @returns {string} */
|
|
21
|
+
export function modelOf(runtimes, id) {
|
|
22
|
+
const model = id ? runtimes[id]?.model : undefined;
|
|
23
|
+
return typeof model === "string" ? model : "";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** @param {string[]} riskTiers @returns {string} */
|
|
27
|
+
export function highestOf(riskTiers) {
|
|
28
|
+
return riskTiers.reduce((highest, tier) => (RISK_TIERS.indexOf(tier) > RISK_TIERS.indexOf(highest) ? tier : highest), RISK_TIERS[0]);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* @param {import("./template.mjs").PlanOutputNode} node
|
|
33
|
+
* @returns {SizedPlanNode}
|
|
34
|
+
*/
|
|
35
|
+
export function toSizingNode(node) {
|
|
36
|
+
return /** @type {SizedPlanNode} */ ({
|
|
37
|
+
id: node.id,
|
|
38
|
+
dependsOn: node.dependsOn,
|
|
39
|
+
taskKind: node.taskKind,
|
|
40
|
+
riskTier: node.riskTier,
|
|
41
|
+
objective: node.objective,
|
|
42
|
+
expectedTurns: node.expectedTurns,
|
|
43
|
+
definitionOfDone: node.definitionOfDone,
|
|
44
|
+
taskPacket: {
|
|
45
|
+
readFiles: node.readFiles,
|
|
46
|
+
writeFiles: node.writeFiles,
|
|
47
|
+
scopeAcknowledged: node.scopeAcknowledged,
|
|
48
|
+
verification: node.verification,
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* @param {SizedPlanNode} node
|
|
55
|
+
* @param {string} phase
|
|
56
|
+
* @param {{worker: string|null, judge: string|null}|undefined} assignment
|
|
57
|
+
* @returns {JsonObject}
|
|
58
|
+
*/
|
|
59
|
+
export function toContractNode(node, phase, assignment) {
|
|
60
|
+
const riskTier = /** @type {string} */ (node.riskTier);
|
|
61
|
+
const gate = riskTier === "low"
|
|
62
|
+
? false
|
|
63
|
+
: {
|
|
64
|
+
review: riskTier === "high" ? "blocking" : "advisory",
|
|
65
|
+
failOn: riskTier === "high" ? ["major", "critical"] : ["critical"],
|
|
66
|
+
...(assignment?.judge ? { runtime: assignment.judge } : {}),
|
|
67
|
+
};
|
|
68
|
+
return {
|
|
69
|
+
id: node.id,
|
|
70
|
+
type: node.taskKind,
|
|
71
|
+
phase,
|
|
72
|
+
dependsOn: node.dependsOn ?? [],
|
|
73
|
+
...(assignment?.worker ? { runtime: assignment.worker } : {}),
|
|
74
|
+
taskPacket: {
|
|
75
|
+
mode: "execution",
|
|
76
|
+
objective: node.objective,
|
|
77
|
+
instructions: [node.objective],
|
|
78
|
+
readFiles: node.taskPacket.readFiles ?? [],
|
|
79
|
+
writeFiles: node.taskPacket.writeFiles ?? [],
|
|
80
|
+
scopeAcknowledged: node.taskPacket.scopeAcknowledged ?? [],
|
|
81
|
+
symbols: [],
|
|
82
|
+
decisions: [],
|
|
83
|
+
nonGoals: [],
|
|
84
|
+
verification: node.taskPacket.verification,
|
|
85
|
+
},
|
|
86
|
+
definitionOfDone: node.definitionOfDone ?? [],
|
|
87
|
+
gate,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** @param {string} path @returns {number|null} */
|
|
92
|
+
export function fileLineCount(path) {
|
|
93
|
+
try {
|
|
94
|
+
const text = readFileSync(path, "utf8");
|
|
95
|
+
if (text === "") return 0;
|
|
96
|
+
return text.split("\n").length - (text.endsWith("\n") ? 1 : 0);
|
|
97
|
+
} catch {
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
}
|