Skip to content
20 changes: 19 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -390,7 +390,25 @@ Also available as interactive `/conquer` in the REPL (with full CLI flag parity:

Launching `agon` starts a powerful terminal REPL equipped with native scrollback, command history, and a file rail.

Available commands include: `/forge`, `/synthesis`, `/brainstorm`, `/tribunal`, `/campfire`, `/pipeline`, `/review`, `/agent`, `/speculate`, `/team-forge`, `/status`, `/leaderboard`, `/history`, `/config`, `/plan`, `/models`, `/engines`, `/doctor`, `/help`, and `/exit`.
Available commands include: `/forge`, `/synthesis`, `/brainstorm`, `/tribunal`, `/campfire`, `/pipeline`, `/review`, `/agent`, `/speculate`, `/team-forge`, `/status`, `/leaderboard`, `/history`, `/config`, `/plan`, `/mode`, `/permissions`, `/models`, `/engines`, `/doctor`, `/help`, and `/exit`.

### Permission modes

Agon uses a Claude-Code-style permission mode, cycled with **Shift+Tab** (or `/mode`) and always visible in the footer:

| Mode | Behavior |
|---|---|
| `ask` | Prompts before file edits and mutating commands. Read-only tools and commands run freely. |
| `auto-edit` | Workspace file edits are auto-approved; Bash mutations still prompt. (Default — migrated from the legacy `smart` mode.) |
| `auto` (AUTO) | Workspace autonomy. Boundaries still prompt: pushes, publishing, deployments, workspace escapes, and deny rules always win. |

Permission prompts offer **Yes / Yes for this session / Always / No / Never**. *Always* persists a scoped rule such as `Bash(git push:*)` into `permissions.allow` (never a bare verb); *Never* persists a deny rule. Inspect and edit rules with `/permissions`, `/permissions add allow|deny <rule>`, and `/permissions remove <rule>`. Delegated engine runs (forge, tribunal, agent teams) execute at least at `auto-edit` so multi-engine dispatches don't queue dozens of file-edit prompts — their Bash mutations still prompt once per pattern.

### Self-verifying turns

When Cesar finishes a turn that edited files, Agon doesn't take "done" on faith. If the permission posture already allows the project's verification gate (the `test`/`typecheck` script from `package.json`, or a `fitness:` line in your project brief), **the harness runs the gate itself**: a green run confirms the claim; a red run feeds the real exit code and output tail straight back to Cesar as a continuation so it fixes actual failures — up to `cesarGateAutoRunLimit` runs per turn (default 3), each re-run gated on fresh edits since the last one. A turn that still ends with a red gate is flagged loudly rather than presented as done.

Note the posture semantics: a gate command classified read-only (like plain `npm test`) auto-runs in **every** mode, because the resolver lets read-only commands run freely — the same command Cesar could already execute unprompted. Mutating gate commands (compound `&&` gates, scripts the classifier can't clear) auto-run only in `auto` mode or under a covering allow rule; gates with output redirection are additionally refused outside `auto`. Everything else falls back to the previous behavior: a one-time nudge asking Cesar to verify. Tune with `cesarGateAutoRun` (on by default), `cesarGateAutoRunLimit`, `cesarGateTimeoutSec`, and `cesarGateOutputTailChars`.

**Example Session:**
```text
Expand Down
287 changes: 182 additions & 105 deletions packages/cli/src/generated/cesar/brain.ts

Large diffs are not rendered by default.

163 changes: 163 additions & 0 deletions packages/cli/src/generated/cesar/gate-runner.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
// @generated by kern v4.5.0 — DO NOT EDIT. Source: src/kern/cesar/gate-runner.kern

import { spawnWithTimeout } from '@kernlang/agon-core';

import type { DiscoveredGate } from '@kernlang/agon-core';

import { resolvePermissionDecision, resolveAgonPermissionMode } from './permission-resolver.js';

// @kern-source: gate-runner:14
export interface GateRunResult {
ok: boolean;
exitCode: number;
outputTail: string;
durationMs: number;
timedOut: boolean;
}

// @kern-source: gate-runner:21
export interface GateAutoRunSnapshot {
gate?: DiscoveredGate|undefined;
config: any;
waived: boolean;
alreadyVerified: boolean;
wroteWork: boolean;
runsSoFar: number;
permitted: boolean;
}

/**
* How many harness-side gate executions one turn may spend (config cesarGateAutoRunLimit, default 3 — headroom for fail → fix → pass). Clamped to [0,10]; 0 disables via the limit.
*/
// @kern-source: gate-runner:30
export function gateAutoRunLimit(config: any): number {
const raw = Number(config?.cesarGateAutoRunLimit ?? 3);
if (!Number.isFinite(raw)) return 3;
return Math.max(0, Math.min(10, Math.floor(raw)));
}

/**
* Wall-clock budget for one gate execution in ms (config cesarGateTimeoutSec, default 300s, capped at 3600s).
*/
// @kern-source: gate-runner:38
export function gateTimeoutMs(config: any): number {
const raw = Number(config?.cesarGateTimeoutSec ?? 300);
if (!Number.isFinite(raw) || raw <= 0) return 300 * 1000;
return Math.min(3600, Math.floor(raw)) * 1000;
}

/**
* How much trailing gate output is fed back to the engine on failure (config cesarGateOutputTailChars, default 2000, clamped to [200,20000]).
*/
// @kern-source: gate-runner:46
export function gateOutputTailChars(config: any): number {
const raw = Number(config?.cesarGateOutputTailChars ?? 2000);
if (!Number.isFinite(raw)) return 2000;
return Math.max(200, Math.min(20000, Math.floor(raw)));
}

/**
* Pure eligibility check for a harness-side gate run at a done-claim. Requires: feature on (cesarGateAutoRun !== false), a discovered gate with matchers, no session waiver, not already verified this turn, real write-work, runs-so-far under the limit, and a permission posture that already allows the command (snapshot.permitted — computed by gateAutoRunPermitted so this stays pure/testable).
*/
// @kern-source: gate-runner:54
export function shouldAutoRunGate(snapshot: GateAutoRunSnapshot): boolean {
const cfg = snapshot.config ?? {};
if (cfg.cesarGateAutoRun === false) return false;
const command = String(snapshot.gate?.command ?? '').trim();
if (!command) return false;
if (!snapshot.gate?.matchers?.length) return false;
if (snapshot.waived) return false;
if (snapshot.alreadyVerified) return false;
if (!snapshot.wroteWork) return false;
if (snapshot.runsSoFar >= gateAutoRunLimit(cfg)) return false;
return snapshot.permitted === true;
}

/**
* Output-redirection detector (mirrors isAgenticMutationOutcome). Read-only command CLASSIFICATION strips redirections, so `npm test > file` resolves 'allow' in ask mode even though it mutates the workspace — in non-auto modes a redirecting gate falls back to the prompt nudge instead of auto-running. >/dev/null and fd-dup stay allowed.
*/
// @kern-source: gate-runner:69
export const GATE_REDIRECTION_RE: RegExp = /(?:^|[\s;|&])\d*(?:>>?|&>)\s*(?!\s|\/dev\/null\b|&\d\b)/;

/**
* When a continuation-loop evaluation is a gate-run trigger point. Legacy fires on an explicit done-claim. Agentic fires on the controller's 'verifying' state (mutations done, answer delivered, gate outstanding) AND on 'verification_failed_fix_required' — the settled post-fix state after a red gate run; without the latter, fail → fix → re-run never re-triggers because verificationFailed routes evaluateAgenticTaskState to 'running', never back to 'verifying'. Mid-work states (continuation intent, todos remaining) never trigger.
*/
// @kern-source: gate-runner:72
export function gateAutoRunTriggered(signals: {agenticAuto:boolean, agenticTaskState:string, agenticReason:string, legacyState:string}): boolean {
if (signals.agenticAuto) {
if (signals.agenticTaskState === 'verifying') return true;
return signals.agenticReason === 'verification_failed_fix_required';
}
return signals.legacyState === 'done';
}

/**
* The harness may only run the gate when the unified resolver would ALLOW the command outright — mode auto for routine gates, or a persisted/session allow rule (the Always artifact, e.g. Bash(npm test:*)) in any mode. A resolver 'ask' (ask/auto-edit without a covering rule, or a gate command that trips a dangerous boundary even in auto) falls back to the prompt nudge — this path never prompts and never bypasses a boundary. Additionally, outside mode auto a gate command containing output redirection is refused even when the read-only classifier would allow it (the classifier strips redirections, so `npm test > file` would otherwise mutate under an ask posture).
*/
// @kern-source: gate-runner:82
export function gateAutoRunPermitted(command: string, cwd: string, config: any): boolean {
try {
if (resolveAgonPermissionMode(config) !== 'auto' && GATE_REDIRECTION_RE.test(command)) return false;
const resolution = resolvePermissionDecision({ tool: 'Bash', target: command, cwd, source: 'native', config });
return resolution.decision === 'allow';
} catch {
return false;
}
}

/**
* Execute the discovered gate via /bin/sh -c (same shell contract as tool-bash) with stdout+stderr captured and tail-capped. Never throws — spawn failures come back as a failed result so the caller's continuation flow stays uniform.
*/
// @kern-source: gate-runner:94
export async function runDiscoveredGate(opts: {command:string, cwd:string, timeoutMs:number, tailChars:number, signal?:AbortSignal}): Promise<GateRunResult> {
const startedAt = Date.now();
try {
const result = await spawnWithTimeout({
command: '/bin/sh',
args: ['-c', opts.command],
cwd: opts.cwd,
timeout: opts.timeoutMs,
signal: opts.signal,
});
const combined = [result.stdout ?? '', result.stderr ?? ''].filter(Boolean).join('\n').trim();
const cap = Math.max(200, opts.tailChars || 2000);
const outputTail = combined.length > cap ? '…' + combined.slice(-cap) : combined;
return {
ok: result.exitCode === 0 && !result.timedOut,
exitCode: result.exitCode ?? -1,
outputTail,
durationMs: result.durationMs ?? (Date.now() - startedAt),
timedOut: result.timedOut === true,
};
} catch (err) {
return {
ok: false,
exitCode: -1,
outputTail: err instanceof Error ? err.message : String(err),
durationMs: Date.now() - startedAt,
timedOut: false,
};
}
}

/**
* The [SYSTEM] continuation injected after a failed harness-side gate run — real exit code and output tail, so the engine fixes actual failures instead of guessing.
*/
// @kern-source: gate-runner:127
export function buildGateFailureMessage(result: GateRunResult, command: string): string {
const seconds = Math.max(1, Math.round(result.durationMs / 1000));
const status = result.timedOut
? `TIMED OUT after ${seconds}s`
: `FAILED (exit ${result.exitCode}) in ${seconds}s`;
const tail = result.outputTail ? `\n\nOutput tail:\n${result.outputTail}` : '';
return `[SYSTEM] I ran the project's verification gate for you: \`${command}\` → ${status}.${tail}\n\nFix the failing checks now using Edit/Write/Bash tools — do not claim done again until the failures are addressed. The gate re-runs automatically on your next done-claim.`;
}

/**
* One-line user-facing confirmation after a green harness-side gate run.
*/
// @kern-source: gate-runner:138
export function buildGateSuccessNote(result: GateRunResult, command: string): string {
const seconds = Math.max(1, Math.round(result.durationMs / 1000));
return `[HARNESS] Verification gate passed: ${command} (${seconds}s) — change is green.`;
}
Loading