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.
@@ -0,0 +1,772 @@
1
+ /**
2
+ * The Campaign Brief core: the deterministic facts behind the pre-execution
3
+ * brief, derived only from the frozen plan, its validated contract and the
4
+ * structured spec that produced them.
5
+ *
6
+ * `buildBriefModel` is the refusal gate. It reads the plan bytes, checks them
7
+ * against the independent `plan.json.sha256` sidecar, then verifies the
8
+ * contract and spec digests, and only then reads facts. A missing input or a
9
+ * mismatch throws a `BriefInputError`; it never degrades into a plausible
10
+ * summary. Nothing here launches a run or writes an artifact: the model is
11
+ * data, and `src/report/campaign-brief.mjs` renders it.
12
+ *
13
+ * The identity section pins the campaign, spec baseline and digest, target git
14
+ * head, frozen plan path and contract digest. The coverage matrix lists every
15
+ * stable spec requirement id, the frozen contract nodes carrying it, each
16
+ * responsible node's declared proof or verification, and whether the
17
+ * requirement is covered, uncovered, outside this plan, or traceability
18
+ * missing. `decideBriefState` turns any coverage, work-graph or estimate gap
19
+ * into `gaps to resolve`; otherwise the brief is `ready for human review`,
20
+ * which is still not approval to execute.
21
+ */
22
+ import { readFileSync } from "node:fs";
23
+ import { dirname, isAbsolute, join, resolve } from "node:path";
24
+ import { contractDigest } from "../contract/index.mjs";
25
+ import { contentDigest, fileDigest } from "../plan/freeze.mjs";
26
+ import { parseSpec } from "../plan/spec.mjs";
27
+ import { errorCode } from "../util.mjs";
28
+ import { asArray, bulletLines, firstSentence, riskRows, successCriteriaRows, unique } from "./brief-text.mjs";
29
+ import { projectCampaignDecisions } from "./projection.mjs";
30
+
31
+ /** @typedef {import("../plan/spec.mjs").ParsedSpec} ParsedSpec */
32
+ /** @typedef {import("../plan/spec.mjs").SpecRequirement} SpecRequirement */
33
+ /** @typedef {import("./index.mjs").Projection} Projection */
34
+ /** @typedef {import("./projection.mjs").ProjectedDecision} ProjectedDecision */
35
+ /** @typedef {Record<string, any>} AnyRecord */
36
+
37
+ /** @typedef {"missing_input"|"invalid_input"|"plan_digest_mismatch"|"missing_identity"|"contract_digest_mismatch"|"spec_digest_mismatch"} BriefInputErrorCode */
38
+ /** @typedef {'covered'|'uncovered'|'outside this plan'|'traceability missing'} CoverageState */
39
+ /** @typedef {'ready for human review'|'gaps to resolve'} BriefDecisionState */
40
+ /** @typedef {{id: string, requirementIds: string[], nodeIds?: string[], deliverable: string}} BriefDeclaration */
41
+ /** @typedef {{id: string, proof: string}} BriefProofNode */
42
+ /** @typedef {{requirementId: string, title: string, declaredNodeIds: string[], nodes: BriefProofNode[], state: CoverageState, reasons: string[]}} BriefCoverageRow */
43
+ /** @typedef {{rows: BriefCoverageRow[], unknownIds: string[], unknownDeclared: string[], unknownStamped: string[], gaps: string[], specPath: string, planPath: string, covered: number, uncovered: number, outside: number, traceabilityMissing: number, total: number}} BriefCoverage */
44
+ /** @typedef {{id: string, runtimeId: string|null, model: string|null, dependsOn: string[], requirementIds: string[]}} BriefGraphNode */
45
+ /** @typedef {{from: string, to: string}} BriefGraphEdge */
46
+ /** @typedef {{node: string, prerequisites: string[]}} BriefBlockingNode */
47
+ /** @typedef {{nodes: BriefGraphNode[], edges: BriefGraphEdge[], independent: string[], blocking: BriefBlockingNode[], maxParallel: number, maxConcurrent: Record<string, number>, effectiveConcurrency: number, dispatchableTogether: string[], dispatchNote: string, gaps: string[]}} BriefGraph */
48
+ /** @typedef {{status: "range"|"insufficient data", min: number|null, max: number|null, samples: number|null, reason: string|null, sourceRuns: string[], method: string|null, provenance: string|null}} BriefMeasure */
49
+ /** @typedef {{status?: "range"|"insufficient data", min?: number|null, max?: number|null, samples?: number|null, reason?: string|null, sourceRuns?: string[], method?: string|null, provenance?: string|null}} BriefMeasureInput */
50
+ /** @typedef {{cost?: BriefMeasureInput, duration?: BriefMeasureInput, runtimes?: string[], models?: string[], effectiveConcurrency?: number, nodeCount?: number, workerCount?: number, sampleCutoff?: string|null, method?: string[], assumptions?: string[]}} BriefEstimateInput */
51
+ /** @typedef {{cost: BriefMeasure, duration: BriefMeasure, runtimes: string[], models: string[], effectiveConcurrency: number, nodeCount: number, workerCount: number, sampleCutoff: string|null, method: string[], assumptions: string[], gaps: string[]}} BriefEstimate */
52
+ /** @typedef {{measure: string, target: string, evidence: string}} BriefSuccessCriterion */
53
+ /** @typedef {{risk: string, impact: string, mitigation: string}} BriefRisk */
54
+ /** @typedef {{intent: string|null, expectedOutcome: string|null, successCriteria: BriefSuccessCriterion[], humanFacts: string[], calculatedFacts: string[], gaps: string[]}} BriefOpening */
55
+ /** @typedef {{human: string[], delegated: string[], journal: ProjectedDecision[], risks: BriefRisk[], evals: string[], gaps: string[]}} BriefDecisions */
56
+ /** @typedef {{campaign: string, specBaseline: string|null, specDigest: string, specPath: string, targetGitHead: string|null, planPath: string, planDigest: string, contractDigest: string, journalCursor: number, usageSampleCutoff: string|null}} BriefIdentity */
57
+ /** @typedef {{identity: BriefIdentity, opening: BriefOpening, coverage: BriefCoverage, graph: BriefGraph, decisions: BriefDecisions, estimate: BriefEstimate, decisionState: BriefDecisionState}} BriefModel */
58
+
59
+ /**
60
+ * Options for `buildBriefModel`. `planPath` points at the frozen `plan.json`;
61
+ * `specPath` overrides the path recorded in `plan.spec.path`, which is resolved
62
+ * against `cwd` when relative. The journal cursor and usage sample cutoff are
63
+ * recorded verbatim so the same snapshots produce the same bytes.
64
+ *
65
+ * @typedef {object} BriefBuildOptions
66
+ * @property {string} campaignId
67
+ * @property {string} planPath
68
+ * @property {string} [contractPath]
69
+ * @property {string} [sidecarPath]
70
+ * @property {string} [specPath]
71
+ * @property {string} [cwd]
72
+ * @property {number} [journalCursor]
73
+ * @property {string|null} [usageSampleCutoff]
74
+ * @property {Projection} [projection]
75
+ * @property {BriefEstimateInput} [estimate]
76
+ */
77
+
78
+ const SAMPLE_FLOOR = 5;
79
+
80
+ /**
81
+ * A refusal to build a brief from missing or mismatched inputs. The `code` names
82
+ * the exact refusal so a caller can distinguish "refreeze this plan" from "the
83
+ * sidecar does not cover these bytes".
84
+ */
85
+ export class BriefInputError extends Error {
86
+ /**
87
+ * @param {BriefInputErrorCode} code
88
+ * @param {string} message
89
+ */
90
+ constructor(code, message) {
91
+ super(message);
92
+ this.name = "BriefInputError";
93
+ /** @type {BriefInputErrorCode} */
94
+ this.code = code;
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Verify the frozen inputs and derive the deterministic brief model. Reads the
100
+ * plan, its sidecar, the contract and the spec; throws `BriefInputError` on a
101
+ * missing file, a malformed document, absent identity fields, or any digest
102
+ * mismatch, before reading a single fact out of the spec.
103
+ *
104
+ * @param {BriefBuildOptions} options
105
+ * @returns {BriefModel}
106
+ */
107
+ export function buildBriefModel(options) {
108
+ const campaignId = requireInputString(options.campaignId, "campaignId");
109
+ const planPath = requireInputString(options.planPath, "planPath");
110
+ const planText = readText(planPath, "frozen plan");
111
+ const planDigest = fileDigest(planPath);
112
+ const sidecarPath = options.sidecarPath ?? `${planPath}.sha256`;
113
+ const sidecarText = readText(sidecarPath, "plan digest sidecar");
114
+ if (planDigest !== sidecarText.trim()) {
115
+ throw new BriefInputError(
116
+ "plan_digest_mismatch",
117
+ `frozen plan ${planPath} does not match its sidecar: the file is ${planDigest}, the sidecar records ${sidecarText.trim() || "(empty)"}`,
118
+ );
119
+ }
120
+ const plan = parseJson(planText, "frozen plan");
121
+ if (plan.formatVersion !== 1) {
122
+ throw new BriefInputError("missing_identity", `frozen plan ${planPath} has no formatVersion 1; refreeze it before generating a brief`);
123
+ }
124
+ const expectedContractDigest = typeof plan.contractDigest === "string" ? plan.contractDigest : "";
125
+ if (!expectedContractDigest) {
126
+ throw new BriefInputError("missing_identity", `frozen plan ${planPath} records no contractDigest; refreeze it before generating a brief`);
127
+ }
128
+ if (!plan.spec || typeof plan.spec.path !== "string" || typeof plan.spec.digest !== "string") {
129
+ throw new BriefInputError("missing_identity", `frozen plan ${planPath} records no spec path and digest; refreeze it before generating a brief`);
130
+ }
131
+
132
+ const planDir = dirname(planPath);
133
+ const contractPath = options.contractPath ?? join(planDir, "contract.json");
134
+ const contractText = readText(contractPath, "contract");
135
+ const contract = parseJson(contractText, "contract");
136
+ const rawContractDigest = contractDigest(contract);
137
+ if (rawContractDigest !== expectedContractDigest) {
138
+ throw new BriefInputError(
139
+ "contract_digest_mismatch",
140
+ `contract ${contractPath} does not match plan.contractDigest: computed ${rawContractDigest}, plan records ${expectedContractDigest}`,
141
+ );
142
+ }
143
+
144
+ const specPath = resolveSpecPath(plan, options);
145
+ const specText = readText(specPath, "structured spec");
146
+ if (contentDigest(specText) !== plan.spec.digest) {
147
+ throw new BriefInputError(
148
+ "spec_digest_mismatch",
149
+ `structured spec ${specPath} does not match plan.spec.digest: the bytes changed since the plan froze`,
150
+ );
151
+ }
152
+
153
+ const parsedSpec = parseSpec(specText);
154
+ const contractNodes = asArray(contract.nodes);
155
+ const declarations = normalizeDeclarations(plan.phases);
156
+ const coverage = buildCoverage(parsedSpec, declarations, contractNodes, specPath, planPath);
157
+ const graph = buildGraph(contract, contractNodes);
158
+ const journalDecisions = options.projection ? projectCampaignDecisions(options.projection) : [];
159
+ const decisions = buildDecisions(parsedSpec, journalDecisions);
160
+ const estimate = buildEstimate(options.estimate, graph, options.usageSampleCutoff ?? null);
161
+ const opening = buildOpening(parsedSpec, coverage, graph, estimate, decisions);
162
+ const identity = buildIdentity({
163
+ campaign: campaignId,
164
+ plan,
165
+ planPath,
166
+ planDigest,
167
+ contractDigest: expectedContractDigest,
168
+ specPath,
169
+ specBaseline: typeof parsedSpec.frontMatter?.baseline === "string" ? parsedSpec.frontMatter.baseline : null,
170
+ journalCursor: options.journalCursor ?? 0,
171
+ usageSampleCutoff: options.usageSampleCutoff ?? null,
172
+ });
173
+ const decisionState = decideBriefState({ opening, coverage, graph, decisions, estimate });
174
+ return { identity, opening, coverage, graph, decisions, estimate, decisionState };
175
+ }
176
+
177
+ /**
178
+ * `gaps to resolve` when the coverage matrix, the work graph or the opening's
179
+ * source facts report a gap, or when either estimate measure lacks its required
180
+ * evidence; otherwise `ready for human review`. Neither state approves
181
+ * execution.
182
+ *
183
+ * @param {{opening: BriefOpening, coverage: BriefCoverage, graph: BriefGraph, decisions: BriefDecisions, estimate: BriefEstimate}} model
184
+ * @returns {BriefDecisionState}
185
+ */
186
+ export function decideBriefState(model) {
187
+ if (model.coverage.gaps.length > 0) return "gaps to resolve";
188
+ if (model.graph.gaps.length > 0) return "gaps to resolve";
189
+ if (model.decisions.gaps.length > 0) return "gaps to resolve";
190
+ if (model.opening.gaps.length > 0) return "gaps to resolve";
191
+ if (model.estimate.cost.status !== "range" || model.estimate.duration.status !== "range") return "gaps to resolve";
192
+ return "ready for human review";
193
+ }
194
+
195
+ /**
196
+ * @param {string|undefined} value
197
+ * @param {string} label
198
+ * @returns {string}
199
+ */
200
+ function requireInputString(value, label) {
201
+ if (typeof value !== "string" || !value.trim()) {
202
+ throw new BriefInputError("missing_input", `brief ${label} is required`);
203
+ }
204
+ return value;
205
+ }
206
+
207
+ /**
208
+ * @param {string} path
209
+ * @param {string} label
210
+ * @returns {string}
211
+ */
212
+ function readText(path, label) {
213
+ try {
214
+ return readFileSync(path, "utf8");
215
+ } catch (error) {
216
+ if (errorCode(error) === "ENOENT") {
217
+ throw new BriefInputError("missing_input", `brief ${label} is missing: ${path}`);
218
+ }
219
+ throw error;
220
+ }
221
+ }
222
+
223
+ /**
224
+ * @param {string} text
225
+ * @param {string} label
226
+ * @returns {AnyRecord}
227
+ */
228
+ function parseJson(text, label) {
229
+ let value;
230
+ try {
231
+ value = JSON.parse(text);
232
+ } catch {
233
+ throw new BriefInputError("invalid_input", `brief ${label} is not valid JSON`);
234
+ }
235
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
236
+ throw new BriefInputError("invalid_input", `brief ${label} must be a JSON object`);
237
+ }
238
+ return /** @type {AnyRecord} */ (value);
239
+ }
240
+
241
+ /**
242
+ * The spec path to verify: an explicit override wins, otherwise the recorded
243
+ * path, resolved against `cwd` when relative.
244
+ *
245
+ * @param {AnyRecord} plan
246
+ * @param {BriefBuildOptions} options
247
+ * @returns {string}
248
+ */
249
+ function resolveSpecPath(plan, options) {
250
+ if (typeof options.specPath === "string" && options.specPath.trim()) return options.specPath;
251
+ const recorded = plan.spec && typeof plan.spec.path === "string" ? plan.spec.path : "";
252
+ if (!recorded) throw new BriefInputError("missing_identity", "frozen plan records no spec path");
253
+ if (isAbsolute(recorded)) return recorded;
254
+ return resolve(options.cwd ?? process.cwd(), recorded);
255
+ }
256
+
257
+ /**
258
+ * @param {unknown} phases
259
+ * @returns {BriefDeclaration[]}
260
+ */
261
+ function normalizeDeclarations(phases) {
262
+ return asArray(phases).map((phase) => {
263
+ const record = /** @type {AnyRecord} */ (phase ?? {});
264
+ /** @type {BriefDeclaration} */
265
+ const declaration = {
266
+ id: String(record.id ?? ""),
267
+ requirementIds: asArray(record.requirementIds).map(String),
268
+ deliverable: String(record.deliverable ?? ""),
269
+ };
270
+ if (Array.isArray(record.nodeIds)) declaration.nodeIds = record.nodeIds.map(String);
271
+ return declaration;
272
+ });
273
+ }
274
+
275
+ /**
276
+ * The frozen nodes a declaration assigns: its explicit `nodeIds`, or — for a
277
+ * legacy declaration that predates node assignment — the contract nodes whose
278
+ * execution `phase` matches, exactly as freeze stamps them.
279
+ *
280
+ * @param {BriefDeclaration} declaration
281
+ * @param {AnyRecord[]} nodes
282
+ * @returns {string[]}
283
+ */
284
+ function declaredNodeIdsFor(declaration, nodes) {
285
+ if (declaration.nodeIds !== undefined) return declaration.nodeIds;
286
+ return nodes.filter((node) => node.phase === declaration.id).map((node) => String(node.id ?? ""));
287
+ }
288
+
289
+ /**
290
+ * Build the coverage matrix by cross-checking the spec's requirement ids
291
+ * against the frozen declarations' node ids and each node's stamped
292
+ * `requirementIds`. Unknown ids on either side are named, never dropped.
293
+ *
294
+ * @param {ParsedSpec} parsedSpec
295
+ * @param {BriefDeclaration[]} declarations
296
+ * @param {AnyRecord[]} nodes
297
+ * @param {string} specPath
298
+ * @param {string} planPath
299
+ * @returns {BriefCoverage}
300
+ */
301
+ function buildCoverage(parsedSpec, declarations, nodes, specPath, planPath) {
302
+ const nodeById = new Map(nodes.map((node) => [String(node.id ?? ""), node]));
303
+ const requirements = parsedSpec.requirements.filter((requirement) => requirement.id !== null);
304
+ const known = new Set(requirements.map((requirement) => String(requirement.id)));
305
+ const declarationsPresent = declarations.length > 0;
306
+ /** @type {BriefCoverageRow[]} */
307
+ const rows = [];
308
+ /** @type {string[]} */
309
+ const gaps = [];
310
+
311
+ for (const requirement of requirements) {
312
+ const requirementId = String(requirement.id);
313
+ const claiming = declarations.filter((declaration) => declaration.requirementIds.includes(requirementId));
314
+ const declaredNodeIds = unique(claiming.flatMap((declaration) => declaredNodeIdsFor(declaration, nodes)));
315
+ /** @type {BriefProofNode[]} */
316
+ const proofNodes = [];
317
+ /** @type {string[]} */
318
+ const reasons = [];
319
+ /** @type {CoverageState} */
320
+ let state;
321
+ if (!declarationsPresent) {
322
+ state = "traceability missing";
323
+ reasons.push("plan.json carries no requirement declarations");
324
+ } else if (claiming.length === 0) {
325
+ state = "outside this plan";
326
+ } else if (declaredNodeIds.length === 0) {
327
+ state = "uncovered";
328
+ reasons.push("declared but no frozen node is assigned");
329
+ } else {
330
+ /** @type {string[]} */
331
+ const traceability = [];
332
+ for (const nodeId of declaredNodeIds) {
333
+ const node = nodeById.get(nodeId);
334
+ if (node === undefined) {
335
+ traceability.push(`declared node ${nodeId} is not a frozen contract node`);
336
+ continue;
337
+ }
338
+ const stamped = asArray(node.requirementIds).map(String);
339
+ if (!stamped.includes(requirementId)) {
340
+ traceability.push(`node ${nodeId} is declared for ${requirementId} but its stamped requirementIds do not include it`);
341
+ continue;
342
+ }
343
+ proofNodes.push({ id: nodeId, proof: nodeProof(node) });
344
+ }
345
+ if (traceability.length > 0) {
346
+ state = "traceability missing";
347
+ reasons.push(...traceability);
348
+ } else {
349
+ state = "covered";
350
+ }
351
+ }
352
+ if (state === "uncovered" || state === "traceability missing") {
353
+ gaps.push(`${requirementId}: ${state} (${reasons.join("; ") || "no reason recorded"})`);
354
+ }
355
+ rows.push({ requirementId, title: requirement.title, declaredNodeIds, nodes: proofNodes, state, reasons });
356
+ }
357
+
358
+ const declaredRequirementIds = unique(declarations.flatMap((declaration) => declaration.requirementIds));
359
+ const stampedRequirementIds = unique(nodes.flatMap((node) => asArray(node.requirementIds).map(String)));
360
+ const unknownDeclared = declaredRequirementIds.filter((id) => !known.has(id));
361
+ const unknownStamped = stampedRequirementIds.filter((id) => !known.has(id));
362
+ const unknownIds = unique([...unknownDeclared, ...unknownStamped]);
363
+ for (const id of unknownIds) {
364
+ const where = [
365
+ unknownDeclared.includes(id) ? "declared" : null,
366
+ unknownStamped.includes(id) ? "stamped on a node" : null,
367
+ ].filter((value) => value !== null).join(" and ");
368
+ gaps.push(`unknown requirement id ${id} (${where})`);
369
+ }
370
+ for (const declaration of declarations) {
371
+ if (declaration.requirementIds.length === 0) {
372
+ gaps.push(`declaration ${declaration.id} names no requirement id`);
373
+ }
374
+ }
375
+
376
+ /** @param {CoverageState} state @returns {number} */
377
+ const counted = (state) => rows.filter((row) => row.state === state).length;
378
+ return {
379
+ rows,
380
+ unknownIds,
381
+ unknownDeclared,
382
+ unknownStamped,
383
+ gaps,
384
+ specPath,
385
+ planPath,
386
+ covered: counted("covered"),
387
+ uncovered: counted("uncovered"),
388
+ outside: counted("outside this plan"),
389
+ traceabilityMissing: counted("traceability missing"),
390
+ total: rows.length,
391
+ };
392
+ }
393
+
394
+ /**
395
+ * The actual `dependsOn` graph plus the contract's capacity limits. The
396
+ * dependency-independent set is distinct from the workers capacity actually
397
+ * lets dispatch together.
398
+ *
399
+ * @param {AnyRecord} contract
400
+ * @param {AnyRecord[]} nodes
401
+ * @returns {BriefGraph}
402
+ */
403
+ function buildGraph(contract, nodes) {
404
+ const runtimes = contract.runtimes && typeof contract.runtimes === "object" && !Array.isArray(contract.runtimes)
405
+ ? /** @type {Record<string, AnyRecord>} */ (contract.runtimes)
406
+ : {};
407
+ const ids = new Set(nodes.map((node) => String(node.id ?? "")));
408
+ /** @type {string[]} */
409
+ const gaps = [];
410
+ /** @type {BriefGraphEdge[]} */
411
+ const edges = [];
412
+ /** @type {BriefGraphNode[]} */
413
+ const graphNodes = nodes.map((node) => {
414
+ const id = String(node.id ?? "");
415
+ const dependsOn = asArray(node.dependsOn).map(String);
416
+ for (const dependency of dependsOn) {
417
+ if (dependency === id) {
418
+ gaps.push(`node ${id} depends on itself`);
419
+ } else if (!ids.has(dependency)) {
420
+ gaps.push(`node ${id} depends on unknown node ${dependency}`);
421
+ } else {
422
+ edges.push({ from: dependency, to: id });
423
+ }
424
+ }
425
+ const runtimeId = typeof node.runtime === "string"
426
+ ? node.runtime
427
+ : typeof contract.runtimeDefaults?.worker === "string" ? contract.runtimeDefaults.worker : null;
428
+ const runtime = runtimeId !== null && typeof runtimes[runtimeId] === "object" ? runtimes[runtimeId] : null;
429
+ return {
430
+ id,
431
+ runtimeId,
432
+ model: runtime && typeof runtime.model === "string" ? runtime.model : null,
433
+ dependsOn,
434
+ requirementIds: asArray(node.requirementIds).map(String),
435
+ };
436
+ });
437
+ const independent = graphNodes.filter((node) => node.dependsOn.length === 0).map((node) => node.id);
438
+ const blocking = graphNodes.filter((node) => node.dependsOn.length > 0).map((node) => ({ node: node.id, prerequisites: node.dependsOn }));
439
+ /** @type {Record<string, number>} */
440
+ const maxConcurrent = {};
441
+ for (const node of graphNodes) {
442
+ if (node.runtimeId === null) continue;
443
+ const runtime = runtimes[node.runtimeId];
444
+ const limit = runtime && Number.isInteger(runtime.maxConcurrent) && runtime.maxConcurrent > 0 ? runtime.maxConcurrent : 1;
445
+ maxConcurrent[node.runtimeId] = maxConcurrent[node.runtimeId] === undefined ? limit : Math.min(maxConcurrent[node.runtimeId], limit);
446
+ }
447
+ const maxParallel = Number.isInteger(contract.maxParallel) && contract.maxParallel > 0 ? contract.maxParallel : 1;
448
+ // Effective concurrency, and the workers that can actually be in flight
449
+ // together, are properties of the graph and the capacities -- not the size of
450
+ // the dependency-independent set. Two independent nodes still cannot run
451
+ // together when maxParallel is 1 or their shared runtime is at its cap.
452
+ const schedule = scheduleUnderCapacity(
453
+ graphNodes.map((node) => ({ id: node.id, runtimeId: node.runtimeId, dependsOn: node.dependsOn })),
454
+ () => 1,
455
+ maxParallel,
456
+ maxConcurrent,
457
+ );
458
+ const effectiveConcurrency = Math.max(1, schedule.peakConcurrency);
459
+ const dispatchableTogether = schedule.peakNodeIds;
460
+ return {
461
+ nodes: graphNodes,
462
+ edges,
463
+ independent,
464
+ blocking,
465
+ maxParallel,
466
+ maxConcurrent,
467
+ effectiveConcurrency,
468
+ dispatchableTogether,
469
+ dispatchNote: dispatchNoteFor(dispatchableTogether, graphNodes.length, maxParallel),
470
+ gaps,
471
+ };
472
+ }
473
+
474
+ /**
475
+ * Why capacity does or does not let two workers run at the same time. The note
476
+ * is the model's honest reading of the limits, so a renderer never has to
477
+ * decide whether an independent pair is "simultaneously dispatchable": with
478
+ * `maxParallel` 1 the answer is always no.
479
+ *
480
+ * @param {string[]} dispatchableTogether
481
+ * @param {number} nodeCount
482
+ * @param {number} maxParallel
483
+ * @returns {string}
484
+ */
485
+ function dispatchNoteFor(dispatchableTogether, nodeCount, maxParallel) {
486
+ if (dispatchableTogether.length >= 2) {
487
+ return `up to ${dispatchableTogether.length} workers run together under maxParallel ${maxParallel} and the per-runtime maxConcurrent caps`;
488
+ }
489
+ if (nodeCount < 2) return "fewer than two nodes are planned";
490
+ if (maxParallel <= 1) return "maxParallel is 1, so dependency-independent nodes are not simultaneously dispatchable";
491
+ return "each assigned runtime's maxConcurrent admits only one worker at a time";
492
+ }
493
+
494
+ /**
495
+ * Schedule a dependency graph under a global `maxParallel` ceiling and each
496
+ * runtime's own `maxConcurrent` ceiling, reporting the wall-clock makespan and
497
+ * the peak number of nodes running at once. `durationOf` supplies each node's
498
+ * duration, so scheduling the graph with lower and upper per-node durations
499
+ * turns a per-node range into a plan-level elapsed range. The peak is a
500
+ * property of the graph and the capacities, not of the dependency-independent
501
+ * set.
502
+ *
503
+ * @param {{id: string, runtimeId: string|null, dependsOn: string[]}[]} nodes
504
+ * @param {(node: {id: string}) => number} durationOf
505
+ * @param {number} maxParallel
506
+ * @param {Record<string, number>} maxConcurrent
507
+ * @returns {{makespanMs: number, peakConcurrency: number, peakNodeIds: string[]}}
508
+ */
509
+ export function scheduleUnderCapacity(nodes, durationOf, maxParallel, maxConcurrent) {
510
+ const byId = new Map(nodes.map((node) => [node.id, node]));
511
+ const remaining = new Map();
512
+ const dependents = new Map();
513
+ for (const node of nodes) {
514
+ const dependencies = node.dependsOn.filter((id) => byId.has(id));
515
+ remaining.set(node.id, dependencies.length);
516
+ for (const dependency of dependencies) dependents.set(dependency, [...(dependents.get(dependency) ?? []), node.id]);
517
+ }
518
+ const running = new Map();
519
+ const runtimeRunning = new Map();
520
+ const done = new Set();
521
+ const limit = Number.isInteger(maxParallel) && maxParallel > 0 ? maxParallel : 1;
522
+ let time = 0;
523
+ let peak = 0;
524
+ /** @type {string[]} */
525
+ let peakNodeIds = [];
526
+ while (done.size < nodes.length) {
527
+ for (const node of nodes) {
528
+ if (done.has(node.id) || running.has(node.id) || (remaining.get(node.id) ?? 0) > 0) continue;
529
+ if (running.size >= limit) break;
530
+ const runtimeKey = node.runtimeId ?? "";
531
+ const width = node.runtimeId !== null && Number.isInteger(maxConcurrent[node.runtimeId]) && maxConcurrent[node.runtimeId] > 0 ? maxConcurrent[node.runtimeId] : 1;
532
+ if ((runtimeRunning.get(runtimeKey) ?? 0) >= width) continue;
533
+ const duration = durationOf(node);
534
+ running.set(node.id, time + (Number.isFinite(duration) ? Math.max(0, duration) : 0));
535
+ runtimeRunning.set(runtimeKey, (runtimeRunning.get(runtimeKey) ?? 0) + 1);
536
+ if (running.size > peak) {
537
+ peak = running.size;
538
+ peakNodeIds = [...running.keys()];
539
+ }
540
+ }
541
+ if (running.size === 0) break; // a cycle or nothing dispatchable
542
+ time = Math.min(...running.values());
543
+ for (const [id, finish] of [...running]) {
544
+ if (finish !== time) continue;
545
+ running.delete(id);
546
+ runtimeRunning.set(byId.get(id)?.runtimeId ?? "", Math.max(0, (runtimeRunning.get(byId.get(id)?.runtimeId ?? "") ?? 1) - 1));
547
+ done.add(id);
548
+ for (const dependent of dependents.get(id) ?? []) remaining.set(dependent, Math.max(0, (remaining.get(dependent) ?? 1) - 1));
549
+ }
550
+ }
551
+ return { makespanMs: time, peakConcurrency: peak, peakNodeIds };
552
+ }
553
+
554
+ /**
555
+ * Human decisions and delegated decisions from the explicit spec sections and
556
+ * the active campaign journal projection; risks and planned evals only from
557
+ * this campaign's spec. A missing section, or a decision the sources cannot
558
+ * reconcile (the same decision claimed by both a human and a delegable section,
559
+ * or two active journal decisions claiming the same text under different ids),
560
+ * is a gap, never inferred from the graph.
561
+ *
562
+ * @param {ParsedSpec} parsedSpec
563
+ * @param {ProjectedDecision[]} journal
564
+ * @returns {BriefDecisions}
565
+ */
566
+ function buildDecisions(parsedSpec, journal) {
567
+ /** @param {string} name @returns {string} */
568
+ const section = (name) => parsedSpec.sections.get(name)?.body ?? "";
569
+ const human = [...bulletLines(section("human decisions")), ...bulletLines(section("settled owner decisions"))];
570
+ const delegated = bulletLines(section("delegable decisions"));
571
+ const evals = bulletLines(section("planned evals"));
572
+ const risks = riskRows(section("risks"));
573
+ /** @type {string[]} */
574
+ const gaps = [];
575
+ /** @type {[string, boolean][]} */
576
+ const sectionPresence = [
577
+ ["intent", parsedSpec.sections.has("intent")],
578
+ ["success criteria", parsedSpec.sections.has("success criteria")],
579
+ ["human decisions", parsedSpec.sections.has("human decisions")],
580
+ ["delegable decisions", parsedSpec.sections.has("delegable decisions")],
581
+ ["risks", parsedSpec.sections.has("risks")],
582
+ ["planned evals", parsedSpec.sections.has("planned evals")],
583
+ ];
584
+ for (const [name, present] of sectionPresence) {
585
+ if (!present) gaps.push(`spec has no ${name} section`);
586
+ }
587
+ if (human.length === 0) gaps.push("spec records no human decision");
588
+ // A decision listed as both human and delegable is ambiguous: the brief
589
+ // cannot claim the human kept it and delegated it. Name the conflict instead
590
+ // of silently choosing one side.
591
+ const humanTexts = new Set(human.map(normalizeDecisionText));
592
+ for (const decision of delegated) {
593
+ if (humanTexts.has(normalizeDecisionText(decision))) {
594
+ gaps.push(`conflicting decision is listed as both human and delegable: "${decision}"`);
595
+ }
596
+ }
597
+ // Two active journal decisions that carry the same text under different ids
598
+ // are a conflict of record; the brief refuses to pick one as authoritative.
599
+ /** @type {Map<string, string>} */
600
+ const journalByText = new Map();
601
+ for (const decision of journal) {
602
+ const key = normalizeDecisionText(decision.text);
603
+ const prior = journalByText.get(key);
604
+ if (prior !== undefined && prior !== decision.id) {
605
+ gaps.push(`conflicting journal decisions ${prior} and ${decision.id} record the same text: "${decision.text}"`);
606
+ } else {
607
+ journalByText.set(key, decision.id);
608
+ }
609
+ }
610
+ return { human, delegated, journal, risks, evals, gaps };
611
+ }
612
+
613
+ /**
614
+ * @param {string} text
615
+ * @returns {string}
616
+ */
617
+ function normalizeDecisionText(text) {
618
+ return text.replace(/\s+/gu, " ").trim().toLowerCase();
619
+ }
620
+
621
+ /**
622
+ * Normalize an estimate input. A `range` needs finite bounds and at least five
623
+ * comparable samples; anything less is `insufficient data` with the reason,
624
+ * never a zero or a point estimate.
625
+ *
626
+ * @param {BriefEstimateInput|undefined} input
627
+ * @param {BriefGraph} graph
628
+ * @param {string|null} sampleCutoff
629
+ * @returns {BriefEstimate}
630
+ */
631
+ function buildEstimate(input, graph, sampleCutoff) {
632
+ const cost = normalizeMeasure(input?.cost, "cost");
633
+ const duration = normalizeMeasure(input?.duration, "duration");
634
+ /** @type {string[]} */
635
+ const gaps = [];
636
+ if (cost.status !== "range") gaps.push(`cost estimate reports insufficient data: ${cost.reason ?? "no reason recorded"}`);
637
+ if (duration.status !== "range") gaps.push(`duration estimate reports insufficient data: ${duration.reason ?? "no reason recorded"}`);
638
+ if (typeof sampleCutoff !== "string" || !sampleCutoff.trim()) {
639
+ gaps.push("usage sample cutoff is not recorded");
640
+ }
641
+ return {
642
+ cost,
643
+ duration,
644
+ runtimes: unique(input?.runtimes ?? graph.nodes.map((node) => node.runtimeId).filter((id) => id !== null).map(String)),
645
+ models: unique(input?.models ?? graph.nodes.map((node) => node.model).filter((model) => model !== null).map(String)),
646
+ effectiveConcurrency: typeof input?.effectiveConcurrency === "number" ? input.effectiveConcurrency : graph.effectiveConcurrency,
647
+ nodeCount: typeof input?.nodeCount === "number" ? input.nodeCount : graph.nodes.length,
648
+ workerCount: typeof input?.workerCount === "number" ? input.workerCount : graph.nodes.length,
649
+ sampleCutoff: typeof sampleCutoff === "string" && sampleCutoff.trim() ? sampleCutoff : null,
650
+ method: input?.method ?? [],
651
+ assumptions: input?.assumptions ?? [],
652
+ gaps,
653
+ };
654
+ }
655
+
656
+ /**
657
+ * @param {BriefMeasureInput|undefined} input
658
+ * @param {string} label
659
+ * @returns {BriefMeasure}
660
+ */
661
+ function normalizeMeasure(input, label) {
662
+ /** @param {string} reason @returns {BriefMeasure} */
663
+ const insufficient = (reason) => ({
664
+ status: /** @type {const} */ ("insufficient data"),
665
+ min: null,
666
+ max: null,
667
+ samples: typeof input?.samples === "number" ? input.samples : null,
668
+ reason,
669
+ sourceRuns: input?.sourceRuns ?? [],
670
+ method: input?.method ?? null,
671
+ provenance: input?.provenance ?? null,
672
+ });
673
+ if (!input || input.status !== "range") return insufficient(input?.reason ?? `${label} range was not provided`);
674
+ if (typeof input.samples !== "number" || input.samples < SAMPLE_FLOOR) {
675
+ return insufficient(`${label} range has fewer than ${SAMPLE_FLOOR} comparable completed nodes`);
676
+ }
677
+ if (typeof input.min !== "number" || typeof input.max !== "number") {
678
+ return insufficient(`${label} range bounds are missing`);
679
+ }
680
+ return {
681
+ status: "range",
682
+ min: input.min,
683
+ max: input.max,
684
+ samples: input.samples,
685
+ reason: null,
686
+ sourceRuns: input.sourceRuns ?? [],
687
+ method: input.method ?? null,
688
+ provenance: input.provenance ?? null,
689
+ };
690
+ }
691
+
692
+ /**
693
+ * The R2 opening: one sentence of intent, expected outcome, measurable success
694
+ * criteria, and separated human-authored and calculated facts. Missing source
695
+ * facts are named as gaps rather than invented.
696
+ *
697
+ * @param {ParsedSpec} parsedSpec
698
+ * @param {BriefCoverage} coverage
699
+ * @param {BriefGraph} graph
700
+ * @param {BriefEstimate} estimate
701
+ * @param {BriefDecisions} decisions
702
+ * @returns {BriefOpening}
703
+ */
704
+ function buildOpening(parsedSpec, coverage, graph, estimate, decisions) {
705
+ const intent = firstSentence(parsedSpec.sections.get("intent")?.body ?? "");
706
+ const successCriteria = successCriteriaRows(parsedSpec.sections.get("success criteria")?.body ?? "");
707
+ const expectedOutcome = successCriteria.length > 0 ? successCriteria[0].target : null;
708
+ /** @type {string[]} */
709
+ const gaps = [];
710
+ if (intent === null) gaps.push("spec Intent section has no sentence to quote");
711
+ if (expectedOutcome === null) gaps.push("spec Success criteria section has no measurable target");
712
+
713
+ /** @type {string[]} */
714
+ const humanFacts = [
715
+ ...decisions.human.map((text) => `Human decision: ${text}`),
716
+ ...decisions.journal.map((decision) => `Journal decision [${decision.id}]: ${decision.text}${decision.at ? ` · ${decision.at}` : ""}`),
717
+ ...decisions.delegated.map((text) => `Delegated decision: ${text}`),
718
+ ...decisions.risks.map((risk) => `Risk: ${risk.risk} — mitigation: ${risk.mitigation}`),
719
+ ...decisions.evals.map((text) => `Planned eval: ${text}`),
720
+ ];
721
+ /** @type {string[]} */
722
+ const calculatedFacts = [
723
+ `Coverage: ${coverage.covered} covered, ${coverage.uncovered} uncovered, ${coverage.outside} outside this plan, ${coverage.traceabilityMissing} traceability missing of ${coverage.total} spec requirements.`,
724
+ `Work graph: ${graph.nodes.length} nodes, ${graph.edges.length} dependency edges, ${graph.independent.length} dependency-independent, effective concurrency ${graph.effectiveConcurrency} under maxParallel ${graph.maxParallel}.`,
725
+ `Estimate: cost ${estimate.cost.status}, duration ${estimate.duration.status}; sample cutoff ${estimate.sampleCutoff ?? "not recorded"}.`,
726
+ ];
727
+ return { intent, expectedOutcome, successCriteria, humanFacts, calculatedFacts, gaps };
728
+ }
729
+
730
+ /**
731
+ * @param {{campaign: string, plan: AnyRecord, planPath: string, planDigest: string, contractDigest: string, specPath: string, specBaseline: string|null, journalCursor: number, usageSampleCutoff: string|null}} input
732
+ * @returns {BriefIdentity}
733
+ */
734
+ function buildIdentity(input) {
735
+ const provenance = input.plan.provenance && typeof input.plan.provenance === "object" ? input.plan.provenance : {};
736
+ return {
737
+ campaign: input.campaign,
738
+ specBaseline: input.specBaseline,
739
+ specDigest: typeof input.plan.spec?.digest === "string" ? input.plan.spec.digest : "",
740
+ specPath: input.specPath,
741
+ targetGitHead: typeof provenance.targetGitHead === "string" ? provenance.targetGitHead : null,
742
+ planPath: input.planPath,
743
+ planDigest: input.planDigest,
744
+ contractDigest: input.contractDigest,
745
+ journalCursor: input.journalCursor,
746
+ usageSampleCutoff: input.usageSampleCutoff,
747
+ };
748
+ }
749
+
750
+ /**
751
+ * @param {AnyRecord} node
752
+ * @returns {string}
753
+ */
754
+ function nodeProof(node) {
755
+ /** @type {string[]} */
756
+ const proofs = [];
757
+ for (const item of asArray(node.definitionOfDone)) {
758
+ const record = /** @type {AnyRecord} */ (item ?? {});
759
+ const proof = record.proof && typeof record.proof === "object" ? /** @type {AnyRecord} */ (record.proof) : null;
760
+ if (proof === null) continue;
761
+ const id = typeof record.id === "string" ? `${record.id} ` : "";
762
+ const kind = typeof proof.kind === "string" ? proof.kind : "proof";
763
+ const ref = typeof proof.ref === "string" && proof.ref ? `: ${proof.ref}` : "";
764
+ proofs.push(`${id}${kind}${ref}`);
765
+ }
766
+ const packet = node.taskPacket && typeof node.taskPacket === "object" ? /** @type {AnyRecord} */ (node.taskPacket) : {};
767
+ for (const command of asArray(packet.verification)) {
768
+ const argv = command && typeof command === "object" ? /** @type {AnyRecord} */ (command).argv : undefined;
769
+ if (Array.isArray(argv)) proofs.push(argv.map(String).join(" "));
770
+ }
771
+ return proofs.length > 0 ? proofs.join("; ") : "no declared proof";
772
+ }