@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
@@ -0,0 +1,112 @@
1
+ import { Database } from "bun:sqlite";
2
+ import { lstatSync, opendirSync, type Dirent, type Stats } from "node:fs";
3
+ import { isAbsolute, join, normalize } from "node:path";
4
+ import { processLiveness } from "../pi/process-signature.ts";
5
+ import { hubHome } from "./project.ts";
6
+ import { REGISTRY_DB_BUSY_TIMEOUT_MS } from "./registry.ts";
7
+ import { RUNTIME_PRUNE_INTERVAL_MS } from "./runtime-capture-prune.ts";
8
+
9
+ export interface RuntimePruneHealthCheck { ok: boolean | undefined; name: string; detail: string }
10
+ const MAX_LEDGER_BYTES = 1024 * 1024, MAX_OUTCOME_BYTES = 16 * 1024;
11
+ const MAX_SCAN_ENTRIES = 1024, MAX_QUARANTINES = 64, SCAN_MS = 250;
12
+ const VERSION = /^[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?$/;
13
+ const columns = "slot,id,admitted_at,phase,pid,outcome";
14
+ const issue = (detail: string, ok: boolean | undefined = undefined): RuntimePruneHealthCheck => ({ ok, name: "runtime pruning", detail });
15
+ const info = (file: string): Stats | undefined => {
16
+ try { return lstatSync(file); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; throw error; }
17
+ };
18
+ const safeFile = (file: string, stat: Stats, limit: number): void => {
19
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1 || (stat.mode & 0o077) !== 0 || stat.size > limit) throw new Error(`unsafe metadata: ${file}`);
20
+ };
21
+ interface Admission { slot: number; id: string; admitted_at: number; phase: string; pid: number | null; outcome: string | null; signature: string | null; outcome_ok: number; signature_ok: number }
22
+ interface LedgerHealth { checks: RuntimePruneHealthCheck[]; active: boolean | undefined }
23
+ function admissionHealth(file: string): LedgerHealth {
24
+ let db: Database | undefined;
25
+ try {
26
+ const before = info(file); if (!before) return { checks: [], active: false };
27
+ safeFile(file, before, MAX_LEDGER_BYTES);
28
+ for (const suffix of ["-wal", "-shm", "-journal"]) { const sidecar = info(`${file}${suffix}`); if (sidecar) safeFile(`${file}${suffix}`, sidecar, MAX_LEDGER_BYTES); }
29
+ db = new Database(file, { readonly: true, create: false });
30
+ db.run(`PRAGMA busy_timeout = ${REGISTRY_DB_BUSY_TIMEOUT_MS}`);
31
+ const tables = db.query("SELECT name,type FROM sqlite_master WHERE name NOT LIKE 'sqlite_%' LIMIT 2").all() as { name: string; type: string }[];
32
+ const shape = (db.query("PRAGMA table_info(admission)").all() as { name: string }[]).map(column => column.name).join(",");
33
+ if (tables.length !== 1 || tables[0]?.name !== "admission" || tables[0]?.type !== "table" || ![columns, `${columns},signature`].includes(shape)) throw new Error("unknown admission schema");
34
+ const signed = shape.endsWith(",signature");
35
+ const rows = db.query(`SELECT slot,id,admitted_at,phase,pid,CASE WHEN typeof(outcome)='text' AND length(CAST(outcome AS BLOB))<=${MAX_OUTCOME_BYTES} THEN outcome END AS outcome,
36
+ CASE WHEN outcome IS NULL OR (typeof(outcome)='text' AND length(CAST(outcome AS BLOB))<=${MAX_OUTCOME_BYTES}) THEN 1 ELSE 0 END AS outcome_ok,
37
+ ${signed ? "CASE WHEN typeof(signature)='text' AND length(CAST(signature AS BLOB))<=1024 THEN signature END" : "NULL"} AS signature,
38
+ ${signed ? "CASE WHEN signature IS NULL OR (typeof(signature)='text' AND length(CAST(signature AS BLOB))<=1024) THEN 1 ELSE 0 END" : "1"} AS signature_ok FROM admission LIMIT 2`).all() as Admission[];
39
+ const row = rows[0], after = info(file);
40
+ if (!after || after.dev !== before.dev || after.ino !== before.ino || after.size > MAX_LEDGER_BYTES) throw new Error("admission identity changed");
41
+ if (!rows.length) return { checks: [], active: false };
42
+ if (rows.length !== 1 || row?.slot !== 1 || row.outcome_ok !== 1 || row.signature_ok !== 1 || typeof row.id !== "string" || row.id.length !== 36 || !/^[a-f0-9-]{36}$/.test(row.id)
43
+ || !Number.isSafeInteger(row.admitted_at) || row.admitted_at < 0 || !["scheduled", "running", "complete"].includes(row.phase)
44
+ || row.pid !== null && (!Number.isSafeInteger(row.pid) || row.pid < 1)
45
+ || row.signature !== null && (!row.signature.trim() || Buffer.byteLength(row.signature) > 1024)) throw new Error("invalid admission row");
46
+ if (row.phase === "running") {
47
+ if (row.pid === null) throw new Error("running admission has no pid");
48
+ const liveness = processLiveness(row.pid, row.signature);
49
+ if (row.signature === null) return { active: undefined, checks: [issue(`legacy running admission has no signed worker identity${liveness === "gone" ? "; its PID is gone" : "; ownership is unknown"}; inspect ${file}`, liveness === "gone" ? false : undefined)] };
50
+ if (liveness === "live") return { active: true, checks: [issue(`signed maintenance worker ${row.pid} is running; ledger ${file}`, true)] };
51
+ return { active: liveness === "gone" ? false : undefined, checks: [issue(`maintenance worker is ${liveness === "gone" ? "gone; admission needs recovery" : "uncertain; ownership needs inspection"}; inspect ${file}`, liveness === "gone" ? false : undefined)] };
52
+ }
53
+ if (row.phase === "scheduled") {
54
+ if (row.pid !== null || row.signature !== null || row.outcome !== null) throw new Error("scheduled admission identity is inconsistent");
55
+ const age = Date.now() - row.admitted_at;
56
+ return { active: false, checks: age < 0 ? [issue(`scheduled admission timestamp is in the future; inspect ${file}`)]
57
+ : age >= RUNTIME_PRUNE_INTERVAL_MS ? [issue(`scheduled maintenance has not started within its admission interval; inspect ${file}`, false)] : [] };
58
+ }
59
+ if (typeof row.outcome !== "string") throw new Error("completed admission has no bounded outcome");
60
+ const outcome = JSON.parse(row.outcome) as { removed?: unknown; quarantined?: unknown; deferred?: unknown };
61
+ if (!outcome || typeof outcome !== "object" || Array.isArray(outcome) || Object.keys(outcome).some(key => !["removed", "quarantined", "deferred"].includes(key))
62
+ || !Number.isSafeInteger(outcome.removed) || (outcome.removed as number) < 0 || !Array.isArray(outcome.quarantined) || outcome.quarantined.length > MAX_QUARANTINES
63
+ || outcome.quarantined.some(value => typeof value !== "string" || value.length > 4096 || isAbsolute(value) || normalize(value) !== value || value.split(/[\\/]/).some(part => part === ".."))
64
+ || outcome.deferred !== undefined && (typeof outcome.deferred !== "string" || Buffer.byteLength(outcome.deferred) > 512)) throw new Error("invalid completed outcome");
65
+ if (outcome.deferred !== undefined || outcome.quarantined.length) {
66
+ const detail = typeof outcome.deferred === "string" ? `maintenance deferred: ${outcome.deferred.replace(/[\x00-\x1f\x7f]/g, " ")}; inspect ${file}` : `maintenance retained quarantine paths; inspect ${file}`;
67
+ return { active: false, checks: [issue(detail, false)] };
68
+ }
69
+ return { checks: [], active: false };
70
+ } catch { return { active: undefined, checks: [issue(`runtime maintenance admission is unreadable, unsafe or unknown; inspect ${file}`)] }; }
71
+ finally { try { db?.close(); } catch { /* Inspection never repairs or alters admission state. */ } }
72
+ }
73
+
74
+ /** Scan only the two release directory levels, never capture contents or a referenced/symlinked directory. */
75
+ function quarantineHealth(home: string, active: boolean | undefined): RuntimePruneHealthCheck[] {
76
+ const releases = join(home, "releases"), checks: RuntimePruneHealthCheck[] = [];
77
+ let remaining = MAX_SCAN_ENTRIES, found = 0;
78
+ const deadline = performance.now() + SCAN_MS;
79
+ const scan = (directory: string, visit: (entry: Dirent) => void) => {
80
+ const stat = info(directory); if (!stat) return;
81
+ if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error("unsafe release directory");
82
+ const stream = opendirSync(directory);
83
+ try { for (;;) { const entry = stream.readSync(); if (!entry) break; if (--remaining < 0 || performance.now() > deadline) throw new Error("release inventory bound exceeded"); visit(entry); } }
84
+ finally { stream.closeSync(); }
85
+ };
86
+ try {
87
+ scan(releases, version => {
88
+ if (VERSION.exec(version.name)?.[0] !== version.name) return;
89
+ const directory = join(releases, version.name);
90
+ if (!version.isDirectory() || version.isSymbolicLink()) throw new Error(`unsafe version directory: ${directory}`);
91
+ scan(directory, entry => {
92
+ if (!entry.name.startsWith(".runtime-prune-")) return;
93
+ if (++found > MAX_QUARANTINES) throw new Error("quarantine result bound exceeded");
94
+ const path = join(directory, entry.name), stat = info(path); if (!stat) return;
95
+ const unsafe = stat.isSymbolicLink() || !stat.isDirectory();
96
+ checks.push({ ok: undefined, name: "runtime quarantine", detail: `${unsafe ? "unsafe reserved quarantine path" : active === true ? "maintenance quarantine is in use" : "runtime quarantine remains"}; inspect ${path}` });
97
+ });
98
+ });
99
+ } catch { checks.push(issue(`runtime quarantine inventory is unreadable, unsafe or exceeds its bound; inspect ${releases}`)); }
100
+ return checks;
101
+ }
102
+
103
+ /** Read-only doctor diagnostics. An absent ledger and quarantine inventory preserves the previous empty result. */
104
+ export function runtimePruneHealth(home?: string): RuntimePruneHealthCheck[] {
105
+ try {
106
+ const target = home ?? hubHome(), stat = info(target);
107
+ if (!stat) return [];
108
+ if (!stat.isDirectory() || stat.isSymbolicLink()) return [issue(`runtime maintenance home is unsafe; inspect ${target}`)];
109
+ const ledger = admissionHealth(join(target, "runtime-prune.db"));
110
+ return [...ledger.checks, ...quarantineHealth(target, ledger.active)];
111
+ } catch { return [issue(`runtime maintenance diagnostics are unavailable; inspect ${home ?? "the agent-hub home"}`)]; }
112
+ }
@@ -1,5 +1,5 @@
1
- import { Database } from "bun:sqlite";
2
- import { HUB_DB_BUSY_TIMEOUT_MS } from "./db-timeout.ts";
1
+ import type { Database } from "./db-schema.ts";
2
+ import { openHubDatabase } from "./db-schema.ts";
3
3
  import { corpusPathProblem } from "./corpus-config.ts";
4
4
 
5
5
  // agent-hub #512 (hub task #330): machine-local schedules. Spec: "Schedules and folder triggers (#512)".
@@ -377,8 +377,7 @@ export interface ScheduleRow {
377
377
  export class ScheduleStore {
378
378
  private readonly db: Database;
379
379
  constructor(dbPath: string) {
380
- this.db = new Database(dbPath, { create: true });
381
- this.db.run(`PRAGMA busy_timeout = ${HUB_DB_BUSY_TIMEOUT_MS}`);
380
+ this.db = openHubDatabase(dbPath, { create: true });
382
381
  this.db.run("CREATE TABLE IF NOT EXISTS schedule_runs (id TEXT PRIMARY KEY, anchor INTEGER NOT NULL, last_run INTEGER, last_task INTEGER, coalesced INTEGER NOT NULL DEFAULT 0, last_error TEXT)");
383
382
  // #335: a person's pause. A column on the existing table, so an older database gains it and every older row reads as not paused.
384
383
  if (!(this.db.query("PRAGMA table_info(schedule_runs)").all() as { name: string }[]).some(column => column.name === "paused")) this.db.run("ALTER TABLE schedule_runs ADD COLUMN paused INTEGER NOT NULL DEFAULT 0");
@@ -144,6 +144,12 @@ export function localGates(input: { subject: DeliveryTask; state: string; taskSt
144
144
  const { subject, config } = input, gates: string[] = [];
145
145
  if (subject.private) return ["private task: a person merges"];
146
146
  if (input.state !== "approved") gates.push(`task not approved (state ${input.state})`);
147
+ const approved = subject.receipt?.reviewedHead;
148
+ if (subject.latestHead !== undefined && subject.latestHead !== approved) gates.push("latest reported head differs from approved head");
149
+ const binding = subject.approval;
150
+ if (binding && (!binding.current || binding.mismatch || !binding.head || binding.head !== approved
151
+ || subject.latestHead !== undefined && binding.head !== subject.latestHead
152
+ || binding.latestHead !== undefined && binding.head !== binding.latestHead)) gates.push("approval is not current at the latest reported head");
147
153
  for (const id of subject.after) {
148
154
  const state = input.taskState(id);
149
155
  if (state !== "approved") gates.push(`dependency task #${id} is not approved (${state ?? "unknown"})`);
@@ -206,6 +212,7 @@ export function dispatchGates(input: Pick<MergeInput, "subject" | "taskState"> &
206
212
  if (!now) return moved;
207
213
  const receipt = now.subject.receipt;
208
214
  if (receipt?.repo !== decision.repo || receipt?.pr !== decision.pr || receipt?.reviewedHead !== decision.head) return moved;
215
+ if (now.subject.latestHead !== input.subject.latestHead || JSON.stringify(now.subject.approval) !== JSON.stringify(input.subject.approval)) return moved;
209
216
  if (now.mode !== input.mode) return ["merge mode changed during the merge check: nothing was run"];
210
217
  if (JSON.stringify(requiredChecksFor(now.config, decision.repo)) !== JSON.stringify(requiredChecksFor(input.config, decision.repo))) return ["required checks changed during the merge check: nothing was run"];
211
218
  if (!now.allowed) return ["authority or hub availability changed during the merge check: nothing was run"];
@@ -4,7 +4,14 @@ import { evalGapBlockers, evalGateView, readEvalFor, type EvalGate } from "./eva
4
4
  import { findingsForHead, findingHead, findingTally, type ReviewFindingSet, type ReviewFindingTally } from "./review-findings.ts";
5
5
  import type { CriteriaView } from "./workflow.ts";
6
6
  import { parsePrEvidence, readSdlcPr, sdlcGh, sdlcStatus, type PrEvidence, type SdlcConfig, type SdlcGh, type SdlcReceipt } from "./sdlc.ts";
7
- export interface DeliveryTask { plan?: { paths?: string[]; revision: number; fingerprint: string; accepted: { repo: string; pr: number; head: string; revision: number; fingerprint: string; paths: string[] }[] }; findings?: readonly ReviewFindingSet[]; id: number; after: number[]; private: boolean; receipt?: SdlcReceipt; phase?: CriteriaView; }
7
+ export interface DeliveryTask {
8
+ /** Latest owner-reported artifact head; omitted by older library callers. */
9
+ latestHead?: string;
10
+ /** Host-derived verdict binding, never inferred from a mutable lifecycle receipt. */
11
+ approval?: { head?: string; latestHead?: string; mismatch: boolean; current: boolean };
12
+ plan?: { paths?: string[]; revision: number; fingerprint: string; accepted: { repo: string; pr: number; head: string; revision: number; fingerprint: string; paths: string[] }[] };
13
+ findings?: readonly ReviewFindingSet[]; id: number; after: number[]; private: boolean; receipt?: SdlcReceipt; phase?: CriteriaView;
14
+ }
8
15
  export interface MergeQueueRow { evalGate?: EvalGate; testIntegrity?: (TestIntegrity & { task?: number })[]; planConformance?: { task?: number; state: string; outside: number; untouched: number; accepted: boolean }[]; findings?: ReviewFindingTally; pr: number; tasks: number[]; head?: string; private?: true; ready: boolean; blockers: string[]; after: number[]; }
9
16
  export interface MergeConflict { left: number; right: number; files: number; order: "dependency" | "person_required"; }
10
17
  export interface MergeQueue { repo: string; rows: MergeQueueRow[]; order: number[]; unordered?: number[]; conflicts: MergeConflict[]; complete: boolean; truncated: boolean; }
@@ -94,6 +101,11 @@ export async function readMergeQueue(repo: string, config: SdlcConfig, tasks: De
94
101
  const integrity = rawIntegrity ? publicTestIntegrity(rawIntegrity, readableMetadata) : undefined;
95
102
  if (integrity) (row.testIntegrity ??= []).push({ task: holder!.id, ...integrity });
96
103
  row.blockers.push(...sdlcStatus(config, holder?.receipt, read, holder?.phase).blockers);
104
+ const approved = holder?.receipt?.reviewedHead, binding = holder?.approval;
105
+ if (holder?.latestHead !== undefined && holder.latestHead !== approved) row.blockers.push("latest reported head differs from approved head");
106
+ if (binding && (!binding.current || binding.mismatch || !binding.head || binding.head !== approved
107
+ || holder?.latestHead !== undefined && binding.head !== holder.latestHead
108
+ || binding.latestHead !== undefined && binding.head !== binding.latestHead)) row.blockers.push("approval is not current at the latest reported head");
97
109
  // #481 S5: the eval check itself is a required check inside sdlcStatus; its recorded pass rate and suite are read once per PR here.
98
110
  if (!row.evalGate) {
99
111
  const gate = evalGateView(config, read, await readEvalFor(config, read, repo, bounded));
@@ -1,6 +1,6 @@
1
1
  import { releaseTrackedClean } from "./release-worktree.ts";
2
2
  import { createHash, randomUUID } from "node:crypto";
3
- import { USER } from "./envelope.ts";
3
+ import { USER, type PeerId } from "./envelope.ts";
4
4
  import { hubGitEnv, hubGitSync } from "./git.ts";
5
5
  import { readDocEvidence, type DocEvidence, type ReleaseGit, type ReleaseReadiness } from "./sdlc-release.ts";
6
6
  import type { SdlcGh } from "./sdlc.ts";
@@ -13,13 +13,15 @@ const SHA = /^[0-9a-f]{40}$/;
13
13
  const VERSION = /^[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
14
14
  export const validReleaseVersion = (value: unknown): value is string => typeof value === "string" && value.length <= 128 && VERSION.test(value);
15
15
  export interface ReleaseDocsVerdict { fresh: true; errors: 0; documents: number; digest: string }
16
- export interface ReleaseAuthorization { schemaVersion: 1; id: string; by: typeof USER; commit: string; version: string; at: number; docs: ReleaseDocsVerdict }
16
+ export interface ReleaseAuthorization { schemaVersion: 1; id: string; by: PeerId; commit: string; version: string; at: number; docs: ReleaseDocsVerdict }
17
17
  export interface ReleaseAuthorizationPorts {
18
18
  git?: ReleaseGit;
19
19
  docs?: (root: string) => Promise<DocEvidence | undefined>;
20
20
  gh?: SdlcGh;
21
21
  now?: () => number;
22
22
  current?: () => boolean;
23
+ /** Host-only current eligible conductor; absent keeps non-person authorization refused. */
24
+ conductor?: () => PeerId | undefined;
23
25
  record: (receipt: ReleaseAuthorization) => boolean;
24
26
  }
25
27
  export type ReleaseGuardVerdict = { ok: true; receipts: ReleaseAuthorization[] } | { ok: false; error: string };
@@ -44,6 +46,9 @@ export class ReleaseAuthorizations {
44
46
  private latest?: Entry;
45
47
  constructor(private readonly root: string, private readonly ports: ReleaseAuthorizationPorts) {}
46
48
  private current(): void { if (this.ports.current?.() === false) throw new ReleaseAuthorizationError("release authorization unavailable during lifecycle transition"); }
49
+ private actor(by: PeerId): void {
50
+ if (by !== USER && (typeof by !== "string" || !by.trim() || this.ports.conductor?.() !== by)) throw new ReleaseAuthorizationError("only the person or the configured conductor may authorize a release");
51
+ }
47
52
  private git(): ReleaseGit {
48
53
  let remaining = 5000;
49
54
  return args => {
@@ -85,16 +90,17 @@ export class ReleaseAuthorizations {
85
90
  return value;
86
91
  }
87
92
  async authorize(by: string, version: unknown, expectedCommit: unknown): Promise<ReleaseAuthorization> {
88
- if (by !== USER) throw new ReleaseAuthorizationError("only the person may authorize a release");
93
+ this.actor(by);
89
94
  if (!validReleaseVersion(version) || typeof expectedCommit !== "string" || !SHA.test(expectedCommit)) throw new ReleaseAuthorizationError("authorization needs an exact version and candidate commit");
90
95
  this.current();
91
96
  const git = this.git(), commit = this.commit(git, "HEAD"), origin = this.origin(git);
92
97
  if (commit !== expectedCommit || this.version(git, commit) !== version) throw new ReleaseAuthorizationError("candidate commit or version differs from the person's request");
93
98
  const docs = docsVerdict(await (this.ports.docs ?? readDocEvidence)(this.root), commit, version);
94
99
  if (!docs) throw new ReleaseAuthorizationError("release authorization requires fresh committed documentation evidence");
95
- this.current();
100
+ this.current(); this.actor(by);
96
101
  if (this.commit(git, "HEAD") !== commit || this.version(git, commit) !== version || this.origin(git).hash !== origin.hash) throw new ReleaseAuthorizationError("release candidate changed during authorization");
97
- const receipt: ReleaseAuthorization = { schemaVersion: 1, id: randomUUID(), by: USER, commit, version, at: (this.ports.now ?? Date.now)(), docs };
102
+ this.current(); this.actor(by);
103
+ const receipt: ReleaseAuthorization = { schemaVersion: 1, id: randomUUID(), by, commit, version, at: (this.ports.now ?? Date.now)(), docs };
98
104
  if (!this.ports.record(structuredClone(receipt))) throw new ReleaseAuthorizationError("release approval audit could not be recorded");
99
105
  const entry = { receipt, origin };
100
106
  this.entries.set(`${version}:${commit}`, entry); this.latest = entry;
@@ -9,7 +9,7 @@
9
9
 
10
10
  /** The task ops a distinct session may run: reads that need no role. Everything else is refused with `sessionRefusal`. */
11
11
  export const SESSION_READ_OPS: ReadonlySet<string> = new Set([
12
- "hub_task_show", "hub_task_list", "hub_status", "hub_capabilities", "hub_study_show", "hub_backlog_list", "hub_release_list",
12
+ "hub_task_show", "hub_task_list", "hub_status", "hub_capabilities", "hub_study_show", "hub_experiment_show", "hub_backlog_list", "hub_release_list",
13
13
  ]);
14
14
 
15
15
  /** The control messages a distinct session may send besides task ops. `task` is gated again by `SESSION_READ_OPS`. */
@@ -12,6 +12,7 @@ import { CLASSES, type TaskClass } from "./board.ts";
12
12
  import { configTracked, configRepositoryPresent } from "./config-trust.ts";
13
13
  import { PERMISSION_MODES, permissionBoundary } from "./permission-mode.ts";
14
14
  import { START_MODES } from "./start-mode.ts";
15
+ import { checkStartAgents } from "./start-agents.ts";
15
16
  import { OVERLAY_FILE, overlayToml, parseOverlay, parseRouting, routingText, type Routing, type RoutingOverlay } from "./routing.ts";
16
17
 
17
18
  /**
@@ -74,6 +75,7 @@ export const SETTINGS: readonly SettingDef[] = [
74
75
  ...ACTION_CLASSES.map((kind): SettingDef => ({ key: `action_policy.budgets.${kind}`, group: "Permissions", label: `${kind}: approved session budget`, type: "integer", min: 0, max: 1000, store: "config", applies: "live", risk: "raises", floor: 0, path: ["action_policy", "budgets", kind] })),
75
76
  ...SETTING_PEERS.filter(peer => !definitionOf(peer)?.permission.unmanaged).map((peer): SettingDef => ({ key: `permission.${peer}`, group: "Permissions", label: `${peer}: mode now`, type: "enum", values: PERMISSION_MODES, store: "runtime", applies: "live", risk: "raises", floor: "ask", path: [peer] })),
76
77
  ...SETTING_PEERS.filter(peer => !definitionOf(peer)?.permission.unmanaged).map((peer): SettingDef => ({ key: `permission_modes.${peer}`, group: "Permissions", label: `${peer}: mode at next peer start`, type: "enum", values: PERMISSION_MODES, store: "config", applies: "next use", risk: "raises", floor: "ask", path: ["permission_modes", peer] })),
78
+ { key: "start.agents", group: "Start", label: "Agents ahub up starts (none for an empty list)", type: "peers", store: "config", applies: "next use", risk: "raises", path: ["start", "agents"] },
77
79
  ...START_MODE_PEERS.map((peer): SettingDef => ({ key: `peers.${peer}.start_mode`, group: "Start", label: `${peer}: start mode (tui unless set)`, type: "enum", values: START_MODES, store: "config", applies: "next use", risk: "raises", floor: "tui", path: ["peers", peer, "start_mode"] })),
78
80
  ...ADAPTERS.flatMap(adapter => adapter.configuration?.settings ?? []),
79
81
  { key: "routing.stay_switch", group: "Routing", label: "Stay or switch (hub/auto and stage routes)", type: "enum", values: ["off", "shadow", "enforce"], store: "routing", applies: "live", risk: "raises", path: ["stay_switch"] },
@@ -203,6 +205,7 @@ export function checkSettingValue(def: SettingDef, raw: unknown, routes: readonl
203
205
  }
204
206
  return peers.length === 1 ? peers[0] as string : [...peers] as string[];
205
207
  }
208
+ if (def.key === "start.agents") return checkStartAgents(raw, def.key);
206
209
  if (!Array.isArray(raw) || raw.length > PEERS_MAX || raw.some((p) => typeof p !== "string" || !PEER.test(p)) || new Set(raw).size !== raw.length) throw new Error(`${def.key} takes up to ${PEERS_MAX} distinct peer ids`);
207
210
  return [...(raw as string[])];
208
211
  }
@@ -217,6 +220,7 @@ export function parseSettingText(def: SettingDef, text: string): unknown {
217
220
  if (def.type === "number") return text.trim() && Number.isFinite(Number(text)) ? Number(text) : text;
218
221
  if (def.type === "integer") return /^(0|[1-9]\d*)$/.test(text) ? Number(text) : text;
219
222
  if (def.type === "boolean") return text === "true" ? true : text === "false" ? false : text;
223
+ if (def.key === "start.agents" && text.trim() === "none") return [];
220
224
  if (def.type === "peers" || def.type === "roles") return text.split(",").map((p) => p.trim()).filter(Boolean);
221
225
  return text;
222
226
  }
@@ -1,4 +1,5 @@
1
- import { Database } from "bun:sqlite";
1
+ import type { Database } from "./db-schema.ts";
2
+ import { openHubDatabase } from "./db-schema.ts";
2
3
  import { closeSync, constants, copyFileSync, existsSync, fstatSync, lstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, readSync, readdirSync, rmSync, statSync, utimesSync, writeFileSync, type Stats } from "node:fs";
3
4
  import { createHash } from "node:crypto";
4
5
  import { tmpdir } from "node:os";
@@ -7,7 +8,6 @@ import { childEnv } from "./child-process.ts";
7
8
  import type { Touch } from "./conflicts.ts";
8
9
  import { hubGitSync } from "./git.ts";
9
10
  import { realPath } from "./project.ts";
10
- import { HUB_DB_BUSY_TIMEOUT_MS } from "./db-timeout.ts";
11
11
 
12
12
  /**
13
13
  * Per-turn workspace snapshots (issue #33): git tree objects written through a copy of the index, so the user's index
@@ -657,8 +657,7 @@ export function submodulePaths(repo: Repo): string[] {
657
657
  export class Turns {
658
658
  private readonly db: Database;
659
659
  constructor(path: string, readonly?: boolean) {
660
- this.db = new Database(path, readonly ? { readonly: true } : { create: true });
661
- this.db.run(`PRAGMA busy_timeout = ${HUB_DB_BUSY_TIMEOUT_MS}`);
660
+ this.db = openHubDatabase(path, readonly ? { readonly: true } : { create: true });
662
661
  if (readonly) return;
663
662
  this.db.run("PRAGMA journal_mode = WAL");
664
663
  this.db.run(`CREATE TABLE IF NOT EXISTS turns (id TEXT PRIMARY KEY, peer TEXT NOT NULL, started INTEGER NOT NULL, ended INTEGER,
@@ -0,0 +1,44 @@
1
+ import { SUPPORTED_PEERS, definitionOf } from "../adapters/registry.ts";
2
+
3
+ /**
4
+ * #608 (agent-hub#1040): the agents `ahub up` asks the hub to start, `start.agents`. Pure and free of file-system
5
+ * imports: the config schema (`config-apply.ts`) reaches the Claude channel bundle. The list is read by the CLI at a
6
+ * person's `ahub up` and by the dashboard manager's start; the daemon never reads it, so a restart, a crash recovery,
7
+ * a reload or an upgrade relaunch starts nothing from it.
8
+ *
9
+ * Which kinds may be listed, which are in the default set and why a kind is refused all come from the kind's own
10
+ * `AdapterDefinition.start.upStart` (#451): nothing here names a peer.
11
+ */
12
+ const upStart = (id: string) => definitionOf(id)?.start.upStart;
13
+
14
+ /** The kinds in the default set, in the registry's order. */
15
+ export const START_AGENTS_DEFAULT: readonly string[] = Object.freeze(SUPPORTED_PEERS.filter(peer => { const s = upStart(peer); return s?.allowed === true && s.default === true; }));
16
+ export const START_AGENTS_MAX = 8;
17
+ const PEER = /^[a-z][a-z0-9-]{0,31}$/;
18
+
19
+ /** The kinds `ahub up` may start. A declared instance, a channel extra and an unknown id are never in it. */
20
+ export const START_AGENTS_ALLOWED: readonly string[] = Object.freeze(SUPPORTED_PEERS.filter(peer => definitionOf(peer)?.start.conductorStartable === true && upStart(peer)?.allowed === true));
21
+
22
+ /** Why `id` cannot be in the start set, or undefined. The reason never repeats the id unless it is a well-formed peer id. */
23
+ export function startAgentRefusal(id: unknown): string | undefined {
24
+ if (typeof id !== "string" || !PEER.test(id)) return "an entry is not a peer id (lowercase letters, digits and dashes)";
25
+ if (START_AGENTS_ALLOWED.includes(id)) return undefined;
26
+ const own = upStart(id);
27
+ if (own?.allowed === false) return `${id} cannot be started by ahub up: ${own.refusal}`;
28
+ if (SUPPORTED_PEERS.includes(id)) return `${id} cannot be started by ahub up: the hub does not start it; run ahub ${id} in a terminal`;
29
+ return `${id} cannot be started by ahub up: it is a declared instance, a channel extra or unknown, and only ${START_AGENTS_ALLOWED.join(", ")} are started by the hub`;
30
+ }
31
+
32
+ /** The checked list (a copy), or a thrown reason. An empty list is allowed and starts nothing. */
33
+ export function checkStartAgents(raw: unknown, key = "start.agents"): string[] {
34
+ if (!Array.isArray(raw) || raw.length > START_AGENTS_MAX) throw new Error(`${key} takes a list of up to ${START_AGENTS_MAX} peer ids (an empty list starts none)`);
35
+ for (const id of raw) { const why = startAgentRefusal(id); if (why) throw new Error(`${key}: ${why}`); }
36
+ if (new Set(raw).size !== raw.length) throw new Error(`${key} takes distinct peer ids`);
37
+ return [...raw] as string[];
38
+ }
39
+
40
+ /** A person's text for the list: commas or spaces between ids, `none` for an empty list. */
41
+ export function parseStartAgents(text: string): string[] {
42
+ const words = text.split(/[\s,]+/).filter(Boolean);
43
+ return checkStartAgents(words.length === 1 && words[0] === "none" ? [] : words);
44
+ }
@@ -0,0 +1,23 @@
1
+ import type { Task } from "./board.ts";
2
+
3
+ export interface ApprovalBinding { head?: string; latestHead?: string; mismatch: boolean; current: boolean }
4
+ const fullHead = (value: unknown): string | undefined => typeof value === "string" && /^[a-f0-9]{40}$/.test(value) ? value : undefined;
5
+
6
+ /** The last approval remains historical evidence after a new delivery or a withdrawal. Unknown legacy heads stay unknown. */
7
+ export function approvalBinding(task: Task): ApprovalBinding | undefined {
8
+ const verdict = task.history.findLast(entry => entry.event === "approved");
9
+ if (!verdict) return undefined;
10
+ const head = fullHead(verdict.reviewedHead) ?? fullHead(verdict.reviewFindings?.head)
11
+ ?? (task.state === "approved" ? fullHead(task.sdlc?.reviewedHead) : undefined);
12
+ const latestHead = fullHead(task.refs.commit ?? task.sdlc?.head ?? task.sdlc?.localHead);
13
+ const mismatch = !!head && !!latestHead && head !== latestHead;
14
+ return { ...(head ? { head } : {}), ...(latestHead ? { latestHead } : {}), mismatch,
15
+ current: task.state === "approved" && !!head && head === latestHead };
16
+ }
17
+
18
+ export function approvalText(binding: ApprovalBinding | undefined): string {
19
+ if (!binding) return "";
20
+ return `Approval bound to head ${binding.head ?? "unknown"}; latest reported head ${binding.latestHead ?? "unknown"}. ${binding.mismatch
21
+ ? "HEAD MISMATCH: earlier approval does not approve the latest reported head."
22
+ : binding.current ? "Approval is current at this head." : "Earlier approval is not current; review is required."}`;
23
+ }