v1.61.0.0 fix wave: guards failing open / silent failures (9 fixes, 4 community PRs absorbed) (#2472)

* fix(careful): warn on chained rm even when the last target is safe

The safe-exception block whitelisted rm -rf of build artifacts by
extracting targets with a single greedy match (.*rm ...), which only ever
inspects the LAST rm in the command. A chain like 'rm -rf /; rm -rf
node_modules' was therefore judged solely by its trailing safe target and
allowed without warning, waving through the destructive 'rm -rf /'.

Gate the shortcut to single rm invocations: when any shell separator
(; | & newline, incl. JSON-escaped \n/\r from the grep extraction path)
is present, fall through to the destructive-pattern check, which warns on
any recursive rm. Single-command artifact cleanups still allow.

Adds 3 regression tests covering semicolon and && chains in both orders.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* harden(careful): substitution separators + capital -R recursive flag (#2039)

Two residual fail-opens in the same guard PR #2040 hardened, both verified
by executing the script pre-fix:

- rm -rf $(./wipe-all)/node_modules silently allowed: the substitution token
  ends in a whitelisted suffix and the safe-exception early exit skipped ALL
  downstream checks. $( and backtick now count as chain separators; plain
  $VAR expansion stays allowed.
- rm -R / silently allowed: both greps required a lowercase r in the flag
  cluster; capital -R is the documented BSD/macOS recursive flag. Both greps
  now match -[a-zA-Z]*[rR].

Six new tests: substitution x2 -> ask, capital-R x2 -> ask, rm -Rf
node_modules single-command -> still allowed, escaped-newline branch
(existing code, previously untested), and a pinned deliberate FP
(cd app && rm -rf node_modules -> ask) documenting the fail-closed
direction on chains.

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

* fix(context-restore): prefer the current branch's own checkpoint (#2052)

All worktrees of a repo share one origin-derived slug, so they share one
`~/.gstack/projects/<slug>/checkpoints/` dir. `/context-restore` loaded the
newest checkpoint across the whole dir, so in one worktree it could silently
restore a *sibling worktree's* newer checkpoint.

Step 1 now orders candidates current-branch-first (read from each file's
`branch:` frontmatter), keeping other branches as a fallback. A branch is
checked out in at most one worktree, so this stops cross-worktree contamination
while preserving Conductor cross-branch handoff: when the current branch has no
checkpoint of its own, the full newest-first set is still used.

- scan the 200 newest before partitioning so a current-branch checkpoint sitting
  below a burst of sibling saves is still found; output still capped at 20
- non-git / detached HEAD / branchless legacy saves fall back to the old
  newest-first behavior (back-compat)
- +5 regression tests in context-save-hardening.test.ts (the #2052 bug case
  fails on the old pipeline); regenerated SKILL.md + proactive-suggestions.json

Fixes #2052

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(gbrain): pass --confirm-destructive on drift re-register (#1985)

ensureSourceRegistered() handles match-but-different-path by removing the
old source then re-adding it at the new path. The remove was issued as
`gbrain sources remove <id> --yes`, but gbrain >= 0.42 gates `sources
remove` behind `--confirm-destructive` (`--yes` alone no longer suppresses
the data-loss prompt). The remove therefore fails with "To proceed, pass
--confirm-destructive", which ensureSourceRegistered surfaces as "source
registration failed" — aborting the entire /sync-gbrain code stage for any
already-registered source whose path has drifted. The memory and brain-sync
stages still pass, so the code index silently stops refreshing.

The orchestrator's own safeSourcesRemove() already passes
--confirm-destructive; this brings the lib helper in line with that
convention. Keeps --yes for older gbrain.

Tests: extend the fake gbrain shim in gbrain-sources.test.ts to simulate
the gbrain >= 0.42 guard (remove without --confirm-destructive exits 1),
update the drift re-register assertion, and add a regression test that
proves the drift path no longer throws. Both fail on main with the exact
"To proceed, pass --confirm-destructive" error and pass with the fix.

Fixes #1985

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* harden(gbrain-sources): route drift remove through #1734 guards + realpath drift check

Absorbing #2031 un-blocked a destructive remove that bypassed the #1734
data-loss guards: ensureSourceRegistered's drift path issued
`gbrain sources remove` directly, without the detectAutopilot +
decideSourceRemove checks every other remove routes through via
safeSourcesRemove. gbrain >= 0.42's own prompt was accidentally blocking
that path; with --confirm-destructive passed it is live again.

- Drift remove now refuses LOUDLY (throws, actionable message) while an
  autopilot is active or when decideSourceRemove disallows; a silent
  changed=false would hide the drifted registration.
- decideSourceRemove's extraArgs (--keep-storage when supported) propagate
  to the remove call, matching safeSourcesRemove.
- Drift is realpath-normalized before being declared: a symlink alias of the
  same directory (macOS /tmp -> /private/tmp) is a match, not drift — the
  probable cause of #1985's reporter hitting the remove on an unmoved repo.
- Drift fires a loud stderr line (old -> new path); perpetual drift in logs
  is the trigger for promoting #1985's reindex-in-place design.

Tests: autopilot-active refusal (no remove in call log), fail-closed refusal
on unreadable sources list, --keep-storage propagation, symlink-alias
no-drift; existing drift tests pin the guard probes so a live autopilot on
the dev machine can't flip them.

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

* fix(developer-profile): exclude mode:resources rows from SESSION_COUNT, TIER, NUDGE_ELIGIBLE (#2067)

Every /office-hours run appends a mode:"resources" bookkeeping row alongside
the real session row, so --read double-counted sessions (~2x): tiers promoted
early and the builder-to-founder nudge armed prematurely. The file already
filtered resources rows for LAST_*/CROSS_PROJECT; the same realSessions
filter now feeds SESSION_COUNT/TIER, and the nudge predicate is the faithful
allowlist (mode === 'builder') so a future mode #4 fails closed instead of
re-opening this bug.

8 regression tests: count vs resources noise, tier boundaries both sides,
nudge false-with-noise / true-at-3-builders, cross-project trailing row.

Absorbed from PR #1991 by @mvann (fix + tests commits; the PR's version-bump
commit is superseded by this wave's consolidated release commit).

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

* fix(hooks): passThrough() two-branch contract — never emit permissionDecision:'defer' (#2035, #2006)

Every AskUserQuestion died with "Tool result missing due to internal error"
on current Claude Code builds (Desktop 1.14271.0, CC 2.1.177). Root cause:
the question-preference-hook emitted permissionDecision:'defer' on every
pass-through path. 'defer' is a real PreToolUse value, but since CC v2.1.89
its semantics are "pause this tool call for external resumption" (headless
resume) — never "abstain". Interactive sessions have nothing to resume the
paused call, so the tool orphaned. Pre-2.1.89 builds ignored the unknown
value, which is why the hook worked when it shipped and broke later.

The fix is the two-branch pass-through contract:
- no context -> exit 0 with EXACTLY empty stdout
- memory nuggets present -> hookSpecificOutput with hookEventName +
  additionalContext ONLY (the documented shape; plan-tune Layer 8 memory
  injection ships through this branch and keeps working)

defer() is renamed passThrough() so the function says what it does, and
docs/spikes/claude-code-hook-mutation.md's protocol contract (cited by the
hook header) is corrected in the same commit — it taught '"defer" — let
permission flow continue' and was the reintroduction vector.

Test contract rewritten in the same commit (13 assertions across 3 files,
verified fail-first against the unfixed hook): pass-through paths assert
exact-empty stdout (a garbage/partial write cannot slip past an
optional-chained parse), the nugget path asserts permissionDecision is
ABSENT while additionalContext survives, and a new tripwire asserts no
non-deny path ever puts the string "permissionDecision" on stdout. The
deny (auto-decide) and Conductor prose-redirect paths are unchanged.

Deployment: no migration needed — settings.json points at the absolute
bash shim which execs the .ts live; /gstack-upgrade delivers the fix.

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

* fix(one-way-doors): unify credential noun net + wire it into the runtime (#2024)

Library fix: revoke/reset/rotate now share ONE noun alternation (api key,
token, secret, credential, access key, password) with optional plural s?.
Pre-fix leaks: "reset my secret", "reset my access key", "revoke my secret"
(mismatched per-verb lists) and every plural form ("rotate the credentials",
"revoke all tokens" — \b(...)\b cannot match a trailing s).

Runtime wiring — the regexes could never fire in production before:
- gstack-question-preference --check gains --summary-stdin: the question
  text pipes via stdin (never argv — summaries carry quotes/newlines/shell
  metacharacters) and feeds isOneWayDoor alongside the id, so an ad-hoc
  destructive question with a stored never-ask preference now forces
  ASK_NORMALLY. Empty/absent stdin keeps exact id-only semantics.
- question-preference-hook falls back to classifyQuestion(question text)
  when the registry lookup misses, so unregistered destructive questions
  pass through to a human instead of auto-deciding.
- question-tuning resolver prose shows the piped form (SKILL.md regen lands
  in the wave's release commit).

Tripwires (verified fail-first): full verbs x nouns x singular/plural matrix
with the #2024 repro rows, benign-summary no-over-match rows, stdin
transport survival (quotes/newlines), empty-stdin fail-safe, and hook
fallback both directions (destructive -> pass-through, benign -> deny).

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

* fix(design): loud integer-flag contract for --count/--retry/--timeout (#2032)

design variants --count abc silently generated ZERO variants and exited 0:
parseInt(NaN) flowed through Math.min into the generation loop bound. The
same NaN class was live on the two sibling flags in the same file:
--retry abc made generate() a silent no-op (attempt <= NaN never true, null
output, exit 0) and --timeout abc killed the serve board ~immediately
(setTimeout(NaN)).

New design/src/flag-utils.ts: parseIntFlag (pure, unit-testable) +
normalizeIntFlag (CLI wrapper). Contract matches the --viewports precedent
(error loudly on nonsense — these commands spend real image-API money, a
silent fixup hides typos from calling agents): undefined -> default; bare
flag/empty/non-integer ("3.7" rejected, not truncated)/below-min -> exit 1
with usage hint; above-max -> clamp with stderr warning. --count normalizes
at the variants() consumption site so programmatic callers are covered, with
the ceiling derived from STYLE_VARIATIONS.length instead of a magic 7; the
CLI passes the raw flag through (a pre-parseInt would truncate "3.7").

Tripwires live in test/design-flag-utils.test.ts — deliberately under test/,
not design/test/, which is invisible to the bun test glob, TEST_ROOTS, and
every workflow (wiring design/test/ into CI is a captured TODO).

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

* fix(gbrain): thin-client state — remote-MCP brains no longer classify as broken-config (#2051)

A thin client (remote-HTTP MCP brain, no local engine by design) probed
`gbrain sources list`, which gbrain's dispatch guard REFUSES on thin clients
(exit 1, no recognized error string), so the classifier fell to its
defensive broken-config default and every suppression gate silently hid
brain-aware blocks from exactly the users on a shared team brain.

New 'thin-client' state, detected PRE-probe from gbrain's own remote_mcp
config marker via the existing gbrainConfigPath() helper (mirrors gbrain's
isThinClient(); honors GBRAIN_HOME; zero network, immune to error-string
drift), with a /thin[- ]client/ stderr backstop in the probe catch. Remote
reachability is deliberately NOT probed by the classifier — that is the
#1964 pathology; gbrain calls degrade gracefully at use time, and the detect
JSON says so honestly (gbrain_thin_client: {probed: false}).

The state is admitted at every suppression gate — gstack-gbrain-detect
--is-ok (drives setup + gbrain-refresh), gen-skill-docs' detection override,
gstack-config gbrain-refresh — while the sync stages (code/memory/dream)
SKIP with an accurate reason: code indexing runs on the brain server, memory
syncs via the remote brain's artifacts pull. The two consumer classes need
opposite answers, which is why this is a distinct state and not a
skip-the-probe special case. sync-gbrain Step 1.5 and setup-gbrain prose
route thin-client to proceed, never into broken-config remediation.

detectMcpMode secondary generalization: url-match against the config's
remote_mcp.mcp_url (deterministic — gbrain mounts at the generic /mcp path)
-> name pattern gbrain[-_]* -> stdio command token; gbrain_mcp_mode stays a
3-value enum.

Tripwires: end-to-end --is-ok exits 0 on a thin-client fixture AND still
exits 1 on broken-config (the gate didn't widen); pre-probe + stderr-fallback
classifier paths; 4 detectMcpMode identification cases incl. a non-matching
url that must NOT false-positive.

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

* release: v1.60.0.0 — regen SKILL.md, VERSION, CHANGELOG, TODOS follow-ups

- Regenerate all SKILL.md from templates (question-tuning --summary-stdin
  prose from #2024, context-restore branch preference from PR #2054,
  sync-gbrain/setup-gbrain thin-client prose from #2051) + llms.txt.
- VERSION + package.json -> 1.60.0.0 (bin/gstack-next-version, queue-aware:
  #1815 claims 1.59.0.0, #2213 claims 1.59.1.0).
- CHANGELOG release summary + itemized entry crediting @jbetala7 (x3) and
  @mvann.
- TODOS.md: three eng-review follow-ups (design/test CI wiring + documented
  pre-existing retry-after flake, /context-save worktree identity, gbrain
  reindex-in-place conditional on the new drift log).

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

* fix(resolvers): compress --summary-stdin preamble prose to fit parity budget; re-bless ship goldens

The v1.57.7.0 parity suite caps investigate's generated size at 1.09x
baseline; the #2024 question-tuning prose (duplicated into every tier->=2
skill) tipped it to 1.092. Compressed to a single inline command + short
pointer (the full rationale lives in bin/gstack-question-preference's
header and the one-way-doors module docs). Ship goldens re-blessed against
the final resolver text (conscious template-change acknowledgment, per the
golden-file regression contract).

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

* fix(e2e): office-hours-spec-review turn budget fits the carved skill layout (#2473)

The test failed deterministically with error_max_turns at 9 turns on main
and this branch alike (CI attempt logs + local main repro). Root cause from
the failing transcript: the Spec Review Loop content is carved out of
office-hours/SKILL.md into office-hours/sections/, so the agent needs
discovery hops (grep SKILL.md -> ls sections/ -> read the section) before it
can write — 8 tool turns + the closing text turn = 9 > the 8-turn budget,
which predates the carve. Observed failures wrote a CORRECT summary on tool
turn 8 and died on the closing turn.

maxTurns 8 -> 12. Verified: PASS locally post-fix (7 turns this run — the
extra headroom absorbs discovery-path nondeterminism).

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

* fix(e2e): review-dashboard-via session budget survives runner contention (#2473)

The test failed on CI (and its baseline run) with the timeout signature:
0 turns, $0.00, exactly 183s, 3/3 attempts — the spawned claude -p session
never emitted a single stream event before the 180s inner timeout. The
file's tests run concurrently on one runner; session startup queues behind
sibling sessions, and this test had the tightest budget in the file (the
240s-budget tests in the same job passed). A clean local run takes 270s
wall for 4 turns, confirming 180s was too tight even without contention.

Inner timeout 180s -> 300s; outer bun timeout 240s -> 360s to keep headroom
over the inner budget. Verified: PASS locally post-fix (4 turns, 270s).

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

* fix(e2e): retro-base-branch session budget survives runner contention (#2473)

Same class as review-dashboard-via, one test over in the same file: /retro
is a long multi-step flow whose clean pass measures 225-239s — a coin flip
against the 240s inner budget. First CI run passed at 225s; the rerun timed
out at the 240s line on all 3 attempts (exitReason "timeout"); the local
verification run passed at 239s, ONE second under the old cap.

Inner timeout 240s -> 360s; outer bun timeout 300s -> 480s for headroom.
Verified: PASS locally post-fix (17 turns, 239s).

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

---------

Co-authored-by: Jayesh Betala <jayesh.betala7@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Michael Vann <9221873+mvann@users.noreply.github.com>
This commit is contained in:
Garry Tan 2026-08-08 09:28:45 -07:00 committed by GitHub
parent 960c3a8d6c
commit 94993f7401
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
84 changed files with 1678 additions and 233 deletions

View File

@ -1,5 +1,56 @@
# Changelog
## [1.61.0.0] - 2026-07-09
## **Nine guard bugs fixed in one wave.**
## **Every fix ships with a tripwire that proves the guard actually guards.**
This release closes out the silent-failure class across gstack: guards and tools that reported success while doing nothing. Question cards render again on current Claude Code builds. /careful catches chained, substituted, and capital-flag deletes it used to wave through. The design CLI fails loudly on bad flags instead of billing you for a guess. Shared team brains (thin clients) get brain-aware planning instead of silent suppression. Four of the fixes came from community PRs, absorbed with authorship intact and hardened on top.
### The six numbers that matter
Source: this branch's diff against v1.58.5.0. Every new test was first run against the unfixed code and confirmed failing, then confirmed passing after the fix.
| What | Before | After |
|------|--------|-------|
| AskUserQuestion on Claude Code 2.1.89+ | "Tool result missing due to internal error" | card renders |
| `rm -R /`, `rm -rf $(cmd)/node_modules` via /careful | silent allow | ask |
| `design variants --count abc` | 0 variants, exit 0 | exit 1 with usage hint |
| Thin-client team brains | broken-config, brain blocks suppressed | usable, sync stages skip with reason |
| /office-hours SESSION_COUNT | ~2x inflated | exact |
| New tripwire test cases | n/a | 72 |
The first row is the one to feel. The question-card primitive every interactive skill depends on was orphaned on current Claude Code builds: the preference hook emitted `permissionDecision:'defer'`, whose semantics became "pause for external resumption" in CC v2.1.89. The fix is a two-branch pass-through (exact-empty stdout, or additionalContext-only output for plan-tune memory nuggets), plus a corrected protocol reference doc so the mistake cannot be re-learned from our own docs.
### What this means for you
Interactive skills ask you questions again on current Claude Code. Safety guards fail closed: chained deletes, command substitution, capital `-R`, and destructive credential phrasings ("reset my secrets") all reach a human now. If your team runs a shared remote brain, `/sync-gbrain` and brain-aware planning work on thin clients out of the box. Run `/gstack-upgrade` to get all of it. The hook fix arrives with the file update, no settings change needed.
### Itemized changes
#### Fixed
- **AskUserQuestion orphaned on Claude Code 2.1.89+ (#2035, #2006).** `question-preference-hook` pass-through is now exit 0 with exactly empty stdout (or additionalContext-only output for plan-tune memory nuggets), never `permissionDecision:'defer'`. `defer()` renamed `passThrough()`; the protocol contract in `docs/spikes/claude-code-hook-mutation.md` corrected in the same commit; 13 assertions rewritten across 3 test files; the tripwire asserts exact-empty stdout so a garbage write cannot slip past an optional-chained parse. Existing installs pick the fix up via `/gstack-upgrade` (the registered hook shim execs the TypeScript live).
- **/careful chained-rm bypass (#2039).** Contributed by @jbetala7 (PR #2040): the safe-exception shortcut no longer judges a chained command by its last (safe) target. Hardened on top of the anchored full-command whitelist: the flag cluster accepts capital `-R` (the BSD/macOS recursive flag — `rm -R /` warned nowhere before; `rm -Rf node_modules` alone still allows) and safe-target tokens exclude `(` and backtick, so command substitution ending in a whitelisted suffix (`rm -rf $(./wipe-all)/node_modules`) cannot ride the whitelist.
- **/context-restore loading a sibling worktree's checkpoint (#2052).** Contributed by @jbetala7: restore prefers the current branch's own checkpoint over newer sibling-worktree saves (scans 200 newest, partitions by branch frontmatter), and keeps the Conductor handoff fallback when the branch has no checkpoint.
- **/sync-gbrain drift re-register on gbrain 0.42+ (#1985).** Contributed by @jbetala7: the drift remove passes `--confirm-destructive`. Hardened on top: the remove routes through the #1734 data-loss guards (refuses loudly while an autopilot runs), propagates `--keep-storage`, realpath-normalizes drift detection (a symlink alias of the same directory is a match, not drift, the probable cause of the reporter's unmoved-repo drift), and logs old vs new path whenever drift fires.
- **Developer-profile double counting (#2067).** Contributed by @mvann: `mode:"resources"` bookkeeping rows no longer inflate SESSION_COUNT, TIER, or the builder-to-founder nudge; 8 regression tests pin the tier boundaries from both sides.
- **One-way-door credential net: plurals + runtime wiring (#2024).** The credential nouns now match plurals ("reset my secrets" / "rotate the credentials" classify one-way), and the keyword net is wired into the runtime for the first time: `gstack-question-preference --check <id> --summary-stdin` pipes the question text (stdin, never argv, so quotes and newlines survive), and the enforcement hook falls back to the classifier for unregistered ids, so an ad-hoc destructive question with a stored never-ask preference can no longer auto-decide.
- **design CLI silent NaN flags (#2032).** `--count`, `--retry`, and `--timeout` share one loud contract via `design/src/flag-utils.ts`: non-integer input errors with exit 1 ("3.7" is rejected, not truncated), above-max clamps with a stderr warning, and the variants ceiling derives from the style list instead of a magic 7. Previously `--retry abc` made generate a silent no-op and `--timeout abc` killed the serve board at boot.
- **Thin-client brains misclassified as broken (#2051).** New `thin-client` engine state, read from gbrain's own `remote_mcp` config marker before any probe. Usable at every suppression gate (`--is-ok`, gen-skill-docs detection, `gstack-config gbrain-refresh`) while the local sync stages skip with an accurate reason (code indexing runs on the brain server; memory syncs via the remote brain's artifacts pull). The detect JSON reports `gbrain_thin_client: {probed: false}`: config verified, reachability checked at use time where gbrain calls degrade gracefully. detectMcpMode also recognizes gbrain servers registered under variant names or matched by the config's `mcp_url`.
#### Closed as already fixed, with receipts
- #1965 (GBRAIN_PREPARE pooler breakage): `lib/gbrain-exec.ts:86` never sets it; pinned by `test/build-gbrain-env.test.ts:121-142`.
- #1950 (Windows git-bash learnings silently dropped): `bin/gstack-learnings-log:10-15` cygpath fix + stderr surfacing; pinned by `test/bin-windows-bun-import-paths.test.ts`.
- #1964 (slow engines misclassified): `probeTimeoutMs()` honors `GSTACK_GBRAIN_PROBE_TIMEOUT_MS`; timeout classifies usable; pinned by `test/gbrain-local-status.test.ts`.
#### For contributors
- 11 bisect commits; 4 community PRs absorbed with authorship preserved. Contributed by @jbetala7 (#2040, #2054, #2031) and @mvann (#1991). Thank you both.
- 72 new test cases across 9 files, each verified failing against the unfixed code before the fix landed.
- Three follow-ups filed in TODOS.md: wire `design/test/` into CI (all 8 existing files are invisible to every runner today, plus a documented pre-existing timing flake), /context-save worktree-identity hardening (the #2052 residual), and conditional gbrain reindex-in-place gated on the new drift log.
## [1.60.2.0] - 2026-08-07
## **Three free-suite tests fail-proofed against machine drift.**

View File

@ -45,6 +45,73 @@ a silent mistake breaks all 52 skills. High blast radius — needs its own focus
## Test infrastructure
### P2: Wire `design/test/` into CI (all 8 files are invisible to every runner)
**What:** Add `design/test/` to the `bun test` glob (`package.json:21`) and
`TEST_ROOTS` (`scripts/test-free-shards.ts:32`) after auditing its 8 files for
server-spawning/flakiness (they were plausibly excluded on purpose). While in
there, fix the known timing flake: `variants-retry-after.test.ts` "HTTP-date:
honors a future date with no extra leading exponential" fails ~1-2 in 9 runs
under parallel suite load (verified pre-existing on v1.58.5.0 during the
June 2026 fix wave — wall-clock assertion with a ~2s window).
**Why:** Every test in `design/test/` runs only when someone types the path by
hand — a silent coverage hole, the fix wave's theme at meta-level. The wave's
own design tests went into `test/design-flag-utils.test.ts` to dodge this.
**Pros:** design binary gets CI coverage; kills a latent "we have tests" illusion.
**Cons:** unaudited files may spawn servers or flake; audit first, wire second.
**Context:** Filed from the June 2026 fix-wave eng review (issue 11 + flake
receipts). Start with the audit: which of the 8 files are hermetic? Wire the
hermetic ones, quarantine or fix the rest.
**Effort:** S-M (human ~1d, CC ~30min). **Depends on:** None.
### P2: /context-save worktree-identity hardening (the #2052 residual)
**What:** Persist a stable worktree identity (path hash or worktree name) into
checkpoint frontmatter at save time; `/context-restore` prefers identity match
over branch-name match. PR #2054 (@jbetala7, absorbed in the June 2026 wave)
fixed restore ORDERING (current-branch first), but branch frontmatter is not a
stable worktree identity: same-name branches across clones/remotes, renamed
branches, and detached HEAD can still restore the wrong checkpoint.
**Why:** Closes the residual wrong-checkpoint class entirely instead of the
common case. Codex outside-voice concurred during the wave's eng review.
**Pros:** Eliminates cross-clone checkpoint collisions.
**Cons:** Frontmatter schema change; needs a migration story for old
checkpoints (no-identity checkpoints rank as fallback, like #2054's
no-branch handling).
**Context:** Filed from the June 2026 fix-wave eng review (NOT-in-scope item).
Start at `context-restore/SKILL.md.tmpl` Step 1 + `/context-save`'s frontmatter
writer; mirror #2054's partition logic with identity as the first key.
**Effort:** S (human ~4h, CC ~20min). **Depends on:** #2054 (landed in the wave).
### P3: gbrain reindex-in-place on perpetual drift (conditional — check the drift log first)
**What:** IF the `[gbrain-sources] drift:` stderr line (added in the June 2026
wave) shows drift firing on every sync for some environment, implement #1985's
reporter design: refresh an existing source in place with `gbrain reindex-code`
instead of remove+add (which drops and re-embeds the full index — 768 pages /
6,786 embeddings in the reporter's case).
**Why:** Perpetual drift means paying full re-embed cost every sync. The wave's
`realpathSync` normalization (symlink aliases are a match, not drift) may have
eliminated the drift class entirely — that's why this is conditional.
**Pros:** Avoids repeated embedding spend for affected environments.
**Cons:** Speculative until the drift log produces evidence; reindex-in-place
has its own consistency questions (stale chunks for deleted files).
**Context:** Filed from the June 2026 fix-wave eng review (4A observability).
Trigger condition documented in `lib/gbrain-sources.ts` at the drift log line.
**Effort:** M (human ~1d, CC ~45min). **Depends on:** drift-log evidence from
the wave's `ensureSourceRegistered` logging.
### P1: Free suite exit code is untrustworthy — in-process force-exits mask failures
**Priority:** P1

View File

@ -1 +1 @@
1.60.2.0
1.61.0.0

View File

@ -711,7 +711,7 @@ If you are looping on the same diagnostic, same file, or failed fix variants, ST
## Question Tuning (skip entirely if `QUESTION_TUNING: false`)
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `printf '%s' "<question summary>" | ~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>" --summary-stdin` (piped summary feeds the one-way keyword net, #2024). `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
**Embed the question_id as a marker in the question text** so hooks can identify it deterministically (plan-tune cathedral T14 / D18 progressive markers). Append `<gstack-qid:{question_id}>` somewhere in the rendered question (the leading line or trailing line is fine; the marker doesn't render visibly to the user when wrapped in HTML-style angle brackets, but the hook strips it). Without the marker the PreToolUse enforcement hook treats the AUQ as observed-only and never auto-decides — so always include it when the question matches a registered `question_id`.

View File

@ -414,9 +414,11 @@ case "${1:-}" in
fi
case "$STATUS" in
ok|timeout)
# "timeout" = slow-but-healthy engine (#1964) — same treatment as
# "ok", matching gstack-gbrain-detect --is-ok and gen-skill-docs.
ok|timeout|thin-client)
# "timeout" = slow-but-healthy engine (#1964); "thin-client" =
# remote-HTTP MCP brain, no local engine by design (#2051) — same
# treatment as "ok", matching gstack-gbrain-detect --is-ok and
# gen-skill-docs.
echo "Detected gbrain v$VERSION (local-status: $STATUS)."
# Render brain-aware blocks INTO the global install so EVERY project's
# Claude sessions get them (other projects read SKILL.md + sections from

View File

@ -225,17 +225,19 @@ do_read() {
cat "$PROFILE_FILE" | bun -e "
const p = JSON.parse(await Bun.stdin.text());
const sessions = p.sessions || [];
const count = sessions.length;
// SESSION_COUNT / TIER / CROSS_PROJECT / NUDGE must reflect real sessions, not
// resource-tracking events (the Phase 6 auto-append). Without this filter, a
// session's resources entry written immediately after the real session inflates
// the count (bumping TIER), clobbers LAST_PROJECT/LAST_ASSIGNMENT/LAST_DESIGN_TITLE,
// and pushes NUDGE_ELIGIBLE over its threshold from bookkeeping alone.
const realSessions = sessions.filter(e => e.mode !== 'resources');
const count = realSessions.length;
let tier = 'introduction';
if (count >= 8) tier = 'inner_circle';
else if (count >= 4) tier = 'regular';
else if (count >= 1) tier = 'welcome_back';
// LAST_* / CROSS_PROJECT must reflect real sessions, not resource-tracking
// events (the Phase 6 auto-append). Without this filter, a session's
// resources entry written immediately after the real session would clobber
// LAST_PROJECT/LAST_ASSIGNMENT/LAST_DESIGN_TITLE.
const realSessions = sessions.filter(e => e.mode !== 'resources');
const last = realSessions[realSessions.length - 1] || {};
const prev = realSessions[realSessions.length - 2] || {};
const crossProject = prev.project_slug && last.project_slug
@ -252,7 +254,11 @@ do_read() {
for (const v of Object.values(signalCounts)) totalSignals += v;
const signalStr = Object.entries(signalCounts).map(([k,v]) => k + ':' + v).join(',');
const builderSessions = sessions.filter(e => e.mode !== 'startup').length;
// Builder-mode design sessions only — the builder->founder nudge is about
// someone who keeps returning to *build*, not startup-mode diagnostics and not
// resources bookkeeping. (Was \`e.mode !== 'startup'\`, which counted resources
// entries and excluded real startup sessions.)
const builderSessions = realSessions.filter(e => e.mode === 'builder').length;
const nudgeEligible = builderSessions >= 3 && totalSignals >= 5;
const resources = p.resources_shown || [];

View File

@ -18,7 +18,7 @@
* "gstack_brain_sync_mode": "off"|"artifacts-only"|"full",
* "gstack_brain_git": true|false,
* "gstack_artifacts_remote": "https://..." | "",
* "gbrain_local_status": "ok"|"no-cli"|"missing-config"|"broken-config"|"broken-db"|"engine-locked"|"timeout",
* "gbrain_local_status": "ok"|"no-cli"|"missing-config"|"broken-config"|"broken-db"|"engine-locked"|"timeout"|"thin-client",
* "gbrain_pooler_mode": "transaction"|"session"|null
* }
*
@ -175,10 +175,13 @@ function detectMcpMode(): "local-stdio" | "remote-http" | "none" {
// fall through
}
}
// Tier 2: `claude mcp list` text-grep
// Tier 2: `claude mcp list` text-grep. Name-pattern generalized (#2051):
// a gbrain server registered as e.g. "gbrain-remote" or "gbrain_work"
// still counts. Anchored to the gbrain token so unrelated servers can't
// false-positive.
const list = tryExec("claude", ["mcp", "list"], 3_000);
if (list) {
const line = list.split("\n").find((l) => /^gbrain:/.test(l));
const line = list.split("\n").find((l) => /^gbrain([-_][\w-]*)?:/.test(l));
if (line) {
if (/\b(http|HTTP)\b/.test(line)) return "remote-http";
return "local-stdio";
@ -186,20 +189,56 @@ function detectMcpMode(): "local-stdio" | "remote-http" | "none" {
}
}
// Tier 3: read ~/.claude.json directly
interface McpServerEntry {
type?: string;
transport?: string;
command?: string;
url?: string;
}
const cj = tryReadJSON(CLAUDE_JSON) as
| { mcpServers?: { gbrain?: { type?: string; transport?: string; command?: string; url?: string } } }
| { mcpServers?: Record<string, McpServerEntry> }
| null;
const entry = cj?.mcpServers?.gbrain;
if (entry) {
const classify = (entry: McpServerEntry): "local-stdio" | "remote-http" | null => {
const mtype = entry.type || entry.transport || "";
if (mtype === "url" || mtype === "http" || mtype === "sse") return "remote-http";
if (mtype === "stdio") return "local-stdio";
if (entry.url) return "remote-http";
if (entry.command) return "local-stdio";
return null;
};
const servers = cj?.mcpServers || {};
const exact = servers["gbrain"];
if (exact) {
const c = classify(exact);
if (c) return c;
}
// #2051 generalization, deterministic identifiers first:
// (a) a server whose url matches the config's remote_mcp.mcp_url is THE
// thin-client brain regardless of its registered name (URL-path
// heuristics are impossible — gbrain mounts at the generic /mcp);
// (b) name pattern gbrain[-_]* ;
// (c) a stdio server whose command mentions gbrain.
const remoteMcpUrl = readRemoteMcpUrl();
for (const [name, entry] of Object.entries(servers)) {
if (remoteMcpUrl && entry.url && entry.url === remoteMcpUrl) return "remote-http";
if (/^gbrain([-_][\w-]*)?$/.test(name)) {
const c = classify(entry);
if (c) return c;
}
if (entry.command && /\bgbrain\b/.test(entry.command)) return "local-stdio";
}
return "none";
}
/** remote_mcp.mcp_url from gbrain's own config (thin-client marker, #2051). */
function readRemoteMcpUrl(): string {
const gbrainHome = process.env.GBRAIN_HOME || join(userHome(), ".gbrain");
const cfg = tryReadJSON(join(gbrainHome, "config.json")) as
| { remote_mcp?: { mcp_url?: string } }
| null;
return cfg?.remote_mcp?.mcp_url || "";
}
// --- artifacts remote URL with brain-* fallback during the rename migration window ---
function detectArtifactsRemote(): string {
const newPath = join(userHome(), ".gstack-artifacts-remote.txt");
@ -237,20 +276,29 @@ function main(): void {
gbrain_pooler_mode: detectPoolerMode(),
};
process.stdout.write(JSON.stringify(out, null, 2) + "\n");
// #2051 honesty marker: on a thin client the classifier verified the CONFIG
// (remote_mcp present), not the remote's reachability — that is checked at
// use time, where gbrain calls degrade gracefully.
const withThinClient =
out.gbrain_local_status === "thin-client"
? { ...out, gbrain_thin_client: { probed: false } }
: out;
process.stdout.write(JSON.stringify(withThinClient, null, 2) + "\n");
}
// --is-ok: live engine-status gate. Exits 0 iff gbrain is usable ("ok", or
// "timeout" — a slow-but-healthy engine, #1964 — slow must not silently
// suppress brain features), 1 otherwise. Runs detection live (never reads
// the possibly-stale gbrain-detection.json), so callers — setup,
// --is-ok: live engine-status gate. Exits 0 iff gbrain is usable ("ok";
// "timeout" — a slow-but-healthy engine, #1964; or "thin-client" — remote-HTTP
// MCP brain with no local engine by design, #2051 — neither slow nor remote
// must silently suppress brain features), 1 otherwise. Runs detection live
// (never reads the possibly-stale gbrain-detection.json), so callers — setup,
// bin/dev-setup, and `gstack-config gbrain-refresh` — can decide whether to
// render the gbrain :user variant without duplicating the JSON grep.
// Prints nothing on stdout.
if (process.argv.includes("--is-ok")) {
const noCache = process.env.GSTACK_DETECT_NO_CACHE === "1";
const status = localEngineStatus({ noCache });
process.exit(status === "ok" || status === "timeout" ? 0 : 1);
process.exit(status === "ok" || status === "timeout" || status === "thin-client" ? 0 : 1);
}
main();

View File

@ -720,6 +720,9 @@ function dreamMarkerPid(): number | null {
* engine-locked PGLite is busy; stop its holder or sync outside the live session
* timeout kept for Record totality; stages PROCEED on timeout (#1964)
* via the gate's warnProbeTimeout path, never this skip.
* thin-client remote-HTTP MCP brain, no local engine by design (#2051);
* local sync stages skip (gbrain refuses sources/sync there),
* but suppression gates treat the brain as USABLE.
*/
function skipStageForLocalStatus(
stage: "code" | "memory" | "dream",
@ -738,6 +741,10 @@ function skipStageForLocalStatus(
"PGLite is busy (often held by gbrain serve); stop the holding process or run /sync-gbrain outside the live Claude session, then retry",
"timeout":
"engine probe timed out; raise GSTACK_GBRAIN_PROBE_TIMEOUT_MS if your pooler is slow",
"thin-client":
"thin client (remote-HTTP MCP brain, no local engine by design, #2051); " +
"code indexing runs on the brain server, memory syncs via the remote " +
"brain's artifacts pull — nothing to do locally",
};
const reason = reasons[status as Exclude<LocalEngineStatus, "ok">];
return {

View File

@ -5,7 +5,9 @@
# Schema: { "<question_id>": "always-ask" | "never-ask" | "ask-only-for-one-way" }
#
# Subcommands:
# --check <id> → emit ASK_NORMALLY | AUTO_DECIDE | ASK_ONLY_ONE_WAY
# --check <id> [--summary-stdin] → emit ASK_NORMALLY | AUTO_DECIDE | ASK_ONLY_ONE_WAY
# (--summary-stdin pipes the question text so the
# keyword net can catch ad-hoc destructive ids, #2024)
# --write '{...}' → set a preference (user-origin gate enforced)
# --read → dump preferences JSON
# --clear [<id>] → clear one or all preferences
@ -44,22 +46,39 @@ ensure_file() {
# --check <question_id>
# -----------------------------------------------------------------------
do_check() {
local QID="${1:-}"
local QID="" SUMMARY_STDIN=false
while [ $# -gt 0 ]; do
case "$1" in
--summary-stdin) SUMMARY_STDIN=true; shift ;;
*) [ -z "$QID" ] && QID="$1"; shift ;;
esac
done
if [ -z "$QID" ]; then
echo "ASK_NORMALLY"
return 0
fi
# #2024: the question text feeds the keyword classifier for unregistered
# ids. Transport is stdin (not argv) — summaries carry quotes, newlines,
# and shell metacharacters an argv tail would mangle. Handed to bun via
# env so no shell re-quoting happens. Empty summary = id-only behavior.
local QSUMMARY=""
if [ "$SUMMARY_STDIN" = true ]; then
QSUMMARY=$(cat 2>/dev/null || true)
fi
ensure_file
cd "$ROOT_DIR"
PREF_FILE_PATH="$PREF_FILE" QID="$QID" bun -e "
PREF_FILE_PATH="$PREF_FILE" QID="$QID" QSUMMARY="$QSUMMARY" bun -e "
import('./scripts/one-way-doors.ts').then((oneway) => {
const fs = require('fs');
const qid = process.env.QID;
const summary = process.env.QSUMMARY || undefined;
const prefs = JSON.parse(fs.readFileSync(process.env.PREF_FILE_PATH, 'utf-8'));
const pref = prefs[qid];
// Always check one-way status first — safety overrides preferences.
const oneWay = oneway.isOneWayDoor({ question_id: qid });
// summary (when piped) lets the keyword net catch ad-hoc destructive
// questions whose id has no registry entry (#2024).
const oneWay = oneway.isOneWayDoor({ question_id: qid, summary });
if (oneWay) {
console.log('ASK_NORMALLY');

View File

@ -703,7 +703,7 @@ If you are looping on the same diagnostic, same file, or failed fix variants, ST
## Question Tuning (skip entirely if `QUESTION_TUNING: false`)
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `printf '%s' "<question summary>" | ~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>" --summary-stdin` (piped summary feeds the one-way keyword net, #2024). `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
**Embed the question_id as a marker in the question text** so hooks can identify it deterministically (plan-tune cathedral T14 / D18 progressive markers). Append `<gstack-qid:{question_id}>` somewhere in the rendered question (the leading line or trailing line is fine; the marker doesn't render visibly to the user when wrapped in HTML-style angle brackets, but the hook strips it). Without the marker the PreToolUse enforcement hook treats the AUQ as observed-only and never auto-decides — so always include it when the question matches a registered `question_id`.

View File

@ -30,7 +30,14 @@ CMD_LOWER=$(printf '%s' "$CMD" | tr '[:upper:]' '[:lower:]')
# syntax or comments can hide an earlier destructive command, for example:
# rm -rf / # rm -rf node_modules
# Unknown syntax fails closed and falls through to the destructive checks.
if printf '%s' "$CMD" | grep -qE '^[[:space:]]*rm[[:space:]]+(-[a-zA-Z]*r[a-zA-Z]*[[:space:]]+|--recursive[[:space:]]+)(([^[:space:];&|#]*/)?(node_modules|\.next|dist|__pycache__|\.cache|build|\.turbo|coverage)[[:space:]]*)+$' 2>/dev/null; then
# Two hardenings on top of the anchored shape (#2039 wave):
# - flag cluster accepts capital -R (BSD/macOS recursive), so a single
# `rm -Rf node_modules` stays allowed instead of prompting;
# - target tokens exclude `(` and backtick, so command substitution that
# ENDS in a whitelisted suffix (`rm -rf $(./wipe-all)/node_modules`)
# cannot ride the whitelist. Plain $VAR expansion (no parenthesis) is
# still allowed.
if printf '%s' "$CMD" | grep -qE '^[[:space:]]*rm[[:space:]]+(-[a-zA-Z]*[rR][a-zA-Z]*[[:space:]]+|--recursive[[:space:]]+)(([^[:space:];&|#(`]*/)?(node_modules|\.next|dist|__pycache__|\.cache|build|\.turbo|coverage)[[:space:]]*)+$' 2>/dev/null; then
echo '{}'
exit 0
fi
@ -39,8 +46,8 @@ fi
WARN=""
PATTERN=""
# rm -rf / rm -r / rm --recursive
if printf '%s' "$CMD" | grep -qE 'rm\s+(-[a-zA-Z]*r|--recursive)' 2>/dev/null; then
# rm -rf / rm -r / rm -R / rm --recursive (capital -R is BSD/macOS recursive)
if printf '%s' "$CMD" | grep -qE 'rm\s+(-[a-zA-Z]*[rR]|--recursive)' 2>/dev/null; then
WARN="Destructive: recursive delete (rm -r). This permanently removes files."
PATTERN="rm_recursive"
fi

View File

@ -706,7 +706,7 @@ If you are looping on the same diagnostic, same file, or failed fix variants, ST
## Question Tuning (skip entirely if `QUESTION_TUNING: false`)
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `printf '%s' "<question summary>" | ~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>" --summary-stdin` (piped summary feeds the one-way keyword net, #2024). `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
**Embed the question_id as a marker in the question text** so hooks can identify it deterministically (plan-tune cathedral T14 / D18 progressive markers). Append `<gstack-qid:{question_id}>` somewhere in the rendered question (the leading line or trailing line is fine; the marker doesn't render visibly to the user when wrapped in HTML-style angle brackets, but the hook strips it). Without the marker the PreToolUse enforcement hook treats the AUQ as observed-only and never auto-decides — so always include it when the question matches a registered `question_id`.

View File

@ -23,8 +23,8 @@ triggers:
## When to invoke this skill
Loads the most recent
saved state (across all branches by default) so you can pick up where you
left off — even across Conductor workspace handoffs.
saved state (preferring the current branch, falling back across branches) so
you can pick up where you left off — even across Conductor workspace handoffs.
Use when asked to "resume", "restore context", "where was I", or
"pick up where I left off". Pair with /context-save.
Formerly /checkpoint resume — renamed because Claude Code treats /checkpoint
@ -707,7 +707,7 @@ If you are looping on the same diagnostic, same file, or failed fix variants, ST
## Question Tuning (skip entirely if `QUESTION_TUNING: false`)
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `printf '%s' "<question summary>" | ~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>" --summary-stdin` (piped summary feeds the one-way keyword net, #2024). `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
**Embed the question_id as a marker in the question text** so hooks can identify it deterministically (plan-tune cathedral T14 / D18 progressive markers). Append `<gstack-qid:{question_id}>` somewhere in the rendered question (the leading line or trailing line is fine; the marker doesn't render visibly to the user when wrapped in HTML-style angle brackets, but the hook strips it). Without the marker the PreToolUse enforcement hook treats the AUQ as observed-only and never auto-decides — so always include it when the question matches a registered `question_id`.
@ -791,13 +791,19 @@ context and present it clearly so the user can resume work without losing a beat
**HARD GATE:** Do NOT implement code changes. This skill only reads saved
context files and presents the summary.
**Default: load the most recent saved context across ALL branches.** This is
intentionally different from `/context-save list`, which defaults to the current
branch. `/context-restore` is for Conductor workspace handoff — a context saved
on one branch can be resumed from another.
**Default: prefer the most recent checkpoint saved on the CURRENT branch; if
this branch has none, fall back to the most recent across ALL branches.** The
fallback is for Conductor workspace handoff — a context saved on one branch can
be resumed from another. The current-branch preference exists because every
worktree of a repo shares one checkpoints directory (same origin-derived slug),
so without it `/context-restore` in one worktree could silently load a sibling
worktree's newer checkpoint.
**Do NOT filter the candidate set by current branch.** The `list` flow does
that; `/context-restore` does not.
**Do NOT hard-filter the candidate set to the current branch** — other-branch
checkpoints stay in the set as a fallback. They are just ordered *after* the
current branch's own, so a current-branch save is never shadowed by a newer
sibling-worktree save. (`/context-save list` is the flow that hard-scopes to one
branch.)
---
@ -828,27 +834,53 @@ else
# copies/rsync). Filesystem mtime drifts and is not authoritative.
# 2. On macOS, `find ... | xargs ls -1t` with zero results falls back to
# listing cwd. `sort -r` on empty input cleanly returns nothing.
# Cap at 20 most recent: a user with 10k saved files shouldn't blow the
# context window just listing them. /context-save list handles pagination.
FILES=$(find "$CHECKPOINT_DIR" -maxdepth 1 -name "*.md" -type f 2>/dev/null | sort -r | head -20)
if [ -z "$FILES" ]; then
# Scan the 200 newest so a current-branch checkpoint sitting below a burst of
# sibling-worktree saves can still be found; the result is capped at 20 below.
ALL=$(find "$CHECKPOINT_DIR" -maxdepth 1 -name "*.md" -type f 2>/dev/null | sort -r | head -200)
if [ -z "$ALL" ]; then
echo "NO_CHECKPOINTS"
else
# Order current-branch checkpoints first, other branches after. A git branch
# is checked out in at most one worktree, and all worktrees of a repo share
# one checkpoints dir (same origin-derived slug), so without this preference
# `/context-restore` in worktree A could load worktree B's newer checkpoint.
# Cross-branch resume (Conductor handoff) is preserved as the fallback: when
# the current branch has no checkpoint, the full newest-first set is used.
# CURRENT_BRANCH may be pre-set (tests); otherwise resolve it from git.
: "${CURRENT_BRANCH:=$(git rev-parse --abbrev-ref HEAD 2>/dev/null)}"
SAME=""; OTHER=""
while IFS= read -r f; do
[ -n "$f" ] || continue
b=$(grep -m1 '^branch:' "$f" 2>/dev/null | sed 's/^branch:[[:space:]]*//')
if [ -n "$CURRENT_BRANCH" ] && [ "$b" = "$CURRENT_BRANCH" ]; then
SAME="${SAME}${f}
"
else
OTHER="${OTHER}${f}
"
fi
done <<EOF
$ALL
EOF
# Cap at 20: a user with 10k saved files shouldn't blow the context window.
FILES=$(printf '%s%s' "$SAME" "$OTHER" | grep -v '^[[:space:]]*$' | head -20)
echo "$FILES"
fi
fi
```
**Candidates include every `.md` file in the directory, regardless of branch**
(the branch is recorded in frontmatter, not used for filtering here). This
enables Conductor workspace handoff.
**Candidates include every `.md` file in the directory**, but they are ordered
**current-branch-first** (the branch is read from each file's `branch:`
frontmatter). Other-branch files stay in the set as a fallback, which preserves
Conductor workspace handoff when the current branch has no checkpoint of its own.
### Step 2: Load the right file
- If the user specified a title fragment or number: find the matching file among
the candidates.
- Otherwise: load the **first file returned by the `sort -r` above** — that is
the newest `YYYYMMDD-HHMMSS` prefix, which is the canonical "most recent."
- Otherwise: load the **first file returned by Step 1 above** — that is the
newest `YYYYMMDD-HHMMSS` checkpoint for the current branch, or, if the current
branch has none, the newest across all branches.
Read the chosen file and present a summary:
@ -900,9 +932,10 @@ state, then `/context-restore` will find it."
## Important Rules
- **Never modify code.** This skill only reads saved files and presents them.
- **Always search across all branches by default.** Cross-branch resume is the
whole point. Only filter by branch if the user explicitly asks via a
title-fragment match that happens to be branch-specific.
- **Prefer the current branch's own checkpoint, but keep all branches in the
fallback set.** Cross-branch resume (Conductor handoff) still works when the
current branch has no checkpoint; it just no longer lets a sibling worktree's
newer save shadow this branch's own.
- **"Most recent" means the filename `YYYYMMDD-HHMMSS` prefix**, not
`ls -1t` (filesystem mtime). Filenames are stable across file-system
operations; mtime is not.

View File

@ -4,8 +4,8 @@ preamble-tier: 2
version: 1.0.0
description: |
Restore working context saved earlier by /context-save. Loads the most recent
saved state (across all branches by default) so you can pick up where you
left off — even across Conductor workspace handoffs.
saved state (preferring the current branch, falling back across branches) so
you can pick up where you left off — even across Conductor workspace handoffs.
Use when asked to "resume", "restore context", "where was I", or
"pick up where I left off". Pair with /context-save.
Formerly /checkpoint resume — renamed because Claude Code treats /checkpoint
@ -35,13 +35,19 @@ context and present it clearly so the user can resume work without losing a beat
**HARD GATE:** Do NOT implement code changes. This skill only reads saved
context files and presents the summary.
**Default: load the most recent saved context across ALL branches.** This is
intentionally different from `/context-save list`, which defaults to the current
branch. `/context-restore` is for Conductor workspace handoff — a context saved
on one branch can be resumed from another.
**Default: prefer the most recent checkpoint saved on the CURRENT branch; if
this branch has none, fall back to the most recent across ALL branches.** The
fallback is for Conductor workspace handoff — a context saved on one branch can
be resumed from another. The current-branch preference exists because every
worktree of a repo shares one checkpoints directory (same origin-derived slug),
so without it `/context-restore` in one worktree could silently load a sibling
worktree's newer checkpoint.
**Do NOT filter the candidate set by current branch.** The `list` flow does
that; `/context-restore` does not.
**Do NOT hard-filter the candidate set to the current branch** — other-branch
checkpoints stay in the set as a fallback. They are just ordered *after* the
current branch's own, so a current-branch save is never shadowed by a newer
sibling-worktree save. (`/context-save list` is the flow that hard-scopes to one
branch.)
---
@ -72,27 +78,53 @@ else
# copies/rsync). Filesystem mtime drifts and is not authoritative.
# 2. On macOS, `find ... | xargs ls -1t` with zero results falls back to
# listing cwd. `sort -r` on empty input cleanly returns nothing.
# Cap at 20 most recent: a user with 10k saved files shouldn't blow the
# context window just listing them. /context-save list handles pagination.
FILES=$(find "$CHECKPOINT_DIR" -maxdepth 1 -name "*.md" -type f 2>/dev/null | sort -r | head -20)
if [ -z "$FILES" ]; then
# Scan the 200 newest so a current-branch checkpoint sitting below a burst of
# sibling-worktree saves can still be found; the result is capped at 20 below.
ALL=$(find "$CHECKPOINT_DIR" -maxdepth 1 -name "*.md" -type f 2>/dev/null | sort -r | head -200)
if [ -z "$ALL" ]; then
echo "NO_CHECKPOINTS"
else
# Order current-branch checkpoints first, other branches after. A git branch
# is checked out in at most one worktree, and all worktrees of a repo share
# one checkpoints dir (same origin-derived slug), so without this preference
# `/context-restore` in worktree A could load worktree B's newer checkpoint.
# Cross-branch resume (Conductor handoff) is preserved as the fallback: when
# the current branch has no checkpoint, the full newest-first set is used.
# CURRENT_BRANCH may be pre-set (tests); otherwise resolve it from git.
: "${CURRENT_BRANCH:=$(git rev-parse --abbrev-ref HEAD 2>/dev/null)}"
SAME=""; OTHER=""
while IFS= read -r f; do
[ -n "$f" ] || continue
b=$(grep -m1 '^branch:' "$f" 2>/dev/null | sed 's/^branch:[[:space:]]*//')
if [ -n "$CURRENT_BRANCH" ] && [ "$b" = "$CURRENT_BRANCH" ]; then
SAME="${SAME}${f}
"
else
OTHER="${OTHER}${f}
"
fi
done <<EOF
$ALL
EOF
# Cap at 20: a user with 10k saved files shouldn't blow the context window.
FILES=$(printf '%s%s' "$SAME" "$OTHER" | grep -v '^[[:space:]]*$' | head -20)
echo "$FILES"
fi
fi
```
**Candidates include every `.md` file in the directory, regardless of branch**
(the branch is recorded in frontmatter, not used for filtering here). This
enables Conductor workspace handoff.
**Candidates include every `.md` file in the directory**, but they are ordered
**current-branch-first** (the branch is read from each file's `branch:`
frontmatter). Other-branch files stay in the set as a fallback, which preserves
Conductor workspace handoff when the current branch has no checkpoint of its own.
### Step 2: Load the right file
- If the user specified a title fragment or number: find the matching file among
the candidates.
- Otherwise: load the **first file returned by the `sort -r` above** — that is
the newest `YYYYMMDD-HHMMSS` prefix, which is the canonical "most recent."
- Otherwise: load the **first file returned by Step 1 above** — that is the
newest `YYYYMMDD-HHMMSS` checkpoint for the current branch, or, if the current
branch has none, the newest across all branches.
Read the chosen file and present a summary:
@ -144,9 +176,10 @@ state, then `/context-restore` will find it."
## Important Rules
- **Never modify code.** This skill only reads saved files and presents them.
- **Always search across all branches by default.** Cross-branch resume is the
whole point. Only filter by branch if the user explicitly asks via a
title-fragment match that happens to be branch-specific.
- **Prefer the current branch's own checkpoint, but keep all branches in the
fallback set.** Cross-branch resume (Conductor handoff) still works when the
current branch has no checkpoint; it just no longer lets a sibling worktree's
newer save shadow this branch's own.
- **"Most recent" means the filename `YYYYMMDD-HHMMSS` prefix**, not
`ls -1t` (filesystem mtime). Filenames are stable across file-system
operations; mtime is not.

View File

@ -706,7 +706,7 @@ If you are looping on the same diagnostic, same file, or failed fix variants, ST
## Question Tuning (skip entirely if `QUESTION_TUNING: false`)
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `printf '%s' "<question summary>" | ~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>" --summary-stdin` (piped summary feeds the one-way keyword net, #2024). `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
**Embed the question_id as a marker in the question text** so hooks can identify it deterministically (plan-tune cathedral T14 / D18 progressive markers). Append `<gstack-qid:{question_id}>` somewhere in the rendered question (the leading line or trailing line is fine; the marker doesn't render visibly to the user when wrapped in HTML-style angle brackets, but the hook strips it). Without the marker the PreToolUse enforcement hook treats the AUQ as observed-only and never auto-decides — so always include it when the question matches a registered `question_id`.

View File

@ -709,7 +709,7 @@ If you are looping on the same diagnostic, same file, or failed fix variants, ST
## Question Tuning (skip entirely if `QUESTION_TUNING: false`)
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `printf '%s' "<question summary>" | ~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>" --summary-stdin` (piped summary feeds the one-way keyword net, #2024). `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
**Embed the question_id as a marker in the question text** so hooks can identify it deterministically (plan-tune cathedral T14 / D18 progressive markers). Append `<gstack-qid:{question_id}>` somewhere in the rendered question (the leading line or trailing line is fine; the marker doesn't render visibly to the user when wrapped in HTML-style angle brackets, but the hook strips it). Without the marker the PreToolUse enforcement hook treats the AUQ as observed-only and never auto-decides — so always include it when the question matches a registered `question_id`.

View File

@ -729,7 +729,7 @@ If you are looping on the same diagnostic, same file, or failed fix variants, ST
## Question Tuning (skip entirely if `QUESTION_TUNING: false`)
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `printf '%s' "<question summary>" | ~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>" --summary-stdin` (piped summary feeds the one-way keyword net, #2024). `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
**Embed the question_id as a marker in the question text** so hooks can identify it deterministically (plan-tune cathedral T14 / D18 progressive markers). Append `<gstack-qid:{question_id}>` somewhere in the rendered question (the leading line or trailing line is fine; the marker doesn't render visibly to the user when wrapped in HTML-style angle brackets, but the hook strips it). Without the marker the PreToolUse enforcement hook treats the AUQ as observed-only and never auto-decides — so always include it when the question matches a registered `question_id`.

View File

@ -710,7 +710,7 @@ If you are looping on the same diagnostic, same file, or failed fix variants, ST
## Question Tuning (skip entirely if `QUESTION_TUNING: false`)
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `printf '%s' "<question summary>" | ~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>" --summary-stdin` (piped summary feeds the one-way keyword net, #2024). `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
**Embed the question_id as a marker in the question text** so hooks can identify it deterministically (plan-tune cathedral T14 / D18 progressive markers). Append `<gstack-qid:{question_id}>` somewhere in the rendered question (the leading line or trailing line is fine; the marker doesn't render visibly to the user when wrapped in HTML-style angle brackets, but the hook strips it). Without the marker the PreToolUse enforcement hook treats the AUQ as observed-only and never auto-decides — so always include it when the question matches a registered `question_id`.

View File

@ -707,7 +707,7 @@ If you are looping on the same diagnostic, same file, or failed fix variants, ST
## Question Tuning (skip entirely if `QUESTION_TUNING: false`)
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `printf '%s' "<question summary>" | ~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>" --summary-stdin` (piped summary feeds the one-way keyword net, #2024). `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
**Embed the question_id as a marker in the question text** so hooks can identify it deterministically (plan-tune cathedral T14 / D18 progressive markers). Append `<gstack-qid:{question_id}>` somewhere in the rendered question (the leading line or trailing line is fine; the marker doesn't render visibly to the user when wrapped in HTML-style angle brackets, but the hook strips it). Without the marker the PreToolUse enforcement hook treats the AUQ as observed-only and never auto-decides — so always include it when the question matches a registered `question_id`.

View File

@ -724,7 +724,7 @@ If you are looping on the same diagnostic, same file, or failed fix variants, ST
## Question Tuning (skip entirely if `QUESTION_TUNING: false`)
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `printf '%s' "<question summary>" | ~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>" --summary-stdin` (piped summary feeds the one-way keyword net, #2024). `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
**Embed the question_id as a marker in the question text** so hooks can identify it deterministically (plan-tune cathedral T14 / D18 progressive markers). Append `<gstack-qid:{question_id}>` somewhere in the rendered question (the leading line or trailing line is fine; the marker doesn't render visibly to the user when wrapped in HTML-style angle brackets, but the hook strips it). Without the marker the PreToolUse enforcement hook treats the AUQ as observed-only and never auto-decides — so always include it when the question matches a registered `question_id`.

View File

@ -25,6 +25,7 @@ import { evolve } from "./evolve";
import { generateDesignToCodePrompt } from "./design-to-code";
import { serve } from "./serve";
import { gallery } from "./gallery";
import { normalizeIntFlag } from "./flag-utils";
import {
daemonStatus as daemonStatusClient,
ensureDaemon,
@ -137,7 +138,7 @@ async function main(): Promise<void> {
briefFile: flags["brief-file"] as string,
output: (flags.output as string) || "/tmp/gstack-mockup.png",
check: !!flags.check,
retry: flags.retry ? parseInt(flags.retry as string) : 0,
retry: normalizeIntFlag(flags.retry, { name: "retry", def: 0, min: 0 }),
size: flags.size as string,
quality: flags.quality as string,
});
@ -163,7 +164,7 @@ async function main(): Promise<void> {
if (flags["no-daemon"]) {
await serve({
html: outputPath,
timeout: flags.timeout ? parseInt(flags.timeout as string) : 600,
timeout: normalizeIntFlag(flags.timeout, { name: "timeout", def: 600, min: 1 }),
});
} else {
await publishToDaemon({
@ -197,7 +198,9 @@ async function main(): Promise<void> {
await variants({
brief: flags.brief as string,
briefFile: flags["brief-file"] as string,
count: flags.count ? parseInt(flags.count as string) : 3,
// #2032: pass the RAW flag through — variants() normalizes at its
// consumption site (a pre-parseInt here would silently truncate "3.7").
count: flags.count,
outputDir: (flags["output-dir"] as string) || "/tmp/gstack-variants/",
size: flags.size as string,
quality: flags.quality as string,

74
design/src/flag-utils.ts Normal file
View File

@ -0,0 +1,74 @@
/**
* Integer flag normalization for the design CLI (#2032).
*
* The CLI's flag parser yields a string ("3"), boolean true (bare flag with
* no value), or undefined (flag absent). parseInt on those produced NaN that
* flowed silently into loop bounds and setTimeout:
* --count abc for (i < NaN) never runs ZERO variants, exit 0
* --retry abc attempt <= NaN is false generate() silent no-op
* --timeout abc setTimeout(NaN) fires ~immediately serve dies at boot
*
* Contract (matches the --viewports precedent, variants.ts: error LOUDLY on
* nonsense; these commands spend real image-API money, so a silent fixup
* hides typos from calling agents):
* - undefined default (flag absent)
* - true / "" (bare flag) error: requires a value
* - non-numeric / non-integer error ("3.7" is rejected, not truncated)
* - below min error
* - above max (when given) clamp to max, stderr warning
* - repeated flag parser is last-wins before we ever see it
*/
export interface IntFlagSpec {
name: string;
def: number;
min: number;
max?: number;
}
export type IntFlagResult =
| { ok: true; value: number; warning?: string }
| { ok: false; error: string };
/** Pure decision function — unit-testable without process.exit. */
export function parseIntFlag(raw: unknown, spec: IntFlagSpec): IntFlagResult {
const { name, def, min, max } = spec;
const bounds = `an integer >= ${min}${max !== undefined ? ` (max ${max})` : ""}`;
if (raw === undefined || raw === false) return { ok: true, value: def };
if (raw === true) {
return { ok: false, error: `--${name} requires a value. Expected ${bounds}.` };
}
const s = String(raw).trim();
if (s === "") {
return { ok: false, error: `--${name} requires a value. Expected ${bounds}.` };
}
if (!/^-?\d+$/.test(s)) {
return { ok: false, error: `Invalid --${name}: "${s}" is not an integer. Expected ${bounds}.` };
}
const n = parseInt(s, 10);
if (n < min) {
return { ok: false, error: `Invalid --${name}: ${n} is below the minimum of ${min}.` };
}
if (max !== undefined && n > max) {
return {
ok: true,
value: max,
warning: `--${name} ${n} exceeds the maximum of ${max}; using ${max}.`,
};
}
return { ok: true, value: n };
}
/** CLI wrapper: loud exit(1) on invalid input, stderr warning on clamp. */
export function normalizeIntFlag(raw: unknown, spec: IntFlagSpec): number {
const r = parseIntFlag(raw, spec);
if (!r.ok) {
console.error(r.error);
process.exit(1);
}
if (r.warning) console.error(r.warning);
return r.value;
}

View File

@ -8,11 +8,17 @@ import fs from "fs";
import path from "path";
import { requireApiKey } from "./auth";
import { parseBrief } from "./brief";
import { normalizeIntFlag } from "./flag-utils";
export interface VariantsOptions {
brief?: string;
briefFile?: string;
count: number;
/**
* Raw CLI flag value or a number. Normalized inside variants() (#2032):
* nonsense errors loudly; above STYLE_VARIATIONS.length clamps with a
* warning past that index variants degrade to duplicate base-brief runs.
*/
count?: number | string | boolean;
outputDir: string;
size?: string;
quality?: string;
@ -153,7 +159,15 @@ export async function variants(options: VariantsOptions): Promise<void> {
return;
}
const count = Math.min(options.count, 7); // Cap at 7 style variations
// #2032: normalize at the consumption site so every caller (CLI or
// programmatic) gets the loud-on-nonsense contract; the ceiling derives
// from STYLE_VARIATIONS so it self-adjusts when styles are added.
const count = normalizeIntFlag(options.count, {
name: "count",
def: 3,
min: 1,
max: STYLE_VARIATIONS.length,
});
const size = options.size || "1536x1024";
console.error(`Generating ${count} variants...`);

View File

@ -709,7 +709,7 @@ If you are looping on the same diagnostic, same file, or failed fix variants, ST
## Question Tuning (skip entirely if `QUESTION_TUNING: false`)
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `printf '%s' "<question summary>" | ~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>" --summary-stdin` (piped summary feeds the one-way keyword net, #2024). `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
**Embed the question_id as a marker in the question text** so hooks can identify it deterministically (plan-tune cathedral T14 / D18 progressive markers). Append `<gstack-qid:{question_id}>` somewhere in the rendered question (the leading line or trailing line is fine; the marker doesn't render visibly to the user when wrapped in HTML-style angle brackets, but the hook strips it). Without the marker the PreToolUse enforcement hook treats the AUQ as observed-only and never auto-decides — so always include it when the question matches a registered `question_id`.

View File

@ -704,7 +704,7 @@ If you are looping on the same diagnostic, same file, or failed fix variants, ST
## Question Tuning (skip entirely if `QUESTION_TUNING: false`)
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `printf '%s' "<question summary>" | ~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>" --summary-stdin` (piped summary feeds the one-way keyword net, #2024). `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
**Embed the question_id as a marker in the question text** so hooks can identify it deterministically (plan-tune cathedral T14 / D18 progressive markers). Append `<gstack-qid:{question_id}>` somewhere in the rendered question (the leading line or trailing line is fine; the marker doesn't render visibly to the user when wrapped in HTML-style angle brackets, but the hook strips it). Without the marker the PreToolUse enforcement hook treats the AUQ as observed-only and never auto-decides — so always include it when the question matches a registered `question_id`.

View File

@ -51,7 +51,13 @@ Optional in subagent context: `agent_id`, `agent_type`.
- `"deny"` — block (feedback to Claude, NOT a synthetic answer per Codex
correction in D-prefixed decisions)
- `"ask"` — escalate to user
- `"defer"` — let permission flow continue
- `"defer"` — pause the tool call for EXTERNAL resumption (Claude Code
v2.1.89+, a headless feature: resume with `-p --resume` to re-evaluate).
NEVER emit this to mean "no opinion" — in an interactive session nothing
resumes the paused call and the tool dies with "Tool result missing due to
internal error" (#2035, #2006). To abstain, exit 0 with EMPTY stdout
(optionally `hookSpecificOutput` with `additionalContext` only, no
`permissionDecision`).
**`updatedInput` semantics:** shallow merge of fields present in the returned
object onto the original `tool_input`. Only valid with
@ -106,15 +112,20 @@ required for our hook to fire there.
}
```
**Pass-through (no preference, or one-way safety override):**
**Pass-through (no preference, or one-way safety override):** exit 0 with
EMPTY stdout. When there is context to inject (plan-tune memory nuggets),
emit `additionalContext` WITHOUT a `permissionDecision`:
```json
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "defer"
"additionalContext": "[plan-tune memory] Past answers suggest: ..."
}
}
```
(Historical note: this example originally emitted `permissionDecision:
"defer"`, which broke every AskUserQuestion once CC v2.1.89 gave 'defer'
pause-for-resume semantics — #2035.)
**PostToolUse capture (always):**
```json

View File

@ -709,7 +709,7 @@ If you are looping on the same diagnostic, same file, or failed fix variants, ST
## Question Tuning (skip entirely if `QUESTION_TUNING: false`)
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `printf '%s' "<question summary>" | ~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>" --summary-stdin` (piped summary feeds the one-way keyword net, #2024). `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
**Embed the question_id as a marker in the question text** so hooks can identify it deterministically (plan-tune cathedral T14 / D18 progressive markers). Append `<gstack-qid:{question_id}>` somewhere in the rendered question (the leading line or trailing line is fine; the marker doesn't render visibly to the user when wrapped in HTML-style angle brackets, but the hook strips it). Without the marker the PreToolUse enforcement hook treats the AUQ as observed-only and never auto-decides — so always include it when the question matches a registered `question_id`.

View File

@ -707,7 +707,7 @@ If you are looping on the same diagnostic, same file, or failed fix variants, ST
## Question Tuning (skip entirely if `QUESTION_TUNING: false`)
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `printf '%s' "<question summary>" | ~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>" --summary-stdin` (piped summary feeds the one-way keyword net, #2024). `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
**Embed the question_id as a marker in the question text** so hooks can identify it deterministically (plan-tune cathedral T14 / D18 progressive markers). Append `<gstack-qid:{question_id}>` somewhere in the rendered question (the leading line or trailing line is fine; the marker doesn't render visibly to the user when wrapped in HTML-style angle brackets, but the hook strips it). Without the marker the PreToolUse enforcement hook treats the AUQ as observed-only and never auto-decides — so always include it when the question matches a registered `question_id`.

View File

@ -705,7 +705,7 @@ If you are looping on the same diagnostic, same file, or failed fix variants, ST
## Question Tuning (skip entirely if `QUESTION_TUNING: false`)
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `printf '%s' "<question summary>" | ~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>" --summary-stdin` (piped summary feeds the one-way keyword net, #2024). `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
**Embed the question_id as a marker in the question text** so hooks can identify it deterministically (plan-tune cathedral T14 / D18 progressive markers). Append `<gstack-qid:{question_id}>` somewhere in the rendered question (the leading line or trailing line is fine; the marker doesn't render visibly to the user when wrapped in HTML-style angle brackets, but the hook strips it). Without the marker the PreToolUse enforcement hook treats the AUQ as observed-only and never auto-decides — so always include it when the question matches a registered `question_id`.

View File

@ -12,11 +12,15 @@
* 2. Look up door_type from scripts/question-registry.ts (default two-way).
* 3. Read preferences with precedence: project-local > global (D8).
* 4. Apply:
* never-ask + one-way defer (safety override; one-way always asks).
* never-ask + one-way pass through (safety override; one-way always asks).
* never-ask + two-way + marker deny with auto-decided recommendation
* in reason. Mark tool_use_id so PostToolUse logs as 'auto-decided'.
* ask-only-for-one-way + two-way + marker same as never-ask.
* always-ask, or no preference defer.
* always-ask, or no preference pass through.
*
* Pass-through = exit 0 with empty stdout (or additionalContext-only output
* when memory nuggets exist) NEVER permissionDecision:'defer', whose
* CC v2.1.89+ semantics are pause-for-external-resumption (#2035, #2006).
*
* Why deny+reason instead of allow+updatedInput:
* AskUserQuestion's `updatedInput` shape for "pre-resolve this question"
@ -31,7 +35,7 @@
* - First: (recommended) label suffix on an option.
* - Fall back: "Recommendation: X" prose match against option labels.
* - Refuse to auto-decide if ambiguous (multiple labels OR no parseable
* recommendation): defer instead of silent-wrong.
* recommendation): pass through instead of silent-wrong.
*
* Always exits 0. Hook errors land in ~/.gstack/hook-errors.log.
* See docs/spikes/claude-code-hook-mutation.md for the protocol contract.
@ -41,6 +45,7 @@ import * as path from 'path';
import * as os from 'os';
import { spawnSync } from 'child_process';
import { isConductor } from '../../../lib/is-conductor';
import { classifyQuestion } from '../../../scripts/one-way-doors';
interface HookStdin {
session_id?: string;
@ -92,13 +97,25 @@ function readStdin(): Promise<string> {
});
}
function defer(additionalContext?: string): void {
const out: Record<string, unknown> = {
hookEventName: 'PreToolUse',
permissionDecision: 'defer',
};
if (additionalContext) out.additionalContext = additionalContext;
process.stdout.write(JSON.stringify({ hookSpecificOutput: out }));
function passThrough(additionalContext?: string): void {
// Abstain = exit 0 with EMPTY stdout (#2035, #2006). Never emit a
// permissionDecision here: 'defer' is a real PreToolUse value, but since
// Claude Code v2.1.89 its semantics are "pause this tool call for external
// resumption" (a headless-resume feature) — NOT "no opinion". In an
// interactive session nothing resumes the paused call, so every
// AskUserQuestion died with "Tool result missing due to internal error".
// additionalContext-only hookSpecificOutput is the documented shape for
// injecting context (plan-tune memory nuggets) without a decision.
if (additionalContext) {
process.stdout.write(
JSON.stringify({
hookSpecificOutput: {
hookEventName: 'PreToolUse',
additionalContext,
},
}),
);
}
process.exit(0);
}
@ -347,7 +364,7 @@ function logAutoDecided(
async function main(): Promise<void> {
const raw = await readStdin();
if (!raw.trim()) {
defer();
passThrough();
return;
}
let stdin: HookStdin;
@ -355,7 +372,7 @@ async function main(): Promise<void> {
stdin = JSON.parse(raw);
} catch (e) {
logHookError(`stdin parse failed: ${(e as Error).message}`);
defer();
passThrough();
return;
}
@ -364,26 +381,26 @@ async function main(): Promise<void> {
toolName !== 'AskUserQuestion' &&
!toolName.match(/^mcp__.+__AskUserQuestion$/)
) {
defer();
passThrough();
return;
}
const questions = stdin.tool_input?.questions || [];
if (questions.length === 0) {
defer();
passThrough();
return;
}
// For multi-question AUQ, enforcement is all-or-nothing per call:
// we deny only if ALL questions have marker + never-ask + safe door type.
// Mixed cases pass through (defer) so the user still gets to answer.
// Mixed cases pass through so the user still gets to answer.
const registry = loadRegistry();
const slug = slugFromCwd(stdin.cwd);
const memoryNuggets = loadMemoryNuggets(stdin.session_id);
// Compute Layer 8 memory context inline: any nuggets matching the
// signal_keys of the questions in this AUQ get surfaced as additionalContext.
// This applies whether we defer OR deny — gives the agent + user the
// This applies whether we pass through OR deny — gives the agent + user the
// relevant prior context either way.
const contextNuggets: string[] = [];
for (const q of questions) {
@ -402,7 +419,7 @@ async function main(): Promise<void> {
: undefined;
// Determine whether EVERY question is eligible for never-ask auto-decide.
// We deliberately do NOT early-return defer on the first ineligible question:
// We deliberately do NOT early-return pass-through on the first ineligible question:
// a Conductor session still needs the [conductor] prose deny as a fallback,
// so we compute eligibility, then branch. memoryContext is preserved on every
// non-enforcing exit. (All-or-nothing per-call semantics are unchanged: any
@ -418,7 +435,21 @@ async function main(): Promise<void> {
if (!pref.preference || pref.preference === 'always-ask') { fullyAutoDecidable = false; break; }
const entry = registry[questionId];
const doorType = entry?.door_type || 'two-way';
let doorType: string = entry?.door_type || 'two-way';
if (!entry) {
// #2024: an unregistered id used to default straight to two-way without
// consulting the keyword net, so an ad-hoc DESTRUCTIVE question with a
// stored never-ask preference auto-decided. classifyQuestion is a pure
// regex pass over the question text; on any failure keep the default
// (enforcement still requires an explicit stored preference).
try {
if (classifyQuestion({ summary: qText.replace(MARKER_RE, '').trim() }).oneWay) {
doorType = 'one-way';
}
} catch (e) {
logHookError(`one-way classifier failed: ${(e as Error).message}`);
}
}
// Safety override — even never-ask doesn't bypass one-way doors.
if (doorType === 'one-way') { fullyAutoDecidable = false; break; }
@ -471,10 +502,10 @@ async function main(): Promise<void> {
return;
}
defer(memoryContext);
passThrough(memoryContext);
}
main().catch((e) => {
logHookError(`main crash: ${(e as Error).message}`);
defer();
passThrough();
});

View File

@ -744,7 +744,7 @@ If you are looping on the same diagnostic, same file, or failed fix variants, ST
## Question Tuning (skip entirely if `QUESTION_TUNING: false`)
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `printf '%s' "<question summary>" | ~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>" --summary-stdin` (piped summary feeds the one-way keyword net, #2024). `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
**Embed the question_id as a marker in the question text** so hooks can identify it deterministically (plan-tune cathedral T14 / D18 progressive markers). Append `<gstack-qid:{question_id}>` somewhere in the rendered question (the leading line or trailing line is fine; the marker doesn't render visibly to the user when wrapped in HTML-style angle brackets, but the hook strips it). Without the marker the PreToolUse enforcement hook treats the AUQ as observed-only and never auto-decides — so always include it when the question matches a registered `question_id`.

View File

@ -707,7 +707,7 @@ If you are looping on the same diagnostic, same file, or failed fix variants, ST
## Question Tuning (skip entirely if `QUESTION_TUNING: false`)
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `printf '%s' "<question summary>" | ~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>" --summary-stdin` (piped summary feeds the one-way keyword net, #2024). `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
**Embed the question_id as a marker in the question text** so hooks can identify it deterministically (plan-tune cathedral T14 / D18 progressive markers). Append `<gstack-qid:{question_id}>` somewhere in the rendered question (the leading line or trailing line is fine; the marker doesn't render visibly to the user when wrapped in HTML-style angle brackets, but the hook strips it). Without the marker the PreToolUse enforcement hook treats the AUQ as observed-only and never auto-decides — so always include it when the question matches a registered `question_id`.

View File

@ -709,7 +709,7 @@ If you are looping on the same diagnostic, same file, or failed fix variants, ST
## Question Tuning (skip entirely if `QUESTION_TUNING: false`)
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `printf '%s' "<question summary>" | ~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>" --summary-stdin` (piped summary feeds the one-way keyword net, #2024). `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
**Embed the question_id as a marker in the question text** so hooks can identify it deterministically (plan-tune cathedral T14 / D18 progressive markers). Append `<gstack-qid:{question_id}>` somewhere in the rendered question (the leading line or trailing line is fine; the marker doesn't render visibly to the user when wrapped in HTML-style angle brackets, but the hook strips it). Without the marker the PreToolUse enforcement hook treats the AUQ as observed-only and never auto-decides — so always include it when the question matches a registered `question_id`.

View File

@ -710,7 +710,7 @@ If you are looping on the same diagnostic, same file, or failed fix variants, ST
## Question Tuning (skip entirely if `QUESTION_TUNING: false`)
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `printf '%s' "<question summary>" | ~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>" --summary-stdin` (piped summary feeds the one-way keyword net, #2024). `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
**Embed the question_id as a marker in the question text** so hooks can identify it deterministically (plan-tune cathedral T14 / D18 progressive markers). Append `<gstack-qid:{question_id}>` somewhere in the rendered question (the leading line or trailing line is fine; the marker doesn't render visibly to the user when wrapped in HTML-style angle brackets, but the hook strips it). Without the marker the PreToolUse enforcement hook treats the AUQ as observed-only and never auto-decides — so always include it when the question matches a registered `question_id`.

View File

@ -713,7 +713,7 @@ If you are looping on the same diagnostic, same file, or failed fix variants, ST
## Question Tuning (skip entirely if `QUESTION_TUNING: false`)
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `printf '%s' "<question summary>" | ~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>" --summary-stdin` (piped summary feeds the one-way keyword net, #2024). `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
**Embed the question_id as a marker in the question text** so hooks can identify it deterministically (plan-tune cathedral T14 / D18 progressive markers). Append `<gstack-qid:{question_id}>` somewhere in the rendered question (the leading line or trailing line is fine; the marker doesn't render visibly to the user when wrapped in HTML-style angle brackets, but the hook strips it). Without the marker the PreToolUse enforcement hook treats the AUQ as observed-only and never auto-decides — so always include it when the question matches a registered `question_id`.

View File

@ -707,7 +707,7 @@ If you are looping on the same diagnostic, same file, or failed fix variants, ST
## Question Tuning (skip entirely if `QUESTION_TUNING: false`)
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `printf '%s' "<question summary>" | ~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>" --summary-stdin` (piped summary feeds the one-way keyword net, #2024). `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
**Embed the question_id as a marker in the question text** so hooks can identify it deterministically (plan-tune cathedral T14 / D18 progressive markers). Append `<gstack-qid:{question_id}>` somewhere in the rendered question (the leading line or trailing line is fine; the marker doesn't render visibly to the user when wrapped in HTML-style angle brackets, but the hook strips it). Without the marker the PreToolUse enforcement hook treats the AUQ as observed-only and never auto-decides — so always include it when the question matches a registered `question_id`.

View File

@ -702,7 +702,7 @@ If you are looping on the same diagnostic, same file, or failed fix variants, ST
## Question Tuning (skip entirely if `QUESTION_TUNING: false`)
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `printf '%s' "<question summary>" | ~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>" --summary-stdin` (piped summary feeds the one-way keyword net, #2024). `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
**Embed the question_id as a marker in the question text** so hooks can identify it deterministically (plan-tune cathedral T14 / D18 progressive markers). Append `<gstack-qid:{question_id}>` somewhere in the rendered question (the leading line or trailing line is fine; the marker doesn't render visibly to the user when wrapped in HTML-style angle brackets, but the hook strips it). Without the marker the PreToolUse enforcement hook treats the AUQ as observed-only and never auto-decides — so always include it when the question matches a registered `question_id`.

View File

@ -703,7 +703,7 @@ If you are looping on the same diagnostic, same file, or failed fix variants, ST
## Question Tuning (skip entirely if `QUESTION_TUNING: false`)
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `printf '%s' "<question summary>" | ~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>" --summary-stdin` (piped summary feeds the one-way keyword net, #2024). `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
**Embed the question_id as a marker in the question text** so hooks can identify it deterministically (plan-tune cathedral T14 / D18 progressive markers). Append `<gstack-qid:{question_id}>` somewhere in the rendered question (the leading line or trailing line is fine; the marker doesn't render visibly to the user when wrapped in HTML-style angle brackets, but the hook strips it). Without the marker the PreToolUse enforcement hook treats the AUQ as observed-only and never auto-decides — so always include it when the question matches a registered `question_id`.

View File

@ -705,7 +705,7 @@ If you are looping on the same diagnostic, same file, or failed fix variants, ST
## Question Tuning (skip entirely if `QUESTION_TUNING: false`)
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `printf '%s' "<question summary>" | ~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>" --summary-stdin` (piped summary feeds the one-way keyword net, #2024). `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
**Embed the question_id as a marker in the question text** so hooks can identify it deterministically (plan-tune cathedral T14 / D18 progressive markers). Append `<gstack-qid:{question_id}>` somewhere in the rendered question (the leading line or trailing line is fine; the marker doesn't render visibly to the user when wrapped in HTML-style angle brackets, but the hook strips it). Without the marker the PreToolUse enforcement hook treats the AUQ as observed-only and never auto-decides — so always include it when the question matches a registered `question_id`.

View File

@ -24,6 +24,12 @@
* Timeout probe exceeded GSTACK_GBRAIN_PROBE_TIMEOUT_MS (default 15s) with no
* recognized error engine is likely healthy but slow (e.g. a cold
* pooler connection, #1964). Consumers treat this as usable.
* Thin-client config carries gbrain's remote_mcp marker (#2051): NO local
* engine by design; queries go to a remote-HTTP MCP brain. Usable
* for brain-aware prose gates; sync stages that need a LOCAL engine
* (code/memory/dream) skip. Remote reachability is verified at USE
* time (gbrain calls degrade gracefully), never by a classifier
* network probe that's the #1964 pathology.
* Ok DB reachable, sources list returned valid JSON.
*/
@ -50,7 +56,8 @@ export type LocalEngineStatus =
| "broken-config"
| "broken-db"
| "engine-locked"
| "timeout";
| "timeout"
| "thin-client";
export interface ClassifyOptions {
/** Bypass the 60s cache. Used after any state-mutating operation. */
@ -270,6 +277,26 @@ function freshClassify(env?: NodeJS.ProcessEnv): LocalEngineStatus {
// 2. Config file present?
if (!existsSync(gbrainConfigPath(env))) return "missing-config";
// 2.5 Thin client? gbrain's own marker (mirrors gbrain isThinClient():
// truthy remote_mcp in config). A thin client has NO local engine — gbrain
// REFUSES `sources` commands on it (THIN_CLIENT_REFUSED_COMMANDS, exit 1
// with no recognized error string), so the probe below would fall to the
// defensive broken-config default and silently suppress brain-aware blocks
// (#2051). Detected PRE-probe from the config file: zero network cost,
// immune to gbrain error-string drift. Remote reachability is deliberately
// NOT probed here — a classifier network probe is the #1964 pathology.
try {
const cfg = JSON.parse(readFileSync(gbrainConfigPath(env), "utf-8")) as {
remote_mcp?: unknown;
};
if (cfg && typeof cfg === "object" && cfg.remote_mcp) {
return "thin-client";
}
} catch {
// Unparseable config: fall through to the probe, whose stderr
// classification surfaces broken-config with the raw error upstream.
}
// 3. Probe gbrain sources list.
//
// Seed DATABASE_URL from ~/.gbrain/config.json (via buildGbrainEnv, the
@ -301,7 +328,11 @@ function freshClassify(env?: NodeJS.ProcessEnv): LocalEngineStatus {
if (e.code === "ENOENT") return "no-cli";
// Pattern match against gbrain's known error strings. Order matters:
// "Cannot connect to database" is the more specific DB-unreachable signal.
// thin-client refusal first (backstop for a config the pre-probe check
// couldn't read — gbrain's dispatch guard says e.g. "`gbrain sources` is
// not routable ... (thin-client of <url>)"), then the more specific
// DB-unreachable signal.
if (/thin[- ]client/i.test(stderr)) return "thin-client";
if (stderr.includes("Cannot connect to database")) return "broken-db";
if (stderr.includes("config.json")) return "broken-config";

View File

@ -10,8 +10,15 @@
*/
import { execFileSync, spawnSync } from "child_process";
import { realpathSync } from "fs";
import { withErrorContext } from "./gstack-memory-helpers";
import { execGbrainJson, NEEDS_SHELL_ON_WINDOWS } from "./gbrain-exec";
import {
detectAutopilot,
decideSourceRemove,
type AutopilotProbe,
type DecideRemoveOpts,
} from "./gbrain-guards";
export interface SourceState {
/** "absent" — id not registered. "match" — id at expected path. "drift" — id at different path. */
@ -70,6 +77,33 @@ export interface EnsureOptions {
* mutations of process.env.PATH unless env is passed explicitly).
*/
env?: NodeJS.ProcessEnv;
/**
* #1734 test hooks for the drift-remove guards. Production callers leave
* these unset (real autopilot detection + real remove decision). Tests pin
* them so a live autopilot on the dev machine can't flip test outcomes.
*/
autopilotProbe?: AutopilotProbe;
removeDecision?: DecideRemoveOpts;
}
/**
* Path equality with realpath normalization (macOS /tmp -> /private/tmp,
* symlinked worktrees). A registered path that resolves to the same real
* directory is NOT drift declaring it drift triggers a destructive
* remove+add and a full re-index for a no-op (#1985 reporter hit the remove
* on an unmoved repo).
*/
function samePath(registered: string | undefined, requested: string): boolean {
if (!registered) return false;
if (registered === requested) return true;
const real = (p: string): string => {
try {
return realpathSync(p);
} catch {
return p;
}
};
return real(registered) === real(requested);
}
/**
@ -124,7 +158,7 @@ export function probeSource(id: string, env?: NodeJS.ProcessEnv): SourceState {
* Behavior:
* - status=absent `gbrain sources add <id> --path <path> [--federated]`, returns changed=true.
* - status=match + same path no-op, returns changed=false.
* - status=match + different path `sources remove` + `sources add`, returns changed=true.
* - status=match + different path `sources remove --confirm-destructive` + `sources add`, returns changed=true.
* (Skip when reregister_on_drift=false; returns changed=false.)
*
* Caller is responsible for catching errors. The function uses withErrorContext for
@ -142,9 +176,10 @@ export async function ensureSourceRegistered(
return withErrorContext(`ensureSourceRegistered:${id}`, () => {
const probed = probeSource(id, env);
// Disambiguate match-but-different-path
// Disambiguate match-but-different-path (realpath-normalized: a symlink
// alias of the same directory is a match, not drift).
let state: SourceState = probed;
if (probed.status === "match" && probed.registered_path !== path) {
if (probed.status === "match" && !samePath(probed.registered_path, path)) {
state = { status: "drift", registered_path: probed.registered_path };
}
@ -157,13 +192,48 @@ export async function ensureSourceRegistered(
}
// For drift, remove first.
//
// #1985: gbrain >= 0.42 gates `sources remove` behind --confirm-destructive
// (`--yes` alone no longer suppresses the data-loss prompt). Without it the
// remove fails with "To proceed, pass --confirm-destructive", which surfaces
// as "source registration failed" and aborts the whole /sync-gbrain code
// stage for any source that has drifted to a new path. This matches the
// flag the orchestrator's own safeSourcesRemove() already passes.
if (state.status === "drift") {
const rm = spawnSync("gbrain", ["sources", "remove", id, "--yes"], {
encoding: "utf-8",
timeout: 30_000,
env,
shell: NEEDS_SHELL_ON_WINDOWS, // #1731: gbrain is a .cmd shim on Windows
});
// Loud drift observability: if this line shows up on every sync for some
// environment, drift is perpetual there and the reindex-in-place design
// from #1985 should be promoted (drop+rebuild re-embeds the full index).
console.error(
`[gbrain-sources] drift: ${id} registered at ${state.registered_path} -> re-registering at ${path}`,
);
// #1734: this remove deletes the source's pages/chunks/embeddings, so it
// runs only behind the same data-loss guards as the orchestrator's
// safeSourcesRemove(). A refusal is FATAL here (not best-effort): without
// the remove the add cannot proceed, and returning changed=false would
// silently hide the drifted registration.
const ap = detectAutopilot(env ?? process.env, options.autopilotProbe ?? {});
if (ap.active) {
throw new Error(
`refusing drift re-register of ${id}: autopilot active (${ap.signal}). ` +
`Stop autopilot, then re-run /sync-gbrain.`,
);
}
const decision = decideSourceRemove(id, env ?? process.env, options.removeDecision ?? {});
if (!decision.allow) {
throw new Error(`refusing drift re-register of ${id}: ${decision.reason}`);
}
const rm = spawnSync(
"gbrain",
["sources", "remove", id, "--yes", "--confirm-destructive", ...decision.extraArgs],
{
encoding: "utf-8",
timeout: 30_000,
env,
shell: NEEDS_SHELL_ON_WINDOWS, // #1731: gbrain is a .cmd shim on Windows
},
);
if (rm.status !== 0) {
throw new Error(`gbrain sources remove ${id} failed: ${rm.stderr || rm.stdout || `exit ${rm.status}`}`);
}

View File

@ -740,7 +740,7 @@ If you are looping on the same diagnostic, same file, or failed fix variants, ST
## Question Tuning (skip entirely if `QUESTION_TUNING: false`)
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `printf '%s' "<question summary>" | ~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>" --summary-stdin` (piped summary feeds the one-way keyword net, #2024). `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
**Embed the question_id as a marker in the question text** so hooks can identify it deterministically (plan-tune cathedral T14 / D18 progressive markers). Append `<gstack-qid:{question_id}>` somewhere in the rendered question (the leading line or trailing line is fine; the marker doesn't render visibly to the user when wrapped in HTML-style angle brackets, but the hook strips it). Without the marker the PreToolUse enforcement hook treats the AUQ as observed-only and never auto-decides — so always include it when the question matches a registered `question_id`.

View File

@ -702,7 +702,7 @@ If you are looping on the same diagnostic, same file, or failed fix variants, ST
## Question Tuning (skip entirely if `QUESTION_TUNING: false`)
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `printf '%s' "<question summary>" | ~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>" --summary-stdin` (piped summary feeds the one-way keyword net, #2024). `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
**Embed the question_id as a marker in the question text** so hooks can identify it deterministically (plan-tune cathedral T14 / D18 progressive markers). Append `<gstack-qid:{question_id}>` somewhere in the rendered question (the leading line or trailing line is fine; the marker doesn't render visibly to the user when wrapped in HTML-style angle brackets, but the hook strips it). Without the marker the PreToolUse enforcement hook treats the AUQ as observed-only and never auto-decides — so always include it when the question matches a registered `question_id`.

View File

@ -1,6 +1,6 @@
{
"name": "gstack",
"version": "1.60.2.0",
"version": "1.61.0.0",
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
"license": "MIT",
"type": "module",

View File

@ -704,7 +704,7 @@ If you are looping on the same diagnostic, same file, or failed fix variants, ST
## Question Tuning (skip entirely if `QUESTION_TUNING: false`)
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `printf '%s' "<question summary>" | ~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>" --summary-stdin` (piped summary feeds the one-way keyword net, #2024). `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
**Embed the question_id as a marker in the question text** so hooks can identify it deterministically (plan-tune cathedral T14 / D18 progressive markers). Append `<gstack-qid:{question_id}>` somewhere in the rendered question (the leading line or trailing line is fine; the marker doesn't render visibly to the user when wrapped in HTML-style angle brackets, but the hook strips it). Without the marker the PreToolUse enforcement hook treats the AUQ as observed-only and never auto-decides — so always include it when the question matches a registered `question_id`.

View File

@ -734,7 +734,7 @@ If you are looping on the same diagnostic, same file, or failed fix variants, ST
## Question Tuning (skip entirely if `QUESTION_TUNING: false`)
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `printf '%s' "<question summary>" | ~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>" --summary-stdin` (piped summary feeds the one-way keyword net, #2024). `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
**Embed the question_id as a marker in the question text** so hooks can identify it deterministically (plan-tune cathedral T14 / D18 progressive markers). Append `<gstack-qid:{question_id}>` somewhere in the rendered question (the leading line or trailing line is fine; the marker doesn't render visibly to the user when wrapped in HTML-style angle brackets, but the hook strips it). Without the marker the PreToolUse enforcement hook treats the AUQ as observed-only and never auto-decides — so always include it when the question matches a registered `question_id`.

View File

@ -706,7 +706,7 @@ If you are looping on the same diagnostic, same file, or failed fix variants, ST
## Question Tuning (skip entirely if `QUESTION_TUNING: false`)
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `printf '%s' "<question summary>" | ~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>" --summary-stdin` (piped summary feeds the one-way keyword net, #2024). `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
**Embed the question_id as a marker in the question text** so hooks can identify it deterministically (plan-tune cathedral T14 / D18 progressive markers). Append `<gstack-qid:{question_id}>` somewhere in the rendered question (the leading line or trailing line is fine; the marker doesn't render visibly to the user when wrapped in HTML-style angle brackets, but the hook strips it). Without the marker the PreToolUse enforcement hook treats the AUQ as observed-only and never auto-decides — so always include it when the question matches a registered `question_id`.

View File

@ -712,7 +712,7 @@ If you are looping on the same diagnostic, same file, or failed fix variants, ST
## Question Tuning (skip entirely if `QUESTION_TUNING: false`)
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `printf '%s' "<question summary>" | ~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>" --summary-stdin` (piped summary feeds the one-way keyword net, #2024). `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
**Embed the question_id as a marker in the question text** so hooks can identify it deterministically (plan-tune cathedral T14 / D18 progressive markers). Append `<gstack-qid:{question_id}>` somewhere in the rendered question (the leading line or trailing line is fine; the marker doesn't render visibly to the user when wrapped in HTML-style angle brackets, but the hook strips it). Without the marker the PreToolUse enforcement hook treats the AUQ as observed-only and never auto-decides — so always include it when the question matches a registered `question_id`.

View File

@ -710,7 +710,7 @@ If you are looping on the same diagnostic, same file, or failed fix variants, ST
## Question Tuning (skip entirely if `QUESTION_TUNING: false`)
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `printf '%s' "<question summary>" | ~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>" --summary-stdin` (piped summary feeds the one-way keyword net, #2024). `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
**Embed the question_id as a marker in the question text** so hooks can identify it deterministically (plan-tune cathedral T14 / D18 progressive markers). Append `<gstack-qid:{question_id}>` somewhere in the rendered question (the leading line or trailing line is fine; the marker doesn't render visibly to the user when wrapped in HTML-style angle brackets, but the hook strips it). Without the marker the PreToolUse enforcement hook treats the AUQ as observed-only and never auto-decides — so always include it when the question matches a registered `question_id`.

View File

@ -715,7 +715,7 @@ If you are looping on the same diagnostic, same file, or failed fix variants, ST
## Question Tuning (skip entirely if `QUESTION_TUNING: false`)
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `printf '%s' "<question summary>" | ~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>" --summary-stdin` (piped summary feeds the one-way keyword net, #2024). `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
**Embed the question_id as a marker in the question text** so hooks can identify it deterministically (plan-tune cathedral T14 / D18 progressive markers). Append `<gstack-qid:{question_id}>` somewhere in the rendered question (the leading line or trailing line is fine; the marker doesn't render visibly to the user when wrapped in HTML-style angle brackets, but the hook strips it). Without the marker the PreToolUse enforcement hook treats the AUQ as observed-only and never auto-decides — so always include it when the question matches a registered `question_id`.

View File

@ -705,7 +705,7 @@ If you are looping on the same diagnostic, same file, or failed fix variants, ST
## Question Tuning (skip entirely if `QUESTION_TUNING: false`)
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `printf '%s' "<question summary>" | ~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>" --summary-stdin` (piped summary feeds the one-way keyword net, #2024). `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
**Embed the question_id as a marker in the question text** so hooks can identify it deterministically (plan-tune cathedral T14 / D18 progressive markers). Append `<gstack-qid:{question_id}>` somewhere in the rendered question (the leading line or trailing line is fine; the marker doesn't render visibly to the user when wrapped in HTML-style angle brackets, but the hook strips it). Without the marker the PreToolUse enforcement hook treats the AUQ as observed-only and never auto-decides — so always include it when the question matches a registered `question_id`.

View File

@ -711,7 +711,7 @@ If you are looping on the same diagnostic, same file, or failed fix variants, ST
## Question Tuning (skip entirely if `QUESTION_TUNING: false`)
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `printf '%s' "<question summary>" | ~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>" --summary-stdin` (piped summary feeds the one-way keyword net, #2024). `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
**Embed the question_id as a marker in the question text** so hooks can identify it deterministically (plan-tune cathedral T14 / D18 progressive markers). Append `<gstack-qid:{question_id}>` somewhere in the rendered question (the leading line or trailing line is fine; the marker doesn't render visibly to the user when wrapped in HTML-style angle brackets, but the hook strips it). Without the marker the PreToolUse enforcement hook treats the AUQ as observed-only and never auto-decides — so always include it when the question matches a registered `question_id`.

View File

@ -722,7 +722,7 @@ If you are looping on the same diagnostic, same file, or failed fix variants, ST
## Question Tuning (skip entirely if `QUESTION_TUNING: false`)
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `printf '%s' "<question summary>" | ~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>" --summary-stdin` (piped summary feeds the one-way keyword net, #2024). `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
**Embed the question_id as a marker in the question text** so hooks can identify it deterministically (plan-tune cathedral T14 / D18 progressive markers). Append `<gstack-qid:{question_id}>` somewhere in the rendered question (the leading line or trailing line is fine; the marker doesn't render visibly to the user when wrapped in HTML-style angle brackets, but the hook strips it). Without the marker the PreToolUse enforcement hook treats the AUQ as observed-only and never auto-decides — so always include it when the question matches a registered `question_id`.

View File

@ -707,7 +707,7 @@ If you are looping on the same diagnostic, same file, or failed fix variants, ST
## Question Tuning (skip entirely if `QUESTION_TUNING: false`)
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `printf '%s' "<question summary>" | ~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>" --summary-stdin` (piped summary feeds the one-way keyword net, #2024). `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
**Embed the question_id as a marker in the question text** so hooks can identify it deterministically (plan-tune cathedral T14 / D18 progressive markers). Append `<gstack-qid:{question_id}>` somewhere in the rendered question (the leading line or trailing line is fine; the marker doesn't render visibly to the user when wrapped in HTML-style angle brackets, but the hook strips it). Without the marker the PreToolUse enforcement hook treats the AUQ as observed-only and never auto-decides — so always include it when the question matches a registered `question_id`.

View File

@ -703,7 +703,7 @@ If you are looping on the same diagnostic, same file, or failed fix variants, ST
## Question Tuning (skip entirely if `QUESTION_TUNING: false`)
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `printf '%s' "<question summary>" | ~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>" --summary-stdin` (piped summary feeds the one-way keyword net, #2024). `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
**Embed the question_id as a marker in the question text** so hooks can identify it deterministically (plan-tune cathedral T14 / D18 progressive markers). Append `<gstack-qid:{question_id}>` somewhere in the rendered question (the leading line or trailing line is fine; the marker doesn't render visibly to the user when wrapped in HTML-style angle brackets, but the hook strips it). Without the marker the PreToolUse enforcement hook treats the AUQ as observed-only and never auto-decides — so always include it when the question matches a registered `question_id`.

View File

@ -47,9 +47,15 @@ function loadGbrainOverride(): { detected: boolean } {
const detectionPath = path.join(stateDir, 'gbrain-detection.json');
try {
const json = JSON.parse(fs.readFileSync(detectionPath, 'utf-8')) as { gbrain_local_status?: string };
// "timeout" = slow-but-healthy engine (#1964) — same treatment as "ok",
// matching gstack-gbrain-detect --is-ok.
return { detected: json.gbrain_local_status === 'ok' || json.gbrain_local_status === 'timeout' };
// "timeout" = slow-but-healthy engine (#1964); "thin-client" = remote-HTTP
// MCP brain with no local engine by design (#2051). Both usable — same
// treatment as "ok", matching gstack-gbrain-detect --is-ok.
return {
detected:
json.gbrain_local_status === 'ok' ||
json.gbrain_local_status === 'timeout' ||
json.gbrain_local_status === 'thin-client',
};
} catch {
return { detected: false };
}

View File

@ -63,12 +63,14 @@ const DESTRUCTIVE_PATTERNS: RegExp[] = [
/\brollback\b/i,
// Credentials / auth — allow filler words ("the", "my") between verb and noun.
// Keep the noun alternation identical across revoke/reset/rotate so the three
// verbs stay parallel; a noun in one but not the others is a false-negative
// safety hole (e.g. "reset my secret" must be one-way just like "rotate my secret").
/\brevoke\s+[\w\s]*\b(api key|token|secret|credential|access key|password)\b/i,
/\breset\s+[\w\s]*\b(api key|token|secret|credential|access key|password)\b/i,
/\brotate\s+[\w\s]*\b(api key|token|secret|credential|access key|password)\b/i,
// Keep the noun alternation IDENTICAL across revoke/reset/rotate — a noun in
// one but not the others is a false-negative safety hole (#2024: "reset my
// secret" / "reset my access key" / "revoke my secret" leaked as two-way).
// Optional plural `s?` on the noun: \b(...)\b alone cannot match
// "credentials" / "tokens" / "passwords".
/\brevoke\s+[\w\s]*\b(api key|token|secret|credential|access key|password)s?\b/i,
/\breset\s+[\w\s]*\b(api key|token|secret|credential|access key|password)s?\b/i,
/\brotate\s+[\w\s]*\b(api key|token|secret|credential|access key|password)s?\b/i,
// Scope / architecture forks (reversible with effort — still deserve confirmation)
/\barchitectur(e|al)\s+(change|fork|shift|decision)\b/i,

View File

@ -40,7 +40,7 @@
},
"context-restore": {
"lead": "Restore working context saved earlier by /context-save.",
"routing": "Loads the most recent\nsaved state (across all branches by default) so you can pick up where you\nleft off — even across Conductor workspace handoffs.\nUse when asked to \"resume\", \"restore context\", \"where was I\", or\n\"pick up where I left off\". Pair with /context-save.\nFormerly /checkpoint resume — renamed because Claude Code treats /checkpoint\nas a native rewind alias in current environments.",
"routing": "Loads the most recent\nsaved state (preferring the current branch, falling back across branches) so\nyou can pick up where you left off — even across Conductor workspace handoffs.\nUse when asked to \"resume\", \"restore context\", \"where was I\", or\n\"pick up where I left off\". Pair with /context-save.\nFormerly /checkpoint resume — renamed because Claude Code treats /checkpoint\nas a native rewind alias in current environments.",
"voice_line": null
},
"context-save": {

View File

@ -23,7 +23,7 @@ export function generateQuestionTuning(ctx: TemplateContext): string {
const bin = binDir(ctx);
return `## Question Tuning (skip entirely if \`QUESTION_TUNING: false\`)
Before each AskUserQuestion, choose \`question_id\` from \`scripts/question-registry.ts\` or \`{skill}-{slug}\`, then run \`${bin}/gstack-question-preference --check "<id>"\`. \`AUTO_DECIDE\` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." \`ASK_NORMALLY\` means ask.
Before each AskUserQuestion, choose \`question_id\` from \`scripts/question-registry.ts\` or \`{skill}-{slug}\`, then run \`printf '%s' "<question summary>" | ${bin}/gstack-question-preference --check "<id>" --summary-stdin\` (piped summary feeds the one-way keyword net, #2024). \`AUTO_DECIDE\` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." \`ASK_NORMALLY\` means ask.
**Embed the question_id as a marker in the question text** so hooks can identify it deterministically (plan-tune cathedral T14 / D18 progressive markers). Append \`<gstack-qid:{question_id}>\` somewhere in the rendered question (the leading line or trailing line is fine; the marker doesn't render visibly to the user when wrapped in HTML-style angle brackets, but the hook strips it). Without the marker the PreToolUse enforcement hook treats the AUQ as observed-only and never auto-decides — so always include it when the question matches a registered \`question_id\`.
@ -51,7 +51,7 @@ export function generateQuestionPreferenceCheck(ctx: TemplateContext): string {
const bin = binDir(ctx);
return `## Question Preference Check (skip if \`QUESTION_TUNING: false\`)
Before each AskUserQuestion, run: \`${bin}/gstack-question-preference --check "<id>"\`.
Before each AskUserQuestion, run: \`printf '%s' "<question summary>" | ${bin}/gstack-question-preference --check "<id>" --summary-stdin\`.
\`AUTO_DECIDE\` → auto-choose recommended with inline annotation. \`ASK_NORMALLY\` → ask.`;
}

View File

@ -706,7 +706,7 @@ If you are looping on the same diagnostic, same file, or failed fix variants, ST
## Question Tuning (skip entirely if `QUESTION_TUNING: false`)
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `printf '%s' "<question summary>" | ~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>" --summary-stdin` (piped summary feeds the one-way keyword net, #2024). `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
**Embed the question_id as a marker in the question text** so hooks can identify it deterministically (plan-tune cathedral T14 / D18 progressive markers). Append `<gstack-qid:{question_id}>` somewhere in the rendered question (the leading line or trailing line is fine; the marker doesn't render visibly to the user when wrapped in HTML-style angle brackets, but the hook strips it). Without the marker the PreToolUse enforcement hook treats the AUQ as observed-only and never auto-decides — so always include it when the question matches a registered `question_id`.

View File

@ -705,7 +705,7 @@ If you are looping on the same diagnostic, same file, or failed fix variants, ST
## Question Tuning (skip entirely if `QUESTION_TUNING: false`)
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `printf '%s' "<question summary>" | ~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>" --summary-stdin` (piped summary feeds the one-way keyword net, #2024). `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
**Embed the question_id as a marker in the question text** so hooks can identify it deterministically (plan-tune cathedral T14 / D18 progressive markers). Append `<gstack-qid:{question_id}>` somewhere in the rendered question (the leading line or trailing line is fine; the marker doesn't render visibly to the user when wrapped in HTML-style angle brackets, but the hook strips it). Without the marker the PreToolUse enforcement hook treats the AUQ as observed-only and never auto-decides — so always include it when the question matches a registered `question_id`.
@ -820,9 +820,13 @@ Capture the JSON output. It contains: `gbrain_on_path`, `gbrain_version`,
`gbrain_config_exists`, `gbrain_engine`, `gbrain_doctor_ok`, `gbrain_mcp_mode`,
`gstack_brain_sync_mode`, `gstack_brain_git`, `gstack_artifacts_remote`, and
the v1.34.0.0+ `gbrain_local_status` field (one of: `ok`, `no-cli`,
`missing-config`, `broken-config`, `broken-db`, `engine-locked`, `timeout`). Treat `timeout`
like `ok` (slow-but-healthy engine, #1964) — it never triggers Step 1.5
remediation.
`missing-config`, `broken-config`, `broken-db`, `engine-locked`, `timeout`,
`thin-client`). Treat `timeout` like `ok` (slow-but-healthy engine, #1964) — it
never triggers Step 1.5 remediation. Treat `thin-client` like `ok` too (#2051):
the machine is a thin client of a remote-HTTP MCP brain, no local engine by
design — brain-aware blocks render, and the detect JSON carries
`gbrain_thin_client: {probed: false}` (config verified; remote reachability
is checked at use time, where gbrain calls degrade gracefully).
Skip downstream steps that are already done. Report the detected state in
one line so the user knows what you found:

View File

@ -66,9 +66,13 @@ Capture the JSON output. It contains: `gbrain_on_path`, `gbrain_version`,
`gbrain_config_exists`, `gbrain_engine`, `gbrain_doctor_ok`, `gbrain_mcp_mode`,
`gstack_brain_sync_mode`, `gstack_brain_git`, `gstack_artifacts_remote`, and
the v1.34.0.0+ `gbrain_local_status` field (one of: `ok`, `no-cli`,
`missing-config`, `broken-config`, `broken-db`, `engine-locked`, `timeout`). Treat `timeout`
like `ok` (slow-but-healthy engine, #1964) — it never triggers Step 1.5
remediation.
`missing-config`, `broken-config`, `broken-db`, `engine-locked`, `timeout`,
`thin-client`). Treat `timeout` like `ok` (slow-but-healthy engine, #1964) — it
never triggers Step 1.5 remediation. Treat `thin-client` like `ok` too (#2051):
the machine is a thin client of a remote-HTTP MCP brain, no local engine by
design — brain-aware blocks render, and the detect JSON carries
`gbrain_thin_client: {probed: false}` (config verified; remote reachability
is checked at use time, where gbrain calls degrade gracefully).
Skip downstream steps that are already done. Report the detected state in
one line so the user knows what you found:

View File

@ -707,7 +707,7 @@ If you are looping on the same diagnostic, same file, or failed fix variants, ST
## Question Tuning (skip entirely if `QUESTION_TUNING: false`)
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `printf '%s' "<question summary>" | ~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>" --summary-stdin` (piped summary feeds the one-way keyword net, #2024). `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
**Embed the question_id as a marker in the question text** so hooks can identify it deterministically (plan-tune cathedral T14 / D18 progressive markers). Append `<gstack-qid:{question_id}>` somewhere in the rendered question (the leading line or trailing line is fine; the marker doesn't render visibly to the user when wrapped in HTML-style angle brackets, but the hook strips it). Without the marker the PreToolUse enforcement hook treats the AUQ as observed-only and never auto-decides — so always include it when the question matches a registered `question_id`.

View File

@ -703,7 +703,7 @@ If you are looping on the same diagnostic, same file, or failed fix variants, ST
## Question Tuning (skip entirely if `QUESTION_TUNING: false`)
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `printf '%s' "<question summary>" | ~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>" --summary-stdin` (piped summary feeds the one-way keyword net, #2024). `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
**Embed the question_id as a marker in the question text** so hooks can identify it deterministically (plan-tune cathedral T14 / D18 progressive markers). Append `<gstack-qid:{question_id}>` somewhere in the rendered question (the leading line or trailing line is fine; the marker doesn't render visibly to the user when wrapped in HTML-style angle brackets, but the hook strips it). Without the marker the PreToolUse enforcement hook treats the AUQ as observed-only and never auto-decides — so always include it when the question matches a registered `question_id`.

View File

@ -704,7 +704,7 @@ If you are looping on the same diagnostic, same file, or failed fix variants, ST
## Question Tuning (skip entirely if `QUESTION_TUNING: false`)
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `printf '%s' "<question summary>" | ~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>" --summary-stdin` (piped summary feeds the one-way keyword net, #2024). `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
**Embed the question_id as a marker in the question text** so hooks can identify it deterministically (plan-tune cathedral T14 / D18 progressive markers). Append `<gstack-qid:{question_id}>` somewhere in the rendered question (the leading line or trailing line is fine; the marker doesn't render visibly to the user when wrapped in HTML-style angle brackets, but the hook strips it). Without the marker the PreToolUse enforcement hook treats the AUQ as observed-only and never auto-decides — so always include it when the question matches a registered `question_id`.
@ -1774,7 +1774,7 @@ If you are looping on the same diagnostic, same file, or failed fix variants, ST
## Question Tuning (skip entirely if `QUESTION_TUNING: false`)
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `printf '%s' "<question summary>" | ~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>" --summary-stdin` (piped summary feeds the one-way keyword net, #2024). `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
**Embed the question_id as a marker in the question text** so hooks can identify it deterministically (plan-tune cathedral T14 / D18 progressive markers). Append `<gstack-qid:{question_id}>` somewhere in the rendered question (the leading line or trailing line is fine; the marker doesn't render visibly to the user when wrapped in HTML-style angle brackets, but the hook strips it). Without the marker the PreToolUse enforcement hook treats the AUQ as observed-only and never auto-decides — so always include it when the question matches a registered `question_id`.

View File

@ -705,7 +705,7 @@ If you are looping on the same diagnostic, same file, or failed fix variants, ST
## Question Tuning (skip entirely if `QUESTION_TUNING: false`)
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `printf '%s' "<question summary>" | ~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>" --summary-stdin` (piped summary feeds the one-way keyword net, #2024). `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
**Embed the question_id as a marker in the question text** so hooks can identify it deterministically (plan-tune cathedral T14 / D18 progressive markers). Append `<gstack-qid:{question_id}>` somewhere in the rendered question (the leading line or trailing line is fine; the marker doesn't render visibly to the user when wrapped in HTML-style angle brackets, but the hook strips it). Without the marker the PreToolUse enforcement hook treats the AUQ as observed-only and never auto-decides — so always include it when the question matches a registered `question_id`.
@ -893,6 +893,14 @@ BEFORE invoking the orchestrator:
slow (cold pooler connection, #1964). Tell the user in one line: "Engine
probe timed out (>15s) — proceeding; raise `GSTACK_GBRAIN_PROBE_TIMEOUT_MS`
if your pooler is slow." Do NOT treat this as a broken config.
- **`thin-client`**: proceed to Step 2 — this machine is a thin client of a
remote-HTTP MCP brain (#2051): no local engine BY DESIGN, so the code,
memory, and dream stages will SKIP with a thin-client reason (code indexing
runs on the brain server; memory syncs via the remote brain's artifacts
pull). Only the brain-sync push runs locally. Tell the user in one line:
"Thin client of a remote brain — local stages skip by design; brain queries
work via remote MCP (reachability is verified at use time, not probed
here)." Do NOT route this into the broken-config remediation.
- **`engine-locked`**: STOP. "The local PGLite database is busy, usually
because `gbrain serve` from a live Claude session owns it. Stop that process
or run `/sync-gbrain` outside the live session, then retry. This identifies

View File

@ -139,6 +139,14 @@ BEFORE invoking the orchestrator:
slow (cold pooler connection, #1964). Tell the user in one line: "Engine
probe timed out (>15s) — proceeding; raise `GSTACK_GBRAIN_PROBE_TIMEOUT_MS`
if your pooler is slow." Do NOT treat this as a broken config.
- **`thin-client`**: proceed to Step 2 — this machine is a thin client of a
remote-HTTP MCP brain (#2051): no local engine BY DESIGN, so the code,
memory, and dream stages will SKIP with a thin-client reason (code indexing
runs on the brain server; memory syncs via the remote brain's artifacts
pull). Only the brain-sync push runs locally. Tell the user in one line:
"Thin client of a remote brain — local stages skip by design; brain queries
work via remote MCP (reachability is verified at use time, not probed
here)." Do NOT route this into the broken-config remediation.
- **`engine-locked`**: STOP. "The local PGLite database is busy, usually
because `gbrain serve` from a live Claude session owns it. Stop that process
or run `/sync-gbrain` outside the live session, then retry. This identifies

View File

@ -36,15 +36,33 @@ echo "TITLE_SLUG=$TITLE_SLUG"
echo "FILE=$FILE"
`;
// The exact find + sort + head used by context-restore/SKILL.md Step 1.
// The exact selection used by context-restore/SKILL.md Step 1: scan newest 200,
// order current-branch checkpoints first (fallback: all branches), cap at 20.
// CURRENT_BRANCH is injected via env in tests; the skill resolves it from git.
const RESTORE_FIND_BASH = `
if [ ! -d "$CHECKPOINT_DIR" ]; then
echo "NO_CHECKPOINTS"
else
FILES=$(find "$CHECKPOINT_DIR" -maxdepth 1 -name "*.md" -type f 2>/dev/null | sort -r | head -20)
if [ -z "$FILES" ]; then
ALL=$(find "$CHECKPOINT_DIR" -maxdepth 1 -name "*.md" -type f 2>/dev/null | sort -r | head -200)
if [ -z "$ALL" ]; then
echo "NO_CHECKPOINTS"
else
: "\${CURRENT_BRANCH:=$(git rev-parse --abbrev-ref HEAD 2>/dev/null)}"
SAME=""; OTHER=""
while IFS= read -r f; do
[ -n "$f" ] || continue
b=$(grep -m1 '^branch:' "$f" 2>/dev/null | sed 's/^branch:[[:space:]]*//')
if [ -n "$CURRENT_BRANCH" ] && [ "$b" = "$CURRENT_BRANCH" ]; then
SAME="\${SAME}\${f}
"
else
OTHER="\${OTHER}\${f}
"
fi
done <<EOF
$ALL
EOF
FILES=$(printf '%s%s' "$SAME" "$OTHER" | grep -v '^[[:space:]]*$' | head -20)
echo "$FILES"
fi
fi
@ -312,6 +330,73 @@ describe('context-restore: find + sort + head cap', () => {
});
});
// ─── Current-branch preference (#2052) ──────────────────────────────────────
//
// All worktrees of a repo share one origin-derived slug → one checkpoints dir.
// Restore must prefer the CURRENT branch's own checkpoint so a sibling
// worktree's newer save can't shadow it, while still falling back across
// branches (Conductor handoff) when the current branch has none.
describe('context-restore: current-branch preference (#2052)', () => {
let tmp: string;
beforeEach(() => { tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ctx-branch-')); });
afterEach(() => { try { fs.rmSync(tmp, { recursive: true, force: true }); } catch {} });
function writeCheckpoint(ts: string, branch: string | null): string {
const file = `${tmp}/${ts}-work.md`;
const fm = branch === null
? `---\nstatus: in-progress\n---\n`
: `---\nstatus: in-progress\nbranch: ${branch}\n---\n`;
fs.writeFileSync(file, fm);
return file;
}
function firstCandidate(currentBranch?: string): string {
const env: Record<string, string> = { CHECKPOINT_DIR: tmp };
if (currentBranch !== undefined) env.CURRENT_BRANCH = currentBranch;
const out = runBash(RESTORE_FIND_BASH, env).stdout;
return out.trim().split('\n').filter(Boolean)[0] ?? '';
}
test('the bug: current-branch save is NOT shadowed by a newer sibling-worktree save', () => {
const mine = writeCheckpoint('20260101-120000', 'feature-a'); // older, my branch
writeCheckpoint('20260619-120000', 'feature-b'); // newer, sibling worktree
// On feature-a, restore must load feature-a's own (older) checkpoint.
expect(firstCandidate('feature-a')).toBe(mine);
});
test('fallback: current branch has no checkpoint → newest across all branches (Conductor handoff)', () => {
writeCheckpoint('20260101-120000', 'feature-a');
const newest = writeCheckpoint('20260619-120000', 'feature-b');
// On feature-c (no own checkpoint), cross-branch resume still works.
expect(firstCandidate('feature-c')).toBe(newest);
});
test('back-compat: empty current branch (non-git) → newest across all', () => {
writeCheckpoint('20260101-120000', 'feature-a');
const newest = writeCheckpoint('20260619-120000', 'feature-b');
expect(firstCandidate('')).toBe(newest);
});
test('checkpoints without a branch frontmatter still rank as fallback, never lost', () => {
const mine = writeCheckpoint('20260101-120000', 'feature-a');
writeCheckpoint('20260301-120000', null); // legacy save, no branch field
const out = runBash(RESTORE_FIND_BASH, { CHECKPOINT_DIR: tmp, CURRENT_BRANCH: 'feature-a' }).stdout;
const lines = out.trim().split('\n').filter(Boolean);
expect(lines[0]).toBe(mine); // current branch first
expect(lines.length).toBe(2); // legacy file is still present
});
test('within the current branch, ordering stays newest-first', () => {
const older = writeCheckpoint('20260101-120000', 'feature-a');
const newer = writeCheckpoint('20260619-120000', 'feature-a');
const out = runBash(RESTORE_FIND_BASH, { CHECKPOINT_DIR: tmp, CURRENT_BRANCH: 'feature-a' }).stdout;
const lines = out.trim().split('\n').filter(Boolean);
expect(lines[0]).toBe(newer);
expect(lines[1]).toBe(older);
});
});
// ─── Migration HOME guard ──────────────────────────────────────────────────
describe('migration v1.1.3.0: HOME guard', () => {

View File

@ -0,0 +1,113 @@
/**
* design/src/flag-utils.ts integer-flag contract (#2032, eng-review 7A/8A).
*
* Lives under test/ (NOT design/test/) deliberately: design/test/ is invisible
* to the bun test glob, scripts/test-free-shards TEST_ROOTS, and every CI
* workflow (eng-review 11A), so a tripwire there guards nothing. flag-utils is
* a pure module, so importing it from here is clean.
*
* The bug class: the design CLI parser yields string | true | undefined;
* parseInt produced NaN that flowed silently into loop bounds and setTimeout
* `variants --count abc` generated ZERO variants and exited 0, `generate
* --retry abc` was a silent no-op, `serve --timeout abc` died at boot.
*/
import { describe, test, expect } from "bun:test";
import { spawnSync } from "child_process";
import * as path from "path";
import { parseIntFlag } from "../design/src/flag-utils";
const ROOT = path.resolve(import.meta.dir, "..");
const COUNT_SPEC = { name: "count", def: 3, min: 1, max: 7 } as const;
const RETRY_SPEC = { name: "retry", def: 0, min: 0 } as const;
const TIMEOUT_SPEC = { name: "timeout", def: 600, min: 1 } as const;
describe("parseIntFlag contract (#2032, codex 17a-c)", () => {
test("undefined → default (flag absent)", () => {
expect(parseIntFlag(undefined, COUNT_SPEC)).toEqual({ ok: true, value: 3 });
expect(parseIntFlag(undefined, RETRY_SPEC)).toEqual({ ok: true, value: 0 });
});
test("non-numeric string → error, never a silent default ('--count abc')", () => {
const r = parseIntFlag("abc", COUNT_SPEC);
expect(r.ok).toBe(false);
if (!r.ok) expect(r.error).toContain('"abc" is not an integer');
});
test("bare flag (parser yields true) → error 'requires a value'", () => {
const r = parseIntFlag(true, COUNT_SPEC);
expect(r.ok).toBe(false);
if (!r.ok) expect(r.error).toContain("requires a value");
});
test("empty string → error 'requires a value'", () => {
const r = parseIntFlag("", COUNT_SPEC);
expect(r.ok).toBe(false);
if (!r.ok) expect(r.error).toContain("requires a value");
});
test("non-integer '3.7' → error (rejected, not silently truncated to 3)", () => {
const r = parseIntFlag("3.7", COUNT_SPEC);
expect(r.ok).toBe(false);
if (!r.ok) expect(r.error).toContain("not an integer");
});
test("below min → error ('--count 0' billed the user for 1 they asked 0 of; now loud)", () => {
expect(parseIntFlag("0", COUNT_SPEC).ok).toBe(false);
expect(parseIntFlag("-2", COUNT_SPEC).ok).toBe(false);
// retry allows 0 (min: 0)
expect(parseIntFlag("0", RETRY_SPEC)).toEqual({ ok: true, value: 0 });
});
test("above max → clamp WITH warning (capability limit, not a user mistake)", () => {
const r = parseIntFlag("99", COUNT_SPEC);
expect(r.ok).toBe(true);
if (r.ok) {
expect(r.value).toBe(7);
expect(r.warning).toContain("exceeds the maximum");
}
});
test("in-range integers pass through untouched", () => {
expect(parseIntFlag("3", COUNT_SPEC)).toEqual({ ok: true, value: 3 });
expect(parseIntFlag(5, COUNT_SPEC)).toEqual({ ok: true, value: 5 });
expect(parseIntFlag("120", TIMEOUT_SPEC)).toEqual({ ok: true, value: 120 });
});
test("retry-NaN and timeout-NaN are errors, not silent no-ops (#2032 siblings)", () => {
// Pre-fix: --retry abc → generate() loop never ran (attempt <= NaN),
// printed null, exited 0. --timeout abc → setTimeout(NaN) ≈ immediate
// SERVE_TIMEOUT. Both members of the same NaN class, same file.
expect(parseIntFlag("abc", RETRY_SPEC).ok).toBe(false);
expect(parseIntFlag("abc", TIMEOUT_SPEC).ok).toBe(false);
});
test("NaN number input (legacy pre-parsed callers) → error", () => {
expect(parseIntFlag(Number.NaN, COUNT_SPEC).ok).toBe(false);
});
});
describe("normalizeIntFlag CLI wrapper (exit-1 semantics)", () => {
function runWrapper(rawExpr: string, specExpr: string): { status: number; stderr: string } {
const script = `
import { normalizeIntFlag } from "${ROOT}/design/src/flag-utils";
const v = normalizeIntFlag(${rawExpr}, ${specExpr});
console.log("VALUE:" + v);
`;
const res = spawnSync("bun", ["-e", script], { encoding: "utf-8", cwd: ROOT });
return { status: res.status ?? -1, stderr: res.stderr ?? "" };
}
test("invalid input exits 1 with the error on stderr", () => {
const r = runWrapper('"abc"', '{ name: "count", def: 3, min: 1, max: 7 }');
expect(r.status).toBe(1);
expect(r.stderr).toContain("not an integer");
});
test("clamp warns on stderr but exits 0", () => {
const r = runWrapper('"99"', '{ name: "count", def: 3, min: 1, max: 7 }');
expect(r.status).toBe(0);
expect(r.stderr).toContain("exceeds the maximum");
});
});

View File

@ -707,7 +707,7 @@ If you are looping on the same diagnostic, same file, or failed fix variants, ST
## Question Tuning (skip entirely if `QUESTION_TUNING: false`)
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `printf '%s' "<question summary>" | ~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>" --summary-stdin` (piped summary feeds the one-way keyword net, #2024). `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
**Embed the question_id as a marker in the question text** so hooks can identify it deterministically (plan-tune cathedral T14 / D18 progressive markers). Append `<gstack-qid:{question_id}>` somewhere in the rendered question (the leading line or trailing line is fine; the marker doesn't render visibly to the user when wrapped in HTML-style angle brackets, but the hook strips it). Without the marker the PreToolUse enforcement hook treats the AUQ as observed-only and never auto-decides — so always include it when the question matches a registered `question_id`.

View File

@ -693,7 +693,7 @@ If you are looping on the same diagnostic, same file, or failed fix variants, ST
## Question Tuning (skip entirely if `QUESTION_TUNING: false`)
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `$GSTACK_BIN/gstack-question-preference --check "<id>"`. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `printf '%s' "<question summary>" | $GSTACK_BIN/gstack-question-preference --check "<id>" --summary-stdin` (piped summary feeds the one-way keyword net, #2024). `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
**Embed the question_id as a marker in the question text** so hooks can identify it deterministically (plan-tune cathedral T14 / D18 progressive markers). Append `<gstack-qid:{question_id}>` somewhere in the rendered question (the leading line or trailing line is fine; the marker doesn't render visibly to the user when wrapped in HTML-style angle brackets, but the hook strips it). Without the marker the PreToolUse enforcement hook treats the AUQ as observed-only and never auto-decides — so always include it when the question matches a registered `question_id`.

View File

@ -695,7 +695,7 @@ If you are looping on the same diagnostic, same file, or failed fix variants, ST
## Question Tuning (skip entirely if `QUESTION_TUNING: false`)
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `$GSTACK_BIN/gstack-question-preference --check "<id>"`. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `printf '%s' "<question summary>" | $GSTACK_BIN/gstack-question-preference --check "<id>" --summary-stdin` (piped summary feeds the one-way keyword net, #2024). `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
**Embed the question_id as a marker in the question text** so hooks can identify it deterministically (plan-tune cathedral T14 / D18 progressive markers). Append `<gstack-qid:{question_id}>` somewhere in the rendered question (the leading line or trailing line is fine; the marker doesn't render visibly to the user when wrapped in HTML-style angle brackets, but the hook strips it). Without the marker the PreToolUse enforcement hook treats the AUQ as observed-only and never auto-decides — so always include it when the question matches a registered `question_id`.

View File

@ -32,7 +32,7 @@ import {
utimesSync,
} from "fs";
import { tmpdir } from "os";
import { join } from "path";
import { join, dirname } from "path";
import { spawnSync } from "child_process";
@ -62,8 +62,10 @@ interface FakeEnv {
*/
function makeEnv(opts: {
withGbrain?: boolean;
gbrainBehavior?: "ok" | "broken-db" | "broken-config" | "engine-locked" | "throws" | "slow";
gbrainBehavior?: "ok" | "broken-db" | "broken-config" | "engine-locked" | "throws" | "slow" | "thin-refusal";
withConfig?: boolean;
/** #2051: config carries gbrain's remote_mcp thin-client marker. */
thinClientConfig?: boolean;
}): FakeEnv {
const tmp = mkdtempSync(join(tmpdir(), "gbrain-local-status-test-"));
const bindir = join(tmp, "bin");
@ -77,7 +79,12 @@ function makeEnv(opts: {
mkdirSync(gstackHome, { recursive: true });
mkdirSync(configDir, { recursive: true });
if (opts.withConfig) {
if (opts.thinClientConfig) {
writeFileSync(
configPath,
JSON.stringify({ remote_mcp: { mcp_url: "https://brain.example.com/mcp" } }),
);
} else if (opts.withConfig) {
writeFileSync(
configPath,
JSON.stringify({ engine: "pglite", database_url: "pglite:///fake" }),
@ -103,7 +110,7 @@ function makeEnv(opts: {
}
function makeFakeGbrainScript(
behavior: "ok" | "broken-db" | "broken-config" | "engine-locked" | "throws" | "slow",
behavior: "ok" | "broken-db" | "broken-config" | "engine-locked" | "throws" | "slow" | "thin-refusal",
): string {
// "slow": healthy engine on a cold pooler connection (#1964) — sleeps past
// the (test-lowered) probe timeout, then would answer fine.
@ -130,7 +137,9 @@ exit 0
? 'echo "gbrain sources: connect timed out (default 10000ms; pass --timeout=Ns to override)." >&2'
: behavior === "throws"
? 'echo "unexpected gbrain failure" >&2'
: "";
: behavior === "thin-refusal"
? 'echo "Error: gbrain sources is not routable to the remote brain (thin-client of https://brain.example.com/mcp)" >&2'
: "";
const exitCode = behavior === "ok" ? 0 : behavior === "engine-locked" ? 124 : 1;
return `#!/bin/sh
if [ "$1" = "--version" ]; then
@ -448,3 +457,72 @@ describe("lib/gbrain-local-status — cache behavior", () => {
}
});
});
// ---------------------------------------------------------------------------
// #2051: thin-client classification + the end-to-end --is-ok gate
// ---------------------------------------------------------------------------
describe("lib/gbrain-local-status — thin-client (#2051)", () => {
let env: FakeEnv | null = null;
let restoreEnv: (() => void) | null = null;
afterEach(() => {
if (restoreEnv) restoreEnv();
if (env) env.cleanup();
env = null;
restoreEnv = null;
});
it("returns 'thin-client' when config carries gbrain's remote_mcp marker (pre-probe, no engine call)", () => {
// The fake gbrain would answer "ok" if probed — proving the marker is
// read from config BEFORE any probe (zero network, no error-string
// dependence).
env = makeEnv({ withGbrain: true, gbrainBehavior: "ok", thinClientConfig: true });
restoreEnv = applyEnv(env);
expect(localEngineStatus({ noCache: true })).toBe("thin-client");
});
it("returns 'thin-client' via the stderr refusal fallback when the config marker is unreadable", () => {
// Regular (non-thin) config on disk, but gbrain itself refuses with the
// dispatch-guard message — the catch-path backstop.
env = makeEnv({ withGbrain: true, gbrainBehavior: "thin-refusal", withConfig: true });
restoreEnv = applyEnv(env);
expect(localEngineStatus({ noCache: true })).toBe("thin-client");
});
// The eng-review 3A tripwire: the END-TO-END gate, not just the classifier
// return. --is-ok drives setup:1299 and gstack-config gbrain-refresh — this
// exit code is what decides whether brain-aware blocks render for a
// thin-client user (the #2051 report).
it("--is-ok exits 0 on a thin-client fixture (end-to-end gate)", () => {
env = makeEnv({ withGbrain: true, gbrainBehavior: "ok", thinClientConfig: true });
const detectBin = join(import.meta.dir, "..", "bin", "gstack-gbrain-detect");
const bunDir = dirname(process.execPath);
const r = spawnSync(detectBin, ["--is-ok"], {
encoding: "utf-8",
env: {
HOME: env.home,
PATH: `${env.bindir}:${bunDir}:/usr/bin:/bin`,
GSTACK_HOME: env.gstackHome,
GSTACK_DETECT_NO_CACHE: "1",
},
});
expect(r.status).toBe(0);
});
it("--is-ok still exits 1 on broken-config (thin-client did not widen the gate)", () => {
env = makeEnv({ withGbrain: true, gbrainBehavior: "broken-config", withConfig: true });
const detectBin = join(import.meta.dir, "..", "bin", "gstack-gbrain-detect");
const bunDir = dirname(process.execPath);
const r = spawnSync(detectBin, ["--is-ok"], {
encoding: "utf-8",
env: {
HOME: env.home,
PATH: `${env.bindir}:${bunDir}:/usr/bin:/bin`,
GSTACK_HOME: env.gstackHome,
GSTACK_DETECT_NO_CACHE: "1",
},
});
expect(r.status).toBe(1);
});
});

View File

@ -8,7 +8,7 @@
*/
import { describe, it, expect } from "bun:test";
import { mkdtempSync, writeFileSync, readFileSync, existsSync, mkdirSync, rmSync, chmodSync } from "fs";
import { mkdtempSync, writeFileSync, readFileSync, existsSync, mkdirSync, rmSync, chmodSync, symlinkSync } from "fs";
import { tmpdir } from "os";
import { join } from "path";
@ -32,8 +32,11 @@ interface FakeGbrainSetup {
* Build a temp dir with a fake `gbrain` shell script on PATH. The fake honors:
* gbrain sources list --json cat $STATE_PATH
* gbrain sources add <id> --path <p> [--federated] append to state, log
* gbrain sources remove <id> --yes drop from state, log
* gbrain --version echo "gbrain 0.25.1"
* gbrain sources remove <id> --confirm-destructive drop from state, log
* (#1985: remove WITHOUT
* --confirm-destructive
* fails like gbrain >= 0.42)
* gbrain --version echo "gbrain 0.42.40.0"
* Anything else exits 1.
*/
function makeFakeGbrain(initialState: { sources: Array<{ id: string; local_path: string; federated?: boolean; page_count?: number }> }): FakeGbrainSetup {
@ -49,7 +52,7 @@ function makeFakeGbrain(initialState: { sources: Array<{ id: string; local_path:
echo "$@" >> "${logPath}"
case "$1 $2" in
"--version ")
echo "gbrain 0.25.1"
echo "gbrain 0.42.40.0"
exit 0
;;
"sources list")
@ -75,6 +78,16 @@ case "$1 $2" in
;;
"sources remove")
ID="$3"
# #1985: gbrain >= 0.42 gates remove behind --confirm-destructive; --yes
# alone no longer suppresses the data-loss prompt. Refuse without it so the
# drift re-register path is exercised against real gbrain 0.42 behavior.
case " $* " in
*" --confirm-destructive "*) : ;;
*)
echo "This will permanently delete pages. To proceed, pass --confirm-destructive" >&2
exit 1
;;
esac
NEW=$(jq --arg id "$ID" '.sources = (.sources | map(select(.id != $id)))' "${statePath}")
echo "$NEW" > "${statePath}"
exit 0
@ -155,6 +168,13 @@ describe("ensureSourceRegistered", () => {
fake.cleanup();
});
// Drift-path tests pin the #1734 guard inputs (inactive autopilot, allowed
// remove) so a REAL autopilot running on the dev machine can't flip them.
const guardsPinnedInactive = {
autopilotProbe: { lockPaths: [], processRunning: () => false },
removeDecision: { keepStorage: false },
} as const;
it("recreates source when path differs (gbrain has no `sources update`), returns changed=true", async () => {
const fake = makeFakeGbrain({
sources: [{ id: "gstack-code-foo", local_path: "/old/path" }],
@ -162,17 +182,138 @@ describe("ensureSourceRegistered", () => {
const result = await ensureSourceRegistered("gstack-code-foo", "/new/path", {
federated: true,
env: fake.env,
...guardsPinnedInactive,
});
expect(result.changed).toBe(true);
expect(result.state.status).toBe("match");
expect(result.state.registered_path).toBe("/new/path");
const log = readFileSync(fake.logPath, "utf-8");
expect(log).toContain("sources remove gstack-code-foo --yes");
// #1985: the remove must carry --confirm-destructive (gbrain >= 0.42 gate).
expect(log).toContain("sources remove gstack-code-foo --yes --confirm-destructive");
expect(log).toContain("sources add gstack-code-foo --path /new/path --federated");
fake.cleanup();
});
// #1985: regression. gbrain >= 0.42 refuses `sources remove` without
// --confirm-destructive. On the drift path that surfaces as "source
// registration failed" and aborts the /sync-gbrain code stage for every
// already-registered source whose path drifted. Before the fix the remove
// was issued with `--yes` only, so the guard-simulating fake rejects it and
// ensureSourceRegistered throws. The fix passes --confirm-destructive, so
// the re-register succeeds.
it("re-registers across the gbrain >= 0.42 destructive-remove guard (does not throw)", async () => {
const fake = makeFakeGbrain({
sources: [{ id: "gstack-code-foo", local_path: "/old/path" }],
});
const result = await ensureSourceRegistered("gstack-code-foo", "/new/path", {
federated: true,
env: fake.env,
...guardsPinnedInactive,
});
expect(result.changed).toBe(true);
expect(result.state.status).toBe("match");
expect(result.state.registered_path).toBe("/new/path");
// The old source was actually removed (the guarded remove succeeded), then
// the new path was added — not left behind as a stale duplicate.
const finalState = JSON.parse(readFileSync(fake.statePath, "utf-8"));
expect(finalState.sources).toHaveLength(1);
expect(finalState.sources[0].local_path).toBe("/new/path");
fake.cleanup();
});
// #1734 tripwire: the drift remove deletes pages/chunks/embeddings, so it
// must refuse while a gbrain autopilot is active — and refuse LOUDLY (throw),
// not silently return changed=false. Before the guard routing, this path
// issued the remove unconditionally.
it("REFUSES the drift remove while autopilot is active (throws, no remove issued)", async () => {
const fake = makeFakeGbrain({
sources: [{ id: "gstack-code-foo", local_path: "/old/path" }],
});
await expect(
ensureSourceRegistered("gstack-code-foo", "/new/path", {
env: fake.env,
autopilotProbe: { lockPaths: [], processRunning: () => true },
removeDecision: { keepStorage: false },
}),
).rejects.toThrow(/autopilot active/);
const log = readFileSync(fake.logPath, "utf-8");
expect(log).not.toContain("sources remove");
expect(log).not.toContain("sources add");
fake.cleanup();
});
it("REFUSES the drift remove when decideSourceRemove disallows (fail closed, throws)", async () => {
const fake = makeFakeGbrain({
sources: [{ id: "gstack-code-foo", local_path: "/old/path" }],
});
await expect(
ensureSourceRegistered("gstack-code-foo", "/new/path", {
env: fake.env,
autopilotProbe: { lockPaths: [], processRunning: () => false },
// A sources-list read failure makes decideSourceRemove fail closed.
removeDecision: {
keepStorage: false,
fetchRows: () => {
throw new Error("sources list unavailable");
},
},
}),
).rejects.toThrow(/fail closed/);
const log = readFileSync(fake.logPath, "utf-8");
expect(log).not.toContain("sources remove");
fake.cleanup();
});
it("propagates decideSourceRemove extraArgs (--keep-storage) to the drift remove", async () => {
const fake = makeFakeGbrain({
sources: [{ id: "gstack-code-foo", local_path: "/old/path" }],
});
const result = await ensureSourceRegistered("gstack-code-foo", "/new/path", {
env: fake.env,
autopilotProbe: { lockPaths: [], processRunning: () => false },
removeDecision: { keepStorage: true },
});
expect(result.changed).toBe(true);
const log = readFileSync(fake.logPath, "utf-8");
expect(log).toContain(
"sources remove gstack-code-foo --yes --confirm-destructive --keep-storage",
);
fake.cleanup();
});
// Realpath normalization: a registered path that is a symlink alias of the
// requested path is a MATCH, not drift. Declaring it drift triggers a
// destructive remove + full re-index for a no-op (#1985 reporter hit the
// remove on an unmoved repo; macOS /tmp -> /private/tmp is the usual cause).
it("does NOT declare drift when registered path is a symlink alias of the requested path", async () => {
const base = mkdtempSync(join(tmpdir(), "gbrain-sources-realpath-"));
const realDir = join(base, "real-repo");
const linkDir = join(base, "link-repo");
mkdirSync(realDir, { recursive: true });
symlinkSync(realDir, linkDir);
const fake = makeFakeGbrain({
sources: [{ id: "gstack-code-foo", local_path: realDir }],
});
const result = await ensureSourceRegistered("gstack-code-foo", linkDir, {
env: fake.env,
...guardsPinnedInactive,
});
expect(result.changed).toBe(false);
expect(result.state.status).toBe("match");
const log = readFileSync(fake.logPath, "utf-8");
expect(log).not.toContain("sources remove");
expect(log).not.toContain("sources add");
fake.cleanup();
rmSync(base, { recursive: true, force: true });
});
it("when reregister_on_drift=false and source is at different path, returns changed=false", async () => {
const fake = makeFakeGbrain({
sources: [{ id: "gstack-code-foo", local_path: "/old/path" }],

View File

@ -556,3 +556,143 @@ describe('gstack-developer-profile --log-session (#1671 fix)', () => {
});
});
// -----------------------------------------------------------------------
// SESSION_COUNT / TIER / NUDGE_ELIGIBLE must ignore mode:resources entries.
//
// Phase 6 of /office-hours auto-appends one (or more) mode:resources bookkeeping
// entries every run, to dedupe which founder-resource links the user has seen.
// Those are not sessions. Counting them inflated SESSION_COUNT (and therefore
// TIER) and pushed NUDGE_ELIGIBLE over its threshold from bookkeeping alone —
// e.g. a single real session plus three closings reported as tier `regular`
// with the builder->founder nudge armed.
// -----------------------------------------------------------------------
describe('gstack-developer-profile resources entries do not inflate count/tier/nudge', () => {
function logStartup(extra: Record<string, unknown> = {}) {
return runDev('--log-session', JSON.stringify({
date: '2026-05-20T00:00:00Z', mode: 'startup', project_slug: 'p',
signal_count: 5, signals: ['a', 'b', 'c', 'd', 'e'], ...extra,
}));
}
function logResources(i: number) {
return runDev('--log-session', JSON.stringify({
date: '2026-05-20T01:00:00Z', mode: 'resources', project_slug: 'p',
resources_shown: [`url${i}`],
}));
}
test('SESSION_COUNT counts only real sessions, not resources entries', () => {
logStartup();
logResources(1);
logResources(2);
logResources(3);
const r = runDev('--read');
expect(r.stdout).toContain('SESSION_COUNT: 1');
expect(r.stdout).toContain('TIER: welcome_back');
});
test('TIER is not bumped to regular by resources bookkeeping', () => {
// 3 real sessions = welcome_back; adding resources entries must not reach the
// 4-session `regular` threshold.
logStartup();
logStartup();
logStartup();
for (let i = 0; i < 4; i++) logResources(i);
const r = runDev('--read');
expect(r.stdout).toContain('SESSION_COUNT: 3');
expect(r.stdout).toContain('TIER: welcome_back');
});
test('NUDGE_ELIGIBLE stays false when builder-session bar is unmet despite resources noise', () => {
// One startup session carrying 5 signals, plus resources entries. builderSessions
// (mode === "builder") is 0, so the nudge must not arm regardless of signal count.
logStartup();
logResources(1);
logResources(2);
logResources(3);
const r = runDev('--read');
expect(r.stdout).toContain('NUDGE_ELIGIBLE: false');
});
test('NUDGE_ELIGIBLE arms on 3 real builder sessions with enough signals', () => {
runDev('--log-session', JSON.stringify({
date: '2026-05-20T00:00:00Z', mode: 'builder', project_slug: 'p', signals: ['a', 'b'],
}));
runDev('--log-session', JSON.stringify({
date: '2026-05-21T00:00:00Z', mode: 'builder', project_slug: 'p', signals: ['c', 'd'],
}));
runDev('--log-session', JSON.stringify({
date: '2026-05-22T00:00:00Z', mode: 'builder', project_slug: 'p', signals: ['e'],
}));
logResources(1); // bookkeeping must not change the verdict either way
const r = runDev('--read');
expect(r.stdout).toContain('NUDGE_ELIGIBLE: true');
});
// Boundary cases around the two `>=` gates, so a future >= → > regression
// (or a re-loosening of the builder filter) is caught, not just the happy path.
function logBuilder(signals: string[], day = 20) {
return runDev('--log-session', JSON.stringify({
date: `2026-05-${day}T00:00:00Z`, mode: 'builder', project_slug: 'p', signals,
}));
}
test('NUDGE_ELIGIBLE stays false at 2 builder sessions (below the 3-session gate)', () => {
logBuilder(['a', 'b', 'c'], 20);
logBuilder(['d', 'e', 'f'], 21); // 6 signals total — signal gate met, session gate is not
logResources(1);
const r = runDev('--read');
expect(r.stdout).toContain('NUDGE_ELIGIBLE: false');
});
test('NUDGE_ELIGIBLE stays false at 3 builder sessions with too few signals', () => {
logBuilder(['a'], 20);
logBuilder(['b'], 21);
logBuilder(['c', 'd'], 22); // 4 signals total — session gate met, signal gate (>=5) is not
const r = runDev('--read');
expect(r.stdout).toContain('NUDGE_ELIGIBLE: false');
});
test('TIER reaches regular at 4 real sessions even when resources entries are present', () => {
logStartup();
logStartup();
logStartup();
logStartup();
for (let i = 0; i < 5; i++) logResources(i);
const r = runDev('--read');
expect(r.stdout).toContain('SESSION_COUNT: 4');
expect(r.stdout).toContain('TIER: regular');
});
test('TIER stays regular at 7 real sessions and crosses to inner_circle at 8 (resources ignored)', () => {
// Upper-tier boundary: the >=8 inner_circle gate must key off real sessions
// only, so a pile of resources bookkeeping can never tip a regular into the
// inner circle, and 8 genuine sessions still reach it.
for (let i = 0; i < 7; i++) logStartup();
for (let i = 0; i < 6; i++) logResources(i); // 13 raw rows; pre-fix would read inner_circle
let r = runDev('--read');
expect(r.stdout).toContain('SESSION_COUNT: 7');
expect(r.stdout).toContain('TIER: regular');
logStartup(); // 8th real session
r = runDev('--read');
expect(r.stdout).toContain('SESSION_COUNT: 8');
expect(r.stdout).toContain('TIER: inner_circle');
});
test('CROSS_PROJECT ignores a trailing resources entry on a different project', () => {
// The last two REAL sessions are the same project, so CROSS_PROJECT is false.
// A trailing resources row carrying a different project_slug must not become
// the `last` entry and flip CROSS_PROJECT true off bookkeeping.
logStartup({ project_slug: 'samep' });
logStartup({ project_slug: 'samep' });
runDev('--log-session', JSON.stringify({
date: '2026-05-20T02:00:00Z', mode: 'resources', project_slug: 'otherp',
resources_shown: ['url1'],
}));
const r = runDev('--read');
expect(r.stdout).toContain('CROSS_PROJECT: false');
expect(r.stdout).toContain('LAST_PROJECT: samep');
});
});

View File

@ -208,6 +208,61 @@ describe('gbrain_mcp_mode — Tier 3: ~/.claude.json jq read', () => {
);
expect(runDetect().json.gbrain_mcp_mode).toBe('none');
});
// #2051 name generalization: a gbrain server registered under a variant
// name still counts. Identification order: url-match against the config's
// remote_mcp.mcp_url (deterministic — gbrain mounts at generic /mcp so
// URL-path heuristics are impossible) → name pattern gbrain[-_]* → stdio
// command token.
test('server named gbrain-remote (name pattern) → remote-http', () => {
fs.writeFileSync(
path.join(tmpHome, '.claude.json'),
JSON.stringify({
mcpServers: { 'gbrain-remote': { type: 'url', url: 'https://brain.corp.example/mcp' } },
})
);
expect(runDetect().json.gbrain_mcp_mode).toBe('remote-http');
});
test('arbitrarily-named server whose url matches config remote_mcp.mcp_url → remote-http', () => {
fs.mkdirSync(path.join(tmpHome, '.gbrain'), { recursive: true });
fs.writeFileSync(
path.join(tmpHome, '.gbrain', 'config.json'),
JSON.stringify({ remote_mcp: { mcp_url: 'https://team-brain.example.com/mcp' } })
);
fs.writeFileSync(
path.join(tmpHome, '.claude.json'),
JSON.stringify({
mcpServers: { 'our-team-brain': { type: 'url', url: 'https://team-brain.example.com/mcp' } },
})
);
expect(runDetect().json.gbrain_mcp_mode).toBe('remote-http');
});
test('unrelated server with a non-matching url does NOT false-positive → none', () => {
fs.mkdirSync(path.join(tmpHome, '.gbrain'), { recursive: true });
fs.writeFileSync(
path.join(tmpHome, '.gbrain', 'config.json'),
JSON.stringify({ remote_mcp: { mcp_url: 'https://team-brain.example.com/mcp' } })
);
fs.writeFileSync(
path.join(tmpHome, '.claude.json'),
JSON.stringify({
mcpServers: { linear: { type: 'url', url: 'https://mcp.linear.app/mcp' } },
})
);
expect(runDetect().json.gbrain_mcp_mode).toBe('none');
});
test('stdio server with gbrain in the command token → local-stdio', () => {
fs.writeFileSync(
path.join(tmpHome, '.claude.json'),
JSON.stringify({
mcpServers: { 'my-brain': { type: 'stdio', command: '/usr/local/bin/gbrain' } },
})
);
expect(runDetect().json.gbrain_mcp_mode).toBe('local-stdio');
});
});
describe('gbrain_mcp_mode — no info anywhere', () => {

View File

@ -39,6 +39,20 @@ function run(...args: string[]): { stdout: string; stderr: string; status: numbe
};
}
function runWithStdin(input: string, ...args: string[]): { stdout: string; stderr: string; status: number } {
const res = spawnSync(BIN, args, {
env: { ...process.env, GSTACK_HOME: tmpHome },
encoding: 'utf-8',
cwd: ROOT,
input,
});
return {
stdout: res.stdout ?? '',
stderr: res.stderr ?? '',
status: res.status ?? -1,
};
}
// -----------------------------------------------------------------------
// --check
// -----------------------------------------------------------------------
@ -103,6 +117,49 @@ describe('--check with preferences set', () => {
});
});
// #2024: the keyword net only fires when the question TEXT reaches the
// classifier. --summary-stdin pipes it (stdin, not argv — summaries carry
// quotes/newlines/shell metacharacters). Without the summary, an unregistered
// id with never-ask auto-decides even for destructive phrasings.
describe('--check --summary-stdin (#2024 keyword net plumb-through)', () => {
function setPref(id: string, pref: string) {
return run('--write', JSON.stringify({ question_id: id, preference: pref, source: 'plan-tune' }));
}
test('destructive summary on unregistered never-ask id → ASK_NORMALLY (keyword net fires)', () => {
setPref('adhoc-cleanup-question', 'never-ask');
const r = runWithStdin('Should I reset my secrets now?', '--check', 'adhoc-cleanup-question', '--summary-stdin');
expect(r.status).toBe(0);
expect(r.stdout).toContain('ASK_NORMALLY');
expect(r.stdout).toContain('one-way door overrides');
});
test('same id WITHOUT summary still AUTO_DECIDEs (id-only fallback, current semantics)', () => {
setPref('adhoc-cleanup-question', 'never-ask');
const r = run('--check', 'adhoc-cleanup-question');
expect(r.stdout.trim()).toContain('AUTO_DECIDE');
});
test('benign summary on unregistered never-ask id → AUTO_DECIDE (no over-match)', () => {
setPref('adhoc-cleanup-question', 'never-ask');
const r = runWithStdin('Reorganize the TODOs file?', '--check', 'adhoc-cleanup-question', '--summary-stdin');
expect(r.stdout.trim()).toContain('AUTO_DECIDE');
});
test('summary with quotes/newlines/dashes survives the stdin transport', () => {
setPref('adhoc-cleanup-question', 'never-ask');
const summary = 'Run "cleanup" --now\nthen rotate the access keys?';
const r = runWithStdin(summary, '--check', 'adhoc-cleanup-question', '--summary-stdin');
expect(r.stdout).toContain('ASK_NORMALLY');
});
test('empty stdin with --summary-stdin → id-only behavior (fail-safe)', () => {
setPref('adhoc-cleanup-question', 'never-ask');
const r = runWithStdin('', '--check', 'adhoc-cleanup-question', '--summary-stdin');
expect(r.stdout.trim()).toContain('AUTO_DECIDE');
});
});
// Split-chain carve-out: question_ids matching <skill>-split-<option-slug>
// must always ASK_NORMALLY regardless of stored preferences.
// See scripts/resolvers/preamble/generate-ask-user-format.ts

View File

@ -97,6 +97,90 @@ describe('check-careful.sh', () => {
expect(output.message).toContain('recursive delete');
});
// The safe exception matches the COMPLETE command against an anchored
// whitelist shape — anything else (chains, comments, substitution) falls
// through to the destructive-pattern warning.
test('rm -rf /; rm -rf node_modules warns (semicolon chain, dangerous first)', () => {
const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput('rm -rf /; rm -rf node_modules'));
expect(exitCode).toBe(0);
expect(output.permissionDecision).toBe('ask');
expect(output.message).toContain('recursive delete');
});
test('rm -rf /etc/data && rm -rf dist warns (&& chain, dangerous first)', () => {
const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput('rm -rf /etc/data && rm -rf dist'));
expect(exitCode).toBe(0);
expect(output.permissionDecision).toBe('ask');
expect(output.message).toContain('recursive delete');
});
test('rm -rf node_modules; rm -rf /home/user/data warns (safe first, dangerous last)', () => {
const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput('rm -rf node_modules; rm -rf /home/user/data'));
expect(exitCode).toBe(0);
expect(output.permissionDecision).toBe('ask');
expect(output.message).toContain('recursive delete');
});
// Command substitution can end in a whitelisted suffix while running
// anything inside $(...) or backticks — the whitelist's target tokens
// exclude `(` and backtick so these cannot ride the safe exception.
test('rm -rf $(./wipe-all)/node_modules warns (command substitution)', () => {
const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput('rm -rf $(./wipe-all)/node_modules'));
expect(exitCode).toBe(0);
expect(output.permissionDecision).toBe('ask');
expect(output.message).toContain('recursive delete');
});
test('rm -rf `./wipe-all`/node_modules warns (backtick substitution)', () => {
const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput('rm -rf `./wipe-all`/node_modules'));
expect(exitCode).toBe(0);
expect(output.permissionDecision).toBe('ask');
expect(output.message).toContain('recursive delete');
});
// Capital -R is the documented recursive flag on BSD rm (macOS) and accepted
// by GNU rm. Both greps previously required a lowercase r, so `rm -R /`
// silently allowed.
test('rm -R / warns (capital -R recursive)', () => {
const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput('rm -R /'));
expect(exitCode).toBe(0);
expect(output.permissionDecision).toBe('ask');
expect(output.message).toContain('recursive delete');
});
test('rm -fR /home/user warns (capital R in flag cluster)', () => {
const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput('rm -fR /home/user'));
expect(exitCode).toBe(0);
expect(output.permissionDecision).toBe('ask');
expect(output.message).toContain('recursive delete');
});
test('rm -Rf node_modules allows (capital R, single safe target)', () => {
const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput('rm -Rf node_modules'));
expect(exitCode).toBe(0);
expect(output.permissionDecision).toBeUndefined();
});
// JSON-escaped newline (literal two-char \n surviving the grep extraction
// path) breaks the anchored whitelist shape → falls through to the warn.
test('newline-chained rm warns (escaped-newline separator branch)', () => {
const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput('rm -rf /etc/x\nrm -rf node_modules'));
expect(exitCode).toBe(0);
expect(output.permissionDecision).toBe('ask');
expect(output.message).toContain('recursive delete');
});
// Deliberate false positive, pinned: a safe-prefix chain ending in a safe rm
// is indistinguishable from the dangerous-first exploit shape without real
// shell parsing, so warn-on-all-chains is the designed fail-closed direction.
// A future per-segment parser must consciously change this test.
test('cd app && rm -rf node_modules asks (fail-closed on chains, by design)', () => {
const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput('cd app && rm -rf node_modules'));
expect(exitCode).toBe(0);
expect(output.permissionDecision).toBe('ask');
expect(output.message).toContain('recursive delete');
});
test.each([
'rm -rf /; rm -rf node_modules',
'rm -rf / && rm -rf node_modules',

View File

@ -2,7 +2,7 @@
* Layer 8 memory cache + injection (plan-tune cathedral T12).
*
* Verifies the PreToolUse hook reads ~/.gstack/free-text-memory.json and
* surfaces matching nuggets via additionalContext on the hook response.
* surfaces matching nuggets via additionalContext-only output (#2035: never a permissionDecision).
* Cache: per-session memory-cache.json populated on first read, sub-1ms
* thereafter (D13 perf).
*/
@ -43,9 +43,9 @@ function runHook(stdin: object): { stdout: string; stderr: string; status: numbe
env.GSTACK_STATE_ROOT = stateRoot;
env.GSTACK_QUESTION_LOG_NO_DERIVE = '1';
delete env.GSTACK_HOME;
// These cases assert the defer-path memoryContext injection. Strip ambient
// These cases assert the pass-through memoryContext injection. Strip ambient
// Conductor markers so running inside Conductor (CONDUCTOR_WORKSPACE_PATH/PORT
// set) doesn't flip the hook into the [conductor] prose deny instead of defer.
// set) doesn't flip the hook into the [conductor] prose deny instead of pass-through.
delete env.CONDUCTOR_WORKSPACE_PATH;
delete env.CONDUCTOR_PORT;
const res = spawnSync(HOOK, [], {
@ -69,7 +69,7 @@ function runHook(stdin: object): { stdout: string; stderr: string; status: numbe
// ----------------------------------------------------------------------
describe('memory injection', () => {
test('injects matching nugget into additionalContext on defer', () => {
test('injects matching nugget into additionalContext on pass-through', () => {
writeMemory([
{
nugget: 'User prefers verbose explanations with tradeoffs',
@ -91,7 +91,10 @@ describe('memory injection', () => {
],
},
});
expect(r.parsed?.hookSpecificOutput?.permissionDecision).toBe('defer');
// #2035: nugget delivery is additionalContext-ONLY — a permissionDecision
// here (any value) would orphan the tool call on CC >= 2.1.89.
expect('permissionDecision' in (r.parsed?.hookSpecificOutput ?? {})).toBe(false);
expect(r.parsed?.hookSpecificOutput?.hookEventName).toBe('PreToolUse');
expect(r.parsed?.hookSpecificOutput?.additionalContext).toContain('verbose explanations');
});
@ -115,8 +118,9 @@ describe('memory injection', () => {
],
},
});
expect(r.parsed?.hookSpecificOutput?.permissionDecision).toBe('defer');
expect(r.parsed?.hookSpecificOutput?.additionalContext).toBeUndefined();
// No nugget → pure pass-through: exit 0 with EXACTLY empty stdout.
expect(r.status).toBe(0);
expect(r.stdout).toBe('');
});
test('caps to 3 most-recent nuggets when many match', () => {
@ -219,7 +223,8 @@ describe('per-session memory cache', () => {
],
},
});
expect(r.parsed?.hookSpecificOutput?.permissionDecision).toBe('defer');
expect(r.parsed?.hookSpecificOutput?.additionalContext).toBeUndefined();
// No nugget → pure pass-through: exit 0 with EXACTLY empty stdout.
expect(r.status).toBe(0);
expect(r.stdout).toBe('');
});
});

View File

@ -43,3 +43,50 @@ describe("one-way-door credential keyword net (#1839)", () => {
}
});
});
describe("one-way-door credential keyword net (#2024)", () => {
const VERBS = ["revoke", "reset", "rotate"];
const NOUNS = ["api key", "token", "secret", "credential", "access key", "password"];
// #2024 repro rows: these leaked as two-way pre-fix because the noun
// alternations were mismatched across verbs (revoke lacked secret; reset
// lacked secret AND access key). The password-parallel test above passes on
// buggy code, so THESE rows are the fails-first proof.
test('"reset my secret" / "reset my access key" / "revoke my secret" classify one-way', () => {
for (const summary of ["reset my secret", "reset my access key", "revoke my secret"]) {
const r = classifyQuestion({ summary });
expect(r.oneWay).toBe(true);
expect(r.reason).toBe("keyword");
}
});
test("full verbs x nouns matrix classifies one-way (singular and plural)", () => {
for (const verb of VERBS) {
for (const noun of NOUNS) {
for (const form of [noun, `${noun}s`]) {
const r = classifyQuestion({ summary: `${verb} the production ${form}` });
expect(r.oneWay).toBe(true);
expect(r.reason).toBe("keyword");
}
}
}
});
// Plural forms leaked before AND after the original #2024 report: \b(...)\b
// cannot match "credentials" (no word boundary between the noun and its s).
test('plurals: "rotate the credentials" / "revoke all tokens" / "reset the passwords" classify one-way', () => {
for (const summary of ["rotate the credentials", "revoke all tokens", "reset the passwords"]) {
expect(classifyQuestion({ summary }).oneWay).toBe(true);
}
});
test("benign summaries stay two-way (no over-match)", () => {
for (const summary of [
"reset the flaky test runner",
"rotate the log files nightly",
"revoke the meeting invite",
]) {
expect(classifyQuestion({ summary }).oneWay).toBe(false);
}
});
});

View File

@ -3,15 +3,18 @@
*
* Covers:
* - never-ask + marker + two-way + clean recommendation deny+reason
* - never-ask + no marker defer (D18 marker gate)
* - never-ask + one-way defer (safety override)
* - never-ask + ambiguous recommendation defer (D2 refuse-on-ambiguous)
* - always-ask defer
* - no preference defer
* - never-ask + no marker pass-through (D18 marker gate)
* - never-ask + one-way pass-through (safety override)
* - never-ask + ambiguous recommendation pass-through (D2 refuse-on-ambiguous)
* - always-ask pass-through
* - no preference pass-through
* - project preference wins over global (D8 precedence)
* - global preference applies when no project preference set
* - mcp__*__AskUserQuestion matcher accepted
* - empty stdin defer (crash safety)
* - empty stdin pass-through (crash safety)
*
* Pass-through contract (#2035/#2006): exit 0 + EXACTLY empty stdout, or
* additionalContext-only hookSpecificOutput never a permissionDecision.
* - auto-decided event logged via gstack-question-log (PostToolUse won't fire)
* - auto-decided marker written to ~/.gstack/sessions/<id>/.auto-decided-<tool_use_id>
*/
@ -97,6 +100,20 @@ function runHook(stdin: object, cwd?: string, extraEnv?: Record<string, string>)
};
}
/**
* #2035/#2006 contract: pass-through (abstain) is exit 0 with EXACTLY empty
* stdout never a permissionDecision. 'defer' is a real PreToolUse value,
* but its semantics are pause-for-external-resumption (CC v2.1.89+), so
* emitting it orphans the tool call in interactive sessions. Exact-empty
* (not trim) is deliberate: whitespace on stdout is still hook output, and a
* garbage/partial write must fail this assertion rather than slip past an
* optional-chained parse.
*/
function expectPassThrough(r: { status: number; stdout: string }): void {
expect(r.status).toBe(0);
expect(r.stdout).toBe('');
}
function autoDecidedEvents(): Array<Record<string, unknown>> {
const f = path.join(stateRoot, 'projects', cwdSlug, 'question-log.jsonl');
if (!fs.existsSync(f)) return [];
@ -113,8 +130,8 @@ function autoDecidedEvents(): Array<Record<string, unknown>> {
// Defer paths
// ----------------------------------------------------------------------
describe('defers (no enforcement)', () => {
test('no preference set → defer', () => {
describe('passes through (no enforcement)', () => {
test('no preference set → pass-through (empty stdout, no permissionDecision)', () => {
const r = runHook({
session_id: 's1',
tool_name: 'AskUserQuestion',
@ -125,11 +142,10 @@ describe('defers (no enforcement)', () => {
],
},
});
expect(r.status).toBe(0);
expect(r.parsed?.hookSpecificOutput?.permissionDecision).toBe('defer');
expectPassThrough(r);
});
test('marker missing → defer (D18)', () => {
test('marker missing → pass-through (D18)', () => {
writeProjectPref('test-q', 'never-ask');
const r = runHook({
session_id: 's2',
@ -141,10 +157,10 @@ describe('defers (no enforcement)', () => {
],
},
});
expect(r.parsed?.hookSpecificOutput?.permissionDecision).toBe('defer');
expectPassThrough(r);
});
test('always-ask preference → defer', () => {
test('always-ask preference → pass-through', () => {
writeProjectPref('test-q', 'always-ask');
const r = runHook({
session_id: 's3',
@ -156,10 +172,10 @@ describe('defers (no enforcement)', () => {
],
},
});
expect(r.parsed?.hookSpecificOutput?.permissionDecision).toBe('defer');
expectPassThrough(r);
});
test('empty stdin → defer (crash safety)', () => {
test('empty stdin → pass-through (crash safety)', () => {
const env: Record<string, string> = {};
for (const [k, v] of Object.entries(process.env)) {
if (v !== undefined) env[k] = v;
@ -167,14 +183,39 @@ describe('defers (no enforcement)', () => {
env.GSTACK_STATE_ROOT = stateRoot;
const res = spawnSync(HOOK, [], { env, input: '', encoding: 'utf-8' });
expect(res.status).toBe(0);
const parsed = JSON.parse(res.stdout || '{}');
expect(parsed.hookSpecificOutput?.permissionDecision).toBe('defer');
expect(res.stdout).toBe('');
});
test('non-AUQ tool_name → defer (defensive)', () => {
test('non-AUQ tool_name → pass-through (defensive)', () => {
writeProjectPref('test-q', 'never-ask');
const r = runHook({ session_id: 's4', tool_name: 'Bash', tool_use_id: 'tu-4', tool_input: {} });
expect(r.parsed?.hookSpecificOutput?.permissionDecision).toBe('defer');
expectPassThrough(r);
});
// #2035 tripwire: no non-deny/non-allow path may EVER put the string
// "permissionDecision" on stdout. Emitting one on a pass-through path (any
// value — 'defer' included) hands the platform a decision where the hook
// has none, and 'defer' specifically pauses the call for a resumption that
// never comes in interactive sessions.
test('pass-through stdout never contains "permissionDecision" (#2035)', () => {
const paths = [
runHook({
session_id: 's-trip-1',
tool_name: 'AskUserQuestion',
tool_use_id: 'tu-trip-1',
tool_input: {
questions: [
{ question: '<gstack-qid:test-q> Approve?', options: ['A) Yes (recommended)', 'B) No'] },
],
},
}),
runHook({ session_id: 's-trip-2', tool_name: 'Bash', tool_use_id: 'tu-trip-2', tool_input: {} }),
runHook({ session_id: 's-trip-3', tool_name: 'AskUserQuestion', tool_use_id: 'tu-trip-3', tool_input: { questions: [] } }),
];
for (const r of paths) {
expect(r.status).toBe(0);
expect(r.stdout).not.toContain('"permissionDecision"');
}
});
});
@ -204,7 +245,7 @@ describe('enforces never-ask preferences', () => {
expect(r.parsed?.hookSpecificOutput?.permissionDecisionReason).toContain('Fix now');
});
test('one-way door → defer even with never-ask (safety override)', () => {
test('one-way door → pass-through even with never-ask (safety override)', () => {
writeProjectPref('ship-test-failure-triage', 'never-ask');
const r = runHook({
session_id: 's6',
@ -219,10 +260,10 @@ describe('enforces never-ask preferences', () => {
],
},
});
expect(r.parsed?.hookSpecificOutput?.permissionDecision).toBe('defer');
expectPassThrough(r);
});
test('ambiguous recommendation (two labels) → defer (D2 refuse-on-ambiguous)', () => {
test('ambiguous recommendation (two labels) → pass-through (D2 refuse-on-ambiguous)', () => {
writeProjectPref('ship-pre-landing-review-fix', 'never-ask');
const r = runHook({
session_id: 's7',
@ -237,10 +278,10 @@ describe('enforces never-ask preferences', () => {
],
},
});
expect(r.parsed?.hookSpecificOutput?.permissionDecision).toBe('defer');
expectPassThrough(r);
});
test('no recommendation marker AND no prose match → defer', () => {
test('no recommendation marker AND no prose match → pass-through', () => {
writeProjectPref('ship-pre-landing-review-fix', 'never-ask');
const r = runHook({
session_id: 's8',
@ -255,7 +296,48 @@ describe('enforces never-ask preferences', () => {
],
},
});
expect(r.parsed?.hookSpecificOutput?.permissionDecision).toBe('defer');
expectPassThrough(r);
});
// #2024: unregistered ids used to default straight to two-way without ever
// consulting the keyword classifier — an ad-hoc DESTRUCTIVE question with a
// stored never-ask preference auto-decided. The hook now falls back to
// classifyQuestion on the question text when the registry lookup misses.
test('unregistered id + never-ask + destructive text → pass-through (keyword net fires, #2024)', () => {
writeProjectPref('adhoc-credential-cleanup', 'never-ask');
const r = runHook({
session_id: 's-kw-1',
tool_name: 'AskUserQuestion',
tool_use_id: 'tu-kw-1',
tool_input: {
questions: [
{
question: '<gstack-qid:adhoc-credential-cleanup> Reset my secret and proceed?',
options: ['A) Yes (recommended)', 'B) No'],
},
],
},
});
expectPassThrough(r);
});
test('unregistered id + never-ask + benign text → still deny (auto-decide unchanged)', () => {
writeProjectPref('adhoc-credential-cleanup', 'never-ask');
const r = runHook({
session_id: 's-kw-2',
tool_name: 'AskUserQuestion',
tool_use_id: 'tu-kw-2',
tool_input: {
questions: [
{
question: '<gstack-qid:adhoc-credential-cleanup> Reorganize the TODOs file?',
options: ['A) Yes (recommended)', 'B) No'],
},
],
},
});
expect(r.parsed?.hookSpecificOutput?.permissionDecision).toBe('deny');
expect(r.parsed?.hookSpecificOutput?.permissionDecisionReason).toContain('plan-tune auto-decide');
});
});
@ -301,7 +383,7 @@ describe('precedence: project wins over global (D8)', () => {
expect(r.parsed?.hookSpecificOutput?.permissionDecision).toBe('deny');
});
test('project always-ask + global never-ask → defer (project wins)', () => {
test('project always-ask + global never-ask → pass-through (project wins)', () => {
writeProjectPref('ship-pre-landing-review-fix', 'always-ask');
writeGlobalPref('ship-pre-landing-review-fix', 'never-ask');
const r = runHook({
@ -317,7 +399,7 @@ describe('precedence: project wins over global (D8)', () => {
],
},
});
expect(r.parsed?.hookSpecificOutput?.permissionDecision).toBe('defer');
expectPassThrough(r);
});
});
@ -437,13 +519,13 @@ describe('Conductor prose redirect', () => {
expect(r.parsed?.hookSpecificOutput?.permissionDecisionReason).not.toContain('[conductor]');
});
test('non-AUQ tool in Conductor → still defer (no redirect on unrelated tools)', () => {
test('non-AUQ tool in Conductor → still pass-through (no redirect on unrelated tools)', () => {
const r = runHook(
{ session_id: 'c6', tool_name: 'Bash', tool_use_id: 'tu-c6', tool_input: {} },
undefined,
CONDUCTOR,
);
expect(r.parsed?.hookSpecificOutput?.permissionDecision).toBe('defer');
expectPassThrough(r);
});
});

View File

@ -296,7 +296,10 @@ describeIfSelected('PlanTune cathedral E2E: annotation', ['plan-tune-annotation'
});
expect(res.status).toBe(0);
const parsed = JSON.parse(res.stdout || '{}');
expect(parsed.hookSpecificOutput?.permissionDecision).toBe('defer');
// #2035: memory-nugget delivery is additionalContext-ONLY. Emitting a
// permissionDecision here (the old 'defer') pauses the tool call for a
// resumption that never comes in interactive sessions.
expect('permissionDecision' in (parsed.hookSpecificOutput ?? {})).toBe(false);
expect(parsed.hookSpecificOutput?.additionalContext).toContain('verbose explanations');
});
});

View File

@ -549,7 +549,13 @@ Summarize what the "Spec Review Loop" section does — specifically:
Write your summary to ${ohDir}/spec-review-summary.md`,
workingDirectory: ohDir,
maxTurns: 8,
// 12, not 8 (#2473): the Spec Review Loop content is CARVED out of
// SKILL.md into office-hours/sections/, so the agent legitimately needs
// discovery hops (grep SKILL.md -> ls sections/ -> read the section)
// before it can write. The 8-turn budget predates the carve — observed
// failures wrote a correct summary on tool-turn 8 and hit the cap on
// the closing text turn (error_max_turns at 9 turns, deterministic).
maxTurns: 12,
timeout: 120_000,
testName: 'office-hours-spec-review',
runId,

View File

@ -427,7 +427,11 @@ This is a local-only repo so use the local branch (main) instead of origin/main
Write your retrospective to ${dir}/retro-output.md`,
workingDirectory: dir,
maxTurns: 25,
timeout: 240_000,
// 360s, not 240s: same runner-contention class as review-dashboard-via.
// /retro is a long multi-step flow — a clean pass measured 225s and the
// next CI run timed out at the 240s line (exitReason "timeout", 3/3
// attempts). Outer bun timeout below rises to 480s for headroom.
timeout: 360_000,
testName: 'retro-base-branch',
runId,
});
@ -444,7 +448,7 @@ Write your retrospective to ${dir}/retro-output.md`,
const content = fs.readFileSync(retroPath, 'utf-8');
expect(content.length).toBeGreaterThan(100);
}
}, 300_000);
}, 480_000);
});
// --- Retro E2E ---
@ -614,7 +618,13 @@ Skip the preamble, lake intro, telemetry, and all other ship steps.
Write the dashboard output to ${dashDir}/dashboard-output.md`,
workingDirectory: dashDir,
maxTurns: 12,
timeout: 180_000,
// 300s, not 180s: on a saturated CI runner this file's concurrent
// sessions queue behind each other and session STARTUP can eat the
// whole budget — observed as deterministic timeout at 0 turns/$0.00
// for exactly 180s across 3 attempts (PR #2472 CI + its baseline),
// while the 240s-budget tests in the same job passed. Outer bun
// timeout below rises to 360s to keep headroom over the inner budget.
timeout: 300_000,
testName: 'review-dashboard-via',
runId,
});
@ -648,7 +658,7 @@ Write the dashboard output to ${dashDir}/dashboard-output.md`,
);
// Ship dashboard should not gate when eng review is clear
expect(gateQuestions).toHaveLength(0);
}, 240_000);
}, 360_000);
});
// Module-level afterAll — finalize eval collector after all tests complete