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.
Files changed (60) hide show
  1. package/package.json +1 -1
  2. package/src/campaign/campaign-brief.mjs +17 -6
  3. package/src/campaign/chain.mjs +124 -8
  4. package/src/campaign/ledger.mjs +19 -7
  5. package/src/campaign/metrics-command.mjs +68 -9
  6. package/src/campaign/metrics.mjs +75 -45
  7. package/src/campaign/record.mjs +8 -1
  8. package/src/campaign/reserve.mjs +340 -0
  9. package/src/campaign/spend.mjs +229 -0
  10. package/src/campaign/unpark.mjs +54 -1
  11. package/src/campaign/watch.mjs +314 -37
  12. package/src/cli/brand.mjs +2 -1
  13. package/src/cli.mjs +52 -2
  14. package/src/contract/judge-envelope.mjs +17 -2
  15. package/src/contract/scope-findings.mjs +3 -1
  16. package/src/contract/task-packet.mjs +40 -5
  17. package/src/contract/verification.mjs +67 -1
  18. package/src/engine/aux-admission.mjs +124 -0
  19. package/src/engine/capacity.mjs +78 -0
  20. package/src/engine/dispatch.mjs +116 -23
  21. package/src/engine/failure-cause.mjs +59 -0
  22. package/src/engine/lifecycle.mjs +3 -2
  23. package/src/engine/phase-session.mjs +56 -10
  24. package/src/engine/prompts.mjs +57 -2
  25. package/src/engine/reserve-admission.mjs +99 -0
  26. package/src/engine/resume.mjs +3 -1
  27. package/src/engine/review.mjs +21 -0
  28. package/src/engine/run-command.mjs +166 -11
  29. package/src/engine/scheduler.mjs +92 -88
  30. package/src/engine/settle.mjs +15 -8
  31. package/src/engine/settlement-overlap.mjs +143 -0
  32. package/src/engine/verify.mjs +131 -7
  33. package/src/harnesses/session-metrics.mjs +112 -20
  34. package/src/notify/index.mjs +86 -10
  35. package/src/plan/freeze.mjs +7 -2
  36. package/src/plan/human-step.mjs +36 -0
  37. package/src/plan/pipeline.mjs +53 -22
  38. package/src/plan/preflight.mjs +81 -5
  39. package/src/plan/progress.mjs +101 -1
  40. package/src/plan/repo-facts.mjs +403 -49
  41. package/src/plan/resolve.mjs +7 -0
  42. package/src/plan/revise-context.mjs +108 -0
  43. package/src/plan/rounds.mjs +180 -45
  44. package/src/plan/sizing.mjs +33 -1
  45. package/src/plan/template.mjs +134 -22
  46. package/src/repo/integrate.mjs +47 -5
  47. package/src/report/campaign-brief-estimate.mjs +47 -1
  48. package/src/report/campaign-brief.mjs +31 -2
  49. package/src/report/locale.mjs +20 -0
  50. package/src/report/message.mjs +13 -1
  51. package/src/report/metrics-report.mjs +38 -0
  52. package/src/report/progress.mjs +194 -10
  53. package/src/report/render.mjs +1 -1
  54. package/src/report/report-html.mjs +235 -0
  55. package/src/report/report-video.mjs +323 -0
  56. package/src/run/usage-windows.mjs +99 -4
  57. package/src/run/usage.mjs +69 -7
  58. package/src/web/api.mjs +179 -24
  59. package/src/web/app.mjs +27 -2
  60. package/src/web/server.mjs +32 -0
@@ -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 (a
4
- * draft carrying the reviewer's findings), spec authoring, and spec review.
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", "repoFactsPath", "cataloguePath", "findingsPath", "planPath"],
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). Every node this
131
- * names is a complete node, because the plan it patches is in the reviser's
132
- * readFiles and the patch says only what changes: the node shape is
133
- * `PLAN_NODE_SHAPE`, one home for both strings so they cannot drift.
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 JSON 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. Carry only the nodes and fields a finding requires — a revise that omits what a finding names has not answered it.`;
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 list is a frozen table because it is the same for every
168
- * campaign; only the sizing sentence depends on what kind of package this is.
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
- const lines = kind !== "revise" || inputs.revisePatch !== true
176
- ? INSTRUCTIONS[kind]
177
- : INSTRUCTIONS[kind].map((line) => (line === REVISE_PLAN_INSTRUCTION ? REVISE_PATCH_INSTRUCTION : line));
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: "Revise the plan in readFiles to resolve every one of the reviewer's findings, changing only what a finding requires.",
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
- "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.",
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
- "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.",
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
- "Start from the plan JSON in readFiles, the plan the findings were raised against, and return it with only the changes the findings require. Keep every node id, write file, definitionOfDone item and contract-level suite that no finding asks you to change: a revise that redrafts from the findings alone loses what the plan already got right.",
346
+ REVISE_OPENING,
255
347
  "Read the findings and resolve every one; do not leave a critical or major finding unaddressed.",
256
- "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.",
348
+ PLAN_PHASES_INSTRUCTION,
257
349
  ...SCOPE_CLOSURE_RULE,
258
350
  NAMED_TEST_FILE_RULE,
259
- COVERING_TEST_RULE,
351
+ REVISE_FACT_SELECTION_RULE,
260
352
  ...CONTRACT_VERIFICATION_RULE,
261
353
  REVISE_PLAN_INSTRUCTION,
262
- "Never name a runtime, harness, model, or vendor anywhere in output.plan.",
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, and the plan under review; you have not seen how the plan was produced or any reasoning behind it. Review the artefact alone.",
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.repoFactsPath), /** @type {string} */ (inputs.cataloguePath), /** @type {string} */ (inputs.findingsPath), /** @type {string} */ (inputs.planPath)];
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)];
@@ -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 {BriefEstimateInput & {cost: BriefMeasureInput, duration: BriefMeasureInput, method: string[], assumptions: string[]}} BriefExpenseInput */
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
 
@@ -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
+ ];
@@ -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
  }