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:
@@ -20,17 +20,8 @@ const RUNTIME_SURFACES: Record<string, string[]> = {
|
||||
codex: ['msd-core', 'skills', 'agents', 'hooks', 'config.toml', 'hooks.json'],
|
||||
gemini: ['msd-core', 'commands/msd', 'hooks'],
|
||||
opencode: ['msd-core', 'command', 'skills', 'agents'],
|
||||
kilo: ['msd-core', 'command', 'skills', 'agents'],
|
||||
copilot: ['msd-core', 'skills', 'agents'],
|
||||
antigravity: ['msd-core', 'skills', 'agents'],
|
||||
cursor: ['msd-core', 'skills', 'agents', 'hooks', 'hooks.json'],
|
||||
windsurf: ['msd-core', 'skills', 'agents', 'rules'],
|
||||
augment: ['msd-core', 'skills', 'agents'],
|
||||
trae: ['msd-core', 'skills', 'agents', 'rules'],
|
||||
qwen: ['msd-core', 'skills', 'agents'],
|
||||
hermes: ['msd-core', 'skills/msd', 'agents'],
|
||||
cline: ['msd-core', 'skills', 'agents'],
|
||||
codebuddy: ['msd-core', 'skills', 'agents'],
|
||||
};
|
||||
|
||||
const COMMON_SURFACES = ['msd-core', 'skills', 'agents', 'hooks'];
|
||||
|
||||
@@ -32,11 +32,8 @@
|
||||
* populations (installs that never ran a baseline scan AND installs that
|
||||
* already applied the original 000 scan) and drifts no checksum.
|
||||
*
|
||||
* Scope: OpenCode only. Kilo shares the same combined-family install path,
|
||||
* but its command directory descriptor is still the singular `command/`
|
||||
* (already covered by 000's RUNTIME_SURFACES.kilo), so this migration must
|
||||
* never touch Kilo installs — enforced by the `runtimes: ['opencode']`
|
||||
* scoping below.
|
||||
* Scope: OpenCode only — enforced by the `runtimes: ['opencode']` scoping
|
||||
* below.
|
||||
*
|
||||
* Classification mirrors 000-first-time-baseline.cts exactly (record-baseline
|
||||
* for manifest-proven files, prompt-user for stale-MSD-looking unmanifested
|
||||
|
||||
@@ -1,129 +0,0 @@
|
||||
/**
|
||||
* Installer migration: retire pi's stale `extensions/msd.cjs` after #2470
|
||||
* renamed the installed native extension to `extensions/msd.js`.
|
||||
*
|
||||
* What old artifact is being retired?
|
||||
* `extensions/msd.cjs` — the pre-#2470 dest filename for pi's native
|
||||
* extension. pi auto-discovers extensions by scanning `<agentDir>/extensions/`
|
||||
* and keeping only names accepted by its own predicate
|
||||
* (`isExtensionFile()` in @earendil-works/pi-coding-agent:
|
||||
* `name.endsWith(".ts") || name.endsWith(".js")`). A `.cjs` file is skipped
|
||||
* SILENTLY — no `/msd` command, no error, no log line. The file is therefore
|
||||
* permanently inert, not merely redundant.
|
||||
*
|
||||
* How do we prove it is MSD-owned?
|
||||
* The installer records the native plugin in the install manifest as
|
||||
* `<nativePlugin.dir>/<nativePlugin.file>` (bin/install.js, the
|
||||
* `_hostBehaviors(runtime).nativePlugin` manifest block), so a pre-#2470 pi
|
||||
* install carries `extensions/msd.cjs` as a manifest-managed entry. Only a
|
||||
* manifest-managed classification produces an action here; an unmanifested
|
||||
* `msd.cjs` is treated as a user's own file and preserved.
|
||||
*
|
||||
* What happens if the user modified it?
|
||||
* `backup-and-remove` instead of `remove-managed`, so a patched extension is
|
||||
* recoverable from the backup rather than silently destroyed.
|
||||
*
|
||||
* What happens if it is missing?
|
||||
* No actions — fresh (post-#2470) installs and already-migrated installs both
|
||||
* plan empty, so the migration is idempotent.
|
||||
*
|
||||
* What runtime and scope does it affect?
|
||||
* pi only, global and local. No other runtime ever installed this path:
|
||||
* OpenCode and Kilo — the only other runtimes declaring
|
||||
* `hostBehaviors.nativePlugin` — both ship `plugins/msd-core.js`.
|
||||
*
|
||||
* Is the action safe in non-interactive install?
|
||||
* Yes. Both emitted action types are non-interactive and journaled; neither
|
||||
* requires a user choice, and unknown files never produce an action.
|
||||
*
|
||||
* Why not `move-managed`? The installer materializes the new `extensions/msd.js`
|
||||
* from the package payload in the same run, so moving the stale file onto that
|
||||
* path would just be overwritten. Retiring the old path is the accurate
|
||||
* description of the change.
|
||||
*
|
||||
* See docs/installer-migrations.md#shipped-migrations and the pi row of
|
||||
* docs/installer-migrations.md#runtime-configuration-contract-registry.
|
||||
*/
|
||||
|
||||
type ArtifactClassification = string;
|
||||
|
||||
interface ClassifiedArtifact {
|
||||
classification: ArtifactClassification;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
type ActionType = 'remove-managed' | 'backup-and-remove';
|
||||
|
||||
interface MigrationAction {
|
||||
type: ActionType;
|
||||
relPath: string;
|
||||
reason: string;
|
||||
ownershipEvidence: string;
|
||||
}
|
||||
|
||||
interface MigrationPlanContext {
|
||||
classifyArtifact(relPath: string): ClassifiedArtifact;
|
||||
}
|
||||
|
||||
interface InstallerMigration {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
introducedIn: string;
|
||||
runtimes: string[];
|
||||
scopes: string[];
|
||||
destructive: boolean;
|
||||
plan: (ctx: MigrationPlanContext) => MigrationAction[];
|
||||
}
|
||||
|
||||
/** Pre-#2470 dest filename for pi's native extension. */
|
||||
const STALE_PI_EXTENSION = 'extensions/msd.cjs';
|
||||
|
||||
const OWNERSHIP_EVIDENCE =
|
||||
'pre-#2470 pi installs record the native extension at extensions/msd.cjs in ' +
|
||||
'msd-file-manifest.json (installer nativePlugin manifest entry)';
|
||||
|
||||
const REASON =
|
||||
'pi cannot auto-discover a .cjs extension (isExtensionFile accepts only .ts/.js), ' +
|
||||
'so this file is inert; superseded by extensions/msd.js (#2470)';
|
||||
|
||||
const migration: InstallerMigration = {
|
||||
id: '2026-07-20-pi-extension-cjs-to-js',
|
||||
title: 'Retire pi\'s undiscoverable extensions/msd.cjs',
|
||||
description:
|
||||
'Remove the stale extensions/msd.cjs left by pre-#2470 pi installs, superseded by ' +
|
||||
'extensions/msd.js — the suffix pi\'s extension auto-discovery actually accepts.',
|
||||
introducedIn: '1.7.1',
|
||||
runtimes: ['pi'],
|
||||
scopes: ['global', 'local'],
|
||||
destructive: true,
|
||||
plan: (ctx: MigrationPlanContext): MigrationAction[] => {
|
||||
const artifact = ctx.classifyArtifact(STALE_PI_EXTENSION);
|
||||
if (artifact.classification === 'managed-pristine') {
|
||||
return [
|
||||
{
|
||||
type: 'remove-managed',
|
||||
relPath: STALE_PI_EXTENSION,
|
||||
reason: REASON,
|
||||
ownershipEvidence: OWNERSHIP_EVIDENCE,
|
||||
},
|
||||
];
|
||||
}
|
||||
if (artifact.classification === 'managed-modified') {
|
||||
return [
|
||||
{
|
||||
type: 'backup-and-remove',
|
||||
relPath: STALE_PI_EXTENSION,
|
||||
reason: REASON,
|
||||
ownershipEvidence: OWNERSHIP_EVIDENCE,
|
||||
},
|
||||
];
|
||||
}
|
||||
// 'unknown' (never MSD-managed), 'missing', and 'managed-missing' all plan
|
||||
// nothing: unknown files are preserved by policy, and an absent file needs
|
||||
// no retirement.
|
||||
return [];
|
||||
},
|
||||
};
|
||||
|
||||
export = migration;
|
||||
@@ -46,24 +46,14 @@
|
||||
* What runtime and scope does it affect?
|
||||
* Every runtime whose config root received the marker — i.e. every runtime
|
||||
* not excluded from `installSharedHooksBundle(targetDir)` by
|
||||
* `hostBehaviors.skipSharedHooksInstall` and not Codex: antigravity,
|
||||
* augment, claude, claude-local, codebuddy, hermes, qwen, kilo, opencode,
|
||||
* and pi. The `runtimes` field is OMITTED — the framework's "all runtimes" —
|
||||
* `hostBehaviors.skipSharedHooksInstall` and not Codex (antigravity,
|
||||
* claude, claude-local, opencode). The `runtimes` field is OMITTED — the
|
||||
* framework's "all runtimes" —
|
||||
* rather than carrying that hand-list: a runtime that never received the
|
||||
* marker simply has no file to match, so enumerating them would add a second
|
||||
* place for the set to drift out of date without changing behavior. Note it
|
||||
* must be omitted and not `[]`; see the field's own comment below.
|
||||
*
|
||||
* ONE DELIBERATE CARVE-OUT — kimi. Kimi's marker was written to its native
|
||||
* hook root (`~/.kimi`, `resolveKimiHooksTomlDir`), which is NOT under
|
||||
* kimi's `configDir` (its generic Agent-Skills root). Migration relPaths are
|
||||
* structurally confined to `configDir` (`validateSafeRelPath` /
|
||||
* `ensureInsideConfig`), so this framework cannot address that path at all.
|
||||
* Kimi's stale root marker is retired by the installer instead, at the same
|
||||
* call site that writes its replacement — see the `kimi-hooks-toml` branch
|
||||
* in `bin/install.js`. Named here so the gap is not mistaken for an
|
||||
* oversight.
|
||||
*
|
||||
* Is the action safe in non-interactive install?
|
||||
* Yes. `remove-managed` is non-interactive and journaled, the executor takes
|
||||
* a rollback snapshot before unlinking, and no branch of this migration can
|
||||
|
||||
@@ -1,241 +0,0 @@
|
||||
/**
|
||||
* Installer migration: retire pi's legacy `<piConfigDir>/hooks/` directory
|
||||
* after MSD's shared hook bundle moved to `<piConfigDir>/msd-hooks/` (#3023).
|
||||
*
|
||||
* What old artifact is being retired?
|
||||
* `hooks/` (and its `hooks/lib/` subdirectory) at the pi config root. pi
|
||||
* reserves that exact name as its own deprecated extension directory and
|
||||
* warns on every startup whenever it exists — pi's
|
||||
* `checkDeprecatedExtensionDirs()` fires on mere PATH EXISTENCE, not on the
|
||||
* directory having contents (unlike the sibling `tools/` check, which does
|
||||
* `readdir` first). MSD used to install its shared hook bundle at exactly
|
||||
* that reserved path, so every pi install carried the warning permanently.
|
||||
* The fix moved the install target to `msd-hooks/`
|
||||
* (`hostBehaviors.sharedHooksDirName`), but an EXISTING install that
|
||||
* upgrades still has the old `hooks/` tree sitting on disk — nothing
|
||||
* removes it on its own, so the warning would persist forever without this
|
||||
* migration.
|
||||
*
|
||||
* How do we prove it is MSD-owned?
|
||||
* Per file, by manifest membership — the same `classifyArtifact()` check
|
||||
* every other migration in this directory uses. Pre-#3023 pi installs
|
||||
* record the shared hook bundle under `hooks/…` keys in
|
||||
* `msd-file-manifest.json`; the new install target writes `msd-hooks/…`
|
||||
* keys instead, which this migration structurally never sees because it
|
||||
* only ever walks the `hooks/` subtree.
|
||||
*
|
||||
* What happens if the user modified it?
|
||||
* `backup-and-remove` instead of `remove-managed`, so a locally patched
|
||||
* hook script is recoverable from the backup rather than silently
|
||||
* destroyed — mirrors migration 006.
|
||||
*
|
||||
* What happens to files the manifest never recorded?
|
||||
* Nothing. An unmanifested file under `hooks/` (classification `unknown`)
|
||||
* is left exactly where it is, and — because its presence keeps the
|
||||
* directory non-empty — it also keeps the directory itself from being
|
||||
* retired. That is a deliberate consequence of directory removal being
|
||||
* gated on emptiness, not a special case.
|
||||
*
|
||||
* What happens to the directory itself?
|
||||
* `hooks/lib/` and then `hooks/` each get a `remove-empty-dir` action (see
|
||||
* `evaluateRemoveEmptyDir` in `../installer-migrations.cts`). That action
|
||||
* only ever calls `fs.rmdirSync` — never a recursive removal — and
|
||||
* re-checks emptiness immediately before doing so, so a directory that
|
||||
* still holds anything (an unmanifested file, or a file-level action that
|
||||
* failed to apply) is left in place rather than assumed empty. Actions are
|
||||
* emitted deepest-first (`hooks/lib` before `hooks`) so the parent has a
|
||||
* chance to become empty in the same pass.
|
||||
*
|
||||
* What happens if it is missing?
|
||||
* No actions. A fresh post-#3023 pi install never creates `hooks/` at all,
|
||||
* and an already-migrated install has nothing left to retire — both plan
|
||||
* empty, so the migration is idempotent.
|
||||
*
|
||||
* What runtime and scope does it affect?
|
||||
* pi only, global and local. No other runtime's install is affected:
|
||||
* `hostBehaviors.sharedHooksDirName` defaults to `'hooks'` for every other
|
||||
* runtime, and none of them reserve that name the way pi does, so a
|
||||
* claude/kimi/opencode/etc. `hooks/` directory is a live, in-use install
|
||||
* surface that must never be touched here. The runtime check is the FIRST
|
||||
* thing `plan()` does, ahead of even checking whether the directory exists,
|
||||
* as defense in depth beyond the `runtimes: ['pi']` record-level filter the
|
||||
* framework itself already enforces.
|
||||
*
|
||||
* Is the action safe in non-interactive install?
|
||||
* Yes. Every emitted action type (`remove-managed`, `backup-and-remove`,
|
||||
* `remove-empty-dir`) is non-interactive and journaled; none requires a
|
||||
* user choice, and unknown files never produce an action.
|
||||
*
|
||||
* See docs/installer-migrations.md#shipped-migrations, the pi row of
|
||||
* docs/installer-migrations.md#runtime-configuration-contract-registry, and
|
||||
* the 2026-08-07 amendment to docs/adr/0008-installer-migration-module.md.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
interface ClassifiedArtifact {
|
||||
classification: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
type ActionType = 'remove-managed' | 'backup-and-remove' | 'remove-empty-dir';
|
||||
|
||||
interface MigrationAction {
|
||||
type: ActionType;
|
||||
relPath: string;
|
||||
reason: string;
|
||||
ownershipEvidence: string;
|
||||
classification?: string;
|
||||
originalHash?: string | null;
|
||||
currentHash?: string | null;
|
||||
}
|
||||
|
||||
interface MigrationPlanContext {
|
||||
configDir: string;
|
||||
runtime: string | null;
|
||||
classifyArtifact(relPath: string): ClassifiedArtifact;
|
||||
}
|
||||
|
||||
interface InstallerMigration {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
introducedIn: string;
|
||||
runtimes: string[];
|
||||
scopes: string[];
|
||||
destructive: boolean;
|
||||
plan: (ctx: MigrationPlanContext) => MigrationAction[];
|
||||
}
|
||||
|
||||
/** pi's reserved (and, pre-#3023, MSD-populated) legacy hook directory name. */
|
||||
const HOOKS_DIR = 'hooks';
|
||||
|
||||
const FILE_REASON =
|
||||
"pi's startup check warns whenever hooks/ exists (checkDeprecatedExtensionDirs), and MSD's shared " +
|
||||
'hook bundle now installs at msd-hooks/ instead (#3023), so the legacy files are superseded';
|
||||
|
||||
const FILE_OWNERSHIP_EVIDENCE =
|
||||
'pre-#3023 pi installs record the shared hook bundle under hooks/… keys in msd-file-manifest.json; ' +
|
||||
'the new install target is msd-hooks/…, which this migration never touches because it only walks the ' +
|
||||
'hooks/ subtree';
|
||||
|
||||
const DIR_REASON =
|
||||
"pi's checkDeprecatedExtensionDirs() warns on hooks/'s mere existence, not its contents (#3023); the " +
|
||||
'reserved container is retired once every MSD-owned entry inside it is gone';
|
||||
|
||||
const DIR_OWNERSHIP_EVIDENCE =
|
||||
'hooks/ and hooks/lib/ are MSD-installed container directories under the pi config root (the pre-#3023 ' +
|
||||
'default of hostBehaviors.sharedHooksDirName); removal is gated on emptiness by the shared ' +
|
||||
'remove-empty-dir action, so a directory that still holds an unmanifested user file — or any file-level ' +
|
||||
'action that failed to apply — is left in place rather than assumed empty';
|
||||
|
||||
/**
|
||||
* Recursively collect files and directories under `relDir`, never following a
|
||||
* symlink (whether it names a file or a directory) and never emitting a path
|
||||
* that resolves outside `baseResolved`. Mirrors the traversal guard in
|
||||
* migration 003 (`walkLegacyFiles`).
|
||||
*/
|
||||
function walkPiHooksTree(root: string, relDir: string, baseResolved: string, files: string[], dirs: string[]): void {
|
||||
const dir = path.join(root, relDir);
|
||||
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
// Never follow a symlink into or through: it must not be traversed,
|
||||
// hashed, or removed, regardless of what it points at.
|
||||
if (entry.isSymbolicLink()) continue;
|
||||
const relPath = path.posix.join(relDir, entry.name);
|
||||
const resolved = path.resolve(root, relPath);
|
||||
if (resolved !== baseResolved && !resolved.startsWith(baseResolved + path.sep)) continue;
|
||||
if (entry.isDirectory()) {
|
||||
dirs.push(relPath);
|
||||
walkPiHooksTree(root, relPath, baseResolved, files, dirs);
|
||||
} else if (entry.isFile()) {
|
||||
files.push(relPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const migration: InstallerMigration = {
|
||||
id: '2026-08-07-pi-retire-reserved-hooks-dir',
|
||||
title: "Retire pi's reserved hooks/ directory",
|
||||
description:
|
||||
'Remove manifest-managed files under <piConfigDir>/hooks/ and, once empty, the directory itself ' +
|
||||
'(and its hooks/lib/ subdirectory), now that the shared hook bundle installs at msd-hooks/ instead. pi ' +
|
||||
'reserves hooks/ as its own deprecated extension directory and warns on every startup while it exists (#3023).',
|
||||
introducedIn: '1.9.2',
|
||||
runtimes: ['pi'],
|
||||
scopes: ['global', 'local'],
|
||||
destructive: true,
|
||||
plan: (ctx: MigrationPlanContext): MigrationAction[] => {
|
||||
// Defense in depth ahead of the framework's own runtimes filter: a
|
||||
// claude/kimi/opencode/etc. hooks/ directory is a live install surface,
|
||||
// never a retirement target.
|
||||
if (ctx.runtime !== 'pi') return [];
|
||||
|
||||
const hooksRoot = path.join(ctx.configDir, HOOKS_DIR);
|
||||
|
||||
let rootLstat: fs.Stats;
|
||||
try {
|
||||
rootLstat = fs.lstatSync(hooksRoot);
|
||||
} catch {
|
||||
return []; // absent -> nothing to retire, idempotent
|
||||
}
|
||||
// Never follow a symlinked hooks/ root: walking through it could plan
|
||||
// actions against paths outside the pi config directory entirely.
|
||||
if (rootLstat.isSymbolicLink()) return [];
|
||||
if (!rootLstat.isDirectory()) return [];
|
||||
|
||||
const baseResolved = path.resolve(ctx.configDir);
|
||||
const files: string[] = [];
|
||||
const dirs: string[] = [];
|
||||
try {
|
||||
walkPiHooksTree(ctx.configDir, HOOKS_DIR, baseResolved, files, dirs);
|
||||
} catch {
|
||||
// Unreadable directory: nothing safe to plan.
|
||||
return [];
|
||||
}
|
||||
|
||||
const actions: MigrationAction[] = [];
|
||||
for (const relPath of files) {
|
||||
const { classification } = ctx.classifyArtifact(relPath);
|
||||
if (classification === 'managed-pristine') {
|
||||
actions.push({ type: 'remove-managed', relPath, reason: FILE_REASON, ownershipEvidence: FILE_OWNERSHIP_EVIDENCE });
|
||||
} else if (classification === 'managed-modified') {
|
||||
actions.push({ type: 'backup-and-remove', relPath, reason: FILE_REASON, ownershipEvidence: FILE_OWNERSHIP_EVIDENCE });
|
||||
}
|
||||
// 'unknown' (user-added, not manifest-recorded): no action, preserved.
|
||||
// 'missing' / 'managed-missing': impossible here — relPath was just
|
||||
// discovered by walking the live filesystem, so it currently exists.
|
||||
}
|
||||
|
||||
// Deepest directories first, so a child has already been evaluated (and
|
||||
// possibly removed) before its parent's own emptiness is re-checked by
|
||||
// the executor. `hooks/` itself is appended last, unconditionally: the
|
||||
// executor's own emptiness re-check is what actually decides whether it
|
||||
// goes, not this ordering — this ordering only gives it the chance to.
|
||||
const orderedDirs = [...dirs].sort((a, b) => b.split('/').length - a.split('/').length);
|
||||
orderedDirs.push(HOOKS_DIR);
|
||||
|
||||
for (const relPath of orderedDirs) {
|
||||
actions.push({
|
||||
type: 'remove-empty-dir',
|
||||
relPath,
|
||||
reason: DIR_REASON,
|
||||
ownershipEvidence: DIR_OWNERSHIP_EVIDENCE,
|
||||
// Declared, not derived: classifyArtifact() hashes file contents via
|
||||
// sha256File(), which throws EISDIR against a directory path. These
|
||||
// relPaths name directories, so classification is stated directly
|
||||
// (never 'unknown', so the planner's unknown-classification block
|
||||
// never fires for them) rather than routed through the file
|
||||
// classifier.
|
||||
classification: 'managed-pristine',
|
||||
originalHash: null,
|
||||
currentHash: null,
|
||||
});
|
||||
}
|
||||
|
||||
return actions;
|
||||
},
|
||||
};
|
||||
|
||||
export = migration;
|
||||
Reference in New Issue
Block a user