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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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