kimi-plugin-cc 2.0.7
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/.agents/plugins/marketplace.json +20 -0
- package/.claude-plugin/marketplace.json +16 -0
- package/.claude-plugin/plugin.json +32 -0
- package/CHANGELOG.md +1096 -0
- package/LICENSE +201 -0
- package/README.md +772 -0
- package/agents/kimi-ask.md +82 -0
- package/agents/kimi-challenge.md +82 -0
- package/agents/kimi-pursue.md +91 -0
- package/agents/kimi-rescue.md +85 -0
- package/agents/kimi-review.md +82 -0
- package/agents/kimi-swarm-write.md +92 -0
- package/agents/kimi-swarm.md +83 -0
- package/commands/README.md +16 -0
- package/commands/ask.md +32 -0
- package/commands/cancel.md +17 -0
- package/commands/challenge.md +29 -0
- package/commands/pursue.md +37 -0
- package/commands/replay.md +13 -0
- package/commands/rescue.md +32 -0
- package/commands/result.md +13 -0
- package/commands/review.md +29 -0
- package/commands/setup.md +23 -0
- package/commands/status.md +13 -0
- package/commands/swarm.md +38 -0
- package/dist/background-spawn.js +224 -0
- package/dist/cli-cancellation.js +43 -0
- package/dist/cli-client.js +845 -0
- package/dist/commands/ask.js +327 -0
- package/dist/commands/cancel.js +178 -0
- package/dist/commands/cli-helpers.js +112 -0
- package/dist/commands/models.js +140 -0
- package/dist/commands/not-implemented.js +4 -0
- package/dist/commands/pursue.js +311 -0
- package/dist/commands/registry.js +87 -0
- package/dist/commands/repair-sessions.js +159 -0
- package/dist/commands/replay.js +123 -0
- package/dist/commands/rescue.js +333 -0
- package/dist/commands/result.js +58 -0
- package/dist/commands/review-gate.js +393 -0
- package/dist/commands/review.js +218 -0
- package/dist/commands/setup.js +1132 -0
- package/dist/commands/status.js +27 -0
- package/dist/commands/swarm.js +576 -0
- package/dist/commands/worker.js +31 -0
- package/dist/companion.js +114 -0
- package/dist/config.js +22 -0
- package/dist/errors.js +40 -0
- package/dist/git.js +215 -0
- package/dist/hooks/approval-hook.js +112 -0
- package/dist/hooks/approval-policy.js +254 -0
- package/dist/hooks/config-safety.js +637 -0
- package/dist/hooks/install-paths.js +504 -0
- package/dist/hooks/install.js +281 -0
- package/dist/hooks/managed-block.js +745 -0
- package/dist/hooks/review-gate-stop.js +19 -0
- package/dist/job-store.js +559 -0
- package/dist/jobs.js +202 -0
- package/dist/kimi-command.js +72 -0
- package/dist/kimi-engine.js +624 -0
- package/dist/kimi-errors.js +220 -0
- package/dist/kimi-home.js +9 -0
- package/dist/kimi-timeouts.js +68 -0
- package/dist/kimi-version-probe.js +1410 -0
- package/dist/logging.js +22 -0
- package/dist/native-v2-preflight.js +417 -0
- package/dist/package.json +1 -0
- package/dist/parsing.js +553 -0
- package/dist/paths.js +27 -0
- package/dist/plugin-data.js +98 -0
- package/dist/process-tree.js +448 -0
- package/dist/render.js +159 -0
- package/dist/rescue-approval.js +951 -0
- package/dist/schemas/review-gate-output.js +43 -0
- package/dist/session-title.js +293 -0
- package/dist/stream-json.js +498 -0
- package/dist/types.js +1 -0
- package/dist/vendor/shell-quote/LICENSE +24 -0
- package/dist/vendor/shell-quote/index.js +1 -0
- package/dist/vendor/shell-quote/parse.js +229 -0
- package/dist/vendor/smol-toml/LICENSE +24 -0
- package/dist/vendor/smol-toml/date.js +127 -0
- package/dist/vendor/smol-toml/error.js +63 -0
- package/dist/vendor/smol-toml/extract.js +100 -0
- package/dist/vendor/smol-toml/parse.js +148 -0
- package/dist/vendor/smol-toml/primitive.js +179 -0
- package/dist/vendor/smol-toml/struct.js +184 -0
- package/dist/vendor/smol-toml/util.js +113 -0
- package/dist/version.js +7 -0
- package/docs/migration.md +215 -0
- package/docs/models.md +82 -0
- package/docs/registry-releases.md +70 -0
- package/docs/safety.md +318 -0
- package/docs/session-visibility.md +32 -0
- package/hooks/README.md +9 -0
- package/hooks/hooks.json +16 -0
- package/package.json +67 -0
- package/plugins/kimi-codex/.codex-plugin/plugin.json +39 -0
- package/plugins/kimi-codex/dist/background-spawn.js +224 -0
- package/plugins/kimi-codex/dist/cli-cancellation.js +43 -0
- package/plugins/kimi-codex/dist/cli-client.js +845 -0
- package/plugins/kimi-codex/dist/commands/ask.js +327 -0
- package/plugins/kimi-codex/dist/commands/cancel.js +178 -0
- package/plugins/kimi-codex/dist/commands/cli-helpers.js +112 -0
- package/plugins/kimi-codex/dist/commands/models.js +140 -0
- package/plugins/kimi-codex/dist/commands/not-implemented.js +4 -0
- package/plugins/kimi-codex/dist/commands/pursue.js +311 -0
- package/plugins/kimi-codex/dist/commands/registry.js +87 -0
- package/plugins/kimi-codex/dist/commands/repair-sessions.js +159 -0
- package/plugins/kimi-codex/dist/commands/replay.js +123 -0
- package/plugins/kimi-codex/dist/commands/rescue.js +333 -0
- package/plugins/kimi-codex/dist/commands/result.js +58 -0
- package/plugins/kimi-codex/dist/commands/review-gate.js +393 -0
- package/plugins/kimi-codex/dist/commands/review.js +218 -0
- package/plugins/kimi-codex/dist/commands/setup.js +1132 -0
- package/plugins/kimi-codex/dist/commands/status.js +27 -0
- package/plugins/kimi-codex/dist/commands/swarm.js +576 -0
- package/plugins/kimi-codex/dist/commands/worker.js +31 -0
- package/plugins/kimi-codex/dist/companion.js +114 -0
- package/plugins/kimi-codex/dist/config.js +22 -0
- package/plugins/kimi-codex/dist/errors.js +40 -0
- package/plugins/kimi-codex/dist/git.js +215 -0
- package/plugins/kimi-codex/dist/hooks/approval-hook.js +112 -0
- package/plugins/kimi-codex/dist/hooks/approval-policy.js +254 -0
- package/plugins/kimi-codex/dist/hooks/config-safety.js +637 -0
- package/plugins/kimi-codex/dist/hooks/install-paths.js +504 -0
- package/plugins/kimi-codex/dist/hooks/install.js +281 -0
- package/plugins/kimi-codex/dist/hooks/managed-block.js +745 -0
- package/plugins/kimi-codex/dist/hooks/review-gate-stop.js +19 -0
- package/plugins/kimi-codex/dist/job-store.js +559 -0
- package/plugins/kimi-codex/dist/jobs.js +202 -0
- package/plugins/kimi-codex/dist/kimi-command.js +72 -0
- package/plugins/kimi-codex/dist/kimi-engine.js +624 -0
- package/plugins/kimi-codex/dist/kimi-errors.js +220 -0
- package/plugins/kimi-codex/dist/kimi-home.js +9 -0
- package/plugins/kimi-codex/dist/kimi-timeouts.js +68 -0
- package/plugins/kimi-codex/dist/kimi-version-probe.js +1410 -0
- package/plugins/kimi-codex/dist/logging.js +22 -0
- package/plugins/kimi-codex/dist/native-v2-preflight.js +417 -0
- package/plugins/kimi-codex/dist/package.json +1 -0
- package/plugins/kimi-codex/dist/parsing.js +553 -0
- package/plugins/kimi-codex/dist/paths.js +27 -0
- package/plugins/kimi-codex/dist/plugin-data.js +98 -0
- package/plugins/kimi-codex/dist/process-tree.js +448 -0
- package/plugins/kimi-codex/dist/render.js +159 -0
- package/plugins/kimi-codex/dist/rescue-approval.js +951 -0
- package/plugins/kimi-codex/dist/schemas/review-gate-output.js +43 -0
- package/plugins/kimi-codex/dist/session-title.js +293 -0
- package/plugins/kimi-codex/dist/stream-json.js +498 -0
- package/plugins/kimi-codex/dist/types.js +1 -0
- package/plugins/kimi-codex/dist/vendor/shell-quote/LICENSE +24 -0
- package/plugins/kimi-codex/dist/vendor/shell-quote/index.js +1 -0
- package/plugins/kimi-codex/dist/vendor/shell-quote/parse.js +229 -0
- package/plugins/kimi-codex/dist/vendor/smol-toml/LICENSE +24 -0
- package/plugins/kimi-codex/dist/vendor/smol-toml/date.js +127 -0
- package/plugins/kimi-codex/dist/vendor/smol-toml/error.js +63 -0
- package/plugins/kimi-codex/dist/vendor/smol-toml/extract.js +100 -0
- package/plugins/kimi-codex/dist/vendor/smol-toml/parse.js +148 -0
- package/plugins/kimi-codex/dist/vendor/smol-toml/primitive.js +179 -0
- package/plugins/kimi-codex/dist/vendor/smol-toml/struct.js +184 -0
- package/plugins/kimi-codex/dist/vendor/smol-toml/util.js +113 -0
- package/plugins/kimi-codex/dist/version.js +7 -0
- package/plugins/kimi-codex/scripts/companion.sh +37 -0
- package/plugins/kimi-codex/scripts/review-gate-hook.sh +32 -0
- package/plugins/kimi-codex/skills/kimi-ask/SKILL.md +31 -0
- package/plugins/kimi-codex/skills/kimi-ask/agents/openai.yaml +6 -0
- package/plugins/kimi-codex/skills/kimi-cancel/SKILL.md +25 -0
- package/plugins/kimi-codex/skills/kimi-cancel/agents/openai.yaml +6 -0
- package/plugins/kimi-codex/skills/kimi-challenge/SKILL.md +31 -0
- package/plugins/kimi-codex/skills/kimi-challenge/agents/openai.yaml +6 -0
- package/plugins/kimi-codex/skills/kimi-pursue/SKILL.md +30 -0
- package/plugins/kimi-codex/skills/kimi-pursue/agents/openai.yaml +6 -0
- package/plugins/kimi-codex/skills/kimi-replay/SKILL.md +25 -0
- package/plugins/kimi-codex/skills/kimi-replay/agents/openai.yaml +6 -0
- package/plugins/kimi-codex/skills/kimi-rescue/SKILL.md +30 -0
- package/plugins/kimi-codex/skills/kimi-rescue/agents/openai.yaml +6 -0
- package/plugins/kimi-codex/skills/kimi-result/SKILL.md +25 -0
- package/plugins/kimi-codex/skills/kimi-result/agents/openai.yaml +6 -0
- package/plugins/kimi-codex/skills/kimi-review/SKILL.md +31 -0
- package/plugins/kimi-codex/skills/kimi-review/agents/openai.yaml +6 -0
- package/plugins/kimi-codex/skills/kimi-setup/SKILL.md +32 -0
- package/plugins/kimi-codex/skills/kimi-setup/agents/openai.yaml +6 -0
- package/plugins/kimi-codex/skills/kimi-status/SKILL.md +25 -0
- package/plugins/kimi-codex/skills/kimi-status/agents/openai.yaml +6 -0
- package/plugins/kimi-codex/skills/kimi-swarm/SKILL.md +31 -0
- package/plugins/kimi-codex/skills/kimi-swarm/agents/openai.yaml +6 -0
- package/plugins/kimi-codex/skills/kimi-swarm-write/SKILL.md +32 -0
- package/plugins/kimi-codex/skills/kimi-swarm-write/agents/openai.yaml +6 -0
- package/scripts/companion.sh +37 -0
- package/scripts/review-gate-hook.sh +32 -0
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { runAsk } from "./commands/ask.js";
|
|
2
|
+
import { runModelInventory } from "./commands/models.js";
|
|
3
|
+
import { runCancel } from "./commands/cancel.js";
|
|
4
|
+
import { notImplementedCompanionCommand } from "./commands/not-implemented.js";
|
|
5
|
+
import { runReplay } from "./commands/replay.js";
|
|
6
|
+
import { runRepairSessions } from "./commands/repair-sessions.js";
|
|
7
|
+
import { runResult } from "./commands/result.js";
|
|
8
|
+
import { runReview } from "./commands/review.js";
|
|
9
|
+
import { runRescue } from "./commands/rescue.js";
|
|
10
|
+
import { runPursue } from "./commands/pursue.js";
|
|
11
|
+
import { runSwarm } from "./commands/swarm.js";
|
|
12
|
+
import { renderSetupResult, runSetup } from "./commands/setup.js";
|
|
13
|
+
import { runStatus } from "./commands/status.js";
|
|
14
|
+
import { runWorker } from "./commands/worker.js";
|
|
15
|
+
import { formatError, RuntimeError } from "./errors.js";
|
|
16
|
+
import { resolveKimiHome } from "./kimi-home.js";
|
|
17
|
+
async function main(argv) {
|
|
18
|
+
const [command, ...rest] = argv;
|
|
19
|
+
const cwd = process.env.KIMI_PLUGIN_CC_WORKSPACE_CWD || process.cwd();
|
|
20
|
+
const context = {
|
|
21
|
+
cwd,
|
|
22
|
+
env: {
|
|
23
|
+
...process.env,
|
|
24
|
+
KIMI_CODE_HOME: resolveKimiHome(process.env, cwd),
|
|
25
|
+
},
|
|
26
|
+
stdout: process.stdout,
|
|
27
|
+
stderr: process.stderr,
|
|
28
|
+
};
|
|
29
|
+
switch (command) {
|
|
30
|
+
case "setup": {
|
|
31
|
+
if (rest.includes("--models")) {
|
|
32
|
+
const args = [...rest];
|
|
33
|
+
args.splice(args.indexOf("--models"), 1);
|
|
34
|
+
context.stdout.write(`${await runModelInventory(args, context)}\n`);
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
const result = await runSetup(rest, context);
|
|
38
|
+
context.stdout.write(`${renderSetupResult(result)}\n`);
|
|
39
|
+
// A failed probe means the hook did not actually deny when exercised —
|
|
40
|
+
// enforcement is NOT in place, whichever action produced it. This must be
|
|
41
|
+
// nonzero for `install` too, not just `check`: since v1.9.0 an agent may
|
|
42
|
+
// run setup and then RETRY the refused command, and it keys that decision
|
|
43
|
+
// on the exit code. Exiting 0 after a failed probe would walk it straight
|
|
44
|
+
// past a proven-dead hook (a byte-exact command + a readable-but-broken
|
|
45
|
+
// script passes `verifyHookInstalled`, since only the probe can tell that
|
|
46
|
+
// the script cannot deny — e.g. a truncated `approval-hook.js`, which has
|
|
47
|
+
// happened here via iCloud sync, or a half-extracted plugin cache).
|
|
48
|
+
// `probe: "skipped"` (uninstall) is deliberately untouched.
|
|
49
|
+
if (result.probe === "failed") {
|
|
50
|
+
process.exitCode = 1;
|
|
51
|
+
}
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
case "review": {
|
|
55
|
+
const result = await runReview(rest, context, "review");
|
|
56
|
+
context.stdout.write(`${result}\n`);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
case "task": {
|
|
60
|
+
const [taskType, ...taskArgs] = rest;
|
|
61
|
+
if (taskType === "rescue") {
|
|
62
|
+
const result = await runRescue(taskArgs, context);
|
|
63
|
+
context.stdout.write(result);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
if (taskType === "challenge") {
|
|
67
|
+
const result = await runReview(taskArgs, context, "challenge");
|
|
68
|
+
context.stdout.write(`${result}\n`);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
if (taskType === "pursue") {
|
|
72
|
+
const result = await runPursue(taskArgs, context);
|
|
73
|
+
context.stdout.write(result);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
if (taskType === "swarm") {
|
|
77
|
+
const result = await runSwarm(taskArgs, context);
|
|
78
|
+
context.stdout.write(result);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
notImplementedCompanionCommand(taskType ? `task ${taskType}` : "task");
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
case "ask": {
|
|
85
|
+
const result = await runAsk(rest, context);
|
|
86
|
+
context.stdout.write(`${result}\n`);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
case "status":
|
|
90
|
+
context.stdout.write(await runStatus(rest, context));
|
|
91
|
+
return;
|
|
92
|
+
case "result":
|
|
93
|
+
context.stdout.write(await runResult(rest, context));
|
|
94
|
+
return;
|
|
95
|
+
case "cancel":
|
|
96
|
+
context.stdout.write(await runCancel(rest, context));
|
|
97
|
+
return;
|
|
98
|
+
case "replay":
|
|
99
|
+
context.stdout.write(await runReplay(rest, context));
|
|
100
|
+
return;
|
|
101
|
+
case "repair-sessions":
|
|
102
|
+
context.stdout.write(await runRepairSessions(rest, context));
|
|
103
|
+
return;
|
|
104
|
+
case "worker":
|
|
105
|
+
await runWorker(rest, context);
|
|
106
|
+
return;
|
|
107
|
+
default:
|
|
108
|
+
throw new RuntimeError("INVALID_COMMAND", `Unknown or missing companion subcommand: ${command ?? "<none>"}. Expected one of setup, review, task, ask, status, result, cancel, replay, repair-sessions.`, "companion");
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
main(process.argv.slice(2)).catch((error) => {
|
|
112
|
+
process.stderr.write(`${formatError(error)}\n`);
|
|
113
|
+
process.exitCode = 1;
|
|
114
|
+
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
const DEFAULT_CONFIG = {
|
|
3
|
+
reviewGateEnabled: false,
|
|
4
|
+
};
|
|
5
|
+
export async function readPluginConfig(paths) {
|
|
6
|
+
try {
|
|
7
|
+
const contents = await readFile(paths.configPath, "utf8");
|
|
8
|
+
const parsed = JSON.parse(contents);
|
|
9
|
+
return {
|
|
10
|
+
reviewGateEnabled: parsed.reviewGateEnabled ?? DEFAULT_CONFIG.reviewGateEnabled,
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
catch (error) {
|
|
14
|
+
if (error.code === "ENOENT") {
|
|
15
|
+
return DEFAULT_CONFIG;
|
|
16
|
+
}
|
|
17
|
+
throw error;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
export async function writePluginConfig(paths, config) {
|
|
21
|
+
await writeFile(paths.configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
|
|
22
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
export class RuntimeError extends Error {
|
|
2
|
+
code;
|
|
3
|
+
stage;
|
|
4
|
+
details;
|
|
5
|
+
constructor(code, message, stage, options) {
|
|
6
|
+
super(message, options);
|
|
7
|
+
this.name = "RuntimeError";
|
|
8
|
+
this.code = code;
|
|
9
|
+
this.stage = stage;
|
|
10
|
+
this.details = options?.details ?? {};
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
export function formatError(error) {
|
|
14
|
+
if (error instanceof RuntimeError) {
|
|
15
|
+
const base = `[${error.code}] [${error.stage}] ${error.message}`;
|
|
16
|
+
// Serialize structured context so an LLM caller can act on it. Without
|
|
17
|
+
// this, `details` existed only in the SQLite job row — reachable via
|
|
18
|
+
// `result <jobId> --json` for BACKGROUND jobs, but simply lost for
|
|
19
|
+
// foreground ones (review/challenge/ask), whose only output is this
|
|
20
|
+
// string. v1.9.0 needs `drift_axis` to reach the caller on every path, so
|
|
21
|
+
// it is rendered here as one deterministic JSON line. (Kimi's pre-release
|
|
22
|
+
// review caught that the agents were told to branch on a field no channel
|
|
23
|
+
// actually carried.)
|
|
24
|
+
const details = error.details;
|
|
25
|
+
if (details !== undefined && Object.keys(details).length > 0) {
|
|
26
|
+
try {
|
|
27
|
+
return `${base}\ndetails: ${JSON.stringify(details)}`;
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
// A non-serializable value must never suppress the error itself.
|
|
31
|
+
return base;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return base;
|
|
35
|
+
}
|
|
36
|
+
if (error instanceof Error) {
|
|
37
|
+
return error.message;
|
|
38
|
+
}
|
|
39
|
+
return String(error);
|
|
40
|
+
}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { execFile } from "node:child_process";
|
|
3
|
+
import { realpath, rm } from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { promisify } from "node:util";
|
|
6
|
+
import { RuntimeError } from "./errors.js";
|
|
7
|
+
const execFileAsync = promisify(execFile);
|
|
8
|
+
const MAX_DIFF_CHARS = 80_000;
|
|
9
|
+
/**
|
|
10
|
+
* Buffer ceiling for a write-swarm capture patch. Unlike review diffs (capped at
|
|
11
|
+
* MAX_DIFF_CHARS for prompt budget), a capture patch MUST be byte-complete to
|
|
12
|
+
* `git apply`, so it is never truncated — only bounded by this generous buffer to
|
|
13
|
+
* avoid an unbounded RSS spike. A swarm patch larger than this is pathological.
|
|
14
|
+
*/
|
|
15
|
+
const MAX_PATCH_BYTES = 64 * 1024 * 1024;
|
|
16
|
+
export async function collectReviewContext(cwd, base) {
|
|
17
|
+
const { repoRoot } = await resolveRepoIdentity(cwd);
|
|
18
|
+
const statusSummary = (await runGit(["status", "--short", "--untracked-files=all"], cwd)).trim();
|
|
19
|
+
if (base) {
|
|
20
|
+
const diffText = await getBaseDiff(cwd, base);
|
|
21
|
+
return {
|
|
22
|
+
repoRoot,
|
|
23
|
+
statusSummary,
|
|
24
|
+
diffText: truncateDiff(diffText),
|
|
25
|
+
targetDescription: `branch diff against ${base}`,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
// Working-tree review is repo-wide. status --short is already repo-wide; limiting diff to
|
|
29
|
+
// the caller's subdirectory would mask unrelated-but-relevant changes elsewhere in the repo.
|
|
30
|
+
const headDiff = await runGitAllowFailure(["diff", "--no-ext-diff", "--unified=3", "HEAD", "--"], cwd);
|
|
31
|
+
const diffText = headDiff.ok && headDiff.stdout.trim().length > 0
|
|
32
|
+
? headDiff.stdout
|
|
33
|
+
: await runGit(["diff", "--no-ext-diff", "--unified=3", "--"], cwd);
|
|
34
|
+
return {
|
|
35
|
+
repoRoot,
|
|
36
|
+
statusSummary,
|
|
37
|
+
diffText: truncateDiff(diffText),
|
|
38
|
+
targetDescription: "current working tree changes",
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
export async function resolveRepoIdentity(cwd) {
|
|
42
|
+
const workspaceRoot = await realpath(cwd);
|
|
43
|
+
const result = await runGitAllowFailure(["rev-parse", "--show-toplevel", "--git-common-dir"], cwd);
|
|
44
|
+
if (!result.ok) {
|
|
45
|
+
return {
|
|
46
|
+
repoId: sha256(workspaceRoot),
|
|
47
|
+
repoRoot: workspaceRoot,
|
|
48
|
+
gitCommonDir: null,
|
|
49
|
+
isGitRepo: false,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
const [repoRootRaw, gitCommonDirRaw] = result.stdout
|
|
53
|
+
.trim()
|
|
54
|
+
.split("\n")
|
|
55
|
+
.map((line) => line.trim())
|
|
56
|
+
.filter(Boolean);
|
|
57
|
+
const repoRoot = repoRootRaw ? await realpath(repoRootRaw) : workspaceRoot;
|
|
58
|
+
const gitCommonDir = gitCommonDirRaw && gitCommonDirRaw.length > 0
|
|
59
|
+
? await realpath(path.resolve(cwd, gitCommonDirRaw))
|
|
60
|
+
: null;
|
|
61
|
+
return {
|
|
62
|
+
repoId: sha256(`${gitCommonDir ?? repoRoot}::${repoRoot}`),
|
|
63
|
+
repoRoot,
|
|
64
|
+
gitCommonDir,
|
|
65
|
+
isGitRepo: true,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
async function getBaseDiff(cwd, base) {
|
|
69
|
+
// --end-of-options is a git safety marker (since 2.24) telling git every token after it is
|
|
70
|
+
// a refspec, not a flag. Defense in depth against argument injection via --base.
|
|
71
|
+
const threeDot = await runGitAllowFailure(["diff", "--no-ext-diff", "--unified=3", "--end-of-options", `${base}...HEAD`, "--"], cwd);
|
|
72
|
+
if (threeDot.ok) {
|
|
73
|
+
return threeDot.stdout;
|
|
74
|
+
}
|
|
75
|
+
return runGit(["diff", "--no-ext-diff", "--unified=3", "--end-of-options", base, "--"], cwd);
|
|
76
|
+
}
|
|
77
|
+
async function runGit(args, cwd) {
|
|
78
|
+
const result = await runGitAllowFailure(args, cwd);
|
|
79
|
+
if (!result.ok) {
|
|
80
|
+
throw new RuntimeError("GIT_COMMAND_FAILED", `git ${args.join(" ")} failed: ${result.stderr || "unknown error"}`, "review.git");
|
|
81
|
+
}
|
|
82
|
+
return result.stdout;
|
|
83
|
+
}
|
|
84
|
+
async function runGitAllowFailure(args, cwd) {
|
|
85
|
+
try {
|
|
86
|
+
const { stdout, stderr } = await execFileAsync("git", args, {
|
|
87
|
+
cwd,
|
|
88
|
+
env: process.env,
|
|
89
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
90
|
+
});
|
|
91
|
+
return { ok: true, stdout, stderr };
|
|
92
|
+
}
|
|
93
|
+
catch (error) {
|
|
94
|
+
const execError = error;
|
|
95
|
+
return {
|
|
96
|
+
ok: false,
|
|
97
|
+
stdout: execError.stdout ?? "",
|
|
98
|
+
stderr: execError.stderr ?? execError.message,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
function truncateDiff(diffText) {
|
|
103
|
+
if (diffText.length <= MAX_DIFF_CHARS) {
|
|
104
|
+
return diffText;
|
|
105
|
+
}
|
|
106
|
+
return `${diffText.slice(0, MAX_DIFF_CHARS)}\n\n[diff truncated by companion after ${MAX_DIFF_CHARS} characters]`;
|
|
107
|
+
}
|
|
108
|
+
function sha256(value) {
|
|
109
|
+
return createHash("sha256").update(value).digest("hex");
|
|
110
|
+
}
|
|
111
|
+
// ---------------------------------------------------------------------
|
|
112
|
+
// Ephemeral worktree lifecycle — write-swarm (v1.4).
|
|
113
|
+
//
|
|
114
|
+
// A write-swarm spawns its coordinator `kimi -p` with cwd = a throwaway
|
|
115
|
+
// detached worktree off the user's HEAD, so `coder` subagents edit there and
|
|
116
|
+
// never touch the user's checkout. The worktree is a LINKED worktree: its
|
|
117
|
+
// working tree + index are isolated and disposable, but it SHARES the main
|
|
118
|
+
// repo's object DB and refs. The PreToolUse hook (swarm-write label) confines
|
|
119
|
+
// every subagent write to this worktree; the plugin captures the result as a
|
|
120
|
+
// patch and removes the worktree. See .claude/docs/write-swarm-spec.md.
|
|
121
|
+
// ---------------------------------------------------------------------
|
|
122
|
+
/**
|
|
123
|
+
* True if the repo at `cwd` has a born HEAD (at least one commit). `git worktree
|
|
124
|
+
* add --detach HEAD` fails on an unborn HEAD (fresh repo, no commits) and on a
|
|
125
|
+
* bare repo, so write-swarm gates on this before creating any job state.
|
|
126
|
+
*/
|
|
127
|
+
export async function hasBornHead(cwd) {
|
|
128
|
+
const result = await runGitAllowFailure(["rev-parse", "--verify", "--quiet", "HEAD"], cwd);
|
|
129
|
+
return result.ok && result.stdout.trim().length > 0;
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Create a throwaway detached worktree at `worktreePath` checked out to `base`
|
|
133
|
+
* (write-swarm hard-codes "HEAD"; never user input). `worktreePath` MUST be
|
|
134
|
+
* outside the repo (the plugin's worktrees dir). Throws GIT_WORKTREE_ADD_FAILED
|
|
135
|
+
* on failure. Caller owns removal (`removeWorktree`) on EVERY terminal path.
|
|
136
|
+
*/
|
|
137
|
+
export async function createEphemeralWorktree(repoRoot, base, worktreePath) {
|
|
138
|
+
const result = await runGitAllowFailure(["worktree", "add", "--detach", worktreePath, base], repoRoot);
|
|
139
|
+
if (!result.ok) {
|
|
140
|
+
throw new RuntimeError("GIT_WORKTREE_ADD_FAILED", `git worktree add failed: ${result.stderr || "unknown error"}`, "swarm.worktree", { details: { repoRoot, worktreePath, base } });
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Capture the full change set in a worktree as a byte-complete, applyable patch.
|
|
145
|
+
*
|
|
146
|
+
* `git add -N -- .` records intent-to-add index entries for untracked files so
|
|
147
|
+
* the diff includes their content — WITHOUT writing blob objects into the shared
|
|
148
|
+
* object DB (intent-to-add stores a zero-oid entry in the worktree's OWN index
|
|
149
|
+
* only). This keeps the "plugin never mutates git state" invariant literally
|
|
150
|
+
* true: no loose objects, no ref/HEAD movement.
|
|
151
|
+
*
|
|
152
|
+
* The diff is taken against **HEAD** (the worktree's base commit), NOT the index:
|
|
153
|
+
* `git diff HEAD` captures the complete change set regardless of index state, so
|
|
154
|
+
* the patch is invariant to any intermediate staging (e.g. if a tool ever
|
|
155
|
+
* `git add`s a file, a plain `git diff` would silently omit it; `git diff HEAD`
|
|
156
|
+
* still captures it). `--binary` makes binary changes applyable; the diff is
|
|
157
|
+
* NEVER truncated (a capture patch must apply cleanly) and uses a raised
|
|
158
|
+
* maxBuffer. Mirrors `collectReviewContext`'s HEAD-based diff.
|
|
159
|
+
*
|
|
160
|
+
* Returns the patch text (empty string if the swarm changed nothing).
|
|
161
|
+
*/
|
|
162
|
+
export async function captureWorktreePatch(worktreePath) {
|
|
163
|
+
// Best-effort intent-to-add so untracked files surface in `git diff HEAD`
|
|
164
|
+
// (without -N, git diff ignores untracked files entirely). A failure here just
|
|
165
|
+
// means new files are omitted from the diff, surfaced as a smaller patch.
|
|
166
|
+
await runGitAllowFailure(["add", "-N", "--", "."], worktreePath);
|
|
167
|
+
try {
|
|
168
|
+
const { stdout } = await execFileAsync("git", ["diff", "--binary", "HEAD", "--"], {
|
|
169
|
+
cwd: worktreePath,
|
|
170
|
+
env: process.env,
|
|
171
|
+
maxBuffer: MAX_PATCH_BYTES,
|
|
172
|
+
});
|
|
173
|
+
return stdout;
|
|
174
|
+
}
|
|
175
|
+
catch (error) {
|
|
176
|
+
const execError = error;
|
|
177
|
+
throw new RuntimeError("GIT_WORKTREE_DIFF_FAILED", `git diff (worktree capture) failed: ${execError.stderr || execError.message || "unknown error"}`, "swarm.worktree", { details: { worktreePath } });
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Remove a worktree (best-effort, idempotent). `--force` discards the dirty tree,
|
|
182
|
+
* so callers MUST capture the patch BEFORE calling this. Falls back to pruning the
|
|
183
|
+
* admin entry and removing the on-disk dir if `worktree remove` cannot (e.g. the
|
|
184
|
+
* dir was partially deleted). Never throws — cleanup must not mask the real error
|
|
185
|
+
* on a failure path.
|
|
186
|
+
*/
|
|
187
|
+
export async function removeWorktree(repoRoot, worktreePath) {
|
|
188
|
+
const removed = await runGitAllowFailure(["worktree", "remove", "--force", worktreePath], repoRoot);
|
|
189
|
+
if (!removed.ok) {
|
|
190
|
+
// Prune the admin entry (handles the case where the dir is already gone) then
|
|
191
|
+
// force-delete any on-disk remnant so no orphan accretes in the plugin dir.
|
|
192
|
+
await runGitAllowFailure(["worktree", "prune"], repoRoot);
|
|
193
|
+
await rm(worktreePath, { recursive: true, force: true }).catch(() => {
|
|
194
|
+
// Best-effort; a residual dir is swept on the next run.
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Prune stale worktree admin entries (entries whose on-disk dir is gone). Cheap;
|
|
200
|
+
* run at the start of every write-swarm. Does NOT remove on-disk worktrees whose
|
|
201
|
+
* dirs still exist — the caller sweeps those by path. Never throws.
|
|
202
|
+
*/
|
|
203
|
+
export async function pruneWorktrees(repoRoot) {
|
|
204
|
+
await runGitAllowFailure(["worktree", "prune"], repoRoot);
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* True if the working tree at `cwd` has uncommitted changes (tracked or
|
|
208
|
+
* untracked). write-swarm bases its worktree on HEAD, so a dirty tree means the
|
|
209
|
+
* user's in-progress work is NOT visible to the swarm — the caller warns. Returns
|
|
210
|
+
* false on a git failure (don't block the run on a status probe).
|
|
211
|
+
*/
|
|
212
|
+
export async function isWorkingTreeDirty(cwd) {
|
|
213
|
+
const result = await runGitAllowFailure(["status", "--porcelain", "--untracked-files=all"], cwd);
|
|
214
|
+
return result.ok && result.stdout.trim().length > 0;
|
|
215
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Entry script for the kimi-code PreToolUse hook.
|
|
3
|
+
//
|
|
4
|
+
// Installed by /kimi:setup as a managed block in ~/.kimi-code/config.toml:
|
|
5
|
+
//
|
|
6
|
+
// [[hooks]]
|
|
7
|
+
// event = "PreToolUse"
|
|
8
|
+
// command = "node /abs/path/to/dist/hooks/approval-hook.js"
|
|
9
|
+
// timeout = 15
|
|
10
|
+
//
|
|
11
|
+
// Hook protocol (kimi-code agent-core/src/session/hooks/runner.ts —
|
|
12
|
+
// path was agent/hooks/ in 0.4.0 and earlier; relocated to session/hooks/
|
|
13
|
+
// in 0.5.0 with byte-identical file contents):
|
|
14
|
+
//
|
|
15
|
+
// stdin: full hook payload as JSON
|
|
16
|
+
// stdout: optional structured JSON (only consulted when exit code is 0)
|
|
17
|
+
// stderr: free-form reason
|
|
18
|
+
// exit 0: allow (with optional structured override)
|
|
19
|
+
// exit 2: deny — `stderr.trim()` becomes the reason surfaced to the model
|
|
20
|
+
// any other exit code: fall through to allow (fail-open)
|
|
21
|
+
//
|
|
22
|
+
// We use exit-code signaling rather than structured JSON because:
|
|
23
|
+
//
|
|
24
|
+
// 1. Exit 2 + stderr is the simpler contract — fewer surfaces for a
|
|
25
|
+
// version skew between us and kimi-code to break safety.
|
|
26
|
+
// 2. The runner short-circuits exit-2 before structuredOutput() is
|
|
27
|
+
// called, so emitting both would be redundant.
|
|
28
|
+
// 3. A crash in our hook would normally exit non-zero and silently
|
|
29
|
+
// allow (fail-open). We compensate by wrapping every code path in
|
|
30
|
+
// try/catch and exiting 2 with a "hook misconfigured" reason on
|
|
31
|
+
// any unexpected error.
|
|
32
|
+
//
|
|
33
|
+
// Process boundary:
|
|
34
|
+
//
|
|
35
|
+
// kimi-code spawns this hook with `shell: true` (see runner.ts:46-53,
|
|
36
|
+
// where `spawn(command, { shell: true, ... })` is invoked).
|
|
37
|
+
// The TOML command is parsed by `/bin/sh -c "..."` so the resolved
|
|
38
|
+
// `node /abs/path/...` works regardless of where it's invoked from.
|
|
39
|
+
import { decideHookOutcome } from "./approval-policy.js";
|
|
40
|
+
import { evaluateRescueHookRequest } from "../rescue-approval.js";
|
|
41
|
+
const STDIN_TIMEOUT_MS = 5_000;
|
|
42
|
+
async function main() {
|
|
43
|
+
const stdinText = await readStdinWithTimeout(STDIN_TIMEOUT_MS);
|
|
44
|
+
let input;
|
|
45
|
+
try {
|
|
46
|
+
const parsed = JSON.parse(stdinText);
|
|
47
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
48
|
+
throw new Error("hook payload must be a JSON object");
|
|
49
|
+
}
|
|
50
|
+
input = parsed;
|
|
51
|
+
}
|
|
52
|
+
catch (err) {
|
|
53
|
+
failClosed(`stdin was not a JSON object: ${formatErr(err)}`);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
const decision = await decideHookOutcome(input, {
|
|
57
|
+
commandLabel: process.env.KIMI_PLUGIN_CC_CMD,
|
|
58
|
+
operationKind: process.env.KIMI_PLUGIN_CC_OPERATION,
|
|
59
|
+
rescueEvaluator: evaluateRescueHookRequest,
|
|
60
|
+
// Trusted root for rescue/pursue/swarm-write. Set by the plugin spawn; the
|
|
61
|
+
// model inside kimi cannot forge it and hook payload cwd is not trusted.
|
|
62
|
+
trustedWorkspaceRoot: process.env.KIMI_PLUGIN_CC_WORKSPACE_ROOT,
|
|
63
|
+
});
|
|
64
|
+
if (decision.decision === "deny") {
|
|
65
|
+
// stderr.trim() in runner.ts:118 becomes the model-visible reason.
|
|
66
|
+
process.stderr.write(`${decision.reason ?? "denied"}\n`);
|
|
67
|
+
process.exit(2);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
process.exit(0);
|
|
71
|
+
}
|
|
72
|
+
function failClosed(reason) {
|
|
73
|
+
process.stderr.write(`kimi-plugin-cc safety hook misconfigured: ${reason}\n`);
|
|
74
|
+
process.exit(2);
|
|
75
|
+
}
|
|
76
|
+
function formatErr(err) {
|
|
77
|
+
return err instanceof Error ? err.message : String(err);
|
|
78
|
+
}
|
|
79
|
+
function readStdinWithTimeout(timeoutMs) {
|
|
80
|
+
return new Promise((resolve, reject) => {
|
|
81
|
+
let buf = "";
|
|
82
|
+
let settled = false;
|
|
83
|
+
const finish = (value) => {
|
|
84
|
+
if (settled)
|
|
85
|
+
return;
|
|
86
|
+
settled = true;
|
|
87
|
+
clearTimeout(timer);
|
|
88
|
+
if (value instanceof Error)
|
|
89
|
+
reject(value);
|
|
90
|
+
else
|
|
91
|
+
resolve(value);
|
|
92
|
+
};
|
|
93
|
+
const timer = setTimeout(() => {
|
|
94
|
+
finish(new Error(`stdin did not close within ${timeoutMs}ms`));
|
|
95
|
+
}, timeoutMs);
|
|
96
|
+
timer.unref();
|
|
97
|
+
try {
|
|
98
|
+
process.stdin.setEncoding("utf8");
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
// some test harnesses pre-encode; fine to ignore
|
|
102
|
+
}
|
|
103
|
+
process.stdin.on("data", (chunk) => {
|
|
104
|
+
buf += typeof chunk === "string" ? chunk : chunk.toString("utf8");
|
|
105
|
+
});
|
|
106
|
+
process.stdin.on("end", () => finish(buf));
|
|
107
|
+
process.stdin.on("error", (err) => finish(err instanceof Error ? err : new Error(String(err))));
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
main().catch((err) => {
|
|
111
|
+
failClosed(`uncaught error: ${formatErr(err)}`);
|
|
112
|
+
});
|