@staix/agent-hub 0.25.0 → 0.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/README.md +10 -5
  3. package/docs/agent-notes/README.md +12 -0
  4. package/docs/agent-notes/adapters.md +1 -1
  5. package/docs/agent-notes/daemon.md +6 -4
  6. package/docs/agent-notes/tasks.md +5 -4
  7. package/docs/agent-notes/tests.md +1 -0
  8. package/docs/dogfood.md +4 -0
  9. package/docs/operations.md +131 -14
  10. package/docs/quickstart.md +14 -4
  11. package/docs/security.md +51 -8
  12. package/docs/specs/2026-11-01-research-campaigns-design.md +9 -6
  13. package/docs/verification/2026-11-01-0.26.0.md +54 -0
  14. package/docs/verified.json +28 -16
  15. package/package.json +1 -1
  16. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  17. package/plugins/agent-hub/server.js +182 -95
  18. package/src/adapters/channel-peer.ts +4 -2
  19. package/src/adapters/definitions/builtin-metadata.ts +6 -6
  20. package/src/adapters/definitions/qwen.ts +2 -0
  21. package/src/adapters/registry-types.ts +2 -0
  22. package/src/adapters/registry.ts +1 -0
  23. package/src/cli/conductor-recovery.ts +3 -2
  24. package/src/cli/help.ts +7 -7
  25. package/src/cli/main.ts +62 -23
  26. package/src/cli/recovery-package.ts +94 -4
  27. package/src/cli/reset.ts +14 -4
  28. package/src/cli/runtime-version-warning.ts +28 -0
  29. package/src/cli/start-agents-run.ts +125 -0
  30. package/src/cli/task-output.ts +5 -0
  31. package/src/cli/upgrade-interactive.ts +4 -4
  32. package/src/cli/upgrade-runtime.ts +105 -57
  33. package/src/cli/upgrade.ts +90 -28
  34. package/src/hub/autonomy-defaults.ts +32 -7
  35. package/src/hub/board.ts +129 -13
  36. package/src/hub/budget.ts +3 -4
  37. package/src/hub/child-process.ts +4 -3
  38. package/src/hub/conductor.ts +3 -4
  39. package/src/hub/config-apply.ts +13 -2
  40. package/src/hub/config-edit-host.ts +1 -0
  41. package/src/hub/config-tier.ts +3 -0
  42. package/src/hub/config-trust.ts +10 -2
  43. package/src/hub/control-client.ts +30 -10
  44. package/src/hub/daemon.ts +82 -17
  45. package/src/hub/db-schema.ts +143 -0
  46. package/src/hub/delivery-journal.ts +3 -4
  47. package/src/hub/execution-budget.ts +3 -4
  48. package/src/hub/folder-trigger.ts +3 -4
  49. package/src/hub/invocations.ts +3 -4
  50. package/src/hub/lifecycle.ts +87 -29
  51. package/src/hub/manager.ts +49 -33
  52. package/src/hub/orca-env.ts +19 -0
  53. package/src/hub/project-runtime-state.ts +139 -0
  54. package/src/hub/project-runtime.ts +62 -0
  55. package/src/hub/protocol-window.ts +19 -0
  56. package/src/hub/recovery-store.ts +102 -12
  57. package/src/hub/registry.ts +5 -0
  58. package/src/hub/research-experiments.ts +33 -4
  59. package/src/hub/research-runner.ts +172 -0
  60. package/src/hub/restart.ts +9 -0
  61. package/src/hub/runtime-capture-prune.ts +415 -0
  62. package/src/hub/runtime-prune-health.ts +112 -0
  63. package/src/hub/schedules.ts +3 -4
  64. package/src/hub/sdlc-merge.ts +7 -0
  65. package/src/hub/sdlc-queue.ts +13 -1
  66. package/src/hub/settings.ts +4 -0
  67. package/src/hub/snapshots.ts +3 -4
  68. package/src/hub/start-agents.ts +44 -0
  69. package/src/hub/task-approval.ts +23 -0
  70. package/src/hub/tasks.ts +133 -34
  71. package/src/hub/usage-rollup.ts +3 -4
  72. package/src/pi/tool-receipts.ts +3 -4
@@ -9,8 +9,8 @@ import { databaseBusySite, isDatabaseBusy, RecoveryDatabaseBusy } from "../hub/r
9
9
  import { Database } from "bun:sqlite";
10
10
  import { createHash, randomUUID } from "node:crypto";
11
11
  import { existsSync, readFileSync } from "node:fs";
12
- import { join } from "node:path";
13
- import { REGISTRY_DB_BUSY_TIMEOUT_MS, PROJECT_COLUMNS, projectFromRow, type Project } from "../hub/registry.ts";
12
+ import { isAbsolute, join } from "node:path";
13
+ import { Registry, REGISTRY_DB_BUSY_TIMEOUT_MS, PROJECT_COLUMNS, projectFromRow, type Project } from "../hub/registry.ts";
14
14
  import { hubHome } from "../hub/project.ts";
15
15
  import { readRecoveryWaits } from "./recovery-waits.ts";
16
16
  import { acquireRecoveryLock, claimRunner, coordinatorCurrent, operationIdPattern, readOperation, recoveryCommand, recoveryLock, releaseRecoveryLock, writeOperation } from "../hub/recovery-store.ts";
@@ -27,6 +27,7 @@ const projectDefinition = (peer: string, root?: string) => {
27
27
 
28
28
  export interface RecoveryPeer {
29
29
  id: string;
30
+ controlProtocol?: number;
30
31
  /** Read-only source observation; never consumed as restoration arguments. */
31
32
  observation?: { kind: string; attached: boolean; sessionDigest?: string };
32
33
  /** Captured stable module/instance identity; never a portable current-owner proof. */
@@ -42,6 +43,8 @@ export interface Inspection {
42
43
  instanceId?: string;
43
44
  version?: string;
44
45
  protocol?: number;
46
+ protocolMin?: number;
47
+ protocolMax?: number;
45
48
  peers: RecoveryPeer[];
46
49
  /** `waiting` (#272): what the running hub says keeps it from being quiet (busy peers, pending approvals); display only. */
47
50
  recovery?: { operationId?: string; phase?: string; ready?: boolean; waiting?: string[] };
@@ -65,6 +68,8 @@ export interface PlannedProject {
65
68
  }
66
69
  export interface UpgradePlan {
67
70
  schema: 1;
71
+ /** Present for new CLI operations; absent only on retained legacy machine-wide plans. */
72
+ scope?: { projectRoot: string };
68
73
  kind: "restart" | "upgrade";
69
74
  version: string;
70
75
  integrity?: string;
@@ -122,7 +127,7 @@ export interface RecoveryOperation {
122
127
  * #227: 2 marks a stop-and-archive recorded on an operation whose coordinator predates #215. Every older runner and
123
128
  * abort refuses a schema other than 1 before it takes the lock or claims the runner, so none can advance it.
124
129
  */
125
- schema: 1 | 2;
130
+ schema: 1 | 2 | 3;
126
131
  id: string;
127
132
  plan: UpgradePlan;
128
133
  createdAt: number;
@@ -417,20 +422,47 @@ export function planFingerprint(plan: Omit<UpgradePlan, "fingerprint">): string
417
422
  return createHash("sha256").update(JSON.stringify(stable)).digest("hex");
418
423
  }
419
424
 
425
+ /** A scoped receipt cannot expand into a second project, even when read by a later coordinator. */
426
+ export function recoveryScope(plan: Pick<UpgradePlan, "scope" | "projects">): string | undefined {
427
+ if (plan.scope === undefined) return undefined;
428
+ const scope = plan.scope;
429
+ if (!scope || typeof scope !== "object" || Array.isArray(scope) || Object.keys(scope).length !== 1 || !Object.hasOwn(scope, "projectRoot")
430
+ || typeof scope.projectRoot !== "string" || !isAbsolute(scope.projectRoot) || !Array.isArray(plan.projects)
431
+ || plan.projects.length !== 1 || plan.projects[0]?.project.root !== scope.projectRoot)
432
+ throw new Error("reviewed project recovery scope is invalid");
433
+ return scope.projectRoot;
434
+ }
435
+ function sealScopedPlan(op: RecoveryOperation): void {
436
+ if (op.schema !== 3) return;
437
+ const seen = new WeakSet<object>();
438
+ const freeze = (value: unknown): void => {
439
+ if (!value || typeof value !== "object" || seen.has(value)) return;
440
+ seen.add(value);
441
+ for (const item of Object.values(value)) freeze(item);
442
+ Object.freeze(value);
443
+ };
444
+ freeze(op.plan);
445
+ Object.defineProperty(op, "plan", { value: op.plan, enumerable: true, writable: false, configurable: false });
446
+ }
447
+
420
448
  export function createOperation(plan: UpgradePlan, sourceRoot: string, home = hubHome(), delegation?: RecoveryDelegation, operationId: string = randomUUID()): RecoveryOperation {
421
449
  if (!operationIdPattern.test(operationId)) throw new Error("invalid recovery operation ID");
422
- if (plan.blockers.length || plan.projects.some((p) => p.blockers.length)) throw new Error("upgrade plan has blockers; no runtimes were changed");
423
450
  if (!plan.projects.length) throw new Error("no running projects to recover");
451
+ if (plan.blockers.length || plan.projects.some((p) => p.blockers.length)) throw new Error("upgrade plan has blockers; no runtimes were changed");
424
452
  const { fingerprint, ...body } = plan;
425
453
  if (planFingerprint(body) !== fingerprint) throw new Error("upgrade plan fingerprint is invalid");
454
+ const scope = recoveryScope(plan);
426
455
  const id = operationId;
427
456
  const op: RecoveryOperation = {
428
- schema: 1, id, plan, sourceRoot, createdAt: Date.now(), updatedAt: Date.now(), phase: "pending", step: "stage",
457
+ schema: scope === undefined ? 1 : 3, id, plan, sourceRoot, createdAt: Date.now(), updatedAt: Date.now(), phase: "pending", step: "stage",
429
458
  projects: plan.projects.map((p) => ({ id: p.project.id, phase: "pending", terminals: {} })),
430
459
  };
431
460
  if (delegation) appendRecoveryDelegation(op, delegation, op.createdAt);
432
- writeOperation(id, op, home);
433
- acquireRecoveryLock(id, home);
461
+ sealScopedPlan(op);
462
+ // Publish the nonterminal receipt and its hold atomically with capture pruning's inventory.
463
+ const registry = new Registry(join(home, "registry.db"));
464
+ try { registry.runtimeCaptureLocked(() => { writeOperation(id, op, home); acquireRecoveryLock(id, home, scope); }); }
465
+ finally { registry.close(); }
434
466
  return op;
435
467
  }
436
468
 
@@ -488,7 +520,9 @@ function validateReceipt(id: string, op: RecoveryOperation, historical = false):
488
520
  // Cleanup validates owned historical facts; this mode never authorizes restore, fresh or reattach.
489
521
  const options = historical ? { authority: "historical" as const } : {};
490
522
  const decode = (value: unknown) => decodeRestoreReceipt(value, options);
491
- if (!(op.schema === 1 || (op.schema === 2 && op.disposition)) || op.id !== id) throw new Error("unsupported operation receipt");
523
+ const scope = recoveryScope(op.plan);
524
+ if (!(scope === undefined ? op.schema === 1 || (op.schema === 2 && op.disposition) : op.schema === 3) || op.id !== id)
525
+ throw new Error("unsupported operation receipt");
492
526
  validateRecoveryDelegations(op);
493
527
  if (op.targetFeatures !== undefined && (!Array.isArray(op.targetFeatures) || !op.targetFeatures.every(feature => typeof feature === "string" && feature.length > 0) || !op.targetRoot || !op.targetDigest)) throw new Error("invalid target features");
494
528
  const { fingerprint, ...reviewed } = op.plan;
@@ -584,13 +618,22 @@ async function authorizeRecoveryDriver(driver: RecoveryDriver, op: RecoveryOpera
584
618
  await driver.authorize(op, phase);
585
619
  }
586
620
 
587
- /** Closed effect ports only. Inspection stays read-only; person drivers keep their existing behavior. */
588
- export function guardedRecoveryDriver(driver: RecoveryDriver, operation: () => RecoveryOperation): RecoveryDriver {
589
- if (driver.conductorPeer === undefined) return driver;
621
+ function scopedRecoveryCurrent(operation: () => RecoveryOperation, scope: string | undefined, id: string, home: string): (() => void) | undefined {
622
+ if (scope === undefined) return undefined;
623
+ return () => {
624
+ if (recoveryScope(operation().plan) !== scope || recoveryLock(home, scope) !== id) throw new Error("project recovery scope or hold changed");
625
+ };
626
+ }
627
+
628
+ /** Closed effect ports only. Scoped operations also fence their project hold; inspection stays read-only. */
629
+ export function guardedRecoveryDriver(driver: RecoveryDriver, operation: () => RecoveryOperation, current?: () => void): RecoveryDriver {
630
+ if (driver.conductorPeer === undefined && !current) return driver;
590
631
  const guard = <A extends unknown[], R>(action: (...args: A) => Promise<R>) => async (...args: A): Promise<R> => {
632
+ current?.();
591
633
  await authorizeRecoveryDriver(driver, operation(), "effect");
634
+ current?.();
592
635
  try { return await action(...args); }
593
- finally { await authorizeRecoveryDriver(driver, operation(), "effect"); }
636
+ finally { await authorizeRecoveryDriver(driver, operation(), "effect"); current?.(); }
594
637
  };
595
638
  return {
596
639
  ...driver,
@@ -619,14 +662,19 @@ export async function runRecovery(id: string, driver: RecoveryDriver, home = hub
619
662
  // mutable phase. A concurrent runner may have completed the operation meanwhile.
620
663
  let op = readOperation<RecoveryOperation>(id, home);
621
664
  validateReceipt(id, op);
665
+ const scope = recoveryScope(op.plan);
666
+ sealScopedPlan(op);
622
667
  if (driver.conductorPeer !== undefined) await authorizeRecoveryDriver(driver, op, "run");
623
- acquireRecoveryLock(id, home);
668
+ const scopeCurrent = scopedRecoveryCurrent(() => op, scope, id, home);
669
+ acquireRecoveryLock(id, home, scope);
624
670
  const releaseRunner = claimRunner(id, home);
625
671
  try {
626
672
  op = readOperation<RecoveryOperation>(id, home);
673
+ if (recoveryScope(op.plan) !== scope) throw new Error("project recovery scope changed while claiming its runner");
674
+ sealScopedPlan(op);
627
675
  validateReceipt(id, op);
628
676
  if (op.phase === "completed" || op.phase === "cancelled") {
629
- if (recoveryLock(home) === id) releaseRecoveryLock(id, home);
677
+ if (recoveryLock(home, scope) === id) releaseRecoveryLock(id, home, scope);
630
678
  releaseRunner();
631
679
  return op;
632
680
  }
@@ -636,7 +684,7 @@ export async function runRecovery(id: string, driver: RecoveryDriver, home = hub
636
684
  writeOperation(id, op, home);
637
685
  }
638
686
  } catch (error) { releaseRunner(); throw error; }
639
- driver = guardedRecoveryDriver(driver, () => op);
687
+ driver = guardedRecoveryDriver(driver, () => op, scopeCurrent);
640
688
  let targetFeatures: readonly string[] = [];
641
689
  const save = () => { op.updatedAt = driver.now(); writeOperation(id, op, home); };
642
690
  const step = (value: string) => { op.step = value; save(); };
@@ -843,8 +891,8 @@ export async function runRecovery(id: string, driver: RecoveryDriver, home = hub
843
891
  progress.phase = "peers-restored"; save();
844
892
  }
845
893
  }
846
- // Claude is deliberately a shared final phase: no project-level plugin isolation is claimed.
847
- if (op.plan.kind === "upgrade" && !op.pluginInstalled) {
894
+ // Legacy receipts keep their shared installation steps; project upgrades leave global tools alone.
895
+ if (scope === undefined && op.plan.kind === "upgrade" && !op.pluginInstalled) {
848
896
  step("install-plugin"); await driver.installPlugin(op); op.pluginInstalled = true; save();
849
897
  }
850
898
  for (let i = 0; i < op.projects.length; i++) {
@@ -866,10 +914,10 @@ export async function runRecovery(id: string, driver: RecoveryDriver, home = hub
866
914
  progress.phase = "verified"; save();
867
915
  }
868
916
  if (driver.refreshManager) { step("refresh-manager"); await driver.refreshManager(op); }
869
- if (op.plan.kind === "upgrade" && !op.globalInstalled) {
917
+ if (scope === undefined && op.plan.kind === "upgrade" && !op.globalInstalled) {
870
918
  step("install-global"); await driver.installGlobal(op); op.globalInstalled = true; save();
871
919
  }
872
- releaseRecoveryLock(id, home); op.phase = "completed"; step("completed");
920
+ releaseRecoveryLock(id, home, scope); op.phase = "completed"; step("completed");
873
921
  } catch (error) {
874
922
  // #215: every runner error ends with the choices status shows, read from every open source, never only this one.
875
923
  const presentationError = error instanceof RecoveryPresentationError ? error
@@ -936,12 +984,18 @@ export async function reattachRecovery(id: string, peer: string, driver: Recover
936
984
  return { progress, planned, marker, native };
937
985
  };
938
986
  selection(); // Finished or unrelated operations must not acquire a new lock merely to refuse.
939
- acquireRecoveryLock(id, home);
987
+ const scope = recoveryScope(op.plan);
988
+ sealScopedPlan(op);
989
+ const scopeCurrent = scopedRecoveryCurrent(() => op, scope, id, home);
990
+ acquireRecoveryLock(id, home, scope);
940
991
  const releaseRunner = claimRunner(id, home);
992
+ driver = guardedRecoveryDriver(driver, () => op, scopeCurrent);
941
993
  let attempted = false;
942
994
  const save = () => { op.updatedAt = driver.now(); writeOperation(id, op, home); };
943
995
  try {
944
996
  op = readOperation<RecoveryOperation>(id, home);
997
+ if (recoveryScope(op.plan) !== scope) throw new Error("project recovery scope changed while claiming its runner");
998
+ sealScopedPlan(op);
945
999
  validateReceipt(id, op);
946
1000
  const { progress, planned, marker, native } = selection();
947
1001
  const expected = structuredClone(marker.binding);
@@ -985,16 +1039,21 @@ export async function reattachRecovery(id: string, peer: string, driver: Recover
985
1039
  export async function abortRecovery(id: string, driver: RecoveryDriver, home = hubHome()): Promise<void> {
986
1040
  let op = readOperation<RecoveryOperation>(id, home);
987
1041
  validateReceipt(id, op, true);
1042
+ const scope = recoveryScope(op.plan);
1043
+ sealScopedPlan(op);
988
1044
  if (driver.conductorPeer !== undefined) await authorizeRecoveryDriver(driver, op, "abort");
989
- acquireRecoveryLock(id, home);
1045
+ const scopeCurrent = scopedRecoveryCurrent(() => op, scope, id, home);
1046
+ acquireRecoveryLock(id, home, scope);
990
1047
  const releaseRunner = claimRunner(id, home);
991
1048
  try {
992
1049
  // Re-read after the exclusive runner claim so cancellation cannot act on a
993
1050
  // stale pending/prepared receipt after another runner advanced it.
994
1051
  op = readOperation<RecoveryOperation>(id, home);
1052
+ if (recoveryScope(op.plan) !== scope) throw new Error("project recovery scope changed while claiming its runner");
1053
+ sealScopedPlan(op);
995
1054
  validateReceipt(id, op, true);
996
1055
  if (op.phase === "completed" || op.phase === "cancelled") {
997
- if (recoveryLock(home) === id) releaseRecoveryLock(id, home);
1056
+ if (recoveryLock(home, scope) === id) releaseRecoveryLock(id, home, scope);
998
1057
  return;
999
1058
  }
1000
1059
  if (driver.conductorPeer !== undefined) await authorizeRecoveryDriver(driver, op, "abort");
@@ -1002,7 +1061,7 @@ export async function abortRecovery(id: string, driver: RecoveryDriver, home = h
1002
1061
  appendRecoveryDelegation(op, { peer: (driver.conductorPeer ?? driver.liveTestPeer)!, command: "recovery abort" }, driver.now());
1003
1062
  writeOperation(id, op, home);
1004
1063
  }
1005
- driver = guardedRecoveryDriver(driver, () => op);
1064
+ driver = guardedRecoveryDriver(driver, () => op, scopeCurrent);
1006
1065
  if (abandoned(op)) writeOperation(id, op, home);
1007
1066
  const { fingerprint, ...body } = op.plan;
1008
1067
  if (fingerprint !== planFingerprint(body)) throw new Error("operation plan changed");
@@ -1017,7 +1076,7 @@ export async function abortRecovery(id: string, driver: RecoveryDriver, home = h
1017
1076
  }
1018
1077
  delete op.error; delete op.failure; delete op.blockedReattachment; delete op.presentationCause;
1019
1078
  op.phase = "cancelled"; op.step = "cancelled"; op.updatedAt = driver.now();
1020
- writeOperation(id, op, home); releaseRecoveryLock(id, home);
1079
+ writeOperation(id, op, home); releaseRecoveryLock(id, home, scope);
1021
1080
  } catch (error) {
1022
1081
  if (isDatabaseBusy(error)) throw new RecoveryDatabaseBusy();
1023
1082
  throw error;
@@ -1037,15 +1096,18 @@ export async function disposeRecovery(id: string, choice: { fresh: string } | {
1037
1096
  if (driver.liveTestPeer && "fresh" in choice && repairedFreshSession(op, choice.fresh) !== false) throw new Error(LIVE_TEST_PERSON_REFUSAL);
1038
1097
  // A finished operation owns no lock: never take one only to refuse.
1039
1098
  if (op.phase === "completed" || op.phase === "cancelled") throw new Error(`operation is already ${op.phase}`);
1040
- acquireRecoveryLock(id, home);
1099
+ const scope = recoveryScope(op.plan);
1100
+ sealScopedPlan(op);
1101
+ const scopeCurrent = scopedRecoveryCurrent(() => op, scope, id, home);
1102
+ acquireRecoveryLock(id, home, scope);
1041
1103
  const releaseRunner = claimRunner(id, home);
1042
1104
  try {
1043
- try { op = readOperation<RecoveryOperation>(id, home); validateReceipt(id, op, "stop" in choice); }
1105
+ try { op = readOperation<RecoveryOperation>(id, home); validateReceipt(id, op, "stop" in choice); if (recoveryScope(op.plan) !== scope) throw new Error("project recovery scope changed while claiming its runner"); sealScopedPlan(op); }
1044
1106
  catch (error) { if (driver.liveTestPeer && "fresh" in choice) throw new Error(LIVE_TEST_PERSON_REFUSAL); throw error; }
1045
1107
  if (driver.liveTestPeer && "fresh" in choice && repairedFreshSession(op, choice.fresh) !== false) throw new Error(LIVE_TEST_PERSON_REFUSAL);
1046
1108
  if (op.phase === "completed" || op.phase === "cancelled") {
1047
1109
  // Another runner finished it while this one waited: leave no lock behind, as resume and abort do.
1048
- if (recoveryLock(home) === id) releaseRecoveryLock(id, home);
1110
+ if (recoveryLock(home, scope) === id) releaseRecoveryLock(id, home, scope);
1049
1111
  throw new Error(`operation is already ${op.phase}`);
1050
1112
  }
1051
1113
  if (driver.liveTestPeer) {
@@ -1155,7 +1217,7 @@ export async function disposeRecovery(id: string, choice: { fresh: string } | {
1155
1217
  }
1156
1218
  delete op.error; delete op.failure;
1157
1219
  op.phase = "cancelled"; op.step = "disposed: stop-and-archive (abandoned, not completed)"; op.updatedAt = driver.now();
1158
- writeOperation(id, op, home); releaseRecoveryLock(id, home);
1220
+ writeOperation(id, op, home); releaseRecoveryLock(id, home, scope);
1159
1221
  return op;
1160
1222
  } finally { releaseRunner(); }
1161
1223
  }
@@ -3,8 +3,10 @@ import { closeSync, constants, fstatSync, fsyncSync, lstatSync, mkdirSync, openS
3
3
  import { isAbsolute, join, relative } from "node:path";
4
4
  import { canonicalPath } from "./project.ts";
5
5
  import { insideRoot } from "./path-policy.ts";
6
+ import { checkStartAgents } from "./start-agents.ts";
6
7
 
7
- export interface AutonomyDefaultsRead { enabled: boolean; fingerprint: string; notice?: string }
8
+ /** `startAgents` is the machine default of `start.agents` (#608), present only when `ahub setup` chose one. */
9
+ export interface AutonomyDefaultsRead { enabled: boolean; fingerprint: string; notice?: string; startAgents?: string[] }
8
10
  const MAX_BYTES = 4096;
9
11
  const same = (a: Stats, b: Stats) => a.dev === b.dev && a.ino === b.ino && a.size === b.size && a.mtimeMs === b.mtimeMs && a.ctimeMs === b.ctimeMs;
10
12
  const refusal = () => new Error("machine autonomy defaults are unsafe or changed; nothing was written");
@@ -30,26 +32,49 @@ function capture(home: string, roots: readonly string[]): { stat?: Stats; text?:
30
32
  return { stat: after, text, fingerprint: createHash("sha256").update(`${after.dev}:${after.ino}:${after.ctimeMs}:`).update(text).digest("hex") };
31
33
  } finally { closeSync(fd); }
32
34
  }
35
+ interface DefaultsDocument { autonomy?: boolean; startAgents?: string[] }
36
+ /** The file's content, or the refusal. `{schema:1}` alone and any key but schema, autonomy and start are refused. */
37
+ function parseDefaults(text: string): DefaultsDocument {
38
+ const raw: unknown = JSON.parse(text);
39
+ const document = raw && typeof raw === "object" && !Array.isArray(raw) ? raw as Record<string, unknown> : undefined;
40
+ if (!document || Object.keys(document).some(key => !["schema", "autonomy", "start"].includes(key)) || document.schema !== 1) throw refusal();
41
+ if (document.autonomy !== undefined && typeof document.autonomy !== "boolean") throw refusal();
42
+ let startAgents: string[] | undefined;
43
+ if (document.start !== undefined) {
44
+ const start = document.start;
45
+ if (!start || typeof start !== "object" || Array.isArray(start) || Object.keys(start).some(key => key !== "agents")) throw refusal();
46
+ try { startAgents = checkStartAgents((start as Record<string, unknown>).agents); } catch { throw refusal(); }
47
+ }
48
+ if (document.autonomy === undefined && startAgents === undefined) throw refusal();
49
+ return { ...(document.autonomy === undefined ? {} : { autonomy: document.autonomy }), ...(startAgents ? { startAgents } : {}) };
50
+ }
33
51
  /** Missing or untrusted machine files never change the shipped ask default. */
34
52
  export function readAutonomyDefaults(home: string, roots: readonly string[] = []): AutonomyDefaultsRead {
35
53
  try {
36
54
  const held = capture(home, roots);
37
55
  if (held.text === undefined) return { enabled: false, fingerprint: held.fingerprint };
38
- const raw: unknown = JSON.parse(held.text);
39
- const document = raw && typeof raw === "object" && !Array.isArray(raw) ? raw as Record<string, unknown> : undefined;
40
- if (!document || Object.keys(document).some(key => !["schema", "autonomy"].includes(key)) || document.schema !== 1 || typeof document.autonomy !== "boolean") throw refusal();
41
- return { enabled: document.autonomy, fingerprint: held.fingerprint };
56
+ const document = parseDefaults(held.text);
57
+ return { enabled: document.autonomy === true, fingerprint: held.fingerprint, ...(document.startAgents ? { startAgents: document.startAgents } : {}) };
42
58
  } catch { return { enabled: false, fingerprint: "untrusted", notice: "Machine autonomy defaults ignored: defaults.json must be an owner-only regular file with a valid schema, outside project roots." }; }
43
59
  }
44
- /** Person CLI only. Exact prior evidence is rechecked after confirmation and before replacement. */
60
+ /** Person CLI only. Exact prior evidence is rechecked after confirmation and before replacement. The other half of the file (the start set) is kept. */
45
61
  export function writeAutonomyDefaults(home: string, enabled: boolean, expected: string, roots: readonly string[] = []): void {
62
+ writeDefaults(home, expected, roots, kept => ({ schema: 1, autonomy: enabled, ...(kept.startAgents ? { start: { agents: kept.startAgents } } : {}) }));
63
+ }
64
+ /** Person CLI only (`ahub setup`): the machine default of `start.agents`. The autonomy half of the file is kept, and absent stays absent. */
65
+ export function writeMachineStartAgents(home: string, agents: readonly string[], expected: string, roots: readonly string[] = []): void {
66
+ const checked = checkStartAgents([...agents]);
67
+ writeDefaults(home, expected, roots, kept => ({ schema: 1, ...(kept.autonomy === undefined ? {} : { autonomy: kept.autonomy }), start: { agents: checked } }));
68
+ }
69
+ function writeDefaults(home: string, expected: string, roots: readonly string[], build: (kept: DefaultsDocument) => Record<string, unknown>): void {
46
70
  const before = capture(home, roots);
47
71
  if (before.fingerprint !== expected) throw refusal();
72
+ const kept = before.text === undefined ? {} : parseDefaults(before.text);
48
73
  const parent = location(home, roots, true)!;
49
74
  const file = join(home, "defaults.json"), temporary = join(home, `.defaults.${randomUUID()}.tmp`);
50
75
  try {
51
76
  const fd = openSync(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
52
- try { writeFileSync(fd, `${JSON.stringify({ schema: 1, autonomy: enabled }, null, 2)}\n`); fsyncSync(fd); }
77
+ try { writeFileSync(fd, `${JSON.stringify(build(kept), null, 2)}\n`); fsyncSync(fd); }
53
78
  finally { closeSync(fd); }
54
79
  const current = lstatSync(home);
55
80
  if (current.dev !== parent.dev || current.ino !== parent.ino || capture(home, roots).fingerprint !== expected) throw refusal();
package/src/hub/board.ts CHANGED
@@ -1,5 +1,6 @@
1
+ import { parseAttemptReceipt, parseResearchRunners, verifyAttemptReceipt, type AttemptReceiptV1 } from "./research-runner.ts";
1
2
  import { canonicalJson } from "./packs.ts";
2
- import { parseComparisonPlan, comparisonPlanHash, evaluatorIdentityHash, parseExperimentProtocol, protocolHash, parseExperimentAttempt, parseExperimentEvent, parseExperimentHostEvidence, experimentStatus, projectExperimentWithObservations,
3
+ import { parseComparisonPlan, comparisonPlanHash, evaluatorIdentityHash, parseExperimentProtocol, protocolHash, parseExperimentAttempt, parseExperimentEvent, parseExperimentHostEvidence, experimentStatus, projectExperimentWithObservations, declaredExperimentEvents,
3
4
  type ComparisonPlanV1, type ExperimentProtocolRecord, type ExperimentAttemptRecord, type ExperimentEventRecord, type ExperimentWriteFence, type DeclaredExperimentEventInput, type ExperimentHostEvidence, type ExperimentStatus } from "./research-experiments.ts";
4
5
  import type { InvocationRow } from "./invocations.ts";
5
6
  import { HOST_CODE_SYMBOLS, parseTaskCodeSymbols, type TaskCodeSymbols } from "./task-code-symbols.ts";
@@ -8,11 +9,11 @@ import { planFingerprint } from "./plan-conformance.ts";
8
9
  import type { PlannerReason } from "./routing.ts";
9
10
  import type { ReviewerPoolReason } from "./reviewer-pool.ts";
10
11
  import type { GitHubSource } from "./github-import.ts";
11
- import { Database } from "bun:sqlite";
12
+ import type { Database } from "./db-schema.ts";
13
+ import { openHubDatabase, advanceHubDbSchema } from "./db-schema.ts";
12
14
  import { planningPatch, type PlanningFields, type Release, type ReleaseState } from "./planning.ts";
13
15
  import { HUB, USER, type PeerId } from "./envelope.ts";
14
16
  import type { SweepRecord } from "./task-sweep.ts";
15
- import { HUB_DB_BUSY_TIMEOUT_MS } from "./db-timeout.ts";
16
17
  import type { SdlcReceipt } from "./sdlc.ts";
17
18
  import { parseReviewFindings, type ReviewFindingSet } from "./review-findings.ts";
18
19
  import { parseDeliverableRecords, type DeliverableRecord } from "./deliverables.ts";
@@ -107,6 +108,12 @@ const EXPERIMENT_PLAN_SELECT = "SELECT rowid AS row_id,hash,project,campaign_id,
107
108
  interface ExperimentProtocolRow { hash: string; project: string; campaign_id: string; campaign_revision: number; task_id: number; body: string | null; by_peer: string; at: number; private: number }
108
109
  interface ExperimentAttemptRow { row_id?: number; attempt_id: string; project: string; campaign_id: string; campaign_revision: number; protocol_hash: string; task_id: number; idempotency_key: string; body: string | null; body_sha256: string; by_peer: string; at: number; private: number }
109
110
  interface ExperimentEventRow { attempt_id: string; project: string; seq: number; idempotency_key: string; kind: string; body: string | null; body_sha256: string; by_peer: string; at: number; private: number }
111
+ export interface RunnerReceiptRecord {
112
+ receipt: AttemptReceiptV1; signature: string; publicKey: string; keyHash: string; bodyHash: string;
113
+ seq: number; by: PeerId; at: number; private: boolean;
114
+ }
115
+ interface RunnerReceiptRow { receipt_id: string; project: string; attempt_id: string; event_seq: number; body: string | null; body_sha256: string; by_peer: string; at: number; private: number }
116
+ const RUNNER_RECEIPT_SELECT = "SELECT receipt_id,project,attempt_id,event_seq,CASE WHEN length(CAST(body AS BLOB))<=131072 THEN body END AS body,body_sha256,by_peer,at,private FROM research_runner_receipts";
110
117
  interface ExperimentObservationRow { attempt_id: string; event_seq: number; body: string | null; body_sha256: string }
111
118
  const EXPERIMENT_PROTOCOL_SELECT = "SELECT hash,project,campaign_id,campaign_revision,task_id,CASE WHEN length(CAST(body AS BLOB))<=65536 THEN body END AS body,by_peer,at,private FROM research_protocols";
112
119
  const EXPERIMENT_ATTEMPT_SELECT = "SELECT rowid AS row_id,attempt_id,project,campaign_id,campaign_revision,protocol_hash,task_id,idempotency_key,CASE WHEN length(CAST(body AS BLOB))<=65536 THEN body END AS body,body_sha256,by_peer,at,private FROM research_attempts";
@@ -212,6 +219,8 @@ export interface HistoryEntry {
212
219
  evidenceOnly?: true;
213
220
  /** Structured review report bound to the reviewed artifact, stored in the existing history JSON. */
214
221
  reviewFindings?: ReviewFindingSet;
222
+ /** Immutable artifact head of this verdict, including when lifecycle gates are off. */
223
+ reviewedHead?: string;
215
224
  /**
216
225
  * #509a: the files a `done`, a lifecycle record or an approval bound, with the content hashes the hub computed. An approval carries the hashes
217
226
  * it was given at review time, so a later edit reads as "deliverable changed since review". Full paths live here; every view goes through
@@ -403,8 +412,7 @@ export class Board {
403
412
  }
404
413
 
405
414
  constructor(path: string) {
406
- this.db = new Database(path, { create: true });
407
- this.db.run(`PRAGMA busy_timeout = ${HUB_DB_BUSY_TIMEOUT_MS}`);
415
+ this.db = openHubDatabase(path, { create: true });
408
416
  this.db.run("PRAGMA journal_mode = WAL");
409
417
  this.db.run(`CREATE TABLE IF NOT EXISTS tasks (
410
418
  id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, detail TEXT NOT NULL DEFAULT '', class TEXT NOT NULL,
@@ -505,6 +513,10 @@ export class Board {
505
513
  this.db.run(`CREATE TABLE IF NOT EXISTS research_attempt_observations (
506
514
  attempt_id TEXT NOT NULL, event_seq INTEGER NOT NULL, body TEXT NOT NULL, body_sha256 TEXT NOT NULL,
507
515
  PRIMARY KEY(attempt_id,event_seq))`);
516
+ this.db.run(`CREATE TABLE IF NOT EXISTS research_runner_receipts (
517
+ receipt_id TEXT PRIMARY KEY, project TEXT NOT NULL, attempt_id TEXT NOT NULL, event_seq INTEGER NOT NULL,
518
+ body TEXT NOT NULL, body_sha256 TEXT NOT NULL, by_peer TEXT NOT NULL, at INTEGER NOT NULL, private INTEGER NOT NULL,
519
+ UNIQUE(attempt_id,event_seq))`);
508
520
  this.db.run(`CREATE TABLE IF NOT EXISTS research_plans (
509
521
  hash TEXT PRIMARY KEY, project TEXT NOT NULL, campaign_id TEXT NOT NULL, campaign_revision INTEGER NOT NULL,
510
522
  task_id INTEGER NOT NULL, body TEXT NOT NULL, by_peer TEXT NOT NULL, at INTEGER NOT NULL, private INTEGER NOT NULL)`);
@@ -867,7 +879,7 @@ export class Board {
867
879
  }
868
880
 
869
881
  hasResearchState(): boolean {
870
- return (this.db.query("SELECT EXISTS(SELECT 1 FROM research_campaigns) OR EXISTS(SELECT 1 FROM research_units) OR EXISTS(SELECT 1 FROM research_protocols) OR EXISTS(SELECT 1 FROM research_attempts) OR EXISTS(SELECT 1 FROM research_attempt_events) OR EXISTS(SELECT 1 FROM research_attempt_observations) AS present").get() as { present: number }).present === 1;
882
+ return (this.db.query("SELECT EXISTS(SELECT 1 FROM research_campaigns) OR EXISTS(SELECT 1 FROM research_units) OR EXISTS(SELECT 1 FROM research_protocols) OR EXISTS(SELECT 1 FROM research_attempts) OR EXISTS(SELECT 1 FROM research_attempt_events) OR EXISTS(SELECT 1 FROM research_attempt_observations) OR EXISTS(SELECT 1 FROM research_runner_receipts) AS present").get() as { present: number }).present === 1;
871
883
  }
872
884
 
873
885
  /** A consistent, streaming digest includes metadata and body hashes, not merely row counts. */
@@ -1104,6 +1116,15 @@ export class Board {
1104
1116
  if (typeof row.body !== "string" || !researchInteger(row.seq) || !researchInteger(row.at, 0) || !researchText(row.by_peer) || ![0, 1].includes(row.private) || !researchText(row.idempotency_key, 64)) throw experimentFailure();
1105
1117
  const attempt = this.experimentAttempt(row.attempt_id), protocol = attempt && this.experimentProtocol(attempt.attempt.protocolHash);
1106
1118
  if (!attempt || !protocol || row.project !== attempt.project) throw experimentFailure();
1119
+ if (row.kind === "attestation") {
1120
+ const raw = JSON.parse(row.body), body = raw?.body, receipt = typeof body?.receiptId === "string" && this.runnerReceipt(body.receiptId);
1121
+ if (!receipt || raw.kind !== "attestation" || Object.keys(raw).length !== 2 || Object.keys(body).length !== 1 || receipt.receipt.attemptId !== row.attempt_id || receipt.seq !== row.seq || receipt.at !== row.at
1122
+ || receipt.by !== row.by_peer || row.body_sha256 !== experimentHash({ kind: "attestation", body }) || row.idempotency_key !== `rr-${receipt.bodyHash.slice(0, 61)}`
1123
+ || this.db.query("SELECT 1 FROM research_attempt_observations WHERE attempt_id=? AND event_seq=?").get(row.attempt_id, row.seq)) throw experimentFailure();
1124
+ return { attemptId: row.attempt_id, idempotencyKey: row.idempotency_key,
1125
+ event: { kind: "attestation", body: { receiptId: receipt.receipt.receiptId }, seq: row.seq, at: row.at, provenance: "runner-attested" },
1126
+ bodyHash: row.body_sha256, by: row.by_peer, private: row.private === 1 || attempt.private || receipt.private, observations: [] };
1127
+ }
1107
1128
  const input = parseExperimentEvent(JSON.parse(row.body), protocol.protocol);
1108
1129
  if (input.kind !== row.kind || experimentHash(input) !== row.body_sha256) throw experimentFailure();
1109
1130
  const event = { ...input, seq: row.seq, at: row.at, provenance: "declared" as const };
@@ -1147,6 +1168,87 @@ export class Board {
1147
1168
  });
1148
1169
  }
1149
1170
 
1171
+ private runnerReceiptFromRow(row: RunnerReceiptRow): RunnerReceiptRecord {
1172
+ try {
1173
+ if (typeof row.body !== "string" || !researchInteger(row.event_seq) || !researchInteger(row.at, 0) || !researchText(row.by_peer) || ![0, 1].includes(row.private)) throw experimentFailure();
1174
+ const raw = JSON.parse(row.body);
1175
+ if (!raw || Object.keys(raw).sort().join(",") !== "publicKey,receipt,signature") throw experimentFailure();
1176
+ const receipt = parseAttemptReceipt(raw.receipt);
1177
+ const runner = parseResearchRunners({ [receipt.runner]: { publicKey: raw.publicKey, projects: [receipt.projectId], revoked: false } })[receipt.runner]!;
1178
+ if (typeof raw.signature !== "string" || raw.signature.length !== 88 || Buffer.from(raw.signature, "base64").length !== 64
1179
+ || Buffer.from(raw.signature, "base64").toString("base64") !== raw.signature) throw experimentFailure();
1180
+ const body = { receipt, signature: raw.signature as string, publicKey: runner.publicKey };
1181
+ const attempt = this.experimentAttempt(receipt.attemptId), protocol = attempt && this.experimentProtocol(attempt.attempt.protocolHash);
1182
+ if (!attempt || !protocol || row.receipt_id !== receipt.receiptId || row.attempt_id !== receipt.attemptId || row.project !== receipt.projectId || row.project !== attempt.project
1183
+ || receipt.protocolHash !== attempt.attempt.protocolHash || experimentHash(body) !== row.body_sha256
1184
+ || receipt.codeCommit !== undefined && "commit" in protocol.protocol.code && receipt.codeCommit !== protocol.protocol.code.commit) throw experimentFailure();
1185
+ const event = this.db.query(`${EXPERIMENT_EVENT_SELECT} WHERE attempt_id=? AND seq=?`).get(row.attempt_id, row.event_seq) as ExperimentEventRow | null;
1186
+ const expectedEvent = { kind: "attestation", body: { receiptId: receipt.receiptId } };
1187
+ if (!event || event.kind !== "attestation" || typeof event.body !== "string" || event.body !== canonicalJson(expectedEvent)
1188
+ || event.body_sha256 !== experimentHash(expectedEvent) || event.project !== row.project || event.by_peer !== row.by_peer || event.at !== row.at
1189
+ || event.idempotency_key !== `rr-${row.body_sha256.slice(0, 61)}` || ![0, 1].includes(event.private)
1190
+ || this.db.query("SELECT 1 FROM research_attempt_observations WHERE attempt_id=? AND event_seq=?").get(row.attempt_id, row.event_seq)) throw experimentFailure();
1191
+ return { ...body, keyHash: createHash("sha256").update(Buffer.from(runner.publicKey, "base64")).digest("hex"), bodyHash: row.body_sha256,
1192
+ seq: row.event_seq, by: row.by_peer, at: row.at, private: row.private === 1 || event.private === 1 || attempt.private };
1193
+ } catch { throw experimentFailure(); }
1194
+ }
1195
+
1196
+ runnerReceipt(id: string): RunnerReceiptRecord | undefined {
1197
+ const row = this.db.query(`${RUNNER_RECEIPT_SELECT} WHERE receipt_id=?`).get(id) as RunnerReceiptRow | null;
1198
+ return row ? this.runnerReceiptFromRow(row) : undefined;
1199
+ }
1200
+
1201
+ runnerReceipts(attemptId: string): RunnerReceiptRecord[] {
1202
+ if (!this.experimentAttempt(attemptId)) throw experimentFailure();
1203
+ // ponytail: at most 64 signed receipts per attempt; paginate only with a reviewed evidence-page contract.
1204
+ const rows = this.db.query(`${RUNNER_RECEIPT_SELECT} WHERE attempt_id=? ORDER BY event_seq LIMIT 65`).all(attemptId) as RunnerReceiptRow[];
1205
+ if (rows.length > 64) throw experimentFailure();
1206
+ return rows.map(row => this.runnerReceiptFromRow(row));
1207
+ }
1208
+
1209
+ appendRunnerReceipt(record: Pick<RunnerReceiptRecord, "receipt" | "signature" | "publicKey" | "by" | "at" | "private">, expectedLastSeq: number, fence: ExperimentWriteFence): RunnerReceiptRecord {
1210
+ const receipt = parseAttemptReceipt(record.receipt), attempt = this.experimentAttempt(receipt.attemptId), protocol = attempt && this.experimentProtocol(attempt.attempt.protocolHash);
1211
+ if (!attempt || !protocol || record.by !== fence.owner && record.by !== USER || !researchInteger(record.at, 0) || typeof record.private !== "boolean" || !researchInteger(expectedLastSeq, 0)) throw experimentFailure();
1212
+ const body = { receipt, signature: record.signature, publicKey: record.publicKey }, bodyHash = experimentHash(body);
1213
+ return this.atomic(() => {
1214
+ const { campaign } = this.experimentFence(fence);
1215
+ if (attempt.project !== fence.project || attempt.attempt.taskId !== fence.taskId || protocol.protocol.campaignId !== campaign.id || protocol.protocol.campaignRevision !== campaign.revision) throw experimentFailure();
1216
+ const prior = this.runnerReceipt(receipt.receiptId);
1217
+ if (prior) {
1218
+ if (prior.bodyHash !== bodyHash || canonicalJson({ receipt: prior.receipt, signature: prior.signature, publicKey: prior.publicKey }) !== canonicalJson(body)) throw new Error("receiptId already records different signed bytes");
1219
+ return prior;
1220
+ }
1221
+ verifyAttemptReceipt(receipt, record.signature, { runners: { [receipt.runner]: { publicKey: record.publicKey, projects: [fence.project], revoked: false } }, projectId: fence.project,
1222
+ attempt: { id: attempt.attemptId, projectId: attempt.project, protocolHash: attempt.attempt.protocolHash, ...("commit" in protocol.protocol.code ? { codeCommit: protocol.protocol.code.commit } : {}) } });
1223
+ if (this.runnerReceipts(attempt.attemptId).length >= 64) throw new Error("an attempt supports at most 64 runner receipts");
1224
+ if (this.experimentLastSeq(attempt.attemptId) !== expectedLastSeq || !researchInteger(expectedLastSeq + 1)) throw new Error("experiment event sequence changed");
1225
+ const seq = expectedLastSeq + 1, event = { kind: "attestation", body: { receiptId: receipt.receiptId } };
1226
+ // The first attestation is incompatible with older event readers, so both writes and the format floor commit together.
1227
+ advanceHubDbSchema(this.db, 2, "0.26.0");
1228
+ this.taskWrite(() => this.db.query("INSERT INTO research_attempt_events(attempt_id,project,seq,idempotency_key,kind,body,body_sha256,by_peer,at,private) VALUES(?,?,?,?,?,?,?,?,?,?)")
1229
+ .run(attempt.attemptId, fence.project, seq, `rr-${bodyHash.slice(0, 61)}`, "attestation", canonicalJson(event), experimentHash(event), record.by, record.at, record.private || attempt.private ? 1 : 0));
1230
+ this.taskWrite(() => this.db.query("INSERT INTO research_runner_receipts(receipt_id,project,attempt_id,event_seq,body,body_sha256,by_peer,at,private) VALUES(?,?,?,?,?,?,?,?,?)")
1231
+ .run(receipt.receiptId, fence.project, attempt.attemptId, seq, canonicalJson(body), bodyHash, record.by, record.at, record.private || attempt.private ? 1 : 0));
1232
+ if (record.private || attempt.private) this.taskWrite(() => this.db.query("UPDATE research_attempts SET private=1 WHERE attempt_id=? AND private=0").run(attempt.attemptId));
1233
+ return this.runnerReceipt(receipt.receiptId)!;
1234
+ });
1235
+ }
1236
+
1237
+ hasRunnerReceipts(): boolean {
1238
+ return (this.db.query("SELECT EXISTS(SELECT 1 FROM research_runner_receipts) OR EXISTS(SELECT 1 FROM research_attempt_events WHERE kind='attestation') AS present").get() as { present: number }).present === 1;
1239
+ }
1240
+
1241
+ runnerReceiptIntegrity(): { count: number; digest: string } {
1242
+ return this.db.transaction(() => {
1243
+ if (this.db.query("SELECT 1 FROM research_attempt_events AS e LEFT JOIN research_runner_receipts AS r ON r.attempt_id=e.attempt_id AND r.event_seq=e.seq WHERE e.kind='attestation' AND r.receipt_id IS NULL LIMIT 1").get()) throw experimentFailure();
1244
+ const digest = createHash("sha256").update("agent-hub.runner-receipts-integrity/v1\n"); let count = 0;
1245
+ for (const row of this.db.query(`${RUNNER_RECEIPT_SELECT} ORDER BY receipt_id`).iterate() as Iterable<RunnerReceiptRow>) {
1246
+ digest.update(canonicalJson(this.runnerReceiptFromRow(row))).update("\n"); count++;
1247
+ }
1248
+ return { count, digest: digest.digest("hex") };
1249
+ }).deferred();
1250
+ }
1251
+
1150
1252
  experimentLastSeq(attemptId: string): number {
1151
1253
  if (!this.experimentAttempt(attemptId)) throw experimentFailure();
1152
1254
  const last = this.db.query("SELECT MAX(seq) AS seq,COUNT(*) AS count FROM research_attempt_events WHERE attempt_id=?").get(attemptId) as { seq: number | null; count: number };
@@ -1177,7 +1279,7 @@ export class Board {
1177
1279
  if (endRows.length > 1) throw experimentFailure();
1178
1280
  const end = endRows[0], start = !end && this.db.query(`${EXPERIMENT_EVENT_SELECT} WHERE attempt_id=? AND kind='start' ORDER BY seq LIMIT 1`).get(attemptId) as ExperimentEventRow | null;
1179
1281
  const events = end ? [this.experimentEventFromRow(end).event] : start ? [this.experimentEventFromRow(start).event] : [];
1180
- return experimentStatus(events, this.get(attempt.attempt.taskId)?.state);
1282
+ return experimentStatus(declaredExperimentEvents(events), this.get(attempt.attempt.taskId)?.state);
1181
1283
  }
1182
1284
 
1183
1285
  // ponytail: exact counts validate all campaign attempts on each show (linear in attempt count). Upgrade to a transactional status projection maintained on event and task-state changes.
@@ -1194,7 +1296,7 @@ export class Board {
1194
1296
  }
1195
1297
 
1196
1298
  hasResearchExperimentState(): boolean {
1197
- return (this.db.query("SELECT EXISTS(SELECT 1 FROM research_protocols) OR EXISTS(SELECT 1 FROM research_attempts) OR EXISTS(SELECT 1 FROM research_attempt_events) OR EXISTS(SELECT 1 FROM research_attempt_observations) AS present").get() as { present: number }).present === 1;
1299
+ return (this.db.query("SELECT EXISTS(SELECT 1 FROM research_protocols) OR EXISTS(SELECT 1 FROM research_attempts) OR EXISTS(SELECT 1 FROM research_attempt_events) OR EXISTS(SELECT 1 FROM research_attempt_observations) OR EXISTS(SELECT 1 FROM research_runner_receipts) AS present").get() as { present: number }).present === 1;
1198
1300
  }
1199
1301
 
1200
1302
  researchExperimentsIntegrity(): { protocols: number; attempts: number; events: number; observations: number; digest: string } {
@@ -1352,19 +1454,26 @@ export class Board {
1352
1454
 
1353
1455
  /** The only way a task changes. Validates the move, records who did what, returns the new row. */
1354
1456
  /** A `plan` in the patch replaces the old one whole: a new plan is the owner's current intent, not an addition. */
1355
- update(id: number, by: PeerId, event: string, patch: Partial<Pick<Task, "state" | "owner" | "planner" | "reviewer" | "reserved" | "refs" | "plan" | "rejections" | "signals" | "sdlc" | "role" | "cancelled" | "codeSymbols">>, note?: string, extra: Pick<HistoryEntry, "invocation" | "plannerReason" | "planAuthor" | "selfReview" | "profile" | "sweep" | "reason" | "from" | "capacity" | "conductorManual" | "withheld" | "noWrites" | "reviewCost" | "reviewContext" | "reviewScope" | "evidenceOnly" | "reviewFindings" | "unmetCriteria" | "deliverables" | "checkKey" | "missingKeys" | "claim" | "planObservation" | "planDeparture" | "planDepartureInvalidated" | keyof ReviewerHistory> = {}, expectedFlowRevision?: number, codeSymbolsAuthority?: typeof HOST_CODE_SYMBOLS): Task {
1457
+ update(id: number, by: PeerId, event: string, patch: Partial<Pick<Task, "state" | "owner" | "planner" | "reviewer" | "reserved" | "refs" | "plan" | "rejections" | "signals" | "sdlc" | "role" | "cancelled" | "codeSymbols">>, note?: string, extra: Pick<HistoryEntry, "invocation" | "plannerReason" | "planAuthor" | "selfReview" | "profile" | "sweep" | "reason" | "from" | "capacity" | "conductorManual" | "withheld" | "noWrites" | "reviewCost" | "reviewContext" | "reviewScope" | "evidenceOnly" | "reviewedHead" | "reviewFindings" | "unmetCriteria" | "deliverables" | "checkKey" | "missingKeys" | "claim" | "planObservation" | "planDeparture" | "planDepartureInvalidated" | keyof ReviewerHistory> = {}, expectedFlowRevision?: number, codeSymbolsAuthority?: typeof HOST_CODE_SYMBOLS): Task {
1356
1458
  const task = this.get(id);
1357
1459
  if (!task) throw new Error(`no task #${id}`);
1358
1460
  if (expectedFlowRevision !== undefined && (!Number.isSafeInteger(expectedFlowRevision) || expectedFlowRevision < 0 || task.history.length !== expectedFlowRevision)) throw new Error("task flow revision changed");
1359
1461
  const untouchedReturn = event === "backlog returned untouched" && patch.state === "backlog" && patch.owner === null && patch.reviewer === null
1360
1462
  && ["proposed", "in_progress"].includes(task.state) && expectedFlowRevision !== undefined && !!note?.trim() && !taskHasWorkEvidence(task);
1361
1463
  // A new delivered head with a configured check is owner work again until that check finishes.
1362
- const reviewCheck = event === "done (checking)" && task.state === "in_review" && patch.state === "in_progress"
1363
- && expectedFlowRevision !== undefined && (by === task.owner || by === "user") && patch.owner === undefined && patch.reviewer === undefined
1464
+ const reviewCheck = task.sdlc?.merge?.state !== "merged" && event === "done (checking)" && ["in_review", "approved"].includes(task.state) && patch.state === "in_progress"
1465
+ && expectedFlowRevision !== undefined && (by === task.owner || by === "user") && patch.owner === undefined && (patch.reviewer === undefined || task.state === "approved" && !task.reviewer && patch.reviewer === "user")
1364
1466
  && typeof patch.refs?.commit === "string" && /^[a-f0-9]{40}$/.test(patch.refs.commit)
1365
1467
  && patch.refs.commit !== (task.refs.commit ?? task.sdlc?.head ?? task.sdlc?.localHead);
1468
+ const approvalReopen = task.state === "approved" && task.sdlc?.merge?.state !== "merged" && expectedFlowRevision !== undefined
1469
+ && patch.owner === undefined && (
1470
+ event === "done" && patch.state === "in_review" && (by === task.owner || by === "user")
1471
+ && typeof patch.refs?.commit === "string" && /^[a-f0-9]{40}$/.test(patch.refs.commit)
1472
+ && patch.refs.commit !== (task.refs.commit ?? task.sdlc?.head ?? task.sdlc?.localHead)
1473
+ || event === "changes_requested" && patch.state === "changes_requested" && (by === task.reviewer || by === "user"));
1474
+ if (extra.reviewedHead !== undefined && (!/^[a-f0-9]{40}$/.test(extra.reviewedHead) || !["approved", "changes_requested"].includes(event))) throw new Error("invalid verdict head");
1366
1475
  if (task.draft && patch.state && patch.state !== "backlog" && patch.state !== "cancelled") throw new Error(`task #${id} is an intake draft; the person approves it first`);
1367
- if (patch.state && patch.state !== task.state && !MOVES[task.state].includes(patch.state) && !untouchedReturn && !reviewCheck) {
1476
+ if (patch.state && patch.state !== task.state && !MOVES[task.state].includes(patch.state) && !untouchedReturn && !reviewCheck && !approvalReopen) {
1368
1477
  throw new Error(`task #${id} is ${task.state}: cannot move to ${patch.state}`);
1369
1478
  }
1370
1479
  const planner = Object.hasOwn(patch, "planner") ? plannerValue(patch.planner) : task.planner ?? null;
@@ -1417,7 +1526,14 @@ export class Board {
1417
1526
  // Disabling reporting must not let plan or repository refs edits/reverts resurrect prior consent.
1418
1527
  const repositoryRefsChanged = patch.refs !== undefined && JSON.stringify([task.refs.repo, task.refs.paths]) !== JSON.stringify([next.refs.repo, next.refs.paths]);
1419
1528
  const departureInvalidated = extra.planDepartureInvalidated === true || hasDeparture && (planChanged || repositoryRefsChanged);
1420
- const history = [...task.history, { at: Date.now(), by, event, ...(extra.invocation ? { invocation: extra.invocation } : {}), ...plannerHistory, ...authorHistory, ...(extra.selfReview === true ? { selfReview: true as const } : {}), ...(planRevision ? { planRevision } : {}), ...(extra.planObservation ? { planObservation: extra.planObservation } : {}), ...(extra.planDeparture ? { planDeparture: extra.planDeparture } : {}), ...(departureInvalidated ? { planDepartureInvalidated: true as const } : {}), ...reviewMetadata, ...(note ? { note } : {}), ...(patch.cancelled?.supersededBy ? { supersededBy: patch.cancelled.supersededBy } : {}), ...("owner" in patch ? { owner: next.owner } : {}), ...(extra.profile ? { profile: extra.profile } : {}), ...(extra.sweep ? { sweep: extra.sweep } : {}), ...(extra.reason && TASK_MOVE_REASONS.includes(extra.reason) ? { reason: extra.reason } : {}), ...(extra.from ? { from: extra.from } : {}), ...(extra.capacity === true ? { capacity: true as const } : {}), ...(extra.conductorManual === true ? { conductorManual: true as const } : {}), ...(extra.withheld && (note || reviewFindings) ? { withheld: true as const } : {}), ...(reviewFindings ? { reviewFindings } : {}), ...(unmetCriteria?.length ? { unmetCriteria } : {}), ...(deliverables ? { deliverables } : {}), ...(extra.reviewContext === "fresh" || extra.reviewContext === "continuing" ? { reviewContext: extra.reviewContext } : {}), ...(extra.reviewScope === "code" ? { reviewScope: "code" as const } : {}), ...(extra.evidenceOnly === true && event === "changes_requested" ? { evidenceOnly: true as const } : {}), ...(extra.reviewCost ? { reviewCost: extra.reviewCost } : {}), ...(event === "done" && extra.noWrites === true ? { noWrites: true as const } : {}), ...(["check passed", "check failed"].includes(event) && typeof extra.checkKey === "string" && /^[a-z][a-z0-9_]{0,47}$/.test(extra.checkKey) ? { checkKey: extra.checkKey } : {}), ...(["check passed", "check failed"].includes(event) && Number.isSafeInteger(extra.missingKeys) && extra.missingKeys! >= 0 && extra.missingKeys! <= 100_000 ? { missingKeys: extra.missingKeys } : {}), ...(event === "claim verdict" && extra.claim ? { claim: extra.claim } : {}) }];
1529
+ // Older verdict rows kept their bound head only on the receipt. Preserve it atomically before reopening drops that receipt.
1530
+ const priorApproval = task.history.findLastIndex(entry => entry.event === "approved");
1531
+ const priorHead = task.sdlc?.reviewedHead;
1532
+ const backfill = (approvalReopen || reviewCheck && task.state === "approved") && priorApproval >= 0
1533
+ && priorHead !== undefined && /^[a-f0-9]{40}$/.test(priorHead);
1534
+ const previousHistory = backfill ? task.history.map((entry, index) => index === priorApproval && !entry.reviewedHead
1535
+ ? { ...entry, reviewedHead: entry.reviewFindings?.head ?? priorHead } : entry) : task.history;
1536
+ const history = [...previousHistory, { at: Date.now(), by, event, ...(extra.invocation ? { invocation: extra.invocation } : {}), ...plannerHistory, ...authorHistory, ...(extra.selfReview === true ? { selfReview: true as const } : {}), ...(planRevision ? { planRevision } : {}), ...(extra.planObservation ? { planObservation: extra.planObservation } : {}), ...(extra.planDeparture ? { planDeparture: extra.planDeparture } : {}), ...(departureInvalidated ? { planDepartureInvalidated: true as const } : {}), ...reviewMetadata, ...(note ? { note } : {}), ...(patch.cancelled?.supersededBy ? { supersededBy: patch.cancelled.supersededBy } : {}), ...("owner" in patch ? { owner: next.owner } : {}), ...(extra.profile ? { profile: extra.profile } : {}), ...(extra.sweep ? { sweep: extra.sweep } : {}), ...(extra.reason && TASK_MOVE_REASONS.includes(extra.reason) ? { reason: extra.reason } : {}), ...(extra.from ? { from: extra.from } : {}), ...(extra.capacity === true ? { capacity: true as const } : {}), ...(extra.conductorManual === true ? { conductorManual: true as const } : {}), ...(extra.withheld && (note || reviewFindings) ? { withheld: true as const } : {}), ...(reviewFindings ? { reviewFindings } : {}), ...(extra.reviewedHead ? { reviewedHead: extra.reviewedHead } : {}), ...(unmetCriteria?.length ? { unmetCriteria } : {}), ...(deliverables ? { deliverables } : {}), ...(extra.reviewContext === "fresh" || extra.reviewContext === "continuing" ? { reviewContext: extra.reviewContext } : {}), ...(extra.reviewScope === "code" ? { reviewScope: "code" as const } : {}), ...(extra.evidenceOnly === true && event === "changes_requested" ? { evidenceOnly: true as const } : {}), ...(extra.reviewCost ? { reviewCost: extra.reviewCost } : {}), ...(event === "done" && extra.noWrites === true ? { noWrites: true as const } : {}), ...(["check passed", "check failed"].includes(event) && typeof extra.checkKey === "string" && /^[a-z][a-z0-9_]{0,47}$/.test(extra.checkKey) ? { checkKey: extra.checkKey } : {}), ...(["check passed", "check failed"].includes(event) && Number.isSafeInteger(extra.missingKeys) && extra.missingKeys! >= 0 && extra.missingKeys! <= 100_000 ? { missingKeys: extra.missingKeys } : {}), ...(event === "claim verdict" && extra.claim ? { claim: extra.claim } : {}) }];
1421
1537
  this.taskWrite(() => {
1422
1538
  const result = this.db.query("UPDATE tasks SET state = ?, owner = ?, reviewer = ?, reserved = ?, refs = ?, plan = ?, rejections = ?, signals = ?, history = ?, sdlc = ?, task_role = ?, cancelled = ?, updated = ?, planner = ? WHERE id = ?")
1423
1539
  .run(next.state, next.owner, next.reviewer, next.reserved ?? null, JSON.stringify(next.refs), JSON.stringify(next.plan ?? {}), next.rejections, JSON.stringify(next.signals), JSON.stringify(history), next.sdlc ? JSON.stringify(next.sdlc) : null, next.role ?? null, next.cancelled ? JSON.stringify(next.cancelled) : null, Date.now(), planner, id);