fix(hooks): tests read SWITCH_DEFAULT instead of assuming off; an unreadable config turns both hooks off whatever the default (GST-1088 follow-up)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
garrytanandcapy-ai[bot] committed 2026-11-03 09:18:56 +00:00
1 parent 7595189caf
commit 457195be85
9 files changed
+133 -73

No files matched your search

+1 -1
View File
@@ -1 +1 @@
Follow-ups to the skill routing and contract hooks (GST-1088). `gstack-ship-queue ready` no longer misreads files a PR adds as deleted when the installed gstack is older than the checkout: the paid-case selector now derives dependencies from the checkout under review. Each of the two hook switches now has one default, `SWITCH_DEFAULT` in `lib/skill-route.ts` (both still off), which setup, both hooks, `gstack-config get|list|defaults` and the new `gstack-skill-route --default <switch>` all read, so changing a default is a one-line edit; `gstack-config list` and `defaults` now show both switches, and an unrecognized value takes the default in the hooks as it already did in setup. The contract checker no longer counts installs, builds or version probes (`bun install`, `go build`, `node --version`, `make clean`) as a test run after an edit, while option values (`make -C web test`, `pnpm --filter app test`) and `node --test` still count, and the Stop hook lets a final stop through while Agent dispatches are still running, recording the run as `deferred`, then checks the contract once the same agents have been running for 10 minutes; `gstack-skill-contract report` re-checks deferred runs.
Follow-ups to the skill routing and contract hooks (GST-1088). `gstack-ship-queue ready` no longer misreads files a PR adds as deleted when the installed gstack is older than the checkout: the paid-case selector now derives dependencies from the checkout under review. Each of the two hook switches now has one default, `SWITCH_DEFAULT` in `lib/skill-route.ts` (both still off), which setup, both hooks, `gstack-config get|list|defaults` and the new `gstack-skill-route --default <switch>` all read, so changing a default is a one-line edit; `gstack-config list` and `defaults` now show both switches, an unrecognized value takes the default in the hooks as it already did in setup, and a config file that exists but cannot be read turns both hooks off in setup and in the hooks whatever the default. The contract checker no longer counts installs, builds or version probes (`bun install`, `go build`, `node --version`, `make clean`) as a test run after an edit, while option values (`make -C web test`, `pnpm --filter app test`) and `node --test` still count, and the Stop hook lets a final stop through while Agent dispatches are still running, recording the run as `deferred`, then checks the contract once the same agents have been running for 10 minutes; `gstack-skill-contract report` re-checks deferred runs.
+1 -1
View File
@@ -14,7 +14,7 @@ Each switch resolves in this order, and the first one set wins:
3. Config: `gstack-config set skill_routing on|off` and `gstack-config set skill_contracts on|off`.
4. The default: `SWITCH_DEFAULT` in `lib/skill-route.ts`, the only place it is written. Setup, the hooks, `gstack-config get|list|defaults` and `gstack-skill-route --default <switch>` all read it.
`on` also accepts yes, true and 1; `off` accepts no, false and 0. Setup warns about any other value, uses the default for that run, and does not save it; the hooks also use the default for such a value.
`on` also accepts yes, true and 1; `off` accepts no, false and 0. Setup warns about any other value, uses the default for that run, and does not save it; the hooks also use the default for such a value. A config file that exists but cannot be read turns both hooks off in setup and in the hooks, whatever the default, because it may hold an `off` you set.
Setup registers a hook only when its switch is on, prints one line the first time it registers it, and removes the registration when the switch is off (or with `--no-team`). The hooks also read the switch every time they run, so this takes effect on the next prompt without re-running setup:
+16 -2
View File
@@ -49,7 +49,21 @@ export function normalizeSwitch(raw: string | null | undefined): 'on' | 'off' |
export interface SwitchReading { on: boolean; source: string; invalid?: string }
/** env GSTACK_SKILL_ROUTING / GSTACK_SKILL_CONTRACTS > config key > SWITCH_DEFAULT. An unrecognized value takes the default, as setup does. */
/** A config.yaml that exists but cannot be read as a file: the person's choice is unknown. */
function configUnreadable(env: NodeJS.ProcessEnv): boolean {
const file = path.join(resolveStateRoot(env), 'config.yaml');
try {
fs.accessSync(file, fs.constants.R_OK);
return !fs.statSync(file).isFile();
} catch (e) {
return (e as NodeJS.ErrnoException)?.code !== 'ENOENT';
}
}
/**
* env GSTACK_SKILL_ROUTING / GSTACK_SKILL_CONTRACTS > config key > SWITCH_DEFAULT. An unrecognized value takes the
* default, as setup does. An unreadable config is off whatever the default: it may hold an off the person set.
*/
export function readSkillSwitch(name: SwitchName, env: NodeJS.ProcessEnv = process.env): SwitchReading {
const fallback = SWITCH_DEFAULT[name] === 'on';
const envName = SWITCH_ENV[name];
@@ -59,7 +73,7 @@ export function readSkillSwitch(name: SwitchName, env: NodeJS.ProcessEnv = proce
return v ? { on: v === 'on', source: `env ${envName}` } : { on: fallback, source: `env ${envName}`, invalid: raw };
}
const cfg = readConfigKey(name, env);
if (cfg === null) return { on: fallback, source: 'default' };
if (cfg === null) return configUnreadable(env) ? { on: false, source: 'config unreadable' } : { on: fallback, source: 'default' };
const v = normalizeSwitch(cfg);
return v ? { on: v === 'on', source: `config ${name}` } : { on: fallback, source: `config ${name}`, invalid: cfg };
}
+4
View File
@@ -4905,6 +4905,10 @@ _skill_hook_gate() {
else
decision="$("$GSTACK_CONFIG" get --stored "$key" 2>/dev/null || true)"; src="config $key"
if [ "$decision" = "unset" ]; then decision=""; src="default"; fi
if [ "$decision" = "unreadable" ]; then
log " WARNING: cannot read $key from config — using 'off' for this run whatever the default; fix config.yaml, then re-run setup"
decision="off"
fi
fi
decision=$(printf '%s' "$decision" | tr '[:upper:]' '[:lower:]' | tr -d '[:space:]')
case "$decision" in
+6 -5
View File
@@ -38,6 +38,7 @@ import { spawnSync } from 'child_process';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { SWITCH_DEFAULT } from '../lib/skill-route';
const ROOT = path.resolve(import.meta.dir, '..');
const CONFIG_BIN = path.join(ROOT, 'bin', 'gstack-config');
@@ -321,14 +322,14 @@ describe('design_detector (auto|off, rejecting validator)', () => {
});
});
describe('skill_routing and skill_contracts (on|off, default off)', () => {
test('both default to off, and list and defaults enumerate them', () => {
for (const key of ['skill_routing', 'skill_contracts']) expect(get(key)).toEqual({ out: 'off', code: 0 });
describe('skill_routing and skill_contracts (on|off, default SWITCH_DEFAULT)', () => {
test('get, list and defaults report SWITCH_DEFAULT for both', () => {
for (const key of ['skill_routing', 'skill_contracts'] as const) expect(get(key)).toEqual({ out: SWITCH_DEFAULT[key], code: 0 });
for (const verb of ['list', 'defaults']) {
const r = spawnSync('bash', [CONFIG_BIN, verb], { encoding: 'utf-8', timeout: 30_000, env: { ...process.env, GSTACK_STATE_ROOT: STATE } });
expect(r.status).toBe(0);
expect(r.stdout).toMatch(/skill_routing:\s+off/);
expect(r.stdout).toMatch(/skill_contracts:\s+off/);
expect(r.stdout).toMatch(new RegExp(`skill_routing:\\s+${SWITCH_DEFAULT.skill_routing}`));
expect(r.stdout).toMatch(new RegExp(`skill_contracts:\\s+${SWITCH_DEFAULT.skill_contracts}`));
}
});
});
+2
View File
@@ -293,6 +293,8 @@ describe('setup: registers the hooks that parse, refuses the rest, exits non-zer
GSTACK_STATE_ROOT: path.join(fx.home, '.gstack'),
GSTACK_SETTINGS_FILE: fx.settings,
GSTACK_PLAN_TUNE_HOOKS: 'yes',
GSTACK_SKILL_ROUTING: 'off',
GSTACK_SKILL_CONTRACTS: 'off',
TMPDIR: fx.base,
};
// A file, not `bash -c`: the script is longer than Windows' 32,767-character command line.
+76 -53
View File
@@ -13,6 +13,7 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } fr
import { spawnSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { SWITCH_DEFAULT } from '../lib/skill-route';
import {
J1_TRANSCRIPT, ROOT, hookEnv, runHook, skillsDir, tmpDir,
} from './helpers/skill-contract-fixture';
@@ -72,12 +73,28 @@ describe('setup: skill hook gates', () => {
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([]);
test('no flag, env or config: SWITCH_DEFAULT decides, and nothing is persisted', () => {
const g = gate('');
expect(g.code).toBe(0);
if (SWITCH_DEFAULT.skill_routing === 'on') {
expect(g.out).toContain('registered UserPromptSubmit hook: routing hint (on via default;');
expect(registered()).toHaveLength(1);
} else {
expect(g.out).toBe('');
expect(registered()).toEqual([]);
}
expect(config()).not.toContain('skill_routing');
});
test('an unreadable config: warns, registers nothing and removes a live registration, whatever the default', () => {
gate('on');
fs.rmSync(path.join(state, 'config.yaml'));
fs.mkdirSync(path.join(state, 'config.yaml'));
const g = gate('');
expect(g.out).toContain("WARNING: cannot read skill_routing from config — using 'off' for this run whatever the default");
expect(registered()).toEqual([]);
});
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);
@@ -92,13 +109,13 @@ describe('setup: skill hook gates', () => {
expect(registered()).toHaveLength(1);
});
test('env on registers without persisting; a typo warns, applies off and is not persisted', () => {
test('env on registers without persisting; a typo warns, takes SWITCH_DEFAULT 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(typo.out).toContain(`WARNING: unrecognized skill_routing value 'onn' (from flag) — using default '${SWITCH_DEFAULT.skill_routing}' for this run, not persisting`);
expect(config()).not.toContain('skill_routing');
expect(registered()).toEqual([]);
expect(registered()).toHaveLength(SWITCH_DEFAULT.skill_routing === 'on' ? 1 : 0);
});
test('off removes a live registration, and so does --no-team', () => {
@@ -116,6 +133,7 @@ describe('setup: skill hook gates', () => {
describe('SWITCH_DEFAULT is the one default (a flip PR changes one line)', () => {
const TABLE = path.join('lib', 'skill-route.ts');
const original = fs.readFileSync(path.join(ROOT, TABLE), 'utf8');
const other = (v: 'on' | 'off') => (v === 'on' ? 'off' : 'on');
let copy = '', skills = '', work = '';
beforeAll(() => {
copy = tmpDir('gstack-switch-default-');
@@ -128,14 +146,15 @@ describe('SWITCH_DEFAULT is the one default (a flip PR changes one line)', () =>
});
afterAll(() => { for (const d of [copy, skills, work]) fs.rmSync(d, { recursive: true, force: true }); });
/** Write the copy's table with only this switch's line flipped to on, and prove the diff is that one line. */
function flip(key: keyof typeof GATES): void {
const line = ` ${key}: 'off',`;
/** Write the copy's table with only this switch's line flipped, prove the diff is that one line, and return the new default. */
function flip(key: keyof typeof GATES): 'on' | 'off' {
const line = ` ${key}: '${SWITCH_DEFAULT[key]}',`;
const next = other(SWITCH_DEFAULT[key]);
expect(original.split('\n').filter(l => l === line)).toHaveLength(1);
const flipped = original.replace(line, ` ${key}: 'on',`);
const changed = flipped.split('\n').filter((l, i) => l !== original.split('\n')[i]);
expect(changed).toEqual([` ${key}: 'on',`]);
const flipped = original.replace(line, ` ${key}: '${next}',`);
expect(flipped.split('\n').filter((l, i) => l !== original.split('\n')[i])).toEqual([` ${key}: '${next}',`]);
fs.writeFileSync(path.join(copy, TABLE), flipped);
return next;
}
const configAt = (root: string, ...args: string[]) => spawnSync(path.join(root, 'bin', 'gstack-config'), args, {
env: { PATH: process.env.PATH ?? '', HOME: home, GSTACK_STATE_ROOT: state }, encoding: 'utf8', timeout: 30_000,
@@ -146,22 +165,42 @@ describe('SWITCH_DEFAULT is the one default (a flip PR changes one line)', () =>
const stopAt = (root: string) => {
const file = path.join(work, 'transcript.jsonl');
fs.writeFileSync(file, `${J1_TRANSCRIPT.join('\n')}\n`);
fs.rmSync(path.join(state, 'skill-contracts'), { recursive: true, force: true });
return runHook(path.join(root, 'hosts', 'claude', 'hooks', 'skill-contract-stop-hook.ts'),
JSON.stringify({ session_id: 'sess-d', transcript_path: file, cwd: work, hook_event_name: 'Stop', stop_hook_active: false }),
hookEnv(state, skills));
};
test('both defaults are off today, and every reader agrees', () => {
expect(original).toContain("export const SWITCH_DEFAULT: Record<SwitchName, 'on' | 'off'> = {\n skill_routing: 'off',\n skill_contracts: 'off',\n};");
for (const key of ['skill_routing', 'skill_contracts']) {
const get = configAt(ROOT, 'get', key);
expect({ code: get.status, out: get.stdout }).toEqual({ code: 0, out: 'off' });
expect(configAt(ROOT, 'get', '--stored', key).stdout).toBe('unset');
const routes = (r: { stdout: string }) => r.stdout !== '' && JSON.parse(r.stdout).hookSpecificOutput.additionalContext.includes('/investigate');
const blocks = (r: { stdout: string }) => r.stdout !== '' && JSON.parse(r.stdout).decision === 'block';
/** Every reader at root agrees that the switches default to want. */
function expectReaders(root: string, want: Record<keyof typeof GATES, 'on' | 'off'>): void {
for (const key of ['skill_routing', 'skill_contracts'] as const) {
const get = configAt(root, 'get', key);
expect({ key, code: get.status, out: get.stdout }).toEqual({ key, code: 0, out: want[key] });
expect(configAt(root, 'get', '--stored', key).stdout).toBe('unset');
expect(configAt(root, 'defaults').stdout).toMatch(new RegExp(`^ {2}${key}: +${want[key]}$`, 'm'));
const g = gate('', {}, 0, root, key);
expect(registered(key === 'skill_routing' ? 'UserPromptSubmit' : 'Stop')).toHaveLength(want[key] === 'on' ? 1 : 0);
if (want[key] === 'on') expect(g.out).toContain('(on via default;');
else expect(g.out).toBe('');
}
expect(configAt(ROOT, 'defaults').stdout).toMatch(/^ {2}skill_routing: +off$/m);
expect(gate('').out).toBe('');
expect(routeAt(ROOT)).toEqual({ code: 0, stdout: '', stderr: '' });
expect(stopAt(ROOT)).toEqual({ code: 0, stdout: '', stderr: '' });
expect(routes(routeAt(root))).toBe(want.skill_routing === 'on');
expect(blocks(stopAt(root))).toBe(want.skill_contracts === 'on');
expect(config()).toBe('');
}
test('pin: skill_routing defaults to off today (a flip PR changes lib/skill-route.ts line 39 and this pin)', () => {
expect(original.split('\n')[38]).toBe(" skill_routing: 'off',");
expect(SWITCH_DEFAULT.skill_routing).toBe('off');
});
test('pin: skill_contracts defaults to off today (a flip PR changes lib/skill-route.ts line 40 and this pin)', () => {
expect(original.split('\n')[39]).toBe(" skill_contracts: 'off',");
expect(SWITCH_DEFAULT.skill_contracts).toBe('off');
});
test('every reader agrees with SWITCH_DEFAULT: gstack-config, setup, the routing hook and the Stop hook', () => {
expectReaders(ROOT, SWITCH_DEFAULT);
});
test('an absent key reads empty from gstack-config get, never "false"', () => {
@@ -169,42 +208,26 @@ describe('SWITCH_DEFAULT is the one default (a flip PR changes one line)', () =>
expect({ code: r.status, out: r.stdout }).toEqual({ code: 1, out: '' });
});
test('flipping skill_routing alone: setup registers, gstack-config reports on, the hook routes; config off still wins', () => {
flip('skill_routing');
expect(configAt(copy, 'get', 'skill_routing').stdout).toBe('on');
expect(configAt(copy, 'get', 'skill_contracts').stdout).toBe('off');
expect(configAt(copy, 'defaults').stdout).toMatch(/^ {2}skill_routing: +on$/m);
const g = gate('', {}, 0, copy);
expect(g.out).toContain('registered UserPromptSubmit hook: routing hint (on via default;');
expect(registered()).toHaveLength(1);
expect(config()).not.toContain('skill_routing');
const hint = routeAt(copy);
expect(hint.code).toBe(0);
expect(JSON.parse(hint.stdout).hookSpecificOutput.additionalContext).toContain('/investigate');
expect(stopAt(copy).stdout).toBe('');
expect(configAt(copy, 'set', 'skill_routing', 'off').status).toBe(0);
test('flipping skill_routing alone moves every reader; an explicit config value still wins', () => {
const next = flip('skill_routing');
expectReaders(copy, { ...SWITCH_DEFAULT, skill_routing: next });
expect(configAt(copy, 'set', 'skill_routing', SWITCH_DEFAULT.skill_routing).status).toBe(0);
gate('', {}, 0, copy);
expect(registered()).toEqual([]);
expect(routeAt(copy).stdout).toBe('');
expect(registered()).toHaveLength(SWITCH_DEFAULT.skill_routing === 'on' ? 1 : 0);
expect(routes(routeAt(copy))).toBe(SWITCH_DEFAULT.skill_routing === 'on');
});
test('flipping skill_contracts alone: setup registers the Stop hook and the hook blocks', () => {
flip('skill_contracts');
const g = gate('', {}, 0, copy, 'skill_contracts');
expect(g.out).toContain('registered Stop hook: contract check (on via default;');
expect(registered('Stop')).toHaveLength(1);
expect(registered()).toEqual([]);
expect(JSON.parse(stopAt(copy).stdout).decision).toBe('block');
expect(routeAt(copy).stdout).toBe('');
test('flipping skill_contracts alone moves every reader', () => {
const next = flip('skill_contracts');
expectReaders(copy, { ...SWITCH_DEFAULT, skill_contracts: next });
});
test('a typo takes the flipped default in setup and in the hook', () => {
flip('skill_routing');
const next = flip('skill_routing');
const typo = gate('onn', {}, 0, copy);
expect(typo.out).toContain("WARNING: unrecognized skill_routing value 'onn' (from flag) — using default 'on' for this run, not persisting");
expect(registered()).toHaveLength(1);
expect(JSON.parse(routeAt(copy, { GSTACK_SKILL_ROUTING: 'onn' }).stdout).hookSpecificOutput.additionalContext).toContain('/investigate');
expect(routeAt(ROOT, { GSTACK_SKILL_ROUTING: 'onn' }).stdout).toBe('');
expect(typo.out).toContain(`WARNING: unrecognized skill_routing value 'onn' (from flag) — using default '${next}' for this run, not persisting`);
expect(registered()).toHaveLength(next === 'on' ? 1 : 0);
expect(routes(routeAt(copy, { GSTACK_SKILL_ROUTING: 'onn' }))).toBe(next === 'on');
expect(routes(routeAt(ROOT, { GSTACK_SKILL_ROUTING: 'onn' }))).toBe(SWITCH_DEFAULT.skill_routing === 'on');
});
});
+8 -1
View File
@@ -8,6 +8,7 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { AGENT_STALL_MS, STATE_ERROR_DOC, stateErrorLine } from '../lib/skill-contract';
import { SWITCH_DEFAULT } from '../lib/skill-route';
import {
CONTRACT_HOOK, DEBUG_REPORT, INTERACTIVE_TURNS, J1_TRANSCRIPT, ROOT, hookEnv, meta, result, runHook, say, skillsDir, slash, tmpDir, tool, user,
} from './helpers/skill-contract-fixture';
@@ -218,9 +219,15 @@ describe('fails open on state errors with one stderr line', () => {
});
describe('off switch', () => {
test('default off, config off, and env off over config on: no output at all', () => {
test('with nothing set SWITCH_DEFAULT decides; config off, env off over config on, and an unreadable config: no output at all', () => {
const file = transcript(J1_TRANSCRIPT);
fs.mkdirSync(path.join(state, 'config.yaml'));
expect(runHook(CONTRACT_HOOK, payload(file), hookEnv(state, skills))).toEqual({ code: 0, stdout: '', stderr: '' });
fs.rmSync(path.join(state, 'config.yaml'), { recursive: true });
const unset = runHook(CONTRACT_HOOK, payload(file), hookEnv(state, skills));
if (SWITCH_DEFAULT.skill_contracts === 'on') expect(JSON.parse(unset.stdout).decision).toBe('block');
else expect(unset).toEqual({ code: 0, stdout: '', stderr: '' });
fs.rmSync(path.join(state, 'skill-contracts'), { recursive: true, force: true });
fs.writeFileSync(path.join(state, 'config.yaml'), 'skill_contracts: off\n');
expect(runHook(CONTRACT_HOOK, payload(file), hookEnv(state, skills))).toEqual({ code: 0, stdout: '', stderr: '' });
fs.writeFileSync(path.join(state, 'config.yaml'), 'skill_contracts: on\n');
+19 -10
View File
@@ -2,13 +2,15 @@
* lib/skill-route.ts, the skill-route hook and gstack-skill-route --explain
* (GST-1088, lane A): deterministic scoring against installed triggers, one
* additionalContext line when it fires, nothing at all (exit 0, no stderr) on
* every error, and off by default.
* every error. Nothing here assumes a default: tests set the switch, or read
* the expected result from SWITCH_DEFAULT (the pins live in
* test/setup-skill-hooks-gate.test.ts).
*/
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 { PROMPT_MAX_CHARS, ROUTED_SKILLS, ROUTE_THRESHOLD, firstStep, hintLine, readSkillSwitch, routePrompt, scanInstalledSkills, scorePrompt, stem } from '../lib/skill-route';
import { PROMPT_MAX_CHARS, ROUTED_SKILLS, ROUTE_THRESHOLD, SWITCH_DEFAULT, firstStep, hintLine, readSkillSwitch, routePrompt, scanInstalledSkills, scorePrompt, stem } from '../lib/skill-route';
import { ROOT, ROUTE_HOOK, SKILLS, hookEnv, runHook, skillsDir, tmpDir } from './helpers/skill-contract-fixture';
let state = '', skills = '';
@@ -75,12 +77,17 @@ describe('scorer', () => {
expect(firstStep(SKILLS.investigate)).toBe('Reproduce the failure before you change code, then fix it and prove the fix.');
});
test('switch precedence: env over config over default off; typos read as off', () => {
expect(readSkillSwitch('skill_routing', { GSTACK_STATE_ROOT: state })).toEqual({ on: false, source: 'default' });
test('switch precedence: env over config over SWITCH_DEFAULT; a typo takes the default; an unreadable config is off', () => {
expect(readSkillSwitch('skill_routing', { GSTACK_STATE_ROOT: state })).toEqual({ on: SWITCH_DEFAULT.skill_routing === 'on', source: 'default' });
fs.writeFileSync(path.join(state, 'config.yaml'), 'skill_routing: on\nskill_contracts: maybe\n');
expect(readSkillSwitch('skill_routing', { GSTACK_STATE_ROOT: state })).toEqual({ on: true, source: 'config skill_routing' });
expect(readSkillSwitch('skill_routing', { GSTACK_STATE_ROOT: state, GSTACK_SKILL_ROUTING: 'off' })).toEqual({ on: false, source: 'env GSTACK_SKILL_ROUTING' });
expect(readSkillSwitch('skill_contracts', { GSTACK_STATE_ROOT: state })).toEqual({ on: false, source: 'config skill_contracts', invalid: 'maybe' });
expect(readSkillSwitch('skill_contracts', { GSTACK_STATE_ROOT: state })).toEqual({ on: SWITCH_DEFAULT.skill_contracts === 'on', source: 'config skill_contracts', invalid: 'maybe' });
fs.rmSync(path.join(state, 'config.yaml'));
fs.mkdirSync(path.join(state, 'config.yaml'));
for (const name of ['skill_routing', 'skill_contracts'] as const) {
expect(readSkillSwitch(name, { GSTACK_STATE_ROOT: state })).toEqual({ on: false, source: 'config unreadable' });
}
});
});
@@ -101,8 +108,10 @@ describe('skill-route hook', () => {
.toContain('Done means: a DEBUG REPORT with Symptom, Root cause, Fix, Evidence; a test or repro run after your last edit.');
});
test('off by default, off in config, and off in env over config on', () => {
expect(route(prompt('fix this bug'), {})).toEqual(silent);
test('with nothing set SWITCH_DEFAULT decides; off in config, and off in env over config on', () => {
const unset = route(prompt('fix this bug'), {});
if (SWITCH_DEFAULT.skill_routing === 'on') expect(expectHookSchema(unset.stdout)).toContain('/investigate');
else expect(unset).toEqual(silent);
fs.writeFileSync(path.join(state, 'config.yaml'), 'skill_routing: off\n');
expect(route(prompt('fix this bug'), {})).toEqual(silent);
fs.writeFileSync(path.join(state, 'config.yaml'), 'skill_routing: on\n');
@@ -136,7 +145,7 @@ describe('skill-route hook', () => {
expect(traceLog()).toContain('event=skill-problems problems="broken: malformed triggers" failures=1');
});
test('unreadable config and an unwritable state root: still exit 0 and silent', () => {
test('an unreadable config is off whatever the default; an unwritable state root still routes when on', () => {
fs.mkdirSync(path.join(state, 'config.yaml'));
expect(route(prompt('fix this bug'), {})).toEqual(silent);
const file = path.join(state, 'root-is-a-file');
@@ -158,10 +167,10 @@ describe('skill-route hook', () => {
describe('gstack-skill-route --explain', () => {
const explain = (...args: string[]) => spawnSync(path.join(ROOT, 'bin', 'gstack-skill-route'), args, { env: hookEnv(state, skills), encoding: 'utf8', timeout: 20_000 });
test('prints the switch, threshold, candidates with scores, the route and the hint (a dry run with the switch off)', () => {
test('prints the switch, threshold, candidates with scores, the route and the hint (a dry run whatever the switch)', () => {
const r = explain('--explain', 'this test fails, fix it');
expect(r.status).toBe(0);
expect(r.stdout).toContain('switch: skill_routing off (default)');
expect(r.stdout).toContain(`switch: skill_routing ${SWITCH_DEFAULT.skill_routing} (default)`);
expect(r.stdout).toContain('threshold: 0.66');
expect(r.stdout).toContain('1.00 /investigate "this test fails"');
expect(r.stdout).toContain('route: /investigate');