feat: KHAELOR v2 (0.2.0) — phase gates, native verify, fork/replay/sdiff, memory, RepoGraph, worktree subtasks, khaelord daemon, Khaelis TUI, website redesign
- §1 Phase gates: phase.* events, PhaseService around the kernel, design tool, PHASE_GATE_BLOCKED enforcement in the Tool Runtime, --gate strict|auto|off, status-bar phase ribbon, /phase override, strict-mode approval panel - §4 Native verification: .khaelor/verify.json + auto-detection, parallel checks after each edit batch, verify.result events, bounded repair loop - §2 Sessions: /fork (JSONL prefix + meta.json lineage), /replay (sandboxed worktree), /sdiff (structured run comparison) - §5 Project memory: remember tool, .khaelor/MEMORY.md with event-anchored provenance, context injection, /memory - §3 RepoGraph: incremental semantic index (TS/JS/Python), symbols + refs tools, file skeletons (tree-sitter documented as upgrade path) - §6 Parallel subtasks: git worktree isolation, child sessions with attenuated permissions, subtask.* events, /spawn /tasks /merge (--no-ff) - §7 khaelord daemon: event-sourced goals, cron + heartbeat scheduler, BudgetGuard (hard ceilings, real costs only), async ApprovalQueue, webhook/command channels, control socket, /goals - TUI v2: Khaelis theme (obsidian + magma), OKLCH gradients, cockpit status bar with context gauge, verify strip, design panel, braille sparkline, splash sweep, motion doctrine, --doctor-tui - Website: full Khaelis redesign, v2 landing, phases + daemon docs, 0.2.0 installer artifact (deployed to www.khaelor.sh) 40 durable event types · 11 tools · 698 tests · npm run check green Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
89 changed files +7,889 −178
added
docs/V2_IMPLEMENTATION.md
+80 −0
@@ -0,0 +1,80 @@ | ||
| 1 | +<!-- | |
| 2 | +KHAELOR | |
| 3 | +File: docs/V2_IMPLEMENTATION.md | |
| 4 | +Author: Simon-Pierre Boucher | |
| 5 | +Contact: contact@spboucher.ai | |
| 6 | +--> | |
| 7 | + | |
| 8 | +# KHAELOR v2 — implementation notes (0.2.0) | |
| 9 | + | |
| 10 | +The seven v2 work streams plus the "Terminal Cinema" TUI upgrade, as shipped. Every number below | |
| 11 | +is enforced by `npm run check` (typecheck · eslint · vitest · header check). | |
| 12 | + | |
| 13 | +## §1 Phase gates — `src/phases/` | |
| 14 | +- Events: `phase.entered` / `phase.artifact` / `phase.approved` / `phase.rejected` (durable catalog, `src/session/events.ts`). | |
| 15 | +- `PhaseService` sits AROUND the kernel: the Tool Runtime (`src/agent/executor.ts`) consults | |
| 16 | + `checkToolCall` between capability mapping and permission evaluation; blocked calls fail with | |
| 17 | + structured `PHASE_GATE_BLOCKED` repair prose. | |
| 18 | +- Per-phase policy (`src/phases/gate.ts`): understand = read + read-only bash whitelist; | |
| 19 | + design = + `docs/design/*.md`; implement = everything. `.khaelor/MEMORY.md` writable in every phase. | |
| 20 | +- The model unlocks implement via the `design` tool (`src/tools/design.ts`). | |
| 21 | +- Modes: `--gate strict|auto|off`, config `gate.autoApprove.maxFiles` (default 3). The doc's | |
| 22 | + `maxRiskLevel` is not implemented — risks are free text; file count is the auto-approval criterion. | |
| 23 | +- TUI: phase ribbon in the status bar, design panel (`src/tui/components/design-panel.ts`), | |
| 24 | + strict-mode approval via the selector (Esc = reject; silence is never approval), `/phase` override. | |
| 25 | + | |
| 26 | +## §4 Native verification — `src/verify/` | |
| 27 | +- `.khaelor/verify.json` or auto-detection (package.json scripts / tsconfig / Cargo.toml / pyproject). | |
| 28 | +- `VerifyRunner` runs checks in parallel (autofix checks last, serially) through the Workspace seam; | |
| 29 | + each check is a durable `verify.result` with real exit codes and errors-first truncation. | |
| 30 | +- Policy `after-each-edit-batch` (default) triggers in the executor after a successful write/edit | |
| 31 | + batch. Failing results are folded into the conversation as user-visible input (the model repairs | |
| 32 | + them) with a bounded loop: `maxRepairLoops` (default 3) failures per user turn, then honest stop. | |
| 33 | +- `before-final-answer` maps onto the existing VerificationGate nudge flow; `/verify` runs on demand. | |
| 34 | + | |
| 35 | +## §2 Fork / replay / sdiff — `src/session/fork.ts`, `sdiff.ts`, `src/cli/replay.ts` | |
| 36 | +- `<sessionId>.meta.json` lineage (`parent`, `forkPoint`, `replayOf`). | |
| 37 | +- `/fork` lists natural checkpoints (user turns, approved designs, compactions), copies the JSONL | |
| 38 | + prefix (sessionIds rewritten, seq/payloads byte-preserved) and opens the fork. | |
| 39 | +- `/replay` re-runs the user turns headless inside a throwaway worktree (`--sandbox` semantics built-in). | |
| 40 | +- `/sdiff` folds two logs into turns/tool-calls/files/tokens/verify-failures/outcome. | |
| 41 | + | |
| 42 | +## §5 Project memory — `src/memory/`, `src/tools/remember.ts` | |
| 43 | +- `.khaelor/MEMORY.md`, entries anchored `<!-- khaelor: session=… tool=… confidence=… date=… -->` | |
| 44 | + (the tool_use id is the event anchor). `memory.written` durable event. | |
| 45 | +- Injected as a project-instruction tier at engine assembly; `/memory` lists with provenance; | |
| 46 | + low-confidence entries surface as purge candidates. | |
| 47 | + | |
| 48 | +## §3 RepoGraph — `src/repograph/` | |
| 49 | +- Dependency-free heuristic extractor (TS/JS/Python): symbols, doc comments, imports. tree-sitter | |
| 50 | + is the documented upgrade path — the service interface will not change. | |
| 51 | +- Incremental by mtime, lazy debounced refresh, in-memory graph; `symbols` and `refs` | |
| 52 | + (callers/callees/importers) tools; `skeleton()` produces signature-level file views for the | |
| 53 | + context engine (wired for future compaction use). | |
| 54 | + | |
| 55 | +## §6 Parallel worktrees — `src/tasks/`, `src/cli/subtasks.ts` | |
| 56 | +- `subtask.created` / `subtask.completed` durable events with REAL git diff stats. | |
| 57 | +- Children run a full headless engine in `.khaelor/worktrees/<id>` on branch `khaelor/<id>`: | |
| 58 | + own JSONL (meta.parent → orchestrator), non-interactive permissions (asks deny), gates + verify on. | |
| 59 | +- `/spawn`, `/tasks`, `/merge` (supervised `--no-ff`; conflicts abort cleanly). | |
| 60 | + | |
| 61 | +## §7 Daemon — `src/daemon/`, bin `khaelord` | |
| 62 | +- Scheduler (cron parser + heartbeat tick), event-sourced `GoalStore` | |
| 63 | + (`.khaelor/daemon/goals/<id>.json` + `.events.jsonl`), `BudgetGuard` (hard daily/per-goal USD | |
| 64 | + ceilings from a persisted ledger; costs only priced when `pricing` is configured — never invented), | |
| 65 | + `ApprovalQueue` (persisted; suspended run costs zero), `ChannelRouter` (webhook + command adapters), | |
| 66 | + control socket (`status/goals/approvals/approve/deny/stop`), `khaelor` TUI `/goals` view. | |
| 67 | +- Goal runs = throwaway worktree + headless engine + gates + verify; escalation notify / | |
| 68 | + draft-pr (branch left for review) / auto-merge-if-verified. LLM heartbeat triage is not | |
| 69 | + implemented — the goal `check` command is the cheap tier; an injectable seam exists. | |
| 70 | + | |
| 71 | +## TUI v2 — "Terminal Cinema" | |
| 72 | +- Khaelis palette (obsidian + magma: ember #FF6B35 → glow #FFB86B, teal #2DD4BF) as the default | |
| 73 | + dark theme; OKLCH gradient interpolation (`src/tui/theme/gradient.ts`), `ember` named gradient. | |
| 74 | +- Cockpit status bar: phase ribbon + `▰▱` context gauge (≥110 cols) + cost; verify strip, | |
| 75 | + design panel, braille sparkline widgets; splash (`--splash`, gradient sweep, skippable); | |
| 76 | + `MotionController` (six sanctioned effects, tick-driven, motion-off aware); `--doctor-tui`. | |
| 77 | + | |
| 78 | +## Website (www.khaelor.sh) | |
| 79 | +- Full Khaelis redesign, v2 landing (cockpit demo, feature grid, daemon section, design-panel mock), | |
| 80 | + new docs: `phases.html`, `daemon.html`; sessions/commands updated; installer artifact 0.2.0. | |
modified
package.json
+3 −2
@@ -1,12 +1,13 @@ | ||
| 1 | 1 | { |
| 2 | 2 | "name": "khaelor", |
| 3 | − "version": "0.1.1", | |
| 3 | + "version": "0.2.0", | |
| 4 | 4 | "description": "KHAELOR — a terminal-native autonomous engineering agent powered by Anthropic.", |
| 5 | 5 | "author": "Simon-Pierre Boucher <contact@spboucher.ai>", |
| 6 | 6 | "license": "UNLICENSED", |
| 7 | 7 | "type": "module", |
| 8 | 8 | "bin": { |
| 9 | − "khaelor": "./dist/cli/main.js" | |
| 9 | + "khaelor": "./dist/cli/main.js", | |
| 10 | + "khaelord": "./dist/daemon/main.js" | |
| 10 | 11 | }, |
| 11 | 12 | "files": [ |
| 12 | 13 | "dist", |
modified
src/agent/executor.ts
+54 −1
@@ -10,9 +10,11 @@ | ||
| 10 | 10 | import * as path from "node:path"; |
| 11 | 11 | import type { CapabilityMappingContext, PermissionService } from "../permissions/index.js"; |
| 12 | 12 | import { mapToolCapabilities } from "../permissions/index.js"; |
| 13 | +import type { PhaseService } from "../phases/index.js"; | |
| 13 | 14 | import type { ToolCompleted, ToolFailed, ToolName } from "../session/index.js"; |
| 14 | 15 | import { CANCELLED_RESULT_CONTENT, parseToolInput } from "../tools/index.js"; |
| 15 | −import type { ToolContext, ToolRegistry, ToolResult } from "../tools/index.js"; | |
| 16 | +import type { RepoGraphFacet, ToolContext, ToolRegistry, ToolResult } from "../tools/index.js"; | |
| 17 | +import type { VerifyRunner } from "../verify/index.js"; | |
| 16 | 18 | import type { FileTimeRegistry, ProcessManager, Workspace } from "../workspace/index.js"; |
| 17 | 19 | import type { KernelSession } from "./session-handle.js"; |
| 18 | 20 | |
@@ -50,6 +52,8 @@ function uiKindFor(toolName: string): ToolUiMeta["kind"] { | ||
| 50 | 52 | return "read"; |
| 51 | 53 | case "grep": |
| 52 | 54 | case "glob": |
| 55 | + case "symbols": | |
| 56 | + case "refs": | |
| 53 | 57 | return "search"; |
| 54 | 58 | case "write": |
| 55 | 59 | case "edit": |
@@ -77,6 +81,12 @@ export interface ToolExecutorOptions { | ||
| 77 | 81 | /** Home directory for `~` expansion in capability subjects. */ |
| 78 | 82 | home?: string; |
| 79 | 83 | now?: () => number; |
| 84 | + /** Phase-gate service (v2 §1); absent → gates off. */ | |
| 85 | + phases?: PhaseService; | |
| 86 | + /** Semantic-index facet for the symbols/refs tools (v2 §3). */ | |
| 87 | + repograph?: RepoGraphFacet; | |
| 88 | + /** Native verification runner (v2 §4); absent → no native verify loop. */ | |
| 89 | + verify?: VerifyRunner; | |
| 80 | 90 | } |
| 81 | 91 | |
| 82 | 92 | /** |
@@ -98,6 +108,9 @@ export class ToolExecutor implements ToolBatchExecutor { | ||
| 98 | 108 | readonly #projectRoot: string; |
| 99 | 109 | readonly #home: string | undefined; |
| 100 | 110 | readonly #now: () => number; |
| 111 | + readonly #phases: PhaseService | undefined; | |
| 112 | + readonly #repograph: RepoGraphFacet | undefined; | |
| 113 | + readonly #verify: VerifyRunner | undefined; | |
| 101 | 114 | #spillCounter = 0; |
| 102 | 115 | |
| 103 | 116 | constructor(options: ToolExecutorOptions) { |
@@ -111,9 +124,13 @@ export class ToolExecutor implements ToolBatchExecutor { | ||
| 111 | 124 | this.#projectRoot = options.projectRoot ?? options.workspace.cwd(); |
| 112 | 125 | this.#home = options.home; |
| 113 | 126 | this.#now = options.now ?? Date.now; |
| 127 | + this.#phases = options.phases; | |
| 128 | + this.#repograph = options.repograph; | |
| 129 | + this.#verify = options.verify; | |
| 114 | 130 | } |
| 115 | 131 | |
| 116 | 132 | async executeBatch(pending: readonly PendingToolCall[], signal: AbortSignal): Promise<void> { |
| 133 | + let hadEdit = false; | |
| 117 | 134 | for (let i = 0; i < pending.length; i += 1) { |
| 118 | 135 | const call = pending[i] as PendingToolCall; |
| 119 | 136 | if (signal.aborted) { |
@@ -125,6 +142,22 @@ export class ToolExecutor implements ToolBatchExecutor { | ||
| 125 | 142 | this.#cancelCalls(pending.slice(i + 1)); |
| 126 | 143 | return; |
| 127 | 144 | } |
| 145 | + if (outcome === "completed" && (call.toolName === "write" || call.toolName === "edit")) { | |
| 146 | + hadEdit = true; | |
| 147 | + } | |
| 148 | + } | |
| 149 | + // Native verification after each edit batch (v2 §4) — bounded repair loop: | |
| 150 | + // once maxRepairLoops failing rounds are recorded since the last user | |
| 151 | + // message, checks stop re-running and the honest failure report stands. | |
| 152 | + if ( | |
| 153 | + hadEdit && | |
| 154 | + this.#verify !== undefined && | |
| 155 | + this.#verify.policy === "after-each-edit-batch" && | |
| 156 | + this.#verify.hasChecks && | |
| 157 | + !signal.aborted && | |
| 158 | + this.#verify.withinRepairBudget() | |
| 159 | + ) { | |
| 160 | + await this.#verify.runAll(signal); | |
| 128 | 161 | } |
| 129 | 162 | } |
| 130 | 163 | |
@@ -165,6 +198,15 @@ export class ToolExecutor implements ToolBatchExecutor { | ||
| 165 | 198 | ); |
| 166 | 199 | } |
| 167 | 200 | |
| 201 | + // Phase gate (v2 §1): evaluated BEFORE permissions — a blocked call is a | |
| 202 | + // structured course-correction telling the model to design first. | |
| 203 | + if (this.#phases !== undefined) { | |
| 204 | + const gate = this.#phases.checkToolCall(capabilities.value); | |
| 205 | + if (!gate.allowed) { | |
| 206 | + return this.#fail(call, "phase-blocked", gate.feedback, startedAt); | |
| 207 | + } | |
| 208 | + } | |
| 209 | + | |
| 168 | 210 | let outcome; |
| 169 | 211 | try { |
| 170 | 212 | outcome = await this.#permissions.check({ |
@@ -313,6 +355,17 @@ export class ToolExecutor implements ToolBatchExecutor { | ||
| 313 | 355 | await this.#workspace.writeFile(file, content); |
| 314 | 356 | return file; |
| 315 | 357 | }, |
| 358 | + ...(this.#phases !== undefined | |
| 359 | + ? { | |
| 360 | + phases: { | |
| 361 | + mode: this.#phases.mode, | |
| 362 | + current: () => this.#phases?.current() ?? "implement", | |
| 363 | + submitDesign: (artifact) => | |
| 364 | + (this.#phases as PhaseService).submitDesign(artifact), | |
| 365 | + }, | |
| 366 | + } | |
| 367 | + : {}), | |
| 368 | + ...(this.#repograph !== undefined ? { repograph: this.#repograph } : {}), | |
| 316 | 369 | }; |
| 317 | 370 | } |
| 318 | 371 | } |
modified
src/agent/kernel.ts
+8 −0
@@ -115,6 +115,14 @@ export function foldTurnState(events: readonly DurableEvent[]): TurnState { | ||
| 115 | 115 | lastInputWasVerification = true; |
| 116 | 116 | lastVerification = { seq: event.seq, detectedChecks: [...event.payload.detectedChecks] }; |
| 117 | 117 | break; |
| 118 | + case "verify.result": | |
| 119 | + // A failing native check is conversation input — the model repairs it | |
| 120 | + // before the turn can complete (v2 §4). Passing checks are evidence only. | |
| 121 | + if (!event.payload.ok) { | |
| 122 | + lastInputSeq = event.seq; | |
| 123 | + lastInputWasVerification = false; | |
| 124 | + } | |
| 125 | + break; | |
| 118 | 126 | default: |
| 119 | 127 | break; |
| 120 | 128 | } |
modified
src/cli/args.ts
+33 −0
@@ -38,6 +38,12 @@ export interface ParsedArgs { | ||
| 38 | 38 | auxModel: string | null; |
| 39 | 39 | thinking: string | null; |
| 40 | 40 | maxOutputTokens: number | null; |
| 41 | + /** Phase-gate rigor: strict | auto | off (v2 §1). */ | |
| 42 | + gate: string | null; | |
| 43 | + /** Print the detected terminal capability report and exit (TUI v2 §7). */ | |
| 44 | + doctorTui: boolean; | |
| 45 | + /** Show the full splash even on repeat launches (TUI v2 §2). */ | |
| 46 | + splash: boolean; | |
| 41 | 47 | errors: string[]; |
| 42 | 48 | } |
| 43 | 49 | |
@@ -53,12 +59,18 @@ Options | ||
| 53 | 59 | --aux-model <id> cheaper model used for context compaction |
| 54 | 60 | --thinking <mode> off | adaptive | always |
| 55 | 61 | --max-output-tokens <n> output token ceiling per model response |
| 62 | + --gate <mode> phase-gate rigor: strict | auto (default) | off | |
| 56 | 63 | -p, --print <prompt> non-interactive single turn (mirrors claude -p) |
| 57 | 64 | --resume <session-id> resume a session by id |
| 65 | + --splash show the full splash on launch | |
| 66 | + --doctor-tui print the detected terminal capabilities and exit | |
| 58 | 67 | --debug write developer logs to ~/.khaelor/logs/ |
| 59 | 68 | -v, --version print the version and exit |
| 60 | 69 | -h, --help show this help |
| 61 | 70 | |
| 71 | +Autonomy | |
| 72 | + khaelord the long-running daemon: goals, heartbeat, budget (khaelord --help) | |
| 73 | + | |
| 62 | 74 | Environment |
| 63 | 75 | ANTHROPIC_API_KEY required — your Anthropic API key |
| 64 | 76 | `; |
@@ -97,6 +109,9 @@ export function parseArgs(argv: readonly string[]): ParsedArgs { | ||
| 97 | 109 | auxModel: null, |
| 98 | 110 | thinking: null, |
| 99 | 111 | maxOutputTokens: null, |
| 112 | + gate: null, | |
| 113 | + doctorTui: false, | |
| 114 | + splash: false, | |
| 100 | 115 | errors: [], |
| 101 | 116 | }; |
| 102 | 117 | |
@@ -164,6 +179,24 @@ export function parseArgs(argv: readonly string[]): ParsedArgs { | ||
| 164 | 179 | } |
| 165 | 180 | break; |
| 166 | 181 | } |
| 182 | + case "--gate": { | |
| 183 | + const value = takeValue("--gate", i); | |
| 184 | + if (value !== null) { | |
| 185 | + if (!["strict", "auto", "off"].includes(value)) { | |
| 186 | + parsed.errors.push("--gate must be strict, auto, or off"); | |
| 187 | + } else { | |
| 188 | + parsed.gate = value; | |
| 189 | + } | |
| 190 | + i += 1; | |
| 191 | + } | |
| 192 | + break; | |
| 193 | + } | |
| 194 | + case "--doctor-tui": | |
| 195 | + parsed.doctorTui = true; | |
| 196 | + break; | |
| 197 | + case "--splash": | |
| 198 | + parsed.splash = true; | |
| 199 | + break; | |
| 167 | 200 | case "--max-output-tokens": { |
| 168 | 201 | const value = takeValue("--max-output-tokens", i); |
| 169 | 202 | if (value !== null) { |
modified
src/cli/commands.ts
+381 −2
@@ -7,13 +7,27 @@ | ||
| 7 | 7 | * Contact: contact@spboucher.ai |
| 8 | 8 | */ |
| 9 | 9 | |
| 10 | +import { join } from "node:path"; | |
| 10 | 11 | import type { ResolvedConfig } from "../config/index.js"; |
| 11 | −import { buildUsageTotals } from "../session/index.js"; | |
| 12 | −import type { DurableEvent } from "../session/index.js"; | |
| 12 | +import { MEMORY_FILE, parseMemory, purgeCandidates } from "../memory/index.js"; | |
| 13 | +import { GoalStore, daemonDirFor, readDaemonStatus } from "../daemon/index.js"; | |
| 14 | +import { | |
| 15 | + SessionLog, | |
| 16 | + buildUsageTotals, | |
| 17 | + forkSession, | |
| 18 | + listForkCheckpoints, | |
| 19 | + renderSessionDiff, | |
| 20 | + summarizeSessionRun, | |
| 21 | +} from "../session/index.js"; | |
| 22 | +import type { DurableEvent, Phase } from "../session/index.js"; | |
| 23 | +import { mergeSubtaskBranch } from "../tasks/index.js"; | |
| 24 | +import type { SubtaskManager } from "../tasks/index.js"; | |
| 13 | 25 | import type { CommandDef } from "../tui/index.js"; |
| 14 | 26 | import type { PaletteItem } from "../tui/index.js"; |
| 15 | 27 | import type { Engine } from "./engine.js"; |
| 28 | +import { replaySession } from "./replay.js"; | |
| 16 | 29 | import { listSessions } from "./sessions.js"; |
| 30 | +import { createSubtaskManager, workspaceExec } from "./subtasks.js"; | |
| 17 | 31 | |
| 18 | 32 | // ───────────────────────── dependencies ───────────────────────── |
| 19 | 33 | |
@@ -59,6 +73,16 @@ export const CLI_SLASH_COMMANDS: readonly string[] = [ | ||
| 59 | 73 | "/diff", |
| 60 | 74 | "/processes", |
| 61 | 75 | "/status", |
| 76 | + "/phase", | |
| 77 | + "/fork", | |
| 78 | + "/sdiff", | |
| 79 | + "/replay", | |
| 80 | + "/memory", | |
| 81 | + "/verify", | |
| 82 | + "/spawn", | |
| 83 | + "/tasks", | |
| 84 | + "/merge", | |
| 85 | + "/goals", | |
| 62 | 86 | ]; |
| 63 | 87 | |
| 64 | 88 | // ───────────────────────── pure report builders ───────────────────────── |
@@ -474,6 +498,361 @@ export function buildCliCommands(deps: CliCommandDeps): CommandDef[] { | ||
| 474 | 498 | }), |
| 475 | 499 | }); |
| 476 | 500 | |
| 501 | + // ───────────────────────── v2 commands ───────────────────────── | |
| 502 | + | |
| 503 | + let subtasks: SubtaskManager | null = null; | |
| 504 | + const getSubtasks = (): SubtaskManager => { | |
| 505 | + if (subtasks === null) { | |
| 506 | + const engine = deps.engine(); | |
| 507 | + subtasks = createSubtaskManager({ | |
| 508 | + config: deps.config, | |
| 509 | + workspace: engine.workspace, | |
| 510 | + projectRoot: deps.cwd, | |
| 511 | + logger: engine.logger, | |
| 512 | + publish: (event) => engine.session.publishDurable(event), | |
| 513 | + parentSessionId: engine.session.sessionId, | |
| 514 | + }); | |
| 515 | + } | |
| 516 | + return subtasks; | |
| 517 | + }; | |
| 518 | + | |
| 519 | + // /phase — the escape hatch: show the phase, force a transition (logged as override). | |
| 520 | + commands.push({ | |
| 521 | + id: "phase.show", | |
| 522 | + title: "Phase gate", | |
| 523 | + slash: "/phase", | |
| 524 | + description: "current phase; select to force a transition", | |
| 525 | + run: () => { | |
| 526 | + const engine = deps.engine(); | |
| 527 | + const state = engine.phases.state(); | |
| 528 | + const items: PaletteItem[] = (["understand", "design", "implement"] as Phase[]).map((phase) => ({ | |
| 529 | + id: phase, | |
| 530 | + label: phase, | |
| 531 | + detail: phase === state.phase ? "current" : "force transition (logged as user-override)", | |
| 532 | + })); | |
| 533 | + print([ | |
| 534 | + "", | |
| 535 | + ` phase · ${state.phase} (gate mode: ${engine.phases.mode})`, | |
| 536 | + state.pendingArtifact !== null | |
| 537 | + ? ` pending design: ${state.pendingArtifact.artifact.goal.slice(0, 60)}` | |
| 538 | + : ` design approved: ${state.designApproved ? "yes" : "no"}`, | |
| 539 | + ]); | |
| 540 | + ui.openSelector(items, (id) => { | |
| 541 | + deps.engine().phases.forcePhase(id as Phase); | |
| 542 | + print(["", ` phase forced to ${id} (recorded as user-override)`]); | |
| 543 | + }); | |
| 544 | + }, | |
| 545 | + }); | |
| 546 | + | |
| 547 | + // /fork — pick a checkpoint, copy the JSONL prefix, resume the fork. | |
| 548 | + commands.push({ | |
| 549 | + id: "session.fork", | |
| 550 | + title: "Fork session", | |
| 551 | + slash: "/fork", | |
| 552 | + description: "fork this session at a checkpoint", | |
| 553 | + run: () => { | |
| 554 | + const engine = deps.engine(); | |
| 555 | + const checkpoints = listForkCheckpoints(engine.session.events()); | |
| 556 | + if (checkpoints.length === 0) { | |
| 557 | + print(["", " /fork — no checkpoints yet (user turns, approved designs, compactions)"]); | |
| 558 | + return; | |
| 559 | + } | |
| 560 | + const items: PaletteItem[] = checkpoints | |
| 561 | + .slice(-30) | |
| 562 | + .reverse() | |
| 563 | + .map((cp) => ({ id: String(cp.seq), label: cp.label, detail: `seq ${cp.seq} · ${cp.kind}` })); | |
| 564 | + ui.openSelector(items, (id) => { | |
| 565 | + runAsync("/fork", async () => { | |
| 566 | + const result = await forkSession({ | |
| 567 | + sessionsDir: deps.sessionsDir, | |
| 568 | + projectHash: deps.projectHash, | |
| 569 | + sourceSessionId: deps.engine().session.sessionId, | |
| 570 | + uptoSeq: Number.parseInt(id, 10), | |
| 571 | + }); | |
| 572 | + print([ | |
| 573 | + "", | |
| 574 | + ` forked at seq ${result.forkPoint} → session ${result.sessionId}`, | |
| 575 | + ` ${result.copiedEvents} events copied · opening the fork…`, | |
| 576 | + ]); | |
| 577 | + deps.actions.resumeSession(result.sessionId); | |
| 578 | + }); | |
| 579 | + }); | |
| 580 | + }, | |
| 581 | + }); | |
| 582 | + | |
| 583 | + // /sdiff — structured diff between two runs: "<idA> <idB>" (A defaults to this session). | |
| 584 | + commands.push({ | |
| 585 | + id: "session.sdiff", | |
| 586 | + title: "Diff two sessions", | |
| 587 | + slash: "/sdiff", | |
| 588 | + description: "structured diff between two session runs", | |
| 589 | + run: () => { | |
| 590 | + const capture = deps.actions.captureNextSubmit; | |
| 591 | + if (capture === undefined) { | |
| 592 | + print(["", " /sdiff is unavailable in this mode"]); | |
| 593 | + return; | |
| 594 | + } | |
| 595 | + print(["", ' sdiff — type "<sessionA> <sessionB>" (or just "<sessionB>" to compare with this one)']); | |
| 596 | + capture((text) => { | |
| 597 | + runAsync("/sdiff", async () => { | |
| 598 | + const parts = text.trim().split(/\s+/).filter((part) => part.length > 0); | |
| 599 | + if (parts.length === 0) { | |
| 600 | + print([" sdiff cancelled"]); | |
| 601 | + return; | |
| 602 | + } | |
| 603 | + const engine = deps.engine(); | |
| 604 | + const idA = parts.length >= 2 ? (parts[0] as string) : engine.session.sessionId; | |
| 605 | + const idB = parts.length >= 2 ? (parts[1] as string) : (parts[0] as string); | |
| 606 | + const load = async (id: string): Promise<ReturnType<typeof summarizeSessionRun>> => { | |
| 607 | + if (id === engine.session.sessionId) { | |
| 608 | + return summarizeSessionRun(id, engine.session.events()); | |
| 609 | + } | |
| 610 | + const log = await SessionLog.open({ | |
| 611 | + projectHash: deps.projectHash, | |
| 612 | + sessionId: id, | |
| 613 | + sessionsDir: deps.sessionsDir, | |
| 614 | + }); | |
| 615 | + return summarizeSessionRun(id, log.replayedEvents); | |
| 616 | + }; | |
| 617 | + const [a, b] = await Promise.all([load(idA), load(idB)]); | |
| 618 | + print(["", " sdiff", ...renderSessionDiff(a, b)]); | |
| 619 | + }); | |
| 620 | + }); | |
| 621 | + }, | |
| 622 | + }); | |
| 623 | + | |
| 624 | + // /replay — re-run a session's user turns with the current model. | |
| 625 | + commands.push({ | |
| 626 | + id: "session.replay", | |
| 627 | + title: "Replay session", | |
| 628 | + slash: "/replay", | |
| 629 | + description: "re-run a session's user turns (current model)", | |
| 630 | + run: () => { | |
| 631 | + const capture = deps.actions.captureNextSubmit; | |
| 632 | + if (capture === undefined) { | |
| 633 | + print(["", " /replay is unavailable in this mode"]); | |
| 634 | + return; | |
| 635 | + } | |
| 636 | + print(["", ' replay — type "<sessionId>" to re-run its user turns with the current model']); | |
| 637 | + capture((text) => { | |
| 638 | + const sourceId = text.trim(); | |
| 639 | + if (sourceId === "") { | |
| 640 | + print([" replay cancelled"]); | |
| 641 | + return; | |
| 642 | + } | |
| 643 | + runAsync("/replay", async () => { | |
| 644 | + const engine = deps.engine(); | |
| 645 | + print(["", ` replaying ${sourceId} — tool calls re-execute for real (use a clean tree)`]); | |
| 646 | + const result = await replaySession({ | |
| 647 | + config: deps.config, | |
| 648 | + cwd: deps.cwd, | |
| 649 | + sourceSessionId: sourceId, | |
| 650 | + sessionsDir: deps.sessionsDir, | |
| 651 | + logger: engine.logger, | |
| 652 | + sandbox: { exec: workspaceExec(engine.workspace) }, | |
| 653 | + onProgress: (message) => print([` ${message}`]), | |
| 654 | + }); | |
| 655 | + print([ | |
| 656 | + "", | |
| 657 | + ` replay done → session ${result.newSessionId} (${result.turnsReplayed} turns, sandboxed worktree)`, | |
| 658 | + ` compare: /sdiff ${result.newSessionId}`, | |
| 659 | + ]); | |
| 660 | + }); | |
| 661 | + }); | |
| 662 | + }, | |
| 663 | + }); | |
| 664 | + | |
| 665 | + // /memory — the project memory with provenance; low-confidence purge candidates flagged. | |
| 666 | + commands.push({ | |
| 667 | + id: "memory.open", | |
| 668 | + title: "Project memory", | |
| 669 | + slash: "/memory", | |
| 670 | + description: "auto-maintained project memory with provenance", | |
| 671 | + run: () => | |
| 672 | + runAsync("/memory", async () => { | |
| 673 | + const engine = deps.engine(); | |
| 674 | + let content = ""; | |
| 675 | + try { | |
| 676 | + content = await engine.workspace.readFile(join(deps.cwd, MEMORY_FILE)); | |
| 677 | + } catch { | |
| 678 | + print(["", " memory — empty (the agent writes durable facts via the remember tool)"]); | |
| 679 | + return; | |
| 680 | + } | |
| 681 | + const entries = parseMemory(content); | |
| 682 | + const lines = ["", ` memory · ${MEMORY_FILE} (${entries.length} entries)`]; | |
| 683 | + let section = ""; | |
| 684 | + for (const entry of entries) { | |
| 685 | + if (entry.section !== section) { | |
| 686 | + section = entry.section; | |
| 687 | + lines.push(` ${section}`); | |
| 688 | + } | |
| 689 | + const provenance = | |
| 690 | + entry.provenance !== null | |
| 691 | + ? ` [${entry.provenance.confidence} · ${entry.provenance.date} · session ${entry.provenance.session.slice(0, 8)}]` | |
| 692 | + : ""; | |
| 693 | + lines.push(` ${entry.text.replace(/^-\s*/, "· ").slice(0, 100)}${provenance}`); | |
| 694 | + } | |
| 695 | + const purgeable = purgeCandidates(entries); | |
| 696 | + if (purgeable.length > 0) { | |
| 697 | + lines.push(` ${purgeable.length} low-confidence entr${purgeable.length === 1 ? "y" : "ies"} — purge candidates at the next /compact`); | |
| 698 | + } | |
| 699 | + print(lines); | |
| 700 | + }), | |
| 701 | + }); | |
| 702 | + | |
| 703 | + // /verify — run the configured checks now, results recorded as verify.result events. | |
| 704 | + commands.push({ | |
| 705 | + id: "verify.run", | |
| 706 | + title: "Run verification", | |
| 707 | + slash: "/verify", | |
| 708 | + description: "run the project's verify checks now", | |
| 709 | + run: () => | |
| 710 | + runAsync("/verify", async () => { | |
| 711 | + const engine = deps.engine(); | |
| 712 | + if (!engine.verifyRunner.hasChecks) { | |
| 713 | + print(["", " verify — no checks configured or detected (.khaelor/verify.json)"]); | |
| 714 | + return; | |
| 715 | + } | |
| 716 | + print(["", " verify — running checks…"]); | |
| 717 | + const outcome = await engine.verifyRunner.runAll(); | |
| 718 | + const lines = ["", ` verify · ${outcome.ok ? "✓ all passed" : "✗ failures"}`]; | |
| 719 | + for (const result of outcome.results) { | |
| 720 | + const mark = result.ok ? "✓" : "✗"; | |
| 721 | + lines.push( | |
| 722 | + ` ${mark} ${result.check.padEnd(12)} ${(result.durationMs / 1000).toFixed(1)}s${result.ok ? "" : ` (exit ${result.exitCode ?? "killed"})`}`, | |
| 723 | + ); | |
| 724 | + } | |
| 725 | + print(lines); | |
| 726 | + }), | |
| 727 | + }); | |
| 728 | + | |
| 729 | + // /spawn — a parallel subtask in its own worktree + child session. | |
| 730 | + commands.push({ | |
| 731 | + id: "tasks.spawn", | |
| 732 | + title: "Spawn subtask", | |
| 733 | + slash: "/spawn", | |
| 734 | + description: "run a task in an isolated worktree", | |
| 735 | + run: () => { | |
| 736 | + const capture = deps.actions.captureNextSubmit; | |
| 737 | + if (capture === undefined) { | |
| 738 | + print(["", " /spawn is unavailable in this mode"]); | |
| 739 | + return; | |
| 740 | + } | |
| 741 | + print(["", " spawn — type the subtask description and press Enter (empty cancels)"]); | |
| 742 | + capture((text) => { | |
| 743 | + const description = text.trim(); | |
| 744 | + if (description === "") { | |
| 745 | + print([" spawn cancelled"]); | |
| 746 | + return; | |
| 747 | + } | |
| 748 | + runAsync("/spawn", async () => { | |
| 749 | + const record = await getSubtasks().spawn(description); | |
| 750 | + print([ | |
| 751 | + "", | |
| 752 | + ` subtask ${record.taskId} spawned`, | |
| 753 | + ` worktree ${record.worktree.path}`, | |
| 754 | + ` branch ${record.worktree.branch} · child session ${record.childSessionId}`, | |
| 755 | + " /tasks shows progress; completion lands in this session's log", | |
| 756 | + ]); | |
| 757 | + }); | |
| 758 | + }); | |
| 759 | + }, | |
| 760 | + }); | |
| 761 | + | |
| 762 | + // /tasks — the subtask board. | |
| 763 | + commands.push({ | |
| 764 | + id: "tasks.open", | |
| 765 | + title: "Subtasks", | |
| 766 | + slash: "/tasks", | |
| 767 | + description: "parallel subtasks and their state", | |
| 768 | + run: () => { | |
| 769 | + const records = subtasks?.list() ?? []; | |
| 770 | + const lines = ["", " tasks"]; | |
| 771 | + if (records.length === 0) lines.push(" none — /spawn starts one"); | |
| 772 | + for (const record of records) { | |
| 773 | + const glyph = record.status === "running" ? "●" : record.status === "done" ? "✓" : "✗"; | |
| 774 | + const verify = record.verifyOk === null ? "" : record.verifyOk ? " · verify ✓" : " · verify ✗"; | |
| 775 | + lines.push( | |
| 776 | + ` ${glyph} ${record.taskId} ${record.status.padEnd(11)} +${record.diff.added} −${record.diff.removed}${verify} ${record.description.slice(0, 44)}`, | |
| 777 | + ); | |
| 778 | + } | |
| 779 | + if (records.some((record) => record.status === "done")) { | |
| 780 | + lines.push(" merge a finished task with /merge"); | |
| 781 | + } | |
| 782 | + print(lines); | |
| 783 | + }, | |
| 784 | + }); | |
| 785 | + | |
| 786 | + // /merge — supervised --no-ff merge of a finished subtask branch. | |
| 787 | + commands.push({ | |
| 788 | + id: "tasks.merge", | |
| 789 | + title: "Merge subtask", | |
| 790 | + slash: "/merge", | |
| 791 | + description: "merge a finished subtask branch (--no-ff)", | |
| 792 | + run: () => { | |
| 793 | + const records = (subtasks?.list() ?? []).filter((record) => record.status === "done"); | |
| 794 | + if (records.length === 0) { | |
| 795 | + print(["", " /merge — no finished subtasks"]); | |
| 796 | + return; | |
| 797 | + } | |
| 798 | + const items: PaletteItem[] = records.map((record) => ({ | |
| 799 | + id: record.taskId, | |
| 800 | + label: `${record.taskId} · ${record.description.slice(0, 40)}`, | |
| 801 | + detail: `+${record.diff.added} −${record.diff.removed} · ${record.diff.files.length} file(s)`, | |
| 802 | + })); | |
| 803 | + ui.openSelector(items, (id) => { | |
| 804 | + runAsync("/merge", async () => { | |
| 805 | + const record = getSubtasks().get(id); | |
| 806 | + if (record === undefined) return; | |
| 807 | + const engine = deps.engine(); | |
| 808 | + const result = await mergeSubtaskBranch( | |
| 809 | + workspaceExec(engine.workspace), | |
| 810 | + deps.cwd, | |
| 811 | + record.worktree.branch, | |
| 812 | + `khaelor subtask ${record.taskId}: ${record.description.slice(0, 60)}`, | |
| 813 | + ); | |
| 814 | + if (result.ok) { | |
| 815 | + print(["", ` merged ${record.worktree.branch} (--no-ff)`]); | |
| 816 | + } else if (result.conflict) { | |
| 817 | + print([ | |
| 818 | + "", | |
| 819 | + ` merge conflict on ${record.worktree.branch} — aborted cleanly`, | |
| 820 | + " ask KHAELOR to resolve it with both designs in context (v2 §6)", | |
| 821 | + ]); | |
| 822 | + } else { | |
| 823 | + print(["", ` merge failed: ${result.detail}`]); | |
| 824 | + } | |
| 825 | + }); | |
| 826 | + }); | |
| 827 | + }, | |
| 828 | + }); | |
| 829 | + | |
| 830 | + // /goals — daemon goals + status, read from the project's daemon directory. | |
| 831 | + commands.push({ | |
| 832 | + id: "goals.open", | |
| 833 | + title: "Daemon goals", | |
| 834 | + slash: "/goals", | |
| 835 | + description: "long-term goals and daemon status", | |
| 836 | + run: () => | |
| 837 | + runAsync("/goals", async () => { | |
| 838 | + const daemonDir = daemonDirFor(deps.cwd); | |
| 839 | + const status = await readDaemonStatus(daemonDir); | |
| 840 | + const store = new GoalStore(daemonDir); | |
| 841 | + const goals = await store.list(); | |
| 842 | + const lines = ["", ` goals · daemon ${status !== null ? `running (pid ${status.pid})` : "not running — khaelord start"}`]; | |
| 843 | + if (goals.length === 0) lines.push(' none — khaelord goal add "<description>"'); | |
| 844 | + for (const goal of goals) { | |
| 845 | + const runs = await store.runsToday(goal.id); | |
| 846 | + const glyph = goal.status === "active" ? "●" : "○"; | |
| 847 | + lines.push( | |
| 848 | + ` ${glyph} ${goal.id} ${goal.type.padEnd(8)} ${goal.schedule.padEnd(16)} $${goal.budget.maxUsdPerDay}/day · runs ${runs}/${goal.budget.maxRunsPerDay}`, | |
| 849 | + ); | |
| 850 | + lines.push(` ${goal.description.slice(0, 70)}`); | |
| 851 | + } | |
| 852 | + print(lines); | |
| 853 | + }), | |
| 854 | + }); | |
| 855 | + | |
| 477 | 856 | return commands; |
| 478 | 857 | } |
| 479 | 858 | |
modified
src/cli/engine.ts
+53 −0
@@ -29,16 +29,21 @@ import { | ||
| 29 | 29 | buildSystemPrompt, |
| 30 | 30 | discoverProjectInstructions, |
| 31 | 31 | } from "../context/index.js"; |
| 32 | +import { MEMORY_FILE } from "../memory/index.js"; | |
| 32 | 33 | import { |
| 33 | 34 | PermissionService, |
| 34 | 35 | normalizePermissionsSection, |
| 35 | 36 | } from "../permissions/index.js"; |
| 36 | 37 | import type { PermissionAsker, PermissionRule } from "../permissions/index.js"; |
| 38 | +import { PhaseService } from "../phases/index.js"; | |
| 39 | +import type { PhaseApprovalAsker } from "../phases/index.js"; | |
| 40 | +import { RepoGraphService } from "../repograph/index.js"; | |
| 37 | 41 | import { GitService } from "../repository/index.js"; |
| 38 | 42 | import { SessionEventBus, SessionLog, defaultSessionsDir } from "../session/index.js"; |
| 39 | 43 | import type { DurableEventInput, ToolName } from "../session/index.js"; |
| 40 | 44 | import { createDefaultToolRegistry } from "../tools/index.js"; |
| 41 | 45 | import type { ToolRegistry } from "../tools/index.js"; |
| 46 | +import { VerifyRunner, loadVerifyConfig } from "../verify/index.js"; | |
| 42 | 47 | import { |
| 43 | 48 | InMemoryFileTimeRegistry, |
| 44 | 49 | LocalProcessManager, |
@@ -126,6 +131,8 @@ export interface AssembleEngineOptions { | ||
| 126 | 131 | context: SessionContext; |
| 127 | 132 | /** TUI permission panel callback; absent → non-interactive (asks resolve deny). */ |
| 128 | 133 | asker?: PermissionAsker; |
| 134 | + /** TUI design-approval panel (strict gate mode); absent → large designs stay pending. */ | |
| 135 | + designAsker?: PhaseApprovalAsker; | |
| 129 | 136 | logger: FileLogger; |
| 130 | 137 | /** User dir for KHAELOR.md discovery. Default ~/.khaelor. */ |
| 131 | 138 | userDir?: string; |
@@ -153,6 +160,9 @@ export class Engine { | ||
| 153 | 160 | readonly modelClient: AnthropicModelClient; |
| 154 | 161 | readonly executor: ToolExecutor; |
| 155 | 162 | readonly logger: FileLogger; |
| 163 | + readonly phases: PhaseService; | |
| 164 | + readonly repograph: RepoGraphService; | |
| 165 | + readonly verifyRunner: VerifyRunner; | |
| 156 | 166 | |
| 157 | 167 | contextEngine: KhaelorContextEngine; |
| 158 | 168 | budget: ContextBudget; |
@@ -179,6 +189,9 @@ export class Engine { | ||
| 179 | 189 | logger: FileLogger; |
| 180 | 190 | systemArgs: SystemPromptArgs; |
| 181 | 191 | bridge: ProcessBridge; |
| 192 | + phases: PhaseService; | |
| 193 | + repograph: RepoGraphService; | |
| 194 | + verifyRunner: VerifyRunner; | |
| 182 | 195 | }) { |
| 183 | 196 | this.#config = args.config; |
| 184 | 197 | this.session = args.context.session; |
@@ -197,6 +210,9 @@ export class Engine { | ||
| 197 | 210 | this.model = args.config.model; |
| 198 | 211 | this.#systemArgs = args.systemArgs; |
| 199 | 212 | this.#bridge = args.bridge; |
| 213 | + this.phases = args.phases; | |
| 214 | + this.repograph = args.repograph; | |
| 215 | + this.verifyRunner = args.verifyRunner; | |
| 200 | 216 | this.steering = new SteeringQueue(this.session); |
| 201 | 217 | this.interruption = new InterruptionController(this.session); |
| 202 | 218 | this.verifier = new VerificationGate({ |
@@ -412,6 +428,25 @@ export async function assembleEngine(options: AssembleEngineOptions): Promise<En | ||
| 412 | 428 | }, |
| 413 | 429 | }); |
| 414 | 430 | |
| 431 | + // ── v2 services around the kernel: phases, semantic index, native verify ── | |
| 432 | + const phases = new PhaseService({ | |
| 433 | + session: context.session, | |
| 434 | + config: { mode: config.gate.mode, autoApprove: { ...config.gate.autoApprove } }, | |
| 435 | + projectRoot: cwd, | |
| 436 | + ...(options.designAsker !== undefined ? { asker: options.designAsker } : {}), | |
| 437 | + }); | |
| 438 | + const repograph = new RepoGraphService({ workspace }); | |
| 439 | + // Background warm-up (visible cost stays out of the first tool call). | |
| 440 | + void repograph.ensureIndexed().catch((error: unknown) => { | |
| 441 | + options.logger.log("warn", "repograph initial index failed", { error: String(error) }); | |
| 442 | + }); | |
| 443 | + const verifyConfig = await loadVerifyConfig(workspace); | |
| 444 | + const verifyRunner = new VerifyRunner({ | |
| 445 | + workspace, | |
| 446 | + session: context.session, | |
| 447 | + config: verifyConfig, | |
| 448 | + }); | |
| 449 | + | |
| 415 | 450 | const executor = new ToolExecutor({ |
| 416 | 451 | session: context.session, |
| 417 | 452 | registry, |
@@ -421,9 +456,22 @@ export async function assembleEngine(options: AssembleEngineOptions): Promise<En | ||
| 421 | 456 | processes, |
| 422 | 457 | spillDir: join(dataDir, "spill"), |
| 423 | 458 | home: homedir(), |
| 459 | + phases, | |
| 460 | + repograph, | |
| 461 | + verify: verifyRunner, | |
| 424 | 462 | }); |
| 425 | 463 | |
| 426 | 464 | const instructions = await discoverProjectInstructions(workspace, { userDir }); |
| 465 | + // Project memory (v2 §5): auto-maintained facts join the instruction tier. | |
| 466 | + try { | |
| 467 | + const memoryPath = join(cwd, MEMORY_FILE); | |
| 468 | + const memoryContent = await workspace.readFile(memoryPath); | |
| 469 | + if (memoryContent.trim().length > 0) { | |
| 470 | + instructions.push({ path: memoryPath, scope: "project", content: memoryContent }); | |
| 471 | + } | |
| 472 | + } catch { | |
| 473 | + // no project memory yet | |
| 474 | + } | |
| 427 | 475 | const systemArgs = { workingDirectory: cwd, toolNames, instructions }; |
| 428 | 476 | |
| 429 | 477 | const modelClient = new AnthropicModelClient({ apiKey: config.apiKey ?? "" }); |
@@ -455,6 +503,9 @@ export async function assembleEngine(options: AssembleEngineOptions): Promise<En | ||
| 455 | 503 | logger: options.logger, |
| 456 | 504 | systemArgs, |
| 457 | 505 | bridge, |
| 506 | + phases, | |
| 507 | + repograph, | |
| 508 | + verifyRunner, | |
| 458 | 509 | }); |
| 459 | 510 | |
| 460 | 511 | // Session lifecycle event + resume recovery — recorded before any turn runs. |
@@ -483,6 +534,8 @@ export async function assembleEngine(options: AssembleEngineOptions): Promise<En | ||
| 483 | 534 | khaelorVersion: KHAELOR_VERSION, |
| 484 | 535 | }, |
| 485 | 536 | }); |
| 537 | + // Gated sessions open in understand (v2 §1). | |
| 538 | + phases.ensureStarted(); | |
| 486 | 539 | } |
| 487 | 540 | |
| 488 | 541 | return engine; |
added
src/cli/headless.ts
+97 −0
@@ -0,0 +1,97 @@ | ||
| 1 | +/** | |
| 2 | + * KHAELOR | |
| 3 | + * File: src/cli/headless.ts | |
| 4 | + * Description: Shared headless run driver — one agent turn in an arbitrary cwd, used by subtasks, goal runs, and /replay (v2 design §2, §6, §7). | |
| 5 | + * | |
| 6 | + * Author: Simon-Pierre Boucher | |
| 7 | + * Contact: contact@spboucher.ai | |
| 8 | + */ | |
| 9 | + | |
| 10 | +import type { ResolvedConfig } from "../config/index.js"; | |
| 11 | +import { buildUsageTotals, writeSessionMeta } from "../session/index.js"; | |
| 12 | +import type { DurableEvent, ModelUsageTotals } from "../session/index.js"; | |
| 13 | +import { assembleEngine, openSessionContext } from "./engine.js"; | |
| 14 | +import type { FileLogger } from "./logger.js"; | |
| 15 | +import { finalAnswerText } from "./print.js"; | |
| 16 | + | |
| 17 | +export interface HeadlessRunResult { | |
| 18 | + outcome: "done" | "failed" | "interrupted" | "idle"; | |
| 19 | + detail: string; | |
| 20 | + sessionId: string; | |
| 21 | + finalText: string; | |
| 22 | + usage: ModelUsageTotals; | |
| 23 | + /** Latest native verify round: true = all checks passed, null = no checks ran. */ | |
| 24 | + verifyOk: boolean | null; | |
| 25 | +} | |
| 26 | + | |
| 27 | +/** Latest verify verdict per check, folded to one boolean (null = never verified). */ | |
| 28 | +export function latestVerifyVerdict(events: readonly DurableEvent[]): boolean | null { | |
| 29 | + const latest = new Map<string, boolean>(); | |
| 30 | + for (const event of events) { | |
| 31 | + if (event.type === "verify.result") latest.set(event.payload.check, event.payload.ok); | |
| 32 | + } | |
| 33 | + if (latest.size === 0) return null; | |
| 34 | + return [...latest.values()].every((ok) => ok); | |
| 35 | +} | |
| 36 | + | |
| 37 | +export interface HeadlessRunOptions { | |
| 38 | + config: ResolvedConfig; | |
| 39 | + cwd: string; | |
| 40 | + prompt: string; | |
| 41 | + logger: FileLogger; | |
| 42 | + sessionsDir?: string; | |
| 43 | + sessionId?: string; | |
| 44 | + /** Lineage recorded in <sessionId>.meta.json (v2 §2/§6). */ | |
| 45 | + meta?: { parent: string | null; replayOf?: string }; | |
| 46 | +} | |
| 47 | + | |
| 48 | +/** | |
| 49 | + * One full agent turn without a TUI: non-interactive permissions (asks | |
| 50 | + * resolve deny), phase gates in the configured mode, native verification | |
| 51 | + * active. This is the execution primitive for subtasks (§6), daemon goal | |
| 52 | + * runs (§7), and /replay (§2). | |
| 53 | + */ | |
| 54 | +export async function runHeadlessTurn(options: HeadlessRunOptions): Promise<HeadlessRunResult> { | |
| 55 | + const context = await openSessionContext({ | |
| 56 | + cwd: options.cwd, | |
| 57 | + ...(options.sessionsDir !== undefined ? { sessionsDir: options.sessionsDir } : {}), | |
| 58 | + logger: options.logger, | |
| 59 | + }); | |
| 60 | + const engine = await assembleEngine({ | |
| 61 | + config: options.config, | |
| 62 | + cwd: options.cwd, | |
| 63 | + context, | |
| 64 | + logger: options.logger, | |
| 65 | + }); | |
| 66 | + try { | |
| 67 | + if (options.meta !== undefined) { | |
| 68 | + const sessionsDirOf = context.log.filePath.slice( | |
| 69 | + 0, | |
| 70 | + context.log.filePath.length - `/${context.hash}/${context.sessionId}.jsonl`.length, | |
| 71 | + ); | |
| 72 | + await writeSessionMeta(sessionsDirOf, context.hash, context.sessionId, { | |
| 73 | + parent: options.meta.parent, | |
| 74 | + forkPoint: null, | |
| 75 | + ...(options.meta.replayOf !== undefined ? { replayOf: options.meta.replayOf } : {}), | |
| 76 | + createdAt: Date.now(), | |
| 77 | + }); | |
| 78 | + } | |
| 79 | + await engine.captureBaseline(); | |
| 80 | + engine.session.publishDurable({ | |
| 81 | + type: "user.message-created", | |
| 82 | + payload: { text: options.prompt, mentions: [] }, | |
| 83 | + }); | |
| 84 | + const outcome = await engine.runTurn(); | |
| 85 | + const events = engine.session.events(); | |
| 86 | + return { | |
| 87 | + outcome: outcome.kind === "done" ? "done" : outcome.kind === "failed" ? "failed" : outcome.kind === "interrupted" ? "interrupted" : "idle", | |
| 88 | + detail: outcome.kind === "failed" ? outcome.detail : "", | |
| 89 | + sessionId: context.sessionId, | |
| 90 | + finalText: finalAnswerText(events), | |
| 91 | + usage: buildUsageTotals(events).totals, | |
| 92 | + verifyOk: latestVerifyVerdict(events), | |
| 93 | + }; | |
| 94 | + } finally { | |
| 95 | + await engine.shutdown(); | |
| 96 | + } | |
| 97 | +} | |
modified
src/cli/main.ts
+60 −0
@@ -239,6 +239,31 @@ async function runInteractiveSession(options: InteractiveOptions): Promise<Sessi | ||
| 239 | 239 | context, |
| 240 | 240 | asker, |
| 241 | 241 | logger, |
| 242 | + // Strict-mode design approval (v2 §1): the panel content prints, the | |
| 243 | + // selector decides; Esc rejects — silence is never approval. | |
| 244 | + designAsker: { | |
| 245 | + askDesign: (_artifactId, artifact) => | |
| 246 | + new Promise((resolve) => { | |
| 247 | + app.printBlock([ | |
| 248 | + "", | |
| 249 | + " ◑ DESIGN — approval required", | |
| 250 | + ` Goal ${artifact.goal}`, | |
| 251 | + ` Files ${artifact.filesTouched.join(", ")}`, | |
| 252 | + ...artifact.approach.split("\n").slice(0, 6).map((line, i) => ` ${i === 0 ? "Approach " : " "}${line}`), | |
| 253 | + ...(artifact.risks.length > 0 ? [` Risks ${artifact.risks.join(" · ")}`] : []), | |
| 254 | + ` Verify ${artifact.verification}`, | |
| 255 | + ...(artifact.outOfScope.length > 0 ? [` Not doing ${artifact.outOfScope.join(" · ")}`] : []), | |
| 256 | + ]); | |
| 257 | + app.openSelector( | |
| 258 | + [ | |
| 259 | + { id: "approve", label: "Approve design", detail: "unlock the implement phase" }, | |
| 260 | + { id: "reject", label: "Reject design", detail: "send KHAELOR back to the drawing board" }, | |
| 261 | + ], | |
| 262 | + (id) => resolve({ approved: id === "approve" }), | |
| 263 | + () => resolve({ approved: false, reason: "dismissed without approval" }), | |
| 264 | + ); | |
| 265 | + }), | |
| 266 | + }, | |
| 242 | 267 | }); |
| 243 | 268 | engineRef.current = assembled; |
| 244 | 269 | registerCliCommands(app.commands, { |
@@ -314,6 +339,40 @@ export async function main(argv: readonly string[] = process.argv.slice(2)): Pro | ||
| 314 | 339 | process.stdout.write(`khaelor ${KHAELOR_VERSION}\n`); |
| 315 | 340 | return; |
| 316 | 341 | } |
| 342 | + if (args.doctorTui) { | |
| 343 | + const { doctorReport } = await import("../tui/doctor.js"); | |
| 344 | + const report = doctorReport( | |
| 345 | + { | |
| 346 | + ...(process.env["TERM"] !== undefined ? { TERM: process.env["TERM"] } : {}), | |
| 347 | + ...(process.env["COLORTERM"] !== undefined ? { COLORTERM: process.env["COLORTERM"] } : {}), | |
| 348 | + ...(process.env["NO_COLOR"] !== undefined ? { NO_COLOR: process.env["NO_COLOR"] } : {}), | |
| 349 | + ...(process.env["LANG"] !== undefined ? { LANG: process.env["LANG"] } : {}), | |
| 350 | + ...(process.env["LC_ALL"] !== undefined ? { LC_ALL: process.env["LC_ALL"] } : {}), | |
| 351 | + ...(process.env["TERM_PROGRAM"] !== undefined ? { TERM_PROGRAM: process.env["TERM_PROGRAM"] } : {}), | |
| 352 | + }, | |
| 353 | + process.stdout.isTTY === true, | |
| 354 | + process.stdout.columns ?? 80, | |
| 355 | + ); | |
| 356 | + process.stdout.write(`${report.join("\n")}\n`); | |
| 357 | + return; | |
| 358 | + } | |
| 359 | + if (args.splash && process.stdout.isTTY === true) { | |
| 360 | + // The full opening moment (~600 ms), skippable by any key (TUI v2 §2). | |
| 361 | + const { renderSplashFrame, SPLASH_FRAMES } = await import("../tui/splash.js"); | |
| 362 | + const truecolor = process.env["COLORTERM"] === "truecolor" && process.env["NO_COLOR"] === undefined; | |
| 363 | + let skipped = false; | |
| 364 | + const onKey = (): void => { | |
| 365 | + skipped = true; | |
| 366 | + }; | |
| 367 | + process.stdin.on("data", onKey); | |
| 368 | + for (let frame = 0; frame < SPLASH_FRAMES && !skipped; frame += 1) { | |
| 369 | + const lines = renderSplashFrame(frame, truecolor); | |
| 370 | + if (frame > 0) process.stdout.write(`\x1b[${lines.length}A`); | |
| 371 | + process.stdout.write(`${lines.join("\x1b[K\n")}\x1b[K\n`); | |
| 372 | + await new Promise((resolve) => setTimeout(resolve, 60)); | |
| 373 | + } | |
| 374 | + process.stdin.off("data", onKey); | |
| 375 | + } | |
| 317 | 376 | |
| 318 | 377 | const logger = new FileLogger(join(defaultLogDir(), "khaelor.log"), { debug: args.debug }); |
| 319 | 378 | installCrashHandlers(logger); |
@@ -326,6 +385,7 @@ export async function main(argv: readonly string[] = process.argv.slice(2)): Pro | ||
| 326 | 385 | ...(args.auxModel !== null ? { auxModel: args.auxModel } : {}), |
| 327 | 386 | ...(args.thinking !== null ? { thinking: args.thinking } : {}), |
| 328 | 387 | ...(args.maxOutputTokens !== null ? { maxOutputTokens: args.maxOutputTokens } : {}), |
| 388 | + ...(args.gate !== null ? { gate: args.gate } : {}), | |
| 329 | 389 | }, |
| 330 | 390 | }); |
| 331 | 391 | if (!config.hasApiKey) { |
added
src/cli/replay.ts
+117 −0
@@ -0,0 +1,117 @@ | ||
| 1 | +/** | |
| 2 | + * KHAELOR | |
| 3 | + * File: src/cli/replay.ts | |
| 4 | + * Description: /replay driver — re-run a session's user turns with another model, optionally sandboxed in a worktree (v2 design §2). | |
| 5 | + * | |
| 6 | + * Author: Simon-Pierre Boucher | |
| 7 | + * Contact: contact@spboucher.ai | |
| 8 | + */ | |
| 9 | + | |
| 10 | +import type { ResolvedConfig } from "../config/index.js"; | |
| 11 | +import { | |
| 12 | + SessionLog, | |
| 13 | + buildUsageTotals, | |
| 14 | + extractUserTurns, | |
| 15 | + summarizeSessionRun, | |
| 16 | + writeSessionMeta, | |
| 17 | +} from "../session/index.js"; | |
| 18 | +import type { SessionRunSummary } from "../session/index.js"; | |
| 19 | +import { createWorktree, removeWorktree } from "../tasks/index.js"; | |
| 20 | +import type { ExecFn } from "../tasks/index.js"; | |
| 21 | +import { assembleEngine, openSessionContext } from "./engine.js"; | |
| 22 | +import type { FileLogger } from "./logger.js"; | |
| 23 | +import { projectHash } from "./sessions.js"; | |
| 24 | + | |
| 25 | +export interface ReplayOptions { | |
| 26 | + config: ResolvedConfig; | |
| 27 | + cwd: string; | |
| 28 | + sourceSessionId: string; | |
| 29 | + sessionsDir: string; | |
| 30 | + logger: FileLogger; | |
| 31 | + /** Replay inside a throwaway worktree so tool calls have no side effects on the working copy (v2 §2/§6). */ | |
| 32 | + sandbox?: { exec: ExecFn }; | |
| 33 | + onProgress?: (message: string) => void; | |
| 34 | +} | |
| 35 | + | |
| 36 | +export interface ReplayResult { | |
| 37 | + newSessionId: string; | |
| 38 | + turnsReplayed: number; | |
| 39 | + summary: SessionRunSummary; | |
| 40 | +} | |
| 41 | + | |
| 42 | +/** | |
| 43 | + * Replay = re-run the *user* turns sequentially in a fresh session. Tool | |
| 44 | + * calls are re-executed, not replayed from the log — combine with sandbox | |
| 45 | + * to avoid side effects. The result is /sdiff-ready (v2 §2). | |
| 46 | + */ | |
| 47 | +export async function replaySession(options: ReplayOptions): Promise<ReplayResult> { | |
| 48 | + const hash = projectHash(options.cwd); | |
| 49 | + const source = await SessionLog.open({ | |
| 50 | + projectHash: hash, | |
| 51 | + sessionId: options.sourceSessionId, | |
| 52 | + sessionsDir: options.sessionsDir, | |
| 53 | + }); | |
| 54 | + const turns = extractUserTurns(source.replayedEvents); | |
| 55 | + if (turns.length === 0) { | |
| 56 | + throw new Error(`Session ${options.sourceSessionId} has no user turns to replay.`); | |
| 57 | + } | |
| 58 | + | |
| 59 | + let runCwd = options.cwd; | |
| 60 | + let worktree: Awaited<ReturnType<typeof createWorktree>> | null = null; | |
| 61 | + if (options.sandbox !== undefined) { | |
| 62 | + worktree = await createWorktree( | |
| 63 | + options.sandbox.exec, | |
| 64 | + options.cwd, | |
| 65 | + `replay-${Date.now().toString(36)}`, | |
| 66 | + ); | |
| 67 | + runCwd = worktree.path; | |
| 68 | + } | |
| 69 | + | |
| 70 | + try { | |
| 71 | + const context = await openSessionContext({ | |
| 72 | + cwd: runCwd, | |
| 73 | + sessionsDir: options.sessionsDir, | |
| 74 | + logger: options.logger, | |
| 75 | + }); | |
| 76 | + const engine = await assembleEngine({ | |
| 77 | + config: options.config, | |
| 78 | + cwd: runCwd, | |
| 79 | + context, | |
| 80 | + logger: options.logger, | |
| 81 | + }); | |
| 82 | + try { | |
| 83 | + await writeSessionMeta(options.sessionsDir, context.hash, context.sessionId, { | |
| 84 | + parent: null, | |
| 85 | + forkPoint: null, | |
| 86 | + replayOf: options.sourceSessionId, | |
| 87 | + createdAt: Date.now(), | |
| 88 | + }); | |
| 89 | + await engine.captureBaseline(); | |
| 90 | + let replayed = 0; | |
| 91 | + for (const turn of turns) { | |
| 92 | + replayed += 1; | |
| 93 | + options.onProgress?.(`replaying turn ${replayed}/${turns.length}`); | |
| 94 | + engine.session.publishDurable({ | |
| 95 | + type: "user.message-created", | |
| 96 | + payload: { text: turn, mentions: [] }, | |
| 97 | + }); | |
| 98 | + const outcome = await engine.runTurn(); | |
| 99 | + if (outcome.kind === "failed") break; // honest stop — the summary shows it | |
| 100 | + } | |
| 101 | + const events = engine.session.events(); | |
| 102 | + // Touch usage so the totals are computed once even if the caller ignores them. | |
| 103 | + buildUsageTotals(events); | |
| 104 | + return { | |
| 105 | + newSessionId: context.sessionId, | |
| 106 | + turnsReplayed: replayed, | |
| 107 | + summary: summarizeSessionRun(context.sessionId, events), | |
| 108 | + }; | |
| 109 | + } finally { | |
| 110 | + await engine.shutdown(); | |
| 111 | + } | |
| 112 | + } finally { | |
| 113 | + if (worktree !== null && options.sandbox !== undefined) { | |
| 114 | + await removeWorktree(options.sandbox.exec, options.cwd, worktree, { deleteBranch: true }); | |
| 115 | + } | |
| 116 | + } | |
| 117 | +} | |
added
src/cli/subtasks.ts
+71 −0
@@ -0,0 +1,71 @@ | ||
| 1 | +/** | |
| 2 | + * KHAELOR | |
| 3 | + * File: src/cli/subtasks.ts | |
| 4 | + * Description: Subtask wiring — SubtaskManager factory whose child runner assembles a full headless engine per worktree (v2 design §6). | |
| 5 | + * | |
| 6 | + * Author: Simon-Pierre Boucher | |
| 7 | + * Contact: contact@spboucher.ai | |
| 8 | + */ | |
| 9 | + | |
| 10 | +import type { ResolvedConfig } from "../config/index.js"; | |
| 11 | +import type { DurableEventInput } from "../session/index.js"; | |
| 12 | +import { SubtaskManager } from "../tasks/index.js"; | |
| 13 | +import type { ExecFn } from "../tasks/index.js"; | |
| 14 | +import type { Workspace } from "../workspace/index.js"; | |
| 15 | +import { runHeadlessTurn } from "./headless.js"; | |
| 16 | +import type { FileLogger } from "./logger.js"; | |
| 17 | + | |
| 18 | +const CHILD_PROMPT_SUFFIX = | |
| 19 | + "\n\nYou are running as an isolated KHAELOR subtask inside a dedicated git worktree. " + | |
| 20 | + "Work only inside this worktree. Do not push, merge, or touch branches — the orchestrator " + | |
| 21 | + "supervises the merge. Design first, verify before finishing."; | |
| 22 | + | |
| 23 | +/** Adapt Workspace.exec to the tasks module's minimal exec seam. */ | |
| 24 | +export function workspaceExec(workspace: Workspace): ExecFn { | |
| 25 | + return async (cmd, cwd) => { | |
| 26 | + const result = await workspace.exec({ cmd, timeoutMs: 120_000, ...(cwd !== undefined ? { cwd } : {}) }); | |
| 27 | + return { exitCode: result.exitCode, stdout: result.stdout, stderr: result.stderr }; | |
| 28 | + }; | |
| 29 | +} | |
| 30 | + | |
| 31 | +export interface CreateSubtaskManagerOptions { | |
| 32 | + config: ResolvedConfig; | |
| 33 | + workspace: Workspace; | |
| 34 | + projectRoot: string; | |
| 35 | + logger: FileLogger; | |
| 36 | + publish: (event: DurableEventInput) => void; | |
| 37 | + /** Orchestrator session id — recorded as the child's meta.parent. */ | |
| 38 | + parentSessionId: string; | |
| 39 | +} | |
| 40 | + | |
| 41 | +/** | |
| 42 | + * Build the SubtaskManager with a child runner that assembles a complete | |
| 43 | + * headless engine over the worktree: own JSONL (meta.parent → orchestrator), | |
| 44 | + * non-interactive permissions (capability attenuation: asks resolve deny), | |
| 45 | + * phase gates in the configured mode, native verify active (v2 §6). | |
| 46 | + */ | |
| 47 | +export function createSubtaskManager(options: CreateSubtaskManagerOptions): SubtaskManager { | |
| 48 | + return new SubtaskManager({ | |
| 49 | + publish: options.publish, | |
| 50 | + exec: workspaceExec(options.workspace), | |
| 51 | + projectRoot: options.projectRoot, | |
| 52 | + runChild: async (args) => { | |
| 53 | + const result = await runHeadlessTurn({ | |
| 54 | + config: options.config, | |
| 55 | + cwd: args.worktreePath, | |
| 56 | + prompt: args.description + CHILD_PROMPT_SUFFIX, | |
| 57 | + logger: options.logger, | |
| 58 | + meta: { parent: options.parentSessionId }, | |
| 59 | + }); | |
| 60 | + return { | |
| 61 | + status: | |
| 62 | + result.outcome === "done" ? "done" : result.outcome === "interrupted" ? "interrupted" : "failed", | |
| 63 | + verifyOk: result.verifyOk, | |
| 64 | + detail: | |
| 65 | + result.outcome === "done" | |
| 66 | + ? result.finalText.slice(0, 2000) | |
| 67 | + : result.detail || result.finalText.slice(0, 2000), | |
| 68 | + }; | |
| 69 | + }, | |
| 70 | + }); | |
| 71 | +} | |
modified
src/config/index.ts
+8 −1
@@ -7,8 +7,15 @@ | ||
| 7 | 7 | * Contact: contact@spboucher.ai |
| 8 | 8 | */ |
| 9 | 9 | |
| 10 | −export { DEFAULT_CONFIG, validatePartialConfig, validatePermissionsSection } from "./schema.js"; | |
| 10 | +export { | |
| 11 | + DEFAULT_CONFIG, | |
| 12 | + validateGateSection, | |
| 13 | + validatePartialConfig, | |
| 14 | + validatePermissionsSection, | |
| 15 | +} from "./schema.js"; | |
| 11 | 16 | export type { |
| 17 | + GateModeSetting, | |
| 18 | + GateSection, | |
| 12 | 19 | KhaelorConfig, |
| 13 | 20 | PartialKhaelorConfig, |
| 14 | 21 | ThinkingMode, |
modified
src/config/loader.ts
+11 −0
@@ -13,6 +13,7 @@ import { join } from "node:path"; | ||
| 13 | 13 | import { KhaelorError, unwrap } from "../shared/index.js"; |
| 14 | 14 | import { DEFAULT_CONFIG, validatePartialConfig } from "./schema.js"; |
| 15 | 15 | import type { |
| 16 | + GateSection, | |
| 16 | 17 | KhaelorConfig, |
| 17 | 18 | PartialKhaelorConfig, |
| 18 | 19 | PermissionsSection, |
@@ -27,6 +28,8 @@ export interface CliConfigFlags { | ||
| 27 | 28 | auxModel?: string; |
| 28 | 29 | thinking?: string; |
| 29 | 30 | maxOutputTokens?: number; |
| 31 | + /** `khaelor --gate strict|auto|off` (v2 §1). */ | |
| 32 | + gate?: string; | |
| 30 | 33 | } |
| 31 | 34 | |
| 32 | 35 | export interface LoadConfigOptions { |
@@ -52,6 +55,7 @@ export class ResolvedConfig implements KhaelorConfig { | ||
| 52 | 55 | readonly thinking: ThinkingMode; |
| 53 | 56 | readonly maxOutputTokens: number; |
| 54 | 57 | readonly permissions: Readonly<PermissionsSection>; |
| 58 | + readonly gate: Readonly<GateSection>; | |
| 55 | 59 | /** Source paths that contributed, highest precedence first (for /config display). */ |
| 56 | 60 | readonly sources: readonly string[]; |
| 57 | 61 | |
@@ -63,6 +67,10 @@ export class ResolvedConfig implements KhaelorConfig { | ||
| 63 | 67 | this.thinking = config.thinking; |
| 64 | 68 | this.maxOutputTokens = config.maxOutputTokens; |
| 65 | 69 | this.permissions = Object.freeze({ ...config.permissions }); |
| 70 | + this.gate = Object.freeze({ | |
| 71 | + mode: config.gate.mode, | |
| 72 | + autoApprove: Object.freeze({ ...config.gate.autoApprove }), | |
| 73 | + }); | |
| 66 | 74 | this.sources = Object.freeze([...sources]); |
| 67 | 75 | this.#apiKey = apiKey; |
| 68 | 76 | } |
@@ -84,6 +92,7 @@ export class ResolvedConfig implements KhaelorConfig { | ||
| 84 | 92 | thinking: this.thinking, |
| 85 | 93 | maxOutputTokens: this.maxOutputTokens, |
| 86 | 94 | permissions: this.permissions, |
| 95 | + gate: this.gate, | |
| 87 | 96 | sources: this.sources, |
| 88 | 97 | apiKey: this.hasApiKey ? REDACTED : null, |
| 89 | 98 | }; |
@@ -126,6 +135,7 @@ function flagsToPartial(flags: CliConfigFlags): unknown { | ||
| 126 | 135 | if (flags.auxModel !== undefined) raw["auxModel"] = flags.auxModel; |
| 127 | 136 | if (flags.thinking !== undefined) raw["thinking"] = flags.thinking; |
| 128 | 137 | if (flags.maxOutputTokens !== undefined) raw["maxOutputTokens"] = flags.maxOutputTokens; |
| 138 | + if (flags.gate !== undefined) raw["gate"] = { mode: flags.gate }; | |
| 129 | 139 | return raw; |
| 130 | 140 | } |
| 131 | 141 | |
@@ -156,6 +166,7 @@ function mergeTier(base: KhaelorConfig, tier: PartialKhaelorConfig): KhaelorConf | ||
| 156 | 166 | thinking: tier.thinking ?? base.thinking, |
| 157 | 167 | maxOutputTokens: tier.maxOutputTokens ?? base.maxOutputTokens, |
| 158 | 168 | permissions: mergePermissions(base.permissions, tier.permissions), |
| 169 | + gate: tier.gate ?? base.gate, | |
| 159 | 170 | }; |
| 160 | 171 | } |
| 161 | 172 | |
modified
src/config/schema.ts
+57 −0
@@ -36,6 +36,15 @@ export interface PermissionsSection { | ||
| 36 | 36 | | undefined; |
| 37 | 37 | } |
| 38 | 38 | |
| 39 | +/** Phase-gate rigor mode (v2 design §1): strict | auto | off. */ | |
| 40 | +export type GateModeSetting = "strict" | "auto" | "off"; | |
| 41 | + | |
| 42 | +/** The `gate` config section (v2 design §1). */ | |
| 43 | +export interface GateSection { | |
| 44 | + mode: GateModeSetting; | |
| 45 | + autoApprove: { maxFiles: number }; | |
| 46 | +} | |
| 47 | + | |
| 39 | 48 | /** User-configurable settings (CLAUDE.md §6). */ |
| 40 | 49 | export interface KhaelorConfig { |
| 41 | 50 | /** Anthropic model id — configurable, never a hard-coded permanent list. */ |
@@ -46,6 +55,8 @@ export interface KhaelorConfig { | ||
| 46 | 55 | maxOutputTokens: number; |
| 47 | 56 | /** Permission policy section (shorthand, nested, and `rules` forms — §4.1). */ |
| 48 | 57 | permissions: PermissionsSection; |
| 58 | + /** Phase-gate configuration (v2 §1): understand → design → implement. */ | |
| 59 | + gate: GateSection; | |
| 49 | 60 | } |
| 50 | 61 | |
| 51 | 62 | export type PartialKhaelorConfig = Partial<KhaelorConfig>; |
@@ -57,8 +68,49 @@ export const DEFAULT_CONFIG: Readonly<KhaelorConfig> = Object.freeze({ | ||
| 57 | 68 | thinking: "adaptive" as ThinkingMode, |
| 58 | 69 | maxOutputTokens: 16000, |
| 59 | 70 | permissions: Object.freeze({}) as PermissionsSection, |
| 71 | + gate: Object.freeze({ | |
| 72 | + mode: "auto" as GateModeSetting, | |
| 73 | + autoApprove: Object.freeze({ maxFiles: 3 }), | |
| 74 | + }) as GateSection, | |
| 60 | 75 | }); |
| 61 | 76 | |
| 77 | +const GATE_MODES: readonly string[] = ["strict", "auto", "off"]; | |
| 78 | + | |
| 79 | +/** Validate a raw `gate` section (v2 §1). */ | |
| 80 | +export function validateGateSection( | |
| 81 | + value: unknown, | |
| 82 | + source: string, | |
| 83 | +): Result<GateSection, KhaelorError> { | |
| 84 | + if (value === null || typeof value !== "object" || Array.isArray(value)) { | |
| 85 | + return err(invalid(source, `"gate" must be an object`)); | |
| 86 | + } | |
| 87 | + const raw = value as Record<string, unknown>; | |
| 88 | + const out: GateSection = { | |
| 89 | + mode: DEFAULT_CONFIG.gate.mode, | |
| 90 | + autoApprove: { ...DEFAULT_CONFIG.gate.autoApprove }, | |
| 91 | + }; | |
| 92 | + if ("mode" in raw) { | |
| 93 | + if (typeof raw["mode"] !== "string" || !GATE_MODES.includes(raw["mode"])) { | |
| 94 | + return err(invalid(source, `"gate.mode" must be one of: ${GATE_MODES.join(", ")}`)); | |
| 95 | + } | |
| 96 | + out.mode = raw["mode"] as GateModeSetting; | |
| 97 | + } | |
| 98 | + if ("autoApprove" in raw) { | |
| 99 | + const auto = raw["autoApprove"]; | |
| 100 | + if (auto === null || typeof auto !== "object" || Array.isArray(auto)) { | |
| 101 | + return err(invalid(source, `"gate.autoApprove" must be an object`)); | |
| 102 | + } | |
| 103 | + const maxFiles = (auto as Record<string, unknown>)["maxFiles"]; | |
| 104 | + if (maxFiles !== undefined) { | |
| 105 | + if (typeof maxFiles !== "number" || !Number.isInteger(maxFiles) || maxFiles < 0) { | |
| 106 | + return err(invalid(source, `"gate.autoApprove.maxFiles" must be a non-negative integer`)); | |
| 107 | + } | |
| 108 | + out.autoApprove.maxFiles = maxFiles; | |
| 109 | + } | |
| 110 | + } | |
| 111 | + return ok(out); | |
| 112 | +} | |
| 113 | + | |
| 62 | 114 | const THINKING_MODES: readonly string[] = ["off", "adaptive", "always"]; |
| 63 | 115 | const PERMISSION_ACTIONS: readonly string[] = ["allow", "ask", "deny"]; |
| 64 | 116 | |
@@ -227,6 +279,11 @@ export function validatePartialConfig( | ||
| 227 | 279 | if (!permissions.ok) return permissions; |
| 228 | 280 | out.permissions = permissions.value; |
| 229 | 281 | } |
| 282 | + if ("gate" in raw) { | |
| 283 | + const gate = validateGateSection(raw["gate"], source); | |
| 284 | + if (!gate.ok) return gate; | |
| 285 | + out.gate = gate.value; | |
| 286 | + } | |
| 230 | 287 | |
| 231 | 288 | return ok(out); |
| 232 | 289 | } |
modified
src/context/system-prompt.ts
+4 −2
@@ -111,14 +111,16 @@ export interface SystemPromptOptions { | ||
| 111 | 111 | |
| 112 | 112 | const IDENTITY_TEXT = `You are KHAELOR, a terminal-native autonomous engineering agent. |
| 113 | 113 | |
| 114 | −You work directly inside the user's repository. Understand before changing: read the relevant code, then make precise, minimal edits. Verify meaningful changes with the repository's own checks (tests, typecheck, build) before claiming completion. Never fabricate results, token counts, or test outcomes — every claim must be backed by observed evidence. | |
| 114 | +You work directly inside the user's repository, in three phases: understand → design → implement. Understand first: read the relevant code. Then design: for any change that touches files, submit a design with the "design" tool (goal, approach, files, risks, verification) — write tools stay locked until a design is approved, and a PHASE_GATE_BLOCKED error means you must design first. Then implement: precise, minimal edits. Verify meaningful changes with the repository's own checks (tests, typecheck, build) before claiming completion — failing verify results arrive automatically after edit batches; repair them. Never fabricate results, token counts, or test outcomes — every claim must be backed by observed evidence. | |
| 115 | + | |
| 116 | +When you discover a durable fact about the project (a convention, a build command, a pitfall), persist it with the "remember" tool so future sessions know it. | |
| 115 | 117 | |
| 116 | 118 | Communication style: short, specific, action-oriented. No filler, no preamble about what you will do next — do it. Protect the user's uncommitted work at all times and never commit unless explicitly asked.`; |
| 117 | 119 | |
| 118 | 120 | function toolTierText(toolNames: readonly string[]): string { |
| 119 | 121 | return `Available tools: ${toolNames.join(", ")}. |
| 120 | 122 | |
| 121 | −Use read/grep/glob to explore before editing. Prefer edit (exact replacement) over write for existing files. Use bash for short foreground commands and process for long-running ones (dev servers, watchers) — never block on a long-running command. Tool outputs may be truncated with explicit markers; re-read with narrower ranges when needed.`; | |
| 123 | +Use symbols/refs (the semantic index) to find definitions and usage sites before reaching for grep; use read/grep/glob to explore before editing. Prefer edit (exact replacement) over write for existing files. Use bash for short foreground commands and process for long-running ones (dev servers, watchers) — never block on a long-running command. Tool outputs may be truncated with explicit markers; re-read with narrower ranges when needed.`; | |
| 122 | 124 | } |
| 123 | 125 | |
| 124 | 126 | function instructionsTierText(instructions: readonly InstructionFile[]): string { |
added
src/daemon/approvals.ts
+96 −0
@@ -0,0 +1,96 @@ | ||
| 1 | +/** | |
| 2 | + * KHAELOR | |
| 3 | + * File: src/daemon/approvals.ts | |
| 4 | + * Description: Approval Queue — asynchronous permission requests for autonomous runs; a suspended run costs zero (v2 design §7.5). | |
| 5 | + * | |
| 6 | + * Author: Simon-Pierre Boucher | |
| 7 | + * Contact: contact@spboucher.ai | |
| 8 | + */ | |
| 9 | + | |
| 10 | +import { mkdir, readFile, writeFile } from "node:fs/promises"; | |
| 11 | +import { dirname, join } from "node:path"; | |
| 12 | +import { ulid } from "../shared/index.js"; | |
| 13 | + | |
| 14 | +export type ApprovalStatus = "pending" | "approved" | "denied"; | |
| 15 | + | |
| 16 | +export interface ApprovalRequest { | |
| 17 | + id: string; | |
| 18 | + runId: string; | |
| 19 | + goalId: string; | |
| 20 | + capability: string; | |
| 21 | + /** Human context: "Draft PR for goal 'deps up to date', diff +42 −13, verify ✓". */ | |
| 22 | + context: string; | |
| 23 | + status: ApprovalStatus; | |
| 24 | + requestedAt: number; | |
| 25 | + resolvedAt: number | null; | |
| 26 | +} | |
| 27 | + | |
| 28 | +/** | |
| 29 | + * Persisted at .khaelor/daemon/approvals.json. The agent never blocks on an | |
| 30 | + * approval: the run checkpoints (its JSONL simply pauses) and resumes when | |
| 31 | + * the answer arrives via `khaelord approve <id>` or a channel reply. | |
| 32 | + */ | |
| 33 | +export class ApprovalQueue { | |
| 34 | + readonly #path: string; | |
| 35 | + #entries: ApprovalRequest[] = []; | |
| 36 | + #loaded = false; | |
| 37 | + | |
| 38 | + constructor(daemonDir: string) { | |
| 39 | + this.#path = join(daemonDir, "approvals.json"); | |
| 40 | + } | |
| 41 | + | |
| 42 | + async #ensureLoaded(): Promise<void> { | |
| 43 | + if (this.#loaded) return; | |
| 44 | + try { | |
| 45 | + const raw = JSON.parse(await readFile(this.#path, "utf8")); | |
| 46 | + if (Array.isArray(raw)) this.#entries = raw as ApprovalRequest[]; | |
| 47 | + } catch { | |
| 48 | + this.#entries = []; | |
| 49 | + } | |
| 50 | + this.#loaded = true; | |
| 51 | + } | |
| 52 | + | |
| 53 | + async #save(): Promise<void> { | |
| 54 | + await mkdir(dirname(this.#path), { recursive: true }); | |
| 55 | + await writeFile(this.#path, `${JSON.stringify(this.#entries, null, 2)}\n`, "utf8"); | |
| 56 | + } | |
| 57 | + | |
| 58 | + async request(input: { | |
| 59 | + runId: string; | |
| 60 | + goalId: string; | |
| 61 | + capability: string; | |
| 62 | + context: string; | |
| 63 | + }): Promise<ApprovalRequest> { | |
| 64 | + await this.#ensureLoaded(); | |
| 65 | + const entry: ApprovalRequest = { | |
| 66 | + id: ulid().slice(0, 10).toLowerCase(), | |
| 67 | + runId: input.runId, | |
| 68 | + goalId: input.goalId, | |
| 69 | + capability: input.capability, | |
| 70 | + context: input.context, | |
| 71 | + status: "pending", | |
| 72 | + requestedAt: Date.now(), | |
| 73 | + resolvedAt: null, | |
| 74 | + }; | |
| 75 | + this.#entries.push(entry); | |
| 76 | + await this.#save(); | |
| 77 | + return entry; | |
| 78 | + } | |
| 79 | + | |
| 80 | + async list(status?: ApprovalStatus): Promise<ApprovalRequest[]> { | |
| 81 | + await this.#ensureLoaded(); | |
| 82 | + return status === undefined | |
| 83 | + ? [...this.#entries] | |
| 84 | + : this.#entries.filter((entry) => entry.status === status); | |
| 85 | + } | |
| 86 | + | |
| 87 | + async resolve(id: string, decision: "approved" | "denied"): Promise<ApprovalRequest | null> { | |
| 88 | + await this.#ensureLoaded(); | |
| 89 | + const entry = this.#entries.find((candidate) => candidate.id === id); | |
| 90 | + if (entry === undefined || entry.status !== "pending") return null; | |
| 91 | + entry.status = decision; | |
| 92 | + entry.resolvedAt = Date.now(); | |
| 93 | + await this.#save(); | |
| 94 | + return entry; | |
| 95 | + } | |
| 96 | +} | |
added
src/daemon/budget.ts
+108 −0
@@ -0,0 +1,108 @@ | ||
| 1 | +/** | |
| 2 | + * KHAELOR | |
| 3 | + * File: src/daemon/budget.ts | |
| 4 | + * Description: Budget Guard — hard daily/per-run USD ceilings with a persisted per-day ledger (v2 design §7.6). | |
| 5 | + * | |
| 6 | + * Author: Simon-Pierre Boucher | |
| 7 | + * Contact: contact@spboucher.ai | |
| 8 | + */ | |
| 9 | + | |
| 10 | +import { mkdir, readFile, writeFile } from "node:fs/promises"; | |
| 11 | +import { dirname, join } from "node:path"; | |
| 12 | + | |
| 13 | +export interface BudgetConfig { | |
| 14 | + maxUsdPerDay: number; | |
| 15 | + maxUsdPerRun: number; | |
| 16 | + hardStop: boolean; | |
| 17 | +} | |
| 18 | + | |
| 19 | +export const DEFAULT_BUDGET: Readonly<BudgetConfig> = Object.freeze({ | |
| 20 | + maxUsdPerDay: 20, | |
| 21 | + maxUsdPerRun: 3, | |
| 22 | + hardStop: true, | |
| 23 | +}); | |
| 24 | + | |
| 25 | +interface Ledger { | |
| 26 | + /** YYYY-MM-DD the ledger accumulates for; a new day resets it. */ | |
| 27 | + date: string; | |
| 28 | + spentUsd: number; | |
| 29 | + perGoal: Record<string, number>; | |
| 30 | +} | |
| 31 | + | |
| 32 | +function today(now: Date): string { | |
| 33 | + return now.toISOString().slice(0, 10); | |
| 34 | +} | |
| 35 | + | |
| 36 | +/** | |
| 37 | + * Persisted at .khaelor/daemon/ledger.json. All numbers come from real API | |
| 38 | + * usage reported by the run (Absolute Rule #4) — the guard only adds and | |
| 39 | + * compares, never estimates. | |
| 40 | + */ | |
| 41 | +export class BudgetGuard { | |
| 42 | + readonly #path: string; | |
| 43 | + readonly #config: BudgetConfig; | |
| 44 | + #ledger: Ledger; | |
| 45 | + | |
| 46 | + constructor(daemonDir: string, config: BudgetConfig = { ...DEFAULT_BUDGET }) { | |
| 47 | + this.#path = join(daemonDir, "ledger.json"); | |
| 48 | + this.#config = config; | |
| 49 | + this.#ledger = { date: today(new Date()), spentUsd: 0, perGoal: {} }; | |
| 50 | + } | |
| 51 | + | |
| 52 | + get config(): BudgetConfig { | |
| 53 | + return this.#config; | |
| 54 | + } | |
| 55 | + | |
| 56 | + async load(): Promise<void> { | |
| 57 | + try { | |
| 58 | + const raw = JSON.parse(await readFile(this.#path, "utf8")) as Ledger; | |
| 59 | + if (typeof raw.date === "string" && typeof raw.spentUsd === "number") { | |
| 60 | + this.#ledger = { date: raw.date, spentUsd: raw.spentUsd, perGoal: raw.perGoal ?? {} }; | |
| 61 | + } | |
| 62 | + } catch { | |
| 63 | + // fresh ledger | |
| 64 | + } | |
| 65 | + } | |
| 66 | + | |
| 67 | + #roll(now: Date): void { | |
| 68 | + const day = today(now); | |
| 69 | + if (this.#ledger.date !== day) { | |
| 70 | + this.#ledger = { date: day, spentUsd: 0, perGoal: {} }; | |
| 71 | + } | |
| 72 | + } | |
| 73 | + | |
| 74 | + spentToday(now = new Date()): number { | |
| 75 | + this.#roll(now); | |
| 76 | + return this.#ledger.spentUsd; | |
| 77 | + } | |
| 78 | + | |
| 79 | + spentTodayForGoal(goalId: string, now = new Date()): number { | |
| 80 | + this.#roll(now); | |
| 81 | + return this.#ledger.perGoal[goalId] ?? 0; | |
| 82 | + } | |
| 83 | + | |
| 84 | + /** | |
| 85 | + * May a run start? Enforces the daemon-wide daily ceiling and the goal's | |
| 86 | + * own daily ceiling. With hardStop, the answer is binding (v2 §7.6). | |
| 87 | + */ | |
| 88 | + canStart(goalId: string, goalMaxUsdPerDay: number, now = new Date()): { ok: boolean; reason?: string } { | |
| 89 | + this.#roll(now); | |
| 90 | + if (this.#ledger.spentUsd >= this.#config.maxUsdPerDay) { | |
| 91 | + return { ok: false, reason: `daemon daily budget exhausted ($${this.#config.maxUsdPerDay})` }; | |
| 92 | + } | |
| 93 | + const goalSpent = this.#ledger.perGoal[goalId] ?? 0; | |
| 94 | + if (goalSpent >= goalMaxUsdPerDay) { | |
| 95 | + return { ok: false, reason: `goal daily budget exhausted ($${goalMaxUsdPerDay})` }; | |
| 96 | + } | |
| 97 | + return { ok: true }; | |
| 98 | + } | |
| 99 | + | |
| 100 | + /** Record a run's real cost. */ | |
| 101 | + async record(goalId: string, usd: number, now = new Date()): Promise<void> { | |
| 102 | + this.#roll(now); | |
| 103 | + this.#ledger.spentUsd += usd; | |
| 104 | + this.#ledger.perGoal[goalId] = (this.#ledger.perGoal[goalId] ?? 0) + usd; | |
| 105 | + await mkdir(dirname(this.#path), { recursive: true }); | |
| 106 | + await writeFile(this.#path, `${JSON.stringify(this.#ledger, null, 2)}\n`, "utf8"); | |
| 107 | + } | |
| 108 | +} | |
added
src/daemon/channels.ts
+93 −0
Large diff (93 lines) is not loaded by default to keep the page fast.Load diff
added
src/daemon/client.ts
+56 −0
Large diff (56 lines) is not loaded by default to keep the page fast.Load diff
added
src/daemon/config.ts
+100 −0
Large diff (100 lines) is not loaded by default to keep the page fast.Load diff
added
src/daemon/cron.ts
+114 −0
Large diff (114 lines) is not loaded by default to keep the page fast.Load diff
added
src/daemon/daemon.ts
+333 −0
Large diff (333 lines) is not loaded by default to keep the page fast.Load diff
added
src/daemon/goals.ts
+179 −0
Large diff (179 lines) is not loaded by default to keep the page fast.Load diff
added
src/daemon/index.ts
+25 −0
Large diff (25 lines) is not loaded by default to keep the page fast.Load diff
added
src/daemon/main.ts
+245 −0
Large diff (245 lines) is not loaded by default to keep the page fast.Load diff
added
src/daemon/runner.ts
+120 −0
Large diff (120 lines) is not loaded by default to keep the page fast.Load diff
added
src/memory/index.ts
+20 −0
Large diff (20 lines) is not loaded by default to keep the page fast.Load diff
added
src/memory/store.ts
+127 −0
Large diff (127 lines) is not loaded by default to keep the page fast.Load diff
modified
src/permissions/capabilities.ts
+22 −0
Large diff (28 lines) is not loaded by default to keep the page fast.Load diff
added
src/phases/gate.ts
+116 −0
Large diff (116 lines) is not loaded by default to keep the page fast.Load diff
added
src/phases/index.ts
+25 −0
Large diff (25 lines) is not loaded by default to keep the page fast.Load diff
added
src/phases/service.ts
+162 −0
Large diff (162 lines) is not loaded by default to keep the page fast.Load diff
added
src/phases/state.ts
+54 −0
Large diff (54 lines) is not loaded by default to keep the page fast.Load diff
added
src/phases/types.ts
+50 −0
Large diff (50 lines) is not loaded by default to keep the page fast.Load diff
added
src/repograph/extractor.ts
+250 −0
Large diff (250 lines) is not loaded by default to keep the page fast.Load diff
added
src/repograph/index.ts
+18 −0
Large diff (18 lines) is not loaded by default to keep the page fast.Load diff
added
src/repograph/service.ts
+285 −0
Large diff (285 lines) is not loaded by default to keep the page fast.Load diff
modified
src/session/events.ts
+142 −4
Large diff (188 lines) is not loaded by default to keep the page fast.Load diff
added
src/session/fork.ts
+167 −0
Large diff (167 lines) is not loaded by default to keep the page fast.Load diff
modified
src/session/index.ts
+23 −0
Large diff (35 lines) is not loaded by default to keep the page fast.Load diff
modified
src/session/projections.ts
+23 −0
Large diff (29 lines) is not loaded by default to keep the page fast.Load diff
added
src/session/sdiff.ts
+115 −0
Large diff (115 lines) is not loaded by default to keep the page fast.Load diff
added
src/tasks/index.ts
+26 −0
Large diff (26 lines) is not loaded by default to keep the page fast.Load diff
added
src/tasks/manager.ts
+169 −0
Large diff (169 lines) is not loaded by default to keep the page fast.Load diff
added
src/tasks/worktree.ts
+122 −0
Large diff (122 lines) is not loaded by default to keep the page fast.Load diff
added
src/tools/design.ts
+138 −0
Large diff (138 lines) is not loaded by default to keep the page fast.Load diff
modified
src/tools/index.ts
+27 −1
Large diff (49 lines) is not loaded by default to keep the page fast.Load diff
added
src/tools/refs.ts
+91 −0
Large diff (91 lines) is not loaded by default to keep the page fast.Load diff
added
src/tools/remember.ts
+115 −0
Large diff (115 lines) is not loaded by default to keep the page fast.Load diff
added
src/tools/symbols.ts
+84 −0
Large diff (84 lines) is not loaded by default to keep the page fast.Load diff
modified
src/tools/types.ts
+73 −2
Large diff (95 lines) is not loaded by default to keep the page fast.Load diff
modified
src/tui/app.ts
+144 −1
Large diff (226 lines) is not loaded by default to keep the page fast.Load diff
added
src/tui/components/design-panel.ts
+90 −0
Large diff (90 lines) is not loaded by default to keep the page fast.Load diff
added
src/tui/components/sparkline.ts
+46 −0
Large diff (46 lines) is not loaded by default to keep the page fast.Load diff
modified
src/tui/components/status-bar.ts
+53 −2
Large diff (80 lines) is not loaded by default to keep the page fast.Load diff
added
src/tui/components/verify-strip.ts
+68 −0
Large diff (68 lines) is not loaded by default to keep the page fast.Load diff
added
src/tui/doctor.ts
+55 −0
Large diff (55 lines) is not loaded by default to keep the page fast.Load diff
modified
src/tui/index.ts
+13 −1
Large diff (21 lines) is not loaded by default to keep the page fast.Load diff
added
src/tui/motion.ts
+103 −0
Large diff (103 lines) is not loaded by default to keep the page fast.Load diff
added
src/tui/splash.ts
+63 −0
Large diff (63 lines) is not loaded by default to keep the page fast.Load diff
modified
src/tui/theme.ts
+40 −33
Large diff (135 lines) is not loaded by default to keep the page fast.Load diff
added
src/tui/theme/gradient.ts
+119 −0
Large diff (119 lines) is not loaded by default to keep the page fast.Load diff
added
src/verify/config.ts
+140 −0
Large diff (140 lines) is not loaded by default to keep the page fast.Load diff
added
src/verify/index.ts
+19 −0
Large diff (19 lines) is not loaded by default to keep the page fast.Load diff
added
src/verify/runner.ts
+148 −0
Large diff (148 lines) is not loaded by default to keep the page fast.Load diff
added
tests/daemon/daemon.test.ts
+206 −0
Large diff (206 lines) is not loaded by default to keep the page fast.Load diff
added
tests/memory/memory.test.ts
+67 −0
Large diff (67 lines) is not loaded by default to keep the page fast.Load diff
added
tests/phases/phases.test.ts
+190 −0
Large diff (190 lines) is not loaded by default to keep the page fast.Load diff
added
tests/repograph/repograph.test.ts
+107 −0
Large diff (107 lines) is not loaded by default to keep the page fast.Load diff
modified
tests/session/events.test.ts
+3 −3
Large diff (18 lines) is not loaded by default to keep the page fast.Load diff
modified
tests/session/fixtures.ts
+60 −0
Large diff (66 lines) is not loaded by default to keep the page fast.Load diff
added
tests/session/fork-sdiff.test.ts
+112 −0
Large diff (112 lines) is not loaded by default to keep the page fast.Load diff
added
tests/tasks/worktree.test.ts
+118 −0
Large diff (118 lines) is not loaded by default to keep the page fast.Load diff
modified
tests/tools/registry.test.ts
+6 −2
Large diff (25 lines) is not loaded by default to keep the page fast.Load diff
modified
tests/tui/status.test.ts
+17 −3
Large diff (37 lines) is not loaded by default to keep the page fast.Load diff
added
tests/tui/v2-widgets.test.ts
+174 −0
Large diff (174 lines) is not loaded by default to keep the page fast.Load diff
added
tests/verify/verify.test.ts
+165 −0
Large diff (165 lines) is not loaded by default to keep the page fast.Load diff
modified
website/public/docs/commands.html
+20 −1
Large diff (39 lines) is not loaded by default to keep the page fast.Load diff
modified
website/public/docs/configuration.html
+5 −1
Large diff (18 lines) is not loaded by default to keep the page fast.Load diff
added
website/public/docs/daemon.html
+151 −0
Large diff (151 lines) is not loaded by default to keep the page fast.Load diff
modified
website/public/docs/faq.html
+5 −1
Large diff (18 lines) is not loaded by default to keep the page fast.Load diff
modified
website/public/docs/getting-started.html
+5 −1
Large diff (18 lines) is not loaded by default to keep the page fast.Load diff
modified
website/public/docs/permissions.html
+5 −1
Large diff (18 lines) is not loaded by default to keep the page fast.Load diff
added
website/public/docs/phases.html
+143 −0
Large diff (143 lines) is not loaded by default to keep the page fast.Load diff
modified
website/public/docs/sessions.html
+25 −1
Large diff (44 lines) is not loaded by default to keep the page fast.Load diff
modified
website/public/docs/tools.html
+5 −1
Large diff (18 lines) is not loaded by default to keep the page fast.Load diff
modified
website/public/docs/usage.html
+5 −1
Large diff (18 lines) is not loaded by default to keep the page fast.Load diff
modified
website/public/index.html
+119 −89
Large diff (272 lines) is not loaded by default to keep the page fast.Load diff
modified
website/public/styles.css
+125 −21
Large diff (172 lines) is not loaded by default to keep the page fast.Load diff