From 9e809ab5b12901c6078082502f55f58065e2591f Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Thu, 5 Nov 2026 12:46:19 -0800 Subject: [PATCH] investigate: opt-in gate that holds root cause before fix, through Bash too (GST-1461) (#4045) * investigate: opt-in gate that holds root cause before fix, through Bash too (GST-1461) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * investigate gate: posix paths in edit blocks, CI fixes (GST-1461) - hook: the edit branch computes the repo-relative path once in posix form, so the marker and deny text read src/total.ts on Windows too - lib/review-depth.ts is back to main's pinned bytes; lib/investigate-gate.ts keeps its own copy of TEST_PATH - gstack-skill-gates report: destructure session_id (the pregate secrets scan read the old line as a secret-named assignment) - tests: pin core.autocrlf=false in the git fixtures, skip the symlink swap on Windows, give setup-hook-syntax-gate's fixture the shared TypeScript entry a shim runs, unit-test excludedReason, and check the smoke's pinned Claude Code version against docs/skill-gates.md Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes (GST-1461) - checkTree saves the bytes a restore replaces in the session directory and the block names the copy, so a concurrent editor save is never lost - a new commit is undone to its newest ancestor that existed before the baseline or that a remote-tracking ref holds, not to the baseline HEAD: a fix committed on another branch moves that branch back to its own tip, and pull/merge of fetched upstream commits is not undone; the block lists the commit's other changes left staged - runGateHook takes the switch reading from its caller, so the investigate_gate config key has a literal reader (gstack-config-defaults) - the slow checkout case gets an explicit 30 s test timeout Review-Finding: p1/specialist-batched#1 reader=security Review-Finding: p1/red-team#1 reader=red-team Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes, round 2 (GST-1461) - an amend or rebase of commits from before the baseline on the same branch is undone to the baseline HEAD, not to its parent; on a different branch the gate blocks but leaves the commit in place - existedBefore answers reachability with one rev-list call instead of one merge-base per ref, so a fix commit in a repo with hundreds of refs is still undone inside the 5 s hook deadline Review-Finding: p2/specialist-batched#1 reader=testing Review-Finding: p2/red-team#1 reader=red-team 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> --- bin/gstack-config | 5 +- bin/gstack-settings-hook | 10 +- bin/gstack-skill-gates | 104 +++++ bin/gstack-skill-route | 6 +- changelog.d/capy-gst-1461-investigate-gate.md | 4 + docs/skill-gates.md | 102 +++++ docs/skill-routing-and-contracts.md | 2 +- hosts/claude/hooks/investigate-gate-post | 12 + .../hooks/investigate-gate-post-failure | 12 + hosts/claude/hooks/investigate-gate-pre | 12 + hosts/claude/hooks/investigate-gate-stop | 12 + hosts/claude/hooks/investigate-gate.ts | 166 +++++++ lib/investigate-gate.ts | 285 ++++++++++++ lib/skill-contract.ts | 2 +- lib/skill-gates.ts | 207 +++++++++ lib/skill-route.ts | 6 +- scripts/skill-gates-smoke.ts | 143 ++++++ setup | 53 ++- test/investigate-gate.test.ts | 419 ++++++++++++++++++ test/setup-hook-syntax-gate.test.ts | 1 + test/setup-skill-hooks-gate.test.ts | 55 ++- test/skill-gates-advisory-bytes.test.ts | 66 +++ test/skill-gates.test.ts | 86 ++++ 23 files changed, 1742 insertions(+), 28 deletions(-) create mode 100755 bin/gstack-skill-gates create mode 100644 changelog.d/capy-gst-1461-investigate-gate.md create mode 100644 docs/skill-gates.md create mode 100755 hosts/claude/hooks/investigate-gate-post create mode 100755 hosts/claude/hooks/investigate-gate-post-failure create mode 100755 hosts/claude/hooks/investigate-gate-pre create mode 100755 hosts/claude/hooks/investigate-gate-stop create mode 100755 hosts/claude/hooks/investigate-gate.ts create mode 100644 lib/investigate-gate.ts create mode 100644 lib/skill-gates.ts create mode 100644 scripts/skill-gates-smoke.ts create mode 100644 test/investigate-gate.test.ts create mode 100644 test/skill-gates-advisory-bytes.test.ts create mode 100644 test/skill-gates.test.ts diff --git a/bin/gstack-config b/bin/gstack-config index c27f155a9..405af9934 100755 --- a/bin/gstack-config +++ b/bin/gstack-config @@ -347,6 +347,7 @@ lookup_default() { timeline_stop_hook) echo "yes" ;; # yes | no — controls ./setup timeline Stop hook registration (#2677) 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) + investigate_gate) bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" "$(dirname "$0")/gstack-skill-route" --default investigate_gate ;; # on | off — the /investigate enforcement hooks; default: SWITCH_DEFAULT in lib/skill-route.ts (docs/skill-gates.md) redact_repo_visibility) echo "" ;; # empty → fall through to gh/glab detection redact_prepush_hook) echo "false" ;; @@ -1143,7 +1144,7 @@ $_gstack_legacy_root" ship_measure_max_rounds ship_measure_sweep_cases ship_measure_sweep_budget_usd ship_rerun_backend test_backend \ plan_review_tier implementation_tier model_frontier_claude model_frontier_openai \ model_smart_claude model_smart_openai codex_effort review_effort_tiers agent_model harness_start_hook \ - skill_routing skill_contracts; do + skill_routing skill_contracts investigate_gate; do gstack_config_select "$KEY" VALUE="$_gstack_cfg_value" SOURCE="default" @@ -1169,7 +1170,7 @@ $_gstack_legacy_root" ship_measure_max_rounds ship_measure_sweep_cases ship_measure_sweep_budget_usd ship_rerun_backend test_backend \ plan_review_tier implementation_tier model_frontier_claude model_frontier_openai \ model_smart_claude model_smart_openai codex_effort review_effort_tiers agent_model harness_start_hook \ - skill_routing skill_contracts; do + skill_routing skill_contracts investigate_gate; do printf ' %-24s %s\n' "$KEY:" "$(lookup_default "$KEY")" done ;; diff --git a/bin/gstack-settings-hook b/bin/gstack-settings-hook index 9e951289a..568acc315 100755 --- a/bin/gstack-settings-hook +++ b/bin/gstack-settings-hook @@ -129,7 +129,11 @@ var KNOWN_HOOKS = { "gstack-session-update": { source: "gstack-session-update", event: "SessionStart", matcher: "", relpath: "bin/gstack-session-update" }, "gstack-verify-gate": { source: "verify-gate", event: "Stop", matcher: "", relpath: "bin/gstack-verify-gate" }, "skill-route-hook": { source: "gstack-skill-route", event: "UserPromptSubmit", matcher: "", relpath: "hosts/claude/hooks/skill-route-hook" }, - "skill-contract-stop-hook": { source: "gstack-skill-contract", event: "Stop", matcher: "", relpath: "hosts/claude/hooks/skill-contract-stop-hook" } + "skill-contract-stop-hook": { source: "gstack-skill-contract", event: "Stop", matcher: "", relpath: "hosts/claude/hooks/skill-contract-stop-hook" }, + "investigate-gate-pre": { source: "gstack-investigate-gate", event: "PreToolUse", matcher: "Edit|Write|MultiEdit|NotebookEdit|Bash", relpath: "hosts/claude/hooks/investigate-gate-pre" }, + "investigate-gate-post": { source: "gstack-investigate-gate", event: "PostToolUse", matcher: "Bash", relpath: "hosts/claude/hooks/investigate-gate-post" }, + "investigate-gate-post-failure": { source: "gstack-investigate-gate", event: "PostToolUseFailure", matcher: "Bash", relpath: "hosts/claude/hooks/investigate-gate-post-failure" }, + "investigate-gate-stop": { source: "gstack-investigate-gate", event: "Stop", matcher: "", relpath: "hosts/claude/hooks/investigate-gate-stop" } }; function gsHadBashPrefix(c) { return String(c == null ? "" : c).trim().indexOf("bash ") === 0; } function gsStripWrap(c) { @@ -488,8 +492,8 @@ case "$ACTION" in exit 1 fi case "$EVENT" in - SessionStart|PreToolUse|PostToolUse|UserPromptSubmit|Stop|Notification) ;; - *) echo "invalid --event '$EVENT'; must be one of SessionStart|PreToolUse|PostToolUse|UserPromptSubmit|Stop|Notification" >&2; exit 1 ;; + SessionStart|PreToolUse|PostToolUse|PostToolUseFailure|UserPromptSubmit|Stop|Notification) ;; + *) echo "invalid --event '$EVENT'; must be one of SessionStart|PreToolUse|PostToolUse|PostToolUseFailure|UserPromptSubmit|Stop|Notification" >&2; exit 1 ;; esac DIFF_ONLY="" ENSURE="" diff --git a/bin/gstack-skill-gates b/bin/gstack-skill-gates new file mode 100755 index 000000000..00df006d7 --- /dev/null +++ b/bin/gstack-skill-gates @@ -0,0 +1,104 @@ +#!/usr/bin/env -S bun --no-env-file --config=/dev/null +/** + * gstack-skill-gates — the opt-in enforcement hooks' helper (GST-1461). + * + * gstack-skill-gates settings --investigate + * Print a Claude Code settings JSON that registers the /investigate gate + * and turns its switch on, for \`claude -p --settings \` or the Agent + * SDK's settings, so a headless run needs no settings.json edit. + * gstack-skill-gates report [--session ] [--expect armed] [--json] + * Count fired, blocked, released, observed and error markers per session + * from $GSTACK_STATE_ROOT/skill-gates//record.json. + * + * Exit codes: 0 ok · 1 usage (SKILL_GATES_USAGE) · 2 GATE_NOT_ARMED (--expect + * armed and a session never fired) · 3 GATE_ERRORED (a session recorded a hook + * error). Docs: docs/skill-gates.md. + */ +import * as fs from 'fs'; +import * as path from 'path'; +import { gatesRoot, readGateRecord, recordPath, type GateHook, type GateRecord } from '../lib/skill-gates'; + +const ROOT = path.resolve(import.meta.dir, '..'); +const USAGE = 'Usage: gstack-skill-gates settings --investigate\n gstack-skill-gates report [--session ] [--expect armed] [--json]'; +const HOOKS: Record }> = { + '--investigate': { + env: 'GSTACK_INVESTIGATE_GATE', + source: 'investigate-gate', + events: [ + { event: 'PreToolUse', shim: 'investigate-gate-pre', matcher: 'Edit|Write|MultiEdit|NotebookEdit|Bash' }, + { event: 'PostToolUse', shim: 'investigate-gate-post', matcher: 'Bash' }, + { event: 'PostToolUseFailure', shim: 'investigate-gate-post-failure', matcher: 'Bash' }, + { event: 'Stop', shim: 'investigate-gate-stop' }, + ], + }, +}; + +function usage(why: string): never { + process.stderr.write(`SKILL_GATES_USAGE: ${why}\n${USAGE}\n`); + process.exit(1); +} + +const args = process.argv.slice(2); +if (args[0] === '--help' || args[0] === '-h') { process.stdout.write(`${USAGE}\n`); process.exit(0); } + +if (args[0] === 'settings') { + const picked = args.slice(1); + if (!picked.length || picked.some(a => !HOOKS[a])) usage(`settings takes ${Object.keys(HOOKS).join(' ')}`); + const settings: { env: Record; hooks: Record } = { env: {}, hooks: {} }; + for (const flag of picked) { + const h = HOOKS[flag]; + settings.env[h.env] = 'on'; + for (const e of h.events) { + (settings.hooks[e.event] ??= []).push({ ...(e.matcher ? { matcher: e.matcher } : {}), hooks: [{ type: 'command', command: path.join(ROOT, 'hosts', 'claude', 'hooks', e.shim), timeout: 10 }] }); + } + } + process.stdout.write(`${JSON.stringify(settings, null, 2)}\n`); + process.exit(0); +} + +if (args[0] !== 'report') usage('expected settings or report'); +const flag = (name: string) => { const i = args.indexOf(name); return i >= 0 ? (args[i + 1] ?? usage(`${name} needs a value`)) : undefined; }; +const session = flag('--session'); +const expect = flag('--expect'); +if (expect !== undefined && expect !== 'armed') usage('--expect takes armed'); +const asJson = args.includes('--json'); + +const records: GateRecord[] = []; +if (session) { + const file = recordPath(session); + if (fs.existsSync(file)) records.push(readGateRecord(file, session)); +} else { + let dirs: string[] = []; + try { dirs = fs.readdirSync(gatesRoot()); } catch { /* none yet */ } + for (const d of dirs.sort()) { + const file = path.join(gatesRoot(), d, 'record.json'); + if (fs.existsSync(file)) records.push(readGateRecord(file, d)); + } +} + +const rows = records.flatMap(({ session_id: session, hooks }) => Object.entries(hooks).map(([hook, h]) => { + const runs = Object.values(h!.runs); + return { + session, hook, runs: runs.length, fired: runs.length, + blocked: runs.reduce((n, r) => n + r.blocked, 0), released: runs.filter(r => r.released_at).length, + observed: runs.reduce((n, r) => n + r.observed, 0), errors: h!.errors.length, error_codes: [...new Set(h!.errors.map(e => e.code))], + skipped: Object.keys(h!.skipped ?? {}), + }; +})); + +if (asJson) process.stdout.write(`${JSON.stringify({ rows }, null, 2)}\n`); +else { + process.stdout.write(`skill gates: ${rows.length} session-hook row(s) under ${gatesRoot()}\n`); + for (const r of rows) { + process.stdout.write(` ${r.session} ${r.hook} fired=${r.fired} blocked=${r.blocked} released=${r.released} observed=${r.observed} errors=${r.errors}${r.error_codes.length ? ` (${r.error_codes.join(',')})` : ''}${r.skipped.length ? ` skipped=${r.skipped.join(',')}` : ''}\n`); + } +} + +if (rows.some(r => r.errors > 0)) { + process.stderr.write('GATE_ERRORED: a gate hook recorded an error; its stops and edits were allowed (docs/skill-gates.md#gate-errored)\n'); + process.exit(3); +} +if (expect === 'armed' && (session ? !rows.some(r => r.fired > 0) : rows.length === 0 || rows.some(r => r.fired === 0))) { + process.stderr.write('GATE_NOT_ARMED: no fired marker for the expected session; the hooks never engaged (docs/skill-gates.md#gate-not-armed)\n'); + process.exit(2); +} diff --git a/bin/gstack-skill-route b/bin/gstack-skill-route index c0273bb4d..cce63f63a 100755 --- a/bin/gstack-skill-route +++ b/bin/gstack-skill-route @@ -4,7 +4,7 @@ * * Usage: * gstack-skill-route --explain "" [--json] [--threshold ] - * gstack-skill-route --default + * gstack-skill-route --default * * 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 @@ -16,13 +16,13 @@ import { ROUTE_THRESHOLD, SWITCH_DEFAULT, hintLine, installedSkillsDir, readSkillSwitch, routePrompt, scanInstalledSkills, } from '../lib/skill-route'; -const USAGE = 'Usage: gstack-skill-route --explain "" [--json] [--threshold ]\n gstack-skill-route --default '; +const USAGE = 'Usage: gstack-skill-route --explain "" [--json] [--threshold ]\n gstack-skill-route --default '; 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.stderr.write(`SKILL_ROUTE_USAGE: --default needs skill_routing, skill_contracts or investigate_gate\n${USAGE}\n`); process.exit(2); } process.stdout.write(`${SWITCH_DEFAULT[name as keyof typeof SWITCH_DEFAULT]}\n`); diff --git a/changelog.d/capy-gst-1461-investigate-gate.md b/changelog.d/capy-gst-1461-investigate-gate.md new file mode 100644 index 000000000..aed7d00ed --- /dev/null +++ b/changelog.d/capy-gst-1461-investigate-gate.md @@ -0,0 +1,4 @@ +--- +bump: minor +--- +**An opt-in /investigate gate that holds "no fix before a root cause", through Bash too (GST-1461).** On Opus 5.5 the skill text alone gets that step followed 0 of 46 times, and GST-938's Edit/Write-only gate held in just 13 of 28 sessions because Opus edits through the shell. With `./setup --investigate-gate` (or `GSTACK_INVESTIGATE_GATE=on`, or `gstack-config set investigate_gate on`), a session that loaded /investigate can't change tracked source until it writes a `Root cause: ` line: Edit, Write, MultiEdit and NotebookEdit are denied, and any Bash command that changes a tracked source file (`sed -i`, redirects, `tee`, `python`, `perl`, `node`, `git apply`, a commit) is caught by comparing the tree with a baseline, put back and reported. Tests, docs, lockfiles and new untracked files are never blocked. Every decision is a `GSTACK_GATE` marker (`fired`, `blocked`, `released`, `observed`, `skipped`, `error`) counted in `$GSTACK_STATE_ROOT/skill-gates//record.json`, traced in `hooks/investigate-gate.log` and recorded in the transcript; a hook error fails open with an `error code=…` marker. `gstack-skill-gates settings --investigate` prints a `--settings` JSON for headless `claude -p` runs and `gstack-skill-gates report --expect armed` checks a session engaged. Off by default; the /investigate skill text is unchanged. Docs: docs/skill-gates.md. diff --git a/docs/skill-gates.md b/docs/skill-gates.md new file mode 100644 index 000000000..880062495 --- /dev/null +++ b/docs/skill-gates.md @@ -0,0 +1,102 @@ +# Skill gates: opt-in enforcement hooks + +gstack's advisory skills say what a careful agent does; a gate makes one rule hold. Each gate is a Claude Code hook that is off unless you turn it on, never changes the skill's text, and leaves a countable marker for every decision it makes (GST-1461). They follow the contract hook in [skill-routing-and-contracts.md](skill-routing-and-contracts.md): the same switch order, the same per-session record, the same fail-open rule. + +| Gate | Switch | What it holds | +|---|---|---| +| /investigate | `investigate_gate` | After /investigate loads, changes to tracked source wait until the session states a root cause | + +## Turn a gate on + +The switch resolves like the contract hook's, and the first one set wins: + +1. The `./setup` flag: `--investigate-gate` / `--no-investigate-gate` (also `--investigate-gate=on|off`). A flag is saved to config. +2. The environment: `GSTACK_INVESTIGATE_GATE=on|off`. Not saved. +3. Config: `gstack-config set investigate_gate on|off`. +4. The default, `SWITCH_DEFAULT` in `lib/skill-route.ts`: off. + +Setup registers the gate's four shims in `~/.claude/settings.json` when the switch is on and removes them when it is off. The hooks also read the switch on every call, so `gstack-config set investigate_gate off` takes effect on the next tool call. + +### Headless runs + +For `claude -p` or an eval harness, skip settings.json: + + gstack-skill-gates settings --investigate > gates.json + claude -p "" --settings gates.json + +The JSON registers the hooks from this checkout and sets `GSTACK_INVESTIGATE_GATE=on` through its `env` block. Under the Agent SDK, pass the same JSON as settings or include `user` in `settingSources` after running setup; with neither, or with `--bare`, no hook loads and no `fired` marker appears. Check every session with `gstack-skill-gates report --session --expect armed` ([exit codes](#exit-codes)). + +## The /investigate gate + +Once a session loads /investigate (the Skill tool, a typed `/investigate`, or a Read of its installed SKILL.md), the gate arms on the next Edit, Write, MultiEdit, NotebookEdit or Bash call. From then until the session states a root cause: + +- **Edit, Write, MultiEdit and NotebookEdit** on a tracked source file are denied before they run. +- **Bash** can't be judged from its command line (`sed -i`, a redirect, `tee`, `python -c`, `perl -pi`, `node -e`, `git apply`, a heredoc, a build step that rewrites a file). So the gate compares the tree instead. When it arms it pins a baseline: the repository root, HEAD, its refs and stash list, and the content of every tracked file that differs from HEAD. Every later Edit/Write/Bash hook call, every PostToolUse and PostToolUseFailure on Bash, and Stop compare the tree with that baseline under a session lock. A tracked source file whose content changed is put back and the call or stop is blocked with the files named. Because every check uses the same baseline, a write from a background command or a parallel call is caught at the next check whichever call made it. + +A change is not a fix write, and is folded into the baseline, when the file's new content is its blob in HEAD (`git checkout -- f`, `git stash`), in a commit that existed before the baseline (`git checkout `, `git bisect`), in a commit a remote-tracking ref holds (`git pull`, `git merge origin/main`), or in a stash entry that existed before (`git stash pop`). A commit made after the baseline that touches a gated file is undone with `git reset --soft` to its newest ancestor that existed before (or that a remote-tracking ref holds), so a fix committed on another branch moves that branch back to its own tip. An amend or rebase of commits from before the baseline on the same branch is undone to the baseline HEAD; on a different branch the gate blocks but leaves the commit in place rather than move that branch. The commit stays in the reflog, those index entries are reset, the files are put back, and the block names the commit's other changes still staged. A commit pushed to a remote counts as upstream, so a fix pushed in the same command is not undone. + +**What counts as source.** A file tracked by git that is not a test path (gstack-diff-scope's test rule plus `fixtures/`), a doc (`.md`, `.mdx`, `.rst`, `.txt`, `.adoc`), a lockfile (`bun.lock`, `package-lock.json`, `yarn.lock`, `pnpm-lock.yaml`, `Cargo.lock`, `go.sum`, `uv.lock`, `poetry.lock`, `Gemfile.lock` and the like), a `linguist-generated` path, or a tracked symlink. New untracked files are never blocked, so a repro script, a new failing test and temporary logging in a scratch file all work. Changes to excluded files are recorded as `observed`, never reverted. + +**What releases it.** A line in the assistant's own reply, after at least one Read, Grep, Glob or Bash call since the load: + + Root cause: + +That is the `Root cause:` label of the skill's DEBUG REPORT. A placeholder (`[what was actually wrong]`, `TBD`) or a hedge (`unknown`, `unclear`, `still investigating`, `see below`, `n/a`) does not count, and neither does the text inside a tool result or a file. The block text names the form, and the rejected value when there was one. After the release the gate does nothing for the rest of that /investigate run; a new load arms it again. + +**Restoring is careful.** The gate never writes through a symlink: it refuses a parent that is not a real directory inside the repository and replaces the path with a rename, so a tracked file swapped for a link to `~/.bashrc` comes back as the file and the link's target is untouched. Files clean at the baseline come back through `git cat-file --filters` (line endings and LFS smudging intact); files dirty at the baseline come back from a raw copy in the session directory. Before a file is put back, the content it replaced is copied into the session directory and the block names that copy, so an edit made in an editor or another session at the same time is never lost. A path that cannot be put back (it is now a directory, the disk is read-only) gives [GATE_RESTORE_FAILED](#gate-restore-failed). + +**Known gaps.** Paths marked assume-unchanged or skip-worktree, untracked files that shadow a tracked module, and ignored directories are outside the check. While the gate is armed it also puts back an edit you make to tracked source in your editor, since it cannot tell who changed the file. + +## Markers + +Every decision is one marker line: + + GSTACK_GATE session= [key=value ...] + +| Event | When | Fields | +|---|---|---| +| `fired` | the gate armed for a skill run (once per run) | `skill`, `on` (the hook event), `root` (the repository folder name) | +| `blocked` | each refusal | `skill`, `via=edit|bash`, `files`, `n` (blocks so far in this run) | +| `released` | the root cause was stated (once per run) | `skill`, `cause` (first 12 hex of the cause's sha256), `blocked` | +| `observed` | an excluded file changed | `skill`, `files` | +| `skipped` | the gate could not arm (once per session and reason) | `skill`, `reason=no-git|no-head` | +| `error` | a hook error; the call or stop went through | `code`, `why` | + +Where each one lands: + +- **Session record** (the count source): `$GSTACK_STATE_ROOT/skill-gates//record.json`, mode 0600, written atomically under the session lock. Per gate and skill run it holds `fired_at`, `released_at`, `cause`, `blocked`, `observed`, plus the gate's `errors` and `skipped` reasons. `gstack-skill-gates report` reads these. +- **Trace log**: `$GSTACK_STATE_ROOT/hooks/investigate-gate.log`, one line per marker in the contract hook's shape (`