Files
gstack/test/setup-skill-hooks-gate.test.ts
T
Garry Tanandcapy-ai[bot] 6a370bd7ba v1.94.6.0 feat(hooks): opt-in skill routing and contract Stop hooks with gstack-skill-contract (GST-1088 lane A) (#3912)
* feat(hooks): opt-in skill routing and contract Stop hooks, gstack-skill-contract and gstack-skill-route (GST-1088 lane A)

Both switches default off. UserPromptSubmit routing hook scores prompts against installed triggers; Stop hook blocks once when a contract skill ends without its outputs, failing open on every state error. setup gains --[no-]skill-routing and --[no-]skill-contracts mirroring the timeline gate.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test(hooks): stop-hook test imports the state error line it pins (test-of-test ratchet)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(hooks): route only the four discovery targets; register the skill-contract fixture as free (GST-1088 lane A)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(hooks): review round 1 on the contract Stop hook: record after stop_hook_active, read last_assistant_message, per-agent completion, met runs stay met, installed-only SKILL.md loads, program-based check commands, safe specialist names, report rechecks the saved run (GST-1088 lane A)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(hooks): review round 2 notes: check commands behind wrappers count, the shell test builtin does not; met_after_block is final (GST-1088 lane A)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* feat(hooks): one DEFAULT_MATCHERS table for the checker markers; N = 0 selects no specialists (GST-1088 lane A)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* feat(hooks): lane C fixed markers: agent_dispatch prefers its marker line, parenthesized notes are not specialist names (GST-1088 lane A)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* feat(hooks): an early exit (STATUS: BLOCKED, Nothing to review) meets the contract (GST-1088 lane A)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(hooks): early exit only from the final message, line-anchored (GST-1088 lane A)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
2026-11-03 06:31:35 +00:00

105 lines
5.9 KiB
TypeScript

/**
* setup: the skill_routing and skill_contracts gates (GST-1088, lane A). The
* _skill_hook_gate function is lifted out of setup and run against the real
* gstack-config and gstack-settings-hook with a temp state root and a temp
* settings.json: default off registers nothing, a flag persists and registers
* once with a one-time line, a typo warns and is not persisted, and off
* removes the registration. Static pins cover the flags and the parse gate.
*/
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
import { spawnSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { ROOT, tmpDir } from './helpers/skill-contract-fixture';
const setupSrc = fs.readFileSync(path.join(ROOT, 'setup'), 'utf8');
const settingsHookSrc = fs.readFileSync(path.join(ROOT, 'bin', 'gstack-settings-hook'), 'utf8');
const gateFn = setupSrc.slice(setupSrc.indexOf('_skill_hook_gate() {'), setupSrc.indexOf('\n}\n', setupSrc.indexOf('_skill_hook_gate() {')) + 3);
let state = '', home = '';
beforeEach(() => { state = tmpDir('gstack-gate-state-'); home = tmpDir('gstack-gate-home-'); });
afterEach(() => { for (const d of [state, home]) fs.rmSync(d, { recursive: true, force: true }); });
const settingsFile = () => path.join(home, 'settings.json');
const settings = () => (fs.existsSync(settingsFile()) ? JSON.parse(fs.readFileSync(settingsFile(), 'utf8')) : {});
/** Run the routing gate once: mode is the flag value ("" = none). */
function gate(mode: string, env: Record<string, string> = {}, noTeam = 0): { code: number | null; out: string } {
const hook = path.join(ROOT, 'hosts', 'claude', 'hooks', 'skill-route-hook');
const script = [
'set -e',
'log() { printf "%s\\n" "$*"; }',
`GSTACK_CONFIG="${path.join(ROOT, 'bin', 'gstack-config')}"`,
`SETTINGS_HOOK="${path.join(ROOT, 'bin', 'gstack-settings-hook')}"`,
`IS_WINDOWS=0; NO_TEAM_MODE=${noTeam}`,
gateFn,
`_skill_hook_gate skill_routing "$1" GSTACK_SKILL_ROUTING UserPromptSubmit "${hook}" gstack-skill-route 5 "routing hint"`,
].join('\n');
const r = spawnSync('bash', ['-c', script, 'gate', mode], {
env: { PATH: process.env.PATH ?? '', HOME: home, GSTACK_STATE_ROOT: state, GSTACK_SETTINGS_FILE: settingsFile(), ...env },
encoding: 'utf8', timeout: 30_000,
});
return { code: r.status, out: (r.stdout ?? '') + (r.stderr ?? '') };
}
const config = () => (fs.existsSync(path.join(state, 'config.yaml')) ? fs.readFileSync(path.join(state, 'config.yaml'), 'utf8') : '');
const registered = () => (settings().hooks?.UserPromptSubmit ?? []) as Array<{ hooks: Array<{ type: string; command: string; timeout?: number }> }>;
describe('setup: skill hook gates', () => {
test('flags parse for both switches, and both appear in --help', () => {
for (const f of ['--skill-routing)', '--no-skill-routing)', '--skill-routing=*)', '--skill-contracts)', '--no-skill-contracts)', '--skill-contracts=*)']) {
expect(setupSrc).toContain(` ${f}`);
}
expect(setupSrc).toContain('--skill-routing --no-skill-routing --skill-contracts --no-skill-contracts');
expect(setupSrc).toContain('--skill-routing / --no-skill-routing');
expect(setupSrc).toContain('--skill-contracts / --no-skill-contracts');
});
test('both hooks resolve through _hook_command_path literals (the parse gate and hook-check see them), with KNOWN_HOOKS rows', () => {
expect(setupSrc).toContain('_hook_command_path hosts/claude/hooks/skill-route-hook ||');
expect(setupSrc).toContain('_hook_command_path hosts/claude/hooks/skill-contract-stop-hook ||');
expect(settingsHookSrc).toContain('"skill-route-hook": { source: "gstack-skill-route", event: "UserPromptSubmit", matcher: "", relpath: "hosts/claude/hooks/skill-route-hook" }');
expect(settingsHookSrc).toContain('"skill-contract-stop-hook": { source: "gstack-skill-contract", event: "Stop", matcher: "", relpath: "hosts/claude/hooks/skill-contract-stop-hook" }');
expect(setupSrc).toMatch(/_skill_hook_gate skill_contracts "\$SKILL_CONTRACTS_MODE" GSTACK_SKILL_CONTRACTS Stop \\\n "\$SKILL_CONTRACT_HOOK" gstack-skill-contract 10/);
});
test('default off: nothing registered, nothing persisted', () => {
expect(gate('')).toEqual({ code: 0, out: '' });
expect(registered()).toEqual([]);
expect(config()).not.toContain('skill_routing');
});
test('flag on: persists, registers a schema-valid entry with a one-time line; a re-run is quiet', () => {
const first = gate('on');
expect(first.code).toBe(0);
expect(first.out).toContain('registered UserPromptSubmit hook: routing hint (on via flag; turn off: gstack-config set skill_routing off, or ./setup --no-skill-routing');
expect(config()).toMatch(/^skill_routing: on$/m);
const entries = registered();
expect(entries).toHaveLength(1);
expect(entries[0].hooks).toHaveLength(1);
expect(entries[0].hooks[0]).toMatchObject({ type: 'command', timeout: 5 });
expect(entries[0].hooks[0].command).toEndWith('hosts/claude/hooks/skill-route-hook');
expect(gate('')).toEqual({ code: 0, out: '' });
expect(registered()).toHaveLength(1);
});
test('env on registers without persisting; a typo warns, applies off and is not persisted', () => {
expect(gate('', { GSTACK_SKILL_ROUTING: 'yes' }).out).toContain('on via env GSTACK_SKILL_ROUTING');
expect(config()).not.toContain('skill_routing');
const typo = gate('onn');
expect(typo.out).toContain("WARNING: unrecognized skill_routing value 'onn' (from flag) — using default 'off' for this run, not persisting");
expect(config()).not.toContain('skill_routing');
expect(registered()).toEqual([]);
});
test('off removes a live registration, and so does --no-team', () => {
gate('on');
expect(registered()).toHaveLength(1);
gate('off');
expect(registered()).toEqual([]);
expect(config()).toMatch(/^skill_routing: off$/m);
gate('on');
gate('', {}, 1);
expect(registered()).toEqual([]);
});
});