From f5f7e325055aa4ace547ed0fe324f686e78a5ae0 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Thu, 11 Jun 2026 20:07:42 -0700 Subject: [PATCH 01/30] feat: add shared call-time isConductor() helper Single source of truth for Conductor host detection in TS consumers (CONDUCTOR_WORKSPACE_PATH / CONDUCTOR_PORT). Reads the passed env at call time, not a module-load snapshot, so unit tests can pin the env inline without Bun --preload (esm-hoist-breaks-env-pin-bootstrap). Co-Authored-By: Claude Fable 5 --- lib/is-conductor.ts | 19 +++++++++++++++ test/is-conductor.test.ts | 50 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+) create mode 100644 lib/is-conductor.ts create mode 100644 test/is-conductor.test.ts diff --git a/lib/is-conductor.ts b/lib/is-conductor.ts new file mode 100644 index 0000000000..3f31c88afa --- /dev/null +++ b/lib/is-conductor.ts @@ -0,0 +1,19 @@ +/** + * Conductor host detection — single source of truth for TS consumers. + * + * Conductor (the Mac app that runs many coding agents in parallel) sets + * CONDUCTOR_WORKSPACE_PATH / CONDUCTOR_PORT in the session env. The same two + * vars are what `bin/gstack-session-kind` keys on (it collapses Conductor into + * `interactive`, so it can't be reused to distinguish Conductor specifically — + * hence this dedicated helper). + * + * IMPORTANT: detection is a CALL-TIME read of the passed-in env (default + * `process.env`), never a module-load-time snapshot. ESM hoists static imports + * above any in-file `process.env.X = ...`, so a load-time read can't be pinned + * by a test without Bun --preload. Reading at call time lets unit tests set + * `process.env.CONDUCTOR_WORKSPACE_PATH` inline before invoking. See the + * `esm-hoist-breaks-env-pin-bootstrap` learning. + */ +export function isConductor(env: NodeJS.ProcessEnv = process.env): boolean { + return !!(env.CONDUCTOR_WORKSPACE_PATH || env.CONDUCTOR_PORT); +} diff --git a/test/is-conductor.test.ts b/test/is-conductor.test.ts new file mode 100644 index 0000000000..be06eabe43 --- /dev/null +++ b/test/is-conductor.test.ts @@ -0,0 +1,50 @@ +import { describe, test, expect } from 'bun:test'; +import { isConductor } from '../lib/is-conductor'; + +describe('is-conductor', () => { + test('true when CONDUCTOR_WORKSPACE_PATH is set', () => { + expect(isConductor({ CONDUCTOR_WORKSPACE_PATH: '/Users/x/conductor/ws' })).toBe(true); + }); + + test('true when CONDUCTOR_PORT is set', () => { + expect(isConductor({ CONDUCTOR_PORT: '55070' })).toBe(true); + }); + + test('true when both are set', () => { + expect(isConductor({ CONDUCTOR_WORKSPACE_PATH: '/ws', CONDUCTOR_PORT: '55070' })).toBe(true); + }); + + test('false when neither is set', () => { + expect(isConductor({ HOME: '/Users/x', PATH: '/usr/bin' })).toBe(false); + }); + + test('false on an empty env', () => { + expect(isConductor({})).toBe(false); + }); + + test('false when the vars are present but empty (Codex #1 hardening — empty != set)', () => { + expect(isConductor({ CONDUCTOR_WORKSPACE_PATH: '', CONDUCTOR_PORT: '' })).toBe(false); + }); + + test('reads the passed env at call time, not a module-load snapshot', () => { + const env: NodeJS.ProcessEnv = {}; + expect(isConductor(env)).toBe(false); + // mutate AFTER the first call — a call-time read must see the new value + env.CONDUCTOR_PORT = '55070'; + expect(isConductor(env)).toBe(true); + }); + + test('defaults to process.env when no arg is passed', () => { + const saved = process.env.CONDUCTOR_PORT; + try { + process.env.CONDUCTOR_PORT = '12345'; + expect(isConductor()).toBe(true); + delete process.env.CONDUCTOR_PORT; + // CONDUCTOR_WORKSPACE_PATH may be set in a real Conductor session; guard the assertion + if (!process.env.CONDUCTOR_WORKSPACE_PATH) expect(isConductor()).toBe(false); + } finally { + if (saved === undefined) delete process.env.CONDUCTOR_PORT; + else process.env.CONDUCTOR_PORT = saved; + } + }); +}); From ba640b2fb5a023f905ee4bc8b0ab7258d4129c38 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Thu, 11 Jun 2026 20:08:25 -0700 Subject: [PATCH 02/30] test: harden question-preference-hook harness against ambient Conductor env runHook copied all of process.env into the hook subprocess, so running the suite inside Conductor (CONDUCTOR_WORKSPACE_PATH/PORT set) would leak those markers. Strip them so the existing cases deterministically characterize NON-Conductor behavior before the Conductor branch lands. Baseline: 15 pass. Co-Authored-By: Claude Fable 5 --- test/question-preference-hook.test.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/test/question-preference-hook.test.ts b/test/question-preference-hook.test.ts index 6b06d22f43..47342c19ab 100644 --- a/test/question-preference-hook.test.ts +++ b/test/question-preference-hook.test.ts @@ -72,6 +72,12 @@ function runHook(stdin: object, cwd?: string): { } env.GSTACK_STATE_ROOT = stateRoot; delete env.GSTACK_HOME; + // Strip ambient Conductor markers so these cases characterize NON-Conductor + // behavior deterministically — otherwise running the suite inside Conductor + // (CONDUCTOR_WORKSPACE_PATH/PORT set) would flip every defer into the + // [conductor] prose deny. The Conductor cases below opt back in explicitly. + delete env.CONDUCTOR_WORKSPACE_PATH; + delete env.CONDUCTOR_PORT; env.GSTACK_QUESTION_LOG_NO_DERIVE = '1'; const res = spawnSync(HOOK, [], { env, From d74a8744e41a22b242b4769096abcd6d2c2f15bc Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Thu, 11 Jun 2026 20:10:40 -0700 Subject: [PATCH 03/30] feat: PreToolUse hook denies AskUserQuestion in Conductor, redirects to prose MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Conductor disables native AskUserQuestion and routes through a flaky MCP variant that returns '[Tool result missing due to internal error]'. The hook now denies any AUQ call in a Conductor session and instructs the model to render a prose decision brief instead (transport avoidance, not preference enforcement) — firing for one-way doors too, with a typed-confirmation requirement for destructive paths. Precedence: never-ask auto-decide still wins (user already settled those); Conductor prose is the fallback for everything else; non-Conductor behavior is byte-for-byte unchanged. Restructured the per-question loop to compute eligibility without early-returning so the Conductor branch can run as the fallback while preserving memoryContext on every exit. Co-Authored-By: Claude Fable 5 --- .../claude/hooks/question-preference-hook.ts | 87 ++++++++------ test/question-preference-hook.test.ts | 108 +++++++++++++++++- 2 files changed, 160 insertions(+), 35 deletions(-) diff --git a/hosts/claude/hooks/question-preference-hook.ts b/hosts/claude/hooks/question-preference-hook.ts index dde1bda0c9..12cbd5ea28 100644 --- a/hosts/claude/hooks/question-preference-hook.ts +++ b/hosts/claude/hooks/question-preference-hook.ts @@ -40,6 +40,7 @@ import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; import { spawnSync } from 'child_process'; +import { isConductor } from '../../../lib/is-conductor'; interface HookStdin { session_id?: string; @@ -400,57 +401,77 @@ async function main(): Promise { ? '[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 defer 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) { - defer(memoryContext); - return; - } + if (!marker) { fullyAutoDecidable = false; break; } const questionId = marker[1]; const pref = lookupPreference(slug, questionId); - if (!pref.preference || pref.preference === 'always-ask') { - defer(memoryContext); - return; - } + if (!pref.preference || pref.preference === 'always-ask') { fullyAutoDecidable = false; break; } const entry = registry[questionId]; const doorType = entry?.door_type || 'two-way'; - if (doorType === 'one-way') { - // Safety override — even never-ask doesn't bypass one-way doors. - defer(memoryContext); - return; - } + // 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); - if (!recommended || ambiguous) { - // Refuse-on-ambiguous per D2 — fail safe, ask normally. - defer(memoryContext); - return; - } + // Refuse-on-ambiguous per D2 — fail safe. + if (!recommended || ambiguous) { fullyAutoDecidable = false; break; } autoDecisions.push({ id: questionId, recommended }); } - // All questions were eligible for enforcement. - markAutoDecided(stdin.session_id, stdin.tool_use_id); + 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); + } - // 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; } - 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')); + // 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()) { + 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 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).' + + (memoryContext ? `\n${memoryContext}` : ''); + deny(conductorReason); + return; + } + + defer(memoryContext); } main().catch((e) => { diff --git a/test/question-preference-hook.test.ts b/test/question-preference-hook.test.ts index 47342c19ab..39de02f4e8 100644 --- a/test/question-preference-hook.test.ts +++ b/test/question-preference-hook.test.ts @@ -60,7 +60,7 @@ function writeGlobalPref(questionId: string, preference: string): void { fs.writeFileSync(f, JSON.stringify(prefs, null, 2)); } -function runHook(stdin: object, cwd?: string): { +function runHook(stdin: object, cwd?: string, extraEnv?: Record): { stdout: string; stderr: string; status: number; @@ -75,10 +75,12 @@ function runHook(stdin: object, cwd?: string): { // Strip ambient Conductor markers so these cases characterize NON-Conductor // behavior deterministically — otherwise running the suite inside Conductor // (CONDUCTOR_WORKSPACE_PATH/PORT set) would flip every defer into the - // [conductor] prose deny. The Conductor cases below opt back in explicitly. + // [conductor] prose deny. The Conductor cases below opt back in explicitly + // via extraEnv. delete env.CONDUCTOR_WORKSPACE_PATH; delete env.CONDUCTOR_PORT; env.GSTACK_QUESTION_LOG_NO_DERIVE = '1'; + if (extraEnv) Object.assign(env, extraEnv); const res = spawnSync(HOOK, [], { env, input: JSON.stringify({ ...stdin, cwd: cwd || fixtureCwd }), @@ -343,6 +345,108 @@ describe('MCP variant', () => { }); }); +// ---------------------------------------------------------------------- +// Conductor: deny + prose redirect (transport avoidance, not preference) +// ---------------------------------------------------------------------- + +describe('Conductor prose redirect', () => { + const CONDUCTOR = { CONDUCTOR_PORT: '55070' }; + + test('two-way, no preference → deny with [conductor] prose directive', () => { + const r = runHook({ + session_id: 'c1', + tool_name: 'AskUserQuestion', + tool_use_id: 'tu-c1', + tool_input: { + questions: [ + { question: ' Need approval?', options: ['A) Yes (recommended)', 'B) No'] }, + ], + }, + }, undefined, CONDUCTOR); + expect(r.parsed?.hookSpecificOutput?.permissionDecision).toBe('deny'); + expect(r.parsed?.hookSpecificOutput?.permissionDecisionReason).toContain('[conductor]'); + expect(r.parsed?.hookSpecificOutput?.permissionDecisionReason).toMatch(/do not call askuserquestion/i); + expect(r.parsed?.hookSpecificOutput?.permissionDecisionReason).toMatch(/reply with a letter/i); + }); + + test('UNMARKED question (modal path) → deny with prose directive', () => { + const r = runHook({ + session_id: 'c2', + tool_name: 'AskUserQuestion', + tool_use_id: 'tu-c2', + tool_input: { + questions: [ + { question: 'No marker — an ad-hoc question', options: ['A) Yes (recommended)', 'B) No'] }, + ], + }, + }, undefined, CONDUCTOR); + expect(r.parsed?.hookSpecificOutput?.permissionDecision).toBe('deny'); + expect(r.parsed?.hookSpecificOutput?.permissionDecisionReason).toContain('[conductor]'); + }); + + test('one-way door → deny with prose directive (NOT defer — destructive must reach human via prose)', () => { + const r = runHook({ + session_id: 'c3', + tool_name: 'AskUserQuestion', + tool_use_id: 'tu-c3', + tool_input: { + questions: [ + { + question: ' Tests failed.', + options: ['A) Fix now (recommended)', 'B) Investigate', 'C) Ack and ship'], + }, + ], + }, + }, undefined, CONDUCTOR); + expect(r.parsed?.hookSpecificOutput?.permissionDecision).toBe('deny'); + expect(r.parsed?.hookSpecificOutput?.permissionDecisionReason).toContain('[conductor]'); + expect(r.parsed?.hookSpecificOutput?.permissionDecisionReason).toMatch(/typed confirmation/i); + }); + + test('CONDUCTOR_WORKSPACE_PATH alone also triggers the redirect', () => { + const r = runHook({ + session_id: 'c4', + tool_name: 'mcp__conductor__AskUserQuestion', + tool_use_id: 'tu-c4', + tool_input: { + questions: [{ question: ' Pick?', options: ['A) X (recommended)', 'B) Y'] }], + }, + }, undefined, { CONDUCTOR_WORKSPACE_PATH: '/Users/x/conductor/ws' }); + expect(r.parsed?.hookSpecificOutput?.permissionDecision).toBe('deny'); + expect(r.parsed?.hookSpecificOutput?.permissionDecisionReason).toContain('[conductor]'); + }); + + test('PRECEDENCE: full never-ask auto-decide still wins over Conductor prose', () => { + writeProjectPref('ship-pre-landing-review-fix', 'never-ask'); + const r = runHook({ + session_id: 'c5', + tool_name: 'AskUserQuestion', + tool_use_id: 'tu-c5', + tool_input: { + questions: [ + { + question: ' Pre-landing review flagged issue.', + options: ['A) Fix now (recommended)', 'B) Skip'], + }, + ], + }, + }, undefined, CONDUCTOR); + expect(r.parsed?.hookSpecificOutput?.permissionDecision).toBe('deny'); + // auto-decide reason, NOT the conductor prose reason + expect(r.parsed?.hookSpecificOutput?.permissionDecisionReason).toContain('plan-tune auto-decide'); + expect(r.parsed?.hookSpecificOutput?.permissionDecisionReason).not.toContain('[conductor]'); + }); + + test('non-AUQ tool in Conductor → still defer (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'); + }); +}); + // ---------------------------------------------------------------------- // Auto-decided event logging (since PostToolUse never fires on deny) // ---------------------------------------------------------------------- From 752865f86661ac2507ed55ddfa973a826c86a0b2 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Thu, 11 Jun 2026 20:15:29 -0700 Subject: [PATCH 04/30] feat: Conductor renders AskUserQuestion decisions as prose by default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In Conductor, native AskUserQuestion is disabled and the MCP variant is flaky, so skills now render every decision as a plain-text prose brief the user answers by typing a letter — proactively, not as a failure reaction. - Preamble emits CONDUCTOR_SESSION, gated on != headless so eval/CI inside Conductor still BLOCKs instead of rendering prose to nobody. - AskUserQuestion Format gains a Conductor-default-prose rule (auto-decide preferences still apply first; prose decisions log via gstack-question-log since PostToolUse never fires), a one-way/destructive typed-confirmation rule, and a typed-reply continuation protocol for split chains. - Regenerated all SKILL.md + ship golden fixtures; bumped affected carve skeleton caps to absorb the always-loaded additions. Co-Authored-By: Claude Fable 5 --- SKILL.md | 7 ++++ autoplan/SKILL.md | 19 ++++++++-- benchmark-models/SKILL.md | 7 ++++ benchmark/SKILL.md | 7 ++++ browse/SKILL.md | 7 ++++ canary/SKILL.md | 19 ++++++++-- codex/SKILL.md | 19 ++++++++-- context-restore/SKILL.md | 19 ++++++++-- context-save/SKILL.md | 19 ++++++++-- cso/SKILL.md | 19 ++++++++-- design-consultation/SKILL.md | 19 ++++++++-- design-html/SKILL.md | 19 ++++++++-- design-review/SKILL.md | 19 ++++++++-- design-shotgun/SKILL.md | 19 ++++++++-- devex-review/SKILL.md | 19 ++++++++-- document-generate/SKILL.md | 19 ++++++++-- document-release/SKILL.md | 19 ++++++++-- health/SKILL.md | 19 ++++++++-- investigate/SKILL.md | 19 ++++++++-- ios-clean/SKILL.md | 19 ++++++++-- ios-design-review/SKILL.md | 19 ++++++++-- ios-fix/SKILL.md | 19 ++++++++-- ios-qa/SKILL.md | 19 ++++++++-- ios-sync/SKILL.md | 19 ++++++++-- land-and-deploy/SKILL.md | 19 ++++++++-- landing-report/SKILL.md | 19 ++++++++-- learn/SKILL.md | 19 ++++++++-- make-pdf/SKILL.md | 7 ++++ office-hours/SKILL.md | 19 ++++++++-- open-gstack-browser/SKILL.md | 19 ++++++++-- pair-agent/SKILL.md | 19 ++++++++-- plan-ceo-review/SKILL.md | 19 ++++++++-- plan-design-review/SKILL.md | 19 ++++++++-- plan-devex-review/SKILL.md | 19 ++++++++-- plan-eng-review/SKILL.md | 19 ++++++++-- plan-tune/SKILL.md | 19 ++++++++-- qa-only/SKILL.md | 19 ++++++++-- qa/SKILL.md | 19 ++++++++-- retro/SKILL.md | 19 ++++++++-- review/SKILL.md | 19 ++++++++-- scrape/SKILL.md | 19 ++++++++-- .../preamble/generate-ask-user-format.ts | 12 ++++-- .../preamble/generate-preamble-bash.ts | 7 ++++ setup-browser-cookies/SKILL.md | 7 ++++ setup-deploy/SKILL.md | 19 ++++++++-- setup-gbrain/SKILL.md | 19 ++++++++-- ship/SKILL.md | 19 ++++++++-- skillify/SKILL.md | 19 ++++++++-- spec/SKILL.md | 38 ++++++++++++++++--- sync-gbrain/SKILL.md | 19 ++++++++-- test/fixtures/golden/claude-ship-SKILL.md | 19 ++++++++-- test/fixtures/golden/codex-ship-SKILL.md | 19 ++++++++-- test/fixtures/golden/factory-ship-SKILL.md | 19 ++++++++-- test/helpers/carve-guards.ts | 20 +++++++--- test/preamble-compose.test.ts | 10 +++++ test/resolver-ask-user-format.test.ts | 21 +++++++++- 56 files changed, 838 insertions(+), 148 deletions(-) diff --git a/SKILL.md b/SKILL.md index 8711ae7f3b..90774950eb 100644 --- a/SKILL.md +++ b/SKILL.md @@ -48,6 +48,13 @@ echo "REPO_MODE: $REPO_MODE" _SESSION_KIND=$(~/.claude/skills/gstack/bin/gstack-session-kind 2>/dev/null || echo "interactive") case "$_SESSION_KIND" in spawned|headless|interactive) ;; *) _SESSION_KIND="interactive" ;; esac echo "SESSION_KIND: $_SESSION_KIND" +# Conductor host: AskUserQuestion is unreliable here (native disabled, MCP +# variant flaky), so skills render decisions as prose instead of calling the +# tool. Gated on !headless so an eval/CI run INSIDE Conductor (GSTACK_HEADLESS) +# still BLOCKs rather than rendering prose to nobody. +if [ "$_SESSION_KIND" != "headless" ] && { [ -n "${CONDUCTOR_WORKSPACE_PATH:-}" ] || [ -n "${CONDUCTOR_PORT:-}" ]; }; then + echo "CONDUCTOR_SESSION: true" +fi _LAKE_SEEN=$([ -f ~/.gstack/.completeness-intro-seen ] && echo "yes" || echo "no") echo "LAKE_INTRO: $_LAKE_SEEN" _TEL=$(~/.claude/skills/gstack/bin/gstack-config get telemetry 2>/dev/null || true) diff --git a/autoplan/SKILL.md b/autoplan/SKILL.md index de7174b03e..49db38ff90 100644 --- a/autoplan/SKILL.md +++ b/autoplan/SKILL.md @@ -57,6 +57,13 @@ echo "REPO_MODE: $REPO_MODE" _SESSION_KIND=$(~/.claude/skills/gstack/bin/gstack-session-kind 2>/dev/null || echo "interactive") case "$_SESSION_KIND" in spawned|headless|interactive) ;; *) _SESSION_KIND="interactive" ;; esac echo "SESSION_KIND: $_SESSION_KIND" +# Conductor host: AskUserQuestion is unreliable here (native disabled, MCP +# variant flaky), so skills render decisions as prose instead of calling the +# tool. Gated on !headless so an eval/CI run INSIDE Conductor (GSTACK_HEADLESS) +# still BLOCKs rather than rendering prose to nobody. +if [ "$_SESSION_KIND" != "headless" ] && { [ -n "${CONDUCTOR_WORKSPACE_PATH:-}" ] || [ -n "${CONDUCTOR_PORT:-}" ]; }; then + echo "CONDUCTOR_SESSION: true" +fi _LAKE_SEEN=$([ -f ~/.gstack/.completeness-intro-seen ] && echo "yes" || echo "no") echo "LAKE_INTRO: $_LAKE_SEEN" _TEL=$(~/.claude/skills/gstack/bin/gstack-config get telemetry 2>/dev/null || true) @@ -306,7 +313,9 @@ AI orchestrator (e.g., OpenClaw). In spawned sessions: "AskUserQuestion" can resolve to two tools at runtime: the **host MCP variant** (e.g. `mcp__conductor__AskUserQuestion` — appears in your tool list when the host registers it) or the **native** Claude Code tool. -**Rule:** if any `mcp__*__AskUserQuestion` variant is in your tool list, prefer it. Hosts may disable native AUQ via `--disallowedTools AskUserQuestion` (Conductor does, by default) and route through their MCP variant; calling native there silently fails. Same questions/options shape; same decision-brief format applies. +**Conductor rule (read before the MCP rule):** if `CONDUCTOR_SESSION: true` was echoed by the preamble, do NOT call AskUserQuestion at all — neither native nor any `mcp__*__AskUserQuestion` variant. Render EVERY decision brief as the **prose form** below and STOP. This is proactive, not a reaction to a failure: Conductor disables native AUQ and its MCP variant is flaky (it returns `[Tool result missing due to internal error]`), so prose is the reliable path. **Auto-decide preferences still apply first:** if a `[plan-tune auto-decide]