Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion deno.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"$schema": "https://raw.githubusercontent.com/denoland/deno/main/cli/schemas/config-file.v1.json",
"name": "@webjam/tools",
"version": "1.32.34",
"version": "1.32.35",
"exports": "./src/uptime/cron.ts",
"tasks": {
"sheet-music:generate": "deno run --allow-read --allow-write src/sheet-music/generate_docx.ts",
Expand Down
15 changes: 15 additions & 0 deletions docs/scripts.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<module>.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
Expand Down
5 changes: 3 additions & 2 deletions skills/file-issue/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. 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/<skill>` and `~/.gemini/config/plugins/webjam-tasks/skills/<skill>` 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/<hook>.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/<hook>.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:
1. When the condition holds (e.g. valid, permitted, or matched).
Expand Down
79 changes: 79 additions & 0 deletions test/skills_validation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,85 @@ 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",
);

// 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/<skill>` and `~/.gemini/config/plugins/webjam-tasks/skills/<skill>` 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/<hook>.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."),
"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);
Expand Down