faberun 0.30.0 → 0.32.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/campaign/campaign-brief.mjs +17 -6
- package/src/campaign/chain.mjs +124 -8
- package/src/campaign/ledger.mjs +19 -7
- package/src/campaign/metrics-command.mjs +68 -9
- package/src/campaign/metrics.mjs +75 -45
- package/src/campaign/record.mjs +8 -1
- package/src/campaign/reserve.mjs +340 -0
- package/src/campaign/spend.mjs +229 -0
- package/src/campaign/unpark.mjs +54 -1
- package/src/campaign/watch.mjs +314 -37
- package/src/cli/brand.mjs +2 -1
- package/src/cli.mjs +52 -2
- package/src/contract/judge-envelope.mjs +17 -2
- package/src/contract/scope-findings.mjs +3 -1
- package/src/contract/task-packet.mjs +40 -5
- package/src/contract/verification.mjs +67 -1
- package/src/engine/aux-admission.mjs +124 -0
- package/src/engine/capacity.mjs +78 -0
- package/src/engine/dispatch.mjs +116 -23
- package/src/engine/failure-cause.mjs +59 -0
- package/src/engine/lifecycle.mjs +3 -2
- package/src/engine/phase-session.mjs +56 -10
- package/src/engine/prompts.mjs +57 -2
- package/src/engine/reserve-admission.mjs +99 -0
- package/src/engine/resume.mjs +3 -1
- package/src/engine/review.mjs +21 -0
- package/src/engine/run-command.mjs +166 -11
- package/src/engine/scheduler.mjs +92 -88
- package/src/engine/settle.mjs +15 -8
- package/src/engine/settlement-overlap.mjs +143 -0
- package/src/engine/verify.mjs +131 -7
- package/src/harnesses/session-metrics.mjs +112 -20
- package/src/notify/index.mjs +86 -10
- package/src/plan/freeze.mjs +7 -2
- package/src/plan/human-step.mjs +36 -0
- package/src/plan/pipeline.mjs +53 -22
- package/src/plan/preflight.mjs +81 -5
- package/src/plan/progress.mjs +101 -1
- package/src/plan/repo-facts.mjs +403 -49
- package/src/plan/resolve.mjs +7 -0
- package/src/plan/revise-context.mjs +108 -0
- package/src/plan/rounds.mjs +180 -45
- package/src/plan/sizing.mjs +33 -1
- package/src/plan/template.mjs +134 -22
- package/src/repo/integrate.mjs +47 -5
- package/src/report/campaign-brief-estimate.mjs +47 -1
- package/src/report/campaign-brief.mjs +31 -2
- package/src/report/locale.mjs +20 -0
- package/src/report/message.mjs +13 -1
- package/src/report/metrics-report.mjs +38 -0
- package/src/report/progress.mjs +194 -10
- package/src/report/render.mjs +1 -1
- package/src/report/report-html.mjs +235 -0
- package/src/report/report-video.mjs +323 -0
- package/src/run/usage-windows.mjs +99 -4
- package/src/run/usage.mjs +69 -7
- package/src/web/api.mjs +179 -24
- package/src/web/app.mjs +27 -2
- package/src/web/server.mjs +32 -0
package/src/plan/template.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Planning contract templates: the one-node `mode: "discovery"` contracts a
|
|
3
|
-
* plan pipeline runs outside the control session — draft, review, revise (
|
|
4
|
-
*
|
|
3
|
+
* plan pipeline runs outside the control session — draft, review, revise (the
|
|
4
|
+
* reviewer's findings and the nodes they name), spec authoring, and spec review.
|
|
5
5
|
* Separate from freeze.mjs (which turns an already-built plan into a
|
|
6
6
|
* validated contract on disk) because this module never touches the
|
|
7
7
|
* filesystem or a model: it only assembles the JSON object `validateContract`
|
|
@@ -27,11 +27,17 @@ import { CONTRACT_VERSION, PROTOCOL_SCHEMA_VERSION } from "../contract/index.mjs
|
|
|
27
27
|
import { validateDefinitionOfDone } from "../contract/definition-of-done.mjs";
|
|
28
28
|
import { validateFinalVerification, validateSharedVerification } from "../contract/final-verification.mjs";
|
|
29
29
|
import { validateVerificationCommands } from "../contract/verification.mjs";
|
|
30
|
+
import { REVISE_FACT_SELECTION_RULE, REVISE_OPENING, REVISE_PATCH_OPENING, REVISE_PATCH_PHASES_INSTRUCTION, REVISE_PATCH_VENDOR_RULE } from "./revise-context.mjs";
|
|
30
31
|
|
|
31
32
|
/** @typedef {import("../contract/index.mjs").JsonObject} JsonObject */
|
|
32
33
|
/** @typedef {"draft"|"review"|"revise"|"spec-author"|"spec-review"} PlanningKind */
|
|
33
34
|
/** @typedef {"low"|"standard"|"high"} RiskTier */
|
|
34
35
|
/** @typedef {{campaignId: string, phase: string, n: number, goal?: string, cwd?: string, runtimes: Record<string, JsonObject>, runtimeDefaults: {worker?: string, judge?: string}, reviewerId?: string, specPath?: string, repoFactsPath?: string, cataloguePath?: string, packageMode?: import("./sizing.mjs").PackageMode, planPath?: string, findingsPath?: string, notesPath?: string, revisePatch?: boolean}} PlanningContractInputs */
|
|
36
|
+
// A revise's planPath names the plan context it starts from: before the
|
|
37
|
+
// first validated plan, the rejected draft, whole; after it, the narrowed
|
|
38
|
+
// context `reviseContextFor` (revise-context.mjs, R4) builds. A revise never
|
|
39
|
+
// reads the repository facts — the plan's readFiles are the selection its
|
|
40
|
+
// drafter drew from the fact set, and the reviser keeps them.
|
|
35
41
|
/** @typedef {{id: string, objective: string, taskKind: string, riskTier: RiskTier, dependsOn: string[], readFiles: string[], writeFiles: string[], scopeAcknowledged: string[], definitionOfDone: import("../contract/definition-of-done.mjs").DefinitionOfDoneItem[], verification: import("../contract/verification.mjs").VerificationCommand[], expectedTurns?: number}} PlanOutputNode */
|
|
36
42
|
/** @typedef {{nodes: PlanOutputNode[], phases?: PlanPhase[], sharedVerification?: import("../contract/verification.mjs").VerificationCommand[], finalVerification?: import("../contract/verification.mjs").VerificationCommand[], findings?: PlanFindingOutput[], justification?: string}} PlanOutput */
|
|
37
43
|
/** @typedef {{id: string, requirementIds: string[], nodeIds?: string[], deliverable: string}} PlanPhase */
|
|
@@ -107,7 +113,7 @@ const KIND_ROLE = Object.freeze({
|
|
|
107
113
|
/** @type {Record<PlanningKind, string[]>} */
|
|
108
114
|
const REQUIRED_INPUTS = Object.freeze({
|
|
109
115
|
draft: ["specPath", "repoFactsPath", "cataloguePath"],
|
|
110
|
-
revise: ["specPath", "
|
|
116
|
+
revise: ["specPath", "cataloguePath", "findingsPath", "planPath"],
|
|
111
117
|
review: ["specPath", "repoFactsPath", "planPath"],
|
|
112
118
|
"spec-author": ["notesPath"],
|
|
113
119
|
"spec-review": ["specPath"],
|
|
@@ -127,10 +133,10 @@ const PLAN_SUITE_SHAPE = '[{argv: [string], cwd?, timeoutSec?, repeat?, env?, mu
|
|
|
127
133
|
const ID_CHARSET_RULE = 'every id in it (node, phase, node assignment, and definitionOfDone item) must match [A-Za-z0-9._-]+ and never be exactly "." or ".."';
|
|
128
134
|
const PLAN_OUTPUT_SHAPE = `{nodes: [${PLAN_NODE_SHAPE}], phases?: ${PLAN_PHASE_SHAPE}, sharedVerification?: ${PLAN_SUITE_SHAPE}, finalVerification?: ${PLAN_SUITE_SHAPE}, justification?}; ${ID_CHARSET_RULE}`;
|
|
129
135
|
/**
|
|
130
|
-
* What a revise writes instead of the plan again (RM-110)
|
|
131
|
-
*
|
|
132
|
-
*
|
|
133
|
-
*
|
|
136
|
+
* What a revise writes instead of the plan again (RM-110): complete nodes,
|
|
137
|
+
* spelled exactly as the plan spells them — `PLAN_NODE_SHAPE`, one home for
|
|
138
|
+
* both strings so they cannot drift. The patch applies to the whole plan the
|
|
139
|
+
* pipeline holds; only the reviser's reading narrowed (R4).
|
|
134
140
|
*/
|
|
135
141
|
const REVISE_PATCH_SHAPE = `{nodes?: [${PLAN_NODE_SHAPE}], removedNodeIds?: [string], phases?: ${PLAN_PHASE_SHAPE}, sharedVerification?: ${PLAN_SUITE_SHAPE}, finalVerification?: ${PLAN_SUITE_SHAPE}, justification?}; ${ID_CHARSET_RULE}`;
|
|
136
142
|
/**
|
|
@@ -161,20 +167,33 @@ const FINDINGS_SHAPE = "[{id, severity, nodeId, text}]";
|
|
|
161
167
|
* rejected output).
|
|
162
168
|
*/
|
|
163
169
|
const REVISE_PLAN_INSTRUCTION = `Return exactly one worker-result JSON object. Put the revised plan in output.plan as ${PLAN_OUTPUT_SHAPE} and nothing else in output.`;
|
|
164
|
-
const REVISE_PATCH_INSTRUCTION = `Return exactly one worker-result JSON object. Put the revision in output.patch as ${REVISE_PATCH_SHAPE} and nothing else in output. The patch applies to the plan
|
|
170
|
+
const REVISE_PATCH_INSTRUCTION = `Return exactly one worker-result JSON object. Put the revision in output.patch as ${REVISE_PATCH_SHAPE} and nothing else in output. The patch applies to the plan the pipeline holds, not to the context in readFiles: a node whose id that plan already has replaces it, a new id adds a node, removedNodeIds names each node the plan must no longer have, and a field you omit keeps the value the plan already has. The context names the nodes your findings concern (changedNodes) and the dependencies those nodes need (dependencyNodes); dependencyNodes are there to be read, never re-emitted. Carry only the nodes and fields a finding requires — a revise that omits what a finding names has not answered it.`;
|
|
171
|
+
|
|
172
|
+
/** The phase declaration rule a draft and a whole-plan revise share. */
|
|
173
|
+
const PLAN_PHASES_INSTRUCTION = "Declare every phase the plan serves in output.plan.phases: the requirement ids (R<n> from the spec) the phase satisfies, the planned node ids it assigns, and the deliverable it produces in one sentence. Every planned node must appear in exactly one phase's nodeIds; a missing, duplicate, or unknown node assignment is refused.";
|
|
174
|
+
|
|
175
|
+
/** The classification rule a draft and a whole-plan revise share. */
|
|
176
|
+
const PLAN_VENDOR_RULE = "Never name a runtime, harness, model, or vendor anywhere in output.plan.";
|
|
165
177
|
|
|
166
178
|
/**
|
|
167
|
-
* The instruction
|
|
168
|
-
*
|
|
179
|
+
* The frozen instruction table; `instructionsFor` swaps the sizing sentence
|
|
180
|
+
* by package mode and, for a patch revise (RM-110), the four lines whose
|
|
181
|
+
* whole plan became the narrowed context (R4, `revise-context.mjs`).
|
|
169
182
|
*
|
|
170
183
|
* @param {PlanningKind} kind
|
|
171
184
|
* @param {PlanningContractInputs} inputs
|
|
172
185
|
* @returns {string[]}
|
|
173
186
|
*/
|
|
174
187
|
function instructionsFor(kind, inputs) {
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
188
|
+
let lines = INSTRUCTIONS[kind];
|
|
189
|
+
if (kind === "revise" && inputs.revisePatch === true) {
|
|
190
|
+
lines = lines.map((line) => (
|
|
191
|
+
line === REVISE_OPENING ? REVISE_PATCH_OPENING
|
|
192
|
+
: line === PLAN_PHASES_INSTRUCTION ? REVISE_PATCH_PHASES_INSTRUCTION
|
|
193
|
+
: line === REVISE_PLAN_INSTRUCTION ? REVISE_PATCH_INSTRUCTION
|
|
194
|
+
: line === PLAN_VENDOR_RULE ? REVISE_PATCH_VENDOR_RULE
|
|
195
|
+
: line));
|
|
196
|
+
}
|
|
178
197
|
if (inputs.packageMode !== "exploratory") return lines;
|
|
179
198
|
return lines.map((line) => (line === SIZING_INSTRUCTION ? EXPLORATORY_SIZING_INSTRUCTION : line));
|
|
180
199
|
}
|
|
@@ -203,6 +222,72 @@ const NAMED_TEST_FILE_RULE = 'A command that filters node:test by name (--test-n
|
|
|
203
222
|
// references, so the planner names the files that can actually break.
|
|
204
223
|
const COVERING_TEST_RULE = 'repo-facts.json\'s testFiles lists, per test file, the repo-relative modules it is named after and the ones it imports or runs. A node\'s verification names every test file whose covers intersects its writeFiles, not only the one named after the module: node --test <file>, one per covering file, and the enclosing test directory when that set is larger than one verification array holds. A test file that references nothing the node writes is not named there; those are the phase\'s finalVerification, which runs the whole suite once when the phase closes.';
|
|
205
224
|
|
|
225
|
+
// R4 of the phase-2 reissue: repo-facts.json's `paths` is a byte-limited cut
|
|
226
|
+
// (DEFAULT_MAX_PATH_BYTES in src/plan/repo-facts.mjs), and the finding it
|
|
227
|
+
// answers (judge Sol's `r4-omission-text-legible`) was that a single
|
|
228
|
+
// `truncated` flag described nothing: a dropped manifest and a dropped
|
|
229
|
+
// historical log read the same. The artefact now reports paths and bytes per
|
|
230
|
+
// source kind, so both stages that read it are told to read that report rather
|
|
231
|
+
// than to read an absent path as an absent file.
|
|
232
|
+
/**
|
|
233
|
+
* What a draft or revise is told about the cut it authors from. The plan is
|
|
234
|
+
* written against the cut, so a node that needs a file the cut discarded has
|
|
235
|
+
* to know the path may exist and be absent from `paths` — otherwise the honest
|
|
236
|
+
* plan it produces is "this repository holds no such file".
|
|
237
|
+
*/
|
|
238
|
+
export const PATH_CUT_RULE = "repo-facts.json's paths is the byte-limited cut of the tracked tree, never the whole index: it keeps every path a requirement's proof or measure names, every test file, and every module the tree references, and reports what the ceiling discarded in pathOmission — paths and bytes in total, and the same two numbers per source kind (document, archived-log, manifest, code, other). Read pathOmission before concluding the tree holds no file of a kind, and never read a path's absence from paths as proof of absence: it may be one the ceiling discarded. The complete index is staged beside the artefact as repo-paths.txt, for a discovery node the plan explicitly authorises to enumerate the tree; a node's readFiles never names it otherwise.";
|
|
239
|
+
/**
|
|
240
|
+
* The reviewer's half of the same rule. A reviewer only ever sees the
|
|
241
|
+
* artefact, so the cut is exactly what could make it raise a finding against
|
|
242
|
+
* the tree rather than against the plan.
|
|
243
|
+
*/
|
|
244
|
+
export const REVIEW_PATH_CUT_RULE = "repo-facts.json's paths is a byte-limited cut of the tracked tree, so a path absent from it is not evidence that the file is absent. pathOmission reports, per source kind (document, archived-log, manifest, code, other), how many paths and how many bytes the cut discarded, and those per-kind numbers sum to its own paths and bytes totals. A finding that a node misses a file the spec implies is only valid when the path is in paths or named by the requirement itself; the complete index is staged beside the artefact as repo-paths.txt for the plan to read if it needs it.";
|
|
245
|
+
|
|
246
|
+
// measured 2026-09-30: the FX runner truncates a tool result at 8,192 output
|
|
247
|
+
// tokens, and a draft that asked for repo-facts.json's whole paths, testFiles
|
|
248
|
+
// or coverage mapping in one call read the partial it got back as though it
|
|
249
|
+
// were the whole tree. The ceiling is per response, so the rule is scoped to
|
|
250
|
+
// one requirement's decision instead of the artefact at large.
|
|
251
|
+
const REPO_FACTS_QUERY_BOUND_RULE = "Bound every repository-facts query by the current requirement and below the measured 8,192-output-token FX response ceiling: query repo-facts.json one requirement at a time, and read only the exact source and test entries that requirement's decision names, so each individual tool result stays strictly below the ceiling. Never dump all of paths, testFiles, or the testFiles[].covers coverage mapping in one result; split a broad selection into requirement-scoped queries, or answer it with a focused summary or count. A tool result that truncated is not evidence: narrow the query and read again before continuing, because a partial result cannot show what it dropped.";
|
|
252
|
+
|
|
253
|
+
// R4 of campaign-efficiency phase 4: a worker packet's context is exactly its
|
|
254
|
+
// declared readFiles, so what the drafter names there is the selection of
|
|
255
|
+
// phase facts that worker will see. Nothing said so, which let a node name a
|
|
256
|
+
// path the planner never measured and freeze a packet whose context nothing
|
|
257
|
+
// vouches for; sizing refuses that selection (`sizing_read_outside_fact_set`)
|
|
258
|
+
// and this is the same rule told to the author.
|
|
259
|
+
export const FACT_SELECTION_RULE = "Each node's readFiles is the closed context its worker receives: the node's declared selection from the phase fact set. Select it from repo-facts.json's paths and testFiles, name exactly the files that node must read for its own work, and never name a path the fact set does not hold — sizing refuses a read outside the fact set (sizing_read_outside_fact_set), because a packet's context must be facts the planner measured. Absence from paths is still not proof a file is absent (see the path-cut rule above); it only means the file is not this phase's measured context.";
|
|
260
|
+
|
|
261
|
+
// R5 of the phase-2 reissue, reissued: this module used to export
|
|
262
|
+
// PLAN_RULE_READ_FILES, the Faberun source and test paths a review packet
|
|
263
|
+
// declared in `readFiles` so its reviewer could read the rules it judged by.
|
|
264
|
+
// A planning packet's readFiles resolve against the repository being planned,
|
|
265
|
+
// so a review contract naming them validated in this repository and nowhere
|
|
266
|
+
// else: an ordinary target checkout could not receive the rules at all, and a
|
|
267
|
+
// path name that matched was read as proof that the target's file held them.
|
|
268
|
+
// The rules travel in the review packet's own `instructions`
|
|
269
|
+
// (INSTRUCTIONS.review) instead, and its readFiles are the target's inputs.
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* What a review packet says about where its own paths point. The rules it is
|
|
273
|
+
* held to are stated in it, never pointed at: no path name is a rule, and a
|
|
274
|
+
* name that matches a file in another repository is not evidence about this
|
|
275
|
+
* one.
|
|
276
|
+
*/
|
|
277
|
+
const REVIEW_TARGET_PATHS_RULE = "The readFiles and writeFiles of every task packet in this plan resolve against the target repository being planned — the checkout the spec, the repository facts and the plan describe. A file outside that repository is not available to this review, and a path name that happens to match one there proves nothing: never read a matching path name as evidence that the target's file, or any file, holds a rule. Judge the plan against the rules this packet states.";
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* The freeze-time refusals the rule strings above do not state (`freeze.mjs`,
|
|
281
|
+
* `proof-scope.mjs`, `sizing.mjs`, `definition-of-done.mjs`). The review is the
|
|
282
|
+
* only stage that sees the whole plan before it is frozen, so a refusal it
|
|
283
|
+
* cannot see is a refusal raised a round later, at freeze, with no reviewer
|
|
284
|
+
* left to repair it.
|
|
285
|
+
*/
|
|
286
|
+
const FREEZE_AND_CONTRACT_RULE = Object.freeze([
|
|
287
|
+
"A plan freezes only if its Definition of Done is provable: every item declares a proof or judgment: true; a judgment item says in `reason` what no command verifies; a `verification` proof's ref is the zero-based index, or the exact text, of one of that node's own verification commands, never a command the node does not carry; a command proof runs through a shell, not an argv array, so an unquoted value after --test-name-pattern is split into extra words, a proof command that leaves a quote open is refused before it runs, and an inline node -e script that does not parse can never exit 0.",
|
|
288
|
+
"A verification command fits its evidence and its limits: its timeoutSec is at least 1.5 times what repo-facts.json measured for the same argv, and at most 1800 s; a node's verification holds at most 32 commands. A phase declaration that assigns nodeIds must cover every planned node exactly once, and a single-node plan or a dependency chain deeper than 8 nodes needs plan.justification.",
|
|
289
|
+
]);
|
|
290
|
+
|
|
206
291
|
// RM-107, measured 2026-09-27 on `safe-to-hand-to-a-friend` phase 1: the phase
|
|
207
292
|
// integrated a red tree -- src/host/preflight.mjs at 840 lines against the 800
|
|
208
293
|
// ceiling, declaredEnvironment exported by seven modules, three test files
|
|
@@ -231,7 +316,7 @@ const SCOPE_CLOSURE_RULE = Object.freeze([
|
|
|
231
316
|
/** @type {Record<PlanningKind, string>} */
|
|
232
317
|
const OBJECTIVES = Object.freeze({
|
|
233
318
|
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.",
|
|
234
|
-
revise: "
|
|
319
|
+
revise: "Resolve every one of the reviewer's findings, changing only what a finding requires.",
|
|
235
320
|
review: "Review this plan against the spec and the repository facts, and report only findings.",
|
|
236
321
|
"spec-author": "Turn free notes into a structured spec document following the spec format.",
|
|
237
322
|
"spec-review": "Review this spec for traceability and completeness, and report only findings.",
|
|
@@ -241,29 +326,49 @@ const OBJECTIVES = Object.freeze({
|
|
|
241
326
|
const INSTRUCTIONS = Object.freeze({
|
|
242
327
|
draft: [
|
|
243
328
|
`Consult the ${TASK_KIND_CATALOGUE_FILE} in readFiles before classifying any node; taskKind must be one of that catalogue and riskTier must be one of ${RISK_TIERS.join(", ")}.`,
|
|
244
|
-
|
|
329
|
+
PLAN_PHASES_INSTRUCTION,
|
|
245
330
|
...SCOPE_CLOSURE_RULE,
|
|
246
331
|
NAMED_TEST_FILE_RULE,
|
|
247
332
|
COVERING_TEST_RULE,
|
|
333
|
+
PATH_CUT_RULE,
|
|
334
|
+
FACT_SELECTION_RULE,
|
|
335
|
+
REPO_FACTS_QUERY_BOUND_RULE,
|
|
248
336
|
...CONTRACT_VERIFICATION_RULE,
|
|
249
337
|
`Return exactly one worker-result JSON object. Put the plan in output.plan as ${PLAN_OUTPUT_SHAPE} and nothing else in output.`,
|
|
250
|
-
|
|
338
|
+
PLAN_VENDOR_RULE,
|
|
251
339
|
SIZING_INSTRUCTION,
|
|
252
340
|
],
|
|
341
|
+
// The revise reads no repository facts (R4): it keeps the plan's own
|
|
342
|
+
// selections (REVISE_FACT_SELECTION_RULE, revise-context.mjs), and the two
|
|
343
|
+
// repo-facts rules the draft authors to stay with the stages that read the
|
|
344
|
+
// facts. The freeze pre-flight holds the revise to both mechanically.
|
|
253
345
|
revise: [
|
|
254
|
-
|
|
346
|
+
REVISE_OPENING,
|
|
255
347
|
"Read the findings and resolve every one; do not leave a critical or major finding unaddressed.",
|
|
256
|
-
|
|
348
|
+
PLAN_PHASES_INSTRUCTION,
|
|
257
349
|
...SCOPE_CLOSURE_RULE,
|
|
258
350
|
NAMED_TEST_FILE_RULE,
|
|
259
|
-
|
|
351
|
+
REVISE_FACT_SELECTION_RULE,
|
|
260
352
|
...CONTRACT_VERIFICATION_RULE,
|
|
261
353
|
REVISE_PLAN_INSTRUCTION,
|
|
262
|
-
|
|
354
|
+
PLAN_VENDOR_RULE,
|
|
263
355
|
SIZING_INSTRUCTION,
|
|
264
356
|
],
|
|
357
|
+
// The rules the plan is judged against, carried as rules. They are the same
|
|
358
|
+
// strings a draft authors to, because a reviewer that grades against other
|
|
359
|
+
// wording grades against a different rule; the reviewer adds the two rules
|
|
360
|
+
// only it can apply (the proof-vs-requirement reading and the path cut) and
|
|
361
|
+
// the freeze-time refusals no other stage can see before the freeze.
|
|
265
362
|
review: [
|
|
266
|
-
"You are given only the spec, the repository facts,
|
|
363
|
+
"You are given only the spec, the repository facts, the plan under review, and the rules stated below; you have not seen how the plan was produced or any reasoning behind it. Review the artefact alone.",
|
|
364
|
+
REVIEW_TARGET_PATHS_RULE,
|
|
365
|
+
...SCOPE_CLOSURE_RULE,
|
|
366
|
+
NAMED_TEST_FILE_RULE,
|
|
367
|
+
COVERING_TEST_RULE,
|
|
368
|
+
REVIEW_PATH_CUT_RULE,
|
|
369
|
+
...CONTRACT_VERIFICATION_RULE,
|
|
370
|
+
...FREEZE_AND_CONTRACT_RULE,
|
|
371
|
+
SIZING_INSTRUCTION,
|
|
267
372
|
PROOF_NOT_STRICTER_THAN_REQUIREMENT_RULE,
|
|
268
373
|
`Return exactly one worker-result JSON object. Put your findings in output.findings as ${FINDINGS_SHAPE} and nothing else in output.`,
|
|
269
374
|
"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.",
|
|
@@ -295,9 +400,16 @@ const NON_GOALS = Object.freeze({
|
|
|
295
400
|
*/
|
|
296
401
|
function readFilesForKind(kind, inputs) {
|
|
297
402
|
if (kind === "draft") return [/** @type {string} */ (inputs.specPath), /** @type {string} */ (inputs.repoFactsPath), /** @type {string} */ (inputs.cataloguePath)];
|
|
403
|
+
// No repository facts: the reviser keeps the plan's own selections (R4).
|
|
404
|
+
// planPath is the whole rejected plan before the first validated plan, the
|
|
405
|
+
// narrowed context `reviseContextFor` builds after.
|
|
298
406
|
if (kind === "revise") {
|
|
299
|
-
return [/** @type {string} */ (inputs.specPath), /** @type {string} */ (inputs.
|
|
407
|
+
return [/** @type {string} */ (inputs.specPath), /** @type {string} */ (inputs.cataloguePath), /** @type {string} */ (inputs.findingsPath), /** @type {string} */ (inputs.planPath)];
|
|
300
408
|
}
|
|
409
|
+
// The review's readFiles are the target's own three inputs: the rules it
|
|
410
|
+
// judges by are in INSTRUCTIONS.review, because a path in this repository
|
|
411
|
+
// would resolve against the target repository being planned and refuse the
|
|
412
|
+
// contract in every ordinary checkout (R5, reissued).
|
|
301
413
|
if (kind === "review") return [/** @type {string} */ (inputs.specPath), /** @type {string} */ (inputs.repoFactsPath), /** @type {string} */ (inputs.planPath)];
|
|
302
414
|
if (kind === "spec-author") return [/** @type {string} */ (inputs.notesPath)];
|
|
303
415
|
return [/** @type {string} */ (inputs.specPath)];
|
package/src/repo/integrate.mjs
CHANGED
|
@@ -20,6 +20,41 @@ import { candidateWorktreePath } from "../run/paths.mjs";
|
|
|
20
20
|
const JOURNAL = "integration.jsonl";
|
|
21
21
|
const TERMINAL = new Set(["accepted", "failed", "conflict"]);
|
|
22
22
|
|
|
23
|
+
/**
|
|
24
|
+
* The candidate resource — the per-run candidate ref, its single worktree and
|
|
25
|
+
* the verify→accept transition around them — admits one integration at a
|
|
26
|
+
* time. The settlement chain serializes callers today; this lock is what
|
|
27
|
+
* keeps that guarantee true at the resource itself rather than by caller
|
|
28
|
+
* discipline, so a caller that overlaps two `integrateAttempt` calls for one
|
|
29
|
+
* run interleaves nothing here: the second transaction verifies only after
|
|
30
|
+
* the first has accepted (and cleaned up), then reads the moved run ref and
|
|
31
|
+
* settles as a concurrent move — the verdict it would have earned arriving
|
|
32
|
+
* second in any order.
|
|
33
|
+
*
|
|
34
|
+
* Recovery's bookkeeping cleanups (`cleanupCandidate` on dead records) stay
|
|
35
|
+
* outside the lock: they run in the recovery loop's own sequence, which no
|
|
36
|
+
* concurrent integration has ever shared.
|
|
37
|
+
*/
|
|
38
|
+
/** @type {Map<string, Promise<unknown>>} */
|
|
39
|
+
const candidateLocks = new Map();
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* @template T
|
|
43
|
+
* @param {string} runDir
|
|
44
|
+
* @param {() => T | Promise<T>} work
|
|
45
|
+
* @returns {Promise<T>}
|
|
46
|
+
*/
|
|
47
|
+
function withCandidateLock(runDir, work) {
|
|
48
|
+
const previous = candidateLocks.get(runDir) ?? Promise.resolve();
|
|
49
|
+
const settled = previous.then(work, work);
|
|
50
|
+
const tail = settled.catch(() => {});
|
|
51
|
+
candidateLocks.set(runDir, tail);
|
|
52
|
+
void tail.then(() => {
|
|
53
|
+
if (candidateLocks.get(runDir) === tail) candidateLocks.delete(runDir);
|
|
54
|
+
});
|
|
55
|
+
return settled;
|
|
56
|
+
}
|
|
57
|
+
|
|
23
58
|
/** @typedef {"prepared"|"verified"|"accepted"|"failed"|"conflict"} IntegrationStatus */
|
|
24
59
|
/** @typedef {{schemaVersion: number, transactionId: string, runId: string, node: string, attempt: number, attemptSha: string, branch: string|null, empty: boolean, previousRunRefTip: string, candidateSha: string|null, conflictingPaths: string[], at: string, status?: IntegrationStatus, verificationEvidence?: unknown, acceptedAt?: string}} IntegrationRecord */
|
|
25
60
|
/** @typedef {{passed?: boolean, error?: string, [key: string]: unknown}} CandidateEvidence */
|
|
@@ -126,7 +161,7 @@ export async function integrateAttempt({
|
|
|
126
161
|
if (transaction.conflictingPaths.length) {
|
|
127
162
|
appendRecord(runDir, { ...transaction, status: "conflict", conflictingPaths: transaction.conflictingPaths, verificationEvidence: verificationEvidence ?? null });
|
|
128
163
|
await onConflict?.(transaction);
|
|
129
|
-
cleanupCandidate(repo, runDir, runId);
|
|
164
|
+
await withCandidateLock(runDir, () => cleanupCandidate(repo, runDir, runId));
|
|
130
165
|
return { status: "conflict", conflictingPaths: transaction.conflictingPaths };
|
|
131
166
|
}
|
|
132
167
|
return verifyAndAdvance({
|
|
@@ -287,7 +322,7 @@ function prepareTransaction({ repo, runId, nodeId, attempt, attemptSha, branch,
|
|
|
287
322
|
return transaction;
|
|
288
323
|
}
|
|
289
324
|
|
|
290
|
-
/** @param {{repo: string, runDir: string, runId: string, transaction: IntegrationRecord, verificationEvidence?: unknown, verifyCandidate: CandidateVerifier, onAccepted?: AcceptedCallback, onVerificationFailure?: VerificationFailureCallback, onConcurrentMove?: ConcurrentMoveCallback, interrupt?: (stage: string) => void}} args @returns {Promise<IntegrationResult>} */
|
|
325
|
+
/** @param {{repo: string, runDir: string, runId: string, transaction: IntegrationRecord, verificationEvidence?: unknown, verifyCandidate: CandidateVerifier, onAccepted?: AcceptedCallback, onVerificationFailure?: VerificationFailureCallback, onConcurrentMove?: ConcurrentMoveCallback, interrupt?: (stage: string) => void, exclusive?: boolean}} args @returns {Promise<IntegrationResult>} */
|
|
291
326
|
async function verifyAndAdvance({
|
|
292
327
|
repo,
|
|
293
328
|
runDir,
|
|
@@ -299,7 +334,11 @@ async function verifyAndAdvance({
|
|
|
299
334
|
onVerificationFailure,
|
|
300
335
|
onConcurrentMove,
|
|
301
336
|
interrupt,
|
|
337
|
+
exclusive = false,
|
|
302
338
|
}) {
|
|
339
|
+
if (!exclusive) {
|
|
340
|
+
return withCandidateLock(runDir, () => verifyAndAdvance({ repo, runDir, runId, transaction, verificationEvidence, verifyCandidate, onAccepted, onVerificationFailure, onConcurrentMove, interrupt, exclusive: true }));
|
|
341
|
+
}
|
|
303
342
|
deleteRef(repo, candidateRefName(runId));
|
|
304
343
|
deleteCandidateWorktree(repo, runDir, runId);
|
|
305
344
|
gitSetRef(repo, candidateRefName(runId), transaction.candidateSha);
|
|
@@ -321,11 +360,14 @@ async function verifyAndAdvance({
|
|
|
321
360
|
const verified = { ...transaction, status: "verified", verificationEvidence: { attempt: verificationEvidence ?? null, candidate: candidateEvidence } };
|
|
322
361
|
appendRecord(runDir, verified);
|
|
323
362
|
interruptStage(interrupt, "before-ref");
|
|
324
|
-
return advanceVerified({ repo, runDir, runId, transaction: verified, onAccepted, onConcurrentMove, interrupt });
|
|
363
|
+
return advanceVerified({ repo, runDir, runId, transaction: verified, onAccepted, onConcurrentMove, interrupt, exclusive: true });
|
|
325
364
|
}
|
|
326
365
|
|
|
327
|
-
/** @param {{repo: string, runDir: string, runId: string, transaction: IntegrationRecord, onAccepted?: AcceptedCallback, onConcurrentMove?: ConcurrentMoveCallback, interrupt?: (stage: string) => void}} args @returns {Promise<IntegrationResult>} */
|
|
328
|
-
async function advanceVerified({ repo, runDir, runId, transaction, onAccepted, onConcurrentMove, interrupt }) {
|
|
366
|
+
/** @param {{repo: string, runDir: string, runId: string, transaction: IntegrationRecord, onAccepted?: AcceptedCallback, onConcurrentMove?: ConcurrentMoveCallback, interrupt?: (stage: string) => void, exclusive?: boolean}} args @returns {Promise<IntegrationResult>} */
|
|
367
|
+
async function advanceVerified({ repo, runDir, runId, transaction, onAccepted, onConcurrentMove, interrupt, exclusive = false }) {
|
|
368
|
+
if (!exclusive) {
|
|
369
|
+
return withCandidateLock(runDir, () => advanceVerified({ repo, runDir, runId, transaction, onAccepted, onConcurrentMove, interrupt, exclusive: true }));
|
|
370
|
+
}
|
|
329
371
|
const current = gitHead(repo, runRefName(runId));
|
|
330
372
|
if (!current) throw new Error(`integration ref is unavailable for ${runId}`);
|
|
331
373
|
if (current === transaction.candidateSha && current !== transaction.previousRunRefTip) {
|
|
@@ -21,12 +21,15 @@
|
|
|
21
21
|
import { collectCompletedExecutionNodes } from "../run/usage.mjs";
|
|
22
22
|
import { asArray, unique } from "../campaign/brief-text.mjs";
|
|
23
23
|
import { scheduleUnderCapacity } from "../campaign/campaign-brief-graph.mjs";
|
|
24
|
+
import { sumCounters } from "../run/usage-windows.mjs";
|
|
24
25
|
|
|
25
26
|
/** @typedef {import("../run/usage.mjs").CompletedExecutionNode} CompletedExecutionNode */
|
|
26
27
|
/** @typedef {import("../run/usage.mjs").CompletedExecutionPool} CompletedExecutionPool */
|
|
27
28
|
/** @typedef {import("../campaign/campaign-brief.mjs").BriefEstimateInput} BriefEstimateInput */
|
|
28
29
|
/** @typedef {import("../campaign/campaign-brief.mjs").BriefMeasureInput} BriefMeasureInput */
|
|
29
|
-
/** @typedef {
|
|
30
|
+
/** @typedef {import("../run/usage.mjs").UsageCounters} UsageCounters */
|
|
31
|
+
/** @typedef {{worker: UsageCounters|null, judge: UsageCounters|null}} BriefUsageRollup */
|
|
32
|
+
/** @typedef {BriefEstimateInput & {cost: BriefMeasureInput, duration: BriefMeasureInput, method: string[], assumptions: string[], usage: BriefUsageRollup}} BriefExpenseInput */
|
|
30
33
|
/** @typedef {Record<string, any>} AnyRecord */
|
|
31
34
|
/** @typedef {"worker"|"judge"} ExecutionRole */
|
|
32
35
|
/** @typedef {{nodeId: string, role: ExecutionRole, taskKind: string, runtimeId: string|null, model: string|null}} AssignedRole */
|
|
@@ -90,6 +93,7 @@ export function estimateBriefExpense(options) {
|
|
|
90
93
|
const bothInsufficient = (reason) => ({
|
|
91
94
|
cost: measure("insufficient data", reason, { provenance: "priced usage.jsonl invocations" }),
|
|
92
95
|
duration: measure("insufficient data", reason, { provenance: "recorded actual node and verification elapsed times" }),
|
|
96
|
+
usage: { worker: null, judge: null },
|
|
93
97
|
runtimes,
|
|
94
98
|
models,
|
|
95
99
|
effectiveConcurrency,
|
|
@@ -118,6 +122,7 @@ export function estimateBriefExpense(options) {
|
|
|
118
122
|
return {
|
|
119
123
|
cost: costMeasure(assignments, samples),
|
|
120
124
|
duration: durationMeasure(assignments, samples, scheduledNodes, maxParallel, maxConcurrent),
|
|
125
|
+
usage: usageRollup(assignments, samples),
|
|
121
126
|
runtimes,
|
|
122
127
|
models,
|
|
123
128
|
effectiveConcurrency,
|
|
@@ -275,6 +280,47 @@ function comparable(key, samples, measureName) {
|
|
|
275
280
|
return result;
|
|
276
281
|
}
|
|
277
282
|
|
|
283
|
+
/**
|
|
284
|
+
* The usage counters carried through with the cost measure: for each role, the
|
|
285
|
+
* strict rollup over the same comparable, priced population the cost range
|
|
286
|
+
* sums, so the usage evidence stands with the cost range or is absent with it.
|
|
287
|
+
*
|
|
288
|
+
* @param {AssignedRole[]} assignments
|
|
289
|
+
* @param {CompletedExecutionNode[]} samples
|
|
290
|
+
* @returns {BriefUsageRollup}
|
|
291
|
+
*/
|
|
292
|
+
function usageRollup(assignments, samples) {
|
|
293
|
+
return {
|
|
294
|
+
worker: roleUsageRollup("worker", assignments, samples),
|
|
295
|
+
judge: roleUsageRollup("judge", assignments, samples),
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* @param {ExecutionRole} role
|
|
301
|
+
* @param {AssignedRole[]} assignments
|
|
302
|
+
* @param {CompletedExecutionNode[]} samples
|
|
303
|
+
* @returns {UsageCounters|null} null when any assigned key of the role lacks the comparable floor
|
|
304
|
+
*/
|
|
305
|
+
function roleUsageRollup(role, assignments, samples) {
|
|
306
|
+
const keys = assignments.filter((assignment) => assignment.role === role);
|
|
307
|
+
if (keys.length === 0) return null;
|
|
308
|
+
const seen = new Set();
|
|
309
|
+
/** @type {CompletedExecutionNode[]} */
|
|
310
|
+
const eligible = [];
|
|
311
|
+
for (const key of keys) {
|
|
312
|
+
const comparableNodes = comparable(key, samples, "cost");
|
|
313
|
+
if (comparableNodes.length < SAMPLE_FLOOR) return null;
|
|
314
|
+
for (const sample of comparableNodes) {
|
|
315
|
+
const identity = `${sample.runId}\u0000${sample.nodeId}`;
|
|
316
|
+
if (seen.has(identity)) continue;
|
|
317
|
+
seen.add(identity);
|
|
318
|
+
eligible.push(sample);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
return sumCounters(eligible.map((sample) => (role === "worker" ? sample.worker.usage ?? null : sample.judge?.usage ?? null)));
|
|
322
|
+
}
|
|
323
|
+
|
|
278
324
|
/**
|
|
279
325
|
* @param {AssignedRole[]} assignments
|
|
280
326
|
* @param {CompletedExecutionNode[]} samples
|
|
@@ -315,12 +315,34 @@ function renderGaps(model) {
|
|
|
315
315
|
return ["## Gaps", ...lines].join("\n");
|
|
316
316
|
}
|
|
317
317
|
|
|
318
|
+
/**
|
|
319
|
+
* The estimate section renders whatever the model carries. `buildEstimate`
|
|
320
|
+
* always records `costPerAccepted`; a model built before the measure existed
|
|
321
|
+
* omits it, and the brief says so instead of crashing.
|
|
322
|
+
*
|
|
323
|
+
* @param {BriefModel["estimate"]} estimate
|
|
324
|
+
* @returns {BriefMeasure}
|
|
325
|
+
*/
|
|
326
|
+
function costPerAcceptedMeasure(estimate) {
|
|
327
|
+
return estimate.costPerAccepted ?? {
|
|
328
|
+
status: "insufficient data",
|
|
329
|
+
min: null,
|
|
330
|
+
max: null,
|
|
331
|
+
samples: null,
|
|
332
|
+
reason: "cost per accepted result was not recorded",
|
|
333
|
+
sourceRuns: [],
|
|
334
|
+
method: null,
|
|
335
|
+
provenance: null,
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
|
|
318
339
|
/**
|
|
319
340
|
* @param {BriefModel} model
|
|
320
341
|
* @returns {string}
|
|
321
342
|
*/
|
|
322
343
|
function renderEstimate(model) {
|
|
323
344
|
const { estimate } = model;
|
|
345
|
+
const costPerAccepted = costPerAcceptedMeasure(estimate);
|
|
324
346
|
const method = estimate.method.length > 0 ? estimate.method.join("; ") : "not recorded";
|
|
325
347
|
const assumptions = estimate.assumptions.length > 0 ? estimate.assumptions.join("; ") : "not recorded";
|
|
326
348
|
return [
|
|
@@ -328,7 +350,12 @@ function renderEstimate(model) {
|
|
|
328
350
|
`- Nodes: ${estimate.nodeCount}`,
|
|
329
351
|
`- Workers: ${estimate.workerCount}`,
|
|
330
352
|
`- Cost: ${renderMeasure(estimate.cost, "usd")}`,
|
|
353
|
+
// Phase-4 R5: the comparison that matters is cost per accepted result,
|
|
354
|
+
// reported next to the isolated price so a cheaper model with fewer
|
|
355
|
+
// accepted results cannot win on price alone.
|
|
356
|
+
`- Cost per accepted result: ${renderMeasure(costPerAccepted, "usd")}`,
|
|
331
357
|
`- Cost provenance: ${estimate.cost.provenance ?? "not recorded"}`,
|
|
358
|
+
`- Cost per accepted result provenance: ${costPerAccepted.provenance ?? "not recorded"}`,
|
|
332
359
|
`- Duration: ${renderMeasure(estimate.duration, "minutes")}`,
|
|
333
360
|
`- Duration provenance: ${estimate.duration.provenance ?? "not recorded"}`,
|
|
334
361
|
`- Runtimes: ${estimate.runtimes.length > 0 ? estimate.runtimes.map((id) => `\`${id}\``).join(", ") : "none"}`,
|
|
@@ -398,12 +425,14 @@ function link(path) {
|
|
|
398
425
|
}
|
|
399
426
|
|
|
400
427
|
/**
|
|
401
|
-
* Escape a table cell and collapse it to one line.
|
|
428
|
+
* Escape a table cell and collapse it to one line. Exported so the report's
|
|
429
|
+
* HTML surface (`report-html.mjs`) shares this one home instead of carrying a
|
|
430
|
+
* byte-identical copy.
|
|
402
431
|
*
|
|
403
432
|
* @param {string} value
|
|
404
433
|
* @returns {string}
|
|
405
434
|
*/
|
|
406
|
-
function cell(value) {
|
|
435
|
+
export function cell(value) {
|
|
407
436
|
return value.replace(/\|/gu, "\\|").replace(/\s+/gu, " ").trim();
|
|
408
437
|
}
|
|
409
438
|
|
package/src/report/locale.mjs
CHANGED
|
@@ -182,3 +182,23 @@ export function labelsFor(language) {
|
|
|
182
182
|
const table = LABELS[language];
|
|
183
183
|
return (key) => table[key] ?? LABELS.en[key] ?? key;
|
|
184
184
|
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* The alert catalogue: the four campaign events that deserve a notification
|
|
188
|
+
* of their own, registered in the same wording home every notification
|
|
189
|
+
* phrase is written in. Each alert carries its text per language and the
|
|
190
|
+
* dedup keys that make one occurrence send once: `campaign` (the campaign
|
|
191
|
+
* id) plus `episode` (the one id that names the occurrence -- the phase for
|
|
192
|
+
* a completed phase, the node for exhausted recovery or a needed decision,
|
|
193
|
+
* the campaign itself for closure). The same occurrence is re-derived on
|
|
194
|
+
* every render; without the keys it would re-send with it.
|
|
195
|
+
*
|
|
196
|
+
* @typedef {{id: string, dedupKeys: ["campaign", "episode"], text: Record<Language, string>}} AlertSpec
|
|
197
|
+
* @type {AlertSpec[]}
|
|
198
|
+
*/
|
|
199
|
+
export const ALERTS = [
|
|
200
|
+
{ id: "phase-completed", dedupKeys: ["campaign", "episode"], text: { en: "phase completed", pt: "fase concluída" } },
|
|
201
|
+
{ id: "recovery-exhausted", dedupKeys: ["campaign", "episode"], text: { en: "recovery exhausted", pt: "recuperação esgotada" } },
|
|
202
|
+
{ id: "decision-needed", dedupKeys: ["campaign", "episode"], text: { en: "decision needed", pt: "decisão necessária" } },
|
|
203
|
+
{ id: "closure", dedupKeys: ["campaign", "episode"], text: { en: "campaign closed", pt: "campanha encerrada" } },
|
|
204
|
+
];
|
package/src/report/message.mjs
CHANGED
|
@@ -124,7 +124,7 @@ export function renderRunProgress(runDir, event) {
|
|
|
124
124
|
const view = { runDir, runId: event.runId ?? basename(runDir), payload, objectives, snapshots, campaign, label, subject };
|
|
125
125
|
|
|
126
126
|
const body = event.type === "run.terminal" ? runTerminal(view) : event.type === "attention" ? attention(view) : nodeTerminal(view);
|
|
127
|
-
const lines = [...body, RULE, footer(view, event.type), ...updateLine(label)];
|
|
127
|
+
const lines = [...body, ...artifactLines(event.artifacts), RULE, footer(view, event.type), ...updateLine(label)];
|
|
128
128
|
return boundToCeiling(lines);
|
|
129
129
|
}
|
|
130
130
|
|
|
@@ -614,6 +614,18 @@ function formatUsdCompact(value) {
|
|
|
614
614
|
return formatUsd(value);
|
|
615
615
|
}
|
|
616
616
|
|
|
617
|
+
/**
|
|
618
|
+
* The run's readable artifacts, one line each with the glyph the report
|
|
619
|
+
* command prints beside it. The path is data, not wording, so the line needs
|
|
620
|
+
* no label and stays out of the operator's language.
|
|
621
|
+
*
|
|
622
|
+
* @param {{kind: "html"|"video", path: string}[]|undefined} artifacts
|
|
623
|
+
* @returns {string[]}
|
|
624
|
+
*/
|
|
625
|
+
function artifactLines(artifacts) {
|
|
626
|
+
return (artifacts ?? []).map((artifact) => `${artifact.kind === "video" ? "📹" : "📄"} ${artifact.path}`);
|
|
627
|
+
}
|
|
628
|
+
|
|
617
629
|
/**
|
|
618
630
|
* The delivered bullets are the only block that grows with the run, so they
|
|
619
631
|
* are dropped from the end first when the whole message would exceed the
|
|
@@ -10,9 +10,14 @@
|
|
|
10
10
|
* many lanes or runtimes prints in the same space as one with few. The
|
|
11
11
|
* dependency runs one way: the projector imports the renderers, and nothing
|
|
12
12
|
* here reads a filesystem path or a projection's inputs.
|
|
13
|
+
*
|
|
14
|
+
* The reserve the projection carries (ADR 0011) is policy state, not an
|
|
15
|
+
* indicator, so it prints on its own line after the indicator set, in the JSON
|
|
16
|
+
* form beside `indicators` instead of inside it.
|
|
13
17
|
*/
|
|
14
18
|
|
|
15
19
|
/** @typedef {import("../campaign/metrics.mjs").CampaignMetrics} CampaignMetrics */
|
|
20
|
+
/** @typedef {import("../campaign/spend.mjs").ReserveProjection} ReserveProjection */
|
|
16
21
|
/** @typedef {import("../campaign/metrics-command.mjs").MetricsSources} MetricsSources */
|
|
17
22
|
|
|
18
23
|
/** Schema of the `--json` form; bumped when a consumer would have to change. */
|
|
@@ -52,9 +57,41 @@ export function renderMetricsReport(sources, metrics) {
|
|
|
52
57
|
const missing = Array.isArray(indicator.missingSources) ? ` · missing ${indicator.missingSources.join(", ")}` : "";
|
|
53
58
|
lines.push(`${name.padEnd(NAME_WIDTH)} ${indicator.direction.padEnd(11)} ${formatValue(name, indicator.value).padEnd(24)} · ${records}${unknown}${missing}`);
|
|
54
59
|
}
|
|
60
|
+
const reserve = projectedReserve(metrics);
|
|
61
|
+
if (reserve !== undefined) lines.push(renderReserveLine(reserve));
|
|
55
62
|
return `${lines.join("\n")}\n`;
|
|
56
63
|
}
|
|
57
64
|
|
|
65
|
+
/** @param {CampaignMetrics} metrics @returns {ReserveProjection|undefined} */
|
|
66
|
+
function projectedReserve(metrics) {
|
|
67
|
+
return /** @type {{reserve?: ReserveProjection}} */ (/** @type {unknown} */ (metrics)).reserve;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The reserve line: the optional balance the campaign configured, what is
|
|
72
|
+
* still held against future charges, what was released once the real charge
|
|
73
|
+
* arrived, and the part of a late charge that went past its reservation. It
|
|
74
|
+
* says outright that the reserve is not an absolute cap, so a blocked dispatch
|
|
75
|
+
* is never read as provider-side enforcement of a ceiling (ADR 0011).
|
|
76
|
+
*
|
|
77
|
+
* @param {ReserveProjection} reserve
|
|
78
|
+
* @returns {string}
|
|
79
|
+
*/
|
|
80
|
+
function renderReserveLine(reserve) {
|
|
81
|
+
const incomplete = reserve.missingSources !== undefined;
|
|
82
|
+
const rounded = (/** @type {number} */ value) => String(Math.round(value * 100) / 100);
|
|
83
|
+
// A campaign that configured no balance never had one, which is a different
|
|
84
|
+
// fact from a balance the preserved sources cannot show.
|
|
85
|
+
const configured = reserve.configuredUsd === null ? "none" : rounded(reserve.configuredUsd);
|
|
86
|
+
const money = (/** @type {number|null} */ value) => value === null
|
|
87
|
+
? (incomplete ? "unknown" : "none")
|
|
88
|
+
: rounded(value);
|
|
89
|
+
const reservations = reserve.heldCount + reserve.reconciledCount;
|
|
90
|
+
const counts = `${reservations} reservation${reservations === 1 ? "" : "s"}${incomplete ? ` · missing ${reserve.missingSources?.join(", ")}` : ""}`;
|
|
91
|
+
const value = `configured=${configured} held=${money(reserve.heldUsd)} released=${money(reserve.releasedUsd)} charged=${money(reserve.chargedUsd)} late=${money(reserve.lateChargeUsd)}`;
|
|
92
|
+
return `${"reserve".padEnd(NAME_WIDTH)} ${"informative".padEnd(11)} ${value.padEnd(24)} · ${counts} · not an absolute cap`;
|
|
93
|
+
}
|
|
94
|
+
|
|
58
95
|
/** @param {{unknownCount: number, unknownFractionByReason?: Record<string, number>}} indicator @returns {string} */
|
|
59
96
|
function unknownCostSuffix(indicator) {
|
|
60
97
|
const reasons = Object.entries(indicator.unknownFractionByReason ?? {})
|
|
@@ -80,6 +117,7 @@ export function renderMetricsJson(sources, metrics) {
|
|
|
80
117
|
missingSources,
|
|
81
118
|
sameProviderReviewNodeCount: sameProviderReviewNodeIds(sources).length,
|
|
82
119
|
sameProviderReviewNodeIds: sameProviderReviewNodeIds(sources),
|
|
120
|
+
reserve: projectedReserve(metrics) ?? null,
|
|
83
121
|
indicators: metrics,
|
|
84
122
|
})}\n`;
|
|
85
123
|
}
|