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.
Files changed (54) hide show
  1. package/package.json +1 -1
  2. package/skills/faberun/references/contract.md +34 -28
  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/contract/task-packet.mjs +4 -1
  19. package/src/contract/worker-result.mjs +35 -4
  20. package/src/engine/assignment.mjs +41 -5
  21. package/src/engine/backoff.mjs +30 -9
  22. package/src/engine/dispatch.mjs +19 -2
  23. package/src/engine/failover.mjs +10 -2
  24. package/src/engine/judge-gate.mjs +24 -4
  25. package/src/engine/judge-list.mjs +206 -0
  26. package/src/engine/lifecycle.mjs +29 -24
  27. package/src/engine/resume.mjs +49 -12
  28. package/src/engine/runtime-discovery.mjs +72 -11
  29. package/src/engine/scheduler.mjs +27 -6
  30. package/src/harnesses/catalogue.mjs +9 -2
  31. package/src/harnesses/index.mjs +2 -2
  32. package/src/harnesses/model-tiers.mjs +39 -0
  33. package/src/harnesses/protocol.mjs +4 -1
  34. package/src/host/config.mjs +7 -2
  35. package/src/plan/contest.mjs +78 -0
  36. package/src/plan/freeze.mjs +118 -7
  37. package/src/plan/human-step.mjs +38 -0
  38. package/src/plan/pipeline.mjs +224 -313
  39. package/src/plan/preflight.mjs +5 -3
  40. package/src/plan/proof-check.mjs +161 -0
  41. package/src/plan/proof-run.mjs +6 -2
  42. package/src/plan/proof-scope.mjs +175 -0
  43. package/src/plan/resolve.mjs +182 -0
  44. package/src/plan/reviewer.mjs +82 -0
  45. package/src/plan/rounds.mjs +446 -0
  46. package/src/plan/routing.mjs +11 -4
  47. package/src/plan/template.mjs +36 -14
  48. package/src/repo/signal-block.mjs +10 -3
  49. package/src/repo/source-identity.mjs +45 -5
  50. package/src/repo/worktree-prune.mjs +72 -0
  51. package/src/report/campaign-brief-estimate.mjs +1 -1
  52. package/src/report/campaign-brief.mjs +4 -0
  53. package/src/report/metrics-report.mjs +34 -1
  54. 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 the machine-managed AGENTS.md signal, 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 and the machine-managed AGENTS.md signal. A
184
- * clean tree is `[]`; an unreadable tree is treated as clean because the
185
- * caller has no dirt to name.
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
- return status.split("\0").filter(Boolean);
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
@@ -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
- return `now: ${active.id} ${active.status} (${formatElapsed(active, now)}) · ${active.runtime ?? "-"} · ${compactCost(active.costUsd)}${verification}${candidate}`;
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: contract.nodes.find((candidate) => candidate.id === node.id)?.phase ?? null,
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: contract.nodes.find((candidate) => candidate.id === node.id)?.phase ?? null,
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 and by the review outcome, so a gated done node
718
- * cannot hide an advisory finding or an invalid verdict behind its gate
719
- * summary (TECH-SPEC lean, rules 1 and 2). The order is the stable format
720
- * every surface shares: `scope: N unexpected paths · <review note> · <gate
721
- * summary or error>`, bounded so the tables and the JSON cannot disagree.
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
  }