mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-07 14:06:42 +02:00
Merge branch 'main' into garrytan/team-supabase-store
Brings in 48 commits from main (v0.15.7–v0.15.16): deterministic slugs, TabSession refactor, pair-agent tunnel fix, content security layers, community security wave, team-friendly install, interactive snapshots. Conflict resolution: - .gitignore: merged both sides (kept .factory/ + added .kiro/.opencode/ .slate/.cursor/.openclaw/ from main) - open-gstack-browser/SKILL.md: accepted main (renamed from .factory/) - setup-team-sync/SKILL.md: regenerated via gen:skill-docs - test/fixtures/golden/*: updated golden baselines for ship SKILL.md - codex-ship-SKILL.md: accepted main (renamed from .factory/) - package.json version: synced to VERSION (0.15.16.0) - bin/gstack-uninstall: check settings file exists before claiming SessionStart hook removal (fixes false positive on clean systems) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -50,6 +50,8 @@ fi
|
||||
echo "Launching Chrome with CDP on port $PORT..."
|
||||
"$CHROME" \
|
||||
--remote-debugging-port="$PORT" \
|
||||
--remote-debugging-address=127.0.0.1 \
|
||||
--remote-allow-origins="http://127.0.0.1:$PORT" \
|
||||
--user-data-dir="$CDP_DATA_DIR" \
|
||||
--restore-last-session &
|
||||
disown
|
||||
|
||||
+44
-3
@@ -13,6 +13,38 @@ set -euo pipefail
|
||||
STATE_DIR="${GSTACK_STATE_DIR:-$HOME/.gstack}"
|
||||
CONFIG_FILE="$STATE_DIR/config.yaml"
|
||||
|
||||
# Annotated header for new config files. Written once on first `set`.
|
||||
CONFIG_HEADER='# gstack configuration — edit freely, changes take effect on next skill run.
|
||||
# Docs: https://github.com/garrytan/gstack
|
||||
#
|
||||
# ─── Behavior ────────────────────────────────────────────────────────
|
||||
# proactive: true # Auto-invoke skills when your request matches one.
|
||||
# # Set to false to only run skills you type explicitly.
|
||||
#
|
||||
# routing_declined: false # Set to true to skip the CLAUDE.md routing injection
|
||||
# # prompt. Set back to false to be asked again.
|
||||
#
|
||||
# ─── Telemetry ───────────────────────────────────────────────────────
|
||||
# telemetry: anonymous # off | anonymous | community
|
||||
# # off — no data sent, no local analytics
|
||||
# # anonymous — counter only, no device ID
|
||||
# # community — usage data + stable device ID
|
||||
#
|
||||
# ─── Updates ─────────────────────────────────────────────────────────
|
||||
# auto_upgrade: false # true = silently upgrade on session start
|
||||
# update_check: true # false = suppress version check notifications
|
||||
#
|
||||
# ─── Skill naming ────────────────────────────────────────────────────
|
||||
# skill_prefix: false # true = namespace skills as /gstack-qa, /gstack-ship
|
||||
# # false = short names /qa, /ship
|
||||
#
|
||||
# ─── Advanced ────────────────────────────────────────────────────────
|
||||
# codex_reviews: enabled # disabled = skip Codex adversarial reviews in /ship
|
||||
# gstack_contributor: false # true = file field reports when gstack misbehaves
|
||||
# skip_eng_review: false # true = skip eng review gate in /ship (not recommended)
|
||||
#
|
||||
'
|
||||
|
||||
case "${1:-}" in
|
||||
get)
|
||||
KEY="${2:?Usage: gstack-config get <key>}"
|
||||
@@ -21,7 +53,7 @@ case "${1:-}" in
|
||||
echo "Error: key must contain only alphanumeric characters and underscores" >&2
|
||||
exit 1
|
||||
fi
|
||||
grep -F "${KEY}:" "$CONFIG_FILE" 2>/dev/null | tail -1 | awk '{print $2}' | tr -d '[:space:]' || true
|
||||
grep -E "^${KEY}:" "$CONFIG_FILE" 2>/dev/null | tail -1 | awk '{print $2}' | tr -d '[:space:]' || true
|
||||
;;
|
||||
set)
|
||||
KEY="${2:?Usage: gstack-config set <key> <value>}"
|
||||
@@ -32,15 +64,24 @@ case "${1:-}" in
|
||||
exit 1
|
||||
fi
|
||||
mkdir -p "$STATE_DIR"
|
||||
# Write annotated header on first creation
|
||||
if [ ! -f "$CONFIG_FILE" ]; then
|
||||
printf '%s' "$CONFIG_HEADER" > "$CONFIG_FILE"
|
||||
fi
|
||||
# Escape sed special chars in value and drop embedded newlines
|
||||
ESC_VALUE="$(printf '%s' "$VALUE" | head -1 | sed 's/[&/\]/\\&/g')"
|
||||
if grep -qF "${KEY}:" "$CONFIG_FILE" 2>/dev/null; then
|
||||
if grep -qE "^${KEY}:" "$CONFIG_FILE" 2>/dev/null; then
|
||||
# Portable in-place edit (BSD sed uses -i '', GNU sed uses -i without arg)
|
||||
_tmpfile="$(mktemp "${CONFIG_FILE}.XXXXXX")"
|
||||
sed "s/^${KEY}:.*/${KEY}: ${ESC_VALUE}/" "$CONFIG_FILE" > "$_tmpfile" && mv "$_tmpfile" "$CONFIG_FILE"
|
||||
sed "/^${KEY}:/s/.*/${KEY}: ${ESC_VALUE}/" "$CONFIG_FILE" > "$_tmpfile" && mv "$_tmpfile" "$CONFIG_FILE"
|
||||
else
|
||||
echo "${KEY}: ${VALUE}" >> "$CONFIG_FILE"
|
||||
fi
|
||||
# Auto-relink skills when prefix setting changes (skip during setup to avoid recursive call)
|
||||
if [ "$KEY" = "skill_prefix" ] && [ -z "${GSTACK_SETUP_RUNNING:-}" ]; then
|
||||
GSTACK_RELINK="$(dirname "$0")/gstack-relink"
|
||||
[ -x "$GSTACK_RELINK" ] && "$GSTACK_RELINK" || true
|
||||
fi
|
||||
;;
|
||||
list)
|
||||
cat "$CONFIG_FILE" 2>/dev/null || true
|
||||
|
||||
@@ -16,6 +16,9 @@ if [ -z "$FILES" ]; then
|
||||
echo "SCOPE_TESTS=false"
|
||||
echo "SCOPE_DOCS=false"
|
||||
echo "SCOPE_CONFIG=false"
|
||||
echo "SCOPE_MIGRATIONS=false"
|
||||
echo "SCOPE_API=false"
|
||||
echo "SCOPE_AUTH=false"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
@@ -25,6 +28,9 @@ PROMPTS=false
|
||||
TESTS=false
|
||||
DOCS=false
|
||||
CONFIG=false
|
||||
MIGRATIONS=false
|
||||
API=false
|
||||
AUTH=false
|
||||
|
||||
while IFS= read -r f; do
|
||||
case "$f" in
|
||||
@@ -57,6 +63,16 @@ while IFS= read -r f; do
|
||||
.github/*) CONFIG=true ;;
|
||||
requirements.txt|pyproject.toml|go.mod|Cargo.toml|composer.json) CONFIG=true ;;
|
||||
|
||||
# Migrations: database migration files
|
||||
db/migrate/*|*/migrations/*|alembic/*|prisma/migrations/*) MIGRATIONS=true ;;
|
||||
|
||||
# API: routes, controllers, endpoints, GraphQL/OpenAPI schemas
|
||||
*controller*|*route*|*endpoint*|*/api/*) API=true ;;
|
||||
*.graphql|*.gql|openapi.*|swagger.*) API=true ;;
|
||||
|
||||
# Auth: authentication, authorization, sessions, permissions
|
||||
*auth*|*session*|*jwt*|*oauth*|*permission*|*role*) AUTH=true ;;
|
||||
|
||||
# Backend: everything else that's code (excluding views/components already matched)
|
||||
*.rb|*.py|*.go|*.rs|*.java|*.php|*.ex|*.exs) BACKEND=true ;;
|
||||
*.ts|*.js) BACKEND=true ;; # Non-component TS/JS is backend
|
||||
@@ -69,3 +85,6 @@ echo "SCOPE_PROMPTS=$PROMPTS"
|
||||
echo "SCOPE_TESTS=$TESTS"
|
||||
echo "SCOPE_DOCS=$DOCS"
|
||||
echo "SCOPE_CONFIG=$CONFIG"
|
||||
echo "SCOPE_MIGRATIONS=$MIGRATIONS"
|
||||
echo "SCOPE_API=$API"
|
||||
echo "SCOPE_AUTH=$AUTH"
|
||||
|
||||
Binary file not shown.
@@ -291,7 +291,7 @@ function extractCwdFromJsonl(filePath: string): string | null {
|
||||
}
|
||||
|
||||
function scanCodex(since: Date): Session[] {
|
||||
const sessionsDir = join(homedir(), ".codex", "sessions");
|
||||
const sessionsDir = process.env.CODEX_SESSIONS_DIR || join(homedir(), ".codex", "sessions");
|
||||
if (!existsSync(sessionsDir)) return [];
|
||||
|
||||
const sessions: Session[] = [];
|
||||
@@ -326,11 +326,14 @@ function scanCodex(since: Date): Session[] {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Read first line for session_meta (only first 4KB)
|
||||
// Codex session_meta lines embed the full system prompt in
|
||||
// base_instructions (~15KB as of CLI v0.117+). A 4KB buffer
|
||||
// truncates the line and JSON.parse fails. 128KB covers current
|
||||
// sizes with room for growth.
|
||||
try {
|
||||
const fd = openSync(filePath, "r");
|
||||
const buf = Buffer.alloc(4096);
|
||||
const bytesRead = readSync(fd, buf, 0, 4096, 0);
|
||||
const buf = Buffer.alloc(131072);
|
||||
const bytesRead = readSync(fd, buf, 0, 131072, 0);
|
||||
closeSync(fd);
|
||||
const firstLine = buf.toString("utf-8", 0, bytesRead).split("\n")[0];
|
||||
if (!firstLine) continue;
|
||||
|
||||
Executable
+30
@@ -0,0 +1,30 @@
|
||||
#!/usr/bin/env bash
|
||||
# gstack-learnings-log — append a learning to the project learnings file
|
||||
# Usage: gstack-learnings-log '{"skill":"review","type":"pitfall","key":"n-plus-one","insight":"...","confidence":8,"source":"observed"}'
|
||||
#
|
||||
# Append-only storage. Duplicates (same key+type) are resolved at read time
|
||||
# by gstack-learnings-search ("latest winner" per key+type).
|
||||
set -euo pipefail
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
eval "$("$SCRIPT_DIR/gstack-slug" 2>/dev/null)"
|
||||
GSTACK_HOME="${GSTACK_HOME:-$HOME/.gstack}"
|
||||
mkdir -p "$GSTACK_HOME/projects/$SLUG"
|
||||
|
||||
INPUT="$1"
|
||||
|
||||
# Validate: input must be parseable JSON
|
||||
if ! printf '%s' "$INPUT" | bun -e "JSON.parse(await Bun.stdin.text())" 2>/dev/null; then
|
||||
echo "gstack-learnings-log: invalid JSON, skipping" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Inject timestamp if not present
|
||||
if ! printf '%s' "$INPUT" | bun -e "const j=JSON.parse(await Bun.stdin.text()); if(!j.ts) process.exit(1)" 2>/dev/null; then
|
||||
INPUT=$(printf '%s' "$INPUT" | bun -e "
|
||||
const j = JSON.parse(await Bun.stdin.text());
|
||||
j.ts = new Date().toISOString();
|
||||
console.log(JSON.stringify(j));
|
||||
" 2>/dev/null) || true
|
||||
fi
|
||||
|
||||
echo "$INPUT" >> "$GSTACK_HOME/projects/$SLUG/learnings.jsonl"
|
||||
Executable
+132
@@ -0,0 +1,132 @@
|
||||
#!/usr/bin/env bash
|
||||
# gstack-learnings-search — read and filter project learnings
|
||||
# Usage: gstack-learnings-search [--type TYPE] [--query KEYWORD] [--limit N] [--cross-project]
|
||||
#
|
||||
# Reads ~/.gstack/projects/$SLUG/learnings.jsonl, applies confidence decay,
|
||||
# resolves duplicates (latest winner per key+type), and outputs formatted text.
|
||||
# Exit 0 silently if no learnings file exists.
|
||||
set -euo pipefail
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
eval "$("$SCRIPT_DIR/gstack-slug" 2>/dev/null)"
|
||||
GSTACK_HOME="${GSTACK_HOME:-$HOME/.gstack}"
|
||||
|
||||
TYPE=""
|
||||
QUERY=""
|
||||
LIMIT=10
|
||||
CROSS_PROJECT=false
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--type) TYPE="$2"; shift 2 ;;
|
||||
--query) QUERY="$2"; shift 2 ;;
|
||||
--limit) LIMIT="$2"; shift 2 ;;
|
||||
--cross-project) CROSS_PROJECT=true; shift ;;
|
||||
*) shift ;;
|
||||
esac
|
||||
done
|
||||
|
||||
LEARNINGS_FILE="$GSTACK_HOME/projects/$SLUG/learnings.jsonl"
|
||||
|
||||
# Collect all JSONL files to search
|
||||
FILES=()
|
||||
[ -f "$LEARNINGS_FILE" ] && FILES+=("$LEARNINGS_FILE")
|
||||
|
||||
if [ "$CROSS_PROJECT" = true ]; then
|
||||
# Add other projects' learnings (max 5, sorted by mtime)
|
||||
for f in $(find "$GSTACK_HOME/projects" -name "learnings.jsonl" -not -path "*/$SLUG/*" 2>/dev/null | head -5); do
|
||||
FILES+=("$f")
|
||||
done
|
||||
fi
|
||||
|
||||
if [ ${#FILES[@]} -eq 0 ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Process all files through bun for JSON parsing, decay, dedup, filtering
|
||||
GSTACK_SEARCH_TYPE="$TYPE" GSTACK_SEARCH_QUERY="$QUERY" GSTACK_SEARCH_LIMIT="$LIMIT" GSTACK_SEARCH_SLUG="$SLUG" GSTACK_SEARCH_CROSS="$CROSS_PROJECT" \
|
||||
cat "${FILES[@]}" 2>/dev/null | GSTACK_SEARCH_TYPE="$TYPE" GSTACK_SEARCH_QUERY="$QUERY" GSTACK_SEARCH_LIMIT="$LIMIT" GSTACK_SEARCH_SLUG="$SLUG" GSTACK_SEARCH_CROSS="$CROSS_PROJECT" bun -e "
|
||||
const lines = (await Bun.stdin.text()).trim().split('\n').filter(Boolean);
|
||||
const now = Date.now();
|
||||
const type = process.env.GSTACK_SEARCH_TYPE || '';
|
||||
const query = (process.env.GSTACK_SEARCH_QUERY || '').toLowerCase();
|
||||
const limit = parseInt(process.env.GSTACK_SEARCH_LIMIT || '10', 10);
|
||||
const slug = process.env.GSTACK_SEARCH_SLUG || '';
|
||||
|
||||
const entries = [];
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const e = JSON.parse(line);
|
||||
if (!e.key || !e.type) continue;
|
||||
|
||||
// Apply confidence decay: observed/inferred lose 1pt per 30 days
|
||||
let conf = e.confidence || 5;
|
||||
if (e.source === 'observed' || e.source === 'inferred') {
|
||||
const days = Math.floor((now - new Date(e.ts).getTime()) / 86400000);
|
||||
conf = Math.max(0, conf - Math.floor(days / 30));
|
||||
}
|
||||
e._effectiveConfidence = conf;
|
||||
|
||||
// Determine if this is from the current project or cross-project
|
||||
// Cross-project entries are tagged for display
|
||||
e._crossProject = !line.includes(slug) && process.env.GSTACK_SEARCH_CROSS === 'true';
|
||||
|
||||
entries.push(e);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// Dedup: latest winner per key+type
|
||||
const seen = new Map();
|
||||
for (const e of entries) {
|
||||
const dk = e.key + '|' + e.type;
|
||||
const existing = seen.get(dk);
|
||||
if (!existing || new Date(e.ts) > new Date(existing.ts)) {
|
||||
seen.set(dk, e);
|
||||
}
|
||||
}
|
||||
let results = Array.from(seen.values());
|
||||
|
||||
// Filter by type
|
||||
if (type) results = results.filter(e => e.type === type);
|
||||
|
||||
// Filter by query
|
||||
if (query) results = results.filter(e =>
|
||||
(e.key || '').toLowerCase().includes(query) ||
|
||||
(e.insight || '').toLowerCase().includes(query) ||
|
||||
(e.files || []).some(f => f.toLowerCase().includes(query))
|
||||
);
|
||||
|
||||
// Sort by effective confidence desc, then recency
|
||||
results.sort((a, b) => {
|
||||
if (b._effectiveConfidence !== a._effectiveConfidence) return b._effectiveConfidence - a._effectiveConfidence;
|
||||
return new Date(b.ts).getTime() - new Date(a.ts).getTime();
|
||||
});
|
||||
|
||||
// Limit
|
||||
results = results.slice(0, limit);
|
||||
|
||||
if (results.length === 0) process.exit(0);
|
||||
|
||||
// Format output
|
||||
const byType = {};
|
||||
for (const e of results) {
|
||||
const t = e.type || 'unknown';
|
||||
if (!byType[t]) byType[t] = [];
|
||||
byType[t].push(e);
|
||||
}
|
||||
|
||||
// Summary line
|
||||
const counts = Object.entries(byType).map(([t, arr]) => arr.length + ' ' + t + (arr.length > 1 ? 's' : ''));
|
||||
console.log('LEARNINGS: ' + results.length + ' loaded (' + counts.join(', ') + ')');
|
||||
console.log('');
|
||||
|
||||
for (const [t, arr] of Object.entries(byType)) {
|
||||
console.log('## ' + t.charAt(0).toUpperCase() + t.slice(1) + 's');
|
||||
for (const e of arr) {
|
||||
const cross = e._crossProject ? ' [cross-project]' : '';
|
||||
const files = e.files?.length ? ' (files: ' + e.files.join(', ') + ')' : '';
|
||||
console.log('- [' + e.key + '] (confidence: ' + e._effectiveConfidence + '/10, ' + e.source + ', ' + (e.ts || '').split('T')[0] + ')' + cross);
|
||||
console.log(' ' + e.insight + files);
|
||||
}
|
||||
console.log('');
|
||||
}
|
||||
" 2>/dev/null || exit 0
|
||||
Executable
+14
@@ -0,0 +1,14 @@
|
||||
#!/usr/bin/env bash
|
||||
# gstack-open-url — cross-platform URL opener
|
||||
#
|
||||
# Usage: gstack-open-url <url>
|
||||
set -euo pipefail
|
||||
|
||||
URL="${1:?Usage: gstack-open-url <url>}"
|
||||
|
||||
case "$(uname -s)" in
|
||||
Darwin) open "$URL" ;;
|
||||
Linux) xdg-open "$URL" 2>/dev/null || echo "$URL" ;;
|
||||
MINGW*|MSYS*|CYGWIN*) start "$URL" ;;
|
||||
*) echo "$URL" ;;
|
||||
esac
|
||||
Executable
+34
@@ -0,0 +1,34 @@
|
||||
#!/usr/bin/env bash
|
||||
# gstack-patch-names — patch name: field in SKILL.md frontmatter for prefix mode
|
||||
# Usage: gstack-patch-names <gstack-dir> <true|false|1|0>
|
||||
set -euo pipefail
|
||||
|
||||
GSTACK_DIR="$1"
|
||||
DO_PREFIX="$2"
|
||||
|
||||
# Normalize prefix arg
|
||||
case "$DO_PREFIX" in true|1) DO_PREFIX=1 ;; *) DO_PREFIX=0 ;; esac
|
||||
|
||||
PATCHED=0
|
||||
for skill_dir in "$GSTACK_DIR"/*/; do
|
||||
[ -f "$skill_dir/SKILL.md" ] || continue
|
||||
dir_name="$(basename "$skill_dir")"
|
||||
[ "$dir_name" = "node_modules" ] && continue
|
||||
cur=$(grep -m1 '^name:' "$skill_dir/SKILL.md" 2>/dev/null | sed 's/^name:[[:space:]]*//' | tr -d '[:space:]' || true)
|
||||
[ -z "$cur" ] && continue
|
||||
[ "$cur" = "gstack" ] && continue # never prefix root skill
|
||||
if [ "$DO_PREFIX" -eq 1 ]; then
|
||||
case "$cur" in gstack-*) continue ;; esac
|
||||
new="gstack-$cur"
|
||||
else
|
||||
case "$cur" in gstack-*) ;; *) continue ;; esac
|
||||
[ "$dir_name" = "$cur" ] && continue # inherently prefixed (gstack-upgrade)
|
||||
new="${cur#gstack-}"
|
||||
fi
|
||||
tmp="$(mktemp "${skill_dir}/SKILL.md.XXXXXX")"
|
||||
sed "1,/^---$/s/^name:[[:space:]]*${cur}/name: ${new}/" "$skill_dir/SKILL.md" > "$tmp" && mv "$tmp" "$skill_dir/SKILL.md"
|
||||
PATCHED=$((PATCHED + 1))
|
||||
done
|
||||
if [ "$PATCHED" -gt 0 ]; then
|
||||
echo " patched name: field in $PATCHED skills"
|
||||
fi
|
||||
+19
-12
@@ -2,19 +2,26 @@
|
||||
set -euo pipefail
|
||||
|
||||
# gstack-platform-detect: show which AI coding agents are installed and gstack status
|
||||
# Config-driven: reads host definitions from hosts/*.ts via host-config-export.ts
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
GSTACK_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
|
||||
printf "%-16s %-10s %-40s %s\n" "Agent" "Version" "Skill Path" "gstack"
|
||||
printf "%-16s %-10s %-40s %s\n" "-----" "-------" "----------" "------"
|
||||
for entry in "claude:claude" "codex:codex" "droid:factory" "kiro-cli:kiro"; do
|
||||
bin="${entry%%:*}"; label="${entry##*:}"
|
||||
if command -v "$bin" >/dev/null 2>&1; then
|
||||
ver=$("$bin" --version 2>/dev/null | head -1 || echo "unknown")
|
||||
case "$label" in
|
||||
claude) spath="$HOME/.claude/skills/gstack" ;;
|
||||
codex) spath="$HOME/.codex/skills/gstack" ;;
|
||||
factory) spath="$HOME/.factory/skills/gstack" ;;
|
||||
kiro) spath="$HOME/.kiro/skills/gstack" ;;
|
||||
esac
|
||||
status=$([ -d "$spath" ] && echo "INSTALLED" || echo "NOT INSTALLED")
|
||||
printf "%-16s %-10s %-40s %s\n" "$label" "$ver" "$spath" "$status"
|
||||
|
||||
for host in $(bun run "$GSTACK_DIR/scripts/host-config-export.ts" list 2>/dev/null); do
|
||||
cmd=$(bun run "$GSTACK_DIR/scripts/host-config-export.ts" get "$host" cliCommand 2>/dev/null)
|
||||
root=$(bun run "$GSTACK_DIR/scripts/host-config-export.ts" get "$host" globalRoot 2>/dev/null)
|
||||
spath="$HOME/$root"
|
||||
|
||||
if command -v "$cmd" >/dev/null 2>&1; then
|
||||
ver=$("$cmd" --version 2>/dev/null | head -1 || echo "unknown")
|
||||
if [ -d "$spath" ] || [ -L "$spath" ]; then
|
||||
status="INSTALLED"
|
||||
else
|
||||
status="NOT INSTALLED"
|
||||
fi
|
||||
printf "%-16s %-10s %-40s %s\n" "$host" "$ver" "$spath" "$status"
|
||||
fi
|
||||
done
|
||||
|
||||
Executable
+90
@@ -0,0 +1,90 @@
|
||||
#!/usr/bin/env bash
|
||||
# gstack-relink — re-create skill symlinks based on skill_prefix config
|
||||
#
|
||||
# Usage:
|
||||
# gstack-relink
|
||||
#
|
||||
# Env overrides (for testing):
|
||||
# GSTACK_STATE_DIR — override ~/.gstack state directory
|
||||
# GSTACK_INSTALL_DIR — override gstack install directory
|
||||
# GSTACK_SKILLS_DIR — override target skills directory
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
GSTACK_CONFIG="${SCRIPT_DIR}/gstack-config"
|
||||
|
||||
# Detect install dir
|
||||
INSTALL_DIR="${GSTACK_INSTALL_DIR:-}"
|
||||
if [ -z "$INSTALL_DIR" ]; then
|
||||
if [ -d "$HOME/.claude/skills/gstack" ]; then
|
||||
INSTALL_DIR="$HOME/.claude/skills/gstack"
|
||||
elif [ -d "${SCRIPT_DIR}/.." ] && [ -f "${SCRIPT_DIR}/../setup" ]; then
|
||||
INSTALL_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -z "$INSTALL_DIR" ] || [ ! -d "$INSTALL_DIR" ]; then
|
||||
echo "Error: gstack install directory not found." >&2
|
||||
echo "Run: cd ~/.claude/skills/gstack && ./setup" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Detect target skills dir
|
||||
SKILLS_DIR="${GSTACK_SKILLS_DIR:-$(dirname "$INSTALL_DIR")}"
|
||||
[ -d "$SKILLS_DIR" ] || mkdir -p "$SKILLS_DIR"
|
||||
|
||||
# Read prefix setting
|
||||
PREFIX=$("$GSTACK_CONFIG" get skill_prefix 2>/dev/null || echo "false")
|
||||
|
||||
# Helper: remove old skill entry (symlink or real directory with symlinked SKILL.md)
|
||||
_cleanup_skill_entry() {
|
||||
local entry="$1"
|
||||
if [ -L "$entry" ]; then
|
||||
rm -f "$entry"
|
||||
elif [ -d "$entry" ] && [ -L "$entry/SKILL.md" ]; then
|
||||
rm -rf "$entry"
|
||||
fi
|
||||
}
|
||||
|
||||
# Discover skills (directories with SKILL.md, excluding meta dirs)
|
||||
SKILL_COUNT=0
|
||||
for skill_dir in "$INSTALL_DIR"/*/; do
|
||||
[ -d "$skill_dir" ] || continue
|
||||
skill=$(basename "$skill_dir")
|
||||
# Skip non-skill directories
|
||||
case "$skill" in bin|browse|design|docs|extension|lib|node_modules|scripts|test|.git|.github) continue ;; esac
|
||||
[ -f "$skill_dir/SKILL.md" ] || continue
|
||||
|
||||
if [ "$PREFIX" = "true" ]; then
|
||||
# Don't double-prefix directories already named gstack-*
|
||||
case "$skill" in
|
||||
gstack-*) link_name="$skill" ;;
|
||||
*) link_name="gstack-$skill" ;;
|
||||
esac
|
||||
# Remove old flat entry if it exists (and isn't the same as the new link)
|
||||
[ "$link_name" != "$skill" ] && _cleanup_skill_entry "$SKILLS_DIR/$skill"
|
||||
else
|
||||
link_name="$skill"
|
||||
# Don't remove gstack-* dirs that are their real name (e.g., gstack-upgrade)
|
||||
case "$skill" in
|
||||
gstack-*) ;; # Already the real name, no old prefixed link to clean
|
||||
*) _cleanup_skill_entry "$SKILLS_DIR/gstack-$skill" ;;
|
||||
esac
|
||||
fi
|
||||
target="$SKILLS_DIR/$link_name"
|
||||
# Upgrade old directory symlinks to real directories
|
||||
[ -L "$target" ] && rm -f "$target"
|
||||
# Create real directory with symlinked SKILL.md (absolute path)
|
||||
mkdir -p "$target"
|
||||
ln -snf "$INSTALL_DIR/$skill/SKILL.md" "$target/SKILL.md"
|
||||
SKILL_COUNT=$((SKILL_COUNT + 1))
|
||||
done
|
||||
|
||||
# Patch SKILL.md name: fields to match prefix setting
|
||||
"$INSTALL_DIR/bin/gstack-patch-names" "$INSTALL_DIR" "$PREFIX"
|
||||
|
||||
if [ "$PREFIX" = "true" ]; then
|
||||
echo "Relinked $SKILL_COUNT skills as gstack-*"
|
||||
else
|
||||
echo "Relinked $SKILL_COUNT skills as flat names"
|
||||
fi
|
||||
Executable
+116
@@ -0,0 +1,116 @@
|
||||
#!/usr/bin/env bash
|
||||
# gstack-session-update — auto-update gstack on session start (team mode)
|
||||
#
|
||||
# Called by Claude Code SessionStart hook. Must be fast, silent, non-fatal.
|
||||
# The entire update runs in background (forked). The hook itself exits
|
||||
# immediately so session startup is never delayed.
|
||||
#
|
||||
# Exit 0 always — errors must never block a Claude Code session.
|
||||
|
||||
set +e
|
||||
|
||||
GSTACK_DIR="${GSTACK_DIR:-$HOME/.claude/skills/gstack}"
|
||||
STATE_DIR="${GSTACK_STATE_DIR:-$HOME/.gstack}"
|
||||
THROTTLE_FILE="$STATE_DIR/.last-session-update"
|
||||
LOCK_DIR="$STATE_DIR/.setup-lock"
|
||||
LOG_FILE="$STATE_DIR/analytics/session-update.log"
|
||||
THROTTLE_SECONDS=3600 # 1 hour
|
||||
|
||||
log_entry() {
|
||||
mkdir -p "$(dirname "$LOG_FILE")"
|
||||
echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) $1" >> "$LOG_FILE" 2>/dev/null || true
|
||||
}
|
||||
|
||||
# ── Guard: gstack must be a git repo ──
|
||||
if [ ! -d "$GSTACK_DIR/.git" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ── Guard: team mode must be enabled ──
|
||||
AUTO=$("$GSTACK_DIR/bin/gstack-config" get auto_upgrade 2>/dev/null || true)
|
||||
if [ "$AUTO" != "true" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ── Throttle: skip if checked recently ──
|
||||
if [ -f "$THROTTLE_FILE" ]; then
|
||||
LAST=$(cat "$THROTTLE_FILE" 2>/dev/null || echo 0)
|
||||
NOW=$(date +%s)
|
||||
ELAPSED=$(( NOW - LAST ))
|
||||
if [ "$ELAPSED" -lt "$THROTTLE_SECONDS" ]; then
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── Fork to background: zero latency on session start ──
|
||||
(
|
||||
# Prevent git from prompting for credentials (would hang the background process)
|
||||
export GIT_TERMINAL_PROMPT=0
|
||||
|
||||
mkdir -p "$STATE_DIR"
|
||||
|
||||
# ── Acquire lockfile (skip if another session is running setup) ──
|
||||
if ! mkdir "$LOCK_DIR" 2>/dev/null; then
|
||||
# Lock exists — check if stale (PID dead)
|
||||
if [ -f "$LOCK_DIR/pid" ]; then
|
||||
LOCK_PID=$(cat "$LOCK_DIR/pid" 2>/dev/null || echo 0)
|
||||
if [ "$LOCK_PID" -gt 0 ] 2>/dev/null && ! kill -0 "$LOCK_PID" 2>/dev/null; then
|
||||
# Stale lock — remove and re-acquire
|
||||
rm -rf "$LOCK_DIR" 2>/dev/null
|
||||
mkdir "$LOCK_DIR" 2>/dev/null || { log_entry "SKIP lock_contested"; exit 0; }
|
||||
else
|
||||
log_entry "SKIP locked_by=$LOCK_PID"
|
||||
exit 0
|
||||
fi
|
||||
else
|
||||
log_entry "SKIP locked_no_pid"
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
# Write PID for stale lock detection
|
||||
echo $$ > "$LOCK_DIR/pid" 2>/dev/null
|
||||
|
||||
# Clean up lock on exit
|
||||
trap 'rm -rf "$LOCK_DIR" 2>/dev/null' EXIT
|
||||
|
||||
# ── Pull latest ──
|
||||
OLD_HEAD=$(git -C "$GSTACK_DIR" rev-parse HEAD 2>/dev/null)
|
||||
git -C "$GSTACK_DIR" pull --ff-only -q 2>/dev/null
|
||||
PULL_EXIT=$?
|
||||
NEW_HEAD=$(git -C "$GSTACK_DIR" rev-parse HEAD 2>/dev/null)
|
||||
|
||||
# Record check time regardless of outcome
|
||||
date +%s > "$THROTTLE_FILE" 2>/dev/null
|
||||
|
||||
if [ "$PULL_EXIT" -ne 0 ]; then
|
||||
log_entry "PULL_FAILED exit=$PULL_EXIT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ── If HEAD moved, run setup -q ──
|
||||
if [ "$OLD_HEAD" != "$NEW_HEAD" ]; then
|
||||
log_entry "UPDATING old=$OLD_HEAD new=$NEW_HEAD"
|
||||
|
||||
# bun must be available for setup
|
||||
if command -v bun >/dev/null 2>&1; then
|
||||
( cd "$GSTACK_DIR" && ./setup -q ) >/dev/null 2>&1 || {
|
||||
log_entry "SETUP_FAILED"
|
||||
}
|
||||
else
|
||||
log_entry "SETUP_SKIPPED bun_missing"
|
||||
fi
|
||||
|
||||
# Write marker so next skill preamble shows "just upgraded"
|
||||
OLD_VER=$(git -C "$GSTACK_DIR" show "$OLD_HEAD:VERSION" 2>/dev/null || echo "unknown")
|
||||
echo "$OLD_VER" > "$STATE_DIR/just-upgraded-from" 2>/dev/null
|
||||
rm -f "$STATE_DIR/last-update-check" 2>/dev/null
|
||||
rm -f "$STATE_DIR/update-snoozed" 2>/dev/null
|
||||
|
||||
log_entry "UPDATED from=$OLD_VER to=$(cat "$GSTACK_DIR/VERSION" 2>/dev/null || echo unknown)"
|
||||
else
|
||||
log_entry "UP_TO_DATE head=$OLD_HEAD"
|
||||
fi
|
||||
) &
|
||||
|
||||
exit 0
|
||||
Executable
+82
@@ -0,0 +1,82 @@
|
||||
#!/usr/bin/env bash
|
||||
# gstack-settings-hook — add/remove SessionStart hooks in Claude Code settings.json
|
||||
#
|
||||
# Usage:
|
||||
# gstack-settings-hook add <hook-command> # add SessionStart hook
|
||||
# gstack-settings-hook remove <hook-command> # remove SessionStart hook
|
||||
#
|
||||
# Requires: bun (already a gstack hard dependency)
|
||||
# Writes atomically: .tmp + rename to prevent corruption on crash/disk-full.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ACTION="${1:-}"
|
||||
HOOK_CMD="${2:-}"
|
||||
SETTINGS_FILE="${GSTACK_SETTINGS_FILE:-$HOME/.claude/settings.json}"
|
||||
|
||||
if [ -z "$ACTION" ] || [ -z "$HOOK_CMD" ]; then
|
||||
echo "Usage: gstack-settings-hook {add|remove} <hook-command>" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v bun >/dev/null 2>&1; then
|
||||
echo "Error: bun is required but not installed." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
case "$ACTION" in
|
||||
add)
|
||||
bun -e "
|
||||
const fs = require('fs');
|
||||
const settingsPath = '$SETTINGS_FILE';
|
||||
const hookCmd = $(printf '%s' "$HOOK_CMD" | bun -e "process.stdout.write(JSON.stringify(require('fs').readFileSync('/dev/stdin','utf8')))");
|
||||
|
||||
let settings = {};
|
||||
try { settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); } catch {}
|
||||
|
||||
if (!settings.hooks) settings.hooks = {};
|
||||
if (!settings.hooks.SessionStart) settings.hooks.SessionStart = [];
|
||||
|
||||
// Dedup: check if hook command already registered
|
||||
const exists = settings.hooks.SessionStart.some(entry =>
|
||||
entry.hooks && entry.hooks.some(h => h.command && h.command.includes('gstack-session-update'))
|
||||
);
|
||||
|
||||
if (!exists) {
|
||||
settings.hooks.SessionStart.push({
|
||||
hooks: [{ type: 'command', command: hookCmd }]
|
||||
});
|
||||
}
|
||||
|
||||
const tmp = settingsPath + '.tmp';
|
||||
fs.writeFileSync(tmp, JSON.stringify(settings, null, 2) + '\n');
|
||||
fs.renameSync(tmp, settingsPath);
|
||||
" 2>/dev/null
|
||||
;;
|
||||
remove)
|
||||
[ -f "$SETTINGS_FILE" ] || exit 0
|
||||
bun -e "
|
||||
const fs = require('fs');
|
||||
const settingsPath = '$SETTINGS_FILE';
|
||||
|
||||
let settings = {};
|
||||
try { settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); } catch { process.exit(0); }
|
||||
|
||||
if (settings.hooks && settings.hooks.SessionStart) {
|
||||
settings.hooks.SessionStart = settings.hooks.SessionStart.filter(entry =>
|
||||
!(entry.hooks && entry.hooks.some(h => h.command && h.command.includes('gstack-session-update')))
|
||||
);
|
||||
if (settings.hooks.SessionStart.length === 0) delete settings.hooks.SessionStart;
|
||||
if (Object.keys(settings.hooks).length === 0) delete settings.hooks;
|
||||
}
|
||||
|
||||
const tmp = settingsPath + '.tmp';
|
||||
fs.writeFileSync(tmp, JSON.stringify(settings, null, 2) + '\n');
|
||||
fs.renameSync(tmp, settingsPath);
|
||||
" 2>/dev/null
|
||||
;;
|
||||
*)
|
||||
echo "Unknown action: $ACTION (expected add or remove)" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
+35
-6
@@ -6,13 +6,42 @@
|
||||
# Security: output is sanitized to [a-zA-Z0-9._-] only, preventing
|
||||
# shell injection when consumed via source or eval.
|
||||
set -euo pipefail
|
||||
RAW_SLUG=$(git remote get-url origin 2>/dev/null | sed 's|.*[:/]\([^/]*/[^/]*\)\.git$|\1|;s|.*[:/]\([^/]*/[^/]*\)$|\1|' | tr '/' '-') || true
|
||||
RAW_BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null | tr '/' '-') || true
|
||||
# Strip any characters that aren't alphanumeric, dot, hyphen, or underscore
|
||||
SLUG=$(printf '%s' "${RAW_SLUG:-}" | tr -cd 'a-zA-Z0-9._-')
|
||||
BRANCH=$(printf '%s' "${RAW_BRANCH:-}" | tr -cd 'a-zA-Z0-9._-')
|
||||
# Fallback when git context is absent
|
||||
|
||||
CACHE_DIR="$HOME/.gstack/slug-cache"
|
||||
PROJECT_DIR="$(pwd)"
|
||||
# Encode absolute path as cache key: /Users/j/foo → _Users_j_foo
|
||||
CACHE_KEY=$(printf '%s' "$PROJECT_DIR" | tr '/' '_')
|
||||
CACHE_FILE="${CACHE_DIR}/${CACHE_KEY}"
|
||||
|
||||
# 1. Try cached slug first (guarantees consistency across sessions)
|
||||
if [[ -f "$CACHE_FILE" ]]; then
|
||||
SLUG=$(cat "$CACHE_FILE")
|
||||
fi
|
||||
|
||||
# 2. If no cache, compute from git remote (separated from pipeline to avoid
|
||||
# pipefail swallowing the error and producing an empty slug)
|
||||
if [[ -z "${SLUG:-}" ]]; then
|
||||
REMOTE_URL=$(git remote get-url origin 2>/dev/null) || REMOTE_URL=""
|
||||
if [[ -n "$REMOTE_URL" ]]; then
|
||||
RAW_SLUG=$(printf '%s' "$REMOTE_URL" | sed 's|.*[:/]\([^/]*/[^/]*\)\.git$|\1|;s|.*[:/]\([^/]*/[^/]*\)$|\1|' | tr '/' '-')
|
||||
SLUG=$(printf '%s' "$RAW_SLUG" | tr -cd 'a-zA-Z0-9._-')
|
||||
fi
|
||||
fi
|
||||
|
||||
# 3. Fallback to basename only when there's truly no git remote configured
|
||||
SLUG="${SLUG:-$(basename "$PWD" | tr -cd 'a-zA-Z0-9._-')}"
|
||||
|
||||
# 4. Cache the slug for future sessions (atomic write, fail silently)
|
||||
if [[ -n "$SLUG" ]]; then
|
||||
mkdir -p "$CACHE_DIR" 2>/dev/null || true
|
||||
CACHE_TMP=$(mktemp "$CACHE_DIR/.slug-XXXXXX" 2>/dev/null) || CACHE_TMP=""
|
||||
if [[ -n "$CACHE_TMP" ]]; then
|
||||
printf '%s' "$SLUG" > "$CACHE_TMP" && mv "$CACHE_TMP" "$CACHE_FILE" 2>/dev/null || rm -f "$CACHE_TMP" 2>/dev/null
|
||||
fi
|
||||
fi
|
||||
|
||||
RAW_BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null) || RAW_BRANCH=""
|
||||
BRANCH=$(printf '%s' "${RAW_BRANCH:-}" | tr -cd 'a-zA-Z0-9._-')
|
||||
BRANCH="${BRANCH:-unknown}"
|
||||
echo "SLUG=$SLUG"
|
||||
echo "BRANCH=$BRANCH"
|
||||
|
||||
Executable
+65
@@ -0,0 +1,65 @@
|
||||
#!/usr/bin/env bash
|
||||
# gstack-specialist-stats — compute per-specialist hit rates from review history
|
||||
# Usage: gstack-specialist-stats
|
||||
#
|
||||
# Reads all *-reviews.jsonl files across branches, parses specialist fields,
|
||||
# and outputs hit rates. Tags specialists as GATE_CANDIDATE (0 findings in 10+
|
||||
# dispatches) or NEVER_GATE (security, data-migration — insurance policy).
|
||||
set -euo pipefail
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
eval "$("$SCRIPT_DIR/gstack-slug" 2>/dev/null)"
|
||||
GSTACK_HOME="${GSTACK_HOME:-$HOME/.gstack}"
|
||||
PROJECT_DIR="$GSTACK_HOME/projects/$SLUG"
|
||||
|
||||
if [ ! -d "$PROJECT_DIR" ]; then
|
||||
echo "SPECIALIST_STATS: 0 reviews analyzed"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Collect all review JSONL files (strip ---CONFIG--- and ---HEAD--- footers)
|
||||
COMBINED=""
|
||||
for f in "$PROJECT_DIR"/*-reviews.jsonl; do
|
||||
[ -f "$f" ] || continue
|
||||
COMBINED="$COMBINED$(sed '/^---/,$d' "$f" 2>/dev/null)
|
||||
"
|
||||
done
|
||||
|
||||
if [ -z "$COMBINED" ]; then
|
||||
echo "SPECIALIST_STATS: 0 reviews analyzed"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
printf '%s' "$COMBINED" | bun -e "
|
||||
const lines = (await Bun.stdin.text()).trim().split('\n').filter(Boolean);
|
||||
const NEVER_GATE = new Set(['security', 'data-migration']);
|
||||
const stats = {};
|
||||
let reviewed = 0;
|
||||
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const e = JSON.parse(line);
|
||||
if (!e.specialists) continue;
|
||||
reviewed++;
|
||||
for (const [name, info] of Object.entries(e.specialists)) {
|
||||
if (!stats[name]) stats[name] = { dispatched: 0, findings: 0 };
|
||||
if (info.dispatched) {
|
||||
stats[name].dispatched++;
|
||||
stats[name].findings += (info.findings || 0);
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
console.log('SPECIALIST_STATS: ' + reviewed + ' reviews analyzed');
|
||||
const sorted = Object.entries(stats).sort((a, b) => a[0].localeCompare(b[0]));
|
||||
for (const [name, s] of sorted) {
|
||||
const pct = s.dispatched > 0 ? Math.round(100 * s.findings / s.dispatched) : 0;
|
||||
let tag = '';
|
||||
if (NEVER_GATE.has(name)) {
|
||||
tag = ' [NEVER_GATE]';
|
||||
} else if (s.dispatched >= 10 && s.findings === 0) {
|
||||
tag = ' [GATE_CANDIDATE]';
|
||||
}
|
||||
console.log(name + ': ' + s.dispatched + '/' + reviewed + ' dispatched, ' + s.findings + ' findings (' + pct + '%)' + tag);
|
||||
}
|
||||
" 2>/dev/null || { echo "SPECIALIST_STATS: 0 reviews analyzed"; exit 0; }
|
||||
Executable
+192
@@ -0,0 +1,192 @@
|
||||
#!/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).
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
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, /investigate, and /browse become available after install.
|
||||
Use /browse for all web browsing. Use ~/.claude/skills/gstack/... for gstack file paths.'
|
||||
else
|
||||
SNIPPET='## gstack (REQUIRED — global install)
|
||||
|
||||
**Before doing ANY work, verify gstack is installed:**
|
||||
|
||||
```bash
|
||||
test -d ~/.claude/skills/gstack/bin && echo "GSTACK_OK" || 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, /investigate,
|
||||
and /browse are available. Use /browse for all web browsing.
|
||||
Use ~/.claude/skills/gstack/... for gstack file paths (the global path).'
|
||||
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 enforcement hook script
|
||||
mkdir -p "$HOOKS_DIR"
|
||||
cat > "$HOOKS_DIR/check-gstack.sh" << 'HOOK_EOF'
|
||||
#!/bin/bash
|
||||
# Block skill usage when gstack is not installed globally.
|
||||
|
||||
if [ ! -d "$HOME/.claude/skills/gstack/bin" ]; then
|
||||
cat >&2 <<'MSG'
|
||||
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.
|
||||
MSG
|
||||
echo '{"permissionDecision":"deny","message":"gstack is required but not installed. See stderr for install instructions."}'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo '{}'
|
||||
HOOK_EOF
|
||||
chmod +x "$HOOKS_DIR/check-gstack.sh"
|
||||
GENERATED+=(".claude/hooks/check-gstack.sh")
|
||||
echo " + .claude/hooks/check-gstack.sh — enforcement hook"
|
||||
|
||||
# Add hook to project-level settings.json
|
||||
if command -v bun >/dev/null 2>&1; then
|
||||
bun -e "
|
||||
const fs = require('fs');
|
||||
const settingsPath = '$SETTINGS';
|
||||
|
||||
let settings = {};
|
||||
try { settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); } catch {}
|
||||
|
||||
if (!settings.hooks) settings.hooks = {};
|
||||
if (!settings.hooks.PreToolUse) settings.hooks.PreToolUse = [];
|
||||
|
||||
// Dedup
|
||||
const exists = settings.hooks.PreToolUse.some(entry =>
|
||||
entry.matcher === 'Skill' &&
|
||||
entry.hooks && entry.hooks.some(h => h.command && h.command.includes('check-gstack'))
|
||||
);
|
||||
|
||||
if (!exists) {
|
||||
settings.hooks.PreToolUse.push({
|
||||
matcher: 'Skill',
|
||||
hooks: [{
|
||||
type: 'command',
|
||||
command: '\"\$CLAUDE_PROJECT_DIR/.claude/hooks/check-gstack.sh\"'
|
||||
}]
|
||||
});
|
||||
}
|
||||
|
||||
const tmp = settingsPath + '.tmp';
|
||||
fs.writeFileSync(tmp, JSON.stringify(settings, null, 2) + '\n');
|
||||
fs.renameSync(tmp, settingsPath);
|
||||
" 2>/dev/null
|
||||
GENERATED+=(".claude/settings.json")
|
||||
echo " + .claude/settings.json — PreToolUse hook registered"
|
||||
else
|
||||
echo " ! bun not found — manually add the PreToolUse hook to .claude/settings.json"
|
||||
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"
|
||||
@@ -122,6 +122,11 @@ case "$HTTP_CODE" in
|
||||
# Advance by SENT count (not inserted count) because we can't map inserted back to
|
||||
# source lines. If inserted==0, something is systemically wrong — don't advance.
|
||||
INSERTED="$(grep -o '"inserted":[0-9]*' "$RESP_FILE" 2>/dev/null | grep -o '[0-9]*' || echo "0")"
|
||||
# Check for upsert errors (installation tracking failures) — log but don't block cursor advance
|
||||
UPSERT_ERRORS="$(grep -o '"upsertErrors"' "$RESP_FILE" 2>/dev/null || true)"
|
||||
if [ -n "$UPSERT_ERRORS" ]; then
|
||||
echo "[gstack-telemetry-sync] Warning: installation upsert errors in response" >&2
|
||||
fi
|
||||
if [ "${INSERTED:-0}" -gt 0 ] 2>/dev/null; then
|
||||
NEW_CURSOR=$(( CURSOR + COUNT ))
|
||||
echo "$NEW_CURSOR" > "$CURSOR_FILE" 2>/dev/null || true
|
||||
|
||||
Executable
+34
@@ -0,0 +1,34 @@
|
||||
#!/usr/bin/env bash
|
||||
# gstack-timeline-log — append a timeline event to the project timeline
|
||||
# Usage: gstack-timeline-log '{"skill":"review","event":"started","branch":"main"}'
|
||||
#
|
||||
# Session timeline: local-only, never sent anywhere.
|
||||
# Required fields: skill, event (started|completed).
|
||||
# Optional: branch, outcome, duration_s, session, ts.
|
||||
# Validation failure → skip silently (non-blocking).
|
||||
set -euo pipefail
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
eval "$("$SCRIPT_DIR/gstack-slug" 2>/dev/null)"
|
||||
GSTACK_HOME="${GSTACK_HOME:-$HOME/.gstack}"
|
||||
mkdir -p "$GSTACK_HOME/projects/$SLUG"
|
||||
|
||||
INPUT="$1"
|
||||
|
||||
# Validate: input must be parseable JSON with required fields
|
||||
if ! printf '%s' "$INPUT" | bun -e "
|
||||
const j = JSON.parse(await Bun.stdin.text());
|
||||
if (!j.skill || !j.event) process.exit(1);
|
||||
" 2>/dev/null; then
|
||||
exit 0 # skip silently, non-blocking
|
||||
fi
|
||||
|
||||
# Inject timestamp if not present
|
||||
if ! printf '%s' "$INPUT" | bun -e "const j=JSON.parse(await Bun.stdin.text()); if(!j.ts) process.exit(1)" 2>/dev/null; then
|
||||
INPUT=$(printf '%s' "$INPUT" | bun -e "
|
||||
const j = JSON.parse(await Bun.stdin.text());
|
||||
j.ts = new Date().toISOString();
|
||||
console.log(JSON.stringify(j));
|
||||
" 2>/dev/null) || true
|
||||
fi
|
||||
|
||||
echo "$INPUT" >> "$GSTACK_HOME/projects/$SLUG/timeline.jsonl"
|
||||
Executable
+94
@@ -0,0 +1,94 @@
|
||||
#!/usr/bin/env bash
|
||||
# gstack-timeline-read — read and format project timeline
|
||||
# Usage: gstack-timeline-read [--since "7 days ago"] [--limit N] [--branch NAME]
|
||||
#
|
||||
# Session timeline: local-only, never sent anywhere.
|
||||
# Reads ~/.gstack/projects/$SLUG/timeline.jsonl, filters, formats.
|
||||
# Exit 0 silently if no timeline file exists.
|
||||
set -euo pipefail
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
eval "$("$SCRIPT_DIR/gstack-slug" 2>/dev/null)"
|
||||
GSTACK_HOME="${GSTACK_HOME:-$HOME/.gstack}"
|
||||
|
||||
SINCE=""
|
||||
LIMIT=20
|
||||
BRANCH=""
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--since) SINCE="$2"; shift 2 ;;
|
||||
--limit) LIMIT="$2"; shift 2 ;;
|
||||
--branch) BRANCH="$2"; shift 2 ;;
|
||||
*) shift ;;
|
||||
esac
|
||||
done
|
||||
|
||||
TIMELINE_FILE="$GSTACK_HOME/projects/$SLUG/timeline.jsonl"
|
||||
|
||||
if [ ! -f "$TIMELINE_FILE" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
cat "$TIMELINE_FILE" 2>/dev/null | bun -e "
|
||||
const lines = (await Bun.stdin.text()).trim().split('\n').filter(Boolean);
|
||||
const since = '${SINCE}';
|
||||
const branch = '${BRANCH}';
|
||||
const limit = ${LIMIT};
|
||||
|
||||
let sinceMs = 0;
|
||||
if (since) {
|
||||
// Parse relative time like '7 days ago'
|
||||
const match = since.match(/(\d+)\s*(day|hour|minute|week|month)s?\s*ago/i);
|
||||
if (match) {
|
||||
const n = parseInt(match[1]);
|
||||
const unit = match[2].toLowerCase();
|
||||
const ms = { minute: 60000, hour: 3600000, day: 86400000, week: 604800000, month: 2592000000 };
|
||||
sinceMs = Date.now() - n * (ms[unit] || 86400000);
|
||||
}
|
||||
}
|
||||
|
||||
const entries = [];
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const e = JSON.parse(line);
|
||||
if (sinceMs && new Date(e.ts).getTime() < sinceMs) continue;
|
||||
if (branch && e.branch !== branch) continue;
|
||||
entries.push(e);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (entries.length === 0) process.exit(0);
|
||||
|
||||
// Take last N entries
|
||||
const recent = entries.slice(-limit);
|
||||
|
||||
// Skill counts (completed events only)
|
||||
const counts = {};
|
||||
const branches = new Set();
|
||||
for (const e of entries) {
|
||||
if (e.event === 'completed') {
|
||||
counts[e.skill] = (counts[e.skill] || 0) + 1;
|
||||
}
|
||||
if (e.branch) branches.add(e.branch);
|
||||
}
|
||||
|
||||
// Output summary
|
||||
const countStr = Object.entries(counts)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.map(([s, n]) => n + ' /' + s)
|
||||
.join(', ');
|
||||
|
||||
if (countStr) {
|
||||
console.log('TIMELINE: ' + countStr + ' across ' + branches.size + ' branch' + (branches.size !== 1 ? 'es' : ''));
|
||||
}
|
||||
|
||||
// Output recent events
|
||||
console.log('');
|
||||
console.log('## Recent Events');
|
||||
for (const e of recent) {
|
||||
const ts = (e.ts || '').replace('T', ' ').replace(/\.\d+Z$/, 'Z');
|
||||
const dur = e.duration_s ? ' (' + e.duration_s + 's)' : '';
|
||||
const outcome = e.outcome ? ' [' + e.outcome + ']' : '';
|
||||
console.log('- ' + ts + ' /' + e.skill + ' ' + e.event + outcome + dur + (e.branch ? ' on ' + e.branch : ''));
|
||||
}
|
||||
" 2>/dev/null || exit 0
|
||||
@@ -227,6 +227,14 @@ if [ -n "$_GIT_ROOT" ]; then
|
||||
fi
|
||||
fi
|
||||
|
||||
# ─── Remove SessionStart hook from Claude Code settings ─────
|
||||
SETTINGS_HOOK="$(dirname "$0")/gstack-settings-hook"
|
||||
SESSION_UPDATE="$(dirname "$0")/gstack-session-update"
|
||||
SETTINGS_FILE="${GSTACK_SETTINGS_FILE:-$HOME/.claude/settings.json}"
|
||||
if [ -x "$SETTINGS_HOOK" ] && [ -f "$SETTINGS_FILE" ]; then
|
||||
"$SETTINGS_HOOK" remove "$SESSION_UPDATE" 2>/dev/null && REMOVED+=("SessionStart hook") || true
|
||||
fi
|
||||
|
||||
# ─── Remove global state ────────────────────────────────────
|
||||
if [ "$KEEP_STATE" -eq 0 ] && [ -d "$STATE_DIR" ]; then
|
||||
rm -rf "$STATE_DIR"
|
||||
|
||||
Reference in New Issue
Block a user