@staix/agent-hub 0.26.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 +30 -0
- package/README.md +13 -4
- package/docs/agent-notes/adapters.md +4 -2
- package/docs/agent-notes/bus.md +4 -4
- package/docs/agent-notes/daemon.md +8 -4
- package/docs/agent-notes/tasks.md +7 -2
- 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 +19 -2
- package/docs/operations.md +337 -15
- package/docs/profiles.md +2 -0
- package/docs/quickstart.md +15 -3
- package/docs/sdlc.md +4 -4
- package/docs/security.md +9 -4
- package/docs/smoke.md +2 -0
- package/docs/specs/2026-09-19-agent-hub-design.md +3 -1
- package/docs/specs/2026-11-01-research-campaigns-design.md +12 -1
- package/docs/verification/2026-10-31-codex-0166-reviewer-isolation.md +2 -0
- package/docs/verification/2026-11-01-0.27.0.md +49 -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 +344 -116
- 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 +8 -6
- package/src/cli/identity.ts +14 -6
- 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 +112 -36
- 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/study-output.ts +56 -0
- package/src/cli/study.ts +26 -3
- package/src/cli/terminal-recovery.ts +35 -11
- package/src/hub/action-policy.ts +5 -3
- 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 +324 -64
- package/src/hub/delivery-hints.ts +6 -3
- package/src/hub/delivery-journal.ts +14 -3
- package/src/hub/events.ts +2 -1
- package/src/hub/hub-tools.ts +15 -9
- package/src/hub/no-writes.ts +1 -1
- package/src/hub/peers.ts +1 -1
- package/src/hub/project-runtime-state.ts +38 -13
- package/src/hub/review-brief.ts +18 -0
- package/src/hub/routing.ts +7 -3
- package/src/hub/sdlc-release-authorization.ts +11 -5
- package/src/hub/session-identity.ts +1 -1
- 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/presets/confirmation-matrix.json +5 -5
- 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/release-guard.block.md +2 -1
- 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";
|
|
@@ -30,7 +30,7 @@ import { renderIntake, renderIntakeChange } from "./intake-output.ts";
|
|
|
30
30
|
import { renderLearnChange, renderLearnList } from "./learn-output.ts";
|
|
31
31
|
import { renderCapabilities } from "./capabilities-output.ts";
|
|
32
32
|
import { parseStudyArgs } from "./study.ts";
|
|
33
|
-
import { renderStudyList, renderStudyReceipt, renderStudyShow } from "./study-output.ts";
|
|
33
|
+
import { renderExperiments, renderStudyList, renderStudyReceipt, renderStudyShow } from "./study-output.ts";
|
|
34
34
|
import { renderHandoffReceipt, renderHandoffs } from "./handoff-output.ts";
|
|
35
35
|
import { parseHandoffArgs } from "./handoff-args.ts";
|
|
36
36
|
import { terminalText } from "./console-state.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 });
|
|
@@ -1525,6 +1588,11 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
1525
1588
|
catch { return fail("cannot read the contract file"); }
|
|
1526
1589
|
try { return JSON.parse(text); } catch { return fail("the contract file is not valid JSON"); }
|
|
1527
1590
|
};
|
|
1591
|
+
if (parsed.op === "experiments") {
|
|
1592
|
+
// A read of the records Tasks keeps (hub_experiment_show): it runs no command and reads no file, and the hub blanks a private record itself.
|
|
1593
|
+
const args = parsed.attempt ? { attemptId: parsed.attempt, ...(parsed.eventsAfter === undefined ? {} : { eventAfter: parsed.eventsAfter }) } : parsed.plan ? { planHash: parsed.plan, baselineAttemptId: parsed.baseline, variantAttemptId: parsed.variant } : { campaignId: parsed.campaign, ...(parsed.after === undefined ? {} : { after: parsed.after }) };
|
|
1594
|
+
return showToolText(data => renderExperiments(data, present.columns), await taskOp("hub_experiment_show", args), present);
|
|
1595
|
+
}
|
|
1528
1596
|
if (parsed.op === "show") return showToolText(data => parsed.id ? renderStudyShow(data, present.columns) : renderStudyList(data, present.columns), await taskOp("hub_study_show", parsed.id ? { id: parsed.id } : parsed.after ? { after: parsed.after } : {}), present);
|
|
1529
1597
|
const request = parsed.op === "draft" ? { parentTaskId: parsed.parentTaskId, contract: contract(parsed.file) }
|
|
1530
1598
|
: parsed.op === "revise" ? { id: parsed.id, revision: parsed.revision, contract: contract(parsed.file) }
|
|
@@ -1623,11 +1691,11 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
1623
1691
|
const state = rest.find((a) => !a.startsWith("--"));
|
|
1624
1692
|
if (byColumn && !boardStatus) boardStatus = (await boardHub.request({ t: "status" })).status;
|
|
1625
1693
|
if (byColumn && !supportsBoardColumnMetadata(boardStatus)) fail("running hub does not support accurate board column metadata; use a compatible daemon");
|
|
1626
|
-
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");
|
|
1627
1695
|
if (options.json && !byColumn) return console.log(JSON.stringify(tasks, null, 2));
|
|
1628
1696
|
let allTasks: any[] | null = tasks;
|
|
1629
1697
|
if (ready || state || release) {
|
|
1630
|
-
try { allTasks =
|
|
1698
|
+
try { allTasks = await readAllTasks(args => taskOp("hub_task_list", args, true, boardHub), byColumn ? { columnMetadata: true } : {}, identity.role === "tools"); }
|
|
1631
1699
|
catch (error) {
|
|
1632
1700
|
if (byColumn) fail("board column dependency context unavailable; retry with a complete snapshot");
|
|
1633
1701
|
allTasks = null;
|
|
@@ -1866,13 +1934,13 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
1866
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...>");
|
|
1867
1935
|
// `--class` is the explicit form. A first word that is a class name is still taken as the class (the documented
|
|
1868
1936
|
// short form), but said out loud: "review the auth module" would otherwise be filed as class review, silently.
|
|
1869
|
-
const urgent = rest.includes("--urgent"), backlog = rest.includes("--backlog");
|
|
1870
|
-
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"]);
|
|
1871
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;
|
|
1872
1940
|
const cls = flags.one["--class"] ?? (positional ? flags.rest[0] : undefined);
|
|
1873
1941
|
const title = (positional ? flags.rest.slice(1) : flags.rest).join(" ");
|
|
1874
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`);
|
|
1875
|
-
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 } : {}) }));
|
|
1876
1944
|
},
|
|
1877
1945
|
|
|
1878
1946
|
review: async () => {
|
|
@@ -2215,7 +2283,7 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
2215
2283
|
if (args.includes("--all")) return printProjects(options);
|
|
2216
2284
|
const hub = await connect();
|
|
2217
2285
|
try {
|
|
2218
|
-
const { status } = await hub.request({ t: "status" });
|
|
2286
|
+
const { status } = await hub.request({ t: "status", specs: true });
|
|
2219
2287
|
if (options.json) return console.log(JSON.stringify(status, null, 2));
|
|
2220
2288
|
const quota = await hub.request({ t: "budget" });
|
|
2221
2289
|
printOutput(renderStatus({ ...status, budget: quota.budget ?? {} }, options.columns, Date.now(), options.full), options.color);
|
|
@@ -2368,6 +2436,14 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
2368
2436
|
if (options.json) return console.log(JSON.stringify(data, null, 2));
|
|
2369
2437
|
printOutput(renderReport(data, options.columns, Date.now(), by >= 0), options.color);
|
|
2370
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
|
+
},
|
|
2371
2447
|
usage: async () => {
|
|
2372
2448
|
const options = outputOptions();
|
|
2373
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
|
+
}
|
package/src/cli/study-output.ts
CHANGED
|
@@ -69,3 +69,59 @@ export function renderStudyReceipt(op: string, receipt: unknown, columns?: numbe
|
|
|
69
69
|
if (op === "draft" || op === "revise") out.push(blank, [s(`nothing is dispatched until you approve: ahub study approve ${r?.id} ${count(r?.revision)}`, "muted")]);
|
|
70
70
|
return out;
|
|
71
71
|
}
|
|
72
|
+
|
|
73
|
+
const ATTEMPT_TONE: Record<string, Tone | undefined> = { succeeded: undefined, running: undefined, declared: "muted", failed: "attention", cancelled: "attention", unknown: "attention" };
|
|
74
|
+
const PROVENANCE_TONE: Record<string, Tone | undefined> = { declared: "muted", unknown: "attention" };
|
|
75
|
+
const clip = (text: string, max = 48) => text.length > max ? `${text.slice(0, max - 1)}…` : text;
|
|
76
|
+
/** A value cell: the value and where it came from, so a declared number never reads as an observed one. Anything the hub withheld prints as the hub wrote it. */
|
|
77
|
+
const sourced = (cell: any): [string, string] => {
|
|
78
|
+
if (cell === "[pii]") return ["[pii]", "withheld"];
|
|
79
|
+
if (!cell || typeof cell !== "object" || !("provenance" in cell)) return ["-", "unknown"];
|
|
80
|
+
const value = cell.value === null || cell.value === undefined ? "-" : typeof cell.value === "object" ? "(structured)" : String(cell.value);
|
|
81
|
+
return [clip(value), String(cell.provenance)];
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* `ahub study experiments`: the attempts of one campaign, one attempt, or one baseline/variant comparison, as the hub's `Tasks.experimentShow` returned them. Pure: it reads no file, runs
|
|
86
|
+
* no command and asks for no protocol text. Every row says whether it is approved, post-hoc or predeclared and that a repeat is not an independent reproduction; every value says where it
|
|
87
|
+
* came from; failed, cancelled and unknown attempts are in the counts. A private record arrives from the hub already as ids, counts and states.
|
|
88
|
+
*/
|
|
89
|
+
export function renderExperiments(view: unknown, columns?: number): Span[][] {
|
|
90
|
+
const v = view as any, attempts = list(v?.attempts), counts = v?.counts ?? {}, statuses = counts.statuses ?? {};
|
|
91
|
+
const out: Span[][] = [...heading(`Experiment attempts (${attempts.length} of ${count(counts.all)} in this campaign${v?.nextCursor !== undefined ? ", more follow" : ""})`, columns)];
|
|
92
|
+
const order = ["declared", "running", "succeeded", "failed", "cancelled", "unknown"];
|
|
93
|
+
out.push(...pair("all", order.map(name => `${name} ${count(statuses[name])}`).join(", "), columns, 8, "muted"));
|
|
94
|
+
if (v?.comparison) {
|
|
95
|
+
const c = v.comparison, compat = c.projection?.compatibility;
|
|
96
|
+
out.push(blank, ...heading("Comparison", columns));
|
|
97
|
+
if (c.projection === "[pii]") out.push(...pair("plan", `${String(c.planHash).slice(0, 12)}: withheld (a private source or record)`, columns, 8, "attention"));
|
|
98
|
+
else {
|
|
99
|
+
out.push(...pair("plan", String(c.planHash).slice(0, 12), columns, 8));
|
|
100
|
+
out.push(...pair("compatible", compat?.compatible ? "yes, under this plan" : `no: ${compat?.field ?? "unknown"} differs (${compat?.side ?? "pair"}); shown, not ranked together`, columns, 8, compat?.compatible ? undefined : "attention"));
|
|
101
|
+
}
|
|
102
|
+
out.push(...pair("approved", c.approved ? "both protocols bound to a person's approval" : "not both approved", columns, 8, c.approved ? undefined : "attention"));
|
|
103
|
+
out.push(...pair("pair", `${c.baselineAttemptId} (baseline) and ${c.variantAttemptId} (variant)`, columns, 8, "muted"));
|
|
104
|
+
}
|
|
105
|
+
if (!attempts.length) return [...out, blank, [s("no attempts recorded", "muted")]];
|
|
106
|
+
out.push(blank, ...grid(["ATTEMPT", "TASK", "PHASE", "STATUS", "APPROVAL", "POST-HOC", "NOTE"], attempts.map((row: any) => [
|
|
107
|
+
s(row.id, "issueRef"), s(`#${count(row.taskId)}`, "taskRef"), s(row.phase), s(row.status, ATTEMPT_TONE[row.status]), s(row.approval, row.approval === "approved" ? undefined : "attention"), s(row.postHoc, row.postHoc === "post-hoc" ? "attention" : "muted"),
|
|
108
|
+
s([row.private ? "private, details withheld" : "", row.retryOf ? `retry of ${row.retryOf}` : "", row.reproducesAttemptId ? `repeat of ${row.reproducesAttemptId}, not independent` : "", row.eventsComplete === false ? "events continue" : ""].filter(Boolean).join("; ") || "-", "muted"),
|
|
109
|
+
]), columns));
|
|
110
|
+
for (const row of attempts) {
|
|
111
|
+
if (row.private) continue;
|
|
112
|
+
const declared = row.evidence?.declared, metrics = list(declared?.metrics), measured = list(row.evidence?.measured), runners = list(row.evidence?.runnerAttested);
|
|
113
|
+
const lines: [string, string, string][] = [];
|
|
114
|
+
for (const metric of metrics) { const [value, from] = sourced(metric.latest); lines.push([`${metric.name} (${metric.unit})`, value, from]); }
|
|
115
|
+
for (const field of ["exitStatus", "startedAt"]) { const [value, from] = sourced(declared?.[field]); if (value !== "-") lines.push([field, value, from]); }
|
|
116
|
+
for (const m of measured) for (const [field, cell] of Object.entries(m.values ?? {})) { const [value, from] = sourced(cell); lines.push([`${field} (${m.source})`, value, from]); }
|
|
117
|
+
for (const r of runners) { const [value, from] = sourced(r.sealing); lines.push([`sealed (runner ${clip(String(r.runner), 24)})`, value, from]); const [match, matchFrom] = sourced(r.codeMatch); lines.push(["code matches protocol", match, matchFrom]); }
|
|
118
|
+
const [sealed, sealedFrom] = sourced(declared?.sealed);
|
|
119
|
+
if (!runners.length) lines.push(["sealed", sealed, `${sealedFrom}; isolation unverified`]);
|
|
120
|
+
if (!lines.length) continue;
|
|
121
|
+
out.push(blank, ...heading(`Evidence of ${row.id}`, columns), ...grid(["FIELD", "VALUE", "SOURCE"], lines.map(([field, value, from]) => [s(field), s(value), s(from, PROVENANCE_TONE[from] ?? (from.startsWith("declared") ? "muted" : undefined))]), columns));
|
|
122
|
+
}
|
|
123
|
+
out.push(blank, [s(`declared values are an agent's own words; only host-observed, runner-attested or person-attested rows are evidence, and none of them proves the science. A repeat is never an independent reproduction.`, "muted")]);
|
|
124
|
+
if (v?.nextCursor !== undefined) out.push([s(`more attempts: ahub study experiments --campaign <id> --after ${v.nextCursor}`, "muted")]);
|
|
125
|
+
for (const row of attempts) if (row.eventsComplete === false && row.nextEventCursor !== undefined) out.push([s(`more events of ${row.id}: ahub study experiments --attempt ${row.id} --events-after ${row.nextEventCursor}; the values above come from the events shown so far`, "attention")]);
|
|
126
|
+
return out;
|
|
127
|
+
}
|