Merge lane wave/setup

This commit is contained in:
garrytan committed 2026-10-03 21:43:08 +00:00
commit 8857089e85
28 files changed
+1545 -129

No files matched your search

+30 -7
View File
@@ -157,6 +157,15 @@ CONFIG_HEADER='# gstack configuration — edit freely, changes take effect on ne
# gstack_contributor: false # true = file field reports when gstack misbehaves
# skip_eng_review: false # true = skip eng review gate in /ship (not recommended)
#
# ─── Browser extension ───────────────────────────────────────────────
# browse_extension_id: # Chrome extension ID the browse daemon trusts for
# # /extension-token and the terminal-agent /ws
# # (32 letters a-p). Empty = the published gstack
# # extension. Set it only for a fork or a
# # self-built extension; no environment variable
# # or project .env can change it. An invalid value
# # is REJECTED and the stored value kept.
#
# ─── Workspace-aware ship ────────────────────────────────────────────
# workspace_root: $HOME/conductor/workspaces # Where /ship looks for sibling
# # Conductor worktrees when picking a VERSION slot.
@@ -195,6 +204,7 @@ lookup_default() {
pair_agent) echo "off" ;; # remote tunnel consent — fail-closed until /pair-agent asks
memorable_recall) echo "off" ;; # on | off — Memorable bridge gate, fail-closed until `gstack-memorable enable`
founder_resources) echo "true" ;; # office-hours resource pitch — #538 permanent opt-out sets false
browse_extension_id) echo "" ;; # empty → browse trusts gstack's own extension ID (D1)
# Brain-aware planning (v1.48 / T5+T10+T16). Defaults documented inline:
# brain_trust_policy@<endpoint-id> — unset on fresh install; setup-gbrain
# writes 'personal' for local engines,
@@ -414,7 +424,7 @@ case "${1:-}" in
# A config value is one non-empty line (disabled_skills may be empty: it
# clears the list). Reject rather than truncate, so a bad value never
# replaces the stored one.
if [ -z "$VALUE" ] && [ "$KEY" != "disabled_skills" ]; then
if [ -z "$VALUE" ] && [ "$KEY" != "disabled_skills" ] && [ "$KEY" != "browse_extension_id" ]; then
echo "Error: $KEY value is empty. Existing value left unchanged." >&2
exit 1
fi
@@ -513,6 +523,13 @@ case "${1:-}" in
echo "Error: memorable_recall '$VALUE' not recognized. Valid values: on, off. Existing value left unchanged." >&2
exit 1
fi
# browse_extension_id (D1) decides which Chrome extension may reach the
# browse daemon's token route and terminal socket. Reject anything that is
# not a Chrome extension ID; "" clears the override.
if [ "$KEY" = "browse_extension_id" ] && [ -n "$VALUE" ] && ! printf '%s' "$VALUE" | LC_ALL=C grep -qE '^[a-p]{32}$'; then
echo "Error: browse_extension_id '$VALUE' is not a Chrome extension ID (32 letters a-p, shown on chrome://extensions). Existing value left unchanged." >&2
exit 1
fi
# disabled_skills (#1206): comma-separated skill names, one value for the
# key. Every name must be a real skill (typos get the closest name);
# gstack and gstack-upgrade can never be disabled; "" re-enables all.
@@ -618,7 +635,8 @@ case "${1:-}" in
skill_prefix explain_level \
codex_reviews gstack_contributor skip_eng_review workspace_root \
artifacts_sync_mode artifacts_sync_mode_prompted plan_tune_hooks \
timeline_stop_hook design_detector design_detector_install_prompted memorable_recall; do
timeline_stop_hook design_detector design_detector_install_prompted memorable_recall \
browse_extension_id; do
gstack_config_select "$KEY"
VALUE="$_gstack_cfg_value"
SOURCE="default"
@@ -638,7 +656,8 @@ case "${1:-}" in
skill_prefix explain_level \
codex_reviews gstack_contributor skip_eng_review workspace_root \
artifacts_sync_mode artifacts_sync_mode_prompted plan_tune_hooks \
timeline_stop_hook design_detector design_detector_install_prompted memorable_recall; do
timeline_stop_hook design_detector design_detector_install_prompted memorable_recall \
browse_extension_id; do
printf ' %-24s %s\n' "$KEY:" "$(lookup_default "$KEY")"
done
;;
@@ -674,10 +693,12 @@ case "${1:-}" in
# Summarize for the user. Use python (already required elsewhere) to
# parse the JSON portably; fall back to grep if python is unavailable.
# The file goes in on stdin (E6, #1967): bash opens the path, so a native
# Windows Python under Git Bash never has to resolve an MSYS /c/... path.
PYTHON_CMD=$(command -v python3 || command -v python || true)
if [ -n "$PYTHON_CMD" ]; then
STATUS=$("$PYTHON_CMD" -c "import json,sys; d=json.load(open('$DETECTION_FILE')); print(d.get('gbrain_local_status','unknown'))" 2>/dev/null || echo unknown)
VERSION=$("$PYTHON_CMD" -c "import json,sys; d=json.load(open('$DETECTION_FILE')); print(d.get('gbrain_version') or 'unknown')" 2>/dev/null || echo unknown)
STATUS=$("$PYTHON_CMD" -c "import json,sys; d=json.load(sys.stdin); print(d.get('gbrain_local_status','unknown'))" < "$DETECTION_FILE" 2>/dev/null || echo unknown)
VERSION=$("$PYTHON_CMD" -c "import json,sys; d=json.load(sys.stdin); print(d.get('gbrain_version') or 'unknown')" < "$DETECTION_FILE" 2>/dev/null || echo unknown)
else
STATUS=$(grep -o '"gbrain_local_status":[[:space:]]*"[^"]*"' "$DETECTION_FILE" | sed 's/.*"\([^"]*\)"$/\1/')
VERSION=$(grep -o '"gbrain_version":[[:space:]]*"[^"]*"' "$DETECTION_FILE" | sed 's/.*"\([^"]*\)"$/\1/')
@@ -686,8 +707,10 @@ case "${1:-}" in
fi
case "$STATUS" in
ok|timeout|thin-client|engine-locked)
# "timeout" = slow-but-healthy engine (#1964); "thin-client" =
ok|timeout|db-unreachable|thin-client|engine-locked)
# "timeout" = slow-but-healthy engine (#1964); "db-unreachable" =
# network/DNS failure with an intact config, transient like timeout
# (A2); "thin-client" =
# remote-HTTP MCP brain, no local engine by design (#2051);
# "engine-locked" = same class (#2456): PGLite is single-writer, so a
# live `gbrain serve` (typically an MCP server) holds the embedded DB.
+45 -3
View File
@@ -299,6 +299,47 @@ _dir_only_links() {
return 0
}
# _serve_rendered_assets SKILL TARGET — F5 (#2706): when the gbrain render has
# a counterpart of a runtime asset setup linked from the checkout (sections/,
# ...), serve the rendered file instead, per file (render dirs carry only the
# generated *.md). An asset whose render counterpart is gone goes back to the
# checkout. Only entries that are our links, or directories of nothing but our
# links, are touched; anything else is the user's and stays.
_serve_rendered_assets() {
local skill="$1" target="$2" asset name t child
for asset in "$INSTALL_DIR/$skill"/*; do
[ -e "$asset" ] || continue
name="${asset##*/}"
case "$name" in SKILL.md|node_modules|dist|test|*.tmpl) continue ;; esac
t="$target/$name"
if [ -L "$t" ]; then
_target_is_ours "$(_link_target_abs "$t")" || continue
elif [ -d "$t" ]; then
_dir_only_links "$t" || continue
else
continue
fi
if [ -d "$asset" ] && [ -d "$RENDER_DIR/$skill/$name" ]; then
rm -rf "$t"
mkdir -p "$t"
for child in "$asset"/*; do
[ -e "$child" ] || continue
if [ -f "$RENDER_DIR/$skill/$name/${child##*/}" ]; then
ln -snf "$RENDER_DIR/$skill/$name/${child##*/}" "$t/${child##*/}"
else
ln -snf "$child" "$t/${child##*/}"
fi
done
elif [ -f "$asset" ] && [ -f "$RENDER_DIR/$skill/$name" ]; then
rm -rf "$t"
ln -snf "$RENDER_DIR/$skill/$name" "$t"
elif [ -d "$t" ] && [ ! -L "$t" ] || [ "$(_link_target_abs "$t")" != "$asset" ]; then
rm -rf "$t"
ln -snf "$asset" "$t"
fi
done
}
_link_root_skill_alias() {
local target="$SKILLS_DIR/_gstack-command"
@@ -330,7 +371,7 @@ _link_root_skill_alias() {
_link_root_skill_alias
# Discover skills (directories with SKILL.md, excluding meta dirs)
# Discover skills: every top-level directory with a SKILL.md
SKILL_COUNT=0
for skill_dir in "$INSTALL_DIR"/*/; do
[ -d "$skill_dir" ] || continue
@@ -340,8 +381,8 @@ for skill_dir in "$INSTALL_DIR"/*/; do
# rewritten-copy alias for those.
[ -L "${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
# A directory is a skill iff it carries a SKILL.md (#2263): a name list
# dropped the real browse skill. Support dirs have no SKILL.md.
[ -f "$skill_dir/SKILL.md" ] || continue
case "$DISABLED" in
*" ${skill#gstack-} "*)
@@ -399,6 +440,7 @@ for skill_dir in "$INSTALL_DIR"/*/; do
fi
fi
ln -snf "$skill_md_src" "$target/SKILL.md"
_serve_rendered_assets "$skill" "$target"
# Provenance marker on every platform (path-independent proof; on Windows
# without Developer Mode `ln -snf` degrades to a copy and this is the only
# proof), but only for a directory we created or already owned.
+90 -19
View File
@@ -1,10 +1,17 @@
#!/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.
# Called by Claude Code SessionStart hook. Must be fast and non-fatal.
# The entire update runs in background (forked). The hook itself exits
# immediately so session startup is never delayed.
#
# Stages (B11): pull, setup and migrations are tracked separately. The
# "just upgraded" marker is written only when every stage succeeded. A failed
# stage is persisted in $STATE_DIR/session-update-pending and retried even
# when HEAD did not move, with backoff (the update-check interval, then 6h,
# then 24h). While a stage is pending, each session start prints one line
# naming it and the repair command; nothing else is ever printed.
#
# Exit 0 always — errors must never block a Claude Code session.
set +e
@@ -21,11 +28,20 @@ STATE_DIR="$(gstack_state_root; printf x)"; STATE_DIR="${STATE_DIR%x}"
THROTTLE_FILE="$STATE_DIR/.last-session-update"
LOCK_DIR="$STATE_DIR/.setup-lock"
LOG_FILE="$STATE_DIR/analytics/session-update.log"
PENDING_FILE="$STATE_DIR/session-update-pending"
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
echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) $1" >> "$LOG_FILE" 2>/dev/null || true # best-effort: logging never blocks a session
}
pending_get() { sed -n "s/^$1=//p" "$PENDING_FILE" 2>/dev/null | head -1; }
# pending_save — persist the P_* stage state; nothing pending removes the file.
pending_save() {
if [ -z "$P_STAGES" ] && [ -z "$P_PULL" ]; then rm -f "$PENDING_FILE"; return 0; fi
printf 'stages=%s\nfrom=%s\nfailures=%s\nnext=%s\nreason=%s\npull=%s\n' \
"$P_STAGES" "$P_FROM" "$P_FAILURES" "$P_NEXT" "$P_REASON" "$P_PULL" > "$PENDING_FILE.tmp.$$" \
&& mv -f "$PENDING_FILE.tmp.$$" "$PENDING_FILE"
}
# ── Guard: gstack must be a git repo ──
@@ -39,6 +55,18 @@ if [ "$AUTO" != "true" ]; then
exit 0
fi
# ── A stage that did not finish: one line per session start, with the fix ──
if [ -f "$PENDING_FILE" ]; then
_P_STAGES="$(pending_get stages)"; _P_REASON="$(pending_get reason)"; _P_PULL="$(pending_get pull)"
if [ -n "$_P_STAGES" ]; then
_P_FIX="cd $GSTACK_DIR && ./setup"
case "$_P_REASON" in bun*) _P_FIX="install Bun (https://bun.sh), then $_P_FIX" ;; esac
echo "gstack auto-update: ${_P_STAGES} did not finish (${_P_REASON:-unknown reason}); installed skills may be out of date. gstack retries automatically. Fix now: $_P_FIX"
elif [ -n "$_P_PULL" ]; then
echo "gstack auto-update: git pull did not finish ($_P_PULL); gstack is not updating. Fix now: cd $GSTACK_DIR && git pull --ff-only && ./setup"
fi
fi
# ── Throttle: skip if checked recently ──
if [ -f "$THROTTLE_FILE" ]; then
LAST=$(cat "$THROTTLE_FILE" 2>/dev/null || echo 0)
@@ -152,9 +180,16 @@ fi
# Record check time regardless of outcome
date +%s > "$THROTTLE_FILE" 2>/dev/null
P_STAGES="$(pending_get stages)"; P_FROM="$(pending_get from)"; P_REASON="$(pending_get reason)"
P_FAILURES="$(pending_get failures)"; P_NEXT="$(pending_get next)"; P_PULL=""
case "$P_FAILURES" in ''|*[!0-9]*) P_FAILURES=0 ;; esac
case "$P_NEXT" in ''|*[!0-9]*) P_NEXT=0 ;; esac
if [ "$PULL_EXIT" -ne 0 ]; then
PULL_REASON=$(head -c 300 "$PULL_ERR_FILE" 2>/dev/null | tr '\n' ' ' | tr -s ' ')
log_entry "PULL_FAILED exit=$PULL_EXIT reason=${PULL_REASON:-unknown}"
P_PULL="exit $PULL_EXIT: $(printf '%s' "${PULL_REASON:-no error output}" | head -c 160)"
pending_save
# Autostash pop conflict leaves the stash behind and the tree half-merged.
# The local patches are REGENERABLE (prefix renames, gbrain blocks), so
# recover to a clean upstream tree and re-render them below rather than
@@ -164,8 +199,8 @@ fi
git -C "$GSTACK_DIR" stash drop -q 2>/dev/null
log_entry "AUTOSTASH_CONFLICT_RECOVERED tree_reset=1"
_PREFIX_CFG=$("$GSTACK_DIR/bin/gstack-config" get skill_prefix 2>/dev/null || echo false)
"$GSTACK_DIR/bin/gstack-patch-names" "$GSTACK_DIR" "$_PREFIX_CFG" >/dev/null 2>&1 || true
"$GSTACK_DIR/bin/gstack-config" gbrain-refresh >/dev/null 2>&1 || true
"$GSTACK_DIR/bin/gstack-patch-names" "$GSTACK_DIR" "$_PREFIX_CFG" >/dev/null 2>&1 || true # best-effort: regenerable prefix rewrite
"$GSTACK_DIR/bin/gstack-config" gbrain-refresh >/dev/null 2>&1 || true # best-effort: regenerable gbrain blocks
fi
rm -f "$PULL_ERR_FILE" 2>/dev/null
exit 0
@@ -175,33 +210,69 @@ fi
# no-ops when the feature is unconfigured); the autostash pop usually
# preserves them, but a clean re-render costs nothing and self-heals.
_PREFIX_CFG=$("$GSTACK_DIR/bin/gstack-config" get skill_prefix 2>/dev/null || echo false)
"$GSTACK_DIR/bin/gstack-patch-names" "$GSTACK_DIR" "$_PREFIX_CFG" >/dev/null 2>&1 || true
"$GSTACK_DIR/bin/gstack-config" gbrain-refresh >/dev/null 2>&1 || true
"$GSTACK_DIR/bin/gstack-patch-names" "$GSTACK_DIR" "$_PREFIX_CFG" >/dev/null 2>&1 || true # best-effort: regenerable prefix rewrite
"$GSTACK_DIR/bin/gstack-config" gbrain-refresh >/dev/null 2>&1 || true # best-effort: regenerable gbrain blocks
# ── If HEAD moved, run setup -q ──
# ── Run setup when HEAD moved, or retry a pending stage after its backoff ──
NOW=$(date +%s)
RUN_SETUP=0
if [ "$OLD_HEAD" != "$NEW_HEAD" ]; then
log_entry "UPDATING old=$OLD_HEAD new=$NEW_HEAD"
RUN_SETUP=1
P_FAILURES=0
[ -n "$P_FROM" ] || P_FROM=$(git -C "$GSTACK_DIR" show "$OLD_HEAD:VERSION" 2>/dev/null || echo "unknown")
elif [ -n "$P_STAGES" ]; then
if [ "$NOW" -ge "$P_NEXT" ]; then
log_entry "RETRY_PENDING stages=$P_STAGES attempt=$((P_FAILURES + 1))"
RUN_SETUP=1
else
log_entry "PENDING_BACKOFF stages=$P_STAGES next=$P_NEXT"
fi
fi
# bun must be available for setup
if [ "$RUN_SETUP" -eq 1 ]; then
FAILED_STAGE="" FAIL_REASON=""
if command -v bun >/dev/null 2>&1; then
( cd "$GSTACK_DIR" && ./setup -q --refresh-registered ) >/dev/null 2>&1 || {
log_entry "SETUP_FAILED"
}
SETUP_OUT=$(mktemp "${TMPDIR:-/tmp}/gstack-session-setup-XXXXXX" 2>/dev/null || echo /dev/null)
( cd "$GSTACK_DIR" && ./setup -q --refresh-registered ) >"$SETUP_OUT" 2>&1
SETUP_EXIT=$?
# Heartbeat: setup done (either way) — refresh the TTL clock.
touch "$LOCK_DIR/pid" 2>/dev/null
if [ "$SETUP_EXIT" -ne 0 ]; then
_MIG="$(grep -o 'migration [^ ]* failed' "$SETUP_OUT" 2>/dev/null | head -1)"
if [ -n "$_MIG" ]; then
FAILED_STAGE="migrations" FAIL_REASON="$_MIG"
else
_LAST="$(grep -v '^[[:space:]]*$' "$SETUP_OUT" 2>/dev/null | tail -1 | head -c 160)"
FAILED_STAGE="setup" FAIL_REASON="./setup exited $SETUP_EXIT${_LAST:+: $_LAST}"
fi
fi
[ "$SETUP_OUT" = /dev/null ] || rm -f "$SETUP_OUT"
else
FAILED_STAGE="setup" FAIL_REASON="bun not found on PATH"
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)"
if [ -z "$FAILED_STAGE" ]; then
# Every stage succeeded: now the next skill preamble may say "just upgraded".
echo "${P_FROM:-unknown}" > "$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=${P_FROM:-unknown} to=$(cat "$GSTACK_DIR/VERSION" 2>/dev/null || echo unknown)"
P_STAGES="" P_FROM="" P_FAILURES=0 P_NEXT=0 P_REASON=""
pending_save
else
P_FAILURES=$((P_FAILURES + 1))
case "$P_FAILURES" in 1) _WAIT=$THROTTLE_SECONDS ;; 2) _WAIT=21600 ;; *) _WAIT=86400 ;; esac
P_STAGES="$FAILED_STAGE" P_NEXT=$((NOW + _WAIT)) P_REASON="$FAIL_REASON"
pending_save
[ "$FAILED_STAGE" = setup ] && [ "$FAIL_REASON" != "bun not found on PATH" ] && log_entry "SETUP_FAILED reason=$FAIL_REASON"
[ "$FAILED_STAGE" = migrations ] && log_entry "MIGRATIONS_FAILED reason=$FAIL_REASON"
log_entry "PENDING stages=$FAILED_STAGE attempt=$P_FAILURES next_retry_in=${_WAIT}s"
fi
else
log_entry "UP_TO_DATE head=$OLD_HEAD"
pending_save
[ -n "$P_STAGES" ] || log_entry "UP_TO_DATE head=$OLD_HEAD"
fi
# The detached subshell must own its stdio: it inherits the session hook's
# pipes, and once the hook exits and the caller closes them, any child that
+8 -3
View File
@@ -14,7 +14,8 @@
# gstack-settings-hook add-event --event <name — see the validator in add-event> \
# --command <cmd> --source <tag> [--matcher <regex>] [--timeout <s>]
# gstack-settings-hook ensure-event --event ... --command ... --source ... [--matcher ...] [--timeout <s>]
# gstack-settings-hook remove-source --source <tag> # removes items the table identifies as <tag>'s, tagged or not
# gstack-settings-hook remove-source --source <tag> [--event <name>]
# # removes items the table identifies as <tag>'s, tagged or not
# gstack-settings-hook diff-event --event ... --command ... --source ... [--matcher ...]
# gstack-settings-hook rollback # restore latest backup (single-step undo)
# gstack-settings-hook list-sources # show all gstack-tagged hook entries
@@ -79,7 +80,7 @@ Usage:
gstack-settings-hook remove <hook-command> # legacy SessionStart remove
gstack-settings-hook add-event --event <name> --command <cmd> --source <tag> [--matcher <re>] [--timeout <s>]
gstack-settings-hook ensure-event --event <name> --command <cmd> --source <tag> [--matcher <re>] [--timeout <s>]
gstack-settings-hook remove-source --source <tag> # tagged OR table-identified items of <tag>
gstack-settings-hook remove-source --source <tag> [--event <name>] # tagged OR table-identified items of <tag>
gstack-settings-hook diff-event --event <name> --command <cmd> --source <tag> [--matcher <re>] [--timeout <s>]
gstack-settings-hook prune-stale [--repoint <root>] [--all]
gstack-settings-hook rollback
@@ -641,10 +642,12 @@ case "$ACTION" in
remove-source)
SOURCE=""
RS_EVENT=""
shift
while [ $# -gt 0 ]; do
case "$1" in
--source) SOURCE="$2"; shift 2 ;;
--event) RS_EVENT="$2"; shift 2 ;;
*) echo "unknown flag: $1" >&2; exit 1 ;;
esac
done
@@ -655,9 +658,10 @@ case "$ACTION" in
[ -f "$SETTINGS_FILE" ] || exit 0
_acquire_lock || exit 5
_mutation_env
GSTACK_SETTINGS_PATH="$SETTINGS_FILE" GSTACK_SOURCE="$SOURCE" bun -e "$_HOOK_JS_PRELUDE"'gsMain(function () {
GSTACK_SETTINGS_PATH="$SETTINGS_FILE" GSTACK_SOURCE="$SOURCE" GSTACK_RS_EVENT="$RS_EVENT" bun -e "$_HOOK_JS_PRELUDE"'gsMain(function () {
const settingsPath = process.env.GSTACK_SETTINGS_PATH;
const source = process.env.GSTACK_SOURCE;
const onlyEvent = process.env.GSTACK_RS_EVENT || "";
const loaded = gsLoadSettings(settingsPath);
const settings = loaded.settings;
if (!settings.hooks) { console.log("OK: removed 0 hook entry/entries tagged source=" + source); process.exit(0); }
@@ -677,6 +681,7 @@ case "$ACTION" in
// the tag with its last owned item. Callers that need every gstack
// item gone still pair this with prune-stale --all.
for (const event of Object.keys(settings.hooks)) {
if (onlyEvent && event !== onlyEvent) continue; // --event scopes the removal (Q3)
const entries = settings.hooks[event];
if (!Array.isArray(entries)) continue; // foreign shape: not ours to judge
const kept = [];
+11 -5
View File
@@ -151,6 +151,8 @@ Install it:
Then restart your AI coding tool.
MSG
# Exit 2 denies on Claude Code (#2413) and on Copilot CLI, which treats a
# preToolUse exit 2 as deny (#2229).
echo '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"gstack is required but not installed. See stderr for install instructions."}}'
exit 2
fi
@@ -173,15 +175,19 @@ HOOK_EOF
if (!settings.hooks) settings.hooks = {};
if (!settings.hooks.PreToolUse) settings.hooks.PreToolUse = [];
// Dedup
const exists = settings.hooks.PreToolUse.some(entry =>
entry.matcher === 'Skill' &&
// 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).
// An entry an older team-init wrote is upgraded in place, never duplicated.
const MATCHER = 'Skill|skill';
const ours = settings.hooks.PreToolUse.filter(entry =>
(entry.matcher === 'Skill' || entry.matcher === MATCHER) &&
entry.hooks && entry.hooks.some(h => h.command && h.command.includes('check-gstack'))
);
for (const entry of ours) entry.matcher = MATCHER;
if (!exists) {
if (ours.length === 0) {
settings.hooks.PreToolUse.push({
matcher: 'Skill',
matcher: MATCHER,
hooks: [{
type: 'command',
command: '\"\$CLAUDE_PROJECT_DIR/.claude/hooks/check-gstack.sh\"'
+12 -2
View File
@@ -20,8 +20,9 @@ const codex = defineHost({
// generateMetadata emits agents/openai.yaml (the format is hardcoded in
// gen-skill-docs.ts). Codex also gets a repo-local sidecar at
// .agents/skills/gstack (symlinked runtime assets: bin, browse, review, qa,
// ETHOS.md) — that behavior lives in setup's create_agents_sidecar, not here.
// .agents/skills/gstack (symlinked runtime assets: bin, lib, browse, review,
// qa, design/dist, make-pdf/dist, ETHOS.md) — that behavior lives in setup's
// create_agents_sidecar, not here.
generation: {
generateMetadata: true,
skipSkills: ['codex'],
@@ -38,6 +39,15 @@ const codex = defineHost({
{ from: 'CLAUDE.md', to: 'AGENTS.md' },
],
// Mirrors create_codex_runtime_root in setup. design/dist and make-pdf/dist
// back $GSTACK_DESIGN and $GSTACK_MAKE_PDF (#2891).
runtimeRoot: {
globalSymlinks: ['bin', 'lib', 'browse/dist', 'browse/bin', 'design/dist', 'make-pdf/dist', 'gstack-upgrade', 'ETHOS.md'],
globalFiles: {
'review': ['checklist.md', 'design-checklist.md', 'greptile-triage.md', 'TODOS-format.md'],
},
},
// Outside-review resolvers route to Claude Code; Review Army has its own restriction.
suppressedResolvers: ['REVIEW_ARMY', ...GBRAIN_RESOLVERS],
+1 -1
View File
@@ -126,7 +126,7 @@ export function defineHost<const N extends string>(overrides: HostOverrides<N>):
toolRewrites,
suppressedResolvers = [...GBRAIN_RESOLVERS],
runtimeRoot = {
globalSymlinks: ['bin', 'browse/dist', 'browse/bin', 'gstack-upgrade', 'ETHOS.md'],
globalSymlinks: ['bin', 'lib', 'browse/dist', 'browse/bin', 'design/dist', 'make-pdf/dist', 'gstack-upgrade', 'ETHOS.md'],
globalFiles: {
'review': ['checklist.md', 'TODOS-format.md'],
},
+1 -1
View File
@@ -14,7 +14,7 @@ const opencode = defineHost({
// OpenCode links a wider runtime asset set than the shared default
// (design binary, review specialists, qa templates/references, DX hall of fame).
runtimeRoot: {
globalSymlinks: ['bin', 'browse/dist', 'browse/bin', 'design/dist', 'gstack-upgrade', 'ETHOS.md', 'review/specialists', 'qa/templates', 'qa/references', 'plan-devex-review/dx-hall-of-fame.md'],
globalSymlinks: ['bin', 'lib', 'browse/dist', 'browse/bin', 'design/dist', 'make-pdf/dist', 'gstack-upgrade', 'ETHOS.md', 'review/specialists', 'qa/templates', 'qa/references', 'plan-devex-review/dx-hall-of-fame.md'],
globalFiles: {
'review': ['checklist.md', 'design-checklist.md', 'greptile-triage.md', 'TODOS-format.md'],
},
+5 -2
View File
@@ -64,7 +64,10 @@ try {
$output = Join-Path $temporary 'probe.exe'
Set-Content -LiteralPath $source -Encoding Ascii -NoNewline -Value 'int main(void) { return 0; }'
$object = Join-Path $temporary 'probe.obj'
& $compiler /nologo /std:c11 /W4 /WX /O2 /MT /GS /guard:cf /D_CRT_SECURE_NO_WARNINGS "/Fo$object" "/Fe$output" $source /link /DYNAMICBASE /NXCOMPAT /HIGHENTROPYVA
# /wd5105: the Windows SDK's own winbase.h raises C5105 ("macro expansion
# producing 'defined' has undefined behavior"), which /WX turns into
# error C2220 inside a system header (#3015). Only that diagnostic is off.
& $compiler /nologo /std:c11 /W4 /WX /wd5105 /O2 /MT /GS /guard:cf /D_CRT_SECURE_NO_WARNINGS "/Fo$object" "/Fe$output" $source /link /DYNAMICBASE /NXCOMPAT /HIGHENTROPYVA
if ($LASTEXITCODE -ne 0) { throw "Native CSO Windows compiler probe failed ($LASTEXITCODE)." }
if (-not (Test-Path -LiteralPath $output -PathType Leaf)) { throw 'Native CSO Windows compiler probe was not produced.' }
} else {
@@ -78,7 +81,7 @@ try {
foreach ($build in $builds) {
$object = Join-Path $temporary $build.Object
$forcedInclude = if ($build.Binding) { "/FI$binding" } else { @() }
& $compiler /nologo /std:c11 /W4 /WX /O2 /MT /GS /guard:cf /D_CRT_SECURE_NO_WARNINGS $forcedInclude "/Fo$object" "/Fe$($build.Output)" $build.Source /link /DYNAMICBASE /NXCOMPAT /HIGHENTROPYVA
& $compiler /nologo /std:c11 /W4 /WX /wd5105 /O2 /MT /GS /guard:cf /D_CRT_SECURE_NO_WARNINGS $forcedInclude "/Fo$object" "/Fe$($build.Output)" $build.Source /link /DYNAMICBASE /NXCOMPAT /HIGHENTROPYVA
if ($LASTEXITCODE -ne 0) { throw "Native CSO Windows helper compilation failed ($LASTEXITCODE)." }
if (-not (Test-Path -LiteralPath $build.Output -PathType Leaf)) { throw 'Native CSO Windows output was not produced.' }
}
+283 -71
View File
@@ -62,6 +62,65 @@ for _arg in "$@"; do
esac
done
# --status is read-only: no bun, no build, no writes (docs/ADDING_A_HOST.md).
# _status_install_checks — per registered install (C3): is the router a real
# file (Codex skips a symlinked SKILL.md), and does every section pointer in an
# installed SKILL.md resolve? A pointer is "Read `<path>/sections/<f>.md`",
# optionally "relative to the installed `<skill>` SKILL.md directory". A bare
# `sections/...` pointer counts in a carved skill (sections/ installed, or a
# **STOP.** line); prefixed ones ($GSTACK_ROOT, ~, absolute, ../) everywhere.
_status_install_checks() {
local reg="$GSTACK_STATE_ROOT/installs.tsv" host scope project dest root src rest router md dir stop tok names n path checked broken first fix
[ -f "$reg" ] || return 0
echo "Install checks:"
while IFS=' ' read -r host scope project dest root src rest; do
case "$host" in ''|'#'*) continue ;; esac
fix="Fix: cd $src && ./setup --host $host"
router="$root/SKILL.md"
if [ -L "$router" ]; then router="router is a symlink, which Codex skips. $fix"
elif [ -f "$router" ]; then router="router is a real file"
else router="router missing at $router. $fix"; fi
checked=0 broken=0 first=""
for md in "$dest"/*/SKILL.md; do
[ -f "$md" ] || continue
dir="${md%/SKILL.md}"
while IFS='|' read -r stop tok names; do
[ -n "$tok" ] || continue
path=""
case "$tok" in
sections/*)
if [ -n "$names" ]; then
for n in $names; do path="$dest/$n/$tok"; [ -r "$path" ] && break; done
elif [ -d "$dir/sections" ] || [ "$stop" = 1 ]; then path="$dir/$tok"
else continue; fi ;;
'$GSTACK_ROOT/'*) path="$root/${tok#\$GSTACK_ROOT/}" ;;
'~/'*) path="$HOME/${tok#\~/}" ;;
/*) path="$tok" ;;
../*) path="$dir/$tok" ;;
*) continue ;;
esac
checked=$((checked + 1))
[ -r "$path" ] && continue
broken=$((broken + 1))
[ -n "$first" ] || first="${md#"$dest"/} names $tok"
done <<EOF
$(awk '{
line = $0; stop = index(line, "**STOP.**") ? 1 : 0
while (match(line, /Read `[^` ]*sections\/[A-Za-z0-9._-]+\.md`( relative to the installed (`[^`]+`\/?)+)?/)) {
m = substr(line, RSTART + 6, RLENGTH - 6); line = substr(line, RSTART + RLENGTH)
tok = m; sub(/`.*/, "", tok); names = ""
if (index(m, " relative to the installed ")) { names = substr(m, index(m, " relative to the installed ") + 27); gsub(/[`\/]+/, " ", names) }
print stop "|" tok "|" names
}
}' "$md")
EOF
done
if [ "$broken" -eq 0 ]; then
printf ' %s %s: %s; section links: %s checked, all resolve\n' "$host" "$scope" "$router" "$checked"
else
printf ' %s %s: %s; section links: %s of %s broken (first: %s). %s\n' "$host" "$scope" "$router" "$broken" "$checked" "$first" "$fix"
fi
done < "$reg"
}
for _arg in "$@"; do
case "$_arg" in
--status)
@@ -69,6 +128,7 @@ for _arg in "$@"; do
. "$_STATUS_DIR/bin/gstack-state-root.sh" && . "$_STATUS_DIR/bin/gstack-install-registry.sh" || exit 1
gstack_state_root_select; GSTACK_STATE_ROOT="$_gstack_sr_root"
gstack_install_status
_status_install_checks
exit 0 ;;
esac
done
@@ -496,6 +556,80 @@ _link_or_copy() {
fi
}
# _link_runtime_dists SOURCE ROOT — the compiled design and make-pdf binaries
# behind $GSTACK_DESIGN and $GSTACK_MAKE_PDF on every env-var host (#2891).
# dist/ only, never the whole directory: make-pdf/ ships a Claude SKILL.md a
# host scanning its skills root would discover as a duplicate skill.
_link_runtime_dists() {
local d
for d in design/dist make-pdf/dist; do
[ -d "$1/$d" ] || continue
if [ "$IS_WINDOWS" -eq 1 ] || [ -L "$2/$d" ] || [ ! -e "$2/$d" ]; then
mkdir -p "$2/${d%/dist}"
_link_or_copy "$1/$d" "$2/$d"
fi
done
}
# ─── Real-file SKILL.md copies in host runtime roots (C3, #333) ──────────────
# Codex skips a symlinked SKILL.md file, so the router and the nested SKILL.md
# files in every host runtime root are real copies, rewritten on every run.
# setup records the hash of each copy it writes in $GSTACK_STATE_ROOT/
# skill-copies.tsv (hash<TAB>path); a copy whose bytes no longer match was
# edited by hand and is saved to a uniquely named backup under the state root
# before the tree is replaced, so the backup outlives the old tree.
_SKILL_COPIES_FILE="$GSTACK_STATE_ROOT/skill-copies.tsv"
_copy_skill_md() {
rm -f "$2" && cp "$1" "$2"
}
_skill_copy_hash() {
git hash-object "$1" 2>/dev/null
}
# _skill_copy_unmodified FILE — FILE is a copy setup wrote and nobody edited.
_skill_copy_unmodified() {
local h
[ -f "$1" ] && [ ! -L "$1" ] && [ -f "$_SKILL_COPIES_FILE" ] || return 1
h="$(_skill_copy_hash "$1")" || return 1
grep -qxF "$h $1" "$_SKILL_COPIES_FILE"
}
# _preserve_skill_copy_edits ROOT — back up every recorded copy under ROOT
# whose bytes changed. Non-zero when a backup failed: the caller must then
# keep the old tree (a failed backup is never a license to overwrite).
_preserve_skill_copy_edits() {
local root="$1" hash path dir=""
[ -f "$_SKILL_COPIES_FILE" ] || return 0
while IFS=' ' read -r hash path; do
case "$path" in "$root"/*) ;; *) continue ;; esac
{ [ -f "$path" ] && [ ! -L "$path" ]; } || continue
[ "$(_skill_copy_hash "$path")" = "$hash" ] && continue
if [ -z "$dir" ]; then
mkdir -p "$GSTACK_STATE_ROOT/backups/skill-copies" || return 1
dir="$(mktemp -d "$GSTACK_STATE_ROOT/backups/skill-copies/$(date +%Y%m%dT%H%M%S)-XXXXXX")" || return 1
fi
mkdir -p "$dir/$(dirname "${path#/}")" && cp -p "$path" "$dir/${path#/}" || return 1
echo " saved your edited $path to $dir/${path#/} (setup rewrites gstack's SKILL.md copies on every run)"
done < "$_SKILL_COPIES_FILE"
}
# _record_skill_copies ROOT — replace ROOT's rows with the real SKILL.md files
# now under it (find does not descend into linked directories). Sets
# _COPIES_REFRESHED and _COPIES_REMOVED (paths relative to ROOT).
_record_skill_copies() {
local root="$1" f tmp old="" new
_COPIES_REFRESHED="" _COPIES_REMOVED=""
if [ -f "$_SKILL_COPIES_FILE" ]; then
old="$(awk -F'\t' -v r="$root/" 'index($2, r) == 1 { print substr($2, length(r) + 1) }' "$_SKILL_COPIES_FILE" | sort)"
fi
new="$(cd "$root" 2>/dev/null && find . -name SKILL.md -type f | sed 's|^\./||' | sort)"
mkdir -p "$GSTACK_STATE_ROOT" || return 1
tmp="$_SKILL_COPIES_FILE.tmp.$$"
{
[ -f "$_SKILL_COPIES_FILE" ] && awk -F'\t' -v r="$root/" 'index($2, r) != 1' "$_SKILL_COPIES_FILE"
for f in $new; do printf '%s\t%s\n' "$(_skill_copy_hash "$root/$f")" "$root/$f"; done
} > "$tmp" && mv -f "$tmp" "$_SKILL_COPIES_FILE" || { rm -f "$tmp"; return 1; }
_COPIES_REFRESHED="$(printf '%s\n' "$new" | tr '\n' ' ' | sed 's/ *$//')"
_COPIES_REMOVED="$(printf '%s\n' "$old" | grep -vxF -f <(printf '%s\n' "$new") | tr '\n' ' ' | sed 's/ *$//')"
}
# ─── Ownership gate for skill entries (#2119) ─────────────────────────────────
# setup and gstack-relink must never delete or link over a skill they do not
# own. This is the single rule both use (relink carries the same logic — keep
@@ -1342,9 +1476,10 @@ _cso_unavailable() {
# A successful probe is only a prerequisite check; any later source build failure
# still aborts setup and therefore cannot be mistaken for a missing local tool.
probe_cso_build_prerequisites() {
local _help _flag _platform _compiler _tmp _ps_script _repo_root _git_path
local _help _flag _platform _compiler _tmp _ps_script _repo_root _git_path _probe_out
CSO_BUILD_AVAILABLE=1
CSO_FAIL_REASON=""
CSO_PROBE_DETAIL=""
if [ "${GSTACK_SETUP_SKIP_CSO_BUILD:-0}" = "1" ]; then
_cso_unavailable "skipped-by-request"
return 0
@@ -1371,9 +1506,16 @@ probe_cso_build_prerequisites() {
_cso_unavailable "windows-git"
return 0
fi
if ! powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass \
-File "$_ps_script" -RepoRoot "$_repo_root" -GitExePath "$(cygpath -aw "$_git_path")" -CheckOnly >/dev/null 2>&1; then
_cso_unavailable "windows-msvc-toolchain"
# E2 (#3015): a probe that found MSVC but could not compile is not a
# missing toolchain; keep the probe's own first diagnostic for the summary.
if ! _probe_out="$(powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass \
-File "$_ps_script" -RepoRoot "$_repo_root" -GitExePath "$(cygpath -aw "$_git_path")" -CheckOnly 2>&1)"; then
case "$_probe_out" in
*"compiler probe failed"*|*"compiler probe was not produced"*)
_cso_unavailable "windows-msvc-compile"
CSO_PROBE_DETAIL="$(printf '%s\n' "$_probe_out" | grep -m1 -E 'error [A-Z]*[0-9]+|probe (failed|was not produced)' | tr -d '\r' | head -c 200)" ;;
*) _cso_unavailable "windows-msvc-toolchain" ;;
esac
fi
return 0
fi
@@ -1526,9 +1668,17 @@ if [ "$NEEDS_BUILD" -eq 1 ]; then
if [ "$(uname -s)" = "Darwin" ] && [ "$(uname -m)" = "arm64" ]; then
# CSO artifacts are signed and verified inside build-cso's staged generation;
# mutating them here would break its launcher-last publication guarantee.
# B4 (#1254): every binary is backed up first and restored as built when
# re-signing fails, so a failed codesign never leaves a half-signed file.
for _bin in browse/dist/browse browse/dist/find-browse design/dist/design make-pdf/dist/pdf bin/gstack-global-discover; do
_bin_path="$SOURCE_GSTACK_DIR/$_bin"
[ -f "$_bin_path" ] && [ -x "$_bin_path" ] || continue
_bin_bak="$_bin_path.gstack-presign.$$"
if ! cp -p "$_bin_path" "$_bin_bak" 2>/dev/null; then
rm -f "$_bin_bak"
log "note: could not back up $_bin before re-signing; left it as built"
continue
fi
# Strip any trailing bytes past LC_CODE_SIGNATURE so codesign can re-sign.
# otool prints the signature's dataoff+datasize; if the file is larger,
# the extra bytes are Bun padding that breaks strict validation.
@@ -1542,17 +1692,25 @@ if [ "$NEEDS_BUILD" -eq 1 ]; then
[ -n "$_trunc_tmp" ] && rm -f "$_trunc_tmp"
fi
codesign --remove-signature "$_bin_path" 2>/dev/null || true
if ! codesign -s - -f "$_bin_path" 2>/dev/null; then
# Re-sign failed. Only warn if the binary genuinely cannot execute
# (SIGKILL = exit 137). Otherwise Bun's adhoc code-page signature still
# runs fine and the codesign --verify miss is cosmetic. set -e safe.
_probe_rc=0
"$_bin_path" --help >/dev/null 2>&1 || _probe_rc=$?
if [ "$_probe_rc" -eq 137 ]; then
log "warning: codesign failed for $_bin and it is SIGKILL'd on exec (exit 137) — it may not run on Apple Silicon"
else
log "note: codesign could not re-sign $_bin, but it executes fine (Bun adhoc signature); continuing"
fi
_signed=1
if codesign -s - -f "$_bin_path" 2>/dev/null; then
rm -f "$_bin_bak"
else
_signed=0
mv -f "$_bin_bak" "$_bin_path"
fi
# Exec probe: after a failed re-sign (the restored binary as built), and
# always for design, whose skills trust a binary that starts. Only
# SIGKILL (exit 137) means it cannot run; set -e safe.
[ "$_signed" -eq 0 ] || [ "$_bin" = design/dist/design ] || continue
_probe_rc=0
"$_bin_path" --help >/dev/null 2>&1 || _probe_rc=$?
if [ "$_probe_rc" -eq 137 ] && [ "$_bin" = design/dist/design ]; then
echo " design unavailable: $_bin_path is killed at launch (exit 137) after re-signing, usually an invalid code signature. Design skills will report DESIGN_NOT_AVAILABLE. Fix: cd $SOURCE_GSTACK_DIR && ./setup" >&2
elif [ "$_probe_rc" -eq 137 ]; then
log "warning: codesign failed for $_bin and it is SIGKILL'd on exec (exit 137) — it may not run on Apple Silicon"
elif [ "$_signed" -eq 0 ]; then
log "note: codesign could not re-sign $_bin; restored it as built, and it executes fine (Bun adhoc signature); continuing"
fi
done
fi
@@ -1878,7 +2036,13 @@ _link_skill_runtime_assets() {
# instead of replaced (#2119). Symlinks are never content and are always
# refreshed. Default 1 = the directory is ours.
local replace_real="${3:-1}"
local asset asset_name
# $4 = this skill's directory in the gbrain render, or empty (F5, #2706).
# The rendered SKILL.md is served from there, and its section index names
# sections relative to the installed directory, so a rendered counterpart of
# an asset is served per file over the checkout's (render dirs carry only
# generated *.md; manifest.json and other assets still come from the checkout).
local render_dir="${4:-}"
local asset asset_name child
for asset in "$src_dir"/*; do
[ -e "$asset" ] || continue # empty-glob guard
asset_name="$(basename "$asset")"
@@ -1894,7 +2058,21 @@ _link_skill_runtime_assets() {
if [ -e "$dst_dir/$asset_name" ] || [ -L "$dst_dir/$asset_name" ]; then
rm -rf "$dst_dir/$asset_name"
fi
_link_or_copy "$asset" "$dst_dir/$asset_name"
if [ -n "$render_dir" ] && [ -d "$asset" ] && [ -d "$render_dir/$asset_name" ]; then
mkdir -p "$dst_dir/$asset_name"
for child in "$asset"/*; do
[ -e "$child" ] || continue
if [ -f "$render_dir/$asset_name/${child##*/}" ]; then
_link_or_copy "$render_dir/$asset_name/${child##*/}" "$dst_dir/$asset_name/${child##*/}"
else
_link_or_copy "$child" "$dst_dir/$asset_name/${child##*/}"
fi
done
elif [ -n "$render_dir" ] && [ -f "$asset" ] && [ -f "$render_dir/$asset_name" ]; then
_link_or_copy "$render_dir/$asset_name" "$dst_dir/$asset_name"
else
_link_or_copy "$asset" "$dst_dir/$asset_name"
fi
# P5: the exclusion list above filters DIRECT children only, but the
# Windows cp -R copy sweeps NESTED gitignored build output too (concrete:
# ios-qa/scripts/gen-accessors-tool/.build is 252MB). Prune post-copy —
@@ -2001,13 +2179,16 @@ link_claude_skill_dirs() {
# #2569: prefer a rendered :user variant when present. gbrain installs
# render brain-aware SKILL.md into ${GSTACK_HOME}/render/claude via
# gen:skill-docs --out-dir instead of dirtying the tracked source
# checkout; when a render exists for this skill, serve it. The rendered
# file's section-base paths point into the render dir, so section reads
# resolve there too.
# checkout; when a render exists for this skill, serve it. Its STOP
# pointers are absolute render-dir paths, but its section index names
# `sections/<f>.md` relative to this directory, so the runtime assets
# below prefer the render too (F5, #2706).
_skill_md_src="$gstack_dir/$dir_name/SKILL.md"
_render_dir="${GSTACK_USER_RENDER_DIR:-$GSTACK_STATE_ROOT/render/claude}"
_skill_render=""
if [ -f "$_render_dir/$dir_name/SKILL.md" ]; then
_skill_md_src="$_render_dir/$dir_name/SKILL.md"
_skill_render="$_render_dir/$dir_name"
fi
# A real-file SKILL.md we can only WEAKLY prove ours and whose content
# differs from what we are about to serve is moved aside, not overwritten.
@@ -2033,7 +2214,7 @@ link_claude_skill_dirs() {
# landed and /review 404'd at "Read .claude/skills/review/checklist.md"
# on every fresh Claude install. Routes through _link_or_copy so Windows
# gets real copies refreshed on every ./setup.
_link_skill_runtime_assets "$gstack_dir/$dir_name" "$target" "$_assets_replace"
_link_skill_runtime_assets "$gstack_dir/$dir_name" "$target" "$_assets_replace" "$_skill_render"
linked+=("$link_name")
fi
done
@@ -2322,6 +2503,8 @@ create_agents_sidecar() {
fi
done
_link_runtime_dists "$SOURCE_GSTACK_DIR" "$agents_gstack"
# supabase/config.sh — required by gstack-telemetry-sync to resolve GSTACK_SUPABASE_URL
# (file-level on purpose: migrations/ and functions/ are dev-only)
if [ -f "$SOURCE_GSTACK_DIR/supabase/config.sh" ]; then
@@ -2353,6 +2536,11 @@ _activate_runtime_root() {
echo " error: could not build the new runtime root for $root (exit $rc); the previous one is unchanged" >&2
return "$rc"
fi
if ! _preserve_skill_copy_edits "$root"; then
rm -rf "$new"
echo " error: could not back up an edited SKILL.md copy under $root; the previous runtime root is unchanged" >&2
return 1
fi
if [ -e "$root" ] || [ -L "$root" ]; then
mv "$root" "$old" || { rm -rf "$new"; return 1; }
fi
@@ -2362,6 +2550,10 @@ _activate_runtime_root() {
return 1
fi
rm -rf "$old"
_record_skill_copies "$root" || echo " warning: could not record SKILL.md copy hashes in $_SKILL_COPIES_FILE; a later hand edit there may not be backed up" >&2
[ -n "$_COPIES_REFRESHED" ] && log " refreshed SKILL.md copies in $root: $_COPIES_REFRESHED"
[ -n "$_COPIES_REMOVED" ] && log " removed SKILL.md copies from $root: $_COPIES_REMOVED"
return 0
}
# ─── Helper: create a minimal ~/.codex/skills/gstack runtime root ───────────
@@ -2384,7 +2576,7 @@ create_codex_runtime_root() {
mkdir -p "$codex_gstack" "$codex_gstack/browse" "$codex_gstack/gstack-upgrade" "$codex_gstack/review"
if [ -f "$agents_dir/gstack/SKILL.md" ]; then
_link_or_copy "$agents_dir/gstack/SKILL.md" "$codex_gstack/SKILL.md"
_copy_skill_md "$agents_dir/gstack/SKILL.md" "$codex_gstack/SKILL.md"
fi
if [ -d "$gstack_dir/bin" ]; then
_link_or_copy "$gstack_dir/bin" "$codex_gstack/bin"
@@ -2398,15 +2590,16 @@ create_codex_runtime_root() {
if [ -d "$gstack_dir/browse/bin" ]; then
_link_or_copy "$gstack_dir/browse/bin" "$codex_gstack/browse/bin"
fi
_link_runtime_dists "$gstack_dir" "$codex_gstack"
if [ -f "$agents_dir/gstack-upgrade/SKILL.md" ]; then
_link_or_copy "$agents_dir/gstack-upgrade/SKILL.md" "$codex_gstack/gstack-upgrade/SKILL.md"
_copy_skill_md "$agents_dir/gstack-upgrade/SKILL.md" "$codex_gstack/gstack-upgrade/SKILL.md"
fi
# plan-eng-review's inline office-hours step reads
# $GSTACK_ROOT/office-hours/SKILL.md (#2449) — install the codex-rendered
# variant there so the documented path exists.
if [ -f "${agents_dir}/gstack-office-hours/SKILL.md" ]; then
mkdir -p "${codex_gstack}/office-hours"
_link_or_copy "${agents_dir}/gstack-office-hours/SKILL.md" "${codex_gstack}/office-hours/SKILL.md"
_copy_skill_md "${agents_dir}/gstack-office-hours/SKILL.md" "${codex_gstack}/office-hours/SKILL.md"
fi
# Review runtime assets (individual files, NOT the whole review/ dir which has SKILL.md)
for f in checklist.md design-checklist.md greptile-triage.md TODOS-format.md; do
@@ -2439,7 +2632,7 @@ create_factory_runtime_root() {
mkdir -p "$factory_gstack" "$factory_gstack/browse" "$factory_gstack/gstack-upgrade" "$factory_gstack/review"
if [ -f "$factory_dir/gstack/SKILL.md" ]; then
_link_or_copy "$factory_dir/gstack/SKILL.md" "$factory_gstack/SKILL.md"
_copy_skill_md "$factory_dir/gstack/SKILL.md" "$factory_gstack/SKILL.md"
fi
if [ -d "$gstack_dir/bin" ]; then
_link_or_copy "$gstack_dir/bin" "$factory_gstack/bin"
@@ -2453,15 +2646,16 @@ create_factory_runtime_root() {
if [ -d "$gstack_dir/browse/bin" ]; then
_link_or_copy "$gstack_dir/browse/bin" "$factory_gstack/browse/bin"
fi
_link_runtime_dists "$gstack_dir" "$factory_gstack"
if [ -f "$factory_dir/gstack-upgrade/SKILL.md" ]; then
_link_or_copy "$factory_dir/gstack-upgrade/SKILL.md" "$factory_gstack/gstack-upgrade/SKILL.md"
_copy_skill_md "$factory_dir/gstack-upgrade/SKILL.md" "$factory_gstack/gstack-upgrade/SKILL.md"
fi
# plan-eng-review's inline office-hours step reads
# $GSTACK_ROOT/office-hours/SKILL.md (#2449) — install the factory-rendered
# variant there so the documented path exists.
if [ -f "${factory_dir}/gstack-office-hours/SKILL.md" ]; then
mkdir -p "${factory_gstack}/office-hours"
_link_or_copy "${factory_dir}/gstack-office-hours/SKILL.md" "${factory_gstack}/office-hours/SKILL.md"
_copy_skill_md "${factory_dir}/gstack-office-hours/SKILL.md" "${factory_gstack}/office-hours/SKILL.md"
fi
for f in checklist.md design-checklist.md greptile-triage.md TODOS-format.md; do
if [ -f "$gstack_dir/review/$f" ]; then
@@ -2489,10 +2683,10 @@ create_opencode_runtime_root() {
rm -rf "$opencode_gstack"
fi
mkdir -p "$opencode_gstack" "$opencode_gstack/browse" "$opencode_gstack/design" "$opencode_gstack/gstack-upgrade" "$opencode_gstack/review" "$opencode_gstack/qa" "$opencode_gstack/plan-devex-review"
mkdir -p "$opencode_gstack" "$opencode_gstack/browse" "$opencode_gstack/gstack-upgrade" "$opencode_gstack/review" "$opencode_gstack/qa" "$opencode_gstack/plan-devex-review"
if [ -f "$opencode_dir/gstack/SKILL.md" ]; then
_link_or_copy "$opencode_dir/gstack/SKILL.md" "$opencode_gstack/SKILL.md"
_copy_skill_md "$opencode_dir/gstack/SKILL.md" "$opencode_gstack/SKILL.md"
fi
if [ -d "$gstack_dir/bin" ]; then
_link_or_copy "$gstack_dir/bin" "$opencode_gstack/bin"
@@ -2506,18 +2700,16 @@ create_opencode_runtime_root() {
if [ -d "$gstack_dir/browse/bin" ]; then
_link_or_copy "$gstack_dir/browse/bin" "$opencode_gstack/browse/bin"
fi
if [ -d "$gstack_dir/design/dist" ]; then
_link_or_copy "$gstack_dir/design/dist" "$opencode_gstack/design/dist"
fi
_link_runtime_dists "$gstack_dir" "$opencode_gstack"
if [ -f "$opencode_dir/gstack-upgrade/SKILL.md" ]; then
_link_or_copy "$opencode_dir/gstack-upgrade/SKILL.md" "$opencode_gstack/gstack-upgrade/SKILL.md"
_copy_skill_md "$opencode_dir/gstack-upgrade/SKILL.md" "$opencode_gstack/gstack-upgrade/SKILL.md"
fi
# plan-eng-review's inline office-hours step reads
# $GSTACK_ROOT/office-hours/SKILL.md (#2449) — install the opencode-rendered
# variant there so the documented path exists.
if [ -f "${opencode_dir}/gstack-office-hours/SKILL.md" ]; then
mkdir -p "${opencode_gstack}/office-hours"
_link_or_copy "${opencode_dir}/gstack-office-hours/SKILL.md" "${opencode_gstack}/office-hours/SKILL.md"
_copy_skill_md "${opencode_dir}/gstack-office-hours/SKILL.md" "${opencode_gstack}/office-hours/SKILL.md"
fi
for f in checklist.md design-checklist.md greptile-triage.md TODOS-format.md; do
if [ -f "$gstack_dir/review/$f" ]; then
@@ -2656,7 +2848,7 @@ create_cursor_runtime_root() {
mkdir -p "$cursor_gstack" "$cursor_gstack/browse" "$cursor_gstack/gstack-upgrade" "$cursor_gstack/review"
if [ -f "$cursor_dir/gstack/SKILL.md" ]; then
_link_or_copy "$cursor_dir/gstack/SKILL.md" "$cursor_gstack/SKILL.md"
_copy_skill_md "$cursor_dir/gstack/SKILL.md" "$cursor_gstack/SKILL.md"
fi
# bin scripts import shared modules via ../lib — bin and lib travel together.
if [ -d "$gstack_dir/bin" ]; then
@@ -2671,8 +2863,9 @@ create_cursor_runtime_root() {
if [ -d "$gstack_dir/browse/bin" ]; then
_link_or_copy "$gstack_dir/browse/bin" "$cursor_gstack/browse/bin"
fi
_link_runtime_dists "$gstack_dir" "$cursor_gstack"
if [ -f "$cursor_dir/gstack-upgrade/SKILL.md" ]; then
_link_or_copy "$cursor_dir/gstack-upgrade/SKILL.md" "$cursor_gstack/gstack-upgrade/SKILL.md"
_copy_skill_md "$cursor_dir/gstack-upgrade/SKILL.md" "$cursor_gstack/gstack-upgrade/SKILL.md"
fi
# Review runtime assets — the cursor host config ships the lean pair.
for f in checklist.md TODOS-format.md; do
@@ -2728,6 +2921,7 @@ create_cursor_sidecar() {
_link_or_copy "$repo_root/browse/bin" "$cursor_gstack/browse/bin"
fi
fi
_link_runtime_dists "$repo_root" "$cursor_gstack"
if [ -f "$cursor_dir/gstack-upgrade/SKILL.md" ]; then
if [ "$IS_WINDOWS" -eq 1 ] || [ -L "$cursor_gstack/gstack-upgrade/SKILL.md" ] || [ ! -e "$cursor_gstack/gstack-upgrade/SKILL.md" ]; then
_link_or_copy "$cursor_dir/gstack-upgrade/SKILL.md" "$cursor_gstack/gstack-upgrade/SKILL.md"
@@ -2795,7 +2989,7 @@ link_cursor_skill_dirs() {
# marker is the user's and is never touched; managed files whose skill is gone
# or disabled are removed.
write_opencode_commands() {
local skills_dir="$1/.opencode/skills" cmd_dir="$2" d name file desc marker
local skills_dir="$1/.opencode/skills" cmd_dir="$2" d name file desc marker skill
marker='<!-- gstack-managed command (./setup --host opencode): edits are overwritten -->'
mkdir -p "$cmd_dir"
for d in "$skills_dir"/gstack-*/; do
@@ -2808,8 +3002,11 @@ write_opencode_commands() {
fi
case "${_DISABLED_SKILLS:- }" in *" ${name#gstack-} "*) rm -f "$file"; continue ;; esac
desc="$(awk '/^description:/ { f = 1; next } f && /^[^ ]/ { exit } f && NF { sub(/^ +/, ""); print; exit }' "$d/SKILL.md" | sed 's/\\/\\\\/g; s/"/\\"/g')"
# The skill tool knows a skill by its frontmatter name (gstack-review's is
# `review`, which OpenCode's builtin /review shadows as a command, #2651).
skill="$(awk '/^---$/ { fm++; next } fm == 1 && $1 == "name:" { print $2; exit }' "$d/SKILL.md")"
printf -- '---\ndescription: "%s"\nagent: build\nsubtask: false\n---\n%s\nLoad the `%s` skill with the skill tool and follow its instructions.\n\n$ARGUMENTS\n' \
"${desc:-gstack $name}" "$marker" "$name" > "$file.tmp.$$" && mv -f "$file.tmp.$$" "$file"
"${desc:-gstack $name}" "$marker" "${skill:-$name}" > "$file.tmp.$$" && mv -f "$file.tmp.$$" "$file"
done
for file in "$cmd_dir"/gstack-*.md; do
[ -f "$file" ] && grep -qF "$marker" "$file" 2>/dev/null || continue
@@ -2831,19 +3028,20 @@ create_copilot_runtime_root() {
mkdir -p "$copilot_gstack" "$copilot_gstack/browse" "$copilot_gstack/gstack-upgrade" "$copilot_gstack/review"
printf '%s\n' "$gstack_dir" > "$copilot_gstack/.source-path"
if [ -f "$copilot_dir/gstack/SKILL.md" ]; then
_link_or_copy "$copilot_dir/gstack/SKILL.md" "$copilot_gstack/SKILL.md"
_copy_skill_md "$copilot_dir/gstack/SKILL.md" "$copilot_gstack/SKILL.md"
fi
for asset in bin lib browse/dist browse/bin; do
if [ -d "$gstack_dir/$asset" ]; then
_link_or_copy "$gstack_dir/$asset" "$copilot_gstack/$asset"
fi
done
_link_runtime_dists "$gstack_dir" "$copilot_gstack"
if [ -f "$copilot_dir/gstack-upgrade/SKILL.md" ]; then
_link_or_copy "$copilot_dir/gstack-upgrade/SKILL.md" "$copilot_gstack/gstack-upgrade/SKILL.md"
_copy_skill_md "$copilot_dir/gstack-upgrade/SKILL.md" "$copilot_gstack/gstack-upgrade/SKILL.md"
fi
if [ -f "$copilot_dir/gstack-office-hours/SKILL.md" ]; then
mkdir -p "$copilot_gstack/office-hours"
_link_or_copy "$copilot_dir/gstack-office-hours/SKILL.md" "$copilot_gstack/office-hours/SKILL.md"
_copy_skill_md "$copilot_dir/gstack-office-hours/SKILL.md" "$copilot_gstack/office-hours/SKILL.md"
fi
for f in checklist.md design-checklist.md greptile-triage.md TODOS-format.md; do
if [ -f "$gstack_dir/review/$f" ]; then
@@ -3122,6 +3320,7 @@ if [ "$INSTALL_KIRO" -eq 1 ]; then
_link_or_copy "$SOURCE_GSTACK_DIR/lib" "$KIRO_GSTACK/lib"
_link_or_copy "$SOURCE_GSTACK_DIR/browse/dist" "$KIRO_GSTACK/browse/dist"
_link_or_copy "$SOURCE_GSTACK_DIR/browse/bin" "$KIRO_GSTACK/browse/bin"
_link_runtime_dists "$SOURCE_GSTACK_DIR" "$KIRO_GSTACK"
if [ -f "$SOURCE_GSTACK_DIR/ETHOS.md" ]; then
_link_or_copy "$SOURCE_GSTACK_DIR/ETHOS.md" "$KIRO_GSTACK/ETHOS.md"
fi
@@ -3129,11 +3328,19 @@ if [ "$INSTALL_KIRO" -eq 1 ]; then
mkdir -p "$KIRO_GSTACK/supabase"
_link_or_copy "$SOURCE_GSTACK_DIR/supabase/config.sh" "$KIRO_GSTACK/supabase/config.sh"
fi
_link_or_copy "$KIRO_DIR/gstack-upgrade/SKILL.md" "$KIRO_GSTACK/gstack-upgrade/SKILL.md"
_link_or_copy "$KIRO_DIR/gstack/SKILL.md" "$KIRO_GSTACK/SKILL.md"
if ! _preserve_skill_copy_edits "$KIRO_GSTACK"; then
echo " error: could not back up an edited SKILL.md copy under $KIRO_GSTACK; Kiro install stopped before overwriting it" >&2
exit 1
fi
if [ -f "$KIRO_DIR/gstack-upgrade/SKILL.md" ]; then
_copy_skill_md "$KIRO_DIR/gstack-upgrade/SKILL.md" "$KIRO_GSTACK/gstack-upgrade/SKILL.md"
fi
if [ -f "$KIRO_DIR/gstack/SKILL.md" ]; then
_copy_skill_md "$KIRO_DIR/gstack/SKILL.md" "$KIRO_GSTACK/SKILL.md"
fi
if [ -f "$KIRO_DIR/gstack-office-hours/SKILL.md" ]; then
mkdir -p "$KIRO_GSTACK/office-hours"
_link_or_copy "$KIRO_DIR/gstack-office-hours/SKILL.md" "$KIRO_GSTACK/office-hours/SKILL.md"
_copy_skill_md "$KIRO_DIR/gstack-office-hours/SKILL.md" "$KIRO_GSTACK/office-hours/SKILL.md"
fi
for f in checklist.md design-checklist.md greptile-triage.md TODOS-format.md; do
if [ -f "$SOURCE_GSTACK_DIR/review/$f" ]; then
@@ -3155,13 +3362,15 @@ if [ "$INSTALL_KIRO" -eq 1 ]; then
mkdir -p "$target_dir"
if [ -f "$target_dir/SKILL.md" ] && [ ! -L "$target_dir/SKILL.md" ] \
&& ! _claude_entry_owned_strongly "$target_dir" "$SOURCE_GSTACK_DIR" \
&& ! _skill_copy_unmodified "$target_dir/SKILL.md" \
&& ! cmp -s "$target_dir/SKILL.md" "$skill_dir/SKILL.md"; then
if ! _backup_skill_md "$target_dir/SKILL.md" "$skill_name"; then
echo " skipped $skill_name: could not back up its customized SKILL.md — left untouched" >&2
continue
fi
fi
_link_or_copy "$skill_dir/SKILL.md" "$target_dir/SKILL.md"
_copy_skill_md "$skill_dir/SKILL.md" "$target_dir/SKILL.md"
_record_skill_copies "$target_dir" || true
# Native sections already contain Kiro paths/provider markers. Refresh
# generated files individually so user assets next to them survive.
if [ -d "$skill_dir/sections" ]; then
@@ -3215,6 +3424,8 @@ if [ "$INSTALL_KIRO" -eq 1 ]; then
fi
done
_prune_stale_generated "$SOURCE_GSTACK_DIR" "$KIRO_DIR" "$KIRO_SKILLS"
_record_skill_copies "$KIRO_GSTACK" || echo " warning: could not record SKILL.md copy hashes in $_SKILL_COPIES_FILE" >&2
[ -n "$_COPIES_REFRESHED" ] && log " refreshed SKILL.md copies in $KIRO_GSTACK: $_COPIES_REFRESHED"
_setup_arm_publish kiro global - "$KIRO_SKILLS" "$KIRO_GSTACK" -
echo "gstack ready (kiro)."
echo " browse: $BROWSE_BIN"
@@ -3685,19 +3896,22 @@ if [ "$NO_TEAM_MODE" -ne 1 ] \
*) PT_DECISION="prompt" ;;
esac
# Conductor host reliability: the PreToolUse preference hook also carries the
# Conductor-prose enforcement (deny the flaky mcp__conductor__AskUserQuestion,
# redirect to a prose decision brief). A Conductor workspace setup otherwise
# falls through to "prompt" → the non-interactive skip below, leaving Conductor
# users without that backstop. Treat Conductor as an implicit opt-in — but
# only on the silent fall-through, never overriding an explicit --no-plan-tune-hooks.
# Only the true silent fall-through auto-opts-in. An explicit
# --plan-tune-hooks=prompt (bin/dev-setup passes exactly this so ephemeral
# workspace setups never install) stays "prompt" — this was the bug that
# baked worktree hook paths into every Conductor user's settings.json.
if [ "$PT_DECISION" = "prompt" ] && [ "$PT_EXPLICIT" -eq 0 ] && { [ -n "${CONDUCTOR_WORKSPACE_PATH:-}" ] || [ -n "${CONDUCTOR_PORT:-}" ]; }; then
PT_DECISION="yes"
_PT_CONDUCTOR_AUTO=1
# Conductor (Q3, #2207): the PreToolUse preference hook breaks Conductor's
# native AskUserQuestion round-trip even when it only defers, so setup never
# installs it in a Conductor workspace unless the user explicitly opted in,
# and removes one an earlier setup added. The other plan-tune hooks are
# unaffected.
_PT_SKIP_PREF=0
if { [ -n "${CONDUCTOR_WORKSPACE_PATH:-}" ] || [ -n "${CONDUCTOR_PORT:-}" ]; } \
&& ! { [ "$PT_EXPLICIT" -eq 1 ] && [ "$PT_DECISION" = "yes" ]; }; then
_PT_SKIP_PREF=1
if [ -n "$("$SETTINGS_HOOK" list-items --event PreToolUse --owned-by plan-tune-cathedral 2>/dev/null)" ]; then
if "$SETTINGS_HOOK" remove-source --source plan-tune-cathedral --event PreToolUse >/dev/null 2>&1; then
log " removed the AskUserQuestion preference hook: it breaks Conductor's native AskUserQuestion (#2207). Keep it anyway: gstack-config set plan_tune_hooks yes, then ./setup"
else
log " warning: could not remove the AskUserQuestion preference hook, which breaks Conductor's native AskUserQuestion. Fix: $SETTINGS_HOOK remove-source --source plan-tune-cathedral --event PreToolUse"
fi
fi
fi
_install_plan_tune_hooks() {
@@ -3714,12 +3928,14 @@ if [ "$NO_TEAM_MODE" -ne 1 ] \
--command "$PLAN_TUNE_LOG_HOOK" \
--source plan-tune-cathedral \
--timeout 5 || _pt_install_rc=1
"$SETTINGS_HOOK" ensure-event \
--event PreToolUse \
--matcher '(AskUserQuestion|mcp__.*__AskUserQuestion)' \
--command "$PLAN_TUNE_PREF_HOOK" \
--source plan-tune-cathedral \
--timeout 5 || _pt_install_rc=1
if [ "$_PT_SKIP_PREF" -eq 0 ]; then
"$SETTINGS_HOOK" ensure-event \
--event PreToolUse \
--matcher '(AskUserQuestion|mcp__.*__AskUserQuestion)' \
--command "$PLAN_TUNE_PREF_HOOK" \
--source plan-tune-cathedral \
--timeout 5 || _pt_install_rc=1
fi
# AskUserQuestion-failure prose-fallback reliability hook (OV3:B). Fires only when
# an AskUserQuestion call returns an error/missing result; inert on success and
# inert if the platform doesn't invoke PostToolUse on tool errors. MUST use its
@@ -3751,15 +3967,10 @@ if [ "$NO_TEAM_MODE" -ne 1 ] \
log ""
log "Plan-tune hooks already installed. Run \`$SETTINGS_HOOK list-sources\` to inspect."
elif [ "$PT_DECISION" = "yes" ]; then
# Explicit opt-in (flag / env / config) or Conductor implicit opt-in. Non-interactive.
# Explicit opt-in (flag / env / config). Non-interactive.
if _install_plan_tune_hooks; then
log ""
if [ "${_PT_CONDUCTOR_AUTO:-0}" -eq 1 ]; then
log "AskUserQuestion reliability hooks installed (Conductor detected): decisions"
log "render as a prose brief instead of the flaky AskUserQuestion tool. Inspect with /plan-tune."
else
log "Plan-tune hooks installed. Run /plan-tune anytime to inspect."
fi
log "Plan-tune hooks installed. Run /plan-tune anytime to inspect."
else
log ""
log " warning: some AskUserQuestion hooks were NOT registered (settings lock contention or a settings error above) — re-run ./setup to complete."
@@ -3995,6 +4206,7 @@ if [ "$CSO_BUILD_AVAILABLE" -eq 0 ]; then
macos-codesign|macos-native-toolchain) _CSO_PREREQ="install the macOS compiler and codesign command-line tools" ;;
windows-shell-toolchain) _CSO_PREREQ="run setup from Git Bash with Windows PowerShell available" ;;
windows-msvc-toolchain) _CSO_PREREQ="install Visual Studio 2022 Build Tools with Desktop development with C++" ;;
windows-msvc-compile) _CSO_PREREQ="fix the MSVC compile error its toolchain probe reported (${CSO_PROBE_DETAIL:-see scripts/build-cso-windows.ps1 -CheckOnly}); Visual Studio is already installed" ;;
windows-git) _CSO_PREREQ="install Git for Windows and run setup from its Git Bash" ;;
unsupported-platform) _CSO_PREREQ="use a supported macOS, Linux, or Windows host" ;;
skipped-by-request) _CSO_PREREQ="re-run without GSTACK_SETUP_SKIP_CSO_BUILD=1" ;;
@@ -0,0 +1,55 @@
/**
* browse_extension_id (D1): the one override for which Chrome extension the
* browse daemon trusts on /extension-token and the terminal-agent /ws. It
* lives only in gstack-config (never an env var a project .env could set),
* defaults to empty (gstack's published extension), and rejects anything
* that is not a Chrome extension ID.
*/
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
import { spawnSync } from 'child_process';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
const CONFIG_BIN = path.join(path.resolve(import.meta.dir, '..'), 'bin', 'gstack-config');
const ID = 'abcdefghijklmnopabcdefghijklmnop';
let state: string;
function cfg(args: string[]) {
const r = spawnSync('bash', [CONFIG_BIN, ...args], {
encoding: 'utf-8', timeout: 30_000,
env: { ...process.env, HOME: state, GSTACK_STATE_ROOT: state, GSTACK_HOME: state },
});
return { code: r.status ?? -1, out: (r.stdout ?? '').trim(), err: r.stderr ?? '' };
}
beforeEach(() => { state = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-cfg-extid-')); });
afterEach(() => { fs.rmSync(state, { recursive: true, force: true }); });
describe('browse_extension_id config key (D1)', () => {
test('is a known key whose default is empty (exit 0)', () => {
expect(cfg(['get', 'browse_extension_id'])).toMatchObject({ code: 0, out: '' });
});
test('a Chrome extension ID round-trips and "" clears it', () => {
expect(cfg(['set', 'browse_extension_id', ID]).code).toBe(0);
expect(cfg(['get', 'browse_extension_id']).out).toBe(ID);
expect(cfg(['set', 'browse_extension_id', '']).code).toBe(0);
expect(cfg(['get', 'browse_extension_id'])).toMatchObject({ code: 0, out: '' });
});
test('a value that is not an extension ID is rejected and the stored one kept', () => {
cfg(['set', 'browse_extension_id', ID]);
for (const bad of ['*', 'chrome-extension://' + ID, ID.toUpperCase(), ID.slice(1), ID + 'a', 'abcdefghijklmnopqrstuvwxyzabcdef']) {
const r = cfg(['set', 'browse_extension_id', bad]);
expect(r.code, bad).toBe(1);
expect(r.err).toContain('Existing value left unchanged');
}
expect(cfg(['get', 'browse_extension_id']).out).toBe(ID);
});
test('appears in list and defaults', () => {
expect(cfg(['list']).out).toMatch(/browse_extension_id:\s+\(default\)/);
expect(cfg(['defaults']).out).toMatch(/browse_extension_id:/);
});
});
+5 -3
View File
@@ -28,7 +28,7 @@
* depend on receiving "" successfully.
*
* The reverse direction is pinned too: every key in the DEFAULTS table must
* be read by code (bin/, lib/, scripts/, setup, or a .tmpl), or be listed in
* be read by code (bin/, lib/, scripts/, browse/src/, setup, or a .tmpl), or be listed in
* PROSE_ONLY_KEYS with a reason. A documented setting no code reads is a
* switch that does nothing, which is how a consent choice once went unread.
*/
@@ -118,9 +118,11 @@ function keysReadInTree(root = ROOT): string[] {
* DEFAULTS-table keys no code reads. Each entry needs a reason; an empty list
* means every documented setting has a reader.
*/
const PROSE_ONLY_KEYS: Record<string, string> = {};
const PROSE_ONLY_KEYS: Record<string, string> = {
browse_extension_id: 'D1: read by the browse daemon (browse/src) once the browse lane lands; delete this entry then',
};
const READER_DIRS = ['bin', 'lib', 'scripts'];
const READER_DIRS = ['bin', 'lib', 'scripts', 'browse/src'];
const READER_FILES = ['setup'];
/**
+64
View File
@@ -0,0 +1,64 @@
/**
* E6 (#1967): under Git Bash on Windows, a native Windows Python first on PATH
* cannot open an MSYS-style /c/... path, so gbrain-refresh always summarized
* the detection file as "local-status: unknown". The file is now read by bash
* and handed to Python on stdin. The stub Python below refuses any path
* argument, as a native Windows Python does with an MSYS path, and parses stdin.
*/
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
import { spawnSync } from 'child_process';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
const ROOT = path.resolve(import.meta.dir, '..');
let tmp: string;
let binDir: string;
let home: string;
const writeDetect = (status: string) => fs.writeFileSync(path.join(binDir, 'gstack-gbrain-detect'),
`#!/usr/bin/env bash\nprintf '%s\\n' '{"gbrain_on_path":true,"gbrain_local_status":"${status}","gbrain_version":"0.35.8.0"}'\n`, { mode: 0o755 });
const refresh = () => spawnSync('bash', [path.join(binDir, 'gstack-config'), 'gbrain-refresh'], {
encoding: 'utf8', timeout: 30_000,
env: { PATH: `${binDir}:${process.env.PATH ?? '/usr/bin:/bin'}`, HOME: home, GSTACK_HOME: home, GSTACK_STATE_ROOT: home },
});
beforeEach(() => {
tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gbrain-refresh-'));
binDir = path.join(tmp, 'bin');
home = path.join(tmp, 'home');
fs.mkdirSync(binDir, { recursive: true });
fs.mkdirSync(home, { recursive: true });
for (const f of ['gstack-config', 'gstack-state-root.sh']) {
fs.copyFileSync(path.join(ROOT, 'bin', f), path.join(binDir, f));
fs.chmodSync(path.join(binDir, f), 0o755);
}
writeDetect('no-config');
// A Python that cannot open() the state path but reads stdin, like native
// Windows Python handed an MSYS path. Bun stands in for its JSON parse.
fs.writeFileSync(path.join(binDir, 'python3'), `#!/usr/bin/env bash
case "$2" in *"open("*) echo "FileNotFoundError: [Errno 2] No such file or directory" >&2; exit 1 ;; esac
exec ${JSON.stringify(process.execPath)} -e 'const d = JSON.parse(require("fs").readFileSync(0, "utf8")); console.log(process.argv[1].includes("gbrain_version") ? (d.gbrain_version || "unknown") : (d.gbrain_local_status || "unknown"))' "$2"
`, { mode: 0o755 });
});
afterEach(() => { fs.rmSync(tmp, { recursive: true, force: true }); });
describe('gstack-config gbrain-refresh (E6)', () => {
test('reads the detection file through stdin, so the status is not masked as unknown', () => {
const r = refresh();
expect(r.status, r.stderr).toBe(0);
expect(r.stdout).toContain('local-status: no-config');
expect(r.stdout).not.toContain('local-status: unknown');
});
// A2: a network/DNS failure with an intact config is transient, like
// timeout, so brain-aware blocks are kept rather than stripped.
test('db-unreachable counts as a configured gbrain, like timeout', () => {
for (const status of ['timeout', 'db-unreachable']) {
writeDetect(status);
const r = refresh();
expect(r.status, r.stderr).toBe(0);
expect(r.stdout).toContain(`Detected gbrain v0.35.8.0 (local-status: ${status}).`);
expect(r.stdout).not.toContain('brain-aware blocks will be suppressed');
}
});
});
+91 -2
View File
@@ -11,7 +11,7 @@
* namespace (#2879). Instruction-only hosts must change nothing.
*/
import { afterEach, describe, expect, test } from 'bun:test';
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, rmSync } from 'node:fs';
import { accessSync, chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { ALL_HOST_CONFIGS } from '../hosts/index';
import { cleanupFixtures, makeFixture, makeSource, put, registryRows, runSetup, setVersion, tree } from './helpers/install-fixture';
@@ -45,6 +45,52 @@ function skillNames(dir: string): string[] {
return names;
}
/** Every SKILL.md under dir that is itself a symlink; linked directories are not entered (INV-3). */
function symlinkedSkillMds(dir: string): string[] {
const found: string[] = [];
for (const name of readdirSync(dir)) {
const p = join(dir, name);
const st = lstatSync(p);
if (st.isSymbolicLink()) { if (name === 'SKILL.md') found.push(p); continue; }
if (st.isDirectory()) found.push(...symlinkedSkillMds(p));
}
return found;
}
/**
* Section pointers in installed SKILL.md files that do not resolve to a
* readable file (INV-3, ENG-1). A pointer is "Read `<path>/sections/<f>.md`",
* optionally "relative to the installed `<skill>` SKILL.md directory". A bare
* `sections/...` pointer counts in a carved skill (sections/ installed, or a
* **STOP.** line); prefixed ones count everywhere. Same rule as ./setup --status.
*/
function brokenSectionPointers(dest: string, runtimeRoot: string, home: string): string[] {
const readable = (p: string) => { try { accessSync(p); return true; } catch { return false; } };
const broken: string[] = [];
for (const skill of readdirSync(dest)) {
const dir = join(dest, skill);
if (!existsSync(join(dir, 'SKILL.md'))) continue;
for (const line of readFileSync(join(dir, 'SKILL.md'), 'utf8').split('\n')) {
const stop = line.includes('**STOP.**');
for (const [, tok, rel] of line.matchAll(/Read `([^` ]*sections\/[A-Za-z0-9._-]+\.md)`((?: relative to the installed (?:`[^`]+`\/?)+)?)/g)) {
const names = [...rel.matchAll(/`([^`]+)`/g)].map(m => m[1]);
let targets: string[];
if (tok.startsWith('sections/')) {
if (names.length) targets = names.map(n => join(dest, n, tok));
else if (existsSync(join(dir, 'sections')) || stop) targets = [join(dir, tok)];
else continue;
} else if (tok.startsWith('$GSTACK_ROOT/')) targets = [join(runtimeRoot, tok.slice('$GSTACK_ROOT/'.length))];
else if (tok.startsWith('~/')) targets = [join(home, tok.slice(2))];
else if (tok.startsWith('/')) targets = [tok];
else if (tok.startsWith('../')) targets = [join(dir, tok)];
else continue;
if (!targets.some(readable)) broken.push(`${skill}: ${tok}`);
}
}
}
return [...new Set(broken)];
}
describe.skipIf(process.platform === 'win32')('host conformance kit', () => {
test('the kit covers every installable host', () => {
expect(Object.keys(DISCOVERY).sort()).toEqual([...installable].sort());
@@ -84,6 +130,22 @@ describe.skipIf(process.platform === 'win32')('host conformance kit', () => {
const status = runSetup(f, join(src, 'setup'), ['--status']);
expect(status.status).toBe(0);
expect(status.stdout).toMatch(new RegExp(`\\b${host}\\s+${ALL_HOST_CONFIGS.find(c => c.name === host)!.tier}\\s+global\\s+current`));
// C5 (#2651): OpenCode's builtin /review shadows the review skill, so
// /gstack-review loads it by the name the skill tool knows.
if (host === 'opencode') expect(readFileSync(join(f.home, '.config/opencode/commands/gstack-review.md'), 'utf8')).toContain('Load the `review` skill with the skill tool');
// INV-3 install-level checks (C2, C3, ENG-1, ENG-14).
if (host !== 'claude') {
const runtimeRoot = rows[0][4];
expect(symlinkedSkillMds(dest), `${host}: symlinked SKILL.md files (Codex skips them)`).toEqual([]);
for (const bin of ['design/dist/design', 'make-pdf/dist/pdf']) {
expect(existsSync(join(runtimeRoot, bin)), `${host} runtime root ${bin}`).toBe(true);
}
expect(brokenSectionPointers(dest, runtimeRoot, f.home), `${host}: unresolved section pointers`).toEqual([]);
expect(status.stdout).toMatch(new RegExp(`\\n ${host} global: router is a real file; section links: \\d+ checked, all resolve\\n`));
}
if (host === 'codex') expect(existsSync(join(src, '.agents/skills/gstack/design/dist/design')), 'agents sidecar design/dist').toBe(true);
}, 60_000);
}
@@ -96,7 +158,8 @@ describe.skipIf(process.platform === 'win32')('host conformance kit', () => {
expect(runSetup(f, join(src, 'setup'), ['--host', 'opencode']).status).toBe(0);
const ship = readFileSync(join(commands, 'gstack-ship.md'), 'utf8');
expect(ship).toMatch(/^---\ndescription: "[^"]+"\nagent: build\nsubtask: false\n---\n/);
expect(ship).toContain('Load the `gstack-ship` skill with the skill tool');
// The skill tool loads by frontmatter name, not directory name (#2651).
expect(ship).toContain('Load the `ship` skill with the skill tool');
expect(ship).toContain('$ARGUMENTS');
expect(readFileSync(join(commands, 'gstack-review.md'), 'utf8')).toBe('my own review command\n');
expect(existsSync(join(commands, 'gstack-retired.md'))).toBe(false);
@@ -128,6 +191,20 @@ describe.skipIf(process.platform === 'win32')('host conformance kit', () => {
expect(status.stdout).toContain(`register it: cd ${realpathSync(src)} && ./setup --host codex`);
}, 60_000);
test('--status names a symlinked router and a broken section link per host (C3)', () => {
const f = makeFixture();
const src = makeSource(f, join(f.dir, 'gstack'));
expect(runSetup(f, join(src, 'setup'), ['--host', 'codex']).status).toBe(0);
const router = join(f.home, '.codex/skills/gstack/SKILL.md');
rmSync(router);
symlinkSync(join(src, '.agents/skills/gstack/SKILL.md'), router);
const qa = join(src, '.agents/skills/gstack-qa/sections');
rmSync(join(qa, readdirSync(qa).find(n => n.endsWith('.md'))!));
const status = runSetup(f, join(src, 'setup'), ['--status']);
expect(status.status).toBe(0);
expect(status.stdout).toMatch(/codex global: router is a symlink, which Codex skips\. Fix: cd \S+ && \.\/setup --host codex; section links: [1-9]\d* of \d+ broken \(first: [^)]*\)\. Fix: cd /);
}, 60_000);
test('upgrade from the previous release layout refreshes every installed host (#1925)', () => {
const f = makeFixture();
const src = makeSource(f, join(f.home, '.claude/skills/gstack'), '1.91.12.0');
@@ -135,6 +212,11 @@ describe.skipIf(process.platform === 'win32')('host conformance kit', () => {
// v1.91.13.0 and earlier: same links, no registry, marker in ~/.gstack.
rmSync(join(f.home, '.gstack/installs.tsv'));
setVersion(src, '1.91.14.0');
// C3: a hand edit to the router copy is saved under the state root before
// the runtime root is replaced, and the refresh rewrites the copy.
const router = join(f.home, '.codex/skills/gstack/SKILL.md');
expect(lstatSync(router).isSymbolicLink()).toBe(false);
writeFileSync(router, readFileSync(router, 'utf8') + '\nmy local note\n');
const oldUpgrade = runSetup(f, join(src, 'setup'), []);
expect(oldUpgrade.status).toBe(0);
@@ -147,6 +229,13 @@ describe.skipIf(process.platform === 'win32')('host conformance kit', () => {
expect(registryRows(f).map(r => [r[0], r[6]]).sort()).toEqual([['claude', '1.91.14.0'], ['codex', '1.91.14.0']]);
// The refreshed Codex install runs the new checkout's render.
expect(realpathSync(join(f.home, '.codex/skills/gstack-review/SKILL.md'))).toBe(join(realpathSync(src), '.agents/skills/gstack-review/SKILL.md'));
expect(readFileSync(router, 'utf8')).toBe(readFileSync(join(src, '.agents/skills/gstack/SKILL.md'), 'utf8'));
const saved = upgrade.stdout.match(/saved your edited (\S+) to (\S+) /);
expect(saved, upgrade.stdout).not.toBeNull();
expect(saved![1]).toBe(router);
expect(saved![2].startsWith(join(f.home, '.gstack/backups/skill-copies/'))).toBe(true);
expect(readFileSync(saved![2], 'utf8')).toEndWith('\nmy local note\n');
expect(upgrade.stdout).toContain(`refreshed SKILL.md copies in ${join(f.home, '.codex/skills/gstack')}: SKILL.md gstack-upgrade/SKILL.md office-hours/SKILL.md`);
}, 90_000);
test('a Codex namespace captured by a vendored project copy is reported, never repointed (#2879)', () => {
+3 -1
View File
@@ -544,6 +544,7 @@ describe('installed QA pointers', () => {
'_gstack_generated_header', '_claude_entry_owned_strongly', '_claude_entry_is_ours', '_write_owned_marker',
'_backup_skill_md', '_cleanup_weak_dir', '_gstack_dir_only_links', '_cleanup_linked_dir',
'_owned_for_windows_refresh', '_sidecar_root_user_owned', '_prune_stale_generated', '_skill_source_exists',
'_link_runtime_dists', '_copy_skill_md', '_skill_copy_hash', '_skill_copy_unmodified', '_preserve_skill_copy_edits', '_record_skill_copies',
].map(setupFunction).join('\n')
// Install-registry rows (setup's _setup_arm_* / _setup_row) are not under test here.
+ '\n_setup_arm_begin() { :; }\n_setup_arm_publish() { :; }\n_setup_row() { :; }';
@@ -583,7 +584,8 @@ describe('installed QA pointers', () => {
const script = [
'set -e', `IS_WINDOWS=${copy ? 1 : 0}`, `SKILL_PREFIX=${prefix}`, 'QUIET=1',
'_FOREIGN_SKIPPED_ENTRIES=()', '_BACKED_UP_SKILL_MDS=()', '_SKILL_BACKUP_ROOT="$HOME/backups"',
'GSTACK_USER_RENDER_DIR="$HOME/absent-render"', helpers,
'GSTACK_USER_RENDER_DIR="$HOME/absent-render"',
'GSTACK_STATE_ROOT="$HOME/.gstack"', '_SKILL_COPIES_FILE="$GSTACK_STATE_ROOT/skill-copies.tsv"', helpers,
'log() { :; }', '_browser_hint() { :; }', 'bun_cmd() { :; }', install,
].join('\n');
const runInstall = () => runBashScript(script, {
+26
View File
@@ -526,6 +526,32 @@ describe('gstack-relink (#578)', () => {
expect(fs.existsSync(path.join(skillsDir, 'gstack-qa'))).toBe(true);
expect(fs.existsSync(path.join(skillsDir, 'gstack-ship'))).toBe(true);
});
// C6 (#2263): discovery is structural (has a SKILL.md), never name-based. A
// hardcoded meta-dir list dropped the real `browse` skill, so /browse
// disappeared after toggling skill_prefix.
test('materializes every dir carrying a SKILL.md, even names once meta-excluded (browse)', () => {
const formerlyExcluded = ['bin', 'browse', 'design', 'docs', 'extension', 'lib', 'node_modules', 'scripts', 'test'];
setupMockInstall(['qa', ...formerlyExcluded]);
const env = { GSTACK_INSTALL_DIR: installDir, GSTACK_SKILLS_DIR: skillsDir };
run(`${path.join(installDir, 'bin', 'gstack-config')} set skill_prefix false`, env);
run(`${path.join(installDir, 'bin', 'gstack-relink')}`, env);
const missing = ['qa', ...formerlyExcluded].filter(s => !fs.existsSync(path.join(skillsDir, s, 'SKILL.md')));
expect(missing).toEqual([]);
});
test('skips top-level dirs that have no SKILL.md', () => {
setupMockInstall(['qa']);
for (const meta of ['node_modules', 'lib', 'scripts']) {
fs.mkdirSync(path.join(installDir, meta), { recursive: true });
fs.writeFileSync(path.join(installDir, meta, 'placeholder.txt'), 'not a skill');
}
const env = { GSTACK_INSTALL_DIR: installDir, GSTACK_SKILLS_DIR: skillsDir };
run(`${path.join(installDir, 'bin', 'gstack-config')} set skill_prefix false`, env);
run(`${path.join(installDir, 'bin', 'gstack-relink')}`, env);
expect(fs.existsSync(path.join(skillsDir, 'qa', 'SKILL.md'))).toBe(true);
expect(['bin', 'node_modules', 'lib', 'scripts'].filter(m => fs.existsSync(path.join(skillsDir, m)))).toEqual([]);
});
});
describe('upgrade migrations', () => {
+124
View File
@@ -0,0 +1,124 @@
/**
* F5 (#2706): installed skills must SERVE the rendered section files.
*
* A gbrain install serves each brain-aware SKILL.md from the render dir
* (#2569). Its STOP pointers name absolute render-dir paths, but its section
* index names `sections/<f>.md` relative to the installed skill directory,
* and runtime assets were linked from the checkout, so the rendered "Save
* Results to Brain" blocks, which live only in the rendered section files,
* were generated and never read. Both install paths are covered: setup's
* _link_skill_runtime_assets and bin/gstack-relink (which
* `gstack-config gbrain-refresh` calls after rendering).
*/
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
import { spawnSync } from 'child_process';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { runBashScript } from './helpers/bash-script';
const ROOT = path.resolve(import.meta.dir, '..');
const BIN = path.join(ROOT, 'bin');
const SETUP_SRC = fs.readFileSync(path.join(ROOT, 'setup'), 'utf-8');
const SAVE_BLOCK = '## Save Results to Brain';
function extractFn(name: string): string {
const start = SETUP_SRC.indexOf(`\n${name}() {`);
const end = SETUP_SRC.indexOf('\n}\n', start);
if (start < 0 || end < 0) throw new Error(`Could not locate ${name}()`);
return SETUP_SRC.slice(start + 1, end + 3);
}
let tmp: string;
beforeEach(() => { tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-render-sections-')); });
afterEach(() => { fs.rmSync(tmp, { recursive: true, force: true }); });
/** A checkout and a render for one carved skill; manifest.json exists only in the checkout. */
function fixture() {
const install = path.join(tmp, 'gstack');
const render = path.join(tmp, 'state', 'render', 'claude');
const skills = path.join(tmp, 'skills');
const put = (p: string, body: string) => { fs.mkdirSync(path.dirname(p), { recursive: true }); fs.writeFileSync(p, body); };
put(path.join(install, 'ship/SKILL.md'), '---\nname: ship\ndescription: t\n---\n# ship');
put(path.join(install, 'ship/sections/adversarial.md'), 'body\n');
put(path.join(install, 'ship/sections/tests.md'), 'tests\n');
put(path.join(install, 'ship/sections/manifest.json'), '{"x":1}\n');
put(path.join(install, 'ship/checklist.md'), 'checkout checklist\n');
put(path.join(render, 'ship/SKILL.md'), '---\nname: ship\ndescription: t\n---\n# ship rendered');
put(path.join(render, 'ship/sections/adversarial.md'), `body\n${SAVE_BLOCK}\n`);
fs.mkdirSync(skills, { recursive: true });
return { install, render, skills, dst: path.join(skills, 'ship') };
}
function runSetupHelper(src: string, dst: string, render?: string) {
fs.mkdirSync(dst, { recursive: true });
const r = runBashScript(['set -eu', 'IS_WINDOWS=0', extractFn('_link_or_copy'), extractFn('_link_skill_runtime_assets'),
`_link_skill_runtime_assets "${src}" "${dst}" 1${render ? ` "${render}"` : ''}`].join('\n'), { timeout: 30_000 });
expect(r.status, r.stderr).toBe(0);
}
describe('installed skills serve rendered sections (F5, #2706)', () => {
test("setup's _link_skill_runtime_assets serves the rendered section per file and keeps checkout-only assets", () => {
const fx = fixture();
runSetupHelper(path.join(fx.install, 'ship'), fx.dst, path.join(fx.render, 'ship'));
expect(fs.readFileSync(path.join(fx.dst, 'sections/adversarial.md'), 'utf-8')).toContain(SAVE_BLOCK);
expect(fs.readFileSync(path.join(fx.dst, 'sections/tests.md'), 'utf-8')).toBe('tests\n');
expect(fs.existsSync(path.join(fx.dst, 'sections/manifest.json'))).toBe(true);
expect(fs.readFileSync(path.join(fx.dst, 'checklist.md'), 'utf-8')).toBe('checkout checklist\n');
});
test('without a render the checkout assets are linked as before', () => {
const fx = fixture();
runSetupHelper(path.join(fx.install, 'ship'), fx.dst);
expect(fs.lstatSync(path.join(fx.dst, 'sections')).isSymbolicLink()).toBe(true);
expect(fs.readFileSync(path.join(fx.dst, 'sections/adversarial.md'), 'utf-8')).not.toContain(SAVE_BLOCK);
});
test('gstack-relink (the gbrain-refresh path) serves the render, and the checkout again once the render is gone', () => {
const fx = fixture();
fs.mkdirSync(path.join(fx.install, 'bin'), { recursive: true });
for (const f of ['gstack-relink', 'gstack-config', 'gstack-state-root.sh', 'gstack-install-registry.sh', 'gstack-patch-names']) {
fs.copyFileSync(path.join(BIN, f), path.join(fx.install, 'bin', f));
fs.chmodSync(path.join(fx.install, 'bin', f), 0o755);
}
// The layout setup leaves: SKILL.md and sections/ linked from the checkout.
runSetupHelper(path.join(fx.install, 'ship'), fx.dst);
fs.symlinkSync(path.join(fx.install, 'ship/SKILL.md'), path.join(fx.dst, 'SKILL.md'));
const relink = () => {
const r = spawnSync('bash', [path.join(fx.install, 'bin/gstack-relink')], {
encoding: 'utf-8', timeout: 30_000,
env: { PATH: process.env.PATH ?? '/usr/bin:/bin', HOME: tmp, GSTACK_STATE_ROOT: path.join(tmp, 'state'),
GSTACK_INSTALL_DIR: fx.install, GSTACK_SKILLS_DIR: fx.skills, GSTACK_USER_RENDER_DIR: fx.render },
});
expect(r.status, r.stderr).toBe(0);
};
relink();
expect(fs.readFileSync(path.join(fx.dst, 'SKILL.md'), 'utf-8')).toContain('ship rendered');
expect(fs.readFileSync(path.join(fx.dst, 'sections/adversarial.md'), 'utf-8')).toContain(SAVE_BLOCK);
expect(fs.existsSync(path.join(fx.dst, 'sections/manifest.json'))).toBe(true);
expect(fs.readFileSync(path.join(fx.install, 'ship/sections/adversarial.md'), 'utf-8')).toBe('body\n');
fs.rmSync(path.join(fx.render, 'ship/sections'), { recursive: true });
relink();
expect(fs.realpathSync(path.join(fx.dst, 'sections'))).toBe(fs.realpathSync(path.join(fx.install, 'ship/sections')));
});
test('a sections/ directory the user owns is left alone by relink', () => {
const fx = fixture();
fs.mkdirSync(path.join(fx.install, 'bin'), { recursive: true });
for (const f of ['gstack-relink', 'gstack-config', 'gstack-state-root.sh', 'gstack-install-registry.sh', 'gstack-patch-names']) {
fs.copyFileSync(path.join(BIN, f), path.join(fx.install, 'bin', f));
fs.chmodSync(path.join(fx.install, 'bin', f), 0o755);
}
fs.mkdirSync(path.join(fx.dst, 'sections'), { recursive: true });
fs.writeFileSync(path.join(fx.dst, 'sections/adversarial.md'), 'my notes\n');
fs.writeFileSync(path.join(fx.dst, '.gstack-owned'), fx.install + '\n');
const r = spawnSync('bash', [path.join(fx.install, 'bin/gstack-relink')], {
encoding: 'utf-8', timeout: 30_000,
env: { PATH: process.env.PATH ?? '/usr/bin:/bin', HOME: tmp, GSTACK_STATE_ROOT: path.join(tmp, 'state'),
GSTACK_INSTALL_DIR: fx.install, GSTACK_SKILLS_DIR: fx.skills, GSTACK_USER_RENDER_DIR: fx.render },
});
expect(r.status, r.stderr).toBe(0);
expect(fs.readFileSync(path.join(fx.dst, 'sections/adversarial.md'), 'utf-8')).toBe('my notes\n');
});
});
+200
View File
@@ -0,0 +1,200 @@
import { afterEach, describe, test, expect } from 'bun:test';
import { execFileSync, spawnSync } from 'child_process';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
// B11: the auto-updater wrote the "just upgraded" marker after setup failed
// (or was skipped because bun was missing), and only re-ran setup when HEAD
// moved, so a failed install was never retried. Pull, setup and migrations
// are now separate stages: the marker is written only when all succeed, a
// failed stage is persisted and retried with backoff even when HEAD did not
// move, and every session start prints one failure line with the fix.
const ROOT = path.resolve(import.meta.dir, '..');
const SCRIPT = path.join(ROOT, 'bin', 'gstack-session-update');
const bases: string[] = [];
afterEach(() => { for (const b of bases.splice(0)) fs.rmSync(b, { recursive: true, force: true }); });
function git(cwd: string, ...args: string[]): string {
return execFileSync('git', args, { cwd, encoding: 'utf8', timeout: 30_000 }).trim();
}
/** PATH without any bun: directories holding bun are replaced by shims of everything else. */
function pathWithoutBun(base: string): string {
return (process.env.PATH ?? '/usr/bin:/bin').split(':').filter(Boolean).map((dir, i) => {
if (!fs.existsSync(path.join(dir, 'bun'))) return dir;
const shim = path.join(base, `nobun-${i}`);
if (fs.existsSync(shim)) return shim;
fs.mkdirSync(shim, { recursive: true });
for (const name of fs.readdirSync(dir)) if (name !== 'bun') fs.symlinkSync(path.join(dir, name), path.join(shim, name));
return shim;
}).join(':');
}
function makeFixture() {
const base = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-supd-stages-'));
bases.push(base);
const origin = path.join(base, 'origin.git');
const seed = path.join(base, 'seed');
const install = path.join(base, 'install');
const state = path.join(base, 'state');
const home = path.join(base, 'home');
fs.mkdirSync(state, { recursive: true });
fs.mkdirSync(home, { recursive: true });
execFileSync('git', ['init', '-q', '--bare', '-b', 'main', origin], { timeout: 30_000 });
fs.mkdirSync(path.join(seed, 'bin'), { recursive: true });
fs.writeFileSync(path.join(seed, 'VERSION'), '1.0.0\n');
fs.writeFileSync(path.join(seed, 'bin', 'gstack-config'),
'#!/usr/bin/env bash\nif [ "$1" = "get" ]; then case "$2" in auto_upgrade) echo true;; skill_prefix) echo false;; *) echo "";; esac; fi\nexit 0\n', { mode: 0o755 });
fs.writeFileSync(path.join(seed, 'bin', 'gstack-patch-names'), '#!/usr/bin/env bash\nexit 0\n', { mode: 0o755 });
// Stub setup: records each run; $SETUP_CONTROL selects the outcome.
fs.writeFileSync(path.join(seed, 'setup'), [
'#!/usr/bin/env bash',
'echo "$*" >> "$SETUP_CALLS"',
'case "$(cat "$SETUP_CONTROL" 2>/dev/null)" in',
' fail) echo "error: could not build browse" >&2; exit 1 ;;',
' migfail) echo " warning: migration 1.1.0 failed; the state root stays marked at 1.0.0, so the next ./setup retries it" >&2; exit 1 ;;',
'esac',
'exit 0',
].join('\n') + '\n', { mode: 0o755 });
git(seed, 'init', '-q');
git(seed, 'add', '-A');
git(seed, 'commit', '-q', '-m', 'seed');
git(seed, 'branch', '-M', 'main');
git(seed, 'remote', 'add', 'origin', origin);
git(seed, 'push', '-q', 'origin', 'main');
execFileSync('git', ['clone', '-q', origin, install], { timeout: 30_000 });
const control = path.join(base, 'control');
const calls = path.join(base, 'calls');
return { base, seed, install, state, home, control, calls };
}
type Fx = ReturnType<typeof makeFixture>;
function upstreamRelease(fx: Fx, version: string) {
fs.writeFileSync(path.join(fx.seed, 'VERSION'), `${version}\n`);
git(fx.seed, 'commit', '-aqm', `release ${version}`);
git(fx.seed, 'push', '-q', 'origin', 'main');
}
function runHook(fx: Fx, opts: { noBun?: boolean } = {}) {
const PATH = opts.noBun ? pathWithoutBun(fx.base) : `${path.dirname(process.execPath)}:${process.env.PATH}`;
return spawnSync('bash', [SCRIPT], {
encoding: 'utf8',
env: { PATH, HOME: fx.home, GSTACK_DIR: fx.install, GSTACK_STATE_ROOT: fx.state, SETUP_CONTROL: fx.control, SETUP_CALLS: fx.calls, TMPDIR: fx.base },
timeout: 20_000,
});
}
/** Wait for the forked updater to log `pattern` `count` times and release its lock. */
async function waitFor(fx: Fx, pattern: RegExp, count = 1, ms = 15_000): Promise<string> {
const logFile = path.join(fx.state, 'analytics', 'session-update.log');
const deadline = Date.now() + ms;
let content = '';
while (Date.now() < deadline) {
content = fs.existsSync(logFile) ? fs.readFileSync(logFile, 'utf8') : '';
const hits = content.split('\n').filter(l => pattern.test(l)).length;
if (hits >= count && !fs.existsSync(path.join(fx.state, '.setup-lock'))) return content;
await new Promise(r => setTimeout(r, 100));
}
throw new Error(`timed out waiting for ${pattern} x${count}; log:\n${content}`);
}
const marker = (fx: Fx) => path.join(fx.state, 'just-upgraded-from');
const pending = (fx: Fx) => path.join(fx.state, 'session-update-pending');
const setupRuns = (fx: Fx) => (fs.existsSync(fx.calls) ? fs.readFileSync(fx.calls, 'utf8').trim().split('\n').length : 0);
/** Let the next hook run start a check: the hourly throttle has elapsed. */
const expireThrottle = (fx: Fx) => fs.rmSync(path.join(fx.state, '.last-session-update'), { force: true });
const expireBackoff = (fx: Fx) => fs.writeFileSync(pending(fx), fs.readFileSync(pending(fx), 'utf8').replace(/^next=\d+$/m, 'next=0'));
describe.skipIf(process.platform === 'win32')('gstack-session-update stages (B11)', () => {
test('setup failure: no upgrade announcement, one failure line, retried with HEAD unchanged after backoff', async () => {
const fx = makeFixture();
fs.writeFileSync(fx.control, 'fail');
upstreamRelease(fx, '1.1.0');
expect(runHook(fx).stdout).toBe('');
await waitFor(fx, /Z PENDING stages=setup attempt=1/);
expect(fs.existsSync(marker(fx))).toBe(false);
expect(setupRuns(fx)).toBe(1);
const state = fs.readFileSync(pending(fx), 'utf8');
expect(state).toContain('stages=setup\n');
expect(state).toContain('from=1.0.0\n');
expect(state).toMatch(/reason=\.\/setup exited 1: error: could not build browse\n/);
// Next session start inside the backoff window: the line, but no setup run.
expireThrottle(fx);
const second = runHook(fx);
expect(second.stdout.trim().split('\n')).toEqual([
`gstack auto-update: setup did not finish (./setup exited 1: error: could not build browse); installed skills may be out of date. gstack retries automatically. Fix now: cd ${fx.install} && ./setup`,
]);
await waitFor(fx, /PENDING_BACKOFF stages=setup/);
expect(setupRuns(fx)).toBe(1);
// After the backoff, the pending stage is retried although HEAD is unchanged.
fs.writeFileSync(fx.control, 'ok');
expireThrottle(fx);
expireBackoff(fx);
runHook(fx);
await waitFor(fx, /UPDATED from=1\.0\.0 to=1\.1\.0/);
expect(setupRuns(fx)).toBe(2);
expect(fs.readFileSync(marker(fx), 'utf8').trim()).toBe('1.0.0');
expect(fs.existsSync(pending(fx))).toBe(false);
expireThrottle(fx);
expect(runHook(fx).stdout).toBe('');
await waitFor(fx, /UP_TO_DATE/);
});
test('backoff grows: update-check interval, then 6h, then 24h', async () => {
const fx = makeFixture();
fs.writeFileSync(fx.control, 'fail');
upstreamRelease(fx, '1.1.0');
const waits: number[] = [];
for (let attempt = 1; attempt <= 4; attempt++) {
if (attempt > 1) { expireThrottle(fx); expireBackoff(fx); }
const before = Math.floor(Date.now() / 1000);
runHook(fx);
await waitFor(fx, /Z PENDING stages=setup attempt=/, attempt);
const next = Number(fs.readFileSync(pending(fx), 'utf8').match(/^next=(\d+)$/m)![1]);
waits.push(Math.round((next - before) / 3600));
}
expect(waits).toEqual([1, 6, 24, 24]);
expect(setupRuns(fx)).toBe(4);
expect(fs.existsSync(marker(fx))).toBe(false);
});
test('bun missing: setup skipped is a pending stage with the install-Bun fix, never an announced upgrade', async () => {
const fx = makeFixture();
upstreamRelease(fx, '1.1.0');
runHook(fx, { noBun: true });
await waitFor(fx, /Z PENDING stages=setup attempt=1/);
expect(fs.readFileSync(path.join(fx.state, 'analytics', 'session-update.log'), 'utf8')).toContain('SETUP_SKIPPED bun_missing');
expect(fs.existsSync(marker(fx))).toBe(false);
expect(setupRuns(fx)).toBe(0);
expireThrottle(fx);
expect(runHook(fx, { noBun: true }).stdout).toContain(`(bun not found on PATH); installed skills may be out of date. gstack retries automatically. Fix now: install Bun (https://bun.sh), then cd ${fx.install} && ./setup`);
});
test('a failed migration is its own stage', async () => {
const fx = makeFixture();
fs.writeFileSync(fx.control, 'migfail');
upstreamRelease(fx, '1.1.0');
runHook(fx);
await waitFor(fx, /MIGRATIONS_FAILED reason=migration 1\.1\.0 failed/);
expect(fs.readFileSync(pending(fx), 'utf8')).toContain('stages=migrations\n');
expect(fs.existsSync(marker(fx))).toBe(false);
expireThrottle(fx);
expect(runHook(fx).stdout).toContain('gstack auto-update: migrations did not finish (migration 1.1.0 failed)');
});
test('all stages pass: the marker is written once and nothing is printed', async () => {
const fx = makeFixture();
upstreamRelease(fx, '1.1.0');
expect(runHook(fx).stdout).toBe('');
await waitFor(fx, /UPDATED from=1\.0\.0 to=1\.1\.0/);
expect(fs.readFileSync(marker(fx), 'utf8').trim()).toBe('1.0.0');
expect(fs.existsSync(pending(fx))).toBe(false);
expect(setupRuns(fx)).toBe(1);
});
});
+88
View File
@@ -0,0 +1,88 @@
import { afterEach, describe, expect, test } from 'bun:test';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { runBashScript } from './helpers/bash-script';
// B4 (#1254, setup half): the Apple Silicon re-sign loop backs each binary up,
// restores it as built when codesign fails, and reports design unavailable when
// the design binary is SIGKILLed at launch (exit 137). The real block runs on
// any OS with uname/codesign/otool stubbed on PATH.
const setup = fs.readFileSync(path.resolve(import.meta.dir, '../setup'), 'utf8');
const start = setup.indexOf(' if [ "$(uname -s)" = "Darwin" ] && [ "$(uname -m)" = "arm64" ]; then\n # CSO artifacts');
const end = setup.indexOf('\n fi\n', setup.indexOf(' done', start));
const block = setup.slice(start, end + 5);
const BINS = ['browse/dist/browse', 'browse/dist/find-browse', 'design/dist/design', 'make-pdf/dist/pdf', 'bin/gstack-global-discover'];
const tmps: string[] = [];
afterEach(() => { for (const t of tmps.splice(0)) fs.rmSync(t, { recursive: true, force: true }); });
function fixture(opts: { sign: 'ok' | 'fail'; killed?: string[] }) {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-resign-'));
tmps.push(tmp);
const src = path.join(tmp, 'gstack');
for (const rel of BINS) {
fs.mkdirSync(path.dirname(path.join(src, rel)), { recursive: true });
fs.writeFileSync(path.join(src, rel), `#!/bin/sh\nexit ${opts.killed?.includes(rel) ? 137 : 0}\n`, { mode: 0o755 });
}
const stubs = path.join(tmp, 'stubs');
fs.mkdirSync(stubs);
fs.writeFileSync(path.join(stubs, 'uname'), '#!/bin/sh\ncase "$1" in -s) echo Darwin ;; -m) echo arm64 ;; esac\n', { mode: 0o755 });
fs.writeFileSync(path.join(stubs, 'otool'), '#!/bin/sh\nexit 0\n', { mode: 0o755 });
fs.writeFileSync(path.join(stubs, 'stat'), '#!/bin/sh\nwc -c < "$2" | tr -d " "\n', { mode: 0o755 }); // BSD stat -f%z
// --remove-signature mutates the file, as the real one does; signing then succeeds or fails.
fs.writeFileSync(path.join(stubs, 'codesign'), `#!/bin/sh
case "$1" in
--remove-signature) printf '# stripped\\n' >> "$2" ;;
-s) exit ${opts.sign === 'ok' ? 0 : 1} ;;
esac
`, { mode: 0o755 });
return { tmp, src, stubs };
}
function run(fx: ReturnType<typeof fixture>) {
return runBashScript(['set -e', 'log() { echo "$@"; }', `SOURCE_GSTACK_DIR="${fx.src}"`, block].join('\n'), {
env: { ...process.env, PATH: `${fx.stubs}:${process.env.PATH}` }, timeout: 30_000,
});
}
const leftovers = (src: string) => BINS.flatMap(rel => fs.readdirSync(path.dirname(path.join(src, rel))).filter(n => n.includes('.gstack-presign.')));
describe.skipIf(process.platform === 'win32')('setup: Apple Silicon re-sign keeps a backup (B4)', () => {
test('the extracted block is the re-sign loop', () => {
expect(block).toContain('codesign -s - -f "$_bin_path"');
expect(block.trimEnd().endsWith('fi')).toBe(true);
});
test('a failed re-sign restores every binary byte-for-byte as built', () => {
const fx = fixture({ sign: 'fail' });
const before = BINS.map(rel => fs.readFileSync(path.join(fx.src, rel), 'utf8'));
const r = run(fx);
expect(r.status, r.stderr).toBe(0);
expect(BINS.map(rel => fs.readFileSync(path.join(fx.src, rel), 'utf8'))).toEqual(before);
expect(leftovers(fx.src)).toEqual([]);
expect(r.stdout).toContain('codesign could not re-sign design/dist/design; restored it as built');
});
test('a design binary killed at launch after re-signing is reported unavailable with the fix', () => {
const fx = fixture({ sign: 'ok', killed: ['design/dist/design'] });
const r = run(fx);
expect(r.status, r.stderr).toBe(0);
expect(r.stderr).toContain(`design unavailable: ${fx.src}/design/dist/design is killed at launch (exit 137)`);
expect(r.stderr).toContain(`Fix: cd ${fx.src} && ./setup`);
expect(leftovers(fx.src)).toEqual([]);
});
test('a clean re-sign is silent and leaves no backups', () => {
const fx = fixture({ sign: 'ok' });
const r = run(fx);
expect(r.status, r.stderr).toBe(0);
expect(r.stdout + r.stderr).toBe('');
expect(leftovers(fx.src)).toEqual([]);
});
test('another binary SIGKILLed after a failed re-sign keeps its warning', () => {
const fx = fixture({ sign: 'fail', killed: ['browse/dist/browse'] });
expect(run(fx).stdout).toContain('warning: codesign failed for browse/dist/browse and it is SIGKILL\'d on exec (exit 137)');
});
});
+151
View File
@@ -0,0 +1,151 @@
import { describe, test, expect } from 'bun:test';
import { spawnSync } from 'child_process';
import * as path from 'path';
import * as fs from 'fs';
import * as os from 'os';
import { HOST_PATHS, type TemplateContext } from '../scripts/resolvers/types';
import { generateDesignSetup } from '../scripts/resolvers/design';
import { generateMakePdfSetup } from '../scripts/resolvers/make-pdf';
// #2891 (C2): Codex design and make-pdf skills resolve their binaries from the
// repo-local .agents sidecar first, then from $GSTACK_DESIGN / $GSTACK_MAKE_PDF
// under the global ~/.codex/skills/gstack root. setup never linked either
// dist/ into those roots, so both preflights printed *_NOT_AVAILABLE on a
// fresh install even though the binaries were built.
//
// Each root is built with the real setup shell functions, then the bash block
// the generator emits for Codex runs in a fresh process (the way Codex runs
// every shell call) with the root named the ways a generated skill may name
// it: $GSTACK_ROOT, $GSTACK_DESIGN / $GSTACK_MAKE_PDF, or the repo-local path.
const ROOT = path.resolve(import.meta.dir, '..');
const SETUP_SRC = fs.readFileSync(path.join(ROOT, 'setup'), 'utf-8');
function extractFunction(name: string): string {
const start = SETUP_SRC.indexOf(`\n${name}() {`);
const end = SETUP_SRC.indexOf('\n}\n', start);
if (start < 0 || end < 0) throw new Error(`Could not locate ${name}() in setup`);
return SETUP_SRC.slice(start + 1, end + 2);
}
function codexCtx(skillName: string): TemplateContext {
return { skillName, tmplPath: `${skillName}/SKILL.md.tmpl`, host: 'codex', paths: HOST_PATHS.codex };
}
function firstBashBlock(rendered: string): string {
const m = rendered.match(/```bash\n([\s\S]*?)```/);
if (!m) throw new Error('no bash block in rendered section');
return m[1];
}
const DESIGN_PREFLIGHT = firstBashBlock(generateDesignSetup(codexCtx('design-shotgun')));
const MAKE_PDF_PREFLIGHT = firstBashBlock(generateMakePdfSetup(codexCtx('make-pdf')));
// A minimal gstack source tree: both built binaries, plus make-pdf's own
// SKILL.md and design sources, which must NOT leak into a Codex-scanned root.
function makeSource(sandbox: string): string {
const src = path.join(sandbox, 'gstack');
for (const [rel, body] of [
['design/dist/design', '#!/bin/sh\necho design\n'],
['make-pdf/dist/pdf', '#!/bin/sh\necho pdf\n'],
] as const) {
fs.mkdirSync(path.dirname(path.join(src, rel)), { recursive: true });
fs.writeFileSync(path.join(src, rel), body, { mode: 0o755 });
}
fs.mkdirSync(path.join(src, 'design', 'src'), { recursive: true });
fs.writeFileSync(path.join(src, 'make-pdf', 'SKILL.md'), '---\nname: make-pdf\n---\n');
return src;
}
function run(cmd: string, opts: { cwd: string; env?: Record<string, string> }) {
const env: Record<string, string> = { PATH: process.env.PATH ?? '/usr/bin:/bin', ...opts.env };
return spawnSync('bash', ['-c', cmd], { cwd: opts.cwd, encoding: 'utf-8', timeout: 30000, env });
}
function rootEnv(sandbox: string, rootDir: string): Record<string, string> {
return {
HOME: path.join(sandbox, 'home'),
GSTACK_ROOT: rootDir,
GSTACK_DESIGN: path.join(rootDir, 'design', 'dist'),
GSTACK_MAKE_PDF: path.join(rootDir, 'make-pdf', 'dist'),
};
}
interface Built { script: string; rootDir: string; preflight: { cwd: string; env: Record<string, string> } }
const BUILDERS: Record<string, (sandbox: string, src: string) => Built> = {
'codex global root': (sandbox, src) => {
const rootDir = path.join(sandbox, 'home', '.codex', 'skills', 'gstack');
const outside = path.join(sandbox, 'not-a-repo');
fs.mkdirSync(outside, { recursive: true });
return {
script: `create_codex_runtime_root "${src}" "${rootDir}"`,
rootDir,
preflight: { cwd: outside, env: rootEnv(sandbox, rootDir) },
};
},
'agents sidecar': (sandbox, src) => {
const repo = path.join(sandbox, 'repo');
fs.mkdirSync(repo, { recursive: true });
spawnSync('git', ['init', '-q'], { cwd: repo, timeout: 30000 });
const rootDir = path.join(repo, '.agents', 'skills', 'gstack');
return {
script: `SOURCE_GSTACK_DIR="${src}"\ncreate_agents_sidecar "${repo}"`,
rootDir,
preflight: { cwd: repo, env: rootEnv(sandbox, rootDir) },
};
},
};
const FUNCTIONS = ['_link_or_copy', '_link_runtime_dists', '_copy_skill_md', '_sidecar_root_user_owned', '_gstack_generated_header', 'create_agents_sidecar', 'create_codex_runtime_root']
.filter(name => SETUP_SRC.includes(`\n${name}() {`))
.map(extractFunction)
.join('\n');
describe.skipIf(process.platform === 'win32')('setup: Codex roots expose design and make-pdf binaries (#2891)', () => {
for (const [name, build] of Object.entries(BUILDERS)) {
for (const isWindows of ['0', '1'] as const) {
const mode = isWindows === '1' ? 'Windows copy install' : 'symlink install';
test(`${name} (${mode}): design and make-pdf preflights report READY`, () => {
const sandbox = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-codex-dist-'));
try {
const src = makeSource(sandbox);
const built = build(sandbox, src);
const b = run(`IS_WINDOWS=${isWindows}\n${FUNCTIONS}\n${built.script}`, { cwd: sandbox });
expect(b.stderr).toBe('');
expect(b.status).toBe(0);
expect(fs.lstatSync(path.join(built.rootDir, 'design', 'dist')).isSymbolicLink()).toBe(isWindows === '0');
expect(fs.existsSync(path.join(built.rootDir, 'make-pdf', 'SKILL.md'))).toBe(false);
expect(fs.existsSync(path.join(built.rootDir, 'design', 'src'))).toBe(false);
const d = run(DESIGN_PREFLIGHT, built.preflight);
expect(d.stdout).toContain('DESIGN_READY:');
expect(d.stdout).not.toContain('DESIGN_NOT_AVAILABLE');
const p = run(MAKE_PDF_PREFLIGHT, built.preflight);
expect(p.stdout).toContain('MAKE_PDF_READY:');
expect(p.stdout).not.toContain('MAKE_PDF_NOT_AVAILABLE');
} finally {
fs.rmSync(sandbox, { recursive: true, force: true });
}
});
}
}
// Negative control: a root without design/dist and make-pdf/dist fails both
// preflights, so the READY cells are not vacuous.
test('a Codex root without design/dist and make-pdf/dist reports NOT_AVAILABLE', () => {
const sandbox = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-codex-dist-'));
try {
const rootDir = path.join(sandbox, 'home', '.codex', 'skills', 'gstack');
fs.mkdirSync(path.join(rootDir, 'browse'), { recursive: true });
const outside = path.join(sandbox, 'not-a-repo');
fs.mkdirSync(outside, { recursive: true });
const env = rootEnv(sandbox, rootDir);
expect(run(DESIGN_PREFLIGHT, { cwd: outside, env }).stdout).toContain('DESIGN_NOT_AVAILABLE');
expect(run(MAKE_PDF_PREFLIGHT, { cwd: outside, env }).stdout).toContain('MAKE_PDF_NOT_AVAILABLE');
} finally {
fs.rmSync(sandbox, { recursive: true, force: true });
}
});
});
+2 -2
View File
@@ -78,8 +78,8 @@ describe('setup Codex model activation', () => {
const kiroStart = setup.indexOf('# 6. Install for Kiro CLI');
const kiroEnd = setup.indexOf('# 6b.', kiroStart);
const block = setup.slice(kiroStart, kiroEnd);
expect(block).toContain('_link_or_copy "$KIRO_DIR/gstack-upgrade/SKILL.md"');
expect(block).toContain('_link_or_copy "$KIRO_DIR/gstack/SKILL.md"');
expect(block).toContain('_copy_skill_md "$KIRO_DIR/gstack-upgrade/SKILL.md"');
expect(block).toContain('_copy_skill_md "$KIRO_DIR/gstack/SKILL.md"');
expect(block).toContain('_link_or_copy "$SOURCE_GSTACK_DIR/bin" "$KIRO_GSTACK/bin"');
expect(block).toContain('_link_or_copy "$SOURCE_GSTACK_DIR/lib" "$KIRO_GSTACK/lib"');
expect(block).not.toContain('$AGENTS_DIR');
+58
View File
@@ -0,0 +1,58 @@
/**
* Q3 (#2207, #2208): in a Conductor workspace the PreToolUse question-
* preference hook breaks Conductor's native AskUserQuestion round-trip even
* when it only defers. setup no longer auto-installs it there, removes one an
* earlier setup added (keeping the other plan-tune hooks), and still honors
* an explicit opt-in. Real ./setup into a temp HOME (test/helpers/install-fixture).
*/
import { afterEach, describe, expect, test } from 'bun:test';
import { spawnSync } from 'node:child_process';
import { join } from 'node:path';
import { cleanupFixtures, makeFixture, makeSource, type Fixture } from './helpers/install-fixture';
afterEach(cleanupFixtures);
function setup(f: Fixture, src: string, args: string[], env: Record<string, string> = {}) {
const r = spawnSync('bash', [join(src, 'setup'), ...args], { cwd: f.home, env: { ...f.env, ...env }, encoding: 'utf8', timeout: 60_000, input: '' });
expect(r.status, r.stdout + r.stderr).toBe(0);
return r.stdout + r.stderr;
}
function items(f: Fixture, src: string, event: string): string {
const r = spawnSync('bash', [join(src, 'bin/gstack-settings-hook'), 'list-items', '--event', event, '--owned-by', 'plan-tune-cathedral'],
{ env: f.env, encoding: 'utf8', timeout: 30_000 });
return r.stdout.trim();
}
const CONDUCTOR = { CONDUCTOR_WORKSPACE_PATH: '/tmp/conductor/workspaces/demo' };
describe.skipIf(process.platform === 'win32')('setup: Conductor and the AskUserQuestion preference hook (Q3)', () => {
test('a Conductor setup with no explicit choice does not install it', () => {
const f = makeFixture();
const src = makeSource(f, join(f.home, '.claude/skills/gstack'));
setup(f, src, [], CONDUCTOR);
expect(items(f, src, 'PreToolUse')).toBe('');
}, 90_000);
test('a Conductor setup removes one an earlier setup added and keeps the other plan-tune hooks', () => {
const f = makeFixture();
const src = makeSource(f, join(f.home, '.claude/skills/gstack'));
setup(f, src, ['--plan-tune-hooks']);
expect(items(f, src, 'PreToolUse')).toContain('question-preference-hook');
const out = setup(f, src, [], CONDUCTOR);
expect(out).toContain("removed the AskUserQuestion preference hook: it breaks Conductor's native AskUserQuestion");
expect(out).toContain('Keep it anyway: gstack-config set plan_tune_hooks yes, then ./setup');
expect(items(f, src, 'PreToolUse')).toBe('');
expect(items(f, src, 'PostToolUse')).toContain('question-log-hook');
// A later run neither re-adds it nor repeats the notice.
expect(setup(f, src, [], CONDUCTOR)).not.toContain('removed the AskUserQuestion preference hook');
expect(items(f, src, 'PreToolUse')).toBe('');
}, 120_000);
test('an explicit opt-in in Conductor still installs it', () => {
const f = makeFixture();
const src = makeSource(f, join(f.home, '.claude/skills/gstack'));
setup(f, src, [], { ...CONDUCTOR, GSTACK_PLAN_TUNE_HOOKS: 'yes' });
expect(items(f, src, 'PreToolUse')).toContain('question-preference-hook');
}, 90_000);
});
+69
View File
@@ -0,0 +1,69 @@
import { afterEach, describe, expect, test } from 'bun:test';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { runBashScript } from './helpers/bash-script';
// E2 (#3015): the Windows /cso build compiles with /W4 /WX, and the Windows
// SDK's winbase.h raises C5105, so every MSVC build failed; and setup reported
// every probe failure as a missing Visual Studio toolchain. The probe and the
// helper builds disable only C5105, and a probe that found MSVC but could not
// compile gets its own reason with the compiler's first diagnostic. Runs the
// real setup function on any OS with powershell.exe/cygpath stubbed.
const ROOT = path.resolve(import.meta.dir, '..');
const setup = fs.readFileSync(path.join(ROOT, 'setup'), 'utf8');
const ps1 = fs.readFileSync(path.join(ROOT, 'scripts/build-cso-windows.ps1'), 'utf8');
const fn = (name: string) => {
const start = setup.indexOf(`\n${name}() {`);
const end = setup.indexOf('\n}\n', start);
if (start < 0 || end < 0) throw new Error(`missing setup function ${name}`);
return setup.slice(start + 1, end + 3);
};
const summaryStart = setup.indexOf(' case "$CSO_FAIL_REASON" in');
const summaryCase = setup.slice(summaryStart, setup.indexOf('\n esac\n', summaryStart) + 8);
const tmps: string[] = [];
afterEach(() => { for (const t of tmps.splice(0)) fs.rmSync(t, { recursive: true, force: true }); });
function probe(powershellOutput: string) {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-cso-win-'));
tmps.push(tmp);
fs.writeFileSync(path.join(tmp, 'powershell.exe'), `#!/bin/sh\ncat <<'OUT' >&2\n${powershellOutput}\nOUT\nexit 1\n`, { mode: 0o755 });
fs.writeFileSync(path.join(tmp, 'cygpath'), '#!/bin/sh\nshift; echo "$1"\n', { mode: 0o755 });
return runBashScript([
'IS_WINDOWS=1', `SOURCE_GSTACK_DIR="${ROOT}"`,
'bun_cmd() { echo "--no-compile-autoload-dotenv --no-compile-autoload-bunfig --no-compile-autoload-tsconfig --no-compile-autoload-package-json"; }',
fn('_cso_unavailable'), fn('probe_cso_build_prerequisites'),
'probe_cso_build_prerequisites', summaryCase,
'printf "reason=%s\\nprereq=%s\\n" "$CSO_FAIL_REASON" "$_CSO_PREREQ"',
].join('\n'), { env: { ...process.env, PATH: `${tmp}:${process.env.PATH}` }, timeout: 30_000 });
}
describe('setup: Windows /cso build probe (E2)', () => {
test('both MSVC compile lines disable only C5105 and keep /W4 /WX', () => {
const lines = ps1.split('\n').filter(l => l.includes('& $compiler'));
expect(lines).toHaveLength(2);
for (const line of lines) expect(line).toContain('/W4 /WX /wd5105 ');
});
});
// The stubs are POSIX shell scripts named powershell.exe and cygpath, so the
// reason mapping runs off Windows; the real probe needs MSVC (not verified here).
describe.skipIf(process.platform === 'win32')('setup: Windows /cso probe failure mapping (E2)', () => {
test('a compile failure with MSVC present names the compiler error, not a missing toolchain', () => {
const r = probe([
"C:\\Program Files (x86)\\Windows Kits\\10\\include\\10.0.19041.0\\um\\winbase.h(9531): error C2220: the following warning is treated as an error",
'Native CSO Windows compiler probe failed (2).',
].join('\n'));
expect(r.stdout).toContain('reason=windows-msvc-compile\n');
expect(r.stdout).toContain('prereq=fix the MSVC compile error its toolchain probe reported (');
expect(r.stdout).toContain('error C2220: the following warning is treated as an error');
expect(r.stdout).toContain('Visual Studio is already installed');
});
test('a missing Visual Studio toolchain keeps the install instruction', () => {
const r = probe('CSO Windows build requires Visual Studio 2022 Build Tools with the Desktop development with C++ workload.');
expect(r.stdout).toContain('reason=windows-msvc-toolchain\n');
expect(r.stdout).toContain('prereq=install Visual Studio 2022 Build Tools with Desktop development with C++\n');
});
});
+3 -1
View File
@@ -17,10 +17,11 @@ const fn = (name: string) => {
const blockStart = setup.indexOf('# 6. Install for Kiro CLI');
const block = setup.slice(blockStart, setup.indexOf('# 6b.', blockStart));
const helpers = [
'_link_or_copy', '_sidecar_root_user_owned', '_claude_entry_is_ours',
'_link_or_copy', '_link_runtime_dists', '_sidecar_root_user_owned', '_claude_entry_is_ours',
'_claude_entry_owned_strongly', '_gstack_link_target_abs', '_gstack_target_is_ours',
'_gstack_generated_header', '_backup_skill_md', '_prune_stale_generated',
'_skill_source_exists', '_owned_for_windows_refresh', '_cleanup_weak_dir',
'_copy_skill_md', '_skill_copy_hash', '_skill_copy_unmodified', '_preserve_skill_copy_edits', '_record_skill_copies',
].map(fn).join('\n');
describe.skipIf(process.platform === 'win32')('native Kiro setup installation', () => {
@@ -51,6 +52,7 @@ describe.skipIf(process.platform === 'win32')('native Kiro setup installation',
'bun_cmd() { printf "%s\\n" "$*" >> "$BUN_LOG"; }',
'INSTALL_KIRO=1', `IS_WINDOWS=${windowsCopy}`, 'BROWSE_BIN=unused',
'_BACKED_UP_SKILL_MDS=()', '_SKILL_BACKUP_ROOT="$HOME/backups"',
'GSTACK_STATE_ROOT="$HOME/.gstack"', '_SKILL_COPIES_FILE="$GSTACK_STATE_ROOT/skill-copies.tsv"',
'KIRO_SKILLS="$HOME/.kiro/skills"', block,
].join('\n'), {
env: { ...process.env, HOME: home, SOURCE_GSTACK_DIR: root, BUN_LOG: log }, timeout: 30_000,
@@ -74,7 +74,7 @@ describe('dev-setup: never silently mutates global settings.json', () => {
});
});
describe('setup: PT_EXPLICIT provenance (Conductor auto-opt-in respects explicit decisions)', () => {
describe('setup: PT_EXPLICIT provenance (explicit decisions are never overridden)', () => {
// The phantom-hooks root cause (Bug A): the Conductor auto-opt-in upgraded
// PT_DECISION "prompt" → "yes" even when "prompt" came from dev-setup's
// EXPLICIT --plan-tune-hooks=prompt flag, so every new Conductor workspace
@@ -88,10 +88,11 @@ describe('setup: PT_EXPLICIT provenance (Conductor auto-opt-in respects explicit
expect(explicitIdx).toBeGreaterThan(flagIdx);
});
test('the Conductor auto-opt-in fires only on the true silent fall-through', () => {
expect(setupSrc).toMatch(
/\[ "\$PT_DECISION" = "prompt" \] && \[ "\$PT_EXPLICIT" -eq 0 \] && \{ \[ -n "\$\{CONDUCTOR_WORKSPACE_PATH:-\}" \] \|\| \[ -n "\$\{CONDUCTOR_PORT:-\}" \]; \}/,
);
// Q3 (#2207) retired the Conductor auto-opt-in: Conductor never upgrades a
// silent "prompt" to "yes". Behavior is pinned by setup-conductor-auq-hook.
test('Conductor never auto-opts into the plan-tune hooks', () => {
expect(setupSrc).not.toMatch(/CONDUCTOR[A-Z_]*:-\}" \]; \}; then\n\s*PT_DECISION="yes"/);
expect(setupSrc).not.toContain('_PT_CONDUCTOR_AUTO');
});
test('config provenance uses gstack-config has (env-resolution-safe), never a hardcoded config grep', () => {
+97
View File
@@ -0,0 +1,97 @@
import { afterEach, describe, expect, test } from 'bun:test';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { runBashScript } from './helpers/bash-script';
// C3 (#333): host runtime roots hold real SKILL.md copies. setup records each
// copy's hash under the state root; an edited copy is saved to a uniquely
// named backup before the tree is replaced, and a failed backup stops the
// replacement.
const setup = fs.readFileSync(path.resolve(import.meta.dir, '../setup'), 'utf8');
const fn = (name: string) => {
const start = setup.indexOf(`\n${name}() {`);
const end = setup.indexOf('\n}\n', start);
if (start < 0 || end < 0) throw new Error(`missing setup helper: ${name}`);
return setup.slice(start + 1, end + 2);
};
const helpers = ['_copy_skill_md', '_skill_copy_hash', '_skill_copy_unmodified', '_preserve_skill_copy_edits', '_record_skill_copies', '_activate_runtime_root'].map(fn).join('\n');
const tmps: string[] = [];
afterEach(() => { for (const t of tmps.splice(0)) { fs.chmodSync(t, 0o755); fs.rmSync(t, { recursive: true, force: true }); } });
function run(tmp: string, body: string) {
return runBashScript([
'set -e', 'log() { echo "$@"; }', `GSTACK_STATE_ROOT="${tmp}/state"`, '_SKILL_COPIES_FILE="$GSTACK_STATE_ROOT/skill-copies.tsv"',
helpers,
// A runtime root build: router plus one nested copy, from $SRC.
'build() { mkdir -p "$2/gstack-upgrade"; _copy_skill_md "$SRC/router.md" "$2/SKILL.md"; [ -f "$SRC/upgrade.md" ] && _copy_skill_md "$SRC/upgrade.md" "$2/gstack-upgrade/SKILL.md"; return 0; }',
`SRC="${tmp}/src"; ROOT="${tmp}/home/.codex/skills/gstack"`,
body,
].join('\n'), { timeout: 30_000 });
}
describe.skipIf(process.platform === 'win32')('setup: real-file SKILL.md copies (C3)', () => {
test('copies, refreshes, saves hand edits uniquely, and reports removals', () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-skill-copies-'));
tmps.push(tmp);
fs.mkdirSync(path.join(tmp, 'src'));
fs.writeFileSync(path.join(tmp, 'src/router.md'), 'router v1\n');
fs.writeFileSync(path.join(tmp, 'src/upgrade.md'), 'upgrade v1\n');
const root = path.join(tmp, 'home/.codex/skills/gstack');
let r = run(tmp, '_activate_runtime_root build "$SRC" "$ROOT"');
expect(r.status, r.stderr).toBe(0);
expect(r.stdout).toContain(`refreshed SKILL.md copies in ${root}: SKILL.md gstack-upgrade/SKILL.md`);
expect(fs.lstatSync(path.join(root, 'SKILL.md')).isSymbolicLink()).toBe(false);
// An unedited copy is refreshed with no backup.
fs.writeFileSync(path.join(tmp, 'src/router.md'), 'router v2\n');
r = run(tmp, '_activate_runtime_root build "$SRC" "$ROOT"');
expect(r.status, r.stderr).toBe(0);
expect(r.stdout).not.toContain('saved your edited');
expect(fs.readFileSync(path.join(root, 'SKILL.md'), 'utf8')).toBe('router v2\n');
// Two runs with hand edits keep two separate backups.
const backups: string[] = [];
for (const note of ['edit one', 'edit two']) {
fs.appendFileSync(path.join(root, 'SKILL.md'), `${note}\n`);
r = run(tmp, '_activate_runtime_root build "$SRC" "$ROOT"');
expect(r.status, r.stderr).toBe(0);
const m = r.stdout.match(/saved your edited (\S+) to (\S+) /);
expect(m, r.stdout).not.toBeNull();
expect(m![1]).toBe(path.join(root, 'SKILL.md'));
expect(m![2].startsWith(path.join(tmp, 'state/backups/skill-copies/'))).toBe(true);
expect(fs.readFileSync(m![2], 'utf8')).toBe(`router v2\n${note}\n`);
backups.push(m![2]);
expect(fs.readFileSync(path.join(root, 'SKILL.md'), 'utf8')).toBe('router v2\n');
}
expect(new Set(backups).size).toBe(2);
expect(fs.readFileSync(backups[0], 'utf8')).toBe('router v2\nedit one\n');
// A copy setup no longer writes is reported as removed.
fs.rmSync(path.join(tmp, 'src/upgrade.md'));
r = run(tmp, '_activate_runtime_root build "$SRC" "$ROOT"');
expect(r.stdout).toContain(`removed SKILL.md copies from ${root}: gstack-upgrade/SKILL.md`);
});
test('a backup that cannot be written keeps the old tree', () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-skill-copies-'));
tmps.push(tmp);
fs.mkdirSync(path.join(tmp, 'src'));
fs.writeFileSync(path.join(tmp, 'src/router.md'), 'router v1\n');
const root = path.join(tmp, 'home/.codex/skills/gstack');
expect(run(tmp, '_activate_runtime_root build "$SRC" "$ROOT"').status).toBe(0);
fs.appendFileSync(path.join(root, 'SKILL.md'), 'mine\n');
fs.mkdirSync(path.join(tmp, 'state/backups'), { recursive: true });
fs.chmodSync(path.join(tmp, 'state/backups'), 0o555);
try {
const r = run(tmp, '_activate_runtime_root build "$SRC" "$ROOT" || echo "rc=$?"');
expect(r.stdout).toContain('rc=1');
expect(r.stderr).toContain('could not back up an edited SKILL.md copy');
expect(fs.readFileSync(path.join(root, 'SKILL.md'), 'utf8')).toBe('router v1\nmine\n');
} finally {
fs.chmodSync(path.join(tmp, 'state/backups'), 0o755);
}
});
});
+1
View File
@@ -159,6 +159,7 @@ function runInstaller(
`IS_WINDOWS=${isWindows}`,
extraVars,
extractFn('_link_or_copy'),
extractFn('_link_runtime_dists'),
// Ownership gates (#2142) — dependencies of every installer under test.
extractFn('_owned_for_windows_refresh'),
extractFn('_sidecar_root_user_owned'),
+16 -1
View File
@@ -248,10 +248,25 @@ describe('gstack-team-init', () => {
expect(fs.existsSync(settingsPath)).toBe(true);
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
expect(settings.hooks.PreToolUse).toHaveLength(1);
expect(settings.hooks.PreToolUse[0].matcher).toBe('Skill');
// C7 (#2229): Copilot CLI's skill tool is lowercase `skill`; Claude-format
// matchers fire when an alternation token equals the runtime tool name.
expect(settings.hooks.PreToolUse[0].matcher.split('|').sort()).toEqual(['Skill', 'skill']);
expect(settings.hooks.PreToolUse[0].hooks[0].command).toContain('check-gstack');
});
test('required: a Skill-only entry from an older team-init is upgraded, not duplicated (C7)', () => {
const settingsPath = path.join(tmpDir, '.claude', 'settings.json');
fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
fs.writeFileSync(settingsPath, JSON.stringify({ hooks: { PreToolUse: [
{ matcher: 'Bash', hooks: [{ type: 'command', command: 'my-own-hook' }] },
{ matcher: 'Skill', hooks: [{ type: 'command', command: '"$CLAUDE_PROJECT_DIR/.claude/hooks/check-gstack.sh"' }] },
] } }));
run(`${TEAM_INIT} required`, { cwd: tmpDir });
run(`${TEAM_INIT} required`, { cwd: tmpDir });
const pre = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')).hooks.PreToolUse;
expect(pre.map((e: { matcher: string }) => e.matcher)).toEqual(['Bash', 'Skill|skill']);
});
test('idempotent: running twice does not duplicate CLAUDE.md section', () => {
run(`${TEAM_INIT} optional`, { cwd: tmpDir });
run(`${TEAM_INIT} optional`, { cwd: tmpDir });