diff --git a/bin/gstack-doctor b/bin/gstack-doctor index c0648255d..3b51a0ff3 100755 --- a/bin/gstack-doctor +++ b/bin/gstack-doctor @@ -13,8 +13,9 @@ # default is every host installed from this checkout. # Rows and ids: bin/gstack-doctor-components.sh. # -# Rows: install root, state root, bun, hooks, codex, codex probe, artifacts -# sync, browse bundle, binaries, cso, claude code, session journal, guard log. Each row is +# Rows: install root, rescue refs, state root, bun, hooks, codex, codex probe, +# artifacts sync, browse bundle, binaries, cso, claude code, session journal, +# guard log, CLAUDE.md. Each row is # `ok`, `warn`, `not configured` or `fail`; a row that is not ok carries a fix # line, and every gstack helper in a fix line is an absolute path under this # install root. Exit 1 when any row is `fail`, 2 on a usage error, else 0. @@ -82,6 +83,9 @@ row() { esac } +# sq TEXT — TEXT single-quoted for bash. +sq() { printf "'%s'" "$(printf '%s' "$1" | sed "s/'/'\\\\''/g")"; } + # clamp CHARSET MAX — stdin's first line reduced to CHARSET, at most MAX chars. clamp() { head -1 | tr -cd "$1" | cut -c1-"$2"; } @@ -454,6 +458,84 @@ check_guard_log() { row ok "guard log" "${summary:-0 decisions (0 denied)}" } +# check_rescue_refs — the refs/gstack/rescue/* commits bin/gstack-ff-update +# leaves when local edits conflicted with an update, newest first, each with +# its date and the inspect and delete commands its banner prints. No row for +# an install that is not a git checkout. +check_rescue_refs() { + local refs ref date q n=0 shown=0 label + git -C "$SRC" rev-parse --git-dir >/dev/null 2>&1 || return 0 + refs=$(GIT_OPTIONAL_LOCKS=0 git -C "$SRC" for-each-ref --sort=-committerdate --format='%(refname) %(committerdate:short)' refs/gstack/rescue/ 2>/dev/null) + q=$(sq "$SRC") + if [ -z "$refs" ]; then + row ok "rescue refs" "none in $SRC" + return + fi + n=$(printf '%s\n' "$refs" | wc -l | tr -d ' ') + row warn "rescue refs" "$n in $SRC: local edits an update set aside instead of leaving conflict markers" "inspect each, export what you want to keep (git -C $q diff '^' '' > ~/gstack-local-edits.patch, then git apply -3 it in a scratch checkout), then delete it ($DOCS#auto-update-rescued-edits)" + while read -r ref date; do + shown=$((shown + 1)) + if [ "$shown" -gt 10 ]; then + printf '%32s%s more: git -C %s for-each-ref refs/gstack/rescue/\n' '' "$((n - 10))" "$q" + break + fi + label="" + git -C "$SRC" cat-file commit "$ref" 2>/dev/null | grep -q '^Needs manual resolution' && label=", needs manual resolution" + printf '%32s%s (%s%s)\n' '' "$ref" "$date" "$label" + printf '%32s inspect: git -C %s diff %s %s\n' '' "$q" "'$ref^'" "'$ref'" + printf '%32s delete: git -C %s update-ref -d %s\n' '' "$q" "'$ref'" + done </dev/null && pwd -P)/CLAUDE.md + case "|$seen|" in *"|$f|"*) continue ;; esac + seen="$seen|$f" + checked="${checked:+$checked, }$file" + n=$(awk ' + /^#+[ \t]/ { + level = match($0, /[^#]/) - 1 + title = tolower(substr($0, level + 1)); sub(/^[ \t]+/, "", title) + if (in_block && level <= block_level) in_block = 0 + if (!in_block && title ~ /^gstack([^a-z0-9-]|$)/) { in_block = 1; block_level = level; next } + } + !in_block { next } + tolower($0) ~ /available skills/ { available = 1 } + { + s = " " $0 + while (match(s, /[ \t,(`*[]\/[a-z][a-z0-9-]*/)) { + tok = substr(s, RSTART + 2, RLENGTH - 2); nxt = substr(s, RSTART + RLENGTH, 1) + s = substr(s, RSTART + RLENGTH) + if (nxt == "/" || tok in seen) continue + seen[tok] = 1; count++ + } + } + END { if (available || count >= 8) print count + 0 }' "$file" 2>/dev/null) + [ -n "$n" ] && listed="${listed:+$listed; }$file lists $n skills" + done + if [ -z "$checked" ]; then + row ok "CLAUDE.md" "none at $user or $project" + elif [ -z "$listed" ]; then + row ok "CLAUDE.md" "no gstack section lists skills ($checked)" + else + row warn "CLAUDE.md" "the gstack section in $listed; Claude Code lists installed skills itself, and a pasted list goes stale with each release" "replace that section with this short block ($DOCS#claude-md-skill-list):" + printf '%34s%s\n' '' '## gstack' '' 'Use /browse from gstack for all web browsing. Never use mcp__claude-in-chrome__* tools.' + fi +} + if ! . "$BIN/gstack-state-root.sh" 2>/dev/null; then row fail "state root" "$BIN/gstack-state-root.sh is missing" "reinstall gstack from a fresh checkout of https://github.com/garrytan/gstack, then run its ./setup" exit 1 @@ -475,6 +557,7 @@ fi echo "gstack doctor $(head -1 "$ROOT/VERSION" 2>/dev/null | clamp '0-9A-Za-z.+-' 40)" check_install +check_rescue_refs check_state_root check_bun check_hooks @@ -487,5 +570,6 @@ check_cso check_claude check_journal check_guard_log +check_claude_md echo "$N_OK ok, $N_WARN warn, $N_NC not configured, $N_FAIL fail" [ "$N_FAIL" -eq 0 ] diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index ab0dd172d..75602084b 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -3371,3 +3371,24 @@ unavailable base, an invalid pin file) is one JSON object on stdout, with exit 3. A run's object carries the same `ok` and `status` (`complete` or `incomplete`), so `status` tells a refusal from a run. The `GSTACK_RESULT:` line goes to stderr under `--json`. + + +### `warn CLAUDE.md the gstack section in lists skills; Claude Code lists installed skills itself, and a pasted list goes stale with each release` + +**Meaning.** `gstack-doctor` found a gstack section in `~/.claude/CLAUDE.md` +or in this project's `CLAUDE.md` that enumerates skills: an `Available +skills:` line, or eight or more `/skill` names. Older install prompts asked +for that list (#3169). Claude Code already lists every installed skill with +its description, and a pasted list goes stale with each release, so the agent +can take a partial list as the complete set. + +**Fix.** Replace the section with the short block the doctor prints (no skill +list; keep any other gstack rules you wrote yourself): + +``` +## gstack +Use /browse from gstack for all web browsing. Never use mcp__claude-in-chrome__* tools. +``` + +**Expected result.** The next `gstack-doctor` run shows +`ok CLAUDE.md no gstack section lists skills (...)`. diff --git a/test/gstack-doctor.test.ts b/test/gstack-doctor.test.ts index 3dddefea5..40a358a95 100644 --- a/test/gstack-doctor.test.ts +++ b/test/gstack-doctor.test.ts @@ -890,3 +890,120 @@ describe('gstack-doctor --check: wrapper and runtime drift', () => { } }); }); + +// Lane F item D: the plain report lists rescue refs with their commands, warns +// about a CLAUDE.md gstack section that enumerates skills (#3169), and on +// Windows names the bash gstack's TypeScript hands its scripts to; --check's +// upgrade fix names the held-install recovery forms on Windows, because +// bin/gstack-upgrade refuses every Git Bash install. GSTACK_DOCTOR_PLATFORM +// injects the platform and GSTACK_DOCTOR_BASH_PROBE the discovery inputs, so +// every case runs on every OS. +describe('gstack-doctor: rescue refs, CLAUDE.md skill lists, Windows bash, Windows upgrade fix', () => { + const git = (f: Fixture, args: string[], extra: Record = {}) => { + const r = spawnSync('git', args, { + cwd: f.root, encoding: 'utf8', timeout: 20_000, + env: { ...process.env, HOME: f.home, GIT_CONFIG_NOSYSTEM: '1', GIT_AUTHOR_NAME: 'fixture', GIT_AUTHOR_EMAIL: 'fixture@example.com', GIT_COMMITTER_NAME: 'fixture', GIT_COMMITTER_EMAIL: 'fixture@example.com', ...extra }, + }); + if (r.status !== 0) throw new Error(`git ${args.join(' ')}: ${r.stderr}`); + return r.stdout.trim(); + }; + /** A git install with one rescue commit per entry: a user edit on top of HEAD, committed on the given date. */ + const withRescues = (f: Fixture, rescues: Array<{ date: string; unmerged?: boolean }>) => { + git(f, ['init', '-q']); + git(f, ['add', '-A']); + git(f, ['commit', '-qm', 'release']); + const index = path.join(f.base, 'rescue.idx'); + return rescues.map(({ date, unmerged }, i) => { + const blob = spawnSync('git', ['hash-object', '-w', '--stdin'], { cwd: f.root, input: `edit ${i}\n`, encoding: 'utf8', timeout: 20_000 }).stdout.trim(); + git(f, ['read-tree', 'HEAD'], { GIT_INDEX_FILE: index }); + git(f, ['update-index', '--add', '--cacheinfo', `100644,${blob},local-edit-${i}.txt`], { GIT_INDEX_FILE: index }); + const tree = git(f, ['write-tree'], { GIT_INDEX_FILE: index }); + const message = `gstack rescue: local edits that conflicted with abc\n${unmerged ? '\nNeeds manual resolution: the install had unmerged files; their captured\ncontent may contain conflict markers.\n' : ''}`; + const oid = spawnSync('git', ['commit-tree', tree, '-p', 'HEAD'], { + cwd: f.root, input: message, encoding: 'utf8', timeout: 20_000, + env: { ...process.env, HOME: f.home, GIT_CONFIG_NOSYSTEM: '1', GIT_AUTHOR_NAME: 'gstack rescue', GIT_AUTHOR_EMAIL: 'gstack@localhost', GIT_COMMITTER_NAME: 'gstack rescue', GIT_COMMITTER_EMAIL: 'gstack@localhost', GIT_AUTHOR_DATE: date, GIT_COMMITTER_DATE: date }, + }).stdout.trim(); + git(f, ['update-ref', `refs/gstack/rescue/${oid}`, oid]); + return `refs/gstack/rescue/${oid}`; + }); + }; + const rescueRefs = (f: Fixture) => git(f, ['for-each-ref', '--format=%(refname)', 'refs/gstack/rescue/']).split('\n').filter(Boolean); + const commandFor = (out: string, label: 'inspect' | 'delete', ref: string) => + out.split('\n').map(l => l.trim()).find(l => l.startsWith(`${label}:`) && l.includes(`'${ref}'`))?.replace(/^[a-z]+:\s+/, '') ?? ''; + + test('rescue refs: two refs are listed newest first with their dates; the printed inspect command shows the edit and the printed delete command removes the ref', () => { + const f = makeFixture(); + const [older, newer] = withRescues(f, [{ date: '2026-10-01T12:00:00Z' }, { date: '2026-10-14T08:30:00Z', unmerged: true }]); + const r = doctor(f); + const row = r.row('rescue refs'); + expect(row.state, r.out).toBe('warn'); + expect(row.detail).toContain(`2 in ${bashPath(f.root)}`); + expect(row.fix).toContain('#auto-update-rescued-edits'); + const listing = r.out.split('\n').map(l => l.trim()); + expect(listing.findIndex(l => l.startsWith(newer))).toBeLessThan(listing.findIndex(l => l.startsWith(older))); + expect(listing).toContain(`${older} (2026-10-01)`); + expect(listing).toContain(`${newer} (2026-10-14, needs manual resolution)`); + const q = `'${bashPath(f.root)}'`; + expect(commandFor(r.out, 'inspect', older)).toBe(`git -C ${q} diff '${older}^' '${older}'`); + expect(commandFor(r.out, 'delete', older)).toBe(`git -C ${q} update-ref -d '${older}'`); + const shown = spawnSync('bash', ['-c', commandFor(r.out, 'inspect', older)], { cwd: f.work, encoding: 'utf8', timeout: 20_000 }); + expect(shown.status, shown.stderr).toBe(0); + expect(shown.stdout).toContain('+edit 0'); + const deleted = spawnSync('bash', ['-c', commandFor(r.out, 'delete', older)], { cwd: f.work, encoding: 'utf8', timeout: 20_000 }); + expect(deleted.status, deleted.stderr).toBe(0); + expect(rescueRefs(f)).toEqual([newer]); + expect(doctor(f).row('rescue refs').detail).toContain(`1 in ${bashPath(f.root)}`); + spawnSync('bash', ['-c', commandFor(r.out, 'delete', newer)], { cwd: f.work, encoding: 'utf8', timeout: 20_000 }); + const none = doctor(f); + expect(none.row('rescue refs')).toEqual({ state: 'ok', detail: `none in ${bashPath(f.root)}`, fix: '' }); + expect(none.status, none.out).toBe(0); + }); + + const LISTING = '## gstack\nUse /browse from gstack for all web browsing. Never use mcp__claude-in-chrome__* tools.\nAvailable skills: /office-hours, /plan-ceo-review, /plan-eng-review, /plan-design-review,\n/design-consultation, /design-shotgun, /design-html, /review, /ship, /qa, /browse, /retro, /investigate.\n'; + const SHORT = '## gstack\nUse /browse from gstack for all web browsing. Never use mcp__claude-in-chrome__* tools.\n'; + const replacement = (out: string) => out.split('\n').filter(l => l.startsWith(' '.repeat(34))).map(l => l.slice(34)); + + test('CLAUDE.md: a gstack section that enumerates skills warns with the short replacement block, for ~/.claude/CLAUDE.md and the project CLAUDE.md', () => { + const f = makeFixture(); + const user = path.join(f.home, '.claude/CLAUDE.md'); + write(user, `# Me\n\n${LISTING}\n## Other\ntext\n`); + const r = doctor(f); + const row = r.row('CLAUDE.md'); + expect(row.state, r.out).toBe('warn'); + expect(row.detail).toContain(`${bashPath(f.home)}/.claude/CLAUDE.md lists 13 skills`); + expect(row.fix).toContain('#claude-md-skill-list'); + expect(fs.readFileSync(path.join(REPO, 'docs/troubleshooting.md'), 'utf8')).toContain(''); + expect(replacement(r.out)).toEqual(['## gstack', 'Use /browse from gstack for all web browsing. Never use mcp__claude-in-chrome__* tools.']); + expect(r.status, r.out).toBe(0); + write(user, SHORT); + write(path.join(f.work, 'CLAUDE.md'), `# Project\n\n## gstack (recommended)\nskills: /office-hours /plan-ceo-review /plan-eng-review /review /ship /qa /browse /retro /investigate\n`); + const project = doctor(f).row('CLAUDE.md'); + expect(project.state).toBe('warn'); + expect(project.detail).toContain(`${bashPath(f.work)}/CLAUDE.md lists 9 skills`); + expect(project.detail).not.toContain('.claude/CLAUDE.md'); + }); + + test('CLAUDE.md: the short block, team-init\'s block, a separate Skill routing section and paths do not warn; no CLAUDE.md does not warn', () => { + const f = makeFixture(); + const none = doctor(f).row('CLAUDE.md'); + expect(none.state).toBe('ok'); + expect(none.fix).toBe(''); + write(path.join(f.home, '.claude/CLAUDE.md'), SHORT); + write(path.join(f.work, 'CLAUDE.md'), [ + '## gstack (recommended)', + 'Using gstack skills: After install, skills like /qa, /ship, /review, /investigate,', + 'and /browse are available. Use the install path ~/.claude/skills/gstack/bin/gstack-config /usr/local/bin/bun /opt/a/b /x/y /z/w.', + '', + '## Skill routing', + '- Product ideas → invoke /office-hours', + '- Strategy → invoke /plan-ceo-review', + '- Architecture → invoke /plan-eng-review', + '- Design → invoke /design-consultation or /plan-design-review', + '- Review → invoke /autoplan, /investigate, /qa, /review, /ship', + '', + ].join('\n')); + const r = doctor(f); + expect(r.row('CLAUDE.md').state, r.out).toBe('ok'); + expect(replacement(r.out)).toEqual([]); + }); +});