refactor: drop 12 runtimes, keep Claude, Codex, OpenCode, Cursor, ZCode, Antigravity

Removes kilo, kimi, kimi-code, copilot, windsurf, augment, trae, qwen, hermes,
cline, codebuddy and pi end to end: capability descriptors, installer branches
and converters (bin/install.js 14.9k -> 11.2k lines), TypeScript converters,
hook surfaces and runtime homes, review lanes qwen/kimi-code, the two pi
migrations, Kimi payload normalization in the hook guards, dead hostBehaviors
vocabulary, launcher home probes, fixtures, runtime-specific tests and the
prose that presented them as supported.

Installer output for the six kept runtimes is byte-identical to before the
prune. The Kimi tool-vocabulary tests in workflow-guard, read-guard and
read-injection-scanner are left in place pending a decision.
This commit is contained in:
Jakub Zych
2026-10-06 20:02:40 +02:00
parent 12ee75a509
commit 6cfa0c55d2
509 changed files with 1869 additions and 47737 deletions

View File

@@ -11,7 +11,7 @@
* primitives (command/dispatch/model/hooks/state/artifact) to the registry's
* declared capability set.
*
* Concrete host binding (OpenCode/VS Code/pi) is deferred to Phase 5 (#1682,
* Concrete host binding (OpenCode/VS Code) is deferred to Phase 5 (#1682,
* D15/D18). This slice ships the adapter + the composed-registry seam.
*
* Minimal interface (per ADR-1239 open wire-shape question): satisfies the

View File

@@ -541,22 +541,14 @@ function checkCodexSandboxPosture(runtime?: string, projectRoot?: string): Codex
/**
* Probe a single agents dir for `<name>` across runtime filename variants.
* Mirrors {@link checkAgentsInstalled}'s probe (`.md`, `.agent.md`, `.toml`,
* and the kimi `subagents/<name>.{yaml,md}` pair) so the two can never disagree
* about which on-disk shapes count as "installed". Not exported — internal to
* {@link resolveAgentHint}.
* Mirrors {@link checkAgentsInstalled}'s probe (`.md` and the Codex `.toml`)
* so the two can never disagree about which on-disk shapes count as
* "installed". Not exported — internal to {@link resolveAgentHint}.
*/
function agentFileExists(agentsDir: string, name: string, runtime: string): boolean {
function agentFileExists(agentsDir: string, name: string, _runtime: string): boolean {
const base = path.join(agentsDir, `${name}.md`);
const copilot = path.join(agentsDir, `${name}.agent.md`);
const codex = path.join(agentsDir, `${name}.toml`);
if (fs.existsSync(base) || fs.existsSync(copilot) || fs.existsSync(codex)) {
return true;
}
// kimi requires BOTH the persona yaml and the prompt md (same as checkAgentsInstalled).
const kimiYaml = path.join(agentsDir, 'subagents', `${name}.yaml`);
const kimiPrompt = path.join(agentsDir, 'subagents', `${name}.md`);
return runtime === 'kimi' && fs.existsSync(kimiYaml) && fs.existsSync(kimiPrompt);
return fs.existsSync(base) || fs.existsSync(codex);
}
/**

View File

@@ -408,7 +408,7 @@ function runMain(main: () => number | string | void | Promise<number | string |
* like `payload`. This exists because `hooks/msd-write-guard.js`'s
* emitBlock does NOT write the same bytes to both streams today — it
* writes the full JSON `output` to stdout but only the plain-text
* `output.reason` STRING to stderr, because Kimi's native hook bus reads
* `output.reason` STRING to stderr, because a native hook bus may read
* stderr verbatim back to the model on exit 2. Migrating that call site
* onto terminateNow requires a way to say "fd 2 gets this different,
* plain-text value" — `stderrPayload` is that seam. Ignored entirely for

View File

@@ -15,7 +15,7 @@
* 1. **Write only where MSD owns the contents.** The marker belongs in the
* directories MSD fills with its own `.js` files (`hooks/`, and the
* `nativePlugin.dir` for the runtimes that declare one) — never at the
* runtime's shared config root, which on OpenCode and Kilo is documented,
* runtime's shared config root, which on OpenCode is documented,
* user-writable territory for declaring local-plugin npm dependencies.
*
* 2. **Never overwrite a file MSD did not write.** Before #2544 the install

View File

@@ -46,7 +46,7 @@ export interface RuntimeTiers {
* to select a preset from the model catalog.
* - `runtime_tiers` — explicit per-runtime, per-tier model overrides
* (Sub-path A). Keys are runtime slugs (e.g. `"opencode"`,
* `"copilot"`); values are `RuntimeTiers` maps.
* `"codex"`); values are `RuntimeTiers` maps.
*/
export interface ModelPolicyConfig {
provider: string;

View File

@@ -2,7 +2,7 @@
* Serialized (out-of-process) capability-exchange handshake (ADR-1239 Phase E / #1683).
*
* Phase 1's `negotiateHostCapabilities` is IN-PROCESS (a host descriptor merged
* directly into the engine). Out-of-process SDK hosts (pi, VS Code) cannot share
* directly into the engine). Out-of-process SDK hosts (VS Code) cannot share
* object references with the engine — they exchange a SERIALIZED capability set
* over a wire boundary (an MCP-style `initialize`). This module is the wire form
* of that handshake, kept CONSISTENT with the in-process negotiation: a request

View File

@@ -10,7 +10,7 @@
* locally for a Phase-5 host binding to dispatch to; `emit` delegates to a
* host-supplied emitter (fail-closed until bound — MSD does not drive a
* host-owned bus).
* - `none` — no bus (Cline-rules). Degrades to rule-text instructions;
* - `none` — no bus (rules-file hosts). Degrades to rule-text instructions;
* subscribe/emit are no-ops.
*
* Portable event floor — the "claude dialect" all hook-capable hosts share

View File

@@ -1,256 +0,0 @@
/**
* Cline SDK binding — AgentPlugin + createAgentModel adapters
* (ADR-1239 Phase D / #2090).
*
* Two Context7-verified UPGRADES the file-convention projection ignored, now
* delivered through the negotiated `hookBus: host` + `modelMode: active`
* interface points:
*
* UPGRADE 1 — `AgentPlugin.hooks.beforeTool` planning-artifact guard.
* Re-implements the `.clinerules/hooks/PreToolUse` file-convention hook
* (issue #787) as a real Cline SDK AgentPlugin. Guard semantics are
* preserved EXACTLY: fail-open, cancel (skip) write-class calls targeting
* `.planning/`, pass through everything else. The SDK maps the file hook's
* `{cancel:true, errorMessage}` to `{skip:true, reason}` (beforeTool
* contract).
* Cite: https://github.com/cline/cline/blob/main/docs/sdk/plugins.mdx
* https://github.com/cline/cline/blob/main/sdk/packages/agents/README.md
*
* UPGRADE 2 — `DefaultGateway.createAgentModel({providerId, modelId})`.
* Resolves MSD's per-subagent `model_overrides` / `model_profile_overrides`
* (already used for OpenCode/Codex passive hosts) into the createAgentModel
* call params for cline's active model mode. The host gateway owns the
* actual model instantiation; this binding resolves WHICH model an
* overridden subagent should use.
* Cite: https://github.com/cline/cline/blob/main/docs/sdk/reference/gateway.mdx
* https://github.com/cline/cline/blob/main/sdk/packages/llms/README.md
*
* This module is PURE (no I/O, no SDK import): the real `@cline/sdk` is a
* fast-moving package set not linked at build/test time, so the binding exposes
* the decision functions a host plugin/gateway would call. Tests drive payloads
* through them directly (same mock-the-SDK pattern as the VS Code reference
* binding, tests/fixtures/vscode-host-binding.cjs).
*/
'use strict';
// ---------------------------------------------------------------------------
// UPGRADE 1 — beforeTool planning-artifact guard
// ---------------------------------------------------------------------------
/**
* Write-class tool-verb detector. Matches the SAME regex as the #787
* PreToolUse file-convention hook so the guard behaves identically.
* Case-insensitive (the SDK delivers tool names in varying case).
*/
export const WRITE_TOOL_PATTERN = /write|edit|replace|create|delete|remove|append|apply|patch|insert|mkdir/i;
/**
* `.planning/` path detector. Matches `.planning` preceded by start-of-string
* or a path separator (posix `/` or windows `\`) and followed by a separator or
* end-of-string — so `.planning-readme.txt` is NOT falsely matched. Mirrors the
* PreToolUse hook's `(^|[\\/])\.planning([\\/]|$)` exactly.
*/
export const PLANNING_PATH_PATTERN = /(^|[\\/])\.planning([\\/]|$)/;
/**
* The user-visible reason returned when a `.planning/` write is blocked.
* Preserves the PreToolUse hook's errorMessage text so the guard behaves
* identically to the user (cancel→skip, errorMessage→reason).
*/
export const PLANNING_GUARD_REASON: string = Object.freeze(
'MSD: .planning/ artifacts are managed by MSD workflows. Edit them only through a /msd-* command, not directly.',
);
/**
* Path-bearing field-name detector. Only PATH-keyed field values are inspected,
* so a document that merely mentions ".planning/" in its body content is never
* falsely blocked. Mirrors the PreToolUse hook's PATH_KEY regex exactly.
*/
const PATH_KEY_PATTERN = /^(path|file|file_?path|filepath|target_?path|target|dir|directory|uri|filename)$/i;
type BeforeToolPayload = {
tool?: { name?: unknown } | string | null | undefined;
input?: unknown;
};
type BeforeToolDecision = { decision: 'skip'; reason: string } | { decision: 'allow'; reason?: undefined };
/**
* Collect PATH-bearing string field values from an object tree, mirroring the
* PreToolUse hook's bounded walk. Pure; never throws.
*/
function collectPathValues(root: unknown): string[] {
const paths: string[] = [];
const walk = (v: unknown, depth: number): void => {
if (depth > 5 || paths.length > 64) return;
if (Array.isArray(v)) {
for (const x of v) walk(x, depth + 1);
return;
}
if (v && typeof v === 'object') {
const obj = v as Record<string, unknown>;
for (const k of Object.keys(obj)) {
const val = obj[k];
if (typeof val === 'string' && PATH_KEY_PATTERN.test(k)) {
paths.push(val);
} else {
walk(val, depth + 1);
}
}
}
};
walk(root, 0);
return paths;
}
/**
* Resolve a tool name from a beforeTool payload's `tool` field, which may be a
* string or an object with a `name` property. Returns '' when absent (treated
* as non-write-class → allow, fail-open).
*/
function resolveToolName(tool: BeforeToolPayload['tool']): string {
if (!tool) return '';
if (typeof tool === 'string') return tool;
const name = tool.name;
return typeof name === 'string' ? name : '';
}
/**
* The pure guard decision: given a beforeTool payload, decide skip (cancel) or
* allow. Fail-OPEN — any malformed input, missing tool, or thrown error returns
* 'allow' (the guard never blocks on a defect, mirroring the PreToolUse hook).
*
* @returns `{decision:'skip', reason}` for a write-class call targeting
* `.planning/`; `{decision:'allow'}` for everything else.
*/
export function evaluateBeforeTool(payload: BeforeToolPayload | null | undefined): BeforeToolDecision {
try {
if (!payload) return { decision: 'allow' };
const toolName = resolveToolName(payload.tool);
if (!toolName) return { decision: 'allow' };
const isWrite = WRITE_TOOL_PATTERN.test(toolName);
if (!isWrite) return { decision: 'allow' };
const paths = collectPathValues(payload.input);
const isPlanningPath = (s: string): boolean => PLANNING_PATH_PATTERN.test(s);
if (paths.some(isPlanningPath)) {
return { decision: 'skip', reason: PLANNING_GUARD_REASON };
}
return { decision: 'allow' };
} catch {
// Fail-open: a defect in the guard never blocks the user's operation.
return { decision: 'allow' };
}
}
/**
* The Cline `AgentPlugin` shape (Context7 /cline/cline). A plugin implements
* `setup({agentId})` returning `{hooks, tools}`. The `beforeTool` hook returns
* `{skip:true, reason}` to block or `undefined` to allow.
*
* This object is the portable plugin a host loads from `~/.cline/plugins/`
* (analogous to `.opencode/plugins/msd-core.js`). Its `beforeTool` delegates to
* the pure `evaluateBeforeTool` so the decision logic is testable without the
* SDK linked.
*/
export const clineMsdPlugin: {
name: string;
setup: (ctx: { agentId?: string }) => {
hooks: {
beforeTool: (payload: BeforeToolPayload) => { skip: true; reason: string } | undefined;
};
};
} = Object.freeze({
name: 'msd-planning-guard',
setup(_ctx: { agentId?: string }) {
return {
hooks: {
beforeTool(payload: BeforeToolPayload): { skip: true; reason: string } | undefined {
const decision = evaluateBeforeTool(payload);
return decision.decision === 'skip' ? { skip: true, reason: decision.reason } : undefined;
},
},
};
},
});
// ---------------------------------------------------------------------------
// UPGRADE 2 — createAgentModel model-override resolution
// ---------------------------------------------------------------------------
/**
* The fallback provider id when a model id does not match a known provider
* family. Anthropic is cline's most common default; the host gateway retains
* the final say over provider resolution.
*/
export const DEFAULT_CLINE_PROVIDER_ID: string = 'anthropic';
/**
* Infer a `providerId` (the createAgentModel first arg) from a model id by
* matching known provider families. Returns DEFAULT_CLINE_PROVIDER_ID for an
* unrecognized or empty id (fail-safe — the gateway applies its own default).
*
* Pure string-prefix classification; does not validate the id is a real model.
*/
export function inferProviderId(modelId: string): string {
if (typeof modelId !== 'string' || modelId.length === 0) return DEFAULT_CLINE_PROVIDER_ID;
const lower = modelId.toLowerCase();
if (lower.startsWith('claude')) return 'anthropic';
if (lower.startsWith('gpt') || lower.startsWith('o1') || lower.startsWith('o3') || lower.startsWith('o4')) return 'openai';
if (lower.startsWith('gemini')) return 'google';
if (lower.startsWith('deepseek')) return 'deepseek';
return DEFAULT_CLINE_PROVIDER_ID;
}
type ModelOverrideMap = Record<string, string> | null | undefined;
type ProfileOverrideMap = Record<string, Record<string, string>> | null | undefined;
type AgentModelParams = { providerId: string; modelId: string };
/**
* Resolve the createAgentModel call params for a cline subagent from MSD's model
* override config. Mirrors the precedence OpenCode/Codex use (passive hosts
* embed the resolved model into agent frontmatter); for cline's active model
* mode the same resolution flows to `gateway.createAgentModel(params)`.
*
* Precedence (matches MSD's model_overrides > model_profile_overrides contract):
* 1. `modelOverrides[agentType]` — direct per-agent override
* 2. `modelProfileOverrides[profile][agentType]` — profile-scoped override
* 3. null — no override; the host gateway applies its own default
*
* Pure; never throws. Non-string / empty override values are ignored (fail-safe).
*
* @returns the `{providerId, modelId}` for createAgentModel, or null when no
* override is configured (the gateway default applies — MSD does NOT
* call createAgentModel in that case).
*/
export function resolveClineAgentModelParams(args: {
agentType: string;
modelOverrides?: ModelOverrideMap;
modelProfileOverrides?: ProfileOverrideMap;
profile?: string;
}): AgentModelParams | null {
const { agentType, modelOverrides, modelProfileOverrides, profile } = args;
if (!agentType || typeof agentType !== 'string') return null;
// 1. Direct per-agent override wins.
if (modelOverrides && typeof modelOverrides === 'object') {
const direct = modelOverrides[agentType];
if (typeof direct === 'string' && direct.length > 0) {
return { providerId: inferProviderId(direct), modelId: direct };
}
}
// 2. Profile-scoped override.
if (modelProfileOverrides && typeof modelProfileOverrides === 'object' && profile) {
const profileEntry = modelProfileOverrides[profile];
if (profileEntry && typeof profileEntry === 'object') {
const profileModel = profileEntry[agentType];
if (typeof profileModel === 'string' && profileModel.length > 0) {
return { providerId: inferProviderId(profileModel), modelId: profileModel };
}
}
}
// 3. No override — gateway default applies.
return null;
}

View File

@@ -50,7 +50,7 @@ const SDK = Object.freeze({
createHookBus: hookBus.createHookBus,
createStateIO: stateIo.createStateIO,
// ── Serialized handshake (out-of-process SDK hosts: pi / VS Code) ─────────
// ── Serialized handshake (out-of-process SDK hosts: VS Code) ──────────────
HANDSHAKE_METHOD: handshake.HANDSHAKE_METHOD,
buildHandshakeRequest: handshake.buildHandshakeRequest,
handleHandshakeRequest: handshake.handleHandshakeRequest,

View File

@@ -650,8 +650,7 @@ function negotiateHostCapabilities(
* This graduates the #853 prose rule (originally `RUNTIME === 'codex'`, then
* extended to cursor) to a typed, documentation-sourced decision; of the shipped
* background-capable hosts only cursor (`maxDepth:2`) remains background-eligible under the
* depth-aware rule — codex (`maxDepth:1`), kimi (`nested:false`), and kimi-code
* (`built-in-only` toolkit) now correctly flatten. See
* depth-aware rule — codex (`maxDepth:1`) now correctly flattens. See
* docs/reference/host-integration-capability-matrix.md.
*
* Null-safety: if dispatch is null, undefined, or not an object, returns true
@@ -683,17 +682,17 @@ function shouldFlattenDispatch(dispatch: UnvalidatedDispatch): boolean {
// descriptor declares `hostIntegration.dispatch.namedDispatch: true` (Claude,
// OpenCode, Cursor, …), the requested name is returned unchanged — those hosts
// can dispatch MSD's named subagents directly. On runtimes with
// `namedDispatch: false` (kimi-code — only three built-in subagents
// `coder`/`explore`/`plan`, per moonshotai.github.io/kimi-code/en/customization/
// agents), the name is mapped to the closest built-in by role-suffix
// `namedDispatch: false` (a built-in-only host exposing just the
// `coder`/`explore`/`plan` subagents — none shipped today), the name is
// mapped to the closest built-in by role-suffix
// heuristic. The persona rides the existing `${AGENT_SKILLS_*}` prompt
// injection (Phase 3 / #2510) regardless of the resolved type, so the
// dispatcher does not need to know the persona — only the toolkit tier.
//
// This is Option A of the Phase 4 design (per-workflow runtime detection via
// `msd_run query resolve-dispatch-type`), not Option B (PreToolUse mutation) —
// Kimi Code's documented hook API supports only allow/deny on PreToolUse, not
// tool_input rewriting, so a hook-based remap is infeasible (see #2508).
// a built-in-only host's hook API may support only allow/deny on PreToolUse,
// not tool_input rewriting, so a hook-based remap is infeasible (see #2508).
//
// Fail-closed: unknown dispatch shape or missing namedDispatch axis ⇒ return
// the requested name unchanged (named-dispatch is the MSD default; degrading
@@ -718,7 +717,7 @@ const GENERIC_NAMES_TO_CODER: ReadonlySet<string> = Object.freeze(new Set([
function resolveDispatchType(requested: unknown, dispatch: UnvalidatedDispatch): string {
if (typeof requested !== 'string' || requested.length === 0) return 'coder';
// Built-in-only runtime (EXPLICIT namedDispatch: false, e.g. kimi-code):
// Built-in-only runtime (EXPLICIT namedDispatch: false):
// map to coder/explore/plan by suffix heuristic.
if (dispatch && typeof dispatch === 'object' && dispatch.namedDispatch === false) {
if (GENERIC_NAMES_TO_CODER.has(requested)) return 'coder';
@@ -769,13 +768,11 @@ function hookEventSurfaceFor(hookEvents: unknown): readonly string[] | null {
// event subset (imperative hosts' extension API). They are not the same thing.
//
// Values are documentation-sourced (ADR-1239 §research): OpenCode ~25 plugin
// events (session/tool/file/permission); pi ~30 fine-grained extension events;
// events (session/tool/file/permission);
// 'none' = the host exposes no extension surface and the engine owns the bus
// (VS Code). Declarative hosts (no plugin API) do not set `extensionEvents`.
// OpenCode's plugin event surface (ADR-1239 §research; ~25 documented events,
// MSD binds this subset). Hoisted to a named const — rather than duplicated
// object literals — so the `kilo` dialect below (#2093) can reuse the IDENTICAL
// array instead of a copy-pasted one that could silently drift out of sync.
// MSD binds this subset).
const OPENCODE_EXTENSION_EVENTS = Object.freeze([
'session.created', 'session.idle', 'experimental.session.compacting',
'tool.execute.before', 'tool.execute.after', 'file.edited',
@@ -786,40 +783,6 @@ const OPENCODE_EXTENSION_EVENTS = Object.freeze([
const EXTENSION_EVENT_SURFACES: Readonly<Record<string, readonly string[]>> = Object.freeze({
opencode: OPENCODE_EXTENSION_EVENTS,
// #2093 — Kilo Code is an OpenCode fork sharing the same plugin/extension
// event bus (host hook bus, UPGRADE 1): reuses OPENCODE_EXTENSION_EVENTS
// verbatim (not a re-derivation), so the two dialects stay pinned together
// by construction. See .kilo/plugins/msd-core.js (copied verbatim from
// .opencode/plugins/msd-core.js).
kilo: OPENCODE_EXTENSION_EVENTS,
// #2091 — Hermes Agent real plugin hook vocabulary (13 events).
// Cite: https://github.com/nousresearch/hermes-agent/blob/main/website/docs/user-guide/features/hooks.md
// Replaces the borrowed `hookEvents: "claude"` 6-event surface that silently
// never fired on Hermes.
hermes: Object.freeze([
'pre_tool_call', 'post_tool_call',
'pre_llm_call', 'post_llm_call',
'on_session_start', 'on_session_end',
'on_session_finalize', 'on_session_reset',
'subagent_start', 'subagent_stop',
'pre_gateway_dispatch', 'pre_approval_request',
'transform_tool_result',
]),
// #2102 Stage 2 — pi's real ExtensionAPI event vocabulary (~30 fine-grained
// extension events; documentation-sourced, ADR-1239 §research). Replaces the
// placeholder single-event ['tool_call'] surface — the Stage 1 value only
// covered the one event pi/msd.cjs happened to bind at the time, not the
// full declared surface.
pi: Object.freeze([
'session_start', 'project_trust', 'resources_discover', 'input',
'before_agent_start', 'agent_start', 'message_start', 'message_update',
'message_end', 'turn_start', 'context', 'before_provider_request',
'after_provider_response', 'tool_execution_start', 'tool_execution_update',
'tool_execution_end', 'tool_call', 'tool_result', 'turn_end', 'agent_end',
'session_before_switch', 'session_shutdown', 'session_before_fork',
'session_info_changed', 'session_before_compact', 'session_compact',
'session_before_tree', 'session_tree', 'thinking_level_select', 'model_select',
]),
none: Object.freeze([]),
});
@@ -867,15 +830,15 @@ type OrchestratorExecResolution =
* Fail-closed: never throws, always returns a discriminated result. When
* `cwdFlag` is a non-empty string, `[cwdFlag, cwd]` is appended to `args`
* exactly once (e.g. codex: `exec --cd <cwd>`); when `cwdFlag` is `null` or
* absent (e.g. kimi-code, which binds via the spawned process's own cwd —
* absent (a host that binds via the spawned process's own cwd — the
* "process-cwd" case), no flag is appended and `cwd` is returned for the
* caller to bind via the subprocess's own working-directory option.
*
* Prompt passing (Phase 3, #2627) is descriptor data for the same reason the
* cwd flag is: the confirmed `orchestrator-worktree` hosts disagree on the
* shape. `codex exec "<prompt>"` and `opencode run "<prompt>"` take it
* positionally; `kimi --print --prompt "<p>"` and Kimi Code's `kimi -p "<p>"`
* take a flag. Encoding that as `promptFlag` keeps the scheduler free of the
* positionally; other hosts take a flag. Encoding that as `promptFlag` keeps
* the scheduler free of the
* per-host branch ADR-1239 exists to remove. Omit `prompt` entirely and the
* resolution is byte-identical to Phase 2's (the unconsumed-resolver shape).
*

View File

@@ -4288,7 +4288,7 @@ function cmdAgentSkills(
);
// #2454: Agent prompt fallback for AGENTS-native runtimes where named
// subagents are NOT dispatchable (kimi-code, kimi, opencode, kilo, etc.).
// subagents are NOT dispatchable (opencode, etc.).
// On these runtimes, workflows inject ${AGENT_SKILLS_*} into the dispatch
// prompt of a built-in subagent (coder/explore/plan). If no
// model_profile_overrides or agent_skills config entry exists, the block
@@ -4638,7 +4638,7 @@ function buildSkillManifest(cwd: string, skillsDir: string | null = null): Skill
}
// Nested layout: <entry>/skills/<stem>/SKILL.md
// Used by cline, qwen, hermes, augment, trae, antigravity (#69 nested=true).
// Used by runtimes whose skills layout declares nesting (#69 nested=true).
// Descend exactly one level into <entry>/skills/ — no deeper recursion.
// Scope to msd-ns-* routers only: never vacuum up an unrelated user skill
// that happens to have its own `skills/` subdirectory.

View File

@@ -191,7 +191,7 @@ function provisionRuntimeSurfaceCorpus(
if (isGlobalScope(scope as InstallScope)) {
for (const kind of layout.kinds) {
if (kind.kind === 'commands' || kind.kind === 'skills') required.add('commands');
if (kind.kind === 'agents' || kind.kind === 'kimi-agents') required.add('agents');
if (kind.kind === 'agents') required.add('agents');
}
}
if (required.size === 0) return;
@@ -283,7 +283,6 @@ function applyOpencodeFamilyPathPrefix(content: string, runtime: string, pathPre
content = content.replace(/\$HOME\/\.claude\//g, pathPrefix);
content = content.replace(/\.\/\.claude\//g, `./${getDirName(runtime)}/`);
content = content.replace(/~\/\.opencode\//g, pathPrefix);
content = content.replace(/~\/\.kilo\//g, pathPrefix);
return content;
}
@@ -297,25 +296,15 @@ function convertClaudeCommandToOpencodeSkill(content: string, skillName: string)
return (runtimeArtifactConversion as any).convertClaudeCommandToOpencodeSkill(content, skillName);
}
/**
* Convert a Claude command (.md) to a Kilo skill (SKILL.md).
* Thin wrapper over the shared OpenCode-family writer (Kilo shares the schema).
*/
function convertClaudeCommandToKiloSkill(content: string, skillName: string): string {
return (runtimeArtifactConversion as any).convertClaudeCommandToKiloSkill(content, skillName);
}
/**
* Converter-name registry for the OpenCode-family combined skills installer
* (ADR-1239 / #2093). Maps the `converter` string declared on each runtime's
* artifactLayout skills-kind descriptor (capabilities/<runtime>/capability.json)
* to the actual conversion function, so `installOpencodeFamilySkills` dispatches
* off the descriptor instead of a `frontmatterDialect === 'kilo'` runtime check.
* off the descriptor instead of a `frontmatterDialect` runtime check.
*/
const SKILLS_CONVERTER_REGISTRY: Record<string, (content: string, skillName: string) => string> = {
convertClaudeCommandToOpencodeSkill,
convertClaudeCommandToKiloSkill,
convertClaudeCommandToKimiCodeSkill: runtimeArtifactConversion.convertClaudeCommandToKimiCodeSkill,
};
// ---------------------------------------------------------------------------
@@ -558,7 +547,7 @@ function _tryResolveUserArtifactStagingRoot(configDir: string): string | null {
* Migrate a legacy dev-preferences.md (saved from commands/msd/) into the
* runtime-aware SKILL.md location used by the writer after #2973.
*
* For runtimes with a nested skills layout (e.g. Hermes: skills/msd/<stem>/),
* For runtimes with a nested skills layout (skills/msd/<stem>/),
* the target is <configDir>/skills/msd/dev-preferences/SKILL.md.
* For runtimes with a flat skills layout (prefix='msd-'), the target is
* <configDir>/skills/msd-dev-preferences/SKILL.md.
@@ -573,7 +562,7 @@ function _tryResolveUserArtifactStagingRoot(configDir: string): string | null {
* user-artifact-staging.cts staged batch's disk contents (#2875) — every
* call site reads this back AFTER its own wipe, never held in memory
* across it.
* @param runtime - canonical runtime ID (e.g. 'hermes', 'qwen', 'claude')
* @param runtime - canonical runtime ID (e.g. 'claude')
* @param scope - install scope
* @returns true if a file was migrated, false otherwise
*/
@@ -618,7 +607,7 @@ function _resolveDevPreferencesSkillTarget(targetDir: string, runtime?: string,
if (runtime) {
const layout: any = runtimeArtifactLayout.resolveRuntimeArtifactLayout(runtime, targetDir, scope as any);
const skillsKindEntry = layout.kinds.find((k: any) => k.kind === 'skills');
if (!skillsKindEntry) return null; // runtime has no skills layout at this scope (e.g. cline local)
if (!skillsKindEntry) return null; // runtime has no skills layout at this scope
const stemName = skillsKindEntry.prefix === '' ? 'dev-preferences' : 'msd-dev-preferences';
// #2911: same destination-root defect as _copyStaged/applySurface — honor
// skillsKindEntry.home as a FALLBACK-preferred override (e.g. Codex skills
@@ -652,7 +641,7 @@ function migrateLegacyDevPreferencesToSkill(
): boolean {
if (!saved || !saved.has('dev-preferences.md')) return false;
const target = _resolveDevPreferencesSkillTarget(targetDir, runtime, scope);
if (!target) return false; // runtime has no skills layout at this scope (e.g. cline local)
if (!target) return false; // runtime has no skills layout at this scope
// #3712 — the SIXTH writer that resolves a skills-kind `home` override.
// Exported and directly callable, and `_runLegacyInstallMigrations` runs it
// BEFORE installRuntimeArtifacts' own assertion, so a future runtime pairing a
@@ -728,7 +717,6 @@ function migrateLegacyDevPreferencesToSkill(
* encodes the MSD namespace as its last segment (e.g. `commands/msd`), in
* which case write as `${stem}.md` (directory IS the namespace).
* - agents: write as-is (files already carry their own `msd-` prefix).
* For kimi-agents kind: recursively copy generated YAML/prompt files.
*/
function _copyStaged(stagedDir: string, destDir: string, kind: any, configDir: string, runtime?: string): void {
// Defense-in-depth: verify destDir is within the install root even if the
@@ -775,11 +763,6 @@ function _copyStaged(stagedDir: string, destDir: string, kind: any, configDir: s
return;
}
if (kind.kind === 'kimi-agents') {
installFs().cpSync(stagedDir, destDir, { recursive: true });
return;
}
// commands or agents
const entries = installFs().readdirSync(stagedDir, { withFileTypes: true });
// For commands: apply prefix unless the destSubpath's last segment already
@@ -806,10 +789,9 @@ function _copyStaged(stagedDir: string, destDir: string, kind: any, configDir: s
let destName: string;
if (kind.kind === 'agents') {
// Agent files already carry the msd- prefix in the source dir.
// #2099: descriptor-driven via hostBehaviors.agentFileExtension (was
// hardcoded `runtime === 'copilot'`). copilot declares '.agent.md';
// every other runtime's descriptor leaves this unset, so destName falls
// back to entry.name unchanged (byte-parity, #1575 origin comment).
// #2099: descriptor-driven via hostBehaviors.agentFileExtension. No
// current runtime declares one, so destName falls back to entry.name
// unchanged (byte-parity, #1575 origin comment).
const _agentExt = runtime ? _hostBehaviors(runtime).agentFileExtension : undefined;
destName = _agentExt
? entry.name.replace(/\.md$/, _agentExt)
@@ -835,28 +817,13 @@ function _copyStaged(stagedDir: string, destDir: string, kind: any, configDir: s
/**
* Remove MSD-prefixed entries from destDir matching kind.prefix.
* For the prefix='' case: the destSubpath IS the namespace — remove the entire
* destDir. (No current runtime uses prefix='' after #947 reversed Hermes; kept
* as a defensive guard for future runtimes.)
* destDir. (No current runtime uses prefix=''; kept as a defensive guard for
* future runtimes.)
*/
function _removeMsdEntries(destDir: string, kind: any): void {
if (!installFs().existsSync(destDir)) return;
if (kind.kind === 'kimi-agents') {
for (const fileName of ['msd.yaml', 'msd.md']) {
installFs().rmSync(path.join(destDir, fileName), { force: true });
}
const subagentsDir = path.join(destDir, 'subagents');
if (installFs().existsSync(subagentsDir)) {
for (const entry of installFs().readdirSync(subagentsDir, { withFileTypes: true })) {
if (!entry.isFile()) continue;
if (!entry.name.startsWith('msd-')) continue;
if (!entry.name.endsWith('.yaml') && !entry.name.endsWith('.md')) continue;
installFs().rmSync(path.join(subagentsDir, entry.name), { force: true });
}
}
return;
}
if (kind.prefix === '') {
// Whole-namespace removal (Hermes nested case — destSubpath is skills/msd)
// Whole-namespace removal (nested case — destSubpath is skills/msd)
// The directory itself is the MSD namespace, so remove it entirely.
installFs().rmSync(destDir, { recursive: true, force: true });
return;
@@ -901,37 +868,6 @@ function _restoreDir(dir: string, snapshot: Map<string, Buffer>): void {
}
}
// ---------------------------------------------------------------------------
// _removeHermesBareStemDirs
// ---------------------------------------------------------------------------
/**
* After the layout-driven install loop writes new msd-<stem>/ dirs to
* skills/msd/, remove any pre-existing bare-stem dirs (skills/msd/<stem>/)
* that correspond to the newly installed msd-<stem> entries.
*
* @param nestedMsdDir absolute path to skills/msd/ category dir
*/
function _removeHermesBareStemDirs(nestedMsdDir: string): void {
if (!installFs().existsSync(nestedMsdDir)) return;
const entries = installFs().readdirSync(nestedMsdDir, { withFileTypes: true });
// Collect the set of stems that were installed as msd-<stem>/ this run.
const installedStems = new Set<string>();
for (const entry of entries) {
if (entry.isDirectory() && entry.name.startsWith('msd-')) {
installedStems.add(entry.name.slice('msd-'.length)); // e.g. 'quick', 'dev-preferences'
}
}
// Remove any bare <stem>/ dir for which msd-<stem>/ was just installed.
for (const entry of entries) {
if (entry.isDirectory() && !entry.name.startsWith('msd-') && installedStems.has(entry.name)) {
installFs().rmSync(path.join(nestedMsdDir, entry.name), { recursive: true });
}
}
}
// ---------------------------------------------------------------------------
// Legacy migration helpers
// ---------------------------------------------------------------------------
@@ -947,10 +883,8 @@ function _removeHermesBareStemDirs(nestedMsdDir: string): void {
function _runLegacyInstallMigrations(runtime: string, configDir: string, scope: string = 'global'): void {
const legacyCommandsMsd = path.join(configDir, 'commands', 'msd');
// Claude / Qwen / Hermes: clean up legacy commands/msd/ and preserve dev-preferences
// for migration. The actual migration call is deferred to after all layout cleanup so
// that for Hermes the flat skills/msd-*/ removal (below) does not delete the freshly
// created skills/msd-dev-preferences/ skill dir.
// Claude: clean up legacy commands/msd/ and preserve dev-preferences for
// migration. The actual migration call is deferred to after all layout cleanup.
let stagedLegacyArtifacts: ReturnType<typeof userArtifactStaging.stageUserArtifacts> | null = null;
if (_hostBehaviors(runtime).legacyCommandsMsdInstallMigration) {
if (installFs().existsSync(legacyCommandsMsd)) {
@@ -967,37 +901,16 @@ function _runLegacyInstallMigrations(runtime: string, configDir: string, scope:
const stagingRoot = _tryResolveUserArtifactStagingRoot(configDir);
if (stagingRoot !== null) {
// #2875 (#1874-F19): staged DURABLY to disk before the wipe below, so a
// crash anywhere in this function — including the Hermes flat-skills
// wipe further down, previously inside the same in-memory-only window
// — survives via recoverOrphanedUserArtifacts on the next run.
// crash anywhere in this function survives via
// recoverOrphanedUserArtifacts on the next run.
stagedLegacyArtifacts = userArtifactStaging.stageUserArtifacts(legacyCommandsMsd, ['dev-preferences.md'], stagingRoot);
installFs().rmSync(legacyCommandsMsd, { recursive: true });
}
}
}
// Hermes: remove pre-#2841 flat skills/msd-*/ entries that lived alongside
// the new skills/msd/ nested layout.
if (runtime === 'hermes') {
const flatSkillsDir = path.join(configDir, 'skills');
if (installFs().existsSync(flatSkillsDir)) {
for (const entry of installFs().readdirSync(flatSkillsDir, { withFileTypes: true })) {
if (entry.isDirectory() && entry.name.startsWith('msd-')) {
installFs().rmSync(path.join(flatSkillsDir, entry.name), { recursive: true });
}
}
}
// Hermes: bare-stem skills/msd/<stem>/ cleanup is deferred to AFTER the
// layout-driven install loop in installRuntimeArtifacts, where the exact set
// of staged msd-<stem>/ dirs is known. Removing here (before staging) would
// require readMsdCommandNames() which misses skills like 'dev-preferences'
// that are not in the commands directory. See _removeHermesBareStemDirs().
}
// Migrate dev-preferences.md content → runtime-aware SKILL.md location (#2973).
// Done after all layout cleanup so Hermes flat-dir removal does not delete the
// newly created skill dir. No-op if skill file already exists.
// Done after all layout cleanup. No-op if skill file already exists.
if (stagedLegacyArtifacts) {
// #2875: read the content back from the DISK-staged copy (fresh, after
// every wipe above has already run) rather than an in-memory value held
@@ -1095,7 +1008,7 @@ function _runLegacyInstallMigrations(runtime: string, configDir: string, scope:
* @returns staged legacy artifacts for post-removal migration, or null
*/
function _runLegacyUninstallCleanup(runtime: string, configDir: string, scope: string = 'global'): ReturnType<typeof userArtifactStaging.stageUserArtifacts> | null {
// commands/msd/ is a legacy location for Qwen, Hermes, and all Claude installs.
// commands/msd/ is a legacy location for all Claude installs.
// Prior to #1367 fix, Claude-local used commands/msd/<cmd>.md (colon-namespaced).
// After #1367, Claude-local uses flat commands/msd-<cmd>.md. The inline uninstall
// block (1c) handles removal of flat files; this function handles the legacy
@@ -1114,7 +1027,7 @@ function _runLegacyUninstallCleanup(runtime: string, configDir: string, scope: s
// module does (ambient default: real fs here, since this call is never
// wrapped in withInstallFs).
let stagedLegacyArtifacts: ReturnType<typeof userArtifactStaging.stageUserArtifacts> | null = null;
// commands/msd/ is a legacy location for Qwen, Hermes, and Claude global.
// commands/msd/ is a legacy location for Claude global.
// Claude local is intentionally excluded: the inline uninstall block (1c) handles
// commands/msd/ for claude local, preserving dev-preferences.md by restoring it
// to the same location (#1423). Using migrateLegacyDevPreferencesToSkill here
@@ -1145,30 +1058,6 @@ function _runLegacyUninstallCleanup(runtime: string, configDir: string, scope: s
}
}
// Hermes: pre-#2841 flat skills/msd-*/ entries
if (runtime === 'hermes') {
const flatSkillsDir = path.join(configDir, 'skills');
if (fs.existsSync(flatSkillsDir)) {
for (const entry of fs.readdirSync(flatSkillsDir, { withFileTypes: true })) {
if (entry.isDirectory() && entry.name.startsWith('msd-')) {
fs.rmSync(path.join(flatSkillsDir, entry.name), { recursive: true });
}
}
}
// Hermes: pre-#947 bare-stem skills/msd/<stem>/ entries (dirs that do NOT
// start with 'msd-') — the #3664 layout used prefix='' so MSD-owned skills
// had bare names (e.g. skills/msd/help/). These are stale on uninstall.
const nestedMsdDirForUninstall = path.join(configDir, 'skills', 'msd');
if (fs.existsSync(nestedMsdDirForUninstall)) {
for (const entry of fs.readdirSync(nestedMsdDirForUninstall, { withFileTypes: true })) {
if (entry.isDirectory() && !entry.name.startsWith('msd-')) {
fs.rmSync(path.join(nestedMsdDirForUninstall, entry.name), { recursive: true });
}
}
}
}
// Return staged artifacts so the caller can migrate after layout-driven removal.
return stagedLegacyArtifacts;
}
@@ -1226,14 +1115,14 @@ function installRuntimeArtifacts(
// before materializing the current layout (#2644).
retiredArtifactCleanup.pruneRetiredRuntimeArtifacts(runtime, configDir);
// Combined-family runtimes (OpenCode/Kilo, ADR-1239 / #2087): route through
// Combined-family runtimes (OpenCode, ADR-1239 / #2087): route through
// the dedicated combined commands+skills+plugin orchestrator instead of the
// generic layout-driven loop below, mirroring the bespoke install path that
// previously lived inline in bin/install.js.
const behaviors = _hostBehaviors(runtime);
const projectDir = scope === 'global' ? process.cwd() : configDir;
if (behaviors.combinedFamilyInstall) {
// #2329: combined-family runtimes (OpenCode/Kilo) bypass
// #2329: combined-family runtimes (OpenCode) bypass
// _runLegacyInstallMigrations below entirely (early return), so their
// legacy-directory cleanup needs its own pre-materialization hook here.
_migrateLegacyOpencodeCommandDir(runtime, configDir, behaviors);
@@ -1377,7 +1266,7 @@ function installRuntimeArtifacts(
// then restore after. This preserves user dirs across a wipe-and-replace
// install (#2973 / #3664).
//
// All runtimes (incl. Hermes after #947) use prefix='msd-'.
// All runtimes use prefix='msd-'.
// _removeMsdEntries removes only msd-* entries; non-msd-* user dirs are
// untouched. Preserve the explicit user-owned MSD-prefixed skill
// msd-dev-preferences, which MSD does not reinstall from source but must
@@ -1427,27 +1316,9 @@ function installRuntimeArtifacts(
}
}
// Hermes: after the install loop has written all msd-<stem>/ dirs to
// skills/msd/, remove any stale bare-stem dirs (skills/msd/<stem>/) that
// correspond to the newly installed msd-<stem> entries. This is the robust
// replacement for the readMsdCommandNames()-based pre-install cleanup that
// missed skills like 'dev-preferences' (#947 adversarial review).
//
// We run this AFTER the install loop so the installed set is authoritative:
// every msd-<stem>/ present now was written this run (or was there before
// with the same prefix). User-owned bare dirs with no msd-<stem> counterpart
// are untouched.
let hermesBareStemCleanup = false;
if (runtime === 'hermes') {
const nestedMsdDirForCleanup = path.join(configDir, 'skills', 'msd');
_removeHermesBareStemDirs(nestedMsdDirForCleanup);
hermesBareStemCleanup = true;
}
// Generic-branch nativePlugin staging (ADR-1239 / #2102 Stage 1): runtimes
// outside the OpenCode/Kilo combined-family install (e.g. pi, whose
// artifactLayout is empty and which never sets combinedFamilyInstall) still
// need their declared hostBehaviors.nativePlugin file copied into configDir.
// outside the OpenCode combined-family install that declare a
// hostBehaviors.nativePlugin still need that file copied into configDir.
// findInstallSourceRoot resolves the repo/package root independent of
// configDir contents (marker check, then a walk-up from __dirname), so this
// is safe even when configDir has no .msd-source marker (artifactLayout: []).
@@ -1468,7 +1339,7 @@ function installRuntimeArtifacts(
scope,
kinds: executedKinds,
cleanup: cleanupResults,
postSteps: { hermesBareStemCleanup, nativePlugin: nativePluginInstalled },
postSteps: { nativePlugin: nativePluginInstalled },
};
});
}
@@ -1478,7 +1349,7 @@ function installRuntimeArtifacts(
// ---------------------------------------------------------------------------
/**
* Install the skills layout kind for an OpenCode-family runtime (OpenCode/Kilo).
* Install the skills layout kind for an OpenCode-family runtime (OpenCode).
*
* These runtimes do NOT go through installRuntimeArtifacts (their commands use a
* bespoke flattened-command writer), so this writes ONLY the skills kind
@@ -1486,7 +1357,7 @@ function installRuntimeArtifacts(
* layout-driven (uninstallRuntimeArtifacts iterates layout.kinds), so the
* skills/ dir is cleaned up automatically once the layout declares it.
*
* @param runtime - 'opencode' or 'kilo'
* @param runtime - 'opencode'
* @param targetDir - resolved runtime config directory
* @param rawCommandsDir - staged RAW Claude command dir (caller's _stageSkills output)
* @param pathPrefix - computed config-path prefix for body rewrites
@@ -1499,7 +1370,7 @@ function installRuntimeArtifacts(
* (capabilityClusters view). When present, installed third-party capability
* skills bound to their declaring capId are unioned into the staged output —
* the actual #2322 seam (install-profiles.cts stageSkillsForRuntimeAsSkills)
* this bespoke OpenCode/Kilo writer never called. Absent -> no third-party
* this bespoke OpenCode writer never called. Absent -> no third-party
* skills staged (fail closed), matching the seam's own optional-registry
* contract.
* @returns number of msd-* skill directories written
@@ -1518,7 +1389,7 @@ function installOpencodeFamilySkills(
if (!skillsKindEntry) return 0;
// #3712: combined-family runtimes take installRuntimeArtifacts' early return
// BEFORE its guard runs, and this writer honors `skillsKindEntry.home` below and
// then prunes that destination. opencode/kilo declare no `home` today, so there
// then prunes that destination. opencode declares no `home` today, so there
// is no live escape — but that makes this a bypass waiting on a descriptor
// change rather than a safe omission, so it is guarded at the writer instead.
// Scoped to the SKILLS kind alone, for the same reason as the agents writer.
@@ -1528,7 +1399,7 @@ function installOpencodeFamilySkills(
// #2093: descriptor-driven — dispatch off the skills-kind entry's `converter`
// string (capabilities/<runtime>/capability.json artifactLayout) via the
// SKILLS_CONVERTER_REGISTRY, instead of a `frontmatterDialect === 'kilo'`
// SKILLS_CONVERTER_REGISTRY, instead of a `frontmatterDialect`
// runtime check. Fail loud if the descriptor names an unregistered converter
// (mirrors the converter=null throw in runtime-artifact-layout.cts).
const converterName: string | undefined = skillsKindEntry.converter;
@@ -1542,8 +1413,8 @@ function installOpencodeFamilySkills(
// #2911: same destination-root defect as _copyStaged/migrateLegacyDevPreferencesToSkill
// — honor skillsKindEntry.home as a FALLBACK-preferred override (e.g. Codex skills
// -> $HOME/.agents) instead of always resolving against targetDir, so this bespoke
// OpenCode/Kilo writer lands in the SAME tree the installer and surface-apply use.
// Runtimes with no `home` override (opencode, kilo today) are unaffected. Must stay
// OpenCode writer lands in the SAME tree the installer and surface-apply use.
// Runtimes with no `home` override (opencode today) are unaffected. Must stay
// in lockstep with the sibling writers — the destination-parity test enforces it.
const installRoot: string = skillsKindEntry.home ?? targetDir;
const dest = runtimeArtifactInstallPlan.assertDestWithinConfigHome(installRoot, skillsKindEntry.destSubpath);
@@ -1659,7 +1530,7 @@ function installOpencodeFamilySkills(
* combination that never reaches that loop's own `layout.kinds` iteration.
* Two such call sites exist (#2875 Part 2):
*
* 1. **OpenCode-family runtimes** (OpenCode/Kilo, Task A) — `hostBehaviors.
* 1. **OpenCode-family runtimes** (OpenCode, Task A) — `hostBehaviors.
* combinedFamilyInstall` makes `installRuntimeArtifacts` early-return into
* `installOpencodeFamilyArtifacts` instead, which stages commands+skills
* via its OWN bespoke writers and never called `resolveRuntimeArtifactLayout`
@@ -1679,7 +1550,7 @@ function installOpencodeFamilySkills(
* (`layout.kinds` → `agentsKindEntry.stage(resolvedProfile, agentCtx)` →
* `_copyStaged`), rather than forking a second agent-staging pipeline. A
* runtime/scope whose resolved layout declares no `agents` kind at all
* (e.g. pi, whose `artifactLayout` is empty for both scopes) is a no-op
* is a no-op
* (`null`) — mirrors `installOpencodeFamilySkills`'s own
* `if (!skillsKindEntry) return 0` contract.
*
@@ -1777,14 +1648,14 @@ function installAgentsKindStandalone(
/**
* Install the flattened commands surface for an OpenCode-family runtime
* (OpenCode/Kilo): commands/msd/**\/*.md -> command/msd-<...>.md, with
* (OpenCode): commands/msd/**\/*.md -> command/msd-<...>.md, with
* per-runtime frontmatter conversion and path-prefix/attribution rewrites.
*
* Mirrors bin/install.js's copyFlattenedCommands VERBATIM (ADR-1239 /
* #2087), except attribution is resolved via the injected
* `resolveAttribution` callback instead of a module-level getCommitAttribution.
*
* @param runtime - 'opencode' or 'kilo'
* @param runtime - 'opencode'
* @param destDir - destination directory for flattened commands (recurses with the same destDir)
* @param srcDir - source directory to walk (commands/msd/, recursing into subdirectories)
* @param pathPrefix - computed config-path prefix for body rewrites
@@ -1821,18 +1692,13 @@ function installOpencodeFamilyCommands(
content = applyOpencodeFamilyPathPrefix(content, runtime, pathPrefix);
content = processAttribution(content, resolveAttribution(runtime));
// #2093: this commands-kind entry's descriptor `converter` field is
// intentionally `null` (see capabilities/{kilo,opencode}/capability.json —
// intentionally `null` (see capabilities/opencode/capability.json —
// the flattened-command writer above applies its own path/attribution
// rewrites and has no per-file converter slot to key on), so there is no
// descriptor string to dispatch through here. `frontmatterDialect` is the
// documented, intentional dispatch key for frontmatter-shape selection —
// it is itself descriptor-driven (not a `runtime === 'kilo'` check), so it
// already satisfies the fold-to-descriptor requirement. Only the SKILLS
// converter site above (installOpencodeFamilySkills) has a real
// `converter` string to key on via SKILLS_CONVERTER_REGISTRY.
content = _hostBehaviors(runtime).frontmatterDialect === 'kilo'
? (runtimeArtifactConversion as any).convertClaudeToKiloFrontmatter(content)
: (runtimeArtifactConversion as any).convertClaudeToOpencodeFrontmatter(content);
// descriptor string to dispatch through here. Only the SKILLS converter
// site above (installOpencodeFamilySkills) has a real `converter` string
// to key on via SKILLS_CONVERTER_REGISTRY.
content = (runtimeArtifactConversion as any).convertClaudeToOpencodeFrontmatter(content);
installFs().writeFileSync(path.join(destDir, destName), content);
}
}
@@ -1848,10 +1714,9 @@ function installOpencodeFamilyCommands(
*
* Extracted (ADR-1239 / #2102 Stage 1) from the body previously inlined in
* installOpencodeFamilyArtifacts so a runtime that is NOT part of the
* OpenCode/Kilo combined-family install (e.g. pi, whose artifactLayout is
* empty and which never sets combinedFamilyInstall) can still get its
* nativePlugin file staged via the generic installRuntimeArtifacts branch.
* Behavior for opencode/kilo is unchanged — same source resolution, same
* OpenCode combined-family install can still get its nativePlugin file
* staged via the generic installRuntimeArtifacts branch.
* Behavior for opencode is unchanged — same source resolution, same
* mkdir + copyFileSync call, same silent no-op when the source is missing.
*
* @param runtime - canonical runtime id (only used for the assertDestWithinConfigHome guard)
@@ -1888,9 +1753,8 @@ function _installNativePluginIfDeclared(
// marker the installer wrote at the config root — the write that
// clobbered user-authored files. Pin it from the plugin's own directory
// instead, leaving the config root alone. The marker cannot disturb
// plugin discovery: OpenCode auto-discovers `plugins/*.{ts,js}` and pi's
// isExtensionFile() accepts only `.ts`/`.js` (see installer-migration
// 006), so a package.json here is never treated as a plugin. Never
// plugin discovery: OpenCode auto-discovers `plugins/*.{ts,js}`, so a
// package.json here is never treated as a plugin. Never
// written over a package.json MSD does not own — but when one is already
// there, say so: the adapter is CommonJS and will not load under a
// foreign `"type": "module"`, and a silent no-op would leave every guard
@@ -1922,8 +1786,8 @@ function _installNativePluginIfDeclared(
* #2329: migrate a pre-fix OpenCode install's legacy singular `command/`
* command directory into the current descriptor-driven destination (plural
* `commands/` for OpenCode — the dir OpenCode actually discovers slash
* commands from; unaffected for Kilo, whose descriptor still declares
* `command`, so `currentName === LEGACY_NAME` short-circuits below).
* commands from; a descriptor that still declares `command` short-circuits
* below via `currentName === LEGACY_NAME`).
*
* Runs BEFORE materialization writes the fresh command set to the new
* location (mirroring `_runLegacyInstallMigrations`'s ordering for the
@@ -1946,14 +1810,14 @@ function _installNativePluginIfDeclared(
* Types), so the empty-directory removal below would need this same
* hand-written glue regardless. It also intentionally is NOT reachable via
* combinedFamilyInstall's early return above `_runLegacyInstallMigrations`,
* matching the existing precedent that OpenCode/Kilo's bespoke install path
* matching the existing precedent that OpenCode's bespoke install path
* owns its own legacy cleanup rather than routing through the generic
* layout-driven migrations hook.
*/
function _migrateLegacyOpencodeCommandDir(runtime: string, configDir: string, behaviors: any): void {
const LEGACY_NAME = 'command';
const currentName = behaviors.flatCommandDir || LEGACY_NAME;
if (currentName === LEGACY_NAME) return; // e.g. Kilo — legacy IS the current location; nothing to migrate
if (currentName === LEGACY_NAME) return; // legacy IS the current location; nothing to migrate
const legacyDir = path.join(configDir, LEGACY_NAME);
if (!installFs().existsSync(legacyDir)) return;
// Never follow a symlinked legacy dir out of configDir.
@@ -1998,13 +1862,13 @@ function _migrateLegacyOpencodeCommandDir(runtime: string, configDir: string, be
// ---------------------------------------------------------------------------
/**
* Combined-family install orchestrator for OpenCode/Kilo (ADR-1239 / #2087,
* Combined-family install orchestrator for OpenCode (ADR-1239 / #2087,
* #2093). Stages the flattened commands surface + skills surface + (any
* runtime whose hostBehaviors declares `nativePlugin` — OpenCode and, since
* #2093, Kilo) native plugin adapter, mirroring the bespoke `else if (isOpencode ||
* isKilo)` block previously inlined in bin/install.js.
* runtime whose hostBehaviors declares `nativePlugin`) native plugin adapter,
* mirroring the bespoke `else if (isOpencode)` block previously inlined in
* bin/install.js.
*
* @param runtime - 'opencode' or 'kilo'
* @param runtime - 'opencode'
* @param configDir - resolved runtime config directory
* @param scope - install scope ('global' | 'local')
* @param resolvedProfile - from resolveProfile() / resolveEffectiveProfile()
@@ -2013,7 +1877,7 @@ function _migrateLegacyOpencodeCommandDir(runtime: string, configDir: string, be
* @param capabilityRegistry - #2362: optional composed capability registry
* (capabilityClusters view), threaded straight through to
* installOpencodeFamilySkills so an installed third-party capability skill
* materializes for this combined-family (OpenCode/Kilo) install path too.
* materializes for this combined-family (OpenCode) install path too.
* Absent -> no third-party skills staged (fail closed).
* @param projectDir - project/config discovery root, distinct from configDir for global installs
* @returns #2874 design row 2: an executed-plan value, same top-level shape
@@ -2063,8 +1927,7 @@ function installOpencodeFamilyArtifacts(
// resolveRuntimeArtifactLayout's commands-kind destSubpath — a hardcoded
// literal here would silently diverge from the descriptor the moment either
// is edited (Generative Fix Divergence guard). OpenCode uses 'commands'
// (plural, the dir OpenCode actually discovers slash commands from); Kilo
// keeps its own descriptor value ('command', singular) unchanged.
// (plural, the dir OpenCode actually discovers slash commands from).
const commandDir = runtimeArtifactInstallPlan.assertDestWithinConfigHome(
configDir,
behaviors.flatCommandDir || 'command',
@@ -2093,7 +1956,7 @@ function installOpencodeFamilyArtifacts(
...(agentsResult ? [{ kind: 'agents', sourceDir: agentsResult.sourceDir, destDir: agentsResult.destDir }] : []),
],
cleanup: [],
postSteps: { hermesBareStemCleanup: false, nativePlugin: Boolean(behaviors.nativePlugin) },
postSteps: { nativePlugin: Boolean(behaviors.nativePlugin) },
};
}
@@ -2145,23 +2008,6 @@ function uninstallRuntimeArtifacts(
_removeMsdEntries(item.destDir, kind);
}
// Hermes: after removing msd-* skill dirs from skills/msd/, also remove
// the MSD-managed DESCRIPTION.md and then the category dir itself if it
// contains no user content (#947). _removeMsdEntries removed msd-* dirs
// but left the category container and DESCRIPTION.md intact.
if (runtime === 'hermes') {
const nestedMsdDir = path.join(configDir, 'skills', 'msd');
if (fs.existsSync(nestedMsdDir)) {
// Remove MSD-owned DESCRIPTION.md (written by writeHermesCategoryDescription)
fs.rmSync(path.join(nestedMsdDir, 'DESCRIPTION.md'), { force: true });
// Remove the category dir if empty (no user content remaining)
const remaining = fs.readdirSync(nestedMsdDir, { withFileTypes: true });
if (remaining.length === 0) {
fs.rmSync(nestedMsdDir, { recursive: true, force: true });
}
}
}
// #2973 / Codex review (bd1f06c9): migrate dev-preferences.md to the
// runtime-aware SKILL.md location after all layout-driven removal is
// complete. Do NOT restore to commands/msd/ — the user is uninstalling.
@@ -2221,12 +2067,10 @@ export = {
migrateLegacyDevPreferencesToSkill,
applyOpencodeFamilyPathPrefix,
convertClaudeCommandToOpencodeSkill,
convertClaudeCommandToKiloSkill,
USER_OWNED_ARTIFACTS,
_runLegacyInstallMigrations,
_runLegacyUninstallCleanup,
_removeMsdEntries,
_snapshotDir,
_restoreDir,
_removeHermesBareStemDirs,
};

View File

@@ -4,7 +4,7 @@
* (#2875 Part 2 / J8).
*
* bin/install.js's inline agent-staging loop duplicated this EXACT precedence
* chain across two runtime branches (OpenCode ~24 lines, Kilo ~24 lines) —
* chain across the OpenCode runtime branch —
* `model_overrides[agent]` > `model_profile_overrides.<runtime>.<tier>` > omit.
* Extracted here — a `src/*.cts` module compiled into the shipped
* `msd-core/bin/lib/` tree, mirroring `install-effort-resolver.cts`'s existing
@@ -21,7 +21,7 @@
*
* #2875 defect fix: this module is on the `installRuntimeArtifacts` call tree
* (reached from `runtime-artifact-layout.cts`'s agents-kind `stage()` for the
* opencode/kilo converters) — every fs touch below routes through
* opencode converter) — every fs touch below routes through
* `installFs()` (install-fs-adapter.cts), matching `retired-artifact-cleanup.cts`
* / `user-artifact-staging.cts`'s existing precedent, instead of calling
* `node:fs` directly. A raw `fs` call here silently bypassed a fake adapter
@@ -291,7 +291,7 @@ function readMsdRuntimeProfileResolver(targetDir: string | null = null): Runtime
* pre-resolved `modelOverrides` map and `runtimeResolver` (both from the
* functions above). Pure — no filesystem access.
*
* Precedence (J8 — identical for kilo and opencode, resolved through this ONE
* Precedence (J8 — resolved through this ONE
* shared function so the two runtimes can never diverge):
* 1. modelOverrides[agentName] (#2256 — explicit per-agent override)
* 2. runtimeResolver.resolve(agentName)?.model

View File

@@ -52,7 +52,7 @@ const {
// #2995 (epic #1671 Phase 6.4): agent bodies join the fragment model. Markers are
// stripped at emit BEFORE any path rewrite or converter runs, so a `.claude/` ->
// `.windsurf/` regex can never reach inside a marker attribute and corrupt it —
// `.<runtime-dir>/` regex can never reach inside a marker attribute and corrupt it —
// the same ordering #2930 established for workflows.
// eslint-disable-next-line @typescript-eslint/no-require-imports
import workflowFragmentsModule = require('./workflow-fragments.cjs');
@@ -952,7 +952,7 @@ interface AgentCtx {
*
* ADR-1235 §1: when `agentCtx` is provided, the per-file order matches the inline
* agent loop in bin/install.js exactly:
* 1. applyAgentPathRewrites (4 base ~/.claude/ regexes; skipped for copilot/antigravity)
* 1. applyAgentPathRewrites (4 base ~/.claude/ regexes; skipped for antigravity)
* 2. processAttribution (Co-Authored-By policy)
* 3. appendAgentTools (#4032: validated agent_tools grants, before host conversion)
* 4. converter (runtime-specific frontmatter/body transform)
@@ -966,11 +966,11 @@ interface AgentCtx {
* @param srcAgentsDir source agents directory (e.g. agents/)
* @param resolvedProfile profile filter from resolveProfile()
* @param converter (content: string, isGlobal?: boolean, meta?: {agentName: string}) → string
* per-file converter; scope-aware converters (copilot/antigravity)
* per-file converter; scope-aware converters (antigravity)
* read isGlobal, single-arg converters ignore both extra args (#1173).
* `meta.agentName` (#2875 Part 2) is passed ONLY when `agentCtx` is
* present, letting a converter close over per-agent config
* (e.g. kilo/opencode model-override resolution in
* (e.g. opencode model-override resolution in
* runtime-artifact-layout.cts's convertedAgentsKind) without widening
* every OTHER converter's contract — converters that don't declare a
* 3rd parameter simply never read it.
@@ -996,11 +996,10 @@ function stageAgentsForRuntimeWithConverter(
// fail-closed wording every other agents-dir-unreadable path in this
// codebase uses (bin/install.js's `_resolveAvailableMsdRoles` /
// `_assertRoleResolvable`), instead of an unrelated raw fs error message
// escaping uncaught. Hermes's role-dispatch validation depends on the
// SAME shipped agents/ directory being readable; before this runtime also
// declared an `agents` kind, this function was never reached on a Hermes
// install, so an unreadable source here silently surfaced as a raw error
// rather than the deliberate fail-closed contract #2284 established.
// escaping uncaught. Role-dispatch validation depends on the SAME shipped
// agents/ directory being readable, so an unreadable source here must
// surface as the deliberate fail-closed contract #2284 established rather
// than a raw error.
entries = installFs().readdirSync(srcAgentsDir, { withFileTypes: true });
} catch (err) {
try { installFs().rmSync(stageDir, { recursive: true, force: true }); } catch { /* best-effort */ }
@@ -1038,7 +1037,7 @@ function stageAgentsForRuntimeWithConverter(
// #2875 Part 2 / row I3: derived exactly as the inline loop does —
// single-sourced via deriveAgentName (runtime-artifact-conversion.cts).
// ADR-1235 §1: pre-converter cross-cutting (matches inline loop order exactly)
// Step 1: path rewrites (4 base ~/.claude/ regexes; skipped for copilot/antigravity)
// Step 1: path rewrites (4 base ~/.claude/ regexes; skipped for antigravity)
content = _applyAgentPathRewrites(content, agentCtx.runtime, agentCtx.pathPrefix);
// Step 2: attribution
content = _processAttribution(content, agentCtx.attribution);

View File

@@ -252,7 +252,7 @@ function deriveStemsFromManifest(
const stems = new Set<string>();
for (const kindEntry of layout.kinds) {
if (kindEntry.kind !== 'commands' && kindEntry.kind !== 'skills') continue; // excludes agents/kimi-agents (D4)
if (kindEntry.kind !== 'commands' && kindEntry.kind !== 'skills') continue; // excludes agents (D4)
for (const stem of deriveStemsForKindEntry(kindEntry.kind, kindEntry.destSubpath, kindEntry.prefix, fileKeys)) {
stems.add(stem);
}

View File

@@ -38,9 +38,6 @@ export const BUNDLED_MSD_HOOK_FILES: ReadonlySet<string> = Object.freeze(new Set
'hooks/msd-cursor-stop.js',
'hooks/msd-cursor-subagent-start.js',
'hooks/msd-cursor-subagent-stop.js',
// Windsurf/Cascade blocking hooks — registered by writeWindsurfHooksJson (#2100).
'hooks/msd-windsurf-pre-write.js',
'hooks/msd-windsurf-pre-command.js',
'hooks/msd-ensure-canonical-path.js',
'hooks/msd-graphify-update.sh',
// #3662: portable node resolver staged into hooks/ (not itself a lifecycle

View File

@@ -254,7 +254,7 @@ const MANIFEST_SCHEMA_VERSION = 2;
/**
* Longest `runtime` string this reader will report. Real runtime ids are
* registry keys (`claude`, `antigravity`, `kimi-code` — 11 chars at the
* registry keys (`claude`, `antigravity` — 11 chars at the
* longest), so this loses nothing legitimate; it exists because the manifest
* is attacker-influenceable (a project-local one lives inside a repository a
* user may merely have cloned) and the value reaches a consumer that renders

View File

@@ -20,17 +20,8 @@ const RUNTIME_SURFACES: Record<string, string[]> = {
codex: ['msd-core', 'skills', 'agents', 'hooks', 'config.toml', 'hooks.json'],
gemini: ['msd-core', 'commands/msd', 'hooks'],
opencode: ['msd-core', 'command', 'skills', 'agents'],
kilo: ['msd-core', 'command', 'skills', 'agents'],
copilot: ['msd-core', 'skills', 'agents'],
antigravity: ['msd-core', 'skills', 'agents'],
cursor: ['msd-core', 'skills', 'agents', 'hooks', 'hooks.json'],
windsurf: ['msd-core', 'skills', 'agents', 'rules'],
augment: ['msd-core', 'skills', 'agents'],
trae: ['msd-core', 'skills', 'agents', 'rules'],
qwen: ['msd-core', 'skills', 'agents'],
hermes: ['msd-core', 'skills/msd', 'agents'],
cline: ['msd-core', 'skills', 'agents'],
codebuddy: ['msd-core', 'skills', 'agents'],
};
const COMMON_SURFACES = ['msd-core', 'skills', 'agents', 'hooks'];

View File

@@ -32,11 +32,8 @@
* populations (installs that never ran a baseline scan AND installs that
* already applied the original 000 scan) and drifts no checksum.
*
* Scope: OpenCode only. Kilo shares the same combined-family install path,
* but its command directory descriptor is still the singular `command/`
* (already covered by 000's RUNTIME_SURFACES.kilo), so this migration must
* never touch Kilo installs — enforced by the `runtimes: ['opencode']`
* scoping below.
* Scope: OpenCode only — enforced by the `runtimes: ['opencode']` scoping
* below.
*
* Classification mirrors 000-first-time-baseline.cts exactly (record-baseline
* for manifest-proven files, prompt-user for stale-MSD-looking unmanifested

View File

@@ -1,129 +0,0 @@
/**
* Installer migration: retire pi's stale `extensions/msd.cjs` after #2470
* renamed the installed native extension to `extensions/msd.js`.
*
* What old artifact is being retired?
* `extensions/msd.cjs` — the pre-#2470 dest filename for pi's native
* extension. pi auto-discovers extensions by scanning `<agentDir>/extensions/`
* and keeping only names accepted by its own predicate
* (`isExtensionFile()` in @earendil-works/pi-coding-agent:
* `name.endsWith(".ts") || name.endsWith(".js")`). A `.cjs` file is skipped
* SILENTLY — no `/msd` command, no error, no log line. The file is therefore
* permanently inert, not merely redundant.
*
* How do we prove it is MSD-owned?
* The installer records the native plugin in the install manifest as
* `<nativePlugin.dir>/<nativePlugin.file>` (bin/install.js, the
* `_hostBehaviors(runtime).nativePlugin` manifest block), so a pre-#2470 pi
* install carries `extensions/msd.cjs` as a manifest-managed entry. Only a
* manifest-managed classification produces an action here; an unmanifested
* `msd.cjs` is treated as a user's own file and preserved.
*
* What happens if the user modified it?
* `backup-and-remove` instead of `remove-managed`, so a patched extension is
* recoverable from the backup rather than silently destroyed.
*
* What happens if it is missing?
* No actions — fresh (post-#2470) installs and already-migrated installs both
* plan empty, so the migration is idempotent.
*
* What runtime and scope does it affect?
* pi only, global and local. No other runtime ever installed this path:
* OpenCode and Kilo — the only other runtimes declaring
* `hostBehaviors.nativePlugin` — both ship `plugins/msd-core.js`.
*
* Is the action safe in non-interactive install?
* Yes. Both emitted action types are non-interactive and journaled; neither
* requires a user choice, and unknown files never produce an action.
*
* Why not `move-managed`? The installer materializes the new `extensions/msd.js`
* from the package payload in the same run, so moving the stale file onto that
* path would just be overwritten. Retiring the old path is the accurate
* description of the change.
*
* See docs/installer-migrations.md#shipped-migrations and the pi row of
* docs/installer-migrations.md#runtime-configuration-contract-registry.
*/
type ArtifactClassification = string;
interface ClassifiedArtifact {
classification: ArtifactClassification;
[key: string]: unknown;
}
type ActionType = 'remove-managed' | 'backup-and-remove';
interface MigrationAction {
type: ActionType;
relPath: string;
reason: string;
ownershipEvidence: string;
}
interface MigrationPlanContext {
classifyArtifact(relPath: string): ClassifiedArtifact;
}
interface InstallerMigration {
id: string;
title: string;
description: string;
introducedIn: string;
runtimes: string[];
scopes: string[];
destructive: boolean;
plan: (ctx: MigrationPlanContext) => MigrationAction[];
}
/** Pre-#2470 dest filename for pi's native extension. */
const STALE_PI_EXTENSION = 'extensions/msd.cjs';
const OWNERSHIP_EVIDENCE =
'pre-#2470 pi installs record the native extension at extensions/msd.cjs in ' +
'msd-file-manifest.json (installer nativePlugin manifest entry)';
const REASON =
'pi cannot auto-discover a .cjs extension (isExtensionFile accepts only .ts/.js), ' +
'so this file is inert; superseded by extensions/msd.js (#2470)';
const migration: InstallerMigration = {
id: '2026-07-20-pi-extension-cjs-to-js',
title: 'Retire pi\'s undiscoverable extensions/msd.cjs',
description:
'Remove the stale extensions/msd.cjs left by pre-#2470 pi installs, superseded by ' +
'extensions/msd.js — the suffix pi\'s extension auto-discovery actually accepts.',
introducedIn: '1.7.1',
runtimes: ['pi'],
scopes: ['global', 'local'],
destructive: true,
plan: (ctx: MigrationPlanContext): MigrationAction[] => {
const artifact = ctx.classifyArtifact(STALE_PI_EXTENSION);
if (artifact.classification === 'managed-pristine') {
return [
{
type: 'remove-managed',
relPath: STALE_PI_EXTENSION,
reason: REASON,
ownershipEvidence: OWNERSHIP_EVIDENCE,
},
];
}
if (artifact.classification === 'managed-modified') {
return [
{
type: 'backup-and-remove',
relPath: STALE_PI_EXTENSION,
reason: REASON,
ownershipEvidence: OWNERSHIP_EVIDENCE,
},
];
}
// 'unknown' (never MSD-managed), 'missing', and 'managed-missing' all plan
// nothing: unknown files are preserved by policy, and an absent file needs
// no retirement.
return [];
},
};
export = migration;

View File

@@ -46,24 +46,14 @@
* What runtime and scope does it affect?
* Every runtime whose config root received the marker — i.e. every runtime
* not excluded from `installSharedHooksBundle(targetDir)` by
* `hostBehaviors.skipSharedHooksInstall` and not Codex: antigravity,
* augment, claude, claude-local, codebuddy, hermes, qwen, kilo, opencode,
* and pi. The `runtimes` field is OMITTED — the framework's "all runtimes" —
* `hostBehaviors.skipSharedHooksInstall` and not Codex (antigravity,
* claude, claude-local, opencode). The `runtimes` field is OMITTED — the
* framework's "all runtimes" —
* rather than carrying that hand-list: a runtime that never received the
* marker simply has no file to match, so enumerating them would add a second
* place for the set to drift out of date without changing behavior. Note it
* must be omitted and not `[]`; see the field's own comment below.
*
* ONE DELIBERATE CARVE-OUT — kimi. Kimi's marker was written to its native
* hook root (`~/.kimi`, `resolveKimiHooksTomlDir`), which is NOT under
* kimi's `configDir` (its generic Agent-Skills root). Migration relPaths are
* structurally confined to `configDir` (`validateSafeRelPath` /
* `ensureInsideConfig`), so this framework cannot address that path at all.
* Kimi's stale root marker is retired by the installer instead, at the same
* call site that writes its replacement — see the `kimi-hooks-toml` branch
* in `bin/install.js`. Named here so the gap is not mistaken for an
* oversight.
*
* Is the action safe in non-interactive install?
* Yes. `remove-managed` is non-interactive and journaled, the executor takes
* a rollback snapshot before unlinking, and no branch of this migration can

View File

@@ -1,241 +0,0 @@
/**
* Installer migration: retire pi's legacy `<piConfigDir>/hooks/` directory
* after MSD's shared hook bundle moved to `<piConfigDir>/msd-hooks/` (#3023).
*
* What old artifact is being retired?
* `hooks/` (and its `hooks/lib/` subdirectory) at the pi config root. pi
* reserves that exact name as its own deprecated extension directory and
* warns on every startup whenever it exists — pi's
* `checkDeprecatedExtensionDirs()` fires on mere PATH EXISTENCE, not on the
* directory having contents (unlike the sibling `tools/` check, which does
* `readdir` first). MSD used to install its shared hook bundle at exactly
* that reserved path, so every pi install carried the warning permanently.
* The fix moved the install target to `msd-hooks/`
* (`hostBehaviors.sharedHooksDirName`), but an EXISTING install that
* upgrades still has the old `hooks/` tree sitting on disk — nothing
* removes it on its own, so the warning would persist forever without this
* migration.
*
* How do we prove it is MSD-owned?
* Per file, by manifest membership — the same `classifyArtifact()` check
* every other migration in this directory uses. Pre-#3023 pi installs
* record the shared hook bundle under `hooks/…` keys in
* `msd-file-manifest.json`; the new install target writes `msd-hooks/…`
* keys instead, which this migration structurally never sees because it
* only ever walks the `hooks/` subtree.
*
* What happens if the user modified it?
* `backup-and-remove` instead of `remove-managed`, so a locally patched
* hook script is recoverable from the backup rather than silently
* destroyed — mirrors migration 006.
*
* What happens to files the manifest never recorded?
* Nothing. An unmanifested file under `hooks/` (classification `unknown`)
* is left exactly where it is, and — because its presence keeps the
* directory non-empty — it also keeps the directory itself from being
* retired. That is a deliberate consequence of directory removal being
* gated on emptiness, not a special case.
*
* What happens to the directory itself?
* `hooks/lib/` and then `hooks/` each get a `remove-empty-dir` action (see
* `evaluateRemoveEmptyDir` in `../installer-migrations.cts`). That action
* only ever calls `fs.rmdirSync` — never a recursive removal — and
* re-checks emptiness immediately before doing so, so a directory that
* still holds anything (an unmanifested file, or a file-level action that
* failed to apply) is left in place rather than assumed empty. Actions are
* emitted deepest-first (`hooks/lib` before `hooks`) so the parent has a
* chance to become empty in the same pass.
*
* What happens if it is missing?
* No actions. A fresh post-#3023 pi install never creates `hooks/` at all,
* and an already-migrated install has nothing left to retire — both plan
* empty, so the migration is idempotent.
*
* What runtime and scope does it affect?
* pi only, global and local. No other runtime's install is affected:
* `hostBehaviors.sharedHooksDirName` defaults to `'hooks'` for every other
* runtime, and none of them reserve that name the way pi does, so a
* claude/kimi/opencode/etc. `hooks/` directory is a live, in-use install
* surface that must never be touched here. The runtime check is the FIRST
* thing `plan()` does, ahead of even checking whether the directory exists,
* as defense in depth beyond the `runtimes: ['pi']` record-level filter the
* framework itself already enforces.
*
* Is the action safe in non-interactive install?
* Yes. Every emitted action type (`remove-managed`, `backup-and-remove`,
* `remove-empty-dir`) is non-interactive and journaled; none requires a
* user choice, and unknown files never produce an action.
*
* See docs/installer-migrations.md#shipped-migrations, the pi row of
* docs/installer-migrations.md#runtime-configuration-contract-registry, and
* the 2026-08-07 amendment to docs/adr/0008-installer-migration-module.md.
*/
import fs from 'node:fs';
import path from 'node:path';
interface ClassifiedArtifact {
classification: string;
[key: string]: unknown;
}
type ActionType = 'remove-managed' | 'backup-and-remove' | 'remove-empty-dir';
interface MigrationAction {
type: ActionType;
relPath: string;
reason: string;
ownershipEvidence: string;
classification?: string;
originalHash?: string | null;
currentHash?: string | null;
}
interface MigrationPlanContext {
configDir: string;
runtime: string | null;
classifyArtifact(relPath: string): ClassifiedArtifact;
}
interface InstallerMigration {
id: string;
title: string;
description: string;
introducedIn: string;
runtimes: string[];
scopes: string[];
destructive: boolean;
plan: (ctx: MigrationPlanContext) => MigrationAction[];
}
/** pi's reserved (and, pre-#3023, MSD-populated) legacy hook directory name. */
const HOOKS_DIR = 'hooks';
const FILE_REASON =
"pi's startup check warns whenever hooks/ exists (checkDeprecatedExtensionDirs), and MSD's shared " +
'hook bundle now installs at msd-hooks/ instead (#3023), so the legacy files are superseded';
const FILE_OWNERSHIP_EVIDENCE =
'pre-#3023 pi installs record the shared hook bundle under hooks/… keys in msd-file-manifest.json; ' +
'the new install target is msd-hooks/…, which this migration never touches because it only walks the ' +
'hooks/ subtree';
const DIR_REASON =
"pi's checkDeprecatedExtensionDirs() warns on hooks/'s mere existence, not its contents (#3023); the " +
'reserved container is retired once every MSD-owned entry inside it is gone';
const DIR_OWNERSHIP_EVIDENCE =
'hooks/ and hooks/lib/ are MSD-installed container directories under the pi config root (the pre-#3023 ' +
'default of hostBehaviors.sharedHooksDirName); removal is gated on emptiness by the shared ' +
'remove-empty-dir action, so a directory that still holds an unmanifested user file — or any file-level ' +
'action that failed to apply — is left in place rather than assumed empty';
/**
* Recursively collect files and directories under `relDir`, never following a
* symlink (whether it names a file or a directory) and never emitting a path
* that resolves outside `baseResolved`. Mirrors the traversal guard in
* migration 003 (`walkLegacyFiles`).
*/
function walkPiHooksTree(root: string, relDir: string, baseResolved: string, files: string[], dirs: string[]): void {
const dir = path.join(root, relDir);
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
// Never follow a symlink into or through: it must not be traversed,
// hashed, or removed, regardless of what it points at.
if (entry.isSymbolicLink()) continue;
const relPath = path.posix.join(relDir, entry.name);
const resolved = path.resolve(root, relPath);
if (resolved !== baseResolved && !resolved.startsWith(baseResolved + path.sep)) continue;
if (entry.isDirectory()) {
dirs.push(relPath);
walkPiHooksTree(root, relPath, baseResolved, files, dirs);
} else if (entry.isFile()) {
files.push(relPath);
}
}
}
const migration: InstallerMigration = {
id: '2026-08-07-pi-retire-reserved-hooks-dir',
title: "Retire pi's reserved hooks/ directory",
description:
'Remove manifest-managed files under <piConfigDir>/hooks/ and, once empty, the directory itself ' +
'(and its hooks/lib/ subdirectory), now that the shared hook bundle installs at msd-hooks/ instead. pi ' +
'reserves hooks/ as its own deprecated extension directory and warns on every startup while it exists (#3023).',
introducedIn: '1.9.2',
runtimes: ['pi'],
scopes: ['global', 'local'],
destructive: true,
plan: (ctx: MigrationPlanContext): MigrationAction[] => {
// Defense in depth ahead of the framework's own runtimes filter: a
// claude/kimi/opencode/etc. hooks/ directory is a live install surface,
// never a retirement target.
if (ctx.runtime !== 'pi') return [];
const hooksRoot = path.join(ctx.configDir, HOOKS_DIR);
let rootLstat: fs.Stats;
try {
rootLstat = fs.lstatSync(hooksRoot);
} catch {
return []; // absent -> nothing to retire, idempotent
}
// Never follow a symlinked hooks/ root: walking through it could plan
// actions against paths outside the pi config directory entirely.
if (rootLstat.isSymbolicLink()) return [];
if (!rootLstat.isDirectory()) return [];
const baseResolved = path.resolve(ctx.configDir);
const files: string[] = [];
const dirs: string[] = [];
try {
walkPiHooksTree(ctx.configDir, HOOKS_DIR, baseResolved, files, dirs);
} catch {
// Unreadable directory: nothing safe to plan.
return [];
}
const actions: MigrationAction[] = [];
for (const relPath of files) {
const { classification } = ctx.classifyArtifact(relPath);
if (classification === 'managed-pristine') {
actions.push({ type: 'remove-managed', relPath, reason: FILE_REASON, ownershipEvidence: FILE_OWNERSHIP_EVIDENCE });
} else if (classification === 'managed-modified') {
actions.push({ type: 'backup-and-remove', relPath, reason: FILE_REASON, ownershipEvidence: FILE_OWNERSHIP_EVIDENCE });
}
// 'unknown' (user-added, not manifest-recorded): no action, preserved.
// 'missing' / 'managed-missing': impossible here — relPath was just
// discovered by walking the live filesystem, so it currently exists.
}
// Deepest directories first, so a child has already been evaluated (and
// possibly removed) before its parent's own emptiness is re-checked by
// the executor. `hooks/` itself is appended last, unconditionally: the
// executor's own emptiness re-check is what actually decides whether it
// goes, not this ordering — this ordering only gives it the chance to.
const orderedDirs = [...dirs].sort((a, b) => b.split('/').length - a.split('/').length);
orderedDirs.push(HOOKS_DIR);
for (const relPath of orderedDirs) {
actions.push({
type: 'remove-empty-dir',
relPath,
reason: DIR_REASON,
ownershipEvidence: DIR_OWNERSHIP_EVIDENCE,
// Declared, not derived: classifyArtifact() hashes file contents via
// sha256File(), which throws EISDIR against a directory path. These
// relPaths name directories, so classification is stated directly
// (never 'unknown', so the planner's unknown-classification block
// never fires for them) rather than routed through the file
// classifier.
classification: 'managed-pristine',
originalHash: null,
currentHash: null,
});
}
return actions;
},
};
export = migration;

View File

@@ -2,8 +2,8 @@
* Companion MCP server (ADR-1239 Phase C-2, #1681 slice 3a).
*
* A minimal stdio JSON-RPC 2.0 server exposing two of the six interface points
* so any MCP-consuming host (Claude/Codex/OpenCode/VS Code/Gemini/Cursor/Cline/
* Hermes) can drive MSD with NO bespoke plugin:
* so any MCP-consuming host (Claude/Codex/OpenCode/VS Code/Cursor)
* can drive MSD with NO bespoke plugin:
*
* - point 1 (command): tool `msd_invoke_command` → `dispatchMsdCommand`
* (src/shell-command-projection.cts), a bounded subprocess-shim to
@@ -13,8 +13,8 @@
* fully-populated hub factory exists anywhere in msd-core (every
* createHub() caller builds a single-family hub for its own narrow
* purpose), so the fix routes through the SAME shared dispatch helper
* the pi extension uses (pi/msd.cjs), mirroring the SUBPROCESS-REUSE
* precedent already established for the OpenCode/Kilo hook bridge.
* every host adapter uses, mirroring the SUBPROCESS-REUSE
* precedent already established for the OpenCode hook bridge.
* - point 5 (state IO): tools `msd_read_state` / `msd_write_state` → the
* Phase 3 `stateIO` seam (src/state-io.cts, filesystem default).
*

View File

@@ -9,8 +9,8 @@
* tier routing from src/model-resolver.cts: `resolveModel` delegates
* straight to `resolveModelForTier`, so passive reproduces current behavior
* byte-for-behavior.
* - `active` — the host exposes a provider `sendRequest` (VS Code `vscode.lm`,
* pi providers). MSD calls the model through the host. Ships here as a SEAM:
* - `active` — the host exposes a provider `sendRequest` (VS Code `vscode.lm`).
* MSD calls the model through the host. Ships here as a SEAM:
* a host-supplied `sendRequest` slot, fail-closed until a real consumer
* binds it (Phase 5 / #1682).
*

View File

@@ -208,7 +208,7 @@ export const KNOWN_PROVIDERS: Set<string> = new Set(
// Codex agent `.toml` `model`. Two forms: (a) a bare Claude Agent-tool tier alias
// (opus/sonnet/haiku/fable — CLAUDE_AGENT_ALIASES below); (b) any Claude model id in
// any provider namespacing — `claude-*`, `anthropic/claude-*`, `us.anthropic.claude-*`
// (the forms the catalog assigns to opencode/hermes/kilo, reachable on a Codex .toml
// (the forms the catalog assigns to opencode, reachable on a Codex .toml
// via the runtime-resolver path). No OpenAI/Codex model id contains "claude", so a
// case-insensitive substring test is a safe, exhaustive guard for (b). Codex/ChatGPT
// rejects all of these.

View File

@@ -755,7 +755,7 @@ function cmdWriteProfile(cwd: string, options: CmdWriteProfileOptions, raw: bool
'claude'
) || 'claude';
}
// path.join (not a string literal) keeps the cline-install leaked-path lint quiet.
// path.join (not a string literal) keeps the leaked-path lint quiet.
outputPath = path.join(getGlobalConfigDir(effectiveRuntime), 'msd-core', 'USER-PROFILE.md');
} else if (!path.isAbsolute(outputPath)) {
outputPath = path.join(cwd, outputPath);
@@ -916,7 +916,7 @@ function cmdGenerateDevPreferences(cwd: string, options: CmdGenerateDevPreferenc
// runtime-generated user artifact). Default now points at the skills/
// location so /msd:profile-user --refresh stops re-creating the legacy
// directory. The path is constructed via path.join (not a literal
// string) so the cline-install leaked-path lint does not flag it.
// string) so the leaked-path lint does not flag it.
let outputPath = options.output;
if (!outputPath) {
let effectiveRuntime = 'claude';
@@ -1033,8 +1033,8 @@ function cmdGenerateClaudeProfile(cwd: string, options: CmdGenerateClaudeProfile
// cmdGenerateClaudeMd above; that fix diverged when it didn't propagate
// here, leaving /msd-profile-user writing Claude files on Codex installs.
// - Project scope: getProjectInstructionFile(runtime) is the single source
// of truth (AGENTS.md for codex/opencode/kilo/kimi/unknown; GEMINI.md for
// antigravity; .github/copilot-instructions.md for copilot).
// of truth (AGENTS.md for codex/opencode/unknown; GEMINI.md for
// antigravity).
// - Global scope: ~/.<config-home>/<instruction-basename>, derived from
// getGlobalConfigDir + basename(getProjectInstructionFile), so codex lands
// at ~/.codex/AGENTS.md. Claude global is preserved byte-for-byte (no
@@ -1162,9 +1162,8 @@ function cmdGenerateClaudeMd(cwd: string, options: CmdGenerateClaudeMdOptions, r
// (single source of truth in runtime-name-policy.cjs, shared with the
// new-project.md bash workflow via `msd-tools query
// project-instruction-file`). Previously this was a codex-only override
// (#3163) that left AGENTS-native runtimes (opencode/kilo/kimi) emitting
// CLAUDE.md; copilot now resolves to .github/copilot-instructions.md, and
// antigravity to GEMINI.md. MSD_RUNTIME env var takes precedence
// (#3163) that left AGENTS-native runtimes (opencode) emitting
// CLAUDE.md; antigravity now resolves to GEMINI.md. MSD_RUNTIME env var takes precedence
// over config.runtime, mirroring detectRuntime().
//
// Non-claude runtimes always win over a stale `claude_md_path` (the #3163

View File

@@ -105,7 +105,7 @@ export type LaneProbe =
* `args` is an argv TEMPLATE, not a prefix. The injected pieces — model, effort, output file,
* argv-borne prompt — do not all go in the same place, and no positional rule expresses that:
* `codex` injects the model in the MIDDLE (after the `exec --ephemeral` subcommand) and the output
* file later still, while `kimi-code` injects the model first and five lanes end with a bare `-` that
* file later still, while other lanes inject the model first and several end with a bare `-` that
* must stay last. Splicing by position silently produced
* `codex --model M -o F exec --ephemeral …`, which is not a valid codex invocation.
*
@@ -195,8 +195,8 @@ interface ReviewerLaneCommon {
* config value that is not a positive finite number, falls back to `timeoutFloorMs` unchanged.
* For a lane whose `args` template ALSO carries a native tool-side timeout (antigravity's
* `--print-timeout`, via the `{{nativeTimeout}}` ARGV_PLACEHOLDER), the resolved value feeds both
* levels — see `resolveLanePlan`'s expansion of that token. Two lanes — `qwen` and `coderabbit` —
* accept neither a model flag nor a host and own no `review.timeouts.<slug>` key either, matching
* levels — see `resolveLanePlan`'s expansion of that token. The `coderabbit` lane
* accepts neither a model flag nor a host and owns no `review.timeouts.<slug>` key either, matching
* the same narrow key-ownership invariant `modelConfigKey` already follows for them (#3691 narrows
* #2797). `cursor` gained a model flag (`review.models.cursor`, #3653) but still owns no
* `review.timeouts.cursor` key of its own.
@@ -278,14 +278,12 @@ const SPAWN_STDIN_STDOUT = {
} as const;
/**
* The eleven declared lanes, in `write_reviews` order.
* The declared lanes, in `write_reviews` order.
*
* The `gemini` lane was retired by #4709: Google sunset Gemini CLI on 2026-06-18 (the same
* sunset that removed the gemini RUNTIME in #1928/1.8.0), so the lane spawned a binary that no
* longer serves the free/Pro/Ultra tiers that are MSD's audience.
*
* `kimi-code` joined in Phase 5b (#2799, closes #2718) — ADR-2782's phase table lands it here
* rather than in 5a precisely so it arrives together with the iteration that can invoke it.
* longer serves the free/Pro/Ultra tiers that are MSD's audience. The `qwen` and `kimi-code`
* lanes were retired together with their runtimes.
*/
export const REVIEWER_LANES: ReadonlyArray<ReviewerLane> = Object.freeze([
{
@@ -414,30 +412,6 @@ export const REVIEWER_LANES: ReadonlyArray<ReviewerLane> = Object.freeze([
// stdout copy would write the raw JSON envelope as the review (#1936). See LaneHandler.
handler: 'opencode',
},
{
slug: 'qwen',
flags: ['--qwen'],
transport: 'spawn',
probe: { kind: 'command-exists', binary: 'qwen' },
invoke: {
binary: 'qwen',
args: ['-'],
...SPAWN_STDIN_STDOUT,
modelArg: null,
effortChannel: 'none',
},
timeoutFloorMs: 900_000,
timeoutConfigKey: null,
emptyOutput: 'stub-with-stderr',
reviewsSection: 'Qwen',
evidenceClass: 'source-grounded',
requiresBinaries: [],
promptBudgetKey: 'review.max_prompt_tokens_per_reviewer.qwen',
modelConfigKey: null,
effortConfigKey: null,
defaultEffort: null,
handler: null,
},
{
// `cursor-agent` is a SEPARATE binary from the `cursor` IDE launcher. Print
// mode takes the prompt as an ARGUMENT, so a full plan set is passed by file
@@ -596,54 +570,6 @@ export const REVIEWER_LANES: ReadonlyArray<ReviewerLane> = Object.freeze([
defaultEffort: null,
handler: 'openai-compatible',
},
{
// Phase 5b (#2799) — closes #2718. Net-new in this phase BY DESIGN (ADR-2782's phase table):
// declaring it in 5a would have made it selectable but not invocable, producing an empty
// section for the whole 5a → 5b window.
//
// The probe is `command-capability`, not `command-exists`, and that is the entire reason D7's
// vocabulary ships wider than existence: `kimi` is claimed by BOTH the Kimi Code CLI (Node) and
// the legacy Python kimi-cli, which is a separate first-party runtime capability in this repo.
// An existence-only probe registers the wrong tool. The needle `--output-format` appears in
// Kimi Code's `--help` and is absent from the legacy CLI (whose headless flags are `--print` /
// `--work-dir`). Verified in both directions against Kimi Code CLI 0.29.2 and a stub legacy
// binary in closed PR #2776 — analysis carried forward with credit to @drungrin.
//
// The original probe there was an UNBOUNDED `kimi --help | grep` that ran on EVERY /msd:review
// regardless of flags: a live instance of this repo's named Unbounded Subprocesses defect, and
// the review blocker. Here the bound is declared (`timeoutMs`) and the runner enforces it, and
// the probe runs only for a SELECTED lane.
slug: 'kimi-code',
flags: ['--kimi-code'],
transport: 'spawn',
probe: {
kind: 'command-capability',
binary: 'kimi',
needle: '--output-format',
timeoutMs: 5_000,
},
invoke: {
// Print mode takes the prompt as an ARGUMENT, so the full plan set goes by file reference to
// stay clear of the 32,767-char Windows execFileSync ceiling — same shape as cursor.
binary: 'kimi',
args: ['{{model}}', '-p', '{{prompt}}'],
promptChannel: 'argv-file-ref',
outputChannel: 'stdout',
modelArg: '-m',
effortChannel: 'none',
},
timeoutFloorMs: 900_000,
timeoutConfigKey: 'review.timeouts.kimi-code',
emptyOutput: 'stub-with-stderr',
reviewsSection: 'Kimi Code',
evidenceClass: 'source-grounded',
requiresBinaries: [],
promptBudgetKey: 'review.max_prompt_tokens_per_reviewer.kimi-code',
modelConfigKey: 'review.models.kimi-code',
effortConfigKey: null,
defaultEffort: null,
handler: null,
},
].map((lane) => Object.freeze(lane)) as ReviewerLane[]);
/**
@@ -1174,7 +1100,7 @@ function findSignatureLine(lines: string[]): number {
* Three arms, deliberately independent so that gaming one does not green the build:
*
* 1. **flag** — every declared flag appears, delimited, somewhere in the doc. This is the arm
* that catches #2781 (`--kimi-code` present in `docs/COMMANDS.md` and absent from all four
* that catches #2781 (a lane flag present in `docs/COMMANDS.md` and absent from all four
* mirrors).
* 2. **signature** — where a `Command:` signature line exists it is held to the FULL roster, and
* any bracketed non-lane token on it is reported. This restores per-site strictness the

View File

@@ -10,7 +10,7 @@
* replaces is the simple system that worked, and every leg encodes a hard-won fix — #2494 and #2605
* (empty output), #1698 (Codex stdout teardown noise), #1936 (OpenCode zero-output turns), #2073
* (Antigravity's three modes), #2176 (repo-root anchoring), #2589 (no jq on stock Windows), #2794
* (Qwen's missing sidecar). A resolver designed from the descriptor TYPES would throw that away and
* (a missing sidecar). A resolver designed from the descriptor TYPES would throw that away and
* rebuild the bugs. So each lane's plan was derived from its leg, and `tests/review-lane-invocation`
* asserts all twelve against a frozen table. Old and new cannot literally run in parallel, so that
* table is the strangler-fig substitute — it is what makes this cutover safe rather than hopeful.
@@ -361,7 +361,7 @@ export function nativeTimeoutToken(timeoutMs: number): string {
*
* WHITESPACE-ONLY COUNTS AS EMPTY, for every lane. The bash tested `[ ! -s file ]`, which counts
* BYTES — so a reply of three spaces passed as a successful review. Two legs (LM Studio,
* llama.cpp) closed this locally with a case-glob; gemini, claude, codex, qwen and cursor did not.
* llama.cpp) closed this locally with a case-glob; gemini, claude, codex and cursor did not.
* Making it uniform is a deliberate, disclosed behavior change (a bug fix that breaks a workaround)
* and is why this phase ships a changeset note.
*

View File

@@ -346,8 +346,8 @@ export async function probeLane(
};
case 'command-capability': {
// Existence alone is structurally insufficient here: `kimi` is claimed by BOTH the Kimi Code
// CLI and the legacy Python kimi-cli, and an existence-only probe registers the wrong tool.
// Existence alone is structurally insufficient here: a binary name may be claimed by more
// than one tool, and an existence-only probe registers the wrong one.
if (!deps.hasBinary(probe.binary)) {
return {
available: false,

View File

@@ -17,8 +17,8 @@
*
* KNOWN_REVIEWER_SLUGS (ADR-2782 D9, Phase 5a #2798): derived from declared
* `reviewer` bodies in the capability registry, not a hand-maintained tail.
* A capability of EITHER `role: "runtime"` (the six dual-purpose hosts —
* antigravity/claude/codex/cursor/opencode/qwen) or the lane-only
* A capability of EITHER `role: "runtime"` (the dual-purpose hosts —
* antigravity/claude/codex/cursor/opencode) or the lane-only
* `role: "reviewer"` (coderabbit/ollama/lm_studio/llama_cpp) may carry
* a `reviewer` body, and it is the body's `reviewer.slug` — NOT the capability
* id — that becomes the roster entry: the two differ for `lm-studio` (id) /
@@ -306,7 +306,7 @@ export function resolveReviewerSelection(
// ADR-2782 D4: absent-safe governs DISCOVERY, never explicit selection.
// Not finding a lane nobody asked for is normal; failing to run a lane
// somebody asked for is an error. Every miss used to be an `info`, so a
// PARTIAL miss (`--codex --qwen` with qwen absent) ran the review with a
// PARTIAL miss (`--codex --cursor` with cursor absent) ran the review with a
// thinner reviewer set while present_results reported success — "a cross-AI
// review that silently drops a lane is blind in one eye" (review.md).
// A total miss already errored, but only as a side effect of the selected

File diff suppressed because it is too large Load Diff

View File

@@ -20,7 +20,7 @@ const { tryWithinRootLexical } = _require('./security.cjs') as typeof import('./
// projection is centralized rather than eliminated).
import { isGlobalScope, type InstallScope } from './install-scope.cjs';
type ArtifactKindName = 'commands' | 'agents' | 'skills' | 'kimi-agents';
type ArtifactKindName = 'commands' | 'agents' | 'skills';
interface ResolvedProfile {
name?: string;
@@ -248,7 +248,7 @@ function createRuntimeArtifactInstallPlan(args: CreateRuntimeArtifactInstallPlan
if (kind.kind === 'commands') {
const rewrittenDir = rewriteStagedCommandBodies(stagedDir, rewriteOpts);
sourceDir = addCleanupDir(cleanupDirs, stagedDir, rewrittenDir);
} else if (kind.kind === 'skills' || kind.kind === 'kimi-agents') {
} else if (kind.kind === 'skills') {
const rewrittenDir = rewriteStagedSkillBodies(stagedDir, rewriteOpts);
sourceDir = addCleanupDir(cleanupDirs, stagedDir, rewrittenDir);
}

View File

@@ -24,7 +24,7 @@ import os from 'node:os';
// eslint-disable-next-line @typescript-eslint/no-require-imports
import installFsAdapter = require('./install-fs-adapter.cjs');
import { tryWithinRootLexical } from './security.cjs';
const { installFs, mkInstallTempDir } = installFsAdapter;
const { installFs } = installFsAdapter;
// Reuse the install manifest's existing parser and streamed SHA-256
// classification instead of deriving a second integrity implementation here.
// eslint-disable-next-line @typescript-eslint/no-require-imports
@@ -43,7 +43,7 @@ const conversionExports = runtimeArtifactConversion as Record<string, unknown> &
readMsdCommandNames?: () => string[];
};
// #2875 Part 2 (J8): shared model-override precedence resolver — see its
// module doc for why kilo/opencode MUST resolve through this ONE function
// module doc for why opencode MUST resolve through this ONE function
// rather than re-deriving the chain per runtime.
// eslint-disable-next-line @typescript-eslint/no-require-imports
import installModelOverrideResolver = require('./install-model-override-resolver.cjs');
@@ -71,7 +71,6 @@ const _require: NodeRequire = require;
// ---------------------------------------------------------------------------
type ArtifactKindName = 'commands' | 'agents' | 'skills';
type KimiArtifactKindName = ArtifactKindName | 'kimi-agents';
// Mirrors the (unexported) ResolvedProfile in install-profiles.cts.
// Must stay in sync if that shape changes.
@@ -105,7 +104,7 @@ interface AgentCtx {
pathPrefix: string;
attribution: string | null | undefined;
/** #2875 Part 2 (row I1-I3): install root, threaded through to the
* frontmatter-extensions step and (for kilo/opencode's converters) the
* frontmatter-extensions step and (for opencode's converter) the
* per-agent model-override resolution below. Mirrors install-profiles.cts's
* identically-named AgentCtx field — see its doc comment. */
targetDir?: string | null;
@@ -114,7 +113,7 @@ interface AgentCtx {
}
interface ArtifactKind {
kind: KimiArtifactKindName;
kind: ArtifactKindName;
destSubpath: string;
prefix: string;
/** For agent kinds, accepts optional pre-converter cross-cutting context
@@ -152,7 +151,7 @@ function requiredRuntimeSurfaceSourceClasses(kinds: Iterable<{ kind?: string }>)
const required = new Set<RuntimeSurfaceSourceClass>();
for (const kind of kinds) {
if (kind.kind === 'commands' || kind.kind === 'skills') required.add('commands');
if (kind.kind === 'agents' || kind.kind === 'kimi-agents') required.add('agents');
if (kind.kind === 'agents') required.add('agents');
}
return required;
}
@@ -524,7 +523,7 @@ function _resolveNamedConverter(converterName: string, kindLabel: string): (...a
/**
* Build a converted-agents kind descriptor for runtimes whose agent `.md` files
* need runtime-specific frontmatter/body conversion (e.g. Copilot, Cursor, Codex).
* need runtime-specific frontmatter/body conversion (e.g. Cursor, Codex).
*
* Unlike `agentsKind` (which raw-copies source files), this kind applies
* `converterName` from Runtime Artifact Conversion exports to each agent file
@@ -538,8 +537,8 @@ function _resolveNamedConverter(converterName: string, kindLabel: string): (...a
*
* Of the four blockers this comment used to name for wiring `bin/install.js`'s
* inline agent loop against this resolver, THREE were already stale by the
* time #2875 measured them and are not re-litigated here: Copilot's
* `.agent.md` rename (the loop's own `destName = entry.name` comment records
* time #2875 measured them and are not re-litigated here: the agent-file
* extension rename (the loop's own `destName = entry.name` comment records
* the ternary dropped in #2099; the descriptor fold applies it via
* `hostBehaviors.agentFileExtension`), the cross-cutting path-prefix rewrite +
* attribution (`stageAgentsForRuntimeWithConverter` already applies
@@ -557,10 +556,8 @@ function _resolveNamedConverter(converterName: string, kindLabel: string): (...a
* `disallowedTools` injection) and (b) let THIS function resolve a per-agent
* model override (`installModelOverrideResolver.resolveAgentModelOverride`,
* `model_overrides[agent]` > `model_profile_overrides.<rt>.<tier>` > omit)
* before invoking a converter that needs it (kilo/opencode). Both pieces —
* plus a data-driven Hermes branding converter
* (`convertClaudeAgentToHermesAgent`, reading `hostBehaviors.brandingRewrites`
* rather than a hardcoded string table) — are single-sourced: `bin/install.js`
* before invoking a converter that needs it (opencode). Both pieces are
* single-sourced: `bin/install.js`
* requires the SAME functions this module does, so its inline loop and the
* descriptor path can no longer independently drift (the CLAUDE.md
* "Generative Fix Divergence" class the prior duplication risked).
@@ -568,15 +565,14 @@ function _resolveNamedConverter(converterName: string, kindLabel: string): (...a
* `tests/agent-descriptor-parity.test.cjs` proves byte-identical output
* between the inline loop and a SYNTHETIC descriptor registry (the same
* override seam `resolveRuntimeArtifactLayoutFromRegistry` exposes) for all
* six runtimes the inline loop still served: claude, cline, codex, hermes,
* kilo, opencode.
* runtimes the inline loop still served (claude, codex, opencode).
*
* Both findings the prior revision of this comment named as STILL deferred
* are now CLOSED (#2875 Part 2 Task A/B/C), measured against the real
* `capability.json` entries and the real production entry points, not
* argued from this module alone:
*
* 1. **kilo/opencode reaching `layout.kinds`.** `installEngine.
* 1. **opencode reaching `layout.kinds`.** `installEngine.
* installAgentsKindStandalone` (install-engine.cts) is called from inside
* `installOpencodeFamilyArtifacts` and resolves the agents kind through
* THIS SAME `resolveRuntimeArtifactLayout`/`convertedAgentsKind` path —
@@ -589,18 +585,15 @@ function _resolveNamedConverter(converterName: string, kindLabel: string): (...a
* install-tree golden fixture (`tests/fixtures/install-tree/claude-local.json`)
* is what caught the gap when it was first missed.
* 2. **`/msd:surface` / `applySurface` activation.** Confirmed convergent,
* not merely non-broken: for all six runtimes (claude, cline, codex,
* hermes, kilo, opencode), staging via `applySurface` into a freshly
* not merely non-broken: for every runtime (claude, codex, opencode),
* staging via `applySurface` into a freshly
* wiped `agents/` directory produces byte-identical output (including
* filenames) to `installRuntimeArtifacts`'s own write — verified directly
* against the built registry, not inferred.
*
* The inline loop (`_DESCRIPTOR_AGENTS_RUNTIMES` and the `bin/install.js`
* agent-staging block it gated) is DELETED — every runtime the registry
* declares an `agents` kind for is descriptor-driven now, including a
* seventh runtime (`kimi-code`) this comment's own prior measurement missed
* (it fell through the inline loop's generic `else if` branch, same as
* claude, with no dedicated dialect arm — caught by the same golden fixture).
* declares an `agents` kind for is descriptor-driven now.
*
* Codex's `config.toml [agents.msd-*]` strip (`bin/install.js`, under
* `isMinimalMode` + `hostBehaviors.tomlConfigInstall`) remains the one
@@ -636,16 +629,16 @@ function convertedAgentsKind(
const rawConverter = _resolveNamedConverter(converterName, 'agents') as
(content: string, arg2?: boolean | { isAgent?: boolean; modelOverride?: string | null; variant?: string | null }) => string;
// #2875 Part 2 (J5-J8): kilo/opencode agent converters take an options
// #2875 Part 2 (J5-J8): the opencode agent converter takes an options
// bag (`{isAgent, modelOverride}`), not the `isGlobal` boolean every
// other agent converter's 2nd positional arg means — mirrors the
// inline loop's per-runtime `frontmatterDialect === 'opencode' | 'kilo'`
// branches (bin/install.js), which resolve model_overrides[agent] >
// inline loop's per-runtime `frontmatterDialect === 'opencode'`
// branch (bin/install.js), which resolves model_overrides[agent] >
// model_profile_overrides.<runtime>.<tier> > omit BEFORE calling the
// converter. Resolved ONCE per stage() call (not per file — a pure
// function of configDir/targetDir) via the single shared precedence
// resolver so kilo and opencode can never diverge (J8).
const needsModelOverride = converterName === 'convertClaudeToOpencodeFrontmatter' || converterName === 'convertClaudeToKiloFrontmatter';
// resolver (J8).
const needsModelOverride = converterName === 'convertClaudeToOpencodeFrontmatter';
let converter: (content: string, isGlobal?: boolean, meta?: { agentName: string }) => string;
if (needsModelOverride) {
const overrideTargetDir = agentCtx?.targetDir ?? configDir;
@@ -665,10 +658,7 @@ function convertedAgentsKind(
// the risk this gate avoids. #1156's rule for `model: inherit` is the
// precedent: do not emit a key the runtime may not understand.
//
// OpenCode only; the kilo converter ignores the field (no EFFORT_ARGV.kilo).
const effortConfig = converterName === 'convertClaudeToOpencodeFrontmatter'
? installEffortResolver.readMsdEffectiveEffortConfig(overrideTargetDir)
: null;
const effortConfig = installEffortResolver.readMsdEffectiveEffortConfig(overrideTargetDir);
converter = (content, _isGlobal, meta) => {
const modelOverride = meta
? installModelOverrideResolver.resolveAgentModelOverride(meta.agentName, modelOverrides, runtimeResolver)
@@ -687,7 +677,7 @@ function convertedAgentsKind(
return rawConverter(content, { isAgent: true, modelOverride, variant });
};
} else {
// isGlobal is threaded so scope-aware agent converters (copilot, antigravity)
// isGlobal is threaded so scope-aware agent converters (antigravity)
// choose global-home vs workspace-relative paths; converters that only take
// (content) ignore the extra positional arg. Mirrors skillsKind's scope
// threading (#1173).
@@ -707,68 +697,17 @@ function convertedAgentsKind(
};
}
function kimiAgentsKind(destSubpath: string, prefix: string, configDir: string, sourceContext: SourceResolutionContext): ArtifactKind {
return {
kind: 'kimi-agents',
destSubpath,
prefix,
stage: (resolved, agentCtx) => {
const buildKimiAgentArtifacts = conversionExports['buildKimiAgentArtifacts'] as (opts: {
rootAgent?: string;
subagents?: Array<{ path: string; content: string }>;
}) => {
root: { yaml: string; prompt: string };
subagents: Array<{ name: string; yaml: string; prompt: string }>;
};
// #2995: compose at staging (identity converter) so the readFileSync below
// sees marker-free content — same single composing stager as agentsKind.
const stagedAgents = stageAgentsForRuntimeWithConverter(
sourceRootFor(sourceContext, 'agents'),
resolved,
(content: string) => content,
false,
agentCtx,
);
const subagents: Array<{ path: string; content: string }> = [];
if (installFs().existsSync(stagedAgents)) {
for (const entry of installFs().readdirSync(stagedAgents, { withFileTypes: true })) {
if (!entry.isFile() || !entry.name.endsWith('.md')) continue;
const agentPath = path.join(stagedAgents, entry.name);
subagents.push({
path: posixNormalize(path.join('agents', entry.name)),
content: installFs().readFileSync(agentPath, 'utf8'),
});
}
}
const rootAgent = `---\nname: msd\ndescription: Run MSD workflows in Kimi CLI.\ntools: Agent\n---\n\n# MSD for Kimi CLI\n\nCoordinate installed /skill:msd-* workflows and route work to generated MSD subagents when a workflow requires an agent handoff.\n`;
const artifacts = buildKimiAgentArtifacts({ rootAgent, subagents });
const stageDir = mkInstallTempDir('msd-kimi-agents-');
installProfiles.STAGED_DIRS.add(stageDir);
installFs().writeFileSync(path.join(stageDir, 'msd.yaml'), artifacts.root.yaml);
installFs().writeFileSync(path.join(stageDir, 'msd.md'), artifacts.root.prompt);
const subagentsDir = path.join(stageDir, 'subagents');
installFs().mkdirSync(subagentsDir, { recursive: true });
for (const artifact of artifacts.subagents) {
installFs().writeFileSync(path.join(subagentsDir, `${artifact.name}.yaml`), artifact.yaml);
installFs().writeFileSync(path.join(subagentsDir, `${artifact.name}.md`), artifact.prompt);
}
return stageDir;
},
};
}
/**
* Build a skills kind descriptor.
*
* @param destSubpath
* @param prefix
* @param converterName name of converter function in Runtime Artifact Conversion exports
* @param runtime canonical runtime ID (gates Hermes/Qwen branding in converter)
* @param runtime canonical runtime ID (passed through to the converter)
* @param configDir runtime config dir (for .msd-source marker resolution)
* @param nested if true, nest concrete skills under their ns-* routers (#69)
* @param scope install scope; converted to isGlobal and passed as 5th positional
* arg so scope-aware converters (antigravity, copilot) can choose
* arg so scope-aware converters (antigravity) can choose
* between global home paths and workspace-relative paths without
* colliding with the `runtime` string at position 3.
* @param capabilityRegistry #2322: optional capability registry — captured in the
@@ -797,8 +736,8 @@ function skillsKind(
// Compute cmdNames once per stage call for performance (#3583).
// Extra trailing args are ignored by converters that don't need them. The
// isGlobal flag is the 5th positional (NOT the 3rd): the 3rd positional is
// `runtime` for the claude/kimi/cline converters, so the scope-aware
// converters (antigravity, copilot) read isGlobal from position 5 to avoid
// `runtime` for the claude converter, so the scope-aware
// converters (antigravity) read isGlobal from position 5 to avoid
// colliding with `runtime` and always taking the global branch.
const cmdNames = conversionExports.readMsdCommandNames
? conversionExports.readMsdCommandNames()
@@ -874,17 +813,10 @@ function convertedCommandsKind(
// flat conservatively. Verified June 2026:
//
// NEST (confirmed non-recursive / one-level scan):
// cline — cline/cline skills.ts scanSkillsDirectory uses flat fs.readdir
// qwen — QwenLM/qwen-code skill-load.ts flat readdir ("depth 2 enough")
// hermes — hermes-agent.nousresearch.com/docs/user-guide/features/skills
// (single-level subdir probe of the tap path)
// augment — https://docs.augmentcode.com/cli/skills (flat single-level)
// trae — docs.trae.ai/ide/skills + Trae-AI/TRAE#2253 (flat; nesting errors)
// Trae IDE (trae.ai), not trae-agent — see runtime-homes.cts header note
// (none of the currently supported runtimes)
// FLAT (recursive loader → nesting gives no saving):
// cursor — https://cursor.com/docs/skills (walks skills root recursively)
// opencode — sst/opencode skill/index.ts glob "skills/**/SKILL.md"
// kilo — Kilo-Org/kilocode (opencode fork, same ** glob)
//
// FLAT (one-level scan, but concrete skills must be directly discoverable):
// antigravity— https://antigravity.google/docs/skills + /docs/cli-plugins
@@ -899,9 +831,6 @@ function convertedCommandsKind(
//
// FLAT (nested-scan behaviour unconfirmed → conservative):
// codex — developers.openai.com/codex/skills/
// copilot — docs.github.com/en/copilot/concepts/agents/about-agent-skills
// windsurf — docs.devin.ai/desktop/cascade/skills
// codebuddy — codebuddy.ai/docs/cli/skills
// ---------------------------------------------------------------------------
// Descriptor-driven dispatch helpers (ADR-857 phase 5d)
@@ -967,10 +896,6 @@ function dispatchKindEntry(entry: ArtifactKindDescriptor, runtime: string, confi
result = skillsKind(destSubpath, prefix, converter, runtime, configDir, nested, scope, sourceContext, capabilityRegistry);
break;
case 'kimi-agents':
result = kimiAgentsKind(destSubpath, prefix, configDir, sourceContext);
break;
default:
throw new TypeError(
`resolveRuntimeArtifactLayout: unknown kind '${kind}' in descriptor for runtime '${runtime}'`,
@@ -1060,12 +985,12 @@ function resolveRuntimeArtifactLayoutFromRegistry(
// rather than "where does a file land". A new function, not a widened
// signature — see .msd/phase/feat-2871-trigger-resolution/40-design.md.
//
// Only `commands` and `skills` are trigger-bearing. `agents` / `kimi-agents`
// Only `commands` and `skills` are trigger-bearing. `agents`
// are a SEPARATE dispatch interface point (subagent invocation via
// `subagent_type` / named dispatch, never a `/msd-<name>` a user types) — see
// 40-design.md's "agents are not trigger-bearing" correction to ADR-2866.
// Excluding them here is deliberate, not an oversight: including `agents`
// would misreport windsurf (whose global scope emits agents only) as fully
// would misreport a runtime whose global scope emits agents only as fully
// shadowing its local `/msd-*` surface, when in fact nothing shadows it.
/** The trigger-bearing subset of ArtifactKindName — mirrors
@@ -1275,7 +1200,7 @@ function resolveTriggerSurface(runtime: string, scopes: InstallScope[], opts: Tr
if (!scopeSet.has(scope)) continue;
const entries = layout[scope] ?? [];
for (const entry of entries) {
if (entry.kind !== 'commands' && entry.kind !== 'skills') continue; // excludes agents/kimi-agents
if (entry.kind !== 'commands' && entry.kind !== 'skills') continue; // excludes agents
const kind = entry.kind;
const destSubpath = posixNormalize(entry.destSubpath);
const namespacedByDir = isNamespacedByDir(kind, entry.destSubpath, entry.prefix);

View File

@@ -14,16 +14,13 @@
* - `installSurface` selects which config handler install() runs:
* 'settings-json' → fall through to the shared settings.json accumulation.
* 'codex-toml' → early-return after writing codex.toml.
* 'copilot-instructions' → early-return after writing .github/copilot-instructions.md.
* 'cline-rules' → early-return after writing .clinerules.
* 'cursor-hooks-json' → early-return after writing .cursor/hooks.json (issue #777).
* 'profile-marker-only' → early-return after writing only the profile marker.
* - `writesSharedSettings` is the finishInstall writeSettings gate:
* false for codex / copilot / kilo / cursor / windsurf / trae / cline / kimi (legacy exclusion list).
* false for codex / cursor (legacy exclusion list).
* true for all other runtimes.
* - `finishPermissionWriter` names the finishInstall-phase dedicated config writer:
* 'opencode' → writes BOTH shared settings AND its own permissions file.
* 'kilo' → writes only its own permissions file.
* 'antigravity' → writes BOTH shared settings.json permissions.allow AND a
* standalone mcp_config.json MCP companion profile (#2096
* Phase B Upgrades 1+2).
@@ -43,8 +40,6 @@ const VALID_SANDBOX_TIERS = new Set(['none', 'codex-agent-sandbox']);
type ConfigInstallSurface =
| 'settings-json'
| 'codex-toml'
| 'copilot-instructions'
| 'cline-rules'
| 'cursor-hooks-json'
| 'profile-marker-only'
// #2103 — Marketplace/VSIX-distributed hosts (e.g. VS Code) with no CLI
@@ -52,16 +47,12 @@ type ConfigInstallSurface =
// (see the ALLOWED_CONFIG_RUNTIMES filter below, which excludes it).
| 'none';
type FinishPermissionWriter = 'opencode' | 'kilo' | 'antigravity' | null;
type FinishPermissionWriter = 'opencode' | 'antigravity' | null;
type HooksSurface =
| 'settings-json'
| 'codex-hooks-json'
| 'cursor-hooks-json'
| 'cline-rules'
| 'copilot-inline'
| 'kimi-hooks-toml'
| 'windsurf-hooks-json'
| 'none';
interface RuntimeConfigIntent {
@@ -94,7 +85,7 @@ interface InstallPlan extends RuntimeConfigIntent {
type RuntimeDescriptorMap = Record<string, { runtime: Record<string, unknown> | undefined }>;
/**
* The complete set of 16 supported runtimes for config-adapter dispatch.
* The complete set of supported runtimes for config-adapter dispatch.
*
* Excludes runtimes whose installSurface is 'none' (#2103 — e.g. VS Code): a
* 'none' installSurface means the runtime has NO CLI install surface at all
@@ -115,8 +106,6 @@ const ALLOWED_CONFIG_RUNTIMES: ReadonlySet<string> = new Set(
const INSTALL_SURFACES: ReadonlyArray<ConfigInstallSurface> = Object.freeze([
'settings-json',
'codex-toml',
'copilot-instructions',
'cline-rules',
'cursor-hooks-json',
'profile-marker-only',
'none',

View File

@@ -7,27 +7,6 @@
* ADR-457 build-at-publish: the hand-written bin/lib/runtime-homes.cjs
* collapsed to a TypeScript source of truth. Behaviour is preserved
* byte-for-behaviour from the prior hand-written .cjs; only types are added.
*
* Runtime-specific notes:
* hermes — MSD skills nest under skills/msd/<skillName>/ (not the flat
* skills/<skillName>/ layout used by all other runtimes).
* cline — Skills-capable since v3.48.0 (#782). SKILL.md files live at
* ~/.cline/skills/<skillName>/SKILL.md (same flat layout as cursor/codex).
* .clinerules is also emitted (rules-based compatibility layer).
* kimi — Agent Skills are discovered from Kimi's generic user roots:
* ~/.config/agents/skills (recommended) then ~/.agents/skills,
* with Kimi selecting the first existing generic skills directory.
* ~/.kimi-code/skills is brand-specific and can be selected as a
* MSD write target with --config-dir or KIMI_CONFIG_DIR.
* trae — Targets Trae IDE (trae.ai), the Electron-based IDE — NOT
* trae-agent (github.com/bytedance/trae-agent), a Python CLI that
* uses trae_config.yaml, has no ~/.trae directory, and has no
* skills system. Both are ByteDance "Trae" products; they are
* entirely distinct. The global ~/.trae/skills/ path is
* community-soft-confirmed: docs.trae.ai/ide/skills documents the
* SKILL.md format and project-level .trae/skills/, but does NOT
* publish the global on-disk path; ~/.trae/skills/ rests on
* community evidence incl. Trae-AI/TRAE#2253. Best-effort only.
*/
import os from 'node:os';
@@ -73,22 +52,6 @@ export interface ResolveAntigravityOpts {
existsSync?: (p: string) => boolean;
}
export interface ResolveKimiOpts {
env?: Record<string, string | undefined>;
home?: string;
existsSync?: (p: string) => boolean;
}
/**
* Options for `resolveKimiHooksTomlDir`. Separate from `ResolveKimiOpts` so the
* `runtime` selector is not implied to affect `resolveKimiGlobalDir`, which
* resolves the generic Agent-Skills root and is runtime-independent.
*/
export interface ResolveKimiHooksTomlOpts extends ResolveKimiOpts {
/** Runtime id — `kimi` (default) or `kimi-code`. See #2755. */
runtime?: string;
}
export interface ResolveConfigHomeOpts {
env?: Record<string, string | undefined>;
home?: string;
@@ -120,8 +83,7 @@ interface DotHomeNestedDescriptor {
* in two passes: first the candidate whose `<candidate>/<probeExists>` exists
* wins (the dir MSD installed into), then a bare-existence pass, then
* `probe[0]`. Without it, behaviour is the legacy first-bare-existing-wins
* probe, so other dot-home-nested runtimes (e.g. windsurf, which has no probe)
* are unaffected. See ADR-1016 and #213/#217 (antigravity split).
* probe, so other dot-home-nested runtimes without a probe are unaffected. See ADR-1016 and #213/#217 (antigravity split).
*/
probeExists?: string;
skillsHome?: ConfigHomeDescriptor;
@@ -309,7 +271,7 @@ export function resolveConfigHomeFromDescriptor(
// fallback: first probe candidate
return path.join(base, configHome.probe[0]);
}
// no probe (e.g. windsurf): always name under parent
// no probe: always name under parent
return path.join(base, configHome.name);
}
@@ -448,75 +410,6 @@ export function detectAntigravityDirAmbiguity(
};
}
/**
* Resolve Kimi's generic user root using Kimi CLI's documented first-existing
* generic skills directory policy:
*
* 1. ~/.config/agents/skills (recommended)
* 2. ~/.agents/skills
*
* If neither generic skills directory exists yet, install to the recommended
* ~/.config/agents root so the generated skills become the first generic
* candidate Kimi discovers.
*
* KIMI_CONFIG_DIR is a MSD installer write-location override. It is not Kimi's
* upstream data-root variable, and arbitrary roots are discoverable by Kimi only
* when the user also configures Kimi --skills-dir or extra_skill_dirs.
*
* Thin wrapper delegating to resolveConfigHomeFromDescriptor with the
* kimi descriptor shape. Preserved for external callers and tests.
*/
export function resolveKimiGlobalDir(opts: ResolveKimiOpts = {}): string {
const env: Record<string, string | undefined> = opts.env ?? process.env;
const home = opts.home ?? os.homedir();
const existsSyncFn = opts.existsSync ?? fs.existsSync;
return resolveConfigHomeFromDescriptor(
{
kind: 'generic-agents-root',
name: 'agents',
env: ['KIMI_CONFIG_DIR'],
probe: ['~/.config/agents', '~/.agents'],
probeExists: 'skills',
},
{ env, home, existsSync: existsSyncFn },
);
}
/**
* Kimi CLI's own native config.toml home. Hoisted out of resolveKimiHooksTomlDir
* so it is ENUMERABLE, not merely resolvable.
*
* #2665 round 3: a config-location var that lives only inside a function body is
* invisible to every consumer that needs the SET rather than the path — the test
* scrub list and the hermeticity guard both derive from descriptors, and this one
* reached neither. `kimi` is the sharp case precisely because it owns TWO config
* homes: KIMI_CONFIG_DIR (registry-visible, already covered) and KIMI_SHARE_DIR
* (this one), so a derivation keyed only on the registry looks complete and is not.
*/
export const KIMI_HOOKS_TOML_DESCRIPTOR: DotHomeDescriptor = {
kind: 'dot-home',
name: '.kimi',
env: ['KIMI_SHARE_DIR'],
};
/**
* Kimi Code's native config.toml home — the `kimi-code` counterpart of the
* descriptor above, hoisted for exactly the same reason.
*
* #2755 landed kimi-code hooks support on `next` while this PR was open, and
* declared this descriptor as an inline object literal inside
* resolveKimiHooksTomlDir's body — the same resolvable-but-not-enumerable shape
* round 3 hoisted KIMI_SHARE_DIR out of. Hoisting it puts `KIMI_CODE_HOME` into
* the derived scrub set and the hermeticity guard's watch roots in the SAME
* commit, which is the property NON_REGISTRY_CONFIG_HOME_DESCRIPTORS exists to
* guarantee. Each product's env var stays scoped to that product (#2755).
*/
export const KIMI_CODE_HOOKS_TOML_DESCRIPTOR: DotHomeDescriptor = {
kind: 'dot-home',
name: '.kimi-code',
env: ['KIMI_CODE_HOME'],
};
/**
* Config-home descriptors resolved OUTSIDE the capability registry.
*
@@ -525,10 +418,7 @@ export const KIMI_CODE_HOOKS_TOML_DESCRIPTOR: DotHomeDescriptor = {
* narrower than the surface it guards. Adding a hardcoded resolver WITHOUT adding
* its descriptor here is the defect this array exists to make hard.
*/
export const NON_REGISTRY_CONFIG_HOME_DESCRIPTORS: ConfigHomeDescriptor[] = [
KIMI_HOOKS_TOML_DESCRIPTOR,
KIMI_CODE_HOOKS_TOML_DESCRIPTOR,
];
export const NON_REGISTRY_CONFIG_HOME_DESCRIPTORS: ConfigHomeDescriptor[] = [];
/**
* MSD's OWN location vars — a second family, not runtime configHomes.
@@ -552,48 +442,6 @@ export const NON_REGISTRY_CONFIG_HOME_DESCRIPTORS: ConfigHomeDescriptor[] = [
*/
export const MSD_LOCATION_ENV_KEYS: readonly string[] = ['MSD_HOME', 'MSD_AGENTS_DIR'];
/**
* Resolve the directory holding the Kimi product's OWN native config.toml —
* the file that product itself reads for providers/models/hooks/etc, and the
* one MSD writes its `[[hooks]]` block, hooks bundle and CommonJS marker into.
*
* The two Kimi runtimes share `hooksSurface: "kimi-hooks-toml"` but are
* different products with different roots, and this must be selected by
* `runtime` (#2755). Before that fix this function was unparameterized and a
* `--kimi-code` install wrote its hooks into Kimi CLI's `~/.kimi`, leaving Kimi
* Code with none:
*
* kimi → `~/.kimi`, overridden by `KIMI_SHARE_DIR`
* (moonshotai.github.io/kimi-cli/en/configuration/data-locations.html)
* kimi-code → `~/.kimi-code`, overridden by `KIMI_CODE_HOME`
* (moonshotai/kimi-code docs/en/configuration/data-locations.md;
* its hooks doc places `[[hooks]]` in `~/.kimi-code/config.toml`)
*
* Each product's env var is scoped to that product: `KIMI_SHARE_DIR` is Kimi
* CLI's own upstream variable and must NOT redirect kimi-code, nor vice versa.
*
* An unrecognised `runtime` (and an omitted one) falls back to `~/.kimi`, which
* preserves the pre-#2755 behaviour for every existing caller that passes no
* runtime — this function is exported, so that default is a contract.
*
* For BOTH runtimes this is deliberately a SEPARATE directory from the generic
* Agent-Skills root resolved by `resolveKimiGlobalDir` (`~/.config/agents`):
* both vendors' docs confirm the Agent-Skills search path is independent of the
* data-root env var. MSD's native `[[hooks]]` entries go in
* `<this dir>/config.toml`, never into the skills configDir.
*/
export function resolveKimiHooksTomlDir(opts: ResolveKimiHooksTomlOpts = {}): string {
const env: Record<string, string | undefined> = opts.env ?? process.env;
const home = opts.home ?? os.homedir();
// Explicit comparison rather than an object lookup keyed on `runtime`: the
// value originates from argv, and an index would resolve inherited keys
// (`constructor`, `__proto__`) to something that is not a descriptor.
const descriptor: DotHomeDescriptor = opts.runtime === 'kimi-code'
? KIMI_CODE_HOOKS_TOML_DESCRIPTOR
: KIMI_HOOKS_TOML_DESCRIPTOR;
return resolveConfigHomeFromDescriptor(descriptor, { 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

@@ -4,12 +4,9 @@
* Runtime Hooks Surface Module — hook-surface writer functions extracted from
* bin/install.js (ADR-857 phase 5f-1).
*
* Owns the lifecycle writer functions for hook surfaces managed by MSD on four
* runtimes:
* Cline: writeClineArtifacts + supporting helpers/constants
* Owns the lifecycle writer functions for hook surfaces managed by MSD:
* Cursor: buildCursorHookEntry, isManagedCursorHookEntry,
* reconcileCursorHooksJson, writeCursorHooksJson, removeCursorHooksJson
* Copilot: buildCopilotHookConfig, writeCopilotHookConfig
* Codex hooks.json: ensureCodexHooksJsonSessionStart, ensureCodexHooksJsonEvent,
* reconcileCodexHooksJsonEvent, reconcileCodexHooksJsonSessionStart,
* removeCodexHooksJsonEvent, removeCodexHooksJsonSessionStart,
@@ -54,7 +51,6 @@ const {
projectPortableHookBaseDir,
projectCodexHookTomlCommand,
shellHookOmitsBashRunner,
escapeTomlDoubleQuotedString,
escapePosixDoubleQuoted,
resolveExecutableBinary,
} = shellCmdProjection as {
@@ -65,7 +61,6 @@ 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;
escapePosixDoubleQuoted: (value: unknown) => string;
resolveExecutableBinary: (name: string, opts?: { platform?: string; requireExecutable?: boolean }) => string | null;
};
@@ -81,56 +76,6 @@ const reset = '\x1b[0m';
// Codex config.toml constants (subset needed by this module)
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// Copilot hook constants
// ---------------------------------------------------------------------------
const MSD_COPILOT_HOOK_FILE = 'msd-session.json';
const MSD_COPILOT_SESSION_MSG_PRESENT =
'MSD: .planning/STATE.md present - review the current phase and any blockers before acting.';
const MSD_COPILOT_SESSION_MSG_ABSENT =
'MSD: no .planning/ workflow found - run /msd-new-project to start a tracked workflow.';
const MSD_COPILOT_SESSION_HOOK_BASH =
'if [ -f .planning/STATE.md ]; then ' +
`printf '%s' '{"additionalContext":"${MSD_COPILOT_SESSION_MSG_PRESENT}"}'; else ` +
`printf '%s' '{"additionalContext":"${MSD_COPILOT_SESSION_MSG_ABSENT}"}'; fi`;
const MSD_COPILOT_SESSION_HOOK_PWSH =
'if (Test-Path .planning/STATE.md) ' +
`{ '{"additionalContext":"${MSD_COPILOT_SESSION_MSG_PRESENT}"}' } ` +
`else { '{"additionalContext":"${MSD_COPILOT_SESSION_MSG_ABSENT}"}' }`;
// #2099 UPGRADE 1: multi-event hook bus. Each additional event is a static,
// deterministic advisory (no branching/no-op-style, matching sessionStart's
// tone) so the emitted hooks/msd-session.json stays golden-trackable — no
// node-runner invocation, no filesystem probing beyond what sessionStart
// already does.
const MSD_COPILOT_PRE_TOOL_MSG =
'MSD: confirm this tool use is in scope for the active phase before proceeding.';
const MSD_COPILOT_PRE_TOOL_HOOK_BASH =
`printf '%s' '{"additionalContext":"${MSD_COPILOT_PRE_TOOL_MSG}"}'`;
const MSD_COPILOT_PRE_TOOL_HOOK_PWSH =
`'{"additionalContext":"${MSD_COPILOT_PRE_TOOL_MSG}"}'`;
const MSD_COPILOT_POST_TOOL_MSG =
'MSD: review the tool result against the active phase before continuing.';
const MSD_COPILOT_POST_TOOL_HOOK_BASH =
`printf '%s' '{"additionalContext":"${MSD_COPILOT_POST_TOOL_MSG}"}'`;
const MSD_COPILOT_POST_TOOL_HOOK_PWSH =
`'{"additionalContext":"${MSD_COPILOT_POST_TOOL_MSG}"}'`;
const MSD_COPILOT_PROMPT_SUBMIT_MSG =
'MSD: check this request against .planning/STATE.md scope before acting.';
const MSD_COPILOT_PROMPT_SUBMIT_HOOK_BASH =
`printf '%s' '{"additionalContext":"${MSD_COPILOT_PROMPT_SUBMIT_MSG}"}'`;
const MSD_COPILOT_PROMPT_SUBMIT_HOOK_PWSH =
`'{"additionalContext":"${MSD_COPILOT_PROMPT_SUBMIT_MSG}"}'`;
const MSD_COPILOT_SESSION_END_MSG =
'MSD: update .planning/STATE.md with the session outcome before ending.';
const MSD_COPILOT_SESSION_END_HOOK_BASH =
`printf '%s' '{"additionalContext":"${MSD_COPILOT_SESSION_END_MSG}"}'`;
const MSD_COPILOT_SESSION_END_HOOK_PWSH =
`'{"additionalContext":"${MSD_COPILOT_SESSION_END_MSG}"}'`;
// ---------------------------------------------------------------------------
// Cursor hook constants
// ---------------------------------------------------------------------------
@@ -150,12 +95,6 @@ const MSD_CURSOR_HOOK_MARKER = 'msd-managed';
// resolveManagedHookEvents(opts.managedHookEvents).
const CURSOR_MANAGED_EVENTS = CURSOR_HOOK_EVENTS;
// ---------------------------------------------------------------------------
// Cline / AGENTS.md constants
// ---------------------------------------------------------------------------
const MSD_AGENTS_MD_MARKER = '<!-- MSD Configuration — managed by msd-core installer -->';
const MSD_AGENTS_MD_CLOSE_MARKER = '<!-- End MSD Configuration -->';
// ---------------------------------------------------------------------------
// Descriptor-driven runtime title lookup (ADR-1239 / #2092)
// ---------------------------------------------------------------------------
@@ -163,9 +102,7 @@ const MSD_AGENTS_MD_CLOSE_MARKER = '<!-- End MSD Configuration -->';
/**
* Console-log label for a runtime, sourced from the capability registry's
* `title` field (capabilities/<runtime>/capability.json). Folded from a
* hardcoded `runtime === 'qwen' ? 'Qwen Code' : runtime === 'claude' ?
* 'Claude Code' : runtime` ternary — cosmetic (log text) only, but resolves
* to the same 'Qwen Code' / 'Claude Code' values for those two runtimes.
* hardcoded per-runtime ternary — cosmetic (log text) only.
* Falls back to the raw runtime id if the registry can't be loaded or the
* runtime has no title.
*/
@@ -253,13 +190,13 @@ function atomicWriteFileSync(target: string, data: string, options: fs.WriteFile
// CommonJS package.json marker for staged .js hook scripts (#2717)
//
// Node resolves the nearest package.json walking up from a .js file. When a
// runtime's config root (e.g. ~/.cursor, ~/.codeium/windsurf, ~/.codex) — or any
// runtime's config root (e.g. ~/.cursor, ~/.codex) — or any
// parent — declares {"type":"module"}, Node loads MSD's staged CommonJS hook
// scripts as ESM and every require() fails with "require is not defined",
// silently disabling that runtime's lifecycle hooks.
//
// installSharedHooksBundle writes this marker for the 12 runtimes that go
// through the shared hooks bundle, but cursor/windsurf (skipSharedHooksInstall)
// installSharedHooksBundle writes this marker for the runtimes that go
// through the shared hooks bundle, but cursor (skipSharedHooksInstall)
// and codex (the !isCodex gate) stage their .js hooks via the dedicated paths
// below and never reached it. These helpers decouple the marker write from the
// shared bundle so any code path that stages .js hooks can ensure the marker
@@ -1604,166 +1541,6 @@ function buildHookCommand(configDir: string, hookName: string, opts?: BuildHookC
}));
}
// ---------------------------------------------------------------------------
// Cline helpers
// ---------------------------------------------------------------------------
function buildClineRulesBody(): string {
return [
'# MSD Core — Make Software Done.',
'',
'- MSD workflows live in `msd-core/workflows/`. Load the relevant workflow when',
' the user runs a `/msd-*` command.',
'- MSD agents live in `agents/`. Use the matching agent when spawning subagents.',
'- MSD tools are at `msd-core/bin/msd-tools.cjs`. Run with `node`.',
'- Planning artifacts live in `.planning/`. Never edit them outside a MSD workflow.',
'- Do not apply MSD workflows unless the user explicitly asks for them.',
'- When a MSD command triggers a deliverable (feature, fix, docs), offer the next',
' step to the user using Cline\'s ask_user tool after completing it.',
].join('\n') + '\n';
}
function buildClineAgentsMdBody(): string {
return buildClineRulesBody();
}
function buildClinePreToolUseHook(): string {
return `#!/usr/bin/env node
'use strict';
/* MSD-managed Cline PreToolUse hook — msd-core issue #787.
* Protocol: JSON on stdin -> JSON decision on stdout.
* Honored fields: { cancel, errorMessage, contextModification }.
* Fails open: any error allows the operation. */
let raw = '';
process.stdin.setEncoding('utf8');
process.stdin.on('data', (c) => { raw += c; });
process.stdin.on('end', () => {
const allow = () => process.stdout.write(JSON.stringify({ cancel: false }));
let input;
try { input = JSON.parse(raw || '{}'); } catch { return allow(); }
try {
const tool = String(
input.toolName || input.tool_name || input.tool ||
(input.toolInput && input.toolInput.name) || (input.tool_input && input.tool_input.name) || ''
).toLowerCase();
const isWrite = /write|edit|replace|create|delete|remove|append|apply|patch|insert|mkdir/.test(tool);
// Collect only PATH-bearing field values (not free-form content), so a doc
// that merely mentions ".planning/" in its body is never falsely blocked.
const paths = [];
const PATH_KEY = /^(path|file|file_?path|filepath|target_?path|target|dir|directory|uri|filename)$/i;
const walk = (v, depth) => {
if (depth > 5 || paths.length > 64) return;
if (Array.isArray(v)) { for (const x of v) walk(x, depth + 1); return; }
if (v && typeof v === 'object') {
for (const k of Object.keys(v)) {
const val = v[k];
if (typeof val === 'string' && PATH_KEY.test(k)) paths.push(val);
else walk(val, depth + 1);
}
}
};
walk(input, 0);
const isPlanningPath = (s) => /(^|[\\\\/])\\.planning([\\\\/]|$)/.test(s);
if (isWrite && paths.some(isPlanningPath)) {
return process.stdout.write(JSON.stringify({
cancel: true,
errorMessage:
'MSD: .planning/ artifacts are managed by MSD workflows. Edit them only through a /msd-* command, not directly.',
}));
}
} catch { /* fall through to allow */ }
return allow();
});
`;
}
function mergeMsdAgentsMd(filePath: string, msdContent: string): void {
const msdBlock = MSD_AGENTS_MD_MARKER + '\n' + msdContent.trim() + '\n' + MSD_AGENTS_MD_CLOSE_MARKER;
if (!fs.existsSync(filePath)) {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, msdBlock + '\n');
return;
}
const existing = fs.readFileSync(filePath, 'utf8');
const openIndex = existing.indexOf(MSD_AGENTS_MD_MARKER);
const closeIndex = existing.indexOf(MSD_AGENTS_MD_CLOSE_MARKER);
if (openIndex !== -1 && closeIndex !== -1) {
const before = existing.substring(0, openIndex).trimEnd();
const after = existing.substring(closeIndex + MSD_AGENTS_MD_CLOSE_MARKER.length).trimStart();
let newContent = '';
if (before) newContent += before + '\n\n';
newContent += msdBlock;
if (after) newContent += '\n\n' + after;
newContent += '\n';
fs.writeFileSync(filePath, newContent);
return;
}
fs.writeFileSync(filePath, existing.trimEnd() + '\n\n' + msdBlock + '\n');
}
// ---------------------------------------------------------------------------
// writeClineArtifacts
// ---------------------------------------------------------------------------
function writeClineArtifacts(targetDir: string, isGlobalInstall: boolean): { written: string[]; configuredEntrypoints: ConfiguredEntrypoint[] } {
const written: string[] = [];
const configuredEntrypoints: ConfiguredEntrypoint[] = [];
const clinerulesDir = path.join(targetDir, '.clinerules');
try {
if (fs.existsSync(clinerulesDir)) {
const st = fs.lstatSync(clinerulesDir);
if (st.isFile() || st.isSymbolicLink()) {
fs.unlinkSync(clinerulesDir);
console.log(` ${green}✓${reset} Migrated legacy .clinerules to directory form`);
}
}
} catch { /* best-effort migration */ }
fs.mkdirSync(clinerulesDir, { recursive: true });
fs.writeFileSync(path.join(clinerulesDir, 'msd.md'), buildClineRulesBody());
written.push('.clinerules/msd.md');
console.log(` ${green}✓${reset} Wrote .clinerules/msd.md`);
const hooksDir = path.join(clinerulesDir, 'hooks');
fs.mkdirSync(hooksDir, { recursive: true });
const hookPath = path.join(hooksDir, 'PreToolUse');
fs.writeFileSync(hookPath, buildClinePreToolUseHook());
try { fs.chmodSync(hookPath, 0o755); } catch { /* Windows: hooks unsupported anyway */ }
written.push('.clinerules/hooks/PreToolUse');
console.log(` ${green}✓${reset} Wrote .clinerules/hooks/PreToolUse`);
// #4249 (CodeRabbit): Cline invokes this file directly via its own
// `#!/usr/bin/env node` shebang — a hybrid case. The script itself still
// needs the execute bit (selfExecutable), but unlike MSD's other JS hooks
// (which bake an absolute, install-time-resolved node path specifically to
// avoid this) its interpreter is looked up on PATH by `env` at hook-fire
// time, so `node` must also resolve or the hook can never run.
configuredEntrypoints.push({
runtime: 'cline',
configPath: hookPath,
scriptPath: hookPath,
interpreterCandidates: ['node'],
selfExecutable: true,
platform: process.platform,
});
if (isGlobalInstall) {
try {
const agentsPath = path.join(os.homedir(), '.agents', 'AGENTS.md');
mergeMsdAgentsMd(agentsPath, buildClineAgentsMdBody());
console.log(` ${green}✓${reset} Merged MSD instructions into ~/.agents/AGENTS.md`);
} catch (err) {
console.warn(` ${yellow}⚠${reset} Could not write ~/.agents/AGENTS.md: ${(err as Error).message}`);
}
}
return { written, configuredEntrypoints };
}
// ---------------------------------------------------------------------------
// Cursor hook functions
// ---------------------------------------------------------------------------
@@ -2090,338 +1867,23 @@ function removeCursorHooksJson(targetDir: string): { changed: boolean } {
return { changed: result.changed };
}
// ---------------------------------------------------------------------------
// Windsurf/Cascade hook functions (ADR-1239 / #2100 Stage 2 — HOOK-BRIDGE)
//
// Cascade (Windsurf's agent) hooks.json format is DISTINCT from Cursor's:
// { "hooks": { "<event>": [ { "command": "<shell cmd>", ... } ] } }
// Each entry carries a bare `command` STRING (a shell command line) — not
// Cursor's `{ type: 'command', command: <cmd> }` wrapper — and there is no
// top-level `version` field. Docs (reference): https://docs.windsurf.com/llms-full.txt ,
// https://docs.devin.ai/desktop/cascade/hooks
//
// Cascade blocks via EXIT CODE 2 (+ a stderr reason), not Cursor's stdout-JSON
// `{ block: true, reason }` form — so the two hook scripts installed here
// (hooks/msd-windsurf-pre-write.js, hooks/msd-windsurf-pre-command.js) speak a
// different protocol than the Cursor scripts, even though the surrounding
// install/reconcile infra mirrors writeCursorHooksJson/removeCursorHooksJson.
//
// Only 2 of MSD's 6 Cursor-parity hook events have a Cascade counterpart with
// BLOCKING semantics: pre_write_code and pre_run_command. Cascade has no
// context-injection channel (no `additional_context`-style advisory
// response), so the 4 advisory events MSD registers on Cursor (sessionStart,
// postToolUse, stop, subagentStart/subagentStop) are deliberately NOT ported.
// ---------------------------------------------------------------------------
const MSD_WINDSURF_PRE_WRITE_HOOK_SCRIPT = 'msd-windsurf-pre-write.js';
const MSD_WINDSURF_PRE_COMMAND_HOOK_SCRIPT = 'msd-windsurf-pre-command.js';
const MSD_WINDSURF_HOOK_MARKER = 'msd-managed';
/** The 2 Cascade hook events MSD wires with blocking (exit-code-2) guards. */
const WINDSURF_HOOK_EVENTS = Object.freeze(['pre_write_code', 'pre_run_command'] as const);
/** Event → hook-script mapping (mirrors CURSOR_EVENT_SCRIPT_MAP's convention). */
const WINDSURF_EVENT_SCRIPT_MAP: Readonly<Record<string, string>> = Object.freeze({
pre_write_code: MSD_WINDSURF_PRE_WRITE_HOOK_SCRIPT,
pre_run_command: MSD_WINDSURF_PRE_COMMAND_HOOK_SCRIPT,
});
/** All MSD-managed Windsurf hook scripts (used by uninstall cleanup). */
const MSD_WINDSURF_HOOK_SCRIPTS = [
MSD_WINDSURF_PRE_WRITE_HOOK_SCRIPT,
MSD_WINDSURF_PRE_COMMAND_HOOK_SCRIPT,
];
/**
* Build a single Cascade hooks.json managed entry. Cascade's entry shape has
* no `type` field (unlike Cursor's `{ type: 'command', command }`) — just a
* bare `command` shell string plus the MSD marker.
*/
function buildWindsurfHookEntry(command: string): Record<string, unknown> {
return {
command,
[MSD_WINDSURF_HOOK_MARKER]: true,
};
}
function isManagedWindsurfHookEntry(entry: unknown): boolean {
return Boolean(entry && typeof entry === 'object' && (entry as Record<string, unknown>)[MSD_WINDSURF_HOOK_MARKER]);
}
interface WindsurfManagedEntries {
pre_write_code?: Record<string, unknown> | null;
pre_run_command?: Record<string, unknown> | null;
[event: string]: Record<string, unknown> | null | undefined;
}
/**
* Reconcile MSD's managed Cascade hook entries into `<targetDir>/hooks.json`,
* preserving any user-owned entries. Mirrors reconcileCursorHooksJson's
* merge/no-write-when-unchanged semantics, adapted to Cascade's flatter
* `{ hooks: { <event>: [...] } }` shape (no `version` field, no legacy
* top-level-array lift — Cascade's hooks.json is a brand-new surface with no
* prior shape to migrate from).
*/
function reconcileWindsurfHooksJson(hooksJsonPath: string, managedEntries: WindsurfManagedEntries | null): ReconcileResult {
let parsed: Record<string, unknown> = {};
let currentContent: string | null = null;
if (fs.existsSync(hooksJsonPath)) {
const raw = fs.readFileSync(hooksJsonPath, 'utf8');
currentContent = raw;
if (raw.trim()) {
try {
parsed = JSON.parse(raw) as Record<string, unknown>;
} catch (err) {
throw new Error(`Windsurf hooks.json parse failed: ${err && (err as Error).message ? (err as Error).message : String(err)}`);
}
}
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) parsed = {};
const hasNestedHooksObject =
parsed['hooks'] && typeof parsed['hooks'] === 'object' && !Array.isArray(parsed['hooks']);
if (!hasNestedHooksObject) parsed['hooks'] = {};
const hookTable = parsed['hooks'] as Record<string, unknown>;
const entries = managedEntries || {};
for (const event of WINDSURF_HOOK_EVENTS) {
const existing = Array.isArray(hookTable[event]) ? (hookTable[event] as unknown[]) : [];
const userOwned = existing.filter((e) => !isManagedWindsurfHookEntry(e));
const newEntry = entries[event] || null;
if (newEntry) {
hookTable[event] = [...userOwned, newEntry];
} else if (userOwned.length > 0) {
hookTable[event] = userOwned;
} else {
delete hookTable[event];
}
}
// Avoid writing an empty `{ "hooks": {} }` artifact.
if (Object.keys(hookTable).length === 0) delete parsed['hooks'];
const nextContent = `${JSON.stringify(parsed, null, 2)}\n`;
const changed = currentContent !== nextContent;
const shouldWrite = changed && (currentContent !== null || Object.keys(parsed).length > 0);
if (shouldWrite) {
atomicWriteFileSync(hooksJsonPath, nextContent, 'utf8');
}
return { changed: changed, wrote: shouldWrite, path: hooksJsonPath };
}
interface WriteWindsurfHooksJsonOpts {
platform?: string;
}
/**
* Write MSD-managed Cascade lifecycle hooks into `<targetDir>/hooks.json`.
* Both managed hook scripts (msd-windsurf-pre-write.js,
* msd-windsurf-pre-command.js) are copied from the MSD hooks/ source to
* `<targetDir>/hooks/` first, so the hooks.json entries never reference a
* script that wasn't installed. Mirrors writeCursorHooksJson's structure;
* `buildHookCommand` is runtime-agnostic (it already returns a plain shell
* command string), so it is reused as-is with `runtime: 'windsurf'` — only
* the hooks.json ENTRY shape (buildWindsurfHookEntry) and the reconcile
* function differ from Cursor's.
*
* @param targetDir - The Windsurf config dir (global: ~/.codeium/windsurf; local: .windsurf)
* @param src - The MSD install source root (for copying hook scripts)
* @param opts - `{ platform? }`
* @returns `{ hooksJsonPath, changed }`
*/
function writeWindsurfHooksJson(targetDir: string, src: string, opts?: WriteWindsurfHooksJsonOpts): { hooksJsonPath: string; changed: boolean; configuredEntrypoints: ConfiguredEntrypoint[] } {
opts = opts || {};
const hooksDir = path.join(targetDir, 'hooks');
fs.mkdirSync(hooksDir, { recursive: true });
const srcHooksDir = path.join(src, 'hooks');
const installedScripts = new Set<string>();
for (const script of MSD_WINDSURF_HOOK_SCRIPTS) {
const srcPath = path.join(srcHooksDir, script);
const destPath = path.join(hooksDir, script);
if (fs.existsSync(srcPath)) {
let content = fs.readFileSync(srcPath, 'utf8');
content = content.replace(/msd:/gi, 'msd-');
fs.writeFileSync(destPath, content);
try { fs.chmodSync(destPath, 0o755); } catch { /* Windows: ignore chmod */ }
installedScripts.add(script);
}
}
// Stage the hooks/lib/ helpers these scripts require (#4087 review). Windsurf
// sets hostBehaviors.skipSharedHooksInstall, so like Cursor it never reaches
// installSharedHooksBundle — the only other stager of hooks/lib — and it was
// staging neither. Both Cascade guards require helpers at module load:
// msd-windsurf-pre-write.js requires ./lib/hook-exit.js and ./lib/git-probe.js,
// msd-windsurf-pre-command.js requires ./lib/hook-exit.js. Measured against a
// real `--windsurf --global` install before this call existed: the installer
// exited 0, hooks/ held only the two scripts, and running either one exited 1
// with "Cannot find module './lib/hook-exit.js'" — the same failure #4087
// reports for Codex, on every pre_write_code / pre_run_command event.
//
// The transform matches the one applied to the scripts above: a helper must be
// rewritten the same way as its caller or the two disagree on the spelling.
stageTransitiveHookLibs({
seedSources: [...installedScripts].map((script) => fs.readFileSync(path.join(hooksDir, script), 'utf8')),
srcLibDir: path.join(srcHooksDir, 'lib'),
destLibDir: path.join(hooksDir, 'lib'),
runtimeLabel: 'Windsurf',
transform: (content) => content.replace(/msd:/gi, 'msd-'),
});
// #2717: write the CommonJS marker into hooks/ alongside the staged .js
// scripts. Windsurf sets skipSharedHooksInstall, so it never reaches
// installSharedHooksBundle (the only other writer of this marker); without
// it, a config-root package.json declaring {"type":"module"} makes Node load
// these require()-using scripts as ESM and the Windsurf hooks fail silently.
//
// #2544: gated on having actually staged a script — see the identical gate in
// the Cursor writer above and `stagedHooks` in installSharedHooksBundle.
if (installedScripts.size > 0) {
ensureCommonJsMarker(hooksDir);
}
const configuredEntrypoints: ConfiguredEntrypoint[] = [];
const hookOpts: BuildHookCommandOpts = {
runtime: 'windsurf',
platform: opts.platform || process.platform,
configPath: path.join(targetDir, 'hooks.json'),
configuredEntrypoints,
};
const commands: Record<string, string | null> = {};
for (const ev of WINDSURF_HOOK_EVENTS) {
const script = WINDSURF_EVENT_SCRIPT_MAP[ev];
commands[ev] = (script && installedScripts.has(script)) ? buildHookCommand(targetDir, script, hookOpts) : null;
}
const managedEntries: WindsurfManagedEntries = {};
for (const ev of WINDSURF_HOOK_EVENTS) {
const cmd = commands[ev];
if (cmd) managedEntries[ev] = buildWindsurfHookEntry(cmd);
}
const hooksJsonPath = path.join(targetDir, 'hooks.json');
const result = reconcileWindsurfHooksJson(hooksJsonPath, managedEntries);
return { hooksJsonPath, changed: result.changed, configuredEntrypoints };
}
/**
* Remove all MSD-managed Cascade hook entries from hooks.json. User-owned
* entries are preserved. If the file becomes empty, it is removed.
*
* @param targetDir - The Windsurf config dir
* @returns `{ changed }`
*/
function removeWindsurfHooksJson(targetDir: string): { changed: boolean } {
const hooksJsonPath = path.join(targetDir, 'hooks.json');
if (!fs.existsSync(hooksJsonPath)) return { changed: false };
const result = reconcileWindsurfHooksJson(hooksJsonPath, null);
if (result.changed) {
try {
const contentRaw = fs.readFileSync(hooksJsonPath, 'utf8');
const parsed = JSON.parse(contentRaw) as Record<string, unknown>;
const hookTable = (parsed['hooks'] && typeof parsed['hooks'] === 'object' && !Array.isArray(parsed['hooks']))
? (parsed['hooks'] as Record<string, unknown>)
: {};
const hasAnyEvents = Object.keys(hookTable).some(
(k) => Array.isArray(hookTable[k]) && (hookTable[k] as unknown[]).length > 0,
);
if (!hasAnyEvents) {
fs.unlinkSync(hooksJsonPath);
// #2717: also remove the CommonJS marker MSD wrote into hooks/ — but
// only if it still carries MSD's exact content (a user-authored
// package.json is never deleted). Best-effort.
try { removeCommonJsMarkerIfMsdOwned(path.join(targetDir, 'hooks')); } catch { /* leave it */ }
return { changed: true };
}
} catch { /* best-effort: leave the file */ }
}
return { changed: result.changed };
}
// ---------------------------------------------------------------------------
// Copilot hook functions
// ---------------------------------------------------------------------------
function buildCopilotHookConfig(): Record<string, unknown> {
return {
version: 1,
hooks: {
sessionStart: [
{
type: 'command',
bash: MSD_COPILOT_SESSION_HOOK_BASH,
powershell: MSD_COPILOT_SESSION_HOOK_PWSH,
timeoutSec: 10,
},
],
// #2099 UPGRADE 1: multi-event hook bus — preToolUse (worktree/read-safety
// advisory), postToolUse (context-monitor advisory), userPromptSubmitted
// (prompt-guard advisory), sessionEnd (session-finalize advisory).
preToolUse: [
{
type: 'command',
bash: MSD_COPILOT_PRE_TOOL_HOOK_BASH,
powershell: MSD_COPILOT_PRE_TOOL_HOOK_PWSH,
timeoutSec: 10,
},
],
postToolUse: [
{
type: 'command',
bash: MSD_COPILOT_POST_TOOL_HOOK_BASH,
powershell: MSD_COPILOT_POST_TOOL_HOOK_PWSH,
timeoutSec: 10,
},
],
userPromptSubmitted: [
{
type: 'command',
bash: MSD_COPILOT_PROMPT_SUBMIT_HOOK_BASH,
powershell: MSD_COPILOT_PROMPT_SUBMIT_HOOK_PWSH,
timeoutSec: 10,
},
],
sessionEnd: [
{
type: 'command',
bash: MSD_COPILOT_SESSION_END_HOOK_BASH,
powershell: MSD_COPILOT_SESSION_END_HOOK_PWSH,
timeoutSec: 10,
},
],
},
};
}
function writeCopilotHookConfig(targetDir: string): string {
const hooksDir = path.join(targetDir, 'hooks');
fs.mkdirSync(hooksDir, { recursive: true });
const hookPath = path.join(hooksDir, MSD_COPILOT_HOOK_FILE);
fs.writeFileSync(hookPath, JSON.stringify(buildCopilotHookConfig(), null, 2) + '\n');
return hookPath;
}
// ---------------------------------------------------------------------------
// applySettingsJsonHooks
//
// MUTATES `settings` by reference — registers all MSD-managed hook entries
// into settings.hooks.* for runtimes that use a settings.json hook surface
// (Claude Code, Antigravity, Qwen Code, and others).
// (Claude Code, Antigravity, and others).
// Skipped entirely for runtimes whose hooksSurface descriptor field is 'none'
// (opencode and kilo, which have their own hook surface).
// (opencode, which has its own hook surface).
//
// Extracted from the `if (!isOpencode && !isKilo) { … }` block inside
// Extracted from the `if (!isOpencode) { … }` block inside
// install() (ADR-857 phase 5f-1b). The hook-skip guard is now descriptor-driven
// (ADR-857 phase 5g drive 3): pass opts.hooksSurface from the runtime descriptor
// instead of deriving isOpencode/isKilo from the runtime name.
// instead of deriving isOpencode from the runtime name.
//
// @param settings - The settings object already read from disk. Mutated in place.
// @param opts - Closure values the block read from install()'s scope.
// runtime - runtime ID string (e.g. 'claude', 'antigravity', 'qwen')
// runtime - runtime ID string (e.g. 'claude', 'antigravity')
// hooksSurface - descriptor hooksSurface field ('settings-json'|'none'|…); if !== 'none', hooks are written
// isGlobal - true for global installs
// targetDir - absolute path to the runtime config dir
@@ -2491,15 +1953,9 @@ function applySettingsJsonHooks(settings: any, opts: ApplySettingsJsonHooksOpts)
// ADR-857 phase 5g drive 3: hook-skip guard is driven by the hooksSurface
// descriptor field. Only runtimes with hooksSurface === 'settings-json'
// register settings.json hooks; runtimes with hooksSurface === 'none'
// (opencode, kilo) are skipped. Equivalence: hooksSurface !== 'none' iff
// the old !isOpencode && !isKilo check.
// #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') {
// (opencode) are skipped. Equivalence: hooksSurface !== 'none' iff
// the old !isOpencode check.
if (hooksSurface !== 'none') {
if (!settings.hooks) {
settings.hooks = {};
}
@@ -2968,16 +2424,15 @@ function applySettingsJsonHooks(settings: any, opts: ApplySettingsJsonHooksOpts)
// ── Extended hook events: SubagentStop / Stop / PreCompact / SubagentStart
// (#788 + #770 + #2092) ────────────────────────────────────────────────
// Claude Code (since #770) and Qwen Code (since #788) both support the
// SubagentStop / Stop / PreCompact lifecycle events. Qwen Code additionally
// supports SubagentStart (#2092 Phase B, Upgrade 2). Wire msd-context-
// Claude Code (since #770) supports the SubagentStop / Stop / PreCompact
// lifecycle events; SubagentStart is wired for any runtime that declares
// it in extendedHookEvents (#2092 Phase B, Upgrade 2). Wire msd-context-
// monitor so agents get context-headroom warnings at subagent start,
// subagent completion, model stop, and pre-compaction (the most critical
// moment to surface headroom info).
//
// SubagentStart — subagent lifecycle start (context headroom tracking;
// qwen-only today — no other runtime declares it in
// extendedHookEvents)
// only for runtimes that declare it in extendedHookEvents)
// SubagentStop — subagent lifecycle completion (context headroom tracking)
// Stop — model stop / final-response moment (context headroom)
// PreCompact — fires before conversation compaction (most critical
@@ -2991,9 +2446,8 @@ function applySettingsJsonHooks(settings: any, opts: ApplySettingsJsonHooksOpts)
// Guard is descriptor-driven: only events present in extendedEvents are wired,
// so this loop is a no-op for every runtime that doesn't list SubagentStart.
{
// Descriptor-driven (ADR-1239 / #2092): folded from a hardcoded
// `runtime === 'qwen' ? ... : ...` ternary into a capability-title
// lookup (see _capabilityTitle above).
// Descriptor-driven (ADR-1239 / #2092): capability-title lookup (see
// _capabilityTitle above).
const runtimeLabel = _capabilityTitle(runtime);
for (const event of ['SubagentStop', 'Stop', 'PreCompact', 'SubagentStart']) {
if (!extendedEvents.includes(event)) continue;
@@ -3083,8 +2537,8 @@ function applySettingsJsonHooks(settings: any, opts: ApplySettingsJsonHooksOpts)
// (Claude Code matches by filename, not full path). The hook exits silently
// when the changed file is not the msd config.
//
// Scoped to Claude Code only: Qwen Code's FileChanged support is not yet
// verified; extend in a follow-on if empirically confirmed.
// Scoped to runtimes that declare FileChanged in extendedHookEvents
// (Claude Code today).
if (extendedEvents.includes('FileChanged')) {
if (!settings.hooks.FileChanged) {
settings.hooks.FileChanged = [];
@@ -3118,225 +2572,6 @@ 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 MSD 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.
//
// MSD-owned [[hooks]] entries are wrapped in marker comments so a reinstall
// can find-and-replace only MSD's own block, leaving any user-authored
// [[hooks]] entries elsewhere in the file untouched — mirrors the marker
// approach stripStaleMsdHookBlocks 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 = '# MSD Hooks BEGIN — managed by MSD, do not edit between these markers';
const KIMI_HOOKS_TOML_MARKER_END = '# MSD 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 MSD [[hooks]] block for kimi's config.toml,
* or null when no MSD 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 MSD 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 MSD 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 — msd-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('msd-check-update.js') },
{ event: 'SessionStart', command: cmd('msd-session-state.sh') },
// PreToolUse
{ event: 'PreToolUse', command: cmd('msd-prompt-guard.js'), matcher: 'WriteFile|StrReplaceFile', timeout: 5 },
{ event: 'PreToolUse', command: cmd('msd-read-guard.js'), matcher: 'WriteFile|StrReplaceFile', timeout: 5 },
{ event: 'PreToolUse', command: cmd('msd-worktree-path-guard.js'), matcher: 'WriteFile|StrReplaceFile', timeout: 5 },
{ event: 'PreToolUse', command: cmd('msd-write-guard.js'), matcher: 'WriteFile', timeout: 5 },
{ event: 'PreToolUse', command: cmd('msd-secret-read-guard.js'), matcher: 'ReadFile|Grep|Shell', timeout: 5 },
{ event: 'PreToolUse', command: cmd('msd-workflow-guard.js'), matcher: 'Shell|WriteFile|StrReplaceFile', timeout: 5 },
{ event: 'PreToolUse', command: cmd('msd-validate-commit.sh'), matcher: 'Shell', timeout: 5 },
// PostToolUse
{ event: 'PostToolUse', command: cmd('msd-context-monitor.js'), timeout: 10 },
{ event: 'PostToolUse', command: cmd('msd-phase-boundary.sh'), matcher: 'WriteFile|StrReplaceFile', timeout: 5 },
{ event: 'PostToolUse', command: cmd('msd-read-injection-scanner.js'), matcher: 'ReadFile', timeout: 5 },
{ event: 'PostToolUse', command: cmd('msd-graphify-update.sh'), matcher: 'Shell', timeout: 5 },
// Extended lifecycle events — context-headroom tracking (unmatched).
{ event: 'Stop', command: cmd('msd-context-monitor.js'), timeout: 10 },
{ event: 'PreCompact', command: cmd('msd-context-monitor.js'), timeout: 10 },
{ event: 'SubagentStart', command: cmd('msd-context-monitor.js'), timeout: 10 },
{ event: 'SubagentStop', command: cmd('msd-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 MSD [[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 MSD-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 MSD-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; configuredEntrypoints: ConfiguredEntrypoint[] } {
const configuredEntrypoints: ConfiguredEntrypoint[] = [];
const trackedOpts = {
hookOpts: {
...opts.hookOpts,
configPath,
configuredEntrypoints,
},
};
const existing = fs.existsSync(configPath) ? fs.readFileSync(configPath, 'utf8') : '';
const stripped = stripKimiHooksTomlBlock(existing) ?? '';
const block = buildKimiHooksTomlBlock(targetDir, trackedOpts);
const entryCount = block ? (block.match(/\[\[hooks\]\]/g) || []).length : 0;
if (!block) {
if (stripped === existing) return { changed: false, path: configPath, entryCount: 0, configuredEntrypoints };
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, configuredEntrypoints };
}
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, configuredEntrypoints };
fs.mkdirSync(path.dirname(configPath), { recursive: true });
atomicWriteFileSync(configPath, next, 'utf8');
return { changed: true, path: configPath, entryCount, configuredEntrypoints };
}
/**
* Uninstall-time counterpart to writeKimiHooksToml: strips the MSD block and
* deletes the file if nothing but MSD'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
//
@@ -3376,8 +2611,8 @@ interface ConfiguredEntrypoint {
// interpreterCandidates, which every producer that needs both sets
// alongside this rather than relying on their absence. Most self-executable
// entries have no candidates (a Windows-Claude .sh hook, Codex's .cmd shim);
// Cline's `#!/usr/bin/env node` is a hybrid needing both: the execute bit
// AND `node` resolving on PATH.
// a `#!/usr/bin/env node` shebang hook is a hybrid needing both: the execute
// bit AND `node` resolving on PATH.
selfExecutable?: boolean;
platform?: string;
command?: string;
@@ -3436,13 +2671,13 @@ function validateConfiguredEntrypoints(
// #4249: selfExecutable is the sole source of truth for whether the OS
// execs scriptPath directly via its own shebang (set explicitly by every
// producer that needs it — a Windows-Claude .sh hook, Codex's Windows
// .cmd shim, Cline's hybrid `env node` hook — rather than inferred from
// the absence of interpreterCandidates, which Cline's hybrid case also
// .cmd shim, a hybrid `env node` shebang hook — rather than inferred from
// the absence of interpreterCandidates, which the hybrid case also
// carries). Skip on win32 like resolveExecutableBinary's own X_OK
// carve-out does: POSIX mode bits don't mean executable on Windows, and a
// real accessSync(X_OK) there would fail a .cmd shim under a test that
// simulates win32 on a POSIX runner (Node's own no-op only protects an
// actual Windows machine). Cline is the only producer where this runs.
// actual Windows machine).
if (scriptOk && entry.selfExecutable && (entry.platform ?? process.platform) !== 'win32') {
try {
accessSync(entry.scriptPath, fs.constants.X_OK);
@@ -3464,15 +2699,6 @@ function validateConfiguredEntrypoints(
// ---------------------------------------------------------------------------
export = {
// Cline
buildClineRulesBody,
buildClineAgentsMdBody,
buildClinePreToolUseHook,
mergeMsdAgentsMd,
writeClineArtifacts,
MSD_AGENTS_MD_MARKER,
MSD_AGENTS_MD_CLOSE_MARKER,
// Cursor
buildCursorHookEntry,
isManagedCursorHookEntry,
@@ -3487,25 +2713,9 @@ export = {
MSD_CURSOR_SUBAGENT_STOP_HOOK_SCRIPT,
MSD_CURSOR_HOOK_MARKER,
// Windsurf/Cascade
buildWindsurfHookEntry,
isManagedWindsurfHookEntry,
reconcileWindsurfHooksJson,
writeWindsurfHooksJson,
removeWindsurfHooksJson,
WINDSURF_HOOK_EVENTS,
WINDSURF_EVENT_SCRIPT_MAP,
MSD_WINDSURF_PRE_WRITE_HOOK_SCRIPT,
MSD_WINDSURF_PRE_COMMAND_HOOK_SCRIPT,
MSD_WINDSURF_HOOK_SCRIPTS,
// CommonJS marker for staged .js hook scripts (#2717)
ensureCommonJsMarker,
removeCommonJsMarkerIfMsdOwned,
MSD_WINDSURF_HOOK_MARKER,
// Copilot
buildCopilotHookConfig,
writeCopilotHookConfig,
MSD_COPILOT_HOOK_FILE,
// Codex hooks.json
reconcileCodexHooksJsonEvent,
@@ -3523,14 +2733,6 @@ export = {
buildCodexHookBlock,
rewriteLegacyCodexHookBlock,
// Kimi hooks.toml
buildKimiHooksTomlBlock,
stripKimiHooksTomlBlock,
writeKimiHooksToml,
removeKimiHooksToml,
KIMI_HOOKS_TOML_MARKER_BEGIN,
KIMI_HOOKS_TOML_MARKER_END,
// Shared
stageTransitiveHookLibs,
buildHookCommand,

View File

@@ -124,20 +124,9 @@ export function assertNotRetiredRuntime(runtime: unknown): void {
const FALLBACK_ALIASES: Readonly<Record<string, string[]>> = {
claude: ['claude', 'claude-code', 'claude-cli'],
opencode: ['opencode', 'open-code', 'opencode-cli'],
kilo: ['kilo', 'kilo-cli'],
codex: ['codex', 'codex-app', 'codex-cli', 'codex_desktop', 'codex-desktop'],
copilot: ['copilot', 'copilot-cli', 'github-copilot'],
antigravity: ['antigravity', 'antigravity-cli', 'antigravity-agent'],
cursor: ['cursor', 'cursor-cli', 'cursor-nightly'],
windsurf: ['windsurf', 'windsurf-cli', 'windsurf-next', 'devin-desktop'],
augment: ['augment', 'augment-code', 'augment-cli'],
trae: ['trae', 'trae-cli'],
qwen: ['qwen', 'qwen-code', 'qwen-cli'],
hermes: ['hermes', 'hermes-agent', 'hermes-cli'],
kimi: ['kimi'],
'kimi-code': ['kimi-code', 'kimicode', 'kimi_code'],
codebuddy: ['codebuddy', 'codebuddy-cli'],
cline: ['cline', 'cline-cli'],
};
function normalizeRuntimeToken(value: string): string {
@@ -207,19 +196,12 @@ export function resolveRuntimeNameFromCandidates(...candidates: unknown[]): stri
* Mapping table (per the #1529 issue contract):
*
* claude → .claude/CLAUDE.md
* codex, opencode, kilo, kimi → AGENTS.md
* copilot → .github/copilot-instructions.md
* codex, opencode → AGENTS.md
* antigravity → GEMINI.md
* unknown / future runtimes → AGENTS.md (safe cross-agent default)
*
* Source-of-truth references for each runtime's read path:
* - copilot: GitHub Docs — repository-wide custom instructions are read ONLY
* from `.github/copilot-instructions.md`; a root `copilot-instructions.md`
* is not a read path. `AGENTS.md` is also read (agent instructions).
* https://docs.github.com/en/copilot/how-tos/configure-custom-instructions/add-repository-instructions
* (Installer parity: runtime-config-adapter-registry.cts installSurface
* 'copilot-instructions' writes the same `.github/copilot-instructions.md`.)
* - codex/opencode/kilo/kimi: AGENTS.md is the documented cross-agent
* - codex/opencode: AGENTS.md is the documented cross-agent
* instruction file (agentsmd/agents.md convention).
* - antigravity: GEMINI.md is Antigravity CLI's contextFileName (the Gemini
* CLI runtime that historically shared this file was removed — #1928;
@@ -228,12 +210,12 @@ export function resolveRuntimeNameFromCandidates(...candidates: unknown[]): stri
* Aliases are normalized via `canonicalizeRuntimeName` first, so inputs like
* `codex-cli` resolve to `codex` → `AGENTS.md`. Replaces the prior codex-only
* override in profile-output.cjs (#3163) which left AGENTS-native runtimes
* (opencode/kilo/kimi) incorrectly emitting `.claude/CLAUDE.md`. Pure: no I/O
* (opencode) incorrectly emitting `.claude/CLAUDE.md`. Pure: no I/O
* (the lazy `require` below reads a static generated module, not the disk).
*
* Descriptor-driven (ADR-1239 / #2096): antigravity's `GEMINI.md` is folded
* from a hardcoded `canonical === 'antigravity'` literal into a read of
* `runtime.hostBehaviors.projectInstructionFile`. claude/copilot stay
* `runtime.hostBehaviors.projectInstructionFile`. claude stays
* hardcoded (out of scope here) mirroring `getDirName` below, which already
* lazy-`require`s `capability-registry.cjs` inside the function body to
* avoid a circular dependency at module load.
@@ -242,14 +224,13 @@ export function getProjectInstructionFile(runtime: unknown): string {
assertNotRetiredRuntime(runtime);
const canonical = canonicalizeRuntimeName(runtime);
if (canonical === 'claude') return '.claude/CLAUDE.md';
if (canonical === 'copilot') return '.github/copilot-instructions.md';
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { runtimes } = require('./capability-registry.cjs') as {
runtimes: Record<string, { runtime?: { hostBehaviors?: { projectInstructionFile?: string } } } | undefined>;
};
const declared = canonical ? runtimes[canonical]?.runtime?.hostBehaviors?.projectInstructionFile : undefined;
if (typeof declared === 'string' && declared.length > 0) return declared;
// codex, opencode, kilo, kimi, AND unknown/future runtimes all default to
// codex, opencode, AND unknown/future runtimes all default to
// root AGENTS.md (the safe cross-agent instruction file).
return 'AGENTS.md';
}
@@ -277,7 +258,7 @@ export const NO_LOCAL_CONFIG_DIR_SENTINEL = '(no-local-config-dir)';
/**
* Map a canonical runtime id to its on-disk local config directory name
* (e.g. `cursor` -> `.cursor`, `windsurf` -> `.windsurf`). Unknown/empty inputs
* (e.g. `cursor` -> `.cursor`, `codex` -> `.codex`). Unknown/empty inputs
* fall back to `.claude`.
*
* #2103: a runtime whose descriptor declares `configHome.kind === 'none'`
@@ -317,14 +298,11 @@ export function getDirName(runtime: string): string {
* Collapses the two duplicated `runtimeLabel` assignment chains that previously
* lived inline in bin/install.js (ADR-1239 Phase B, #1679) — the add-a-host tax:
* a new runtime meant remembering to add a label line in BOTH chains, and they
* had drifted out of sync (uninstall omitted `cline` and used a different
* `kimi` value than install). This table is the curated canonical resolution:
* - kimi: install 'Kimi' / uninstall 'Kimi CLI' → 'Kimi CLI' (majority + descriptor title)
* - cline: install 'Cline' / uninstall (omitted) → 'Cline' (majority + descriptor title)
* had drifted out of sync. This table is the curated canonical resolution.
*
* Voice: these are the SHORT UI labels, intentionally distinct from the
* descriptor `title` (the long product name — e.g. "OpenAI Codex CLI",
* "GitHub Copilot") which serves documentation/registry display,
* descriptor `title` (the long product name — e.g. "OpenAI Codex CLI")
* which serves documentation/registry display,
* not the install console. A future slice may relocate this to a
* `runtime.label` descriptor field; until then this table is the source.
*
@@ -339,22 +317,10 @@ export function getDirName(runtime: string): string {
const RUNTIME_LABELS: Readonly<Record<string, string>> = {
claude: 'Claude Code',
opencode: 'OpenCode',
kilo: 'Kilo',
codex: 'Codex',
copilot: 'Copilot',
antigravity: 'Antigravity',
cursor: 'Cursor',
windsurf: 'Windsurf',
augment: 'Augment',
trae: 'Trae',
qwen: 'Qwen Code',
hermes: 'Hermes Agent',
kimi: 'Kimi CLI',
'kimi-code': 'Kimi Code',
codebuddy: 'CodeBuddy',
cline: 'Cline',
zcode: 'ZCode',
pi: 'pi',
// #2103: vscode is a registered (role:runtime) capability for validator +
// host-integration coverage, even though it is never CLI-installed (no
// --vscode flag — see NON_INSTALLABLE_RUNTIMES in tests/runtime-flags.test.cjs).
@@ -395,27 +361,10 @@ export function getRuntimeLabel(runtime: string): string {
*/
const DEFAULT_CONFIG_HOME_FRAGMENT = "'.claude'";
const GLOBAL_CONFIG_HOME_FRAGMENTS: Readonly<Record<string, string>> = {
copilot: "'.copilot'",
opencode: "'.config', 'opencode'",
kilo: "'.config', 'kilo'",
codex: "'.codex'",
cursor: "'.cursor'",
windsurf: "'.windsurf'",
augment: "'.augment'",
trae: "'.trae'",
qwen: "'.qwen'",
hermes: "'.hermes'",
codebuddy: "'.codebuddy'",
cline: "'.cline'",
kimi: "'.config', 'agents'",
'kimi-code': "'.kimi-code'",
zcode: "'.zcode'",
// pi's global config home is ~/.pi/agent (configHome: dot-home-nested,
// parent '.pi', name 'agent' — capabilities/pi/capability.json), matching
// resolveConfigHomeFromDescriptor's `path.join(home, parent, name)` for the
// no-probe dot-home-nested case (src/runtime-homes.cts). Two-segment
// path.join args, same shape as opencode/kilo/kimi above.
pi: "'.pi', 'agent'",
};
/**
@@ -438,22 +387,15 @@ export function getGlobalConfigHomeFragment(runtime: string): string {
* function declaration block (the add-a-host tax ADR-1239 Phase B / #1679 AC2
* removes).
*/
// #2094: 'trae' stays here — bin/install.js's agents-converter dispatch
// (convertClaudeAgentToTraeAgent selection) still reads isTrae directly.
// Removing it is gated on migrating that runtime-keyed `else if` chain to a
// cross-runtime agents-dispatch table (out of scope for #2094, which only
// folds the shared-hooks-install skip).
const RUNTIME_FLAG_IDS = Object.freeze([
'opencode', 'kilo', 'codex', 'copilot', 'antigravity', 'cursor',
'windsurf', 'augment', 'trae', 'qwen', 'hermes', 'codebuddy', 'cline', 'kimi', 'kimi-code', 'zcode', 'pi',
'opencode', 'codex', 'antigravity', 'cursor', 'zcode',
] as const);
/**
* Convert a runtime id (kebab-case, e.g. 'kimi-code') to its `is<Foo>` flag
* name in PascalCase (e.g. 'isKimiCode'). The first letter is capitalised and
* every `-[a-z]` boundary is folded to its uppercase twin. Single-word ids
* (the prior 16: opencode, kilo, codex, …) are unaffected — only hyphenated
* ids like 'kimi-code' (#2454) hit the folding branch.
* Convert a runtime id (kebab-case) to its `is<Foo>` flag name in PascalCase.
* The first letter is capitalised and every `-[a-z]` boundary is folded to
* its uppercase twin. Single-word ids (opencode, codex, …) are unaffected —
* only hyphenated ids hit the folding branch (#2454).
*/
function runtimeIdToFlagName(id: string): string {
return 'is' + id.charAt(0).toUpperCase() + id.slice(1).replace(/-([a-z])/g, (_m, c: string) => c.toUpperCase());
@@ -485,7 +427,6 @@ const DEFAULT_NEW_PROJECT_COMMAND = '/msd-new-project';
const RUNTIME_NEW_PROJECT_COMMANDS: Readonly<Record<string, string>> = {
codex: '$msd-new-project',
cursor: 'msd-new-project (mention the skill name)',
kimi: '/skill:msd-new-project',
};
export function getRuntimeNewProjectCommand(runtime: string): string {

View File

@@ -15,7 +15,7 @@
* which shape to emit.
*
* - codex: $msd-<cmd> (shell-var syntax)
* - claude, cursor, opencode, kilo, etc.: /msd-<cmd>
* - claude, cursor, opencode, etc.: /msd-<cmd>
*
* The colon form is never emitted.
*

View File

@@ -1013,7 +1013,7 @@ export function resolveMsdToolsPath(): string {
* single-family hub for its own narrow purpose. The ONLY dispatch path that
* covers the FULL family/subcommand surface is the msd-tools.cjs CLI itself.
* This mirrors the SUBPROCESS-REUSE precedent already established for the
* OpenCode/Kilo hook bridge (see .opencode/plugins/msd-core.js header:
* OpenCode hook bridge (see .opencode/plugins/msd-core.js header:
* "Architecture: SUBPROCESS REUSE ... spawns existing hook scripts as child
* processes") — the same pattern, applied to command dispatch instead of
* hook dispatch.

View File

@@ -8,7 +8,7 @@
* `configHome`. TODAY's behavior. Delegates to fs.
* - `sandboxed-storage` — VS Code web (no arbitrary FS). Seam: a
* host-supplied backend; fail-closed until Phase 5.
* - `session-log-append` — pi (JSONL session log). Seam: host-supplied
* - `session-log-append` — JSONL session log. Seam: host-supplied
* backend; fail-closed until Phase 5.
*
* `filesystem` is the default and reproduces today's IO byte-for-behavior

View File

@@ -451,24 +451,19 @@ function applySurface(runtimeConfigDir: string, layout: Layout, manifest: Map<st
try {
for (const kind of layout.kinds) {
let staged: string;
// #4211: kimi-agents is an AGENT kind — kimiAgentsKind.stage() forwards
// agentCtx into stageAgentsForRuntimeWithConverter exactly as agentsKind
// does, and createRuntimeArtifactInstallPlan hands every kind the
// context. Staging it bare here dropped the path-prefix rewrites and the
// attribution trailer from Kimi's generated subagents, and (under an
// unmodified `full` profile) staged only the skill-referenced subset the
// #4211: agent kinds forward agentCtx into
// stageAgentsForRuntimeWithConverter, and createRuntimeArtifactInstallPlan
// hands every kind the context. Staging bare here would drop the
// path-prefix rewrites and the attribution trailer, and (under an
// unmodified `full` profile) stage only the skill-referenced subset the
// install path stages with `skills: '*'`.
if (kind.kind === 'agents' || kind.kind === 'kimi-agents') {
if (kind.kind === 'agents') {
const agentProfile = _isUnmodifiedFull ? { ...resolved, skills: '*' as const } : resolved;
staged = kind.stage(agentProfile, agentCtx);
} else {
staged = kind.stage(resolved);
}
// #4211: kimi-agents takes the skill-body rewrite too —
// createRuntimeArtifactInstallPlan routes `skills` and `kimi-agents`
// through rewriteStagedSkillBodies together, so omitting it here left
// Kimi's surface-materialized prompts with unrewritten paths.
if (kind.kind === 'skills' || kind.kind === 'kimi-agents') {
if (kind.kind === 'skills') {
runtimeArtifactConversion.rewriteStagedSkillBodies(staged, {
runtime: layout.runtime,
configDir: layout.configDir,
@@ -535,10 +530,9 @@ function applySurface(runtimeConfigDir: string, layout: Layout, manifest: Map<st
* that match the prefix but satisfy NEITHER are treated as user-owned and
* preserved — this prevents data loss for user-created msd-* directories.
* A warning is written to stderr when such a dir is encountered.
* - Empty prefix (Hermes): dir name appears as a canonical skill stem in the
* manifest. User dirs not in the manifest are preserved. (Hermes does not
* yet stage third-party capability skills, so the marker check does not
* apply on this path.)
* - Empty prefix (nested skills/msd layout): dir name appears as a canonical
* skill stem in the manifest. User dirs not in the manifest are preserved.
* (No current runtime uses this path; the marker check does not apply.)
* - Empty prefix without manifest, or manifest not a Map: conservative; no
* dirs are removed.
*
@@ -547,8 +541,8 @@ function applySurface(runtimeConfigDir: string, layout: Layout, manifest: Map<st
*
* @param skillsDir directory that contains the msd-STEM sub-dirs
* @param retainedNames set of directory names to keep (e.g. 'msd-help')
* @param prefix MSD dir prefix, e.g. 'msd-' (or '' for Hermes)
* @param manifest optional; required for Hermes empty-prefix case
* @param prefix MSD dir prefix, e.g. 'msd-' (or '' for the nested layout)
* @param manifest optional; required for the empty-prefix case
* and for manifest-membership gate in prefixed case.
* Must be a Map; any other type is treated as missing.
*/
@@ -559,7 +553,7 @@ function pruneSkillDirs(skillsDir: string, retainedNames: Set<string>, prefix: s
// A non-Map manifest would throw on .keys(); treat it as absent and be conservative.
const safeManifest = (manifest instanceof Map) ? manifest : null;
// Build the canonical stem set from the manifest (used for both prefixed and Hermes paths).
// Build the canonical stem set from the manifest (used for both prefixed and empty-prefix paths).
// Deletion requires manifest membership — without a valid manifest, be conservative.
const canonicalStems = safeManifest
? new Set([...safeManifest.keys()].filter(k => !k.startsWith('_calls_agents_')))
@@ -618,14 +612,14 @@ function pruneSkillDirs(skillsDir: string, retainedNames: Set<string>, prefix: s
continue;
}
} else if (canonicalStems) {
// Hermes: MSD-owned iff the directory name appears in the canonical manifest.
// Empty prefix: MSD-owned iff the directory name appears in the canonical manifest.
isMsdOwned = canonicalStems.has(entry);
} else {
// No manifest available: be conservative, don't remove anything.
continue;
}
if (!isMsdOwned) continue; // Hermes path only: preserve user-owned dirs not in manifest
if (!isMsdOwned) continue; // empty-prefix path only: preserve user-owned dirs not in manifest
if (retainedNames.has(entry)) continue; // MSD-owned and in retain set
try {
fs.rmSync(entryPath, { recursive: true, force: true });
@@ -644,7 +638,7 @@ function pruneSkillDirs(skillsDir: string, retainedNames: Set<string>, prefix: s
* by copying recursively; remove dirs not in staged set. Preserves dirs not matching
* the prefix (user-owned skills). Pruning is delegated to pruneSkillDirs().
*
* For Hermes (empty prefix): uses manifest membership to discriminate MSD-owned vs
* For an empty prefix: uses manifest membership to discriminate MSD-owned vs
* user-owned dirs. MSD-owned = stem in manifest; removal targets = in manifest AND
* not in staged set. User-owned (not in manifest) are always preserved.
*/
@@ -658,52 +652,13 @@ function _syncMsdDir(stagedDir: string, destDir: string, kind: ArtifactKind | st
// #1575 / #2103: agent files are renamed .md -> <agentFileExtension> at copy
// time when the runtime's descriptor declares hostBehaviors.agentFileExtension
// (e.g. copilot's '.agent.md'), mirroring install-engine.cts's staged-copy
// loop (`_copyStaged`) — ONE descriptor read shared by both surfaces instead
// of a duplicated hardcoded `runtime === 'copilot'` literal. Other runtimes
// (no agentFileExtension declared) keep the staged filename verbatim.
// mirroring install-engine.cts's staged-copy loop (`_copyStaged`) — ONE
// descriptor read shared by both surfaces. Runtimes with no
// agentFileExtension declared (all of them today) keep the staged filename
// verbatim.
const _agentExt = runtime ? runtimeArtifactConversion.agentFileExtensionFor(runtime) : undefined;
const isRenamedAgents = !!_agentExt && kindName === 'agents';
if (kindName === 'kimi-agents') {
// #4211: Kimi's managed tree is `msd.yaml` + `msd.md` + `subagents/msd-*.{yaml,md}`
// (runtime-artifact-layout.cts kimiAgentsKind), and install copies it
// RECURSIVELY (_copyStaged in src/install-engine.cts). Surface apply fell
// through to the flat command/agent branch below, which reads only `*.md`
// at the top level: the YAML half and the whole subagents/ subtree were
// dropped, and `msd.md` was written as `msdmsd.md` (the flat branch
// re-applies kind.prefix to a name that already carries it). A surface
// change could therefore corrupt Kimi's installed artifacts while still
// reporting success.
fs.cpSync(stagedDir, destDir, { recursive: true });
// Prune MSD-owned files the new surface no longer stages, with exactly the
// ownership rule install's _removeMsdEntries applies to this kind: the two
// root files, and `msd-`-prefixed .yaml/.md under subagents/. Everything
// else in the directory is user-owned and is preserved.
const _rootStaged = new Set(fs.readdirSync(stagedDir));
for (const fileName of ['msd.yaml', 'msd.md']) {
if (!_rootStaged.has(fileName)) {
try { fs.rmSync(path.join(destDir, fileName), { force: true }); } catch { /* ignore */ }
}
}
const _stagedSubagentsDir = path.join(stagedDir, 'subagents');
const _destSubagentsDir = path.join(destDir, 'subagents');
const _stagedSubagents = fs.existsSync(_stagedSubagentsDir)
? new Set(fs.readdirSync(_stagedSubagentsDir))
: new Set<string>();
if (fs.existsSync(_destSubagentsDir)) {
for (const entry of fs.readdirSync(_destSubagentsDir, { withFileTypes: true })) {
if (!entry.isFile()) continue;
if (!entry.name.startsWith('msd-')) continue;
if (!entry.name.endsWith('.yaml') && !entry.name.endsWith('.md')) continue;
if (_stagedSubagents.has(entry.name)) continue;
try { fs.rmSync(path.join(_destSubagentsDir, entry.name), { force: true }); } catch { /* ignore */ }
}
}
return;
}
if (kindName === 'skills') {
// Skills kind: work with directories, not files.
// Each staged entry is a directory named ${prefix}${stem}.
@@ -725,7 +680,7 @@ function _syncMsdDir(stagedDir: string, destDir: string, kind: ArtifactKind | st
} else {
// commands / agents kind: mirror installRuntimeArtifacts (_copyStaged /
// _removeMsdEntries in bin/install.js) so surface produces the SAME files as a
// fresh install (#816). Flat command dirs (opencode/cursor/augment/kilo) take
// fresh install (#816). Flat command dirs (opencode/cursor) take
// the msd- prefix on copy; namespaced command dirs (commands/msd) and agents
// keep their staged names. Copying staged names verbatim previously diverged
// from install and orphaned the installed msd-*.md files, and the unscoped
@@ -751,7 +706,7 @@ function _syncMsdDir(stagedDir: string, destDir: string, kind: ArtifactKind | st
// Prune stale MSD-owned files not in the staged set, preserving user-owned files
// (mirrors install's prefix-scoped _removeMsdEntries):
// - agents: only msd-* are MSD-owned (copilot: msd-*.agent.md)
// - agents: only msd-* are MSD-owned
// - flat command dirs: only `${kindPrefix}`-prefixed are MSD-owned
// - namespaced command dirs: the whole dir is MSD-owned
const shouldPruneAgents = !(kindName === 'agents' && (!manifest || manifest.size === 0));

View File

@@ -26,10 +26,6 @@ export const RUNTIME_DIRS: RuntimeDirEntry[] = [
['antigravity', '.gemini/antigravity'],
['antigravity', '.agents'], // local Antigravity install dir canonical (#791; bin/install.js getDirName('antigravity'))
['antigravity', '.agent'], // local Antigravity install dir legacy (#503; backward-compat with pre-#791 installs)
['windsurf', '.windsurf'], // local Windsurf workflow dir canonical (#1615; bin/install.js getDirName('windsurf'))
['windsurf', '.devin'], // local Devin Desktop install dir legacy (#1085; backward-compat)
['kilo', '.config/kilo'],
['kilo', '.kilo'],
['codex', '.codex'],
];
@@ -84,8 +80,6 @@ export interface InferPreferredRuntimeOpts {
// Infer the preferred runtime from preferredConfigDir config files, then env.
export function inferPreferredRuntime({ fs, env, preferredConfigDir }: InferPreferredRuntimeOpts): string {
if (preferredConfigDir) {
if (fs.exists(path.join(preferredConfigDir, 'kilo.json')) ||
fs.exists(path.join(preferredConfigDir, 'kilo.jsonc'))) return 'kilo';
if (fs.exists(path.join(preferredConfigDir, 'opencode.json')) ||
fs.exists(path.join(preferredConfigDir, 'opencode.jsonc'))) return 'opencode';
if (fs.exists(path.join(preferredConfigDir, CODEX_CONFIG_MARKER))) return 'codex';
@@ -96,7 +90,6 @@ export function inferPreferredRuntime({ fs, env, preferredConfigDir }: InferPref
}
if (env['CODEX_HOME']) return 'codex';
if (env['ANTIGRAVITY_CONFIG_DIR']) return 'antigravity';
if (env['KILO_CONFIG_DIR'] || env['KILO_CONFIG']) return 'kilo';
if (env['OPENCODE_CONFIG_DIR'] || env['OPENCODE_CONFIG']) return 'opencode';
if (env['CLAUDE_CONFIG_DIR']) return 'claude';
return '';
@@ -113,9 +106,6 @@ export function envRuntimeDirs({ env, home }: EnvRuntimeDirsOpts): RuntimeDirEnt
const ex = (v: string | undefined) => expandHome(v, home);
if (env['CLAUDE_CONFIG_DIR']) out.push(['claude', ex(env['CLAUDE_CONFIG_DIR'])]);
if (env['ANTIGRAVITY_CONFIG_DIR']) out.push(['antigravity', ex(env['ANTIGRAVITY_CONFIG_DIR'])]);
if (env['KILO_CONFIG_DIR']) out.push(['kilo', ex(env['KILO_CONFIG_DIR'])]);
else if (env['KILO_CONFIG']) out.push(['kilo', path.dirname(ex(env['KILO_CONFIG']))]);
else if (env['XDG_CONFIG_HOME']) out.push(['kilo', path.join(ex(env['XDG_CONFIG_HOME']), 'kilo')]);
if (env['OPENCODE_CONFIG_DIR']) out.push(['opencode', ex(env['OPENCODE_CONFIG_DIR'])]);
else if (env['OPENCODE_CONFIG']) out.push(['opencode', path.dirname(ex(env['OPENCODE_CONFIG']))]);
else if (env['XDG_CONFIG_HOME']) out.push(['opencode', path.join(ex(env['XDG_CONFIG_HOME']), 'opencode')]);