@staix/agent-hub 0.25.0 → 0.27.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 (87) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +12 -6
  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 +7 -5
  6. package/docs/agent-notes/tasks.md +7 -6
  7. package/docs/agent-notes/tests.md +1 -0
  8. package/docs/dogfood.md +7 -1
  9. package/docs/operations.md +160 -17
  10. package/docs/profiles.md +1 -0
  11. package/docs/quickstart.md +18 -4
  12. package/docs/sdlc.md +3 -3
  13. package/docs/security.md +52 -9
  14. package/docs/specs/2026-09-19-agent-hub-design.md +1 -1
  15. package/docs/specs/2026-11-01-research-campaigns-design.md +21 -7
  16. package/docs/verification/2026-11-01-0.26.0.md +54 -0
  17. package/docs/verification/2026-11-01-0.27.0.md +49 -0
  18. package/docs/verified.json +28 -16
  19. package/package.json +1 -1
  20. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  21. package/plugins/agent-hub/server.js +190 -101
  22. package/src/adapters/channel-peer.ts +4 -2
  23. package/src/adapters/definitions/builtin-metadata.ts +6 -6
  24. package/src/adapters/definitions/qwen.ts +2 -0
  25. package/src/adapters/registry-types.ts +2 -0
  26. package/src/adapters/registry.ts +1 -0
  27. package/src/cli/conductor-recovery.ts +3 -2
  28. package/src/cli/help.ts +9 -8
  29. package/src/cli/identity.ts +5 -5
  30. package/src/cli/main.ts +68 -24
  31. package/src/cli/recovery-package.ts +94 -4
  32. package/src/cli/reset.ts +14 -4
  33. package/src/cli/runtime-version-warning.ts +28 -0
  34. package/src/cli/start-agents-run.ts +125 -0
  35. package/src/cli/study-output.ts +56 -0
  36. package/src/cli/study.ts +26 -3
  37. package/src/cli/task-output.ts +5 -0
  38. package/src/cli/upgrade-interactive.ts +4 -4
  39. package/src/cli/upgrade-runtime.ts +105 -57
  40. package/src/cli/upgrade.ts +90 -28
  41. package/src/hub/action-policy.ts +5 -3
  42. package/src/hub/autonomy-defaults.ts +32 -7
  43. package/src/hub/board.ts +129 -13
  44. package/src/hub/budget.ts +3 -4
  45. package/src/hub/child-process.ts +4 -3
  46. package/src/hub/conductor.ts +3 -4
  47. package/src/hub/config-apply.ts +13 -2
  48. package/src/hub/config-edit-host.ts +1 -0
  49. package/src/hub/config-tier.ts +3 -0
  50. package/src/hub/config-trust.ts +10 -2
  51. package/src/hub/control-client.ts +30 -10
  52. package/src/hub/daemon.ts +103 -26
  53. package/src/hub/db-schema.ts +143 -0
  54. package/src/hub/delivery-journal.ts +3 -4
  55. package/src/hub/events.ts +2 -1
  56. package/src/hub/execution-budget.ts +3 -4
  57. package/src/hub/folder-trigger.ts +3 -4
  58. package/src/hub/hub-tools.ts +7 -5
  59. package/src/hub/invocations.ts +3 -4
  60. package/src/hub/lifecycle.ts +87 -29
  61. package/src/hub/manager.ts +49 -33
  62. package/src/hub/no-writes.ts +1 -1
  63. package/src/hub/orca-env.ts +19 -0
  64. package/src/hub/project-runtime-state.ts +164 -0
  65. package/src/hub/project-runtime.ts +62 -0
  66. package/src/hub/protocol-window.ts +19 -0
  67. package/src/hub/recovery-store.ts +102 -12
  68. package/src/hub/registry.ts +5 -0
  69. package/src/hub/research-experiments.ts +33 -4
  70. package/src/hub/research-runner.ts +172 -0
  71. package/src/hub/restart.ts +9 -0
  72. package/src/hub/runtime-capture-prune.ts +415 -0
  73. package/src/hub/runtime-prune-health.ts +112 -0
  74. package/src/hub/schedules.ts +3 -4
  75. package/src/hub/sdlc-merge.ts +7 -0
  76. package/src/hub/sdlc-queue.ts +13 -1
  77. package/src/hub/sdlc-release-authorization.ts +11 -5
  78. package/src/hub/session-identity.ts +1 -1
  79. package/src/hub/settings.ts +4 -0
  80. package/src/hub/snapshots.ts +3 -4
  81. package/src/hub/start-agents.ts +44 -0
  82. package/src/hub/task-approval.ts +23 -0
  83. package/src/hub/tasks.ts +133 -34
  84. package/src/hub/usage-rollup.ts +3 -4
  85. package/src/pi/tool-receipts.ts +3 -4
  86. package/templates/presets/confirmation-matrix.json +5 -5
  87. package/templates/release-guard.block.md +2 -1
@@ -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
  }
@@ -37,13 +37,15 @@ export function askedClasses(config: ActionPolicyConfig): ActionClass[] {
37
37
  return ACTION_CLASSES.filter(key => config.always_ask.includes(key) || config.floor === true && FLOOR_ASKED.includes(key));
38
38
  }
39
39
 
40
+ const RELEASE_FLOOR_GUARD = "release only through ahub sdlc authorize-release and the OIDC tag workflow; consent is recorded by the person or the unique configured conductor, which acts only on the person's explicit release instruction in its own session (hub/task/review/file/peer relays never qualify)";
41
+
40
42
  /**
41
43
  * The structural guards the floor rests on where the hub cannot see a call first (Codex in never-ask, Kimi in auto, Jcode: #999 T3). The hub names
42
44
  * them and cannot verify them from here, so a status row says "not verified", never that they hold. A guard that is missing is the person's to report.
43
45
  */
44
46
  export const FLOOR_GUARDS: readonly { readonly asks: string; readonly guard: string }[] = Object.freeze([
45
47
  { asks: "force push, a push to the default branch or of tags", guard: "GitHub branch protection on the default branch" },
46
- { asks: "gh release and a release tag", guard: "release only through ahub sdlc authorize-release and the OIDC tag workflow" },
48
+ { asks: "gh release and a release tag", guard: RELEASE_FLOOR_GUARD },
47
49
  { asks: "deletion outside the project, a write to another repository", guard: "the peer's native sandbox" },
48
50
  ]);
49
51
  export const floorGuardText = (): string => FLOOR_GUARDS.map(entry => `${entry.asks}: ${entry.guard}`).join("; ");
@@ -77,8 +79,8 @@ export function floorReads(calls: number, ms: number, now: () => number = Date.n
77
79
  export const FLOOR_CLASS_TABLE: Readonly<Record<string, { readonly action: ActionClass; readonly asks: string; readonly guard: string | null }>> = Object.freeze({
78
80
  "force-push": { action: "publish", asks: "a force or delete push", guard: "GitHub branch protection on the default branch" },
79
81
  "default-branch-push": { action: "publish", asks: "a push to the default branch", guard: "GitHub branch protection on the default branch" },
80
- "tag-push": { action: "publish", asks: "a push of a tag", guard: "release only through ahub sdlc authorize-release and the OIDC tag workflow" },
81
- "release-publish": { action: "publish", asks: "gh release", guard: "release only through ahub sdlc authorize-release and the OIDC tag workflow" },
82
+ "tag-push": { action: "publish", asks: "a push of a tag", guard: RELEASE_FLOOR_GUARD },
83
+ "release-publish": { action: "publish", asks: "gh release", guard: RELEASE_FLOOR_GUARD },
82
84
  "package-publish": { action: "publish", asks: "npm, bun, pnpm, yarn or cargo publish", guard: null },
83
85
  "destroy-outside-root": { action: "destroy", asks: "deletion outside the execution root", guard: "the peer's native sandbox" },
84
86
  "foreign-repo-write": { action: "external-write", asks: "a write to another repository", guard: "the peer's native sandbox" },
@@ -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();