Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,43 @@
# Changelog

## [1.60.2.0] - 2026-08-01

## **Questions work again in headless and spawned sessions.**
## **One hook field was cancelling every AskUserQuestion call.**

If you ran gstack in a non-interactive session, a spawned agent, a `claude -p` run, a scheduled task, anything that is not a live terminal, every AskUserQuestion died with `Tool execution was interrupted`. No error, no log line, nothing pointing at a cause. The culprit was our own PreToolUse hook. `question-preference-hook` returned `permissionDecision: 'defer'` to mean "no opinion, let the tool run". In Claude Code, `defer` means "cancel this tool call and park it for a later resume". Interactive sessions warn and ignore it, which is why terminal users never saw this and headless users saw nothing else.

### The numbers that matter

Source: the `case "defer"` branch in the Claude Code 2.1.219 runtime, plus the new regression test (reproducible: `bun test test/auq-no-defer-decision.test.ts`).

| Metric | Before | After | Δ |
|--------|--------|-------|---|
| AskUserQuestion success, non-interactive session | 0% | 100% | restored |
| AskUserQuestion success, interactive session | 100% | 100% | unchanged |
| Hook call sites returning the cancelling value | 6 | 0 | -6 |
| Tripwire tests guarding the invariant | 0 | 5 | +5 |

The failure rate was 0%, not "flaky". Every question, every run, as long as the session was not a live terminal. If a spawned agent ever went quiet on you partway through a task and you assumed it had crashed, this is what happened to it.

### What this means for you

Any workflow that asks you something now works headless: spawned skill agents, scheduled runs, `claude -p` pipelines, and Conductor sessions. If you had worked around this by avoiding questions in automation, you can stop. Upgrade with `/gstack-upgrade`, then re-run whatever you gave up on.

### Itemized changes

#### Fixed

- `hosts/claude/hooks/question-preference-hook.ts`: the no-opinion helper now omits `permissionDecision` entirely instead of sending `'defer'`. That covers all 6 call sites: empty stdin, unparseable stdin, non-AUQ tool name, zero questions, the main pass-through (where no stored preference, always-ask, missing recommendation marker, one-way door, and ambiguous recommendation all land), and the `main()` crash handler. `additionalContext` still rides along, since it is delivered on a separate event. The `deny` path used by auto-decide and the Conductor prose fallback is untouched.

#### For contributors

- `test/auq-no-defer-decision.test.ts`: new tripwire. Two static greps (comment-stripped, so prose explaining the ban does not trip them) fail CI if any hook under `hosts/claude/hooks/` reintroduces a live `permissionDecision: 'defer'`, or if any test re-asserts `toBe('defer')`. Behavioral assertions check the key is absent rather than present-and-undefined, since a serialized `null` would still reach the validator.
- 14 assertions changed from `toBe('defer')` to `toBeUndefined()` across `test/question-preference-hook.test.ts` (10), `test/memory-cache-injection.test.ts` (3), and `test/skill-e2e-plan-tune-cathedral.test.ts` (1). These previously encoded the broken behavior as expected behavior.
- `package.json` version synced to `1.60.2.0` (`gen-skill-docs` pins it against `VERSION`).
- `defer()` is renamed to `passThrough()` in `question-preference-hook.ts` and `auq-error-fallback-hook.ts`, with the comments that used "defer" to mean "let the user answer" reworded to match. The old name is what made emitting the cancelling wire value look like the obvious implementation. No behavior change in either hook.
- `CLAUDE.md`: new "PreToolUse hooks: never emit `permissionDecision: 'defer'`" section with the per-mode behavior table and a pointer to the tripwire.

## [1.60.1.0] - 2026-07-09

## **The /autoplan dual-voice eval is back on the board, catching real regressions.**
Expand Down
37 changes: 37 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,43 @@ structures. Instead:
This applies to test commands, eval commands, deploy commands, and any other
project-specific behavior. The project owns its config; gstack reads it.

## PreToolUse hooks: never emit `permissionDecision: 'defer'`

A PreToolUse hook that has no opinion MUST omit `permissionDecision` entirely.
Emitting `'defer'` is not a no-op. In Claude Code it means "cancel this tool call
and park it for a later resume":

| Session | Solo tool call | Result |
|---------|----------------|--------|
| interactive | any | warned and ignored, tool runs |
| non-interactive (print/headless/spawned) | yes | `hook_deferred_tool`, tool **never executes** |
| non-interactive | no (batched) | warned and ignored, tool runs |

Because our matcher is scoped to `(AskUserQuestion|mcp__.*__AskUserQuestion)` and an
AskUserQuestion call is almost always solo, a `defer` return meant every question in
every headless or spawned session died as `Tool execution was interrupted`, with no
error and no log line pointing at the hook. It reproduced 100% of the time and was
invisible to `bun test`, because the unit tests asserted the broken value instead of
the observable behavior.

Omitting the field falls through to the normal permission flow in both modes, and
`additionalContext` still arrives (it is delivered on a separate event). `deny` is
unaffected and remains the mechanism for auto-decide and the Conductor prose path.

The helper for this in both AskUserQuestion hooks is `passThrough()`. It is
deliberately NOT called `defer()` — that name is what made emitting `'defer'`
look like the obvious implementation in the first place.

`test/auq-no-defer-decision.test.ts` pins the invariant with two static-grep
tripwires (comment-stripped, so prose explaining the ban does not trip them) plus
behavioral assertions that the key is absent, not merely undefined:

1. no hook under `hosts/claude/hooks/` may emit a live `permissionDecision: 'defer'`
2. no test may re-assert `toBe('defer')` — the original bug survived a fully green
`bun test` because 14 assertions across three test files encoded it as the spec

Reintroducing either fails CI.

## Writing SKILL templates

SKILL.md.tmpl files are **prompt templates read by Claude**, not bash scripts.
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
1.60.1.0
1.60.2.0
12 changes: 6 additions & 6 deletions hosts/claude/hooks/auq-error-fallback-hook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ function readStdin(): Promise<string> {
}

/** No-op output — let the tool result stand untouched. */
function defer(): void {
function passThrough(): void {
process.stdout.write(
JSON.stringify({ hookSpecificOutput: { hookEventName: 'PostToolUse' } }),
);
Expand Down Expand Up @@ -172,22 +172,22 @@ export function directiveFor(kind: 'spawned' | 'headless' | 'interactive'): stri

async function main(): Promise<void> {
const raw = await readStdin();
if (!raw.trim()) return defer();
if (!raw.trim()) return passThrough();

let stdin: HookStdin;
try {
stdin = JSON.parse(raw);
} catch (e) {
logHookError(`stdin parse failed: ${(e as Error).message}`);
return defer();
return passThrough();
}

const toolName = stdin.tool_name || '';
if (toolName !== 'AskUserQuestion' && !/^mcp__.+__AskUserQuestion$/.test(toolName)) {
return defer();
return passThrough();
}

if (!isErrorResponse(stdin.tool_response)) return defer();
if (!isErrorResponse(stdin.tool_response)) return passThrough();

inject(directiveFor(sessionKind(stdin.cwd)));
}
Expand All @@ -197,6 +197,6 @@ async function main(): Promise<void> {
if (import.meta.main) {
main().catch((e) => {
logHookError(`main crash: ${(e as Error).message}`);
defer();
passThrough();
});
}
35 changes: 21 additions & 14 deletions hosts/claude/hooks/question-preference-hook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,11 @@
* 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.
*
* Why deny+reason instead of allow+updatedInput:
* AskUserQuestion's `updatedInput` shape for "pre-resolve this question"
Expand All @@ -31,7 +31,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.
Expand Down Expand Up @@ -92,10 +92,17 @@ function readStdin(): Promise<string> {
});
}

function defer(additionalContext?: string): void {
// No-op pass-through: emit NO `permissionDecision` at all.
//
// Do NOT send `permissionDecision: 'defer'` here. Claude Code's `defer` is not a
// no-op: in a NON-INTERACTIVE (print-mode) session with a solo tool call it emits
// `hook_deferred_tool` and returns WITHOUT ever executing the tool, so every
// AskUserQuestion dies as "Tool execution was interrupted". In interactive mode it
// is merely warned-and-ignored, which is why this only bites headless/spawned runs.
// Omitting the field falls through to the normal permission flow in both modes.
function passThrough(additionalContext?: string): void {
const out: Record<string, unknown> = {
hookEventName: 'PreToolUse',
permissionDecision: 'defer',
};
if (additionalContext) out.additionalContext = additionalContext;
process.stdout.write(JSON.stringify({ hookSpecificOutput: out }));
Expand Down Expand Up @@ -347,15 +354,15 @@ function logAutoDecided(
async function main(): Promise<void> {
const raw = await readStdin();
if (!raw.trim()) {
defer();
passThrough();
return;
}
let stdin: HookStdin;
try {
stdin = JSON.parse(raw);
} catch (e) {
logHookError(`stdin parse failed: ${(e as Error).message}`);
defer();
passThrough();
return;
}

Expand All @@ -364,26 +371,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) {
Expand All @@ -402,7 +409,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
Expand Down Expand Up @@ -471,10 +478,10 @@ async function main(): Promise<void> {
return;
}

defer(memoryContext);
passThrough(memoryContext);
}

main().catch((e) => {
logHookError(`main crash: ${(e as Error).message}`);
defer();
passThrough();
});
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "gstack",
"version": "1.60.1.0",
"version": "1.60.2.0",
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
"license": "MIT",
"type": "module",
Expand Down
Loading
Loading