Files
gstack/setup
T
28d59ad56c v1.68.0.0 fix: next tracker wave — 16 verified fixes in, 90 stale PRs and 21 issues closed with receipts (#2632)
* fix(plan-tune): reject never-ask on one-way ids at --write

--check already ignored those prefs; --write still stored them and
--stats counted them as a working NEVER_ASK. Refuse the write and
count leftover on-disk prefs as INERT_ONE_WAY.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Fix: gstack-config get returns "" with exit 0 for keys that have no default

Skill preambles read configuration with

    VAR=$(gstack-config get <key> 2>/dev/null || echo "<default>")

and that fallback only fires on a non-zero exit. lookup_default ended in a
catch-all that echoed "" and returned 0, so for any key missing from the table
VAR came back empty and the default written right there in the preamble was
unreachable. The skill then branched on a value it never specified: "skip
entirely if QUESTION_TUNING is false", reached with QUESTION_TUNING="".

Four keys that skills actually read had no entry and took that path:

    question_tuning         -> callers assume "false"
    repo_mode               -> callers assume "unknown"
    team_mode               -> callers assume "false"
    transcript_ingest_mode  -> callers assume "off"

Each default above is the value the call sites already substitute in their own
`|| echo` fallback, so this only makes reachable what was already intended.

The catch-all now returns non-zero. That is deliberately scoped to the
unknown-key arm alone: keys whose default is intentionally empty still exit 0,
because "" is their real answer and their callers depend on it --
cross_project_learnings ("unset triggers the first-time prompt"),
redact_repo_visibility ("empty falls through to gh/glab detection"),
salience_allowlist, user_slug_at_*. Making every empty answer an error would
have broken those.

test/gstack-config-defaults.test.ts pins the class rather than the four
instances: it parses the case arms and asserts every `gstack-config get <key>`
site in the tree is covered, so adding a read without a default fails CI. It
also pins the exit-code contract in both directions. Verified failing against
the pre-fix script, where it names exactly those four keys.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(redact): a typo'd subcommand no longer exits 0 having done nothing

main() recognised exactly two subcommands and let everything else fall through
to the stdin scan. On empty stdin that prints "(no findings)" and exits 0, so:

    $ gstack-redact install-prepush-hooks    # plural typo
    gstack-redact scan — repo UNKNOWN
      (no findings)
    $ echo $?
    0

No hook was installed, and the operator has every reason to believe the
credential guard is armed. A guard that silently no-ops must never exit 0.

Two smaller faults in the same dispatch, both of which lead people here:

- There was no --help handler, so `gstack-redact --help` fell through to the
  scanner. Piping a credential to it scanned the secret and exited 3.
- With no piped input and no --from-file, readInput() blocks on readSync(fd 0)
  until an EOF that an interactive terminal never sends. That prints nothing
  at all, so it reads as a hang rather than as "this is a filter, feed it".

Now: --help/-h/help prints usage and exits 0; an unrecognised positional
prints the offender and exits 1; a TTY with nothing piped in prints usage
instead of blocking. "scan" stays accepted, because the human output header
reads "gstack-redact scan — repo …" and that is what people type.

Usage errors exit 1, deliberately not 2 or 3. Those mean MEDIUM and HIGH
findings and callers gate dispatch on them, so a usage error exiting 2 would
be read as "medium findings — prompt the user". A test pins that.

Tests: 4 written failing first, then fixed. Full suite 7,722 pass / 0 fail.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(browse): one ambiguous ref no longer kills the whole annotated screenshot

`snapshot -a` exits 1 with "Selector matched multiple elements" on most real
pages, so /qa, /canary and /land-and-deploy silently produce reports whose
screenshots do not exist. Plain `screenshot <path>` is unaffected.

Refs are built as getByRole(role, {name}) and disambiguated with .nth() when
role+name repeats. That disambiguation cannot fire for a node with NO accessible
name: the locator degrades to getByRole(role) with no name filter, and the count
driving .nth() is taken from the FILTERED aria snapshot while getByRole matches
the unfiltered DOM. Measured on a live page: the tree surfaced 2 unnamed
paragraphs, the DOM had 9. Landmarks (banner/main/contentinfo) and paragraphs are
correctly unnamed per ARIA, so this is the common case rather than an edge case.

boundingBox() then hits Playwright strict mode, and the catch allowlisted only
timeout/closed/Target/Execution-context messages — so the strict-mode error was
re-thrown and aborted every remaining annotation.

Two changes:

- `.first()` before boundingBox(), so an ambiguous ref draws a box on its first
  match instead of aborting. The heatmap path below has always tolerated this via
  a bare `catch {}`; annotate was the only path that could be killed outright.
- the catch no longer re-throws on unrecognised messages. A box we cannot measure
  is a box we do not draw, never a reason to lose the rest of the page. Set
  BROWSE_DEBUG to see what was skipped.

Also: `-o` passed without `-a`/`-H` was silently ignored (exit 0, no file), which
reads as "screenshots are broken" rather than "you forgot a flag". It now warns
and points at `browse screenshot <path>`.

Verified by rebuilding both ways against the same page with 51 refs present:
  before — "Selector matched multiple elements", no file written
  after  — exit 0, 229KB PNG

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(version-bump): missing or empty VERSION no longer repairs a fabricated 0.0.0.0 into package.json

repair now fails with exit 2 when the VERSION file is absent or empty
instead of folding to DEFAULT ("0.0.0.0") — which passed VERSION_RE and
regressed package.json below where it started. classify gains an additive
versionFileExists field so /ship can tell a real 0.0.0.0 from a fabricated
one. Re-derived from PR #2612 under the generated-file screening rule.

Fixes #2600 (repair half; the path-configurability half landed in v1.67 via #2531).
Contributed by @Lockyer228

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(memory-ingest): --probe counts post-attribution, through the same gate --bulk uses

probeMode previously stat'd every walked file, so setup-gbrain gated its
silent bulk ingest on pre-filter counts that the write path would never
ingest (#2394). The attribution decision now lives in ONE shared gate
(sessionIsAttributable — cheap-parse: cwd extraction + memoized
resolveGitRemote, never a full page build) used by BOTH probeMode and
preparePages, so the two stages' post-attribution counts are structurally
identical. ProbeReport gains skipped_unattributed; the probe prints what it
excluded and --include-unattributed restores raw counts. The parity is
pinned at the prepare stage (probe post-attribution == transcripts reaching
import), deliberately NOT == final written.

Re-derived from PR #2612 under the generated-file screening rule; the
shared-gate design and the remote memo are additions from the plan review.

Fixes #2394.
Contributed by @Lockyer228

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(browse): allow CPU and network throttling for performance measurement

Adds Emulation.setCPUThrottlingRate and Network.emulateNetworkConditions to
CDP_ALLOWLIST.

Motivation: diagnosing a real "uploads take 1-2 minutes" report, the only
machine available was a fast developer workstation. Client-side processing
measured 1.4s where the user experienced minutes, so the conclusion had to be
reached arithmetically rather than observed. Throttling would have let the
measurement reproduce the reporter's conditions directly.

Both fit the existing posture rather than widening it:
  - Emulation already allows setDeviceMetricsOverride, clearDeviceMetricsOverride
    and setUserAgentOverride, which are equally mutating and scoped to the tab.
  - Neither method reads page content. setCPUThrottlingRate affects only timing;
    emulateNetworkConditions constrains traffic rather than inspecting it, so no
    request bodies, headers or cookies are exposed. Both are output: 'trusted'
    because they return no page-derived data.

scope 'tab' for both, matching the surrounding Emulation entries.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(session-update): lock pidfile records the live holder; hard TTL bounds every wedge (#2613)

echo $$ inside the backgrounded subshell recorded the PARENT hook's PID —
which exits immediately — so every subsequent session judged the lock stale
and rm -rf'd a LIVE holder's lock, letting concurrent updaters run over each
other. The pidfile now records ${BASHPID:-$(sh -c 'echo $PPID')} (macOS
bash 3.2 has no BASHPID; the sh child's PPID is exactly this subshell).

Staleness is now two independent detectors: PID liveness (as before, but
against the real holder), and a 30-minute hard TTL on the heartbeat mtime —
reclaimed regardless of kill -0, so a recycled PID or hung holder can't wedge
the lock forever. The holder touches the pidfile after the pull and after
setup, so a legitimately-slow run keeps itself alive. Empty and missing
pidfiles are respected inside the TTL window (the mkdir→echo race) and
reclaimed past it.

Fixes #2613.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(browse): explicit windowsHide on every Bun.spawn site + census tripwire (#2575 residual)

Bun.spawn sites were structurally outside the windowsHide census (it swept
child_process bindings only). The runtime was already safe — native Bun hides
consoles by default and bun-polyfill.cjs defaults windowsHide !== false since
#2523/#2539 — but implicit defaults are exactly what regress silently. Every
Bun.spawn/spawnSync in browse/src now carries the explicit flag (harmless on
unix-only sites like Xvfb/xattr/open), and a second SWEEP in
windows-spawn-hide.test.ts fails CI on any new flagless Bun.spawn site.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(gbrain): brain worktree advances on the daily sync — no more silently stale brains (#2516)

The daily pull refreshed only ~/.gstack itself, never the detached worktree
at ~/.gstack-brain-worktree that gbrain actually indexes — so after setup the
brain served stale pages forever unless setup-gbrain/sync-gbrain happened to
run. brain-sync --once now advances the worktree once per 24h behind an
ATTEMPT stamp (.brain-worktree-last-advance — a persistently-failing advance
warns once a day, not at every skill boundary), inside the existing run lock
and before any ingest step touches the worktree.

The new gstack-gbrain-source-wireup --advance-only is built for the
unattended cadence: git-only (no gbrain prereqs), pins every operation to the
managed worktree (refuses paths that are not worktrees of the artifacts
repo), refuses dirty worktrees, and never runs the force-remove recovery — a
cron path must not be able to delete local changes. A static pin keeps the
force-remove out. docs/gbrain-sync.md stops overclaiming the old cadence.

Fixes #2516.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(memory-ingest): honor the per-remote deny/read-only trust policy (#2392)

Transcript ingest now respects the same trust store as code import — the gate
existed only in gstack-gbrain-sync's runCodeImport, so memory-ingest happily
ingested transcripts from deny-listed repos. preparePages filters prepared
transcript pages through ONE batch policy lookup (new 'get --batch' verb on
bin/gstack-gbrain-repo-policy — the script owns URL normalization; the client
adds repoPolicyTierBatch, one spawn for all distinct remotes, so large corpora
never pay a 10s-timeout subprocess per remote).

Outcomes match code-import semantics: read-only → clean skip
(skipped_policy_readonly), deny → counted refusal (skipped_policy_deny),
corrupted/unreadable store → HARD ERROR before any write (state, staging,
egress receipt, and import all untouched) with the recovery command named —
policy corruption must never read as successful ingestion. Artifacts are
never policy-filtered (their git_remote is a project slug, not a remote).

Fixes #2392.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(config): repo_mode keeps its empty no-default semantics (#2611 follow-up)

The ported defaults table synthesized repo_mode → "unknown", but EMPTY is
load-bearing for that key: gstack-repo-mode treats any non-empty answer as a
user override and skips its own repo classification — the synthesized default
turned the classifier into dead code (REPO_MODE=unknown everywhere; caught by
test/gstack-repo-mode.test.ts via the wave's cross-agent blame protocol).
repo_mode joins the empty-is-real carve-outs (empty output, exit 0).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(pair-agent): consent before killing a healthy headless daemon

The pair-agent headed switch spawned 'connect --force-restart'
unconditionally — auto-killing a live headless daemon (open tabs, cookies,
logins) in direct contradiction of the iron rule it sits beside ('only an
explicit --force-restart may kill a live daemon'). The CLI now captures
daemon liveness BEFORE ensureServer (which can itself boot a fresh daemon)
and relaunches only when the user passed --force-restart to pair-agent;
otherwise it prints the tab count and continues against the existing daemon.
The /pair-agent skill gains a matching one-way-door consent question
(template half rides the wave's template block).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(gbrain-status): MCP scoping is per-project, and project-local beats user scope

hasRemoteOnlyGbrainMcp scanned EVERY project's mcpServers in ~/.claude.json,
so one project's remote gbrain registration reclassified broken local engines
as thin-client machine-wide. It now reads user scope plus only the cwd's
nearest-ancestor project key.

The precedence itself was verified empirically and hermetically (fake HOME +
CLAUDE_CONFIG_DIR fixtures, claude 2.1.233): with both scopes defining
gbrain, 'claude mcp get gbrain' reports Scope: Local config — PROJECT-LOCAL
WINS. Both in-repo consumers assumed the opposite; brain-cache's endpoint
resolution flips to nearest-ancestor-project-first, and the stale user-first
pin in brain-cache-roundtrip now pins the verified precedence. (The user-first
jq in the brain-sync preamble resolver gets the same swap in the template
block.)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(slug): gstack-slug matches remote-slug's owner-repo canonical form (live misfile bug)

Found live during this wave's CEO review: bin/gstack-slug emitted
SLUG=garrytan for this garrytan/gstack worktree while remote-slug correctly
gave garrytan-gstack — decisions, timeline, ceo-plans, and learnings were
filing into the wrong project store (observed polluting Context Recovery with
another repo's decisions). Root cause: a stray empty ~/.git directory made
the walk-up crown $HOME as the outermost project root; the remote lookup ran
only against that root, failed silently, and the basename fallback cached
'garrytan' sticky. NOT worktree-specific — any strong marker on a non-repo
ancestor triggered it.

Fix: the walk now finds the outermost ancestor whose .git actually resolves
an origin remote and derives owner-repo with remote-slug's byte-identical
parse; marker-only ancestors keep anchoring the basename fallback but can no
longer shadow a real remote. A new cache self-heal recomputes the poisoned
shape (cached == basename of a marker root while a remote-bearing repo exists
below), preserving legit #2212 stickiness. Nested-repo walk-up, no-remote and
non-git fallbacks, and the SLUG=/BRANCH= eval contract are unchanged, pinned
by a 10-case parity suite. Store migration for pre-fix data is tracked in
TODOS.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(brain-sync): per-record spool dir — the enqueue/drain race dies structurally

Producers appended lines to .brain-queue.jsonl while the drain re-read and
os.replace'd it; the in-code comment admitted a lockless append between the
re-read and the replace was lost. Locks and rename-rotation designs were both
reviewed and rejected (each retained a tail race); the shipped design is a
maildir-style spool: one FILE per record in .brain-queue.d/ (tmp + atomic
rename), the drain snapshots filenames, processes, and deletes exactly what
it snapshotted. Writer and drainer never share an inode — nothing to race.

Semantics: at-least-once (a crash between process and unlink re-drains;
downstream content-hash dedup absorbs duplicates); retained (privacy-held)
records keep their files; unparseable records are kept + warned, never
destroyed. Legacy .brain-queue.jsonl migrates atomically on the next drain
(crash-leftover .migrating files recovered too); status/drop-queue count both
surfaces; discover-new writes spool records and advances its cursor
per-record-written. The preamble's queue-depth line switches to spool count
in this wave's template block.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(bin-context): native slug fallback walks up like bash gstack-slug

slugFromEnvironment derived the slug from the INNERMOST repo's origin while
bash gstack-slug walks to the outermost project root — nested/vendored repos
split their stores across the bash/native boundary (win32 hits the native
path constantly). The native fallback now ports _outermost_project_root
faithfully (strong/weak markers, outermost-strong-wins, 64-depth cap,
fixed-point termination) plus the full resolution order: env override →
walk-up → sticky cache with the #1125 self-heal → remote get-url → basename.
Twelve mirrored scenarios drive BOTH implementations against the same
fixtures and pin identical slugs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(next-version): git fallback queries the live remote, never mutates, and keeps 3-digit width

The degraded path counted every remote-tracking ref on every remote — stale
experiment branches and second remotes inflated version allocation, and a
failed base read flipped 3-digit repos to 4-digit slots. Now: ls-remote
--heads origin first (GIT_TERMINAL_PROMPT=0, 5s timeout, zero local ref
mutation); on failure, local refs/remotes/origin ONLY with an explicit
stale-refs warning; a failed base read zeroes at the LOCAL version file's
width so a 3-digit repo allocates 0.0.1, not 0.0.1.0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(setup): hooks register the global-install path and re-point stale ones

Registering hooks from a dev worktree baked that worktree's absolute path
into settings.json — deleting the worktree left a dead hook erroring on
every session stop, and the presence-only dedup (list-sources | grep) could
never re-point it. setup's hook paths now route through _hook_install_path
(global install preferred, source dir fallback), and the new ensure-event
verb on gstack-settings-hook compares the registered command payload against
canonical: identical → no write, different → single atomic replacement
(never zero or two registrations). The plan-tune hooks had the same stale
pattern and get the same fix without re-triggering their consent prompt.

Also hardened: bun 1.3.13 turns an uncaught sync fs error in bun -e into a
SILENT exit 0 — the registrar's write path now catches, prints, and exits 1,
so a failed update can never report fake-green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(preamble): learnings capture is unconditional at completion (#2402)

43 of 44 learnings entries came from explicit /learn — the completion-status
prose read 'if you discovered a durable project quirk... log it', which
models treated as optional. The step now ALWAYS runs: review the session for
durable learnings, log each one, and state 'No durable learnings this
session' explicitly when the review comes up empty — an empty result, never
a skipped step. Re-derived from PR #2612 under the generated-file screening
rule.

Fixes #2402.
Contributed by @Lockyer228

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(scrape): untrusted-content warning on the page-fetching skills (#2441)

/scrape and /skillify consumed page content with zero injection guidance —
the CHANGELOG claimed coverage the skills didn't have. The warning now lives
in ONE exported const (UNTRUSTED_CONTENT_WARNING in resolvers/browse.ts),
embedded in the browse COMMAND_REFERENCE as before AND injected standalone
into both skills via the new {{UNTRUSTED_CONTENT_WARNING}} token — single
source, wording can never drift between surfaces. Re-derived from PR #2612
under the generated-file screening rule. (Structural isolation for
skillify-generated code is tracked as its own TODO.)

Fixes #2441.
Contributed by @Lockyer228

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(review): checklist paths resolve from the installed skill root (#2518)

/review Step 2 read .claude/skills/review/checklist.md — a path relative to
the TARGET repo, which only resolves in gstack's own checkout. Every
checklist/greptile-triage/TODOS-format reference (six across five templates —
two more than the issue named, same class) now uses the installed-root form
~/.claude/skills/gstack/review/... that the templates' other references
already use. The install-root class itself (non-default install dirs) is
#1882, deliberately its own PR.

Fixes #2518.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(pair-agent): one-way-door consent question before a daemon relaunch (template half)

The skill flow now checks daemon liveness before Step 4 and asks an explicit
one-way-door question (tabs/cookies/logins are lost) before passing
--force-restart — never proceeding on a vague reply. Pairs with the CLI-half
commit that stopped pair-agent auto-killing live daemons.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(codex): resume does not amortize the ~21K session prelude (#2387)

Measured (#2387): every codex exec call pays Codex's session prelude, and a
resumed call came in slightly ABOVE a fresh one — resume buys continuity,
never token savings. The skill now says so where the resume flow lives:
prefer one codex call per skill, batch questions into it.

Fixes #2387.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(upgrade): fast-forward first; reset --hard only behind a proved-safe gate (#2517)

/gstack-upgrade went straight to stash + reset --hard origin/main. Now it
tries git pull --ff-only --autostash first (the same policy session-update's
auto-upgrade uses). The destructive fallback runs unprompted ONLY when both
git status --porcelain AND git rev-list origin/main..HEAD are empty — a
clean tree with unpushed local commits is NOT safe, reset destroys them.
Anything else requires an explicit one-way-door confirmation that lists every
dirty file and unpushed commit being discarded.

Fixes #2517.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(preamble): brain-sync block counts the spool queue and resolves MCP project-first

Two resolver halves deferred from earlier wave commits: the queue-depth line
counts .brain-queue.d/*.json spool records (plus legacy lines until the
drain migrates them), and GBRAIN_MCP_ENTRY_JQ swaps its operands to
nearest-ancestor-project-first — matching the empirically verified Claude
Code precedence (project-local beats user scope) instead of the backwards
user-first assumption.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore: regenerate SKILL.md docs + golden fixtures (single regen for the template block)

Pure generator output for the six template/resolver commits above (learnings
capture, untrusted-content warning, review paths, pair-agent consent, codex
resume note, upgrade ff-only, brain-sync block) — bun run gen:skill-docs +
--host codex + --host factory, with the three ship golden fixtures refreshed
per the documented procedure. The three sidecar-path pins in
gen-skill-docs.test.ts move to the new installed-root/$GSTACK_ROOT contract
(#2518). Restores template freshness; full suite green from here.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore: TODOS.md — strike the six wave-fixed residuals, add two follow-ups

The v1.67 adversarial-review residuals section shrinks to the one item the
wave couldn't reach (iOS tap routing — needs real-device verification). New
entries: skillify structural isolation (a prose warning is not a boundary for
page-derived generated code) and the slug store migration (pre-fix sessions
on stray-marker machines filed data under the degraded slug; post-fix reads
go to the correct store, so history needs a merge/alias).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test: align cross-cutting pins with the wave's contracts

Three suites pinned pre-wave behavior: browse's gstack-config test asserted
the old unknown-key ''/exit-0 shape (#2611 made it exit 1); the Windows-paths
suite pinned O_APPEND enqueue atomicity (the spool design satisfies the same
invariant via tmp + os.replace, one file per record — pinned in its new
form); and nine carve-guard skeleton ceilings absorbed the #2402
unconditional-learnings prose (~450B per skill), bumped with measured values
per the guard's own protocol.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test: re-anchor the referenced-path scanner self-check to the gstack-rooted review refs

The self-check pinned the review checklist as a class-1 alias-relative ref;
#2518 moved those refs to the installed gstack root (class 2). The guard now
proves the scanner sees them in their new class, so the class-2 assertion
can't go vacuous.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test: pin the wave's prose-tier behaviors (ship coverage-audit gap closure)

The coverage audit found one regression-shaped gap: nothing pinned that the
upgrade template's ff-only pull precedes the gated reset --hard (#2517) — a
future template edit reverting to reset-first would fail nothing. Pinned:
the ordering, the FF_OK gate, and the unpushed-commits check. Also pinned
the two minor gaps: the {{UNTRUSTED_CONTENT_WARNING}} injection points in
scrape/skillify (#2441) and brain-uninstall's spool-dir cleanup.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: pre-landing review round — 8 auto-fixes + 8 accepted findings hardened

The ship review army (4 specialists + red-team + checklist, 29 findings)
produced 8 mechanical auto-fixes and 11 decisions; the accepted set:

- win32 slug parity completed: lib/bin-context.ts gains the remote-first
  outermost walk + degraded-cache self-heal the bash side got this wave —
  the two implementations now agree on the stray-marker live-bug shape,
  pinned by shared fixtures (multi-specialist 9/10 finding).
- probe honors the plan's bounded-read decision: 256KB prefix, extraction
  semantics mirrored from parseTranscriptJsonl so probe/prepare can never
  diverge on the same file (>1MB transcript test).
- policy normalize parity: bash normalize() now matches canonicalizeRemote
  on .git/-trailing and uppercase-.GIT shapes (7-shape corpus pinned two
  ways) — a deny for those shapes could previously slip the transcript gate.
- session-update reclaim is TOCTOU-safe (atomic mv-aside on both branches).
- settings-hook: unparseable settings.json errors instead of being replaced
  with {}; ensure-event keys on (event, source) so matcher changes update
  in place — never zero or two registrations.
- dot-only slug guard at both parse sites (hostile 'url = ..' can't escape
  projects/); enqueue tmp-file janitor (1h TTL, inside the drain lock);
  brain-sync .migrating never clobbered; drop-queue/status count .migrating;
  snapshot -o warning correct + surfaced in diff mode; version-bump test
  order-dependence removed; uninstall clears the advance stamp.

Deferred with record: slug heal-probe cost sentinel (P3 TODO), FF_OK
conflation (noted, misdiagnosis-only).

270 pass / 0 fail across the 10 touched suites.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: adversarial round — the P0 finalize fail-safe and 12 hardened findings

Three adversarial passes (Claude fresh-context, Codex chaos, Codex structured
with P1 gate) on the full wave diff. Multi-source findings, all fixed:

- P0: finalize_queue is now explicit-delete-only — a record is unlinked ONLY
  when classification proves it staged or dropped; a classifier crash, a
  missing class file, or a malformed pulled .brain-privacy-map.json (which
  previously nuked the whole snapshotted queue, remotely triggerable) now
  retains everything, warns, and re-drains next run. load_privacy_map treats
  corrupt maps as retain-all, never as empty.
- next-version cannot silently drop a live claim: unreadable advertised refs
  get a targeted --depth=1 fetch + retry; still-unreadable claims surface as
  UNKNOWN warnings instead of duplicate-version silence.
- session-update lock: ownership-checked EXIT trap (a TTL-reclaimed holder
  can no longer delete the new holder's lock) + a 5-min background heartbeat
  so a legitimately-slow pull/setup is never reclaimed while alive.
- ensure-event collapses ALL same-(event,source) duplicates to one canonical
  entry; unique per-process tmp path; setup call sites surface (not swallow)
  the hardened refusals.
- memory-ingest: --limit counts only policy-permitted pages (denied records
  no longer starve permitted ones); --probe applies the same policy filter as
  --bulk (skipped_policy_* fields on the report).
- version-bump repair accepts a genuine literal 0.0.0.0 VERSION file.
- slug heal restricted to the stray-.git shape — package.json-anchored
  wrapper roots keep their legit sticky identity (#2212 preserved).
- brain-sync: idle fast path sees leftover .migrating records; unparseable
  spool records quarantine instead of warning forever; migration comment
  stops overclaiming the transition-window race.
- CDP throttling justifications document override persistence (callers own
  restoration), pinned in the allowlist test.

Deferred with record: deny retroactivity for already-ingested pages (P2 TODO,
same semantics as the code-import gate); legacy-migration tail race
(transition-window, requires pre-spool writers).

288 pass / 0 fail across the 10 touched suites.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore: regenerate SKILL.md docs + goldens (Windows-separator jq fix)

Pure generator output for the brain-sync block's jq ancestor match now
accepting backslash-formed Windows project keys — previously project-scoped
brains were invisible on Windows while the TS scope resolvers saw them.
Golden ship fixtures refreshed per the documented procedure.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: codex verify-pass residuals — chunked cwd read, post-filter partial count, migrating depth

The verify re-review passed the P1 gate (0 P1s) and left three residuals,
all applied: transcriptCwdFromPrefix reads in chunks until one complete
record (4MB cap) so a giant first prompt can't truncate mid-JSON and break
probe/bulk parity; partial_pages derives from the FINAL prepared set instead
of the whole scanned corpus; the preamble queue-depth line counts leftover
.brain-queue.jsonl.migrating records like the status path does (regen + goldens included).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore: bump version and changelog (v1.68.0.0)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: update project documentation for v1.68.0.0

BROWSER.md: fix the $B cdp example (positional JSON params, not --json;
depth is the real CDP param) and add the new perf-throttling examples
(Emulation.setCPUThrottlingRate, Network.emulateNetworkConditions) with
their clear-override counterparts. USING_GBRAIN_WITH_GSTACK.md: the
state-files table row for the sync queue now names the maildir-style
spool dir .brain-queue.d/ that replaced .brain-queue.jsonl this release.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test: align memory-pipeline probe pins with the #2394 stage-count contract

The paid-tier E2E pinned the pre-fix contract (probe headline = raw
discovered). Probe now counts post-attribution — the same gate --bulk
uses — with an explicit unattributed-skip line. Adds the
--include-unattributed companion pin so all 9 fixtures stay accounted for.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(next-version): batch missing-tip fetches — one bounded round trip, never a per-branch crawl

The targeted-fetch retry for branches whose advertised tip has no local
object ran ONE git fetch per branch (10s cap each). On a shallow clone
against a busy remote that crawls the network for minutes — CI's shard
deadline killed the free suite mid-file. Missing tips now collect into a
single batched shallow fetch (15s cap); refs still missing after the
batch (one unservable ref fails the whole transfer) get a capped
per-branch retry, and anything past the cap warns as an UNKNOWN claim
instead of fetching.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(next-version): pin the batched fetch + make the offline-contract tests hermetic

Two new G2 pins: N unfetched claim branches resolve with exactly ONE
fetch spawn (PATH-shimmed git counts invocations), and one unservable
ref no longer poisons the batch — live claims resolve via the bounded
retry while only the ghost warns UNKNOWN.

The #2545 offline-contract tests now run the CLI in a local fixture repo
instead of the repo's own checkout: the checkout path did a live
ls-remote against the real origin (operator-network-dependent, and the
CI shard-deadline hang). The online-contract test gains a succeeding gh
stub, so fallback:null is asserted deterministically instead of only
when the operator happens to be authed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(redact-cli): derive the synthetic AWS-key fixture — no contiguous credential literal in source

The CI quality gate scans every ADDED diff line with the redact engine,
so the #2610 port's raw fixture literals failed the very gate they
exist to test. The fixture is now assembled at runtime; the scanner
still receives the identical bytes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(next-version): pin the fixture's host via origin-URL sniff — kills the last environment dependence

The hermetic offline-contract fixture had no origin remote, so
detectHost() fell through to auth probes: a machine with glab authed
passed via the gitlab path while a bare CI runner read host:unknown
(offline stays false there) and failed. The fixture now pushes to a
local bare origin at a path containing github.com — the URL sniff pins
host:github identically everywhere, asserted explicitly in both tests,
with every git call still local.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: y$un_ <forrest.sun527@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: benjamin beres <benjamin.beres@bienpreter.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Ricky <ricky@kinokostudio.com.hk>
Co-authored-by: Connex Client Access <paul@paulkortman.com>
Co-authored-by: henbima <henbima@gmail.com>
2026-08-19 11:42:55 -07:00

2348 lines
106 KiB
Bash
Executable File

#!/usr/bin/env bash
# gstack setup — build browser binary + register skills with Claude Code / Codex
set -e
umask 077 # Restrict new files to owner-only (0o600 files, 0o700 dirs)
usage() {
cat <<'EOF'
gstack setup — install gstack skills + build browse binary
Usage: ./setup [options]
Options:
--host <name> Install for a specific host (claude, codex, kiro, factory,
opencode, openclaw, hermes, gbrain, auto). Default: claude.
--model <id> Codex model profile override. Otherwise reads Codex config.
--prefix Install skills with the gstack- prefix (e.g. /gstack-review).
--no-prefix Install skills with short names (e.g. /review). Default.
--team Switch to team mode (per-repo gstack with auto-update).
--no-team Force solo install even if a team-mode repo is detected.
-q, --quiet Suppress progress output.
-h, --help Show this help and exit.
Examples:
./setup # solo install for Claude Code
./setup --host codex # install for OpenAI Codex CLI
./setup --host codex --model gpt-5.6-sol
./setup --team # team mode for a shared repo
./setup --no-prefix # use short slash-command names
Docs: https://github.com/garrytan/gstack
EOF
}
# Short-circuit on -h/--help before any environment checks so users can
# discover flags even without bun installed.
for _arg in "$@"; do
case "$_arg" in
-h|--help) usage; exit 0 ;;
esac
done
if ! command -v bun >/dev/null 2>&1; then
echo "Error: bun is required but not installed." >&2
echo "Install with checksum verification:" >&2
echo ' BUN_VERSION="1.3.10"' >&2
echo ' tmpfile=$(mktemp)' >&2
echo ' curl -fsSL "https://bun.sh/install" -o "$tmpfile"' >&2
echo ' echo "Verify checksum before running: shasum -a 256 $tmpfile"' >&2
echo ' BUN_VERSION="$BUN_VERSION" bash "$tmpfile" && rm "$tmpfile"' >&2
exit 1
fi
INSTALL_GSTACK_DIR="$(cd "$(dirname "$0")" && pwd)"
SOURCE_GSTACK_DIR="$(cd "$(dirname "$0")" && pwd -P)"
INSTALL_SKILLS_DIR="$(dirname "$INSTALL_GSTACK_DIR")"
BROWSE_BIN="$SOURCE_GSTACK_DIR/browse/dist/browse"
CODEX_SKILLS="${CODEX_HOME:-$HOME/.codex}/skills"
CODEX_GSTACK="$CODEX_SKILLS/gstack"
FACTORY_SKILLS="$HOME/.factory/skills"
FACTORY_GSTACK="$FACTORY_SKILLS/gstack"
OPENCODE_SKILLS="$HOME/.config/opencode/skills"
OPENCODE_GSTACK="$OPENCODE_SKILLS/gstack"
CURSOR_SKILLS="$HOME/.cursor/skills"
CURSOR_GSTACK="$CURSOR_SKILLS/gstack"
IS_WINDOWS=0
case "$(uname -s)" in
MINGW*|MSYS*|CYGWIN*|Windows_NT) IS_WINDOWS=1 ;;
esac
# Windows: binaries are compiled with .exe suffix
if [ "$IS_WINDOWS" -eq 1 ]; then
BROWSE_BIN="$SOURCE_GSTACK_DIR/browse/dist/browse.exe"
fi
# ─── Symlink-or-copy helper ───────────────────────────────────
# On macOS/Linux: create a symlink (existing behavior).
# On Windows without Developer Mode (MSYS2/Git Bash): plain ln -snf silently
# creates a frozen file copy that doesn't refresh after `git pull`. We use
# explicit `cp -R` / `cp -f` so the user gets a real copy and the staleness
# is reportable (re-run ./setup after pull). Auto-detects file vs dir.
#
# INVARIANT: every symlink in this script MUST route through this helper.
# A raw ln call here will be caught by test/setup-windows-fallback.test.ts
# (the static-invariant assertion D7).
_link_or_copy() {
local src="$1"
local dst="$2"
if [ "$IS_WINDOWS" -eq 1 ]; then
rm -rf "$dst"
# Unix `ln -snf` accepts a name-only or relative-path source even when the
# target doesn't resolve from CWD (e.g. the connect-chrome alias points at
# the sibling-relative "gstack/open-gstack-browser"). On Windows the
# equivalent semantics don't exist — we'd need a real source on disk to
# copy. Skip the alias quietly rather than aborting setup under `set -e`.
if [ ! -e "$src" ]; then
return 0
fi
if [ -d "$src" ]; then
cp -R "$src" "$dst"
else
cp -f "$src" "$dst"
fi
else
ln -snf "$src" "$dst"
fi
}
# ─── Ownership gates for the Windows refresh bypass (#2444 → #2142) ─────────
# On Windows a refresh means rm -rf + re-copy (_link_or_copy). The host
# skills dirs are SHARED namespaces (~/.codex/skills, ~/.factory/skills,
# ~/.cursor/skills, ...), so a gstack* glob name can collide with a user's
# OWN real directory (e.g. ~/.cursor/skills/gstack-notes) — deleting it on
# every ./setup re-run is silent data loss. Mirror of bin/gstack-uninstall's
# provenance gate (#2563): an existing REAL skill dir may only be replaced
# when its SKILL.md carries the generated banner. Missing targets and
# symlinks always pass (replacing a link never destroys content); non-dir
# targets pass (file targets live inside gstack-owned roots).
_owned_for_windows_refresh() {
local dst="$1"
if [ ! -e "$dst" ] && [ ! -L "$dst" ]; then return 0; fi
if [ -L "$dst" ]; then return 0; fi
if [ ! -d "$dst" ]; then return 0; fi
grep -q '<!-- AUTO-GENERATED from' "$dst/SKILL.md" 2>/dev/null
}
# A sidecar/runtime ROOT (…/skills/gstack) is provably USER-owned when it is
# a real dir whose SKILL.md exists but lacks the generated banner — a
# hand-written skill squatting on the canonical name. The sidecar installers
# skip it entirely rather than write into (or wipe) someone else's skill.
# A root with NO SKILL.md stays presumed ours: it is the documented gstack
# install location and old/partial installs legitimately look like that.
_sidecar_root_user_owned() {
local root="$1"
[ -d "$root" ] || return 1
[ -L "$root" ] && return 1
[ -f "$root/SKILL.md" ] || return 1
! grep -q '<!-- AUTO-GENERATED from' "$root/SKILL.md" 2>/dev/null
}
# Swap a freshly-rendered tmp dir into the live render location (#2569
# hardening). Installed skills SYMLINK into the live dir, so it is only ever
# replaced AFTER a successful render — a failed render leaves the previous
# render (and every link into it) fully intact. Keep in sync with
# bin/gstack-config's _swap_in_render (same contract, both pinned by
# test/user-render-out-dir-install.test.ts).
_swap_in_render() {
local render_dir="$1" render_tmp="$2"
local render_old="$render_dir.old.$$"
rm -rf "$render_old"
if [ -e "$render_dir" ] || [ -L "$render_dir" ]; then mv "$render_dir" "$render_old"; fi
mv "$render_tmp" "$render_dir"
rm -rf "$render_old"
}
_WINDOWS_COPY_NOTE_PRINTED=0
_print_windows_copy_note_once() {
if [ "$IS_WINDOWS" -eq 1 ] && [ "$_WINDOWS_COPY_NOTE_PRINTED" -eq 0 ]; then
echo " note: Windows install uses file copies (no Developer Mode required). Re-run ./setup after every 'git pull' to refresh skill files."
_WINDOWS_COPY_NOTE_PRINTED=1
fi
}
# ─── Quiet mode helper ────────────────────────────────────────
QUIET=0
log() { [ "$QUIET" -eq 0 ] && echo "$@" || true; }
# ─── Parse flags ──────────────────────────────────────────────
HOST="claude"
LOCAL_INSTALL=0
SKILL_PREFIX=1
SKILL_PREFIX_FLAG=0
TEAM_MODE=0
NO_TEAM_MODE=0
PLAN_TUNE_HOOKS_MODE="" # "" = resolve from env/config/prompt; "yes"/"no" = explicit
MODEL_OVERRIDE=""
MODEL_OVERRIDE_SET=0
while [ $# -gt 0 ]; do
case "$1" in
--host) [ -z "$2" ] && echo "Missing value for --host (expected claude, codex, kiro, factory, opencode, cursor, slate, openclaw, hermes, gbrain, or auto)" >&2 && exit 1; HOST="$2"; shift 2 ;;
--host=*) HOST="${1#--host=}"; shift ;;
--model) [ -z "$2" ] && echo "Missing value for --model" >&2 && exit 1; MODEL_OVERRIDE="$2"; MODEL_OVERRIDE_SET=1; shift 2 ;;
--model=*) MODEL_OVERRIDE="${1#--model=}"; MODEL_OVERRIDE_SET=1; shift ;;
--local) LOCAL_INSTALL=1; shift ;;
--prefix) SKILL_PREFIX=1; SKILL_PREFIX_FLAG=1; shift ;;
--no-prefix) SKILL_PREFIX=0; SKILL_PREFIX_FLAG=1; shift ;;
--team) TEAM_MODE=1; shift ;;
--no-team) NO_TEAM_MODE=1; shift ;;
--plan-tune-hooks) PLAN_TUNE_HOOKS_MODE="yes"; shift ;;
--no-plan-tune-hooks) PLAN_TUNE_HOOKS_MODE="no"; shift ;;
--plan-tune-hooks=*) PLAN_TUNE_HOOKS_MODE="${1#--plan-tune-hooks=}"; shift ;;
-q|--quiet) QUIET=1; shift ;;
*) shift ;;
esac
done
case "$HOST" in
claude|codex|kiro|factory|opencode|cursor|slate|auto) ;;
openclaw)
echo ""
echo "OpenClaw integration uses a different model — OpenClaw spawns Claude Code"
echo "sessions natively via ACP. gstack provides methodology artifacts, not a"
echo "full skill installation."
echo ""
echo "To integrate gstack with OpenClaw:"
echo " 1. Tell your OpenClaw agent: 'install gstack for openclaw'"
echo " 2. Or generate artifacts: bun run gen:skill-docs --host openclaw"
echo " 3. See docs/OPENCLAW.md for the full architecture"
echo ""
exit 0 ;;
hermes)
echo ""
echo "Hermes integration uses the same model as OpenClaw — Hermes spawns"
echo "Claude Code sessions, and gstack provides methodology artifacts."
echo ""
echo "To integrate gstack with Hermes:"
echo " 1. Tell your Hermes agent: 'install gstack for hermes'"
echo " 2. Or generate artifacts: bun run gen:skill-docs --host hermes"
echo ""
exit 0 ;;
gbrain)
echo ""
echo "GBrain is a mod for gstack — it makes coding skills brain-aware."
echo "GBrain generates brain-enhanced skill variants that search your brain"
echo "for context before starting and save results after finishing."
echo ""
echo "To generate brain-aware skills:"
echo " bun run gen:skill-docs --host gbrain"
echo ""
echo "GBrain setup and brain skills ship from the GBrain repo."
echo ""
exit 0 ;;
*) echo "Unknown --host value: $HOST (expected claude, codex, kiro, factory, opencode, cursor, slate, openclaw, hermes, gbrain, or auto)" >&2; exit 1 ;;
esac
# ─── Resolve skill prefix preference ─────────────────────────
# Priority: CLI flag > saved config > interactive prompt (or flat default for non-TTY)
GSTACK_CONFIG="$SOURCE_GSTACK_DIR/bin/gstack-config"
export GSTACK_SETUP_RUNNING=1 # Prevent gstack-config post-set hook from triggering relink mid-setup
if [ "$SKILL_PREFIX_FLAG" -eq 0 ]; then
_saved_prefix="$("$GSTACK_CONFIG" get skill_prefix 2>/dev/null || true)"
if [ "$_saved_prefix" = "true" ]; then
SKILL_PREFIX=1
elif [ "$_saved_prefix" = "false" ]; then
SKILL_PREFIX=0
else
# No saved preference — prompt interactively (or default flat for non-TTY/quiet)
if [ "$QUIET" -eq 1 ]; then
SKILL_PREFIX=0
elif [ -t 0 ]; then
echo ""
echo "Skill naming: how should gstack skills appear?"
echo ""
echo " 1) Short names: /qa, /ship, /review"
echo " Recommended. Clean and fast to type."
echo ""
echo " 2) Namespaced: /gstack-qa, /gstack-ship, /gstack-review"
echo " Use this if you run other skill packs alongside gstack to avoid conflicts."
echo ""
printf "Choice [1/2] (default: 1, auto-selects in 10s): "
read -t 10 -r _prefix_choice </dev/tty 2>/dev/null || _prefix_choice=""
case "$_prefix_choice" in
2) SKILL_PREFIX=1 ;;
*) SKILL_PREFIX=0 ;;
esac
else
SKILL_PREFIX=0
fi
# Save the choice for future runs
"$GSTACK_CONFIG" set skill_prefix "$([ "$SKILL_PREFIX" -eq 1 ] && echo true || echo false)" 2>/dev/null || true
fi
else
# Flag was passed explicitly — persist the choice
"$GSTACK_CONFIG" set skill_prefix "$([ "$SKILL_PREFIX" -eq 1 ] && echo true || echo false)" 2>/dev/null || true
fi
# --local: install to .claude/skills/ in the current working directory (deprecated)
if [ "$LOCAL_INSTALL" -eq 1 ]; then
echo "Warning: --local is deprecated. Use global install + --team instead." >&2
echo " See: https://github.com/garrytan/gstack#team-mode" >&2
if [ "$HOST" = "codex" ]; then
echo "Error: --local is only supported for Claude Code (not Codex)." >&2
exit 1
fi
INSTALL_SKILLS_DIR="$(pwd)/.claude/skills"
mkdir -p "$INSTALL_SKILLS_DIR"
HOST="claude"
INSTALL_CODEX=0
fi
# For auto: detect which agents are installed
INSTALL_CLAUDE=0
INSTALL_CODEX=0
INSTALL_KIRO=0
INSTALL_FACTORY=0
INSTALL_OPENCODE=0
INSTALL_CURSOR=0
if [ "$HOST" = "auto" ]; then
command -v claude >/dev/null 2>&1 && INSTALL_CLAUDE=1
command -v codex >/dev/null 2>&1 && INSTALL_CODEX=1
command -v kiro-cli >/dev/null 2>&1 && INSTALL_KIRO=1
command -v droid >/dev/null 2>&1 && INSTALL_FACTORY=1
command -v opencode >/dev/null 2>&1 && INSTALL_OPENCODE=1
# Cursor's `cursor` CLI shim isn't always on PATH; ~/.cursor is the
# reliable footprint of an installed Cursor IDE.
command -v cursor >/dev/null 2>&1 && INSTALL_CURSOR=1
[ -d "$HOME/.cursor" ] && INSTALL_CURSOR=1
# If none found, default to claude
if [ "$INSTALL_CLAUDE" -eq 0 ] && [ "$INSTALL_CODEX" -eq 0 ] && [ "$INSTALL_KIRO" -eq 0 ] && [ "$INSTALL_FACTORY" -eq 0 ] && [ "$INSTALL_OPENCODE" -eq 0 ] && [ "$INSTALL_CURSOR" -eq 0 ]; then
INSTALL_CLAUDE=1
fi
elif [ "$HOST" = "claude" ]; then
INSTALL_CLAUDE=1
elif [ "$HOST" = "codex" ]; then
INSTALL_CODEX=1
elif [ "$HOST" = "kiro" ]; then
INSTALL_KIRO=1
elif [ "$HOST" = "factory" ]; then
INSTALL_FACTORY=1
elif [ "$HOST" = "opencode" ]; then
INSTALL_OPENCODE=1
elif [ "$HOST" = "cursor" ]; then
INSTALL_CURSOR=1
fi
if [ "$MODEL_OVERRIDE_SET" -eq 1 ] && [ "$INSTALL_CODEX" -eq 0 ]; then
echo "Error: --model is supported only when Codex is selected (--host codex or --host auto with Codex installed)." >&2
exit 1
fi
migrate_direct_codex_install() {
local gstack_dir="$1"
local codex_gstack="$2"
local migrated_dir="$HOME/.gstack/repos/gstack"
[ "$gstack_dir" = "$codex_gstack" ] || return 0
[ -L "$gstack_dir" ] && return 0
mkdir -p "$(dirname "$migrated_dir")"
if [ -e "$migrated_dir" ] && [ "$migrated_dir" != "$gstack_dir" ]; then
echo "gstack setup failed: direct Codex install detected at $gstack_dir" >&2
echo "A migrated repo already exists at $migrated_dir; move one of them aside and rerun setup." >&2
exit 1
fi
log "Migrating direct Codex install to $migrated_dir to avoid duplicate skill discovery..."
mv "$gstack_dir" "$migrated_dir"
SOURCE_GSTACK_DIR="$migrated_dir"
INSTALL_GSTACK_DIR="$migrated_dir"
INSTALL_SKILLS_DIR="$(dirname "$INSTALL_GSTACK_DIR")"
BROWSE_BIN="$SOURCE_GSTACK_DIR/browse/dist/browse"
# Windows: binaries are compiled with .exe suffix (same as the top-level
# BROWSE_BIN assignment — this re-derivation must not drop the suffix).
if [ "$IS_WINDOWS" -eq 1 ]; then
BROWSE_BIN="$SOURCE_GSTACK_DIR/browse/dist/browse.exe"
fi
}
if [ "$INSTALL_CODEX" -eq 1 ]; then
migrate_direct_codex_install "$SOURCE_GSTACK_DIR" "$CODEX_GSTACK"
fi
# Kill an entire process tree rooted at $1, leaves first. Killing only the
# backgrounded subshell orphans the wedged node/bun -> Chromium probe
# processes underneath it — re-creating the #2136 stuck-process pile-up and
# potentially leaving Playwright cache locks held. macOS ships no setsid
# binary, so a portable group-kill isn't available; walk `pgrep -P` children
# depth-first instead (pgrep exists on macOS and Linux). Falls back to a
# plain kill of the root pid when pgrep is unavailable.
_kill_tree() {
local pid="$1" child
if command -v pgrep >/dev/null 2>&1; then
for child in $(pgrep -P "$pid" 2>/dev/null); do
_kill_tree "$child"
done
fi
kill -9 "$pid" 2>/dev/null || true
}
# Deadline-bounded wait for a background probe. macOS ships no GNU timeout;
# poll the PID and SIGKILL the whole probe tree past the deadline. Returns
# the probe's exit code, or 124 on timeout.
_wait_with_deadline() {
local pid="$1" deadline_s="$2" waited=0
while kill -0 "$pid" 2>/dev/null; do
if [ "$waited" -ge "$deadline_s" ]; then
_kill_tree "$pid"
wait "$pid" 2>/dev/null || true
return 124
fi
sleep 1
waited=$((waited + 1))
done
wait "$pid"
}
ensure_playwright_browser() {
# #2136: fresh installs hung forever at this probe (macOS arm64) and
# re-runs stacked stuck process trees, so skills never got linked. Two
# fixes: prefer Node for the launch probe everywhere it exists (the
# bun --eval launch is the same pipe-bug family already worked around on
# Windows), and bound the probe with a 90s deadline — a wedged probe now
# reports failure (which routes to the install path) instead of hanging
# setup.
local probe_cmd
if command -v node >/dev/null 2>&1; then
probe_cmd='node -e "const { chromium } = require((process.cwd()) + \"/node_modules/playwright\"); (async () => { const b = await chromium.launch(); await b.close(); })().then(() => process.exit(0), () => process.exit(1))"'
elif [ "$IS_WINDOWS" -eq 1 ]; then
echo "gstack setup failed: Node.js is required on Windows" >&2
return 1
else
probe_cmd="bun --eval 'import { chromium } from \"playwright\"; const browser = await chromium.launch(); await browser.close();'"
fi
(
cd "$SOURCE_GSTACK_DIR"
eval "$probe_cmd"
) >/dev/null 2>&1 &
_wait_with_deadline $! 90
}
# P0 #2554: a macOS XProtect definition update can start SIGKILLing the
# Chromium revision the lockfile pins, which surfaces here as a failed launch
# probe. Clear com.apple.quarantine on the Playwright cache bundles ONLY —
# never a GSTACK_CHROMIUM_PATH bundle (that belongs to the wrapper/embedder;
# same scope contract as browse's probePoisonedChromiumBundle) — so the
# reinstall below produces a launchable browser. Best-effort and macOS-only.
_clear_playwright_quarantine() {
[ "$(uname -s)" = "Darwin" ] || return 0
local cache_root="${PLAYWRIGHT_BROWSERS_PATH:-$HOME/Library/Caches/ms-playwright}"
[ -d "$cache_root" ] || return 0
local d
for d in "$cache_root"/chromium-* "$cache_root"/chromium_headless_shell-*; do
[ -d "$d" ] || continue
echo " clearing com.apple.quarantine on $(basename "$d") (XProtect self-heal, #2554)" >&2
xattr -dr com.apple.quarantine "$d" 2>/dev/null || true
done
}
# Ensure a color-emoji font is installed (Linux only).
#
# Chromium renders emoji code points as .notdef "tofu" (▯) when no color-emoji
# font is installed. macOS ships "Apple Color Emoji" and Windows ships "Segoe UI
# Emoji", so they're fine out of the box. Most Linux distros and containers ship
# NO color-emoji font, which is why make-pdf output shows tofu in headers/tables
# that contain emoji. Install Noto Color Emoji to fix it.
#
# Best-effort: warn (don't fail) if we can't install — PDFs still generate, they
# just fall back to tofu for emoji as before. Skip entirely with
# GSTACK_SKIP_FONTS=1 (CI without sudo, managed machines, offline envs).
#
# Returns 0 and sets EMOJI_FONT_INSTALLED=1 when it actually installs a font.
EMOJI_FONT_INSTALLED=0
ensure_emoji_font() {
# macOS/Windows ship a color-emoji font; nothing to do.
[ "$(uname -s)" = "Linux" ] || return 0
[ "${GSTACK_SKIP_FONTS:-0}" = "1" ] && return 0
# Idempotency: a real COLOR emoji font that resolves for an actual emoji code
# point (U+1F600). `fc-list :lang=und-zsye` is too broad — it matches symbol
# and last-resort fallback fonts — so we use fc-match and require color=True.
if command -v fc-match >/dev/null 2>&1; then
if fc-match -f '%{family[0]}\t%{color}\n' ':lang=und-zsye:charset=1F600' 2>/dev/null | grep -qi 'True'; then
return 0
fi
fi
local sudo=""
if [ "$(id -u)" -ne 0 ] && command -v sudo >/dev/null 2>&1; then
# -n: never prompt. If a password is required we fail fast into the
# warn-not-fail path below instead of hanging a non-interactive setup.
sudo="sudo -n"
fi
# Every package-manager call is wrapped in `timeout` so a stuck dpkg/rpm lock
# or a wedged mirror fails fast into the warn path instead of hanging setup.
if command -v apt-get >/dev/null 2>&1; then
echo "Installing color-emoji font (fonts-noto-color-emoji) so make-pdf emoji render (set GSTACK_SKIP_FONTS=1 to skip)..."
DEBIAN_FRONTEND=noninteractive timeout 30 $sudo apt-get update -qq >/dev/null 2>&1 || true
DEBIAN_FRONTEND=noninteractive timeout 120 $sudo apt-get install -y -qq fonts-noto-color-emoji >/dev/null 2>&1 || return 1
elif command -v dnf >/dev/null 2>&1; then
echo "Installing color-emoji font (google-noto-color-emoji-fonts)..."
timeout 120 $sudo dnf install -y google-noto-color-emoji-fonts >/dev/null 2>&1 || return 1
elif command -v pacman >/dev/null 2>&1; then
echo "Installing color-emoji font (noto-fonts-emoji)..."
timeout 120 $sudo pacman -Sy --noconfirm noto-fonts-emoji >/dev/null 2>&1 || return 1
elif command -v apk >/dev/null 2>&1; then
echo "Installing color-emoji font (font-noto-emoji)..."
timeout 120 $sudo apk add --no-cache font-noto-emoji >/dev/null 2>&1 || return 1
else
return 1
fi
# Refresh fontconfig cache so Chromium picks up the new font. Run under sudo
# for the system cache dirs (unprivileged fc-cache fails on unwritable dirs).
if command -v fc-cache >/dev/null 2>&1; then
$sudo fc-cache -f >/dev/null 2>&1 || fc-cache -f >/dev/null 2>&1 || true
fi
EMOJI_FONT_INSTALLED=1
return 0
}
# After a fresh font install, stop any running browse render daemon so the next
# make-pdf render spawns a fresh Chromium that sees the new font. Chromium
# caches its font list at process start, so a daemon that was alive before the
# install would keep emitting tofu. `browse stop` is the graceful API; the
# daemon auto-respawns on the next render. Best-effort and per-project-root, so
# we also print a note for daemons in other roots.
refresh_browse_daemon_for_fonts() {
[ "$EMOJI_FONT_INSTALLED" -eq 1 ] || return 0
if [ -x "$BROWSE_BIN" ]; then
"$BROWSE_BIN" stop >/dev/null 2>&1 || true
fi
echo " Installed a color-emoji font. The next make-pdf render will show emoji."
echo " If a gstack browser is running in another project, restart it to pick up the font."
}
prepare_bun_for_windows_compile() {
BUN_CMD="bun"
BUN_CMD_WAS_COPIED=0
[ "$IS_WINDOWS" -eq 1 ] || return 0
local bun_path
bun_path="$(command -v bun 2>/dev/null || true)"
case "$bun_path" in
*[![:ascii:]]*)
local bun_copy_dir="$SOURCE_GSTACK_DIR/.tmp-bun-bin"
mkdir -p "$bun_copy_dir"
cp -f "$bun_path" "$bun_copy_dir/bun.exe"
BUN_CMD="$bun_copy_dir/bun.exe"
BUN_CMD_WAS_COPIED=1
;;
esac
}
bun_cmd() {
"$BUN_CMD" "$@"
}
cleanup_copied_bun() {
if [ "${BUN_CMD_WAS_COPIED:-0}" -eq 1 ]; then
rm -rf "$SOURCE_GSTACK_DIR/.tmp-bun-bin"
fi
}
prepare_bun_for_windows_compile
trap cleanup_copied_bun EXIT
# Resolve the model overlay used for generated Codex skills. Setup auto-detects
# only Codex because it has one canonical TOML config surface; direct generator
# calls remain deterministic and use the host default unless --model is explicit.
# The resolver runs on EVERY setup, not just codex installs: step 1b regenerates
# .agents/ unconditionally, and existing ~/.codex/skills symlinks point into it —
# a plain `./setup` on a Sol user's machine must not clobber their profile with
# the hardcoded fallback. The resolver is a read-only TOML lookup that falls
# back to gpt when no Codex config exists.
CODEX_GENERATION_MODEL="gpt"
CODEX_GENERATION_MODEL_SOURCE="default (gpt)"
_CODEX_MODEL_ARGS=(run scripts/resolve-codex-generation-model.ts)
if [ "$MODEL_OVERRIDE_SET" -eq 1 ]; then
_CODEX_MODEL_ARGS+=(--explicit "$MODEL_OVERRIDE")
fi
_CODEX_MODEL_OUTPUT="$(cd "$SOURCE_GSTACK_DIR" && bun_cmd "${_CODEX_MODEL_ARGS[@]}")"
IFS=$'\t' read -r CODEX_GENERATION_MODEL CODEX_GENERATION_MODEL_SOURCE <<< "$_CODEX_MODEL_OUTPUT"
if [ -z "$CODEX_GENERATION_MODEL" ]; then
echo "gstack setup failed: Codex model resolver returned no model" >&2
exit 1
fi
if [ "$INSTALL_CODEX" -eq 1 ] || [ "$CODEX_GENERATION_MODEL" != "gpt" ]; then
log "Codex skill profile: $CODEX_GENERATION_MODEL"
log "Source: $CODEX_GENERATION_MODEL_SOURCE"
fi
# 1. Build browse binary if needed (smart rebuild: stale sources, package.json, lock)
NEEDS_BUILD=0
if [ ! -x "$BROWSE_BIN" ]; then
NEEDS_BUILD=1
elif [ -n "$(find "$SOURCE_GSTACK_DIR/browse/src" -type f -newer "$BROWSE_BIN" -print -quit 2>/dev/null)" ]; then
NEEDS_BUILD=1
elif [ "$SOURCE_GSTACK_DIR/package.json" -nt "$BROWSE_BIN" ]; then
NEEDS_BUILD=1
elif [ -f "$SOURCE_GSTACK_DIR/bun.lock" ] && [ "$SOURCE_GSTACK_DIR/bun.lock" -nt "$BROWSE_BIN" ]; then
NEEDS_BUILD=1
fi
if [ "$NEEDS_BUILD" -eq 1 ]; then
log "Building browse binary..."
(
cd "$SOURCE_GSTACK_DIR"
bun_cmd install --frozen-lockfile 2>/dev/null || bun_cmd install
bun_cmd run build
)
# Safety net: write .version if build script didn't (e.g., git not available during build)
if [ ! -f "$SOURCE_GSTACK_DIR/browse/dist/.version" ]; then
git -C "$SOURCE_GSTACK_DIR" rev-parse HEAD > "$SOURCE_GSTACK_DIR/browse/dist/.version" 2>/dev/null || true
fi
# macOS Apple Silicon: ad-hoc codesign compiled binaries.
# Bun's --compile can produce a corrupt or linker-only code signature that
# macOS kills with SIGKILL (exit 137). The two-step remove+re-sign is
# required because a naive `codesign -s - -f` fails when the existing
# signature block is corrupt. This is idempotent and costs <1s.
#
# Some binaries (observed: find-browse, gstack-global-discover) also carry
# trailing zero-padding AFTER the Mach-O LC_CODE_SIGNATURE region. macOS
# codesign requires the signature to be the last content and extend to EOF,
# so the padding triggers "main executable failed strict validation" on
# re-sign (and "internal error in Code Signing subsystem" on remove). We
# truncate that trailing slack to the end of LC_CODE_SIGNATURE first, which
# lets the identical re-sign succeed. The binary runs either way: Bun's
# adhoc code-page signature satisfies the kernel's exec check even when
# `codesign --verify` is unhappy, so a re-sign failure only warns when the
# binary is genuinely SIGKILL'd on exec (exit 137).
# See: https://github.com/garrytan/gstack/issues/997
if [ "$(uname -s)" = "Darwin" ] && [ "$(uname -m)" = "arm64" ]; then
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
# 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.
_sig_end=$(otool -l "$_bin_path" 2>/dev/null | awk '/LC_CODE_SIGNATURE/{f=1} f&&/dataoff/{o=$2} f&&/datasize/{print o+$2; exit}')
_fsize=$(stat -f%z "$_bin_path" 2>/dev/null)
if [ -n "$_sig_end" ] && [ -n "$_fsize" ] && [ "$_sig_end" -gt 0 ] 2>/dev/null && [ "$_sig_end" -lt "$_fsize" ] 2>/dev/null; then
_trunc_tmp=$(mktemp 2>/dev/null) || _trunc_tmp=""
if [ -n "$_trunc_tmp" ] && head -c "$_sig_end" "$_bin_path" > "$_trunc_tmp" 2>/dev/null; then
cat "$_trunc_tmp" > "$_bin_path" && chmod +x "$_bin_path"
fi
[ -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
fi
done
fi
# macOS: install coreutils for `gtimeout` (Codex hang protection in /codex + /autoplan).
# macOS ships BSD `timeout`-less; Homebrew's coreutils installs GNU timeout as
# `gtimeout` to avoid shadowing BSD utilities. The /codex and /autoplan skills
# fall back to unwrapped codex invocations when neither is available — this
# auto-install upgrades them to hang-protected where possible.
# Skip entirely with GSTACK_SKIP_COREUTILS=1 (CI, managed machines, offline envs).
if [ "$(uname -s)" = "Darwin" ] && [ "${GSTACK_SKIP_COREUTILS:-0}" != "1" ]; then
if ! command -v gtimeout >/dev/null 2>&1 && ! command -v timeout >/dev/null 2>&1; then
if command -v brew >/dev/null 2>&1; then
log "Installing coreutils for Codex hang protection (set GSTACK_SKIP_COREUTILS=1 to skip)..."
brew install coreutils >/dev/null 2>&1 || log "warning: brew install coreutils failed; /codex will run without hang protection"
else
log "warning: Homebrew not found. /codex will run without hang protection. Install coreutils manually or set GSTACK_SKIP_COREUTILS=1."
fi
fi
fi
fi
if [ ! -x "$BROWSE_BIN" ]; then
echo "gstack setup failed: browse binary missing at $BROWSE_BIN" >&2
exit 1
fi
# 1b. Generate .agents/ Codex skill docs — always regenerate to prevent stale descriptions.
# .agents/ is no longer committed — generated at setup time from .tmpl templates.
# bun run build generates the host-default artifact. Always render Codex again
# with the resolved user profile so a build cannot overwrite a Sol-specific render.
# Always regenerate: generation is fast (<2s) and mtime-based staleness checks are fragile
# (miss stale files when timestamps match after clone/checkout/upgrade).
AGENTS_DIR="$SOURCE_GSTACK_DIR/.agents/skills"
NEEDS_AGENTS_GEN=1
if [ "$NEEDS_AGENTS_GEN" -eq 1 ]; then
log "Generating .agents/ skill docs..."
(
cd "$SOURCE_GSTACK_DIR"
bun_cmd install --frozen-lockfile 2>/dev/null || bun_cmd install
bun_cmd run gen:skill-docs --host codex --model "$CODEX_GENERATION_MODEL"
)
fi
# 1c. Generate .factory/ Factory Droid skill docs
if [ "$INSTALL_FACTORY" -eq 1 ] && [ "$NEEDS_BUILD" -eq 0 ]; then
log "Generating .factory/ skill docs..."
(
cd "$SOURCE_GSTACK_DIR"
bun_cmd install --frozen-lockfile 2>/dev/null || bun_cmd install
bun_cmd run gen:skill-docs --host factory
)
fi
# 1d. Generate .opencode/ OpenCode skill docs
if [ "$INSTALL_OPENCODE" -eq 1 ] && [ "$NEEDS_BUILD" -eq 0 ]; then
log "Generating .opencode/ skill docs..."
(
cd "$SOURCE_GSTACK_DIR"
bun_cmd install --frozen-lockfile 2>/dev/null || bun_cmd install
bun_cmd run gen:skill-docs --host opencode
)
fi
# 1e. Generate .cursor/ Cursor skill docs
if [ "$INSTALL_CURSOR" -eq 1 ] && [ "$NEEDS_BUILD" -eq 0 ]; then
log "Generating .cursor/ skill docs..."
(
cd "$SOURCE_GSTACK_DIR"
bun_cmd install --frozen-lockfile 2>/dev/null || bun_cmd install
bun_cmd run gen:skill-docs --host cursor
)
fi
# 2. Ensure Playwright's Chromium is available
# Detect Ubuntu 26.04: Playwright does not yet ship a native chromium build for
# ubuntu26.04-x64. Override the platform to ubuntu24.04-x64 so the installer
# picks the correct binary. This is safe because the ubuntu24.04 build runs
# fine on ubuntu26.04 (same glibc lineage). See #2101.
_PLAYWRIGHT_PLATFORM_OVERRIDE=""
if [ -f /etc/os-release ]; then
_os_id=$(grep '^ID=' /etc/os-release | cut -d= -f2 | tr -d '"')
_os_ver=$(grep '^VERSION_ID=' /etc/os-release | cut -d= -f2 | tr -d '"')
if [ "$_os_id" = "ubuntu" ] && [ "$_os_ver" = "26.04" ]; then
_PLAYWRIGHT_PLATFORM_OVERRIDE="ubuntu24.04-x64"
echo "Ubuntu 26.04 detected — using PLAYWRIGHT_HOST_PLATFORM_OVERRIDE=$_PLAYWRIGHT_PLATFORM_OVERRIDE"
fi
fi
if ! ensure_playwright_browser; then
echo "Installing Playwright Chromium..."
# XProtect self-heal (#2554): the probe failure may be the OS killing the
# cached Chromium, not a missing install. Clear quarantine on the Playwright
# cache bundles before reinstalling so the fresh fetch launches clean.
_clear_playwright_quarantine
_PW_LOCK="${TMPDIR:-/tmp}/gstack-playwright-install.lock"
# Stale-lock self-heal: a SIGKILL'd prior setup leaves the lock dir behind
# forever (mkdir mutexes have no owner). If the recorded holder PID is dead,
# reclaim instead of telling the user to rmdir by hand.
if [ -d "$_PW_LOCK" ] && [ -f "$_PW_LOCK/pid" ]; then
_PW_HOLDER=$(cat "$_PW_LOCK/pid" 2>/dev/null || true)
if [ -n "$_PW_HOLDER" ] && ! kill -0 "$_PW_HOLDER" 2>/dev/null; then
echo " reclaiming stale Chromium-install lock (holder pid $_PW_HOLDER is gone)" >&2
rm -rf "$_PW_LOCK" 2>/dev/null || true
fi
fi
if mkdir "$_PW_LOCK" 2>/dev/null; then
echo "$$" > "$_PW_LOCK/pid" 2>/dev/null || true
# Chain the earlier cleanup_copied_bun EXIT trap: `trap ... EXIT` REPLACES
# the previous handler, so the lock trap must run both or any run taking
# this path leaves .tmp-bun-bin behind.
trap 'rm -rf "$_PW_LOCK" 2>/dev/null || true; cleanup_copied_bun' EXIT
(
cd "$SOURCE_GSTACK_DIR"
if [ -n "$_PLAYWRIGHT_PLATFORM_OVERRIDE" ]; then
PLAYWRIGHT_HOST_PLATFORM_OVERRIDE="$_PLAYWRIGHT_PLATFORM_OVERRIDE" bunx playwright install chromium
else
bunx playwright install chromium
fi
)
rm -rf "$_PW_LOCK" 2>/dev/null || true
# Restore the original handler (never `trap - EXIT`, which would clear
# cleanup_copied_bun for the rest of the script).
trap cleanup_copied_bun EXIT
else
echo " another gstack setup is already installing Chromium (lock: $_PW_LOCK)." >&2
echo " Wait for it to finish, then re-run ./setup. If no other setup is running," >&2
echo " remove the stale lock: rm -rf \"$_PW_LOCK\"" >&2
exit 1
fi
if [ "$IS_WINDOWS" -eq 1 ]; then
# On Windows, Node.js launches Chromium (not Bun — see oven-sh/bun#4253).
# Ensure playwright is importable by Node from the gstack directory.
if ! command -v node >/dev/null 2>&1; then
echo "gstack setup failed: Node.js is required on Windows (Bun cannot launch Chromium due to a pipe bug)" >&2
echo " Install Node.js: https://nodejs.org/" >&2
exit 1
fi
echo "Windows detected — verifying Node.js can load Playwright..."
(
cd "$SOURCE_GSTACK_DIR"
# Bun's node_modules already has playwright; verify Node can require it
node -e "require('playwright')" 2>/dev/null || npm install --no-save playwright
# @ngrok/ngrok is externalized in server-node.mjs and resolved at runtime.
# Verify the platform-specific native binary is installed so /pair-agent
# tunnels don't fail later with a cryptic module-not-found error.
node -e "require('@ngrok/ngrok')" 2>/dev/null || npm install --no-save @ngrok/ngrok
)
fi
fi
if ! ensure_playwright_browser; then
if [ "$IS_WINDOWS" -eq 1 ]; then
echo "gstack setup failed: Playwright Chromium could not be launched via Node.js" >&2
echo " This is a known issue with Bun on Windows (oven-sh/bun#4253)." >&2
echo " Ensure Node.js is installed and 'node -e \"require('playwright')\"' works." >&2
else
echo "gstack setup failed: Playwright Chromium could not be launched" >&2
fi
exit 1
fi
# 2b. Ensure a color-emoji font is installed so make-pdf emoji render (Linux).
# Best-effort: warn instead of failing if it can't install.
if ! ensure_emoji_font; then
echo " Note: could not auto-install a color-emoji font. Emoji in make-pdf" >&2
echo " output may render as boxes (▯). Install one manually, e.g.:" >&2
echo " Debian/Ubuntu: sudo apt-get install fonts-noto-color-emoji" >&2
echo " Fedora: sudo dnf install google-noto-color-emoji-fonts" >&2
echo " Arch: sudo pacman -S noto-fonts-emoji" >&2
echo " Alpine: sudo apk add font-noto-emoji" >&2
else
refresh_browse_daemon_for_fonts
fi
# 3. Ensure ~/.gstack global state directory exists
mkdir -p "$HOME/.gstack/projects"
# ─── Helper: link a skill's runtime assets into its installed dir ────────────
# Installs EVERY runtime asset a skill ships next to its SKILL.md (#2317,
# #2454): review/checklist.md + specialists/, qa/templates + references,
# gstack-upgrade/migrations, careful/bin, freeze/bin, sections/, etc.
# Exclusion list rather than inclusion list (F7) so a new asset file is
# installed by default instead of silently dropped:
# - SKILL.md linked separately by the caller (name-aware)
# - node_modules dependency trees, never a runtime read
# - dist compiled binaries; skills reference them repo-anchored
# (~/.claude/skills/gstack/browse/dist/...), never
# alias-relative, and fresh clones haven't built them
# - test test fixtures
# - *.tmpl generator sources; the generated file is the asset
# - hidden files excluded by the glob (no dotglob)
# Shared so any flattened-skill installer can reuse it (the Claude path is
# the first consumer; codex/factory/opencode install from generated trees).
_link_skill_runtime_assets() {
local src_dir="$1"
local dst_dir="$2"
local asset asset_name
for asset in "$src_dir"/*; do
[ -e "$asset" ] || continue # empty-glob guard
asset_name="$(basename "$asset")"
case "$asset_name" in
SKILL.md|node_modules|dist|test|*.tmpl) continue ;;
esac
# Refresh unconditionally: rm the old entry (symlink OR real copy — the
# Windows install pattern) so re-runs after `git pull` pick up changes.
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"
# 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 —
# a rendered skill install is never a build root, so nested
# node_modules/.build/dist are dead weight. ONLY here: the generic
# _link_or_copy stays untouched because runtime roots (browse/, design/)
# intentionally copy their dist/ binaries.
if [ "$IS_WINDOWS" -eq 1 ] && [ -d "$dst_dir/$asset_name" ] && [ ! -L "$dst_dir/$asset_name" ]; then
find "$dst_dir/$asset_name" -type d \( -name node_modules -o -name .build -o -name dist \) -prune -exec rm -rf {} + 2>/dev/null || true
fi
done
}
# ─── Helper: link Claude skill subdirectories into a skills parent directory ──
# Creates real directories (not symlinks) at the top level with a SKILL.md symlink
# inside. This ensures Claude discovers them as top-level skills, not nested under
# gstack/ (which would auto-prefix them as gstack-*).
# When SKILL_PREFIX=1, directories are prefixed with "gstack-".
# Use --no-prefix to restore flat names.
link_claude_skill_dirs() {
local gstack_dir="$1"
local skills_dir="$2"
local linked=()
for skill_dir in "$gstack_dir"/*/; do
if [ -f "$skill_dir/SKILL.md" ]; then
dir_name="$(basename "$skill_dir")"
# Skip node_modules
[ "$dir_name" = "node_modules" ] && continue
# Use frontmatter name: if present (e.g., run-tests/ with name: test → symlink as "test")
skill_name=$(grep -m1 '^name:' "$skill_dir/SKILL.md" 2>/dev/null | sed 's/^name:[[:space:]]*//' | tr -d '[:space:]')
[ -z "$skill_name" ] && skill_name="$dir_name"
# Apply gstack- prefix unless --no-prefix or already prefixed
if [ "$SKILL_PREFIX" -eq 1 ]; then
case "$skill_name" in
gstack-*) link_name="$skill_name" ;;
*) link_name="gstack-$skill_name" ;;
esac
else
link_name="$skill_name"
fi
target="$skills_dir/$link_name"
# Upgrade old directory symlinks to real directories
if [ -L "$target" ]; then
rm -f "$target"
fi
# Create real directory with symlinked SKILL.md (absolute path)
# Use mkdir -p unconditionally (idempotent) to avoid TOCTOU race
mkdir -p "$target"
# Validate target isn't a symlink before creating the link
if [ -L "$target/SKILL.md" ]; then rm "$target/SKILL.md"; fi
# #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.
_skill_md_src="$gstack_dir/$dir_name/SKILL.md"
_render_dir="${GSTACK_USER_RENDER_DIR:-${GSTACK_HOME:-$HOME/.gstack}/render/claude}"
if [ -f "$_render_dir/$dir_name/SKILL.md" ]; then
_skill_md_src="$_render_dir/$dir_name/SKILL.md"
fi
_link_or_copy "$_skill_md_src" "$target/SKILL.md"
# Link every runtime asset the skill ships next to its SKILL.md (#2317,
# #2454): sections/ for carved skills, review's checklist.md +
# specialists/, qa's templates/ + references/, gstack-upgrade's
# migrations/, careful/freeze's bin/, ... Without this, only SKILL.md
# 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"
linked+=("$link_name")
fi
done
if [ ${#linked[@]} -gt 0 ]; then
echo " linked skills: ${linked[*]}"
_print_windows_copy_note_once
fi
}
# ─── Helper: install an alias SKILL.md as a rewritten COPY ───────────────────
# Alias dirs (_gstack-command, connect-chrome) must NOT symlink the canonical
# SKILL.md: the alias then carries the canonical frontmatter name:, Claude Code
# sees two skills with the same name, and drops the ENTIRE personal-skills set
# (#2511, #2201). Copy-then-rewrite instead: sed reads the SOURCE and writes a
# fresh copy with name: set to the alias. It must never edit through an
# existing symlink — that would rewrite the generated source file itself.
# NOTE: every alias name passed to this helper (_gstack-command,
# connect-chrome, gstack-connect-chrome) is hardcoded in the _INVENTORY seed
# list in bin/gstack-uninstall — keep the two sites in sync when adding or
# renaming an alias, or uninstall will refuse to delete the new alias dir.
_install_alias_skill_md() {
local src_skill_md="$1"
local dst_dir="$2"
local alias_name="$3"
[ -f "$src_skill_md" ] || return 0
# Old installs left the alias as a whole-dir symlink — replace it.
if [ -L "$dst_dir" ]; then rm -f "$dst_dir"; fi
mkdir -p "$dst_dir"
# Remove any prior symlinked SKILL.md so the redirect below cannot write
# through it into the generated source.
rm -f "$dst_dir/SKILL.md"
sed "1,/^---\$/ s/^name:[[:space:]].*/name: $alias_name/" "$src_skill_md" > "$dst_dir/SKILL.md"
}
# Claude Code skips the repo-shaped ~/.claude/skills/gstack directory when
# building the user-facing slash-command list. Keep the repo path for runtime
# assets, and add a separate thin wrapper. Its frontmatter name is rewritten to
# `_gstack-command` (the dir name) so it never collides with the canonical
# `gstack` name (#2511).
link_claude_root_skill_alias() {
local gstack_dir="$1"
local skills_dir="$2"
local target="$skills_dir/_gstack-command"
[ -f "$gstack_dir/SKILL.md" ] || return 0
_install_alias_skill_md "$gstack_dir/SKILL.md" "$target" "_gstack-command"
echo " linked root skill alias: gstack"
}
# ─── Helper: remove old unprefixed Claude skill entries ───────────────────────
# Migration: when switching from flat names to gstack- prefixed names,
# clean up stale symlinks or directories that point into the gstack directory.
cleanup_old_claude_symlinks() {
local gstack_dir="$1"
local skills_dir="$2"
local removed=()
for skill_dir in "$gstack_dir"/*/; do
if [ -f "$skill_dir/SKILL.md" ]; then
skill_name="$(basename "$skill_dir")"
[ "$skill_name" = "node_modules" ] && continue
# Skip already-prefixed dirs (gstack-upgrade) — no old symlink to clean
case "$skill_name" in gstack-*) continue ;; esac
old_target="$skills_dir/$skill_name"
# Remove directory symlinks pointing into gstack/
if [ -L "$old_target" ]; then
link_dest="$(readlink "$old_target" 2>/dev/null || true)"
case "$link_dest" in
gstack/*|*/gstack/*)
rm -f "$old_target"
removed+=("$skill_name")
;;
esac
# Remove real directories with symlinked SKILL.md pointing into gstack/
elif [ -d "$old_target" ] && [ -L "$old_target/SKILL.md" ]; then
link_dest="$(readlink "$old_target/SKILL.md" 2>/dev/null || true)"
case "$link_dest" in
*gstack*)
rm -rf "$old_target"
removed+=("$skill_name")
;;
esac
# Windows install pattern: real dir with real-file SKILL.md (no symlink
# available, so we can't readlink to verify provenance). The outer loop
# iterates known gstack skill names from "$gstack_dir"/*, so a name match
# plus IS_WINDOWS is safe to treat as gstack-managed during a mode flip.
elif [ "$IS_WINDOWS" -eq 1 ] && [ -d "$old_target" ] && [ -f "$old_target/SKILL.md" ]; then
rm -rf "$old_target"
removed+=("$skill_name")
fi
fi
done
if [ ${#removed[@]} -gt 0 ]; then
echo " cleaned up old entries: ${removed[*]}"
fi
}
# ─── Helper: remove old prefixed Claude skill entries ─────────────────────────
# Reverse migration: when switching from gstack- prefixed names to flat names,
# clean up stale gstack-* symlinks or directories that point into the gstack directory.
cleanup_prefixed_claude_symlinks() {
local gstack_dir="$1"
local skills_dir="$2"
local removed=()
for skill_dir in "$gstack_dir"/*/; do
if [ -f "$skill_dir/SKILL.md" ]; then
skill_name="$(basename "$skill_dir")"
[ "$skill_name" = "node_modules" ] && continue
# Only clean up prefixed entries for dirs that AREN'T already prefixed
# (e.g., remove gstack-qa but NOT gstack-upgrade which is the real dir name)
case "$skill_name" in gstack-*) continue ;; esac
prefixed_target="$skills_dir/gstack-$skill_name"
# Remove directory symlinks pointing into gstack/
if [ -L "$prefixed_target" ]; then
link_dest="$(readlink "$prefixed_target" 2>/dev/null || true)"
case "$link_dest" in
gstack/*|*/gstack/*)
rm -f "$prefixed_target"
removed+=("gstack-$skill_name")
;;
esac
# Remove real directories with symlinked SKILL.md pointing into gstack/
elif [ -d "$prefixed_target" ] && [ -L "$prefixed_target/SKILL.md" ]; then
link_dest="$(readlink "$prefixed_target/SKILL.md" 2>/dev/null || true)"
case "$link_dest" in
*gstack*)
rm -rf "$prefixed_target"
removed+=("gstack-$skill_name")
;;
esac
# Windows install pattern: real dir with real-file SKILL.md. Same
# reasoning as cleanup_old_claude_symlinks — directory name match plus
# IS_WINDOWS is safe during a mode flip.
elif [ "$IS_WINDOWS" -eq 1 ] && [ -d "$prefixed_target" ] && [ -f "$prefixed_target/SKILL.md" ]; then
rm -rf "$prefixed_target"
removed+=("gstack-$skill_name")
fi
fi
done
if [ ${#removed[@]} -gt 0 ]; then
echo " cleaned up prefixed entries: ${removed[*]}"
fi
}
# ─── Helper: link generated Codex skills into a skills parent directory ──
# Installs from .agents/skills/gstack-* (the generated Codex-format skills)
# instead of source dirs (which have Claude paths).
link_codex_skill_dirs() {
local gstack_dir="$1"
local skills_dir="$2"
local agents_dir="$gstack_dir/.agents/skills"
local linked=()
if [ ! -d "$agents_dir" ]; then
echo " Generating .agents/ skill docs..."
( cd "$gstack_dir" && bun_cmd run gen:skill-docs --host codex --model "$CODEX_GENERATION_MODEL" )
fi
if [ ! -d "$agents_dir" ]; then
echo " warning: .agents/skills/ generation failed — run 'bun run gen:skill-docs --host codex --model $CODEX_GENERATION_MODEL' manually" >&2
return 1
fi
for skill_dir in "$agents_dir"/gstack*/; do
if [ -f "$skill_dir/SKILL.md" ]; then
skill_name="$(basename "$skill_dir")"
# Skip the sidecar directory — it contains runtime asset symlinks (bin/,
# browse/), not a skill. Linking it would overwrite the root gstack
# symlink that Step 5 already pointed at the repo root.
[ "$skill_name" = "gstack" ] && continue
target="$skills_dir/$skill_name"
# Create or update symlink
# #2444: on Windows the installed target is a REAL directory copy, so
# the symlink-or-missing guard skipped every re-run and SKILL.md never
# refreshed after `git pull`. IS_WINDOWS bypasses the guard —
# _link_or_copy rm -rf's the destination first, refreshing the copy.
# #2142: a real dir may only be replaced when it is provably ours
# (_owned_for_windows_refresh), never a user's own colliding dir.
if [ "$IS_WINDOWS" -eq 1 ] || [ -L "$target" ] || [ ! -e "$target" ]; then
if _owned_for_windows_refresh "$target"; then
_link_or_copy "$skill_dir" "$target"
linked+=("$skill_name")
else
echo " left in place (existing dir not gstack-managed — no generated banner): $target" >&2
fi
fi
fi
done
if [ ${#linked[@]} -gt 0 ]; then
echo " linked skills: ${linked[*]}"
fi
}
# ─── Helper: create .agents/skills/gstack/ sidecar symlinks ──────────
# Codex/Gemini/Cursor read skills from .agents/skills/. We link runtime
# assets (bin/, browse/dist/, review/, qa/, etc.) so skill templates can
# resolve paths like $SKILL_ROOT/review/design-checklist.md.
create_agents_sidecar() {
local repo_root="$1"
local agents_gstack="$repo_root/.agents/skills/gstack"
# #2142: a hand-written skill squatting on the canonical name is the
# user's — never write into it (the Windows branch would rm -rf its
# subdirs on every re-run).
if _sidecar_root_user_owned "$agents_gstack"; then
echo " left in place (existing dir not gstack-managed — no generated banner): $agents_gstack" >&2
return 0
fi
mkdir -p "$agents_gstack"
# Sidecar directories that skills reference at runtime. bin scripts import
# shared modules via ../lib, so bin and lib must always travel together.
for asset in bin lib browse review qa; do
local src="$SOURCE_GSTACK_DIR/$asset"
local dst="$agents_gstack/$asset"
if [ -d "$src" ] || [ -f "$src" ]; then
# #2444: IS_WINDOWS bypass — real-dir copies never match -L, so re-runs
# skipped the refresh. _link_or_copy rm -rf's the destination first.
if [ "$IS_WINDOWS" -eq 1 ] || [ -L "$dst" ] || [ ! -e "$dst" ]; then
_link_or_copy "$src" "$dst"
fi
fi
done
# Sidecar files that skills reference at runtime
for file in ETHOS.md; do
local src="$SOURCE_GSTACK_DIR/$file"
local dst="$agents_gstack/$file"
if [ -f "$src" ]; then
# #2444: IS_WINDOWS bypass — real-dir copies never match -L, so re-runs
# skipped the refresh. _link_or_copy rm -rf's the destination first.
if [ "$IS_WINDOWS" -eq 1 ] || [ -L "$dst" ] || [ ! -e "$dst" ]; then
_link_or_copy "$src" "$dst"
fi
fi
done
# 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
mkdir -p "$agents_gstack/supabase"
_link_or_copy "$SOURCE_GSTACK_DIR/supabase/config.sh" "$agents_gstack/supabase/config.sh"
fi
}
# ─── Helper: create a minimal ~/.codex/skills/gstack runtime root ───────────
# Codex scans ~/.codex/skills recursively. Exposing the whole repo here causes
# duplicate skills because source SKILL.md files and generated Codex skills are
# both discoverable. Keep this directory limited to runtime assets + root skill.
create_codex_runtime_root() {
local gstack_dir="$1"
local codex_gstack="$2"
local agents_dir="$gstack_dir/.agents/skills"
if [ -L "$codex_gstack" ]; then
rm -f "$codex_gstack"
elif [ -d "$codex_gstack" ] && [ "$codex_gstack" != "$gstack_dir" ]; then
# Old direct installs left a real directory here with stale source skills.
# Remove it so we start fresh with only the minimal runtime assets.
rm -rf "$codex_gstack"
fi
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"
fi
if [ -d "$gstack_dir/bin" ]; then
_link_or_copy "$gstack_dir/bin" "$codex_gstack/bin"
fi
if [ -d "$gstack_dir/lib" ]; then
_link_or_copy "$gstack_dir/lib" "$codex_gstack/lib"
fi
if [ -d "$gstack_dir/browse/dist" ]; then
_link_or_copy "$gstack_dir/browse/dist" "$codex_gstack/browse/dist"
fi
if [ -d "$gstack_dir/browse/bin" ]; then
_link_or_copy "$gstack_dir/browse/bin" "$codex_gstack/browse/bin"
fi
if [ -f "$agents_dir/gstack-upgrade/SKILL.md" ]; then
_link_or_copy "$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"
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
if [ -f "$gstack_dir/review/$f" ]; then
_link_or_copy "$gstack_dir/review/$f" "$codex_gstack/review/$f"
fi
done
# ETHOS.md — referenced by "Search Before Building" in all skill preambles
if [ -f "$gstack_dir/ETHOS.md" ]; then
_link_or_copy "$gstack_dir/ETHOS.md" "$codex_gstack/ETHOS.md"
fi
# supabase/config.sh — required by gstack-telemetry-sync to resolve GSTACK_SUPABASE_URL
if [ -f "$gstack_dir/supabase/config.sh" ]; then
mkdir -p "$codex_gstack/supabase"
_link_or_copy "$gstack_dir/supabase/config.sh" "$codex_gstack/supabase/config.sh"
fi
}
create_factory_runtime_root() {
local gstack_dir="$1"
local factory_gstack="$2"
local factory_dir="$gstack_dir/.factory/skills"
if [ -L "$factory_gstack" ]; then
rm -f "$factory_gstack"
elif [ -d "$factory_gstack" ] && [ "$factory_gstack" != "$gstack_dir" ]; then
rm -rf "$factory_gstack"
fi
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"
fi
if [ -d "$gstack_dir/bin" ]; then
_link_or_copy "$gstack_dir/bin" "$factory_gstack/bin"
fi
if [ -d "$gstack_dir/lib" ]; then
_link_or_copy "$gstack_dir/lib" "$factory_gstack/lib"
fi
if [ -d "$gstack_dir/browse/dist" ]; then
_link_or_copy "$gstack_dir/browse/dist" "$factory_gstack/browse/dist"
fi
if [ -d "$gstack_dir/browse/bin" ]; then
_link_or_copy "$gstack_dir/browse/bin" "$factory_gstack/browse/bin"
fi
if [ -f "$factory_dir/gstack-upgrade/SKILL.md" ]; then
_link_or_copy "$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"
fi
for f in checklist.md design-checklist.md greptile-triage.md TODOS-format.md; do
if [ -f "$gstack_dir/review/$f" ]; then
_link_or_copy "$gstack_dir/review/$f" "$factory_gstack/review/$f"
fi
done
if [ -f "$gstack_dir/ETHOS.md" ]; then
_link_or_copy "$gstack_dir/ETHOS.md" "$factory_gstack/ETHOS.md"
fi
# supabase/config.sh — required by gstack-telemetry-sync to resolve GSTACK_SUPABASE_URL
if [ -f "$gstack_dir/supabase/config.sh" ]; then
mkdir -p "$factory_gstack/supabase"
_link_or_copy "$gstack_dir/supabase/config.sh" "$factory_gstack/supabase/config.sh"
fi
}
create_opencode_runtime_root() {
local gstack_dir="$1"
local opencode_gstack="$2"
local opencode_dir="$gstack_dir/.opencode/skills"
if [ -L "$opencode_gstack" ]; then
rm -f "$opencode_gstack"
elif [ -d "$opencode_gstack" ] && [ "$opencode_gstack" != "$gstack_dir" ]; then
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"
if [ -f "$opencode_dir/gstack/SKILL.md" ]; then
_link_or_copy "$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"
fi
if [ -d "$gstack_dir/lib" ]; then
_link_or_copy "$gstack_dir/lib" "$opencode_gstack/lib"
fi
if [ -d "$gstack_dir/browse/dist" ]; then
_link_or_copy "$gstack_dir/browse/dist" "$opencode_gstack/browse/dist"
fi
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
if [ -f "$opencode_dir/gstack-upgrade/SKILL.md" ]; then
_link_or_copy "$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"
fi
for f in checklist.md design-checklist.md greptile-triage.md TODOS-format.md; do
if [ -f "$gstack_dir/review/$f" ]; then
_link_or_copy "$gstack_dir/review/$f" "$opencode_gstack/review/$f"
fi
done
if [ -d "$gstack_dir/review/specialists" ]; then
_link_or_copy "$gstack_dir/review/specialists" "$opencode_gstack/review/specialists"
fi
if [ -d "$gstack_dir/qa/templates" ]; then
_link_or_copy "$gstack_dir/qa/templates" "$opencode_gstack/qa/templates"
fi
if [ -d "$gstack_dir/qa/references" ]; then
_link_or_copy "$gstack_dir/qa/references" "$opencode_gstack/qa/references"
fi
if [ -f "$gstack_dir/plan-devex-review/dx-hall-of-fame.md" ]; then
_link_or_copy "$gstack_dir/plan-devex-review/dx-hall-of-fame.md" "$opencode_gstack/plan-devex-review/dx-hall-of-fame.md"
fi
if [ -f "$gstack_dir/ETHOS.md" ]; then
_link_or_copy "$gstack_dir/ETHOS.md" "$opencode_gstack/ETHOS.md"
fi
# supabase/config.sh — required by gstack-telemetry-sync to resolve GSTACK_SUPABASE_URL
if [ -f "$gstack_dir/supabase/config.sh" ]; then
mkdir -p "$opencode_gstack/supabase"
_link_or_copy "$gstack_dir/supabase/config.sh" "$opencode_gstack/supabase/config.sh"
fi
}
link_factory_skill_dirs() {
local gstack_dir="$1"
local skills_dir="$2"
local factory_dir="$gstack_dir/.factory/skills"
local linked=()
if [ ! -d "$factory_dir" ]; then
echo " Generating .factory/ skill docs..."
( cd "$gstack_dir" && bun_cmd run gen:skill-docs --host factory )
fi
if [ ! -d "$factory_dir" ]; then
echo " warning: .factory/skills/ generation failed — run 'bun run gen:skill-docs --host factory' manually" >&2
return 1
fi
for skill_dir in "$factory_dir"/gstack*/; do
if [ -f "$skill_dir/SKILL.md" ]; then
skill_name="$(basename "$skill_dir")"
[ "$skill_name" = "gstack" ] && continue
target="$skills_dir/$skill_name"
# #2444: on Windows the installed target is a REAL directory copy, so
# the symlink-or-missing guard skipped every re-run and SKILL.md never
# refreshed after `git pull`. IS_WINDOWS bypasses the guard —
# _link_or_copy rm -rf's the destination first, refreshing the copy.
# #2142: a real dir may only be replaced when it is provably ours
# (_owned_for_windows_refresh), never a user's own colliding dir.
if [ "$IS_WINDOWS" -eq 1 ] || [ -L "$target" ] || [ ! -e "$target" ]; then
if _owned_for_windows_refresh "$target"; then
_link_or_copy "$skill_dir" "$target"
linked+=("$skill_name")
else
echo " left in place (existing dir not gstack-managed — no generated banner): $target" >&2
fi
fi
fi
done
if [ ${#linked[@]} -gt 0 ]; then
echo " linked skills: ${linked[*]}"
fi
}
link_opencode_skill_dirs() {
local gstack_dir="$1"
local skills_dir="$2"
local opencode_dir="$gstack_dir/.opencode/skills"
local linked=()
if [ ! -d "$opencode_dir" ]; then
echo " Generating .opencode/ skill docs..."
( cd "$gstack_dir" && bun_cmd run gen:skill-docs --host opencode )
fi
if [ ! -d "$opencode_dir" ]; then
echo " warning: .opencode/skills/ generation failed — run 'bun run gen:skill-docs --host opencode' manually" >&2
return 1
fi
for skill_dir in "$opencode_dir"/gstack*/; do
if [ -f "$skill_dir/SKILL.md" ]; then
skill_name="$(basename "$skill_dir")"
[ "$skill_name" = "gstack" ] && continue
target="$skills_dir/$skill_name"
# #2444: on Windows the installed target is a REAL directory copy, so
# the symlink-or-missing guard skipped every re-run and SKILL.md never
# refreshed after `git pull`. IS_WINDOWS bypasses the guard —
# _link_or_copy rm -rf's the destination first, refreshing the copy.
# #2142: a real dir may only be replaced when it is provably ours
# (_owned_for_windows_refresh), never a user's own colliding dir.
if [ "$IS_WINDOWS" -eq 1 ] || [ -L "$target" ] || [ ! -e "$target" ]; then
if _owned_for_windows_refresh "$target"; then
_link_or_copy "$skill_dir" "$target"
linked+=("$skill_name")
else
echo " left in place (existing dir not gstack-managed — no generated banner): $target" >&2
fi
fi
fi
done
if [ ${#linked[@]} -gt 0 ]; then
echo " linked skills: ${linked[*]}"
fi
}
# ─── Helper: create a minimal ~/.cursor/skills/gstack runtime root ──────────
# Cursor scans ~/.cursor/skills. Same shape as the Codex/Factory/OpenCode
# runtime roots: root SKILL.md from the generated tree + runtime assets only.
# Contributed by @szsunyuan (PR #2547), re-derived onto the current installers.
create_cursor_runtime_root() {
local gstack_dir="$1"
local cursor_gstack="$2"
local cursor_dir="$gstack_dir/.cursor/skills"
local generated_root="$cursor_dir/gstack"
if [ -L "$cursor_gstack" ]; then
rm -f "$cursor_gstack"
elif _sidecar_root_user_owned "$cursor_gstack"; then
# #2142: a hand-written skill squatting on the canonical name is the
# user's — never wipe it to make room for the runtime root.
echo " left in place (existing dir not gstack-managed — no generated banner): $cursor_gstack" >&2
return 0
elif [ -d "$cursor_gstack" ] && [ "$cursor_gstack" != "$gstack_dir" ] && [ "$cursor_gstack" != "$generated_root" ]; then
rm -rf "$cursor_gstack"
fi
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"
fi
# bin scripts import shared modules via ../lib — bin and lib travel together.
if [ -d "$gstack_dir/bin" ]; then
_link_or_copy "$gstack_dir/bin" "$cursor_gstack/bin"
fi
if [ -d "$gstack_dir/lib" ]; then
_link_or_copy "$gstack_dir/lib" "$cursor_gstack/lib"
fi
if [ -d "$gstack_dir/browse/dist" ]; then
_link_or_copy "$gstack_dir/browse/dist" "$cursor_gstack/browse/dist"
fi
if [ -d "$gstack_dir/browse/bin" ]; then
_link_or_copy "$gstack_dir/browse/bin" "$cursor_gstack/browse/bin"
fi
if [ -f "$cursor_dir/gstack-upgrade/SKILL.md" ]; then
_link_or_copy "$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
if [ -f "$gstack_dir/review/$f" ]; then
_link_or_copy "$gstack_dir/review/$f" "$cursor_gstack/review/$f"
fi
done
if [ -f "$gstack_dir/ETHOS.md" ]; then
_link_or_copy "$gstack_dir/ETHOS.md" "$cursor_gstack/ETHOS.md"
fi
# supabase/config.sh — required by gstack-telemetry-sync to resolve GSTACK_SUPABASE_URL
if [ -f "$gstack_dir/supabase/config.sh" ]; then
mkdir -p "$cursor_gstack/supabase"
_link_or_copy "$gstack_dir/supabase/config.sh" "$cursor_gstack/supabase/config.sh"
fi
}
# Plant runtime assets into the repo-local generated skill dir so in-repo
# GSTACK_ROOT (preamble prefers $_ROOT/.cursor/skills/gstack) has bin/.
# NEVER wipe this directory — it holds the generated SKILL.md files.
create_cursor_sidecar() {
local repo_root="$1"
local cursor_gstack="$repo_root/.cursor/skills/gstack"
local cursor_dir="$repo_root/.cursor/skills"
# #2142: same user-ownership gate as create_agents_sidecar — but the
# generated tree's own root (cursor_dir/gstack carries the banner) always
# passes, so normal installs refresh as before.
if _sidecar_root_user_owned "$cursor_gstack"; then
echo " left in place (existing dir not gstack-managed — no generated banner): $cursor_gstack" >&2
return 0
fi
mkdir -p "$cursor_gstack" "$cursor_gstack/browse" "$cursor_gstack/gstack-upgrade" "$cursor_gstack/review"
if [ -d "$repo_root/bin" ]; then
if [ "$IS_WINDOWS" -eq 1 ] || [ -L "$cursor_gstack/bin" ] || [ ! -e "$cursor_gstack/bin" ]; then
_link_or_copy "$repo_root/bin" "$cursor_gstack/bin"
fi
fi
if [ -d "$repo_root/lib" ]; then
if [ "$IS_WINDOWS" -eq 1 ] || [ -L "$cursor_gstack/lib" ] || [ ! -e "$cursor_gstack/lib" ]; then
_link_or_copy "$repo_root/lib" "$cursor_gstack/lib"
fi
fi
if [ -d "$repo_root/browse/dist" ]; then
if [ "$IS_WINDOWS" -eq 1 ] || [ -L "$cursor_gstack/browse/dist" ] || [ ! -e "$cursor_gstack/browse/dist" ]; then
_link_or_copy "$repo_root/browse/dist" "$cursor_gstack/browse/dist"
fi
fi
if [ -d "$repo_root/browse/bin" ]; then
if [ "$IS_WINDOWS" -eq 1 ] || [ -L "$cursor_gstack/browse/bin" ] || [ ! -e "$cursor_gstack/browse/bin" ]; then
_link_or_copy "$repo_root/browse/bin" "$cursor_gstack/browse/bin"
fi
fi
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"
fi
fi
for f in checklist.md TODOS-format.md; do
if [ -f "$repo_root/review/$f" ]; then
if [ "$IS_WINDOWS" -eq 1 ] || [ -L "$cursor_gstack/review/$f" ] || [ ! -e "$cursor_gstack/review/$f" ]; then
_link_or_copy "$repo_root/review/$f" "$cursor_gstack/review/$f"
fi
fi
done
if [ -f "$repo_root/ETHOS.md" ]; then
if [ "$IS_WINDOWS" -eq 1 ] || [ -L "$cursor_gstack/ETHOS.md" ] || [ ! -e "$cursor_gstack/ETHOS.md" ]; then
_link_or_copy "$repo_root/ETHOS.md" "$cursor_gstack/ETHOS.md"
fi
fi
}
link_cursor_skill_dirs() {
local gstack_dir="$1"
local skills_dir="$2"
local cursor_dir="$gstack_dir/.cursor/skills"
local linked=()
if [ ! -d "$cursor_dir" ]; then
echo " Generating .cursor/ skill docs..."
( cd "$gstack_dir" && bun_cmd run gen:skill-docs --host cursor )
fi
if [ ! -d "$cursor_dir" ]; then
echo " warning: .cursor/skills/ generation failed — run 'bun run gen:skill-docs --host cursor' manually" >&2
return 1
fi
for skill_dir in "$cursor_dir"/gstack*/; do
if [ -f "$skill_dir/SKILL.md" ]; then
skill_name="$(basename "$skill_dir")"
[ "$skill_name" = "gstack" ] && continue
target="$skills_dir/$skill_name"
# #2444: IS_WINDOWS bypass — real-dir copies never match -L, so re-runs
# skipped the refresh. Only replace a symlink, a missing path, or a
# PROVABLY gstack-managed real dir; never a user's own Cursor skill
# dir that merely starts with gstack (#2142).
if [ "$IS_WINDOWS" -eq 1 ] || [ -L "$target" ] || [ ! -e "$target" ]; then
if _owned_for_windows_refresh "$target"; then
_link_or_copy "$skill_dir" "$target"
linked+=("$skill_name")
else
echo " left in place (existing dir not gstack-managed — no generated banner): $target" >&2
fi
fi
fi
done
if [ ${#linked[@]} -gt 0 ]; then
echo " linked skills: ${linked[*]}"
fi
}
# 4. Install for Claude (default)
SKILLS_BASENAME="$(basename "$INSTALL_SKILLS_DIR")"
SKILLS_PARENT_BASENAME="$(basename "$(dirname "$INSTALL_SKILLS_DIR")")"
CODEX_REPO_LOCAL=0
if [ "$SKILLS_BASENAME" = "skills" ] && [ "$SKILLS_PARENT_BASENAME" = ".agents" ]; then
CODEX_REPO_LOCAL=1
fi
if [ "$INSTALL_CLAUDE" -eq 1 ]; then
if [ "$SKILLS_BASENAME" = "skills" ]; then
# Clean up stale symlinks from the opposite prefix mode
if [ "$SKILL_PREFIX" -eq 1 ]; then
cleanup_old_claude_symlinks "$SOURCE_GSTACK_DIR" "$INSTALL_SKILLS_DIR"
else
cleanup_prefixed_claude_symlinks "$SOURCE_GSTACK_DIR" "$INSTALL_SKILLS_DIR"
fi
# Patch name: fields BEFORE creating symlinks so link_claude_skill_dirs
# reads the correct (patched) name: values for symlink naming
"$SOURCE_GSTACK_DIR/bin/gstack-patch-names" "$SOURCE_GSTACK_DIR" "$SKILL_PREFIX"
link_claude_skill_dirs "$SOURCE_GSTACK_DIR" "$INSTALL_SKILLS_DIR"
link_claude_root_skill_alias "$SOURCE_GSTACK_DIR" "$INSTALL_SKILLS_DIR"
_CLAUDE_SKILLS_LINKED=1
# Self-healing: re-run gstack-relink to ensure name: fields and directory
# names are consistent with the config. This catches cases where an interrupted
# setup, stale git state, or gen:skill-docs left name: fields out of sync.
GSTACK_RELINK="$SOURCE_GSTACK_DIR/bin/gstack-relink"
if [ -x "$GSTACK_RELINK" ]; then
GSTACK_SKILLS_DIR="$INSTALL_SKILLS_DIR" GSTACK_INSTALL_DIR="$SOURCE_GSTACK_DIR" "$GSTACK_RELINK" >/dev/null 2>&1 || true
fi
# Backwards-compat alias: /connect-chrome → /open-gstack-browser
# Rewritten copy, not a symlink: a symlinked alias re-serves the canonical
# name: open-gstack-browser, so one of the two silently shadows the other
# (#2201) — and duplicate names can drop the whole skill set (#2511).
_OGB_LINK="$INSTALL_SKILLS_DIR/connect-chrome"
_OGB_ALIAS_NAME="connect-chrome"
if [ "$SKILL_PREFIX" -eq 1 ]; then
_OGB_LINK="$INSTALL_SKILLS_DIR/gstack-connect-chrome"
_OGB_ALIAS_NAME="gstack-connect-chrome"
fi
_install_alias_skill_md "$SOURCE_GSTACK_DIR/open-gstack-browser/SKILL.md" "$_OGB_LINK" "$_OGB_ALIAS_NAME"
if [ "$LOCAL_INSTALL" -eq 1 ]; then
log "gstack ready (project-local)."
log " skills: $INSTALL_SKILLS_DIR"
else
log "gstack ready (claude)."
fi
log " browse: $BROWSE_BIN"
else
# Not inside a skills/ directory — would symlink the source into
# ~/.claude/skills/gstack/ and register from there.
CLAUDE_SKILLS_DIR="$HOME/.claude/skills"
CLAUDE_GSTACK_LINK="$CLAUDE_SKILLS_DIR/gstack"
# Conductor worktree guard: if ~/.claude/skills/gstack is already a real
# (non-symlink) directory pointing to a *different* install, refuse to plant
# a symlink there. On macOS/BSD, `ln -snf SRC DST` won't replace a real DST;
# it creates DST/$(basename SRC) → SRC inside it. The result is per-worktree
# symlinks leaking into the global install that Claude Code picks up as
# separate top-level skills (dublin-v1, lincoln-v2, ...). Typical trigger:
# running ./setup from a Conductor worktree of the gstack repo itself.
_SKIP_CLAUDE_REGISTER=0
if [ -d "$CLAUDE_GSTACK_LINK" ] && [ ! -L "$CLAUDE_GSTACK_LINK" ]; then
_EXISTING_REAL=$(cd "$CLAUDE_GSTACK_LINK" 2>/dev/null && pwd -P || echo "")
if [ -n "$_EXISTING_REAL" ] && [ "$_EXISTING_REAL" != "$SOURCE_GSTACK_DIR" ]; then
_SKIP_CLAUDE_REGISTER=1
fi
fi
if [ "$_SKIP_CLAUDE_REGISTER" -eq 1 ]; then
log ""
log " $CLAUDE_GSTACK_LINK already exists as a separate global install."
log " Skipping Claude skill registration to avoid polluting it with"
log " per-worktree symlinks. (Binaries still built locally for dev.)"
log ""
log " Global install: $CLAUDE_GSTACK_LINK"
log " This worktree: $SOURCE_GSTACK_DIR"
log ""
log " To register this worktree as the active gstack, remove the global"
log " install first: rm -rf $CLAUDE_GSTACK_LINK"
log ""
log "gstack built (claude registration skipped)."
log " browse: $BROWSE_BIN"
else
mkdir -p "$CLAUDE_SKILLS_DIR"
_link_or_copy "$SOURCE_GSTACK_DIR" "$CLAUDE_GSTACK_LINK"
log " symlinked $CLAUDE_GSTACK_LINK -> $SOURCE_GSTACK_DIR"
INSTALL_SKILLS_DIR="$CLAUDE_SKILLS_DIR"
INSTALL_GSTACK_DIR="$CLAUDE_GSTACK_LINK"
# Clean up stale symlinks from the opposite prefix mode
if [ "$SKILL_PREFIX" -eq 1 ]; then
cleanup_old_claude_symlinks "$SOURCE_GSTACK_DIR" "$INSTALL_SKILLS_DIR"
else
cleanup_prefixed_claude_symlinks "$SOURCE_GSTACK_DIR" "$INSTALL_SKILLS_DIR"
fi
"$SOURCE_GSTACK_DIR/bin/gstack-patch-names" "$SOURCE_GSTACK_DIR" "$SKILL_PREFIX"
link_claude_skill_dirs "$SOURCE_GSTACK_DIR" "$INSTALL_SKILLS_DIR"
link_claude_root_skill_alias "$SOURCE_GSTACK_DIR" "$INSTALL_SKILLS_DIR"
_CLAUDE_SKILLS_LINKED=1
GSTACK_RELINK="$SOURCE_GSTACK_DIR/bin/gstack-relink"
if [ -x "$GSTACK_RELINK" ]; then
GSTACK_SKILLS_DIR="$INSTALL_SKILLS_DIR" GSTACK_INSTALL_DIR="$SOURCE_GSTACK_DIR" "$GSTACK_RELINK" >/dev/null 2>&1 || true
fi
# Rewritten copy, not a symlink: a symlinked alias re-serves the
# canonical name: open-gstack-browser, so one of the two silently
# shadows the other (#2201) — and duplicate names can drop the whole
# skill set (#2511).
_OGB_LINK="$INSTALL_SKILLS_DIR/connect-chrome"
_OGB_ALIAS_NAME="connect-chrome"
if [ "$SKILL_PREFIX" -eq 1 ]; then
_OGB_LINK="$INSTALL_SKILLS_DIR/gstack-connect-chrome"
_OGB_ALIAS_NAME="gstack-connect-chrome"
fi
_install_alias_skill_md "$SOURCE_GSTACK_DIR/open-gstack-browser/SKILL.md" "$_OGB_LINK" "$_OGB_ALIAS_NAME"
log "gstack ready (claude)."
log " browse: $BROWSE_BIN"
fi
fi
fi
# 5. Install for Codex
if [ "$INSTALL_CODEX" -eq 1 ]; then
if [ "$CODEX_REPO_LOCAL" -eq 1 ]; then
CODEX_SKILLS="$INSTALL_SKILLS_DIR"
CODEX_GSTACK="$INSTALL_GSTACK_DIR"
fi
mkdir -p "$CODEX_SKILLS"
# Skip runtime root creation for repo-local installs — the checkout IS the runtime root.
# create_codex_runtime_root would create self-referential symlinks (bin → bin, etc.).
if [ "$CODEX_REPO_LOCAL" -eq 0 ]; then
create_codex_runtime_root "$SOURCE_GSTACK_DIR" "$CODEX_GSTACK"
fi
# Install generated Codex-format skills (not Claude source dirs)
link_codex_skill_dirs "$SOURCE_GSTACK_DIR" "$CODEX_SKILLS"
log "gstack ready (codex)."
log " browse: $BROWSE_BIN"
log " codex skills: $CODEX_SKILLS"
log " model profile: $CODEX_GENERATION_MODEL ($CODEX_GENERATION_MODEL_SOURCE)"
log " model changes: rerun ./setup --host codex"
if [ "$MODEL_OVERRIDE_SET" -eq 1 ]; then
log " note: --model applies to this run only. To persist across upgrades,"
log " set model = \"$MODEL_OVERRIDE\" in \${CODEX_HOME:-~/.codex}/config.toml."
fi
fi
# 6. Install for Kiro CLI (copy from .agents/skills, rewrite paths)
if [ "$INSTALL_KIRO" -eq 1 ]; then
KIRO_SKILLS="$HOME/.kiro/skills"
AGENTS_DIR="$SOURCE_GSTACK_DIR/.agents/skills"
mkdir -p "$KIRO_SKILLS"
# Kiro builds from the codex-shaped render but fronts Claude-family models
# (hosts/kiro.ts defaultModel: 'claude'). Re-render with the claude overlay
# before copying so Kiro skills never ship the GPT/Sol behavioral patch;
# the resolved Codex profile is restored right after the copy loop.
if [ "$CODEX_GENERATION_MODEL" != "claude" ]; then
log "Rendering claude-profile skills for Kiro..."
( cd "$SOURCE_GSTACK_DIR" && bun_cmd run gen:skill-docs --host codex --model claude )
fi
# Create gstack dir with symlinks for runtime assets, copy+sed for SKILL.md
KIRO_GSTACK="$KIRO_SKILLS/gstack"
# Remove old whole-dir symlink from previous installs
[ -L "$KIRO_GSTACK" ] && rm -f "$KIRO_GSTACK"
mkdir -p "$KIRO_GSTACK" "$KIRO_GSTACK/browse" "$KIRO_GSTACK/gstack-upgrade" "$KIRO_GSTACK/review"
_link_or_copy "$SOURCE_GSTACK_DIR/bin" "$KIRO_GSTACK/bin"
_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"
# ETHOS.md — referenced by "Search Before Building" in all skill preambles
if [ -f "$SOURCE_GSTACK_DIR/ETHOS.md" ]; then
_link_or_copy "$SOURCE_GSTACK_DIR/ETHOS.md" "$KIRO_GSTACK/ETHOS.md"
fi
# supabase/config.sh — required by gstack-telemetry-sync to resolve GSTACK_SUPABASE_URL
if [ -f "$SOURCE_GSTACK_DIR/supabase/config.sh" ]; then
mkdir -p "$KIRO_GSTACK/supabase"
_link_or_copy "$SOURCE_GSTACK_DIR/supabase/config.sh" "$KIRO_GSTACK/supabase/config.sh"
fi
# gstack-upgrade skill — sed COPY, never a symlink: a symlink would track
# .agents after the Codex-profile restore below (wrong overlay AND a baked
# './setup --host codex' that reinstalls the wrong host on /gstack-upgrade).
if [ -f "$AGENTS_DIR/gstack-upgrade/SKILL.md" ]; then
sed -e 's|\$HOME/.codex/skills/gstack|$HOME/.kiro/skills/gstack|g' \
-e "s|~/.codex/skills/gstack|~/.kiro/skills/gstack|g" \
-e "s|~/.claude/skills/gstack|~/.kiro/skills/gstack|g" \
-e 's|\./setup --host codex|./setup --host kiro|g' \
"$AGENTS_DIR/gstack-upgrade/SKILL.md" > "$KIRO_GSTACK/gstack-upgrade/SKILL.md"
fi
# Review runtime assets (individual files, not whole dir)
for f in checklist.md design-checklist.md greptile-triage.md TODOS-format.md; do
if [ -f "$SOURCE_GSTACK_DIR/review/$f" ]; then
_link_or_copy "$SOURCE_GSTACK_DIR/review/$f" "$KIRO_GSTACK/review/$f"
fi
done
# Rewrite root SKILL.md paths for Kiro
sed -e "s|~/.claude/skills/gstack|~/.kiro/skills/gstack|g" \
-e "s|\.claude/skills/gstack|.kiro/skills/gstack|g" \
-e "s|\.claude/skills|.kiro/skills|g" \
"$SOURCE_GSTACK_DIR/SKILL.md" > "$KIRO_GSTACK/SKILL.md"
if [ ! -d "$AGENTS_DIR" ]; then
echo " warning: no .agents/skills/ directory found — run 'bun run build' first" >&2
else
for skill_dir in "$AGENTS_DIR"/gstack*/; do
[ -f "$skill_dir/SKILL.md" ] || continue
skill_name="$(basename "$skill_dir")"
target_dir="$KIRO_SKILLS/$skill_name"
mkdir -p "$target_dir"
# Generated Codex skills use $HOME/.codex (not ~/), plus $GSTACK_ROOT variables.
# Rewrite the default GSTACK_ROOT value, any remaining literal paths, and
# the SETUP_COMMAND host (the artifact was rendered for codex).
sed -e 's|\$HOME/.codex/skills/gstack|$HOME/.kiro/skills/gstack|g' \
-e "s|~/.codex/skills/gstack|~/.kiro/skills/gstack|g" \
-e "s|~/.claude/skills/gstack|~/.kiro/skills/gstack|g" \
-e 's|\./setup --host codex|./setup --host kiro|g' \
"$skill_dir/SKILL.md" > "$target_dir/SKILL.md"
# Carved skills (v2 plan T9): rewrite + copy each sections/*.md the same way,
# so a runtime "Read sections/<name>.md" resolves under ~/.kiro and doesn't
# leak a ~/.codex or ~/.claude path. Kiro builds from the codex output, so
# these section files only exist for skills that have been carved.
if [ -d "$skill_dir/sections" ]; then
mkdir -p "$target_dir/sections"
for section_file in "$skill_dir/sections"/*; do
[ -f "$section_file" ] || continue
sed -e 's|\$HOME/.codex/skills/gstack|$HOME/.kiro/skills/gstack|g' \
-e "s|~/.codex/skills/gstack|~/.kiro/skills/gstack|g" \
-e "s|~/.claude/skills/gstack|~/.kiro/skills/gstack|g" \
-e 's|\./setup --host codex|./setup --host kiro|g' \
"$section_file" > "$target_dir/sections/$(basename "$section_file")"
done
fi
done
echo "gstack ready (kiro)."
echo " browse: $BROWSE_BIN"
echo " kiro skills: $KIRO_SKILLS"
fi
# Restore the resolved Codex profile — ~/.codex/skills symlinks point into
# .agents/skills, so the tree must not stay on the Kiro claude render.
if [ "$CODEX_GENERATION_MODEL" != "claude" ]; then
( cd "$SOURCE_GSTACK_DIR" && bun_cmd run gen:skill-docs --host codex --model "$CODEX_GENERATION_MODEL" )
fi
fi
# 6b. Install for Factory Droid
if [ "$INSTALL_FACTORY" -eq 1 ]; then
mkdir -p "$FACTORY_SKILLS"
create_factory_runtime_root "$SOURCE_GSTACK_DIR" "$FACTORY_GSTACK"
link_factory_skill_dirs "$SOURCE_GSTACK_DIR" "$FACTORY_SKILLS"
echo "gstack ready (factory)."
echo " browse: $BROWSE_BIN"
echo " factory skills: $FACTORY_SKILLS"
fi
# 6c. Install for OpenCode
if [ "$INSTALL_OPENCODE" -eq 1 ]; then
mkdir -p "$OPENCODE_SKILLS"
create_opencode_runtime_root "$SOURCE_GSTACK_DIR" "$OPENCODE_GSTACK"
link_opencode_skill_dirs "$SOURCE_GSTACK_DIR" "$OPENCODE_SKILLS"
echo "gstack ready (opencode)."
echo " browse: $BROWSE_BIN"
echo " opencode skills: $OPENCODE_SKILLS"
fi
# 6d. Install for Cursor
if [ "$INSTALL_CURSOR" -eq 1 ]; then
mkdir -p "$CURSOR_SKILLS"
create_cursor_runtime_root "$SOURCE_GSTACK_DIR" "$CURSOR_GSTACK"
# Link before sidecar. Sidecar mkdir -p creates .cursor/skills/gstack, which
# would make link_cursor_skill_dirs' "[ ! -d generated ]" gen fallback a no-op.
link_cursor_skill_dirs "$SOURCE_GSTACK_DIR" "$CURSOR_SKILLS"
create_cursor_sidecar "$SOURCE_GSTACK_DIR"
echo "gstack ready (cursor)."
echo " browse: $BROWSE_BIN"
echo " cursor skills: $CURSOR_SKILLS"
fi
# 7. Create .agents/ sidecar symlinks for the real Codex skill target.
# The root Codex skill ends up pointing at $SOURCE_GSTACK_DIR/.agents/skills/gstack,
# so the runtime assets must live there for both global and repo-local installs.
if [ "$INSTALL_CODEX" -eq 1 ]; then
create_agents_sidecar "$SOURCE_GSTACK_DIR"
fi
# 8. Run pending version migrations
# Migrations handle state fixes that ./setup alone can't cover (stale config,
# orphaned files, directory structure changes). Each migration is idempotent.
MIGRATIONS_DIR="$SOURCE_GSTACK_DIR/gstack-upgrade/migrations"
CURRENT_VERSION=$(cat "$SOURCE_GSTACK_DIR/VERSION" 2>/dev/null || echo "unknown")
LAST_SETUP_VERSION=$(cat "$HOME/.gstack/.last-setup-version" 2>/dev/null || echo "0.0.0.0")
if [ -d "$MIGRATIONS_DIR" ] && [ "$CURRENT_VERSION" != "unknown" ] && [ "$LAST_SETUP_VERSION" != "$CURRENT_VERSION" ]; then
# Fresh install (no marker file) — skip migrations, just write marker
if [ ! -f "$HOME/.gstack/.last-setup-version" ]; then
: # fall through to marker write below
else
find "$MIGRATIONS_DIR" -maxdepth 1 -name 'v*.sh' -type f 2>/dev/null | sort -V | while IFS= read -r migration; do
m_ver="$(basename "$migration" .sh | sed 's/^v//')"
# Run if migration is newer than last setup version AND not newer than current version
if [ "$(printf '%s\n%s' "$LAST_SETUP_VERSION" "$m_ver" | sort -V | head -1)" = "$LAST_SETUP_VERSION" ] && [ "$LAST_SETUP_VERSION" != "$m_ver" ] \
&& [ "$(printf '%s\n%s' "$m_ver" "$CURRENT_VERSION" | sort -V | tail -1)" = "$CURRENT_VERSION" ]; then
echo " running migration $m_ver..."
# GSTACK_INSTALL_DIR: migrations that clean the INSTALL (not just
# ~/.gstack state) default to ~/.claude/skills/gstack when unset —
# a repo-local ./setup would silently no-op them against the wrong
# tree without this.
GSTACK_INSTALL_DIR="$SOURCE_GSTACK_DIR" bash "$migration" || echo " warning: migration $m_ver had errors (non-fatal)"
fi
done
fi
fi
mkdir -p "$HOME/.gstack"
if [ "$CURRENT_VERSION" != "unknown" ]; then
echo "$CURRENT_VERSION" > "$HOME/.gstack/.last-setup-version"
fi
# 9. First-time welcome + legacy cleanup
if [ ! -f "$HOME/.gstack/.welcome-seen" ]; then
log ""
log " gstack is ready. First move:"
log " New idea / empty repo? /office-hours or /spec"
log " Existing code? /qa to see it work, or /investigate"
log " (Run /gstack-upgrade anytime to stay current)"
log ""
# Best-effort onboarding telemetry (respects telemetry!=off; never blocks setup).
if [ -x "$SOURCE_GSTACK_DIR/bin/gstack-telemetry-log" ]; then
"$SOURCE_GSTACK_DIR/bin/gstack-telemetry-log" --event-type onboarding --skill _setup_welcome --outcome shown >/dev/null 2>&1 || true
fi
touch "$HOME/.gstack/.welcome-seen"
fi
rm -f /tmp/gstack-latest-version
# 10. Team mode: register/unregister SessionStart hook
SETTINGS_HOOK="$SOURCE_GSTACK_DIR/bin/gstack-settings-hook"
# On Windows (Git Bash / MSYS2 / Cygwin), extensionless scripts can't be
# launched directly by the OS — the file-association dialog appears instead.
# Prefix with 'bash' so Claude Code's hook runner invokes Git Bash explicitly.
if [ "$IS_WINDOWS" -eq 1 ]; then
HOOK_CMD="bash $SOURCE_GSTACK_DIR/bin/gstack-session-update"
else
HOOK_CMD="$SOURCE_GSTACK_DIR/bin/gstack-session-update"
fi
if [ "$TEAM_MODE" -eq 1 ]; then
"$GSTACK_CONFIG" set auto_upgrade true 2>/dev/null || true
"$GSTACK_CONFIG" set team_mode true 2>/dev/null || true
# Register SessionStart hook in Claude Code settings
if [ -x "$SETTINGS_HOOK" ]; then
"$SETTINGS_HOOK" add "$HOOK_CMD" 2>/dev/null || true
fi
log ""
log "Team mode enabled: gstack will auto-update at the start of each Claude Code session."
log " Hook: $HOOK_CMD"
log " To disable: ./setup --no-team"
log ""
log "Bootstrap your repo:"
log " cd <your-repo> && $SOURCE_GSTACK_DIR/bin/gstack-team-init required"
fi
if [ "$NO_TEAM_MODE" -eq 1 ]; then
"$GSTACK_CONFIG" set auto_upgrade false 2>/dev/null || true
"$GSTACK_CONFIG" set team_mode false 2>/dev/null || true
# Remove SessionStart hook from Claude Code settings
if [ -x "$SETTINGS_HOOK" ]; then
"$SETTINGS_HOOK" remove "$HOOK_CMD" 2>/dev/null || true
fi
log "Team mode disabled: auto-update hook removed."
fi
# ─── GBrain detection + conditional SKILL.md render ─────────────────────
#
# Detect whether gbrain is installed and persist the result to
# ~/.gstack/gbrain-detection.json so gen-skill-docs can decide whether to
# render GBRAIN_CONTEXT_LOAD and GBRAIN_SAVE_RESULTS blocks. If detected,
# render the Claude-host :user variant (un-suppressed brain-aware blocks)
# into an UNTRACKED out-dir — ${GSTACK_HOME}/render/claude — and repoint the
# installed skills at it (#2569). The old in-place render wrote into TRACKED
# files of the install checkout, so a global-git install stayed permanently
# dirty and every upgrade stashed 16 files of generated dirt.
#
# If gbrain is not detected, the canonical no-gbrain SKILL.md files stay
# as-is (zero token overhead) and any stale render dir is removed so it
# can't shadow canonical files on the next relink.
#
# Users who install gbrain after running ./setup should re-run setup OR
# call `gstack-config gbrain-refresh`.
DETECT_BIN="$SOURCE_GSTACK_DIR/bin/gstack-gbrain-detect"
GBRAIN_STATE_DIR="${GSTACK_HOME:-$HOME/.gstack}"
DETECTION_FILE="$GBRAIN_STATE_DIR/gbrain-detection.json"
_GSTACK_RENDER_DIR="${GSTACK_USER_RENDER_DIR:-$GBRAIN_STATE_DIR/render/claude}"
# PID-unique tmp so concurrent setups (parallel Conductor workspaces) can't
# clobber each other's in-flight detection write.
DETECTION_TMP="$DETECTION_FILE.$$.tmp"
mkdir -p "$GBRAIN_STATE_DIR"
if [ -x "$DETECT_BIN" ]; then
if "$DETECT_BIN" > "$DETECTION_TMP" 2>/dev/null; then
mv "$DETECTION_TMP" "$DETECTION_FILE"
# Single source of truth for "is gbrain usable" — `--is-ok` runs live
# detection (exit 0 iff ok), so setup, bin/dev-setup, and gstack-config
# all gate on the same check instead of re-grepping the JSON.
if "$DETECT_BIN" --is-ok 2>/dev/null; then
if [ -n "${GSTACK_SKIP_GBRAIN_REGEN:-}" ]; then
# Dev/source tree (set by bin/dev-setup): detection is persisted
# above; the dev workspace renders the :user variant into its own
# untracked dir (.claude/gstack-rendered), and other projects get
# blocks via `gstack-config gbrain-refresh`.
log "gbrain detected — GSTACK_SKIP_GBRAIN_REGEN set: leaving tracked SKILL.md canonical (dev/source tree)."
else
log "gbrain detected — rendering brain-aware Claude SKILL.md into $_GSTACK_RENDER_DIR (~250 token overhead per planning skill; source checkout stays clean)..."
# Render into a tmp dir and swap it in only on SUCCESS. Installed
# skills SYMLINK into the render dir (relink prefers it), so wiping
# it before the render meant one transient failure left every
# brain-aware SKILL.md link dangling — the whole skill set vanished
# from Claude Code until a successful re-render.
_GSTACK_RENDER_TMP="$_GSTACK_RENDER_DIR.tmp.$$"
rm -rf "$_GSTACK_RENDER_TMP"
if (
cd "$SOURCE_GSTACK_DIR"
# No pipe before the || guard: `cmd | tail -3` reports TAIL's exit
# status, so a generator crash read as success (same masking the
# main gen:skill-docs site had). Capture, show the tail, propagate.
_GEN_USER_OUT=$(bun_cmd run gen:skill-docs:user --host claude --out-dir "$_GSTACK_RENDER_TMP" 2>&1)
_GEN_USER_RC=$?
printf '%s\n' "$_GEN_USER_OUT" | tail -3
exit "$_GEN_USER_RC"
); then
_swap_in_render "$_GSTACK_RENDER_DIR" "$_GSTACK_RENDER_TMP"
# Repoint the installed skills at the fresh render — the installer
# prefers rendered files when present (#2569).
if [ "${_CLAUDE_SKILLS_LINKED:-0}" -eq 1 ]; then
link_claude_skill_dirs "$SOURCE_GSTACK_DIR" "$INSTALL_SKILLS_DIR" >/dev/null
fi
else
rm -rf "$_GSTACK_RENDER_TMP"
log " warning: gen:skill-docs:user failed — previous render (if any) left in place, links stay valid. Run 'bun run gen:skill-docs:user --host claude --out-dir $_GSTACK_RENDER_DIR' manually if you want fresh brain-aware blocks"
fi
fi
else
log "gbrain not detected — brain-aware blocks suppressed in planning-skill SKILL.md files (zero token overhead)."
log " To enable: install gbrain via /setup-gbrain, then re-run ./setup or 'gstack-config gbrain-refresh'."
# A render from a previous gbrain install would shadow canonical files
# on the next link/relink — drop it and restore canonical links.
if [ -d "$_GSTACK_RENDER_DIR" ] && [ -z "${GSTACK_SKIP_GBRAIN_REGEN:-}" ]; then
rm -rf "$_GSTACK_RENDER_DIR"
if [ "${_CLAUDE_SKILLS_LINKED:-0}" -eq 1 ]; then
link_claude_skill_dirs "$SOURCE_GSTACK_DIR" "$INSTALL_SKILLS_DIR" >/dev/null
fi
fi
fi
else
rm -f "$DETECTION_TMP"
log " warning: gstack-gbrain-detect failed — brain-aware blocks will stay suppressed"
fi
fi
# Hook commands registered into ~/.claude/settings.json must survive deletion
# of the directory setup ran from. A dev-worktree setup used to bake
# $SOURCE_GSTACK_DIR's absolute path into the registration; deleting that
# worktree left a dead hook erroring on every trigger. Prefer the global
# install (~/.claude/skills/gstack — a persistent checkout, or a stable
# symlink) and fall back to the setup-time source tree only when no global
# install exists yet (first install from a fresh clone).
_hook_install_path() {
local rel="$1"
local global_hook="$HOME/.claude/skills/gstack/$rel"
if [ -x "$global_hook" ]; then
printf '%s' "$global_hook"
else
printf '%s' "$SOURCE_GSTACK_DIR/$rel"
fi
}
# 11. Plan-tune cathedral hook install (T8).
#
# Registers PostToolUse (deterministic AUQ capture) + PreToolUse (preference
# enforcement) hooks in ~/.claude/settings.json so /plan-tune actually does
# something at runtime instead of being agent-convention. Explicit consent UX
# per D4 + Codex: never mutate settings.json silently.
#
# Idempotent via _gstack_source tag = 'plan-tune-cathedral'. If both hooks
# already registered under that tag, the install skips the consent prompt and
# only refreshes the registered command paths in place (ensure-event is a
# no-op when they already match — see the stale-path note above).
PLAN_TUNE_LOG_HOOK="$(_hook_install_path hosts/claude/hooks/question-log-hook)"
PLAN_TUNE_PREF_HOOK="$(_hook_install_path hosts/claude/hooks/question-preference-hook)"
AUQ_ERROR_FALLBACK_HOOK="$(_hook_install_path hosts/claude/hooks/auq-error-fallback-hook)"
PLAN_TUNE_INSTALL_MARKER="$HOME/.gstack/.plan-tune-hooks-prompted"
if [ "$NO_TEAM_MODE" -ne 1 ] \
&& [ -x "$SETTINGS_HOOK" ] \
&& [ -x "$PLAN_TUNE_LOG_HOOK" ] \
&& [ -x "$PLAN_TUNE_PREF_HOOK" ]; then
# Already installed? Require BOTH the plan-tune source AND the AUQ-error-fallback
# source — so an existing install that predates the fallback hook re-runs the
# install (which is idempotent for the plan-tune hooks) and picks up the new one.
ALREADY_INSTALLED=0
_HOOK_SOURCES=$("$SETTINGS_HOOK" list-sources 2>/dev/null || true)
if printf '%s' "$_HOOK_SOURCES" | grep -q "plan-tune-cathedral" \
&& printf '%s' "$_HOOK_SOURCES" | grep -q "auq-error-fallback"; then
ALREADY_INSTALLED=1
fi
# Resolve the desired action without ever blocking.
# Priority: CLI flag (--plan-tune-hooks / --no-plan-tune-hooks)
# > env (GSTACK_PLAN_TUNE_HOOKS=yes|no)
# > saved config (plan_tune_hooks)
# > smart default ("prompt" → timed prompt on a real TTY, else skip).
# This guarantees scripted/workspace setups (conductor, CI) are never
# interactive: pass --no-plan-tune-hooks (or --plan-tune-hooks) and the
# block runs to completion with no `read`.
PT_DECISION="$PLAN_TUNE_HOOKS_MODE"
[ -z "$PT_DECISION" ] && PT_DECISION="${GSTACK_PLAN_TUNE_HOOKS:-}"
[ -z "$PT_DECISION" ] && PT_DECISION="$("$GSTACK_CONFIG" get plan_tune_hooks 2>/dev/null || true)"
# Normalize: strip whitespace + lowercase so "YES", "Yes", " yes" from a flag
# or env var all resolve correctly (an unrecognized opt-in must NOT silently
# downgrade to skip). Unknown values fall through to "prompt".
PT_DECISION=$(printf '%s' "$PT_DECISION" | tr '[:upper:]' '[:lower:]' | tr -d '[:space:]')
case "$PT_DECISION" in
y|yes|true|install|on|1) PT_DECISION="yes" ;;
n|no|false|skip|off|0) PT_DECISION="no" ;;
*) 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.
if [ "$PT_DECISION" = "prompt" ] && { [ -n "${CONDUCTOR_WORKSPACE_PATH:-}" ] || [ -n "${CONDUCTOR_PORT:-}" ]; }; then
PT_DECISION="yes"
_PT_CONDUCTOR_AUTO=1
fi
_install_plan_tune_hooks() {
# ensure-event (not add-event): registers when missing, RE-POINTS a stale
# command path in place when the registration differs, and is a true no-op
# (no write, no backup churn) when it already matches.
"$SETTINGS_HOOK" ensure-event \
--event PostToolUse \
--matcher '(AskUserQuestion|mcp__.*__AskUserQuestion)' \
--command "$PLAN_TUNE_LOG_HOOK" \
--source plan-tune-cathedral \
--timeout 5
"$SETTINGS_HOOK" ensure-event \
--event PreToolUse \
--matcher '(AskUserQuestion|mcp__.*__AskUserQuestion)' \
--command "$PLAN_TUNE_PREF_HOOK" \
--source plan-tune-cathedral \
--timeout 5
# 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
# OWN source tag: gstack-settings-hook dedupes by (event, matcher, source) and
# REPLACES the entry's hooks, so sharing 'plan-tune-cathedral' would overwrite the
# question-log capture hook (same event+matcher). A distinct source = a second
# PostToolUse entry; both run in parallel.
if [ -x "$AUQ_ERROR_FALLBACK_HOOK" ]; then
"$SETTINGS_HOOK" ensure-event \
--event PostToolUse \
--matcher '(AskUserQuestion|mcp__.*__AskUserQuestion)' \
--command "$AUQ_ERROR_FALLBACK_HOOK" \
--source auq-error-fallback \
--timeout 5
fi
}
if [ "$ALREADY_INSTALLED" -eq 1 ]; then
# Consent already recorded — no prompt. But a registration from an earlier
# setup may carry a stale absolute path (a since-deleted dev worktree);
# ensure-event re-points it in place and no-ops when everything matches.
# Non-fatal to setup, but never silent: the hardened settings-hook refuses
# to rewrite a corrupt settings.json (exit 1), and swallowing that refusal
# left users with stale hooks and no signal.
if ! _PT_ENSURE_ERR=$(_install_plan_tune_hooks 2>&1 >/dev/null); then
log " warning: settings hook update failed: $(printf '%s\n' "$_PT_ENSURE_ERR" | head -1) — run $SETTINGS_HOOK manually"
fi
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.
_install_plan_tune_hooks
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
touch "$PLAN_TUNE_INSTALL_MARKER"
elif [ "$PT_DECISION" = "no" ]; then
# Explicit opt-out (flag / env / config). Non-interactive.
log ""
log "Plan-tune cathedral hooks not installed (opted out)."
log "Install later with: ./setup --plan-tune-hooks (or /update-config)."
touch "$PLAN_TUNE_INSTALL_MARKER"
elif [ -f "$PLAN_TUNE_INSTALL_MARKER" ]; then
# Previously declined. Don't re-ask. User can re-enable via /update-config.
:
elif [ "$QUIET" -ne 1 ] && [ -t 0 ] && [ -t 1 ]; then
# Real interactive terminal with no recorded preference: ask, with explicit
# consent + diff preview. The read is time-bounded and defaults to "skip" so
# it can never hang an automated/forwarded TTY (the conductor failure mode).
_PT_PROMPT_TIMEOUT=10 # single source of truth for the read + the countdown text
log ""
log "──────────────────────────────────────────────────────────"
log "Plan-tune cathedral: install Claude Code hooks?"
log "──────────────────────────────────────────────────────────"
log ""
log "These hooks make /plan-tune settings actually bind at runtime:"
log " • PostToolUse hook captures every AskUserQuestion fire (no agent"
log " compliance required). Today it's agent-convention and the log"
log " is empty in dogfood."
log " • PreToolUse hook enforces 'never-ask' preferences via Claude Code's"
log " permissionDecision protocol. Today preferences are agent-honored"
log " convention; this makes them binding."
log ""
log "Diff preview (PostToolUse capture hook):"
"$SETTINGS_HOOK" diff-event \
--event PostToolUse \
--matcher '(AskUserQuestion|mcp__.*__AskUserQuestion)' \
--command "$PLAN_TUNE_LOG_HOOK" \
--source plan-tune-cathedral \
--timeout 5 2>/dev/null || true
log ""
log "Backup: settings.json.bak.<ts> written before any mutation."
log "Rollback: $SETTINGS_HOOK rollback"
log ""
printf "Install both hooks now? [y/N] (default: N, auto-skips in %ss): " "$_PT_PROMPT_TIMEOUT"
read -t "$_PT_PROMPT_TIMEOUT" -r PLAN_TUNE_INSTALL_REPLY </dev/tty 2>/dev/null || PLAN_TUNE_INSTALL_REPLY=""
case "$PLAN_TUNE_INSTALL_REPLY" in
y|Y)
_install_plan_tune_hooks
log ""
log "Plan-tune hooks installed. Run /plan-tune anytime to inspect."
touch "$PLAN_TUNE_INSTALL_MARKER"
;;
n|N)
log ""
log "Skipped. Re-run ./setup --plan-tune-hooks or use /update-config to install later."
touch "$PLAN_TUNE_INSTALL_MARKER"
;;
*)
# Empty / timed out — treat as "ask me again" (don't persist a decline).
log ""
log "No response — skipped for now. Re-run ./setup --plan-tune-hooks to install."
;;
esac
else
# Non-interactive (CI, scripted/workspace setup, quiet). Never prompt.
log ""
log "Plan-tune cathedral hooks not installed (non-interactive setup)."
log "Install with: ./setup --plan-tune-hooks"
log " (or set GSTACK_PLAN_TUNE_HOOKS=yes, or run the commands below)"
log " $SETTINGS_HOOK add-event --event PostToolUse \\"
log " --matcher '(AskUserQuestion|mcp__.*__AskUserQuestion)' \\"
log " --command $PLAN_TUNE_LOG_HOOK --source plan-tune-cathedral --timeout 5"
log " $SETTINGS_HOOK add-event --event PreToolUse \\"
log " --matcher '(AskUserQuestion|mcp__.*__AskUserQuestion)' \\"
log " --command $PLAN_TUNE_PREF_HOOK --source plan-tune-cathedral --timeout 5"
fi
fi
# ─── Timeline Stop hook (#2553) ──────────────────────────────────────────────
# The preamble writes event:"started" to the project timeline at every skill
# start; the completion write lives in end-of-workflow prose and is
# unenforceable — interrupted sessions leaked started > completed forever.
# Register a Stop-event hook that closes dangling entries. FAIL-OPEN contract
# (F5): the hook always exits 0 and repairs best-effort — it can never block
# a session. Removed by --no-team and gstack-uninstall.
#
# The command path prefers the global install (see _hook_install_path): a
# dev-worktree setup used to bake its own absolute dir into settings.json, so
# deleting the worktree left a dead hook erroring on every session stop — and
# the old presence-only dedup (list-sources | grep) never re-pointed it on a
# re-run. ensure-event registers when missing, replaces a stale path in place
# (one atomic write — never zero or two registrations), and no-ops when the
# registration already matches.
TIMELINE_STOP_HOOK="$(_hook_install_path hosts/claude/hooks/timeline-stop-hook)"
if [ "$NO_TEAM_MODE" -ne 1 ] && [ -x "$SETTINGS_HOOK" ] && [ -x "$TIMELINE_STOP_HOOK" ]; then
if _TL_ENSURE_OUT=$("$SETTINGS_HOOK" ensure-event \
--event Stop \
--command "$TIMELINE_STOP_HOOK" \
--source gstack-timeline-stop \
--timeout 5 2>&1); then
case "$_TL_ENSURE_OUT" in
*unchanged*)
: # already registered with the canonical command — quiet no-op
;;
*re-pointed*)
log " re-pointed Stop hook to $TIMELINE_STOP_HOOK (previous registration held a stale path)"
;;
*)
log " registered Stop hook: session timeline entries now close even when a skill is interrupted (backup: settings.json.bak.<ts>; remove: $SETTINGS_HOOK remove-source --source gstack-timeline-stop)"
;;
esac
else
# Non-fatal to setup, but never silent: the hardened settings-hook refuses
# to rewrite a corrupt settings.json (exit 1), and swallowing that refusal
# left the Stop hook unregistered with no signal.
log " warning: settings hook update failed: $(printf '%s\n' "$_TL_ENSURE_OUT" | head -1) — run $SETTINGS_HOOK manually"
fi
fi
# Also tear down plan-tune + timeline hooks on --no-team (matches the existing pattern).
if [ "$NO_TEAM_MODE" -eq 1 ] && [ -x "$SETTINGS_HOOK" ]; then
"$SETTINGS_HOOK" remove-source --source plan-tune-cathedral 2>/dev/null || true
"$SETTINGS_HOOK" remove-source --source gstack-timeline-stop 2>/dev/null || true
fi
# ─── Redact pre-push guard consent (#1946) ───────────────────────────────────
# The credential pre-push hook is per-REPO state — setup runs in the gstack
# checkout, the wrong repo to install it into, so setup NEVER installs the
# hook itself. /ship installs it silently in any repo where
# redact_prepush_hook=true. What setup owns is CONSENT: on a real interactive
# terminal it asks ONCE whether pushes should be scanned, recording the
# answer to the existing redact_prepush_hook key (default stays false — a
# timeout or non-interactive run changes nothing and keeps the hint-only
# posture). An explicit answer is persisted and never re-asked; an explicit
# "false" is a recorded decline (adversarial review finding 11).
# `gstack-config get` defaults absent keys to "false", which is
# indistinguishable from a decline — test key presence in the config file.
_GSTACK_CFG_FILE="${GSTACK_HOME:-$HOME/.gstack}/config.yaml"
if ! grep -q '^redact_prepush_hook:' "$_GSTACK_CFG_FILE" 2>/dev/null; then
if [ "$QUIET" -ne 1 ] && [ -t 0 ] && [ -t 1 ]; then
_REDACT_PROMPT_TIMEOUT=10
log ""
log "Credential push guard: gstack can block pushes containing credentials"
log "(a per-repo git pre-push hook; /ship installs it automatically in every"
log "repo you ship from — nothing is installed right now)."
printf "Enable the pre-push credential guard? [y/N] (default: N, auto-skips in %ss): " "$_REDACT_PROMPT_TIMEOUT"
read -t "$_REDACT_PROMPT_TIMEOUT" -r _REDACT_REPLY </dev/tty 2>/dev/null || _REDACT_REPLY=""
case "$_REDACT_REPLY" in
y|Y)
"$GSTACK_CONFIG" set redact_prepush_hook true 2>/dev/null || true
log "Enabled. /ship will install the guard in each repo at first push."
;;
n|N)
"$GSTACK_CONFIG" set redact_prepush_hook false 2>/dev/null || true
log "Declined — recorded. Re-enable anytime: gstack-config set redact_prepush_hook true"
;;
*)
# Timed out / empty: don't persist a decline — hint and ask next time.
log ""
log "Skipped for now. Enable anytime: gstack-config set redact_prepush_hook true"
;;
esac
else
log ""
log "Tip: gstack can block pushes containing credentials (per-repo git hook)."
log " Enable once: gstack-config set redact_prepush_hook true — /ship"
log " installs the hook automatically in every repo you ship from."
fi
fi