From 8eecd2fb42ef75e3a9bb4824ac4b7e6ec8e2c44d Mon Sep 17 00:00:00 2001 From: "agon (KERN)" <292465531+KERN-Agon@users.noreply.github.com> Date: Thu, 16 Jul 2026 12:13:10 +0200 Subject: [PATCH 1/6] =?UTF-8?q?feat:=20conquer=20hardening=20=E2=80=94=20p?= =?UTF-8?q?rotected-branch=20push=20guard=20and=20REPL=20flag=20parity?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two review follow-ups from the conquer falsifier round: (1) unattended --push now refuses to land on a protected branch (main/master by default, protectedPushBranches config override, case-insensitive, fail-closed on an unresolvable HEAD) via isProtectedPushBranch in the forge decision core — a conquer worktree normally sits on its own conquer/ branch, so a protected HEAD means something unexpected happened and the work stays committed locally for a human push. (2) the REPL /conquer handler no longer hardcodes what the CLI exposes: --gate-timeout, --max-hours and --timeout parse through the intent layer and reach the gate spawn and runConquer caps, mirroring the CLI defaults (1800s gate, 600s turn, no wall cap). ⚔️ Forged by [Agon](https://github.com/KERNlang/agon) Co-Authored-By: agon (KERN) <292465531+KERN-Agon@users.noreply.github.com> --- README.md | 4 +- .../cli/src/generated/commands/conquer.ts | 12 ++- packages/cli/src/generated/commands/daemon.ts | 2 +- .../cli/src/generated/handlers/conquer.ts | 13 ++- .../signals/dispatch/intent-orchestration.ts | 2 +- packages/cli/src/generated/signals/intent.ts | 85 +++++++++++-------- packages/cli/src/kern/commands/conquer.kern | 10 ++- packages/cli/src/kern/handlers/conquer.kern | 13 ++- .../dispatch/intent-orchestration.kern | 2 +- packages/cli/src/kern/signals/intent.kern | 17 +++- packages/core/src/generated/models/types.ts | 52 ++++++------ packages/core/src/kern/models/types.kern | 2 + packages/forge/src/generated/conquer.ts | 67 +++++++++------ packages/forge/src/index.ts | 1 + packages/forge/src/kern/conquer.kern | 14 +++ tests/unit/conquer.test.ts | 27 ++++++ tests/unit/intent.test.ts | 19 +++++ 17 files changed, 239 insertions(+), 103 deletions(-) diff --git a/README.md b/README.md index 7d102fb7d..fd79b3d8c 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 diff --git a/packages/cli/src/generated/commands/conquer.ts b/packages/cli/src/generated/commands/conquer.ts index 9dc103359..bdc6620bc 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'; @@ -194,6 +194,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..bbe2a0d48 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(); @@ -43,6 +43,11 @@ export async function handleConquer(task: string, dispatch: Dispatch, ctx: Handl } 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 +77,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 +89,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/commands/conquer.kern b/packages/cli/src/kern/commands/conquer.kern index 45b5f8bf9..de0c639f9 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" @@ -196,6 +196,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..02b1fd8ff 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(); @@ -40,6 +40,11 @@ fn name=handleConquer params="task:string, dispatch:Dispatch, ctx:HandlerContext } 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 +74,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 +86,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/core/src/generated/models/types.ts b/packages/core/src/generated/models/types.ts index 0ae541d6e..6a4886623 100644 --- a/packages/core/src/generated/models/types.ts +++ b/packages/core/src/generated/models/types.ts @@ -1,7 +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 @@ -37,6 +35,8 @@ // @kern-source: types:537 // @kern-source: types:538 // @kern-source: types:539 +// @kern-source: types:540 +// @kern-source: types:541 // @kern-source: types:1 export type EngineMode = 'exec' | 'review' | 'agent'; @@ -347,6 +347,7 @@ export interface AgonConfig { cesarSelfTurnAutoApproveMaxDiffTokens?: number; cesarApprovalLedger?: boolean; cesarToolTimeline?: boolean; + protectedPushBranches: string[]; cesarStreamingXmlTools?: boolean; campfireObserverStrategy?: 'lead-first'|'all-respond'; hooks: Record>; @@ -454,6 +455,7 @@ export const DEFAULT_AGON_CONFIG: Required = { cesarSelfTurnAutoApproveMaxDiffTokens: 1200, cesarApprovalLedger: true, cesarToolTimeline: true, + protectedPushBranches: [], cesarStreamingXmlTools: true, campfireObserverStrategy: 'lead-first', hooks: {} as any, @@ -484,7 +486,7 @@ export const DEFAULT_AGON_CONFIG: Required = { browserExtensionIds: [], }; -// @kern-source: types:369 +// @kern-source: types:371 export interface ScoutBid { engineId: string; confidence: number; @@ -495,7 +497,7 @@ export interface ScoutBid { needsCompetition: boolean; } -// @kern-source: types:378 +// @kern-source: types:380 export interface RoutingDecision { action: 'chat'|'build'|'pipeline'|'campfire'|'forge'|'brainstorm'|'tribunal'|'agent'|'team-agent'; leadEngine: string; @@ -507,14 +509,14 @@ export interface RoutingDecision { bids: ScoutBid[]; } -// @kern-source: types:388 +// @kern-source: types:390 export interface CampfireMessage { engineId: string; content: string; isLead: boolean; } -// @kern-source: types:393 +// @kern-source: types:395 export interface ForgeOptions { task: string; fitnessCmd: string; @@ -540,7 +542,7 @@ export interface ForgeOptions { synthEngine?: string; } -// @kern-source: types:422 +// @kern-source: types:424 export interface EngineResult { engineId: string; pass: boolean; @@ -562,14 +564,14 @@ export interface EngineResult { syntaxInvalidFiles?: string[]; } -// @kern-source: types:442 +// @kern-source: types:444 export interface SkippedEngine { engineId: string; status: string; reason?: string; } -// @kern-source: types:447 +// @kern-source: types:449 export interface DispatchMetric { engineId: string; phase: 'stage1'|'stage1-fallback'|'stage2-scout'|'stage2-scout-fallback'|'stage2-follower'|'stage2-fallback'|'synthesis'|'gauntlet'; @@ -583,7 +585,7 @@ export interface DispatchMetric { tokens?: {prompt:number, response:number, costUsd:number}; } -// @kern-source: types:459 +// @kern-source: types:461 export interface ForgeManifest { forgeId: string; forgeDir: string; @@ -615,7 +617,7 @@ export interface ForgeManifest { gauntlet?: GauntletResult; } -// @kern-source: types:489 +// @kern-source: types:491 export interface ConvergenceEntry { file: string; fn: string; @@ -623,7 +625,7 @@ export interface ConvergenceEntry { reason: string; } -// @kern-source: types:495 +// @kern-source: types:497 export interface ForgeJudgment { winner: string; strengths: { engineId: string; category: string; reason: string }[]; @@ -634,7 +636,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:504 export interface ForgeEvent { type: ForgeEventType; engineId?: string; @@ -683,7 +685,7 @@ export interface ForgeEventMap { export type ForgeEventCallback = (event: ForgeEvent) => void; -// @kern-source: types:541 +// @kern-source: types:543 export interface BrainstormBid { engineId: string; confidence: number; @@ -692,26 +694,26 @@ export interface BrainstormBid { score?: number; } -// @kern-source: types:548 +// @kern-source: types:550 export interface BrainstormGroup { members: string[]; representative: string; similarity: number; } -// @kern-source: types:553 +// @kern-source: types:555 export interface BrainstormDedupStatus { status: 'not-needed' | 'applied' | 'unavailable' | 'failed' | 'timed-out'; detail?: string; } -// @kern-source: types:557 +// @kern-source: types:559 export interface BrainstormSynthesisStatus { status: 'completed' | 'fallback'; detail?: string; } -// @kern-source: types:564 +// @kern-source: types:566 export interface PanelHealth { requested: number; responded: number; @@ -720,7 +722,7 @@ export interface PanelHealth { banner: string | null; } -// @kern-source: types:571 +// @kern-source: types:573 export interface BrainstormResult { question: string; bids: BrainstormBid[]; @@ -732,7 +734,7 @@ export interface BrainstormResult { panelHealth?: PanelHealth; } -// @kern-source: types:581 +// @kern-source: types:583 export interface BreakerArtifact { engineId: string; testScript: string; @@ -742,7 +744,7 @@ export interface BreakerArtifact { validated: boolean; } -// @kern-source: types:589 +// @kern-source: types:591 export interface GauntletResult { winnerId: string; breakerArtifacts: BreakerArtifact[]; @@ -755,7 +757,7 @@ export interface GauntletResult { patchPath?: string; } -// @kern-source: types:600 +// @kern-source: types:602 export interface CorpusEntry { forgeId: string; taskClass: TaskClass; @@ -765,7 +767,7 @@ export interface CorpusEntry { pattern?: string; } -// @kern-source: types:608 +// @kern-source: types:610 export interface GapPattern { pattern: string; taskClass: TaskClass; @@ -776,7 +778,7 @@ export interface GapPattern { skillPath?: string; } -// @kern-source: types:617 +// @kern-source: types:619 export interface Critique { file: string; lines: string; @@ -784,5 +786,5 @@ export interface Critique { minimalFix: string; } -// @kern-source: types:623 +// @kern-source: types:625 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..9be0d5e91 100644 --- a/packages/core/src/kern/models/types.kern +++ b/packages/core/src/kern/models/types.kern @@ -315,6 +315,8 @@ 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: main, master. Case-insensitive exact match." 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..fc470ba4a 100644 --- a/packages/forge/src/generated/conquer.ts +++ b/packages/forge/src/generated/conquer.ts @@ -101,13 +101,32 @@ 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']; 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 configured = Array.isArray(config?.protectedPushBranches) + ? config.protectedPushBranches.map((b: unknown) => String(b).trim().toLowerCase()).filter(Boolean) + : DEFAULT_PROTECTED_PUSH_BRANCHES; + return configured.includes(name); +} + +// @kern-source: conquer:113 export interface ConquerCaps { maxTurns: number; maxWallClockMs: number; } -// @kern-source: conquer:103 +// @kern-source: conquer:117 export interface ConquerState { turn: number; spentUsd: number; @@ -115,7 +134,7 @@ export interface ConquerState { consults: number; } -// @kern-source: conquer:109 +// @kern-source: conquer:123 export interface ConquerTurn { n: number; engineId: string; @@ -123,7 +142,7 @@ export interface ConquerTurn { detail: string; } -// @kern-source: conquer:115 +// @kern-source: conquer:129 export interface ConquerOptions { task: string; builderEngine: string; @@ -140,7 +159,7 @@ export interface ConquerOptions { signal?: AbortSignal | undefined; } -// @kern-source: conquer:130 +// @kern-source: conquer:144 export interface ConquerResult { ok: boolean; task: string; @@ -157,7 +176,7 @@ export interface ConquerResult { observed: string; } -// @kern-source: conquer:145 +// @kern-source: conquer:159 export interface ConquerIsolation { repoRoot: string; branch: string; @@ -167,7 +186,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:164 export function createConquerIsolation(task: string, cwd?: string, runId?: string): ConquerIsolation { const sourceCwd = cwd ?? resolveWorkingDir(); const root = repoRoot(sourceCwd); @@ -178,7 +197,7 @@ export function createConquerIsolation(task: string, cwd?: string, runId?: strin return { repoRoot: root, branch, path: worktree.path }; } -// @kern-source: conquer:162 +// @kern-source: conquer:176 export interface DoneOracleInput { gateOk: boolean; oracleTampered: boolean; @@ -190,7 +209,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:183 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 +219,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:191 export function parseBuilderSignals(turnText: string): { claimedDone: boolean; ask: string | null; claim: string } { const lines = turnText.split('\n'); let claimedDone = false; @@ -223,7 +242,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:212 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 +256,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:224 export function buildConquerSystemPrompt(): string { return [ 'You are an autonomous build agent supervised by Cesar (you do NOT talk to a human directly).', @@ -253,7 +272,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:238 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 +295,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:259 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 +322,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:284 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 +332,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:301 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:309 export interface FalsifierResult { falsified: boolean; counterexample: string | null; @@ -334,7 +353,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:318 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 +363,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:326 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 +390,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:351 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 +402,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:361 export function isSafeCounterexample(cmd: string): boolean { const c = cmd.toLowerCase(); const danger: RegExp[] = [ @@ -403,7 +422,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:379 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 +537,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:492 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 +566,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:519 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..b877f9ff1 100644 --- a/packages/forge/src/kern/conquer.kern +++ b/packages/forge/src/kern/conquer.kern @@ -94,6 +94,20 @@ 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']; 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 configured = Array.isArray(config?.protectedPushBranches) + ? config.protectedPushBranches.map((b: unknown) => String(b).trim().toLowerCase()).filter(Boolean) + : 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..e674d1bac 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,32 @@ 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('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/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); From 4709e32b7df549859e61968f3a8ea2d3060e1222 Mon Sep 17 00:00:00 2001 From: "agon (KERN)" <292465531+KERN-Agon@users.noreply.github.com> Date: Thu, 16 Jul 2026 12:21:51 +0200 Subject: [PATCH 2/6] fix: empty protectedPushBranches means defaults, never protect-nothing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Full-roster review (6/6 engines, 5-engine consensus on the blocker): KERN codegen emits optional array fields as [] into DEFAULT_AGON_CONFIG, so every real loadConfig() caller passed { protectedPushBranches: [] } and the Array.isArray fallback never fired — main/master were NOT protected in production. An empty or whitespace-only list now falls back to the default ['main','master']; only a non-empty override replaces it. Verified against the real loadConfig() output. Also: CLI --timeout/--gate-timeout reject negative values instead of propagating them to spawnWithTimeout (agy), the REPL /conquer rejects --max-turns < 1 exactly like the CLI (minimax), and the exact production config state is now a test case (zai). Refuted (no change): the cqAbort cleanup claim — the handler's KERN cleanup block generates the finally { ctx.setActiveAbort(null) }, and a still-running background job after /focus-away is the job system's design. ⚔️ Forged by [Agon](https://github.com/KERNlang/agon) Co-Authored-By: agon (KERN) <292465531+KERN-Agon@users.noreply.github.com> --- .../cli/src/generated/commands/conquer.ts | 6 +- .../cli/src/generated/handlers/conquer.ts | 6 ++ packages/cli/src/kern/commands/conquer.kern | 6 +- packages/cli/src/kern/handlers/conquer.kern | 6 ++ packages/core/src/kern/models/types.kern | 2 +- packages/forge/src/generated/conquer.ts | 55 ++++++++++--------- packages/forge/src/kern/conquer.kern | 7 ++- tests/unit/conquer.test.ts | 12 ++++ 8 files changed, 65 insertions(+), 35 deletions(-) diff --git a/packages/cli/src/generated/commands/conquer.ts b/packages/cli/src/generated/commands/conquer.ts index bdc6620bc..35bf29852 100644 --- a/packages/cli/src/generated/commands/conquer.ts +++ b/packages/cli/src/generated/commands/conquer.ts @@ -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; diff --git a/packages/cli/src/generated/handlers/conquer.ts b/packages/cli/src/generated/handlers/conquer.ts index bbe2a0d48..7e5d75c5a 100644 --- a/packages/cli/src/generated/handlers/conquer.ts +++ b/packages/cli/src/generated/handlers/conquer.ts @@ -42,6 +42,12 @@ 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). diff --git a/packages/cli/src/kern/commands/conquer.kern b/packages/cli/src/kern/commands/conquer.kern index de0c639f9..67b56f726 100644 --- a/packages/cli/src/kern/commands/conquer.kern +++ b/packages/cli/src/kern/commands/conquer.kern @@ -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; diff --git a/packages/cli/src/kern/handlers/conquer.kern b/packages/cli/src/kern/handlers/conquer.kern index 02b1fd8ff..fc6f47ede 100644 --- a/packages/cli/src/kern/handlers/conquer.kern +++ b/packages/cli/src/kern/handlers/conquer.kern @@ -39,6 +39,12 @@ 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). diff --git a/packages/core/src/kern/models/types.kern b/packages/core/src/kern/models/types.kern index 9be0d5e91..7c26521d8 100644 --- a/packages/core/src/kern/models/types.kern +++ b/packages/core/src/kern/models/types.kern @@ -316,7 +316,7 @@ config name=AgonConfig 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: main, master. Case-insensitive exact match." + 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=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 fc470ba4a..38fae5118 100644 --- a/packages/forge/src/generated/conquer.ts +++ b/packages/forge/src/generated/conquer.ts @@ -108,25 +108,26 @@ export function summarizeConsultForBuilder(consult: { mode: string; verdict: str 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']; matching is exact after trim, case-insensitive; an empty/unresolvable branch name is protected (fail-closed). Pure — exported for testing. + * 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 configured = Array.isArray(config?.protectedPushBranches) + const raw = Array.isArray(config?.protectedPushBranches) ? config.protectedPushBranches.map((b: unknown) => String(b).trim().toLowerCase()).filter(Boolean) - : DEFAULT_PROTECTED_PUSH_BRANCHES; + : []; + const configured = raw.length > 0 ? raw : DEFAULT_PROTECTED_PUSH_BRANCHES; return configured.includes(name); } -// @kern-source: conquer:113 +// @kern-source: conquer:114 export interface ConquerCaps { maxTurns: number; maxWallClockMs: number; } -// @kern-source: conquer:117 +// @kern-source: conquer:118 export interface ConquerState { turn: number; spentUsd: number; @@ -134,7 +135,7 @@ export interface ConquerState { consults: number; } -// @kern-source: conquer:123 +// @kern-source: conquer:124 export interface ConquerTurn { n: number; engineId: string; @@ -142,7 +143,7 @@ export interface ConquerTurn { detail: string; } -// @kern-source: conquer:129 +// @kern-source: conquer:130 export interface ConquerOptions { task: string; builderEngine: string; @@ -159,7 +160,7 @@ export interface ConquerOptions { signal?: AbortSignal | undefined; } -// @kern-source: conquer:144 +// @kern-source: conquer:145 export interface ConquerResult { ok: boolean; task: string; @@ -176,7 +177,7 @@ export interface ConquerResult { observed: string; } -// @kern-source: conquer:159 +// @kern-source: conquer:160 export interface ConquerIsolation { repoRoot: string; branch: string; @@ -186,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:164 +// @kern-source: conquer:165 export function createConquerIsolation(task: string, cwd?: string, runId?: string): ConquerIsolation { const sourceCwd = cwd ?? resolveWorkingDir(); const root = repoRoot(sourceCwd); @@ -197,7 +198,7 @@ export function createConquerIsolation(task: string, cwd?: string, runId?: strin return { repoRoot: root, branch, path: worktree.path }; } -// @kern-source: conquer:176 +// @kern-source: conquer:177 export interface DoneOracleInput { gateOk: boolean; oracleTampered: boolean; @@ -209,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:183 +// @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'; @@ -219,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:191 +// @kern-source: conquer:192 export function parseBuilderSignals(turnText: string): { claimedDone: boolean; ask: string | null; claim: string } { const lines = turnText.split('\n'); let claimedDone = false; @@ -242,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:212 +// @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); @@ -256,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:224 +// @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).', @@ -272,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:238 +// @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 ?? ''); @@ -295,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:259 +// @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; @@ -322,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:284 +// @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)' }; @@ -332,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:301 +// @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:309 +// @kern-source: conquer:310 export interface FalsifierResult { falsified: boolean; counterexample: string | null; @@ -353,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:318 +// @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; @@ -363,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:326 +// @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.'); @@ -390,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:351 +// @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)]; @@ -402,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:361 +// @kern-source: conquer:362 export function isSafeCounterexample(cmd: string): boolean { const c = cmd.toLowerCase(); const danger: RegExp[] = [ @@ -422,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:379 +// @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' }); @@ -537,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:492 +// @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)); @@ -566,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:519 +// @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/kern/conquer.kern b/packages/forge/src/kern/conquer.kern index b877f9ff1..8425a8f7d 100644 --- a/packages/forge/src/kern/conquer.kern +++ b/packages/forge/src/kern/conquer.kern @@ -98,13 +98,14 @@ const name=DEFAULT_PROTECTED_PUSH_BRANCHES type="string[]" value={{ ['main', 'ma 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']; matching is exact after trim, case-insensitive; an empty/unresolvable branch name is protected (fail-closed). Pure — exported for testing." + 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 configured = Array.isArray(config?.protectedPushBranches) + const raw = Array.isArray(config?.protectedPushBranches) ? config.protectedPushBranches.map((b: unknown) => String(b).trim().toLowerCase()).filter(Boolean) - : DEFAULT_PROTECTED_PUSH_BRANCHES; + : []; + const configured = raw.length > 0 ? raw : DEFAULT_PROTECTED_PUSH_BRANCHES; return configured.includes(name); >>> diff --git a/tests/unit/conquer.test.ts b/tests/unit/conquer.test.ts index e674d1bac..8021cf8f7 100644 --- a/tests/unit/conquer.test.ts +++ b/tests/unit/conquer.test.ts @@ -146,6 +146,18 @@ describe('isProtectedPushBranch — unattended --push guard', () => { 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); From c1b567673a446f1457d69669a90354377652075a Mon Sep 17 00:00:00 2001 From: "agon (KERN)" <292465531+KERN-Agon@users.noreply.github.com> Date: Thu, 16 Jul 2026 12:32:13 +0200 Subject: [PATCH 3/6] =?UTF-8?q?feat:=20experience=20precedent=20=E2=80=94?= =?UTF-8?q?=20similar=20past=20runs=20injected=20as=20advisory=20evidence?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RAG roadmap 1b (6/6-engine cesar-mode-rag brainstorm design): when the user's prompt resembles past orchestration runs, the matching episodes (mode, winner, outcome, duration) are injected ahead of the Cesar turn as an [EXPERIENCE] block framed as evidence, never authority. New KERN module cesar/experience.kern: intent summarization (capped, control-char safe), stopword-filtered token-set Jaccard similarity (no sidecar on the hot path), and retrieval gated on a similarity floor AND a min-N support threshold (default 3 qualifying episodes) so a couple of stale runs can't steer routing. recordRun now stores a 160-char intentSummary; legacy records without it are skipped. Injection reuses the shouldGroundInput trigger (slash commands and trivial prompts never retrieve) and is fail-open. Config: cesarExperience (default on), plus tunable minSimilarity/minEpisodes/topK/window resolved 0-safe against the DEFAULT_AGON_CONFIG codegen emission. 14 unit tests. ⚔️ Forged by [Agon](https://github.com/KERNlang/agon) Co-Authored-By: agon (KERN) <292465531+KERN-Agon@users.noreply.github.com> --- README.md | 4 + packages/cli/src/generated/cesar/brain.ts | 27 +++- .../cli/src/generated/cesar/experience.ts | 142 ++++++++++++++++++ packages/cli/src/kern/cesar/brain.kern | 17 +++ packages/cli/src/kern/cesar/experience.kern | 126 ++++++++++++++++ packages/cli/src/telemetry/index.ts | 9 ++ packages/core/src/generated/models/types.ts | 76 ++++++---- packages/core/src/kern/models/types.kern | 10 ++ tests/unit/experience.test.ts | Bin 0 -> 5357 bytes 9 files changed, 374 insertions(+), 37 deletions(-) create mode 100644 packages/cli/src/generated/cesar/experience.ts create mode 100644 packages/cli/src/kern/cesar/experience.kern create mode 100644 tests/unit/experience.test.ts diff --git a/README.md b/README.md index fd79b3d8c..39feabf68 100644 --- a/README.md +++ b/README.md @@ -596,6 +596,10 @@ If you want manual control, you can easily override Cesar's routing: /cesar agy ``` +### 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..5c2375135 100644 --- a/packages/cli/src/generated/cesar/brain.ts +++ b/packages/cli/src/generated/cesar/brain.ts @@ -34,6 +34,10 @@ import { buildRoutingContext, deriveRoutingHints, shouldSpeculate } from './rout import { shouldGroundInput, buildGroundingBlock } from './grounding.js'; +import { episodesFromRunRecords, retrieveExperience, buildExperienceBlock, experienceRetrievalOptions } from './experience.js'; + +import { recentRunRecords } from '../../telemetry/index.js'; + import { readCesarToolReliability, formatCesarReliabilityLine, shouldDowngradeCesarToolWork, buildWhatHappenedSummary } from './reliability.js'; import { applyCesarSelfTurnApproval } from './self-turn-approval.js'; @@ -58,7 +62,7 @@ import { consumeCesarPlanControlSignals } from './plan-control-signals.js'; import { hostNowIso, hostWaitForInteractiveChoice } from '../lib/kern-host.js'; -// @kern-source: brain:31 +// @kern-source: brain:33 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 +95,7 @@ export async function commitTurnAndDelegate(pendingDel: PendingDelegation, input return { delegated: false, responded: true, decisionReason: 'delegation-cancelled', ...telemetry ?? {} }; } -// @kern-source: brain:58 +// @kern-source: brain:60 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 +126,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:85 export const _noBriefNudged: WeakMap = new WeakMap(); -// @kern-source: brain:85 +// @kern-source: brain:87 export async function handleCesarBrain(input: string, dispatch: Dispatch, ctx: HandlerContext, images?: ImageAttachment[]): Promise { const abort = new AbortController(); const _turnStart = Date.now(); @@ -904,6 +908,21 @@ 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); + const _expEpisodes = episodesFromRunRecords(recentRunRecords(_expOpts.window)); + const _expBlock = buildExperienceBlock(retrieveExperience(input, _expEpisodes, _expOpts)); + if (_expBlock) enrichedInput = `${_expBlock}\n\n${enrichedInput}`; + } catch { /* experience 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/experience.ts b/packages/cli/src/generated/cesar/experience.ts new file mode 100644 index 000000000..41d3c9fd2 --- /dev/null +++ b/packages/cli/src/generated/cesar/experience.ts @@ -0,0 +1,142 @@ +// @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; +} + +// @kern-source: experience:19 +export interface ExperienceMatch { + episode: ExperienceEpisode; + similarity: number; +} + +// @kern-source: experience:23 +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:29 +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:32 +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 alphanumeric tokens, ≥3 chars, stopwords removed, deduplicated — the unit of Jaccard similarity. + */ +// @kern-source: experience:43 +export function tokenizeExperienceText(text: string): Set { + const tokens = String(text ?? '') + .toLowerCase() + .split(/[^a-z0-9]+/) + .filter((t) => t.length >= 3 && !EXPERIENCE_STOPWORDS.has(t)); + return new Set(tokens); +} + +/** + * 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:53 +export function scoreExperienceSimilarity(a: string, b: string): number { + const ta = tokenizeExperienceText(a); + const tb = tokenizeExperienceText(b); + 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; +} + +/** + * 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:65 +export function experienceRetrievalOptions(config: any): ExperienceRetrievalOptions { + const num = (v: unknown, fallback: number) => { + const n = Number(v); + return Number.isFinite(n) && n > 0 ? n : fallback; + }; + return { + minSimilarity: Math.min(1, num(config?.cesarExperienceMinSimilarity, 0.2)), + minEpisodes: Math.floor(num(config?.cesarExperienceMinEpisodes, 3)), + topK: Math.floor(num(config?.cesarExperienceTopK, 3)), + window: Math.floor(num(config?.cesarExperienceWindow, 200)), + }; +} + +/** + * Map telemetry RunRecords into episodes, keeping only records that stored an intentSummary (older records predate the field and carry no precedent signal). + */ +// @kern-source: experience:80 +export function episodesFromRunRecords(records: any[]): ExperienceEpisode[] { + const episodes: ExperienceEpisode[] = []; + for (const r of Array.isArray(records) ? records : []) { + const intentSummary = typeof r?.intentSummary === 'string' ? r.intentSummary.trim() : ''; + if (!intentSummary) 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, + }); + } + 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:100 +export function retrieveExperience(prompt: string, episodes: ExperienceEpisode[], opts: ExperienceRetrievalOptions): ExperienceMatch[] { + const scored: ExperienceMatch[] = []; + for (const e of episodes) { + const similarity = scoreExperienceSimilarity(prompt, 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:113 +export function buildExperienceBlock(matches: ExperienceMatch[]): string | null { + if (!matches.length) return null; + const lines = matches.map((m) => { + 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`) : ''; + return `- "${e.intentSummary}" — 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/cesar/brain.kern b/packages/cli/src/kern/cesar/brain.kern index f0eaf182d..181d747ed 100644 --- a/packages/cli/src/kern/cesar/brain.kern +++ b/packages/cli/src/kern/cesar/brain.kern @@ -15,6 +15,8 @@ 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="../../telemetry/index.js" names="recentRunRecords" 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 +863,21 @@ ${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); + const _expEpisodes = episodesFromRunRecords(recentRunRecords(_expOpts.window)); + const _expBlock = buildExperienceBlock(retrieveExperience(input, _expEpisodes, _expOpts)); + if (_expBlock) enrichedInput = `${_expBlock}\n\n${enrichedInput}`; + } catch { /* experience 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/experience.kern b/packages/cli/src/kern/cesar/experience.kern new file mode 100644 index 000000000..cc6ba38ef --- /dev/null +++ b/packages/cli/src/kern/cesar/experience.kern @@ -0,0 +1,126 @@ +// ── 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 + +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 alphanumeric tokens, ≥3 chars, stopwords removed, deduplicated — the unit of Jaccard similarity." + handler <<< + const tokens = String(text ?? '') + .toLowerCase() + .split(/[^a-z0-9]+/) + .filter((t) => t.length >= 3 && !EXPERIENCE_STOPWORDS.has(t)); + return new Set(tokens); + >>> + +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 <<< + const ta = tokenizeExperienceText(a); + const tb = tokenizeExperienceText(b); + 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=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 num = (v: unknown, fallback: number) => { + const n = Number(v); + return Number.isFinite(n) && n > 0 ? n : fallback; + }; + return { + minSimilarity: Math.min(1, num(config?.cesarExperienceMinSimilarity, 0.2)), + minEpisodes: Math.floor(num(config?.cesarExperienceMinEpisodes, 3)), + topK: Math.floor(num(config?.cesarExperienceTopK, 3)), + window: Math.floor(num(config?.cesarExperienceWindow, 200)), + }; + >>> + +fn name=episodesFromRunRecords params="records:any[]" 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)." + handler <<< + const episodes: ExperienceEpisode[] = []; + for (const r of Array.isArray(records) ? records : []) { + const intentSummary = typeof r?.intentSummary === 'string' ? r.intentSummary.trim() : ''; + if (!intentSummary) 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, + }); + } + 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 scored: ExperienceMatch[] = []; + for (const e of episodes) { + const similarity = scoreExperienceSimilarity(prompt, 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 = matches.map((m) => { + 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`) : ''; + return `- "${e.intentSummary}" — 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/telemetry/index.ts b/packages/cli/src/telemetry/index.ts index 6d5c9aa5c..6f2893dfc 100644 --- a/packages/cli/src/telemetry/index.ts +++ b/packages/cli/src/telemetry/index.ts @@ -8,6 +8,7 @@ import { join, resolve } from 'node:path'; import { homedir } from 'node:os'; import { tracker } from '@kernlang/agon-core'; import { runsStore } from '../generated/signals/runs-store.js'; +import { summarizeIntentForEpisode } from '../generated/cesar/experience.js'; // ── Types ────────────────────────────────────────────────────────────── @@ -46,6 +47,7 @@ export interface RunRecord { engineIds: string[]; completionState: string; costEstimateUsd?: number; + intentSummary?: string; } export interface TelemetryFile { @@ -190,6 +192,12 @@ const defaultLedger = new TelemetryLedger(); * Record an orchestration run. Uses a process-wide TelemetryLedger singleton * writing to ~/.agon/telemetry.json. */ +/** 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 +211,7 @@ export function recordRun(result: OrchestrationResult): RunRecord { engineIds: result.engineIds ?? [], completionState: result.completionState, costEstimateUsd: stats?.totalCostUsd ?? undefined, + intentSummary: summarizeIntentForEpisode(result.intent ?? '') || undefined, }; 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 6a4886623..56e70eb91 100644 --- a/packages/core/src/generated/models/types.ts +++ b/packages/core/src/generated/models/types.ts @@ -1,15 +1,5 @@ // @generated by kern v4.5.0 — DO NOT EDIT. Source: src/kern/models/types.kern -// @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 @@ -37,6 +27,16 @@ // @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:1 export type EngineMode = 'exec' | 'review' | 'agent'; @@ -348,6 +348,11 @@ export interface AgonConfig { cesarApprovalLedger?: boolean; cesarToolTimeline?: boolean; protectedPushBranches: string[]; + cesarExperience?: boolean; + cesarExperienceMinSimilarity: number; + cesarExperienceMinEpisodes: number; + cesarExperienceTopK: number; + cesarExperienceWindow: number; cesarStreamingXmlTools?: boolean; campfireObserverStrategy?: 'lead-first'|'all-respond'; hooks: Record>; @@ -456,6 +461,11 @@ export const DEFAULT_AGON_CONFIG: Required = { cesarApprovalLedger: true, cesarToolTimeline: true, protectedPushBranches: [], + cesarExperience: true, + cesarExperienceMinSimilarity: 0, + cesarExperienceMinEpisodes: 0, + cesarExperienceTopK: 0, + cesarExperienceWindow: 0, cesarStreamingXmlTools: true, campfireObserverStrategy: 'lead-first', hooks: {} as any, @@ -486,7 +496,7 @@ export const DEFAULT_AGON_CONFIG: Required = { browserExtensionIds: [], }; -// @kern-source: types:371 +// @kern-source: types:381 export interface ScoutBid { engineId: string; confidence: number; @@ -497,7 +507,7 @@ export interface ScoutBid { needsCompetition: boolean; } -// @kern-source: types:380 +// @kern-source: types:390 export interface RoutingDecision { action: 'chat'|'build'|'pipeline'|'campfire'|'forge'|'brainstorm'|'tribunal'|'agent'|'team-agent'; leadEngine: string; @@ -509,14 +519,14 @@ export interface RoutingDecision { bids: ScoutBid[]; } -// @kern-source: types:390 +// @kern-source: types:400 export interface CampfireMessage { engineId: string; content: string; isLead: boolean; } -// @kern-source: types:395 +// @kern-source: types:405 export interface ForgeOptions { task: string; fitnessCmd: string; @@ -542,7 +552,7 @@ export interface ForgeOptions { synthEngine?: string; } -// @kern-source: types:424 +// @kern-source: types:434 export interface EngineResult { engineId: string; pass: boolean; @@ -564,14 +574,14 @@ export interface EngineResult { syntaxInvalidFiles?: string[]; } -// @kern-source: types:444 +// @kern-source: types:454 export interface SkippedEngine { engineId: string; status: string; reason?: string; } -// @kern-source: types:449 +// @kern-source: types:459 export interface DispatchMetric { engineId: string; phase: 'stage1'|'stage1-fallback'|'stage2-scout'|'stage2-scout-fallback'|'stage2-follower'|'stage2-fallback'|'synthesis'|'gauntlet'; @@ -585,7 +595,7 @@ export interface DispatchMetric { tokens?: {prompt:number, response:number, costUsd:number}; } -// @kern-source: types:461 +// @kern-source: types:471 export interface ForgeManifest { forgeId: string; forgeDir: string; @@ -617,7 +627,7 @@ export interface ForgeManifest { gauntlet?: GauntletResult; } -// @kern-source: types:491 +// @kern-source: types:501 export interface ConvergenceEntry { file: string; fn: string; @@ -625,7 +635,7 @@ export interface ConvergenceEntry { reason: string; } -// @kern-source: types:497 +// @kern-source: types:507 export interface ForgeJudgment { winner: string; strengths: { engineId: string; category: string; reason: string }[]; @@ -636,7 +646,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:504 +// @kern-source: types:514 export interface ForgeEvent { type: ForgeEventType; engineId?: string; @@ -685,7 +695,7 @@ export interface ForgeEventMap { export type ForgeEventCallback = (event: ForgeEvent) => void; -// @kern-source: types:543 +// @kern-source: types:553 export interface BrainstormBid { engineId: string; confidence: number; @@ -694,26 +704,26 @@ export interface BrainstormBid { score?: number; } -// @kern-source: types:550 +// @kern-source: types:560 export interface BrainstormGroup { members: string[]; representative: string; similarity: number; } -// @kern-source: types:555 +// @kern-source: types:565 export interface BrainstormDedupStatus { status: 'not-needed' | 'applied' | 'unavailable' | 'failed' | 'timed-out'; detail?: string; } -// @kern-source: types:559 +// @kern-source: types:569 export interface BrainstormSynthesisStatus { status: 'completed' | 'fallback'; detail?: string; } -// @kern-source: types:566 +// @kern-source: types:576 export interface PanelHealth { requested: number; responded: number; @@ -722,7 +732,7 @@ export interface PanelHealth { banner: string | null; } -// @kern-source: types:573 +// @kern-source: types:583 export interface BrainstormResult { question: string; bids: BrainstormBid[]; @@ -734,7 +744,7 @@ export interface BrainstormResult { panelHealth?: PanelHealth; } -// @kern-source: types:583 +// @kern-source: types:593 export interface BreakerArtifact { engineId: string; testScript: string; @@ -744,7 +754,7 @@ export interface BreakerArtifact { validated: boolean; } -// @kern-source: types:591 +// @kern-source: types:601 export interface GauntletResult { winnerId: string; breakerArtifacts: BreakerArtifact[]; @@ -757,7 +767,7 @@ export interface GauntletResult { patchPath?: string; } -// @kern-source: types:602 +// @kern-source: types:612 export interface CorpusEntry { forgeId: string; taskClass: TaskClass; @@ -767,7 +777,7 @@ export interface CorpusEntry { pattern?: string; } -// @kern-source: types:610 +// @kern-source: types:620 export interface GapPattern { pattern: string; taskClass: TaskClass; @@ -778,7 +788,7 @@ export interface GapPattern { skillPath?: string; } -// @kern-source: types:619 +// @kern-source: types:629 export interface Critique { file: string; lines: string; @@ -786,5 +796,5 @@ export interface Critique { minimalFix: string; } -// @kern-source: types:625 +// @kern-source: types:635 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 7c26521d8..6c4397c98 100644 --- a/packages/core/src/kern/models/types.kern +++ b/packages/core/src/kern/models/types.kern @@ -317,6 +317,16 @@ config name=AgonConfig 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=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/tests/unit/experience.test.ts b/tests/unit/experience.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..9d2f76f428a51fd783d8e5ca77e15e1aeb4d730d GIT binary patch literal 5357 zcmcIo-A)`g6z(;z&>P>)pl*hRkY3OdDWxQhl0ZagQ;DF+%$OaQS$kr8cG)1LJVc+N z50)qC=b!oAU7(Q~C5YI^_WAkFcg`6;Eu}K#hGa~qieIo1Vb>)~&4_SAZpo#RQxeU% zVcJALZSWwzLC8hLi|i1xiZhY2UkaICjj+y2t|e}Jj*};qIAN(&nRYvk@Ch?GHKWCG zX}A<_SNS#8UTM#0obD{W<5ONx#m&NPR?{i|^O?OAh6(drszd*{-8Axw3GB7^ow94= zb`~Wg?Q(Obwl+z&@VBJt6`e4>l@@$Ut8{C^1XI+oY%67&sx8)-ll-OOn~fBfnA(1_ zPhvS^N^$sNkG!SIa9aGl^z2gz-rR2PEh|L!pOW|nmQ3N!JrZ3?HDQr6faZo)e?z~V@+ z3tKcI@uoG~TFp%iU5bL1nrSl6Ed&u0MySY0id8}l+5T}%(j3P1jsk=Z!|~de;)uZb zYXX?$?1T6qf`dr3xtSRG3yT}~`JD(RK$`d%7g9_Rx9B=b6e}4u@$PuslaQ5x!20*? z4dFo06(^E{iHXU*2Cw1z)0~Q9b@-{GMGRAy4=|DG_uS-h^!3X>jqWyHPS!czyAtVD zDWM6QmS#bKB}A3a07>F;qluQ*84Ds9U`sBLw^LT5`M>+OQ6{QgcYjPQk{ka&_*f+} zcF6_cOf{**m5_5`f7LJ$POjtW@9TKP?gK@Ls8OxI(abmoFSlKq0g&2jMVDPBju#&l*D;)HUfBgz5|mkytAS7dDq&(n5O-4{xJc zwnC~KS6ebPQLM0JO-j_E(!fU-U?kw9UYCNc_ERl@^j%kcT6P$cD?$|w*Om!4B(jf7 ztI{{W2yW#QfSwd`!UfoV!jRVFgaOclzy<4kEM6#Z7gLsI;zo@=%dC|jCp&$~_5#PC zHGjq9WZMaLLgYz^i?TvsoR`UGreqK2P2BH@g7r2Pi+;j+np}}vmVkV-GVDKk58DI- zfMhWPia;&2DvTb1F5xRAHW_=K(aeze*Td(}et&)X@!5;xqmKv2N6%lr0K;-vC0%C& zdKK050;FOOjW>G_)zt)|8o9Y%z4(T>mZQzuH(a!>&>k6&RtSW?2DN&5`&5>%R;|D1 zB9n9MIBv29&uW#L*y@-}yRU|9UV=d_SwA1-9{0iGq`+UV?$}040%d~0PAIS)6y;J# zI2IZfJVm$KbR2Dq&~m8j7XByqKe^leJ4@T%{o70Hwfnap?thX8g)D>f>SW*-V5VM;IP;bz{p@u12xkYG0Li^e~U+0Ov0co=G!T8Lz zX9SZ>zg;F~xmoVN)oGPOr5X_!-|L3a$#r|-UMGy67wpjXz)#4n?TYQ6Mj6FsoXufX zM;nQHry>ZGtu0h3_Q-YPq+k=8)?Nzth^i?3o#*{Df=g_E{6=T&wd)<@K`0?`Ha<_R zPeLMvIijItmr!kUzC4DJuV4Ol%>mN?(Gh_qnH~7Z1f`=FCtuDiwh$(m-en6=N`gGV zT8+#-@)+)LJcJF8HjEWxK;QO8{xV;@ab44n-(OrYYxG-Q0Z&`vzF>u%cP!CW>4smE zgYI)#WSD(tOh6Zt+wRde`BQ4rd`+X^@pu*h8E@ja$0qo?g9n&o)A`&F5P?sZ-5h&c z?Iox-){Cxn?S!_?!GVGB;-gm>k)S&hvYOIGC__j}v8H=uU1jXl+qCH9G(Nkj9luxqV)>wrq2<3_ zB*B2c-KEyt15BZmi#R$v{PXSM$;-o|gTwPd>%J^pb1{-x8bT|B84%yb?mcI%-uK^= z`r%oA!ha6BPs)`?mNATGy_8wL&kpBPwBbg3(GG}#4flz{Ry+5FLdZ4z8%T6@D9%Ho O9O0VSsbk-;|NaGjQrvX_ literal 0 HcmV?d00001 From 0181752cfae7a9e0488df9bbb323915c2895818a Mon Sep 17 00:00:00 2001 From: "agon (KERN)" <292465531+KERN-Agon@users.noreply.github.com> Date: Thu, 16 Jul 2026 12:53:28 +0200 Subject: [PATCH 4/6] fix: project-scope experience precedent and harden the retrieval hot path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Full-roster review (6/6 engines) findings, each verified: (1) codex blocker — telemetry.json is global, so precedent from one repo could inject its task text into another repo's turn; run records now store a projectKey (resolved working dir) and retrieval filters on it, fail-closed for unscoped legacy records. (2) zai/claude/kimi — the new test file contained raw NUL/ESC bytes and diffed as binary; escaped to \u sequences. (3) agy — the prompt is tokenized once per retrieval, not once per episode (scoreTokenSetSimilarity). (4) agy/zai/kimi/claude — the ledger read is cached on (mtimeMs,size) so unchanged telemetry.json is parsed once, and append() trims to MAX_LEDGER_RUNS (2000) so the file cannot grow unbounded. (5) kimi — tokenization is unicode-aware (\p{L}\p{N}) so non-English prompts score. (6) codex prompt-replay: summaries are the user's own local prompts (same trust domain); quote normalization added for rendering hygiene. Also: buildExperienceBlock rewritten loop-style to satisfy the kern:self-coverage var-bad-expr ratchet (every blocker category sits exactly at its cap). ⚔️ Forged by [Agon](https://github.com/KERNlang/agon) Co-Authored-By: agon (KERN) <292465531+KERN-Agon@users.noreply.github.com> --- packages/cli/src/generated/cesar/brain.ts | 6 +- .../cli/src/generated/cesar/experience.ts | 76 +++++++++++------- packages/cli/src/kern/cesar/brain.kern | 6 +- packages/cli/src/kern/cesar/experience.kern | 54 ++++++++----- packages/cli/src/telemetry/index.ts | 47 +++++++++-- tests/unit/experience.test.ts | Bin 5357 -> 6136 bytes 6 files changed, 129 insertions(+), 60 deletions(-) diff --git a/packages/cli/src/generated/cesar/brain.ts b/packages/cli/src/generated/cesar/brain.ts index 5c2375135..c1f6a3fc5 100644 --- a/packages/cli/src/generated/cesar/brain.ts +++ b/packages/cli/src/generated/cesar/brain.ts @@ -36,7 +36,7 @@ import { shouldGroundInput, buildGroundingBlock } from './grounding.js'; import { episodesFromRunRecords, retrieveExperience, buildExperienceBlock, experienceRetrievalOptions } from './experience.js'; -import { recentRunRecords } from '../../telemetry/index.js'; +import { recentRunRecords, currentProjectKey } from '../../telemetry/index.js'; import { readCesarToolReliability, formatCesarReliabilityLine, shouldDowngradeCesarToolWork, buildWhatHappenedSummary } from './reliability.js'; @@ -917,7 +917,9 @@ export async function handleCesarBrain(input: string, dispatch: Dispatch, ctx: H if ((config as any).cesarExperience !== false && shouldGroundInput(input)) { try { const _expOpts = experienceRetrievalOptions(config); - const _expEpisodes = episodesFromRunRecords(recentRunRecords(_expOpts.window)); + // 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 */ } diff --git a/packages/cli/src/generated/cesar/experience.ts b/packages/cli/src/generated/cesar/experience.ts index 41d3c9fd2..b451db697 100644 --- a/packages/cli/src/generated/cesar/experience.ts +++ b/packages/cli/src/generated/cesar/experience.ts @@ -9,15 +9,16 @@ export interface ExperienceEpisode { success: boolean; durationMs: number; timestamp: number; + projectKey: string; } -// @kern-source: experience:19 +// @kern-source: experience:20 export interface ExperienceMatch { episode: ExperienceEpisode; similarity: number; } -// @kern-source: experience:23 +// @kern-source: experience:24 export interface ExperienceRetrievalOptions { minSimilarity: number; minEpisodes: number; @@ -28,13 +29,13 @@ export interface ExperienceRetrievalOptions { /** * 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:29 +// @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:32 +// @kern-source: experience:33 export function summarizeIntentForEpisode(intent: string): string { const collapsed = String(intent ?? '') .replace(/[\u0000-\u001f\u007f]+/g, ' ') @@ -45,24 +46,22 @@ export function summarizeIntentForEpisode(intent: string): string { } /** - * Lowercased alphanumeric tokens, ≥3 chars, stopwords removed, deduplicated — the unit of Jaccard similarity. + * 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:43 +// @kern-source: experience:44 export function tokenizeExperienceText(text: string): Set { const tokens = String(text ?? '') .toLowerCase() - .split(/[^a-z0-9]+/) + .split(/[^\p{L}\p{N}]+/u) .filter((t) => t.length >= 3 && !EXPERIENCE_STOPWORDS.has(t)); return new Set(tokens); } /** - * Token-set Jaccard similarity in [0,1]. Deterministic and cheap — precedent retrieval must never cost a sidecar spawn on the hot turn path. + * Jaccard over pre-tokenized sets — retrieveExperience tokenizes the prompt ONCE and reuses the set across every episode (review: agy). */ -// @kern-source: experience:53 -export function scoreExperienceSimilarity(a: string, b: string): number { - const ta = tokenizeExperienceText(a); - const tb = tokenizeExperienceText(b); +// @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++; } @@ -70,32 +69,43 @@ export function scoreExperienceSimilarity(a: string, b: string): number { 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:65 +// @kern-source: experience:70 export function experienceRetrievalOptions(config: any): ExperienceRetrievalOptions { - const num = (v: unknown, fallback: number) => { - const n = Number(v); - return Number.isFinite(n) && n > 0 ? n : fallback; - }; + 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, num(config?.cesarExperienceMinSimilarity, 0.2)), - minEpisodes: Math.floor(num(config?.cesarExperienceMinEpisodes, 3)), - topK: Math.floor(num(config?.cesarExperienceTopK, 3)), - window: Math.floor(num(config?.cesarExperienceWindow, 200)), + 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). + * 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:80 -export function episodesFromRunRecords(records: any[]): ExperienceEpisode[] { +// @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'), @@ -104,6 +114,7 @@ export function episodesFromRunRecords(records: any[]): ExperienceEpisode[] { 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; @@ -112,11 +123,12 @@ export function episodesFromRunRecords(records: any[]): ExperienceEpisode[] { /** * 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:100 +// @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 = scoreExperienceSimilarity(prompt, e.intentSummary); + const similarity = scoreTokenSetSimilarity(promptTokens, tokenizeExperienceText(e.intentSummary)); if (similarity >= opts.minSimilarity) scored.push({ episode: e, similarity }); } if (scored.length < opts.minEpisodes) return []; @@ -127,16 +139,20 @@ export function retrieveExperience(prompt: string, episodes: ExperienceEpisode[] /** * 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:113 +// @kern-source: experience:123 export function buildExperienceBlock(matches: ExperienceMatch[]): string | null { if (!matches.length) return null; - const lines = matches.map((m) => { + 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`) : ''; - return `- "${e.intentSummary}" — mode=${e.mode}${winner}, ${outcome}${dur} (similarity ${m.similarity.toFixed(2)})`; - }); + // 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/cesar/brain.kern b/packages/cli/src/kern/cesar/brain.kern index 181d747ed..ecbd3756a 100644 --- a/packages/cli/src/kern/cesar/brain.kern +++ b/packages/cli/src/kern/cesar/brain.kern @@ -16,7 +16,7 @@ import from="./escalation.js" names="fireQuickNero,fireNero,fireAdvisor,handleSe 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="../../telemetry/index.js" names="recentRunRecords" +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" @@ -872,7 +872,9 @@ ${reviewFollowup.prompt}`; if ((config as any).cesarExperience !== false && shouldGroundInput(input)) { try { const _expOpts = experienceRetrievalOptions(config); - const _expEpisodes = episodesFromRunRecords(recentRunRecords(_expOpts.window)); + // 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 */ } diff --git a/packages/cli/src/kern/cesar/experience.kern b/packages/cli/src/kern/cesar/experience.kern index cc6ba38ef..6e30ea97f 100644 --- a/packages/cli/src/kern/cesar/experience.kern +++ b/packages/cli/src/kern/cesar/experience.kern @@ -15,6 +15,7 @@ interface name=ExperienceEpisode export=true 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 @@ -41,20 +42,18 @@ fn name=summarizeIntentForEpisode params="intent:string" returns=string export=t >>> fn name=tokenizeExperienceText params="text:string" returns="Set" export=true - doc "Lowercased alphanumeric tokens, ≥3 chars, stopwords removed, deduplicated — the unit of Jaccard similarity." + 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(/[^a-z0-9]+/) + .split(/[^\p{L}\p{N}]+/u) .filter((t) => t.length >= 3 && !EXPERIENCE_STOPWORDS.has(t)); return new Set(tokens); >>> -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." +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 <<< - const ta = tokenizeExperienceText(a); - const tb = tokenizeExperienceText(b); if (ta.size === 0 || tb.size === 0) return 0; let shared = 0; for (const t of ta) { if (tb.has(t)) shared++; } @@ -62,28 +61,37 @@ fn name=scoreExperienceSimilarity params="a:string, b:string" returns=number exp 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 num = (v: unknown, fallback: number) => { - const n = Number(v); - return Number.isFinite(n) && n > 0 ? n : fallback; - }; + 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, num(config?.cesarExperienceMinSimilarity, 0.2)), - minEpisodes: Math.floor(num(config?.cesarExperienceMinEpisodes, 3)), - topK: Math.floor(num(config?.cesarExperienceTopK, 3)), - window: Math.floor(num(config?.cesarExperienceWindow, 200)), + 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[]" 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)." +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'), @@ -92,6 +100,7 @@ fn name=episodesFromRunRecords params="records:any[]" returns="ExperienceEpisode 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; @@ -100,9 +109,10 @@ fn name=episodesFromRunRecords params="records:any[]" returns="ExperienceEpisode 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 = scoreExperienceSimilarity(prompt, e.intentSummary); + const similarity = scoreTokenSetSimilarity(promptTokens, tokenizeExperienceText(e.intentSummary)); if (similarity >= opts.minSimilarity) scored.push({ episode: e, similarity }); } if (scored.length < opts.minEpisodes) return []; @@ -114,13 +124,17 @@ fn name=buildExperienceBlock params="matches:ExperienceMatch[]" returns="string 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 = matches.map((m) => { + 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`) : ''; - return `- "${e.intentSummary}" — mode=${e.mode}${winner}, ${outcome}${dur} (similarity ${m.similarity.toFixed(2)})`; - }); + // 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/telemetry/index.ts b/packages/cli/src/telemetry/index.ts index 6f2893dfc..53fa67c42 100644 --- a/packages/cli/src/telemetry/index.ts +++ b/packages/cli/src/telemetry/index.ts @@ -3,10 +3,10 @@ // 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'; @@ -48,8 +48,14 @@ export interface RunRecord { 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[]; @@ -117,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: [] }; } @@ -136,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. */ @@ -192,6 +215,17 @@ 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[] { @@ -212,6 +246,7 @@ export function recordRun(result: OrchestrationResult): RunRecord { 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/tests/unit/experience.test.ts b/tests/unit/experience.test.ts index 9d2f76f428a51fd783d8e5ca77e15e1aeb4d730d..1cbd9bd7a3ec87ea19e245aa5739ba4dbd62bc96 100644 GIT binary patch delta 659 zcmaJGgr>E(iU;)~ew;)+sE2JjO=FYYb$6=<5&aMGe<&3H zkatfi=*>x*Y87=a`|)PpyqUL8&YO3CP^w5JPS7{om^8ui6iVSSYp_>(EzKSEYqU%k zvqQZ#*qwNo3T6O8V_Vu65;YhBgoDDPKYHR4QFakdk8r!Luu6(|)!XcH-)+NNEjij_ARDkJod1(Wv23qc@@b^Ys zFkwu}SS*`Rhb#9_o=w0=Q=8p~-=4tO4cV`_6n$1C~f3$6_e zhm+ow7q5%SxL?*d!$`c4L&=mThw=dw1m2bh`y;Q&VU<7+QEJG%OiVZP+xA%$q0%6vfskiEA`?ZBQ_XuS Wl%{Lt`31Y^$N8bSF?gSSnfw42=i7V$ delta 32 ocmeyN|5kHDIMe0=rjJal3?&(<(whrdw{lG0DayZDM(_?B0MpD1*8l(j From bd89c268d3c0dc5f39b2724b66b1ff07b949357f Mon Sep 17 00:00:00 2001 From: "agon (KERN)" <292465531+KERN-Agon@users.noreply.github.com> Date: Thu, 16 Jul 2026 13:05:24 +0200 Subject: [PATCH 5/6] =?UTF-8?q?feat:=20delegation=20reflex=20=E2=80=94=20s?= =?UTF-8?q?tructural=20fan-out=20advisory=20for=20list-shaped=20tasks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First slice of the 6/6-engine delegation-reflex brainstorm consensus: a pure veto-first scorer (cesar/delegation-reflex.kern) detects prompts with ≥3 explicit list items and NO sequential-dependency, shared-artifact, or small-task markers, and injects an in-prompt structural note that the work may decompose. Surfacing stays with the existing suggestion flow — Cesar may offer [SUGGEST:agent-team], promptDelegation confirms with the user, nothing ever auto-spawns. False positives cost one advisory line by construction: prose, ordered work, 2-item lists, tiny items, and prompts under 80 chars never trigger. Config cesarDelegationReflex (default on), fail-open injection beside the experience block. 10 unit tests. ⚔️ Forged by [Agon](https://github.com/KERNlang/agon) Co-Authored-By: agon (KERN) <292465531+KERN-Agon@users.noreply.github.com> --- README.md | 4 + packages/cli/src/generated/cesar/brain.ts | 22 +++++- .../src/generated/cesar/delegation-reflex.ts | 68 ++++++++++++++++ packages/cli/src/kern/cesar/brain.kern | 13 ++++ .../cli/src/kern/cesar/delegation-reflex.kern | 58 ++++++++++++++ packages/core/src/generated/models/types.ts | 52 +++++++------ packages/core/src/kern/models/types.kern | 2 + tests/unit/delegation-reflex.test.ts | 77 +++++++++++++++++++ 8 files changed, 267 insertions(+), 29 deletions(-) create mode 100644 packages/cli/src/generated/cesar/delegation-reflex.ts create mode 100644 packages/cli/src/kern/cesar/delegation-reflex.kern create mode 100644 tests/unit/delegation-reflex.test.ts diff --git a/README.md b/README.md index 39feabf68..28186dad1 100644 --- a/README.md +++ b/README.md @@ -596,6 +596,10 @@ 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. diff --git a/packages/cli/src/generated/cesar/brain.ts b/packages/cli/src/generated/cesar/brain.ts index c1f6a3fc5..e22d4e3c6 100644 --- a/packages/cli/src/generated/cesar/brain.ts +++ b/packages/cli/src/generated/cesar/brain.ts @@ -36,6 +36,8 @@ 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'; @@ -62,7 +64,7 @@ import { consumeCesarPlanControlSignals } from './plan-control-signals.js'; import { hostNowIso, hostWaitForInteractiveChoice } from '../lib/kern-host.js'; -// @kern-source: brain:33 +// @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 @@ -95,7 +97,7 @@ export async function commitTurnAndDelegate(pendingDel: PendingDelegation, input return { delegated: false, responded: true, decisionReason: 'delegation-cancelled', ...telemetry ?? {} }; } -// @kern-source: brain:60 +// @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. @@ -126,10 +128,10 @@ export async function commitTurnAndSuggest(suggestion: {action:string, rest?:str return { delegated: false, responded: true, decisionReason: 'suggestion-cancelled', ...telemetry ?? {} }; } -// @kern-source: brain:85 +// @kern-source: brain:86 export const _noBriefNudged: WeakMap = new WeakMap(); -// @kern-source: brain:87 +// @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(); @@ -925,6 +927,18 @@ export async function handleCesarBrain(input: string, dispatch: Dispatch, ctx: H } 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..4df3156d5 --- /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. + */ +// @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[\s\S]*\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(?:lockfile|package\.json|package-lock|migration|schema|generated|shared (?:type|module|config|state)|same file)\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:agent-team] (worktree-isolated, 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/brain.kern b/packages/cli/src/kern/cesar/brain.kern index ecbd3756a..c757f043e 100644 --- a/packages/cli/src/kern/cesar/brain.kern +++ b/packages/cli/src/kern/cesar/brain.kern @@ -16,6 +16,7 @@ import from="./escalation.js" names="fireQuickNero,fireNero,fireAdvisor,handleSe 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" @@ -880,6 +881,18 @@ ${reviewFollowup.prompt}`; } 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..6711f88c5 --- /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[\s\S]*\bthen)\b/i }} + doc "Sequential-dependency markers: ordered work must never be pitched as parallel." + +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(?:lockfile|package\.json|package-lock|migration|schema|generated|shared (?:type|module|config|state)|same file)\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:agent-team] (worktree-isolated, 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/core/src/generated/models/types.ts b/packages/core/src/generated/models/types.ts index 56e70eb91..052486e41 100644 --- a/packages/core/src/generated/models/types.ts +++ b/packages/core/src/generated/models/types.ts @@ -1,7 +1,5 @@ // @generated by kern v4.5.0 — DO NOT EDIT. Source: src/kern/models/types.kern -// @kern-source: types:515 -// @kern-source: types:516 // @kern-source: types:517 // @kern-source: types:518 // @kern-source: types:519 @@ -37,6 +35,8 @@ // @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'; @@ -353,6 +353,7 @@ export interface AgonConfig { cesarExperienceMinEpisodes: number; cesarExperienceTopK: number; cesarExperienceWindow: number; + cesarDelegationReflex?: boolean; cesarStreamingXmlTools?: boolean; campfireObserverStrategy?: 'lead-first'|'all-respond'; hooks: Record>; @@ -466,6 +467,7 @@ export const DEFAULT_AGON_CONFIG: Required = { cesarExperienceMinEpisodes: 0, cesarExperienceTopK: 0, cesarExperienceWindow: 0, + cesarDelegationReflex: true, cesarStreamingXmlTools: true, campfireObserverStrategy: 'lead-first', hooks: {} as any, @@ -496,7 +498,7 @@ export const DEFAULT_AGON_CONFIG: Required = { browserExtensionIds: [], }; -// @kern-source: types:381 +// @kern-source: types:383 export interface ScoutBid { engineId: string; confidence: number; @@ -507,7 +509,7 @@ export interface ScoutBid { needsCompetition: boolean; } -// @kern-source: types:390 +// @kern-source: types:392 export interface RoutingDecision { action: 'chat'|'build'|'pipeline'|'campfire'|'forge'|'brainstorm'|'tribunal'|'agent'|'team-agent'; leadEngine: string; @@ -519,14 +521,14 @@ export interface RoutingDecision { bids: ScoutBid[]; } -// @kern-source: types:400 +// @kern-source: types:402 export interface CampfireMessage { engineId: string; content: string; isLead: boolean; } -// @kern-source: types:405 +// @kern-source: types:407 export interface ForgeOptions { task: string; fitnessCmd: string; @@ -552,7 +554,7 @@ export interface ForgeOptions { synthEngine?: string; } -// @kern-source: types:434 +// @kern-source: types:436 export interface EngineResult { engineId: string; pass: boolean; @@ -574,14 +576,14 @@ export interface EngineResult { syntaxInvalidFiles?: string[]; } -// @kern-source: types:454 +// @kern-source: types:456 export interface SkippedEngine { engineId: string; status: string; reason?: string; } -// @kern-source: types:459 +// @kern-source: types:461 export interface DispatchMetric { engineId: string; phase: 'stage1'|'stage1-fallback'|'stage2-scout'|'stage2-scout-fallback'|'stage2-follower'|'stage2-fallback'|'synthesis'|'gauntlet'; @@ -595,7 +597,7 @@ export interface DispatchMetric { tokens?: {prompt:number, response:number, costUsd:number}; } -// @kern-source: types:471 +// @kern-source: types:473 export interface ForgeManifest { forgeId: string; forgeDir: string; @@ -627,7 +629,7 @@ export interface ForgeManifest { gauntlet?: GauntletResult; } -// @kern-source: types:501 +// @kern-source: types:503 export interface ConvergenceEntry { file: string; fn: string; @@ -635,7 +637,7 @@ export interface ConvergenceEntry { reason: string; } -// @kern-source: types:507 +// @kern-source: types:509 export interface ForgeJudgment { winner: string; strengths: { engineId: string; category: string; reason: string }[]; @@ -646,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:514 +// @kern-source: types:516 export interface ForgeEvent { type: ForgeEventType; engineId?: string; @@ -695,7 +697,7 @@ export interface ForgeEventMap { export type ForgeEventCallback = (event: ForgeEvent) => void; -// @kern-source: types:553 +// @kern-source: types:555 export interface BrainstormBid { engineId: string; confidence: number; @@ -704,26 +706,26 @@ export interface BrainstormBid { score?: number; } -// @kern-source: types:560 +// @kern-source: types:562 export interface BrainstormGroup { members: string[]; representative: string; similarity: number; } -// @kern-source: types:565 +// @kern-source: types:567 export interface BrainstormDedupStatus { status: 'not-needed' | 'applied' | 'unavailable' | 'failed' | 'timed-out'; detail?: string; } -// @kern-source: types:569 +// @kern-source: types:571 export interface BrainstormSynthesisStatus { status: 'completed' | 'fallback'; detail?: string; } -// @kern-source: types:576 +// @kern-source: types:578 export interface PanelHealth { requested: number; responded: number; @@ -732,7 +734,7 @@ export interface PanelHealth { banner: string | null; } -// @kern-source: types:583 +// @kern-source: types:585 export interface BrainstormResult { question: string; bids: BrainstormBid[]; @@ -744,7 +746,7 @@ export interface BrainstormResult { panelHealth?: PanelHealth; } -// @kern-source: types:593 +// @kern-source: types:595 export interface BreakerArtifact { engineId: string; testScript: string; @@ -754,7 +756,7 @@ export interface BreakerArtifact { validated: boolean; } -// @kern-source: types:601 +// @kern-source: types:603 export interface GauntletResult { winnerId: string; breakerArtifacts: BreakerArtifact[]; @@ -767,7 +769,7 @@ export interface GauntletResult { patchPath?: string; } -// @kern-source: types:612 +// @kern-source: types:614 export interface CorpusEntry { forgeId: string; taskClass: TaskClass; @@ -777,7 +779,7 @@ export interface CorpusEntry { pattern?: string; } -// @kern-source: types:620 +// @kern-source: types:622 export interface GapPattern { pattern: string; taskClass: TaskClass; @@ -788,7 +790,7 @@ export interface GapPattern { skillPath?: string; } -// @kern-source: types:629 +// @kern-source: types:631 export interface Critique { file: string; lines: string; @@ -796,5 +798,5 @@ export interface Critique { minimalFix: string; } -// @kern-source: types:635 +// @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 6c4397c98..9005ae286 100644 --- a/packages/core/src/kern/models/types.kern +++ b/packages/core/src/kern/models/types.kern @@ -327,6 +327,8 @@ config name=AgonConfig 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/tests/unit/delegation-reflex.test.ts b/tests/unit/delegation-reflex.test.ts new file mode 100644 index 000000000..9c4efd6ca --- /dev/null +++ b/tests/unit/delegation-reflex.test.ts @@ -0,0 +1,77 @@ +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', () => { + const advisory = buildDelegationAdvisory(assessDelegationShape(listTask)); + expect(advisory).toContain('[DELEGATION SHAPE]'); + expect(advisory).toContain('3 explicit list items'); + expect(advisory).toContain('SUGGEST:agent-team'); + expect(advisory).toContain('ignore this note'); + }); + + it('returns null for a none decision', () => { + expect(buildDelegationAdvisory(assessDelegationShape('fix the typo in the readme'))).toBeNull(); + }); +}); From 2dffa279abbc209f53915d66afb79f2b31dcc76d Mon Sep 17 00:00:00 2001 From: "agon (KERN)" <292465531+KERN-Agon@users.noreply.github.com> Date: Thu, 16 Jul 2026 13:13:41 +0200 Subject: [PATCH 6/6] fix: correct the suggestion marker and delegation-reflex veto regexes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Full-roster review (6/6 engines) fixes: (1) kimi — the advisory pointed Cesar at [SUGGEST:agent-team], a marker parseSuggestion/routing do not recognize; the canonical action is team-agent (team- prefix + agent). (2) agy — shared-state vetoes now match plural forms (schemas, lockfiles, migrations). (3) kimi — the first…then sequential veto is windowed to one sentence-ish span so unrelated far-apart mentions no longer over-veto. Recorded as by-design: multiline list items measured by their first line (under-triggering is the stated cost asymmetry) and the shouldGroundInput reuse (generic real-prose predicate, and the reflex carries its own 80-char floor). ⚔️ Forged by [Agon](https://github.com/KERNlang/agon) Co-Authored-By: agon (KERN) <292465531+KERN-Agon@users.noreply.github.com> --- .../src/generated/cesar/delegation-reflex.ts | 8 ++++---- .../cli/src/kern/cesar/delegation-reflex.kern | 8 ++++---- tests/unit/delegation-reflex.test.ts | 18 ++++++++++++++++-- 3 files changed, 24 insertions(+), 10 deletions(-) diff --git a/packages/cli/src/generated/cesar/delegation-reflex.ts b/packages/cli/src/generated/cesar/delegation-reflex.ts index 4df3156d5..edbf0fe47 100644 --- a/packages/cli/src/generated/cesar/delegation-reflex.ts +++ b/packages/cli/src/generated/cesar/delegation-reflex.ts @@ -9,10 +9,10 @@ export interface DelegationShape { } /** - * Sequential-dependency markers: ordered work must never be pitched as parallel. + * 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[\s\S]*\bthen)\b/i; +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. @@ -24,7 +24,7 @@ export const DELEGATION_SMALL_RE: RegExp = /\b(?:quick(?:ly)?|just|tiny|small|si * 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(?:lockfile|package\.json|package-lock|migration|schema|generated|shared (?:type|module|config|state)|same file)\b/i; +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. @@ -64,5 +64,5 @@ export function assessDelegationShape(input: string): DelegationShape { // @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:agent-team] (worktree-isolated, user confirms before anything spawns) or background jobs. If they share state or order matters, ignore this note entirely — do not mention it.`; + 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/delegation-reflex.kern b/packages/cli/src/kern/cesar/delegation-reflex.kern index 6711f88c5..84eec9812 100644 --- a/packages/cli/src/kern/cesar/delegation-reflex.kern +++ b/packages/cli/src/kern/cesar/delegation-reflex.kern @@ -14,13 +14,13 @@ interface name=DelegationShape export=true 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[\s\S]*\bthen)\b/i }} - doc "Sequential-dependency markers: ordered work must never be pitched as parallel." +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(?:lockfile|package\.json|package-lock|migration|schema|generated|shared (?:type|module|config|state)|same file)\b/i }} +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 }} @@ -54,5 +54,5 @@ fn name=buildDelegationAdvisory params="shape:DelegationShape" returns="string | 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:agent-team] (worktree-isolated, user confirms before anything spawns) or background jobs. If they share state or order matters, ignore this note entirely — do not mention it.`; + 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/tests/unit/delegation-reflex.test.ts b/tests/unit/delegation-reflex.test.ts index 9c4efd6ca..3be55891e 100644 --- a/tests/unit/delegation-reflex.test.ts +++ b/tests/unit/delegation-reflex.test.ts @@ -63,14 +63,28 @@ describe('assessDelegationShape — veto-first fan-out detection', () => { }); describe('buildDelegationAdvisory', () => { - it('renders the advisory for a suggest decision', () => { + 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'); - expect(advisory).toContain('SUGGEST:agent-team'); + // '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(); });