mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-01 14:34:42 +01:00
doctor: list rescue refs and warn about CLAUDE.md skill lists
gstack-doctor's plain report gains two rows. 'rescue refs' lists each refs/gstack/rescue/* commit in the install checkout, newest first, with its date, a needs-manual-resolution label when the rescue held unmerged files, and the shell-quoted inspect and delete commands bin/gstack-ff-update's banner prints. 'CLAUDE.md' warns when a gstack section in ~/.claude/CLAUDE.md or the project's CLAUDE.md enumerates skills (#3169) and prints the short block to paste instead; docs/troubleshooting.md#claude-md-skill-list explains it. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
f0748d6157
commit
3f8bd20734
3 files changed
+224
-2
No files matched your search
+86
-2
@@ -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 '<ref>^' '<ref>' > ~/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 <<REFS
|
||||
$refs
|
||||
REFS
|
||||
}
|
||||
|
||||
# check_claude_md — a gstack section in ~/.claude/CLAUDE.md or this project's
|
||||
# CLAUDE.md that enumerates skills (#3169): Claude Code lists the installed
|
||||
# skills itself, and a pasted list goes stale with each release. A section is
|
||||
# a heading whose title starts with "gstack", up to the next heading of its
|
||||
# level or higher; it enumerates when it has an "Available skills" line or
|
||||
# names 8 or more distinct /skill tokens (paths are not tokens).
|
||||
check_claude_md() {
|
||||
local user project file f n checked="" listed="" seen=""
|
||||
user="${CLAUDE_CONFIG_DIR:-$HOME/.claude}/CLAUDE.md"
|
||||
project="$(git rev-parse --show-toplevel 2>/dev/null || pwd -P)/CLAUDE.md"
|
||||
for file in "$user" "$project"; do
|
||||
[ -f "$file" ] || continue
|
||||
f=$(cd "$(dirname "$file")" 2>/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 ]
|
||||
@@ -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`.
|
||||
|
||||
<a id="claude-md-skill-list"></a>
|
||||
### `warn CLAUDE.md the gstack section in <file> lists <n> 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 (...)`.
|
||||
@@ -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<string, string> = {}) => {
|
||||
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('<a id="claude-md-skill-list"></a>');
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user