v1.68.1.0 fix: phantom AskUserQuestion hooks — canonical-only registration + self-healing settings.json (#2631)

* fix(settings-hook): KNOWN_HOOKS identity healer — per-item ownership, mutation lock, fail-closed parse

Claude Code strips the unknown _gstack_source key when it rewrites
settings.json, so tag-based dedupe degraded to exact-command equality and
every Conductor worktree's setup appended a fresh hook entry; deleted
worktrees left dead hooks erroring on every AskUserQuestion fire.

- KNOWN_HOOKS identity table (shared JS prelude, single source of truth):
  ownership is intrinsic and PER HOOK ITEM — basename + relpath suffix +
  event (+ matcher where defined). Tags never claim foreign items.
- New `prune-stale [--repoint <root>] [--all]`: prune dead gstack items,
  re-point survivors at the stable install (tag restore from the table),
  exact-duplicate collapse, uninstall/no-team identity sweep. Explicit
  plan_tune_hooks:no is honored (dead pruned, live never re-pointed).
- add-event / remove-source become item-aware: replace/remove only the owned
  item; a user's co-located hook in the same entry is never collateral.
- Mutation safety: mkdir lock with owner token, ownership-checked release,
  atomic stale takeover; per-process-unique tmp + backup names;
  backup-on-change everywhere; fail-closed on parse failure (a corrupt
  settings.json is never overwritten — previously catch{} clobbered it);
  locked atomic rollback.

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

* feat(gstack-config): `has <key>` — key-presence provenance through STATE_DIR resolution

`get` returns the DEFAULTS value for absent keys, so callers that need to
know whether the USER decided something (vs inherited a default) had no
correct primitive — setup's consent logic was about to grep a hardcoded
~/.gstack/config.yaml, which misclassifies under GSTACK_STATE_ROOT /
GSTACK_HOME / GSTACK_STATE_DIR overrides. `has` exits 0 iff the key is
literally present in the resolved config file, with the same C-locale key
validation as get/set.

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

* fix(setup): canonical-only hook registration, heal-first, PT_EXPLICIT consent provenance

Three root causes of the phantom-AskUserQuestion-hooks class, all in the
registration path:

- Bug A: the Conductor auto-opt-in upgraded PT_DECISION "prompt" -> "yes"
  even when "prompt" was dev-setup's EXPLICIT --plan-tune-hooks=prompt pin,
  so every new Conductor workspace installed hooks. PT_EXPLICIT (flag/env/
  config-key-presence via `gstack-config has`) now gates the auto-opt-in to
  the true silent fall-through.
- Bug B: hook commands were baked from $SOURCE_GSTACK_DIR (`pwd -P` of the
  running tree — ephemeral for worktrees). Registration is now CANONICAL-ONLY
  via _hook_command_path (${CLAUDE_CONFIG_DIR:-$HOME/.claude}/skills/gstack);
  missing canonical hook = skip + log, never a baked tree path. SessionStart
  moves to schema-aware add-event under its identity source; whitespace paths
  are quoted.
- Bug C: nothing ever pruned, and dead tagged entries blocked the
  "already installed" guards forever. Setup now heals FIRST on every run
  (prune-stale --repoint at the stable install), surfaces a one-line summary
  only when something changed, surfaces the plan_tune_hooks:no-vs-live-hooks
  contradiction, and --no-team tears down all three sources plus an identity
  sweep for untagged strays.

dev-setup's no-mutation guarantee gains its stated repair exception (prune
dead / re-point existing, never ADD).

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

* fix(uninstall): run hook cleanup BEFORE install-root deletion + full identity sweep

SETTINGS_HOOK resolves via $(dirname "$0") INSIDE the install root, but the
cleanup ran after `rm -rf ~/.claude/skills/gstack` — a real global uninstall
(running the installed copy) silently no-op'd and orphaned every hook entry.
Tests masked it by running the uninstaller from the repo checkout.

The relocated block also removes the auq-error-fallback source (registered by
setup, previously never torn down) and finishes with a prune-stale --all
identity sweep so untagged strays (Claude Code strips _gstack_source) go too.

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

* test: phantom-hooks heal coverage — incident facsimile, per-item safety, lock, canonical tripwires

- gstack-settings-hook-schema-aware: 16 new cases — identity re-point (tag
  restore), foreign-basename rejection, mixed-entry per-item safety for
  add-event/remove-source/--all, prune-stale modes incl. bash-prefix +
  Windows-backslash + spaced-path idempotence, duplicate collapse preferring
  the tagged twin, plan_tune_hooks:no split, backup-on-change no-churn,
  fail-closed corrupt-JSON for every mutator, stale-lock takeover,
  fresh-foreign-lock skip, two-writer concurrency smoke, and an INCIDENT
  FACSIMILE replaying the exact 2026-08-17 production damage (6/3/2 entries,
  mixed tags, live-ephemeral Stop) healing to 2/1/1 canonical.
- NEW setup-hook-canonical-paths: static tripwires — canonical-only resolver
  (no $SOURCE_GSTACK_DIR anywhere in it), heal-before-guards ordering,
  unsuppressed heal output, ${VAR:-0} counter idiom, shared-prelude
  concatenation at every bun call site, KNOWN_HOOKS completeness vs setup's
  registrations, uninstall cleanup-before-deletion ordering, defect-class
  warning present.
- setup-plan-tune-hooks-noninteractive: PT_EXPLICIT pins + `gstack-config
  has` provenance + has-subcommand behavior (env-resolution, malformed keys).
- auq-error-fallback-hook: registration + both-teardown wiring (previously
  untested).
- uninstall: behavioral ordering test running the INSTALLED copy from inside
  the root it deletes.
- setup-windows-fallback / gstack-config-key-locale: pins updated for the new
  HOOK_CMD shape and the third C-locale validator.

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

* fix(test): banner-tripwire exec used JSON.stringify as shell quoting — vacuous pass + stray artifact

JSON escaping is not shell escaping. Interpolating JSON.stringify(script)
into `bash -c ${...}` left every JSON "\n" as a literal backslash-n inside
shell double quotes, collapsing the extracted release-body tripwire block
onto one line: `then\n` parsed as the command word `thenn`, and
`>&2\nelse\n` parsed as the redirect `>&2nelsen` — so every full-suite run
littered a `2nelsen` file (containing "bash: thenn: command not found") in
the repo root, and the test's single not-contains assertion passed
VACUOUSLY because all output had been redirected into that file. The
"and it actually fires" functional check never verified anything.

Fix: pass the script as an argv element (spawnSync array form) and assert
both branches for real — ABORT case must print the leak message to stderr,
clean case must print "banner tripwire clean" to stdout.

Verified: `bun test test/binding-template-drift.test.ts` previously created
the artifact deterministically; the full free suite now runs artifact-free.
The other shell-interpolation sites (evidence, schema-aware concurrency,
empty-find-fallthrough, branch-slug-hygiene) already use correct quoting.

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

* test: regression pin for legacy remove mixed-entry filtering + ownership negatives

Coverage-audit iron rule: the rewritten legacy `remove` action filters
per-item (pre-v1.67.2 it dropped the whole entry, destroying a user's
co-located SessionStart hook) — modified existing behavior, previously
untested. Also pins two ownership negatives: an owned basename+relpath under
the WRONG matcher stays foreign, and prune-stale on an absent settings file
exits 0 with removed 0.

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

* fix: pre-landing review fixes — review-army findings hardened

Specialist review (testing, maintainability, security, performance,
data-migration) findings, each verified against code before fixing:

- legacy remove: preserve malformed/foreign entries (hooks absent, non-array,
  or pre-existing empty) — only entries THIS pass emptied are dropped
- add-event: never tag a mixed entry (old gstack versions in sibling
  worktrees treat tags as entry-level ownership and would destroy the user's
  co-located items); tag only single-item entries; prune-stale drops tags
  from mixed entries for the same reason
- prune-stale: within-entry twin collapse (two dead copies of one hook
  re-pointed to the same canonical command no longer double-fire); command
  quoting hardened via gsQuoteCmd (escapes \\ " $ backtick; gsStripWrap
  unescapes so identity round-trips); NUL bytes in the dedupe key replaced
  with a JSON.stringify key (bash silently dropped the NULs, degrading the
  separator; the file also read as binary to tooling)
- gsIsAlive: only provable absence (ENOENT/ENOTDIR) counts as dead —
  EACCES/EIO/unmounted volumes no longer prune (one-way-ratchet guard)
- gsWriteIfChanged: preserves the live settings.json mode across rewrites
  (a user-tightened 0600 carrying API keys was silently broadened to 0644);
  fresh files start 0600; backups rotate (keep 10)
- remove-source: command-less items default to foreign (gstack only writes
  type:command items); single-item stray claim requires a command
- rollback: pointer target must be a sibling settings.json.bak.* file
- uninstall + setup --no-team + SessionStart registration: stderr stays
  attached — a lock give-up or fail-closed parse during TEARDOWN must be
  visible ("the next setup retries" does not apply after uninstall)
- setup: team-mode banner no longer claims an auto-update hook when
  registration was skipped; heal log documents the rollback-pointer caveat;
  SESSION_UPDATE_CMD quoting mirrors gsQuoteCmd; lock constants named
- list-sources: corrupt settings.json reports to stderr instead of silently
  printing nothing (setup guards must not misread corrupt as no-hooks)
- tests: 10 new pins (malformed-entry preservation, mixed no-tag, twin
  collapse, 0600 mode, metachar escaping round-trip, backup rotation,
  rollback pointer refusal, held-lock uninstall warning, matcher-drift
  tripwire, ownership negatives)

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

* fix: red-team findings — verify-gate identity, single quoting authority, Windows paths

Red-team pass over the hardened diff (several findings empirically verified
by the reviewer before reporting):

- KNOWN_HOOKS gains the sixth identity: gstack-verify-gate (README-documented
  opt-in Stop hook). A tag-stripped verify-gate entry previously survived
  prune-stale --all and errored at the end of EVERY turn after uninstall
  deleted the install root — the exact phantom-hook class this branch fixes.
  Uninstall also sweeps its tagged form.
- add-event is now the single quoting authority: every registered command is
  normalized through the same gsQuoteCmd/gsStripWrap round-trip the healer
  uses. Pre-fix, only SessionStart got caller-side quoting — a spaced/metachar
  canonical root registered broken plan-tune/AUQ/timeline hooks that the very
  next heal rewrote (the codebase disagreed with its own registrations).
- Windows: MSYS-form paths (/c/Users/...) are drive-translated for fs checks
  only (gsWinPath) — native bun resolved them drive-relative, so the heal
  judged every LIVE Windows hook dead and pruned it. The three AskUserQuestion
  hooks and the Stop hook now also get the mandatory 'bash ' prefix on
  Windows (previously only SessionStart did; extensionless bash shims
  otherwise hit the file-association dialog).
- CANONICAL_GSTACK_ROOT falls back to $HOME/.claude/skills/gstack when a
  CLAUDE_CONFIG_DIR-derived root was never installed (the installer hardcodes
  the home path — split-brain left such users permanently hookless).
- prune-stale preserves foreign entries that STARTED empty (they were
  silently deleted, uncounted, on every heal).
- The timeline Stop registration and its list-sources guard join the
  zero-silent-mutations contract (stderr attached).

Tests: verify-gate tag-stripped heal+sweep, started-empty preservation,
add-event quoting-authority round-trip.

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

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

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

* docs: update project documentation for v1.68.1.0

README: document canonical-only hook registration + the prune-stale
self-heal in the setup hooks section; expand the manual-uninstall note
to cover every gstack hook identity, not just timeline-stop-hook.
CONTRIBUTING: record PT_EXPLICIT provenance (Conductor auto-opt-in
fires only on the true silent fall-through) and the heal-first repair
exception in the dev-setup paragraph.

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

* fix(settings-hook): fail-loud hardening — gsMain umbrella, lock exit 5, prototype-safe ownership

bun in -e mode swallows uncaught exceptions thrown after a require() and
exits 0 (verified on 1.3.13; uncaughtException handlers never fire either),
so any runtime throw in a mutator was a SILENT SUCCESS. Every script body
now runs inside a gsMain try/catch that prints "internal error ... refusing
to mutate" and exits 4.

Also: lock give-up now exits 5 instead of 0 (callers must not report a
skipped mutation as registered); basename lookup uses hasOwnProperty so a
foreign hook named "toString"/"constructor" can't resolve to an inherited
Object.prototype member and abort the sweep; ownership-checked release also
clears an empty/missing owner file; backup rotation sorts by mtime, not
name; Windows-only backslash normalization (a legal Unix path containing a
backslash is no longer rewritten); GSTACK_SWEEP_EXCLUDE_SOURCES lets a
sweep spare named sources; lock tradeoffs documented at the lock helper.

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

* fix(setup): honest hook-registration reporting + verify-gate sweep exclusion

_install_plan_tune_hooks now propagates per-add-event failures (lock
contention exits 5, fail-closed settings errors exit 3) and both caller
sites branch on it: success logs the installed message, failure logs a
visible "NOT registered — re-run ./setup" warning instead of claiming
success for a mutation that never happened.

--no-team's identity sweep runs with GSTACK_SWEEP_EXCLUDE_SOURCES=
verify-gate: turning team mode off must not delete the user-registered
verify-gate opt-in whose binary still exists (uninstall still sweeps it,
correctly, because there the binary itself is being removed).

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

* test: adversarial regression pins — wrong-shape fail-loud, prototype basename, sweep exclusion, lock exit 5

New pins for the fail-loud hardening: a wrong-shape hooks value (object
where an array belongs) exits 4 with "refusing to mutate" and leaves the
file byte-identical (pre-gsMain this was a silent exit-0 no-op); a foreign
hook whose basename collides with Object.prototype ("toString") survives
an --all sweep that still removes gstack rows; GSTACK_SWEEP_EXCLUDE_SOURCES
preserves the verify-gate row during --all; the fresh-foreign-lock test now
asserts the loud exit 5 instead of a quiet skip.

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

* test(verify-gate): allow the --no-team sweep exclusion, keep registration banned

setup now legitimately mentions verify-gate once: the --no-team identity
sweep excludes it via GSTACK_SWEEP_EXCLUDE_SOURCES so team-mode teardown
can't delete a user-registered gate. The opt-in pin tightens from a blanket
not-contains to: every mention must be a comment or that exclusion, and no
mention may sit on an add-event line.

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

* fix(settings-hook): GNU-first stat in the lock stale check — Linux abort on held locks

On Linux, BSD-style `stat -f %m` prints a multi-line FILESYSTEM block to
stdout before exiting 1, so the BSD-first || chain captured that garbage
concatenated with the real `stat -c %Y` epoch. The non-numeric mtime made
`$(( now - mtime ))` a syntax error and set -e killed the binary with
exit 1 whenever a lock dir already existed — every contention path (stale
takeover, give-up, concurrent writers) broke on CI while staying green on
macOS, where BSD stat -f succeeds cleanly.

GNU `stat -c %Y` now goes first (BSD stat rejects -c with no stdout, so
macOS falls through cleanly), and a numeric guard blanks any residual
garbage so a future platform quirk degrades to the normal give-up path
instead of an arithmetic abort. Same defect class as gstack-repo-mode's
GNU-first ordering (#2195). Verified in an oven/bun Linux container:
the four CI-failing lock tests now pass (62/62 across both files).

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

* test(uninstall): 30s budgets for the two subprocess-heavy behavioral tests

Both tests spawn the copied uninstaller, which itself runs several
settings-hook bun -e children (the lock-contention one also waits out a
300ms give-up per call). On a loaded box those cold starts blow bun's
default 5s per-test timeout, and a timeout kill reports as a bare fail
with no assertion diff — observed at 5.6-8.5s under load avg 25+.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-08-19 16:53:19 -07:00
committed by GitHub
co-authored by Claude Fable 5
parent 28d59ad56c
commit 9da6692930
21 changed files with 2201 additions and 271 deletions
+51
View File
@@ -1,5 +1,56 @@
# Changelog # Changelog
## [1.68.1.0] - 2026-08-18
**Phantom hook errors are dead. Your settings.json now heals itself**
**on every setup, and no ephemeral path can ever be baked in again.**
If you work in Conductor workspaces or git worktrees, you have probably seen it: `PostToolUse:AskUserQuestion hook error ... No such file or directory` spraying on every question, pointing at a workspace you deleted last week. The cause was a three-part failure. Setup baked the running tree's physical path into your global `~/.claude/settings.json`, the Conductor auto-opt-in overrode the exact flag `bin/dev-setup` passes to prevent that, and the dedupe tag gstack relied on gets stripped by Claude Code itself, so every new workspace appended a fresh dead entry instead of replacing the old one.
All three are fixed at the root. Hook registration is now canonical-only: commands point at the stable `~/.claude/skills/gstack` install or are not registered at all. Ownership is decided by a fixed identity table in `bin/gstack-settings-hook`, per hook item, so it survives tag-stripping and can never claim a hook you wrote yourself. And every `./setup` run now heals first: `gstack-settings-hook prune-stale --repoint` removes dead gstack entries, re-points stale ones, restores stripped tags, and collapses duplicates, printing one line only when it changed something.
### The numbers that matter
Source: the 2026-08-17 incident on a real dev box, replayed byte-for-byte as the `incident facsimile` test in `test/gstack-settings-hook-schema-aware.test.ts`.
| Metric | Before | After | Δ |
|--------|--------|-------|---|
| Hook entries in settings.json | 11 (6 dead) | 5, all canonical | 6 dead |
| Error lines per AskUserQuestion | 4 | 0 | 4 |
| Hook processes spawned per question that do nothing | 4 | 0 | 4 |
| Traced code paths under test | — | 53 of 61 (87%) | new |
The healer also fixes damage you could not see: a corrupt settings.json is never overwritten (every mutator now fails closed instead of clobbering it with `{}`), a user-tightened 0600 file keeps its mode across rewrites (settings.json can carry API keys), concurrent setups can no longer rename a half-written temp file into place, and uninstall now cleans hooks BEFORE deleting the install root, which previously made cleanup silently no-op in exactly the case it existed for.
### What this means for you
Run `./setup` (or `/gstack-upgrade`) once and the errors stop, on every machine, with a printed receipt of what was healed and a backup beside the file. New workspaces can never reintroduce them. If you ever want everything gone, `gstack-uninstall` now actually removes every gstack hook, including the ones an older version orphaned.
### Itemized changes
### Added
- `gstack-settings-hook prune-stale [--repoint <root>] [--all]`: self-healing for hook registrations. Dead gstack entries pruned, stale paths re-pointed at the stable install, stripped `_gstack_source` tags restored from the identity table, exact duplicates and within-entry twins collapsed. Runs automatically at the start of every `./setup`; `--all` is the complete teardown sweep used by uninstall and `--no-team`.
- `gstack-config has <key>`: key-presence check through the same state-dir resolution as `get` (which returns defaults for absent keys), so consent logic can tell a recorded decision from a default.
- KNOWN_HOOKS identity table covering all six gstack hooks (plan-tune trio, timeline Stop, session update, verify-gate), shared by registration dedupe and the healer so the two can never drift.
- A mutation lock around every settings.json write: mkdir-based with an owner token, ownership-checked release, and atomic stale-lock takeover. Backups get unique names and rotate (10 kept); `rollback` validates its pointer and restores atomically.
### Changed
- Hook registration is canonical-only. Setup never writes a running-tree path into global settings; if the stable install is missing a hook, it skips with a visible log line instead. The Conductor auto-opt-in for AskUserQuestion reliability hooks now respects explicit decisions (flag, env, or a recorded config key) and fires only on the true silent fall-through.
- `add-event` is the single quoting authority: registered commands are normalized once (whitespace and shell metacharacters escaped), so a spaced or `$`-bearing install path produces a working hook from the first registration. Windows gets the required `bash ` prefix on all hooks, not just SessionStart, and MSYS-form paths no longer read as dead to the healer.
- All settings.json mutators are per-item: a hook you co-located in the same entry as a gstack hook survives every gstack operation, including uninstall, and gstack never tags an entry that contains your items.
- Teardown paths (`gstack-uninstall`, `./setup --no-team`) run hook cleanup before any deletion, sweep untagged strays by identity, and keep stderr attached so a skipped cleanup is loud, never silent.
### Fixed
- Deleted Conductor workspaces and worktrees no longer leave dead hooks erroring on every AskUserQuestion, session start, and stop event.
- A corrupt settings.json is preserved and reported (exit 3) instead of being replaced with an empty object by the next hook operation.
- settings.json file mode is preserved across rewrites; fresh files are created 0600.
- Liveness checks treat only provable absence as dead, so an unmounted volume or permission blip cannot prune a working hook.
- A vacuous test in the banner-tripwire check executed its script through JSON-as-shell-quoting, silently littering a `2nelsen` artifact in the repo root on every suite run while asserting nothing; it now passes the script as argv and asserts both branches.
### For contributors
- 60+ new or updated test cases across 8 files, including the incident facsimile, a two-writer concurrency smoke, an uninstall test that runs the installed copy from inside the root it deletes, held-lock teardown visibility, quoting round-trips, and static tripwires pinning canonical-only registration, heal-first ordering, matcher-literal parity, and the shared-prelude call sites.
- The review pipeline for this release (five specialists plus red team plus two Codex passes) contributed 14 verified hardening fixes; rejected findings are documented in the PR.
## [1.68.0.0] - 2026-08-18 ## [1.68.0.0] - 2026-08-18
**The next tracker wave: 16 verified fixes in, 90 stale PRs and 21 issues out.** **The next tracker wave: 16 verified fixes in, 90 stale PRs and 21 issues out.**
+1 -1
View File
@@ -431,7 +431,7 @@ If you're using [Conductor](https://conductor.build) to run multiple Claude Code
When Conductor creates a new workspace, `bin/dev-setup` runs automatically. It detects the main worktree (via `git worktree list`), copies your `.env` so API keys carry over, and sets up dev mode — no manual steps needed. When Conductor creates a new workspace, `bin/dev-setup` runs automatically. It detects the main worktree (via `git worktree list`), copies your `.env` so API keys carry over, and sets up dev mode — no manual steps needed.
`bin/dev-setup` runs `./setup` fully non-interactively (it passes `--plan-tune-hooks=prompt` and closes stdin), so a forwarded Conductor TTY can never hang on a hidden setup prompt. It also never installs the plan-tune Claude Code hooks, which means a throwaway workspace can't rewrite your global `~/.claude/settings.json` to point at an ephemeral worktree path. To install the plan-tune hooks deliberately, run `./setup --plan-tune-hooks` outside dev-setup (or `gstack-config set plan_tune_hooks yes`). `bin/dev-setup` runs `./setup` fully non-interactively (it passes `--plan-tune-hooks=prompt` and closes stdin), so a forwarded Conductor TTY can never hang on a hidden setup prompt. It also never installs the plan-tune Claude Code hooks, which means a throwaway workspace can't rewrite your global `~/.claude/settings.json` to point at an ephemeral worktree path. To install the plan-tune hooks deliberately, run `./setup --plan-tune-hooks` outside dev-setup (or `gstack-config set plan_tune_hooks yes`). The explicit flag counts as an explicit decision: setup's Conductor auto-opt-in for AskUserQuestion hooks fires only on the true silent fall-through (no flag, no `GSTACK_PLAN_TUNE_HOOKS` env var, no `plan_tune_hooks` key literally present in config, checked via `gstack-config has`), so it can never override dev-setup into installing hooks. One stated repair exception: setup's heal-first pass (`gstack-settings-hook prune-stale --repoint`) may prune dead gstack hook entries and re-point existing ones at the stable `~/.claude/skills/gstack` install. That is strictly convergent repair, never a new registration, and registration itself is canonical-only, so an ephemeral tree path can never be baked into settings.json.
**First-time setup:** Put your `ANTHROPIC_API_KEY` in `.env` in the main repo (see `.env.example`). Every Conductor workspace inherits it automatically. **First-time setup:** Put your `ANTHROPIC_API_KEY` in `.env` in the main repo (see `.env.example`). Every Conductor workspace inherits it automatically.
+15 -3
View File
@@ -272,6 +272,14 @@ a session). Skip it with `./setup --no-team`, remove it with
`gstack-settings-hook remove-source --source gstack-timeline-stop`; `gstack-settings-hook remove-source --source gstack-timeline-stop`;
`gstack-uninstall` removes it too. `gstack-uninstall` removes it too.
Hook registration is canonical-only: every hook command points at the stable
`~/.claude/skills/gstack` install, never the tree setup ran from, so deleting
a worktree or Conductor workspace can't leave dead hooks erroring in your
sessions. Every `./setup` run also heals first: `gstack-settings-hook
prune-stale --repoint` removes dead gstack hook entries, re-points stale ones
at the stable install, and collapses duplicates, printing one line (and
writing a backup beside the file) only when it changed something.
### Continuous checkpoint mode (opt-in, local by default) ### Continuous checkpoint mode (opt-in, local by default)
Set `gstack-config set checkpoint_mode continuous` and skills auto-commit your work as you go with a `WIP:` prefix plus a structured `[gstack-context]` body (decisions, remaining work, failed approaches). Survives crashes and context switches. `/context-restore` reads those commits to reconstruct session state. `/ship` filter-squashes WIP commits before the PR (preserving non-WIP commits) so bisect stays clean. Push is opt-in via `checkpoint_push=true` — default is local-only so you don't trigger CI on every WIP commit. Set `gstack-config set checkpoint_mode continuous` and skills auto-commit your work as you go with a `WIP:` prefix plus a structured `[gstack-context]` body (decisions, remaining work, failed approaches). Survives crashes and context switches. `/context-restore` reads those commits to reconstruct session state. `/ship` filter-squashes WIP commits before the PR (preserving non-WIP commits) so bisect stays clean. Push is opt-in via `checkpoint_push=true` — default is local-only so you don't trigger CI on every WIP commit.
@@ -399,9 +407,13 @@ rm -rf .gstack .gstack-worktrees .claude/skills/gstack 2>/dev/null
rm -rf .agents/skills/gstack* .factory/skills/gstack* 2>/dev/null rm -rf .agents/skills/gstack* .factory/skills/gstack* 2>/dev/null
``` ```
Manual removal leaves the gstack Stop hook entry behind in `~/.claude/settings.json` Manual removal leaves gstack's hook entries behind in `~/.claude/settings.json`
(the uninstall script removes it for you). Edit that file and delete the hook whose (the uninstall script removes all of them for you, including entries whose
command path ends in `hosts/claude/hooks/timeline-stop-hook`. `_gstack_source` tag was stripped). Edit that file and delete every hook whose
command path points into `.claude/skills/gstack/`: the SessionStart auto-update
hook, the AskUserQuestion PreToolUse/PostToolUse hooks, and the Stop hooks
(session timeline, plus verify-gate if you opted in). Left in place, they error
on every matching event once the install directory is gone.
### Clean up CLAUDE.md ### Clean up CLAUDE.md
+28
View File
@@ -58,6 +58,19 @@ global-path registration + re-point). Remaining:
doesn't handle the coordinate. Needs hit-test-aware routing + real-device doesn't handle the coordinate. Needs hit-test-aware routing + real-device
verification. Effort M. (Related: the multi-window rewrite has no static verification. Effort M. (Related: the multi-window rewrite has no static
pins — see the test-gap backlog below.) pins — see the test-gap backlog below.)
- **setup:1601 CLAUDE_CONFIG_DIR alignment** — the skills installer hardcodes
`$HOME/.claude/skills` while settings.json and hook registration honor
`CLAUDE_CONFIG_DIR`; users with the override get a split-brain install.
Mitigated in v1.68.1 (canonical-root fallback to the home path so hooks
still register), but the installer itself should honor the override.
**Priority:** P3. Effort S.
- **Centralize plan_tune_hooks bool parsing + gstack-config key validation** —
the `n|no|false|skip|off|0` negative-value set is triplicated
(gstack-settings-hook prune-stale, setup heal note, setup PT_DECISION) and
gstack-config carries three verbatim copies of the key-validation block
(get/has/set). Extract a `gstack-config` bool helper + `validate_key()`;
update the locale pin test. Filed via /ship review army (maintainability).
**Priority:** P3. Effort S.
- **Accepted threat-model notes (documented, no action planned):** - **Accepted threat-model notes (documented, no action planned):**
redact-prepush treats content pushed to ANY private remote as already-left redact-prepush treats content pushed to ANY private remote as already-left
(accident-only threat model); a parcel-shaped twin within 400 chars can (accident-only threat model); a parcel-shaped twin within 400 chars can
@@ -2773,6 +2786,21 @@ needs one paid run to validate, so it didn't ride the ship.
## Completed ## Completed
### ✅ DONE (v1.68.1.0): Stop-hook registration pins the setup-time absolute path
**Priority:** P1 (was filed Effort S, scoped to the Stop hook — shipped as the full defect class)
**What:** Registering hooks from a dev worktree baked that worktree's physical
path into global settings.json; deleting the worktree left dead hooks erroring
on every AskUserQuestion/session stop. Fixed for ALL gstack hooks, not just
Stop: canonical-only registration via `_hook_command_path`, a KNOWN_HOOKS
identity table in `gstack-settings-hook` (survives Claude Code stripping
`_gstack_source` tags), a `prune-stale [--repoint|--all]` self-healer that
runs heal-first on every `./setup`, per-item mutation safety, a mutation lock,
fail-closed parse, and complete uninstall/no-team teardown.
**Completed:** v1.68.1.0 (2026-08-18)
### ✅ DONE (v1.66.0.0): Free suite exit code is untrustworthy — in-process force-exits mask failures ### ✅ DONE (v1.66.0.0): Free suite exit code is untrustworthy — in-process force-exits mask failures
**Priority:** P1 **Priority:** P1
+1 -1
View File
@@ -1 +1 @@
1.68.0.0 1.68.1.0
+11 -4
View File
@@ -68,10 +68,17 @@ fi
# GSTACK_PLAN_TUNE_HOOKS=yes would still resolve to "install" and rewrite the # GSTACK_PLAN_TUNE_HOOKS=yes would still resolve to "install" and rewrite the
# user's global ~/.claude/settings.json to point at THIS ephemeral worktree — # user's global ~/.claude/settings.json to point at THIS ephemeral worktree —
# which breaks once the workspace is deleted. The flag has highest precedence, # which breaks once the workspace is deleted. The flag has highest precedence,
# so it pins resolution to "prompt", and closed stdin then makes prompt-mode a # so it pins resolution to "prompt" (setup's PT_EXPLICIT provenance keeps the
# no-op skip (no install, no decline marker). A dev workspace must never mutate # Conductor auto-opt-in from overriding an explicit flag), and closed stdin
# global settings.json. To install the hooks, run `./setup --plan-tune-hooks` # then makes prompt-mode a no-op skip (no install, no decline marker).
# directly (outside dev-setup). Saved prefix/other config preferences still apply. #
# A dev workspace never ADDS hooks to global settings.json. One stated repair
# exception: setup's heal-first pass may PRUNE dead gstack hook entries and
# RE-POINT existing ones at the stable ~/.claude/skills/gstack install —
# strictly convergent repair, never a new registration, and hook registration
# itself is canonical-only (an ephemeral tree path can never be baked in).
# To install the hooks, run `./setup --plan-tune-hooks` directly (outside
# dev-setup). Saved prefix/other config preferences still apply.
# #
# GSTACK_SKIP_GBRAIN_REGEN=1 is passed INLINE (not exported) so it scopes to # GSTACK_SKIP_GBRAIN_REGEN=1 is passed INLINE (not exported) so it scopes to
# exactly this nested setup call and can't leak into any other setup path. It # exactly this nested setup call and can't leak into any other setup path. It
+11
View File
@@ -3,6 +3,9 @@
# #
# Usage: # Usage:
# gstack-config get <key> — read a config value (falls back to DEFAULTS) # gstack-config get <key> — read a config value (falls back to DEFAULTS)
# gstack-config has <key> — exit 0 iff the key is literally present in the
# config file (get returns DEFAULTS for absent keys,
# so callers that need provenance use this instead)
# gstack-config set <key> <value> — write a config value # gstack-config set <key> <value> — write a config value
# gstack-config list — show all config (values + defaults) # gstack-config list — show all config (values + defaults)
# gstack-config defaults — show just the defaults table # gstack-config defaults — show just the defaults table
@@ -326,6 +329,14 @@ case "${1:-}" in
fi fi
printf '%s' "$VALUE" printf '%s' "$VALUE"
;; ;;
has)
KEY="${2:?Usage: gstack-config has <key>}"
if ! printf '%s' "$KEY" | LC_ALL=C grep -qE '^[a-zA-Z0-9_]+(@[a-zA-Z0-9]+)?$'; then
echo "Error: key must contain only alphanumeric characters, underscores, and an optional @<endpoint-id> suffix" >&2
exit 1
fi
grep -qE "^${KEY}:" "$CONFIG_FILE" 2>/dev/null
;;
set) set)
KEY="${2:?Usage: gstack-config set <key> <value>}" KEY="${2:?Usage: gstack-config set <key> <value>}"
VALUE="${3:?Usage: gstack-config set <key> <value>}" VALUE="${3:?Usage: gstack-config set <key> <value>}"
+616 -159
View File
@@ -1,39 +1,59 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# gstack-settings-hook — manage Claude Code hooks in ~/.claude/settings.json # gstack-settings-hook — manage Claude Code hooks in ~/.claude/settings.json
# #
# Two shapes: # Three shapes:
# #
# 1. Legacy (SessionStart only — used by setup --team and gstack-uninstall): # 1. Legacy (SessionStart only — kept so old installs still clean up):
# gstack-settings-hook add <cmd> # adds SessionStart hook # gstack-settings-hook add <cmd> # adds SessionStart hook
# gstack-settings-hook remove <cmd> # removes matching SessionStart hook # gstack-settings-hook remove <cmd> # removes gstack-session-update items
# # (the <cmd> arg is accepted for
# # interface compat; matching is by
# # the gstack-session-update basename)
# #
# 2. Schema-aware (plan-tune cathedral T3 — supports PreToolUse + PostToolUse): # 2. Schema-aware (plan-tune cathedral T3):
# gstack-settings-hook add-event --event <SessionStart|PreToolUse|PostToolUse> \ # gstack-settings-hook add-event --event <name — see the validator in add-event> \
# --command <cmd> --source <tag> [--matcher <regex>] [--timeout <s>] # --command <cmd> --source <tag> [--matcher <regex>] [--timeout <s>]
# gstack-settings-hook ensure-event --event ... --command ... --source ... [--matcher ...] [--timeout <s>] # gstack-settings-hook ensure-event --event ... --command ... --source ... [--matcher ...] [--timeout <s>]
# gstack-settings-hook remove-source --source <tag> # gstack-settings-hook remove-source --source <tag>
# gstack-settings-hook diff-event --event ... --command ... --source ... [--matcher ...] # gstack-settings-hook diff-event --event ... --command ... --source ... [--matcher ...]
# gstack-settings-hook rollback # restore latest backup # gstack-settings-hook rollback # restore latest backup (single-step undo)
# gstack-settings-hook list-sources # show all gstack-tagged hook entries # gstack-settings-hook list-sources # show all gstack-tagged hook entries
# #
# ensure-event is the update-in-place verb: same flags as add-event, but it # 3. Self-heal (phantom-hooks fix):
# first compares the REGISTERED payload for (event, matcher, source) against # gstack-settings-hook prune-stale # prune dead gstack hook items
# the requested one. Identical → no write, no backup ("unchanged"). Different # gstack-settings-hook prune-stale --repoint <root> # re-point gstack items at <root>, prune still-dead
# → the single matching entry is replaced via one atomic tmp+rename, so a # gstack-settings-hook prune-stale --all # remove ALL gstack hook items (uninstall sweep)
# failed update can never leave zero or two registrations. This is what heals
# a stale absolute hook path (e.g. a deleted dev worktree) baked into
# settings.json by an earlier setup — presence-only dedup never re-pointed it.
# #
# Every add-event/remove-source writes a backup to ~/.claude/settings.json.bak.<ts> # ensure-event is the update-in-place verb: same flags as add-event, but keyed
# before mutating (Codex correction — silent settings.json mutation is wrong); # on (event, source) — any entry carrying our source tag for the event is THE
# ensure-event backs up only when it actually mutates, so a no-op re-run of # registration to compare/update, so a matcher change re-points in place
# ./setup doesn't churn backup files. # instead of pushing a second entry, and duplicate same-source twins from the
# old matcher-keyed dedup collapse to one (reported on stderr). Identical
# payload → no write, no backup ("unchanged"); a re-run of ./setup stays a
# true no-op. This heals a stale absolute hook path (e.g. a deleted dev
# worktree) baked into settings.json by an earlier setup.
# #
# Dedup: legacy `add`/`remove` dedupe by the historical `gstack-session-update` # Ownership model (KNOWN_HOOKS identity table): a hook ITEM is gstack-owned iff
# substring. Schema-aware `add-event` dedupes by (event, matcher, _gstack_source) so # its command basename + relpath suffix + event (+ matcher where the table row
# multiple gstack registrations (plan-tune, ...) don't collide. # defines one) match a table row. Entry-level `_gstack_source` tags are
# best-effort metadata — Claude Code strips unknown keys when it rewrites
# settings.json, so identity is intrinsic (the table), never tag-only. A tag
# NEVER claims foreign items: in a tagged multi-item entry, unrecognized items
# are always preserved; only a tagged SINGLE-item entry with no table match is
# treated as an owned legacy stray.
# #
# Writes atomically: .tmp + rename to prevent corruption on crash/disk-full. # Mutation safety:
# - every mutation runs under a mkdir lock (<settings>.lock/) with an owner
# token; release is ownership-checked; stale locks (>30s) are taken over
# via atomic rename. On lock give-up the mutation is SKIPPED with a warning
# (the next setup retries — the system is convergent).
# - parse failure fails CLOSED: a corrupt settings.json is never overwritten
# (only ENOENT starts fresh). Exit 3.
# - backup-on-change: a backup (unique name, .bak-latest pointer) is written
# only when the file content actually changes. No-op mutations are silent
# on disk. `rollback` is a single-step undo of the last real mutation.
# - writes are atomic: unique tmp file + rename (a fixed tmp name would let
# two concurrent writers rename a half-written file into place).
set -euo pipefail set -euo pipefail
ACTION="${1:-}" ACTION="${1:-}"
@@ -48,6 +68,7 @@ Usage:
gstack-settings-hook ensure-event --event <name> --command <cmd> --source <tag> [--matcher <re>] [--timeout <s>] gstack-settings-hook ensure-event --event <name> --command <cmd> --source <tag> [--matcher <re>] [--timeout <s>]
gstack-settings-hook remove-source --source <tag> gstack-settings-hook remove-source --source <tag>
gstack-settings-hook diff-event --event <name> --command <cmd> --source <tag> [--matcher <re>] [--timeout <s>] gstack-settings-hook diff-event --event <name> --command <cmd> --source <tag> [--matcher <re>] [--timeout <s>]
gstack-settings-hook prune-stale [--repoint <root>] [--all]
gstack-settings-hook rollback gstack-settings-hook rollback
gstack-settings-hook list-sources gstack-settings-hook list-sources
EOF EOF
@@ -59,41 +80,258 @@ if ! command -v bun >/dev/null 2>&1; then
exit 1 exit 1
fi fi
backup_settings() { # ─── Shared JS prelude ────────────────────────────────────────────────
if [ -f "$SETTINGS_FILE" ]; then # Single source of truth for the KNOWN_HOOKS identity table and the
local ts # ownership/liveness/IO helpers, interpolated into EVERY bun -e script as
ts=$(date +%Y%m%d-%H%M%S) # bun -e "$_HOOK_JS_PRELUDE"' <single-quoted body>'
cp "$SETTINGS_FILE" "$SETTINGS_FILE.bak.$ts" # so the dedupe key and the prune predicate cannot drift. The prelude MUST NOT
echo "$SETTINGS_FILE.bak.$ts" > "$SETTINGS_FILE.bak-latest" # contain single quotes (this assignment is single-quoted).
_HOOK_JS_PRELUDE='
// Umbrella fail-closed guard: bun in -e mode swallows uncaught exceptions
// thrown after a require() call and exits 0 (verified on bun 1.3.13;
// uncaughtException handlers never fire in -e mode either). Every script body
// below runs inside gsMain so a runtime throw becomes a LOUD exit 4 instead
// of a silent success that reports a mutation as clean.
function gsMain(fn) {
try {
fn();
} catch (e) {
process.stderr.write("gstack-settings-hook: internal error (" + (e && e.message) + ") -- refusing to mutate\n");
process.exit(4);
}
}
var KNOWN_HOOKS = {
"question-log-hook": { source: "plan-tune-cathedral", event: "PostToolUse", matcher: "(AskUserQuestion|mcp__.*__AskUserQuestion)", relpath: "hosts/claude/hooks/question-log-hook" },
"question-preference-hook": { source: "plan-tune-cathedral", event: "PreToolUse", matcher: "(AskUserQuestion|mcp__.*__AskUserQuestion)", relpath: "hosts/claude/hooks/question-preference-hook" },
"auq-error-fallback-hook": { source: "auq-error-fallback", event: "PostToolUse", matcher: "(AskUserQuestion|mcp__.*__AskUserQuestion)", relpath: "hosts/claude/hooks/auq-error-fallback-hook" },
"timeline-stop-hook": { source: "gstack-timeline-stop", event: "Stop", matcher: "", relpath: "hosts/claude/hooks/timeline-stop-hook" },
"gstack-session-update": { source: "gstack-session-update", event: "SessionStart", matcher: "", relpath: "bin/gstack-session-update" },
"gstack-verify-gate": { source: "verify-gate", event: "Stop", matcher: "", relpath: "bin/gstack-verify-gate" }
};
function gsHadBashPrefix(c) { return String(c == null ? "" : c).trim().indexOf("bash ") === 0; }
function gsStripWrap(c) {
var s = String(c == null ? "" : c).trim();
if (s.indexOf("bash ") === 0) s = s.slice(5).trim();
if (s.length >= 2 && s.charAt(0) === "\"" && s.charAt(s.length - 1) === "\"") {
// Unescape the gsQuoteCmd form so a re-pointed escaped command is still
// recognized as ours on later passes (identity round-trips).
s = s.slice(1, -1).replace(/\\([\\"$\x60])/g, "$1");
}
// Separator normalization is Windows-only (a rare-but-legal Unix path
// containing a backslash must not be rewritten and mis-stat-ed).
if (process.platform === "win32" || /^[A-Za-z]:[\\\/]/.test(s)) {
s = s.replace(/\\/g, "/");
}
return s;
}
function gsBaseOf(c) { var p = gsStripWrap(c); return p.split("/").pop(); }
function gsOwnedRow(cmd, event, matcher) {
var p = gsStripWrap(cmd);
var b = p.split("/").pop();
// hasOwnProperty guard: a foreign hook basename like "toString" or
// "constructor" must not resolve to an inherited Object.prototype member.
var row = Object.prototype.hasOwnProperty.call(KNOWN_HOOKS, b) ? KNOWN_HOOKS[b] : null;
if (!row) return null;
if (p !== row.relpath && p.slice(-(row.relpath.length + 1)) !== "/" + row.relpath) return null;
if (row.event !== event) return null;
if (row.matcher && (matcher || "") !== row.matcher) return null;
return row;
}
function gsWinPath(p) {
// Git Bash writes MSYS-form paths (/c/Users/...) into settings.json, but
// native bun resolves them drive-relative (C:\c\Users\...) -- translate for
// fs calls only; stored commands keep the form the firing shell expects.
if (process.platform === "win32" && /^\/[A-Za-z]\//.test(p)) {
return p.charAt(1) + ":" + p.slice(2);
}
return p;
}
function gsIsAlive(cmd) {
var fs = require("fs");
var p = gsWinPath(gsStripWrap(cmd));
if (!p) return false;
try {
if (process.platform === "win32") return fs.existsSync(p);
var st = fs.statSync(p);
if (!st.isFile()) return false;
fs.accessSync(p, fs.constants.X_OK);
return true;
} catch (e) {
// Only provable absence counts as dead. EACCES/EIO/unmounted-volume
// errors are transient unreachability -- pruning on those would be a
// one-way ratchet, so conservatively treat the item as alive.
var code = e && e.code;
return !(code === "ENOENT" || code === "ENOTDIR");
}
}
function gsQuoteCmd(target, hadBash) {
// Shell-metacharacter hardening: the command string is executed by a shell
// when Claude Code fires the hook, so a path containing $, backtick (x60 --
// written as an escape so the prelude itself stays backtick-free), or a
// quote must be neutralized, not just space-wrapped.
var needsQuote = /[\s$\x60"\\]/.test(target);
var quoted = needsQuote
? "\"" + target.replace(/[\\"$\x60]/g, function (ch) { return "\\" + ch; }) + "\""
: target;
return (hadBash ? "bash " : "") + quoted;
}
function gsRotateBackups(settingsPath, keep) {
// Backup files are change-gated but unbounded across months of setups --
// keep the most recent N so ~/.claude does not accumulate forever.
var fs = require("fs");
var path = require("path");
try {
var dir = path.dirname(settingsPath);
var base = path.basename(settingsPath) + ".bak.";
var baks = fs.readdirSync(dir)
.filter(function (f) { return f.indexOf(base) === 0; })
.map(function (f) {
var full = path.join(dir, f);
var m = 0;
try { m = fs.statSync(full).mtimeMs; } catch (e3) {}
return { full: full, m: m };
})
.sort(function (a, b) { return a.m - b.m; });
for (var i = 0; i < baks.length - keep; i++) {
try { fs.unlinkSync(baks[i].full); } catch (e2) {}
}
} catch (e) {}
}
function gsLoadSettings(path) {
var fs = require("fs");
var raw = null;
try { raw = fs.readFileSync(path, "utf8"); }
catch (e) {
if (e && e.code === "ENOENT") return { settings: {}, existed: false };
process.stderr.write("gstack-settings-hook: cannot read " + path + ": " + e.message + " -- refusing to mutate\n");
process.exit(3);
}
try { return { settings: JSON.parse(raw), existed: true }; }
catch (e) {
process.stderr.write("gstack-settings-hook: " + path + " is not valid JSON (" + e.message + ") -- refusing to mutate; fix or restore it (.bak files / rollback)\n");
process.exit(3);
}
}
function gsWriteIfChanged(path, beforeText, settings, existed) {
var fs = require("fs");
var afterText = JSON.stringify(settings, null, 2);
if (afterText === beforeText) return false;
// Preserve the live file mode across the tmp+rename (settings.json can
// carry API keys in its env block -- a user-tightened 0600 must never be
// silently broadened to the default 0644). Fresh files start 0600.
var mode = 0o600;
if (existed) {
try { mode = fs.statSync(path).mode & 0o777; } catch (e) {}
fs.copyFileSync(path, process.env.GSTACK_BACKUP_PATH);
fs.writeFileSync(process.env.GSTACK_BAK_LATEST, process.env.GSTACK_BACKUP_PATH + "\n");
gsRotateBackups(path, 10);
}
var tmp = process.env.GSTACK_TMP_PATH;
fs.writeFileSync(tmp, afterText + "\n");
try { fs.chmodSync(tmp, mode); } catch (e) {}
fs.renameSync(tmp, path);
return true;
}
'
# ─── Mutation lock ────────────────────────────────────────────────────
# Accepted tradeoffs (adversarial-reviewed): (1) the lock serializes gstack
# writers only -- Claude Code rewrites settings.json without honoring it, so a
# lost update against a live session remains possible (convergent: the next
# heal repairs); (2) stale takeover is mtime-based -- a holder legitimately
# slower than the stale window can be stolen from, and a fresh crash stalls
# callers for the give-up window. PID-aware takeover was considered and
# deferred (owner file already carries $$ if it becomes worth it).
_LOCK_DIR="$SETTINGS_FILE.lock"
_LOCK_TOKEN=""
_release_lock() {
if [ -n "$_LOCK_TOKEN" ] && [ -d "$_LOCK_DIR" ]; then
# Ownership-checked: never remove a lock another process re-acquired
# after a stale takeover.
_OWNER_CONTENT="$(cat "$_LOCK_DIR/owner" 2>/dev/null || true)"
if [ "$_OWNER_CONTENT" = "$_LOCK_TOKEN" ] || [ -z "$_OWNER_CONTENT" ]; then
rm -rf "$_LOCK_DIR" 2>/dev/null || true
fi
fi fi
_LOCK_TOKEN=""
} }
# --- legacy SessionStart add/remove (backwards compat) ----------------- _acquire_lock() {
# GSTACK_SETTINGS_LOCK_TIMEOUT_MS: test-only override for the give-up
# (a contention test should not stall the suite for 10 real seconds).
local waited_ms=0 token stale mtime now
local give_up_ms="${GSTACK_SETTINGS_LOCK_TIMEOUT_MS:-10000}"
local stale_after_s=30 # lock older than this belongs to a crashed holder
local poll_ms=50 # retry cadence; sleep below derives from this
token="$$-$RANDOM$RANDOM"
while :; do
if mkdir "$_LOCK_DIR" 2>/dev/null; then
printf '%s\n' "$token" > "$_LOCK_DIR/owner" 2>/dev/null || true
_LOCK_TOKEN="$token"
trap _release_lock EXIT
trap 'exit 129' INT
trap 'exit 143' TERM
return 0
fi
# mkdir failed but no lock dir exists: NOT contention (unwritable parent,
# read-only fs, missing directory) -- waiting cannot help, so give up
# loudly now instead of spinning out the full timeout. (Tiny race: a
# contender could acquire+release between our mkdir and this check; that
# transient reads as an environment failure and the next run converges.)
if [ ! -e "$_LOCK_DIR" ]; then
echo "gstack-settings-hook: cannot create lock $_LOCK_DIR (unwritable parent?) -- skipping this mutation, exit 5" >&2
return 1
fi
# Stale takeover: atomic rename means exactly one contender wins; the
# loser loops and re-contends against the winner's fresh mkdir.
# GNU stat (-c %Y) first: on Linux, BSD-style `stat -f %m` prints a
# multi-line FILESYSTEM block to stdout before failing, and the || chain
# would capture that garbage alongside the real epoch. BSD stat rejects
# -c with no stdout, so macOS falls through cleanly. The numeric guard
# below makes any residual garbage inert (no takeover, normal give-up)
# instead of an arithmetic abort under set -e.
mtime=$(stat -c %Y "$_LOCK_DIR" 2>/dev/null || stat -f %m "$_LOCK_DIR" 2>/dev/null || echo "")
case "$mtime" in *[!0-9]*) mtime="" ;; esac
now=$(date +%s)
if [ -n "$mtime" ] && [ $(( now - mtime )) -gt "$stale_after_s" ]; then
stale="$_LOCK_DIR.stale.$$-$RANDOM"
if mv "$_LOCK_DIR" "$stale" 2>/dev/null; then rm -rf "$stale" 2>/dev/null || true; fi
continue
fi
if [ "$waited_ms" -ge "$give_up_ms" ]; then
echo "gstack-settings-hook: could not acquire lock $_LOCK_DIR -- skipping this mutation, exit 5 (the next setup retries it)" >&2
return 1
fi
sleep "$(printf '0.%03d' "$poll_ms")"
waited_ms=$(( waited_ms + poll_ms ))
done
}
# Per-invocation unique backup + tmp paths, exported for gsWriteIfChanged.
_mutation_env() {
GSTACK_BACKUP_PATH="$SETTINGS_FILE.bak.$(date +%Y%m%d-%H%M%S).$$.$RANDOM"
GSTACK_BAK_LATEST="$SETTINGS_FILE.bak-latest"
GSTACK_TMP_PATH="$SETTINGS_FILE.tmp.$$.$RANDOM"
export GSTACK_BACKUP_PATH GSTACK_BAK_LATEST GSTACK_TMP_PATH
}
case "$ACTION" in case "$ACTION" in
# --- legacy SessionStart add/remove (backwards compat) -----------------
add) add)
HOOK_CMD="${2:-}" HOOK_CMD="${2:-}"
if [ -z "$HOOK_CMD" ]; then if [ -z "$HOOK_CMD" ]; then
echo "Usage: gstack-settings-hook add <hook-command>" >&2 echo "Usage: gstack-settings-hook add <hook-command>" >&2
exit 1 exit 1
fi fi
backup_settings _acquire_lock || exit 5
GSTACK_SETTINGS_PATH="$SETTINGS_FILE" GSTACK_HOOK_CMD="$HOOK_CMD" bun -e ' _mutation_env
const fs = require("fs"); GSTACK_SETTINGS_PATH="$SETTINGS_FILE" GSTACK_HOOK_CMD="$HOOK_CMD" bun -e "$_HOOK_JS_PRELUDE"'gsMain(function () {
const settingsPath = process.env.GSTACK_SETTINGS_PATH; const settingsPath = process.env.GSTACK_SETTINGS_PATH;
const hookCmd = process.env.GSTACK_HOOK_CMD; const hookCmd = process.env.GSTACK_HOOK_CMD;
let settings = {}; const loaded = gsLoadSettings(settingsPath);
// An EXISTING file that does not parse must never be rewritten: the const settings = loaded.settings;
// old catch{} folded it to {} and the atomic write below replaced the const before = JSON.stringify(settings, null, 2);
// user permissions/env/other hooks with just ours. Refuse loudly.
if (fs.existsSync(settingsPath)) {
try { settings = JSON.parse(fs.readFileSync(settingsPath, "utf8")); }
catch (e) {
console.error("error: " + settingsPath + " exists but is not valid JSON (" +
(e && e.message ? e.message : e) + "); refusing to rewrite it. Fix or move the file, then re-run.");
process.exit(1);
}
}
if (!settings.hooks) settings.hooks = {}; if (!settings.hooks) settings.hooks = {};
if (!settings.hooks.SessionStart) settings.hooks.SessionStart = []; if (!settings.hooks.SessionStart) settings.hooks.SessionStart = [];
const exists = settings.hooks.SessionStart.some(entry => const exists = settings.hooks.SessionStart.some(entry =>
@@ -104,9 +342,8 @@ case "$ACTION" in
hooks: [{ type: "command", command: hookCmd }] hooks: [{ type: "command", command: hookCmd }]
}); });
} }
const tmp = settingsPath + ".tmp." + process.pid; // per-process: parallel writers must not share a tmp gsWriteIfChanged(settingsPath, before, settings, loaded.existed);
fs.writeFileSync(tmp, JSON.stringify(settings, null, 2) + "\n"); });
fs.renameSync(tmp, settingsPath);
' '
;; ;;
@@ -117,23 +354,34 @@ case "$ACTION" in
exit 1 exit 1
fi fi
[ -f "$SETTINGS_FILE" ] || exit 1 [ -f "$SETTINGS_FILE" ] || exit 1
backup_settings _acquire_lock || exit 5
GSTACK_SETTINGS_PATH="$SETTINGS_FILE" bun -e ' _mutation_env
const fs = require("fs"); GSTACK_SETTINGS_PATH="$SETTINGS_FILE" bun -e "$_HOOK_JS_PRELUDE"'gsMain(function () {
const settingsPath = process.env.GSTACK_SETTINGS_PATH; const settingsPath = process.env.GSTACK_SETTINGS_PATH;
let settings = {}; const loaded = gsLoadSettings(settingsPath);
try { settings = JSON.parse(fs.readFileSync(settingsPath, "utf8")); } catch { process.exit(0); } const settings = loaded.settings;
const before = JSON.stringify(settings, null, 2);
if (settings.hooks && settings.hooks.SessionStart) { if (settings.hooks && settings.hooks.SessionStart) {
settings.hooks.SessionStart = settings.hooks.SessionStart.filter(entry => // Item-aware: remove only matching hook items; foreign items in the
!(entry.hooks && entry.hooks.some(h => h.command && h.command.includes("gstack-session-update"))) // same entry survive; an entry is dropped ONLY when this pass emptied
); // it. Malformed/foreign entries (hooks absent, non-array, or already
// empty) are preserved verbatim -- they are not ours to judge.
settings.hooks.SessionStart = settings.hooks.SessionStart
.filter(entry => {
if (!Array.isArray(entry.hooks)) return true;
const beforeLen = entry.hooks.length;
entry.hooks = entry.hooks.filter(h =>
!(h && h.command && h.command.includes("gstack-session-update"))
);
if (entry.hooks.length === 0 && beforeLen > 0) return false;
return true;
});
if (settings.hooks.SessionStart.length === 0) delete settings.hooks.SessionStart; if (settings.hooks.SessionStart.length === 0) delete settings.hooks.SessionStart;
if (Object.keys(settings.hooks).length === 0) delete settings.hooks; if (Object.keys(settings.hooks).length === 0) delete settings.hooks;
} }
const tmp = settingsPath + ".tmp." + process.pid; // per-process: parallel writers must not share a tmp gsWriteIfChanged(settingsPath, before, settings, loaded.existed);
fs.writeFileSync(tmp, JSON.stringify(settings, null, 2) + "\n"); });
fs.renameSync(tmp, settingsPath); '
' 2>/dev/null
;; ;;
add-event|diff-event|ensure-event) add-event|diff-event|ensure-event)
@@ -161,13 +409,15 @@ case "$ACTION" in
SessionStart|PreToolUse|PostToolUse|UserPromptSubmit|Stop|Notification) ;; SessionStart|PreToolUse|PostToolUse|UserPromptSubmit|Stop|Notification) ;;
*) echo "invalid --event '$EVENT'; must be one of SessionStart|PreToolUse|PostToolUse|UserPromptSubmit|Stop|Notification" >&2; exit 1 ;; *) echo "invalid --event '$EVENT'; must be one of SessionStart|PreToolUse|PostToolUse|UserPromptSubmit|Stop|Notification" >&2; exit 1 ;;
esac esac
if [ "$ACTION" = "add-event" ]; then
backup_settings
fi
DIFF_ONLY="" DIFF_ONLY=""
if [ "$ACTION" = "diff-event" ]; then DIFF_ONLY=1; fi
ENSURE="" ENSURE=""
if [ "$ACTION" = "ensure-event" ]; then ENSURE=1; fi if [ "$ACTION" = "diff-event" ]; then
DIFF_ONLY=1
else
[ "$ACTION" = "ensure-event" ] && ENSURE=1
_acquire_lock || exit 5
fi
_mutation_env
GSTACK_SETTINGS_PATH="$SETTINGS_FILE" \ GSTACK_SETTINGS_PATH="$SETTINGS_FILE" \
GSTACK_EVENT="$EVENT" \ GSTACK_EVENT="$EVENT" \
GSTACK_COMMAND="$COMMAND" \ GSTACK_COMMAND="$COMMAND" \
@@ -176,8 +426,7 @@ case "$ACTION" in
GSTACK_TIMEOUT="$TIMEOUT" \ GSTACK_TIMEOUT="$TIMEOUT" \
GSTACK_DIFF_ONLY="$DIFF_ONLY" \ GSTACK_DIFF_ONLY="$DIFF_ONLY" \
GSTACK_ENSURE="$ENSURE" \ GSTACK_ENSURE="$ENSURE" \
bun -e ' bun -e "$_HOOK_JS_PRELUDE"'gsMain(function () {
const fs = require("fs");
const settingsPath = process.env.GSTACK_SETTINGS_PATH; const settingsPath = process.env.GSTACK_SETTINGS_PATH;
const event = process.env.GSTACK_EVENT; const event = process.env.GSTACK_EVENT;
const cmd = process.env.GSTACK_COMMAND; const cmd = process.env.GSTACK_COMMAND;
@@ -187,64 +436,98 @@ case "$ACTION" in
const diffOnly = process.env.GSTACK_DIFF_ONLY === "1"; const diffOnly = process.env.GSTACK_DIFF_ONLY === "1";
const ensure = process.env.GSTACK_ENSURE === "1"; const ensure = process.env.GSTACK_ENSURE === "1";
let settings = {}; const loaded = gsLoadSettings(settingsPath);
// An EXISTING file that does not parse must never be rewritten: the const settings = loaded.settings;
// old catch{} folded it to {} and the atomic write below replaced the
// user permissions/env/other hooks with just ours. Refuse loudly.
if (fs.existsSync(settingsPath)) {
try { settings = JSON.parse(fs.readFileSync(settingsPath, "utf8")); }
catch (e) {
console.error("error: " + settingsPath + " exists but is not valid JSON (" +
(e && e.message ? e.message : e) + "); refusing to rewrite it. Fix or move the file, then re-run.");
process.exit(1);
}
}
const before = JSON.stringify(settings, null, 2); const before = JSON.stringify(settings, null, 2);
if (!settings.hooks) settings.hooks = {}; if (!settings.hooks) settings.hooks = {};
if (!settings.hooks[event]) settings.hooks[event] = []; if (!settings.hooks[event]) settings.hooks[event] = [];
// Identity key is (event, source): any existing entry carrying OUR // add-event is the single quoting authority: normalize the command
// source tag for this event IS the entry to compare/update — a matcher // through the same round-trip the healer uses so metachar paths are
// change must update it in place, never push a SECOND gstack entry // registered in the escaped-quoted form from the start (a caller-side
// (the old key included the matcher, so a future matcher change would // quoting step would drift per call site).
// have duplicated the registration). Untagged legacy entries are still const cmdNorm = gsQuoteCmd(gsStripWrap(cmd), gsHadBashPrefix(cmd));
// adopted when both matcher and command line up. const hookEntry = { type: "command", command: cmdNorm };
const matchesEntry = (entry) => {
if (entry._gstack_source === source) return true;
const sameMatcher = (entry.matcher || "") === matcher;
const sameCommand = entry.hooks && entry.hooks[0] && entry.hooks[0].command === cmd;
return sameMatcher && sameCommand;
};
// Collect ALL matches, not just the first: pre-existing installs can
// carry two entries with the same (event, _gstack_source) from the old
// matcher-keyed dedup. `.find()` updated only the first and left the
// stale twin running forever. Keep ONE canonical entry (the first),
// remove the rest in the same atomic write.
const matched = settings.hooks[event].filter(matchesEntry);
let existing = matched.length > 0 ? matched[0] : undefined;
let collapsed = 0;
if (matched.length > 1) {
const extras = new Set(matched.slice(1));
settings.hooks[event] = settings.hooks[event].filter((e) => !extras.has(e));
collapsed = matched.length - 1;
}
const hookEntry = { type: "command", command: cmd };
if (timeoutRaw) { if (timeoutRaw) {
const n = Number(timeoutRaw); const n = Number(timeoutRaw);
if (Number.isFinite(n) && n > 0) hookEntry.timeout = n; if (Number.isFinite(n) && n > 0) hookEntry.timeout = n;
} }
if (existing) { // Identity is item-level and two-layer:
existing.hooks = [hookEntry]; // - a same-event entry carrying OUR source tag is ours even under a
existing._gstack_source = source; // DIFFERENT matcher (a matcher change must re-point in place, never
// Keep the matcher current too — under the (event, source) key the // push a second registration -- the old matcher-keyed dedupe
// matched entry may carry a stale matcher. // duplicated the entry on every matcher change), and
if (matcher) existing.matcher = matcher; // - a same-matcher entry containing our exact command or a table-owned
else delete existing.matcher; // item with our basename is OUR registration under a stale path
} else { // (Claude Code strips _gstack_source on its own rewrites, so
// tag-based dedupe degrades).
// A tag never claims foreign items: in a multi-item entry only the
// identified item is touched; entries where no item can be identified
// as ours are left alone entirely.
const ourItemIdx = (entry) => {
if (!Array.isArray(entry.hooks)) return -1;
let idx = entry.hooks.findIndex(h => h && h.command === cmdNorm);
if (idx < 0) {
idx = entry.hooks.findIndex(h => {
if (!h || !h.command) return false;
const row = gsOwnedRow(h.command, event, entry.matcher || "");
return !!row && gsBaseOf(h.command) === gsBaseOf(cmdNorm);
});
}
if (idx < 0 && entry._gstack_source === source && entry.hooks.length === 1 && entry.hooks[0] && entry.hooks[0].command) idx = 0;
return idx;
};
const cands = [];
for (const entry of settings.hooks[event]) {
const idx = ourItemIdx(entry);
if (idx < 0) continue;
if (entry._gstack_source === source || (entry.matcher || "") === matcher) cands.push({ entry: entry, idx: idx });
}
let placed = false;
let collapsed = 0;
if (cands.length > 0) {
const primary = cands[0];
if ((primary.entry.matcher || "") === matcher) {
primary.entry.hooks[primary.idx] = hookEntry;
// Mixed-version ratchet guard: tag ONLY single-item entries (the
// item we just placed). Old gstack versions in sibling worktrees do
// entry-level ownership (remove-source deletes the whole tagged
// entry; add-event clobbers entry.hooks wholesale) -- a tag on a
// mixed entry hands them permission to destroy the user items in it.
if (primary.entry.hooks.length === 1) primary.entry._gstack_source = source;
else delete primary.entry._gstack_source;
placed = true;
} else if (primary.entry.hooks.length === 1) {
// Tagged single-item entry under a stale matcher: the entry is
// exclusively ours, so re-point payload AND matcher in place.
primary.entry.hooks = [hookEntry];
primary.entry._gstack_source = source;
if (matcher) primary.entry.matcher = matcher;
else delete primary.entry.matcher;
placed = true;
} else {
// Our item sits in a MIXED entry under a different matcher: the
// entry-level matcher also governs the foreign siblings, so pull
// our item out and register separately below.
primary.entry.hooks.splice(primary.idx, 1);
delete primary.entry._gstack_source;
}
// Duplicate registrations (e.g. same-source twins from the old
// matcher-keyed dedupe): remove OUR item from every other candidate.
for (let i = 1; i < cands.length; i++) {
cands[i].entry.hooks.splice(cands[i].idx, 1);
delete cands[i].entry._gstack_source;
collapsed++;
}
// Drop only entries WE emptied; started-empty foreign entries are
// never candidates, so they are preserved verbatim.
settings.hooks[event] = settings.hooks[event].filter(e =>
!(Array.isArray(e.hooks) && e.hooks.length === 0 && cands.some(c => c.entry === e)));
}
if (!placed) {
const newEntry = { _gstack_source: source, hooks: [hookEntry] }; const newEntry = { _gstack_source: source, hooks: [hookEntry] };
if (matcher) newEntry.matcher = matcher; if (matcher) newEntry.matcher = matcher;
settings.hooks[event].push(newEntry); settings.hooks[event].push(newEntry);
@@ -261,48 +544,22 @@ case "$ACTION" in
} }
if (ensure && before === after) { if (ensure && before === after) {
// Registered payload already matches the canonical one no write, no // Registered payload already matches the canonical one -- no write,
// backup, no churn. Re-running ./setup stays a true no-op. // no backup, no churn. Re-running ./setup stays a true no-op.
console.log("OK: " + event + " hook unchanged (source: " + source + ")"); console.log("OK: " + event + " hook unchanged (source: " + source + ")");
process.exit(0); process.exit(0);
} }
try { gsWriteIfChanged(settingsPath, before, settings, loaded.existed);
if (ensure && fs.existsSync(settingsPath)) {
// Mirrors backup_settings (bash) — but only when a write actually
// happens, so a no-op ensure-event never creates backup files.
const d = new Date();
const pad = (n) => String(n).padStart(2, "0");
const ts = "" + d.getFullYear() + pad(d.getMonth() + 1) + pad(d.getDate()) +
"-" + pad(d.getHours()) + pad(d.getMinutes()) + pad(d.getSeconds());
fs.copyFileSync(settingsPath, settingsPath + ".bak." + ts);
fs.writeFileSync(settingsPath + ".bak-latest", settingsPath + ".bak." + ts + "\n");
}
// Atomic tmp+rename: the settings file is either the old JSON (with
// the old single registration) or the new JSON (with the replaced
// one) — a failed update can never leave zero or two registrations.
// Per-process tmp suffix: a fixed settings.json.tmp let two parallel
// writers consume one another. (No apostrophes here: this JS lives
// inside a bash single-quoted string.)
const tmp = settingsPath + ".tmp." + process.pid;
fs.writeFileSync(tmp, after + "\n");
fs.renameSync(tmp, settingsPath);
} catch (e) {
// Explicit catch + exit 1: bun -e has been observed (1.3.13) to turn
// an uncaught sync fs error into a SILENT exit 0, which would let a
// failed update masquerade as success to the caller.
console.error("error: could not update " + settingsPath + ": " + (e && e.message ? e.message : e));
process.exit(1);
}
if (collapsed > 0) { if (collapsed > 0) {
console.error("collapsed " + collapsed + " duplicate (event, source) hook entr" + (collapsed === 1 ? "y" : "ies") + " for " + event + " (source: " + source + ")"); console.error("collapsed " + collapsed + " duplicate (event, source) hook entr" + (collapsed === 1 ? "y" : "ies") + " for " + event + " (source: " + source + ")");
} }
if (ensure && existing) { if (ensure && cands.length > 0) {
console.log("OK: " + event + " hook re-pointed (source: " + source + ")"); console.log("OK: " + event + " hook re-pointed (source: " + source + ")");
} else { } else {
console.log("OK: " + event + " hook registered (source: " + source + ")"); console.log("OK: " + event + " hook registered (source: " + source + ")");
} }
});
' '
;; ;;
@@ -320,26 +577,199 @@ case "$ACTION" in
exit 1 exit 1
fi fi
[ -f "$SETTINGS_FILE" ] || exit 0 [ -f "$SETTINGS_FILE" ] || exit 0
backup_settings _acquire_lock || exit 5
GSTACK_SETTINGS_PATH="$SETTINGS_FILE" GSTACK_SOURCE="$SOURCE" bun -e ' _mutation_env
const fs = require("fs"); GSTACK_SETTINGS_PATH="$SETTINGS_FILE" GSTACK_SOURCE="$SOURCE" bun -e "$_HOOK_JS_PRELUDE"'gsMain(function () {
const settingsPath = process.env.GSTACK_SETTINGS_PATH; const settingsPath = process.env.GSTACK_SETTINGS_PATH;
const source = process.env.GSTACK_SOURCE; const source = process.env.GSTACK_SOURCE;
let settings = {}; const loaded = gsLoadSettings(settingsPath);
try { settings = JSON.parse(fs.readFileSync(settingsPath, "utf8")); } catch { process.exit(0); } const settings = loaded.settings;
if (!settings.hooks) { process.exit(0); } if (!settings.hooks) { console.log("OK: removed 0 hook entry/entries tagged source=" + source); process.exit(0); }
const before = JSON.stringify(settings, null, 2);
let removed = 0; let removed = 0;
for (const event of Object.keys(settings.hooks)) { for (const event of Object.keys(settings.hooks)) {
const before = settings.hooks[event].length; const kept = [];
settings.hooks[event] = settings.hooks[event].filter(entry => entry._gstack_source !== source); for (const entry of settings.hooks[event]) {
removed += before - settings.hooks[event].length; if (entry._gstack_source !== source) { kept.push(entry); continue; }
if (!Array.isArray(entry.hooks) || entry.hooks.length === 0) { removed++; continue; }
// Item-aware: remove table-owned items (or the single item of a
// tagged legacy-stray entry); foreign items in a tagged multi-item
// entry are preserved and the tag is dropped with the last owned item.
const single = entry.hooks.length === 1;
const remain = entry.hooks.filter(h => {
// Command-less items cannot be ours (gstack only writes
// type:command items) -- preserve them.
const owned = (h && h.command)
? gsOwnedRow(h.command, event, entry.matcher || "") !== null
: false;
// The single-item stray claim requires a command item (gstack
// never writes command-less items).
if (owned || (single && h && h.command)) { removed++; return false; }
return true;
});
if (remain.length === 0) continue;
entry.hooks = remain;
delete entry._gstack_source;
kept.push(entry);
}
settings.hooks[event] = kept;
if (settings.hooks[event].length === 0) delete settings.hooks[event]; if (settings.hooks[event].length === 0) delete settings.hooks[event];
} }
if (Object.keys(settings.hooks).length === 0) delete settings.hooks; if (Object.keys(settings.hooks).length === 0) delete settings.hooks;
const tmp = settingsPath + ".tmp." + process.pid; // per-process: parallel writers must not share a tmp gsWriteIfChanged(settingsPath, before, settings, loaded.existed);
fs.writeFileSync(tmp, JSON.stringify(settings, null, 2) + "\n");
fs.renameSync(tmp, settingsPath);
console.log("OK: removed " + removed + " hook entry/entries tagged source=" + source); console.log("OK: removed " + removed + " hook entry/entries tagged source=" + source);
});
'
;;
prune-stale)
REPOINT_ROOT=""
PRUNE_ALL=""
shift
while [ $# -gt 0 ]; do
case "$1" in
--repoint) REPOINT_ROOT="$2"; shift 2 ;;
--all) PRUNE_ALL=1; shift ;;
*) echo "unknown flag: $1" >&2; exit 1 ;;
esac
done
if [ -n "$REPOINT_ROOT" ] && [ -n "$PRUNE_ALL" ]; then
echo "prune-stale: --repoint and --all are mutually exclusive" >&2
exit 1
fi
if [ ! -f "$SETTINGS_FILE" ]; then
echo "OK: removed 0 gstack hook entries (repointed 0)"
exit 0
fi
# Explicit plan_tune_hooks opt-out: dead plan-tune items are still pruned,
# but live ones are never re-pointed (re-activation needs consent; removal
# of live ones is --no-team/uninstall territory).
GSTACK_PT_OPTOUT=0
# The opt-out lookup is repoint/heal-only — the --all sweep never
# re-points, and uninstall must not depend on a sibling gstack-config.
if [ -z "$PRUNE_ALL" ]; then
_CFG_BIN="$(cd "$(dirname "$0")" && pwd)/gstack-config"
if [ -x "$_CFG_BIN" ] && "$_CFG_BIN" has plan_tune_hooks 2>/dev/null; then
_PT_VAL=$("$_CFG_BIN" get plan_tune_hooks 2>/dev/null || true)
_PT_VAL=$(printf '%s' "$_PT_VAL" | tr '[:upper:]' '[:lower:]' | tr -d '[:space:]')
case "$_PT_VAL" in
n|no|false|skip|off|0) GSTACK_PT_OPTOUT=1 ;;
esac
fi
fi
_acquire_lock || exit 5
_mutation_env
GSTACK_SETTINGS_PATH="$SETTINGS_FILE" \
GSTACK_REPOINT_ROOT="$REPOINT_ROOT" \
GSTACK_PRUNE_ALL="$PRUNE_ALL" \
GSTACK_PT_OPTOUT="$GSTACK_PT_OPTOUT" \
GSTACK_SWEEP_EXCLUDE_SOURCES="${GSTACK_SWEEP_EXCLUDE_SOURCES:-}" \
bun -e "$_HOOK_JS_PRELUDE"'gsMain(function () {
const settingsPath = process.env.GSTACK_SETTINGS_PATH;
const root = (process.env.GSTACK_REPOINT_ROOT || "").replace(/\/+$/, "");
const all = process.env.GSTACK_PRUNE_ALL === "1";
const ptOptout = process.env.GSTACK_PT_OPTOUT === "1";
const PT_SOURCES = { "plan-tune-cathedral": true, "auq-error-fallback": true };
// Sources a sweep must leave alone (e.g. `setup --no-team` excludes the
// user-registered verify-gate hook: turning team mode off must not
// delete an unrelated opt-in whose binary still exists).
const sweepExclude = {};
(process.env.GSTACK_SWEEP_EXCLUDE_SOURCES || "").split(",").forEach(function (sName) {
if (sName.trim()) sweepExclude[sName.trim()] = true;
});
const loaded = gsLoadSettings(settingsPath);
const settings = loaded.settings;
const before = JSON.stringify(settings, null, 2);
let removed = 0;
let repointed = 0;
if (settings.hooks) {
for (const event of Object.keys(settings.hooks)) {
const rebuilt = [];
for (const entry of settings.hooks[event]) {
if (!Array.isArray(entry.hooks)) { rebuilt.push(entry); continue; }
const matcher = entry.matcher || "";
const wasSingle = entry.hooks.length === 1;
const remain = [];
const seenInEntry = new Set();
if (entry.hooks.length === 0) {
// Started-empty entries are foreign data we never touched --
// preserve them (only a gstack-tagged empty entry is claimable,
// and only by the --all sweep).
if (all && entry._gstack_source && !sweepExclude[entry._gstack_source]) { removed++; continue; }
rebuilt.push(entry);
continue;
}
for (const h of entry.hooks) {
const cmdRaw = h && h.command;
const row = cmdRaw ? gsOwnedRow(cmdRaw, event, matcher) : null;
// A tagged SINGLE-item entry with no table match is an owned
// legacy stray (command items only -- gstack never writes
// command-less items); tags never claim items in multi-item entries.
const stray = !row && !!cmdRaw && !!entry._gstack_source && wasSingle;
if (!row && !stray) { remain.push(h); continue; } // foreign: never touched
if (all) {
if ((row && sweepExclude[row.source]) || (!row && entry._gstack_source && sweepExclude[entry._gstack_source])) { remain.push(h); continue; }
removed++; continue;
}
if (row && root && !(ptOptout && PT_SOURCES[row.source])) {
const target = root + "/" + row.relpath;
if (gsIsAlive(target)) {
const newCmd = gsQuoteCmd(target, gsHadBashPrefix(cmdRaw));
if (h.command !== newCmd) { h.command = newCmd; repointed++; }
// Within-entry twin collapse: two dead copies of the same
// hook re-point to the same canonical command -- keeping
// both would fire the hook twice per event, forever.
if (seenInEntry.has(newCmd)) { removed++; continue; }
seenInEntry.add(newCmd);
if (wasSingle) entry._gstack_source = row.source; // tag restore
remain.push(h);
continue;
}
}
// No re-point target (or plan-tune opt-out): keep live, prune dead.
if (gsIsAlive(cmdRaw)) remain.push(h); else { removed++; }
}
if (remain.length === 0) continue; // entry emptied → dropped
entry.hooks = remain;
// Tag hygiene: a tag must never sit on an entry containing foreign
// items (old gstack versions in sibling worktrees treat tags as
// entry-level ownership and would destroy the user items). Drop
// the tag from any mixed entry; single-item strays keep theirs.
if (entry._gstack_source && entry.hooks.length > 0
&& !entry.hooks.every(h => h && h.command && gsOwnedRow(h.command, event, matcher))
&& !(entry.hooks.length === 1 && wasSingle)) {
delete entry._gstack_source;
}
rebuilt.push(entry);
}
// Collapse exact duplicates among FULLY-owned entries (same matcher,
// same item commands). Prefer the tagged twin so stripped tags heal.
const seen = new Map();
const out = [];
for (const entry of rebuilt) {
const items = Array.isArray(entry.hooks) ? entry.hooks : [];
const fullyOwned = items.length > 0 && items.every(h =>
h && h.command && gsOwnedRow(h.command, event, entry.matcher || ""));
if (!fullyOwned) { out.push(entry); continue; }
const key = JSON.stringify([entry.matcher || ""].concat(items.map(function (h) { return h.command; }).sort()));
const at = seen.get(key);
if (at === undefined) { seen.set(key, out.length); out.push(entry); }
else {
if (!out[at]._gstack_source && entry._gstack_source) out[at] = entry;
removed++;
}
}
settings.hooks[event] = out;
if (out.length === 0) delete settings.hooks[event];
}
if (settings.hooks && Object.keys(settings.hooks).length === 0) delete settings.hooks;
}
gsWriteIfChanged(settingsPath, before, settings, loaded.existed);
console.log("OK: removed " + removed + " gstack hook entries (repointed " + repointed + ")");
});
' '
;; ;;
@@ -349,20 +779,46 @@ case "$ACTION" in
exit 1 exit 1
fi fi
LATEST=$(cat "$SETTINGS_FILE.bak-latest") LATEST=$(cat "$SETTINGS_FILE.bak-latest")
LATEST=$(printf '%s' "$LATEST" | tr -d '\n')
# Defense in depth: only ever restore a sibling settings.json.bak.* file
# (a corrupted/hostile pointer must not install an arbitrary file as the
# live settings.json).
case "$LATEST" in
"$SETTINGS_FILE".bak.*) ;;
*)
echo "rollback: pointer target $LATEST is not a $SETTINGS_FILE.bak.* file -- refusing" >&2
exit 1
;;
esac
case "${LATEST#"$SETTINGS_FILE".bak.}" in
*/*)
echo "rollback: pointer suffix contains a path separator -- refusing" >&2
exit 1
;;
esac
if [ ! -f "$LATEST" ]; then if [ ! -f "$LATEST" ]; then
echo "rollback: pointer references missing backup $LATEST" >&2 echo "rollback: pointer references missing backup $LATEST" >&2
exit 1 exit 1
fi fi
cp "$LATEST" "$SETTINGS_FILE" _acquire_lock || exit 1
_RB_TMP="$SETTINGS_FILE.tmp.$$.$RANDOM"
cp "$LATEST" "$_RB_TMP"
mv "$_RB_TMP" "$SETTINGS_FILE"
echo "OK: restored $SETTINGS_FILE from $LATEST" echo "OK: restored $SETTINGS_FILE from $LATEST"
;; ;;
list-sources) list-sources)
[ -f "$SETTINGS_FILE" ] || { echo "(no settings file)"; exit 0; } [ -f "$SETTINGS_FILE" ] || { echo "(no settings file)"; exit 0; }
GSTACK_SETTINGS_PATH="$SETTINGS_FILE" bun -e ' GSTACK_SETTINGS_PATH="$SETTINGS_FILE" bun -e "$_HOOK_JS_PRELUDE"'gsMain(function () {
const fs = require("fs"); const fs = require("fs");
let settings = {}; let settings = {};
try { settings = JSON.parse(fs.readFileSync(process.env.GSTACK_SETTINGS_PATH, "utf8")); } catch { process.exit(0); } try { settings = JSON.parse(fs.readFileSync(process.env.GSTACK_SETTINGS_PATH, "utf8")); }
catch (e) {
// Read-only surface: report loudly (setup guards read this output and
// must not mistake corrupt-file for no-hooks) but exit 0.
process.stderr.write("gstack-settings-hook: settings.json unparseable (" + e.message + ") -- fix or rollback\n");
process.exit(0);
}
const hooks = settings.hooks || {}; const hooks = settings.hooks || {};
let any = false; let any = false;
for (const event of Object.keys(hooks)) { for (const event of Object.keys(hooks)) {
@@ -374,6 +830,7 @@ case "$ACTION" in
} }
} }
if (!any) console.log("(no gstack-tagged hooks)"); if (!any) console.log("(no gstack-tagged hooks)");
});
' '
;; ;;
+33 -15
View File
@@ -132,6 +132,39 @@ if [ -d "$STATE_DIR/projects" ]; then
done < <(find "$STATE_DIR/projects" -name browse.json -path '*/.gstack/*' 2>/dev/null || true) done < <(find "$STATE_DIR/projects" -name browse.json -path '*/.gstack/*' 2>/dev/null || true)
fi fi
# ─── Remove gstack hooks from Claude Code settings ──────────
# MUST run BEFORE any install-root deletion: SETTINGS_HOOK resolves inside the
# install being removed — in a real global uninstall, running this after
# `rm -rf ~/.claude/skills/gstack` silently no-ops and orphans every hook.
SETTINGS_HOOK="$(dirname "$0")/gstack-settings-hook"
SESSION_UPDATE="$(dirname "$0")/gstack-session-update"
if [ -x "$SETTINGS_HOOK" ]; then
"$SETTINGS_HOOK" remove "$SESSION_UPDATE" && REMOVED+=("SessionStart hook") || true
# Cathedral T8 cleanup: also remove plan-tune PreToolUse + PostToolUse hooks.
if "$SETTINGS_HOOK" remove-source --source plan-tune-cathedral | grep -q "removed [1-9]"; then
REMOVED+=("plan-tune cathedral hooks")
fi
# AskUserQuestion error-fallback hook (registered by setup; previously never
# torn down).
if "$SETTINGS_HOOK" remove-source --source auq-error-fallback | grep -q "removed [1-9]"; then
REMOVED+=("AskUserQuestion error-fallback hook")
fi
# Timeline Stop hook (#2553).
if "$SETTINGS_HOOK" remove-source --source gstack-timeline-stop | grep -q "removed [1-9]"; then
REMOVED+=("timeline Stop hook")
fi
# Verification stop hook (opt-in via README; user-registered, ours to sweep).
if "$SETTINGS_HOOK" remove-source --source verify-gate | grep -q "removed [1-9]"; then
REMOVED+=("verification Stop hook")
fi
# Identity sweep for untagged strays (Claude Code strips _gstack_source
# tags; pre-v1.67 setups baked worktree paths). Removes every gstack-owned
# hook item, live or dead — the binaries they point at are being deleted.
if "$SETTINGS_HOOK" prune-stale --all | grep -q "removed [1-9]"; then
REMOVED+=("stray gstack hook entries")
fi
fi
# ─── Remove global Claude skills ──────────────────────────── # ─── Remove global Claude skills ────────────────────────────
CLAUDE_SKILLS="$HOME/.claude/skills" CLAUDE_SKILLS="$HOME/.claude/skills"
@@ -334,21 +367,6 @@ if [ -n "$_GIT_ROOT" ]; then
fi fi
fi fi
# ─── Remove SessionStart hook from Claude Code settings ─────
SETTINGS_HOOK="$(dirname "$0")/gstack-settings-hook"
SESSION_UPDATE="$(dirname "$0")/gstack-session-update"
if [ -x "$SETTINGS_HOOK" ]; then
"$SETTINGS_HOOK" remove "$SESSION_UPDATE" 2>/dev/null && REMOVED+=("SessionStart hook") || true
# Cathedral T8 cleanup: also remove plan-tune PreToolUse + PostToolUse hooks.
if "$SETTINGS_HOOK" remove-source --source plan-tune-cathedral 2>/dev/null | grep -q "removed [1-9]"; then
REMOVED+=("plan-tune cathedral hooks")
fi
# Timeline Stop hook (#2553).
if "$SETTINGS_HOOK" remove-source --source gstack-timeline-stop 2>/dev/null | grep -q "removed [1-9]"; then
REMOVED+=("timeline Stop hook")
fi
fi
# ─── Remove global state ──────────────────────────────────── # ─── Remove global state ────────────────────────────────────
if [ "$KEEP_STATE" -eq 0 ] && [ -d "$STATE_DIR" ]; then if [ "$KEEP_STATE" -eq 0 ] && [ -d "$STATE_DIR" ]; then
rm -rf "$STATE_DIR" rm -rf "$STATE_DIR"
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "gstack", "name": "gstack",
"version": "1.68.0", "version": "1.68.1",
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.", "description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
"license": "MIT", "license": "MIT",
"type": "module", "type": "module",
+198 -60
View File
@@ -1920,27 +1920,113 @@ rm -f /tmp/gstack-latest-version
# 10. Team mode: register/unregister SessionStart hook # 10. Team mode: register/unregister SessionStart hook
SETTINGS_HOOK="$SOURCE_GSTACK_DIR/bin/gstack-settings-hook" SETTINGS_HOOK="$SOURCE_GSTACK_DIR/bin/gstack-settings-hook"
# ─── Canonical hook paths + self-heal (phantom-hooks fix) ─────────────────────
# Hook commands written to GLOBAL settings.json must survive deletion of the
# tree setup ran from: SOURCE_GSTACK_DIR is `pwd -P` of the running tree, which
# for Conductor workspaces / manual worktrees / temp clones is EPHEMERAL —
# baking it produced dead hooks erroring on every AskUserQuestion until v1.67.
# Hook registration is therefore CANONICAL-ONLY: the stable install path below,
# or no registration at all. By this point setup has already installed/linked
# the canonical tree, so a missing canonical hook means "don't register", never
# "fall back to the running tree". The canonical path is symlink-preserving, so
# re-pointing ~/.claude/skills/gstack at a new clone heals every hook with zero
# settings writes. Repo-local --local installs don't register global Claude
# hooks (by design).
#
# WARNING for future code AND migrations (the v1.58.0.0.sh defect class):
# NEVER register ${SCRIPT_DIR}/$SOURCE_GSTACK_DIR-relative hook paths.
CANONICAL_GSTACK_ROOT="${CLAUDE_CONFIG_DIR:-$HOME/.claude}/skills/gstack"
# Split-brain guard: the installer currently hardcodes $HOME/.claude/skills
# (setup:1601 TODO), so a CLAUDE_CONFIG_DIR override can name a root that was
# never installed. Fall back to where the install actually lives — both are
# stable, neither is the running tree, so canonical-only still holds.
if [ ! -x "$CANONICAL_GSTACK_ROOT/bin/gstack-session-update" ] \
&& [ -x "$HOME/.claude/skills/gstack/bin/gstack-session-update" ]; then
CANONICAL_GSTACK_ROOT="$HOME/.claude/skills/gstack"
fi
# Echo the canonical path for a hook (repo-relative arg); fails when the hook
# is not executable at the canonical install — callers must skip + log.
_hook_command_path() {
if [ -x "$CANONICAL_GSTACK_ROOT/$1" ]; then
printf '%s\n' "$CANONICAL_GSTACK_ROOT/$1"
return 0
fi
return 1
}
# Heal-first: prune dead gstack hook entries and re-point survivors at the
# stable install BEFORE any tag-presence guard below (a dead entry carrying the
# tag otherwise blocks re-registration forever — the missing-Stop-hook failure
# mode). Runs on EVERY setup, including --no-team, so upgrades self-heal
# without migrations. One log line only when something actually changed; stderr
# passes through uncaptured (zero silent failures).
if [ -x "$SETTINGS_HOOK" ]; then
if [ -d "$CANONICAL_GSTACK_ROOT" ]; then
_HEAL_OUT=$("$SETTINGS_HOOK" prune-stale --repoint "$CANONICAL_GSTACK_ROOT" || true)
else
_HEAL_OUT=$("$SETTINGS_HOOK" prune-stale || true)
fi
_HEAL_REMOVED=$(printf '%s' "$_HEAL_OUT" | sed -n 's/^OK: removed \([0-9]*\).*/\1/p')
_HEAL_REPOINTED=$(printf '%s' "$_HEAL_OUT" | sed -n 's/.*repointed \([0-9]*\).*/\1/p')
if [ "${_HEAL_REMOVED:-0}" -gt 0 ] 2>/dev/null || [ "${_HEAL_REPOINTED:-0}" -gt 0 ] 2>/dev/null; then
log " healed hook registrations: removed ${_HEAL_REMOVED:-0}, repointed ${_HEAL_REPOINTED:-0} (backup: settings.json.bak.<ts>; note: later registrations in this run move the rollback pointer — restore the heal's own .bak file directly if needed)"
fi
# Explicit opt-out + live plan-tune hooks is a contradiction worth surfacing:
# the heal honors the opt-out (dead plan-tune entries pruned, never
# re-pointed) but live hooks stay until the user removes them.
if "$GSTACK_CONFIG" has plan_tune_hooks 2>/dev/null; then
_PT_CFG_VAL=$("$GSTACK_CONFIG" get plan_tune_hooks 2>/dev/null || true)
case "$(printf '%s' "$_PT_CFG_VAL" | tr '[:upper:]' '[:lower:]' | tr -d '[:space:]')" in
n|no|false|skip|off|0)
if "$SETTINGS_HOOK" list-sources 2>/dev/null | grep -q "plan-tune-cathedral"; then
log " note: plan_tune_hooks is 'no' in config but live plan-tune hooks exist — remove with ./setup --no-team or $SETTINGS_HOOK remove-source --source plan-tune-cathedral"
fi
;;
esac
fi
fi
# On Windows (Git Bash / MSYS2 / Cygwin), extensionless scripts can't be # On Windows (Git Bash / MSYS2 / Cygwin), extensionless scripts can't be
# launched directly by the OS — the file-association dialog appears instead. # launched directly by the OS — the file-association dialog appears instead.
# Prefix with 'bash' so Claude Code's hook runner invokes Git Bash explicitly. # Prefix with 'bash' so Claude Code's hook runner invokes Git Bash explicitly.
if [ "$IS_WINDOWS" -eq 1 ]; then # Paths with whitespace are quoted so the hook command survives shell parsing.
HOOK_CMD="bash $SOURCE_GSTACK_DIR/bin/gstack-session-update" SESSION_UPDATE_CMD="$(_hook_command_path bin/gstack-session-update || true)"
else HOOK_CMD=""
HOOK_CMD="$SOURCE_GSTACK_DIR/bin/gstack-session-update" if [ -n "$SESSION_UPDATE_CMD" ]; then
# No caller-side quoting: add-event is the single quoting authority — it
# normalizes every registered command through the same gsQuoteCmd round-trip
# the healer uses, so metachar/space paths cannot drift per call site.
if [ "$IS_WINDOWS" -eq 1 ]; then
HOOK_CMD="bash $SESSION_UPDATE_CMD"
else
HOOK_CMD="$SESSION_UPDATE_CMD"
fi
fi fi
if [ "$TEAM_MODE" -eq 1 ]; then if [ "$TEAM_MODE" -eq 1 ]; then
"$GSTACK_CONFIG" set auto_upgrade true 2>/dev/null || true "$GSTACK_CONFIG" set auto_upgrade true 2>/dev/null || true
"$GSTACK_CONFIG" set team_mode true 2>/dev/null || true "$GSTACK_CONFIG" set team_mode true 2>/dev/null || true
# Register SessionStart hook in Claude Code settings # Register SessionStart hook in Claude Code settings (schema-aware: the
if [ -x "$SETTINGS_HOOK" ]; then # legacy `add` action's substring dedupe bypasses the KNOWN_HOOKS identity
"$SETTINGS_HOOK" add "$HOOK_CMD" 2>/dev/null || true # system; add-event re-points stale paths in place instead of appending).
# stderr stays attached (zero silent settings mutations — a fail-closed
# parse error or lock give-up must reach the user).
if [ -x "$SETTINGS_HOOK" ] && [ -n "$HOOK_CMD" ]; then
"$SETTINGS_HOOK" add-event --event SessionStart --command "$HOOK_CMD" --source gstack-session-update >/dev/null || true
elif [ -z "$HOOK_CMD" ]; then
log " SessionStart hook not registered: bin/gstack-session-update missing at $CANONICAL_GSTACK_ROOT (no stable install)"
fi fi
log "" log ""
log "Team mode enabled: gstack will auto-update at the start of each Claude Code session." if [ -n "$HOOK_CMD" ]; then
log " Hook: $HOOK_CMD" log "Team mode enabled: gstack will auto-update at the start of each Claude Code session."
log " Hook: $HOOK_CMD"
else
log "Team mode enabled (auto-update hook pending a stable install — re-run ./setup after installing globally)."
fi
log " To disable: ./setup --no-team" log " To disable: ./setup --no-team"
log "" log ""
log "Bootstrap your repo:" log "Bootstrap your repo:"
@@ -2045,22 +2131,13 @@ if [ -x "$DETECT_BIN" ]; then
fi fi
fi fi
# Hook commands registered into ~/.claude/settings.json must survive deletion # Hook path resolution is CANONICAL-ONLY via the resolver defined near
# of the directory setup ran from. A dev-worktree setup used to bake # CANONICAL_GSTACK_ROOT above (_hook_command_path): a hook command registered into
# $SOURCE_GSTACK_DIR's absolute path into the registration; deleting that # ~/.claude/settings.json must survive deletion of the directory setup ran
# worktree left a dead hook erroring on every trigger. Prefer the global # from, and no heuristic can enumerate every ephemeral tree (manual worktrees,
# install (~/.claude/skills/gstack — a persistent checkout, or a stable # temp clones, CI checkouts) — so there is deliberately NO fallback to
# symlink) and fall back to the setup-time source tree only when no global # $SOURCE_GSTACK_DIR here. A missing canonical hook means "skip registration
# install exists yet (first install from a fresh clone). # with a log line", never "bake the running tree's path".
_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). # 11. Plan-tune cathedral hook install (T8).
# #
@@ -2072,16 +2149,31 @@ _hook_install_path() {
# Idempotent via _gstack_source tag = 'plan-tune-cathedral'. If both hooks # Idempotent via _gstack_source tag = 'plan-tune-cathedral'. If both hooks
# already registered under that tag, the install skips the consent prompt and # already registered under that tag, the install skips the consent prompt and
# only refreshes the registered command paths in place (ensure-event is a # only refreshes the registered command paths in place (ensure-event is a
# no-op when they already match — see the stale-path note above). # no-op when they already match).
PLAN_TUNE_LOG_HOOK="$(_hook_install_path hosts/claude/hooks/question-log-hook)" PLAN_TUNE_LOG_HOOK="$(_hook_command_path hosts/claude/hooks/question-log-hook || true)"
PLAN_TUNE_PREF_HOOK="$(_hook_install_path hosts/claude/hooks/question-preference-hook)" PLAN_TUNE_PREF_HOOK="$(_hook_command_path hosts/claude/hooks/question-preference-hook || true)"
AUQ_ERROR_FALLBACK_HOOK="$(_hook_install_path hosts/claude/hooks/auq-error-fallback-hook)" AUQ_ERROR_FALLBACK_HOOK="$(_hook_command_path hosts/claude/hooks/auq-error-fallback-hook || true)"
# Windows: extensionless bash shims need the explicit 'bash ' prefix (same
# rationale as HOOK_CMD above — the OS file-association dialog otherwise).
# KNOWN_HOOKS identity round-trips the prefix, so healing preserves it.
if [ "$IS_WINDOWS" -eq 1 ]; then
[ -n "$PLAN_TUNE_LOG_HOOK" ] && PLAN_TUNE_LOG_HOOK="bash $PLAN_TUNE_LOG_HOOK"
[ -n "$PLAN_TUNE_PREF_HOOK" ] && PLAN_TUNE_PREF_HOOK="bash $PLAN_TUNE_PREF_HOOK"
[ -n "$AUQ_ERROR_FALLBACK_HOOK" ] && AUQ_ERROR_FALLBACK_HOOK="bash $AUQ_ERROR_FALLBACK_HOOK"
fi
PLAN_TUNE_INSTALL_MARKER="$HOME/.gstack/.plan-tune-hooks-prompted" PLAN_TUNE_INSTALL_MARKER="$HOME/.gstack/.plan-tune-hooks-prompted"
# Canonical-only: an ephemeral tree with no stable install gets a visible skip,
# never a baked worktree path.
if [ "$NO_TEAM_MODE" -ne 1 ] && [ -x "$SETTINGS_HOOK" ] \
&& { [ -z "$PLAN_TUNE_LOG_HOOK" ] || [ -z "$PLAN_TUNE_PREF_HOOK" ]; }; then
log " AskUserQuestion hooks not registered: hooks missing at $CANONICAL_GSTACK_ROOT (no stable install)"
fi
if [ "$NO_TEAM_MODE" -ne 1 ] \ if [ "$NO_TEAM_MODE" -ne 1 ] \
&& [ -x "$SETTINGS_HOOK" ] \ && [ -x "$SETTINGS_HOOK" ] \
&& [ -x "$PLAN_TUNE_LOG_HOOK" ] \ && [ -n "$PLAN_TUNE_LOG_HOOK" ] \
&& [ -x "$PLAN_TUNE_PREF_HOOK" ]; then && [ -n "$PLAN_TUNE_PREF_HOOK" ]; then
# Already installed? Require BOTH the plan-tune source AND the AUQ-error-fallback # 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 # source — so an existing install that predates the fallback hook re-runs the
@@ -2101,9 +2193,25 @@ if [ "$NO_TEAM_MODE" -ne 1 ] \
# This guarantees scripted/workspace setups (conductor, CI) are never # This guarantees scripted/workspace setups (conductor, CI) are never
# interactive: pass --no-plan-tune-hooks (or --plan-tune-hooks) and the # interactive: pass --no-plan-tune-hooks (or --plan-tune-hooks) and the
# block runs to completion with no `read`. # block runs to completion with no `read`.
PT_DECISION="$PLAN_TUNE_HOOKS_MODE" # PT_EXPLICIT provenance: an EXPLICIT decision (CLI flag, env var, or a key
[ -z "$PT_DECISION" ] && PT_DECISION="${GSTACK_PLAN_TUNE_HOOKS:-}" # literally present in the config file) must never be overridden by the
[ -z "$PT_DECISION" ] && PT_DECISION="$("$GSTACK_CONFIG" get plan_tune_hooks 2>/dev/null || true)" # Conductor auto-opt-in below. `gstack-config get` returns the default
# "prompt" for absent keys, so provenance uses `gstack-config has` (which
# resolves GSTACK_STATE_ROOT/GSTACK_HOME/GSTACK_STATE_DIR the same way get
# does — never grep a hardcoded ~/.gstack/config.yaml).
PT_EXPLICIT=0
if [ -n "$PLAN_TUNE_HOOKS_MODE" ]; then
PT_DECISION="$PLAN_TUNE_HOOKS_MODE"
PT_EXPLICIT=1
elif [ -n "${GSTACK_PLAN_TUNE_HOOKS:-}" ]; then
PT_DECISION="${GSTACK_PLAN_TUNE_HOOKS}"
PT_EXPLICIT=1
else
PT_DECISION="$("$GSTACK_CONFIG" get plan_tune_hooks 2>/dev/null || true)"
if "$GSTACK_CONFIG" has plan_tune_hooks 2>/dev/null; then
PT_EXPLICIT=1
fi
fi
# Normalize: strip whitespace + lowercase so "YES", "Yes", " yes" from a flag # Normalize: strip whitespace + lowercase so "YES", "Yes", " yes" from a flag
# or env var all resolve correctly (an unrecognized opt-in must NOT silently # or env var all resolve correctly (an unrecognized opt-in must NOT silently
# downgrade to skip). Unknown values fall through to "prompt". # downgrade to skip). Unknown values fall through to "prompt".
@@ -2120,7 +2228,11 @@ if [ "$NO_TEAM_MODE" -ne 1 ] \
# falls through to "prompt" → the non-interactive skip below, leaving Conductor # falls through to "prompt" → the non-interactive skip below, leaving Conductor
# users without that backstop. Treat Conductor as an implicit opt-in — but # users without that backstop. Treat Conductor as an implicit opt-in — but
# only on the silent fall-through, never overriding an explicit --no-plan-tune-hooks. # only 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 # Only the true silent fall-through auto-opts-in. An explicit
# --plan-tune-hooks=prompt (bin/dev-setup passes exactly this so ephemeral
# workspace setups never install) stays "prompt" — this was the bug that
# baked worktree hook paths into every Conductor user's settings.json.
if [ "$PT_DECISION" = "prompt" ] && [ "$PT_EXPLICIT" -eq 0 ] && { [ -n "${CONDUCTOR_WORKSPACE_PATH:-}" ] || [ -n "${CONDUCTOR_PORT:-}" ]; }; then
PT_DECISION="yes" PT_DECISION="yes"
_PT_CONDUCTOR_AUTO=1 _PT_CONDUCTOR_AUTO=1
fi fi
@@ -2129,18 +2241,22 @@ if [ "$NO_TEAM_MODE" -ne 1 ] \
# ensure-event (not add-event): registers when missing, RE-POINTS a stale # 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 # command path in place when the registration differs, and is a true no-op
# (no write, no backup churn) when it already matches. # (no write, no backup churn) when it already matches.
# Returns non-zero if ANY registration was skipped (lock contention or a
# fail-closed settings error) so callers log honestly instead of claiming
# success for a mutation that never happened.
local _pt_install_rc=0
"$SETTINGS_HOOK" ensure-event \ "$SETTINGS_HOOK" ensure-event \
--event PostToolUse \ --event PostToolUse \
--matcher '(AskUserQuestion|mcp__.*__AskUserQuestion)' \ --matcher '(AskUserQuestion|mcp__.*__AskUserQuestion)' \
--command "$PLAN_TUNE_LOG_HOOK" \ --command "$PLAN_TUNE_LOG_HOOK" \
--source plan-tune-cathedral \ --source plan-tune-cathedral \
--timeout 5 --timeout 5 || _pt_install_rc=1
"$SETTINGS_HOOK" ensure-event \ "$SETTINGS_HOOK" ensure-event \
--event PreToolUse \ --event PreToolUse \
--matcher '(AskUserQuestion|mcp__.*__AskUserQuestion)' \ --matcher '(AskUserQuestion|mcp__.*__AskUserQuestion)' \
--command "$PLAN_TUNE_PREF_HOOK" \ --command "$PLAN_TUNE_PREF_HOOK" \
--source plan-tune-cathedral \ --source plan-tune-cathedral \
--timeout 5 --timeout 5 || _pt_install_rc=1
# AskUserQuestion-failure prose-fallback reliability hook (OV3:B). Fires only when # AskUserQuestion-failure prose-fallback reliability hook (OV3:B). Fires only when
# an AskUserQuestion call returns an error/missing result; inert on success and # 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 # inert if the platform doesn't invoke PostToolUse on tool errors. MUST use its
@@ -2148,14 +2264,15 @@ if [ "$NO_TEAM_MODE" -ne 1 ] \
# REPLACES the entry's hooks, so sharing 'plan-tune-cathedral' would overwrite the # 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 # question-log capture hook (same event+matcher). A distinct source = a second
# PostToolUse entry; both run in parallel. # PostToolUse entry; both run in parallel.
if [ -x "$AUQ_ERROR_FALLBACK_HOOK" ]; then if [ -n "$AUQ_ERROR_FALLBACK_HOOK" ]; then
"$SETTINGS_HOOK" ensure-event \ "$SETTINGS_HOOK" ensure-event \
--event PostToolUse \ --event PostToolUse \
--matcher '(AskUserQuestion|mcp__.*__AskUserQuestion)' \ --matcher '(AskUserQuestion|mcp__.*__AskUserQuestion)' \
--command "$AUQ_ERROR_FALLBACK_HOOK" \ --command "$AUQ_ERROR_FALLBACK_HOOK" \
--source auq-error-fallback \ --source auq-error-fallback \
--timeout 5 --timeout 5 || _pt_install_rc=1
fi fi
return $_pt_install_rc
} }
if [ "$ALREADY_INSTALLED" -eq 1 ]; then if [ "$ALREADY_INSTALLED" -eq 1 ]; then
@@ -2172,13 +2289,17 @@ if [ "$NO_TEAM_MODE" -ne 1 ] \
log "Plan-tune hooks already installed. Run \`$SETTINGS_HOOK list-sources\` to inspect." log "Plan-tune hooks already installed. Run \`$SETTINGS_HOOK list-sources\` to inspect."
elif [ "$PT_DECISION" = "yes" ]; then elif [ "$PT_DECISION" = "yes" ]; then
# Explicit opt-in (flag / env / config) or Conductor implicit opt-in. Non-interactive. # Explicit opt-in (flag / env / config) or Conductor implicit opt-in. Non-interactive.
_install_plan_tune_hooks if _install_plan_tune_hooks; then
log "" log ""
if [ "${_PT_CONDUCTOR_AUTO:-0}" -eq 1 ]; then if [ "${_PT_CONDUCTOR_AUTO:-0}" -eq 1 ]; then
log "AskUserQuestion reliability hooks installed (Conductor detected): decisions" log "AskUserQuestion reliability hooks installed (Conductor detected): decisions"
log "render as a prose brief instead of the flaky AskUserQuestion tool. Inspect with /plan-tune." 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
else else
log "Plan-tune hooks installed. Run /plan-tune anytime to inspect." log ""
log " warning: some AskUserQuestion hooks were NOT registered (settings lock contention or a settings error above) — re-run ./setup to complete."
fi fi
touch "$PLAN_TUNE_INSTALL_MARKER" touch "$PLAN_TUNE_INSTALL_MARKER"
elif [ "$PT_DECISION" = "no" ]; then elif [ "$PT_DECISION" = "no" ]; then
@@ -2223,9 +2344,13 @@ if [ "$NO_TEAM_MODE" -ne 1 ] \
read -t "$_PT_PROMPT_TIMEOUT" -r PLAN_TUNE_INSTALL_REPLY </dev/tty 2>/dev/null || PLAN_TUNE_INSTALL_REPLY="" 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 case "$PLAN_TUNE_INSTALL_REPLY" in
y|Y) y|Y)
_install_plan_tune_hooks if _install_plan_tune_hooks; then
log "" log ""
log "Plan-tune hooks installed. Run /plan-tune anytime to inspect." log "Plan-tune hooks installed. Run /plan-tune anytime to inspect."
else
log ""
log " warning: some AskUserQuestion hooks were NOT registered (settings lock contention or a settings error above) — re-run ./setup to complete."
fi
touch "$PLAN_TUNE_INSTALL_MARKER" touch "$PLAN_TUNE_INSTALL_MARKER"
;; ;;
n|N) n|N)
@@ -2262,15 +2387,18 @@ fi
# (F5): the hook always exits 0 and repairs best-effort — it can never block # (F5): the hook always exits 0 and repairs best-effort — it can never block
# a session. Removed by --no-team and gstack-uninstall. # a session. Removed by --no-team and gstack-uninstall.
# #
# The command path prefers the global install (see _hook_install_path): a # The command path is canonical-only (see _hook_command_path): a dev-worktree
# dev-worktree setup used to bake its own absolute dir into settings.json, so # setup used to bake its own absolute dir into settings.json, so deleting the
# deleting the worktree left a dead hook erroring on every session stop — and # worktree left a dead hook erroring on every session stop — and the old
# the old presence-only dedup (list-sources | grep) never re-pointed it on a # presence-only dedup (list-sources | grep) never re-pointed it on a re-run.
# re-run. ensure-event registers when missing, replaces a stale path in place # ensure-event registers when missing, replaces a stale path in place (one
# (one atomic write — never zero or two registrations), and no-ops when the # atomic write — never zero or two registrations), and no-ops when the
# registration already matches. # registration already matches.
TIMELINE_STOP_HOOK="$(_hook_install_path hosts/claude/hooks/timeline-stop-hook)" TIMELINE_STOP_HOOK="$(_hook_command_path hosts/claude/hooks/timeline-stop-hook || true)"
if [ "$NO_TEAM_MODE" -ne 1 ] && [ -x "$SETTINGS_HOOK" ] && [ -x "$TIMELINE_STOP_HOOK" ]; then if [ "$IS_WINDOWS" -eq 1 ] && [ -n "$TIMELINE_STOP_HOOK" ]; then
TIMELINE_STOP_HOOK="bash $TIMELINE_STOP_HOOK"
fi
if [ "$NO_TEAM_MODE" -ne 1 ] && [ -x "$SETTINGS_HOOK" ] && [ -n "$TIMELINE_STOP_HOOK" ]; then
if _TL_ENSURE_OUT=$("$SETTINGS_HOOK" ensure-event \ if _TL_ENSURE_OUT=$("$SETTINGS_HOOK" ensure-event \
--event Stop \ --event Stop \
--command "$TIMELINE_STOP_HOOK" \ --command "$TIMELINE_STOP_HOOK" \
@@ -2289,16 +2417,26 @@ if [ "$NO_TEAM_MODE" -ne 1 ] && [ -x "$SETTINGS_HOOK" ] && [ -x "$TIMELINE_STOP_
esac esac
else else
# Non-fatal to setup, but never silent: the hardened settings-hook refuses # Non-fatal to setup, but never silent: the hardened settings-hook refuses
# to rewrite a corrupt settings.json (exit 1), and swallowing that refusal # to mutate a corrupt settings.json (exit 3) or under a held lock (exit 5),
# left the Stop hook unregistered with no signal. # 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" log " warning: settings hook update failed: $(printf '%s\n' "$_TL_ENSURE_OUT" | head -1) — run $SETTINGS_HOOK manually"
fi fi
fi fi
# Also tear down plan-tune + timeline hooks on --no-team (matches the existing pattern). # Also tear down plan-tune + timeline hooks on --no-team (matches the existing pattern).
# Tag-only remove-source misses untagged entries (Claude Code strips
# _gstack_source), so the identity sweep (prune-stale --all) finishes the job.
# stderr stays attached on every call: a lock give-up or fail-closed parse
# error during TEARDOWN must be visible — "the next setup retries" does not
# apply when the user is turning the hooks off.
if [ "$NO_TEAM_MODE" -eq 1 ] && [ -x "$SETTINGS_HOOK" ]; then 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 plan-tune-cathedral >/dev/null || true
"$SETTINGS_HOOK" remove-source --source gstack-timeline-stop 2>/dev/null || true "$SETTINGS_HOOK" remove-source --source auq-error-fallback >/dev/null || true
"$SETTINGS_HOOK" remove-source --source gstack-timeline-stop >/dev/null || true
# verify-gate is a user-registered opt-in unrelated to team mode -- turning
# team mode off must not delete it (uninstall still sweeps it, correctly,
# because there the binary itself is being removed).
GSTACK_SWEEP_EXCLUDE_SOURCES="verify-gate" "$SETTINGS_HOOK" prune-stale --all >/dev/null || true
fi fi
# ─── Redact pre-push guard consent (#1946) ─────────────────────────────────── # ─── Redact pre-push guard consent (#1946) ───────────────────────────────────
+33
View File
@@ -129,3 +129,36 @@ describe('hook integration — invoked as PostToolUse', () => {
expect(out.additionalContext).toBeUndefined(); expect(out.additionalContext).toBeUndefined();
}); });
}); });
// ----------------------------------------------------------------------
// Registration + teardown wiring (static). Setup registers this hook under
// its own source tag (sharing plan-tune-cathedral would overwrite the
// question-log entry — same event+matcher); both teardown surfaces
// (--no-team, uninstall) must remove it, which pre-v1.67.2 neither did.
// ----------------------------------------------------------------------
import * as fs from 'fs';
describe('setup registration + teardown wiring (static)', () => {
const ROOT = path.resolve(__dirname, '..');
const setupSrc = fs.readFileSync(path.join(ROOT, 'setup'), 'utf-8');
const uninstallSrc = fs.readFileSync(path.join(ROOT, 'bin', 'gstack-uninstall'), 'utf-8');
test('setup registers the hook via the canonical resolver under --source auq-error-fallback', () => {
expect(setupSrc).toMatch(
/AUQ_ERROR_FALLBACK_HOOK="\$\(_hook_command_path hosts\/claude\/hooks\/auq-error-fallback-hook/,
);
expect(setupSrc).toContain('--source auq-error-fallback');
});
test('--no-team tears the hook down', () => {
const idx = setupSrc.indexOf('# Also tear down plan-tune');
expect(idx).toBeGreaterThan(-1);
const slice = setupSrc.slice(idx, idx + 900);
expect(slice).toContain('remove-source --source auq-error-fallback');
});
test('gstack-uninstall tears the hook down', () => {
expect(uninstallSrc).toContain('remove-source --source auq-error-fallback');
});
});
+34 -8
View File
@@ -68,21 +68,47 @@ describe('content-binding template drift', () => {
// Functional: execute the template's tripwire block against a 0-banner // Functional: execute the template's tripwire block against a 0-banner
// original and a 1-banner outgoing body — the ABORT branch must fire. // original and a 1-banner outgoing body — the ABORT branch must fire.
//
// Pass the script as an ARGV element (spawnSync array form), never by
// interpolating JSON.stringify into a shell line: JSON escaping is not
// shell escaping. Inside shell double quotes a JSON "\n" stays a literal
// backslash-n, which collapsed this multi-line script onto one line where
// `then\n` became the command word `thenn` and `>&2\nelse\n` became the
// redirect `>&2nelsen` — silently littering a `2nelsen` file (containing
// "bash: thenn: command not found") in the repo root on every suite run,
// while the old not-contains assertion passed vacuously because ALL
// output had been redirected into that file.
const block = body.match(/_ORIG_BANNERS=\$\(grep[\s\S]*?fi\n/); const block = body.match(/_ORIG_BANNERS=\$\(grep[\s\S]*?fi\n/);
expect(block).not.toBeNull(); expect(block).not.toBeNull();
const fs = require('fs'); const fs = require('fs');
const os = require('os'); const os = require('os');
const path = require('path'); const path = require('path');
const { execSync } = require('child_process'); const { spawnSync } = require('child_process');
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-banner-')); const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-banner-'));
try { try {
fs.writeFileSync(path.join(dir, 'orig.md'), 'clean body\n'); const scriptFor = (origContent: string, newContent: string) => {
fs.writeFileSync(path.join(dir, 'new.md'), 'body with UNTRUSTED TRACKER CONTENT banner leak\n'); fs.writeFileSync(path.join(dir, 'orig.md'), origContent);
const script = block![0] fs.writeFileSync(path.join(dir, 'new.md'), newContent);
.replaceAll('/tmp/gstack-pr-body-orig-$$.md', path.join(dir, 'orig.md')) return block![0]
.replaceAll('/tmp/gstack-pr-body-$$.md', path.join(dir, 'new.md')); .replaceAll('/tmp/gstack-pr-body-orig-$$.md', path.join(dir, 'orig.md'))
const out = execSync(`bash -c ${JSON.stringify(script + '; true')}`, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }); .replaceAll('/tmp/gstack-pr-body-$$.md', path.join(dir, 'new.md'));
expect(out).not.toContain('banner tripwire clean'); };
// Banner leaked into the outgoing body → the ABORT branch fires, loudly.
const abort = spawnSync('bash', ['-c', scriptFor(
'clean body\n',
'body with UNTRUSTED TRACKER CONTENT banner leak\n',
)], { encoding: 'utf-8' });
expect(abort.stderr).toContain('ABORT: envelope banner leaked');
expect(abort.stdout).not.toContain('banner tripwire clean');
// No banner delta → the clean branch fires.
const clean = spawnSync('bash', ['-c', scriptFor(
'clean body\n',
'also clean body\n',
)], { encoding: 'utf-8' });
expect(clean.stdout).toContain('banner tripwire clean');
expect(clean.stderr).not.toContain('ABORT');
} finally { } finally {
fs.rmSync(dir, { recursive: true, force: true }); fs.rmSync(dir, { recursive: true, force: true });
} }
+2 -2
View File
@@ -46,13 +46,13 @@ afterEach(() => {
}); });
describe("gstack-config key validation is locale-independent", () => { describe("gstack-config key validation is locale-independent", () => {
test("both get and set validate ASCII ranges under the C locale", () => { test("get, has, and set all validate ASCII ranges under the C locale", () => {
const source = fs.readFileSync(CONFIG, "utf8"); const source = fs.readFileSync(CONFIG, "utf8");
const guardedValidators = source.match( const guardedValidators = source.match(
/LC_ALL=C grep -qE '\^\[a-zA-Z0-9_\]\+\(@\[a-zA-Z0-9\]\+\)\?\$'/g, /LC_ALL=C grep -qE '\^\[a-zA-Z0-9_\]\+\(@\[a-zA-Z0-9\]\+\)\?\$'/g,
); );
expect(guardedValidators).toHaveLength(2); expect(guardedValidators).toHaveLength(3);
}); });
test("round-trips existing keys that contain i", () => { test("round-trips existing keys that contain i", () => {
@@ -420,3 +420,719 @@ describe('list-sources', () => {
expect(r.stdout).toMatch(/no settings file/); expect(r.stdout).toMatch(/no settings file/);
}); });
}); });
// ----------------------------------------------------------------------
// Phantom-hooks heal surface (v1.67.2): KNOWN_HOOKS identity table,
// per-item mutation, prune-stale, mutation lock, fail-closed parse.
//
// Ownership is intrinsic (basename + relpath suffix + event/matcher against
// the fixed table) because Claude Code strips the _gstack_source key when it
// rewrites settings.json — tag-only dedupe is what let every Conductor
// worktree append a fresh dead entry.
// ----------------------------------------------------------------------
const AUQ_MATCHER = '(AskUserQuestion|mcp__.*__AskUserQuestion)';
const HOOK_NAMES = [
'question-log-hook',
'question-preference-hook',
'auq-error-fallback-hook',
'timeline-stop-hook',
];
/** run() with hermetic gstack-config state (prune-stale consults plan_tune_hooks). */
function runIso(args: string[], extraEnv: Record<string, string> = {}) {
try {
const stdout = execSync([SETTINGS_HOOK, ...args].map((s) => `'${s}'`).join(' '), {
env: {
...process.env,
GSTACK_SETTINGS_FILE: settingsFile,
GSTACK_STATE_ROOT: tmpDir,
...extraEnv,
},
encoding: 'utf-8',
timeout: 15000,
});
return { stdout, stderr: '', exitCode: 0 };
} catch (e: any) {
return { stdout: e.stdout || '', stderr: e.stderr || '', exitCode: e.status ?? 1 };
}
}
/** A fake stable install with executable hooks, under `base`. */
function mkCanon(base: string, name = 'canon'): string {
const canon = path.join(base, name);
fs.mkdirSync(path.join(canon, 'hosts', 'claude', 'hooks'), { recursive: true });
fs.mkdirSync(path.join(canon, 'bin'), { recursive: true });
for (const h of HOOK_NAMES) {
const p = path.join(canon, 'hosts', 'claude', 'hooks', h);
fs.writeFileSync(p, '#!/bin/sh\n');
fs.chmodSync(p, 0o755);
}
const su = path.join(canon, 'bin', 'gstack-session-update');
fs.writeFileSync(su, '#!/bin/sh\n');
fs.chmodSync(su, 0o755);
return canon;
}
function hookEntry(cmd: string, matcher?: string, src?: string, extraItems: any[] = []) {
const e: any = { hooks: [...extraItems, { type: 'command', command: cmd, timeout: 5 }] };
if (matcher) e.matcher = matcher;
if (src) e._gstack_source = src;
return e;
}
function backups(): string[] {
return fs.readdirSync(tmpDir).filter((f) => f.startsWith('settings.json.bak.'));
}
describe('add-event: per-item identity re-point', () => {
test('tag-stripped stale worktree path is re-pointed in place, tag restored', () => {
const canon = mkCanon(tmpDir);
fs.writeFileSync(settingsFile, JSON.stringify({
hooks: { PostToolUse: [hookEntry('/dead/wt/hosts/claude/hooks/question-log-hook', AUQ_MATCHER)] },
}, null, 2));
runIso([
'add-event', '--event', 'PostToolUse', '--matcher', AUQ_MATCHER,
'--command', `${canon}/hosts/claude/hooks/question-log-hook`,
'--source', 'plan-tune-cathedral', '--timeout', '5',
]);
const s = settings();
expect(s.hooks.PostToolUse).toHaveLength(1);
expect(s.hooks.PostToolUse[0].hooks[0].command).toBe(`${canon}/hosts/claude/hooks/question-log-hook`);
expect(s.hooks.PostToolUse[0]._gstack_source).toBe('plan-tune-cathedral');
});
test('foreign path with a gstack basename is NOT claimed (wrong relpath suffix)', () => {
const canon = mkCanon(tmpDir);
fs.writeFileSync(settingsFile, JSON.stringify({
hooks: { PostToolUse: [hookEntry('/home/u/myhooks/question-log-hook', AUQ_MATCHER)] },
}, null, 2));
runIso([
'add-event', '--event', 'PostToolUse', '--matcher', AUQ_MATCHER,
'--command', `${canon}/hosts/claude/hooks/question-log-hook`,
'--source', 'plan-tune-cathedral',
]);
const s = settings();
expect(s.hooks.PostToolUse).toHaveLength(2);
expect(s.hooks.PostToolUse[0].hooks[0].command).toBe('/home/u/myhooks/question-log-hook');
});
test('mixed entry: only the gstack item (index > 0) is replaced; the user item survives', () => {
const canon = mkCanon(tmpDir);
fs.writeFileSync(settingsFile, JSON.stringify({
hooks: {
PostToolUse: [hookEntry(
'/dead/wt/hosts/claude/hooks/question-log-hook', AUQ_MATCHER, undefined,
[{ type: 'command', command: '/Users/me/my-own-hook' }],
)],
},
}, null, 2));
runIso([
'add-event', '--event', 'PostToolUse', '--matcher', AUQ_MATCHER,
'--command', `${canon}/hosts/claude/hooks/question-log-hook`,
'--source', 'plan-tune-cathedral',
]);
const s = settings();
expect(s.hooks.PostToolUse).toHaveLength(1);
const items = s.hooks.PostToolUse[0].hooks;
expect(items).toHaveLength(2);
expect(items[0].command).toBe('/Users/me/my-own-hook');
expect(items[1].command).toBe(`${canon}/hosts/claude/hooks/question-log-hook`);
});
});
describe('legacy remove: per-item (regression)', () => {
test('mixed SessionStart entry: user item survives in place, gstack item removed', () => {
// REGRESSION pin: the pre-v1.67.2 legacy `remove` dropped the ENTIRE
// entry when any item matched gstack-session-update, destroying a user's
// co-located hook. The rewrite filters per-item; this is the only test of
// that branch (team-mode.test.ts covers single-item entries only).
fs.writeFileSync(settingsFile, JSON.stringify({
hooks: {
SessionStart: [{
hooks: [
{ type: 'command', command: '/Users/me/my-own-session-hook' },
{ type: 'command', command: '/old/install/bin/gstack-session-update' },
],
}],
},
}, null, 2));
const r = runIso(['remove', '/old/install/bin/gstack-session-update']);
expect(r.exitCode).toBe(0);
const s = settings();
expect(s.hooks.SessionStart).toHaveLength(1);
expect(s.hooks.SessionStart[0].hooks).toHaveLength(1);
expect(s.hooks.SessionStart[0].hooks[0].command).toBe('/Users/me/my-own-session-hook');
});
});
describe('review-army hardening (specialist findings)', () => {
test('legacy remove preserves malformed/foreign entries it never touched', () => {
fs.writeFileSync(settingsFile, JSON.stringify({
hooks: {
SessionStart: [
{ comment: 'no hooks array at all' },
{ hooks: 'not-an-array' },
{ hooks: [] },
{ hooks: [{ type: 'command', command: '/x/bin/gstack-session-update' }] },
],
},
}, null, 2));
runIso(['remove', '/x/bin/gstack-session-update']);
const s = settings();
// Only the entry we emptied is gone; the three malformed/foreign ones stay.
expect(s.hooks.SessionStart).toHaveLength(3);
});
test('add-event never tags a mixed entry (old-version ratchet guard)', () => {
const canon = mkCanon(tmpDir);
fs.writeFileSync(settingsFile, JSON.stringify({
hooks: {
PostToolUse: [{
matcher: AUQ_MATCHER,
_gstack_source: 'plan-tune-cathedral',
hooks: [
{ type: 'command', command: '/Users/me/my-own-hook' },
{ type: 'command', command: '/dead/wt/hosts/claude/hooks/question-log-hook' },
],
}],
},
}, null, 2));
runIso([
'add-event', '--event', 'PostToolUse', '--matcher', AUQ_MATCHER,
'--command', `${canon}/hosts/claude/hooks/question-log-hook`,
'--source', 'plan-tune-cathedral',
]);
const s = settings();
expect(s.hooks.PostToolUse).toHaveLength(1);
const e = s.hooks.PostToolUse[0];
expect(e.hooks).toHaveLength(2);
expect(e.hooks[0].command).toBe('/Users/me/my-own-hook');
// A tag on a mixed entry hands old-version remove-source permission to
// destroy the user's item — it must be gone.
expect(e._gstack_source).toBeUndefined();
});
test('two dead twins of one hook in ONE entry collapse to a single item after --repoint', () => {
const canon = mkCanon(tmpDir);
fs.writeFileSync(settingsFile, JSON.stringify({
hooks: {
PostToolUse: [{
matcher: AUQ_MATCHER,
hooks: [
{ type: 'command', command: '/dead/a/hosts/claude/hooks/question-log-hook' },
{ type: 'command', command: '/dead/b/hosts/claude/hooks/question-log-hook' },
],
}],
},
}, null, 2));
runIso(['prune-stale', '--repoint', canon]);
const items = settings().hooks.PostToolUse[0].hooks;
expect(items).toHaveLength(1); // pre-fix: two identical items → hook fires twice per event
expect(items[0].command).toBe(`${canon}/hosts/claude/hooks/question-log-hook`);
});
test('a 0600 settings.json keeps its mode across mutations (API keys stay private)', () => {
fs.writeFileSync(settingsFile, JSON.stringify({ env: { SECRET: 'x' } }, null, 2));
fs.chmodSync(settingsFile, 0o600);
runIso(['add-event', '--event', 'Stop', '--command', '/x/hosts/claude/hooks/timeline-stop-hook', '--source', 'gstack-timeline-stop']);
const mode = fs.statSync(settingsFile).mode & 0o777;
expect(mode).toBe(0o600);
});
test('a canonical root containing $ is escaped in the registered command', () => {
const trickyBase = path.join(tmpDir, 'weird$dir');
fs.mkdirSync(trickyBase, { recursive: true });
const canon = mkCanon(trickyBase);
fs.writeFileSync(settingsFile, JSON.stringify({
hooks: { Stop: [hookEntry('/dead/wt/hosts/claude/hooks/timeline-stop-hook')] },
}, null, 2));
runIso(['prune-stale', '--repoint', canon]);
const cmd = settings().hooks.Stop[0].hooks[0].command;
expect(cmd.startsWith('"')).toBe(true);
expect(cmd).toContain('\\$'); // $ neutralized — shell must not expand it at hook-fire time
// Idempotent: the escaped command is still recognized as ours.
const before = fs.readFileSync(settingsFile, 'utf-8');
const r2 = runIso(['prune-stale', '--repoint', canon]);
expect(r2.stdout).toMatch(/removed 0 gstack hook entries \(repointed 0\)/);
expect(fs.readFileSync(settingsFile, 'utf-8')).toBe(before);
});
test('backups rotate: at most 10 .bak files survive repeated mutations', () => {
for (let i = 0; i < 13; i++) {
runIso(['add-event', '--event', 'Stop', '--command', `/x/hosts/claude/hooks/timeline-stop-hook-${i}`, '--source', 'gstack-timeline-stop']);
}
expect(backups().length).toBeLessThanOrEqual(10);
// The rollback pointer still resolves to an existing backup.
const latest = fs.readFileSync(path.join(tmpDir, 'settings.json.bak-latest'), 'utf-8').trim();
expect(fs.existsSync(latest)).toBe(true);
});
test('tag-stripped verify-gate entry is table-owned: healed by --repoint, swept by --all', () => {
// Red-team catch: verify-gate is a README-documented opt-in Stop hook.
// Without a KNOWN_HOOKS row, a tag-stripped entry survived uninstall and
// errored at the end of EVERY turn after the install root was deleted.
const canon = mkCanon(tmpDir);
const vg = path.join(canon, 'bin', 'gstack-verify-gate');
fs.writeFileSync(vg, '#!/bin/sh\n');
fs.chmodSync(vg, 0o755);
fs.writeFileSync(settingsFile, JSON.stringify({
hooks: { Stop: [hookEntry('/dead/install/bin/gstack-verify-gate')] }, // tag STRIPPED
}, null, 2));
runIso(['prune-stale', '--repoint', canon]);
let s = settings();
expect(s.hooks.Stop[0].hooks[0].command).toBe(vg);
expect(s.hooks.Stop[0]._gstack_source).toBe('verify-gate');
const r = runIso(['prune-stale', '--all']);
expect(r.stdout).toMatch(/removed 1/);
expect(settings().hooks).toBeUndefined();
});
test('a foreign entry that STARTED empty survives prune-stale untouched', () => {
fs.writeFileSync(settingsFile, JSON.stringify({
hooks: { PreToolUse: [{ matcher: 'Bash', hooks: [] }] },
}, null, 2) + '\n');
const before = fs.readFileSync(settingsFile, 'utf-8');
const r = runIso(['prune-stale', '--repoint', mkCanon(tmpDir, 'c2')]);
expect(r.stdout).toMatch(/removed 0 gstack hook entries \(repointed 0\)/);
expect(fs.readFileSync(settingsFile, 'utf-8')).toBe(before);
});
test('add-event is the quoting authority: spaced canonical path stored escaped-quoted, healer idempotent', () => {
// Red-team catch (empirically verified pre-fix): setup registered raw
// paths and the very next heal rewrote them — fresh installs shipped a
// form the codebase itself considered wrong.
const spacedBase = path.join(tmpDir, 'canon root');
fs.mkdirSync(spacedBase, { recursive: true });
const canon = mkCanon(spacedBase);
runIso([
'add-event', '--event', 'PostToolUse', '--matcher', AUQ_MATCHER,
'--command', `${canon}/hosts/claude/hooks/question-log-hook`,
'--source', 'plan-tune-cathedral', '--timeout', '5',
]);
const stored = settings().hooks.PostToolUse[0].hooks[0].command;
expect(stored).toBe(`"${canon}/hosts/claude/hooks/question-log-hook"`);
const before = fs.readFileSync(settingsFile, 'utf-8');
const r = runIso(['prune-stale', '--repoint', canon]);
expect(r.stdout).toMatch(/removed 0 gstack hook entries \(repointed 0\)/);
expect(fs.readFileSync(settingsFile, 'utf-8')).toBe(before);
});
test('rollback refuses a pointer that names a non-backup file', () => {
fs.writeFileSync(settingsFile, JSON.stringify({ a: 1 }, null, 2));
const evil = path.join(tmpDir, 'evil.json');
fs.writeFileSync(evil, JSON.stringify({ hooks: { Stop: [{ hooks: [{ type: 'command', command: '/evil' }] }] } }));
fs.writeFileSync(path.join(tmpDir, 'settings.json.bak-latest'), evil + '\n');
const r = runIso(['rollback']);
expect(r.exitCode).not.toBe(0);
expect(r.stderr).toMatch(/refusing/);
expect(settings().a).toBe(1);
});
});
describe('ownership negatives', () => {
test('owned basename+relpath under the WRONG matcher stays foreign (not re-pointed)', () => {
const canon = mkCanon(tmpDir);
fs.writeFileSync(settingsFile, JSON.stringify({
hooks: {
PostToolUse: [hookEntry('/dead/wt/hosts/claude/hooks/question-log-hook', 'Bash')],
},
}, null, 2));
const before = fs.readFileSync(settingsFile, 'utf-8');
const r = runIso(['prune-stale', '--repoint', canon]);
expect(r.stdout).toMatch(/removed 0 gstack hook entries \(repointed 0\)/);
expect(fs.readFileSync(settingsFile, 'utf-8')).toBe(before);
});
test('prune-stale on an absent settings file exits 0 with removed 0', () => {
const r = runIso(['prune-stale', '--repoint', '/nonexistent-root']);
expect(r.exitCode).toBe(0);
expect(r.stdout).toMatch(/removed 0 gstack hook entries \(repointed 0\)/);
expect(fs.existsSync(settingsFile)).toBe(false);
});
});
describe('remove-source: per-item', () => {
test('mixed tagged entry: gstack item removed, user item survives, tag dropped', () => {
fs.writeFileSync(settingsFile, JSON.stringify({
hooks: {
PostToolUse: [hookEntry(
'/x/hosts/claude/hooks/question-log-hook', AUQ_MATCHER, 'plan-tune-cathedral',
[{ type: 'command', command: '/Users/me/my-own-hook' }],
)],
},
}, null, 2));
const r = runIso(['remove-source', '--source', 'plan-tune-cathedral']);
expect(r.stdout).toMatch(/removed 1 hook/);
const s = settings();
expect(s.hooks.PostToolUse).toHaveLength(1);
expect(s.hooks.PostToolUse[0].hooks).toHaveLength(1);
expect(s.hooks.PostToolUse[0].hooks[0].command).toBe('/Users/me/my-own-hook');
expect(s.hooks.PostToolUse[0]._gstack_source).toBeUndefined();
});
});
describe('prune-stale', () => {
test('prunes dead gstack items; keeps live gstack and dead non-gstack', () => {
const canon = mkCanon(tmpDir);
fs.writeFileSync(settingsFile, JSON.stringify({
hooks: {
PostToolUse: [
hookEntry(`${canon}/hosts/claude/hooks/question-log-hook`, AUQ_MATCHER), // live gstack
hookEntry('/dead/wt/hosts/claude/hooks/auq-error-fallback-hook', AUQ_MATCHER), // dead gstack
hookEntry('/dead/user/own-hook', AUQ_MATCHER), // dead NON-gstack
],
},
}, null, 2));
const r = runIso(['prune-stale']);
expect(r.stdout).toMatch(/removed 1 gstack hook entries/);
const s = settings();
expect(s.hooks.PostToolUse).toHaveLength(2);
const cmds = s.hooks.PostToolUse.map((e: any) => e.hooks[0].command);
expect(cmds).toContain(`${canon}/hosts/claude/hooks/question-log-hook`);
expect(cmds).toContain('/dead/user/own-hook');
});
test('no-op run writes no backup and leaves the file byte-identical', () => {
fs.writeFileSync(settingsFile, JSON.stringify({
hooks: { PreToolUse: [hookEntry('/Users/me/my-own-hook', 'Bash')] },
}, null, 2) + '\n');
const before = fs.readFileSync(settingsFile, 'utf-8');
const r = runIso(['prune-stale']);
expect(r.exitCode).toBe(0);
expect(fs.readFileSync(settingsFile, 'utf-8')).toBe(before);
expect(backups()).toHaveLength(0);
expect(fs.existsSync(path.join(tmpDir, 'settings.json.bak-latest'))).toBe(false);
});
test('--repoint re-points dead AND live items, preserves bash prefix, restores tags', () => {
const canon = mkCanon(tmpDir);
const live = mkCanon(tmpDir, 'live-worktree');
fs.writeFileSync(settingsFile, JSON.stringify({
hooks: {
Stop: [hookEntry(`${live}/hosts/claude/hooks/timeline-stop-hook`)], // LIVE but ephemeral
PostToolUse: [hookEntry('bash /dead/wt/hosts/claude/hooks/question-log-hook', AUQ_MATCHER)],
},
}, null, 2));
const r = runIso(['prune-stale', '--repoint', canon]);
expect(r.stdout).toMatch(/repointed 2/);
const s = settings();
expect(s.hooks.Stop[0].hooks[0].command).toBe(`${canon}/hosts/claude/hooks/timeline-stop-hook`);
expect(s.hooks.Stop[0]._gstack_source).toBe('gstack-timeline-stop');
expect(s.hooks.PostToolUse[0].hooks[0].command).toBe(`bash ${canon}/hosts/claude/hooks/question-log-hook`);
expect(s.hooks.PostToolUse[0]._gstack_source).toBe('plan-tune-cathedral');
});
test('--repoint collapses exact duplicates preferring the tagged twin', () => {
const canon = mkCanon(tmpDir);
const cmd = `${canon}/hosts/claude/hooks/question-log-hook`;
fs.writeFileSync(settingsFile, JSON.stringify({
hooks: {
PostToolUse: [
hookEntry('/dead/a/hosts/claude/hooks/question-log-hook', AUQ_MATCHER),
hookEntry(cmd, AUQ_MATCHER, 'plan-tune-cathedral'),
],
},
}, null, 2));
runIso(['prune-stale', '--repoint', canon]);
const s = settings();
expect(s.hooks.PostToolUse).toHaveLength(1);
expect(s.hooks.PostToolUse[0].hooks[0].command).toBe(cmd);
expect(s.hooks.PostToolUse[0]._gstack_source).toBe('plan-tune-cathedral');
});
test('--repoint never ADDS entries (repair, not registration)', () => {
const canon = mkCanon(tmpDir);
fs.writeFileSync(settingsFile, JSON.stringify({ theme: 'dark' }, null, 2) + '\n');
const before = fs.readFileSync(settingsFile, 'utf-8');
runIso(['prune-stale', '--repoint', canon]);
expect(fs.readFileSync(settingsFile, 'utf-8')).toBe(before);
});
test('Windows backslash path is classified as gstack-owned and pruned when dead', () => {
fs.writeFileSync(settingsFile, JSON.stringify({
hooks: {
PostToolUse: [hookEntry('C:\\dead\\wt\\hosts\\claude\\hooks\\question-log-hook', AUQ_MATCHER)],
},
}, null, 2));
const r = runIso(['prune-stale']);
expect(r.stdout).toMatch(/removed 1/);
expect(settings().hooks).toBeUndefined();
});
test('spaced canonical root produces a quoted command that stays owned (idempotent)', () => {
const spacedBase = path.join(tmpDir, 'My Claude');
fs.mkdirSync(spacedBase, { recursive: true });
const canon = mkCanon(spacedBase);
fs.writeFileSync(settingsFile, JSON.stringify({
hooks: { Stop: [hookEntry('/dead/wt/hosts/claude/hooks/timeline-stop-hook')] },
}, null, 2));
runIso(['prune-stale', '--repoint', canon]);
const s = settings();
expect(s.hooks.Stop[0].hooks[0].command).toBe(`"${canon}/hosts/claude/hooks/timeline-stop-hook"`);
// Second run: the quoted command is still recognized as ours — no churn.
const before = fs.readFileSync(settingsFile, 'utf-8');
const r2 = runIso(['prune-stale', '--repoint', canon]);
expect(r2.stdout).toMatch(/removed 0 gstack hook entries \(repointed 0\)/);
expect(fs.readFileSync(settingsFile, 'utf-8')).toBe(before);
});
test('--all removes live untagged gstack items, spares user hooks and mixed-entry user items', () => {
const canon = mkCanon(tmpDir);
fs.writeFileSync(settingsFile, JSON.stringify({
hooks: {
PostToolUse: [hookEntry(`${canon}/hosts/claude/hooks/question-log-hook`, AUQ_MATCHER)],
Stop: [hookEntry(
`${canon}/hosts/claude/hooks/timeline-stop-hook`, undefined, 'gstack-timeline-stop',
[{ type: 'command', command: '/Users/me/custom-stop-hook' }],
)],
PreCompact: [hookEntry('/Users/me/my-own-hook')],
},
}, null, 2));
const r = runIso(['prune-stale', '--all']);
expect(r.stdout).toMatch(/removed 2/);
const s = settings();
expect(s.hooks.PostToolUse).toBeUndefined();
expect(s.hooks.Stop[0].hooks).toHaveLength(1);
expect(s.hooks.Stop[0].hooks[0].command).toBe('/Users/me/custom-stop-hook');
expect(s.hooks.Stop[0]._gstack_source).toBeUndefined();
expect(s.hooks.PreCompact[0].hooks[0].command).toBe('/Users/me/my-own-hook');
});
test('--all removes tagged single-item legacy strays (no table match)', () => {
fs.writeFileSync(settingsFile, JSON.stringify({
hooks: { Stop: [hookEntry('/old/install/bin/gstack-verify-gate', undefined, 'gstack-verify-gate')] },
}, null, 2));
const r = runIso(['prune-stale', '--all']);
expect(r.stdout).toMatch(/removed 1/);
expect(settings().hooks).toBeUndefined();
});
test('--all and --repoint are mutually exclusive', () => {
const r = runIso(['prune-stale', '--all', '--repoint', '/x']);
expect(r.exitCode).not.toBe(0);
expect(r.stderr).toMatch(/mutually exclusive/);
});
test('explicit plan_tune_hooks:no — dead plan-tune pruned, live plan-tune NOT re-pointed, Stop still re-pointed', () => {
const canon = mkCanon(tmpDir);
const live = mkCanon(tmpDir, 'live-worktree');
execSync(`'${path.join(ROOT, 'bin', 'gstack-config')}' set plan_tune_hooks no`, {
env: { ...process.env, GSTACK_STATE_ROOT: tmpDir },
});
fs.writeFileSync(settingsFile, JSON.stringify({
hooks: {
PostToolUse: [
hookEntry('/dead/wt/hosts/claude/hooks/question-log-hook', AUQ_MATCHER), // dead plan-tune
hookEntry(`${live}/hosts/claude/hooks/auq-error-fallback-hook`, AUQ_MATCHER), // LIVE plan-tune
],
Stop: [hookEntry('/dead/wt/hosts/claude/hooks/timeline-stop-hook')],
},
}, null, 2));
runIso(['prune-stale', '--repoint', canon]);
const s = settings();
expect(s.hooks.PostToolUse).toHaveLength(1);
// Live plan-tune hook left exactly where it was (no re-activation without consent).
expect(s.hooks.PostToolUse[0].hooks[0].command).toBe(`${live}/hosts/claude/hooks/auq-error-fallback-hook`);
// Stop hook is not part of the opt-out — re-pointed to canonical.
expect(s.hooks.Stop[0].hooks[0].command).toBe(`${canon}/hosts/claude/hooks/timeline-stop-hook`);
});
test('incident facsimile: the exact live-damage shape heals to canonical', () => {
// Replays the 2026-08-17 production state: 6 PostToolUse / 3 PreToolUse /
// 2 Stop entries; 6 dead (deleted worktrees), tags stripped on some, one
// live-but-ephemeral Stop hook, plus a user hook that must survive.
const canon = mkCanon(tmpDir);
const cebu = mkCanon(tmpDir, 'cebu-v4');
const dead = (n: string) => `/dead/biarritz-v3/hosts/claude/hooks/${n}`;
const dead2 = (n: string) => `/dead/taipei-v2/hosts/claude/hooks/${n}`;
fs.writeFileSync(settingsFile, JSON.stringify({
hooks: {
SessionStart: [hookEntry(`${canon}/bin/gstack-session-update`)],
PostToolUse: [
hookEntry(`${canon}/hosts/claude/hooks/auq-error-fallback-hook`, AUQ_MATCHER),
hookEntry(`${canon}/hosts/claude/hooks/question-log-hook`, AUQ_MATCHER),
hookEntry(dead('question-log-hook'), AUQ_MATCHER),
hookEntry(dead('auq-error-fallback-hook'), AUQ_MATCHER),
hookEntry(dead2('question-log-hook'), AUQ_MATCHER, 'plan-tune-cathedral'),
hookEntry(dead2('auq-error-fallback-hook'), AUQ_MATCHER, 'auq-error-fallback'),
],
PreToolUse: [
hookEntry(`${canon}/hosts/claude/hooks/question-preference-hook`, AUQ_MATCHER),
hookEntry(dead('question-preference-hook'), AUQ_MATCHER),
hookEntry(dead2('question-preference-hook'), AUQ_MATCHER, 'plan-tune-cathedral'),
],
Stop: [
hookEntry(`${cebu}/hosts/claude/hooks/timeline-stop-hook`),
hookEntry(dead2('timeline-stop-hook'), undefined, 'gstack-timeline-stop'),
],
PreCompact: [hookEntry('/Users/me/my-own-hook')],
},
}, null, 2));
const r = runIso(['prune-stale', '--repoint', canon]);
expect(r.exitCode).toBe(0);
const s = settings();
expect(s.hooks.SessionStart).toHaveLength(1);
expect(s.hooks.PostToolUse).toHaveLength(2);
expect(s.hooks.PreToolUse).toHaveLength(1);
expect(s.hooks.Stop).toHaveLength(1);
expect(s.hooks.PreCompact[0].hooks[0].command).toBe('/Users/me/my-own-hook');
for (const ev of ['SessionStart', 'PostToolUse', 'PreToolUse', 'Stop']) {
for (const e of s.hooks[ev]) {
expect(e._gstack_source).toBeDefined();
for (const it of e.hooks) expect(it.command.startsWith(canon)).toBe(true);
}
}
const postSources = s.hooks.PostToolUse.map((e: any) => e._gstack_source).sort();
expect(postSources).toEqual(['auq-error-fallback', 'plan-tune-cathedral']);
});
});
describe('fail-closed parse (pre-existing data-loss fix)', () => {
const MUTATORS: string[][] = [
['add', '/x/bin/gstack-session-update'],
['remove', '/x/bin/gstack-session-update'],
['add-event', '--event', 'Stop', '--command', '/x', '--source', 's'],
['remove-source', '--source', 'plan-tune-cathedral'],
['prune-stale'],
];
test('every mutator refuses to touch a corrupt settings.json', () => {
for (const args of MUTATORS) {
fs.writeFileSync(settingsFile, '{definitely not json');
const r = runIso(args);
expect(r.exitCode).not.toBe(0);
expect(r.stderr).toMatch(/refusing to mutate/);
expect(fs.readFileSync(settingsFile, 'utf-8')).toBe('{definitely not json');
}
});
});
describe('mutation lock', () => {
test('stale lock (old mtime) is taken over; mutation proceeds', () => {
const lockDir = `${settingsFile}.lock`;
fs.mkdirSync(lockDir);
fs.writeFileSync(path.join(lockDir, 'owner'), 'dead-process');
const old = new Date(Date.now() - 120_000);
fs.utimesSync(lockDir, old, old);
const r = runIso(['add-event', '--event', 'Stop', '--command', '/x/hosts/claude/hooks/timeline-stop-hook', '--source', 'gstack-timeline-stop']);
expect(r.exitCode).toBe(0);
expect(settings().hooks.Stop).toHaveLength(1);
expect(fs.existsSync(lockDir)).toBe(false); // released after the mutation
});
test('fresh foreign lock: mutation skipped loudly (exit 5), file untouched', () => {
fs.writeFileSync(settingsFile, JSON.stringify({ theme: 'dark' }, null, 2) + '\n');
const before = fs.readFileSync(settingsFile, 'utf-8');
const lockDir = `${settingsFile}.lock`;
fs.mkdirSync(lockDir);
fs.writeFileSync(path.join(lockDir, 'owner'), 'another-live-process');
const r = runIso(
['add-event', '--event', 'Stop', '--command', '/x', '--source', 's'],
{ GSTACK_SETTINGS_LOCK_TIMEOUT_MS: '300' },
);
expect(r.exitCode).toBe(5); // loud give-up, not silent skip
expect(r.stderr).toMatch(/could not acquire lock/);
expect(fs.readFileSync(settingsFile, 'utf-8')).toBe(before);
expect(fs.existsSync(lockDir)).toBe(true); // foreign lock NOT stolen
});
test('two concurrent add-events both land (lock serializes; file stays valid JSON)', () => {
const q = (args: string[]) =>
[SETTINGS_HOOK, ...args].map((s) => `'${s}'`).join(' ');
const a = q(['add-event', '--event', 'PreToolUse', '--matcher', AUQ_MATCHER, '--command', '/pre-hook', '--source', 'src-a']);
const b = q(['add-event', '--event', 'PostToolUse', '--matcher', AUQ_MATCHER, '--command', '/post-hook', '--source', 'src-b']);
execSync(`sh -c "${a} & ${b} & wait"`, {
env: { ...process.env, GSTACK_SETTINGS_FILE: settingsFile, GSTACK_STATE_ROOT: tmpDir },
encoding: 'utf-8',
timeout: 20000,
});
const s = settings(); // throws if the file is corrupt
expect(s.hooks.PreToolUse).toHaveLength(1);
expect(s.hooks.PostToolUse).toHaveLength(1);
});
});
describe('gstack-settings-hook adversarial hardening', () => {
let tmpDir: string;
let settingsFile: string;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-settings-adv-'));
settingsFile = path.join(tmpDir, 'settings.json');
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
function runAdv(args: string[], extraEnv: Record<string, string> = {}) {
try {
const stdout = execSync([SETTINGS_HOOK, ...args].map((s) => `'${s}'`).join(' '), {
env: {
...process.env,
GSTACK_SETTINGS_FILE: settingsFile,
GSTACK_STATE_ROOT: tmpDir,
...extraEnv,
},
encoding: 'utf-8',
timeout: 15000,
});
return { stdout, stderr: '', exitCode: 0 };
} catch (e: any) {
return { stdout: e.stdout || '', stderr: e.stderr || '', exitCode: e.status ?? 1 };
}
}
const advSettings = (): any => JSON.parse(fs.readFileSync(settingsFile, 'utf-8'));
test('wrong-shape hooks value fails LOUD (exit 4), file untouched', () => {
// bun -e swallows uncaught exceptions after a require() and exits 0
// (verified on bun 1.3.13) -- without the gsMain umbrella this exact
// input produced a silent exit-0 no-op that reported clean.
const raw = JSON.stringify({ hooks: { Stop: { bogus: 'shape' } } }, null, 2) + '\n';
fs.writeFileSync(settingsFile, raw);
const r = runAdv(['prune-stale', '--all']);
expect(r.exitCode).toBe(4);
expect(r.stderr).toMatch(/internal error/);
expect(r.stderr).toMatch(/refusing to mutate/);
expect(fs.readFileSync(settingsFile, 'utf-8')).toBe(raw);
});
test('foreign hook whose basename collides with Object.prototype survives --all', () => {
// KNOWN_HOOKS["toString"] returns an inherited member without the
// hasOwnProperty guard -- pre-fix, this threw mid-scan and turned the
// sweep into a silent no-op.
fs.writeFileSync(settingsFile, JSON.stringify({
hooks: {
Stop: [
{ hooks: [{ type: 'command', command: '/usr/local/bin/toString' }] },
{ _gstack_source: 'gstack-timeline-stop', hooks: [{ type: 'command', command: '/x/hosts/claude/hooks/timeline-stop-hook' }] },
],
},
}, null, 2) + '\n');
const r = runAdv(['prune-stale', '--all']);
expect(r.exitCode).toBe(0);
const s = advSettings();
expect(s.hooks.Stop).toHaveLength(1); // gstack entry swept...
expect(s.hooks.Stop[0].hooks[0].command).toBe('/usr/local/bin/toString'); // ...foreign one kept
});
test('GSTACK_SWEEP_EXCLUDE_SOURCES preserves verify-gate during an --all sweep', () => {
// `setup --no-team` sweeps team hooks but must not delete the
// user-registered verify-gate opt-in whose binary still exists.
fs.writeFileSync(settingsFile, JSON.stringify({
hooks: {
Stop: [
{ _gstack_source: 'verify-gate', hooks: [{ type: 'command', command: '/x/bin/gstack-verify-gate' }] },
{ _gstack_source: 'gstack-timeline-stop', hooks: [{ type: 'command', command: '/x/hosts/claude/hooks/timeline-stop-hook' }] },
],
},
}, null, 2) + '\n');
const r = runAdv(['prune-stale', '--all'], { GSTACK_SWEEP_EXCLUDE_SOURCES: 'verify-gate' });
expect(r.exitCode).toBe(0);
expect(r.stdout).toMatch(/removed 1 /);
const s = advSettings();
expect(s.hooks.Stop).toHaveLength(1);
expect(s.hooks.Stop[0]._gstack_source).toBe('verify-gate');
});
});
+216
View File
@@ -0,0 +1,216 @@
/**
* Canonical-only hook registration (phantom-hooks fix, v1.67.2).
*
* Static tripwires over `setup` and `bin/gstack-settings-hook`. The defect
* class these pin against: hook commands baked from the SETUP-TIME tree
* (`$SOURCE_GSTACK_DIR` = `pwd -P` of the running tree) into the user's
* GLOBAL ~/.claude/settings.json. Conductor worktrees are ephemeral, so every
* deleted workspace left dead hooks erroring on each AskUserQuestion fire.
*
* The contract:
* - hook registration paths come ONLY from `_hook_command_path` (canonical
* install: ${CLAUDE_CONFIG_DIR:-$HOME/.claude}/skills/gstack) an
* ephemeral tree can never be baked in; missing canonical = skip + log.
* - setup heals BEFORE any tag-presence guard (`prune-stale --repoint`),
* so a dead tagged entry can't block re-registration forever.
* - the heal is visible when it changes anything (no full output
* suppression at the call site).
* - the settings-hook binary's bun scripts share one JS prelude (KNOWN_HOOKS
* identity table + helpers) so the dedupe key and the prune predicate
* cannot drift.
*/
import { describe, test, expect } from 'bun:test';
import * as fs from 'fs';
import * as path from 'path';
const ROOT = path.resolve(import.meta.dir, '..');
const setupSrc = fs.readFileSync(path.join(ROOT, 'setup'), 'utf-8');
const hookBinSrc = fs.readFileSync(path.join(ROOT, 'bin', 'gstack-settings-hook'), 'utf-8');
const uninstallSrc = fs.readFileSync(path.join(ROOT, 'bin', 'gstack-uninstall'), 'utf-8');
describe('setup: canonical-only hook paths', () => {
test('CANONICAL_GSTACK_ROOT honors CLAUDE_CONFIG_DIR with ~/.claude fallback', () => {
expect(setupSrc).toContain(
'CANONICAL_GSTACK_ROOT="${CLAUDE_CONFIG_DIR:-$HOME/.claude}/skills/gstack"',
);
});
test('_hook_command_path body never references the running tree', () => {
const start = setupSrc.indexOf('_hook_command_path() {');
expect(start).toBeGreaterThan(-1);
const end = setupSrc.indexOf('\n}', start);
const body = setupSrc.slice(start, end);
expect(body).not.toContain('SOURCE_GSTACK_DIR');
expect(body).toContain('CANONICAL_GSTACK_ROOT');
});
test('every hook var routes through _hook_command_path; no raw SOURCE_GSTACK_DIR hook assignment remains', () => {
for (const rel of [
'hosts/claude/hooks/question-log-hook',
'hosts/claude/hooks/question-preference-hook',
'hosts/claude/hooks/auq-error-fallback-hook',
'hosts/claude/hooks/timeline-stop-hook',
'bin/gstack-session-update',
]) {
expect(setupSrc).toContain(`$(_hook_command_path ${rel}`);
}
// The bug: FOO_HOOK="$SOURCE_GSTACK_DIR/hosts/claude/hooks/..."
expect(setupSrc).not.toMatch(/="\$SOURCE_GSTACK_DIR\/hosts\/claude\/hooks\//);
expect(setupSrc).not.toMatch(/HOOK_CMD="(bash )?\$SOURCE_GSTACK_DIR\/bin\/gstack-session-update"/);
});
test('SessionStart registers via schema-aware add-event under its identity source', () => {
expect(setupSrc).toMatch(
/add-event --event SessionStart --command "\$HOOK_CMD" --source gstack-session-update/,
);
});
test('every add-event source in setup has a KNOWN_HOOKS table row', () => {
// Future-hook tripwire: a new add-event registration whose hook basename
// is missing from the identity table would be invisible to the healer,
// the --no-team sweep, and uninstall.
const rels = [...setupSrc.matchAll(/_hook_command_path (\S+)/g)].map((m) => m[1]);
expect(rels.length).toBeGreaterThanOrEqual(5);
for (const rel of rels) {
const basename = rel.split('/').pop()!;
expect(hookBinSrc).toContain(`"${basename}":`);
}
});
});
describe('setup: heal-first ordering + visibility', () => {
test('prune-stale --repoint runs before any list-sources guard or add-event registration', () => {
const heal = setupSrc.indexOf('prune-stale --repoint');
const firstGuard = setupSrc.indexOf('list-sources');
const firstAdd = setupSrc.indexOf('add-event');
expect(heal).toBeGreaterThan(-1);
expect(heal).toBeLessThan(firstGuard);
expect(heal).toBeLessThan(firstAdd);
});
test('the heal call site is not output-suppressed (zero silent settings mutations)', () => {
const lines = setupSrc.split('\n').filter((l) => l.includes('prune-stale --repoint'));
expect(lines.length).toBeGreaterThanOrEqual(1);
for (const line of lines) {
expect(line).not.toContain('>/dev/null');
expect(line).not.toContain('2>&1');
}
// Captured for the change-only summary line.
expect(setupSrc).toMatch(/_HEAL_OUT=\$\("\$SETTINGS_HOOK" prune-stale/);
expect(setupSrc).toContain('healed hook registrations');
});
test('heal counters use the ${VAR:-0} idiom, never `grep -c || echo 0`', () => {
// Prior learning grep-c-double-emit-fail-open: `grep -c ... || echo 0`
// double-emits "0\n0" on no-match and breaks numeric guards open.
expect(setupSrc).toContain('${_HEAL_REMOVED:-0}');
expect(setupSrc).toContain('${_HEAL_REPOINTED:-0}');
const healRegion = setupSrc.slice(
setupSrc.indexOf('_HEAL_OUT='),
setupSrc.indexOf('healed hook registrations'),
);
expect(healRegion).not.toMatch(/grep -c .*\|\| echo 0/);
});
test('--no-team teardown includes the auq source and the identity sweep', () => {
const idx = setupSrc.indexOf('# Also tear down plan-tune');
expect(idx).toBeGreaterThan(-1);
const slice = setupSrc.slice(idx, idx + 900);
expect(slice).toContain('remove-source --source plan-tune-cathedral');
expect(slice).toContain('remove-source --source auq-error-fallback');
expect(slice).toContain('remove-source --source gstack-timeline-stop');
expect(slice).toContain('prune-stale --all');
});
});
describe('gstack-settings-hook: shared prelude (dedupe key == prune predicate)', () => {
test('every bun script call site uses the shared JS prelude concatenation', () => {
const codeLines = hookBinSrc.split('\n').filter((l) => !l.trim().startsWith('#'));
const bunCalls = codeLines.filter((l) => l.includes('bun -e '));
const preludeCalls = codeLines.filter((l) => l.includes(`bun -e "$_HOOK_JS_PRELUDE"'`));
expect(bunCalls.length).toBeGreaterThanOrEqual(6);
expect(preludeCalls.length).toBe(bunCalls.length);
});
test('the prelude contains no single quotes (single-quoted shell assignment)', () => {
const start = hookBinSrc.indexOf("_HOOK_JS_PRELUDE='");
expect(start).toBeGreaterThan(-1);
const end = hookBinSrc.indexOf("\n'", start);
const prelude = hookBinSrc.slice(start + "_HOOK_JS_PRELUDE='".length, end);
expect(prelude).not.toContain("'");
// And no shell-expansion hazards inside the double-quoted call-site expansion.
expect(prelude).not.toContain('`');
});
test('KNOWN_HOOKS table carries all five identities with source+event+relpath', () => {
for (const [name, source, event] of [
['question-log-hook', 'plan-tune-cathedral', 'PostToolUse'],
['question-preference-hook', 'plan-tune-cathedral', 'PreToolUse'],
['auq-error-fallback-hook', 'auq-error-fallback', 'PostToolUse'],
['timeline-stop-hook', 'gstack-timeline-stop', 'Stop'],
['gstack-session-update', 'gstack-session-update', 'SessionStart'],
]) {
const rowStart = hookBinSrc.indexOf(`"${name}":`);
expect(rowStart).toBeGreaterThan(-1);
const row = hookBinSrc.slice(rowStart, hookBinSrc.indexOf('}', rowStart));
expect(row).toContain(`source: "${source}"`);
expect(row).toContain(`event: "${event}"`);
expect(row).toContain('relpath: "');
}
});
});
describe('gstack-uninstall: hook cleanup runs before install-root deletion', () => {
test('the settings cleanup block precedes every install-root rm -rf', () => {
// Pre-fix bug: SETTINGS_HOOK=$(dirname "$0")/gstack-settings-hook resolved
// INSIDE the install root, which was already deleted by the time cleanup
// ran — a real global uninstall silently orphaned every hook.
const cleanup = uninstallSrc.indexOf('Remove gstack hooks from Claude Code settings');
const rootDelete = uninstallSrc.indexOf('rm -rf "$CLAUDE_SKILLS/gstack"');
expect(cleanup).toBeGreaterThan(-1);
expect(rootDelete).toBeGreaterThan(-1);
expect(cleanup).toBeLessThan(rootDelete);
});
test('uninstall removes all three sources and sweeps untagged strays', () => {
expect(uninstallSrc).toContain('remove-source --source plan-tune-cathedral');
expect(uninstallSrc).toContain('remove-source --source auq-error-fallback');
expect(uninstallSrc).toContain('remove-source --source gstack-timeline-stop');
expect(uninstallSrc).toContain('prune-stale --all');
});
});
describe('the defect-class warning is written down where the next author will see it', () => {
test('setup carries the never-register-tree-relative-paths warning', () => {
expect(setupSrc).toMatch(/NEVER register .*SOURCE_GSTACK_DIR.*hook paths/);
});
});
describe('matcher-literal drift tripwire (review-army)', () => {
test("every --matcher literal in setup equals its KNOWN_HOOKS row's matcher", () => {
// gsOwnedRow requires an EXACT matcher match — if setup's registration
// matcher drifts from the table row, identity re-pointing/pruning silently
// stops recognizing the hook and the phantom-duplicate class returns.
const rowMatcher = (name: string) => {
const rowStart = hookBinSrc.indexOf(`"${name}":`);
expect(rowStart).toBeGreaterThan(-1);
const row = hookBinSrc.slice(rowStart, hookBinSrc.indexOf('}', rowStart));
return row.match(/matcher: "([^"]*)"/)![1];
};
const pairs: Array<[string, string]> = [
['question-log-hook', '(AskUserQuestion|mcp__.*__AskUserQuestion)'],
['question-preference-hook', '(AskUserQuestion|mcp__.*__AskUserQuestion)'],
['auq-error-fallback-hook', '(AskUserQuestion|mcp__.*__AskUserQuestion)'],
];
for (const [name, expected] of pairs) {
expect(rowMatcher(name)).toBe(expected);
}
// And setup registers those hooks with exactly that matcher literal.
const matcherLiterals = [...setupSrc.matchAll(/--matcher '([^']+)'/g)].map((m) => m[1]);
expect(matcherLiterals.length).toBeGreaterThanOrEqual(3);
for (const lit of matcherLiterals) {
expect(lit).toBe('(AskUserQuestion|mcp__.*__AskUserQuestion)');
}
});
});
@@ -74,6 +74,75 @@ describe('dev-setup: never silently mutates global settings.json', () => {
}); });
}); });
describe('setup: PT_EXPLICIT provenance (Conductor auto-opt-in respects explicit decisions)', () => {
// The phantom-hooks root cause (Bug A): the Conductor auto-opt-in upgraded
// PT_DECISION "prompt" → "yes" even when "prompt" came from dev-setup's
// EXPLICIT --plan-tune-hooks=prompt flag, so every new Conductor workspace
// installed hooks pointing at its ephemeral worktree.
test('flag and env set PT_EXPLICIT=1', () => {
expect(setupSrc).toContain('PT_EXPLICIT=1');
const flagIdx = setupSrc.indexOf('PT_DECISION="$PLAN_TUNE_HOOKS_MODE"');
const explicitIdx = setupSrc.indexOf('PT_EXPLICIT=1', flagIdx);
expect(flagIdx).toBeGreaterThan(-1);
expect(explicitIdx).toBeGreaterThan(flagIdx);
});
test('the Conductor auto-opt-in fires only on the true silent fall-through', () => {
expect(setupSrc).toMatch(
/\[ "\$PT_DECISION" = "prompt" \] && \[ "\$PT_EXPLICIT" -eq 0 \] && \{ \[ -n "\$\{CONDUCTOR_WORKSPACE_PATH:-\}" \] \|\| \[ -n "\$\{CONDUCTOR_PORT:-\}" \]; \}/,
);
});
test('config provenance uses gstack-config has (env-resolution-safe), never a hardcoded config grep', () => {
// `gstack-config get` returns the default "prompt" for absent keys, so
// key PRESENCE must come from `has`, which resolves GSTACK_STATE_ROOT /
// GSTACK_HOME / GSTACK_STATE_DIR the same way `get` does. A hardcoded
// grep of ~/.gstack/config.yaml misclassifies under env overrides.
expect(setupSrc).toMatch(/"\$GSTACK_CONFIG" has plan_tune_hooks/);
expect(setupSrc).not.toMatch(/grep -q ["']\^plan_tune_hooks:/);
});
});
describe('gstack-config: has subcommand (key-presence provenance)', () => {
let tmpHome2: string;
let env2: NodeJS.ProcessEnv;
beforeAll(() => {
tmpHome2 = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-cfg-has-'));
env2 = { ...process.env, GSTACK_STATE_ROOT: tmpHome2 };
});
afterAll(() => {
fs.rmSync(tmpHome2, { recursive: true, force: true });
});
function has(key: string): number {
try {
execSync(`${GSTACK_CONFIG} has '${key}'`, { encoding: 'utf-8', env: env2 });
return 0;
} catch (e: any) {
return e.status ?? 1;
}
}
test('absent key exits nonzero even though get returns the default', () => {
expect(has('plan_tune_hooks')).not.toBe(0);
const got = execSync(`${GSTACK_CONFIG} get plan_tune_hooks`, { encoding: 'utf-8', env: env2 }).trim();
expect(got).toBe('prompt'); // default — indistinguishable from a saved value via get
});
test('present key exits 0 through the same STATE_DIR resolution as get', () => {
execSync(`${GSTACK_CONFIG} set plan_tune_hooks no`, { encoding: 'utf-8', env: env2 });
expect(has('plan_tune_hooks')).toBe(0);
// GSTACK_STATE_ROOT was the writer — a hardcoded ~/.gstack grep would miss it.
});
test('rejects malformed keys', () => {
expect(has('bad key$(touch /tmp/pwned)')).not.toBe(0);
});
});
describe('gstack-config: plan_tune_hooks key', () => { describe('gstack-config: plan_tune_hooks key', () => {
// Isolate state: gstack-config reads $GSTACK_HOME/config.yaml. Point it at a // Isolate state: gstack-config reads $GSTACK_HOME/config.yaml. Point it at a
// fresh temp dir so `get` returns the built-in default rather than whatever // fresh temp dir so `get` returns the built-in default rather than whatever
+5 -1
View File
@@ -61,7 +61,11 @@ describe('setup: _link_or_copy invariant (D7)', () => {
const hookEnd = SETUP_SRC.indexOf('\nif [ "$TEAM_MODE" -eq 1 ]', hookStart); const hookEnd = SETUP_SRC.indexOf('\nif [ "$TEAM_MODE" -eq 1 ]', hookStart);
const hookSection = SETUP_SRC.slice(hookStart, hookEnd); const hookSection = SETUP_SRC.slice(hookStart, hookEnd);
expect(hookSection).toContain('IS_WINDOWS'); expect(hookSection).toContain('IS_WINDOWS');
expect(hookSection).toContain('bash $SOURCE_GSTACK_DIR/bin/gstack-session-update'); // v1.67.2 phantom-hooks fix: the command comes from the CANONICAL install
// via _hook_command_path (never $SOURCE_GSTACK_DIR — ephemeral trees were
// baked into settings.json), but the Windows bash prefix survives.
expect(hookSection).toContain('HOOK_CMD="bash $SESSION_UPDATE_CMD"');
expect(hookSection).toContain('_hook_command_path bin/gstack-session-update');
}); });
}); });
+26 -15
View File
@@ -261,37 +261,47 @@ describe('timeline-stop-hook wiring', () => {
expect(setup).not.toMatch(/list-sources 2>\/dev\/null \| grep -q "gstack-timeline-stop"/); expect(setup).not.toMatch(/list-sources 2>\/dev\/null \| grep -q "gstack-timeline-stop"/);
}); });
test('fresh register prefers the global-install hook path when present', () => { test('hook path resolution is canonical-only: global install or skip, never the worktree', () => {
// Drive setup's _hook_install_path directly: global install present → the // Drive setup's _hook_command_path directly: canonical install present →
// registration survives deleting the worktree setup ran from. // that path (survives deleting the worktree setup ran from). Absent →
// non-zero and NO output — registration is skipped with a log line; the
// running tree's path is NEVER baked into settings.json (the SOURCE
// fallback was the phantom-hooks defect and is deliberately gone).
const setup = fs.readFileSync(path.join(ROOT, 'setup'), 'utf-8'); const setup = fs.readFileSync(path.join(ROOT, 'setup'), 'utf-8');
const fn = setup.match(/_hook_install_path\(\) \{[\s\S]*?\n\}/); const fn = setup.match(/_hook_command_path\(\) \{[\s\S]*?\n\}/);
expect(fn).not.toBeNull(); expect(fn).not.toBeNull();
const fakeHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-hookpath-')); const fakeHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-hookpath-'));
try { try {
const globalHook = path.join( const canonicalRoot = path.join(fakeHome, '.claude', 'skills', 'gstack');
fakeHome, '.claude', 'skills', 'gstack', 'hosts', 'claude', 'hooks', 'timeline-stop-hook', const globalHook = path.join(canonicalRoot, 'hosts', 'claude', 'hooks', 'timeline-stop-hook');
);
fs.mkdirSync(path.dirname(globalHook), { recursive: true }); fs.mkdirSync(path.dirname(globalHook), { recursive: true });
fs.writeFileSync(globalHook, '#!/bin/sh\nexit 0\n', { mode: 0o755 }); fs.writeFileSync(globalHook, '#!/bin/sh\nexit 0\n', { mode: 0o755 });
const env = { ...process.env, HOME: fakeHome, SOURCE_GSTACK_DIR: '/some/dev/worktree' }; const env = {
...process.env,
HOME: fakeHome,
SOURCE_GSTACK_DIR: '/some/dev/worktree',
CANONICAL_GSTACK_ROOT: canonicalRoot,
};
const withGlobal = spawnSync( const withGlobal = spawnSync(
'bash', 'bash',
['-c', `${fn![0]}\n_hook_install_path hosts/claude/hooks/timeline-stop-hook`], ['-c', `${fn![0]}\n_hook_command_path hosts/claude/hooks/timeline-stop-hook`],
{ env, encoding: 'utf-8', timeout: 10_000 }, { env, encoding: 'utf-8', timeout: 10_000 },
); );
expect(withGlobal.status).toBe(0);
expect(withGlobal.stdout.trim()).toBe(globalHook); expect(withGlobal.stdout.trim()).toBe(globalHook);
// No global install (fresh first install from a clone) → setup-time path. // No canonical install → the resolver FAILS (caller logs a visible
// skip); it never falls back to the setup-time tree.
fs.rmSync(globalHook); fs.rmSync(globalHook);
const withoutGlobal = spawnSync( const withoutGlobal = spawnSync(
'bash', 'bash',
['-c', `${fn![0]}\n_hook_install_path hosts/claude/hooks/timeline-stop-hook`], ['-c', `${fn![0]}\n_hook_command_path hosts/claude/hooks/timeline-stop-hook`],
{ env, encoding: 'utf-8', timeout: 10_000 }, { env, encoding: 'utf-8', timeout: 10_000 },
); );
expect(withoutGlobal.stdout.trim()).toBe('/some/dev/worktree/hosts/claude/hooks/timeline-stop-hook'); expect(withoutGlobal.status).not.toBe(0);
expect(withoutGlobal.stdout.trim()).toBe('');
} finally { } finally {
fs.rmSync(fakeHome, { recursive: true, force: true }); fs.rmSync(fakeHome, { recursive: true, force: true });
} }
@@ -357,10 +367,11 @@ describe('timeline-stop-hook wiring', () => {
} }
}); });
test('corrupt settings.json: ensure-event refuses (exit 1) and never rewrites the file', () => { test('corrupt settings.json: ensure-event refuses (exit 3) and never rewrites the file', () => {
// The old catch{} folded an unparseable EXISTING settings.json into {} // The old catch{} folded an unparseable EXISTING settings.json into {}
// and the atomic write replaced the user's permissions/env/other hooks // and the atomic write replaced the user's permissions/env/other hooks
// with just ours. Now: loud stderr error, exit 1, file byte-identical. // with just ours. Now: loud stderr error, fail-closed exit 3 (the
// settings-hook parse-refusal code), file byte-identical.
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-ensure-corrupt-')); const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-ensure-corrupt-'));
try { try {
const settingsFile = path.join(dir, 'settings.json'); const settingsFile = path.join(dir, 'settings.json');
@@ -375,7 +386,7 @@ describe('timeline-stop-hook wiring', () => {
'--timeout', '5', '--timeout', '5',
], { env: { ...process.env, GSTACK_SETTINGS_FILE: settingsFile }, encoding: 'utf-8', timeout: 15_000 }); ], { env: { ...process.env, GSTACK_SETTINGS_FILE: settingsFile }, encoding: 'utf-8', timeout: 15_000 });
expect(r.status).toBe(1); expect(r.status).toBe(3);
expect(r.stderr).toContain('not valid JSON'); expect(r.stderr).toContain('not valid JSON');
// Never rewritten — the corrupt bytes (and whatever the user can still // Never rewritten — the corrupt bytes (and whatever the user can still
// salvage from them) survive verbatim. // salvage from them) survive verbatim.
+123
View File
@@ -231,3 +231,126 @@ describe('gstack-uninstall', () => {
}); });
}); });
}); });
// ----------------------------------------------------------------------
// Hook-cleanup ordering (phantom-hooks fix). Pre-v1.67.2, the settings
// cleanup ran AFTER `rm -rf $CLAUDE_SKILLS/gstack` — and SETTINGS_HOOK
// resolves via $(dirname "$0") INSIDE that root, so a real global uninstall
// (running the installed copy) silently orphaned every hook. Prior tests
// masked this by running the uninstaller from the repo checkout. This test
// runs the INSTALLED copy.
// ----------------------------------------------------------------------
describe('hook cleanup runs before the install root is deleted', () => {
test('uninstall executed FROM the install root still removes hook entries', () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-uninstall-order-'));
try {
const mockHome = path.join(tmp, 'home');
const installRoot = path.join(mockHome, '.claude', 'skills', 'gstack');
const installBin = path.join(installRoot, 'bin');
fs.mkdirSync(installBin, { recursive: true });
// The installed copies — the uninstaller under test IS the one inside
// the root it deletes.
for (const b of ['gstack-uninstall', 'gstack-settings-hook', 'gstack-session-update', 'gstack-config']) {
const src = path.join(ROOT, 'bin', b);
const dst = path.join(installBin, b);
fs.copyFileSync(src, dst);
fs.chmodSync(dst, 0o755);
}
const settingsFile = path.join(mockHome, '.claude', 'settings.json');
fs.writeFileSync(settingsFile, JSON.stringify({
hooks: {
PostToolUse: [{
matcher: '(AskUserQuestion|mcp__.*__AskUserQuestion)',
_gstack_source: 'auq-error-fallback',
hooks: [{ type: 'command', command: '/dead/wt/hosts/claude/hooks/auq-error-fallback-hook', timeout: 5 }],
}],
Stop: [{
hooks: [{ type: 'command', command: `${installRoot}/hosts/claude/hooks/timeline-stop-hook`, timeout: 5 }],
}],
PreCompact: [{ hooks: [{ type: 'command', command: '/Users/me/my-own-hook' }] }],
},
}, null, 2));
fs.mkdirSync(path.join(mockHome, '.gstack'), { recursive: true });
const result = spawnSync('bash', [path.join(installBin, 'gstack-uninstall'), '--force', '--keep-state'], {
stdio: 'pipe',
env: {
...process.env,
HOME: mockHome,
GSTACK_SETTINGS_FILE: settingsFile,
GSTACK_STATE_ROOT: path.join(mockHome, '.gstack'),
},
cwd: tmp,
});
expect(result.status).toBe(0);
// Install root gone…
expect(fs.existsSync(installRoot)).toBe(false);
// …and the hook entries were still cleaned (cleanup ran BEFORE deletion).
const s = JSON.parse(fs.readFileSync(settingsFile, 'utf-8'));
expect(s.hooks?.PostToolUse).toBeUndefined();
expect(s.hooks?.Stop).toBeUndefined();
// The user's own hook survives the sweep.
expect(s.hooks?.PreCompact?.[0]?.hooks?.[0]?.command).toBe('/Users/me/my-own-hook');
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
// 30s: the copied uninstaller spawns several bun -e children; on a loaded
// box their cold starts blow bun's default 5s per-test timeout.
}, 30000);
});
describe('hook cleanup under lock contention is loud, never silent (review-army)', () => {
test('a held foreign lock during uninstall surfaces the give-up warning on stderr', () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-uninstall-lock-'));
try {
const mockHome = path.join(tmp, 'home');
const installRoot = path.join(mockHome, '.claude', 'skills', 'gstack');
const installBin = path.join(installRoot, 'bin');
fs.mkdirSync(installBin, { recursive: true });
for (const b of ['gstack-uninstall', 'gstack-settings-hook', 'gstack-session-update', 'gstack-config']) {
const dst = path.join(installBin, b);
fs.copyFileSync(path.join(ROOT, 'bin', b), dst);
fs.chmodSync(dst, 0o755);
}
const settingsFile = path.join(mockHome, '.claude', 'settings.json');
fs.writeFileSync(settingsFile, JSON.stringify({
hooks: {
Stop: [{
_gstack_source: 'gstack-timeline-stop',
hooks: [{ type: 'command', command: `${installRoot}/hosts/claude/hooks/timeline-stop-hook` }],
}],
},
}, null, 2));
fs.mkdirSync(path.join(mockHome, '.gstack'), { recursive: true });
// A fresh foreign lock: pre-fix, every cleanup call silently skipped and
// uninstall reported clean while orphaning the hooks forever.
fs.mkdirSync(`${settingsFile}.lock`);
fs.writeFileSync(path.join(`${settingsFile}.lock`, 'owner'), 'another-live-process');
const result = spawnSync('bash', [path.join(installBin, 'gstack-uninstall'), '--force', '--keep-state'], {
stdio: 'pipe',
env: {
...process.env,
HOME: mockHome,
GSTACK_SETTINGS_FILE: settingsFile,
GSTACK_STATE_ROOT: path.join(mockHome, '.gstack'),
GSTACK_SETTINGS_LOCK_TIMEOUT_MS: '300',
},
cwd: tmp,
});
const stderr = result.stderr.toString();
const s = JSON.parse(fs.readFileSync(settingsFile, 'utf-8'));
const cleaned = s.hooks?.Stop === undefined;
// Either the sweep still happened, or the user SEES why it didn't.
expect(cleaned || /could not acquire lock/.test(stderr)).toBe(true);
expect(/could not acquire lock/.test(stderr)).toBe(true);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
// 30s: several settings-hook calls each wait out the 300ms lock give-up,
// and their bun -e cold starts stack up under load — the default 5s
// per-test budget is too tight on a busy box.
}, 30000);
});
+11 -1
View File
@@ -349,7 +349,17 @@ describe('opt-in contract (adapted from the fork: NOT registered by default)', (
const gate = fs.readFileSync(GATE, 'utf-8'); const gate = fs.readFileSync(GATE, 'utf-8');
test('./setup does NOT register the gate — a Stop hook running the verify command after every turn is opt-in', () => { test('./setup does NOT register the gate — a Stop hook running the verify command after every turn is opt-in', () => {
expect(setup).not.toContain('verify-gate'); // The only permitted mentions are comments and the --no-team sweep
// EXCLUSION (protecting a user-registered gate from team-mode teardown).
// A registration (add-event) referencing the gate stays banned.
const mentions = setup.split('\n').filter((l) => l.includes('verify-gate'));
expect(mentions.length).toBeGreaterThan(0); // the exclusion itself is pinned
for (const line of mentions) {
const t = line.trim();
const allowed = t.startsWith('#') || t.includes('GSTACK_SWEEP_EXCLUDE_SOURCES="verify-gate"');
expect(allowed).toBe(true);
expect(t).not.toContain('add-event');
}
}); });
test('the bin documents its own registration and removal commands', () => { test('the bin documents its own registration and removal commands', () => {