feat(hooks): one SWITCH_DEFAULT per hook switch, read by setup, the hooks, gstack-config and gstack-skill-route (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 07:00:29 +00:00
1 parent afd766600b
commit 365dd613d2
9 files changed
+167 -35

No files matched your search

+3 -2
View File
@@ -26,6 +26,7 @@
# reports which root received the value and which root still overrides it.
# Docs: https://github.com/garrytan/gstack/blob/main/docs/state-root.md
set -euo pipefail
GSTACK_BUN_CONFIG=/dev/null; case "${OSTYPE:-}" in msys*|cygwin*) if command -v cygpath >/dev/null 2>&1; then GSTACK_BUN_CONFIG=NUL; fi ;; esac
[ -r "$(dirname "$0")/gstack-state-root.sh" ] && . "$(dirname "$0")/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: $(dirname "$0")/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; }
gstack_state_root_select
@@ -344,8 +345,8 @@ lookup_default() {
artifacts_sync_removals) echo "off" ;; # on | off — publish removals of synced files gone from disk
plan_tune_hooks) echo "prompt" ;; # prompt | yes | no — controls ./setup plan-tune hook install
timeline_stop_hook) echo "yes" ;; # yes | no — controls ./setup timeline Stop hook registration (#2677)
skill_routing) echo "off" ;; # on | off — ./setup registers the skill-routing UserPromptSubmit hook (docs/skill-routing-and-contracts.md)
skill_contracts) echo "off" ;; # on | off — ./setup registers the skill-contract Stop hook (docs/skill-routing-and-contracts.md)
skill_routing) bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" "$(dirname "$0")/gstack-skill-route" --default skill_routing ;; # on | off — the skill-routing UserPromptSubmit hook; default: SWITCH_DEFAULT in lib/skill-route.ts (docs/skill-routing-and-contracts.md)
skill_contracts) bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" "$(dirname "$0")/gstack-skill-route" --default skill_contracts ;; # on | off — the skill-contract Stop hook; default: SWITCH_DEFAULT in lib/skill-route.ts (docs/skill-routing-and-contracts.md)
redact_repo_visibility) echo "" ;; # empty → fall through to gh/glab detection
redact_prepush_hook) echo "false" ;;
+14 -3
View File
@@ -4,19 +4,30 @@
*
* Usage:
* gstack-skill-route --explain "<prompt>" [--json] [--threshold <n>]
* gstack-skill-route --default <skill_routing|skill_contracts>
*
* Prints the switch state, the threshold, every candidate skill with its
* score and best trigger, the route the hook would take and the exact line it
* would inject. Works with the switch off (a dry run): it never injects
* anything. Exit 0, or 2 on a usage error. Docs: docs/skill-routing-and-contracts.md.
* anything. --default prints a switch's SWITCH_DEFAULT (on or off), the value
* setup and gstack-config use when the switch is unset. Exit 0, or 2 on a usage error. Docs: docs/skill-routing-and-contracts.md.
*/
import {
ROUTE_THRESHOLD, hintLine, installedSkillsDir, readSkillSwitch, routePrompt, scanInstalledSkills,
ROUTE_THRESHOLD, SWITCH_DEFAULT, hintLine, installedSkillsDir, readSkillSwitch, routePrompt, scanInstalledSkills,
} from '../lib/skill-route';
const USAGE = 'Usage: gstack-skill-route --explain "<prompt>" [--json] [--threshold <n>]';
const USAGE = 'Usage: gstack-skill-route --explain "<prompt>" [--json] [--threshold <n>]\n gstack-skill-route --default <skill_routing|skill_contracts>';
const args = process.argv.slice(2);
if (args[0] === '--help' || args[0] === '-h') { process.stdout.write(`${USAGE}\n`); process.exit(0); }
if (args[0] === '--default') {
const name = args[1] ?? '';
if (!Object.hasOwn(SWITCH_DEFAULT, name)) {
process.stderr.write(`SKILL_ROUTE_USAGE: --default needs skill_routing or skill_contracts\n${USAGE}\n`);
process.exit(2);
}
process.stdout.write(`${SWITCH_DEFAULT[name as keyof typeof SWITCH_DEFAULT]}\n`);
process.exit(0);
}
const at = args.indexOf('--explain');
const prompt = at >= 0 ? args[at + 1] : undefined;
const tAt = args.indexOf('--threshold');
+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. `gstack-config list` and `defaults` show `skill_routing` and `skill_contracts` (both off by 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, and the Stop hook lets a stop through only once while the same Agent dispatches are still running, recording the run as `deferred`, then checks the contract if they are still running at the next stop.
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, and the Stop hook lets a stop through only once while the same Agent dispatches are still running, recording the run as `deferred`, then checks the contract if they are still running at the next stop.
+4 -4
View File
@@ -1,6 +1,6 @@
# Skill routing and skill contracts
Two opt-in Claude Code hooks that help a model actually run gstack's skills (GST-1088). Both are **off by default**, and with both off nothing changes.
Two Claude Code hooks that help a model actually run gstack's skills (GST-1088). Each has one default, `SWITCH_DEFAULT` in `lib/skill-route.ts`; `gstack-config defaults` prints it. With both off nothing changes.
- **Skill routing** (`skill_routing`, a UserPromptSubmit hook). When a prompt matches an installed skill's `triggers:`, the hook adds one line of context naming that skill, for example "this request fits /investigate. Run /investigate with the Skill tool before you start".
- **Skill contracts** (`skill_contracts`, a Stop hook). A skill can declare the outputs that must exist before it is done. When the model tries to stop without them, the hook blocks once and names what is missing.
@@ -12,9 +12,9 @@ Each switch resolves in this order, and the first one set wins:
1. The `./setup` flag: `--skill-routing` / `--no-skill-routing` and `--skill-contracts` / `--no-skill-contracts` (also `--skill-routing=on|off`). A flag is saved to config, so it survives upgrades.
2. The environment: `GSTACK_SKILL_ROUTING` and `GSTACK_SKILL_CONTRACTS` (`on` or `off`). Not saved.
3. Config: `gstack-config set skill_routing on|off` and `gstack-config set skill_contracts on|off`.
4. The default: 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 off for that run, and does not save 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.
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:
@@ -73,7 +73,7 @@ Every item has an `id` (lowercase letters, digits, `.`, `_`, `-`) and a `kind`,
| kind | Fields | Met when |
|---|---|---|
| `report_labels` | `heading`, `labels[]` | The last assistant message containing the heading has a `Label: value` line for every label, and no value is empty or a placeholder (`[...]`, `<...>`, TBD, TODO, `...`, or a template like `DONE \| BLOCKED`) |
| `check_after_last_edit` | optional `match` (regular expression) | No Edit, Write, MultiEdit or NotebookEdit happened, or a Bash test or repro command ran after the last one. By default a command counts when, with quoted text removed, one of its parts (split on `&&`, `\|\|`, `;`, `\|`) starts with a test runner, build tool or interpreter (bun, npm, pytest, go, cargo, make, node, python, curl, ...) or a script whose name says test, spec, repro, check, verify or lint, directly or behind a wrapper (`bundle exec rspec`, `uv run pytest`, `docker compose run app pytest`, `xvfb-run npm test`, `bash scripts/test.sh`). `git commit --no-verify`, `cat test/a.test.ts` and the shell's `test -f` do not count |
| `check_after_last_edit` | optional `match` (regular expression) | No Edit, Write, MultiEdit or NotebookEdit happened, or a Bash test or repro command ran after the last one. By default a command counts when, with quoted text removed, one of its parts (split on `&&`, `\|\|`, `;`, `\|`) runs a test runner (pytest, jest, vitest, rspec, tsc, ...), a test or check verb (`npm test`, `go test`, `cargo run`, `make check`, `bun run lint`), code through an interpreter (`node repro.js`, `python -c ...`, `bun scripts/repro.ts`), `curl` against a URL, or a script whose name says test, spec, repro, check, verify or lint, directly or behind a wrapper (`bundle exec rspec`, `uv run pytest`, `docker compose run app pytest`, `xvfb-run npm test`, `bash scripts/test.sh`). Installs, builds, cleans and version probes (`bun install`, `npm install`, `go build`, `make clean`, `node --version`), `git commit --no-verify`, `cat test/a.test.ts` and the shell's `test -f` do not count |
| `selected_specialists_dispatched` | optional `marker`, `empty_marker` | The selection line was printed in an assistant message or a Bash result (never a fetched page or file), its names are plain (letters, digits, spaces, `_`, `.`, `-`, at most 40 characters), and each specialist it names has an Agent dispatch that mentions it. The marker is literal text in which a whole-word N stands for a number; the default is `Dispatching N specialists:`. N = 0 (`Dispatching 0 specialists: (small diff)`) or a "specialists skipped" line means zero specialists were selected, which passes. Text in parentheses is not a name (`Dispatching 1 specialists: State Machines (small diff)` selects State Machines) |
| `agent_dispatch` | `match` (regular expression, case-insensitive), optional `marker` | The `marker` line (literal text, like /review's `Dispatching adversarial pass: native`) was printed and an Agent dispatch follows it; otherwise, as the fallback, an Agent dispatch's description or prompt matches `match` |
@@ -3,7 +3,7 @@
* skill-contract-stop-hook — Stop hook that blocks once when a contract skill
* ends without its required outputs (GST-1088, lane A). Off unless
* skill_contracts is on (env GSTACK_SKILL_CONTRACTS, then config
* skill_contracts; default off).
* skill_contracts, then SWITCH_DEFAULT).
*
* stdin -> switch on? -> transcript_path -> parse (+ last_assistant_message)
* -> the most recently loaded skill that declares a contract (none: allow)
+1 -1
View File
@@ -2,7 +2,7 @@
/**
* skill-route-hook — UserPromptSubmit hook that names the gstack skill a prompt
* fits (GST-1088, lane A). Off unless skill_routing is on (env
* GSTACK_SKILL_ROUTING, then config skill_routing; default off).
* GSTACK_SKILL_ROUTING, then config skill_routing, then SWITCH_DEFAULT).
*
* stdin (<= 256 KiB, 500 ms) -> switch on? -> JSON object with a prompt?
* -> not a /command, not over 8000 chars -> score against installed triggers
+11 -4
View File
@@ -34,6 +34,12 @@ export const SWITCH_ENV: Record<SwitchName, string> = {
skill_contracts: 'GSTACK_SKILL_CONTRACTS',
};
/** Each switch's one default. setup, gstack-config and gstack-skill-route read it through `gstack-skill-route --default <switch>`; the hooks read it here. */
export const SWITCH_DEFAULT: Record<SwitchName, 'on' | 'off'> = {
skill_routing: 'off',
skill_contracts: 'off',
};
export function normalizeSwitch(raw: string | null | undefined): 'on' | 'off' | null {
const v = String(raw ?? '').trim().toLowerCase();
if (['on', 'yes', 'y', 'true', '1'].includes(v)) return 'on';
@@ -43,18 +49,19 @@ 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 > default off. An unrecognized value is off. */
/** env GSTACK_SKILL_ROUTING / GSTACK_SKILL_CONTRACTS > config key > SWITCH_DEFAULT. An unrecognized value takes the default, as setup does. */
export function readSkillSwitch(name: SwitchName, env: NodeJS.ProcessEnv = process.env): SwitchReading {
const fallback = SWITCH_DEFAULT[name] === 'on';
const envName = SWITCH_ENV[name];
const raw = env[envName];
if (raw !== undefined && raw !== '') {
const v = normalizeSwitch(raw);
return v ? { on: v === 'on', source: `env ${envName}` } : { on: false, source: `env ${envName}`, invalid: raw };
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: false, source: 'default' };
if (cfg === null) return { on: fallback, source: 'default' };
const v = normalizeSwitch(cfg);
return v ? { on: v === 'on', source: `config ${name}` } : { on: false, source: `config ${name}`, invalid: cfg };
return v ? { on: v === 'on', source: `config ${name}` } : { on: fallback, source: `config ${name}`, invalid: cfg };
}
/** Where Claude Code reads skills: GSTACK_INSTALLED_SKILLS_DIR, else ${CLAUDE_CONFIG_DIR:-~/.claude}/skills. */
+17 -10
View File
@@ -53,7 +53,7 @@ Options:
-q, --quiet Suppress progress output.
-h, --help Show this help and exit.
Claude Code hooks (off by default; docs/skill-routing-and-contracts.md):
Claude Code hooks (docs/skill-routing-and-contracts.md):
--skill-routing / --no-skill-routing
Name the gstack skill a prompt fits, in one line of
context (UserPromptSubmit). Saved as skill_routing.
@@ -4882,32 +4882,38 @@ if [ "$NO_TEAM_MODE" -ne 1 ] && [ "$TL_DECISION" != "no" ] && [ -x "$SETTINGS_HO
fi
# ─── Skill routing + contract hooks (GST-1088) ───────────────────────────────
# Two opt-in Claude Code hooks, both OFF by default (docs/skill-routing-and-contracts.md):
# Two Claude Code hooks (docs/skill-routing-and-contracts.md):
# skill_routing — UserPromptSubmit: one line naming the gstack skill a prompt fits.
# skill_contracts — Stop: blocks once when a contract skill ends without its outputs.
# Gate per hook, mirroring the timeline_stop_hook block above: flag > env
# (GSTACK_SKILL_ROUTING / GSTACK_SKILL_CONTRACTS) > saved config (skill_routing /
# skill_contracts) > default off. An explicit flag persists to config; a typo
# warns and is not persisted; off (or --no-team) removes a live registration.
# skill_contracts) > the switch's default, SWITCH_DEFAULT in lib/skill-route.ts,
# read through gstack-skill-route --default (off if that cannot run). An
# explicit flag persists to config; a typo warns, takes the default and is not
# persisted; off (or --no-team) removes a live registration.
# The hooks read the same switch at run time, so `gstack-config set <key> off`
# takes effect on the next prompt without re-running setup.
_skill_hook_gate() {
local key="$1" mode="$2" env_name="$3" event="$4" cmd="$5" source="$6" timeout="$7" what="$8"
local decision src out unrecognized=0
local decision src out def unrecognized=0
def="$(bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" "$SKILL_ROUTE_BIN" --default "$key" 2>/dev/null || true)"
[ "$def" = "on" ] || def="off"
if [ -n "$mode" ]; then
decision="$mode"; src="flag"
elif [ -n "${!env_name:-}" ]; then
decision="${!env_name}"; src="env $env_name"
else
decision="$("$GSTACK_CONFIG" get "$key" 2>/dev/null || true)"; src="config $key"
decision="$("$GSTACK_CONFIG" get --stored "$key" 2>/dev/null || true)"; src="config $key"
if [ "$decision" = "unset" ]; then decision=""; src="default"; fi
fi
decision=$(printf '%s' "$decision" | tr '[:upper:]' '[:lower:]' | tr -d '[:space:]')
case "$decision" in
on|yes|y|true|1) decision="on" ;;
off|no|n|false|skip|0|"") decision="off" ;;
on|yes|y|true|1) decision="on" ;;
off|no|n|false|skip|0) decision="off" ;;
"") decision="$def" ;;
*)
log " WARNING: unrecognized $key value '$decision' (from $src) — using default 'off' for this run, not persisting"
decision="off"; unrecognized=1 ;;
log " WARNING: unrecognized $key value '$decision' (from $src) — using default '$def' for this run, not persisting"
decision="$def"; unrecognized=1 ;;
esac
if [ -n "$mode" ] && [ "$unrecognized" -eq 0 ]; then
"$GSTACK_CONFIG" set "$key" "$decision" >/dev/null 2>&1 || true
@@ -4929,6 +4935,7 @@ _skill_hook_gate() {
log " warning: settings hook update failed: $(printf '%s\n' "$out" | head -1) — run $SETTINGS_HOOK manually"
fi
}
SKILL_ROUTE_BIN="$SOURCE_GSTACK_DIR/bin/gstack-skill-route"
SKILL_ROUTE_HOOK="$(_hook_command_path hosts/claude/hooks/skill-route-hook || true)"
SKILL_CONTRACT_HOOK="$(_hook_command_path hosts/claude/hooks/skill-contract-stop-hook || true)"
_skill_hook_gate skill_routing "$SKILL_ROUTING_MODE" GSTACK_SKILL_ROUTING UserPromptSubmit \
+115 -9
View File
@@ -5,12 +5,17 @@
* 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.
* Each switch's default lives only in SWITCH_DEFAULT (lib/skill-route.ts): a
* copy with one table line flipped changes what setup registers, what
* gstack-config reports and what the hook does.
*/
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
import { afterAll, afterEach, beforeAll, 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';
import {
J1_TRANSCRIPT, ROOT, hookEnv, runHook, skillsDir, 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');
@@ -23,26 +28,31 @@ afterEach(() => { for (const d of [state, home]) fs.rmSync(d, { recursive: 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 GATES = {
skill_routing: 'GSTACK_SKILL_ROUTING UserPromptSubmit "$2/hosts/claude/hooks/skill-route-hook" gstack-skill-route 5 "routing hint"',
skill_contracts: 'GSTACK_SKILL_CONTRACTS Stop "$2/hosts/claude/hooks/skill-contract-stop-hook" gstack-skill-contract 10 "contract check"',
};
/** Run one gate once from the checkout at root: mode is the flag value ("" = none). */
function gate(mode: string, env: Record<string, string> = {}, noTeam = 0, root = ROOT, key: keyof typeof GATES = 'skill_routing'): { code: number | null; out: string } {
const script = [
'set -e',
'log() { printf "%s\\n" "$*"; }',
`GSTACK_CONFIG="${path.join(ROOT, 'bin', 'gstack-config')}"`,
'GSTACK_CONFIG="$2/bin/gstack-config"',
`SETTINGS_HOOK="${path.join(ROOT, 'bin', 'gstack-settings-hook')}"`,
'SKILL_ROUTE_BIN="$2/bin/gstack-skill-route"',
`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"`,
`_skill_hook_gate ${key} "$1" ${GATES[key]}`,
].join('\n');
const r = spawnSync('bash', ['-c', script, 'gate', mode], {
const r = spawnSync('bash', ['-c', script, 'gate', mode, root], {
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 }> }>;
const registered = (event = 'UserPromptSubmit') => (settings().hooks?.[event] ?? []) 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', () => {
@@ -102,3 +112,99 @@ describe('setup: skill hook gates', () => {
expect(registered()).toEqual([]);
});
});
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');
let copy = '', skills = '', work = '';
beforeAll(() => {
copy = tmpDir('gstack-switch-default-');
fs.cpSync(path.join(ROOT, 'lib'), path.join(copy, 'lib'), { recursive: true });
fs.cpSync(path.join(ROOT, 'hosts', 'claude', 'hooks'), path.join(copy, 'hosts', 'claude', 'hooks'), { recursive: true });
for (const f of ['gstack-config', 'gstack-state-root.sh', 'gstack-skill-route']) fs.cpSync(path.join(ROOT, 'bin', f), path.join(copy, 'bin', f));
fs.symlinkSync(path.join(ROOT, 'node_modules'), path.join(copy, 'node_modules'));
skills = skillsDir(['investigate']);
work = tmpDir('gstack-switch-default-work-');
});
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',`;
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',`]);
fs.writeFileSync(path.join(copy, TABLE), flipped);
}
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,
});
const routeAt = (root: string, env: Record<string, string> = {}) => runHook(path.join(root, 'hosts', 'claude', 'hooks', 'skill-route-hook.ts'),
JSON.stringify({ session_id: 'sess-d', transcript_path: '/tmp/t.jsonl', cwd: work, hook_event_name: 'UserPromptSubmit', prompt: 'please fix this bug in login' }),
hookEnv(state, skills, env));
const stopAt = (root: string) => {
const file = path.join(work, 'transcript.jsonl');
fs.writeFileSync(file, `${J1_TRANSCRIPT.join('\n')}\n`);
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');
}
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: '' });
});
test('an absent key reads empty from gstack-config get, never "false"', () => {
const r = configAt(ROOT, 'get', 'no_such_key_gst1088');
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);
gate('', {}, 0, copy);
expect(registered()).toEqual([]);
expect(routeAt(copy).stdout).toBe('');
});
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('a typo takes the flipped default in setup and in the hook', () => {
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('');
});
});