@staix/agent-hub 0.17.0 → 0.19.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 (109) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/README.md +4 -4
  3. package/docs/agent-notes/README.md +11 -0
  4. package/docs/agent-notes/adapters.md +10 -6
  5. package/docs/agent-notes/benchmarks.md +1 -0
  6. package/docs/agent-notes/daemon.md +6 -2
  7. package/docs/agent-notes/local-worker.md +3 -1
  8. package/docs/agent-notes/tasks.md +11 -2
  9. package/docs/agent-notes/tests.md +7 -3
  10. package/docs/dogfood.md +22 -2
  11. package/docs/memory.md +1 -1
  12. package/docs/operations.md +162 -35
  13. package/docs/profiles.md +36 -0
  14. package/docs/quickstart.md +15 -5
  15. package/docs/sdlc.md +29 -3
  16. package/docs/security.md +44 -13
  17. package/docs/smoke.md +3 -3
  18. package/docs/specs/2026-09-19-agent-hub-design.md +31 -10
  19. package/docs/verification/2026-10-25-0.18.0.md +76 -0
  20. package/docs/verification/2026-10-25-codex-review-live-verdict.md +35 -0
  21. package/docs/verification/2026-10-25-remote-docker-transport.md +56 -0
  22. package/docs/verification/2026-10-26-agy-1.4.7.md +69 -0
  23. package/docs/verification/2026-10-26-claude-implementer-2.1.310.md +69 -0
  24. package/docs/verification/2026-10-26-headless-claude-shell-context.md +62 -0
  25. package/docs/verification/2026-10-26-jcode-0.99.1.md +49 -0
  26. package/docs/verification/2026-10-26-kimi-232-reviewer-compat.md +53 -0
  27. package/docs/verification/2026-10-26-pi-140-compat.md +41 -0
  28. package/docs/verification/2026-10-26-qwen-0261-acp.md +60 -0
  29. package/docs/verification/2026-10-27-0.19.0.md +86 -0
  30. package/docs/verification/claude-permission-transport-native.py +187 -0
  31. package/docs/verification/claude-shell-wrapper-host.ts +20 -0
  32. package/docs/verification/claude-shell-wrapper-native.py +145 -0
  33. package/docs/verification/pi-140-model-free.ts +57 -0
  34. package/docs/verification/pi-140-native.mjs +21 -0
  35. package/docs/verification/profiles/YYYY-MM-DD-general.md +46 -0
  36. package/docs/verification/profiles/YYYY-MM-DD-research.md +61 -0
  37. package/docs/verification/profiles/YYYY-MM-DD-writing.md +46 -0
  38. package/docs/verified.json +24 -18
  39. package/package.json +2 -2
  40. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  41. package/plugins/agent-hub/server.js +364 -98
  42. package/src/adapters/agy/entry.ts +1 -1
  43. package/src/adapters/claude-channel.ts +7 -3
  44. package/src/adapters/claude-implementer.ts +5 -3
  45. package/src/adapters/compat.ts +32 -18
  46. package/src/adapters/conformance/fakes/pi-rpc.ts +1 -0
  47. package/src/adapters/definitions/builtin-metadata.ts +3 -3
  48. package/src/adapters/definitions/native-factories.ts +7 -2
  49. package/src/adapters/definitions/reviewer-variants.ts +2 -2
  50. package/src/adapters/local-worker.ts +5 -3
  51. package/src/adapters/pi.ts +39 -3
  52. package/src/adapters/qwen-dual.ts +25 -0
  53. package/src/adapters/registry-types.ts +6 -2
  54. package/src/adapters/registry.ts +1 -0
  55. package/src/cli/agents.ts +69 -7
  56. package/src/cli/claude-shell-wrapper.ts +54 -0
  57. package/src/cli/console-state.ts +9 -2
  58. package/src/cli/help.ts +29 -28
  59. package/src/cli/identity.ts +87 -6
  60. package/src/cli/main.ts +198 -71
  61. package/src/cli/output.ts +11 -2
  62. package/src/cli/sdlc-output.ts +242 -0
  63. package/src/cli/task-output.ts +212 -0
  64. package/src/cli/upgrade-output.ts +57 -0
  65. package/src/hub/agent-drift.ts +61 -0
  66. package/src/hub/board.ts +31 -2
  67. package/src/hub/check-runner.ts +385 -25
  68. package/src/hub/claude-shell-wrapper.ts +349 -0
  69. package/src/hub/conductor.ts +5 -0
  70. package/src/hub/config-apply.ts +16 -7
  71. package/src/hub/config-edit-host.ts +1 -1
  72. package/src/hub/config-edit.ts +2 -2
  73. package/src/hub/config-tier.ts +3 -3
  74. package/src/hub/daemon.ts +530 -87
  75. package/src/hub/dogfood-profile.ts +248 -0
  76. package/src/hub/dogfood-read.ts +77 -14
  77. package/src/hub/dogfood.ts +5 -1
  78. package/src/hub/events.ts +1 -1
  79. package/src/hub/host-executable.ts +21 -1
  80. package/src/hub/hub-tools.ts +2 -2
  81. package/src/hub/instances.ts +18 -2
  82. package/src/hub/lifecycle.ts +1 -1
  83. package/src/hub/operational-authority.ts +67 -0
  84. package/src/hub/operational-view.ts +137 -0
  85. package/src/hub/orphan-process.ts +69 -0
  86. package/src/hub/permission-mode.ts +1 -1
  87. package/src/hub/read-only-shell-proof.ts +81 -0
  88. package/src/hub/read-only-shell.ts +162 -0
  89. package/src/hub/review-scope.ts +13 -0
  90. package/src/hub/reviewer-pool-config.ts +3 -2
  91. package/src/hub/reviewer-pool.ts +3 -0
  92. package/src/hub/routing.ts +23 -6
  93. package/src/hub/sdlc-merge.ts +299 -0
  94. package/src/hub/sdlc-metrics.ts +6 -4
  95. package/src/hub/sdlc-mirror.ts +129 -0
  96. package/src/hub/sdlc.ts +58 -12
  97. package/src/hub/settings.ts +11 -5
  98. package/src/hub/snapshots.ts +60 -0
  99. package/src/hub/task-repository.ts +31 -5
  100. package/src/hub/task-shell-command.ts +85 -0
  101. package/src/hub/task-shell-default.ts +78 -0
  102. package/src/hub/task-shell-proof.ts +171 -0
  103. package/src/hub/task-shell-sandbox.ts +61 -0
  104. package/src/hub/tasks.ts +378 -27
  105. package/src/local/deny.ts +91 -11
  106. package/src/local/sandbox.ts +67 -28
  107. package/src/local/shell-environment.ts +10 -0
  108. package/src/local/tools.ts +17 -4
  109. package/src/ui/index.html +3 -1
@@ -0,0 +1,349 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { accessSync, chmodSync, constants, lstatSync, mkdtempSync, rmSync, rmdirSync, unlinkSync } from "node:fs";
3
+ import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
4
+ import { type Socket } from "node:net";
5
+ import { isAbsolute, join, relative } from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+ import { spawn, type ChildProcess } from "node:child_process";
8
+ import type { Readable, Writable } from "node:stream";
9
+ import { HOST_SHELL_PROTOCOL as WIRE } from "../cli/claude-shell-wrapper.ts";
10
+ import { shellQuote } from "../cli/shell-quote.ts";
11
+ import { readProcessTable, stopOwnedProcess, trackGroup } from "./child-process.ts";
12
+ import { realPath } from "./project.ts";
13
+ import { sbplString } from "../local/deny.ts";
14
+
15
+ export interface ShellGrant {
16
+ peer: string; task: number; generation: string; root: string; branch: string;
17
+ executable: string; argv: readonly string[]; env: Readonly<Record<string, string>>;
18
+ signal: AbortSignal; timeoutMs: number; current(): boolean; activity?: () => void;
19
+ sandbox?: { profile: string; readOnlyData: boolean };
20
+ }
21
+ type Stamp = { path: string; identity: string };
22
+ type Ticket = { source: ShellGrant; grant: Readonly<ShellGrant>; expires: number; root: Stamp; executable: Stamp; abort(): void };
23
+ type Execution = { ticket: Ticket; proc?: ChildProcess; temporary?: Stamp; response: ServerResponse; cancelled: boolean; failedCleanup: boolean; stopping?: Promise<void>; done: Promise<void>; cancel(): Promise<void> };
24
+ const MAX_TICKETS = 64, MAX_PEER = 8, TTL_MS = 30_000;
25
+ const MAX_EXECUTION_PAYLOAD = 256 * 1024;
26
+ // Host-only source: no public CLI mode accepts execution descriptors. The descriptor arrives on fd4,
27
+ // never argv/env, and the exact target env is passed to spawn rather than inherited through a shell.
28
+ const SUPERVISOR_SOURCE = String.raw`
29
+ import { createReadStream, writeSync, closeSync } from "node:fs";
30
+ import { spawn } from "node:child_process";
31
+ import { constants } from "node:os";
32
+ let finished = false;
33
+ const finish = code => {
34
+ if (finished) return;
35
+ finished = true;
36
+ try { writeSync(3, String(code) + "\n"); } catch {}
37
+ for (const fd of [1, 2, 3]) { try { closeSync(fd); } catch {} }
38
+ };
39
+ // The host owns stdin. On owner loss, only this original detached group is terminated.
40
+ process.stdin.on("end", () => { try { process.kill(-process.pid, "SIGKILL"); } catch { process.exit(125); } });
41
+ process.stdin.resume();
42
+ try {
43
+ const pieces = []; let bytes = 0;
44
+ for await (const part of createReadStream("/dev/null", { fd: 4, autoClose: true, highWaterMark: 16384 })) {
45
+ const block = Buffer.isBuffer(part) ? part : Buffer.from(part); bytes += block.length;
46
+ if (bytes > 262144) throw new Error(); pieces.push(block);
47
+ }
48
+ const value = JSON.parse(Buffer.concat(pieces, bytes).toString("utf8"));
49
+ if (!value || typeof value !== "object" || Array.isArray(value) || Object.keys(value).sort().join(",") !== "argv,env,executable,root"
50
+ || typeof value.root !== "string" || !value.root.startsWith("/") || typeof value.executable !== "string" || !value.executable.startsWith("/")
51
+ || !Array.isArray(value.argv) || value.argv.some(v => typeof v !== "string" || v.includes("\0"))
52
+ || !value.env || typeof value.env !== "object" || Array.isArray(value.env)
53
+ || Object.entries(value.env).some(([key, v]) => !key || key.includes("=") || key.includes("\0") || typeof v !== "string" || v.includes("\0"))) throw new Error();
54
+ const child = spawn(value.executable, value.argv, { cwd: value.root, env: value.env, stdio: ["ignore", 1, 2, "ignore", "ignore"] });
55
+ const status = (code, signal) => {
56
+ const found = code ?? (signal && constants.signals[signal] ? 128 + constants.signals[signal] : 125);
57
+ finish(Number.isInteger(found) && found >= 0 && found <= 255 ? found : 125);
58
+ };
59
+ child.once("error", () => finish(256)); child.once("exit", status);
60
+ if (child.exitCode !== null || child.signalCode !== null) status(child.exitCode, child.signalCode);
61
+ } catch { finish(256); }
62
+ `;
63
+ const fail = () => new Error(WIRE.refusal);
64
+ const beneath = (root: string, file: string) => { const r = relative(root, file); return !r || r !== ".." && !r.startsWith("../") && !isAbsolute(r); };
65
+ function stamp(value: string, directory = false): Stamp {
66
+ const path = realPath(value), s = lstatSync(path);
67
+ if (directory ? !s.isDirectory() : !s.isFile()) throw fail();
68
+ if (!directory) accessSync(path, constants.R_OK);
69
+ return { path, identity: JSON.stringify(directory ? [path, s.dev, s.ino, s.mode] : [path, s.dev, s.ino, s.mode, s.size, s.mtimeMs, s.ctimeMs]) };
70
+ }
71
+ const same = (value: string, prior: Stamp, directory = false) => { try { return stamp(value, directory).identity === prior.identity; } catch { return false; } };
72
+ function removeOwned(directory: Stamp): void {
73
+ try { lstatSync(directory.path); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return; throw fail(); }
74
+ if (!same(directory.path, directory, true)) throw fail();
75
+ rmSync(directory.path, { recursive: true, force: true });
76
+ }
77
+ function snapshot(source: ShellGrant): Readonly<ShellGrant> | undefined {
78
+ try {
79
+ if (!source || !/^[a-z][a-z0-9-]{0,63}$/.test(source.peer) || !Number.isSafeInteger(source.task) || source.task < 1
80
+ || typeof source.generation !== "string" || !source.generation || source.generation.length > 256 || /[\p{Cc}]/u.test(source.generation)
81
+ || typeof source.root !== "string" || !isAbsolute(source.root) || typeof source.executable !== "string" || !isAbsolute(source.executable)
82
+ || typeof source.branch !== "string" || !source.branch || source.branch.length > 256 || /[\p{Cc}]/u.test(source.branch)
83
+ || !Array.isArray(source.argv) || source.argv.length > 256 || source.argv.some(a => typeof a !== "string" || a.includes("\0") || a.length > 64 * 1024)
84
+ || !source.env || typeof source.env !== "object" || Array.isArray(source.env) || !(source.signal instanceof AbortSignal) || source.signal.aborted
85
+ || !Number.isSafeInteger(source.timeoutMs) || source.timeoutMs < 1 || source.timeoutMs > WIRE.maximumMs || typeof source.current !== "function"
86
+ || source.activity !== undefined && typeof source.activity !== "function") return;
87
+ if (source.sandbox !== undefined && (!source.sandbox || typeof source.sandbox !== "object" || Array.isArray(source.sandbox)
88
+ || Object.keys(source.sandbox).sort().join(",") !== "profile,readOnlyData" || typeof source.sandbox.profile !== "string" || !source.sandbox.profile
89
+ || source.sandbox.profile.includes("\0") || Buffer.byteLength(source.sandbox.profile) > 128 * 1024 || typeof source.sandbox.readOnlyData !== "boolean"
90
+ || source.sandbox.readOnlyData && /\(allow file-write\* \(subpath /.test(source.sandbox.profile))) return;
91
+ const entries = Object.entries(source.env);
92
+ if (entries.length > 256 || entries.some(([key, value]) => !key || key.includes("=") || key.includes("\0") || typeof value !== "string" || value.includes("\0"))
93
+ || Buffer.byteLength(JSON.stringify([source.argv, entries, source.sandbox])) > 256 * 1024) return;
94
+ return Object.freeze({ ...source, argv: Object.freeze([...source.argv]), env: Object.freeze(Object.fromEntries(entries)),
95
+ ...(source.sandbox ? { sandbox: Object.freeze({ ...source.sandbox }) } : {}) });
96
+ } catch { return; }
97
+ }
98
+ function unchanged(ticket: Ticket): boolean {
99
+ const a = ticket.source, b = ticket.grant;
100
+ try {
101
+ return a.peer === b.peer && a.task === b.task && a.generation === b.generation && a.root === b.root && a.branch === b.branch
102
+ && a.executable === b.executable && a.signal === b.signal && a.timeoutMs === b.timeoutMs && a.current === b.current && a.activity === b.activity
103
+ && a.argv.length === b.argv.length && a.argv.every((v, i) => v === b.argv[i])
104
+ && (a.sandbox === undefined ? b.sandbox === undefined : b.sandbox !== undefined && Object.keys(a.sandbox).sort().join(",") === "profile,readOnlyData"
105
+ && a.sandbox.profile === b.sandbox.profile && a.sandbox.readOnlyData === b.sandbox.readOnlyData)
106
+ && Object.keys(a.env).length === Object.keys(b.env).length && Object.entries(b.env).every(([k, v]) => a.env[k] === v);
107
+ } catch { return false; }
108
+ }
109
+
110
+ /** An in-memory single-use capability service. Only the host supplies grants; request bodies carry a ticket only. */
111
+ export class HostShellWrapper {
112
+ private server!: Server;
113
+ private readonly tickets = new Map<string, Ticket>();
114
+ private readonly active = new Set<Execution>();
115
+ private readonly connections = new Set<Socket>();
116
+ private closing = false;
117
+ private closed = false;
118
+ private closingWork?: Promise<void>;
119
+ private socketIdentity?: string;
120
+ private constructor(private readonly directory: Stamp, private readonly socket: string, private readonly runtime: Stamp, private readonly entry: Stamp, private readonly envProgram: Stamp, private readonly implementation: Stamp, private readonly sandboxExec?: Stamp) {}
121
+
122
+ static async create(): Promise<HostShellWrapper> {
123
+ // A short system temp path avoids Darwin's Unix-socket length ceiling under a nested test TMPDIR.
124
+ // Native/target TMPDIR never chooses the private capability directory.
125
+ const directory = mkdtempSync(join(realPath("/tmp"), "ahub-shell-")); chmodSync(directory, 0o700);
126
+ let wrapper: HostShellWrapper | undefined;
127
+ try {
128
+ const runtime = stamp(process.execPath), entry = stamp(fileURLToPath(new URL("../cli/claude-shell-wrapper.ts", import.meta.url))), envProgram = stamp("/usr/bin/env"), implementation = stamp(fileURLToPath(import.meta.url));
129
+ for (const program of [runtime, envProgram]) accessSync(program.path, constants.X_OK);
130
+ let sandboxExec: Stamp | undefined;
131
+ if (process.platform === "darwin") { try { sandboxExec = stamp("/usr/bin/sandbox-exec"); accessSync(sandboxExec.path, constants.X_OK); } catch { sandboxExec = undefined; } }
132
+ wrapper = new HostShellWrapper(stamp(directory, true), join(directory, "s"), runtime, entry, envProgram, implementation, sandboxExec);
133
+ const owner = wrapper;
134
+ const server = wrapper.server = createServer({ maxHeaderSize: 2048 }, (request, response) => {
135
+ response.on("error", () => {});
136
+ void owner.handle(request, response).catch(() => owner.refuse(response));
137
+ });
138
+ server.maxConnections = 96; server.maxHeadersCount = 8; server.requestTimeout = 5000; server.headersTimeout = 5000; server.keepAliveTimeout = 1000; server.timeout = 0;
139
+ server.on("connection", socket => { owner.connections.add(socket); socket.once("close", () => owner.connections.delete(socket)); });
140
+ server.on("clientError", (_error, socket) => { socket.destroy(); });
141
+ await new Promise<void>((resolve, reject) => { server.once("error", reject); server.listen(owner.socket, () => { server.off("error", reject); resolve(); }); });
142
+ server.on("error", () => {});
143
+ chmodSync(wrapper.socket, 0o600);
144
+ const socket = lstatSync(wrapper.socket); wrapper.socketIdentity = JSON.stringify([socket.dev, socket.ino, socket.mode]);
145
+ return wrapper;
146
+ } catch {
147
+ if (wrapper?.server) {
148
+ for (const connection of wrapper.connections) connection.destroy();
149
+ if (wrapper.server.listening) await new Promise<void>(resolve => wrapper!.server.close(() => resolve()));
150
+ }
151
+ try { rmSync(directory, { recursive: true, force: true }); } catch { /* unavailable service, no issued grant */ }
152
+ throw fail();
153
+ }
154
+ }
155
+
156
+ private stable(root: string): boolean {
157
+ return !this.closing && same(this.directory.path, this.directory, true) && same(this.runtime.path, this.runtime)
158
+ && same(this.entry.path, this.entry) && same(this.envProgram.path, this.envProgram)
159
+ && same(this.implementation.path, this.implementation)
160
+ && ![this.runtime.path, this.entry.path, this.envProgram.path, this.implementation.path].some(file => beneath(root, file));
161
+ }
162
+ private sandboxStable(grant: Readonly<ShellGrant>, root: string): boolean {
163
+ return !grant.sandbox || process.platform === "darwin" && !!this.sandboxExec && same(this.sandboxExec.path, this.sandboxExec) && !beneath(root, this.sandboxExec.path);
164
+ }
165
+ private count(peer?: string): number { return [...this.tickets.values(), ...[...this.active].map(a => a.ticket)].filter(t => peer === undefined || t.grant.peer === peer).length; }
166
+ private forget(token: string, ticket: Ticket): void { this.tickets.delete(token); ticket.grant.signal.removeEventListener("abort", ticket.abort); }
167
+ private prune(): void { for (const [token, ticket] of this.tickets) if (ticket.expires <= Date.now() || ticket.grant.signal.aborted) this.forget(token, ticket); }
168
+
169
+ issue(source: ShellGrant): { command: string } | undefined {
170
+ this.prune(); const grant = snapshot(source); if (!grant) return;
171
+ try {
172
+ const root = stamp(grant.root, true), executable = stamp(grant.executable); accessSync(executable.path, constants.X_OK);
173
+ if (!this.stable(root.path) || !this.sandboxStable(grant, root.path) || this.count() >= MAX_TICKETS || this.count(grant.peer) >= MAX_PEER || grant.current() !== true || grant.signal.aborted || this.closing) return;
174
+ const token = randomBytes(32).toString("hex");
175
+ const ticket: Ticket = { source, grant, root, executable, expires: Date.now() + TTL_MS, abort: () => this.forget(token, ticket) };
176
+ if (!unchanged(ticket) || !this.stable(root.path) || !this.sandboxStable(grant, root.path) || this.count() >= MAX_TICKETS || this.count(grant.peer) >= MAX_PEER) return;
177
+ this.tickets.set(token, ticket); grant.signal.addEventListener("abort", ticket.abort, { once: true });
178
+ if (grant.signal.aborted) { this.forget(token, ticket); return; }
179
+ // env -i alone does not prevent Bun from discovering config/preloads in a persisted native cwd.
180
+ return { command: [this.envProgram.path, "-i", `HOME=${this.directory.path}`, this.runtime.path, "--config=/dev/null", "--no-env-file", `--cwd=${this.directory.path}`, this.entry.path, this.socket, token].map(shellQuote).join(" ") };
181
+ } catch { return; }
182
+ }
183
+
184
+ private refuse(response: ServerResponse): void {
185
+ if (response.destroyed || response.writableEnded) return;
186
+ if (!response.headersSent) { response.statusCode = 403; response.setHeader("connection", "close"); response.end(`${WIRE.refusal}\n`); }
187
+ else response.destroy();
188
+ }
189
+ private async body(request: IncomingMessage): Promise<unknown> {
190
+ const length = request.headers["content-length"];
191
+ if (typeof length !== "string" || !/^[0-9]+$/.test(length) || Number(length) > 256 || Number(length) < 1 || request.headers["transfer-encoding"] !== undefined) throw fail();
192
+ const chunks: Buffer[] = []; let bytes = 0;
193
+ const timer = setTimeout(() => request.destroy(), 3000);
194
+ try {
195
+ for await (const part of request) { const chunk = Buffer.isBuffer(part) ? part : Buffer.from(part); bytes += chunk.length; if (bytes > 256) throw fail(); chunks.push(chunk); }
196
+ if (bytes !== Number(length)) throw fail();
197
+ return JSON.parse(Buffer.concat(chunks).toString("utf8"));
198
+ } finally { clearTimeout(timer); }
199
+ }
200
+ private async handle(request: IncomingMessage, response: ServerResponse): Promise<void> {
201
+ if (this.closing || request.method !== "POST" || request.url !== "/execute" || Object.hasOwn(request.headers, "origin") || request.headers["content-type"] !== "application/json") return this.refuse(response);
202
+ const body = await this.body(request) as { ticket?: unknown };
203
+ if (!body || typeof body !== "object" || Array.isArray(body) || typeof body.ticket !== "string" || !/^[a-f0-9]{64}$/.test(body.ticket)) return this.refuse(response);
204
+ const ticket = this.tickets.get(body.ticket);
205
+ if (!ticket) return this.refuse(response);
206
+ this.forget(body.ticket, ticket); // Consume before schema, expiry, authority or metadata validation.
207
+ if (Object.keys(body).length !== 1 || ticket.expires <= Date.now()) return this.refuse(response);
208
+ const execution: Execution = { ticket, response, cancelled: false, failedCleanup: false, done: Promise.resolve(), cancel: async () => { execution.cancelled = true; await this.stop(execution); } };
209
+ this.active.add(execution);
210
+ execution.done = this.execute(execution);
211
+ await execution.done;
212
+ }
213
+ private async stop(execution: Execution): Promise<void> {
214
+ if (!execution.proc) return;
215
+ if (execution.stopping) return execution.stopping;
216
+ const work = stopOwnedProcess(execution.proc, { group: true }).then(() => { execution.failedCleanup = false; }, () => { execution.failedCleanup = true; throw fail(); });
217
+ execution.stopping = work;
218
+ try { await work; } finally { execution.stopping = undefined; }
219
+ }
220
+ private async execute(execution: Execution): Promise<void> {
221
+ const { ticket, response } = execution, grant = ticket.grant;
222
+ let timer: ReturnType<typeof setTimeout> | undefined, heartbeat: ReturnType<typeof setInterval> | undefined, temporary: Stamp | undefined;
223
+ const activity = () => { try { grant.activity?.(); } catch { /* A heartbeat grants nothing. */ } };
224
+ const abort = () => { void execution.cancel().catch(() => response.destroy()); };
225
+ const disconnected = () => { if (!response.writableEnded) abort(); };
226
+ response.once("close", disconnected); response.on("error", abort); grant.signal.addEventListener("abort", abort, { once: true });
227
+ try {
228
+ if (!unchanged(ticket) || !this.stable(ticket.root.path) || !this.sandboxStable(grant, ticket.root.path) || !same(grant.root, ticket.root, true) || !same(grant.executable, ticket.executable)) throw fail();
229
+ const temp = mkdtempSync(join(this.directory.path, "r-")); chmodSync(temp, 0o700); temporary = execution.temporary = stamp(temp, true);
230
+ const env = { ...grant.env, TMPDIR: `${temporary.path}/` };
231
+ // Identical own-temp allow suffix and data.readonly broad-write guard to sandboxedExec.
232
+ if (grant.sandbox?.readOnlyData && /\(allow file-write\* \(subpath /.test(grant.sandbox.profile)) throw fail();
233
+ const sandbox = grant.sandbox && `${grant.sandbox.profile}\n(allow file-read* ${grant.sandbox.readOnlyData ? "" : "file-write* "}process-exec (subpath ${sbplString(temporary.path)}))`;
234
+ const target = sandbox ? [this.sandboxExec!.path, "-p", sandbox, ticket.executable.path, ...grant.argv] : [ticket.executable.path, ...grant.argv];
235
+ const payload = JSON.stringify({ root: ticket.root.path, executable: target[0], argv: target.slice(1), env });
236
+ if (Buffer.byteLength(payload) > MAX_EXECUTION_PAYLOAD) throw fail();
237
+ // No await or effectful setup remains between this final host proof and spawn.
238
+ if (!this.sandboxStable(grant, ticket.root.path) || grant.current() !== true || grant.signal.aborted || execution.cancelled || this.closing || response.destroyed) throw fail();
239
+ // Keep our original group leader alive through output drain. stopOwnedProcess refuses to signal a
240
+ // reaped leader's surviving group. The fixed Bun supervisor reports target status on fd3,
241
+ // closes its own output fds and stays alive on the host-owned stdin release pipe.
242
+ const proc = execution.proc = spawn(this.runtime.path, ["--config=/dev/null", "--no-env-file", `--cwd=${this.directory.path}`, "-e", SUPERVISOR_SOURCE], {
243
+ cwd: this.directory.path, env: { HOME: this.directory.path }, detached: true, stdio: ["pipe", "pipe", "pipe", "pipe", "pipe"],
244
+ });
245
+ proc.on("error", () => {}); proc.stdin!.on("error", () => {});
246
+ const control = proc.stdio[4] as Writable;
247
+ control.on("error", abort);
248
+ proc.once("spawn", () => {
249
+ try {
250
+ if (!unchanged(ticket) || !this.stable(ticket.root.path) || !this.sandboxStable(grant, ticket.root.path)
251
+ || !same(grant.root, ticket.root, true) || !same(grant.executable, ticket.executable)
252
+ || grant.current() !== true || grant.signal.aborted || execution.cancelled || this.closing || response.destroyed) { abort(); return; }
253
+ control.end(payload);
254
+ } catch { abort(); }
255
+ });
256
+ trackGroup(proc);
257
+ response.writeHead(200, { "content-type": "application/x-ndjson", connection: "close", "cache-control": "no-store" }); response.flushHeaders();
258
+ timer = setTimeout(abort, grant.timeoutMs); heartbeat = setInterval(activity, 250); heartbeat.unref(); activity();
259
+ let queued = Promise.resolve();
260
+ const frame = (value: unknown): Promise<void> => {
261
+ const bytes = `${JSON.stringify(value)}\n`; if (Buffer.byteLength(bytes) > WIRE.frameBytes) return Promise.reject(fail());
262
+ const work = queued.then(() => new Promise<void>((resolve, reject) => {
263
+ if (response.destroyed || response.writableEnded) { reject(fail()); return; }
264
+ response.write(bytes, error => error ? reject(error) : resolve());
265
+ }));
266
+ queued = work.catch(() => {}); return work;
267
+ };
268
+ const pump = async (stream: Readable, type: "stdout" | "stderr") => {
269
+ stream.pause();
270
+ for (;;) {
271
+ const raw = stream.read(Math.min(WIRE.chunkBytes, stream.readableLength || WIRE.chunkBytes));
272
+ if (raw !== null) {
273
+ const chunk = Buffer.isBuffer(raw) ? raw : Buffer.from(raw);
274
+ if (chunk.length > WIRE.chunkBytes) throw fail();
275
+ activity(); await frame({ type, data: chunk.toString("base64") }); continue;
276
+ }
277
+ if (stream.readableEnded) return;
278
+ if (stream.destroyed) throw fail();
279
+ await new Promise<void>((resolve, reject) => {
280
+ const clean = () => { stream.off("readable", ready); stream.off("end", ready); stream.off("error", broken); stream.off("close", closed); };
281
+ const ready = () => { clean(); resolve(); };
282
+ const broken = () => { clean(); reject(fail()); };
283
+ const closed = () => stream.readableEnded ? ready() : broken();
284
+ stream.once("readable", ready); stream.once("end", ready); stream.once("error", broken); stream.once("close", closed);
285
+ if (stream.readableLength || stream.readableEnded) ready(); else if (stream.destroyed) broken();
286
+ });
287
+ }
288
+ };
289
+ const status = (async () => {
290
+ let value = "";
291
+ for await (const raw of proc.stdio[3] as Readable) { value += Buffer.from(raw).toString("ascii"); if (value.length > 16) throw fail(); }
292
+ if (!/^[0-9]{1,3}\n$/.test(value) || Number(value) > 255) throw fail();
293
+ // The leader is still ours. A surviving same-group child is an unclean target outcome; reap it
294
+ // now rather than circularly waiting for a pipe that that child still owns. Unknown evidence asks.
295
+ const rows = await readProcessTable();
296
+ if (!rows || !rows.some(row => row.pid === proc.pid && row.pgid === proc.pid)) throw fail();
297
+ if (rows.some(row => row.pgid === proc.pid && row.pid !== proc.pid)) { execution.cancelled = true; await this.stop(execution); throw fail(); }
298
+ return Number(value);
299
+ })();
300
+ const [code] = await Promise.all([status, pump(proc.stdout!, "stdout"), pump(proc.stderr!, "stderr")]);
301
+ await this.stop(execution);
302
+ if (execution.cancelled) throw fail();
303
+ removeOwned(temporary); temporary = undefined; execution.temporary = undefined;
304
+ if (execution.cancelled || grant.signal.aborted || this.closing || response.destroyed) throw fail();
305
+ if (!Number.isInteger(code) || code < 0 || code > 255) throw fail();
306
+ await frame({ type: "exit", code }); response.end();
307
+ } catch {
308
+ execution.cancelled = true;
309
+ try { await this.stop(execution); } catch { execution.failedCleanup = true; }
310
+ this.refuse(response);
311
+ } finally {
312
+ clearTimeout(timer); clearInterval(heartbeat); grant.signal.removeEventListener("abort", abort); response.off("close", disconnected); response.off("error", abort);
313
+ if (!execution.failedCleanup && temporary) { try { removeOwned(temporary); execution.temporary = undefined; } catch { execution.failedCleanup = true; } }
314
+ if (!execution.failedCleanup) this.active.delete(execution);
315
+ }
316
+ }
317
+
318
+ close(): Promise<void> {
319
+ if (this.closed) return Promise.resolve();
320
+ if (this.closingWork) return this.closingWork;
321
+ this.closing = true;
322
+ for (const [token, ticket] of this.tickets) this.forget(token, ticket);
323
+ const work = (async () => {
324
+ const entries = [...this.active];
325
+ await Promise.allSettled(entries.map(entry => entry.cancel()));
326
+ await Promise.allSettled(entries.map(entry => entry.done));
327
+ for (const entry of entries) if (!entry.failedCleanup) {
328
+ try { if (entry.temporary) removeOwned(entry.temporary); entry.temporary = undefined; this.active.delete(entry); }
329
+ catch { entry.failedCleanup = true; }
330
+ }
331
+ for (const connection of this.connections) connection.destroy();
332
+ await new Promise<void>(resolve => { if (!this.server.listening) resolve(); else this.server.close(() => resolve()); });
333
+ if ([...this.active].some(entry => entry.failedCleanup)) throw fail();
334
+ if (!same(this.directory.path, this.directory, true)) throw fail();
335
+ try {
336
+ const socket = lstatSync(this.socket);
337
+ if (!socket.isSocket() || JSON.stringify([socket.dev, socket.ino, socket.mode]) !== this.socketIdentity) throw fail();
338
+ unlinkSync(this.socket);
339
+ } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw fail(); }
340
+ try { rmdirSync(this.directory.path); } catch { throw fail(); }
341
+ this.closed = true;
342
+ })();
343
+ this.closingWork = work;
344
+ void work.finally(() => { if (!this.closed && this.closingWork === work) this.closingWork = undefined; }).catch(() => {});
345
+ return work;
346
+ }
347
+ }
348
+
349
+ export const createHostShellWrapper = (): Promise<HostShellWrapper> => HostShellWrapper.create();
@@ -166,6 +166,8 @@ export interface ConductorHooks {
166
166
  status(): ConductorStatusInput;
167
167
  task(id: number): Task | undefined;
168
168
  publicView(task: Task): Record<string, unknown>;
169
+ /** #812 AC5: true for a peer holding the configured planner role; it may read any task's public view. */
170
+ isPlanner?(actor: string): boolean;
169
171
  /** These callbacks must call Tasks, never Board.update. */
170
172
  assign(actor: string, id: number, peer: string, role?: TaskAssignmentRole): Promise<unknown>;
171
173
  escalate(actor: string, id: number): Promise<unknown>;
@@ -202,6 +204,9 @@ export class Conductor {
202
204
  const task = id === undefined ? undefined : this.hooks.task(id);
203
205
  // A task's current owner and reviewer read its public view without the role (#208); nobody else does.
204
206
  if (task && task.state !== "backlog" && tool === "hub_task_show" && (task.owner === actor || task.reviewer === actor)) return publicConductorTask(task, this.hooks.publicView);
207
+ // #812 AC5: the configured planner reads any task the conductor can, through the same public view (a PII task stays a stub). A staged backlog row stays hidden from agent task
208
+ // tools (#263: backlog is read through planner tools), and a peer that is also the conductor takes the audited conductor path above this branch. Read only: no hold or assignment.
209
+ if (task && task.state !== "backlog" && tool === "hub_task_show" && this.hooks.isPlanner?.(actor)) return publicConductorTask(task, this.hooks.publicView);
205
210
  // Its proposer (the first history entry) redirects it while nobody ever accepted it (#207: a decline or a release puts
206
211
  // worked tasks back in proposed); work that waits gets a reserved owner. Never over the person: a console assign or
207
212
  // reservation stands until the conductor or the console moves it, also once the hub carried it out (the hub's own
@@ -1,3 +1,5 @@
1
+ import { operationalSettingKey } from "./operational-authority.ts";
2
+ import { remoteRunnerConfig } from "./check-runner.ts";
1
3
  import { ACTION_CLASSES } from "./action-class.ts";
2
4
  import { actionPolicyConfig } from "./action-policy.ts";
3
5
  import { adapterModuleDeclarations } from "../adapters/external-modules.ts";
@@ -39,7 +41,7 @@ export interface ConfigAuthorizationInput {
39
41
  /** Captured host filename, not the requested setting or a caller-provided authority. */
40
42
  sourceFile?: string;
41
43
  tracked: boolean | undefined;
42
- authority: "ordinary" | "settings" | "terminal";
44
+ authority: "ordinary" | "settings" | "terminal" | "conductor";
43
45
  /** The daemon supplies this only for the selected setting or an explicitly confirmed reload key. */
44
46
  confirmed?: boolean;
45
47
  /** Root proved the destination equals an unchanged, effective startup declaration in this origin. */
@@ -60,9 +62,14 @@ export function authorizeConfigLeaf(input: ConfigAuthorizationInput): ConfigAuth
60
62
  if (input.sourceFile !== "config.local.json") return result(false, true, "action policy requires .agenthub/config.local.json");
61
63
  if (input.tracked !== false) return result(false, true, "action policy requires an untracked machine-local source");
62
64
  }
65
+ const remote = key === "checks.remote" || key.startsWith("checks.remote.") || key === "checks.runner" && after === "remote"
66
+ || key === "checks" && !!after && typeof after === "object" && ((after as Record<string, unknown>).runner === "remote" || (after as Record<string, unknown>).remote !== undefined);
67
+ if (remote && after !== undefined && (input.sourceFile !== "config.local.json" || input.tracked !== false)) return result(false, true, "remote checks require untracked config.local.json");
63
68
  if (same(before, after)) return result(true, false, "unchanged");
64
69
  const supplied = input.guard;
65
70
  const guard = supplied && (key === supplied.key || key === `${supplied.store === "routing" ? "routing." : ""}${supplied.path.join(".")}`) ? supplied : undefined;
71
+ if (input.authority === "conductor" && (!guard || input.source !== "config" || guard.store !== "config" || input.structural
72
+ || !(operationalSettingKey(key) || /^roster\.[a-z][a-z0-9-]{0,31}$/.test(key)))) return result(false, true, "conductor changes require one selected operational registry setting");
66
73
  const machine = input.source === "config" && ALL_MACHINE_LOCAL.some(path => key === path || key.startsWith(`${path}.`));
67
74
  const strings = (value: unknown): value is string[] => Array.isArray(value) && value.every(item => typeof item === "string");
68
75
  const subset = (smaller: unknown, larger: unknown) => strings(larger) && (smaller === undefined || strings(smaller) && smaller.every(item => larger.includes(item)));
@@ -88,7 +95,7 @@ export function authorizeConfigLeaf(input: ConfigAuthorizationInput): ConfigAuth
88
95
  if (input.source === "config" && (key === "pet" || key.startsWith("pet."))) return result(true, false, "console-local presentation");
89
96
  if (guard?.risk === "safe" && !machine) return result(true, false, "safe registered setting");
90
97
  if (input.acceptedFallback && !machine) {
91
- const person = input.authority === "terminal" || input.authority === "settings" && !!guard;
98
+ const person = input.authority === "terminal" || (input.authority === "settings" || input.authority === "conductor") && !!guard;
92
99
  return result(person && input.confirmed === true, true, "restoring an unchanged effective startup value needs this key to be explicitly confirmed");
93
100
  }
94
101
  // Every other policy change is conservative: person intent never turns tracked or unknown code into host authority.
@@ -101,7 +108,7 @@ export function authorizeConfigLeaf(input: ConfigAuthorizationInput): ConfigAuth
101
108
  return result(true, true, guard ? "confirmed registered setting" : "confirmed terminal reload");
102
109
  }
103
110
  type TrustLayer = ConfigLayer | { name: "routing.toml" | "routing.local.toml"; raw: Record<string, unknown> };
104
- type Authority = "ordinary" | "settings" | "terminal";
111
+ type Authority = "ordinary" | "settings" | "terminal" | "conductor";
105
112
  type Check = (value: unknown, key: string) => void;
106
113
  const fail = (key: string, reason = "invalid value"): never => { throw new Error(`${key}: ${reason}`); };
107
114
  const object = (value: unknown, key: string): Record<string, unknown> => {
@@ -173,14 +180,15 @@ const schema: Record<string, Check> = {
173
180
  checks: (v, k) => { for (const [key, value] of Object.entries(object(v, k))) {
174
181
  if (value === undefined) continue;
175
182
  if (key === "timeout_s") timerSeconds(value, `${k}.${key}`);
176
- else if (key === "runner") words(["container"])(value, `${k}.${key}`);
183
+ else if (key === "runner") words(["container", "remote"])(value, `${k}.${key}`);
184
+ else if (key === "remote") remoteRunnerConfig(value);
177
185
  else if (/^[a-z][a-z0-9_]{0,47}$/.test(key)) text(value, `${k}.${key}`); else fail(`${k}.${key}`, "unknown key");
178
186
  } },
179
187
  snapshots: block({ enabled: bool, keep: number(1, 1_000_000, true) }), research: block({ enabled: bool }), bench: block({ enabled: bool }),
180
188
  peers: (v, k) => { map(block({ start_mode: words(["tui", "headless"]) }), START_MODE_PEERS)(v, k); peerStartConfig(v); },
181
189
  terminal: (v, k) => { block({ open: strings })(v, k); terminalTemplate(object(v, k).open); },
182
190
  limits: block({ sender_per_min: number(0), pair_per_min: number(0), important_per_hour: number(0), repeat_window_s: number(0) }),
183
- review: block({ adaptive: bool, min_reviews: number(1, Number.MAX_SAFE_INTEGER, true), pool: v => { reviewerPoolConfig({ pool: v }, DEFAULT_REVIEW_POOL, REVIEW_POOL_KINDS); }, rerotate: bool }), recovery: block({ auto_resume_after_crash: bool }),
191
+ review: block({ adaptive: bool, min_reviews: number(1, Number.MAX_SAFE_INTEGER, true), pool: v => { reviewerPoolConfig({ pool: v }, DEFAULT_REVIEW_POOL, REVIEW_POOL_KINDS); }, rerotate: bool, same_family: words(["fallback", "never"]) }), recovery: block({ auto_resume_after_crash: bool }),
184
192
  capabilities: map(strings), coordination: words(["advisory", "turn-free"]), experiments: block({ stale_notices: words(["drop", "deliver"]) }),
185
193
  };
186
194
  export const SCHEMA_KEYS: readonly string[] = Object.freeze(Object.keys(schema));
@@ -206,7 +214,7 @@ export function validateEffectiveConfig(value: unknown, catalog?: ProjectAdapter
206
214
  delete raw.ignored; delete raw.retired; delete raw.permission_default_sources;
207
215
  validateRawConfig(raw, undefined, catalog);
208
216
  const profile = resolveProfile(raw.profile, raw.profile_overrides);
209
- for (const key of Object.keys(object(raw.checks, "checks"))) if (!["timeout_s", "runner", ...CLASSES, ...Object.keys(profile.classes)].includes(key)) fail(`checks.${key}`, "unknown task class");
217
+ for (const key of Object.keys(object(raw.checks, "checks"))) if (!["timeout_s", "runner", "remote", ...CLASSES, ...Object.keys(profile.classes)].includes(key)) fail(`checks.${key}`, "unknown task class");
210
218
  for (const adapter of ADAPTERS) adapter.configuration?.validateEffective?.(raw, fail);
211
219
  }
212
220
 
@@ -231,7 +239,7 @@ export function configApply(key: string, scopes: readonly ConfigIdentityScope[]
231
239
  if (top !== "routing" && top !== "permission" && key.includes(".")) {
232
240
  const special: Record<string, readonly string[]> = {
233
241
  task_sweep: ["enabled", "auto_reassign", "no_writes_turns", "interval_s", "unaccepted_min", "idle_min", "review_min", "ready_min", "ladder_min"],
234
- checks: ["timeout_s", "runner", ...CLASSES], terminal: ["open"],
242
+ checks: ["timeout_s", "runner", "remote", ...CLASSES], terminal: ["open"],
235
243
  };
236
244
  let check = schema[top];
237
245
  for (const [index, part] of key.split(".").slice(1).entries()) {
@@ -298,6 +306,7 @@ export function assertReloadTrust(cwd: string, layers: readonly TrustLayer[], pr
298
306
  if (!guard.machine && value !== undefined && same(value, guard.floor)) continue;
299
307
  // Empty machine-local declarations choose no program/path and are stripped at startup.
300
308
  if (!inherit && guard.machine && (value === undefined || value === null || value === "" || Array.isArray(value) && !value.length)) continue;
309
+ if (authority === "conductor" && (!guard.registered || !(operationalSettingKey(key) || /^roster\.[a-z][a-z0-9-]{0,31}$/.test(key)))) fail(`${layer.name}:${key}`, "conductor changes require an operational registry setting");
301
310
  let tracked = configTracked(cwd, layer.name);
302
311
  if (!guard.machine && tracked === undefined && !configRepositoryPresent(cwd)) tracked = false;
303
312
  if (tracked !== false) fail(`${layer.name}:${key}`, tracked ? "guarded change is tracked" : "guarded source trust is unknown");
@@ -47,7 +47,7 @@ export interface ConfigEditView {
47
47
  fields: { instance: readonly string[]; tier3Instance: readonly string[] };
48
48
  }
49
49
  const REASON_BYTES = 400;
50
- const BLOCKS: readonly (readonly string[])[] = [["review", "adaptive"], ["review", "min_reviews"], ["review", "pool"], ["review", "rerotate"], ["snapshots", "keep"], ["approvals", "timeout_s"], ["context", "gate"]];
50
+ const BLOCKS: readonly (readonly string[])[] = [["review", "adaptive"], ["review", "min_reviews"], ["review", "pool"], ["review", "rerotate"], ["review", "same_family"], ["snapshots", "keep"], ["approvals", "timeout_s"], ["context", "gate"]];
51
51
 
52
52
  export const CONFIG_EDIT_OPS = ["config_edit_get", "config_edit_preview", "config_edit_apply"] as const;
53
53
 
@@ -2,7 +2,7 @@ import { createHash } from "node:crypto";
2
2
  import { chmodSync, lstatSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { configRepositoryPresent, configTracked } from "./config-trust.ts";
5
- import { configTier, CONFIG_EDIT_FILES, type ConfigEditFile } from "./config-tier.ts";
5
+ import { configTier, CONFIG_EDIT_FILES, TIER3_INSTANCE_FIELDS, type ConfigEditFile } from "./config-tier.ts";
6
6
  import { sdlcConfig } from "./sdlc.ts";
7
7
  import { VERSION } from "../version.ts";
8
8
 
@@ -202,7 +202,7 @@ export function planConfigEdit(cwd: string, rawRequest: unknown, check: Candidat
202
202
  if (tier.tier === 3) tier3Keys.push(tier.key);
203
203
  // A whole instance replaced by a tier 2 object must not drop a tier 3 field it held: that would silently erase a home or account.
204
204
  if (path[0] === "instances" && path.length === 2 && isObject(held) && isObject(value)) {
205
- const lost = Object.keys(held).find(field => !(field in value) && ["jcode_home", "provider", "model", "provider_profile"].includes(field));
205
+ const lost = Object.keys(held).find(field => !(field in value) && (TIER3_INSTANCE_FIELDS as readonly string[]).includes(field));
206
206
  if (lost) return { ok: false, error: `instances.${path[1]}.${lost}: this replacement would drop a native home or account setting; set only the fields you change (instances.${path[1]}.<field>); nothing was written`, field: `instances.${path[1]}.${lost}` };
207
207
  }
208
208
  putPath(doc, path, value);
@@ -14,12 +14,12 @@ const PEER = /^[a-z][a-z0-9-]{0,31}$/;
14
14
  const UNSAFE = new Set(["__proto__", "constructor", "prototype"]);
15
15
  /** Instance fields that choose no executable and no home. */
16
16
  export const TIER2_INSTANCE_FIELDS = ["kind", "roles", "start_mode", "review_mode", "readOnly", "worktree", "permission_mode"] as const;
17
- /** Instance fields that choose a native home, account or model: tier 3 (#685). */
18
- export const TIER3_INSTANCE_FIELDS = ["jcode_home", "provider", "model", "provider_profile"] as const;
17
+ /** Instance fields that choose a native home, account or model, or widen what a reviewer verdict may skip (`review_scope`, #852): tier 3 (#685). */
18
+ export const TIER3_INSTANCE_FIELDS = ["jcode_home", "provider", "model", "provider_profile", "review_scope"] as const;
19
19
  /** Every NEVER_EDITABLE key except `instances`, which splits by field. */
20
20
  export const TIER3_PREFIXES: readonly string[] = NEVER_EDITABLE.filter(key => key !== "instances");
21
21
  const TIER2_BLOCKS: Readonly<Record<string, readonly string[]>> = {
22
- review: ["adaptive", "min_reviews", "pool", "rerotate"],
22
+ review: ["adaptive", "min_reviews", "pool", "rerotate", "same_family"],
23
23
  snapshots: ["keep"],
24
24
  approvals: ["timeout_s"],
25
25
  context: ["gate"],