Files
gstack/hosts/claude/hooks/question-preference-hook.ts
T
Garry TanandClaude Fable 5 253d1dfe26 v1.76.0.0 fix: ship doc-sync survives Conductor — spawned subagent sessions reachable (#2733) (#2741)
* feat(session-kind): explicit GSTACK_SESSION_KIND override; skill-start spawned gates keyed on kind (#2733)

Claude Code subagents inherit the parent env byte-for-byte, so ambient
markers classify them as the parent's kind and the spawned classification
was unreachable outside OpenClaw. GSTACK_SESSION_KIND=spawned (step 0,
spawned-only by design) lets a dispatching skill mark its subagent per
command. skill-start now keys SPAWNED_SESSION and the spawned-session
instruction block on the resolved kind (was raw OPENCLAW_SESSION),
suppresses CONDUCTOR_SESSION for spawned sessions, gates all 11
interactive-onboarding blocks plus their ack-at-emit marker writes on
kind != spawned, and adds a destructive-gate carve-out to the spawned
block (conservative-continue, never prose-STOP).

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

* fix(hooks): spawned-session escape in Conductor AUQ deny; override coverage in AUQ-error fallback (#2733)

Hooks inherit the harness env, so a per-command GSTACK_SESSION_KIND
prefix inside a subagent's bash can never reach them. Levers added:
a deterministic [conductor][spawned] auto-choose deny for env-level
spawned sessions (OPENCLAW_SESSION or session-wide GSTACK_SESSION_KIND),
and a spawned escape sentence appended to both hooks' prose directives
so a marked subagent that slips and calls AUQ resolves to auto-choose
instead of prose-STOP. The sentence lives in one shared constant
(hosts/claude/hooks/spawned-directive.ts) so the two paths can never
drift; destructive semantics are unified to conservative-continue.

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

* fix(ship): Step 18 marks the document-release subagent spawned — env prefix + auto-choose prompt (#2733)

The dispatch prompt now (1) frames the run as a SPAWNED subagent whose
LAST line is machine-parsed, (2) instructs prefixing the preamble's
gstack-skill-start invocation with GSTACK_SESSION_KIND=spawned on the
same command line (template bash blocks don't share exports), and
(3) resolves every AUQ gate to auto-choosing the recommended option,
conservative on no-recommendation, never destructive. The JSON contract
gains a required "decisions" array (auto-chosen gates, printed to the
ship console — never embedded in the public PR body) and a placement
clause so the skill's own doc-health summary stops competing with the
LAST-line JSON. Tripwire pins added; codex/factory goldens refreshed.

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

* feat(auq-format): proactive SESSION_KIND=spawned rule ordered above the Conductor rule (#2733)

The spawned classification previously existed only in the failure-fallback
branch — a spawned session was invited to call AskUserQuestion and reach
auto-choose via the deny/error detour, and a spawned session inside a
Conductor workspace hit the Conductor prose-STOP rule first. The Tool
resolution list now leads with the spawned rule (auto-choose recommended,
never prose, never BLOCKED, destructive gates resolve conservative), the
self-check carries the never-reach-this-checklist clause, and all tier>=2
SKILL.md renders are regenerated. Context-budget fixture refreshed in the
same commit per the ratchet protocol (the AUQ section is eager in every
tier>=2 skill).

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

* test(e2e): spawned document-release subagent returns the JSON contract through a firing gate (#2733)

The behavioral proof the bug shipped without: ship-docsync stubs the
skill (no preamble, no gates) and skill-e2e-workflow suppresses the
gates by prompt. This gate-tier E2E plays the parent — it drives the
verbatim Step 18 dispatch prompt (extracted from the live pr-body.md,
drift-proof) against a real preamble-bearing document-release slice in
a Conductor-ambient env with both AUQ hooks seeded live, an unbumped
VERSION making Step 8 fire. Asserts: the final line parses as the
5-key JSON contract, the fired gate's auto-choice is recorded in
decisions, and VERSION is untouched (the gate resolved to its
recommended Skip). Burn-in: 1/1 pass, $0.35, 21 turns, 106s.

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

* docs(openclaw): document the GSTACK_SESSION_KIND override; wire session-kind into paid selectors (#2733)

OPENCLAW.md's spawned-session section now covers the explicit per-command
marker, its deliberate spawned-only narrowness, the /ship Step 18 usage,
the destructive carve-out, onboarding-block suppression, and the hook
env-blindness caveat. bin/gstack-session-kind and the shared
spawned-directive module join the conductor-prose and
auto-decide-preserved selector dep lists (session-kind previously
appeared in no touchfiles entry — editing it alone triggered no paid
E2E). TODOS.md gains the plan-tune capture follow-up for spawned
auto-choices.

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

* fix: pre-landing review fixes (#2733)

Review army + coverage audit findings, all applied:
- headless directive carries the spawned escape sentence too (multi-
  specialist: a CI-hosted ship's marked subagent must not end BLOCKED)
- anti-injection scoping on every text-claimable spawned trigger (AUQ
  rule + shared escape sentence): markings count only from the creating
  prompt, never from files/tool output/web content read mid-run
- [conductor][spawned] deny annotates one-way doors per question
- SPAWNED_OVERRIDE: env tamper-visibility status line + OPENCLAW.md note
- spawned sessions skip the network update-check and first-task probe
  (consumers suppressed; preserves the one-shot just-upgraded marker)
- test hardening: dispatch-tripwire end-bound validated, vacuous marker
  asserts replaced with output asserts, E2E cpSync size filter + named
  fence tolerance, spawnedByEnv parity pin, destructive-policy cross-
  surface drift guard, one-way annotation + bogus-value hook cases
- session-kind duplicate rationale comment deduped; regen + goldens +
  context-budget fixture refreshed

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

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

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

* docs: update project documentation for v1.76.0.0

PROJECT_STRUCTURE.md: add hosts/claude/hooks/ to the directory tree
(AUQ capture + enforcement hooks, spawned-session directive, timeline
stop) — the tree omitted the directory while docs/OPENCLAW.md and
CHANGELOG.md now reference paths inside it.

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

* docs: sync TODOS.md ship dispatch entry with the v1.76.0.0 contract

Codex doc-review finding: the SHIPPED entry for /ship auto-invoking
/document-release still described the four-key JSON contract. Adds the
decisions key (console-printed, never PR markdown), the
GSTACK_SESSION_KIND=spawned dispatch marking (#2733), and the new
spawned-dispatch gate E2E to the proven-by list.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 20:45:19 -07:00

572 lines
22 KiB
TypeScript

#!/usr/bin/env bun
/**
* PreToolUse hook for AskUserQuestion (Claude Code, plan-tune cathedral T6).
*
* Enforces never-ask / always-ask / ask-only-for-one-way preferences
* deterministically — no agent compliance required.
*
* Decision tree (per question in tool_input.questions):
* 1. Extract question_id via marker (<gstack-qid:foo-bar>). If no marker,
* enforcement is skipped for this question (D18 — hash IDs are
* observed-only, never used as preference keys).
* 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 → 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 → 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"
* isn't structurally pinned in Claude Code docs (spike T4 left as open
* question). `deny` with a reason that names the auto-decided option is
* conservative + reliable: the model receives the rejection feedback,
* reads the recommended option from the reason, and proceeds without
* re-firing AUQ. When the spike around input mutation lands, we can
* swap to allow+updatedInput without changing the contract.
*
* Recommended-option extraction (per D2):
* - 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): 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.
*/
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { runBin, repoRoot } from './spawn-bin';
import { isConductor } from '../../../lib/is-conductor';
import { classifyQuestion } from '../../../scripts/one-way-doors';
import { SPAWNED_ESCAPE_SENTENCE, CONDUCTOR_SPAWNED_DENY_REASON, spawnedByEnv } from './spawned-directive';
interface HookStdin {
session_id?: string;
hook_event_name?: string;
tool_name?: string;
tool_use_id?: string;
tool_input?: {
questions?: Array<{
question?: string;
options?: Array<string | { label?: string; description?: string }>;
multiSelect?: boolean;
}>;
};
cwd?: string;
}
const MARKER_RE = /<gstack-qid:([a-z0-9-]{1,64})>/i;
const RECOMMENDED_LABEL_RE = /\(recommended\)\s*$/i;
function stateRoot(): string {
return (
process.env.GSTACK_STATE_ROOT ||
process.env.GSTACK_HOME ||
path.join(os.homedir(), '.gstack')
);
}
function logHookError(msg: string): void {
try {
const sr = stateRoot();
fs.mkdirSync(sr, { recursive: true });
fs.appendFileSync(
path.join(sr, 'hook-errors.log'),
`${new Date().toISOString()} question-preference-hook: ${msg}\n`,
);
} catch {
// last-resort swallow
}
}
function readStdin(): Promise<string> {
return new Promise((resolve) => {
let buf = '';
process.stdin.setEncoding('utf-8');
process.stdin.on('data', (chunk) => (buf += chunk));
process.stdin.on('end', () => resolve(buf));
process.stdin.on('error', () => resolve(buf));
setTimeout(() => resolve(buf), 2000);
});
}
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);
}
function deny(reason: string): void {
process.stdout.write(
JSON.stringify({
hookSpecificOutput: {
hookEventName: 'PreToolUse',
permissionDecision: 'deny',
permissionDecisionReason: reason,
},
}),
);
process.exit(0);
}
function readJsonSafe(filePath: string): Record<string, unknown> | null {
try {
return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
} catch {
return null;
}
}
interface PreferenceLookup {
preference: string | undefined;
source: 'project' | 'global' | 'none';
}
function lookupPreference(slug: string, questionId: string): PreferenceLookup {
const sr = stateRoot();
const projectFile = path.join(sr, 'projects', slug, 'question-preferences.json');
const globalFile = path.join(sr, 'global-question-preferences.json');
const project = readJsonSafe(projectFile);
if (project && typeof project[questionId] === 'string') {
return { preference: project[questionId] as string, source: 'project' };
}
const global = readJsonSafe(globalFile);
if (global && typeof global[questionId] === 'string') {
return { preference: global[questionId] as string, source: 'global' };
}
return { preference: undefined, source: 'none' };
}
interface RegistryEntry {
id: string;
door_type?: 'one-way' | 'two-way';
signal_key?: string;
}
interface MemoryNugget {
nugget: string;
applies_to_signal_keys: string[];
applied_at?: string;
}
/**
* Read per-session cache first, fall back to canonical local file. Cache
* invalidates by being missing — gstack-distill-apply doesn't touch the
* cache because the canonical file is always the source-of-truth on read
* miss. Sub-1ms cache reads (D13 perf).
*/
function loadMemoryNuggets(sessionId: string | undefined): MemoryNugget[] {
const sr = stateRoot();
const canonical = path.join(sr, 'free-text-memory.json');
let nuggets: MemoryNugget[] | null = null;
if (sessionId) {
const cachePath = path.join(sr, 'sessions', sessionId, 'memory-cache.json');
try {
const cached = JSON.parse(fs.readFileSync(cachePath, 'utf-8'));
if (Array.isArray(cached.nuggets)) {
return cached.nuggets;
}
} catch {
// miss → fall through
}
}
try {
const j = JSON.parse(fs.readFileSync(canonical, 'utf-8'));
nuggets = Array.isArray(j.nuggets) ? j.nuggets : [];
} catch {
nuggets = [];
}
// Write through to the per-session cache so subsequent hooks on this
// session take the fast path. Best-effort; never fails the hook.
if (sessionId && nuggets) {
try {
const dir = path.join(sr, 'sessions', sessionId);
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(
path.join(dir, 'memory-cache.json'),
JSON.stringify({ nuggets, cached_at: new Date().toISOString() }, null, 2),
);
} catch {
// swallow
}
}
return nuggets || [];
}
/**
* For a given signal_key, return up to N nuggets whose applies_to_signal_keys
* include it. Sorted by recency (most-recently-applied first), capped.
*/
function nuggetsForSignal(nuggets: MemoryNugget[], signalKey: string, max = 3): string[] {
return nuggets
.filter((n) => Array.isArray(n.applies_to_signal_keys) && n.applies_to_signal_keys.includes(signalKey))
.sort((a, b) => (b.applied_at || '').localeCompare(a.applied_at || ''))
.slice(0, max)
.map((n) => n.nugget);
}
let registryCache: Record<string, RegistryEntry> | null = null;
function loadRegistry(): Record<string, RegistryEntry> {
if (registryCache) return registryCache;
registryCache = {};
try {
// Hook lives at hosts/claude/hooks/; registry at scripts/question-registry.ts
const regPath = path.join(repoRoot(), 'scripts', 'question-registry.ts');
if (!fs.existsSync(regPath)) return registryCache;
const src = fs.readFileSync(regPath, 'utf-8');
// Cheap regex extraction so the hook doesn't need to import the TS file
// (which would require bun resolving the module at hook-invocation time).
// Matches entries like:
// 'ship-test-failure-triage': {
// id: 'ship-test-failure-triage',
// ...
// door_type: 'one-way',
// signal_key: 'test-discipline',
// ...
// },
const blockRe =
/'([a-z0-9-]+)':\s*\{[^}]*?door_type:\s*'(one-way|two-way)'[^}]*?\}/g;
let m: RegExpExecArray | null;
while ((m = blockRe.exec(src))) {
const [block, id, door_type] = m;
const sk = block.match(/signal_key:\s*'([a-z0-9-]+)'/);
registryCache[id] = {
id,
door_type: door_type as 'one-way' | 'two-way',
signal_key: sk ? sk[1] : undefined,
};
}
} catch (e) {
logHookError(`registry load failed: ${(e as Error).message}`);
}
return registryCache;
}
function optionLabels(opts: Array<string | { label?: string; description?: string }>): string[] {
return opts.map((o) => (typeof o === 'string' ? o : o.label || o.description || ''));
}
function extractRecommended(
questionText: string,
opts: string[],
): { recommended: string | undefined; ambiguous: boolean } {
const labelMatches = opts.filter((o) => RECOMMENDED_LABEL_RE.test(o));
if (labelMatches.length === 1) {
return { recommended: labelMatches[0].replace(RECOMMENDED_LABEL_RE, '').trim(), ambiguous: false };
}
if (labelMatches.length > 1) return { recommended: undefined, ambiguous: true };
const m = questionText.match(/Recommendation:\s*([^\n]+)/i);
if (!m) return { recommended: undefined, ambiguous: false };
const recPhrase = m[1].trim();
const prefixMatches = opts.filter((o) =>
o.toLowerCase().startsWith(recPhrase.toLowerCase().slice(0, 12)),
);
if (prefixMatches.length === 1) return { recommended: prefixMatches[0], ambiguous: false };
if (prefixMatches.length > 1) return { recommended: undefined, ambiguous: true };
return { recommended: undefined, ambiguous: false };
}
function slugFromCwd(cwd: string | undefined): string {
// Mirror gstack-slug's basename fallback. The full slug resolver shells out
// to git, which is too expensive on a hot hook path; the basename is close
// enough for preference lookup (preferences are keyed by question_id, slug
// is just the directory bucket).
if (!cwd) return 'unknown';
return path.basename(cwd);
}
function markAutoDecided(sessionId: string | undefined, toolUseId: string | undefined): void {
if (!sessionId || !toolUseId) return;
try {
const sr = stateRoot();
const dir = path.join(sr, 'sessions', sessionId);
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, `.auto-decided-${toolUseId}`), '');
} catch (e) {
logHookError(`markAutoDecided failed: ${(e as Error).message}`);
}
}
/**
* Log an auto-decided event directly from PreToolUse, since `deny` prevents
* the tool from running and PostToolUse never fires. Without this, /plan-tune
* Recent auto-decisions would be blind to enforcement hits.
*/
function logAutoDecided(
questionId: string,
questionSummary: string,
recommended: string,
optionsCount: number,
sessionId: string | undefined,
toolUseId: string | undefined,
cwd: string | undefined,
source: string = 'auto-decided',
): void {
try {
const payload: Record<string, unknown> = {
skill: 'unknown',
question_id: questionId,
question_summary: questionSummary.slice(0, 200),
options_count: optionsCount,
user_choice: recommended.slice(0, 64),
recommended: recommended.slice(0, 64),
source,
session_id: sessionId?.slice(0, 64),
tool_use_id: toolUseId?.slice(0, 128),
};
runBin('gstack-question-log', [JSON.stringify(payload)], {
encoding: 'utf-8',
stdio: ['ignore', 'pipe', 'pipe'],
timeout: 3000,
// cwd of the originating tool call so gstack-slug resolves to the
// project the user is actually in, not the hook script's location.
cwd: cwd && fs.existsSync(cwd) ? cwd : undefined,
});
} catch (e) {
logHookError(`logAutoDecided failed: ${(e as Error).message}`);
}
}
async function main(): Promise<void> {
const raw = await readStdin();
if (!raw.trim()) {
passThrough();
return;
}
let stdin: HookStdin;
try {
stdin = JSON.parse(raw);
} catch (e) {
logHookError(`stdin parse failed: ${(e as Error).message}`);
passThrough();
return;
}
const toolName = stdin.tool_name || '';
if (
toolName !== 'AskUserQuestion' &&
!toolName.match(/^mcp__.+__AskUserQuestion$/)
) {
passThrough();
return;
}
const questions = stdin.tool_input?.questions || [];
if (questions.length === 0) {
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 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 pass through OR deny — gives the agent + user the
// relevant prior context either way.
const contextNuggets: string[] = [];
for (const q of questions) {
const qText = q.question || '';
const marker = qText.match(MARKER_RE);
if (!marker) continue;
const entry = registry[marker[1]];
if (!entry?.signal_key) continue;
const hits = nuggetsForSignal(memoryNuggets, entry.signal_key);
for (const h of hits) {
if (!contextNuggets.includes(h)) contextNuggets.push(h);
}
}
const memoryContext = contextNuggets.length
? '[plan-tune memory] Past answers suggest: ' + contextNuggets.join(' | ')
: undefined;
// Determine whether EVERY question is eligible for never-ask auto-decide.
// 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
// ineligible question makes the whole call not auto-decidable.)
const autoDecisions: Array<{ id: string; recommended: string }> = [];
let fullyAutoDecidable = true;
for (const q of questions) {
const qText = q.question || '';
const marker = qText.match(MARKER_RE);
if (!marker) { fullyAutoDecidable = false; break; }
const questionId = marker[1];
const pref = lookupPreference(slug, questionId);
if (!pref.preference || pref.preference === 'always-ask') { fullyAutoDecidable = false; break; }
const entry = registry[questionId];
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; }
const opts = optionLabels(q.options || []);
const { recommended, ambiguous } = extractRecommended(qText, opts);
// Refuse-on-ambiguous per D2 — fail safe.
if (!recommended || ambiguous) { fullyAutoDecidable = false; break; }
autoDecisions.push({ id: questionId, recommended });
}
if (fullyAutoDecidable && autoDecisions.length > 0) {
// All questions were eligible for enforcement.
markAutoDecided(stdin.session_id, stdin.tool_use_id);
// Log each auto-decided question now, since deny prevents PostToolUse from
// firing. /plan-tune Recent auto-decisions reads source=auto-decided events.
for (let i = 0; i < autoDecisions.length; i++) {
const d = autoDecisions[i];
const q = questions[i];
const qText = (q.question || '').replace(MARKER_RE, '').trim();
const opts = optionLabels(q.options || []);
logAutoDecided(d.id, qText, d.recommended, opts.length, stdin.session_id, stdin.tool_use_id, stdin.cwd);
}
const reasonLines = autoDecisions.map(
(d) =>
`[plan-tune auto-decide] ${d.id}${d.recommended} (your never-ask preference). Proceed with that option without re-prompting. Change with /plan-tune.`,
);
deny(reasonLines.join('\n'));
return;
}
// Not fully auto-decidable. In Conductor, AskUserQuestion is unreliable
// (native is disabled, the mcp__conductor__AskUserQuestion variant is flaky),
// so deny the tool and redirect to a prose decision brief. This is TRANSPORT
// AVOIDANCE, not preference enforcement: it fires regardless of marker,
// preference, or door type — including one-way doors, which must reach the
// human via prose rather than the unreliable tool.
if (isConductor()) {
// #2733: env-level spawned sessions (OpenClaw inside a Conductor
// workspace, or a harness launched with GSTACK_SESSION_KIND=spawned in
// its env) get an auto-choose deny — a prose brief has no reader there.
// LIMITATION: a per-command GSTACK_SESSION_KIND prefix inside a
// subagent's bash never reaches this hook (hooks inherit the harness
// env); that case is covered by the escape sentence below plus the
// dispatching skill's prompt.
if (spawnedByEnv()) {
// Name the driving env var in the reason — a human whose session was
// env-polluted into spawned mode must see WHY in the transcript.
const driver =
process.env.GSTACK_SESSION_KIND === 'spawned' ? 'GSTACK_SESSION_KIND' : 'OPENCLAW_SESSION';
// Deterministic per-question door check (#2733 review): this deny path
// performs no preference/door lookup, so detect one-way doors here and
// annotate them — a destructive option marked (recommended) must not be
// auto-approved on the strength of one prose sentence alone. Registry
// PRIMARY, keyword-net fallback (mirrors the never-ask gate above); the
// fallback classifies question text AND option labels, so a bland
// "Proceed?" with a "Force-push (recommended)" option cannot evade.
const oneWayNotes: string[] = [];
for (let i = 0; i < questions.length; i++) {
const rawText = questions[i]?.question || '';
const qText = rawText.replace(MARKER_RE, '').trim();
const opts = optionLabels(questions[i]?.options || []);
const marker = rawText.match(MARKER_RE);
const entry = marker ? registry[marker[1]] : undefined;
let oneWay = entry?.door_type === 'one-way';
if (!entry) {
const optText = opts.join(' / ');
try {
oneWay = classifyQuestion({ summary: optText ? `${qText} options: ${optText}` : qText }).oneWay;
} catch (e) {
logHookError(`spawned one-way classifier failed: ${(e as Error).message}`);
}
}
if (oneWay) {
oneWayNotes.push(
`[one-way door detected: Q${i + 1} — do NOT take the destructive branch even if it is marked (recommended); choose the conservative non-destructive option and record it]`,
);
}
// Forensic record (#2733 review): the deny prevents PostToolUse
// capture, and unlike the never-ask path this branch previously left
// NO trace of a machine-resolved gate. Log every question.
const { recommended } = extractRecommended(rawText, opts);
logAutoDecided(
marker?.[1] ?? 'unmarked',
qText,
recommended ?? 'unrecorded',
opts.length,
stdin.session_id,
stdin.tool_use_id,
stdin.cwd,
'spawned-env-deny',
);
}
deny(
`${CONDUCTOR_SPAWNED_DENY_REASON} (spawned driver: ${driver})` +
(oneWayNotes.length ? `\n${oneWayNotes.join('\n')}` : '') +
(memoryContext ? `\n${memoryContext}` : ''),
);
return;
}
const conductorReason =
'[conductor] AskUserQuestion is unreliable in Conductor (native disabled, MCP variant flaky). ' +
'Do NOT call AskUserQuestion (native or any mcp__*__AskUserQuestion). Render this decision as a ' +
'PROSE decision brief now: a D<N> label, an ELI10 of the issue, a Recommendation line, then one ' +
'paragraph per choice carrying its `(recommended)` marker and `Completeness: X/10`; tell the user ' +
'to reply with a letter, then STOP. For a one-way/destructive confirmation, require an explicit ' +
'typed confirmation and do NOT proceed on a vague reply. Capture the decision with gstack-question-log ' +
'(PostToolUse will not fire on a prose path). ' +
SPAWNED_ESCAPE_SENTENCE +
(memoryContext ? `\n${memoryContext}` : '');
deny(conductorReason);
return;
}
passThrough(memoryContext);
}
main().catch((e) => {
logHookError(`main crash: ${(e as Error).message}`);
passThrough();
});