@staix/agent-hub 0.24.0 → 0.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/README.md +35 -8
  3. package/docs/agent-notes/README.md +12 -0
  4. package/docs/agent-notes/adapters.md +2 -1
  5. package/docs/agent-notes/bus.md +1 -0
  6. package/docs/agent-notes/daemon.md +18 -3
  7. package/docs/agent-notes/tasks.md +12 -4
  8. package/docs/agent-notes/tests.md +3 -1
  9. package/docs/dogfood.md +16 -2
  10. package/docs/memory.md +1 -1
  11. package/docs/operations.md +264 -14
  12. package/docs/profiles.md +2 -0
  13. package/docs/quickstart.md +23 -6
  14. package/docs/security.md +198 -3
  15. package/docs/specs/2026-09-19-agent-hub-design.md +25 -1
  16. package/docs/specs/2026-09-19-multi-project-design.md +12 -1
  17. package/docs/specs/2026-11-01-research-campaigns-design.md +354 -0
  18. package/docs/verification/2026-11-01-0.25.0.md +91 -0
  19. package/docs/verification/2026-11-01-0.26.0.md +54 -0
  20. package/docs/verified.json +45 -17
  21. package/package.json +4 -3
  22. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  23. package/plugins/agent-hub/server.js +5294 -4797
  24. package/src/adapters/channel-peer.ts +4 -2
  25. package/src/adapters/claude-channel.ts +29 -6
  26. package/src/adapters/definitions/builtin-metadata.ts +6 -6
  27. package/src/adapters/definitions/native-cli-doctor.ts +30 -0
  28. package/src/adapters/definitions/qwen.ts +2 -0
  29. package/src/adapters/jcode.ts +3 -0
  30. package/src/adapters/registry-types.ts +2 -0
  31. package/src/adapters/registry.ts +2 -1
  32. package/src/cli/acon-register.ts +29 -0
  33. package/src/cli/conductor-recovery.ts +3 -2
  34. package/src/cli/handoff-args.ts +27 -0
  35. package/src/cli/handoff-output.ts +25 -0
  36. package/src/cli/help.ts +11 -8
  37. package/src/cli/identity.ts +7 -1
  38. package/src/cli/main.ts +138 -30
  39. package/src/cli/output.ts +8 -5
  40. package/src/cli/recovery-package.ts +94 -4
  41. package/src/cli/reset.ts +14 -4
  42. package/src/cli/runtime-version-warning.ts +28 -0
  43. package/src/cli/schedule-output.ts +8 -4
  44. package/src/cli/start-agents-run.ts +125 -0
  45. package/src/cli/study-output.ts +71 -0
  46. package/src/cli/study.ts +58 -0
  47. package/src/cli/task-output.ts +6 -0
  48. package/src/cli/upgrade-interactive.ts +4 -4
  49. package/src/cli/upgrade-runtime.ts +105 -57
  50. package/src/cli/upgrade.ts +90 -28
  51. package/src/hub/autonomy-defaults.ts +32 -7
  52. package/src/hub/board.ts +993 -8
  53. package/src/hub/budget.ts +3 -4
  54. package/src/hub/channel-project.ts +61 -0
  55. package/src/hub/child-process.ts +4 -3
  56. package/src/hub/conductor.ts +3 -4
  57. package/src/hub/config-apply.ts +13 -2
  58. package/src/hub/config-edit-host.ts +1 -0
  59. package/src/hub/config-tier.ts +3 -0
  60. package/src/hub/config-trust.ts +10 -2
  61. package/src/hub/control-client.ts +39 -10
  62. package/src/hub/daemon.ts +326 -25
  63. package/src/hub/db-schema.ts +143 -0
  64. package/src/hub/delivery-journal.ts +3 -4
  65. package/src/hub/envelope.ts +2 -0
  66. package/src/hub/events.ts +4 -1
  67. package/src/hub/execution-budget.ts +15 -4
  68. package/src/hub/folder-trigger.ts +3 -4
  69. package/src/hub/handoff.ts +126 -0
  70. package/src/hub/hub-tools.ts +12 -2
  71. package/src/hub/invocations.ts +3 -4
  72. package/src/hub/lifecycle.ts +87 -29
  73. package/src/hub/manager.ts +110 -87
  74. package/src/hub/no-writes.ts +1 -1
  75. package/src/hub/orca-env.ts +19 -0
  76. package/src/hub/path-policy.ts +12 -8
  77. package/src/hub/project-runtime-state.ts +139 -0
  78. package/src/hub/project-runtime.ts +62 -0
  79. package/src/hub/project.ts +14 -5
  80. package/src/hub/protocol-window.ts +19 -0
  81. package/src/hub/provenance.ts +30 -0
  82. package/src/hub/recovery-store.ts +102 -12
  83. package/src/hub/registry.ts +5 -0
  84. package/src/hub/research-campaign.ts +265 -0
  85. package/src/hub/research-experiments.ts +474 -0
  86. package/src/hub/research-runner.ts +172 -0
  87. package/src/hub/restart.ts +35 -0
  88. package/src/hub/runtime-capture-prune.ts +415 -0
  89. package/src/hub/runtime-prune-health.ts +112 -0
  90. package/src/hub/schedules.ts +213 -13
  91. package/src/hub/sdlc-merge.ts +7 -0
  92. package/src/hub/sdlc-queue.ts +13 -1
  93. package/src/hub/session-identity.ts +49 -0
  94. package/src/hub/settings.ts +4 -0
  95. package/src/hub/snapshots.ts +3 -4
  96. package/src/hub/start-agents.ts +44 -0
  97. package/src/hub/task-approval.ts +23 -0
  98. package/src/hub/tasks.ts +1005 -34
  99. package/src/hub/ui.ts +15 -6
  100. package/src/hub/usage-rollup.ts +3 -4
  101. package/src/memory/capture.ts +2 -2
  102. package/src/pi/tool-receipts.ts +3 -4
  103. package/src/ui/index.html +5 -3
  104. package/src/ui/office-space.ts +118 -0
  105. package/src/ui/plugin.ts +293 -0
  106. package/templates/AGENTS.block.md +1 -0
  107. package/templates/profiles/general.json +1 -1
  108. package/templates/profiles/research.json +4 -4
  109. package/templates/profiles/writing.json +1 -1
  110. package/templates/roles/research/analyst.md +1 -0
  111. package/templates/roles/research/fact-checker.md +1 -0
  112. package/templates/roles/research/research-lead.md +1 -0
  113. package/templates/roles/research/researcher.md +1 -0
  114. package/templates/roles/research/statistician.md +1 -0
@@ -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
+ }
@@ -3,22 +3,26 @@
3
3
  import { blank, grid, heading, pair, s } from "./sdlc-output.ts";
4
4
  import { moment } from "./sdlc-output.ts";
5
5
  import type { Span, Tone } from "./console-state.ts";
6
+ import { schedulePeriodText, scheduleWhen } from "../hub/schedules.ts";
6
7
 
7
8
  const list = (value: unknown): any[] => Array.isArray(value) ? value : [];
8
9
  const TONE: Record<string, Tone | undefined> = { due: "attention", waiting: "attention", held: "attention", paused: "muted", scheduled: undefined };
9
10
  const WHEN: Record<string, string> = { scheduled: "next", due: "due now", waiting: "due, waiting for its task to close", held: "due, held (the hub is stopping or recovering)", paused: "paused" };
10
11
 
11
12
  /** The next run, as the person reads it: a paused schedule says so and what it would have been. */
12
- export const nextText = (row: any, now: number): string =>
13
- row?.state === "paused" ? `paused (would be ${moment(row.next, now)})` : row?.state === "scheduled" ? moment(row.next, now) : WHEN[row?.state] ?? "unknown";
13
+ export const nextText = (row: any, now: number): string => {
14
+ // A calendar schedule reads its next run in its own zone, with the relative time after it; an `every` schedule keeps the UTC form.
15
+ const at = (instant: unknown) => row?.zone && typeof instant === "number" && Number.isFinite(instant) ? `${scheduleWhen(row, instant)} (${moment(instant, now).replace(/^.*?\((.*)\)$/, "$1")})` : moment(instant, now);
16
+ return row?.state === "paused" ? `paused (would be ${at(row.next)})` : row?.state === "scheduled" ? at(row.next) : WHEN[row?.state] ?? "unknown";
17
+ };
14
18
 
15
19
  /** `ahub schedule list`: one row per configured schedule with its next run. */
16
20
  export function renderScheduleList(rows: unknown, columns?: number, now = Date.now()): Span[][] {
17
21
  const schedules = list(rows);
18
22
  if (!schedules.length) return [[s("no schedules configured; add them under `schedules` in .agenthub/config.local.json", "muted")]];
19
23
  const out: Span[][] = [...heading(`Schedules (${schedules.length})`, columns)];
20
- out.push(...grid(["ID", "EVERY", "CLASS", "QUEUE", "STATE", "NEXT RUN", "LAST RUN", "TASK"], schedules.map((row: any) => [
21
- s(row.id, "issueRef"), s(row.every), s(row.class), s(row.queue), s(row.state, TONE[row.state]), s(nextText(row, now), TONE[row.state]),
24
+ out.push(...grid(["ID", "SCHEDULE", "CLASS", "QUEUE", "STATE", "NEXT RUN", "LAST RUN", "TASK"], schedules.map((row: any) => [
25
+ s(row.id, "issueRef"), s(row.cron !== undefined ? schedulePeriodText(row) : String(row.every)), s(row.class), s(row.queue), s(row.state, TONE[row.state]), s(nextText(row, now), TONE[row.state]),
22
26
  s(row.last ? moment(row.last, now) : "never", "muted"), s(row.task === undefined ? "-" : `#${row.task}`, "taskRef"),
23
27
  ]), columns));
24
28
  for (const row of schedules) {
@@ -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
+ }
@@ -0,0 +1,71 @@
1
+ // Human output for `ahub study draft|revise|approve|approve-protocol|close|show`. Pure: the hub's parsed JSON in, spans out, no I/O. `--json` is the caller's branch and prints the same text.
2
+ // A campaign is named by ids, revisions, states and counts. The hub already stubs private campaigns, tasks and sources; this file never asks for a question, scope, path or excerpt.
3
+ import { blank, grid, heading, pair, s } from "./sdlc-output.ts";
4
+ import type { Span, Tone } from "./console-state.ts";
5
+
6
+ const list = (value: unknown): any[] => Array.isArray(value) ? value : [];
7
+ const count = (value: unknown): number => typeof value === "number" && Number.isFinite(value) ? value : 0;
8
+ const STATUS_TONE: Record<string, Tone | undefined> = { approved: undefined, draft: "attention", superseded: "muted", closed: "muted" };
9
+
10
+ /** `ahub study show` with no id: one page of campaigns of this project, with the command that continues it when the hub says there is more. */
11
+ export function renderStudyList(page: unknown, columns?: number): Span[][] {
12
+ const campaigns = list((page as any)?.campaigns), next = typeof (page as any)?.next === "string" ? (page as any).next as string : undefined;
13
+ if (!campaigns.length) return [[s("no research campaigns in this project; a planner or you draft one with `ahub study draft <parent> <contract.json>`", "muted")]];
14
+ const out: Span[][] = [...heading(`Research campaigns (${campaigns.length}${next ? ", more follow" : ""})`, columns)];
15
+ out.push(...grid(["ID", "REV", "STATUS", "PARENT", "UNITS"], campaigns.map((row: any) => [
16
+ s(row.id, "issueRef"), s(`r${count(row.revision)}`), s(row.status, STATUS_TONE[row.status]), s(`#${count(row.parentTaskId)}`, "taskRef"), s(count(row.unitCount)),
17
+ ]), columns));
18
+ if (next) out.push(blank, [s(`more campaigns: ahub study show --after ${next}`, "muted")]);
19
+ return out;
20
+ }
21
+
22
+ const SUPPORT_TONE: Record<string, Tone | undefined> = { enforceable: undefined, supported: undefined, advisory: "muted", unavailable: "attention", unsupported: "attention", unknown: "attention" };
23
+
24
+ /** `ahub study show <id>`: the coverage and stop projection the hub computed. Held work is said first, with every reason. */
25
+ export function renderStudyShow(projection: unknown, columns?: number): Span[][] {
26
+ const p = projection as any, units = list(p?.units), rounds = list(p?.discoveryRounds), reasons = list(p?.reasons);
27
+ const out: Span[][] = [...heading(`Research campaign ${p?.id ?? "?"}, revision ${count(p?.revision)}${p?.held ? ", dispatch HELD" : ""}`, columns)];
28
+ for (const reason of reasons) out.push(...pair("held", reason, columns, 8, "attention"));
29
+ if (p?.sharedPeerLedger) out.push(...pair("budget", "one run ledger is shared by the peers", columns, 8, "muted"));
30
+ if (rounds.length) out.push(...pair("rounds", rounds.map((round: any) => `${count(round.round)}: ${round.complete ? "discovery complete" : "discovery open"}`).join(", "), columns, 8));
31
+ out.push(blank);
32
+ if (!units.length) return [...out, [s("no units", "muted")]];
33
+ out.push(...grid(["UNIT", "ROUND", "TASK", "STATE", "PURPOSE", "HANDOFF", "SOURCES"], units.map((unit: any) => {
34
+ const handoff = unit.handoff ?? {};
35
+ const evidence = handoff.insufficientEvidence ? "insufficient evidence" : handoff.complete ? "complete" : handoff.present ? "incomplete" : "none";
36
+ const covered = list(unit.sourceIds).length;
37
+ const note = [unit.intentionalIndependentCheck ? "independent check" : "", list(unit.duplicateSourceIds).length ? `${list(unit.duplicateSourceIds).length} duplicated` : ""].filter(Boolean).join(", ");
38
+ return [s(unit.unitId, "issueRef"), s(count(unit.round)), s(unit.taskId == null ? "-" : `#${unit.taskId}`, "taskRef"), s(unit.state), s(unit.purpose), s(evidence, handoff.complete || handoff.insufficientEvidence ? undefined : "attention"),
39
+ s(covered ? `${list(handoff.coveredSourceIds).length}/${covered} covered${note ? `, ${note}` : ""}` : note || "-", "muted")];
40
+ }), columns));
41
+ // Every resource of a unit is shown whenever the campaign names a budget, with its support state: a limit the ledger does not carry (`null`) is a placeholder, not a reason to hide a row that says
42
+ // `unavailable` or `advisory`. A campaign that names no budget has nothing to say (its `tokens` row reads `unavailable` whatever the ledger is), so the hub's `budgetNamed` decides, not the values.
43
+ const budgeted = units.flatMap((unit: any) => Object.entries(unit.budget ?? {}).filter(([, value]: [string, any]) => value && typeof value === "object").map(([name, value]: [string, any]) => ({ unit: unit.unitId, name, value })));
44
+ if (budgeted.length && p?.budgetNamed === true) {
45
+ out.push(blank, ...heading("Budget", columns));
46
+ out.push(...grid(["UNIT", "RESOURCE", "SUPPORT", "USED", "LIMIT"], budgeted.map(({ unit, name, value }) => [
47
+ s(unit, "issueRef"), s(name), s(value.support, SUPPORT_TONE[value.support]), s(value.used === null || value.used === undefined ? "-" : value.used), s(value.limit === null || value.limit === undefined ? "-" : value.limit),
48
+ ]), columns));
49
+ }
50
+ return out;
51
+ }
52
+
53
+ const VERB: Record<string, string> = { draft: "drafted", revise: "drafted as the next revision", approve: "approved", close: "closed" };
54
+
55
+ /** The receipt of a draft, revision, approval or close. */
56
+ export function renderStudyReceipt(op: string, receipt: unknown, columns?: number): Span[][] {
57
+ const r = receipt as any, units = list(r?.units);
58
+ if (op === "approve_protocol") {
59
+ const hash = (value: unknown) => typeof value === "string" && value.length === 64 && /^[a-f0-9]{64}$/.test(value) ? value : "unknown";
60
+ const by = typeof r?.approvedBy === "string" && /^[a-z][a-z0-9_-]{0,63}$/.test(r.approvedBy) ? r.approvedBy : "unknown";
61
+ const at = typeof r?.approvedAt === "number" && Number.isFinite(r.approvedAt) && r.approvedAt >= 0 && r.approvedAt <= 8_640_000_000_000_000
62
+ ? new Date(r.approvedAt).toISOString().slice(0, 19).replace("T", " ") + " UTC" : "unknown";
63
+ return [...heading("Research protocol approval", columns), ...pair("protocol hash", hash(r?.protocolHash), columns, 16),
64
+ ...pair("evaluator hash", hash(r?.evaluatorHash), columns, 16), ...pair("approved by", by, columns, 16), ...pair("approved at", at, columns, 16)];
65
+ }
66
+ const out: Span[][] = [...heading(`Research campaign ${r?.id ?? "?"} revision ${count(r?.revision)} ${VERB[op] ?? op}`, columns)];
67
+ out.push(...pair("status", r?.status, columns, 8, STATUS_TONE[r?.status]), ...pair("parent", `#${count(r?.parentTaskId)}`, columns, 8, "taskRef"), ...pair("units", count(r?.unitCount), columns, 8));
68
+ if (units.length) out.push(blank, ...grid(["UNIT", "TASK", "ROUND"], units.map((unit: any) => [s(unit.unitId, "issueRef"), s(`#${count(unit.taskId)}`, "taskRef"), s(count(unit.round))]), columns));
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
+ return out;
71
+ }
@@ -0,0 +1,58 @@
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]";
3
+
4
+ export type StudyRequest =
5
+ | { op: "draft"; parentTaskId: number; file: string }
6
+ | { op: "revise"; id: string; revision: number; file: string }
7
+ | { op: "approve"; id: string; revision: number }
8
+ | { op: "approve_protocol"; hash: string }
9
+ | { op: "close"; id: string; revision: number; reason: string }
10
+ | { op: "show"; id?: string; after?: string };
11
+
12
+ const ID = /^rc-[a-f0-9]{12}$/, INT = /^[1-9]\d{0,8}$/;
13
+
14
+ /** `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
+ export function parseStudyArgs(operands: readonly string[]): StudyRequest | { usage: string } {
16
+ const usage = (detail?: string) => ({ usage: detail ? `${detail}\n${STUDY_USAGE}` : STUDY_USAGE });
17
+ const [sub, ...rest] = operands;
18
+ const id = (value: string | undefined) => value !== undefined && ID.test(value) ? value : undefined;
19
+ const int = (value: string | undefined) => value !== undefined && INT.test(value) ? Number(value) : undefined;
20
+ if (sub === "approve-protocol") {
21
+ if (rest.length !== 1) return usage();
22
+ const hash = rest[0];
23
+ 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
+ if (sub === "show") {
26
+ // `--after <id>` continues a list from the cursor a previous page printed; it has no meaning with an id, which shows one campaign.
27
+ if (rest[0] === "--after") {
28
+ if (rest.length !== 2) return usage();
29
+ return id(rest[1]) ? { op: "show", after: rest[1]! } : usage("a campaign id looks like rc-0123456789ab");
30
+ }
31
+ if (rest.length > 1) return usage();
32
+ if (rest[0] === undefined) return { op: "show" };
33
+ return id(rest[0]) ? { op: "show", id: rest[0] } : usage("a campaign id looks like rc-0123456789ab");
34
+ }
35
+ if (sub === "draft") {
36
+ const parent = int(rest[0]);
37
+ if (parent === undefined || !rest[1] || rest.length !== 2) return usage();
38
+ return { op: "draft", parentTaskId: parent, file: rest[1] };
39
+ }
40
+ if (sub === "revise") {
41
+ const revision = int(rest[1]);
42
+ if (!id(rest[0]) || revision === undefined || !rest[2] || rest.length !== 3) return usage();
43
+ return { op: "revise", id: rest[0]!, revision, file: rest[2] };
44
+ }
45
+ if (sub === "approve") {
46
+ const revision = int(rest[1]);
47
+ if (!id(rest[0]) || revision === undefined || rest.length !== 2) return usage();
48
+ return { op: "approve", id: rest[0]!, revision };
49
+ }
50
+ if (sub === "close") {
51
+ const revision = int(rest[1]);
52
+ const at = rest.indexOf("--reason");
53
+ if (!id(rest[0]) || revision === undefined || at !== 2) return usage();
54
+ const reason = rest.slice(at + 1).join(" ").trim();
55
+ return reason ? { op: "close", id: rest[0]!, revision, reason } : usage("study close needs a reason");
56
+ }
57
+ return usage();
58
+ }
@@ -104,6 +104,7 @@ function taskBlock(task: any, columns: number | undefined, now: number, detail:
104
104
  ...pair("class", dash(task?.class), columns, w, undefined, " "), ...pair("owner", taskHolderText(task, "owner"), columns, w, undefined, " "), ...pair("reviewer", taskHolderText(task, "reviewer"), columns, w, undefined, " "));
105
105
  if (task?.planner !== undefined) out.push(...pair("planner", taskHolderText(task, "planner"), columns, w, undefined, " "));
106
106
  if (task?.reserved) out.push(...pair("reserved", task.reserved, columns, w, undefined, " "));
107
+ if (task?.handoffOrigin) out.push(...pair("from", `project ${task.handoffOrigin.projectId}, handed off by ${task.handoffOrigin.peer}${typeof task.handoffOrigin.task === "number" ? ` (their task #${task.handoffOrigin.task})` : ""}`, columns, w, "attention", " "));
107
108
  out.push(...pair("after", ids(task?.deps), columns, w, undefined, " "));
108
109
  if (typeof task?.release === "number") out.push(...pair("release", `#${task.release}`, columns, w, undefined, " "));
109
110
  if (typeof task?.rank === "number" || typeof task?.priority === "number") out.push(...pair("planning", [typeof task.rank === "number" ? `rank ${task.rank}` : "", typeof task.priority === "number" ? `priority P${task.priority}` : "", task.pinned ? "pinned" : ""].filter(Boolean).join(", "), columns, w, undefined, " "));
@@ -119,6 +120,11 @@ function taskBlock(task: any, columns: number | undefined, now: number, detail:
119
120
  const refText = [refs.repo, refs.branch ? `branch ${refs.branch}` : "", refs.commit ? `commit ${short(refs.commit)}` : ""].filter(Boolean).join(", ");
120
121
  if (refText) out.push(blank, ...pair("refs", refText, columns, w, undefined, " "));
121
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
+ }
122
128
  const plan = planBlock(task?.plan, columns);
123
129
  if (plan.length) out.push(blank, [s("plan", "muted")], ...plan);
124
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.