@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
@@ -1,9 +1,10 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { createHash, randomUUID } from "node:crypto";
3
3
  import { cpSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
4
- import { dirname, join, resolve } from "node:path";
4
+ import { basename, dirname, join, resolve } from "node:path";
5
5
  import { createRequire } from "node:module";
6
6
  import { hubHome, realPath } from "../hub/project.ts";
7
+ import { Registry } from "../hub/registry.ts";
7
8
 
8
9
  export type CommandResult = { code: number; stdout: string; stderr: string };
9
10
  export type RunCommand = (args: string[], options?: { cwd?: string; env?: NodeJS.ProcessEnv; timeoutMs?: number }) => Promise<CommandResult>;
@@ -109,7 +110,11 @@ export function verifyPackage(root: string, version: string, options: { ownDepen
109
110
  export function recoveryPackageOptions(kind: "restart" | "upgrade", root: string): { ownDependencies?: boolean; dependencyRoot?: string } {
110
111
  if (kind === "restart") return { ownDependencies: true };
111
112
  if (kind !== "upgrade") throw new Error("unsupported recovery package kind");
112
- const canonical = realPath(root), dependencyRoot = resolve(canonical, "../../..");
113
+ const canonical = realPath(root);
114
+ // Project captures own the exact dependency and keep the registry install separate below this version.
115
+ if (canonical === root && /^source-[a-f0-9]{64}$/.test(basename(canonical)) && /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(basename(dirname(canonical)))
116
+ && basename(dirname(dirname(canonical))) === "releases") return { ownDependencies: true };
117
+ const dependencyRoot = resolve(canonical, "../../..");
113
118
  if (canonical !== root || canonical !== join(dependencyRoot, "node_modules", "@staix", "agent-hub")) throw new Error("invalid staged recovery package path");
114
119
  return { dependencyRoot };
115
120
  }
@@ -122,6 +127,82 @@ export function verifyRecoveryTarget(kind: "restart" | "upgrade", root: string,
122
127
  return verifyPackage(root, version, recoveryPackageOptions(kind, root));
123
128
  }
124
129
 
130
+ const RUNTIME_ASSETS = ["src", "plugins", "templates", ".claude-plugin", "package.json", "README.md", "LICENSE", "CHANGELOG.md"] as const;
131
+ function runtimeDirectory(directory: string): void {
132
+ let info;
133
+ try { info = lstatSync(directory); }
134
+ catch (error) {
135
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
136
+ try { mkdirSync(directory, { mode: 0o700 }); }
137
+ catch (error) { if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; }
138
+ info = lstatSync(directory);
139
+ }
140
+ if (info.isSymbolicLink() || !info.isDirectory()) throw new Error("runtime cache must contain real directories");
141
+ }
142
+
143
+ /** Preserve the exact bounded asset set; legacy coordinators need not have a plugin identity manifest. */
144
+ function preserveRuntimeAssets(sourceRoot: string, destination: string, expectedDigest: string, version?: string): { root: string; digest: string } {
145
+ const digest = (root: string, ownDependencies = false) => version === undefined
146
+ ? packageDigest(root, { ownDependencies }) : verifyPackage(root, version, { ownDependencies });
147
+ if (digest(sourceRoot) !== expectedDigest) throw new Error("source package changed before preservation");
148
+ runtimeDirectory(dirname(destination));
149
+ const cached = () => {
150
+ let info;
151
+ try { info = lstatSync(destination); }
152
+ catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; throw error; }
153
+ if (info.isSymbolicLink() || !info.isDirectory() || digest(destination, true) !== expectedDigest)
154
+ throw new Error(version === undefined ? "preserved source package changed" : "preserved runtime changed; refusing it");
155
+ return { root: realPath(destination), digest: expectedDigest };
156
+ };
157
+ const previous = cached(); if (previous) return previous;
158
+ const temporary = join(dirname(destination), `.source-${randomUUID()}`);
159
+ mkdirSync(temporary, { mode: 0o700 });
160
+ try {
161
+ for (const name of RUNTIME_ASSETS) cpSync(join(sourceRoot, name), join(temporary, name), { recursive: true, dereference: false });
162
+ preserveRuntimeDependencies(sourceRoot, temporary);
163
+ if (digest(temporary, true) !== expectedDigest || digest(sourceRoot) !== expectedDigest)
164
+ throw new Error("source package changed while being preserved");
165
+ try { renameSync(temporary, destination); }
166
+ catch (error) {
167
+ if (!["EEXIST", "ENOTEMPTY"].includes((error as NodeJS.ErrnoException).code ?? "")) throw error;
168
+ // A concurrent publisher may have won. Validate its complete bytes; never remove its directory.
169
+ const winner = cached(); if (winner) return winner;
170
+ throw error;
171
+ }
172
+ const published = cached();
173
+ if (!published) throw new Error("preserved runtime vanished after publication");
174
+ return published;
175
+ } finally { rmSync(temporary, { recursive: true, force: true }); }
176
+ }
177
+
178
+ /** Capture a verified package identity and its exact first-party dependency, never a node_modules tree. */
179
+ export function preserveRuntimePackage(sourceRoot: string, destination: string, expectedDigest: string): { root: string; digest: string; version: string } {
180
+ const version = exactVersion(JSON.parse(readFileSync(join(sourceRoot, "package.json"), "utf8")).version);
181
+ return { ...preserveRuntimeAssets(sourceRoot, destination, expectedDigest, version), version };
182
+ }
183
+
184
+ /** Retained legacy coordinators keep their digest-only contract and still require their own exact dependency. */
185
+ export function preserveRuntimeSource(sourceRoot: string, destination: string, expectedDigest: string): string {
186
+ preserveRuntimeAssets(sourceRoot, destination, expectedDigest);
187
+ return destination; // Legacy receipts retain the path spelling their coordinator already bound.
188
+ }
189
+
190
+ /** Ordinary startup preserves the invoking package, including unpublished builds, below its version namespace. */
191
+ export function stageCurrentRuntime(sourceRoot: string, version: string, home = hubHome(), registry?: Registry): { root: string; digest: string; version: string } {
192
+ exactVersion(version);
193
+ const digest = verifyPackage(sourceRoot, version);
194
+ const owner = registry ?? new Registry(join(home, "registry.db"));
195
+ try {
196
+ if (realPath(owner.path) !== realPath(join(home, "registry.db"))) throw new Error("runtime capture registry belongs to another home");
197
+ return owner.runtimeCaptureLocked(() => {
198
+ mkdirSync(home, { recursive: true, mode: 0o700 });
199
+ const releases = join(realPath(home), "releases"); runtimeDirectory(releases);
200
+ const versionRoot = join(releases, version); runtimeDirectory(versionRoot);
201
+ return preserveRuntimePackage(sourceRoot, join(versionRoot, `source-${digest}`), digest);
202
+ });
203
+ } finally { if (!registry) owner.close(); }
204
+ }
205
+
125
206
  export async function registryRelease(version: string, run: RunCommand = runCommand): Promise<{ version: string; integrity: string }> {
126
207
  exactVersion(version);
127
208
  const result = await run(["npm", "view", `@staix/agent-hub@${version}`, "version", "dist.integrity", "--json"], { timeoutMs: 30_000 });
@@ -151,10 +232,18 @@ export function newerVersion(a: string, b: string): boolean {
151
232
  }
152
233
 
153
234
  /** Stage outside the mutable global install; retain successful versions for recovery. */
154
- export async function stageRelease(version: string, integrity: string, run: RunCommand = runCommand, home = hubHome()): Promise<{ root: string; digest: string }> {
235
+ export async function stageRelease(version: string, integrity: string, run: RunCommand = runCommand, home = hubHome(), project = false): Promise<{ root: string; digest: string }> {
155
236
  exactVersion(version);
156
237
  const base = join(home, "releases");
157
- const destination = join(base, version);
238
+ const versionRoot = join(base, version);
239
+ // Ordinary startup can create this parent before an unstaged legacy receipt resumes.
240
+ // An actual legacy install (even a damaged one) must retain its original path and validation.
241
+ if (!project && existsSync(versionRoot) && !existsSync(join(versionRoot, "verified.json")) && !existsSync(join(versionRoot, "node_modules"))) {
242
+ runtimeDirectory(versionRoot);
243
+ if (!readdirSync(versionRoot).every(name => name === "registry" || /^source-[a-f0-9]{64}$/.test(name))) throw new Error("cached release state is unknown; refusing it");
244
+ project = true;
245
+ }
246
+ const destination = project ? join(versionRoot, "registry") : versionRoot;
158
247
  const root = join(destination, "node_modules/@staix/agent-hub");
159
248
  if (existsSync(destination)) {
160
249
  const saved = JSON.parse(readFileSync(join(destination, "verified.json"), "utf8"));
@@ -163,6 +252,7 @@ export async function stageRelease(version: string, integrity: string, run: RunC
163
252
  return { root, digest };
164
253
  }
165
254
  mkdirSync(base, { recursive: true, mode: 0o700 });
255
+ if (project) runtimeDirectory(join(base, version));
166
256
  const temporary = join(base, `.stage-${randomUUID()}`);
167
257
  mkdirSync(temporary, { mode: 0o700 });
168
258
  try {
package/src/cli/reset.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { constants, Database } from "bun:sqlite";
2
+ import { assertHubDbCompatible, openHubDatabase } from "../hub/db-schema.ts";
2
3
  import { randomUUID } from "node:crypto";
3
4
  import { chmodSync, copyFileSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
4
5
  import { join } from "node:path";
@@ -50,21 +51,30 @@ const isEnvelope = (v: unknown) => isObject(v) && typeof v.id === "string";
50
51
  /** Valid JSON of the wrong shape is damage as well: the journal refuses it on every open, so the message says so. */
51
52
  const shaped = (ok: boolean, what: string) => { if (!ok) throw new Error(`invalid delivery journal: ${what} has the wrong shape`); };
52
53
 
54
+ /** The existing quiet archive preview owns its raw URI handle and sets its one bounded timeout at open. */
55
+ function quietResetDatabase(file: string): Database {
56
+ const db = new Database(`file:${file.replace(/[%?#]/g, (c) => `%${c.charCodeAt(0).toString(16)}`)}?immutable=1`, constants.SQLITE_OPEN_READONLY | constants.SQLITE_OPEN_URI);
57
+ try {
58
+ db.run(`PRAGMA busy_timeout = ${HUB_DB_BUSY_TIMEOUT_MS}`);
59
+ return db;
60
+ } catch (error) { db.close(); throw error; }
61
+ }
62
+
53
63
  /** What a reset would act on, read from the state directory without changing it (hub.db opened read-only). */
54
64
  export function planReset(stateDir: string, projectId: string): ResetPlan {
55
65
  const plan: ResetPlan = { queued: [], needsReview: [], inFlight: [], manualHolds: [], budgetPauses: [], conductorHolds: [],
56
66
  sessionPointers: sessionPointers(stateDir).filter((name) => existsSync(join(stateDir, name))), entries: existsSync(stateDir) ? readdirSync(stateDir).length : 0 };
57
67
  const file = join(stateDir, "hub.db");
58
68
  if (!existsSync(file)) return plan;
69
+ assertHubDbCompatible(file); // A normal readonly guard sees WAL metadata before the quiet preview may use immutable.
59
70
  // A read-only open of a WAL database creates hub.db-wal and -shm when the last writer's close removed them (Linux),
60
71
  // and fails without them on macOS. Without them no writer is mid-transaction, so `immutable` reads hub.db alone and
61
72
  // writes nothing. ponytail: a -wal left without its -shm (deleted by hand) is ignored then, and commits still in
62
73
  // it go unlisted; open plainly in that corner if it ever matters. SQLITE_OPEN_URI is passed because Linux builds do
63
74
  // not parse `file:` names by default (the name then fails as a plain path).
64
75
  const quiet = !existsSync(`${file}-wal`) || !existsSync(`${file}-shm`);
65
- const db = quiet ? new Database(`file:${file.replace(/[%?#]/g, (c) => `%${c.charCodeAt(0).toString(16)}`)}?immutable=1`, constants.SQLITE_OPEN_READONLY | constants.SQLITE_OPEN_URI) : new Database(file, { readonly: true });
76
+ const db = quiet ? quietResetDatabase(file) : openHubDatabase(file, { readonly: true });
66
77
  try {
67
- db.run(`PRAGMA busy_timeout = ${HUB_DB_BUSY_TIMEOUT_MS}`);
68
78
  const has = (table: string) => !!db.query("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(table);
69
79
  const peers = (table: string) => has(table) ? (db.query(`SELECT peer FROM ${table} ORDER BY peer`).all() as { peer: string }[]).map((row) => row.peer) : [];
70
80
  plan.budgetPauses = peers("budget_pauses");
@@ -120,6 +130,7 @@ export function resetLines(plan: ResetPlan): string[] {
120
130
  */
121
131
  export function resetRuntime(stateDir: string, project: Project): { settled: string[]; plan: ResetPlan; stale: string[] } {
122
132
  const file = join(stateDir, "hub.db");
133
+ assertHubDbCompatible(file); // Refuse before journal settlement or any direct runtime-state mutation.
123
134
  const plan = planReset(stateDir, project.id);
124
135
  const settled: string[] = [];
125
136
  if (existsSync(file)) {
@@ -135,9 +146,8 @@ export function resetRuntime(stateDir: string, project: Project): { settled: str
135
146
  }
136
147
  bus.setManualPaused([]);
137
148
  } finally { bus.closeJournal(); }
138
- const db = new Database(file);
149
+ const db = openHubDatabase(file);
139
150
  try {
140
- db.run(`PRAGMA busy_timeout = ${HUB_DB_BUSY_TIMEOUT_MS}`);
141
151
  db.transaction(() => {
142
152
  for (const table of ["budget_pauses", "conductor_holds"]) {
143
153
  if (db.query("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(table)) db.run(`DELETE FROM ${table}`);
@@ -0,0 +1,28 @@
1
+ import type { DoctorCheck } from "./output.ts";
2
+
3
+ const VERSION = /^[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z]+(?:[.-][0-9A-Za-z]+)*)?(?:\+[0-9A-Za-z]+(?:[.-][0-9A-Za-z]+)*)?$/;
4
+ const version = (value: unknown): string | undefined => typeof value === "string" && value.length <= 64 && VERSION.exec(value)?.[0] === value ? value : undefined;
5
+
6
+ /** Authenticated daemon status and the existing plugin inventory only. Suggestions never run an update. */
7
+ export function runtimeVersionWarnings(daemonVersion: unknown, cliVersion: unknown, pluginVersion: unknown): DoctorCheck[] {
8
+ const running = version(daemonVersion), cli = version(cliVersion), plugin = version(pluginVersion);
9
+ const rows: DoctorCheck[] = [];
10
+ const unknown = (name: string, detail: string) => rows.push({ section: "Hub", level: "unknown", name, detail });
11
+ if (!running) {
12
+ unknown("runtime version comparison", "authenticated daemon version unavailable; CLI and plugin age are unknown");
13
+ return rows;
14
+ }
15
+ let cliOlder = false;
16
+ if (!cli) unknown("CLI versus daemon", "this CLI version is unavailable; its age relative to the daemon is unknown");
17
+ else try {
18
+ cliOlder = Bun.semver.order(cli, running) < 0;
19
+ if (cliOlder) rows.push({ section: "Hub", level: "warn", name: "CLI versus daemon",
20
+ detail: `running daemon ${running} is newer than this CLI ${cli}; update separately: bun add --global @staix/agent-hub@${running}` });
21
+ } catch { unknown("CLI versus daemon", "CLI and daemon versions could not be compared"); }
22
+ if (!plugin) unknown("plugin versus daemon", "installed plugin version unavailable; run ahub setup with an updated CLI to inspect it");
23
+ else try {
24
+ if (Bun.semver.order(plugin, running) < 0) rows.push({ section: "Hub", level: "warn", name: "plugin versus daemon",
25
+ detail: `running daemon ${running} is newer than installed plugin ${plugin}; run ahub setup${cliOlder ? " after the CLI update" : " with a CLI at least as new as the daemon"}` });
26
+ } catch { unknown("plugin versus daemon", "plugin and daemon versions could not be compared"); }
27
+ return rows;
28
+ }
@@ -0,0 +1,125 @@
1
+ import { existsSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { ControlClient } from "../hub/control-client.ts";
4
+ import { readAutonomyDefaults, writeMachineStartAgents } from "../hub/autonomy-defaults.ts";
5
+ import { configRefusal } from "../hub/config-trust.ts";
6
+ import { START_AGENTS_DEFAULT, parseStartAgents } from "../hub/start-agents.ts";
7
+ import { MAX_COMMAND_MS } from "./terminal-recovery.ts";
8
+
9
+ /**
10
+ * #608 (agent-hub#1040): the default agent set `ahub up` starts. Every start goes through the console `peer_start`, so it is
11
+ * `hubStart`'s start (the peer's own start mode, the terminal provider, `already` for an attached peer, the manual command for
12
+ * a refusal) and never a headless fallback. Nothing here reaches the daemon except that one message per listed peer.
13
+ */
14
+ export interface StartRequester { request(message: Record<string, unknown>, timeoutMs?: number): Promise<Record<string, any>> }
15
+
16
+ /** One printable line per agent. A refusal carries the hub's own reason, which already names the manual command. */
17
+ export function startLine(id: string, reply: Record<string, any>): string {
18
+ if (reply.ok === true) {
19
+ if (reply.already === true) return `${id}: already attached`;
20
+ return `${id}: started${reply.mode ? ` ${reply.mode}` : ""}${reply.opened ? ` (${reply.opened})` : ""}${reply.mode === "tui" ? ", it attaches when its TUI is ready" : ""}`;
21
+ }
22
+ return `${id}: not started: ${String(reply.error ?? "the hub refused")}`;
23
+ }
24
+
25
+ /** Asks for every listed peer at once and returns the lines in list order. A refusal or a failed request never stops the others. */
26
+ export async function startDefaultAgents(client: StartRequester, agents: readonly string[]): Promise<string[]> {
27
+ return Promise.all(agents.map(async id => {
28
+ try { return startLine(id, await client.request({ t: "peer_start", peer: id }, MAX_COMMAND_MS + 5_000)); }
29
+ catch (error) { return `${id}: not started: ${(error as Error).message}`; }
30
+ }));
31
+ }
32
+
33
+ /** The list `ahub up` starts: the project's machine-local value, else this machine's `ahub setup` choice, else claude and codex. */
34
+ export function effectiveStartAgents(projectRoot: string, projectValue: readonly string[], home: string, roots: readonly string[] = []): { agents: string[]; source: "project" | "machine" | "default"; notice?: string } {
35
+ let set = false;
36
+ try {
37
+ const file = join(projectRoot, ".agenthub", "config.local.json");
38
+ if (existsSync(file)) { const doc = JSON.parse(readFileSync(file, "utf8")); set = Array.isArray(doc?.start?.agents); }
39
+ } catch { set = false; }
40
+ if (set) return { agents: [...projectValue], source: "project" };
41
+ const machine = readAutonomyDefaults(home, roots);
42
+ if (machine.startAgents) return { agents: [...machine.startAgents], source: "machine" };
43
+ return { agents: [...START_AGENTS_DEFAULT], source: "default", ...(machine.notice ? { notice: machine.notice } : {}) };
44
+ }
45
+
46
+ /** The dashboard manager's and `ahub up`'s common path: a console connection to the running hub, then the set. */
47
+ export async function startProjectAgents(project: { root: string; stateDir: string }, agents: readonly string[], connect?: () => Promise<ControlClient>): Promise<string[]> {
48
+ if (!agents.length) return [];
49
+ let client: ControlClient;
50
+ try { client = await (connect ?? (() => ControlClient.connect(project.stateDir, { role: "console", projectRoot: project.root }, 5_000)))(); }
51
+ catch (error) { return agents.map(id => `${id}: not started: the hub did not answer (${(error as Error).message})`); }
52
+ try { return await startDefaultAgents(client, agents); }
53
+ finally { client.close(); }
54
+ }
55
+
56
+ export interface SetupStartPorts {
57
+ home: string;
58
+ roots: readonly string[];
59
+ /** `--agents <a,b>`, already split from argv, or undefined. */
60
+ flag?: string;
61
+ yes: boolean;
62
+ ask?: (prompt: string) => Promise<string | null>;
63
+ print: (line: string) => void;
64
+ }
65
+ const show = (agents: readonly string[]) => agents.length ? agents.join(", ") : "none";
66
+
67
+ /**
68
+ * `ahub setup`'s one question (#608): which agents `ahub up` starts. `--agents` answers it, `--yes` takes this machine's current
69
+ * choice (or claude and codex), a terminal is asked once (an answer already on this machine is shown, not asked again), and a
70
+ * run with no terminal and neither flag keeps the default and says so. Writes only `start.agents` of `~/.agenthub/defaults.json`.
71
+ * Returns the refusal text for a bad `--agents`, else undefined.
72
+ */
73
+ export async function setupStartAgents(ports: SetupStartPorts): Promise<string | undefined> {
74
+ const held = readAutonomyDefaults(ports.home, ports.roots);
75
+ const current = held.startAgents ?? [...START_AGENTS_DEFAULT];
76
+ const save = (agents: string[]): void => {
77
+ try { writeMachineStartAgents(ports.home, agents, held.fingerprint, ports.roots); ports.print(`Agents ahub up starts: ${show(agents)} (saved in this machine's defaults; a new project inherits it at ahub init).`); }
78
+ catch { ports.print("Agents ahub up starts: not saved, because this machine's defaults.json is unsafe or changed; the default stays (claude, codex)."); }
79
+ };
80
+ if (ports.flag !== undefined) {
81
+ let agents: string[];
82
+ try { agents = parseStartAgents(ports.flag); } catch (error) { return (error as Error).message; }
83
+ save(agents); return undefined;
84
+ }
85
+ if (ports.yes) { if (held.startAgents) ports.print(`Agents ahub up starts: ${show(current)} (kept).`); else save(current); return undefined; }
86
+ if (held.startAgents) { ports.print(`Agents ahub up starts: ${show(current)} (change it with ahub setup --agents <a,b> or ahub settings set start.agents <a,b>).`); return undefined; }
87
+ if (!ports.ask) { ports.print(`Agents ahub up starts: ${show(current)} (the default; no terminal to ask in, so pass --agents <a,b> to choose).`); return undefined; }
88
+ for (let attempt = 0; attempt < 3; attempt++) {
89
+ const answer = await ports.ask(`Agents to start with ahub up [${show(current)}] (comma list, "none" for no agent): `);
90
+ if (answer === null || !answer.trim()) { save(current); return undefined; }
91
+ try { save(parseStartAgents(answer)); return undefined; } catch (error) { ports.print((error as Error).message); }
92
+ }
93
+ ports.print(`Agents ahub up starts: ${show(current)} (no valid answer; the default stays).`);
94
+ return undefined;
95
+ }
96
+
97
+ /**
98
+ * `ahub init` of a new project (#608): copies this machine's chosen set into the project's `.agenthub/config.local.json`, as the
99
+ * autonomy preset is inherited. Only when `ahub setup` chose one, never over a value the project already has, and never into a
100
+ * file git tracks. Person-only by its caller; returns the lines to print.
101
+ */
102
+ export function inheritMachineStart(projectRoot: string, home: string, roots: readonly string[]): string[] {
103
+ const machine = readAutonomyDefaults(home, roots);
104
+ if (machine.notice) return [machine.notice];
105
+ if (!machine.startAgents) return [];
106
+ const file = join(projectRoot, ".agenthub", "config.local.json");
107
+ let doc: Record<string, unknown> = {};
108
+ try {
109
+ if (existsSync(file)) {
110
+ const parsed: unknown = JSON.parse(readFileSync(file, "utf8"));
111
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return ["start.agents not copied: .agenthub/config.local.json is not a JSON object"];
112
+ doc = parsed as Record<string, unknown>;
113
+ }
114
+ } catch { return ["start.agents not copied: .agenthub/config.local.json could not be read"]; }
115
+ const start = doc.start;
116
+ if (start !== undefined && (typeof start !== "object" || start === null || Array.isArray(start))) return ["start.agents not copied: start in .agenthub/config.local.json is not an object"];
117
+ if (start && (start as Record<string, unknown>).agents !== undefined) return [];
118
+ const why = configRefusal(projectRoot, "config.local.json");
119
+ if (why) return [`start.agents not copied: ${why}`];
120
+ const text = `${JSON.stringify({ ...doc, start: { ...(start as object | undefined), agents: machine.startAgents } }, null, 2)}\n`;
121
+ const temporary = `${file}.${process.pid}.tmp`;
122
+ try { writeFileSync(temporary, text, { mode: 0o600, flag: "wx" }); renameSync(temporary, file); }
123
+ catch (error) { try { unlinkSync(temporary); } catch { /* nothing was written */ } return [`start.agents not copied: ${(error as NodeJS.ErrnoException).code ?? "write failed"}`]; }
124
+ return [`Inherited the machine's agent set for ahub up: ${show(machine.startAgents)}.`];
125
+ }
@@ -69,3 +69,59 @@ export function renderStudyReceipt(op: string, receipt: unknown, columns?: numbe
69
69
  if (op === "draft" || op === "revise") out.push(blank, [s(`nothing is dispatched until you approve: ahub study approve ${r?.id} ${count(r?.revision)}`, "muted")]);
70
70
  return out;
71
71
  }
72
+
73
+ const ATTEMPT_TONE: Record<string, Tone | undefined> = { succeeded: undefined, running: undefined, declared: "muted", failed: "attention", cancelled: "attention", unknown: "attention" };
74
+ const PROVENANCE_TONE: Record<string, Tone | undefined> = { declared: "muted", unknown: "attention" };
75
+ const clip = (text: string, max = 48) => text.length > max ? `${text.slice(0, max - 1)}…` : text;
76
+ /** A value cell: the value and where it came from, so a declared number never reads as an observed one. Anything the hub withheld prints as the hub wrote it. */
77
+ const sourced = (cell: any): [string, string] => {
78
+ if (cell === "[pii]") return ["[pii]", "withheld"];
79
+ if (!cell || typeof cell !== "object" || !("provenance" in cell)) return ["-", "unknown"];
80
+ const value = cell.value === null || cell.value === undefined ? "-" : typeof cell.value === "object" ? "(structured)" : String(cell.value);
81
+ return [clip(value), String(cell.provenance)];
82
+ };
83
+
84
+ /**
85
+ * `ahub study experiments`: the attempts of one campaign, one attempt, or one baseline/variant comparison, as the hub's `Tasks.experimentShow` returned them. Pure: it reads no file, runs
86
+ * no command and asks for no protocol text. Every row says whether it is approved, post-hoc or predeclared and that a repeat is not an independent reproduction; every value says where it
87
+ * came from; failed, cancelled and unknown attempts are in the counts. A private record arrives from the hub already as ids, counts and states.
88
+ */
89
+ export function renderExperiments(view: unknown, columns?: number): Span[][] {
90
+ const v = view as any, attempts = list(v?.attempts), counts = v?.counts ?? {}, statuses = counts.statuses ?? {};
91
+ const out: Span[][] = [...heading(`Experiment attempts (${attempts.length} of ${count(counts.all)} in this campaign${v?.nextCursor !== undefined ? ", more follow" : ""})`, columns)];
92
+ const order = ["declared", "running", "succeeded", "failed", "cancelled", "unknown"];
93
+ out.push(...pair("all", order.map(name => `${name} ${count(statuses[name])}`).join(", "), columns, 8, "muted"));
94
+ if (v?.comparison) {
95
+ const c = v.comparison, compat = c.projection?.compatibility;
96
+ out.push(blank, ...heading("Comparison", columns));
97
+ if (c.projection === "[pii]") out.push(...pair("plan", `${String(c.planHash).slice(0, 12)}: withheld (a private source or record)`, columns, 8, "attention"));
98
+ else {
99
+ out.push(...pair("plan", String(c.planHash).slice(0, 12), columns, 8));
100
+ out.push(...pair("compatible", compat?.compatible ? "yes, under this plan" : `no: ${compat?.field ?? "unknown"} differs (${compat?.side ?? "pair"}); shown, not ranked together`, columns, 8, compat?.compatible ? undefined : "attention"));
101
+ }
102
+ out.push(...pair("approved", c.approved ? "both protocols bound to a person's approval" : "not both approved", columns, 8, c.approved ? undefined : "attention"));
103
+ out.push(...pair("pair", `${c.baselineAttemptId} (baseline) and ${c.variantAttemptId} (variant)`, columns, 8, "muted"));
104
+ }
105
+ if (!attempts.length) return [...out, blank, [s("no attempts recorded", "muted")]];
106
+ out.push(blank, ...grid(["ATTEMPT", "TASK", "PHASE", "STATUS", "APPROVAL", "POST-HOC", "NOTE"], attempts.map((row: any) => [
107
+ s(row.id, "issueRef"), s(`#${count(row.taskId)}`, "taskRef"), s(row.phase), s(row.status, ATTEMPT_TONE[row.status]), s(row.approval, row.approval === "approved" ? undefined : "attention"), s(row.postHoc, row.postHoc === "post-hoc" ? "attention" : "muted"),
108
+ s([row.private ? "private, details withheld" : "", row.retryOf ? `retry of ${row.retryOf}` : "", row.reproducesAttemptId ? `repeat of ${row.reproducesAttemptId}, not independent` : "", row.eventsComplete === false ? "events continue" : ""].filter(Boolean).join("; ") || "-", "muted"),
109
+ ]), columns));
110
+ for (const row of attempts) {
111
+ if (row.private) continue;
112
+ const declared = row.evidence?.declared, metrics = list(declared?.metrics), measured = list(row.evidence?.measured), runners = list(row.evidence?.runnerAttested);
113
+ const lines: [string, string, string][] = [];
114
+ for (const metric of metrics) { const [value, from] = sourced(metric.latest); lines.push([`${metric.name} (${metric.unit})`, value, from]); }
115
+ for (const field of ["exitStatus", "startedAt"]) { const [value, from] = sourced(declared?.[field]); if (value !== "-") lines.push([field, value, from]); }
116
+ for (const m of measured) for (const [field, cell] of Object.entries(m.values ?? {})) { const [value, from] = sourced(cell); lines.push([`${field} (${m.source})`, value, from]); }
117
+ for (const r of runners) { const [value, from] = sourced(r.sealing); lines.push([`sealed (runner ${clip(String(r.runner), 24)})`, value, from]); const [match, matchFrom] = sourced(r.codeMatch); lines.push(["code matches protocol", match, matchFrom]); }
118
+ const [sealed, sealedFrom] = sourced(declared?.sealed);
119
+ if (!runners.length) lines.push(["sealed", sealed, `${sealedFrom}; isolation unverified`]);
120
+ if (!lines.length) continue;
121
+ out.push(blank, ...heading(`Evidence of ${row.id}`, columns), ...grid(["FIELD", "VALUE", "SOURCE"], lines.map(([field, value, from]) => [s(field), s(value), s(from, PROVENANCE_TONE[from] ?? (from.startsWith("declared") ? "muted" : undefined))]), columns));
122
+ }
123
+ out.push(blank, [s(`declared values are an agent's own words; only host-observed, runner-attested or person-attested rows are evidence, and none of them proves the science. A repeat is never an independent reproduction.`, "muted")]);
124
+ if (v?.nextCursor !== undefined) out.push([s(`more attempts: ahub study experiments --campaign <id> --after ${v.nextCursor}`, "muted")]);
125
+ for (const row of attempts) if (row.eventsComplete === false && row.nextEventCursor !== undefined) out.push([s(`more events of ${row.id}: ahub study experiments --attempt ${row.id} --events-after ${row.nextEventCursor}; the values above come from the events shown so far`, "attention")]);
126
+ return out;
127
+ }
package/src/cli/study.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  // Argument parsing for `ahub study` (#601). Pure: argv in, one request description or a usage line out, so the I/O in main.ts stays a thin dispatcher and every form is testable without a daemon.
2
- export const STUDY_USAGE = "usage: ahub study draft <parent> <contract.json> | revise <id> <revision> <contract.json> | approve <id> <revision> | approve-protocol <hash> | close <id> <revision> --reason <text> | show [<id> | --after <id>] [--json] [--color=auto|always|never]";
2
+ export const STUDY_USAGE = "usage: ahub study draft <parent> <contract.json> | revise <id> <revision> <contract.json> | approve <id> <revision> | approve-protocol <hash> | close <id> <revision> --reason <text> | show [<id> | --after <id>] | experiments (--campaign <id> [--after <n>] | --attempt <ra-id> [--events-after <n>] | --plan <hash> --baseline <ra-id> --variant <ra-id>) [--json] [--color=auto|always|never]";
3
3
 
4
4
  export type StudyRequest =
5
5
  | { op: "draft"; parentTaskId: number; file: string }
@@ -7,9 +7,10 @@ export type StudyRequest =
7
7
  | { op: "approve"; id: string; revision: number }
8
8
  | { op: "approve_protocol"; hash: string }
9
9
  | { op: "close"; id: string; revision: number; reason: string }
10
- | { op: "show"; id?: string; after?: string };
10
+ | { op: "show"; id?: string; after?: string }
11
+ | { op: "experiments"; campaign?: string; attempt?: string; after?: number; eventsAfter?: number; plan?: string; baseline?: string; variant?: string };
11
12
 
12
- const ID = /^rc-[a-f0-9]{12}$/, INT = /^[1-9]\d{0,8}$/;
13
+ const ID = /^rc-[a-f0-9]{12}$/, INT = /^[1-9]\d{0,8}$/, ATTEMPT = /^ra-[a-f0-9]{12}$/, HASH = /^[a-f0-9]{64}$/, CURSOR = /^(0|[1-9]\d{0,8})$/;
13
14
 
14
15
  /** `operands` is argv with the presentation flags (`--json`, `--color=`) already removed by `splitPresentation`. `--reason` takes the rest of the words, as `task cancel --reason` does. */
15
16
  export function parseStudyArgs(operands: readonly string[]): StudyRequest | { usage: string } {
@@ -22,6 +23,28 @@ export function parseStudyArgs(operands: readonly string[]): StudyRequest | { us
22
23
  const hash = rest[0];
23
24
  return hash?.length === 64 && /^[a-f0-9]{64}$/.test(hash) ? { op: "approve_protocol", hash } : usage("a protocol hash is 64 lowercase hexadecimal characters");
24
25
  }
26
+ if (sub === "experiments") {
27
+ // Three closed forms, the ones Tasks.experimentShow takes: a campaign page, one attempt, or one comparison. A flag with no value, a repeat, a stray word or a mix is a usage error before anything is sent.
28
+ const names = ["--campaign", "--attempt", "--after", "--events-after", "--plan", "--baseline", "--variant"], seen = new Map<string, string>();
29
+ for (let at = 0; at < rest.length; at += 2) {
30
+ const name = rest[at], value = rest[at + 1];
31
+ if (name === undefined || !names.includes(name) || value === undefined || value.startsWith("--") || seen.has(name)) return usage();
32
+ seen.set(name, value);
33
+ }
34
+ const campaign = seen.get("--campaign"), attempt = seen.get("--attempt"), after = seen.get("--after"), eventsAfter = seen.get("--events-after"), plan = seen.get("--plan"), baseline = seen.get("--baseline"), variant = seen.get("--variant");
35
+ if (campaign !== undefined && !id(campaign)) return usage("a campaign id looks like rc-0123456789ab");
36
+ for (const [name, value] of [["--attempt", attempt], ["--baseline", baseline], ["--variant", variant]] as const) if (value !== undefined && !ATTEMPT.test(value)) return usage(`${name} takes an attempt id like ra-0123456789ab`);
37
+ if (plan !== undefined && !HASH.test(plan)) return usage("a plan hash is 64 lowercase hexadecimal characters");
38
+ if (after !== undefined && !CURSOR.test(after)) return usage("--after takes the cursor a previous page printed");
39
+ if (eventsAfter !== undefined && !CURSOR.test(eventsAfter)) return usage("--events-after takes the event cursor a previous page printed");
40
+ const comparison = plan !== undefined || baseline !== undefined || variant !== undefined;
41
+ if (comparison) return plan !== undefined && baseline !== undefined && variant !== undefined && campaign === undefined && attempt === undefined && after === undefined && eventsAfter === undefined
42
+ ? { op: "experiments", plan, baseline, variant } : usage("a comparison needs --plan, --baseline and --variant and nothing else");
43
+ if ((campaign === undefined) === (attempt === undefined)) return usage("give --campaign or --attempt");
44
+ if (attempt !== undefined && after !== undefined) return usage("--after pages a campaign, not one attempt; --events-after pages the events of one attempt");
45
+ if (campaign !== undefined && eventsAfter !== undefined) return usage("--events-after pages the events of one attempt, not a campaign");
46
+ return attempt !== undefined ? { op: "experiments", attempt, ...(eventsAfter === undefined ? {} : { eventsAfter: Number(eventsAfter) }) } : { op: "experiments", campaign: campaign!, ...(after === undefined ? {} : { after: Number(after) }) };
47
+ }
25
48
  if (sub === "show") {
26
49
  // `--after <id>` continues a list from the cursor a previous page printed; it has no meaning with an id, which shows one campaign.
27
50
  if (rest[0] === "--after") {
@@ -120,6 +120,11 @@ function taskBlock(task: any, columns: number | undefined, now: number, detail:
120
120
  const refText = [refs.repo, refs.branch ? `branch ${refs.branch}` : "", refs.commit ? `commit ${short(refs.commit)}` : ""].filter(Boolean).join(", ");
121
121
  if (refText) out.push(blank, ...pair("refs", refText, columns, w, undefined, " "));
122
122
  out.push(...bullets("ref paths", list(refs.paths), columns, undefined, " "));
123
+ if (!isStub(task) && task?.approval) {
124
+ const approval = task.approval;
125
+ const binding = `head ${approval.head ?? "unknown"}; latest ${approval.latestHead ?? "unknown"}; ${approval.mismatch ? "HEAD MISMATCH, approval is stale" : approval.current ? "current" : "not current, review required"}`;
126
+ out.push(...pair("approval", binding, columns, w, approval.current ? "success" : "attention", " "));
127
+ }
123
128
  const plan = planBlock(task?.plan, columns);
124
129
  if (plan.length) out.push(blank, [s("plan", "muted")], ...plan);
125
130
  const sdlc = task?.sdlc;
@@ -36,8 +36,8 @@ export interface UpgradeHost {
36
36
  apply(plan: UpgradePlan, interrupted: () => boolean): Promise<RecoveryOperation>;
37
37
  /** End one attached agent now: close a TUI agent's terminal, or ask the hub to stop a headless one. One line of outcome. */
38
38
  endPeer(planned: PlannedProject, peer: RecoveryPeer): Promise<string>;
39
- /** The operation that holds the machine's recovery lock. */
40
- lock(): string | undefined;
39
+ /** This selected project's hold, or an authoritative retained legacy machine hold. A reset may name another project. */
40
+ lock(projectRoot?: string): string | undefined;
41
41
  read(id: string): RecoveryOperation;
42
42
  runner(id: string): number | "unknown" | undefined;
43
43
  live(op: RecoveryOperation): Promise<Record<string, Inspection | undefined>>;
@@ -215,13 +215,13 @@ export async function endAgents(host: UpgradeHost, io: ScreenIO, endable: { plan
215
215
 
216
216
  /** `ahub reset` for one project: the scope, its dry run, a confirmation, then the reset itself. */
217
217
  export async function resetFlow(host: UpgradeHost, io: ScreenIO, projects: { id: string; root: string }[]): Promise<void> {
218
- if (host.lock()) return human(io, "an operation holds the recovery lock; cancel or end it before a reset");
219
218
  let project = projects[0];
220
219
  if (projects.length > 1) {
221
220
  projects.forEach((p, i) => human(io, ` ${i + 1} ${p.id} ${p.root}`));
222
221
  project = projects[Number(await ask(io, "Reset which project? [number, Enter: none] ")) - 1];
223
222
  }
224
223
  if (!project) return human(io, "nothing was reset");
224
+ if (host.lock(project.root)) return human(io, "an operation holds the recovery lock; cancel or end it before a reset");
225
225
  const scope = ((await ask(io, "[r] runtime reset (deliveries, holds, pauses, session pointers) [a] full reset (archive the state directory) [Enter] none: ")) ?? "").toLowerCase();
226
226
  if (scope !== "r" && scope !== "a") return human(io, "nothing was reset");
227
227
  const reset = [host.entry, "--project", project.root, "reset", ...(scope === "a" ? ["--all"] : [])];
@@ -323,7 +323,7 @@ export async function planScreen(host: UpgradeHost, io: ScreenIO): Promise<void>
323
323
  if (io.presentation) emit(io, renderPlan(plan, host.version, io.presentation, planLines(plan, host.version)));
324
324
  else for (const line of planLines(plan, host.version)) human(io, line);
325
325
  const blocked = plan.blockers.length > 0 || plan.projects.some((p) => p.blockers.length > 0);
326
- if (!plan.projects.length) human(io, plan.kind === "upgrade" ? `no hub is running, so nothing is carried over: install with \`bun add -g @staix/agent-hub@${plan.version}\`, then \`ahub setup\`` : "this project's hub is not running: `ahub up` starts it");
326
+ if (!plan.projects.length) human(io, plan.kind === "upgrade" && plan.scope === undefined ? `no hub is running, so nothing is carried over: install with \`bun add -g @staix/agent-hub@${plan.version}\`, then \`ahub setup\`` : "this project's hub is not running: `ahub up` starts it");
327
327
  const endable = plan.projects.flatMap((p) => p.source.peers.filter((peer) => ["tui", "headless"].includes(peerKind(p, peer))).map((peer) => ({ planned: p, peer })));
328
328
  const keys = [...(blocked ? [] : ["[a] apply"]), "[r] refresh", ...(endable.length ? ["[k] end agents"] : []), "[j] plan as JSON", ...(plan.projects.length ? ["[x] reset a project's hub"] : []), "[q] quit"];
329
329
  const preferred = "r"; // Enter never closes terminals, stops a hub, resumes or reattaches implicitly.