@staix/agent-hub 0.27.0 → 0.28.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 (89) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/README.md +11 -3
  3. package/docs/agent-notes/adapters.md +4 -2
  4. package/docs/agent-notes/bus.md +4 -4
  5. package/docs/agent-notes/daemon.md +7 -3
  6. package/docs/agent-notes/tasks.md +5 -0
  7. package/docs/agent-notes/tests.md +6 -5
  8. package/docs/design/adapters.md +76 -0
  9. package/docs/design/agent-hub-architecture.md +67 -0
  10. package/docs/design/agent-hub.md +52 -0
  11. package/docs/design/bus.md +48 -0
  12. package/docs/design/task-board.md +86 -0
  13. package/docs/dogfood.md +16 -1
  14. package/docs/operations.md +308 -12
  15. package/docs/profiles.md +1 -0
  16. package/docs/quickstart.md +11 -3
  17. package/docs/sdlc.md +1 -1
  18. package/docs/security.md +8 -3
  19. package/docs/smoke.md +2 -0
  20. package/docs/specs/2026-09-19-agent-hub-design.md +2 -0
  21. package/docs/verification/2026-10-31-codex-0166-reviewer-isolation.md +2 -0
  22. package/docs/verification/2026-11-02-0.28.0.md +54 -0
  23. package/docs/verification/2026-11-02-autonomy-t4.md +39 -0
  24. package/docs/verification/2026-11-02-codex-0166-reviewer-tool-call.md +53 -0
  25. package/docs/verification/2026-11-02-control-write-after.json +139 -0
  26. package/docs/verification/2026-11-02-control-write-baseline.json +127 -0
  27. package/docs/verification/2026-11-02-control-write-guarded.json +127 -0
  28. package/docs/verification/2026-11-02-control-write-outcomes.md +65 -0
  29. package/docs/verification/2026-11-02-orca-1.4.245-cli-contract.md +51 -0
  30. package/docs/verified.json +13 -13
  31. package/package.json +2 -2
  32. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  33. package/plugins/agent-hub/server.js +338 -112
  34. package/src/adapters/claude-channel.ts +12 -2
  35. package/src/adapters/compat.ts +1 -1
  36. package/src/adapters/definitions/native-cli-claude-setup.ts +31 -9
  37. package/src/adapters/definitions/native-cli-entry-jcode.ts +33 -0
  38. package/src/adapters/definitions/native-cli-entry-qwen.ts +2 -2
  39. package/src/adapters/jcode.ts +37 -4
  40. package/src/adapters/qwen-dual.ts +3 -2
  41. package/src/cli/agents.ts +4 -2
  42. package/src/cli/bench.ts +4 -2
  43. package/src/cli/board-read.ts +25 -0
  44. package/src/cli/claude-homes.ts +2 -2
  45. package/src/cli/console-state.ts +8 -3
  46. package/src/cli/help.ts +6 -5
  47. package/src/cli/identity.ts +9 -1
  48. package/src/cli/init-prompt.ts +179 -0
  49. package/src/cli/init-settings.ts +229 -0
  50. package/src/cli/init.ts +102 -9
  51. package/src/cli/launch.ts +3 -1
  52. package/src/cli/main.ts +106 -35
  53. package/src/cli/output.ts +14 -0
  54. package/src/cli/setup-homes.ts +18 -3
  55. package/src/cli/specs.ts +150 -0
  56. package/src/cli/terminal-recovery.ts +35 -11
  57. package/src/hub/board.ts +56 -3
  58. package/src/hub/bus.ts +54 -7
  59. package/src/hub/child-process.ts +6 -4
  60. package/src/hub/config-apply.ts +4 -1
  61. package/src/hub/config-edit.ts +1 -1
  62. package/src/hub/config-tier.ts +1 -0
  63. package/src/hub/control-client.ts +28 -4
  64. package/src/hub/cost-governance.ts +22 -6
  65. package/src/hub/crash.ts +20 -0
  66. package/src/hub/daemon.ts +304 -56
  67. package/src/hub/delivery-hints.ts +6 -3
  68. package/src/hub/delivery-journal.ts +14 -3
  69. package/src/hub/hub-tools.ts +10 -6
  70. package/src/hub/peers.ts +1 -1
  71. package/src/hub/review-brief.ts +18 -0
  72. package/src/hub/routing.ts +7 -3
  73. package/src/hub/settings.ts +2 -0
  74. package/src/hub/specs-config.ts +43 -0
  75. package/src/hub/specs-task.ts +108 -0
  76. package/src/hub/specs.ts +1008 -0
  77. package/src/hub/task-list-page.ts +75 -0
  78. package/src/hub/task-sweep.ts +10 -1
  79. package/src/hub/tasks.ts +448 -40
  80. package/src/hub/terminal-open.ts +10 -5
  81. package/src/hub/tool-request.ts +131 -0
  82. package/src/ui/office-model.ts +17 -1
  83. package/src/ui/office-space.ts +1 -1
  84. package/templates/config.json +1 -0
  85. package/templates/profiles/dev.json +1 -0
  86. package/templates/profiles/general.json +1 -0
  87. package/templates/profiles/research.json +1 -0
  88. package/templates/profiles/writing.json +1 -0
  89. package/templates/specs.block.md +5 -0
package/src/cli/main.ts CHANGED
@@ -19,7 +19,7 @@ import { parseConfiguredAcpArguments, runConfiguredAcpEntry, requireActiveConfig
19
19
  import type { NativeCliEntryContext } from "../adapters/definitions/native-cli-entry-types.ts";
20
20
  import { BUILTIN_CLI_ENTRIES, createProjectAdapterCatalog, definitionOf, DOCTOR_TOOL_PROBES, DOCTOR_MEMORY_CAPTURE_PEERS, DOCTOR_PLUGIN_PROBE, nativeDoctorCheck, nativeMemoryCaptureRow, type MemorySetupReading, nativeDoctorRecoveryIdentity, nativeDoctorOptionalProbes } from "../adapters/registry.ts";
21
21
  import { parsePiFlags } from "../adapters/definitions/native-cli-pi.ts";
22
- import { supportsBoardReleaseFilter, supportsBoardColumnMetadata } from "./board-read.ts";
22
+ import { supportsBoardReleaseFilter, supportsBoardColumnMetadata, readAllTasks } from "./board-read.ts";
23
23
  import { matchesPolicy } from "../hub/path-policy.ts";
24
24
  import { projectedDiff } from "../hub/projected-diff.ts";
25
25
  import { TextProjections } from "../hub/text-projection.ts";
@@ -70,7 +70,10 @@ import { inspectProject, startProject, stopProject, runProjectDaemon } from "../
70
70
  import { processLiveness, processSignature } from "../pi/process-signature.ts";
71
71
  import { openManager, startManager, stopManager } from "../hub/manager.ts";
72
72
  import { OmniRoute } from "../omniroute/client.ts";
73
- import { init, planInit } from "./init.ts";
73
+ import { autonomyInheritanceChange, init, initSettingRows, planInit } from "./init.ts";
74
+ import { INIT_USAGE, parseInitSettings } from "./init-settings.ts";
75
+ import { promptGate, runInitPrompt, settingLines, shadowOffered, type InitAnswers } from "./init-prompt.ts";
76
+ import { loadPacks } from "../hub/packs.ts";
74
77
  import { launcherPreview } from "./preview.ts";
75
78
  import { instanceLaunchArguments, requirePeerInstances, requireActiveInstance, cleanupStaleClaudeSettings, cleanupClaudeSettings, buildLaunch, buildKimiTuiLaunch, claudeObservationHooks, nativeLaunchEnv, recordClaudeLaunch, runClaudeLaunch, type ClaudeLaunchRecord, UNATTENDED_WARNING } from "./launch.ts";
76
79
  import { configureKimiMcp } from "./kimi-mcp.ts";
@@ -101,6 +104,7 @@ import { armRuns, benchCompare, benchCsv, benchReport, formatCompare, formatRepo
101
104
  import { benchPreflight, defaultIo, runBench } from "./bench.ts";
102
105
  import { ADDONS, isAddon, type AddonName } from "../hub/tooling.ts";
103
106
  import { goalCommand, memoryCommand, memoryStaleAnchorsCommand } from "./memory.ts";
107
+ import { runSpecs, runSpecsStamp } from "./specs.ts";
104
108
  import { formatMemoryStaleAnchors } from "../hub/memory-stale-anchors.ts";
105
109
  import { AGENT_MEM_VERSION, assertAgentMemVersion, selectedMemoryProfile } from "../hub/memory-profile.ts";
106
110
  import { checkMemoryProfiles, formatMemoryProfileCheck } from "../hub/memory-profile-check.ts";
@@ -1037,23 +1041,28 @@ const commands: Record<string, () => Promise<void> | void> = {
1037
1041
  const setupFlags = takeFlags(args.filter(arg => arg !== "--yes"), ["--agents"], []);
1038
1042
  if (setupFlags.rest.length) fail("usage: ahub setup [--yes] [--agents <a,b>]");
1039
1043
  const registry = new Registry(), rl = process.stdin.isTTY ? createInterface({ input: process.stdin, output: process.stdout }) : undefined;
1044
+ let homesFailed = false, homesFailure: unknown;
1040
1045
  try {
1041
1046
  const found = recoveryLauncherCurrent || delegatedPeer ? await discoverClaudeHomes({ home: homedir(), env: process.env }) : await usedClaudeHomes(registry, homedir(), process.env);
1042
1047
  for (const notice of found.notices) console.log(terminalText(notice));
1043
- await setupClaudeHomes({ homes: found.homes, env: process.env, packageRoot: root, yes: args.includes("--yes"),
1044
- interactive: !!rl,
1045
- current: () => { recoveryLauncherCurrent?.(); assertLifecycleAvailable(cwd); },
1046
- print: line => console.log(terminalText(line)), ask: async prompt => rl ? rl.question(prompt) : null,
1047
- probe: (argv, options) => { recoveryLauncherCurrent?.(); const result = spawnSync(argv[0]!, argv.slice(1), { encoding: "utf8", env: options.env, timeout: options.timeoutMs, maxBuffer: 1024 * 1024 }); recoveryLauncherCurrent?.(); return { status: result.status ?? -1, stdout: result.stdout ?? "" }; },
1048
- run: async (argv, env) => { recoveryLauncherCurrent?.(); const result = spawnSync(argv[0]!, argv.slice(1), { stdio: "inherit", env, timeout: 30_000 }); recoveryLauncherCurrent?.(); return result.status ?? -1; },
1049
- });
1048
+ try {
1049
+ await setupClaudeHomes({ homes: found.homes, env: process.env, packageRoot: root, yes: args.includes("--yes"),
1050
+ interactive: !!rl,
1051
+ current: () => { recoveryLauncherCurrent?.(); assertLifecycleAvailable(cwd); },
1052
+ print: line => console.log(terminalText(line)), ask: async prompt => rl ? rl.question(prompt) : null,
1053
+ probe: (argv, options) => { recoveryLauncherCurrent?.(); const result = spawnSync(argv[0]!, argv.slice(1), { encoding: "utf8", env: options.env, timeout: options.timeoutMs, maxBuffer: 1024 * 1024 }); recoveryLauncherCurrent?.(); return { status: result.status ?? -1, stdout: result.stdout ?? "" }; },
1054
+ run: async (argv, env) => { recoveryLauncherCurrent?.(); const result = spawnSync(argv[0]!, argv.slice(1), { stdio: "inherit", env, timeout: 30_000 }); recoveryLauncherCurrent?.(); return result.status ?? -1; },
1055
+ });
1056
+ } catch (error) { homesFailed = true; homesFailure = error; }
1050
1057
  // #608: the one question about the default agent set, after the Claude homes and before doctor.
1051
1058
  if (!recoveryLauncherCurrent && !delegatedPeer) {
1059
+ assertLifecycleAvailable(cwd);
1052
1060
  const refused = await setupStartAgents({ home: hubHome(), roots: [...registry.list().map(row => row.root), cwd], ...(setupFlags.one["--agents"] !== undefined ? { flag: setupFlags.one["--agents"] } : {}), yes: args.includes("--yes"),
1053
1061
  ...(rl ? { ask: async (prompt: string) => { try { return await rl.question(prompt); } catch { return null; } } } : {}), print: line => console.log(terminalText(line)) });
1054
1062
  if (refused) fail(refused);
1055
1063
  }
1056
1064
  } finally { registry.close(); rl?.close(); }
1065
+ if (homesFailed) fail(startupDiagnostic(homesFailure));
1057
1066
  // #935: register the acon console plugin (acon reads it at its next start; no acon install is needed for this).
1058
1067
  const acon = registerAconPlugin(hubHome(), join(root, "src", "ui", "plugin.ts"));
1059
1068
  console.log("error" in acon ? `acon plugin not registered: ${acon.error}` : acon.changed ? `acon plugin registered: ${acon.path}` : `acon plugin up to date: ${acon.path}`);
@@ -1062,28 +1071,69 @@ const commands: Record<string, () => Promise<void> | void> = {
1062
1071
  },
1063
1072
 
1064
1073
  init: async () => {
1065
- const flags = takeFlags(args.filter(arg => arg !== "--dry-run" && arg !== "--json" && arg !== "--shadow" && arg !== "--release-guard"), ["--profile"], []);
1066
- if (flags.rest.length) fail("usage: ahub init [--profile dev|research|writing|general|<pack>] [--shadow] [--release-guard] [--dry-run --json]");
1074
+ // #1065 T1: the settings flags come out first (a value flag takes the next word), then the older flags as before.
1075
+ let settings: ReturnType<typeof parseInitSettings>;
1076
+ try { settings = parseInitSettings(args); } catch (error) { return fail((error as Error).message); }
1077
+ const flags = takeFlags(settings.rest.filter(arg => arg !== "--dry-run" && arg !== "--json" && arg !== "--shadow" && arg !== "--release-guard" && arg !== "--yes"), ["--profile"], []);
1078
+ if (flags.rest.length) fail(INIT_USAGE);
1067
1079
  // A pack is a project file in .agenthub/roles/<name>.pack.json (#513a); init resolves the name and refuses an unknown one with the profiles and packs it knows.
1068
- const requestedProfile = flags.one["--profile"];
1080
+ let requestedProfile = flags.one["--profile"];
1069
1081
  const newProject = !existsSync(join(cwd, ".agenthub/config.json"));
1082
+ let request = settings.request, asked = Object.keys(request.values).length > 0;
1083
+ let shadow = args.includes("--shadow"), releaseGuard = args.includes("--release-guard");
1084
+ // #1065 T2: a person at a terminal on a new project, with no flag that sets anything and no --yes, is asked each setting (init-prompt.ts). Every other run, including a delegated peer, a live test and any run
1085
+ // without a terminal, is never asked and gets defaults plus its flags. The gate is checked here, before any file is read or written, and --dry-run never prompts.
1086
+ const flagGiven = asked || settings.request.autonomy !== undefined || requestedProfile !== undefined || shadow || releaseGuard;
1087
+ if (promptGate({ newProject, flagGiven, yes: args.includes("--yes"), dryRun: args.includes("--dry-run"), stdinIsTTY: !!process.stdin.isTTY, stdoutIsTTY: !!process.stdout.isTTY, role: identity.role, delegated: !!delegatedPeer, liveTest })) {
1088
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
1089
+ // Ctrl-C closes the readline interface, which settles a pending question as null: the prompt ends with nothing written.
1090
+ const interrupted = new Promise<null>(done => rl.once("close", () => done(null)));
1091
+ const ask = (question: string): Promise<string | null> => Promise.race([rl.question(question).catch(() => null), interrupted]);
1092
+ const machine = readAutonomyDefaults(hubHome(), [cwd]);
1093
+ const preview = (answers: InitAnswers): string[] => {
1094
+ const rows = initSettingRows(cwd, answers.profile, answers.shadow, answers.releaseGuard, answers.request);
1095
+ const plan = planInit(cwd, answers.profile, answers.shadow, answers.releaseGuard, answers.request);
1096
+ // The same autonomy-inheritance row `--dry-run` shows, from the one helper: a new project inherits the trusted machine preset after the writes unless the answer was off.
1097
+ const inherit = autonomyInheritanceChange(cwd, machine.enabled, answers.request.autonomy);
1098
+ if (inherit) plan.push(inherit);
1099
+ return [...plan.map(change => `${change.action} ${change.path}: ${change.reason}${change.managedBlock ? ` (managed block: ${change.managedBlock})` : ""}`), ...settingLines(rows)];
1100
+ };
1101
+ let result: Awaited<ReturnType<typeof runInitPrompt>>;
1102
+ try {
1103
+ result = await runInitPrompt({ ask, print: line => console.log(terminalText(line)) },
1104
+ { ...(machine.startAgents ? { machineAgents: machine.startAgents } : {}), packs: [...loadPacks(cwd).packs.keys()], shadowAvailable: shadowOffered(repoOf(cwd)) }, preview);
1105
+ } finally { rl.close(); }
1106
+ if (!result.ok) { console.error(`ahub init: ${result.reason === "refused" ? result.message : result.reason === "interrupted" ? `interrupted; ${result.message}` : result.message}`); process.exitCode = result.reason === "refused" ? 1 : 0; return; }
1107
+ requestedProfile = result.answers.profile; request = result.answers.request; asked = Object.keys(request.values).length > 0; shadow = result.answers.shadow; releaseGuard = result.answers.releaseGuard;
1108
+ }
1109
+ // `--autonomy off` skips the machine inheritance on a new project; `inherit` is today's behaviour. Neither changes an existing project.
1110
+ const inheritAutonomy = newProject && request.autonomy !== "off";
1111
+ if (!newProject && request.autonomy !== undefined) console.error("note: --autonomy applies to a new project only; this project already exists, so its autonomy settings are unchanged");
1070
1112
  if (args.includes("--dry-run")) {
1071
- const plan = planInit(cwd, requestedProfile, args.includes("--shadow"), args.includes("--release-guard"));
1113
+ const plan = planInit(cwd, requestedProfile, shadow, releaseGuard, request);
1114
+ const rows = initSettingRows(cwd, requestedProfile, shadow, releaseGuard, request);
1072
1115
  const defaults = readAutonomyDefaults(hubHome(), [cwd]);
1073
- if (newProject && defaults.enabled) plan.push({ action: existsSync(join(cwd, ".agenthub/config.local.json")) ? "modify" : "create", path: join(cwd, ".agenthub/config.local.json"), reason: "inherit the trusted machine autonomy preset; consent is checked at daemon startup" });
1116
+ const inherit = inheritAutonomy ? autonomyInheritanceChange(cwd, defaults.enabled, request.autonomy) : undefined;
1117
+ if (inherit) plan.push(inherit);
1074
1118
  if (defaults.notice) console.error(defaults.notice);
1075
- return console.log(args.includes("--json") ? JSON.stringify(plan, null, 2) : plan.map(change => `${change.action} ${change.path}: ${change.reason}${change.managedBlock ? ` (managed block: ${change.managedBlock})` : ""}`).join("\n") || "already up to date");
1119
+ // Without a settings flag the preview is the bare list of file rows, as it always was; with one it is { changes, settings }.
1120
+ if (args.includes("--json")) return console.log(JSON.stringify(asked ? { changes: plan, settings: rows } : plan, null, 2));
1121
+ const settingLines = rows.map(row => ` ${row.key}: ${row.from === undefined ? "(not set)" : JSON.stringify(row.from)} -> ${JSON.stringify(row.to)} (${row.file})`);
1122
+ return console.log([...plan.map(change => `${change.action} ${change.path}: ${change.reason}${change.managedBlock ? ` (managed block: ${change.managedBlock})` : ""}`), ...settingLines].join("\n") || "already up to date");
1076
1123
  }
1077
1124
  assertLifecycleAvailable(cwd);
1078
- if (newProject && delegatedPeer && readAutonomyDefaults(hubHome(), [cwd]).enabled) fail("machine autonomy inheritance is person-only; delegated live tests cannot apply it");
1079
- const changed = init(cwd, requestedProfile, args.includes("--shadow"), args.includes("--release-guard"));
1125
+ if (inheritAutonomy && delegatedPeer && readAutonomyDefaults(hubHome(), [cwd]).enabled) fail("machine autonomy inheritance is person-only; delegated live tests cannot apply it (ahub init --autonomy off skips it)");
1126
+ let changed: string[];
1127
+ try { changed = init(cwd, requestedProfile, shadow, releaseGuard, request); } catch (error) { return fail((error as Error).message); }
1080
1128
  const project = registeredProject();
1081
1129
  console.log(changed.length ? changed.map((p) => `${existsSync(p) ? "wrote" : "removed"} ${p}`).join("\n") : "already up to date");
1082
1130
  if (newProject) {
1083
- const registry = new Registry();
1084
- try { for (const line of await inheritMachineAutonomy(project, hubHome(), autonomyEnvironment(process.env, !!process.stdin.isTTY), registry)) console.log(terminalText(line)); }
1085
- finally { registry.close(); }
1086
- // #608: the machine's default agent set, copied the same way (person-only: a delegated live test never inherits it).
1131
+ if (inheritAutonomy) {
1132
+ const registry = new Registry();
1133
+ try { for (const line of await inheritMachineAutonomy(project, hubHome(), autonomyEnvironment(process.env, !!process.stdin.isTTY), registry)) console.log(terminalText(line)); }
1134
+ finally { registry.close(); }
1135
+ }
1136
+ // #608: the machine's default agent set, copied the same way (person-only: a delegated live test never inherits it). An explicit --agents is already in config.local.json, so it wins.
1087
1137
  if (!delegatedPeer) { const roots = (() => { const r = new Registry(); try { return r.list().map(row => row.root); } finally { r.close(); } })(); for (const line of inheritMachineStart(cwd, hubHome(), [...roots, cwd])) console.log(terminalText(line)); }
1088
1138
  }
1089
1139
  const config = projectConfig(), examples = resolveProfile(config.profile, config.profile_overrides).example_checks;
@@ -1201,29 +1251,42 @@ const commands: Record<string, () => Promise<void> | void> = {
1201
1251
  const hub = await connect();
1202
1252
  try {
1203
1253
  if (operation === "list") {
1204
- const { one, rest: flags } = takeFlags(rest, ["--peer"], []);
1205
- if (flags.length) fail("usage: ahub queue list [--peer <id>] [--json] [--full] [--color=auto|always|never]");
1206
- const result = await hub.request({ t: "queue", op: "list", ...(one["--peer"] ? { peer: one["--peer"] } : {}) });
1254
+ const all = rest.includes("--all");
1255
+ const { one, rest: flags } = takeFlags(rest.filter((word) => word !== "--all"), ["--peer"], []);
1256
+ if (flags.length) fail("usage: ahub queue list [--peer <id>] [--all] [--json] [--full] [--color=auto|always|never]");
1257
+ const result = await hub.request({ t: "queue", op: "list", ...(one["--peer"] ? { peer: one["--peer"] } : {}), ...(all ? { all: true } : {}) });
1207
1258
  if (!result.ok) fail(result.error,result.refusalAudited===true,result.authorityRefused);
1208
1259
  if (options.json) return console.log(JSON.stringify(result.deliveries, null, 2));
1209
1260
  printOutput(renderQueue(result.deliveries, options.columns, Date.now()), options.color);
1261
+ // #1096: rows a resolution already settled are history, hidden by default; say how many and how to see them, on stderr so a pipe of the table stays clean.
1262
+ if (result.hiddenSettled) console.error(`${result.hiddenSettled} settled deliver${result.hiddenSettled === 1 ? "y" : "ies"} hidden (already resolved); ahub queue list --all shows them`);
1210
1263
  return;
1211
1264
  }
1212
1265
  if (operation === "discard") {
1213
1266
  // #713: the person's bulk clear of one peer's queue. Without --yes it only says how many it would discard.
1214
1267
  const yes = rest.includes("--yes");
1215
1268
  const { one, rest: others } = takeFlags(rest.filter((word) => word !== "--yes"), ["--peer", "--reason", "--state"], []);
1216
- if (others.length || !one["--peer"] || (one["--state"] !== undefined && !["queued", "needs_review"].includes(one["--state"]))) fail("usage: ahub queue discard --peer <id> --reason <text> [--state queued|needs_review] [--yes]");
1269
+ if (others.length || !one["--peer"] || (one["--state"] !== undefined && !["queued", "needs_review", "failed"].includes(one["--state"]))) fail("usage: ahub queue discard --peer <id> --reason <text> [--state queued|needs_review|failed] [--yes]; without --state it clears the peer's queued and unsettled failed deliveries");
1217
1270
  if (yes && !one["--reason"]?.trim()) fail("ahub queue discard needs --reason <text> with --yes: it is recorded on every discarded delivery");
1218
- const result = await hub.request({ t: "queue", op: "discard", peer: one["--peer"], ...(one["--state"] ? { state: one["--state"] } : {}), ...(yes ? { confirmed: true, reason: one["--reason"] } : {}) }, 60_000);
1271
+ // #1096: with no --state the bulk clear takes the peer's queued rows and its unsettled failed rows (needs_review rows still need an explicit --state, as before); an explicit --state names exactly one kind.
1272
+ const states = one["--state"] === undefined ? ["queued", "failed"] : [one["--state"]];
1273
+ // A hub from before `queue_discard_states` ignores `states` and discards queued rows whatever kind was asked for, so the new wire form is never sent to it. A single queued or needs_review kind still goes in the legacy `state` field that every hub reads.
1274
+ const legacy = states.length === 1 && (states[0] === "queued" || states[0] === "needs_review");
1275
+ if (!legacy && !hub.features.includes("queue_discard_states")) fail(`the running hub predates failed-row discard and would discard queued rows instead of ${one["--state"] === undefined ? "queued and failed rows" : `${one["--state"]} rows`}; nothing was sent. Upgrade and restart it first, or run ahub queue discard --peer ${one["--peer"]} --state queued to clear queued rows only`);
1276
+ const result = await hub.request({ t: "queue", op: "discard", peer: one["--peer"], ...(legacy ? { state: states[0] } : { states }), ...(yes ? { confirmed: true, reason: one["--reason"] } : {}) }, 60_000);
1219
1277
  if (!result.ok) fail(result.error,result.refusalAudited===true,result.authorityRefused);
1220
1278
  if (options.json) return console.log(JSON.stringify(result, null, 2));
1221
- if (!yes) return console.log(`${result.count} ${result.state} deliver${result.count === 1 ? "y" : "ies"} for ${result.peer}; nothing discarded. Run again with --reason <text> --yes to discard them.`);
1222
- console.log(`discarded ${result.discarded} of ${result.count} ${result.state} deliver${result.count === 1 ? "y" : "ies"} for ${result.peer}${result.refused ? `; ${result.refused} refused (${Object.entries(result.refusedBy ?? {}).map(([why, n]) => `${why} ${n}`).join(", ")})` : ""}`);
1279
+ // One kind keeps the sentence this command always printed ("12 queued deliveries for claude"); a mix of kinds names each ("3 queued and 2 failed deliveries for claude").
1280
+ const present = Object.entries(result.byState ?? {}).filter(([, n]) => n) as [string, number][];
1281
+ const noun = `deliver${result.count === 1 ? "y" : "ies"}`;
1282
+ const what = present.length > 1 ? `${result.count} ${noun}` : `${result.count} ${present[0]?.[0] ?? states.join(" or ")} ${noun}`;
1283
+ const split = present.length > 1 ? ` (${present.map(([kind, n]) => `${n} ${kind}`).join(" and ")})` : "";
1284
+ if (!yes) return console.log(`${what} for ${result.peer}${split}; nothing discarded. Run again with --reason <text> --yes to discard them.`);
1285
+ console.log(`discarded ${result.discarded} of ${what} for ${result.peer}${split}${result.refused ? `; ${result.refused} refused (${Object.entries(result.refusedBy ?? {}).map(([why, n]) => `${why} ${n}`).join(", ")})` : ""}`);
1223
1286
  return;
1224
1287
  }
1225
1288
  const [id, ...flags] = rest;
1226
- if (!id || !["show", "resolve"].includes(operation)) fail("usage: ahub queue show <id> | resolve <id> --action completed|retry|discard --reason <text> [--revision N] | discard --peer <id> --reason <text> [--state queued|needs_review] [--yes]");
1289
+ if (!id || !["show", "resolve"].includes(operation)) fail("usage: ahub queue show <id> | resolve <id> --action completed|retry|discard --reason <text> [--revision N] | discard --peer <id> --reason <text> [--state queued|needs_review|failed] [--yes]");
1227
1290
  if (operation === "show") {
1228
1291
  if (flags.length) fail("usage: ahub queue show <id>");
1229
1292
  const shown = await hub.request({ t: "queue", op: "show", id });
@@ -1628,11 +1691,11 @@ const commands: Record<string, () => Promise<void> | void> = {
1628
1691
  const state = rest.find((a) => !a.startsWith("--"));
1629
1692
  if (byColumn && !boardStatus) boardStatus = (await boardHub.request({ t: "status" })).status;
1630
1693
  if (byColumn && !supportsBoardColumnMetadata(boardStatus)) fail("running hub does not support accurate board column metadata; use a compatible daemon");
1631
- const tasks = JSON.parse(await taskOp("hub_task_list", { ...(ready ? { ready: true } : state ? { state } : {}), ...(release ? { release } : {}), ...(byColumn ? { columnMetadata: true } : {}) }, false, boardHub)) as any[];
1694
+ const tasks = await readAllTasks(args => taskOp("hub_task_list", args, false, boardHub), { ...(ready ? { ready: true } : state ? { state } : {}), ...(release ? { release } : {}), ...(byColumn ? { columnMetadata: true } : {}) }, identity.role === "tools");
1632
1695
  if (options.json && !byColumn) return console.log(JSON.stringify(tasks, null, 2));
1633
1696
  let allTasks: any[] | null = tasks;
1634
1697
  if (ready || state || release) {
1635
- try { allTasks = JSON.parse(await taskOp("hub_task_list", byColumn ? { columnMetadata: true } : {}, true, boardHub)) as any[]; }
1698
+ try { allTasks = await readAllTasks(args => taskOp("hub_task_list", args, true, boardHub), byColumn ? { columnMetadata: true } : {}, identity.role === "tools"); }
1636
1699
  catch (error) {
1637
1700
  if (byColumn) fail("board column dependency context unavailable; retry with a complete snapshot");
1638
1701
  allTasks = null;
@@ -1871,13 +1934,13 @@ const commands: Record<string, () => Promise<void> | void> = {
1871
1934
  if (sub !== "propose" || rest.length < 1) fail("usage: ahub task propose [<class>] <title...> | show <id> | assign <id> <peer> | unassign <id> | cancel <id> --reason <text> | escalate <id> | handoff --project <root|id> <title...>");
1872
1935
  // `--class` is the explicit form. A first word that is a class name is still taken as the class (the documented
1873
1936
  // short form), but said out loud: "review the auth module" would otherwise be filed as class review, silently.
1874
- const urgent = rest.includes("--urgent"), backlog = rest.includes("--backlog");
1875
- const flags = takeFlags(rest.filter((a) => a !== "--urgent" && a !== "--backlog"), ["--owner", "--detail", "--class"], ["--path", "--after"]);
1937
+ const urgent = rest.includes("--urgent"), backlog = rest.includes("--backlog"), needsNetwork = rest.includes("--needs-network"), needsWorktree = rest.includes("--needs-worktree");
1938
+ const flags = takeFlags(rest.filter((a) => a !== "--urgent" && a !== "--backlog" && a !== "--needs-network" && a !== "--needs-worktree"), ["--owner", "--detail", "--class"], ["--path", "--after"]);
1876
1939
  const positional = !flags.one["--class"] && ((CLASSES as readonly string[]).includes(flags.rest[0] ?? "") || Object.hasOwn((() => { const config = projectConfig(); return resolveProfile(config.profile, config.profile_overrides); })().classes, flags.rest[0] ?? "")) && flags.rest.length > 1;
1877
1940
  const cls = flags.one["--class"] ?? (positional ? flags.rest[0] : undefined);
1878
1941
  const title = (positional ? flags.rest.slice(1) : flags.rest).join(" ");
1879
1942
  if (positional) console.error(`note: "${cls}" was taken as the class and left out of the title; use --class <c> when the title itself starts with that word`);
1880
- console.log(await taskOp("hub_task_propose", { ...(cls ? { class: cls } : {}), title, owner: flags.one["--owner"], detail: flags.one["--detail"], ...(flags.many["--path"]?.length ? { refs: { paths: flags.many["--path"] } } : {}), ...(flags.many["--after"]?.length ? { after: flags.many["--after"].map(Number) } : {}), ...(urgent ? { urgent: true } : {}), ...(backlog ? { backlog: true } : {}) }));
1943
+ console.log(await taskOp("hub_task_propose", { ...(cls ? { class: cls } : {}), title, owner: flags.one["--owner"], detail: flags.one["--detail"], ...(flags.many["--path"]?.length ? { refs: { paths: flags.many["--path"] } } : {}), ...(flags.many["--after"]?.length ? { after: flags.many["--after"].map(Number) } : {}), ...(urgent ? { urgent: true } : {}), ...(needsNetwork ? { needs_network: true } : {}), ...(needsWorktree ? { needs_worktree: true } : {}), ...(backlog ? { backlog: true } : {}) }));
1881
1944
  },
1882
1945
 
1883
1946
  review: async () => {
@@ -2220,7 +2283,7 @@ const commands: Record<string, () => Promise<void> | void> = {
2220
2283
  if (args.includes("--all")) return printProjects(options);
2221
2284
  const hub = await connect();
2222
2285
  try {
2223
- const { status } = await hub.request({ t: "status" });
2286
+ const { status } = await hub.request({ t: "status", specs: true });
2224
2287
  if (options.json) return console.log(JSON.stringify(status, null, 2));
2225
2288
  const quota = await hub.request({ t: "budget" });
2226
2289
  printOutput(renderStatus({ ...status, budget: quota.budget ?? {} }, options.columns, Date.now(), options.full), options.color);
@@ -2373,6 +2436,14 @@ const commands: Record<string, () => Promise<void> | void> = {
2373
2436
  if (options.json) return console.log(JSON.stringify(data, null, 2));
2374
2437
  printOutput(renderReport(data, options.columns, Date.now(), by >= 0), options.color);
2375
2438
  },
2439
+ specs: async () => {
2440
+ if (args[0] !== "stamp") { process.exitCode = runSpecs(args, cwd, loadConfig(cwd).specs); return; }
2441
+ // #1064 T3: person only. An agent identity never gets here (stamp is a `console` command in classifyPeerCommand); runSpecsStamp also refuses a run with no terminal.
2442
+ const rl = process.stdin.isTTY && process.stdout.isTTY ? createInterface({ input: process.stdin, output: process.stdout }) : undefined;
2443
+ try {
2444
+ process.exitCode = await runSpecsStamp(args.slice(1), cwd, loadConfig(cwd).specs, { isTTY: !!rl, ask: async question => /^y(es)?$/i.test((await rl!.question(`${question} [y/N] `)).trim()) });
2445
+ } finally { rl?.close(); }
2446
+ },
2376
2447
  usage: async () => {
2377
2448
  const options = outputOptions();
2378
2449
  const rest = outputArguments();
package/src/cli/output.ts CHANGED
@@ -10,9 +10,11 @@ import { ownerIsolationText } from "./role-holders.ts";
10
10
  import { schedulePeriodText, scheduleWhen } from "../hub/schedules.ts";
11
11
  import { formatReport, formatTaskReport, type Report, type TaskReport } from "../hub/report.ts";
12
12
  import type { Counter, LimitsResult, TotalResult, UsageResult } from "../hub/usage-rollup.ts";
13
+ import type { SpecsStatus } from "../hub/specs-task.ts";
13
14
 
14
15
  export interface DoctorCheck { section: "Tools" | "Hub" | "Config" | "Models" | "Memory"; level: "ok" | "warn" | "fail" | "unknown"; name: string; detail: string }
15
16
  export interface StatusOutput {
17
+ specs?: SpecsStatus & { missingBlockHint?: boolean };
16
18
  queueBackpressure?: QueueBackpressureStatus;
17
19
  pendingRestart?: readonly { key: string; reason: string }[];
18
20
  retainedConfig?: readonly { key: string; reason: string }[];
@@ -211,6 +213,13 @@ export function renderStatus(data: StatusOutput, columns?: number, now = Date.no
211
213
  if (data.roleInfo) data = { ...data, peers: Object.fromEntries(Object.entries(data.peers).map(([peer, value]) => [peer, { ...value, ...data.roleInfo?.[peer] }])) };
212
214
  const out = lines(`agent-hub${data.version ? ` ${data.version}` : ""} pid ${data.pid ?? "unknown"} control 127.0.0.1:${data.controlPort ?? "unknown"}`, columns, "info");
213
215
  if (data.cwd) out.push(...lines(data.cwd, columns));
216
+ const specs = data.specs;
217
+ if (specs && [specs.specCount, specs.documents, specs.errors, specs.stale].every(value => Number.isSafeInteger(value) && value >= 0)
218
+ && (specs.specCount > 0 || specs.documents > 0 || specs.errors > 0)) {
219
+ out.push(...lines(`specs: ${specs.specCount} (errors ${specs.errors}, stale ${specs.stale})`, columns, specs.errors || specs.stale ? "attention" : undefined));
220
+ if (specs.truncated) out.push(...lines("specs: report truncated; narrow specs.roots and run ahub specs check", columns, "attention"));
221
+ if (specs.missingBlockHint) out.push(...lines("specs: AGENTS.md has no specs instruction block; read the covering specs before changing their paths", columns, "attention"));
222
+ }
214
223
  for (const pending of data.pendingRestart ?? []) out.push(...detail("pending restart", `${pending.key}: ${pending.reason}`, columns, "attention"));
215
224
  if (data.mainCheckout) out.push(...detail("main checkout", data.mainCheckout, columns, "attention"));
216
225
  for (const drift of data.agentDrift ?? []) out.push(...detail("agent CLI drift", `${drift.id} ${drift.version} ${drift.state}; last receipt ${drift.receipt}`, columns, "attention"));
@@ -532,6 +541,11 @@ function fields(value: unknown, columns: number | undefined, now: number, prefix
532
541
  export function renderQueueShow(delivery: any, columns?: number, now = Date.now()): Span[][] {
533
542
  const out = [...lines("Delivery", columns, "strong"), ...fields(delivery, columns, now)];
534
543
  if (delivery?.state === "needs_review" && delivery.id) out.push([], ...detail("resolve", `ahub queue resolve ${delivery.id} --action completed|retry|discard --reason <text>`, columns, "failure"));
544
+ // #1096: a failed row that no one has settled can be cleared by the person or the conductor; one a resolution already settled says what is left (nothing).
545
+ else if (delivery?.state === "failed" && delivery.id) {
546
+ if (delivery.resolvedBy) out.push([], ...detail("resolve", `already resolved by ${delivery.resolvedBy}; nothing is left to do for this row${delivery.resolvedBy === "retry" ? " (its redelivery is a separate delivery: ahub queue list)" : ""}`, columns, "muted"));
547
+ else out.push([], ...detail("resolve", `ahub queue resolve ${delivery.id} --action discard|completed --reason <text> (discard to drop it, completed to acknowledge it as handled; the person or the conductor)`, columns, "failure"));
548
+ }
535
549
  return out;
536
550
  }
537
551
  export function renderModelsStatus(status: unknown, columns?: number, now = Date.now()): Span[][] {
@@ -1,6 +1,8 @@
1
1
  import { checkClaudeHome, claudeHomeEnvironment, type ClaudeHome, type ClaudeHomeProbe } from "./claude-homes.ts";
2
2
  import { installClaudeProfileHook } from "./claude-profile-hook.ts";
3
3
 
4
+ const marketplaceRemovalRefusal = (home: string) => new Error(`Claude setup refused marketplace removal in ${home}; non-user plugin rows were preserved`);
5
+
4
6
  export interface SetupHomesPorts {
5
7
  env: NodeJS.ProcessEnv;
6
8
  packageRoot: string;
@@ -21,9 +23,11 @@ export async function setupClaudeHomes(ports: SetupHomesPorts): Promise<void> {
21
23
  for (const home of ports.homes) initial.push(await checkClaudeHome(home, ports.packageRoot, ports.env, ports.probe, undefined, true));
22
24
  for (const check of initial) {
23
25
  ports.print(`Claude home ${check.home.path}: plugin ${check.plugin.state}; hook ${check.hook.state}.`);
26
+ for (const notice of ("notices" in check.plugin ? check.plugin.notices : undefined) ?? []) ports.print(`${check.home.path}: ${notice}.`);
24
27
  if (check.plugin.state === "unknown" || check.hook.state === "refused") throw new Error(`Claude setup cannot safely inspect ${check.home.path}; nothing was changed`);
28
+ if (check.next?.blocked) throw marketplaceRemovalRefusal(check.home.path);
25
29
  }
26
- for (const check of initial) if (check.next) ports.print(`${check.home.path}: ${check.next.argv.join(" ")} (${check.next.why})`);
30
+ for (const check of initial) if (check.next) ports.print(`${check.home.path}: ${check.next.blocked ? "setup blocked" : check.next.argv.join(" ")} (${check.next.why})`);
27
31
  if (initial.every(check => !check.next && check.hook.state === "installed")) return;
28
32
  if (!ports.yes && ports.interactive === false) throw new Error("not a terminal: nothing was changed; rerun with --yes to apply");
29
33
  if (!ports.yes && !["y", "yes"].includes((await ports.ask("Install the hub plugin and project-neutral hook in these Claude homes? [y/N] "))?.trim().toLowerCase() ?? "")) { ports.print("Claude setup was not changed."); return; }
@@ -31,11 +35,22 @@ export async function setupClaudeHomes(ports: SetupHomesPorts): Promise<void> {
31
35
  for (const check of initial) {
32
36
  let current = check;
33
37
  for (let step = 0; current.next && step < 6; step++) {
34
- ports.print(`${check.home.path}: ${current.next.argv.join(" ")} (${current.next.why})`);
38
+ const action = current.next;
39
+ if (action.blocked) throw marketplaceRemovalRefusal(check.home.path);
40
+ ports.print(`${check.home.path}: ${action.argv.join(" ")} (${action.why})`);
35
41
  ports.current?.();
36
- if (await ports.run(current.next.argv, claudeHomeEnvironment(check.home, ports.env)) !== 0) throw new Error(`Claude plugin setup failed for ${check.home.path}; no later home was changed`);
42
+ if (action.argv[2] === "marketplace" && action.argv[3] === "remove") {
43
+ const fresh = await checkClaudeHome({ ...check.home, exists: true }, ports.packageRoot, ports.env, ports.probe, undefined, true);
44
+ if (fresh.plugin.state === "unknown") throw new Error(`Claude plugin inventory is unavailable for ${check.home.path}`);
45
+ if (fresh.next?.blocked) throw marketplaceRemovalRefusal(check.home.path);
46
+ if (JSON.stringify(fresh.next?.argv) !== JSON.stringify(action.argv)) { current = fresh; continue; }
47
+ current = fresh;
48
+ ports.current?.();
49
+ }
50
+ if (await ports.run(action.argv, claudeHomeEnvironment(check.home, ports.env)) !== 0) throw new Error(`Claude plugin setup failed for ${check.home.path}; no later home was changed`);
37
51
  current = await checkClaudeHome({ ...check.home, exists: true }, ports.packageRoot, ports.env, ports.probe, undefined, true);
38
52
  if (current.plugin.state === "unknown") throw new Error(`Claude plugin inventory is unavailable for ${check.home.path}`);
53
+ if (current.next?.blocked) throw marketplaceRemovalRefusal(check.home.path);
39
54
  }
40
55
  if (current.next) throw new Error(`Claude setup exceeded six plugin steps for ${check.home.path}`);
41
56
  ports.current?.();
@@ -0,0 +1,150 @@
1
+ import { chmodSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+ import { SpecsEngine, stampFrontmatter, type SpecsConfig, type SpecsStampRow } from "../hub/specs.ts";
4
+ import { terminalText } from "./console-state.ts";
5
+ import { editJsonText } from "./init-settings.ts";
6
+
7
+ export interface SpecsOutput { out(line: string): void; error(line: string): void }
8
+ const output: SpecsOutput = { out: line => console.log(line), error: line => console.error(line) };
9
+
10
+ /** Read-only repository inspection: no daemon connection or stamp mutation. */
11
+ export function runSpecs(args: readonly string[], root: string, config: SpecsConfig, io: SpecsOutput = output): number {
12
+ const [command, ...rest] = args;
13
+ const usage = "usage: ahub specs list [--json] | check [--require-fresh] [--base <ref>] [--json] | covers <file...>";
14
+ if (!["list", "check", "covers"].includes(command ?? "")) { io.error(usage); return 1; }
15
+ let json = false, requireFresh = false, base: string | undefined;
16
+ const paths: string[] = [];
17
+ for (let index = 0; index < rest.length; index++) {
18
+ const arg = rest[index]!;
19
+ if (arg === "--json" && command !== "covers" && !json) json = true;
20
+ else if (arg === "--require-fresh" && command === "check" && !requireFresh) requireFresh = true;
21
+ else if (arg === "--base" && command === "check" && base === undefined) {
22
+ base = rest[++index];
23
+ if (!base || base.startsWith("-")) { io.error("specs: --base needs a Git reference; name a commit or branch"); return 1; }
24
+ } else if (command === "covers" && !arg.startsWith("-")) paths.push(arg);
25
+ else { io.error(usage); return 1; }
26
+ }
27
+ if (command === "covers" && !paths.length) { io.error(usage); return 1; }
28
+ try {
29
+ const engine = new SpecsEngine(root, config);
30
+ if (command === "list" || command === "covers") {
31
+ const report = command === "list" ? engine.list() : engine.covers(paths);
32
+ if (json) io.out(JSON.stringify(report));
33
+ else {
34
+ for (const spec of report.specs) io.out(terminalText(`${spec.id} (${spec.path}): ${spec.title}`));
35
+ for (const item of report.items) io.error(terminalText(`specs: ${item.severity} ${item.code}: ${item.message}; ${item.nextSteps}`));
36
+ if (report.truncated) io.error("specs: scan truncated; narrow specs.roots and run ahub specs check again");
37
+ }
38
+ return report.errors ? 1 : 0;
39
+ }
40
+ const report = engine.check({ ...(base === undefined ? {} : { base }), requireFresh });
41
+ if (json) io.out(JSON.stringify(report));
42
+ else if (report.specCount || report.freshness.length || report.items.length || report.truncated) {
43
+ for (const item of report.items) io.out(terminalText(`specs: ${item.severity} ${item.code}: ${item.message}; ${item.nextSteps}`));
44
+ for (const row of report.freshness) if (row.state === "stale") {
45
+ io.out(terminalText(`specs: ${row.path}: stale (${row.commitsSince ?? "unknown"} source commits${row.dirty ? "; uncommitted source changes" : ""})`));
46
+ }
47
+ if (report.truncated) io.out("specs: scan truncated; narrow specs.roots and run ahub specs check again");
48
+ io.out(`specs: ${report.specCount} specs, ${report.errors} errors, ${report.warnings} warnings, ${report.stale} stale`);
49
+ }
50
+ return report.errors || requireFresh && report.stale ? 1 : 0;
51
+ } catch (error) {
52
+ io.error(terminalText(`specs: ${error instanceof Error ? error.message : "inspection unavailable"}; check the repository and specs configuration, then run ahub specs check again`));
53
+ return 1;
54
+ }
55
+ }
56
+
57
+ interface PreparedStamp { path: string; absolute: string; original: Buffer; next: Buffer; mode: number }
58
+ /** The new bytes for every file a stamp would change, made without writing; a string is the reason one could not be made. A stamps file takes all of its entries in one write. */
59
+ function prepareStamps(root: string, rows: readonly SpecsStampRow[]): PreparedStamp[] | string {
60
+ const byFile = new Map<string, SpecsStampRow[]>();
61
+ for (const row of rows) byFile.set(row.path, [...(byFile.get(row.path) ?? []), row]);
62
+ const files: PreparedStamp[] = [];
63
+ for (const [path, group] of byFile) {
64
+ const absolute = resolve(root, path), original = readFileSync(absolute), mode = statSync(absolute).mode & 0o777; // the file keeps its own permissions
65
+ if (group[0]!.kind === "stamps-file") {
66
+ let text = original.toString("utf8");
67
+ for (const row of group) text = editJsonText(text, ["documents", row.document!, "verifiedAgainst"], row.to);
68
+ JSON.parse(text); // the edit must still be the JSON the engine reads
69
+ files.push({ path, absolute, original, next: Buffer.from(text, "utf8"), mode });
70
+ } else {
71
+ const done = stampFrontmatter(original.toString("latin1"), group[0]!.to);
72
+ if (!done.ok) return `${path}: ${done.error}`;
73
+ files.push({ path, absolute, original, next: Buffer.from(done.text, "latin1"), mode });
74
+ }
75
+ }
76
+ return files;
77
+ }
78
+
79
+ /** What `ahub specs stamp` needs from its surroundings: whether a person is at a terminal, and a yes/no question to ask them. Tests pass their own. */
80
+ export interface SpecsStampTerminal { isTTY: boolean; ask(question: string): Promise<boolean> }
81
+
82
+ const STAMP_USAGE = "usage: ahub specs stamp <id-or-document>... [--yes]";
83
+ const writeAtomic = (path: string, bytes: Buffer, mode: number): void => {
84
+ const temporary = `${path}.${process.pid}.${Date.now()}.tmp`;
85
+ writeFileSync(temporary, bytes, { mode, flag: "wx" });
86
+ chmodSync(temporary, mode);
87
+ renameSync(temporary, path);
88
+ };
89
+
90
+ /**
91
+ * #1064 T3: `ahub specs stamp <id-or-document>... [--yes]`, a person's record that they reviewed a spec or stamped document against HEAD.
92
+ * Person only, three ways: main.ts refuses any agent identity before this runs (the command is `console` class, and `--live-test` delegation is refused for it too), this refuses
93
+ * a run without a terminal even with --yes, and nothing here talks to the hub. It shows what it would write (the old and new stamp per document), asks, and then changes only the
94
+ * `verified-against` line of a spec, or the `verifiedAgainst` value of a stamps-file entry, keeping every other byte. It is refused, writing nothing, while a document's covered
95
+ * paths have uncommitted changes. The new stamp is the full HEAD SHA, so it is an ancestor of HEAD.
96
+ */
97
+ export async function runSpecsStamp(args: readonly string[], root: string, config: SpecsConfig, terminal: SpecsStampTerminal, io: SpecsOutput = output): Promise<number> {
98
+ const targets: string[] = [];
99
+ let yes = false;
100
+ for (const arg of args) {
101
+ if (arg === "--yes" && !yes) yes = true;
102
+ else if (!arg.startsWith("-")) targets.push(arg);
103
+ else { io.error(STAMP_USAGE); return 1; }
104
+ }
105
+ if (!targets.length) { io.error(STAMP_USAGE); return 1; }
106
+ if (!terminal.isTTY) {
107
+ io.error("specs: stamping records a person's review and needs a terminal, even with --yes; run ahub specs stamp in your own terminal, or in ahub console; nothing was written");
108
+ return 1;
109
+ }
110
+ try {
111
+ const engine = new SpecsEngine(root, config);
112
+ const plan = engine.stampPlan(targets);
113
+ if (plan.refusals.length) {
114
+ for (const refusal of plan.refusals) io.error(terminalText(`specs: ${refusal}`));
115
+ io.error("specs: nothing was written");
116
+ return 1;
117
+ }
118
+ const short = (sha: string | null) => sha === null ? "none" : sha.slice(0, 12);
119
+ io.out(terminalText(`stamp against HEAD ${plan.head.slice(0, 12)}:`));
120
+ for (const row of plan.rows) io.out(terminalText(` ${row.path}${row.document ? ` (${row.document})` : ""}: ${short(row.from)} -> ${short(row.to)}${row.from === row.to ? " (already this stamp)" : ""}`));
121
+ const changing = plan.rows.filter(row => row.from !== row.to);
122
+ if (!changing.length) { io.out("specs: every named document already holds this stamp; nothing to write"); return 0; }
123
+ // Every file's new bytes are made before anyone is asked, so a document that cannot be stamped is refused up front and no file is written for it, and what the person approves is
124
+ // exactly what is written. `original` is what the new bytes were made from.
125
+ const prepared = prepareStamps(engine.rootPath, changing);
126
+ if (typeof prepared === "string") { io.error(terminalText(`specs: ${prepared}; nothing was written`)); return 1; }
127
+ if (!yes && !await terminal.ask(`Record that you reviewed ${changing.length === 1 ? "this document" : `these ${changing.length} documents`} against HEAD ${plan.head.slice(0, 12)}?`)) {
128
+ io.out("specs: not stamped; nothing was written");
129
+ return 0;
130
+ }
131
+ // The answer may have taken minutes. Revalidate what the person was shown before any write: HEAD, every target's plan row (so a covered path that became dirty is refused), and each
132
+ // file's bytes. Any difference refuses the whole run.
133
+ const again = engine.stampPlan(targets);
134
+ const changed = again.refusals.length ? again.refusals.map(refusal => `specs: ${refusal}`)
135
+ : again.head !== plan.head || JSON.stringify(again.rows) !== JSON.stringify(plan.rows) ? ["specs: HEAD or a document's covered paths changed while you were deciding"]
136
+ : prepared.filter(file => !readFileSync(file.absolute).equals(file.original)).map(file => `specs: ${file.path} changed while you were deciding`);
137
+ if (changed.length) {
138
+ for (const line of changed) io.error(terminalText(line));
139
+ io.error("specs: nothing was written; review the current state and run ahub specs stamp again");
140
+ return 1;
141
+ }
142
+ // ponytail: a change in the instant between this recheck and the renames below is not seen; each rename is atomic and the diff is the person's to review before committing, so there is no lock. A lock file if two people stamp one checkout.
143
+ for (const file of prepared) writeAtomic(file.absolute, file.next, file.mode);
144
+ io.out(`specs: stamped ${changing.length} document${changing.length === 1 ? "" : "s"} at ${plan.head.slice(0, 12)}; review the diff and commit it`);
145
+ return 0;
146
+ } catch (error) {
147
+ io.error(terminalText(`specs: ${error instanceof Error ? error.message : "stamping unavailable"}; check the repository and specs configuration, then run ahub specs stamp again`));
148
+ return 1;
149
+ }
150
+ }
@@ -225,7 +225,7 @@ function runnerFailure(argv: readonly string[], result: CommandResult): Recovery
225
225
  async function run(runner: CommandRunner, argv: readonly string[]): Promise<unknown> {
226
226
  const result = await runner(argv);
227
227
  if (commandStatus(result) !== 0) {
228
- // Orca 1.4.223 answers a handle whose terminal was removed with `{"ok":false,"error":{"code":"terminal_handle_stale"}}`.
228
+ // Orca 1.4.223 and, rechecked read-only, 1.4.245 (docs/verification/2026-11-02-orca-1.4.245-cli-contract.md) answer a handle whose terminal was removed with `{"ok":false,"error":{"code":"terminal_handle_stale"}}`.
229
229
  if (errorCode(result) === "terminal_handle_stale") {
230
230
  const handle = argv.includes("--terminal") ? argv[argv.indexOf("--terminal") + 1] : undefined;
231
231
  throw new OrcaCommandError({ code: "terminal-gone", message: `the Orca terminal${handle ? ` ${handle}` : ""} no longer exists` });
@@ -300,17 +300,41 @@ function defaultRunner(executable: string): CommandRunner {
300
300
  export const orcaExecutable = (): string => resolveOrcaExecutable();
301
301
 
302
302
  /**
303
- * #269: the Orca worktree a terminal for this project can be opened in: the one this process runs in, or the one the
304
- * last recorded `ahub <peer>` launch of this project used. Undefined when there is none or the record does not read.
305
- * ponytail: the environment's worktree is taken as this project's; a hub started from another project's Orca terminal
306
- * (`ahub --project`, the dashboard manager's start, an upgrade or restart that brings several projects up from one
307
- * terminal) opens the terminal there and `ahub <peer>` then fails its root readback. Ask Orca for the worktree's path
308
- * and compare it if that case shows up.
303
+ * #269: whether Orca is in play for this project: this process runs in an Orca terminal, or an earlier `ahub <peer>`
304
+ * launch of this project recorded a worktree. It is only a hint for a synchronous "could a start open a terminal"
305
+ * answer. It is never the worktree a terminal is opened in: the environment names the worktree of whatever terminal
306
+ * this process was started from, which is another project's when `ahub --project`, the dashboard manager or an upgrade
307
+ * brought this project up (#1089). `resolveStartTerminalWorktree` names the worktree.
309
308
  */
310
- export function startTerminalWorktree(projectRoot: string, stateDir: string, env: NodeJS.ProcessEnv = process.env): string | undefined {
311
- if (env.ORCA_WORKTREE_ID) return env.ORCA_WORKTREE_ID;
309
+ export function startTerminalCandidate(projectRoot: string, stateDir: string, env: NodeJS.ProcessEnv = process.env): boolean {
310
+ if (env.ORCA_WORKTREE_ID) return true;
312
311
  const records = launchRecords(stateDir);
313
- return records === "unreadable" ? undefined : records.rows.filter((row) => row.projectRoot === projectRoot).at(-1)?.worktreeId;
312
+ return records !== "unreadable" && records.rows.some((row) => row.projectRoot === projectRoot && typeof row.worktreeId === "string");
313
+ }
314
+
315
+ /** #1089: how many worktrees one `orca worktree list` asks for; a longer list is reported as truncated and never read as "none". */
316
+ const WORKTREE_LIST_LIMIT = 500;
317
+
318
+ /**
319
+ * #1089: the Orca worktree whose root is exactly this project's root, asked of Orca and never taken from the caller's
320
+ * environment. The match is exact because `ahub <peer>` compares the terminal's worktree root with the project root by
321
+ * equality and refuses on a difference: a terminal opened in any other worktree would only fail later. The refusal names
322
+ * the cause and the next step and carries no path; Orca's own words go in `detail` for the hub log.
323
+ */
324
+ export async function resolveStartTerminalWorktree(projectRoot: string, options?: CommandRunner | TerminalRecoveryOptions): Promise<{ ok: true; worktreeId: string } | { ok: false; why: string; detail?: string }> {
325
+ const config = normalizeOptions(options);
326
+ let value: unknown;
327
+ try { value = await run(config.runner, ["worktree", "list", "--limit", String(WORKTREE_LIST_LIMIT), "--json"]); }
328
+ catch (error) {
329
+ return { ok: false, why: "Orca could not list its worktrees, so no terminal was opened. Check that Orca is running (orca worktree list), then start the peer again, or set terminal.open", detail: (error as Error).message };
330
+ }
331
+ const payload = responseResult(value);
332
+ const rows = Array.isArray(payload.worktrees) ? payload.worktrees.map(object) : [];
333
+ const matches = rows.filter((row) => row.isArchived !== true && rootMatches(row.path, projectRoot) && typeof row.id === "string" && row.id.length > 0);
334
+ if (matches.length === 1) return { ok: true, worktreeId: String(matches[0]!.id) };
335
+ if (matches.length > 1) return { ok: false, why: "more than one Orca worktree has this project's root, so no terminal was opened. Archive the extra entry in Orca, or set terminal.open" };
336
+ if (payload.truncated === true) return { ok: false, why: `Orca's worktree list was cut off at ${WORKTREE_LIST_LIMIT} rows before one with this project's root appeared, so no terminal was opened. Archive worktrees you no longer use in Orca, or set terminal.open` };
337
+ return { ok: false, why: "Orca has no worktree whose root is this project's root, so no terminal was opened. Open this project in Orca (add it as a repository or worktree), then start the peer again, or set terminal.open" };
314
338
  }
315
339
 
316
340
  /** #269: a new Orca terminal running `command` (a complete, already quoted command line); the terminal's handle. */
@@ -791,7 +815,7 @@ export async function inspectTerminals(projectRoot: string, sessions: Partial<Re
791
815
  }
792
816
 
793
817
  /**
794
- * One bounded Orca wait. Orca 1.4.223 answers a wait that timed out with exit 1 and `{"ok":false,"error":{"code":"timeout"}}`:
818
+ * One bounded Orca wait. Orca 1.4.223 and, rechecked read-only, 1.4.245 (docs/verification/2026-11-02-orca-1.4.245-cli-contract.md) answer a wait that timed out with exit 1 and `{"ok":false,"error":{"code":"timeout"}}`:
795
819
  * that is "not satisfied", not a failure. Any other failure still throws.
796
820
  */
797
821
  async function waitSatisfied(runner: CommandRunner, handle: string, timeoutMs: number): Promise<boolean> {