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/2] =?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/2] 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);