@staix/agent-hub 0.18.0 → 0.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +30 -0
- package/README.md +4 -4
- package/docs/agent-notes/README.md +6 -0
- package/docs/agent-notes/adapters.md +13 -10
- package/docs/agent-notes/bus.md +1 -1
- package/docs/agent-notes/daemon.md +11 -4
- package/docs/agent-notes/local-worker.md +3 -1
- package/docs/agent-notes/tasks.md +6 -1
- package/docs/agent-notes/tests.md +8 -4
- package/docs/dogfood.md +13 -4
- package/docs/memory.md +1 -1
- package/docs/operations.md +98 -28
- package/docs/quickstart.md +13 -5
- package/docs/sdlc.md +12 -0
- package/docs/security.md +24 -15
- package/docs/smoke.md +3 -3
- package/docs/specs/2026-09-19-agent-hub-design.md +101 -8
- package/docs/verification/2026-10-25-remote-docker-transport.md +10 -0
- package/docs/verification/2026-10-26-agy-1.4.7.md +69 -0
- package/docs/verification/2026-10-26-claude-implementer-2.1.310.md +69 -0
- package/docs/verification/2026-10-26-headless-claude-shell-context.md +62 -0
- package/docs/verification/2026-10-26-jcode-0.99.1.md +49 -0
- package/docs/verification/2026-10-26-kimi-232-reviewer-compat.md +53 -0
- package/docs/verification/2026-10-26-pi-140-compat.md +41 -0
- package/docs/verification/2026-10-26-qwen-0261-acp.md +60 -0
- package/docs/verification/2026-10-27-0.19.0.md +86 -0
- package/docs/verification/2026-10-27-0.20.0.md +75 -0
- package/docs/verification/claude-permission-transport-native.py +187 -0
- package/docs/verification/claude-shell-wrapper-host.ts +20 -0
- package/docs/verification/claude-shell-wrapper-native.py +145 -0
- package/docs/verification/pi-140-model-free.ts +57 -0
- package/docs/verification/pi-140-native.mjs +21 -0
- package/docs/verified.json +25 -17
- package/package.json +2 -2
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +373 -72
- package/src/adapters/acp.ts +8 -0
- package/src/adapters/agy/entry.ts +1 -1
- package/src/adapters/channel-peer.ts +7 -5
- package/src/adapters/claude-channel.ts +7 -3
- package/src/adapters/claude-implementer.ts +6 -3
- package/src/adapters/codex-appserver.ts +11 -1
- package/src/adapters/compat.ts +32 -18
- package/src/adapters/conformance/enrollment.ts +4 -4
- package/src/adapters/conformance/factories.ts +2 -1
- package/src/adapters/conformance/fakes/pi-rpc.ts +1 -0
- package/src/adapters/conformance/kit.ts +10 -0
- package/src/adapters/definitions/builtin-metadata.ts +2 -2
- package/src/adapters/definitions/native-factories.ts +7 -2
- package/src/adapters/definitions/reviewer-variants.ts +2 -2
- package/src/adapters/local-worker.ts +2 -0
- package/src/adapters/pi.ts +46 -3
- package/src/adapters/qwen-dual.ts +25 -0
- package/src/adapters/registry-types.ts +6 -2
- package/src/adapters/registry.ts +1 -0
- package/src/adapters/reviewer-print.ts +1 -0
- package/src/cli/agents.ts +69 -7
- package/src/cli/claude-shell-wrapper.ts +54 -0
- package/src/cli/main.ts +30 -24
- package/src/cli/output.ts +4 -0
- package/src/hub/agent-drift.ts +61 -0
- package/src/hub/board.ts +14 -2
- package/src/hub/check-runner.ts +148 -18
- package/src/hub/ci-watch.ts +140 -0
- package/src/hub/claude-launch-proof.ts +67 -2
- package/src/hub/claude-shell-wrapper.ts +349 -0
- package/src/hub/config-apply.ts +2 -2
- package/src/hub/config-edit.ts +2 -2
- package/src/hub/config-tier.ts +2 -2
- package/src/hub/config-trust.ts +1 -0
- package/src/hub/daemon.ts +282 -113
- package/src/hub/host-executable.ts +20 -1
- package/src/hub/instances.ts +18 -2
- package/src/hub/lifecycle.ts +1 -1
- package/src/hub/operational-authority.ts +7 -0
- package/src/hub/operational-view.ts +6 -10
- package/src/hub/peers.ts +34 -0
- package/src/hub/read-only-shell-proof.ts +7 -6
- package/src/hub/review-scope.ts +13 -0
- package/src/hub/sdlc-metrics.ts +6 -4
- package/src/hub/settings.ts +1 -0
- package/src/hub/task-shell-command.ts +85 -0
- package/src/hub/task-shell-default.ts +79 -0
- package/src/hub/task-shell-proof.ts +171 -0
- package/src/hub/task-shell-sandbox.ts +61 -0
- package/src/hub/tasks.ts +274 -16
- package/src/local/deny.ts +112 -11
- package/src/local/sandbox.ts +70 -29
- package/src/local/shell-environment.ts +10 -0
- package/src/ui/index.html +1 -0
package/src/cli/agents.ts
CHANGED
|
@@ -4,19 +4,81 @@ import { assessVersion, COMPAT, describe, isCompatId, readTrust, trustFile, writ
|
|
|
4
4
|
/** #691: what `ahub agents` and `ahub doctor` show for each gated native CLI. */
|
|
5
5
|
export interface AgentVersionRow { id: CompatId; executable?: string; verdict: CompatVerdict; policy: string }
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
const PROBE_ENV = { JCODE_NO_TELEMETRY: "1", JCODE_DISABLE_CLAUDE_MCP: "1" };
|
|
8
|
+
|
|
9
|
+
/** The one place that decides what is run for an id: the configured argv (including pi.cmd) plus the probe words. */
|
|
10
|
+
function probeTarget(id: CompatId, commands: Partial<Record<CompatId, readonly string[]>>): { executable: string; args: string[] } | undefined {
|
|
11
|
+
const [defaultBin, ...words] = COMPAT[id].probe;
|
|
12
|
+
const [bin = defaultBin, ...prefix] = commands[id] ?? [defaultBin];
|
|
13
|
+
const executable = Bun.which(bin) ?? undefined;
|
|
14
|
+
return executable ? { executable, args: [...prefix, ...words] } : undefined;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function probeRow(id: CompatId, executable: string | undefined, stdout: string | undefined, trusted: ReturnType<typeof readTrust>): AgentVersionRow {
|
|
18
|
+
if (!executable) return { id, verdict: { ok: false, state: "unknown", reason: `${COMPAT[id].label} not found on PATH` }, policy: describe(id) };
|
|
19
|
+
return { id, executable, verdict: assessVersion(id, stdout === undefined ? undefined : COMPAT[id].parse(stdout), trusted), policy: describe(id) };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** `commands` carries the configured argv (including pi.cmd); the probe words follow it. */
|
|
8
23
|
export function agentVersions(commands: Partial<Record<CompatId, readonly string[]>> = {}): AgentVersionRow[] {
|
|
9
24
|
const trusted = readTrust();
|
|
10
25
|
return (Object.keys(COMPAT) as CompatId[]).map(id => {
|
|
11
|
-
const
|
|
12
|
-
|
|
13
|
-
const
|
|
14
|
-
|
|
15
|
-
const result = spawnSync(executable, [...prefix, ...words], { encoding: "utf8", timeout: 10_000, maxBuffer: 64 * 1024, env: { ...process.env, JCODE_NO_TELEMETRY: "1", JCODE_DISABLE_CLAUDE_MCP: "1" } });
|
|
16
|
-
return { id, executable, verdict: assessVersion(id, result.status === 0 ? COMPAT[id].parse(result.stdout ?? "") : undefined, trusted), policy: describe(id) };
|
|
26
|
+
const target = probeTarget(id, commands);
|
|
27
|
+
if (!target) return probeRow(id, undefined, undefined, trusted);
|
|
28
|
+
const result = spawnSync(target.executable, target.args, { encoding: "utf8", timeout: 10_000, maxBuffer: 64 * 1024, env: { ...process.env, ...PROBE_ENV } });
|
|
29
|
+
return probeRow(id, target.executable, result.status === 0 ? result.stdout ?? "" : undefined, trusted);
|
|
17
30
|
});
|
|
18
31
|
}
|
|
19
32
|
|
|
33
|
+
const PROBE_OUTPUT_CAP = 64 * 1024;
|
|
34
|
+
/** After the probe process ends, how long a descendant that kept the pipe open may still be read before the reader is cancelled. */
|
|
35
|
+
const PROBE_EXIT_GRACE_MS = 250;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* One bounded probe run. Output is read as a stream and counted as it arrives: past the cap the probe is killed and the
|
|
39
|
+
* reader cancelled, so a chatty or endless CLI never accumulates in memory. A deadline kills the probe and cancels the
|
|
40
|
+
* reader, and a descendant that inherited stdout cannot hold the reader past the grace after the probe's own exit.
|
|
41
|
+
* ponytail: only the probe process is killed, not its descendants; a stray one ends on its own or when its pipe closes.
|
|
42
|
+
* The upgrade is a process group via child_process (as `jcodeVersionAsync` does).
|
|
43
|
+
*/
|
|
44
|
+
async function runProbe(executable: string, args: readonly string[], timeoutMs: number, graceMs = PROBE_EXIT_GRACE_MS): Promise<string | undefined> {
|
|
45
|
+
const child = Bun.spawn([executable, ...args], { stdout: "pipe", stderr: "ignore", stdin: "ignore", env: { ...process.env, ...PROBE_ENV }, timeout: timeoutMs, killSignal: "SIGKILL" });
|
|
46
|
+
const reader = child.stdout.getReader();
|
|
47
|
+
const chunks: Uint8Array[] = [];
|
|
48
|
+
let total = 0, overflow = false, cancelled = false;
|
|
49
|
+
const release = () => { if (cancelled) return; cancelled = true; void reader.cancel().catch(() => undefined); };
|
|
50
|
+
const pump = (async () => {
|
|
51
|
+
try {
|
|
52
|
+
for (;;) {
|
|
53
|
+
const { done, value } = await reader.read();
|
|
54
|
+
if (done) return;
|
|
55
|
+
total += value.byteLength;
|
|
56
|
+
if (total > PROBE_OUTPUT_CAP) { overflow = true; child.kill("SIGKILL"); release(); return; }
|
|
57
|
+
chunks.push(value);
|
|
58
|
+
}
|
|
59
|
+
} catch { /* a cancelled or failed read ends the probe with what was counted */ }
|
|
60
|
+
})();
|
|
61
|
+
const deadline = setTimeout(() => { child.kill("SIGKILL"); release(); }, timeoutMs + graceMs);
|
|
62
|
+
let grace: ReturnType<typeof setTimeout> | undefined;
|
|
63
|
+
try {
|
|
64
|
+
const code = await child.exited;
|
|
65
|
+
grace = setTimeout(release, graceMs);
|
|
66
|
+
await pump;
|
|
67
|
+
return code === 0 && !overflow ? Buffer.concat(chunks).toString("utf8") : undefined;
|
|
68
|
+
} finally { clearTimeout(deadline); clearTimeout(grace); release(); }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** #843 AC3: the same probe for the daemon, which must not block its event loop for up to ten seconds an id. */
|
|
72
|
+
export async function agentVersionsAsync(commands: Partial<Record<CompatId, readonly string[]>> = {}, timeoutMs = 10_000, graceMs = PROBE_EXIT_GRACE_MS): Promise<AgentVersionRow[]> {
|
|
73
|
+
const trusted = readTrust();
|
|
74
|
+
return Promise.all((Object.keys(COMPAT) as CompatId[]).map(async id => {
|
|
75
|
+
const target = probeTarget(id, commands);
|
|
76
|
+
if (!target) return probeRow(id, undefined, undefined, trusted);
|
|
77
|
+
try { return probeRow(id, target.executable, await runProbe(target.executable, target.args, timeoutMs, graceMs), trusted); }
|
|
78
|
+
catch { return probeRow(id, target.executable, undefined, trusted); }
|
|
79
|
+
}));
|
|
80
|
+
}
|
|
81
|
+
|
|
20
82
|
export function runAgents(args: string[], commands: Partial<Record<CompatId, readonly string[]>>, fail: (message: string) => never): void {
|
|
21
83
|
const [sub, id, version, ...rest] = args.filter(arg => arg !== "--json");
|
|
22
84
|
if (sub === "trust") {
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { request } from "node:http";
|
|
2
|
+
import { isAbsolute, basename } from "node:path";
|
|
3
|
+
|
|
4
|
+
export const HOST_SHELL_PROTOCOL = Object.freeze({ chunkBytes: 16 * 1024, frameBytes: 24 * 1024, failure: 125, maximumMs: 3_600_000, refusal: "host shell execution refused" });
|
|
5
|
+
|
|
6
|
+
async function output(stream: NodeJS.WriteStream, data: Buffer): Promise<void> {
|
|
7
|
+
await new Promise<void>((resolve, reject) => stream.write(data, error => error ? reject(error) : resolve()));
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
async function main(): Promise<number> {
|
|
11
|
+
const [socket, ticket, ...extra] = process.argv.slice(2);
|
|
12
|
+
if (!socket || !isAbsolute(socket) || socket.length > 4096 || !ticket || !/^[a-f0-9]{64}$/.test(ticket) || extra.length) throw new Error();
|
|
13
|
+
const body = JSON.stringify({ ticket });
|
|
14
|
+
const call = request({ socketPath: socket, path: "/execute", method: "POST", headers: { "content-type": "application/json", "content-length": Buffer.byteLength(body), connection: "close" } });
|
|
15
|
+
const connection = new Promise<import("node:http").IncomingMessage>((resolve, reject) => { call.once("response", resolve); call.once("error", reject); });
|
|
16
|
+
const opening = setTimeout(() => call.destroy(new Error()), 5000);
|
|
17
|
+
const total = setTimeout(() => call.destroy(new Error()), HOST_SHELL_PROTOCOL.maximumMs + 10_000);
|
|
18
|
+
let response: import("node:http").IncomingMessage | undefined;
|
|
19
|
+
try {
|
|
20
|
+
call.end(body); response = await connection; clearTimeout(opening);
|
|
21
|
+
if (response.statusCode !== 200 || response.headers["content-type"] !== "application/x-ndjson") throw new Error();
|
|
22
|
+
let pending = "", status: number | undefined;
|
|
23
|
+
for await (const raw of response) {
|
|
24
|
+
const chunk = Buffer.isBuffer(raw) ? raw : Buffer.from(raw);
|
|
25
|
+
if (chunk.length > 1024 * 1024) throw new Error();
|
|
26
|
+
pending += chunk.toString("utf8");
|
|
27
|
+
for (let end; (end = pending.indexOf("\n")) >= 0;) {
|
|
28
|
+
if (end > HOST_SHELL_PROTOCOL.frameBytes) throw new Error();
|
|
29
|
+
const line = pending.slice(0, end); pending = pending.slice(end + 1);
|
|
30
|
+
if (!line || status !== undefined) throw new Error();
|
|
31
|
+
const frame = JSON.parse(line);
|
|
32
|
+
if (!frame || typeof frame !== "object" || Array.isArray(frame)) throw new Error();
|
|
33
|
+
const keys = Object.keys(frame).sort().join(",");
|
|
34
|
+
if (frame.type === "exit" && keys === "code,type" && Number.isSafeInteger(frame.code) && frame.code >= 0 && frame.code <= 255) { status = frame.code; continue; }
|
|
35
|
+
if (!["stdout", "stderr"].includes(frame.type) || keys !== "data,type" || typeof frame.data !== "string" || frame.data.length > 4 * Math.ceil(HOST_SHELL_PROTOCOL.chunkBytes / 3)) throw new Error();
|
|
36
|
+
const bytes = Buffer.from(frame.data, "base64");
|
|
37
|
+
if (bytes.length > HOST_SHELL_PROTOCOL.chunkBytes || bytes.toString("base64") !== frame.data) throw new Error();
|
|
38
|
+
await output(frame.type === "stdout" ? process.stdout : process.stderr, bytes);
|
|
39
|
+
}
|
|
40
|
+
if (pending.length > HOST_SHELL_PROTOCOL.frameBytes) throw new Error();
|
|
41
|
+
}
|
|
42
|
+
if (pending || status === undefined) throw new Error();
|
|
43
|
+
return status;
|
|
44
|
+
} finally { clearTimeout(opening); clearTimeout(total); response?.destroy(); call.destroy(); }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// The filename fence also keeps this inert if a host imports the protocol constants through a bundle.
|
|
48
|
+
if (import.meta.main && basename(process.argv[1] ?? "") === "claude-shell-wrapper.ts") {
|
|
49
|
+
process.stdout.on("error", () => {}); process.stderr.on("error", () => {});
|
|
50
|
+
void main().then(code => { process.exitCode = code; }, async () => {
|
|
51
|
+
try { await output(process.stderr, Buffer.from(`${HOST_SHELL_PROTOCOL.refusal}\n`)); } catch { /* broken caller pipe */ }
|
|
52
|
+
process.exitCode = HOST_SHELL_PROTOCOL.failure;
|
|
53
|
+
});
|
|
54
|
+
}
|
package/src/cli/main.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
-
import { conductorOperation } from "../hub/operational-authority.ts";
|
|
2
|
+
import { conductorOperation, OPERATIONAL_PERSON_GUIDANCE } from "../hub/operational-authority.ts";
|
|
3
3
|
import type { DeliverableRow } from "../hub/tasks.ts";
|
|
4
4
|
import { kimiSourceRow } from "../hub/budget-notes.ts";
|
|
5
5
|
import { hubGitSync, hubGitEnv } from "../hub/git.ts";
|
|
@@ -487,12 +487,17 @@ function startupDiagnostic(error: unknown): string {
|
|
|
487
487
|
return message.replace(/[\p{Cc}\p{Cf}]/gu, " ").replace(/\s+/g, " ").trim().slice(0, 512) || "invalid startup configuration";
|
|
488
488
|
}
|
|
489
489
|
|
|
490
|
-
function fail(message: string, refusalAudited=false): never {
|
|
491
|
-
if(operationalAuditReady){if(refusalAudited)operationalRefusalRecorded=true;if(!operationalRefusalRecorded)audit("refused");}
|
|
490
|
+
function fail(message: string, refusalAudited=false, authorityRefused?:boolean): never {
|
|
491
|
+
if(operationalAuditReady && authorityRefused!==false){if(refusalAudited)operationalRefusalRecorded=true;if(!operationalRefusalRecorded)audit("refused");}
|
|
492
492
|
console.error(`ahub: ${message}`);
|
|
493
493
|
process.exit(1);
|
|
494
494
|
}
|
|
495
495
|
|
|
496
|
+
function operationalIncomplete(result: {complete?:boolean;incompleteReason?:unknown;truncated?:boolean}, history: "log"|"event"): void {
|
|
497
|
+
if(result.complete===false)console.error(`ahub: operational output is incomplete: ${typeof result.incompleteReason==="string"?terminalText(result.incompleteReason).replace(/[\r\n]+/g," ").slice(0,200):"public privacy or history coverage unavailable"}`);
|
|
498
|
+
else if(result.truncated)console.error(`ahub: operational ${history} history is truncated`);
|
|
499
|
+
}
|
|
500
|
+
|
|
496
501
|
async function taskOp(op: string, a: Record<string, unknown>, throwOnError = false, connection?: ControlClient, timeoutMs?: number): Promise<string> {
|
|
497
502
|
const hub = connection ?? await connect();
|
|
498
503
|
try {
|
|
@@ -501,11 +506,11 @@ async function taskOp(op: string, a: Record<string, unknown>, throwOnError = fal
|
|
|
501
506
|
if (!res.ok) {
|
|
502
507
|
if (throwOnError) throw new Error(res.error);
|
|
503
508
|
if(res.refusalAudited===true)operationalRefusalRecorded=true;
|
|
504
|
-
audit("refused");
|
|
509
|
+
if(!operationalAuditReady || res.authorityRefused!==false)audit("refused");
|
|
505
510
|
// #711: an agent shell refused something only the person may do is told why it counts as an agent. The refusal and the authority stay as they were.
|
|
506
511
|
const cause = agentShellCause(identity, String(res.error));
|
|
507
512
|
if (cause) console.error(`ahub: ${res.error}\n${cause}`), process.exit(1);
|
|
508
|
-
fail(res.error);
|
|
513
|
+
fail(res.error,res.refusalAudited===true,res.authorityRefused);
|
|
509
514
|
}
|
|
510
515
|
return res.text;
|
|
511
516
|
} finally { if (!connection) hub.close(); }
|
|
@@ -1065,7 +1070,7 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
1065
1070
|
const { one, rest: flags } = takeFlags(rest, ["--peer"], []);
|
|
1066
1071
|
if (flags.length) fail("usage: ahub queue list [--peer <id>] [--json] [--full] [--color=auto|always|never]");
|
|
1067
1072
|
const result = await hub.request({ t: "queue", op: "list", ...(one["--peer"] ? { peer: one["--peer"] } : {}) });
|
|
1068
|
-
if (!result.ok) fail(result.error,result.refusalAudited===true);
|
|
1073
|
+
if (!result.ok) fail(result.error,result.refusalAudited===true,result.authorityRefused);
|
|
1069
1074
|
if (options.json) return console.log(JSON.stringify(result.deliveries, null, 2));
|
|
1070
1075
|
printOutput(renderQueue(result.deliveries, options.columns, Date.now()), options.color);
|
|
1071
1076
|
return;
|
|
@@ -1077,7 +1082,7 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
1077
1082
|
if (others.length || !one["--peer"] || (one["--state"] !== undefined && !["queued", "needs_review"].includes(one["--state"]))) fail("usage: ahub queue discard --peer <id> --reason <text> [--state queued|needs_review] [--yes]");
|
|
1078
1083
|
if (yes && !one["--reason"]?.trim()) fail("ahub queue discard needs --reason <text> with --yes: it is recorded on every discarded delivery");
|
|
1079
1084
|
const result = await hub.request({ t: "queue", op: "discard", peer: one["--peer"], ...(one["--state"] ? { state: one["--state"] } : {}), ...(yes ? { confirmed: true, reason: one["--reason"] } : {}) }, 60_000);
|
|
1080
|
-
if (!result.ok) fail(result.error,result.refusalAudited===true);
|
|
1085
|
+
if (!result.ok) fail(result.error,result.refusalAudited===true,result.authorityRefused);
|
|
1081
1086
|
if (options.json) return console.log(JSON.stringify(result, null, 2));
|
|
1082
1087
|
if (!yes) return console.log(`${result.count} ${result.state} deliver${result.count === 1 ? "y" : "ies"} for ${result.peer}; nothing discarded. Run again with --reason <text> --yes to discard them.`);
|
|
1083
1088
|
console.log(`discarded ${result.discarded} of ${result.count} ${result.state} deliver${result.count === 1 ? "y" : "ies"} for ${result.peer}${result.refused ? `; ${result.refused} refused (${Object.entries(result.refusedBy ?? {}).map(([why, n]) => `${why} ${n}`).join(", ")})` : ""}`);
|
|
@@ -1088,7 +1093,7 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
1088
1093
|
if (operation === "show") {
|
|
1089
1094
|
if (flags.length) fail("usage: ahub queue show <id>");
|
|
1090
1095
|
const shown = await hub.request({ t: "queue", op: "show", id });
|
|
1091
|
-
if (!shown.ok) fail(shown.error,shown.refusalAudited===true);
|
|
1096
|
+
if (!shown.ok) fail(shown.error,shown.refusalAudited===true,shown.authorityRefused);
|
|
1092
1097
|
if (options.json) return console.log(JSON.stringify(shown.delivery, null, 2));
|
|
1093
1098
|
return printOutput(renderQueueShow(shown.delivery, options.columns, Date.now()), options.color);
|
|
1094
1099
|
}
|
|
@@ -1098,11 +1103,11 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
1098
1103
|
let revision = parsedRevision;
|
|
1099
1104
|
if (revision === undefined) {
|
|
1100
1105
|
const shown = await hub.request({ t: "queue", op: "show", id });
|
|
1101
|
-
if (!shown.ok) fail(shown.error,shown.refusalAudited===true);
|
|
1106
|
+
if (!shown.ok) fail(shown.error,shown.refusalAudited===true,shown.authorityRefused);
|
|
1102
1107
|
revision = shown.delivery.revision;
|
|
1103
1108
|
}
|
|
1104
1109
|
const result = await hub.request({ t: "queue", op: "resolve", id, revision, action: one["--action"], reason: one["--reason"] });
|
|
1105
|
-
if (!result.ok) fail(result.error,result.refusalAudited===true);
|
|
1110
|
+
if (!result.ok) fail(result.error,result.refusalAudited===true,result.authorityRefused);
|
|
1106
1111
|
if (options.json) console.log(JSON.stringify(result.delivery, null, 2));
|
|
1107
1112
|
else printOutput(renderQueueShow(result.delivery, options.columns, Date.now()), options.color);
|
|
1108
1113
|
} finally { hub.close(); }
|
|
@@ -1566,7 +1571,7 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
1566
1571
|
const reply = await hub.request({ t: "peer_stop", peer }, 35_000);
|
|
1567
1572
|
if (reply.ok !== true) {
|
|
1568
1573
|
const error = String(reply.error ?? "peer stop request refused");
|
|
1569
|
-
fail(/unknown (?:control )?(?:message|request|command)(?:\b|:)|this hub does not know "peer_stop"/i.test(error) ? `${error}; upgrade the running hub to use ahub stop` : error,reply.refusalAudited===true);
|
|
1574
|
+
fail(/unknown (?:control )?(?:message|request|command)(?:\b|:)|this hub does not know "peer_stop"/i.test(error) ? `${error}; upgrade the running hub to use ahub stop` : error,reply.refusalAudited===true,reply.authorityRefused);
|
|
1570
1575
|
}
|
|
1571
1576
|
console.log(`${peer}: ${reply.state}`);
|
|
1572
1577
|
} finally { hub.close(); }
|
|
@@ -1602,7 +1607,7 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
1602
1607
|
const hub = await connect();
|
|
1603
1608
|
try {
|
|
1604
1609
|
requireNamedPeerFeature(hub, peer);
|
|
1605
|
-
const request = async (message: Record<string, unknown>) => { const reply = await hub.request(message, 35_000); if (reply.ok !== true) fail(String(reply.error ?? "roster request refused"),reply.refusalAudited===true); return reply; };
|
|
1610
|
+
const request = async (message: Record<string, unknown>) => { const reply = await hub.request(message, 35_000); if (reply.ok !== true) fail(String(reply.error ?? "roster request refused"),reply.refusalAudited===true,reply.authorityRefused); return reply; };
|
|
1606
1611
|
if (sub === "list") {
|
|
1607
1612
|
const view = await request({ t: "settings_get" });
|
|
1608
1613
|
if (!Array.isArray(view.roster)) fail("upgrade the running hub to use ahub roster");
|
|
@@ -1668,7 +1673,7 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
1668
1673
|
const reply = await hub.request(message, 35_000);
|
|
1669
1674
|
if (reply.ok !== true) {
|
|
1670
1675
|
const error = String(reply.error ?? "settings request refused");
|
|
1671
|
-
fail(/unknown (?:control )?(?:message|request|command)(?:\b|:)|this hub does not know "settings_/i.test(error) ? `${error}; upgrade the running hub to use ahub settings` : error,reply.refusalAudited===true);
|
|
1676
|
+
fail(/unknown (?:control )?(?:message|request|command)(?:\b|:)|this hub does not know "settings_/i.test(error) ? `${error}; upgrade the running hub to use ahub settings` : error,reply.refusalAudited===true,reply.authorityRefused);
|
|
1672
1677
|
}
|
|
1673
1678
|
return reply;
|
|
1674
1679
|
};
|
|
@@ -1798,14 +1803,14 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
1798
1803
|
|
|
1799
1804
|
logs: async () => {
|
|
1800
1805
|
if (identity.role !== "tools") return exec("tail", [args.includes("-f") ? "-f" : "-n100", join(stateDir, "hub.log")]);
|
|
1801
|
-
const hub=await connect();const pending:string[]=[];let ready=false;
|
|
1802
|
-
hub.onPush=message=>{if(message.t==="operational_log" && typeof message.line==="string"){if(ready)console.log(message.line);else if(pending.length<100)pending.push(message.line);}};
|
|
1806
|
+
const hub=await connect();const pending:string[]=[];let ready=false,incompleteShown=false;
|
|
1807
|
+
hub.onPush=message=>{if(message.t==="operational_log" && typeof message.line==="string"){if(message.complete===false && !incompleteShown){operationalIncomplete(message,"log");incompleteShown=true;}if(ready)console.log(message.line);else if(pending.length<100)pending.push(message.line);}};
|
|
1803
1808
|
try {
|
|
1804
1809
|
const result=await hub.request({t:"conductor_operational",op:"logs",follow:args.includes("-f")});
|
|
1805
|
-
if(result.ok!==true)fail(String(result.error??"operational logs unavailable"),result.refusalAudited===true);
|
|
1810
|
+
if(result.ok!==true)fail(String(result.error??"operational logs unavailable"),result.refusalAudited===true,result.authorityRefused);
|
|
1806
1811
|
for(const line of result.lines??[])console.log(line);
|
|
1807
1812
|
ready=true;for(const line of pending)console.log(line);
|
|
1808
|
-
if(
|
|
1813
|
+
if(!incompleteShown){operationalIncomplete(result,"log");if(result.complete===false)incompleteShown=true;}
|
|
1809
1814
|
if(args.includes("-f"))await new Promise<void>(resolve=>{
|
|
1810
1815
|
const finish=()=>{process.off("SIGINT",finish);resolve();};
|
|
1811
1816
|
hub.onClose=finish;process.once("SIGINT",finish);
|
|
@@ -1815,7 +1820,7 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
1815
1820
|
export: async () => {
|
|
1816
1821
|
if(identity.role==="tools") {
|
|
1817
1822
|
const hub=await connect();
|
|
1818
|
-
try {const result=await hub.request({t:"conductor_operational",op:"export",since:since()});if(result.ok!==true)fail(String(result.error??"operational export unavailable"),result.refusalAudited===true);for(const row of result.events??[])console.log(JSON.stringify(row));
|
|
1823
|
+
try {const result=await hub.request({t:"conductor_operational",op:"export",since:since()});if(result.ok!==true)fail(String(result.error??"operational export unavailable"),result.refusalAudited===true,result.authorityRefused);for(const row of result.events??[])console.log(JSON.stringify(row));operationalIncomplete(result,"event");}
|
|
1819
1824
|
finally {hub.close();}
|
|
1820
1825
|
return;
|
|
1821
1826
|
}
|
|
@@ -2230,7 +2235,7 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
2230
2235
|
}
|
|
2231
2236
|
if (args.includes("--orphans")) {
|
|
2232
2237
|
if(identity.role==="tools" && args.includes("--kill")) {
|
|
2233
|
-
const hub=await connect();try {const result=await hub.request({t:"conductor_operational",op:"orphans"},10_000);if(result.ok!==true)fail(String(result.error??"orphan inspection unavailable"),result.refusalAudited===true);console.log(JSON.stringify(result,null,2));}finally{hub.close();}return;
|
|
2238
|
+
const hub=await connect();try {const result=await hub.request({t:"conductor_operational",op:"orphans"},10_000);if(result.ok!==true)fail(String(result.error??"orphan inspection unavailable"),result.refusalAudited===true,result.authorityRefused);console.log(JSON.stringify(result,null,2));}finally{hub.close();}return;
|
|
2234
2239
|
}
|
|
2235
2240
|
return orphanDoctor(args.includes("--kill"), options);
|
|
2236
2241
|
}
|
|
@@ -2392,16 +2397,17 @@ function inspectDoctorRouting(cwd: string): { routing?: Routing; errors: string[
|
|
|
2392
2397
|
async function runConductorCommand(): Promise<void> {
|
|
2393
2398
|
const operation=conductorOperation(cmd,args);
|
|
2394
2399
|
if(operation) {
|
|
2395
|
-
const
|
|
2400
|
+
const unavailable=`conductor authorization unavailable; nothing was executed; ${OPERATIONAL_PERSON_GUIDANCE}`;
|
|
2396
2401
|
const descriptor=readControl(stateDir);
|
|
2397
|
-
if(!descriptor?.instanceId || !descriptor.projectId || !descriptor.pid || !descriptor.pidSignature || processLiveness(descriptor.pid,descriptor.pidSignature)!=="live")fail(
|
|
2402
|
+
if(!descriptor?.instanceId || !descriptor.projectId || !descriptor.pid || !descriptor.pidSignature || processLiveness(descriptor.pid,descriptor.pidSignature)!=="live")fail(unavailable);
|
|
2398
2403
|
let hub:ControlClient;
|
|
2399
|
-
try {hub=await connect(
|
|
2404
|
+
try {hub=await connect(3000);} catch {fail(unavailable);}
|
|
2400
2405
|
let release:(()=>void)|undefined;
|
|
2401
2406
|
try {
|
|
2402
2407
|
let result:any;
|
|
2403
|
-
try {result=await hub.request({t:"conductor_operational",op:"authorize",command:operation},
|
|
2404
|
-
if(result.ok!==true
|
|
2408
|
+
try {result=await hub.request({t:"conductor_operational",op:"authorize",command:operation},3000);} catch {fail(unavailable);}
|
|
2409
|
+
if(result.ok!==true)fail(result.authorityRefused===true || result.authorityRefused===false?String(result.error??unavailable):unavailable,result.refusalAudited===true,result.authorityRefused);
|
|
2410
|
+
if(!hub.instanceId)fail(unavailable);
|
|
2405
2411
|
release=ControlClient.pinInstance(stateDir,hub.instanceId);
|
|
2406
2412
|
await commands[cmd]!();
|
|
2407
2413
|
} finally {release?.();hub.close();}
|
package/src/cli/output.ts
CHANGED
|
@@ -14,6 +14,8 @@ export interface StatusOutput {
|
|
|
14
14
|
retainedConfig?: readonly { key: string; reason: string }[];
|
|
15
15
|
/** #572 AC3: advisory, present while the project root checkout is on a branch other than the default. */
|
|
16
16
|
mainCheckout?: string;
|
|
17
|
+
/** #843 AC3: installed agent CLIs not on a verified version; ids, version, state and a receipt path only. */
|
|
18
|
+
agentDrift?: readonly { id: string; version: string; state: string; receipt: string }[];
|
|
17
19
|
taskStalls?: readonly { task: number; cause: { kind: string; peer?: string; dependency?: number; code: string } }[];
|
|
18
20
|
pendingTaskOffers?: readonly { task: number; peer: string }[];
|
|
19
21
|
isolatedChecks?: { pending?: number; orphans?: string[]; unknownReceipts?: readonly string[]; maintenanceUnavailable?: boolean };
|
|
@@ -204,6 +206,7 @@ export function renderStatus(data: StatusOutput, columns?: number, now = Date.no
|
|
|
204
206
|
if (data.cwd) out.push(...lines(data.cwd, columns));
|
|
205
207
|
for (const pending of data.pendingRestart ?? []) out.push(...detail("pending restart", `${pending.key}: ${pending.reason}`, columns, "attention"));
|
|
206
208
|
if (data.mainCheckout) out.push(...detail("main checkout", data.mainCheckout, columns, "attention"));
|
|
209
|
+
for (const drift of data.agentDrift ?? []) out.push(...detail("agent CLI drift", `${drift.id} ${drift.version} ${drift.state}; last receipt ${drift.receipt}`, columns, "attention"));
|
|
207
210
|
if (data.retainedConfig?.length) {
|
|
208
211
|
out.push(...detail("config retained", `${data.retainedConfig.map(row => row.key).join(", ")}; run ahub settings reload`, columns, "attention"));
|
|
209
212
|
for (const pending of data.retainedConfig) out.push(...detail("retained reason", `${pending.key}: ${pending.reason}`, columns, "attention"));
|
|
@@ -262,6 +265,7 @@ export function renderStatus(data: StatusOutput, columns?: number, now = Date.no
|
|
|
262
265
|
if (p.permissionNote) out.push(...detail("permission", p.permissionNote, columns, "muted"));
|
|
263
266
|
if (typeof p.statusNotice?.message === "string" && p.statusNotice.message) out.push(...detail("status notice", p.statusNotice.message, columns, "attention"));
|
|
264
267
|
if (p.requestedModel && p.servedBy && p.requestedModel !== p.servedBy) out.push(...detail("requested model", p.requestedModel, columns));
|
|
268
|
+
if (p.reviewScope === "code" || p.reviewScope === "full") out.push(...detail("review scope", p.reviewScope === "code" ? "code only (cannot run tests or read CI; the merger gates on exact-head CI)" : "full", columns, "muted")); // #852
|
|
265
269
|
if (typeof p.modelSource === "string" && p.modelSource) out.push(...detail("model source", flat(p.modelSource), columns, "muted")); // an extra Jcode instance (#685): names only, never a URL or token
|
|
266
270
|
if (typeof p.importantWaitingSince === "number" && p.importantWaitingNote) out.push(...detail("important waiting", `${p.importantWaitingNote.replace(/^\d+ important messages? waiting/, "important messages waiting")}`, columns, "attention"));
|
|
267
271
|
if (typeof p.oldestQueuedAt === "number") out.push(...detail(`${id} oldest queued`, relative(p.oldestQueuedAt, now), columns, "muted"));
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { COMPAT, type CompatId } from "../adapters/compat.ts";
|
|
2
|
+
import type { AgentVersionRow } from "../cli/agents.ts";
|
|
3
|
+
import { HUB, newEnvelope, sanitize, type PeerId } from "./envelope.ts";
|
|
4
|
+
|
|
5
|
+
/** #843 AC3: an installed gated CLI whose version is not `verified`. Ids, version, state and one receipt path, never output of the CLI. */
|
|
6
|
+
export interface DriftedAgent { id: CompatId; version: string; state: "in-line" | "trusted" | "refused"; receipt: string }
|
|
7
|
+
|
|
8
|
+
/** A version string that is safe to carry in a notice and a marker: what `COMPAT[id].parse` yields for a real CLI. */
|
|
9
|
+
const SAFE_VERSION = /^[0-9A-Za-z][0-9A-Za-z.+_-]{0,63}$/;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Pure: which rows are drift. A missing executable is not drift (no executable, no version), and a CLI whose
|
|
13
|
+
* version could not be read has no (id, version) to announce, so `unknown` is never drift either.
|
|
14
|
+
*/
|
|
15
|
+
export function driftedAgents(rows: readonly AgentVersionRow[]): DriftedAgent[] {
|
|
16
|
+
const out: DriftedAgent[] = [];
|
|
17
|
+
for (const row of rows) {
|
|
18
|
+
const { verdict } = row;
|
|
19
|
+
if (!row.executable || verdict.state === "verified" || verdict.state === "unknown") continue;
|
|
20
|
+
const version = verdict.version;
|
|
21
|
+
if (!version || !SAFE_VERSION.test(version)) continue;
|
|
22
|
+
out.push({ id: row.id, version, state: verdict.state, receipt: COMPAT[row.id].receipt });
|
|
23
|
+
}
|
|
24
|
+
return out;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Durable key of one notice: the (id, version) and the planner it was queued for. */
|
|
28
|
+
export const driftMarker = (agent: Pick<DriftedAgent, "id" | "version">, planner: PeerId): string => `${agent.id}:${agent.version}@${planner}`;
|
|
29
|
+
|
|
30
|
+
export const driftNoticeBody = (agent: DriftedAgent): string =>
|
|
31
|
+
sanitize(`Agent CLI drift: ${agent.id} ${agent.version} ${agent.state}; last receipt ${agent.receipt}`);
|
|
32
|
+
|
|
33
|
+
export interface DriftDeps {
|
|
34
|
+
/** True when the (id, version, planner) marker is new and now recorded (durable: `Board.recordAgentDriftNotice`). */
|
|
35
|
+
record(marker: string): boolean;
|
|
36
|
+
/** Undo a record whose notice could not be queued, so the next sweep tries again. */
|
|
37
|
+
forget(marker: string): void;
|
|
38
|
+
/** The effective planner holders from the settled role map. */
|
|
39
|
+
planners(): readonly PeerId[];
|
|
40
|
+
isRostered(peer: PeerId): boolean;
|
|
41
|
+
publish(env: ReturnType<typeof newEnvelope>): PeerId[];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* One notice per (id, version) to each effective planner, kept durably per recipient: a planner that was queued is
|
|
46
|
+
* never told again, and one that could not be queued (or was not rostered yet) is retried alone by the next sweep.
|
|
47
|
+
* With no rostered planner nothing is recorded. Returns the notices queued.
|
|
48
|
+
*/
|
|
49
|
+
export function noticeDrift(drift: readonly DriftedAgent[], deps: DriftDeps): number {
|
|
50
|
+
const planners = deps.planners().filter(peer => deps.isRostered(peer));
|
|
51
|
+
let sent = 0;
|
|
52
|
+
for (const agent of drift) for (const planner of planners) {
|
|
53
|
+
const marker = driftMarker(agent, planner);
|
|
54
|
+
if (!deps.record(marker)) continue;
|
|
55
|
+
try {
|
|
56
|
+
if (!deps.publish(newEnvelope(HUB, driftNoticeBody(agent), { to: [planner], kind: "task" })).includes(planner)) throw new Error("the notice was not queued");
|
|
57
|
+
sent++;
|
|
58
|
+
} catch { deps.forget(marker); }
|
|
59
|
+
}
|
|
60
|
+
return sent;
|
|
61
|
+
}
|
package/src/hub/board.ts
CHANGED
|
@@ -72,6 +72,10 @@ export interface HistoryEntry {
|
|
|
72
72
|
reviewCost?: ReviewCost;
|
|
73
73
|
/** #223 R4: on a verdict, whether the reviewer's context was new for it; set by the hub, never by the reviewer. */
|
|
74
74
|
reviewContext?: "fresh" | "continuing";
|
|
75
|
+
/** #852: a verdict by a `code`-scope reviewer says so; a `full` reviewer's verdict carries nothing, so existing rows are unchanged. */
|
|
76
|
+
reviewScope?: "code";
|
|
77
|
+
/** #852 AC3: a `code`-scope reviewer's changes_requested with no open finding. It is recorded, returns the task to its owner and counts toward nothing. */
|
|
78
|
+
evidenceOnly?: true;
|
|
75
79
|
/** Structured review report bound to the reviewed artifact, stored in the existing history JSON. */
|
|
76
80
|
reviewFindings?: ReviewFindingSet;
|
|
77
81
|
/**
|
|
@@ -259,6 +263,8 @@ export class Board {
|
|
|
259
263
|
this.db.run("CREATE TABLE IF NOT EXISTS task_stalls (task INTEGER PRIMARY KEY, payload TEXT NOT NULL)");
|
|
260
264
|
// #481 S7: one row per log-tier band breach episode already announced, so a restart never repeats the notice.
|
|
261
265
|
this.db.run("CREATE TABLE IF NOT EXISTS band_notices (marker TEXT PRIMARY KEY, at INTEGER NOT NULL)");
|
|
266
|
+
// #843 AC3: one row per (agent id, installed version, planner) already queued, so a restart never repeats it.
|
|
267
|
+
this.db.run("CREATE TABLE IF NOT EXISTS agent_drift_notices (marker TEXT PRIMARY KEY, at INTEGER NOT NULL)");
|
|
262
268
|
// Derived status-cache generation: only Board task/stall writes advance it, never journal writes.
|
|
263
269
|
this.db.run("CREATE TABLE IF NOT EXISTS board_status_revision (id INTEGER PRIMARY KEY CHECK(id=1), revision INTEGER NOT NULL)");
|
|
264
270
|
this.db.run("INSERT OR IGNORE INTO board_status_revision VALUES (1,0)");
|
|
@@ -373,6 +379,12 @@ export class Board {
|
|
|
373
379
|
if (!/^[a-z_]{1,64}:#[1-9][0-9]{0,9}:log$/.test(marker)) throw new Error("invalid band notice marker");
|
|
374
380
|
return this.db.query("INSERT OR IGNORE INTO band_notices(marker, at) VALUES(?, ?)").run(marker, Date.now()).changes > 0;
|
|
375
381
|
}
|
|
382
|
+
/** True when the (id, version, planner) marker was new and is now recorded; false when it was announced before. */
|
|
383
|
+
recordAgentDriftNotice(marker: string): boolean {
|
|
384
|
+
if (!/^[a-z][a-z0-9-]{0,31}:[0-9A-Za-z][0-9A-Za-z.+_-]{0,63}@[0-9A-Za-z][0-9A-Za-z._-]{0,63}$/.test(marker)) throw new Error("invalid agent drift marker");
|
|
385
|
+
return this.db.query("INSERT OR IGNORE INTO agent_drift_notices(marker, at) VALUES(?, ?)").run(marker, Date.now()).changes > 0;
|
|
386
|
+
}
|
|
387
|
+
forgetAgentDriftNotice(marker: string): void { this.db.query("DELETE FROM agent_drift_notices WHERE marker=?").run(marker); }
|
|
376
388
|
clearStall(task: number): void { this.taskWrite(() => this.db.query("DELETE FROM task_stalls WHERE task=?").run(task)); }
|
|
377
389
|
stalls(): TaskStall[] {
|
|
378
390
|
return (this.db.query("SELECT payload FROM task_stalls ORDER BY task").all() as { payload: string }[]).flatMap(row => {
|
|
@@ -398,7 +410,7 @@ export class Board {
|
|
|
398
410
|
|
|
399
411
|
/** The only way a task changes. Validates the move, records who did what, returns the new row. */
|
|
400
412
|
/** A `plan` in the patch replaces the old one whole: a new plan is the owner's current intent, not an addition. */
|
|
401
|
-
update(id: number, by: PeerId, event: string, patch: Partial<Pick<Task, "state" | "owner" | "planner" | "reviewer" | "reserved" | "refs" | "plan" | "rejections" | "signals" | "sdlc" | "role" | "cancelled" | "codeSymbols">>, note?: string, extra: Pick<HistoryEntry, "plannerReason" | "planAuthor" | "selfReview" | "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 {
|
|
413
|
+
update(id: number, by: PeerId, event: string, patch: Partial<Pick<Task, "state" | "owner" | "planner" | "reviewer" | "reserved" | "refs" | "plan" | "rejections" | "signals" | "sdlc" | "role" | "cancelled" | "codeSymbols">>, note?: string, extra: Pick<HistoryEntry, "plannerReason" | "planAuthor" | "selfReview" | "profile" | "sweep" | "reason" | "from" | "capacity" | "withheld" | "noWrites" | "reviewCost" | "reviewContext" | "reviewScope" | "evidenceOnly" | "reviewFindings" | "unmetCriteria" | "deliverables" | "planObservation" | "planDeparture" | "planDepartureInvalidated" | keyof ReviewerHistory> = {}, expectedFlowRevision?: number, codeSymbolsAuthority?: typeof HOST_CODE_SYMBOLS): Task {
|
|
402
414
|
const task = this.get(id);
|
|
403
415
|
if (!task) throw new Error(`no task #${id}`);
|
|
404
416
|
if (expectedFlowRevision !== undefined && (!Number.isSafeInteger(expectedFlowRevision) || expectedFlowRevision < 0 || task.history.length !== expectedFlowRevision)) throw new Error("task flow revision changed");
|
|
@@ -454,7 +466,7 @@ export class Board {
|
|
|
454
466
|
// Disabling reporting must not let plan or repository refs edits/reverts resurrect prior consent.
|
|
455
467
|
const repositoryRefsChanged = patch.refs !== undefined && JSON.stringify([task.refs.repo, task.refs.paths]) !== JSON.stringify([next.refs.repo, next.refs.paths]);
|
|
456
468
|
const departureInvalidated = extra.planDepartureInvalidated === true || hasDeparture && (planChanged || repositoryRefsChanged);
|
|
457
|
-
const history = [...task.history, { at: Date.now(), by, event, ...plannerHistory, ...authorHistory, ...(extra.selfReview === true ? { selfReview: true as const } : {}), ...(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 } : {}) }];
|
|
469
|
+
const history = [...task.history, { at: Date.now(), by, event, ...plannerHistory, ...authorHistory, ...(extra.selfReview === true ? { selfReview: true as const } : {}), ...(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.reviewScope === "code" ? { reviewScope: "code" as const } : {}), ...(extra.evidenceOnly === true && event === "changes_requested" ? { evidenceOnly: true as const } : {}), ...(extra.reviewCost ? { reviewCost: extra.reviewCost } : {}), ...(event === "done" && extra.noWrites === true ? { noWrites: true as const } : {}) }];
|
|
458
470
|
this.taskWrite(() => {
|
|
459
471
|
const result = this.db.query("UPDATE tasks SET state = ?, owner = ?, reviewer = ?, reserved = ?, refs = ?, plan = ?, rejections = ?, signals = ?, history = ?, sdlc = ?, task_role = ?, cancelled = ?, updated = ?, planner = ? WHERE id = ?")
|
|
460
472
|
.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(), planner, id);
|