@staix/agent-hub 0.12.45 → 0.14.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 +31 -0
- package/README.md +85 -116
- package/docs/agent-notes/README.md +6 -0
- package/docs/agent-notes/adapters.md +10 -7
- package/docs/agent-notes/budget.md +7 -1
- package/docs/agent-notes/bus.md +6 -4
- package/docs/agent-notes/daemon.md +14 -2
- package/docs/agent-notes/local-worker.md +2 -1
- package/docs/agent-notes/tasks.md +16 -3
- package/docs/agent-notes/tests.md +5 -3
- package/docs/dogfood.md +95 -0
- package/docs/events.md +14 -1
- package/docs/operations.md +290 -27
- package/docs/profiles.md +2 -2
- package/docs/quickstart.md +13 -3
- package/docs/sdlc.md +59 -8
- package/docs/security.md +26 -13
- package/docs/smoke.md +9 -4
- package/docs/specs/2026-09-19-agent-hub-design.md +797 -8
- package/docs/verification/2026-10-19-reviewer-isolation.md +2 -0
- package/docs/verification/2026-10-22-code-facts.md +78 -0
- package/docs/verification/2026-10-22-codex-reviewer-isolation.md +61 -0
- package/docs/verification/2026-10-22-kimi-qwen-compat.md +79 -0
- package/docs/verification/2026-10-23-0.13.0.md +89 -0
- package/docs/verification/2026-10-23-0.14.0.md +69 -0
- package/docs/verification/dogfood/2026-10-22.md +3109 -0
- package/docs/verified.json +22 -17
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +702 -347
- package/src/adapters/agy/entry.ts +5 -4
- package/src/adapters/claude-channel.ts +20 -9
- package/src/adapters/codex-appserver.ts +18 -2
- package/src/adapters/codex-review-isolation.ts +29 -11
- package/src/adapters/compat.ts +140 -0
- package/src/adapters/definitions/builtin-metadata.ts +3 -2
- package/src/adapters/definitions/native-cli-doctor.ts +12 -5
- package/src/adapters/definitions/native-cli-entry-codex.ts +13 -0
- package/src/adapters/definitions/native-cli-entry-jcode.ts +9 -6
- package/src/adapters/definitions/native-cli-jcode-mcp.ts +9 -2
- package/src/adapters/definitions/native-cli-preview-common.ts +2 -0
- package/src/adapters/definitions/native-cli-preview-jcode.ts +8 -2
- package/src/adapters/definitions/native-cli-preview-qwen.ts +2 -1
- package/src/adapters/definitions/qwen-native.ts +7 -3
- package/src/adapters/definitions/qwen.ts +2 -1
- package/src/adapters/jcode.ts +72 -31
- package/src/adapters/kimi-review.ts +6 -3
- package/src/adapters/registry-types.ts +3 -1
- package/src/adapters/registry.ts +1 -0
- package/src/adapters/reviewer-print.ts +34 -7
- package/src/cli/agents.ts +40 -0
- package/src/cli/console-review.ts +54 -0
- package/src/cli/console-state.ts +124 -29
- package/src/cli/console-symbols.ts +74 -0
- package/src/cli/console-usage.ts +89 -0
- package/src/cli/console.ts +26 -1
- package/src/cli/help.ts +17 -6
- package/src/cli/identity.ts +29 -6
- package/src/cli/init.ts +109 -11
- package/src/cli/main.ts +127 -11
- package/src/cli/output.ts +91 -3
- package/src/cli/preview.ts +1 -1
- package/src/cli/sdlc-release-guard.ts +82 -0
- package/src/cli/status-lines.ts +1 -1
- package/src/hub/action-class.ts +362 -0
- package/src/hub/action-policy.ts +106 -0
- package/src/hub/board.ts +65 -9
- package/src/hub/budget.ts +71 -2
- package/src/hub/bus.ts +43 -3
- package/src/hub/checks.ts +80 -31
- package/src/hub/code-facts.ts +223 -0
- package/src/hub/config-apply.ts +10 -1
- package/src/hub/config-trust.ts +7 -4
- package/src/hub/daemon.ts +319 -34
- package/src/hub/dogfood-read.ts +199 -0
- package/src/hub/dogfood.ts +163 -0
- package/src/hub/envelope.ts +1 -1
- package/src/hub/events.ts +11 -2
- package/src/hub/hub-tools.ts +26 -3
- package/src/hub/inbox-polling.ts +25 -0
- package/src/hub/instances.ts +87 -5
- package/src/hub/manager.ts +2 -2
- package/src/hub/profiles.ts +7 -2
- package/src/hub/release-worktree.ts +56 -0
- package/src/hub/review-brief.ts +36 -0
- package/src/hub/sdlc-metrics.ts +260 -0
- package/src/hub/sdlc-queue.ts +31 -9
- package/src/hub/sdlc-release-authorization.ts +173 -0
- package/src/hub/sdlc.ts +54 -14
- package/src/hub/settings.ts +5 -2
- package/src/hub/snapshots.ts +93 -8
- package/src/hub/spec-read.ts +31 -0
- package/src/hub/symbol-normalizer.ts +26 -0
- package/src/hub/task-code-group.ts +52 -0
- package/src/hub/task-code-symbols.ts +90 -0
- package/src/hub/task-sweep.ts +3 -2
- package/src/hub/tasks.ts +455 -67
- package/src/hub/test-integrity.ts +162 -0
- package/src/hub/ui.ts +3 -0
- package/src/hub/usage-rollup.ts +419 -0
- package/src/local/sandbox.ts +5 -3
- package/src/ui/index.html +274 -2
- package/templates/AGENTS.block.md +1 -1
- package/templates/profiles/dev.json +6 -1
- package/templates/profiles/general.json +6 -1
- package/templates/profiles/research.json +6 -1
- package/templates/profiles/writing.json +6 -1
- package/templates/release-guard.block.md +6 -0
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { describe } from "../compat.ts";
|
|
1
2
|
import { join } from "node:path";
|
|
2
3
|
import type { Launch } from "../../cli/launch.ts";
|
|
3
4
|
import { REDACTED, UNRESOLVED, nativeCommand, previewState, finishPreview, type NativePreviewContext } from "./native-cli-preview-common.ts";
|
|
@@ -16,7 +17,7 @@ export function previewQwen(ctx: NativePreviewContext) {
|
|
|
16
17
|
if (sessionId) unresolved.push({ field: "sessionId", reason: "requested through ACP session/load at start; preview does not load or expose it" });
|
|
17
18
|
launch = adapter.start.launch!({ instance: tool, form: "headless", projectRoot: cwd, stateDir, entrypoint: join(cliDir, "main.js"),
|
|
18
19
|
userArgs: [], unattended: false, env: {}, preview: true, nativeCommand: nativeCommand(config.qwen_cmd, ["qwen", "--acp"]), model: model ? REDACTED : undefined });
|
|
19
|
-
unresolved.push({ field: "nativeIdentity", reason:
|
|
20
|
+
unresolved.push({ field: "nativeIdentity", reason: `Qwen Code (${describe("qwen")}) identity and permission modes are checked at ACP initialization; preview starts nothing` });
|
|
20
21
|
|
|
21
22
|
return finishPreview(ctx, launch, envNames, unresolved, undefined);
|
|
22
23
|
}
|
|
@@ -1,16 +1,20 @@
|
|
|
1
|
+
import { assessVersion } from "../compat.ts";
|
|
1
2
|
import type { PermissionMode } from "../../hub/permission-mode.ts";
|
|
2
3
|
import type { Launch } from "../../cli/launch.ts";
|
|
3
4
|
|
|
4
5
|
export const QWEN_MODE_IDS: Readonly<Record<PermissionMode, string>> = { ask: "default", "ask-when-needed": "auto-edit", "never-ask": "yolo" };
|
|
5
6
|
|
|
6
7
|
export class QwenIdentityError extends Error {
|
|
7
|
-
constructor() { super("Qwen Code
|
|
8
|
+
constructor(reason?: string) { super(reason ? `Qwen Code native identity is not verified: ${reason}` : "Qwen Code native identity is not verified; check the executable and its advertised ACP version"); }
|
|
8
9
|
}
|
|
9
10
|
|
|
10
11
|
export function qwenNativeIdentity(instance: string, production: boolean | undefined, init: unknown, kind?: string): boolean {
|
|
11
12
|
const native = init as { agentInfo?: { name?: unknown; version?: unknown } } | null | undefined;
|
|
12
|
-
const
|
|
13
|
-
|
|
13
|
+
const identity = production === true && (kind ?? instance) === "qwen" && native?.agentInfo?.name === "qwen-code";
|
|
14
|
+
// #691: a version refusal carries the compat reason (observed and verified versions, the trust command).
|
|
15
|
+
const version = identity ? assessVersion("qwen", native?.agentInfo?.version) : undefined;
|
|
16
|
+
if (production && !version?.ok) throw new QwenIdentityError(version && !version.ok ? version.reason : undefined);
|
|
17
|
+
const verified = !!version?.ok;
|
|
14
18
|
return verified;
|
|
15
19
|
}
|
|
16
20
|
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { describe } from "../compat.ts";
|
|
1
2
|
import { runQwenEntry } from "./native-cli-entry-qwen.ts";
|
|
2
3
|
import { previewQwen } from "./native-cli-preview-qwen.ts";
|
|
3
4
|
import { QWEN_DISPLAY } from "./native-display.ts";
|
|
@@ -67,7 +68,7 @@ export const QWEN_DEFINITION: AdapterDefinition = {
|
|
|
67
68
|
turnEnd: "adapter",
|
|
68
69
|
budget: { account: "qwen" },
|
|
69
70
|
capabilities: { watch: true },
|
|
70
|
-
cli: { command: "qwen", entry: { lifecycleAdmission: true, execute: runQwenEntry }, preview: previewQwen, usage: [["ahub qwen [--mode headless] [--model <alias>] [--session-id <id>] [--print-command]",
|
|
71
|
+
cli: { command: "qwen", entry: { lifecycleAdmission: true, execute: runQwenEntry }, preview: previewQwen, usage: [["ahub qwen [--mode headless] [--model <alias>] [--session-id <id>] [--print-command]", `start optional Qwen Code ACP (${describe("qwen")}; roster.qwen, default off)`]], launchConfigKeys: ["qwen_cmd"], startWords: () => ["qwen"] },
|
|
71
72
|
display: QWEN_DISPLAY,
|
|
72
73
|
hooks: { recallPlatform: "qwen" },
|
|
73
74
|
};
|
package/src/adapters/jcode.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { spawn, spawnSync, type ChildProcess } from "node:child_process";
|
|
2
|
+
import { assessVersion, COMPAT, type CompatVerdict } from "./compat.ts";
|
|
2
3
|
import { createConnection } from "node:net";
|
|
3
4
|
import { closeSync, constants, existsSync, fstatSync, lstatSync, mkdtempSync, openSync, readSync, rmSync } from "node:fs";
|
|
4
5
|
import { tmpdir } from "node:os";
|
|
@@ -12,21 +13,25 @@ export interface JcodeOptions extends Omit<AcpOptions, "cmd" | "qwen025" | "expe
|
|
|
12
13
|
command: string[];
|
|
13
14
|
home: string;
|
|
14
15
|
providerProfile?: string;
|
|
16
|
+
/** The hub peer id this Jcode acts for: "jcode" for the base peer, an instance id for an extra one (#685). */
|
|
17
|
+
peer?: string;
|
|
18
|
+
/** Initial native provider (`-p`), passed exactly as the person wrote it (#685). The hub never handles model credentials. */
|
|
19
|
+
provider?: string;
|
|
15
20
|
/** How long a native steer may take to be acknowledged (#572); default 5 s. */
|
|
16
21
|
steerTimeoutMs?: number;
|
|
17
22
|
}
|
|
18
|
-
export function jcodeCommand(command: string[], kind: "serve" | "acp", socket: string, cwd: string, model?: string, profile?: string): string[] {
|
|
23
|
+
export function jcodeCommand(command: string[], kind: "serve" | "acp", socket: string, cwd: string, model?: string, profile?: string, provider?: string, peer = "jcode"): string[] {
|
|
19
24
|
if (!command.length || command.some(word => typeof word !== "string" || !word)) throw new Error("jcode_cmd must name an executable argv");
|
|
20
|
-
return [...command, kind, "--socket", socket, "--cwd", cwd, "--no-update", "--no-selfdev", ...(model ? ["--model", model] : []), ...(profile ? ["--provider-profile", profile] : []),
|
|
21
|
-
...(kind === "serve" ? ["--temporary-server", "--owner-pid", String(process.pid), "--server-name",
|
|
25
|
+
return [...command, kind, "--socket", socket, "--cwd", cwd, "--no-update", "--no-selfdev", ...(provider ? ["-p", provider] : []), ...(model ? ["--model", model] : []), ...(profile ? ["--provider-profile", profile] : []),
|
|
26
|
+
...(kind === "serve" ? ["--temporary-server", "--owner-pid", String(process.pid), "--server-name", `agent-hub-${peer}`] : ["--mcp-tools", "eager"])];
|
|
22
27
|
}
|
|
23
28
|
export interface JcodeTuiBinding { launchId: string; sessionId: string }
|
|
24
29
|
export type JcodeTuiState = { state: "waiting"; binding: JcodeTuiBinding } |
|
|
25
30
|
{ state: "ready"; binding: JcodeTuiBinding; launch: { cmd: string; args: string[]; env: NodeJS.ProcessEnv } };
|
|
26
31
|
|
|
27
|
-
export function jcodeTuiCommand(command: string[], socket: string, sessionId: string, model?: string, profile?: string): string[] {
|
|
32
|
+
export function jcodeTuiCommand(command: string[], socket: string, sessionId: string, model?: string, profile?: string, provider?: string): string[] {
|
|
28
33
|
if (!command.length || command.some(word => typeof word !== "string" || !word)) throw new Error("jcode_cmd must name an executable argv");
|
|
29
|
-
return [...command, "--socket", socket, "--resume", sessionId, "--no-update", "--no-selfdev",
|
|
34
|
+
return [...command, "--socket", socket, "--resume", sessionId, "--no-update", "--no-selfdev", ...(provider ? ["-p", provider] : []),
|
|
30
35
|
...(model ? ["--model", model] : []), ...(profile ? ["--provider-profile", profile] : [])];
|
|
31
36
|
}
|
|
32
37
|
|
|
@@ -65,17 +70,8 @@ export class JcodeOptionMismatch extends Error {
|
|
|
65
70
|
constructor(readonly option: "--session-id" | "--model" | "--provider-profile") { super("Jcode launch options differ from its running session"); }
|
|
66
71
|
}
|
|
67
72
|
const JCODE_OWNED_ENV = { JCODE_DISABLE_CLAUDE_MCP: "1", JCODE_NO_TELEMETRY: "1" } as const;
|
|
68
|
-
/**
|
|
69
|
-
export const
|
|
70
|
-
/** The first native release assumed to break that contract: the next major. Lower it when a release is shown to break. */
|
|
71
|
-
export const JCODE_BREAKING_VERSION = "1.0.0";
|
|
72
|
-
const release = (version: string) => /^(\d+)\.(\d+)\.(\d+)$/.exec(version)?.slice(1).map(Number);
|
|
73
|
-
const compare = (a: number[], b: number[]) => a[0]! - b[0]! || a[1]! - b[1]! || a[2]! - b[2]!;
|
|
74
|
-
/** A plain release from JCODE_MIN_VERSION up to, not including, JCODE_BREAKING_VERSION; the minor is not pinned (#529). */
|
|
75
|
-
export function jcodeVersionSupported(version: unknown): boolean {
|
|
76
|
-
const v = typeof version === "string" ? release(version) : undefined;
|
|
77
|
-
return !!v && compare(v, release(JCODE_MIN_VERSION)!) >= 0 && compare(v, release(JCODE_BREAKING_VERSION)!) < 0;
|
|
78
|
-
}
|
|
73
|
+
/** #691: the verdict lives in `src/adapters/compat.ts` (line, verified versions, the person's trust); never pin here. */
|
|
74
|
+
export const jcodeVersionSupported = (version: unknown): boolean => assessVersion("jcode", version).ok;
|
|
79
75
|
|
|
80
76
|
/** A native steer the server did not take, with nothing sent: the message may safely stay queued. */
|
|
81
77
|
class SteerRefused extends Error {}
|
|
@@ -137,20 +133,56 @@ function nativeSteer(socket: string, cwd: string, sessionId: string, text: strin
|
|
|
137
133
|
}
|
|
138
134
|
|
|
139
135
|
/** The wrapper contract preserves every configured prefix argument before the native probe. */
|
|
140
|
-
export function
|
|
136
|
+
export function jcodeVersion(command: string[], cwd: string): CompatVerdict {
|
|
141
137
|
const [bin, ...prefix] = command;
|
|
142
|
-
if (!bin) return
|
|
138
|
+
if (!bin) return assessVersion("jcode", undefined);
|
|
143
139
|
try {
|
|
144
140
|
const result = spawnSync(bin, [...prefix, "--quiet", "version", "--json"], { cwd, env: { ...peerChildEnv("jcode"), ...JCODE_OWNED_ENV }, encoding: "utf8", timeout: 10_000, maxBuffer: 64 * 1024 });
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
141
|
+
return assessVersion("jcode", result.status === 0 ? COMPAT.jcode.parse(result.stdout) : undefined);
|
|
142
|
+
} catch { return assessVersion("jcode", undefined); }
|
|
143
|
+
}
|
|
144
|
+
export const verifiedJcodeCommand = (command: string[], cwd: string): boolean => jcodeVersion(command, cwd).ok;
|
|
145
|
+
/** Collects at most `cap` bytes; the chunk that would pass it, and every later one, is refused and not kept. */
|
|
146
|
+
export function boundedOutput(cap: number): { push(chunk: Buffer): boolean; text(): string; readonly size: number; readonly overflowed: boolean } {
|
|
147
|
+
const chunks: Buffer[] = [];
|
|
148
|
+
let size = 0, overflowed = false;
|
|
149
|
+
return {
|
|
150
|
+
push(chunk) { if (overflowed || size + chunk.length > cap) { overflowed = true; return false; } chunks.push(chunk); size += chunk.length; return true; },
|
|
151
|
+
text: () => Buffer.concat(chunks).toString("utf8"),
|
|
152
|
+
get size() { return size; },
|
|
153
|
+
get overflowed() { return overflowed; },
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* The daemon's preflight: the same probe without blocking the event loop (a slow wrapper must not stall peers). The probe owns its
|
|
158
|
+
* process group: at the deadline, or once stdout passes 64 KiB, the whole group is stopped, descendants included (kimi-review's pattern).
|
|
159
|
+
*/
|
|
160
|
+
export function jcodeVersionAsync(command: string[], cwd: string, timeoutMs = 10_000): Promise<CompatVerdict> {
|
|
161
|
+
const [bin, ...prefix] = command;
|
|
162
|
+
if (!bin) return Promise.resolve(assessVersion("jcode", undefined));
|
|
163
|
+
return new Promise(resolve => {
|
|
164
|
+
let child: ChildProcess;
|
|
165
|
+
try { child = spawn(bin, [...prefix, "--quiet", "version", "--json"], { cwd, env: { ...peerChildEnv("jcode"), ...JCODE_OWNED_ENV }, detached: true, stdio: ["ignore", "pipe", "ignore"] }); }
|
|
166
|
+
catch { resolve(assessVersion("jcode", undefined)); return; }
|
|
167
|
+
const output = boundedOutput(64 * 1024);
|
|
168
|
+
let done = false;
|
|
169
|
+
const finish = async (code: number | null) => {
|
|
170
|
+
if (done) return; done = true; clearTimeout(timer);
|
|
171
|
+
try { await stopOwnedProcess(child, { group: true }); } catch { resolve(assessVersion("jcode", undefined)); return; }
|
|
172
|
+
resolve(assessVersion("jcode", code === 0 && !output.overflowed ? COMPAT.jcode.parse(output.text()) : undefined));
|
|
173
|
+
};
|
|
174
|
+
const timer = setTimeout(() => { void finish(null); }, timeoutMs);
|
|
175
|
+
child.once("spawn", () => trackGroup(child));
|
|
176
|
+
// Chunks after the cap or after settling are drained and discarded, never buffered.
|
|
177
|
+
child.stdout!.on("data", (bytes: Buffer) => { if (!done && !output.push(bytes)) void finish(null); });
|
|
178
|
+
child.once("error", () => { void finish(null); });
|
|
179
|
+
child.once("close", code => { void finish(code); });
|
|
180
|
+
});
|
|
149
181
|
}
|
|
150
182
|
|
|
151
183
|
/** Both processes are ours. The user's global daemon and its sockets are never stopped. */
|
|
152
184
|
export class JcodePeer extends AcpPeer {
|
|
153
|
-
private readonly launchId
|
|
185
|
+
private readonly launchId: string;
|
|
154
186
|
private socketIdentity: { dev: number; ino: number } | undefined;
|
|
155
187
|
private readonly root: string;
|
|
156
188
|
private readonly socket: string;
|
|
@@ -161,21 +193,28 @@ export class JcodePeer extends AcpPeer {
|
|
|
161
193
|
|
|
162
194
|
constructor(private readonly jcode: JcodeOptions) {
|
|
163
195
|
if (!isAbsolute(jcode.home)) throw new Error("jcode_home must be an absolute native data path");
|
|
196
|
+
const peer = jcode.peer ?? "jcode";
|
|
164
197
|
jcodeCommand(jcode.command, "acp", "preview", jcode.cwd);
|
|
165
198
|
// The name must end in ".sock": jcode names its debug socket by replacing that suffix, so "s" binds one path twice.
|
|
166
199
|
const root = mkdtempSync(join(tmpdir(), "ahub-jcode-")), socket = join(root, "j.sock");
|
|
167
|
-
const cmd = jcodeCommand(jcode.command, "acp", socket, jcode.cwd, jcode.launchModel, jcode.providerProfile);
|
|
168
|
-
|
|
169
|
-
this.
|
|
200
|
+
const cmd = jcodeCommand(jcode.command, "acp", socket, jcode.cwd, jcode.launchModel, jcode.providerProfile, jcode.provider, peer);
|
|
201
|
+
const launchId = crypto.randomUUID();
|
|
202
|
+
// The ACP child gets peerChildEnv(this.id): the instance id, so its hub tools act as this peer and never as the base Jcode. The launch id
|
|
203
|
+
// goes with it: the daemon admits a hub-tools hello for this peer only when it carries the id of this peer's current owned launch (#368).
|
|
204
|
+
super(peer, { ...jcode, cmd, env: { ...jcode.env, JCODE_HOME: jcode.home, JCODE_SOCKET: socket, AGENTHUB_LAUNCH_ID: launchId, ...JCODE_OWNED_ENV }, mcpServers: [], requireLoadSessionEcho: true, expectedAgent: { name: "jcode", version: jcodeVersionSupported } });
|
|
205
|
+
this.root = root; this.socket = socket; this.launchId = launchId;
|
|
170
206
|
}
|
|
171
207
|
|
|
208
|
+
/** True only for the id of this peer's current owned launch, while it is not ending: the proof a hub-tools hello must carry (#368). */
|
|
209
|
+
ownsLaunch(launchId: unknown): boolean { return !this.ending && typeof launchId === "string" && launchId === this.launchId; }
|
|
210
|
+
|
|
172
211
|
override async start(): Promise<void> {
|
|
173
212
|
// The native loader otherwise imports Codex MCP into the user's store on first use.
|
|
174
213
|
if (!existsSync(join(this.jcode.home, "mcp.json"))) throw new Error("initialize Jcode's global MCP store with its native client before enabling this peer; the hub never initializes or copies it");
|
|
175
214
|
if (existsSync(this.socket)) { this.preserveRoot = true; throw new Error("Jcode private socket is occupied; no daemon was adopted or removed"); }
|
|
176
215
|
this.ending = false;
|
|
177
|
-
const [bin, ...args] = jcodeCommand(this.jcode.command, "serve", this.socket, this.jcode.cwd, this.jcode.launchModel, this.jcode.providerProfile);
|
|
178
|
-
const server = spawn(bin!, args, { cwd: this.jcode.cwd, detached: true, env: { ...peerChildEnv(
|
|
216
|
+
const [bin, ...args] = jcodeCommand(this.jcode.command, "serve", this.socket, this.jcode.cwd, this.jcode.launchModel, this.jcode.providerProfile, this.jcode.provider, this.id);
|
|
217
|
+
const server = spawn(bin!, args, { cwd: this.jcode.cwd, detached: true, env: { ...peerChildEnv(this.id), JCODE_HOME: this.jcode.home, JCODE_SOCKET: this.socket, JCODE_READY_FD: "3", AGENTHUB_LAUNCH_ID: this.launchId, ...JCODE_OWNED_ENV }, stdio: ["ignore", "ignore", "ignore", "pipe"] });
|
|
179
218
|
this.server = server; trackGroup(server);
|
|
180
219
|
try {
|
|
181
220
|
await new Promise<void>((resolve, reject) => {
|
|
@@ -279,11 +318,13 @@ export class JcodePeer extends AcpPeer {
|
|
|
279
318
|
: options.providerProfile !== undefined && options.providerProfile !== this.jcode.providerProfile ? "--provider-profile" : undefined;
|
|
280
319
|
if (differs) throw new JcodeOptionMismatch(differs);
|
|
281
320
|
if (!savedSession(this.jcode.home, sessionId)) return { state: "waiting", binding };
|
|
282
|
-
const [cmd, ...args] = jcodeTuiCommand(this.jcode.command, this.socket, sessionId, this.jcode.launchModel, this.jcode.providerProfile);
|
|
283
|
-
|
|
321
|
+
const [cmd, ...args] = jcodeTuiCommand(this.jcode.command, this.socket, sessionId, this.jcode.launchModel, this.jcode.providerProfile, this.jcode.provider);
|
|
322
|
+
// The TUI is the person's own client of this peer's server. The shared project MCP entry names no peer (#685), so every launch
|
|
323
|
+
// path supplies its own id: the base Jcode's too, or its MCP child would fall back to the channel default.
|
|
324
|
+
return { state: "ready", binding, launch: { cmd: cmd!, args, env: { JCODE_HOME: this.jcode.home, JCODE_SOCKET: this.socket, AGENTHUB_PEER_ID: this.id, AGENTHUB_LAUNCH_ID: this.launchId, ...JCODE_OWNED_ENV } } };
|
|
284
325
|
}
|
|
285
326
|
override recoveryMetadata(): Record<string, unknown> {
|
|
286
|
-
return { ...super.recoveryMetadata(), launch: { kind: "jcode", peer:
|
|
327
|
+
return { ...super.recoveryMetadata(), launch: { kind: "jcode", peer: this.id, mode: "headless", ...(this.jcode.launchModel ? { model: this.jcode.launchModel } : {}), ...(this.jcode.providerProfile ? { providerProfile: this.jcode.providerProfile } : {}) } };
|
|
287
328
|
}
|
|
288
329
|
}
|
|
289
330
|
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { spawn, type ChildProcess } from "node:child_process";
|
|
2
|
+
import { assessVersion, COMPAT } from "./compat.ts";
|
|
2
3
|
import { createHash } from "node:crypto";
|
|
3
4
|
import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
|
4
5
|
import { homedir, tmpdir } from "node:os";
|
|
@@ -7,7 +8,7 @@ import { stopOwnedProcess, trackGroup } from "../hub/child-process.ts";
|
|
|
7
8
|
import { realPath } from "../hub/project.ts";
|
|
8
9
|
import type { ReviewerBackend, ReviewerPrepareContext } from "./reviewer-print.ts";
|
|
9
10
|
|
|
10
|
-
const
|
|
11
|
+
const MANAGED = "managed:kimi-code";
|
|
11
12
|
const object = (v: unknown): v is Record<string, unknown> => !!v && typeof v === "object" && !Array.isArray(v);
|
|
12
13
|
const hash = (text: string) => createHash("sha256").update(text).digest("hex");
|
|
13
14
|
/** Native discovery can walk above cwd; check lexical aliases as well as the physical temporary root. */
|
|
@@ -133,7 +134,9 @@ export const kimiReviewerBackend: ReviewerBackend = {
|
|
|
133
134
|
const executable = realPath(Bun.which(native[0]!, { PATH: process.env.PATH }) ?? resolve(native[0]!));
|
|
134
135
|
const command = [executable, ...native.slice(1)];
|
|
135
136
|
const executableStat = lstatSync(executable);
|
|
136
|
-
|
|
137
|
+
// #691: the verified versions and the person's trust live in compat.ts.
|
|
138
|
+
const verdict = assessVersion("kimi-review", COMPAT["kimi-review"].parse(await version(command, cwd, env, context.signal)));
|
|
139
|
+
if (!verdict.ok) throw new Error(verdict.reason);
|
|
137
140
|
const fingerprints = [configFile, mcpFile, agentFile].map(file => hash(read(file, 2_097_152)));
|
|
138
141
|
let cleaned = false;
|
|
139
142
|
const cleanup = () => { if (cleaned) return; rmSync(root, { recursive: true, force: true }); cleaned = true; };
|
|
@@ -149,7 +152,7 @@ export const kimiReviewerBackend: ReviewerBackend = {
|
|
|
149
152
|
&& ["credentials", "oauth", "device_id"].every((name, index) => realPath(join(source, name)) === sources[index] && realPath(join(tool, name)) === sources[index]);
|
|
150
153
|
} catch { return false; }
|
|
151
154
|
},
|
|
152
|
-
isolation: { backend: "kimi", version:
|
|
155
|
+
isolation: { backend: "kimi", version: verdict.version, binary: executable, profile: "private-config-exact-two-tools-shared-native-oauth", readOnly: true, strictMcp: true, ephemeral: true,
|
|
153
156
|
enforcement: "native-policy", freshSession: true, hubToolsOnly: true,
|
|
154
157
|
evidence: ["#533 person ruling2026-10-19: native execution-time exact tool allowlist, no kernel sandbox", "private config/cwd/empty skills; shared OAuth directory symlinks and native lock/atomic rename", "task-bound in-process hub MCP; no full control token", "residual: interruption mid-refresh may require native re-login"] },
|
|
155
158
|
};
|
|
@@ -202,7 +202,9 @@ export interface AdapterDefinition {
|
|
|
202
202
|
};
|
|
203
203
|
identity: { mcp: "acp-session" | "project-file" | "env" | "channel-plugin" };
|
|
204
204
|
/** Declaration admission only; execution-root and runtime owner proof remain host responsibilities. */
|
|
205
|
-
instances?: { mode: "external" | "headless"; roles?: readonly string[]
|
|
205
|
+
instances?: { mode: "external" | "headless"; roles?: readonly string[];
|
|
206
|
+
/** Each declared instance runs its own native data home (`jcode_home`) and may pass its own provider, model and profile names (#685). Fixed builtins only. */
|
|
207
|
+
nativeHome?: true };
|
|
206
208
|
attach?: {
|
|
207
209
|
/** The authenticated external hello may claim this id; independent of MCP configuration shape. */
|
|
208
210
|
externalHello?: boolean;
|
package/src/adapters/registry.ts
CHANGED
|
@@ -232,6 +232,7 @@ export function assertNativeCapabilityScope(definition: AdapterDefinition): void
|
|
|
232
232
|
["start.daemonPort pi-rpc", definition.start.daemonPort === "pi-rpc"],
|
|
233
233
|
["routing", definition.routing !== undefined],
|
|
234
234
|
["projection", definition.projection !== undefined],
|
|
235
|
+
["instances.nativeHome", declared(definition.instances, "nativeHome")], // #685: a fixed-builtin assumption, one native data home per declared instance
|
|
235
236
|
["reviewerVariant", definition.reviewerVariant !== undefined],
|
|
236
237
|
["reviewer", definition.reviewer !== undefined],
|
|
237
238
|
["hooks.factsHook", declared(definition.hooks, "factsHook")],
|
|
@@ -6,6 +6,28 @@ import { BasePeer } from "../hub/peers.ts";
|
|
|
6
6
|
import type { Envelope } from "../hub/envelope.ts";
|
|
7
7
|
import type { ReviewerMcpSession } from "./reviewer-mcp.ts";
|
|
8
8
|
|
|
9
|
+
/** #722: the closed list of legs a reviewer's per-review start can fail on. A class names a leg, never a path, content or token. */
|
|
10
|
+
export const REVIEWER_CAUSE_CLASSES = ["version", "binary", "mcp_inventory", "plugins_hooks_apps", "sandbox_proof", "credential_root", "timeout", "unknown"] as const;
|
|
11
|
+
export type ReviewerCauseClass = typeof REVIEWER_CAUSE_CLASSES[number];
|
|
12
|
+
const REVIEWER_CAUSE_HINT: Record<ReviewerCauseClass, string> = {
|
|
13
|
+
version: "the CLI version is neither verified nor trusted; see ahub agents",
|
|
14
|
+
binary: "the CLI binary or a watched config file could not be bound, or changed during the proof; check the configured binary and retry",
|
|
15
|
+
mcp_inventory: "the MCP server inventory or the hub server entry did not match the proof",
|
|
16
|
+
plugins_hooks_apps: "plugins, hooks or apps could not be proven disabled",
|
|
17
|
+
sandbox_proof: "the native sandbox proof did not pass",
|
|
18
|
+
credential_root: "a host credential root is unusable; remove or repair it",
|
|
19
|
+
timeout: "a proof command timed out",
|
|
20
|
+
unknown: "unclassified; inspect hub.log",
|
|
21
|
+
};
|
|
22
|
+
/** The one wording for the unavailable reason, the log and `ahub status`. `detail` is only ever a credential-root path class. */
|
|
23
|
+
export function reviewerUnavailableReason(cause: ReviewerCauseClass, detail?: string): string {
|
|
24
|
+
return `reviewer isolation unavailable [cause: ${cause}]: ${REVIEWER_CAUSE_HINT[cause]}${detail ? ` (${detail})` : ""}`;
|
|
25
|
+
}
|
|
26
|
+
/** A prepare refusal that carries its class to the host; the message stays the backend's own text. */
|
|
27
|
+
export class ReviewerUnavailableError extends Error {
|
|
28
|
+
constructor(message: string, readonly causeClass: ReviewerCauseClass, readonly detail?: string) { super(message); }
|
|
29
|
+
}
|
|
30
|
+
|
|
9
31
|
export interface ReviewerPrintUsage { input_tokens?: number; output_tokens?: number; cache_creation_input_tokens?: number; cache_read_input_tokens?: number }
|
|
10
32
|
export interface ReviewerIsolationReceipt {
|
|
11
33
|
backend: string;
|
|
@@ -51,7 +73,7 @@ export interface ReviewerBackend {
|
|
|
51
73
|
id: string;
|
|
52
74
|
taskMcp?: true;
|
|
53
75
|
nativePolicy?: true;
|
|
54
|
-
prepare(context: ReviewerPrepareContext): Promise<ReviewerLaunch | { unavailable: string }>;
|
|
76
|
+
prepare(context: ReviewerPrepareContext): Promise<ReviewerLaunch | { unavailable: string; causeClass?: ReviewerCauseClass; detail?: string }>;
|
|
55
77
|
usage?(event: unknown): ReviewerPrintUsage | undefined;
|
|
56
78
|
failure?(event: unknown): "quota" | undefined;
|
|
57
79
|
}
|
|
@@ -60,6 +82,9 @@ export interface ReviewerFailure {
|
|
|
60
82
|
head?: string;
|
|
61
83
|
flowRevision?: number;
|
|
62
84
|
cause: "start_failed" | "quota" | "no_verdict";
|
|
85
|
+
/** Only for `start_failed`: the leg that refused (#722). */
|
|
86
|
+
causeClass?: ReviewerCauseClass;
|
|
87
|
+
causeDetail?: string;
|
|
63
88
|
}
|
|
64
89
|
|
|
65
90
|
export interface ReviewerReviewBinding { readonly head?: string; readonly flowRevision?: number }
|
|
@@ -93,8 +118,8 @@ const reviewTarget = (env: Envelope): ReviewTarget | undefined => {
|
|
|
93
118
|
const revision = Number(env.refs?.reviewRevision);
|
|
94
119
|
return { task, ...(typeof env.refs?.commit === "string" ? { head: env.refs.commit } : {}), ...(Number.isSafeInteger(revision) && revision > 0 ? { flowRevision: revision } : {}) };
|
|
95
120
|
};
|
|
96
|
-
const remainingFailures = (batch: ReviewBatch, start: number, cause: ReviewerFailure["cause"]): ReviewerFailure[] =>
|
|
97
|
-
batch.targets.slice(start).flatMap(target => target ? [{ ...target, cause }] : []);
|
|
121
|
+
const remainingFailures = (batch: ReviewBatch, start: number, cause: ReviewerFailure["cause"], why?: { causeClass: ReviewerCauseClass; causeDetail?: string }): ReviewerFailure[] =>
|
|
122
|
+
batch.targets.slice(start).flatMap(target => target ? [{ ...target, cause, ...(why ? { causeClass: why.causeClass, ...(why.causeDetail ? { causeDetail: why.causeDetail } : {}) } : {}) }] : []);
|
|
98
123
|
const reviewTask = (env: Envelope): number | undefined => {
|
|
99
124
|
if (env.from !== HUB || env.kind !== "review") return undefined;
|
|
100
125
|
const id = Number(env.refs?.task);
|
|
@@ -225,7 +250,9 @@ export class ReviewerPrintPeer extends BasePeer {
|
|
|
225
250
|
}
|
|
226
251
|
// Once an earlier review in this batch ran, the batch had effects: hold it for a person, never requeue it.
|
|
227
252
|
failure = { state: started ? "needs_review" : "failed_safe", reason: `reviewer could not start: ${(error as Error).message}` };
|
|
228
|
-
|
|
253
|
+
const why = error instanceof ReviewerUnavailableError ? { causeClass: error.causeClass, causeDetail: error.detail } : { causeClass: "unknown" as const };
|
|
254
|
+
this.opts.log?.(`[${this.id}] review of task #${task} could not start [cause: ${why.causeClass}]`);
|
|
255
|
+
reroute.push(...remainingFailures(batch, index, "start_failed", why));
|
|
229
256
|
batch.next = envs.length;
|
|
230
257
|
break;
|
|
231
258
|
}
|
|
@@ -273,14 +300,14 @@ export class ReviewerPrintPeer extends BasePeer {
|
|
|
273
300
|
mcpConfig: this.opts.backend.taskMcp ? (mcpConfig ?? '{"mcpServers":{}}') : this.opts.mcpConfig,
|
|
274
301
|
systemPrompt: this.opts.systemPrompt, privatePaths: this.opts.privatePaths ?? [], command: this.opts.command, signal: abort.signal,
|
|
275
302
|
...(input === undefined ? {} : { input }), ...(task === undefined ? {} : { task }) });
|
|
276
|
-
if ("unavailable" in launch) throw new
|
|
303
|
+
if ("unavailable" in launch) throw new ReviewerUnavailableError(launch.unavailable, launch.causeClass ?? "unknown", launch.detail);
|
|
277
304
|
const proof = launch.isolation;
|
|
278
305
|
const policy = this.opts.backend.nativePolicy
|
|
279
306
|
? proof?.enforcement === "native-policy" && proof.freshSession === true && proof.hubToolsOnly === true
|
|
280
307
|
: proof?.strictMcp === true && proof.ephemeral === true;
|
|
281
308
|
if (abort.signal.aborted || !proof || proof.backend !== this.opts.backend.id || proof.readOnly !== true || !policy || !proof.binary || !proof.version || !proof.profile || !proof.evidence.length || !launch.command.length) {
|
|
282
309
|
await launch.cleanup?.();
|
|
283
|
-
throw new
|
|
310
|
+
throw new ReviewerUnavailableError("reviewer isolation is unavailable or preparation was cancelled", "unknown");
|
|
284
311
|
}
|
|
285
312
|
return launch;
|
|
286
313
|
})();
|
|
@@ -307,7 +334,7 @@ export class ReviewerPrintPeer extends BasePeer {
|
|
|
307
334
|
const release = () => releasing ??= Promise.resolve().then(() => launch?.cleanup?.()).then(() => {});
|
|
308
335
|
try {
|
|
309
336
|
launch = await this.prepare(randomUUID(), input, task, mcp?.config);
|
|
310
|
-
if (generation !== this.generation || this.opts.admit?.(env) === false || launch.current?.() === false) throw new
|
|
337
|
+
if (generation !== this.generation || this.opts.admit?.(env) === false || launch.current?.() === false) throw new ReviewerUnavailableError("reviewer isolation or admission changed before child spawn", launch.current?.() === false ? "binary" : "unknown");
|
|
311
338
|
this.receipt = launch.isolation;
|
|
312
339
|
const [cmd, ...prefix] = launch.command;
|
|
313
340
|
let quota = false;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { assessVersion, COMPAT, describe, isCompatId, readTrust, trustFile, writeTrust, type CompatId, type CompatVerdict } from "../adapters/compat.ts";
|
|
3
|
+
|
|
4
|
+
/** #691: what `ahub agents` and `ahub doctor` show for each gated native CLI. */
|
|
5
|
+
export interface AgentVersionRow { id: CompatId; executable?: string; verdict: CompatVerdict; policy: string }
|
|
6
|
+
|
|
7
|
+
/** `commands` carries the configured argv (codex_bin, jcode_cmd, qwen_cmd); the probe words follow it. */
|
|
8
|
+
export function agentVersions(commands: Partial<Record<CompatId, readonly string[]>> = {}): AgentVersionRow[] {
|
|
9
|
+
const trusted = readTrust();
|
|
10
|
+
return (Object.keys(COMPAT) as CompatId[]).map(id => {
|
|
11
|
+
const [defaultBin, ...words] = COMPAT[id].probe;
|
|
12
|
+
const [bin = defaultBin, ...prefix] = commands[id] ?? [defaultBin];
|
|
13
|
+
const executable = Bun.which(bin) ?? undefined;
|
|
14
|
+
if (!executable) return { id, verdict: { ok: false, state: "unknown", reason: `${COMPAT[id].label} not found on PATH` }, policy: describe(id) };
|
|
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) };
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function runAgents(args: string[], commands: Partial<Record<CompatId, readonly string[]>>, fail: (message: string) => never): void {
|
|
21
|
+
const [sub, id, version, ...rest] = args.filter(arg => arg !== "--json");
|
|
22
|
+
if (sub === "trust") {
|
|
23
|
+
const revoke = rest.includes("--revoke");
|
|
24
|
+
if (!isCompatId(id) || !version || rest.some(arg => arg !== "--revoke")) fail(`usage: ahub agents trust <${Object.keys(COMPAT).join("|")}> <version> [--revoke]`);
|
|
25
|
+
// The issue requires saying what trust covers before it is recorded.
|
|
26
|
+
if (!revoke) console.log(`${COMPAT[id].label} ${version}: ${COMPAT[id].trustNote}`);
|
|
27
|
+
let rows;
|
|
28
|
+
try { rows = writeTrust(id, version, revoke); } catch (error) { fail((error as Error).message); }
|
|
29
|
+
if (revoke) return console.log(`revoked: ${id} ${version} (${rows.length} trusted on this machine, ${trustFile()})`);
|
|
30
|
+
console.log(`trusted on this machine: ${COMPAT[id].label} ${version}\nUndo: ahub agents trust ${id} ${version} --revoke`);
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
if (sub !== undefined) fail("usage: ahub agents [--json] | ahub agents trust <id> <version> [--revoke]");
|
|
34
|
+
const rows = agentVersions(commands);
|
|
35
|
+
if (args.includes("--json")) return console.log(JSON.stringify(rows.map(row => ({ id: row.id, executable: row.executable ?? null, ...row.verdict, policy: row.policy })), null, 2));
|
|
36
|
+
for (const row of rows) {
|
|
37
|
+
const v = row.verdict;
|
|
38
|
+
console.log(`${row.id.padEnd(13)} ${(v.version ?? "-").padEnd(10)} ${v.state.padEnd(9)} ${row.policy}${v.ok ? (v.note ? `\n${" ".repeat(14)}${v.note}` : "") : `\n${" ".repeat(14)}${v.reason}`}`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// #711 / #380: the console's review guidance. Pure strings and decisions, no terminal code and no state import: console-state.ts renders them.
|
|
2
|
+
// Nothing here changes authority: the console runs `ahub review` as the person, and the hub decides.
|
|
3
|
+
|
|
4
|
+
export type Verdict = "approved" | "changes_requested";
|
|
5
|
+
/** The task a review choice was opened for, taken from the selected row or the open detail. */
|
|
6
|
+
export interface ReviewChoice { id: number; title: string; reviewerIsUser: boolean }
|
|
7
|
+
|
|
8
|
+
const MAX_TITLE = 40;
|
|
9
|
+
const clean = (text: string) => text.replace(/[\p{Cc}\p{Cf}]/gu, " ").replace(/\s+/g, " ").trim();
|
|
10
|
+
/** The title as the choice line shows it: quoted, one line, cut to the width; a private task is `[pii]`. */
|
|
11
|
+
export function choiceTitle(row: { title?: unknown; signals?: unknown }): string {
|
|
12
|
+
const priv = (Array.isArray(row.signals) && row.signals.some(s => s === "pii" || s === "pii_path")) || row.title === "[pii]";
|
|
13
|
+
if (priv) return "[pii]";
|
|
14
|
+
const text = clean(String(row.title ?? ""));
|
|
15
|
+
return JSON.stringify(text.length > MAX_TITLE ? `${text.slice(0, MAX_TITLE - 3)}...` : text);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** The choice for a task row, or undefined when it is not one the person can review (not in review). */
|
|
19
|
+
export function reviewChoiceFor(row: any): ReviewChoice | undefined {
|
|
20
|
+
if (!row || row.state !== "in_review" || !Number.isSafeInteger(Number(row.id)) || Number(row.id) <= 0) return undefined;
|
|
21
|
+
return { id: Number(row.id), title: choiceTitle(row), reviewerIsUser: row.reviewer === "user" };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The prompt line while the choice is open (#711 change 1). The title is already quoted. */
|
|
25
|
+
export const choicePrompt = (c: ReviewChoice): string => `review #${c.id} ${c.title}: a approve c request changes Esc cancel`;
|
|
26
|
+
/** What `a` and `c` prefill on the input line. The trailing space is where the note goes. */
|
|
27
|
+
export const verdictInput = (id: number, verdict: Verdict): string => `review ${id} ${verdict} `;
|
|
28
|
+
/** The guide line shown above the input after a choice (#711 change 1). */
|
|
29
|
+
export const verdictGuide = (id: number, verdict: Verdict): string =>
|
|
30
|
+
verdict === "approved" ? `Enter approves #${id}. You can type a note first. Esc cancels.` : "Say what to fix, then Enter. Esc cancels.";
|
|
31
|
+
|
|
32
|
+
/** The footer hint for a task in review (#711 change 2): the verb, and whose review it is when it is the person's. */
|
|
33
|
+
export const reviewHint = (reviewer: unknown): string => reviewer === "user" ? "your review: r approve or request changes" : "r approve or request changes";
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* A `review <id> <verdict> [note]` line the person typed, checked before it is sent: a changes request must say what to fix (#711 change 1). Returns the refusal,
|
|
37
|
+
* or undefined when the line is fine or is not a review line at all (other commands are validated where they always were).
|
|
38
|
+
*/
|
|
39
|
+
export function reviewRefusal(args: readonly string[]): string | undefined {
|
|
40
|
+
if (args[0] !== "review") return undefined;
|
|
41
|
+
const verdict = args[2];
|
|
42
|
+
if (verdict !== "changes_requested") return undefined;
|
|
43
|
+
const note = args.slice(3).filter(word => !word.startsWith("--")).join(" ").trim();
|
|
44
|
+
return note ? undefined : "say what to fix before requesting changes";
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The notice after a review command finished: `#372 approved`, `#372 changes requested`, or the CLI's own refusal verbatim (#711 change 3). */
|
|
48
|
+
export function reviewResult(args: readonly string[], code: number, diagnostic: string): { text: string; tone: "success" | "failure" } | undefined {
|
|
49
|
+
if (args[0] !== "review" || !/^\d+$/.test(args[1] ?? "")) return undefined;
|
|
50
|
+
const verdict = args[2];
|
|
51
|
+
if (verdict !== "approved" && verdict !== "changes_requested") return undefined;
|
|
52
|
+
if (code) return { text: clean(diagnostic) || `command exited ${code}`, tone: "failure" };
|
|
53
|
+
return { text: `#${args[1]} ${verdict === "approved" ? "approved" : "changes requested"}`, tone: "success" };
|
|
54
|
+
}
|