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
|
@@ -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
|
+
}
|