mirror of
https://github.com/garrytan/gstack.git
synced 2026-08-23 22:42:31 +02:00
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:
co-authored by
Claude Fable 5
parent
28d59ad56c
commit
9da6692930
@@ -1920,27 +1920,113 @@ rm -f /tmp/gstack-latest-version
|
||||
|
||||
# 10. Team mode: register/unregister SessionStart 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
|
||||
# launched directly by the OS — the file-association dialog appears instead.
|
||||
# Prefix with 'bash' so Claude Code's hook runner invokes Git Bash explicitly.
|
||||
if [ "$IS_WINDOWS" -eq 1 ]; then
|
||||
HOOK_CMD="bash $SOURCE_GSTACK_DIR/bin/gstack-session-update"
|
||||
else
|
||||
HOOK_CMD="$SOURCE_GSTACK_DIR/bin/gstack-session-update"
|
||||
# Paths with whitespace are quoted so the hook command survives shell parsing.
|
||||
SESSION_UPDATE_CMD="$(_hook_command_path bin/gstack-session-update || true)"
|
||||
HOOK_CMD=""
|
||||
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
|
||||
|
||||
if [ "$TEAM_MODE" -eq 1 ]; then
|
||||
"$GSTACK_CONFIG" set auto_upgrade true 2>/dev/null || true
|
||||
"$GSTACK_CONFIG" set team_mode true 2>/dev/null || true
|
||||
|
||||
# Register SessionStart hook in Claude Code settings
|
||||
if [ -x "$SETTINGS_HOOK" ]; then
|
||||
"$SETTINGS_HOOK" add "$HOOK_CMD" 2>/dev/null || true
|
||||
# Register SessionStart hook in Claude Code settings (schema-aware: the
|
||||
# legacy `add` action's substring dedupe bypasses the KNOWN_HOOKS identity
|
||||
# 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
|
||||
|
||||
log ""
|
||||
log "Team mode enabled: gstack will auto-update at the start of each Claude Code session."
|
||||
log " Hook: $HOOK_CMD"
|
||||
if [ -n "$HOOK_CMD" ]; then
|
||||
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 ""
|
||||
log "Bootstrap your repo:"
|
||||
@@ -2045,22 +2131,13 @@ if [ -x "$DETECT_BIN" ]; then
|
||||
fi
|
||||
fi
|
||||
|
||||
# Hook commands registered into ~/.claude/settings.json must survive deletion
|
||||
# of the directory setup ran from. A dev-worktree setup used to bake
|
||||
# $SOURCE_GSTACK_DIR's absolute path into the registration; deleting that
|
||||
# worktree left a dead hook erroring on every trigger. Prefer the global
|
||||
# install (~/.claude/skills/gstack — a persistent checkout, or a stable
|
||||
# symlink) and fall back to the setup-time source tree only when no global
|
||||
# install exists yet (first install from a fresh clone).
|
||||
_hook_install_path() {
|
||||
local rel="$1"
|
||||
local global_hook="$HOME/.claude/skills/gstack/$rel"
|
||||
if [ -x "$global_hook" ]; then
|
||||
printf '%s' "$global_hook"
|
||||
else
|
||||
printf '%s' "$SOURCE_GSTACK_DIR/$rel"
|
||||
fi
|
||||
}
|
||||
# Hook path resolution is CANONICAL-ONLY via the resolver defined near
|
||||
# CANONICAL_GSTACK_ROOT above (_hook_command_path): a hook command registered into
|
||||
# ~/.claude/settings.json must survive deletion of the directory setup ran
|
||||
# from, and no heuristic can enumerate every ephemeral tree (manual worktrees,
|
||||
# temp clones, CI checkouts) — so there is deliberately NO fallback to
|
||||
# $SOURCE_GSTACK_DIR here. A missing canonical hook means "skip registration
|
||||
# with a log line", never "bake the running tree's path".
|
||||
|
||||
# 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
|
||||
# already registered under that tag, the install skips the consent prompt and
|
||||
# only refreshes the registered command paths in place (ensure-event is a
|
||||
# no-op when they already match — see the stale-path note above).
|
||||
PLAN_TUNE_LOG_HOOK="$(_hook_install_path hosts/claude/hooks/question-log-hook)"
|
||||
PLAN_TUNE_PREF_HOOK="$(_hook_install_path hosts/claude/hooks/question-preference-hook)"
|
||||
AUQ_ERROR_FALLBACK_HOOK="$(_hook_install_path hosts/claude/hooks/auq-error-fallback-hook)"
|
||||
# no-op when they already match).
|
||||
PLAN_TUNE_LOG_HOOK="$(_hook_command_path hosts/claude/hooks/question-log-hook || true)"
|
||||
PLAN_TUNE_PREF_HOOK="$(_hook_command_path hosts/claude/hooks/question-preference-hook || true)"
|
||||
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"
|
||||
|
||||
# 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 ] \
|
||||
&& [ -x "$SETTINGS_HOOK" ] \
|
||||
&& [ -x "$PLAN_TUNE_LOG_HOOK" ] \
|
||||
&& [ -x "$PLAN_TUNE_PREF_HOOK" ]; then
|
||||
&& [ -n "$PLAN_TUNE_LOG_HOOK" ] \
|
||||
&& [ -n "$PLAN_TUNE_PREF_HOOK" ]; then
|
||||
|
||||
# Already installed? Require BOTH the plan-tune source AND the AUQ-error-fallback
|
||||
# source — so an existing install that predates the fallback hook re-runs the
|
||||
@@ -2101,9 +2193,25 @@ if [ "$NO_TEAM_MODE" -ne 1 ] \
|
||||
# This guarantees scripted/workspace setups (conductor, CI) are never
|
||||
# interactive: pass --no-plan-tune-hooks (or --plan-tune-hooks) and the
|
||||
# block runs to completion with no `read`.
|
||||
PT_DECISION="$PLAN_TUNE_HOOKS_MODE"
|
||||
[ -z "$PT_DECISION" ] && PT_DECISION="${GSTACK_PLAN_TUNE_HOOKS:-}"
|
||||
[ -z "$PT_DECISION" ] && PT_DECISION="$("$GSTACK_CONFIG" get plan_tune_hooks 2>/dev/null || true)"
|
||||
# PT_EXPLICIT provenance: an EXPLICIT decision (CLI flag, env var, or a key
|
||||
# literally present in the config file) must never be overridden by the
|
||||
# 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
|
||||
# or env var all resolve correctly (an unrecognized opt-in must NOT silently
|
||||
# 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
|
||||
# users without that backstop. Treat Conductor as an implicit opt-in — but
|
||||
# only on the silent fall-through, never overriding an explicit --no-plan-tune-hooks.
|
||||
if [ "$PT_DECISION" = "prompt" ] && { [ -n "${CONDUCTOR_WORKSPACE_PATH:-}" ] || [ -n "${CONDUCTOR_PORT:-}" ]; }; then
|
||||
# Only the true silent fall-through auto-opts-in. An explicit
|
||||
# --plan-tune-hooks=prompt (bin/dev-setup passes exactly this so ephemeral
|
||||
# workspace setups never install) stays "prompt" — this was the bug that
|
||||
# baked worktree hook paths into every Conductor user's settings.json.
|
||||
if [ "$PT_DECISION" = "prompt" ] && [ "$PT_EXPLICIT" -eq 0 ] && { [ -n "${CONDUCTOR_WORKSPACE_PATH:-}" ] || [ -n "${CONDUCTOR_PORT:-}" ]; }; then
|
||||
PT_DECISION="yes"
|
||||
_PT_CONDUCTOR_AUTO=1
|
||||
fi
|
||||
@@ -2129,18 +2241,22 @@ if [ "$NO_TEAM_MODE" -ne 1 ] \
|
||||
# ensure-event (not add-event): registers when missing, RE-POINTS a stale
|
||||
# command path in place when the registration differs, and is a true no-op
|
||||
# (no write, no backup churn) when it already matches.
|
||||
# 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 \
|
||||
--event PostToolUse \
|
||||
--matcher '(AskUserQuestion|mcp__.*__AskUserQuestion)' \
|
||||
--command "$PLAN_TUNE_LOG_HOOK" \
|
||||
--source plan-tune-cathedral \
|
||||
--timeout 5
|
||||
--timeout 5 || _pt_install_rc=1
|
||||
"$SETTINGS_HOOK" ensure-event \
|
||||
--event PreToolUse \
|
||||
--matcher '(AskUserQuestion|mcp__.*__AskUserQuestion)' \
|
||||
--command "$PLAN_TUNE_PREF_HOOK" \
|
||||
--source plan-tune-cathedral \
|
||||
--timeout 5
|
||||
--timeout 5 || _pt_install_rc=1
|
||||
# AskUserQuestion-failure prose-fallback reliability hook (OV3:B). Fires only when
|
||||
# an AskUserQuestion call returns an error/missing result; inert on success and
|
||||
# inert if the platform doesn't invoke PostToolUse on tool errors. MUST use its
|
||||
@@ -2148,14 +2264,15 @@ if [ "$NO_TEAM_MODE" -ne 1 ] \
|
||||
# REPLACES the entry's hooks, so sharing 'plan-tune-cathedral' would overwrite the
|
||||
# question-log capture hook (same event+matcher). A distinct source = a second
|
||||
# PostToolUse entry; both run in parallel.
|
||||
if [ -x "$AUQ_ERROR_FALLBACK_HOOK" ]; then
|
||||
if [ -n "$AUQ_ERROR_FALLBACK_HOOK" ]; then
|
||||
"$SETTINGS_HOOK" ensure-event \
|
||||
--event PostToolUse \
|
||||
--matcher '(AskUserQuestion|mcp__.*__AskUserQuestion)' \
|
||||
--command "$AUQ_ERROR_FALLBACK_HOOK" \
|
||||
--source auq-error-fallback \
|
||||
--timeout 5
|
||||
--timeout 5 || _pt_install_rc=1
|
||||
fi
|
||||
return $_pt_install_rc
|
||||
}
|
||||
|
||||
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."
|
||||
elif [ "$PT_DECISION" = "yes" ]; then
|
||||
# Explicit opt-in (flag / env / config) or Conductor implicit opt-in. Non-interactive.
|
||||
_install_plan_tune_hooks
|
||||
log ""
|
||||
if [ "${_PT_CONDUCTOR_AUTO:-0}" -eq 1 ]; then
|
||||
log "AskUserQuestion reliability hooks installed (Conductor detected): decisions"
|
||||
log "render as a prose brief instead of the flaky AskUserQuestion tool. Inspect with /plan-tune."
|
||||
if _install_plan_tune_hooks; then
|
||||
log ""
|
||||
if [ "${_PT_CONDUCTOR_AUTO:-0}" -eq 1 ]; then
|
||||
log "AskUserQuestion reliability hooks installed (Conductor detected): decisions"
|
||||
log "render as a prose brief instead of the flaky AskUserQuestion tool. Inspect with /plan-tune."
|
||||
else
|
||||
log "Plan-tune hooks installed. Run /plan-tune anytime to inspect."
|
||||
fi
|
||||
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
|
||||
touch "$PLAN_TUNE_INSTALL_MARKER"
|
||||
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=""
|
||||
case "$PLAN_TUNE_INSTALL_REPLY" in
|
||||
y|Y)
|
||||
_install_plan_tune_hooks
|
||||
log ""
|
||||
log "Plan-tune hooks installed. Run /plan-tune anytime to inspect."
|
||||
if _install_plan_tune_hooks; then
|
||||
log ""
|
||||
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"
|
||||
;;
|
||||
n|N)
|
||||
@@ -2262,15 +2387,18 @@ fi
|
||||
# (F5): the hook always exits 0 and repairs best-effort — it can never block
|
||||
# a session. Removed by --no-team and gstack-uninstall.
|
||||
#
|
||||
# The command path prefers the global install (see _hook_install_path): a
|
||||
# dev-worktree setup used to bake its own absolute dir into settings.json, so
|
||||
# deleting the worktree left a dead hook erroring on every session stop — and
|
||||
# the old presence-only dedup (list-sources | grep) never re-pointed it on a
|
||||
# re-run. ensure-event registers when missing, replaces a stale path in place
|
||||
# (one atomic write — never zero or two registrations), and no-ops when the
|
||||
# The command path is canonical-only (see _hook_command_path): a dev-worktree
|
||||
# setup used to bake its own absolute dir into settings.json, so deleting the
|
||||
# worktree left a dead hook erroring on every session stop — and the old
|
||||
# presence-only dedup (list-sources | grep) never re-pointed it on a re-run.
|
||||
# ensure-event registers when missing, replaces a stale path in place (one
|
||||
# atomic write — never zero or two registrations), and no-ops when the
|
||||
# registration already matches.
|
||||
TIMELINE_STOP_HOOK="$(_hook_install_path hosts/claude/hooks/timeline-stop-hook)"
|
||||
if [ "$NO_TEAM_MODE" -ne 1 ] && [ -x "$SETTINGS_HOOK" ] && [ -x "$TIMELINE_STOP_HOOK" ]; then
|
||||
TIMELINE_STOP_HOOK="$(_hook_command_path hosts/claude/hooks/timeline-stop-hook || true)"
|
||||
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 \
|
||||
--event Stop \
|
||||
--command "$TIMELINE_STOP_HOOK" \
|
||||
@@ -2289,16 +2417,26 @@ if [ "$NO_TEAM_MODE" -ne 1 ] && [ -x "$SETTINGS_HOOK" ] && [ -x "$TIMELINE_STOP_
|
||||
esac
|
||||
else
|
||||
# Non-fatal to setup, but never silent: the hardened settings-hook refuses
|
||||
# to rewrite a corrupt settings.json (exit 1), and swallowing that refusal
|
||||
# left the Stop hook unregistered with no signal.
|
||||
# to mutate a corrupt settings.json (exit 3) or under a held lock (exit 5),
|
||||
# and swallowing that refusal left the Stop hook unregistered with no signal.
|
||||
log " warning: settings hook update failed: $(printf '%s\n' "$_TL_ENSURE_OUT" | head -1) — run $SETTINGS_HOOK manually"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Also tear down plan-tune + timeline hooks on --no-team (matches the existing pattern).
|
||||
# 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
|
||||
"$SETTINGS_HOOK" remove-source --source plan-tune-cathedral 2>/dev/null || true
|
||||
"$SETTINGS_HOOK" remove-source --source gstack-timeline-stop 2>/dev/null || true
|
||||
"$SETTINGS_HOOK" remove-source --source plan-tune-cathedral >/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
|
||||
|
||||
# ─── Redact pre-push guard consent (#1946) ───────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user