feat(#2093): migrate Kilo onto EoS imperative adapter + hook-bus/model/MCP/dispatch upgrades (ADR-1239)

Fold remaining isKilo logic branches into descriptor-driven reads:
finishPermissionWriter (uninstall cleanup), skipSharedHooksInstall (hooks
copy), and a skills converter-name registry (the artifactLayout.converter
field is now load-bearing, not decorative). frontmatterDialect stays the
documented dispatch key for frontmatter (no descriptor field for it). Dead
isKilo destructure bindings removed. Byte-identical golden parity for all 16
runtimes (opencode, which shares kilo's combined-family path, verified clean).

UPGRADE 1 (hook bus): install .kilo/plugins/gsd-core.js native plugin +
extensionEvents:"kilo" + EXTENSION_EVENT_SURFACES.kilo (OpenCode-fork bus).
UPGRADE 2 (active model): populate runtimeTierDefaults.kilo + thread
modelOverride through convertClaudeToKiloFrontmatter — model no longer stripped
from agents. UPGRADE 3 (MCP): document the gsd-core MCP companion under kilo's
mcp config key. UPGRADE 4 (named dispatch): agents/*.md mode:subagent roster is
the Task-tool dispatch surface (tested); subagentToolkit stays 'undocumented'
per AC so dispatch degrades to 'degraded' by design.

Model-catalog single-source edit ripples the shared model-catalog.json hash
into all 16 golden fixtures (expected). Inline defect fixes (no-defer): stale-
bake-guard resolveAgentDir 'agent'->'agents' (was a silent no-op for opencode/
codex), hardcoded 'Removed OpenCode plugin' uninstall log -> generic, and the
connect-gsd-mcp-server.md OpenCode mcpServers->mcp doc error.

Tests: kilo-imperative-reference (adapter/axes/fail-closed/degradation/
hostBehaviors + widened isKilo source-grep across 4 modules) + kilo-upgrades
(plugin parity+load, model-override converter, agents dispatch surface, MCP
doc). Matrix + how-to + config docs updated; changeset added.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-07-10 17:40:50 -04:00
parent 61d04daacf
commit f014ec83bd
41 changed files with 1666 additions and 117 deletions

View File

@@ -0,0 +1,5 @@
---
type: Changed
pr: 2156
---
**Kilo Code now supports native hooks, active-model routing, and named subagent dispatch** — installing GSD into Kilo wires a lifecycle-hook plugin, keeps each agent's requested model instead of dropping it, projects GSD's specialist agents as invokable subagents, and documents the GSD MCP companion. Kilo's install is driven by its negotiated capability descriptor instead of hardcoded runtime special-cases. (#2093)

731
.kilo/plugins/gsd-core.js Normal file
View File

@@ -0,0 +1,731 @@
/**
* GSD plugin for OpenCode.ai (CommonJS)
*
* Architecture: SUBPROCESS REUSE. Instead of re-implementing hook logic inside
* the plugin, this file is a thin adapter that spawns the existing Claude Code
* hook scripts under hooks/ as child processes. The hooks speak a stable
* protocol (JSON on stdin, JSON + exit code on stdout); this adapter:
* 1. Translates OpenCode plugin events into Claude Code hook payloads
* 2. Spawns `node <HOOKS_DIR>/<hook>.js` with the payload on stdin
* 3. Translates hook output back into OpenCode semantics
* - block → throw Error (OpenCode returns the error to the model)
* - advisory → output.metadata + console.error (best-effort surfacing)
*
* Namespace conversion (/gsd:xxx → /gsd-xxx) reuses scripts/fix-slash-commands.cjs
* via require(), keeping the single source of truth.
*
* ── Two distribution shapes, one adapter (issue #1914) ─────────────────────
* This single file serves both distribution paths, distinguished at load time
* by REPO_ROOT (path.resolve(__dirname, "../..")):
*
* • Option 1 — file copy (the supported GSD path). `bin/install.js` copies
* this file to <opencodeConfigDir>/plugins/gsd-core.js, so REPO_ROOT is the
* OpenCode config dir. GSD's own install already stages `hooks/*.js` and
* `gsd-core/` there (ADR-857 skips hook *registration* for OpenCode, not the
* file copy), so the hook bridge and content rewriting resolve natively.
* Commands/agents/skills are ALREADY registered by GSD's native file copy in
* this mode, so the plugin's own config-hook registration is redundant and is
* SKIPPED (see IS_PACKAGE_TREE) to avoid double-registration.
*
* • Option 2 — package / git-spec. When loaded from the package tree (npm
* `main`, or an OpenCode git-spec install), REPO_ROOT is the package root and
* the source layout (commands/gsd/, agents/, skills/) is present. Here the
* plugin IS the sole registrar, so it registers commands/agents/skills too.
*
* IS_PACKAGE_TREE keys off the presence of the SOURCE command layout
* (commands/gsd/), which only exists in the package tree — never in an installed
* config dir (that uses the flattened command/ layout). The hook bridge and
* Read-time content rewriting run in BOTH modes; only the config-hook
* registration of commands/agents/skills is gated.
*
* Runtime-specific hooks are deliberately excluded:
* - gsd-statusline.js / gsd-update-banner.js (Claude Code statusline)
* - gsd-cursor-*.js (Cursor-specific)
* - *.sh scripts (invoked directly by commands/agents, not hook events)
*/
"use strict";
const path = require("path");
const fs = require("fs");
const os = require("os");
const { spawnSync } = require("child_process");
// Resolve REPO_ROOT to the directory that actually holds the GSD payload
// (hooks/ + gsd-core/). This must work across three physical layouts because a
// single adapter file serves both distribution shapes (see header):
// • package/git-spec tree: <root>/.opencode/plugins/gsd-core.js → <root>
// • global file-copy: ~/.config/opencode/plugins/gsd-core.js → ~/.config/opencode
// • local file-copy: <proj>/.opencode/plugins/gsd-core.js → <proj>/.opencode
// A fixed "../.." only works for the first; the copied layouts sit one level
// shallower. Walking up to the first ancestor containing BOTH payload markers
// resolves all three deterministically. Falls back to the package-tree
// assumption ("../..") if no ancestor matches (keeps graceful degradation).
function resolveRepoRoot(startDir) {
let dir = startDir;
for (let i = 0; i < 6; i++) {
if (
fs.existsSync(path.join(dir, "hooks")) &&
fs.existsSync(path.join(dir, "gsd-core"))
) {
return dir;
}
const parent = path.dirname(dir);
if (parent === dir) break; // filesystem root
dir = parent;
}
// No ancestor carried both markers (broken/partial layout — the plugin can't
// function regardless). Fall back to the package-tree assumption ("../.."),
// matching the historical fixed-depth behavior and the .opencode/plugins/
// source layout.
return path.resolve(startDir, "../..");
}
// CJS: __dirname is a global, no need to derive from import.meta.url
const REPO_ROOT = resolveRepoRoot(__dirname);
const HOOKS_DIR = path.join(REPO_ROOT, "hooks");
const COMMANDS = path.join(REPO_ROOT, "commands", "gsd");
const AGENTS = path.join(REPO_ROOT, "agents");
const SKILLS = path.join(REPO_ROOT, "skills");
const GSD_CORE = path.join(REPO_ROOT, "gsd-core");
// True only when loaded from the package/source tree (Option 2), detected by the
// presence of the SOURCE command layout (commands/gsd/). In an installed OpenCode
// config dir (Option 1) this directory is absent — the flattened command/ layout
// is used instead — so the plugin skips its own command/agent/skill registration
// and lets GSD's native file copy own that surface (avoids double-registration).
const IS_PACKAGE_TREE = fs.existsSync(COMMANDS);
// ---------------------------------------------------------------------------
// Namespace conversion — reuse the single source of truth
// ---------------------------------------------------------------------------
let _cmdNames = null;
let _transformFn = null;
/**
* Lazily load scripts/fix-slash-commands.cjs and cache the transform function
* + command name list. Returns null if the module is unavailable (the plugin
* still works, just without namespace conversion).
*/
function getNamespaceConverter() {
if (_transformFn) return _transformFn;
try {
const mod = require(
path.join(REPO_ROOT, "scripts", "fix-slash-commands.cjs"),
);
_cmdNames = mod.readCmdNames();
_transformFn = mod.transformContentToHyphen;
return _transformFn;
} catch {
return null;
}
}
// ---------------------------------------------------------------------------
// Session state — tracked across plugin hook invocations
// ---------------------------------------------------------------------------
let currentSessionId = null;
let currentCwd = process.cwd();
// ---------------------------------------------------------------------------
// Tool name / argument mapping (OpenCode ↔ Claude Code)
// ---------------------------------------------------------------------------
const TOOL_NAME_MAP = {
read: "Read",
write: "Write",
edit: "Edit",
apply_patch: "MultiEdit",
multi_edit: "MultiEdit",
bash: "Bash",
webfetch: "WebFetch",
web_search: "WebSearch",
websearch: "WebSearch",
task: "Task",
subagent: "Task",
};
function mapToolName(tool) {
if (!tool) return "";
return TOOL_NAME_MAP[String(tool).toLowerCase()] || tool;
}
// Build a Claude-style `tool_input` object from OpenCode's `output.args`.
function mapToolInput(args) {
const input = {};
if (!args || typeof args !== "object") return input;
// File-path keys (OpenCode uses filePath/path; Claude uses file_path)
const filePath = args.filePath || args.path || args.file_path;
if (filePath) input.file_path = filePath;
// Content for Write
if (args.content !== undefined) input.content = args.content;
// Edit patch fields
if (args.new_string !== undefined) input.new_string = args.new_string;
if (args.newString !== undefined) input.new_string = args.newString;
if (args.old_string !== undefined) input.old_string = args.old_string;
if (args.oldString !== undefined) input.old_string = args.oldString;
// Bash command
if (args.command !== undefined) input.command = args.command;
// Web
if (args.url !== undefined) input.url = args.url;
if (args.query !== undefined) input.query = args.query;
return input;
}
// ---------------------------------------------------------------------------
// Hook subprocess runner
// ---------------------------------------------------------------------------
/**
* Spawn a Claude Code hook script and pipe a JSON payload to its stdin.
*
* Hooks follow the convention:
* - stdout: JSON object (decision/advisory) or empty
* - exit 0: allow (with optional advisory JSON on stdout)
* - exit 2: block (Claude convention; reason in stdout JSON)
* - any error: exit 0 silently (hooks swallow their own errors)
*
* @param {string} hookFile filename under hooks/, e.g. "gsd-prompt-guard.js"
* @param {object} payload stdin JSON (hook_event_name, tool_name, ...)
* @param {object} [opts]
* @param {number} [opts.timeout=8000] spawn timeout in ms
* @param {string} [opts.cwd] working directory for the child
* @returns {{ stdout: string, exitCode: number, timedOut: boolean }}
*/
function runHook(hookFile, payload, opts = {}) {
const hookPath = path.join(HOOKS_DIR, hookFile);
if (!fs.existsSync(hookPath)) {
return { stdout: "", exitCode: 0, timedOut: false };
}
const timeout = opts.timeout ?? 8000;
let result;
try {
result = spawnSync(process.execPath, [hookPath], {
input: JSON.stringify(payload),
encoding: "utf8",
timeout,
cwd: opts.cwd || currentCwd,
windowsHide: true,
});
} catch {
// Spawn failure — never break the tool call
return { stdout: "", exitCode: 0, timedOut: false };
}
const stdout = (result.stdout || "").trim();
const exitCode = result.status == null ? 0 : result.status;
return { stdout, exitCode, timedOut: result.signal === "SIGTERM" };
}
// ---------------------------------------------------------------------------
// Hook output translation → OpenCode semantics
// ---------------------------------------------------------------------------
/**
* Parse a hook's stdout and apply its effect to the OpenCode output object.
*
* - Block → throw Error(parsed.reason) so OpenCode aborts the tool call
* - Advisory→ append to output.metadata._gsdAdvisory[] and log to stderr
* - Silent → no-op
*
* @param {{ stdout: string, exitCode: number }} hookResult
* @param {object} [output] OpenCode mutable output object (optional)
*/
function handleHookResult(hookResult, output) {
const { stdout, exitCode } = hookResult;
if (!stdout && exitCode !== 2) return; // silent allow
let parsed = null;
if (stdout) {
try {
parsed = JSON.parse(stdout);
} catch {
// Non-JSON stdout (e.g. a stray log) — treat exit 2 as hard block, else allow
}
}
// Block: explicit decision OR Claude exit-code-2 convention
const isBlock = exitCode === 2 || (parsed && parsed.decision === "block");
if (isBlock) {
const reason =
(parsed && parsed.reason) || "Blocked by GSD hook (no reason provided).";
throw new Error(reason);
}
// Advisory: inject additionalContext into metadata + log
const advisory =
parsed &&
parsed.hookSpecificOutput &&
parsed.hookSpecificOutput.additionalContext;
if (advisory) {
if (output) {
output.metadata = output.metadata || {};
// Accumulate: a single tool call can run several advisory hooks in
// sequence (prompt guard, read guard, worktree guard, workflow guard).
// Storing a scalar would let a later advisory clobber an earlier one, so
// collect them all.
if (!Array.isArray(output.metadata._gsdAdvisory)) {
output.metadata._gsdAdvisory = [];
}
output.metadata._gsdAdvisory.push(advisory);
}
// Best-effort visibility when metadata isn't surfaced to the model
console.error(advisory);
}
}
// ---------------------------------------------------------------------------
// Frontmatter helpers (for config registration)
// ---------------------------------------------------------------------------
function parseFrontmatter(content) {
const m = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
if (!m) return { frontmatter: {}, body: content };
const fm = {};
for (const line of m[1].split("\n")) {
const i = line.indexOf(":");
if (i > 0) {
let v = line.slice(i + 1).trim();
if (v.startsWith('"') && v.endsWith('"')) v = v.slice(1, -1);
fm[line.slice(0, i).trim()] = v;
}
}
return { frontmatter: fm, body: m[2] };
}
// Rewrite @~/.claude/ includes to point at the repo root.
// Also applies /gsd:xxx → /gsd-xxx namespace conversion via the shared
// transform from scripts/fix-slash-commands.cjs (single source of truth).
function rewriteRefs(content) {
let out = content.replace(/@~\/\.claude\//g, `@${REPO_ROOT}/`);
const transform = getNamespaceConverter();
if (transform && _cmdNames && _cmdNames.length) {
out = transform(out, _cmdNames);
}
return out;
}
function loadDir(dir, keyFn, valFn) {
const result = {};
if (!fs.existsSync(dir)) return result;
for (const f of fs.readdirSync(dir).filter((f) => f.endsWith(".md"))) {
const raw = fs.readFileSync(path.join(dir, f), "utf8");
const { frontmatter, body } = parseFrontmatter(raw);
result[keyFn(f)] = valFn(body, frontmatter, f);
}
return result;
}
// ---------------------------------------------------------------------------
// Runtime content transform — for Read tool results on GSD-managed files
// ---------------------------------------------------------------------------
// Directories whose .md files may contain ~/.claude/ paths and gsd: namespace
// refs. When the model reads these via the Read tool, we transparently rewrite
// both so OpenCode sees correct paths and hyphen-form command names.
const GSD_MANAGED_DIRS = [
path.join(GSD_CORE, "workflows"),
path.join(GSD_CORE, "references"),
path.join(GSD_CORE, "templates"),
path.join(GSD_CORE, "contexts"),
COMMANDS,
AGENTS,
SKILLS,
];
function isGsdManagedFile(filePath) {
if (!filePath) return false;
const resolved = path.resolve(filePath);
return GSD_MANAGED_DIRS.some(
(dir) => resolved === dir || resolved.startsWith(dir + path.sep),
);
}
// Rewrite content for OpenCode consumption:
// 1. @-include paths: @~/.claude/ → @<REPO_ROOT>/
// 2. plain-text paths: ~/.claude/gsd-core/ → <GSD_CORE>/
// 3. namespace: gsd:xxx → gsd-xxx (via fix-slash-commands.cjs)
function rewriteContent(content) {
let out = content;
out = out.replace(/@~\/\.claude\//g, `@${REPO_ROOT}/`);
out = out.replace(/~\/\.claude\/gsd-core\//g, `${GSD_CORE}/`);
const transform = getNamespaceConverter();
if (transform && _cmdNames && _cmdNames.length) {
out = transform(out, _cmdNames);
}
return out;
}
// ---------------------------------------------------------------------------
// Skills cache — copy SKILL.md files with rewritten @-include paths
// ---------------------------------------------------------------------------
//
// OpenCode's skill loader reads SKILL.md files directly from disk and resolves
// @-includes internally — this bypasses our tool.execute hooks. To make
// @~/.claude/gsd-core/... includes resolve, we copy all SKILL.md files to a
// cache directory with paths rewritten to the actual GSD_CORE location.
//
// Only used in package-tree mode (Option 2). In an installed OpenCode config
// dir (Option 1) skills are already staged + registered by GSD's native file
// copy, so we never register skills from the plugin (see IS_PACKAGE_TREE).
const SKILLS_CACHE = path.join(
os.homedir(),
".cache",
"opencode",
"gsd-skills",
);
function prepareSkillsCache() {
if (!fs.existsSync(SKILLS)) return null;
fs.mkdirSync(SKILLS_CACHE, { recursive: true });
for (const dir of fs.readdirSync(SKILLS)) {
const srcFile = path.join(SKILLS, dir, "SKILL.md");
if (!fs.existsSync(srcFile)) continue;
const raw = fs.readFileSync(srcFile, "utf8");
// Rewrite @-include paths only; namespace conversion is handled at
// Read-time via tool.execute.after for workflow/reference files.
const rewritten = raw
.replace(/@~\/\.claude\/gsd-core\//g, `@${GSD_CORE}/`)
.replace(/~\/\.claude\/gsd-core\//g, `${GSD_CORE}/`);
const destDir = path.join(SKILLS_CACHE, dir);
fs.mkdirSync(destDir, { recursive: true });
fs.writeFileSync(path.join(destDir, "SKILL.md"), rewritten);
}
return SKILLS_CACHE;
}
// ===========================================================================
// Plugin entry
// ===========================================================================
const GsdCorePlugin = async ({ directory } = {}) => {
if (directory) currentCwd = directory;
return {
// ── Config: register commands / agents / skills paths ──────────────
// Only in package-tree mode (Option 2). In an installed config dir
// (Option 1) GSD's native file copy already registered these, so the
// plugin stays out of registration to avoid double-registering.
config: async (config) => {
if (!IS_PACKAGE_TREE) return;
// Commands (commands/gsd/*.md → gsd-<name>)
config.command = config.command || {};
const cmds = loadDir(
COMMANDS,
(f) => "gsd-" + f.slice(0, -3),
(body, fm, name) => ({
template: rewriteRefs(body.trim()),
description: fm.description || `GSD ${name.slice(0, -3)} command`,
}),
);
for (const [k, v] of Object.entries(cmds)) {
if (!config.command[k]) config.command[k] = v;
}
// Agents (agents/*.md)
config.agent = config.agent || {};
const agents = loadDir(
AGENTS,
(f) => f.slice(0, -3),
(body, fm, name) => ({
prompt: rewriteRefs(body.trim()),
description: fm.description || `GSD ${name.slice(0, -3)} agent`,
mode: fm.mode || "subagent",
}),
);
for (const [k, v] of Object.entries(agents)) {
if (!config.agent[k]) config.agent[k] = v;
}
// Skills — copy SKILL.md files to cache with rewritten @-include paths,
// then register the cache directory. OpenCode's skill loader reads
// SKILL.md from disk and resolves @-includes internally (bypassing our
// tool.execute hooks), so we must pre-process the files.
const skillsCache = prepareSkillsCache();
config.skills = config.skills || {};
config.skills.paths = config.skills.paths || [];
const skillsPath = skillsCache || SKILLS;
if (!config.skills.paths.includes(skillsPath)) {
config.skills.paths.push(skillsPath);
}
},
// ── shell.env ───────────────────────────────────────────────────────
"shell.env": async (_input, output) => {
output.env = output.env || {};
output.env.GSD_DIR = GSD_CORE;
},
// ── tool.execute.before — PreToolUse hooks ─────────────────────────
"tool.execute.before": async (input, output) => {
const claudeTool = mapToolName(input.tool);
const toolInput = mapToolInput(output.args || {});
const cwd = currentCwd;
// 0. Read path rewrite — redirect ~/.claude/gsd-core/ to actual GSD_CORE
// so the model can read workflow/reference/template files that SKILL.md
// and command templates reference via the canonical Claude path.
if (claudeTool === "Read" && toolInput.file_path) {
const original = toolInput.file_path;
const rewritten = original
.replace(/^~\/\.claude\/gsd-core\//, GSD_CORE + "/")
.replace(/(?:.*)\/\.claude\/gsd-core\//, GSD_CORE + "/");
if (rewritten !== original) {
const args = output.args || {};
if (args.filePath) args.filePath = rewritten;
else if (args.path) args.path = rewritten;
else if (args.file_path) args.file_path = rewritten;
else args.filePath = rewritten;
}
}
const basePayload = {
hook_event_name: "PreToolUse",
cwd,
};
// NOTE: session_id intentionally omitted for PreToolUse hooks.
// gsd-read-guard.js treats a non-empty session_id as a Claude Code
// session and skips its advisory. On OpenCode we WANT the advisory.
const prePayload = (overrides = {}) => ({
...basePayload,
tool_name: claudeTool,
tool_input: toolInput,
...overrides,
});
const isWriteLike = ["Write", "Edit", "MultiEdit"].includes(claudeTool);
// 1. gsd-prompt-guard.js — injection scan on .planning/ writes
if (claudeTool === "Write" || claudeTool === "Edit") {
const r = runHook("gsd-prompt-guard.js", prePayload());
handleHookResult(r, output);
}
// 2. gsd-read-guard.js — read-before-edit advisory
if (claudeTool === "Write" || claudeTool === "Edit") {
const r = runHook("gsd-read-guard.js", prePayload());
handleHookResult(r, output);
}
// 3. gsd-worktree-path-guard.js — hard-block edits outside worktree
if (isWriteLike) {
const r = runHook("gsd-worktree-path-guard.js", prePayload());
handleHookResult(r, output);
}
// 4. gsd-workflow-guard.js — workflow advisory + git-force-add block
// (covers Write/Edit/MultiEdit AND Bash force-add detection)
if (isWriteLike || claudeTool === "Bash") {
const r = runHook("gsd-workflow-guard.js", prePayload());
handleHookResult(r, output);
}
},
// ── tool.execute.after — PostToolUse hooks ─────────────────────────
"tool.execute.after": async (input, output) => {
const claudeTool = mapToolName(input.tool);
// NOTE: In the `after` hook, `args` lives on `input` (not `output`).
// The `output` object only has { title, output, metadata }.
const toolInput = mapToolInput(input.args || {});
const cwd = currentCwd;
// GSD content transform — rewrite paths + namespace in Read results
// BEFORE injection scanning so the scanner sees the final content.
if (
claudeTool === "Read" &&
output.output &&
isGsdManagedFile(toolInput.file_path)
) {
const content =
typeof output.output === "string"
? output.output
: String(output.output);
output.output = rewriteContent(content);
}
// gsd-read-injection-scanner.js — scan Read/WebFetch/WebSearch results
if (
claudeTool === "Read" ||
claudeTool === "WebFetch" ||
claudeTool === "WebSearch"
) {
const payload = {
hook_event_name: "PostToolUse",
tool_name: claudeTool,
tool_input: toolInput,
tool_response: output.output,
cwd,
};
const r = runHook("gsd-read-injection-scanner.js", payload);
handleHookResult(r, output);
return;
}
// gsd-context-monitor.js — context usage warnings (Bash/Edit/Write/Task/...)
// Only meaningful when a session_id is tracked (writes metrics sentinel).
if (currentSessionId) {
const payload = {
hook_event_name: "PostToolUse",
tool_name: claudeTool,
tool_input: toolInput,
session_id: currentSessionId,
cwd,
};
const r = runHook("gsd-context-monitor.js", payload);
handleHookResult(r, output);
}
},
// ── experimental.session.compacting — PreCompact ───────────────────
"experimental.session.compacting": async (_input, output) => {
if (!currentSessionId) return;
const payload = {
hook_event_name: "PreCompact",
session_id: currentSessionId,
cwd: currentCwd,
};
const r = runHook("gsd-context-monitor.js", payload);
handleHookResult(r, output);
// Also inject a GSD compaction breadcrumb (mirrors the original plugin)
output.context = output.context || [];
output.context.push(
`[GSD] Active session: ${currentSessionId}. Preserve any in-flight phase/plan state.`,
);
},
// ── General event subscriptions ─────────────────────────────────────
event: async ({ event }) => {
// session.created → SessionStart hooks
if (event.type === "session.created") {
// Track session for context-monitor payloads.
// SDK type EventSessionCreated: { properties: { info: Session } }
// Session has `id` and `directory` (not `cwd`).
const info = event.properties?.info;
currentSessionId =
info?.id || event.sessionID || event.session_id || null;
if (info?.directory) currentCwd = info.directory;
// gsd-ensure-canonical-path.js — no stdin dependency; silent
runHook("gsd-ensure-canonical-path.js", {
hook_event_name: "SessionStart",
session_id: currentSessionId,
cwd: currentCwd,
});
// gsd-check-update.js — spawns its own background worker; no stdin
runHook("gsd-check-update.js", {
hook_event_name: "SessionStart",
session_id: currentSessionId,
cwd: currentCwd,
});
return;
}
// file.edited → FileChanged hook (config.json reload)
if (event.type === "file.edited") {
// SDK type EventFileEdited: { properties: { file: string } }
const filePath = event.properties?.file || event.filePath || "";
if (!filePath.endsWith("config.json")) return;
const cwd = event.properties?.cwd || currentCwd;
const expected = path.join(cwd, ".planning", "config.json");
if (path.resolve(filePath) !== path.resolve(expected)) return;
const payload = {
hook_event_name: "FileChanged",
file_path: filePath,
event: "change",
cwd,
};
const r = runHook("gsd-config-reload.js", payload);
// Advisory-only (additionalContext); surface to logs
handleHookResult(r);
return;
}
// session.idle ↔ Claude Stop lifecycle point (#1682 Slice 1b/c).
// OpenCode fires session.idle when the run quiesces. GSD maps it to the
// Stop equivalent — the opencode-subset lifecycle peer of compaction
// (compaction preserves state across context-window summarization; idle
// marks end-of-turn). No-op sentinel today (GSD state is already
// persisted to .planning/), but it MUST be recognized so the declared
// opencode-subset surface is fully wired and a future Stop-class hook can
// attach without a plugin change.
if (event.type === "session.idle") {
return;
}
// permission.asked / permission.replied — OpenCode permission lifecycle
// (#2087, opencode.ai/docs/plugins). GSD gates tool INPUTS at
// tool.execute.before (read-guard, injection-scanner); the permission
// grant/deny decision itself carries no GSD workflow-phase contribution,
// so these are recognized sentinels — wired so a future permission-aware
// gate can attach without a plugin change (the engine owns phase
// sequencing; this host bus is session/tool/permission-scoped, never
// phase-scoped — ADR-1239 §OpenCode).
if (event.type === "permission.asked" || event.type === "permission.replied") {
return;
}
// session.error — OpenCode session-error lifecycle point (#2087). No GSD
// hook fires here today (loop state is already persisted to .planning/);
// recognized so the declared extension-event surface is fully wired and a
// future error-class hook can attach without a plugin change.
if (event.type === "session.error") {
return;
}
},
};
};
// Export shape — verified against OpenCode's plugin loader source
// (packages/opencode/src/plugin). The loader imports this module and runs
// `for (const entry of Object.values(mod)) { getServerPlugin(entry) }`, where
// `getServerPlugin` accepts a bare function OR an object exposing a `.server`
// function, and THROWS `TypeError("Plugin export is not a function")` for
// anything else. So EVERY enumerable value the loader iterates must be a
// function or an object with `.server`.
//
// The subtlety: depending on how OpenCode's runtime (Node or Bun) imports a
// CommonJS file, `mod` may be the raw `module.exports` OR an ESM namespace of
// the form `{ default: module.exports, ...syntheticNamedExports }`. A plain
// `module.exports = { id: "gsd-core", server }` literal risks a string `id`
// appearing in `Object.values(mod)` (as a raw property, or as a lexer-
// synthesized named export) — which would trip the throw. Two defenses:
// 1. `id` is defined NON-ENUMERABLE, so it never appears in Object.values yet
// stays readable (via property access) for the loader's identity/dedup.
// 2. `module.exports` is assigned from a VARIABLE (not an object literal), so
// cjs-module-lexer cannot statically synthesize named exports from it —
// only `default` is exposed under ESM/Bun interop.
// Result: raw-CJS `Object.values` = `[server]`; ESM `Object.values` =
// `[{server, <id non-enum>}]` — both fully extractable. Test-only helpers hang
// off the `server` FUNCTION (`server._internals`), never as a sibling export.
GsdCorePlugin._internals = {
REPO_ROOT,
IS_PACKAGE_TREE,
mapToolName,
mapToolInput,
parseFrontmatter,
rewriteContent,
isGsdManagedFile,
handleHookResult,
GsdCorePlugin,
};
const gsdCorePluginExport = { server: GsdCorePlugin };
Object.defineProperty(gsdCorePluginExport, "id", {
value: "gsd-core",
enumerable: false,
writable: false,
configurable: false,
});
module.exports = gsdCorePluginExport;

View File

@@ -6077,7 +6077,12 @@ function convertClaudeToOpencodeFrontmatter(content, { isAgent = false, modelOve
}
// Kilo CLI — same conversion logic as OpenCode, different config paths.
function convertClaudeToKiloFrontmatter(content, { isAgent = false } = {}) {
// DEFECT.GENERATIVE-FIX: this body is mirrored in
// src/runtime-artifact-conversion.cts's convertClaudeToKiloFrontmatter (used by
// src/install-engine.cts's install path). Neither copy re-exports the other —
// mirror any behavior change into both. Guarded by the output-parity test in
// tests/runtime-converters.test.cjs (#2093).
function convertClaudeToKiloFrontmatter(content, { isAgent = false, modelOverride = null } = {}) {
// Replace tool name references in content (applies to all files)
let convertedContent = content;
convertedContent = convertedContent.replace(/\bAskUserQuestion\b/g, 'question');
@@ -6236,6 +6241,13 @@ function convertClaudeToKiloFrontmatter(content, { isAgent = false } = {}) {
// For agents: add required Kilo agent fields
if (isAgent) {
newLines.push('mode: subagent');
// Embed model override from ~/.gsd/defaults.json so model_overrides is
// respected on Kilo (which uses static agent frontmatter, not inline
// Task() model parameters) — mirrors convertClaudeToOpencodeFrontmatter's
// model emission exactly (#2093 UPGRADE 2 / ADR-1239). See #2256.
if (modelOverride) {
newLines.push(['model:', modelOverride].join(' '));
}
newLines.push(...buildKiloAgentPermissionBlock(agentTools));
}
@@ -6788,7 +6800,10 @@ const GSD_UNINSTALL_HOOKS = [
* @param {string} runtime - Target runtime ('claude', 'opencode', 'codex', 'copilot')
*/
function uninstall(isGlobal, runtime = DEFAULT_RUNTIME) {
const { isOpencode, isKilo, isCodex, isCopilot, isAntigravity, isCursor, isWindsurf, isAugment, isTrae, isQwen, isHermes, isCodebuddy, isCline, isKimi } = runtimeFlags(runtime);
// #2093: isKilo dropped — the Kilo permission-cleanup branch below is
// descriptor-driven (resolveInstallPlan(runtime).finishPermissionWriter),
// not gated on this flag.
const { isOpencode, isCodex, isCopilot, isAntigravity, isCursor, isWindsurf, isAugment, isTrae, isQwen, isHermes, isCodebuddy, isCline, isKimi } = runtimeFlags(runtime);
const dirName = getDirName(runtime);
// Get the target directory based on runtime and install type. Cline local
@@ -7197,9 +7212,11 @@ function uninstall(isGlobal, runtime = DEFAULT_RUNTIME) {
}
}
// 4z. Remove the OpenCode native plugin adapter (#1914). Only GSD's own
// plugin file is removed; the plugins/ dir is pruned only if it becomes
// empty, preserving any user-authored OpenCode plugins.
// 4z. Remove the native plugin adapter (#1914, extended to Kilo by #2093).
// Descriptor-driven via hostBehaviors.nativePlugin — covers every runtime
// that declares the block (OpenCode, Kilo, ...), not just OpenCode. Only
// GSD's own plugin file is removed; the plugins/ dir is pruned only if it
// becomes empty, preserving any user-authored plugins for that host.
const _np = _hostBehaviors(runtime).nativePlugin;
if (_np) {
const pluginsDir = path.join(targetDir, _np.dir);
@@ -7208,7 +7225,7 @@ function uninstall(isGlobal, runtime = DEFAULT_RUNTIME) {
try {
fs.unlinkSync(pluginPath);
removedCount++;
console.log(` ${green}✓${reset} Removed OpenCode plugin`);
console.log(` ${green}✓${reset} Removed native plugin adapter (${runtime})`);
} catch (_) { /* best-effort */ }
try { fs.rmdirSync(pluginsDir); } catch (_) { /* not empty — user plugins present */ }
}
@@ -7423,7 +7440,9 @@ function uninstall(isGlobal, runtime = DEFAULT_RUNTIME) {
}
// 7. For Kilo, clean up permissions from kilo.json or kilo.jsonc
if (isKilo) {
// #2093: descriptor-driven via resolveInstallPlan(runtime).finishPermissionWriter,
// mirroring the OpenCode branch above (was hardcoded `isKilo`).
if (resolveInstallPlan(runtime).finishPermissionWriter === 'kilo') {
const configPath = resolveKiloConfigPath(targetDir);
if (fs.existsSync(configPath)) {
try {
@@ -7825,7 +7844,8 @@ function resolveInstallRelativePath(baseDir, relPath) {
* Write file manifest after installation for future modification detection
*/
function writeManifest(configDir, runtime = DEFAULT_RUNTIME, options = {}) {
const { isOpencode, isKilo, isCodex, isCopilot, isAntigravity, isCursor, isWindsurf, isAugment, isTrae, isQwen, isHermes, isCodebuddy, isCline, isKimi } = runtimeFlags(runtime);
// #2093: isKilo dropped — unused in this function.
const { isOpencode, isCodex, isCopilot, isAntigravity, isCursor, isWindsurf, isAugment, isTrae, isQwen, isHermes, isCodebuddy, isCline, isKimi } = runtimeFlags(runtime);
const gsdDir = path.join(configDir, 'gsd-core');
// #1367: Claude local now writes flat gsd-*.md files at commands/ (not commands/gsd/).
// Claude local uses flatCommandsDir instead for manifest recording.
@@ -8325,7 +8345,9 @@ function reportInstallerMigrationResult(result) {
}
function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
const { isOpencode, isKilo, isZcode, isCodex, isCopilot, isAntigravity, isCursor, isWindsurf, isAugment, isTrae, isQwen, isHermes, isCodebuddy, isCline, isKimi } = runtimeFlags(runtime);
// #2093: isKilo dropped — Kilo's agent/model-override handling below reads
// _hostBehaviors(runtime).frontmatterDialect === 'kilo' instead of this flag.
const { isOpencode, isZcode, isCodex, isCopilot, isAntigravity, isCursor, isWindsurf, isAugment, isTrae, isQwen, isHermes, isCodebuddy, isCline, isKimi } = runtimeFlags(runtime);
const plan = resolveInstallPlan(runtime);
const dirName = getDirName(runtime);
const src = path.join(__dirname, '..');
@@ -9237,7 +9259,25 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
}
content = convertClaudeToOpencodeFrontmatter(content, { isAgent: true, modelOverride: _ocModelOverride });
} else if (_hostBehaviors(runtime).frontmatterDialect === 'kilo') {
content = convertClaudeToKiloFrontmatter(content, { isAgent: true });
// Resolve per-agent model for Kilo agents (#2093 UPGRADE 2; Kilo is an
// OpenCode fork with the same static-frontmatter model constraint).
// Precedence: model_overrides[agent] > model_profile_overrides.kilo.<tier> > omit.
// model_overrides (#2256): explicit per-agent override, highest precedence.
// model_profile_overrides (#2794): tier-based runtime resolver, same parity as OpenCode.
const _kiloAgentName = entry.name.replace(/\.md$/, '');
const _kiloModelOverrides = readGsdEffectiveModelOverrides(targetDir);
let _kiloModelOverride = _kiloModelOverrides?.[_kiloAgentName] || null;
if (!_kiloModelOverride) {
// Fall back to tier-based resolution via model_profile_overrides.kilo.<tier>.
const _kiloRuntimeResolver = readGsdRuntimeProfileResolver(targetDir);
if (_kiloRuntimeResolver) {
const _kiloEntry = _kiloRuntimeResolver.resolve(_kiloAgentName);
if (_kiloEntry?.model) {
_kiloModelOverride = _kiloEntry.model;
}
}
}
content = convertClaudeToKiloFrontmatter(content, { isAgent: true, modelOverride: _kiloModelOverride });
} else if (_hostBehaviors(runtime).frontmatterDialect === 'codex') {
content = convertClaudeAgentToCodexAgent(content);
} else if (isCopilot) {
@@ -9331,7 +9371,10 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
// hostBehaviors.skipSharedHooksInstall (was hardcoded !isCursor).
// #2090: Cline's exclusion is likewise descriptor-driven (cline declares
// skipSharedHooksInstall:true) — the redundant `&& !isCline` was removed.
if (!isCodex && !isCopilot && _hostBehaviors(runtime).skipSharedHooksInstall !== true && !isWindsurf && !isTrae && !isKimi && !isKilo && !isZcode) {
// #2093: Kilo's exclusion is likewise descriptor-driven (kilo declares
// skipSharedHooksInstall:true) — the redundant `&& !isKilo` was removed.
// ZCode still has an empty hostBehaviors, so `&& !isZcode` stays.
if (!isCodex && !isCopilot && _hostBehaviors(runtime).skipSharedHooksInstall !== true && !isWindsurf && !isTrae && !isKimi && !isZcode) {
// Write package.json to force CommonJS mode for GSD scripts
// Prevents "require is not defined" errors when project has "type": "module"
// Node.js walks up looking for package.json - this stops inheritance from project
@@ -9423,17 +9466,18 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
}
// Gate hooks/lib/ install on the same runtimes that receive hooks (see line ~8702).
// Codex/Copilot/Cursor/Windsurf/Trae/Cline do not use the shared hooks/lib/ helpers
// (Cursor uses standalone .js hook scripts registered via hooks.json — gated
// Codex/Copilot/Cursor/Windsurf/Trae/Cline/Kilo do not use the shared hooks/lib/
// helpers (Cursor uses standalone .js hook scripts registered via hooks.json — gated
// descriptor-driven via hostBehaviors.skipSharedHooksInstall, #2089; Cline likewise
// #2090; Codex uses hooks.json directly; the others skip hooks entirely); Kilo and
// ZCode also skip hooks entirely (hooksSurface:'none' with no plugin surface — #1821).
// OpenCode is NOT excluded: its #1914 plugin adapter spawns the staged hooks and
// requires hooks/lib/ helpers. None of the excluded runtimes must receive the
// hooks/lib/ helpers — otherwise the Codex comment downstream ("we deliberately do
// *not* copy hooks/lib/ for Codex") is contradicted in practice.
// #2090; Kilo likewise #2093; Codex uses hooks.json directly; the others skip hooks
// entirely); Kilo and ZCode also skip hooks entirely (hooksSurface:'none' with no
// plugin surface — #1821). ZCode's hostBehaviors is still empty, so `&& !isZcode`
// stays hardcoded. OpenCode is NOT excluded: its #1914 plugin adapter spawns the
// staged hooks and requires hooks/lib/ helpers. None of the excluded runtimes must
// receive the hooks/lib/ helpers — otherwise the Codex comment downstream ("we
// deliberately do *not* copy hooks/lib/ for Codex") is contradicted in practice.
const hooksLibSrc = path.join(src, 'hooks', 'lib');
if (!isCodex && !isCopilot && _hostBehaviors(runtime).skipSharedHooksInstall !== true && !isWindsurf && !isTrae && !isKimi && !isKilo && !isZcode && fs.existsSync(hooksLibSrc)) {
if (!isCodex && !isCopilot && _hostBehaviors(runtime).skipSharedHooksInstall !== true && !isWindsurf && !isTrae && !isKimi && !isZcode && fs.existsSync(hooksLibSrc)) {
const hooksLibDest = path.join(targetDir, 'hooks', 'lib');
fs.mkdirSync(hooksLibDest, { recursive: true });
copyLibDir(hooksLibSrc, hooksLibDest, GSD_HOOK_LIB_FILES);
@@ -10367,7 +10411,9 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
* Apply statusline config, then print completion message
*/
function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallStatusline, runtime = DEFAULT_RUNTIME, isGlobal = true, configDir = null, bannerOpts = {}) {
const { isOpencode, isKilo, isCodex, isCopilot, isAntigravity, isCursor, isWindsurf, isAugment, isTrae, isQwen, isHermes, isCodebuddy, isCline, isKimi } = runtimeFlags(runtime);
// #2093: isKilo dropped — the Kilo permissions-writer call below is gated
// on plan.finishPermissionWriter === 'kilo' (descriptor-driven), not this flag.
const { isOpencode, isCodex, isCopilot, isAntigravity, isCursor, isWindsurf, isAugment, isTrae, isQwen, isHermes, isCodebuddy, isCline, isKimi } = runtimeFlags(runtime);
const plan = resolveInstallPlan(runtime);
if (shouldInstallStatusline && plan.writesSharedSettings && !_hostBehaviors(runtime).skipSettingsUi) {

View File

@@ -66,6 +66,7 @@
},
"commandStyle": "slash-hyphen",
"hooksSurface": "none",
"extensionEvents": "kilo",
"sandboxTier": "none",
"supportTier": 2,
"installSurface": "settings-json",
@@ -95,7 +96,13 @@
"flatCommandDir": "command",
"combinedFamilyInstall": true,
"frontmatterDialect": "kilo",
"skipUpdateBannerCommand": true
"nativePlugin": {
"dir": "plugins",
"file": "gsd-core.js",
"source": ".kilo/plugins/gsd-core.js"
},
"skipUpdateBannerCommand": true,
"skipSharedHooksInstall": true
}
}
}

View File

@@ -1424,7 +1424,8 @@ When `runtime` is set, profile tiers (`opus`/`sonnet`/`haiku`) resolve to runtim
| `opencode` | `anthropic/claude-opus-4-8` | `anthropic/claude-sonnet-5` | `anthropic/claude-haiku-4-5` | (not used) |
| `copilot` | `claude-opus-4-8` | `claude-sonnet-5` | `claude-haiku-4-5` | (not used) |
| `hermes` | `anthropic/claude-opus-4-8` | `anthropic/claude-sonnet-5` | `anthropic/claude-haiku-4-5` | (not used) |
| Group B (`kilo`, `cline`, `cursor`, `windsurf` (alias: `devin-desktop`), `augment`, `trae`, `codebuddy`, `antigravity`) | (no built-in default — your runtime handles model selection) | | | |
| `kilo` | `anthropic/claude-opus-4-8` | `anthropic/claude-sonnet-5` | `anthropic/claude-haiku-4-5` | (not used) |
| Group B (`cline`, `cursor`, `windsurf` (alias: `devin-desktop`), `augment`, `trae`, `codebuddy`, `antigravity`) | (no built-in default — your runtime handles model selection) | | | |
> **How these model IDs are sourced.** The catalog (`bin/shared/model-catalog.json`) pins each runtime's tier defaults to that provider's current frontier IDs, and may intentionally carry forward-dated IDs ahead of a provider's public docs. To verify an ID is live before changing it, check the provider's own source/API — e.g. Codex: `codex debug models` or the OpenAI Codex models page; Qwen: Alibaba Model Studio model list. Only change an ID that the provider actually rejects — absence from documentation alone is not proof of invalidity.

View File

@@ -26,14 +26,38 @@ host.
}
```
- **Claude Code / Codex / OpenCode / Cursor / Cline / Hermes** — under the
host's `mcpServers` object (project or user config).
- **Claude Code / Codex / Cursor / Cline / Hermes** — under the host's
`mcpServers` object (project or user config).
- **VS Code** — in the workspace MCP servers list.
- **Antigravity CLI** — under its `mcpServers` block.
- **OpenCode** — under the `mcp` key (**not** `mcpServers`), in
`~/.config/opencode/opencode.jsonc` (global) or `./opencode.json`
(project). The entry shape also differs — see below.
- **Kilo Code** — an OpenCode fork; also under the `mcp` key (**not**
`mcpServers`), in `~/.config/kilo/opencode.jsonc` (global) or
`./opencode.json` (project). Same entry shape as OpenCode.
Set `cwd` to the project whose `.planning/` you want GSD to manage — the server
resolves state paths against it.
### OpenCode / Kilo entry shape
OpenCode (and Kilo, which shares OpenCode's config schema) use a
`type`/`command`/`timeout` entry under the `mcp` key instead of the generic
`command`/`args`/`cwd` form above:
```jsonc
{
"mcp": {
"gsd": {
"type": "local",
"command": ["npx", "-y", "@opengsd/gsd-core", "gsd-mcp-server"],
"timeout": 10000
}
}
}
```
## 2. Restart the host
On startup the host performs the MCP `initialize` handshake, lists tools, and

View File

@@ -462,6 +462,8 @@ Sources consulted:
Documentation gaps:
- dispatch.subagentToolkit — docs describe per-subagent configurable permissions (allow/ask/deny) but do not document a single default toolkit level (full vs read-only) for subagents that lack explicit permission overrides.
**EoS migration status (#2093):** Migrated onto the imperative adapter. All `runtime === 'kilo'` / `isKilo` logic branches in `bin/install.js`, `src/install-engine.cts`, `src/runtime-artifact-conversion.cts`, and `src/runtime-artifact-layout.cts` folded into descriptor-driven `runtime.hostBehaviors` (`finishPermissionWriter`, `skipSharedHooksInstall`, and the skills converter registry are now resolved off the descriptor, not a hardcoded Kilo check). Four upgrades land: (1) **native hook-bus plugin** — `.kilo/plugins/gsd-core.js` (byte-identical to `.opencode/plugins/gsd-core.js`, cite: Kilo is an OpenCode fork sharing the same plugin/extension event bus) bridges GSD's hook scripts onto Kilo's plugin event bus; `extensionEvents: "kilo"` reuses `OPENCODE_EXTENSION_EVENTS` verbatim. (2) **active-model routing** — `convertClaudeToKiloFrontmatter` now emits a `model:` field from the resolved `model_overrides`/`model_profile_overrides.kilo.<tier>` value instead of always stripping it (mirrors the OpenCode upgrade; #2256). (3) **MCP companion documented** — `docs/how-to/connect-gsd-mcp-server.md` covers Kilo's `mcp`-keyed config (not `mcpServers`) and its `{type:"local", command, timeout}` entry shape. (4) **named subagent dispatch** — GSD's specialist agents install as `<configDir>/agents/gsd-*.md` with `mode: subagent` frontmatter (the slug Kilo's Task tool dispatches by) and a `permission:` block. `dispatch.subagentToolkit` stays `undocumented` — no authoritative Kilo doc states a default subagent toolkit level — so `degradationFor('dispatch', …)` returns `'degraded'`, not `'full'`, by design (fail-closed negotiation, not a regression).
---
## windsurf

View File

@@ -1548,6 +1548,7 @@ const capabilities = {
},
"commandStyle": "slash-hyphen",
"hooksSurface": "none",
"extensionEvents": "kilo",
"sandboxTier": "none",
"supportTier": 2,
"installSurface": "settings-json",
@@ -1577,7 +1578,13 @@ const capabilities = {
"flatCommandDir": "command",
"combinedFamilyInstall": true,
"frontmatterDialect": "kilo",
"skipUpdateBannerCommand": true
"nativePlugin": {
"dir": "plugins",
"file": "gsd-core.js",
"source": ".kilo/plugins/gsd-core.js"
},
"skipUpdateBannerCommand": true,
"skipSharedHooksInstall": true
}
}
},
@@ -4669,6 +4676,7 @@ const runtimes = {
},
"commandStyle": "slash-hyphen",
"hooksSurface": "none",
"extensionEvents": "kilo",
"sandboxTier": "none",
"supportTier": 2,
"installSurface": "settings-json",
@@ -4698,7 +4706,13 @@ const runtimes = {
"flatCommandDir": "command",
"combinedFamilyInstall": true,
"frontmatterDialect": "kilo",
"skipUpdateBannerCommand": true
"nativePlugin": {
"dir": "plugins",
"file": "gsd-core.js",
"source": ".kilo/plugins/gsd-core.js"
},
"skipUpdateBannerCommand": true,
"skipSharedHooksInstall": true
}
}
},

View File

@@ -711,7 +711,7 @@ const VALID_HOOK_EVENTS = new Set(['claude', 'gemini']);
// DISTINCT from hookEvents (managed-hook dialect): extensionEvents describes the
// plugin-owned event subset imperative hosts expose (opencode / pi); 'none' = the
// host exposes no extension surface (engine owns the bus, e.g. VS Code).
const VALID_EXTENSION_EVENTS = new Set(['opencode', 'pi', 'hermes', 'none']);
const VALID_EXTENSION_EVENTS = new Set(['opencode', 'pi', 'hermes', 'kilo', 'none']);
const VALID_SANDBOX_TIERS = new Set(['none', 'codex-agent-sandbox']);
const VALID_ARTIFACT_KIND_NAMES = new Set(['commands', 'agents', 'skills', 'kimi-agents']);
const VALID_ARTIFACT_NESTINGS = new Set(['flat', 'nested']);

View File

@@ -3,9 +3,10 @@
/**
* Stale-bake guard for static-frontmatter runtimes (#1688, follow-up to #1650).
*
* Runtimes `codex` and `opencode` bake the resolved model ID into each agent's
* static config at install time (bin/install.js ~5667-5767 for codex,
* ~10008-10026 for opencode). Their task/spawn_agent interfaces do not accept
* Runtimes `codex`, `opencode`, and (since #2093) `kilo` bake the resolved
* model ID into each agent's static config at install time (bin/install.js
* ~5667-5767 for codex, ~10008-10026 for opencode, and the adjacent kilo
* branch added by #2093). Their task/spawn_agent interfaces do not accept
* an inline `model` parameter, so editing `model_overrides` in
* `.planning/config.json` or `~/.gsd/defaults.json` has NO effect until the
* user re-runs `gsd install <runtime>` (or `gsd update`). The failure is
@@ -25,9 +26,17 @@ const os = require('os');
* Runtimes whose agent config is static frontmatter/TOML baked at install time.
* MUST stay in sync with the bake paths in bin/install.js. The parity test in
* tests/stale-bake-guard.test.cjs asserts this matches the runtimes that
* actually emit a baked model: line id #2256 (opencode) and #49/#2256 (codex).
* actually emit a baked model: line id #2256 (opencode), #49/#2256 (codex),
* and #2093 (kilo).
*/
const STATIC_FRONTMATTER_RUNTIMES = Object.freeze(['codex', 'opencode']);
const STATIC_FRONTMATTER_RUNTIMES = Object.freeze(['codex', 'kilo', 'opencode']);
/** Per-runtime `gsd install` flag, for the remediation hint in the warning. */
const INSTALL_FLAG_BY_RUNTIME = Object.freeze({
codex: '--codex',
opencode: '--opencode',
kilo: '--kilo',
});
const _warnedKeys = new Set();
@@ -55,7 +64,7 @@ function formatStaleBakeWarning({ runtime, configPath, configMtimeMs, agentMtime
const signal = detectStaleBake({ runtime, configMtimeMs, agentMtimeMs });
if (!signal) return '';
const configDate = new Date(configMtimeMs).toISOString();
const installFlag = runtime === 'opencode' ? '--opencode' : '--codex';
const installFlag = INSTALL_FLAG_BY_RUNTIME[runtime] || `--${runtime}`;
return [
`gsd: model config in ${configPath} changed since agents were last baked (${configDate}).`,
` Static-frontmatter runtime '${runtime}' ignores the new model_overrides`,
@@ -79,13 +88,24 @@ function resolveRuntimeFromConfig(config) {
/**
* Resolve the install root for a runtime's agent files, honoring the same env
* vars the installer does (CODEX_HOME, OPENCODE_CONFIG_DIR). Returns the
* absolute directory or `null` for unsupported runtimes.
* vars the installer does (CODEX_HOME, OPENCODE_CONFIG_DIR, KILO_CONFIG_DIR).
* Returns the absolute directory or `null` for unsupported runtimes.
*/
function resolveAgentDir(runtime, { env = process.env, homedir = os.homedir } = {}) {
if (runtime === 'opencode') {
const base = (env.OPENCODE_CONFIG_DIR && String(env.OPENCODE_CONFIG_DIR).trim()) || path.join(homedir(), '.config', 'opencode');
return path.join(base, 'agent');
// #2093 fix: the installer writes agents to `<base>/agents` (plural — see
// bin/install.js's universal `agentsDest = path.join(targetDir, 'agents')`,
// verified against a live `--opencode --global` install). The prior
// singular `agent` never matched the real install output, so this guard's
// `findOldestAgentMtime` always hit ENOENT and warnIfStaleBake was a
// silent no-op for opencode in production — discovered while wiring the
// parallel kilo entry below (same bake mechanism, #2093).
return path.join(base, 'agents');
}
if (runtime === 'kilo') {
const base = (env.KILO_CONFIG_DIR && String(env.KILO_CONFIG_DIR).trim()) || path.join(homedir(), '.config', 'kilo');
return path.join(base, 'agents');
}
if (runtime === 'codex') {
const base = (env.CODEX_HOME && String(env.CODEX_HOME).trim()) || path.join(homedir(), '.codex');
@@ -148,7 +168,7 @@ function findOldestAgentMtime(runtime, { env = process.env, homedir = os.homedir
for (const entry of entries) {
if (!entry.isFile()) continue;
if (!entry.name.startsWith('gsd-')) continue;
const isAgentFile = (runtime === 'opencode' && entry.name.endsWith('.md'))
const isAgentFile = ((runtime === 'opencode' || runtime === 'kilo') && entry.name.endsWith('.md'))
|| (runtime === 'codex' && (entry.name.endsWith('.toml') || entry.name.endsWith('.md')));
if (!isAgentFile) continue;
try {

View File

@@ -38,9 +38,9 @@
"haiku": { "model": "anthropic/claude-haiku-4-5" }
},
"kilo": {
"opus": null,
"sonnet": null,
"haiku": null
"opus": { "model": "anthropic/claude-opus-4-8" },
"sonnet": { "model": "anthropic/claude-sonnet-5" },
"haiku": { "model": "anthropic/claude-haiku-4-5" }
},
"cline": {
"opus": null,

View File

@@ -360,7 +360,8 @@ Built-in tier defaults by runtime:
| `opencode` | `anthropic/claude-opus-4-8` | `anthropic/claude-sonnet-5` | `anthropic/claude-haiku-4-5` |
| `copilot` | `claude-opus-4-8` | `claude-sonnet-5` | `claude-haiku-4-5` |
| `hermes` | `anthropic/claude-opus-4-8` | `anthropic/claude-sonnet-5` | `anthropic/claude-haiku-4-5` |
| Group B (`kilo`, `cline`, `cursor`, `windsurf`, `augment`, `trae`, `codebuddy`, `antigravity`) | (no built-in default — your runtime handles model selection) | | |
| `kilo` | `anthropic/claude-opus-4-8` | `anthropic/claude-sonnet-5` | `anthropic/claude-haiku-4-5` |
| Group B (`cline`, `cursor`, `windsurf`, `augment`, `trae`, `codebuddy`, `antigravity`) | (no built-in default — your runtime handles model selection) | | |
Display a table to the user showing the effective configuration:
@@ -387,8 +388,8 @@ AskUserQuestion([
options: [
{ label: "Keep current (<runtime>)", description: "Configure overrides for the current runtime." },
{ label: "Common runtimes", description: "claude, codex, gemini, qwen" },
{ label: "Additional runtimes", description: "opencode, copilot, hermes" },
{ label: "Other (Group B or custom)", description: "kilo, cline, cursor, windsurf, augment, trae, codebuddy, antigravity, or a custom runtime string." }
{ label: "Additional runtimes", description: "opencode, copilot, hermes, kilo" },
{ label: "Other (Group B or custom)", description: "cline, cursor, windsurf, augment, trae, codebuddy, antigravity, or a custom runtime string." }
]
}
])
@@ -423,7 +424,8 @@ AskUserQuestion([
options: [
{ label: "opencode", description: "OpenCode (uses anthropic/ prefix)." },
{ label: "copilot", description: "GitHub Copilot." },
{ label: "hermes", description: "Hermes (uses anthropic/ prefix)." }
{ label: "hermes", description: "Hermes (uses anthropic/ prefix)." },
{ label: "kilo", description: "Kilo Code (uses anthropic/ prefix)." }
]
}
])

View File

@@ -551,14 +551,26 @@ function hookEventSurfaceFor(hookEvents: unknown): readonly string[] | null {
// events (session/tool/file/permission); pi ~30 fine-grained extension events;
// '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,
// GSD 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.
const OPENCODE_EXTENSION_EVENTS = Object.freeze([
'session.created', 'session.idle', 'experimental.session.compacting',
'tool.execute.before', 'tool.execute.after', 'file.edited',
// #2087 — additional documented plugin events GSD binds (opencode.ai/docs/plugins):
// permission decisions + session error surface.
'permission.asked', 'permission.replied', 'session.error',
]);
const EXTENSION_EVENT_SURFACES: Readonly<Record<string, readonly string[]>> = Object.freeze({
opencode: Object.freeze([
'session.created', 'session.idle', 'experimental.session.compacting',
'tool.execute.before', 'tool.execute.after', 'file.edited',
// #2087 — additional documented plugin events GSD binds (opencode.ai/docs/plugins):
// permission decisions + session error surface.
'permission.asked', 'permission.replied', 'session.error',
]),
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/gsd-core.js (copied verbatim from
// .opencode/plugins/gsd-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

View File

@@ -122,6 +122,18 @@ function convertClaudeCommandToKiloSkill(content: string, skillName: string): st
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.
*/
const SKILLS_CONVERTER_REGISTRY: Record<string, (content: string, skillName: string) => string> = {
convertClaudeCommandToOpencodeSkill,
convertClaudeCommandToKiloSkill,
};
// ---------------------------------------------------------------------------
// User-artifact preservation helpers
// ---------------------------------------------------------------------------
@@ -734,9 +746,18 @@ function installOpencodeFamilySkills(
const rawDir = rawCommandsDir;
if (!rawDir || !fs.existsSync(rawDir)) return 0;
const converter = _hostBehaviors(runtime).frontmatterDialect === 'kilo'
? convertClaudeCommandToKiloSkill
: convertClaudeCommandToOpencodeSkill;
// #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'`
// 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;
const converter = converterName ? SKILLS_CONVERTER_REGISTRY[converterName] : undefined;
if (!converter) {
throw new TypeError(
`installOpencodeFamilySkills: unknown skills converter '${String(converterName)}' for runtime '${runtime}'`,
);
}
const dest = runtimeArtifactInstallPlan.assertDestWithinConfigHome(targetDir, skillsKindEntry.destSubpath);
// Symlink-escape guard: reject if any path component between targetDir and
@@ -836,6 +857,16 @@ function installOpencodeFamilyCommands(
let content = fs.readFileSync(srcPath, 'utf8');
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 —
// 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);
@@ -849,9 +880,10 @@ function installOpencodeFamilyCommands(
// ---------------------------------------------------------------------------
/**
* Combined-family install orchestrator for OpenCode/Kilo (ADR-1239 / #2087).
* Stages the flattened commands surface + skills surface + (OpenCode only)
* native plugin adapter, mirroring the bespoke `else if (isOpencode ||
* Combined-family install orchestrator for OpenCode/Kilo (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.
*
* @param runtime - 'opencode' or 'kilo'

View File

@@ -1676,7 +1676,12 @@ function convertClaudeToOpencodeFrontmatter(content, { isAgent = false, modelOve
}
// Kilo CLI — same conversion logic as OpenCode, different config paths.
function convertClaudeToKiloFrontmatter(content, { isAgent = false } = {}) {
// DEFECT.GENERATIVE-FIX: this body is mirrored in bin/install.js's
// convertClaudeToKiloFrontmatter (used by bin/install.js's own legacy install
// path). Neither copy re-exports the other — mirror any behavior change into
// both. Guarded by the output-parity test in tests/runtime-converters.test.cjs
// (#2093).
function convertClaudeToKiloFrontmatter(content, { isAgent = false, modelOverride = null } = {}) {
// Replace tool name references in content (applies to all files)
let convertedContent = content;
convertedContent = convertedContent.replace(/\bAskUserQuestion\b/g, 'question');
@@ -1835,6 +1840,14 @@ function convertClaudeToKiloFrontmatter(content, { isAgent = false } = {}) {
// For agents: add required Kilo agent fields
if (isAgent) {
newLines.push('mode: subagent');
// Embed model override from ~/.gsd/defaults.json so model_overrides is
// respected on Kilo (which uses static agent frontmatter, not inline
// Task() model parameters) — mirrors convertClaudeToOpencodeFrontmatter's
// model emission exactly (#2093 UPGRADE 2 / ADR-1239; Kilo is an OpenCode
// fork with the same static-frontmatter model constraint). See #2256.
if (modelOverride) {
newLines.push(['model:', modelOverride].join(' '));
}
newLines.push(...buildKiloAgentPermissionBlock(agentTools));
}

View File

@@ -77,6 +77,12 @@ interface ArtifactKind {
* specifies one (e.g. codex skills → $HOME/.agents). Undefined means the
* kind installs under the runtime's normal configDir. */
home?: string;
/** Name of the converter function in Runtime Artifact Conversion exports, as
* declared on the descriptor's `converter` field. Only populated for the
* `skills` kind today — lets bespoke callers (e.g. the OpenCode-family
* combined installer, ADR-1239 / #2093) look up the descriptor-declared
* converter by name instead of re-deriving it from a runtime === check. */
converter?: string;
}
interface Layout {
@@ -323,6 +329,7 @@ function skillsKind(
kind: 'skills',
destSubpath,
prefix,
converter: converterName,
stage: (resolved) => {
const realConverter = conversionExports[converterName] as (content: string, skillName: string, runtime: string, cmdNames: string[], isGlobal: boolean) => string;
// Compute cmdNames once per stage call for performance (#3583).

View File

@@ -3967,12 +3967,12 @@ describe('ADR-1016 phase 5a: closed-vocab set exports', () => {
'opencode-subset is NOT a hookEvents value — it is the extensionEvents vocabulary (#1943)');
});
test('VALID_EXTENSION_EVENTS has the extension-system dialects (opencode/pi/hermes/none — #1943/#2091)', () => {
test('VALID_EXTENSION_EVENTS has the extension-system dialects (opencode/pi/hermes/kilo/none — #1943/#2091/#2093)', () => {
assert.ok(VALID_EXTENSION_EVENTS instanceof Set);
for (const v of ['opencode', 'pi', 'hermes', 'none']) {
for (const v of ['opencode', 'pi', 'hermes', 'kilo', 'none']) {
assert.ok(VALID_EXTENSION_EVENTS.has(v), 'VALID_EXTENSION_EVENTS must contain "' + v + '"');
}
assert.strictEqual(VALID_EXTENSION_EVENTS.size, 4);
assert.strictEqual(VALID_EXTENSION_EVENTS.size, 5);
});
test('VALID_SANDBOX_TIERS has exactly 2 values', () => {

View File

@@ -42,7 +42,7 @@
"gsd-core/bin/gsd_run": "62d9b647ede212e6",
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
"gsd-core/bin/shared/config-schema.manifest.json": "6bba2b9c9fa47cb8",
"gsd-core/bin/shared/model-catalog.json": "eee64a2cf3b94c00",
"gsd-core/bin/shared/model-catalog.json": "d0b59409a46a456a",
"gsd-core/bin/shared/runtime-aliases.manifest.json": "2df2c5ac1957911a",
"gsd-core/bin/verify-reapply-patches.cjs": "8bc541aabc2e143c",
"gsd-core/contexts/dev.md": "dcb0de9dce33cf41",
@@ -289,7 +289,7 @@
"gsd-core/workflows/scan.md": "a7fecd67e5cd655f",
"gsd-core/workflows/secure-phase.md": "96b199dfac00e60f",
"gsd-core/workflows/session-report.md": "2e5b1205324ddefa",
"gsd-core/workflows/settings-advanced.md": "b7050fcf24175188",
"gsd-core/workflows/settings-advanced.md": "289b3d653d18e284",
"gsd-core/workflows/settings-integrations.md": "4d3001ad2b5dad8f",
"gsd-core/workflows/settings.md": "8258f7bd3700d608",
"gsd-core/workflows/ship.md": "984bd7660e7791fd",

View File

@@ -113,7 +113,7 @@
"gsd-core/bin/gsd_run": "62d9b647ede212e6",
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
"gsd-core/bin/shared/config-schema.manifest.json": "6bba2b9c9fa47cb8",
"gsd-core/bin/shared/model-catalog.json": "eee64a2cf3b94c00",
"gsd-core/bin/shared/model-catalog.json": "d0b59409a46a456a",
"gsd-core/bin/shared/runtime-aliases.manifest.json": "2df2c5ac1957911a",
"gsd-core/bin/verify-reapply-patches.cjs": "caec5dbce11e3904",
"gsd-core/contexts/dev.md": "dcb0de9dce33cf41",
@@ -360,7 +360,7 @@
"gsd-core/workflows/scan.md": "003883d71c37da7d",
"gsd-core/workflows/secure-phase.md": "29fc6b62c5c5dc62",
"gsd-core/workflows/session-report.md": "2e5b1205324ddefa",
"gsd-core/workflows/settings-advanced.md": "77ada5fe336ed9f7",
"gsd-core/workflows/settings-advanced.md": "b6fa466a953dd3a2",
"gsd-core/workflows/settings-integrations.md": "70515c5838fb9826",
"gsd-core/workflows/settings.md": "d96ddf01fb85e61e",
"gsd-core/workflows/ship.md": "7b8fe9f89143e648",

View File

@@ -112,7 +112,7 @@
"gsd-core/bin/gsd_run": "62d9b647ede212e6",
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
"gsd-core/bin/shared/config-schema.manifest.json": "6bba2b9c9fa47cb8",
"gsd-core/bin/shared/model-catalog.json": "eee64a2cf3b94c00",
"gsd-core/bin/shared/model-catalog.json": "d0b59409a46a456a",
"gsd-core/bin/shared/runtime-aliases.manifest.json": "2df2c5ac1957911a",
"gsd-core/bin/verify-reapply-patches.cjs": "caec5dbce11e3904",
"gsd-core/contexts/dev.md": "dcb0de9dce33cf41",
@@ -359,7 +359,7 @@
"gsd-core/workflows/scan.md": "75c670d08cee8680",
"gsd-core/workflows/secure-phase.md": "64ec4d06ca85720a",
"gsd-core/workflows/session-report.md": "2e5b1205324ddefa",
"gsd-core/workflows/settings-advanced.md": "0c8a218bad054722",
"gsd-core/workflows/settings-advanced.md": "a44896a18f1f2edc",
"gsd-core/workflows/settings-integrations.md": "dfe3672c4fabf139",
"gsd-core/workflows/settings.md": "acdd79110699a608",
"gsd-core/workflows/ship.md": "44af1c72d86e153b",

View File

@@ -41,7 +41,7 @@
"gsd-core/bin/gsd_run": "62d9b647ede212e6",
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
"gsd-core/bin/shared/config-schema.manifest.json": "6bba2b9c9fa47cb8",
"gsd-core/bin/shared/model-catalog.json": "eee64a2cf3b94c00",
"gsd-core/bin/shared/model-catalog.json": "d0b59409a46a456a",
"gsd-core/bin/shared/runtime-aliases.manifest.json": "2df2c5ac1957911a",
"gsd-core/bin/verify-reapply-patches.cjs": "caec5dbce11e3904",
"gsd-core/contexts/dev.md": "dcb0de9dce33cf41",
@@ -288,7 +288,7 @@
"gsd-core/workflows/scan.md": "47371c2073d6c0be",
"gsd-core/workflows/secure-phase.md": "59d3c50aba8c9a6c",
"gsd-core/workflows/session-report.md": "2e5b1205324ddefa",
"gsd-core/workflows/settings-advanced.md": "38e4e7b92b396e3c",
"gsd-core/workflows/settings-advanced.md": "fa4a285b6bcbc384",
"gsd-core/workflows/settings-integrations.md": "53649313d20694ae",
"gsd-core/workflows/settings.md": "7e7458cdb2b68ec5",
"gsd-core/workflows/ship.md": "12e8e58c077a891a",

View File

@@ -45,7 +45,7 @@
"gsd-core/bin/gsd_run": "62d9b647ede212e6",
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
"gsd-core/bin/shared/config-schema.manifest.json": "6bba2b9c9fa47cb8",
"gsd-core/bin/shared/model-catalog.json": "eee64a2cf3b94c00",
"gsd-core/bin/shared/model-catalog.json": "d0b59409a46a456a",
"gsd-core/bin/shared/runtime-aliases.manifest.json": "2df2c5ac1957911a",
"gsd-core/bin/verify-reapply-patches.cjs": "caec5dbce11e3904",
"gsd-core/contexts/dev.md": "dcb0de9dce33cf41",
@@ -292,7 +292,7 @@
"gsd-core/workflows/scan.md": "dfd92717caea0ce7",
"gsd-core/workflows/secure-phase.md": "cf78183f06a02582",
"gsd-core/workflows/session-report.md": "2e5b1205324ddefa",
"gsd-core/workflows/settings-advanced.md": "30dcd0980f4ae882",
"gsd-core/workflows/settings-advanced.md": "7147ba291d13e007",
"gsd-core/workflows/settings-integrations.md": "76eee76d6eb57657",
"gsd-core/workflows/settings.md": "3701faed09d55247",
"gsd-core/workflows/ship.md": "5f931a25ea9102a4",

View File

@@ -113,7 +113,7 @@
"gsd-core/bin/gsd_run": "62d9b647ede212e6",
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
"gsd-core/bin/shared/config-schema.manifest.json": "6bba2b9c9fa47cb8",
"gsd-core/bin/shared/model-catalog.json": "eee64a2cf3b94c00",
"gsd-core/bin/shared/model-catalog.json": "d0b59409a46a456a",
"gsd-core/bin/shared/runtime-aliases.manifest.json": "2df2c5ac1957911a",
"gsd-core/bin/verify-reapply-patches.cjs": "caec5dbce11e3904",
"gsd-core/contexts/dev.md": "dcb0de9dce33cf41",
@@ -360,7 +360,7 @@
"gsd-core/workflows/scan.md": "003883d71c37da7d",
"gsd-core/workflows/secure-phase.md": "29fc6b62c5c5dc62",
"gsd-core/workflows/session-report.md": "2e5b1205324ddefa",
"gsd-core/workflows/settings-advanced.md": "77ada5fe336ed9f7",
"gsd-core/workflows/settings-advanced.md": "b6fa466a953dd3a2",
"gsd-core/workflows/settings-integrations.md": "70515c5838fb9826",
"gsd-core/workflows/settings.md": "d96ddf01fb85e61e",
"gsd-core/workflows/ship.md": "7b8fe9f89143e648",

View File

@@ -148,7 +148,7 @@
"gsd-core/bin/gsd_run": "62d9b647ede212e6",
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
"gsd-core/bin/shared/config-schema.manifest.json": "6bba2b9c9fa47cb8",
"gsd-core/bin/shared/model-catalog.json": "eee64a2cf3b94c00",
"gsd-core/bin/shared/model-catalog.json": "d0b59409a46a456a",
"gsd-core/bin/shared/runtime-aliases.manifest.json": "2df2c5ac1957911a",
"gsd-core/bin/verify-reapply-patches.cjs": "caec5dbce11e3904",
"gsd-core/contexts/dev.md": "dcb0de9dce33cf41",
@@ -395,7 +395,7 @@
"gsd-core/workflows/scan.md": "1a3caa5d724d39e9",
"gsd-core/workflows/secure-phase.md": "db91810d16964b1e",
"gsd-core/workflows/session-report.md": "dd8fa011c9394075",
"gsd-core/workflows/settings-advanced.md": "14de66e631f172d7",
"gsd-core/workflows/settings-advanced.md": "38566aab0c529f2c",
"gsd-core/workflows/settings-integrations.md": "77730321d3d6d317",
"gsd-core/workflows/settings.md": "054c8c31b3905ced",
"gsd-core/workflows/ship.md": "d86233c9a365effd",

View File

@@ -43,7 +43,7 @@
"gsd-core/bin/gsd_run": "62d9b647ede212e6",
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
"gsd-core/bin/shared/config-schema.manifest.json": "6bba2b9c9fa47cb8",
"gsd-core/bin/shared/model-catalog.json": "eee64a2cf3b94c00",
"gsd-core/bin/shared/model-catalog.json": "d0b59409a46a456a",
"gsd-core/bin/shared/runtime-aliases.manifest.json": "2df2c5ac1957911a",
"gsd-core/bin/verify-reapply-patches.cjs": "10226e9512dd44bf",
"gsd-core/contexts/dev.md": "dcb0de9dce33cf41",
@@ -290,7 +290,7 @@
"gsd-core/workflows/scan.md": "dcc2f76d0850e2fb",
"gsd-core/workflows/secure-phase.md": "d87bd706f85bcad6",
"gsd-core/workflows/session-report.md": "2e5b1205324ddefa",
"gsd-core/workflows/settings-advanced.md": "d46274308c4e2584",
"gsd-core/workflows/settings-advanced.md": "8c88bbcb8fa0fa39",
"gsd-core/workflows/settings-integrations.md": "a1d146d6bfd14db5",
"gsd-core/workflows/settings.md": "f611f14f2f447f1e",
"gsd-core/workflows/ship.md": "b9dc0aaee0ff68e7",

View File

@@ -113,7 +113,7 @@
"gsd-core/bin/gsd_run": "62d9b647ede212e6",
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
"gsd-core/bin/shared/config-schema.manifest.json": "6bba2b9c9fa47cb8",
"gsd-core/bin/shared/model-catalog.json": "eee64a2cf3b94c00",
"gsd-core/bin/shared/model-catalog.json": "d0b59409a46a456a",
"gsd-core/bin/shared/runtime-aliases.manifest.json": "2df2c5ac1957911a",
"gsd-core/bin/verify-reapply-patches.cjs": "caec5dbce11e3904",
"gsd-core/contexts/dev.md": "dcb0de9dce33cf41",
@@ -360,7 +360,7 @@
"gsd-core/workflows/scan.md": "47371c2073d6c0be",
"gsd-core/workflows/secure-phase.md": "c55975672c4e1895",
"gsd-core/workflows/session-report.md": "2e5b1205324ddefa",
"gsd-core/workflows/settings-advanced.md": "6828a746997784d8",
"gsd-core/workflows/settings-advanced.md": "93a20fc634b05964",
"gsd-core/workflows/settings-integrations.md": "166ca51b1f33c2a3",
"gsd-core/workflows/settings.md": "0623c673eaf04799",
"gsd-core/workflows/ship.md": "38830806d244fe9c",

View File

@@ -42,7 +42,7 @@
"gsd-core/bin/gsd_run": "62d9b647ede212e6",
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
"gsd-core/bin/shared/config-schema.manifest.json": "6bba2b9c9fa47cb8",
"gsd-core/bin/shared/model-catalog.json": "eee64a2cf3b94c00",
"gsd-core/bin/shared/model-catalog.json": "d0b59409a46a456a",
"gsd-core/bin/shared/runtime-aliases.manifest.json": "2df2c5ac1957911a",
"gsd-core/bin/verify-reapply-patches.cjs": "caec5dbce11e3904",
"gsd-core/contexts/dev.md": "dcb0de9dce33cf41",
@@ -289,7 +289,7 @@
"gsd-core/workflows/scan.md": "b28f65d88c522767",
"gsd-core/workflows/secure-phase.md": "f2957d4b88fb3746",
"gsd-core/workflows/session-report.md": "2e5b1205324ddefa",
"gsd-core/workflows/settings-advanced.md": "dd44be875331ccb9",
"gsd-core/workflows/settings-advanced.md": "7374ef968d5280f0",
"gsd-core/workflows/settings-integrations.md": "1dce76db0aca08a5",
"gsd-core/workflows/settings.md": "0845d073009a4619",
"gsd-core/workflows/ship.md": "dbf8bf636cb196c0",

View File

@@ -113,7 +113,7 @@
"gsd-core/bin/gsd_run": "62d9b647ede212e6",
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
"gsd-core/bin/shared/config-schema.manifest.json": "6bba2b9c9fa47cb8",
"gsd-core/bin/shared/model-catalog.json": "eee64a2cf3b94c00",
"gsd-core/bin/shared/model-catalog.json": "d0b59409a46a456a",
"gsd-core/bin/shared/runtime-aliases.manifest.json": "2df2c5ac1957911a",
"gsd-core/bin/verify-reapply-patches.cjs": "caec5dbce11e3904",
"gsd-core/contexts/dev.md": "dcb0de9dce33cf41",
@@ -360,7 +360,7 @@
"gsd-core/workflows/scan.md": "47371c2073d6c0be",
"gsd-core/workflows/secure-phase.md": "e8855104c1e0417c",
"gsd-core/workflows/session-report.md": "2e5b1205324ddefa",
"gsd-core/workflows/settings-advanced.md": "40af6774a38adb07",
"gsd-core/workflows/settings-advanced.md": "e00cd1a5d3fbba9c",
"gsd-core/workflows/settings-integrations.md": "3ea8095d5fad891b",
"gsd-core/workflows/settings.md": "1925ecc2225c2216",
"gsd-core/workflows/ship.md": "afd77be2093535f8",
@@ -383,6 +383,7 @@
"gsd-core/workflows/verify-phase.md": "e0957e153788a222",
"gsd-core/workflows/verify-work.md": "9fc717845828c6e3",
"kilo.json": "13151e97ff23c1aa",
"plugins/gsd-core.js": "931ca839dc9eb7f1",
"scripts/changeset/README.md": "86ff89331dfd94b2",
"scripts/changeset/cli.cjs": "68f92a344b199271",
"scripts/changeset/github-release-notes.cjs": "795677f0c009b132",

View File

@@ -78,7 +78,7 @@
"gsd-core/bin/gsd_run": "62d9b647ede212e6",
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
"gsd-core/bin/shared/config-schema.manifest.json": "6bba2b9c9fa47cb8",
"gsd-core/bin/shared/model-catalog.json": "eee64a2cf3b94c00",
"gsd-core/bin/shared/model-catalog.json": "d0b59409a46a456a",
"gsd-core/bin/shared/runtime-aliases.manifest.json": "2df2c5ac1957911a",
"gsd-core/bin/verify-reapply-patches.cjs": "caec5dbce11e3904",
"gsd-core/contexts/dev.md": "dcb0de9dce33cf41",
@@ -325,7 +325,7 @@
"gsd-core/workflows/scan.md": "003883d71c37da7d",
"gsd-core/workflows/secure-phase.md": "29fc6b62c5c5dc62",
"gsd-core/workflows/session-report.md": "2e5b1205324ddefa",
"gsd-core/workflows/settings-advanced.md": "77ada5fe336ed9f7",
"gsd-core/workflows/settings-advanced.md": "b6fa466a953dd3a2",
"gsd-core/workflows/settings-integrations.md": "70515c5838fb9826",
"gsd-core/workflows/settings.md": "d96ddf01fb85e61e",
"gsd-core/workflows/ship.md": "7b8fe9f89143e648",

View File

@@ -113,7 +113,7 @@
"gsd-core/bin/gsd_run": "62d9b647ede212e6",
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
"gsd-core/bin/shared/config-schema.manifest.json": "6bba2b9c9fa47cb8",
"gsd-core/bin/shared/model-catalog.json": "eee64a2cf3b94c00",
"gsd-core/bin/shared/model-catalog.json": "d0b59409a46a456a",
"gsd-core/bin/shared/runtime-aliases.manifest.json": "2df2c5ac1957911a",
"gsd-core/bin/verify-reapply-patches.cjs": "caec5dbce11e3904",
"gsd-core/contexts/dev.md": "dcb0de9dce33cf41",
@@ -360,7 +360,7 @@
"gsd-core/workflows/scan.md": "ad8ebcad4626d4a8",
"gsd-core/workflows/secure-phase.md": "e9a488cec3b4efdc",
"gsd-core/workflows/session-report.md": "2e5b1205324ddefa",
"gsd-core/workflows/settings-advanced.md": "afa9c38b68500b29",
"gsd-core/workflows/settings-advanced.md": "92524bd2a53b43f5",
"gsd-core/workflows/settings-integrations.md": "d1711a95f44fdbde",
"gsd-core/workflows/settings.md": "2e42ee34c791378a",
"gsd-core/workflows/ship.md": "924c79e3cfd1e42b",

View File

@@ -42,7 +42,7 @@
"gsd-core/bin/gsd_run": "62d9b647ede212e6",
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
"gsd-core/bin/shared/config-schema.manifest.json": "6bba2b9c9fa47cb8",
"gsd-core/bin/shared/model-catalog.json": "eee64a2cf3b94c00",
"gsd-core/bin/shared/model-catalog.json": "d0b59409a46a456a",
"gsd-core/bin/shared/runtime-aliases.manifest.json": "2df2c5ac1957911a",
"gsd-core/bin/verify-reapply-patches.cjs": "caec5dbce11e3904",
"gsd-core/contexts/dev.md": "dcb0de9dce33cf41",
@@ -289,7 +289,7 @@
"gsd-core/workflows/scan.md": "949692db4834dd27",
"gsd-core/workflows/secure-phase.md": "6758f1acf4113e9e",
"gsd-core/workflows/session-report.md": "2e5b1205324ddefa",
"gsd-core/workflows/settings-advanced.md": "6f11a48034be4fa5",
"gsd-core/workflows/settings-advanced.md": "d80d7acc4058cb40",
"gsd-core/workflows/settings-integrations.md": "29c5de27fbbb18e9",
"gsd-core/workflows/settings.md": "26b9b7979d3a5747",
"gsd-core/workflows/ship.md": "9f94d0b155eb041e",

View File

@@ -42,7 +42,7 @@
"gsd-core/bin/gsd_run": "62d9b647ede212e6",
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
"gsd-core/bin/shared/config-schema.manifest.json": "6bba2b9c9fa47cb8",
"gsd-core/bin/shared/model-catalog.json": "eee64a2cf3b94c00",
"gsd-core/bin/shared/model-catalog.json": "d0b59409a46a456a",
"gsd-core/bin/shared/runtime-aliases.manifest.json": "2df2c5ac1957911a",
"gsd-core/bin/verify-reapply-patches.cjs": "caec5dbce11e3904",
"gsd-core/contexts/dev.md": "dcb0de9dce33cf41",
@@ -289,7 +289,7 @@
"gsd-core/workflows/scan.md": "63631467651d9ca8",
"gsd-core/workflows/secure-phase.md": "6cc236e53c2e7d56",
"gsd-core/workflows/session-report.md": "2e5b1205324ddefa",
"gsd-core/workflows/settings-advanced.md": "72d9c0759cc01d99",
"gsd-core/workflows/settings-advanced.md": "90d913043cd11502",
"gsd-core/workflows/settings-integrations.md": "f8f756709ec02363",
"gsd-core/workflows/settings.md": "44b10c59215633b8",
"gsd-core/workflows/ship.md": "c08f0fe3025d2c86",

View File

@@ -42,7 +42,7 @@
"gsd-core/bin/gsd_run": "62d9b647ede212e6",
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
"gsd-core/bin/shared/config-schema.manifest.json": "6bba2b9c9fa47cb8",
"gsd-core/bin/shared/model-catalog.json": "eee64a2cf3b94c00",
"gsd-core/bin/shared/model-catalog.json": "d0b59409a46a456a",
"gsd-core/bin/shared/runtime-aliases.manifest.json": "2df2c5ac1957911a",
"gsd-core/bin/verify-reapply-patches.cjs": "caec5dbce11e3904",
"gsd-core/contexts/dev.md": "dcb0de9dce33cf41",
@@ -289,7 +289,7 @@
"gsd-core/workflows/scan.md": "12c11b2edc165df9",
"gsd-core/workflows/secure-phase.md": "7bf923689bf58288",
"gsd-core/workflows/session-report.md": "2e5b1205324ddefa",
"gsd-core/workflows/settings-advanced.md": "76399a40da18f0b4",
"gsd-core/workflows/settings-advanced.md": "18eff9c17e0b1948",
"gsd-core/workflows/settings-integrations.md": "b082fc518b484c07",
"gsd-core/workflows/settings.md": "002eb0ce3c10c741",
"gsd-core/workflows/ship.md": "c035bb8b3bb7efbb",

View File

@@ -113,7 +113,7 @@
"gsd-core/bin/gsd_run": "62d9b647ede212e6",
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
"gsd-core/bin/shared/config-schema.manifest.json": "6bba2b9c9fa47cb8",
"gsd-core/bin/shared/model-catalog.json": "eee64a2cf3b94c00",
"gsd-core/bin/shared/model-catalog.json": "d0b59409a46a456a",
"gsd-core/bin/shared/runtime-aliases.manifest.json": "2df2c5ac1957911a",
"gsd-core/bin/verify-reapply-patches.cjs": "caec5dbce11e3904",
"gsd-core/contexts/dev.md": "dcb0de9dce33cf41",
@@ -360,7 +360,7 @@
"gsd-core/workflows/scan.md": "003883d71c37da7d",
"gsd-core/workflows/secure-phase.md": "29fc6b62c5c5dc62",
"gsd-core/workflows/session-report.md": "2e5b1205324ddefa",
"gsd-core/workflows/settings-advanced.md": "77ada5fe336ed9f7",
"gsd-core/workflows/settings-advanced.md": "b6fa466a953dd3a2",
"gsd-core/workflows/settings-integrations.md": "70515c5838fb9826",
"gsd-core/workflows/settings.md": "d96ddf01fb85e61e",
"gsd-core/workflows/ship.md": "7b8fe9f89143e648",

View File

@@ -74,9 +74,15 @@ describe('extensionEventSurfaceFor (extension-system event dialect — #1943)',
assert.equal(extensionEventSurfaceFor('nope'), null);
assert.equal(extensionEventSurfaceFor(undefined), null);
});
test('EXTENSION_EVENT_SURFACES is frozen + covers opencode/pi/hermes/none', () => {
test('EXTENSION_EVENT_SURFACES is frozen + covers opencode/pi/hermes/kilo/none', () => {
assert.equal(Object.isFrozen(EXTENSION_EVENT_SURFACES), true);
assert.deepEqual(Object.keys(EXTENSION_EVENT_SURFACES).sort(), ['hermes', 'none', 'opencode', 'pi']);
assert.deepEqual(Object.keys(EXTENSION_EVENT_SURFACES).sort(), ['hermes', 'kilo', 'none', 'opencode', 'pi']);
});
test('kilo reuses the IDENTICAL event array as opencode (Kilo is an OpenCode fork, same bus — #2093)', () => {
const kiloSurface = extensionEventSurfaceFor('kilo');
const opencodeSurface = extensionEventSurfaceFor('opencode');
assert.ok(kiloSurface, 'kilo must resolve (non-null) — it is a consumed extensionEvents value');
assert.deepEqual(kiloSurface, opencodeSurface);
});
});

View File

@@ -702,6 +702,36 @@ describe('issue #2612: runtime "opencode" — OpenCode tier resolution', () => {
});
});
// ─── Issue #2093: kilo runtime tier resolution ───────────────────────────────
// Kilo is an OpenCode fork and shares the IDENTICAL built-in tier IDs (UPGRADE 2
// / ADR-1239). Kilo moved from Group B (no built-in defaults) to Group A here.
describe('issue #2093: runtime "kilo" — Kilo tier resolution', () => {
let tmpDir;
beforeEach(() => { isolateHome(); tmpDir = createTempProject(); resetRuntimeWarningCaches(); });
afterEach(() => { cleanup(tmpDir); restoreHome(); });
test('opus tier -> anthropic/claude-opus-4-8', () => {
writeConfig(tmpDir, { runtime: 'kilo', model_profile: 'quality' });
assert.strictEqual(resolveModelInternal(tmpDir, 'gsd-planner'), 'anthropic/claude-opus-4-8');
});
test('sonnet tier -> anthropic/claude-sonnet-5', () => {
writeConfig(tmpDir, { runtime: 'kilo', model_profile: 'balanced' });
assert.strictEqual(resolveModelInternal(tmpDir, 'gsd-roadmapper'), 'anthropic/claude-sonnet-5');
});
test('haiku tier -> anthropic/claude-haiku-4-5', () => {
writeConfig(tmpDir, { runtime: 'kilo', model_profile: 'budget' });
assert.strictEqual(resolveModelInternal(tmpDir, 'gsd-codebase-mapper'), 'anthropic/claude-haiku-4-5');
});
test('kilo: effort resolves universally but render param is null (no wire param)', () => {
writeConfig(tmpDir, { runtime: 'kilo', model_profile: 'quality' });
const eff = resolveEffortInternal(tmpDir, 'gsd-planner');
assert.strictEqual(renderEffortForRuntime('kilo', eff).param, null);
});
});
// ─── Issue #2612: copilot runtime tier resolution ────────────────────────────
describe('issue #2612: runtime "copilot" — Copilot tier resolution', () => {
let tmpDir;
@@ -736,10 +766,6 @@ describe('issue #2612: Group B runtimes — no built-in map, use unknown-runtime
assert.strictEqual(RUNTIME_PROFILE_MAP.cursor, undefined);
});
test('kilo is not in RUNTIME_PROFILE_MAP', () => {
assert.strictEqual(RUNTIME_PROFILE_MAP.kilo, undefined);
});
test('windsurf is not in RUNTIME_PROFILE_MAP', () => {
assert.strictEqual(RUNTIME_PROFILE_MAP.windsurf, undefined);
});
@@ -830,4 +856,21 @@ describe('issue #2612: partial override merge for new Group A runtimes', () => {
// gsd-planner budget -> sonnet -> built-in default
assert.strictEqual(resolveModelInternal(tmpDir, 'gsd-planner'), 'claude-sonnet-5');
});
// #2093: kilo just moved into Group A — same partial-merge coverage as opencode.
test('kilo.sonnet override wins; opus and haiku still use built-in defaults', () => {
writeConfig(tmpDir, {
runtime: 'kilo',
model_profile: 'balanced',
model_profile_overrides: {
kilo: { sonnet: 'anthropic/claude-sonnet-4-7' },
},
});
// gsd-planner balanced -> opus -> built-in default
assert.strictEqual(resolveModelInternal(tmpDir, 'gsd-planner'), 'anthropic/claude-opus-4-8');
// gsd-roadmapper balanced -> sonnet -> overridden
assert.strictEqual(resolveModelInternal(tmpDir, 'gsd-roadmapper'), 'anthropic/claude-sonnet-4-7');
// gsd-codebase-mapper balanced -> haiku -> built-in default (haiku not overridden)
assert.strictEqual(resolveModelInternal(tmpDir, 'gsd-codebase-mapper'), 'anthropic/claude-haiku-4-5');
});
});

View File

@@ -0,0 +1,182 @@
// allow-test-rule: structural-regression-guard — AC2 requires asserting no `runtime === 'kilo'` string-equality branch (nor an `isKilo` logic branch) remains in bin/install.js, src/install-engine.cts, src/runtime-artifact-conversion.cts, and src/runtime-artifact-layout.cts — the descriptor-migration contract is a property of the source text, so a source-grep is the only faithful check (#2093)
'use strict';
/**
* kilo imperative reference host — ADR-1239 Phase D / #2093 (EoS/kilo).
*
* Proves Kilo Code is driven through the PUBLIC Host-Integration Interface
* (the imperative adapter), that its negotiated axes classify + negotiate
* correctly, that negotiation fails CLOSED on a corrupted descriptor, and
* that the migration retired the hardcoded `runtime === 'kilo'` / `isKilo`
* branches across the install engine, artifact conversion, and artifact
* layout modules (folded into descriptor-driven `runtime.hostBehaviors`).
*
* Kilo is an OpenCode fork (same plugin/extension event bus, same static
* agent-frontmatter model constraint) but its `dispatch.subagentToolkit` is
* `'undocumented'` — no authoritative Kilo doc states a default subagent
* toolkit level — so unlike OpenCode/Qwen/Cursor, Kilo's dispatch interface
* point degrades to `'degraded'`, not `'full'`, even though every other
* dispatch axis (namedDispatch/nested/maxDepth/background) matches the
* unbounded-depth programmatic-cli baseline. This is the fail-closed
* negotiation contract working as designed (see AC-specific test below).
* The real upgrades (native `.kilo/plugins/gsd-core.js` hook-bus plugin,
* active-model routing, MCP companion doc, named agent dispatch) are covered
* in tests/kilo-upgrades.test.cjs.
*/
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { createImperativeAdapter } = require('../gsd-core/bin/lib/adapter-imperative.cjs');
const {
profileOf,
negotiateHostCapabilities,
degradationFor,
extensionEventSurfaceFor,
PROFILE_BASELINES,
UNDOCUMENTED,
} = require('../gsd-core/bin/lib/host-integration.cjs');
const KILO_CAP = JSON.parse(
fs.readFileSync(path.join(__dirname, '..', 'capabilities', 'kilo', 'capability.json'), 'utf8'),
);
const KILO_AXES = KILO_CAP.runtime.hostIntegration;
// -- AC2: driven through the public interface (imperative adapter) -----------
test('createImperativeAdapter classifies kilo as imperative + composes the registry', () => {
const adapter = createImperativeAdapter({ runtime: 'kilo' });
assert.equal(adapter.kind, 'imperative');
assert.equal(adapter.runtime, 'kilo');
assert.ok(adapter.registry && typeof adapter.registry === 'object');
assert.equal(typeof adapter.install, 'function');
assert.equal(typeof adapter.uninstall, 'function');
});
test('kilo axes classify as the programmatic-cli reference profile', () => {
// Confirmed via `node -e` against the real descriptor before asserting:
// profileOf(KILO_AXES) === 'programmatic-cli' (embeddingMode: 'imperative').
assert.equal(profileOf(KILO_AXES), 'programmatic-cli');
});
// -- AC3: all axes populated + validated -------------------------------------
test('kilo descriptor declares all 8 axes + 6 dispatch sub-axes with exact values', () => {
assert.equal(KILO_AXES.embeddingMode, 'imperative');
assert.equal(KILO_AXES.commandSurface, 'slash-file');
assert.equal(KILO_AXES.modelMode, 'active');
assert.equal(KILO_AXES.hookBus, 'host');
assert.equal(KILO_AXES.stateIO, 'filesystem');
assert.equal(KILO_AXES.transport, 'mcp');
assert.equal(KILO_AXES.runtime, 'bun');
const d = KILO_AXES.dispatch;
assert.equal(d.namedDispatch, true);
assert.equal(d.nested, true);
assert.equal(d.maxDepth, -1);
assert.equal(d.background, true);
assert.equal(d.subagentToolkit, 'undocumented');
assert.equal(d.backgroundDispatch, false);
});
// -- AC5: negotiation fails CLOSED on a corrupted descriptor ------------------
test('negotiateHostCapabilities never throws for kilo, even fully corrupted', () => {
assert.doesNotThrow(() => negotiateHostCapabilities({}));
assert.doesNotThrow(() => negotiateHostCapabilities({ ...KILO_AXES, embeddingMode: UNDOCUMENTED }));
assert.doesNotThrow(() => negotiateHostCapabilities({ ...KILO_AXES, embeddingMode: 'future-unknown' }));
});
test('AC-SPECIFIC: kilo real axes degrade dispatch to "degraded", not "full", because subagentToolkit is "undocumented"', () => {
// Confirmed via `node -e` against the real descriptor before asserting:
// degradationFor('dispatch', { dispatch: KILO_AXES.dispatch }) returns
// { level: 'degraded', fallback: 'restricted/undocumented subagent toolkit — limited dispatch surface' }.
//
// Every OTHER dispatch axis matches the unbounded-depth programmatic-cli
// baseline (namedDispatch:true, nested:true, maxDepth:-1 = unbounded,
// background:true) — the degradationFor 'dispatch' interface point would
// return 'full' for that shape IF subagentToolkit were 'full' (see
// src/host-integration.cts degradationFor, the `disp.subagentToolkit === 'full'`
// gate ~line 183). Kilo's subagentToolkit is 'undocumented' (no authoritative
// default toolkit level is documented for Kilo subagents), so the gate fails
// closed and dispatch degrades instead of reporting full capability.
const result = degradationFor('dispatch', { dispatch: KILO_AXES.dispatch });
assert.equal(result.level, 'degraded');
assert.notEqual(result.level, 'full');
assert.match(result.fallback, /undocumented subagent toolkit/);
// Sanity: if subagentToolkit WERE 'full' (all else equal), the same shape
// would degrade to 'full' — proving the gate is what flips the result, not
// some other axis.
const hypotheticalFull = degradationFor('dispatch', { dispatch: { ...KILO_AXES.dispatch, subagentToolkit: 'full' } });
assert.equal(hypotheticalFull.level, 'full');
});
test('a partial/empty kilo descriptor degrades to the safe floor, not the programmatic-cli baseline', () => {
const result = negotiateHostCapabilities({});
assert.equal(result.effective.embeddingMode, 'declarative', 'omitted embeddingMode degrades closed');
assert.equal(result.effective.hookBus, 'none');
assert.notDeepEqual(result.effective, PROFILE_BASELINES['programmatic-cli']);
assert.ok(result.warnings.length > 0);
});
// -- AC2: the folded-in behaviors ---------------------------------------------
test('kilo descriptor declares runtime.hostBehaviors (the folded-in behaviors)', () => {
const hb = KILO_CAP.runtime.hostBehaviors;
assert.ok(hb && typeof hb === 'object');
assert.equal(hb.attributionConfigResolver, 'kilo');
assert.equal(hb.flatCommandDir, 'command');
assert.equal(hb.combinedFamilyInstall, true);
assert.equal(hb.frontmatterDialect, 'kilo');
assert.equal(hb.skipUpdateBannerCommand, true);
assert.equal(hb.skipSharedHooksInstall, true);
assert.ok(hb.nativePlugin && typeof hb.nativePlugin === 'object');
assert.equal(hb.nativePlugin.dir, 'plugins');
assert.equal(hb.nativePlugin.file, 'gsd-core.js');
assert.equal(hb.nativePlugin.source, '.kilo/plugins/gsd-core.js');
});
// -- UPGRADE 1 dialect: kilo shares OpenCode's extension-event bus -----------
test('kilo declares extensionEvents:"kilo" and its event surface equals OpenCode\'s (Kilo is an OpenCode fork)', () => {
assert.equal(KILO_CAP.runtime.extensionEvents, 'kilo');
const kiloSurface = extensionEventSurfaceFor('kilo');
const opencodeSurface = extensionEventSurfaceFor('opencode');
assert.ok(Array.isArray(kiloSurface) && kiloSurface.length > 0, 'kilo is a consumed extensionEvents dialect (non-empty surface)');
assert.deepEqual(kiloSurface, opencodeSurface, 'kilo reuses OPENCODE_EXTENSION_EVENTS verbatim — same bus');
});
// -- AC2: the hardcoded branches are retired across all folded modules -------
test('no `runtime === "kilo"` / `isKilo` logic branch remains in the descriptor-migrated modules (AC2)', () => {
const strip = (src) => src
.replace(/\/\*[\s\S]*?\*\//g, '')
.replace(/\/\/[^\r\n]*/g, '')
.replace(/`[^`]*`/g, '');
const repoRoot = path.join(__dirname, '..');
const files = [
path.join(repoRoot, 'bin', 'install.js'),
path.join(repoRoot, 'src', 'install-engine.cts'),
path.join(repoRoot, 'src', 'runtime-artifact-conversion.cts'),
path.join(repoRoot, 'src', 'runtime-artifact-layout.cts'),
];
for (const file of files) {
const stripped = strip(fs.readFileSync(file, 'utf8'));
const equalityOffenders = stripped.match(/runtime\s*[!=]==\s*'kilo'/g) || [];
assert.deepEqual(equalityOffenders, [],
`AC2: no hardcoded runtime==='kilo' branch may remain in ${path.relative(repoRoot, file)}; found: ${equalityOffenders.join(', ')}`);
// A plain `isKilo` binding (e.g. destructured off runtimeFlags()) is fine —
// it's the LOGIC branches keyed on it that must be retired.
const logicOffenders = [
...(stripped.match(/if\s*\(\s*isKilo/g) || []),
...(stripped.match(/isKilo\s*[&|?]/g) || []),
...(stripped.match(/[&|]\s*isKilo/g) || []),
];
assert.deepEqual(logicOffenders, [],
`AC2: no isKilo logic branch may remain in ${path.relative(repoRoot, file)}; found: ${logicOffenders.join(', ')}`);
}
});

View File

@@ -0,0 +1,295 @@
'use strict';
/**
* kilo capability UPGRADES — ADR-1239 Phase D / #2093 (EoS/kilo).
*
* Drives the user-reachable surface (spawned `bin/install.js` via
* `runMinimalInstall`) plus targeted unit coverage to prove the four real
* upgrades Kilo contributes as part of the imperative-adapter migration:
*
* UPGRADE 1 — native hook-bus plugin: `.kilo/plugins/gsd-core.js`, a
* byte-identical copy of `.opencode/plugins/gsd-core.js` (Kilo is an
* OpenCode fork sharing the same plugin/extension event bus).
*
* UPGRADE 2 — active-model routing: `convertClaudeToKiloFrontmatter` now
* emits a `model:` field from the resolved model override instead of
* always stripping it (mirrors the OpenCode upgrade, #2256).
*
* UPGRADE 3 — MCP companion documented + reachable: `docs/how-to/connect-gsd-mcp-server.md`
* covers Kilo's `mcp`-keyed config (not `mcpServers`), and the companion the
* doc points at (`bin/gsd-mcp-server.js`) is proven live by spawning it and
* performing a real initialize + tools/list handshake (AC4: "test: connect
* and list tools") — mirrors tests/gsd-mcp-server-bin.test.cjs exactly.
*
* UPGRADE 4 — named subagent dispatch: GSD's specialist agents install as
* `<configDir>/agents/gsd-*.md` with `mode: subagent` + a `permission:`
* block — the slug Kilo's Task tool dispatches by.
*/
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { spawnSync } = require('node:child_process');
const { runMinimalInstall } = require('./helpers/install-shared.cjs');
const { cleanup } = require('./helpers.cjs');
const { listAgentFiles } = require('./helpers/agent-roster.cjs');
const { convertClaudeToKiloFrontmatter } = require('../bin/install.js');
const { PROTOCOL_VERSION } = require('../gsd-core/bin/lib/mcp-server.cjs');
const MCP_SERVER_BIN = path.join(__dirname, '..', 'bin', 'gsd-mcp-server.js');
const KILO_CAP = JSON.parse(
fs.readFileSync(path.join(__dirname, '..', 'capabilities', 'kilo', 'capability.json'), 'utf8'),
);
const ADAPTER_SRC = path.join(__dirname, '..', '.kilo', 'plugins', 'gsd-core.js');
const OPENCODE_ADAPTER_SRC = path.join(__dirname, '..', '.opencode', 'plugins', 'gsd-core.js');
/** Extract the YAML frontmatter block (between the first pair of `---` lines), or null. */
function parseFrontmatter(content) {
const m = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
return m ? m[1] : null;
}
// ---------------------------------------------------------------------------
// UPGRADE 1: native hook-bus plugin (.kilo/plugins/gsd-core.js)
// ---------------------------------------------------------------------------
for (const scope of ['global', 'local']) {
test(`kilo --${scope}: installs .../plugins/gsd-core.js byte-identical to the repo source (UPGRADE 1)`, (t) => {
const { configDir, root } = runMinimalInstall({ runtime: 'kilo', scope });
t.after(() => cleanup(root));
const installedPluginPath = path.join(configDir, 'plugins', 'gsd-core.js');
assert.ok(fs.existsSync(installedPluginPath), `${installedPluginPath} must exist`);
const installed = fs.readFileSync(installedPluginPath);
const source = fs.readFileSync(ADAPTER_SRC);
assert.ok(installed.equals(source), 'installed plugin must byte-equal the repo .kilo/plugins/gsd-core.js source');
});
}
// Faithful emulation of a plugin loader: `getServerPlugin` accepts a bare
// function OR an object with a `.server` function (mirrors the OpenCode
// loader contract Kilo forked, tests/opencode-plugin-adapter.test.cjs).
function getServerPlugin(entry) {
if (typeof entry === 'function') return entry;
if (entry && typeof entry === 'object' && typeof entry.server === 'function') return entry.server;
return null;
}
function loaderExtract(mod) {
const servers = [];
for (const entry of Object.values(mod)) {
const s = getServerPlugin(entry);
if (!s) throw new TypeError('Plugin export is not a function');
servers.push(s);
}
return servers;
}
test('.kilo/plugins/gsd-core.js loads as raw CommonJS and exposes id "gsd-core" + server._internals (UPGRADE 1)', () => {
const mod = require(ADAPTER_SRC);
assert.equal(mod.id, 'gsd-core');
// NON-ENUMERABLE so it never lands in Object.values (would throw in the loader loop).
assert.ok(!Object.keys(mod).includes('id'), 'id must be non-enumerable');
const servers = loaderExtract(mod); // must not throw
assert.equal(servers.length, 1);
assert.equal(typeof servers[0], 'function');
assert.equal(typeof mod.server._internals, 'object');
assert.ok(mod.server._internals, 'server._internals must be present');
});
// DEFECT.GENERATIVE-FIX parity guard: .kilo/plugins/gsd-core.js is a deliberate
// byte-copy of .opencode/plugins/gsd-core.js (Kilo is an OpenCode fork sharing
// the same plugin/extension event bus, see the UPGRADE 1 doc comment above).
// Nothing enforces that copy relationship — a future edit to either file that
// forgets its twin would silently drift the two runtimes apart. This fails the
// instant that happens.
test('.kilo/plugins/gsd-core.js stays byte-identical to .opencode/plugins/gsd-core.js (Kilo is an OpenCode fork; parity guard, UPGRADE 1)', () => {
const kilo = fs.readFileSync(ADAPTER_SRC, 'utf8');
const opencode = fs.readFileSync(OPENCODE_ADAPTER_SRC, 'utf8');
assert.equal(
kilo,
opencode,
'.kilo/plugins/gsd-core.js and .opencode/plugins/gsd-core.js must stay byte-identical — ' +
'Kilo is an OpenCode fork and intentionally reuses the same plugin verbatim; if you edited ' +
'one, mirror the change into the other (or this guard will keep failing).',
);
});
// ---------------------------------------------------------------------------
// UPGRADE 2: active-model routing (convertClaudeToKiloFrontmatter)
// ---------------------------------------------------------------------------
const SAMPLE_AGENT = `---
name: gsd-executor
description: Executes GSD plans with atomic commits
tools: Read, Write, Edit, Bash, Grep, Glob
color: yellow
---
<role>
You are a GSD plan executor.
</role>`;
const SAMPLE_COMMAND = `---
name: gsd-execute-phase
description: Execute all plans in a phase
allowed-tools:
- Read
- Write
- Bash
---
Execute the phase plan.`;
test('UPGRADE 2: convertClaudeToKiloFrontmatter emits model: when isAgent + modelOverride is provided', () => {
const result = convertClaudeToKiloFrontmatter(SAMPLE_AGENT, { isAgent: true, modelOverride: 'anthropic/claude-sonnet-5' });
const frontmatter = result.split('---')[1];
assert.match(frontmatter, /^model: anthropic\/claude-sonnet-5$/m, 'model: field must carry the resolved override');
});
test('UPGRADE 2: convertClaudeToKiloFrontmatter emits NO model: when isAgent + modelOverride is null', () => {
const result = convertClaudeToKiloFrontmatter(SAMPLE_AGENT, { isAgent: true, modelOverride: null });
const frontmatter = result.split('---')[1];
assert.ok(!/^model:/m.test(frontmatter), 'model: field must be absent when no override is resolved');
});
test('UPGRADE 2: convertClaudeToKiloFrontmatter emits NO model: for commands, even with a modelOverride (commands strip)', () => {
const result = convertClaudeToKiloFrontmatter(SAMPLE_COMMAND, { isAgent: false, modelOverride: 'x' });
const frontmatter = result.split('---')[1];
assert.ok(!/^model:/m.test(frontmatter), 'commands never carry a model: field, regardless of modelOverride');
});
// Note: a bare runMinimalInstall does NOT configure a runtime model_overrides/
// model_profile_overrides config, so installed agents will NOT carry a model:
// line from a plain install — that is expected (readGsdEffectiveModelOverrides
// / readGsdRuntimeProfileResolver resolve to nothing) and is NOT a regression.
// The unit tests above are the correct surface for proving U2's "stop
// stripping, emit requested model" behavior change.
// ---------------------------------------------------------------------------
// UPGRADE 3: MCP companion documented
// ---------------------------------------------------------------------------
// allow-test-rule: docs-parity (#2093) — docs/how-to/connect-gsd-mcp-server.md
// must document Kilo's real mcp-keyed config (not mcpServers); the doc prose IS
// the canonical statement of that fact and there is no runtime API to enumerate
// it, so reading the file and asserting on its text is the only parity check
// available.
test('UPGRADE 3: docs/how-to/connect-gsd-mcp-server.md documents Kilo\'s mcp-keyed config', () => {
const docPath = path.join(__dirname, '..', 'docs', 'how-to', 'connect-gsd-mcp-server.md');
const doc = fs.readFileSync(docPath, 'utf8');
assert.match(doc, /Kilo/, 'doc must mention Kilo');
assert.match(doc, /`mcp` key \(\*\*not\*\* `mcpServers`\)/,
'doc must call out the mcp (not mcpServers) key for Kilo/OpenCode');
assert.ok(doc.includes('"mcp"'), 'doc must show the literal "mcp" config key');
assert.ok(doc.includes('opencode.jsonc') || doc.includes('opencode.json'),
'doc must name Kilo\'s native config file (shared with OpenCode\'s schema)');
});
// AC4 ("test: connect and list tools"): prove the companion Kilo's `mcp` config
// points at (bin/gsd-mcp-server.js) is actually reachable, not just documented.
// Mirrors tests/gsd-mcp-server-bin.test.cjs's spawn/handshake mechanism exactly
// (same shim, same line-delimited JSON-RPC over stdio, same clean-exit-on-EOF
// contract) rather than reinventing the protocol handshake.
test('UPGRADE 3: gsd-mcp-server companion is reachable — spawn, initialize, tools/list over stdio (AC4)', () => {
const stdin = [
JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize' }),
JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'tools/list' }),
].join('\n') + '\n';
const res = spawnSync(process.execPath, [MCP_SERVER_BIN], {
input: stdin,
encoding: 'utf-8',
timeout: 15000,
env: { ...process.env, GSD_TEST_MODE: '1' },
});
assert.strictEqual(res.status, 0, `gsd-mcp-server must exit cleanly on stdin EOF; stderr: ${res.stderr}`);
const lines = res.stdout.trim().split('\n').map((l) => JSON.parse(l));
assert.strictEqual(lines.length, 2, 'one response per request');
assert.strictEqual(lines[0].id, 1);
assert.strictEqual(lines[0].result.protocolVersion, PROTOCOL_VERSION, 'initialize handshake succeeds');
const toolNames = lines[1].result.tools.map((t) => t.name).sort();
assert.deepStrictEqual(
toolNames,
['gsd_invoke_command', 'gsd_read_state', 'gsd_write_state'],
'the companion Kilo\'s mcp config connects to advertises the real GSD tool surface',
);
});
// ---------------------------------------------------------------------------
// UPGRADE 4: named subagent dispatch (agents/*.md, mode: subagent)
// ---------------------------------------------------------------------------
const KILO_AGENT_PERMISSION_KEYS = [
'read', 'edit', 'bash', 'grep', 'glob', 'task',
'webfetch', 'websearch', 'skill', 'question', 'todowrite', 'list', 'codesearch', 'lsp',
];
for (const scope of ['global', 'local']) {
test(`kilo --${scope}: native agents/*.md subagent projection with mode: subagent (UPGRADE 4)`, (t) => {
const { configDir, root } = runMinimalInstall({ runtime: 'kilo', scope });
t.after(() => cleanup(root));
const agentsDir = path.join(configDir, 'agents');
assert.ok(fs.existsSync(agentsDir), `${agentsDir} must exist`);
const expectedNames = listAgentFiles();
assert.equal(expectedNames.length, 34,
'sanity: shipped GSD agent roster is 34 files — update this boundary if the roster changes');
const installedFiles = fs.readdirSync(agentsDir)
.filter((f) => f.startsWith('gsd-') && f.endsWith('.md'));
assert.ok(installedFiles.length >= expectedNames.length,
`expected at least ${expectedNames.length} installed agents under ${agentsDir}, got ${installedFiles.length}`);
for (const name of expectedNames) {
assert.ok(installedFiles.includes(`${name}.md`), `${name}.md must be installed under ${agentsDir}`);
}
for (const known of ['gsd-code-reviewer', 'gsd-planner', 'gsd-executor']) {
const filePath = path.join(agentsDir, `${known}.md`);
assert.ok(fs.existsSync(filePath), `${filePath} must exist`);
const content = fs.readFileSync(filePath, 'utf8');
const fm = parseFrontmatter(content);
assert.ok(fm, `${known}.md must have YAML frontmatter`);
assert.match(fm, /^name:\s*\S+/m, `${known}.md frontmatter must declare name:`);
assert.match(fm, /^mode:\s*subagent\s*$/m,
`${known}.md frontmatter must declare mode: subagent (the slug Kilo's Task tool dispatches by)`);
assert.match(fm, /^permission:\s*$/m, `${known}.md frontmatter must declare a permission: block`);
for (const key of KILO_AGENT_PERMISSION_KEYS) {
assert.match(fm, new RegExp(`^\\s+${key}:\\s*(allow|deny)\\s*$`, 'm'),
`${known}.md permission: block must declare ${key}: allow|deny`);
}
// Branding-residue checks are scoped to the FRONTMATTER — the part the
// opencode/kilo converter fully rewrites into Kilo-native form. The agent
// BODY legitimately retains Claude-Code source references byte-identical to
// opencode's installed agents (verified): the shared runtime-launcher shell
// preamble's git-root `.claude/` fallback
// (`${RUNTIME_DIR:-$(git rev-parse --show-toplevel)/.claude/…}`) and prose
// product-name mentions (e.g. "…inside a Claude Code worktree…"). These are
// family-wide launcher/prose artifacts, not kilo conversion defects — the
// opencode/kilo family, unlike qwen's aggressive converter, does not rewrite
// body prose.
assert.ok(!fm.includes('CLAUDE.md'), `${known}.md frontmatter must not contain residual "CLAUDE.md"`);
assert.ok(!fm.includes('Claude Code'), `${known}.md frontmatter must not contain residual "Claude Code"`);
assert.ok(!fm.includes('.claude/'), `${known}.md frontmatter must not contain residual ".claude/"`);
}
});
}
// -- boundary/negative: hooksSurface:'none' + subagentToolkit stays undocumented
test('capabilities/kilo/capability.json extendedHookEvents is exactly [] (hooksSurface: "none") and dispatch.subagentToolkit stays "undocumented"', () => {
assert.deepEqual(KILO_CAP.runtime.extendedHookEvents, []);
assert.equal(KILO_CAP.runtime.hooksSurface, 'none');
assert.equal(KILO_CAP.runtime.hostIntegration.dispatch.subagentToolkit, 'undocumented');
});

View File

@@ -229,6 +229,44 @@ Fallback skills live in .agents/skills/.`;
}
}
// ─────────────────────────────────────────────────────────────────────────────
// DEFECT.GENERATIVE-FIX output-parity guard: convertClaudeToKiloFrontmatter is
// defined TWICE — once in bin/install.js (the function bound at the top of this
// file, used by bin/install.js's own legacy install path) and once in
// src/runtime-artifact-conversion.cts, compiled to
// gsd-core/bin/lib/runtime-artifact-conversion.cjs (used by
// src/install-engine.cts's newer TS install path, see install-engine.cts ~L871).
// Both copies are LIVE — neither re-exports the other — so #2093's modelOverride
// edit had to be applied twice by hand. Source-text identity can't be asserted
// (they live in different module systems: plain CJS vs a tsc-compiled .cts
// output with different surrounding comments), so this instead proves the two
// implementations still produce IDENTICAL output for representative agent and
// command input. If a future edit changes one copy's behavior without mirroring
// it into the other, this test is the guard that catches the divergence.
// ─────────────────────────────────────────────────────────────────────────────
describe('convertClaudeToKiloFrontmatter output parity: bin/install.js vs runtime-artifact-conversion.cjs (#2093)', () => {
const { convertClaudeToKiloFrontmatter: convertViaConversionModule } =
require('../gsd-core/bin/lib/runtime-artifact-conversion.cjs');
test('identical output for an agent with a model override', () => {
const viaInstall = convertClaudeToKiloFrontmatter(SAMPLE_AGENT, { isAgent: true, modelOverride: 'anthropic/claude-sonnet-5' });
const viaModule = convertViaConversionModule(SAMPLE_AGENT, { isAgent: true, modelOverride: 'anthropic/claude-sonnet-5' });
assert.equal(viaInstall, viaModule, 'bin/install.js and runtime-artifact-conversion.cjs must emit identical agent output');
});
test('identical output for an agent with no model override', () => {
const viaInstall = convertClaudeToKiloFrontmatter(SAMPLE_AGENT, { isAgent: true, modelOverride: null });
const viaModule = convertViaConversionModule(SAMPLE_AGENT, { isAgent: true, modelOverride: null });
assert.equal(viaInstall, viaModule, 'bin/install.js and runtime-artifact-conversion.cjs must emit identical agent output with no override');
});
test('identical output for a command (model override never applies)', () => {
const viaInstall = convertClaudeToKiloFrontmatter(SAMPLE_COMMAND, { isAgent: false, modelOverride: 'x' });
const viaModule = convertViaConversionModule(SAMPLE_COMMAND, { isAgent: false, modelOverride: 'x' });
assert.equal(viaInstall, viaModule, 'bin/install.js and runtime-artifact-conversion.cjs must emit identical command output');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Antigravity agent conversion — shared Gemini-backend tool mapping (#1394 / #1928)
// ─────────────────────────────────────────────────────────────────────────────

View File

@@ -1,7 +1,7 @@
/**
* Stale-bake guard tests (#1688, follow-up to #1650).
*
* Regression contract: on a static-frontmatter runtime (codex/opencode), if
* Regression contract: on a static-frontmatter runtime (codex/opencode/kilo), if
* `.planning/config.json` or `~/.gsd/defaults.json` was edited AFTER the
* installed agent files were baked, `warnIfStaleBake` MUST emit a single
* stderr warning naming the config path and the remediation command. Before
@@ -71,6 +71,13 @@ describe('stale-bake-guard.detectStaleBake (pure decision)', () => {
);
});
test('kilo runtime honored symmetrically with opencode/codex (#2093)', () => {
assert.deepEqual(
detectStaleBake({ runtime: 'kilo', configMtimeMs: AGENT_MS + 5000, agentMtimeMs: AGENT_MS }),
{ stale: true, deltaMs: 5000 },
);
});
test('non-finite mtimes rejected (NaN / Infinity)', () => {
assert.equal(detectStaleBake({ runtime: 'opencode', configMtimeMs: NaN, agentMtimeMs: AGENT_MS }), null);
assert.equal(detectStaleBake({ runtime: 'opencode', configMtimeMs: Infinity, agentMtimeMs: AGENT_MS }), null);
@@ -114,6 +121,13 @@ describe('stale-bake-guard.formatStaleBakeWarning (pure formatter)', () => {
assert.match(w, /gsd install --codex/);
assert.doesNotMatch(w, /--opencode/);
});
test('kilo warning uses --kilo flag (not --opencode/--codex) (#2093)', () => {
const w = formatStaleBakeWarning({ runtime: 'kilo', configPath: '/x', configMtimeMs: 1_700_000_000_000, agentMtimeMs: 1 });
assert.match(w, /gsd install --kilo/);
assert.doesNotMatch(w, /--opencode/);
assert.doesNotMatch(w, /--codex\b/);
});
});
// ---------------------------------------------------------------------------
@@ -143,11 +157,17 @@ describe('stale-bake-guard.resolveAgentDir (env-var aware)', () => {
// the raw return — that fails windows-latest CI.
const posix = (p) => String(p).replace(/\\/g, '/');
test('opencode default lands under ~/.config/opencode/agent', () => {
assert.equal(posix(resolveAgentDir('opencode', { env: {}, homedir: () => '/H' })), '/H/.config/opencode/agent');
// #2093: fixed from the stale singular `.../agent` — the real installer
// writes to the plural `agents/` dir (bin/install.js's universal
// `agentsDest = path.join(targetDir, 'agents')`), verified against a live
// `--opencode --global` install. The prior singular assertion here matched
// the (wrong) implementation, which made `warnIfStaleBake` a silent no-op
// for opencode in production — see resolveAgentDir's inline comment.
test('opencode default lands under ~/.config/opencode/agents', () => {
assert.equal(posix(resolveAgentDir('opencode', { env: {}, homedir: () => '/H' })), '/H/.config/opencode/agents');
});
test('opencode honors OPENCODE_CONFIG_DIR', () => {
assert.equal(posix(resolveAgentDir('opencode', { env: { OPENCODE_CONFIG_DIR: '/custom/oc' }, homedir: () => '/H' })), '/custom/oc/agent');
assert.equal(posix(resolveAgentDir('opencode', { env: { OPENCODE_CONFIG_DIR: '/custom/oc' }, homedir: () => '/H' })), '/custom/oc/agents');
});
test('codex default lands under ~/.codex/agents', () => {
assert.equal(posix(resolveAgentDir('codex', { env: {}, homedir: () => '/H' })), '/H/.codex/agents');
@@ -155,6 +175,12 @@ describe('stale-bake-guard.resolveAgentDir (env-var aware)', () => {
test('codex honors CODEX_HOME', () => {
assert.equal(posix(resolveAgentDir('codex', { env: { CODEX_HOME: '/custom/cx' }, homedir: () => '/H' })), '/custom/cx/agents');
});
test('kilo default lands under ~/.config/kilo/agents (#2093)', () => {
assert.equal(posix(resolveAgentDir('kilo', { env: {}, homedir: () => '/H' })), '/H/.config/kilo/agents');
});
test('kilo honors KILO_CONFIG_DIR (#2093)', () => {
assert.equal(posix(resolveAgentDir('kilo', { env: { KILO_CONFIG_DIR: '/custom/ko' }, homedir: () => '/H' })), '/custom/ko/agents');
});
test('unsupported runtime → null', () => {
assert.equal(resolveAgentDir('gemini', { env: {}, homedir: () => '/H' }), null);
});
@@ -210,11 +236,14 @@ describe('stale-bake-guard.warnIfStaleBake (orchestrator, fixtures)', () => {
function cxEnv(agentParentDir) {
return { CODEX_HOME: agentParentDir };
}
function koEnv(agentParentDir) {
return { KILO_CONFIG_DIR: agentParentDir };
}
test('claude runtime → no warning even when config is newer than agents', () => {
// OpenCode agent dir shape so the runtime path resolves, but runtime is claude.
const agentParent = path.join(tmpRoot, 'oc-config');
const agentDir = path.join(agentParent, 'agent');
const agentDir = path.join(agentParent, 'agents');
setupProject({ runtime: 'claude', configMtime: NEWER, agentDir, agentMtime: OLDER });
const wrote = warnIfStaleBake(tmpRoot, { stderr: stderrStub, homedir: () => tmpRoot, env: ocEnv(agentParent) });
assert.equal(wrote, false);
@@ -223,7 +252,7 @@ describe('stale-bake-guard.warnIfStaleBake (orchestrator, fixtures)', () => {
test('opencode: config NEWER than agents → warning written (the #1650 failure mode)', () => {
const agentParent = path.join(tmpRoot, 'oc-config');
const agentDir = path.join(agentParent, 'agent');
const agentDir = path.join(agentParent, 'agents');
setupProject({ runtime: 'opencode', configMtime: NEWER, agentDir, agentMtime: OLDER });
const wrote = warnIfStaleBake(tmpRoot, { stderr: stderrStub, homedir: () => tmpRoot, env: ocEnv(agentParent) });
assert.equal(wrote, true);
@@ -242,9 +271,31 @@ describe('stale-bake-guard.warnIfStaleBake (orchestrator, fixtures)', () => {
assert.match(chunks[0], /gsd install --codex/);
});
// #2093: kilo bakes model: like opencode (same static-frontmatter constraint).
test('kilo: config NEWER than agents → warning written with --kilo flag', () => {
const agentParent = path.join(tmpRoot, 'ko-config');
const agentDir = path.join(agentParent, 'agents');
setupProject({ runtime: 'kilo', configMtime: NEWER, agentDir, agentMtime: OLDER });
const wrote = warnIfStaleBake(tmpRoot, { stderr: stderrStub, homedir: () => tmpRoot, env: koEnv(agentParent) });
assert.equal(wrote, true);
assert.equal(chunks.length, 1);
assert.match(chunks[0], /model config in .*config\.json changed since agents were last baked/);
assert.match(chunks[0], /'kilo'/);
assert.match(chunks[0], /gsd install --kilo/);
});
test('kilo: config OLDER than agents → no warning (boundary limit-1)', () => {
const agentParent = path.join(tmpRoot, 'ko-config');
const agentDir = path.join(agentParent, 'agents');
setupProject({ runtime: 'kilo', configMtime: OLDER, agentDir, agentMtime: NEWER });
const wrote = warnIfStaleBake(tmpRoot, { stderr: stderrStub, homedir: () => tmpRoot, env: koEnv(agentParent) });
assert.equal(wrote, false);
assert.deepEqual(chunks, []);
});
test('opencode: config OLDER than agents → no warning (boundary limit-1)', () => {
const agentParent = path.join(tmpRoot, 'oc-config');
const agentDir = path.join(agentParent, 'agent');
const agentDir = path.join(agentParent, 'agents');
setupProject({ runtime: 'opencode', configMtime: OLDER, agentDir, agentMtime: NEWER });
const wrote = warnIfStaleBake(tmpRoot, { stderr: stderrStub, homedir: () => tmpRoot, env: ocEnv(agentParent) });
assert.equal(wrote, false);
@@ -253,7 +304,7 @@ describe('stale-bake-guard.warnIfStaleBake (orchestrator, fixtures)', () => {
test('opencode: config EQUAL to agents → no warning (boundary limit)', () => {
const agentParent = path.join(tmpRoot, 'oc-config');
const agentDir = path.join(agentParent, 'agent');
const agentDir = path.join(agentParent, 'agents');
setupProject({ runtime: 'opencode', configMtime: NEWER, agentDir, agentMtime: NEWER });
const wrote = warnIfStaleBake(tmpRoot, { stderr: stderrStub, homedir: () => tmpRoot, env: ocEnv(agentParent) });
assert.equal(wrote, false);
@@ -272,7 +323,7 @@ describe('stale-bake-guard.warnIfStaleBake (orchestrator, fixtures)', () => {
test('opencode: agent dir present but no gsd-* files → no warning', () => {
const agentParent = path.join(tmpRoot, 'oc-config');
const agentDir = path.join(agentParent, 'agent');
const agentDir = path.join(agentParent, 'agents');
setupProject({ runtime: 'opencode', configMtime: NEWER, agentDir, agentMtime: OLDER, agentFiles: ['some-other-agent.md'] });
const wrote = warnIfStaleBake(tmpRoot, { stderr: stderrStub, homedir: () => tmpRoot, env: ocEnv(agentParent) });
assert.equal(wrote, false);
@@ -280,7 +331,7 @@ describe('stale-bake-guard.warnIfStaleBake (orchestrator, fixtures)', () => {
test('dedup: second call with same (runtime, cwd) → no repeat warning', () => {
const agentParent = path.join(tmpRoot, 'oc-config');
const agentDir = path.join(agentParent, 'agent');
const agentDir = path.join(agentParent, 'agents');
setupProject({ runtime: 'opencode', configMtime: NEWER, agentDir, agentMtime: OLDER });
const opts = { stderr: stderrStub, homedir: () => tmpRoot, env: ocEnv(agentParent) };
const w1 = warnIfStaleBake(tmpRoot, opts);
@@ -292,7 +343,7 @@ describe('stale-bake-guard.warnIfStaleBake (orchestrator, fixtures)', () => {
test('global ~/.gsd/defaults.json (in tmp homedir) NEWER than agents → warning', () => {
const agentParent = path.join(tmpRoot, 'oc-config');
const agentDir = path.join(agentParent, 'agent');
const agentDir = path.join(agentParent, 'agents');
fs.mkdirSync(agentDir, { recursive: true });
const ap = path.join(agentDir, 'gsd-executor.md');
fs.writeFileSync(ap, '---\n---\n');
@@ -311,7 +362,7 @@ describe('stale-bake-guard.warnIfStaleBake (orchestrator, fixtures)', () => {
test('guard never throws — stderr.write failure is swallowed', () => {
const agentParent = path.join(tmpRoot, 'oc-config');
const agentDir = path.join(agentParent, 'agent');
const agentDir = path.join(agentParent, 'agents');
setupProject({ runtime: 'opencode', configMtime: NEWER, agentDir, agentMtime: OLDER });
const throwingStderr = { write: () => { throw new Error('boom'); } };
let threw = false;
@@ -378,8 +429,8 @@ describe('stale-bake-guard parity with bin/install.js bake paths', () => {
return;
}
test('STATIC_FRONTMATTER_RUNTIMES is exactly codex + opencode (no silent drift)', () => {
assert.deepEqual([...STATIC_FRONTMATTER_RUNTIMES].sort(), ['codex', 'opencode']);
test('STATIC_FRONTMATTER_RUNTIMES is exactly codex + kilo + opencode (no silent drift)', () => {
assert.deepEqual([...STATIC_FRONTMATTER_RUNTIMES].sort(), ['codex', 'kilo', 'opencode']);
});
test('opencode converter bakes a model: line when modelOverride is provided', () => {
@@ -391,6 +442,23 @@ describe('stale-bake-guard parity with bin/install.js bake paths', () => {
);
});
// #2093: kilo bakes model: the same way opencode does (same static-frontmatter constraint).
test('kilo converter bakes a model: line when modelOverride is provided', () => {
const sample = '---\nname: gsd-executor\ndescription: x\nmodel: sonnet\ntools: Read\n---\nbody\n';
const out = install.convertClaudeToKiloFrontmatter(sample, { isAgent: true, modelOverride: 'anthropic/claude-sonnet-5' });
assert.ok(
typeof out === 'string' && out.includes('model: anthropic/claude-sonnet-5'),
'kilo converter no longer bakes modelOverride — STATIC_FRONTMATTER_RUNTIMES is stale vs bin/install.js',
);
});
test('kilo converter omits model: when no override (stale-bake fallback shape)', () => {
const sample = '---\nname: gsd-executor\ndescription: x\nmodel: sonnet\ntools: Read\n---\nbody\n';
const out = install.convertClaudeToKiloFrontmatter(sample, { isAgent: true });
assert.ok(typeof out === 'string', 'converter must return a string');
assert.doesNotMatch(out, /^model:/m, 'no-override path should not emit a model: line');
});
test('opencode converter omits model: when no override (stale-bake fallback shape)', () => {
const sample = '---\nname: gsd-executor\ndescription: x\nmodel: sonnet\ntools: Read\n---\nbody\n';
const out = install.convertClaudeToOpencodeFrontmatter(sample, { isAgent: true });

View File

@@ -68,7 +68,7 @@
"scan.md": 7732,
"secure-phase.md": 13520,
"session-report.md": 4044,
"settings-advanced.md": 39728,
"settings-advanced.md": 39908,
"settings-integrations.md": 15892,
"settings.md": 33467,
"ship.md": 24691,