diff --git a/CHANGELOG.md b/CHANGELOG.md
index a8fa915..4bb96f9 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,24 @@
# Changelog
+## Unreleased
+
+### Changed
+
+- The built-in `kimi` agent now drives Kimi Code CLI (`@moonshot-ai/kimi-code`), the successor to
+ the legacy Python `kimi-cli` that 0.6.0 targeted. Both install a `kimi` executable, but Kimi Code
+ rejects `--quiet` and `--work-dir`. Reviews run in prompt mode with a shipped read-only profile
+ (`lib/agents/kimi-reviewer.md`: no Edit or Write tools, read-only sub-agents only), the report is
+ read from `--output-format stream-json`, and replies resume the exact session id Kimi prints.
+ The entry stays opt-in. Users of the legacy CLI can keep the old command with a `jury.config.json`
+ override; see [docs/configuration.md](docs/configuration.md#authentication-versions-and-conversations).
+
+### Added
+
+- `report: "kimi-json"` reads the assistant text out of Kimi Code's JSON lines, ignoring tool
+ results and metadata, and rejects non-JSON or empty output; the console shows the decoded words
+ while the agent runs. `{{packageDir}}` in a command names the installed package directory, for
+ files that ship with it.
+
## 0.6.0 — 2026-09-16
- Validate new agents through fresh installed CLI tests; fix Qwen worktree and Git access, Copilot Git permissions, final structured result handling, and conversation isolation. All six new agents are opt-in.
diff --git a/README.md b/README.md
index 33730ed..5d7012e 100644
--- a/README.md
+++ b/README.md
@@ -28,7 +28,7 @@ It uses illustrative data and does not start agents.
| | GitHub Copilot CLI | `copilot` | Opt-in |
| | Cursor Agent CLI | `cursor` | Opt-in |
| | Sourcegraph Amp | `amp` | Opt-in |
-| | Moonshot Kimi CLI | `kimi` | Opt-in |
+| | Moonshot Kimi Code CLI | `kimi` | Opt-in |
**Opt-in** agents are fully supported as reviewers and judges, but stay out of the default pool so
a fresh install does not require every CLI to be present. Select one by name — no configuration
@@ -178,7 +178,7 @@ or select installed reviewers explicitly.
| `opencode` | OpenCode | `npm i -g opencode-ai` | opt-in |
| `cursor` | Cursor Agent CLI | `curl https://cursor.com/install -fsS \| bash` | opt-in |
| `amp` | Sourcegraph Amp | `npm i -g @sourcegraph/amp` | opt-in |
-| `kimi` | Moonshot Kimi CLI | `uv tool install --python 3.13 kimi-cli` | opt-in |
+| `kimi` | Moonshot Kimi Code CLI | `npm i -g @moonshot-ai/kimi-code` | opt-in |
**Opt-in** agents are fully supported — usable with `--reviewer`, `--jury`, `--judge` and
`jury agents judge` — but stay out of the default pool, so a fresh install does not require every
diff --git a/docs/cli-test-coverage.md b/docs/cli-test-coverage.md
index ad10715..2de7c66 100644
--- a/docs/cli-test-coverage.md
+++ b/docs/cli-test-coverage.md
@@ -18,7 +18,7 @@ they do not validate a provider's current authentication or CLI compatibility.
| `finding reproduce` | Evidence persisted and visible in subsequent listing; invalid inputs do not mutate events | `cli-commands` |
| `finding resolve` | All four verdicts; acceptance requires reproduction and test evidence; persisted output | `cli-commands` |
| `finding settled` | Empty output and regenerated file agree with resolved findings | `cli-commands` |
-| `reply` | Selected reviewers receive their own findings; reply events/output; empty reply refused | `reviewer-selection`, `new-agents`, `cli-commands` |
+| `reply` | Selected reviewers receive their own findings; reply events/output; empty reply refused | `reviewer-selection`, `new-agents`, `kimi`, `cli-commands` |
| `runs` | Empty listing and saved run slug, title, rounds, finding counts | `cli-commands` |
| `agents` | Executable availability, missing executable failure, effective judge role | `cli-commands`, agent integration tests |
| `agents judge ` | Every built-in set, queried in a different directory, and shown as main in listing | `cli-commands` |
diff --git a/docs/configuration.md b/docs/configuration.md
index b5def52..3b4ee43 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -40,8 +40,12 @@ and remain visible in the saved reports.
`my-reviewer` is a placeholder executable, not an included product. Use arguments supported by your CLI.
Supported prompt delivery is `argv` or `file` (`{{promptFile}}`); stdin is closed.
`cwd: "worktree"` starts in the checkout; with `cwd: "flag"`, pass `{{worktree}}` in the command.
-`report: "tail"` extracts a final response from a verbose transcript; otherwise the whole output is read.
+`report: "tail"` extracts a final response from a verbose transcript; `report: "kimi-json"` reads the
+assistant messages out of Kimi Code's `--output-format stream-json` lines; otherwise the whole output is read.
`expectSeconds` informs the timeout (at least 600 seconds, normally three times that estimate).
+Command placeholders are `{{promptText}}`, `{{promptFile}}`, `{{worktree}}`, `{{sessionId}}` (for
+resumable agents), and `{{packageDir}}`, the directory Code Jury is installed in, for files that ship
+with it such as `lib/agents/kimi-reviewer.md`.
The historical YAML example is design documentation; the CLI loads JSON.
A judge may specify separate `judgeArgv`. A custom `argv` overrides the built-in judge invocation
@@ -90,7 +94,7 @@ review could not start until every supported CLI was installed.
| OpenCode (opt-in) | `opencode` | Plan agent with explicit edit/task/shell restrictions | Build agent with `--auto` |
| Cursor Agent (opt-in) | `cursor-agent` | ⚠️ Print mode; all tools including write and bash | `--force` |
| Amp (opt-in) | `amp` | ⚠️ Execute mode; no read-only flag | Same command unless overridden |
-| Kimi (opt-in) | `kimi` | ⚠️ Print mode auto-approves tool calls | Same command unless overridden |
+| Kimi Code (opt-in) | `kimi` | Prompt mode with the shipped `lib/agents/kimi-reviewer.md` profile: Edit and Write tools removed, only the read-only `explore` sub-agent; shell available | Prompt mode with all tools |
| Custom | Your `argv[0]` | Your `argv` | Your `judgeArgv`, or `argv` |
⚠️ marks agents whose CLI offers **no read-only invocation**. They still review, but only the
@@ -111,7 +115,11 @@ your environment. Run records include prompts and outputs, so redact them before
### Authentication, versions, and conversations
-- Kimi: verified CLI 1.50.0; run `kimi login`. Quiet mode prints the final message and auto-approves tools, even if a conversation initially starts in plan mode. Both roles therefore require a trusted worktree; replies start fresh.
+- Kimi Code: verified CLI 0.39.1 and 0.42.0 (`npm install -g @moonshot-ai/kimi-code`); run `kimi login`, or put an API key in `~/.kimi-code/config.toml`, whose `default_model` is the model used. Prompt mode (`kimi -p`) rejects `--plan`, `--yolo` and `--auto` and approves every tool call itself, so the reviewer boundary is the shipped agent profile: it wraps Kimi's default prompt, removes the Edit and Write tools, and allows only the read-only `explore` sub-agent (the default `coder` sub-agent can write). The shell stays available, so the profile is not a sandbox. The report is read from `--output-format stream-json`, and replies resume the exact session id Kimi prints at the end of the stream; the profile flag is omitted on resume because Kimi refuses it next to `--session`, and the session keeps the agent it was created with. Kimi keeps its own session history per working directory under `~/.kimi-code`. The legacy Python `kimi-cli` installs an executable of the same name but takes different flags (`kimi --version` prints 1.x for it and 0.x for Kimi Code); to keep using it, override the entry in `jury.config.json`:
+
+ ```json
+ { "agents": [{ "name": "kimi", "argv": ["kimi", "--quiet", "--work-dir", "{{worktree}}", "--prompt", "{{promptText}}"], "report": "whole", "resume": { "supported": false, "reason": "quiet mode prints no session id" } }] }
+ ```
- Cursor: verified `cursor-agent` 2025.10.28-0a91dc2; run `cursor-agent login`. The generic executable `agent` may belong to another product, so Jury uses `cursor-agent`. Final JSON must explicitly report success. Replies start fresh.
- Copilot: verified CLI 0.0.392 flags; authenticate with interactive `/login`. A reviewer can inspect files and Git but cannot use write tools; the judge allows tools. Local permission configuration remains trusted. Replies start fresh.
- Qwen: targets CLI 0.23.4 (`npm install -g @qwen-code/qwen-code`); run `qwen` and complete `/auth` before headless use. It runs in the process worktree, not merely an added access directory. Reviews use assigned UUIDs, and replies resume only that UUID; no implicit latest session. Final JSON must explicitly report success.
diff --git a/lib/agent-schema.js b/lib/agent-schema.js
index 9550f78..9c5fa9b 100644
--- a/lib/agent-schema.js
+++ b/lib/agent-schema.js
@@ -14,7 +14,7 @@ export const PROMPT_DELIVERY = ["argv", "file", "stdin"];
export const CWD_MODES = ["worktree", "flag"];
/** How much of stdout is the agent's report. */
-export const REPORT_MODES = ["whole", "tail", "opencode-json", "result-json"];
+export const REPORT_MODES = ["whole", "tail", "opencode-json", "result-json", "kimi-json"];
/**
* How strongly the reviewer invocation is prevented from writing.
diff --git a/lib/agents.js b/lib/agents.js
index 8e521d0..6d48796 100644
--- a/lib/agents.js
+++ b/lib/agents.js
@@ -4,6 +4,17 @@ import { randomUUID } from "node:crypto";
import { mkdtemp, writeFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
+import { fileURLToPath } from "node:url";
+
+/**
+ * Where Code Jury itself is installed, for files that ship with it.
+ *
+ * An agent that takes a profile or policy file on its command line needs an
+ * absolute path to one — the process starts in the worktree, so a relative
+ * path would resolve into the code under review. kimi's read-only reviewer
+ * profile in lib/agents/ is reached this way, as {{packageDir}}.
+ */
+export const PACKAGE_DIR = fileURLToPath(new URL("..", import.meta.url)).replace(/[\\/]+$/, "");
function subst(argv, vars) {
return argv.map((a) => a.replace(/\{\{(\w+)\}\}/g, (_, k) => vars[k] ?? ""));
@@ -44,7 +55,7 @@ export async function runAgent(agent, { worktree, prompt, stopToken, dryRun, onL
const assigned = agent.newSession && agent.argv.some(arg => arg.includes("{{sessionId}}")) ? randomUUID() : null;
const vars = {
worktree, promptFile: promptFile ?? "", promptText: prompt,
- sessionId: assigned ?? "",
+ sessionId: assigned ?? "", packageDir: PACKAGE_DIR,
};
const [cmd, ...args] = subst(agent.argv, vars);
const cwd = agent.cwd === "worktree" ? worktree : process.cwd();
@@ -81,10 +92,13 @@ export async function runAgent(agent, { worktree, prompt, stopToken, dryRun, onL
// reviewer that takes twenty minutes is otherwise a black box for all
// twenty: nothing reaches disk until close, so "still thinking" and
// "wedged" look identical to anyone watching.
- child.stdout.on("data", (d) => { stdout += d; onChunk?.(String(d)); });
+ // A kimi-json agent's live output is JSON lines; the console wants the
+ // words inside them, so those are decoded per complete line as they land.
+ const speak = onChunk && agent.report === "kimi-json" ? lineDecoder(onChunk) : onChunk;
+ child.stdout.on("data", (d) => { stdout += d; speak?.(String(d)); });
child.stderr.on("data", (d) => (stderr += d));
child.on("error", (err) => { clearTimeout(timer); resolve({ code: -1, stdout, stderr: stderr + String(err) }); });
- child.on("close", (code) => { clearTimeout(timer); resolve({ code, stdout, stderr }); });
+ child.on("close", (code) => { clearTimeout(timer); speak?.flush?.(); resolve({ code, stdout, stderr }); });
});
if (tmp) await rm(tmp, { recursive: true, force: true });
@@ -150,6 +164,7 @@ export async function runAgent(agent, { worktree, prompt, stopToken, dryRun, onL
*/
export function extractReport(stdout, mode = "whole") {
const text = stdout.replace(/\r/g, "");
+ if (mode === "kimi-json") return kimiText(text);
if (mode === "result-json") {
let data;
try { data = JSON.parse(text); } catch { throw new Error("Invalid agent JSON output"); }
@@ -194,6 +209,96 @@ export function extractReport(stdout, mode = "whole") {
return lines.slice(-80).join("\n").trim();
}
+/**
+ * The assistant's words out of Kimi Code's `--output-format stream-json`.
+ *
+ * One JSON object per line: assistant messages, the tool calls they made, the
+ * tool results, and metadata — the version banner, retry notices, and the
+ * session hint at the end. Only the assistant's text is the report: a tool
+ * result that happens to print the prompt's "NO NEW FINDINGS" line must not
+ * end the loop. A line that is not JSON means the agent was not run in
+ * stream-json mode, and a stream with no assistant text is a turn that never
+ * answered; both are errors rather than a report to be read.
+ */
+export function kimiText(text) {
+ const said = [];
+ for (const line of text.split("\n").filter((l) => l.trim())) {
+ const o = parseLine(line);
+ if (!o) throw new Error("Invalid Kimi Code JSON output");
+ if (o.role !== "assistant") continue;
+ const t = contentText(o.content).trim();
+ if (t) said.push(t);
+ }
+ if (!said.length) throw new Error("Kimi Code returned no assistant text");
+ return said.join("\n\n");
+}
+
+function parseLine(line) {
+ if (!line.trim().startsWith("{")) return null;
+ try {
+ const o = JSON.parse(line);
+ return o && typeof o === "object" && !Array.isArray(o) ? o : null;
+ } catch {
+ return null;
+ }
+}
+
+/** Message content as text, whether it is a string or a list of typed parts. */
+function contentText(content) {
+ if (typeof content === "string") return content;
+ if (!Array.isArray(content)) return "";
+ return content
+ .filter((p) => p && typeof p === "object" && p.type === "text" && typeof p.text === "string")
+ .map((p) => p.text)
+ .join("\n");
+}
+
+/**
+ * Live output from a kimi-json agent, as words rather than JSON.
+ *
+ * Streaming the raw lines to the console shows escaped newlines and tool
+ * payloads in place of a reviewer mid-sentence. Each complete line is decoded
+ * as it lands: the assistant's text as it is, a tool call as one short line so
+ * the console can tell "reading the diff" from "wedged", and tool results and
+ * metadata not at all. The raw stream is still kept whole for the artifact and
+ * the final parse.
+ */
+function lineDecoder(onChunk) {
+ let pending = "";
+ const emit = (line) => {
+ const text = spokenLine(line);
+ if (text) onChunk(text);
+ };
+ const decode = (chunk) => {
+ pending += chunk;
+ const lines = pending.split("\n");
+ pending = lines.pop();
+ for (const line of lines) emit(line);
+ };
+ decode.flush = () => {
+ if (pending.trim()) emit(pending);
+ pending = "";
+ };
+ return decode;
+}
+
+function spokenLine(line) {
+ const o = parseLine(line);
+ // Not JSON: a warning, or a misconfigured agent. Shown as it is; the final
+ // parse is what decides whether that was acceptable.
+ if (!o) return line.trim() ? `${line.replace(/\r$/, "")}\n` : "";
+ if (o.role !== "assistant") return "";
+ const parts = [];
+ const text = contentText(o.content).trim();
+ if (text) parts.push(text);
+ for (const call of Array.isArray(o.tool_calls) ? o.tool_calls : []) {
+ const name = call?.function?.name ?? call?.name ?? "tool";
+ const args = String(call?.function?.arguments ?? call?.arguments ?? "").replace(/\s+/g, " ");
+ parts.push(`» ${name} ${args.length > 160 ? `${args.slice(0, 160)}…` : args}`.trimEnd());
+ }
+ return parts.length ? `${parts.join("\n")}\n` : "";
+}
+
// Agents write markdown even when asked for plain text, so strip the decoration
// before matching rather than spelling every variant out in the pattern.
// Headings and list numbering count: "### FINDING:" and "1. FINDING:" are both
diff --git a/lib/agents/kimi-reviewer.md b/lib/agents/kimi-reviewer.md
new file mode 100644
index 0000000..f96cd92
--- /dev/null
+++ b/lib/agents/kimi-reviewer.md
@@ -0,0 +1,17 @@
+---
+name: jury-reviewer
+description: Independent code reviewer for Code Jury. Reads a change and reports findings; never edits files.
+disallowedTools:
+ - Edit
+ - Write
+subagents:
+ - explore
+---
+${base_prompt}
+
+You are one of several independent reviewers in a Code Jury review. Read the change and report
+findings in exactly the format the review prompt asks for.
+
+You are a reviewer, not the author: do not create, edit or delete files, do not stage or commit, and
+do not work around the missing editing tools through the shell. The judge owns every change and
+will act on your findings. Use the shell only to read: the diff, the history, the tests.
diff --git a/lib/agents/kimi.json b/lib/agents/kimi.json
index fe79dc1..73ccd2e 100644
--- a/lib/agents/kimi.json
+++ b/lib/agents/kimi.json
@@ -1,26 +1,43 @@
{
"name": "kimi",
- "product": "Moonshot Kimi CLI",
+ "product": "Moonshot Kimi Code CLI",
"role": "reviewer",
"enabled": false,
"promptDelivery": "argv",
"cwd": "worktree",
"argv": [
"kimi",
- "--quiet",
- "--work-dir",
- "{{worktree}}",
- "--prompt",
- "{{promptText}}"
+ "-p",
+ "{{promptText}}",
+ "--output-format",
+ "stream-json",
+ "--agent-file",
+ "{{packageDir}}/lib/agents/kimi-reviewer.md"
],
- "sandbox": "none",
- "sandboxNote": "print mode auto-approves every tool call; only the review prompt keeps it read-only",
+ "judgeArgv": [
+ "kimi",
+ "-p",
+ "{{promptText}}",
+ "--output-format",
+ "stream-json"
+ ],
+ "sandbox": "tools",
+ "sandboxNote": "prompt mode auto-approves tool calls; the shipped reviewer profile removes the Edit and Write tools and allows only the read-only explore sub-agent, but the shell stays available",
"resume": {
- "supported": false,
- "reason": "final text cannot reliably identify a session; replies start fresh with finding context"
+ "supported": true,
+ "argv": [
+ "kimi",
+ "-p",
+ "{{promptText}}",
+ "--output-format",
+ "stream-json",
+ "--session",
+ "{{sessionId}}"
+ ],
+ "idFrom": "session_id\\W{0,3}((?:session_)?[0-9a-f]{8}-[0-9a-f-]{27})"
},
- "report": "whole",
- "expectSeconds": 400,
- "install": "uv tool install --python 3.13 kimi-cli",
- "docs": "https://github.com/MoonshotAI/kimi-cli"
+ "report": "kimi-json",
+ "expectSeconds": 600,
+ "install": "npm install -g @moonshot-ai/kimi-code",
+ "docs": "https://moonshotai.github.io/kimi-code/"
}
diff --git a/scripts/check-package.mjs b/scripts/check-package.mjs
index 40c7062..84ab1c5 100644
--- a/scripts/check-package.mjs
+++ b/scripts/check-package.mjs
@@ -13,7 +13,7 @@ const exec = (command, args, options = {}) => execFileSync(command, args, { enco
try {
const [packed] = JSON.parse(exec(npm, ['pack', '--json', '--pack-destination', temporary]));
const files = packed.files.map(f => f.path);
- for (const required of ['bin/jury.js', 'lib/review-group.js', 'web/index.html', 'jury.config.example.json', 'LICENSE', 'README.md', 'docs/usage.md', 'docs/console-demo.html']) assert.ok(files.includes(required), `missing ${required}`);
+ for (const required of ['bin/jury.js', 'lib/review-group.js', 'lib/agents/kimi-reviewer.md', 'web/index.html', 'jury.config.example.json', 'LICENSE', 'README.md', 'docs/usage.md', 'docs/console-demo.html']) assert.ok(files.includes(required), `missing ${required}`);
assert.ok(!files.some(f => /(^|\/)(runs|node_modules|test|\.git)(\/|$)|(^|\/)\.(env|npmrc)$|(^|\/)jury\.config\.json$/.test(f)), 'private/development files in package');
exec(npm, ['install', '--prefix', temporary, '--ignore-scripts', '--no-audit', '--no-fund', path.join(temporary, packed.filename)]);
// Exercise npm-created executable links through PATH, as users invoke them.
@@ -31,7 +31,7 @@ try {
assert.match(help, /--push=false/);
assert.match(exec(process.execPath, [installed, 'help', '--all'], { cwd: temporary }), /jury review /);
// Full related-PR path: subprocess agents, real local remotes, fixes, resume, failures.
- exec(process.execPath, ['--test', 'test/cli-commands.test.js', 'test/global-judge.test.js', 'test/help.test.js', 'test/web-launch.test.js', 'test/review-group.test.js', 'test/reviewer-selection.test.js', 'test/automatic-roles.test.js', 'test/agy.test.js', 'test/opencode.test.js', 'test/new-agents.test.js'], {
+ exec(process.execPath, ['--test', 'test/cli-commands.test.js', 'test/global-judge.test.js', 'test/help.test.js', 'test/web-launch.test.js', 'test/review-group.test.js', 'test/reviewer-selection.test.js', 'test/automatic-roles.test.js', 'test/agy.test.js', 'test/opencode.test.js', 'test/new-agents.test.js', 'test/kimi.test.js'], {
cwd: root, env: { ...isolated.env, JURY_TEST_CLI: installed }, maxBuffer: 8e6,
});
console.log(`Verified installed @agentsdance/codejury@${version}: ${files.length} files, CLI commands/subcommands, executable links, saved console, and related-PR fixes/push/resume/failure tests.`);
diff --git a/test/kimi.test.js b/test/kimi.test.js
new file mode 100644
index 0000000..07b1dc0
--- /dev/null
+++ b/test/kimi.test.js
@@ -0,0 +1,217 @@
+// The built-in Kimi Code entry, driven through a stand-in `kimi`. Run with `node --test`.
+//
+// The stand-in prints exactly what Kimi Code CLI 0.39–0.42 prints in prompt
+// mode with --output-format stream-json, so what is under test is Jury's side
+// of the contract: the reviewer profile reaches the command line as a real file
+// of the installed package, the report is the assistant's words alone, a tool
+// result cannot end the loop, the session id is captured, and the reply resumes
+// that session without the profile flag Kimi refuses next to --session.
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { mkdtemp, mkdir, writeFile, readFile, readdir, rm, access } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import path from "node:path";
+import { execFileSync, spawnSync } from "node:child_process";
+import { fileURLToPath, pathToFileURL } from "node:url";
+
+const cli = process.env.JURY_TEST_CLI ?? fileURLToPath(new URL("../bin/jury.js", import.meta.url));
+const root = path.dirname(path.dirname(cli));
+const { DEFAULTS, loadConfig, judgeAgent, knownAgents } = await import(pathToFileURL(path.join(root, "lib/config.js")));
+const { runAgent, hasStopToken } = await import(pathToFileURL(path.join(root, "lib/agents.js")));
+const { replyArgv } = await import(pathToFileURL(path.join(root, "lib/reply.js")));
+const { appendEvent, readEvents } = await import(pathToFileURL(path.join(root, "lib/store.js")));
+
+const SESSION = "session_75050fde-14b3-43af-a479-2946f0f11375";
+const STOP = "NO NEW FINDINGS";
+// Verbatim from kimi 0.39.1 (0.42.0 adds retry notices with role "meta"): a
+// version banner, an assistant turn that calls a tool, the tool's result — which
+// quotes the prompt's stop-token line — the answer, and the resume hint.
+const HINT = `{"role":"meta","type":"session.resume_hint","session_id":"${SESSION}","command":"kimi -r ${SESSION}","content":"To resume this session: kimi -r ${SESSION}"}`;
+
+// The stand-in records how it was called and answers with KIMI_STUB_ANSWER;
+// KIMI_STUB_OUTPUT switches it to plain text or to a stream with no answer.
+const STUB = `const fs = require("node:fs");
+const args = process.argv.slice(2);
+const at = args.indexOf("--agent-file");
+fs.writeFileSync("call.json", JSON.stringify({ cwd: process.cwd(), args,
+ profile: at >= 0 ? { path: args[at + 1], exists: fs.existsSync(args[at + 1]) } : null }));
+const answer = process.env.KIMI_STUB_ANSWER || ${JSON.stringify(STOP)};
+const json = (o) => JSON.stringify(o);
+const lines = process.env.KIMI_STUB_OUTPUT === "text" ? ["• " + answer]
+ : process.env.KIMI_STUB_OUTPUT === "meta" ? [json({ role: "meta", type: "system.version", version: "0.39.1" })]
+ : [
+ json({ role: "meta", type: "system.version", version: "0.39.1" }),
+ json({ role: "assistant", tool_calls: [{ type: "function", id: "Bash_0_ed8a0b21", function: { name: "Bash", arguments: json({ command: "grep -n 'NO NEW FINDINGS' prompt.md" }) } }] }),
+ json({ role: "tool", tool_call_id: "Bash_0_ed8a0b21", content: "40:say exactly \\"NO NEW FINDINGS\\" on its own line\\n" }),
+ json({ role: "assistant", content: answer }),
+ ${JSON.stringify(HINT)},
+ ];
+process.stdout.write(lines.join("\\n") + "\\n");
+`;
+
+async function fixture(t) {
+ const dir = await mkdtemp(path.join(tmpdir(), "jury-kimi-"));
+ t.after(() => rm(dir, { recursive: true, force: true }));
+ const worktree = path.join(dir, "work tree");
+ const bin = path.join(dir, "bin");
+ await mkdir(worktree); await mkdir(bin);
+ const exe = path.join(bin, "kimi");
+ await writeFile(exe, `#!${process.execPath}\n${STUB}`, { mode: 0o755 });
+ const cfg = await loadConfig(worktree, { globalFile: path.join(dir, "absent") });
+ const agent = knownAgents(cfg).find((a) => a.name === "kimi");
+ const call = async () => JSON.parse(await readFile(path.join(worktree, "call.json"), "utf8"));
+ return { dir, worktree, bin, exe, cfg, agent, call };
+}
+
+test("kimi is an opt-in built-in whose review, judge and reply commands differ where they must", async (t) => {
+ const { cfg, agent } = await fixture(t);
+ assert.ok(agent, "kimi must be a known built-in agent");
+ assert.ok(!cfg.agents.some((a) => a.name === "kimi"), "kimi ships opt-in");
+ assert.equal(agent.report, "kimi-json");
+ assert.equal(agent.cwd, "worktree", "Kimi Code has no --work-dir; it reviews where it is started");
+ assert.equal(agent.sandbox, "tools");
+
+ // Prompt mode refuses --plan, --yolo and --auto, so none may appear anywhere.
+ const judge = judgeAgent(cfg, "kimi");
+ for (const argv of [agent.argv, judge.argv, agent.resume.argv]) {
+ assert.equal(argv[0], "kimi");
+ assert.ok(argv.includes("-p") && argv.includes("{{promptText}}"));
+ assert.deepEqual(argv.slice(argv.indexOf("--output-format"), argv.indexOf("--output-format") + 2), ["--output-format", "stream-json"]);
+ assert.ok(!argv.some((a) => ["--plan", "--yolo", "--auto", "--continue"].includes(a)), "refused next to -p, or resumes the wrong session");
+ }
+ // The reviewer is boxed by the shipped profile; the judge writes without it.
+ const at = agent.argv.indexOf("--agent-file");
+ assert.ok(at > 0);
+ assert.equal(agent.argv[at + 1], "{{packageDir}}/lib/agents/kimi-reviewer.md");
+ assert.ok(!judge.argv.includes("--agent-file"));
+ // A resume names the exact session and, because Kimi refuses the pair, no profile.
+ assert.ok(agent.resume.supported);
+ assert.deepEqual(agent.resume.argv.slice(-2), ["--session", "{{sessionId}}"]);
+ assert.ok(!agent.resume.argv.includes("--agent-file"));
+ // The id is read from the trailing meta line Kimi prints on stdout, prefix and all:
+ // `kimi --session` wants the same string the hint shows.
+ assert.equal(HINT.match(new RegExp(agent.resume.idFrom, "i"))?.[1], SESSION);
+});
+
+test("the shipped reviewer profile is in the package and says what it removes", async () => {
+ const file = path.join(root, "lib", "agents", "kimi-reviewer.md");
+ await access(file);
+ const text = (await readFile(file, "utf8")).replace(/\r/g, "");
+ const [, front] = text.match(/^---\n([\s\S]*?)\n---\n/) ?? [];
+ assert.ok(front, "a frontmatter block is what Kimi reads the tool policy from");
+ assert.match(front, /^description: .+/m, "Kimi requires a description");
+ assert.match(front, /^name: [a-z0-9-]+$/m, "a non-kebab-case name is skipped with a warning");
+ const list = (key) => (front.match(new RegExp(`^${key}:\\n((?: - .+\\n?)+)`, "m"))?.[1] ?? "").match(/- (.+)/g)?.map((s) => s.slice(2).trim()) ?? [];
+ assert.deepEqual(list("disallowedTools").sort(), ["Edit", "Write"]);
+ assert.deepEqual(list("subagents"), ["explore"], "the default coder sub-agent can write files");
+ assert.match(text, /\$\{base_prompt\}/, "wrap Kimi's default prompt rather than replace it");
+});
+
+test("a review is parsed from the JSON stream, its session captured, and its live output decoded", async (t) => {
+ const { worktree, exe, cfg, agent, call } = await fixture(t);
+ const prompt = 'Read this literally: "quotes"; $(touch unwanted)\nReview only.';
+ const chunks = [];
+ const review = await runAgent({
+ ...agent, argv: [exe, ...agent.argv.slice(1)],
+ env: { KIMI_STUB_ANSWER: "FINDING: jitter is deletable\nWHERE: main_test.go:2237\n\nEvery assertion accepts the nominal delay." },
+ }, { worktree, prompt, stopToken: STOP, onChunk: (text) => chunks.push(text) });
+ assert.equal(review.ok, true, review.report);
+ // The tool result quoted the stop token; only the assistant's words count.
+ assert.equal(review.verdict, "found");
+ assert.equal(hasStopToken(review.report, STOP), false);
+ assert.equal(review.findings.length, 1);
+ assert.equal(review.findings[0].claim, "jitter is deletable");
+ assert.equal(review.sessionId, SESSION, "the id the reply will resume");
+ assert.match(review.raw, /system\.version/, "the raw artifact keeps the whole stream");
+
+ let made = await call();
+ assert.equal(made.args[0], "-p");
+ assert.equal(made.args[1], prompt, "the prompt arrives literally, as one argument");
+ assert.deepEqual(made.args.slice(2, 4), ["--output-format", "stream-json"]);
+ assert.ok(path.isAbsolute(made.profile.path), "the profile must be an absolute path: the process starts in the worktree");
+ assert.equal(made.profile.exists, true, "{{packageDir}} must point at the installed package");
+ assert.equal(path.relative(root, made.profile.path).split(path.sep).join("/"), "lib/agents/kimi-reviewer.md");
+
+ // What the console saw while it ran: words and tool calls, no JSON envelope.
+ const live = chunks.join("");
+ assert.match(live, /» Bash \{"command":"grep -n 'NO NEW FINDINGS' prompt.md"\}/);
+ assert.match(live, /FINDING: jitter is deletable/);
+ assert.doesNotMatch(live, /"role"|resume_hint|say exactly/);
+
+ // The judge runs the same CLI with every tool: no profile on its command line.
+ const judge = judgeAgent(cfg, "kimi");
+ const triage = await runAgent({ ...judge, argv: [exe, ...judge.argv.slice(1)] }, { worktree, prompt, stopToken: STOP });
+ assert.equal(triage.verdict, "clean");
+ made = await call();
+ assert.equal(made.profile, null);
+ assert.deepEqual(made.args, ["-p", prompt, "--output-format", "stream-json"]);
+
+ // And the reply goes to that exact session, without the profile flag.
+ const reply = replyArgv(agent, { promptText: "verdicts", worktree, sha: "abc", sessionId: review.sessionId });
+ assert.equal(reply.resumed, true);
+ assert.deepEqual(reply.argv, ["kimi", "-p", "verdicts", "--output-format", "stream-json", "--session", SESSION]);
+ const fresh = replyArgv(agent, { promptText: "verdicts", worktree, sha: "abc" });
+ assert.equal(fresh.resumed, false);
+ assert.ok(!fresh.argv.includes("--session"));
+});
+
+test("output that is not Kimi's JSON, or has no answer, is an error rather than a report", async (t) => {
+ const { worktree, exe, agent } = await fixture(t);
+ const invoke = (env) => runAgent({ ...agent, argv: [exe, ...agent.argv.slice(1)], env }, { worktree, prompt: "review", stopToken: STOP });
+ // Text mode is what a customised argv without --output-format stream-json
+ // produces; its bullet-prefixed stop token must not be mistaken for clean.
+ const text = await invoke({ KIMI_STUB_OUTPUT: "text" });
+ assert.equal(text.ok, false);
+ assert.equal(text.verdict, "error");
+ assert.match(text.report, /Invalid Kimi Code JSON output/);
+ const silent = await invoke({ KIMI_STUB_OUTPUT: "meta" });
+ assert.equal(silent.verdict, "error");
+ assert.match(silent.report, /no assistant text/);
+});
+
+test("through the CLI: listed opt-in, run when named, and replied to in its recorded session", async (t) => {
+ const { dir, worktree, bin, agent, call } = await fixture(t);
+ const env = { ...process.env, HOME: dir, USERPROFILE: dir, PATH: `${bin}${path.delimiter}${process.env.PATH}` };
+ const command = (args) => spawnSync(process.execPath, [cli, ...args], { cwd: worktree, env, encoding: "utf8", timeout: 30000 });
+
+ assert.match(command(["agents"]).stdout, /ok\s+kimi\s+reviewer\s+\S+\s+\(opt-in\)/);
+ assert.equal(command(["agents", "judge", "kimi"]).status, 0);
+ assert.equal(JSON.parse(await readFile(path.join(dir, ".jury/config.json"))).judge, "kimi");
+ assert.equal(command(["agents", "judge", "--reset"]).status, 0);
+
+ const git = (args) => execFileSync("git", args, { cwd: worktree, env, stdio: "ignore" });
+ git(["init", "-q", "-b", "master"]);
+ git(["-c", "user.name=Fixture", "-c", "user.email=fixture@example.com", "commit", "--allow-empty", "-qm", "base"]);
+ git(["checkout", "-qb", "feature"]);
+ // Keep one mock judge and this actual built-in registry entry. Never launch another installed agent.
+ await writeFile(path.join(worktree, "jury.config.json"), JSON.stringify({ agents: [
+ ...DEFAULTS.agents.filter((a) => a.name !== "kimi").map(({ name }) => ({ name, enabled: false })),
+ { name: "fixture-judge", role: "main", argv: [process.execPath, "-e", "console.log('NO NEW FINDINGS')"] },
+ ] }));
+
+ const review = command(["review", "--dir", worktree, "--trunk", "master", "--web=false", "--push=false", "--rounds", "1", "--reviewer", "kimi"]);
+ assert.equal(review.status, 0, review.stderr);
+ assert.match(review.stdout, /REVIEW COMPLETE/);
+ let made = await call();
+ assert.equal(made.profile.exists, true, "the profile reached a real file from the installed CLI");
+ assert.deepEqual(made.args.slice(-4, -2), ["--output-format", "stream-json"]);
+
+ const [slug] = await readdir(path.join(worktree, "runs"));
+ const runDir = path.join(worktree, "runs", slug);
+ const report = (await readEvents(runDir)).filter((e) => e.t === "agent.report" && e.agent === "kimi").at(-1);
+ assert.equal(report.sessionId, SESSION);
+ assert.equal(report.verdict, "clean");
+
+ await appendEvent(runDir, { t: "finding.raised", id: "reply-check", round: 1, agent: "kimi", claim: "fixture claim", body: "original finding context" });
+ await appendEvent(runDir, { t: "finding.resolved", id: "reply-check", verdict: "rejected", reason: "fixture verdict" });
+ const reply = command(["reply", "--dir", worktree, "--run", slug, "--jury", "kimi"]);
+ assert.equal(reply.status, 0, reply.stderr);
+ made = await call();
+ assert.equal(made.args[made.args.indexOf("--session") + 1], SESSION, "the reply resumes the session the review recorded");
+ assert.equal(made.profile, null, "Kimi refuses --agent-file next to --session; the session keeps its agent");
+ assert.ok(!made.args.some((a) => a.includes("original finding context")), "a resumed session already holds its review");
+
+ const self = command(["review", "--dir", worktree, "--trunk", "master", "--web=false", "--push=false", "--judge", "kimi", "--reviewer", "kimi"]);
+ assert.notEqual(self.status, 0);
+ assert.match(self.stderr, /selected judge and cannot review its own work/);
+});
diff --git a/test/new-agents.test.js b/test/new-agents.test.js
index f710225..8b59cbc 100644
--- a/test/new-agents.test.js
+++ b/test/new-agents.test.js
@@ -116,24 +116,6 @@ const cases = [
"@prompt"
],
"output": "{\"type\":\"result\",\"subtype\":\"success\",\"is_error\":false,\"result\":\"NO NEW FINDINGS\"}"
- },
- {
- "name": "kimi",
- "bin": "kimi",
- "review": [
- "--quiet",
- "--work-dir",
- "@cwd",
- "--prompt",
- "@prompt"
- ],
- "judge": [
- "--quiet",
- "--work-dir",
- "@cwd",
- "--prompt",
- "@prompt"
- ]
}
];
diff --git a/test/parse.test.js b/test/parse.test.js
index d57a6a2..7e3f914 100644
--- a/test/parse.test.js
+++ b/test/parse.test.js
@@ -137,3 +137,33 @@ test("nonzero agent exit with a stop token is an error, never approval", async (
assert.equal(result.verdict, "error");
assert.match(result.report, /quota exhausted/);
});
+
+test("Kimi Code's JSON lines are read as the assistant's words only", () => {
+ // The shape kimi prints with --output-format stream-json (0.39.1). The tool
+ // result quotes the prompt's stop-token instruction, and the meta lines carry
+ // the version and the session hint; none of that is the report.
+ const stream = [
+ { role: "meta", type: "system.version", version: "0.39.1" },
+ { role: "assistant", content: "Reading the diff first.", tool_calls: [{ type: "function", id: "Bash_0", function: { name: "Bash", arguments: JSON.stringify({ command: "grep -n FINDINGS prompt.md" }) } }] },
+ { role: "tool", tool_call_id: "Bash_0", content: '40:say exactly "NO NEW FINDINGS" on its own line\n' },
+ { role: "meta", type: "turn.step.retrying", failed_attempt: 1, next_attempt: 2 },
+ { role: "assistant", content: "FINDING: jitter is deletable\nWHERE: main_test.go:2237\n\nEvery assertion accepts the nominal delay." },
+ { role: "meta", type: "session.resume_hint", session_id: "session_75050fde-14b3-43af-a479-2946f0f11375" },
+ ].map((o) => JSON.stringify(o)).join("\n") + "\n";
+ const report = extractReport(stream, "kimi-json");
+ assert.equal(report, "Reading the diff first.\n\nFINDING: jitter is deletable\nWHERE: main_test.go:2237\n\nEvery assertion accepts the nominal delay.");
+ assert.equal(hasStopToken(report, "NO NEW FINDINGS"), false, "a tool result must not end the loop");
+ const [f] = parseFindings(report);
+ assert.equal(f.claim, "jitter is deletable");
+ assert.equal(f.loc, "main_test.go:2237");
+ assert.doesNotMatch(report, /session_75050fde|system\.version|retrying/);
+
+ // Typed content parts are read too.
+ const parts = JSON.stringify({ role: "assistant", content: [{ type: "text", text: "NO NEW FINDINGS" }] });
+ assert.equal(extractReport(`${parts}\n`, "kimi-json"), "NO NEW FINDINGS");
+
+ // Plain text means the agent was not run in stream-json mode, and a stream
+ // with no answer never reviewed anything. Neither may be read as a report.
+ assert.throws(() => extractReport("• NO NEW FINDINGS\n", "kimi-json"), /Invalid Kimi Code JSON output/);
+ assert.throws(() => extractReport(`${JSON.stringify({ role: "meta", type: "system.version" })}\n`, "kimi-json"), /no assistant text/);
+});
diff --git a/web/index.html b/web/index.html
index 465e7df..7402594 100644
--- a/web/index.html
+++ b/web/index.html
@@ -5,7 +5,7 @@
--ground:#F4F6F8; --surface:#FFFFFF; --surface-alt:#EDF0F4;
--line:#D7DDE4; --line-soft:#E6EAEF; --ink:#14181D; --ink-2:#39434F; --muted:#5A6572;
--accent:#3A6B8A; --accent-soft:#E2ECF3; --main-hue:#6A4E8C; --main-soft:#EDE6F4;
- --codex:#3A6B8A; --droid:#1F7A6B; --droid-soft:#DDF0EC; --agy:#4E7BD6;
+ --codex:#3A6B8A; --droid:#1F7A6B; --droid-soft:#DDF0EC; --agy:#4E7BD6; --kimi:#8A5A2B;
--ok:#2E7D5B; --ok-soft:#E0F0E8; --warn:#B07A2B; --warn-soft:#F6ECD9;
--bad:#A8443A; --bad-soft:#F6E3E0;
--shadow:0 1px 2px rgba(20,24,29,.05), 0 8px 24px -14px rgba(20,24,29,.20);
@@ -18,7 +18,7 @@
--ground:#0E1216; --surface:#161C23; --surface-alt:#1C232B;
--line:#2B343E; --line-soft:#222A33; --ink:#E4E9EE; --ink-2:#B4BEC9; --muted:#8593A2;
--accent:#7FB0D0; --accent-soft:#1A2B38; --main-hue:#B49AD4; --main-soft:#241C31;
- --codex:#7FB0D0; --droid:#63C4B0; --droid-soft:#142C28; --agy:#8FB4F5;
+ --codex:#7FB0D0; --droid:#63C4B0; --droid-soft:#142C28; --agy:#8FB4F5; --kimi:#D9A66E;
--ok:#6FC49B; --ok-soft:#15271E; --warn:#D9A857; --warn-soft:#292013;
--bad:#E08076; --bad-soft:#2B1816;
--shadow:0 1px 2px rgba(0,0,0,.45), 0 10px 28px -16px rgba(0,0,0,.75);
@@ -28,7 +28,7 @@
--ground:#0E1216; --surface:#161C23; --surface-alt:#1C232B;
--line:#2B343E; --line-soft:#222A33; --ink:#E4E9EE; --ink-2:#B4BEC9; --muted:#8593A2;
--accent:#7FB0D0; --accent-soft:#1A2B38; --main-hue:#B49AD4; --main-soft:#241C31;
- --codex:#7FB0D0; --droid:#63C4B0; --droid-soft:#142C28; --agy:#8FB4F5;
+ --codex:#7FB0D0; --droid:#63C4B0; --droid-soft:#142C28; --agy:#8FB4F5; --kimi:#D9A66E;
--ok:#6FC49B; --ok-soft:#15271E; --warn:#D9A857; --warn-soft:#292013;
--bad:#E08076; --bad-soft:#2B1816;
--shadow:0 1px 2px rgba(0,0,0,.45), 0 10px 28px -16px rgba(0,0,0,.75);
@@ -149,6 +149,7 @@
.seg[data-a="droid"] { background-color:var(--droid); }
.seg[data-a="main"], .seg[data-a="claude"] { background-color:var(--main-hue); }
.seg[data-a="agy"] { background-color:var(--accent); }
+ .seg[data-a="kimi"] { background-color:var(--kimi); }
.lane-durations { grid-column:2; display:flex; flex-wrap:wrap; gap:6px 16px;
font-family:var(--mono); font-size:11px; line-height:1.6; color:var(--muted); }
.lane-duration { white-space:nowrap; }
@@ -636,6 +637,10 @@
What shipped
// Factory's own mark, taken from factory.ai's nav logo.
droid: '',
+ // Kimi's mark (simple-icons, CC0 icon set; the mark itself remains Moonshot
+ // AI's trademark and is used here only to identify the tool).
+ kimi: '',
};
/**
* A mark for an agent with no official one.
@@ -1184,7 +1189,7 @@
What shipped
// chips — and so the same agent keeps its colour across reloads.
const HUES = {
claude: "var(--main-hue)", codex: "var(--codex)", droid: "var(--droid)",
- grok: "var(--ink-2)", agy: "var(--agy)",
+ grok: "var(--ink-2)", agy: "var(--agy)", kimi: "var(--kimi)",
};
function hueFor(name) {
if (HUES[name]) return HUES[name];