Files
gstack/bin/gstack-team-init
Garry Tanandcapy-ai[bot] ef99eb5a55 feat: retire /investigate after a no-gain eval (GST-1717) (#4110)
* feat: retire /investigate after a no-gain eval (GST-1717)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(skill-start): core routing offer drops every non-core rule line (GST-1717)

The retired /investigate rule became 'Bugs/errors → fix them directly', which no
longer matches '→ invoke /', so proactive=core leaked it into the offered section.
Match any '- … → ' rule line.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* revert(review): keep review/checklist.md at its no-loss-pinned bytes (GST-1717)

The pin names replay GSTA-224 H1; a reviewer checklist edit merges only after a
passing no-loss replay (crew rule 15). The /investigate flake line stays until a
replayed repin.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test: repair free-suite fallout from retiring /investigate (GST-1717)

CI run 42340956269 at 83a8a9a: drop fixtures, ledger rows, allowlist entries and
counts for the retired skill and its deleted paid files; record the harness
change as non-behavioral; the test-of-test detector accepts path.join(ROOT, 'bin');
uncovered-prompt fixtures use a tracked prompt, since an untracked path now
reads as a deleted one.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(relink): back up a retired skill's real SKILL.md before cleaning it (GST-1717)

With the skill's source gone, the customized-file check never ran, so a
banner-carrying local copy of investigate/SKILL.md was deleted instead of moved
to the backup root (red-team review, pass 1).

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(qm): restore the review checklist's flake-line rewrite (GST-1717)

review/checklist.md keeps its pinned /investigate line, so the QM edition needs
its replaceText rule back; qm/ stays fresh against the generator.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs(changelog): name the setup flag break, the relink backup and the pinned checklist line (GST-1717)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(setup,relink): back up a retired skill's real SKILL.md instead of deleting it (GST-1717)

Scoped fix round under grant a-360dd62bd46a (outside-voice blocking finding and
adversarial #1, #4). setup's prune compares a copied host entry with the render
before removing that render, and backs up a copy that differs or whose render is
gone; relink no longer takes the marker's rm -rf for a skill whose source is
gone, so a real SKILL.md is backed up and the user's files stay. setup reports a
failed remove-source of the retired gate hooks on stderr.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(pr-profile): a changed prompt counts as deleted only when it is gone from disk (GST-1717)

An untracked prompt file on disk still needs prompt validation (targeted review
finding); harness change recorded as non-behavioral.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs(changelog): exact backup wording, the hook-removal warning and GST-1744 (GST-1717)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(setup): one backup folder per host for a retired skill's copies (GST-1717)

Under grant a-c95576b8b1f9 (fix-check#1): every copy host moved its
gstack-investigate/SKILL.md to the same backup path, so a later host's copy
overwrote an earlier host's edits. A sandboxed ./setup --host factory run showed
gen-skill-docs removes the retired render before the prune, so every real copy
is backed up; each host now gets its own folder.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs(changelog): say exactly what setup backs up for a retired skill (GST-1717)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs(changelog): end the two colon sentences prose-lint flagged (GST-1717)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test(retired-skills): name plan-eng-review once behind a pregate-literal waiver (GST-1717)

pregate's literals check read the GSTACK_RETIRED_SKILLS change as an old
value still in the tests. plan-eng-review is still retired; investigate was
appended. The fixture names it once, with the waiver.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
2026-11-07 06:52:04 +00:00

270 lines
13 KiB
Bash
Executable File

#!/usr/bin/env bash
# gstack-team-init — generate repo-level bootstrap files for team mode
#
# Usage:
# gstack-team-init optional # gentle CLAUDE.md suggestion, one-time offer
# gstack-team-init required # CLAUDE.md enforcement + PreToolUse hook
#
# Run from the root of your team's repo (not from the gstack directory).
# Heredoc delivery guard. bash 5.2+ writes a heredoc body <=64KiB through a
# pipe in the forked child before exec, with no reader on the other end. On
# macOS under pipe-KVA pressure a fresh pipe gets a 512-byte buffer, so any
# body >=512B blocks write() forever and the script hangs at startup with no
# output. Compat level 50 restores the tempfile path. These scripts are
# bash-3.2-clean, so the compat level costs them nothing. Not exported: the
# guard is per-script, and it survives `bash script.sh` call sites that
# bypass the shebang.
BASH_COMPAT=50
set -euo pipefail
GSTACK_BUN_CONFIG=/dev/null; case "${OSTYPE:-}" in msys*|cygwin*) if command -v cygpath >/dev/null 2>&1; then GSTACK_BUN_CONFIG=NUL; fi ;; esac
MODE="${1:-}"
if [ "$MODE" != "optional" ] && [ "$MODE" != "required" ]; then
echo "Usage: gstack-team-init {optional|required}" >&2
echo "" >&2
echo " optional — suggest gstack install once per developer (gentle)" >&2
echo " required — enforce gstack install, block work without it" >&2
exit 1
fi
# Must be in a git repo
if ! git rev-parse --show-toplevel >/dev/null 2>&1; then
echo "Error: not in a git repository. Run from your project root." >&2
exit 1
fi
REPO_ROOT=$(git rev-parse --show-toplevel)
CLAUDE_MD="$REPO_ROOT/CLAUDE.md"
GENERATED=()
# ── Migrate vendored copy if present ──────────────────────────
if [ -d "$REPO_ROOT/.claude/skills/gstack" ] && [ ! -L "$REPO_ROOT/.claude/skills/gstack" ]; then
if [ -f "$REPO_ROOT/.claude/skills/gstack/VERSION" ] || [ -d "$REPO_ROOT/.claude/skills/gstack/.git" ]; then
echo " Found vendored gstack copy at $REPO_ROOT/.claude/skills/gstack"
echo " Team mode uses the global install — removing vendored copy..."
( cd "$REPO_ROOT" && git rm -r --cached .claude/skills/gstack/ 2>/dev/null ) || true
if [ -f "$REPO_ROOT/.gitignore" ]; then
if ! grep -qF '.claude/skills/gstack/' "$REPO_ROOT/.gitignore" 2>/dev/null; then
echo '.claude/skills/gstack/' >> "$REPO_ROOT/.gitignore"
fi
else
echo '.claude/skills/gstack/' > "$REPO_ROOT/.gitignore"
fi
rm -rf "$REPO_ROOT/.claude/skills/gstack"
GENERATED+=(".gitignore")
echo " Removed vendored copy and added .claude/skills/gstack/ to .gitignore"
fi
fi
# ── CLAUDE.md snippet ──────────────────────────────────────────
if [ "$MODE" = "optional" ]; then
SNIPPET='## gstack (recommended)
This project uses [gstack](https://github.com/garrytan/gstack) for AI-assisted workflows.
Install it for the best experience:
```bash
git clone --depth 1 https://github.com/garrytan/gstack.git ~/.claude/skills/gstack
cd ~/.claude/skills/gstack && ./setup --team
```
Skills like /qa, /ship, /review and /browse become available after install.
Use /browse for all web browsing (Aside first, the bundled gstack browser as fallback). Use ~/.claude/skills/gstack/... for gstack file paths.'
else
SNIPPET='## gstack (REQUIRED — global install)
**Before doing ANY work, verify gstack is installed:**
```bash
_GS=""
for _D in "${GSTACK_ROOT:-}" "$HOME/.claude/skills/gstack" "$HOME/.codex/skills/gstack" "$HOME/.factory/skills/gstack" "$HOME/.kiro/skills/gstack" "$HOME/.config/opencode/skills/gstack" "$HOME/.slate/skills/gstack" "$HOME/.cursor/skills/gstack" "$HOME/.openclaw/skills/gstack" "$HOME/.hermes/skills/gstack" "$HOME/.gbrain/skills/gstack" "$HOME/.copilot/skills/gstack" "$HOME/.gemini/antigravity-cli/skills/gstack" "$HOME/.gemini/skills/gstack" "$HOME/.gstack/repos/gstack"; do
[ -z "$_GS" ] && [ -n "$_D" ] && [ -d "$_D/bin" ] && _GS="$_D"
done
[ -n "$_GS" ] && echo "GSTACK_OK: $_GS" || echo "GSTACK_MISSING"
```
If GSTACK_MISSING: STOP. Do not proceed. Tell the user:
> gstack is required for all AI-assisted work in this repo.
> Install it:
> ```bash
> git clone --depth 1 https://github.com/garrytan/gstack.git ~/.claude/skills/gstack
> cd ~/.claude/skills/gstack && ./setup --team
> ```
> Then restart your AI coding tool.
Do not skip skills, ignore gstack errors, or work around missing gstack.
Using gstack skills: After install, skills like /qa, /ship, /review
and /browse are available. Use /browse for all web browsing (Aside first, the bundled gstack browser as fallback).
Use the resolved install path above for gstack file paths
(default: ~/.claude/skills/gstack).
The Skill gate in .claude/settings.json runs .claude/hooks/check-gstack.cjs with
Node.js and blocks when it cannot run. If a skill is blocked with "node: not
found", tell the user to install Node.js (https://nodejs.org); if it is blocked
with "Cannot find module ... check-gstack.cjs", the hook file is missing from
this checkout. Do not work around either block.'
fi
# Check if CLAUDE.md already has a gstack section
if [ -f "$CLAUDE_MD" ] && grep -q "## gstack" "$CLAUDE_MD" 2>/dev/null; then
echo "CLAUDE.md already has a gstack section. Skipping CLAUDE.md update."
echo " To replace it, remove the existing ## gstack section and re-run."
else
if [ -f "$CLAUDE_MD" ]; then
echo "" >> "$CLAUDE_MD"
fi
echo "$SNIPPET" >> "$CLAUDE_MD"
GENERATED+=("CLAUDE.md")
echo " + CLAUDE.md — added gstack $MODE section"
fi
# ── Required mode: enforcement hook ────────────────────────────
if [ "$MODE" = "required" ]; then
HOOKS_DIR="$REPO_ROOT/.claude/hooks"
SETTINGS="$REPO_ROOT/.claude/settings.json"
# Create the enforcement hook. Claude Code and Copilot CLI may run hook
# commands through sh, cmd.exe or PowerShell, so the hook is CommonJS run by
# node, and the registered command holds no shell variable syntax: it reads
# CLAUDE_PROJECT_DIR through process.env. `.cjs` keeps a project's
# package.json "type" from changing how node loads it.
mkdir -p "$HOOKS_DIR"
cat > "$HOOKS_DIR/check-gstack.cjs" << 'HOOK_EOF'
'use strict';
// Block skill usage when gstack is not installed globally.
//
// Resolve the install root the way gstack skill preambles do: the GSTACK_ROOT
// env var first, then every host's global install location, then the migrated
// repo location. Block only when NONE exist (#2500 — hardcoding
// ~/.claude/skills/gstack false-blocked Codex-host and migrated-repo installs).
const fs = require('fs');
const os = require('os');
const path = require('path');
const home = process.env.HOME || process.env.USERPROFILE || os.homedir();
const roots = [process.env.GSTACK_ROOT || ''].concat([
'.claude/skills/gstack', '.codex/skills/gstack', '.factory/skills/gstack', '.kiro/skills/gstack',
'.config/opencode/skills/gstack', '.slate/skills/gstack', '.cursor/skills/gstack', '.openclaw/skills/gstack',
'.hermes/skills/gstack', '.gbrain/skills/gstack', '.copilot/skills/gstack', '.gemini/antigravity-cli/skills/gstack', '.gemini/skills/gstack', '.gstack/repos/gstack',
].map((rel) => path.join(home, rel)));
const gstackRoot = roots.find((root) => {
try { return root !== '' && fs.statSync(path.join(root, 'bin')).isDirectory(); } catch { return false; }
});
if (gstackRoot) {
process.stdout.write('{}\n');
} else {
process.stderr.write(`BLOCKED: gstack is not installed globally.
gstack is required for AI-assisted work in this repo.
Install it:
git clone --depth 1 https://github.com/garrytan/gstack.git ~/.claude/skills/gstack
cd ~/.claude/skills/gstack && ./setup --team
Then restart your AI coding tool.
`);
// Exit 2 denies on Claude Code (#2413) and on Copilot CLI, which treats a
// preToolUse exit 2 as deny (#2229).
process.stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny',
permissionDecisionReason: 'gstack is required but not installed. See stderr for install instructions.' } }) + '\n');
process.exitCode = 2;
}
HOOK_EOF
chmod +x "$HOOKS_DIR/check-gstack.cjs"
GENERATED+=(".claude/hooks/check-gstack.cjs")
echo " + .claude/hooks/check-gstack.cjs — enforcement hook"
# Add hook to project-level settings.json. The command exits 0 (allow) or
# 2 (deny) only when node runs check-gstack.cjs. Without node the shell exits
# 127, and a require error exits 1; Claude Code treats any other exit as a
# non-blocking error and lets the Skill call through, so the handler is
# registered with onFailure: block (Claude Code 2.1.295+) to fail closed
# (#3319). Older Claude Code ignores the field and keeps the old behavior.
HOOK_COMMAND="node -e \"require(require('path').resolve(process.env.CLAUDE_PROJECT_DIR || '.', '.claude', 'hooks', 'check-gstack.cjs'))\""
if ! command -v bun >/dev/null 2>&1; then
echo " ! bun not found — manually add this PreToolUse hook (matcher Skill|skill, \"onFailure\": \"block\") to .claude/settings.json:"
echo " $HOOK_COMMAND"
elif GSTACK_SETTINGS_PATH="$SETTINGS" GSTACK_HOOK_COMMAND="$HOOK_COMMAND" bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e "
const fs = require('fs');
const settingsPath = process.env.GSTACK_SETTINGS_PATH;
const handler = { type: 'command', command: process.env.GSTACK_HOOK_COMMAND, onFailure: 'block' };
let settings = {};
try { settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); } catch {}
if (!settings.hooks) settings.hooks = {};
if (!settings.hooks.PreToolUse) settings.hooks.PreToolUse = [];
// Claude Code's skill tool is Skill; Copilot CLI's is skill, which no
// Claude tool name maps to, so a bare 'Skill' never fired there (#2229).
// A registration an older team-init wrote (the .sh hook, matcher Skill)
// is replaced in place: exactly one check-gstack handler remains, and
// unrelated hooks in the same entry are kept. Re-running upgrades a
// handler registered without onFailure.
const MATCHER = 'Skill|skill';
const isOurs = h => h && typeof h.command === 'string' && h.command.includes('check-gstack');
let placed = false;
settings.hooks.PreToolUse = settings.hooks.PreToolUse.filter(entry => {
if (entry.matcher !== 'Skill' && entry.matcher !== MATCHER) return true;
if (!Array.isArray(entry.hooks) || !entry.hooks.some(isOurs)) return true;
entry.matcher = MATCHER;
const kept = [];
for (const h of entry.hooks) {
if (!isOurs(h)) kept.push(h);
else if (!placed) { kept.push({ ...handler }); placed = true; }
}
entry.hooks = kept;
return kept.length > 0;
});
if (!placed) settings.hooks.PreToolUse.push({ matcher: MATCHER, hooks: [{ ...handler }] });
const tmp = settingsPath + '.tmp';
fs.writeFileSync(tmp, JSON.stringify(settings, null, 2) + '\n');
fs.renameSync(tmp, settingsPath);
"; then
GENERATED+=(".claude/settings.json")
echo " + .claude/settings.json — PreToolUse hook registered"
# The bash hook an older team-init wrote goes only once its replacement is
# registered; a tracked one is staged as a deletion by the git add below.
if [ -e "$HOOKS_DIR/check-gstack.sh" ]; then
rm -f "$HOOKS_DIR/check-gstack.sh"
git -C "$REPO_ROOT" ls-files --error-unmatch -- .claude/hooks/check-gstack.sh >/dev/null 2>&1 \
&& GENERATED+=(".claude/hooks/check-gstack.sh")
echo " - .claude/hooks/check-gstack.sh — replaced by check-gstack.cjs"
fi
else
echo " ! could not update .claude/settings.json — add this PreToolUse hook (matcher Skill|skill, \"onFailure\": \"block\") manually:"
echo " $HOOK_COMMAND"
fi
fi
# ── Summary ────────────────────────────────────────────────────
echo ""
echo "Team mode ($MODE) initialized."
echo ""
if [ ${#GENERATED[@]} -gt 0 ]; then
echo "Commit the generated files:"
echo " git add ${GENERATED[*]}"
echo " git commit -m \"chore: require gstack for AI-assisted work\""
fi
echo ""
echo "Each developer then runs:"
echo " git clone --depth 1 https://github.com/garrytan/gstack.git ~/.claude/skills/gstack"
echo " cd ~/.claude/skills/gstack && ./setup --team"
if [ "$MODE" = "required" ]; then
echo ""
echo "The Skill gate runs on Node.js and blocks when it cannot run, so every"
echo "developer also needs node on PATH and Claude Code 2.1.295 or later."
echo "Older Claude Code ignores onFailure: without node there, skills are not blocked."
fi