diff --git a/README.md b/README.md index 7d102fb7d..28186dad1 100644 --- a/README.md +++ b/README.md @@ -382,9 +382,9 @@ agon conquer "..." --push # on success, commit + push - **Cesar consults on forks.** When the builder hits a genuine fork it emits `CONQUER_ASK: `; Cesar classifies it and convenes the _cheapest sufficient_ panel — `nero` (quick), `tribunal` (a choice), `brainstorm` (open), `council` (high-stakes) — then feeds back a compact verdict. A normal agent loop re-prompts itself with the same blind spots; conquer brings the whole heterogeneous panel to bear. - **A layered done-oracle, not a self-claim.** On `CONQUER_DONE: ` the cheap deterministic layers run first — the `--gate` command (L0), a diff acceptance-drift check that blocks weakened/rewritten existing tests (L1), and an empty-claim guard (L2). Only when those pass does the **evidence-based falsifier** run (L4/L5): a _tool-enabled_ critic clones the working tree into a throwaway sandbox, reads the real code and runs commands to hunt a counterexample, then Cesar **mechanically re-runs** that counterexample in the sandbox and blocks "done" _only_ when it actually reproduces a failure — never on a bare opinion or confidence score. The adversary's findings are surfaced to your merge gate whether or not they blocked. A red gate, a weakened/tampered test, or a _verified_ counterexample blocks "done." - **Runs in an isolated worktree.** conquer creates a persistent `conquer/*` branch and worktree from `HEAD`, so the builder never edits your source checkout and uncommitted source changes are not silently included. The final summary prints both locations and the cleanup command. -- **Stops at a human merge gate.** conquer never auto-merges to main. By default it leaves the isolated branch and worktree for you to review; `--push` commits + pushes that branch, then prints an **engine-written PR title/body** (from the real branch diff) and a **prefilled GitHub PR link** (`…/compare/…?quick_pull=1&title=…&body=…`) — click it, the form is already filled, review + merge. No `gh` CLI or token needed. The done-oracle is irreducible for open-ended work, so the human merge gate is load-bearing — it holds the original product intent no automated layer can reconstruct. +- **Stops at a human merge gate.** conquer never auto-merges to main. By default it leaves the isolated branch and worktree for you to review; `--push` commits + pushes that branch, then prints an **engine-written PR title/body** (from the real branch diff) and a **prefilled GitHub PR link** (`…/compare/…?quick_pull=1&title=…&body=…`) — click it, the form is already filled, review + merge. No `gh` CLI or token needed. `--push` additionally **refuses protected branches** (`main`/`master` by default; override with `protectedPushBranches` in config) — if the worktree HEAD somehow resolves to one, the work stays committed locally for a human push. The done-oracle is irreducible for open-ended work, so the human merge gate is load-bearing — it holds the original product intent no automated layer can reconstruct. -Also available as interactive `/conquer` in the REPL and `agon call conquer` for external CLIs. +Also available as interactive `/conquer` in the REPL (with full CLI flag parity: `--max-turns`, `--gate-timeout`, `--max-hours`, `--timeout`) and `agon call conquer` for external CLIs. ## Interactive REPL @@ -596,6 +596,14 @@ If you want manual control, you can easily override Cesar's routing: /cesar agy ``` +### Delegation reflex + +When a prompt is explicitly list-shaped (three or more numbered/bulleted items) with no sequential-dependency, shared-artifact, or small-task markers, Cesar receives a structural note that the work may decompose into parallel subtasks — it can then offer an agent team (worktree-isolated, running under the delegated permission floor). Detection is veto-first and advisory-only: nothing ever spawns without your confirmation, and prose or ordered work never triggers it. Disable with `cesarDelegationReflex false`. + +### Experience precedent + +Cesar learns from its own history beyond ratings: when your prompt resembles past runs, the matching episodes are injected into the turn as **advisory precedent** — `"fix the CSV parser crash" — mode=forge → codex, succeeded, 4m`. It's framed as evidence, never authority: nothing is shown unless at least `cesarExperienceMinEpisodes` (default 3) past runs clear the similarity gate, matching is purely lexical (no model or sidecar on the hot path), and any failure injects nothing. Disable with `cesarExperience false`; tune with `cesarExperienceMinSimilarity`, `cesarExperienceTopK`, and `cesarExperienceWindow`. Episodes accumulate from run telemetry as you use agon — older records without an intent summary are simply skipped. + ## Configuration Global configuration, engine selection, model preferences, and telemetry settings are managed via your personal config file located at `~/.agon/AGON.md`. Project-specific settings can be defined in a local `AGON.md` within your repository. diff --git a/packages/cli/src/generated/cesar/brain.ts b/packages/cli/src/generated/cesar/brain.ts index 12398b34a..e22d4e3c6 100644 --- a/packages/cli/src/generated/cesar/brain.ts +++ b/packages/cli/src/generated/cesar/brain.ts @@ -34,6 +34,12 @@ import { buildRoutingContext, deriveRoutingHints, shouldSpeculate } from './rout import { shouldGroundInput, buildGroundingBlock } from './grounding.js'; +import { episodesFromRunRecords, retrieveExperience, buildExperienceBlock, experienceRetrievalOptions } from './experience.js'; + +import { assessDelegationShape, buildDelegationAdvisory } from './delegation-reflex.js'; + +import { recentRunRecords, currentProjectKey } from '../../telemetry/index.js'; + import { readCesarToolReliability, formatCesarReliabilityLine, shouldDowngradeCesarToolWork, buildWhatHappenedSummary } from './reliability.js'; import { applyCesarSelfTurnApproval } from './self-turn-approval.js'; @@ -58,7 +64,7 @@ import { consumeCesarPlanControlSignals } from './plan-control-signals.js'; import { hostNowIso, hostWaitForInteractiveChoice } from '../lib/kern-host.js'; -// @kern-source: brain:31 +// @kern-source: brain:34 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 @@ -91,7 +97,7 @@ export async function commitTurnAndDelegate(pendingDel: PendingDelegation, input return { delegated: false, responded: true, decisionReason: 'delegation-cancelled', ...telemetry ?? {} }; } -// @kern-source: brain:58 +// @kern-source: brain:61 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. @@ -122,10 +128,10 @@ export async function commitTurnAndSuggest(suggestion: {action:string, rest?:str return { delegated: false, responded: true, decisionReason: 'suggestion-cancelled', ...telemetry ?? {} }; } -// @kern-source: brain:83 +// @kern-source: brain:86 export const _noBriefNudged: WeakMap = new WeakMap(); -// @kern-source: brain:85 +// @kern-source: brain:88 export async function handleCesarBrain(input: string, dispatch: Dispatch, ctx: HandlerContext, images?: ImageAttachment[]): Promise { const abort = new AbortController(); const _turnStart = Date.now(); @@ -904,6 +910,35 @@ export async function handleCesarBrain(input: string, dispatch: Dispatch, ctx: H } catch { /* grounding is best-effort */ } } + // ── Experience precedent (advisory, default-on: config.cesarExperience) ── + // RAG roadmap 1b: similar PAST RUN episodes (mode/winner/outcome) injected + // as evidence, never authority. Purely lexical (no sidecar spawn on the hot + // path), gated on min-N qualifying matches + a similarity floor, and + // fail-open — any error injects nothing. Reuses shouldGroundInput so slash + // commands and trivial prompts never retrieve. + if ((config as any).cesarExperience !== false && shouldGroundInput(input)) { + try { + const _expOpts = experienceRetrievalOptions(config); + // Scoped to THIS project: global telemetry must never leak another + // repo's task text into this turn (review blocker: codex). + const _expEpisodes = episodesFromRunRecords(recentRunRecords(_expOpts.window), currentProjectKey()); + const _expBlock = buildExperienceBlock(retrieveExperience(input, _expEpisodes, _expOpts)); + if (_expBlock) enrichedInput = `${_expBlock}\n\n${enrichedInput}`; + } catch { /* experience is best-effort */ } + } + + // ── Delegation reflex (advisory-only: config.cesarDelegationReflex) ── + // Structural fan-out detection (explicit ≥3-item lists, veto-first). The + // advisory rides the prompt; surfacing stays with Cesar's existing + // [SUGGEST:agent-team] → promptDelegation flow, so the user always + // confirms and nothing auto-spawns. Fail-open like its siblings. + if ((config as any).cesarDelegationReflex !== false && shouldGroundInput(input)) { + try { + const _delAdvisory = buildDelegationAdvisory(assessDelegationShape(input)); + if (_delAdvisory) enrichedInput = `${_delAdvisory}\n\n${enrichedInput}`; + } catch { /* delegation reflex is best-effort */ } + } + // ── Sequential-thinking scaffold (opt-in: config.cesarThinkFirst) ── // The in-loop, dispatch-free form of `agon think`: when enabled, scaffold // structured decomposition into the prompt so Cesar reasons before acting diff --git a/packages/cli/src/generated/cesar/delegation-reflex.ts b/packages/cli/src/generated/cesar/delegation-reflex.ts new file mode 100644 index 000000000..edbf0fe47 --- /dev/null +++ b/packages/cli/src/generated/cesar/delegation-reflex.ts @@ -0,0 +1,68 @@ +// @generated by kern v4.5.0 — DO NOT EDIT. Source: src/kern/cesar/delegation-reflex.kern + +// @kern-source: delegation-reflex:11 +export interface DelegationShape { + decision: 'none' | 'suggest'; + items: string[]; + reasons: string[]; + vetoes: string[]; +} + +/** + * Sequential-dependency markers: ordered work must never be pitched as parallel. The first…then pair is windowed to one sentence-ish span so unrelated mentions across a long prompt don't over-veto (review: kimi). + */ +// @kern-source: delegation-reflex:17 +export const DELEGATION_SEQUENTIAL_RE: RegExp = /\b(?:then|after (?:that|this|which)|once (?:that|this|it)|depends on|followed by|first\b[^\n.]{0,120}\bthen)\b/i; + +/** + * The user saying the work is small is a hard veto — spawning a team for a typo fix burns money. + */ +// @kern-source: delegation-reflex:20 +export const DELEGATION_SMALL_RE: RegExp = /\b(?:quick(?:ly)?|just|tiny|small|simple|typo|one[- ]liner|minor)\b/i; + +/** + * Shared-artifact mentions: list-shaped prompts whose items converge on one artifact look parallel but are not. + */ +// @kern-source: delegation-reflex:23 +export const DELEGATION_SHARED_STATE_RE: RegExp = /\b(?:lockfiles?|package\.json|package-lock|migrations?|schemas?|generated|shared (?:types?|modules?|configs?|state)|same files?)\b/i; + +/** + * Explicit list items only (numbered or bulleted lines). Deliberately strict for the first slice: comma-series and 'for each X' phrasings under-trigger rather than over-trigger. + */ +// @kern-source: delegation-reflex:26 +export const DELEGATION_ITEM_RE: RegExp = /^\s*(?:\d+[.)]\s+|[-*•]\s+)(.+)$/gm; + +/** + * Pure fan-out-shape assessment of a user prompt. Suggests ONLY when: ≥3 explicit list items, every item clears a 20-char size floor, and NO veto fires (sequential markers, small-task language, shared-artifact mentions, prompt too short overall). Vetoes are reported so the advisory can be debugged; the caller decides how to surface the result. + */ +// @kern-source: delegation-reflex:29 +export function assessDelegationShape(input: string): DelegationShape { + const text = String(input ?? ''); + const vetoes: string[] = []; + const reasons: string[] = []; + const items: string[] = []; + const re = new RegExp(DELEGATION_ITEM_RE.source, 'gm'); + for (const m of text.matchAll(re)) { + const item = (m[1] ?? '').trim(); + if (item) items.push(item.length > 120 ? item.slice(0, 119).trimEnd() + '…' : item); + } + if (text.trim().length < 80) vetoes.push('prompt-too-short'); + if (items.length < 3) vetoes.push('fewer-than-3-items'); + if (items.some((i) => i.length < 20)) vetoes.push('item-below-size-floor'); + if (DELEGATION_SEQUENTIAL_RE.test(text)) vetoes.push('sequential-markers'); + if (DELEGATION_SMALL_RE.test(text)) vetoes.push('small-task-language'); + if (DELEGATION_SHARED_STATE_RE.test(text)) vetoes.push('shared-artifact-mention'); + if (vetoes.length > 0) return { decision: 'none', items, reasons, vetoes }; + reasons.push(`${items.length} explicit list items`); + reasons.push('no sequential/shared-state/small-task markers'); + return { decision: 'suggest', items, reasons, vetoes }; +} + +/** + * The in-prompt advisory injected ahead of the user message when the shape suggests fan-out. Deliberately framed as a structural observation Cesar may reject: the model owns the judgment, the existing [SUGGEST:agent-team] → user-confirm flow owns the UX, and nothing spawns without the user saying yes. + */ +// @kern-source: delegation-reflex:53 +export function buildDelegationAdvisory(shape: DelegationShape): string | null { + if (shape.decision !== 'suggest' || shape.items.length < 3) return null; + return `[DELEGATION SHAPE] This request contains ${shape.items.length} explicit list items with no sequential or shared-state markers — it MAY decompose into independent parallel subtasks. If, after reading them, you judge them genuinely independent (disjoint files, no ordering), you can offer parallel execution via [SUGGEST:team-agent] (worktree-isolated agent team; the user confirms before anything spawns) or background jobs. If they share state or order matters, ignore this note entirely — do not mention it.`; +} diff --git a/packages/cli/src/generated/cesar/experience.ts b/packages/cli/src/generated/cesar/experience.ts new file mode 100644 index 000000000..b451db697 --- /dev/null +++ b/packages/cli/src/generated/cesar/experience.ts @@ -0,0 +1,158 @@ +// @generated by kern v4.5.0 — DO NOT EDIT. Source: src/kern/cesar/experience.kern + +// @kern-source: experience:10 +export interface ExperienceEpisode { + mode: string; + taskType: string; + intentSummary: string; + winner: string; + success: boolean; + durationMs: number; + timestamp: number; + projectKey: string; +} + +// @kern-source: experience:20 +export interface ExperienceMatch { + episode: ExperienceEpisode; + similarity: number; +} + +// @kern-source: experience:24 +export interface ExperienceRetrievalOptions { + minSimilarity: number; + minEpisodes: number; + topK: number; + window: number; +} + +/** + * Common filler words removed before similarity scoring — generic verbs like add/use/run appear in most engineering prompts and would inflate Jaccard overlap between unrelated tasks. + */ +// @kern-source: experience:30 +export const EXPERIENCE_STOPWORDS: Set = new Set(['the', 'and', 'for', 'with', 'that', 'this', 'from', 'into', 'when', 'then', 'them', 'they', 'have', 'has', 'had', 'was', 'were', 'will', 'would', 'should', 'could', 'can', 'not', 'but', 'are', 'you', 'your', 'our', 'its', 'all', 'any', 'each', 'also', 'via', 'per', 'about', 'after', 'before', 'make', 'made', 'use', 'using', 'used', 'add', 'new', 'get', 'set', 'run', 'runs', 'please']); + +/** + * Normalize a run intent into a compact, storable episode summary: whitespace collapsed, control characters stripped, capped at 160 chars. Empty result (< 8 meaningful chars) means the run stores NO summary and never becomes an episode — a bare mode invocation carries no precedent signal. + */ +// @kern-source: experience:33 +export function summarizeIntentForEpisode(intent: string): string { + const collapsed = String(intent ?? '') + .replace(/[\u0000-\u001f\u007f]+/g, ' ') + .replace(/\s+/g, ' ') + .trim(); + if (collapsed.length < 8) return ''; + return collapsed.length > 160 ? collapsed.slice(0, 159).trimEnd() + '…' : collapsed; +} + +/** + * Lowercased word tokens (unicode letters + digits, so non-English prompts score too — review: kimi), ≥3 chars, stopwords removed, deduplicated — the unit of Jaccard similarity. + */ +// @kern-source: experience:44 +export function tokenizeExperienceText(text: string): Set { + const tokens = String(text ?? '') + .toLowerCase() + .split(/[^\p{L}\p{N}]+/u) + .filter((t) => t.length >= 3 && !EXPERIENCE_STOPWORDS.has(t)); + return new Set(tokens); +} + +/** + * Jaccard over pre-tokenized sets — retrieveExperience tokenizes the prompt ONCE and reuses the set across every episode (review: agy). + */ +// @kern-source: experience:54 +export function scoreTokenSetSimilarity(ta: Set, tb: Set): number { + if (ta.size === 0 || tb.size === 0) return 0; + let shared = 0; + for (const t of ta) { if (tb.has(t)) shared++; } + const unionSize = ta.size + tb.size - shared; + return unionSize > 0 ? shared / unionSize : 0; +} + +/** + * Token-set Jaccard similarity in [0,1]. Deterministic and cheap — precedent retrieval must never cost a sidecar spawn on the hot turn path. + */ +// @kern-source: experience:64 +export function scoreExperienceSimilarity(a: string, b: string): number { + return scoreTokenSetSimilarity(tokenizeExperienceText(a), tokenizeExperienceText(b)); +} + +/** + * Resolve the retrieval gates from config with safe defaults in ONE place: cesarExperienceMinSimilarity (default 0.2), cesarExperienceMinEpisodes (default 3 — the brainstorm's min-N gate), cesarExperienceTopK (default 3), cesarExperienceWindow (default 200 recent runs). Non-positive / non-finite values mean 'unset' (KERN codegen emits optional number fields as 0 into DEFAULT_AGON_CONFIG). + */ +// @kern-source: experience:70 +export function experienceRetrievalOptions(config: any): ExperienceRetrievalOptions { + const rawSim = Number(config?.cesarExperienceMinSimilarity); + const rawMin = Number(config?.cesarExperienceMinEpisodes); + const rawTop = Number(config?.cesarExperienceTopK); + const rawWin = Number(config?.cesarExperienceWindow); + return { + minSimilarity: Math.min(1, Number.isFinite(rawSim) && rawSim > 0 ? rawSim : 0.2), + minEpisodes: Math.floor(Number.isFinite(rawMin) && rawMin > 0 ? rawMin : 3), + topK: Math.floor(Number.isFinite(rawTop) && rawTop > 0 ? rawTop : 3), + window: Math.floor(Number.isFinite(rawWin) && rawWin > 0 ? rawWin : 200), + }; +} + +/** + * Map telemetry RunRecords into episodes, keeping only records that stored an intentSummary (older records predate the field and carry no precedent signal). When projectKey is given, ONLY records from that project qualify — telemetry.json is global, and precedent from another repo must never leak its task text into this repo's turn (review blocker: codex). Records without a projectKey are excluded from a scoped query (fail-closed). + */ +// @kern-source: experience:85 +export function episodesFromRunRecords(records: any[], projectKey?: string): ExperienceEpisode[] { + const scope = typeof projectKey === 'string' ? projectKey.trim() : ''; + const episodes: ExperienceEpisode[] = []; + for (const r of Array.isArray(records) ? records : []) { + const intentSummary = typeof r?.intentSummary === 'string' ? r.intentSummary.trim() : ''; + if (!intentSummary) continue; + const recordProject = typeof r.projectKey === 'string' ? r.projectKey.trim() : ''; + if (scope && recordProject !== scope) continue; + episodes.push({ + mode: String(r.mode ?? ''), + taskType: String(r.taskType ?? 'other'), + intentSummary, + winner: typeof r.winner === 'string' ? r.winner : '', + success: r.success === true, + durationMs: Number.isFinite(Number(r.durationMs)) ? Number(r.durationMs) : 0, + timestamp: Number.isFinite(Number(r.timestamp)) ? Number(r.timestamp) : 0, + projectKey: recordProject, + }); + } + return episodes; +} + +/** + * Score every episode against the prompt and return the top-K — but ONLY when at least minEpisodes clear the similarity gate. Below that support threshold precedent is noise, not evidence, and nothing is returned (the brainstorm's min-N≥3 rule). Ties break toward the more recent episode. + */ +// @kern-source: experience:109 +export function retrieveExperience(prompt: string, episodes: ExperienceEpisode[], opts: ExperienceRetrievalOptions): ExperienceMatch[] { + const promptTokens = tokenizeExperienceText(prompt); + const scored: ExperienceMatch[] = []; + for (const e of episodes) { + const similarity = scoreTokenSetSimilarity(promptTokens, tokenizeExperienceText(e.intentSummary)); + if (similarity >= opts.minSimilarity) scored.push({ episode: e, similarity }); + } + if (scored.length < opts.minEpisodes) return []; + scored.sort((a, b) => (b.similarity - a.similarity) || (b.episode.timestamp - a.episode.timestamp)); + return scored.slice(0, Math.max(1, opts.topK)); +} + +/** + * Render the advisory precedent block injected ahead of the user prompt. The framing is deliberate: evidence, not authority — the model judges the current task on its own merits. + */ +// @kern-source: experience:123 +export function buildExperienceBlock(matches: ExperienceMatch[]): string | null { + if (!matches.length) return null; + const lines: string[] = []; + for (const m of matches) { + const e = m.episode; + const outcome = e.success ? 'succeeded' : 'did not succeed'; + const winner = e.winner ? ` → ${e.winner}` : ''; + const mins = Math.round(e.durationMs / 60000); + const dur = e.durationMs > 0 ? (mins > 0 ? `, ${mins}m` : `, <1m`) : ''; + // Rendering hygiene: summaries are the user's OWN past prompts (same + // local trust domain as the live prompt), but keep the quoting intact. + const quoted = e.intentSummary.replace(/"/g, "'"); + lines.push(`- "${quoted}" — mode=${e.mode}${winner}, ${outcome}${dur} (similarity ${m.similarity.toFixed(2)})`); + } + return `[EXPERIENCE] Precedent from similar past agon runs — evidence, not authority:\n${lines.join('\n')}\nWeigh these outcomes when choosing a mode or engine, but judge the current task on its own merits.`; +} diff --git a/packages/cli/src/generated/commands/conquer.ts b/packages/cli/src/generated/commands/conquer.ts index 9dc103359..35bf29852 100644 --- a/packages/cli/src/generated/commands/conquer.ts +++ b/packages/cli/src/generated/commands/conquer.ts @@ -1,10 +1,10 @@ -// @generated by kern v4.0.0 — DO NOT EDIT. Source: src/kern/commands/conquer.kern +// @generated by kern v4.5.0 — DO NOT EDIT. Source: src/kern/commands/conquer.kern import { defineCommand } from 'citty'; import { EngineRegistry, ensureAgonHome, loadConfig, createRunDir, spawnWithTimeout, appendAttribution, appendPrAttribution, githubRepoUrl, defaultBaseBranch, prefilledPrUrl } from '@kernlang/agon-core'; -import { runConquer, runPrText, createConquerIsolation } from '@kernlang/agon-forge'; +import { runConquer, runPrText, createConquerIsolation, isProtectedPushBranch } from '@kernlang/agon-forge'; import type { ConquerResult, ConquerTurn } from '@kernlang/agon-forge'; @@ -93,8 +93,10 @@ export const conquerCommand: any = defineCommand({ maxHours = parseFloat(String(args.maxHours)); if (Number.isNaN(maxHours) || maxHours < 0) { console.error(`Invalid --max-hours: ${args.maxHours}`); process.exitCode = 1; return; } } - const turnTimeout = parseInt(String(args.timeout ?? '600'), 10) || 600; - const gateTimeout = parseInt(String(args.gateTimeout ?? '1800'), 10) || 1800; + const turnTimeoutRaw = parseInt(String(args.timeout ?? '600'), 10); + const turnTimeout = Number.isFinite(turnTimeoutRaw) && turnTimeoutRaw > 0 ? turnTimeoutRaw : 600; + const gateTimeoutRaw = parseInt(String(args.gateTimeout ?? '1800'), 10); + const gateTimeout = Number.isFinite(gateTimeoutRaw) && gateTimeoutRaw > 0 ? gateTimeoutRaw : 1800; const { path: outputDir } = createRunDir({ mode: 'conquer', label: args.label, announce: false }); let isolation; @@ -194,6 +196,14 @@ export const conquerCommand: any = defineCommand({ if (branchRes.exitCode !== 0 || !headBranch || headBranch === 'HEAD') { info(dim('Could not resolve a branch to push (detached HEAD?). The builder\'s changes are uncommitted in the tree — review + commit + push manually.')); process.exitCode = 1; + } else if (isProtectedPushBranch(headBranch, config)) { + // Unattended pushes never land on a protected branch (main/master by + // default, protectedPushBranches to override) — a conquer worktree + // normally sits on its own conquer/ branch, so a protected HEAD + // here means something unexpected happened. Leave the work committed + // locally and hand the push to the human. + info(dim(`Refusing to push protected branch '${headBranch}' from an unattended run. The work is in the worktree at ${worktreeCwd} — review and push it manually (or set protectedPushBranches in config).`)); + process.exitCode = 1; } else { const add = await git(['add', '-A'], 30_000); // appendAttribution = the Claude-Code-style footer (Generated-with line + diff --git a/packages/cli/src/generated/commands/daemon.ts b/packages/cli/src/generated/commands/daemon.ts index aa4b4a4ae..70f8388f0 100644 --- a/packages/cli/src/generated/commands/daemon.ts +++ b/packages/cli/src/generated/commands/daemon.ts @@ -545,7 +545,7 @@ export async function startDaemon(foreground: boolean): Promise { if (spawnErr.msg) break; info0 = readPidFile(); if (info0 && isProcessAlive(info0.pid)) break; - await new Promise((r) => { setTimeout(r, 50); }); + await new Promise((r) => { setTimeout(r, 50); }); } if (spawnErr.msg) { diff --git a/packages/cli/src/generated/handlers/conquer.ts b/packages/cli/src/generated/handlers/conquer.ts index b1686e4ff..7e5d75c5a 100644 --- a/packages/cli/src/generated/handlers/conquer.ts +++ b/packages/cli/src/generated/handlers/conquer.ts @@ -17,7 +17,7 @@ import type { Dispatch, HandlerContext } from '../../handlers/types.js'; import { filterDefaultOrchestrationEngines } from './engine-filter.js'; // @kern-source: conquer:17 -export async function handleConquer(task: string, dispatch: Dispatch, ctx: HandlerContext, opts?: {gate?:string, builder?:string, engineIds?:string[], maxTurns?:number}): Promise { +export async function handleConquer(task: string, dispatch: Dispatch, ctx: HandlerContext, opts?: {gate?:string, builder?:string, engineIds?:string[], maxTurns?:number, gateTimeoutSec?:number, maxHours?:number, turnTimeoutSec?:number}): Promise { const cqAbort = new AbortController(); try { ensureAgonHome(); @@ -42,7 +42,18 @@ export async function handleConquer(task: string, dispatch: Dispatch, ctx: Handl advisors = resolved.filter((id) => id !== builder); } + // CLI parity: `agon conquer` rejects --max-turns < 1 instead of silently + // running 40 turns the user tried to cap at 0. + if (opts?.maxTurns !== undefined && (!Number.isFinite(opts.maxTurns) || opts.maxTurns < 1)) { + dispatch({ type: 'error', message: `Invalid --max-turns: ${opts.maxTurns}` }); + return; + } const maxTurns = opts?.maxTurns && opts.maxTurns > 0 ? opts.maxTurns : 40; + // CLI-parity knobs (previously hardcoded in this handler — review follow-up): + // defaults mirror `agon conquer` (gate 1800s, per-builder-turn 600s, no wall cap). + const gateTimeoutSec = opts?.gateTimeoutSec && Number.isFinite(opts.gateTimeoutSec) && opts.gateTimeoutSec > 0 ? Math.floor(opts.gateTimeoutSec) : 1800; + const turnTimeoutSec = opts?.turnTimeoutSec && Number.isFinite(opts.turnTimeoutSec) && opts.turnTimeoutSec > 0 ? Math.floor(opts.turnTimeoutSec) : 600; + const maxWallClockMs = opts?.maxHours && Number.isFinite(opts.maxHours) && opts.maxHours > 0 ? Math.round(opts.maxHours * 3_600_000) : 0; const outputDir = join(RUNS_DIR, `conquer-${Date.now()}`); mkdirSync(outputDir, { recursive: true }); let isolation; @@ -72,7 +83,7 @@ export async function handleConquer(task: string, dispatch: Dispatch, ctx: Handl const evaluateDone = async (_claim: string) => { try { const diffRes = await spawnWithTimeout({ command: 'git', args: ['diff'], cwd: worktreeCwd, timeout: 30_000 }); - const gateRes = await spawnWithTimeout({ command: 'sh', args: ['-c', gate], cwd: worktreeCwd, timeout: 1_800_000 }); + const gateRes = await spawnWithTimeout({ command: 'sh', args: ['-c', gate], cwd: worktreeCwd, timeout: gateTimeoutSec * 1000 }); return { diff: String(diffRes.stdout ?? ''), gateOk: gateRes.exitCode === 0, oracleTampered: false }; } catch (err) { dispatch({ type: 'info', message: `done-check gate errored: ${err instanceof Error ? err.message : String(err)} — treating as not-done.` }); @@ -84,8 +95,8 @@ export async function handleConquer(task: string, dispatch: Dispatch, ctx: Handl try { result = await runConquer({ task, builderEngine: builder, advisorEngines: advisors, - registry: ctx.registry, adapter: ctx.adapter, timeout: 600, outputDir, cwd: worktreeCwd, gate, - caps: { maxTurns, maxWallClockMs: 0 }, + registry: ctx.registry, adapter: ctx.adapter, timeout: turnTimeoutSec, outputDir, cwd: worktreeCwd, gate, + caps: { maxTurns, maxWallClockMs }, evaluateDone, onTurn: (t: ConquerTurn) => dispatch({ type: 'info', message: `turn ${t.n} · ${t.action}${t.action === 'consult' || t.action === 'done-check' ? ` — ${t.detail.slice(0, 80)}` : ''}` }), signal: cqAbort.signal, diff --git a/packages/cli/src/generated/signals/dispatch/intent-orchestration.ts b/packages/cli/src/generated/signals/dispatch/intent-orchestration.ts index 218dc35cf..cf4fe5a9b 100644 --- a/packages/cli/src/generated/signals/dispatch/intent-orchestration.ts +++ b/packages/cli/src/generated/signals/dispatch/intent-orchestration.ts @@ -156,7 +156,7 @@ export async function dispatchOrchestrationIntent(intent: any, input: string, cb const cqEpoch = cb.ctx.inputEpoch ?? 0; const cqUserTurns = countTrackedUserTurns(cb.ctx); cb.runAsJob('conquer', _cqLabel, withThreadOutcome(_cqCwd, 'conquer', _cqLabel, async () => { - await handleConquer(intent.task ?? '', cb.dispatch, cb.ctx, { gate: intent.gate, builder: intent.builder, engineIds: intent.engineIds, maxTurns: intent.maxTurns }); + await handleConquer(intent.task ?? '', cb.dispatch, cb.ctx, { gate: intent.gate, builder: intent.builder, engineIds: intent.engineIds, maxTurns: intent.maxTurns, gateTimeoutSec: intent.gateTimeout, maxHours: intent.maxHours, turnTimeoutSec: intent.turnTimeout }); const chatContext = collectRecentEngineContext(cb.ctx, 14, 1500); if (chatContext) await continueCesarAfterResult(`Conquer build on: "${(intent.task ?? '').slice(0, 200)}"\n\n${chatContext}\n\nSummarize what was built + the done-oracle outcome, and recommend the next step (review/merge or keep going).`, cb, cqEpoch, cqUserTurns); }, cb.ctx)); diff --git a/packages/cli/src/generated/signals/intent.ts b/packages/cli/src/generated/signals/intent.ts index b49386394..841b80771 100644 --- a/packages/cli/src/generated/signals/intent.ts +++ b/packages/cli/src/generated/signals/intent.ts @@ -47,61 +47,64 @@ export interface Intent { gate: string|undefined; builder: string|undefined; maxTurns: number|undefined; + gateTimeout: number|undefined; + maxHours: number|undefined; + turnTimeout: number|undefined; swaps: number|undefined; reasoning: string|undefined; count: number|undefined; last: boolean|undefined; } -// @kern-source: intent:49 -export const SLASH_COMMANDS: SlashCommand[] = [{ cmd: '/forge', desc: ' test with [--hardened] — competitive code generation' }, { cmd: '/brainstorm', desc: ' — confidence-bidding answers' }, { cmd: '/tribunal', desc: '[mode] — debate (adversarial|socratic|red-team|steelman|synthesis|postmortem)' }, { cmd: '/campfire', desc: ' — think together, no competition' }, { cmd: '/think', desc: ' [--strategy reflexion] [--steps 8] — sequential thinking, one engine' }, { cmd: '/council', desc: ' — roundtable: every engine a role, top-rated chairs' }, { cmd: '/research', desc: ' [--count N] [--engine X] — keyless web-grounded cited research (npm/GitHub/MDN/IETF/SO/Wikipedia)' }, { cmd: '/chrome', desc: ' — drive your browser (read/navigate/screenshot/click), result feeds Cesar' }, { cmd: '/synthesis', desc: ' [--swaps 2] — engines draft, swap, improve; judge picks winner' }, { cmd: '/workspace', desc: 'add|remove|list|switch — manage project repos' }, { cmd: '/ws', desc: ' — list workspaces (shortcut)' }, { cmd: '/cesar', desc: ' — set Cesar brain engine (e.g. /cesar codex)' }, { cmd: '/models', desc: ' — browse & add provider models + CLI models' }, { cmd: '/tokens', desc: ' — show token usage & costs' }, { cmd: '/raw', desc: ' — reprint last folded engine output (unfolded)' }, { cmd: '/engines', desc: ' — select active engines' }, { cmd: '/leaderboard', desc: ' — Glicko rankings' }, { cmd: '/cesar-report', desc: ' — Cesar routing calibration report' }, { cmd: '/cesar-hints', desc: ' — inspect Cesar routing hints for a prompt' }, { cmd: '/history', desc: '[id] — past forge runs' }, { cmd: '/config', desc: '[list|get|set] — settings' }, { cmd: '/plan', desc: ' or no args — plan mode or show plan' }, { cmd: '/auto', desc: '[on|off|toggle|status] or — autonomous mode control' }, { cmd: '/plans', desc: ' — list recent plans' }, { cmd: '/approve', desc: ' — approve current plan' }, { cmd: '/retry', desc: ' — retry failed plan step' }, { cmd: '/cancel', desc: ' — cancel current plan' }, { cmd: '/apply', desc: '[path] [--force] — apply winning forge patch' }, { cmd: '/cp', desc: '[N|last] — copy code block N, or last response, to clipboard' }, { cmd: '/img', desc: ' — attach image to next prompt' }, { cmd: '/flow', desc: ' — log this session' }, { cmd: '/flows', desc: ' — flow analytics dashboard' }, { cmd: '/chats', desc: '[id|resume ] — chat history or resume session' }, { cmd: '/build', desc: ' — agent builds in cwd (reads/edits/tests)' }, { cmd: '/goal', desc: ' --queue --gate "" [--push] — autonomous queue: build→review(all)→judge→fix→commit→push per task (background job)' }, { cmd: '/conquer', desc: ' --gate "" [--builder X] [-e a,b] — supervised-autonomous build: Cesar drives a builder CLI, convenes nero/tribunal/council on forks, stops at a human merge gate (background job)' },{ cmd: '/agent', desc: ' — autonomous agent loop (solo or shadow, auto-routed)' }, { cmd: '/agent-solo', desc: ' — force solo agent mode, no shadow worker' }, { cmd: '/speculate', desc: ' — parallel speculation: N engines race in worktrees, winner applied' }, { cmd: '/team-forge', desc: '[2v2|3v3] test with — team code competition' }, { cmd: '/team-tribunal', desc: '[2v2|3v3] [mode] — team debate' }, { cmd: '/team-brainstorm', desc: '[2v2|3v3] — team ideation' }, { cmd: '/pipeline', desc: ' [test with ] — build→review→fix loop' }, { cmd: '/review', desc: '[with ] [] — code review (uncommitted|branch:NAME|commit:SHA)' }, { cmd: '/provider', desc: 'add|remove|list|key — providers & keys (key set/clear/list)' }, { cmd: '/run', desc: ' — run shell command inline' }, { cmd: '/commit', desc: '[message] — stage & commit with auto-generated message' }, { cmd: '/status', desc: ' — live engine telemetry snapshot' }, { cmd: '/doctor', desc: '[engines|harness] — diagnose engines, worktree, or Cesar harness' }, { cmd: '/harness-replay', desc: '[turnId] — replay Cesar tool timeline + approval ledger' }, { cmd: '/undo', desc: ' — revert last patch or Cesar checkpoint' }, { cmd: '/checkpoints', desc: ' — list recent file checkpoints' }, { cmd: '/jobs', desc: ' — list running/completed jobs' }, { cmd: '/focus', desc: ' — switch to background job output' }, { cmd: '/explore', desc: ' — toggle exploration mode (read-only)' }, { cmd: '/permissions', desc: ' — show allow/deny permission rules (.agon.json)' }, { cmd: '/nogate', desc: ' — toggle the verify-before-done gate nudge for this session' }, { cmd: '/nero', desc: '[] — toggle Nero mode, or challenge a decision (top-rated critic)' }, { cmd: '/btw', desc: ' — ask something while engines work (side-channel)' }, { cmd: '/compact', desc: ' — shrink Cesar context without clearing transcript' }, { cmd: '/mcp', desc: 'connect | disconnect | list — manage session MCP servers' }, { cmd: '/init', desc: ' — create AGON.md config wizard' }, { cmd: '/create-skill', desc: ' — scaffold a new skill (.agon/skills/)' }, { cmd: '/clear', desc: ' — reset session (saves chat, clears brain)' }, { cmd: '/clean', desc: ' — alias for /clear' }, { cmd: '/extensions', desc: ' — list installed extensions' }, { cmd: '/help', desc: ' — show this help' }, { cmd: '/exit', desc: ' — quit' }]; +// @kern-source: intent:52 +export const SLASH_COMMANDS: SlashCommand[] = [{ cmd: '/forge', desc: ' test with [--hardened] — competitive code generation' }, { cmd: '/brainstorm', desc: ' — confidence-bidding answers' }, { cmd: '/tribunal', desc: '[mode] — debate (adversarial|socratic|red-team|steelman|synthesis|postmortem)' }, { cmd: '/campfire', desc: ' — think together, no competition' }, { cmd: '/think', desc: ' [--strategy reflexion] [--steps 8] — sequential thinking, one engine' }, { cmd: '/council', desc: ' — roundtable: every engine a role, top-rated chairs' }, { cmd: '/research', desc: ' [--count N] [--engine X] — keyless web-grounded cited research (npm/GitHub/MDN/IETF/SO/Wikipedia)' }, { cmd: '/chrome', desc: ' — drive your browser (read/navigate/screenshot/click), result feeds Cesar' }, { cmd: '/synthesis', desc: ' [--swaps 2] — engines draft, swap, improve; judge picks winner' }, { cmd: '/workspace', desc: 'add|remove|list|switch — manage project repos' }, { cmd: '/ws', desc: ' — list workspaces (shortcut)' }, { cmd: '/cesar', desc: ' — set Cesar brain engine (e.g. /cesar codex)' }, { cmd: '/models', desc: ' — browse & add provider models + CLI models' }, { cmd: '/tokens', desc: ' — show token usage & costs' }, { cmd: '/raw', desc: ' — reprint last folded engine output (unfolded)' }, { cmd: '/engines', desc: ' — select active engines' }, { cmd: '/leaderboard', desc: ' — Glicko rankings' }, { cmd: '/cesar-report', desc: ' — Cesar routing calibration report' }, { cmd: '/cesar-hints', desc: ' — inspect Cesar routing hints for a prompt' }, { cmd: '/history', desc: '[id] — past forge runs' }, { cmd: '/config', desc: '[list|get|set] — settings' }, { cmd: '/plan', desc: ' or no args — plan mode or show plan' }, { cmd: '/auto', desc: '[on|off|toggle|status] or — autonomous mode control' }, { cmd: '/plans', desc: ' — list recent plans' }, { cmd: '/approve', desc: ' — approve current plan' }, { cmd: '/retry', desc: ' — retry failed plan step' }, { cmd: '/cancel', desc: ' — cancel current plan' }, { cmd: '/apply', desc: '[path] [--force] — apply winning forge patch' }, { cmd: '/cp', desc: '[N|last] — copy code block N, or last response, to clipboard' }, { cmd: '/img', desc: ' — attach image to next prompt' }, { cmd: '/flow', desc: ' — log this session' }, { cmd: '/flows', desc: ' — flow analytics dashboard' }, { cmd: '/chats', desc: '[id|resume ] — chat history or resume session' }, { cmd: '/build', desc: ' — agent builds in cwd (reads/edits/tests)' }, { cmd: '/goal', desc: ' --queue --gate "" [--push] — autonomous queue: build→review(all)→judge→fix→commit→push per task (background job)' }, { cmd: '/conquer', desc: ' --gate "" [--builder X] [-e a,b] [--max-turns N] [--gate-timeout s] [--max-hours h] [--timeout s] — supervised-autonomous build: Cesar drives a builder CLI, convenes nero/tribunal/council on forks, stops at a human merge gate (background job)' },{ cmd: '/agent', desc: ' — autonomous agent loop (solo or shadow, auto-routed)' }, { cmd: '/agent-solo', desc: ' — force solo agent mode, no shadow worker' }, { cmd: '/speculate', desc: ' — parallel speculation: N engines race in worktrees, winner applied' }, { cmd: '/team-forge', desc: '[2v2|3v3] test with — team code competition' }, { cmd: '/team-tribunal', desc: '[2v2|3v3] [mode] — team debate' }, { cmd: '/team-brainstorm', desc: '[2v2|3v3] — team ideation' }, { cmd: '/pipeline', desc: ' [test with ] — build→review→fix loop' }, { cmd: '/review', desc: '[with ] [] — code review (uncommitted|branch:NAME|commit:SHA)' }, { cmd: '/provider', desc: 'add|remove|list|key — providers & keys (key set/clear/list)' }, { cmd: '/run', desc: ' — run shell command inline' }, { cmd: '/commit', desc: '[message] — stage & commit with auto-generated message' }, { cmd: '/status', desc: ' — live engine telemetry snapshot' }, { cmd: '/doctor', desc: '[engines|harness] — diagnose engines, worktree, or Cesar harness' }, { cmd: '/harness-replay', desc: '[turnId] — replay Cesar tool timeline + approval ledger' }, { cmd: '/undo', desc: ' — revert last patch or Cesar checkpoint' }, { cmd: '/checkpoints', desc: ' — list recent file checkpoints' }, { cmd: '/jobs', desc: ' — list running/completed jobs' }, { cmd: '/focus', desc: ' — switch to background job output' }, { cmd: '/explore', desc: ' — toggle exploration mode (read-only)' }, { cmd: '/permissions', desc: ' — show allow/deny permission rules (.agon.json)' }, { cmd: '/nogate', desc: ' — toggle the verify-before-done gate nudge for this session' }, { cmd: '/nero', desc: '[] — toggle Nero mode, or challenge a decision (top-rated critic)' }, { cmd: '/btw', desc: ' — ask something while engines work (side-channel)' }, { cmd: '/compact', desc: ' — shrink Cesar context without clearing transcript' }, { cmd: '/mcp', desc: 'connect | disconnect | list — manage session MCP servers' }, { cmd: '/init', desc: ' — create AGON.md config wizard' }, { cmd: '/create-skill', desc: ' — scaffold a new skill (.agon/skills/)' }, { cmd: '/clear', desc: ' — reset session (saves chat, clears brain)' }, { cmd: '/clean', desc: ' — alias for /clear' }, { cmd: '/extensions', desc: ' — list installed extensions' }, { cmd: '/help', desc: ' — show this help' }, { cmd: '/exit', desc: ' — quit' }]; -// @kern-source: intent:51 +// @kern-source: intent:54 export const FITNESS_PATTERN: RegExp = /\b(?:test with|test:|--test|fitness:)\s+(.+)/i; -// @kern-source: intent:54 +// @kern-source: intent:57 export const LEADERBOARD_KEYWORDS: RegExp = /\b(leaderboard|elo|rankings?)\b/i; -// @kern-source: intent:56 +// @kern-source: intent:59 export const HISTORY_KEYWORDS: RegExp = /\b(history|last runs?|recent)\b/i; -// @kern-source: intent:58 +// @kern-source: intent:61 export const ENGINES_KEYWORDS: RegExp = /\b(engines?|what engines)\b/i; -// @kern-source: intent:60 +// @kern-source: intent:63 export const CONFIG_KEYWORDS: RegExp = /\b(config|settings?)\b/i; -// @kern-source: intent:62 +// @kern-source: intent:65 export const HELP_KEYWORDS: RegExp = /^(help|\?)$/i; -// @kern-source: intent:64 +// @kern-source: intent:67 export const EXIT_KEYWORDS: RegExp = /^(exit|quit|bye)$/i; -// @kern-source: intent:66 +// @kern-source: intent:69 export const SENTENCE_PREFIX: RegExp = /^(do|does|did|is|are|was|were|have|has|had|can|could|would|should|will|shall|i\s)/i; -// @kern-source: intent:68 +// @kern-source: intent:71 export const QUESTION_PATTERN: RegExp = /^(what|how|why|where|when|who|which|explain|describe|tell|show|list|is there|does|can you explain|walk me through)\b/i; -// @kern-source: intent:70 +// @kern-source: intent:73 export const CODE_TASK_PATTERN: RegExp = /^(fix|add|implement|refactor|debug|create|build|write|update|change|remove|delete|rename|move|test|deploy|install|upgrade|migrate|convert|extract|inline|optimize|port)\b/i; -// @kern-source: intent:72 +// @kern-source: intent:75 export const CODE_ARTIFACT_PATTERN: RegExp = /(?:at \w+.*:\d+|\.[tj]sx?\b|\.[a-z]{2,4}:\d+|^[+-]{3}\s)/m; -// @kern-source: intent:74 +// @kern-source: intent:77 export const AGENT_TRIGGER_PATTERN: RegExp = /^(?:agent(?:\s+mode)?|autonomous(?:\s+agent)?|run\s+agent)\s+([\s\S]+)$/i; -// @kern-source: intent:77 +// @kern-source: intent:80 export const AUTOCREDIT_OFF_KEYWORDS: RegExp = /\b(?:schalt(?:e|)?\s+(?:das|es|autoCredit)\s+ab|mach(?:e|)?\s+(?:das|es|autoCredit)\s+(?:aus|weg)|das\s+nervt|(?:autoCredit|co[\s-]?authored?|contributor)\s+(?:aus|ab|weg|nervt))\b/i; -// @kern-source: intent:79 +// @kern-source: intent:82 export const AUTOCREDIT_ON_KEYWORDS: RegExp = /\b(?:schalt(?:e|)?\s+(?:das|es|autoCredit)\s+an|mach(?:e|)?\s+(?:das|es|autoCredit)\s+an|(?:autoCredit|co[\s-]?authored?|contributor)\s+an)\b/i; -// @kern-source: intent:81 +// @kern-source: intent:84 export const KNOWN_COLLAB_ENGINE_IDS: Set = hostStringSet([ 'claude', 'codex', 'agy', 'antigravity', 'qwen', 'kimi', 'opencode', 'open-code', 'minimax', 'zai', 'aider', 'cursor', 'gpt', 'openai' ]); -// @kern-source: intent:83 +// @kern-source: intent:86 export function classifyTask(input: string): 'code'|'question'|'ambiguous' { if (hostRegexObjectTest(QUESTION_PATTERN, input)) { return 'question'; @@ -115,7 +118,7 @@ export function classifyTask(input: string): 'code'|'question'|'ambiguous' { return 'ambiguous'; } -// @kern-source: intent:93 +// @kern-source: intent:96 function parseForgeInput(input: string): Intent { // Only match --hardened as a standalone flag (not inside task text or test args) const hardenedMatch = ((__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])) : {} })(input.match(/^(--hardened)[ \t\n\r\f\v]+(.*)$/i)) || ((__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])) : {} })(input.match(/^(.*?)[ \t\n\r\f\v]+(--hardened)[ \t\n\r\f\v]*$/i)); @@ -127,7 +130,7 @@ function parseForgeInput(input: string): Intent { return { type: 'forge', task: task, fitnessCmd: fitnessCmd, hardened: hardened } as Intent; } -// @kern-source: intent:104 +// @kern-source: intent:107 function parseAgentShortcut(input: string): Intent|null { const match = hostRegexMatch(AGENT_TRIGGER_PATTERN, input); if (!match) { @@ -140,7 +143,7 @@ function parseAgentShortcut(input: string): Intent|null { return { type: 'agent', input: task } as Intent; } -// @kern-source: intent:114 +// @kern-source: intent:117 function normalizeEngineToken(part: string): string|null { const cleaned = part.trim().toLowerCase().replace(/^@+/, '').replace(/^[^A-Za-z0-9_-]+|[^A-Za-z0-9_-]+$/g, ''); if (!cleaned) { @@ -161,7 +164,7 @@ function normalizeEngineToken(part: string): string|null { return null; } -// @kern-source: intent:130 +// @kern-source: intent:133 function parseExplicitEngineIds(input: string): string[] { const engineIds: string[] = []; const add = (value: string | null) => { @@ -188,7 +191,7 @@ function parseExplicitEngineIds(input: string): string[] { return engineIds; } -// @kern-source: intent:157 +// @kern-source: intent:160 function parseSemanticReviewShortcut(input: string): Intent|null { const lower = input.toLowerCase(); const reviewVerb = /\b(?:review|check|audit|inspect|look[ \t\n\r\f\v]+over)\b/i.test(input); @@ -218,17 +221,17 @@ function parseSemanticReviewShortcut(input: string): Intent|null { return { type: 'review', engineId: engineIds[0], engineIds: engineIds, target: target } as Intent; } -// @kern-source: intent:181 +// @kern-source: intent:184 function stripCollaborationLeadIn(input: string): string { return input.replace(/^(?:can[ \t\n\r\f\v]+you[ \t\n\r\f\v]+|could[ \t\n\r\f\v]+you[ \t\n\r\f\v]+|please[ \t\n\r\f\v]+)?(?:ask|have|get)[ \t\n\r\f\v]+(?:the[ \t\n\r\f\v]+)?(?:others|other[ \t\n\r\f\v]+engines|team|engines|models|everyone|all[ \t\n\r\f\v]+engines)[ \t\n\r\f\v]+(?:to[ \t\n\r\f\v]+)?/i, '').replace(/^(?:can[ \t\n\r\f\v]+you[ \t\n\r\f\v]+|could[ \t\n\r\f\v]+you[ \t\n\r\f\v]+|please[ \t\n\r\f\v]+)?what[ \t\n\r\f\v]+do[ \t\n\r\f\v]+(?:the[ \t\n\r\f\v]+)?(?:others|other[ \t\n\r\f\v]+engines|team|engines|models|everyone|all[ \t\n\r\f\v]+engines)[ \t\n\r\f\v]+(?:think[ \t\n\r\f\v]+about[ \t\n\r\f\v]+|say[ \t\n\r\f\v]+about[ \t\n\r\f\v]+|recommend[ \t\n\r\f\v]+for[ \t\n\r\f\v]+)?/i, '').replace(/^(?:can[ \t\n\r\f\v]+you[ \t\n\r\f\v]+|could[ \t\n\r\f\v]+you[ \t\n\r\f\v]+|please[ \t\n\r\f\v]+)?(?:talk|think)[ \t\n\r\f\v]+(?:it|this)?[ \t\n\r\f\v]*(?:through[ \t\n\r\f\v]+)?with[ \t\n\r\f\v]+(?:the[ \t\n\r\f\v]+)?(?:others|other[ \t\n\r\f\v]+engines|team|engines|models|everyone|all[ \t\n\r\f\v]+engines)[ \t\n\r\f\v]*/i, '').trim(); } -// @kern-source: intent:185 +// @kern-source: intent:188 function hasCollaborationAskShape(input: string): boolean { return /^(?:can[ \t\n\r\f\v]+you[ \t\n\r\f\v]+|could[ \t\n\r\f\v]+you[ \t\n\r\f\v]+|please[ \t\n\r\f\v]+)?(?:ask|have|get)[ \t\n\r\f\v]+(?:the[ \t\n\r\f\v]+)?(?:others|other[ \t\n\r\f\v]+engines|team|engines|models|everyone|all[ \t\n\r\f\v]+engines)\b/i.test(input) || /^(?:can[ \t\n\r\f\v]+you[ \t\n\r\f\v]+|could[ \t\n\r\f\v]+you[ \t\n\r\f\v]+|please[ \t\n\r\f\v]+)?what[ \t\n\r\f\v]+do[ \t\n\r\f\v]+(?:the[ \t\n\r\f\v]+)?(?:others|other[ \t\n\r\f\v]+engines|team|engines|models|everyone|all[ \t\n\r\f\v]+engines)[ \t\n\r\f\v]+(?:think|say|recommend)\b/i.test(input) || /^(?:can[ \t\n\r\f\v]+you[ \t\n\r\f\v]+|could[ \t\n\r\f\v]+you[ \t\n\r\f\v]+|please[ \t\n\r\f\v]+)?(?:brainstorm|compare|weigh[ \t\n\r\f\v]+in)[ \t\n\r\f\v]+(?:this|it)?[ \t\n\r\f\v]*(?:with[ \t\n\r\f\v]+)?(?:the[ \t\n\r\f\v]+)?(?:others|other[ \t\n\r\f\v]+engines|team|engines|models|everyone|all[ \t\n\r\f\v]+engines)\b/i.test(input); } -// @kern-source: intent:189 +// @kern-source: intent:192 function parseSemanticCollaborationShortcut(input: string): Intent|null { const question = stripCollaborationLeadIn(input); if (/\b(?:debate|argue|tribunal|red-team|red[ \t\n\r\f\v]+team)\b/i.test(input)) { @@ -244,7 +247,7 @@ function parseSemanticCollaborationShortcut(input: string): Intent|null { return null; } -// @kern-source: intent:201 +// @kern-source: intent:204 function parseSemanticForgeShortcut(input: string): Intent|null { const explicitForgeImperative = /^(?:can[ \t\n\r\f\v]+you[ \t\n\r\f\v]+|could[ \t\n\r\f\v]+you[ \t\n\r\f\v]+|please[ \t\n\r\f\v]+)?forge\b/i.test(input) && !/^(?:can[ \t\n\r\f\v]+you[ \t\n\r\f\v]+|could[ \t\n\r\f\v]+you[ \t\n\r\f\v]+|please[ \t\n\r\f\v]+)?forge[ \t\n\r\f\v]+(?:is|was|seems?|looks?|does|did|can|should|would|will|not|still)\b/i.test(input); const hasForgeShape = explicitForgeImperative || /\b(?:forge[ \t\n\r\f\v]+this|forge[ \t\n\r\f\v]+it|have[ \t\n\r\f\v]+(?:the[ \t\n\r\f\v]+)?(?:engines|models|team|others)[ \t\n\r\f\v]+compete|make[ \t\n\r\f\v]+(?:the[ \t\n\r\f\v]+)?(?:engines|models|team|others)[ \t\n\r\f\v]+compete|competitive[ \t\n\r\f\v]+(?:build|implementation|fix))\b/i.test(input); @@ -259,23 +262,23 @@ function parseSemanticForgeShortcut(input: string): Intent|null { /** * Plain text must not start orchestration. Brainstorm, tribunal, campfire, forge, and review are slash-only from chat input; mention words like 'tribunal' or 'forge' should reach Cesar as normal text unless the user uses /tribunal, /forge, etc. */ -// @kern-source: intent:211 +// @kern-source: intent:214 function parseSemanticDelegationShortcut(input: string): Intent|null { return null; } -// @kern-source: intent:216 +// @kern-source: intent:219 function splitReviewArgs(input: string): string[] { return input.split(/[ \t\n\r\f\v]+/).flatMap((part) => part.split(',')).map((part) => part.trim()).filter(Boolean); } -// @kern-source: intent:220 +// @kern-source: intent:223 function isReviewTargetArg(part: string): boolean { const lower = part.toLowerCase(); return lower === 'uncommitted' || lower.startsWith('branch:') || lower.startsWith('commit:'); } -// @kern-source: intent:225 +// @kern-source: intent:228 function isImplicitReviewSubjectArg(part: string): boolean { const lower = part.toLowerCase(); return lower === 'it' || lower === 'this' || lower === 'that' || lower === 'them' || lower === 'changes' || lower === 'diff'; @@ -284,7 +287,7 @@ function isImplicitReviewSubjectArg(part: string): boolean { /** * Parse review args into target + engine list. When bareWordsAreEngines is true (the explicit /review slash path), any bare word that isn't a target (uncommitted/branch:/commit:) or a keyword is treated as an engine name — so `/review codex claude` reviews with BOTH, no `with` needed. The natural-language shortcut path leaves it false so prose like `review this code` doesn't mis-bind `code` as an engine. */ -// @kern-source: intent:230 +// @kern-source: intent:233 function parseReviewInput(input: string, bareWordsAreEngines?: boolean): Intent { const reviewParts = splitReviewArgs(input); const engineIds: string[] = []; @@ -319,7 +322,7 @@ function parseReviewInput(input: string, bareWordsAreEngines?: boolean): Intent return { type: 'review', engineId, engineIds: engineIds.length > 0 ? engineIds : undefined, target } as Intent; } -// @kern-source: intent:266 +// @kern-source: intent:269 function parseReviewShortcut(input: string): Intent|null { 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])) : {} })(input.match(/^(?:review|cr)(?:[ \t\n\r\f\v]+([ \t\n\r\f\v\S]+))?$/i)); if (!match) { @@ -347,7 +350,7 @@ function parseReviewShortcut(input: string): Intent|null { return null; } -// @kern-source: intent:288 +// @kern-source: intent:291 function parseSlashCommand(input: string, commandRegistry?: any): Intent { const stripped = input.slice(1).trim(); if (!stripped) return { type: 'slash-list' } as Intent; @@ -497,10 +500,14 @@ function parseSlashCommand(input: string, commandRegistry?: any): Intent { } case 'conquer': { // /conquer --gate "" [--builder X] [-e a,b] [--max-turns N] + // [--gate-timeout S] [--max-hours H] [--timeout S] (CLI parity) let cqGate: string | undefined; let cqBuilder: string | undefined; let cqEngineIds: string[] | undefined; let cqMaxTurns: number | undefined; + let cqGateTimeout: number | undefined; + let cqMaxHours: number | undefined; + let cqTurnTimeout: number | undefined; let cqRest = rest; const gateM = cqRest.match(/--gate\s+(?:"([^"]*)"|'([^']*)'|(\S+(?:\s+(?!--|-[A-Za-z])\S+)*))/); if (gateM) { cqGate = (gateM[1] ?? gateM[2] ?? gateM[3] ?? '').trim(); cqRest = cqRest.replace(gateM[0], ' '); } @@ -510,7 +517,13 @@ function parseSlashCommand(input: string, commandRegistry?: any): Intent { if (enginesM) { cqEngineIds = enginesM[1].split(',').map((x) => x.trim()).filter(Boolean); cqRest = cqRest.replace(enginesM[0], ' '); } const turnsM = cqRest.match(/--max-turns\s+(\d+)/); if (turnsM) { cqMaxTurns = parseInt(turnsM[1], 10); cqRest = cqRest.replace(turnsM[0], ' '); } - return { type: 'conquer', task: cqRest.replace(/\s+/g, ' ').trim(), gate: cqGate, builder: cqBuilder, engineIds: cqEngineIds, maxTurns: cqMaxTurns } as Intent; + const gateToM = cqRest.match(/--gate-timeout\s+(\d+)/); + if (gateToM) { cqGateTimeout = parseInt(gateToM[1], 10); cqRest = cqRest.replace(gateToM[0], ' '); } + const hoursM = cqRest.match(/--max-hours\s+(\d+(?:\.\d+)?)/); + if (hoursM) { cqMaxHours = parseFloat(hoursM[1]); cqRest = cqRest.replace(hoursM[0], ' '); } + const turnToM = cqRest.match(/--timeout\s+(\d+)/); + if (turnToM) { cqTurnTimeout = parseInt(turnToM[1], 10); cqRest = cqRest.replace(turnToM[0], ' '); } + return { type: 'conquer', task: cqRest.replace(/\s+/g, ' ').trim(), gate: cqGate, builder: cqBuilder, engineIds: cqEngineIds, maxTurns: cqMaxTurns, gateTimeout: cqGateTimeout, maxHours: cqMaxHours, turnTimeout: cqTurnTimeout } as Intent; } case 'synthesis': case 'synth': { @@ -750,7 +763,7 @@ function parseSlashCommand(input: string, commandRegistry?: any): Intent { } } -// @kern-source: intent:691 +// @kern-source: intent:704 export function detectIntent(raw: string, commandRegistry?: any): Intent { const input = raw.trim(); if (!input) { diff --git a/packages/cli/src/kern/cesar/brain.kern b/packages/cli/src/kern/cesar/brain.kern index f0eaf182d..c757f043e 100644 --- a/packages/cli/src/kern/cesar/brain.kern +++ b/packages/cli/src/kern/cesar/brain.kern @@ -15,6 +15,9 @@ import from="./tools.js" names="createCesarToolRegistry,createEagerToolContext,e import from="./escalation.js" names="fireQuickNero,fireNero,fireAdvisor,handleSecondOpinion,activateNero,deactivateNero,promptDelegation,promptProtocolEnforcement" import from="./routing.js" names="buildRoutingContext,deriveRoutingHints,shouldSpeculate" import from="./grounding.js" names="shouldGroundInput,buildGroundingBlock" +import from="./experience.js" names="episodesFromRunRecords,retrieveExperience,buildExperienceBlock,experienceRetrievalOptions" +import from="./delegation-reflex.js" names="assessDelegationShape,buildDelegationAdvisory" +import from="../../telemetry/index.js" names="recentRunRecords,currentProjectKey" import from="./reliability.js" names="readCesarToolReliability,formatCesarReliabilityLine,shouldDowngradeCesarToolWork,buildWhatHappenedSummary" import from="./self-turn-approval.js" names="applyCesarSelfTurnApproval" import from="./approval-diff.js" names="approvalToolIsFileMutating,buildApprovalDiffPreview" @@ -861,6 +864,35 @@ ${reviewFollowup.prompt}`; } catch { /* grounding is best-effort */ } } + // ── Experience precedent (advisory, default-on: config.cesarExperience) ── + // RAG roadmap 1b: similar PAST RUN episodes (mode/winner/outcome) injected + // as evidence, never authority. Purely lexical (no sidecar spawn on the hot + // path), gated on min-N qualifying matches + a similarity floor, and + // fail-open — any error injects nothing. Reuses shouldGroundInput so slash + // commands and trivial prompts never retrieve. + if ((config as any).cesarExperience !== false && shouldGroundInput(input)) { + try { + const _expOpts = experienceRetrievalOptions(config); + // Scoped to THIS project: global telemetry must never leak another + // repo's task text into this turn (review blocker: codex). + const _expEpisodes = episodesFromRunRecords(recentRunRecords(_expOpts.window), currentProjectKey()); + const _expBlock = buildExperienceBlock(retrieveExperience(input, _expEpisodes, _expOpts)); + if (_expBlock) enrichedInput = `${_expBlock}\n\n${enrichedInput}`; + } catch { /* experience is best-effort */ } + } + + // ── Delegation reflex (advisory-only: config.cesarDelegationReflex) ── + // Structural fan-out detection (explicit ≥3-item lists, veto-first). The + // advisory rides the prompt; surfacing stays with Cesar's existing + // [SUGGEST:agent-team] → promptDelegation flow, so the user always + // confirms and nothing auto-spawns. Fail-open like its siblings. + if ((config as any).cesarDelegationReflex !== false && shouldGroundInput(input)) { + try { + const _delAdvisory = buildDelegationAdvisory(assessDelegationShape(input)); + if (_delAdvisory) enrichedInput = `${_delAdvisory}\n\n${enrichedInput}`; + } catch { /* delegation reflex is best-effort */ } + } + // ── Sequential-thinking scaffold (opt-in: config.cesarThinkFirst) ── // The in-loop, dispatch-free form of `agon think`: when enabled, scaffold // structured decomposition into the prompt so Cesar reasons before acting diff --git a/packages/cli/src/kern/cesar/delegation-reflex.kern b/packages/cli/src/kern/cesar/delegation-reflex.kern new file mode 100644 index 000000000..84eec9812 --- /dev/null +++ b/packages/cli/src/kern/cesar/delegation-reflex.kern @@ -0,0 +1,58 @@ +// ── Delegation reflex (first slice: advisory-only) ────────────────── +// Structural detection of fan-out-shaped tasks — 6/6-engine brainstorm +// (label delegation-reflex, 2026-07-16) converged on: pure scorer, vetoes +// first (false positives are worse than misses), explicit list shape with +// a ≥3-item floor, advisory-only surfacing through the EXISTING suggestion +// machinery ([SUGGEST:agent-team] → promptDelegation → user confirms). +// This module never spawns anything: it only tells Cesar, in-prompt, that +// the task LOOKS parallelizable; auto-spawn thresholds are a deliberately +// unbuilt follow-up. + +interface name=DelegationShape export=true + field name=decision type="'none' | 'suggest'" + field name=items type="string[]" + field name=reasons type="string[]" + field name=vetoes type="string[]" + +const name=DELEGATION_SEQUENTIAL_RE type=RegExp value={{ /\b(?:then|after (?:that|this|which)|once (?:that|this|it)|depends on|followed by|first\b[^\n.]{0,120}\bthen)\b/i }} + doc "Sequential-dependency markers: ordered work must never be pitched as parallel. The first…then pair is windowed to one sentence-ish span so unrelated mentions across a long prompt don't over-veto (review: kimi)." + +const name=DELEGATION_SMALL_RE type=RegExp value={{ /\b(?:quick(?:ly)?|just|tiny|small|simple|typo|one[- ]liner|minor)\b/i }} + doc "The user saying the work is small is a hard veto — spawning a team for a typo fix burns money." + +const name=DELEGATION_SHARED_STATE_RE type=RegExp value={{ /\b(?:lockfiles?|package\.json|package-lock|migrations?|schemas?|generated|shared (?:types?|modules?|configs?|state)|same files?)\b/i }} + doc "Shared-artifact mentions: list-shaped prompts whose items converge on one artifact look parallel but are not." + +const name=DELEGATION_ITEM_RE type=RegExp value={{ /^\s*(?:\d+[.)]\s+|[-*•]\s+)(.+)$/gm }} + doc "Explicit list items only (numbered or bulleted lines). Deliberately strict for the first slice: comma-series and 'for each X' phrasings under-trigger rather than over-trigger." + +fn name=assessDelegationShape params="input:string" returns=DelegationShape export=true + doc "Pure fan-out-shape assessment of a user prompt. Suggests ONLY when: ≥3 explicit list items, every item clears a 20-char size floor, and NO veto fires (sequential markers, small-task language, shared-artifact mentions, prompt too short overall). Vetoes are reported so the advisory can be debugged; the caller decides how to surface the result." + handler <<< + const text = String(input ?? ''); + const vetoes: string[] = []; + const reasons: string[] = []; + const items: string[] = []; + const re = new RegExp(DELEGATION_ITEM_RE.source, 'gm'); + for (const m of text.matchAll(re)) { + const item = (m[1] ?? '').trim(); + if (item) items.push(item.length > 120 ? item.slice(0, 119).trimEnd() + '…' : item); + } + if (text.trim().length < 80) vetoes.push('prompt-too-short'); + if (items.length < 3) vetoes.push('fewer-than-3-items'); + if (items.some((i) => i.length < 20)) vetoes.push('item-below-size-floor'); + if (DELEGATION_SEQUENTIAL_RE.test(text)) vetoes.push('sequential-markers'); + if (DELEGATION_SMALL_RE.test(text)) vetoes.push('small-task-language'); + if (DELEGATION_SHARED_STATE_RE.test(text)) vetoes.push('shared-artifact-mention'); + if (vetoes.length > 0) return { decision: 'none', items, reasons, vetoes }; + reasons.push(`${items.length} explicit list items`); + reasons.push('no sequential/shared-state/small-task markers'); + return { decision: 'suggest', items, reasons, vetoes }; + >>> + +fn name=buildDelegationAdvisory params="shape:DelegationShape" returns="string | null" export=true + doc "The in-prompt advisory injected ahead of the user message when the shape suggests fan-out. Deliberately framed as a structural observation Cesar may reject: the model owns the judgment, the existing [SUGGEST:agent-team] → user-confirm flow owns the UX, and nothing spawns without the user saying yes." + handler <<< + if (shape.decision !== 'suggest' || shape.items.length < 3) return null; + return `[DELEGATION SHAPE] This request contains ${shape.items.length} explicit list items with no sequential or shared-state markers — it MAY decompose into independent parallel subtasks. If, after reading them, you judge them genuinely independent (disjoint files, no ordering), you can offer parallel execution via [SUGGEST:team-agent] (worktree-isolated agent team; the user confirms before anything spawns) or background jobs. If they share state or order matters, ignore this note entirely — do not mention it.`; + >>> diff --git a/packages/cli/src/kern/cesar/experience.kern b/packages/cli/src/kern/cesar/experience.kern new file mode 100644 index 000000000..6e30ea97f --- /dev/null +++ b/packages/cli/src/kern/cesar/experience.kern @@ -0,0 +1,140 @@ +// ── Experience precedent (RAG roadmap 1b) ─────────────────────────── +// Retrieve similar PAST RUN episodes (mode, winner, outcome) for the +// current prompt and present them as ADVISORY evidence — never authority +// (6/6-engine cesar-mode-rag brainstorm: the mode catalog stays statically +// injected; precedent surfaces only with similarity + min-N gates so a +// couple of stale runs can't steer routing). Lexical similarity only — no +// python sidecar, deterministic, fast enough to run on every turn, +// fail-open at the injection site. + +interface name=ExperienceEpisode export=true + field name=mode type=string + field name=taskType type=string + field name=intentSummary type=string + field name=winner type=string + field name=success type=boolean + field name=durationMs type=number + field name=timestamp type=number + field name=projectKey type=string + +interface name=ExperienceMatch export=true + field name=episode type=ExperienceEpisode + field name=similarity type=number + +interface name=ExperienceRetrievalOptions export=true + field name=minSimilarity type=number + field name=minEpisodes type=number + field name=topK type=number + field name=window type=number + +const name=EXPERIENCE_STOPWORDS type="Set" value={{ new Set(['the', 'and', 'for', 'with', 'that', 'this', 'from', 'into', 'when', 'then', 'them', 'they', 'have', 'has', 'had', 'was', 'were', 'will', 'would', 'should', 'could', 'can', 'not', 'but', 'are', 'you', 'your', 'our', 'its', 'all', 'any', 'each', 'also', 'via', 'per', 'about', 'after', 'before', 'make', 'made', 'use', 'using', 'used', 'add', 'new', 'get', 'set', 'run', 'runs', 'please']) }} + doc "Common filler words removed before similarity scoring — generic verbs like add/use/run appear in most engineering prompts and would inflate Jaccard overlap between unrelated tasks." + +fn name=summarizeIntentForEpisode params="intent:string" returns=string export=true + doc "Normalize a run intent into a compact, storable episode summary: whitespace collapsed, control characters stripped, capped at 160 chars. Empty result (< 8 meaningful chars) means the run stores NO summary and never becomes an episode — a bare mode invocation carries no precedent signal." + handler <<< + const collapsed = String(intent ?? '') + .replace(/[\u0000-\u001f\u007f]+/g, ' ') + .replace(/\s+/g, ' ') + .trim(); + if (collapsed.length < 8) return ''; + return collapsed.length > 160 ? collapsed.slice(0, 159).trimEnd() + '…' : collapsed; + >>> + +fn name=tokenizeExperienceText params="text:string" returns="Set" export=true + doc "Lowercased word tokens (unicode letters + digits, so non-English prompts score too — review: kimi), ≥3 chars, stopwords removed, deduplicated — the unit of Jaccard similarity." + handler <<< + const tokens = String(text ?? '') + .toLowerCase() + .split(/[^\p{L}\p{N}]+/u) + .filter((t) => t.length >= 3 && !EXPERIENCE_STOPWORDS.has(t)); + return new Set(tokens); + >>> + +fn name=scoreTokenSetSimilarity params="ta:Set, tb:Set" returns=number export=true + doc "Jaccard over pre-tokenized sets — retrieveExperience tokenizes the prompt ONCE and reuses the set across every episode (review: agy)." + handler <<< + if (ta.size === 0 || tb.size === 0) return 0; + let shared = 0; + for (const t of ta) { if (tb.has(t)) shared++; } + const unionSize = ta.size + tb.size - shared; + return unionSize > 0 ? shared / unionSize : 0; + >>> + +fn name=scoreExperienceSimilarity params="a:string, b:string" returns=number export=true + doc "Token-set Jaccard similarity in [0,1]. Deterministic and cheap — precedent retrieval must never cost a sidecar spawn on the hot turn path." + handler <<< + return scoreTokenSetSimilarity(tokenizeExperienceText(a), tokenizeExperienceText(b)); + >>> + +fn name=experienceRetrievalOptions params="config:any" returns=ExperienceRetrievalOptions export=true + doc "Resolve the retrieval gates from config with safe defaults in ONE place: cesarExperienceMinSimilarity (default 0.2), cesarExperienceMinEpisodes (default 3 — the brainstorm's min-N gate), cesarExperienceTopK (default 3), cesarExperienceWindow (default 200 recent runs). Non-positive / non-finite values mean 'unset' (KERN codegen emits optional number fields as 0 into DEFAULT_AGON_CONFIG)." + handler <<< + const rawSim = Number(config?.cesarExperienceMinSimilarity); + const rawMin = Number(config?.cesarExperienceMinEpisodes); + const rawTop = Number(config?.cesarExperienceTopK); + const rawWin = Number(config?.cesarExperienceWindow); + return { + minSimilarity: Math.min(1, Number.isFinite(rawSim) && rawSim > 0 ? rawSim : 0.2), + minEpisodes: Math.floor(Number.isFinite(rawMin) && rawMin > 0 ? rawMin : 3), + topK: Math.floor(Number.isFinite(rawTop) && rawTop > 0 ? rawTop : 3), + window: Math.floor(Number.isFinite(rawWin) && rawWin > 0 ? rawWin : 200), + }; + >>> + +fn name=episodesFromRunRecords params="records:any[], projectKey?:string" returns="ExperienceEpisode[]" export=true + doc "Map telemetry RunRecords into episodes, keeping only records that stored an intentSummary (older records predate the field and carry no precedent signal). When projectKey is given, ONLY records from that project qualify — telemetry.json is global, and precedent from another repo must never leak its task text into this repo's turn (review blocker: codex). Records without a projectKey are excluded from a scoped query (fail-closed)." + handler <<< + const scope = typeof projectKey === 'string' ? projectKey.trim() : ''; + const episodes: ExperienceEpisode[] = []; + for (const r of Array.isArray(records) ? records : []) { + const intentSummary = typeof r?.intentSummary === 'string' ? r.intentSummary.trim() : ''; + if (!intentSummary) continue; + const recordProject = typeof r.projectKey === 'string' ? r.projectKey.trim() : ''; + if (scope && recordProject !== scope) continue; + episodes.push({ + mode: String(r.mode ?? ''), + taskType: String(r.taskType ?? 'other'), + intentSummary, + winner: typeof r.winner === 'string' ? r.winner : '', + success: r.success === true, + durationMs: Number.isFinite(Number(r.durationMs)) ? Number(r.durationMs) : 0, + timestamp: Number.isFinite(Number(r.timestamp)) ? Number(r.timestamp) : 0, + projectKey: recordProject, + }); + } + return episodes; + >>> + +fn name=retrieveExperience params="prompt:string, episodes:ExperienceEpisode[], opts:ExperienceRetrievalOptions" returns="ExperienceMatch[]" export=true + doc "Score every episode against the prompt and return the top-K — but ONLY when at least minEpisodes clear the similarity gate. Below that support threshold precedent is noise, not evidence, and nothing is returned (the brainstorm's min-N≥3 rule). Ties break toward the more recent episode." + handler <<< + const promptTokens = tokenizeExperienceText(prompt); + const scored: ExperienceMatch[] = []; + for (const e of episodes) { + const similarity = scoreTokenSetSimilarity(promptTokens, tokenizeExperienceText(e.intentSummary)); + if (similarity >= opts.minSimilarity) scored.push({ episode: e, similarity }); + } + if (scored.length < opts.minEpisodes) return []; + scored.sort((a, b) => (b.similarity - a.similarity) || (b.episode.timestamp - a.episode.timestamp)); + return scored.slice(0, Math.max(1, opts.topK)); + >>> + +fn name=buildExperienceBlock params="matches:ExperienceMatch[]" returns="string | null" export=true + doc "Render the advisory precedent block injected ahead of the user prompt. The framing is deliberate: evidence, not authority — the model judges the current task on its own merits." + handler <<< + if (!matches.length) return null; + const lines: string[] = []; + for (const m of matches) { + const e = m.episode; + const outcome = e.success ? 'succeeded' : 'did not succeed'; + const winner = e.winner ? ` → ${e.winner}` : ''; + const mins = Math.round(e.durationMs / 60000); + const dur = e.durationMs > 0 ? (mins > 0 ? `, ${mins}m` : `, <1m`) : ''; + // Rendering hygiene: summaries are the user's OWN past prompts (same + // local trust domain as the live prompt), but keep the quoting intact. + const quoted = e.intentSummary.replace(/"/g, "'"); + lines.push(`- "${quoted}" — mode=${e.mode}${winner}, ${outcome}${dur} (similarity ${m.similarity.toFixed(2)})`); + } + return `[EXPERIENCE] Precedent from similar past agon runs — evidence, not authority:\n${lines.join('\n')}\nWeigh these outcomes when choosing a mode or engine, but judge the current task on its own merits.`; + >>> diff --git a/packages/cli/src/kern/commands/conquer.kern b/packages/cli/src/kern/commands/conquer.kern index 45b5f8bf9..67b56f726 100644 --- a/packages/cli/src/kern/commands/conquer.kern +++ b/packages/cli/src/kern/commands/conquer.kern @@ -10,7 +10,7 @@ import from="citty" names="defineCommand" import from="@kernlang/agon-core" names="EngineRegistry,ensureAgonHome,loadConfig,createRunDir,spawnWithTimeout,appendAttribution,appendPrAttribution,githubRepoUrl,defaultBaseBranch,prefilledPrUrl" -import from="@kernlang/agon-forge" names="runConquer,runPrText,createConquerIsolation" +import from="@kernlang/agon-forge" names="runConquer,runPrText,createConquerIsolation,isProtectedPushBranch" import from="@kernlang/agon-forge" names="ConquerResult,ConquerTurn" types=true import from="../lib/engines-dir.js" names="resolveBuiltinEnginesDir" import from="@kernlang/agon-adapter-cli" names="createCliAdapter" @@ -95,8 +95,10 @@ const name=conquerCommand type=any maxHours = parseFloat(String(args.maxHours)); if (Number.isNaN(maxHours) || maxHours < 0) { console.error(`Invalid --max-hours: ${args.maxHours}`); process.exitCode = 1; return; } } - const turnTimeout = parseInt(String(args.timeout ?? '600'), 10) || 600; - const gateTimeout = parseInt(String(args.gateTimeout ?? '1800'), 10) || 1800; + const turnTimeoutRaw = parseInt(String(args.timeout ?? '600'), 10); + const turnTimeout = Number.isFinite(turnTimeoutRaw) && turnTimeoutRaw > 0 ? turnTimeoutRaw : 600; + const gateTimeoutRaw = parseInt(String(args.gateTimeout ?? '1800'), 10); + const gateTimeout = Number.isFinite(gateTimeoutRaw) && gateTimeoutRaw > 0 ? gateTimeoutRaw : 1800; const { path: outputDir } = createRunDir({ mode: 'conquer', label: args.label, announce: false }); let isolation; @@ -196,6 +198,14 @@ const name=conquerCommand type=any if (branchRes.exitCode !== 0 || !headBranch || headBranch === 'HEAD') { info(dim('Could not resolve a branch to push (detached HEAD?). The builder\'s changes are uncommitted in the tree — review + commit + push manually.')); process.exitCode = 1; + } else if (isProtectedPushBranch(headBranch, config)) { + // Unattended pushes never land on a protected branch (main/master by + // default, protectedPushBranches to override) — a conquer worktree + // normally sits on its own conquer/ branch, so a protected HEAD + // here means something unexpected happened. Leave the work committed + // locally and hand the push to the human. + info(dim(`Refusing to push protected branch '${headBranch}' from an unattended run. The work is in the worktree at ${worktreeCwd} — review and push it manually (or set protectedPushBranches in config).`)); + process.exitCode = 1; } else { const add = await git(['add', '-A'], 30_000); // appendAttribution = the Claude-Code-style footer (Generated-with line + diff --git a/packages/cli/src/kern/handlers/conquer.kern b/packages/cli/src/kern/handlers/conquer.kern index ebac56665..fc6f47ede 100644 --- a/packages/cli/src/kern/handlers/conquer.kern +++ b/packages/cli/src/kern/handlers/conquer.kern @@ -14,7 +14,7 @@ import from="../../telemetry/index.js" names="recordRun,formatRunSummary" import from="../../handlers/types.js" names="Dispatch,HandlerContext" types=true import from="./engine-filter.js" names="filterDefaultOrchestrationEngines" -fn name=handleConquer params="task:string, dispatch:Dispatch, ctx:HandlerContext, opts?:{gate?:string, builder?:string, engineIds?:string[], maxTurns?:number}" returns="Promise" async=true +fn name=handleConquer params="task:string, dispatch:Dispatch, ctx:HandlerContext, opts?:{gate?:string, builder?:string, engineIds?:string[], maxTurns?:number, gateTimeoutSec?:number, maxHours?:number, turnTimeoutSec?:number}" returns="Promise" async=true signal name=cqAbort handler <<< ensureAgonHome(); @@ -39,7 +39,18 @@ fn name=handleConquer params="task:string, dispatch:Dispatch, ctx:HandlerContext advisors = resolved.filter((id) => id !== builder); } + // CLI parity: `agon conquer` rejects --max-turns < 1 instead of silently + // running 40 turns the user tried to cap at 0. + if (opts?.maxTurns !== undefined && (!Number.isFinite(opts.maxTurns) || opts.maxTurns < 1)) { + dispatch({ type: 'error', message: `Invalid --max-turns: ${opts.maxTurns}` }); + return; + } const maxTurns = opts?.maxTurns && opts.maxTurns > 0 ? opts.maxTurns : 40; + // CLI-parity knobs (previously hardcoded in this handler — review follow-up): + // defaults mirror `agon conquer` (gate 1800s, per-builder-turn 600s, no wall cap). + const gateTimeoutSec = opts?.gateTimeoutSec && Number.isFinite(opts.gateTimeoutSec) && opts.gateTimeoutSec > 0 ? Math.floor(opts.gateTimeoutSec) : 1800; + const turnTimeoutSec = opts?.turnTimeoutSec && Number.isFinite(opts.turnTimeoutSec) && opts.turnTimeoutSec > 0 ? Math.floor(opts.turnTimeoutSec) : 600; + const maxWallClockMs = opts?.maxHours && Number.isFinite(opts.maxHours) && opts.maxHours > 0 ? Math.round(opts.maxHours * 3_600_000) : 0; const outputDir = join(RUNS_DIR, `conquer-${Date.now()}`); mkdirSync(outputDir, { recursive: true }); let isolation; @@ -69,7 +80,7 @@ fn name=handleConquer params="task:string, dispatch:Dispatch, ctx:HandlerContext const evaluateDone = async (_claim: string) => { try { const diffRes = await spawnWithTimeout({ command: 'git', args: ['diff'], cwd: worktreeCwd, timeout: 30_000 }); - const gateRes = await spawnWithTimeout({ command: 'sh', args: ['-c', gate], cwd: worktreeCwd, timeout: 1_800_000 }); + const gateRes = await spawnWithTimeout({ command: 'sh', args: ['-c', gate], cwd: worktreeCwd, timeout: gateTimeoutSec * 1000 }); return { diff: String(diffRes.stdout ?? ''), gateOk: gateRes.exitCode === 0, oracleTampered: false }; } catch (err) { dispatch({ type: 'info', message: `done-check gate errored: ${err instanceof Error ? err.message : String(err)} — treating as not-done.` }); @@ -81,8 +92,8 @@ fn name=handleConquer params="task:string, dispatch:Dispatch, ctx:HandlerContext try { result = await runConquer({ task, builderEngine: builder, advisorEngines: advisors, - registry: ctx.registry, adapter: ctx.adapter, timeout: 600, outputDir, cwd: worktreeCwd, gate, - caps: { maxTurns, maxWallClockMs: 0 }, + registry: ctx.registry, adapter: ctx.adapter, timeout: turnTimeoutSec, outputDir, cwd: worktreeCwd, gate, + caps: { maxTurns, maxWallClockMs }, evaluateDone, onTurn: (t: ConquerTurn) => dispatch({ type: 'info', message: `turn ${t.n} · ${t.action}${t.action === 'consult' || t.action === 'done-check' ? ` — ${t.detail.slice(0, 80)}` : ''}` }), signal: cqAbort.signal, diff --git a/packages/cli/src/kern/signals/dispatch/intent-orchestration.kern b/packages/cli/src/kern/signals/dispatch/intent-orchestration.kern index d92a0551d..3ac7ebd09 100644 --- a/packages/cli/src/kern/signals/dispatch/intent-orchestration.kern +++ b/packages/cli/src/kern/signals/dispatch/intent-orchestration.kern @@ -139,7 +139,7 @@ fn name=dispatchOrchestrationIntent async=true params="intent:any, input:string, const cqEpoch = cb.ctx.inputEpoch ?? 0; const cqUserTurns = countTrackedUserTurns(cb.ctx); cb.runAsJob('conquer', _cqLabel, withThreadOutcome(_cqCwd, 'conquer', _cqLabel, async () => { - await handleConquer(intent.task ?? '', cb.dispatch, cb.ctx, { gate: intent.gate, builder: intent.builder, engineIds: intent.engineIds, maxTurns: intent.maxTurns }); + await handleConquer(intent.task ?? '', cb.dispatch, cb.ctx, { gate: intent.gate, builder: intent.builder, engineIds: intent.engineIds, maxTurns: intent.maxTurns, gateTimeoutSec: intent.gateTimeout, maxHours: intent.maxHours, turnTimeoutSec: intent.turnTimeout }); const chatContext = collectRecentEngineContext(cb.ctx, 14, 1500); if (chatContext) await continueCesarAfterResult(`Conquer build on: "${(intent.task ?? '').slice(0, 200)}"\n\n${chatContext}\n\nSummarize what was built + the done-oracle outcome, and recommend the next step (review/merge or keep going).`, cb, cqEpoch, cqUserTurns); }, cb.ctx)); diff --git a/packages/cli/src/kern/signals/intent.kern b/packages/cli/src/kern/signals/intent.kern index 57d62ccde..6e498abb1 100644 --- a/packages/cli/src/kern/signals/intent.kern +++ b/packages/cli/src/kern/signals/intent.kern @@ -41,12 +41,15 @@ module name=IntentParsing field name=gate type="string|undefined" field name=builder type="string|undefined" field name=maxTurns type="number|undefined" + field name=gateTimeout type="number|undefined" + field name=maxHours type="number|undefined" + field name=turnTimeout type="number|undefined" field name=swaps type="number|undefined" field name=reasoning type="string|undefined" field name=count type="number|undefined" field name=last type="boolean|undefined" - const name=SLASH_COMMANDS type="SlashCommand[]" value={{ [{ cmd: '/forge', desc: ' test with [--hardened] — competitive code generation' }, { cmd: '/brainstorm', desc: ' — confidence-bidding answers' }, { cmd: '/tribunal', desc: '[mode] — debate (adversarial|socratic|red-team|steelman|synthesis|postmortem)' }, { cmd: '/campfire', desc: ' — think together, no competition' }, { cmd: '/think', desc: ' [--strategy reflexion] [--steps 8] — sequential thinking, one engine' }, { cmd: '/council', desc: ' — roundtable: every engine a role, top-rated chairs' }, { cmd: '/research', desc: ' [--count N] [--engine X] — keyless web-grounded cited research (npm/GitHub/MDN/IETF/SO/Wikipedia)' }, { cmd: '/chrome', desc: ' — drive your browser (read/navigate/screenshot/click), result feeds Cesar' }, { cmd: '/synthesis', desc: ' [--swaps 2] — engines draft, swap, improve; judge picks winner' }, { cmd: '/workspace', desc: 'add|remove|list|switch — manage project repos' }, { cmd: '/ws', desc: ' — list workspaces (shortcut)' }, { cmd: '/cesar', desc: ' — set Cesar brain engine (e.g. /cesar codex)' }, { cmd: '/models', desc: ' — browse & add provider models + CLI models' }, { cmd: '/tokens', desc: ' — show token usage & costs' }, { cmd: '/raw', desc: ' — reprint last folded engine output (unfolded)' }, { cmd: '/engines', desc: ' — select active engines' }, { cmd: '/leaderboard', desc: ' — Glicko rankings' }, { cmd: '/cesar-report', desc: ' — Cesar routing calibration report' }, { cmd: '/cesar-hints', desc: ' — inspect Cesar routing hints for a prompt' }, { cmd: '/history', desc: '[id] — past forge runs' }, { cmd: '/config', desc: '[list|get|set] — settings' }, { cmd: '/plan', desc: ' or no args — plan mode or show plan' }, { cmd: '/auto', desc: '[on|off|toggle|status] or — autonomous mode control' }, { cmd: '/plans', desc: ' — list recent plans' }, { cmd: '/approve', desc: ' — approve current plan' }, { cmd: '/retry', desc: ' — retry failed plan step' }, { cmd: '/cancel', desc: ' — cancel current plan' }, { cmd: '/apply', desc: '[path] [--force] — apply winning forge patch' }, { cmd: '/cp', desc: '[N|last] — copy code block N, or last response, to clipboard' }, { cmd: '/img', desc: ' — attach image to next prompt' }, { cmd: '/flow', desc: ' — log this session' }, { cmd: '/flows', desc: ' — flow analytics dashboard' }, { cmd: '/chats', desc: '[id|resume ] — chat history or resume session' }, { cmd: '/build', desc: ' — agent builds in cwd (reads/edits/tests)' }, { cmd: '/goal', desc: ' --queue --gate "" [--push] — autonomous queue: build→review(all)→judge→fix→commit→push per task (background job)' }, { cmd: '/conquer', desc: ' --gate "" [--builder X] [-e a,b] — supervised-autonomous build: Cesar drives a builder CLI, convenes nero/tribunal/council on forks, stops at a human merge gate (background job)' },{ cmd: '/agent', desc: ' — autonomous agent loop (solo or shadow, auto-routed)' }, { cmd: '/agent-solo', desc: ' — force solo agent mode, no shadow worker' }, { cmd: '/speculate', desc: ' — parallel speculation: N engines race in worktrees, winner applied' }, { cmd: '/team-forge', desc: '[2v2|3v3] test with — team code competition' }, { cmd: '/team-tribunal', desc: '[2v2|3v3] [mode] — team debate' }, { cmd: '/team-brainstorm', desc: '[2v2|3v3] — team ideation' }, { cmd: '/pipeline', desc: ' [test with ] — build→review→fix loop' }, { cmd: '/review', desc: '[with ] [] — code review (uncommitted|branch:NAME|commit:SHA)' }, { cmd: '/provider', desc: 'add|remove|list|key — providers & keys (key set/clear/list)' }, { cmd: '/run', desc: ' — run shell command inline' }, { cmd: '/commit', desc: '[message] — stage & commit with auto-generated message' }, { cmd: '/status', desc: ' — live engine telemetry snapshot' }, { cmd: '/doctor', desc: '[engines|harness] — diagnose engines, worktree, or Cesar harness' }, { cmd: '/harness-replay', desc: '[turnId] — replay Cesar tool timeline + approval ledger' }, { cmd: '/undo', desc: ' — revert last patch or Cesar checkpoint' }, { cmd: '/checkpoints', desc: ' — list recent file checkpoints' }, { cmd: '/jobs', desc: ' — list running/completed jobs' }, { cmd: '/focus', desc: ' — switch to background job output' }, { cmd: '/explore', desc: ' — toggle exploration mode (read-only)' }, { cmd: '/permissions', desc: ' — show allow/deny permission rules (.agon.json)' }, { cmd: '/nogate', desc: ' — toggle the verify-before-done gate nudge for this session' }, { cmd: '/nero', desc: '[] — toggle Nero mode, or challenge a decision (top-rated critic)' }, { cmd: '/btw', desc: ' — ask something while engines work (side-channel)' }, { cmd: '/compact', desc: ' — shrink Cesar context without clearing transcript' }, { cmd: '/mcp', desc: 'connect | disconnect | list — manage session MCP servers' }, { cmd: '/init', desc: ' — create AGON.md config wizard' }, { cmd: '/create-skill', desc: ' — scaffold a new skill (.agon/skills/)' }, { cmd: '/clear', desc: ' — reset session (saves chat, clears brain)' }, { cmd: '/clean', desc: ' — alias for /clear' }, { cmd: '/extensions', desc: ' — list installed extensions' }, { cmd: '/help', desc: ' — show this help' }, { cmd: '/exit', desc: ' — quit' }] }} + const name=SLASH_COMMANDS type="SlashCommand[]" value={{ [{ cmd: '/forge', desc: ' test with [--hardened] — competitive code generation' }, { cmd: '/brainstorm', desc: ' — confidence-bidding answers' }, { cmd: '/tribunal', desc: '[mode] — debate (adversarial|socratic|red-team|steelman|synthesis|postmortem)' }, { cmd: '/campfire', desc: ' — think together, no competition' }, { cmd: '/think', desc: ' [--strategy reflexion] [--steps 8] — sequential thinking, one engine' }, { cmd: '/council', desc: ' — roundtable: every engine a role, top-rated chairs' }, { cmd: '/research', desc: ' [--count N] [--engine X] — keyless web-grounded cited research (npm/GitHub/MDN/IETF/SO/Wikipedia)' }, { cmd: '/chrome', desc: ' — drive your browser (read/navigate/screenshot/click), result feeds Cesar' }, { cmd: '/synthesis', desc: ' [--swaps 2] — engines draft, swap, improve; judge picks winner' }, { cmd: '/workspace', desc: 'add|remove|list|switch — manage project repos' }, { cmd: '/ws', desc: ' — list workspaces (shortcut)' }, { cmd: '/cesar', desc: ' — set Cesar brain engine (e.g. /cesar codex)' }, { cmd: '/models', desc: ' — browse & add provider models + CLI models' }, { cmd: '/tokens', desc: ' — show token usage & costs' }, { cmd: '/raw', desc: ' — reprint last folded engine output (unfolded)' }, { cmd: '/engines', desc: ' — select active engines' }, { cmd: '/leaderboard', desc: ' — Glicko rankings' }, { cmd: '/cesar-report', desc: ' — Cesar routing calibration report' }, { cmd: '/cesar-hints', desc: ' — inspect Cesar routing hints for a prompt' }, { cmd: '/history', desc: '[id] — past forge runs' }, { cmd: '/config', desc: '[list|get|set] — settings' }, { cmd: '/plan', desc: ' or no args — plan mode or show plan' }, { cmd: '/auto', desc: '[on|off|toggle|status] or — autonomous mode control' }, { cmd: '/plans', desc: ' — list recent plans' }, { cmd: '/approve', desc: ' — approve current plan' }, { cmd: '/retry', desc: ' — retry failed plan step' }, { cmd: '/cancel', desc: ' — cancel current plan' }, { cmd: '/apply', desc: '[path] [--force] — apply winning forge patch' }, { cmd: '/cp', desc: '[N|last] — copy code block N, or last response, to clipboard' }, { cmd: '/img', desc: ' — attach image to next prompt' }, { cmd: '/flow', desc: ' — log this session' }, { cmd: '/flows', desc: ' — flow analytics dashboard' }, { cmd: '/chats', desc: '[id|resume ] — chat history or resume session' }, { cmd: '/build', desc: ' — agent builds in cwd (reads/edits/tests)' }, { cmd: '/goal', desc: ' --queue --gate "" [--push] — autonomous queue: build→review(all)→judge→fix→commit→push per task (background job)' }, { cmd: '/conquer', desc: ' --gate "" [--builder X] [-e a,b] [--max-turns N] [--gate-timeout s] [--max-hours h] [--timeout s] — supervised-autonomous build: Cesar drives a builder CLI, convenes nero/tribunal/council on forks, stops at a human merge gate (background job)' },{ cmd: '/agent', desc: ' — autonomous agent loop (solo or shadow, auto-routed)' }, { cmd: '/agent-solo', desc: ' — force solo agent mode, no shadow worker' }, { cmd: '/speculate', desc: ' — parallel speculation: N engines race in worktrees, winner applied' }, { cmd: '/team-forge', desc: '[2v2|3v3] test with — team code competition' }, { cmd: '/team-tribunal', desc: '[2v2|3v3] [mode] — team debate' }, { cmd: '/team-brainstorm', desc: '[2v2|3v3] — team ideation' }, { cmd: '/pipeline', desc: ' [test with ] — build→review→fix loop' }, { cmd: '/review', desc: '[with ] [] — code review (uncommitted|branch:NAME|commit:SHA)' }, { cmd: '/provider', desc: 'add|remove|list|key — providers & keys (key set/clear/list)' }, { cmd: '/run', desc: ' — run shell command inline' }, { cmd: '/commit', desc: '[message] — stage & commit with auto-generated message' }, { cmd: '/status', desc: ' — live engine telemetry snapshot' }, { cmd: '/doctor', desc: '[engines|harness] — diagnose engines, worktree, or Cesar harness' }, { cmd: '/harness-replay', desc: '[turnId] — replay Cesar tool timeline + approval ledger' }, { cmd: '/undo', desc: ' — revert last patch or Cesar checkpoint' }, { cmd: '/checkpoints', desc: ' — list recent file checkpoints' }, { cmd: '/jobs', desc: ' — list running/completed jobs' }, { cmd: '/focus', desc: ' — switch to background job output' }, { cmd: '/explore', desc: ' — toggle exploration mode (read-only)' }, { cmd: '/permissions', desc: ' — show allow/deny permission rules (.agon.json)' }, { cmd: '/nogate', desc: ' — toggle the verify-before-done gate nudge for this session' }, { cmd: '/nero', desc: '[] — toggle Nero mode, or challenge a decision (top-rated critic)' }, { cmd: '/btw', desc: ' — ask something while engines work (side-channel)' }, { cmd: '/compact', desc: ' — shrink Cesar context without clearing transcript' }, { cmd: '/mcp', desc: 'connect | disconnect | list — manage session MCP servers' }, { cmd: '/init', desc: ' — create AGON.md config wizard' }, { cmd: '/create-skill', desc: ' — scaffold a new skill (.agon/skills/)' }, { cmd: '/clear', desc: ' — reset session (saves chat, clears brain)' }, { cmd: '/clean', desc: ' — alias for /clear' }, { cmd: '/extensions', desc: ' — list installed extensions' }, { cmd: '/help', desc: ' — show this help' }, { cmd: '/exit', desc: ' — quit' }] }} const name=FITNESS_PATTERN type=RegExp value={{ /\b(?:test with|test:|--test|fitness:)\s+(.+)/i }} @@ -435,10 +438,14 @@ module name=IntentParsing } case 'conquer': { // /conquer --gate "" [--builder X] [-e a,b] [--max-turns N] + // [--gate-timeout S] [--max-hours H] [--timeout S] (CLI parity) let cqGate: string | undefined; let cqBuilder: string | undefined; let cqEngineIds: string[] | undefined; let cqMaxTurns: number | undefined; + let cqGateTimeout: number | undefined; + let cqMaxHours: number | undefined; + let cqTurnTimeout: number | undefined; let cqRest = rest; const gateM = cqRest.match(/--gate\s+(?:"([^"]*)"|'([^']*)'|(\S+(?:\s+(?!--|-[A-Za-z])\S+)*))/); if (gateM) { cqGate = (gateM[1] ?? gateM[2] ?? gateM[3] ?? '').trim(); cqRest = cqRest.replace(gateM[0], ' '); } @@ -448,7 +455,13 @@ module name=IntentParsing if (enginesM) { cqEngineIds = enginesM[1].split(',').map((x) => x.trim()).filter(Boolean); cqRest = cqRest.replace(enginesM[0], ' '); } const turnsM = cqRest.match(/--max-turns\s+(\d+)/); if (turnsM) { cqMaxTurns = parseInt(turnsM[1], 10); cqRest = cqRest.replace(turnsM[0], ' '); } - return { type: 'conquer', task: cqRest.replace(/\s+/g, ' ').trim(), gate: cqGate, builder: cqBuilder, engineIds: cqEngineIds, maxTurns: cqMaxTurns } as Intent; + const gateToM = cqRest.match(/--gate-timeout\s+(\d+)/); + if (gateToM) { cqGateTimeout = parseInt(gateToM[1], 10); cqRest = cqRest.replace(gateToM[0], ' '); } + const hoursM = cqRest.match(/--max-hours\s+(\d+(?:\.\d+)?)/); + if (hoursM) { cqMaxHours = parseFloat(hoursM[1]); cqRest = cqRest.replace(hoursM[0], ' '); } + const turnToM = cqRest.match(/--timeout\s+(\d+)/); + if (turnToM) { cqTurnTimeout = parseInt(turnToM[1], 10); cqRest = cqRest.replace(turnToM[0], ' '); } + return { type: 'conquer', task: cqRest.replace(/\s+/g, ' ').trim(), gate: cqGate, builder: cqBuilder, engineIds: cqEngineIds, maxTurns: cqMaxTurns, gateTimeout: cqGateTimeout, maxHours: cqMaxHours, turnTimeout: cqTurnTimeout } as Intent; } case 'synthesis': case 'synth': { diff --git a/packages/cli/src/telemetry/index.ts b/packages/cli/src/telemetry/index.ts index 6d5c9aa5c..53fa67c42 100644 --- a/packages/cli/src/telemetry/index.ts +++ b/packages/cli/src/telemetry/index.ts @@ -3,11 +3,12 @@ // mode, task type, pass/fail, and timing. Rolling win rates are computed // over the last N runs per mode. -import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'; +import { readFileSync, writeFileSync, mkdirSync, statSync } from 'node:fs'; import { join, resolve } from 'node:path'; import { homedir } from 'node:os'; -import { tracker } from '@kernlang/agon-core'; +import { tracker, resolveWorkingDir } from '@kernlang/agon-core'; import { runsStore } from '../generated/signals/runs-store.js'; +import { summarizeIntentForEpisode } from '../generated/cesar/experience.js'; // ── Types ────────────────────────────────────────────────────────────── @@ -46,8 +47,15 @@ export interface RunRecord { engineIds: string[]; completionState: string; costEstimateUsd?: number; + intentSummary?: string; + projectKey?: string; } +/** Ledger size cap: append() trims to the newest MAX_LEDGER_RUNS so + * telemetry.json cannot grow unbounded (win-rate windows and experience + * retrieval both look at far fewer records than this). */ +export const MAX_LEDGER_RUNS = 2000; + export interface TelemetryFile { version: 1; runs: RunRecord[]; @@ -115,15 +123,31 @@ export class TelemetryLedger { this.filePath = filePath ?? telemetryPath(); } - /** Read the entire ledger file, returning an empty structure if missing/malformed. */ + private cached: TelemetryFile | null = null; + private cachedStamp = ''; + + /** Read the entire ledger file, returning an empty structure if missing/malformed. + * Cached on (mtimeMs, size): experience retrieval reads the ledger on hot + * interactive turns, and re-parsing an unchanged file every turn is pure + * waste. Any writer (this process via write(), or another agon process) + * changes the stamp and invalidates the cache. */ read(): TelemetryFile { try { + const stat = statSync(this.filePath); + const stamp = `${stat.mtimeMs}:${stat.size}`; + if (this.cached && stamp === this.cachedStamp) return this.cached; const raw = readFileSync(this.filePath, 'utf-8'); const parsed = JSON.parse(raw) as TelemetryFile; - if (parsed && Array.isArray(parsed.runs)) return parsed; + if (parsed && Array.isArray(parsed.runs)) { + this.cached = parsed; + this.cachedStamp = stamp; + return parsed; + } } catch { // file missing or malformed — return empty } + this.cached = null; + this.cachedStamp = ''; return { version: 1, runs: [] }; } @@ -134,10 +158,11 @@ export class TelemetryLedger { writeFileSync(this.filePath, JSON.stringify(data, null, 2)); } - /** Append a single run record. */ + /** Append a single run record, trimming the ledger to MAX_LEDGER_RUNS. */ append(record: RunRecord): void { const data = this.read(); - this.write({ ...data, runs: [...data.runs, record] }); + const runs = [...data.runs, record]; + this.write({ ...data, runs: runs.length > MAX_LEDGER_RUNS ? runs.slice(-MAX_LEDGER_RUNS) : runs }); } /** Return the last N runs, optionally filtered by mode. */ @@ -190,6 +215,23 @@ const defaultLedger = new TelemetryLedger(); * Record an orchestration run. Uses a process-wide TelemetryLedger singleton * writing to ~/.agon/telemetry.json. */ +/** The project scope a run record belongs to: the resolved working dir. + * Experience retrieval filters on this — telemetry.json is global, and + * precedent from another repo must never leak into this repo's turns. */ +export function currentProjectKey(): string { + try { + return resolve(resolveWorkingDir()); + } catch { + return resolve(process.cwd()); + } +} + +/** Read recent run records (newest last) from the process-wide ledger — the + * experience-precedent retrieval feed. */ +export function recentRunRecords(limit: number = 200): RunRecord[] { + return defaultLedger.recentRuns(limit); +} + export function recordRun(result: OrchestrationResult): RunRecord { const stats = typeof tracker?.getStats === 'function' ? tracker.getStats() : null; const record: RunRecord = { @@ -203,6 +245,8 @@ export function recordRun(result: OrchestrationResult): RunRecord { engineIds: result.engineIds ?? [], completionState: result.completionState, costEstimateUsd: stats?.totalCostUsd ?? undefined, + intentSummary: summarizeIntentForEpisode(result.intent ?? '') || undefined, + projectKey: currentProjectKey(), }; defaultLedger.append(record); // A run record was written — forge also writes a ${forgeId}.json into diff --git a/packages/core/src/generated/models/types.ts b/packages/core/src/generated/models/types.ts index 0ae541d6e..052486e41 100644 --- a/packages/core/src/generated/models/types.ts +++ b/packages/core/src/generated/models/types.ts @@ -1,19 +1,5 @@ // @generated by kern v4.5.0 — DO NOT EDIT. Source: src/kern/models/types.kern -// @kern-source: types:503 -// @kern-source: types:504 -// @kern-source: types:505 -// @kern-source: types:506 -// @kern-source: types:507 -// @kern-source: types:508 -// @kern-source: types:509 -// @kern-source: types:510 -// @kern-source: types:511 -// @kern-source: types:512 -// @kern-source: types:513 -// @kern-source: types:514 -// @kern-source: types:515 -// @kern-source: types:516 // @kern-source: types:517 // @kern-source: types:518 // @kern-source: types:519 @@ -37,6 +23,20 @@ // @kern-source: types:537 // @kern-source: types:538 // @kern-source: types:539 +// @kern-source: types:540 +// @kern-source: types:541 +// @kern-source: types:542 +// @kern-source: types:543 +// @kern-source: types:544 +// @kern-source: types:545 +// @kern-source: types:546 +// @kern-source: types:547 +// @kern-source: types:548 +// @kern-source: types:549 +// @kern-source: types:550 +// @kern-source: types:551 +// @kern-source: types:552 +// @kern-source: types:553 // @kern-source: types:1 export type EngineMode = 'exec' | 'review' | 'agent'; @@ -347,6 +347,13 @@ export interface AgonConfig { cesarSelfTurnAutoApproveMaxDiffTokens?: number; cesarApprovalLedger?: boolean; cesarToolTimeline?: boolean; + protectedPushBranches: string[]; + cesarExperience?: boolean; + cesarExperienceMinSimilarity: number; + cesarExperienceMinEpisodes: number; + cesarExperienceTopK: number; + cesarExperienceWindow: number; + cesarDelegationReflex?: boolean; cesarStreamingXmlTools?: boolean; campfireObserverStrategy?: 'lead-first'|'all-respond'; hooks: Record>; @@ -454,6 +461,13 @@ export const DEFAULT_AGON_CONFIG: Required = { cesarSelfTurnAutoApproveMaxDiffTokens: 1200, cesarApprovalLedger: true, cesarToolTimeline: true, + protectedPushBranches: [], + cesarExperience: true, + cesarExperienceMinSimilarity: 0, + cesarExperienceMinEpisodes: 0, + cesarExperienceTopK: 0, + cesarExperienceWindow: 0, + cesarDelegationReflex: true, cesarStreamingXmlTools: true, campfireObserverStrategy: 'lead-first', hooks: {} as any, @@ -484,7 +498,7 @@ export const DEFAULT_AGON_CONFIG: Required = { browserExtensionIds: [], }; -// @kern-source: types:369 +// @kern-source: types:383 export interface ScoutBid { engineId: string; confidence: number; @@ -495,7 +509,7 @@ export interface ScoutBid { needsCompetition: boolean; } -// @kern-source: types:378 +// @kern-source: types:392 export interface RoutingDecision { action: 'chat'|'build'|'pipeline'|'campfire'|'forge'|'brainstorm'|'tribunal'|'agent'|'team-agent'; leadEngine: string; @@ -507,14 +521,14 @@ export interface RoutingDecision { bids: ScoutBid[]; } -// @kern-source: types:388 +// @kern-source: types:402 export interface CampfireMessage { engineId: string; content: string; isLead: boolean; } -// @kern-source: types:393 +// @kern-source: types:407 export interface ForgeOptions { task: string; fitnessCmd: string; @@ -540,7 +554,7 @@ export interface ForgeOptions { synthEngine?: string; } -// @kern-source: types:422 +// @kern-source: types:436 export interface EngineResult { engineId: string; pass: boolean; @@ -562,14 +576,14 @@ export interface EngineResult { syntaxInvalidFiles?: string[]; } -// @kern-source: types:442 +// @kern-source: types:456 export interface SkippedEngine { engineId: string; status: string; reason?: string; } -// @kern-source: types:447 +// @kern-source: types:461 export interface DispatchMetric { engineId: string; phase: 'stage1'|'stage1-fallback'|'stage2-scout'|'stage2-scout-fallback'|'stage2-follower'|'stage2-fallback'|'synthesis'|'gauntlet'; @@ -583,7 +597,7 @@ export interface DispatchMetric { tokens?: {prompt:number, response:number, costUsd:number}; } -// @kern-source: types:459 +// @kern-source: types:473 export interface ForgeManifest { forgeId: string; forgeDir: string; @@ -615,7 +629,7 @@ export interface ForgeManifest { gauntlet?: GauntletResult; } -// @kern-source: types:489 +// @kern-source: types:503 export interface ConvergenceEntry { file: string; fn: string; @@ -623,7 +637,7 @@ export interface ConvergenceEntry { reason: string; } -// @kern-source: types:495 +// @kern-source: types:509 export interface ForgeJudgment { winner: string; strengths: { engineId: string; category: string; reason: string }[]; @@ -634,7 +648,7 @@ export interface ForgeJudgment { export type ForgeEventType = 'baseline:start' | 'baseline:done' | 'stage1:start' | 'stage1:dispatch' | 'stage1:score' | 'stage1:accepted' | 'stage2:start' | 'stage2:dispatch' | 'stage2:score' | 'stage2:done' | 'engine:failed' | 'engine:worktree' | 'winner:determined' | 'forge:no-candidate-diff' | 'synthesis:start' | 'synthesis:critique' | 'synthesis:refine' | 'synthesis:score' | 'synthesis:done' | 'elo:update' | 'gauntlet:start' | 'gauntlet:breaker-dispatch' | 'gauntlet:breaker-done' | 'gauntlet:attack-landed' | 'gauntlet:repair-start' | 'gauntlet:repair-done' | 'gauntlet:corpus-save' | 'gauntlet:done' | 'forge:done' | 'forge:engine-skipped' | 'forge:already-satisfied' | 'forge:single-survivor' | 'forge:no-engines-available' | 'forge:fatal' | 'forge:auto-finalize' | 'forge:health-check-start' | 'forge:health-check-done'; -// @kern-source: types:502 +// @kern-source: types:516 export interface ForgeEvent { type: ForgeEventType; engineId?: string; @@ -683,7 +697,7 @@ export interface ForgeEventMap { export type ForgeEventCallback = (event: ForgeEvent) => void; -// @kern-source: types:541 +// @kern-source: types:555 export interface BrainstormBid { engineId: string; confidence: number; @@ -692,26 +706,26 @@ export interface BrainstormBid { score?: number; } -// @kern-source: types:548 +// @kern-source: types:562 export interface BrainstormGroup { members: string[]; representative: string; similarity: number; } -// @kern-source: types:553 +// @kern-source: types:567 export interface BrainstormDedupStatus { status: 'not-needed' | 'applied' | 'unavailable' | 'failed' | 'timed-out'; detail?: string; } -// @kern-source: types:557 +// @kern-source: types:571 export interface BrainstormSynthesisStatus { status: 'completed' | 'fallback'; detail?: string; } -// @kern-source: types:564 +// @kern-source: types:578 export interface PanelHealth { requested: number; responded: number; @@ -720,7 +734,7 @@ export interface PanelHealth { banner: string | null; } -// @kern-source: types:571 +// @kern-source: types:585 export interface BrainstormResult { question: string; bids: BrainstormBid[]; @@ -732,7 +746,7 @@ export interface BrainstormResult { panelHealth?: PanelHealth; } -// @kern-source: types:581 +// @kern-source: types:595 export interface BreakerArtifact { engineId: string; testScript: string; @@ -742,7 +756,7 @@ export interface BreakerArtifact { validated: boolean; } -// @kern-source: types:589 +// @kern-source: types:603 export interface GauntletResult { winnerId: string; breakerArtifacts: BreakerArtifact[]; @@ -755,7 +769,7 @@ export interface GauntletResult { patchPath?: string; } -// @kern-source: types:600 +// @kern-source: types:614 export interface CorpusEntry { forgeId: string; taskClass: TaskClass; @@ -765,7 +779,7 @@ export interface CorpusEntry { pattern?: string; } -// @kern-source: types:608 +// @kern-source: types:622 export interface GapPattern { pattern: string; taskClass: TaskClass; @@ -776,7 +790,7 @@ export interface GapPattern { skillPath?: string; } -// @kern-source: types:617 +// @kern-source: types:631 export interface Critique { file: string; lines: string; @@ -784,5 +798,5 @@ export interface Critique { minimalFix: string; } -// @kern-source: types:623 +// @kern-source: types:637 export const DEFAULT_CONFIG: Required = DEFAULT_AGON_CONFIG; diff --git a/packages/core/src/kern/models/types.kern b/packages/core/src/kern/models/types.kern index 174676d73..9005ae286 100644 --- a/packages/core/src/kern/models/types.kern +++ b/packages/core/src/kern/models/types.kern @@ -315,6 +315,20 @@ config name=AgonConfig doc "When true, write compact JSONL permission-decision records for Cesar tool approvals, prompts, and blocks." field name=cesarToolTimeline type=boolean default=true doc "When true, write compact JSONL per-turn tool timeline records for Cesar tool calls, results, and summaries." + field name=protectedPushBranches type="string[]" optional=true + doc "Branches unattended pushes (conquer --push) refuse to land on. Default when absent OR empty: main, master (an empty list never disables the guard). Case-insensitive exact match. KERN-GAP: codegen — optional array fields are emitted as [] into DEFAULT_AGON_CONFIG and as required (non-?) TS fields, so consumers must treat [] as 'unset'." + field name=cesarExperience type=boolean default=true + doc "When true, similar past run episodes (mode/winner/outcome) are injected into Cesar turns as advisory precedent — evidence, not authority. Lexical similarity, min-N + similarity gated, fail-open." + field name=cesarExperienceMinSimilarity type=number optional=true + doc "Similarity gate (0..1 Jaccard) an episode must clear to count as precedent. Default 0.2. Non-positive = unset (KERN codegen emits optional numbers as 0)." + field name=cesarExperienceMinEpisodes type=number optional=true + doc "Minimum qualifying episodes before ANY precedent is shown (the min-N evidence rule). Default 3. Non-positive = unset." + field name=cesarExperienceTopK type=number optional=true + doc "Maximum precedent lines injected per turn. Default 3. Non-positive = unset." + field name=cesarExperienceWindow type=number optional=true + doc "How many recent run records the precedent retrieval scans. Default 200. Non-positive = unset." + field name=cesarDelegationReflex type=boolean default=true + doc "When true, list-shaped prompts with no sequential/shared-state markers get an in-prompt structural advisory telling Cesar the task may decompose — Cesar may then offer [SUGGEST:agent-team]; the user always confirms before anything spawns. Advisory-only; nothing auto-spawns." field name=cesarStreamingXmlTools type=boolean default=true doc "When true, interrupt text streaming as soon as a complete XML tool call is parsed so XML fallback tools can execute live instead of waiting for the whole response." field name=campfireObserverStrategy type="'lead-first'|'all-respond'" default=lead-first diff --git a/packages/forge/src/generated/conquer.ts b/packages/forge/src/generated/conquer.ts index 23b68ca05..38fae5118 100644 --- a/packages/forge/src/generated/conquer.ts +++ b/packages/forge/src/generated/conquer.ts @@ -101,13 +101,33 @@ export function summarizeConsultForBuilder(consult: { mode: string; verdict: str return `[Cesar consulted ${consult.mode}]${conf} ${body}`; } -// @kern-source: conquer:99 +/** + * Branches an unattended conquer --push must never land on. Overridable via config protectedPushBranches. + */ +// @kern-source: conquer:97 +export const DEFAULT_PROTECTED_PUSH_BRANCHES: string[] = ['main', 'master']; + +/** + * Guard for the unattended --push path (agy review follow-up): a conquer run normally pushes its own isolated conquer/ branch, but if the worktree HEAD ever resolves to a protected branch (builder switched branches, odd isolation state) the push is refused rather than landing an unreviewed build on main. protectedPushBranches (string[]) in config overrides the default ['main','master'] ONLY when non-empty — an empty/absent list means 'use the defaults', never 'protect nothing' (KERN codegen emits optional array fields as [] into DEFAULT_AGON_CONFIG, so an empty-array-disables rule would silently kill the guard for every real loadConfig() caller — 5/6-engine review consensus). Matching is exact after trim, case-insensitive; an empty/unresolvable branch name is protected (fail-closed). Pure — exported for testing. + */ +// @kern-source: conquer:100 +export function isProtectedPushBranch(branch: string, config?: any): boolean { + const name = String(branch ?? '').trim().toLowerCase(); + if (!name) return true; + const raw = Array.isArray(config?.protectedPushBranches) + ? config.protectedPushBranches.map((b: unknown) => String(b).trim().toLowerCase()).filter(Boolean) + : []; + const configured = raw.length > 0 ? raw : DEFAULT_PROTECTED_PUSH_BRANCHES; + return configured.includes(name); +} + +// @kern-source: conquer:114 export interface ConquerCaps { maxTurns: number; maxWallClockMs: number; } -// @kern-source: conquer:103 +// @kern-source: conquer:118 export interface ConquerState { turn: number; spentUsd: number; @@ -115,7 +135,7 @@ export interface ConquerState { consults: number; } -// @kern-source: conquer:109 +// @kern-source: conquer:124 export interface ConquerTurn { n: number; engineId: string; @@ -123,7 +143,7 @@ export interface ConquerTurn { detail: string; } -// @kern-source: conquer:115 +// @kern-source: conquer:130 export interface ConquerOptions { task: string; builderEngine: string; @@ -140,7 +160,7 @@ export interface ConquerOptions { signal?: AbortSignal | undefined; } -// @kern-source: conquer:130 +// @kern-source: conquer:145 export interface ConquerResult { ok: boolean; task: string; @@ -157,7 +177,7 @@ export interface ConquerResult { observed: string; } -// @kern-source: conquer:145 +// @kern-source: conquer:160 export interface ConquerIsolation { repoRoot: string; branch: string; @@ -167,7 +187,7 @@ export interface ConquerIsolation { /** * Create a persistent named worktree for one Conquer run. It branches from HEAD, so uncommitted source-checkout work remains untouched and is intentionally not copied into the build. The worktree is kept after success/failure for the human merge gate and crash recovery. */ -// @kern-source: conquer:150 +// @kern-source: conquer:165 export function createConquerIsolation(task: string, cwd?: string, runId?: string): ConquerIsolation { const sourceCwd = cwd ?? resolveWorkingDir(); const root = repoRoot(sourceCwd); @@ -178,7 +198,7 @@ export function createConquerIsolation(task: string, cwd?: string, runId?: strin return { repoRoot: root, branch, path: worktree.path }; } -// @kern-source: conquer:162 +// @kern-source: conquer:177 export interface DoneOracleInput { gateOk: boolean; oracleTampered: boolean; @@ -190,7 +210,7 @@ export interface DoneOracleInput { /** * Pure cap check: the run stops when it has used maxTurns or exceeded maxWallClockMs (0 = no wall-clock cap). Budget-USD is intentionally NOT enforced here — DispatchResult carries token usage, not price, so spend is best-effort until a cost-bearing result exists. Pure — exported for testing. */ -// @kern-source: conquer:169 +// @kern-source: conquer:184 export function capBreached(state: ConquerState, caps: ConquerCaps, nowMs: number): '' | 'cap-turns' | 'cap-time' { if (state.turn >= caps.maxTurns) return 'cap-turns'; if (caps.maxWallClockMs > 0 && (nowMs - state.startedAtMs) >= caps.maxWallClockMs) return 'cap-time'; @@ -200,7 +220,7 @@ export function capBreached(state: ConquerState, caps: ConquerCaps, nowMs: numbe /** * Parse a builder turn for the conquer sentinels: a line starting CONQUER_DONE claims completion (the rest of that line is the disprovable claim for the done-oracle); a line `CONQUER_ASK: ` is a fork to resolve. Pure — exported for testing. */ -// @kern-source: conquer:177 +// @kern-source: conquer:192 export function parseBuilderSignals(turnText: string): { claimedDone: boolean; ask: string | null; claim: string } { const lines = turnText.split('\n'); let claimedDone = false; @@ -223,7 +243,7 @@ export function parseBuilderSignals(turnText: string): { claimedDone: boolean; a /** * Heuristically classify a builder ASK into a fork kind so pickEscalationMode can route it: high-stakes cues (irreversible/schema/security/architecture) -> high-stakes; >=2 enumerated options -> choice; open 'how/where do I' phrasing -> ideation; else approach-doubt. Pure — exported for testing. */ -// @kern-source: conquer:198 +// @kern-source: conquer:213 export function classifyAsk(ask: string): { kind: 'approach-doubt' | 'choice' | 'ideation' | 'high-stakes'; optionCount: number } { const lower = ask.toLowerCase(); const optMatches = ask.match(/(?:^|\s)(?:\d+[.)]|[a-d][.)]|\bor\b)/gi); @@ -237,7 +257,7 @@ export function classifyAsk(ask: string): { kind: 'approach-doubt' | 'choice' | /** * The protocol Cesar gives the builder: work autonomously in this repo toward the task; when you hit a genuine fork you cannot resolve, emit a single line `CONQUER_ASK: ` and STOP for guidance; when finished and the project's tests pass, emit `CONQUER_DONE: `. Pure — exported for testing. */ -// @kern-source: conquer:210 +// @kern-source: conquer:225 export function buildConquerSystemPrompt(): string { return [ 'You are an autonomous build agent supervised by Cesar (you do NOT talk to a human directly).', @@ -253,7 +273,7 @@ export function buildConquerSystemPrompt(): string { /** * Build a bounded supervisor envelope for every builder turn. The original task is repeated verbatim on every dispatch, while only recent state and a capped previous output are carried forward so a long run neither loses its objective nor grows prompt context without bound. */ -// @kern-source: conquer:224 +// @kern-source: conquer:239 export function buildConquerTurnPrompt(opts: {task:string, turn:number, kernSpine?:string, previousOutput?:string, instruction:string, transcript?:string[]}): string { const recentState = (opts.transcript ?? []).slice(-6).join('\n') || '(first turn)'; const previous = String(opts.previousOutput ?? ''); @@ -276,7 +296,7 @@ export function buildConquerTurnPrompt(opts: {task:string, turn:number, kernSpin /** * Convene the chosen agon mode on a builder fork/stuck (or as the done-oracle's nero falsification round) and normalize its output to { verdict text, confidence, flawed }. Reuses runNero/runTribunal/runBrainstorm/runCouncil. `flawed` is true only for a nero verdict of 'flawed' (a found counterexample). Defensive field access survives result-shape drift. */ -// @kern-source: conquer:245 +// @kern-source: conquer:260 export async function dispatchConsult(opts: { mode: 'nero' | 'tribunal' | 'brainstorm' | 'council'; question: string; engines: string[]; registry: EngineRegistry; adapter: EngineAdapter; timeout: number; outputDir: string; cwd?: string; signal?: AbortSignal }): Promise<{ mode: string; verdict: string; confidence: number | null; flawed: boolean }> { const base: any = { engines: opts.engines, registry: opts.registry, adapter: opts.adapter, timeout: opts.timeout, outputDir: opts.outputDir, cwd: opts.cwd, signal: opts.signal }; let r: any; @@ -303,7 +323,7 @@ export async function dispatchConsult(opts: { mode: 'nero' | 'tribunal' | 'brain /** * Mechanical, layered done decision (the design's L0/L1/L2/L5). A tampered frozen oracle, weakened existing tests, a red gate, an empty claim, or a reproducible nero counterexample each BLOCK; otherwise the work passes the AUTOMATED bar and proceeds to the human merge gate (L6). Pure — exported for testing. Gate execution + diff classification + nero are done outside and passed in. */ -// @kern-source: conquer:270 +// @kern-source: conquer:285 export function doneOracleDecision(i: DoneOracleInput): { passed: boolean; reason: string } { if (i.oracleTampered) return { passed: false, reason: 'L0: the frozen baseline oracle was modified' }; if (i.weakenedTests) return { passed: false, reason: 'L1: existing tests were weakened or rewritten (acceptance drift)' }; @@ -313,14 +333,14 @@ export function doneOracleDecision(i: DoneOracleInput): { passed: boolean; reaso return { passed: true, reason: 'L0–L5 clean — ready for the human merge gate' }; } -// @kern-source: conquer:287 +// @kern-source: conquer:302 export interface SandboxOps { clone: (src: string, dest: string, signal?: AbortSignal) => Promise; exec: (cmd: string, cwd: string, timeoutMs: number, signal?: AbortSignal) => Promise<{ exitCode: number; stdout: string; stderr: string; timedOut: boolean }>; remove: (dir: string) => Promise; } -// @kern-source: conquer:295 +// @kern-source: conquer:310 export interface FalsifierResult { falsified: boolean; counterexample: string | null; @@ -334,7 +354,7 @@ export interface FalsifierResult { /** * True when an engine can act as a TOOL-ENABLED agent critic in the done-falsifier — i.e. it has a real CLI binary that supports agent mode (codex/claude/agy/aider). API-only coding-plan engines (kimi/minimax/zai) have no binary and no agent-mode config, so they cannot reliably read code + run commands in the sandbox; the falsifier skips them and falls back to advisory. Pure — exported for testing. (FOLLOW-UP: an explicit `agentCapable` flag on EngineDefinition would beat this structural inference.) */ -// @kern-source: conquer:304 +// @kern-source: conquer:319 export function isAgentCapableEngine(engine: { binary?: string; agent?: unknown; modes?: readonly string[] }): boolean { if (!engine || !engine.binary) return false; if (engine.agent) return true; @@ -344,7 +364,7 @@ export function isAgentCapableEngine(engine: { binary?: string; agent?: unknown; /** * Build the tool-enabled falsifier prompt. Unlike the tool-LESS nero prompt, it grants full sandbox tool access and DEMANDS a self-asserting, re-runnable counterexample (exit non-zero iff the bug is present) so the verdict can be mechanically verified rather than trusted. Pure — exported for testing. */ -// @kern-source: conquer:312 +// @kern-source: conquer:327 export function buildFalsifierPrompt(opts: { claim:string; gate?:string }): string { const lines: string[] = []; lines.push('You are a falsification critic with FULL tool access to this repository — a disposable sandbox copy. You may read any file and run any shell command.'); @@ -371,7 +391,7 @@ export function buildFalsifierPrompt(opts: { claim:string; gate?:string }): stri /** * Parse a done-falsifier reply: the LAST COUNTEREXAMPLE: line (the runnable command), the LAST OBSERVED: line, and the verdict (via parseNeroVerdict — last VERDICT: marker wins). Tolerant of casing + surrounding whitespace. Pure — exported for testing. */ -// @kern-source: conquer:337 +// @kern-source: conquer:352 export function parseFalsifierOutput(text: string): { counterexample: string | null; observed: string | null; verdict: 'flawed' | 'proceed-with-caution' | 'sound' | 'unknown' } { const ceMatches = [...text.matchAll(/^[ \t]*COUNTEREXAMPLE:[ \t]*(.+?)[ \t]*$/gim)]; const obMatches = [...text.matchAll(/^[ \t]*OBSERVED:[ \t]*(.+?)[ \t]*$/gim)]; @@ -383,7 +403,7 @@ export function parseFalsifierOutput(text: string): { counterexample: string | n /** * Defence-in-depth gate on the LLM-proposed counterexample before AUTO-EXECUTING it (codex/minimax review, blocking). Even inside the CoW sandbox, `sh -c` would otherwise let a confused/hallucinating critic escape to the wider filesystem or network. Rejects the catastrophic patterns a legitimate self-asserting behavioural check NEVER needs — privilege escalation, recursive force-deletes, fork bombs, device/disk clobber, host control, network/exfil tools, recursive perm changes, remote git mutation, and write-redirects into absolute system dirs. A rejected counterexample is NOT executed → treated as advisory (surfaced to the human), erring safe. Pairs with HOME/TMPDIR confinement + the CoW sandbox + the gate-green precondition. Returns true = safe to run. Pure — exported for testing. (FOLLOW-UP: real OS/container confinement — sandbox-exec/bwrap with denied network — for untrusted tasks; this denylist + env-confinement is the v1 floor.) */ -// @kern-source: conquer:347 +// @kern-source: conquer:362 export function isSafeCounterexample(cmd: string): boolean { const c = cmd.toLowerCase(); const danger: RegExp[] = [ @@ -403,7 +423,7 @@ export function isSafeCounterexample(cmd: string): boolean { /** * The L4/L5 done-oracle: an EVIDENCE-BASED, mechanically-verified falsifier. (1) Pick the top-rated AGENT-CAPABLE critic from the advisor pool via the same critique->tribunal->global cascade nero uses, skipping API-only engines that can't read code/run commands (advisory fallback if none). (2) Clone the live working tree into a throwaway CoW sandbox. (3) Dispatch the critic in AGENT mode against the sandbox to find a runnable counterexample. (4) MECHANICALLY re-run the proposed COUNTEREXAMPLE in the sandbox — `falsified` is true ONLY when the verdict is FLAWED with a counterexample, the command passes the isSafeCounterexample auto-exec gate, AND the re-run reproduces a failure (non-zero exit). An opinion with no reproducible command NEVER blocks. The full attack text is returned as advisory for the human merge gate. SECURITY: auto-exec runs ONLY inside the disposable sandbox with HOME/TMPDIR confined to it, never the live tree; the command is denylist-screened; time-bounded + abort-aware; ANY unexpected error fails SAFE to advisory (never blocks, never crashes the conquer loop). (FOLLOW-UP: real OS/container confinement with denied network for untrusted tasks; non-APFS sandbox strategy.) Sandbox + exec injected (SandboxOps) for unit-testing. */ -// @kern-source: conquer:365 +// @kern-source: conquer:380 export async function runDoneFalsifier(opts: { claim:string; gate?:string; cwd:string; engines:string[]; registry:EngineRegistry; adapter:EngineAdapter; timeout:number; outputDir:string; ratings?:RatingRecord; signal?:AbortSignal; sandbox?:SandboxOps }): Promise { const advisory = (over: Partial): FalsifierResult => ({ falsified: false, counterexample: null, observed: null, attackText: '', critic: '', advisoryOnly: true, note: '', ...over }); if (opts.signal?.aborted) return advisory({ note: 'aborted before falsification' }); @@ -518,7 +538,7 @@ export async function runDoneFalsifier(opts: { claim:string; gate?:string; cwd:s /** * Run the conquer done-oracle for a completion claim. CHEAP DETERMINISTIC LAYERS FIRST (L0/L1/L2): a tampered oracle, weakened existing tests, a red gate, or an empty claim each block immediately — WITHOUT cloning the repo or dispatching an agent (so a premature CONQUER_DONE never burns a full falsifier run). Only when those pass does the L4/L5 EVIDENCE-BASED falsifier run (a tool-enabled agent critic tries to reproduce a runnable counterexample in a throwaway sandbox; blocks ONLY on a mechanically-verified failure — never on a bare opinion). gateOk + oracleTampered are computed by the caller in the worktree. Returns `falsified` (was the block a verified reproduction?) plus the falsifier's attack text + counterexample for the human merge gate, whether or not they blocked. */ -// @kern-source: conquer:478 +// @kern-source: conquer:493 export async function runDoneOracle(opts: { claim: string; diff: string; gate?: string; gateOk: boolean; oracleTampered: boolean; engines: string[]; registry: EngineRegistry; adapter: EngineAdapter; timeout: number; outputDir: string; cwd: string; ratings?: RatingRecord; signal?: AbortSignal; sandbox?: SandboxOps }): Promise<{ passed: boolean; reason: string; falsified: boolean; attackText: string; counterexample: string | null; observed: string | null; critic: string; advisoryOnly: boolean }> { const changed = parseChangedLines(opts.diff); const newSet = new Set(newFilesInDiff(opts.diff)); @@ -547,7 +567,7 @@ export async function runDoneOracle(opts: { claim: string; diff: string; gate?: /** * The supervised-autonomous build loop. Drives the builder engine in agent mode turn-by-turn; on a CONQUER_ASK fork, classifies it and convenes the cheapest sufficient consult (nero→tribunal→brainstorm→council), feeding back a compact verdict; on a CONQUER_DONE claim, runs the done-oracle via the injected evaluateDone seam (the caller computes diff/gate/oracle in the worktree). Stops on done, a turn/wall-clock cap, abort, or builder failure. Auto-approve + worktree isolation + the human merge gate live in the CLI surface. Composes the injected adapter + the agon mode runners; no environment ops here (kept testable). */ -// @kern-source: conquer:505 +// @kern-source: conquer:520 export async function runConquer(opts: ConquerOptions): Promise { const cwd = opts.cwd ?? resolveWorkingDir(); const caps = opts.caps ?? { maxTurns: 40, maxWallClockMs: 0 }; diff --git a/packages/forge/src/index.ts b/packages/forge/src/index.ts index 63a93644c..004a7e01c 100644 --- a/packages/forge/src/index.ts +++ b/packages/forge/src/index.ts @@ -71,6 +71,7 @@ export { capBreached, parseBuilderSignals, classifyAsk, buildConquerSystemPrompt, buildConquerTurnPrompt, createConquerIsolation, dispatchConsult, doneOracleDecision, runDoneOracle, runConquer, isAgentCapableEngine, buildFalsifierPrompt, parseFalsifierOutput, isSafeCounterexample, runDoneFalsifier, + isProtectedPushBranch, DEFAULT_PROTECTED_PUSH_BRANCHES, ESCAPING_OPS, DONE_SENTINEL, ASK_SENTINEL, } from './conquer.js'; export type { StuckSignals, ConquerCaps, ConquerState, ConquerTurn, ConquerOptions, ConquerResult, ConquerIsolation, DoneOracleInput, SandboxOps, FalsifierResult } from './conquer.js'; diff --git a/packages/forge/src/kern/conquer.kern b/packages/forge/src/kern/conquer.kern index c662fd965..8425a8f7d 100644 --- a/packages/forge/src/kern/conquer.kern +++ b/packages/forge/src/kern/conquer.kern @@ -94,6 +94,21 @@ fn name=summarizeConsultForBuilder params="consult:{ mode: string; verdict: stri return `[Cesar consulted ${consult.mode}]${conf} ${body}`; >>> +const name=DEFAULT_PROTECTED_PUSH_BRANCHES type="string[]" value={{ ['main', 'master'] }} export=true + doc "Branches an unattended conquer --push must never land on. Overridable via config protectedPushBranches." + +fn name=isProtectedPushBranch params="branch:string, config?:any" returns=boolean export=true + doc "Guard for the unattended --push path (agy review follow-up): a conquer run normally pushes its own isolated conquer/ branch, but if the worktree HEAD ever resolves to a protected branch (builder switched branches, odd isolation state) the push is refused rather than landing an unreviewed build on main. protectedPushBranches (string[]) in config overrides the default ['main','master'] ONLY when non-empty — an empty/absent list means 'use the defaults', never 'protect nothing' (KERN codegen emits optional array fields as [] into DEFAULT_AGON_CONFIG, so an empty-array-disables rule would silently kill the guard for every real loadConfig() caller — 5/6-engine review consensus). Matching is exact after trim, case-insensitive; an empty/unresolvable branch name is protected (fail-closed). Pure — exported for testing." + handler <<< + const name = String(branch ?? '').trim().toLowerCase(); + if (!name) return true; + const raw = Array.isArray(config?.protectedPushBranches) + ? config.protectedPushBranches.map((b: unknown) => String(b).trim().toLowerCase()).filter(Boolean) + : []; + const configured = raw.length > 0 ? raw : DEFAULT_PROTECTED_PUSH_BRANCHES; + return configured.includes(name); + >>> + // ── Phase 2/3: the supervisory loop + the done-oracle ─────────────────────── interface name=ConquerCaps diff --git a/tests/unit/conquer.test.ts b/tests/unit/conquer.test.ts index ea89f2138..8021cf8f7 100644 --- a/tests/unit/conquer.test.ts +++ b/tests/unit/conquer.test.ts @@ -7,6 +7,7 @@ import { classifyStuck, shouldEscalate, shouldAutoApprove, + isProtectedPushBranch, summarizeConsultForBuilder, capBreached, parseBuilderSignals, @@ -125,6 +126,44 @@ describe('shouldAutoApprove — worktree-gated', () => { }); }); +describe('isProtectedPushBranch — unattended --push guard', () => { + it('protects main and master by default, case-insensitively', () => { + expect(isProtectedPushBranch('main', {})).toBe(true); + expect(isProtectedPushBranch('master', {})).toBe(true); + expect(isProtectedPushBranch('Main', {})).toBe(true); + expect(isProtectedPushBranch(' main ', {})).toBe(true); + }); + + it('lets a normal conquer branch through', () => { + expect(isProtectedPushBranch('conquer/add-oauth-x1', {})).toBe(false); + expect(isProtectedPushBranch('feat/parser', {})).toBe(false); + }); + + it('config protectedPushBranches overrides the default list', () => { + const config = { protectedPushBranches: ['release', 'dev'] }; + expect(isProtectedPushBranch('dev', config)).toBe(true); + expect(isProtectedPushBranch('release', config)).toBe(true); + expect(isProtectedPushBranch('main', config)).toBe(false); + }); + + it('an EMPTY protectedPushBranches array means defaults, never protect-nothing — the DEFAULT_AGON_CONFIG production state', () => { + // KERN codegen emits optional array fields as [] into DEFAULT_AGON_CONFIG, + // so every real loadConfig() caller passes { protectedPushBranches: [] }. + // 5/6-engine review consensus: an empty-array-disables rule silently kills + // the guard in production. Empty MUST fall back to main/master. + expect(isProtectedPushBranch('main', { protectedPushBranches: [] })).toBe(true); + expect(isProtectedPushBranch('master', { protectedPushBranches: [] })).toBe(true); + expect(isProtectedPushBranch('conquer/x', { protectedPushBranches: [] })).toBe(false); + // Whitespace-only entries filter out and also fall back to the defaults. + expect(isProtectedPushBranch('main', { protectedPushBranches: [' '] })).toBe(true); + }); + + it('fails closed on an empty or unresolvable branch name', () => { + expect(isProtectedPushBranch('', {})).toBe(true); + expect(isProtectedPushBranch(' ', undefined as any)).toBe(true); + }); +}); + describe('summarizeConsultForBuilder — compact feedback', () => { it('labels the mode and appends confidence when present', () => { const s = summarizeConsultForBuilder({ mode: 'nero', verdict: 'Use a streaming parser.', confidence: 80 }); diff --git a/tests/unit/delegation-reflex.test.ts b/tests/unit/delegation-reflex.test.ts new file mode 100644 index 000000000..3be55891e --- /dev/null +++ b/tests/unit/delegation-reflex.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from 'vitest'; +import { + assessDelegationShape, + buildDelegationAdvisory, +} from '../../packages/cli/src/generated/cesar/delegation-reflex.js'; + +const listTask = [ + 'Please handle these independent cleanups across the repo:', + '1. migrate the audio player component to the new event bus API', + '2. rewrite the settings screen validation against the form contract', + '3. port the notification badge logic from the legacy polling service', +].join('\n'); + +describe('assessDelegationShape — veto-first fan-out detection', () => { + it('suggests on an explicit 3-item list with no vetoes', () => { + const shape = assessDelegationShape(listTask); + expect(shape.decision).toBe('suggest'); + expect(shape.items).toHaveLength(3); + expect(shape.vetoes).toHaveLength(0); + }); + + it('never suggests for prose without explicit list items', () => { + const shape = assessDelegationShape('Refactor the audio player, the settings screen, and the notification badge to use the new event bus API across the whole application.'); + expect(shape.decision).toBe('none'); + expect(shape.vetoes).toContain('fewer-than-3-items'); + }); + + it('a 2-item list stays below the floor', () => { + const shape = assessDelegationShape('Two things:\n1. migrate the audio player component to the new bus\n2. rewrite the settings screen validation against the contract'); + expect(shape.decision).toBe('none'); + expect(shape.vetoes).toContain('fewer-than-3-items'); + }); + + it('sequential markers veto even a clean list', () => { + const shape = assessDelegationShape(`${listTask}\nDo the first one, then the second once that is green.`); + expect(shape.decision).toBe('none'); + expect(shape.vetoes).toContain('sequential-markers'); + }); + + it('small-task language vetoes', () => { + const shape = assessDelegationShape(`Just a quick pass please:\n${listTask}`); + expect(shape.decision).toBe('none'); + expect(shape.vetoes).toContain('small-task-language'); + }); + + it('shared-artifact mentions veto — parallel-looking work on one artifact is not parallel', () => { + const shape = assessDelegationShape(`${listTask}\nAll three write to the same generated schema module.`); + expect(shape.decision).toBe('none'); + expect(shape.vetoes).toContain('shared-artifact-mention'); + }); + + it('tiny list items fail the size floor', () => { + const shape = assessDelegationShape('These please:\n1. fix the header layout problem\n2. do tests\n3. update the readme documentation for the release'); + expect(shape.decision).toBe('none'); + expect(shape.vetoes).toContain('item-below-size-floor'); + }); + + it('long items are capped in the reported list', () => { + const long = 'x'.repeat(300); + const shape = assessDelegationShape(`Work:\n1. ${long}\n2. ${long}\n3. ${long}`); + expect(shape.items.every((i) => i.length <= 120)).toBe(true); + }); +}); + +describe('buildDelegationAdvisory', () => { + it('renders the advisory for a suggest decision, using the canonical team-agent action', () => { + const advisory = buildDelegationAdvisory(assessDelegationShape(listTask)); + expect(advisory).toContain('[DELEGATION SHAPE]'); + expect(advisory).toContain('3 explicit list items'); + // 'team-agent' is the action routing.kern/parseSuggestion actually + // recognize (team- prefix + agent) — NOT 'agent-team' (review: kimi). + expect(advisory).toContain('SUGGEST:team-agent'); + expect(advisory).toContain('ignore this note'); + }); + + it('plural shared-artifact mentions veto too', () => { + const shape = assessDelegationShape(`${listTask}\nCareful: all of them touch the schemas and lockfiles.`); + expect(shape.decision).toBe('none'); + expect(shape.vetoes).toContain('shared-artifact-mention'); + }); + + it('an unrelated far-apart first/then pair does not over-veto', () => { + const text = `${listTask}\nThe first item is the most valuable one for users.\nOverall this is not urgent; there is more work coming eventually.`; + const shape = assessDelegationShape(text); + expect(shape.vetoes).not.toContain('sequential-markers'); + }); + + it('returns null for a none decision', () => { + expect(buildDelegationAdvisory(assessDelegationShape('fix the typo in the readme'))).toBeNull(); + }); +}); diff --git a/tests/unit/experience.test.ts b/tests/unit/experience.test.ts new file mode 100644 index 000000000..1cbd9bd7a --- /dev/null +++ b/tests/unit/experience.test.ts @@ -0,0 +1,152 @@ +import { describe, expect, it } from 'vitest'; +import { + buildExperienceBlock, + episodesFromRunRecords, + experienceRetrievalOptions, + retrieveExperience, + scoreExperienceSimilarity, + summarizeIntentForEpisode, + tokenizeExperienceText, + type ExperienceEpisode, +} from '../../packages/cli/src/generated/cesar/experience.js'; + +const episode = (overrides: Partial = {}): ExperienceEpisode => ({ + mode: 'forge', + taskType: 'bugfix', + intentSummary: 'fix the CSV parser crash on quoted fields', + winner: 'codex', + success: true, + durationMs: 240_000, + timestamp: 1000, + projectKey: '/repo/a', + ...overrides, +}); + +describe('summarizeIntentForEpisode', () => { + it('collapses whitespace and caps at 160 chars', () => { + expect(summarizeIntentForEpisode(' fix the\n\nparser ')).toBe('fix the parser'); + const long = 'x'.repeat(400); + const out = summarizeIntentForEpisode(long); + expect(out.length).toBeLessThanOrEqual(160); + expect(out.endsWith('…')).toBe(true); + }); + + it('returns empty for trivial intents so they never become episodes', () => { + expect(summarizeIntentForEpisode('')).toBe(''); + expect(summarizeIntentForEpisode('fix')).toBe(''); + expect(summarizeIntentForEpisode(undefined as unknown as string)).toBe(''); + }); + + it('strips control characters', () => { + expect(summarizeIntentForEpisode('fix\u0000the\u001bparser thing')).toBe('fix the parser thing'); + }); +}); + +describe('tokenize + similarity', () => { + it('drops stopwords and short tokens', () => { + const t = tokenizeExperienceText('Add the new CSV parser for the app'); + expect(t.has('csv')).toBe(true); + expect(t.has('parser')).toBe(true); + expect(t.has('the')).toBe(false); + expect(t.has('add')).toBe(false); + }); + + it('scores related prompts above unrelated ones', () => { + const related = scoreExperienceSimilarity('fix the CSV parser crash', 'CSV parser crash on quoted fields'); + const unrelated = scoreExperienceSimilarity('fix the CSV parser crash', 'design a login page in React'); + expect(related).toBeGreaterThan(unrelated); + expect(related).toBeGreaterThan(0.2); + expect(unrelated).toBeLessThan(0.1); + }); + + it('empty inputs score zero', () => { + expect(scoreExperienceSimilarity('', 'anything')).toBe(0); + }); +}); + +describe('experienceRetrievalOptions', () => { + it('resolves defaults, treating 0 as unset (DEFAULT_AGON_CONFIG emits optional numbers as 0)', () => { + const opts = experienceRetrievalOptions({ cesarExperienceMinSimilarity: 0, cesarExperienceMinEpisodes: 0, cesarExperienceTopK: 0, cesarExperienceWindow: 0 }); + expect(opts.minSimilarity).toBe(0.2); + expect(opts.minEpisodes).toBe(3); + expect(opts.topK).toBe(3); + expect(opts.window).toBe(200); + }); + + it('honors explicit overrides and caps similarity at 1', () => { + const opts = experienceRetrievalOptions({ cesarExperienceMinSimilarity: 5, cesarExperienceMinEpisodes: 2, cesarExperienceTopK: 1, cesarExperienceWindow: 10 }); + expect(opts.minSimilarity).toBe(1); + expect(opts.minEpisodes).toBe(2); + expect(opts.topK).toBe(1); + expect(opts.window).toBe(10); + }); +}); + +describe('episodesFromRunRecords', () => { + it('keeps only records that stored an intentSummary', () => { + const eps = episodesFromRunRecords([ + { mode: 'forge', taskType: 'bugfix', intentSummary: 'fix the parser', winner: 'codex', success: true, durationMs: 100, timestamp: 5 }, + { mode: 'review', taskType: 'other' }, // pre-field legacy record + null, + ]); + expect(eps).toHaveLength(1); + expect(eps[0].mode).toBe('forge'); + }); + + it('scopes to the requesting project — other repos never leak, unscoped legacy records fail closed', () => { + const records = [ + { mode: 'forge', intentSummary: 'fix the parser here', projectKey: '/repo/a', timestamp: 1 }, + { mode: 'forge', intentSummary: 'fix the parser elsewhere', projectKey: '/repo/b', timestamp: 2 }, + { mode: 'forge', intentSummary: 'fix the parser nowhere', timestamp: 3 }, // no projectKey + ]; + const scoped = episodesFromRunRecords(records, '/repo/a'); + expect(scoped).toHaveLength(1); + expect(scoped[0].intentSummary).toBe('fix the parser here'); + // Unscoped query keeps everything (callers opt in to scoping). + expect(episodesFromRunRecords(records)).toHaveLength(3); + }); +}); + +describe('retrieveExperience — the min-N evidence gate', () => { + const opts = { minSimilarity: 0.2, minEpisodes: 3, topK: 2, window: 200 }; + const prompt = 'fix the CSV parser crash on quoted fields'; + + it('returns nothing below the min-N support threshold, even with strong matches', () => { + const eps = [episode(), episode({ timestamp: 2000 })]; + expect(retrieveExperience(prompt, eps, opts)).toHaveLength(0); + }); + + it('returns top-K when enough episodes clear the similarity gate, newest first on ties', () => { + const eps = [ + episode({ timestamp: 1 }), + episode({ timestamp: 2 }), + episode({ timestamp: 3 }), + episode({ intentSummary: 'design a login page in React', timestamp: 4 }), + ]; + const matches = retrieveExperience(prompt, eps, opts); + expect(matches).toHaveLength(2); + expect(matches[0].episode.timestamp).toBe(3); + expect(matches.every((m) => m.similarity >= 0.2)).toBe(true); + }); + + it('unrelated episodes never surface', () => { + const eps = [1, 2, 3, 4].map((i) => episode({ intentSummary: 'design a login page in React', timestamp: i })); + expect(retrieveExperience(prompt, eps, opts)).toHaveLength(0); + }); +}); + +describe('buildExperienceBlock', () => { + it('renders advisory framing with mode, winner, outcome', () => { + const block = buildExperienceBlock([{ episode: episode(), similarity: 0.42 }]); + expect(block).toContain('[EXPERIENCE]'); + expect(block).toContain('evidence, not authority'); + expect(block).toContain('mode=forge'); + expect(block).toContain('codex'); + expect(block).toContain('succeeded'); + expect(block).toContain('own merits'); + }); + + it('returns null for no matches', () => { + expect(buildExperienceBlock([])).toBeNull(); + }); +}); diff --git a/tests/unit/intent.test.ts b/tests/unit/intent.test.ts index 8169d244a..40e144994 100644 --- a/tests/unit/intent.test.ts +++ b/tests/unit/intent.test.ts @@ -179,6 +179,25 @@ describe('Intent Detection — Slash Commands', () => { expect((r as any).maxTurns).toBe(20); }); + it('/conquer parses the CLI-parity timing flags out of the task', () => { + const r = detectIntent('/conquer add OAuth --gate "npm test" --gate-timeout 900 --max-hours 2.5 --timeout 300'); + expect(r.type).toBe('conquer'); + expect((r as any).task).toBe('add OAuth'); + expect((r as any).gate).toBe('npm test'); + expect((r as any).gateTimeout).toBe(900); + expect((r as any).maxHours).toBe(2.5); + expect((r as any).turnTimeout).toBe(300); + }); + + it('/conquer --gate-timeout does not get swallowed by the --gate or --timeout parsers', () => { + const r = detectIntent('/conquer fix parser --gate-timeout 900'); + expect(r.type).toBe('conquer'); + expect((r as any).task).toBe('fix parser'); + expect((r as any).gate).toBeUndefined(); + expect((r as any).gateTimeout).toBe(900); + expect((r as any).turnTimeout).toBeUndefined(); + }); + it('think/council/synthesis/conquer appear in the /help slash list', () => { for (const cmd of ['/think', '/council', '/synthesis', '/conquer']) { expect(SLASH_COMMANDS.some((c) => c.cmd === cmd)).toBe(true);