v1.57.2.0 feat: AskUserQuestion prose fallback when the tool fails at runtime (#1908)

* feat(auq): add gstack-session-kind + echo SESSION_KIND in preamble

Classifies the session as spawned | headless | interactive from env markers
(OPENCLAW_SESSION / GSTACK_HEADLESS / CONDUCTOR_* / CLAUDE_CODE_ENTRYPOINT / CI),
defaulting to interactive. Echoed once at skill start alongside BRANCH/REPO_MODE
so the AskUserQuestion-failure fallback can branch without a shell-out at failure
time. Degrade-safe: empty/error => interactive.

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

* feat(auq): prose fallback when AskUserQuestion fails (interactive sessions)

On a genuine AUQ failure (tool absent, or present-but-erroring like Conductor's
flaky MCP returning '[Tool result missing due to internal error]'): retry once,
then branch on SESSION_KIND — spawned auto-chooses, headless BLOCKs, interactive
renders a prose decision brief the user answers by typing a letter.

The prose fallback MUST surface the triad: a clear ELI10 of the issue, a
per-choice Completeness score, and a recommendation+why (one paragraph per
choice). Carves out the [plan-tune auto-decide] denial as NOT a failure, and
qualifies the former 'tool_use, not prose' assertions so the rule isn't
self-contradicting. Tests pin the triad, the SESSION_KIND branch, the OV2
collision guard, the always-loaded guarantee, and a cross-file invariant on the
auto-decide prefix.

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

* test(auq): default GSTACK_HEADLESS=1 in eval/E2E runners

Headless harness runs classify as headless (BLOCK on AUQ failure rather than
emit a prose question no one reads). SDK runner uses ambient mutation, not the
Options.env object, to avoid breaking the SDK auth pipeline. Interactive-path
suites opt out by overriding the env per-run.

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

* feat(auq): defensive PostToolUse error-fallback hook (OV3:B)

When an AskUserQuestion call returns an error/missing result, this hook injects
additionalContext reminding the model to run the prose fallback for the current
SESSION_KIND. It does not render prose itself — it guarantees the reminder fires
at the moment of failure instead of relying on the model recalling SESSION_KIND.

Inert on success and inert if the platform never invokes PostToolUse on tool
errors (unverified — could not force the Conductor MCP error in a harness; see
the spike doc). The prompt-level fallback covers the case regardless. Decision
logic is unit-tested deterministically; registered in setup beside the existing
AUQ hooks.

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

* chore(auq): regenerate SKILL.md for all hosts + refresh ship goldens

Regenerated from the resolver changes (gen:skill-docs --host all). Refreshes the
byte-exact ship golden fixtures (claude/codex/factory). Spec prose tightened so
the cross-cutting preamble addition stays under the 5% per-skill parity ceiling
(investigate 4.8%) — guard unchanged.

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

* fix(test): kebab testNames for section-loading E2Es to match TOUCHFILES keys

The two section-loading E2E tests used display-form testNames ('/ship
section-loading', '/plan-ceo-review section-loading') while every other E2E
testName and their E2E_TOUCHFILES keys are kebab. The completeness gate does an
exact `name in E2E_TOUCHFILES` check, so it failed (pre-existing on main); diff-
based selection also couldn't match them. Align to ship-section-loading /
plan-ceo-section-loading.

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

* fix(test): make external-host freshness checks deterministic

The parameterized host smoke + --host all freshness tests assumed an external
`gen:skill-docs --host all` had run first (it never does in `bun test`), so which
host reported STALE varied by sibling-test timing — flaky. Regenerate the
gitignored external host dirs in a beforeAll so the --dry-run check is
deterministic. It still catches non-deterministic generation (the real bug class
for regenerated outputs); the tracked-claude freshness test runs earlier and is
unaffected.

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

* test(parity): headroom for AUQ cross-cutting addition on carved document-release

Merging main brought the carve of document-release (smaller skeleton); the AUQ
prose-fallback adds ~2KB to every skill's always-loaded preamble, landing
document-release at ~5.9% over the pre-carve v1.53.0.0 baseline. Add a per-carve
maxSizeRatio override (CARVE_GUARDS single source of truth) and bump only this
skill to 1.08. All other skills keep the strict 1.05 ceiling.

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

* fix(auq): harden error-fallback hook + harness per adversarial review

Codex pre-landing review found three real issues:
- The PostToolUse fallback hook shared source 'plan-tune-cathedral' with the
  question-log hook (same event+matcher); gstack-settings-hook replaces the entry,
  so it would have clobbered plan-tune capture. Give it its own 'auq-error-fallback'
  source (separate entry, both run); ALREADY_INSTALLED now requires both sources.
- isErrorResponse triggered on any string containing 'internal error'/'is_error',
  so a real answer or a {"is_error": false} payload could fire the fallback after a
  successful question. Narrow it to the missing-result sentinel + boolean is_error.
- The SDK runner mutated process.env.GSTACK_HEADLESS process-wide (leaked headless
  into later tests). Removed; GSTACK_HEADLESS=1 now lives in the eval package.json
  scripts, scoped to the invocation and inherited by the SDK child.

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

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

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-06-07 21:38:21 -07:00
committed by GitHub
parent e722c5bf89
commit 4dfdb7cdc2
72 changed files with 2035 additions and 211 deletions
+131
View File
@@ -0,0 +1,131 @@
/**
* auq-error-fallback-hook — the OV3:B runtime reliability layer.
*
* Two layers of testing:
* - PURE functions (isErrorResponse, directiveFor): deterministic, the core logic.
* - INTEGRATION: spawn the hook as a PostToolUse process with synthetic stdin and
* a controlled env, assert it injects the right directive on an error result and
* stays inert on a real answer.
*
* NOTE: whether the Claude Code PLATFORM invokes PostToolUse on an MCP
* transport/missing-result error is unverified (could not force the Conductor
* bug in a harness — see docs/spikes/claude-code-hook-mutation.md). These tests
* pin the hook's BEHAVIOR given it is invoked; the platform trigger is the
* documented residual risk. The hook is inert if never invoked.
*/
import { describe, test, expect } from 'bun:test';
import { spawnSync } from 'child_process';
import * as path from 'path';
import { isErrorResponse, directiveFor } from '../hosts/claude/hooks/auq-error-fallback-hook.ts';
const HOOK = path.resolve(__dirname, '..', 'hosts', 'claude', 'hooks', 'auq-error-fallback-hook.ts');
describe('isErrorResponse — only clear failures, never a real answer', () => {
test('null / undefined / empty string are failures', () => {
expect(isErrorResponse(null)).toBe(true);
expect(isErrorResponse(undefined)).toBe(true);
expect(isErrorResponse('')).toBe(true);
expect(isErrorResponse(' ')).toBe(true);
});
test('the Conductor missing-result string is a failure', () => {
expect(isErrorResponse('[Tool result missing due to internal error]')).toBe(true);
});
test('is_error: true / error-field / sentinel-in-content are failures', () => {
expect(isErrorResponse({ is_error: true })).toBe(true);
expect(isErrorResponse({ isError: true })).toBe(true);
expect(isErrorResponse({ error: 'boom' })).toBe(true);
expect(isErrorResponse({ content: 'Tool result missing due to internal error' })).toBe(true);
});
test('a real answer is NOT a failure (no false trigger)', () => {
expect(isErrorResponse({ answers: [{ option_label: 'A' }] })).toBe(false);
expect(isErrorResponse('A')).toBe(false);
// a choice that coincidentally contains "error" must not trip it
expect(isErrorResponse({ answers: [{ option_label: 'Fix the error' }] })).toBe(false);
expect(isErrorResponse('Investigate the login error')).toBe(false);
});
test('Codex review: narrow detection — generic "error"/"is_error" substrings do NOT trigger', () => {
// A real answer mentioning "internal error" must not be read as a failure.
expect(isErrorResponse('Investigate the internal error')).toBe(false);
// A serialized success payload containing the substring is_error:false must not trigger.
expect(isErrorResponse('{"is_error": false, "answer": "A"}')).toBe(false);
expect(isErrorResponse({ is_error: false })).toBe(false);
expect(isErrorResponse({ content: 'The page had an internal error we fixed' })).toBe(false);
});
});
describe('directiveFor — per-session-kind instruction', () => {
test('interactive directive demands the prose triad', () => {
const d = directiveFor('interactive');
expect(d).toMatch(/ELI10/);
expect(d).toMatch(/Completeness: X\/10/);
expect(d).toMatch(/\(recommended\)/);
expect(d).toMatch(/reply with a letter/i);
expect(d).toMatch(/STOP/);
});
test('headless directive BLOCKs', () => {
expect(directiveFor('headless')).toMatch(/BLOCKED — AskUserQuestion unavailable/);
});
test('spawned directive auto-chooses', () => {
expect(directiveFor('spawned')).toMatch(/auto-choose/i);
});
});
/** Spawn the hook with synthetic stdin + controlled env; parse its JSON stdout. */
function runHook(stdin: object, env: Record<string, string>): { additionalContext?: string } {
const res = spawnSync('bun', [HOOK], {
input: JSON.stringify(stdin),
encoding: 'utf-8',
env: { PATH: process.env.PATH ?? '/usr/bin:/bin', ...env },
});
const parsed = JSON.parse(res.stdout || '{}');
return parsed.hookSpecificOutput ?? {};
}
describe('hook integration — invoked as PostToolUse', () => {
test('error result + headless env → injects BLOCK directive', () => {
const out = runHook(
{ tool_name: 'mcp__conductor__AskUserQuestion', tool_response: '[Tool result missing due to internal error]' },
{ GSTACK_HEADLESS: '1' },
);
expect(out.additionalContext).toMatch(/BLOCKED — AskUserQuestion unavailable/);
});
test('error result + interactive env → injects prose-triad directive', () => {
const out = runHook(
{ tool_name: 'AskUserQuestion', tool_response: null },
{ CONDUCTOR_PORT: '55010' },
);
expect(out.additionalContext).toMatch(/render the decision as a PROSE message/i);
expect(out.additionalContext).toMatch(/Completeness: X\/10/);
});
test('error result + spawned env → injects auto-choose directive', () => {
const out = runHook(
{ tool_name: 'AskUserQuestion', tool_response: { is_error: true } },
{ OPENCLAW_SESSION: '1' },
);
expect(out.additionalContext).toMatch(/auto-choose/i);
});
test('SUCCESSFUL answer → no injection (inert on real answers)', () => {
const out = runHook(
{ tool_name: 'AskUserQuestion', tool_response: { answers: [{ option_label: 'A' }] } },
{ GSTACK_HEADLESS: '1' },
);
expect(out.additionalContext).toBeUndefined();
});
test('non-AUQ tool → defers (no injection)', () => {
const out = runHook(
{ tool_name: 'Bash', tool_response: null },
{ GSTACK_HEADLESS: '1' },
);
expect(out.additionalContext).toBeUndefined();
});
});
+5
View File
@@ -47,6 +47,11 @@ const MANDATORY: Array<{ name: string; re: RegExp }> = [
{ name: 'Completeness coverage rule', re: /Completeness\s*:/i },
{ name: 'kind-vs-coverage rule', re: /options differ in kind/i },
{ name: 'Self-check checklist', re: /Self-check before emitting/i },
// The runtime-failure fallback must be ALWAYS-LOADED too: when an AUQ call errors
// mid-skill, the model needs the prose-fallback rule in context that instant, not
// stranded in an on-demand section. Same guarantee as the format spec above.
{ name: 'AUQ-failure fallback subsection', re: /When AskUserQuestion is unavailable or a call fails/i },
{ name: 'fallback SESSION_KIND branch', re: /SESSION_KIND/ },
];
/** Per-skill AUQ rules that govern review-finding cadence. A carve may move
+27 -4
View File
@@ -50,6 +50,9 @@ echo "SKILL_PREFIX: $_SKILL_PREFIX"
source <(~/.claude/skills/gstack/bin/gstack-repo-mode 2>/dev/null) || true
REPO_MODE=${REPO_MODE:-unknown}
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"
_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)
@@ -129,7 +132,7 @@ In plan mode, allowed because they inform the plan: `$B`, `$D`, `codex exec`/`co
## Skill Invocation During Plan Mode
If the user invokes a skill in plan mode, the skill takes precedence over generic plan mode behavior. **Treat the skill file as executable instructions, not reference.** Follow it step by step starting from Step 0; the first AskUserQuestion is the workflow entering plan mode, not a violation of it. AskUserQuestion (any variant — `mcp__*__AskUserQuestion` or native; see "AskUserQuestion Format → Tool resolution") satisfies plan mode's end-of-turn requirement. If no variant is callable, the skill is BLOCKED — stop and report `BLOCKED — AskUserQuestion unavailable` per the AskUserQuestion Format rule. At a STOP point, stop immediately. Do not continue the workflow or call ExitPlanMode there. Commands marked "PLAN MODE EXCEPTION — ALWAYS RUN" execute. Call ExitPlanMode only after the skill workflow completes, or if the user tells you to cancel the skill or leave plan mode.
If the user invokes a skill in plan mode, the skill takes precedence over generic plan mode behavior. **Treat the skill file as executable instructions, not reference.** Follow it step by step starting from Step 0; the first AskUserQuestion is the workflow entering plan mode, not a violation of it. AskUserQuestion (any variant — `mcp__*__AskUserQuestion` or native; see "AskUserQuestion Format → Tool resolution") satisfies plan mode's end-of-turn requirement. If AskUserQuestion is unavailable or a call fails, follow the AskUserQuestion Format failure fallback: `headless` → BLOCKED; `interactive` → the prose fallback (also satisfies end-of-turn). At a STOP point, stop immediately. Do not continue the workflow or call ExitPlanMode there. Commands marked "PLAN MODE EXCEPTION — ALWAYS RUN" execute. Call ExitPlanMode only after the skill workflow completes, or if the user tells you to cancel the skill or leave plan mode.
If `PROACTIVE` is `"false"`, do not auto-invoke or proactively suggest skills. If a skill seems useful, ask: "I think /skillname might help here — want me to run it?"
@@ -301,11 +304,31 @@ AI orchestrator (e.g., OpenClaw). In spawned sessions:
**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.
**If no AskUserQuestion variant appears in your tool list, this skill is BLOCKED.** Stop, report `BLOCKED — AskUserQuestion unavailable`, and wait for the user. Do not write decisions to the plan file as a substitute, do not emit them as prose and stop, and do not silently auto-decide (only `/plan-tune` AUTO_DECIDE opt-ins authorize auto-picking).
If AskUserQuestion is unavailable (no variant in your tool list) OR a call to it fails, do NOT silently auto-decide or write the decision to the plan file as a substitute. Follow the **failure fallback** below.
### When AskUserQuestion is unavailable or a call fails
Tell three outcomes apart:
1. **Auto-decide denial (NOT a failure).** The result contains `[plan-tune auto-decide] <id> → <option>` — the preference hook working as designed. Proceed with that option. Do NOT retry, do NOT fall back to prose.
2. **Genuine failure** — no variant in your tool list, OR the variant is present but the call returns an error / missing result (MCP transport error, empty result, host bug — e.g. Conductor's MCP AskUserQuestion is flaky and returns `[Tool result missing due to internal error]`).
- If it was present and **errored** (not absent), retry the SAME call **once** — but only if no answer could have surfaced (a missing-result error can arrive after the user already saw the question; retrying would double-prompt, so if it may have reached them, treat as pending, don't retry).
- Then branch on `SESSION_KIND` (echoed by the preamble; empty/absent ⇒ `interactive`):
- `spawned` → defer to the **Spawned session** block: auto-choose the recommended option. Never prose, never BLOCKED.
- `headless``BLOCKED — AskUserQuestion unavailable`; stop and wait (no human can answer).
- `interactive`**prose fallback** (below).
**Prose fallback — render the decision brief as a markdown message, not a tool call.** Same information as the tool format below, different structure (paragraphs, not ✅/❌ bullets). It MUST surface this triad:
1. **A clear ELI10 of the issue itself** — plain English on what's being decided and why it matters (the question, not per-choice), naming the stakes. Lead with it.
2. **Completeness scores per choice** — explicit `Completeness: X/10` on EACH choice (10 complete, 7 happy-path, 3 shortcut); use the kind-note when options differ in kind not coverage, but never silently drop the score.
3. **The recommendation and why** — a `Recommendation: <choice> because <reason>` line plus the `(recommended)` marker on that choice.
Layout: a `D<N>` title + a one-line note that AskUserQuestion failed and to reply with a letter; the issue ELI10; the Recommendation line; then ONE paragraph per choice carrying its `(recommended)` marker, its `Completeness: X/10`, and 2-4 sentences of reasoning — never a bare bullet list; a closing `Net:` line. Split chains / 5+ options: one prose block per per-option call, in sequence. Then STOP and wait — the user's typed answer is the decision. In plan mode this satisfies end-of-turn like a tool call.
### Format
Every AskUserQuestion is a decision brief and must be sent as tool_use, not prose.
Every AskUserQuestion is a decision brief and must be sent as tool_use, not prose — unless the documented failure fallback above applies (interactive session + the call is unavailable/erroring), in which case the prose fallback is the correct output.
```
D<N> — <one-line question title>
@@ -385,7 +408,7 @@ Before calling AskUserQuestion, verify:
- [ ] (recommended) label on one option (even for neutral-posture)
- [ ] Dual-scale effort labels on effort-bearing options (human / CC)
- [ ] Net line closes the decision
- [ ] You are calling the tool, not writing prose
- [ ] You are calling the tool, not writing prose — unless the documented failure fallback applies (then: prose with the mandatory triad — issue ELI10, per-choice Completeness, Recommendation + `(recommended)` — and a "reply with a letter" instruction, then STOP)
- [ ] Non-ASCII characters (CJK / accents) written directly, NOT \u-escaped
- [ ] If you had 5+ options, you split (or batched into ≤4-groups) — did NOT drop any
- [ ] If you split, you checked dependencies between options before firing the chain
+27 -4
View File
@@ -36,6 +36,9 @@ echo "SKILL_PREFIX: $_SKILL_PREFIX"
source <($GSTACK_BIN/gstack-repo-mode 2>/dev/null) || true
REPO_MODE=${REPO_MODE:-unknown}
echo "REPO_MODE: $REPO_MODE"
_SESSION_KIND=$($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"
_LAKE_SEEN=$([ -f ~/.gstack/.completeness-intro-seen ] && echo "yes" || echo "no")
echo "LAKE_INTRO: $_LAKE_SEEN"
_TEL=$($GSTACK_BIN/gstack-config get telemetry 2>/dev/null || true)
@@ -115,7 +118,7 @@ In plan mode, allowed because they inform the plan: `$B`, `$D`, `codex exec`/`co
## Skill Invocation During Plan Mode
If the user invokes a skill in plan mode, the skill takes precedence over generic plan mode behavior. **Treat the skill file as executable instructions, not reference.** Follow it step by step starting from Step 0; the first AskUserQuestion is the workflow entering plan mode, not a violation of it. AskUserQuestion (any variant — `mcp__*__AskUserQuestion` or native; see "AskUserQuestion Format → Tool resolution") satisfies plan mode's end-of-turn requirement. If no variant is callable, the skill is BLOCKED — stop and report `BLOCKED — AskUserQuestion unavailable` per the AskUserQuestion Format rule. At a STOP point, stop immediately. Do not continue the workflow or call ExitPlanMode there. Commands marked "PLAN MODE EXCEPTION — ALWAYS RUN" execute. Call ExitPlanMode only after the skill workflow completes, or if the user tells you to cancel the skill or leave plan mode.
If the user invokes a skill in plan mode, the skill takes precedence over generic plan mode behavior. **Treat the skill file as executable instructions, not reference.** Follow it step by step starting from Step 0; the first AskUserQuestion is the workflow entering plan mode, not a violation of it. AskUserQuestion (any variant — `mcp__*__AskUserQuestion` or native; see "AskUserQuestion Format → Tool resolution") satisfies plan mode's end-of-turn requirement. If AskUserQuestion is unavailable or a call fails, follow the AskUserQuestion Format failure fallback: `headless` → BLOCKED; `interactive` → the prose fallback (also satisfies end-of-turn). At a STOP point, stop immediately. Do not continue the workflow or call ExitPlanMode there. Commands marked "PLAN MODE EXCEPTION — ALWAYS RUN" execute. Call ExitPlanMode only after the skill workflow completes, or if the user tells you to cancel the skill or leave plan mode.
If `PROACTIVE` is `"false"`, do not auto-invoke or proactively suggest skills. If a skill seems useful, ask: "I think /skillname might help here — want me to run it?"
@@ -287,11 +290,31 @@ AI orchestrator (e.g., OpenClaw). In spawned sessions:
**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.
**If no AskUserQuestion variant appears in your tool list, this skill is BLOCKED.** Stop, report `BLOCKED — AskUserQuestion unavailable`, and wait for the user. Do not write decisions to the plan file as a substitute, do not emit them as prose and stop, and do not silently auto-decide (only `/plan-tune` AUTO_DECIDE opt-ins authorize auto-picking).
If AskUserQuestion is unavailable (no variant in your tool list) OR a call to it fails, do NOT silently auto-decide or write the decision to the plan file as a substitute. Follow the **failure fallback** below.
### When AskUserQuestion is unavailable or a call fails
Tell three outcomes apart:
1. **Auto-decide denial (NOT a failure).** The result contains `[plan-tune auto-decide] <id> → <option>` — the preference hook working as designed. Proceed with that option. Do NOT retry, do NOT fall back to prose.
2. **Genuine failure** — no variant in your tool list, OR the variant is present but the call returns an error / missing result (MCP transport error, empty result, host bug — e.g. Conductor's MCP AskUserQuestion is flaky and returns `[Tool result missing due to internal error]`).
- If it was present and **errored** (not absent), retry the SAME call **once** — but only if no answer could have surfaced (a missing-result error can arrive after the user already saw the question; retrying would double-prompt, so if it may have reached them, treat as pending, don't retry).
- Then branch on `SESSION_KIND` (echoed by the preamble; empty/absent ⇒ `interactive`):
- `spawned` → defer to the **Spawned session** block: auto-choose the recommended option. Never prose, never BLOCKED.
- `headless``BLOCKED — AskUserQuestion unavailable`; stop and wait (no human can answer).
- `interactive`**prose fallback** (below).
**Prose fallback — render the decision brief as a markdown message, not a tool call.** Same information as the tool format below, different structure (paragraphs, not ✅/❌ bullets). It MUST surface this triad:
1. **A clear ELI10 of the issue itself** — plain English on what's being decided and why it matters (the question, not per-choice), naming the stakes. Lead with it.
2. **Completeness scores per choice** — explicit `Completeness: X/10` on EACH choice (10 complete, 7 happy-path, 3 shortcut); use the kind-note when options differ in kind not coverage, but never silently drop the score.
3. **The recommendation and why** — a `Recommendation: <choice> because <reason>` line plus the `(recommended)` marker on that choice.
Layout: a `D<N>` title + a one-line note that AskUserQuestion failed and to reply with a letter; the issue ELI10; the Recommendation line; then ONE paragraph per choice carrying its `(recommended)` marker, its `Completeness: X/10`, and 2-4 sentences of reasoning — never a bare bullet list; a closing `Net:` line. Split chains / 5+ options: one prose block per per-option call, in sequence. Then STOP and wait — the user's typed answer is the decision. In plan mode this satisfies end-of-turn like a tool call.
### Format
Every AskUserQuestion is a decision brief and must be sent as tool_use, not prose.
Every AskUserQuestion is a decision brief and must be sent as tool_use, not prose — unless the documented failure fallback above applies (interactive session + the call is unavailable/erroring), in which case the prose fallback is the correct output.
```
D<N> — <one-line question title>
@@ -371,7 +394,7 @@ Before calling AskUserQuestion, verify:
- [ ] (recommended) label on one option (even for neutral-posture)
- [ ] Dual-scale effort labels on effort-bearing options (human / CC)
- [ ] Net line closes the decision
- [ ] You are calling the tool, not writing prose
- [ ] You are calling the tool, not writing prose — unless the documented failure fallback applies (then: prose with the mandatory triad — issue ELI10, per-choice Completeness, Recommendation + `(recommended)` — and a "reply with a letter" instruction, then STOP)
- [ ] Non-ASCII characters (CJK / accents) written directly, NOT \u-escaped
- [ ] If you had 5+ options, you split (or batched into ≤4-groups) — did NOT drop any
- [ ] If you split, you checked dependencies between options before firing the chain
+27 -4
View File
@@ -38,6 +38,9 @@ echo "SKILL_PREFIX: $_SKILL_PREFIX"
source <($GSTACK_BIN/gstack-repo-mode 2>/dev/null) || true
REPO_MODE=${REPO_MODE:-unknown}
echo "REPO_MODE: $REPO_MODE"
_SESSION_KIND=$($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"
_LAKE_SEEN=$([ -f ~/.gstack/.completeness-intro-seen ] && echo "yes" || echo "no")
echo "LAKE_INTRO: $_LAKE_SEEN"
_TEL=$($GSTACK_BIN/gstack-config get telemetry 2>/dev/null || true)
@@ -117,7 +120,7 @@ In plan mode, allowed because they inform the plan: `$B`, `$D`, `codex exec`/`co
## Skill Invocation During Plan Mode
If the user invokes a skill in plan mode, the skill takes precedence over generic plan mode behavior. **Treat the skill file as executable instructions, not reference.** Follow it step by step starting from Step 0; the first AskUserQuestion is the workflow entering plan mode, not a violation of it. AskUserQuestion (any variant — `mcp__*__AskUserQuestion` or native; see "AskUserQuestion Format → Tool resolution") satisfies plan mode's end-of-turn requirement. If no variant is callable, the skill is BLOCKED — stop and report `BLOCKED — AskUserQuestion unavailable` per the AskUserQuestion Format rule. At a STOP point, stop immediately. Do not continue the workflow or call ExitPlanMode there. Commands marked "PLAN MODE EXCEPTION — ALWAYS RUN" execute. Call ExitPlanMode only after the skill workflow completes, or if the user tells you to cancel the skill or leave plan mode.
If the user invokes a skill in plan mode, the skill takes precedence over generic plan mode behavior. **Treat the skill file as executable instructions, not reference.** Follow it step by step starting from Step 0; the first AskUserQuestion is the workflow entering plan mode, not a violation of it. AskUserQuestion (any variant — `mcp__*__AskUserQuestion` or native; see "AskUserQuestion Format → Tool resolution") satisfies plan mode's end-of-turn requirement. If AskUserQuestion is unavailable or a call fails, follow the AskUserQuestion Format failure fallback: `headless` → BLOCKED; `interactive` → the prose fallback (also satisfies end-of-turn). At a STOP point, stop immediately. Do not continue the workflow or call ExitPlanMode there. Commands marked "PLAN MODE EXCEPTION — ALWAYS RUN" execute. Call ExitPlanMode only after the skill workflow completes, or if the user tells you to cancel the skill or leave plan mode.
If `PROACTIVE` is `"false"`, do not auto-invoke or proactively suggest skills. If a skill seems useful, ask: "I think /skillname might help here — want me to run it?"
@@ -289,11 +292,31 @@ AI orchestrator (e.g., OpenClaw). In spawned sessions:
**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.
**If no AskUserQuestion variant appears in your tool list, this skill is BLOCKED.** Stop, report `BLOCKED — AskUserQuestion unavailable`, and wait for the user. Do not write decisions to the plan file as a substitute, do not emit them as prose and stop, and do not silently auto-decide (only `/plan-tune` AUTO_DECIDE opt-ins authorize auto-picking).
If AskUserQuestion is unavailable (no variant in your tool list) OR a call to it fails, do NOT silently auto-decide or write the decision to the plan file as a substitute. Follow the **failure fallback** below.
### When AskUserQuestion is unavailable or a call fails
Tell three outcomes apart:
1. **Auto-decide denial (NOT a failure).** The result contains `[plan-tune auto-decide] <id> → <option>` — the preference hook working as designed. Proceed with that option. Do NOT retry, do NOT fall back to prose.
2. **Genuine failure** — no variant in your tool list, OR the variant is present but the call returns an error / missing result (MCP transport error, empty result, host bug — e.g. Conductor's MCP AskUserQuestion is flaky and returns `[Tool result missing due to internal error]`).
- If it was present and **errored** (not absent), retry the SAME call **once** — but only if no answer could have surfaced (a missing-result error can arrive after the user already saw the question; retrying would double-prompt, so if it may have reached them, treat as pending, don't retry).
- Then branch on `SESSION_KIND` (echoed by the preamble; empty/absent ⇒ `interactive`):
- `spawned` → defer to the **Spawned session** block: auto-choose the recommended option. Never prose, never BLOCKED.
- `headless``BLOCKED — AskUserQuestion unavailable`; stop and wait (no human can answer).
- `interactive`**prose fallback** (below).
**Prose fallback — render the decision brief as a markdown message, not a tool call.** Same information as the tool format below, different structure (paragraphs, not ✅/❌ bullets). It MUST surface this triad:
1. **A clear ELI10 of the issue itself** — plain English on what's being decided and why it matters (the question, not per-choice), naming the stakes. Lead with it.
2. **Completeness scores per choice** — explicit `Completeness: X/10` on EACH choice (10 complete, 7 happy-path, 3 shortcut); use the kind-note when options differ in kind not coverage, but never silently drop the score.
3. **The recommendation and why** — a `Recommendation: <choice> because <reason>` line plus the `(recommended)` marker on that choice.
Layout: a `D<N>` title + a one-line note that AskUserQuestion failed and to reply with a letter; the issue ELI10; the Recommendation line; then ONE paragraph per choice carrying its `(recommended)` marker, its `Completeness: X/10`, and 2-4 sentences of reasoning — never a bare bullet list; a closing `Net:` line. Split chains / 5+ options: one prose block per per-option call, in sequence. Then STOP and wait — the user's typed answer is the decision. In plan mode this satisfies end-of-turn like a tool call.
### Format
Every AskUserQuestion is a decision brief and must be sent as tool_use, not prose.
Every AskUserQuestion is a decision brief and must be sent as tool_use, not prose — unless the documented failure fallback above applies (interactive session + the call is unavailable/erroring), in which case the prose fallback is the correct output.
```
D<N> — <one-line question title>
@@ -373,7 +396,7 @@ Before calling AskUserQuestion, verify:
- [ ] (recommended) label on one option (even for neutral-posture)
- [ ] Dual-scale effort labels on effort-bearing options (human / CC)
- [ ] Net line closes the decision
- [ ] You are calling the tool, not writing prose
- [ ] You are calling the tool, not writing prose — unless the documented failure fallback applies (then: prose with the mandatory triad — issue ELI10, per-choice Completeness, Recommendation + `(recommended)` — and a "reply with a letter" instruction, then STOP)
- [ ] Non-ASCII characters (CJK / accents) written directly, NOT \u-escaped
- [ ] If you had 5+ options, you split (or batched into ≤4-groups) — did NOT drop any
- [ ] If you split, you checked dependencies between options before firing the chain
+26 -1
View File
@@ -1,4 +1,4 @@
import { describe, test, expect } from 'bun:test';
import { describe, test, expect, beforeAll } from 'bun:test';
import { COMMAND_DESCRIPTIONS } from '../browse/src/commands';
import { SNAPSHOT_FLAGS } from '../browse/src/snapshot';
import * as fs from 'fs';
@@ -2125,6 +2125,21 @@ describe('Factory generation (--host factory)', () => {
import { ALL_HOST_CONFIGS, getExternalHosts } from '../hosts/index';
describe('Parameterized host smoke tests', () => {
// Regenerate every external host up front so the per-host `--dry-run` freshness
// checks are deterministic. These host dirs (.agents/.factory/.cursor/...) are
// gitignored regenerated artifacts, so the freshness check is really an
// idempotency/determinism check — it still catches non-deterministic gen, but no
// longer flakes on stale-on-disk state left by a missing `gen --host all` prestep
// (the canonical `bun test` does not run one). The tracked-claude freshness test
// (`generated files are fresh`) runs earlier and is unaffected.
beforeAll(() => {
for (const h of getExternalHosts()) {
Bun.spawnSync(['bun', 'run', 'scripts/gen-skill-docs.ts', '--host', h.name], {
cwd: ROOT, stdout: 'pipe', stderr: 'pipe',
});
}
});
for (const hostConfig of getExternalHosts()) {
describe(`${hostConfig.displayName} (--host ${hostConfig.name})`, () => {
const hostDir = path.join(ROOT, hostConfig.hostSubdir, 'skills');
@@ -2208,6 +2223,16 @@ describe('Parameterized host smoke tests', () => {
// ─── --host all tests ────────────────────────────────────────
describe('--host all', () => {
// Same determinism guard as the parameterized block: make external hosts fresh on
// disk so `--host all --dry-run` reports FRESH regardless of prior state.
beforeAll(() => {
for (const h of getExternalHosts()) {
Bun.spawnSync(['bun', 'run', 'scripts/gen-skill-docs.ts', '--host', h.name], {
cwd: ROOT, stdout: 'pipe', stderr: 'pipe',
});
}
});
test('--host all generates for all registered hosts', () => {
const result = Bun.spawnSync(['bun', 'run', 'scripts/gen-skill-docs.ts', '--host', 'all', '--dry-run'], {
cwd: ROOT, stdout: 'pipe', stderr: 'pipe',
+70
View File
@@ -0,0 +1,70 @@
/**
* gstack-session-kind — classifies the session so skills know whether a human can
* answer an AskUserQuestion. Drives the AUQ-failure fallback branch:
* spawned → auto-choose (orchestrator)
* headless → BLOCK on AUQ failure
* interactive → prose fallback on AUQ failure
*
* These permutations are the contract the resolver rule depends on. Run with a
* SCRUBBED env (the test process itself runs inside Conductor, so CONDUCTOR_* /
* CLAUDE_CODE_* would leak in and contaminate the classification).
*
* Free, deterministic, gate-tier.
*/
import { describe, test, expect } from 'bun:test';
import { execFileSync } from 'child_process';
import * as path from 'path';
const BIN = path.resolve(__dirname, '..', 'bin', 'gstack-session-kind');
/** Run the helper with ONLY the supplied env (plus PATH so bash resolves). */
function kind(env: Record<string, string>): string {
return execFileSync(BIN, [], {
env: { PATH: process.env.PATH ?? '/usr/bin:/bin', ...env },
encoding: 'utf-8',
}).trim();
}
describe('gstack-session-kind', () => {
test('OPENCLAW_SESSION → spawned (highest precedence)', () => {
expect(kind({ OPENCLAW_SESSION: '1' })).toBe('spawned');
// spawned wins even when other markers are also present
expect(kind({ OPENCLAW_SESSION: '1', GSTACK_HEADLESS: '1', CONDUCTOR_PORT: '5' })).toBe('spawned');
});
test('GSTACK_HEADLESS → headless', () => {
expect(kind({ GSTACK_HEADLESS: '1' })).toBe('headless');
});
test('CONDUCTOR_* → interactive (a human host is present)', () => {
expect(kind({ CONDUCTOR_WORKSPACE_PATH: '/tmp/ws' })).toBe('interactive');
expect(kind({ CONDUCTOR_PORT: '55010' })).toBe('interactive');
});
test('CLAUDE_CODE_ENTRYPOINT=cli → interactive', () => {
expect(kind({ CLAUDE_CODE_ENTRYPOINT: 'cli' })).toBe('interactive');
});
test('interactive host beats CI markers', () => {
expect(kind({ CONDUCTOR_PORT: '5', CI: '1' })).toBe('interactive');
});
test('CI / GITHUB_ACTIONS with no host → headless', () => {
expect(kind({ CI: '1' })).toBe('headless');
expect(kind({ GITHUB_ACTIONS: 'true' })).toBe('headless');
});
test('GSTACK_HEADLESS beats CONDUCTOR (explicit override wins)', () => {
expect(kind({ GSTACK_HEADLESS: '1', CONDUCTOR_PORT: '5' })).toBe('headless');
});
test('bare env → interactive (degrade-safe default)', () => {
expect(kind({})).toBe('interactive');
});
test('empty GSTACK_HEADLESS is treated as unset (interactive)', () => {
// The resolver/helper guard on -n, so an empty string must NOT mean headless —
// this is the opt-out path harness suites use to exercise the interactive branch.
expect(kind({ GSTACK_HEADLESS: '' })).toBe('interactive');
});
});
+7
View File
@@ -300,6 +300,13 @@ export async function runAgentSdkTest(
const queryImpl: QueryProvider = opts.queryProvider ?? query;
const model = opts.model ?? 'claude-opus-4-7';
// NOTE on GSTACK_HEADLESS: the SDK child inherits process.env, so headless
// classification for eval/E2E runs is set by the `test:gate` / `test:evals`
// package.json scripts (scoped to that invocation), NOT mutated here. We must not
// pass sdkOpts.env (it breaks the SDK auth pipeline — see CLAUDE.md) and must not
// mutate process.env ambiently (it would leak headless into later interactive-path
// tests in the same Bun process — Codex review finding).
let attempt = 0;
let lastErr: unknown = null;
+11
View File
@@ -87,6 +87,12 @@ export interface CarveGuard {
minUnionBytes: number;
/** Parity: content phrases the union must preserve. */
mustContain: string[];
/**
* Parity: optional per-skill override for the union size-growth ceiling vs the
* v1.53.0.0 baseline (default 1.05). Bumped only when a deliberate cross-cutting
* preamble feature legitimately grows a smaller carved skeleton past 5%.
*/
maxSizeRatio?: number;
}
export const CARVE_GUARDS: Record<string, CarveGuard> = {
@@ -216,6 +222,11 @@ export const CARVE_GUARDS: Record<string, CarveGuard> = {
maxSkeletonBytes: 50_000,
minUnionBytes: 55_000,
mustContain: ['CHANGELOG', 'Diataxis', 'coverage'],
// The AUQ-failure prose fallback (v1.57.2.0) adds ~2KB to every skill's
// always-loaded preamble; on this small carved skeleton that lands at ~5.9%
// over the pre-carve/pre-AUQ v1.53.0.0 baseline. Headroom for the
// cross-cutting addition; all other skills keep the strict 1.05 ceiling.
maxSizeRatio: 1.08,
},
'design-consultation': {
skill: 'design-consultation',
+1 -1
View File
@@ -252,7 +252,7 @@ const CARVED_INVARIANTS: ParityInvariant[] = Object.values(CARVE_GUARDS).map((g)
minBytes: g.minUnionBytes,
mustContain: g.mustContain,
mustHaveHeadings: ['## Preamble', '## When to invoke'],
maxSizeRatio: 1.05,
maxSizeRatio: g.maxSizeRatio ?? 1.05,
}));
export const PARITY_INVARIANTS: ParityInvariant[] = [
+3
View File
@@ -52,6 +52,9 @@ export class ClaudeAdapter implements ProviderAdapter {
timeout: opts.timeoutMs,
encoding: 'utf-8',
maxBuffer: 32 * 1024 * 1024,
// Default GSTACK_HEADLESS=1 so a benchmark run classifies as headless (an
// AskUserQuestion failure BLOCKs rather than emitting unanswerable prose).
env: { ...process.env, GSTACK_HEADLESS: '1' },
});
const parsed = this.parseOutput(out);
return {
+5 -1
View File
@@ -176,7 +176,11 @@ export async function runSkillTest(options: {
const proc = Bun.spawn(['sh', '-c', `cat "${promptFile}" | claude ${args.map(a => `"${a}"`).join(' ')}`], {
cwd: workingDirectory,
env: extraEnv ? { ...process.env, ...extraEnv } : undefined,
// Default GSTACK_HEADLESS=1 so eval/E2E runs classify as headless (BLOCK on an
// AskUserQuestion failure rather than emit a prose question no human reads). A
// suite exercising the INTERACTIVE prose-fallback path opts out by passing
// `env: { GSTACK_HEADLESS: '' }` — extraEnv wins because it spreads last.
env: { ...process.env, GSTACK_HEADLESS: '1', ...extraEnv },
stdout: 'pipe',
stderr: 'pipe',
});
+87
View File
@@ -16,6 +16,8 @@
* for the weekly periodic eval to notice.
*/
import { describe, test, expect } from 'bun:test';
import * as fs from 'fs';
import * as path from 'path';
import type { TemplateContext } from '../scripts/resolvers/types';
import { HOST_PATHS } from '../scripts/resolvers/types';
import { generateAskUserFormat } from '../scripts/resolvers/preamble/generate-ask-user-format';
@@ -161,3 +163,88 @@ describe('generateAskUserFormat — 5+ option split rule (slim inline + docs poi
expect(out).toContain('**Non-ASCII characters');
});
});
describe('generateAskUserFormat — runtime-failure prose fallback', () => {
const out = generateAskUserFormat(makeCtx());
test('documents the unavailable/failed subsection', () => {
expect(out).toMatch(/When AskUserQuestion is unavailable or a call fails/i);
});
test('carves out the auto-decide denial as NOT a failure', () => {
expect(out).toContain('[plan-tune auto-decide]');
expect(out).toMatch(/NOT a failure/i);
// and explicitly: do not fall back to prose on an auto-decide denial
expect(out).toMatch(/Do NOT[\s\S]{0,40}fall back to prose|never prose/i);
});
test('retries the errored call exactly once before degrading', () => {
expect(out).toMatch(/retry the SAME call \*\*once\*\*|retry the same call.*once/i);
// idempotency guard against double-prompting
expect(out).toMatch(/double-prompt|no answer could have surfaced/i);
});
test('branches on SESSION_KIND: spawned / headless / interactive', () => {
expect(out).toContain('SESSION_KIND');
expect(out).toMatch(/`spawned`[\s\S]*auto-choose/);
expect(out).toMatch(/`headless`[\s\S]*BLOCKED/);
expect(out).toMatch(/`interactive`[\s\S]*prose fallback/);
// empty/absent SESSION_KIND degrades to interactive
expect(out).toMatch(/empty\/absent[\s\S]{0,40}interactive/i);
});
// The mandatory triad the user explicitly required for the plain-text output.
test('prose fallback mandates the triad: issue ELI10', () => {
expect(out).toMatch(/ELI10 of the issue itself/i);
});
test('prose fallback mandates the triad: per-choice Completeness score', () => {
expect(out).toMatch(/Completeness scores per choice/i);
expect(out).toMatch(/Completeness: X\/10.*EACH choice|on EACH choice/i);
});
test('prose fallback mandates the triad: recommendation + (recommended) marker', () => {
expect(out).toMatch(/Recommendation: <choice> because/);
expect(out).toMatch(/\(recommended\)`? marker on that choice/);
});
test('prose fallback is one paragraph per choice, not a bare bullet list', () => {
expect(out).toMatch(/ONE paragraph per choice/i);
expect(out).toMatch(/never a bare bullet list/i);
});
test('prose fallback tells the user to reply with a letter, then STOP', () => {
expect(out).toMatch(/reply with a letter/i);
expect(out).toMatch(/STOP and wait/i);
});
// OV2: the former "tool_use, not prose" assertions must carry the qualifier so the
// fallback is not self-contradicting. Guards against the instruction collision
// silently returning on a future edit.
test('OV2: the Format line qualifies "not prose" with the fallback exception', () => {
expect(out).toMatch(/must be sent as tool_use, not prose — unless the documented failure fallback/);
});
test('OV2: the self-check "not writing prose" line carries the fallback qualifier', () => {
expect(out).toMatch(/not writing prose — unless the documented failure fallback applies/);
});
});
describe('CQ2 — cross-file invariant: auto-decide prefix matches the hook', () => {
const out = generateAskUserFormat(makeCtx());
const hookSrc = fs.readFileSync(
path.resolve(__dirname, '..', 'hosts', 'claude', 'hooks', 'question-preference-hook.ts'),
'utf-8',
);
test('the hook actually emits the [plan-tune auto-decide] prefix', () => {
expect(hookSrc).toContain('[plan-tune auto-decide]');
});
test('the resolver references the exact same prefix the hook emits', () => {
// If a future edit reworded the hook reason, this catches the drift: the prose
// fallback would stop recognizing the auto-decide denial as not-a-failure.
const PREFIX = '[plan-tune auto-decide]';
expect(hookSrc.includes(PREFIX) && out.includes(PREFIX)).toBe(true);
});
});