feat(#2095): migrate Kimi CLI onto EoS imperative adapter + native hook-bus + background dispatch (ADR-1239)

Fold all runtime==='kimi'/isKimi logic branches into descriptor-driven
hostBehaviors (localInstallDeferred, verificationStyle, agentManifestStyle,
reapplyCommand, doneBannerStyle) + add 'kimi' to _DESCRIPTOR_AGENTS_RUNTIMES.
Kimi's skills/kimi-agents dispatch was already descriptor-driven (converter-by-
name + kimi-agents kind). Zero isKimi/runtime==='kimi' branches remain.

UPGRADE 1 (native hook bus): new hooksSurface 'kimi-hooks-toml' + a marker-
delimited config.toml [[hooks]] emitter (buildKimiHooksTomlBlock/writeKimiHooksToml
in runtime-hooks-surface.cts; resolveKimiHooksTomlDir in runtime-homes.cts).
GSD's lifecycle hooks now wire into Kimi's native ~/.kimi/config.toml (Context7-
confirmed path) at SessionStart/PreToolUse/Stop/PreCompact/SubagentStart/
SubagentStop — kimi becomes a hooks/ consumer (the 3 && !isKimi exclusion guards
removed). config.toml holds absolute install paths so it's golden-excluded via
an exact relative-path (.kimi/config.toml), not a basename (which would blind
Codex's config.toml). New hooksSurface value added to the closed enum in
capability-validator + runtime-config-adapter-registry.
UPGRADE 2 (background dispatch): flip dispatch.backgroundDispatch true (Kimi's
Agent tool takes run_in_background; root agent already gets the Agent tool), so
negotiation no longer flattens dispatch. subagentToolkit stays 'undocumented'
per AC (coder/explore/plan have distinct tool policies).
MCP transport explicitly deferred (no installer-driven MCP for any runtime).

Golden: only kimi.json changes (hooks/ scripts now installed); all 15 others +
claude-local byte-identical (kilo/zcode keep their own exclusions). Tests:
kimi-imperative-reference (adapter/axes/fail-closed/hostBehaviors + source-grep
guard) + kimi-upgrades (config.toml [[hooks]] SessionStart + marker idempotency
+ backgroundDispatch negotiation). CONTEXT.md glossary + matrix + how-to updated;
changeset (Added).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-07-10 23:30:09 -04:00
parent a022eec117
commit ab04916682
23 changed files with 1239 additions and 103 deletions

View File

@@ -505,6 +505,14 @@ function convertGsdCommandReferencesToKimiSkillInvocations(content, cmdNames) {
.replace(hyphenPattern, (_, cmd) => `/skill:gsd-${cmd}`);
}
// DEFECT.GENERATIVE-FIX: this body is mirrored in bin/install.js's
// convertClaudeCommandToKimiSkill (kept for bin/install.js's own
// module-level export/test surface; dead for the live skills-install path,
// which routes here via install-engine.cts's SKILLS_CONVERTER_REGISTRY
// through the kimi capability descriptor's artifactLayout
// `converter: "convertClaudeCommandToKimiSkill"`). Neither copy re-exports
// the other — mirror any behavior change into both. Guarded by the
// output-parity test in tests/runtime-converters.test.cjs (#2095).
function convertClaudeCommandToKimiSkill(content, skillName, _runtime = null, cmdNames = null) {
const { frontmatter, body } = extractFrontmatterAndBody(content);
const kimiSkillName = normalizeKimiSkillName(skillName);
@@ -649,6 +657,15 @@ function buildKimiSubagentYaml({ name, description, tools }) {
return `${lines.join('\n')}\n`;
}
// DEFECT.GENERATIVE-FIX: this body is mirrored in bin/install.js's
// buildKimiAgentArtifacts (kept for bin/install.js's own module-level
// export/test surface; dead for the live install path, which routes here via
// runtime-artifact-layout.cts's kimiAgentsKind through a dynamic
// `conversionExports['buildKimiAgentArtifacts']` lookup against this
// compiled module). Neither copy re-exports the other — mirror any behavior
// change into both, including the kimi_cli.tools.agent:Agent grant that
// enables background dispatch (#2095 Upgrade 2). Guarded by the
// output-parity test in tests/runtime-converters.test.cjs (#2095).
function buildKimiAgentArtifacts({
rootAgent = '',
subagents = [],

View File

@@ -53,6 +53,7 @@ type HooksSurface =
| 'cursor-hooks-json'
| 'cline-rules'
| 'copilot-inline'
| 'kimi-hooks-toml'
| 'none';
interface RuntimeConfigIntent {

View File

@@ -369,6 +369,31 @@ export function resolveKimiGlobalDir(opts: ResolveKimiOpts = {}): string {
);
}
/**
* Resolve the directory holding Kimi CLI's OWN native config.toml (the file
* Kimi itself reads for providers/models/hooks/etc — see
* moonshotai.github.io/kimi-cli/en/configuration/data-locations.html and
* .../reference/kimi-command.html). Default `~/.kimi`, overridden by
* `KIMI_SHARE_DIR` per Kimi's own upstream env-var (NOT `KIMI_CONFIG_DIR`,
* which is a GSD-installer write-location override for the unrelated generic
* Agent-Skills root resolved by resolveKimiGlobalDir above).
*
* This is deliberately a SEPARATE directory from GSD's kimi configHome
* (~/.config/agents): Kimi's own docs confirm the Agent-Skills search path is
* independent of KIMI_SHARE_DIR ("This variable does not affect Agent Skills
* search paths, which are handled separately"). #2095 Upgrade 1 writes GSD's
* native [[hooks]] entries into `<this dir>/config.toml`, never into the
* skills configDir.
*/
export function resolveKimiHooksTomlDir(opts: ResolveKimiOpts = {}): string {
const env: Record<string, string | undefined> = opts.env ?? process.env;
const home = opts.home ?? os.homedir();
return resolveConfigHomeFromDescriptor(
{ kind: 'dot-home', name: '.kimi', env: ['KIMI_SHARE_DIR'] },
{ env, home },
);
}
/**
* Return the global config base directory for the given runtime.
* Respects the same env-var overrides as bin/install.js getGlobalDir().

View File

@@ -44,6 +44,7 @@ const {
projectPortableHookBaseDir,
projectCodexHookTomlCommand,
shellHookOmitsBashRunner,
escapeTomlDoubleQuotedString,
} = shellCmdProjection as {
isManagedHookBasename: (scriptPath: string, opts?: { surface?: string }) => boolean;
isManagedHookCommand: (cmd: string | null | undefined, opts?: { surface?: string; includeLegacyAliases?: boolean; configDir?: string }) => boolean;
@@ -52,6 +53,7 @@ const {
projectPortableHookBaseDir: (opts: { configDir: string; homeDir: string }) => string;
projectCodexHookTomlCommand: (opts: { absoluteRunner: string; scriptPath: string; platform: string }) => string;
shellHookOmitsBashRunner: (opts: { platform: string; runtime: string; isShellHook: boolean }) => boolean;
escapeTomlDoubleQuotedString: (value: unknown) => string;
};
// ---------------------------------------------------------------------------
@@ -1245,7 +1247,13 @@ function applySettingsJsonHooks(settings: any, opts: ApplySettingsJsonHooksOpts)
// register settings.json hooks; runtimes with hooksSurface === 'none'
// (opencode, kilo) are skipped. Equivalence: hooksSurface !== 'none' iff
// the old !isOpencode && !isKilo check.
if (hooksSurface !== 'none') {
// #2095: kimi's hooksSurface is 'kimi-hooks-toml' — it registers hooks into
// its own native config.toml via writeKimiHooksToml, not settings.json (kimi
// never writes settings.json at all: writesSharedSettings stays false). This
// guard must also skip kimi's surface so applySettingsJsonHooks doesn't log
// misleading "Configured ..." console messages for a settings object that
// finishInstall() will never persist for kimi.
if (hooksSurface !== 'none' && hooksSurface !== 'kimi-hooks-toml') {
if (!settings.hooks) {
settings.hooks = {};
}
@@ -1725,6 +1733,215 @@ function applySettingsJsonHooks(settings: any, opts: ApplySettingsJsonHooksOpts)
@typescript-eslint/no-unsafe-assignment */
}
// ---------------------------------------------------------------------------
// Kimi hooks.toml (#2095 EoS/kimi Upgrade 1 — native hook bus)
//
// Kimi CLI reads lifecycle hooks from a flat `[[hooks]]` array in its own
// config.toml (moonshotai.github.io/kimi-cli/en/customization/hooks.html),
// not from settings.json. Unlike every other hooksSurface writer above, this
// file lives OUTSIDE the runtime's GSD configDir: kimi's configDir is the
// generic Agent-Skills root (~/.config/agents by default), while config.toml
// is a sibling at ~/.kimi (KIMI_SHARE_DIR override), resolved by
// resolveKimiHooksTomlDir in runtime-homes.cts. Callers resolve that path and
// pass it in explicitly — this module never reaches into runtime-homes.cjs
// itself, keeping the same configDir/targetDir-passed-in shape every other
// writer in this file uses.
//
// GSD-owned [[hooks]] entries are wrapped in marker comments so a reinstall
// can find-and-replace only GSD's own block, leaving any user-authored
// [[hooks]] entries elsewhere in the file untouched — mirrors the marker
// approach stripStaleGsdHookBlocks uses for Codex's config.toml, simplified
// to plain string slicing since this block is a flat, self-contained span
// (no nested per-key structural TOML parsing is needed).
// ---------------------------------------------------------------------------
const KIMI_HOOKS_TOML_MARKER_BEGIN = '# GSD Hooks BEGIN — managed by GSD, do not edit between these markers';
const KIMI_HOOKS_TOML_MARKER_END = '# GSD Hooks END';
interface KimiHookEntrySpec {
event: string;
command: string | null;
matcher?: string;
timeout?: number;
}
function buildKimiHookEntryToml(spec: KimiHookEntrySpec): string | null {
if (!spec.command) return null;
const lines = ['[[hooks]]', `event = "${spec.event}"`];
if (spec.matcher) {
lines.push(`matcher = "${escapeTomlDoubleQuotedString(spec.matcher)}"`);
}
lines.push(`command = "${escapeTomlDoubleQuotedString(spec.command)}"`);
if (typeof spec.timeout === 'number') {
lines.push(`timeout = ${spec.timeout}`);
}
return lines.join('\n');
}
/**
* Build the full marker-delimited GSD [[hooks]] block for kimi's config.toml,
* or null when no GSD hook resolved to a usable command (hooks/ missing, or
* the node/bash runner could not be resolved — mirrors the #1754/#3002
* defensive guards applySettingsJsonHooks applies per-hook above).
*
* Event -> hook mapping mirrors applySettingsJsonHooks' settings.json wiring
* 1:1 by GSD hook script (update check, session-state, phase-boundary,
* graphify, context monitor, prompt/read/workflow/worktree guards, commit
* validation). Kimi's 13 lifecycle events include exact-name equivalents for
* every Claude-dialect event GSD currently wires (SessionStart, PreToolUse,
* PostToolUse, Stop, PreCompact, SubagentStart, SubagentStop) — see
* moonshotai.github.io/kimi-cli/en/customization/hooks.html.
*
* Matcher translation (best-effort — Kimi's tool-name vocabulary is
* confirmed distinct from Claude's by the upstream hooks doc's own examples):
* Bash -> Shell, Write -> WriteFile, Edit/MultiEdit -> StrReplaceFile.
* Read -> ReadFile follows the same WriteFile/StrReplaceFile naming
* convention but is not independently doc-confirmed. Claude's Agent|Task
* (subagent-dispatch) matcher segment has no confirmed Kimi tool name and is
* dropped rather than guessed — gsd-context-monitor's PostToolUse entry runs
* unmatched (all tools) instead, which only widens when it fires, it never
* narrows incorrectly.
*/
function buildKimiHooksTomlBlock(targetDir: string, opts: { hookOpts: BuildHookCommandOpts }): string | null {
const { hookOpts } = opts;
const cmd = (hookName: string): string | null => {
if (!fs.existsSync(path.join(targetDir, 'hooks', hookName))) return null;
return buildHookCommand(targetDir, hookName, hookOpts);
};
const specs: KimiHookEntrySpec[] = [
// SessionStart — unmatched (session-level; no tool_name to filter on).
{ event: 'SessionStart', command: cmd('gsd-check-update.js') },
{ event: 'SessionStart', command: cmd('gsd-session-state.sh') },
// PreToolUse
{ event: 'PreToolUse', command: cmd('gsd-prompt-guard.js'), matcher: 'WriteFile|StrReplaceFile', timeout: 5 },
{ event: 'PreToolUse', command: cmd('gsd-read-guard.js'), matcher: 'WriteFile|StrReplaceFile', timeout: 5 },
{ event: 'PreToolUse', command: cmd('gsd-worktree-path-guard.js'), matcher: 'WriteFile|StrReplaceFile', timeout: 5 },
{ event: 'PreToolUse', command: cmd('gsd-workflow-guard.js'), matcher: 'Shell|WriteFile|StrReplaceFile', timeout: 5 },
{ event: 'PreToolUse', command: cmd('gsd-validate-commit.sh'), matcher: 'Shell', timeout: 5 },
// PostToolUse
{ event: 'PostToolUse', command: cmd('gsd-context-monitor.js'), timeout: 10 },
{ event: 'PostToolUse', command: cmd('gsd-phase-boundary.sh'), matcher: 'WriteFile|StrReplaceFile', timeout: 5 },
{ event: 'PostToolUse', command: cmd('gsd-read-injection-scanner.js'), matcher: 'ReadFile', timeout: 5 },
{ event: 'PostToolUse', command: cmd('gsd-graphify-update.sh'), matcher: 'Shell', timeout: 5 },
// Extended lifecycle events — context-headroom tracking (unmatched).
{ event: 'Stop', command: cmd('gsd-context-monitor.js'), timeout: 10 },
{ event: 'PreCompact', command: cmd('gsd-context-monitor.js'), timeout: 10 },
{ event: 'SubagentStart', command: cmd('gsd-context-monitor.js'), timeout: 10 },
{ event: 'SubagentStop', command: cmd('gsd-context-monitor.js'), timeout: 10 },
];
const entries = specs
.map(buildKimiHookEntryToml)
.filter((entry): entry is string => entry !== null);
if (entries.length === 0) return null;
return [KIMI_HOOKS_TOML_MARKER_BEGIN, '', entries.join('\n\n'), '', KIMI_HOOKS_TOML_MARKER_END].join('\n');
}
/**
* Strip a previously-written GSD [[hooks]] block from kimi's config.toml
* content. Pure string function (no fs access) so install, uninstall, and
* tests share one strip implementation. Returns null when stripping leaves
* nothing but whitespace (the file was GSD-only), so the caller can unlink
* it instead of writing an empty file.
*/
function stripKimiHooksTomlBlock(content: string): string | null {
const beginIdx = content.indexOf(KIMI_HOOKS_TOML_MARKER_BEGIN);
if (beginIdx === -1) {
return content.trim() === '' ? null : content;
}
const endMarkerIdx = content.indexOf(KIMI_HOOKS_TOML_MARKER_END, beginIdx);
if (endMarkerIdx === -1) {
// Malformed marker pair — BEGIN present but no END after it (missing END,
// or an END that only appears earlier in the file, before BEGIN). Never
// fall back to content.length here: that would slice to EOF and destroy
// every user section that follows. Leave the content untouched instead;
// a subsequent writeKimiHooksToml call will append a fresh, well-formed
// block rather than silently deleting user data.
return content;
}
const endIdx = endMarkerIdx + KIMI_HOOKS_TOML_MARKER_END.length;
// Swallow blank lines immediately surrounding the block so repeated
// strip+rewrite cycles never accumulate blank lines.
let sliceStart = beginIdx;
while (sliceStart > 0 && (content[sliceStart - 1] === '\n' || content[sliceStart - 1] === '\r')) sliceStart -= 1;
let sliceEnd = endIdx;
while (sliceEnd < content.length && (content[sliceEnd] === '\n' || content[sliceEnd] === '\r')) sliceEnd += 1;
const before = content.slice(0, sliceStart);
const after = content.slice(sliceEnd);
// Blank-line swallowing above consumes every newline flanking the block,
// including the one required to keep the surrounding user sections on
// separate lines. If the block sat BETWEEN two user sections (content
// survives on both sides), concatenating `before` + `after` directly would
// glue the last line of the earlier section onto the first line of the
// later one. Reinsert a blank-line separator in that case; when only one
// side has content (block at file start or EOF), no separator is needed —
// that matches the pre-existing idempotent behavior for those shapes.
const result = before.trim() !== '' && after.trim() !== ''
? `${before}\n\n${after}`
: before + after;
return result.trim() === '' ? null : result;
}
/**
* Idempotently (re)write kimi's GSD-owned [[hooks]] block into its native
* config.toml at `configPath` (resolved by the caller via
* resolveKimiHooksTomlDir). No-ops (`{changed:false}`) when the computed
* block is byte-identical to what's already on disk, so reinstalls don't
* touch the file's mtime for no reason.
*/
function writeKimiHooksToml(
configPath: string,
targetDir: string,
opts: { hookOpts: BuildHookCommandOpts },
): { changed: boolean; path: string; entryCount: number } {
const existing = fs.existsSync(configPath) ? fs.readFileSync(configPath, 'utf8') : '';
const stripped = stripKimiHooksTomlBlock(existing) ?? '';
const block = buildKimiHooksTomlBlock(targetDir, opts);
const entryCount = block ? (block.match(/\[\[hooks\]\]/g) || []).length : 0;
if (!block) {
if (stripped === existing) return { changed: false, path: configPath, entryCount: 0 };
if (stripped.trim() === '') {
if (fs.existsSync(configPath)) fs.unlinkSync(configPath);
} else {
fs.mkdirSync(path.dirname(configPath), { recursive: true });
atomicWriteFileSync(configPath, stripped, 'utf8');
}
return { changed: true, path: configPath, entryCount: 0 };
}
const separator = stripped.trim() === '' ? '' : (stripped.endsWith('\n') ? '\n' : '\n\n');
const next = stripped.trim() === '' ? `${block}\n` : `${stripped}${separator}${block}\n`;
if (next === existing) return { changed: false, path: configPath, entryCount };
fs.mkdirSync(path.dirname(configPath), { recursive: true });
atomicWriteFileSync(configPath, next, 'utf8');
return { changed: true, path: configPath, entryCount };
}
/**
* Uninstall-time counterpart to writeKimiHooksToml: strips the GSD block and
* deletes the file if nothing but GSD's own block was ever in it.
*/
function removeKimiHooksToml(configPath: string): { changed: boolean } {
if (!fs.existsSync(configPath)) return { changed: false };
const existing = fs.readFileSync(configPath, 'utf8');
const stripped = stripKimiHooksTomlBlock(existing);
if (stripped === existing) return { changed: false };
if (stripped === null || stripped.trim() === '') {
fs.unlinkSync(configPath);
} else {
atomicWriteFileSync(configPath, stripped, 'utf8');
}
return { changed: true };
}
// ---------------------------------------------------------------------------
// referencesHook
//
@@ -1798,6 +2015,14 @@ export = {
buildCodexHookBlock,
rewriteLegacyCodexHookBlock,
// Kimi hooks.toml
buildKimiHooksTomlBlock,
stripKimiHooksTomlBlock,
writeKimiHooksToml,
removeKimiHooksToml,
KIMI_HOOKS_TOML_MARKER_BEGIN,
KIMI_HOOKS_TOML_MARKER_END,
// Shared
buildHookCommand,
applySettingsJsonHooks,