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

@@ -0,0 +1,5 @@
---
type: Added
pr: 2159
---
**GSD's lifecycle hooks now run under Kimi CLI** — installing GSD into Kimi wires its session-state, phase-boundary, graphify, and guard hooks into Kimi's own native `config.toml` `[[hooks]]` bus (Beta on Kimi's side) instead of silently no-op'ing, and GSD's Kimi subagents can now run in the background. Kimi's install is driven by its negotiated capability descriptor instead of hardcoded runtime special-cases. (#2095)

View File

@@ -270,7 +270,7 @@ A per-agent narrative entry written by `mempalace_diary_write`. GSD's `gsd-mempa
The `mempalace.memory_mode` config key controlling how authoritative MemPalace is during recall/capture relative to GSD's native memory. Three wired values: `augment` (default — palace is an additive recall layer; native memory stays authoritative; lowest coupling), `kg_backend` (knowledge-graph queries resolve against MemPalace's temporal graph as the primary source, `.planning/graphs/` as fallback; non-KG drawer recall stays additive), `replace` (recall resolves through the palace as the source of truth, native artifacts as fallback). Every mode is `onError:skip` and default-resilient — an unreachable palace degrades to native memory and GSD keeps writing `.planning/graphs/`, so no mode loses memory. Read at hook-render time; switching is a config change, not a reinstall. Cross-mode migration of existing `.planning/graphs/` into the palace is a separate, not-yet-implemented concern (PRD/ADR §17 open question). See MemPalace Settings in `docs/CONFIGURATION.md`.
### Runtime Hooks Surface Module
Standalone hook-surface writer module extracted from `bin/install.js` as ADR-857 phase 5f-1 (behavior-preserving relocation, no logic change). Owns: Cline rules-body/agents-md/pre-tool-use hook generation (`buildClineRulesBody`, `buildClineAgentsMdBody`, `buildClinePreToolUseHook`, `mergeGsdAgentsMd`, `writeClineArtifacts`); Cursor `hooks.json` lifecycle (`buildCursorHookEntry`, `isManagedCursorHookEntry`, `reconcileCursorHooksJson`, `writeCursorHooksJson`, `removeCursorHooksJson`); Copilot session-hook config (`buildCopilotHookConfig`, `writeCopilotHookConfig`); Codex hook-block and event management (`buildCodexHookBlock`, `rewriteLegacyCodexHookBlock`, `reconcileCodexHooksJsonEvent`, `reconcileCodexHooksJsonSessionStart`, `ensureCodexHooksJsonSessionStart`, `ensureCodexHooksJsonEvent`, `removeCodexHooksJsonEvent`, `removeCodexHooksJsonSessionStart`, `buildCodexHookWindowsShimIR`); and shared hook command helpers (`buildHookCommand`, `rewriteLegacyManagedNodeHookCommands`, `normalizeNodePath`, `resolveNodeRunner`). `bin/install.js` delegates to this module via thin wrappers and re-exports its functions unchanged so existing tests require no modification. Source: `src/runtime-hooks-surface.cts`. Built output: `gsd-core/bin/lib/runtime-hooks-surface.cjs`.
Standalone hook-surface writer module extracted from `bin/install.js` as ADR-857 phase 5f-1 (behavior-preserving relocation, no logic change). Owns: Cline rules-body/agents-md/pre-tool-use hook generation (`buildClineRulesBody`, `buildClineAgentsMdBody`, `buildClinePreToolUseHook`, `mergeGsdAgentsMd`, `writeClineArtifacts`); Cursor `hooks.json` lifecycle (`buildCursorHookEntry`, `isManagedCursorHookEntry`, `reconcileCursorHooksJson`, `writeCursorHooksJson`, `removeCursorHooksJson`); Copilot session-hook config (`buildCopilotHookConfig`, `writeCopilotHookConfig`); Codex hook-block and event management (`buildCodexHookBlock`, `rewriteLegacyCodexHookBlock`, `reconcileCodexHooksJsonEvent`, `reconcileCodexHooksJsonSessionStart`, `ensureCodexHooksJsonSessionStart`, `ensureCodexHooksJsonEvent`, `removeCodexHooksJsonEvent`, `removeCodexHooksJsonSessionStart`, `buildCodexHookWindowsShimIR`); Kimi native config.toml `[[hooks]]` lifecycle (`buildKimiHooksTomlBlock`, `stripKimiHooksTomlBlock`, `writeKimiHooksToml`, `removeKimiHooksToml` — #2095 EoS/kimi Upgrade 1, the first genuinely NEW hook surface added post-relocation rather than a behavior-preserving move: kimi's `[[hooks]]` array lives in its own native `config.toml`, resolved by `resolveKimiHooksTomlDir` in Runtime Homes Module to a directory deliberately separate from kimi's GSD configDir, wrapped in `# GSD Hooks BEGIN`/`END` marker comments for idempotent reinstall); and shared hook command helpers (`buildHookCommand`, `rewriteLegacyManagedNodeHookCommands`, `normalizeNodePath`, `resolveNodeRunner`). `bin/install.js` delegates to this module via thin wrappers and re-exports its functions unchanged so existing tests require no modification. Source: `src/runtime-hooks-surface.cts`. Built output: `gsd-core/bin/lib/runtime-hooks-surface.cjs`.
### Runtime Config Adapter Registry
Module owning the explicit per-runtime config-mutation dispatch table for the installer. `resolveRuntimeConfigIntent(runtime)` projects a typed config intent — `installSurface` (`settings-json` | `codex-toml` | `copilot-instructions` | `cline-rules` | `cursor-hooks-json` | `profile-marker-only`), `writesSharedSettings` (the `finishInstall` shared-settings write gate), and `finishPermissionWriter` (`opencode` | `kilo` | none) — that `bin/install.js` dispatches on instead of inline `runtime === '...'` branching. Owns adapter selection only: it performs no filesystem IO and does not execute config mutations (the install/finishInstall handlers and the per-runtime writers do that). Unknown runtimes fail loudly with a `TypeError`, guarded by an `Object.hasOwn` own-property check so prototype-chain keys (`__proto__`, `constructor`) also throw. Also exports `resolveInstallPlan(runtime)` — the ADR-58 `InstallPlan` capstone — which collects the install-level descriptor axes (`installSurface`, `writesSharedSettings`, `finishPermissionWriter`, `hookEvents`, `extendedHookEvents`, `hooksSurface`, `sandboxTier`) into one typed `InstallPlan` value consumed by `install()` and `finishInstall()` in `bin/install.js`. `sandboxTier` (`none` | `codex-agent-sandbox`) gates per-agent `sandbox_mode` emission in the codex TOML path and fails loud on a missing/invalid value (#1151). The spatial axes (`configHome`, `artifactLayout`, `commandStyle`) remain behind their self-resolving adapter modules and are not part of the plan; they are the execution adapters. Realizes both the adapter-selection and plan-collection halves of the Runtime Install Policy Module boundary. Source: `gsd-core/bin/lib/runtime-config-adapter-registry.cjs`. See ADR-58, #60.

View File

@@ -32,6 +32,7 @@ const {
resolveAntigravityGlobalDir,
getGlobalConfigDir,
getGlobalSkillsBase,
resolveKimiHooksTomlDir,
} = require('../gsd-core/bin/lib/runtime-homes.cjs');
// getDirName (runtime -> local config dir name) is relocated out of this
// installer to the runtime-name-policy leaf (ADR-1508 / #1510 Phase 1) so the
@@ -754,6 +755,10 @@ const referencesHook = hooksSurface.referencesHook;
// applySettingsJsonHooks: mutates settings.hooks.* in place with all GSD-managed
// hook registrations for settings.json-surface runtimes (ADR-857 phase 5f-1b).
const applySettingsJsonHooks = hooksSurface.applySettingsJsonHooks;
// writeKimiHooksToml / removeKimiHooksToml: kimi's native config.toml [[hooks]]
// surface (#2095 EoS/kimi Upgrade 1) — separate from settings.json entirely.
const writeKimiHooksToml = hooksSurface.writeKimiHooksToml;
const removeKimiHooksToml = hooksSurface.removeKimiHooksToml;
// processAttribution: pure Co-Authored-By content transform, relocated to the
// conversion module (ADR-1508 / #1510 Phase 1). Bound here so install.js
// callers continue to work and there is a single implementation. (All call
@@ -1900,6 +1905,14 @@ function convertGsdCommandReferencesToKimiSkillInvocations(content, cmdNames) {
.replace(hyphenPattern, (_, cmd) => `/skill:gsd-${cmd}`);
}
// DEFECT.GENERATIVE-FIX: this body is mirrored in
// src/runtime-artifact-conversion.cts's convertClaudeCommandToKimiSkill (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"`;
// kept for bin/install.js's own module-level export/test surface). 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);
@@ -2044,6 +2057,16 @@ function buildKimiSubagentYaml({ name, description, tools }) {
return `${lines.join('\n')}\n`;
}
// DEFECT.GENERATIVE-FIX: this body is mirrored in
// src/runtime-artifact-conversion.cts's buildKimiAgentArtifacts (dead for the
// live install path, which routes here via runtime-artifact-layout.cts's
// kimiAgentsKind — see its `conversionExports['buildKimiAgentArtifacts']`
// dynamic lookup against the compiled runtime-artifact-conversion.cjs; kept
// for bin/install.js's own module-level export/test surface). 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 = [],
@@ -6790,25 +6813,21 @@ function validateHookFields(settings) {
* GSD hook filenames removed during uninstall.
* Module-level so tests can assert structurally instead of regex-parsing source
* (retires pending-migration-to-typed-ir on hooks-opt-in.test.cjs, per #455).
*
* Derived from _HOOKS_TO_COPY (scripts/build-hooks.js — the SAME single source
* of truth INSTALLED_HOOK_FILES uses for manifest-tracking above) instead of a
* separately hand-maintained literal array. The hand-maintained array had
* silently drifted out of sync with the install-time set — missing
* gsd-check-update-worker.js, gsd-ensure-canonical-path.js,
* managed-hooks-registry.cjs, gsd-cursor-pre-tool.js, gsd-cursor-stop.js,
* gsd-cursor-subagent-start.js, gsd-cursor-subagent-stop.js, and
* gsd-worktree-path-guard.js — so every one of those files (and the hooks/ dir
* itself, via the non-empty-dir rmdir guard) was left behind on uninstall for
* every settings-json-hook runtime. `gsd-check-update.cmd` is added on top: a
* Windows-only SessionStart shim generated at install time (not copied from
* hooks/dist/, so it is not in _HOOKS_TO_COPY).
*/
const GSD_UNINSTALL_HOOKS = [
'gsd-statusline.js',
'gsd-check-update.js',
'gsd-check-update.cmd',
'gsd-config-reload.js',
'gsd-context-monitor.js',
'gsd-cursor-session-start.js',
'gsd-cursor-post-tool.js',
'gsd-prompt-guard.js',
'gsd-read-guard.js',
'gsd-read-injection-scanner.js',
'gsd-update-banner.js',
'gsd-workflow-guard.js',
'gsd-session-state.sh',
'gsd-validate-commit.sh',
'gsd-phase-boundary.sh',
'gsd-graphify-update.sh',
];
const GSD_UNINSTALL_HOOKS = [..._HOOKS_TO_COPY, 'gsd-check-update.cmd'];
/**
* Uninstall GSD from the specified directory for a specific runtime
@@ -6822,8 +6841,9 @@ function uninstall(isGlobal, runtime = DEFAULT_RUNTIME) {
// not gated on this flag.
// #2094: isTrae dropped — unused in this function after the
// skipSharedHooksInstall fold (was never referenced here besides the
// destructure).
const { isOpencode, isCodex, isCopilot, isAntigravity, isCursor, isWindsurf, isAugment, isQwen, isHermes, isCodebuddy, isCline, isKimi } = runtimeFlags(runtime);
// destructure). #2095: isKimi likewise dropped — kimi is now a hooks/
// consumer, so its former `&& !isKimi` uninstall guards were removed.
const { isOpencode, isCodex, isCopilot, isAntigravity, isCursor, isWindsurf, isAugment, isQwen, isHermes, isCodebuddy, isCline } = runtimeFlags(runtime);
const dirName = getDirName(runtime);
// Get the target directory based on runtime and install type. Cline local
@@ -6962,6 +6982,79 @@ function uninstall(isGlobal, runtime = DEFAULT_RUNTIME) {
}
}
// 1a-kimi. Non-layout Kimi side-effect (#2095 EoS/kimi Upgrade 1): kimi's
// native config.toml lives outside targetDir entirely (resolveKimiHooksTomlDir
// resolves ~/.kimi, a sibling of targetDir's ~/.config/agents), so its
// cleanup can't be driven by anything under targetDir the way every other
// hook surface above is.
if (resolveInstallPlan(runtime).hooksSurface === 'kimi-hooks-toml') {
const kimiHooksRoot = resolveKimiHooksTomlDir();
const kimiHooksTomlPath = path.join(kimiHooksRoot, 'config.toml');
const kimiHooksCleanup = removeKimiHooksToml(kimiHooksTomlPath);
if (kimiHooksCleanup.changed) {
removedCount++;
console.log(` ${green}✓${reset} Removed GSD hooks from ${kimiHooksTomlPath}`);
}
// Kimi's shared hook scripts + CommonJS package.json marker are installed
// into this SAME ~/.kimi root (installSharedHooksBundle, install()'s
// kimi-hooks-toml branch) rather than under targetDir — mirror steps "4.
// Remove GSD hooks" / "5. Remove GSD package.json" below, but scoped to
// kimiHooksRoot. ~/.kimi is Kimi's own native config home (shared space —
// may hold the user's real config.toml/providers), so only the exact
// GSD-owned filenames are removed, and directories are pruned only if left
// empty by that removal.
const kimiHooksDir = path.join(kimiHooksRoot, 'hooks');
if (fs.existsSync(kimiHooksDir)) {
let kimiHookCount = 0;
for (const hook of GSD_UNINSTALL_HOOKS) {
const hookPath = path.join(kimiHooksDir, hook);
if (fs.existsSync(hookPath)) {
fs.unlinkSync(hookPath);
kimiHookCount++;
}
}
if (kimiHookCount > 0) {
removedCount++;
console.log(` ${green}✓${reset} Removed ${kimiHookCount} GSD hooks from ${kimiHooksDir}`);
}
const kimiHooksLibDir = path.join(kimiHooksDir, 'lib');
if (fs.existsSync(kimiHooksLibDir)) {
let removedKimiLibFiles = 0;
for (const file of GSD_HOOK_LIB_FILES) {
try {
fs.unlinkSync(path.join(kimiHooksLibDir, file));
removedKimiLibFiles++;
} catch (_) { /* best-effort */ }
}
try { fs.rmdirSync(kimiHooksLibDir); } catch (_) { /* not empty or other error — leave it */ }
if (removedKimiLibFiles > 0) {
removedCount++;
console.log(` ${green}✓${reset} Removed ${removedKimiLibFiles} hooks/lib/ helper(s) from ${kimiHooksLibDir}`);
}
}
try {
if (fs.readdirSync(kimiHooksDir).length === 0) fs.rmdirSync(kimiHooksDir);
} catch (_) { /* not empty — leave it */ }
}
const kimiPkgJsonPath = path.join(kimiHooksRoot, 'package.json');
if (fs.existsSync(kimiPkgJsonPath)) {
try {
const content = fs.readFileSync(kimiPkgJsonPath, 'utf8').trim();
if (content === '{"type":"commonjs"}') {
fs.unlinkSync(kimiPkgJsonPath);
removedCount++;
console.log(` ${green}✓${reset} Removed GSD package.json from ${kimiHooksRoot}`);
}
} catch (e) {
// Ignore read errors
}
}
}
// 1b. Non-layout Copilot side-effect: copilot-instructions.md cleanup
if (isCopilot) {
const instructionsPath = path.join(targetDir, 'copilot-instructions.md');
@@ -7867,7 +7960,9 @@ function writeManifest(configDir, runtime = DEFAULT_RUNTIME, options = {}) {
// #2093: isKilo dropped — unused in this function.
// #2094: isTrae dropped — was only used in the hooks-tracking conditional
// above, now covered by hostBehaviors.skipSharedHooksInstall.
const { isOpencode, isCodex, isCopilot, isAntigravity, isCursor, isWindsurf, isAugment, isQwen, isHermes, isCodebuddy, isCline, isKimi } = runtimeFlags(runtime);
// #2095: isKimi dropped — kimi is now a hooks/ consumer like every other
// settings-json-adjacent runtime, so the `&& !isKimi` term below was removed.
const { isOpencode, isCodex, isCopilot, isAntigravity, isCursor, isWindsurf, isAugment, isQwen, isHermes, isCodebuddy, isCline } = runtimeFlags(runtime);
const gsdDir = path.join(configDir, 'gsd-core');
// #1367: Claude local now writes flat gsd-*.md files at commands/ (not commands/gsd/).
// Claude local uses flatCommandsDir instead for manifest recording.
@@ -7941,7 +8036,7 @@ function writeManifest(configDir, runtime = DEFAULT_RUNTIME, options = {}) {
}
}
}
if (isKimi && fs.existsSync(agentsDir)) {
if (_hostBehaviors(runtime).agentManifestStyle === 'kimi-nested' && fs.existsSync(agentsDir)) {
const agentHashes = generateManifest(agentsDir);
for (const [rel, hash] of Object.entries(agentHashes)) {
const isRootAgent = rel === 'gsd.yaml' || rel === 'gsd.md';
@@ -7977,7 +8072,9 @@ function writeManifest(configDir, runtime = DEFAULT_RUNTIME, options = {}) {
// hostBehaviors.skipSharedHooksInstall (was hardcoded !isCline).
// #2094: Trae's exclusion is likewise descriptor-driven (trae declares
// skipSharedHooksInstall:true) — the redundant `&& !isTrae` was removed.
if (!isCodex && !isCopilot && _hostBehaviors(runtime).skipSharedHooksInstall !== true && !isWindsurf && !isKimi) {
// #2095: kimi is now a hooks/ consumer (native config.toml [[hooks]] bus) —
// the redundant `&& !isKimi` was removed so its hook files are tracked too.
if (!isCodex && !isCopilot && _hostBehaviors(runtime).skipSharedHooksInstall !== true && !isWindsurf) {
const hooksDir = path.join(configDir, 'hooks');
if (fs.existsSync(hooksDir)) {
// Drive from INSTALLED_HOOK_FILES (the canonical HOOKS_TO_COPY set from
@@ -8338,11 +8435,7 @@ function reportLocalPatches(configDir, runtime = DEFAULT_RUNTIME) {
try { meta = JSON.parse(fs.readFileSync(metaPath, 'utf8')); } catch { return []; }
if (meta.files && meta.files.length > 0) {
const reapplyCommand = _hostBehaviors(runtime).reapplyCommand
? _hostBehaviors(runtime).reapplyCommand
: runtime === 'kimi'
? '/skill:gsd-update --reapply'
: '/gsd-update --reapply';
const reapplyCommand = _hostBehaviors(runtime).reapplyCommand || '/gsd-update --reapply';
console.log('');
console.log(' ' + yellow + 'Local patches detected' + reset + ' (from v' + meta.from_version + '):');
for (const f of meta.files) {
@@ -8371,12 +8464,17 @@ function reportInstallerMigrationResult(result) {
function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
// #2093: isKilo dropped — Kilo's agent/model-override handling below reads
// _hostBehaviors(runtime).frontmatterDialect === 'kilo' instead of this flag.
const { isOpencode, isZcode, isCodex, isCopilot, isAntigravity, isCursor, isWindsurf, isAugment, isTrae, isQwen, isHermes, isCodebuddy, isCline, isKimi } = runtimeFlags(runtime);
// #2095: isKimi dropped — kimi is now a hooks/ consumer like every other
// settings-json-adjacent runtime; the two `&& !isKimi` hooks-copy guards
// below were removed, leaving isKimi unused in this function (the kimi
// local-install-deferred branch above already reads
// _hostBehaviors(runtime).localInstallDeferred instead of this flag).
const { isOpencode, isZcode, isCodex, isCopilot, isAntigravity, isCursor, isWindsurf, isAugment, isTrae, isQwen, isHermes, isCodebuddy, isCline } = runtimeFlags(runtime);
const plan = resolveInstallPlan(runtime);
const dirName = getDirName(runtime);
const src = path.join(__dirname, '..');
if (isKimi && !isGlobal) {
if (_hostBehaviors(runtime).localInstallDeferred && !isGlobal) {
console.log(` ${yellow}⚠${reset} Kimi local install is deferred for Phase 2.`);
console.log(` No .kimi-code/skills or .agents/skills project artifacts were written.`);
console.log(` Project-level Kimi install semantics remain deferred.`);
@@ -8908,7 +9006,7 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
} else {
failures.push('skills/gsd/*');
}
} else if (isKimi) {
} else if (_hostBehaviors(runtime).verificationStyle === 'kimi') {
const skillsDir = path.join(targetDir, 'skills');
const rootAgentPath = path.join(targetDir, 'agents', 'gsd.yaml');
if (fs.existsSync(skillsDir)) {
@@ -9199,7 +9297,7 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
// brandingRewrites-only branch).
// cline remains excluded: rules-only local branch + local/global complication
// that the descriptor-driven path does not handle correctly.
const _DESCRIPTOR_AGENTS_RUNTIMES = new Set(['cursor', 'windsurf', 'augment', 'trae', 'codebuddy', 'copilot', 'antigravity', 'qwen']);
const _DESCRIPTOR_AGENTS_RUNTIMES = new Set(['cursor', 'windsurf', 'augment', 'trae', 'codebuddy', 'copilot', 'antigravity', 'qwen', 'kimi']);
// Always remove stale gsd-* agents first so re-installing with
// `--minimal` actually shrinks a previously-full install.
@@ -9217,9 +9315,7 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
}
}
if (isKimi) {
console.log(` ${dim}↳${reset} Kimi custom agent YAML/prompt artifacts were installed via runtime artifact layout`);
} else if (_DESCRIPTOR_AGENTS_RUNTIMES.has(runtime)) {
if (_DESCRIPTOR_AGENTS_RUNTIMES.has(runtime)) {
// installRuntimeArtifacts already wrote agents + handles stale-file cleanup
// via its own prune pass. No further action needed.
console.log(` ${dim}↳${reset} Agents installed via descriptor-driven layout (${runtime})`);
@@ -9385,34 +9481,41 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
failures.push('VERSION');
}
// #1821: Kilo and ZCode declare hooksSurface:'none' AND have no plugin surface,
// so the staged hook scripts are dead weight for them — exclude both here.
// OpenCode also declares hooksSurface:'none' but is deliberately NOT excluded:
// its native plugin adapter (#1914, installed above under plugins/gsd-core.js)
// spawns the staged hooks/*.js scripts via OpenCode's event bus and needs both
// them and the CommonJS package.json marker written below.
// #2089: Cursor's exclusion is now descriptor-driven via
// hostBehaviors.skipSharedHooksInstall (was hardcoded !isCursor).
// #2090: Cline's exclusion is likewise descriptor-driven (cline declares
// skipSharedHooksInstall:true) — the redundant `&& !isCline` was removed.
// #2093: Kilo's exclusion is likewise descriptor-driven (kilo declares
// skipSharedHooksInstall:true) — the redundant `&& !isKilo` was removed.
// #2094: Trae's exclusion is likewise descriptor-driven (trae declares
// skipSharedHooksInstall:true) — the redundant `&& !isTrae` was removed.
// ZCode still has an empty hostBehaviors, so `&& !isZcode` stays.
if (!isCodex && !isCopilot && _hostBehaviors(runtime).skipSharedHooksInstall !== true && !isWindsurf && !isKimi && !isZcode) {
// Reusable: copy hooks/dist/ + hooks/lib/ into destRootDir, writing the
// CommonJS package.json marker alongside them. Used below for the generic
// configDir install path (guarded by hostBehaviors.skipSharedHooksInstall),
// and — since #2095 — for Kimi's OWN native hook-install root (~/.kimi,
// resolved by resolveKimiHooksTomlDir), a directory entirely separate from
// Kimi's configDir/agents-root. Kimi's contract forbids hooks/ or
// package.json under its generic Agent-Skills root (see
// capabilities/kimi/capability.json hostBehaviors.skipSharedHooksInstall
// and the kimi-hooks-toml branch further below), so its shared-hooks bundle
// is installed into its own root via this same helper instead.
// Returns false when hooks/dist/ exists but failed to verify post-copy (a
// genuine failure the caller should surface); true otherwise (including
// when hooks/dist/ is absent from the package — nothing to verify).
function installSharedHooksBundle(destRootDir) {
// destRootDir already exists for the generic call site (targetDir — created
// earlier in install() by the skills/agents writes above). It does NOT yet
// exist for kimi's call site (~/.kimi, resolved by resolveKimiHooksTomlDir):
// a fresh install has never created that dir before. mkdirSync recursive is
// a safe no-op when the dir is already present.
fs.mkdirSync(destRootDir, { recursive: true });
// Write package.json to force CommonJS mode for GSD scripts
// Prevents "require is not defined" errors when project has "type": "module"
// Node.js walks up looking for package.json - this stops inheritance from project
const pkgJsonDest = path.join(targetDir, 'package.json');
const pkgJsonDest = path.join(destRootDir, 'package.json');
fs.writeFileSync(pkgJsonDest, '{"type":"commonjs"}\n');
console.log(` ${green}✓${reset} Wrote package.json (CommonJS mode)`);
let hooksOk = true;
// Copy hooks from dist/ (bundled with dependencies)
// Template paths for the target runtime (replaces '.claude' with correct config dir)
const hooksSrc = path.join(src, 'hooks', 'dist');
if (fs.existsSync(hooksSrc)) {
const hooksDest = path.join(targetDir, 'hooks');
const hooksDest = path.join(destRootDir, 'hooks');
fs.mkdirSync(hooksDest, { recursive: true });
const hookEntries = fs.readdirSync(hooksSrc);
const configDirReplacement = getConfigDirFromHome(runtime, isGlobal);
@@ -9486,29 +9589,58 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
}
}
} else {
failures.push('hooks');
hooksOk = false;
}
}
// Gate hooks/lib/ install on the same set of runtimes that receive hooks/.
// Codex/Copilot/Cursor/Windsurf/Trae/Cline/Kilo do not use the shared
// hooks/lib/ helpers (Cursor uses standalone .js hook scripts registered
// via hooks.json — gated descriptor-driven via
// hostBehaviors.skipSharedHooksInstall, #2089; Cline likewise #2090; Kilo
// likewise #2093; Trae likewise #2094; Codex uses hooks.json directly;
// the others skip hooks entirely); Kilo and ZCode also skip hooks entirely
// (hooksSurface:'none' with no plugin surface — #1821). None of the
// excluded runtimes must receive the hooks/lib/ helpers — otherwise the
// Codex comment downstream ("we deliberately do *not* copy hooks/lib/ for
// Codex") is contradicted in practice. (Gating lives at the call sites
// below; this helper itself only checks source presence.)
const hooksLibSrc = path.join(src, 'hooks', 'lib');
if (fs.existsSync(hooksLibSrc)) {
const hooksLibDest = path.join(destRootDir, 'hooks', 'lib');
fs.mkdirSync(hooksLibDest, { recursive: true });
copyLibDir(hooksLibSrc, hooksLibDest, GSD_HOOK_LIB_FILES);
console.log(` ${green}✓${reset} Installed hooks/lib/ helpers (git-cmd, graphify-rebuild, ...)`);
}
return hooksOk;
}
// Gate hooks/lib/ install on the same runtimes that receive hooks (see line ~8702).
// Codex/Copilot/Cursor/Windsurf/Trae/Cline/Kilo do not use the shared hooks/lib/
// helpers (Cursor uses standalone .js hook scripts registered via hooks.json — gated
// descriptor-driven via hostBehaviors.skipSharedHooksInstall, #2089; Cline likewise
// #2090; Kilo likewise #2093; Trae likewise #2094; Codex uses hooks.json directly;
// the others skip hooks entirely); Kilo and ZCode also skip hooks entirely
// (hooksSurface:'none' with no plugin surface — #1821). ZCode's hostBehaviors is
// still empty, so `&& !isZcode` stays hardcoded. OpenCode is NOT excluded: its
// #1914 plugin adapter spawns the staged hooks and requires hooks/lib/ helpers.
// None of the excluded runtimes must receive the hooks/lib/ helpers — otherwise
// the Codex comment downstream ("we deliberately do *not* copy hooks/lib/ for
// Codex") is contradicted in practice.
const hooksLibSrc = path.join(src, 'hooks', 'lib');
if (!isCodex && !isCopilot && _hostBehaviors(runtime).skipSharedHooksInstall !== true && !isWindsurf && !isKimi && !isZcode && fs.existsSync(hooksLibSrc)) {
const hooksLibDest = path.join(targetDir, 'hooks', 'lib');
fs.mkdirSync(hooksLibDest, { recursive: true });
copyLibDir(hooksLibSrc, hooksLibDest, GSD_HOOK_LIB_FILES);
console.log(` ${green}✓${reset} Installed hooks/lib/ helpers (git-cmd, graphify-rebuild, ...)`);
// #1821: Kilo and ZCode declare hooksSurface:'none' AND have no plugin surface,
// so the staged hook scripts are dead weight for them — exclude both here.
// OpenCode also declares hooksSurface:'none' but is deliberately NOT excluded:
// its native plugin adapter (#1914, installed above under plugins/gsd-core.js)
// spawns the staged hooks/*.js scripts via OpenCode's event bus and needs both
// them and the CommonJS package.json marker written below.
// #2089: Cursor's exclusion is now descriptor-driven via
// hostBehaviors.skipSharedHooksInstall (was hardcoded !isCursor).
// #2090: Cline's exclusion is likewise descriptor-driven (cline declares
// skipSharedHooksInstall:true) — the redundant `&& !isCline` was removed.
// #2093: Kilo's exclusion is likewise descriptor-driven (kilo declares
// skipSharedHooksInstall:true) — the redundant `&& !isKilo` was removed.
// #2094: Trae's exclusion is likewise descriptor-driven (trae declares
// skipSharedHooksInstall:true) — the redundant `&& !isTrae` was removed.
// ZCode still has an empty hostBehaviors, so `&& !isZcode` stays.
// #2095: Kimi's exclusion is likewise descriptor-driven (kimi declares
// skipSharedHooksInstall:true) — kimi's shared hooks/ + package.json marker
// are instead installed into its OWN native hook root (~/.kimi, resolved by
// resolveKimiHooksTomlDir) via installSharedHooksBundle, at the
// kimi-hooks-toml branch further below — never under the generic
// Agent-Skills configDir GSD installs skills/agents into for kimi.
if (!isCodex && !isCopilot && _hostBehaviors(runtime).skipSharedHooksInstall !== true && !isWindsurf && !isZcode) {
if (!installSharedHooksBundle(targetDir)) {
failures.push('hooks');
}
}
// Install scripts/changeset/ and scripts/lib/ into <configDir>/scripts/
@@ -10137,7 +10269,47 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
}
if (plan.installSurface === 'profile-marker-only') {
// Windsurf/Trae/Kimi use artifact-only surfaces — no config.toml or settings.json hooks needed.
// Windsurf/Trae use artifact-only surfaces — no config.toml or settings.json
// hooks needed. Kimi is also artifact-only for its INSTALL surface (skills +
// kimi-agents, no settings.json) but #2095 Upgrade 1 gives it its own
// independent hooksSurface: kimi's native config.toml [[hooks]] array, which
// lives outside targetDir entirely (resolveKimiHooksTomlDir resolves ~/.kimi,
// a sibling of targetDir's ~/.config/agents) — hence writing it here, inside
// this early-return, rather than requiring installSurface to change.
//
// GATED TO GLOBAL ONLY (belt-and-suspenders): kimi local installs already
// return early at the top of install() via hostBehaviors.localInstallDeferred,
// long before this point is ever reached — so `isGlobal` is always true here
// in practice. The explicit check documents that invariant and fails closed
// if that early-return is ever refactored away.
//
// Kimi's contract forbids hooks/ or package.json under its generic
// Agent-Skills configDir (targetDir) — capabilities/kimi/capability.json
// declares hostBehaviors.skipSharedHooksInstall:true, which excludes it from
// the shared installSharedHooksBundle(targetDir) call above. Kimi still needs
// those SAME hook scripts + the CommonJS package.json marker, but SELF-
// CONTAINED under its own native hook root instead — so install them there,
// and point buildHookCommand (via writeKimiHooksToml's second arg) at that
// same root so the generated [[hooks]] command paths reference
// ~/.kimi/hooks/<script> rather than a script that doesn't exist under
// targetDir/hooks (which kimi no longer receives).
if (plan.hooksSurface === 'kimi-hooks-toml' && isGlobal) {
const kimiHooksRoot = resolveKimiHooksTomlDir();
// Note: the `failures` array's hard-fail gate (`if (failures.length > 0)
// process.exit(1)`) runs earlier in this function, before this
// profile-marker-only branch is ever reached — pushing to it here would
// be silently ineffective. Warn instead; a failed hooks copy still
// leaves kimi's skills/agents artifacts installed correctly.
if (!installSharedHooksBundle(kimiHooksRoot)) {
console.warn(` ${yellow}⚠${reset} Kimi hook bundle did not verify at ${path.join(kimiHooksRoot, 'hooks')} — GSD lifecycle hooks may be incomplete`);
}
const kimiHookOpts = { portableHooks: hasPortableHooks, runtime };
const kimiHooksTomlPath = path.join(kimiHooksRoot, 'config.toml');
const kimiHooksResult = writeKimiHooksToml(kimiHooksTomlPath, kimiHooksRoot, { hookOpts: kimiHookOpts });
if (kimiHooksResult.changed) {
console.log(` ${green}✓${reset} Configured ${kimiHooksResult.entryCount} GSD hook(s) in ${kimiHooksTomlPath}`);
}
}
persistActiveProfileMarker();
return { settingsPath: null, settings: null, statuslineCommand: null, updateBannerCommand: null, runtime, configDir: targetDir };
}
@@ -10441,7 +10613,9 @@ function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallS
// #2093: isKilo dropped — the Kilo permissions-writer call below is gated
// on plan.finishPermissionWriter === 'kilo' (descriptor-driven), not this flag.
// #2094: isTrae dropped — unused in this function.
const { isOpencode, isCodex, isCopilot, isAntigravity, isCursor, isWindsurf, isAugment, isQwen, isHermes, isCodebuddy, isCline, isKimi } = runtimeFlags(runtime);
// #2095: isKimi dropped — the Kimi "Done!" banner below reads
// _hostBehaviors(runtime).doneBannerStyle === 'kimi-agent-file' (descriptor-driven), not this flag.
const { isOpencode, isCodex, isCopilot, isAntigravity, isCursor, isWindsurf, isAugment, isQwen, isHermes, isCodebuddy, isCline } = runtimeFlags(runtime);
const plan = resolveInstallPlan(runtime);
if (shouldInstallStatusline && plan.writesSharedSettings && !_hostBehaviors(runtime).skipSettingsUi) {
@@ -10583,7 +10757,7 @@ function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallS
return;
}
if (runtime === 'kimi') {
if (_hostBehaviors(runtime).doneBannerStyle === 'kimi-agent-file') {
const agentPath = configDir ? path.join(configDir, 'agents', 'gsd.yaml') : 'agents/gsd.yaml';
console.log(`
${green}Done!${reset} Start ${program} with ${cyan}kimi --agent-file ${agentPath}${reset}, then run ${cyan}${command}${reset}.

View File

@@ -3,7 +3,7 @@
"role": "runtime",
"version": "1.7.0-rc.5",
"title": "Kimi CLI",
"description": "Kimi CLI (Moonshot AI) — generic agents root at ~/.config/agents; skills + kimi-agents artifact layout; no hook surface; no hook events; tier-2 support.",
"description": "Kimi CLI (Moonshot AI) — generic agents root at ~/.config/agents; skills + kimi-agents artifact layout; native config.toml [[hooks]] bus at ~/.kimi/config.toml; background dispatch; tier-2 support.",
"tier": "core",
"requires": [],
"engines": {
@@ -46,13 +46,19 @@
"local": []
},
"commandStyle": "slash-hyphen",
"hooksSurface": "none",
"hooksSurface": "kimi-hooks-toml",
"hookEvents": "claude",
"sandboxTier": "none",
"supportTier": 2,
"installSurface": "profile-marker-only",
"writesSharedSettings": false,
"permissionWriter": null,
"extendedHookEvents": [],
"extendedHookEvents": [
"SubagentStop",
"Stop",
"PreCompact",
"SubagentStart"
],
"hostIntegration": {
"embeddingMode": "imperative",
"commandSurface": "slash-file",
@@ -62,13 +68,21 @@
"maxDepth": 1,
"background": true,
"subagentToolkit": "undocumented",
"backgroundDispatch": false
"backgroundDispatch": true
},
"modelMode": "passive",
"hookBus": "host",
"stateIO": "filesystem",
"transport": "mcp",
"runtime": "python"
},
"hostBehaviors": {
"reapplyCommand": "/skill:gsd-update --reapply",
"localInstallDeferred": true,
"verificationStyle": "kimi",
"agentManifestStyle": "kimi-nested",
"doneBannerStyle": "kimi-agent-file",
"skipSharedHooksInstall": true
}
}
}

View File

@@ -229,6 +229,22 @@ Avoid arbitrary `KIMI_CONFIG_DIR` roots unless your Kimi configuration also adds
`--kimi --local` is intentionally deferred and guarded in v1; use the global install path above for Kimi CLI.
**Hook coverage**
GSD wires its lifecycle hooks into Kimi's native `[[hooks]]` array in `config.toml` — by default `~/.kimi/config.toml` (overridable via Kimi's own `KIMI_SHARE_DIR` environment variable, a directory deliberately separate from the `~/.config/agents` skills root above). Kimi CLI's hooks system is documented as **Beta**. GSD's entries are wrapped in `# GSD Hooks BEGIN`/`# GSD Hooks END` marker comments, so reinstalling only ever rewrites GSD's own block and never touches hand-written `[[hooks]]` entries around it.
| Event | Hook | Purpose |
|---|---|---|
| `SessionStart` | `gsd-check-update.js`, `gsd-session-state.sh` | Update check and session-state bootstrap at session open |
| `PreToolUse` | `gsd-prompt-guard.js`, `gsd-read-guard.js`, `gsd-worktree-path-guard.js`, `gsd-workflow-guard.js`, `gsd-validate-commit.sh` | Prompt-injection guard, read-before-edit guidance, worktree path safety, workflow guard, and commit validation before tool calls |
| `PostToolUse` | `gsd-context-monitor.js`, `gsd-phase-boundary.sh`, `gsd-read-injection-scanner.js`, `gsd-graphify-update.sh` | Context window tracking, phase-boundary detection, read-time injection scanning, and graph updates after tool calls |
| `Stop` | `gsd-context-monitor.js` | Context headroom tracking before the model stops |
| `PreCompact` | `gsd-context-monitor.js` | Context headroom tracking before compaction |
| `SubagentStart` | `gsd-context-monitor.js` | Inject context / GSD_AGENT_NAME awareness at subagent open |
| `SubagentStop` | `gsd-context-monitor.js` | Context headroom tracking at subagent stop |
All registered hooks are managed by GSD and are removed cleanly on `--uninstall`.
---
### GitHub Copilot

View File

@@ -147,7 +147,7 @@ Runtime capabilities describe how GSD projects its artefacts onto one host CLI.
| Config format | `runtime.configFormat` | Closed enum: `settings-json` \| `toml` \| `markdown` \| `markdown-dir` \| `none`. |
| Artefact layout | `runtime.artifactLayout` | Object with `global` and `local` arrays of `ArtifactKind` (`kind`, `destSubpath`, `prefix`, `nesting`, `recursive`, `stage`). |
| Command style | `runtime.commandStyle` | Closed enum: `slash-hyphen` \| `shell-var`. |
| Hooks surface | `runtime.hooksSurface` | Closed enum: `settings-json` \| `codex-hooks-json` \| `cursor-hooks-json` \| `copilot-inline` \| `cline-rules` \| `none`. |
| Hooks surface | `runtime.hooksSurface` | Closed enum: `settings-json` \| `codex-hooks-json` \| `cursor-hooks-json` \| `copilot-inline` \| `cline-rules` \| `kimi-hooks-toml` \| `none`. |
| Sandbox tier | `runtime.sandboxTier` | Closed enum: `none` \| `codex-agent-sandbox`. |
| Support tier | `runtime.supportTier` | Integer: `1` (fully tested first-party) \| `2` (shipped, lower coverage). |
| Install surface | `runtime.installSurface` | Closed enum: `settings-json` \| `codex-toml` \| `copilot-instructions` \| `cline-rules` \| `cursor-hooks-json` \| `profile-marker-only`. |

View File

@@ -544,6 +544,8 @@ Documentation gaps:
## kimi
**EoS migration status (#2095):** Two upgrades landed. **Upgrade 1 — native hook bus:** `hooksSurface` moved from `"none"` to `"kimi-hooks-toml"` (`extendedHookEvents: ["SubagentStop", "Stop", "PreCompact", "SubagentStart"]`, `hookEvents: "claude"` — Kimi's 13 lifecycle events include exact-name equivalents for every Claude-dialect event GSD wires). GSD's hook scripts (session-state, phase-boundary, graphify, context monitor, the prompt/read/workflow/worktree guards, commit validation) are now registered as `[[hooks]]` entries in Kimi's own `config.toml` (default `~/.kimi/config.toml`, overridable via Kimi's own `KIMI_SHARE_DIR` env var — a directory deliberately separate from the `~/.config/agents` Agent-Skills root GSD installs into, since Kimi's docs confirm the skills search path is independent of `KIMI_SHARE_DIR`). GSD-owned entries are wrapped in `# GSD Hooks BEGIN`/`END` marker comments (`writeKimiHooksToml` / `stripKimiHooksTomlBlock` in `src/runtime-hooks-surface.cts`) so a reinstall replaces only GSD's own block, and `installSurface` deliberately stays `"profile-marker-only"` — the config.toml write is independent of the artifact-install surface. `hooks/` and `hooks/lib/` now install for kimi (the three `&& !isKimi` install-guard exclusions were removed) — but SELF-CONTAINED under kimi's own native hook root (`~/.kimi/`, alongside `config.toml`), never under the `~/.config/agents` Agent-Skills root: kimi declares `hostBehaviors.skipSharedHooksInstall:true` like Cline/Kilo/Cursor/Trae, so the shared install path never writes hooks/package.json there, and a dedicated call installs the same bundle into `resolveKimiHooksTomlDir()` instead, with `buildHookCommand` pointed at that root so the generated `[[hooks]]` command paths resolve. **Upgrade 2 — background dispatch:** `hostIntegration.dispatch.backgroundDispatch` flipped `false` → `true` (Kimi's `Agent` tool takes a call-time `run_in_background` param — same evidence as `dispatch.background` below), which flips `shouldFlattenDispatch` to `false` for kimi (may background, joining codex/cursor/opencode) — a negotiation-only axis with no install-output effect, confirmed via golden parity. Exercising the actual `run_in_background` call end-to-end is Kimi's own runtime behavior and is out of the installer's test scope; the installer's deliverable stops at the `kimi_cli.tools.agent:Agent` tool grant on the root agent YAML (`buildKimiAgentArtifacts`, only emitted when a subagent is present) plus the negotiated `backgroundDispatch` axis above — both covered by `tests/kimi-upgrades.test.cjs`. **MCP transport deferred:** kimi's `transport: mcp` axis (declared below) is descriptor-only, like every other runtime's — no runtime has installer-driven MCP registration (GSD's installer never invokes `kimi mcp add`); users register the GSD MCP companion server with Kimi CLI manually.
| Axis | Value | Source | Evidence |
|---|---|---|---|
| embeddingMode | imperative | https://context7.com/moonshotai/kimi-cli/llms.txt | "from kimi_cli.app import KimiCLI, enable_logging ... instance = await KimiCLI.create(session, agent_file=myagent) ... class Ls(CallableTool2)" |
@@ -558,7 +560,7 @@ Documentation gaps:
| dispatch.maxDepth | 1 | https://moonshotai.github.io/kimi-cli/en/customization/agents.html | "All subagent types are prohibited from nesting the `Agent` tool (subagents cannot create their own subagents). Only root" |
| dispatch.background | true | https://moonshotai.github.io/kimi-cli/en/customization/agents.html | "Subagents support foreground and background modes. The `run_in_background` parameter allows tasks to execute asynchronou" |
| dispatch.subagentToolkit | undocumented | no authoritative doc — searched: https://moonshotai.github.io/kimi-cli/en/customization/agents.html | — |
| dispatch.backgroundDispatch | false | https://github.com/moonshotai/kimi-cli/blob/main/docs/en/customization/agents.md (also mirrored at https://moonshotai.github.io/kimi-cli/en/customization/agents.html) | "All subagent types are prohibited from nesting the `Agent` tool, meaning subagents cannot create their own subagents. Only the root agent has access to the `Agent` tool for launching further subagent |
| dispatch.backgroundDispatch | true (#2095 Upgrade 2; was `false`) | https://moonshotai.github.io/kimi-cli/en/customization/agents.html | "Subagents support foreground and background modes. The `run_in_background` parameter allows tasks to execute asynchronously" (same evidence as dispatch.background above — the root agent's `Agent` tool call itself takes the `run_in_background` param) |
Sources consulted:
- https://moonshotai.github.io/kimi-cli/en/customization/hooks.html

View File

@@ -57,6 +57,11 @@ const PATH_RETURNING_FNS = [
'resolveSkillsBaseFromDescriptor',
'resolveConfigHomeFromDescriptor',
'resolveKimiGlobalDir',
// #2095 (EoS/kimi): resolves the directory holding Kimi CLI's OWN native
// config.toml (~/.kimi by default, KIMI_SHARE_DIR override) — a sibling of,
// and deliberately separate from, resolveKimiGlobalDir's generic
// Agent-Skills root above.
'resolveKimiHooksTomlDir',
'resolveAntigravityGlobalDir',
'getGlobalDir',
'getConfigDirFromHome',

View File

@@ -1593,7 +1593,7 @@ const capabilities = {
"role": "runtime",
"version": "1.7.0-rc.5",
"title": "Kimi CLI",
"description": "Kimi CLI (Moonshot AI) — generic agents root at ~/.config/agents; skills + kimi-agents artifact layout; no hook surface; no hook events; tier-2 support.",
"description": "Kimi CLI (Moonshot AI) — generic agents root at ~/.config/agents; skills + kimi-agents artifact layout; native config.toml [[hooks]] bus at ~/.kimi/config.toml; background dispatch; tier-2 support.",
"tier": "core",
"requires": [],
"engines": {
@@ -1636,13 +1636,19 @@ const capabilities = {
"local": []
},
"commandStyle": "slash-hyphen",
"hooksSurface": "none",
"hooksSurface": "kimi-hooks-toml",
"hookEvents": "claude",
"sandboxTier": "none",
"supportTier": 2,
"installSurface": "profile-marker-only",
"writesSharedSettings": false,
"permissionWriter": null,
"extendedHookEvents": [],
"extendedHookEvents": [
"SubagentStop",
"Stop",
"PreCompact",
"SubagentStart"
],
"hostIntegration": {
"embeddingMode": "imperative",
"commandSurface": "slash-file",
@@ -1652,13 +1658,21 @@ const capabilities = {
"maxDepth": 1,
"background": true,
"subagentToolkit": "undocumented",
"backgroundDispatch": false
"backgroundDispatch": true
},
"modelMode": "passive",
"hookBus": "host",
"stateIO": "filesystem",
"transport": "mcp",
"runtime": "python"
},
"hostBehaviors": {
"reapplyCommand": "/skill:gsd-update --reapply",
"localInstallDeferred": true,
"verificationStyle": "kimi",
"agentManifestStyle": "kimi-nested",
"doneBannerStyle": "kimi-agent-file",
"skipSharedHooksInstall": true
}
}
},
@@ -4725,7 +4739,7 @@ const runtimes = {
"role": "runtime",
"version": "1.7.0-rc.5",
"title": "Kimi CLI",
"description": "Kimi CLI (Moonshot AI) — generic agents root at ~/.config/agents; skills + kimi-agents artifact layout; no hook surface; no hook events; tier-2 support.",
"description": "Kimi CLI (Moonshot AI) — generic agents root at ~/.config/agents; skills + kimi-agents artifact layout; native config.toml [[hooks]] bus at ~/.kimi/config.toml; background dispatch; tier-2 support.",
"tier": "core",
"requires": [],
"engines": {
@@ -4768,13 +4782,19 @@ const runtimes = {
"local": []
},
"commandStyle": "slash-hyphen",
"hooksSurface": "none",
"hooksSurface": "kimi-hooks-toml",
"hookEvents": "claude",
"sandboxTier": "none",
"supportTier": 2,
"installSurface": "profile-marker-only",
"writesSharedSettings": false,
"permissionWriter": null,
"extendedHookEvents": [],
"extendedHookEvents": [
"SubagentStop",
"Stop",
"PreCompact",
"SubagentStart"
],
"hostIntegration": {
"embeddingMode": "imperative",
"commandSurface": "slash-file",
@@ -4784,13 +4804,21 @@ const runtimes = {
"maxDepth": 1,
"background": true,
"subagentToolkit": "undocumented",
"backgroundDispatch": false
"backgroundDispatch": true
},
"modelMode": "passive",
"hookBus": "host",
"stateIO": "filesystem",
"transport": "mcp",
"runtime": "python"
},
"hostBehaviors": {
"reapplyCommand": "/skill:gsd-update --reapply",
"localInstallDeferred": true,
"verificationStyle": "kimi",
"agentManifestStyle": "kimi-nested",
"doneBannerStyle": "kimi-agent-file",
"skipSharedHooksInstall": true
}
}
},

View File

@@ -705,7 +705,7 @@ const VALID_CONVERTER_NAMES = new Set([
const VALID_CONFIG_FORMATS = new Set(['settings-json', 'toml', 'markdown', 'markdown-dir', 'none']);
const VALID_CONFIG_HOME_KINDS = new Set(['dot-home', 'dot-home-nested', 'xdg', 'generic-agents-root']);
const VALID_COMMAND_STYLES = new Set(['slash-hyphen', 'shell-var']);
const VALID_HOOKS_SURFACES = new Set(['settings-json', 'codex-hooks-json', 'cursor-hooks-json', 'copilot-inline', 'cline-rules', 'none']);
const VALID_HOOKS_SURFACES = new Set(['settings-json', 'codex-hooks-json', 'cursor-hooks-json', 'copilot-inline', 'cline-rules', 'kimi-hooks-toml', 'none']);
const VALID_HOOK_EVENTS = new Set(['claude', 'gemini']);
// extensionEvents — the plugin/extension-system event dialect (ADR-1239 amendment / #1943).
// DISTINCT from hookEvents (managed-hook dialect): extensionEvents describes the
@@ -740,7 +740,7 @@ const INSTALL_SURFACE_TO_ALLOWED_HOOKS_SURFACES = new Map([
['copilot-instructions', new Set(['copilot-inline'])],
['cline-rules', new Set(['cline-rules'])],
['cursor-hooks-json', new Set(['cursor-hooks-json'])],
['profile-marker-only', new Set(['none'])],
['profile-marker-only', new Set(['none', 'kimi-hooks-toml'])],
]);
// GATE B: extended hook event families → required hookEvents value
@@ -979,7 +979,7 @@ function validateRuntimeBody(cap) {
);
}
// hooksSurface — closed 6-enum (ADR-1016 Decision 5); inline literal guard (CodeQL barrier)
// hooksSurface — closed 7-enum (ADR-1016 Decision 5); inline literal guard (CodeQL barrier)
if (r.hooksSurface === '__proto__' || r.hooksSurface === 'constructor' || r.hooksSurface === 'prototype') {
errors.push('runtime.hooksSurface "' + r.hooksSurface + '" is a reserved name');
} else if (!VALID_HOOKS_SURFACES.has(r.hooksSurface)) {

View File

@@ -27,6 +27,11 @@ const VOLATILE_FILES = new Set([
'gsd-core/CHANGELOG.md',
]);
const HOOK_CONFIG_FILES = new Set(['settings.json', 'hooks.json']);
// Kimi's native config.toml (#2095) — see tests/golden-install-parity.test.cjs'
// HOOK_CONFIG_RELATIVE_PATHS comment for why this is an exact relative-path
// exclusion rather than a HOOK_CONFIG_FILES basename entry (a basename entry
// would also blind Codex's stable, platform-independent config.toml fixture).
const HOOK_CONFIG_RELATIVE_PATHS = new Set(['.kimi/config.toml']);
const EXCLUDED_PREFIXES = ['gsd-core/bin/lib/'];
function buildParityManifest(configDir, root) {
@@ -36,6 +41,7 @@ function buildParityManifest(configDir, root) {
const rel = path.relative(configDir, full).split(path.sep).join('/');
if (VOLATILE_FILES.has(rel)) continue;
if (HOOK_CONFIG_FILES.has(path.basename(rel))) continue;
if (HOOK_CONFIG_RELATIVE_PATHS.has(rel)) continue;
if (EXCLUDED_PREFIXES.some((p) => rel.startsWith(p))) continue;
const content = fs.readFileSync(full);
const normalized = content.toString('utf8').split(root).join('<HOME>').split(PKG_VERSION).join('<VERSION>');

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,

View File

@@ -3412,11 +3412,13 @@ describe('ADR-1016 phase 5a: sample axis value assertions', () => {
);
});
test('copilot/trae/kimi/windsurf/cline/opencode/kilo: hooksSurface none or copilot-inline/cline-rules, no hookEvents', () => {
test('copilot/trae/windsurf/cline/opencode/kilo: hooksSurface none or copilot-inline/cline-rules, no hookEvents', () => {
const { capMap } = loadAndValidate(new Set());
const registry = buildRegistry(capMap);
// opencode and kilo register ZERO lifecycle hooks → hooksSurface none, no hookEvents
const noEventsRuntimes = ['trae', 'kimi', 'windsurf', 'opencode', 'kilo'];
// #2095: kimi moved OUT of this group — it now has hooksSurface:'kimi-hooks-toml'
// and hookEvents:'claude' (see the dedicated kimi hooksSurface test below).
const noEventsRuntimes = ['trae', 'windsurf', 'opencode', 'kilo'];
for (const id of noEventsRuntimes) {
const rt = registry.runtimes[id].runtime;
assert.ok(
@@ -3440,6 +3442,22 @@ describe('ADR-1016 phase 5a: sample axis value assertions', () => {
);
});
test('kimi: hooksSurface === kimi-hooks-toml, hookEvents === claude, extendedHookEvents wired (#2095)', () => {
const { capMap } = loadAndValidate(new Set());
const registry = buildRegistry(capMap);
const kimiRt = registry.runtimes['kimi'].runtime;
assert.strictEqual(kimiRt.hooksSurface, 'kimi-hooks-toml', 'kimi.hooksSurface must be "kimi-hooks-toml"');
assert.strictEqual(kimiRt.hookEvents, 'claude', 'kimi.hookEvents must be "claude" (Kimi\'s 13 lifecycle events include exact-name equivalents for the Claude dialect)');
assert.deepStrictEqual(
[...kimiRt.extendedHookEvents].sort(),
['PreCompact', 'Stop', 'SubagentStart', 'SubagentStop'].sort(),
'kimi.extendedHookEvents must wire SubagentStop/Stop/PreCompact/SubagentStart',
);
// installSurface stays profile-marker-only — the native config.toml
// [[hooks]] write is independent of the artifact-install surface (#2095).
assert.strictEqual(kimiRt.installSurface, 'profile-marker-only', 'kimi.installSurface must remain "profile-marker-only"');
});
test('codex: hooksSurface === codex-hooks-json, cursor: hooksSurface === cursor-hooks-json', () => {
const { capMap } = loadAndValidate(new Set());
const registry = buildRegistry(capMap);
@@ -3949,12 +3967,12 @@ describe('ADR-1016 phase 5a: closed-vocab set exports', () => {
assert.strictEqual(VALID_COMMAND_STYLES.size, 2);
});
test('VALID_HOOKS_SURFACES has exactly 6 values', () => {
test('VALID_HOOKS_SURFACES has exactly 7 values', () => {
assert.ok(VALID_HOOKS_SURFACES instanceof Set);
for (const v of ['settings-json', 'codex-hooks-json', 'cursor-hooks-json', 'copilot-inline', 'cline-rules', 'none']) {
for (const v of ['settings-json', 'codex-hooks-json', 'cursor-hooks-json', 'copilot-inline', 'cline-rules', 'kimi-hooks-toml', 'none']) {
assert.ok(VALID_HOOKS_SURFACES.has(v), 'VALID_HOOKS_SURFACES must contain "' + v + '"');
}
assert.strictEqual(VALID_HOOKS_SURFACES.size, 6);
assert.strictEqual(VALID_HOOKS_SURFACES.size, 7);
});
test('VALID_HOOK_EVENTS has exactly 2 managed-hook dialects (claude/gemini)', () => {

View File

@@ -1,6 +1,32 @@
{
".gsd-profile": "0e716a5fef4e6dc1",
".gsd/defaults.json": "560664b045e645cb",
".kimi/hooks/gsd-check-update-worker.js": "6593f80914f4edee",
".kimi/hooks/gsd-check-update.js": "aac7612ee04ff5fe",
".kimi/hooks/gsd-config-reload.js": "96546e0e8bb47904",
".kimi/hooks/gsd-context-monitor.js": "783c93351e82151f",
".kimi/hooks/gsd-cursor-post-tool.js": "9168e0a09de1972a",
".kimi/hooks/gsd-cursor-pre-tool.js": "873998b25e308c29",
".kimi/hooks/gsd-cursor-session-start.js": "9b2e6f4f0c405375",
".kimi/hooks/gsd-cursor-stop.js": "bfaaf60f419e3238",
".kimi/hooks/gsd-cursor-subagent-start.js": "06d77fde5c1372b6",
".kimi/hooks/gsd-cursor-subagent-stop.js": "4bbf22917da4d389",
".kimi/hooks/gsd-ensure-canonical-path.js": "8bd016237c88b738",
".kimi/hooks/gsd-graphify-update.sh": "e4c6e14fe6ad64ff",
".kimi/hooks/gsd-phase-boundary.sh": "32739d5fbe0d0a1c",
".kimi/hooks/gsd-prompt-guard.js": "a749b8cb2c5248de",
".kimi/hooks/gsd-read-guard.js": "9e423cd03e2d1b16",
".kimi/hooks/gsd-read-injection-scanner.js": "c519598b9257aafa",
".kimi/hooks/gsd-session-state.sh": "e54379ba86bf1b6d",
".kimi/hooks/gsd-statusline.js": "2736b0885aa97bbf",
".kimi/hooks/gsd-update-banner.js": "55143a25f978f301",
".kimi/hooks/gsd-validate-commit.sh": "bf5dd61d33cb3a38",
".kimi/hooks/gsd-workflow-guard.js": "91ae24a15d2bca6f",
".kimi/hooks/gsd-worktree-path-guard.js": "cfde29a547677422",
".kimi/hooks/lib/git-cmd.js": "268ba15992ca0b23",
".kimi/hooks/lib/gsd-graphify-rebuild.sh": "66af89601074d2a9",
".kimi/hooks/managed-hooks-registry.cjs": "07c6a5ecd724edb3",
".kimi/package.json": "dbf8353f77358bc1",
"agents/gsd.md": "60fee7782ae4f2c6",
"agents/gsd.yaml": "253a23ddda06c6c2",
"agents/subagents/gsd-advisor-researcher.md": "81bdc6cbd8fcde40",

View File

@@ -84,6 +84,24 @@ const PKG_VERSION = require('../package.json').version;
// node-runner command as settings.json, so excluded for the same reason (#2086).
const HOOK_CONFIG_FILES = new Set(['settings.json', 'settings.local.json', 'hooks.json']);
// Kimi's native config.toml (#2095 EoS/kimi Upgrade 1) embeds the same
// platform-varying node-runner command as the HOOK_CONFIG_FILES above (via the
// same buildHookCommand/projectManagedHookCommand machinery), so it needs the
// same exclusion — but it is NOT matched by basename like HOOK_CONFIG_FILES:
// Codex's OWN config.toml (installSurface 'codex-toml') is a stable, tracked
// top-level `config.toml` entry in its golden fixture (it only ever gets a
// platform-stable `[features] hooks = true` flag — the real hook commands
// live in Codex's separate hooks.json, already excluded above). Blanket-
// excluding the 'config.toml' basename would silently blind Codex's fixture
// to any future regression there. Kimi's config.toml instead lives OUTSIDE
// its GSD configDir at runtime (resolveKimiHooksTomlDir resolves ~/.kimi, a
// sibling of the configDir ~/.config/agents) — it only appears inside this
// harness's walked tree at all because runMinimalInstall sets HOME to the
// same temp root used as --config-dir, collapsing the two into one directory
// for the isolated test run. So it is excluded by its exact relative path
// under that collapsed root, not by basename.
const HOOK_CONFIG_RELATIVE_PATHS = new Set(['.kimi/config.toml']);
// Path prefixes excluded from the parity manifest. `gsd-core/bin/lib/` holds the
// tsc-built runtime artifacts (compiled from src/*.cts) that the install COPIES
// verbatim — they are NOT produced by installRuntimeArtifacts (the move's parity
@@ -130,6 +148,7 @@ function buildParityManifest(configDir, root) {
if (VOLATILE_FILES.has(rel)) continue;
if (HOOK_CONFIG_FILES.has(path.basename(rel))) continue;
if (HOOK_CONFIG_RELATIVE_PATHS.has(rel)) continue;
if (EXCLUDED_PREFIXES.some((p) => rel.startsWith(p))) continue;
const content = fs.readFileSync(full);

View File

@@ -262,7 +262,8 @@ describe('ADR-1239 Phase A: hostIntegration descriptors', () => {
// ─── shouldFlattenDispatch per-host (#853 discriminator) ─────────────────────
// Expected: false (may background) for codex and cursor ONLY; true (must inline) for the other 15.
// Expected: false (may background) for codex, cursor, kimi, and opencode;
// true (must inline) for the other 13.
const EXPECTED_FLATTEN = {
antigravity: true,
augment: true,
@@ -274,7 +275,11 @@ describe('ADR-1239 Phase A: hostIntegration descriptors', () => {
cursor: false,
hermes: true,
kilo: true,
kimi: true,
// #2095: Kimi Upgrade 2 — Kimi's Agent tool takes a run_in_background
// call-time param (Context7 agents.html) → backgroundDispatch flipped to
// true → dispatch.background/backgroundDispatch both true → NOT
// force-flattened (mirrors the #2087 OpenCode precedent below).
kimi: false,
// #2087: OpenCode background subagents (v1.15 param, v1.17 default-on) →
// dispatch.background/backgroundDispatch true → NOT force-flattened.
opencode: false,

View File

@@ -0,0 +1,155 @@
// allow-test-rule: structural-regression-guard — AC2 requires asserting no `runtime === 'kimi'` string-equality branch (nor an `isKimi` helper) remains in bin/install.js, src/install-engine.cts, and src/runtime-artifact-conversion.cts — the descriptor-migration contract is a property of the source text, so a source-grep is the only faithful check (#2095)
'use strict';
/**
* kimi imperative reference host — ADR-1239 Phase D / #2095 (EoS/kimi).
*
* Proves kimi is driven through the PUBLIC Host-Integration Interface (the
* imperative adapter), that its negotiated axes classify + negotiate correctly,
* that negotiation fails CLOSED on a corrupted descriptor, and that the
* migration retired the hardcoded `runtime === 'kimi'` / `isKimi` branches
* across the install engine and artifact conversion modules (folded into
* descriptor-driven `runtime.hostBehaviors`).
*
* kimi contributes two real upgrades covered in tests/kimi-upgrades.test.cjs:
*
* UPGRADE 1 — native hook bus: `hooksSurface` moved from `"none"` to
* `"kimi-hooks-toml"` — GSD's lifecycle hooks are now registered as
* `[[hooks]]` array-of-tables entries in Kimi's own native
* `~/.kimi/config.toml` (Beta feature on Kimi's side), marker-delimited so
* reinstall only ever touches GSD's own block.
*
* UPGRADE 2 — background dispatch: `hostIntegration.dispatch.backgroundDispatch`
* flipped `false` → `true`, which flips `shouldFlattenDispatch` to `false`
* for kimi (joining codex/cursor/opencode as background-eligible).
*
* kimi's `dispatch.subagentToolkit` stays `'undocumented'` (no authoritative
* doc found), so this file also proves that axis degrades closed to
* `'read-only'` on negotiation rather than being trusted at face value.
*/
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { createImperativeAdapter } = require('../gsd-core/bin/lib/adapter-imperative.cjs');
const {
profileOf,
negotiateHostCapabilities,
PROFILE_BASELINES,
UNDOCUMENTED,
} = require('../gsd-core/bin/lib/host-integration.cjs');
const KIMI_CAP = JSON.parse(
fs.readFileSync(path.join(__dirname, '..', 'capabilities', 'kimi', 'capability.json'), 'utf8'),
);
const KIMI_AXES = KIMI_CAP.runtime.hostIntegration;
// -- AC2: driven through the public interface (imperative adapter) -----------
test('createImperativeAdapter classifies kimi as imperative + composes the registry', () => {
const adapter = createImperativeAdapter({ runtime: 'kimi' });
assert.equal(adapter.kind, 'imperative');
assert.equal(adapter.runtime, 'kimi');
assert.ok(adapter.registry && typeof adapter.registry === 'object');
assert.equal(typeof adapter.install, 'function');
assert.equal(typeof adapter.uninstall, 'function');
});
test('kimi axes classify as the programmatic-cli reference profile', () => {
assert.equal(profileOf(KIMI_AXES), 'programmatic-cli');
});
// -- AC3: all axes populated + validated -------------------------------------
test('kimi descriptor declares all 8 axes + 6 dispatch sub-axes with exact values', () => {
assert.equal(KIMI_AXES.embeddingMode, 'imperative');
assert.equal(KIMI_AXES.commandSurface, 'slash-file');
assert.equal(KIMI_AXES.modelMode, 'passive');
assert.equal(KIMI_AXES.hookBus, 'host');
assert.equal(KIMI_AXES.stateIO, 'filesystem');
assert.equal(KIMI_AXES.transport, 'mcp');
assert.equal(KIMI_AXES.runtime, 'python');
const d = KIMI_AXES.dispatch;
assert.equal(d.namedDispatch, true);
assert.equal(d.nested, false);
assert.equal(d.maxDepth, 1);
assert.equal(d.background, true);
assert.equal(d.subagentToolkit, 'undocumented');
assert.equal(d.backgroundDispatch, true);
});
// -- AC5: negotiation fails CLOSED on a corrupted descriptor ------------------
test('negotiateHostCapabilities never throws for kimi, even fully corrupted', () => {
assert.doesNotThrow(() => negotiateHostCapabilities({}));
assert.doesNotThrow(() => negotiateHostCapabilities({ ...KIMI_AXES, embeddingMode: UNDOCUMENTED }));
assert.doesNotThrow(() => negotiateHostCapabilities({ ...KIMI_AXES, embeddingMode: 'future-unknown' }));
assert.doesNotThrow(() => negotiateHostCapabilities({ ...KIMI_AXES, dispatch: 'corrupted-not-an-object' }));
assert.doesNotThrow(() => negotiateHostCapabilities({ ...KIMI_AXES, dispatch: { ...KIMI_AXES.dispatch, maxDepth: 'not-a-number' } }));
});
test('a partial/empty kimi descriptor degrades to the safe floor, not the programmatic-cli baseline', () => {
const result = negotiateHostCapabilities({});
assert.equal(result.effective.embeddingMode, 'declarative', 'omitted embeddingMode degrades closed');
assert.equal(result.effective.hookBus, 'none');
assert.notDeepEqual(result.effective, PROFILE_BASELINES['programmatic-cli']);
assert.ok(result.warnings.length > 0);
});
test("kimi's undocumented dispatch.subagentToolkit degrades closed to 'read-only' on negotiation", () => {
const { effective, warnings } = negotiateHostCapabilities(KIMI_AXES);
assert.equal(KIMI_AXES.dispatch.subagentToolkit, 'undocumented', 'sanity: the descriptor itself stays undocumented');
assert.equal(effective.dispatch.subagentToolkit, 'read-only', 'undocumented never propagates as full — degrades closed');
assert.ok(
warnings.some((w) => /subagentToolkit is undocumented/.test(w)),
'a warning must be raised for the undocumented axis',
);
});
// -- AC2: the folded-in behaviors ---------------------------------------------
test('kimi descriptor declares runtime.hostBehaviors (the folded-in behaviors)', () => {
const hb = KIMI_CAP.runtime.hostBehaviors;
assert.ok(hb && typeof hb === 'object');
assert.equal(hb.reapplyCommand, '/skill:gsd-update --reapply');
assert.equal(hb.localInstallDeferred, true);
assert.equal(hb.verificationStyle, 'kimi');
assert.equal(hb.agentManifestStyle, 'kimi-nested');
assert.equal(hb.doneBannerStyle, 'kimi-agent-file');
});
// -- boundary/negative: hooksSurface + extendedHookEvents are exactly what's documented
test('capabilities/kimi/capability.json hooksSurface is "kimi-hooks-toml" and extendedHookEvents is exactly the 4 documented events', () => {
assert.equal(KIMI_CAP.runtime.hooksSurface, 'kimi-hooks-toml');
const events = KIMI_CAP.runtime.extendedHookEvents;
assert.deepEqual(events, ['SubagentStop', 'Stop', 'PreCompact', 'SubagentStart']);
assert.equal(events.length, 4);
});
// -- AC2: the hardcoded branches are retired across all folded modules -------
test('no `runtime === "kimi"` string-equality branch (nor an `isKimi` helper) remains in the descriptor-migrated modules (AC2)', () => {
const strip = (src) => src
.replace(/\/\*[\s\S]*?\*\//g, '')
.replace(/\/\/[^\r\n]*/g, '')
.replace(/`[^`]*`/g, '');
const repoRoot = path.join(__dirname, '..');
const files = [
path.join(repoRoot, 'bin', 'install.js'),
path.join(repoRoot, 'src', 'install-engine.cts'),
path.join(repoRoot, 'src', 'runtime-artifact-conversion.cts'),
];
for (const file of files) {
const src = fs.readFileSync(file, 'utf8');
const stripped = strip(src);
const offenders = stripped.match(/runtime\s*[!=]==\s*'kimi'/g) || [];
assert.deepEqual(offenders, [],
`AC2: no hardcoded runtime==='kimi' branch may remain in ${path.relative(repoRoot, file)}; found: ${offenders.join(', ')}`);
const isKimiHits = stripped.match(/\bisKimi\b/g) || [];
assert.deepEqual(isKimiHits, [],
`AC2: no isKimi helper may remain in ${path.relative(repoRoot, file)}; found ${isKimiHits.length} occurrence(s)`);
}
});

View File

@@ -0,0 +1,321 @@
'use strict';
/**
* kimi capability UPGRADES — ADR-1239 Phase D / #2095 (EoS/kimi).
*
* Drives the user-reachable surface (spawned `bin/install.js` via
* `runMinimalInstall`) plus targeted unit coverage to prove the two real
* upgrades kimi contributes as part of the imperative-adapter migration:
*
* UPGRADE 1 — native hook bus: `hooksSurface` moved from `"none"` to
* `"kimi-hooks-toml"`. GSD's lifecycle hook scripts (session-state,
* phase-boundary, graphify, context monitor, the prompt/read/workflow/
* worktree guards, commit validation) are now registered as `[[hooks]]`
* array-of-tables entries inside Kimi's own native config.toml (default
* `~/.kimi/config.toml`, overridable via Kimi's `KIMI_SHARE_DIR`), wrapped
* in `# GSD Hooks BEGIN`/`END` marker comments so a reinstall only ever
* rewrites GSD's own block (idempotency contract). `hooks/`, `hooks/lib/`,
* and the CommonJS `package.json` marker now also install for kimi — but
* SELF-CONTAINED under `~/.kimi/` (alongside config.toml), never under the
* generic Agent-Skills configDir GSD installs skills/agents into. Kimi's
* contract forbids hooks/ or package.json under that generic root (see
* capabilities/kimi/capability.json hostBehaviors.skipSharedHooksInstall,
* and tests/kimi-imperative-reference.test.cjs's source-grep guard).
*
* Kimi's local install is intentionally deferred (Phase 2): `--kimi
* --local` exits 0 and writes nothing (`hostBehaviors.localInstallDeferred:
* true`, verified by manual spawn during authoring — see PR description).
* This file therefore only exercises `--global`.
*
* UPGRADE 2 — background dispatch: `hostIntegration.dispatch.backgroundDispatch`
* flipped `false` → `true` (Kimi's `Agent` tool takes a call-time
* `run_in_background` param), which flips `shouldFlattenDispatch` to
* `false` for kimi — a negotiation-only axis with no install-output effect.
*/
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { spawnSync } = require('node:child_process');
const { runMinimalInstall, INSTALL_SCRIPT, installerEnv } = require('./helpers/install-shared.cjs');
const { cleanup } = require('./helpers.cjs');
const {
negotiateHostCapabilities,
shouldFlattenDispatch,
} = require('../gsd-core/bin/lib/host-integration.cjs');
const {
stripKimiHooksTomlBlock,
writeKimiHooksToml,
removeKimiHooksToml,
KIMI_HOOKS_TOML_MARKER_BEGIN,
KIMI_HOOKS_TOML_MARKER_END,
} = require('../gsd-core/bin/lib/runtime-hooks-surface.cjs');
const KIMI_CAP = JSON.parse(
fs.readFileSync(path.join(__dirname, '..', 'capabilities', 'kimi', 'capability.json'), 'utf8'),
);
// ---------------------------------------------------------------------------
// UPGRADE 1: native hook bus (~/.kimi/config.toml [[hooks]])
// ---------------------------------------------------------------------------
test('kimi --global: native config.toml [[hooks]] bus wired at <HOME>/.kimi/config.toml (UPGRADE 1)', (t) => {
const { root } = runMinimalInstall({ runtime: 'kimi', scope: 'global' });
t.after(() => cleanup(root));
// FACTS: root === sandbox HOME for this helper (env HOME/USERPROFILE are
// both pointed at `root`), and Kimi's native hook config is a sibling of
// the generic-agents-root GSD installs skills/agents into: <HOME>/.kimi/config.toml.
const tomlPath = path.join(root, '.kimi', 'config.toml');
assert.ok(fs.existsSync(tomlPath), `${tomlPath} must exist`);
const toml = fs.readFileSync(tomlPath, 'utf8');
// Idempotency contract: GSD's block is delimited by these exact markers so
// a reinstall can strip-and-rewrite only its own content.
assert.match(toml, /^# GSD Hooks BEGIN — managed by GSD, do not edit between these markers$/m,
'config.toml must carry the GSD Hooks BEGIN marker');
assert.match(toml, /^# GSD Hooks END$/m,
'config.toml must carry the GSD Hooks END marker');
const beginIdx = toml.indexOf('# GSD Hooks BEGIN');
const endIdx = toml.indexOf('# GSD Hooks END');
assert.ok(beginIdx !== -1 && endIdx !== -1 && beginIdx < endIdx,
'both markers must be present and correctly ordered');
const managedBlock = toml.slice(beginIdx, endIdx);
// Minimal TOML parse-check: an array-of-tables ([[hooks]]), each with an
// event key — not a single [hooks] table.
const hooksTables = managedBlock.match(/^\[\[hooks\]\]$/gm) || [];
assert.ok(hooksTables.length > 0, 'the managed block must contain at least one [[hooks]] array-of-tables entry');
const eventLines = managedBlock.match(/^event = ".+"$/gm) || [];
assert.equal(eventLines.length, hooksTables.length,
'every [[hooks]] table must declare an event key (array-of-tables shape, not a bare [hooks] map)');
// A SessionStart [[hooks]] entry must exist inside the markers.
assert.match(managedBlock, /\[\[hooks\]\]\nevent = "SessionStart"/,
'a [[hooks]] block with event = "SessionStart" must be inside the GSD markers');
// At least one hook command references an installed hook script path.
assert.match(managedBlock, /command = ".*gsd-check-update\.js.*"/,
'a hook command must reference the installed gsd-check-update.js script');
// The hooks/ scripts themselves are installed under Kimi's OWN native hook
// root (~/.kimi/hooks) — NOT under the generic Agent-Skills configDir
// (targetDir) GSD installs skills/agents into. Kimi's contract forbids
// hooks/ or package.json under that generic root (capabilities/kimi/
// capability.json declares hostBehaviors.skipSharedHooksInstall:true), so
// the shared hook bundle is self-contained alongside config.toml instead.
const hooksDir = path.join(root, '.kimi', 'hooks');
assert.ok(fs.existsSync(path.join(hooksDir, 'gsd-check-update.js')),
'hooks/gsd-check-update.js must be installed under ~/.kimi/hooks — the command above references it by path');
assert.ok(fs.existsSync(path.join(hooksDir, 'lib')),
'hooks/lib/ helpers must also install for kimi, under ~/.kimi/hooks/lib');
assert.ok(!fs.existsSync(path.join(root, 'hooks')),
'hooks/ must NOT be installed under the generic Agent-Skills configDir for kimi');
assert.ok(!fs.existsSync(path.join(root, 'package.json')),
'package.json (CommonJS marker) must NOT be installed under the generic Agent-Skills configDir for kimi');
assert.ok(fs.existsSync(path.join(root, '.kimi', 'package.json')),
'package.json (CommonJS marker) must be installed alongside config.toml under ~/.kimi');
});
test('kimi --global: reinstalling is idempotent — the GSD [[hooks]] block is not duplicated', (t) => {
const { root } = runMinimalInstall({ runtime: 'kimi', scope: 'global' });
t.after(() => cleanup(root));
const tomlPath = path.join(root, '.kimi', 'config.toml');
const first = fs.readFileSync(tomlPath, 'utf8');
const beginMarkers = (content) => (content.match(/# GSD Hooks BEGIN/g) || []).length;
const endMarkers = (content) => (content.match(/# GSD Hooks END/g) || []).length;
assert.equal(beginMarkers(first), 1, 'exactly one BEGIN marker after a fresh install');
assert.equal(endMarkers(first), 1, 'exactly one END marker after a fresh install');
// Reinstall over the SAME root/config (runMinimalInstall always mkdtemps a
// fresh root, so the reinstall is driven directly against this test's root
// exactly the way runMinimalInstall drives its own install internally).
const reinstall = spawnSync(process.execPath, [INSTALL_SCRIPT, '--kimi', '--global', '--config-dir', root], {
cwd: process.cwd(), encoding: 'utf8',
env: installerEnv({ HOME: root, USERPROFILE: root }),
});
assert.strictEqual(reinstall.status, 0,
`reinstall exited with status ${reinstall.status}\nstdout: ${reinstall.stdout}\nstderr: ${reinstall.stderr}`);
const second = fs.readFileSync(tomlPath, 'utf8');
assert.equal(beginMarkers(second), 1, 'reinstall must not duplicate the BEGIN marker');
assert.equal(endMarkers(second), 1, 'reinstall must not duplicate the END marker');
});
// ---------------------------------------------------------------------------
// Code-review regression: stripKimiHooksTomlBlock must never glue two user
// TOML sections together when the GSD block sits between them, and must
// never delete to EOF when the marker pair is malformed (BEGIN without a
// matching END). Exercised directly against the exported pure function
// (unit-level) and via writeKimiHooksToml/removeKimiHooksToml (the real
// install/uninstall callers), so both the read seam and both call sites are
// covered.
// ---------------------------------------------------------------------------
function gsdHooksBlock(entry = '[[hooks]]\nevent = "SessionStart"\ncommand = "x"') {
return `${KIMI_HOOKS_TOML_MARKER_BEGIN}\n\n${entry}\n\n${KIMI_HOOKS_TOML_MARKER_END}`;
}
describe('stripKimiHooksTomlBlock (code-review regression)', () => {
test('block at EOF is stripped cleanly (existing idempotent case still works)', () => {
const input = `[providers]\nx = 1\n\n${gsdHooksBlock()}\n`;
const result = stripKimiHooksTomlBlock(input);
assert.equal(result, '[providers]\nx = 1');
});
test('block BETWEEN two user sections is stripped WITHOUT gluing the sections together', () => {
const input = `[providers]\nx = 1\n\n${gsdHooksBlock()}\n\n[models]\ny = 2\n`;
const result = stripKimiHooksTomlBlock(input);
assert.equal(result, '[providers]\nx = 1\n\n[models]\ny = 2\n');
assert.doesNotMatch(result, /x = 1\[models\]/, 'the two user sections must not be glued onto one line');
});
test('block at START with user content after is stripped without a stray leading blank line', () => {
const input = `${gsdHooksBlock()}\n\n[models]\ny = 2\n`;
const result = stripKimiHooksTomlBlock(input);
assert.equal(result, '[models]\ny = 2\n');
});
test('BEGIN present but END missing leaves content UNCHANGED (never deletes to EOF)', () => {
const input = `[providers]\nx = 1\n\n${KIMI_HOOKS_TOML_MARKER_BEGIN}\n\n[[hooks]]\nevent = "SessionStart"\n\n[models]\ny = 2\n`;
const result = stripKimiHooksTomlBlock(input);
assert.equal(result, input, 'a malformed marker pair (no END) must be a no-op, not a deletion to EOF');
});
test('no markers at all is a no-op', () => {
const input = '[providers]\nx = 1\n\n[models]\ny = 2\n';
const result = stripKimiHooksTomlBlock(input);
assert.equal(result, input);
});
});
describe('writeKimiHooksToml / removeKimiHooksToml (code-review regression: surrounding user content)', () => {
function makeHooksDir(t) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'kimi-hooksdir-'));
t.after(() => cleanup(dir));
fs.mkdirSync(path.join(dir, 'hooks'), { recursive: true });
fs.writeFileSync(path.join(dir, 'hooks', 'gsd-check-update.js'), '// stub');
return dir;
}
test('reinstall over a config.toml with user sections BEFORE and AFTER the GSD block preserves both, un-glued', (t) => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'kimi-toml-'));
t.after(() => cleanup(tmp));
const configPath = path.join(tmp, 'config.toml');
fs.writeFileSync(configPath, '[providers]\nx = 1\n\n[models]\ny = 2\n');
const hooksDir = makeHooksDir(t);
const hookOpts = { platform: 'linux', node: 'node', bash: '/bin/bash' };
writeKimiHooksToml(configPath, hooksDir, { hookOpts });
// Re-seed a user section AFTER the newly-written GSD block, mirroring a
// hand-edited config.toml, then reinstall on top of it.
fs.appendFileSync(configPath, '\n[extra]\nz = 3\n');
writeKimiHooksToml(configPath, hooksDir, { hookOpts });
const content = fs.readFileSync(configPath, 'utf8');
assert.match(content, /\[providers\]\s*\nx = 1/, 'the earlier [providers] section must survive');
assert.match(content, /\[extra\]\s*\nz = 3/, 'the later [extra] section must survive');
assert.doesNotMatch(content, /GSD Hooks END\s*\[extra\]/, 'GSD Hooks END must not glue directly onto [extra]');
});
test('uninstall (removeKimiHooksToml) with user content on both sides of the block preserves it, un-glued', (t) => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'kimi-toml-remove-'));
t.after(() => cleanup(tmp));
const configPath = path.join(tmp, 'config.toml');
const block = gsdHooksBlock();
fs.writeFileSync(configPath, `[providers]\nx = 1\n\n${block}\n\n[models]\ny = 2\n`);
const result = removeKimiHooksToml(configPath);
assert.equal(result.changed, true);
const content = fs.readFileSync(configPath, 'utf8');
assert.equal(content, '[providers]\nx = 1\n\n[models]\ny = 2\n');
});
});
// ---------------------------------------------------------------------------
// UPGRADE 2: background dispatch (backgroundDispatch: false -> true)
// ---------------------------------------------------------------------------
test('UPGRADE 2: capabilities/kimi/capability.json declares dispatch.background && dispatch.backgroundDispatch as true', () => {
const d = KIMI_CAP.runtime.hostIntegration.dispatch;
assert.equal(d.background, true);
assert.equal(d.backgroundDispatch, true);
});
test('UPGRADE 2: negotiateHostCapabilities against kimi axes yields effective.dispatch.backgroundDispatch === true, and shouldFlattenDispatch is false (background now allowed)', () => {
const KIMI_AXES = KIMI_CAP.runtime.hostIntegration;
const { effective } = negotiateHostCapabilities(KIMI_AXES);
assert.equal(effective.dispatch.backgroundDispatch, true);
assert.equal(shouldFlattenDispatch(effective.dispatch), false,
'kimi may now background — dispatch must not be flattened to inline');
});
test('UPGRADE 2: a corrupted/undeclared dispatch still fails closed to inline (shouldFlattenDispatch === true)', () => {
assert.equal(shouldFlattenDispatch(null), true, 'null dispatch must flatten (fail-closed)');
assert.equal(shouldFlattenDispatch(undefined), true, 'undefined dispatch must flatten (fail-closed)');
assert.equal(shouldFlattenDispatch({ background: true, backgroundDispatch: 'undocumented' }), true,
'a non-boolean-true backgroundDispatch must still flatten');
});
// Code-review AC4 follow-up: negotiating backgroundDispatch=true is necessary
// but not sufficient — Kimi's run_in_background dispatch (moonshotai.github.io
// /kimi-cli/en/customization/agents.html) is only reachable through a root
// agent whose YAML `tools:` list actually grants kimi_cli.tools.agent:Agent.
// This is the installer-testable proxy for UPGRADE 2: a real `--kimi --global`
// install (which generates subagents from GSD's own agents/ dir) must emit
// that tool grant on the root agent. Exercising the actual run_in_background
// call is Kimi's own runtime behavior, out of the installer's test scope (see
// docs/reference/host-integration-capability-matrix.md's kimi EoS-status
// paragraph) — the installer's deliverable stops at the Agent-tool grant plus
// the negotiated backgroundDispatch axis asserted above.
test('UPGRADE 2 (installer-testable proxy): kimi --global install with subagents present grants kimi_cli.tools.agent:Agent on the root agent', (t) => {
const { root } = runMinimalInstall({ runtime: 'kimi', scope: 'global' });
t.after(() => cleanup(root));
const rootYamlPath = path.join(root, 'agents', 'gsd.yaml');
assert.ok(fs.existsSync(rootYamlPath), 'kimi: agents/gsd.yaml must exist');
const rootYaml = fs.readFileSync(rootYamlPath, 'utf8');
// Sanity: subagents must actually be present, otherwise this assertion
// would prove nothing about the background-dispatch grant.
assert.match(rootYaml, /^\s*subagents:\s*$/m,
'a real kimi install must generate at least one subagent for this proxy to be meaningful');
assert.match(rootYaml, /kimi_cli\.tools\.agent:Agent/,
'root agent YAML must grant kimi_cli.tools.agent:Agent — the tool grant that enables run_in_background dispatch');
});
// ---------------------------------------------------------------------------
// Boundary: extendedHookEvents is exactly the 4 documented events, and each
// one is a real, wired Kimi event — not merely declared in capability.json.
// ---------------------------------------------------------------------------
test('capabilities/kimi/capability.json extendedHookEvents contains exactly the 4 documented events', () => {
const events = KIMI_CAP.runtime.extendedHookEvents;
assert.deepEqual(events, ['SubagentStop', 'Stop', 'PreCompact', 'SubagentStart']);
assert.equal(events.length, 4);
});
test('boundary: every capability-declared extendedHookEvent is wired as a real event = "..." entry in config.toml, not merely declared', (t) => {
const { root } = runMinimalInstall({ runtime: 'kimi', scope: 'global' });
t.after(() => cleanup(root));
const tomlPath = path.join(root, '.kimi', 'config.toml');
const toml = fs.readFileSync(tomlPath, 'utf8');
for (const event of KIMI_CAP.runtime.extendedHookEvents) {
assert.match(toml, new RegExp(`event = "${event}"`),
`${event} must be wired as an actual [[hooks]] entry, not merely declared in capability.json`);
}
// Sanity: the base (non-extended) claude-dialect events are also wired,
// since kimi's hookEvents dialect is "claude" (SessionStart/PreToolUse/PostToolUse).
for (const event of ['SessionStart', 'PreToolUse', 'PostToolUse']) {
assert.match(toml, new RegExp(`event = "${event}"`),
`base claude-dialect event ${event} must also be wired for kimi`);
}
});

View File

@@ -20,6 +20,8 @@ const {
convertClaudeCommandToOpencodeSkill,
convertClaudeCommandToKiloSkill,
convertClaudeCommandToTraeSkill,
convertClaudeCommandToKimiSkill,
buildKimiAgentArtifacts,
neutralizeAgentReferences,
} = require('../bin/install.js');
@@ -310,6 +312,58 @@ Do nothing.`;
});
});
// ─────────────────────────────────────────────────────────────────────────────
// DEFECT.GENERATIVE-FIX output-parity guard: convertClaudeCommandToKimiSkill
// and buildKimiAgentArtifacts are each defined TWICE — once in bin/install.js
// (dead for the live install path; kept for this file's own module-level
// export/test surface) and once in src/runtime-artifact-conversion.cts,
// compiled to gsd-core/bin/lib/runtime-artifact-conversion.cjs (used by
// src/install-engine.cts's skills-install path via SKILLS_CONVERTER_REGISTRY
// for skills, and by src/runtime-artifact-layout.cts's kimiAgentsKind for
// agent YAML — see runtime-artifact-layout.cts ~L268/L289). Both copies are
// LIVE call surfaces — neither re-exports the other, so any future
// kimi-specific fix (e.g. the #2095 code-review fixes) must be applied to
// both by hand. Source-text identity can't be asserted (different module
// systems: plain CJS vs a tsc-compiled .cts output with different
// surrounding comments), so this instead proves the two implementations
// still produce IDENTICAL output for representative command and agent
// input. If a future edit changes one copy's behavior without mirroring it
// into the other, this test is the guard that catches the divergence.
// ─────────────────────────────────────────────────────────────────────────────
describe('convertClaudeCommandToKimiSkill / buildKimiAgentArtifacts output parity: bin/install.js vs runtime-artifact-conversion.cjs (#2095)', () => {
const {
convertClaudeCommandToKimiSkill: convertViaConversionModule,
buildKimiAgentArtifacts: buildViaConversionModule,
} = require('../gsd-core/bin/lib/runtime-artifact-conversion.cjs');
test('identical skill output for a representative command', () => {
const viaInstall = convertClaudeCommandToKimiSkill(SAMPLE_COMMAND, 'gsd-execute-phase');
const viaModule = convertViaConversionModule(SAMPLE_COMMAND, 'gsd-execute-phase');
assert.equal(viaInstall, viaModule, 'bin/install.js and runtime-artifact-conversion.cjs must emit identical Kimi skill output');
});
test('identical skill output when the source has no description (falls back to generic description)', () => {
const noDescriptionCommand = `---
name: gsd-noop
allowed-tools:
- Read
---
Do nothing.`;
const viaInstall = convertClaudeCommandToKimiSkill(noDescriptionCommand, 'gsd-noop');
const viaModule = convertViaConversionModule(noDescriptionCommand, 'gsd-noop');
assert.equal(viaInstall, viaModule, 'bin/install.js and runtime-artifact-conversion.cjs must emit identical output when description is absent');
});
test('identical agent-artifact output for a representative agent with a subagent, including the Agent tool grant', () => {
const viaInstall = buildKimiAgentArtifacts({ rootAgent: SAMPLE_AGENT, subagents: [SAMPLE_AGENT] });
const viaModule = buildViaConversionModule({ rootAgent: SAMPLE_AGENT, subagents: [SAMPLE_AGENT] });
assert.deepEqual(viaInstall, viaModule, 'bin/install.js and runtime-artifact-conversion.cjs must emit an identical artifact bundle');
assert.match(viaInstall.root.yaml, /kimi_cli\.tools\.agent:Agent/,
'both copies must grant the Agent tool when a subagent is present (#2095 Upgrade 2)');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Antigravity agent conversion — shared Gemini-backend tool mapping (#1394 / #1928)
// ─────────────────────────────────────────────────────────────────────────────

View File

@@ -27,11 +27,31 @@ const INSTALL_JS = path.join(__dirname, '..', 'bin', 'install.js');
// include both files so structural invariants are verified against the correct source.
const HOOKS_SURFACE_SRC = path.join(__dirname, '..', 'src', 'runtime-hooks-surface.cts');
// Hooks whose registration lives in runtime-hooks-surface module, not install.js.
// These are excluded from the install.js source-scan and validated behaviorally.
// Hooks whose registration is NOT a literal buildHookCommand(..., '<name>', ...)
// call in install.js/runtime-hooks-surface.cts, so the source-scan below cannot
// find them there. These are excluded from that scan and validated/documented
// elsewhere.
const MODULE_OWNED_HOOKS = new Set([
// Cursor lifecycle hooks — registered by writeCursorHooksJson via the
// descriptor-driven CURSOR_EVENT_SCRIPT_MAP indirection
// (src/host-integration-adapters/imperative-hook-bus.cts), never a literal
// buildHookCommand(..., '<hook-name>', ...) call this source-scan can match.
// Validated behaviorally in the describe block below.
'gsd-cursor-session-start.js',
'gsd-cursor-post-tool.js',
'gsd-cursor-pre-tool.js',
'gsd-cursor-stop.js',
'gsd-cursor-subagent-start.js',
'gsd-cursor-subagent-stop.js',
// gsd-check-update-worker.js is an implementation detail of gsd-check-update.js
// (spawned internally via child_process.spawn), never itself registered as a
// hook entry point.
'gsd-check-update-worker.js',
// gsd-ensure-canonical-path.js (#997) is registered ONLY in the
// marketplace-plugin manifest (hooks/hooks.json's SessionStart entry), not
// via install.js's classic-installer settings.json path — the plugin
// install mode never runs bin/install.js at all.
'gsd-ensure-canonical-path.js',
]);
// ADR-857 phase 5f-1b: settings-json hook registration moved to runtime-hooks-surface.cts.