@staix/agent-hub 0.24.0 → 0.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/README.md +35 -8
  3. package/docs/agent-notes/README.md +12 -0
  4. package/docs/agent-notes/adapters.md +2 -1
  5. package/docs/agent-notes/bus.md +1 -0
  6. package/docs/agent-notes/daemon.md +18 -3
  7. package/docs/agent-notes/tasks.md +12 -4
  8. package/docs/agent-notes/tests.md +3 -1
  9. package/docs/dogfood.md +16 -2
  10. package/docs/memory.md +1 -1
  11. package/docs/operations.md +264 -14
  12. package/docs/profiles.md +2 -0
  13. package/docs/quickstart.md +23 -6
  14. package/docs/security.md +198 -3
  15. package/docs/specs/2026-09-19-agent-hub-design.md +25 -1
  16. package/docs/specs/2026-09-19-multi-project-design.md +12 -1
  17. package/docs/specs/2026-11-01-research-campaigns-design.md +354 -0
  18. package/docs/verification/2026-11-01-0.25.0.md +91 -0
  19. package/docs/verification/2026-11-01-0.26.0.md +54 -0
  20. package/docs/verified.json +45 -17
  21. package/package.json +4 -3
  22. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  23. package/plugins/agent-hub/server.js +5294 -4797
  24. package/src/adapters/channel-peer.ts +4 -2
  25. package/src/adapters/claude-channel.ts +29 -6
  26. package/src/adapters/definitions/builtin-metadata.ts +6 -6
  27. package/src/adapters/definitions/native-cli-doctor.ts +30 -0
  28. package/src/adapters/definitions/qwen.ts +2 -0
  29. package/src/adapters/jcode.ts +3 -0
  30. package/src/adapters/registry-types.ts +2 -0
  31. package/src/adapters/registry.ts +2 -1
  32. package/src/cli/acon-register.ts +29 -0
  33. package/src/cli/conductor-recovery.ts +3 -2
  34. package/src/cli/handoff-args.ts +27 -0
  35. package/src/cli/handoff-output.ts +25 -0
  36. package/src/cli/help.ts +11 -8
  37. package/src/cli/identity.ts +7 -1
  38. package/src/cli/main.ts +138 -30
  39. package/src/cli/output.ts +8 -5
  40. package/src/cli/recovery-package.ts +94 -4
  41. package/src/cli/reset.ts +14 -4
  42. package/src/cli/runtime-version-warning.ts +28 -0
  43. package/src/cli/schedule-output.ts +8 -4
  44. package/src/cli/start-agents-run.ts +125 -0
  45. package/src/cli/study-output.ts +71 -0
  46. package/src/cli/study.ts +58 -0
  47. package/src/cli/task-output.ts +6 -0
  48. package/src/cli/upgrade-interactive.ts +4 -4
  49. package/src/cli/upgrade-runtime.ts +105 -57
  50. package/src/cli/upgrade.ts +90 -28
  51. package/src/hub/autonomy-defaults.ts +32 -7
  52. package/src/hub/board.ts +993 -8
  53. package/src/hub/budget.ts +3 -4
  54. package/src/hub/channel-project.ts +61 -0
  55. package/src/hub/child-process.ts +4 -3
  56. package/src/hub/conductor.ts +3 -4
  57. package/src/hub/config-apply.ts +13 -2
  58. package/src/hub/config-edit-host.ts +1 -0
  59. package/src/hub/config-tier.ts +3 -0
  60. package/src/hub/config-trust.ts +10 -2
  61. package/src/hub/control-client.ts +39 -10
  62. package/src/hub/daemon.ts +326 -25
  63. package/src/hub/db-schema.ts +143 -0
  64. package/src/hub/delivery-journal.ts +3 -4
  65. package/src/hub/envelope.ts +2 -0
  66. package/src/hub/events.ts +4 -1
  67. package/src/hub/execution-budget.ts +15 -4
  68. package/src/hub/folder-trigger.ts +3 -4
  69. package/src/hub/handoff.ts +126 -0
  70. package/src/hub/hub-tools.ts +12 -2
  71. package/src/hub/invocations.ts +3 -4
  72. package/src/hub/lifecycle.ts +87 -29
  73. package/src/hub/manager.ts +110 -87
  74. package/src/hub/no-writes.ts +1 -1
  75. package/src/hub/orca-env.ts +19 -0
  76. package/src/hub/path-policy.ts +12 -8
  77. package/src/hub/project-runtime-state.ts +139 -0
  78. package/src/hub/project-runtime.ts +62 -0
  79. package/src/hub/project.ts +14 -5
  80. package/src/hub/protocol-window.ts +19 -0
  81. package/src/hub/provenance.ts +30 -0
  82. package/src/hub/recovery-store.ts +102 -12
  83. package/src/hub/registry.ts +5 -0
  84. package/src/hub/research-campaign.ts +265 -0
  85. package/src/hub/research-experiments.ts +474 -0
  86. package/src/hub/research-runner.ts +172 -0
  87. package/src/hub/restart.ts +35 -0
  88. package/src/hub/runtime-capture-prune.ts +415 -0
  89. package/src/hub/runtime-prune-health.ts +112 -0
  90. package/src/hub/schedules.ts +213 -13
  91. package/src/hub/sdlc-merge.ts +7 -0
  92. package/src/hub/sdlc-queue.ts +13 -1
  93. package/src/hub/session-identity.ts +49 -0
  94. package/src/hub/settings.ts +4 -0
  95. package/src/hub/snapshots.ts +3 -4
  96. package/src/hub/start-agents.ts +44 -0
  97. package/src/hub/task-approval.ts +23 -0
  98. package/src/hub/tasks.ts +1005 -34
  99. package/src/hub/ui.ts +15 -6
  100. package/src/hub/usage-rollup.ts +3 -4
  101. package/src/memory/capture.ts +2 -2
  102. package/src/pi/tool-receipts.ts +3 -4
  103. package/src/ui/index.html +5 -3
  104. package/src/ui/office-space.ts +118 -0
  105. package/src/ui/plugin.ts +293 -0
  106. package/templates/AGENTS.block.md +1 -0
  107. package/templates/profiles/general.json +1 -1
  108. package/templates/profiles/research.json +4 -4
  109. package/templates/profiles/writing.json +1 -1
  110. package/templates/roles/research/analyst.md +1 -0
  111. package/templates/roles/research/fact-checker.md +1 -0
  112. package/templates/roles/research/research-lead.md +1 -0
  113. package/templates/roles/research/researcher.md +1 -0
  114. package/templates/roles/research/statistician.md +1 -0
@@ -0,0 +1,172 @@
1
+ import { createPublicKey, verify } from "node:crypto";
2
+ import { types } from "node:util";
3
+ import { canonicalJson } from "./packs.ts";
4
+
5
+ export interface RegisteredRunnerV1 { publicKey: string; projects: string[]; revoked: boolean }
6
+ export interface RunnerContainer { id: string; role: "candidate" | "evaluator" | "other" }
7
+ export interface RunnerMount { container: string; path: string; readOnly: boolean; purpose: "evaluator-code" | "evaluator-data" | "candidate-output" | "other" }
8
+ export interface RunnerOutput { name: string; sha256: string }
9
+ export interface AttemptReceiptV1 {
10
+ receiptId: string; runner: string; projectId: string; attemptId: string; protocolHash: string;
11
+ codeCommit?: string; imageDigest?: string; containers?: RunnerContainer[]; mounts?: RunnerMount[];
12
+ network?: "none" | "restricted" | "open";
13
+ resourceLimits?: { name: string; value: number; unit: string }[];
14
+ startedAt?: number; endedAt?: number; exitStatus?: number; outputs?: RunnerOutput[]; metricsFile?: RunnerOutput;
15
+ }
16
+ export interface RunnerVerificationContext {
17
+ runners: unknown;
18
+ projectId: string;
19
+ attempt: { id: string; projectId: string; protocolHash: string; codeCommit?: string } | undefined;
20
+ }
21
+
22
+ const ID = /^[a-z0-9][a-z0-9_-]{0,63}$/, HASH = /^[0-9a-f]{64}$/, ATTEMPT = /^ra-[0-9a-f]{12}$/;
23
+ const BODY = 64 * 1024, SHORT = 512;
24
+ /** Exactly the receipt domain and its NUL separator; no executable runner protocol. */
25
+ export const RUNNER_RECEIPT_DOMAIN = "agent-hub.runner-receipt/v1" + String.fromCharCode(0);
26
+ function fail(field: string, reason: string): never { throw new Error(`${field}: ${reason}`); }
27
+ function object(input: unknown, keys: readonly string[] | undefined, field: string): Record<string, unknown> {
28
+ if (!input || typeof input !== "object" || Array.isArray(input) || types.isProxy(input)
29
+ || ![Object.prototype, null].includes(Object.getPrototypeOf(input)) || Object.getOwnPropertySymbols(input).length)
30
+ fail(field, "expected a plain data object");
31
+ const descriptors = Object.getOwnPropertyDescriptors(input);
32
+ if (Object.values(descriptors).some(value => !Object.hasOwn(value, "value"))) fail(field, "accessors are unavailable");
33
+ if (keys && Object.keys(descriptors).some(key => !keys.includes(key))) fail(field, "unknown field");
34
+ if (Object.values(descriptors).some(value => value.value === undefined)) fail(field, "omit absent fields");
35
+ return Object.fromEntries(Object.entries(descriptors).map(([key, value]) => [key, value.value]));
36
+ }
37
+ function array(input: unknown, max: number, field: string): unknown[] {
38
+ if (!Array.isArray(input) || types.isProxy(input) || Object.getPrototypeOf(input) !== Array.prototype
39
+ || input.length > max || Object.getOwnPropertySymbols(input).length) fail(field, `expected a list of at most ${max} items`);
40
+ const descriptors = Object.getOwnPropertyDescriptors(input);
41
+ if (Object.keys(descriptors).some(key => key !== "length" && !/^(0|[1-9][0-9]*)$/.test(key))) fail(field, "unknown list fields");
42
+ return Array.from({ length: input.length }, (_, index) => {
43
+ const value = descriptors[String(index)];
44
+ if (!value || !Object.hasOwn(value, "value")) fail(field, "expected a dense data list");
45
+ return value.value;
46
+ });
47
+ }
48
+ function text(input: unknown, field: string): string {
49
+ if (typeof input !== "string" || !input.trim() || input.length > SHORT || Buffer.byteLength(input, "utf8") > SHORT
50
+ || Buffer.from(input, "utf8").toString("utf8") !== input) fail(field, "expected non-empty UTF-8 text of at most 512 bytes");
51
+ return input;
52
+ }
53
+ function pattern(input: unknown, rule: RegExp, field: string): string {
54
+ if (typeof input !== "string" || rule.exec(input)?.[0] !== input) fail(field, "invalid identifier or hash");
55
+ return input;
56
+ }
57
+ function integer(input: unknown, field: string, min = 0): number {
58
+ if (typeof input !== "number" || !Number.isSafeInteger(input) || input < min) fail(field, "expected a bounded integer");
59
+ return input;
60
+ }
61
+ function choice<T extends string>(input: unknown, values: readonly T[], field: string): T {
62
+ if (typeof input !== "string" || !values.includes(input as T)) fail(field, "unsupported value");
63
+ return input as T;
64
+ }
65
+ function bounded<T>(value: T, field: string): T {
66
+ if (Buffer.byteLength(canonicalJson(value), "utf8") > BODY) fail(field, "stored body exceeds 64 KiB");
67
+ return value;
68
+ }
69
+ function base64(input: unknown, bytes: number, field: string): Buffer {
70
+ if (typeof input !== "string" || input.length !== 4 * Math.ceil(bytes / 3) || !/^[A-Za-z0-9+/]+={0,2}$/.test(input))
71
+ fail(field, "invalid canonical base64");
72
+ const decoded = Buffer.from(input, "base64");
73
+ if (decoded.length !== bytes || decoded.toString("base64") !== input) fail(field, "invalid canonical base64");
74
+ return decoded;
75
+ }
76
+ function publicKey(input: unknown, field: string): string {
77
+ const bytes = base64(input, 44, field);
78
+ try {
79
+ const key = createPublicKey({ key: bytes, format: "der", type: "spki" });
80
+ if (key.asymmetricKeyType !== "ed25519" || !key.export({ format: "der", type: "spki" }).equals(bytes))
81
+ fail(field, "expected an Ed25519 SPKI public key");
82
+ } catch { fail(field, "expected an Ed25519 SPKI public key"); }
83
+ return input as string;
84
+ }
85
+
86
+ /** Data validation only. Person-only registration and machine-local placement belong to the host. */
87
+ export function parseResearchRunners(input: unknown): Record<string, RegisteredRunnerV1> {
88
+ const raw = object(input, undefined, "research.runners"), entries = Object.entries(raw);
89
+ if (entries.length > 16) fail("research.runners", "at most 16 runners");
90
+ return bounded(Object.fromEntries(entries.map(([name, value]) => {
91
+ const field = `research.runners.${pattern(name, ID, "research.runners.name")}`, row = object(value, ["publicKey", "projects", "revoked"], field);
92
+ const projects = array(row.projects, 64, `${field}.projects`).map(value => text(value, `${field}.projects`));
93
+ if (new Set(projects).size !== projects.length) fail(`${field}.projects`, "duplicate projects");
94
+ if (typeof row.revoked !== "boolean") fail(`${field}.revoked`, "expected a boolean");
95
+ return [name, { publicKey: publicKey(row.publicKey, `${field}.publicKey`), projects, revoked: row.revoked }];
96
+ })), "research.runners");
97
+ }
98
+
99
+ function output(input: unknown, field: string): RunnerOutput {
100
+ const row = object(input, ["name", "sha256"], field);
101
+ return { name: text(row.name, `${field}.name`), sha256: pattern(row.sha256, HASH, `${field}.sha256`) };
102
+ }
103
+
104
+ /** Absent observations stay absent. Isolation inconsistencies are signed evidence, not schema failures. */
105
+ export function parseAttemptReceipt(input: unknown): AttemptReceiptV1 {
106
+ const raw = object(input, ["receiptId", "runner", "projectId", "attemptId", "protocolHash", "codeCommit", "imageDigest", "containers", "mounts", "network", "resourceLimits", "startedAt", "endedAt", "exitStatus", "outputs", "metricsFile"], "receipt");
107
+ const receipt: AttemptReceiptV1 = { receiptId: pattern(raw.receiptId, ID, "receipt.receiptId"), runner: pattern(raw.runner, ID, "receipt.runner"),
108
+ projectId: text(raw.projectId, "receipt.projectId"), attemptId: pattern(raw.attemptId, ATTEMPT, "receipt.attemptId"), protocolHash: pattern(raw.protocolHash, HASH, "receipt.protocolHash") };
109
+ if (raw.codeCommit !== undefined) receipt.codeCommit = pattern(raw.codeCommit, /^[0-9a-f]{40}$/, "receipt.codeCommit");
110
+ if (raw.imageDigest !== undefined) receipt.imageDigest = pattern(raw.imageDigest, /^sha256:[0-9a-f]{64}$/, "receipt.imageDigest");
111
+ if (raw.containers !== undefined) receipt.containers = array(raw.containers, 64, "receipt.containers").map((value, index) => {
112
+ const field = `receipt.containers[${index}]`, row = object(value, ["id", "role"], field);
113
+ return { id: pattern(row.id, ID, `${field}.id`), role: choice(row.role, ["candidate", "evaluator", "other"], `${field}.role`) };
114
+ });
115
+ if (raw.mounts !== undefined) receipt.mounts = array(raw.mounts, 256, "receipt.mounts").map((value, index) => {
116
+ const field = `receipt.mounts[${index}]`, row = object(value, ["container", "path", "readOnly", "purpose"], field);
117
+ if (typeof row.readOnly !== "boolean") fail(`${field}.readOnly`, "expected a boolean");
118
+ return { container: pattern(row.container, ID, `${field}.container`), path: text(row.path, `${field}.path`), readOnly: row.readOnly,
119
+ purpose: choice(row.purpose, ["evaluator-code", "evaluator-data", "candidate-output", "other"], `${field}.purpose`) };
120
+ });
121
+ if (raw.network !== undefined) receipt.network = choice<NonNullable<AttemptReceiptV1["network"]>>(raw.network, ["none", "restricted", "open"], "receipt.network");
122
+ if (raw.resourceLimits !== undefined) receipt.resourceLimits = array(raw.resourceLimits, 16, "receipt.resourceLimits").map((value, index) => {
123
+ const field = `receipt.resourceLimits[${index}]`, row = object(value, ["name", "value", "unit"], field);
124
+ if (typeof row.value !== "number" || !Number.isFinite(row.value) || row.value < 0) fail(`${field}.value`, "expected a nonnegative finite number");
125
+ return { name: text(row.name, `${field}.name`), value: row.value, unit: text(row.unit, `${field}.unit`) };
126
+ });
127
+ if (raw.startedAt !== undefined) receipt.startedAt = integer(raw.startedAt, "receipt.startedAt");
128
+ if (raw.endedAt !== undefined) receipt.endedAt = integer(raw.endedAt, "receipt.endedAt");
129
+ if (receipt.startedAt !== undefined && receipt.endedAt !== undefined && receipt.endedAt < receipt.startedAt) fail("receipt.endedAt", "precedes startedAt");
130
+ if (raw.exitStatus !== undefined) receipt.exitStatus = integer(raw.exitStatus, "receipt.exitStatus", Number.MIN_SAFE_INTEGER);
131
+ if (raw.outputs !== undefined) receipt.outputs = array(raw.outputs, 64, "receipt.outputs").map((value, index) => output(value, `receipt.outputs[${index}]`));
132
+ if (raw.metricsFile !== undefined) receipt.metricsFile = output(raw.metricsFile, "receipt.metricsFile");
133
+ return bounded(receipt, "receipt");
134
+ }
135
+
136
+ /** Predicate on evidence only; callers must verify its signature before treating sealing as attested. */
137
+ export function runnerSealing(receipt: AttemptReceiptV1): boolean {
138
+ const parsed = parseAttemptReceipt(receipt), containers = parsed.containers ?? [], mounts = parsed.mounts ?? [];
139
+ const roles = new Map(containers.map(container => [container.id, container.role]));
140
+ if (roles.size !== containers.length || !containers.some(container => container.role === "candidate")
141
+ || !containers.some(container => container.role === "evaluator") || !mounts.length) return false;
142
+ let evaluatorEvidence = false;
143
+ for (const mount of mounts) {
144
+ const role = roles.get(mount.container);
145
+ if (!role) return false;
146
+ if (mount.purpose === "evaluator-code" || mount.purpose === "evaluator-data") {
147
+ if (role !== "evaluator" || !mount.readOnly) return false;
148
+ evaluatorEvidence = true;
149
+ }
150
+ }
151
+ return evaluatorEvidence;
152
+ }
153
+
154
+ /** Pure verification against the current host-supplied registration and attempt identity. No host-observed or scientific claim. */
155
+ export function verifyAttemptReceipt(input: unknown, signature: unknown, context: RunnerVerificationContext): {
156
+ receipt: AttemptReceiptV1; provenance: "runner-attested"; sealed: boolean;
157
+ } {
158
+ const receipt = parseAttemptReceipt(input), runners = parseResearchRunners(context.runners);
159
+ if (!Object.hasOwn(runners, receipt.runner)) fail("receipt.runner", "unknown runner");
160
+ const runner = runners[receipt.runner]!;
161
+ if (runner.revoked) fail("receipt.runner", "revoked runner");
162
+ if (receipt.projectId !== context.projectId || !runner.projects.includes(receipt.projectId)) fail("receipt.projectId", "project is not allowed");
163
+ const attempt = context.attempt;
164
+ if (!attempt || attempt.id !== receipt.attemptId || attempt.projectId !== context.projectId || attempt.protocolHash !== receipt.protocolHash)
165
+ fail("receipt.attemptId", "attempt or protocol identity mismatch");
166
+ if (receipt.codeCommit !== undefined && attempt.codeCommit !== undefined && receipt.codeCommit !== attempt.codeCommit)
167
+ fail("receipt.codeCommit", "protocol commit mismatch");
168
+ const bytes = base64(signature, 64, "receipt.signature");
169
+ const key = createPublicKey({ key: Buffer.from(runner.publicKey, "base64"), format: "der", type: "spki" });
170
+ if (!verify(null, Buffer.from(RUNNER_RECEIPT_DOMAIN + canonicalJson(receipt), "utf8"), key, bytes)) fail("receipt.signature", "invalid signature");
171
+ return { receipt, provenance: "runner-attested", sealed: runnerSealing(receipt) };
172
+ }
@@ -16,6 +16,8 @@ export type RecoveryPhase = "preparing" | "prepared" | "restored" | "released";
16
16
  export interface RestartPeerSnapshot {
17
17
  id: PeerId;
18
18
  state: PeerState;
19
+ /** Attached channel's selected wire, not the daemon descriptor's compatibility floor. */
20
+ controlProtocol?: number;
19
21
  queueIds: string[];
20
22
  launch?: Record<string, unknown>;
21
23
  /** Safe native correlation metadata. Never include prompt/message text here. */
@@ -46,6 +48,12 @@ export interface RestartSnapshot {
46
48
  budgetDigest: string;
47
49
  /** Whole captured binding set; portable identities are covered alongside queue/board integrity. */
48
50
  externalBindingsDigest?: string;
51
+ /** Research state remains part of controlled-recovery integrity when present. */
52
+ research?: { campaigns: number; units: number; revision: number; digest: string };
53
+ researchDispatch?: { activations: number; handoffs: number; digest: string };
54
+ researchExperiments?: { protocols: number; attempts: number; events: number; observations: number; digest: string };
55
+ researchComparisons?: { plans: number; approvals: number; contexts: number; digest: string };
56
+ researchRunners?: { count: number; digest: string };
49
57
  };
50
58
  }
51
59
 
@@ -93,8 +101,35 @@ export function readRestartSnapshot(stateDir: string, expected: RestartReadExpec
93
101
  } catch (error) { if (error instanceof RecoveryDeferredHubError) throw error; return undefined; }
94
102
  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
103
  if (s.peers.some(peer => Object.hasOwn(peer, "externalInstance") && (peer.state !== "offline" || !validExternalInstanceBinding(peer.externalInstance, expected.projectRoot, peer.id)))) return undefined;
104
+ if (s.peers.some(peer => Object.hasOwn(peer, "controlProtocol") && (typeof peer.controlProtocol !== "number" || !Number.isSafeInteger(peer.controlProtocol) || peer.controlProtocol < 1))) return undefined;
96
105
  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
106
  if (s.integrity && Object.hasOwn(s.integrity, "externalBindingsDigest") && (typeof s.integrity.externalBindingsDigest !== "string" || !/^[a-f0-9]{64}$/.test(s.integrity.externalBindingsDigest))) return undefined;
107
+ if (s.integrity && Object.hasOwn(s.integrity, "research")) {
108
+ const research = s.integrity.research;
109
+ if (!research || typeof research !== "object" || Array.isArray(research)
110
+ || ![research.campaigns, research.units, research.revision].every(value => Number.isSafeInteger(value) && value >= 0)
111
+ || typeof research.digest !== "string" || !/^[a-f0-9]{64}$/.test(research.digest)) return undefined;
112
+ }
113
+ if (s.integrity && Object.hasOwn(s.integrity, "researchDispatch")) {
114
+ const value = s.integrity.researchDispatch;
115
+ if (!value || typeof value !== "object" || Array.isArray(value) || ![value.activations, value.handoffs].every(count => Number.isSafeInteger(count) && count >= 0)
116
+ || typeof value.digest !== "string" || !/^[a-f0-9]{64}$/.test(value.digest)) return undefined;
117
+ }
118
+ if (s.integrity && Object.hasOwn(s.integrity, "researchExperiments")) {
119
+ const value = s.integrity.researchExperiments;
120
+ if (!value || typeof value !== "object" || Array.isArray(value) || ![value.protocols, value.attempts, value.events, value.observations].every(count => Number.isSafeInteger(count) && count >= 0)
121
+ || typeof value.digest !== "string" || !/^[a-f0-9]{64}$/.test(value.digest)) return undefined;
122
+ }
123
+ if (s.integrity && Object.hasOwn(s.integrity, "researchComparisons")) {
124
+ const value = s.integrity.researchComparisons;
125
+ if (!value || typeof value !== "object" || Array.isArray(value) || ![value.plans, value.approvals, value.contexts].every(count => Number.isSafeInteger(count) && count >= 0)
126
+ || typeof value.digest !== "string" || !/^[a-f0-9]{64}$/.test(value.digest)) return undefined;
127
+ }
128
+ if (s.integrity && Object.hasOwn(s.integrity, "researchRunners")) {
129
+ const value = s.integrity.researchRunners;
130
+ if (!value || typeof value !== "object" || Array.isArray(value) || !Number.isSafeInteger(value.count) || value.count < 0
131
+ || typeof value.digest !== "string" || !/^[a-f0-9]{64}$/.test(value.digest)) return undefined;
132
+ }
98
133
  if (bus.journal !== undefined) { try { validateJournalSnapshot(bus.journal); } catch { return undefined; } }
99
134
  return s as RestartSnapshot;
100
135
  }