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:
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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).
|
||||
*
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'];
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
@@ -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).
|
||||
*
|
||||
|
||||
@@ -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).
|
||||
*
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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().
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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')]);
|
||||
|
||||
Reference in New Issue
Block a user