faberun 0.25.1 → 0.26.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 (52) hide show
  1. package/package.json +1 -1
  2. package/skills/faberun/references/contract.md +30 -26
  3. package/src/campaign/campaign-brief-graph.mjs +218 -0
  4. package/src/campaign/campaign-brief.mjs +15 -167
  5. package/src/campaign/metrics-command.mjs +3 -1
  6. package/src/cli/brand.mjs +1 -0
  7. package/src/cli/plan.mjs +75 -3
  8. package/src/cli/setup.mjs +22 -5
  9. package/src/cli.mjs +35 -1
  10. package/src/contract/definition-of-done.mjs +48 -8
  11. package/src/contract/human-step.mjs +54 -0
  12. package/src/contract/index.mjs +25 -38
  13. package/src/contract/judge-independence.mjs +170 -0
  14. package/src/contract/judges.mjs +27 -0
  15. package/src/contract/provider.mjs +103 -0
  16. package/src/contract/runtime.mjs +19 -2
  17. package/src/contract/snapshot.mjs +31 -1
  18. package/src/engine/assignment.mjs +41 -5
  19. package/src/engine/backoff.mjs +30 -9
  20. package/src/engine/dispatch.mjs +19 -2
  21. package/src/engine/failover.mjs +10 -2
  22. package/src/engine/judge-gate.mjs +24 -4
  23. package/src/engine/judge-list.mjs +206 -0
  24. package/src/engine/lifecycle.mjs +19 -16
  25. package/src/engine/resume.mjs +46 -9
  26. package/src/engine/runtime-discovery.mjs +72 -11
  27. package/src/engine/scheduler.mjs +27 -6
  28. package/src/harnesses/catalogue.mjs +9 -2
  29. package/src/harnesses/index.mjs +2 -2
  30. package/src/harnesses/model-tiers.mjs +39 -0
  31. package/src/harnesses/protocol.mjs +4 -1
  32. package/src/host/config.mjs +7 -2
  33. package/src/plan/contest.mjs +78 -0
  34. package/src/plan/freeze.mjs +118 -7
  35. package/src/plan/human-step.mjs +38 -0
  36. package/src/plan/pipeline.mjs +224 -313
  37. package/src/plan/preflight.mjs +5 -3
  38. package/src/plan/proof-check.mjs +161 -0
  39. package/src/plan/proof-run.mjs +6 -2
  40. package/src/plan/proof-scope.mjs +175 -0
  41. package/src/plan/resolve.mjs +182 -0
  42. package/src/plan/reviewer.mjs +82 -0
  43. package/src/plan/rounds.mjs +446 -0
  44. package/src/plan/routing.mjs +11 -4
  45. package/src/plan/template.mjs +36 -14
  46. package/src/repo/signal-block.mjs +10 -3
  47. package/src/repo/source-identity.mjs +45 -5
  48. package/src/repo/worktree-prune.mjs +72 -0
  49. package/src/report/campaign-brief-estimate.mjs +1 -1
  50. package/src/report/campaign-brief.mjs +4 -0
  51. package/src/report/metrics-report.mjs +34 -1
  52. package/src/report/render.mjs +31 -10
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "faberun",
3
- "version": "0.25.1",
3
+ "version": "0.26.0",
4
4
  "description": "Faberun is a development orchestration system that turns intent into verified software: harness- and model-agnostic, it keeps the intent, coordinates the work, verifies the result and decides what happens next.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -22,9 +22,9 @@ typecheck`). Schema version is `3`.
22
22
  "vendor": "deepseek", "sandbox": "danger-full-access",
23
23
  "config": { "provider": "deepseek-official", "api_key.env_key": "DEEPSEEK_API_KEY" } },
24
24
  "luna": { "harness": "codex", "model": "gpt-5.6-luna", "reasoning": "xhigh" },
25
- "sol": { "harness": "codex", "model": "gpt-5.6-sol", "reasoning": "xhigh", "vendor": "openai-sol" },
25
+ "sol": { "harness": "codex", "model": "gpt-5.6-sol", "reasoning": "xhigh" },
26
26
  "opus": { "harness": "claude", "model": "opus", "permissionMode": "acceptEdits" },
27
- "zcode-flash": { "harness": "zcode", "model": "glm-5.3-flash", "vendor": "zhipu-flash", "permissionMode": "edit" },
27
+ "zcode-flash": { "harness": "zcode", "model": "glm-5.3-flash", "permissionMode": "edit" },
28
28
  "agy-flash": { "harness": "agy", "model": "gemini-3.8-flash-low" }
29
29
  },
30
30
  "nodes": [
@@ -148,32 +148,36 @@ to its `readFiles` (every planning stage) delivers through `output`, `artifacts`
148
148
  ## Runtimes and routing
149
149
 
150
150
  Resolve a worker as `nodes[].runtime`, then `runtimeDefaults.worker`; a judge
151
- as `nodes[].gate.runtime`, then `runtimeDefaults.judge`. When `runtimes` and
152
- `runtimeDefaults` are both omitted, the factory composes them from the
153
- discovery catalogue (`DISCOVERY_RUNTIME_DEFINITIONS`: `dsh-deepseek`,
154
- `zcode-glm`, `agy-gemini` at tier 1, `codex-gpt` and `claude-sonnet` at tier 2;
155
- available when the binary answers and every `config["*.env_key"]` it names is
156
- set): the cheapest available runtime executes, the strongest runtime of a
157
- *different vendor* judges, persisted in `routing.assignments`; no admissible
158
- cross-vendor judge fails by name (`runtime_assignment_judge_unavailable`).
151
+ as `nodes[].gate.runtime`, then `runtimeDefaults.judge`, then a contract's (or
152
+ absent one, the machine config's) ordered `judges` list: its first entry off
153
+ the worker's provider and its fallback chain's, unrefused and under a 90%
154
+ usage window, hopping the same way on a later refusal without repeats,
155
+ replacing any declared `fallback` edge outright, and blocking the node by name
156
+ when every entry is skipped (R18).
157
+ When `runtimes` and `runtimeDefaults` are both omitted, the factory composes
158
+ them from the discovery catalogue (`DISCOVERY_RUNTIME_DEFINITIONS`:
159
+ `dsh-deepseek`, `zcode-glm`, `agy-gemini` at tier 1, `codex-gpt` and
160
+ `claude-sonnet` at tier 2; available when the binary answers and every
161
+ `config["*.env_key"]` it names is set): the cheapest available runtime
162
+ executes, the strongest runtime of a *different vendor* judges, persisted in
163
+ `routing.assignments`; no admissible cross-vendor judge fails by name
164
+ (`runtime_assignment_judge_unavailable`).
159
165
 
160
166
  `harness` names the adapter that runs the turn (`claude`, `codex`, `agy`,
161
- `dsh`, `zcode`, `exec-jsonl`, `replay`) and `model` what it asks; the two
162
- vary independently — DeepSeek answers through `dsh`, GLM through `zcode`. Name
163
- a runtime id `<harness>-<model>` so a recorded run says which harness produced
164
- it; ids take letters, numbers, dot, underscore, dash only. Vendor is resolved (`resolveVendor` in `harnesses/index.mjs`), not the
165
- harness name: an explicit `vendor`, else a provider-config override (a codex
166
- runtime with `config.model_provider: "deepseek"` is vendor `deepseek`), else
167
- the harness default (`claude`→anthropic, `codex`→openai, `agy`→google,
168
- `zcode`→zhipu); `dsh`/`replay`/`exec-jsonl` have no default and must declare
169
- `vendor`. Validation rejects a gate-enabled node whose worker and judge
170
- resolve to the same vendor, and does the same for every runtime in the
171
- worker's declared fallback chain (rejecting a cycle in that chain outright)
172
- — all statically knowable from the contract alone. The symmetric case, a
173
- judge fallback landing on the vendor of the worker runtime that actually ran,
174
- cannot be checked statically (it depends on which worker runtime ran this
175
- attempt) and is instead refused at execution; see Failover below. Two models of one family (a GLM 5.3-flash worker judged by GLM 5.3) pair only by
176
- declaring distinct `vendor` strings — a claim about review independence.
167
+ `dsh`, `zcode`, `exec-jsonl`, `replay`) and `model` what it asks; the two vary
168
+ independently — DeepSeek answers through `dsh`, GLM through `zcode`. Name a
169
+ runtime id `<harness>-<model>` so a recorded run says which harness produced
170
+ it; ids take letters, numbers, dot, underscore, dash only. Vendor is the
171
+ canonical provider (`canonicalProvider` in `src/contract/provider.mjs`)
172
+ derived from route, model family, or harness default (`claude`→anthropic,
173
+ `codex`→openai, `agy`→google, `zcode`→zhipu; `dsh`/`replay`/`exec-jsonl` have
174
+ none and must declare `vendor`), falling back to `resolveVendor` only when it
175
+ derives none; a declared `vendor` that contradicts it is refused. Validation
176
+ rejects a gate-enabled node whose worker and judge resolve to the same
177
+ vendor, and does the same for every runtime in the worker's declared fallback
178
+ chain (rejecting a cycle outright). The symmetric case for a judge fallback
179
+ depends on which worker runtime ran and is refused at execution instead; see
180
+ Failover below.
177
181
 
178
182
  An optional `runtimes[<id>].pricing` object declares `inputPerMTok`,
179
183
  `cachedInputPerMTok`, and `outputPerMTok` (each finite and >= 0, at
@@ -0,0 +1,218 @@
1
+ /**
2
+ * The Campaign Brief's work graph: dependency edges, per-node worker/judge
3
+ * runtimes, per-runtime concurrency caps and the schedule they allow. Split
4
+ * out of `campaign-brief.mjs` (R20), already at this repository's 800-line
5
+ * ceiling, so the same-provider review marking (R20) had a file with room to
6
+ * land in.
7
+ *
8
+ * The frozen contract this reads has already passed `validateContract`, which
9
+ * is the one place that enforces same-vendor mode's tier rule
10
+ * (`contract/judge-independence.mjs`); a node this module marks
11
+ * `sameProviderReview` could only exist here because that check already
12
+ * passed, so the mark is a plain vendor-and-opt-in comparison, not a second
13
+ * enforcement of the rule.
14
+ */
15
+ import { asArray } from "./brief-text.mjs";
16
+ import { sharesVendorThroughFallback } from "../contract/judge-independence.mjs";
17
+
18
+ /** @typedef {Record<string, any>} AnyRecord */
19
+ /** @typedef {{id: string, runtimeId: string|null, model: string|null, judgeRuntimeId?: string|null, sameProviderReview?: boolean, dependsOn: string[], requirementIds: string[]}} BriefGraphNode */
20
+ /** @typedef {{from: string, to: string}} BriefGraphEdge */
21
+ /** @typedef {{node: string, prerequisites: string[]}} BriefBlockingNode */
22
+ /** @typedef {{nodes: BriefGraphNode[], edges: BriefGraphEdge[], independent: string[], blocking: BriefBlockingNode[], maxParallel: number, maxConcurrent: Record<string, number>, effectiveConcurrency: number, dispatchableTogether: string[], dispatchNote: string, gaps: string[]}} BriefGraph */
23
+
24
+ /**
25
+ * @param {AnyRecord} contract
26
+ * @param {AnyRecord[]} nodes
27
+ * @returns {BriefGraph}
28
+ */
29
+ export function buildGraph(contract, nodes) {
30
+ const runtimes = contract.runtimes && typeof contract.runtimes === "object" && !Array.isArray(contract.runtimes)
31
+ ? /** @type {Record<string, AnyRecord>} */ (contract.runtimes)
32
+ : {};
33
+ const ids = new Set(nodes.map((node) => String(node.id ?? "")));
34
+ /** @type {string[]} */
35
+ const gaps = [];
36
+ /** @type {BriefGraphEdge[]} */
37
+ const edges = [];
38
+ /** @type {BriefGraphNode[]} */
39
+ const graphNodes = nodes.map((node) => {
40
+ const id = String(node.id ?? "");
41
+ const dependsOn = asArray(node.dependsOn).map(String);
42
+ for (const dependency of dependsOn) {
43
+ if (dependency === id) {
44
+ gaps.push(`node ${id} depends on itself`);
45
+ } else if (!ids.has(dependency)) {
46
+ gaps.push(`node ${id} depends on unknown node ${dependency}`);
47
+ } else {
48
+ edges.push({ from: dependency, to: id });
49
+ }
50
+ }
51
+ const runtimeId = typeof node.runtime === "string"
52
+ ? node.runtime
53
+ : typeof contract.runtimeDefaults?.worker === "string" ? contract.runtimeDefaults.worker : null;
54
+ const runtime = runtimeId !== null && typeof runtimes[runtimeId] === "object" ? runtimes[runtimeId] : null;
55
+ return {
56
+ id,
57
+ runtimeId,
58
+ model: runtime && typeof runtime.model === "string" ? runtime.model : null,
59
+ judgeRuntimeId: judgeRuntimeIdOf(contract, node),
60
+ sameProviderReview: sameProviderReviewOf(contract, runtimes, runtimeId, node),
61
+ dependsOn,
62
+ requirementIds: asArray(node.requirementIds).map(String),
63
+ };
64
+ });
65
+ const independent = graphNodes.filter((node) => node.dependsOn.length === 0).map((node) => node.id);
66
+ const blocking = graphNodes.filter((node) => node.dependsOn.length > 0).map((node) => ({ node: node.id, prerequisites: node.dependsOn }));
67
+ /** @type {Record<string, number>} */
68
+ const maxConcurrent = {};
69
+ for (const node of graphNodes) {
70
+ if (node.runtimeId === null) continue;
71
+ const runtime = runtimes[node.runtimeId];
72
+ const limit = runtime && Number.isInteger(runtime.maxConcurrent) && runtime.maxConcurrent > 0 ? runtime.maxConcurrent : 1;
73
+ maxConcurrent[node.runtimeId] = maxConcurrent[node.runtimeId] === undefined ? limit : Math.min(maxConcurrent[node.runtimeId], limit);
74
+ }
75
+ const maxParallel = Number.isInteger(contract.maxParallel) && contract.maxParallel > 0 ? contract.maxParallel : 1;
76
+ // Effective concurrency, and the workers that can actually be in flight
77
+ // together, are properties of the graph and the capacities -- not the size of
78
+ // the dependency-independent set. Two independent nodes still cannot run
79
+ // together when maxParallel is 1 or their shared runtime is at its cap.
80
+ const schedule = scheduleUnderCapacity(
81
+ graphNodes.map((node) => ({ id: node.id, runtimeId: node.runtimeId, dependsOn: node.dependsOn })),
82
+ () => 1,
83
+ maxParallel,
84
+ maxConcurrent,
85
+ );
86
+ const effectiveConcurrency = Math.max(1, schedule.peakConcurrency);
87
+ const dispatchableTogether = schedule.peakNodeIds;
88
+ return {
89
+ nodes: graphNodes,
90
+ edges,
91
+ independent,
92
+ blocking,
93
+ maxParallel,
94
+ maxConcurrent,
95
+ effectiveConcurrency,
96
+ dispatchableTogether,
97
+ dispatchNote: dispatchNoteFor(dispatchableTogether, graphNodes.length, maxParallel),
98
+ gaps,
99
+ };
100
+ }
101
+
102
+ /**
103
+ * @param {AnyRecord} contract
104
+ * @param {AnyRecord} node
105
+ * @returns {string|null}
106
+ */
107
+ function judgeRuntimeIdOf(contract, node) {
108
+ const gate = node.gate;
109
+ const gateEnabled = gate !== false && gate !== undefined && (gate.enabled === undefined || gate.enabled === true);
110
+ if (!gateEnabled) return null;
111
+ if (typeof gate.runtime === "string") return gate.runtime;
112
+ return typeof contract.runtimeDefaults?.judge === "string" ? contract.runtimeDefaults.judge : null;
113
+ }
114
+
115
+ /**
116
+ * Whether the frozen contract already marked this node's pairing admitted
117
+ * under same-vendor mode (R20): the worker's declared runtime, or its one-hop
118
+ * fallback, shares a vendor with the judge, and only `judgeIndependence:
119
+ * "same-vendor"` on the contract admits that -- `validateContract` refuses
120
+ * every other same-vendor pairing (primary or fallback) before it can reach a
121
+ * frozen `contract.json`. The fallback is included because it is a real way
122
+ * this node lands a same-provider review, not only the primary pairing: a
123
+ * plan-time surface has no run to read, so it reports every reachable
124
+ * pairing rather than only the one dispatch happens to pick.
125
+ *
126
+ * @param {AnyRecord} contract
127
+ * @param {Record<string, AnyRecord>} runtimes
128
+ * @param {string|null} workerRuntimeId
129
+ * @param {AnyRecord} node
130
+ * @returns {boolean}
131
+ */
132
+ function sameProviderReviewOf(contract, runtimes, workerRuntimeId, node) {
133
+ if (contract.judgeIndependence !== "same-vendor") return false;
134
+ const judgeRuntimeId = judgeRuntimeIdOf(contract, node);
135
+ if (workerRuntimeId === null || judgeRuntimeId === null) return false;
136
+ const judgeVendor = runtimes[judgeRuntimeId]?.vendor;
137
+ return sharesVendorThroughFallback(runtimes, workerRuntimeId, judgeVendor);
138
+ }
139
+
140
+ /**
141
+ * Why capacity does or does not let two workers run at the same time. The note
142
+ * is the model's honest reading of the limits, so a renderer never has to
143
+ * decide whether an independent pair is "simultaneously dispatchable": with
144
+ * `maxParallel` 1 the answer is always no.
145
+ *
146
+ * @param {string[]} dispatchableTogether
147
+ * @param {number} nodeCount
148
+ * @param {number} maxParallel
149
+ * @returns {string}
150
+ */
151
+ function dispatchNoteFor(dispatchableTogether, nodeCount, maxParallel) {
152
+ if (dispatchableTogether.length >= 2) {
153
+ return `up to ${dispatchableTogether.length} workers run together under maxParallel ${maxParallel} and the per-runtime maxConcurrent caps`;
154
+ }
155
+ if (nodeCount < 2) return "fewer than two nodes are planned";
156
+ if (maxParallel <= 1) return "maxParallel is 1, so dependency-independent nodes are not simultaneously dispatchable";
157
+ return "each assigned runtime's maxConcurrent admits only one worker at a time";
158
+ }
159
+
160
+ /**
161
+ * Schedule a dependency graph under a global `maxParallel` ceiling and each
162
+ * runtime's own `maxConcurrent` ceiling, reporting the wall-clock makespan and
163
+ * the peak number of nodes running at once. `durationOf` supplies each node's
164
+ * duration, so scheduling the graph with lower and upper per-node durations
165
+ * turns a per-node range into a plan-level elapsed range. The peak is a
166
+ * property of the graph and the capacities, not of the dependency-independent
167
+ * set.
168
+ *
169
+ * @param {{id: string, runtimeId: string|null, dependsOn: string[]}[]} nodes
170
+ * @param {(node: {id: string}) => number} durationOf
171
+ * @param {number} maxParallel
172
+ * @param {Record<string, number>} maxConcurrent
173
+ * @returns {{makespanMs: number, peakConcurrency: number, peakNodeIds: string[]}}
174
+ */
175
+ export function scheduleUnderCapacity(nodes, durationOf, maxParallel, maxConcurrent) {
176
+ const byId = new Map(nodes.map((node) => [node.id, node]));
177
+ const remaining = new Map();
178
+ const dependents = new Map();
179
+ for (const node of nodes) {
180
+ const dependencies = node.dependsOn.filter((id) => byId.has(id));
181
+ remaining.set(node.id, dependencies.length);
182
+ for (const dependency of dependencies) dependents.set(dependency, [...(dependents.get(dependency) ?? []), node.id]);
183
+ }
184
+ const running = new Map();
185
+ const runtimeRunning = new Map();
186
+ const done = new Set();
187
+ const limit = Number.isInteger(maxParallel) && maxParallel > 0 ? maxParallel : 1;
188
+ let time = 0;
189
+ let peak = 0;
190
+ /** @type {string[]} */
191
+ let peakNodeIds = [];
192
+ while (done.size < nodes.length) {
193
+ for (const node of nodes) {
194
+ if (done.has(node.id) || running.has(node.id) || (remaining.get(node.id) ?? 0) > 0) continue;
195
+ if (running.size >= limit) break;
196
+ const runtimeKey = node.runtimeId ?? "";
197
+ const width = node.runtimeId !== null && Number.isInteger(maxConcurrent[node.runtimeId]) && maxConcurrent[node.runtimeId] > 0 ? maxConcurrent[node.runtimeId] : 1;
198
+ if ((runtimeRunning.get(runtimeKey) ?? 0) >= width) continue;
199
+ const duration = durationOf(node);
200
+ running.set(node.id, time + (Number.isFinite(duration) ? Math.max(0, duration) : 0));
201
+ runtimeRunning.set(runtimeKey, (runtimeRunning.get(runtimeKey) ?? 0) + 1);
202
+ if (running.size > peak) {
203
+ peak = running.size;
204
+ peakNodeIds = [...running.keys()];
205
+ }
206
+ }
207
+ if (running.size === 0) break; // a cycle or nothing dispatchable
208
+ time = Math.min(...running.values());
209
+ for (const [id, finish] of [...running]) {
210
+ if (finish !== time) continue;
211
+ running.delete(id);
212
+ runtimeRunning.set(byId.get(id)?.runtimeId ?? "", Math.max(0, (runtimeRunning.get(byId.get(id)?.runtimeId ?? "") ?? 1) - 1));
213
+ done.add(id);
214
+ for (const dependent of dependents.get(id) ?? []) remaining.set(dependent, Math.max(0, (remaining.get(dependent) ?? 1) - 1));
215
+ }
216
+ }
217
+ return { makespanMs: time, peakConcurrency: peak, peakNodeIds };
218
+ }
@@ -26,6 +26,7 @@ import { contentDigest, fileDigest } from "../plan/freeze.mjs";
26
26
  import { parseSpec } from "../plan/spec.mjs";
27
27
  import { errorCode } from "../util.mjs";
28
28
  import { asArray, bulletLines, firstSentence, riskRows, successCriteriaRows, unique } from "./brief-text.mjs";
29
+ import { buildGraph } from "./campaign-brief-graph.mjs";
29
30
  import { projectCampaignDecisions } from "./projection.mjs";
30
31
 
31
32
  /** @typedef {import("../plan/spec.mjs").ParsedSpec} ParsedSpec */
@@ -41,10 +42,7 @@ import { projectCampaignDecisions } from "./projection.mjs";
41
42
  /** @typedef {{id: string, proof: string}} BriefProofNode */
42
43
  /** @typedef {{requirementId: string, title: string, declaredNodeIds: string[], nodes: BriefProofNode[], state: CoverageState, reasons: string[]}} BriefCoverageRow */
43
44
  /** @typedef {{rows: BriefCoverageRow[], unknownIds: string[], unknownDeclared: string[], unknownStamped: string[], gaps: string[], specPath: string, planPath: string, covered: number, uncovered: number, outside: number, traceabilityMissing: number, total: number}} BriefCoverage */
44
- /** @typedef {{id: string, runtimeId: string|null, model: string|null, dependsOn: string[], requirementIds: string[]}} BriefGraphNode */
45
- /** @typedef {{from: string, to: string}} BriefGraphEdge */
46
- /** @typedef {{node: string, prerequisites: string[]}} BriefBlockingNode */
47
- /** @typedef {{nodes: BriefGraphNode[], edges: BriefGraphEdge[], independent: string[], blocking: BriefBlockingNode[], maxParallel: number, maxConcurrent: Record<string, number>, effectiveConcurrency: number, dispatchableTogether: string[], dispatchNote: string, gaps: string[]}} BriefGraph */
45
+ /** @typedef {import("./campaign-brief-graph.mjs").BriefGraph} BriefGraph */
48
46
  /** @typedef {{status: "range"|"insufficient data", min: number|null, max: number|null, samples: number|null, reason: string|null, sourceRuns: string[], method: string|null, provenance: string|null}} BriefMeasure */
49
47
  /** @typedef {{status?: "range"|"insufficient data", min?: number|null, max?: number|null, samples?: number|null, reason?: string|null, sourceRuns?: string[], method?: string|null, provenance?: string|null}} BriefMeasureInput */
50
48
  /** @typedef {{cost?: BriefMeasureInput, duration?: BriefMeasureInput, runtimes?: string[], models?: string[], effectiveConcurrency?: number, nodeCount?: number, workerCount?: number, sampleCutoff?: string|null, method?: string[], assumptions?: string[]}} BriefEstimateInput */
@@ -156,7 +154,12 @@ export function buildBriefModel(options) {
156
154
  const coverage = buildCoverage(parsedSpec, declarations, contractNodes, specPath, planPath);
157
155
  const graph = buildGraph(contract, contractNodes);
158
156
  const journalDecisions = options.projection ? projectCampaignDecisions(options.projection) : [];
159
- const decisions = buildDecisions(parsedSpec, journalDecisions);
157
+ // R16: an operator step the frozen plan carries as an explicit stop
158
+ // (`plan.humanSteps`, `src/plan/human-step.mjs`) is a human decision the
159
+ // spec's own "Human decisions" bullets cannot list, since it is declared on
160
+ // a requirement's constraints instead of that section.
161
+ const humanSteps = asArray(plan.humanSteps);
162
+ const decisions = buildDecisions(parsedSpec, journalDecisions, humanSteps);
160
163
  const estimate = buildEstimate(options.estimate, graph, options.usageSampleCutoff ?? null);
161
164
  const opening = buildOpening(parsedSpec, coverage, graph, estimate, decisions);
162
165
  const identity = buildIdentity({
@@ -391,166 +394,6 @@ function buildCoverage(parsedSpec, declarations, nodes, specPath, planPath) {
391
394
  };
392
395
  }
393
396
 
394
- /**
395
- * The actual `dependsOn` graph plus the contract's capacity limits. The
396
- * dependency-independent set is distinct from the workers capacity actually
397
- * lets dispatch together.
398
- *
399
- * @param {AnyRecord} contract
400
- * @param {AnyRecord[]} nodes
401
- * @returns {BriefGraph}
402
- */
403
- function buildGraph(contract, nodes) {
404
- const runtimes = contract.runtimes && typeof contract.runtimes === "object" && !Array.isArray(contract.runtimes)
405
- ? /** @type {Record<string, AnyRecord>} */ (contract.runtimes)
406
- : {};
407
- const ids = new Set(nodes.map((node) => String(node.id ?? "")));
408
- /** @type {string[]} */
409
- const gaps = [];
410
- /** @type {BriefGraphEdge[]} */
411
- const edges = [];
412
- /** @type {BriefGraphNode[]} */
413
- const graphNodes = nodes.map((node) => {
414
- const id = String(node.id ?? "");
415
- const dependsOn = asArray(node.dependsOn).map(String);
416
- for (const dependency of dependsOn) {
417
- if (dependency === id) {
418
- gaps.push(`node ${id} depends on itself`);
419
- } else if (!ids.has(dependency)) {
420
- gaps.push(`node ${id} depends on unknown node ${dependency}`);
421
- } else {
422
- edges.push({ from: dependency, to: id });
423
- }
424
- }
425
- const runtimeId = typeof node.runtime === "string"
426
- ? node.runtime
427
- : typeof contract.runtimeDefaults?.worker === "string" ? contract.runtimeDefaults.worker : null;
428
- const runtime = runtimeId !== null && typeof runtimes[runtimeId] === "object" ? runtimes[runtimeId] : null;
429
- return {
430
- id,
431
- runtimeId,
432
- model: runtime && typeof runtime.model === "string" ? runtime.model : null,
433
- dependsOn,
434
- requirementIds: asArray(node.requirementIds).map(String),
435
- };
436
- });
437
- const independent = graphNodes.filter((node) => node.dependsOn.length === 0).map((node) => node.id);
438
- const blocking = graphNodes.filter((node) => node.dependsOn.length > 0).map((node) => ({ node: node.id, prerequisites: node.dependsOn }));
439
- /** @type {Record<string, number>} */
440
- const maxConcurrent = {};
441
- for (const node of graphNodes) {
442
- if (node.runtimeId === null) continue;
443
- const runtime = runtimes[node.runtimeId];
444
- const limit = runtime && Number.isInteger(runtime.maxConcurrent) && runtime.maxConcurrent > 0 ? runtime.maxConcurrent : 1;
445
- maxConcurrent[node.runtimeId] = maxConcurrent[node.runtimeId] === undefined ? limit : Math.min(maxConcurrent[node.runtimeId], limit);
446
- }
447
- const maxParallel = Number.isInteger(contract.maxParallel) && contract.maxParallel > 0 ? contract.maxParallel : 1;
448
- // Effective concurrency, and the workers that can actually be in flight
449
- // together, are properties of the graph and the capacities -- not the size of
450
- // the dependency-independent set. Two independent nodes still cannot run
451
- // together when maxParallel is 1 or their shared runtime is at its cap.
452
- const schedule = scheduleUnderCapacity(
453
- graphNodes.map((node) => ({ id: node.id, runtimeId: node.runtimeId, dependsOn: node.dependsOn })),
454
- () => 1,
455
- maxParallel,
456
- maxConcurrent,
457
- );
458
- const effectiveConcurrency = Math.max(1, schedule.peakConcurrency);
459
- const dispatchableTogether = schedule.peakNodeIds;
460
- return {
461
- nodes: graphNodes,
462
- edges,
463
- independent,
464
- blocking,
465
- maxParallel,
466
- maxConcurrent,
467
- effectiveConcurrency,
468
- dispatchableTogether,
469
- dispatchNote: dispatchNoteFor(dispatchableTogether, graphNodes.length, maxParallel),
470
- gaps,
471
- };
472
- }
473
-
474
- /**
475
- * Why capacity does or does not let two workers run at the same time. The note
476
- * is the model's honest reading of the limits, so a renderer never has to
477
- * decide whether an independent pair is "simultaneously dispatchable": with
478
- * `maxParallel` 1 the answer is always no.
479
- *
480
- * @param {string[]} dispatchableTogether
481
- * @param {number} nodeCount
482
- * @param {number} maxParallel
483
- * @returns {string}
484
- */
485
- function dispatchNoteFor(dispatchableTogether, nodeCount, maxParallel) {
486
- if (dispatchableTogether.length >= 2) {
487
- return `up to ${dispatchableTogether.length} workers run together under maxParallel ${maxParallel} and the per-runtime maxConcurrent caps`;
488
- }
489
- if (nodeCount < 2) return "fewer than two nodes are planned";
490
- if (maxParallel <= 1) return "maxParallel is 1, so dependency-independent nodes are not simultaneously dispatchable";
491
- return "each assigned runtime's maxConcurrent admits only one worker at a time";
492
- }
493
-
494
- /**
495
- * Schedule a dependency graph under a global `maxParallel` ceiling and each
496
- * runtime's own `maxConcurrent` ceiling, reporting the wall-clock makespan and
497
- * the peak number of nodes running at once. `durationOf` supplies each node's
498
- * duration, so scheduling the graph with lower and upper per-node durations
499
- * turns a per-node range into a plan-level elapsed range. The peak is a
500
- * property of the graph and the capacities, not of the dependency-independent
501
- * set.
502
- *
503
- * @param {{id: string, runtimeId: string|null, dependsOn: string[]}[]} nodes
504
- * @param {(node: {id: string}) => number} durationOf
505
- * @param {number} maxParallel
506
- * @param {Record<string, number>} maxConcurrent
507
- * @returns {{makespanMs: number, peakConcurrency: number, peakNodeIds: string[]}}
508
- */
509
- export function scheduleUnderCapacity(nodes, durationOf, maxParallel, maxConcurrent) {
510
- const byId = new Map(nodes.map((node) => [node.id, node]));
511
- const remaining = new Map();
512
- const dependents = new Map();
513
- for (const node of nodes) {
514
- const dependencies = node.dependsOn.filter((id) => byId.has(id));
515
- remaining.set(node.id, dependencies.length);
516
- for (const dependency of dependencies) dependents.set(dependency, [...(dependents.get(dependency) ?? []), node.id]);
517
- }
518
- const running = new Map();
519
- const runtimeRunning = new Map();
520
- const done = new Set();
521
- const limit = Number.isInteger(maxParallel) && maxParallel > 0 ? maxParallel : 1;
522
- let time = 0;
523
- let peak = 0;
524
- /** @type {string[]} */
525
- let peakNodeIds = [];
526
- while (done.size < nodes.length) {
527
- for (const node of nodes) {
528
- if (done.has(node.id) || running.has(node.id) || (remaining.get(node.id) ?? 0) > 0) continue;
529
- if (running.size >= limit) break;
530
- const runtimeKey = node.runtimeId ?? "";
531
- const width = node.runtimeId !== null && Number.isInteger(maxConcurrent[node.runtimeId]) && maxConcurrent[node.runtimeId] > 0 ? maxConcurrent[node.runtimeId] : 1;
532
- if ((runtimeRunning.get(runtimeKey) ?? 0) >= width) continue;
533
- const duration = durationOf(node);
534
- running.set(node.id, time + (Number.isFinite(duration) ? Math.max(0, duration) : 0));
535
- runtimeRunning.set(runtimeKey, (runtimeRunning.get(runtimeKey) ?? 0) + 1);
536
- if (running.size > peak) {
537
- peak = running.size;
538
- peakNodeIds = [...running.keys()];
539
- }
540
- }
541
- if (running.size === 0) break; // a cycle or nothing dispatchable
542
- time = Math.min(...running.values());
543
- for (const [id, finish] of [...running]) {
544
- if (finish !== time) continue;
545
- running.delete(id);
546
- runtimeRunning.set(byId.get(id)?.runtimeId ?? "", Math.max(0, (runtimeRunning.get(byId.get(id)?.runtimeId ?? "") ?? 1) - 1));
547
- done.add(id);
548
- for (const dependent of dependents.get(id) ?? []) remaining.set(dependent, Math.max(0, (remaining.get(dependent) ?? 1) - 1));
549
- }
550
- }
551
- return { makespanMs: time, peakConcurrency: peak, peakNodeIds };
552
- }
553
-
554
397
  /**
555
398
  * Human decisions and delegated decisions from the explicit spec sections and
556
399
  * the active campaign journal projection; risks and planned evals only from
@@ -561,12 +404,17 @@ export function scheduleUnderCapacity(nodes, durationOf, maxParallel, maxConcurr
561
404
  *
562
405
  * @param {ParsedSpec} parsedSpec
563
406
  * @param {ProjectedDecision[]} journal
407
+ * @param {AnyRecord[]} [humanSteps] the frozen plan's `humanSteps` (R16)
564
408
  * @returns {BriefDecisions}
565
409
  */
566
- function buildDecisions(parsedSpec, journal) {
410
+ function buildDecisions(parsedSpec, journal, humanSteps = []) {
567
411
  /** @param {string} name @returns {string} */
568
412
  const section = (name) => parsedSpec.sections.get(name)?.body ?? "";
569
- const human = [...bulletLines(section("human decisions")), ...bulletLines(section("settled owner decisions"))];
413
+ const human = [
414
+ ...bulletLines(section("human decisions")),
415
+ ...bulletLines(section("settled owner decisions")),
416
+ ...humanSteps.map((step) => `${step.requirementId}: ${step.step}`),
417
+ ];
570
418
  const delegated = bulletLines(section("delegable decisions"));
571
419
  const evals = bulletLines(section("planned evals"));
572
420
  const risks = riskRows(section("risks"));
@@ -15,7 +15,7 @@ import { listNodeSnapshots, nodeSnapshotPath } from "../run/node-store.mjs";
15
15
  import { runsRoot } from "../run/paths.mjs";
16
16
 
17
17
  /** @typedef {Record<string, unknown>} JsonObject */
18
- /** @typedef {{runId: string, id: string, status: string, attempt?: number|null, revisions?: number|null, review?: string|null}} RunNode */
18
+ /** @typedef {{runId: string, id: string, status: string, attempt?: number|null, revisions?: number|null, review?: string|null, sameProviderReview?: boolean|null}} RunNode */
19
19
 
20
20
  /** Flags of `faberun metrics`, declared here so the router only names them. */
21
21
  /** @type {import("node:util").ParseArgsOptionsConfig} */
@@ -153,6 +153,7 @@ function readLedgerNodeSnapshots(path) {
153
153
  attempt: typeof object.attempt === "number" ? object.attempt : null,
154
154
  revisions: typeof object.revisions === "number" ? object.revisions : null,
155
155
  review: typeof object.review === "string" ? object.review : null,
156
+ sameProviderReview: typeof object.sameProviderReview === "boolean" ? object.sameProviderReview : null,
156
157
  }];
157
158
  });
158
159
  } catch {
@@ -187,6 +188,7 @@ export function readMetricNodeSnapshots(runDir) {
187
188
  attempt: typeof record.attempt === "number" ? record.attempt : null,
188
189
  revisions: typeof record.revisions === "number" ? record.revisions : null,
189
190
  review: typeof record.review === "string" ? record.review : null,
191
+ sameProviderReview: typeof record.sameProviderReview === "boolean" ? record.sameProviderReview : null,
190
192
  });
191
193
  }
192
194
  return nodes;
package/src/cli/brand.mjs CHANGED
@@ -192,6 +192,7 @@ export function renderUsage() {
192
192
  "update [--check] [--json]",
193
193
  "project <new-path> [--from <old-path>]",
194
194
  "migrate [--cwd <dir>]",
195
+ "prune [--cwd <dir>] [--parked] [--json]",
195
196
  "models [--probe] [--json]",
196
197
  "next [--cwd <dir>] [--json]",
197
198
  "bulk-read --question <text> --paths <a,b,c> [--json]",