From 8c14f61773e38c4713eff97a1097abcf521d0d2e Mon Sep 17 00:00:00 2001 From: "agon (KERN)" <292465531+KERN-Agon@users.noreply.github.com> Date: Thu, 16 Jul 2026 18:24:30 +0200 Subject: [PATCH 1/2] feat(cesar): structured [ASK] ask-with-options marker (RULE 7d) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the Cesar brain is genuinely unsure between concrete alternatives it can now END its turn with one [ASK]{question, options:[{label,description}]} [/ASK] block. The runtime strips it (streamed + committed text), renders the existing question overlay with dim per-option description lines and the auto-appended free-text Other row, and feeds the selection back through the existing interactive follow-up path so the turn continues. - ask-marker.kern: parser — last well-formed block wins, all blocks stripped, 2-6 options, length clamps; located-but-malformed keeps found:true so the turn surfaces a visible warning instead of silently never asking - brain-helpers.kern: generalize the [TODOS] stream stripper into createBlockDisplayStripper; add [ASK] to splitBeforeToolMarkup - brain.kern: extractAsk at every marker-extraction site; ask branch at the single end-of-turn interactive chokepoint (suppresses the heuristic fork/ confirm pickers; skipped while a plan surface is active) - session.kern: RULE 7d prompt rule + agentic-prompt bullet - composer.kern/app-layout.kern: description line per choice row + matching reserved-row estimate REPL-only by design; agon one-shot surfaces are untouched. ⚔️ Forged by [Agon](https://github.com/KERNlang/agon) Co-Authored-By: agon (KERN) <292465531+KERN-Agon@users.noreply.github.com> --- .../cli/src/generated/blocks/composer.tsx | 18 ++- .../cli/src/generated/cesar/ask-marker.ts | 106 ++++++++++++++ .../cli/src/generated/cesar/brain-helpers.ts | 68 +++++---- packages/cli/src/generated/cesar/brain.ts | 117 ++++++++++++--- packages/cli/src/generated/cesar/session.ts | 62 ++++---- .../cli/src/generated/surfaces/app-layout.ts | 25 ++-- packages/cli/src/kern/blocks/composer.kern | 16 ++- packages/cli/src/kern/cesar/ask-marker.kern | 99 +++++++++++++ .../cli/src/kern/cesar/brain-helpers.kern | 55 ++++--- packages/cli/src/kern/cesar/brain.kern | 108 ++++++++++++-- packages/cli/src/kern/cesar/session.kern | 6 + .../cli/src/kern/surfaces/app-layout.kern | 13 +- tests/unit/ask-marker.test.ts | 136 ++++++++++++++++++ 13 files changed, 708 insertions(+), 121 deletions(-) create mode 100644 packages/cli/src/generated/cesar/ask-marker.ts create mode 100644 packages/cli/src/kern/cesar/ask-marker.kern create mode 100644 tests/unit/ask-marker.test.ts diff --git a/packages/cli/src/generated/blocks/composer.tsx b/packages/cli/src/generated/blocks/composer.tsx index e0efc302d..0a1e2da09 100644 --- a/packages/cli/src/generated/blocks/composer.tsx +++ b/packages/cli/src/generated/blocks/composer.tsx @@ -1,4 +1,4 @@ -// @generated by kern v3.5.8 — DO NOT EDIT. Source: src/kern/blocks/composer.kern +// @generated by kern v4.5.0 — DO NOT EDIT. Source: src/kern/blocks/composer.kern import React from 'react'; import { Box, Text } from 'ink'; @@ -75,10 +75,20 @@ const ComposerView = React.memo(function ComposerView({ mode, replState, planMod const selIdx = Math.min(Math.max(0, selectedChoiceIndex ?? 0), Math.max(0, choiceList.length - 1)); const renderChoiceRow = (c: any, i: number, accent: string) => { const active = i === selIdx; + // Structured-ask options ([ASK] marker) carry a one-line trade-off + // description; render it dim under the label so the menu reads like a + // Claude-Code AskUserQuestion. Plain choices (no description) keep the + // original single-row shape. + const desc = typeof c.description === 'string' ? c.description.trim() : ''; return ( - - {active ? '❯ ' : ' '}{i + 1}{'. '}{c.label} - + + + {active ? '❯ ' : ' '}{i + 1}{'. '}{c.label} + + {desc ? ( + {' '}{truncateCodeLine(desc, Math.max(24, termWidth - 12))} + ) : null} + ); }; return ( diff --git a/packages/cli/src/generated/cesar/ask-marker.ts b/packages/cli/src/generated/cesar/ask-marker.ts new file mode 100644 index 000000000..9f3b08f8b --- /dev/null +++ b/packages/cli/src/generated/cesar/ask-marker.ts @@ -0,0 +1,106 @@ +// @generated by kern v4.5.0 — DO NOT EDIT. Source: src/kern/cesar/ask-marker.kern + +/** + * One selectable answer: short label plus an optional one-line trade-off description rendered dim under the row. + */ +// @kern-source: ask-marker:21 +export interface AskOption { + label: string; + description?: string; +} + +/** + * A validated structured question: the prompt line and 2–6 options. + */ +// @kern-source: ask-marker:26 +export interface StructuredAsk { + question: string; + options: AskOption[]; +} + +/** + * The parsed ask from the LAST well-formed block, or null when absent/malformed. + * + * True when an [ASK]…[/ASK] block was located (even if its body was malformed) — so the caller knows to strip it and can warn on ask:null. + * + * Response text with every [ASK]…[/ASK] block removed, ready to display. + */ +// @kern-source: ask-marker:31 +export interface AskMarkerResult { + ask: StructuredAsk | null; + found: boolean; + rest: string; +} + +// @kern-source: ask-marker:39 +export const MAX_ASK_OPTIONS: number = 6; + +// @kern-source: ask-marker:40 +export const MIN_ASK_OPTIONS: number = 2; + +// @kern-source: ask-marker:41 +export const MAX_ASK_LABEL_CHARS: number = 100; + +// @kern-source: ask-marker:42 +export const MAX_ASK_DESCRIPTION_CHARS: number = 200; + +// @kern-source: ask-marker:43 +export const MAX_ASK_QUESTION_CHARS: number = 300; + +/** + * Extract a structured ask from [ASK]…[/ASK] blocks. The last well-formed block wins; ALL blocks are stripped from rest. A located-but-malformed block (bad JSON, missing question, fewer than 2 valid options) returns ask:null with found:true so the caller can warn. Options are capped at 6; labels/descriptions/question are length-clamped. + */ +// @kern-source: ask-marker:45 +export function parseAskMarker(response: string): AskMarkerResult { + const text = String(response ?? ''); + const blockRe = /\[ASK\]([\s\S]*?)\[\/ASK\]/gi; + const matches = text.match(blockRe); + if (!matches || matches.length === 0) { + return { ask: null, found: false, rest: text }; + } + const rest = text.replace(blockRe, '').trim(); + // Re-scan to capture bodies (match() loses capture groups). Walk ALL blocks + // and keep the LAST one that validates — a model that changed its mind + // mid-stream re-emits, and the latest snapshot is authoritative (same + // last-wins contract as [TODOS]). + const clamp = (value: string, max: number): string => + value.length > max ? value.slice(0, max - 1).trimEnd() + '…' : value; + let ask: StructuredAsk | null = null; + const re2 = /\[ASK\]([\s\S]*?)\[\/ASK\]/gi; + const bodies: string[] = []; + let m = re2.exec(text); + while (m !== null) { + bodies.push(String(m[1] ?? '').trim()); + m = re2.exec(text); + } + for (const body of bodies) { + let parsed: unknown; + try { + parsed = JSON.parse(body); + } catch { + continue; // malformed body — keep scanning; found stays true + } + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) continue; + const obj = parsed as Record; + const question = obj.question !== undefined && obj.question !== null ? String(obj.question).trim() : ''; + if (!question) continue; + if (!Array.isArray(obj.options)) continue; + const options: AskOption[] = []; + for (const raw of (obj.options as unknown[]).slice(0, MAX_ASK_OPTIONS)) { + if (!raw || typeof raw !== 'object') continue; + const r = raw as Record; + const label = r.label !== undefined && r.label !== null ? String(r.label).trim() : ''; + if (!label) continue; + const description = r.description !== undefined && r.description !== null + ? String(r.description).trim() + : ''; + options.push({ + label: clamp(label, MAX_ASK_LABEL_CHARS), + ...(description ? { description: clamp(description, MAX_ASK_DESCRIPTION_CHARS) } : {}), + }); + } + if (options.length < MIN_ASK_OPTIONS) continue; + ask = { question: clamp(question, MAX_ASK_QUESTION_CHARS), options }; + } + return { ask, found: true, rest }; +} diff --git a/packages/cli/src/generated/cesar/brain-helpers.ts b/packages/cli/src/generated/cesar/brain-helpers.ts index 4d8bbdfe7..fa23a015f 100644 --- a/packages/cli/src/generated/cesar/brain-helpers.ts +++ b/packages/cli/src/generated/cesar/brain-helpers.ts @@ -31,7 +31,7 @@ export function recordCesarTurn(ctx: HandlerContext, cesarEngineId: string, inpu // @kern-source: brain-helpers:29 export const splitBeforeToolMarkup: (text:string) => { visible:string, hasToolMarkup:boolean } = (text: string) => { - const markers = ['', '', ' { visible:string, hasToolMa export const XML_TOOL_MARKUP_HOLD_CHARS: number = 24; /** - * Factory for the native-path [TODOS]…[/TODOS] incremental display stripper. Returns a stateful strip fn (chunk, force?) => string closing over its own hold buffer + insideBlock flag — one instance per turn. force=true flushes any held tail verbatim (bypassing holds) for the stream-end flush; force=false holds partial-marker prefixes (open '[todos]' AND close '[/todos]') across chunks so a marker split mid-stream neither leaks nor latches the block open forever. + * Generic factory for a [TAG]…[/TAG] incremental display stripper — the shared machinery behind createTodosDisplayStripper and createAskDisplayStripper. Takes the lowercase open/close markers; returns a stateful strip fn (chunk, force?) => string closing over its own hold buffer + insideBlock flag — one instance per turn per marker. force=true flushes any held tail verbatim (bypassing holds) for the stream-end flush; force=false holds partial-marker prefixes (open AND close) across chunks so a marker split mid-stream neither leaks nor latches the block open forever. */ -// @kern-source: brain-helpers:64 -export const createTodosDisplayStripper: () => ((chunk: string, force?: boolean) => string) = () => { - let insideBlock = false; // between [TODOS] and [/TODOS] +// @kern-source: brain-helpers:67 +export const createBlockDisplayStripper: (open: string, close: string) => ((chunk: string, force?: boolean) => string) = (open: string, close: string) => { + let insideBlock = false; // between [TAG] and [/TAG] let hold = ''; // partial trailing marker prefix carried to next chunk - const OPEN = '[todos]'; - const CLOSE = '[/todos]'; + const OPEN = open.toLowerCase(); + const CLOSE = close.toLowerCase(); // Longest suffix of `combined` that is a strict prefix of `marker` (len < marker.length). const trailingPrefixLen = (combined: string, marker: string): number => { const lower = combined.toLowerCase(); @@ -98,7 +98,7 @@ export const createTodosDisplayStripper: () => ((chunk: string, force?: boolean) combined = combined.slice(start + OPEN.length); } if (!insideBlock) { - // Guard a partial trailing '[todos]' (or shorter prefix) split mid-marker. + // Guard a partial trailing open marker (or shorter prefix) split mid-marker. const p = trailingPrefixLen(combined, OPEN); if (p > 0) { hold = combined.slice(combined.length - p); @@ -110,10 +110,22 @@ export const createTodosDisplayStripper: () => ((chunk: string, force?: boolean) }; }; +/** + * Per-turn [TODOS]…[/TODOS] display stripper — createBlockDisplayStripper bound to the live-todos marker. + */ +// @kern-source: brain-helpers:132 +export const createTodosDisplayStripper: () => ((chunk: string, force?: boolean) => string) = () => createBlockDisplayStripper('[todos]', '[/todos]'); + +/** + * Per-turn [ASK]…[/ASK] display stripper — createBlockDisplayStripper bound to the structured-ask marker (RULE 7d), so the JSON question block never leaks into the streamed display. + */ +// @kern-source: brain-helpers:138 +export const createAskDisplayStripper: () => ((chunk: string, force?: boolean) => string) = () => createBlockDisplayStripper('[ask]', '[/ask]'); + /** * Factory for the native-path START-anchored [INTENT] preamble display stripper. Returns a stateful strip fn (chunk, force?) => string closing over its own decision flags + hold buffer — one instance per turn. Holds leading text until it can decide whether the response opens with an [INTENT] line; suppresses the matched line; once decided (matched-and-consumed, or provably-not-a-marker) it is a pass-through. force=true flushes any held tail verbatim for the stream-end flush. Mirrors createTodosDisplayStripper's hold/flush guarantees for the single-line, no-close-tag marker shape. */ -// @kern-source: brain-helpers:151 +// @kern-source: brain-helpers:166 export const createPreambleStripper: () => ((chunk: string, force?: boolean) => string) = () => { const MARKER = '[intent]'; let decided = false; // start-anchored decision resolved → pass-through @@ -195,7 +207,7 @@ export const createPreambleStripper: () => ((chunk: string, force?: boolean) => /** * True when the last line of a Cesar turn is a question directed at the USER — either keyword-addressed (which/should/want/your call/…) OR an either/or fork ('A, or B?'). Auto-continuation uses this to STOP and wait for the user instead of treating the question as a mid-task stall and re-prompting Cesar (which caused fork questions like 'plan it all, or start with X?' to loop). */ -// @kern-source: brain-helpers:233 +// @kern-source: brain-helpers:248 export function isUserDirectedQuestion(lastLine: string): boolean { const last = String(lastLine ?? '').trim(); if (!/\?\s*$/.test(last)) return false; @@ -209,7 +221,7 @@ export function isUserDirectedQuestion(lastLine: string): boolean { /** * Return the nearest user-directed question in the TAIL of a Cesar turn (last 6 non-empty lines), or null. Unlike checking only the LAST line, this catches the common 'ask, then advise' shape — a question FOLLOWED BY a recommendation / rationale / confidence line (esp. minimax) — so auto-continuation recognizes it as the user's turn instead of barreling through it ('asked but never got a chance to answer'). Bounded to the last 6 non-empty lines so a question buried mid-narration does NOT stop the loop; reuses isUserDirectedQuestion for the per-line test. NOTE: we deliberately do NOT try to detect 'the model proceeded past its own question' (matching done / I'll / 'I renamed …') — every anchored attempt produced false positives ('ill', 'I'll wait for your input', 'this is not done yet') that SUPPRESS real questions and reintroduce the very bug this fixes. A stale trailing question merely STOPS the auto-continuation loop when the model is already done, which is benign — the done/effect logic in _detectTurnState already classifies genuine completions. (2 rounds of multi-engine agon review, 2026-06-07.) */ -// @kern-source: brain-helpers:245 +// @kern-source: brain-helpers:260 export function findTrailingUserQuestion(text: string): string | null { const lines = String(text ?? '').split(/\r?\n/).map((l) => l.trim()).filter(Boolean); const TAIL = 6; @@ -223,7 +235,7 @@ export function findTrailingUserQuestion(text: string): string | null { /** * True when a Cesar turn ENDS awaiting user input — either a trailing user-directed question (findTrailingUserQuestion) OR a 'holding / awaiting your approval / greenlight' STATEMENT in the tail (which has no '?'). PLAN EXECUTION uses this to pause an approved plan to idle when the brain stalls asking for input it cannot receive mid-run (the weak-brain 'I'll hold for your greenlight' re-ask of already-granted approval). Scoped to plan execution only; a false positive merely causes a recoverable pause (the one-shot executor override + /plan resume recover it), so the statement pattern is tuned to catch that shape without firing on ordinary completion text. */ -// @kern-source: brain-helpers:257 +// @kern-source: brain-helpers:272 export function detectAwaitingUserInput(text: string): boolean { const body = String(text ?? ''); if (findTrailingUserQuestion(body)) return true; @@ -259,7 +271,7 @@ export function detectAwaitingUserInput(text: string): boolean { /** * Detect responses where a model narrates tool intent instead of actually calling tools. Used for Cesar tool-use telemetry, especially API models with weak function calling. */ -// @kern-source: brain-helpers:291 +// @kern-source: brain-helpers:306 export function detectNarratedToolStall(text: string): boolean { const body = String(text ?? '').trim(); if (!body) { @@ -277,7 +289,7 @@ export function detectNarratedToolStall(text: string): boolean { /** * Layer 1 of the watchdog pipeline (brainstorm-validated 2026-06-11): remove NON-ASSERTED spans before any guard regex runs, so quoted/demonstrated text can never read as a claim. Strips fenced code blocks, inline backtick spans, double/curly-quoted spans, and parenthesized capitalized enumerations like '(Read/Edit/Write/Bash)' — the exact shape that false-fired the guards on a description OF the harness. Carve-out: a backticked/quoted span that is ITSELF an active first-person/imperative claim ('I'll edit foo') is kept, so an engine cannot hide a real stall inside quotes. Straight single-quoted spans are deliberately NOT stripped — apostrophes in contractions (can't … don't) would pair up and delete real assertions. Pure; exported for unit tests. */ -// @kern-source: brain-helpers:305 +// @kern-source: brain-helpers:320 export function stripNonAssertionSpans(text: string): string { let body = String(text ?? ''); // A span that is itself a claim stays visible to the guards. @@ -296,7 +308,7 @@ export function stripNonAssertionSpans(text: string): string { /** * Detect a 'false read-only hand-back': the orchestrator narrated a concrete change (claim-shaped: first-person intent, result-first 'patch ready', or an apply-it-yourself instruction) AND claimed it cannot write / delegated the write to an agent / pushed the apply to the user — WITHOUT emitting a mutating tool call. CLAIM-SHAPED matching (Layer 2, brainstorm-validated 2026-06-11): bare nouns like 'Edit/Write tools' or 'read-only investigation phase' in a DESCRIPTION of the harness must NOT fire — only inability/hand-back/intent CLAIMS do. Runs on stripNonAssertionSpans output so quoted failure text can't trip it. Result-first phrasing ('Patch ready. Please apply it manually; this environment is read-only.') is the recall-preserving branch — real stalls are often not first-person. */ -// @kern-source: brain-helpers:322 +// @kern-source: brain-helpers:337 export function detectMutationIntentStall(text: string): boolean { const body = stripNonAssertionSpans(String(text ?? '')).trim(); if (!body) return false; @@ -325,7 +337,7 @@ export function detectMutationIntentStall(text: string): boolean { /** * Detect a response that CLAIMS an async review/forge/tribunal/brainstorm/agent or background job was dispatched or is now running — e.g. 'review delegated to codex, claude, agy', 'three reviewers are reading the diff in parallel', 'I kicked off the review'. CLAIM-SHAPED matching (Layer 2, brainstorm-validated 2026-06-11): descriptive prose like 'Forge dispatches engines that work in parallel and report back with a winner' must NOT fire — the dispatch/running claim must be first-person ('I kicked off…'), a delegated-to statement, a present-continuous running state adjacent to a delegable target ('the review is still running', 'background review in progress'), or a they-will-report promise. Runs on stripNonAssertionSpans output. The caller pairs this with 'no delegation was actually emitted this turn' (ctx.cesar.pendingDelegation is null). */ -// @kern-source: brain-helpers:349 +// @kern-source: brain-helpers:364 export function detectFabricatedDelegation(text: string): boolean { const body = stripNonAssertionSpans(String(text ?? '')).trim(); if (!body) return false; @@ -350,7 +362,7 @@ export function detectFabricatedDelegation(text: string): boolean { /** * Layer 3 of the watchdog pipeline: NEVER suppresses a guard, only converts a positive match into a visible warning WITHOUT the injected corrective nudge. True only when every available signal says conversational: router intake chat (with its 'answer' flow — chat is the router's DEFAULT bucket, so a chat intake the router escalated to any other flow does NOT de-escalate) or a positively-classified exploration intake (campfire/brainstorm/answer flows), AND no mutating tool (Write/Edit/Bash family) ran this turn. Missing/unknown signals return false → full nudge (the nero-validated answer to 'frame it as chat' bypasses). HONEST LIMIT (review finding, 0.60): chat+answer is the router's no-signal default, so a genuinely stalling work turn the router failed to classify can land here — the deliberate trade is that de-escalation is WARN-ONLY (the match is still surfaced, the claim-shaped detector already had to fire, and no nudge is injected); it can never silently swallow a stall. The confidence self-report ('informational') joins as a third positive signal once ReportConfidence carries a structured task type. */ -// @kern-source: brain-helpers:372 +// @kern-source: brain-helpers:387 export function shouldDeescalateGuard(opts: {intakeKind?:string, recommendedFlow?:string, usedMutatingTool?:boolean}): boolean { const kind = String(opts.intakeKind ?? ''); const flow = String(opts.recommendedFlow ?? ''); @@ -364,7 +376,7 @@ export function shouldDeescalateGuard(opts: {intakeKind?:string, recommendedFlow /** * Give an uncorrelated eager streaming call one stable identity before any lifecycle event is emitted. The same enriched metadata must feed the running event and executeEagerTool terminal event so recap correlation cannot split one call into two records. */ -// @kern-source: brain-helpers:384 +// @kern-source: brain-helpers:399 export function withEagerToolCallId(meta: Record, fallbackId: string): Record { const existing = typeof meta?.toolCallId === 'string' ? meta.toolCallId.trim() : ''; if (existing) return meta; @@ -374,7 +386,7 @@ export function withEagerToolCallId(meta: Record, fallbackId: st /** * Claim one correlated eager tool execution for the current Cesar turn. Streaming adapters may repeat a running chunk while assembling arguments; a stable toolCallId executes once. Uncorrelated calls remain fail-open because a tool name or payload cannot safely distinguish duplicates from independent calls. */ -// @kern-source: brain-helpers:392 +// @kern-source: brain-helpers:407 export function claimEagerToolExecution(claimed: Set, toolCallId: unknown): boolean { const id = String(toolCallId ?? '').trim(); if (!id) { @@ -390,7 +402,7 @@ export function claimEagerToolExecution(claimed: Set, toolCallId: unknow /** * Return unique tool names from failed eager tool results. Used to restrict one-shot repair retries to the tool that just failed. */ -// @kern-source: brain-helpers:403 +// @kern-source: brain-helpers:418 export function eagerFailedToolNames(results: ToolCallResult[]): string[] { const names: string[] = []; for (const result of results ?? []) { @@ -408,7 +420,7 @@ export function eagerFailedToolNames(results: ToolCallResult[]): string[] { /** * Gate eager tool repair retries. A corrected tool call may run once only if the same tool failed in the immediately previous eager batch. */ -// @kern-source: brain-helpers:415 +// @kern-source: brain-helpers:430 export function shouldRunEagerRepairTool(toolName: string, meta: any, failedToolNames: string[], usedToolNames: string[]): boolean { const name = String(toolName ?? '').trim(); if (!name) return false; @@ -423,7 +435,7 @@ export function shouldRunEagerRepairTool(toolName: string, meta: any, failedTool /** * Return true for XML tools that hand control back to the Agon dispatcher. These tools do not produce inline results; continuing the XML tool loop after them can make Cesar claim a delegation happened while the actual forge/brainstorm/etc. job has not started yet. */ -// @kern-source: brain-helpers:428 +// @kern-source: brain-helpers:443 export function shouldStopAfterXmlToolCall(toolName: string): boolean { const HANDOFF_TOOLS = hostStringSet(['Forge', 'Brainstorm', 'Tribunal', 'Campfire', 'Council', 'Pipeline', 'Review', 'Agent', 'Goal', 'Conquer', 'ProposePlan', 'ExitPlanMode']); return HANDOFF_TOOLS.has(String(toolName ?? '')); @@ -432,7 +444,7 @@ export function shouldStopAfterXmlToolCall(toolName: string): boolean { /** * Strip the 'Agon' orchestration alias prefix from a tool name. The default companion/MCP path surfaces write/bash tools as AgonBash/AgonEdit/AgonWrite (the MCP completion's done.tool is the ORIGINAL alias, not the mapped kern tool — see agon-orchestration.kern handleWriteToolCall), while the native/XML loop uses the bare names. Case-insensitive prefix match; returns the un-prefixed remainder so both paths classify identically. */ -// @kern-source: brain-helpers:434 +// @kern-source: brain-helpers:449 export function stripAgonToolPrefix(name: string): string { const n = String(name ?? ''); return n.toLowerCase().startsWith('agon') ? n.slice(4) : n; @@ -441,18 +453,18 @@ export function stripAgonToolPrefix(name: string): string { /** * True when a tool name denotes a shell/bash call on EITHER path — bare 'Bash' (native/XML) or 'AgonBash' (MCP). Path-agnostic so the verify-before-done gate arms on the default companion path, not only the native one. */ -// @kern-source: brain-helpers:440 +// @kern-source: brain-helpers:455 export function isBashToolName(name: string): boolean { return stripAgonToolPrefix(name).toLowerCase() === 'bash'; } -// @kern-source: brain-helpers:445 +// @kern-source: brain-helpers:460 export const WRITE_TOOL_NAMES: Set = hostStringSet(['edit', 'write', 'multiedit', 'notebookedit']); /** * True when a tool name denotes project write-work (file edit/create) on EITHER path — bare Edit/Write/MultiEdit/NotebookEdit (native/XML) or the AgonEdit/AgonWrite MCP aliases. SaveMemory is deliberately NOT write-work: it appends to .agon/project.md (durable memory), not the project tree, so the project's verification gate has nothing to verify for it. */ -// @kern-source: brain-helpers:447 +// @kern-source: brain-helpers:462 export function isWriteToolName(name: string): boolean { return WRITE_TOOL_NAMES.has(stripAgonToolPrefix(name).toLowerCase()); } @@ -460,7 +472,7 @@ export function isWriteToolName(name: string): boolean { /** * Expand a bare 'fix it' follow-up into an explicit prompt grounded in the most recent stored review result. This avoids making Cesar guess which reviewer findings the user means, especially because /review runs outside Cesar's live session history. */ -// @kern-source: brain-helpers:452 +// @kern-source: brain-helpers:467 export function buildReviewFollowupPrompt(input: string, ctx: HandlerContext): { matched: boolean; prompt: string } { const trimmed = input.trim(); const match = ((__m) => __m === null ? null : { full: __m[0], groups: Array.from(__m).slice(1).map((g) => g === undefined ? null : g), index: __m.index, named: __m.groups ? Object.fromEntries(Object.entries(__m.groups).map(([__k, __v]) => [__k, __v === undefined ? null : __v])) : {} })(trimmed.match(/^fix it(?:[ \t\n\r\f\v]+with[ \t\n\r\f\v]+([a-z0-9._-]+))?[ \t\n\r\f\v?!.,;:]*$/i)); @@ -481,7 +493,7 @@ export function buildReviewFollowupPrompt(input: string, ctx: HandlerContext): { return { matched: true, prompt: prompt }; } -// @kern-source: brain-helpers:471 +// @kern-source: brain-helpers:486 export function extractDelegation(toolName: string, args: Record): PendingDelegation { const argsRecord = args as Record; const taskKindRaw = argsRecord.taskKind; diff --git a/packages/cli/src/generated/cesar/brain.ts b/packages/cli/src/generated/cesar/brain.ts index 41156e873..42a8f58b1 100644 --- a/packages/cli/src/generated/cesar/brain.ts +++ b/packages/cli/src/generated/cesar/brain.ts @@ -22,6 +22,8 @@ import { extractAdjacentForkOptions } from './fork-options.js'; import { parseLiveTodos, parsePreamble } from './todos-marker.js'; +import { parseAskMarker } from './ask-marker.js'; + import { ensureCesarSession, CESAR_SYSTEM_PROMPT, buildCesarSystemPrompt, resolveCesarBackend, renderToolPermissionCommand } from './session.js'; import { enforceContextBudget } from './context-budget.js'; @@ -62,7 +64,7 @@ import { getSessionAllowList } from '../signals/output.js'; import { resolveCesarHarnessProfile, evaluateAgenticTaskState, buildAgenticProgressSignature, buildAgenticAutoTurnDirective, resolveCesarToolReadOnlyMode, extractAgenticBashCommand, isAgenticMutationOutcome } from './task-controller.js'; -import { yieldToInk, recordCesarTurn, splitBeforeToolMarkup, XML_TOOL_MARKUP_HOLD_CHARS, createTodosDisplayStripper, createPreambleStripper, findTrailingUserQuestion, detectAwaitingUserInput, detectNarratedToolStall, detectMutationIntentStall, detectFabricatedDelegation, shouldDeescalateGuard, withEagerToolCallId, claimEagerToolExecution, eagerFailedToolNames, shouldRunEagerRepairTool, shouldStopAfterXmlToolCall, buildReviewFollowupPrompt, extractDelegation, isBashToolName, isWriteToolName } from './brain-helpers.js'; +import { yieldToInk, recordCesarTurn, splitBeforeToolMarkup, XML_TOOL_MARKUP_HOLD_CHARS, createTodosDisplayStripper, createAskDisplayStripper, createPreambleStripper, findTrailingUserQuestion, detectAwaitingUserInput, detectNarratedToolStall, detectMutationIntentStall, detectFabricatedDelegation, shouldDeescalateGuard, withEagerToolCallId, claimEagerToolExecution, eagerFailedToolNames, shouldRunEagerRepairTool, shouldStopAfterXmlToolCall, buildReviewFollowupPrompt, extractDelegation, isBashToolName, isWriteToolName } from './brain-helpers.js'; import { runCesarConfirmationFollowUp } from './confirmation-follow-up.js'; @@ -70,7 +72,7 @@ import { consumeCesarPlanControlSignals } from './plan-control-signals.js'; import { hostNowIso, hostWaitForInteractiveChoice } from '../lib/kern-host.js'; -// @kern-source: brain:37 +// @kern-source: brain:38 export async function commitTurnAndDelegate(pendingDel: PendingDelegation, input: string, response: string, cesarEngineId: string, streaming: boolean, dispatch: Dispatch, ctx: HandlerContext, telemetry?: Record, turnAlreadyCommitted?: boolean): Promise { // streaming-end commits a real stream OR (when only a speculative preview // draft sits on the pane) drops the draft without committing — safe no-op when @@ -103,7 +105,7 @@ export async function commitTurnAndDelegate(pendingDel: PendingDelegation, input return { delegated: false, responded: true, decisionReason: 'delegation-cancelled', ...telemetry ?? {} }; } -// @kern-source: brain:64 +// @kern-source: brain:65 export async function commitTurnAndSuggest(suggestion: {action:string, rest?:string, hardened?:boolean, tribunalMode?:string, team?:boolean}, input: string, response: string, cesarEngineId: string, color: number, streaming: boolean, dispatch: Dispatch, ctx: HandlerContext, telemetry?: Record): Promise { // streaming-end commits a real stream OR drops a lingering speculative preview // draft without committing — safe no-op when there's no entry at all. @@ -134,10 +136,10 @@ export async function commitTurnAndSuggest(suggestion: {action:string, rest?:str return { delegated: false, responded: true, decisionReason: 'suggestion-cancelled', ...telemetry ?? {} }; } -// @kern-source: brain:89 +// @kern-source: brain:90 export const _noBriefNudged: WeakMap = new WeakMap(); -// @kern-source: brain:91 +// @kern-source: brain:92 export async function handleCesarBrain(input: string, dispatch: Dispatch, ctx: HandlerContext, images?: ImageAttachment[]): Promise { const abort = new AbortController(); const _turnStart = Date.now(); @@ -382,6 +384,27 @@ export async function handleCesarBrain(input: string, dispatch: Dispatch, ctx: H } return parsed.rest; }; + // ── Structured ask extraction (the [ASK] marker, RULE 7d) ── + // Parse an [ASK]…[/ASK] block out of the brain's text (ask-marker.kern) and + // stash it; the end-of-turn interactive section renders it through the + // existing question overlay — the SAME chokepoint as the heuristic fork / + // confirm pickers, so a deliberate ask suppresses both and the turn can + // never double-prompt. Unlike [TODOS], a located-but-malformed block keeps + // a flag so the turn surfaces a visible warning instead of silently never + // asking (a lost ask is not recoverable; a lost todo list is). + let _pendingAsk: any = null; + let _askMalformed = false; + const extractAsk = (text: string): string => { + const parsed = parseAskMarker(text); + if (!parsed.found) return text; + if (parsed.ask) { + _pendingAsk = parsed.ask; + _askMalformed = false; + } else { + _askMalformed = true; + } + return parsed.rest; + }; const emitLiveTodos = (text: string): string => { const planActive = !!(ctx.activePlan && ['planning', 'awaiting_approval', 'running', 'paused'].includes(ctx.activePlan.state)); if (planActive) return text; @@ -749,6 +772,9 @@ export async function handleCesarBrain(input: string, dispatch: Dispatch, ctx: H // Per-turn native-path [TODOS] display stripper (state encapsulated in the // factory closure: hold buffer + insideBlock). Flushed at stream end. const stripTodosForDisplay = createTodosDisplayStripper(); + // Per-turn native-path [ASK] display stripper — same machinery, own state; + // chained after the todos stripper on every native display path + flush. + const stripAskForDisplay = createAskDisplayStripper(); // Per-turn native-path [INTENT] preamble display stripper. Holds the leading // text until it can decide whether the response opens with an [INTENT] line, // suppressing that line from the streamed display. Flushed at stream end. @@ -1382,7 +1408,7 @@ export async function handleCesarBrain(input: string, dispatch: Dispatch, ctx: H cleanFirst = split.visible; if (split.hasToolMarkup) suppressXmlToolDisplay = true; } else { - cleanFirst = stripTodosForDisplay(cleanFirst); + cleanFirst = stripAskForDisplay(stripTodosForDisplay(cleanFirst)); } if (cleanFirst.trim()) dispatch({ type: 'streaming-chunk', engineId: cesarEngineId, chunk: cleanFirst }); } else { @@ -1399,8 +1425,8 @@ export async function handleCesarBrain(input: string, dispatch: Dispatch, ctx: H if (!displayChunk) continue; } else { // Native path: XML markup suppression doesn't run, so strip the - // live-todos marker here before it reaches the display dispatch. - displayChunk = stripTodosForDisplay(displayChunk); + // live-todos + structured-ask markers here before display dispatch. + displayChunk = stripAskForDisplay(stripTodosForDisplay(displayChunk)); if (!displayChunk && !insideThinkBlock) continue; } if (insideThinkBlock) { @@ -1445,7 +1471,7 @@ export async function handleCesarBrain(input: string, dispatch: Dispatch, ctx: H const trailingPreambleSafe = stripPreambleForDisplay('', true); if (streaming && trailingPreambleSafe) { const routed = ctx.cesar!.hasNativeTools - ? stripTodosForDisplay(trailingPreambleSafe) + ? stripAskForDisplay(stripTodosForDisplay(trailingPreambleSafe)) : takeXmlSafeDisplayChunk(trailingPreambleSafe); if (routed.trim()) dispatch({ type: 'streaming-chunk', engineId: cesarEngineId, chunk: routed }); } @@ -1453,14 +1479,20 @@ export async function handleCesarBrain(input: string, dispatch: Dispatch, ctx: H if (streaming && trailingXmlSafe.trim()) { dispatch({ type: 'streaming-chunk', engineId: cesarEngineId, chunk: trailingXmlSafe }); } - // Native path: flush any held partial-marker tail from the todos stripper - // (force=true bypasses holds, returns held text verbatim) so a final chunk - // ending in a legit prefix like "… see [" isn't silently dropped. + // Native path: flush any held partial-marker tails (force=true bypasses + // holds, returns held text verbatim) so a final chunk ending in a legit + // prefix like "… see [" isn't silently dropped. The todos flush is routed + // through the ask stripper non-forced first (its verbatim tail may still + // contain ask markers), then the ask stripper force-flushes its own hold. if (ctx.cesar!.hasNativeTools) { - const trailingTodosSafe = stripTodosForDisplay('', true); + const trailingTodosSafe = stripAskForDisplay(stripTodosForDisplay('', true)); if (streaming && trailingTodosSafe.trim()) { dispatch({ type: 'streaming-chunk', engineId: cesarEngineId, chunk: trailingTodosSafe }); } + const trailingAskSafe = stripAskForDisplay('', true); + if (streaming && trailingAskSafe.trim()) { + dispatch({ type: 'streaming-chunk', engineId: cesarEngineId, chunk: trailingAskSafe }); + } } } catch (err) { dispatch({ type: 'spinner-stop' }); @@ -1520,6 +1552,9 @@ export async function handleCesarBrain(input: string, dispatch: Dispatch, ctx: H // the source:'live' checklist, and keep only the stripped text. Safe no-op // when no block is present or a plan is active (emitLiveTodos guards both). response = emitLiveTodos(response); + // Extract a structured [ASK] block (RULE 7d) — stashed for the end-of-turn + // interactive section; the marker never reaches the committed transcript. + response = extractAsk(response); // ── Await eager tool results ── if (eagerPromises.length > 0 && !ctx.cesar!.hasNativeTools && session.alive && !abort.signal.aborted) { @@ -1590,7 +1625,7 @@ export async function handleCesarBrain(input: string, dispatch: Dispatch, ctx: H // leaks into committed text on a tool-result continuation. emitPreamble is // idempotent (the _preambleEmitted latch suppresses a duplicate block) and // still strips the marker from the text on every call. - if (continuation.trim()) response = emitLiveTodos(emitPreamble(continuation.trim())); + if (continuation.trim()) response = extractAsk(emitLiveTodos(emitPreamble(continuation.trim()))); } } @@ -2789,7 +2824,7 @@ export async function handleCesarBrain(input: string, dispatch: Dispatch, ctx: H // mirroring the main commit path) so the marker never leaks into the // committed/displayed text on an auto-continuation turn. emitPreamble is // idempotent — the _preambleEmitted latch suppresses a duplicate block. - const cleanCont = emitLiveTodos(emitPreamble(contResponse.replace(/[\s\S]*?<\/think>\s*/gi, '').trim())); + const cleanCont = extractAsk(emitLiveTodos(emitPreamble(contResponse.replace(/[\s\S]*?<\/think>\s*/gi, '').trim()))); // Engine failed to produce a continuation (overflow / rate limit / dead // session). Surface the REAL reason and stop — don't keep nudging into // the misleading canned "paused mid-task" closure (the spin bug). @@ -3157,7 +3192,57 @@ export async function handleCesarBrain(input: string, dispatch: Dispatch, ctx: H dispatch({ type: 'spinner-stop' }); } }; - if (isForkQuestion) { + // A located-but-malformed [ASK] block was stripped from the display; a + // lost ask is not recoverable (the user would silently never be asked), + // so surface it instead of proceeding as if nothing happened. The turn + // still ends normally — the prose (and the awaitingUserInput tail-scan) + // remain the fallback question path. + if (_askMalformed && !_pendingAsk) { + dispatch({ type: 'warning', message: 'Cesar emitted a malformed [ASK] block (stripped). Answer its question in plain text instead.' }); + } + // Deliberate structured ask (RULE 7d): takes precedence over the fork / + // confirm HEURISTICS below — one chokepoint, so a turn can never + // double-prompt. Deliberate asks fire even on chat turns (the chat gate + // exists to stop heuristics boxing users in, not intentional questions). + // Skipped while a plan surface is active (proposal approval / execution + // owns the screen — same guard as emitLiveTodos), falling through to the + // normal end-of-turn path. + const _planSurfaceActive = !!(ctx.activePlan && ['planning', 'awaiting_approval', 'running', 'paused'].includes(ctx.activePlan.state)); + if (_pendingAsk && !_planSurfaceActive && session.alive && !abort.signal.aborted) { + const _askColors = ['#4ade80', '#22d3ee', '#fbbf24', '#a78bfa', '#f97316', '#ef4444']; + const _ask = _pendingAsk as { question: string; options: { label: string; description?: string }[] }; + // Keys are the 1-based positions; keyboard.kern already resolves digits. + // The composer auto-appends the "Other" free-text row (output.kern), so + // the user is never boxed into the listed options. + const _askChoices = _ask.options.map((o, i) => ({ + key: String(i + 1), + label: o.label, + ...(o.description ? { description: o.description } : {}), + color: _askColors[i % _askColors.length], + })); + const picked = String(await hostWaitForInteractiveChoice(abort.signal, (resolve) => { + dispatch({ type: 'question', prompt: `${cesarEngineId} asks: ${_ask.question}`, choices: _askChoices, resolve } as any); + })).toLowerCase(); + // '__other:' routes the typed answer as a fresh user turn via + // handleSubmit (app.kern); Esc resolves 'n'/'' — both leave chosen null. + const _chosenIdx = _askChoices.findIndex((c) => c.key === picked); + const chosen = _chosenIdx >= 0 ? _ask.options[_chosenIdx] : null; + if (chosen && session.alive && !abort.signal.aborted) { + await _runInteractiveChoice(picked, `Answer to your question "${_ask.question}": ${chosen.label}${chosen.description ? ` (${chosen.description})` : ''}. Continue with this choice.`); + if (interactivePlanControl?.error) console.warn(`[agon] Interactive plan control failed: ${interactivePlanControl.error}`); + if (interactivePlanControl?.planProposed) { + return { mode: 'self', delegated: false, responded: true, decisionReason: 'plan-proposed', ...buildToolTelemetry() }; + } + const interactiveDelegation = ctx.cesar?.pendingDelegation; + if (interactiveDelegation) { + ctx.cesar!.pendingDelegation = null; + return await commitTurnAndDelegate(interactiveDelegation, input, response, cesarEngineId, false, dispatch, ctx, buildToolTelemetry(), true); + } + if (_turnTerminalState === 'failed') { + return { delegated: false, responded: true, decisionReason: 'interactive-follow-up-incomplete', ...buildToolTelemetry() }; + } + } + } else if (isForkQuestion) { const _forkColors = ['#4ade80', '#22d3ee', '#fbbf24', '#a78bfa', '#f97316', '#ef4444']; // The composer auto-appends an "Other" row so the user is never boxed // into the listed options — picking it opens an inline text field and diff --git a/packages/cli/src/generated/cesar/session.ts b/packages/cli/src/generated/cesar/session.ts index 42c48351f..d06ef1879 100644 --- a/packages/cli/src/generated/cesar/session.ts +++ b/packages/cli/src/generated/cesar/session.ts @@ -177,6 +177,11 @@ RULE 7b — LIVE TODO CHECKLIST (the sanctioned exception to RULE 7): For multi- [/TODOS] Keep texts short (≤6 words). Do NOT use [TODOS] in plan mode — there ProposePlan owns the checklist. RULE 7c — INTENT PREAMBLE (the second sanctioned exception to RULE 7): Before multi-step work, you MAY open your response with ONE short intent line as the VERY FIRST line: [INTENT] . It is INVISIBLE prose to the user (the runtime strips it and renders a distinct dim line BEFORE your tool work) — never describe it. Only at the start of the response counts; keep it to one line, e.g. [INTENT] Tracing the dispatch path before changing timeouts. OPTIONAL: skip it for single-step or chat turns. +RULE 7d — STRUCTURED ASK (the third sanctioned exception to RULE 7): When you are genuinely unsure between CONCRETE alternatives — scope, approach, which variant the user wants — especially when clarifying scope BEFORE calling ProposePlan, END your turn with ONE [ASK] block: + [ASK] + {"question":"Which storage backend for the session cache?","options":[{"label":"SQLite file","description":"zero dependencies, single file, fine up to ~10k sessions"},{"label":"Postgres","description":"needs a running server, but scales and supports concurrent writers"}]} + [/ASK] +One JSON object: "question" (one line) + "options" (2-4 entries; label ≤ 8 words; description = one short sentence on the trade-off). It is INVISIBLE to the user (the runtime strips it and renders a selectable menu with a free-text "Other" row) — never describe it, never repeat the same question in prose. Place it at the very END of the response, after your findings. At most ONE per turn, and only when the choice genuinely changes what you build — open-ended questions stay plain prose (RULE 10 shape c), and next-ACTION forks stay RULE 10 shape d. Never emit [ASK] during plan execution self-steps. RULE 8 — AUTONOMOUS PLANS: Plan mode is optional, not the default. Stay live unless staged execution is genuinely useful. Switch to planning when the task needs multiple dependent steps, expensive orchestration, resumability, explicit approval, or cost visibility. When you call ProposePlan, decide whether to set autoApprove=true. Set it ONLY when (a) the user clearly described a multi-stage workflow ("plan it, build it, review it"; "investigate then forge it"; "do the whole thing autonomously") AND (b) you have HIGH confidence in the steps after investigation (not before). The runtime applies a layered policy and may still ask the user — your autoApprove=true is permission, not a guarantee. Default to autoApprove=false (or omit it) whenever you are uncertain or when the plan touches mutating steps and the user did not explicitly invite autonomous execution. selfReview defaults to true for mutating plans — only set selfReview=false for purely advisory plans (brainstorm/tribunal/research only) where a code-review gate would have nothing to review. RULE 8b — AUTONOMOUS BUILD TOOLS (Goal / Conquer): These run a build to completion in the BACKGROUND. Goal(intent, queue?, gate?) drives a finite, machine-verifiable task QUEUE (forge → witness → review → commit per task on a goal/* branch). Conquer(task, gate, builder?, engines?) is for an OPEN-ENDED build you'd otherwise babysit: it drives an external builder CLI as the user (builder defaults to codex — pass builder:"codex" / "claude" / "agy") unattended until the gate passes, convening nero/tribunal/council on forks, then STOPS at a human merge gate (NEVER auto-merges). Fire EITHER ONLY when the user explicitly asks to build it unattended ("conquer this with codex", "build it autonomously") — NEVER on a vague request; both are long, real-spend, multi-hour runs. Conquer REQUIRES a discriminating gate (the done-spec, e.g. gate:"pnpm test"); if the user did not give one, ASK for the command that proves the build is done before calling. After calling either, STOP and wait — the background job and the merge gate handle the rest. @@ -204,7 +209,7 @@ RULE 10 — TURN CLOSURE: End every turn with one clear closing line so the user /** * Compact controller prompt for agentic AUTO. Deterministic tool leases, task state, epochs, and verification enforce the mechanics; this prompt states intent instead of duplicating the implementation manual. Keep below 10,000 characters before project/tool context. */ -// @kern-source: session:182 +// @kern-source: session:187 export const CESAR_AGENTIC_SYSTEM_PROMPT: string = [ "You are Cesar, Agon's autonomous coding orchestrator. Be precise, direct, calm, and useful. Match the user's language and level. Lead with outcomes, not process narration.", '', 'TASK OWNERSHIP', @@ -218,6 +223,7 @@ export const CESAR_AGENTIC_SYSTEM_PROMPT: string = [ '- Runtime policy is authoritative: explicit deny rules, workspace escapes, destructive commands, pushes, publishing, deployments, credential changes, and external side effects remain fenced. Never bypass or disguise a denied boundary.', '- ReportConfidence is optional telemetry. Use it only for genuine uncertainty or risk; it is never completion and never a prerequisite.', '- Keep a short [TODOS] checklist for work with more than two steps and update it when state changes.', + '- When a decision genuinely needs the user to pick between concrete alternatives, end the turn with ONE [ASK] block — {"question":"…","options":[{"label":"…","description":"…"}]} between [ASK] and [/ASK] — instead of an open prose question. The runtime renders it as a selectable menu; never also ask the same question in prose.', '', 'VERIFICATION AND COMPLETION', '- A mutating task is complete only after structural mutation evidence and a successful applicable verification command. If no project gate exists, use the narrowest meaningful check and state that limitation.', '- A read-only task is complete when the requested answer is delivered with concrete evidence.', @@ -240,25 +246,25 @@ export const CESAR_AGENTIC_SYSTEM_PROMPT: string = [ /** * The EXACT RULE 1 — CONFIDENCE paragraph baked into CESAR_SYSTEM_PROMPT (the every-turn ReportConfidence ceremony). Held here verbatim so the invariants-mode rewrite is an exact string replacement: strict/shadow keep CESAR_SYSTEM_PROMPT byte-identical, invariants swaps this paragraph for CESAR_RULE_1_INVARIANTS. If RULE 1's wording in CESAR_SYSTEM_PROMPT ever changes, this const MUST change in lockstep or the replacement silently no-ops (the prompt stays strict). applyInvariantsRule1 fail-safes to the strict prompt on a mismatch AND emits a one-time console.warn so the drift is observable instead of silent. */ -// @kern-source: session:215 +// @kern-source: session:221 export const CESAR_RULE_1_STRICT: string = `RULE 1 — CONFIDENCE: Call ReportConfidence(value) FIRST on every turn. If you cannot call tools, write ~X% at the very start instead. No exceptions. On the FIRST turn about a topic, low confidence is expected — investigate, then report your INFORMED confidence. BUT you carry the whole conversation: files you already read, searches you already ran, and conclusions you already reached EARLIER THIS SESSION are still valid context — build on them and report informed confidence immediately. Re-read a file ONLY if it changed or you never saw it. Do NOT restart every turn from zero with "let me check what's going on" when the answer is already in your history — re-discovering what you already know makes you look lost and wastes the user's time.`; /** * RULE 1 rewrite for guard mode 'invariants'. The GuardPipeline's grounded-write/evidence invariants now ENFORCE the confidence signal structurally (a well-formed Edit after a Read IS the proof), so the every-turn ReportConfidence ceremony is demoted to on-demand. RULE 1b is kept verbatim via the strict template — only this paragraph is swapped. */ -// @kern-source: session:221 +// @kern-source: session:227 export const CESAR_RULE_1_INVARIANTS: string = `RULE 1 — CONFIDENCE: Report confidence via ReportConfidence ONLY when you are about to run a risky command (Bash mutations, multi-file writes, delegation) or when genuinely uncertain. Do NOT call it ritually every turn — a well-formed Edit after reading the file IS the confidence signal.`; /** * FIX 3 (R4) — module-level once-flag for applyInvariantsRule1's drift warning. A mutable {warned} holder (mutated in place, never frozen at module load) so the console.warn fires AT MOST ONCE per process even though buildCesarSystemPrompt calls applyInvariantsRule1 on every invariants-mode prompt assembly. Resettable in tests via the exported _resetInvariantsRule1DriftWarning seam. */ -// @kern-source: session:227 +// @kern-source: session:233 export const invariantsRule1DriftState = { warned: false }; /** * Test-only seam: reset the once-flag so a unit test can re-trigger applyInvariantsRule1's drift warning with a deliberately drifted prompt. Not used in production. */ -// @kern-source: session:230 +// @kern-source: session:236 export function _resetInvariantsRule1DriftWarning(): void { invariantsRule1DriftState.warned = false; } @@ -266,7 +272,7 @@ export function _resetInvariantsRule1DriftWarning(): void { /** * Rewrite the every-turn RULE 1 — CONFIDENCE ceremony to the on-demand 'invariants' form. Pure string transform on the assembled CESAR_SYSTEM_PROMPT: replaces the exact CESAR_RULE_1_STRICT paragraph with CESAR_RULE_1_INVARIANTS, leaving RULE 1b and everything else byte-identical. Only called on guard mode 'invariants' — strict/shadow never reach here, so the base prompt stays byte-identical for them by construction. If the strict text isn't found (RULE 1 wording in CESAR_SYSTEM_PROMPT drifted out of sync with the CESAR_RULE_1_STRICT const) it FAILS SAFE: it returns the prompt UNCHANGED (serving the stricter every-turn ceremony rather than silently dropping RULE 1) AND emits a one-time console.warn so the drift is observable instead of passing unnoticed. The warning is gated by a module-level once-flag (invariantsRule1DriftState) so it fires at most once per process despite the per-turn call cadence. */ -// @kern-source: session:236 +// @kern-source: session:242 export function applyInvariantsRule1(prompt: string): string { if (!prompt.includes(CESAR_RULE_1_STRICT)) { if (!invariantsRule1DriftState.warned) { @@ -281,19 +287,19 @@ export function applyInvariantsRule1(prompt: string): string { /** * FIX 6a — re-read ~/.agon/config.json's guardModes at most once per 60s. resolveCesarGuardMode runs on EVERY prompt assembly; the config rarely changes mid-session, so a minute-stale view is fine and keeps the synchronous file read off the per-turn prompt-build path. Mirrors GUARD_TELEMETRY_SNAPSHOT_TTL_MS in status-helpers. */ -// @kern-source: session:249 +// @kern-source: session:255 export const GUARD_MODES_CONFIG_TTL_MS: number = 60 * 1000; /** * FIX 6a — module-level {at, home, value} memo for readGuardModesFromConfig(). `home` keys the entry to the AGON_HOME that produced it so an in-process AGON_HOME change (tests, embedded use) can never serve another home's config for up to a TTL. Mutated in place; never frozen at module load. */ -// @kern-source: session:252 +// @kern-source: session:258 export const guardModesConfigCache = { at: 0, home: '', value: null as (ReturnType) }; /** * FIX 6a — memoized wrapper over readGuardModesFromConfig(): the synchronous ~/.agon/config.json read happens at most once per GUARD_MODES_CONFIG_TTL_MS, keyed by AGON_HOME so an in-process home change invalidates. Best-effort: a read failure caches null for the TTL. Mirrors loadGuardTelemetrySnapshot's memo pattern in status-helpers.kern. */ -// @kern-source: session:255 +// @kern-source: session:261 function readGuardModesFromConfigMemoized(): ReturnType { const now = Date.now(); const home = process.env.AGON_HOME?.trim() ?? ''; @@ -319,7 +325,7 @@ function readGuardModesFromConfigMemoized(): ReturnTypePromise): Promise { const targetCwd = cwd ?? resolveWorkingDir(); let spine = ''; @@ -580,19 +586,19 @@ export async function prepareCesarSystemPrompt(ctx: HandlerContext, cwd?: string return buildCesarSystemPrompt(ctx); } -// @kern-source: session:537 +// @kern-source: session:543 export const CESAR_SNAPSHOT_MSG_CHAR_CAP: number = 4000; -// @kern-source: session:539 +// @kern-source: session:545 export const CONFIDENCE_BLOCK_LIMIT: number = 2; -// @kern-source: session:541 +// @kern-source: session:547 export const SEARCH_NUDGE_THRESHOLD: number = 40; /** * Bound one message's text to CESAR_SNAPSHOT_MSG_CHAR_CAP with a truncation marker. Applied on BOTH snapshot paths (direct session history AND the chat-transcript fallback) so oversized content never floods Cesar's continuity context regardless of which path produced it. */ -// @kern-source: session:543 +// @kern-source: session:549 export function capSnapshotMessageContent(content: string): string { if (content.length <= CESAR_SNAPSHOT_MSG_CHAR_CAP) return content; return `${content.slice(0, CESAR_SNAPSHOT_MSG_CHAR_CAP)}\n… [${content.length - CESAR_SNAPSHOT_MSG_CHAR_CAP} chars truncated for Cesar context]`; @@ -601,7 +607,7 @@ export function capSnapshotMessageContent(content: string): string { /** * Render the `command` string shown in a Cesar permission prompt for a tool call. SaveMemory renders the human-readable '[
] ' (the durable fact the user is confirming) instead of an opaque JSON args blob; every other tool keeps the existing precedence: args.command -> args.file_path -> JSON.stringify(args). Shared across the API-native and both XML-loop permission builders so they render identically (the MCP watcher in brain.kern already special-cases SaveMemory the same way). Pure; tolerant of non-object args. */ -// @kern-source: session:550 +// @kern-source: session:556 export function renderToolPermissionCommand(tool: string, args: unknown): string { const a = (args && typeof args === 'object') ? (args as Record) : {}; if (tool === 'SaveMemory') { @@ -619,7 +625,7 @@ export function renderToolPermissionCommand(tool: string, args: unknown): string /** * Build a normalized continuity snapshot. Prefer the session's internal history; fall back to the visible chat transcript. Per-message string content is capped on EITHER path so review/brainstorm spam (or a huge tool result) doesn't flood Cesar's context; tool_calls/tool_call_id and non-string content are preserved untouched. */ -// @kern-source: session:566 +// @kern-source: session:572 export function buildCesarConversationSnapshot(session: PersistentSession|null, chatSession: any): Array<{role:string,content:any,tool_calls?:any[],tool_call_id?:string}> { const directHistory = session?.getMessageHistory?.() ?? []; if (directHistory.length > 0) { @@ -652,7 +658,7 @@ export function buildCesarConversationSnapshot(session: PersistentSession|null, /** * Persist the active Cesar conversation before the session is discarded. */ -// @kern-source: session:591 +// @kern-source: session:597 export function saveCesarConversationSnapshot(session: PersistentSession|null, chatSession: any): void { if (!session) return; const snapshot = buildCesarConversationSnapshot(session, chatSession); @@ -674,7 +680,7 @@ export function saveCesarConversationSnapshot(session: PersistentSession|null, c /** * Build the onToolCall callback for API engines with native function calling. */ -// @kern-source: session:611 +// @kern-source: session:617 export function buildOnToolCall(ctx: HandlerContext, toolRegistry: ToolRegistry, config: any): ((name:string, args:Record, callId:string, controlPlane?:any) => Promise) | undefined { const cwd = resolveWorkingDir(); const fsc = getProjectFileStateCache(cwd); @@ -1056,7 +1062,7 @@ export function buildOnToolCall(ctx: HandlerContext, toolRegistry: ToolRegistry, /** * Build the onApproval callback for engine tool approvals. Returns true to approve, false to deny silently, or a string to deny with a reason the engine can see. */ -// @kern-source: session:991 +// @kern-source: session:997 export function buildOnApproval(ctx: HandlerContext, engineId: string): (tool:string, command:string, controlPlane?:any) => Promise { const engine = ctx.registry.get(engineId); const evaluateApproval = async (tool: string, command: string): Promise => { @@ -1264,7 +1270,7 @@ export function buildOnApproval(ctx: HandlerContext, engineId: string): (tool:st }; } -// @kern-source: session:1200 +// @kern-source: session:1206 export function normalizeCesarMcpServers(raw: unknown): Array> { const isRecord = (value: unknown): value is Record => !!value && typeof value === 'object' && !Array.isArray(value); @@ -1298,7 +1304,7 @@ export function normalizeCesarMcpServers(raw: unknown): Array>|undefined { if (!(config as any).cesarMcpEnabled) return undefined; @@ -1322,7 +1328,7 @@ export function loadCesarMcpServers(config: any, cwd: string): Array/mcp/index.js (see tsup.config.ts), so the published install is self-contained — no @kernlang/agon-mcp npm dependency. Resolution order: (0) the bundled sibling /mcp/index.js (the published, self-contained path), (1) node module resolution of @kernlang/agon-mcp (monorepo-via-symlink / legacy installs), (2) walk up to the repo root containing packages/mcp/dist/index.js (monorepo without a symlink), (3) the original relative guess as a last resort. `fromUrl` is for tests; defaults to this module's URL. */ -// @kern-source: session:1283 +// @kern-source: session:1289 export function resolveAgonMcpServerPath(fromUrl?: string): string { const raw = fromUrl ?? import.meta.url; // Accept either a file: URL (normal) or a bare path (defensive): fileURLToPath @@ -1388,7 +1394,7 @@ export function resolveAgonMcpServerPath(fromUrl?: string): string { /** * Single source of truth for which backend a Cesar engine will actually use. Honours config.cesarBackend preference ('auto' | 'cli' | 'api'). Pure — no side effects beyond registry lookups. Returns backend='none' when the engine has neither a usable binary nor an API key; callers decide how to handle that. */ -// @kern-source: session:1315 +// @kern-source: session:1321 export function resolveCesarBackend(ctx: HandlerContext, engineId?: string): { backend: 'cli'|'api'|'none', binaryPath: string, hasBinary: boolean, hasApi: boolean, engine: any } { const config = ctx.config; const cesarEngineId = engineId ?? (config as any).cesarEngine ?? config.forgeFixedStarter ?? 'claude'; @@ -1413,7 +1419,7 @@ export function resolveCesarBackend(ctx: HandlerContext, engineId?: string): { b return { backend: 'none', binaryPath: '', hasBinary, hasApi, engine }; } -// @kern-source: session:1341 +// @kern-source: session:1347 export async function ensureCesarSession(ctx: HandlerContext): Promise { const config = ctx.config; const cesarEngineId = (config as any).cesarEngine ?? config.forgeFixedStarter ?? 'claude'; diff --git a/packages/cli/src/generated/surfaces/app-layout.ts b/packages/cli/src/generated/surfaces/app-layout.ts index eb572986f..563102900 100644 --- a/packages/cli/src/generated/surfaces/app-layout.ts +++ b/packages/cli/src/generated/surfaces/app-layout.ts @@ -73,12 +73,21 @@ export function estimateQuestionReservedRows(questionState: any, termWidth: numb const label = String(choice?.label ?? '').trim(); return sum + stringDisplayWidth(`[${fmtChoiceKey(choice, index)}] ${label}`.trim()) + (index > 0 ? 2 : 0); }, 0); - if (questionState.choices.length <= 3 && inlineChoiceWidth <= safeWidth) { + // Structured-ask choices ([ASK] marker) render a dim description line + // under each label, so they never qualify for the single-inline-row + // shortcut and each description reserves its own wrapped rows. + const hasDescriptions = questionState.choices.some((choice: any) => String(choice?.description ?? '').trim()); + if (!hasDescriptions && questionState.choices.length <= 3 && inlineChoiceWidth <= safeWidth) { return promptRows + 1; } const choiceRows = questionState.choices.reduce((sum: number, choice: any, index: number) => { const label = String(choice?.label ?? '').trim(); - return sum + estimateWrappedRowCount(`[${fmtChoiceKey(choice, index)}] ${label}`.trim(), safeWidth); + // The composer renders each description truncated to ONE line + // (truncateCodeLine), so reserve exactly one row per description. + const description = String(choice?.description ?? '').trim(); + return sum + + estimateWrappedRowCount(`[${fmtChoiceKey(choice, index)}] ${label}`.trim(), safeWidth) + + (description ? 1 : 0); }, 0); return promptRows + Math.max(1, choiceRows); } @@ -88,7 +97,7 @@ export function estimateQuestionReservedRows(questionState: any, termWidth: numb /** * Reserve extra rows above the base composer/status chrome for stacked prompt cards, queued-input badges, and the one-line plan chip. The legacy spinner argument is retained for call compatibility, but spinner activity now renders only in CesarStatusStrip and consumes no duplicate row. */ -// @kern-source: app-layout:80 +// @kern-source: app-layout:89 export function estimateBottomChromeExtraRows(_mode: string, questionState: any, termWidth: number, pendingImageCount: number, inputQueueCount: number, _hasLiveSpinner: boolean, hasPlanChip: boolean = false): number { let extraRows = 0; if (pendingImageCount > 0) { @@ -104,7 +113,7 @@ export function estimateBottomChromeExtraRows(_mode: string, questionState: any, return extraRows; } -// @kern-source: app-layout:93 +// @kern-source: app-layout:102 export function estimatePinnedLiveRows(mode: string, hasStream: boolean, hasProgress: boolean, agentCount: number, toolStreamCount?: number): number { const streamRows = hasStream ? ((mode === 'chat') ? 3 : 6) : 0; const progressRows = hasProgress ? ((mode === 'chat') ? 3 : 5) : 0; @@ -116,7 +125,7 @@ export function estimatePinnedLiveRows(mode: string, hasStream: boolean, hasProg return streamRows + progressRows + agentRows + toolRows; } -// @kern-source: app-layout:104 +// @kern-source: app-layout:113 export function estimateWrappedRows(text: string, width: number): number { const safeWidth = Math.max(1, width); if (!text) return 0; @@ -126,7 +135,7 @@ export function estimateWrappedRows(text: string, width: number): number { }, 0); } -// @kern-source: app-layout:114 +// @kern-source: app-layout:123 export function estimateToolCallRows(event: any, toolOutputExpanded: boolean, codeWidth: number): number { if (!event || event.type !== 'tool-call') { return 0; @@ -218,7 +227,7 @@ export function estimateToolCallRows(event: any, toolOutputExpanded: boolean, co return rows; } -// @kern-source: app-layout:186 +// @kern-source: app-layout:195 export function estimateOutputEventRows(event: OutputEvent, mode: string, toolOutputExpanded: boolean, thinkingExpanded: boolean): number { const proseWidth = contentWidth(4); const chatWidth = contentWidth(2); @@ -330,7 +339,7 @@ export function estimateOutputEventRows(event: OutputEvent, mode: string, toolOu } } -// @kern-source: app-layout:298 +// @kern-source: app-layout:307 export function estimateDisplayItemRows(item: OutputBlock, mode: string, toolOutputExpanded: boolean, thinkingExpanded: boolean): number { return estimateOutputEventRows(item.event, mode, toolOutputExpanded, thinkingExpanded); } diff --git a/packages/cli/src/kern/blocks/composer.kern b/packages/cli/src/kern/blocks/composer.kern index 90e0afdaf..3b71e5664 100644 --- a/packages/cli/src/kern/blocks/composer.kern +++ b/packages/cli/src/kern/blocks/composer.kern @@ -115,10 +115,20 @@ screen name=ComposerView target=ink memo=true const selIdx = Math.min(Math.max(0, selectedChoiceIndex ?? 0), Math.max(0, choiceList.length - 1)); const renderChoiceRow = (c: any, i: number, accent: string) => { const active = i === selIdx; + // Structured-ask options ([ASK] marker) carry a one-line trade-off + // description; render it dim under the label so the menu reads like a + // Claude-Code AskUserQuestion. Plain choices (no description) keep the + // original single-row shape. + const desc = typeof c.description === 'string' ? c.description.trim() : ''; return ( - - {active ? '❯ ' : ' '}{i + 1}{'. '}{c.label} - + + + {active ? '❯ ' : ' '}{i + 1}{'. '}{c.label} + + {desc ? ( + {' '}{truncateCodeLine(desc, Math.max(24, termWidth - 12))} + ) : null} + ); }; return ( diff --git a/packages/cli/src/kern/cesar/ask-marker.kern b/packages/cli/src/kern/cesar/ask-marker.kern new file mode 100644 index 000000000..d895e6fb3 --- /dev/null +++ b/packages/cli/src/kern/cesar/ask-marker.kern @@ -0,0 +1,99 @@ +// ── Cesar Structured Ask: parse the [ASK]…[/ASK] question marker ─────── +// The THIRD sanctioned marker idiom (RULE 7d): when the brain is genuinely +// unsure between concrete alternatives it ENDS its turn with one [ASK] block — +// a single JSON object — and the runtime strips it and renders the existing +// question overlay (selectable options + the auto-appended "Other" free-text +// row) instead. Same settled design as [TODOS]/[INTENT]: no engine round-trip, +// no new MCP/XML tool — the brain owns the parse, the model just writes text. +// +// FORMAT (one JSON object per block; the LAST well-formed block wins): +// [ASK] +// {"question":"Which storage backend?", +// "options":[{"label":"SQLite","description":"zero-dep, single file"}, +// {"label":"Postgres","description":"needs a server, scales"}]} +// [/ASK] +// +// Robustness contract — STRICTER than [TODOS] because a lost ask is not +// recoverable (the user is silently never asked): malformed input is still +// ignored (ask:null) and stripped, but `found` stays true so the caller can +// surface a visible warning instead of proceeding as if nothing happened. + +interface name=AskOption export=true + doc "One selectable answer: short label plus an optional one-line trade-off description rendered dim under the row." + field name=label type=string + field name=description type=string optional=true + +interface name=StructuredAsk export=true + doc "A validated structured question: the prompt line and 2–6 options." + field name=question type=string + field name=options type="AskOption[]" + +interface name=AskMarkerResult export=true + field name=ask type="StructuredAsk | null" + doc "The parsed ask from the LAST well-formed block, or null when absent/malformed." + field name=found type=boolean + doc "True when an [ASK]…[/ASK] block was located (even if its body was malformed) — so the caller knows to strip it and can warn on ask:null." + field name=rest type=string + doc "Response text with every [ASK]…[/ASK] block removed, ready to display." + +const name=MAX_ASK_OPTIONS type=number value=6 export=true +const name=MIN_ASK_OPTIONS type=number value=2 export=true +const name=MAX_ASK_LABEL_CHARS type=number value=100 export=true +const name=MAX_ASK_DESCRIPTION_CHARS type=number value=200 export=true +const name=MAX_ASK_QUESTION_CHARS type=number value=300 export=true + +fn name=parseAskMarker params="response:string" returns="AskMarkerResult" export=true + doc "Extract a structured ask from [ASK]…[/ASK] blocks. The last well-formed block wins; ALL blocks are stripped from rest. A located-but-malformed block (bad JSON, missing question, fewer than 2 valid options) returns ask:null with found:true so the caller can warn. Options are capped at 6; labels/descriptions/question are length-clamped." + handler <<< + const text = String(response ?? ''); + const blockRe = /\[ASK\]([\s\S]*?)\[\/ASK\]/gi; + const matches = text.match(blockRe); + if (!matches || matches.length === 0) { + return { ask: null, found: false, rest: text }; + } + const rest = text.replace(blockRe, '').trim(); + // Re-scan to capture bodies (match() loses capture groups). Walk ALL blocks + // and keep the LAST one that validates — a model that changed its mind + // mid-stream re-emits, and the latest snapshot is authoritative (same + // last-wins contract as [TODOS]). + const clamp = (value: string, max: number): string => + value.length > max ? value.slice(0, max - 1).trimEnd() + '…' : value; + let ask: StructuredAsk | null = null; + const re2 = /\[ASK\]([\s\S]*?)\[\/ASK\]/gi; + const bodies: string[] = []; + let m = re2.exec(text); + while (m !== null) { + bodies.push(String(m[1] ?? '').trim()); + m = re2.exec(text); + } + for (const body of bodies) { + let parsed: unknown; + try { + parsed = JSON.parse(body); + } catch { + continue; // malformed body — keep scanning; found stays true + } + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) continue; + const obj = parsed as Record; + const question = obj.question !== undefined && obj.question !== null ? String(obj.question).trim() : ''; + if (!question) continue; + if (!Array.isArray(obj.options)) continue; + const options: AskOption[] = []; + for (const raw of (obj.options as unknown[]).slice(0, MAX_ASK_OPTIONS)) { + if (!raw || typeof raw !== 'object') continue; + const r = raw as Record; + const label = r.label !== undefined && r.label !== null ? String(r.label).trim() : ''; + if (!label) continue; + const description = r.description !== undefined && r.description !== null + ? String(r.description).trim() + : ''; + options.push({ + label: clamp(label, MAX_ASK_LABEL_CHARS), + ...(description ? { description: clamp(description, MAX_ASK_DESCRIPTION_CHARS) } : {}), + }); + } + if (options.length < MIN_ASK_OPTIONS) continue; + ask = { question: clamp(question, MAX_ASK_QUESTION_CHARS), options }; + } + return { ask, found: true, rest }; + >>> diff --git a/packages/cli/src/kern/cesar/brain-helpers.kern b/packages/cli/src/kern/cesar/brain-helpers.kern index ec4d5a895..343cf2c95 100644 --- a/packages/cli/src/kern/cesar/brain-helpers.kern +++ b/packages/cli/src/kern/cesar/brain-helpers.kern @@ -29,7 +29,7 @@ fn name=recordCesarTurn params="ctx:HandlerContext, cesarEngineId:string, input: const name=splitBeforeToolMarkup type="(text:string) => { visible:string, hasToolMarkup:boolean }" export=true handler <<< (text: string) => { - const markers = ['', '', ' string closing over its own hold buffer + insideBlock flag — one instance per turn. force=true flushes any held tail verbatim (bypassing holds) for the stream-end flush; force=false holds partial-marker prefixes (open '[todos]' AND close '[/todos]') across chunks so a marker split mid-stream neither leaks nor latches the block open forever." +const name=createBlockDisplayStripper type="(open: string, close: string) => ((chunk: string, force?: boolean) => string)" export=true + doc "Generic factory for a [TAG]…[/TAG] incremental display stripper — the shared machinery behind createTodosDisplayStripper and createAskDisplayStripper. Takes the lowercase open/close markers; returns a stateful strip fn (chunk, force?) => string closing over its own hold buffer + insideBlock flag — one instance per turn per marker. force=true flushes any held tail verbatim (bypassing holds) for the stream-end flush; force=false holds partial-marker prefixes (open AND close) across chunks so a marker split mid-stream neither leaks nor latches the block open forever." handler <<< - () => { - let insideBlock = false; // between [TODOS] and [/TODOS] + (open: string, close: string) => { + let insideBlock = false; // between [TAG] and [/TAG] let hold = ''; // partial trailing marker prefix carried to next chunk - const OPEN = '[todos]'; - const CLOSE = '[/todos]'; + const OPEN = open.toLowerCase(); + const CLOSE = close.toLowerCase(); // Longest suffix of `combined` that is a strict prefix of `marker` (len < marker.length). const trailingPrefixLen = (combined: string, marker: string): number => { const lower = combined.toLowerCase(); @@ -113,7 +116,7 @@ const name=createTodosDisplayStripper type="() => ((chunk: string, force?: boole combined = combined.slice(start + OPEN.length); } if (!insideBlock) { - // Guard a partial trailing '[todos]' (or shorter prefix) split mid-marker. + // Guard a partial trailing open marker (or shorter prefix) split mid-marker. const p = trailingPrefixLen(combined, OPEN); if (p > 0) { hold = combined.slice(combined.length - p); @@ -126,6 +129,18 @@ const name=createTodosDisplayStripper type="() => ((chunk: string, force?: boole } >>> +const name=createTodosDisplayStripper type="() => ((chunk: string, force?: boolean) => string)" export=true + doc "Per-turn [TODOS]…[/TODOS] display stripper — createBlockDisplayStripper bound to the live-todos marker." + handler <<< + () => createBlockDisplayStripper('[todos]', '[/todos]') + >>> + +const name=createAskDisplayStripper type="() => ((chunk: string, force?: boolean) => string)" export=true + doc "Per-turn [ASK]…[/ASK] display stripper — createBlockDisplayStripper bound to the structured-ask marker (RULE 7d), so the JSON question block never leaks into the streamed display." + handler <<< + () => createBlockDisplayStripper('[ask]', '[/ask]') + >>> + // ── Preamble [INTENT] incremental display stripper (factory) ────────── // Sibling to createTodosDisplayStripper for the SECOND marker idiom: a single // START-anchored `[INTENT] ` preamble. The shapes differ enough that diff --git a/packages/cli/src/kern/cesar/brain.kern b/packages/cli/src/kern/cesar/brain.kern index f89dad167..8277e87d1 100644 --- a/packages/cli/src/kern/cesar/brain.kern +++ b/packages/cli/src/kern/cesar/brain.kern @@ -9,6 +9,7 @@ import from="./confidence.js" names="CONFIDENCE_TIERS,parseConfidence,confidence import from="./suggestion.js" names="parseSuggestion" import from="./fork-options.js" names="extractAdjacentForkOptions" import from="./todos-marker.js" names="parseLiveTodos,parsePreamble" +import from="./ask-marker.js" names="parseAskMarker" import from="./session.js" names="ensureCesarSession,CESAR_SYSTEM_PROMPT,buildCesarSystemPrompt,resolveCesarBackend,renderToolPermissionCommand" import from="./context-budget.js" names="enforceContextBudget" import from="./tools.js" names="createCesarToolRegistry,createEagerToolContext,executeEagerTool" @@ -29,7 +30,7 @@ import from="./permission-resolver.js" names="resolveAgonPermissionMode,resolveP import from="./gate-runner.js" names="shouldAutoRunGate,gateAutoRunTriggered,gateAutoRunPermitted,gateAutoRunLimit,gateTimeoutMs,gateOutputTailChars,runDiscoveredGate,buildGateFailureMessage,buildGateSuccessNote" import from="../signals/output.js" names="getSessionAllowList" import from="./task-controller.js" names="resolveCesarHarnessProfile,evaluateAgenticTaskState,buildAgenticProgressSignature,buildAgenticAutoTurnDirective,resolveCesarToolReadOnlyMode,extractAgenticBashCommand,isAgenticMutationOutcome" -import from="./brain-helpers.js" names="yieldToInk,recordCesarTurn,splitBeforeToolMarkup,XML_TOOL_MARKUP_HOLD_CHARS,createTodosDisplayStripper,createPreambleStripper,findTrailingUserQuestion,detectAwaitingUserInput,detectNarratedToolStall,detectMutationIntentStall,detectFabricatedDelegation,shouldDeescalateGuard,withEagerToolCallId,claimEagerToolExecution,eagerFailedToolNames,shouldRunEagerRepairTool,shouldStopAfterXmlToolCall,buildReviewFollowupPrompt,extractDelegation,isBashToolName,isWriteToolName" +import from="./brain-helpers.js" names="yieldToInk,recordCesarTurn,splitBeforeToolMarkup,XML_TOOL_MARKUP_HOLD_CHARS,createTodosDisplayStripper,createAskDisplayStripper,createPreambleStripper,findTrailingUserQuestion,detectAwaitingUserInput,detectNarratedToolStall,detectMutationIntentStall,detectFabricatedDelegation,shouldDeescalateGuard,withEagerToolCallId,claimEagerToolExecution,eagerFailedToolNames,shouldRunEagerRepairTool,shouldStopAfterXmlToolCall,buildReviewFollowupPrompt,extractDelegation,isBashToolName,isWriteToolName" import from="./confirmation-follow-up.js" names="runCesarConfirmationFollowUp" import from="./plan-control-signals.js" names="consumeCesarPlanControlSignals" import from="../lib/kern-host.js" names="hostNowIso,hostWaitForInteractiveChoice" @@ -333,6 +334,27 @@ fn name=handleCesarBrain async=true params="input:string, dispatch:Dispatch, ctx } return parsed.rest; }; + // ── Structured ask extraction (the [ASK] marker, RULE 7d) ── + // Parse an [ASK]…[/ASK] block out of the brain's text (ask-marker.kern) and + // stash it; the end-of-turn interactive section renders it through the + // existing question overlay — the SAME chokepoint as the heuristic fork / + // confirm pickers, so a deliberate ask suppresses both and the turn can + // never double-prompt. Unlike [TODOS], a located-but-malformed block keeps + // a flag so the turn surfaces a visible warning instead of silently never + // asking (a lost ask is not recoverable; a lost todo list is). + let _pendingAsk: any = null; + let _askMalformed = false; + const extractAsk = (text: string): string => { + const parsed = parseAskMarker(text); + if (!parsed.found) return text; + if (parsed.ask) { + _pendingAsk = parsed.ask; + _askMalformed = false; + } else { + _askMalformed = true; + } + return parsed.rest; + }; const emitLiveTodos = (text: string): string => { const planActive = !!(ctx.activePlan && ['planning', 'awaiting_approval', 'running', 'paused'].includes(ctx.activePlan.state)); if (planActive) return text; @@ -700,6 +722,9 @@ fn name=handleCesarBrain async=true params="input:string, dispatch:Dispatch, ctx // Per-turn native-path [TODOS] display stripper (state encapsulated in the // factory closure: hold buffer + insideBlock). Flushed at stream end. const stripTodosForDisplay = createTodosDisplayStripper(); + // Per-turn native-path [ASK] display stripper — same machinery, own state; + // chained after the todos stripper on every native display path + flush. + const stripAskForDisplay = createAskDisplayStripper(); // Per-turn native-path [INTENT] preamble display stripper. Holds the leading // text until it can decide whether the response opens with an [INTENT] line, // suppressing that line from the streamed display. Flushed at stream end. @@ -1333,7 +1358,7 @@ ${reviewFollowup.prompt}`; cleanFirst = split.visible; if (split.hasToolMarkup) suppressXmlToolDisplay = true; } else { - cleanFirst = stripTodosForDisplay(cleanFirst); + cleanFirst = stripAskForDisplay(stripTodosForDisplay(cleanFirst)); } if (cleanFirst.trim()) dispatch({ type: 'streaming-chunk', engineId: cesarEngineId, chunk: cleanFirst }); } else { @@ -1350,8 +1375,8 @@ ${reviewFollowup.prompt}`; if (!displayChunk) continue; } else { // Native path: XML markup suppression doesn't run, so strip the - // live-todos marker here before it reaches the display dispatch. - displayChunk = stripTodosForDisplay(displayChunk); + // live-todos + structured-ask markers here before display dispatch. + displayChunk = stripAskForDisplay(stripTodosForDisplay(displayChunk)); if (!displayChunk && !insideThinkBlock) continue; } if (insideThinkBlock) { @@ -1396,7 +1421,7 @@ ${reviewFollowup.prompt}`; const trailingPreambleSafe = stripPreambleForDisplay('', true); if (streaming && trailingPreambleSafe) { const routed = ctx.cesar!.hasNativeTools - ? stripTodosForDisplay(trailingPreambleSafe) + ? stripAskForDisplay(stripTodosForDisplay(trailingPreambleSafe)) : takeXmlSafeDisplayChunk(trailingPreambleSafe); if (routed.trim()) dispatch({ type: 'streaming-chunk', engineId: cesarEngineId, chunk: routed }); } @@ -1404,14 +1429,20 @@ ${reviewFollowup.prompt}`; if (streaming && trailingXmlSafe.trim()) { dispatch({ type: 'streaming-chunk', engineId: cesarEngineId, chunk: trailingXmlSafe }); } - // Native path: flush any held partial-marker tail from the todos stripper - // (force=true bypasses holds, returns held text verbatim) so a final chunk - // ending in a legit prefix like "… see [" isn't silently dropped. + // Native path: flush any held partial-marker tails (force=true bypasses + // holds, returns held text verbatim) so a final chunk ending in a legit + // prefix like "… see [" isn't silently dropped. The todos flush is routed + // through the ask stripper non-forced first (its verbatim tail may still + // contain ask markers), then the ask stripper force-flushes its own hold. if (ctx.cesar!.hasNativeTools) { - const trailingTodosSafe = stripTodosForDisplay('', true); + const trailingTodosSafe = stripAskForDisplay(stripTodosForDisplay('', true)); if (streaming && trailingTodosSafe.trim()) { dispatch({ type: 'streaming-chunk', engineId: cesarEngineId, chunk: trailingTodosSafe }); } + const trailingAskSafe = stripAskForDisplay('', true); + if (streaming && trailingAskSafe.trim()) { + dispatch({ type: 'streaming-chunk', engineId: cesarEngineId, chunk: trailingAskSafe }); + } } } catch (err) { dispatch({ type: 'spinner-stop' }); @@ -1471,6 +1502,9 @@ ${reviewFollowup.prompt}`; // the source:'live' checklist, and keep only the stripped text. Safe no-op // when no block is present or a plan is active (emitLiveTodos guards both). response = emitLiveTodos(response); + // Extract a structured [ASK] block (RULE 7d) — stashed for the end-of-turn + // interactive section; the marker never reaches the committed transcript. + response = extractAsk(response); // ── Await eager tool results ── if (eagerPromises.length > 0 && !ctx.cesar!.hasNativeTools && session.alive && !abort.signal.aborted) { @@ -1541,7 +1575,7 @@ ${reviewFollowup.prompt}`; // leaks into committed text on a tool-result continuation. emitPreamble is // idempotent (the _preambleEmitted latch suppresses a duplicate block) and // still strips the marker from the text on every call. - if (continuation.trim()) response = emitLiveTodos(emitPreamble(continuation.trim())); + if (continuation.trim()) response = extractAsk(emitLiveTodos(emitPreamble(continuation.trim()))); } } @@ -2740,7 +2774,7 @@ ${reviewFollowup.prompt}`; // mirroring the main commit path) so the marker never leaks into the // committed/displayed text on an auto-continuation turn. emitPreamble is // idempotent — the _preambleEmitted latch suppresses a duplicate block. - const cleanCont = emitLiveTodos(emitPreamble(contResponse.replace(/[\s\S]*?<\/think>\s*/gi, '').trim())); + const cleanCont = extractAsk(emitLiveTodos(emitPreamble(contResponse.replace(/[\s\S]*?<\/think>\s*/gi, '').trim()))); // Engine failed to produce a continuation (overflow / rate limit / dead // session). Surface the REAL reason and stop — don't keep nudging into // the misleading canned "paused mid-task" closure (the spin bug). @@ -3108,7 +3142,57 @@ ${reviewFollowup.prompt}`; dispatch({ type: 'spinner-stop' }); } }; - if (isForkQuestion) { + // A located-but-malformed [ASK] block was stripped from the display; a + // lost ask is not recoverable (the user would silently never be asked), + // so surface it instead of proceeding as if nothing happened. The turn + // still ends normally — the prose (and the awaitingUserInput tail-scan) + // remain the fallback question path. + if (_askMalformed && !_pendingAsk) { + dispatch({ type: 'warning', message: 'Cesar emitted a malformed [ASK] block (stripped). Answer its question in plain text instead.' }); + } + // Deliberate structured ask (RULE 7d): takes precedence over the fork / + // confirm HEURISTICS below — one chokepoint, so a turn can never + // double-prompt. Deliberate asks fire even on chat turns (the chat gate + // exists to stop heuristics boxing users in, not intentional questions). + // Skipped while a plan surface is active (proposal approval / execution + // owns the screen — same guard as emitLiveTodos), falling through to the + // normal end-of-turn path. + const _planSurfaceActive = !!(ctx.activePlan && ['planning', 'awaiting_approval', 'running', 'paused'].includes(ctx.activePlan.state)); + if (_pendingAsk && !_planSurfaceActive && session.alive && !abort.signal.aborted) { + const _askColors = ['#4ade80', '#22d3ee', '#fbbf24', '#a78bfa', '#f97316', '#ef4444']; + const _ask = _pendingAsk as { question: string; options: { label: string; description?: string }[] }; + // Keys are the 1-based positions; keyboard.kern already resolves digits. + // The composer auto-appends the "Other" free-text row (output.kern), so + // the user is never boxed into the listed options. + const _askChoices = _ask.options.map((o, i) => ({ + key: String(i + 1), + label: o.label, + ...(o.description ? { description: o.description } : {}), + color: _askColors[i % _askColors.length], + })); + const picked = String(await hostWaitForInteractiveChoice(abort.signal, (resolve) => { + dispatch({ type: 'question', prompt: `${cesarEngineId} asks: ${_ask.question}`, choices: _askChoices, resolve } as any); + })).toLowerCase(); + // '__other:' routes the typed answer as a fresh user turn via + // handleSubmit (app.kern); Esc resolves 'n'/'' — both leave chosen null. + const _chosenIdx = _askChoices.findIndex((c) => c.key === picked); + const chosen = _chosenIdx >= 0 ? _ask.options[_chosenIdx] : null; + if (chosen && session.alive && !abort.signal.aborted) { + await _runInteractiveChoice(picked, `Answer to your question "${_ask.question}": ${chosen.label}${chosen.description ? ` (${chosen.description})` : ''}. Continue with this choice.`); + if (interactivePlanControl?.error) console.warn(`[agon] Interactive plan control failed: ${interactivePlanControl.error}`); + if (interactivePlanControl?.planProposed) { + return { mode: 'self', delegated: false, responded: true, decisionReason: 'plan-proposed', ...buildToolTelemetry() }; + } + const interactiveDelegation = ctx.cesar?.pendingDelegation; + if (interactiveDelegation) { + ctx.cesar!.pendingDelegation = null; + return await commitTurnAndDelegate(interactiveDelegation, input, response, cesarEngineId, false, dispatch, ctx, buildToolTelemetry(), true); + } + if (_turnTerminalState === 'failed') { + return { delegated: false, responded: true, decisionReason: 'interactive-follow-up-incomplete', ...buildToolTelemetry() }; + } + } + } else if (isForkQuestion) { const _forkColors = ['#4ade80', '#22d3ee', '#fbbf24', '#a78bfa', '#f97316', '#ef4444']; // The composer auto-appends an "Other" row so the user is never boxed // into the listed options — picking it opens an inline text field and diff --git a/packages/cli/src/kern/cesar/session.kern b/packages/cli/src/kern/cesar/session.kern index 6dc1d8ac3..89f4414dc 100644 --- a/packages/cli/src/kern/cesar/session.kern +++ b/packages/cli/src/kern/cesar/session.kern @@ -154,6 +154,11 @@ RULE 7b — LIVE TODO CHECKLIST (the sanctioned exception to RULE 7): For multi- [/TODOS] Keep texts short (≤6 words). Do NOT use [TODOS] in plan mode — there ProposePlan owns the checklist. RULE 7c — INTENT PREAMBLE (the second sanctioned exception to RULE 7): Before multi-step work, you MAY open your response with ONE short intent line as the VERY FIRST line: [INTENT] . It is INVISIBLE prose to the user (the runtime strips it and renders a distinct dim line BEFORE your tool work) — never describe it. Only at the start of the response counts; keep it to one line, e.g. [INTENT] Tracing the dispatch path before changing timeouts. OPTIONAL: skip it for single-step or chat turns. +RULE 7d — STRUCTURED ASK (the third sanctioned exception to RULE 7): When you are genuinely unsure between CONCRETE alternatives — scope, approach, which variant the user wants — especially when clarifying scope BEFORE calling ProposePlan, END your turn with ONE [ASK] block: + [ASK] + {"question":"Which storage backend for the session cache?","options":[{"label":"SQLite file","description":"zero dependencies, single file, fine up to ~10k sessions"},{"label":"Postgres","description":"needs a running server, but scales and supports concurrent writers"}]} + [/ASK] +One JSON object: "question" (one line) + "options" (2-4 entries; label ≤ 8 words; description = one short sentence on the trade-off). It is INVISIBLE to the user (the runtime strips it and renders a selectable menu with a free-text "Other" row) — never describe it, never repeat the same question in prose. Place it at the very END of the response, after your findings. At most ONE per turn, and only when the choice genuinely changes what you build — open-ended questions stay plain prose (RULE 10 shape c), and next-ACTION forks stay RULE 10 shape d. Never emit [ASK] during plan execution self-steps. RULE 8 — AUTONOMOUS PLANS: Plan mode is optional, not the default. Stay live unless staged execution is genuinely useful. Switch to planning when the task needs multiple dependent steps, expensive orchestration, resumability, explicit approval, or cost visibility. When you call ProposePlan, decide whether to set autoApprove=true. Set it ONLY when (a) the user clearly described a multi-stage workflow ("plan it, build it, review it"; "investigate then forge it"; "do the whole thing autonomously") AND (b) you have HIGH confidence in the steps after investigation (not before). The runtime applies a layered policy and may still ask the user — your autoApprove=true is permission, not a guarantee. Default to autoApprove=false (or omit it) whenever you are uncertain or when the plan touches mutating steps and the user did not explicitly invite autonomous execution. selfReview defaults to true for mutating plans — only set selfReview=false for purely advisory plans (brainstorm/tribunal/research only) where a code-review gate would have nothing to review. RULE 8b — AUTONOMOUS BUILD TOOLS (Goal / Conquer): These run a build to completion in the BACKGROUND. Goal(intent, queue?, gate?) drives a finite, machine-verifiable task QUEUE (forge → witness → review → commit per task on a goal/* branch). Conquer(task, gate, builder?, engines?) is for an OPEN-ENDED build you'd otherwise babysit: it drives an external builder CLI as the user (builder defaults to codex — pass builder:"codex" / "claude" / "agy") unattended until the gate passes, convening nero/tribunal/council on forks, then STOPS at a human merge gate (NEVER auto-merges). Fire EITHER ONLY when the user explicitly asks to build it unattended ("conquer this with codex", "build it autonomously") — NEVER on a vague request; both are long, real-spend, multi-hour runs. Conquer REQUIRES a discriminating gate (the done-spec, e.g. gate:"pnpm test"); if the user did not give one, ASK for the command that proves the build is done before calling. After calling either, STOP and wait — the background job and the merge gate handle the rest. @@ -192,6 +197,7 @@ const name=CESAR_AGENTIC_SYSTEM_PROMPT type=string export=true value={{ [ '- Runtime policy is authoritative: explicit deny rules, workspace escapes, destructive commands, pushes, publishing, deployments, credential changes, and external side effects remain fenced. Never bypass or disguise a denied boundary.', '- ReportConfidence is optional telemetry. Use it only for genuine uncertainty or risk; it is never completion and never a prerequisite.', '- Keep a short [TODOS] checklist for work with more than two steps and update it when state changes.', + '- When a decision genuinely needs the user to pick between concrete alternatives, end the turn with ONE [ASK] block — {"question":"…","options":[{"label":"…","description":"…"}]} between [ASK] and [/ASK] — instead of an open prose question. The runtime renders it as a selectable menu; never also ask the same question in prose.', '', 'VERIFICATION AND COMPLETION', '- A mutating task is complete only after structural mutation evidence and a successful applicable verification command. If no project gate exists, use the narrowest meaningful check and state that limitation.', '- A read-only task is complete when the requested answer is delivered with concrete evidence.', diff --git a/packages/cli/src/kern/surfaces/app-layout.kern b/packages/cli/src/kern/surfaces/app-layout.kern index c5c522b8b..00831f858 100644 --- a/packages/cli/src/kern/surfaces/app-layout.kern +++ b/packages/cli/src/kern/surfaces/app-layout.kern @@ -65,12 +65,21 @@ module name=AppLayout const label = String(choice?.label ?? '').trim(); return sum + stringDisplayWidth(`[${fmtChoiceKey(choice, index)}] ${label}`.trim()) + (index > 0 ? 2 : 0); }, 0); - if (questionState.choices.length <= 3 && inlineChoiceWidth <= safeWidth) { + // Structured-ask choices ([ASK] marker) render a dim description line + // under each label, so they never qualify for the single-inline-row + // shortcut and each description reserves its own wrapped rows. + const hasDescriptions = questionState.choices.some((choice: any) => String(choice?.description ?? '').trim()); + if (!hasDescriptions && questionState.choices.length <= 3 && inlineChoiceWidth <= safeWidth) { return promptRows + 1; } const choiceRows = questionState.choices.reduce((sum: number, choice: any, index: number) => { const label = String(choice?.label ?? '').trim(); - return sum + estimateWrappedRowCount(`[${fmtChoiceKey(choice, index)}] ${label}`.trim(), safeWidth); + // The composer renders each description truncated to ONE line + // (truncateCodeLine), so reserve exactly one row per description. + const description = String(choice?.description ?? '').trim(); + return sum + + estimateWrappedRowCount(`[${fmtChoiceKey(choice, index)}] ${label}`.trim(), safeWidth) + + (description ? 1 : 0); }, 0); return promptRows + Math.max(1, choiceRows); } diff --git a/tests/unit/ask-marker.test.ts b/tests/unit/ask-marker.test.ts new file mode 100644 index 000000000..0dda7de00 --- /dev/null +++ b/tests/unit/ask-marker.test.ts @@ -0,0 +1,136 @@ +import { describe, expect, it } from 'vitest'; +import { parseAskMarker } from '../../packages/cli/src/generated/cesar/ask-marker.js'; +import { createAskDisplayStripper } from '../../packages/cli/src/generated/cesar/brain-helpers.js'; + +const block = (body: string) => `[ASK]\n${body}\n[/ASK]`; + +describe('parseAskMarker — structured [ASK] marker parser', () => { + it('parses a well-formed block and strips it from rest', () => { + const text = [ + 'Two viable backends here.', + block('{"question":"Which storage backend?","options":[{"label":"SQLite","description":"zero deps"},{"label":"Postgres","description":"needs a server"}]}'), + ].join('\n'); + const r = parseAskMarker(text); + expect(r.found).toBe(true); + expect(r.ask).not.toBeNull(); + expect(r.ask!.question).toBe('Which storage backend?'); + expect(r.ask!.options).toHaveLength(2); + expect(r.ask!.options[0]).toEqual({ label: 'SQLite', description: 'zero deps' }); + expect(r.rest).toBe('Two viable backends here.'); + expect(r.rest).not.toContain('[ASK]'); + }); + + it('returns found:false and untouched rest when no block present', () => { + const text = 'Just a normal answer.'; + const r = parseAskMarker(text); + expect(r.found).toBe(false); + expect(r.ask).toBeNull(); + expect(r.rest).toBe(text); + }); + + it('treats malformed JSON as found-but-null so the caller can warn', () => { + const r = parseAskMarker(block('{question: "oops", options: [}')); + expect(r.found).toBe(true); + expect(r.ask).toBeNull(); + expect(r.rest).not.toContain('[ASK]'); + }); + + it('rejects a block with fewer than 2 valid options', () => { + const r = parseAskMarker(block('{"question":"Pick?","options":[{"label":"Only one"}]}')); + expect(r.found).toBe(true); + expect(r.ask).toBeNull(); + }); + + it('rejects a block with a missing or empty question', () => { + const r = parseAskMarker(block('{"options":[{"label":"A"},{"label":"B"}]}')); + expect(r.ask).toBeNull(); + const r2 = parseAskMarker(block('{"question":" ","options":[{"label":"A"},{"label":"B"}]}')); + expect(r2.ask).toBeNull(); + }); + + it('skips invalid option entries and keeps valid ones', () => { + const r = parseAskMarker(block('{"question":"Pick?","options":[{"label":"A"},null,{"nope":1},{"label":"B","description":42}]}')); + expect(r.ask).not.toBeNull(); + expect(r.ask!.options).toHaveLength(2); + // Non-string description is stringified, not dropped. + expect(r.ask!.options[1]).toEqual({ label: 'B', description: '42' }); + }); + + it('caps options at 6', () => { + const options = Array.from({ length: 9 }, (_, i) => ({ label: `Option ${i + 1}` })); + const r = parseAskMarker(block(JSON.stringify({ question: 'Pick?', options }))); + expect(r.ask!.options).toHaveLength(6); + expect(r.ask!.options[5].label).toBe('Option 6'); + }); + + it('last well-formed block wins; all blocks are stripped', () => { + const text = [ + block('{"question":"First?","options":[{"label":"A"},{"label":"B"}]}'), + 'some prose', + block('{"question":"Second?","options":[{"label":"C"},{"label":"D"}]}'), + ].join('\n'); + const r = parseAskMarker(text); + expect(r.ask!.question).toBe('Second?'); + expect(r.rest).toBe('some prose'); + }); + + it('a later malformed block does not clobber an earlier well-formed one', () => { + const text = [ + block('{"question":"Good?","options":[{"label":"A"},{"label":"B"}]}'), + block('{broken'), + ].join('\n'); + const r = parseAskMarker(text); + expect(r.ask).not.toBeNull(); + expect(r.ask!.question).toBe('Good?'); + }); + + it('clamps overlong question, label, and description', () => { + const long = 'x'.repeat(500); + const r = parseAskMarker(block(JSON.stringify({ + question: long, + options: [{ label: long, description: long }, { label: 'B' }], + }))); + expect(r.ask!.question.length).toBeLessThanOrEqual(300); + expect(r.ask!.question.endsWith('…')).toBe(true); + expect(r.ask!.options[0].label.length).toBeLessThanOrEqual(100); + expect(r.ask!.options[0].description!.length).toBeLessThanOrEqual(200); + }); + + it('is case-insensitive on the marker tags', () => { + const r = parseAskMarker('[ask]{"question":"Q?","options":[{"label":"A"},{"label":"B"}]}[/ask]'); + expect(r.found).toBe(true); + expect(r.ask!.question).toBe('Q?'); + }); +}); + +describe('createAskDisplayStripper — native-path streaming stripper', () => { + it('strips a complete block within one chunk', () => { + const strip = createAskDisplayStripper(); + expect(strip('before [ASK]{"q":1}[/ASK] after')).toBe('before after'); + }); + + it('suppresses an open block across chunks and resumes after the close tag', () => { + const strip = createAskDisplayStripper(); + expect(strip('text [ASK]{"question":')).toBe('text '); + expect(strip('"Q?","options":[]}')).toBe(''); + expect(strip('[/ASK] tail')).toBe(' tail'); + }); + + it('holds a partial open marker split across chunks', () => { + const strip = createAskDisplayStripper(); + expect(strip('see [AS')).toBe('see '); + expect(strip('K]hidden[/ASK]visible')).toBe('visible'); + }); + + it('holds a partial close marker split across chunks without latching open', () => { + const strip = createAskDisplayStripper(); + strip('[ASK]body [/AS'); + expect(strip('K]done')).toBe('done'); + }); + + it('force flush returns a held legit prefix verbatim at stream end', () => { + const strip = createAskDisplayStripper(); + expect(strip('trailing [')).toBe('trailing '); + expect(strip('', true)).toBe('['); + }); +}); From 020f6d6018086b2f1f8a639daea70517b7eb3207 Mon Sep 17 00:00:00 2001 From: "agon (KERN)" <292465531+KERN-Agon@users.noreply.github.com> Date: Thu, 16 Jul 2026 18:34:36 +0200 Subject: [PATCH 2/2] fix(cesar): never lose a stripped [ASK] when the overlay cannot render MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Multi-engine review consensus (codex 0.84 + kimi 0.78): extractAsk always strips the block from streamed and committed text, but the overlay branch is skipped while a plan surface is active or the session is dead — the well-formed ask vanished without a trace. Surface it as plain visible text in that case (options numbered, answer via composer); a user interrupt stays a deliberate skip. ⚔️ Forged by [Agon](https://github.com/KERNlang/agon) Co-Authored-By: agon (KERN) <292465531+KERN-Agon@users.noreply.github.com> --- packages/cli/src/generated/cesar/brain.ts | 13 +++++++++++++ packages/cli/src/kern/cesar/brain.kern | 13 +++++++++++++ 2 files changed, 26 insertions(+) diff --git a/packages/cli/src/generated/cesar/brain.ts b/packages/cli/src/generated/cesar/brain.ts index 42a8f58b1..2a4acc3be 100644 --- a/packages/cli/src/generated/cesar/brain.ts +++ b/packages/cli/src/generated/cesar/brain.ts @@ -3208,6 +3208,19 @@ export async function handleCesarBrain(input: string, dispatch: Dispatch, ctx: H // owns the screen — same guard as emitLiveTodos), falling through to the // normal end-of-turn path. const _planSurfaceActive = !!(ctx.activePlan && ['planning', 'awaiting_approval', 'running', 'paused'].includes(ctx.activePlan.state)); + // NEVER-LOSE-AN-ASK fallback (agon-review, codex+kimi consensus): the + // [ASK] block was already stripped from the streamed AND committed text, + // so when the interactive overlay can't render (plan surface owns the + // screen, or the session died) the question must be surfaced as plain + // visible text — otherwise a well-formed ask vanishes without a trace. + // A user interrupt (abort) is the one deliberate skip: they cancelled + // the turn, re-asking would be noise. + if (_pendingAsk && (_planSurfaceActive || !session.alive) && !abort.signal.aborted) { + const _lostAsk = _pendingAsk as { question: string; options: { label: string; description?: string }[] }; + const _lostLines = _lostAsk.options.map((o, i) => ` ${i + 1}. ${o.label}${o.description ? ` — ${o.description}` : ''}`); + dispatch({ type: 'info', message: `Cesar asks: ${_lostAsk.question}\n${_lostLines.join('\n')}\n(answer in the composer when ready)` }); + _pendingAsk = null; + } if (_pendingAsk && !_planSurfaceActive && session.alive && !abort.signal.aborted) { const _askColors = ['#4ade80', '#22d3ee', '#fbbf24', '#a78bfa', '#f97316', '#ef4444']; const _ask = _pendingAsk as { question: string; options: { label: string; description?: string }[] }; diff --git a/packages/cli/src/kern/cesar/brain.kern b/packages/cli/src/kern/cesar/brain.kern index 8277e87d1..b6b40da17 100644 --- a/packages/cli/src/kern/cesar/brain.kern +++ b/packages/cli/src/kern/cesar/brain.kern @@ -3158,6 +3158,19 @@ ${reviewFollowup.prompt}`; // owns the screen — same guard as emitLiveTodos), falling through to the // normal end-of-turn path. const _planSurfaceActive = !!(ctx.activePlan && ['planning', 'awaiting_approval', 'running', 'paused'].includes(ctx.activePlan.state)); + // NEVER-LOSE-AN-ASK fallback (agon-review, codex+kimi consensus): the + // [ASK] block was already stripped from the streamed AND committed text, + // so when the interactive overlay can't render (plan surface owns the + // screen, or the session died) the question must be surfaced as plain + // visible text — otherwise a well-formed ask vanishes without a trace. + // A user interrupt (abort) is the one deliberate skip: they cancelled + // the turn, re-asking would be noise. + if (_pendingAsk && (_planSurfaceActive || !session.alive) && !abort.signal.aborted) { + const _lostAsk = _pendingAsk as { question: string; options: { label: string; description?: string }[] }; + const _lostLines = _lostAsk.options.map((o, i) => ` ${i + 1}. ${o.label}${o.description ? ` — ${o.description}` : ''}`); + dispatch({ type: 'info', message: `Cesar asks: ${_lostAsk.question}\n${_lostLines.join('\n')}\n(answer in the composer when ready)` }); + _pendingAsk = null; + } if (_pendingAsk && !_planSurfaceActive && session.alive && !abort.signal.aborted) { const _askColors = ['#4ade80', '#22d3ee', '#fbbf24', '#a78bfa', '#f97316', '#ef4444']; const _ask = _pendingAsk as { question: string; options: { label: string; description?: string }[] };