Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: <question>`; 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: <claim>` 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

Expand Down
18 changes: 14 additions & 4 deletions packages/cli/src/generated/commands/conquer.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -194,6 +196,14 @@ export const conquerCommand: any = defineCommand({
if (branchRes.exitCode !== 0 || !headBranch || headBranch === 'HEAD') {
info(dim('Could not resolve a branch to push (detached HEAD?). The builder\'s changes are uncommitted in the tree — review + commit + push manually.'));
process.exitCode = 1;
} else if (isProtectedPushBranch(headBranch, config)) {
// Unattended pushes never land on a protected branch (main/master by
// default, protectedPushBranches to override) — a conquer worktree
// normally sits on its own conquer/<slug> 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 +
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/generated/commands/daemon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -545,7 +545,7 @@ export async function startDaemon(foreground: boolean): Promise<void> {
if (spawnErr.msg) break;
info0 = readPidFile();
if (info0 && isProcessAlive(info0.pid)) break;
await new Promise<void>((r) => { setTimeout(r, 50); });
await new Promise<void>((r) => { setTimeout(r, 50); });
}

if (spawnErr.msg) {
Expand Down
19 changes: 15 additions & 4 deletions packages/cli/src/generated/handlers/conquer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
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<void> {
const cqAbort = new AbortController();
try {
ensureAgonHome();
Expand All @@ -42,7 +42,18 @@ export async function handleConquer(task: string, dispatch: Dispatch, ctx: Handl
advisors = resolved.filter((id) => id !== builder);
}

// CLI parity: `agon conquer` rejects --max-turns < 1 instead of silently
// running 40 turns the user tried to cap at 0.
if (opts?.maxTurns !== undefined && (!Number.isFinite(opts.maxTurns) || opts.maxTurns < 1)) {
dispatch({ type: 'error', message: `Invalid --max-turns: ${opts.maxTurns}` });
return;
}
const maxTurns = opts?.maxTurns && opts.maxTurns > 0 ? opts.maxTurns : 40;
// CLI-parity knobs (previously hardcoded in this handler — review follow-up):
// defaults mirror `agon conquer` (gate 1800s, per-builder-turn 600s, no wall cap).
const gateTimeoutSec = opts?.gateTimeoutSec && Number.isFinite(opts.gateTimeoutSec) && opts.gateTimeoutSec > 0 ? Math.floor(opts.gateTimeoutSec) : 1800;
const turnTimeoutSec = opts?.turnTimeoutSec && Number.isFinite(opts.turnTimeoutSec) && opts.turnTimeoutSec > 0 ? Math.floor(opts.turnTimeoutSec) : 600;
const maxWallClockMs = opts?.maxHours && Number.isFinite(opts.maxHours) && opts.maxHours > 0 ? Math.round(opts.maxHours * 3_600_000) : 0;
const outputDir = join(RUNS_DIR, `conquer-${Date.now()}`);
mkdirSync(outputDir, { recursive: true });
let isolation;
Expand Down Expand Up @@ -72,7 +83,7 @@ export async function handleConquer(task: string, dispatch: Dispatch, ctx: Handl
const evaluateDone = async (_claim: string) => {
try {
const diffRes = await spawnWithTimeout({ command: 'git', args: ['diff'], cwd: worktreeCwd, timeout: 30_000 });
const gateRes = await spawnWithTimeout({ command: 'sh', args: ['-c', gate], cwd: worktreeCwd, timeout: 1_800_000 });
const gateRes = await spawnWithTimeout({ command: 'sh', args: ['-c', gate], cwd: worktreeCwd, timeout: gateTimeoutSec * 1000 });
return { diff: String(diffRes.stdout ?? ''), gateOk: gateRes.exitCode === 0, oracleTampered: false };
} catch (err) {
dispatch({ type: 'info', message: `done-check gate errored: ${err instanceof Error ? err.message : String(err)} — treating as not-done.` });
Expand All @@ -84,8 +95,8 @@ export async function handleConquer(task: string, dispatch: Dispatch, ctx: Handl
try {
result = await runConquer({
task, builderEngine: builder, advisorEngines: advisors,
registry: ctx.registry, adapter: ctx.adapter, timeout: 600, outputDir, cwd: worktreeCwd, gate,
caps: { maxTurns, maxWallClockMs: 0 },
registry: ctx.registry, adapter: ctx.adapter, timeout: turnTimeoutSec, outputDir, cwd: worktreeCwd, gate,
caps: { maxTurns, maxWallClockMs },
evaluateDone,
onTurn: (t: ConquerTurn) => dispatch({ type: 'info', message: `turn ${t.n} · ${t.action}${t.action === 'consult' || t.action === 'done-check' ? ` — ${t.detail.slice(0, 80)}` : ''}` }),
signal: cqAbort.signal,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down
Loading