diff --git a/bin/gstack-config b/bin/gstack-config index 5a37b3a0e..cd1603444 100755 --- a/bin/gstack-config +++ b/bin/gstack-config @@ -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@ — 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. diff --git a/bin/gstack-relink b/bin/gstack-relink index 9dfb06a4b..71ddb5477 100755 --- a/bin/gstack-relink +++ b/bin/gstack-relink @@ -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. diff --git a/bin/gstack-session-update b/bin/gstack-session-update index 6c40c494a..e49fc97f4 100755 --- a/bin/gstack-session-update +++ b/bin/gstack-session-update @@ -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 diff --git a/bin/gstack-settings-hook b/bin/gstack-settings-hook index 645616e61..d97efe6e8 100755 --- a/bin/gstack-settings-hook +++ b/bin/gstack-settings-hook @@ -14,7 +14,8 @@ # gstack-settings-hook add-event --event \ # --command --source [--matcher ] [--timeout ] # gstack-settings-hook ensure-event --event ... --command ... --source ... [--matcher ...] [--timeout ] -# gstack-settings-hook remove-source --source # removes items the table identifies as 's, tagged or not +# gstack-settings-hook remove-source --source [--event ] +# # removes items the table identifies as '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 # legacy SessionStart remove gstack-settings-hook add-event --event --command --source [--matcher ] [--timeout ] gstack-settings-hook ensure-event --event --command --source [--matcher ] [--timeout ] - gstack-settings-hook remove-source --source # tagged OR table-identified items of + gstack-settings-hook remove-source --source [--event ] # tagged OR table-identified items of gstack-settings-hook diff-event --event --command --source [--matcher ] [--timeout ] gstack-settings-hook prune-stale [--repoint ] [--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 = []; diff --git a/bin/gstack-team-init b/bin/gstack-team-init index eba34c66d..2c92c82b4 100755 --- a/bin/gstack-team-init +++ b/bin/gstack-team-init @@ -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\"' diff --git a/hosts/codex.ts b/hosts/codex.ts index e447b8153..0f2594626 100644 --- a/hosts/codex.ts +++ b/hosts/codex.ts @@ -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], diff --git a/hosts/define-host.ts b/hosts/define-host.ts index d2b9927e1..a90010e37 100644 --- a/hosts/define-host.ts +++ b/hosts/define-host.ts @@ -126,7 +126,7 @@ export function defineHost(overrides: HostOverrides): 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'], }, diff --git a/hosts/opencode.ts b/hosts/opencode.ts index 30da3f7ea..038267a0f 100644 --- a/hosts/opencode.ts +++ b/hosts/opencode.ts @@ -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'], }, diff --git a/scripts/build-cso-windows.ps1 b/scripts/build-cso-windows.ps1 index 77522461a..481c39382 100644 --- a/scripts/build-cso-windows.ps1 +++ b/scripts/build-cso-windows.ps1 @@ -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.' } } diff --git a/setup b/setup index cbb1ed14c..70dfdc662 100755 --- a/setup +++ b/setup @@ -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 `/sections/.md`", +# optionally "relative to the installed `` 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 <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/.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='' 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" ;; diff --git a/test/gstack-config-browse-extension-id.test.ts b/test/gstack-config-browse-extension-id.test.ts new file mode 100644 index 000000000..634eb592c --- /dev/null +++ b/test/gstack-config-browse-extension-id.test.ts @@ -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:/); + }); +}); diff --git a/test/gstack-config-defaults.test.ts b/test/gstack-config-defaults.test.ts index d3e493401..ecc5087ad 100644 --- a/test/gstack-config-defaults.test.ts +++ b/test/gstack-config-defaults.test.ts @@ -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 = {}; +const PROSE_ONLY_KEYS: Record = { + 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']; /** diff --git a/test/gstack-config-gbrain-refresh.test.ts b/test/gstack-config-gbrain-refresh.test.ts new file mode 100644 index 000000000..a0b8bae67 --- /dev/null +++ b/test/gstack-config-gbrain-refresh.test.ts @@ -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'); + } + }); +}); diff --git a/test/host-conformance.test.ts b/test/host-conformance.test.ts index 793488a5e..953b70263 100644 --- a/test/host-conformance.test.ts +++ b/test/host-conformance.test.ts @@ -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 `/sections/.md`", + * optionally "relative to the installed `` 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)', () => { diff --git a/test/qa-lazy-sections.test.ts b/test/qa-lazy-sections.test.ts index 4779689d6..412fc7ab6 100644 --- a/test/qa-lazy-sections.test.ts +++ b/test/qa-lazy-sections.test.ts @@ -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, { diff --git a/test/relink.test.ts b/test/relink.test.ts index 45b9e6233..87dfa51d9 100644 --- a/test/relink.test.ts +++ b/test/relink.test.ts @@ -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', () => { diff --git a/test/render-sections-provenance.test.ts b/test/render-sections-provenance.test.ts new file mode 100644 index 000000000..2573c0bcd --- /dev/null +++ b/test/render-sections-provenance.test.ts @@ -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/.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'); + }); +}); diff --git a/test/session-update-stages.test.ts b/test/session-update-stages.test.ts new file mode 100644 index 000000000..4107fe656 --- /dev/null +++ b/test/session-update-stages.test.ts @@ -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; + +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 { + 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); + }); +}); diff --git a/test/setup-codesign-restore.test.ts b/test/setup-codesign-restore.test.ts new file mode 100644 index 000000000..34d0414c3 --- /dev/null +++ b/test/setup-codesign-restore.test.ts @@ -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) { + 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)'); + }); +}); diff --git a/test/setup-codex-design-pdf-runtime.test.ts b/test/setup-codex-design-pdf-runtime.test.ts new file mode 100644 index 000000000..f8474fc06 --- /dev/null +++ b/test/setup-codex-design-pdf-runtime.test.ts @@ -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 }) { + const env: Record = { 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 { + 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 } } + +const BUILDERS: Record 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 }); + } + }); +}); diff --git a/test/setup-codex-model.test.ts b/test/setup-codex-model.test.ts index ab2a24ccb..b7d6db21f 100644 --- a/test/setup-codex-model.test.ts +++ b/test/setup-codex-model.test.ts @@ -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'); diff --git a/test/setup-conductor-auq-hook.test.ts b/test/setup-conductor-auq-hook.test.ts new file mode 100644 index 000000000..81f5c6897 --- /dev/null +++ b/test/setup-conductor-auq-hook.test.ts @@ -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 = {}) { + 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); +}); diff --git a/test/setup-cso-windows-probe.test.ts b/test/setup-cso-windows-probe.test.ts new file mode 100644 index 000000000..20635bdcb --- /dev/null +++ b/test/setup-cso-windows-probe.test.ts @@ -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'); + }); +}); diff --git a/test/setup-kiro-native.test.ts b/test/setup-kiro-native.test.ts index aac6d8e8a..ddfd1eb57 100644 --- a/test/setup-kiro-native.test.ts +++ b/test/setup-kiro-native.test.ts @@ -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, diff --git a/test/setup-plan-tune-hooks-noninteractive.test.ts b/test/setup-plan-tune-hooks-noninteractive.test.ts index a9da5d26a..e5ebd07ff 100644 --- a/test/setup-plan-tune-hooks-noninteractive.test.ts +++ b/test/setup-plan-tune-hooks-noninteractive.test.ts @@ -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', () => { diff --git a/test/setup-skill-copies.test.ts b/test/setup-skill-copies.test.ts new file mode 100644 index 000000000..549586267 --- /dev/null +++ b/test/setup-skill-copies.test.ts @@ -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); + } + }); +}); diff --git a/test/setup-windows-rerun-refresh.test.ts b/test/setup-windows-rerun-refresh.test.ts index b68ad23a1..f9e18401d 100644 --- a/test/setup-windows-rerun-refresh.test.ts +++ b/test/setup-windows-rerun-refresh.test.ts @@ -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'), diff --git a/test/team-mode.test.ts b/test/team-mode.test.ts index ea9f854aa..f8c267c3b 100644 --- a/test/team-mode.test.ts +++ b/test/team-mode.test.ts @@ -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 });