@staix/agent-hub 0.12.35 → 0.12.37
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 +25 -0
- package/README.md +15 -8
- package/docs/agent-notes/adapters.md +9 -2
- package/docs/agent-notes/budget.md +3 -2
- package/docs/agent-notes/bus.md +3 -0
- package/docs/agent-notes/daemon.md +11 -3
- package/docs/agent-notes/tasks.md +14 -1
- package/docs/agent-notes/tests.md +6 -2
- package/docs/memory.md +3 -1
- package/docs/operations.md +74 -18
- package/docs/quickstart.md +7 -9
- package/docs/sdlc.md +72 -5
- package/docs/security.md +11 -3
- package/docs/smoke.md +18 -2
- package/docs/specs/2026-09-19-agent-hub-design.md +108 -3
- package/docs/verification/2026-10-19-0.12.36.md +63 -0
- package/docs/verification/2026-10-19-0.12.37.md +60 -0
- package/docs/verification/2026-10-19-jcode-0.98.1.md +30 -0
- package/docs/verification/2026-10-19-reviewer-isolation.md +119 -0
- package/docs/verified.json +23 -15
- package/package.json +2 -2
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +2843 -1632
- package/src/adapters/acp.ts +3 -2
- package/src/adapters/claude-print.ts +92 -0
- package/src/adapters/codex-review-isolation.ts +276 -0
- package/src/adapters/definitions/builtin-metadata.ts +20 -1
- package/src/adapters/definitions/native-cli-entry-claude.ts +17 -3
- package/src/adapters/definitions/native-cli-entry-jcode.ts +3 -2
- package/src/adapters/definitions/native-cli-entry-kimi.ts +3 -2
- package/src/adapters/definitions/native-cli-entry-types.ts +2 -0
- package/src/adapters/definitions/native-factories.ts +24 -0
- package/src/adapters/definitions/native-policy-aux.ts +1 -1
- package/src/adapters/definitions/reviewer-variants.ts +32 -0
- package/src/adapters/jcode.ts +20 -5
- package/src/adapters/registry-types.ts +13 -3
- package/src/adapters/registry.ts +29 -5
- package/src/adapters/reviewer-print.ts +319 -0
- package/src/cli/help.ts +7 -0
- package/src/cli/identity.ts +2 -1
- package/src/cli/launch.ts +19 -6
- package/src/cli/main.ts +57 -4
- package/src/cli/memory.ts +13 -12
- package/src/cli/reset.ts +4 -2
- package/src/cli/statusline-tee.ts +5 -2
- package/src/cli/terminal-recovery.ts +7 -2
- package/src/cli/upgrade-runtime.ts +18 -15
- package/src/hub/board.ts +61 -11
- package/src/hub/bus.ts +11 -5
- package/src/hub/claude-state.ts +37 -0
- package/src/hub/config-apply.ts +3 -2
- package/src/hub/control-client.ts +1 -1
- package/src/hub/cost-governance.ts +76 -0
- package/src/hub/daemon.ts +269 -87
- package/src/hub/envelope.ts +1 -1
- package/src/hub/host-executable.ts +1 -1
- package/src/hub/hub-tools.ts +25 -1
- package/src/hub/instances.ts +29 -5
- package/src/hub/memory-profile.ts +67 -11
- package/src/hub/planning.ts +2 -0
- package/src/hub/release-admission.ts +64 -0
- package/src/hub/review-brief.ts +175 -0
- package/src/hub/reviewer-pool-config.ts +11 -0
- package/src/hub/reviewer-pool.ts +70 -0
- package/src/hub/routing.ts +58 -4
- package/src/hub/sdlc-release.ts +113 -0
- package/src/hub/sdlc.ts +205 -4
- package/src/hub/status-roster.ts +10 -0
- package/src/hub/task-sweep.ts +143 -0
- package/src/hub/tasks.ts +788 -52
- package/src/hub/usage.ts +57 -20
package/src/cli/launch.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
// Launchers inject only the flags the hub owns and refuse user-supplied duplicates.
|
|
2
|
+
import { claudeStateFile } from "../hub/claude-state.ts";
|
|
2
3
|
import { mkdirSync, writeFileSync, renameSync, unlinkSync, lstatSync, readFileSync, openSync, closeSync, readSync, accessSync, constants, linkSync } from "node:fs";
|
|
3
4
|
import { spawn } from "node:child_process";
|
|
4
5
|
import { randomUUID, createHash } from "node:crypto";
|
|
@@ -12,10 +13,10 @@ import { kimiAcpCommand } from "../adapters/definitions/native-daemon-kimi.ts";
|
|
|
12
13
|
export const CLAUDE_CHANNEL = "plugin:agent-hub@agent-hub";
|
|
13
14
|
|
|
14
15
|
/** Consume only ahub's selector, never native arguments after the passthrough delimiter. */
|
|
15
|
-
export function instanceLaunchArguments(command: string, raw: readonly string[], instances?: InstancesConfig): { instance?: string; args: string[] } {
|
|
16
|
+
export function instanceLaunchArguments(command: string, raw: readonly string[], instances?: InstancesConfig): { instance?: string; args: string[]; hubStarted?: true } {
|
|
16
17
|
if (!definitionOf(command)) return { args: [...raw] };
|
|
17
18
|
const args: string[] = [];
|
|
18
|
-
let instance: string | undefined;
|
|
19
|
+
let instance: string | undefined, hubStarted = false;
|
|
19
20
|
for (let index = 0; index < raw.length; index++) {
|
|
20
21
|
const arg = raw[index]!;
|
|
21
22
|
if (arg === "--") { args.push(...raw.slice(index)); break; }
|
|
@@ -25,9 +26,19 @@ export function instanceLaunchArguments(command: string, raw: readonly string[],
|
|
|
25
26
|
if (!id || id.startsWith("--")) throw new Error("--as needs a declared instance id");
|
|
26
27
|
const declaration = instances && Object.hasOwn(instances, id) ? instances[id] : undefined;
|
|
27
28
|
if (!declaration || declaration.kind !== command || !definitionOf(id, instances)) throw new Error("--as must name a declared instance of this command's native kind");
|
|
29
|
+
// #223: a headless reviewer is started by the hub, never from this terminal.
|
|
30
|
+
hubStarted = definitionOf(id, instances) === definitionOf(command)?.reviewerVariant;
|
|
28
31
|
instance = id;
|
|
29
32
|
}
|
|
30
|
-
|
|
33
|
+
if (hubStarted && args.some(arg => arg !== "--print-command" && arg !== "--dry-run")) throw new Error(`${instance} is a headless reviewer the hub runs per review: ahub ${command} --as ${instance} takes no launch arguments`);
|
|
34
|
+
return { ...(instance ? { instance } : {}), args, ...(hubStarted ? { hubStarted: true as const } : {}) };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The running daemon bound this instance at startup; a declaration added later, or an older daemon, is not proof. */
|
|
38
|
+
export function requireActiveInstance(status: unknown, instance: string): void {
|
|
39
|
+
const active = (status as { activeInstances?: unknown } | undefined)?.activeInstances;
|
|
40
|
+
if (!Array.isArray(active)) throw new Error("running hub does not report its declared instances; upgrade it before starting an extra instance");
|
|
41
|
+
if (!active.includes(instance)) throw new Error(`running hub has not loaded the declaration of ${instance}; restart the hub (ahub restart) after declaring it`);
|
|
31
42
|
}
|
|
32
43
|
|
|
33
44
|
/** Only an authenticated daemon hello can authorize extra-instance effects. */
|
|
@@ -92,6 +103,8 @@ export function cleanupStaleClaudeSettings(stateDir: string, previous: unknown,
|
|
|
92
103
|
|
|
93
104
|
export interface ClaudeLaunchRecord extends Record<string, unknown> {
|
|
94
105
|
instanceId: string; launchId: string; settingsFile?: string; launcherPid: number; launcherSignature?: string;
|
|
106
|
+
/** A claude-<name> instance (#450 I3); absent for the builtin claude peer, whose file keeps its original name. */
|
|
107
|
+
peer?: string;
|
|
95
108
|
}
|
|
96
109
|
|
|
97
110
|
export interface ClaudeLaunchPublication { recorded: boolean; warning?: string; }
|
|
@@ -144,7 +157,7 @@ async function withClaudeLaunchRecord(stateDir: string, action: () => void): Pro
|
|
|
144
157
|
}
|
|
145
158
|
|
|
146
159
|
function writeClaudeLaunchRecord(stateDir: string, record: Record<string, unknown>): void {
|
|
147
|
-
const file = join(stateDir, "claude-launch.
|
|
160
|
+
const file = join(stateDir, claudeStateFile("claude-launch", typeof record.peer === "string" ? record.peer : "claude")), temp = `${file}.${randomUUID()}.tmp`;
|
|
148
161
|
try { writeFileSync(temp, JSON.stringify(record), { mode: 0o600 }); renameSync(temp, file); }
|
|
149
162
|
finally { try { unlinkSync(temp); } catch { /* the rename normally consumed it */ } }
|
|
150
163
|
}
|
|
@@ -187,7 +200,7 @@ export async function recordClaudeNative(stateDir: string, launch: ClaudeLaunchR
|
|
|
187
200
|
const publication = await withClaudeLaunchRecord(stateDir, () => {
|
|
188
201
|
let current: ClaudeLaunchRecord;
|
|
189
202
|
try {
|
|
190
|
-
const file = join(stateDir, "claude-launch.
|
|
203
|
+
const file = join(stateDir, claudeStateFile("claude-launch", launch.peer ?? "claude")), stat = lstatSync(file);
|
|
191
204
|
if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1 || (stat.mode & 0o777) !== 0o600 || !process.getuid || stat.uid !== process.getuid()) return;
|
|
192
205
|
current = JSON.parse(readFileSync(file, "utf8"));
|
|
193
206
|
} catch { return; }
|
|
@@ -204,7 +217,7 @@ export async function recordClaudeNative(stateDir: string, launch: ClaudeLaunchR
|
|
|
204
217
|
writeFileSync(temp, JSON.stringify({ ...launch, nativePid: pid, nativeSignature: signature, nativeIdentity: "direct-child" }), { mode: 0o600, flag: "wx" });
|
|
205
218
|
renameSync(temp, file);
|
|
206
219
|
} finally { try { unlinkSync(temp); } catch { /* atomic rename consumed our private temporary file */ } }
|
|
207
|
-
try { recorded = sameClaudeLaunch(JSON.parse(readFileSync(join(stateDir, "claude-launch.
|
|
220
|
+
try { recorded = sameClaudeLaunch(JSON.parse(readFileSync(join(stateDir, claudeStateFile("claude-launch", launch.peer ?? "claude")), "utf8")), launch); } catch { /* replaced or unreadable managed record */ }
|
|
208
221
|
});
|
|
209
222
|
if (publication.warning) report?.(publication.warning);
|
|
210
223
|
return publication.recorded && recorded;
|
package/src/cli/main.ts
CHANGED
|
@@ -40,7 +40,7 @@ import { OmniRoute } from "../omniroute/client.ts";
|
|
|
40
40
|
import { MemoryClient } from "../memory/client.ts";
|
|
41
41
|
import { init, planInit } from "./init.ts";
|
|
42
42
|
import { launcherPreview } from "./preview.ts";
|
|
43
|
-
import { instanceLaunchArguments, requirePeerInstances, cleanupStaleClaudeSettings, cleanupClaudeSettings, buildLaunch, buildKimiTuiLaunch, claudeObservationHooks, nativeLaunchEnv, recordClaudeLaunch, runClaudeLaunch, type ClaudeLaunchRecord, UNATTENDED_WARNING } from "./launch.ts";
|
|
43
|
+
import { instanceLaunchArguments, requirePeerInstances, requireActiveInstance, cleanupStaleClaudeSettings, cleanupClaudeSettings, buildLaunch, buildKimiTuiLaunch, claudeObservationHooks, nativeLaunchEnv, recordClaudeLaunch, runClaudeLaunch, type ClaudeLaunchRecord, UNATTENDED_WARNING } from "./launch.ts";
|
|
44
44
|
import { configureKimiMcp } from "./kimi-mcp.ts";
|
|
45
45
|
import { nextStep, parseList, pluginState, type InstalledPlugin, type Marketplace } from "./setup.ts";
|
|
46
46
|
import { CLASSES } from "../hub/board.ts";
|
|
@@ -848,14 +848,14 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
848
848
|
if (entry.lifecycleAdmission && !args.includes("--print-command") && !args.includes("--dry-run")) assertLifecycleAvailable();
|
|
849
849
|
const nativeConnect = async () => {
|
|
850
850
|
const hub = await connect();
|
|
851
|
-
try { if (selectedInstance) requirePeerInstances(hub.features); return hub; }
|
|
851
|
+
try { if (selectedInstance) { requirePeerInstances(hub.features); requireActiveInstance((await hub.request({ t: "status" })).status, selectedInstance); } return hub; }
|
|
852
852
|
catch (error) { hub.close(); throw error; }
|
|
853
853
|
};
|
|
854
854
|
if (selectedInstance && !args.includes("--print-command") && !args.includes("--dry-run")) {
|
|
855
855
|
const hub = await nativeConnect(); hub.close();
|
|
856
856
|
}
|
|
857
857
|
const context: NativeCliEntryContext = { args, cwd, stateDir, cliDir: import.meta.dir, unattendedEnv, definition: entry.definition,
|
|
858
|
-
...(selectedInstance ? { instance: selectedInstance } : {}),
|
|
858
|
+
...(selectedInstance ? { instance: selectedInstance } : {}), ...(nativeSelection.hubStarted ? { hubStarted: true as const } : {}),
|
|
859
859
|
projectConfig, connect: nativeConnect, fail,
|
|
860
860
|
exec: (bin, argv, tool) => exec(bin, argv, tool, context.executionCwd),
|
|
861
861
|
execWithEnv: (bin, argv, env) => execWithEnv(bin, argv, env, context.executionCwd), takeFlags,
|
|
@@ -1032,6 +1032,10 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
1032
1032
|
backlog: async () => {
|
|
1033
1033
|
const [sub, id, revision, value, ...reason] = args;
|
|
1034
1034
|
if (sub === "list" && args.length === 1) return console.log(await taskOp("hub_backlog_list", {}));
|
|
1035
|
+
if (sub === "groom" && args.length === 1) return console.log(await taskOp("hub_planner_groom", {}));
|
|
1036
|
+
if (["return", "return-untouched"].includes(sub ?? "") && /^[1-9]\d*$/.test(id ?? "") && /^\d+$/.test(revision ?? "") && args.length >= 4) {
|
|
1037
|
+
return console.log(await taskOp("hub_backlog_return_untouched", { id: Number(id), revision: Number(revision), reason: args.slice(3).join(" ") }));
|
|
1038
|
+
}
|
|
1035
1039
|
if (sub === "add" && args.length >= 3) return console.log(await taskOp("hub_task_propose", { class: id, title: args.slice(2).join(" "), backlog: true }));
|
|
1036
1040
|
if (!/^[1-9]\d*$/.test(id ?? "")) fail("usage: ahub backlog list|add <class> <title...>|pull|return <id>|plan|pin <id> <revision> <value> <reason...>|undo <id> <revision>");
|
|
1037
1041
|
if (["pull", "return"].includes(sub ?? "") && args.length === 2) return console.log(await taskOp(sub === "pull" ? "backlog_pull" : "backlog_return", { id }));
|
|
@@ -1114,6 +1118,50 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
1114
1118
|
finally { hub.close(); }
|
|
1115
1119
|
return;
|
|
1116
1120
|
}
|
|
1121
|
+
if (sub === "train") {
|
|
1122
|
+
const operation = args[1], releaseId = Number(args[2]), revision = Number(args[3]);
|
|
1123
|
+
if (!Number.isSafeInteger(releaseId) || releaseId <= 0) fail("usage: ahub sdlc train report|configure|cut <release-id> [revision] [configuration-json]");
|
|
1124
|
+
if (operation === "report" && args.length === 3) return console.log(await taskOp("hub_release_report", { id: releaseId }));
|
|
1125
|
+
if (!Number.isSafeInteger(revision) || revision < 0) fail("train mutation requires its current revision");
|
|
1126
|
+
if (operation === "cut" && args.length === 4) return console.log(await taskOp("hub_release_cut", { id: releaseId, revision }));
|
|
1127
|
+
if (operation === "configure" && args.length === 5) {
|
|
1128
|
+
let fields;
|
|
1129
|
+
try { fields = JSON.parse(args[4]!); } catch { return fail("release train configuration must be JSON"); }
|
|
1130
|
+
if (!fields || typeof fields !== "object" || Array.isArray(fields) || Object.keys(fields).some(key => !["state", "cut", "hotfix"].includes(key))) fail("invalid release train configuration fields");
|
|
1131
|
+
return console.log(await taskOp("hub_release_configure", { ...fields, id: releaseId, revision }));
|
|
1132
|
+
}
|
|
1133
|
+
fail("usage: ahub sdlc train report|configure|cut <release-id> [revision] [configuration-json]");
|
|
1134
|
+
}
|
|
1135
|
+
if (sub === "release-assign") {
|
|
1136
|
+
const revision = Number(args[2]), release = args[3] === "none" ? null : Number(args[3]);
|
|
1137
|
+
if (!Number.isSafeInteger(id) || id! <= 0 || !Number.isSafeInteger(revision) || revision < 0 || release !== null && (!Number.isSafeInteger(release) || release <= 0) || args.length < 5) fail("usage: ahub sdlc release-assign <task> <planning-revision> <release-id|none> <reason...>");
|
|
1138
|
+
return console.log(await taskOp("hub_release_assign", { id, revision, release, reason: args.slice(4).join(" ") }));
|
|
1139
|
+
}
|
|
1140
|
+
if (sub === "costs") {
|
|
1141
|
+
if (args.length > 2) fail("usage: ahub sdlc costs [owner/repository]");
|
|
1142
|
+
return console.log(await taskOp("hub_sdlc_costs", { ...(args[1] ? { repo: args[1] } : {}) }));
|
|
1143
|
+
}
|
|
1144
|
+
if (sub === "plan-review") {
|
|
1145
|
+
const revision = Number(args[2]);
|
|
1146
|
+
if (!Number.isSafeInteger(id) || id! <= 0 || !Number.isSafeInteger(revision) || revision < 0 || args.length < 4) fail("usage: ahub sdlc plan-review <task> <revision> <reason...>");
|
|
1147
|
+
const flags = takeFlags(args.slice(3), ["--review", "--reviewers", "--ci"], []);
|
|
1148
|
+
const scope = flags.one["--review"], reviewers = flags.one["--reviewers"];
|
|
1149
|
+
if (!flags.rest.length || (scope === undefined) !== (reviewers === undefined)) fail("plan-review needs a reason and both --review and --reviewers when supplying review scope");
|
|
1150
|
+
return console.log(await taskOp("hub_plan_review", { id, revision, reason: flags.rest.join(" "), ...(scope !== undefined ? { review: { scope, reviewers: Number(reviewers) } } : {}), ...(flags.one["--ci"] !== undefined ? { ci: flags.one["--ci"] } : {}) }));
|
|
1151
|
+
}
|
|
1152
|
+
if (sub === "accept") {
|
|
1153
|
+
// #481 S1: the person accepts the task's spec issue at its current body; agent shells are refused before this runs.
|
|
1154
|
+
const flags = takeFlags(args.slice(2), ["--issue", "--repo"], []);
|
|
1155
|
+
const issue = Number(flags.one["--issue"]), repo = flags.one["--repo"];
|
|
1156
|
+
if (flags.rest.length || !Number.isSafeInteger(id) || id! <= 0 || !Number.isSafeInteger(issue) || issue <= 0) fail("usage: ahub sdlc accept <task> --issue <n> [--repo owner/repository]");
|
|
1157
|
+
const hub = await connect();
|
|
1158
|
+
try {
|
|
1159
|
+
const reply = await hub.request({ t: "sdlc", op: "accept", id, issue, ...(repo ? { repo } : {}) }, 30_000);
|
|
1160
|
+
if (!reply.ok) fail(reply.error);
|
|
1161
|
+
console.log(`task #${id}: spec issue #${issue} accepted at body edit ${reply.acceptance?.lastEditedAt ?? "never"}`);
|
|
1162
|
+
} finally { hub.close(); }
|
|
1163
|
+
return;
|
|
1164
|
+
}
|
|
1117
1165
|
if (sub === "record") {
|
|
1118
1166
|
if (args.length !== 3 || !Number.isSafeInteger(id) || id! <= 0 || Buffer.byteLength(args[2]!) > 32768) fail("usage: ahub sdlc record <task> '<receipt JSON>'");
|
|
1119
1167
|
let receipt: unknown; try { receipt = JSON.parse(args[2]!); } catch { fail("receipt must be valid JSON"); }
|
|
@@ -1181,9 +1229,14 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
1181
1229
|
},
|
|
1182
1230
|
|
|
1183
1231
|
review: async () => {
|
|
1232
|
+
// #520: the review brief a reviewer gets, for a person or an outside tool.
|
|
1233
|
+
if (args[0] === "brief") {
|
|
1234
|
+
if (args.length !== 2 || !/^\d+$/.test(args[1]!)) fail("usage: ahub review brief <task id>");
|
|
1235
|
+
return console.log(await taskOp("review_brief", { id: Number(args[1]) }));
|
|
1236
|
+
}
|
|
1184
1237
|
const { one, many, rest } = takeFlags(args, ["--head"], ["--unmet"]);
|
|
1185
1238
|
const [id, verdict, ...note] = rest;
|
|
1186
|
-
if (!id || !verdict) fail("usage: ahub review <id> approved|changes_requested [note...] [--unmet <item>]...");
|
|
1239
|
+
if (!id || !verdict) fail("usage: ahub review <id> approved|changes_requested [note...] [--unmet <item>]... | ahub review brief <id>");
|
|
1187
1240
|
console.log(await taskOp("hub_review", { id: Number(id), verdict, ...(one["--head"] ? { head: one["--head"] } : {}), note: note.join(" "), ...(many["--unmet"]?.length ? { unmet: many["--unmet"] } : {}) }));
|
|
1188
1241
|
},
|
|
1189
1242
|
|
package/src/cli/memory.ts
CHANGED
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
import { detectCliIdentity } from "./identity.ts";
|
|
2
2
|
import { createStalenessProbe } from "../hub/memory-staleness.ts";
|
|
3
|
-
import { looksSecret, type MemoryStore
|
|
3
|
+
import { looksSecret, type MemoryStore } from "@staix/agent-mem";
|
|
4
4
|
import type { SyncOptions } from "@staix/agent-mem";
|
|
5
5
|
import { MEMORY_USAGE as CORE_MEMORY_USAGE, rawMemoryCommand, rawGoalCommand } from "@staix/agent-mem";
|
|
6
|
-
import { memoryProjectKey } from "@staix/agent-mem";
|
|
7
6
|
import { mentionsPrivatePath, promotionScreen, withoutPii } from "../hub/memory-policy.ts";
|
|
8
7
|
import { currentRouting, detectSignals } from "../hub/routing.ts";
|
|
9
8
|
import { loadConfig } from "../hub/daemon.ts";
|
|
10
|
-
import { bindProfileMemory, openProfileMemory, MEMORY_PROFILE_UNAVAILABLE, type MemoryProfileReceipt } from "../hub/memory-profile.ts";
|
|
9
|
+
import { bindProfileMemory, memoryProjectFor, openProfileMemory, MEMORY_PROFILE_UNAVAILABLE, MEMORY_SPLIT_INCOMPLETE, type MemoryProfileReceipt, type ProfileMemoryOptions } from "../hub/memory-profile.ts";
|
|
11
10
|
|
|
12
11
|
export { GOAL_USAGE } from "@staix/agent-mem";
|
|
13
12
|
export const MEMORY_USAGE = `${CORE_MEMORY_USAGE} | decay [--days N] [--json] | profile-check [--json]`;
|
|
@@ -18,9 +17,9 @@ const loadConfigSafe = (cwd: string) => { try { return loadConfig(cwd); } catch
|
|
|
18
17
|
|
|
19
18
|
|
|
20
19
|
/** Bind each command at entry; the CLI closes default opens and injected stores remain caller-owned. */
|
|
21
|
-
function commandStores(provided
|
|
20
|
+
function commandStores(provided: MemoryStore | undefined, options: ProfileMemoryOptions) {
|
|
22
21
|
try { return { ...(provided ? bindProfileMemory(provided) : openProfileMemory(options)), owned: !provided }; }
|
|
23
|
-
catch { throw new Error(MEMORY_PROFILE_UNAVAILABLE); }
|
|
22
|
+
catch (error) { throw new Error(error instanceof Error && error.message === MEMORY_SPLIT_INCOMPLETE ? error.message : MEMORY_PROFILE_UNAVAILABLE); }
|
|
24
23
|
}
|
|
25
24
|
function profileOutput(text: string, json: boolean, profile: MemoryProfileReceipt | undefined, person: boolean): string {
|
|
26
25
|
if (!profile) return text;
|
|
@@ -44,9 +43,9 @@ export async function memoryCommand(args: string[], cwd: string, opts: { store?:
|
|
|
44
43
|
const cutoffMs = (opts.now ?? Date.now)() - days * 24 * 3600_000;
|
|
45
44
|
if (!Number.isSafeInteger(days) || !Number.isFinite(cutoffMs) || Math.abs(cutoffMs) > 8_640_000_000_000_000) fail("--days is outside the supported date range");
|
|
46
45
|
const cutoff = new Date(cutoffMs).toISOString();
|
|
47
|
-
const project =
|
|
46
|
+
const project = memoryProjectFor(cwd), routing = currentRouting(cwd), paths = privatePaths();
|
|
48
47
|
// Review must not promote a held source while reading it: only explicit person actions may append rows.
|
|
49
|
-
const context = commandStores(opts.store, { deferPromotions: true, now: opts.now });
|
|
48
|
+
const context = commandStores(opts.store, { project, deferPromotions: true, now: opts.now });
|
|
50
49
|
const store = context.store;
|
|
51
50
|
try {
|
|
52
51
|
context.binding.assertCurrent();
|
|
@@ -68,11 +67,12 @@ export async function memoryCommand(args: string[], cwd: string, opts: { store?:
|
|
|
68
67
|
} finally { if (context.owned) store.close(); }
|
|
69
68
|
}
|
|
70
69
|
let showStale = false;
|
|
71
|
-
const
|
|
72
|
-
|
|
70
|
+
const project = memoryProjectFor(cwd);
|
|
71
|
+
const context = commandStores(opts.store, { project, ttlMs: (loadConfigSafe(cwd)?.memory.short_ttl_days ?? 14) * 24 * 3600_000,
|
|
72
|
+
screen: promotionScreen(project, cwd, () => currentRouting(cwd), privatePaths) });
|
|
73
73
|
try {
|
|
74
74
|
context.binding.assertCurrent();
|
|
75
|
-
const text = await rawMemoryCommand(args, cwd, { ...opts, store: context.store, open: () => context.store,
|
|
75
|
+
const text = await rawMemoryCommand(args, cwd, { ...opts, project, store: context.store, open: () => context.store,
|
|
76
76
|
readable: notes => {
|
|
77
77
|
const paths = privatePaths(), visible = withoutPii(notes, currentRouting(cwd), cwd, paths);
|
|
78
78
|
if (args[0] !== "show" || args.includes("--json")) return visible;
|
|
@@ -92,10 +92,11 @@ export async function memoryCommand(args: string[], cwd: string, opts: { store?:
|
|
|
92
92
|
export async function goalCommand(args: string[], cwd: string, opts: { store?: MemoryStore; link?: (goal: string, task: string) => Promise<string>; person?: boolean } = {}): Promise<string> {
|
|
93
93
|
const person = opts.person ?? detectCliIdentity(process.env).role === "console";
|
|
94
94
|
const policy = (): string[] => { try { return loadConfig(cwd).pii?.paths ?? []; } catch (e) { return fail(`the project's config does not load (${(e as Error).message}); fix it before using long-term tasks`); } };
|
|
95
|
-
const
|
|
95
|
+
const project = memoryProjectFor(cwd);
|
|
96
|
+
const context = commandStores(opts.store, { project });
|
|
96
97
|
try {
|
|
97
98
|
context.binding.assertCurrent();
|
|
98
|
-
const text = await rawGoalCommand(args, cwd, { ...opts, store: context.store, open: () => context.store,
|
|
99
|
+
const text = await rawGoalCommand(args, cwd, { ...opts, project, store: context.store, open: () => context.store,
|
|
99
100
|
clean: (title, milestones, project) => !looksSecret(`${title}\n${milestones.join("\n")}`) && withoutPii([{ title, text: milestones.join("\n"), project }], currentRouting(cwd), cwd, policy()).length > 0,
|
|
100
101
|
});
|
|
101
102
|
return profileOutput(text, args.includes("--json"), context.binding.status(context.store), person);
|
package/src/cli/reset.ts
CHANGED
|
@@ -10,6 +10,8 @@ import { HUB_DB_BUSY_TIMEOUT_MS } from "../hub/db-timeout.ts";
|
|
|
10
10
|
|
|
11
11
|
/** Agent session resume pointers. `pi-sessions/` holds Pi transcripts, history rather than pointers, and stays. */
|
|
12
12
|
export const SESSION_POINTERS = ["sessions.json", "claude-session.json", "claude-context.json"];
|
|
13
|
+
/** The fixed pointers plus each Claude instance's own session and context files (#450 I3). */
|
|
14
|
+
const sessionPointers = (stateDir: string): string[] => [...SESSION_POINTERS, ...(existsSync(stateDir) ? readdirSync(stateDir).filter(name => /^claude-(session|context)\.[a-z][a-z0-9-]{0,31}\.json$/.test(name)) : [])];
|
|
13
15
|
/** What a running daemon publishes; a daemon that did not stop cleanly (crash, SIGKILL, forced exit) leaves it. */
|
|
14
16
|
export const MANIFEST = ["status.json", "control-token", "hub.pid"];
|
|
15
17
|
|
|
@@ -51,7 +53,7 @@ const shaped = (ok: boolean, what: string) => { if (!ok) throw new Error(`invali
|
|
|
51
53
|
/** What a reset would act on, read from the state directory without changing it (hub.db opened read-only). */
|
|
52
54
|
export function planReset(stateDir: string, projectId: string): ResetPlan {
|
|
53
55
|
const plan: ResetPlan = { queued: [], needsReview: [], inFlight: [], manualHolds: [], budgetPauses: [], conductorHolds: [],
|
|
54
|
-
sessionPointers:
|
|
56
|
+
sessionPointers: sessionPointers(stateDir).filter((name) => existsSync(join(stateDir, name))), entries: existsSync(stateDir) ? readdirSync(stateDir).length : 0 };
|
|
55
57
|
const file = join(stateDir, "hub.db");
|
|
56
58
|
if (!existsSync(file)) return plan;
|
|
57
59
|
// A read-only open of a WAL database creates hub.db-wal and -shm when the last writer's close removed them (Linux),
|
|
@@ -143,7 +145,7 @@ export function resetRuntime(stateDir: string, project: Project): { settled: str
|
|
|
143
145
|
})();
|
|
144
146
|
} finally { db.close(); }
|
|
145
147
|
}
|
|
146
|
-
for (const name of
|
|
148
|
+
for (const name of sessionPointers(stateDir)) rmSync(join(stateDir, name), { force: true });
|
|
147
149
|
// The caller has checked that no daemon behind these is alive or uncertain: they are a crashed run's leftovers.
|
|
148
150
|
const stale = MANIFEST.filter((name) => existsSync(join(stateDir, name)));
|
|
149
151
|
for (const name of stale) rmSync(join(stateDir, name), { force: true });
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import { spawnSync } from "node:child_process";
|
|
7
7
|
import { chmodSync, mkdirSync, renameSync, writeFileSync } from "node:fs";
|
|
8
8
|
import { join } from "node:path";
|
|
9
|
+
import { claudePeer, claudeStateFile } from "../hub/claude-state.ts";
|
|
9
10
|
|
|
10
11
|
const input = await Bun.stdin.text();
|
|
11
12
|
let own = "";
|
|
@@ -15,6 +16,8 @@ try {
|
|
|
15
16
|
const pct = (w: any) => (typeof w?.used_percentage === "number" ? `${Math.round(w.used_percentage)}%` : "-");
|
|
16
17
|
if (limits) own = `agent-hub 5h ${pct(limits.five_hour)} wk ${pct(limits.seven_day)}`;
|
|
17
18
|
const dir = process.env.AGENTHUB_STATE_DIR;
|
|
19
|
+
// Usage is per account and shared; context and session belong to this peer (#450 I3: a claude-<name> instance).
|
|
20
|
+
const peer = claudePeer(process.env.AGENTHUB_PEER_ID);
|
|
18
21
|
if (limits && dir) {
|
|
19
22
|
mkdirSync(dir, { recursive: true });
|
|
20
23
|
const file = join(dir, "claude-usage.json");
|
|
@@ -25,7 +28,7 @@ try {
|
|
|
25
28
|
if (sessionId && dir) {
|
|
26
29
|
// Bind context to the launcher, daemon and native session. Persist no prompts or transcript text.
|
|
27
30
|
mkdirSync(dir, { recursive: true });
|
|
28
|
-
const file = join(dir, "claude-context
|
|
31
|
+
const file = join(dir, claudeStateFile("claude-context", peer));
|
|
29
32
|
const c = parsed.context_window;
|
|
30
33
|
const current = c?.current_usage;
|
|
31
34
|
const numeric = (v: unknown) => typeof v === "number" && Number.isFinite(v) && v >= 0 ? v : null;
|
|
@@ -38,7 +41,7 @@ try {
|
|
|
38
41
|
}
|
|
39
42
|
if (sessionId && dir) {
|
|
40
43
|
mkdirSync(dir, { recursive: true });
|
|
41
|
-
const file = join(dir, "claude-session
|
|
44
|
+
const file = join(dir, claudeStateFile("claude-session", peer));
|
|
42
45
|
// The transcript path lets the hub tell a zero-turn session (file not yet created, so
|
|
43
46
|
// `claude --resume` can never work) from one whose conversation must be preserved (#64).
|
|
44
47
|
const transcriptPath = typeof parsed.transcript_path === "string" && parsed.transcript_path ? parsed.transcript_path : undefined;
|
|
@@ -331,7 +331,12 @@ function executionRoot(peer: string, projectRoot: string, captured?: string): st
|
|
|
331
331
|
const { instances: declarations, roles } = readInstanceRoleConfig(projectRoot);
|
|
332
332
|
const definition = definitionOf(peer, declarations);
|
|
333
333
|
if (!definition?.session.launchRecord) throw new Error(`${peer}: no managed terminal recovery adapter`);
|
|
334
|
-
if (definition.transport !== "codex-appserver"
|
|
334
|
+
if (!declarations?.[peer] || definition.transport !== "codex-appserver" && definition.transport !== "claude-channel") throw new Error(`${peer}: extra instance has no managed terminal support`);
|
|
335
|
+
// #450 I3: a Claude instance reviews or plans in the canonical checkout; it never owns an execution worktree.
|
|
336
|
+
if (definition.transport === "claude-channel") {
|
|
337
|
+
if (captured !== undefined && captured !== projectRoot) throw new Error(`${peer}: recorded execution root changed; manual recovery required`);
|
|
338
|
+
return projectRoot;
|
|
339
|
+
}
|
|
335
340
|
const expected = resolveInstanceExecutionCwd(projectRoot, peer, declarations, effectiveInstanceRoles(peer, declarations, roles));
|
|
336
341
|
if (captured !== undefined && captured !== expected) throw new Error(`${peer}: declared execution worktree changed; manual recovery required`);
|
|
337
342
|
return expected;
|
|
@@ -662,7 +667,7 @@ export async function inspectTerminals(projectRoot: string, sessions: Partial<Re
|
|
|
662
667
|
for (const [peer, ref] of requested) {
|
|
663
668
|
const sessionId = ref.sessionId;
|
|
664
669
|
const adapter = definitionOf(peer, instances), extra = definitionOf(peer) === undefined;
|
|
665
|
-
if (!adapter?.session.launchRecord || extra && adapter.transport !== "codex-appserver") { blockers.push(blocker("ownership-unknown", `${peer}: no declared managed-terminal recovery adapter`, peer)); continue; }
|
|
670
|
+
if (!adapter?.session.launchRecord || extra && adapter.transport !== "codex-appserver" && adapter.transport !== "claude-channel") { blockers.push(blocker("ownership-unknown", `${peer}: no declared managed-terminal recovery adapter`, peer)); continue; }
|
|
666
671
|
const ownRecord = records.find(item => item.peer === peer);
|
|
667
672
|
let expectedRoot: string, expectedTerminalRoot: string;
|
|
668
673
|
try {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { resolveAcceptedBinding } from "./recovery-guidance.ts";
|
|
2
|
+
import { claudeStateFile } from "../hub/claude-state.ts";
|
|
2
3
|
import { ADAPTERS, definitionOf } from "../adapters/registry.ts";
|
|
3
4
|
import { readInstanceConfig } from "../hub/instance-config.ts";
|
|
4
5
|
import { isDatabaseBusy, isDatabaseBusyReply, RecoveryDatabaseBusy } from "../hub/recovery-error.ts";
|
|
@@ -265,21 +266,21 @@ export async function makeUpgradePlan(kind: "restart" | "upgrade", version: stri
|
|
|
265
266
|
if (recorded?.state === "unknown") {
|
|
266
267
|
// An unreadable launcher is never gone (#215): whether the session is managed cannot be told.
|
|
267
268
|
delete peer.sessionId;
|
|
268
|
-
blockers.push(
|
|
269
|
+
blockers.push(`${peer.id}: ${recorded.record ? `the launcher recorded in terminal ${recorded.record.handle}` : recorded.invalidRow ? "a launch record in terminal-recovery.json that may be its launcher's" : "the launcher record (terminal-recovery.json)"} cannot be read, so whether the attached session is managed is unknown; manual-required; next action: make it readable (or end that session and close its terminal), then make a new plan`);
|
|
269
270
|
continue;
|
|
270
271
|
}
|
|
271
272
|
const launcher = recorded?.state === "live" ? recorded.record! : undefined;
|
|
272
273
|
if (!launcher) {
|
|
273
274
|
delete peer.sessionId;
|
|
274
275
|
if (source.protocol === PROTOCOL) reconnectOnly.push(peer.id);
|
|
275
|
-
else blockers.push(
|
|
276
|
+
else blockers.push(`${peer.id}: unmanaged session (no live ahub claude launcher recorded) runs a protocol-${source.protocol} plugin and cannot reconnect to a protocol-${PROTOCOL} hub; next action: end that Claude session, or relaunch it with ahub claude in an Orca terminal, then make a new plan`);
|
|
276
277
|
continue;
|
|
277
278
|
}
|
|
278
279
|
let record: { instanceId?: unknown; launchId?: unknown; sessionId?: unknown } | undefined;
|
|
279
|
-
try { record = JSON.parse(readFileSync(join(project.stateDir, "claude-session.
|
|
280
|
+
try { record = JSON.parse(readFileSync(join(project.stateDir, claudeStateFile("claude-session", peer.id)), "utf8")); } catch { /* no record: the id check below reports it */ }
|
|
280
281
|
if (peer.sessionId && (record?.instanceId !== source.instanceId || record?.launchId !== launcher.launchId || record?.sessionId !== peer.sessionId)) {
|
|
281
282
|
delete peer.sessionId;
|
|
282
|
-
blockers.push(
|
|
283
|
+
blockers.push(`${peer.id}: the recorded session was not written by the live launcher in terminal ${launcher.handle}; manual-required; next action: close any other Claude session in this project, send one message in that terminal, then make a new plan`);
|
|
283
284
|
continue;
|
|
284
285
|
}
|
|
285
286
|
}
|
|
@@ -340,7 +341,8 @@ export function preserveSource(plan: UpgradePlan, home = hubHome()): string {
|
|
|
340
341
|
export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?: string, clock?: Pick<RecoveryDriver, "now" | "sleep">): RecoveryDriver {
|
|
341
342
|
const now = clock?.now ?? (() => Date.now());
|
|
342
343
|
const sleep = clock?.sleep ?? ((ms: number) => Bun.sleep(ms));
|
|
343
|
-
|
|
344
|
+
/** `only`: one binding's native profile, for a command run once per profile (#450 I3: Claude instances may differ). */
|
|
345
|
+
const env = (op: RecoveryOperation, project?: Project, only?: TerminalBinding): NodeJS.ProcessEnv => {
|
|
344
346
|
const value = { ...process.env };
|
|
345
347
|
// Do not carry one project's gateway, auth overrides or unattended mode into another.
|
|
346
348
|
for (const key of Object.keys(value)) if (key.startsWith("AGENTHUB_") || key.startsWith("OMNIROUTE_") || key.startsWith("CF_ACCESS_") ||
|
|
@@ -352,7 +354,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
|
|
|
352
354
|
value.AGENTHUB_PROJECT_DIR = project.root; value.AGENTHUB_STATE_DIR = project.stateDir;
|
|
353
355
|
delete value.CODEX_HOME; delete value.CLAUDE_CONFIG_DIR;
|
|
354
356
|
const bindings = op.plan.projects.find((p) => p.project.id === project.id)?.terminals as TerminalBinding[] | undefined;
|
|
355
|
-
for (const binding of bindings ?? []) {
|
|
357
|
+
for (const binding of only ? [only] : bindings ?? []) {
|
|
356
358
|
// Preserve the captured legacy profile for terminal bindings without a dedicated native config variable.
|
|
357
359
|
const key = projectDefinition(binding.peer, binding.projectRoot)?.session.configDirEnv ?? "CLAUDE_CONFIG_DIR";
|
|
358
360
|
if (binding.launch.env[key]) value[key] = binding.launch.env[key];
|
|
@@ -360,9 +362,9 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
|
|
|
360
362
|
}
|
|
361
363
|
return value;
|
|
362
364
|
};
|
|
363
|
-
const command = async (op: RecoveryOperation, args: string[], project?: Project) => {
|
|
365
|
+
const command = async (op: RecoveryOperation, args: string[], project?: Project, only?: TerminalBinding) => {
|
|
364
366
|
const result = await run([process.execPath, join(op.targetRoot!, "src/cli/main.js"), ...(project ? ["--project", project.root] : []), ...(liveTestPeer ? ["--live-test"] : []), ...args],
|
|
365
|
-
{ env: env(op, project), cwd: project?.root ?? op.plan.projects[0]!.project.root, timeoutMs: 180_000 });
|
|
367
|
+
{ env: env(op, project, only), cwd: project?.root ?? op.plan.projects[0]!.project.root, timeoutMs: 180_000 });
|
|
366
368
|
if (result.code !== 0) throw new Error(`target command ${args[0]} failed; inspect recovery state before retrying`);
|
|
367
369
|
return result;
|
|
368
370
|
};
|
|
@@ -927,12 +929,13 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
|
|
|
927
929
|
installPlugin: async (op) => {
|
|
928
930
|
const configured = new Set<string>();
|
|
929
931
|
for (const planned of op.plan.projects) {
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
932
|
+
// Every Claude binding (the builtin and each instance), once per distinct profile, each under its own profile.
|
|
933
|
+
for (const binding of (planned.terminals as TerminalBinding[]).filter((t) => projectDefinition(t.peer, t.projectRoot)?.transport === "claude-channel")) {
|
|
934
|
+
const profile = binding.launch.env.CLAUDE_CONFIG_DIR ?? "default";
|
|
935
|
+
if (configured.has(profile)) continue;
|
|
936
|
+
await command(op, ["setup", "--yes"], planned.project, binding);
|
|
937
|
+
configured.add(profile);
|
|
938
|
+
}
|
|
936
939
|
}
|
|
937
940
|
if (!configured.size) await command(op, ["setup", "--yes"]);
|
|
938
941
|
},
|
|
@@ -1002,7 +1005,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
|
|
|
1002
1005
|
// #64: the restore gate already accepted a fresh session when the original never
|
|
1003
1006
|
// persisted a transcript. Re-derive persistence from the binding exactly like that
|
|
1004
1007
|
// gate did, so a first turn landing after the plan was made keeps the check strict.
|
|
1005
|
-
const binding = (planned.terminals as TerminalBinding[]).find((t) => projectDefinition(t.peer, t.projectRoot)?.transport === "claude-channel");
|
|
1008
|
+
const binding = (planned.terminals as TerminalBinding[]).find((t) => t.peer === old.id && projectDefinition(t.peer, t.projectRoot)?.transport === "claude-channel");
|
|
1006
1009
|
if (recordedOnly || !binding || binding.sessionId !== old.sessionId || !nativeZeroTurn(planned.project.stateDir, binding, "verify")) throw nativeFailure(planned, old.id, "session_changed", "Claude resumed a different conversation", { ...(old.sessionId ? { expectedSession: old.sessionId } : {}), ...(peer.sessionId ? { observedSession: peer.sessionId } : {}) });
|
|
1007
1010
|
}
|
|
1008
1011
|
if (projectDefinition(old.id, planned.project.root)?.session.launchRecord && projectDefinition(old.id, planned.project.root)?.session.crashIdentity && (peer.sessionId !== old.sessionId || (old.sessionFile && peer.sessionFile !== old.sessionFile))) throw nativeFailure(planned, old.id, "session_changed", "Pi resumed a different conversation", { ...(old.sessionId ? { expectedSession: old.sessionId } : {}), ...(peer.sessionId ? { observedSession: peer.sessionId } : {}) });
|
package/src/hub/board.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { ReviewerPoolReason } from "./reviewer-pool.ts";
|
|
1
2
|
import type { GitHubSource } from "./github-import.ts";
|
|
2
3
|
import { Database } from "bun:sqlite";
|
|
3
4
|
import { planningPatch, type PlanningFields, type Release, type ReleaseState } from "./planning.ts";
|
|
@@ -5,6 +6,7 @@ import { HUB, type PeerId } from "./envelope.ts";
|
|
|
5
6
|
import type { SweepRecord } from "./task-sweep.ts";
|
|
6
7
|
import { HUB_DB_BUSY_TIMEOUT_MS } from "./db-timeout.ts";
|
|
7
8
|
import type { SdlcReceipt } from "./sdlc.ts";
|
|
9
|
+
import type { CostPlan, ReviewCost } from "./cost-governance.ts";
|
|
8
10
|
|
|
9
11
|
export const CLASSES = ["plan", "implement", "bulk_edit", "test", "review", "summarize", "triage"] as const;
|
|
10
12
|
export type TaskClass = (typeof CLASSES)[number];
|
|
@@ -27,7 +29,7 @@ export interface TaskRefs {
|
|
|
27
29
|
export const PLAN_KEYS = ["paths", "symbols", "signatures", "insertion_points"] as const;
|
|
28
30
|
export const DOCUMENT_PLAN_KEYS = ["paths", "sections", "sources", "claims"] as const;
|
|
29
31
|
export const ALL_PLAN_KEYS = [...PLAN_KEYS, "sections", "sources", "claims"] as const;
|
|
30
|
-
export type TaskPlan = Partial<Record<(typeof ALL_PLAN_KEYS)[number], string[]
|
|
32
|
+
export type TaskPlan = Partial<Record<(typeof ALL_PLAN_KEYS)[number], string[]>> & CostPlan;
|
|
31
33
|
/** Task outcomes older than this are pruned: demotion (issue #36) reads one week, seven half-lives. */
|
|
32
34
|
export const OUTCOMES_KEPT_MS = 7 * 24 * 3_600_000;
|
|
33
35
|
|
|
@@ -51,8 +53,24 @@ export interface HistoryEntry {
|
|
|
51
53
|
sweep?: SweepRecord;
|
|
52
54
|
/** The PII screen did not clear this entry's note (#198): only the console reads it. */
|
|
53
55
|
withheld?: true;
|
|
56
|
+
/** Host-recorded expected cost and exact review head, never narrative. */
|
|
57
|
+
reviewCost?: ReviewCost;
|
|
58
|
+
/** #223 R4: on a verdict, whether the reviewer's context was new for it; set by the hub, never by the reviewer. */
|
|
59
|
+
reviewContext?: "fresh" | "continuing";
|
|
60
|
+
reviewerFamily?: string;
|
|
61
|
+
reviewerReason?: ReviewerPoolReason;
|
|
62
|
+
reviewBlocked?: true;
|
|
63
|
+
reviewerFailure?: { peer: string; head?: string; cause: "start_failed" | "quota" | "no_verdict" };
|
|
54
64
|
planning?: { revision: number; fields: (keyof PlanningFields)[]; previous: PlanningFields; pull?: true; split?: { children: { id: number; revision: number; history: number }[] } };
|
|
55
65
|
}
|
|
66
|
+
export type ReviewerHistory = Pick<HistoryEntry, "reviewerFamily" | "reviewerReason" | "reviewBlocked" | "reviewerFailure">;
|
|
67
|
+
function reviewerHistory(extra: ReviewerHistory): ReviewerHistory {
|
|
68
|
+
const id = (value: unknown) => typeof value === "string" && /^[a-z][a-z0-9-]{0,63}$/.test(value);
|
|
69
|
+
if (extra.reviewerFamily !== undefined && !id(extra.reviewerFamily) || extra.reviewerReason !== undefined && !["rotation", "implementer skip", "unavailable skip", "same-family fallback"].includes(extra.reviewerReason) || extra.reviewBlocked !== undefined && extra.reviewBlocked !== true) throw new Error("invalid reviewer metadata");
|
|
70
|
+
const failure = extra.reviewerFailure;
|
|
71
|
+
if (failure && (!id(failure.peer) || !["start_failed", "quota", "no_verdict"].includes(failure.cause) || failure.head !== undefined && (typeof failure.head !== "string" || !failure.head.trim() || failure.head.length > 300 || Buffer.byteLength(failure.head) > 1200 || /\p{Cc}/u.test(failure.head)))) throw new Error("invalid reviewer failure metadata");
|
|
72
|
+
return { ...(extra.reviewerFamily ? { reviewerFamily: extra.reviewerFamily } : {}), ...(extra.reviewerReason ? { reviewerReason: extra.reviewerReason } : {}), ...(extra.reviewBlocked ? { reviewBlocked: true } : {}), ...(failure ? { reviewerFailure: { peer: failure.peer, cause: failure.cause, ...(failure.head ? { head: failure.head } : {}) } } : {}) };
|
|
73
|
+
}
|
|
56
74
|
export interface Task extends PlanningFields {
|
|
57
75
|
offer?: TaskOffer;
|
|
58
76
|
cancelled?: TaskCancellation;
|
|
@@ -90,6 +108,19 @@ export interface Task extends PlanningFields {
|
|
|
90
108
|
export function taskHasStarted(task: Pick<Task, "history">): boolean {
|
|
91
109
|
return task.history.some(entry => ["accepted", "done", "done (checking)", "check passed", "check failed", "changes_requested", "approved", "reopened", "integration requested", "integrated"].includes(entry.event));
|
|
92
110
|
}
|
|
111
|
+
/** S9 return eligibility never erases the historical acceptance used by cancellation and undo. */
|
|
112
|
+
export function taskHasWorkEvidence(task: Pick<Task, "history" | "refs" | "sdlc">): boolean {
|
|
113
|
+
return !!(task.refs.branch || task.refs.commit || task.sdlc?.localHead || task.sdlc?.localResult || task.sdlc?.reviewedHead || task.sdlc?.pr)
|
|
114
|
+
|| task.history.some(entry => entry.event === "done" || entry.event.startsWith("done (") || entry.event.startsWith("check ")
|
|
115
|
+
|| ["changes_requested", "approved", "reopened", "integration requested", "integration unresolved", "integrated"].includes(entry.event));
|
|
116
|
+
}
|
|
117
|
+
/** A successful untouched return permits replanning, without changing historical start evidence. */
|
|
118
|
+
export function taskCanPlan(task: Pick<Task, "history" | "refs" | "sdlc">): boolean {
|
|
119
|
+
if (!taskHasStarted(task)) return true;
|
|
120
|
+
const returned = task.history.findLastIndex(entry => entry.event === "backlog returned untouched");
|
|
121
|
+
const accepted = task.history.findLastIndex(entry => entry.event === "accepted");
|
|
122
|
+
return returned > accepted && !taskHasWorkEvidence(task);
|
|
123
|
+
}
|
|
93
124
|
/** First offer boundary; staged work has none until it is pulled. */
|
|
94
125
|
export function taskProposalAt(task: Pick<Task, "history">): number | undefined {
|
|
95
126
|
return task.history.find(entry => entry.event === "proposed" || entry.event === "backlog pulled")?.at;
|
|
@@ -191,6 +222,7 @@ export class Board {
|
|
|
191
222
|
release_order INTEGER NOT NULL, state TEXT NOT NULL DEFAULT 'planning', planned_start INTEGER, cut INTEGER, ship INTEGER,
|
|
192
223
|
activated_at INTEGER, frozen_at INTEGER, shipped_at INTEGER, created INTEGER NOT NULL, updated INTEGER NOT NULL,
|
|
193
224
|
revision INTEGER NOT NULL DEFAULT 0)`);
|
|
225
|
+
if (!(this.db.query("PRAGMA table_info(releases)").all() as { name: string }[]).some(row => row.name === "train")) this.db.run("ALTER TABLE releases ADD COLUMN train TEXT");
|
|
194
226
|
|
|
195
227
|
}
|
|
196
228
|
|
|
@@ -270,18 +302,23 @@ export class Board {
|
|
|
270
302
|
|
|
271
303
|
/** The only way a task changes. Validates the move, records who did what, returns the new row. */
|
|
272
304
|
/** A `plan` in the patch replaces the old one whole: a new plan is the owner's current intent, not an addition. */
|
|
273
|
-
update(id: number, by: PeerId, event: string, patch: Partial<Pick<Task, "state" | "owner" | "reviewer" | "reserved" | "refs" | "plan" | "rejections" | "signals" | "sdlc" | "role" | "cancelled">>, note?: string, extra: Pick<HistoryEntry, "profile" | "sweep" | "reason" | "from" | "withheld"> = {}, expectedFlowRevision?: number): Task {
|
|
305
|
+
update(id: number, by: PeerId, event: string, patch: Partial<Pick<Task, "state" | "owner" | "reviewer" | "reserved" | "refs" | "plan" | "rejections" | "signals" | "sdlc" | "role" | "cancelled">>, note?: string, extra: Pick<HistoryEntry, "profile" | "sweep" | "reason" | "from" | "withheld" | "reviewCost" | "reviewContext" | keyof ReviewerHistory> = {}, expectedFlowRevision?: number): Task {
|
|
274
306
|
const task = this.get(id);
|
|
275
307
|
if (!task) throw new Error(`no task #${id}`);
|
|
276
308
|
if (expectedFlowRevision !== undefined && (!Number.isSafeInteger(expectedFlowRevision) || expectedFlowRevision < 0 || task.history.length !== expectedFlowRevision)) throw new Error("task flow revision changed");
|
|
277
|
-
|
|
309
|
+
const untouchedReturn = event === "backlog returned untouched" && patch.state === "backlog" && patch.owner === null && patch.reviewer === null
|
|
310
|
+
&& ["proposed", "in_progress"].includes(task.state) && expectedFlowRevision !== undefined && !!note?.trim() && !taskHasWorkEvidence(task);
|
|
311
|
+
if (patch.state && patch.state !== task.state && !MOVES[task.state].includes(patch.state) && !untouchedReturn) {
|
|
278
312
|
throw new Error(`task #${id} is ${task.state}: cannot move to ${patch.state}`);
|
|
279
313
|
}
|
|
280
314
|
const next = { ...task, ...patch, refs: { ...task.refs, ...patch.refs } };
|
|
281
315
|
if (next.state === "backlog" && (next.owner || next.reviewer)) throw new Error(`task #${id}: backlog cannot have an owner or reviewer`);
|
|
282
|
-
if (
|
|
283
|
-
|
|
284
|
-
|
|
316
|
+
if (!untouchedReturn) {
|
|
317
|
+
if (patch.state === "backlog" && task.owner) throw new Error(`task #${id}: owned work cannot return to backlog`);
|
|
318
|
+
}
|
|
319
|
+
if (patch.state === "backlog" && taskHasStarted(task) && !untouchedReturn) throw new Error(`task #${id}: started work cannot return to backlog`);
|
|
320
|
+
const reviewMetadata = reviewerHistory(extra);
|
|
321
|
+
const history = [...task.history, { at: Date.now(), by, event, ...reviewMetadata, ...(note ? { note } : {}), ...(patch.cancelled?.supersededBy ? { supersededBy: patch.cancelled.supersededBy } : {}), ...("owner" in patch ? { owner: next.owner } : {}), ...(extra.profile ? { profile: extra.profile } : {}), ...(extra.sweep ? { sweep: extra.sweep } : {}), ...(extra.reason && TASK_MOVE_REASONS.includes(extra.reason) ? { reason: extra.reason } : {}), ...(extra.from ? { from: extra.from } : {}), ...(extra.withheld && note ? { withheld: true as const } : {}), ...(extra.reviewContext === "fresh" || extra.reviewContext === "continuing" ? { reviewContext: extra.reviewContext } : {}), ...(extra.reviewCost ? { reviewCost: extra.reviewCost } : {}) }];
|
|
285
322
|
this.taskWrite(() => this.db
|
|
286
323
|
.query("UPDATE tasks SET state = ?, owner = ?, reviewer = ?, reserved = ?, refs = ?, plan = ?, rejections = ?, signals = ?, history = ?, sdlc = ?, task_role = ?, cancelled = ?, updated = ? WHERE id = ?")
|
|
287
324
|
.run(next.state, next.owner, next.reviewer, next.reserved ?? null, JSON.stringify(next.refs), JSON.stringify(next.plan ?? {}), next.rejections, JSON.stringify(next.signals), JSON.stringify(history), next.sdlc ? JSON.stringify(next.sdlc) : null, next.role ?? null, next.cancelled ? JSON.stringify(next.cancelled) : null, Date.now(), id));
|
|
@@ -309,7 +346,7 @@ export class Board {
|
|
|
309
346
|
/** A planner reserves a routing slot without claiming or dispatching the work. */
|
|
310
347
|
pullPlanned(id: number, by: PeerId, reserved: PeerId, revision: number, note: string, withheld = false): Task {
|
|
311
348
|
const task = this.get(id);
|
|
312
|
-
if (!task || (task.planningRevision ?? 0) !== revision || task.state !== "backlog" || task.owner ||
|
|
349
|
+
if (!task || (task.planningRevision ?? 0) !== revision || task.state !== "backlog" || task.owner || !taskCanPlan(task)) throw new Error("planner pull changed or already started");
|
|
313
350
|
const now = Date.now(), entry: HistoryEntry = { at: now, by, event: "backlog pulled", planning: { revision: revision + 1, fields: ["reserved"], previous: { reserved: task.reserved ?? null }, pull: true }, note, ...(withheld ? { withheld: true as const } : {}) };
|
|
314
351
|
const result = this.taskWrite(() => this.db.query("UPDATE tasks SET state='proposed',owner=NULL,reviewer=NULL,reserved=?,planning_revision=planning_revision+1,history=?,updated=? WHERE id=? AND planning_revision=? AND state='backlog' AND owner IS NULL")
|
|
315
352
|
.run(reserved, JSON.stringify([...task.history, entry]), now, id, revision));
|
|
@@ -380,20 +417,33 @@ export class Board {
|
|
|
380
417
|
releases(): Release[] {
|
|
381
418
|
return (this.db.query("SELECT * FROM releases ORDER BY release_order,id").all() as Record<string, unknown>[]).map(parseRelease);
|
|
382
419
|
}
|
|
383
|
-
updateRelease(id: number, patch: Partial<Pick<Release, "state" | "plannedStart" | "cut" | "ship">>, revision: number): Release {
|
|
420
|
+
updateRelease(id: number, patch: Partial<Pick<Release, "state" | "plannedStart" | "cut" | "ship" | "train">>, revision: number): Release {
|
|
384
421
|
const release = this.release(id);
|
|
385
422
|
if (!release || release.revision !== revision) throw new Error("release revision changed");
|
|
386
423
|
const next = { ...release, ...patch }, now = Date.now();
|
|
387
424
|
const moves: Record<ReleaseState, ReleaseState[]> = { planning: ["active", "dropped"], active: ["frozen", "dropped"], frozen: ["shipped", "dropped"], shipped: [], dropped: [] };
|
|
388
425
|
if (next.state !== release.state && !moves[release.state].includes(next.state)) throw new Error("invalid release transition");
|
|
426
|
+
if (patch.train && next.state !== release.state && ["frozen", "shipped"].includes(next.state)) {
|
|
427
|
+
const event = next.train?.history.at(-1);
|
|
428
|
+
if (event?.event === (next.state === "frozen" ? "cut" : "shipped")) event.at = now;
|
|
429
|
+
}
|
|
389
430
|
const dates = [next.plannedStart, next.cut, next.ship].filter((date): date is number => date !== null);
|
|
390
431
|
if (dates.some(date => !Number.isSafeInteger(date) || date <= 0 || date > 8_640_000_000_000_000) || dates.some((date, i) => i > 0 && dates[i-1]! > date)) throw new Error("invalid release planned dates");
|
|
391
|
-
const result = this.db.query("UPDATE releases SET state=?,planned_start=?,cut=?,ship=?,activated_at=?,frozen_at=?,shipped_at=?,updated=?,revision=revision+1 WHERE id=? AND revision=?")
|
|
392
|
-
.run(next.state, next.plannedStart, next.cut, next.ship, release.activatedAt ?? (next.state === "active" ? now : null), release.frozenAt ?? (next.state === "frozen" ? now : null), release.shippedAt ?? (next.state === "shipped" ? now : null), now, id, revision);
|
|
432
|
+
const result = this.db.query("UPDATE releases SET state=?,planned_start=?,cut=?,ship=?,activated_at=?,frozen_at=?,shipped_at=?,updated=?,train=?,revision=revision+1 WHERE id=? AND revision=?")
|
|
433
|
+
.run(next.state, next.plannedStart, next.cut, next.ship, release.activatedAt ?? (next.state === "active" ? now : null), release.frozenAt ?? (next.state === "frozen" ? now : null), release.shippedAt ?? (next.state === "shipped" ? now : null), now, next.train === undefined ? null : JSON.stringify(next.train), id, revision);
|
|
393
434
|
if (result.changes !== 1) throw new Error("release revision changed");
|
|
394
435
|
return this.release(id)!;
|
|
395
436
|
}
|
|
396
437
|
|
|
438
|
+
/** Cross-connection scope changes cannot race the final freeze write. */
|
|
439
|
+
freezeRelease(id: number, revision: number, scope: Task[], train: NonNullable<Release["train"]>): Release {
|
|
440
|
+
return this.db.transaction(() => {
|
|
441
|
+
const release = this.release(id);
|
|
442
|
+
if (!release || release.revision !== revision || release.state !== "active" || this.releases().some(row => row.id !== id && ["active", "frozen"].includes(row.state)) || JSON.stringify(this.list().filter(task => task.release === id)) !== JSON.stringify(scope)) throw new Error("release scope changed before freeze");
|
|
443
|
+
return this.updateRelease(id, { state: "frozen", train }, revision);
|
|
444
|
+
})();
|
|
445
|
+
}
|
|
446
|
+
|
|
397
447
|
recordOutcome(peer: PeerId, cls: TaskClass, ok: boolean, at = Date.now()): void {
|
|
398
448
|
this.db.query("INSERT INTO outcomes (peer, class, ok, at) VALUES (?, ?, ?, ?)").run(peer, cls, ok ? 1 : 0, at);
|
|
399
449
|
this.db.query("DELETE FROM outcomes WHERE at < ?").run(at - OUTCOMES_KEPT_MS); // nothing older is read
|
|
@@ -452,7 +502,7 @@ function parse(row: Record<string, unknown>): Task {
|
|
|
452
502
|
}
|
|
453
503
|
|
|
454
504
|
function parseRelease(row: Record<string, unknown>): Release {
|
|
455
|
-
return { id: Number(row.id), name: String(row.name), goal: String(row.goal), order: Number(row.release_order), state: row.state as ReleaseState,
|
|
505
|
+
return { ...(row.train == null ? {} : { train: JSON.parse(String(row.train)) }), id: Number(row.id), name: String(row.name), goal: String(row.goal), order: Number(row.release_order), state: row.state as ReleaseState,
|
|
456
506
|
plannedStart: row.planned_start as number | null, cut: row.cut as number | null, ship: row.ship as number | null,
|
|
457
507
|
activatedAt: row.activated_at as number | null, frozenAt: row.frozen_at as number | null, shippedAt: row.shipped_at as number | null,
|
|
458
508
|
created: Number(row.created), updated: Number(row.updated), revision: Number(row.revision) };
|