@staix/agent-hub 0.14.0 → 0.15.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 +17 -0
- package/README.md +16 -6
- package/docs/agent-notes/README.md +4 -0
- package/docs/agent-notes/adapters.md +17 -3
- package/docs/agent-notes/budget.md +4 -4
- package/docs/agent-notes/bus.md +1 -0
- package/docs/agent-notes/daemon.md +14 -9
- package/docs/agent-notes/tasks.md +9 -4
- package/docs/agent-notes/tests.md +3 -3
- package/docs/dogfood.md +9 -4
- package/docs/events.md +21 -7
- package/docs/operations.md +90 -18
- package/docs/profiles.md +1 -0
- package/docs/quickstart.md +9 -3
- package/docs/sdlc.md +17 -0
- package/docs/security.md +37 -10
- package/docs/specs/2026-09-19-agent-hub-design.md +74 -10
- package/docs/verification/2026-10-23-0.15.0.md +80 -0
- package/docs/verification/2026-10-23-daemon-responsiveness.md +44 -0
- package/docs/verification/2026-10-23-headless-claude-permissions.md +47 -0
- package/docs/verification/2026-10-23-terminal-claude-execution-root.md +41 -0
- package/docs/verified.json +29 -17
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +992 -312
- package/src/adapters/acp.ts +43 -7
- package/src/adapters/claude-channel.ts +26 -4
- package/src/adapters/claude-implementer.ts +243 -0
- package/src/adapters/claude-print-child.ts +90 -0
- package/src/adapters/claude-task-sessions.ts +80 -0
- package/src/adapters/compat.ts +4 -0
- package/src/adapters/conformance/enrollment.ts +2 -1
- package/src/adapters/conformance/fakes/acp-server.ts +3 -1
- package/src/adapters/definitions/builtin-metadata.ts +14 -2
- package/src/adapters/definitions/native-channel-state.ts +1 -1
- package/src/adapters/definitions/native-cli-entry-claude.ts +13 -8
- package/src/adapters/definitions/native-cli-entry-types.ts +2 -0
- package/src/adapters/definitions/native-factories.ts +8 -0
- package/src/adapters/kimi-wire-usage.ts +303 -0
- package/src/adapters/registry-types.ts +7 -3
- package/src/adapters/registry.ts +14 -2
- package/src/adapters/reviewer-print.ts +30 -53
- package/src/cli/console-config.ts +123 -0
- package/src/cli/console-state.ts +28 -1
- package/src/cli/console.ts +25 -1
- package/src/cli/facts-hook.ts +9 -5
- package/src/cli/help.ts +4 -3
- package/src/cli/launch.ts +10 -4
- package/src/cli/main.ts +47 -9
- package/src/cli/output.ts +28 -0
- package/src/cli/reset.ts +1 -1
- package/src/cli/settings-edit.ts +89 -0
- package/src/cli/terminal-recovery.ts +1 -6
- package/src/hub/action-enforce.ts +82 -0
- package/src/hub/action-policy.ts +22 -10
- package/src/hub/board.ts +26 -2
- package/src/hub/budget.ts +5 -0
- package/src/hub/bus.ts +5 -5
- package/src/hub/claude-launch-proof.ts +33 -0
- package/src/hub/config-apply.ts +5 -0
- package/src/hub/config-edit-host.ts +173 -0
- package/src/hub/config-edit.ts +282 -0
- package/src/hub/config-tier.ts +89 -0
- package/src/hub/control-client.ts +24 -3
- package/src/hub/daemon.ts +368 -59
- package/src/hub/deliverable-files.ts +131 -0
- package/src/hub/deliverables.ts +125 -0
- package/src/hub/delivery-journal.ts +18 -2
- package/src/hub/events.ts +3 -1
- package/src/hub/hub-tools.ts +19 -3
- package/src/hub/instances.ts +7 -4
- package/src/hub/kimi-usage.ts +144 -0
- package/src/hub/manager.ts +1 -1
- package/src/hub/peers.ts +7 -0
- package/src/hub/permission-payload.ts +15 -0
- package/src/hub/routing.ts +118 -3
- package/src/hub/settings.ts +1 -1
- package/src/hub/tasks.ts +202 -15
- package/src/hub/token-window.ts +33 -0
- package/src/hub/usage-rollup.ts +16 -6
- package/src/hub/workflow.ts +16 -0
- package/src/ui/index.html +115 -1
package/src/cli/main.ts
CHANGED
|
@@ -18,6 +18,7 @@ import { matchesPolicy } from "../hub/path-policy.ts";
|
|
|
18
18
|
import { projectedDiff } from "../hub/projected-diff.ts";
|
|
19
19
|
import { TextProjections } from "../hub/text-projection.ts";
|
|
20
20
|
import { terminalText } from "./console-state.ts";
|
|
21
|
+
import { parseSettingsEditArgs, runSettingsEdit, SETTINGS_EDIT_USAGE } from "./settings-edit.ts";
|
|
21
22
|
import { petView, readPet, renderPet } from "./pet.ts";
|
|
22
23
|
import { startPetWindow, closePetWindow } from "../pet/window.ts";
|
|
23
24
|
import { loadPetConfig, savePetPalette } from "./pet-config.ts";
|
|
@@ -36,7 +37,7 @@ import { chmodSync, existsSync, readFileSync, renameSync, writeFileSync } from "
|
|
|
36
37
|
import { randomUUID } from "node:crypto";
|
|
37
38
|
import { homedir } from "node:os";
|
|
38
39
|
import { basename, dirname, join, relative, resolve } from "node:path";
|
|
39
|
-
import { ControlClient, readControl } from "../hub/control-client.ts";
|
|
40
|
+
import { retryLaunchConnect, ControlClient, readControl } from "../hub/control-client.ts";
|
|
40
41
|
import { loadConfig } from "../hub/daemon.ts";
|
|
41
42
|
import { factsHook } from "./facts-hook.ts";
|
|
42
43
|
import { hubHome, projectContext, projectRoot, realPath } from "../hub/project.ts";
|
|
@@ -89,7 +90,7 @@ import { agentShellCause, classifyPeerCommand, cliCommandLabel, detectCliIdentit
|
|
|
89
90
|
import { recordCliAudit, recordDelegatedCliAudit } from "./identity-audit.ts";
|
|
90
91
|
import { runConsole } from "./console.ts";
|
|
91
92
|
import { paint, permissionBoundary, resolveColor, wrap, type Span } from "./console-state.ts";
|
|
92
|
-
import { outputWidth, renderRoster, renderStatus, renderBoard, renderBoardColumns, boardColumnModel, renderBudget, renderDoctor, renderProjects, renderQueue, renderTurns, renderOrphans, renderQueueShow, renderModelsStatus, renderExecutionBudgetStatus, renderReport, renderUsage, renderLimits, renderTotal, type DoctorCheck } from "./output.ts";
|
|
93
|
+
import { outputWidth, renderRoster, renderStatus, renderBoard, renderBacklog, renderBoardColumns, boardColumnModel, renderBudget, renderDoctor, renderProjects, renderQueue, renderTurns, renderOrphans, renderQueueShow, renderModelsStatus, renderExecutionBudgetStatus, renderReport, renderUsage, renderLimits, renderTotal, type DoctorCheck } from "./output.ts";
|
|
93
94
|
import { renderHelp, configuredAcpHelp } from "./help.ts";
|
|
94
95
|
import { renderTailEvent } from "./tail-render.ts";
|
|
95
96
|
import { archiveProblem, archiveState, damagedState, failureText, MANIFEST, planReset, resetLines, resetRuntime, startState, type ResetPlan } from "./reset.ts";
|
|
@@ -274,13 +275,15 @@ function printOutput(lines: Span[][], color: boolean): void {
|
|
|
274
275
|
}
|
|
275
276
|
const unattendedEnv = process.env.AGENTHUB_UNATTENDED === "1";
|
|
276
277
|
const lifecycle = { inspectProject, startProject, stopProject };
|
|
277
|
-
const connect = async () => {
|
|
278
|
+
const connect = async (timeoutMs = 3000) => {
|
|
278
279
|
assertDelegatedAuditConsumer();
|
|
279
280
|
if (delegatedAuditHub && !delegatedConnectionTaken) { delegatedConnectionTaken = true; return delegatedAuditHub; }
|
|
280
281
|
return ControlClient.connect(stateDir, identity.role === "tools" ? { role: "tools", peer: identity.peer, projectRoot: cwd }
|
|
281
|
-
: { role: "console", projectRoot: cwd, ...(delegatedAuditHub ? { instanceId: delegatedAuditHub.instanceId } : {}) });
|
|
282
|
+
: { role: "console", projectRoot: cwd, ...(delegatedAuditHub ? { instanceId: delegatedAuditHub.instanceId } : {}) }, timeoutMs);
|
|
282
283
|
};
|
|
283
284
|
|
|
285
|
+
const connectLaunch = () => retryLaunchConnect(connect, () => console.error("hub busy, retrying"));
|
|
286
|
+
|
|
284
287
|
function registeredProject(): Project {
|
|
285
288
|
const registry = new Registry();
|
|
286
289
|
try { return registry.register(cwd, stateDir); }
|
|
@@ -943,7 +946,7 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
943
946
|
// Preserve the original guard before native effects; previews remain read-only and precede this guard.
|
|
944
947
|
if (entry.lifecycleAdmission && !args.includes("--print-command") && !args.includes("--dry-run")) assertLifecycleAvailable();
|
|
945
948
|
const nativeConnect = async () => {
|
|
946
|
-
const hub = await
|
|
949
|
+
const hub = await connectLaunch();
|
|
947
950
|
try { if (selectedInstance) { requirePeerInstances(hub.features); requireActiveInstance((await hub.request({ t: "status" })).status, selectedInstance); } return hub; }
|
|
948
951
|
catch (error) { hub.close(); throw error; }
|
|
949
952
|
};
|
|
@@ -955,7 +958,10 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
955
958
|
projectConfig, connect: nativeConnect, fail,
|
|
956
959
|
exec: (bin, argv, tool) => exec(bin, argv, tool, context.executionCwd),
|
|
957
960
|
execWithEnv: (bin, argv, env) => execWithEnv(bin, argv, env, selectedInstance ?? entry.command, context.executionCwd), takeFlags,
|
|
958
|
-
helpers: {
|
|
961
|
+
helpers: { resolveExecutionCwd: peer => {
|
|
962
|
+
const current = readInstanceRoleConfig(cwd);
|
|
963
|
+
return resolveInstanceExecutionCwd(cwd, peer, current.instances, effectiveInstanceRoles(peer, current.instances, current.roles, current.catalog), current.catalog);
|
|
964
|
+
}, launcherPreview: (tool, raw, projectRoot, state, unattended) => launcherPreview(tool, raw, projectRoot, state, unattended, selectedInstance), readControl, recordTerminalLaunch, prepareTerminalLaunch, registerTerminalLaunch, recoveryLock, processSignature,
|
|
959
965
|
buildLaunch, claudeObservationHooks, recordClaudeLaunch, cleanupStaleClaudeSettings, cleanupClaudeSettings,
|
|
960
966
|
nativeLaunchEnv, runClaudeLaunch, childEnv, qwenSessionId, configureJcodeMcp, configureKimiMcp,
|
|
961
967
|
buildKimiTuiLaunch, startModeOf },
|
|
@@ -1141,7 +1147,15 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
1141
1147
|
|
|
1142
1148
|
backlog: async () => {
|
|
1143
1149
|
const [sub, id, revision, value, ...reason] = args;
|
|
1144
|
-
if (sub === "list" &&
|
|
1150
|
+
if (sub === "list" && outputArguments().length === 1) {
|
|
1151
|
+
// #399: a table by default; --json prints the hub's listing unchanged, and a refusal is printed as it came.
|
|
1152
|
+
const options = outputOptions(), raw = await taskOp("hub_backlog_list", {});
|
|
1153
|
+
if (options.json) return console.log(raw);
|
|
1154
|
+
let cards: unknown;
|
|
1155
|
+
try { cards = JSON.parse(raw); } catch { return console.log(raw); }
|
|
1156
|
+
if (!Array.isArray(cards)) return console.log(raw);
|
|
1157
|
+
return printOutput(renderBacklog(cards, options.columns, Date.now(), options.full), options.color);
|
|
1158
|
+
}
|
|
1145
1159
|
if (sub === "groom" && args.length === 1) return console.log(await taskOp("hub_planner_groom", {}));
|
|
1146
1160
|
if (["return", "return-untouched"].includes(sub ?? "") && /^[1-9]\d*$/.test(id ?? "") && /^\d+$/.test(revision ?? "") && args.length >= 4) {
|
|
1147
1161
|
return console.log(await taskOp("hub_backlog_return_untouched", { id: Number(id), revision: Number(revision), reason: args.slice(3).join(" ") }));
|
|
@@ -1509,7 +1523,31 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
1509
1523
|
} finally { hub.close(); }
|
|
1510
1524
|
},
|
|
1511
1525
|
settings: async () => {
|
|
1512
|
-
|
|
1526
|
+
// #689: structural config edits (instances, review, snapshots, approvals, context.gate, sdlc.toml) and their backups. The hub validates and writes.
|
|
1527
|
+
if (args[0] === "edit" || args[0] === "backups") {
|
|
1528
|
+
// A command line the parser refuses never reaches the hub, not even as a connection.
|
|
1529
|
+
const early = args[0] === "backups" ? (args.length > 2 || (args[1] !== undefined && args[1] !== "--json") ? SETTINGS_EDIT_USAGE : undefined) : (() => { const parsed = parseSettingsEditArgs(args.slice(1)); return typeof parsed === "string" ? parsed : undefined; })();
|
|
1530
|
+
if (early) fail(early);
|
|
1531
|
+
const hub = await connect();
|
|
1532
|
+
try {
|
|
1533
|
+
const rl = process.stdin.isTTY ? createInterface({ input: process.stdin, output: process.stdout }) : undefined;
|
|
1534
|
+
const usage = await runSettingsEdit(args, {
|
|
1535
|
+
request: async message => {
|
|
1536
|
+
const reply = await hub.request(message, 35_000);
|
|
1537
|
+
if (reply.ok !== true) {
|
|
1538
|
+
const error = String(reply.error ?? "config edit refused");
|
|
1539
|
+
fail(/unknown (?:control )?(?:message|request|command)(?:\b|:)|this hub does not know "config_edit/i.test(error) ? `${error}; upgrade and restart the running hub to use ahub settings edit` : error);
|
|
1540
|
+
}
|
|
1541
|
+
return reply;
|
|
1542
|
+
},
|
|
1543
|
+
ask: async prompt => { if (!rl) return null; try { return await rl.question(prompt); } catch { return null; } },
|
|
1544
|
+
print: line => console.log(terminalText(line)),
|
|
1545
|
+
}).finally(() => rl?.close());
|
|
1546
|
+
if (usage) fail(usage);
|
|
1547
|
+
} finally { hub.close(); }
|
|
1548
|
+
return;
|
|
1549
|
+
}
|
|
1550
|
+
const usage = "usage: ahub settings [list] [--json] | get <key> [--json] | set <key> <value|inherit> [--yes] [--preview] | undo | reload | edit <file> ... | backups";
|
|
1513
1551
|
const flags = args.filter((arg) => arg.startsWith("--")), [sub = "list", key, text, ...extra] = args.filter((arg) => !arg.startsWith("--"));
|
|
1514
1552
|
const allowed: Record<string, string[]> = { list: ["--json"], get: ["--json"], set: ["--yes", "--preview", "--now"], undo: [], reload: [] };
|
|
1515
1553
|
if (!Object.hasOwn(allowed, sub) || flags.some((flag) => !allowed[sub]!.includes(flag)) || extra.length) fail(usage);
|
|
@@ -2254,7 +2292,7 @@ async function runConductorCommand(): Promise<void> {
|
|
|
2254
2292
|
const runConfigured = configuredCommand ? async () => {
|
|
2255
2293
|
const selection = parseConfiguredAcpArguments(cmd, args, projectCatalog, declaredInstances);
|
|
2256
2294
|
if (!selection.preview) assertLifecycleAvailable();
|
|
2257
|
-
await runConfiguredAcpEntry({ kind: cmd, raw: args, catalog: projectCatalog, instances: declaredInstances, connect, print: text => console.log(text), operationId: process.env.AGENTHUB_RECOVERY_OPERATION });
|
|
2295
|
+
await runConfiguredAcpEntry({ kind: cmd, raw: args, catalog: projectCatalog, instances: declaredInstances, connect: connectLaunch, print: text => console.log(text), operationId: process.env.AGENTHUB_RECOVERY_OPERATION });
|
|
2258
2296
|
} : undefined;
|
|
2259
2297
|
const run = identity.role === "tools" && commandAccess === "conductor" ? runConductorCommand : (Object.hasOwn(commands, cmd) ? commands[cmd] : undefined) ?? runConfigured ?? (() => fail(`unknown command "${cmd}"; run ahub help`));
|
|
2260
2298
|
try {
|
package/src/cli/output.ts
CHANGED
|
@@ -249,6 +249,7 @@ export function renderStatus(data: StatusOutput, columns?: number, now = Date.no
|
|
|
249
249
|
const actionPolicy = data.actionPolicyInfo?.[id];
|
|
250
250
|
if (actionPolicy?.note) out.push(...detail(`${id} action policy`, actionPolicy.note, columns, actionPolicy.enforceable ? "muted" : "attention"));
|
|
251
251
|
if (typeof p.reviewerUnavailable === "string" && p.reviewerUnavailable) out.push(...detail("reviewer", flat(p.reviewerUnavailable), columns, "failure")); // #722: the cause class, never a path
|
|
252
|
+
if (p.ownerIsolationNote) out.push(...detail("owner isolation", p.ownerIsolationNote, columns, "attention"));
|
|
252
253
|
if (p.permissionNote) out.push(...detail("permission", p.permissionNote, columns, "muted"));
|
|
253
254
|
if (typeof p.statusNotice?.message === "string" && p.statusNotice.message) out.push(...detail("status notice", p.statusNotice.message, columns, "attention"));
|
|
254
255
|
if (p.requestedModel && p.servedBy && p.requestedModel !== p.servedBy) out.push(...detail("requested model", p.requestedModel, columns));
|
|
@@ -279,6 +280,33 @@ export function stageCode(next: string): string {
|
|
|
279
280
|
}
|
|
280
281
|
const BOARD_LEGEND = "STAGE: [#---] SDLC progress, stage: next step. issue? no issue, PR? no PR, CI? checks, review? not reviewed, threads? open threads. Full advice: ahub board --full.";
|
|
281
282
|
const ellipsis = (text: string, room: number) => width(text) <= room ? text : room <= 3 ? ".".repeat(Math.max(0, room)) : (() => { let out = ""; for (const unit of characters(text)) { if (width(out + unit) > room - 3) break; out += unit; } return out + "..."; })();
|
|
283
|
+
/**
|
|
284
|
+
* #399: `ahub backlog list` as a table: one line per card, by rank (unranked last) then id. With a width, TITLE goes last and is
|
|
285
|
+
* cut like the compact board, and AGE then CLASS give way first on a narrow terminal; without one (piped) or with full, titles are whole.
|
|
286
|
+
*/
|
|
287
|
+
export function renderBacklog(cards: any[], columns?: number, now = Date.now(), full = false): Span[][] {
|
|
288
|
+
if (!cards.length) return [[span("no backlog cards")]];
|
|
289
|
+
const sorted = [...cards].sort((a, b) => (typeof a.rank === "number" ? a.rank : Infinity) - (typeof b.rank === "number" ? b.rank : Infinity) || a.id - b.id);
|
|
290
|
+
let names = ["RANK", "ID", "PRI", "RESERVED", "AFTER", "CLASS", "AGE", "TITLE"];
|
|
291
|
+
let table: OutputCell[][] = sorted.map(card => {
|
|
292
|
+
const deps: number[] = Array.isArray(card.deps) ? card.deps : [];
|
|
293
|
+
return [span(typeof card.rank === "number" ? card.rank : "-", "muted", true), span(`#${card.id}`, "info", true), span(typeof card.priority === "number" ? `P${card.priority}` : "-", undefined, true),
|
|
294
|
+
span(card.reserved ?? "-", peerTone(card.reserved ?? ""), true), span(deps.length ? (full || columns === undefined ? deps.map(id => `#${id}`).join(",") : `#${deps[0]}${deps.length > 1 ? `,+${deps.length - 1}` : ""}`) : "-", undefined, true), span(card.class ?? "-", undefined, true),
|
|
295
|
+
span(typeof card.created === "number" ? duration(now - card.created) : "-", "muted", true), span(flat(card.title))];
|
|
296
|
+
});
|
|
297
|
+
if (!full && columns !== undefined) {
|
|
298
|
+
const drop = (name: string) => { const at = names.indexOf(name); if (at < 0) return; names = names.filter((_, i) => i !== at); table = table.map(row => row.filter((_, i) => i !== at)); };
|
|
299
|
+
const room = () => columns - names.reduce((sum, name, i) => name === "TITLE" ? sum : sum + Math.max(width(name), ...table.map(row => width(flat(row[i]!.text)))), 0) - 2 * (names.length - 1);
|
|
300
|
+
const want = Math.min(30, Math.max(...table.map(row => width(row[row.length - 1]!.text))));
|
|
301
|
+
for (const name of ["AGE", "CLASS", "PRI"]) if (room() < want) drop(name);
|
|
302
|
+
const titleRoom = Math.max(5, room());
|
|
303
|
+
table = table.map(row => [...row.slice(0, -1), { ...row[row.length - 1]!, text: ellipsis(row[row.length - 1]!.text, titleRoom) }]);
|
|
304
|
+
}
|
|
305
|
+
const owners = new Map<string, number>();
|
|
306
|
+
for (const card of sorted) owners.set(card.reserved ?? "unreserved", (owners.get(card.reserved ?? "unreserved") ?? 0) + 1);
|
|
307
|
+
const summary = `${plural(sorted.length, "backlog card")}; reserved: ${[...owners].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).map(([owner, n]) => `${owner} ${n}`).join(", ")}`;
|
|
308
|
+
return [...rows(names, table, columns), [], ...lines(summary, columns)];
|
|
309
|
+
}
|
|
282
310
|
/** #724: one line per task. TITLE goes last and is cut to the room left; at a narrow width REVIEWER, CLASS and AGE give way first. */
|
|
283
311
|
function compactBoard(tasks: any[], data: OutputCell[][], columns: number, staged: boolean): Span[][] {
|
|
284
312
|
const head = ["ID", "STATE", "OWNER", "REVIEWER", "CLASS", "AGE", "AFTER", ...(staged ? ["STAGE"] : []), "TITLE"];
|
package/src/cli/reset.ts
CHANGED
|
@@ -11,7 +11,7 @@ import { HUB_DB_BUSY_TIMEOUT_MS } from "../hub/db-timeout.ts";
|
|
|
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
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)) : [])];
|
|
14
|
+
const sessionPointers = (stateDir: string): string[] => [...SESSION_POINTERS, ...(existsSync(stateDir) ? readdirSync(stateDir).filter(name => /^claude-(session|context|task-sessions)\.[a-z][a-z0-9-]{0,31}\.json$/.test(name)) : [])];
|
|
15
15
|
/** What a running daemon publishes; a daemon that did not stop cleanly (crash, SIGKILL, forced exit) leaves it. */
|
|
16
16
|
export const MANIFEST = ["status.json", "control-token", "hub.pid"];
|
|
17
17
|
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/** #689: `ahub settings edit` and `ahub settings backups`. Parsing and rendering only; the hub validates, previews and writes. */
|
|
2
|
+
export interface SettingsEditArgs { file: "config.local.json" | "sdlc.toml"; set: { path: string[]; value: unknown }[]; reason?: string; preview: boolean; yes: boolean }
|
|
3
|
+
export const SETTINGS_EDIT_USAGE = "usage: ahub settings edit <config.local.json|sdlc.toml> (set <path> <json> | remove <path>)... [--reason <text>] [--preview] [--yes] | ahub settings backups [--json]";
|
|
4
|
+
|
|
5
|
+
/** The request a command line asks for, or the usage line. Values are JSON, so a string needs its quotes. */
|
|
6
|
+
export function parseSettingsEditArgs(argv: readonly string[]): SettingsEditArgs | string {
|
|
7
|
+
const [file, ...rest] = argv;
|
|
8
|
+
if (file !== "config.local.json" && file !== "sdlc.toml") return SETTINGS_EDIT_USAGE;
|
|
9
|
+
const set: SettingsEditArgs["set"] = [];
|
|
10
|
+
let reason: string | undefined, preview = false, yes = false;
|
|
11
|
+
const path = (text: string | undefined): string[] | undefined => {
|
|
12
|
+
if (!text || text.startsWith(".") || text.endsWith(".") || text.includes("..")) return undefined;
|
|
13
|
+
return text.split(".");
|
|
14
|
+
};
|
|
15
|
+
for (let index = 0; index < rest.length; index++) {
|
|
16
|
+
const word = rest[index]!;
|
|
17
|
+
if (word === "--preview") preview = true;
|
|
18
|
+
else if (word === "--yes") yes = true;
|
|
19
|
+
else if (word === "--reason") { reason = rest[++index]; if (reason === undefined) return SETTINGS_EDIT_USAGE; }
|
|
20
|
+
else if (word === "set") {
|
|
21
|
+
const target = path(rest[++index]), text = rest[++index];
|
|
22
|
+
if (!target || text === undefined) return SETTINGS_EDIT_USAGE;
|
|
23
|
+
try { set.push({ path: target, value: JSON.parse(text) }); } catch { return SETTINGS_EDIT_USAGE; }
|
|
24
|
+
} else if (word === "remove") {
|
|
25
|
+
const target = path(rest[++index]);
|
|
26
|
+
if (!target) return SETTINGS_EDIT_USAGE;
|
|
27
|
+
set.push({ path: target, value: null });
|
|
28
|
+
} else return SETTINGS_EDIT_USAGE;
|
|
29
|
+
}
|
|
30
|
+
if (!set.length) return SETTINGS_EDIT_USAGE;
|
|
31
|
+
return { file, set, ...(reason !== undefined ? { reason } : {}), preview, yes };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** What a person reads before confirming: the diff lines, the warning of a tier 3 key and the word to type. */
|
|
35
|
+
export function renderEditPreview(reply: { ok?: unknown; lines?: unknown; warning?: unknown; confirm?: unknown }): string[] {
|
|
36
|
+
const lines = Array.isArray(reply.lines) ? reply.lines.map(line => String(line)) : [];
|
|
37
|
+
if (typeof reply.warning === "string") lines.push(`warning: ${reply.warning}`);
|
|
38
|
+
if (typeof reply.confirm === "string") lines.push(`type ${reply.confirm} to confirm`);
|
|
39
|
+
return lines;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface SettingsEditPorts {
|
|
43
|
+
request: (message: Record<string, unknown>) => Promise<Record<string, unknown>>;
|
|
44
|
+
/** Asks one line on a terminal; null when there is none. */
|
|
45
|
+
ask: (prompt: string) => Promise<string | null>;
|
|
46
|
+
print: (line: string) => void;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* The whole command, with its ports injected. The person's terminal is the only place a tier 3 key can be changed, and only by
|
|
50
|
+
* typing the key's name: `--yes` confirms an ordinary structural edit and never a tier 3 one.
|
|
51
|
+
*/
|
|
52
|
+
export async function runSettingsEdit(argv: readonly string[], ports: SettingsEditPorts): Promise<string | undefined> {
|
|
53
|
+
const { request, ask, print } = ports;
|
|
54
|
+
if (argv[0] === "backups") {
|
|
55
|
+
if (argv.length > 2 || (argv[1] !== undefined && argv[1] !== "--json")) return SETTINGS_EDIT_USAGE;
|
|
56
|
+
const view = await request({ t: "config_edit_get" });
|
|
57
|
+
const backups = Array.isArray(view.backups) ? view.backups as { name: string; file: string; at: number; bytes: number }[] : [];
|
|
58
|
+
if (argv[1] === "--json") print(JSON.stringify(backups, null, 2));
|
|
59
|
+
else if (!backups.length) print("no config backups yet");
|
|
60
|
+
else for (const item of backups) print(`${new Date(item.at).toISOString()} ${item.file} ${item.bytes} bytes ${item.name}`);
|
|
61
|
+
return undefined;
|
|
62
|
+
}
|
|
63
|
+
const parsed = parseSettingsEditArgs(argv[0] === "edit" ? argv.slice(1) : argv);
|
|
64
|
+
if (typeof parsed === "string") return parsed;
|
|
65
|
+
const view = await request({ t: "config_edit_get" });
|
|
66
|
+
const revisions = view.revisions as Record<string, string> | undefined;
|
|
67
|
+
const revision = revisions?.[parsed.file];
|
|
68
|
+
if (typeof revision !== "string") return "the running hub does not report config revisions: upgrade and restart it to use ahub settings edit";
|
|
69
|
+
const body = { file: parsed.file, revision, set: parsed.set };
|
|
70
|
+
const preview = await request({ t: "config_edit_preview", edit: body });
|
|
71
|
+
for (const line of renderEditPreview(preview)) print(line);
|
|
72
|
+
if (parsed.preview) { print("preview only; nothing was changed"); return undefined; }
|
|
73
|
+
const typed = typeof preview.confirm === "string" ? preview.confirm : undefined;
|
|
74
|
+
let confirm: string | undefined;
|
|
75
|
+
if (typed) {
|
|
76
|
+
const answer = await ask(`Type ${typed} to confirm: `);
|
|
77
|
+
if (answer === null) return "not a terminal: a key that runs code or locates secrets is confirmed by typing its name; nothing was changed";
|
|
78
|
+
if (answer.trim() !== typed) { print("nothing was changed"); return undefined; }
|
|
79
|
+
confirm = typed;
|
|
80
|
+
} else if (!parsed.yes) {
|
|
81
|
+
const answer = await ask("Write this change? [y/N] ");
|
|
82
|
+
if (answer === null) return "not a terminal: nothing was changed; rerun with --yes after reviewing the preview";
|
|
83
|
+
if (!/^(y|yes)$/i.test(answer.trim())) { print("nothing was changed"); return undefined; }
|
|
84
|
+
}
|
|
85
|
+
const result = await request({ t: "config_edit_apply", edit: body, ...(confirm ? { confirm } : {}), ...(parsed.reason !== undefined ? { reason: parsed.reason } : {}) });
|
|
86
|
+
print(String(result.text ?? "config written"));
|
|
87
|
+
if (Array.isArray(result.restartPending) && result.restartPending.length) print(`restart pending: ${result.restartPending.join(", ")}; restart the hub (ahub restart) to apply`);
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
@@ -328,7 +328,7 @@ export function recordPath(stateDir: string): string {
|
|
|
328
328
|
return join(stateDir, RECORD_FILE);
|
|
329
329
|
}
|
|
330
330
|
|
|
331
|
-
/**
|
|
331
|
+
/** Resolve declared terminal roots without treating a launch candidate as daemon ownership proof. */
|
|
332
332
|
function executionRoot(peer: string, projectRoot: string, captured?: string): string {
|
|
333
333
|
const builtin = definitionOf(peer);
|
|
334
334
|
if (builtin?.session.launchRecord) return projectRoot;
|
|
@@ -336,11 +336,6 @@ function executionRoot(peer: string, projectRoot: string, captured?: string): st
|
|
|
336
336
|
const definition = catalog.definitionOf(peer, declarations);
|
|
337
337
|
if (!definition?.session.launchRecord) throw new Error(`${peer}: no managed terminal recovery adapter`);
|
|
338
338
|
if (!declarations?.[peer] || definition.transport !== "codex-appserver" && definition.transport !== "claude-channel") throw new Error(`${peer}: extra instance has no managed terminal support`);
|
|
339
|
-
// #450 I3: a Claude instance reviews or plans in the canonical checkout; it never owns an execution worktree.
|
|
340
|
-
if (definition.transport === "claude-channel") {
|
|
341
|
-
if (captured !== undefined && captured !== projectRoot) throw new Error(`${peer}: recorded execution root changed; manual recovery required`);
|
|
342
|
-
return projectRoot;
|
|
343
|
-
}
|
|
344
339
|
const expected = resolveInstanceExecutionCwd(projectRoot, peer, declarations, effectiveInstanceRoles(peer, declarations, roles, catalog), catalog);
|
|
345
340
|
if (captured !== undefined && captured !== expected) throw new Error(`${peer}: declared execution worktree changed; manual recovery required`);
|
|
346
341
|
return expected;
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { ACTION_CLASSES, classifyAction, type ActionClass } from "./action-class.ts";
|
|
2
|
+
import { actionApprovalText, actionPolicyConfig, type ActionBudgets, type ActionPolicyConfig } from "./action-policy.ts";
|
|
3
|
+
import type { ActionGateVerdict } from "../adapters/acp.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Where the always-ask policy (#636) meets the two surfaces that see a tool call before it runs: the Claude PreToolUse hook and ACP
|
|
7
|
+
* `session/request_permission`. The classifier (`action-class.ts`) and the budget ledger (`action-policy.ts`) decide; this file only
|
|
8
|
+
* connects them, and only ever adds an approval: an empty result keeps the native permission policy.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** One native tool call as an adapter saw it: every name it is known by, the ACP tool kind when reported, and its arguments. */
|
|
12
|
+
export interface ActionCall { names: readonly string[]; kind?: string; input: unknown }
|
|
13
|
+
|
|
14
|
+
/** The classes of one call. ACP names a shell call `execute` by kind whatever its tool is called, so that kind is also read as a shell. */
|
|
15
|
+
export function classifyCall(call: ActionCall, root: string): ActionClass[] {
|
|
16
|
+
const names = new Set(call.names.filter(name => typeof name === "string" && name.length > 0));
|
|
17
|
+
if (call.kind === "execute") names.add("bash");
|
|
18
|
+
const found = new Set<ActionClass>();
|
|
19
|
+
for (const name of names) for (const key of classifyAction(name, call.input, root)) found.add(key);
|
|
20
|
+
return ACTION_CLASSES.filter(key => found.has(key));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The classes the Claude PreToolUse hook must `ask` about, in every permission mode. The budget never relaxes it: Claude's own dialog
|
|
25
|
+
* cannot carry a class-budget choice, so every classed Claude call asks (a count of zero approved calls, always). A handle-less request
|
|
26
|
+
* selects the classes without touching any session ledger.
|
|
27
|
+
*/
|
|
28
|
+
export function claudeActionClasses(budgets: ActionBudgets, tool: string, input: unknown, root: string): ActionClass[] {
|
|
29
|
+
const classes = classifyAction(tool, input, root);
|
|
30
|
+
if (!classes.length) return [];
|
|
31
|
+
const decision = budgets.request(undefined, classes);
|
|
32
|
+
return decision.kind === "ask" ? decision.classes : [];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The command line an approval shows: the shell command when there is one, else the arguments. Bounded by the caller's screen. */
|
|
36
|
+
export function commandText(input: unknown): string {
|
|
37
|
+
let value: unknown = input;
|
|
38
|
+
if (typeof input === "string") { try { value = JSON.parse(input) as unknown; } catch { return input; } }
|
|
39
|
+
if (typeof value === "string") return value;
|
|
40
|
+
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
41
|
+
const record = value as Record<string, unknown>, command = record.command ?? record.cmd;
|
|
42
|
+
if (typeof command === "string") return command;
|
|
43
|
+
if (Array.isArray(command) && command.every(word => typeof word === "string")) return (command as string[]).join(" ");
|
|
44
|
+
if (Array.isArray(record.args) && record.args.every(word => typeof word === "string")) return ["git", ...(record.args as string[])].join(" ");
|
|
45
|
+
}
|
|
46
|
+
try { return JSON.stringify(value) ?? ""; } catch { return ""; }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface ActionGatePorts<T> {
|
|
50
|
+
budgets: ActionBudgets;
|
|
51
|
+
policy(): ActionPolicyConfig | undefined;
|
|
52
|
+
/** The peer's execution root: a delete outside it is classed. */
|
|
53
|
+
root(peer: string): string;
|
|
54
|
+
/** The task the peer is working on, for the approval line. */
|
|
55
|
+
task(peer: string): T | undefined;
|
|
56
|
+
/** The task's public view (`Tasks.publicView`): the only way a task title reaches an approval. */
|
|
57
|
+
publicView(task: T): Record<string, unknown>;
|
|
58
|
+
/** A private turn or an open PII task: the command is withheld. */
|
|
59
|
+
isPrivate(peer: string): boolean;
|
|
60
|
+
/** The PII screen for text (`Tasks.nameable`). */
|
|
61
|
+
nameable(text: string): boolean;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The ACP gate of one peer. It returns a verdict only when a person must be asked: a classed call with no session budget left. A call
|
|
66
|
+
* within an approved class budget, and every unclassed call, return undefined and follow the permission mode as before (the budget
|
|
67
|
+
* spends here, once per request).
|
|
68
|
+
*/
|
|
69
|
+
export function actionGate<T>(ports: ActionGatePorts<T>, peer: string): (call: ActionCall, session: string) => ActionGateVerdict | undefined {
|
|
70
|
+
return (call, session) => {
|
|
71
|
+
const classes = classifyCall(call, ports.root(peer));
|
|
72
|
+
if (!classes.length) return undefined;
|
|
73
|
+
const decision = ports.budgets.request(ports.budgets.bindSession(peer, session), classes);
|
|
74
|
+
if (decision.kind !== "ask") return undefined; // native, or within an approved class budget
|
|
75
|
+
const title = actionApprovalText(decision.classes, commandText(call.input), ports.task(peer), {
|
|
76
|
+
publicView: task => ports.publicView(task), private: ports.isPrivate(peer), commandView: command => ports.nameable(command) ? command : undefined });
|
|
77
|
+
const challenge = decision.challenge, limits = actionPolicyConfig(ports.policy()).budgets;
|
|
78
|
+
// A budget of one is this call alone: the ordinary allow already says that, so no second choice is offered for it.
|
|
79
|
+
if (!challenge || !decision.classes.some(key => limits[key] > 1)) return { title };
|
|
80
|
+
return { title, budget: { label: decision.classes.map(key => `${key} x${limits[key]}`).join(", "), approve: () => ports.budgets.approve(challenge) } };
|
|
81
|
+
};
|
|
82
|
+
}
|
package/src/hub/action-policy.ts
CHANGED
|
@@ -31,24 +31,25 @@ export function actionPolicyConfig(value?: unknown): ActionPolicyConfig {
|
|
|
31
31
|
export interface ActionSessionBudget { readonly peer: string; readonly session: string }
|
|
32
32
|
export interface ActionChallenge { readonly classes: readonly ActionClass[] }
|
|
33
33
|
export type ActionDecision = { kind: "native" } | { kind: "ask"; classes: ActionClass[]; challenge?: ActionChallenge } | { kind: "budget"; classes: ActionClass[] };
|
|
34
|
-
|
|
34
|
+
/** `grants` counts the class-budget approvals of this session and policy: a challenge remembers it, so a later approval can tell that another one landed first. */
|
|
35
|
+
interface Session { handle: ActionSessionBudget; policy: string; remaining: Record<ActionClass, number>; grants: Record<ActionClass, number> }
|
|
35
36
|
const empty = (): Record<ActionClass, number> => ({ publish: 0, destroy: 0, "external-write": 0, install: 0 });
|
|
36
37
|
/** Host-only ledger: session ids and policy come from current runtime ports, never a tool argument. */
|
|
37
38
|
export class ActionBudgets {
|
|
38
39
|
private sessions = new Map<string, Session>();
|
|
39
|
-
private challenges = new WeakMap<ActionChallenge, { handle: ActionSessionBudget; policy: string; classes: ActionClass[] }>();
|
|
40
|
+
private challenges = new WeakMap<ActionChallenge, { handle: ActionSessionBudget; policy: string; classes: ActionClass[]; grants: Record<ActionClass, number> }>();
|
|
40
41
|
constructor(private readonly config: () => ActionPolicyConfig | undefined, private readonly currentSession: (peer: string) => string | undefined) {}
|
|
41
42
|
bindSession(peer: string, session: string): ActionSessionBudget | undefined {
|
|
42
43
|
if (!session || this.currentSession(peer) !== session) return undefined;
|
|
43
44
|
const previous = this.sessions.get(peer);
|
|
44
45
|
if (previous?.handle.session === session) return previous.handle;
|
|
45
46
|
const handle = Object.freeze({ peer, session });
|
|
46
|
-
this.sessions.set(peer, { handle, policy: "", remaining: empty() });
|
|
47
|
+
this.sessions.set(peer, { handle, policy: "", remaining: empty(), grants: empty() });
|
|
47
48
|
return handle;
|
|
48
49
|
}
|
|
49
50
|
revoke(peer: string): void { this.sessions.delete(peer); }
|
|
50
51
|
policyChanged(): void {
|
|
51
|
-
for (const state of this.sessions.values()) { state.policy = ""; state.remaining = empty(); }
|
|
52
|
+
for (const state of this.sessions.values()) { state.policy = ""; state.remaining = empty(); state.grants = empty(); }
|
|
52
53
|
this.challenges = new WeakMap();
|
|
53
54
|
}
|
|
54
55
|
private current(handle: ActionSessionBudget | undefined): Session | undefined {
|
|
@@ -56,7 +57,7 @@ export class ActionBudgets {
|
|
|
56
57
|
const state = this.sessions.get(handle.peer);
|
|
57
58
|
if (state?.handle !== handle || this.currentSession(handle.peer) !== handle.session) return undefined;
|
|
58
59
|
const policy = JSON.stringify(actionPolicyConfig(this.config()));
|
|
59
|
-
if (state.policy !== policy) { state.policy = policy; state.remaining = empty(); }
|
|
60
|
+
if (state.policy !== policy) { state.policy = policy; state.remaining = empty(); state.grants = empty(); }
|
|
60
61
|
return state;
|
|
61
62
|
}
|
|
62
63
|
private selected(actions: readonly ActionClass[]): ActionClass[] {
|
|
@@ -70,28 +71,39 @@ export class ActionBudgets {
|
|
|
70
71
|
if (!state) return { kind: "ask", classes };
|
|
71
72
|
if (classes.some(key => state.remaining[key] <= 0)) {
|
|
72
73
|
const challenge = Object.freeze({ classes: Object.freeze([...classes]) });
|
|
73
|
-
this.challenges.set(challenge, { handle: state.handle, policy: state.policy, classes: [...classes] });
|
|
74
|
+
this.challenges.set(challenge, { handle: state.handle, policy: state.policy, classes: [...classes], grants: { ...state.grants } });
|
|
74
75
|
return { kind: "ask", classes, challenge };
|
|
75
76
|
}
|
|
76
77
|
for (const key of classes) state.remaining[key]--;
|
|
77
78
|
return { kind: "budget", classes };
|
|
78
79
|
}
|
|
79
|
-
/**
|
|
80
|
+
/**
|
|
81
|
+
* Explicit class-budget approval only, never an ordinary allow_once; this invocation consumes slot one. Requests run concurrently, so
|
|
82
|
+
* two challenges can be issued while a class is spent: the first approval opens the allowance (budget minus this call), and an approval of
|
|
83
|
+
* a challenge issued before it is a later call inside that allowance, which consumes one slot instead of opening another. With no slot
|
|
84
|
+
* left it is refused (the person asks again and sees a fresh challenge), so N approved calls is the most any budget lets through.
|
|
85
|
+
* Every class is checked before any is spent: a refusal changes nothing.
|
|
86
|
+
*/
|
|
80
87
|
approve(challenge: ActionChallenge | undefined): boolean {
|
|
81
88
|
if (!challenge) return false;
|
|
82
89
|
const grant = this.challenges.get(challenge); this.challenges.delete(challenge);
|
|
83
90
|
if (!grant) return false;
|
|
84
91
|
const state = this.current(grant.handle), config = actionPolicyConfig(this.config());
|
|
85
92
|
if (!state || state.policy !== grant.policy) return false;
|
|
86
|
-
|
|
93
|
+
const late = (key: ActionClass) => state.grants[key] !== grant.grants[key];
|
|
94
|
+
if (grant.classes.some(key => late(key) && state.remaining[key] <= 0)) return false;
|
|
95
|
+
for (const key of grant.classes) {
|
|
96
|
+
state.remaining[key] = late(key) ? state.remaining[key] - 1 : Math.max(0, config.budgets[key] - 1);
|
|
97
|
+
state.grants[key]++;
|
|
98
|
+
}
|
|
87
99
|
return true;
|
|
88
100
|
}
|
|
89
101
|
/** Read-only projection: no session is created, rebound or reset by a status poll. */
|
|
90
|
-
status(peer: string, enforced: boolean) {
|
|
102
|
+
status(peer: string, enforced: boolean, reason = "adapter integration pending") {
|
|
91
103
|
const config = actionPolicyConfig(this.config()), state = this.sessions.get(peer);
|
|
92
104
|
const bound = !!state && this.currentSession(peer) === state.handle.session && state.policy === JSON.stringify(config);
|
|
93
105
|
return { enabled: config.enabled, alwaysAsk: [...config.always_ask], limits: { ...config.budgets }, remaining: bound ? { ...state.remaining } : empty(), sessionBound: bound,
|
|
94
|
-
enforceable: enforced, note: !enforced ? `always-ask not enforceable:
|
|
106
|
+
enforceable: enforced, note: !enforced ? `always-ask not enforceable: ${reason}${config.enabled ? "" : "; policy disabled"}` : config.enabled ? "always-ask active" : "always-ask disabled" };
|
|
95
107
|
}
|
|
96
108
|
}
|
|
97
109
|
|
package/src/hub/board.ts
CHANGED
|
@@ -9,6 +9,7 @@ import type { SweepRecord } from "./task-sweep.ts";
|
|
|
9
9
|
import { HUB_DB_BUSY_TIMEOUT_MS } from "./db-timeout.ts";
|
|
10
10
|
import type { SdlcReceipt } from "./sdlc.ts";
|
|
11
11
|
import { parseReviewFindings, type ReviewFindingSet } from "./review-findings.ts";
|
|
12
|
+
import { parseDeliverableRecords, type DeliverableRecord } from "./deliverables.ts";
|
|
12
13
|
import type { CostPlan, ReviewCost } from "./cost-governance.ts";
|
|
13
14
|
|
|
14
15
|
export const CLASSES = ["plan", "implement", "bulk_edit", "test", "review", "summarize", "triage"] as const;
|
|
@@ -66,6 +67,14 @@ export interface HistoryEntry {
|
|
|
66
67
|
reviewContext?: "fresh" | "continuing";
|
|
67
68
|
/** Structured review report bound to the reviewed artifact, stored in the existing history JSON. */
|
|
68
69
|
reviewFindings?: ReviewFindingSet;
|
|
70
|
+
/**
|
|
71
|
+
* #509a: the files a `done`, a lifecycle record or an approval bound, with the content hashes the hub computed. An approval carries the hashes
|
|
72
|
+
* it was given at review time, so a later edit reads as "deliverable changed since review". Full paths live here; every view goes through
|
|
73
|
+
* `publicDeliverables`.
|
|
74
|
+
*/
|
|
75
|
+
deliverables?: DeliverableRecord[];
|
|
76
|
+
/** #507b: the recorded acceptance criteria (S8 checklist ids) a verdict's unmet items named; derived by the hub from the checklist, never free text. */
|
|
77
|
+
unmetCriteria?: string[];
|
|
69
78
|
/** Host-authored plan content revision, separate from backlog planningRevision. */
|
|
70
79
|
planRevision?: { revision: number; fingerprint: string };
|
|
71
80
|
planObservation?: { repo: string; pr: number; head: string; revision: number; fingerprint: string };
|
|
@@ -77,6 +86,11 @@ export interface HistoryEntry {
|
|
|
77
86
|
reviewerFailure?: { peer: string; head?: string; cause: "start_failed" | "quota" | "no_verdict" };
|
|
78
87
|
planning?: { revision: number; fields: (keyof PlanningFields)[]; previous: PlanningFields; pull?: true; split?: { children: { id: number; revision: number; history: number }[] } };
|
|
79
88
|
}
|
|
89
|
+
/** The ids a stored verdict may carry (#507b): bounded criterion identifiers in the S8 checklist's own format, at most its 32, each once. */
|
|
90
|
+
function criterionIdList(value: unknown): string[] {
|
|
91
|
+
if (!Array.isArray(value) || value.length > 32 || value.some(id => typeof id !== "string" || !/^[A-Za-z][A-Za-z0-9-]{0,47}$/.test(id)) || new Set(value).size !== value.length) throw new Error("unmet criteria: expected up to 32 distinct criterion ids");
|
|
92
|
+
return [...value] as string[];
|
|
93
|
+
}
|
|
80
94
|
export type ReviewerHistory = Pick<HistoryEntry, "reviewerFamily" | "reviewerReason" | "reviewBlocked" | "reviewerFailure">;
|
|
81
95
|
function reviewerHistory(extra: ReviewerHistory): ReviewerHistory {
|
|
82
96
|
const id = (value: unknown) => typeof value === "string" && /^[a-z][a-z0-9-]{0,63}$/.test(value);
|
|
@@ -332,7 +346,7 @@ export class Board {
|
|
|
332
346
|
|
|
333
347
|
/** The only way a task changes. Validates the move, records who did what, returns the new row. */
|
|
334
348
|
/** A `plan` in the patch replaces the old one whole: a new plan is the owner's current intent, not an addition. */
|
|
335
|
-
update(id: number, by: PeerId, event: string, patch: Partial<Pick<Task, "state" | "owner" | "reviewer" | "reserved" | "refs" | "plan" | "rejections" | "signals" | "sdlc" | "role" | "cancelled" | "codeSymbols">>, note?: string, extra: Pick<HistoryEntry, "profile" | "sweep" | "reason" | "from" | "capacity" | "withheld" | "noWrites" | "reviewCost" | "reviewContext" | "reviewFindings" | "planObservation" | "planDeparture" | "planDepartureInvalidated" | keyof ReviewerHistory> = {}, expectedFlowRevision?: number, codeSymbolsAuthority?: typeof HOST_CODE_SYMBOLS): Task {
|
|
349
|
+
update(id: number, by: PeerId, event: string, patch: Partial<Pick<Task, "state" | "owner" | "reviewer" | "reserved" | "refs" | "plan" | "rejections" | "signals" | "sdlc" | "role" | "cancelled" | "codeSymbols">>, note?: string, extra: Pick<HistoryEntry, "profile" | "sweep" | "reason" | "from" | "capacity" | "withheld" | "noWrites" | "reviewCost" | "reviewContext" | "reviewFindings" | "unmetCriteria" | "deliverables" | "planObservation" | "planDeparture" | "planDepartureInvalidated" | keyof ReviewerHistory> = {}, expectedFlowRevision?: number, codeSymbolsAuthority?: typeof HOST_CODE_SYMBOLS): Task {
|
|
336
350
|
const task = this.get(id);
|
|
337
351
|
if (!task) throw new Error(`no task #${id}`);
|
|
338
352
|
if (expectedFlowRevision !== undefined && (!Number.isSafeInteger(expectedFlowRevision) || expectedFlowRevision < 0 || task.history.length !== expectedFlowRevision)) throw new Error("task flow revision changed");
|
|
@@ -366,6 +380,16 @@ export class Board {
|
|
|
366
380
|
if (head !== undefined && !/^[a-f0-9]{40}$/.test(head)) throw new Error("finding metadata requires a full reviewed head");
|
|
367
381
|
reviewFindings = { ...(head ? { head } : {}), items: parseReviewFindings(extra.reviewFindings.items, { person: by === "user", maxNits: 64 }) };
|
|
368
382
|
}
|
|
383
|
+
let deliverables: DeliverableRecord[] | undefined;
|
|
384
|
+
if (extra.deliverables !== undefined) {
|
|
385
|
+
if (!["done", "done (checking)", "lifecycle", "approved"].includes(event)) throw new Error("deliverable records require a done, a lifecycle record or an approval");
|
|
386
|
+
deliverables = parseDeliverableRecords(extra.deliverables);
|
|
387
|
+
}
|
|
388
|
+
let unmetCriteria: string[] | undefined;
|
|
389
|
+
if (extra.unmetCriteria !== undefined) {
|
|
390
|
+
if (!["approved", "changes_requested"].includes(event) || patch.state !== event) throw new Error("unmet criteria require a saved review verdict");
|
|
391
|
+
unmetCriteria = criterionIdList(extra.unmetCriteria);
|
|
392
|
+
}
|
|
369
393
|
if (extra.planDeparture && (by !== "user" || event !== "plan departure accepted")) throw new Error("plan departure acceptance is person-only");
|
|
370
394
|
const trackPlan = this.trackPlanRevisions?.() === true, hasDeparture = task.history.some(h => h.planDeparture);
|
|
371
395
|
const fingerprint = patch.plan && (trackPlan || hasDeparture) ? planFingerprint(next.plan ?? {}) : undefined;
|
|
@@ -374,7 +398,7 @@ export class Board {
|
|
|
374
398
|
// Disabling reporting must not let plan or repository refs edits/reverts resurrect prior consent.
|
|
375
399
|
const repositoryRefsChanged = patch.refs !== undefined && JSON.stringify([task.refs.repo, task.refs.paths]) !== JSON.stringify([next.refs.repo, next.refs.paths]);
|
|
376
400
|
const departureInvalidated = extra.planDepartureInvalidated === true || hasDeparture && (planChanged || repositoryRefsChanged);
|
|
377
|
-
const history = [...task.history, { at: Date.now(), by, event, ...(planRevision ? { planRevision } : {}), ...(extra.planObservation ? { planObservation: extra.planObservation } : {}), ...(extra.planDeparture ? { planDeparture: extra.planDeparture } : {}), ...(departureInvalidated ? { planDepartureInvalidated: true as const } : {}), ...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.capacity === true ? { capacity: true as const } : {}), ...(extra.withheld && (note || reviewFindings) ? { withheld: true as const } : {}), ...(reviewFindings ? { reviewFindings } : {}), ...(extra.reviewContext === "fresh" || extra.reviewContext === "continuing" ? { reviewContext: extra.reviewContext } : {}), ...(extra.reviewCost ? { reviewCost: extra.reviewCost } : {}), ...(event === "done" && extra.noWrites === true ? { noWrites: true as const } : {}) }];
|
|
401
|
+
const history = [...task.history, { at: Date.now(), by, event, ...(planRevision ? { planRevision } : {}), ...(extra.planObservation ? { planObservation: extra.planObservation } : {}), ...(extra.planDeparture ? { planDeparture: extra.planDeparture } : {}), ...(departureInvalidated ? { planDepartureInvalidated: true as const } : {}), ...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.capacity === true ? { capacity: true as const } : {}), ...(extra.withheld && (note || reviewFindings) ? { withheld: true as const } : {}), ...(reviewFindings ? { reviewFindings } : {}), ...(unmetCriteria?.length ? { unmetCriteria } : {}), ...(deliverables ? { deliverables } : {}), ...(extra.reviewContext === "fresh" || extra.reviewContext === "continuing" ? { reviewContext: extra.reviewContext } : {}), ...(extra.reviewCost ? { reviewCost: extra.reviewCost } : {}), ...(event === "done" && extra.noWrites === true ? { noWrites: true as const } : {}) }];
|
|
378
402
|
this.taskWrite(() => {
|
|
379
403
|
const result = this.db.query("UPDATE tasks SET state = ?, owner = ?, reviewer = ?, reserved = ?, refs = ?, plan = ?, rejections = ?, signals = ?, history = ?, sdlc = ?, task_role = ?, cancelled = ?, updated = ? WHERE id = ?")
|
|
380
404
|
.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);
|
package/src/hub/budget.ts
CHANGED
|
@@ -248,6 +248,11 @@ export class Budget {
|
|
|
248
248
|
this.evaluate(peer, hard).catch((e: Error) => this.closed || this.hooks.notify(`budget: evaluating ${peer} failed: ${e.message}`));
|
|
249
249
|
}
|
|
250
250
|
|
|
251
|
+
/** A source that can no longer be trusted drops its reading of one window: the window shows unknown, never the last number. */
|
|
252
|
+
forget(peer: PeerId, id: UsageWindow["id"]): void {
|
|
253
|
+
this.readings.get(this.account(peer))?.delete(id);
|
|
254
|
+
}
|
|
255
|
+
|
|
251
256
|
/** Readings that still say something: not stale, and not about a window that has reset since they were taken. */
|
|
252
257
|
private fresh(peer: PeerId): Reading[] {
|
|
253
258
|
peer = this.account(peer);
|
package/src/hub/bus.ts
CHANGED
|
@@ -429,14 +429,14 @@ export class Bus {
|
|
|
429
429
|
}
|
|
430
430
|
|
|
431
431
|
knownPeers(): string[] {
|
|
432
|
-
const pending = this.storageError ? [] : this.journal?.
|
|
432
|
+
const pending = this.storageError ? [] : this.journal?.pendingPeers() ?? [];
|
|
433
433
|
return [...new Set([...this.peers.keys(), ...[...this.queues].filter(([, queue]) => queue.length).map(([id]) => id), ...pending])].sort();
|
|
434
434
|
}
|
|
435
435
|
|
|
436
|
-
queueList(peer?: string): JournalDelivery[] {
|
|
436
|
+
queueList(peer?: string, unresolvedOnly = false): JournalDelivery[] {
|
|
437
437
|
if (this.journal) {
|
|
438
|
-
const rows = this.journal.list(peer).filter((row) => !row.reason?.startsWith("grouped into delivery "));
|
|
439
|
-
const revision = this.journal.
|
|
438
|
+
const rows = (unresolvedOnly ? this.journal.unresolved(peer) : this.journal.list(peer)).filter((row) => !row.reason?.startsWith("grouped into delivery "));
|
|
439
|
+
const revision = this.journal.revision();
|
|
440
440
|
const recorded = new Set(rows.filter((r) => ["queued", "dispatching", "accepted", "needs_review"].includes(r.state)).flatMap((r) => (r.originals as Envelope[]).map((e) => `${r.peer}:${e.id}`)));
|
|
441
441
|
const ids = peer ? [peer] : [...this.queues.keys()];
|
|
442
442
|
for (const p of ids) for (const e of this.queues.get(p) ?? []) if (!recorded.has(`${p}:${e.id}`)) rows.push({ id: `q:${p}:${e.id}`, peer: p, state: "queued", revision, createdAt: e.ts, updatedAt: e.ts, originals: [e], out: [e] });
|
|
@@ -525,7 +525,7 @@ export class Bus {
|
|
|
525
525
|
}
|
|
526
526
|
|
|
527
527
|
queueSummary(peer: string): { needsReview: number; heldBy?: string; liveAccepted?: string[]; oldestQueuedAt?: number } {
|
|
528
|
-
const rows = this.storageError ? [] : this.queueList(peer);
|
|
528
|
+
const rows = this.storageError ? [] : this.queueList(peer, true);
|
|
529
529
|
const queued = this.queues.get(peer) ?? [];
|
|
530
530
|
const review = rows.filter((r) => r.state === "needs_review");
|
|
531
531
|
const needsReview = review.length;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { closeSync, fstatSync, openSync, readSync, constants } from "node:fs";
|
|
2
|
+
import { isAbsolute, join } from "node:path";
|
|
3
|
+
import { claudeStateFile } from "./claude-state.ts";
|
|
4
|
+
|
|
5
|
+
export interface ClaudeLaunchProof {
|
|
6
|
+
launchId: string;
|
|
7
|
+
executionCwd?: string;
|
|
8
|
+
privateRecord: boolean;
|
|
9
|
+
permissionHook: boolean;
|
|
10
|
+
unattended: boolean;
|
|
11
|
+
hookPurpose?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** One bounded reader for current managed launch metadata; no native identity or payload escapes. */
|
|
15
|
+
export function readClaudeLaunchProof(stateDir: string, peer: string, instanceId: string): ClaudeLaunchProof | undefined {
|
|
16
|
+
let fd: number | undefined;
|
|
17
|
+
try {
|
|
18
|
+
fd = openSync(join(stateDir, claudeStateFile("claude-launch", peer)), constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
19
|
+
const stat = fstatSync(fd);
|
|
20
|
+
// ponytail: launch metadata is capped at 16 KiB; add a versioned projection if it ever grows.
|
|
21
|
+
if (!stat.isFile() || stat.size > 16 * 1024) return;
|
|
22
|
+
const bytes = Buffer.alloc(16 * 1024 + 1);
|
|
23
|
+
const count = readSync(fd, bytes, 0, bytes.length, 0);
|
|
24
|
+
if (count > 16 * 1024) return;
|
|
25
|
+
const row = JSON.parse(bytes.subarray(0, count).toString("utf8"));
|
|
26
|
+
if (row?.instanceId !== instanceId || typeof row.launchId !== "string" || !row.launchId || row.launchId.length > 128) return;
|
|
27
|
+
return { launchId: row.launchId, privateRecord: stat.nlink === 1 && (stat.mode & 0o777) === 0o600 && !!process.getuid && stat.uid === process.getuid(),
|
|
28
|
+
...(typeof row.executionCwd === "string" && row.executionCwd.length <= 4096 && isAbsolute(row.executionCwd) ? { executionCwd: row.executionCwd } : {}),
|
|
29
|
+
permissionHook: row.permissionHook === true, unattended: row.unattended === true,
|
|
30
|
+
...(typeof row.hookPurpose === "string" ? { hookPurpose: row.hookPurpose } : {}) };
|
|
31
|
+
} catch { return; }
|
|
32
|
+
finally { if (fd !== undefined) closeSync(fd); }
|
|
33
|
+
}
|