mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-06 21:46:40 +02:00
feat(paths): bin/gstack-paths helper + migrate 8 skills off inline state-root chains
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) <noreply@anthropic.com>
This commit is contained in:
Executable
+61
@@ -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"
|
||||
+25
-8
@@ -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 "<prompt>" -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
|
||||
|
||||
+25
-8
@@ -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 "<prompt>" -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+2
-1
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
+2
-1
@@ -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"
|
||||
|
||||
+2
-1
@@ -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"
|
||||
|
||||
@@ -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 "<detected-directory>/" > "$STATE_DIR/freeze-dir.txt"
|
||||
echo "Debug scope locked to: <detected-directory>/"
|
||||
|
||||
@@ -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 "<detected-directory>/" > "$STATE_DIR/freeze-dir.txt"
|
||||
echo "Debug scope locked to: <detected-directory>/"
|
||||
|
||||
+2
-2
@@ -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"
|
||||
|
||||
+2
-2
@@ -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"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
+8
-4
@@ -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());
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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<string, string | undefined>): Record<string, string> {
|
||||
const result = spawnSync(BIN, [], {
|
||||
env: { PATH: process.env.PATH, ...env } as Record<string, string>,
|
||||
encoding: 'utf-8',
|
||||
});
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`gstack-paths failed (status ${result.status}): ${result.stderr}`);
|
||||
}
|
||||
const out: Record<string, string> = {};
|
||||
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<string, string>,
|
||||
encoding: 'utf-8',
|
||||
});
|
||||
const lines = result.stdout.split('\n').filter(Boolean);
|
||||
for (const line of lines) {
|
||||
expect(line).toMatch(/^[A-Z_]+=.*/);
|
||||
}
|
||||
});
|
||||
});
|
||||
+2
-1
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user