Claude Code rewrites $1/$2/$9/$0 in 8 generated skills when they are invoked with arguments
Claude Code replaces $<digit> tokens in a SKILL.md body with words from the invocation arguments before the model reads the skill (documented; broader report: anthropics/claude-code#94709). In Claude Code 2.1.271 the rule is /\$(\d+)(?!\w)/g, indexing the argument words from 0; a token with no matching word is left alone. So shell and awk code in a skill body arrives rewritten once the arguments are long enough, and it still parses.
On main @ a6b3a57 (v1.87.4.0) this affects eight generated skills. With the arguments import cookies from chrome for github then compare two landing page variants side by side, the model receives:
| Skill | Delivered | Source | Breaks from |
|---|---|---|---|
open-gstack-browser, pair-agent, setup-browser-cookies |
actual_sha=$(sha256sum "$tmpfile" | awk '{print cookies}') (both branches) |
scripts/resolvers/browse.ts:144,146 |
2 words — actual_sha is empty, so on a machine without bun the setup stops at "bun install script checksum mismatch" |
gstack-upgrade |
awk '{print cookies}', awk '{print from}' on the snooze file |
gstack-upgrade/SKILL.md.tmpl:62,64 |
2–3 words |
health |
printf 'ERROR:typecheck CAPTURE:%s\n' "cookies" |
health/SKILL.md.tmpl:127 |
2 words |
design-html |
lsof … | awk '{print landing}' → _PORT is empty |
design-html/SKILL.md.tmpl:312 |
10 words |
context-save |
# Bash-side title sanitize. Pass the raw title as cookies when running this block. |
context-save/SKILL.md.tmpl:125 |
2 words |
benchmark-models |
Adds ~import.05/run, (adds ~import.05) |
benchmark-models/SKILL.md.tmpl:96,99 |
any argument |
Suggested fix (sources only; bun run gen:skill-docs regenerates the rest):
- awk
$N→$(N)— the same field in POSIX awk, with nothing for the substitution to match - shell
"$1"→"${1}" - prose
~$0.05→~5 cents - the
context-savecomment now namesTITLE_RAW, which is what the block actually reads
\$1 works too in Claude Code, but gstack renders the same skills for hosts that don't substitute, where the backslash would reach the model, so the patch avoids the token instead.
diff --git a/benchmark-models/SKILL.md.tmpl b/benchmark-models/SKILL.md.tmpl
index 034cda1..422a19c 100644
--- a/benchmark-models/SKILL.md.tmpl
+++ b/benchmark-models/SKILL.md.tmpl
@@ -93,10 +93,10 @@ If at least one is OK: AskUserQuestion:If judge is available, AskUserQuestion: -- Simplify: "The quality judge scores each model's output on a 0-10 scale using Anthropic's Claude as a tiebreaker. Adds ~$0.05/run. Recommended if you care about output quality, not just latency and cost." +- Simplify: "The quality judge scores each model's output on a 0-10 scale using Anthropic's Claude as a tiebreaker. Adds ~5 cents/run. Recommended if you care about output quality, not just latency and cost."
- RECOMMENDATION: A — the whole point is comparing quality, not just speed.
- Options:
- A) Enable judge (adds ~$0.05). Completeness: 10/10.
- A) Enable judge (adds ~5 cents). Completeness: 10/10.
- B) Skip judge — speed/cost/tokens only. Completeness: 7/10.
If judge is NOT available, skip this question and omit the --judge flag.
diff --git a/context-save/SKILL.md.tmpl b/context-save/SKILL.md.tmpl
index a3702bc..7cc1bfb 100644
--- a/context-save/SKILL.md.tmpl
+++ b/context-save/SKILL.md.tmpl
@@ -122,7 +122,7 @@ eval "$(~/.claude/skills/gstack/bin/gstack-paths)"
CHECKPOINT_DIR="$GSTACK_STATE_ROOT/projects/$SLUG/checkpoints"
mkdir -p "$CHECKPOINT_DIR"
TIMESTAMP=$(date +%Y%m%d-%H%M%S)
-# Bash-side title sanitize. Pass the raw title as $1 when running this block.
+# Bash-side title sanitize. Pass the raw title in TITLE_RAW when running this block.
Example: TITLE_RAW="wintermute progress" bash -c '...'
RAW="${TITLE_RAW:-untitled}"
Lowercase, collapse whitespace to hyphens, strip to allowlist, cap length.
diff --git a/design-html/SKILL.md.tmpl b/design-html/SKILL.md.tmpl index 38cd6bc..c5bcb0f 100644 --- a/design-html/SKILL.md.tmpl +++ b/design-html/SKILL.md.tmpl @@ -309,7 +309,7 @@ _OUTPUT_DIR=$(dirname <path-to-finalized.html>) cd "$_OUTPUT_DIR" python3 -m http.server 0 --bind 127.0.0.1 & _SERVER_PID=$! -_PORT=$(lsof -i -P -n | grep "$_SERVER_PID" | grep LISTEN | awk '{print $9}' | cut -d: -f2 | head -1) +_PORT=$(lsof -i -P -n | grep "$_SERVER_PID" | grep LISTEN | awk '{print $(9)}' | cut -d: -f2 | head -1) echo "SERVER: http://localhost:$_PORT/finalized.html" echo "PID: $_SERVER_PID"
diff --git a/gstack-upgrade/SKILL.md.tmpl b/gstack-upgrade/SKILL.md.tmpl
index 84fd586..3032c48 100644
--- a/gstack-upgrade/SKILL.md.tmpl
+++ b/gstack-upgrade/SKILL.md.tmpl
@@ -59,9 +59,9 @@ _SNOOZE_FILE="$HOME/.gstack/update-snoozed"
_REMOTE_VER="{new}"
_CUR_LEVEL=0
if [ -f "$_SNOOZE_FILE" ]; then
- _SNOOZED_VER=$(awk '{print $1}' "$_SNOOZE_FILE")
+ _SNOOZED_VER=$(awk '{print $(1)}' "$_SNOOZE_FILE")
if [ "$_SNOOZED_VER" = "$_REMOTE_VER" ]; then
- _CUR_LEVEL=$(awk '{print $2}' "$_SNOOZE_FILE")
+ _CUR_LEVEL=$(awk '{print $(2)}' "$_SNOOZE_FILE")
case "$_CUR_LEVEL" in *[!0-9]*) _CUR_LEVEL=0 ;; esac
fi
fi
diff --git a/health/SKILL.md.tmpl b/health/SKILL.md.tmpl
index a608005..0bca084 100644
--- a/health/SKILL.md.tmpl
+++ b/health/SKILL.md.tmpl
@@ -124,7 +124,7 @@ Run each detected tool. For each tool:
(
umask 077
health_capture_error() {
- printf 'ERROR:typecheck CAPTURE:%s\n' "$1" >&2
+ printf 'ERROR:typecheck CAPTURE:%s\n' "${1}" >&2
exit 125
}
health_log=$(mktemp "${TMPDIR:-/tmp}/gstack-health.XXXXXX") || health_capture_error log_creation
diff --git a/scripts/resolvers/browse.ts b/scripts/resolvers/browse.ts
index 8ad1b13..28df445 100644
--- a/scripts/resolvers/browse.ts
+++ b/scripts/resolvers/browse.ts
@@ -140,10 +140,12 @@ If \`NEEDS_SETUP\`:
curl -fsSL "https://bun.sh/install" -o "$tmpfile"
# shasum is macOS/perl; coreutils-only Linux ships sha256sum instead —
# resolve whichever exists so the verify never fails on a missing tool.
+ # awk field is written $(1): Claude Code replaces a dollar-digit token in a
+ # SKILL.md body with the skill's argument words before the model reads it.
if command -v sha256sum >/dev/null 2>&1; then
- actual_sha=$(sha256sum "$tmpfile" | awk '{print $1}')
+ actual_sha=$(sha256sum "$tmpfile" | awk '{print $(1)}')
else
- actual_sha=$(shasum -a 256 "$tmpfile" | awk '{print $1}')
+ actual_sha=$(shasum -a 256 "$tmpfile" | awk '{print $(1)}')
fi
if [ "$actual_sha" != "$BUN_INSTALL_SHA" ]; then
echo "ERROR: bun install script checksum mismatch" >&2A check in bun run test that fails when a generated Claude SKILL.md matches /\$\d+(?!\w)/ would keep this from coming back; on main today the lines above are the only matches.
How I checked: the delivered lines come from running Claude Code's substitution rule over the generated files at a6b3a57 (the same rule reproduces, word for word, what the Skill tool delivered live for another skill on my machine). On my install (v1.76.0.0, where browse and design-review still carry the checksum block) I applied the same change to the generated files and confirmed that awk '{print $(1)}' returns the same hash as $1 with both sha256sum and shasum -a 256 (macOS awk). I haven't run gen:skill-docs or bun run test here. Happy to turn this into a PR if that's easier for you.
Drafted with Claude Code (Opus 5); reviewed by me.
Source: garrytan/gstack