@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.
- package/CHANGELOG.md +23 -0
- package/README.md +11 -3
- package/docs/agent-notes/adapters.md +4 -2
- package/docs/agent-notes/bus.md +4 -4
- package/docs/agent-notes/daemon.md +7 -3
- package/docs/agent-notes/tasks.md +5 -0
- package/docs/agent-notes/tests.md +6 -5
- package/docs/design/adapters.md +76 -0
- package/docs/design/agent-hub-architecture.md +67 -0
- package/docs/design/agent-hub.md +52 -0
- package/docs/design/bus.md +48 -0
- package/docs/design/task-board.md +86 -0
- package/docs/dogfood.md +16 -1
- package/docs/operations.md +308 -12
- package/docs/profiles.md +1 -0
- package/docs/quickstart.md +11 -3
- package/docs/sdlc.md +1 -1
- package/docs/security.md +8 -3
- package/docs/smoke.md +2 -0
- package/docs/specs/2026-09-19-agent-hub-design.md +2 -0
- package/docs/verification/2026-10-31-codex-0166-reviewer-isolation.md +2 -0
- package/docs/verification/2026-11-02-0.28.0.md +54 -0
- package/docs/verification/2026-11-02-autonomy-t4.md +39 -0
- package/docs/verification/2026-11-02-codex-0166-reviewer-tool-call.md +53 -0
- package/docs/verification/2026-11-02-control-write-after.json +139 -0
- package/docs/verification/2026-11-02-control-write-baseline.json +127 -0
- package/docs/verification/2026-11-02-control-write-guarded.json +127 -0
- package/docs/verification/2026-11-02-control-write-outcomes.md +65 -0
- package/docs/verification/2026-11-02-orca-1.4.245-cli-contract.md +51 -0
- package/docs/verified.json +13 -13
- package/package.json +2 -2
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +338 -112
- package/src/adapters/claude-channel.ts +12 -2
- package/src/adapters/compat.ts +1 -1
- package/src/adapters/definitions/native-cli-claude-setup.ts +31 -9
- package/src/adapters/definitions/native-cli-entry-jcode.ts +33 -0
- package/src/adapters/definitions/native-cli-entry-qwen.ts +2 -2
- package/src/adapters/jcode.ts +37 -4
- package/src/adapters/qwen-dual.ts +3 -2
- package/src/cli/agents.ts +4 -2
- package/src/cli/bench.ts +4 -2
- package/src/cli/board-read.ts +25 -0
- package/src/cli/claude-homes.ts +2 -2
- package/src/cli/console-state.ts +8 -3
- package/src/cli/help.ts +6 -5
- package/src/cli/identity.ts +9 -1
- package/src/cli/init-prompt.ts +179 -0
- package/src/cli/init-settings.ts +229 -0
- package/src/cli/init.ts +102 -9
- package/src/cli/launch.ts +3 -1
- package/src/cli/main.ts +106 -35
- package/src/cli/output.ts +14 -0
- package/src/cli/setup-homes.ts +18 -3
- package/src/cli/specs.ts +150 -0
- package/src/cli/terminal-recovery.ts +35 -11
- package/src/hub/board.ts +56 -3
- package/src/hub/bus.ts +54 -7
- package/src/hub/child-process.ts +6 -4
- package/src/hub/config-apply.ts +4 -1
- package/src/hub/config-edit.ts +1 -1
- package/src/hub/config-tier.ts +1 -0
- package/src/hub/control-client.ts +28 -4
- package/src/hub/cost-governance.ts +22 -6
- package/src/hub/crash.ts +20 -0
- package/src/hub/daemon.ts +304 -56
- package/src/hub/delivery-hints.ts +6 -3
- package/src/hub/delivery-journal.ts +14 -3
- package/src/hub/hub-tools.ts +10 -6
- package/src/hub/peers.ts +1 -1
- package/src/hub/review-brief.ts +18 -0
- package/src/hub/routing.ts +7 -3
- package/src/hub/settings.ts +2 -0
- package/src/hub/specs-config.ts +43 -0
- package/src/hub/specs-task.ts +108 -0
- package/src/hub/specs.ts +1008 -0
- package/src/hub/task-list-page.ts +75 -0
- package/src/hub/task-sweep.ts +10 -1
- package/src/hub/tasks.ts +448 -40
- package/src/hub/terminal-open.ts +10 -5
- package/src/hub/tool-request.ts +131 -0
- package/src/ui/office-model.ts +17 -1
- package/src/ui/office-space.ts +1 -1
- package/templates/config.json +1 -0
- package/templates/profiles/dev.json +1 -0
- package/templates/profiles/general.json +1 -0
- package/templates/profiles/research.json +1 -0
- package/templates/profiles/writing.json +1 -0
- 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
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
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
|
-
|
|
1066
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
1079
|
-
|
|
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
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
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
|
|
1205
|
-
|
|
1206
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1222
|
-
|
|
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 =
|
|
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 =
|
|
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[][] {
|
package/src/cli/setup-homes.ts
CHANGED
|
@@ -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
|
-
|
|
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 (
|
|
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?.();
|
package/src/cli/specs.ts
ADDED
|
@@ -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
|
|
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:
|
|
304
|
-
*
|
|
305
|
-
*
|
|
306
|
-
*
|
|
307
|
-
*
|
|
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
|
|
311
|
-
if (env.ORCA_WORKTREE_ID) return
|
|
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
|
|
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
|
|
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> {
|