@staix/agent-hub 0.12.35 → 0.12.36

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 (49) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +8 -6
  3. package/docs/agent-notes/adapters.md +4 -1
  4. package/docs/agent-notes/budget.md +1 -1
  5. package/docs/agent-notes/bus.md +3 -0
  6. package/docs/agent-notes/daemon.md +6 -2
  7. package/docs/agent-notes/tasks.md +11 -1
  8. package/docs/agent-notes/tests.md +5 -1
  9. package/docs/operations.md +40 -16
  10. package/docs/quickstart.md +6 -8
  11. package/docs/sdlc.md +46 -5
  12. package/docs/security.md +5 -1
  13. package/docs/specs/2026-09-19-agent-hub-design.md +76 -1
  14. package/docs/verification/2026-10-19-0.12.36.md +63 -0
  15. package/docs/verified.json +18 -14
  16. package/package.json +1 -1
  17. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  18. package/plugins/agent-hub/server.js +385 -29
  19. package/src/adapters/claude-print.ts +230 -0
  20. package/src/adapters/definitions/builtin-metadata.ts +17 -1
  21. package/src/adapters/definitions/native-cli-entry-claude.ts +17 -3
  22. package/src/adapters/definitions/native-cli-entry-jcode.ts +3 -2
  23. package/src/adapters/definitions/native-cli-entry-kimi.ts +3 -2
  24. package/src/adapters/definitions/native-cli-entry-types.ts +2 -0
  25. package/src/adapters/definitions/native-factories.ts +7 -0
  26. package/src/adapters/registry-types.ts +8 -2
  27. package/src/adapters/registry.ts +13 -1
  28. package/src/cli/help.ts +5 -0
  29. package/src/cli/identity.ts +2 -1
  30. package/src/cli/launch.ts +19 -6
  31. package/src/cli/main.ts +38 -4
  32. package/src/cli/reset.ts +4 -2
  33. package/src/cli/statusline-tee.ts +5 -2
  34. package/src/cli/terminal-recovery.ts +7 -2
  35. package/src/cli/upgrade-runtime.ts +18 -15
  36. package/src/hub/board.ts +29 -7
  37. package/src/hub/bus.ts +11 -5
  38. package/src/hub/claude-state.ts +37 -0
  39. package/src/hub/cost-governance.ts +76 -0
  40. package/src/hub/daemon.ts +181 -73
  41. package/src/hub/hub-tools.ts +20 -1
  42. package/src/hub/instances.ts +17 -3
  43. package/src/hub/review-brief.ts +175 -0
  44. package/src/hub/routing.ts +17 -2
  45. package/src/hub/sdlc.ts +205 -4
  46. package/src/hub/status-roster.ts +10 -0
  47. package/src/hub/task-sweep.ts +143 -0
  48. package/src/hub/tasks.ts +544 -26
  49. package/src/hub/usage.ts +57 -20
package/src/cli/main.ts CHANGED
@@ -40,7 +40,7 @@ import { OmniRoute } from "../omniroute/client.ts";
40
40
  import { MemoryClient } from "../memory/client.ts";
41
41
  import { init, planInit } from "./init.ts";
42
42
  import { launcherPreview } from "./preview.ts";
43
- import { instanceLaunchArguments, requirePeerInstances, cleanupStaleClaudeSettings, cleanupClaudeSettings, buildLaunch, buildKimiTuiLaunch, claudeObservationHooks, nativeLaunchEnv, recordClaudeLaunch, runClaudeLaunch, type ClaudeLaunchRecord, UNATTENDED_WARNING } from "./launch.ts";
43
+ import { instanceLaunchArguments, requirePeerInstances, requireActiveInstance, cleanupStaleClaudeSettings, cleanupClaudeSettings, buildLaunch, buildKimiTuiLaunch, claudeObservationHooks, nativeLaunchEnv, recordClaudeLaunch, runClaudeLaunch, type ClaudeLaunchRecord, UNATTENDED_WARNING } from "./launch.ts";
44
44
  import { configureKimiMcp } from "./kimi-mcp.ts";
45
45
  import { nextStep, parseList, pluginState, type InstalledPlugin, type Marketplace } from "./setup.ts";
46
46
  import { CLASSES } from "../hub/board.ts";
@@ -848,14 +848,14 @@ const commands: Record<string, () => Promise<void> | void> = {
848
848
  if (entry.lifecycleAdmission && !args.includes("--print-command") && !args.includes("--dry-run")) assertLifecycleAvailable();
849
849
  const nativeConnect = async () => {
850
850
  const hub = await connect();
851
- try { if (selectedInstance) requirePeerInstances(hub.features); return hub; }
851
+ try { if (selectedInstance) { requirePeerInstances(hub.features); requireActiveInstance((await hub.request({ t: "status" })).status, selectedInstance); } return hub; }
852
852
  catch (error) { hub.close(); throw error; }
853
853
  };
854
854
  if (selectedInstance && !args.includes("--print-command") && !args.includes("--dry-run")) {
855
855
  const hub = await nativeConnect(); hub.close();
856
856
  }
857
857
  const context: NativeCliEntryContext = { args, cwd, stateDir, cliDir: import.meta.dir, unattendedEnv, definition: entry.definition,
858
- ...(selectedInstance ? { instance: selectedInstance } : {}),
858
+ ...(selectedInstance ? { instance: selectedInstance } : {}), ...(nativeSelection.hubStarted ? { hubStarted: true as const } : {}),
859
859
  projectConfig, connect: nativeConnect, fail,
860
860
  exec: (bin, argv, tool) => exec(bin, argv, tool, context.executionCwd),
861
861
  execWithEnv: (bin, argv, env) => execWithEnv(bin, argv, env, context.executionCwd), takeFlags,
@@ -1032,6 +1032,10 @@ const commands: Record<string, () => Promise<void> | void> = {
1032
1032
  backlog: async () => {
1033
1033
  const [sub, id, revision, value, ...reason] = args;
1034
1034
  if (sub === "list" && args.length === 1) return console.log(await taskOp("hub_backlog_list", {}));
1035
+ if (sub === "groom" && args.length === 1) return console.log(await taskOp("hub_planner_groom", {}));
1036
+ if (["return", "return-untouched"].includes(sub ?? "") && /^[1-9]\d*$/.test(id ?? "") && /^\d+$/.test(revision ?? "") && args.length >= 4) {
1037
+ return console.log(await taskOp("hub_backlog_return_untouched", { id: Number(id), revision: Number(revision), reason: args.slice(3).join(" ") }));
1038
+ }
1035
1039
  if (sub === "add" && args.length >= 3) return console.log(await taskOp("hub_task_propose", { class: id, title: args.slice(2).join(" "), backlog: true }));
1036
1040
  if (!/^[1-9]\d*$/.test(id ?? "")) fail("usage: ahub backlog list|add <class> <title...>|pull|return <id>|plan|pin <id> <revision> <value> <reason...>|undo <id> <revision>");
1037
1041
  if (["pull", "return"].includes(sub ?? "") && args.length === 2) return console.log(await taskOp(sub === "pull" ? "backlog_pull" : "backlog_return", { id }));
@@ -1114,6 +1118,31 @@ const commands: Record<string, () => Promise<void> | void> = {
1114
1118
  finally { hub.close(); }
1115
1119
  return;
1116
1120
  }
1121
+ if (sub === "costs") {
1122
+ if (args.length > 2) fail("usage: ahub sdlc costs [owner/repository]");
1123
+ return console.log(await taskOp("hub_sdlc_costs", { ...(args[1] ? { repo: args[1] } : {}) }));
1124
+ }
1125
+ if (sub === "plan-review") {
1126
+ const revision = Number(args[2]);
1127
+ if (!Number.isSafeInteger(id) || id! <= 0 || !Number.isSafeInteger(revision) || revision < 0 || args.length < 4) fail("usage: ahub sdlc plan-review <task> <revision> <reason...>");
1128
+ const flags = takeFlags(args.slice(3), ["--review", "--reviewers", "--ci"], []);
1129
+ const scope = flags.one["--review"], reviewers = flags.one["--reviewers"];
1130
+ if (!flags.rest.length || (scope === undefined) !== (reviewers === undefined)) fail("plan-review needs a reason and both --review and --reviewers when supplying review scope");
1131
+ return console.log(await taskOp("hub_plan_review", { id, revision, reason: flags.rest.join(" "), ...(scope !== undefined ? { review: { scope, reviewers: Number(reviewers) } } : {}), ...(flags.one["--ci"] !== undefined ? { ci: flags.one["--ci"] } : {}) }));
1132
+ }
1133
+ if (sub === "accept") {
1134
+ // #481 S1: the person accepts the task's spec issue at its current body; agent shells are refused before this runs.
1135
+ const flags = takeFlags(args.slice(2), ["--issue", "--repo"], []);
1136
+ const issue = Number(flags.one["--issue"]), repo = flags.one["--repo"];
1137
+ if (flags.rest.length || !Number.isSafeInteger(id) || id! <= 0 || !Number.isSafeInteger(issue) || issue <= 0) fail("usage: ahub sdlc accept <task> --issue <n> [--repo owner/repository]");
1138
+ const hub = await connect();
1139
+ try {
1140
+ const reply = await hub.request({ t: "sdlc", op: "accept", id, issue, ...(repo ? { repo } : {}) }, 30_000);
1141
+ if (!reply.ok) fail(reply.error);
1142
+ console.log(`task #${id}: spec issue #${issue} accepted at body edit ${reply.acceptance?.lastEditedAt ?? "never"}`);
1143
+ } finally { hub.close(); }
1144
+ return;
1145
+ }
1117
1146
  if (sub === "record") {
1118
1147
  if (args.length !== 3 || !Number.isSafeInteger(id) || id! <= 0 || Buffer.byteLength(args[2]!) > 32768) fail("usage: ahub sdlc record <task> '<receipt JSON>'");
1119
1148
  let receipt: unknown; try { receipt = JSON.parse(args[2]!); } catch { fail("receipt must be valid JSON"); }
@@ -1181,9 +1210,14 @@ const commands: Record<string, () => Promise<void> | void> = {
1181
1210
  },
1182
1211
 
1183
1212
  review: async () => {
1213
+ // #520: the review brief a reviewer gets, for a person or an outside tool.
1214
+ if (args[0] === "brief") {
1215
+ if (args.length !== 2 || !/^\d+$/.test(args[1]!)) fail("usage: ahub review brief <task id>");
1216
+ return console.log(await taskOp("review_brief", { id: Number(args[1]) }));
1217
+ }
1184
1218
  const { one, many, rest } = takeFlags(args, ["--head"], ["--unmet"]);
1185
1219
  const [id, verdict, ...note] = rest;
1186
- if (!id || !verdict) fail("usage: ahub review <id> approved|changes_requested [note...] [--unmet <item>]...");
1220
+ if (!id || !verdict) fail("usage: ahub review <id> approved|changes_requested [note...] [--unmet <item>]... | ahub review brief <id>");
1187
1221
  console.log(await taskOp("hub_review", { id: Number(id), verdict, ...(one["--head"] ? { head: one["--head"] } : {}), note: note.join(" "), ...(many["--unmet"]?.length ? { unmet: many["--unmet"] } : {}) }));
1188
1222
  },
1189
1223
 
package/src/cli/reset.ts CHANGED
@@ -10,6 +10,8 @@ import { HUB_DB_BUSY_TIMEOUT_MS } from "../hub/db-timeout.ts";
10
10
 
11
11
  /** Agent session resume pointers. `pi-sessions/` holds Pi transcripts, history rather than pointers, and stays. */
12
12
  export const SESSION_POINTERS = ["sessions.json", "claude-session.json", "claude-context.json"];
13
+ /** The fixed pointers plus each Claude instance's own session and context files (#450 I3). */
14
+ const sessionPointers = (stateDir: string): string[] => [...SESSION_POINTERS, ...(existsSync(stateDir) ? readdirSync(stateDir).filter(name => /^claude-(session|context)\.[a-z][a-z0-9-]{0,31}\.json$/.test(name)) : [])];
13
15
  /** What a running daemon publishes; a daemon that did not stop cleanly (crash, SIGKILL, forced exit) leaves it. */
14
16
  export const MANIFEST = ["status.json", "control-token", "hub.pid"];
15
17
 
@@ -51,7 +53,7 @@ const shaped = (ok: boolean, what: string) => { if (!ok) throw new Error(`invali
51
53
  /** What a reset would act on, read from the state directory without changing it (hub.db opened read-only). */
52
54
  export function planReset(stateDir: string, projectId: string): ResetPlan {
53
55
  const plan: ResetPlan = { queued: [], needsReview: [], inFlight: [], manualHolds: [], budgetPauses: [], conductorHolds: [],
54
- sessionPointers: SESSION_POINTERS.filter((name) => existsSync(join(stateDir, name))), entries: existsSync(stateDir) ? readdirSync(stateDir).length : 0 };
56
+ sessionPointers: sessionPointers(stateDir).filter((name) => existsSync(join(stateDir, name))), entries: existsSync(stateDir) ? readdirSync(stateDir).length : 0 };
55
57
  const file = join(stateDir, "hub.db");
56
58
  if (!existsSync(file)) return plan;
57
59
  // A read-only open of a WAL database creates hub.db-wal and -shm when the last writer's close removed them (Linux),
@@ -143,7 +145,7 @@ export function resetRuntime(stateDir: string, project: Project): { settled: str
143
145
  })();
144
146
  } finally { db.close(); }
145
147
  }
146
- for (const name of SESSION_POINTERS) rmSync(join(stateDir, name), { force: true });
148
+ for (const name of sessionPointers(stateDir)) rmSync(join(stateDir, name), { force: true });
147
149
  // The caller has checked that no daemon behind these is alive or uncertain: they are a crashed run's leftovers.
148
150
  const stale = MANIFEST.filter((name) => existsSync(join(stateDir, name)));
149
151
  for (const name of stale) rmSync(join(stateDir, name), { force: true });
@@ -6,6 +6,7 @@
6
6
  import { spawnSync } from "node:child_process";
7
7
  import { chmodSync, mkdirSync, renameSync, writeFileSync } from "node:fs";
8
8
  import { join } from "node:path";
9
+ import { claudePeer, claudeStateFile } from "../hub/claude-state.ts";
9
10
 
10
11
  const input = await Bun.stdin.text();
11
12
  let own = "";
@@ -15,6 +16,8 @@ try {
15
16
  const pct = (w: any) => (typeof w?.used_percentage === "number" ? `${Math.round(w.used_percentage)}%` : "-");
16
17
  if (limits) own = `agent-hub 5h ${pct(limits.five_hour)} wk ${pct(limits.seven_day)}`;
17
18
  const dir = process.env.AGENTHUB_STATE_DIR;
19
+ // Usage is per account and shared; context and session belong to this peer (#450 I3: a claude-<name> instance).
20
+ const peer = claudePeer(process.env.AGENTHUB_PEER_ID);
18
21
  if (limits && dir) {
19
22
  mkdirSync(dir, { recursive: true });
20
23
  const file = join(dir, "claude-usage.json");
@@ -25,7 +28,7 @@ try {
25
28
  if (sessionId && dir) {
26
29
  // Bind context to the launcher, daemon and native session. Persist no prompts or transcript text.
27
30
  mkdirSync(dir, { recursive: true });
28
- const file = join(dir, "claude-context.json");
31
+ const file = join(dir, claudeStateFile("claude-context", peer));
29
32
  const c = parsed.context_window;
30
33
  const current = c?.current_usage;
31
34
  const numeric = (v: unknown) => typeof v === "number" && Number.isFinite(v) && v >= 0 ? v : null;
@@ -38,7 +41,7 @@ try {
38
41
  }
39
42
  if (sessionId && dir) {
40
43
  mkdirSync(dir, { recursive: true });
41
- const file = join(dir, "claude-session.json");
44
+ const file = join(dir, claudeStateFile("claude-session", peer));
42
45
  // The transcript path lets the hub tell a zero-turn session (file not yet created, so
43
46
  // `claude --resume` can never work) from one whose conversation must be preserved (#64).
44
47
  const transcriptPath = typeof parsed.transcript_path === "string" && parsed.transcript_path ? parsed.transcript_path : undefined;
@@ -331,7 +331,12 @@ function executionRoot(peer: string, projectRoot: string, captured?: string): st
331
331
  const { instances: declarations, roles } = readInstanceRoleConfig(projectRoot);
332
332
  const definition = definitionOf(peer, declarations);
333
333
  if (!definition?.session.launchRecord) throw new Error(`${peer}: no managed terminal recovery adapter`);
334
- if (definition.transport !== "codex-appserver" || !declarations?.[peer]) throw new Error(`${peer}: I2 extra instance has no managed terminal support`);
334
+ if (!declarations?.[peer] || definition.transport !== "codex-appserver" && definition.transport !== "claude-channel") throw new Error(`${peer}: extra instance has no managed terminal support`);
335
+ // #450 I3: a Claude instance reviews or plans in the canonical checkout; it never owns an execution worktree.
336
+ if (definition.transport === "claude-channel") {
337
+ if (captured !== undefined && captured !== projectRoot) throw new Error(`${peer}: recorded execution root changed; manual recovery required`);
338
+ return projectRoot;
339
+ }
335
340
  const expected = resolveInstanceExecutionCwd(projectRoot, peer, declarations, effectiveInstanceRoles(peer, declarations, roles));
336
341
  if (captured !== undefined && captured !== expected) throw new Error(`${peer}: declared execution worktree changed; manual recovery required`);
337
342
  return expected;
@@ -662,7 +667,7 @@ export async function inspectTerminals(projectRoot: string, sessions: Partial<Re
662
667
  for (const [peer, ref] of requested) {
663
668
  const sessionId = ref.sessionId;
664
669
  const adapter = definitionOf(peer, instances), extra = definitionOf(peer) === undefined;
665
- if (!adapter?.session.launchRecord || extra && adapter.transport !== "codex-appserver") { blockers.push(blocker("ownership-unknown", `${peer}: no declared managed-terminal recovery adapter`, peer)); continue; }
670
+ if (!adapter?.session.launchRecord || extra && adapter.transport !== "codex-appserver" && adapter.transport !== "claude-channel") { blockers.push(blocker("ownership-unknown", `${peer}: no declared managed-terminal recovery adapter`, peer)); continue; }
666
671
  const ownRecord = records.find(item => item.peer === peer);
667
672
  let expectedRoot: string, expectedTerminalRoot: string;
668
673
  try {
@@ -1,4 +1,5 @@
1
1
  import { resolveAcceptedBinding } from "./recovery-guidance.ts";
2
+ import { claudeStateFile } from "../hub/claude-state.ts";
2
3
  import { ADAPTERS, definitionOf } from "../adapters/registry.ts";
3
4
  import { readInstanceConfig } from "../hub/instance-config.ts";
4
5
  import { isDatabaseBusy, isDatabaseBusyReply, RecoveryDatabaseBusy } from "../hub/recovery-error.ts";
@@ -265,21 +266,21 @@ export async function makeUpgradePlan(kind: "restart" | "upgrade", version: stri
265
266
  if (recorded?.state === "unknown") {
266
267
  // An unreadable launcher is never gone (#215): whether the session is managed cannot be told.
267
268
  delete peer.sessionId;
268
- blockers.push(`claude: ${recorded.record ? `the launcher recorded in terminal ${recorded.record.handle}` : recorded.invalidRow ? "a launch record in terminal-recovery.json that may be its launcher's" : "the launcher record (terminal-recovery.json)"} cannot be read, so whether the attached session is managed is unknown; manual-required; next action: make it readable (or end that session and close its terminal), then make a new plan`);
269
+ blockers.push(`${peer.id}: ${recorded.record ? `the launcher recorded in terminal ${recorded.record.handle}` : recorded.invalidRow ? "a launch record in terminal-recovery.json that may be its launcher's" : "the launcher record (terminal-recovery.json)"} cannot be read, so whether the attached session is managed is unknown; manual-required; next action: make it readable (or end that session and close its terminal), then make a new plan`);
269
270
  continue;
270
271
  }
271
272
  const launcher = recorded?.state === "live" ? recorded.record! : undefined;
272
273
  if (!launcher) {
273
274
  delete peer.sessionId;
274
275
  if (source.protocol === PROTOCOL) reconnectOnly.push(peer.id);
275
- else blockers.push(`claude: unmanaged session (no live ahub claude launcher recorded) runs a protocol-${source.protocol} plugin and cannot reconnect to a protocol-${PROTOCOL} hub; next action: end that Claude session, or relaunch it with ahub claude in an Orca terminal, then make a new plan`);
276
+ else blockers.push(`${peer.id}: unmanaged session (no live ahub claude launcher recorded) runs a protocol-${source.protocol} plugin and cannot reconnect to a protocol-${PROTOCOL} hub; next action: end that Claude session, or relaunch it with ahub claude in an Orca terminal, then make a new plan`);
276
277
  continue;
277
278
  }
278
279
  let record: { instanceId?: unknown; launchId?: unknown; sessionId?: unknown } | undefined;
279
- try { record = JSON.parse(readFileSync(join(project.stateDir, "claude-session.json"), "utf8")); } catch { /* no record: the id check below reports it */ }
280
+ try { record = JSON.parse(readFileSync(join(project.stateDir, claudeStateFile("claude-session", peer.id)), "utf8")); } catch { /* no record: the id check below reports it */ }
280
281
  if (peer.sessionId && (record?.instanceId !== source.instanceId || record?.launchId !== launcher.launchId || record?.sessionId !== peer.sessionId)) {
281
282
  delete peer.sessionId;
282
- blockers.push(`claude: the recorded session was not written by the live launcher in terminal ${launcher.handle}; manual-required; next action: close any other Claude session in this project, send one message in that terminal, then make a new plan`);
283
+ blockers.push(`${peer.id}: the recorded session was not written by the live launcher in terminal ${launcher.handle}; manual-required; next action: close any other Claude session in this project, send one message in that terminal, then make a new plan`);
283
284
  continue;
284
285
  }
285
286
  }
@@ -340,7 +341,8 @@ export function preserveSource(plan: UpgradePlan, home = hubHome()): string {
340
341
  export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?: string, clock?: Pick<RecoveryDriver, "now" | "sleep">): RecoveryDriver {
341
342
  const now = clock?.now ?? (() => Date.now());
342
343
  const sleep = clock?.sleep ?? ((ms: number) => Bun.sleep(ms));
343
- const env = (op: RecoveryOperation, project?: Project): NodeJS.ProcessEnv => {
344
+ /** `only`: one binding's native profile, for a command run once per profile (#450 I3: Claude instances may differ). */
345
+ const env = (op: RecoveryOperation, project?: Project, only?: TerminalBinding): NodeJS.ProcessEnv => {
344
346
  const value = { ...process.env };
345
347
  // Do not carry one project's gateway, auth overrides or unattended mode into another.
346
348
  for (const key of Object.keys(value)) if (key.startsWith("AGENTHUB_") || key.startsWith("OMNIROUTE_") || key.startsWith("CF_ACCESS_") ||
@@ -352,7 +354,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
352
354
  value.AGENTHUB_PROJECT_DIR = project.root; value.AGENTHUB_STATE_DIR = project.stateDir;
353
355
  delete value.CODEX_HOME; delete value.CLAUDE_CONFIG_DIR;
354
356
  const bindings = op.plan.projects.find((p) => p.project.id === project.id)?.terminals as TerminalBinding[] | undefined;
355
- for (const binding of bindings ?? []) {
357
+ for (const binding of only ? [only] : bindings ?? []) {
356
358
  // Preserve the captured legacy profile for terminal bindings without a dedicated native config variable.
357
359
  const key = projectDefinition(binding.peer, binding.projectRoot)?.session.configDirEnv ?? "CLAUDE_CONFIG_DIR";
358
360
  if (binding.launch.env[key]) value[key] = binding.launch.env[key];
@@ -360,9 +362,9 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
360
362
  }
361
363
  return value;
362
364
  };
363
- const command = async (op: RecoveryOperation, args: string[], project?: Project) => {
365
+ const command = async (op: RecoveryOperation, args: string[], project?: Project, only?: TerminalBinding) => {
364
366
  const result = await run([process.execPath, join(op.targetRoot!, "src/cli/main.js"), ...(project ? ["--project", project.root] : []), ...(liveTestPeer ? ["--live-test"] : []), ...args],
365
- { env: env(op, project), cwd: project?.root ?? op.plan.projects[0]!.project.root, timeoutMs: 180_000 });
367
+ { env: env(op, project, only), cwd: project?.root ?? op.plan.projects[0]!.project.root, timeoutMs: 180_000 });
366
368
  if (result.code !== 0) throw new Error(`target command ${args[0]} failed; inspect recovery state before retrying`);
367
369
  return result;
368
370
  };
@@ -927,12 +929,13 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
927
929
  installPlugin: async (op) => {
928
930
  const configured = new Set<string>();
929
931
  for (const planned of op.plan.projects) {
930
- const binding = (planned.terminals as TerminalBinding[]).find((t) => projectDefinition(t.peer, t.projectRoot)?.transport === "claude-channel");
931
- if (!binding) continue;
932
- const profile = binding.launch.env.CLAUDE_CONFIG_DIR ?? "default";
933
- if (configured.has(profile)) continue;
934
- await command(op, ["setup", "--yes"], planned.project);
935
- configured.add(profile);
932
+ // Every Claude binding (the builtin and each instance), once per distinct profile, each under its own profile.
933
+ for (const binding of (planned.terminals as TerminalBinding[]).filter((t) => projectDefinition(t.peer, t.projectRoot)?.transport === "claude-channel")) {
934
+ const profile = binding.launch.env.CLAUDE_CONFIG_DIR ?? "default";
935
+ if (configured.has(profile)) continue;
936
+ await command(op, ["setup", "--yes"], planned.project, binding);
937
+ configured.add(profile);
938
+ }
936
939
  }
937
940
  if (!configured.size) await command(op, ["setup", "--yes"]);
938
941
  },
@@ -1002,7 +1005,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
1002
1005
  // #64: the restore gate already accepted a fresh session when the original never
1003
1006
  // persisted a transcript. Re-derive persistence from the binding exactly like that
1004
1007
  // gate did, so a first turn landing after the plan was made keeps the check strict.
1005
- const binding = (planned.terminals as TerminalBinding[]).find((t) => projectDefinition(t.peer, t.projectRoot)?.transport === "claude-channel");
1008
+ const binding = (planned.terminals as TerminalBinding[]).find((t) => t.peer === old.id && projectDefinition(t.peer, t.projectRoot)?.transport === "claude-channel");
1006
1009
  if (recordedOnly || !binding || binding.sessionId !== old.sessionId || !nativeZeroTurn(planned.project.stateDir, binding, "verify")) throw nativeFailure(planned, old.id, "session_changed", "Claude resumed a different conversation", { ...(old.sessionId ? { expectedSession: old.sessionId } : {}), ...(peer.sessionId ? { observedSession: peer.sessionId } : {}) });
1007
1010
  }
1008
1011
  if (projectDefinition(old.id, planned.project.root)?.session.launchRecord && projectDefinition(old.id, planned.project.root)?.session.crashIdentity && (peer.sessionId !== old.sessionId || (old.sessionFile && peer.sessionFile !== old.sessionFile))) throw nativeFailure(planned, old.id, "session_changed", "Pi resumed a different conversation", { ...(old.sessionId ? { expectedSession: old.sessionId } : {}), ...(peer.sessionId ? { observedSession: peer.sessionId } : {}) });
package/src/hub/board.ts CHANGED
@@ -5,6 +5,7 @@ import { HUB, type PeerId } from "./envelope.ts";
5
5
  import type { SweepRecord } from "./task-sweep.ts";
6
6
  import { HUB_DB_BUSY_TIMEOUT_MS } from "./db-timeout.ts";
7
7
  import type { SdlcReceipt } from "./sdlc.ts";
8
+ import type { CostPlan, ReviewCost } from "./cost-governance.ts";
8
9
 
9
10
  export const CLASSES = ["plan", "implement", "bulk_edit", "test", "review", "summarize", "triage"] as const;
10
11
  export type TaskClass = (typeof CLASSES)[number];
@@ -27,7 +28,7 @@ export interface TaskRefs {
27
28
  export const PLAN_KEYS = ["paths", "symbols", "signatures", "insertion_points"] as const;
28
29
  export const DOCUMENT_PLAN_KEYS = ["paths", "sections", "sources", "claims"] as const;
29
30
  export const ALL_PLAN_KEYS = [...PLAN_KEYS, "sections", "sources", "claims"] as const;
30
- export type TaskPlan = Partial<Record<(typeof ALL_PLAN_KEYS)[number], string[]>>;
31
+ export type TaskPlan = Partial<Record<(typeof ALL_PLAN_KEYS)[number], string[]>> & CostPlan;
31
32
  /** Task outcomes older than this are pruned: demotion (issue #36) reads one week, seven half-lives. */
32
33
  export const OUTCOMES_KEPT_MS = 7 * 24 * 3_600_000;
33
34
 
@@ -51,6 +52,10 @@ export interface HistoryEntry {
51
52
  sweep?: SweepRecord;
52
53
  /** The PII screen did not clear this entry's note (#198): only the console reads it. */
53
54
  withheld?: true;
55
+ /** Host-recorded expected cost and exact review head, never narrative. */
56
+ reviewCost?: ReviewCost;
57
+ /** #223 R4: on a verdict, whether the reviewer's context was new for it; set by the hub, never by the reviewer. */
58
+ reviewContext?: "fresh" | "continuing";
54
59
  planning?: { revision: number; fields: (keyof PlanningFields)[]; previous: PlanningFields; pull?: true; split?: { children: { id: number; revision: number; history: number }[] } };
55
60
  }
56
61
  export interface Task extends PlanningFields {
@@ -90,6 +95,19 @@ export interface Task extends PlanningFields {
90
95
  export function taskHasStarted(task: Pick<Task, "history">): boolean {
91
96
  return task.history.some(entry => ["accepted", "done", "done (checking)", "check passed", "check failed", "changes_requested", "approved", "reopened", "integration requested", "integrated"].includes(entry.event));
92
97
  }
98
+ /** S9 return eligibility never erases the historical acceptance used by cancellation and undo. */
99
+ export function taskHasWorkEvidence(task: Pick<Task, "history" | "refs" | "sdlc">): boolean {
100
+ return !!(task.refs.branch || task.refs.commit || task.sdlc?.localHead || task.sdlc?.localResult || task.sdlc?.reviewedHead || task.sdlc?.pr)
101
+ || task.history.some(entry => entry.event === "done" || entry.event.startsWith("done (") || entry.event.startsWith("check ")
102
+ || ["changes_requested", "approved", "reopened", "integration requested", "integration unresolved", "integrated"].includes(entry.event));
103
+ }
104
+ /** A successful untouched return permits replanning, without changing historical start evidence. */
105
+ export function taskCanPlan(task: Pick<Task, "history" | "refs" | "sdlc">): boolean {
106
+ if (!taskHasStarted(task)) return true;
107
+ const returned = task.history.findLastIndex(entry => entry.event === "backlog returned untouched");
108
+ const accepted = task.history.findLastIndex(entry => entry.event === "accepted");
109
+ return returned > accepted && !taskHasWorkEvidence(task);
110
+ }
93
111
  /** First offer boundary; staged work has none until it is pulled. */
94
112
  export function taskProposalAt(task: Pick<Task, "history">): number | undefined {
95
113
  return task.history.find(entry => entry.event === "proposed" || entry.event === "backlog pulled")?.at;
@@ -270,18 +288,22 @@ export class Board {
270
288
 
271
289
  /** The only way a task changes. Validates the move, records who did what, returns the new row. */
272
290
  /** A `plan` in the patch replaces the old one whole: a new plan is the owner's current intent, not an addition. */
273
- update(id: number, by: PeerId, event: string, patch: Partial<Pick<Task, "state" | "owner" | "reviewer" | "reserved" | "refs" | "plan" | "rejections" | "signals" | "sdlc" | "role" | "cancelled">>, note?: string, extra: Pick<HistoryEntry, "profile" | "sweep" | "reason" | "from" | "withheld"> = {}, expectedFlowRevision?: number): Task {
291
+ update(id: number, by: PeerId, event: string, patch: Partial<Pick<Task, "state" | "owner" | "reviewer" | "reserved" | "refs" | "plan" | "rejections" | "signals" | "sdlc" | "role" | "cancelled">>, note?: string, extra: Pick<HistoryEntry, "profile" | "sweep" | "reason" | "from" | "withheld" | "reviewCost" | "reviewContext"> = {}, expectedFlowRevision?: number): Task {
274
292
  const task = this.get(id);
275
293
  if (!task) throw new Error(`no task #${id}`);
276
294
  if (expectedFlowRevision !== undefined && (!Number.isSafeInteger(expectedFlowRevision) || expectedFlowRevision < 0 || task.history.length !== expectedFlowRevision)) throw new Error("task flow revision changed");
277
- if (patch.state && patch.state !== task.state && !MOVES[task.state].includes(patch.state)) {
295
+ const untouchedReturn = event === "backlog returned untouched" && patch.state === "backlog" && patch.owner === null && patch.reviewer === null
296
+ && ["proposed", "in_progress"].includes(task.state) && expectedFlowRevision !== undefined && !!note?.trim() && !taskHasWorkEvidence(task);
297
+ if (patch.state && patch.state !== task.state && !MOVES[task.state].includes(patch.state) && !untouchedReturn) {
278
298
  throw new Error(`task #${id} is ${task.state}: cannot move to ${patch.state}`);
279
299
  }
280
300
  const next = { ...task, ...patch, refs: { ...task.refs, ...patch.refs } };
281
301
  if (next.state === "backlog" && (next.owner || next.reviewer)) throw new Error(`task #${id}: backlog cannot have an owner or reviewer`);
282
- if (patch.state === "backlog" && task.owner) throw new Error(`task #${id}: owned work cannot return to backlog`);
283
- if (patch.state === "backlog" && taskHasStarted(task)) throw new Error(`task #${id}: started work cannot return to backlog`);
284
- const history = [...task.history, { at: Date.now(), by, event, ...(note ? { note } : {}), ...(patch.cancelled?.supersededBy ? { supersededBy: patch.cancelled.supersededBy } : {}), ...("owner" in patch ? { owner: next.owner } : {}), ...(extra.profile ? { profile: extra.profile } : {}), ...(extra.sweep ? { sweep: extra.sweep } : {}), ...(extra.reason && TASK_MOVE_REASONS.includes(extra.reason) ? { reason: extra.reason } : {}), ...(extra.from ? { from: extra.from } : {}), ...(extra.withheld && note ? { withheld: true as const } : {}) }];
302
+ if (!untouchedReturn) {
303
+ if (patch.state === "backlog" && task.owner) throw new Error(`task #${id}: owned work cannot return to backlog`);
304
+ }
305
+ if (patch.state === "backlog" && taskHasStarted(task) && !untouchedReturn) throw new Error(`task #${id}: started work cannot return to backlog`);
306
+ const history = [...task.history, { at: Date.now(), by, event, ...(note ? { note } : {}), ...(patch.cancelled?.supersededBy ? { supersededBy: patch.cancelled.supersededBy } : {}), ...("owner" in patch ? { owner: next.owner } : {}), ...(extra.profile ? { profile: extra.profile } : {}), ...(extra.sweep ? { sweep: extra.sweep } : {}), ...(extra.reason && TASK_MOVE_REASONS.includes(extra.reason) ? { reason: extra.reason } : {}), ...(extra.from ? { from: extra.from } : {}), ...(extra.withheld && note ? { withheld: true as const } : {}), ...(extra.reviewContext === "fresh" || extra.reviewContext === "continuing" ? { reviewContext: extra.reviewContext } : {}), ...(extra.reviewCost ? { reviewCost: extra.reviewCost } : {}) }];
285
307
  this.taskWrite(() => this.db
286
308
  .query("UPDATE tasks SET state = ?, owner = ?, reviewer = ?, reserved = ?, refs = ?, plan = ?, rejections = ?, signals = ?, history = ?, sdlc = ?, task_role = ?, cancelled = ?, updated = ? WHERE id = ?")
287
309
  .run(next.state, next.owner, next.reviewer, next.reserved ?? null, JSON.stringify(next.refs), JSON.stringify(next.plan ?? {}), next.rejections, JSON.stringify(next.signals), JSON.stringify(history), next.sdlc ? JSON.stringify(next.sdlc) : null, next.role ?? null, next.cancelled ? JSON.stringify(next.cancelled) : null, Date.now(), id));
@@ -309,7 +331,7 @@ export class Board {
309
331
  /** A planner reserves a routing slot without claiming or dispatching the work. */
310
332
  pullPlanned(id: number, by: PeerId, reserved: PeerId, revision: number, note: string, withheld = false): Task {
311
333
  const task = this.get(id);
312
- if (!task || (task.planningRevision ?? 0) !== revision || task.state !== "backlog" || task.owner || taskHasStarted(task)) throw new Error("planner pull changed or already started");
334
+ if (!task || (task.planningRevision ?? 0) !== revision || task.state !== "backlog" || task.owner || !taskCanPlan(task)) throw new Error("planner pull changed or already started");
313
335
  const now = Date.now(), entry: HistoryEntry = { at: now, by, event: "backlog pulled", planning: { revision: revision + 1, fields: ["reserved"], previous: { reserved: task.reserved ?? null }, pull: true }, note, ...(withheld ? { withheld: true as const } : {}) };
314
336
  const result = this.taskWrite(() => this.db.query("UPDATE tasks SET state='proposed',owner=NULL,reviewer=NULL,reserved=?,planning_revision=planning_revision+1,history=?,updated=? WHERE id=? AND planning_revision=? AND state='backlog' AND owner IS NULL")
315
337
  .run(reserved, JSON.stringify([...task.history, entry]), now, id, revision));
package/src/hub/bus.ts CHANGED
@@ -40,9 +40,9 @@ export interface BusOptions {
40
40
  /**
41
41
  * Optional: does a queued envelope still matter to this recipient (issue #106)? Asked per recipient when a delivery is
42
42
  * built and again right before it is handed over, after condensation. False drops it unsent: the journal records it
43
- * as discarded and taps see a `stale` event.
43
+ * as discarded and taps see a `stale` event. A string is false with its own reason (#223, a review-only peer).
44
44
  */
45
- relevant?: (peer: PeerId, env: Envelope) => boolean;
45
+ relevant?: (peer: PeerId, env: Envelope) => boolean | string;
46
46
  /**
47
47
  * Optional: why this recipient does not get this envelope (issue #107, a turn-free cohort). Asked per recipient once
48
48
  * the audience is final (implicit replies and `digest` resolved), so the other recipients still get it, unchanged;
@@ -1088,7 +1088,13 @@ export class Bus {
1088
1088
  }
1089
1089
 
1090
1090
  private isRelevant(id: PeerId, env: Envelope): boolean {
1091
- return !this.withdrawn.has(env.id) && (this.opts.relevant?.(id, env) ?? true);
1091
+ return this.irrelevance(id, env) === undefined;
1092
+ }
1093
+
1094
+ /** Why a queued envelope no longer goes to `id`, or undefined when it still does. */
1095
+ private irrelevance(id: PeerId, env: Envelope): string | undefined {
1096
+ const verdict = this.withdrawn.has(env.id) ? false : this.opts.relevant?.(id, env) ?? true;
1097
+ return verdict === true ? undefined : typeof verdict === "string" ? verdict : `stale: task #${env.refs?.task ?? "?"} is no longer open for ${id}`;
1092
1098
  }
1093
1099
 
1094
1100
  /**
@@ -1101,8 +1107,8 @@ export class Bus {
1101
1107
  let dropped = false;
1102
1108
  for (let i = queue.length - 1; i >= 0; i--) {
1103
1109
  const env = queue[i]!;
1104
- if (this.isRelevant(id, env)) continue;
1105
- const reason = `stale: task #${env.refs?.task ?? "?"} is no longer open for ${id}`;
1110
+ const reason = this.irrelevance(id, env);
1111
+ if (reason === undefined) continue;
1106
1112
  queue.splice(i, 1);
1107
1113
  dropped = true;
1108
1114
  if (this.journal) this.pendingOutcomes.push({ id: crypto.randomUUID(), peer: id, state: "discarded", createdAt: Date.now(), originals: [env], out: [], reason });
@@ -0,0 +1,37 @@
1
+ // No imports: the statusline tee and the CLI load this without the daemon (#450 I3).
2
+ const PEER = /^[a-z][a-z0-9-]{0,31}$/;
3
+
4
+ /** The peer a Claude process writes for: its AGENTHUB_PEER_ID when well formed, otherwise the builtin claude peer. */
5
+ export function claudePeer(value: string | undefined): string {
6
+ return value !== undefined && PEER.test(value) ? value : "claude";
7
+ }
8
+
9
+ /** Per-peer Claude state file names. The builtin claude peer keeps its original names, so nothing migrates. */
10
+ export function claudeStateFile(base: "claude-launch" | "claude-session" | "claude-context", peer: string): string {
11
+ // Instance ids are always <kind>-<name>; a builtin id has no hyphen.
12
+ return peer.includes("-") && PEER.test(peer) ? `${base}.${peer}.json` : `${base}.json`;
13
+ }
14
+
15
+ /** A Claude peer's own row in terminal-recovery.json for this daemon, never a sibling instance's (row order is arbitrary). */
16
+ export function ownTerminalRow(records: unknown, peer: string, projectRoot: string, instanceId: string): { launchId?: unknown } | undefined {
17
+ if (!Array.isArray(records)) return undefined;
18
+ return records.find((row) => row?.peer === peer && row?.projectRoot === projectRoot && row?.instanceId === instanceId);
19
+ }
20
+
21
+ /**
22
+ * Whether a Claude session's transcript exists: the path the peer's own status line reported for that session, else
23
+ * the builtin's default location. An instance may run under its own CLAUDE_CONFIG_DIR, which the hub cannot see:
24
+ * without its reported path the answer is unknown, and unknown counts as persisted (it never waives session identity).
25
+ */
26
+ export function transcriptPersisted(reported: { sessionId?: string; transcriptPath?: string }, sessionId: string, instance: boolean, fallbackPath: string, exists: (path: string) => boolean | "unknown"): boolean {
27
+ // Only a positive "missing" (ENOENT) is not persisted; an unreadable path is unknown and keeps the identity strict.
28
+ const found = (path: string) => exists(path) !== false;
29
+ if (reported.sessionId === sessionId && reported.transcriptPath) return found(reported.transcriptPath);
30
+ return instance || found(fallbackPath);
31
+ }
32
+
33
+ /** lstat evidence: true when present, false only on ENOENT/ENOTDIR, "unknown" for any other failure (EACCES included). */
34
+ export function pathEvidence(path: string, lstat: (path: string) => unknown): boolean | "unknown" {
35
+ try { lstat(path); return true; }
36
+ catch (error) { const code = (error as { code?: string }).code; return code === "ENOENT" || code === "ENOTDIR" ? false : "unknown"; }
37
+ }
@@ -0,0 +1,76 @@
1
+ /** S10 cost evidence is metadata only. Legacy scope and incomplete readback remain unknown. */
2
+ export interface ReviewScope { scope: "none" | "delta" | "full"; reviewers: number }
3
+ export interface CostPlan { review?: ReviewScope; ci?: "focused" | "full" | "reuse" }
4
+ export interface ReviewCost extends CostPlan { head?: string }
5
+ export interface CostTask { repo: string; pr: number; private?: boolean; history: { event: string; at: number; reviewCost?: ReviewCost }[] }
6
+ export interface CostPr { repo: string; pr: number; state: "OPEN" | "CLOSED" | "MERGED"; mergedAt?: number; complete: boolean; ciRuns?: { id: number; attempt: number }[]; reviews?: number }
7
+ export interface CostRow { repo: string; pr: number; reviewRounds: number; ciRuns: number | null; ciReruns: number | null; fullReReviews: number; deltaReviews: number; reviewMs: number | null; unknownReviewScopes: number; githubReviews: number | null; complete: boolean }
8
+ export interface CostReport { rows: CostRow[]; truncated: boolean }
9
+ export function cleanCostPlan(input: unknown): CostPlan {
10
+ if (!input || typeof input !== "object" || Array.isArray(input)) throw new Error("invalid cost scope");
11
+ const value = input as Record<string, unknown>;
12
+ const result: CostPlan = {};
13
+ if (value.review !== undefined) {
14
+ const review = value.review as Record<string, unknown>;
15
+ if (!review || typeof review !== "object" || Array.isArray(review) || Object.keys(review).some(key => !["scope", "reviewers"].includes(key))
16
+ || !["none", "delta", "full"].includes(review.scope as string) || !Number.isSafeInteger(review.reviewers)
17
+ || (review.scope === "none" ? review.reviewers !== 0 : (review.reviewers as number) < 1 || (review.reviewers as number) > 8)) throw new Error("invalid review cost scope");
18
+ result.review = { scope: review.scope as ReviewScope["scope"], reviewers: review.reviewers as number };
19
+ }
20
+ if (value.ci !== undefined) {
21
+ if (!["focused", "full", "reuse"].includes(value.ci as string)) throw new Error("invalid CI cost scope");
22
+ result.ci = value.ci as CostPlan["ci"];
23
+ }
24
+ return result;
25
+ }
26
+ const validRepo = (repo: string) => /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo) && !repo.startsWith("-");
27
+ const key = (repo: string, pr: number) => `${repo.toLowerCase()}#${pr}`;
28
+ /** One round is a board review handoff, not one reviewer in a multi-reviewer round. CI attempts share a run id. */
29
+ export function costMetrics(tasks: CostTask[], prs: CostPr[]): CostReport {
30
+ const withheld = new Set(tasks.filter(task => task.private).map(task => key(task.repo, task.pr)));
31
+ const rows: CostRow[] = [];
32
+ let truncated = tasks.length > 500 || prs.length > 200, historyLeft = 10_000;
33
+ const seen = new Set<string>();
34
+ for (const pr of prs.slice(0, 200)) {
35
+ const identity = key(pr.repo, pr.pr);
36
+ if (!validRepo(pr.repo) || !Number.isSafeInteger(pr.pr) || pr.pr < 1 || pr.state !== "MERGED" || withheld.has(identity) || seen.has(identity)) continue;
37
+ if (!tasks.some(task => !task.private && key(task.repo, task.pr) === identity)) continue;
38
+ seen.add(identity);
39
+ const rounds: { at: number; scope?: ReviewScope["scope"]; elapsed?: number }[] = [];
40
+ let complete = pr.complete && Number.isFinite(pr.mergedAt);
41
+ for (const task of tasks.slice(0, 500).filter(task => key(task.repo, task.pr) === identity)) {
42
+ const history = task.history.slice(0, Math.max(0, historyLeft)); historyLeft -= history.length;
43
+ if (history.length < task.history.length) { complete = false; truncated = true; }
44
+ let pending: typeof rounds[number] | undefined;
45
+ for (const event of [...history].sort((a, b) => a.at - b.at)) {
46
+ if (!Number.isFinite(event.at) || event.at < 0 || !Number.isFinite(pr.mergedAt) || event.at > pr.mergedAt!) continue;
47
+ if (event.event === "done") {
48
+ pending = { at: event.at, scope: event.reviewCost?.review?.scope }; rounds.push(pending);
49
+ } else if (pending && ["approved", "changes_requested"].includes(event.event)) { pending.elapsed = event.at - pending.at; pending = undefined; }
50
+ }
51
+ }
52
+ rounds.sort((a, b) => a.at - b.at);
53
+ const attempts = new Map<number, number>();
54
+ if (pr.ciRuns) for (const run of pr.ciRuns.slice(0, 100)) {
55
+ if (!Number.isSafeInteger(run.id) || run.id < 1 || !Number.isSafeInteger(run.attempt) || run.attempt < 1 || run.attempt > 10_000) { complete = false; continue; }
56
+ attempts.set(run.id, Math.max(attempts.get(run.id) ?? 0, run.attempt));
57
+ }
58
+ if (pr.ciRuns && pr.ciRuns.length > 100) { complete = false; truncated = true; }
59
+ if (!pr.ciRuns || pr.reviews === undefined || rounds.some(round => round.elapsed === undefined || round.scope === undefined)) complete = false;
60
+ rows.push({ repo: pr.repo.toLowerCase(), pr: pr.pr, reviewRounds: rounds.length,
61
+ ciRuns: pr.ciRuns ? attempts.size : null, ciReruns: pr.ciRuns ? [...attempts.values()].reduce((sum, n) => sum + n - 1, 0) : null,
62
+ fullReReviews: rounds.filter((round, index) => index > 0 && round.scope === "full").length,
63
+ deltaReviews: rounds.filter(round => round.scope === "delta").length,
64
+ reviewMs: rounds.every(round => round.elapsed !== undefined) ? rounds.reduce((sum, round) => sum + round.elapsed!, 0) : null,
65
+ unknownReviewScopes: rounds.filter(round => round.scope === undefined).length, githubReviews: pr.reviews ?? null, complete });
66
+ }
67
+ return { rows: rows.sort((a, b) => a.repo.localeCompare(b.repo) || a.pr - b.pr), truncated };
68
+ }
69
+ export function costBandProposals(report: CostReport, upper: Partial<Record<"reviewRounds" | "ciRuns" | "ciReruns" | "fullReReviews" | "deltaReviews" | "reviewMs", number>>): { repo: string; pr: number; metric: string; observed: number; upper: number }[] {
70
+ if (report.truncated) return [];
71
+ return report.rows.filter(row => row.complete).flatMap(row => Object.entries(upper).flatMap(([metric, limit]) => {
72
+ if (!["reviewRounds", "ciRuns", "ciReruns", "fullReReviews", "deltaReviews", "reviewMs"].includes(metric) || !Number.isFinite(limit) || limit! < 0) return [];
73
+ const observed = row[metric as keyof CostRow];
74
+ return typeof observed === "number" && observed > limit! ? [{ repo: row.repo, pr: row.pr, metric, observed, upper: limit! }] : [];
75
+ }));
76
+ }