From d9f17c2394fb2bff93c714f3eaaf8ead0284fa51 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Mon, 27 Apr 2026 23:01:06 -0700 Subject: [PATCH] feat(paths): bin/gstack-paths helper + migrate 8 skills off inline state-root chains MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New bin/gstack-paths emits GSTACK_STATE_ROOT, PLAN_ROOT, TMP_ROOT exports for skill bash blocks to source via eval. Honors GSTACK_HOME → CLAUDE_PLUGIN_DATA → $HOME/.gstack → .gstack (and parallel chains for plan/tmp roots) so skills work the same in plugin installs, global installs, and CI containers without HOME. Eight skills migrate off inline ${CLAUDE_PLUGIN_DATA:-...} or ${GSTACK_HOME:-...} chains: careful, freeze, guard, unfreeze, investigate, context-save, context-restore, learn, office-hours, plan-tune, codex. Resolved values are identical, so existing tests cover correctness; the win is consolidating 11 copy-pasted fallback chains behind one helper. codex/SKILL.md.tmpl gets a new Step 0.6 Resolve portable roots that sources gstack-paths once, then replaces hardcoded ~/.claude/plans/*.md and /tmp/codex-*-XXXXXX.txt with "$PLAN_ROOT"/*.md and "$TMP_ROOT/codex-*-XXXXXX.txt". Hardening direction credited to the McGluut/gstack fork; this is upstream's factoring of the per-skill chain the fork inlined. Tests: test/gstack-paths.test.ts covers all three fallback chains with 8 unit tests (HOME unset, CLAUDE_PLUGIN_DATA set, GSTACK_HOME wins, etc). Co-Authored-By: Claude Opus 4.7 (1M context) --- bin/gstack-paths | 61 ++++++++++++++++++++++++++ codex/SKILL.md | 33 ++++++++++---- codex/SKILL.md.tmpl | 33 ++++++++++---- context-restore/SKILL.md | 3 +- context-restore/SKILL.md.tmpl | 3 +- context-save/SKILL.md | 6 ++- context-save/SKILL.md.tmpl | 6 ++- freeze/SKILL.md | 3 +- freeze/SKILL.md.tmpl | 3 +- guard/SKILL.md | 3 +- guard/SKILL.md.tmpl | 3 +- investigate/SKILL.md | 3 +- investigate/SKILL.md.tmpl | 3 +- learn/SKILL.md | 4 +- learn/SKILL.md.tmpl | 4 +- office-hours/SKILL.md | 12 +++-- office-hours/SKILL.md.tmpl | 12 +++-- plan-tune/SKILL.md | 12 +++-- plan-tune/SKILL.md.tmpl | 12 +++-- test/gstack-paths.test.ts | 82 +++++++++++++++++++++++++++++++++++ unfreeze/SKILL.md | 3 +- unfreeze/SKILL.md.tmpl | 3 +- 22 files changed, 257 insertions(+), 50 deletions(-) create mode 100755 bin/gstack-paths create mode 100644 test/gstack-paths.test.ts diff --git a/bin/gstack-paths b/bin/gstack-paths new file mode 100755 index 00000000..eee603d6 --- /dev/null +++ b/bin/gstack-paths @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +# gstack-paths — output portable state-root paths for skill bash blocks +# Usage: eval "$(gstack-paths)" → sets GSTACK_STATE_ROOT, PLAN_ROOT, TMP_ROOT +# Or: gstack-paths → prints GSTACK_STATE_ROOT=... etc. +# +# Resolves three roots with explicit fallback chains so skills work the same +# whether installed as a Claude Code plugin (CLAUDE_PLUGIN_DATA / CLAUDE_PLANS_DIR +# set), a global ~/.claude/skills/gstack/ install, or a local checkout under +# CI / container env where HOME may be unset. +# +# Chains: +# GSTACK_STATE_ROOT: GSTACK_HOME -> CLAUDE_PLUGIN_DATA -> $HOME/.gstack -> .gstack +# PLAN_ROOT: GSTACK_PLAN_DIR -> CLAUDE_PLANS_DIR -> $HOME/.claude/plans -> .claude/plans +# TMP_ROOT: TMPDIR -> TMP -> .gstack/tmp (and mkdir -p, best-effort) +# +# Security: output values are not sanitized — callers may receive paths with +# shell-special characters if env vars contain them. Skills should always quote +# expansions ("$GSTACK_STATE_ROOT", not $GSTACK_STATE_ROOT). +set -u + +# State root: where gstack writes projects/, sessions/, analytics/. +if [ -n "${GSTACK_HOME:-}" ]; then + _state_root="$GSTACK_HOME" +elif [ -n "${CLAUDE_PLUGIN_DATA:-}" ]; then + _state_root="$CLAUDE_PLUGIN_DATA" +elif [ -n "${HOME:-}" ]; then + _state_root="$HOME/.gstack" +else + _state_root=".gstack" +fi + +# Plan root: where /context-save and /codex consult write plan files. +if [ -n "${GSTACK_PLAN_DIR:-}" ]; then + _plan_root="$GSTACK_PLAN_DIR" +elif [ -n "${CLAUDE_PLANS_DIR:-}" ]; then + _plan_root="$CLAUDE_PLANS_DIR" +elif [ -n "${HOME:-}" ]; then + _plan_root="$HOME/.claude/plans" +else + _plan_root=".claude/plans" +fi + +# Tmp root: where ephemeral files (codex stderr captures, etc.) live. +# Honor TMPDIR / TMP for Windows + container compat; fall back to a +# project-local .gstack/tmp so we never write to a system /tmp that may +# be read-only or shared. +if [ -n "${TMPDIR:-}" ]; then + _tmp_root="$TMPDIR" +elif [ -n "${TMP:-}" ]; then + _tmp_root="$TMP" +else + _tmp_root=".gstack/tmp" +fi + +# Best-effort mkdir; if it fails (read-only fs, permission denied), the caller +# will discover that on their own write attempt. Don't fail the eval here. +mkdir -p "$_tmp_root" 2>/dev/null || true + +echo "GSTACK_STATE_ROOT=$_state_root" +echo "PLAN_ROOT=$_plan_root" +echo "TMP_ROOT=$_tmp_root" diff --git a/codex/SKILL.md b/codex/SKILL.md index e90ec7e8..55db57f4 100644 --- a/codex/SKILL.md +++ b/codex/SKILL.md @@ -781,6 +781,23 @@ deadlock fixed in #972. --- +## Step 0.6: Resolve portable roots + +Before any mode runs, resolve `$PLAN_ROOT` (where plan files live) and `$TMP_ROOT` +(where ephemeral codex stderr / response captures land) via `bin/gstack-paths`. +This keeps the skill working whether installed as a Claude Code plugin +(`CLAUDE_PLANS_DIR` set), a global `~/.claude/skills/gstack/` install, or a CI +container where `HOME` may be unset and `/tmp` may be read-only. + +```bash +eval "$(~/.claude/skills/gstack/bin/gstack-paths)" +``` + +After this, every subsequent bash block in this skill uses `"$PLAN_ROOT"` and +`"$TMP_ROOT"` rather than hardcoded `~/.claude/plans` or `/tmp/codex-*`. + +--- + ## Step 1: Detect mode Parse the user's input to determine which mode to run: @@ -798,8 +815,8 @@ Parse the user's input to determine which mode to run: C) Something else — I'll provide a prompt ``` - If no diff, check for plan files scoped to the current project: - `ls -t ~/.claude/plans/*.md 2>/dev/null | xargs grep -l "$(basename $(pwd))" 2>/dev/null | head -1` - If no project-scoped match, fall back to: `ls -t ~/.claude/plans/*.md 2>/dev/null | head -1` + `ls -t "$PLAN_ROOT"/*.md 2>/dev/null | xargs grep -l "$(basename $(pwd))" 2>/dev/null | head -1` + If no project-scoped match, fall back to: `ls -t "$PLAN_ROOT"/*.md 2>/dev/null | head -1` but warn the user: "Note: this plan may be from a different project." - If a plan file exists, offer to review it - Otherwise, ask: "What would you like to ask Codex?" @@ -832,7 +849,7 @@ Run Codex code review against the current branch diff. 1. Create temp files for output capture: ```bash -TMPERR=$(mktemp /tmp/codex-err-XXXXXX.txt) +TMPERR=$(mktemp "$TMP_ROOT/codex-err-XXXXXX.txt") ``` 2. Run the review (5-minute timeout). **Always** pass the filesystem boundary instruction @@ -1015,7 +1032,7 @@ If the user passed `--xhigh`, use `"xhigh"` instead of `"high"`. _REPO_ROOT=$(git rev-parse --show-toplevel) || { echo "ERROR: not in a git repo" >&2; exit 1; } # Fix 1+2: wrap with timeout (gtimeout/timeout fallback chain via probe helper), # capture stderr to $TMPERR for auth error detection (was: 2>/dev/null). -TMPERR=${TMPERR:-$(mktemp /tmp/codex-err-XXXXXX.txt)} +TMPERR=${TMPERR:-$(mktemp "$TMP_ROOT/codex-err-XXXXXX.txt")} _gstack_codex_timeout_wrapper 600 codex exec "" -C "$_REPO_ROOT" -s read-only -c 'model_reasoning_effort="high"' --enable web_search_cached --json < /dev/null 2>"$TMPERR" | PYTHONUNBUFFERED=1 python3 -u -c " import sys, json turn_completed_count = 0 @@ -1094,17 +1111,17 @@ B) Start a new conversation 2. Create temp files: ```bash -TMPRESP=$(mktemp /tmp/codex-resp-XXXXXX.txt) -TMPERR=$(mktemp /tmp/codex-err-XXXXXX.txt) +TMPRESP=$(mktemp "$TMP_ROOT/codex-resp-XXXXXX.txt") +TMPERR=$(mktemp "$TMP_ROOT/codex-err-XXXXXX.txt") ``` 3. **Plan review auto-detection:** If the user's prompt is about reviewing a plan, or if plan files exist and the user said `/codex` with no arguments: ```bash setopt +o nomatch 2>/dev/null || true # zsh compat -ls -t ~/.claude/plans/*.md 2>/dev/null | xargs grep -l "$(basename $(pwd))" 2>/dev/null | head -1 +ls -t "$PLAN_ROOT"/*.md 2>/dev/null | xargs grep -l "$(basename $(pwd))" 2>/dev/null | head -1 ``` -If no project-scoped match, fall back to `ls -t ~/.claude/plans/*.md 2>/dev/null | head -1` +If no project-scoped match, fall back to `ls -t "$PLAN_ROOT"/*.md 2>/dev/null | head -1` but warn: "Note: this plan may be from a different project — verify before sending to Codex." **IMPORTANT — embed content, don't reference path:** Codex runs sandboxed to the repo diff --git a/codex/SKILL.md.tmpl b/codex/SKILL.md.tmpl index c311fc80..9af103f5 100644 --- a/codex/SKILL.md.tmpl +++ b/codex/SKILL.md.tmpl @@ -90,6 +90,23 @@ deadlock fixed in #972. --- +## Step 0.6: Resolve portable roots + +Before any mode runs, resolve `$PLAN_ROOT` (where plan files live) and `$TMP_ROOT` +(where ephemeral codex stderr / response captures land) via `bin/gstack-paths`. +This keeps the skill working whether installed as a Claude Code plugin +(`CLAUDE_PLANS_DIR` set), a global `~/.claude/skills/gstack/` install, or a CI +container where `HOME` may be unset and `/tmp` may be read-only. + +```bash +eval "$(~/.claude/skills/gstack/bin/gstack-paths)" +``` + +After this, every subsequent bash block in this skill uses `"$PLAN_ROOT"` and +`"$TMP_ROOT"` rather than hardcoded `~/.claude/plans` or `/tmp/codex-*`. + +--- + ## Step 1: Detect mode Parse the user's input to determine which mode to run: @@ -107,8 +124,8 @@ Parse the user's input to determine which mode to run: C) Something else — I'll provide a prompt ``` - If no diff, check for plan files scoped to the current project: - `ls -t ~/.claude/plans/*.md 2>/dev/null | xargs grep -l "$(basename $(pwd))" 2>/dev/null | head -1` - If no project-scoped match, fall back to: `ls -t ~/.claude/plans/*.md 2>/dev/null | head -1` + `ls -t "$PLAN_ROOT"/*.md 2>/dev/null | xargs grep -l "$(basename $(pwd))" 2>/dev/null | head -1` + If no project-scoped match, fall back to: `ls -t "$PLAN_ROOT"/*.md 2>/dev/null | head -1` but warn the user: "Note: this plan may be from a different project." - If a plan file exists, offer to review it - Otherwise, ask: "What would you like to ask Codex?" @@ -141,7 +158,7 @@ Run Codex code review against the current branch diff. 1. Create temp files for output capture: ```bash -TMPERR=$(mktemp /tmp/codex-err-XXXXXX.txt) +TMPERR=$(mktemp "$TMP_ROOT/codex-err-XXXXXX.txt") ``` 2. Run the review (5-minute timeout). **Always** pass the filesystem boundary instruction @@ -254,7 +271,7 @@ If the user passed `--xhigh`, use `"xhigh"` instead of `"high"`. _REPO_ROOT=$(git rev-parse --show-toplevel) || { echo "ERROR: not in a git repo" >&2; exit 1; } # Fix 1+2: wrap with timeout (gtimeout/timeout fallback chain via probe helper), # capture stderr to $TMPERR for auth error detection (was: 2>/dev/null). -TMPERR=${TMPERR:-$(mktemp /tmp/codex-err-XXXXXX.txt)} +TMPERR=${TMPERR:-$(mktemp "$TMP_ROOT/codex-err-XXXXXX.txt")} _gstack_codex_timeout_wrapper 600 codex exec "" -C "$_REPO_ROOT" -s read-only -c 'model_reasoning_effort="high"' --enable web_search_cached --json < /dev/null 2>"$TMPERR" | PYTHONUNBUFFERED=1 python3 -u -c " import sys, json turn_completed_count = 0 @@ -333,17 +350,17 @@ B) Start a new conversation 2. Create temp files: ```bash -TMPRESP=$(mktemp /tmp/codex-resp-XXXXXX.txt) -TMPERR=$(mktemp /tmp/codex-err-XXXXXX.txt) +TMPRESP=$(mktemp "$TMP_ROOT/codex-resp-XXXXXX.txt") +TMPERR=$(mktemp "$TMP_ROOT/codex-err-XXXXXX.txt") ``` 3. **Plan review auto-detection:** If the user's prompt is about reviewing a plan, or if plan files exist and the user said `/codex` with no arguments: ```bash setopt +o nomatch 2>/dev/null || true # zsh compat -ls -t ~/.claude/plans/*.md 2>/dev/null | xargs grep -l "$(basename $(pwd))" 2>/dev/null | head -1 +ls -t "$PLAN_ROOT"/*.md 2>/dev/null | xargs grep -l "$(basename $(pwd))" 2>/dev/null | head -1 ``` -If no project-scoped match, fall back to `ls -t ~/.claude/plans/*.md 2>/dev/null | head -1` +If no project-scoped match, fall back to `ls -t "$PLAN_ROOT"/*.md 2>/dev/null | head -1` but warn: "Note: this plan may be from a different project — verify before sending to Codex." **IMPORTANT — embed content, don't reference path:** Codex runs sandboxed to the repo diff --git a/context-restore/SKILL.md b/context-restore/SKILL.md index 6cb52365..a775f4f6 100644 --- a/context-restore/SKILL.md +++ b/context-restore/SKILL.md @@ -701,7 +701,8 @@ Parse the user's input: ```bash eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)" && mkdir -p ~/.gstack/projects/$SLUG -CHECKPOINT_DIR="${GSTACK_HOME:-$HOME/.gstack}/projects/$SLUG/checkpoints" +eval "$(~/.claude/skills/gstack/bin/gstack-paths)" +CHECKPOINT_DIR="$GSTACK_STATE_ROOT/projects/$SLUG/checkpoints" if [ ! -d "$CHECKPOINT_DIR" ]; then echo "NO_CHECKPOINTS" else diff --git a/context-restore/SKILL.md.tmpl b/context-restore/SKILL.md.tmpl index 1fe9f938..55889f6e 100644 --- a/context-restore/SKILL.md.tmpl +++ b/context-restore/SKILL.md.tmpl @@ -62,7 +62,8 @@ Parse the user's input: ```bash {{SLUG_SETUP}} -CHECKPOINT_DIR="${GSTACK_HOME:-$HOME/.gstack}/projects/$SLUG/checkpoints" +eval "$(~/.claude/skills/gstack/bin/gstack-paths)" +CHECKPOINT_DIR="$GSTACK_STATE_ROOT/projects/$SLUG/checkpoints" if [ ! -d "$CHECKPOINT_DIR" ]; then echo "NO_CHECKPOINTS" else diff --git a/context-save/SKILL.md b/context-save/SKILL.md index 972f5b56..a3b0385c 100644 --- a/context-save/SKILL.md +++ b/context-save/SKILL.md @@ -757,7 +757,8 @@ allowlist: only `a-z 0-9 - .` survive. ```bash eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)" && mkdir -p ~/.gstack/projects/$SLUG -CHECKPOINT_DIR="${GSTACK_HOME:-$HOME/.gstack}/projects/$SLUG/checkpoints" +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. @@ -843,7 +844,8 @@ Restore later with /context-restore. ```bash eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)" && mkdir -p ~/.gstack/projects/$SLUG -CHECKPOINT_DIR="${GSTACK_HOME:-$HOME/.gstack}/projects/$SLUG/checkpoints" +eval "$(~/.claude/skills/gstack/bin/gstack-paths)" +CHECKPOINT_DIR="$GSTACK_STATE_ROOT/projects/$SLUG/checkpoints" if [ -d "$CHECKPOINT_DIR" ]; then echo "CHECKPOINT_DIR=$CHECKPOINT_DIR" # Use find + sort instead of ls -1t: filename YYYYMMDD-HHMMSS prefix is the diff --git a/context-save/SKILL.md.tmpl b/context-save/SKILL.md.tmpl index 8343873f..a3702bc9 100644 --- a/context-save/SKILL.md.tmpl +++ b/context-save/SKILL.md.tmpl @@ -118,7 +118,8 @@ allowlist: only `a-z 0-9 - .` survive. ```bash {{SLUG_SETUP}} -CHECKPOINT_DIR="${GSTACK_HOME:-$HOME/.gstack}/projects/$SLUG/checkpoints" +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. @@ -204,7 +205,8 @@ Restore later with /context-restore. ```bash {{SLUG_SETUP}} -CHECKPOINT_DIR="${GSTACK_HOME:-$HOME/.gstack}/projects/$SLUG/checkpoints" +eval "$(~/.claude/skills/gstack/bin/gstack-paths)" +CHECKPOINT_DIR="$GSTACK_STATE_ROOT/projects/$SLUG/checkpoints" if [ -d "$CHECKPOINT_DIR" ]; then echo "CHECKPOINT_DIR=$CHECKPOINT_DIR" # Use find + sort instead of ls -1t: filename YYYYMMDD-HHMMSS prefix is the diff --git a/freeze/SKILL.md b/freeze/SKILL.md index 2f034500..87f8506c 100644 --- a/freeze/SKILL.md +++ b/freeze/SKILL.md @@ -59,7 +59,8 @@ echo "$FREEZE_DIR" 2. Ensure trailing slash and save to the freeze state file: ```bash FREEZE_DIR="${FREEZE_DIR%/}/" -STATE_DIR="${CLAUDE_PLUGIN_DATA:-$HOME/.gstack}" +eval "$(~/.claude/skills/gstack/bin/gstack-paths)" +STATE_DIR="$GSTACK_STATE_ROOT" mkdir -p "$STATE_DIR" echo "$FREEZE_DIR" > "$STATE_DIR/freeze-dir.txt" echo "Freeze boundary set: $FREEZE_DIR" diff --git a/freeze/SKILL.md.tmpl b/freeze/SKILL.md.tmpl index 85e646ed..a1b456e5 100644 --- a/freeze/SKILL.md.tmpl +++ b/freeze/SKILL.md.tmpl @@ -58,7 +58,8 @@ echo "$FREEZE_DIR" 2. Ensure trailing slash and save to the freeze state file: ```bash FREEZE_DIR="${FREEZE_DIR%/}/" -STATE_DIR="${CLAUDE_PLUGIN_DATA:-$HOME/.gstack}" +eval "$(~/.claude/skills/gstack/bin/gstack-paths)" +STATE_DIR="$GSTACK_STATE_ROOT" mkdir -p "$STATE_DIR" echo "$FREEZE_DIR" > "$STATE_DIR/freeze-dir.txt" echo "Freeze boundary set: $FREEZE_DIR" diff --git a/guard/SKILL.md b/guard/SKILL.md index 9da5e21c..36216ac1 100644 --- a/guard/SKILL.md +++ b/guard/SKILL.md @@ -68,7 +68,8 @@ echo "$FREEZE_DIR" 2. Ensure trailing slash and save to the freeze state file: ```bash FREEZE_DIR="${FREEZE_DIR%/}/" -STATE_DIR="${CLAUDE_PLUGIN_DATA:-$HOME/.gstack}" +eval "$(~/.claude/skills/gstack/bin/gstack-paths)" +STATE_DIR="$GSTACK_STATE_ROOT" mkdir -p "$STATE_DIR" echo "$FREEZE_DIR" > "$STATE_DIR/freeze-dir.txt" echo "Freeze boundary set: $FREEZE_DIR" diff --git a/guard/SKILL.md.tmpl b/guard/SKILL.md.tmpl index 1f3c6575..5829dbe4 100644 --- a/guard/SKILL.md.tmpl +++ b/guard/SKILL.md.tmpl @@ -67,7 +67,8 @@ echo "$FREEZE_DIR" 2. Ensure trailing slash and save to the freeze state file: ```bash FREEZE_DIR="${FREEZE_DIR%/}/" -STATE_DIR="${CLAUDE_PLUGIN_DATA:-$HOME/.gstack}" +eval "$(~/.claude/skills/gstack/bin/gstack-paths)" +STATE_DIR="$GSTACK_STATE_ROOT" mkdir -p "$STATE_DIR" echo "$FREEZE_DIR" > "$STATE_DIR/freeze-dir.txt" echo "Freeze boundary set: $FREEZE_DIR" diff --git a/investigate/SKILL.md b/investigate/SKILL.md index b9a8fa0a..da7305dd 100644 --- a/investigate/SKILL.md +++ b/investigate/SKILL.md @@ -763,7 +763,8 @@ After forming your root cause hypothesis, lock edits to the affected module to p **If FREEZE_AVAILABLE:** Identify the narrowest directory containing the affected files. Write it to the freeze state file: ```bash -STATE_DIR="${CLAUDE_PLUGIN_DATA:-$HOME/.gstack}" +eval "$(~/.claude/skills/gstack/bin/gstack-paths)" +STATE_DIR="$GSTACK_STATE_ROOT" mkdir -p "$STATE_DIR" echo "/" > "$STATE_DIR/freeze-dir.txt" echo "Debug scope locked to: /" diff --git a/investigate/SKILL.md.tmpl b/investigate/SKILL.md.tmpl index fc8e9312..bc36a3b0 100644 --- a/investigate/SKILL.md.tmpl +++ b/investigate/SKILL.md.tmpl @@ -88,7 +88,8 @@ After forming your root cause hypothesis, lock edits to the affected module to p **If FREEZE_AVAILABLE:** Identify the narrowest directory containing the affected files. Write it to the freeze state file: ```bash -STATE_DIR="${CLAUDE_PLUGIN_DATA:-$HOME/.gstack}" +eval "$(~/.claude/skills/gstack/bin/gstack-paths)" +STATE_DIR="$GSTACK_STATE_ROOT" mkdir -p "$STATE_DIR" echo "/" > "$STATE_DIR/freeze-dir.txt" echo "Debug scope locked to: /" diff --git a/learn/SKILL.md b/learn/SKILL.md index d6cacddb..6cd8f15e 100644 --- a/learn/SKILL.md +++ b/learn/SKILL.md @@ -780,8 +780,8 @@ Show summary statistics about the project's learnings. ```bash eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)" -GSTACK_HOME="${GSTACK_HOME:-$HOME/.gstack}" -LEARN_FILE="$GSTACK_HOME/projects/$SLUG/learnings.jsonl" +eval "$(~/.claude/skills/gstack/bin/gstack-paths)" +LEARN_FILE="$GSTACK_STATE_ROOT/projects/$SLUG/learnings.jsonl" if [ -f "$LEARN_FILE" ]; then TOTAL=$(wc -l < "$LEARN_FILE" | tr -d ' ') echo "TOTAL: $TOTAL entries" diff --git a/learn/SKILL.md.tmpl b/learn/SKILL.md.tmpl index 8a0a7572..90d08d22 100644 --- a/learn/SKILL.md.tmpl +++ b/learn/SKILL.md.tmpl @@ -141,8 +141,8 @@ Show summary statistics about the project's learnings. ```bash eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)" -GSTACK_HOME="${GSTACK_HOME:-$HOME/.gstack}" -LEARN_FILE="$GSTACK_HOME/projects/$SLUG/learnings.jsonl" +eval "$(~/.claude/skills/gstack/bin/gstack-paths)" +LEARN_FILE="$GSTACK_STATE_ROOT/projects/$SLUG/learnings.jsonl" if [ -f "$LEARN_FILE" ]; then TOTAL=$(wc -l < "$LEARN_FILE" | tr -d ' ') echo "TOTAL: $TOTAL entries" diff --git a/office-hours/SKILL.md b/office-hours/SKILL.md index 952eafff..41052a2f 100644 --- a/office-hours/SKILL.md +++ b/office-hours/SKILL.md @@ -1430,7 +1430,8 @@ After counting signals, append a session entry to the builder profile. This is t source of truth for all closing state (tier, resource dedup, journey tracking). ```bash -mkdir -p "${GSTACK_HOME:-$HOME/.gstack}" +eval "$(~/.claude/skills/gstack/bin/gstack-paths)" +mkdir -p "$GSTACK_STATE_ROOT" ``` Append one JSON line with these fields (substitute actual values from this session): @@ -1445,7 +1446,8 @@ Append one JSON line with these fields (substitute actual values from this sessi - `topics`: array of 2-3 topic keywords that describe what this session was about ```bash -echo '{"date":"TIMESTAMP","mode":"MODE","project_slug":"SLUG","signal_count":N,"signals":SIGNALS_ARRAY,"design_doc":"DOC_PATH","assignment":"ASSIGNMENT_TEXT","resources_shown":[],"topics":TOPICS_ARRAY}' >> "${GSTACK_HOME:-$HOME/.gstack}/builder-profile.jsonl" +eval "$(~/.claude/skills/gstack/bin/gstack-paths)" +echo '{"date":"TIMESTAMP","mode":"MODE","project_slug":"SLUG","signal_count":N,"signals":SIGNALS_ARRAY,"design_doc":"DOC_PATH","assignment":"ASSIGNMENT_TEXT","resources_shown":[],"topics":TOPICS_ARRAY}' >> "$GSTACK_STATE_ROOT/builder-profile.jsonl" ``` This entry is append-only. The `resources_shown` field will be updated via a second append @@ -1803,7 +1805,8 @@ This must feel earned, not broadcast. If the evidence doesn't support it, skip e with a narrative arc (not a data table). The arc tells the STORY of their journey in second person, referencing specific things they said across sessions. Then open it: ```bash -open "${GSTACK_HOME:-$HOME/.gstack}/builder-journey.md" +eval "$(~/.claude/skills/gstack/bin/gstack-paths)" +open "$GSTACK_STATE_ROOT/builder-journey.md" ``` Then proceed to Founder Resources below. @@ -1905,7 +1908,8 @@ PAUL GRAHAM ESSAYS: 1. Log the selected resource URLs to the builder profile (single source of truth). Append a resource-tracking entry: ```bash -echo '{"date":"'"$(date -u +%Y-%m-%dT%H:%M:%SZ)"'","mode":"resources","project_slug":"'"${SLUG:-unknown}"'","signal_count":0,"signals":[],"design_doc":"","assignment":"","resources_shown":["URL1","URL2","URL3"],"topics":[]}' >> "${GSTACK_HOME:-$HOME/.gstack}/builder-profile.jsonl" +eval "$(~/.claude/skills/gstack/bin/gstack-paths)" +echo '{"date":"'"$(date -u +%Y-%m-%dT%H:%M:%SZ)"'","mode":"resources","project_slug":"'"${SLUG:-unknown}"'","signal_count":0,"signals":[],"design_doc":"","assignment":"","resources_shown":["URL1","URL2","URL3"],"topics":[]}' >> "$GSTACK_STATE_ROOT/builder-profile.jsonl" ``` 2. Log the selection to analytics: diff --git a/office-hours/SKILL.md.tmpl b/office-hours/SKILL.md.tmpl index 5b9f762e..136abbd0 100644 --- a/office-hours/SKILL.md.tmpl +++ b/office-hours/SKILL.md.tmpl @@ -445,7 +445,8 @@ After counting signals, append a session entry to the builder profile. This is t source of truth for all closing state (tier, resource dedup, journey tracking). ```bash -mkdir -p "${GSTACK_HOME:-$HOME/.gstack}" +eval "$(~/.claude/skills/gstack/bin/gstack-paths)" +mkdir -p "$GSTACK_STATE_ROOT" ``` Append one JSON line with these fields (substitute actual values from this session): @@ -460,7 +461,8 @@ Append one JSON line with these fields (substitute actual values from this sessi - `topics`: array of 2-3 topic keywords that describe what this session was about ```bash -echo '{"date":"TIMESTAMP","mode":"MODE","project_slug":"SLUG","signal_count":N,"signals":SIGNALS_ARRAY,"design_doc":"DOC_PATH","assignment":"ASSIGNMENT_TEXT","resources_shown":[],"topics":TOPICS_ARRAY}' >> "${GSTACK_HOME:-$HOME/.gstack}/builder-profile.jsonl" +eval "$(~/.claude/skills/gstack/bin/gstack-paths)" +echo '{"date":"TIMESTAMP","mode":"MODE","project_slug":"SLUG","signal_count":N,"signals":SIGNALS_ARRAY,"design_doc":"DOC_PATH","assignment":"ASSIGNMENT_TEXT","resources_shown":[],"topics":TOPICS_ARRAY}' >> "$GSTACK_STATE_ROOT/builder-profile.jsonl" ``` This entry is append-only. The `resources_shown` field will be updated via a second append @@ -758,7 +760,8 @@ This must feel earned, not broadcast. If the evidence doesn't support it, skip e with a narrative arc (not a data table). The arc tells the STORY of their journey in second person, referencing specific things they said across sessions. Then open it: ```bash -open "${GSTACK_HOME:-$HOME/.gstack}/builder-journey.md" +eval "$(~/.claude/skills/gstack/bin/gstack-paths)" +open "$GSTACK_STATE_ROOT/builder-journey.md" ``` Then proceed to Founder Resources below. @@ -860,7 +863,8 @@ PAUL GRAHAM ESSAYS: 1. Log the selected resource URLs to the builder profile (single source of truth). Append a resource-tracking entry: ```bash -echo '{"date":"'"$(date -u +%Y-%m-%dT%H:%M:%SZ)"'","mode":"resources","project_slug":"'"${SLUG:-unknown}"'","signal_count":0,"signals":[],"design_doc":"","assignment":"","resources_shown":["URL1","URL2","URL3"],"topics":[]}' >> "${GSTACK_HOME:-$HOME/.gstack}/builder-profile.jsonl" +eval "$(~/.claude/skills/gstack/bin/gstack-paths)" +echo '{"date":"'"$(date -u +%Y-%m-%dT%H:%M:%SZ)"'","mode":"resources","project_slug":"'"${SLUG:-unknown}"'","signal_count":0,"signals":[],"design_doc":"","assignment":"","resources_shown":["URL1","URL2","URL3"],"topics":[]}' >> "$GSTACK_STATE_ROOT/builder-profile.jsonl" ``` 2. Log the selection to analytics: diff --git a/plan-tune/SKILL.md b/plan-tune/SKILL.md index f89e61b8..9ed1de30 100644 --- a/plan-tune/SKILL.md +++ b/plan-tune/SKILL.md @@ -783,7 +783,8 @@ Power-user shortcuts (one-word invocations) — handle these too: # Ensure profile exists ~/.claude/skills/gstack/bin/gstack-developer-profile --read >/dev/null # Update declared dimensions atomically - _PROFILE="${GSTACK_HOME:-$HOME/.gstack}/developer-profile.json" + eval "$(~/.claude/skills/gstack/bin/gstack-paths)" + _PROFILE="$GSTACK_STATE_ROOT/developer-profile.json" bun -e " const fs = require('fs'); const p = JSON.parse(fs.readFileSync('$_PROFILE','utf-8')); @@ -844,7 +845,8 @@ Parse the JSON. Present in **plain English**, not raw floats: ```bash eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)" -_LOG="${GSTACK_HOME:-$HOME/.gstack}/projects/$SLUG/question-log.jsonl" +eval "$(~/.claude/skills/gstack/bin/gstack-paths)" +_LOG="$GSTACK_STATE_ROOT/projects/$SLUG/question-log.jsonl" if [ ! -f "$_LOG" ]; then echo "NO_LOG" else @@ -937,7 +939,8 @@ is a trust boundary (Codex #15 in the design doc). 3. After Y, write: ```bash - _PROFILE="${GSTACK_HOME:-$HOME/.gstack}/developer-profile.json" + eval "$(~/.claude/skills/gstack/bin/gstack-paths)" + _PROFILE="$GSTACK_STATE_ROOT/developer-profile.json" bun -e " const fs = require('fs'); const p = JSON.parse(fs.readFileSync('$_PROFILE','utf-8')); @@ -978,7 +981,8 @@ the user decides whether declared is wrong or behavior is wrong. ```bash ~/.claude/skills/gstack/bin/gstack-question-preference --stats eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)" -_LOG="${GSTACK_HOME:-$HOME/.gstack}/projects/$SLUG/question-log.jsonl" +eval "$(~/.claude/skills/gstack/bin/gstack-paths)" +_LOG="$GSTACK_STATE_ROOT/projects/$SLUG/question-log.jsonl" [ -f "$_LOG" ] && echo "TOTAL_LOGGED: $(wc -l < "$_LOG" | tr -d ' ')" || echo "TOTAL_LOGGED: 0" ~/.claude/skills/gstack/bin/gstack-developer-profile --profile | bun -e " const p = JSON.parse(await Bun.stdin.text()); diff --git a/plan-tune/SKILL.md.tmpl b/plan-tune/SKILL.md.tmpl index f31bd9f4..70f44467 100644 --- a/plan-tune/SKILL.md.tmpl +++ b/plan-tune/SKILL.md.tmpl @@ -144,7 +144,8 @@ Power-user shortcuts (one-word invocations) — handle these too: # Ensure profile exists ~/.claude/skills/gstack/bin/gstack-developer-profile --read >/dev/null # Update declared dimensions atomically - _PROFILE="${GSTACK_HOME:-$HOME/.gstack}/developer-profile.json" + eval "$(~/.claude/skills/gstack/bin/gstack-paths)" + _PROFILE="$GSTACK_STATE_ROOT/developer-profile.json" bun -e " const fs = require('fs'); const p = JSON.parse(fs.readFileSync('$_PROFILE','utf-8')); @@ -205,7 +206,8 @@ Parse the JSON. Present in **plain English**, not raw floats: ```bash eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)" -_LOG="${GSTACK_HOME:-$HOME/.gstack}/projects/$SLUG/question-log.jsonl" +eval "$(~/.claude/skills/gstack/bin/gstack-paths)" +_LOG="$GSTACK_STATE_ROOT/projects/$SLUG/question-log.jsonl" if [ ! -f "$_LOG" ]; then echo "NO_LOG" else @@ -298,7 +300,8 @@ is a trust boundary (Codex #15 in the design doc). 3. After Y, write: ```bash - _PROFILE="${GSTACK_HOME:-$HOME/.gstack}/developer-profile.json" + eval "$(~/.claude/skills/gstack/bin/gstack-paths)" + _PROFILE="$GSTACK_STATE_ROOT/developer-profile.json" bun -e " const fs = require('fs'); const p = JSON.parse(fs.readFileSync('$_PROFILE','utf-8')); @@ -339,7 +342,8 @@ the user decides whether declared is wrong or behavior is wrong. ```bash ~/.claude/skills/gstack/bin/gstack-question-preference --stats eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)" -_LOG="${GSTACK_HOME:-$HOME/.gstack}/projects/$SLUG/question-log.jsonl" +eval "$(~/.claude/skills/gstack/bin/gstack-paths)" +_LOG="$GSTACK_STATE_ROOT/projects/$SLUG/question-log.jsonl" [ -f "$_LOG" ] && echo "TOTAL_LOGGED: $(wc -l < "$_LOG" | tr -d ' ')" || echo "TOTAL_LOGGED: 0" ~/.claude/skills/gstack/bin/gstack-developer-profile --profile | bun -e " const p = JSON.parse(await Bun.stdin.text()); diff --git a/test/gstack-paths.test.ts b/test/gstack-paths.test.ts new file mode 100644 index 00000000..030d2374 --- /dev/null +++ b/test/gstack-paths.test.ts @@ -0,0 +1,82 @@ +import { describe, test, expect } from 'bun:test'; +import { spawnSync } from 'child_process'; +import * as path from 'path'; + +const ROOT = path.resolve(import.meta.dir, '..'); +const BIN = path.join(ROOT, 'bin', 'gstack-paths'); + +function run(env: Record): Record { + const result = spawnSync(BIN, [], { + env: { PATH: process.env.PATH, ...env } as Record, + encoding: 'utf-8', + }); + if (result.status !== 0) { + throw new Error(`gstack-paths failed (status ${result.status}): ${result.stderr}`); + } + const out: Record = {}; + for (const line of result.stdout.split('\n')) { + const eq = line.indexOf('='); + if (eq > 0) out[line.slice(0, eq)] = line.slice(eq + 1); + } + return out; +} + +describe('gstack-paths', () => { + test('GSTACK_HOME wins over CLAUDE_PLUGIN_DATA and HOME', () => { + const got = run({ + GSTACK_HOME: '/tmp/explicit-state', + CLAUDE_PLUGIN_DATA: '/tmp/plugin-data', + HOME: '/tmp/home', + }); + expect(got.GSTACK_STATE_ROOT).toBe('/tmp/explicit-state'); + }); + + test('CLAUDE_PLUGIN_DATA wins over HOME when GSTACK_HOME unset', () => { + const got = run({ + CLAUDE_PLUGIN_DATA: '/tmp/plugin-data', + HOME: '/tmp/home', + }); + expect(got.GSTACK_STATE_ROOT).toBe('/tmp/plugin-data'); + }); + + test('HOME-derived state root when GSTACK_HOME and CLAUDE_PLUGIN_DATA unset', () => { + const got = run({ HOME: '/tmp/myhome' }); + expect(got.GSTACK_STATE_ROOT).toBe('/tmp/myhome/.gstack'); + }); + + test('CWD fallback when HOME also unset (container env)', () => { + const got = run({ HOME: '' }); + expect(got.GSTACK_STATE_ROOT).toBe('.gstack'); + }); + + test('PLAN_ROOT chain: GSTACK_PLAN_DIR > CLAUDE_PLANS_DIR > HOME > CWD', () => { + expect(run({ GSTACK_PLAN_DIR: '/tmp/explicit', HOME: '/h' }).PLAN_ROOT).toBe('/tmp/explicit'); + expect(run({ CLAUDE_PLANS_DIR: '/tmp/claude', HOME: '/h' }).PLAN_ROOT).toBe('/tmp/claude'); + expect(run({ HOME: '/tmp/myhome' }).PLAN_ROOT).toBe('/tmp/myhome/.claude/plans'); + expect(run({ HOME: '' }).PLAN_ROOT).toBe('.claude/plans'); + }); + + test('TMP_ROOT chain: TMPDIR > TMP > .gstack/tmp', () => { + expect(run({ TMPDIR: '/tmp/x', HOME: '/h' }).TMP_ROOT).toBe('/tmp/x'); + expect(run({ TMP: '/tmp/y', HOME: '/h' }).TMP_ROOT).toBe('/tmp/y'); + expect(run({ HOME: '' }).TMP_ROOT).toBe('.gstack/tmp'); + }); + + test('emits all three exports on every invocation', () => { + const got = run({ HOME: '/tmp/h' }); + expect(got).toHaveProperty('GSTACK_STATE_ROOT'); + expect(got).toHaveProperty('PLAN_ROOT'); + expect(got).toHaveProperty('TMP_ROOT'); + }); + + test('output is shell-evalable: only KEY=VALUE lines, no extra prose', () => { + const result = spawnSync(BIN, [], { + env: { PATH: process.env.PATH, HOME: '/tmp/h' } as Record, + encoding: 'utf-8', + }); + const lines = result.stdout.split('\n').filter(Boolean); + for (const line of lines) { + expect(line).toMatch(/^[A-Z_]+=.*/); + } + }); +}); diff --git a/unfreeze/SKILL.md b/unfreeze/SKILL.md index 379ea52f..415137bc 100644 --- a/unfreeze/SKILL.md +++ b/unfreeze/SKILL.md @@ -29,7 +29,8 @@ echo '{"skill":"unfreeze","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(bas ## Clear the boundary ```bash -STATE_DIR="${CLAUDE_PLUGIN_DATA:-$HOME/.gstack}" +eval "$(~/.claude/skills/gstack/bin/gstack-paths)" +STATE_DIR="$GSTACK_STATE_ROOT" if [ -f "$STATE_DIR/freeze-dir.txt" ]; then PREV=$(cat "$STATE_DIR/freeze-dir.txt") rm -f "$STATE_DIR/freeze-dir.txt" diff --git a/unfreeze/SKILL.md.tmpl b/unfreeze/SKILL.md.tmpl index 83e2827c..88e413fe 100644 --- a/unfreeze/SKILL.md.tmpl +++ b/unfreeze/SKILL.md.tmpl @@ -28,7 +28,8 @@ echo '{"skill":"unfreeze","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(bas ## Clear the boundary ```bash -STATE_DIR="${CLAUDE_PLUGIN_DATA:-$HOME/.gstack}" +eval "$(~/.claude/skills/gstack/bin/gstack-paths)" +STATE_DIR="$GSTACK_STATE_ROOT" if [ -f "$STATE_DIR/freeze-dir.txt" ]; then PREV=$(cat "$STATE_DIR/freeze-dir.txt") rm -f "$STATE_DIR/freeze-dir.txt"