Interactive Clarification
Forced interactive clarification is a Guide + Sensor pair (see the harness model), not one of the enforced gates. The Guide is a canonical callout embedded in every asset that asks a user a question; the Sensor, forge clarify-gate, reads two persisted marker scopes and warns — but by design can never refuse a run — when one is left unanswered.
Not one of the enforced gates
forge clarify-gate deliberately never returns Claude Code's blocking PreToolUse exit code, is wired only to lifecycle-end events (Stop, session.idle) that fire after the agent's turn has already ended, and is not consumed by forge review validate at any delivery gate. It surfaces a warning; it does not stop anything. Read this whole page before assuming it gives the same guarantee as AskUserQuestion or question themselves.
The Guide: one canonical callout
Every asset that asks the user a discrete or open-ended question — the SDD gap commands, sdd-propose, clarify.md itself, and the orchestrator workflow prompt — carries the same byte-identical callout:
> **Interactive input (required):** If your harness provides an interactive
> question/selection tool (Claude Code's `AskUserQuestion`, OpenCode's
> `question`), you MUST use it for every question in this step — one tool
> call per decision point — instead of rendering the question as plain chat
> text. For open-ended questions, still use the tool: offer the 2–4 most
> plausible concrete answers as selectable options and let the user supply
> their own wording through the tool's free-text ("Other") option. Do not
> downgrade an open-ended question to chat prose, do not answer on the
> user's behalf, and do not continue past the question without a real user
> turn. Only if your harness has no interactive tool at all: ask in plain
> text, then STOP and wait for a new user message before any further action.That single paragraph carries every rule a phase skill or command needs:
- Discrete choices go through the harness's native tool —
AskUserQuestionon Claude Code,questionon OpenCode — one tool call per decision point, never rendered as plain chat text. - Open-ended questions still use the tool. The asset offers 2–4 plausible concrete answers as selectable options and lets the user type their own wording through the tool's "Other" free-text option, instead of downgrading to chat prose.
- Plain-text fallback is the last resort, permitted only when the active harness genuinely has no interactive-tool equivalent — and even then the skill must STOP and wait for a real new user message before continuing.
It is installed byte-identical in every one of these assets, so a single golden-byte-comparison test — not a review — is what keeps them from drifting apart:
commands/{infra,brand,constitution,analyze,refine,brainstorm,drift}.md, commands/clarify.md (both the multiple-choice and the short-answer path), skills/sdd-propose/SKILL.md (Step 0), and claude/sdd-orchestrator-workflow.md (widened from covering only the orchestrator's own cached decisions to any clarifying question raised by any phase skill or command it delegates to). OpenCode's own orchestrator asset carries the equivalent widened guidance too.
The Sensor: forge clarify-gate
forge clarify-gate reads two persisted marker scopes and performs zero model judgment — it never decides whether a question is ambiguous, it only reports what a skill already wrote to disk.
forge clarify-gate [--cwd <repo>] [--session-id <id>] [--change <name>] [--format text|json]| Scope | File | Written by |
|---|---|---|
| per-change | .forge/changes/<change>/clarify.yaml | change-bound commands — propose, spec, design, tasks, apply, clarify |
| per-session | .forge/clarify/sessions/<session-id>.yaml | change-less commands — /forge.brand, /forge.constitution, /forge.infra, /forge.brainstorm |
--change explicit wins; omitted, the gate falls back to the single active change the same way forge hook does. --session-id only activates the session scope when it sanitizes cleanly — empty, missing, or anything that looks like a path (/, \, .., a null byte) silently degrades to change-scope-only rather than erroring or ever being joined into a filesystem path.
$ forge clarify-gate --cwd . --change checkout
clarify-gate: clean (no unresolved clarifying questions)
# exit 0
$ forge clarify-gate --cwd . --change checkout
CLARIFY GATE — unresolved clarifying question(s) found. This is a warning, not a hard block; the session continues.
[change:checkout] 1 unresolved:
- q1: Which auth model?
A human answers via the interactive tool, or waives with: forge clarify waive --id <qid> [--change <c>|--session <id>]
# exit 1That non-zero exit is the entire warning. It folds every unresolved question's scope, id, and text into the returned error so a host's uniform Error: … stderr print carries the detail — clarify-gate needs no dedicated stderr writer of its own. --format json emits the same clean/scopes[] structure for a host that wants to parse it instead of reading text.
The marker lifecycle
A skill writes a marker the moment it decides a question is needed, before it calls the interactive tool:
forge clarify open --id q1 --question "Which auth model?" --asked-in sdd-propose --change checkoutThree commands can move a question out of status: open:
forge clarify answer --id q1 --tool AskUserQuestion --answer-digest <12hex> --change checkout
forge clarify waive --id q1 [--reason "…"] --change checkout
forge clarify list --change checkout # print only what is still unresolvedThe trust gap: answer is best-effort
answer is agent-runnable, and that is an honest limit, not a bug
forge clarify answer requires a recognised tool name (AskUserQuestion/question) and a 12-hex answer digest — the same fingerprint discipline secscan uses — but forge has no way to verify the agent didn't invent both. An agent that wants to self-clear a marker and answer on the user's behalf can technically do so. clarify-gate prints answered entries as an informational tally specifically so a human can spot-check them; it treats them as resolved either way.
forge clarify waive is the only path forge actually trusts, because it never accepts caller-supplied identity: waived_by always comes from identity.Resolve(root) — the resolved git/environment identity, never text an agent passes on the command line. This mirrors secscan's "a human waives, never the agent" discipline exactly (see Secret & PII Scan). If you need the strong guarantee that a human, not an agent, actually cleared a question, waive is the only command that gives it to you — answer never does.
Installer wiring
Claude Code — the Stop hook
forge install (and forge init) appends an idempotent Stop hook entry to ~/.claude/settings.json (or the project's .claude/settings.json), alongside every other lifecycle hook Forge manages:
{ "hooks": { "Stop": [
{ "hooks": [ { "type": "command", "command": "forge clarify-gate --cwd \"${CLAUDE_PROJECT_DIR:-$PWD}\"" } ] }
] } }There is deliberately no || true on this line — same convention as the verify circuit-breaker's Stop hook in Connect a live agent: the gate is meant to return non-zero so Claude Code surfaces it, not so it can silently fail open. The insertion is append-if-absent and idempotent: re-running forge install never duplicates the entry and never disturbs a pre-existing Stop array you already had.
Verify it installed:
grep -A2 '"Stop"' ~/.claude/settings.jsonOpenCode — the session.idle plugin
OpenCode has no Stop-hook equivalent, so its wiring uses the structurally equivalent session.idle event — the point where the agent's turn actually ends — instead of forcing a Claude-Code-style shape onto a different host. forge install writes the plugin to:
~/.config/opencode/plugins/clarify-gate.tsThe plugin execFiles the forge binary with a 30-second timeout, passing event.properties.sessionID as --session-id, and never throws — a failed or unresolved gate surfaces as a toast (input.client.tui.showToast({ variant: "warning" })), never as a plugin crash. It is purely observational: session.idle cannot refuse the stop, it can only report after the fact.
Verify it installed:
ls ~/.config/opencode/plugins/ | grep clarify-gate.tsThe v1 non-goal: ten harnesses are not covered
Forge supports twelve agent harnesses. This release wires the clarify-gate backstop into exactly two: Claude Code (Stop hook) and OpenCode (session.idle plugin). The remaining ten — cursor, codex, gemini, kiro, kimi, qwen, hermes, antigravity, windsurf, and generic — get the canonical callout Guide (the asset text itself asks the agent to use whatever interactive tool exists, or fall back to plain text and stop), but no Sensor at all. Nothing in those ten harnesses reads or warns on an unresolved clarify.yaml/sessions/<id>.yaml marker today.
This gap is intentional, not an oversight, and it is not marked (Deferred: <target>) in the spec because no concrete future change name exists yet to serve as that target. If you rely on one of the other ten harnesses, the only enforcement you have is the Guide text itself — the same honest limit AskUserQuestion/question parity would not have given you anyway, since those tools are Claude Code- and OpenCode-specific to begin with.
Where it fits
clarify-gate bounds a different failure mode than the four enforced controls: not runaway iterations, unbounded spend, a dangerous action, or unreviewed delivery, but an agent quietly self-answering a product decision it should have routed through the user. It shares their shape — a persisted marker, a deterministic read, a non-zero exit — but not their guarantee: it is a Sensor that warns, wired only to lifecycle-end events on two of twelve supported harnesses, never a gate that blocks.
See also
- The Enforcement Doctrine — where the four enforced controls sit, and why
clarify-gateis not a fifth one. - Connect a live agent — the
Stophook andsession.idlewiring in full. - Secret & PII Scan — the sibling gate
clarify waiveborrows its human-only waiver discipline from. - The forge CLI — the full
clarify-gateandclarifyflag reference.