@staix/agent-hub 0.20.0 → 0.21.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 (175) hide show
  1. package/CHANGELOG.md +59 -3
  2. package/README.md +2 -2
  3. package/docs/agent-notes/README.md +11 -0
  4. package/docs/agent-notes/adapters.md +7 -3
  5. package/docs/agent-notes/benchmarks.md +27 -0
  6. package/docs/agent-notes/budget.md +2 -1
  7. package/docs/agent-notes/bus.md +2 -2
  8. package/docs/agent-notes/daemon.md +15 -6
  9. package/docs/agent-notes/local-worker.md +4 -0
  10. package/docs/agent-notes/models.md +2 -0
  11. package/docs/agent-notes/tasks.md +17 -9
  12. package/docs/agent-notes/tests.md +9 -2
  13. package/docs/bench.md +1 -1
  14. package/docs/data-policy.md +2 -0
  15. package/docs/dogfood.md +37 -4
  16. package/docs/events.md +2 -1
  17. package/docs/memory.md +9 -3
  18. package/docs/operations.md +565 -62
  19. package/docs/profiles.md +13 -1
  20. package/docs/quickstart.md +14 -9
  21. package/docs/research.md +7 -4
  22. package/docs/sdlc.md +54 -10
  23. package/docs/security.md +396 -34
  24. package/docs/smoke.md +127 -0
  25. package/docs/specs/2026-09-19-agent-hub-design.md +1070 -22
  26. package/docs/verification/2026-10-27-0.19.0.md +5 -5
  27. package/docs/verification/2026-10-28-claude-2.1.311-2.1.312-bash-permission.json +635 -0
  28. package/docs/verification/2026-10-28-claude-2.1.311-2.1.312-permission-transport.json +403 -0
  29. package/docs/verification/2026-10-28-claude-implementer-permission-triage.md +123 -0
  30. package/docs/verification/2026-10-28-jcode-0.99.1-usage.md +41 -0
  31. package/docs/verification/2026-10-28-realpath-name-audit.md +222 -0
  32. package/docs/verification/2026-10-28-routing-f0bb8ad0.md +1000 -0
  33. package/docs/verification/2026-10-29-0.21.0.md +61 -0
  34. package/docs/verification/2026-10-29-model-effort-capabilities.md +123 -0
  35. package/docs/verification/2026-10-29-modelrouting-b152ddb9.md +953 -0
  36. package/docs/verification/claude-implementer-permission-triage.ts +160 -0
  37. package/docs/verification/claude-permission-transport-native.py +71 -14
  38. package/docs/verification/profiles/YYYY-MM-DD-research.md +1 -1
  39. package/docs/verified.json +15 -15
  40. package/package.json +2 -2
  41. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  42. package/plugins/agent-hub/server.js +1016 -280
  43. package/src/adapters/acp.ts +79 -3
  44. package/src/adapters/channel-peer.ts +4 -0
  45. package/src/adapters/claude-channel.ts +13 -6
  46. package/src/adapters/claude-implementer.ts +60 -7
  47. package/src/adapters/codex-appserver.ts +77 -4
  48. package/src/adapters/conformance/fakes/jcode-agent.ts +6 -2
  49. package/src/adapters/conformance/kit.ts +3 -9
  50. package/src/adapters/definitions/builtin-metadata.ts +3 -114
  51. package/src/adapters/definitions/native-config-defaults.ts +1 -1
  52. package/src/adapters/definitions/native-config-policy.ts +3 -2
  53. package/src/adapters/definitions/native-context.ts +12 -0
  54. package/src/adapters/external-metadata.ts +48 -0
  55. package/src/adapters/external-modules.ts +63 -3
  56. package/src/adapters/jcode.ts +6 -2
  57. package/src/adapters/local-worker.ts +40 -25
  58. package/src/adapters/native-proof-ports.ts +10 -0
  59. package/src/adapters/registry-types.ts +4 -0
  60. package/src/broker/protocol.ts +203 -0
  61. package/src/broker/server.ts +314 -0
  62. package/src/broker/store.ts +548 -0
  63. package/src/cli/bench.ts +3 -2
  64. package/src/cli/broker.ts +58 -0
  65. package/src/cli/conductor-recovery.ts +142 -0
  66. package/src/cli/configured-acp-entry.ts +26 -6
  67. package/src/cli/console-state.ts +59 -17
  68. package/src/cli/console.ts +17 -2
  69. package/src/cli/facts-hook.ts +3 -1
  70. package/src/cli/help.ts +28 -16
  71. package/src/cli/identity.ts +23 -10
  72. package/src/cli/init.ts +34 -7
  73. package/src/cli/intake-output.ts +33 -0
  74. package/src/cli/main.ts +314 -71
  75. package/src/cli/output.ts +41 -1
  76. package/src/cli/recovery-package.ts +18 -1
  77. package/src/cli/role-holders.ts +35 -0
  78. package/src/cli/room.ts +304 -0
  79. package/src/cli/say-input.ts +16 -0
  80. package/src/cli/schedule-output.ts +49 -0
  81. package/src/cli/sdlc-output.ts +44 -6
  82. package/src/cli/smoke-pty-bridge.ts +33 -0
  83. package/src/cli/smoke-pty.ts +145 -0
  84. package/src/cli/smoke-run.ts +139 -0
  85. package/src/cli/smoke-target.ts +130 -0
  86. package/src/cli/source-output.ts +29 -0
  87. package/src/cli/task-output.ts +3 -2
  88. package/src/cli/upgrade-runtime.ts +98 -16
  89. package/src/cli/upgrade.ts +66 -7
  90. package/src/hub/ask.ts +76 -12
  91. package/src/hub/board.ts +132 -22
  92. package/src/hub/bus.ts +399 -56
  93. package/src/hub/child-process.ts +2 -0
  94. package/src/hub/ci-watch.ts +42 -20
  95. package/src/hub/claude-failure-stage.ts +85 -0
  96. package/src/hub/claude-shell-wrapper.ts +11 -5
  97. package/src/hub/conductor.ts +17 -4
  98. package/src/hub/config-apply.ts +32 -5
  99. package/src/hub/config-reload.ts +7 -5
  100. package/src/hub/config-trust.ts +7 -1
  101. package/src/hub/context-window.ts +33 -3
  102. package/src/hub/control-client.ts +14 -2
  103. package/src/hub/corpus-config.ts +34 -0
  104. package/src/hub/corpus-evidence.ts +184 -0
  105. package/src/hub/corpus.ts +373 -0
  106. package/src/hub/cost-governance.ts +26 -0
  107. package/src/hub/daemon.ts +1150 -171
  108. package/src/hub/deliverable-files.ts +8 -2
  109. package/src/hub/delivery-journal.ts +123 -17
  110. package/src/hub/dogfood-profile.ts +32 -5
  111. package/src/hub/dogfood-read.ts +6 -2
  112. package/src/hub/envelope.ts +11 -0
  113. package/src/hub/eval-gate.ts +131 -0
  114. package/src/hub/events.ts +1 -1
  115. package/src/hub/external-instance.ts +69 -0
  116. package/src/hub/facts.ts +15 -5
  117. package/src/hub/folder-trigger.ts +223 -0
  118. package/src/hub/hub-tools.ts +2 -1
  119. package/src/hub/inference.ts +20 -5
  120. package/src/hub/instance-config.ts +69 -3
  121. package/src/hub/instance-execution.ts +6 -2
  122. package/src/hub/instances.ts +32 -4
  123. package/src/hub/intake.ts +125 -0
  124. package/src/hub/learning.ts +85 -0
  125. package/src/hub/lifecycle.ts +7 -3
  126. package/src/hub/limits.ts +12 -5
  127. package/src/hub/native-permission-request.ts +77 -0
  128. package/src/hub/operational-authority.ts +63 -5
  129. package/src/hub/operational-settings.ts +76 -0
  130. package/src/hub/operational-view.ts +1 -1
  131. package/src/hub/packs.ts +132 -0
  132. package/src/hub/path-policy.ts +7 -0
  133. package/src/hub/peer-doing.ts +130 -0
  134. package/src/hub/peers.ts +12 -0
  135. package/src/hub/queue-backpressure.ts +58 -0
  136. package/src/hub/read-tools.ts +2 -0
  137. package/src/hub/recovery-authority.ts +151 -0
  138. package/src/hub/recovery-policy.ts +220 -0
  139. package/src/hub/research.ts +24 -14
  140. package/src/hub/restart.ts +7 -0
  141. package/src/hub/routing.ts +54 -11
  142. package/src/hub/schedules.ts +266 -0
  143. package/src/hub/sdlc-merge.ts +38 -14
  144. package/src/hub/sdlc-metrics.ts +32 -10
  145. package/src/hub/sdlc-queue.ts +7 -1
  146. package/src/hub/sdlc-release.ts +1 -1
  147. package/src/hub/sdlc.ts +37 -7
  148. package/src/hub/security-baseline.ts +38 -0
  149. package/src/hub/settings.ts +6 -1
  150. package/src/hub/shipped-packs.ts +8 -0
  151. package/src/hub/snapshots.ts +72 -6
  152. package/src/hub/source-read.ts +160 -0
  153. package/src/hub/sources.ts +311 -0
  154. package/src/hub/tasks.ts +910 -110
  155. package/src/hub/workflow.ts +1 -1
  156. package/src/local/doc-read.ts +69 -0
  157. package/src/local/proxy.ts +4 -13
  158. package/src/local/tools.ts +37 -3
  159. package/src/local/web-fetch-config.ts +43 -0
  160. package/src/local/web-fetch.ts +183 -0
  161. package/src/memory/brief.ts +85 -13
  162. package/src/memory/capture.ts +1 -1
  163. package/src/rooms/application.ts +114 -0
  164. package/src/rooms/boundary.ts +103 -0
  165. package/src/rooms/commands.ts +294 -0
  166. package/src/rooms/crypto.ts +371 -0
  167. package/src/rooms/link.ts +584 -0
  168. package/src/rooms/policy.ts +82 -0
  169. package/src/rooms/remote-peer.ts +80 -0
  170. package/src/rooms/service.ts +426 -0
  171. package/src/rooms/status.ts +146 -0
  172. package/src/rooms/store.ts +1210 -0
  173. package/src/rooms/wire.ts +465 -0
  174. package/src/ui/index.html +91 -4
  175. package/templates/packs/example.pack.json +13 -0
@@ -0,0 +1,220 @@
1
+ /** Fresh, read-only privacy and policy proof for a host-sealed conductor recovery.
2
+ * This does not reconstruct the daemon's memory-only private-turn flag: the live
3
+ * admission path must also refuse contextPii before minting a recovery authority.
4
+ */
5
+ import { Database } from "bun:sqlite";
6
+ import { createHash } from "node:crypto";
7
+ import { closeSync, constants, fstatSync, lstatSync, openSync, readSync } from "node:fs";
8
+ import { isAbsolute, join, resolve } from "node:path";
9
+ import { types } from "node:util";
10
+ import { definitionOf } from "../adapters/registry.ts";
11
+ import type { HubConfig } from "./daemon.ts";
12
+ import type { ConfigSourceTexts } from "./config-reload.ts";
13
+ import { conductorPeer } from "./conductor.ts";
14
+ import { DEFAULT_ROLES } from "./hub-tools.ts";
15
+ import { HUB_DB_BUSY_TIMEOUT_MS } from "./db-timeout.ts";
16
+ import { readInstanceConfig, type InstanceConfigLayer } from "./instance-config.ts";
17
+ import { effectiveInstanceRoles } from "./instances.ts";
18
+ import { realPath } from "./project.ts";
19
+ import { RecoveryAuthorityError } from "./recovery-authority.ts";
20
+ import { peerRosterConfig, rosterAllows } from "./roster.ts";
21
+ import { parseOverlay, parseRouting, type Routing } from "./routing.ts";
22
+
23
+ export interface RecoveryPolicyProject { root: string; id: string; stateDir: string }
24
+ export interface RecoveryPolicyCapture { policyDigest: string; boardDigest: string }
25
+ /** Host-captured inputs, shared with loadConfig before any module evaluation. */
26
+ export interface RecoveryPolicyInputs { layers: InstanceConfigLayer[]; routing: Routing; texts?: ConfigSourceTexts; routingFallback?: string }
27
+ const MAX_POLICY_BYTES = 1024 * 1024;
28
+ const MAX_ACTOR_TASKS = 4096;
29
+ const MAX_SIGNALS_BYTES = 65536;
30
+ function refuse(): never { throw new RecoveryAuthorityError(); }
31
+ const hash = (text: string): string => createHash("sha256").update(text).digest("hex");
32
+ const stamp = (s: NonNullable<ReturnType<typeof lstatSync>>): string => JSON.stringify([s.dev, s.ino, s.size, s.mode, s.nlink, s.mtimeMs, s.ctimeMs]);
33
+
34
+ /** Only bounded, regular, stable files; an absent optional policy is different from an unreadable one. */
35
+ function boundedText(file: string, optional = false): string | undefined {
36
+ let fd: number;
37
+ try { fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); }
38
+ catch (error) { if (optional && (error as NodeJS.ErrnoException).code === "ENOENT") return undefined; throw error; }
39
+ try {
40
+ const before = fstatSync(fd);
41
+ if (!before.isFile() || before.nlink !== 1 || before.size > MAX_POLICY_BYTES || realPath(file) !== file) refuse();
42
+ const bytes = Buffer.alloc(before.size + 1);
43
+ let length = 0;
44
+ while (length < bytes.length) { const n = readSync(fd, bytes, length, bytes.length - length, null); if (!n) break; length += n; }
45
+ if (length !== before.size || stamp(before) !== stamp(fstatSync(fd)) || stamp(before) !== stamp(lstatSync(file))) refuse();
46
+ return new TextDecoder("utf-8", { fatal: true }).decode(bytes.subarray(0, length));
47
+ } finally { closeSync(fd); }
48
+ }
49
+
50
+ /** Derive compatibility from the verified target bytes, never the caller's mutable feature claim. */
51
+ export function readRecoveryTargetFeatures(targetRoot: string): readonly string[] {
52
+ try {
53
+ const source = boundedText(join(targetRoot, "src/hub/control-client.ts"))!;
54
+ // ponytail: one literal array, at most 128 fixed labels. A computed export needs a versioned target manifest.
55
+ const declarations = [...source.matchAll(/export const DAEMON_FEATURES\s*=\s*(\[[^\]]*\])(?:\s+as const)?\s*;/g)];
56
+ if (declarations.length !== 1) refuse();
57
+ const values: unknown = JSON.parse(declarations[0]![1]!);
58
+ if (!Array.isArray(values) || !values.length || values.length > 128 || values.some(value => typeof value !== "string" || !/^[a-z][a-z0-9_]{0,63}$/.test(value))
59
+ || new Set(values).size !== values.length || !values.includes("conductor_recovery")) refuse();
60
+ return Object.freeze([...values]);
61
+ } catch { return refuse(); }
62
+ }
63
+
64
+ /** Stable semantic encoding; never evaluate config accessors or serialize runtime objects. */
65
+ function canonical(value: unknown, depth = 0): string {
66
+ if (depth > 32) refuse();
67
+ if (value === null || typeof value === "boolean" || typeof value === "string") return JSON.stringify(value);
68
+ if (typeof value === "number" && Number.isFinite(value)) return JSON.stringify(value);
69
+ if (types.isProxy(value)) refuse();
70
+ if (Array.isArray(value)) {
71
+ if (Object.getPrototypeOf(value) !== Array.prototype || value.length > MAX_POLICY_BYTES) refuse();
72
+ const fields = Object.getOwnPropertyDescriptors(value);
73
+ if (Reflect.ownKeys(value).length !== value.length + 1 || Object.values(fields).some(field => !Object.hasOwn(field, "value"))) refuse();
74
+ const parts: string[] = [];
75
+ for (let index = 0; index < value.length; index++) {
76
+ const field = fields[String(index)]; if (!field) refuse();
77
+ parts.push(canonical(field.value, depth + 1));
78
+ }
79
+ return `[${parts.join(",")}]`;
80
+ }
81
+ if (!value || typeof value !== "object" || ![Object.prototype, null].includes(Object.getPrototypeOf(value))) refuse();
82
+ const fields = Object.getOwnPropertyDescriptors(value);
83
+ if (Reflect.ownKeys(value).some(key => typeof key !== "string") || Object.values(fields).some(field => !Object.hasOwn(field, "value"))) refuse();
84
+ return `{${Object.keys(fields).sort().filter(key => fields[key]!.value !== undefined).map(key => `${JSON.stringify(key)}:${canonical(fields[key]!.value, depth + 1)}`).join(",")}}`;
85
+ }
86
+
87
+ function freshRoles(layers: readonly InstanceConfigLayer[], scope: ReturnType<typeof readInstanceConfig>): Record<string, string[]> {
88
+ const roles = structuredClone(DEFAULT_ROLES);
89
+ for (const { raw } of layers) if (raw.roles !== undefined) {
90
+ conductorPeer(raw.roles);
91
+ for (const [id, list] of Object.entries(raw.roles as Record<string, string[]>)) {
92
+ const definition = definitionOf(id), ceiling = definition?.routing?.allowedRoles;
93
+ roles[id] = (ceiling ? list.filter(role => ceiling.includes(role)) : list).filter(role => role !== "planner" || !definition || definition.projection?.plannerEligible === true);
94
+ }
95
+ }
96
+ const configured = Object.fromEntries(scope.catalog.definitions.filter(d => !definitionOf(d.kind)).map(d => [d.kind, [...d.roles]]));
97
+ const extras = Object.fromEntries(Object.keys(scope.instances ?? {}).map(id => [id, effectiveInstanceRoles(id, scope.instances, roles, scope.catalog)]));
98
+ return { ...configured, ...roles, ...extras };
99
+ }
100
+
101
+ function publicActorBoard(stateDir: string, peer: string, routing: Routing): string {
102
+ const file = join(stateDir, "hub.db"), before = lstatSync(file);
103
+ if (!before.isFile() || before.isSymbolicLink() || before.nlink !== 1 || realPath(file) !== file) refuse();
104
+ const db = new Database(file, { readonly: true });
105
+ db.run(`PRAGMA busy_timeout = ${HUB_DB_BUSY_TIMEOUT_MS}`);
106
+ try {
107
+ db.run("BEGIN");
108
+ // Metadata only. In particular no unrelated task titles, bodies, history or refs are read.
109
+ const rows = db.query(`SELECT id, state, owner, reviewer,
110
+ CASE WHEN typeof(signals) = 'text' AND length(CAST(signals AS BLOB)) <= ${MAX_SIGNALS_BYTES} THEN signals ELSE NULL END AS signals
111
+ FROM tasks WHERE (owner = ? OR reviewer = ?) AND (state NOT IN ('approved', 'cancelled') OR state IS NULL)
112
+ ORDER BY id LIMIT ${MAX_ACTOR_TASKS + 1}`).all(peer, peer) as { id: number; state: string; owner: string | null; reviewer: string | null; signals: string | null }[];
113
+ if (rows.length > MAX_ACTOR_TASKS) refuse();
114
+ let total = 0;
115
+ for (const row of rows) {
116
+ if (!Number.isSafeInteger(row.id) || row.id <= 0 || !["proposed", "in_progress", "in_review", "changes_requested"].includes(row.state)
117
+ || row.signals === null || (total += Buffer.byteLength(row.signals)) > MAX_POLICY_BYTES) refuse();
118
+ const signals: unknown = JSON.parse(row.signals);
119
+ if (!Array.isArray(signals) || signals.length > 1024 || signals.some(signal => typeof signal !== "string" || signal.length > 1024)) refuse();
120
+ if (signals.includes("pii_path") || signals.includes("pii") && routing.constraints.pii === "local_only") refuse();
121
+ }
122
+ db.run("COMMIT");
123
+ const after = lstatSync(file);
124
+ if (after.dev !== before.dev || after.ino !== before.ino || realPath(file) !== file) refuse();
125
+ // Unrelated public board activity must not revoke a public conductor's offline continuity.
126
+ return hash(canonical({ schema: 1, peer, private: false }));
127
+ } finally { db.close(); }
128
+ }
129
+
130
+ /** A protected pending edit is not authorization to apply it through a restart. */
131
+ export function assertRecoveryConfiguration(accepted: HubConfig, candidate: HubConfig): void {
132
+ try {
133
+ const previous = canonical(accepted), next = canonical(candidate);
134
+ if (Buffer.byteLength(previous) > 8 * MAX_POLICY_BYTES || Buffer.byteLength(next) > 8 * MAX_POLICY_BYTES || previous !== next) refuse();
135
+ } catch { return refuse(); }
136
+ }
137
+
138
+ export function assertRecoveryRouting(accepted: Routing, candidate: Routing): void {
139
+ try { if (canonical(accepted) !== canonical(candidate)) refuse(); }
140
+ catch { refuse(); }
141
+ }
142
+
143
+ /** Unknown future keys are still protected inputs; an older source may not silently authorize a pending edit. */
144
+ export function assertRecoverySourceInputs(accepted: readonly { name: string; raw: Record<string, unknown> }[], candidate: RecoveryPolicyInputs): void {
145
+ try {
146
+ const actual: { name: string; raw: Record<string, unknown> }[] = [...candidate.layers];
147
+ for (const name of ["routing.toml", "routing.local.toml"] as const) {
148
+ const text = candidate.texts?.[name];
149
+ if (text != null) {
150
+ const raw = Bun.TOML.parse(text);
151
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) refuse();
152
+ actual.push({ name, raw: raw as Record<string, unknown> });
153
+ }
154
+ }
155
+ const projection = (rows: readonly { name: string; raw: Record<string, unknown> }[]) => Object.fromEntries(rows.filter(row => Object.keys(row.raw).length > 0).map(row => [row.name, row.raw]));
156
+ if (canonical(projection(accepted)) !== canonical(projection(actual))) refuse();
157
+ } catch { refuse(); }
158
+ }
159
+
160
+ /** One bounded source capture feeds both loadConfig and the recovery proof. No module is evaluated. */
161
+ export function recoveryRoutingFallback(): string {
162
+ try { return boundedText(resolve(import.meta.dir, "..", "..", "templates", "routing.toml"))!; } catch { return refuse(); }
163
+ }
164
+
165
+ export function readRecoveryPolicyInputs(root: string, fallback?: string): RecoveryPolicyInputs {
166
+ try {
167
+ if (!isAbsolute(root) || realPath(root) !== root) refuse();
168
+ const layers: InstanceConfigLayer[] = [];
169
+ const texts: ConfigSourceTexts = { "config.json": null, "config.local.json": null, "routing.toml": null, "routing.local.toml": null };
170
+ for (const name of ["config.json", "config.local.json"] as const) {
171
+ const text = boundedText(join(root, ".agenthub", name), true);
172
+ texts[name] = text ?? null;
173
+ if (text === undefined) continue;
174
+ const raw: unknown = JSON.parse(text);
175
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) refuse();
176
+ layers.push({ name, raw: raw as Record<string, unknown> });
177
+ }
178
+ const ownMain = boundedText(join(root, ".agenthub", "routing.toml"), true);
179
+ const main = ownMain ?? fallback ?? recoveryRoutingFallback();
180
+ if (Buffer.byteLength(main) > MAX_POLICY_BYTES) refuse();
181
+ const overlay = boundedText(join(root, ".agenthub", "routing.local.toml"), true);
182
+ texts["routing.toml"] = ownMain ?? null; texts["routing.local.toml"] = overlay ?? null;
183
+ return { layers, routing: parseRouting(main, overlay === undefined ? undefined : parseOverlay(overlay)), texts, ...(ownMain === undefined ? { routingFallback: main } : {}) };
184
+ } catch { return refuse(); }
185
+ }
186
+
187
+ /** currentConfig must be host-loaded from these exact inputs and compared with the running accepted config.
188
+ * Supplied inputs are never reread from raw config/routing files. Board privacy is always fresh.
189
+ */
190
+ export function captureRecoveryPolicy(project: RecoveryPolicyProject, peer: string, currentConfig: HubConfig, inputs?: RecoveryPolicyInputs): RecoveryPolicyCapture {
191
+ try {
192
+ if (!project.id || !isAbsolute(project.root) || !isAbsolute(project.stateDir) || realPath(project.root) !== project.root || realPath(project.stateDir) !== project.stateDir) refuse();
193
+ const snapshot = canonical(inputs ?? readRecoveryPolicyInputs(project.root));
194
+ if (Buffer.byteLength(snapshot) > 8 * MAX_POLICY_BYTES) refuse();
195
+ // Copy host own-data fields once, so proof evaluation cannot run callbacks or observe mutation mid-capture.
196
+ const captured = JSON.parse(snapshot) as RecoveryPolicyInputs;
197
+ const { layers, routing } = captured;
198
+ if (!Array.isArray(layers) || layers.length > 2 || new Set(layers.map(layer => layer.name)).size !== layers.length
199
+ || layers.some(layer => !["config.json", "config.local.json"].includes(layer.name) || !layer.raw || typeof layer.raw !== "object" || Array.isArray(layer.raw))) refuse();
200
+ // Explicit empty runtime definitions: never import a module or trust a retained display catalog.
201
+ const scope = readInstanceConfig(project.root, layers, []);
202
+ if (scope.adapter_modules?.length || scope.declaredExternalInstances?.length || currentConfig.adapter_modules?.length) refuse();
203
+ const roles = freshRoles(layers, scope);
204
+ if (conductorPeer(roles) !== peer || conductorPeer(currentConfig.roles) !== peer || canonical(roles) !== canonical(currentConfig.roles)
205
+ || canonical(scope.instances ?? {}) !== canonical(currentConfig.instances ?? {}) || scope.instances?.[peer]?.readOnly === true
206
+ || !scope.catalog.definitionOf(peer, scope.instances)) refuse();
207
+ // roster is merged key by key by loadConfig; independently refresh the exact fields that admit the actor.
208
+ const rawRoster = Object.assign({}, ...layers.map(layer => layer.raw.roster ?? {}));
209
+ const roster = peerRosterConfig(rawRoster, scope.instances, scope.catalog);
210
+ if (canonical(roster) !== canonical(currentConfig.roster) || !rosterAllows(roster, peer, currentConfig.pi.enabled, scope.instances, scope.catalog)) refuse();
211
+ // ponytail: at most 2 MiB config, 2 MiB routing and 4096 actor-bound open rows (1 MiB signals).
212
+ // A larger board fails closed; the upgrade path is an indexed durable per-actor privacy generation.
213
+ // Seal version-neutral inputs and the fresh actor decision. Defaults and registry projections may evolve in the verified target.
214
+ const policy = canonical({ schema: 1, project: { root: project.root, id: project.id, stateDir: project.stateDir }, peer,
215
+ actor: { conductor: true, rostered: true, writable: true }, sources: layers, texts: captured.texts ?? null,
216
+ routingFallback: captured.routingFallback ?? null });
217
+ if (Buffer.byteLength(policy) > 8 * MAX_POLICY_BYTES) refuse();
218
+ return { policyDigest: hash(policy), boardDigest: publicActorBoard(project.stateDir, peer, routing) };
219
+ } catch { return refuse(); }
220
+ }
@@ -58,8 +58,8 @@ export interface TaskRecord {
58
58
  /** #258 AC11: task briefs handed over for it, and how many of them carried a hub-memory fail note; absent without briefs. */
59
59
  briefs?: { delivered: number; withFail: number };
60
60
  }
61
- /** A person's later verdict on one task record: `createdAt` binds it to that task, not a later one with the same id. */
62
- export interface LabelRecord { schema: typeof RESEARCH_SCHEMA; kind: "label"; project: string; task: number; createdAt: string; label: Label; at: string }
61
+ /** A person's later verdict on one task record: `createdAt` binds it to that task, not a later one with the same id. `by`: the agent that labelled its own task (#933); absent for the person. */
62
+ export interface LabelRecord { schema: typeof RESEARCH_SCHEMA; kind: "label"; project: string; task: number; createdAt: string; label: Label; at: string; by?: string }
63
63
  export type ResearchRecord = TaskRecord | LabelRecord;
64
64
 
65
65
  /** The store name for a project: a hash of its id, never its path. */
@@ -293,15 +293,26 @@ export function formatResearch(r: ResearchReport): string[] {
293
293
  return [head, row("all approved tasks", r.overall), ...section("by class", r.byClass), ...section("by owner", r.byOwner), ...section("by model", r.byModel), ...section("by project", r.byProject), ...(Object.keys(r.byMemoryBrief).some((k) => k !== "no brief recorded") ? section("by memory brief", r.byMemoryBrief) : [])];
294
294
  }
295
295
 
296
- /** Each approved task once, its last record, and every task's latest label: what both reports count. */
297
- function latestRecords(records: ResearchRecord[], since: number): { all: TaskRecord[]; labels: Map<string, Label> } {
298
- const latest = new Map<string, TaskRecord>(), labels = new Map<string, Label>();
296
+ /**
297
+ * The label each record counts with: the person's latest label, whatever came after it; an agent's (#933, `by` set) only when the person
298
+ * never labelled that record, the latest of them then. An agent's verdict on its own task never replaces the person's.
299
+ */
300
+ function effectiveLabels(records: readonly ResearchRecord[]): Map<string, LabelRecord> {
301
+ const labels = new Map<string, LabelRecord>();
299
302
  for (const r of records) {
300
- const key = recordKey(r);
301
- if (r.kind === "label") { labels.set(key, r.label); continue; }
302
- if (Date.parse(r.approvedAt) >= since) latest.set(key, r);
303
+ if (r.kind !== "label") continue;
304
+ const key = recordKey(r), held = labels.get(key);
305
+ if (r.by === undefined || held === undefined || held.by !== undefined) labels.set(key, r);
303
306
  }
304
- return { all: [...latest.values()], labels };
307
+ return labels;
308
+ }
309
+ const labelValues = (labels: Map<string, LabelRecord>): Map<string, Label> => new Map([...labels].map(([key, r]) => [key, r.label]));
310
+
311
+ /** Each approved task once, its last record, and every task's effective label: what both reports count. */
312
+ function latestRecords(records: ResearchRecord[], since: number): { all: TaskRecord[]; labels: Map<string, Label> } {
313
+ const latest = new Map<string, TaskRecord>();
314
+ for (const r of records) if (r.kind === "task" && Date.parse(r.approvedAt) >= since) latest.set(recordKey(r), r);
315
+ return { all: [...latest.values()], labels: labelValues(effectiveLabels(records)) };
305
316
  }
306
317
 
307
318
  /** The peers that worked on a task: every peer with an attributed event, else (older records) owners, reviewer and token peers. */
@@ -364,15 +375,14 @@ export function formatAddons(rows: AddonComparison[]): string[] {
364
375
 
365
376
  export const CSV_COLUMNS = ["project", "task", "class", "pii", "outcome", "createdAt", "startedAt", "approvedAt", "wallMs", "activeMs", "owners",
366
377
  "reviewer", "reviewRounds", "changesRequested", "checkPassed", "checkFailed", "dones", "reassignments", "firstPass", "stuck", "overlaps", "conflicts",
367
- "testsPass", "testsFail", "tokens", "turns", "filesChanged", "models", "label", "writerVersion", "writerSource"] as const;
368
- /** Task records as CSV with a header row, one row per record, each with the task's latest label; JSONL has every field. */
378
+ "testsPass", "testsFail", "tokens", "turns", "filesChanged", "models", "label", "labelBy", "writerVersion", "writerSource"] as const;
379
+ /** Task records as CSV with a header row, one row per record, each with its effective label and who gave it (empty: the person); JSONL has every field. */
369
380
  export function toCsv(records: ResearchRecord[]): string {
370
- const labels = new Map<string, Label>();
371
- for (const r of records) if (r.kind === "label") labels.set(recordKey(r), r.label);
381
+ const labels = effectiveLabels(records);
372
382
  const cell = (v: unknown) => { const s = v === null || v === undefined ? "" : String(v); return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s; };
373
383
  const rows = records.filter((r): r is TaskRecord => r.kind === "task").map((r) => [r.project, r.task, r.class, r.pii, r.outcome, r.createdAt, r.startedAt,
374
384
  r.approvedAt, r.wallMs, r.activeMs, r.owners.join(" "), r.reviewer, r.reviewRounds, r.changesRequested, r.checkPassed, r.checkFailed, r.dones,
375
385
  r.reassignments, r.firstPass, r.stuck, r.overlaps, r.conflicts, r.tests.pass, r.tests.fail, r.tokens.total, r.turns, r.filesChanged, r.models.join(" "),
376
- labels.get(recordKey(r)) ?? "", r.writer.version, r.writer.source].map(cell).join(","));
386
+ labels.get(recordKey(r))?.label ?? "", labels.get(recordKey(r))?.by ?? "", r.writer.version, r.writer.source].map(cell).join(","));
377
387
  return [CSV_COLUMNS.join(","), ...rows].join("\n") + "\n";
378
388
  }
@@ -7,6 +7,7 @@ import { MAX_HOP } from "./envelope.ts";
7
7
  import { validateJournalSnapshot } from "./delivery-journal.ts";
8
8
  import { atomicPrivateJSON } from "./recovery-store.ts";
9
9
  import { RecoveryDeferredHubError, validateDeferredHub } from "./deferred-hub.ts";
10
+ import { validExternalInstanceBinding, type ExternalInstanceBinding } from "./external-instance.ts";
10
11
 
11
12
  export const RESTART_SCHEMA_VERSION = 1;
12
13
 
@@ -24,6 +25,8 @@ export interface RestartPeerSnapshot {
24
25
  * cannot be resumed and a fresh session is accepted instead. Absent in snapshots written
25
26
  * before this flag; those are re-derived from disk (#64). */
26
27
  sessionPersisted?: boolean;
28
+ /** Retained stable identity for an explicitly stopped module instance; never authorizes automatic resume. */
29
+ externalInstance?: ExternalInstanceBinding;
27
30
  }
28
31
 
29
32
  export interface RestartSnapshot {
@@ -41,6 +44,8 @@ export interface RestartSnapshot {
41
44
  manualPaused: PeerId[];
42
45
  boardDigest: string;
43
46
  budgetDigest: string;
47
+ /** Whole captured binding set; portable identities are covered alongside queue/board integrity. */
48
+ externalBindingsDigest?: string;
44
49
  };
45
50
  }
46
51
 
@@ -87,7 +92,9 @@ export function readRestartSnapshot(stateDir: string, expected: RestartReadExpec
87
92
  }, occupied);
88
93
  } catch (error) { if (error instanceof RecoveryDeferredHubError) throw error; return undefined; }
89
94
  if (!Array.isArray(s.manualPaused) || !s.manualPaused.every((id) => typeof id === "string") || !Array.isArray(s.peers) || !s.peers.every((peer) => peer && typeof peer === "object" && typeof peer.id === "string" && ["idle", "busy", "paused", "offline"].includes(peer.state as string) && Array.isArray(peer.queueIds) && peer.queueIds.every((id) => typeof id === "string"))) return undefined;
95
+ if (s.peers.some(peer => Object.hasOwn(peer, "externalInstance") && (peer.state !== "offline" || !validExternalInstanceBinding(peer.externalInstance, expected.projectRoot, peer.id)))) return undefined;
90
96
  if (s.integrity !== undefined && (!s.integrity || typeof s.integrity !== "object" || typeof s.integrity.boardDigest !== "string" || typeof s.integrity.budgetDigest !== "string" || !Array.isArray(s.integrity.manualPaused) || !s.integrity.manualPaused.every((id) => typeof id === "string") || !s.integrity.queues || typeof s.integrity.queues !== "object" || !Object.values(s.integrity.queues).every((ids) => Array.isArray(ids) && ids.every((id) => typeof id === "string")))) return undefined;
97
+ if (s.integrity && Object.hasOwn(s.integrity, "externalBindingsDigest") && (typeof s.integrity.externalBindingsDigest !== "string" || !/^[a-f0-9]{64}$/.test(s.integrity.externalBindingsDigest))) return undefined;
91
98
  if (bus.journal !== undefined) { try { validateJournalSnapshot(bus.journal); } catch { return undefined; } }
92
99
  return s as RestartSnapshot;
93
100
  }
@@ -1,3 +1,5 @@
1
+ import { contextHeadroom, type ContextAdmission } from "./context-window.ts";
2
+ import { queueBackpressure, type QueueBackpressureCode, type QueueBackpressurePolicy, type QueuePressure } from "./queue-backpressure.ts";
1
3
  import { selectReviewerPool, type ReviewerPoolSelection } from "./reviewer-pool.ts";
2
4
  import { ADAPTERS, definitionOf, kindOf, familyOf, type ProjectAdapterCatalog } from "../adapters/registry.ts";
3
5
  import { readFileSync, statSync } from "node:fs";
@@ -119,6 +121,17 @@ export interface InstanceRoutingOptions {
119
121
  instances?: Readonly<Record<string, { kind: string }>>;
120
122
  wip?: Record<string, number>;
121
123
  load?: Record<string, number>;
124
+ /** Separate from WIP and sibling load: bounded pending delivery metadata for new automatic offers. */
125
+ queues?: Readonly<Record<string, QueuePressure>>;
126
+ queueBackpressure?: QueueBackpressurePolicy;
127
+ /** Host-only explicit person/conductor nomination; a name or reservation alone grants no bypass. */
128
+ queueOverride?: "owner" | "reviewer" | "both";
129
+ /** Fresh native session evidence supplied by the host; never read telemetry inside assign(). */
130
+ contextAdmission?: ContextAdmission;
131
+ /** Host-authorized explicit nomination or self-claim, never inferred from namedOwner or a reservation. */
132
+ contextOverride?: boolean;
133
+ /** Host-proven person reservation: queue pressure holds this exact target instead of rerouting it. No bypass is granted. */
134
+ queueReservedHold?: PeerId;
122
135
  /** Positive host verification of the live extra runtime's noncanonical execution root. */
123
136
  ownerIsolation?: Readonly<Record<string, boolean>>;
124
137
  }
@@ -297,7 +310,7 @@ export function detectSignals(task: Pick<Task, "title" | "detail" | "refs">, rou
297
310
  /** A peer's entry in a peer-indexed map: own keys only, so a peer named `constructor` is never an inherited Object member. */
298
311
  export const peerEntry = <T>(map: Readonly<Record<string, T>> | undefined, peer: string): T | undefined => (map && Object.hasOwn(map, peer) ? map[peer] : undefined);
299
312
 
300
- export type RoutingRefusalCode = "instance" | "family" | "tests" | "spec" | "roster" | "pull-only" | "not-attached" | "excluded" | "owner-admission" | "offline" | "paused" | "failing" | "pii" | "policy";
313
+ export type RoutingRefusalCode = QueueBackpressureCode | "context-headroom" | "instance" | "family" | "tests" | "spec" | "roster" | "pull-only" | "not-attached" | "excluded" | "owner-admission" | "offline" | "paused" | "failing" | "pii" | "policy";
301
314
  export interface RoutingRefusal { peer: PeerId; role: "owner" | "reviewer"; code: RoutingRefusalCode; reason: string }
302
315
  export interface Assignment {
303
316
  reviewSelection?: ReviewerPoolSelection;
@@ -333,6 +346,9 @@ export function ownerRoleOfClass(cls: TaskClass): "planner" | "implementer" | "r
333
346
  /** Why a planner was chosen: the person named it, rotation reached it, the task's recorded planner is kept, or there is none (PII, no holder). */
334
347
  export type PlannerReason = "rotation" | "named" | "kept" | "none";
335
348
 
349
+ /** The remote namespace has no local task, reviewer or planner authority, even for malformed remote ids. */
350
+ export function isRemotePeerId(peer: unknown): boolean { return typeof peer === "string" && peer.includes("@"); }
351
+
336
352
  /**
337
353
  * I-2 (#525-3): the peers that implemented a task, read from its own history: any peer with an `accepted` or `done` entry. A reviewer is
338
354
  * never one of them, so a multi-role holder (implementer and reviewer) never reviews a change it made, even after the task changed hands.
@@ -367,10 +383,11 @@ export interface PlannerWalk { planner?: PeerId; reason: PlannerReason; walk: {
367
383
  * holder, availability never renumbers it (the #519 seed rule). When all are skipped, the seed holder is taken, "rotation (none available)".
368
384
  */
369
385
  export function plannerWalk(input: PlannerWalkInput): PlannerWalk {
370
- const holders = [...new Set(input.planners)].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
386
+ const holders = [...new Set(input.planners)].filter(peer => !isRemotePeerId(peer)).sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
371
387
  const walk: { peer: PeerId; note: string }[] = [];
372
388
  const finish = (reason: PlannerReason, planner: PeerId | undefined, detail: string): PlannerWalk => ({ ...(planner ? { planner } : {}), reason, walk, line: planner ? `planner: ${planner} (${detail})` : `planner: none (${detail})` });
373
389
  if (input.pii) return finish("none", undefined, "pii: planning stays with the person");
390
+ if (isRemotePeerId(input.named)) return finish("none", undefined, "remote peers cannot hold local roles");
374
391
  if (!holders.length) return finish("none", undefined, "no planner holder");
375
392
  if (input.named !== undefined) {
376
393
  if (!holders.includes(input.named)) { walk.push({ peer: input.named, note: "skipped, not a planner holder" }); return finish("none", undefined, `named ${input.named} is not a planner holder`); }
@@ -463,6 +480,7 @@ export function assign(
463
480
  const former = implementers(task.history);
464
481
  const blocked = (peer: PeerId, role: "owner" | "reviewer"): string | undefined => {
465
482
  const deny = (code: RoutingRefusalCode, reason: string): string => { refusals.set(`${role}:${peer}`, { peer, role, code, reason }); return reason; };
483
+ if (isRemotePeerId(peer)) return deny("policy", "remote peers cannot hold local roles");
466
484
  const ceiling = (opts.adapterCatalog?.definitionOf ?? definitionOf)(peer, opts.instances)?.routing?.allowedRoles;
467
485
  const requiredRole = role === "reviewer" ? "reviewer" : ownerRoleOfClass(task.class);
468
486
  if (ceiling && !ceiling.includes(requiredRole)) return deny("policy", "adapter role ceiling excludes this assignment");
@@ -498,10 +516,20 @@ export function assign(
498
516
  if (routing.signals.long_context_tokens > limit) return deny("policy", `${nativeRouting.backendLabel} ${backend} capability limit ${limit} tokens`);
499
517
  }
500
518
  if (nativeRouting?.longContext === "skip-owner" && role === "owner" && task.signals.includes("long_context") && routing.constraints.long_context === "skip_local") return deny("policy", "long_context: skip local");
501
- // I-2 (#525-3): a peer that implemented this task never reviews it, whoever holds it now, a named reviewer included. It is the LAST check,
502
- // so it only ever removes a candidate that was otherwise eligible: a former implementer that is offline, paused, failing or off roster is
503
- // refused for that real reason and leaves the outcome exactly as it was before I-2 (a task with no other reviewer still approves directly).
519
+ // I-2 (#525-3) is the last capability/independence gate, before queue-capacity admission. A former implementer never reviews its task,
520
+ // including a named reviewer. Earlier offline, paused, failing and roster refusals retain their existing reason and outcome.
504
521
  if (role === "reviewer" && former.has(peer)) return deny("policy", "implemented this task");
522
+ if (role === "owner" && requiredRole !== "reviewer" && opts.contextAdmission) {
523
+ const decision = contextHeadroom(opts.contextAdmission.policy, adapter?.budget?.context ? peerEntry(opts.contextAdmission.peers, peer) : undefined, opts.now ?? NaN);
524
+ if (decision) {
525
+ trace.push(` owner context ${peer}: ${decision.note}${opts.contextOverride ? "; explicit override" : ""}`);
526
+ if (decision.blocked && !opts.contextOverride) return deny("context-headroom", decision.note);
527
+ }
528
+ }
529
+ if (opts.queueOverride !== role && opts.queueOverride !== "both") {
530
+ const pressure = queueBackpressure(peer, opts.queueBackpressure, peerEntry(opts.queues, peer), opts.now ?? NaN);
531
+ if (pressure) return deny(pressure.code, pressure.reason);
532
+ }
505
533
  return undefined;
506
534
  };
507
535
  /** Quota that resets soonest gets used first: headroom per hour left in the window, at least 15 min (a task assigned that close to a reset mostly runs after it). */
@@ -556,12 +584,15 @@ export function assign(
556
584
  return ranked.find((p) => states[p] === "idle") ?? ranked[0];
557
585
  };
558
586
 
559
- // The PII constraint, capability limits, exclusions and peer states still apply: when they pass it over, routing proceeds.
587
+ // Safety/state refusals still permit the existing reservation fallback. Queue pressure holds a host-proven person reservation instead.
560
588
  const unreserved = reserved ? blocked(reserved, "owner") : undefined;
561
- if (reserved) trace.push(`reserved owner ${reserved}: ${unreserved ? `not honored, ${unreserved}; routing proceeds` : "honored"}`);
589
+ const reservedRefusal = reserved ? refusals.get(`owner:${reserved}`) : undefined;
590
+ const queueReservedHeld = reserved === opts.queueReservedHold && reservedRefusal !== undefined
591
+ && ["queue-count", "queue-age", "queue-age-unknown"].includes(reservedRefusal.code);
592
+ if (reserved) trace.push(`reserved owner ${reserved}: ${queueReservedHeld ? `held, ${unreserved}; reservation retained` : unreserved ? `not honored, ${unreserved}; routing proceeds` : "honored"}`);
562
593
  // Never the task's current owner by default: a decline or an escalation has to reach the next peer in the list.
563
594
  const automaticOwner = !(reserved && !unreserved) && opts.candidates === undefined && !(instanceMode && opts.namedOwner !== undefined);
564
- const wanted = reserved && !unreserved ? [reserved] : opts.candidates ?? (instanceMode && opts.namedOwner !== undefined ? [opts.namedOwner] : expandClassPeers(policy?.peers ?? [], states, opts));
595
+ const wanted = queueReservedHeld ? [] : reserved && !unreserved ? [reserved] : opts.candidates ?? (instanceMode && opts.namedOwner !== undefined ? [opts.namedOwner] : expandClassPeers(policy?.peers ?? [], states, opts));
565
596
  const owner = pick(wanted, "owner", undefined, automaticOwner);
566
597
  trace.push(owner ? `owner: ${owner}` : "owner: none available, task stays proposed (ahub task assign <id> <peer>)");
567
598
  const ownerHold = owner ? peerEntry(opts.held, owner) : undefined;
@@ -680,6 +711,12 @@ export function assign(
680
711
  reviewBlocked = true;
681
712
  trace.push(" every eligible reviewer implemented this task: the person reviews");
682
713
  }
714
+ // Queue pressure holds a new review offer; it must never turn review exhaustion into direct approval.
715
+ if (!reviewer && [...refusals.values()].some(row => row.role === "reviewer" && (row.code === "queue-count" || row.code === "queue-age" || row.code === "queue-age-unknown"))) {
716
+ reviewer = "user";
717
+ reviewBlocked = true;
718
+ trace.push(" reviewer queue backpressure: automatic review held; the person may override");
719
+ }
683
720
  trace.push(reviewer ? `reviewer: ${reviewer}` : "reviewer: none, done will approve directly");
684
721
  const reviewerHold = reviewer ? peerEntry(opts.held, reviewer) : undefined;
685
722
  if (reviewerHold) trace.push(` hold: ${reviewer}'s queue is held: ${reviewerHold} (it receives the review once the hold is resolved)`);
@@ -719,6 +756,10 @@ export interface SplitInput {
719
756
  /** Other open work each peer has: a busy owner does not start from orientation plus two whole units. */
720
757
  backlog: Record<PeerId, number>;
721
758
  available: Record<PeerId, boolean>;
759
+ /** #489 H2: `tasks.split_min_observations`; absent is `SPLIT_MIN`. */
760
+ minObservations?: number;
761
+ /** #489 H2: `tasks.split_max_failure_share`; absent is `SPLIT_FAILURE_SHARE`. */
762
+ maxFailureShare?: number;
722
763
  }
723
764
 
724
765
  export interface SplitPrediction {
@@ -733,7 +774,7 @@ export interface SplitPrediction {
733
774
 
734
775
  /** Observations a peer needs, and the share of failures a history may hold, before a prediction is made. */
735
776
  export const SPLIT_MIN = 5;
736
- const SPLIT_FAILURE_SHARE = 0.3;
777
+ export const SPLIT_FAILURE_SHARE = 0.3;
737
778
 
738
779
  /**
739
780
  * Shadow split prediction (issue #109): never changes assignment. For two similar units of overlapping work, a split
@@ -744,6 +785,8 @@ const SPLIT_FAILURE_SHARE = 0.3;
744
785
  * comparable. A difference under a tenth of the single time is inconclusive.
745
786
  */
746
787
  export function predictSplit(input: SplitInput): SplitPrediction {
788
+ // #489 H2: `tasks.split_min_observations` and `tasks.split_max_failure_share`; absent, the constants above.
789
+ const minObservations = input.minObservations ?? SPLIT_MIN, maxFailureShare = input.maxFailureShare ?? SPLIT_FAILURE_SHARE;
747
790
  const trace: string[] = ["shadow split prediction (it never changes assignment):"];
748
791
  const unknown = (why: string): SplitPrediction => ({ verdict: "unknown", trace: [...trace, ` unknown: ${why}`] });
749
792
  const [a, b] = input.peers;
@@ -767,8 +810,8 @@ export function predictSplit(input: SplitInput): SplitPrediction {
767
810
  const all = input.observations[p] ?? [];
768
811
  const failed = all.filter((o) => o.outcome !== "approved").length;
769
812
  const ok = all.filter((o): o is Required<SplitObservation> => o.outcome === "approved" && o.orient !== undefined && o.work !== undefined);
770
- if (ok.length < SPLIT_MIN) return unknown(`${p} has ${ok.length} measured task(s) with this profile; ${SPLIT_MIN} are needed`);
771
- if (failed / all.length > SPLIT_FAILURE_SHARE) return unknown(`${failed} of ${all.length} of ${p}'s recorded tasks failed: its successes alone would understate its time`);
813
+ if (ok.length < minObservations) return unknown(`${p} has ${ok.length} measured task(s) with this profile; ${minObservations} are needed`);
814
+ if (failed / all.length > maxFailureShare) return unknown(`${failed} of ${all.length} of ${p}'s recorded tasks failed: its successes alone would understate its time`);
772
815
  const works = ok.map((o) => o.work).sort((x, y) => x - y);
773
816
  const u = median(works);
774
817
  const iqr = works[Math.floor((works.length * 3) / 4)]! - works[Math.floor(works.length / 4)]!;