faberun 0.8.0 → 0.10.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/README.md +193 -31
- package/package.json +1 -1
- package/src/campaign/chain.mjs +19 -0
- package/src/campaign/journal.mjs +48 -0
- package/src/cli/brand.mjs +1 -0
- package/src/cli/campaign.mjs +15 -2
- package/src/cli/plan.mjs +142 -0
- package/src/cli.mjs +19 -3
- package/src/contract/final-verification.mjs +41 -10
- package/src/contract/task-packet.mjs +1 -1
- package/src/contract/worker-result.mjs +40 -8
- package/src/engine/lifecycle.mjs +11 -0
- package/src/engine/result-file.mjs +18 -4
- package/src/engine/scheduler.mjs +199 -26
- package/src/engine/settle.mjs +1 -1
- package/src/engine/verify.mjs +57 -4
- package/src/harnesses/protocol.mjs +91 -11
- package/src/plan/freeze.mjs +7 -3
- package/src/plan/pipeline.mjs +371 -0
- package/src/plan/template.mjs +278 -0
- package/src/repo/integrate.mjs +6 -1
- package/src/report/render.mjs +58 -58
- package/src/seat/allowance.mjs +177 -0
- package/src/web/index.html +3 -3
- package/src/web/server.mjs +2 -1
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Planning contract templates: the one-node `mode: "discovery"` contracts a
|
|
3
|
+
* plan pipeline runs outside the control session — draft, review, revise (a
|
|
4
|
+
* draft carrying the reviewer's findings), spec authoring, and spec review.
|
|
5
|
+
* Separate from freeze.mjs (which turns an already-built plan into a
|
|
6
|
+
* validated contract on disk) because this module never touches the
|
|
7
|
+
* filesystem or a model: it only assembles the JSON object `validateContract`
|
|
8
|
+
* accepts, from exactly the inputs each role may see. The reviewer's packet
|
|
9
|
+
* is the enforced case: it carries the spec, the repository facts and the
|
|
10
|
+
* artefact under review, never the author's packet, transcript or summary.
|
|
11
|
+
*/
|
|
12
|
+
import { assertObject, rejectUnknown, requireId, requireString, requireStringArray } from "../contract/assert.mjs";
|
|
13
|
+
import { CONTRACT_VERSION, PROTOCOL_SCHEMA_VERSION } from "../contract/index.mjs";
|
|
14
|
+
import { validateDefinitionOfDone } from "../contract/definition-of-done.mjs";
|
|
15
|
+
import { validateVerificationCommands } from "../contract/verification.mjs";
|
|
16
|
+
|
|
17
|
+
/** @typedef {import("../contract/index.mjs").JsonObject} JsonObject */
|
|
18
|
+
/** @typedef {"draft"|"review"|"revise"|"spec-author"|"spec-review"} PlanningKind */
|
|
19
|
+
/** @typedef {"low"|"standard"|"high"} RiskTier */
|
|
20
|
+
/** @typedef {{campaignId: string, phase: string, n: number, goal?: string, cwd?: string, runtimes: Record<string, JsonObject>, runtimeDefaults: {worker?: string, judge?: string}, specPath?: string, repoFactsPath?: string, planPath?: string, findingsPath?: string, notesPath?: string}} PlanningContractInputs */
|
|
21
|
+
/** @typedef {{id: string, objective: string, taskKind: string, riskTier: RiskTier, dependsOn: string[], readFiles: string[], writeFiles: string[], definitionOfDone: import("../contract/definition-of-done.mjs").DefinitionOfDoneItem[], verification: import("../contract/verification.mjs").VerificationCommand[]}} PlanOutputNode */
|
|
22
|
+
/** @typedef {{nodes: PlanOutputNode[], justification?: string}} PlanOutput */
|
|
23
|
+
/** @typedef {{id: string, severity: "critical"|"major"|"minor", nodeId: string, text: string}} PlanFindingOutput */
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The taskKind catalogue a draft or revise classifies against. Exported here,
|
|
27
|
+
* not read from a separate document, so `TASK_KIND_CATALOGUE_PATH` (this
|
|
28
|
+
* module's own repo-relative path) is a real, always-present file a
|
|
29
|
+
* closed-context worker can be told to read for the authoritative list.
|
|
30
|
+
*/
|
|
31
|
+
export const TASK_KINDS = Object.freeze(["docs", "implement", "test", "refactor", "infra", "judge"]);
|
|
32
|
+
|
|
33
|
+
/** The risk tiers a draft or revise classifies against. */
|
|
34
|
+
export const RISK_TIERS = Object.freeze(["low", "standard", "high"]);
|
|
35
|
+
|
|
36
|
+
/** This module's own repo-relative path: the taskKind catalogue's home. */
|
|
37
|
+
export const TASK_KIND_CATALOGUE_PATH = "src/plan/template.mjs";
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Which of the caller's `runtimeDefaults` roles resolves this contract's
|
|
41
|
+
* single node. A draft or revise is authored by the worker role; a review or
|
|
42
|
+
* spec-review is graded by the judge role — there is no gate on this
|
|
43
|
+
* single-node contract, so the role only decides which runtime id the node
|
|
44
|
+
* itself carries.
|
|
45
|
+
*
|
|
46
|
+
* @type {Record<PlanningKind, "worker"|"judge">}
|
|
47
|
+
*/
|
|
48
|
+
const KIND_ROLE = Object.freeze({
|
|
49
|
+
draft: "worker",
|
|
50
|
+
revise: "worker",
|
|
51
|
+
"spec-author": "worker",
|
|
52
|
+
review: "judge",
|
|
53
|
+
"spec-review": "judge",
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
/** @type {Record<PlanningKind, string[]>} */
|
|
57
|
+
const REQUIRED_INPUTS = Object.freeze({
|
|
58
|
+
draft: ["specPath", "repoFactsPath"],
|
|
59
|
+
revise: ["specPath", "repoFactsPath", "findingsPath"],
|
|
60
|
+
review: ["specPath", "repoFactsPath", "planPath"],
|
|
61
|
+
"spec-author": ["notesPath"],
|
|
62
|
+
"spec-review": ["specPath"],
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
const PLAN_OUTPUT_SHAPE = "{nodes: [{id, objective, taskKind, riskTier, dependsOn, readFiles, writeFiles, definitionOfDone, verification}], justification?}";
|
|
66
|
+
const FINDINGS_SHAPE = "[{id, severity, nodeId, text}]";
|
|
67
|
+
|
|
68
|
+
/** @type {Record<PlanningKind, string>} */
|
|
69
|
+
const OBJECTIVES = Object.freeze({
|
|
70
|
+
draft: "Draft an execution plan for this phase: classify every node's taskKind and riskTier from the spec and the repository facts, and propose the dependency graph.",
|
|
71
|
+
revise: "Revise the plan to resolve every one of the reviewer's findings, keeping the same classification and graph shape as a fresh draft.",
|
|
72
|
+
review: "Review this plan against the spec and the repository facts, and report only findings.",
|
|
73
|
+
"spec-author": "Turn free notes into a structured spec document following the spec format.",
|
|
74
|
+
"spec-review": "Review this spec for traceability and completeness, and report only findings.",
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
/** @type {Record<PlanningKind, string[]>} */
|
|
78
|
+
const INSTRUCTIONS = Object.freeze({
|
|
79
|
+
draft: [
|
|
80
|
+
`Consult ${TASK_KIND_CATALOGUE_PATH}'s exported TASK_KINDS before classifying any node; taskKind must be one of that catalogue and riskTier must be one of ${RISK_TIERS.join(", ")}.`,
|
|
81
|
+
`Return exactly one worker-result JSON object. Put the plan in output.plan as ${PLAN_OUTPUT_SHAPE} and nothing else in output.`,
|
|
82
|
+
"Never name a runtime, harness, model, or vendor anywhere in output.plan. taskKind and riskTier are the only classification a draft makes; a routing table assigns a runtime afterward, from those two fields alone.",
|
|
83
|
+
],
|
|
84
|
+
revise: [
|
|
85
|
+
"Read the findings and resolve every one; do not leave a critical or major finding unaddressed.",
|
|
86
|
+
`Consult ${TASK_KIND_CATALOGUE_PATH}'s exported TASK_KINDS before classifying any node; taskKind must be one of that catalogue and riskTier must be one of ${RISK_TIERS.join(", ")}.`,
|
|
87
|
+
`Return exactly one worker-result JSON object. Put the revised plan in output.plan as ${PLAN_OUTPUT_SHAPE} and nothing else in output.`,
|
|
88
|
+
"Never name a runtime, harness, model, or vendor anywhere in output.plan.",
|
|
89
|
+
],
|
|
90
|
+
review: [
|
|
91
|
+
"You are given only the spec, the repository facts, and the plan under review; you have not seen how the plan was produced or any reasoning behind it. Review the artefact alone.",
|
|
92
|
+
`Return exactly one worker-result JSON object. Put your findings in output.findings as ${FINDINGS_SHAPE} and nothing else in output.`,
|
|
93
|
+
"severity must be one of critical, major, minor. Every finding's nodeId must name a node id that actually appears in the plan under review.",
|
|
94
|
+
],
|
|
95
|
+
"spec-author": [
|
|
96
|
+
"Read the notes and turn them into a structured spec document: front matter, Intent, Requirements (each with a stable R<n> id and a proof), Non-goals, Constraints, Success criteria, and Risks.",
|
|
97
|
+
"Return exactly one worker-result JSON object. Put the authored spec text, as one markdown document, in output.spec and nothing else in output.",
|
|
98
|
+
],
|
|
99
|
+
"spec-review": [
|
|
100
|
+
"You are given only the spec under review; you have not seen the author's notes or reasoning. Review the document alone.",
|
|
101
|
+
`Return exactly one worker-result JSON object. Put your findings in output.findings as ${FINDINGS_SHAPE} and nothing else in output.`,
|
|
102
|
+
"severity must be one of critical, major, minor. Every finding's nodeId must name the requirement id, or section heading, it concerns.",
|
|
103
|
+
],
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
/** @type {Record<PlanningKind, string[]>} */
|
|
107
|
+
const NON_GOALS = Object.freeze({
|
|
108
|
+
draft: ["Assigning a runtime, harness, or model to any node."],
|
|
109
|
+
revise: ["Assigning a runtime, harness, or model to any node.", "Reopening a finding the reviewer did not raise."],
|
|
110
|
+
review: ["Proposing a replacement plan.", "Assigning a runtime, harness, or model to any node."],
|
|
111
|
+
"spec-author": ["Declaring nodes, phases, or architecture in the spec."],
|
|
112
|
+
"spec-review": ["Proposing a replacement spec."],
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* @param {PlanningKind} kind
|
|
117
|
+
* @param {PlanningContractInputs} inputs
|
|
118
|
+
* @returns {string[]}
|
|
119
|
+
*/
|
|
120
|
+
function readFilesForKind(kind, inputs) {
|
|
121
|
+
if (kind === "draft") return [/** @type {string} */ (inputs.specPath), /** @type {string} */ (inputs.repoFactsPath), TASK_KIND_CATALOGUE_PATH];
|
|
122
|
+
if (kind === "revise") {
|
|
123
|
+
return [/** @type {string} */ (inputs.specPath), /** @type {string} */ (inputs.repoFactsPath), TASK_KIND_CATALOGUE_PATH, /** @type {string} */ (inputs.findingsPath)];
|
|
124
|
+
}
|
|
125
|
+
if (kind === "review") return [/** @type {string} */ (inputs.specPath), /** @type {string} */ (inputs.repoFactsPath), /** @type {string} */ (inputs.planPath)];
|
|
126
|
+
if (kind === "spec-author") return [/** @type {string} */ (inputs.notesPath)];
|
|
127
|
+
return [/** @type {string} */ (inputs.specPath)];
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Build one of the planning pipeline's one-node discovery contracts. Pure:
|
|
132
|
+
* no file is read or written, and no model is invoked. The returned object is
|
|
133
|
+
* the raw, not-yet-validated contract JSON `validateContract` accepts.
|
|
134
|
+
*
|
|
135
|
+
* @param {PlanningKind} kind
|
|
136
|
+
* @param {PlanningContractInputs} inputs
|
|
137
|
+
* @returns {JsonObject}
|
|
138
|
+
*/
|
|
139
|
+
export function buildPlanningContract(kind, inputs) {
|
|
140
|
+
const required = REQUIRED_INPUTS[kind];
|
|
141
|
+
if (!required) throw new TypeError(`buildPlanningContract: unknown kind ${kind}`);
|
|
142
|
+
requireId(inputs.campaignId, "inputs.campaignId");
|
|
143
|
+
requireString(inputs.phase, "inputs.phase");
|
|
144
|
+
if (!Number.isInteger(inputs.n) || inputs.n <= 0) throw new TypeError("inputs.n must be a positive integer");
|
|
145
|
+
assertObject(inputs.runtimes, "inputs.runtimes");
|
|
146
|
+
assertObject(inputs.runtimeDefaults ?? {}, "inputs.runtimeDefaults");
|
|
147
|
+
const inputRecord = /** @type {Record<string, unknown>} */ (inputs);
|
|
148
|
+
for (const field of required) requireString(inputRecord[field], `inputs.${field}`);
|
|
149
|
+
|
|
150
|
+
const readFiles = readFilesForKind(kind, inputs);
|
|
151
|
+
const role = KIND_ROLE[kind];
|
|
152
|
+
const runtimeId = (inputs.runtimeDefaults ?? {})[role];
|
|
153
|
+
|
|
154
|
+
/** @type {JsonObject} */
|
|
155
|
+
const taskPacket = {
|
|
156
|
+
mode: "discovery",
|
|
157
|
+
objective: OBJECTIVES[kind],
|
|
158
|
+
instructions: INSTRUCTIONS[kind],
|
|
159
|
+
readFiles,
|
|
160
|
+
writeFiles: [],
|
|
161
|
+
symbols: [],
|
|
162
|
+
decisions: [],
|
|
163
|
+
nonGoals: NON_GOALS[kind],
|
|
164
|
+
verification: [],
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
return {
|
|
168
|
+
schemaVersion: PROTOCOL_SCHEMA_VERSION,
|
|
169
|
+
contractVersion: CONTRACT_VERSION,
|
|
170
|
+
id: `${inputs.campaignId}-plan-${inputs.phase}-${kind}-${inputs.n}`,
|
|
171
|
+
campaignId: inputs.campaignId,
|
|
172
|
+
goal: inputs.goal ?? `Plan ${kind} for phase ${inputs.phase}`,
|
|
173
|
+
cwd: inputs.cwd ?? ".",
|
|
174
|
+
runtimes: inputs.runtimes,
|
|
175
|
+
runtimeDefaults: inputs.runtimeDefaults ?? {},
|
|
176
|
+
nodes: [
|
|
177
|
+
{
|
|
178
|
+
id: kind,
|
|
179
|
+
type: kind,
|
|
180
|
+
phase: inputs.phase,
|
|
181
|
+
dependsOn: [],
|
|
182
|
+
...(runtimeId === undefined ? {} : { runtime: runtimeId }),
|
|
183
|
+
taskPacket,
|
|
184
|
+
gate: false,
|
|
185
|
+
},
|
|
186
|
+
],
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const PLAN_FIELDS = new Set(["nodes", "justification"]);
|
|
191
|
+
const PLAN_NODE_FIELDS = new Set(["id", "objective", "taskKind", "riskTier", "dependsOn", "readFiles", "writeFiles", "definitionOfDone", "verification"]);
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Validate a draft or revise worker's `output.plan`. Rejects a node naming a
|
|
195
|
+
* runtime, harness, model, or vendor (an unknown field, since a plan node's
|
|
196
|
+
* shape never includes one) and a node missing taskKind or riskTier.
|
|
197
|
+
*
|
|
198
|
+
* @param {unknown} plan
|
|
199
|
+
* @returns {PlanOutput}
|
|
200
|
+
*/
|
|
201
|
+
export function validatePlanOutput(plan) {
|
|
202
|
+
assertObject(plan, "plan");
|
|
203
|
+
const record = /** @type {Record<string, unknown>} */ (plan);
|
|
204
|
+
rejectUnknown(record, PLAN_FIELDS, "plan");
|
|
205
|
+
if (!Array.isArray(record.nodes) || record.nodes.length === 0) {
|
|
206
|
+
throw new TypeError("plan.nodes must be a non-empty array");
|
|
207
|
+
}
|
|
208
|
+
const nodes = record.nodes.map((node, index) => {
|
|
209
|
+
const label = `plan.nodes[${index}]`;
|
|
210
|
+
assertObject(node, label);
|
|
211
|
+
const nodeRecord = /** @type {Record<string, unknown>} */ (node);
|
|
212
|
+
rejectUnknown(nodeRecord, PLAN_NODE_FIELDS, label);
|
|
213
|
+
requireId(nodeRecord.id, `${label}.id`);
|
|
214
|
+
requireString(nodeRecord.objective, `${label}.objective`);
|
|
215
|
+
if (typeof nodeRecord.taskKind !== "string" || !TASK_KINDS.includes(nodeRecord.taskKind)) {
|
|
216
|
+
throw new TypeError(`${label}.taskKind must be one of ${TASK_KINDS.join(", ")}`);
|
|
217
|
+
}
|
|
218
|
+
if (typeof nodeRecord.riskTier !== "string" || !RISK_TIERS.includes(nodeRecord.riskTier)) {
|
|
219
|
+
throw new TypeError(`${label}.riskTier must be one of ${RISK_TIERS.join(", ")}`);
|
|
220
|
+
}
|
|
221
|
+
const dependsOn = nodeRecord.dependsOn ?? [];
|
|
222
|
+
requireStringArray(dependsOn, `${label}.dependsOn`);
|
|
223
|
+
const readFiles = nodeRecord.readFiles ?? [];
|
|
224
|
+
requireStringArray(readFiles, `${label}.readFiles`);
|
|
225
|
+
const writeFiles = nodeRecord.writeFiles ?? [];
|
|
226
|
+
requireStringArray(writeFiles, `${label}.writeFiles`);
|
|
227
|
+
const definitionOfDone = validateDefinitionOfDone(nodeRecord.definitionOfDone ?? [], `${label}.definitionOfDone`);
|
|
228
|
+
const verification = validateVerificationCommands(nodeRecord.verification ?? [], `${label}.verification`);
|
|
229
|
+
return /** @type {PlanOutputNode} */ ({
|
|
230
|
+
id: /** @type {string} */ (nodeRecord.id),
|
|
231
|
+
objective: /** @type {string} */ (nodeRecord.objective),
|
|
232
|
+
taskKind: /** @type {string} */ (nodeRecord.taskKind),
|
|
233
|
+
riskTier: /** @type {RiskTier} */ (nodeRecord.riskTier),
|
|
234
|
+
dependsOn: /** @type {string[]} */ (dependsOn),
|
|
235
|
+
readFiles: /** @type {string[]} */ (readFiles),
|
|
236
|
+
writeFiles: /** @type {string[]} */ (writeFiles),
|
|
237
|
+
definitionOfDone,
|
|
238
|
+
verification,
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
if (record.justification !== undefined) requireString(record.justification, "plan.justification");
|
|
242
|
+
return {
|
|
243
|
+
nodes,
|
|
244
|
+
...(record.justification === undefined ? {} : { justification: /** @type {string} */ (record.justification) }),
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const FINDING_FIELDS = new Set(["id", "severity", "nodeId", "text"]);
|
|
249
|
+
const FINDING_SEVERITIES = new Set(["critical", "major", "minor"]);
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Validate a review or spec-review worker's `output.findings`. A finding
|
|
253
|
+
* without `severity` or `nodeId` is invalid.
|
|
254
|
+
*
|
|
255
|
+
* @param {unknown} findings
|
|
256
|
+
* @returns {PlanFindingOutput[]}
|
|
257
|
+
*/
|
|
258
|
+
export function validateFindings(findings) {
|
|
259
|
+
if (!Array.isArray(findings)) throw new TypeError("findings must be an array");
|
|
260
|
+
return findings.map((finding, index) => {
|
|
261
|
+
const label = `findings[${index}]`;
|
|
262
|
+
assertObject(finding, label);
|
|
263
|
+
const record = /** @type {Record<string, unknown>} */ (finding);
|
|
264
|
+
rejectUnknown(record, FINDING_FIELDS, label);
|
|
265
|
+
requireId(record.id, `${label}.id`);
|
|
266
|
+
if (typeof record.severity !== "string" || !FINDING_SEVERITIES.has(record.severity)) {
|
|
267
|
+
throw new TypeError(`${label}.severity must be one of critical, major, minor`);
|
|
268
|
+
}
|
|
269
|
+
requireString(record.nodeId, `${label}.nodeId`);
|
|
270
|
+
requireString(record.text, `${label}.text`);
|
|
271
|
+
return /** @type {PlanFindingOutput} */ ({
|
|
272
|
+
id: /** @type {string} */ (record.id),
|
|
273
|
+
severity: /** @type {"critical"|"major"|"minor"} */ (record.severity),
|
|
274
|
+
nodeId: /** @type {string} */ (record.nodeId),
|
|
275
|
+
text: /** @type {string} */ (record.text),
|
|
276
|
+
});
|
|
277
|
+
});
|
|
278
|
+
}
|
package/src/repo/integrate.mjs
CHANGED
|
@@ -503,7 +503,7 @@ export function promoteRun({ repo, runId, landBranch, runHead, baseSha, finalVer
|
|
|
503
503
|
if (!head) throw refusal(`refusing to promote ${runId}: the run ref is unavailable`, "run_ref_missing");
|
|
504
504
|
const checkedOutAt = worktreeCheckedOutAt(repo, landBranch);
|
|
505
505
|
if (checkedOutAt) {
|
|
506
|
-
throw refusal(`refusing to promote ${landBranch}: it is checked out in ${checkedOutAt}`, "land_branch_checked_out");
|
|
506
|
+
throw refusal(`refusing to promote ${landBranch}: it is checked out in ${checkedOutAt}; ${landBranchCheckedOutRemedy(checkedOutAt)}`, "land_branch_checked_out");
|
|
507
507
|
}
|
|
508
508
|
const branchRef = `refs/heads/${landBranch}`;
|
|
509
509
|
const current = gitHead(repo, branchRef);
|
|
@@ -540,6 +540,11 @@ function refusal(message, code) {
|
|
|
540
540
|
return Object.assign(new Error(message), { code });
|
|
541
541
|
}
|
|
542
542
|
|
|
543
|
+
/** @param {string} checkedOutAt @returns {string} */
|
|
544
|
+
function landBranchCheckedOutRemedy(checkedOutAt) {
|
|
545
|
+
return `detach the checkout at ${checkedOutAt} or run the coordinator from a worktree`;
|
|
546
|
+
}
|
|
547
|
+
|
|
543
548
|
/** @param {string} path @returns {string} */
|
|
544
549
|
function readTextFile(path) {
|
|
545
550
|
return readFileSync(path, "utf8");
|
package/src/report/render.mjs
CHANGED
|
@@ -26,7 +26,7 @@ const POINTER_ATTENTION_CHARS = 80;
|
|
|
26
26
|
/** @typedef {{costUsd: number|null, costProvenance: CostProvenance, inputTokens: number, outputTokens: number, cacheReadInputTokens: number, pricedInvocations: number, unpricedInvocations: number}} RoleUsage */
|
|
27
27
|
/** @typedef {{inputTokens: number|null, outputTokens: number|null, cacheReadInputTokens: number|null}} StatusPayloadUsage */
|
|
28
28
|
/** @typedef {{index: number, total: number, argv: string}} VerificationProgress */
|
|
29
|
-
/** @typedef {{id: string, status: NodeStatus, phase: string|null, executionPhase: string|null, runtime: string|null, workerRuntime: string|null, continuation: string, attempt: number, revisions: number, startedAt: string|null, updatedAt: string|null, usage: StatusPayloadUsage|null, costUsd: number|null, verdict: string|null, pendingHandoff: {runtime: string, reason: string}|null, note: string|null, scopeFindings: string[]|null, errorCode: string|null, blockedBy: string[], verificationProgress: VerificationProgress|null, declaredReadBytes: number|null}} StatusPayloadNode */
|
|
29
|
+
/** @typedef {{id: string, status: NodeStatus, phase: string|null, executionPhase: string|null, runtime: string|null, workerRuntime: string|null, continuation: string, attempt: number, revisions: number, startedAt: string|null, updatedAt: string|null, usage: StatusPayloadUsage|null, costUsd: number|null, verdict: string|null, gateOutcome: "passed"|"rejected"|null, pendingHandoff: {runtime: string, reason: string}|null, note: string|null, scopeFindings: string[]|null, errorCode: string|null, blockedBy: string[], verificationProgress: VerificationProgress|null, declaredReadBytes: number|null}} StatusPayloadNode */
|
|
30
30
|
/** @typedef {{schemaVersion: 1, run: string, contractId: string, campaignId: string, goal: string, usage: {inputTokens: number, outputTokens: number, cacheReadInputTokens: number, costUsd: number|null}, roles: {worker: RoleUsage, judge: RoleUsage}, controller: JsonObject, identityWarnings: string[], summary: string, nodes: StatusPayloadNode[]}} StatusPayload */
|
|
31
31
|
|
|
32
32
|
/** The glyph each terminal state prints in a status table. */
|
|
@@ -43,13 +43,11 @@ export const MARK = {
|
|
|
43
43
|
};
|
|
44
44
|
|
|
45
45
|
/**
|
|
46
|
-
* `status <run-dir>`:
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
* `
|
|
50
|
-
*
|
|
51
|
-
* used only for the two facts the payload does not carry: throwing on an
|
|
52
|
-
* invalid persisted snapshot, and `controllerStatus`'s lock read.
|
|
46
|
+
* `status <run-dir>`: needs-you, now, nodes, cost, in the dashboard's order
|
|
47
|
+
* (TECH-SPEC lean, section 4's last paragraph). Every cell comes from the same
|
|
48
|
+
* payload `status --json` emits (`buildStatusPayload`); `nodes` and
|
|
49
|
+
* `identityWarnings` from `loadRun` cover only what the payload does not:
|
|
50
|
+
* throwing on an invalid snapshot, and `controllerStatus`'s lock read.
|
|
53
51
|
*
|
|
54
52
|
* @param {string} runDir
|
|
55
53
|
* @returns {string}
|
|
@@ -75,7 +73,7 @@ export function renderStatus(runDir) {
|
|
|
75
73
|
const widths = [3, 24, 9, 3, 28, 8, 6, 6, 6, 10, 9, MAX_NOTE_LENGTH];
|
|
76
74
|
/** @type {(cells: unknown[]) => string} */
|
|
77
75
|
const row = (cells) => cells.map((cell, i) => fit(String(cell ?? ""), widths[i])).join(" ");
|
|
78
|
-
lines.push("```", row(["", "NODE", "STATE", "TRY", "RUNTIME", "ELAPSED", "IN", "CACHE", "OUT", "USD", "
|
|
76
|
+
lines.push("```", row(["", "NODE", "STATE", "TRY", "RUNTIME", "ELAPSED", "IN", "CACHE", "OUT", "USD", "GATE", "NOTE"]), row(widths.map((width) => "-".repeat(width))));
|
|
79
77
|
for (const node of payload.nodes) {
|
|
80
78
|
lines.push(row([
|
|
81
79
|
MARK[node.status] ?? "[?]",
|
|
@@ -88,7 +86,7 @@ export function renderStatus(runDir) {
|
|
|
88
86
|
compactTokens(node.usage?.cacheReadInputTokens),
|
|
89
87
|
compactTokens(node.usage?.outputTokens),
|
|
90
88
|
compactCost(node.costUsd),
|
|
91
|
-
node.
|
|
89
|
+
node.gateOutcome ?? "-",
|
|
92
90
|
node.note ?? "-",
|
|
93
91
|
]));
|
|
94
92
|
}
|
|
@@ -98,12 +96,10 @@ export function renderStatus(runDir) {
|
|
|
98
96
|
}
|
|
99
97
|
|
|
100
98
|
/**
|
|
101
|
-
* The node the operator should look at right now, formatted the
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
* appends which one, `k/n`, and its argv, so a minutes-long suite is not
|
|
106
|
-
* silent between ticks.
|
|
99
|
+
* The node the operator should look at right now, formatted like the
|
|
100
|
+
* dashboard's now strip (TECH-SPEC lean, section 4, item 1): elapsed time and
|
|
101
|
+
* cost so far, or idle once every node has settled. A node awaiting a
|
|
102
|
+
* controller verification command appends which one, `k/n`, and its argv.
|
|
107
103
|
*
|
|
108
104
|
* @param {StatusPayload} payload
|
|
109
105
|
* @param {number} now epoch ms
|
|
@@ -114,7 +110,8 @@ function nowLine(payload, now) {
|
|
|
114
110
|
if (active) {
|
|
115
111
|
const progress = active.verificationProgress;
|
|
116
112
|
const verification = progress ? ` · verification ${progress.index}/${progress.total} · ${progress.argv}` : "";
|
|
117
|
-
|
|
113
|
+
const candidate = !progress && active.executionPhase === "candidate" ? " · candidate verification" : "";
|
|
114
|
+
return `now: ${active.id} ${active.status} (${formatElapsed(active, now)}) · ${active.runtime ?? "-"} · ${compactCost(active.costUsd)}${verification}${candidate}`;
|
|
118
115
|
}
|
|
119
116
|
const allTerminal = payload.nodes.every((node) => SUCCESS.has(node.status));
|
|
120
117
|
return allTerminal ? `now: idle · run done · ${compactCost(payload.usage.costUsd)}` : "now: idle";
|
|
@@ -134,6 +131,27 @@ function verificationProgress(node) {
|
|
|
134
131
|
return verification?.progress ?? null;
|
|
135
132
|
}
|
|
136
133
|
|
|
134
|
+
/** Set by `verifyCandidateWorkspace` (engine/verify.mjs) for the run of the sealed candidate's own re-verification pass. @param {NodeSnapshot} node @returns {boolean} */
|
|
135
|
+
function candidateVerificationActive(node) {
|
|
136
|
+
const verification = /** @type {{candidate?: boolean}|null|undefined} */ (node.verification);
|
|
137
|
+
return verification?.candidate === true;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** `candidate` while re-verifying the sealed candidate, `verification` mid-attempt-verification, else the persisted phase; the two never overlap. @param {NodeSnapshot} node @returns {string|null} */
|
|
141
|
+
function executionPhaseOf(node) {
|
|
142
|
+
if (candidateVerificationActive(node)) return "candidate";
|
|
143
|
+
if (verificationProgress(node)) return "verification";
|
|
144
|
+
return node.phase;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** What the gate decided (independent of `verdict`: an advisory review or a below-threshold fail verdict still accepts the node, engine/review.mjs `applyJudgeResult`). @param {NodeSnapshot} node @returns {"passed"|"rejected"|null} */
|
|
148
|
+
export function gateOutcome(node) {
|
|
149
|
+
if (!node.gate) return null;
|
|
150
|
+
if (SUCCESS.has(node.status)) return "passed";
|
|
151
|
+
if (node.status === "failed" || node.status === "exhausted") return "rejected";
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
|
|
137
155
|
/**
|
|
138
156
|
* A node's wall-clock elapsed time: `startedAt` to `updatedAt` once it has
|
|
139
157
|
* settled into a terminal state, `startedAt` to `now` while it is still
|
|
@@ -216,7 +234,7 @@ function buildStatusPayload(runDir, contract, nodes, identityWarnings, usage) {
|
|
|
216
234
|
// A running controller verification command does not move
|
|
217
235
|
// `node.phase` (it stays `worker`, the phase that dispatched it), so
|
|
218
236
|
// the surface that shows it is computed here rather than persisted.
|
|
219
|
-
executionPhase:
|
|
237
|
+
executionPhase: executionPhaseOf(node),
|
|
220
238
|
runtime: node.runtime ? `${node.runtime.harness}/${node.runtime.model}` : null,
|
|
221
239
|
workerRuntime: workerRuntimeLabel(node),
|
|
222
240
|
continuation: continuationMode(node),
|
|
@@ -227,6 +245,7 @@ function buildStatusPayload(runDir, contract, nodes, identityWarnings, usage) {
|
|
|
227
245
|
usage: node.usage ? { inputTokens: node.usage.inputTokens ?? null, outputTokens: node.usage.outputTokens ?? null, cacheReadInputTokens: node.usage.cacheReadInputTokens ?? null } : null,
|
|
228
246
|
costUsd: typeof node.costUsd === "number" ? node.costUsd : null,
|
|
229
247
|
verdict: node.gate?.verdict ?? null,
|
|
248
|
+
gateOutcome: gateOutcome(node),
|
|
230
249
|
pendingHandoff: pendingHandoff(node),
|
|
231
250
|
note: statusNote(node),
|
|
232
251
|
scopeFindings: node.scopeFindings?.unexpectedPaths ?? null,
|
|
@@ -256,16 +275,11 @@ function activeStatusNode(nodes) {
|
|
|
256
275
|
}
|
|
257
276
|
|
|
258
277
|
/**
|
|
259
|
-
* The bounded pointer record written to `.runs/status.json
|
|
260
|
-
*
|
|
261
|
-
*
|
|
262
|
-
*
|
|
263
|
-
*
|
|
264
|
-
* no clock and only jq's builtin `now` can compute an age from a live clock,
|
|
265
|
-
* and neither path needs a `date` process to read an integer. `elapsedSec`
|
|
266
|
-
* is likewise precomputed here (as of `generatedAt`, not live) so the
|
|
267
|
-
* statusline never has to subtract two timestamps to show it — it just
|
|
268
|
-
* prints the integer, whichever reader it is.
|
|
278
|
+
* The bounded pointer record written to `.runs/status.json`, for a quick
|
|
279
|
+
* ambient read without opening the per-run status.json (bounded like the old
|
|
280
|
+
* heartbeat.json). `generatedAt` is unix seconds, not ISO, and `elapsedSec` is
|
|
281
|
+
* precomputed from it, since the no-jq statusline fallback has no clock to
|
|
282
|
+
* subtract two timestamps with.
|
|
269
283
|
*
|
|
270
284
|
* @param {JsonObject} payload the per-run status.json payload
|
|
271
285
|
* @param {NodeSnapshot[]} nodes
|
|
@@ -340,10 +354,7 @@ export function writeStatusArtifacts(runDir, runsDir, contract, states) {
|
|
|
340
354
|
*/
|
|
341
355
|
export function controllerStatus(runDir, nodes) {
|
|
342
356
|
const lock = readLock(runDir);
|
|
343
|
-
// The heartbeat's `at`
|
|
344
|
-
// one are distinguishable on disk. Before this it was hard-coded null here
|
|
345
|
-
// and computed from node updates only once the lock was already stale, which
|
|
346
|
-
// is why all 47 recorded status.json files reported a null lastTick.
|
|
357
|
+
// The heartbeat's `at` distinguishes a working controller from a dead one on disk.
|
|
347
358
|
const heartbeat = readHeartbeat(runDir);
|
|
348
359
|
const lastTick = typeof heartbeat?.at === "string" ? heartbeat.at : null;
|
|
349
360
|
if (!lock || /** @type {{invalid?: true}} */ (lock).invalid) {
|
|
@@ -473,14 +484,11 @@ export function renderFindings(runDir) {
|
|
|
473
484
|
}
|
|
474
485
|
|
|
475
486
|
/**
|
|
476
|
-
* A node the worker itself stopped on, rendered as the repair it asks for
|
|
477
|
-
*
|
|
478
|
-
* `
|
|
479
|
-
*
|
|
480
|
-
*
|
|
481
|
-
* where three nodes were blocked and the command printed one line saying so.
|
|
482
|
-
* The question is already structured, so the repair is mechanical: put the
|
|
483
|
-
* named paths in the packet's `readFiles` and take a new run id.
|
|
487
|
+
* A node the worker itself stopped on, rendered as the repair it asks for:
|
|
488
|
+
* `findings` used to answer only gate exhaustion, so a run whose nodes all
|
|
489
|
+
* stopped on `blocked_context` reported nothing to act on (measured on a
|
|
490
|
+
* four-node campaign with three nodes blocked). The repair is mechanical: put
|
|
491
|
+
* the named paths in the packet's `readFiles` and take a new run id.
|
|
484
492
|
*
|
|
485
493
|
* @param {NodeSnapshot} node
|
|
486
494
|
* @returns {string|null}
|
|
@@ -553,16 +561,11 @@ function continuationMode(node) {
|
|
|
553
561
|
}
|
|
554
562
|
|
|
555
563
|
/**
|
|
556
|
-
* Who produced this node's work, for the RUNTIME column
|
|
557
|
-
*
|
|
558
|
-
*
|
|
559
|
-
*
|
|
560
|
-
*
|
|
561
|
-
* were five different harnesses rendered as though three of them had never
|
|
562
|
-
* run (measured 2026-09-13). The invocation ledger keeps both roles, so the
|
|
563
|
-
* label is derived from it: the worker that ran, falling back to the live
|
|
564
|
-
* runtime for a node that has not dispatched one yet. The judge is not lost —
|
|
565
|
-
* it owns the VERDICT column, and `nowLine` still names whatever is running.
|
|
564
|
+
* Who produced this node's work, for the RUNTIME column: `state.runtime` is
|
|
565
|
+
* the last runtime *dispatched*, and the judge dispatch overwrites the
|
|
566
|
+
* worker's (measured 2026-09-13: a six-harness campaign rendered as though
|
|
567
|
+
* three workers never ran), so this reads the invocation ledger for the
|
|
568
|
+
* worker that ran, falling back to the live runtime if none dispatched yet.
|
|
566
569
|
*
|
|
567
570
|
* @param {NodeSnapshot} node
|
|
568
571
|
* @returns {string|null}
|
|
@@ -633,7 +636,7 @@ function boundedNote(segments, maxLength = MAX_NOTE_LENGTH) {
|
|
|
633
636
|
export function statusNote(node) {
|
|
634
637
|
const scope = scopeFindingsNote(node.scopeFindings);
|
|
635
638
|
const review = reviewNote(node);
|
|
636
|
-
const detail = node.gate?.summary ?? node.error?.message ?? node.blockedBy?.join(", ") ?? node.phase;
|
|
639
|
+
const detail = node.gate?.summary ?? node.error?.message ?? node.blockedBy?.join(", ") ?? (candidateVerificationActive(node) ? "candidate" : node.phase);
|
|
637
640
|
const note = boundedNote([review, detail]);
|
|
638
641
|
if (!scope) return note;
|
|
639
642
|
return boundedNote([scope, note]);
|
|
@@ -657,13 +660,10 @@ function nodeNote(node) {
|
|
|
657
660
|
/** @typedef {{costUsd: number|null, status: "known"|"estimated"|"ambiguous"}} CostProjection */
|
|
658
661
|
|
|
659
662
|
/**
|
|
660
|
-
* Per-role usage from the invocation ledger
|
|
661
|
-
*
|
|
662
|
-
*
|
|
663
|
-
*
|
|
664
|
-
* its `costUsd` stays null rather than summing the known half and understating
|
|
665
|
-
* it. A role with no invocation is `none`. Token totals are always carried, so
|
|
666
|
-
* a role the provider would not price still reads as work.
|
|
663
|
+
* Per-role usage from the invocation ledger, with provenance for whether the
|
|
664
|
+
* cost total is honest: `priced` (all invocations costed), `partial`/`unpriced`
|
|
665
|
+
* (some/none costed, so `costUsd` stays null rather than understating), or
|
|
666
|
+
* `none` (no invocation). Token totals are always carried.
|
|
667
667
|
*
|
|
668
668
|
* @param {NodeSnapshot[]} nodes
|
|
669
669
|
* @returns {{worker: RoleUsage, judge: RoleUsage}}
|