From fa96a9313724f776a44a5bdde784309c072e962a Mon Sep 17 00:00:00 2001 From: JoshuaVSherman Date: Fri, 28 Aug 2026 12:22:22 -0400 Subject: [PATCH 1/5] Split the both-surfaces acceptance criterion by change kind and ban ~/.claude/hooks/lib/ Rule 14 in skills/file-issue/SKILL.md always demanded a per-surface installer, which produced web-jam-tools#841's defective acceptance criteria asserting a ~/.claude/hooks/lib/ path that has never existed: install-hooks.sh only symlinks hooks/*.sh, and hooks/lib/*.ts is reached by each hook resolving its own symlink back into the canonical clone. Documents that mechanism once in docs/scripts.md, splits rule 14 into structural (installer required) vs content-only (symlink-resolution check, no installer) acceptance criteria, and adds an explicit prohibition on asserting the nonexistent hooks/lib/ path. Pins the new rule text in test/skills_validation.test.ts. Co-Authored-By: Claude Opus 5 --- docs/scripts.md | 15 +++++++++++++++ skills/file-issue/SKILL.md | 5 +++-- test/skills_validation.test.ts | 35 ++++++++++++++++++++++++++++++++++ 3 files changed, 53 insertions(+), 2 deletions(-) diff --git a/docs/scripts.md b/docs/scripts.md index 665f6b56..616eac6f 100644 --- a/docs/scripts.md +++ b/docs/scripts.md @@ -167,6 +167,21 @@ echo '{"model":{"id":"claude-opus-5","display_name":"Opus 5"}}' | scripts/status Overriding this is a test-only seam (the real default hits the network, which an automated test must not depend on); leave it unset for normal use. +### `install-hooks.sh` — what actually gets symlinked + +`scripts/install-hooks.sh` symlinks `hooks/*.sh` only — `hooks/lib/` is +never installed, so there is no `~/.claude/hooks/lib/` path on disk. A hook +script reaches its shared `hooks/lib/*.ts` modules by resolving its own +symlink back to the canonical clone (`HOOK_DIR=$(cd "$(dirname +"$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)`), then reading +`$HOOK_DIR/lib/.ts` from there — agy reaches the same modules +through those same Claude Code symlinks via `hooks/agy-hook-shim.sh`. That +means a content-only change to an already-installed hook script, skill +body, or `hooks/lib/*.ts` module is live on both surfaces the moment `dev` +is pulled; no install step is needed unless the change is structural (a +new/renamed/deleted skill or hook, or a changed event/matcher +registration). + ### `permissions.defaultMode` (managed by `install-hooks.sh`) `~/.claude/settings.json` has no `permissions.defaultMode` key by default, so diff --git a/skills/file-issue/SKILL.md b/skills/file-issue/SKILL.md index 4e22e483..f5292bf3 100644 --- a/skills/file-issue/SKILL.md +++ b/skills/file-issue/SKILL.md @@ -128,8 +128,9 @@ that's what following this skill prevents. 14. **Hook and Skill Issues Must Target Both Claude Code and agy/Antigravity.** - Any issue about a **hook** (a script under `hooks/`) or a **skill** (a directory under `skills/`) must declare it targets **both** agent surfaces — Claude Code and agy/Antigravity — unless Josh has explicitly approved a single-surface exception for that specific issue *in this filing session*, with approval recorded in the issue body. - **Surfaces must be named in `## What this builds`.** The section opening the body must state what is being built for each surface, not just once for both (e.g. not "updates the hook" but "updates the hook in Claude Code and adds the agy variant"). - - **One acceptance criterion per surface.** The `## Acceptance criteria` section must carry a separate, distinct criterion checking each surface: Claude Code (`scripts/install-hooks.sh` for hooks; `scripts/install-skills.ts` for skills) and agy (`scripts/install-hooks.sh` on agy side; same for skills). - - **Both installers named in verification steps.** The `## How to test locally` section must name both `scripts/install-hooks.sh` and `scripts/install-skills.ts` as part of the verification steps, so the implementing agent installs and checks both surfaces rather than one. + - **Structural change: one acceptance criterion per surface, naming the installer.** A new, renamed, or deleted skill or hook, or an existing hook whose event or matcher registration changed, requires an installer to reach either surface. The `## Acceptance criteria` section must carry a separate, distinct criterion checking each surface: Claude Code (`scripts/install-hooks.sh` for hooks; `scripts/install-skills.ts` for skills) and agy (`scripts/install-hooks.sh` on agy side; same for skills). `## How to test locally` must name both `scripts/install-hooks.sh` and `scripts/install-skills.ts` as part of the verification steps, so the implementing agent installs and checks both surfaces rather than one. + - **Content-only change: one acceptance criterion per surface, naming the resolution check, not an installer.** A content-only change to a file that is already installed — a skill body, a hook script's contents, or a shared `hooks/lib/*.ts` module — needs no installer: `git pull` on `dev` makes it live on both surfaces (see `docs/scripts.md` for the mechanism; do not restate it here). The `## Acceptance criteria` section instead asserts, per surface, that the installed symlink still resolves into the canonical clone, and `## How to test locally` names that resolution check rather than an installer. + - **Never assert a `~/.claude/hooks/lib/` path.** `hooks/lib/` is never installed by `scripts/install-hooks.sh`, so that path does not exist and never has. No acceptance criterion, for either change kind, may claim a file resolves there. 15. **A Guard Has Three Outcomes, Not Two.** - Every issue that designs, modifies, or implements a guard, gate, permission check, or validation rule must explicitly specify all three outcomes: 1. When the condition holds (e.g. valid, permitted, or matched). diff --git a/test/skills_validation.test.ts b/test/skills_validation.test.ts index 9bc93851..270988c1 100644 --- a/test/skills_validation.test.ts +++ b/test/skills_validation.test.ts @@ -160,6 +160,41 @@ Deno.test("skills/file-issue/SKILL.md contains the three-outcomes guard rule, po ); }); +Deno.test("skills/file-issue/SKILL.md item 14 splits both-surfaces acceptance criteria by change kind and bans the hooks/lib path", async () => { + const fileIssuePath = `${SKILLS_DIR}file-issue/SKILL.md`; + const text = await Deno.readTextFile(fileIssuePath); + + // Structural change kind still names an installer per surface + assert( + text.includes("Structural change: one acceptance criterion per surface, naming the installer."), + "skills/file-issue/SKILL.md item 14 must keep the structural-change bullet naming an installer per surface", + ); + + // Content-only change kind names the resolution check instead of an installer + assert( + text.includes( + "Content-only change: one acceptance criterion per surface, naming the resolution check, not an installer.", + ), + "skills/file-issue/SKILL.md item 14 must add the content-only-change bullet naming the resolution check", + ); + assert( + text.includes("needs no installer: `git pull` on `dev` makes it live on both surfaces"), + "skills/file-issue/SKILL.md item 14 must state that a content-only change needs no installer", + ); + + // Explicit prohibition on asserting a ~/.claude/hooks/lib/ path + assert( + text.includes("Never assert a `~/.claude/hooks/lib/` path."), + "skills/file-issue/SKILL.md item 14 must prohibit asserting a ~/.claude/hooks/lib/ path", + ); + assert( + text.includes( + "`hooks/lib/` is never installed by `scripts/install-hooks.sh`, so that path does not exist", + ), + "skills/file-issue/SKILL.md item 14 must state that hooks/lib/ is never installed and the path does not exist", + ); +}); + Deno.test("skills/design-issue/SKILL.md contains the both-surfaces rule and refusal table entry", async () => { const designIssuePath = `${SKILLS_DIR}design-issue/SKILL.md`; const text = await Deno.readTextFile(designIssuePath); From 5b77c4925ceaf837ee1264f779cfec20de248936 Mon Sep 17 00:00:00 2001 From: JoshuaVSherman Date: Fri, 28 Aug 2026 13:03:14 -0400 Subject: [PATCH 2/5] fix(file-issue): split hook-symlink criterion from skill-symlink criterion in rule 14 Rule 14's content-only bullet demanded a distinct per-surface symlink assertion for every content-only change, but a hook (or hooks/lib/*.ts module) only has ONE installed symlink (~/.claude/hooks/.sh) -- agy reaches it through that same Claude Code symlink via agy-hook-shim.sh, not a second symlink of its own. Following the old wording produced either a duplicate assertion of the same path or an invented agy-side hook path, exactly the fabrication the rule's third bullet already bans. - skills/file-issue/SKILL.md rule 14: for a hook/hooks/lib/*.ts content-only change, the Claude Code criterion asserts the hook symlink resolves into the canonical clone and the agy criterion asserts ~/.gemini/config/hooks.json's shim registration still targets that same $HOME/.claude/hooks/ path (the registration, not a second symlink, is what makes the agy criterion distinct). The skill case is unchanged -- two genuinely distinct symlinks. - Also closes two gaps flagged in review: a new supporting file inside an already-installed skill directory (e.g. skills/fix-labels/labels.yaml) is now explicitly content-only, and a brand-new hooks/lib/*.ts module consumed by an already-installed hook is now explicitly content-only rather than ambiguously reading as "a new hook" under the structural bullet. - test/skills_validation.test.ts: added pins for each of the above in the existing rule-14 test (no parallel duplicate test added). Addresses Must Fix and Suggestions 2-3 from the PR #865 review. --- skills/file-issue/SKILL.md | 4 ++-- test/skills_validation.test.ts | 44 ++++++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/skills/file-issue/SKILL.md b/skills/file-issue/SKILL.md index f5292bf3..b4523204 100644 --- a/skills/file-issue/SKILL.md +++ b/skills/file-issue/SKILL.md @@ -128,8 +128,8 @@ that's what following this skill prevents. 14. **Hook and Skill Issues Must Target Both Claude Code and agy/Antigravity.** - Any issue about a **hook** (a script under `hooks/`) or a **skill** (a directory under `skills/`) must declare it targets **both** agent surfaces — Claude Code and agy/Antigravity — unless Josh has explicitly approved a single-surface exception for that specific issue *in this filing session*, with approval recorded in the issue body. - **Surfaces must be named in `## What this builds`.** The section opening the body must state what is being built for each surface, not just once for both (e.g. not "updates the hook" but "updates the hook in Claude Code and adds the agy variant"). - - **Structural change: one acceptance criterion per surface, naming the installer.** A new, renamed, or deleted skill or hook, or an existing hook whose event or matcher registration changed, requires an installer to reach either surface. The `## Acceptance criteria` section must carry a separate, distinct criterion checking each surface: Claude Code (`scripts/install-hooks.sh` for hooks; `scripts/install-skills.ts` for skills) and agy (`scripts/install-hooks.sh` on agy side; same for skills). `## How to test locally` must name both `scripts/install-hooks.sh` and `scripts/install-skills.ts` as part of the verification steps, so the implementing agent installs and checks both surfaces rather than one. - - **Content-only change: one acceptance criterion per surface, naming the resolution check, not an installer.** A content-only change to a file that is already installed — a skill body, a hook script's contents, or a shared `hooks/lib/*.ts` module — needs no installer: `git pull` on `dev` makes it live on both surfaces (see `docs/scripts.md` for the mechanism; do not restate it here). The `## Acceptance criteria` section instead asserts, per surface, that the installed symlink still resolves into the canonical clone, and `## How to test locally` names that resolution check rather than an installer. + - **Structural change: one acceptance criterion per surface, naming the installer.** A new, renamed, or deleted skill or hook, or an existing hook whose event or matcher registration changed, requires an installer to reach either surface. A brand-new `hooks/lib/*.ts` module consumed by an already-installed hook is **not** a new hook under this bullet — see the content-only bullet below. The `## Acceptance criteria` section must carry a separate, distinct criterion checking each surface: Claude Code (`scripts/install-hooks.sh` for hooks; `scripts/install-skills.ts` for skills) and agy (`scripts/install-hooks.sh` on agy side; same for skills). `## How to test locally` must name both `scripts/install-hooks.sh` and `scripts/install-skills.ts` as part of the verification steps, so the implementing agent installs and checks both surfaces rather than one. + - **Content-only change: one acceptance criterion per surface, naming the resolution check, not an installer.** A content-only change to a file that is already installed — a skill body, a new or existing supporting file inside an already-installed skill directory (e.g. adding `skills/fix-labels/labels.yaml` to the already-installed `fix-labels` skill), a hook script's contents, or a new or existing shared `hooks/lib/*.ts` module consumed by an already-installed hook — needs no installer: `git pull` on `dev` makes it live on both surfaces (see `docs/scripts.md` for the mechanism; do not restate it here). For a **skill**, `~/.claude/skills/` and `~/.gemini/config/plugins/webjam-tasks/skills/` are two genuinely distinct symlinks, so the `## Acceptance criteria` section asserts, per surface, that the installed symlink still resolves into the canonical clone. For a **hook or a `hooks/lib/*.ts` module**, there is only one hook symlink (`~/.claude/hooks/.sh`) — agy reaches it through that same Claude Code symlink via `hooks/agy-hook-shim.sh`, not a second symlink of its own — so the two per-surface criteria must NOT be the same assertion written twice: the Claude Code criterion asserts `~/.claude/hooks/.sh` resolves into the canonical clone, and the agy criterion asserts that `~/.gemini/config/hooks.json`'s shim registration still targets that same `$HOME/.claude/hooks/` path — that registration, not a second symlink, is what makes the agy criterion distinct. `## How to test locally` names that resolution check rather than an installer. - **Never assert a `~/.claude/hooks/lib/` path.** `hooks/lib/` is never installed by `scripts/install-hooks.sh`, so that path does not exist and never has. No acceptance criterion, for either change kind, may claim a file resolves there. 15. **A Guard Has Three Outcomes, Not Two.** - Every issue that designs, modifies, or implements a guard, gate, permission check, or validation rule must explicitly specify all three outcomes: diff --git a/test/skills_validation.test.ts b/test/skills_validation.test.ts index 270988c1..54cb3d53 100644 --- a/test/skills_validation.test.ts +++ b/test/skills_validation.test.ts @@ -182,6 +182,50 @@ Deno.test("skills/file-issue/SKILL.md item 14 splits both-surfaces acceptance cr "skills/file-issue/SKILL.md item 14 must state that a content-only change needs no installer", ); + // A new supporting file inside an already-installed skill directory is content-only + assert( + text.includes( + "a new or existing supporting file inside an already-installed skill directory (e.g. adding `skills/fix-labels/labels.yaml` to the already-installed `fix-labels` skill)", + ), + "skills/file-issue/SKILL.md item 14 must classify a new supporting file in an already-installed skill directory as content-only", + ); + + // A brand-new hooks/lib/*.ts module consumed by an already-installed hook is content-only, not structural + assert( + text.includes( + "A brand-new `hooks/lib/*.ts` module consumed by an already-installed hook is **not** a new hook under this bullet", + ), + "skills/file-issue/SKILL.md item 14's structural bullet must disclaim a new hooks/lib/*.ts module as not a new hook", + ); + assert( + text.includes( + "a new or existing shared `hooks/lib/*.ts` module consumed by an already-installed hook", + ), + "skills/file-issue/SKILL.md item 14's content-only bullet must classify a new hooks/lib/*.ts module as content-only", + ); + + // Skill case: two genuinely distinct symlinks, one criterion per surface as before + assert( + text.includes( + "For a **skill**, `~/.claude/skills/` and `~/.gemini/config/plugins/webjam-tasks/skills/` are two genuinely distinct symlinks, so the `## Acceptance criteria` section asserts, per surface, that the installed symlink still resolves into the canonical clone.", + ), + "skills/file-issue/SKILL.md item 14 must keep the skill content-only case as two distinct symlink assertions", + ); + + // Hook case: only one hook symlink, so the agy criterion asserts the hooks.json shim registration, not a second symlink + assert( + text.includes( + "For a **hook or a `hooks/lib/*.ts` module**, there is only one hook symlink (`~/.claude/hooks/.sh`)", + ), + "skills/file-issue/SKILL.md item 14 must state that a hook or hooks/lib/*.ts module has only one hook symlink", + ); + assert( + text.includes( + "the agy criterion asserts that `~/.gemini/config/hooks.json`'s shim registration still targets that same `$HOME/.claude/hooks/` path", + ), + "skills/file-issue/SKILL.md item 14 must state the agy criterion for a hook content-only change asserts the hooks.json shim registration", + ); + // Explicit prohibition on asserting a ~/.claude/hooks/lib/ path assert( text.includes("Never assert a `~/.claude/hooks/lib/` path."), From ef11fcfd2cdca79b5e67095135964b3e3ab3ce5c Mon Sep 17 00:00:00 2001 From: JoshuaVSherman Date: Wed, 2 Sep 2026 00:52:47 -0400 Subject: [PATCH 3/5] fix(review): resolve design-issue/rule-14 contradiction and unsatisfiable agy criterion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two Must Fix blockers from PR review on this branch: 1. skills/design-issue/SKILL.md's "Installing is for Structure, Never for Content" paragraph asserted a both-surfaces criterion ("asserts only that those symlinks still resolve into the clone") that the amended skills/file-issue/SKILL.md rule 14 now contradicts for the hook case, and separately restated the symlink mechanism rule 14 already delegates to docs/scripts.md. Removed both the restated mechanism and the competing criterion; the paragraph now points at the single source of each instead of carrying its own copy. 2. Rule 14's content-only bullet unconditionally required asserting that ~/.gemini/config/hooks.json's shim registration targets a changed hook — but 9 of the repo's 29 hooks/*.sh scripts are SessionStart/Stop lifecycle hooks that carry no such registration and never can (registering one there disables agy's entire hooks config per docs/agy-hooks.md finding 9, enforced by merge-hooks-into-settings.ts --forbid-lifecycle-hooks). Branched the agy criterion on the hook's event: PreToolUse/PostToolUse hooks keep the hooks.json shim-registration assertion; SessionStart/Stop hooks instead assert the deliberate fact that no agy registration exists, so the content change has no agy-side effect. test/skills_validation.test.ts pins updated to match the reworded rule 14 text and gained new pins for the SessionStart/Stop branch. Also re-bumps deno.json to 1.32.38: origin/dev advanced to 1.32.37 (web-jam-tools#868 merged) while this branch sat at the same version. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SzQWMYV2yZCRxvcNNWeUmC --- deno.json | 2 +- skills/design-issue/SKILL.md | 2 +- skills/file-issue/SKILL.md | 2 +- test/skills_validation.test.ts | 32 ++++++++++++++++++++++++++++++-- 4 files changed, 33 insertions(+), 5 deletions(-) diff --git a/deno.json b/deno.json index 607c9b6e..09cb96a1 100644 --- a/deno.json +++ b/deno.json @@ -1,7 +1,7 @@ { "$schema": "https://raw.githubusercontent.com/denoland/deno/main/cli/schemas/config-file.v1.json", "name": "@webjam/tools", - "version": "1.32.37", + "version": "1.32.38", "exports": "./src/uptime/cron.ts", "tasks": { "sheet-music:generate": "deno run --allow-read --allow-write src/sheet-music/generate_docx.ts", diff --git a/skills/design-issue/SKILL.md b/skills/design-issue/SKILL.md index 921d3807..b5a64591 100644 --- a/skills/design-issue/SKILL.md +++ b/skills/design-issue/SKILL.md @@ -172,7 +172,7 @@ Everything this skill designs works on both Claude Code and agy/Antigravity. A m ### Installing is for Structure, Never for Content -**Installing is for structure, never for content.** Skill bodies and hook scripts are symlinked into the canonical clone, and agy invokes those same Claude Code symlinks through `agy-hook-shim.sh` while symlinking the same skill sources into its own plugin directory. One set of files serves both surfaces. A content change to an existing skill or hook is therefore live on both the moment the canonical clone is on the merged commit, and the both-surfaces acceptance criterion for such a change asserts only that those symlinks still resolve into the clone — the failure that has actually occurred, when they were repointed into a temporary directory. The installers exist for the two things a symlink cannot carry: a link that does not exist yet, and a registration entry. A new, renamed or deleted skill runs the skill installer. A new or deleted hook, or an existing hook whose event or matcher changed, runs the hook installer, which re-merges the repository's entries into the two settings files — merge targets holding Josh's own settings beside the repository's, and so not themselves symlinkable. Both run from the canonical clone, never from a worktree, which the installer's own path validation enforces. +**Installing is for structure, never for content.** Skill bodies and hook scripts are symlinked into the canonical clone, and agy invokes those same Claude Code symlinks through `agy-hook-shim.sh` while symlinking the same skill sources into its own plugin directory. One set of files serves both surfaces, so a content change to an existing skill or hook is live on both the moment the canonical clone is on the merged commit. For the resolution mechanism, see `docs/scripts.md` ("`install-hooks.sh` — what actually gets symlinked"); for the both-surfaces acceptance-criterion shape a content-only change must use, see `skills/file-issue/SKILL.md` rule 14 — do not restate either here. The installers exist for the two things a symlink cannot carry: a link that does not exist yet, and a registration entry. A new, renamed or deleted skill runs the skill installer. A new or deleted hook, or an existing hook whose event or matcher changed, runs the hook installer, which re-merges the repository's entries into the two settings files — merge targets holding Josh's own settings beside the repository's, and so not themselves symlinkable. Both run from the canonical clone, never from a worktree, which the installer's own path validation enforces. --- diff --git a/skills/file-issue/SKILL.md b/skills/file-issue/SKILL.md index b4523204..3738b29c 100644 --- a/skills/file-issue/SKILL.md +++ b/skills/file-issue/SKILL.md @@ -129,7 +129,7 @@ that's what following this skill prevents. - Any issue about a **hook** (a script under `hooks/`) or a **skill** (a directory under `skills/`) must declare it targets **both** agent surfaces — Claude Code and agy/Antigravity — unless Josh has explicitly approved a single-surface exception for that specific issue *in this filing session*, with approval recorded in the issue body. - **Surfaces must be named in `## What this builds`.** The section opening the body must state what is being built for each surface, not just once for both (e.g. not "updates the hook" but "updates the hook in Claude Code and adds the agy variant"). - **Structural change: one acceptance criterion per surface, naming the installer.** A new, renamed, or deleted skill or hook, or an existing hook whose event or matcher registration changed, requires an installer to reach either surface. A brand-new `hooks/lib/*.ts` module consumed by an already-installed hook is **not** a new hook under this bullet — see the content-only bullet below. The `## Acceptance criteria` section must carry a separate, distinct criterion checking each surface: Claude Code (`scripts/install-hooks.sh` for hooks; `scripts/install-skills.ts` for skills) and agy (`scripts/install-hooks.sh` on agy side; same for skills). `## How to test locally` must name both `scripts/install-hooks.sh` and `scripts/install-skills.ts` as part of the verification steps, so the implementing agent installs and checks both surfaces rather than one. - - **Content-only change: one acceptance criterion per surface, naming the resolution check, not an installer.** A content-only change to a file that is already installed — a skill body, a new or existing supporting file inside an already-installed skill directory (e.g. adding `skills/fix-labels/labels.yaml` to the already-installed `fix-labels` skill), a hook script's contents, or a new or existing shared `hooks/lib/*.ts` module consumed by an already-installed hook — needs no installer: `git pull` on `dev` makes it live on both surfaces (see `docs/scripts.md` for the mechanism; do not restate it here). For a **skill**, `~/.claude/skills/` and `~/.gemini/config/plugins/webjam-tasks/skills/` are two genuinely distinct symlinks, so the `## Acceptance criteria` section asserts, per surface, that the installed symlink still resolves into the canonical clone. For a **hook or a `hooks/lib/*.ts` module**, there is only one hook symlink (`~/.claude/hooks/.sh`) — agy reaches it through that same Claude Code symlink via `hooks/agy-hook-shim.sh`, not a second symlink of its own — so the two per-surface criteria must NOT be the same assertion written twice: the Claude Code criterion asserts `~/.claude/hooks/.sh` resolves into the canonical clone, and the agy criterion asserts that `~/.gemini/config/hooks.json`'s shim registration still targets that same `$HOME/.claude/hooks/` path — that registration, not a second symlink, is what makes the agy criterion distinct. `## How to test locally` names that resolution check rather than an installer. + - **Content-only change: one acceptance criterion per surface, naming the resolution check, not an installer.** A content-only change to a file that is already installed — a skill body, a new or existing supporting file inside an already-installed skill directory (e.g. adding `skills/fix-labels/labels.yaml` to the already-installed `fix-labels` skill), a hook script's contents, or a new or existing shared `hooks/lib/*.ts` module consumed by an already-installed hook — needs no installer: `git pull` on `dev` makes it live on both surfaces (see `docs/scripts.md` for the mechanism; do not restate it here). For a **skill**, `~/.claude/skills/` and `~/.gemini/config/plugins/webjam-tasks/skills/` are two genuinely distinct symlinks, so the `## Acceptance criteria` section asserts, per surface, that the installed symlink still resolves into the canonical clone. For a **hook or a `hooks/lib/*.ts` module**, there is only one hook symlink (`~/.claude/hooks/.sh`) — agy reaches it through that same Claude Code symlink via `hooks/agy-hook-shim.sh`, not a second symlink of its own — so the two per-surface criteria must NOT be the same assertion written twice: the Claude Code criterion asserts `~/.claude/hooks/.sh` resolves into the canonical clone, and the agy criterion depends on which event the hook (or the hook consuming the `hooks/lib/*.ts` module) is registered under, checked against `scripts/install-hooks.sh`'s own `PRE_TOOL_USE_HOOKS`/`POST_TOOL_USE_HOOKS`/`AGY_ONLY_PRE_TOOL_USE_HOOKS` vs. `SESSION_START_HOOKS`/`STOP_HOOKS` arrays: for a `PreToolUse` or `PostToolUse` hook, which carries a `~/.gemini/config/hooks.json` shim registration, the agy criterion asserts that registration still targets that same `$HOME/.claude/hooks/` path — that registration, not a second symlink, is what makes the agy criterion distinct; for a `SessionStart` or `Stop` hook, `~/.gemini/config/hooks.json` carries no registration for it at all and never can — `scripts/merge-hooks-into-settings.ts --forbid-lifecycle-hooks` refuses one there because registering either lifecycle event silently disables agy's entire hooks config (`docs/agy-hooks.md` finding 9) — so the agy criterion asserts that fact plainly: the hook has no `~/.gemini/config/hooks.json` registration, and the content change has no agy-side effect. Never invent an agy-side path or registration for a `SessionStart`/`Stop` hook to satisfy this criterion. `## How to test locally` names that resolution check rather than an installer. - **Never assert a `~/.claude/hooks/lib/` path.** `hooks/lib/` is never installed by `scripts/install-hooks.sh`, so that path does not exist and never has. No acceptance criterion, for either change kind, may claim a file resolves there. 15. **A Guard Has Three Outcomes, Not Two.** - Every issue that designs, modifies, or implements a guard, gate, permission check, or validation rule must explicitly specify all three outcomes: diff --git a/test/skills_validation.test.ts b/test/skills_validation.test.ts index 54cb3d53..f6a8e1d8 100644 --- a/test/skills_validation.test.ts +++ b/test/skills_validation.test.ts @@ -221,9 +221,37 @@ Deno.test("skills/file-issue/SKILL.md item 14 splits both-surfaces acceptance cr ); assert( text.includes( - "the agy criterion asserts that `~/.gemini/config/hooks.json`'s shim registration still targets that same `$HOME/.claude/hooks/` path", + "for a `PreToolUse` or `PostToolUse` hook, which carries a `~/.gemini/config/hooks.json` shim registration, the agy criterion asserts that registration still targets that same `$HOME/.claude/hooks/` path", ), - "skills/file-issue/SKILL.md item 14 must state the agy criterion for a hook content-only change asserts the hooks.json shim registration", + "skills/file-issue/SKILL.md item 14 must state the agy criterion for a PreToolUse/PostToolUse hook content-only change asserts the hooks.json shim registration", + ); + + // Hook case: a SessionStart/Stop lifecycle hook carries NO agy registration at all (registering + // one there disables agy's entire hooks config), so the agy criterion asserts that absence as a + // deliberate fact rather than inventing a path or registration to assert instead. + assert( + text.includes( + "for a `SessionStart` or `Stop` hook, `~/.gemini/config/hooks.json` carries no registration for it at all and never can", + ), + "skills/file-issue/SKILL.md item 14 must state that a SessionStart/Stop hook carries no agy hooks.json registration at all", + ); + assert( + text.includes( + "registering either lifecycle event silently disables agy's entire hooks config (`docs/agy-hooks.md` finding 9)", + ), + "skills/file-issue/SKILL.md item 14 must cite docs/agy-hooks.md finding 9 for why lifecycle hooks are never registered with agy", + ); + assert( + text.includes( + "so the agy criterion asserts that fact plainly: the hook has no `~/.gemini/config/hooks.json` registration, and the content change has no agy-side effect", + ), + "skills/file-issue/SKILL.md item 14 must state the no-registration agy criterion wording for a SessionStart/Stop hook", + ); + assert( + text.includes( + "Never invent an agy-side path or registration for a `SessionStart`/`Stop` hook to satisfy this criterion.", + ), + "skills/file-issue/SKILL.md item 14 must prohibit inventing an agy-side path or registration for a SessionStart/Stop hook", ); // Explicit prohibition on asserting a ~/.claude/hooks/lib/ path From 9ed08e0411f874f77ca854fcb9a86baeddf213bc Mon Sep 17 00:00:00 2001 From: JoshuaVSherman Date: Wed, 2 Sep 2026 01:17:31 -0400 Subject: [PATCH 4/5] fix(file-issue): close the mirror gap for agy-only hooks in rule 14 Rule 14's content-only bullet told the filing agent to write a Claude Code criterion asserting ~/.claude/hooks/.sh resolves into the canonical clone. For the two hooks in install-hooks.sh's AGY_ONLY_PRE_TOOL_USE_HOOKS array (agy-model-guard.sh, block-agy-gmail-send-delete.sh) that assertion is true but hollow: both are symlinked into ~/.claude/hooks/ like every other hook, yet neither is registered in Claude Code's settings.json, because they depend on agy-native payload fields Claude Code's hook payload does not carry (docs/agy-hooks.md, "agy-only hooks"). A criterion that passes while implying a Claude-Code-side effect that cannot occur is the same defect this rule was amended to eliminate, mirrored onto the other surface. The agy half of that bullet already branches this way for SessionStart and Stop hooks. This adds the matching Claude Code branch: for an agy-only hook the Claude Code criterion states plainly that the hook has no settings.json registration and the change has no Claude-Code-side effect, with a ban on inventing one to satisfy it. Pins the new wording in test/skills_validation.test.ts's existing rule-14 test; no parallel test added. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SzQWMYV2yZCRxvcNNWeUmC --- skills/file-issue/SKILL.md | 2 +- test/skills_validation.test.ts | 35 ++++++++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/skills/file-issue/SKILL.md b/skills/file-issue/SKILL.md index f0c6ee10..5308b76a 100644 --- a/skills/file-issue/SKILL.md +++ b/skills/file-issue/SKILL.md @@ -129,7 +129,7 @@ that's what following this skill prevents. - Any issue about a **hook** (a script under `hooks/`) or a **skill** (a directory under `skills/`) must declare it targets **both** agent surfaces — Claude Code and agy/Antigravity — unless Josh has explicitly approved a single-surface exception for that specific issue *in this filing session*, with approval recorded in the issue body. - **Surfaces must be named in `## What this builds`.** The section opening the body must state what is being built for each surface, not just once for both (e.g. not "updates the hook" but "updates the hook in Claude Code and adds the agy variant"). - **Structural change: one acceptance criterion per surface, naming the installer.** A new, renamed, or deleted skill or hook, or an existing hook whose event or matcher registration changed, requires an installer to reach either surface. A brand-new `hooks/lib/*.ts` module consumed by an already-installed hook is **not** a new hook under this bullet — see the content-only bullet below. The `## Acceptance criteria` section must carry a separate, distinct criterion checking each surface: Claude Code (`scripts/install-hooks.sh` for hooks; `scripts/install-skills.ts` for skills) and agy (`scripts/install-hooks.sh` on agy side; same for skills). `## How to test locally` must name both `scripts/install-hooks.sh` and `scripts/install-skills.ts` as part of the verification steps, so the implementing agent installs and checks both surfaces rather than one. - - **Content-only change: one acceptance criterion per surface, naming the resolution check, not an installer.** A content-only change to a file that is already installed — a skill body, a new or existing supporting file inside an already-installed skill directory (e.g. adding `skills/fix-labels/labels.yaml` to the already-installed `fix-labels` skill), a hook script's contents, or a new or existing shared `hooks/lib/*.ts` module consumed by an already-installed hook — needs no installer: `git pull` on `dev` makes it live on both surfaces (see `docs/scripts.md` for the mechanism; do not restate it here). For a **skill**, `~/.claude/skills/` and `~/.gemini/config/plugins/webjam-tasks/skills/` are two genuinely distinct symlinks, so the `## Acceptance criteria` section asserts, per surface, that the installed symlink still resolves into the canonical clone. For a **hook or a `hooks/lib/*.ts` module**, there is only one hook symlink (`~/.claude/hooks/.sh`) — agy reaches it through that same Claude Code symlink via `hooks/agy-hook-shim.sh`, not a second symlink of its own — so the two per-surface criteria must NOT be the same assertion written twice: the Claude Code criterion asserts `~/.claude/hooks/.sh` resolves into the canonical clone, and the agy criterion depends on which event the hook (or the hook consuming the `hooks/lib/*.ts` module) is registered under, checked against `scripts/install-hooks.sh`'s own `PRE_TOOL_USE_HOOKS`/`POST_TOOL_USE_HOOKS`/`AGY_ONLY_PRE_TOOL_USE_HOOKS` vs. `SESSION_START_HOOKS`/`STOP_HOOKS` arrays: for a `PreToolUse` or `PostToolUse` hook, which carries a `~/.gemini/config/hooks.json` shim registration, the agy criterion asserts that registration still targets that same `$HOME/.claude/hooks/` path — that registration, not a second symlink, is what makes the agy criterion distinct; for a `SessionStart` or `Stop` hook, `~/.gemini/config/hooks.json` carries no registration for it at all and never can — `scripts/merge-hooks-into-settings.ts --forbid-lifecycle-hooks` refuses one there because registering either lifecycle event silently disables agy's entire hooks config (`docs/agy-hooks.md` finding 9) — so the agy criterion asserts that fact plainly: the hook has no `~/.gemini/config/hooks.json` registration, and the content change has no agy-side effect. Never invent an agy-side path or registration for a `SessionStart`/`Stop` hook to satisfy this criterion. `## How to test locally` names that resolution check rather than an installer. + - **Content-only change: one acceptance criterion per surface, naming the resolution check, not an installer.** A content-only change to a file that is already installed — a skill body, a new or existing supporting file inside an already-installed skill directory (e.g. adding `skills/fix-labels/labels.yaml` to the already-installed `fix-labels` skill), a hook script's contents, or a new or existing shared `hooks/lib/*.ts` module consumed by an already-installed hook — needs no installer: `git pull` on `dev` makes it live on both surfaces (see `docs/scripts.md` for the mechanism; do not restate it here). For a **skill**, `~/.claude/skills/` and `~/.gemini/config/plugins/webjam-tasks/skills/` are two genuinely distinct symlinks, so the `## Acceptance criteria` section asserts, per surface, that the installed symlink still resolves into the canonical clone. For a **hook or a `hooks/lib/*.ts` module**, there is only one hook symlink (`~/.claude/hooks/.sh`) — agy reaches it through that same Claude Code symlink via `hooks/agy-hook-shim.sh`, not a second symlink of its own — so the two per-surface criteria must NOT be the same assertion written twice: the Claude Code criterion asserts `~/.claude/hooks/.sh` resolves into the canonical clone — except for a hook listed in `scripts/install-hooks.sh`'s `AGY_ONLY_PRE_TOOL_USE_HOOKS` array (`hooks/agy-model-guard.sh`, `hooks/block-agy-gmail-send-delete.sh`), which is symlinked into `~/.claude/hooks/` like every other hook but is deliberately never registered in Claude Code's `settings.json`, because it depends on agy-native payload fields Claude Code's hook payload does not carry (`docs/agy-hooks.md` § "agy-only hooks"); for those, asserting only that the symlink resolves would be a true statement implying a Claude-Code-side effect that cannot occur, so the Claude Code criterion instead asserts that fact plainly: the hook has no Claude Code `settings.json` registration, and the content change has no Claude-Code-side effect. Never invent a Claude Code registration for an agy-only hook to satisfy this criterion. The agy criterion depends on which event the hook (or the hook consuming the `hooks/lib/*.ts` module) is registered under, checked against `scripts/install-hooks.sh`'s own `PRE_TOOL_USE_HOOKS`/`POST_TOOL_USE_HOOKS`/`AGY_ONLY_PRE_TOOL_USE_HOOKS` vs. `SESSION_START_HOOKS`/`STOP_HOOKS` arrays: for a `PreToolUse` or `PostToolUse` hook, which carries a `~/.gemini/config/hooks.json` shim registration, the agy criterion asserts that registration still targets that same `$HOME/.claude/hooks/` path — that registration, not a second symlink, is what makes the agy criterion distinct; for a `SessionStart` or `Stop` hook, `~/.gemini/config/hooks.json` carries no registration for it at all and never can — `scripts/merge-hooks-into-settings.ts --forbid-lifecycle-hooks` refuses one there because registering either lifecycle event silently disables agy's entire hooks config (`docs/agy-hooks.md` finding 9) — so the agy criterion asserts that fact plainly: the hook has no `~/.gemini/config/hooks.json` registration, and the content change has no agy-side effect. Never invent an agy-side path or registration for a `SessionStart`/`Stop` hook to satisfy this criterion. `## How to test locally` names that resolution check rather than an installer. - **Never assert a `~/.claude/hooks/lib/` path.** `hooks/lib/` is never installed by `scripts/install-hooks.sh`, so that path does not exist and never has. No acceptance criterion, for either change kind, may claim a file resolves there. 15. **A Guard Has Three Outcomes, Not Two.** - Every issue that designs, modifies, or implements a guard, gate, permission check, or validation rule must explicitly specify all three outcomes: diff --git a/test/skills_validation.test.ts b/test/skills_validation.test.ts index 3e6391d1..09d3bdba 100644 --- a/test/skills_validation.test.ts +++ b/test/skills_validation.test.ts @@ -254,6 +254,41 @@ Deno.test("skills/file-issue/SKILL.md item 14 splits both-surfaces acceptance cr "skills/file-issue/SKILL.md item 14 must prohibit inventing an agy-side path or registration for a SessionStart/Stop hook", ); + // agy-only hooks: the mirror case on the Claude Code side. These are symlinked into + // ~/.claude/hooks/ like every other hook but are never registered in Claude Code's + // settings.json, so a bare symlink-resolution criterion would be true while implying a + // Claude-Code-side effect that cannot occur. + assert( + text.includes( + "except for a hook listed in `scripts/install-hooks.sh`'s `AGY_ONLY_PRE_TOOL_USE_HOOKS` array (`hooks/agy-model-guard.sh`, `hooks/block-agy-gmail-send-delete.sh`)", + ), + "skills/file-issue/SKILL.md item 14 must carve out the AGY_ONLY_PRE_TOOL_USE_HOOKS hooks from the Claude Code symlink-resolution criterion", + ); + assert( + text.includes( + "is deliberately never registered in Claude Code's `settings.json`, because it depends on agy-native payload fields Claude Code's hook payload does not carry", + ), + "skills/file-issue/SKILL.md item 14 must state why an agy-only hook carries no Claude Code settings.json registration", + ); + assert( + text.includes( + "asserting only that the symlink resolves would be a true statement implying a Claude-Code-side effect that cannot occur", + ), + "skills/file-issue/SKILL.md item 14 must state why a bare symlink assertion is hollow for an agy-only hook", + ); + assert( + text.includes( + "the Claude Code criterion instead asserts that fact plainly: the hook has no Claude Code `settings.json` registration, and the content change has no Claude-Code-side effect", + ), + "skills/file-issue/SKILL.md item 14 must state the no-registration Claude Code criterion wording for an agy-only hook", + ); + assert( + text.includes( + "Never invent a Claude Code registration for an agy-only hook to satisfy this criterion.", + ), + "skills/file-issue/SKILL.md item 14 must prohibit inventing a Claude Code registration for an agy-only hook", + ); + // Explicit prohibition on asserting a ~/.claude/hooks/lib/ path assert( text.includes("Never assert a `~/.claude/hooks/lib/` path."), From 717cffcfda74bf8a3eaa72ed63888ecf652df7fd Mon Sep 17 00:00:00 2001 From: JoshuaVSherman Date: Wed, 2 Sep 2026 01:19:14 -0400 Subject: [PATCH 5/5] chore: bump version to 1.32.40 after merging dev origin/dev advanced to 1.32.39 via web-jam-tools#873 "feat(book-gig): add surrounding town mappings for Pembroke, Pulaski, Wirtz, and Huddleston", matching this branch's version, so the version no longer strictly exceeded dev and CircleCI's version-bump gate would have failed. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SzQWMYV2yZCRxvcNNWeUmC --- deno.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deno.json b/deno.json index 733ad3c1..516298b0 100644 --- a/deno.json +++ b/deno.json @@ -1,7 +1,7 @@ { "$schema": "https://raw.githubusercontent.com/denoland/deno/main/cli/schemas/config-file.v1.json", "name": "@webjam/tools", - "version": "1.32.39", + "version": "1.32.40", "exports": "./src/uptime/cron.ts", "tasks": { "sheet-music:generate": "deno run --allow-read --allow-write src/sheet-music/generate_docx.ts",