SPB Git

spb/khaelor

Public

KHAELOR — a terminal-native autonomous engineering agent powered by Anthropic.

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>
Simon-Pierre Boucher committed (Aug 10, 2026) parent ccdb55b Browse files

89 changed files +7,889 −178

89 changed files
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
Menu

Theme