Files
hooks/skills/hooks-install/SKILL.md
T
jiantw83andClaude Opus 5 7f6e0d9299 Merge develop
三份 manifest 取 develop 的版號再往上升一版:分支停在一個比 develop 舊的版號。

行為清單那一節四列兩邊都改過,但改的是不同層面:分支改「目錄頁怎麼寫」——索引
從表格列變成一個報告一個大標題區塊、寫入語意委派給共用工具;develop 改「根目錄
怎麼解」——前置步驟解出兩條字面絕對路徑再拿去叫工具。逐列用差異區間比對過,
兩邊在共同基底上動到的位置完全不重疊,所以照基底的座標把兩組改動一起套回去,
不是取其中一邊。

技能本文那一段同理:分支把整段改寫成區塊語意,develop 只在指令路徑前面補了
根目錄前綴。取分支那一版再補上前綴。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 09:10:57 +08:00

32 KiB
Raw Blame History

name, description
name description
hooks-install Wire jsc hooks (STE100 guard, session timer, skill usage logger, SDLC model gate, plugin version guard, post-deploy restart gate, comment scope scanner, language guard, write and commit guard) into every installed AI CLI, purging all pre-existing hooks first — third-party ones included, backed up before removal. Drive it per CLI through tools/wire-cli.sh purge, tools/wire-cli.sh, tools/wire-cli.sh status, tools/wire-cli.sh smoke and tools/scan-hook-errors.sh. Hand any hook error, wiring or runtime, to jsc-hooks:repair, which must finish with a PR against develop; aborting the rest of the install to start that repair is allowed. Use after installing or updating the jsc plugin set; not for writing new hooks.

hooks-install — wire jsc hooks into every installed CLI

Goal: make the nine hooks (ste100-guard.sh, session-timer.sh, skill-usage.sh, sdlc-gate.sh, version-guard.sh, restart-gate.sh, comment-scope.sh, lang-guard.sh, write-guard.sh) effective in every CLI, with nothing else wired alongside them.

Path rule

Every script call in this skill is written as a literal absolute path. A path that still carries $JSC_HOME, any other unexpanded variable, or a ~ cannot be resolved statically by the permission layer, so it is treated as unknown and always asks for approval. An unattended round has nobody to approve, so it stops at the first script and the whole install never starts.

Measured on this machine: $JSC_HOME/current/jsc-assist/tools/patrol.sh and ~/.jsc/current/... were both blocked and the command never ran; the same script at /root/.jsc/current/... ran. Adding an allow rule that itself starts with $JSC_HOME changed nothing on a retest, because the rule is matched against the expanded command — widening the permission list is not the fix.

Do not trade this back for portability. A variable-form path in this file buys no portability; it buys a round that dies before its first stage. Portability lives in the prerequisite below, which resolves the roots once, on the machine, at run time.

Prerequisite — resolve the roots once

Run these two before any other call in this skill, and only here:

  1. readlink -f "$JSC_HOME/current" prints the link farm as a literal absolute path. Call it {JSC_ROOT}. Every cross-domain call is written {JSC_ROOT}/jsc-{domain}/... with that path substituted in.
  2. readlink -f "$JSC_HOME/current/jsc-hooks" prints the physical root behind the jsc-hooks link. Call it {HOOKS_ROOT}. tools/wire-cli.sh is called from there and from nowhere else. That script rewrites the very {JSC_ROOT}/jsc-hooks link it would be running through and derives its own root from $0, so running it through the link makes ln -sfn point that link at itself. The loop takes every CLI's hooks down at once, and it has happened.

Both results are checked before anything else runs: [ -d "{JSC_ROOT}" ] and [ -d "{HOOKS_ROOT}" ], each in the same approved step as its own readlink. A readlink that printed something is not a readlink that found something. With JSC_HOME unset the first call prints /current and exits 0 — non-empty, absolute, and wrong — and every literal path built from it then names a place that is not there; the second call has the same hole one level down. An empty result, a non-zero exit, a path that is not absolute, or a directory that does not exist stops the skill here: report which of the two roots did not resolve and what the command printed, say jsc-cli:deploy has to run to restore current and its jsc-hooks link, and wire nothing. Never guess a root, never fall back to a versioned plugin cache path, and never create either root here — a run that pushes on wires every CLI to scripts that are not there, and purge has already removed the hooks that worked.

Resolve both once, here. Do not re-resolve per call, and do not add a tool that prints these paths — two readlink runs and their two checks are the whole step. The main agent resolves them and hands both literal paths to every sub agent it starts, so a sub agent never resolves anything itself. Done when you hold two literal absolute paths, both naming directories that exist, and every later call starts with one of them.

Wiring

Install on a clean slate. Every CLI is purged of all hooks first, third-party ones included, so a later failure has exactly one owner. tools/wire-cli.sh purge backs up every file it touches before it removes anything, so the removal stays reversible.

The wiring commands stored in user config use {JSC_ROOT}/jsc-hooks, not the versioned plugin cache path and not the development checkout. wire-cli.sh expands that root itself, so what lands in each CLI's config is already a literal absolute path. {HOOKS_ROOT}/tools/wire-cli.sh {cli} creates or refreshes that symlink before it writes notify, shell aliases or Kiro hook JSON, then verifies the linked scripts exist. If the filesystem cannot create the symlink, the script must say so and explicitly fall back to the current root; it must never write a silent broken path. The bundled hooks/hooks.json follows the same rule with one deliberate exception: use ${CLAUDE_PLUGIN_ROOT} only where the host provides it, and fall back to the manifest's own text, ${JSC_HOME:-$HOME/.jsc}/current/jsc-hooks, for any other CLI reading the same manifest, so an unset Claude-only variable never expands into /hooks/.... That one stays in variable form on purpose: it is file content shipped with the repo, expanded by the hook's own shell on whatever machine reads it, and it never passes through the permission layer. It is not a path anyone types at a prompt, so the path rule above does not reach it.

Four of the five CLIs have a pre-tool hook that can block. The version guard and the restart gate reach codex, copilot and antigravity too — each at its own wiring point, with its own matcher and its own blocking shape. Never say a CLI "has no pre-tool hook"; that claim is wrong and it is what left three CLIs unguarded.

CLI Wiring point Event and matcher Blocking shape Verdict
claude hooks/hooks.json PreToolUse, matcher Skill stderr plus exit 2 wired
codex hooks/codex-hooks.json, pointed at by the hooks path string in .codex-plugin/plugin.json PreToolUse, matcher Bash stderr plus exit 2 wired
copilot hooks key of ~/.copilot/settings.json, merged in place PreToolUse, matcher skill stderr plus exit 2 wired
antigravity jsc block of ~/.gemini/config/hooks.json PreToolUse, matcher ^view_file$ (Grouped), plus PreInvocation (Flat) {"decision":"deny",...} on stdout wired
kiro hooks key of ~/.kiro/agents/jsc.json, plus chat.defaultAgent=jsc agentSpawn, userPromptSubmit, stop warning injected on stdout; blocks nothing degraded

On the four non-claude CLIs every wired command carries its own CLI code as a JSC_CLI={code} prefix. A gate has to know which CLI it is running under before it can read a skill name out of skill-name.sh or a blocking shape out of deny.sh; with no code the skill name resolves to nothing and both gates pass in silence — config correct, matcher correct, blocks never. On antigravity it is worse: an unknown code makes deny.sh fall back to the exit-code shape, which that CLI ignores, so the gate decides to block and the CLI never hears it. The jsc-wrap.sh alias exports JSC_CLI as well, but only when the user starts the CLI through the alias from an interactive shell, so wiring never leans on it. wire-cli.sh asserts the prefix per CLI at wiring time, at status time and in smoke.

Only claude reaches all nine hooks. On codex, copilot and antigravity the SDLC gate still degrades to the skill-step check, the three write and commit guard modes are still unwired, and the comment and language scans still run as sweep — say exactly that, in the words the script prints, instead of implying full coverage.

kiro is degraded because the CLI cannot block a skill call, not because the wiring is short of anything. A skill there is a ResolveSkill request inside the agent, off the tool pipeline, so preToolUse never sees it and a non-zero exit from userPromptSubmit does not stop the turn. Injecting a warning on stdout is the only intervention left. Its hook declarations live in the agent config's hooks key — .kiro/hooks/ is not in kiro's config-directory constants and is never read — and that same agent file needs the two-level skill:// glob in resources (the default glob scans one level, jsc skills sit at jsc-{domain}/{name}/SKILL.md) plus an explicit tools list, with chat.defaultAgent set to jsc so the agent is chosen at all.

Shape is part of the wiring, and a wrong shape fails silently. Two rules are read straight out of the CLIs' own embedded specs and asserted by wire-cli.sh. Antigravity splits its events: PreToolUse and PostToolUse are Grouped — handlers wrapped in a matcher plus hooks group — while PreInvocation, PostInvocation and Stop are Flat. A Flat PreToolUse is discarded whole: the hook name still registers, no actions key is even generated, nothing errors, and the file reads as correct. Codex's plugin manifest takes hooks as a path string, exactly like skills; an inline object does not parse. That path is an override, so hooks/codex-hooks.json is derived from hooks/hooks.json — copied whole, with "matcher": "Skill" rewritten to "matcher": "Bash" — which keeps every other event and keeps hooks/hooks.json the single source of truth. Never hand-write the second file.

Copilot keeps hook config in the hooks key of settings.json — inline definitions keyed by event name. $COPILOT_HOME/hooks/ holds the scripts a hook runs, not the config; a config file written there sits on disk, correct and unread. That same settings.json also carries enabledPlugins and extraKnownMarketplaces, so wiring merges and never overwrites: back up first, touch only jsc's own entries under hooks, then read back and compare the top-level keys and every foreign hook entry against what was there before, restoring the backup if either moved. purge takes out only jsc's entries and leaves the third-party SessionStart alone.

Kiro declares hooks in the agent config's hooks key, and its only legal events are agentSpawn, userPromptSubmit, preToolUse, postToolUse and stop; the fields are command (required), matcher and timeout_ms — there is no on, run or env. The old wiring put on/run/env at the top level, kiro-cli agent validate reported nothing at all, and the file did nothing: unknown top-level keys are ignored in silence. A valid file is not a wired file, so check the shape separately.

kiro-cli agent validate always exits 0. Valid, illegal event name, on/run inside hooks, missing command — all four exit 0, and the errors only appear in the output. Reading the exit code builds a check that can never fail, which is the same class of bug as the ones being fixed here. Judge by the output: empty means valid. Output about something else — not logged in, expired credentials — means the check could not run, not that the file is bad; pass it and say so, because failing there would block wiring on every machine that is not logged in.

Verification level, per CLI. "Shape" means the CLI really parses the config; "firing" means a hook really ran. Keep them apart and report them as this table has them:

CLI Shape Firing
claude proven proven
codex proven — [hooks.state] in ~/.codex/config.toml records {event}:{group}:{entry}, a two-level index that only a Grouped structure produces; the manifest's hooks is a path string per the binary's own plugin-json-spec.md unverified
antigravity proven — after wiring, agy -p "/hooks" lists all four entries with matcher=^view_file$, third-party block intact unverified (conversation quota exhausted)
copilot unproven — settings.json has no read-only listing path; the location and entry form come from copilot help config and the working third-party entry already on the machine unverified
kiro proven — kiro-cli agent validate passes with empty output, and a counter-check confirms it discriminates: sessionStart, on/run inside hooks, and a missing command each produce an error partly proven — agentSpawn and userPromptSubmit were observed firing; preToolUse and stop are unverified (model quota)

Two more open items: whether kiro's two-level resources glob actually fixes skill visibility is unverified, and on the four non-claude CLIs the three write-guard.sh modes and the SDLC model lock are still unwired — not yet done, rather than failing. wire-cli.sh smoke asserts wiring content and script logic line by line; firing is outside its reach.

comment-scope.sh and lang-guard.sh both reach all five, wired at the same set of places, but on a different event and at a different moment each. Report the timing per CLI; never state it as one uniform behaviour:

CLI Scanning moment Wired through
claude Per file, the instant it is written PostToolUse
codex End of every turn, over the whole git worktree notify in config.toml
kiro On every prompt submit, over the whole git worktree — it sees what the previous turn wrote userPromptSubmit in ~/.kiro/agents/jsc.json
copilot, antigravity Once, when the session ends tools/jsc-wrap.sh teardown

The table above holds for both scanners. The sweep mode reads git diff HEAD, so its coverage matches what claude sees; only the feedback delay differs. Outside a git worktree sweep exits 0 in silence and nothing is scanned at all — say so when the user works outside git. Both prompt rule reminders still go into every rule file alongside the STE100 block, because a warning that arrives a turn late is worth less than not writing the offending text in the first place. The lock file still works on those four because the SDLC skills call sdlc-gate.sh lock {stage} directly — that call is where the capability-tag comparison happens, so the gate keeps its force even where the prompt hook cannot be wired. The gate needs $JSC_HOME/model-tags.tsv; when it is missing, report that jsc-cli:models (or jsc-cli/tools/model-tags.sh sync) must run once, because sdlc-gate.sh lock refuses to lock without it.

Treat any hook error as repair work, whether it appeared while wiring or while running. Stopping the remaining installs to start that repair is the right call; leaving a broken hook wired is not.

The detailed flow MUST run as a sub agent; the main agent only reports the summary.

Steps

  1. Take the CLI list from the caller when it hands one over — jsc-cli:deploy passes the list it already detected, and probing the same five executables a second time buys nothing. Run {JSC_ROOT}/jsc-cli/tools/detect-clis.sh yourself only when no list came in; that fallback is what keeps this skill usable when it is called on its own. The script always exits 0 and prints one name<TAB>path<TAB>version line per installed CLI. Done when you hold that list and have said which of the two ways produced it; when it is empty, report that no CLI was detected and stop.

  2. Run the five-stage pipeline purge → wire → status → smoke → scan once per detected CLI. Run the first CLI's pipeline on its own, because {HOOKS_ROOT}/tools/wire-cli.sh {cli} is what refreshes the shared {JSC_ROOT}/jsc-hooks link and two CLIs must not rewrite it at the same time; once that first pipeline has finished, run every remaining CLI's pipeline in parallel, one sub agent per CLI — the five stages of one CLI stay in this order, but different CLIs touch different config files and share nothing else. Every stage prints its verdict on its first line, so read that line and never infer the outcome from the prose below it.

    1. {HOOKS_ROOT}/tools/wire-cli.sh purge {cli} — backs up every file it touches, removes all hooks, re-reads each file to confirm the removal, and restores the backup by itself when a check fails. Marker matching trims leading and trailing whitespace, so an indented or padded marker block is still removed as the same jsc-owned block. Exit 0 is purged, exit 3 is skipped (that CLI's executable is not on this machine, so skip its remaining stages too), exit 4 is failed and goes to step 3. Exit 2 is a bad CLI name, not a purge outcome — fix the name and rerun the stage.
    2. {HOOKS_ROOT}/tools/wire-cli.sh {cli} — owns both the wiring and its verification: it refreshes the link, writes the config, alias or hook file inside a <!-- jsc-hooks --> (or # jsc-hooks) marker block, re-reads every file it wrote, confirms the block is present and correctly placed, and confirms the stored runtime paths resolve to existing scripts before it prints a success status. Exit 0 is wired and is the expected result on claude, codex, copilot and antigravity; exit 1 is degraded and is expected on kiro alone; exit 3 is skipped, exit 4 is failed and goes to step 3. Exit 2 is a bad CLI name — fix the name and rerun. The matcher is verified on its own, not just the presence of a key: a key that is there with the wrong matcher reports as wired and fires never.
    3. {HOOKS_ROOT}/tools/wire-cli.sh status {cli} — the read-only inventory of what the previous stage wrote. It writes nothing and runs no hook, so it is safe to run right after wiring. Exit 0 is wired (claude, codex, copilot, antigravity), exit 1 is degraded (kiro), exit 3 is skipped, exit 5 is unwired, which names every missing item and means the wiring stage has to run again before you continue. Exit 2 is a bad CLI name. For codex this stage is the only one that reads the installed jsc-hooks manifest in the Codex plugin cache and reports a stale UserPromptSubmit command there, the one that expands ${CLAUDE_PLUGIN_ROOT} into /hooks/...; carry that item into the report.
    4. {HOOKS_ROOT}/tools/wire-cli.sh smoke {cli} — runs every wired mode of all nine hooks once, plus each decision path of the work-package check, of the restart gate and of the write and commit guard. It catches what the wiring check cannot see: a hook that is wired correctly and still fails when it executes. It also runs each CLI's real payload through skill-name.sh, each blocking shape through deny.sh, and those same payloads straight through restart-gate.sh end to end, so a break anywhere along parse, decide and emit is caught — the two ends look healthy on their own while the middle silently passes everything through, which is exactly how three CLIs went unguarded. It prints its own result-line count as lines<TAB>{count} and asserts that count against what it expected to run, so read the number from that line and never restate a number of your own. Exit 0 is ok, exit 4 is failed — either a hook errored or the line count did not match, and both go to step 3. Exit 2 is a bad CLI name.
    5. {JSC_ROOT}/jsc-hooks/tools/scan-hook-errors.sh --cli {cli} — only claude keeps hook results in its native records and can answer clean or errors; codex, copilot, antigravity and kiro answer unavailable, and their runtime evidence comes from the smoke stage alone. Exit 0 covers both clean and unavailable, exit 1 is errors and every entry with jsc=true goes to step 3, exit 2 is a bad CLI name.

    Done when every detected CLI has exactly one verdict line per stage, no stage exited 2, the smoke stage's lines count matches its own assertion, the four non-claude CLIs are reported as unavailable rather than clean on the scan stage, and antigravity and kiro carry the note that their hook firing is unverified.

  3. For each error — a failed purge, a failed wiring, an unwired status, a failed smoke, or a scanned error with jsc=true — run {JSC_ROOT}/jsc-hooks/tools/report-error.sh --hook {script name} --exit {code} --summary "{reason}" --cli {cli} with the script's [jsc] output on stdin, then hand the failure to jsc-hooks:repair, which MUST run as a sub agent and must finish by opening a PR against develop. Aborting the remaining installs here is allowed as long as the repair starts. The error page and the error directory page live in two different wiki repos, resolved separately: the page through wiki-repo ERROR in report-error.sh itself, the directory through wiki-repo CONTENTS inside wiki-contents.sh. Exit 0 with an ERROR_{HASH} page name and URL on stdout means the page was written; the same exit 0 with a [jsc] line on stderr still means the page landed, and that line says what is missing — the directory repo would not resolve (or wiki-contents.sh is not on disk), so nothing indexes the page; the page URL could not be read back, so the page name comes out on its own; or the URL failed the reachability check, so the directory entry's page-name bullet carries the page name as plain text with no link — carry that note into step 4. One error report is one H2 block on the directory page: the heading is that report's own page name, ERROR_{HASH}, and the fields sit under it as one bullet each — time, page name, repo, hook, exit code, summary, written - {field}:{value} — with no markdown table anywhere on the page. Every link inside that block is written as [{text}]({url}) with the URL from gitea.sh wiki-url, and the script checks it with jsc-gitea/tools/link-check.sh before writing: exit 0 writes the link, anything else keeps the bullet and drops the link, and none of it changes the exit code — this is the failure-reporting path, so a failed report must never become a second failure. Exit 0 with no output at all means the run ended on one of the quiet-degradation reasons listed in the script's own header — no gitea.sh on the path, the error page's wiki repo unresolved, the hash not computed, or a temp file not created — so no page was written at all and that reason goes into step 4 instead; exit 2 means the call itself was malformed — --hook or --summary is missing — so fix the arguments and rerun the same call; exit 4 means the wiki record did not land, so report the failure text and still start the repair — a page that could not be written is no reason to leave a broken hook wired. Exit 4 covers two cases, and the report has to say which: a failed write, or the directory page left untouched because the old one could not be read back. The directory page itself is not assembled here: report-error.sh builds only its own block and hands it to jsc-gitea/tools/wiki-contents.sh upsert ERROR 2 {page name} {block file} {template}, so the read, the key match and the whole-page write have exactly one owner. That page is upserted, never overwritten: every block on it is somebody else's error report, so the tool reads the page, replaces the block whose heading equals this page name or appends a new block at the end, and writes the page back. Only a genuine 404 (wiki-get exit 4) means the page is not there yet and lets it build one from the template. An invalid key (exit 7) or any other API failure (exit 8) leaves the old blocks unknown, so it skips the directory write and names the code instead — writing a fresh template over a directory it never read would erase every earlier report, with no merge and no backup behind it. A scanned error with jsc=false belongs to a third-party hook: report it and leave it alone. Skip this step when every CLI passed all five stages. Done when every error carries one ERROR_{HASH} result — a page name with its URL, a page name plus the reason the URL is missing, or the recorded reason no page was written — and one repair PR URL against develop.

  4. Report five results per CLI — purge, wiring, status, smoke, scan — each with the reason its script printed, plus the smoke lines count, any ERROR_{HASH} page name and every repair PR URL. Done when every detected CLI appears with one verdict per stage and every repair has a PR against develop.

Notes

  • Every hook script accepts both stdin JSON and environment variables (JSC_CLI, JSC_SESSION_ID, JSC_SKILL, JSC_TOOL_NAME, JSC_TOOL_COMMAND, JSC_MODEL); jsc-wrap.sh sets the first two itself.
  • session-timer.sh takes start (keep an existing start time), restart (always overwrite it, for a CLI with no session id — kiro), mark and report. wire-cli.sh picks the right one per CLI; do not hand-edit the generated hook files. start and restart also clear the restart gate whenever they decide this SessionStart is a new session, so the wiring of those two events is what lowers the gate after a restart — a CLI wired without them keeps the gate up until the user sets JSC_RESTART_GATE=off.
  • hooks/skill-name.sh is the one place that turns a CLI's hook payload into {domain}<TAB>{skill}, one subcommand per CLI: claude reads the skill field, codex reads the SKILL.md path inside tool_input.command (it has no Skill tool — the model loads a skill by reading the file with Bash), copilot reads toolArgs and has to unwrap one layer of stringified JSON, antigravity reads toolCall.args.AbsolutePath and also the prompt text (a slash command injects the whole SKILL.md and produces no tool call), kiro reads the leading slash command in prompt. All five honour JSC_SKILL and SKILL first. It always exits 0: the gates fail open, and copilot's command hooks are fail-closed, where any non-zero exit means deny. hooks/deny.sh is the matching single source for the blocking shape — stderr plus exit 2 for claude, codex and copilot; a single-line {"decision":"deny","reason":"..."} on stdout with a fixed exit 0 for antigravity, whose exit-code semantics are undocumented and must never be relied on; a printed warning and exit 0 for kiro, which cannot block. Neither guard keeps a second copy of either rule; a repair goes into these two files.
  • restart-gate.sh blocks jsc skill calls while $JSC_HOME/restart-required.d/{cli} exists — one file per CLI, named after the CLI code — so a freshly deployed skill set is not used by a process still running the old one. Each CLI reads only its own file: another CLI's file never blocks this one, and a restart clears only the file of the CLI that restarted. jsc-cli:deploy writes the current CLI's file through restart-gate.sh require {install|update} [{domain}...] at the end of an install or update; restart-gate.sh report prints one line per file, so it is visible which CLIs still owe a restart. A leftover old-format single file at $JSC_HOME/restart-required blocks every CLI and is deleted on the next clear — transitional only, and hooks/restart-gate.sh records when it can be dropped. The gate matches skill names, not call chains, so a nested call to anything off the exemption list is blocked all the same; hooks/restart-gate.sh owns that list with a reason per entry, and jsc-meta/references/guidelines.md「部署後重啟閘門」carries the same list. Escape hatch: JSC_RESTART_GATE=off.
  • write-guard.sh takes three blocking modes, wired on two PreToolUse matchers on claude only, so claude is still the only CLI where any of it takes effect — codex, copilot and antigravity now have a usable pre-tool hook, but these three modes are not wired there yet; say that, rather than blaming a missing hook, plus a fourth mode, release, that is wired nowhere and is called by a skill itself. stage reads the stage lock that sdlc-gate.sh already owns and blocks Write, Edit and MultiEdit while plan or analyze holds it, because those two stages produce wiki pages rather than files. review reads the current skill — the environment variable first, then the record skill-usage.sh keeps — and blocks writes while jsc-review:code-review or jsc-review:api-doc runs, since both only report findings. It deliberately does not block jsc-review:comment-cleanup: that skill has to write, limited to comment lines, and deciding that limit needs per-language comment parsing of the whole proposed content, which would block legitimate cleanups more often than it caught bad ones — that boundary stays with the skill text and the later review. commit is wired on Bash and blocks a single command that stages everything and commits in one go, plus any commit message carrying simplified characters or mojibake, which it decides by calling lang-guard.sh rather than keeping a second word list. A git add -A split across two separate tool calls is not caught, on purpose: catching it needs cross-call state that the blocked operator has no way to clear. release deletes that recorded skill and always exits 0; jsc-review:code-review and jsc-review:api-doc call it once each as they hand their findings back. It exists because the record says which skill was loaded last, not which one is still running: both audit skills end by leaving the fixing to their caller, and without release every write that caller makes stays blocked for the whole TTL, with the escape hatch or a wait as the only way out — a gate must never lock away its own release. Escape hatch: JSC_WRITE_GUARD=off, which release ignores because clearing a record blocks nobody, plus JSC_WRITE_GUARD_TTL for how long a recorded skill counts as still running.
  • purge reaches the user-level config only. Hooks that another plugin ships in its own hooks.json stay active, and uninstalling that plugin is the only way to clear them — say so when reporting, and treat their errors as third-party.
  • Backups land in $JSC_HOME/backup/hooks/{cli}/{yyyyMMdd_HHmmss}/, one directory per purge run, under the original file names. Hand that path to the user whenever a purge removed something.
  • status claude reads Claude Code's installed_plugins.json and checks the installPath that the CLI actually loads. It must not check only the hooks.json next to the wire-cli.sh that happens to be running, because a development checkout can otherwise hide a broken installed plugin.
  • Never set JSC_READONLY=1 for this skill. wire-cli.sh refuses purge and wiring with exit 6 under that variable, which is exactly what a health check wants and exactly what an install must not have.
  • smoke treats sdlc-gate.sh check exit 2 as healthy: that exit is the stage lock blocking a turn on purpose, not a runtime error. comment-scope.sh, lang-guard.sh and write-guard.sh exit 2 count as healthy for the same reason — the check found something and said so. Their no-argument mode has no file name during smoke and exits 0 in silence; sweep depends on the worktree it runs in, so it answers 2 whenever that worktree happens to carry an offending comment, a simplified character or a mojibake sequence, and write-guard.sh answers 2 whenever the machine happens to hold a plan stage lock or a recent audit skill. None of these is a broken hook.
  • comment-scope.sh takes three modes: prompt (inject the rule summary at UserPromptSubmit), no argument at all (scan the file just written at PostToolUse, reading file_path from stdin JSON or JSC_CHANGED_FILE), and sweep [dir] (scan every file the git worktree changed, for the four CLIs with no post-tool hook). All scanning modes read only the lines a diff added, skip markdown and binary files, and turn off entirely with JSC_COMMENT_SCOPE=off. The rule text itself lives in one place only, jsc-review's references/comment-scope.md; never restate the list anywhere in this repo.
  • lang-guard.sh takes the same three modes as comment-scope.sh and is wired at the same places, but it scans differently on purpose: it reads the whole file rather than comment lines only, and it does scan .md and plain-text files, because those are exactly the non-code output the rule targets. It flags three things — simplified characters (word list in hooks/simplified.txt, the single source of truth for this repo; a missing list skips that check in silence), mojibake (U+FFFD and double-encoding remnants), and non-UTF-8 encoding (decided by iconv; no iconv skips that check). It skips binaries, generated files, and the three files whose subject is those very characters (simplified.txt, ste100-guard.sh, lang-guard.sh). Turn it off with JSC_LANG_GUARD=off. The rule text lives only in jsc-meta's references/ste100.md.
  • jsc-wrap.sh runs both sweeps after the CLI exits and always returns the CLI's own exit code. A sweep hit warns on stderr and changes nothing else — never let a language or comment warning turn a successful CLI run into a failed one.
  • tools/report-error.sh is operator- or skill-invoked only. Never wire it to fire from a failing hook: hooks stay silent and exit 0, and a failing hook that reports itself can loop.
  • Data lands in $JSC_HOME (default ~/.jsc), consumed by jsc-log:worklog and jsc-log:stats.