faberun 0.25.0 → 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.
- package/package.json +1 -1
- package/skills/faberun/references/contract.md +34 -28
- package/src/campaign/campaign-brief-graph.mjs +218 -0
- package/src/campaign/campaign-brief.mjs +15 -167
- package/src/campaign/metrics-command.mjs +3 -1
- package/src/cli/brand.mjs +1 -0
- package/src/cli/plan.mjs +75 -3
- package/src/cli/setup.mjs +22 -5
- package/src/cli.mjs +35 -1
- package/src/contract/definition-of-done.mjs +48 -8
- package/src/contract/human-step.mjs +54 -0
- package/src/contract/index.mjs +25 -38
- package/src/contract/judge-independence.mjs +170 -0
- package/src/contract/judges.mjs +27 -0
- package/src/contract/provider.mjs +103 -0
- package/src/contract/runtime.mjs +19 -2
- package/src/contract/snapshot.mjs +31 -1
- package/src/contract/task-packet.mjs +4 -1
- package/src/contract/worker-result.mjs +35 -4
- package/src/engine/assignment.mjs +41 -5
- package/src/engine/backoff.mjs +30 -9
- package/src/engine/dispatch.mjs +19 -2
- package/src/engine/failover.mjs +10 -2
- package/src/engine/judge-gate.mjs +24 -4
- package/src/engine/judge-list.mjs +206 -0
- package/src/engine/lifecycle.mjs +29 -24
- package/src/engine/resume.mjs +49 -12
- package/src/engine/runtime-discovery.mjs +72 -11
- package/src/engine/scheduler.mjs +27 -6
- package/src/harnesses/catalogue.mjs +9 -2
- package/src/harnesses/index.mjs +2 -2
- package/src/harnesses/model-tiers.mjs +39 -0
- package/src/harnesses/protocol.mjs +4 -1
- package/src/host/config.mjs +7 -2
- package/src/plan/contest.mjs +78 -0
- package/src/plan/freeze.mjs +118 -7
- package/src/plan/human-step.mjs +38 -0
- package/src/plan/pipeline.mjs +224 -313
- package/src/plan/preflight.mjs +5 -3
- package/src/plan/proof-check.mjs +161 -0
- package/src/plan/proof-run.mjs +6 -2
- package/src/plan/proof-scope.mjs +175 -0
- package/src/plan/resolve.mjs +182 -0
- package/src/plan/reviewer.mjs +82 -0
- package/src/plan/rounds.mjs +446 -0
- package/src/plan/routing.mjs +11 -4
- package/src/plan/template.mjs +36 -14
- package/src/repo/signal-block.mjs +10 -3
- package/src/repo/source-identity.mjs +45 -5
- package/src/repo/worktree-prune.mjs +72 -0
- package/src/report/campaign-brief-estimate.mjs +1 -1
- package/src/report/campaign-brief.mjs +4 -0
- package/src/report/metrics-report.mjs +34 -1
- package/src/report/render.mjs +31 -10
|
@@ -112,7 +112,9 @@ function validateHashMap(value, label) {
|
|
|
112
112
|
}
|
|
113
113
|
/**
|
|
114
114
|
* The pathspec that scopes a source-identity read: everything, minus the
|
|
115
|
-
* factory's own run tree, minus
|
|
115
|
+
* factory's own run tree, minus `AGENTS.md` (whose managed signal block git
|
|
116
|
+
* cannot tell apart from a human edit; `agentGuidanceDirty` and
|
|
117
|
+
* `agentGuidanceIdentity` check its content directly instead), minus
|
|
116
118
|
* whatever the caller declared out of scope.
|
|
117
119
|
*
|
|
118
120
|
* @param {{ignorePaths?: string[], ignoreRoots?: string[]}} options
|
|
@@ -180,9 +182,14 @@ function resolveGitHead(cwd, baseRef) {
|
|
|
180
182
|
|
|
181
183
|
/**
|
|
182
184
|
* The paths the working tree has modified, added or left untracked, ignoring
|
|
183
|
-
* the factory's own `.runs` tree
|
|
184
|
-
*
|
|
185
|
-
*
|
|
185
|
+
* the factory's own `.runs` tree. `AGENTS.md` is git-excluded from that scan
|
|
186
|
+
* (its managed block would otherwise flag every launch) and checked on its
|
|
187
|
+
* own instead: it counts as dirty exactly when its content differs from
|
|
188
|
+
* `gitHead`'s once both are normalized with `normalizeManagedSignalBlock`, so
|
|
189
|
+
* a human edit outside the block is refused — including one that sits
|
|
190
|
+
* alongside a block change in the same file (R17) — while a block-only
|
|
191
|
+
* change is not. A clean tree is `[]`; an unreadable tree is treated as clean
|
|
192
|
+
* because the caller has no dirt to name.
|
|
186
193
|
*
|
|
187
194
|
* @param {string} cwd
|
|
188
195
|
* @param {{ignorePaths?: string[], ignoreRoots?: string[]}} [options]
|
|
@@ -194,13 +201,46 @@ export function dirtyTreePaths(cwd, options = {}) {
|
|
|
194
201
|
encoding: "utf8",
|
|
195
202
|
stdio: ["ignore", "pipe", "ignore"],
|
|
196
203
|
}));
|
|
197
|
-
|
|
204
|
+
const paths = status.split("\0").filter(Boolean);
|
|
205
|
+
if (agentGuidanceDirty(cwd, resolveGitHead(cwd, undefined))) paths.push("AGENTS.md");
|
|
206
|
+
return paths;
|
|
198
207
|
} catch {
|
|
199
208
|
// Not a repository, or git absent: there is no dirt the launch can name.
|
|
200
209
|
return [];
|
|
201
210
|
}
|
|
202
211
|
}
|
|
203
212
|
|
|
213
|
+
/**
|
|
214
|
+
* Whether `AGENTS.md` itself counts as a dirty path: its working-tree content
|
|
215
|
+
* differs from `gitHead`'s once both are normalized with
|
|
216
|
+
* `normalizeManagedSignalBlock`. A change confined to the managed block does
|
|
217
|
+
* not count, so the block the runner itself rewrites never blocks a launch;
|
|
218
|
+
* any other edit does. `gitHead` null (no commit to compare against) is never
|
|
219
|
+
* dirty.
|
|
220
|
+
*
|
|
221
|
+
* @param {string} cwd
|
|
222
|
+
* @param {string|null} gitHead
|
|
223
|
+
* @returns {boolean}
|
|
224
|
+
*/
|
|
225
|
+
function agentGuidanceDirty(cwd, gitHead) {
|
|
226
|
+
if (!gitHead) return false;
|
|
227
|
+
let working = null;
|
|
228
|
+
try { working = readFileSync(resolve(cwd, "AGENTS.md"), "utf8"); }
|
|
229
|
+
catch (error) { if (errorCode(error) !== "ENOENT") throw error; }
|
|
230
|
+
let committed = null;
|
|
231
|
+
try {
|
|
232
|
+
committed = String(gitSyncOrThrow(["-C", cwd, "show", `${gitHead}:AGENTS.md`], {
|
|
233
|
+
encoding: "utf8",
|
|
234
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
235
|
+
}));
|
|
236
|
+
} catch {
|
|
237
|
+
// AGENTS.md does not exist at gitHead: committed stays null.
|
|
238
|
+
}
|
|
239
|
+
if (working === null && committed === null) return false;
|
|
240
|
+
const normalize = (/** @type {string|null} */ text) => (text === null ? null : normalizeManagedSignalBlock(text));
|
|
241
|
+
return normalize(working) !== normalize(committed);
|
|
242
|
+
}
|
|
243
|
+
|
|
204
244
|
/**
|
|
205
245
|
* Refuse a launch whose base is the checked-out HEAD and whose tree is dirty.
|
|
206
246
|
* The base is what every worktree is cut from, so a dirty tree only matters
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Releasing the attempt worktrees of runs whose work has already landed.
|
|
3
|
+
* `campaign close` releases a campaign's worktrees, but a long campaign
|
|
4
|
+
* integrates run after run long before it closes. Measured 2026-09-25 on
|
|
5
|
+
* `planner-and-routing`: 19 registered worktrees from five finished runs, all
|
|
6
|
+
* already merged into the campaign branch. Separate from `worktree.mjs`
|
|
7
|
+
* because deciding which runs are finished and integrated reads run state
|
|
8
|
+
* (`run/disk-gc.mjs`) and every branch of the repository, while `worktree.mjs`
|
|
9
|
+
* only acts on one run it is handed.
|
|
10
|
+
*/
|
|
11
|
+
import { basename, join } from "node:path";
|
|
12
|
+
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
13
|
+
import { SETTLED } from "../engine/prompts.mjs";
|
|
14
|
+
import { describeRuns } from "../run/disk-gc.mjs";
|
|
15
|
+
import { worktreeRoot } from "../run/paths.mjs";
|
|
16
|
+
import { git, gitHead, releaseRunWorktrees, runRefName } from "./worktree.mjs";
|
|
17
|
+
|
|
18
|
+
/** @typedef {{runId: string, removed: number, archived: string[]}} PrunedRun */
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Is the run's integrated head already on a branch of the operator's? An
|
|
22
|
+
* attempt branch (`refs/heads/faberun/…`) holds the run's own commits and
|
|
23
|
+
* does not count; a run with no run ref has nothing integrated to check.
|
|
24
|
+
*
|
|
25
|
+
* @param {string} repo
|
|
26
|
+
* @param {string} runId
|
|
27
|
+
* @returns {boolean}
|
|
28
|
+
*/
|
|
29
|
+
function runRefLanded(repo, runId) {
|
|
30
|
+
const ref = runRefName(runId);
|
|
31
|
+
if (!gitHead(repo, ref)) return false;
|
|
32
|
+
return git(repo, ["for-each-ref", "--contains", ref, "--format=%(refname)", "refs/heads"])
|
|
33
|
+
.split("\n")
|
|
34
|
+
.some((name) => name && !name.startsWith("refs/heads/faberun/"));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* @param {string} runDir
|
|
39
|
+
* @returns {boolean}
|
|
40
|
+
*/
|
|
41
|
+
function allNodesSettled(runDir) {
|
|
42
|
+
const names = readdirSync(join(runDir, "nodes")).filter((name) => name.endsWith(".json"));
|
|
43
|
+
return names.length > 0 && names.every((name) => SETTLED.has(JSON.parse(readFileSync(join(runDir, "nodes", name), "utf8")).status));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Release the attempt worktrees of every run that no controller holds, whose
|
|
48
|
+
* nodes are all terminal, and whose run ref a local branch already contains.
|
|
49
|
+
* `releaseRunWorktrees` keeps each attempt's HEAD and uncommitted delta under
|
|
50
|
+
* `refs/faberun-archive/`, so a released attempt can still be recovered.
|
|
51
|
+
* `parked` also takes a run with a blocked or exhausted node: only on the
|
|
52
|
+
* operator's word, because a resume that re-judges an attempt reads its
|
|
53
|
+
* worktree, and only the operator knows the run was superseded.
|
|
54
|
+
*
|
|
55
|
+
* @param {string} repo
|
|
56
|
+
* @param {string} runsDir
|
|
57
|
+
* @param {{parked?: boolean}} [options]
|
|
58
|
+
* @returns {PrunedRun[]}
|
|
59
|
+
*/
|
|
60
|
+
export function pruneIntegratedRunWorktrees(repo, runsDir, options = {}) {
|
|
61
|
+
/** @type {PrunedRun[]} */
|
|
62
|
+
const pruned = [];
|
|
63
|
+
for (const run of describeRuns(runsDir)) {
|
|
64
|
+
if (run.hasActiveController) continue;
|
|
65
|
+
if (!run.allNodesTerminal && !(options.parked === true && allNodesSettled(run.path))) continue;
|
|
66
|
+
const runId = basename(run.path);
|
|
67
|
+
if (!existsSync(worktreeRoot(join(runsDir, runId), runId))) continue;
|
|
68
|
+
if (!runRefLanded(repo, runId)) continue;
|
|
69
|
+
pruned.push({ runId, ...releaseRunWorktrees(repo, join(runsDir, runId), runId) });
|
|
70
|
+
}
|
|
71
|
+
return pruned;
|
|
72
|
+
}
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
*/
|
|
21
21
|
import { collectCompletedExecutionNodes } from "../run/usage.mjs";
|
|
22
22
|
import { asArray, unique } from "../campaign/brief-text.mjs";
|
|
23
|
-
import { scheduleUnderCapacity } from "../campaign/campaign-brief.mjs";
|
|
23
|
+
import { scheduleUnderCapacity } from "../campaign/campaign-brief-graph.mjs";
|
|
24
24
|
|
|
25
25
|
/** @typedef {import("../run/usage.mjs").CompletedExecutionNode} CompletedExecutionNode */
|
|
26
26
|
/** @typedef {import("../run/usage.mjs").CompletedExecutionPool} CompletedExecutionPool */
|
|
@@ -231,6 +231,7 @@ function renderWorkGraph(model) {
|
|
|
231
231
|
const concurrent = Object.entries(graph.maxConcurrent)
|
|
232
232
|
.map(([runtimeId, limit]) => `\`${runtimeId}\` ${limit}`)
|
|
233
233
|
.join(", ");
|
|
234
|
+
const sameProviderReview = graph.nodes.filter((node) => node.sameProviderReview).map((node) => `\`${node.id}\``);
|
|
234
235
|
return [
|
|
235
236
|
"## Work graph",
|
|
236
237
|
...edgeLines,
|
|
@@ -240,6 +241,9 @@ function renderWorkGraph(model) {
|
|
|
240
241
|
`- maxConcurrent: ${concurrent || "none"}`,
|
|
241
242
|
`- Effective concurrency: ${graph.effectiveConcurrency}`,
|
|
242
243
|
`- ${renderDispatchable(graph)}`,
|
|
244
|
+
// R20: same-vendor mode admits a worker/judge pair sharing a vendor, and
|
|
245
|
+
// every human-facing surface names which nodes that applies to.
|
|
246
|
+
`- Same-provider review: ${sameProviderReview.length > 0 ? sameProviderReview.join(", ") : "none"}`,
|
|
243
247
|
].join("\n");
|
|
244
248
|
}
|
|
245
249
|
|
|
@@ -41,7 +41,7 @@ const HOURS_PRECISION = 10_000;
|
|
|
41
41
|
export function renderMetricsReport(sources, metrics) {
|
|
42
42
|
const missingSources = sources.missingSources ?? [];
|
|
43
43
|
const lines = [
|
|
44
|
-
`[metrics] ${sources.campaignId} · ${sources.runIds.length} runs · ${sources.events.length} events · ${Object.keys(metrics).length} indicators`,
|
|
44
|
+
`[metrics] ${sources.campaignId} · ${sources.runIds.length} runs · ${sources.events.length} events · ${Object.keys(metrics).length} indicators · ${sameProviderReviewSummary(sources)}`,
|
|
45
45
|
];
|
|
46
46
|
if (missingSources.length > 0) lines.push(`missing sources: ${missingSources.join(", ")}`);
|
|
47
47
|
for (const [name, indicator] of Object.entries(metrics)) {
|
|
@@ -78,10 +78,43 @@ export function renderMetricsJson(sources, metrics) {
|
|
|
78
78
|
runs: sources.runIds.length,
|
|
79
79
|
events: sources.events.length,
|
|
80
80
|
missingSources,
|
|
81
|
+
sameProviderReviewNodeCount: sameProviderReviewNodeIds(sources).length,
|
|
82
|
+
sameProviderReviewNodeIds: sameProviderReviewNodeIds(sources),
|
|
81
83
|
indicators: metrics,
|
|
82
84
|
})}\n`;
|
|
83
85
|
}
|
|
84
86
|
|
|
87
|
+
/**
|
|
88
|
+
* Which recorded nodes had their judge review the same vendor's own output
|
|
89
|
+
* under same-vendor mode (R20), `runId/id` so a node id repeated across runs
|
|
90
|
+
* still names a specific one — metadata about the sources, like `runs` and
|
|
91
|
+
* `events`, not a TECH-SPEC section 6 indicator, so it sits beside the
|
|
92
|
+
* indicator set rather than inside it.
|
|
93
|
+
*
|
|
94
|
+
* @param {MetricsSources} sources
|
|
95
|
+
* @returns {string[]}
|
|
96
|
+
*/
|
|
97
|
+
function sameProviderReviewNodeIds(sources) {
|
|
98
|
+
return (sources.nodes ?? [])
|
|
99
|
+
.filter((node) => node.sameProviderReview === true)
|
|
100
|
+
.map((node) => `${node.runId}/${node.id}`);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The bounded human summary of `sameProviderReviewNodeIds`, eliding past
|
|
105
|
+
* `MAX_GROUPS` like every other grouped indicator this report prints.
|
|
106
|
+
*
|
|
107
|
+
* @param {MetricsSources} sources
|
|
108
|
+
* @returns {string}
|
|
109
|
+
*/
|
|
110
|
+
function sameProviderReviewSummary(sources) {
|
|
111
|
+
const ids = sameProviderReviewNodeIds(sources);
|
|
112
|
+
if (ids.length === 0) return "0 same-provider review nodes";
|
|
113
|
+
const shown = ids.slice(0, MAX_GROUPS);
|
|
114
|
+
const elided = ids.length > shown.length ? `, +${ids.length - shown.length} more` : "";
|
|
115
|
+
return `${ids.length} same-provider review nodes (${shown.join(", ")}${elided})`;
|
|
116
|
+
}
|
|
117
|
+
|
|
85
118
|
/**
|
|
86
119
|
* @param {string} name
|
|
87
120
|
* @param {number|Record<string, number>|null} value
|
package/src/report/render.mjs
CHANGED
|
@@ -5,6 +5,7 @@ import { readJson, writeJsonAtomic } from "../run/store.mjs";
|
|
|
5
5
|
import { lockStale, pidAlive, readLock } from "../run/lock.mjs";
|
|
6
6
|
import { scopeFindingsNote, verificationArtifactsNote } from "../contract/scope-findings.mjs";
|
|
7
7
|
import { reviewNote } from "../contract/review-modes.mjs";
|
|
8
|
+
import { SAME_PROVIDER_REVIEW_LABEL } from "../contract/judge-independence.mjs";
|
|
8
9
|
import { validateNodeSnapshot, validateRunMetadata } from "../contract/snapshot.mjs";
|
|
9
10
|
import { compactCost, compactTokens, truncateChars } from "../util.mjs";
|
|
10
11
|
import { listNodeSnapshots, nodeSnapshotPath } from "../run/node-store.mjs";
|
|
@@ -32,7 +33,7 @@ const POINTER_ATTENTION_CHARS = 80;
|
|
|
32
33
|
/** @typedef {{inputTokens: number|null, outputTokens: number|null, cacheReadInputTokens: number|null}} StatusPayloadUsage */
|
|
33
34
|
/** @typedef {{index: number, total: number, argv: string}} VerificationProgress */
|
|
34
35
|
/** @typedef {{verdict: string, maxSeverity: string, findingCount: number, summary: string|null}} StatusPayloadGate */
|
|
35
|
-
/** @typedef {{id: string, status: NodeStatus, phase: string|null, executionPhase: string|null, runtime: string|null, workerRuntime: string|null, judgeRuntime: string|null, continuation: string, attempt: number, revisions: number, startedAt: string|null, updatedAt: string|null, usage: StatusPayloadUsage|null, costUsd: number|null, roleCostUsd: {worker: number|null, judge: number|null}, costProvenance: {worker: string, judge: string}, verdict: string|null, gate: StatusPayloadGate|null, gateOutcome: "passed"|"rejected"|null, pendingHandoff: {runtime: string, reason: string}|null, note: string|null, scopeFindings: string[]|null, verificationArtifacts: string[]|null, errorCode: string|null, blockedBy: string[], verificationProgress: VerificationProgress|null, declaredReadBytes: number|null}} StatusPayloadNode */
|
|
36
|
+
/** @typedef {{id: string, status: NodeStatus, phase: string|null, executionPhase: string|null, runtime: string|null, workerRuntime: string|null, judgeRuntime: string|null, continuation: string, attempt: number, revisions: number, startedAt: string|null, updatedAt: string|null, usage: StatusPayloadUsage|null, costUsd: number|null, roleCostUsd: {worker: number|null, judge: number|null}, costProvenance: {worker: string, judge: string}, verdict: string|null, gate: StatusPayloadGate|null, gateOutcome: "passed"|"rejected"|null, pendingHandoff: {runtime: string, reason: string}|null, note: string|null, scopeFindings: string[]|null, verificationArtifacts: string[]|null, errorCode: string|null, blockedBy: string[], verificationProgress: VerificationProgress|null, declaredReadBytes: number|null, sameProviderReview: boolean}} StatusPayloadNode */
|
|
36
37
|
/** @typedef {{schemaVersion: 1, run: string, contractId: string, campaignId: string, goal: string, usage: {inputTokens: number, outputTokens: number, cacheReadInputTokens: number, costUsd: number|null}, roles: {worker: RoleUsage, judge: RoleUsage}, controller: JsonObject, identityWarnings: string[], summary: string, nodes: StatusPayloadNode[]}} StatusPayload */
|
|
37
38
|
|
|
38
39
|
/** The glyph each terminal state prints in a status table. */
|
|
@@ -126,7 +127,8 @@ function nowLine(payload, now) {
|
|
|
126
127
|
const progress = active.verificationProgress;
|
|
127
128
|
const verification = progress ? ` · verification ${progress.index}/${progress.total} · ${progress.argv}` : "";
|
|
128
129
|
const candidate = !progress && active.executionPhase === "candidate" ? " · candidate verification" : "";
|
|
129
|
-
|
|
130
|
+
const sameProviderReview = active.sameProviderReview ? ` · ${SAME_PROVIDER_REVIEW_LABEL}` : "";
|
|
131
|
+
return `now: ${active.id} ${active.status} (${formatElapsed(active, now)}) · ${active.runtime ?? "-"} · ${compactCost(active.costUsd)}${verification}${candidate}${sameProviderReview}`;
|
|
130
132
|
}
|
|
131
133
|
const allTerminal = payload.nodes.every((node) => SUCCESS.has(node.status));
|
|
132
134
|
return allTerminal ? `now: idle · run done · ${compactCost(payload.usage.costUsd)}` : "now: idle";
|
|
@@ -259,10 +261,11 @@ function buildStatusPayload(runDir, contract, nodes, identityWarnings, usage) {
|
|
|
259
261
|
nodes: nodes.map((node) => {
|
|
260
262
|
const progress = verificationProgress(node);
|
|
261
263
|
const nodeRoles = roleUsage([node]);
|
|
264
|
+
const contractNode = contract.nodes.find((candidate) => candidate.id === node.id);
|
|
262
265
|
return {
|
|
263
266
|
id: node.id,
|
|
264
267
|
status: node.status,
|
|
265
|
-
phase:
|
|
268
|
+
phase: contractNode?.phase ?? null,
|
|
266
269
|
// A running controller verification command does not move
|
|
267
270
|
// `node.phase` (it stays `worker`, the phase that dispatched it), so
|
|
268
271
|
// the surface that shows it is computed here rather than persisted.
|
|
@@ -301,6 +304,11 @@ function buildStatusPayload(runDir, contract, nodes, identityWarnings, usage) {
|
|
|
301
304
|
blockedBy: node.blockedBy ?? [],
|
|
302
305
|
verificationProgress: progress,
|
|
303
306
|
declaredReadBytes: typeof node.declaredReadBytes === "number" ? node.declaredReadBytes : null,
|
|
307
|
+
// R20: the dynamic per-attempt fact `startJudge` stamped from the
|
|
308
|
+
// runtimes that actually ran (`engine/dispatch.mjs`) -- not the
|
|
309
|
+
// contract's static admission, which only says the pairing was
|
|
310
|
+
// possible (primary or fallback), not that this attempt hit it.
|
|
311
|
+
sameProviderReview: node.sameProviderReview === true,
|
|
304
312
|
};
|
|
305
313
|
}),
|
|
306
314
|
};
|
|
@@ -446,6 +454,10 @@ export function renderReport(runDir) {
|
|
|
446
454
|
for (const key of /** @type {("inputTokens"|"outputTokens"|"cacheReadInputTokens")[]} */ (Object.keys(totals).filter((key) => key !== "costUsd"))) totals[key] = (totals[key] ?? 0) + (usage[key] ?? 0);
|
|
447
455
|
const runtime = workerRuntimeLabel(node) ?? "-";
|
|
448
456
|
const planNode = contract.nodes.find((candidate) => candidate.id === node.id);
|
|
457
|
+
// `nodeNote` already carries the same-provider review mark (R20) through
|
|
458
|
+
// `statusNote`, in both branches below -- it is never conditioned on the
|
|
459
|
+
// scope-findings branch, unlike the phase/continuation prefix which only
|
|
460
|
+
// applies when there is no scope finding to report instead.
|
|
449
461
|
const note = scopeFindingsNote(node.scopeFindings)
|
|
450
462
|
? nodeNote(node)
|
|
451
463
|
: `phase ${planNode?.phase ?? "-"} · ${continuationMode(node)} · ${nodeNote(node)}`;
|
|
@@ -475,10 +487,15 @@ export function renderReportJson(runDir) {
|
|
|
475
487
|
for (const key of /** @type {("inputTokens"|"outputTokens"|"cacheReadInputTokens")[]} */ (["inputTokens", "outputTokens", "cacheReadInputTokens"])) totals[key] = (totals[key] ?? 0) + (usage[key] ?? 0);
|
|
476
488
|
const cost = costs[index];
|
|
477
489
|
totals.declaredReadBytes += typeof node.declaredReadBytes === "number" ? node.declaredReadBytes : 0;
|
|
490
|
+
const planNode = contract.nodes.find((candidate) => candidate.id === node.id);
|
|
478
491
|
return {
|
|
479
492
|
id: node.id,
|
|
480
493
|
status: node.status,
|
|
481
|
-
phase:
|
|
494
|
+
phase: planNode?.phase ?? null,
|
|
495
|
+
// The dynamic per-attempt fact (see buildStatusPayload), not the
|
|
496
|
+
// contract's static admission: this is what actually ran, fallback
|
|
497
|
+
// included.
|
|
498
|
+
sameProviderReview: node.sameProviderReview === true,
|
|
482
499
|
executionPhase: node.phase,
|
|
483
500
|
runtime: node.runtime ? `${node.runtime.harness}/${node.runtime.model}` : null,
|
|
484
501
|
attempt: node.attempt,
|
|
@@ -714,20 +731,24 @@ function boundedNote(segments, maxLength = MAX_NOTE_LENGTH) {
|
|
|
714
731
|
|
|
715
732
|
/**
|
|
716
733
|
* The note a status surface shows for a node: gate summary or error, led by
|
|
717
|
-
* any advisory scope finding
|
|
718
|
-
* cannot hide an advisory finding or an
|
|
719
|
-
* summary (TECH-SPEC lean, rules 1 and 2).
|
|
720
|
-
* every surface shares: `scope: N unexpected
|
|
721
|
-
*
|
|
734
|
+
* any advisory scope finding, the same-provider review mark (R20) and the
|
|
735
|
+
* review outcome, so a gated done node cannot hide an advisory finding or an
|
|
736
|
+
* invalid verdict behind its gate summary (TECH-SPEC lean, rules 1 and 2).
|
|
737
|
+
* The order is the stable format every surface shares: `scope: N unexpected
|
|
738
|
+
* paths · same-provider review · <review note> · <gate summary or error>`,
|
|
739
|
+
* bounded so the tables and the JSON cannot disagree. `sameProviderReview` is
|
|
740
|
+
* led with the other markers rather than appended, so `boundedNote`'s cut
|
|
741
|
+
* (it drops the trailing segment first) never silently loses the mark.
|
|
722
742
|
*
|
|
723
743
|
* @param {NodeSnapshot} node
|
|
724
744
|
* @returns {string|null}
|
|
725
745
|
*/
|
|
726
746
|
export function statusNote(node) {
|
|
727
747
|
const scope = boundedNote([scopeFindingsNote(node.scopeFindings), verificationArtifactsNote(node.verificationArtifacts)]);
|
|
748
|
+
const sameProviderReview = node.sameProviderReview === true ? SAME_PROVIDER_REVIEW_LABEL : null;
|
|
728
749
|
const review = reviewNote(node);
|
|
729
750
|
const detail = node.gate?.summary ?? node.error?.message ?? node.blockedBy?.join(", ") ?? (candidateVerificationActive(node) ? "candidate" : node.phase);
|
|
730
|
-
const note = boundedNote([review, detail]);
|
|
751
|
+
const note = boundedNote([sameProviderReview, review, detail]);
|
|
731
752
|
if (!scope) return note;
|
|
732
753
|
return boundedNote([scope, note]);
|
|
733
754
|
}
|