fix(#2305): stage the shared guard hooks Kilo's native plugin spawns (#2327)

* fix(#2305): stage shared guard hooks for Kilo — drop skipSharedHooksInstall

Kilo's capability descriptor declared BOTH hostBehaviors.nativePlugin (a
plugin that spawns the shared PreToolUse guard scripts as subprocesses)
AND hostBehaviors.skipSharedHooksInstall:true, which suppresses staging
of hooks/*.js into the Kilo config dir. The plugin's runHook treats an
absent hook script as a silent allow, so every guard it spawned
(gsd-prompt-guard, gsd-read-guard, gsd-worktree-path-guard) no-opped on
every Kilo install. OpenCode uses the byte-identical plugin with hook
staging on and is unaffected — it is the reference shape.

The skip flag predates Kilo's plugin surface: it dates to #1821 (hooks
were dead weight for a runtime with no hook consumer), and #2093 added
the hooks-dependent nativePlugin without revisiting it.

- capabilities/kilo/capability.json: remove skipSharedHooksInstall
  (regenerated gsd-core/bin/lib/capability-registry.cjs accordingly)
- bin/install.js: correct the stale #1821 comments claiming Kilo has no
  plugin surface
- tests/kilo-upgrades.test.cjs: install-fixture tests (global + local)
  asserting the guard scripts land where the plugin's walk-up resolves
  them; an end-to-end test driving a disallowed out-of-worktree write
  through the REAL installed Kilo tree and asserting the guard rejects
  it; a cross-runtime descriptor invariant (nativePlugin and
  skipSharedHooksInstall:true must never coexist)
- tests/kilo-imperative-reference.test.cjs: flip the pinned assertion
- golden fixtures regenerated (kilo now stages the 24 hook files, same
  set as OpenCode)

Fixes #2305

* fix(#2305): warn loudly when a guard hook script is missing (runHook)

runHook's absent-file branch returned a silent exit-0 allow — the
mechanism that let #2305 ship undetected: with the hooks bundle never
staged on Kilo, every PreToolUse guard the plugin spawned resolved to
"file not found → allow" with zero signal anywhere.

Keep the adapter's design contract (a missing hook must never break the
tool call — pinned by the existing adapter test) but make the absence
loud: console.error once per hook file, naming the unresolved path and
the remediation. Applied identically to .kilo/ and .opencode/ plugin
copies (byte-parity guard). Golden parity fixtures regenerated (the
installed plugin file's hash changed).

Fixes #2305

* chore(#2305): add changeset fragment

* test(#2305): include gsd-workflow-guard.js in the staged-guards regression list

The native plugin spawns four guards on write-like tool calls — the
regression test's PLUGIN_GUARD_HOOKS list covered three. Staging itself
was already asserted via the golden fixtures (the full bundle), but the
named per-guard assertion should cover every guard the plugin actually
dispatches. Surfaced by cross-AI review of PR #2327.

* test(#2305): update the #1821 tests that encoded Kilo's false no-plugin premise

The #1821 hook-copy test asserted Kilo must receive no staged hooks — the
exact behavior this PR reverses (and the cause of all 8 CI failures). Kilo
moves from the ZCode "no dead hooks" loop to the OpenCode group, with
positive assertions on the new contract: the three guard hooks the plugin
spawns, hooks/lib/git-cmd.js, and plugins/gsd-core.js all staged. The
integration runtime contract flips kilo packageJson to true (the CommonJS
marker ships with the bundle), and the pi contract comment no longer cites
Kilo as a no-plugin runtime.

* chore(#2305): scope the queued #1821 changeset fragment to ZCode only

The fragment still claimed the installer skips hooks for Kilo — rendering
both it and this PR's fragment into the same release would ship two
contradictory statements about Kilo's install behavior. It now claims
ZCode only and notes that #2327 reverses the Kilo half.

* chore(#2305): rename changeset fragment to the generator naming convention

2305-kilo-stage-guard-hooks.md -> loud-guard-hooks.md, matching the
<adjective>-<noun>-<noun> shape npm run changeset generates (review nit).

---------

Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
This commit is contained in:
0xdhx
2026-07-18 11:20:32 -05:00
committed by GitHub
parent 13d181aedf
commit 50efae13ce
15 changed files with 314 additions and 45 deletions

View File

@@ -671,19 +671,23 @@ describe('#1755: .sh hooks are copied and executable after install', () => {
});
});
// ─── #1821: Kilo/ZCode (hooksSurface:none, no plugin) receive no dead hooks ────
// ─── #1821/#2305: hooks staged iff a surface consumes them ─────────────────────
//
// #1821 reported dead hook scripts staged for runtimes with hooksSurface:'none'.
// OpenCode and pi ALSO declare hooksSurface:'none', but each has a native plugin
// adapter that spawns the staged hooks/*.js scripts as subprocesses (OpenCode's
// #1914 plugins/gsd-core.js via OpenCode's event bus; pi's #2102 Stage 2
// pi/gsd.cjs → extensions/gsd.cjs via pi.on(...) bridges) — so for both, the
// hooks are LIVE and must keep being copied. Kilo and ZCode have no plugin
// surface at all, so their staged hooks are genuinely dead: this is the case
// the fix removes. These tests assert the split: Kilo/ZCode get no hooks;
// OpenCode/pi (and Claude) still do.
// OpenCode, pi — and, corrected by #2305, Kilo — ALSO declare
// hooksSurface:'none', but each has a native plugin adapter that spawns the
// staged hooks/*.js scripts as subprocesses (OpenCode's #1914
// plugins/gsd-core.js via OpenCode's event bus; pi's #2102 Stage 2 pi/gsd.cjs
// → extensions/gsd.cjs via pi.on(...) bridges; Kilo's plugins/gsd-core.js,
// byte-identical to OpenCode's) — so for all three, the hooks are LIVE and
// must keep being copied. ZCode has no plugin surface at all, so its staged
// hooks are genuinely dead: that is the case #1821's fix removes. (#1821
// originally excluded Kilo too, on the false premise that it had no plugin
// surface — #2305 reversed that: the skip flag silently no-opped every guard
// hook Kilo's plugin spawns.) These tests assert the split: ZCode gets no
// hooks; Kilo/OpenCode/pi (and Claude) do.
describe('#1821: Kilo/ZCode receive no dead hook files; OpenCode/Claude keep their hooks', () => {
describe('#1821/#2305: ZCode receives no dead hook files; Kilo/OpenCode/Claude keep their hooks', () => {
function gsdHookFilesUnder(configDir) {
const hooksDir = path.join(configDir, 'hooks');
if (!fs.existsSync(hooksDir)) return [];
@@ -716,10 +720,11 @@ describe('#1821: Kilo/ZCode receive no dead hook files; OpenCode/Claude keep the
}
}
// Kilo and ZCode both declare hooksSurface:'none' with no plugin surface, so
// their staged hooks are genuinely dead weight (#1821) — this is the case
// the fix removes.
for (const runtime of ['kilo', 'zcode']) {
// ZCode declares hooksSurface:'none' with no plugin surface, so its staged
// hooks are genuinely dead weight (#1821) — this is the case the fix
// removes. (Kilo was originally in this loop; #2305 moved it to the
// OpenCode group below — its native plugin spawns the staged guard hooks.)
for (const runtime of ['zcode']) {
test(`${runtime} --global install creates no gsd-*.js/.sh hook files or hooks/lib`, () => {
const { hookFiles, hooksLibExists } = installAndCollect(runtime);
assert.deepStrictEqual(hookFiles, [], `${runtime} install must not copy any gsd-*.js/.sh hook files, found: ${hookFiles.join(', ')}`);
@@ -727,6 +732,26 @@ describe('#1821: Kilo/ZCode receive no dead hook files; OpenCode/Claude keep the
});
}
// #2305: Kilo's native plugin (plugins/gsd-core.js, byte-identical to
// OpenCode's) spawns the staged PreToolUse guard hooks as subprocesses, so
// Kilo must receive the shared hooks bundle as a sibling of gsd-core/ —
// the shape the plugin's resolveRepoRoot walk requires. #1821 excluded
// Kilo on the false premise that it had no plugin surface; with the skip
// flag set, every guard silently no-opped on every Kilo install.
test('kilo --global install stages the guard hooks its plugin spawns, hooks/lib, and the plugin', () => {
const { hookFiles, hooksLibExists, gitCmdExists, pluginExists } = installAndCollect('kilo');
const basenames = hookFiles.map((f) => path.basename(f));
for (const expected of ['gsd-prompt-guard.js', 'gsd-read-guard.js', 'gsd-worktree-path-guard.js']) {
assert.ok(
basenames.includes(expected),
`kilo install must copy ${expected} (spawned by the plugin's runHook), found: ${basenames.join(', ')}`,
);
}
assert.ok(hooksLibExists, 'kilo install must create hooks/lib/');
assert.ok(gitCmdExists, 'kilo install must copy hooks/lib/git-cmd.js (required by the shared hooks)');
assert.ok(pluginExists, 'kilo install must install plugins/gsd-core.js (the hook-spawning plugin)');
});
// Regression guard for #1914: OpenCode's plugin adapter spawns the staged
// hooks, so excluding OpenCode from the hook copy would break it. OpenCode
// must KEEP its hooks and receive the plugin.