SPB Git

spb/khaelor Public

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

TypeScript 82.9% HTML 14.9% CSS 1.1% JavaScript 0.7%

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 4 h ago (Aug 10, 2026) parent ccdb55b

Showing 89 changed files with +7,889 and −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
@@ -0,0 +1,93 @@
1 +/**
2 + * KHAELOR
3 + * File: src/daemon/channels.ts
4 + * Description: Channel Router — webhook + command adapters behind one ChannelAdapter seam (v2 design §7.7).
5 + *
6 + * Author: Simon-Pierre Boucher
7 + * Contact: contact@spboucher.ai
8 + */
9 +
10 +import { spawn } from "node:child_process";
11 +
12 +/** One outbound daemon notification. */
13 +export interface ChannelNotification {
14 + kind: "run-completed" | "goal-escalated" | "approval-requested" | "budget-exhausted" | "daemon-status";
15 + goalId?: string;
16 + runId?: string;
17 + title: string;
18 + body: string;
19 + ts: number;
20 +}
21 +
22 +/**
23 + * The single channel seam — same philosophy as the isolated ModelClient:
24 + * rich channels (Telegram, Slack) arrive later as plugins behind this
25 + * interface, never inside the daemon core (v2 §7.7).
26 + */
27 +export interface ChannelAdapter {
28 + readonly name: string;
29 + send(notification: ChannelNotification): Promise<void>;
30 +}
31 +
32 +/** POST the notification as JSON to a webhook URL. */
33 +export class WebhookChannel implements ChannelAdapter {
34 + readonly name = "webhook";
35 + readonly #url: string;
36 +
37 + constructor(url: string) {
38 + this.#url = url;
39 + }
40 +
41 + async send(notification: ChannelNotification): Promise<void> {
42 + await fetch(this.#url, {
43 + method: "POST",
44 + headers: { "content-type": "application/json" },
45 + body: JSON.stringify(notification),
46 + });
47 + }
48 +}
49 +
50 +/** Pipe the notification JSON to a user script's stdin (mail/Slack/ntfy — user's choice). */
51 +export class CommandChannel implements ChannelAdapter {
52 + readonly name = "command";
53 + readonly #command: string;
54 +
55 + constructor(command: string) {
56 + this.#command = command;
57 + }
58 +
59 + send(notification: ChannelNotification): Promise<void> {
60 + return new Promise((resolve) => {
61 + const child = spawn(this.#command, { shell: true, stdio: ["pipe", "ignore", "ignore"] });
62 + child.on("error", () => resolve());
63 + child.on("exit", () => resolve());
64 + child.stdin.end(`${JSON.stringify(notification)}\n`);
65 + });
66 + }
67 +}
68 +
69 +/** Fan a notification out to every configured channel; failures never crash the daemon. */
70 +export class ChannelRouter {
71 + readonly #channels: ChannelAdapter[];
72 + readonly #onError: (channel: string, error: unknown) => void;
73 +
74 + constructor(
75 + channels: ChannelAdapter[],
76 + onError: (channel: string, error: unknown) => void = () => undefined,
77 + ) {
78 + this.#channels = channels;
79 + this.#onError = onError;
80 + }
81 +
82 + async send(notification: ChannelNotification): Promise<void> {
83 + await Promise.all(
84 + this.#channels.map(async (channel) => {
85 + try {
86 + await channel.send(notification);
87 + } catch (error) {
88 + this.#onError(channel.name, error);
89 + }
90 + }),
91 + );
92 + }
93 +}
added src/daemon/client.ts +56 −0
@@ -0,0 +1,56 @@
1 +/**
2 + * KHAELOR
3 + * File: src/daemon/client.ts
4 + * Description: Control-socket client — status/goals/approvals/stop requests against a running khaelord (v2 design §7.3).
5 + *
6 + * Author: Simon-Pierre Boucher
7 + * Contact: contact@spboucher.ai
8 + */
9 +
10 +import { connect } from "node:net";
11 +import { join } from "node:path";
12 +
13 +/** One JSON-line request/response round-trip on the daemon's unix socket. */
14 +export function daemonRequest(
15 + daemonDir: string,
16 + request: Record<string, unknown>,
17 + timeoutMs = 3_000,
18 +): Promise<Record<string, unknown>> {
19 + const socketPath = join(daemonDir, "daemon.sock");
20 + return new Promise((resolve, reject) => {
21 + const socket = connect(socketPath);
22 + const timer = setTimeout(() => {
23 + socket.destroy();
24 + reject(new Error("daemon did not respond"));
25 + }, timeoutMs);
26 + let buffer = "";
27 + socket.on("connect", () => {
28 + socket.write(`${JSON.stringify(request)}\n`);
29 + });
30 + socket.on("data", (chunk) => {
31 + buffer += chunk.toString("utf8");
32 + });
33 + socket.on("end", () => {
34 + clearTimeout(timer);
35 + try {
36 + resolve(JSON.parse(buffer.trim()) as Record<string, unknown>);
37 + } catch {
38 + reject(new Error(`invalid daemon response: ${buffer.slice(0, 200)}`));
39 + }
40 + });
41 + socket.on("error", (error) => {
42 + clearTimeout(timer);
43 + reject(error);
44 + });
45 + });
46 +}
47 +
48 +/** True when the recorded pid is alive. */
49 +export function pidAlive(pid: number): boolean {
50 + try {
51 + process.kill(pid, 0);
52 + return true;
53 + } catch {
54 + return false;
55 + }
56 +}
added src/daemon/config.ts +100 −0
@@ -0,0 +1,100 @@
1 +/**
2 + * KHAELOR
3 + * File: src/daemon/config.ts
4 + * Description: Daemon configuration — .khaelor/daemon/config.json: budget, active hours, models, channels, pricing (v2 design §7.6).
5 + *
6 + * Author: Simon-Pierre Boucher
7 + * Contact: contact@spboucher.ai
8 + */
9 +
10 +import { readFile } from "node:fs/promises";
11 +import { join } from "node:path";
12 +import { DEFAULT_BUDGET } from "./budget.js";
13 +import type { BudgetConfig } from "./budget.js";
14 +
15 +export interface DaemonPricing {
16 + inputPerMTok: number;
17 + outputPerMTok: number;
18 + cacheReadPerMTok: number;
19 + cacheWritePerMTok: number;
20 +}
21 +
22 +export interface DaemonConfig {
23 + budget: BudgetConfig;
24 + /** "07:00-23:00" — outside this window the daemon stays quiet (v2 §7.6). */
25 + activeHours?: string;
26 + /** Heartbeat tick in minutes (default 30). */
27 + heartbeatMinutes: number;
28 + model: {
29 + /** Cheap triage tier (goal checks / future LLM triage). */
30 + heartbeat?: string;
31 + /** The model real runs use; falls back to the session config model. */
32 + runs?: string;
33 + };
34 + channels: { webhook?: string; command?: string };
35 + /**
36 + * USD prices per million tokens for the runs model. Absent → run costs are
37 + * recorded as $0 and budget enforcement relies on maxRunsPerDay — costs are
38 + * never invented (Absolute Rule #4).
39 + */
40 + pricing?: DaemonPricing;
41 +}
42 +
43 +export const DEFAULT_DAEMON_CONFIG: Readonly<DaemonConfig> = Object.freeze({
44 + budget: { ...DEFAULT_BUDGET },
45 + heartbeatMinutes: 30,
46 + model: Object.freeze({}),
47 + channels: Object.freeze({}),
48 +});
49 +
50 +export function daemonDirFor(projectRoot: string): string {
51 + return join(projectRoot, ".khaelor", "daemon");
52 +}
53 +
54 +function num(value: unknown, fallback: number): number {
55 + return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : fallback;
56 +}
57 +
58 +/** Load .khaelor/daemon/config.json; every field optional, defaults applied. */
59 +export async function loadDaemonConfig(projectRoot: string): Promise<DaemonConfig> {
60 + let raw: Record<string, unknown> = {};
61 + try {
62 + raw = JSON.parse(await readFile(join(daemonDirFor(projectRoot), "config.json"), "utf8")) as Record<
63 + string,
64 + unknown
65 + >;
66 + } catch {
67 + return { ...DEFAULT_DAEMON_CONFIG, budget: { ...DEFAULT_BUDGET }, model: {}, channels: {} };
68 + }
69 + const budgetRaw = (raw["budget"] ?? {}) as Record<string, unknown>;
70 + const modelRaw = (raw["model"] ?? {}) as Record<string, unknown>;
71 + const channelsRaw = (raw["channels"] ?? {}) as Record<string, unknown>;
72 + const pricingRaw = raw["pricing"] as Record<string, unknown> | undefined;
73 + return {
74 + budget: {
75 + maxUsdPerDay: num(budgetRaw["maxUsdPerDay"], DEFAULT_BUDGET.maxUsdPerDay),
76 + maxUsdPerRun: num(budgetRaw["maxUsdPerRun"], DEFAULT_BUDGET.maxUsdPerRun),
77 + hardStop: budgetRaw["hardStop"] !== false,
78 + },
79 + ...(typeof raw["activeHours"] === "string" ? { activeHours: raw["activeHours"] } : {}),
80 + heartbeatMinutes: num(raw["heartbeatMinutes"], 30),
81 + model: {
82 + ...(typeof modelRaw["heartbeat"] === "string" ? { heartbeat: modelRaw["heartbeat"] } : {}),
83 + ...(typeof modelRaw["runs"] === "string" ? { runs: modelRaw["runs"] } : {}),
84 + },
85 + channels: {
86 + ...(typeof channelsRaw["webhook"] === "string" ? { webhook: channelsRaw["webhook"] } : {}),
87 + ...(typeof channelsRaw["command"] === "string" ? { command: channelsRaw["command"] } : {}),
88 + },
89 + ...(pricingRaw !== undefined
90 + ? {
91 + pricing: {
92 + inputPerMTok: num(pricingRaw["inputPerMTok"], 0),
93 + outputPerMTok: num(pricingRaw["outputPerMTok"], 0),
94 + cacheReadPerMTok: num(pricingRaw["cacheReadPerMTok"], 0),
95 + cacheWritePerMTok: num(pricingRaw["cacheWritePerMTok"], 0),
96 + },
97 + }
98 + : {}),
99 + };
100 +}
added src/daemon/cron.ts +114 −0
@@ -0,0 +1,114 @@
1 +/**
2 + * KHAELOR
3 + * File: src/daemon/cron.ts
4 + * Description: Dependency-free cron parser (5 fields: min hour dom month dow) with next-run computation (v2 design §7).
5 + *
6 + * Author: Simon-Pierre Boucher
7 + * Contact: contact@spboucher.ai
8 + */
9 +
10 +export interface CronSpec {
11 + minutes: Set<number>;
12 + hours: Set<number>;
13 + daysOfMonth: Set<number>;
14 + months: Set<number>;
15 + daysOfWeek: Set<number>;
16 +}
17 +
18 +function parseField(field: string, min: number, max: number): Set<number> | null {
19 + const out = new Set<number>();
20 + for (const part of field.split(",")) {
21 + const step = /^(\*|\d+(?:-\d+)?)\/(\d+)$/.exec(part);
22 + if (step !== null) {
23 + const stepBy = Number.parseInt(step[2] as string, 10);
24 + if (Number.isNaN(stepBy) || stepBy <= 0) return null;
25 + let lo = min;
26 + let hi = max;
27 + const range = step[1] as string;
28 + if (range !== "*") {
29 + const dash = range.split("-");
30 + lo = Number.parseInt(dash[0] as string, 10);
31 + hi = dash.length > 1 ? Number.parseInt(dash[1] as string, 10) : max;
32 + }
33 + for (let v = lo; v <= hi; v += stepBy) out.add(v);
34 + continue;
35 + }
36 + if (part === "*") {
37 + for (let v = min; v <= max; v += 1) out.add(v);
38 + continue;
39 + }
40 + const dash = /^(\d+)-(\d+)$/.exec(part);
41 + if (dash !== null) {
42 + const lo = Number.parseInt(dash[1] as string, 10);
43 + const hi = Number.parseInt(dash[2] as string, 10);
44 + if (lo < min || hi > max || lo > hi) return null;
45 + for (let v = lo; v <= hi; v += 1) out.add(v);
46 + continue;
47 + }
48 + const value = Number.parseInt(part, 10);
49 + if (Number.isNaN(value) || value < min || value > max) return null;
50 + out.add(value);
51 + }
52 + return out.size > 0 ? out : null;
53 +}
54 +
55 +/** Parse a 5-field cron expression. Returns null when invalid. */
56 +export function parseCron(expr: string): CronSpec | null {
57 + const fields = expr.trim().split(/\s+/);
58 + if (fields.length !== 5) return null;
59 + const minutes = parseField(fields[0] as string, 0, 59);
60 + const hours = parseField(fields[1] as string, 0, 23);
61 + const daysOfMonth = parseField(fields[2] as string, 1, 31);
62 + const months = parseField(fields[3] as string, 1, 12);
63 + const daysOfWeek = parseField(fields[4] as string, 0, 7);
64 + if (minutes === null || hours === null || daysOfMonth === null || months === null || daysOfWeek === null) {
65 + return null;
66 + }
67 + // Cron convention: 7 == 0 == Sunday.
68 + if (daysOfWeek.has(7)) daysOfWeek.add(0);
69 + return { minutes, hours, daysOfMonth, months, daysOfWeek };
70 +}
71 +
72 +function matches(spec: CronSpec, date: Date): boolean {
73 + return (
74 + spec.minutes.has(date.getMinutes()) &&
75 + spec.hours.has(date.getHours()) &&
76 + spec.daysOfMonth.has(date.getDate()) &&
77 + spec.months.has(date.getMonth() + 1) &&
78 + spec.daysOfWeek.has(date.getDay())
79 + );
80 +}
81 +
82 +const MAX_SCAN_MINUTES = 366 * 24 * 60;
83 +
84 +/** Next fire time strictly after `from`. Null when the spec can never fire within a year. */
85 +export function nextCronRun(spec: CronSpec, from: Date): Date | null {
86 + const cursor = new Date(from.getTime());
87 + cursor.setSeconds(0, 0);
88 + for (let i = 0; i < MAX_SCAN_MINUTES; i += 1) {
89 + cursor.setTime(cursor.getTime() + 60_000);
90 + if (matches(spec, cursor)) return new Date(cursor.getTime());
91 + }
92 + return null;
93 +}
94 +
95 +/** Parse "07:00-23:00"-style active hours. Null = always active. */
96 +export function parseActiveHours(value: string | undefined): { start: number; end: number } | null {
97 + if (value === undefined) return null;
98 + const match = /^(\d{1,2}):(\d{2})-(\d{1,2}):(\d{2})$/.exec(value.trim());
99 + if (match === null) return null;
100 + const start = Number.parseInt(match[1] as string, 10) * 60 + Number.parseInt(match[2] as string, 10);
101 + const end = Number.parseInt(match[3] as string, 10) * 60 + Number.parseInt(match[4] as string, 10);
102 + return { start, end };
103 +}
104 +
105 +/** True when `date` falls inside the active-hours window (wrapping windows supported). */
106 +export function withinActiveHours(
107 + hours: { start: number; end: number } | null,
108 + date: Date,
109 +): boolean {
110 + if (hours === null) return true;
111 + const minute = date.getHours() * 60 + date.getMinutes();
112 + if (hours.start <= hours.end) return minute >= hours.start && minute < hours.end;
113 + return minute >= hours.start || minute < hours.end;
114 +}
added src/daemon/daemon.ts +333 −0
@@ -0,0 +1,333 @@
1 +/**
2 + * KHAELOR
3 + * File: src/daemon/daemon.ts
4 + * Description: KhaelorDaemon — scheduler tick, heartbeat, goal dispatch, budget enforcement, control socket (v2 design §7.3).
5 + *
6 + * Author: Simon-Pierre Boucher
7 + * Contact: contact@spboucher.ai
8 + */
9 +
10 +import { mkdir, readFile, unlink, writeFile } from "node:fs/promises";
11 +import { createServer } from "node:net";
12 +import type { Server, Socket } from "node:net";
13 +import { join } from "node:path";
14 +import { ulid } from "../shared/index.js";
15 +import type { ApprovalQueue } from "./approvals.js";
16 +import type { BudgetGuard } from "./budget.js";
17 +import type { ChannelRouter } from "./channels.js";
18 +import type { DaemonConfig } from "./config.js";
19 +import { nextCronRun, parseActiveHours, parseCron, withinActiveHours } from "./cron.js";
20 +import type { Goal, GoalStore } from "./goals.js";
21 +
22 +/** What the injected goal runner reports back — real numbers only. */
23 +export interface GoalRunResult {
24 + outcome: "done" | "failed";
25 + detail: string;
26 + costUsd: number;
27 + sessionId: string | null;
28 + verifyOk: boolean | null;
29 + branch: string | null;
30 +}
31 +
32 +export type GoalRunner = (goal: Goal, runId: string) => Promise<GoalRunResult>;
33 +
34 +/** Exec seam for goal `check` commands. */
35 +export type CheckExec = (cmd: string) => Promise<{ exitCode: number | null }>;
36 +
37 +export interface DaemonDeps {
38 + daemonDir: string;
39 + config: DaemonConfig;
40 + goals: GoalStore;
41 + budget: BudgetGuard;
42 + approvals: ApprovalQueue;
43 + channels: ChannelRouter;
44 + runGoal: GoalRunner;
45 + execCheck: CheckExec;
46 + log: (level: "info" | "warn" | "error", message: string) => void;
47 + /** Tick interval override (tests). Default 30 s. */
48 + tickMs?: number;
49 +}
50 +
51 +interface GoalClock {
52 + lastConsideredAt: number;
53 + nextCronAt: number | null;
54 +}
55 +
56 +/**
57 + * The daemon core (v2 §7.3): a tick loop over the goal set. Every decision —
58 + * skip, run, escalate, budget stop — lands in the goal's event log, so 3 AM
59 + * behavior is always explainable (autonomy WITH auditability).
60 + */
61 +export class KhaelorDaemon {
62 + readonly #deps: DaemonDeps;
63 + readonly #clocks = new Map<string, GoalClock>();
64 + #timer: NodeJS.Timeout | null = null;
65 + #server: Server | null = null;
66 + #running = false;
67 + #startedAt = 0;
68 + #activeRuns = 0;
69 +
70 + constructor(deps: DaemonDeps) {
71 + this.#deps = deps;
72 + }
73 +
74 + get statusFilePath(): string {
75 + return join(this.#deps.daemonDir, "daemon.json");
76 + }
77 +
78 + get socketPath(): string {
79 + return join(this.#deps.daemonDir, "daemon.sock");
80 + }
81 +
82 + async start(): Promise<void> {
83 + if (this.#running) return;
84 + this.#running = true;
85 + this.#startedAt = Date.now();
86 + await mkdir(this.#deps.daemonDir, { recursive: true });
87 + await this.#deps.budget.load();
88 + await writeFile(
89 + this.statusFilePath,
90 + `${JSON.stringify({ pid: process.pid, startedAt: this.#startedAt }, null, 2)}\n`,
91 + "utf8",
92 + );
93 + await this.#startControlSocket();
94 + const tickMs = this.#deps.tickMs ?? 30_000;
95 + this.#timer = setInterval(() => {
96 + void this.tick().catch((error: unknown) => {
97 + this.#deps.log("error", `tick failed: ${error instanceof Error ? error.message : String(error)}`);
98 + });
99 + }, tickMs);
100 + this.#deps.log("info", `khaelord started (pid ${process.pid}, tick ${tickMs}ms)`);
101 + await this.tick();
102 + }
103 +
104 + async stop(): Promise<void> {
105 + if (!this.#running) return;
106 + this.#running = false;
107 + if (this.#timer !== null) clearInterval(this.#timer);
108 + this.#timer = null;
109 + if (this.#server !== null) {
110 + await new Promise<void>((resolve) => this.#server?.close(() => resolve()));
111 + this.#server = null;
112 + }
113 + await unlink(this.statusFilePath).catch(() => undefined);
114 + await unlink(this.socketPath).catch(() => undefined);
115 + this.#deps.log("info", "khaelord stopped");
116 + }
117 +
118 + /** One scheduler pass — public for tests and for a manual `khaelord tick`. */
119 + async tick(now = new Date()): Promise<void> {
120 + const hours = parseActiveHours(this.#deps.config.activeHours);
121 + if (!withinActiveHours(hours, now)) return;
122 + const goals = await this.#deps.goals.list();
123 + for (const goal of goals) {
124 + if (goal.status !== "active") continue;
125 + if (!this.#isDue(goal, now)) continue;
126 + await this.#consider(goal, now);
127 + }
128 + }
129 +
130 + #isDue(goal: Goal, now: Date): boolean {
131 + const clock = this.#clocks.get(goal.id) ?? { lastConsideredAt: 0, nextCronAt: null };
132 + if (goal.schedule === "heartbeat") {
133 + const interval = this.#deps.config.heartbeatMinutes * 60_000;
134 + if (now.getTime() - clock.lastConsideredAt < interval) return false;
135 + clock.lastConsideredAt = now.getTime();
136 + this.#clocks.set(goal.id, clock);
137 + return true;
138 + }
139 + const spec = parseCron(goal.schedule);
140 + if (spec === null) return false;
141 + if (clock.nextCronAt === null) {
142 + clock.nextCronAt = nextCronRun(spec, new Date(clock.lastConsideredAt || now.getTime()))?.getTime() ?? null;
143 + this.#clocks.set(goal.id, clock);
144 + return false; // first sighting schedules, never fires immediately
145 + }
146 + if (now.getTime() >= clock.nextCronAt) {
147 + clock.lastConsideredAt = now.getTime();
148 + clock.nextCronAt = nextCronRun(spec, now)?.getTime() ?? null;
149 + this.#clocks.set(goal.id, clock);
150 + return true;
151 + }
152 + return false;
153 + }
154 +
155 + async #consider(goal: Goal, now: Date): Promise<void> {
156 + // Bicouche routing, cheap tier first (v2 §7.6): the goal's own check
157 + // command decides whether there is anything to do at all.
158 + if (goal.check !== undefined && goal.check.length > 0) {
159 + try {
160 + const result = await this.#deps.execCheck(goal.check);
161 + if (result.exitCode === 0) {
162 + await this.#deps.goals.appendEvent(goal.id, {
163 + ts: now.getTime(),
164 + type: "run.skipped",
165 + payload: { reason: "check passed (HEARTBEAT_OK)" },
166 + });
167 + return;
168 + }
169 + } catch (error) {
170 + this.#deps.log("warn", `goal ${goal.id} check errored: ${String(error)}`);
171 + }
172 + }
173 +
174 + const runsToday = await this.#deps.goals.runsToday(goal.id, now);
175 + if (runsToday >= goal.budget.maxRunsPerDay) {
176 + await this.#deps.goals.appendEvent(goal.id, {
177 + ts: now.getTime(),
178 + type: "run.skipped",
179 + payload: { reason: `maxRunsPerDay reached (${goal.budget.maxRunsPerDay})` },
180 + });
181 + return;
182 + }
183 + const allowed = this.#deps.budget.canStart(goal.id, goal.budget.maxUsdPerDay, now);
184 + if (!allowed.ok) {
185 + await this.#deps.goals.appendEvent(goal.id, {
186 + ts: now.getTime(),
187 + type: "run.skipped",
188 + payload: { reason: allowed.reason ?? "budget" },
189 + });
190 + await this.#deps.channels.send({
191 + kind: "budget-exhausted",
192 + goalId: goal.id,
193 + title: `Budget stop: ${goal.description.slice(0, 60)}`,
194 + body: allowed.reason ?? "budget exhausted",
195 + ts: now.getTime(),
196 + });
197 + return;
198 + }
199 +
200 + const runId = ulid().slice(0, 12).toLowerCase();
201 + await this.#deps.goals.appendEvent(goal.id, {
202 + ts: now.getTime(),
203 + type: "run.started",
204 + payload: { runId },
205 + });
206 + this.#activeRuns += 1;
207 + let result: GoalRunResult;
208 + try {
209 + result = await this.#deps.runGoal(goal, runId);
210 + } catch (error) {
211 + result = {
212 + outcome: "failed",
213 + detail: error instanceof Error ? error.message : String(error),
214 + costUsd: 0,
215 + sessionId: null,
216 + verifyOk: null,
217 + branch: null,
218 + };
219 + } finally {
220 + this.#activeRuns -= 1;
221 + }
222 + await this.#deps.budget.record(goal.id, result.costUsd);
223 + await this.#deps.goals.appendEvent(goal.id, {
224 + ts: Date.now(),
225 + type: "run.completed",
226 + payload: {
227 + runId,
228 + outcome: result.outcome,
229 + detail: result.detail.slice(0, 1000),
230 + costUsd: result.costUsd,
231 + sessionId: result.sessionId,
232 + verifyOk: result.verifyOk,
233 + branch: result.branch,
234 + },
235 + });
236 + await this.#deps.channels.send({
237 + kind: "run-completed",
238 + goalId: goal.id,
239 + runId,
240 + title: `${result.outcome === "done" ? "✓" : "✗"} ${goal.description.slice(0, 60)}`,
241 + body:
242 + `outcome: ${result.outcome} · verify: ${result.verifyOk === null ? "n/a" : result.verifyOk ? "ok" : "FAILED"}` +
243 + `${result.branch !== null ? ` · branch: ${result.branch}` : ""}\n${result.detail.slice(0, 500)}`,
244 + ts: Date.now(),
245 + });
246 + }
247 +
248 + // ── control socket: status | goals | approvals | approve/deny | stop ──
249 +
250 + async #startControlSocket(): Promise<void> {
251 + await unlink(this.socketPath).catch(() => undefined);
252 + this.#server = createServer((socket: Socket) => {
253 + let buffer = "";
254 + socket.on("data", (chunk) => {
255 + buffer += chunk.toString("utf8");
256 + const newline = buffer.indexOf("\n");
257 + if (newline === -1) return;
258 + const line = buffer.slice(0, newline);
259 + buffer = buffer.slice(newline + 1);
260 + void this.#handleControl(line)
261 + .then((response) => {
262 + socket.end(`${JSON.stringify(response)}\n`);
263 + })
264 + .catch((error: unknown) => {
265 + socket.end(`${JSON.stringify({ ok: false, error: String(error) })}\n`);
266 + });
267 + });
268 + });
269 + await new Promise<void>((resolve, reject) => {
270 + this.#server?.once("error", reject);
271 + this.#server?.listen(this.socketPath, () => resolve());
272 + });
273 + }
274 +
275 + async #handleControl(line: string): Promise<Record<string, unknown>> {
276 + let request: Record<string, unknown>;
277 + try {
278 + request = JSON.parse(line) as Record<string, unknown>;
279 + } catch {
280 + return { ok: false, error: "invalid JSON" };
281 + }
282 + switch (request["cmd"]) {
283 + case "status":
284 + return {
285 + ok: true,
286 + pid: process.pid,
287 + startedAt: this.#startedAt,
288 + activeRuns: this.#activeRuns,
289 + spentTodayUsd: this.#deps.budget.spentToday(),
290 + budget: this.#deps.budget.config,
291 + };
292 + case "goals":
293 + return { ok: true, goals: await this.#deps.goals.list() };
294 + case "approvals":
295 + return { ok: true, approvals: await this.#deps.approvals.list("pending") };
296 + case "approve":
297 + case "deny": {
298 + const id = request["id"];
299 + if (typeof id !== "string") return { ok: false, error: "missing id" };
300 + const resolved = await this.#deps.approvals.resolve(
301 + id,
302 + request["cmd"] === "approve" ? "approved" : "denied",
303 + );
304 + return resolved !== null ? { ok: true, approval: resolved } : { ok: false, error: "unknown or already resolved" };
305 + }
306 + case "stop":
307 + setTimeout(() => {
308 + void this.stop().then(() => process.exit(0));
309 + }, 50);
310 + return { ok: true, stopping: true };
311 + default:
312 + return { ok: false, error: `unknown cmd: ${String(request["cmd"])}` };
313 + }
314 + }
315 +}
316 +
317 +/** Read the daemon status file (pid liveness checked by the caller). */
318 +export async function readDaemonStatus(
319 + daemonDir: string,
320 +): Promise<{ pid: number; startedAt: number } | null> {
321 + try {
322 + const raw = JSON.parse(await readFile(join(daemonDir, "daemon.json"), "utf8")) as Record<
323 + string,
324 + unknown
325 + >;
326 + if (typeof raw["pid"] === "number" && typeof raw["startedAt"] === "number") {
327 + return { pid: raw["pid"], startedAt: raw["startedAt"] };
328 + }
329 + } catch {
330 + // no status file
331 + }
332 + return null;
333 +}
added src/daemon/goals.ts +179 −0
@@ -0,0 +1,179 @@
1 +/**
2 + * KHAELOR
3 + * File: src/daemon/goals.ts
4 + * Description: Goal Engine — structured, event-sourced long-term goals with per-goal budgets and escalation (v2 design §7.4).
5 + *
6 + * Author: Simon-Pierre Boucher
7 + * Contact: contact@spboucher.ai
8 + */
9 +
10 +import { appendFile, mkdir, readFile, readdir, writeFile } from "node:fs/promises";
11 +import { join } from "node:path";
12 +import { ulid } from "../shared/index.js";
13 +
14 +export type GoalType = "maintain" | "achieve" | "watch";
15 +export type GoalEscalation = "notify" | "draft-pr" | "auto-merge-if-verified";
16 +export type GoalStatus = "active" | "paused" | "satisfied";
17 +
18 +export interface Goal {
19 + id: string;
20 + description: string;
21 + type: GoalType;
22 + /** Command whose exit code evaluates the goal's state; empty → schedule-driven. */
23 + check?: string;
24 + /** Cron expression or the literal "heartbeat". */
25 + schedule: string;
26 + budget: { maxUsdPerDay: number; maxRunsPerDay: number };
27 + escalation: GoalEscalation;
28 + status: GoalStatus;
29 + createdAt: number;
30 +}
31 +
32 +/** One line of a goal's event log (goals/<id>.events.jsonl) — replayable, forkable, diffable. */
33 +export interface GoalEvent {
34 + ts: number;
35 + type:
36 + | "goal.created"
37 + | "goal.paused"
38 + | "goal.resumed"
39 + | "goal.satisfied"
40 + | "goal.escalated"
41 + | "run.started"
42 + | "run.completed"
43 + | "run.skipped";
44 + payload: Record<string, unknown>;
45 +}
46 +
47 +export interface GoalRunRecord {
48 + runId: string;
49 + goalId: string;
50 + startedAt: number;
51 + finishedAt: number | null;
52 + outcome: "done" | "failed" | "skipped" | "running";
53 + detail: string;
54 + /** Child session id — every nocturnal action replays from its JSONL (v2 §7.2). */
55 + sessionId: string | null;
56 +}
57 +
58 +/**
59 + * Disk layout under `.khaelor/daemon/goals/`:
60 + * <id>.json — current goal definition + status
61 + * <id>.events.jsonl — append-only goal event log (the audit trail)
62 + */
63 +export class GoalStore {
64 + readonly #dir: string;
65 +
66 + constructor(daemonDir: string) {
67 + this.#dir = join(daemonDir, "goals");
68 + }
69 +
70 + get dir(): string {
71 + return this.#dir;
72 + }
73 +
74 + async create(input: {
75 + description: string;
76 + type: GoalType;
77 + check?: string;
78 + schedule: string;
79 + budget?: Partial<Goal["budget"]>;
80 + escalation?: GoalEscalation;
81 + }): Promise<Goal> {
82 + const goal: Goal = {
83 + id: ulid().slice(0, 12).toLowerCase(),
84 + description: input.description,
85 + type: input.type,
86 + ...(input.check !== undefined ? { check: input.check } : {}),
87 + schedule: input.schedule,
88 + budget: {
89 + maxUsdPerDay: input.budget?.maxUsdPerDay ?? 5,
90 + maxRunsPerDay: input.budget?.maxRunsPerDay ?? 8,
91 + },
92 + escalation: input.escalation ?? "notify",
93 + status: "active",
94 + createdAt: Date.now(),
95 + };
96 + await mkdir(this.#dir, { recursive: true });
97 + await this.#save(goal);
98 + await this.appendEvent(goal.id, { ts: Date.now(), type: "goal.created", payload: { goal } });
99 + return goal;
100 + }
101 +
102 + async list(): Promise<Goal[]> {
103 + let names: string[];
104 + try {
105 + names = await readdir(this.#dir);
106 + } catch {
107 + return [];
108 + }
109 + const goals: Goal[] = [];
110 + for (const name of names) {
111 + if (!name.endsWith(".json")) continue;
112 + try {
113 + goals.push(JSON.parse(await readFile(join(this.#dir, name), "utf8")) as Goal);
114 + } catch {
115 + // unreadable goal file — skipped, never fatal
116 + }
117 + }
118 + goals.sort((a, b) => a.createdAt - b.createdAt);
119 + return goals;
120 + }
121 +
122 + async get(goalId: string): Promise<Goal | null> {
123 + try {
124 + return JSON.parse(await readFile(join(this.#dir, `${goalId}.json`), "utf8")) as Goal;
125 + } catch {
126 + return null;
127 + }
128 + }
129 +
130 + async setStatus(goalId: string, status: GoalStatus): Promise<void> {
131 + const goal = await this.get(goalId);
132 + if (goal === null) return;
133 + goal.status = status;
134 + await this.#save(goal);
135 + const type =
136 + status === "satisfied" ? "goal.satisfied" : status === "paused" ? "goal.paused" : "goal.resumed";
137 + await this.appendEvent(goalId, { ts: Date.now(), type, payload: {} });
138 + }
139 +
140 + async remove(goalId: string): Promise<void> {
141 + // Goals are never hard-deleted — paused is the terminal user-facing state;
142 + // the event log stays as the audit trail.
143 + await this.setStatus(goalId, "paused");
144 + }
145 +
146 + async appendEvent(goalId: string, event: GoalEvent): Promise<void> {
147 + await mkdir(this.#dir, { recursive: true });
148 + await appendFile(join(this.#dir, `${goalId}.events.jsonl`), `${JSON.stringify(event)}\n`, "utf8");
149 + }
150 +
151 + async events(goalId: string): Promise<GoalEvent[]> {
152 + try {
153 + const content = await readFile(join(this.#dir, `${goalId}.events.jsonl`), "utf8");
154 + const out: GoalEvent[] = [];
155 + for (const line of content.split("\n")) {
156 + if (line.length === 0) continue;
157 + try {
158 + out.push(JSON.parse(line) as GoalEvent);
159 + } catch {
160 + // torn tail tolerated
161 + }
162 + }
163 + return out;
164 + } catch {
165 + return [];
166 + }
167 + }
168 +
169 + /** Runs today, derived from the event log (budget.maxRunsPerDay enforcement). */
170 + async runsToday(goalId: string, now = new Date()): Promise<number> {
171 + const dayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
172 + const events = await this.events(goalId);
173 + return events.filter((event) => event.type === "run.started" && event.ts >= dayStart).length;
174 + }
175 +
176 + async #save(goal: Goal): Promise<void> {
177 + await writeFile(join(this.#dir, `${goal.id}.json`), `${JSON.stringify(goal, null, 2)}\n`, "utf8");
178 + }
179 +}
added src/daemon/index.ts +25 −0
@@ -0,0 +1,25 @@
1 +/**
2 + * KHAELOR
3 + * File: src/daemon/index.ts
4 + * Description: Public surface of the daemon module (v2 design §7).
5 + *
6 + * Author: Simon-Pierre Boucher
7 + * Contact: contact@spboucher.ai
8 + */
9 +
10 +export { parseCron, nextCronRun, parseActiveHours, withinActiveHours } from "./cron.js";
11 +export type { CronSpec } from "./cron.js";
12 +export { GoalStore } from "./goals.js";
13 +export type { Goal, GoalEscalation, GoalEvent, GoalStatus, GoalType } from "./goals.js";
14 +export { BudgetGuard, DEFAULT_BUDGET } from "./budget.js";
15 +export type { BudgetConfig } from "./budget.js";
16 +export { ApprovalQueue } from "./approvals.js";
17 +export type { ApprovalRequest, ApprovalStatus } from "./approvals.js";
18 +export { ChannelRouter, CommandChannel, WebhookChannel } from "./channels.js";
19 +export type { ChannelAdapter, ChannelNotification } from "./channels.js";
20 +export { DEFAULT_DAEMON_CONFIG, daemonDirFor, loadDaemonConfig } from "./config.js";
21 +export type { DaemonConfig, DaemonPricing } from "./config.js";
22 +export { KhaelorDaemon, readDaemonStatus } from "./daemon.js";
23 +export type { CheckExec, DaemonDeps, GoalRunResult, GoalRunner } from "./daemon.js";
24 +export { buildGoalRunner, costFromUsage } from "./runner.js";
25 +export { daemonRequest, pidAlive } from "./client.js";
added src/daemon/main.ts +245 −0
@@ -0,0 +1,245 @@
1 +/**
2 + * KHAELOR
3 + * File: src/daemon/main.ts
4 + * Description: khaelord entrypoint — start/stop/status/tick and goal/approval management from the command line (v2 design §7).
5 + *
6 + * Author: Simon-Pierre Boucher
7 + * Contact: contact@spboucher.ai
8 + */
9 +
10 +import { join } from "node:path";
11 +import { loadConfig } from "../config/index.js";
12 +import { defaultLogDir, FileLogger } from "../cli/logger.js";
13 +import { workspaceExec } from "../cli/subtasks.js";
14 +import { LocalWorkspace } from "../workspace/index.js";
15 +import { ApprovalQueue } from "./approvals.js";
16 +import { BudgetGuard } from "./budget.js";
17 +import { ChannelRouter, CommandChannel, WebhookChannel } from "./channels.js";
18 +import type { ChannelAdapter } from "./channels.js";
19 +import { daemonDirFor, loadDaemonConfig } from "./config.js";
20 +import { KhaelorDaemon, readDaemonStatus } from "./daemon.js";
21 +import { daemonRequest, pidAlive } from "./client.js";
22 +import { GoalStore } from "./goals.js";
23 +import type { GoalEscalation, GoalType } from "./goals.js";
24 +import { buildGoalRunner } from "./runner.js";
25 +
26 +const HELP = `khaelord — the KHAELOR autonomous daemon (v2 §7)
27 +
28 +Usage
29 + khaelord start run the daemon in the foreground (use nohup/launchd/systemd to detach)
30 + khaelord stop stop a running daemon
31 + khaelord status show pid, budget, active runs
32 + khaelord tick force one scheduler pass on the running daemon's project
33 +
34 + khaelord goal add "<description>" [--type maintain|achieve|watch] [--schedule "<cron>"|heartbeat]
35 + [--check "<cmd>"] [--budget <usd>/day] [--runs <n>/day]
36 + [--escalation notify|draft-pr|auto-merge-if-verified]
37 + khaelord goal list list goals with status
38 + khaelord goal pause <id> | resume <id>
39 +
40 + khaelord approvals list pending approvals
41 + khaelord approve <id> | deny <id> resolve an approval
42 +
43 +Configuration: .khaelor/daemon/config.json (budget, activeHours, heartbeatMinutes, model, channels, pricing).
44 +Every decision the daemon takes is an event in .khaelor/daemon/goals/<id>.events.jsonl — replayable, auditable.
45 +`;
46 +
47 +function out(text: string): void {
48 + process.stdout.write(`${text}\n`);
49 +}
50 +
51 +function fail(text: string): never {
52 + process.stderr.write(`khaelord: ${text}\n`);
53 + process.exit(1);
54 +}
55 +
56 +function flagValue(argv: string[], flag: string): string | undefined {
57 + const index = argv.indexOf(flag);
58 + if (index === -1) return undefined;
59 + return argv[index + 1];
60 +}
61 +
62 +async function cmdStart(cwd: string): Promise<void> {
63 + const daemonDir = daemonDirFor(cwd);
64 + const existing = await readDaemonStatus(daemonDir);
65 + if (existing !== null && pidAlive(existing.pid)) {
66 + fail(`already running (pid ${existing.pid})`);
67 + }
68 + const logger = new FileLogger(join(defaultLogDir(), "khaelord.log"), { debug: true });
69 + const daemonConfig = await loadDaemonConfig(cwd);
70 + const sessionConfig = await loadConfig({
71 + cwd,
72 + ...(daemonConfig.model.runs !== undefined ? { flags: { model: daemonConfig.model.runs } } : {}),
73 + });
74 + if (!sessionConfig.hasApiKey) fail("ANTHROPIC_API_KEY is not set — the daemon cannot run goals.");
75 +
76 + const workspace = new LocalWorkspace(cwd);
77 + const exec = workspaceExec(workspace);
78 + const channels: ChannelAdapter[] = [];
79 + if (daemonConfig.channels.webhook !== undefined) channels.push(new WebhookChannel(daemonConfig.channels.webhook));
80 + if (daemonConfig.channels.command !== undefined) channels.push(new CommandChannel(daemonConfig.channels.command));
81 +
82 + const daemon = new KhaelorDaemon({
83 + daemonDir,
84 + config: daemonConfig,
85 + goals: new GoalStore(daemonDir),
86 + budget: new BudgetGuard(daemonDir, daemonConfig.budget),
87 + approvals: new ApprovalQueue(daemonDir),
88 + channels: new ChannelRouter(channels, (channel, error) => {
89 + logger.log("warn", `channel ${channel} failed`, { error: String(error) });
90 + }),
91 + runGoal: buildGoalRunner({
92 + config: sessionConfig,
93 + projectRoot: cwd,
94 + exec,
95 + logger,
96 + ...(daemonConfig.pricing !== undefined ? { pricing: daemonConfig.pricing } : {}),
97 + }),
98 + execCheck: async (cmd) => {
99 + const result = await workspace.exec({ cmd, timeoutMs: 120_000 });
100 + return { exitCode: result.exitCode };
101 + },
102 + log: (level, message) => {
103 + logger.log(level === "info" ? "info" : level, message);
104 + out(`[${level}] ${message}`);
105 + },
106 + });
107 +
108 + const shutdown = (): void => {
109 + void daemon.stop().then(() => process.exit(0));
110 + };
111 + process.on("SIGINT", shutdown);
112 + process.on("SIGTERM", shutdown);
113 + await daemon.start();
114 + out(`khaelord running — project ${cwd}`);
115 + out(`control socket: ${daemon.socketPath}`);
116 + // Foreground loop; the interval keeps the process alive.
117 +}
118 +
119 +async function cmdGoal(cwd: string, argv: string[]): Promise<void> {
120 + const store = new GoalStore(daemonDirFor(cwd));
121 + const sub = argv[0];
122 + if (sub === "add") {
123 + const description = argv[1];
124 + if (description === undefined || description.startsWith("--")) fail("goal add needs a description");
125 + const type = (flagValue(argv, "--type") ?? "watch") as GoalType;
126 + if (!["maintain", "achieve", "watch"].includes(type)) fail(`invalid --type ${type}`);
127 + const schedule = flagValue(argv, "--schedule") ?? "heartbeat";
128 + const check = flagValue(argv, "--check");
129 + const escalation = (flagValue(argv, "--escalation") ?? "notify") as GoalEscalation;
130 + if (!["notify", "draft-pr", "auto-merge-if-verified"].includes(escalation)) {
131 + fail(`invalid --escalation ${escalation}`);
132 + }
133 + const budgetRaw = flagValue(argv, "--budget");
134 + const runsRaw = flagValue(argv, "--runs");
135 + const maxUsdPerDay = budgetRaw !== undefined ? Number.parseFloat(budgetRaw) : undefined;
136 + const maxRunsPerDay = runsRaw !== undefined ? Number.parseInt(runsRaw, 10) : undefined;
137 + const goal = await store.create({
138 + description,
139 + type,
140 + schedule,
141 + ...(check !== undefined ? { check } : {}),
142 + escalation,
143 + budget: {
144 + ...(maxUsdPerDay !== undefined && !Number.isNaN(maxUsdPerDay) ? { maxUsdPerDay } : {}),
145 + ...(maxRunsPerDay !== undefined && !Number.isNaN(maxRunsPerDay) ? { maxRunsPerDay } : {}),
146 + },
147 + });
148 + out(`goal ${goal.id} created — ${goal.type} · schedule ${goal.schedule} · escalation ${goal.escalation}`);
149 + return;
150 + }
151 + if (sub === "list") {
152 + const goals = await store.list();
153 + if (goals.length === 0) {
154 + out("no goals — add one with: khaelord goal add \"<description>\"");
155 + return;
156 + }
157 + for (const goal of goals) {
158 + const runs = await store.runsToday(goal.id);
159 + out(
160 + `${goal.id} [${goal.status}] ${goal.type} · ${goal.schedule} · $${goal.budget.maxUsdPerDay}/day · runs today ${runs}/${goal.budget.maxRunsPerDay}\n ${goal.description}`,
161 + );
162 + }
163 + return;
164 + }
165 + if (sub === "pause" || sub === "resume") {
166 + const id = argv[1];
167 + if (id === undefined) fail(`goal ${sub} needs an id`);
168 + await store.setStatus(id, sub === "pause" ? "paused" : "active");
169 + out(`goal ${id} ${sub}d`);
170 + return;
171 + }
172 + fail(`unknown goal subcommand: ${String(sub)}`);
173 +}
174 +
175 +export async function daemonMain(argv: string[] = process.argv.slice(2)): Promise<void> {
176 + const cwd = process.cwd();
177 + const daemonDir = daemonDirFor(cwd);
178 + const command = argv[0];
179 +
180 + switch (command) {
181 + case undefined:
182 + case "-h":
183 + case "--help":
184 + case "help":
185 + out(HELP);
186 + return;
187 + case "start":
188 + await cmdStart(cwd);
189 + return;
190 + case "stop": {
191 + const response = await daemonRequest(daemonDir, { cmd: "stop" }).catch(() => null);
192 + if (response === null) fail("no running daemon (or socket unreachable)");
193 + out("stopping");
194 + return;
195 + }
196 + case "status": {
197 + const status = await readDaemonStatus(daemonDir);
198 + if (status === null || !pidAlive(status.pid)) {
199 + out("khaelord: not running");
200 + return;
201 + }
202 + const live = await daemonRequest(daemonDir, { cmd: "status" }).catch(() => null);
203 + out(`khaelord: running (pid ${status.pid}, since ${new Date(status.startedAt).toISOString()})`);
204 + if (live !== null) {
205 + out(
206 + ` active runs: ${String(live["activeRuns"])} · spent today: $${Number(live["spentTodayUsd"] ?? 0).toFixed(2)}`,
207 + );
208 + }
209 + return;
210 + }
211 + case "goal":
212 + await cmdGoal(cwd, argv.slice(1));
213 + return;
214 + case "approvals": {
215 + const response = await daemonRequest(daemonDir, { cmd: "approvals" }).catch(() => null);
216 + if (response === null) fail("no running daemon");
217 + const approvals = (response["approvals"] ?? []) as { id: string; capability: string; context: string }[];
218 + if (approvals.length === 0) out("no pending approvals");
219 + for (const approval of approvals) {
220 + out(`${approval.id} ${approval.capability}\n ${approval.context}`);
221 + }
222 + return;
223 + }
224 + case "approve":
225 + case "deny": {
226 + const id = argv[1];
227 + if (id === undefined) fail(`${command} needs an approval id`);
228 + const response = await daemonRequest(daemonDir, { cmd: command, id }).catch(() => null);
229 + if (response === null) fail("no running daemon");
230 + out(response["ok"] === true ? `${command}d ${id}` : `failed: ${String(response["error"])}`);
231 + return;
232 + }
233 + default:
234 + fail(`unknown command: ${command}\n\n${HELP}`);
235 + }
236 +}
237 +
238 +// Direct execution (bin entry).
239 +const isMain = process.argv[1]?.endsWith("daemon/main.js") === true || process.argv[1]?.endsWith("khaelord") === true;
240 +if (isMain) {
241 + daemonMain().catch((error: unknown) => {
242 + process.stderr.write(`khaelord: ${error instanceof Error ? error.message : String(error)}\n`);
243 + process.exit(1);
244 + });
245 +}
added src/daemon/runner.ts +120 −0
@@ -0,0 +1,120 @@
1 +/**
2 + * KHAELOR
3 + * File: src/daemon/runner.ts
4 + * Description: Goal runner — one autonomous run: throwaway worktree, headless engine, phase gates + verify, escalation (v2 design §7.4).
5 + *
6 + * Author: Simon-Pierre Boucher
7 + * Contact: contact@spboucher.ai
8 + */
9 +
10 +import type { ResolvedConfig } from "../config/index.js";
11 +import { runHeadlessTurn } from "../cli/headless.js";
12 +import type { FileLogger } from "../cli/logger.js";
13 +import {
14 + createWorktree,
15 + mergeSubtaskBranch,
16 + removeWorktree,
17 + worktreeDiffStats,
18 +} from "../tasks/index.js";
19 +import type { ExecFn } from "../tasks/index.js";
20 +import type { DaemonPricing } from "./config.js";
21 +import type { GoalRunResult, GoalRunner } from "./daemon.js";
22 +import type { Goal } from "./goals.js";
23 +
24 +const RUN_PROMPT_SUFFIX =
25 + "\n\nYou are an autonomous KHAELOR daemon run inside a dedicated git worktree. " +
26 + "Design before implementing (the phase gate enforces it), verify before finishing, and never " +
27 + "push or merge — escalation is handled by the daemon. If nothing needs doing, say so and stop.";
28 +
29 +/** Real-usage → USD, only when pricing is configured (Absolute Rule #4). */
30 +export function costFromUsage(
31 + usage: { inputTokens: number; outputTokens: number; cacheReadTokens: number; cacheWriteTokens: number },
32 + pricing: DaemonPricing | undefined,
33 +): number {
34 + if (pricing === undefined) return 0;
35 + return (
36 + (usage.inputTokens / 1_000_000) * pricing.inputPerMTok +
37 + (usage.outputTokens / 1_000_000) * pricing.outputPerMTok +
38 + (usage.cacheReadTokens / 1_000_000) * pricing.cacheReadPerMTok +
39 + (usage.cacheWriteTokens / 1_000_000) * pricing.cacheWritePerMTok
40 + );
41 +}
42 +
43 +export interface BuildGoalRunnerOptions {
44 + config: ResolvedConfig;
45 + projectRoot: string;
46 + exec: ExecFn;
47 + logger: FileLogger;
48 + pricing?: DaemonPricing;
49 + /** Model override for runs (daemon config `model.runs`). */
50 + runsModel?: string;
51 +}
52 +
53 +/**
54 + * Each goal run (v2 §7.4):
55 + * 1. isolated session in a throwaway worktree — the daemon never touches the working copy,
56 + * 2. phase gates in the configured mode — the DesignArtifact lands in the log even at 3 AM,
57 + * 3. mandatory verification — no escalation when checks fail,
58 + * 4. escalation: notify → branch left for review; auto-merge-if-verified → --no-ff merge.
59 + */
60 +export function buildGoalRunner(options: BuildGoalRunnerOptions): GoalRunner {
61 + return async (goal: Goal, runId: string): Promise<GoalRunResult> => {
62 + const worktree = await createWorktree(options.exec, options.projectRoot, `goal-${runId}`);
63 + try {
64 + const result = await runHeadlessTurn({
65 + config: options.config,
66 + cwd: worktree.path,
67 + prompt: `Goal (${goal.type}): ${goal.description}${RUN_PROMPT_SUFFIX}`,
68 + logger: options.logger,
69 + meta: { parent: null },
70 + });
71 + const costUsd = costFromUsage(result.usage, options.pricing);
72 +
73 + // Commit the run's changes onto the goal branch so diff/merge see them.
74 + await options.exec("git add -A", worktree.path);
75 + await options.exec(
76 + `git commit -m ${JSON.stringify(`khaelor goal ${goal.id} run ${runId}`)} --no-verify`,
77 + worktree.path,
78 + );
79 + const diff = await worktreeDiffStats(options.exec, options.projectRoot, worktree.branch);
80 + const changed = diff.files.length > 0;
81 +
82 + let detail =
83 + `${result.finalText.slice(0, 800)}\n` +
84 + `diff: +${diff.added} −${diff.removed} across ${diff.files.length} file(s)`;
85 + let keepBranch = changed;
86 +
87 + if (changed && result.outcome === "done") {
88 + if (goal.escalation === "auto-merge-if-verified" && result.verifyOk === true) {
89 + const merge = await mergeSubtaskBranch(
90 + options.exec,
91 + options.projectRoot,
92 + worktree.branch,
93 + `khaelor goal ${goal.id}: ${goal.description.slice(0, 60)}`,
94 + );
95 + detail += merge.ok ? "\nauto-merged (verified)" : `\nmerge failed: ${merge.detail}`;
96 + keepBranch = !merge.ok;
97 + } else {
98 + detail += `\nbranch ${worktree.branch} left for review (escalation: ${goal.escalation})`;
99 + }
100 + }
101 +
102 + await removeWorktree(options.exec, options.projectRoot, worktree, {
103 + deleteBranch: !keepBranch,
104 + });
105 + return {
106 + outcome: result.outcome === "done" ? "done" : "failed",
107 + detail,
108 + costUsd,
109 + sessionId: result.sessionId,
110 + verifyOk: result.verifyOk,
111 + branch: keepBranch ? worktree.branch : null,
112 + };
113 + } catch (error) {
114 + await removeWorktree(options.exec, options.projectRoot, worktree, { deleteBranch: true }).catch(
115 + () => undefined,
116 + );
117 + throw error;
118 + }
119 + };
120 +}
added src/memory/index.ts +20 −0
@@ -0,0 +1,20 @@
1 +/**
2 + * KHAELOR
3 + * File: src/memory/index.ts
4 + * Description: Public surface of the project-memory module (v2 design §5).
5 + *
6 + * Author: Simon-Pierre Boucher
7 + * Contact: contact@spboucher.ai
8 + */
9 +
10 +export {
11 + MEMORY_FILE,
12 + appendMemoryEntry,
13 + parseMemory,
14 + purgeCandidates,
15 +} from "./store.js";
16 +export type {
17 + MemoryConfidence,
18 + MemoryEntry,
19 + MemoryProvenance,
20 +} from "./store.js";
added src/memory/store.ts +127 −0
@@ -0,0 +1,127 @@
1 +/**
2 + * KHAELOR
3 + * File: src/memory/store.ts
4 + * Description: Project memory store — .khaelor/MEMORY.md with event-anchored provenance comments (v2 design §5).
5 + *
6 + * Author: Simon-Pierre Boucher
7 + * Contact: contact@spboucher.ai
8 + */
9 +
10 +/** Relative path of the project memory file. */
11 +export const MEMORY_FILE = ".khaelor/MEMORY.md";
12 +
13 +export type MemoryConfidence = "high" | "medium" | "low";
14 +
15 +export interface MemoryProvenance {
16 + session: string;
17 + /** Anchor into the session log — the tool_use id of the remember call. */
18 + tool: string;
19 + confidence: MemoryConfidence;
20 + /** ISO date (YYYY-MM-DD). */
21 + date: string;
22 +}
23 +
24 +export interface MemoryEntry {
25 + section: string;
26 + text: string;
27 + provenance: MemoryProvenance | null;
28 +}
29 +
30 +const HEADER = "# Project memory\n\n> Maintained by KHAELOR. Every entry is anchored to the session/event that produced it.\n";
31 +
32 +const PROVENANCE_RE =
33 + /<!--\s*khaelor:\s*session=(\S+)\s+tool=(\S+)\s+confidence=(high|medium|low)\s+date=(\S+)\s*-->/;
34 +
35 +/** Canonical section title casing: "conventions" → "Conventions". */
36 +function sectionTitle(section: string): string {
37 + const trimmed = section.trim();
38 + if (trimmed.length === 0) return "Notes";
39 + return trimmed.charAt(0).toUpperCase() + trimmed.slice(1);
40 +}
41 +
42 +/** Parse MEMORY.md into entries. Tolerant: unknown lines are ignored. */
43 +export function parseMemory(content: string): MemoryEntry[] {
44 + const entries: MemoryEntry[] = [];
45 + let section = "Notes";
46 + let currentText: string[] = [];
47 + let currentProvenance: MemoryProvenance | null = null;
48 +
49 + const flush = (): void => {
50 + const text = currentText.join("\n").trim();
51 + if (text.length > 0) entries.push({ section, text, provenance: currentProvenance });
52 + currentText = [];
53 + currentProvenance = null;
54 + };
55 +
56 + for (const line of content.split("\n")) {
57 + const heading = /^##\s+(.+)$/.exec(line);
58 + if (heading !== null) {
59 + flush();
60 + section = (heading[1] as string).trim();
61 + continue;
62 + }
63 + const provenance = PROVENANCE_RE.exec(line);
64 + if (provenance !== null) {
65 + currentProvenance = {
66 + session: provenance[1] as string,
67 + tool: provenance[2] as string,
68 + confidence: provenance[3] as MemoryConfidence,
69 + date: provenance[4] as string,
70 + };
71 + flush();
72 + continue;
73 + }
74 + if (/^-\s+/.test(line) && currentText.length > 0) flush();
75 + if (line.trim().length > 0 && !line.startsWith("#") && !line.startsWith(">")) {
76 + currentText.push(line);
77 + }
78 + }
79 + flush();
80 + return entries;
81 +}
82 +
83 +function formatProvenance(p: MemoryProvenance): string {
84 + return ` <!-- khaelor: session=${p.session} tool=${p.tool} confidence=${p.confidence} date=${p.date} -->`;
85 +}
86 +
87 +/** Append one entry to existing MEMORY.md content, creating the section when needed. */
88 +export function appendMemoryEntry(
89 + existing: string | null,
90 + entry: { section: string; text: string; provenance: MemoryProvenance },
91 +): string {
92 + const title = sectionTitle(entry.section);
93 + const bullet = entry.text.startsWith("- ") ? entry.text : `- ${entry.text}`;
94 + const block = `${bullet}\n${formatProvenance(entry.provenance)}\n`;
95 +
96 + const base = existing !== null && existing.trim().length > 0 ? existing : HEADER;
97 + const lines = base.split("\n");
98 + const headingLine = `## ${title}`;
99 + const headingIndex = lines.findIndex((line) => line.trim() === headingLine);
100 +
101 + if (headingIndex === -1) {
102 + const trimmed = base.replace(/\n+$/, "");
103 + return `${trimmed}\n\n${headingLine}\n${block}`;
104 + }
105 +
106 + // Insert at the end of the section (before the next heading or EOF).
107 + let insertAt = lines.length;
108 + for (let i = headingIndex + 1; i < lines.length; i++) {
109 + if (/^##\s+/.test(lines[i] as string)) {
110 + insertAt = i;
111 + break;
112 + }
113 + }
114 + while (insertAt > headingIndex + 1 && (lines[insertAt - 1] as string).trim().length === 0) {
115 + insertAt -= 1;
116 + }
117 + const out = [...lines.slice(0, insertAt), ...block.replace(/\n$/, "").split("\n"), ...lines.slice(insertAt)];
118 + return out.join("\n");
119 +}
120 +
121 +/**
122 + * Hygiene report: low-confidence entries are purge candidates the agent may
123 + * propose to drop during /compact (v2 §5.4).
124 + */
125 +export function purgeCandidates(entries: readonly MemoryEntry[]): MemoryEntry[] {
126 + return entries.filter((entry) => entry.provenance?.confidence === "low");
127 +}
modified src/permissions/capabilities.ts +22 −0
@@ -228,6 +228,28 @@ export function mapToolCapabilities(
228 228 const subject = resolveSubjectPath(filePath.value, ctx);
229 229 return ok([fileReadRequest(subject, `Read ${subject}`)]);
230 230 }
231 + case "design":
232 + // Pure phase-gate bookkeeping — records events, touches nothing (v2 §1).
233 + return ok([]);
234 + case "remember": {
235 + // Writes exclusively to the project memory file (v2 §5).
236 + const subject = resolveSubjectPath(".khaelor/MEMORY.md", ctx);
237 + return ok([
238 + {
239 + capability: "file.write.project",
240 + subject,
241 + display: `Append to project memory ${subject}`,
242 + alwaysPatterns: [subject],
243 + riskNotes: [],
244 + },
245 + ]);
246 + }
247 + case "symbols":
248 + case "refs": {
249 + // Read-only queries over the local semantic index (v2 §3).
250 + const subject = resolveSubjectPath(ctx.cwd, ctx);
251 + return ok([fileReadRequest(subject, `Query symbol index ${subject}`)]);
252 + }
231 253 case "glob":
232 254 case "grep": {
233 255 const pathValue = raw["path"];
added src/phases/gate.ts +116 −0
@@ -0,0 +1,116 @@
1 +/**
2 + * KHAELOR
3 + * File: src/phases/gate.ts
4 + * Description: The pure phase gate — per-phase capability policy over capability requests (v2 design §1).
5 + *
6 + * Author: Simon-Pierre Boucher
7 + * Contact: contact@spboucher.ai
8 + */
9 +
10 +import type { CapabilityRequest } from "../permissions/index.js";
11 +import type { Phase } from "../session/index.js";
12 +import { PHASE_GATE_BLOCKED } from "./types.js";
13 +
14 +/**
15 + * Read-only command prefixes usable in understand/design (collapsed-whitespace
16 + * prefix match). Deliberately conservative — anything else waits for implement.
17 + */
18 +const READONLY_COMMAND_PREFIXES: readonly string[] = [
19 + "git status",
20 + "git diff",
21 + "git log",
22 + "git show",
23 + "git branch",
24 + "git blame",
25 + "ls",
26 + "cat ",
27 + "pwd",
28 + "which ",
29 + "wc ",
30 + "head ",
31 + "tail ",
32 + "grep ",
33 + "rg ",
34 + "find ",
35 + "file ",
36 + "du ",
37 + "tree",
38 + "node --version",
39 + "npm ls",
40 + "npm view",
41 +];
42 +
43 +function isReadonlyCommand(subject: string): boolean {
44 + const collapsed = subject.replace(/\s+/g, " ").trim();
45 + return READONLY_COMMAND_PREFIXES.some(
46 + (prefix) => collapsed === prefix.trim() || collapsed.startsWith(prefix),
47 + );
48 +}
49 +
50 +/** Design-phase writable area: docs/design/*.md under the project root. */
51 +function isDesignDocPath(subject: string, projectRoot: string): boolean {
52 + const normalizedRoot = projectRoot.endsWith("/") ? projectRoot : `${projectRoot}/`;
53 + return (
54 + subject.startsWith(`${normalizedRoot}docs/design/`) &&
55 + subject.endsWith(".md") &&
56 + !subject.includes("..")
57 + );
58 +}
59 +
60 +/** Project memory is writable in every phase — remembering IS understanding (v2 §5). */
61 +function isMemoryPath(subject: string): boolean {
62 + return subject.endsWith("/.khaelor/MEMORY.md") || subject.endsWith(".khaelor/MEMORY.md");
63 +}
64 +
65 +export type GateCheck = { allowed: true } | { allowed: false; feedback: string };
66 +
67 +function blocked(capability: string, subject: string, phase: Phase): GateCheck {
68 + return {
69 + allowed: false,
70 + feedback:
71 + `${PHASE_GATE_BLOCKED}: ${capability} for "${subject}" is not available in the "${phase}" phase. ` +
72 + `KHAELOR works in three phases: understand → design → implement. ` +
73 + `Finalize your design first: call the "design" tool with your goal, technical approach, ` +
74 + `the files you plan to touch, the risks, and how you will verify the result. ` +
75 + `Once the design is approved, implementation capabilities unlock.`,
76 + };
77 +}
78 +
79 +/**
80 + * Evaluate one tool call's capability requests against the current phase.
81 + * The gate sits BEFORE the permission evaluation: a blocked call never
82 + * reaches the permission service (v2 design §1 — the Tool Runtime checks
83 + * the current phase before each dispatch).
84 + */
85 +export function checkPhaseGate(
86 + phase: Phase,
87 + requests: readonly CapabilityRequest[],
88 + projectRoot: string,
89 +): GateCheck {
90 + if (phase === "implement") return { allowed: true };
91 +
92 + for (const request of requests) {
93 + switch (request.capability) {
94 + case "file.read":
95 + continue;
96 + case "file.write.project": {
97 + if (isMemoryPath(request.subject)) continue;
98 + if (phase === "design" && isDesignDocPath(request.subject, projectRoot)) continue;
99 + return blocked(request.capability, request.subject, phase);
100 + }
101 + case "file.write.outsideProject":
102 + return blocked(request.capability, request.subject, phase);
103 + case "process.execute":
104 + case "process.background": {
105 + if (isReadonlyCommand(request.subject)) continue;
106 + return blocked(request.capability, request.subject, phase);
107 + }
108 + case "network.access":
109 + case "git.modify":
110 + return blocked(request.capability, request.subject, phase);
111 + default:
112 + return blocked(request.capability, request.subject, phase);
113 + }
114 + }
115 + return { allowed: true };
116 +}
added src/phases/index.ts +25 −0
@@ -0,0 +1,25 @@
1 +/**
2 + * KHAELOR
3 + * File: src/phases/index.ts
4 + * Description: Public surface of the phase-gate module (v2 design §1).
5 + *
6 + * Author: Simon-Pierre Boucher
7 + * Contact: contact@spboucher.ai
8 + */
9 +
10 +export { checkPhaseGate } from "./gate.js";
11 +export type { GateCheck } from "./gate.js";
12 +export { foldPhaseState } from "./state.js";
13 +export type { PhaseState } from "./state.js";
14 +export { PhaseService } from "./service.js";
15 +export type { PhaseServiceOptions, PhaseSessionHandle } from "./service.js";
16 +export { DEFAULT_GATE_CONFIG, PHASE_GATE_BLOCKED } from "./types.js";
17 +export type {
18 + DesignArtifact,
19 + DesignDecision,
20 + GateAutoApprove,
21 + GateConfig,
22 + GateMode,
23 + Phase,
24 + PhaseApprovalAsker,
25 +} from "./types.js";
added src/phases/service.ts +162 −0
@@ -0,0 +1,162 @@
1 +/**
2 + * KHAELOR
3 + * File: src/phases/service.ts
4 + * Description: PhaseService — session-facing phase-gate coordinator: transitions, design approval, tool-call checks (v2 design §1).
5 + *
6 + * Author: Simon-Pierre Boucher
7 + * Contact: contact@spboucher.ai
8 + */
9 +
10 +import type { CapabilityRequest } from "../permissions/index.js";
11 +import type { DesignArtifact, DurableEvent, DurableEventInput, Phase } from "../session/index.js";
12 +import { ulid } from "../shared/index.js";
13 +import { checkPhaseGate } from "./gate.js";
14 +import type { GateCheck } from "./gate.js";
15 +import { foldPhaseState } from "./state.js";
16 +import type { PhaseState } from "./state.js";
17 +import type { DesignDecision, GateConfig, PhaseApprovalAsker } from "./types.js";
18 +
19 +/** The session seam the service needs — satisfied by EventLogSession. */
20 +export interface PhaseSessionHandle {
21 + events(): readonly DurableEvent[];
22 + publishDurable(input: DurableEventInput): unknown;
23 +}
24 +
25 +export interface PhaseServiceOptions {
26 + session: PhaseSessionHandle;
27 + config: GateConfig;
28 + projectRoot: string;
29 + /** Strict-mode design approval panel; absent → approval stays pending. */
30 + asker?: PhaseApprovalAsker;
31 + newId?: () => string;
32 +}
33 +
34 +/**
35 + * The gate as a service AROUND the kernel (Absolute Rule #3): the executor
36 + * consults `checkToolCall` before dispatch; the `design` tool calls
37 + * `submitDesign`; the TUI's /phase escape hatch calls `forcePhase`.
38 + */
39 +export class PhaseService {
40 + readonly #session: PhaseSessionHandle;
41 + readonly #config: GateConfig;
42 + readonly #projectRoot: string;
43 + readonly #asker: PhaseApprovalAsker | undefined;
44 + readonly #newId: () => string;
45 +
46 + constructor(options: PhaseServiceOptions) {
47 + this.#session = options.session;
48 + this.#config = options.config;
49 + this.#projectRoot = options.projectRoot;
50 + this.#asker = options.asker;
51 + this.#newId = options.newId ?? ulid;
52 + }
53 +
54 + get mode(): GateConfig["mode"] {
55 + return this.#config.mode;
56 + }
57 +
58 + state(): PhaseState {
59 + return foldPhaseState(this.#session.events());
60 + }
61 +
62 + current(): Phase {
63 + return this.#config.mode === "off" ? "implement" : this.state().phase;
64 + }
65 +
66 + /** Record the initial understand phase for fresh gated sessions. */
67 + ensureStarted(): void {
68 + if (this.#config.mode === "off") return;
69 + const hasPhaseEvent = this.#session.events().some((e) => e.type === "phase.entered");
70 + if (hasPhaseEvent) return;
71 + this.#session.publishDurable({
72 + type: "phase.entered",
73 + payload: { phase: "understand", via: "session-start" },
74 + });
75 + }
76 +
77 + /** Tool Runtime hook — evaluated between capability mapping and permissions. */
78 + checkToolCall(requests: readonly CapabilityRequest[]): GateCheck {
79 + if (this.#config.mode === "off") return { allowed: true };
80 + return checkPhaseGate(this.state().phase, requests, this.#projectRoot);
81 + }
82 +
83 + /**
84 + * Record a design artifact and decide its approval:
85 + * - auto mode: self-approved when it touches ≤ autoApprove.maxFiles files,
86 + * otherwise falls through to the asker (or stays pending).
87 + * - strict mode: always asks the user.
88 + */
89 + async submitDesign(artifact: DesignArtifact): Promise<DesignDecision> {
90 + const artifactId = this.#newId();
91 + if (this.state().phase === "understand") {
92 + this.#session.publishDurable({
93 + type: "phase.entered",
94 + payload: { phase: "design", via: "design-submitted" },
95 + });
96 + }
97 + this.#session.publishDurable({
98 + type: "phase.artifact",
99 + payload: { artifactId, artifact },
100 + });
101 +
102 + if (this.#config.mode === "off") {
103 + return { status: "approved", artifactId, reason: "phase gates are off" };
104 + }
105 +
106 + if (
107 + this.#config.mode === "auto" &&
108 + artifact.filesTouched.length <= this.#config.autoApprove.maxFiles
109 + ) {
110 + this.#approve("auto-policy", artifactId);
111 + return { status: "approved", artifactId };
112 + }
113 +
114 + if (this.#asker !== undefined) {
115 + const answer = await this.#asker.askDesign(artifactId, artifact);
116 + if (answer.approved) {
117 + this.#approve("user", artifactId);
118 + return { status: "approved", artifactId };
119 + }
120 + const reason = answer.reason ?? "rejected by user";
121 + this.#session.publishDurable({
122 + type: "phase.rejected",
123 + payload: { phase: "design", reason, artifactId },
124 + });
125 + return { status: "rejected", artifactId, reason };
126 + }
127 +
128 + // Non-interactive with a design above the auto threshold: stays pending.
129 + return {
130 + status: "pending",
131 + artifactId,
132 + reason:
133 + "The design exceeds the auto-approval threshold and no interactive approver is available. " +
134 + "Ask the user to approve with /phase, or narrow the design.",
135 + };
136 + }
137 +
138 + /** /phase escape hatch — a user-forced transition, always logged as an override. */
139 + forcePhase(phase: Phase): void {
140 + if (phase === "implement") {
141 + this.#session.publishDurable({
142 + type: "phase.approved",
143 + payload: { phase: "design", approvedBy: "user-override" },
144 + });
145 + }
146 + this.#session.publishDurable({
147 + type: "phase.entered",
148 + payload: { phase, via: "user-override" },
149 + });
150 + }
151 +
152 + #approve(approvedBy: "user" | "auto-policy", artifactId: string): void {
153 + this.#session.publishDurable({
154 + type: "phase.approved",
155 + payload: { phase: "design", approvedBy, artifactId },
156 + });
157 + this.#session.publishDurable({
158 + type: "phase.entered",
159 + payload: { phase: "implement", via: "approval" },
160 + });
161 + }
162 +}
added src/phases/state.ts +54 −0
@@ -0,0 +1,54 @@
1 +/**
2 + * KHAELOR
3 + * File: src/phases/state.ts
4 + * Description: Pure fold of phase events into the current phase-gate state (v2 design §1).
5 + *
6 + * Author: Simon-Pierre Boucher
7 + * Contact: contact@spboucher.ai
8 + */
9 +
10 +import type { DesignArtifact, DurableEvent, Phase } from "../session/index.js";
11 +
12 +export interface PhaseState {
13 + /** Current phase; sessions without any phase event are in "understand". */
14 + phase: Phase;
15 + /** Artifact submitted but not yet approved/rejected. */
16 + pendingArtifact: { artifactId: string; artifact: DesignArtifact } | null;
17 + /** The artifact id whose approval unlocked implement, when one exists. */
18 + approvedArtifactId: string | null;
19 + /** True once a phase.approved{phase:"design"} exists in the session. */
20 + designApproved: boolean;
21 +}
22 +
23 +/** Fold the durable stream into the phase-gate decision state. */
24 +export function foldPhaseState(events: readonly DurableEvent[]): PhaseState {
25 + let phase: Phase = "understand";
26 + let pending: PhaseState["pendingArtifact"] = null;
27 + let approvedArtifactId: string | null = null;
28 + let designApproved = false;
29 +
30 + for (const event of events) {
31 + switch (event.type) {
32 + case "phase.entered":
33 + phase = event.payload.phase;
34 + break;
35 + case "phase.artifact":
36 + pending = { artifactId: event.payload.artifactId, artifact: event.payload.artifact };
37 + break;
38 + case "phase.approved":
39 + if (event.payload.phase === "design") {
40 + designApproved = true;
41 + approvedArtifactId = event.payload.artifactId ?? pending?.artifactId ?? null;
42 + pending = null;
43 + }
44 + break;
45 + case "phase.rejected":
46 + pending = null;
47 + break;
48 + default:
49 + break;
50 + }
51 + }
52 +
53 + return { phase, pendingArtifact: pending, approvedArtifactId, designApproved };
54 +}
added src/phases/types.ts +50 −0
@@ -0,0 +1,50 @@
1 +/**
2 + * KHAELOR
3 + * File: src/phases/types.ts
4 + * Description: Phase-gate vocabulary — gate modes, gate config, defaults (v2 design §1).
5 + *
6 + * Author: Simon-Pierre Boucher
7 + * Contact: contact@spboucher.ai
8 + */
9 +
10 +import type { DesignArtifact, Phase } from "../session/index.js";
11 +
12 +export type { DesignArtifact, Phase };
13 +
14 +/**
15 + * Gate rigor modes:
16 + * - "strict": three phases, human approval of every design artifact.
17 + * - "auto": the agent self-approves when the design touches ≤ maxFiles files (default).
18 + * - "off": v1 behavior — no gate.
19 + */
20 +export type GateMode = "strict" | "auto" | "off";
21 +
22 +export interface GateAutoApprove {
23 + /** Auto-approve designs touching at most this many files. */
24 + maxFiles: number;
25 +}
26 +
27 +export interface GateConfig {
28 + mode: GateMode;
29 + autoApprove: GateAutoApprove;
30 +}
31 +
32 +export const DEFAULT_GATE_CONFIG: Readonly<GateConfig> = Object.freeze({
33 + mode: "auto" as GateMode,
34 + autoApprove: Object.freeze({ maxFiles: 3 }),
35 +});
36 +
37 +/** Result of submitting a design artifact through the gate. */
38 +export interface DesignDecision {
39 + status: "approved" | "rejected" | "pending";
40 + artifactId: string;
41 + reason?: string;
42 +}
43 +
44 +/** Injected TUI panel callback for strict-mode design approval. */
45 +export interface PhaseApprovalAsker {
46 + askDesign(artifactId: string, artifact: DesignArtifact): Promise<{ approved: boolean; reason?: string }>;
47 +}
48 +
49 +/** Structured error prefix the model learns to react to (prompt engineering by architecture). */
50 +export const PHASE_GATE_BLOCKED = "PHASE_GATE_BLOCKED";
added src/repograph/extractor.ts +250 −0
@@ -0,0 +1,250 @@
1 +/**
2 + * KHAELOR
3 + * File: src/repograph/extractor.ts
4 + * Description: Symbol/import extraction for TS/JS/Python — dependency-free heuristic parser (tree-sitter is the documented upgrade path) (v2 design §3).
5 + *
6 + * Author: Simon-Pierre Boucher
7 + * Contact: contact@spboucher.ai
8 + */
9 +
10 +export type SymbolKind = "function" | "class" | "type" | "export" | "variable" | "method";
11 +
12 +export interface ExtractedSymbol {
13 + name: string;
14 + kind: SymbolKind;
15 + /** 1-based line of the declaration. */
16 + line: number;
17 + /** The declaration line, trimmed. */
18 + signature: string;
19 + /** First line of the preceding doc comment, when present. */
20 + docComment?: string;
21 + exported: boolean;
22 +}
23 +
24 +export interface ExtractedImport {
25 + /** Module specifier as written: "./kernel.js", "node:path", "react". */
26 + spec: string;
27 + /** Imported names ("default" for default imports, "*" for namespace). */
28 + names: string[];
29 + line: number;
30 +}
31 +
32 +export interface ExtractionResult {
33 + symbols: ExtractedSymbol[];
34 + imports: ExtractedImport[];
35 +}
36 +
37 +const TS_EXTENSIONS = new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"]);
38 +
39 +/** Extensions the extractor understands. */
40 +export function isIndexablePath(path: string): boolean {
41 + const dot = path.lastIndexOf(".");
42 + if (dot === -1) return false;
43 + const ext = path.slice(dot);
44 + return TS_EXTENSIONS.has(ext) || ext === ".py";
45 +}
46 +
47 +const MAX_SIGNATURE = 160;
48 +
49 +function clip(line: string): string {
50 + const trimmed = line.trim();
51 + return trimmed.length > MAX_SIGNATURE ? `${trimmed.slice(0, MAX_SIGNATURE)}…` : trimmed;
52 +}
53 +
54 +/** Extract the first sentence of a `/** … *\/` block ending just above `index`. */
55 +function docCommentAbove(lines: readonly string[], index: number): string | undefined {
56 + let i = index - 1;
57 + while (i >= 0 && (lines[i] as string).trim().length === 0) i -= 1;
58 + if (i < 0) return undefined;
59 + const above = (lines[i] as string).trim();
60 + if (above.endsWith("*/")) {
61 + // Walk up to the /** opener collecting the first content line.
62 + for (let j = i; j >= 0 && j > i - 20; j -= 1) {
63 + const candidate = (lines[j] as string).trim();
64 + if (candidate.startsWith("/**")) {
65 + const inline = candidate.replace(/^\/\*\*\s*/, "").replace(/\s*\*\/$/, "");
66 + if (inline.length > 0) return clip(inline);
67 + const next = (lines[j + 1] as string | undefined)?.trim().replace(/^\*\s?/, "");
68 + return next !== undefined && next.length > 0 ? clip(next) : undefined;
69 + }
70 + }
71 + }
72 + if (above.startsWith("//")) return clip(above.replace(/^\/\/\s?/, ""));
73 + if (above.startsWith("#")) return clip(above.replace(/^#\s?/, ""));
74 + return undefined;
75 +}
76 +
77 +// TS/JS declaration patterns — anchored to line starts, tolerant of export modifiers.
78 +const TS_PATTERNS: { re: RegExp; kind: SymbolKind }[] = [
79 + { re: /^(export\s+)?(default\s+)?(async\s+)?function\s*\*?\s+([A-Za-z_$][\w$]*)/, kind: "function" },
80 + { re: /^(export\s+)?(default\s+)?(abstract\s+)?class\s+([A-Za-z_$][\w$]*)/, kind: "class" },
81 + { re: /^(export\s+)?interface\s+([A-Za-z_$][\w$]*)/, kind: "type" },
82 + { re: /^(export\s+)?type\s+([A-Za-z_$][\w$]*)\s*=/, kind: "type" },
83 + { re: /^(export\s+)?enum\s+([A-Za-z_$][\w$]*)/, kind: "type" },
84 + { re: /^(export\s+)?const\s+([A-Za-z_$][\w$]*)\s*(?::[^=]+)?=/, kind: "variable" },
85 + { re: /^(export\s+)?let\s+([A-Za-z_$][\w$]*)\s*(?::[^=]+)?=/, kind: "variable" },
86 +];
87 +
88 +const TS_METHOD = /^(?:public\s+|private\s+|protected\s+|static\s+|readonly\s+)*(?:async\s+)?(?:get\s+|set\s+)?([A-Za-z_$][\w$]*)\s*(?:<[^>]*>)?\([^;]*\)\s*(?::[^{;]+)?\{\s*$/;
89 +const TS_KEYWORD_NOT_METHOD = new Set([
90 + "if", "for", "while", "switch", "catch", "return", "function", "constructor", "new", "typeof", "else", "do", "try",
91 +]);
92 +
93 +const TS_IMPORT = /^import\s+(?:type\s+)?(.+?)\s+from\s+["']([^"']+)["']/;
94 +const TS_SIDE_EFFECT_IMPORT = /^import\s+["']([^"']+)["']/;
95 +const TS_EXPORT_LIST = /^export\s*\{([^}]*)\}/;
96 +
97 +function parseImportNames(clause: string): string[] {
98 + const names: string[] = [];
99 + const braces = /\{([^}]*)\}/.exec(clause);
100 + if (braces !== null) {
101 + for (const part of (braces[1] as string).split(",")) {
102 + const name = part.trim().split(/\s+as\s+/)[0]?.trim();
103 + if (name !== undefined && name.length > 0) names.push(name);
104 + }
105 + }
106 + const withoutBraces = clause.replace(/\{[^}]*\}/, "").trim();
107 + if (withoutBraces.startsWith("* as")) names.push("*");
108 + else {
109 + const first = withoutBraces.split(",")[0]?.trim();
110 + if (first !== undefined && first.length > 0 && first !== "*") names.push("default");
111 + }
112 + return names;
113 +}
114 +
115 +function extractTs(lines: readonly string[]): ExtractionResult {
116 + const symbols: ExtractedSymbol[] = [];
117 + const imports: ExtractedImport[] = [];
118 + let braceDepth = 0;
119 + let inClassAtDepth = -1;
120 +
121 + for (let i = 0; i < lines.length; i += 1) {
122 + const raw = lines[i] as string;
123 + const line = raw.trim();
124 +
125 + const importMatch = TS_IMPORT.exec(line);
126 + if (importMatch !== null) {
127 + imports.push({
128 + spec: importMatch[2] as string,
129 + names: parseImportNames(importMatch[1] as string),
130 + line: i + 1,
131 + });
132 + } else {
133 + const sideEffect = TS_SIDE_EFFECT_IMPORT.exec(line);
134 + if (sideEffect !== null) {
135 + imports.push({ spec: sideEffect[1] as string, names: [], line: i + 1 });
136 + }
137 + }
138 +
139 + if (braceDepth === 0) {
140 + const exportList = TS_EXPORT_LIST.exec(line);
141 + if (exportList !== null && !line.includes(" from ")) {
142 + for (const part of (exportList[1] as string).split(",")) {
143 + const name = part.trim().split(/\s+as\s+/)[0]?.trim();
144 + if (name !== undefined && name.length > 0) {
145 + symbols.push({ name, kind: "export", line: i + 1, signature: clip(line), exported: true });
146 + }
147 + }
148 + }
149 + for (const pattern of TS_PATTERNS) {
150 + const match = pattern.re.exec(line);
151 + if (match !== null) {
152 + const name = match[match.length - 1] as string;
153 + const doc = docCommentAbove(lines, i);
154 + symbols.push({
155 + name,
156 + kind: pattern.kind,
157 + line: i + 1,
158 + signature: clip(line),
159 + ...(doc !== undefined ? { docComment: doc } : {}),
160 + exported: /^export\b/.test(line),
161 + });
162 + if (pattern.kind === "class") inClassAtDepth = braceDepth;
163 + break;
164 + }
165 + }
166 + } else if (braceDepth === 1 && inClassAtDepth === 0) {
167 + const method = TS_METHOD.exec(line);
168 + if (method !== null) {
169 + const name = method[1] as string;
170 + if (!TS_KEYWORD_NOT_METHOD.has(name)) {
171 + const doc = docCommentAbove(lines, i);
172 + symbols.push({
173 + name,
174 + kind: "method",
175 + line: i + 1,
176 + signature: clip(line),
177 + ...(doc !== undefined ? { docComment: doc } : {}),
178 + exported: false,
179 + });
180 + }
181 + }
182 + }
183 +
184 + // Cheap brace tracking, ignoring string/comment contents well enough for indexing.
185 + for (const ch of raw) {
186 + if (ch === "{") braceDepth += 1;
187 + else if (ch === "}") braceDepth = Math.max(0, braceDepth - 1);
188 + }
189 + if (braceDepth === 0) inClassAtDepth = -1;
190 + }
191 + return { symbols, imports };
192 +}
193 +
194 +const PY_DEF = /^(\s*)def\s+([A-Za-z_]\w*)/;
195 +const PY_CLASS = /^(\s*)class\s+([A-Za-z_]\w*)/;
196 +const PY_IMPORT = /^import\s+([\w.]+)/;
197 +const PY_FROM_IMPORT = /^from\s+([\w.]+)\s+import\s+(.+)/;
198 +
199 +function extractPy(lines: readonly string[]): ExtractionResult {
200 + const symbols: ExtractedSymbol[] = [];
201 + const imports: ExtractedImport[] = [];
202 + for (let i = 0; i < lines.length; i += 1) {
203 + const raw = lines[i] as string;
204 + const def = PY_DEF.exec(raw);
205 + if (def !== null) {
206 + const indent = (def[1] as string).length;
207 + const doc = docCommentAbove(lines, i);
208 + symbols.push({
209 + name: def[2] as string,
210 + kind: indent > 0 ? "method" : "function",
211 + line: i + 1,
212 + signature: clip(raw),
213 + ...(doc !== undefined ? { docComment: doc } : {}),
214 + exported: indent === 0 && !(def[2] as string).startsWith("_"),
215 + });
216 + continue;
217 + }
218 + const cls = PY_CLASS.exec(raw);
219 + if (cls !== null) {
220 + const doc = docCommentAbove(lines, i);
221 + symbols.push({
222 + name: cls[2] as string,
223 + kind: "class",
224 + line: i + 1,
225 + signature: clip(raw),
226 + ...(doc !== undefined ? { docComment: doc } : {}),
227 + exported: !(cls[2] as string).startsWith("_"),
228 + });
229 + continue;
230 + }
231 + const from = PY_FROM_IMPORT.exec(raw.trim());
232 + if (from !== null) {
233 + const names = (from[2] as string).split(",").map((n) => n.trim().split(/\s+as\s+/)[0] ?? "");
234 + imports.push({ spec: from[1] as string, names: names.filter((n) => n.length > 0), line: i + 1 });
235 + continue;
236 + }
237 + const imp = PY_IMPORT.exec(raw.trim());
238 + if (imp !== null) {
239 + imports.push({ spec: imp[1] as string, names: ["*"], line: i + 1 });
240 + }
241 + }
242 + return { symbols, imports };
243 +}
244 +
245 +/** Extract symbols and imports from one file's content. */
246 +export function extractFile(path: string, content: string): ExtractionResult {
247 + const lines = content.split("\n");
248 + if (path.endsWith(".py")) return extractPy(lines);
249 + return extractTs(lines);
250 +}
added src/repograph/index.ts +18 −0
@@ -0,0 +1,18 @@
1 +/**
2 + * KHAELOR
3 + * File: src/repograph/index.ts
4 + * Description: Public surface of the RepoGraph semantic-index module (v2 design §3).
5 + *
6 + * Author: Simon-Pierre Boucher
7 + * Contact: contact@spboucher.ai
8 + */
9 +
10 +export { extractFile, isIndexablePath } from "./extractor.js";
11 +export type {
12 + ExtractedImport,
13 + ExtractedSymbol,
14 + ExtractionResult,
15 + SymbolKind,
16 +} from "./extractor.js";
17 +export { RepoGraphService } from "./service.js";
18 +export type { IndexStats, IndexedFile, RefQueryHit, SymbolQueryHit } from "./service.js";
added src/repograph/service.ts +285 −0
@@ -0,0 +1,285 @@
1 +/**
2 + * KHAELOR
3 + * File: src/repograph/service.ts
4 + * Description: RepoGraphService — incremental symbol index, symbols/refs queries, file skeletons for the context engine (v2 design §3).
5 + *
6 + * Author: Simon-Pierre Boucher
7 + * Contact: contact@spboucher.ai
8 + */
9 +
10 +import * as path from "node:path";
11 +import { globToRegExp, walkFiles } from "../workspace/walk.js";
12 +import type { Workspace } from "../workspace/index.js";
13 +import { extractFile, isIndexablePath } from "./extractor.js";
14 +import type { ExtractedImport, ExtractedSymbol, SymbolKind } from "./extractor.js";
15 +
16 +export interface IndexedFile {
17 + path: string; // relative, posix separators
18 + mtimeMs: number;
19 + symbols: ExtractedSymbol[];
20 + imports: ExtractedImport[];
21 +}
22 +
23 +export interface SymbolQueryHit {
24 + symbol: string;
25 + kind: SymbolKind;
26 + file: string;
27 + line: number;
28 + signature: string;
29 + docComment?: string;
30 +}
31 +
32 +export interface RefQueryHit {
33 + file: string;
34 + line: number;
35 + context: string;
36 +}
37 +
38 +export interface IndexStats {
39 + files: number;
40 + symbols: number;
41 + indexedAt: number;
42 + durationMs: number;
43 +}
44 +
45 +const REFRESH_INTERVAL_MS = 5_000;
46 +const MAX_REF_FILES = 4_000;
47 +
48 +function toWildcardRegex(query: string): RegExp {
49 + if (query.includes("*")) {
50 + const escaped = query.split("*").map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
51 + return new RegExp(`^${escaped.join(".*")}$`, "i");
52 + }
53 + return new RegExp(`^${query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`, "i");
54 +}
55 +
56 +/**
57 + * The semantic repository index (v2 §3). Incremental by mtime; queries
58 + * refresh lazily (debounced) so results never go stale by more than the
59 + * refresh interval. Reference queries scan file contents at query time with
60 + * a word-boundary regex over the indexed file set — precise enough for
61 + * blast-radius work without storing every identifier.
62 + */
63 +export class RepoGraphService {
64 + readonly #workspace: Workspace;
65 + readonly #files = new Map<string, IndexedFile>();
66 + #lastRefresh = 0;
67 + #stats: IndexStats = { files: 0, symbols: 0, indexedAt: 0, durationMs: 0 };
68 + #refreshing: Promise<void> | null = null;
69 +
70 + constructor(options: { workspace: Workspace }) {
71 + this.#workspace = options.workspace;
72 + }
73 +
74 + get stats(): IndexStats {
75 + return this.#stats;
76 + }
77 +
78 + /** Walk the repo and (re)index changed files. Serialized; cheap when fresh. */
79 + async ensureIndexed(force = false): Promise<void> {
80 + if (!force && Date.now() - this.#lastRefresh < REFRESH_INTERVAL_MS) return;
81 + if (this.#refreshing !== null) return this.#refreshing;
82 + this.#refreshing = this.#refresh().finally(() => {
83 + this.#refreshing = null;
84 + });
85 + return this.#refreshing;
86 + }
87 +
88 + async #refresh(): Promise<void> {
89 + const startedAt = Date.now();
90 + const cwd = this.#workspace.cwd();
91 + const walked = await walkFiles(cwd, { builtinIgnores: ["node_modules", ".git", "dist", "references"] });
92 + const seen = new Set<string>();
93 + for (const file of walked) {
94 + const rel = path.relative(cwd, file.path).split(path.sep).join("/");
95 + if (!isIndexablePath(rel)) continue;
96 + seen.add(rel);
97 + const existing = this.#files.get(rel);
98 + if (existing !== undefined && existing.mtimeMs === file.mtimeMs) continue;
99 + try {
100 + const content = await this.#workspace.readFile(file.path);
101 + const extracted = extractFile(rel, content);
102 + this.#files.set(rel, {
103 + path: rel,
104 + mtimeMs: file.mtimeMs,
105 + symbols: extracted.symbols,
106 + imports: extracted.imports,
107 + });
108 + } catch {
109 + this.#files.delete(rel); // binary/unreadable — drop from the index
110 + }
111 + }
112 + for (const known of [...this.#files.keys()]) {
113 + if (!seen.has(known)) this.#files.delete(known);
114 + }
115 + this.#lastRefresh = Date.now();
116 + this.#stats = {
117 + files: this.#files.size,
118 + symbols: [...this.#files.values()].reduce((sum, file) => sum + file.symbols.length, 0),
119 + indexedAt: this.#lastRefresh,
120 + durationMs: this.#lastRefresh - startedAt,
121 + };
122 + }
123 +
124 + /** symbols tool backend: wildcard name match, optional kind/scope filters. */
125 + async querySymbols(query: string, kind?: string, scope?: string): Promise<SymbolQueryHit[]> {
126 + await this.ensureIndexed();
127 + // Support "class:*Controller" shorthand.
128 + let effectiveKind = kind;
129 + let effectiveQuery = query.trim();
130 + const colon = /^(function|class|type|export|variable|method):(.+)$/.exec(effectiveQuery);
131 + if (colon !== null) {
132 + effectiveKind = colon[1] as string;
133 + effectiveQuery = (colon[2] as string).trim();
134 + }
135 + const nameRe = toWildcardRegex(effectiveQuery);
136 + const scopeRe = scope !== undefined ? safeGlob(scope) : null;
137 +
138 + const hits: SymbolQueryHit[] = [];
139 + for (const file of this.#files.values()) {
140 + if (scopeRe !== null && !scopeRe.test(file.path)) continue;
141 + for (const symbol of file.symbols) {
142 + if (effectiveKind !== undefined && symbol.kind !== effectiveKind) continue;
143 + if (!nameRe.test(symbol.name)) continue;
144 + hits.push({
145 + symbol: symbol.name,
146 + kind: symbol.kind,
147 + file: file.path,
148 + line: symbol.line,
149 + signature: symbol.signature,
150 + ...(symbol.docComment !== undefined ? { docComment: symbol.docComment } : {}),
151 + });
152 + }
153 + }
154 + hits.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line);
155 + return hits;
156 + }
157 +
158 + /** refs tool backend: callers / callees / importers. */
159 + async queryRefs(
160 + symbol: string,
161 + direction: "callers" | "callees" | "importers",
162 + ): Promise<RefQueryHit[]> {
163 + await this.ensureIndexed();
164 + if (direction === "importers") return this.#importers(symbol);
165 + if (direction === "callees") return this.#callees(symbol);
166 + return this.#callers(symbol);
167 + }
168 +
169 + /** Skeleton of a file: signatures + doc comments — ~5-15% of the tokens of the full file (v2 §3). */
170 + async skeleton(relPath: string): Promise<string | null> {
171 + await this.ensureIndexed();
172 + const file = this.#files.get(relPath.split(path.sep).join("/"));
173 + if (file === undefined || file.symbols.length === 0) return null;
174 + const lines = file.symbols.map((symbol) => {
175 + const doc = symbol.docComment !== undefined ? ` // ${symbol.docComment}` : "";
176 + return `${String(symbol.line).padStart(5)} | ${symbol.signature}${doc}`;
177 + });
178 + return `${file.path} — skeleton (${file.symbols.length} symbols; re-read the file if you need bodies)\n${lines.join("\n")}`;
179 + }
180 +
181 + #definitions(symbol: string): { file: IndexedFile; symbol: ExtractedSymbol }[] {
182 + const out: { file: IndexedFile; symbol: ExtractedSymbol }[] = [];
183 + for (const file of this.#files.values()) {
184 + for (const sym of file.symbols) {
185 + if (sym.name === symbol) out.push({ file, symbol: sym });
186 + }
187 + }
188 + return out;
189 + }
190 +
191 + #importers(symbol: string): RefQueryHit[] {
192 + const hits: RefQueryHit[] = [];
193 + for (const file of this.#files.values()) {
194 + for (const imp of file.imports) {
195 + if (imp.names.includes(symbol)) {
196 + hits.push({ file: file.path, line: imp.line, context: `import { ${symbol} } from "${imp.spec}"` });
197 + }
198 + }
199 + }
200 + return hits;
201 + }
202 +
203 + async #callers(symbol: string): Promise<RefQueryHit[]> {
204 + const definitionFiles = new Set(this.#definitions(symbol).map((d) => d.file.path));
205 + const re = new RegExp(`\\b${symbol.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`);
206 + const hits: RefQueryHit[] = [];
207 + const cwd = this.#workspace.cwd();
208 + let scanned = 0;
209 + for (const file of this.#files.values()) {
210 + if (scanned >= MAX_REF_FILES) break;
211 + scanned += 1;
212 + let content: string;
213 + try {
214 + content = await this.#workspace.readFile(path.join(cwd, file.path));
215 + } catch {
216 + continue;
217 + }
218 + const lines = content.split("\n");
219 + for (let i = 0; i < lines.length; i += 1) {
220 + const line = lines[i] as string;
221 + if (!re.test(line)) continue;
222 + // Skip the definition lines themselves.
223 + if (definitionFiles.has(file.path) && file.symbols.some((s) => s.name === symbol && s.line === i + 1)) {
224 + continue;
225 + }
226 + if (/^\s*import\b/.test(line)) continue; // importers direction covers these
227 + hits.push({ file: file.path, line: i + 1, context: line.trim().slice(0, 200) });
228 + }
229 + }
230 + return hits;
231 + }
232 +
233 + async #callees(symbol: string): Promise<RefQueryHit[]> {
234 + // Heuristic: identifiers referenced inside the defining symbol's region
235 + // (its line to the next top-level symbol) that are known symbols elsewhere.
236 + const definitions = this.#definitions(symbol);
237 + if (definitions.length === 0) return [];
238 + const known = new Map<string, { file: string; line: number }>();
239 + for (const file of this.#files.values()) {
240 + for (const sym of file.symbols) {
241 + if (sym.name !== symbol && (sym.kind === "function" || sym.kind === "class" || sym.kind === "method")) {
242 + if (!known.has(sym.name)) known.set(sym.name, { file: file.path, line: sym.line });
243 + }
244 + }
245 + }
246 + const cwd = this.#workspace.cwd();
247 + const hits: RefQueryHit[] = [];
248 + const seen = new Set<string>();
249 + for (const def of definitions) {
250 + let content: string;
251 + try {
252 + content = await this.#workspace.readFile(path.join(cwd, def.file.path));
253 + } catch {
254 + continue;
255 + }
256 + const lines = content.split("\n");
257 + const sorted = [...def.file.symbols].sort((a, b) => a.line - b.line);
258 + const next = sorted.find((s) => s.line > def.symbol.line && s.kind !== "method");
259 + const end = next !== undefined ? next.line - 1 : lines.length;
260 + for (let i = def.symbol.line; i < end; i += 1) {
261 + const line = lines[i] as string;
262 + for (const match of line.matchAll(/\b([A-Za-z_$][\w$]*)\s*\(/g)) {
263 + const name = match[1] as string;
264 + const target = known.get(name);
265 + if (target === undefined || seen.has(name)) continue;
266 + seen.add(name);
267 + hits.push({
268 + file: target.file,
269 + line: target.line,
270 + context: `${name}(…) called from ${def.file.path}:${i + 1}`,
271 + });
272 + }
273 + }
274 + }
275 + return hits;
276 + }
277 +}
278 +
279 +function safeGlob(pattern: string): RegExp | null {
280 + try {
281 + return globToRegExp(pattern);
282 + } catch {
283 + return null;
284 + }
285 +}
modified src/session/events.ts +142 −4
@@ -1,7 +1,7 @@
1 1 /**
2 2 * KHAELOR
3 3 * File: src/session/events.ts
4 * Description: The complete typed event catalog — envelopes, 38 event types, durable/ephemeral split (EVENT_MODEL.md).
4 + * Description: The complete typed event catalog — envelopes, durable/ephemeral split (EVENT_MODEL.md), including the v2 phase-gate, verify, memory, and subtask events.
5 5 *
6 6 * Author: Simon-Pierre Boucher
7 7 * Contact: contact@spboucher.ai
@@ -80,7 +80,38 @@ export type ModelErrorKind =
80 80 | "invalid-request"
81 81 | "cancelled";
82 82
83 export type ToolName = "read" | "write" | "edit" | "grep" | "glob" | "bash" | "process";
83 +export type ToolName =
84 + | "read"
85 + | "write"
86 + | "edit"
87 + | "grep"
88 + | "glob"
89 + | "bash"
90 + | "process"
91 + | "design"
92 + | "remember"
93 + | "symbols"
94 + | "refs";
95 +
96 +// ─────────────────────── phase gates (durable, v2 §1) ───────────────────────
97 +
98 +export type Phase = "understand" | "design" | "implement";
99 +
100 +/** The structured design the agent must produce before implement is unlocked. */
101 +export interface DesignArtifact {
102 + /** Reformulation of the need by the agent. */
103 + goal: string;
104 + /** Files it plans to modify. */
105 + filesTouched: string[];
106 + /** Technical approach, 5–15 lines. */
107 + approach: string;
108 + /** Identified risks. */
109 + risks: string[];
110 + /** How it will prove the change works. */
111 + verification: string;
112 + /** What it will NOT do. */
113 + outOfScope: string[];
114 +}
84 115
85 116 // ─────────────────────── session lifecycle (durable) ───────────────────────
86 117
@@ -346,6 +377,7 @@ export type ToolFailed = Durable<
346 377 | "exec-error"
347 378 | "timeout"
348 379 | "permission-denied"
380 + | "phase-blocked"
349 381 | "internal";
350 382 durationMs: number;
351 383 }
@@ -465,6 +497,96 @@ export type TaskFailed = Durable<
465 497 }
466 498 >;
467 499
500 +// ─────────────────────── phase gate events (durable, v2 §1) ───────────────────────
501 +
502 +export type PhaseEntered = Durable<
503 + "phase.entered",
504 + {
505 + phase: Phase;
506 + /** What caused the transition — the workflow, an approval, or a user override. */
507 + via: "session-start" | "design-submitted" | "approval" | "user-override";
508 + }
509 +>;
510 +
511 +export type PhaseArtifact = Durable<
512 + "phase.artifact",
513 + {
514 + artifactId: string;
515 + artifact: DesignArtifact;
516 + }
517 +>;
518 +
519 +export type PhaseApproved = Durable<
520 + "phase.approved",
521 + {
522 + phase: Phase;
523 + approvedBy: "user" | "auto-policy" | "user-override";
524 + artifactId?: string;
525 + }
526 +>;
527 +
528 +export type PhaseRejected = Durable<
529 + "phase.rejected",
530 + {
531 + phase: Phase;
532 + reason: string;
533 + artifactId?: string;
534 + }
535 +>;
536 +
537 +// ─────────────────────── native verification (durable, v2 §4) ───────────────────────
538 +
539 +export type VerifyResult = Durable<
540 + "verify.result",
541 + {
542 + /** Check name from verify config: "typecheck", "test", "lint", … */
543 + check: string;
544 + command: string;
545 + ok: boolean;
546 + exitCode: number | null;
547 + /** Intelligently truncated output — errors first. */
548 + output: string;
549 + durationMs: number;
550 + }
551 +>;
552 +
553 +// ─────────────────────── project memory (durable, v2 §5) ───────────────────────
554 +
555 +export type MemoryWritten = Durable<
556 + "memory.written",
557 + {
558 + section: string;
559 + entry: string;
560 + confidence: "high" | "medium" | "low";
561 + toolUseId: string;
562 + }
563 +>;
564 +
565 +// ─────────────────────── parallel subtasks (durable, v2 §6) ───────────────────────
566 +
567 +export type SubtaskCreated = Durable<
568 + "subtask.created",
569 + {
570 + taskId: string;
571 + description: string;
572 + /** Child session id (its own JSONL, meta.parent points here). */
573 + childSessionId: string;
574 + worktreePath: string;
575 + branch: string;
576 + }
577 +>;
578 +
579 +export type SubtaskCompleted = Durable<
580 + "subtask.completed",
581 + {
582 + taskId: string;
583 + outcome: "done" | "failed" | "interrupted";
584 + diffStats: DiffStats;
585 + verifyOk: boolean | null;
586 + detail: string;
587 + }
588 +>;
589 +
468 590 // ───────────────────────────── unions ─────────────────────────────
469 591
470 592 export type DurableEvent =
@@ -499,7 +621,15 @@ export type DurableEvent =
499 621 | ContextCompacted
500 622 | VerificationRequested
501 623 | TaskCompleted
502 | TaskFailed;
624 + | TaskFailed
625 + | PhaseEntered
626 + | PhaseArtifact
627 + | PhaseApproved
628 + | PhaseRejected
629 + | VerifyResult
630 + | MemoryWritten
631 + | SubtaskCreated
632 + | SubtaskCompleted;
503 633
504 634 export type EphemeralEvent =
505 635 | ModelTextDelta
@@ -559,6 +689,14 @@ const DURABLE_TYPE_LIST = [
559 689 "task.verification-requested",
560 690 "task.completed",
561 691 "task.failed",
692 + "phase.entered",
693 + "phase.artifact",
694 + "phase.approved",
695 + "phase.rejected",
696 + "verify.result",
697 + "memory.written",
698 + "subtask.created",
699 + "subtask.completed",
562 700 ] as const satisfies readonly DurableEventType[];
563 701
564 702 const EPHEMERAL_TYPE_LIST = [
@@ -579,7 +717,7 @@ export type _EphemeralListIsExhaustive = AssertNever<
579 717 Exclude<EphemeralEventType, (typeof EPHEMERAL_TYPE_LIST)[number]>
580 718 >;
581 719
582 /** The 32 durable event types (EVENT_MODEL.md §3). */
720 +/** The durable event types (EVENT_MODEL.md §3 + v2 additions). */
583 721 export const DURABLE_EVENT_TYPES: ReadonlySet<DurableEventType> = new Set(DURABLE_TYPE_LIST);
584 722
585 723 /** The 6 ephemeral event types (EVENT_MODEL.md §3). */
added src/session/fork.ts +167 −0
@@ -0,0 +1,167 @@
1 +/**
2 + * KHAELOR
3 + * File: src/session/fork.ts
4 + * Description: Session forking — JSONL prefix copy + meta.json lineage (parent/forkPoint) and checkpoint discovery (v2 design §2).
5 + *
6 + * Author: Simon-Pierre Boucher
7 + * Contact: contact@spboucher.ai
8 + */
9 +
10 +import { mkdir, readFile, writeFile } from "node:fs/promises";
11 +import { join } from "node:path";
12 +import { KhaelorError, ulid } from "../shared/index.js";
13 +import type { DurableEvent } from "./events.js";
14 +
15 +/** Lineage sidecar: <sessionId>.meta.json next to the JSONL (v2 §2). */
16 +export interface SessionMeta {
17 + parent: string | null;
18 + forkPoint: number | null;
19 + /** Set when this session is a /replay of another session. */
20 + replayOf?: string;
21 + createdAt: number;
22 +}
23 +
24 +export function metaFilePath(sessionsDir: string, projectHash: string, sessionId: string): string {
25 + return join(sessionsDir, projectHash, `${sessionId}.meta.json`);
26 +}
27 +
28 +export async function readSessionMeta(
29 + sessionsDir: string,
30 + projectHash: string,
31 + sessionId: string,
32 +): Promise<SessionMeta | null> {
33 + try {
34 + const raw = JSON.parse(
35 + await readFile(metaFilePath(sessionsDir, projectHash, sessionId), "utf8"),
36 + ) as Record<string, unknown>;
37 + return {
38 + parent: typeof raw["parent"] === "string" ? raw["parent"] : null,
39 + forkPoint: typeof raw["forkPoint"] === "number" ? raw["forkPoint"] : null,
40 + ...(typeof raw["replayOf"] === "string" ? { replayOf: raw["replayOf"] } : {}),
41 + createdAt: typeof raw["createdAt"] === "number" ? raw["createdAt"] : 0,
42 + };
43 + } catch {
44 + return null;
45 + }
46 +}
47 +
48 +export async function writeSessionMeta(
49 + sessionsDir: string,
50 + projectHash: string,
51 + sessionId: string,
52 + meta: SessionMeta,
53 +): Promise<void> {
54 + const path = metaFilePath(sessionsDir, projectHash, sessionId);
55 + await mkdir(join(sessionsDir, projectHash), { recursive: true });
56 + await writeFile(path, `${JSON.stringify(meta, null, 2)}\n`, "utf8");
57 +}
58 +
59 +// ───────────────────────── checkpoints ─────────────────────────
60 +
61 +export interface ForkCheckpoint {
62 + seq: number;
63 + kind: "user-turn" | "design-approved" | "compaction";
64 + label: string;
65 +}
66 +
67 +/**
68 + * The natural fork points already present in the log: every user turn, every
69 + * approved design, every structured checkpoint (v2 §2).
70 + */
71 +export function listForkCheckpoints(events: readonly DurableEvent[]): ForkCheckpoint[] {
72 + const checkpoints: ForkCheckpoint[] = [];
73 + for (const event of events) {
74 + if (event.type === "user.message-created") {
75 + const preview = event.payload.text.replace(/\s+/g, " ").slice(0, 48);
76 + checkpoints.push({ seq: event.seq, kind: "user-turn", label: `❯ ${preview}` });
77 + } else if (event.type === "phase.approved" && event.payload.phase === "design") {
78 + checkpoints.push({ seq: event.seq, kind: "design-approved", label: "✓ design approved" });
79 + } else if (event.type === "context.compacted") {
80 + checkpoints.push({ seq: event.seq, kind: "compaction", label: "⊟ context checkpoint" });
81 + }
82 + }
83 + return checkpoints;
84 +}
85 +
86 +// ───────────────────────── fork ─────────────────────────
87 +
88 +export interface ForkOptions {
89 + sessionsDir: string;
90 + projectHash: string;
91 + sourceSessionId: string;
92 + /** Copy events up to and including this seq. Omitted → the whole log. */
93 + uptoSeq?: number;
94 + newSessionId?: string;
95 +}
96 +
97 +export interface ForkResult {
98 + sessionId: string;
99 + filePath: string;
100 + copiedEvents: number;
101 + forkPoint: number;
102 +}
103 +
104 +/**
105 + * Fork = copy the JSONL prefix into a fresh session file and record lineage.
106 + * Envelope sessionIds are rewritten so the child log is self-consistent;
107 + * seq/ids/payloads are byte-preserved otherwise. Resume of the fork replays
108 + * the prefix exactly (v2 §2 — "resume = replay du JSONL, donc fork est
109 + * presque gratuit").
110 + */
111 +export async function forkSession(options: ForkOptions): Promise<ForkResult> {
112 + const dir = join(options.sessionsDir, options.projectHash);
113 + const sourcePath = join(dir, `${options.sourceSessionId}.jsonl`);
114 + let content: string;
115 + try {
116 + content = await readFile(sourcePath, "utf8");
117 + } catch (error) {
118 + throw new KhaelorError("session-log-io", `Cannot read session log: ${sourcePath}`, {
119 + cause: String(error),
120 + });
121 + }
122 +
123 + const newSessionId = options.newSessionId ?? ulid();
124 + const outLines: string[] = [];
125 + let lastSeq = 0;
126 + for (const line of content.split("\n")) {
127 + if (line.length === 0) continue;
128 + let parsed: Record<string, unknown>;
129 + try {
130 + parsed = JSON.parse(line) as Record<string, unknown>;
131 + } catch {
132 + break; // torn tail — the prefix up to here is still a valid fork base
133 + }
134 + const seq = parsed["seq"];
135 + if (typeof seq !== "number") break;
136 + if (options.uptoSeq !== undefined && seq > options.uptoSeq) break;
137 + parsed["sessionId"] = newSessionId;
138 + outLines.push(JSON.stringify(parsed));
139 + lastSeq = seq;
140 + }
141 +
142 + if (outLines.length === 0) {
143 + throw new KhaelorError(
144 + "invalid-event",
145 + `Fork of ${options.sourceSessionId} at seq ${options.uptoSeq ?? 0} would be empty.`,
146 + );
147 + }
148 +
149 + const filePath = join(dir, `${newSessionId}.jsonl`);
150 + await mkdir(dir, { recursive: true });
151 + await writeFile(filePath, `${outLines.join("\n")}\n`, { flag: "wx", encoding: "utf8" });
152 + await writeSessionMeta(options.sessionsDir, options.projectHash, newSessionId, {
153 + parent: options.sourceSessionId,
154 + forkPoint: lastSeq,
155 + createdAt: Date.now(),
156 + });
157 + return { sessionId: newSessionId, filePath, copiedEvents: outLines.length, forkPoint: lastSeq };
158 +}
159 +
160 +/** The user turns of a session, in order — the /replay input (v2 §2). */
161 +export function extractUserTurns(events: readonly DurableEvent[]): string[] {
162 + const turns: string[] = [];
163 + for (const event of events) {
164 + if (event.type === "user.message-created") turns.push(event.payload.text);
165 + }
166 + return turns;
167 +}
modified src/session/index.ts +23 −0
@@ -65,6 +65,16 @@ export type {
65 65 VerificationRequested,
66 66 TaskCompleted,
67 67 TaskFailed,
68 + Phase,
69 + DesignArtifact,
70 + PhaseEntered,
71 + PhaseArtifact,
72 + PhaseApproved,
73 + PhaseRejected,
74 + VerifyResult,
75 + MemoryWritten,
76 + SubtaskCreated,
77 + SubtaskCompleted,
68 78 DurableEvent,
69 79 EphemeralEvent,
70 80 KhaelorEvent,
@@ -85,6 +95,19 @@ export type {
85 95 } from "./bus.js";
86 96
87 97 export { SessionLog, defaultSessionsDir, canonicalizeJsonValue } from "./store.js";
98 +
99 +export {
100 + extractUserTurns,
101 + forkSession,
102 + listForkCheckpoints,
103 + metaFilePath,
104 + readSessionMeta,
105 + writeSessionMeta,
106 +} from "./fork.js";
107 +export type { ForkCheckpoint, ForkOptions, ForkResult, SessionMeta } from "./fork.js";
108 +
109 +export { renderSessionDiff, summarizeSessionRun } from "./sdiff.js";
110 +export type { SessionRunSummary } from "./sdiff.js";
88 111 export type {
89 112 SessionLogCreateOptions,
90 113 SessionLogOpenOptions,
modified src/session/projections.ts +23 −0
@@ -272,6 +272,29 @@ export function buildConversation(events: readonly DurableEvent[]): Conversation
272 272 );
273 273 break;
274 274 }
275 + case "verify.result": {
276 + // Failing native checks enter the conversation as user-visible input;
277 + // passing checks stay out of context (evidence only, v2 §4).
278 + if (event.payload.ok) break;
279 + flushAssistant();
280 + closeToolResults();
281 + const exit = event.payload.exitCode === null ? "timeout/kill" : `exit ${event.payload.exitCode}`;
282 + entries.push({
283 + role: "user",
284 + blocks: [
285 + {
286 + type: "text",
287 + text:
288 + `[verify] check "${event.payload.check}" failed (${exit}): ${event.payload.command}\n` +
289 + event.payload.output,
290 + },
291 + ],
292 + minSeq: event.seq,
293 + maxSeq: event.seq,
294 + openToolResults: false,
295 + });
296 + break;
297 + }
275 298 case "context.pruned": {
276 299 flushAssistant();
277 300 applyPrune(event.payload.toolUseIds, event.payload.placeholder);
added src/session/sdiff.ts +115 −0
@@ -0,0 +1,115 @@
1 +/**
2 + * KHAELOR
3 + * File: src/session/sdiff.ts
4 + * Description: Structured diff between two session runs — turns, tool calls, files, tokens (v2 design §2).
5 + *
6 + * Author: Simon-Pierre Boucher
7 + * Contact: contact@spboucher.ai
8 + */
9 +
10 +import type { DurableEvent } from "./events.js";
11 +import { buildFileChangeSet, buildUsageTotals } from "./projections.js";
12 +
13 +export interface SessionRunSummary {
14 + sessionId: string;
15 + models: string[];
16 + agentTurns: number;
17 + toolCalls: number;
18 + filesModified: string[];
19 + inputTokens: number;
20 + outputTokens: number;
21 + cacheReadTokens: number;
22 + verifyFailures: number;
23 + outcome: "completed" | "failed" | "open";
24 +}
25 +
26 +/** Fold one session's durable stream into the /sdiff row (real usage only — Rule #4). */
27 +export function summarizeSessionRun(
28 + sessionId: string,
29 + events: readonly DurableEvent[],
30 +): SessionRunSummary {
31 + const usage = buildUsageTotals(events);
32 + const files = buildFileChangeSet(events);
33 + const models = new Set<string>();
34 + let agentTurns = 0;
35 + let toolCalls = 0;
36 + let verifyFailures = 0;
37 + let outcome: SessionRunSummary["outcome"] = "open";
38 + for (const event of events) {
39 + switch (event.type) {
40 + case "model.request-started":
41 + models.add(event.payload.model);
42 + break;
43 + case "model.response-completed":
44 + agentTurns += 1;
45 + break;
46 + case "tool.requested":
47 + toolCalls += 1;
48 + break;
49 + case "verify.result":
50 + if (!event.payload.ok) verifyFailures += 1;
51 + break;
52 + case "task.completed":
53 + outcome = "completed";
54 + break;
55 + case "task.failed":
56 + outcome = "failed";
57 + break;
58 + default:
59 + break;
60 + }
61 + }
62 + return {
63 + sessionId,
64 + models: [...models],
65 + agentTurns,
66 + toolCalls,
67 + filesModified: [...files.changes.keys()].sort(),
68 + inputTokens: usage.totals.inputTokens,
69 + outputTokens: usage.totals.outputTokens,
70 + cacheReadTokens: usage.totals.cacheReadTokens,
71 + verifyFailures,
72 + outcome,
73 + };
74 +}
75 +
76 +function formatTokens(n: number): string {
77 + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
78 + if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`;
79 + return String(n);
80 +}
81 +
82 +function row(label: string, a: string, b: string, widths: [number, number, number]): string {
83 + return ` ${label.padEnd(widths[0])} ${a.padEnd(widths[1])} ${b.padEnd(widths[2])}`.trimEnd();
84 +}
85 +
86 +/** Render the /sdiff table: two runs side by side (v2 §2 — "run it twice, diff the runs"). */
87 +export function renderSessionDiff(a: SessionRunSummary, b: SessionRunSummary): string[] {
88 + const widths: [number, number, number] = [18, 28, 28];
89 + const shortId = (id: string): string => id.slice(0, 12);
90 + const filesA = a.filesModified;
91 + const filesB = b.filesModified;
92 + const onlyA = filesA.filter((f) => !filesB.includes(f));
93 + const onlyB = filesB.filter((f) => !filesA.includes(f));
94 +
95 + const lines = [
96 + row("", `${shortId(a.sessionId)} (${a.models.join(",") || "—"})`, `${shortId(b.sessionId)} (${b.models.join(",") || "—"})`, widths),
97 + row("agent turns", String(a.agentTurns), String(b.agentTurns), widths),
98 + row("tool calls", String(a.toolCalls), String(b.toolCalls), widths),
99 + row("files modified", String(filesA.length), String(filesB.length), widths),
100 + row(
101 + "tokens (in/out)",
102 + `${formatTokens(a.inputTokens)} / ${formatTokens(a.outputTokens)}`,
103 + `${formatTokens(b.inputTokens)} / ${formatTokens(b.outputTokens)}`,
104 + widths,
105 + ),
106 + row("cache reads", formatTokens(a.cacheReadTokens), formatTokens(b.cacheReadTokens), widths),
107 + row("verify failures", String(a.verifyFailures), String(b.verifyFailures), widths),
108 + row("outcome", a.outcome, b.outcome, widths),
109 + ];
110 + if (onlyA.length > 0) lines.push(` files only in A: ${onlyA.join(", ")}`);
111 + if (onlyB.length > 0) lines.push(` files only in B: ${onlyB.join(", ")}`);
112 + const common = filesA.filter((f) => filesB.includes(f));
113 + if (common.length > 0) lines.push(` files in both: ${common.join(", ")}`);
114 + return lines;
115 +}
added src/tasks/index.ts +26 −0
@@ -0,0 +1,26 @@
1 +/**
2 + * KHAELOR
3 + * File: src/tasks/index.ts
4 + * Description: Public surface of the parallel-subtask module (v2 design §6).
5 + *
6 + * Author: Simon-Pierre Boucher
7 + * Contact: contact@spboucher.ai
8 + */
9 +
10 +export {
11 + WORKTREES_DIR,
12 + createWorktree,
13 + mergeSubtaskBranch,
14 + removeWorktree,
15 + worktreeBranch,
16 + worktreeDiffStats,
17 +} from "./worktree.js";
18 +export type { ExecFn, MergeResult, WorktreeDiff, WorktreeInfo } from "./worktree.js";
19 +export { SubtaskManager } from "./manager.js";
20 +export type {
21 + ChildRunOutcome,
22 + ChildRunner,
23 + SubtaskManagerOptions,
24 + SubtaskRecord,
25 + SubtaskStatus,
26 +} from "./manager.js";
added src/tasks/manager.ts +169 −0
@@ -0,0 +1,169 @@
1 +/**
2 + * KHAELOR
3 + * File: src/tasks/manager.ts
4 + * Description: SubtaskManager — spawn isolated child runs in worktrees, supervise, record subtask events (v2 design §6).
5 + *
6 + * Author: Simon-Pierre Boucher
7 + * Contact: contact@spboucher.ai
8 + */
9 +
10 +import type { DurableEventInput } from "../session/index.js";
11 +import { ulid } from "../shared/index.js";
12 +import { createWorktree, worktreeDiffStats } from "./worktree.js";
13 +import type { ExecFn, WorktreeInfo } from "./worktree.js";
14 +
15 +export type SubtaskStatus = "running" | "done" | "failed" | "interrupted";
16 +
17 +export interface SubtaskRecord {
18 + taskId: string;
19 + description: string;
20 + childSessionId: string;
21 + worktree: WorktreeInfo;
22 + status: SubtaskStatus;
23 + startedAt: number;
24 + finishedAt: number | null;
25 + diff: { added: number; removed: number; files: string[] };
26 + verifyOk: boolean | null;
27 + detail: string;
28 +}
29 +
30 +/** Outcome the injected child runner reports back. */
31 +export interface ChildRunOutcome {
32 + status: Exclude<SubtaskStatus, "running">;
33 + verifyOk: boolean | null;
34 + detail: string;
35 +}
36 +
37 +/**
38 + * The child runner seam: the CLI layer assembles a full child engine over the
39 + * worktree cwd — child session (own JSONL, meta.parent → orchestrator),
40 + * attenuated permissions (non-interactive: asks resolve deny), same kernel.
41 + */
42 +export type ChildRunner = (args: {
43 + taskId: string;
44 + childSessionId: string;
45 + worktreePath: string;
46 + description: string;
47 +}) => Promise<ChildRunOutcome>;
48 +
49 +export interface SubtaskManagerOptions {
50 + /** Orchestrator session sink for subtask.* durable events. */
51 + publish: (event: DurableEventInput) => void;
52 + exec: ExecFn;
53 + projectRoot: string;
54 + runChild: ChildRunner;
55 + newId?: () => string;
56 +}
57 +
58 +/**
59 + * Orchestrates parallel subtasks (v2 §6): each gets a git worktree + branch,
60 + * a child session, and attenuated capabilities. Completion publishes a
61 + * durable `subtask.completed` with real diff stats — never fabricated.
62 + */
63 +export class SubtaskManager {
64 + readonly #publish: (event: DurableEventInput) => void;
65 + readonly #exec: ExecFn;
66 + readonly #projectRoot: string;
67 + readonly #runChild: ChildRunner;
68 + readonly #newId: () => string;
69 + readonly #tasks = new Map<string, SubtaskRecord>();
70 + readonly #running = new Map<string, Promise<void>>();
71 +
72 + constructor(options: SubtaskManagerOptions) {
73 + this.#publish = options.publish;
74 + this.#exec = options.exec;
75 + this.#projectRoot = options.projectRoot;
76 + this.#runChild = options.runChild;
77 + this.#newId = options.newId ?? ulid;
78 + }
79 +
80 + list(): SubtaskRecord[] {
81 + return [...this.#tasks.values()].sort((a, b) => a.startedAt - b.startedAt);
82 + }
83 +
84 + get(taskId: string): SubtaskRecord | undefined {
85 + return this.#tasks.get(taskId);
86 + }
87 +
88 + /** Create the worktree, record subtask.created, and launch the child run. */
89 + async spawn(description: string): Promise<SubtaskRecord> {
90 + const taskId = this.#newId().slice(0, 10).toLowerCase();
91 + const childSessionId = this.#newId();
92 + const worktree = await createWorktree(this.#exec, this.#projectRoot, taskId);
93 + const record: SubtaskRecord = {
94 + taskId,
95 + description,
96 + childSessionId,
97 + worktree,
98 + status: "running",
99 + startedAt: Date.now(),
100 + finishedAt: null,
101 + diff: { added: 0, removed: 0, files: [] },
102 + verifyOk: null,
103 + detail: "",
104 + };
105 + this.#tasks.set(taskId, record);
106 + this.#publish({
107 + type: "subtask.created",
108 + payload: {
109 + taskId,
110 + description,
111 + childSessionId,
112 + worktreePath: worktree.path,
113 + branch: worktree.branch,
114 + },
115 + });
116 +
117 + const run = this.#supervise(record);
118 + this.#running.set(taskId, run);
119 + return record;
120 + }
121 +
122 + /** Await every running subtask (used by --parallel batch mode and shutdown). */
123 + async waitAll(): Promise<void> {
124 + await Promise.all([...this.#running.values()]);
125 + }
126 +
127 + async #supervise(record: SubtaskRecord): Promise<void> {
128 + let outcome: ChildRunOutcome;
129 + try {
130 + outcome = await this.#runChild({
131 + taskId: record.taskId,
132 + childSessionId: record.childSessionId,
133 + worktreePath: record.worktree.path,
134 + description: record.description,
135 + });
136 + } catch (error) {
137 + outcome = {
138 + status: "failed",
139 + verifyOk: null,
140 + detail: error instanceof Error ? error.message : String(error),
141 + };
142 + }
143 + // Commit the worktree changes onto the subtask branch so diff/merge see them.
144 + await this.#exec("git add -A", record.worktree.path);
145 + await this.#exec(
146 + `git commit -m ${JSON.stringify(`khaelor subtask ${record.taskId}: ${record.description.slice(0, 60)}`)} --no-verify`,
147 + record.worktree.path,
148 + );
149 + const diff = await worktreeDiffStats(this.#exec, this.#projectRoot, record.worktree.branch);
150 +
151 + record.status = outcome.status;
152 + record.finishedAt = Date.now();
153 + record.diff = diff;
154 + record.verifyOk = outcome.verifyOk;
155 + record.detail = outcome.detail;
156 + this.#running.delete(record.taskId);
157 +
158 + this.#publish({
159 + type: "subtask.completed",
160 + payload: {
161 + taskId: record.taskId,
162 + outcome: outcome.status,
163 + diffStats: { added: diff.added, removed: diff.removed },
164 + verifyOk: outcome.verifyOk,
165 + detail: outcome.detail.slice(0, 2000),
166 + },
167 + });
168 + }
169 +}
added src/tasks/worktree.ts +122 −0
@@ -0,0 +1,122 @@
1 +/**
2 + * KHAELOR
3 + * File: src/tasks/worktree.ts
4 + * Description: Git worktree isolation for parallel subtasks — create, inspect, merge, remove (v2 design §6).
5 + *
6 + * Author: Simon-Pierre Boucher
7 + * Contact: contact@spboucher.ai
8 + */
9 +
10 +import { join } from "node:path";
11 +import { KhaelorError } from "../shared/index.js";
12 +
13 +/** Minimal exec seam — satisfied by an adapter over Workspace.exec. */
14 +export type ExecFn = (
15 + cmd: string,
16 + cwd?: string,
17 +) => Promise<{ exitCode: number | null; stdout: string; stderr: string }>;
18 +
19 +export interface WorktreeInfo {
20 + taskId: string;
21 + path: string;
22 + branch: string;
23 +}
24 +
25 +/** Where subtask worktrees live, relative to the project root (v2 §6). */
26 +export const WORKTREES_DIR = ".khaelor/worktrees";
27 +
28 +export function worktreeBranch(taskId: string): string {
29 + return `khaelor/${taskId}`;
30 +}
31 +
32 +/** `git worktree add .khaelor/worktrees/<taskId> -b khaelor/<taskId>` */
33 +export async function createWorktree(
34 + exec: ExecFn,
35 + projectRoot: string,
36 + taskId: string,
37 +): Promise<WorktreeInfo> {
38 + const path = join(projectRoot, WORKTREES_DIR, taskId);
39 + const branch = worktreeBranch(taskId);
40 + const result = await exec(
41 + `git worktree add ${JSON.stringify(path)} -b ${JSON.stringify(branch)}`,
42 + projectRoot,
43 + );
44 + if (result.exitCode !== 0) {
45 + throw new KhaelorError("internal", `git worktree add failed: ${result.stderr.trim()}`, {
46 + taskId,
47 + });
48 + }
49 + return { taskId, path, branch };
50 +}
51 +
52 +/** Remove a worktree and its branch (used after merge or abandon). */
53 +export async function removeWorktree(
54 + exec: ExecFn,
55 + projectRoot: string,
56 + info: WorktreeInfo,
57 + options: { deleteBranch?: boolean } = {},
58 +): Promise<void> {
59 + await exec(`git worktree remove --force ${JSON.stringify(info.path)}`, projectRoot);
60 + if (options.deleteBranch === true) {
61 + await exec(`git branch -D ${JSON.stringify(info.branch)}`, projectRoot);
62 + }
63 +}
64 +
65 +export interface WorktreeDiff {
66 + added: number;
67 + removed: number;
68 + files: string[];
69 +}
70 +
71 +/** Diff stats of a subtask branch against the fork point (merge-base with HEAD). */
72 +export async function worktreeDiffStats(
73 + exec: ExecFn,
74 + projectRoot: string,
75 + branch: string,
76 +): Promise<WorktreeDiff> {
77 + const base = await exec(`git merge-base HEAD ${JSON.stringify(branch)}`, projectRoot);
78 + const baseRef = base.exitCode === 0 ? base.stdout.trim() : "HEAD";
79 + const numstat = await exec(
80 + `git diff --numstat ${JSON.stringify(baseRef)} ${JSON.stringify(branch)}`,
81 + projectRoot,
82 + );
83 + const diff: WorktreeDiff = { added: 0, removed: 0, files: [] };
84 + if (numstat.exitCode !== 0) return diff;
85 + for (const line of numstat.stdout.split("\n")) {
86 + const parts = line.split("\t");
87 + if (parts.length < 3) continue;
88 + const added = Number.parseInt(parts[0] as string, 10);
89 + const removed = Number.parseInt(parts[1] as string, 10);
90 + if (!Number.isNaN(added)) diff.added += added;
91 + if (!Number.isNaN(removed)) diff.removed += removed;
92 + diff.files.push(parts[2] as string);
93 + }
94 + return diff;
95 +}
96 +
97 +export interface MergeResult {
98 + ok: boolean;
99 + conflict: boolean;
100 + detail: string;
101 +}
102 +
103 +/** Supervised merge: `git merge --no-ff` of the subtask branch into the current branch (v2 §6). */
104 +export async function mergeSubtaskBranch(
105 + exec: ExecFn,
106 + projectRoot: string,
107 + branch: string,
108 + message: string,
109 +): Promise<MergeResult> {
110 + const result = await exec(
111 + `git merge --no-ff -m ${JSON.stringify(message)} ${JSON.stringify(branch)}`,
112 + projectRoot,
113 + );
114 + if (result.exitCode === 0) return { ok: true, conflict: false, detail: result.stdout.trim() };
115 + const conflict = /conflict/i.test(result.stdout + result.stderr);
116 + if (conflict) await exec("git merge --abort", projectRoot);
117 + return {
118 + ok: false,
119 + conflict,
120 + detail: (result.stderr || result.stdout).trim().slice(0, 2000),
121 + };
122 +}
added src/tools/design.ts +138 −0
@@ -0,0 +1,138 @@
1 +/**
2 + * KHAELOR
3 + * File: src/tools/design.ts
4 + * Description: The design tool — submit a DesignArtifact through the phase gate to unlock implement (v2 design §1).
5 + *
6 + * Author: Simon-Pierre Boucher
7 + * Contact: contact@spboucher.ai
8 + */
9 +
10 +import type { ToolDefinition } from "./registry.js";
11 +import type { ToolContext, ToolDesignArtifact, ToolResult } from "./types.js";
12 +
13 +export interface DesignInput {
14 + goal: string;
15 + approach: string;
16 + files: string;
17 + risks?: string;
18 + verification: string;
19 +}
20 +
21 +const DESCRIPTION =
22 + "Submit your design before implementing. KHAELOR works in three phases: understand → design → " +
23 + "implement. Write tools stay locked until a design is approved. Provide: goal (restate the need in " +
24 + "your own words), approach (the technical plan, 5-15 lines), files (one file path per line you plan " +
25 + 'to modify), risks (one per line; prefix a line with "out of scope:" to declare a non-goal), and ' +
26 + "verification (how you will prove it works). Small designs are auto-approved; larger ones may need " +
27 + "user approval.";
28 +
29 +const OUT_OF_SCOPE_PREFIX = /^out[ -]of[ -]scope:\s*/i;
30 +
31 +function splitLines(value: string | undefined): string[] {
32 + if (value === undefined) return [];
33 + return value
34 + .split("\n")
35 + .map((line) => line.trim())
36 + .filter((line) => line.length > 0);
37 +}
38 +
39 +/** Parse the flat tool input into the structured artifact (risks vs out-of-scope). */
40 +export function parseDesignInput(input: DesignInput): ToolDesignArtifact {
41 + const riskLines = splitLines(input.risks);
42 + const risks: string[] = [];
43 + const outOfScope: string[] = [];
44 + for (const line of riskLines) {
45 + const match = OUT_OF_SCOPE_PREFIX.exec(line);
46 + if (match !== null) outOfScope.push(line.slice(match[0].length));
47 + else risks.push(line);
48 + }
49 + return {
50 + goal: input.goal.trim(),
51 + approach: input.approach.trim(),
52 + filesTouched: splitLines(input.files),
53 + risks,
54 + verification: input.verification.trim(),
55 + outOfScope,
56 + };
57 +}
58 +
59 +async function executeDesign(input: DesignInput, ctx: ToolContext): Promise<ToolResult> {
60 + const startedAt = Date.now();
61 + const artifact = parseDesignInput(input);
62 +
63 + if (ctx.phases === undefined || ctx.phases.mode === "off") {
64 + return {
65 + content:
66 + "Design recorded (phase gates are off in this session — implementation was already unlocked).",
67 + metadata: { title: "Design recorded (gates off)", durationMs: Date.now() - startedAt },
68 + };
69 + }
70 +
71 + if (artifact.filesTouched.length === 0) {
72 + return {
73 + content:
74 + 'The "files" parameter must list at least one file you plan to modify (one path per line). ' +
75 + "If the task changes no files, say so in your answer instead of designing.",
76 + isError: true,
77 + metadata: { title: "Design rejected · no files", durationMs: Date.now() - startedAt },
78 + };
79 + }
80 +
81 + const decision = await ctx.phases.submitDesign(artifact);
82 + const fileCount = artifact.filesTouched.length;
83 +
84 + switch (decision.status) {
85 + case "approved":
86 + return {
87 + content:
88 + `Design approved (${fileCount} file${fileCount === 1 ? "" : "s"}). ` +
89 + "You are now in the implement phase — write, edit, and full bash are unlocked. " +
90 + "Follow your design; verify as planned before claiming completion.",
91 + metadata: {
92 + title: `Design approved · ${fileCount} file${fileCount === 1 ? "" : "s"}`,
93 + durationMs: Date.now() - startedAt,
94 + extra: { artifactId: decision.artifactId },
95 + },
96 + };
97 + case "rejected":
98 + return {
99 + content:
100 + `Design rejected: ${decision.reason ?? "no reason given"}. ` +
101 + "Revise the design based on this feedback and submit again, or ask the user for direction.",
102 + isError: true,
103 + metadata: { title: "Design rejected", durationMs: Date.now() - startedAt },
104 + };
105 + case "pending":
106 + return {
107 + content:
108 + `Design submitted but not yet approved: ${decision.reason ?? "awaiting approval"}. ` +
109 + "Tell the user what you plan to do and wait for their approval before implementing.",
110 + metadata: { title: "Design pending approval", durationMs: Date.now() - startedAt },
111 + };
112 + }
113 +}
114 +
115 +/** Build the design tool (phase gates, v2 §1). */
116 +export function createDesignTool(): ToolDefinition<DesignInput> {
117 + return {
118 + name: "design",
119 + description: DESCRIPTION,
120 + inputSchema: {
121 + type: "object",
122 + properties: {
123 + goal: { type: "string", description: "The need, restated in your own words." },
124 + approach: { type: "string", description: "Technical approach, 5-15 lines." },
125 + files: { type: "string", description: "Files you plan to modify, one path per line." },
126 + risks: {
127 + type: "string",
128 + description:
129 + 'Identified risks, one per line. Prefix with "out of scope:" for explicit non-goals.',
130 + },
131 + verification: { type: "string", description: "How you will prove the change works." },
132 + },
133 + required: ["goal", "approach", "files", "verification"],
134 + },
135 + capability: "file.read",
136 + execute: executeDesign,
137 + };
138 +}
modified src/tools/index.ts +27 −1
@@ -41,8 +41,24 @@ export type { BashInput } from "./bash.js";
41 41 export { createBashTool } from "./bash.js";
42 42 export type { ProcessAction, ProcessInput } from "./process.js";
43 43 export { createProcessTool } from "./process.js";
44 +export type { DesignInput } from "./design.js";
45 +export { createDesignTool, parseDesignInput } from "./design.js";
46 +export type { RememberInput } from "./remember.js";
47 +export { createRememberTool } from "./remember.js";
48 +export type { SymbolsInput } from "./symbols.js";
49 +export { createSymbolsTool } from "./symbols.js";
50 +export type { RefsInput } from "./refs.js";
51 +export { createRefsTool } from "./refs.js";
52 +export type {
53 + PhaseToolFacet,
54 + RefHit,
55 + RepoGraphFacet,
56 + SymbolHit,
57 + ToolDesignArtifact,
58 +} from "./types.js";
44 59
45 60 import { createBashTool } from "./bash.js";
61 +import { createDesignTool } from "./design.js";
46 62 import { createEditTool } from "./edit.js";
47 63 import { createGlobTool } from "./glob.js";
48 64 import type { GrepToolOptions } from "./grep.js";
@@ -50,9 +66,15 @@ import { createGrepTool } from "./grep.js";
50 66 import { createProcessTool } from "./process.js";
51 67 import { createReadTool } from "./read.js";
52 68 import { ToolRegistry } from "./registry.js";
69 +import { createRefsTool } from "./refs.js";
70 +import { createRememberTool } from "./remember.js";
71 +import { createSymbolsTool } from "./symbols.js";
53 72 import { createWriteTool } from "./write.js";
54 73
55 /** Build the V1 registry with all seven tools (ADR-8). */
74 +/**
75 + * Build the default registry: the seven V1 tools (ADR-8) plus the v2 tools —
76 + * design (phase gates), remember (project memory), symbols/refs (RepoGraph).
77 + */
56 78 export function createDefaultToolRegistry(options: { grep?: GrepToolOptions } = {}): ToolRegistry {
57 79 const registry = new ToolRegistry();
58 80 registry.register(createReadTool());
@@ -62,5 +84,9 @@ export function createDefaultToolRegistry(options: { grep?: GrepToolOptions } =
62 84 registry.register(createGlobTool());
63 85 registry.register(createBashTool());
64 86 registry.register(createProcessTool());
87 + registry.register(createDesignTool());
88 + registry.register(createRememberTool());
89 + registry.register(createSymbolsTool());
90 + registry.register(createRefsTool());
65 91 return registry;
66 92 }
added src/tools/refs.ts +91 −0
@@ -0,0 +1,91 @@
1 +/**
2 + * KHAELOR
3 + * File: src/tools/refs.ts
4 + * Description: The refs tool — who uses what: callers/callees/importers of a symbol via the RepoGraph index (v2 design §3).
5 + *
6 + * Author: Simon-Pierre Boucher
7 + * Contact: contact@spboucher.ai
8 + */
9 +
10 +import type { ToolDefinition } from "./registry.js";
11 +import type { ToolContext, ToolResult } from "./types.js";
12 +
13 +export interface RefsInput {
14 + symbol: string;
15 + direction: string;
16 +}
17 +
18 +const DESCRIPTION =
19 + "Find usage sites of a symbol via the semantic index. direction: callers (who calls/uses it), " +
20 + "callees (what it calls), importers (which files import it). Each site comes with its line of " +
21 + "context. Use this to assess blast radius before editing a symbol.";
22 +
23 +const MAX_RESULTS = 80;
24 +
25 +async function executeRefs(input: RefsInput, ctx: ToolContext): Promise<ToolResult> {
26 + const startedAt = Date.now();
27 + if (ctx.repograph === undefined) {
28 + return {
29 + content:
30 + "The semantic index is unavailable in this session. Use grep to find usages instead.",
31 + isError: true,
32 + metadata: { title: "refs · index unavailable", durationMs: Date.now() - startedAt },
33 + };
34 + }
35 + const direction =
36 + input.direction === "callers" || input.direction === "callees" || input.direction === "importers"
37 + ? input.direction
38 + : null;
39 + if (direction === null) {
40 + return {
41 + content: 'Parameter "direction" must be one of: callers, callees, importers.',
42 + isError: true,
43 + metadata: { title: "refs · invalid direction", durationMs: Date.now() - startedAt },
44 + };
45 + }
46 + const hits = await ctx.repograph.queryRefs(input.symbol, direction);
47 + if (hits.length === 0) {
48 + return {
49 + content: `No ${direction} found for "${input.symbol}". The symbol may be unused, dynamic, or misspelled.`,
50 + metadata: {
51 + title: `Refs ${input.symbol} · 0`,
52 + durationMs: Date.now() - startedAt,
53 + matches: 0,
54 + },
55 + };
56 + }
57 + const shown = hits.slice(0, MAX_RESULTS);
58 + const lines = shown.map((hit) => `${hit.file}:${hit.line} ${hit.context}`);
59 + const omitted = hits.length - shown.length;
60 + const tail = omitted > 0 ? `\n[... ${omitted} more sites omitted]` : "";
61 + return {
62 + content: lines.join("\n") + tail,
63 + metadata: {
64 + title: `Refs ${input.symbol} · ${hits.length} ${direction}`,
65 + durationMs: Date.now() - startedAt,
66 + matches: hits.length,
67 + },
68 + };
69 +}
70 +
71 +/** Build the refs tool (RepoGraph, v2 §3). */
72 +export function createRefsTool(): ToolDefinition<RefsInput> {
73 + return {
74 + name: "refs",
75 + description: DESCRIPTION,
76 + inputSchema: {
77 + type: "object",
78 + properties: {
79 + symbol: { type: "string", description: "The symbol name to trace." },
80 + direction: {
81 + type: "string",
82 + description: "callers | callees | importers",
83 + enum: ["callers", "callees", "importers"],
84 + },
85 + },
86 + required: ["symbol", "direction"],
87 + },
88 + capability: "file.read",
89 + execute: executeRefs,
90 + };
91 +}
added src/tools/remember.ts +115 −0
@@ -0,0 +1,115 @@
1 +/**
2 + * KHAELOR
3 + * File: src/tools/remember.ts
4 + * Description: The remember tool — persist a durable project fact into .khaelor/MEMORY.md with provenance (v2 design §5).
5 + *
6 + * Author: Simon-Pierre Boucher
7 + * Contact: contact@spboucher.ai
8 + */
9 +
10 +import * as path from "node:path";
11 +import type { ToolDefinition } from "./registry.js";
12 +import type { ToolContext, ToolResult } from "./types.js";
13 +
14 +export interface RememberInput {
15 + fact: string;
16 + section: string;
17 + confidence?: string;
18 +}
19 +
20 +const DESCRIPTION =
21 + "Persist a durable fact about this project into its memory (.khaelor/MEMORY.md) — a convention, a " +
22 + "build command, a pitfall, an architectural constraint. Use it when you discover something future " +
23 + "sessions must know. Do NOT store transient task state. section: conventions | commands | pitfalls " +
24 + "| architecture | notes. confidence defaults to medium.";
25 +
26 +const MEMORY_RELATIVE = ".khaelor/MEMORY.md";
27 +
28 +// Local re-implementation seam: the memory module's append logic is imported
29 +// lazily to keep the tools layer free of static cross-module dependencies.
30 +async function appendToMemory(
31 + existing: string | null,
32 + entry: {
33 + section: string;
34 + text: string;
35 + provenance: { session: string; tool: string; confidence: "high" | "medium" | "low"; date: string };
36 + },
37 +): Promise<string> {
38 + const { appendMemoryEntry } = await import("../memory/store.js");
39 + return appendMemoryEntry(existing, entry);
40 +}
41 +
42 +async function executeRemember(input: RememberInput, ctx: ToolContext): Promise<ToolResult> {
43 + const startedAt = Date.now();
44 + const fact = input.fact.trim();
45 + if (fact.length === 0) {
46 + return {
47 + content: 'Parameter "fact" must be a non-empty durable fact about the project.',
48 + isError: true,
49 + metadata: { title: "remember · empty fact", durationMs: Date.now() - startedAt },
50 + };
51 + }
52 + const confidence =
53 + input.confidence === "high" || input.confidence === "low" ? input.confidence : "medium";
54 + const filePath = path.join(ctx.workspace.cwd(), MEMORY_RELATIVE);
55 +
56 + let existing: string | null = null;
57 + try {
58 + existing = await ctx.workspace.readFile(filePath);
59 + } catch {
60 + existing = null; // first memory — the file is created below
61 + }
62 +
63 + const updated = await appendToMemory(existing, {
64 + section: input.section,
65 + text: fact,
66 + provenance: {
67 + session: ctx.sessionId,
68 + tool: ctx.callId,
69 + confidence,
70 + date: new Date().toISOString().slice(0, 10),
71 + },
72 + });
73 + await ctx.workspace.writeFile(filePath, updated);
74 +
75 + ctx.emit({
76 + type: "memory.written",
77 + payload: { section: input.section, entry: fact, confidence, toolUseId: ctx.callId },
78 + });
79 +
80 + return {
81 + content: `Remembered under "${input.section}" (confidence: ${confidence}).`,
82 + metadata: {
83 + title: `Remember · ${input.section}`,
84 + durationMs: Date.now() - startedAt,
85 + extra: { confidence },
86 + },
87 + };
88 +}
89 +
90 +/** Build the remember tool (project memory, v2 §5). */
91 +export function createRememberTool(): ToolDefinition<RememberInput> {
92 + return {
93 + name: "remember",
94 + description: DESCRIPTION,
95 + inputSchema: {
96 + type: "object",
97 + properties: {
98 + fact: { type: "string", description: "The durable fact, one or two sentences." },
99 + section: {
100 + type: "string",
101 + description: "Where it belongs: conventions, commands, pitfalls, architecture, or notes.",
102 + enum: ["conventions", "commands", "pitfalls", "architecture", "notes"],
103 + },
104 + confidence: {
105 + type: "string",
106 + description: "How certain you are: high, medium (default), or low.",
107 + enum: ["high", "medium", "low"],
108 + },
109 + },
110 + required: ["fact", "section"],
111 + },
112 + capability: "file.write",
113 + execute: executeRemember,
114 + };
115 +}
added src/tools/symbols.ts +84 −0
@@ -0,0 +1,84 @@
1 +/**
2 + * KHAELOR
3 + * File: src/tools/symbols.ts
4 + * Description: The symbols tool — semantic symbol search over the RepoGraph index (v2 design §3).
5 + *
6 + * Author: Simon-Pierre Boucher
7 + * Contact: contact@spboucher.ai
8 + */
9 +
10 +import type { ToolDefinition } from "./registry.js";
11 +import type { ToolContext, ToolResult } from "./types.js";
12 +
13 +export interface SymbolsInput {
14 + query: string;
15 + kind?: string;
16 + scope?: string;
17 +}
18 +
19 +const DESCRIPTION =
20 + 'Search the repository\'s symbol index: functions, classes, types, exports. query supports "*" ' +
21 + 'wildcards, e.g. "handleAuth" or "class:*Controller". Prefer this over grep when looking for a ' +
22 + "definition — it returns the signature and location directly. kind filters to function | class | " +
23 + "type | export | variable | method. scope restricts to a glob like src/**.";
24 +
25 +const MAX_RESULTS = 50;
26 +
27 +async function executeSymbols(input: SymbolsInput, ctx: ToolContext): Promise<ToolResult> {
28 + const startedAt = Date.now();
29 + if (ctx.repograph === undefined) {
30 + return {
31 + content:
32 + "The semantic index is unavailable in this session. Use grep to search for the symbol instead.",
33 + isError: true,
34 + metadata: { title: "symbols · index unavailable", durationMs: Date.now() - startedAt },
35 + };
36 + }
37 + const hits = await ctx.repograph.querySymbols(input.query, input.kind, input.scope);
38 + if (hits.length === 0) {
39 + return {
40 + content:
41 + `No symbols matched "${input.query}"${input.kind !== undefined ? ` (kind: ${input.kind})` : ""}. ` +
42 + "Try a broader query with wildcards, or fall back to grep.",
43 + metadata: { title: `Symbols "${input.query}" · 0`, durationMs: Date.now() - startedAt, matches: 0 },
44 + };
45 + }
46 + const shown = hits.slice(0, MAX_RESULTS);
47 + const lines = shown.map((hit) => {
48 + const doc = hit.docComment !== undefined ? `\n ${hit.docComment}` : "";
49 + return `${hit.file}:${hit.line} [${hit.kind}] ${hit.signature}${doc}`;
50 + });
51 + const omitted = hits.length - shown.length;
52 + const tail = omitted > 0 ? `\n[... ${omitted} more matches omitted — narrow the query]` : "";
53 + return {
54 + content: lines.join("\n") + tail,
55 + metadata: {
56 + title: `Symbols "${input.query}" · ${hits.length}`,
57 + durationMs: Date.now() - startedAt,
58 + matches: hits.length,
59 + },
60 + };
61 +}
62 +
63 +/** Build the symbols tool (RepoGraph, v2 §3). */
64 +export function createSymbolsTool(): ToolDefinition<SymbolsInput> {
65 + return {
66 + name: "symbols",
67 + description: DESCRIPTION,
68 + inputSchema: {
69 + type: "object",
70 + properties: {
71 + query: { type: "string", description: 'Symbol name or pattern, e.g. "handleAuth" or "*Controller".' },
72 + kind: {
73 + type: "string",
74 + description: "Filter by symbol kind.",
75 + enum: ["function", "class", "type", "export", "variable", "method"],
76 + },
77 + scope: { type: "string", description: 'Restrict to a path glob, e.g. "src/**".' },
78 + },
79 + required: ["query"],
80 + },
81 + capability: "file.read",
82 + execute: executeSymbols,
83 + };
84 +}
modified src/tools/types.ts +73 −2
@@ -9,8 +9,19 @@
9 9
10 10 import type { FileTimeRegistry, ProcessManager, Workspace } from "../workspace/index.js";
11 11
12 /** The seven V1 tools (ADR-8). */
13 export type ToolName = "read" | "write" | "edit" | "grep" | "glob" | "bash" | "process";
12 +/** The seven V1 tools plus the v2 additions: design (phase gates), remember (memory), symbols/refs (RepoGraph). */
13 +export type ToolName =
14 + | "read"
15 + | "write"
16 + | "edit"
17 + | "grep"
18 + | "glob"
19 + | "bash"
20 + | "process"
21 + | "design"
22 + | "remember"
23 + | "symbols"
24 + | "refs";
14 25
15 26 /**
16 27 * Coarse capability hint used by the executor/permission layer to derive
@@ -89,8 +100,64 @@ export type ToolEmittedEvent =
89 100 diff?: string;
90 101 toolUseId: string;
91 102 };
103 + }
104 + | {
105 + type: "memory.written";
106 + payload: {
107 + section: string;
108 + entry: string;
109 + confidence: "high" | "medium" | "low";
110 + toolUseId: string;
111 + };
92 112 };
93 113
114 +// ─────────────────── v2 service facets (structural seams) ───────────────────
115 +// Defined locally so tools keep importing only `workspace` and `shared`
116 +// (ARCHITECTURE.md layering) — the executor injects implementations that are
117 +// structurally identical to the phases/repograph services.
118 +
119 +/** Design artifact shape — structurally identical to session/events DesignArtifact. */
120 +export interface ToolDesignArtifact {
121 + goal: string;
122 + filesTouched: string[];
123 + approach: string;
124 + risks: string[];
125 + verification: string;
126 + outOfScope: string[];
127 +}
128 +
129 +/** Phase-gate facet the `design` tool uses (v2 §1). */
130 +export interface PhaseToolFacet {
131 + mode: "strict" | "auto" | "off";
132 + current(): "understand" | "design" | "implement";
133 + submitDesign(
134 + artifact: ToolDesignArtifact,
135 + ): Promise<{ status: "approved" | "rejected" | "pending"; artifactId: string; reason?: string }>;
136 +}
137 +
138 +/** One symbol row returned by the semantic index (v2 §3). */
139 +export interface SymbolHit {
140 + symbol: string;
141 + kind: "function" | "class" | "type" | "export" | "variable" | "method";
142 + file: string;
143 + line: number;
144 + signature: string;
145 + docComment?: string;
146 +}
147 +
148 +/** One reference site returned by the semantic index (v2 §3). */
149 +export interface RefHit {
150 + file: string;
151 + line: number;
152 + context: string;
153 +}
154 +
155 +/** RepoGraph facet the `symbols`/`refs` tools use (v2 §3). */
156 +export interface RepoGraphFacet {
157 + querySymbols(query: string, kind?: string, scope?: string): Promise<SymbolHit[]>;
158 + queryRefs(symbol: string, direction: "callers" | "callees" | "importers"): Promise<RefHit[]>;
159 +}
160 +
94 161 /**
95 162 * Everything a tool may touch during execution (TOOL_PROTOCOL §1.2).
96 163 * Built by the executor; tools never reach for Node globals (ADR-13).
@@ -111,6 +178,10 @@ export interface ToolContext {
111 178 progress(meta: Record<string, unknown>): void;
112 179 /** Spill oversized output; returns the absolute path (TOOL_PROTOCOL §1.3). */
113 180 spill(label: string, content: string): Promise<string>;
181 + /** Phase-gate facet (v2 §1); absent when gates are unavailable. */
182 + readonly phases?: PhaseToolFacet;
183 + /** Semantic-index facet (v2 §3); absent when the index is unavailable. */
184 + readonly repograph?: RepoGraphFacet;
114 185 }
115 186
116 187 /** Synthetic content used when a tool call is cancelled (TOOL_PROTOCOL §1.2). */
modified src/tui/app.ts +144 −1
@@ -19,6 +19,7 @@ import { CommandRegistry } from "./commands.js";
19 19 import { renderDiffBlock, renderEditSummary } from "./components/diff.js";
20 20 import { renderErrorPanel } from "./components/error-panel.js";
21 21 import { renderPermissionPanel } from "./components/permission-panel.js";
22 +import { renderDesignPanel } from "./components/design-panel.js";
22 23 import { renderStatusBar } from "./components/status-bar.js";
23 24 import type { StatusBarData } from "./components/status-bar.js";
24 25 import { renderStatusLine } from "./components/status-line.js";
@@ -104,6 +105,10 @@ const TOOL_STATUS: Record<ToolName, AgentStatus["kind"]> = {
104 105 write: "editing",
105 106 bash: "running",
106 107 process: "running",
108 + design: "thinking",
109 + remember: "editing",
110 + symbols: "searching",
111 + refs: "searching",
107 112 };
108 113
109 114 const TOOL_VERB: Record<ToolName, string> = {
@@ -114,6 +119,10 @@ const TOOL_VERB: Record<ToolName, string> = {
114 119 write: "Write",
115 120 bash: "Run",
116 121 process: "Process",
122 + design: "Design",
123 + remember: "Remember",
124 + symbols: "Symbols",
125 + refs: "Refs",
117 126 };
118 127
119 128 const TOOL_KIND: Record<ToolName, ToolLineKind> = {
@@ -124,6 +133,10 @@ const TOOL_KIND: Record<ToolName, ToolLineKind> = {
124 133 write: "edit",
125 134 bash: "exec",
126 135 process: "process",
136 + design: "exec",
137 + remember: "edit",
138 + symbols: "search",
139 + refs: "search",
127 140 };
128 141
129 142 export class TuiApp {
@@ -177,6 +190,17 @@ export class TuiApp {
177 190 cacheWriteTokens: 0,
178 191 };
179 192 private lastContextTokens: number | null = null;
193 + /** Phase-gate ribbon state (v2 §1); null until a phase.entered arrives. */
194 + private currentPhase: "understand" | "design" | "implement" | null = null;
195 + /** Last submitted design artifact — collapsed on approval/rejection. */
196 + private lastArtifact: {
197 + goal: string;
198 + filesTouched: string[];
199 + approach: string;
200 + risks: string[];
201 + verification: string;
202 + outOfScope: string[];
203 + } | null = null;
180 204 private dirtyStats = { added: 0, removed: 0 };
181 205 private processCount = 0;
182 206 private lastCtrlC = 0;
@@ -185,6 +209,7 @@ export class TuiApp {
185 209 private placeholder: string | null = "What do you want to build?";
186 210 /** When set, the next universal-palette selection routes here instead of the registry. */
187 211 private universalPaletteOnSelect: ((id: string) => void) | null = null;
212 + private universalPaletteOnCancel: (() => void) | null = null;
188 213
189 214 constructor(options: TuiAppOptions) {
190 215 this.options = options;
@@ -274,9 +299,10 @@ export class TuiApp {
274 299 }
275 300
276 301 /** Open the universal palette with custom items; selection routes to `onSelect`. */
277 openSelector(items: PaletteItem[], onSelect: (id: string) => void): void {
302 + openSelector(items: PaletteItem[], onSelect: (id: string) => void, onCancel?: () => void): void {
278 303 this.universalPalette = createPalette("command", items);
279 304 this.universalPaletteOnSelect = onSelect;
305 + this.universalPaletteOnCancel = onCancel ?? null;
280 306 this.renderer.markDirty();
281 307 this.renderer.flushNow();
282 308 }
@@ -541,6 +567,107 @@ export class TuiApp {
541 567 this.busy = false;
542 568 this.setStatus("idle");
543 569 break;
570 + case "phase.entered": {
571 + this.currentPhase = event.payload.phase;
572 + const glyph =
573 + event.payload.phase === "understand" ? "◐" : event.payload.phase === "design" ? "◑" : "●";
574 + // The session-start understand entry stays silent — the ribbon carries it.
575 + if (event.payload.via !== "session-start") {
576 + this.renderer.printSettled([
577 + truncateAnsi(
578 + ` ${this.theme.paint("accent", glyph)} phase → ${this.theme.paint("bold", event.payload.phase)}${event.payload.via === "user-override" ? this.theme.paint("dim", " (user override)") : ""}`,
579 + this.io.columns(),
580 + ),
581 + ]);
582 + }
583 + break;
584 + }
585 + case "phase.artifact": {
586 + this.lastArtifact = event.payload.artifact;
587 + const files = event.payload.artifact.filesTouched.length;
588 + this.renderer.printSettled([
589 + truncateAnsi(
590 + ` ${this.theme.paint("accent", "◑")} design submitted · ${files} file${files === 1 ? "" : "s"} ${this.theme.paint("dim", `· ${event.payload.artifact.goal.slice(0, 60)}`)}`,
591 + this.io.columns(),
592 + ),
593 + ]);
594 + break;
595 + }
596 + case "phase.approved": {
597 + if (event.payload.phase !== "design") break;
598 + const artifact = this.lastArtifact;
599 + this.renderer.printSettled(
600 + renderDesignPanel(
601 + {
602 + goal: artifact?.goal ?? "",
603 + filesTouched: artifact?.filesTouched ?? [],
604 + approach: artifact?.approach ?? "",
605 + risks: artifact?.risks ?? [],
606 + verification: artifact?.verification ?? "",
607 + outOfScope: artifact?.outOfScope ?? [],
608 + decision: event.payload.approvedBy === "auto-policy" ? "auto-approved" : "approved",
609 + },
610 + this.io.columns(),
611 + this.theme,
612 + ),
613 + );
614 + break;
615 + }
616 + case "phase.rejected": {
617 + this.renderer.printSettled([
618 + truncateAnsi(
619 + ` ${this.theme.paint("error", "✗")} design rejected ${this.theme.paint("dim", `· ${event.payload.reason.slice(0, 70)}`)}`,
620 + this.io.columns(),
621 + ),
622 + ]);
623 + break;
624 + }
625 + case "verify.result": {
626 + const secs = (event.payload.durationMs / 1000).toFixed(1);
627 + if (event.payload.ok) {
628 + this.renderer.printSettled([
629 + truncateAnsi(
630 + ` ${this.theme.paint("success", "✓")} verify ${event.payload.check} ${this.theme.paint("dim", `${secs}s`)}`,
631 + this.io.columns(),
632 + ),
633 + ]);
634 + } else {
635 + const exit = event.payload.exitCode === null ? "killed" : `exit ${event.payload.exitCode}`;
636 + this.renderer.printSettled([
637 + truncateAnsi(
638 + ` ${this.theme.paint("error", "✗")} verify ${event.payload.check} ${this.theme.paint("dim", `${secs}s · ${exit} — repairing`)}`,
639 + this.io.columns(),
640 + ),
641 + ...event.payload.output
642 + .split("\n")
643 + .slice(0, 3)
644 + .map((line) => truncateAnsi(` ${this.theme.paint("dim", line)}`, this.io.columns())),
645 + ]);
646 + }
647 + break;
648 + }
649 + case "subtask.created": {
650 + this.renderer.printSettled([
651 + truncateAnsi(
652 + ` ${this.theme.paint("accent", "●")} subtask ${event.payload.taskId} spawned ${this.theme.paint("dim", `· ${event.payload.description.slice(0, 56)}`)}`,
653 + this.io.columns(),
654 + ),
655 + ]);
656 + break;
657 + }
658 + case "subtask.completed": {
659 + const ok = event.payload.outcome === "done";
660 + const verify =
661 + event.payload.verifyOk === null ? "" : event.payload.verifyOk ? " · verify ✓" : " · verify ✗";
662 + this.renderer.printSettled([
663 + truncateAnsi(
664 + ` ${this.theme.paint(ok ? "success" : "error", ok ? "✓" : "✗")} subtask ${event.payload.taskId} ${event.payload.outcome} ` +
665 + `${this.theme.paint("success", `+${event.payload.diffStats.added}`)} ${this.theme.paint("error", `−${event.payload.diffStats.removed}`)}${verify} ${this.theme.paint("dim", "· /tasks · /merge")}`,
666 + this.io.columns(),
667 + ),
668 + ]);
669 + break;
670 + }
544 671 default:
545 672 break;
546 673 }
@@ -670,8 +797,11 @@ export class TuiApp {
670 797 private handleUniversalPaletteKey(key: KeyEvent): void {
671 798 const palette = this.universalPalette as PaletteState;
672 799 if (key.type === "esc") {
800 + const onCancel = this.universalPaletteOnCancel;
673 801 this.universalPalette = null;
674 802 this.universalPaletteOnSelect = null;
803 + this.universalPaletteOnCancel = null;
804 + if (onCancel !== null) onCancel();
675 805 return;
676 806 }
677 807 if (key.type === "arrow" && key.key === "up") {
@@ -693,11 +823,15 @@ export class TuiApp {
693 823 if (key.type === "enter") {
694 824 const item = paletteSelection(palette);
695 825 const onSelect = this.universalPaletteOnSelect;
826 + const onCancel = this.universalPaletteOnCancel;
696 827 this.universalPalette = null;
697 828 this.universalPaletteOnSelect = null;
829 + this.universalPaletteOnCancel = null;
698 830 if (item !== null) {
699 831 if (onSelect !== null) onSelect(item.id);
700 832 else this.registry.find(item.id)?.run();
833 + } else if (onCancel !== null) {
834 + onCancel();
701 835 }
702 836 return;
703 837 }
@@ -955,6 +1089,7 @@ export class TuiApp {
955 1089 }
956 1090 if (this.processCount > 0) data.processCount = this.processCount;
957 1091 if (this.queued.length > 0) data.queuedCount = this.queued.length;
1092 + if (this.currentPhase !== null) data.phase = this.currentPhase;
958 1093 return renderStatusBar(data, width, this.theme);
959 1094 }
960 1095 }
@@ -977,6 +1112,14 @@ function describeToolInput(name: ToolName, input: unknown): string {
977 1112 case "bash":
978 1113 case "process":
979 1114 return str("command") ?? "";
1115 + case "design":
1116 + return str("goal") ?? "";
1117 + case "remember":
1118 + return str("section") ?? "";
1119 + case "symbols":
1120 + return str("query") !== null ? `"${str("query") as string}"` : "";
1121 + case "refs":
1122 + return str("symbol") ?? "";
980 1123 }
981 1124 }
982 1125
added src/tui/components/design-panel.ts +90 −0
@@ -0,0 +1,90 @@
1 +/**
2 + * KHAELOR
3 + * File: src/tui/components/design-panel.ts
4 + * Description: The DesignArtifact panel — the workflow's most important surface (TUI v2 §5.2).
5 + *
6 + * Author: Simon-Pierre Boucher
7 + * Contact: contact@spboucher.ai
8 + */
9 +
10 +import { truncateAnsi } from "../renderer/ansi.js";
11 +import type { Theme } from "../theme.js";
12 +
13 +export interface DesignPanelData {
14 + goal: string;
15 + filesTouched: string[];
16 + approach: string;
17 + risks: string[];
18 + verification: string;
19 + outOfScope: string[];
20 + /** Panel mode: awaiting a decision, or already decided. */
21 + decision: "pending" | "approved" | "rejected" | "auto-approved";
22 +}
23 +
24 +function wrap(text: string, width: number): string[] {
25 + const words = text.split(/\s+/);
26 + const lines: string[] = [];
27 + let current = "";
28 + for (const word of words) {
29 + if (current.length + word.length + 1 > width && current.length > 0) {
30 + lines.push(current);
31 + current = word;
32 + } else {
33 + current = current.length === 0 ? word : `${current} ${word}`;
34 + }
35 + }
36 + if (current.length > 0) lines.push(current);
37 + return lines;
38 +}
39 +
40 +/**
41 + * Bordered panel with the ember gradient on the frame while pending; collapses
42 + * to one settled line once decided: `✓ design approuvé · 2 fichiers` (TUI v2 §5.2).
43 + */
44 +export function renderDesignPanel(data: DesignPanelData, width: number, theme: Theme): string[] {
45 + if (data.decision !== "pending") {
46 + const glyph =
47 + data.decision === "rejected" ? theme.paint("error", "✗") : theme.paint("success", "✓");
48 + const label =
49 + data.decision === "rejected"
50 + ? "design rejected"
51 + : data.decision === "auto-approved"
52 + ? "design auto-approved"
53 + : "design approved";
54 + const count = data.filesTouched.length;
55 + return [
56 + truncateAnsi(
57 + ` ${glyph} ${label} · ${count} file${count === 1 ? "" : "s"} ${theme.paint("dim", `· ${data.goal.slice(0, 60)}`)}`,
58 + width,
59 + ),
60 + ];
61 + }
62 +
63 + const inner = Math.max(30, Math.min(width - 4, 76));
64 + const border = (s: string): string => theme.paintGradient("ember", s);
65 + const row = (label: string, text: string): string[] =>
66 + wrap(text, inner - 11).map((line, index) =>
67 + truncateAnsi(
68 + ` ${border("│")} ${theme.paint("dim", (index === 0 ? label : "").padEnd(9))}${line.padEnd(inner - 11)} ${border("│")}`,
69 + width,
70 + ),
71 + );
72 +
73 + const lines: string[] = [];
74 + lines.push(truncateAnsi(` ${border(`╭─ ◑ DESIGN — approval required ${"─".repeat(Math.max(0, inner - 31))}╮`)}`, width));
75 + lines.push(...row("Goal", data.goal));
76 + lines.push(...row("Files", data.filesTouched.join(" · ")));
77 + lines.push(...row("Approach", data.approach));
78 + if (data.risks.length > 0) lines.push(...row("Risks", data.risks.map((risk) => `▪ ${risk}`).join(" ")));
79 + lines.push(...row("Verify", data.verification));
80 + if (data.outOfScope.length > 0) lines.push(...row("Not doing", data.outOfScope.join(" · ")));
81 + lines.push(truncateAnsi(` ${border(`├${"─".repeat(inner)}┤`)}`, width));
82 + lines.push(
83 + truncateAnsi(
84 + ` ${border("│")} ${theme.paint("bold", "[a]")} approve ${theme.paint("bold", "[r]")} reject${" ".repeat(Math.max(0, inner - 25))} ${border("│")}`,
85 + width,
86 + ),
87 + );
88 + lines.push(truncateAnsi(` ${border(`╰${"─".repeat(inner)}╯`)}`, width));
89 + return lines;
90 +}
added src/tui/components/sparkline.ts +46 −0
@@ -0,0 +1,46 @@
1 +/**
2 + * KHAELOR
3 + * File: src/tui/components/sparkline.ts
4 + * Description: Braille sparkline — 2×4 dots per cell, used by /cost per-turn graphs (TUI v2 §3).
5 + *
6 + * Author: Simon-Pierre Boucher
7 + * Contact: contact@spboucher.ai
8 + */
9 +
10 +// Braille dot bits by (column 0-1, row 0-3), per the Unicode braille block.
11 +const DOT_BITS: number[][] = [
12 + [0x01, 0x02, 0x04, 0x40],
13 + [0x08, 0x10, 0x20, 0x80],
14 +];
15 +
16 +/**
17 + * Render a numeric series as a braille sparkline: `⣀⣄⣤⣶⣿⣷⣄`. Two values per
18 + * cell, four vertical levels per dot column. Empty/flat series render as a
19 + * flat baseline — no fabricated variance (Absolute Rule #4).
20 + */
21 +export function brailleSparkline(values: readonly number[], maxCells?: number): string {
22 + if (values.length === 0) return "";
23 + const max = Math.max(...values);
24 + const levels = values.map((value) => {
25 + if (max <= 0) return 1;
26 + return Math.max(1, Math.min(4, Math.ceil((value / max) * 4)));
27 + });
28 + const cellCount = Math.ceil(levels.length / 2);
29 + const cells: string[] = [];
30 + for (let cell = 0; cell < cellCount; cell += 1) {
31 + let bits = 0;
32 + for (let column = 0; column < 2; column += 1) {
33 + const level = levels[cell * 2 + column];
34 + if (level === undefined) continue;
35 + for (let row = 4 - level; row < 4; row += 1) {
36 + bits |= (DOT_BITS[column] as number[])[row] as number;
37 + }
38 + }
39 + cells.push(String.fromCharCode(0x2800 + bits));
40 + }
41 + const rendered = cells.join("");
42 + if (maxCells !== undefined && cells.length > maxCells) {
43 + return rendered.slice(cells.length - maxCells);
44 + }
45 + return rendered;
46 +}
modified src/tui/components/status-bar.ts +53 −2
@@ -27,6 +27,47 @@ export interface StatusBarData {
27 27 processCount?: number;
28 28 /** Queued steering messages. */
29 29 queuedCount?: number;
30 + /** Phase-gate ribbon (v2 §1): absent when gates are off. */
31 + phase?: "understand" | "design" | "implement";
32 +}
33 +
34 +const PHASE_GLYPH: Record<NonNullable<StatusBarData["phase"]>, string> = {
35 + understand: "◐",
36 + design: "◑",
37 + implement: "●",
38 +};
39 +
40 +const PHASE_ORDER: NonNullable<StatusBarData["phase"]>[] = ["understand", "design", "implement"];
41 +
42 +/**
43 + * Phase ribbon: the active phase in ember (accent), passed phases dimmed with
44 + * a ✓, future phases dim — `◐ understand ─ design ─ implement` (TUI v2 §3).
45 + */
46 +export function renderPhaseRibbon(
47 + phase: NonNullable<StatusBarData["phase"]>,
48 + theme: Theme,
49 + compact: boolean,
50 +): string {
51 + if (compact) {
52 + return theme.paint("accent", `${PHASE_GLYPH[phase]} ${phase.toUpperCase()}`);
53 + }
54 + const activeIndex = PHASE_ORDER.indexOf(phase);
55 + const parts = PHASE_ORDER.map((name, index) => {
56 + if (index === activeIndex) return theme.paint("accent", `${PHASE_GLYPH[name]} ${name.toUpperCase()}`);
57 + if (index < activeIndex) return theme.paint("dim", `✓ ${name}`);
58 + return theme.paint("dim", name);
59 + });
60 + return parts.join(theme.paint("dim", " ─ "));
61 +}
62 +
63 +/** Context gauge `▰▰▰▱▱▱▱▱ 62%` — teal < 50, warning < 80, error above; ≥ 80 nudges /compact (TUI v2 §3). */
64 +export function renderContextGauge(pct: number, theme: Theme): string {
65 + const clamped = Math.max(0, Math.min(100, Math.round(pct)));
66 + const filled = Math.round((clamped / 100) * 8);
67 + const bar = "▰".repeat(filled) + "▱".repeat(8 - filled);
68 + const role = clamped >= 80 ? "error" : clamped >= 50 ? "warning" : "teal";
69 + const nudge = clamped >= 80 ? " · /compact" : "";
70 + return theme.paint(role, `${bar} ${clamped}%${nudge}`);
30 71 }
31 72
32 73 function shortModel(data: StatusBarData): string | undefined {
@@ -65,21 +106,31 @@ export function renderStatusBar(data: StatusBarData, width: number, theme: Theme
65 106 : undefined;
66 107
67 108 if (width < 60) {
109 + if (data.phase !== undefined) segments.push(renderPhaseRibbon(data.phase, theme, true));
68 110 if (data.branch !== undefined) segments.push(theme.paint("cyan", data.branch));
69 111 const ctx = contextSegment(data, theme, false);
70 112 if (ctx !== undefined) segments.push(ctx);
71 113 } else if (width < 80) {
114 + if (data.phase !== undefined) segments.push(renderPhaseRibbon(data.phase, theme, true));
72 115 if (branchFull !== undefined) segments.push(branchFull);
73 116 const alias = shortModel(data);
74 117 if (alias !== undefined) segments.push(theme.paint("violet", alias));
75 118 const ctx = contextSegment(data, theme, false);
76 119 if (ctx !== undefined) segments.push(ctx);
77 120 } else {
121 + // The cockpit (TUI v2 §3): phase ribbon left, then branch, model, gauge, cost.
122 + if (data.phase !== undefined) {
123 + segments.push(renderPhaseRibbon(data.phase, theme, width < 110));
124 + }
78 125 if (branchFull !== undefined) segments.push(branchFull);
79 126 const model = width < 100 ? shortModel(data) : data.model;
80 127 if (model !== undefined) segments.push(theme.paint("violet", model));
81 const ctx = contextSegment(data, theme, true);
82 if (ctx !== undefined) segments.push(ctx);
128 + if (data.contextPct !== undefined && width >= 110) {
129 + segments.push(renderContextGauge(data.contextPct, theme));
130 + } else {
131 + const ctx = contextSegment(data, theme, true);
132 + if (ctx !== undefined) segments.push(ctx);
133 + }
83 134 if (data.costUsd !== undefined) segments.push(theme.paint("orange", `$${data.costUsd.toFixed(2)}`));
84 135 if (data.processCount !== undefined && data.processCount > 0) {
85 136 segments.push(`${theme.paint("accent", "●")} ${data.processCount}`);
added src/tui/components/verify-strip.ts +68 −0
@@ -0,0 +1,68 @@
1 +/**
2 + * KHAELOR
3 + * File: src/tui/components/verify-strip.ts
4 + * Description: The verify strip — live check progress that contracts to `✓ verified 4.2s` on success (TUI v2 §5.4).
5 + *
6 + * Author: Simon-Pierre Boucher
7 + * Contact: contact@spboucher.ai
8 + */
9 +
10 +import { truncateAnsi } from "../renderer/ansi.js";
11 +import type { Theme } from "../theme.js";
12 +
13 +export interface VerifyCheckState {
14 + check: string;
15 + state: "running" | "ok" | "failed";
16 + durationMs?: number;
17 + /** First error lines, shown inline on failure. */
18 + errorHead?: string[];
19 +}
20 +
21 +/**
22 + * Live strip while checks run: `⟳ verify typecheck ✓ 1.2s · tests ⠧ · lint ✓`
23 + * All-passed contraction: `✓ verified 4.2s`
24 + * Failure keeps the failing check pinned with its first error lines.
25 + */
26 +export function renderVerifyStrip(
27 + checks: readonly VerifyCheckState[],
28 + width: number,
29 + theme: Theme,
30 +): string[] {
31 + if (checks.length === 0) return [];
32 + const allDone = checks.every((check) => check.state !== "running");
33 + const allOk = allDone && checks.every((check) => check.state === "ok");
34 +
35 + if (allOk) {
36 + const totalMs = Math.max(...checks.map((check) => check.durationMs ?? 0));
37 + return [
38 + truncateAnsi(
39 + ` ${theme.paint("success", "✓")} ${theme.paint("success", `verified ${(totalMs / 1000).toFixed(1)}s`)}` +
40 + ` ${theme.paint("dim", checks.map((check) => check.check).join(" · "))}`,
41 + width,
42 + ),
43 + ];
44 + }
45 +
46 + const parts = checks.map((check) => {
47 + if (check.state === "running") return `${check.check} ${theme.paint("teal", "⠧")}`;
48 + if (check.state === "ok") {
49 + const secs = check.durationMs !== undefined ? ` ${(check.durationMs / 1000).toFixed(1)}s` : "";
50 + return `${check.check} ${theme.paint("success", "✓")}${theme.paint("dim", secs)}`;
51 + }
52 + return theme.paint("error", `${check.check} ✗`);
53 + });
54 + const lines = [
55 + truncateAnsi(
56 + ` ${theme.paint("accent", "⟳")} ${theme.paint("bold", "verify")} ${parts.join(theme.paint("dim", " · "))}`,
57 + width,
58 + ),
59 + ];
60 + for (const check of checks) {
61 + if (check.state === "failed" && check.errorHead !== undefined) {
62 + for (const errorLine of check.errorHead.slice(0, 3)) {
63 + lines.push(truncateAnsi(` ${theme.paint("dim", errorLine)}`, width));
64 + }
65 + }
66 + }
67 + return lines;
68 +}
added src/tui/doctor.ts +55 −0
@@ -0,0 +1,55 @@
1 +/**
2 + * KHAELOR
3 + * File: src/tui/doctor.ts
4 + * Description: `khaelor --doctor-tui` — detected terminal capabilities and why, turning display bugs into self-diagnosed tickets (TUI v2 §7).
5 + *
6 + * Author: Simon-Pierre Boucher
7 + * Contact: contact@spboucher.ai
8 + */
9 +
10 +import { detectColorDepth } from "./renderer/capabilities.js";
11 +
12 +export interface DoctorEnv {
13 + TERM?: string;
14 + COLORTERM?: string;
15 + NO_COLOR?: string;
16 + LANG?: string;
17 + LC_ALL?: string;
18 + TERM_PROGRAM?: string;
19 +}
20 +
21 +/** Terminals known to support OSC 8 hyperlinks / OSC 52 clipboard. */
22 +const OSC8_TERMINALS = new Set(["iTerm.app", "WezTerm", "kitty", "ghostty", "Hyper", "vscode"]);
23 +const OSC52_TERMINALS = new Set(["iTerm.app", "WezTerm", "kitty", "ghostty", "Apple_Terminal"]);
24 +
25 +/** Build the --doctor-tui report: what was detected, and the reason. */
26 +export function doctorReport(env: DoctorEnv, isTty: boolean, columns: number): string[] {
27 + const colorDepth = detectColorDepth(env as NodeJS.ProcessEnv, isTty);
28 + const utf8 = /utf-?8/i.test(env.LC_ALL ?? env.LANG ?? "");
29 + const program = env.TERM_PROGRAM ?? "";
30 + const osc8 = OSC8_TERMINALS.has(program);
31 + const osc52 = OSC52_TERMINALS.has(program);
32 +
33 + const line = (name: string, value: string, why: string): string =>
34 + ` ${name.padEnd(16)} ${value.padEnd(18)} ${why}`;
35 +
36 + return [
37 + "",
38 + " doctor-tui · detected capabilities",
39 + line("tty", String(isTty), isTty ? "stdout is a terminal" : "stdout is piped — TUI disabled"),
40 + line(
41 + "colors",
42 + colorDepth,
43 + env.NO_COLOR !== undefined
44 + ? "NO_COLOR is set"
45 + : `COLORTERM=${env.COLORTERM ?? "(unset)"} · TERM=${env.TERM ?? "(unset)"}`,
46 + ),
47 + line("unicode", utf8 ? "utf-8" : "ascii-fallback", `LANG=${env.LANG ?? "(unset)"}`),
48 + line("width", String(columns), columns < 100 ? "narrow layout: 2-line status bar, unified diffs" : "full layout"),
49 + line("osc8 links", osc8 ? "yes" : "unknown", `TERM_PROGRAM=${program || "(unset)"} — fallback: text (url)`),
50 + line("osc52 clipboard", osc52 ? "yes" : "unknown", "fallback: copy hint hidden"),
51 + "",
52 + " theme: khaelis (obsidian + magma) · degradation: truecolor → 256 → 16 → mono",
53 + " report display bugs with this block attached.",
54 + ];
55 +}
modified src/tui/index.ts +13 −1
@@ -51,8 +51,20 @@ export type { SynSpan, SynRole } from "./markdown/highlight.js";
51 51
52 52 export { renderStatusLine } from "./components/status-line.js";
53 53 export type { AgentStatus, AgentStateKind } from "./components/status-line.js";
54 export { renderStatusBar } from "./components/status-bar.js";
54 +export { renderContextGauge, renderPhaseRibbon, renderStatusBar } from "./components/status-bar.js";
55 55 export type { StatusBarData } from "./components/status-bar.js";
56 +export { renderVerifyStrip } from "./components/verify-strip.js";
57 +export type { VerifyCheckState } from "./components/verify-strip.js";
58 +export { renderDesignPanel } from "./components/design-panel.js";
59 +export type { DesignPanelData } from "./components/design-panel.js";
60 +export { brailleSparkline } from "./components/sparkline.js";
61 +export { gradientSteps, gradientText, gradientSweepFrame, mixOklch } from "./theme/gradient.js";
62 +export type { Rgb } from "./theme/gradient.js";
63 +export { MotionController } from "./motion.js";
64 +export type { MotionEffect } from "./motion.js";
65 +export { SPLASH_FRAMES, WORDMARK, WORDMARK_TAGLINE, renderCompactBrand, renderSplashFrame } from "./splash.js";
66 +export { doctorReport } from "./doctor.js";
67 +export type { DoctorEnv } from "./doctor.js";
56 68 export {
57 69 renderToolLine,
58 70 renderRunningTool,
added src/tui/motion.ts +103 −0
@@ -0,0 +1,103 @@
1 +/**
2 + * KHAELOR
3 + * File: src/tui/motion.ts
4 + * Description: Motion doctrine — the six sanctioned animations as a tick-driven state machine, `--motion off` aware (TUI v2 §6).
5 + *
6 + * Author: Simon-Pierre Boucher
7 + * Contact: contact@spboucher.ai
8 + */
9 +
10 +/**
11 + * The complete catalog — six effects, each carrying meaning, nothing else
12 + * (TUI v2 §6): splash sweep · phase-transition flash · approval-panel
13 + * breathing · gauge drain on compact · post-stream code colorization ·
14 + * verify-strip contraction.
15 + */
16 +export type MotionEffect =
17 + | "splash-sweep"
18 + | "phase-flash"
19 + | "panel-breath"
20 + | "gauge-drain"
21 + | "code-colorize"
22 + | "verify-contract";
23 +
24 +/** Frames each transition runs (~16 ms ticks; ≤ 300 ms per rule 2). */
25 +const EFFECT_FRAMES: Record<MotionEffect, number> = {
26 + "splash-sweep": 10,
27 + "phase-flash": 3,
28 + "panel-breath": Number.POSITIVE_INFINITY, // waiting indicator: ≤ 1 Hz, runs until decided
29 + "gauge-drain": 18,
30 + "code-colorize": 1,
31 + "verify-contract": 6,
32 +};
33 +
34 +interface ActiveEffect {
35 + effect: MotionEffect;
36 + frame: number;
37 +}
38 +
39 +/**
40 + * Rules (TUI v2 §6):
41 + * 1. every animation advances ONLY inside the existing render tick — zero
42 + * extra timers, zero re-renders outside the frame budget;
43 + * 2. transitions ≤ 300 ms; waiting indicators ≤ 1 Hz;
44 + * 3. `enabled: false` (--motion off, NVIM, screen readers) reduces everything
45 + * to its final static state instantly.
46 + */
47 +export class MotionController {
48 + #enabled: boolean;
49 + readonly #active = new Map<string, ActiveEffect>();
50 +
51 + constructor(options: { enabled: boolean }) {
52 + this.#enabled = options.enabled;
53 + }
54 +
55 + get enabled(): boolean {
56 + return this.#enabled;
57 + }
58 +
59 + setEnabled(enabled: boolean): void {
60 + this.#enabled = enabled;
61 + if (!enabled) this.#active.clear();
62 + }
63 +
64 + /** Begin an effect under a stable key (e.g. `phase-flash:implement`). */
65 + start(key: string, effect: MotionEffect): void {
66 + if (!this.#enabled) return;
67 + this.#active.set(key, { effect, frame: 0 });
68 + }
69 +
70 + stop(key: string): void {
71 + this.#active.delete(key);
72 + }
73 +
74 + /**
75 + * Advance every active effect by one render tick; finished transitions are
76 + * dropped. Returns whether anything is still animating (the renderer may
77 + * skip scheduling extra frames when false).
78 + */
79 + tick(): boolean {
80 + for (const [key, active] of this.#active) {
81 + active.frame += 1;
82 + if (active.frame >= EFFECT_FRAMES[active.effect]) this.#active.delete(key);
83 + }
84 + return this.#active.size > 0;
85 + }
86 +
87 + /** Current frame of an effect, or null when inactive/off (render static). */
88 + frame(key: string): number | null {
89 + const active = this.#active.get(key);
90 + return active !== undefined ? active.frame : null;
91 + }
92 +
93 + /**
94 + * Breathing intensity for the approval panel: ±8% luminance over ~2 s.
95 + * Returns 0 when motion is off — the panel renders at rest.
96 + */
97 + breathIntensity(key: string, ticksPerSecond = 60): number {
98 + const active = this.#active.get(key);
99 + if (active === undefined || active.effect !== "panel-breath") return 0;
100 + const period = 2 * ticksPerSecond;
101 + return 0.08 * Math.sin((2 * Math.PI * active.frame) / period);
102 + }
103 +}
added src/tui/splash.ts +63 −0
@@ -0,0 +1,63 @@
1 +/**
2 + * KHAELOR
3 + * File: src/tui/splash.ts
4 + * Description: The opening moment — wordmark with an ember gradient sweep, ~600 ms, skippable, first-run only (TUI v2 §2).
5 + *
6 + * Author: Simon-Pierre Boucher
7 + * Contact: contact@spboucher.ai
8 + */
9 +
10 +import { gradientSweepFrame, gradientText } from "./theme/gradient.js";
11 +import type { Rgb } from "./theme/gradient.js";
12 +import type { Theme } from "./theme.js";
13 +
14 +/** The KHAELOR wordmark (block letters, no box-drawing dependence). */
15 +export const WORDMARK: readonly string[] = [
16 + "██╗ ██╗██╗ ██╗ █████╗ ███████╗██╗ ██████╗ ██████╗",
17 + "██║ ██╔╝██║ ██║██╔══██╗██╔════╝██║ ██╔═══██╗██╔══██╗",
18 + "█████╔╝ ███████║███████║█████╗ ██║ ██║ ██║██████╔╝",
19 + "██╔═██╗ ██╔══██║██╔══██║██╔══╝ ██║ ██║ ██║██╔══██╗",
20 + "██║ ██╗██║ ██║██║ ██║███████╗███████╗╚██████╔╝██║ ██║",
21 + "╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═╝╚══════╝╚══════╝ ╚═════╝ ╚═╝ ╚═╝",
22 +];
23 +
24 +export const WORDMARK_TAGLINE = "understand · design · implement";
25 +
26 +const EMBER: Rgb = [255, 107, 53];
27 +const EMBER_GLOW: Rgb = [255, 184, 107];
28 +
29 +/** Total sweep frames (~600 ms at one frame per 60 ms tick, TUI v2 §2). */
30 +export const SPLASH_FRAMES = 10;
31 +
32 +/**
33 + * One frame of the reveal: the ember gradient sweeps left → right, then the
34 + * final frame settles on the static ember→glow ramp. Pure — the caller owns
35 + * timing (16 ms budget rule) and skip-on-any-key.
36 + */
37 +export function renderSplashFrame(frame: number, truecolor: boolean): string[] {
38 + const lines: string[] = [""];
39 + for (const row of WORDMARK) {
40 + if (!truecolor) {
41 + lines.push(` ${row}`);
42 + } else if (frame >= SPLASH_FRAMES - 1) {
43 + lines.push(` ${gradientText(row, EMBER, EMBER_GLOW)}`);
44 + } else {
45 + const offset = Math.round((row.length / SPLASH_FRAMES) * (SPLASH_FRAMES - 1 - frame));
46 + lines.push(` ${gradientSweepFrame(row, EMBER, EMBER_GLOW, offset)}`);
47 + }
48 + }
49 + lines.push(` ${truecolor ? gradientText(WORDMARK_TAGLINE, EMBER_GLOW, EMBER) : WORDMARK_TAGLINE}`);
50 + lines.push("");
51 + return lines;
52 +}
53 +
54 +/** The compact returning-user line: `KHAELOR ▸ my-project · main` (TUI v2 §2). */
55 +export function renderCompactBrand(
56 + theme: Theme,
57 + project: string,
58 + branch: string | null,
59 +): string {
60 + const mark = theme.paintGradient("ember", "KHAELOR");
61 + const context = branch !== null ? `${project} · ${branch}` : project;
62 + return ` ${mark} ${theme.paint("dim", "▸")} ${theme.paint("text", context)}`;
63 +}
modified src/tui/theme.ts +40 −33
@@ -1,13 +1,14 @@
1 1 /**
2 2 * KHAELOR
3 3 * File: src/tui/theme.ts
4 * Description: The one default theme (khaelor-dark + light variant) — capability ladder, NO_COLOR degradation, never color-alone.
4 + * Description: The signature "khaelis" theme (obsidian + magma) + light variant — capability ladder, NO_COLOR degradation, never color-alone (TUI v2 §1).
5 5 *
6 6 * Author: Simon-Pierre Boucher
7 7 * Contact: contact@spboucher.ai
8 8 */
9 9
10 10 import type { Background, ColorDepth } from "./renderer/capabilities.js";
11 +import { mixOklch } from "./theme/gradient.js";
11 12
12 13 /**
13 14 * Style roles (TUI_DESIGN §12). Color reinforces state; it never carries it
@@ -41,7 +42,7 @@ export type StyleRole =
41 42 | "synType";
42 43
43 44 /** Named per-character truecolor gradients (fallback: solid color → identity). */
44 export type GradientName = "brand";
45 +export type GradientName = "brand" | "ember";
45 46
46 47 /** Subtle full-line background tints (truecolor only; identity elsewhere). */
47 48 export type LineTint = "add" | "del";
@@ -86,27 +87,32 @@ interface Palette {
86 87 synType: Rgb;
87 88 }
88 89
89 /** khaelor-dark — the vibrant default (TUI_DESIGN §12). */
90 +/**
91 + * khaelis — the signature dark palette: obsidian + magma (TUI v2 §1.1).
92 + * accent = ember #FF6B35, orange = ember-glow #FFB86B, teal = #2DD4BF.
93 + * Not another generic violet-cyan CLI theme — the warm accent "flows"
94 + * through phase ribbon, prompts, and gradients.
95 + */
90 96 const DARK: Palette = {
91 text: [216, 222, 233], // #d8dee9
92 dim: [107, 114, 128], // #6b7280
93 accent: [122, 162, 247], // #7aa2f7
94 accentDim: [61, 89, 161], // #3d59a1
95 success: [158, 206, 106], // #9ece6a
96 warning: [224, 175, 104], // #e0af68
97 error: [247, 118, 142], // #f7768e
97 + text: [201, 209, 227], // #c9d1e3
98 + dim: [91, 101, 122], // #5b657a
99 + accent: [255, 107, 53], // #ff6b35 — ember (magma)
100 + accentDim: [179, 73, 31], // ember, banked
101 + success: [126, 231, 135], // #7ee787
102 + warning: [240, 180, 41], // #f0b429
103 + error: [255, 92, 87], // #ff5c57
98 104 code: [154, 165, 206],
99 105 violet: [187, 154, 247], // #bb9af7
100 106 cyan: [125, 207, 255], // #7dcfff
101 107 magenta: [255, 121, 198], // #ff79c6
102 teal: [115, 218, 202], // #73daca
103 orange: [255, 158, 100], // #ff9e64
108 + teal: [45, 212, 191], // #2dd4bf — cool counterpoint to the ember
109 + orange: [255, 184, 107], // #ffb86b — ember-glow
104 110 synKeyword: [187, 154, 247], // violet
105 synString: [158, 206, 106], // green
106 synComment: [107, 114, 128], // dim
107 synNumber: [255, 158, 100], // orange
111 + synString: [126, 231, 135], // green
112 + synComment: [91, 101, 122], // dim
113 + synNumber: [255, 184, 107], // ember-glow
108 114 synFunction: [125, 207, 255], // cyan
109 synType: [42, 195, 222], // #2ac3de
115 + synType: [45, 212, 191], // teal
110 116 };
111 117
112 118 /** khaelor-light — same hue system, adjusted for light backgrounds. */
@@ -134,25 +140,25 @@ const LIGHT: Palette = {
134 140
135 141 /** ANSI-256 quantization of the two palettes (precomputed, no math at paint time). */
136 142 const DARK_256: Record<keyof Palette, number> = {
137 text: 253,
138 dim: 243,
139 accent: 111,
140 accentDim: 61,
141 success: 149,
142 warning: 179,
143 error: 211,
143 + text: 189,
144 + dim: 60,
145 + accent: 202, // ember
146 + accentDim: 130,
147 + success: 114,
148 + warning: 178,
149 + error: 203,
144 150 code: 146,
145 151 violet: 141,
146 152 cyan: 117,
147 153 magenta: 212,
148 teal: 79,
149 orange: 215,
154 + teal: 43,
155 + orange: 215, // ember-glow
150 156 synKeyword: 141,
151 synString: 149,
152 synComment: 243,
157 + synString: 114,
158 + synComment: 60,
153 159 synNumber: 215,
154 160 synFunction: 117,
155 synType: 44,
161 + synType: 43,
156 162 };
157 163
158 164 const LIGHT_256: Record<keyof Palette, number> = {
@@ -203,6 +209,9 @@ const ANSI16: Record<keyof Palette, number> = {
203 209 /** Gradient endpoints per named gradient (roles resolved against the palette). */
204 210 const GRADIENTS: Record<GradientName, [keyof Palette, keyof Palette]> = {
205 211 brand: ["violet", "cyan"],
212 + // The signature: ember → ember-glow, flowing across wordmark, phase ribbon,
213 + // active panel borders, and the context gauge (TUI v2 §1.3).
214 + ember: ["accent", "orange"],
206 215 };
207 216
208 217 /** Diff-line background tints — subtle, truecolor only. */
@@ -245,7 +254,7 @@ export interface ResolveThemeOptions {
245 254 export function resolveTheme(options: ResolveThemeOptions): Theme {
246 255 const background: Background = options.background ?? "dark";
247 256 const depth = options.colorDepth;
248 const name = background === "dark" ? "khaelor-dark" : "khaelor-light";
257 + const name = background === "dark" ? "khaelis" : "khaelor-light";
249 258
250 259 if (depth === "mono") {
251 260 return {
@@ -298,10 +307,8 @@ export function resolveTheme(options: ResolveThemeOptions): Theme {
298 307 const steps = Math.max(1, chars.length - 1);
299 308 let out = "";
300 309 for (let i = 0; i < chars.length; i++) {
301 const t = i / steps;
302 const r = Math.round(from[0] + (to[0] - from[0]) * t);
303 const g = Math.round(from[1] + (to[1] - from[1]) * t);
304 const b = Math.round(from[2] + (to[2] - from[2]) * t);
310 + // OKLCH interpolation — perceptually smooth, no muddy midpoints (TUI v2 §1.3).
311 + const [r, g, b] = mixOklch(from, to, i / steps);
305 312 out += `\x1b[38;2;${r};${g};${b}m${chars[i] as string}`;
306 313 }
307 314 return out + CLOSE_FG;
added src/tui/theme/gradient.ts +119 −0
@@ -0,0 +1,119 @@
1 +/**
2 + * KHAELOR
3 + * File: src/tui/theme/gradient.ts
4 + * Description: OKLCH gradient interpolation for truecolor text — perceptually smooth, cached per length (TUI v2 §1.3).
5 + *
6 + * Author: Simon-Pierre Boucher
7 + * Contact: contact@spboucher.ai
8 + */
9 +
10 +export type Rgb = readonly [number, number, number];
11 +
12 +// ── sRGB ↔ OKLab/OKLCH (Björn Ottosson's reference math, dependency-free) ──
13 +
14 +function srgbToLinear(channel: number): number {
15 + const c = channel / 255;
16 + return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
17 +}
18 +
19 +function linearToSrgb(channel: number): number {
20 + const c = channel <= 0.0031308 ? channel * 12.92 : 1.055 * Math.pow(channel, 1 / 2.4) - 0.055;
21 + return Math.max(0, Math.min(255, Math.round(c * 255)));
22 +}
23 +
24 +interface OkLab {
25 + L: number;
26 + a: number;
27 + b: number;
28 +}
29 +
30 +function rgbToOklab(rgb: Rgb): OkLab {
31 + const r = srgbToLinear(rgb[0]);
32 + const g = srgbToLinear(rgb[1]);
33 + const b = srgbToLinear(rgb[2]);
34 + const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b);
35 + const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b);
36 + const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b);
37 + return {
38 + L: 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s,
39 + a: 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s,
40 + b: 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s,
41 + };
42 +}
43 +
44 +function oklabToRgb(lab: OkLab): Rgb {
45 + const l = Math.pow(lab.L + 0.3963377774 * lab.a + 0.2158037573 * lab.b, 3);
46 + const m = Math.pow(lab.L - 0.1055613458 * lab.a - 0.0638541728 * lab.b, 3);
47 + const s = Math.pow(lab.L - 0.0894841775 * lab.a - 1.291485548 * lab.b, 3);
48 + return [
49 + linearToSrgb(4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s),
50 + linearToSrgb(-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s),
51 + linearToSrgb(-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s),
52 + ];
53 +}
54 +
55 +/**
56 + * Interpolate two sRGB colors through OKLCH (hue-aware polar OKLab) — no
57 + * muddy midpoints, unlike naive RGB lerp (TUI v2 §1.3).
58 + */
59 +export function mixOklch(from: Rgb, to: Rgb, t: number): Rgb {
60 + const a = rgbToOklab(from);
61 + const b = rgbToOklab(to);
62 + const chromaA = Math.hypot(a.a, a.b);
63 + const chromaB = Math.hypot(b.a, b.b);
64 + let hueA = Math.atan2(a.b, a.a);
65 + let hueB = Math.atan2(b.b, b.a);
66 + // Shortest-arc hue interpolation.
67 + if (hueB - hueA > Math.PI) hueA += 2 * Math.PI;
68 + else if (hueA - hueB > Math.PI) hueB += 2 * Math.PI;
69 + const L = a.L + (b.L - a.L) * t;
70 + const chroma = chromaA + (chromaB - chromaA) * t;
71 + const hue = hueA + (hueB - hueA) * t;
72 + return oklabToRgb({ L, a: chroma * Math.cos(hue), b: chroma * Math.sin(hue) });
73 +}
74 +
75 +const stepCache = new Map<string, Rgb[]>();
76 +
77 +/** Precomputed color ramp for a (from,to,length) triple — cached (TUI v2: no math per frame). */
78 +export function gradientSteps(from: Rgb, to: Rgb, length: number): Rgb[] {
79 + const key = `${from.join(",")}|${to.join(",")}|${length}`;
80 + const cached = stepCache.get(key);
81 + if (cached !== undefined) return cached;
82 + const steps: Rgb[] = [];
83 + const denominator = Math.max(1, length - 1);
84 + for (let i = 0; i < length; i += 1) {
85 + steps.push(mixOklch(from, to, i / denominator));
86 + }
87 + stepCache.set(key, steps);
88 + return steps;
89 +}
90 +
91 +/** Paint a plain string with a truecolor OKLCH gradient. */
92 +export function gradientText(text: string, from: Rgb, to: Rgb): string {
93 + if (text.length === 0) return text;
94 + const chars = [...text];
95 + const steps = gradientSteps(from, to, chars.length);
96 + let out = "";
97 + for (let i = 0; i < chars.length; i += 1) {
98 + const [r, g, b] = steps[i] as Rgb;
99 + out += `\x1b[38;2;${r};${g};${b}m${chars[i] as string}`;
100 + }
101 + return `${out}\x1b[39m`;
102 +}
103 +
104 +/**
105 + * One frame of the splash "gradient sweep": the ramp is phase-shifted by
106 + * `offset` characters, wrapping — advanced one step per render tick, never
107 + * by a free-running timer (TUI v2 §6 rule 1).
108 + */
109 +export function gradientSweepFrame(text: string, from: Rgb, to: Rgb, offset: number): string {
110 + if (text.length === 0) return text;
111 + const chars = [...text];
112 + const steps = gradientSteps(from, to, chars.length);
113 + let out = "";
114 + for (let i = 0; i < chars.length; i += 1) {
115 + const [r, g, b] = steps[(i + offset) % steps.length] as Rgb;
116 + out += `\x1b[38;2;${r};${g};${b}m${chars[i] as string}`;
117 + }
118 + return `${out}\x1b[39m`;
119 +}
added src/verify/config.ts +140 −0
@@ -0,0 +1,140 @@
1 +/**
2 + * KHAELOR
3 + * File: src/verify/config.ts
4 + * Description: Verification config — .khaelor/verify.json loading and project auto-detection (v2 design §4).
5 + *
6 + * Author: Simon-Pierre Boucher
7 + * Contact: contact@spboucher.ai
8 + */
9 +
10 +import type { Workspace } from "../workspace/index.js";
11 +
12 +export type VerifyPolicy = "after-each-edit-batch" | "before-final-answer" | "off";
13 +
14 +export interface VerifyCheck {
15 + name: string;
16 + cmd: string;
17 + timeoutMs: number;
18 + /** The check mutates files (e.g. eslint --fix) — run it last. */
19 + autofix: boolean;
20 +}
21 +
22 +export interface VerifyConfig {
23 + checks: VerifyCheck[];
24 + policy: VerifyPolicy;
25 + /** Bounded repair loop: at most this many failing-verify → model-repair rounds per user turn. */
26 + maxRepairLoops: number;
27 +}
28 +
29 +export const VERIFY_FILE = ".khaelor/verify.json";
30 +export const DEFAULT_MAX_REPAIR_LOOPS = 3;
31 +const DEFAULT_TIMEOUT_MS = 120_000;
32 +
33 +const POLICIES: readonly string[] = ["after-each-edit-batch", "before-final-answer", "off"];
34 +
35 +/** Parse a raw verify.json object. Invalid entries are skipped, never fatal. */
36 +export function parseVerifyConfig(raw: unknown): VerifyConfig | null {
37 + if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return null;
38 + const record = raw as Record<string, unknown>;
39 + const checks: VerifyCheck[] = [];
40 + for (const [name, value] of Object.entries(record)) {
41 + if (name === "policy" || name === "maxRepairLoops") continue;
42 + if (value === null || typeof value !== "object" || Array.isArray(value)) continue;
43 + const entry = value as Record<string, unknown>;
44 + const cmd = entry["cmd"];
45 + if (typeof cmd !== "string" || cmd.length === 0) continue;
46 + const timeout = entry["timeout"];
47 + const timeoutMs =
48 + typeof timeout === "number" && timeout > 0 ? Math.round(timeout * 1000) : DEFAULT_TIMEOUT_MS;
49 + checks.push({ name, cmd, timeoutMs, autofix: entry["autofix"] === true });
50 + }
51 + const policyRaw = record["policy"];
52 + const policy: VerifyPolicy =
53 + typeof policyRaw === "string" && POLICIES.includes(policyRaw)
54 + ? (policyRaw as VerifyPolicy)
55 + : "after-each-edit-batch";
56 + const loopsRaw = record["maxRepairLoops"];
57 + const maxRepairLoops =
58 + typeof loopsRaw === "number" && Number.isInteger(loopsRaw) && loopsRaw >= 0
59 + ? loopsRaw
60 + : DEFAULT_MAX_REPAIR_LOOPS;
61 + return { checks, policy, maxRepairLoops };
62 +}
63 +
64 +async function readJson(workspace: Workspace, path: string): Promise<unknown | null> {
65 + try {
66 + return JSON.parse(await workspace.readFile(path)) as unknown;
67 + } catch {
68 + return null;
69 + }
70 +}
71 +
72 +async function exists(workspace: Workspace, path: string): Promise<boolean> {
73 + try {
74 + await workspace.readFile(path);
75 + return true;
76 + } catch {
77 + return false;
78 + }
79 +}
80 +
81 +/**
82 + * Auto-detect checks from the project (package.json scripts, tsconfig,
83 + * Cargo.toml, pyproject.toml). Best-effort and conservative: only well-known
84 + * commands, never destructive ones.
85 + */
86 +export async function detectVerifyChecks(workspace: Workspace): Promise<VerifyCheck[]> {
87 + const cwd = workspace.cwd();
88 + const checks: VerifyCheck[] = [];
89 +
90 + const pkg = await readJson(workspace, `${cwd}/package.json`);
91 + if (pkg !== null && typeof pkg === "object" && !Array.isArray(pkg)) {
92 + const scripts = (pkg as Record<string, unknown>)["scripts"];
93 + const scriptSet =
94 + scripts !== null && typeof scripts === "object" && !Array.isArray(scripts)
95 + ? (scripts as Record<string, unknown>)
96 + : {};
97 + if (typeof scriptSet["typecheck"] === "string") {
98 + checks.push({ name: "typecheck", cmd: "npm run typecheck", timeoutMs: DEFAULT_TIMEOUT_MS, autofix: false });
99 + } else if (await exists(workspace, `${cwd}/tsconfig.json`)) {
100 + checks.push({ name: "typecheck", cmd: "npx tsc --noEmit", timeoutMs: DEFAULT_TIMEOUT_MS, autofix: false });
101 + }
102 + if (typeof scriptSet["test"] === "string") {
103 + checks.push({ name: "test", cmd: "npm test", timeoutMs: 300_000, autofix: false });
104 + }
105 + if (typeof scriptSet["lint"] === "string") {
106 + checks.push({ name: "lint", cmd: "npm run lint", timeoutMs: DEFAULT_TIMEOUT_MS, autofix: false });
107 + }
108 + return checks;
109 + }
110 +
111 + if (await exists(workspace, `${cwd}/Cargo.toml`)) {
112 + checks.push({ name: "check", cmd: "cargo check", timeoutMs: 300_000, autofix: false });
113 + checks.push({ name: "test", cmd: "cargo test", timeoutMs: 600_000, autofix: false });
114 + return checks;
115 + }
116 +
117 + if (await exists(workspace, `${cwd}/pyproject.toml`)) {
118 + checks.push({ name: "test", cmd: "python3 -m pytest -x -q", timeoutMs: 300_000, autofix: false });
119 + return checks;
120 + }
121 +
122 + return checks;
123 +}
124 +
125 +/**
126 + * Load the effective verify config: .khaelor/verify.json when present,
127 + * auto-detection otherwise (v2 §4 — surchargeable).
128 + */
129 +export async function loadVerifyConfig(workspace: Workspace): Promise<VerifyConfig> {
130 + const raw = await readJson(workspace, `${workspace.cwd()}/${VERIFY_FILE}`);
131 + if (raw !== null) {
132 + const parsed = parseVerifyConfig(raw);
133 + if (parsed !== null) return parsed;
134 + }
135 + return {
136 + checks: await detectVerifyChecks(workspace),
137 + policy: "after-each-edit-batch",
138 + maxRepairLoops: DEFAULT_MAX_REPAIR_LOOPS,
139 + };
140 +}
added src/verify/index.ts +19 −0
@@ -0,0 +1,19 @@
1 +/**
2 + * KHAELOR
3 + * File: src/verify/index.ts
4 + * Description: Public surface of the native-verification module (v2 design §4).
5 + *
6 + * Author: Simon-Pierre Boucher
7 + * Contact: contact@spboucher.ai
8 + */
9 +
10 +export {
11 + DEFAULT_MAX_REPAIR_LOOPS,
12 + VERIFY_FILE,
13 + detectVerifyChecks,
14 + loadVerifyConfig,
15 + parseVerifyConfig,
16 +} from "./config.js";
17 +export type { VerifyCheck, VerifyConfig, VerifyPolicy } from "./config.js";
18 +export { VerifyRunner, countRepairFailures, truncateErrorsFirst } from "./runner.js";
19 +export type { VerifyOutcome, VerifyRunnerOptions, VerifySessionHandle } from "./runner.js";
added src/verify/runner.ts +148 −0
@@ -0,0 +1,148 @@
1 +/**
2 + * KHAELOR
3 + * File: src/verify/runner.ts
4 + * Description: Native verification runner — parallel checks, errors-first truncation, bounded repair loop accounting (v2 design §4).
5 + *
6 + * Author: Simon-Pierre Boucher
7 + * Contact: contact@spboucher.ai
8 + */
9 +
10 +import type { DurableEvent, DurableEventInput } from "../session/index.js";
11 +import type { Workspace } from "../workspace/index.js";
12 +import type { VerifyCheck, VerifyConfig } from "./config.js";
13 +
14 +/** The session seam the runner needs — satisfied by EventLogSession. */
15 +export interface VerifySessionHandle {
16 + events(): readonly DurableEvent[];
17 + publishDurable(input: DurableEventInput): unknown;
18 +}
19 +
20 +export interface VerifyOutcome {
21 + ok: boolean;
22 + results: { check: string; ok: boolean; exitCode: number | null; durationMs: number }[];
23 +}
24 +
25 +const OUTPUT_BUDGET = 4000;
26 +const ERROR_LINE = /error|fail||FAIL|Err\b|exception/i;
27 +
28 +/**
29 + * Errors-first truncation: lines that look like failures are kept ahead of
30 + * everything else, then head-fill up to the budget (v2 §4 — "tronqué
31 + * intelligemment: erreurs d'abord").
32 + */
33 +export function truncateErrorsFirst(output: string, budget = OUTPUT_BUDGET): string {
34 + if (output.length <= budget) return output;
35 + const lines = output.split("\n");
36 + const errorLines: string[] = [];
37 + const otherLines: string[] = [];
38 + for (const line of lines) {
39 + if (ERROR_LINE.test(line)) errorLines.push(line);
40 + else otherLines.push(line);
41 + }
42 + const kept: string[] = [];
43 + let used = 0;
44 + for (const line of [...errorLines, ...otherLines]) {
45 + if (used + line.length + 1 > budget) break;
46 + kept.push(line);
47 + used += line.length + 1;
48 + }
49 + return `${kept.join("\n")}\n[... verify output truncated: errors shown first]`;
50 +}
51 +
52 +/** Count failing verify results since the last user message — the repair-loop bound. */
53 +export function countRepairFailures(events: readonly DurableEvent[]): number {
54 + let count = 0;
55 + for (const event of events) {
56 + if (event.type === "user.message-created") count = 0;
57 + else if (event.type === "verify.result" && !event.payload.ok) count += 1;
58 + }
59 + return count;
60 +}
61 +
62 +export interface VerifyRunnerOptions {
63 + workspace: Workspace;
64 + session: VerifySessionHandle;
65 + config: VerifyConfig;
66 +}
67 +
68 +/**
69 + * Runs the configured checks through the workspace seam, publishes one
70 + * durable `verify.result` per check, and enforces the bounded repair loop:
71 + * once `maxRepairLoops` failing rounds have been recorded since the last
72 + * user message, the runner stops re-running — the agent reports honestly
73 + * instead of looping (v2 §4).
74 + */
75 +export class VerifyRunner {
76 + readonly #workspace: Workspace;
77 + readonly #session: VerifySessionHandle;
78 + readonly #config: VerifyConfig;
79 +
80 + constructor(options: VerifyRunnerOptions) {
81 + this.#workspace = options.workspace;
82 + this.#session = options.session;
83 + this.#config = options.config;
84 + }
85 +
86 + get policy(): VerifyConfig["policy"] {
87 + return this.#config.policy;
88 + }
89 +
90 + get hasChecks(): boolean {
91 + return this.#config.checks.length > 0;
92 + }
93 +
94 + /** True when another failing round is still within the repair budget. */
95 + withinRepairBudget(): boolean {
96 + return countRepairFailures(this.#session.events()) < this.#config.maxRepairLoops;
97 + }
98 +
99 + /** Run all checks (autofix checks last, serially) and record the results. */
100 + async runAll(signal?: AbortSignal): Promise<VerifyOutcome> {
101 + const parallel = this.#config.checks.filter((check) => !check.autofix);
102 + const fixers = this.#config.checks.filter((check) => check.autofix);
103 +
104 + const results: VerifyOutcome["results"] = [];
105 + const settled = await Promise.all(parallel.map((check) => this.#runOne(check, signal)));
106 + results.push(...settled);
107 + for (const fixer of fixers) {
108 + if (signal?.aborted === true) break;
109 + results.push(await this.#runOne(fixer, signal));
110 + }
111 + return { ok: results.every((result) => result.ok), results };
112 + }
113 +
114 + async #runOne(
115 + check: VerifyCheck,
116 + signal?: AbortSignal,
117 + ): Promise<VerifyOutcome["results"][number]> {
118 + const startedAt = Date.now();
119 + let exitCode: number | null = null;
120 + let output = "";
121 + try {
122 + const result = await this.#workspace.exec({
123 + cmd: check.cmd,
124 + timeoutMs: check.timeoutMs,
125 + ...(signal !== undefined ? { signal } : {}),
126 + });
127 + exitCode = result.exitCode;
128 + output = [result.stdout, result.stderr].filter((part) => part.length > 0).join("\n");
129 + } catch (error) {
130 + exitCode = null;
131 + output = error instanceof Error ? error.message : String(error);
132 + }
133 + const ok = exitCode === 0;
134 + const durationMs = Date.now() - startedAt;
135 + this.#session.publishDurable({
136 + type: "verify.result",
137 + payload: {
138 + check: check.name,
139 + command: check.cmd,
140 + ok,
141 + exitCode,
142 + output: ok ? "" : truncateErrorsFirst(output),
143 + durationMs,
144 + },
145 + });
146 + return { check: check.name, ok, exitCode, durationMs };
147 + }
148 +}
added tests/daemon/daemon.test.ts +206 −0
@@ -0,0 +1,206 @@
1 +/**
2 + * KHAELOR
3 + * File: tests/daemon/daemon.test.ts
4 + * Description: Daemon unit tests — cron parsing, active hours, goal store, budget guard, approvals, scheduler decisions (v2 §7).
5 + *
6 + * Author: Simon-Pierre Boucher
7 + * Contact: contact@spboucher.ai
8 + */
9 +
10 +import { mkdtempSync } from "node:fs";
11 +import { tmpdir } from "node:os";
12 +import { join } from "node:path";
13 +import { describe, expect, it } from "vitest";
14 +import {
15 + ApprovalQueue,
16 + BudgetGuard,
17 + ChannelRouter,
18 + GoalStore,
19 + KhaelorDaemon,
20 + nextCronRun,
21 + parseActiveHours,
22 + parseCron,
23 + withinActiveHours,
24 +} from "../../src/daemon/index.js";
25 +import { costFromUsage } from "../../src/daemon/runner.js";
26 +
27 +describe("cron", () => {
28 + it("parses */30 and computes the next run", () => {
29 + const spec = parseCron("*/30 * * * *");
30 + expect(spec).not.toBeNull();
31 + const next = nextCronRun(spec!, new Date(2026, 7, 10, 12, 10));
32 + expect(next?.getMinutes()).toBe(30);
33 + expect(next?.getHours()).toBe(12);
34 + });
35 +
36 + it("parses '0 6 * * 1' as Monday 06:00", () => {
37 + const spec = parseCron("0 6 * * 1");
38 + // 2026-08-10 is a Monday.
39 + const next = nextCronRun(spec!, new Date(2026, 7, 10, 7, 0));
40 + expect(next?.getDay()).toBe(1);
41 + expect(next?.getHours()).toBe(6);
42 + expect(next?.getDate()).toBe(17);
43 + });
44 +
45 + it("rejects malformed expressions", () => {
46 + expect(parseCron("nope")).toBeNull();
47 + expect(parseCron("61 * * * *")).toBeNull();
48 + expect(parseCron("* * * *")).toBeNull();
49 + });
50 +
51 + it("active hours window (incl. wrapping)", () => {
52 + const hours = parseActiveHours("07:00-23:00");
53 + expect(withinActiveHours(hours, new Date(2026, 7, 10, 12, 0))).toBe(true);
54 + expect(withinActiveHours(hours, new Date(2026, 7, 10, 3, 0))).toBe(false);
55 + const night = parseActiveHours("22:00-06:00");
56 + expect(withinActiveHours(night, new Date(2026, 7, 10, 23, 30))).toBe(true);
57 + expect(withinActiveHours(night, new Date(2026, 7, 10, 12, 0))).toBe(false);
58 + expect(withinActiveHours(null, new Date())).toBe(true);
59 + });
60 +});
61 +
62 +describe("GoalStore", () => {
63 + it("creates, lists, and event-sources goals", async () => {
64 + const store = new GoalStore(mkdtempSync(join(tmpdir(), "khaelor-daemon-")));
65 + const goal = await store.create({
66 + description: "keep deps fresh",
67 + type: "maintain",
68 + schedule: "0 6 * * 1",
69 + escalation: "draft-pr",
70 + budget: { maxUsdPerDay: 5 },
71 + });
72 + expect(goal.budget.maxUsdPerDay).toBe(5);
73 + expect(goal.status).toBe("active");
74 + const listed = await store.list();
75 + expect(listed).toHaveLength(1);
76 + const events = await store.events(goal.id);
77 + expect(events[0]?.type).toBe("goal.created");
78 +
79 + await store.appendEvent(goal.id, { ts: Date.now(), type: "run.started", payload: { runId: "r1" } });
80 + expect(await store.runsToday(goal.id)).toBe(1);
81 +
82 + await store.setStatus(goal.id, "paused");
83 + expect((await store.get(goal.id))?.status).toBe("paused");
84 + });
85 +});
86 +
87 +describe("BudgetGuard", () => {
88 + it("enforces daemon and per-goal daily ceilings from recorded real costs", async () => {
89 + const guard = new BudgetGuard(mkdtempSync(join(tmpdir(), "khaelor-budget-")), {
90 + maxUsdPerDay: 10,
91 + maxUsdPerRun: 3,
92 + hardStop: true,
93 + });
94 + await guard.load();
95 + expect(guard.canStart("g1", 5).ok).toBe(true);
96 + await guard.record("g1", 5);
97 + expect(guard.canStart("g1", 5).ok).toBe(false); // goal ceiling
98 + expect(guard.canStart("g2", 5).ok).toBe(true);
99 + await guard.record("g2", 5);
100 + expect(guard.canStart("g3", 5).ok).toBe(false); // daemon ceiling
101 + expect(guard.spentToday()).toBe(10);
102 + });
103 +});
104 +
105 +describe("ApprovalQueue", () => {
106 + it("persists requests and resolves them once", async () => {
107 + const dir = mkdtempSync(join(tmpdir(), "khaelor-approvals-"));
108 + const queue = new ApprovalQueue(dir);
109 + const entry = await queue.request({
110 + runId: "r1",
111 + goalId: "g1",
112 + capability: "bash:git push",
113 + context: "Draft PR, diff +42 −13, verify ✓",
114 + });
115 + expect((await queue.list("pending"))).toHaveLength(1);
116 + const resolved = await queue.resolve(entry.id, "approved");
117 + expect(resolved?.status).toBe("approved");
118 + expect(await queue.resolve(entry.id, "denied")).toBeNull();
119 + // A fresh instance reads the same file (persistence).
120 + const reloaded = new ApprovalQueue(dir);
121 + expect((await reloaded.list("approved"))).toHaveLength(1);
122 + });
123 +});
124 +
125 +describe("costFromUsage", () => {
126 + it("prices real usage only when pricing is configured", () => {
127 + const usage = { inputTokens: 1_000_000, outputTokens: 500_000, cacheReadTokens: 0, cacheWriteTokens: 0 };
128 + expect(costFromUsage(usage, undefined)).toBe(0);
129 + expect(
130 + costFromUsage(usage, { inputPerMTok: 3, outputPerMTok: 15, cacheReadPerMTok: 0.3, cacheWritePerMTok: 3.75 }),
131 + ).toBeCloseTo(3 + 7.5);
132 + });
133 +});
134 +
135 +describe("KhaelorDaemon tick", () => {
136 + it("skips a goal whose check passes and runs one whose check fails", async () => {
137 + const daemonDir = mkdtempSync(join(tmpdir(), "khaelor-tick-"));
138 + const goals = new GoalStore(daemonDir);
139 + const passing = await goals.create({ description: "all green", type: "watch", schedule: "heartbeat", check: "true" });
140 + const failing = await goals.create({ description: "needs work", type: "watch", schedule: "heartbeat", check: "false" });
141 +
142 + const ran: string[] = [];
143 + const daemon = new KhaelorDaemon({
144 + daemonDir,
145 + config: { budget: { maxUsdPerDay: 10, maxUsdPerRun: 3, hardStop: true }, heartbeatMinutes: 30, model: {}, channels: {} },
146 + goals,
147 + budget: new BudgetGuard(daemonDir),
148 + approvals: new ApprovalQueue(daemonDir),
149 + channels: new ChannelRouter([]),
150 + runGoal: (goal, runId) => {
151 + ran.push(goal.id);
152 + return Promise.resolve({
153 + outcome: "done" as const,
154 + detail: `run ${runId}`,
155 + costUsd: 0.5,
156 + sessionId: "s-child",
157 + verifyOk: true,
158 + branch: null,
159 + });
160 + },
161 + execCheck: (cmd) => Promise.resolve({ exitCode: cmd === "true" ? 0 : 1 }),
162 + log: () => undefined,
163 + });
164 +
165 + await daemon.tick(new Date(2026, 7, 10, 12, 0));
166 + expect(ran).toEqual([failing.id]);
167 +
168 + const skipEvents = await goals.events(passing.id);
169 + expect(skipEvents.some((event) => event.type === "run.skipped")).toBe(true);
170 + const runEvents = await goals.events(failing.id);
171 + expect(runEvents.some((event) => event.type === "run.started")).toBe(true);
172 + expect(runEvents.some((event) => event.type === "run.completed")).toBe(true);
173 + });
174 +
175 + it("stops running when the goal's daily run budget is exhausted", async () => {
176 + const daemonDir = mkdtempSync(join(tmpdir(), "khaelor-tick2-"));
177 + const goals = new GoalStore(daemonDir);
178 + const goal = await goals.create({
179 + description: "hungry goal",
180 + type: "watch",
181 + schedule: "heartbeat",
182 + check: "false",
183 + budget: { maxRunsPerDay: 1 },
184 + });
185 + let runs = 0;
186 + const daemon = new KhaelorDaemon({
187 + daemonDir,
188 + config: { budget: { maxUsdPerDay: 100, maxUsdPerRun: 3, hardStop: true }, heartbeatMinutes: 0, model: {}, channels: {} },
189 + goals,
190 + budget: new BudgetGuard(daemonDir),
191 + approvals: new ApprovalQueue(daemonDir),
192 + channels: new ChannelRouter([]),
193 + runGoal: () => {
194 + runs += 1;
195 + return Promise.resolve({ outcome: "done" as const, detail: "", costUsd: 0, sessionId: null, verifyOk: null, branch: null });
196 + },
197 + execCheck: () => Promise.resolve({ exitCode: 1 }),
198 + log: () => undefined,
199 + });
200 + await daemon.tick(new Date(2026, 7, 10, 12, 0));
201 + await daemon.tick(new Date(2026, 7, 10, 13, 0));
202 + expect(runs).toBe(1);
203 + const events = await goals.events(goal.id);
204 + expect(events.some((event) => event.type === "run.skipped" && String(event.payload["reason"]).includes("maxRunsPerDay"))).toBe(true);
205 + });
206 +});
added tests/memory/memory.test.ts +67 −0
@@ -0,0 +1,67 @@
1 +/**
2 + * KHAELOR
3 + * File: tests/memory/memory.test.ts
4 + * Description: Project-memory tests — parse/append round-trip, provenance anchors, purge candidates (v2 §5).
5 + *
6 + * Author: Simon-Pierre Boucher
7 + * Contact: contact@spboucher.ai
8 + */
9 +
10 +import { describe, expect, it } from "vitest";
11 +import { appendMemoryEntry, parseMemory, purgeCandidates } from "../../src/memory/index.js";
12 +
13 +const PROVENANCE = {
14 + session: "01ABCDEF",
15 + tool: "toolu_1",
16 + confidence: "high" as const,
17 + date: "2026-08-10",
18 +};
19 +
20 +describe("project memory", () => {
21 + it("creates the file with a header and section on first append", () => {
22 + const content = appendMemoryEntry(null, {
23 + section: "conventions",
24 + text: "Errors flow through Result<T, KError>, never throw in src/core.",
25 + provenance: PROVENANCE,
26 + });
27 + expect(content).toContain("# Project memory");
28 + expect(content).toContain("## Conventions");
29 + expect(content).toContain("- Errors flow through Result");
30 + expect(content).toContain("<!-- khaelor: session=01ABCDEF tool=toolu_1 confidence=high date=2026-08-10 -->");
31 + });
32 +
33 + it("round-trips entries with provenance through parseMemory", () => {
34 + let content = appendMemoryEntry(null, { section: "commands", text: "Build: npm run check", provenance: PROVENANCE });
35 + content = appendMemoryEntry(content, {
36 + section: "pitfalls",
37 + text: "Never touch the render buffer outside the 16ms tick.",
38 + provenance: { ...PROVENANCE, confidence: "medium" },
39 + });
40 + const entries = parseMemory(content);
41 + expect(entries).toHaveLength(2);
42 + expect(entries[0]?.section).toBe("Commands");
43 + expect(entries[0]?.provenance?.confidence).toBe("high");
44 + expect(entries[1]?.section).toBe("Pitfalls");
45 + expect(entries[1]?.provenance?.tool).toBe("toolu_1");
46 + });
47 +
48 + it("appends into an existing section, not a duplicate one", () => {
49 + let content = appendMemoryEntry(null, { section: "commands", text: "Build: npm run check", provenance: PROVENANCE });
50 + content = appendMemoryEntry(content, { section: "commands", text: "Test: npm test", provenance: PROVENANCE });
51 + expect(content.match(/## Commands/g)).toHaveLength(1);
52 + const entries = parseMemory(content);
53 + expect(entries.filter((entry) => entry.section === "Commands")).toHaveLength(2);
54 + });
55 +
56 + it("flags low-confidence entries as purge candidates (v2 §5.4)", () => {
57 + let content = appendMemoryEntry(null, { section: "notes", text: "Solid fact.", provenance: PROVENANCE });
58 + content = appendMemoryEntry(content, {
59 + section: "notes",
60 + text: "Shaky guess.",
61 + provenance: { ...PROVENANCE, confidence: "low" },
62 + });
63 + const candidates = purgeCandidates(parseMemory(content));
64 + expect(candidates).toHaveLength(1);
65 + expect(candidates[0]?.text).toContain("Shaky guess");
66 + });
67 +});
added tests/phases/phases.test.ts +190 −0
@@ -0,0 +1,190 @@
1 +/**
2 + * KHAELOR
3 + * File: tests/phases/phases.test.ts
4 + * Description: Phase-gate tests — state fold, per-phase capability policy, service transitions and approvals (v2 §1).
5 + *
6 + * Author: Simon-Pierre Boucher
7 + * Contact: contact@spboucher.ai
8 + */
9 +
10 +import { describe, expect, it } from "vitest";
11 +import { checkPhaseGate, foldPhaseState, PhaseService, PHASE_GATE_BLOCKED } from "../../src/phases/index.js";
12 +import type { CapabilityRequest } from "../../src/permissions/index.js";
13 +import type { DesignArtifact, DurableEvent, DurableEventInput } from "../../src/session/index.js";
14 +
15 +const ROOT = "/repo";
16 +
17 +function request(capability: CapabilityRequest["capability"], subject: string): CapabilityRequest {
18 + return { capability, subject, display: subject, alwaysPatterns: [], riskNotes: [] };
19 +}
20 +
21 +function artifact(files: string[]): DesignArtifact {
22 + return {
23 + goal: "fix it",
24 + filesTouched: files,
25 + approach: "carefully",
26 + risks: [],
27 + verification: "run tests",
28 + outOfScope: [],
29 + };
30 +}
31 +
32 +/** Tiny in-memory session satisfying PhaseSessionHandle. */
33 +function fakeSession(): {
34 + events(): readonly DurableEvent[];
35 + publishDurable(input: DurableEventInput): DurableEvent;
36 +} {
37 + const events: DurableEvent[] = [];
38 + let seq = 0;
39 + return {
40 + events: () => events,
41 + publishDurable(input: DurableEventInput): DurableEvent {
42 + seq += 1;
43 + const event = {
44 + v: 1,
45 + id: `e${seq}`,
46 + sessionId: "s1",
47 + seq,
48 + ts: seq,
49 + type: input.type,
50 + payload: input.payload,
51 + } as DurableEvent;
52 + events.push(event);
53 + return event;
54 + },
55 + };
56 +}
57 +
58 +describe("foldPhaseState", () => {
59 + it("starts in understand with nothing pending", () => {
60 + const state = foldPhaseState([]);
61 + expect(state.phase).toBe("understand");
62 + expect(state.pendingArtifact).toBeNull();
63 + expect(state.designApproved).toBe(false);
64 + });
65 +
66 + it("tracks entered/artifact/approved through the stream", () => {
67 + const session = fakeSession();
68 + session.publishDurable({ type: "phase.entered", payload: { phase: "design", via: "design-submitted" } });
69 + session.publishDurable({
70 + type: "phase.artifact",
71 + payload: { artifactId: "a1", artifact: artifact(["src/a.ts"]) },
72 + });
73 + let state = foldPhaseState(session.events());
74 + expect(state.phase).toBe("design");
75 + expect(state.pendingArtifact?.artifactId).toBe("a1");
76 +
77 + session.publishDurable({
78 + type: "phase.approved",
79 + payload: { phase: "design", approvedBy: "auto-policy", artifactId: "a1" },
80 + });
81 + session.publishDurable({ type: "phase.entered", payload: { phase: "implement", via: "approval" } });
82 + state = foldPhaseState(session.events());
83 + expect(state.phase).toBe("implement");
84 + expect(state.designApproved).toBe(true);
85 + expect(state.pendingArtifact).toBeNull();
86 + expect(state.approvedArtifactId).toBe("a1");
87 + });
88 +});
89 +
90 +describe("checkPhaseGate", () => {
91 + it("implement allows everything", () => {
92 + expect(checkPhaseGate("implement", [request("file.write.project", "/repo/src/a.ts")], ROOT).allowed).toBe(true);
93 + });
94 +
95 + it("understand allows reads and readonly commands only", () => {
96 + expect(checkPhaseGate("understand", [request("file.read", "/repo/src/a.ts")], ROOT).allowed).toBe(true);
97 + expect(checkPhaseGate("understand", [request("process.execute", "git log --oneline")], ROOT).allowed).toBe(true);
98 + const blocked = checkPhaseGate("understand", [request("process.execute", "npm install")], ROOT);
99 + expect(blocked.allowed).toBe(false);
100 + if (!blocked.allowed) expect(blocked.feedback).toContain(PHASE_GATE_BLOCKED);
101 + });
102 +
103 + it("blocks writes in understand, allows design docs in design", () => {
104 + expect(checkPhaseGate("understand", [request("file.write.project", "/repo/src/a.ts")], ROOT).allowed).toBe(false);
105 + expect(checkPhaseGate("design", [request("file.write.project", "/repo/docs/design/plan.md")], ROOT).allowed).toBe(true);
106 + expect(checkPhaseGate("design", [request("file.write.project", "/repo/src/a.ts")], ROOT).allowed).toBe(false);
107 + });
108 +
109 + it("project memory is writable in every phase (v2 §5)", () => {
110 + expect(
111 + checkPhaseGate("understand", [request("file.write.project", "/repo/.khaelor/MEMORY.md")], ROOT).allowed,
112 + ).toBe(true);
113 + });
114 +
115 + it("git.modify and network stay locked before implement", () => {
116 + expect(checkPhaseGate("design", [request("git.modify", "git push")], ROOT).allowed).toBe(false);
117 + expect(checkPhaseGate("design", [request("network.access", "curl https://x")], ROOT).allowed).toBe(false);
118 + });
119 +});
120 +
121 +describe("PhaseService", () => {
122 + it("auto mode self-approves small designs and unlocks implement", async () => {
123 + const session = fakeSession();
124 + const service = new PhaseService({
125 + session,
126 + config: { mode: "auto", autoApprove: { maxFiles: 3 } },
127 + projectRoot: ROOT,
128 + });
129 + service.ensureStarted();
130 + expect(service.current()).toBe("understand");
131 +
132 + const decision = await service.submitDesign(artifact(["a.ts", "b.ts"]));
133 + expect(decision.status).toBe("approved");
134 + expect(service.current()).toBe("implement");
135 + const types = session.events().map((event) => event.type);
136 + expect(types).toContain("phase.artifact");
137 + expect(types).toContain("phase.approved");
138 + });
139 +
140 + it("auto mode above the threshold stays pending without an asker", async () => {
141 + const session = fakeSession();
142 + const service = new PhaseService({
143 + session,
144 + config: { mode: "auto", autoApprove: { maxFiles: 1 } },
145 + projectRoot: ROOT,
146 + });
147 + const decision = await service.submitDesign(artifact(["a.ts", "b.ts"]));
148 + expect(decision.status).toBe("pending");
149 + expect(service.current()).toBe("design");
150 + });
151 +
152 + it("strict mode asks; rejection records phase.rejected", async () => {
153 + const session = fakeSession();
154 + const service = new PhaseService({
155 + session,
156 + config: { mode: "strict", autoApprove: { maxFiles: 3 } },
157 + projectRoot: ROOT,
158 + asker: { askDesign: () => Promise.resolve({ approved: false, reason: "too vague" }) },
159 + });
160 + const decision = await service.submitDesign(artifact(["a.ts"]));
161 + expect(decision.status).toBe("rejected");
162 + expect(session.events().some((event) => event.type === "phase.rejected")).toBe(true);
163 + expect(service.current()).toBe("design");
164 + });
165 +
166 + it("off mode reports implement and allows everything", () => {
167 + const service = new PhaseService({
168 + session: fakeSession(),
169 + config: { mode: "off", autoApprove: { maxFiles: 3 } },
170 + projectRoot: ROOT,
171 + });
172 + expect(service.current()).toBe("implement");
173 + expect(service.checkToolCall([request("file.write.project", "/repo/a.ts")]).allowed).toBe(true);
174 + });
175 +
176 + it("forcePhase to implement records the user override", () => {
177 + const session = fakeSession();
178 + const service = new PhaseService({
179 + session,
180 + config: { mode: "strict", autoApprove: { maxFiles: 3 } },
181 + projectRoot: ROOT,
182 + });
183 + service.forcePhase("implement");
184 + expect(service.current()).toBe("implement");
185 + const approved = session.events().find((event) => event.type === "phase.approved");
186 + expect(approved !== undefined && approved.type === "phase.approved" ? approved.payload.approvedBy : "").toBe(
187 + "user-override",
188 + );
189 + });
190 +});
added tests/repograph/repograph.test.ts +107 −0
@@ -0,0 +1,107 @@
1 +/**
2 + * KHAELOR
3 + * File: tests/repograph/repograph.test.ts
4 + * Description: RepoGraph tests — TS/Python extraction, symbol queries, refs directions, skeletons (v2 §3).
5 + *
6 + * Author: Simon-Pierre Boucher
7 + * Contact: contact@spboucher.ai
8 + */
9 +
10 +import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
11 +import { tmpdir } from "node:os";
12 +import { join } from "node:path";
13 +import { describe, expect, it } from "vitest";
14 +import { extractFile, RepoGraphService } from "../../src/repograph/index.js";
15 +import { LocalWorkspace } from "../../src/workspace/index.js";
16 +
17 +const TS_SAMPLE = `/**
18 + * Handles authentication.
19 + */
20 +export async function handleAuth(token: string): Promise<boolean> {
21 + return verifyToken(token);
22 +}
23 +
24 +export class AuthController {
25 + /** Login entrypoint. */
26 + async login(user: string): Promise<void> {
27 + await handleAuth(user);
28 + }
29 +}
30 +
31 +export interface Session { id: string }
32 +export type AuthResult = boolean;
33 +const secret = "x";
34 +import { verifyToken } from "./token.js";
35 +`;
36 +
37 +describe("extractFile (TS)", () => {
38 + it("extracts functions, classes, methods, types, and imports", () => {
39 + const result = extractFile("src/auth.ts", TS_SAMPLE);
40 + const names = result.symbols.map((symbol) => `${symbol.kind}:${symbol.name}`);
41 + expect(names).toContain("function:handleAuth");
42 + expect(names).toContain("class:AuthController");
43 + expect(names).toContain("method:login");
44 + expect(names).toContain("type:Session");
45 + expect(names).toContain("type:AuthResult");
46 + expect(names).toContain("variable:secret");
47 + const auth = result.symbols.find((symbol) => symbol.name === "handleAuth");
48 + expect(auth?.docComment).toContain("Handles authentication");
49 + expect(auth?.exported).toBe(true);
50 + expect(result.imports[0]?.spec).toBe("./token.js");
51 + expect(result.imports[0]?.names).toContain("verifyToken");
52 + });
53 +});
54 +
55 +describe("extractFile (Python)", () => {
56 + it("extracts defs, classes, and imports", () => {
57 + const result = extractFile("app.py", "import os\nfrom flask import Flask\n\nclass App:\n def run(self):\n pass\n\ndef main():\n pass\n");
58 + const names = result.symbols.map((symbol) => `${symbol.kind}:${symbol.name}`);
59 + expect(names).toContain("class:App");
60 + expect(names).toContain("method:run");
61 + expect(names).toContain("function:main");
62 + expect(result.imports.map((imp) => imp.spec)).toEqual(["os", "flask"]);
63 + });
64 +});
65 +
66 +describe("RepoGraphService", () => {
67 + function makeRepo(): string {
68 + const dir = mkdtempSync(join(tmpdir(), "khaelor-repograph-"));
69 + mkdirSync(join(dir, "src"), { recursive: true });
70 + writeFileSync(join(dir, "src", "auth.ts"), TS_SAMPLE);
71 + writeFileSync(
72 + join(dir, "src", "caller.ts"),
73 + `import { handleAuth } from "./auth.js";\nexport function guard(): void {\n void handleAuth("t");\n}\n`,
74 + );
75 + return dir;
76 + }
77 +
78 + it("answers symbol queries with wildcards, kind, and scope filters", async () => {
79 + const service = new RepoGraphService({ workspace: new LocalWorkspace(makeRepo()) });
80 + const exact = await service.querySymbols("handleAuth");
81 + expect(exact).toHaveLength(1);
82 + expect(exact[0]?.file).toBe("src/auth.ts");
83 + const wildcard = await service.querySymbols("*Controller", "class");
84 + expect(wildcard.map((hit) => hit.symbol)).toEqual(["AuthController"]);
85 + const shorthand = await service.querySymbols("class:*Controller");
86 + expect(shorthand).toHaveLength(1);
87 + const scoped = await service.querySymbols("handleAuth", undefined, "docs/**");
88 + expect(scoped).toHaveLength(0);
89 + });
90 +
91 + it("finds importers and callers of a symbol", async () => {
92 + const service = new RepoGraphService({ workspace: new LocalWorkspace(makeRepo()) });
93 + const importers = await service.queryRefs("handleAuth", "importers");
94 + expect(importers.some((hit) => hit.file === "src/caller.ts")).toBe(true);
95 + const callers = await service.queryRefs("handleAuth", "callers");
96 + expect(callers.some((hit) => hit.file === "src/caller.ts" && hit.context.includes("handleAuth"))).toBe(true);
97 + });
98 +
99 + it("produces a token-cheap skeleton with line anchors", async () => {
100 + const service = new RepoGraphService({ workspace: new LocalWorkspace(makeRepo()) });
101 + const skeleton = await service.skeleton("src/auth.ts");
102 + expect(skeleton).not.toBeNull();
103 + expect(skeleton).toContain("export async function handleAuth");
104 + expect(skeleton).toContain("re-read the file if you need bodies");
105 + expect((skeleton as string).length).toBeLessThan(TS_SAMPLE.length * 2);
106 + });
107 +});
modified tests/session/events.test.ts +3 −3
@@ -19,8 +19,8 @@ import {
19 19 import { envelope, ephemeralEnvelope, sampleDurableInputs, sampleEphemeralInputs } from "./fixtures.js";
20 20
21 21 describe("event catalog", () => {
22 it("has exactly 32 durable and 6 ephemeral event types (EVENT_MODEL.md §3)", () => {
23 expect(DURABLE_EVENT_TYPES.size).toBe(32);
22 + it("has exactly 40 durable and 6 ephemeral event types (EVENT_MODEL.md §3 + v2 additions)", () => {
23 + expect(DURABLE_EVENT_TYPES.size).toBe(40);
24 24 expect(EPHEMERAL_EVENT_TYPES.size).toBe(6);
25 25 });
26 26
@@ -32,7 +32,7 @@ describe("event catalog", () => {
32 32
33 33 it("fixtures cover every catalog type exactly once", () => {
34 34 const durableTypes = sampleDurableInputs().map((i) => i.type);
35 expect(new Set(durableTypes).size).toBe(32);
35 + expect(new Set(durableTypes).size).toBe(40);
36 36 expect(new Set(durableTypes)).toEqual(new Set(DURABLE_EVENT_TYPES));
37 37 const ephemeralTypes = sampleEphemeralInputs().map((i) => i.type);
38 38 expect(new Set(ephemeralTypes)).toEqual(new Set(EPHEMERAL_EVENT_TYPES));
modified tests/session/fixtures.ts +60 −0
@@ -259,6 +259,66 @@ export function sampleDurableInputs(): DurableEventInput[] {
259 259 type: "task.failed",
260 260 payload: { reason: "iteration-budget-exhausted", detail: "gave up after 50 iterations" },
261 261 },
262 + { type: "phase.entered", payload: { phase: "design", via: "design-submitted" } },
263 + {
264 + type: "phase.artifact",
265 + payload: {
266 + artifactId: "art_1",
267 + artifact: {
268 + goal: "fix the renderer leak",
269 + filesTouched: ["src/tui/render.ts"],
270 + approach: "ring buffer instead of growing array",
271 + risks: ["scrollback regression"],
272 + verification: "vitest run tests/tui",
273 + outOfScope: ["theming"],
274 + },
275 + },
276 + },
277 + {
278 + type: "phase.approved",
279 + payload: { phase: "design", approvedBy: "auto-policy", artifactId: "art_1" },
280 + },
281 + { type: "phase.rejected", payload: { phase: "design", reason: "too broad", artifactId: "art_1" } },
282 + {
283 + type: "verify.result",
284 + payload: {
285 + check: "typecheck",
286 + command: "npx tsc --noEmit",
287 + ok: false,
288 + exitCode: 2,
289 + output: "src/a.ts(42): TS2345",
290 + durationMs: 4200,
291 + },
292 + },
293 + {
294 + type: "memory.written",
295 + payload: {
296 + section: "conventions",
297 + entry: "errors flow through Result<T, KError>",
298 + confidence: "high",
299 + toolUseId: "toolu_9",
300 + },
301 + },
302 + {
303 + type: "subtask.created",
304 + payload: {
305 + taskId: "t1",
306 + description: "add rate limiting",
307 + childSessionId: "01CHILD",
308 + worktreePath: "/repo/.khaelor/worktrees/t1",
309 + branch: "khaelor/t1",
310 + },
311 + },
312 + {
313 + type: "subtask.completed",
314 + payload: {
315 + taskId: "t1",
316 + outcome: "done",
317 + diffStats: { added: 42, removed: 13 },
318 + verifyOk: true,
319 + detail: "rate limiting added",
320 + },
321 + },
262 322 ];
263 323 }
264 324
added tests/session/fork-sdiff.test.ts +112 −0
@@ -0,0 +1,112 @@
1 +/**
2 + * KHAELOR
3 + * File: tests/session/fork-sdiff.test.ts
4 + * Description: Fork + sdiff tests — prefix copy with lineage, checkpoint discovery, structured run diff (v2 §2).
5 + *
6 + * Author: Simon-Pierre Boucher
7 + * Contact: contact@spboucher.ai
8 + */
9 +
10 +import { mkdtempSync } from "node:fs";
11 +import { readFile } from "node:fs/promises";
12 +import { tmpdir } from "node:os";
13 +import { join } from "node:path";
14 +import { describe, expect, it } from "vitest";
15 +import {
16 + SessionLog,
17 + extractUserTurns,
18 + forkSession,
19 + listForkCheckpoints,
20 + readSessionMeta,
21 + renderSessionDiff,
22 + summarizeSessionRun,
23 +} from "../../src/session/index.js";
24 +
25 +const HASH = "abcd1234abcd1234";
26 +
27 +async function seedSession(sessionsDir: string): Promise<SessionLog> {
28 + const log = await SessionLog.create({ projectHash: HASH, sessionsDir });
29 + log.append({ type: "user.message-created", payload: { text: "fix the auth bug", mentions: [] } });
30 + log.append({
31 + type: "model.request-started",
32 + payload: {
33 + requestId: "req_1",
34 + model: "claude-sonnet-4-5",
35 + purpose: "main",
36 + contextStats: { estimatedInputTokens: 10, sections: [] },
37 + },
38 + });
39 + log.append({
40 + type: "model.response-completed",
41 + payload: {
42 + requestId: "req_1",
43 + stopReason: "end_turn",
44 + usage: { inputTokens: 100, outputTokens: 20, cacheReadTokens: 0, cacheWriteTokens: 0 },
45 + durationMs: 5,
46 + },
47 + });
48 + log.append({
49 + type: "phase.approved",
50 + payload: { phase: "design", approvedBy: "auto-policy", artifactId: "a1" },
51 + });
52 + log.append({ type: "user.message-created", payload: { text: "now add tests", mentions: [] } });
53 + await log.flush();
54 + return log;
55 +}
56 +
57 +describe("fork", () => {
58 + it("copies the prefix, rewrites sessionIds, and records lineage", async () => {
59 + const sessionsDir = mkdtempSync(join(tmpdir(), "khaelor-fork-"));
60 + const source = await seedSession(sessionsDir);
61 + const checkpoints = listForkCheckpoints(source.replayedEvents.length > 0 ? source.replayedEvents : []);
62 + // Freshly created log: read events back from disk for checkpoints.
63 + const reopened = await SessionLog.open({ projectHash: HASH, sessionId: source.sessionId, sessionsDir });
64 + const points = listForkCheckpoints(reopened.replayedEvents);
65 + expect(checkpoints).toHaveLength(0);
66 + expect(points.map((point) => point.kind)).toEqual(["user-turn", "design-approved", "user-turn"]);
67 +
68 + const fork = await forkSession({
69 + sessionsDir,
70 + projectHash: HASH,
71 + sourceSessionId: source.sessionId,
72 + uptoSeq: 4,
73 + });
74 + expect(fork.copiedEvents).toBe(4);
75 + expect(fork.forkPoint).toBe(4);
76 +
77 + const forked = await SessionLog.open({ projectHash: HASH, sessionId: fork.sessionId, sessionsDir });
78 + expect(forked.replayedEvents).toHaveLength(4);
79 + expect(forked.replayedEvents.every((event) => event.sessionId === fork.sessionId)).toBe(true);
80 +
81 + const meta = await readSessionMeta(sessionsDir, HASH, fork.sessionId);
82 + expect(meta?.parent).toBe(source.sessionId);
83 + expect(meta?.forkPoint).toBe(4);
84 +
85 + const raw = await readFile(fork.filePath, "utf8");
86 + expect(raw.trim().split("\n")).toHaveLength(4);
87 + });
88 +
89 + it("extracts user turns for /replay", async () => {
90 + const sessionsDir = mkdtempSync(join(tmpdir(), "khaelor-fork-"));
91 + const source = await seedSession(sessionsDir);
92 + const reopened = await SessionLog.open({ projectHash: HASH, sessionId: source.sessionId, sessionsDir });
93 + expect(extractUserTurns(reopened.replayedEvents)).toEqual(["fix the auth bug", "now add tests"]);
94 + });
95 +});
96 +
97 +describe("sdiff", () => {
98 + it("summarizes runs from real events and renders the comparison", async () => {
99 + const sessionsDir = mkdtempSync(join(tmpdir(), "khaelor-sdiff-"));
100 + const source = await seedSession(sessionsDir);
101 + const reopened = await SessionLog.open({ projectHash: HASH, sessionId: source.sessionId, sessionsDir });
102 + const summary = summarizeSessionRun(source.sessionId, reopened.replayedEvents);
103 + expect(summary.agentTurns).toBe(1);
104 + expect(summary.inputTokens).toBe(100);
105 + expect(summary.models).toEqual(["claude-sonnet-4-5"]);
106 + expect(summary.outcome).toBe("open");
107 +
108 + const lines = renderSessionDiff(summary, { ...summary, sessionId: "other", outputTokens: 999 });
109 + expect(lines.join("\n")).toContain("agent turns");
110 + expect(lines.join("\n")).toContain("tokens (in/out)");
111 + });
112 +});
added tests/tasks/worktree.test.ts +118 −0
@@ -0,0 +1,118 @@
1 +/**
2 + * KHAELOR
3 + * File: tests/tasks/worktree.test.ts
4 + * Description: Worktree subtask tests — create/diff/merge/remove against a real temp git repo (v2 §6).
5 + *
6 + * Author: Simon-Pierre Boucher
7 + * Contact: contact@spboucher.ai
8 + */
9 +
10 +import { execFileSync } from "node:child_process";
11 +import { mkdtempSync, writeFileSync } from "node:fs";
12 +import { tmpdir } from "node:os";
13 +import { join } from "node:path";
14 +import { describe, expect, it } from "vitest";
15 +import {
16 + createWorktree,
17 + mergeSubtaskBranch,
18 + removeWorktree,
19 + worktreeDiffStats,
20 +} from "../../src/tasks/index.js";
21 +import type { ExecFn } from "../../src/tasks/index.js";
22 +import { SubtaskManager } from "../../src/tasks/index.js";
23 +import type { DurableEventInput } from "../../src/session/index.js";
24 +
25 +const exec: ExecFn = (cmd, cwd) => {
26 + try {
27 + const stdout = execFileSync("/bin/sh", ["-c", cmd], { cwd, encoding: "utf8" });
28 + return Promise.resolve({ exitCode: 0, stdout, stderr: "" });
29 + } catch (error) {
30 + const failure = error as { status?: number; stdout?: string; stderr?: string };
31 + return Promise.resolve({
32 + exitCode: failure.status ?? 1,
33 + stdout: failure.stdout?.toString() ?? "",
34 + stderr: failure.stderr?.toString() ?? "",
35 + });
36 + }
37 +};
38 +
39 +function makeRepo(): string {
40 + const dir = mkdtempSync(join(tmpdir(), "khaelor-worktree-"));
41 + execFileSync("git", ["init", "-q", "-b", "main"], { cwd: dir });
42 + execFileSync("git", ["config", "user.email", "t@t"], { cwd: dir });
43 + execFileSync("git", ["config", "user.name", "t"], { cwd: dir });
44 + writeFileSync(join(dir, "a.txt"), "hello\n");
45 + execFileSync("git", ["add", "-A"], { cwd: dir });
46 + execFileSync("git", ["commit", "-qm", "init"], { cwd: dir });
47 + return dir;
48 +}
49 +
50 +describe("worktree lifecycle", () => {
51 + it("creates, diffs, merges (--no-ff), and removes a subtask worktree", async () => {
52 + const repo = makeRepo();
53 + const worktree = await createWorktree(exec, repo, "t1");
54 + expect(worktree.branch).toBe("khaelor/t1");
55 +
56 + writeFileSync(join(worktree.path, "b.txt"), "new file\nsecond line\n");
57 + await exec("git add -A && git commit -qm change", worktree.path);
58 +
59 + const diff = await worktreeDiffStats(exec, repo, worktree.branch);
60 + expect(diff.added).toBe(2);
61 + expect(diff.files).toEqual(["b.txt"]);
62 +
63 + const merge = await mergeSubtaskBranch(exec, repo, worktree.branch, "merge t1");
64 + expect(merge.ok).toBe(true);
65 + const log = await exec("git log --oneline -1", repo);
66 + expect(log.stdout).toContain("merge t1");
67 +
68 + await removeWorktree(exec, repo, worktree, { deleteBranch: true });
69 + const branches = await exec("git branch --list 'khaelor/*'", repo);
70 + expect(branches.stdout.trim()).toBe("");
71 + });
72 +
73 + it("aborts cleanly on merge conflicts", async () => {
74 + const repo = makeRepo();
75 + const worktree = await createWorktree(exec, repo, "t2");
76 + writeFileSync(join(worktree.path, "a.txt"), "worktree version\n");
77 + await exec("git add -A && git commit -qm wt", worktree.path);
78 + writeFileSync(join(repo, "a.txt"), "main version\n");
79 + await exec("git add -A && git commit -qm main", repo);
80 +
81 + const merge = await mergeSubtaskBranch(exec, repo, worktree.branch, "merge t2");
82 + expect(merge.ok).toBe(false);
83 + expect(merge.conflict).toBe(true);
84 + const status = await exec("git status --porcelain", repo);
85 + expect(status.stdout.trim()).toBe(""); // aborted — tree left clean
86 + });
87 +});
88 +
89 +describe("SubtaskManager", () => {
90 + it("spawns a child run in a worktree and records subtask events with real diff stats", async () => {
91 + const repo = makeRepo();
92 + const published: DurableEventInput[] = [];
93 + const manager = new SubtaskManager({
94 + publish: (event) => published.push(event),
95 + exec,
96 + projectRoot: repo,
97 + runChild: (args) => {
98 + writeFileSync(join(args.worktreePath, "feature.txt"), "done by child\n");
99 + return Promise.resolve({ status: "done" as const, verifyOk: true, detail: "implemented" });
100 + },
101 + });
102 + const record = await manager.spawn("add the feature");
103 + expect(record.status).toBe("running");
104 + await manager.waitAll();
105 +
106 + const done = manager.get(record.taskId);
107 + expect(done?.status).toBe("done");
108 + expect(done?.diff.added).toBe(1);
109 + expect(done?.verifyOk).toBe(true);
110 +
111 + const types = published.map((event) => event.type);
112 + expect(types).toEqual(["subtask.created", "subtask.completed"]);
113 + const completed = published[1];
114 + expect(
115 + completed !== undefined && completed.type === "subtask.completed" ? completed.payload.diffStats.added : 0,
116 + ).toBe(1);
117 + });
118 +});
modified tests/tools/registry.test.ts +6 −2
@@ -30,7 +30,7 @@ describe("ToolRegistry", () => {
30 30 expect(() => registry.register(createReadTool())).toThrowError(/already registered/);
31 31 });
32 32
33 it("registers all seven default tools", () => {
33 + it("registers the seven V1 tools plus the four v2 tools", () => {
34 34 const registry = createDefaultToolRegistry();
35 35 expect(registry.list().map((t) => t.name)).toEqual([
36 36 "read",
@@ -40,12 +40,16 @@ describe("ToolRegistry", () => {
40 40 "glob",
41 41 "bash",
42 42 "process",
43 + "design",
44 + "remember",
45 + "symbols",
46 + "refs",
43 47 ]);
44 48 });
45 49
46 50 it("exports valid Anthropic tool params with at most 5 parameters each", () => {
47 51 const params = createDefaultToolRegistry().toAnthropicTools();
48 expect(params).toHaveLength(7);
52 + expect(params).toHaveLength(11);
49 53 for (const param of params) {
50 54 expect(typeof param.name).toBe("string");
51 55 expect(param.description.length).toBeGreaterThan(50);
modified tests/tui/status.test.ts +17 −3
@@ -26,12 +26,24 @@ const FULL: StatusBarData = {
26 26 };
27 27
28 28 describe("renderStatusBar degradation", () => {
29 it("width ≥ 100: all segments, full model id", () => {
29 + it("width ≥ 110: all segments, full model id, context gauge (TUI v2 §3)", () => {
30 30 expect(renderStatusBar(FULL, 120, mono)).toBe(
31 + " main +4 −1 │ claude-sonnet-4-5 │ ▰▰▱▱▱▱▱▱ 31% │ $0.42 │ ● 2",
32 + );
33 + });
34 +
35 + it("width 100–109: context falls back to the worded percent", () => {
36 + expect(renderStatusBar(FULL, 105, mono)).toBe(
31 37 " main +4 −1 │ claude-sonnet-4-5 │ context 31% │ $0.42 │ ● 2",
32 38 );
33 39 });
34 40
41 + it("phase ribbon leads the cockpit when a phase is active (v2 §1)", () => {
42 + const bar = renderStatusBar({ ...FULL, phase: "design" }, 130, mono);
43 + expect(bar).toContain("◑ DESIGN");
44 + expect(bar.indexOf("◑")).toBeLessThan(bar.indexOf("main"));
45 + });
46 +
35 47 it("width 80–99: model shortens to its alias", () => {
36 48 expect(renderStatusBar(FULL, 90, mono)).toBe(
37 49 " main +4 −1 │ sonnet │ context 31% │ $0.42 │ ● 2",
@@ -59,8 +71,10 @@ describe("renderStatusBar degradation", () => {
59 71 });
60 72
61 73 it("context pressure appends the /compact nudge at ≥ 80%", () => {
62 const bar = renderStatusBar({ ...FULL, contextPct: 85 }, 120, mono);
63 expect(bar).toContain("context 85% · /compact");
74 + const gauge = renderStatusBar({ ...FULL, contextPct: 85 }, 120, mono);
75 + expect(gauge).toContain("85% · /compact");
76 + const worded = renderStatusBar({ ...FULL, contextPct: 85 }, 105, mono);
77 + expect(worded).toContain("context 85% · /compact");
64 78 });
65 79
66 80 it("queued steering shows its count", () => {
added tests/tui/v2-widgets.test.ts +174 −0
@@ -0,0 +1,174 @@
1 +/**
2 + * KHAELOR
3 + * File: tests/tui/v2-widgets.test.ts
4 + * Description: TUI v2 widget tests — gradients, sparkline, verify strip, design panel, splash, motion, doctor (TUI v2).
5 + *
6 + * Author: Simon-Pierre Boucher
7 + * Contact: contact@spboucher.ai
8 + */
9 +
10 +import { describe, expect, it } from "vitest";
11 +import { renderDesignPanel } from "../../src/tui/components/design-panel.js";
12 +import { brailleSparkline } from "../../src/tui/components/sparkline.js";
13 +import { renderVerifyStrip } from "../../src/tui/components/verify-strip.js";
14 +import { doctorReport } from "../../src/tui/doctor.js";
15 +import { MotionController } from "../../src/tui/motion.js";
16 +import { renderSplashFrame, SPLASH_FRAMES } from "../../src/tui/splash.js";
17 +import { gradientText, mixOklch } from "../../src/tui/theme/gradient.js";
18 +import { resolveTheme } from "../../src/tui/theme.js";
19 +
20 +const mono = resolveTheme({ colorDepth: "mono" });
21 +const truecolor = resolveTheme({ colorDepth: "truecolor" });
22 +
23 +describe("OKLCH gradient", () => {
24 + it("interpolates endpoints exactly and midpoints smoothly", () => {
25 + expect(mixOklch([255, 107, 53], [255, 184, 107], 0)).toEqual([255, 107, 53]);
26 + expect(mixOklch([255, 107, 53], [255, 184, 107], 1)).toEqual([255, 184, 107]);
27 + const mid = mixOklch([255, 107, 53], [255, 184, 107], 0.5);
28 + expect(mid[0]).toBeGreaterThan(200); // stays warm — no muddy midpoint
29 + });
30 +
31 + it("emits one truecolor SGR per character and closes the color", () => {
32 + const painted = gradientText("ABC", [255, 107, 53], [255, 184, 107]);
33 + expect(painted.match(/\x1b\[38;2;/g)).toHaveLength(3);
34 + expect(painted.endsWith("\x1b[39m")).toBe(true);
35 + });
36 +
37 + it("theme.paintGradient('ember', …) is identity in mono", () => {
38 + expect(mono.paintGradient("ember", "KHAELOR")).toBe("KHAELOR");
39 + expect(truecolor.paintGradient("ember", "KHAELOR")).toContain("\x1b[38;2;");
40 + });
41 +});
42 +
43 +describe("brailleSparkline", () => {
44 + it("renders two values per braille cell", () => {
45 + expect(brailleSparkline([])).toBe("");
46 + expect(brailleSparkline([1, 2, 3, 4])).toHaveLength(2);
47 + const flat = brailleSparkline([5, 5, 5, 5]);
48 + expect(flat).toHaveLength(2);
49 + expect(flat[0]).toBe(flat[1]);
50 + });
51 +
52 + it("caps to maxCells keeping the most recent samples", () => {
53 + const line = brailleSparkline([1, 2, 3, 4, 5, 6, 7, 8], 2);
54 + expect(line).toHaveLength(2);
55 + });
56 +});
57 +
58 +describe("verify strip", () => {
59 + it("shows live check states with a spinner on running checks", () => {
60 + const lines = renderVerifyStrip(
61 + [
62 + { check: "typecheck", state: "ok", durationMs: 1200 },
63 + { check: "tests", state: "running" },
64 + ],
65 + 100,
66 + mono,
67 + );
68 + expect(lines[0]).toContain("verify");
69 + expect(lines[0]).toContain("typecheck ✓");
70 + expect(lines[0]).toContain("tests ⠧");
71 + });
72 +
73 + it("contracts to a single ✓ verified line when everything passes (TUI v2 §5.4)", () => {
74 + const lines = renderVerifyStrip(
75 + [
76 + { check: "typecheck", state: "ok", durationMs: 1200 },
77 + { check: "tests", state: "ok", durationMs: 4200 },
78 + ],
79 + 100,
80 + mono,
81 + );
82 + expect(lines).toHaveLength(1);
83 + expect(lines[0]).toContain("✓ verified 4.2s");
84 + });
85 +
86 + it("pins failures with their first error lines", () => {
87 + const lines = renderVerifyStrip(
88 + [{ check: "tests", state: "failed", errorHead: ["FAIL src/a.test.ts", "expected 2 to be 3"] }],
89 + 100,
90 + mono,
91 + );
92 + expect(lines[0]).toContain("tests ✗");
93 + expect(lines[1]).toContain("FAIL src/a.test.ts");
94 + });
95 +});
96 +
97 +describe("design panel", () => {
98 + const artifact = {
99 + goal: "Fix the renderer memory leak",
100 + filesTouched: ["src/tui/render.ts", "src/tui/buffer.ts"],
101 + approach: "Replace the growing buffer with a ring buffer.",
102 + risks: ["scrollback regression (medium)"],
103 + verification: "vitest run tests/tui + manual demo",
104 + outOfScope: ["theming"],
105 + };
106 +
107 + it("renders the full pending panel with approve/reject hints", () => {
108 + const lines = renderDesignPanel({ ...artifact, decision: "pending" }, 100, mono);
109 + const text = lines.join("\n");
110 + expect(text).toContain("DESIGN — approval required");
111 + expect(text).toContain("Fix the renderer memory leak");
112 + expect(text).toContain("[a]");
113 + expect(text).toContain("[r]");
114 + expect(lines[0]).toContain("╭");
115 + expect(lines[lines.length - 1]).toContain("╰");
116 + });
117 +
118 + it("collapses to one line once decided (TUI v2 §5.2)", () => {
119 + const approved = renderDesignPanel({ ...artifact, decision: "auto-approved" }, 100, mono);
120 + expect(approved).toHaveLength(1);
121 + expect(approved[0]).toContain("✓ design auto-approved · 2 files");
122 + const rejected = renderDesignPanel({ ...artifact, decision: "rejected" }, 100, mono);
123 + expect(rejected[0]).toContain("✗ design rejected");
124 + });
125 +});
126 +
127 +describe("splash", () => {
128 + it("renders the wordmark and settles on the final gradient frame", () => {
129 + const first = renderSplashFrame(0, true);
130 + const last = renderSplashFrame(SPLASH_FRAMES - 1, true);
131 + expect(first.length).toBe(last.length);
132 + // The tagline is gradient-painted per character — strip SGR before matching.
133 + const plain = last.join("\n").replace(/\x1b\[[0-9;]*m/g, "");
134 + expect(plain).toContain("understand · design · implement");
135 + expect(last.join("\n")).toContain("\x1b[38;2;");
136 + const monoFrame = renderSplashFrame(0, false);
137 + expect(monoFrame.join("\n")).not.toContain("\x1b[");
138 + });
139 +});
140 +
141 +describe("motion controller", () => {
142 + it("advances effects only on tick and drops finished transitions", () => {
143 + const motion = new MotionController({ enabled: true });
144 + motion.start("phase", "phase-flash");
145 + expect(motion.frame("phase")).toBe(0);
146 + motion.tick();
147 + expect(motion.frame("phase")).toBe(1);
148 + motion.tick();
149 + motion.tick();
150 + expect(motion.frame("phase")).toBeNull(); // 3 frames max
151 + });
152 +
153 + it("--motion off reduces everything to static instantly", () => {
154 + const motion = new MotionController({ enabled: false });
155 + motion.start("panel", "panel-breath");
156 + expect(motion.frame("panel")).toBeNull();
157 + expect(motion.breathIntensity("panel")).toBe(0);
158 + });
159 +});
160 +
161 +describe("doctor-tui", () => {
162 + it("reports detected capabilities with reasons", () => {
163 + const report = doctorReport(
164 + { TERM: "xterm-256color", COLORTERM: "truecolor", TERM_PROGRAM: "iTerm.app", LANG: "en_US.UTF-8" },
165 + true,
166 + 120,
167 + );
168 + const text = report.join("\n");
169 + expect(text).toContain("truecolor");
170 + expect(text).toContain("utf-8");
171 + expect(text).toContain("osc8 links");
172 + expect(text).toContain("khaelis");
173 + });
174 +});
added tests/verify/verify.test.ts +165 −0
@@ -0,0 +1,165 @@
1 +/**
2 + * KHAELOR
3 + * File: tests/verify/verify.test.ts
4 + * Description: Native-verification tests — config parsing, errors-first truncation, repair-loop accounting, runner events (v2 §4).
5 + *
6 + * Author: Simon-Pierre Boucher
7 + * Contact: contact@spboucher.ai
8 + */
9 +
10 +import { describe, expect, it } from "vitest";
11 +import {
12 + countRepairFailures,
13 + parseVerifyConfig,
14 + truncateErrorsFirst,
15 + VerifyRunner,
16 +} from "../../src/verify/index.js";
17 +import type { DurableEvent, DurableEventInput } from "../../src/session/index.js";
18 +import type { Command, ProcessResult, Workspace } from "../../src/workspace/index.js";
19 +
20 +function fakeSession(): {
21 + events(): readonly DurableEvent[];
22 + publishDurable(input: DurableEventInput): DurableEvent;
23 +} {
24 + const events: DurableEvent[] = [];
25 + let seq = 0;
26 + return {
27 + events: () => events,
28 + publishDurable(input: DurableEventInput): DurableEvent {
29 + seq += 1;
30 + const event = {
31 + v: 1,
32 + id: `e${seq}`,
33 + sessionId: "s1",
34 + seq,
35 + ts: seq,
36 + type: input.type,
37 + payload: input.payload,
38 + } as DurableEvent;
39 + events.push(event);
40 + return event;
41 + },
42 + };
43 +}
44 +
45 +function fakeWorkspace(results: Record<string, Partial<ProcessResult>>): Workspace {
46 + return {
47 + cwd: () => "/repo",
48 + readFile: () => Promise.reject(new Error("not used")),
49 + writeFile: () => Promise.resolve(),
50 + exec: (command: Command) =>
51 + Promise.resolve({
52 + exitCode: 0,
53 + stdout: "",
54 + stderr: "",
55 + durationMs: 1,
56 + truncated: false,
57 + ...(results[command.cmd] ?? {}),
58 + }),
59 + };
60 +}
61 +
62 +describe("parseVerifyConfig", () => {
63 + it("parses checks, policy, and maxRepairLoops", () => {
64 + const config = parseVerifyConfig({
65 + typecheck: { cmd: "npx tsc --noEmit", timeout: 60 },
66 + lint: { cmd: "npx eslint --fix", timeout: 30, autofix: true },
67 + policy: "before-final-answer",
68 + maxRepairLoops: 2,
69 + });
70 + expect(config?.checks).toHaveLength(2);
71 + expect(config?.checks[0]?.timeoutMs).toBe(60_000);
72 + expect(config?.checks[1]?.autofix).toBe(true);
73 + expect(config?.policy).toBe("before-final-answer");
74 + expect(config?.maxRepairLoops).toBe(2);
75 + });
76 +
77 + it("skips invalid entries and defaults the policy", () => {
78 + const config = parseVerifyConfig({ bad: { nope: true }, test: { cmd: "npm test" } });
79 + expect(config?.checks.map((check) => check.name)).toEqual(["test"]);
80 + expect(config?.policy).toBe("after-each-edit-batch");
81 + expect(config?.maxRepairLoops).toBe(3);
82 + });
83 +});
84 +
85 +describe("truncateErrorsFirst", () => {
86 + it("keeps error-looking lines ahead of noise", () => {
87 + const noise = Array.from({ length: 300 }, (_, i) => `line ${i} of ordinary output padding`).join("\n");
88 + const output = `${noise}\nsrc/a.ts(42): error TS2345: nope`;
89 + const truncated = truncateErrorsFirst(output, 500);
90 + expect(truncated).toContain("error TS2345");
91 + expect(truncated).toContain("[... verify output truncated");
92 + expect(truncated.indexOf("error TS2345")).toBeLessThan(truncated.indexOf("line 0"));
93 + });
94 +
95 + it("returns small output untouched", () => {
96 + expect(truncateErrorsFirst("all good", 100)).toBe("all good");
97 + });
98 +});
99 +
100 +describe("countRepairFailures", () => {
101 + it("counts failing rounds since the last user message", () => {
102 + const session = fakeSession();
103 + session.publishDurable({ type: "user.message-created", payload: { text: "go", mentions: [] } });
104 + session.publishDurable({
105 + type: "verify.result",
106 + payload: { check: "t", command: "c", ok: false, exitCode: 1, output: "x", durationMs: 1 },
107 + });
108 + session.publishDurable({
109 + type: "verify.result",
110 + payload: { check: "t", command: "c", ok: true, exitCode: 0, output: "", durationMs: 1 },
111 + });
112 + expect(countRepairFailures(session.events())).toBe(1);
113 + session.publishDurable({ type: "user.message-created", payload: { text: "next", mentions: [] } });
114 + expect(countRepairFailures(session.events())).toBe(0);
115 + });
116 +});
117 +
118 +describe("VerifyRunner", () => {
119 + it("publishes one verify.result per check with real exit codes", async () => {
120 + const session = fakeSession();
121 + const workspace = fakeWorkspace({
122 + "npm test": { exitCode: 1, stderr: "1 test failed\nFAIL src/a.test.ts" },
123 + });
124 + const runner = new VerifyRunner({
125 + workspace,
126 + session,
127 + config: {
128 + checks: [
129 + { name: "typecheck", cmd: "npx tsc --noEmit", timeoutMs: 1000, autofix: false },
130 + { name: "test", cmd: "npm test", timeoutMs: 1000, autofix: false },
131 + ],
132 + policy: "after-each-edit-batch",
133 + maxRepairLoops: 3,
134 + },
135 + });
136 + const outcome = await runner.runAll();
137 + expect(outcome.ok).toBe(false);
138 + const results = session.events().filter((event) => event.type === "verify.result");
139 + expect(results).toHaveLength(2);
140 + const failed = results.find((event) => event.type === "verify.result" && !event.payload.ok);
141 + expect(failed !== undefined && failed.type === "verify.result" ? failed.payload.output : "").toContain(
142 + "test failed",
143 + );
144 + });
145 +
146 + it("withinRepairBudget flips false after maxRepairLoops failures", async () => {
147 + const session = fakeSession();
148 + session.publishDurable({ type: "user.message-created", payload: { text: "go", mentions: [] } });
149 + const workspace = fakeWorkspace({ bad: { exitCode: 1, stderr: "boom" } });
150 + const runner = new VerifyRunner({
151 + workspace,
152 + session,
153 + config: {
154 + checks: [{ name: "bad", cmd: "bad", timeoutMs: 1000, autofix: false }],
155 + policy: "after-each-edit-batch",
156 + maxRepairLoops: 2,
157 + },
158 + });
159 + expect(runner.withinRepairBudget()).toBe(true);
160 + await runner.runAll();
161 + expect(runner.withinRepairBudget()).toBe(true);
162 + await runner.runAll();
163 + expect(runner.withinRepairBudget()).toBe(false);
164 + });
165 +});
modified website/public/docs/commands.html +20 −1
@@ -21,7 +21,7 @@ Contact: contact@spboucher.ai
21 21 <body>
22 22
23 23 <header class="site-header"><div class="inner">
24 <a class="wordmark" href="/"><span class="glyph">&#10095;</span>KHAELOR</a>
24 + <a class="wordmark" href="/"><span class="glyph">&#10095;</span><span class="g-ember">KHAELOR</span></a>
25 25 <nav>
26 26 <a href="/docs/getting-started.html">Docs</a>
27 27 <a href="/#install">Install</a>
@@ -41,6 +41,10 @@ Contact: contact@spboucher.ai
41 41 <a href="/docs/tools.html">Tools</a>
42 42 <a href="/docs/sessions.html">Sessions</a>
43 43 </div>
44 + <div class="group"><div class="group-title">Autonomy</div>
45 + <a href="/docs/phases.html">Phase gates &amp; verify</a>
46 + <a href="/docs/daemon.html">The daemon</a>
47 + </div>
44 48 <div class="group"><div class="group-title">Control</div>
45 49 <a href="/docs/permissions.html">Permissions</a>
46 50 <a href="/docs/configuration.html">Configuration</a>
@@ -166,6 +170,21 @@ dollar column honestly reads <code>n/a</code> rather than inventing an estimate.
166 170 <p>A one-shot summary of the session: working directory, git branch and dirty state, model and
167 171 thinking mode, context utilization, cost so far, and running background processes.</p>
168 172
173 +<h3 id="cmd-v2">Autonomy &amp; audit commands (v2)</h3>
174 +<table>
175 + <tr><th>Command</th><th>What it does</th></tr>
176 + <tr><td><code>/phase</code></td><td class="wrap">Show the current phase (understand / design / implement) and force a transition — logged as a user override. See <a href="/docs/phases.html">Phase gates</a>.</td></tr>
177 + <tr><td><code>/verify</code></td><td class="wrap">Run the project's verify checks now (typecheck, tests, lint) and record the real results.</td></tr>
178 + <tr><td><code>/fork</code></td><td class="wrap">Branch this session at a checkpoint. See <a href="/docs/sessions.html#fork">Fork, replay, sdiff</a>.</td></tr>
179 + <tr><td><code>/replay</code></td><td class="wrap">Re-run a session's user turns with the current model, sandboxed in a worktree.</td></tr>
180 + <tr><td><code>/sdiff</code></td><td class="wrap">Structured diff between two session runs.</td></tr>
181 + <tr><td><code>/memory</code></td><td class="wrap">The auto-maintained project memory with per-entry provenance.</td></tr>
182 + <tr><td><code>/spawn</code></td><td class="wrap">Run a subtask in an isolated git worktree with attenuated capabilities.</td></tr>
183 + <tr><td><code>/tasks</code></td><td class="wrap">The subtask board: phase, diff stats, verify state per task.</td></tr>
184 + <tr><td><code>/merge</code></td><td class="wrap">Supervised <code>--no-ff</code> merge of a finished subtask branch; conflicts abort cleanly.</td></tr>
185 + <tr><td><code>/goals</code></td><td class="wrap">Daemon goals and status. See <a href="/docs/daemon.html">The daemon</a>.</td></tr>
186 +</table>
187 +
169 188 <h3 id="cmd-diff"><code>/diff</code> — the diff viewer</h3>
170 189 <p id="diff-viewer">Opens the full-screen diff viewer (the one alternate-screen surface in
171 190 KHAELOR) showing the session's cumulative changes — only what KHAELOR changed, never your
modified website/public/docs/configuration.html +5 −1
@@ -21,7 +21,7 @@ Contact: contact@spboucher.ai
21 21 <body>
22 22
23 23 <header class="site-header"><div class="inner">
24 <a class="wordmark" href="/"><span class="glyph">&#10095;</span>KHAELOR</a>
24 + <a class="wordmark" href="/"><span class="glyph">&#10095;</span><span class="g-ember">KHAELOR</span></a>
25 25 <nav>
26 26 <a href="/docs/getting-started.html">Docs</a>
27 27 <a href="/#install">Install</a>
@@ -41,6 +41,10 @@ Contact: contact@spboucher.ai
41 41 <a href="/docs/tools.html">Tools</a>
42 42 <a href="/docs/sessions.html">Sessions</a>
43 43 </div>
44 + <div class="group"><div class="group-title">Autonomy</div>
45 + <a href="/docs/phases.html">Phase gates &amp; verify</a>
46 + <a href="/docs/daemon.html">The daemon</a>
47 + </div>
44 48 <div class="group"><div class="group-title">Control</div>
45 49 <a href="/docs/permissions.html">Permissions</a>
46 50 <a href="/docs/configuration.html">Configuration</a>
added website/public/docs/daemon.html +151 −0
@@ -0,0 +1,151 @@
1 +<!doctype html>
2 +<!--
3 +KHAELOR
4 +File: website/public/docs/daemon.html
5 +Description: Docs — khaelord: event-sourced goals, heartbeat, budget guard, approvals, channels (v2 §7).
6 +Author: Simon-Pierre Boucher
7 +Contact: contact@spboucher.ai
8 +-->
9 +<html lang="en">
10 +<head>
11 +<meta charset="utf-8">
12 +<meta name="viewport" content="width=device-width, initial-scale=1">
13 +<title>The daemon (khaelord) — KHAELOR docs</title>
14 +<meta name="description" content="khaelord runs KHAELOR autonomously: structured event-sourced goals, throwaway worktrees, phase gates at 3 AM, hard budget ceilings, async approvals, and channels.">
15 +<link rel="stylesheet" href="/styles.css">
16 +<script>
17 +(function(){try{var t=localStorage.getItem("khaelor-theme");if(!t&&window.matchMedia&&window.matchMedia("(prefers-color-scheme: light)").matches)t="light";if(t==="light")document.documentElement.setAttribute("data-theme","light");}catch(e){}})();
18 +</script>
19 +<script defer src="/site.js"></script>
20 +</head>
21 +<body>
22 +
23 +<header class="site-header"><div class="inner">
24 + <a class="wordmark" href="/"><span class="glyph">&#10095;</span><span class="g-ember">KHAELOR</span></a>
25 + <nav>
26 + <a href="/docs/getting-started.html">Docs</a>
27 + <a href="/#install">Install</a>
28 + <button id="theme-toggle" type="button" aria-label="Toggle color theme">light</button>
29 + </nav>
30 +</div></header>
31 +
32 +<main class="page"><div class="docs-layout">
33 +
34 +<aside class="sidebar">
35 + <div class="group"><div class="group-title">Start</div>
36 + <a href="/docs/getting-started.html">Getting started</a>
37 + </div>
38 + <div class="group"><div class="group-title">Using KHAELOR</div>
39 + <a href="/docs/usage.html">The TUI</a>
40 + <a href="/docs/commands.html">Commands &amp; keyboard</a>
41 + <a href="/docs/tools.html">Tools</a>
42 + <a href="/docs/sessions.html">Sessions</a>
43 + </div>
44 + <div class="group"><div class="group-title">Autonomy</div>
45 + <a href="/docs/phases.html">Phase gates &amp; verify</a>
46 + <a href="/docs/daemon.html">The daemon</a>
47 + </div>
48 + <div class="group"><div class="group-title">Control</div>
49 + <a href="/docs/permissions.html">Permissions</a>
50 + <a href="/docs/configuration.html">Configuration</a>
51 + </div>
52 + <div class="group"><div class="group-title">Help</div>
53 + <a href="/docs/faq.html">FAQ &amp; troubleshooting</a>
54 + </div>
55 +</aside>
56 +
57 +<article class="content">
58 +
59 +<h1>The daemon — <code>khaelord</code></h1>
60 +<p class="lead">The long-term autonomous mode: an engineer that lives in your project, whose every
61 +decision — even at 3&nbsp;AM — is an event you can replay the next morning.</p>
62 +
63 +<h2 id="model">The model<a class="anchor" href="#model">#</a></h2>
64 +<p>Where other always-on agents keep a prose checklist, KHAELOR has <strong>structured,
65 +event-sourced goals</strong>. Each goal run:</p>
66 +<ol>
67 + <li>opens an isolated session in a <strong>throwaway git worktree</strong> — the daemon never touches your working copy;</li>
68 + <li>runs with <strong>phase gates in auto mode</strong> — the design artifact lands in the log even at night;</li>
69 + <li>runs <strong>mandatory verification</strong> — no escalation when typecheck or tests fail;</li>
70 + <li>appends every event to <code>.khaelor/daemon/goals/&lt;id&gt;.events.jsonl</code> — replayable, auditable.</li>
71 +</ol>
72 +
73 +<h2 id="goals">Goals<a class="anchor" href="#goals">#</a></h2>
74 +<pre><code>khaelord goal add "watch GitHub issues labeled 'bug', reproduce, propose a fix" \
75 + --type watch --schedule "*/30 * * * *" --budget 5 --escalation draft-pr
76 +
77 +khaelord goal add "npm deps stay fresh without breaking changes" \
78 + --type maintain --schedule "0 6 * * 1" --check "npm outdated --json | grep -q ." \
79 + --escalation draft-pr
80 +
81 +khaelord goal list
82 +khaelord goal pause &lt;id&gt; · khaelord goal resume &lt;id&gt;</code></pre>
83 +<table>
84 + <tr><th>Field</th><th>Meaning</th></tr>
85 + <tr><td><code>--type</code></td><td class="wrap"><code>maintain</code> (keep an invariant), <code>achieve</code> (reach a state), <code>watch</code> (react to signals).</td></tr>
86 + <tr><td><code>--schedule</code></td><td class="wrap">A cron expression, or <code>heartbeat</code> to ride the daemon tick (default every 30 min).</td></tr>
87 + <tr><td><code>--check</code></td><td class="wrap">Cheap triage: a command whose exit code decides if there is anything to do. Exit 0 → the run is skipped and logged as such. This is the two-tier cost routing: the check is (nearly) free, the strong model only runs when there is real work.</td></tr>
88 + <tr><td><code>--budget</code></td><td class="wrap">USD/day ceiling for this goal (with configured pricing) plus <code>--runs</code> runs/day.</td></tr>
89 + <tr><td><code>--escalation</code></td><td class="wrap"><code>notify</code> · <code>draft-pr</code> (branch left for review) · <code>auto-merge-if-verified</code> (--no-ff merge only when every check passed).</td></tr>
90 +</table>
91 +
92 +<h2 id="running">Running it<a class="anchor" href="#running">#</a></h2>
93 +<pre><code>khaelord start # foreground; use nohup / launchd / systemd to detach
94 +khaelord status # pid, active runs, spent today
95 +khaelord stop</code></pre>
96 +<pre class="term" data-no-copy><code> <span class="t-dim">03:12</span> <span class="t-ember">●</span> goal <span class="t-teal">deps-fresh</span> check failed → run r7f2 <span class="t-dim">in worktree khaelor/goal-r7f2</span>
97 + <span class="t-dim">03:14</span> <span class="t-ember">◑</span> design recorded <span class="t-dim">· 3 files</span>
98 + <span class="t-dim">03:17</span> <span class="t-ok">✓ verified</span> · branch khaelor/goal-r7f2 left for review <span class="t-dim">(escalation: draft-pr)</span>
99 + <span class="t-dim">03:17 spent today: $1.84 / $20 · runs 1/8</span></code></pre>
100 +
101 +<h2 id="budget">Budget guard<a class="anchor" href="#budget">#</a></h2>
102 +<pre><code>// .khaelor/daemon/config.json
103 +{
104 + "budget": { "maxUsdPerDay": 20, "maxUsdPerRun": 3, "hardStop": true },
105 + "activeHours": "07:00-23:00",
106 + "heartbeatMinutes": 30,
107 + "model": { "runs": "claude-sonnet-4-5" },
108 + "channels": { "webhook": "https://…", "command": "./notify.sh" },
109 + "pricing": { "inputPerMTok": 3, "outputPerMTok": 15, "cacheReadPerMTok": 0.3, "cacheWritePerMTok": 3.75 }
110 +}</code></pre>
111 +<p>Ceilings are hard: once a goal or the daemon hits its daily budget, runs are skipped and the skip
112 +is logged. Costs are computed from <strong>real API usage</strong> and only when pricing is
113 +configured — KHAELOR never invents a dollar figure. Without pricing, <code>maxRunsPerDay</code>
114 +is the binding limit.</p>
115 +
116 +<h2 id="approvals">Asynchronous approvals<a class="anchor" href="#approvals">#</a></h2>
117 +<p>When a run needs a permission at 3&nbsp;AM, it does not block and does not burn tokens waiting:
118 +the request lands in a persisted queue, the run checkpoints (a paused run is just a paused JSONL —
119 +it costs zero), and the daemon moves to the next goal.</p>
120 +<pre><code>khaelord approvals # list pending requests with context
121 +khaelord approve &lt;id&gt; # or: khaelord deny &lt;id&gt;</code></pre>
122 +
123 +<h2 id="channels">Channels<a class="anchor" href="#channels">#</a></h2>
124 +<p>V1 ships two adapters behind one <code>ChannelAdapter</code> seam — the same philosophy as the
125 +isolated <code>ModelClient</code>:</p>
126 +<ul>
127 + <li><strong>webhook</strong> — every notification POSTed as JSON;</li>
128 + <li><strong>command</strong> — notifications piped to a script's stdin: plug in mail, Slack, ntfy, whatever you like.</li>
129 +</ul>
130 +
131 +<h2 id="audit">Auditability<a class="anchor" href="#audit">#</a></h2>
132 +<p>In the TUI, <code>/goals</code> shows every goal, its schedule, and today's spend. Each run
133 +references its child session id — resume it, <code>/fork</code> it, or <code>/sdiff</code> it
134 +against another run. <em>Autonomy without auditability is scary; KHAELOR's autonomy is auditable
135 +by construction.</em></p>
136 +
137 +<div class="pager">
138 + <div><span class="label">Previous</span><a href="/docs/phases.html">&larr; Phase gates &amp; verify</a></div>
139 + <div class="next"><span class="label">Next</span><a href="/docs/permissions.html">Permissions &rarr;</a></div>
140 +</div>
141 +
142 +</article>
143 +</div></main>
144 +
145 +<footer class="site-footer"><div class="inner">
146 + <span>KHAELOR — Simon-Pierre Boucher &middot; <a href="mailto:contact@spboucher.ai">contact@spboucher.ai</a></span>
147 + <span>Anthropic-powered &middot; terminal-native by design</span>
148 +</div></footer>
149 +
150 +</body>
151 +</html>
modified website/public/docs/faq.html +5 −1
@@ -21,7 +21,7 @@ Contact: contact@spboucher.ai
21 21 <body>
22 22
23 23 <header class="site-header"><div class="inner">
24 <a class="wordmark" href="/"><span class="glyph">&#10095;</span>KHAELOR</a>
24 + <a class="wordmark" href="/"><span class="glyph">&#10095;</span><span class="g-ember">KHAELOR</span></a>
25 25 <nav>
26 26 <a href="/docs/getting-started.html">Docs</a>
27 27 <a href="/#install">Install</a>
@@ -41,6 +41,10 @@ Contact: contact@spboucher.ai
41 41 <a href="/docs/tools.html">Tools</a>
42 42 <a href="/docs/sessions.html">Sessions</a>
43 43 </div>
44 + <div class="group"><div class="group-title">Autonomy</div>
45 + <a href="/docs/phases.html">Phase gates &amp; verify</a>
46 + <a href="/docs/daemon.html">The daemon</a>
47 + </div>
44 48 <div class="group"><div class="group-title">Control</div>
45 49 <a href="/docs/permissions.html">Permissions</a>
46 50 <a href="/docs/configuration.html">Configuration</a>
modified website/public/docs/getting-started.html +5 −1
@@ -21,7 +21,7 @@ Contact: contact@spboucher.ai
21 21 <body>
22 22
23 23 <header class="site-header"><div class="inner">
24 <a class="wordmark" href="/"><span class="glyph">&#10095;</span>KHAELOR</a>
24 + <a class="wordmark" href="/"><span class="glyph">&#10095;</span><span class="g-ember">KHAELOR</span></a>
25 25 <nav>
26 26 <a href="/docs/getting-started.html">Docs</a>
27 27 <a href="/#install">Install</a>
@@ -41,6 +41,10 @@ Contact: contact@spboucher.ai
41 41 <a href="/docs/tools.html">Tools</a>
42 42 <a href="/docs/sessions.html">Sessions</a>
43 43 </div>
44 + <div class="group"><div class="group-title">Autonomy</div>
45 + <a href="/docs/phases.html">Phase gates &amp; verify</a>
46 + <a href="/docs/daemon.html">The daemon</a>
47 + </div>
44 48 <div class="group"><div class="group-title">Control</div>
45 49 <a href="/docs/permissions.html">Permissions</a>
46 50 <a href="/docs/configuration.html">Configuration</a>
modified website/public/docs/permissions.html +5 −1
@@ -21,7 +21,7 @@ Contact: contact@spboucher.ai
21 21 <body>
22 22
23 23 <header class="site-header"><div class="inner">
24 <a class="wordmark" href="/"><span class="glyph">&#10095;</span>KHAELOR</a>
24 + <a class="wordmark" href="/"><span class="glyph">&#10095;</span><span class="g-ember">KHAELOR</span></a>
25 25 <nav>
26 26 <a href="/docs/getting-started.html">Docs</a>
27 27 <a href="/#install">Install</a>
@@ -41,6 +41,10 @@ Contact: contact@spboucher.ai
41 41 <a href="/docs/tools.html">Tools</a>
42 42 <a href="/docs/sessions.html">Sessions</a>
43 43 </div>
44 + <div class="group"><div class="group-title">Autonomy</div>
45 + <a href="/docs/phases.html">Phase gates &amp; verify</a>
46 + <a href="/docs/daemon.html">The daemon</a>
47 + </div>
44 48 <div class="group"><div class="group-title">Control</div>
45 49 <a href="/docs/permissions.html">Permissions</a>
46 50 <a href="/docs/configuration.html">Configuration</a>
added website/public/docs/phases.html +143 −0
@@ -0,0 +1,143 @@
1 +<!doctype html>
2 +<!--
3 +KHAELOR
4 +File: website/public/docs/phases.html
5 +Description: Docs — phase gates (understand → design → implement), native verification, and project memory (v2).
6 +Author: Simon-Pierre Boucher
7 +Contact: contact@spboucher.ai
8 +-->
9 +<html lang="en">
10 +<head>
11 +<meta charset="utf-8">
12 +<meta name="viewport" content="width=device-width, initial-scale=1">
13 +<title>Phase gates &amp; verification — KHAELOR docs</title>
14 +<meta name="description" content="Understand → design → implement as an enforced runtime mechanism: design artifacts, gate modes, native verification, and provenance-anchored project memory.">
15 +<link rel="stylesheet" href="/styles.css">
16 +<script>
17 +(function(){try{var t=localStorage.getItem("khaelor-theme");if(!t&&window.matchMedia&&window.matchMedia("(prefers-color-scheme: light)").matches)t="light";if(t==="light")document.documentElement.setAttribute("data-theme","light");}catch(e){}})();
18 +</script>
19 +<script defer src="/site.js"></script>
20 +</head>
21 +<body>
22 +
23 +<header class="site-header"><div class="inner">
24 + <a class="wordmark" href="/"><span class="glyph">&#10095;</span><span class="g-ember">KHAELOR</span></a>
25 + <nav>
26 + <a href="/docs/getting-started.html">Docs</a>
27 + <a href="/#install">Install</a>
28 + <button id="theme-toggle" type="button" aria-label="Toggle color theme">light</button>
29 + </nav>
30 +</div></header>
31 +
32 +<main class="page"><div class="docs-layout">
33 +
34 +<aside class="sidebar">
35 + <div class="group"><div class="group-title">Start</div>
36 + <a href="/docs/getting-started.html">Getting started</a>
37 + </div>
38 + <div class="group"><div class="group-title">Using KHAELOR</div>
39 + <a href="/docs/usage.html">The TUI</a>
40 + <a href="/docs/commands.html">Commands &amp; keyboard</a>
41 + <a href="/docs/tools.html">Tools</a>
42 + <a href="/docs/sessions.html">Sessions</a>
43 + </div>
44 + <div class="group"><div class="group-title">Autonomy</div>
45 + <a href="/docs/phases.html">Phase gates &amp; verify</a>
46 + <a href="/docs/daemon.html">The daemon</a>
47 + </div>
48 + <div class="group"><div class="group-title">Control</div>
49 + <a href="/docs/permissions.html">Permissions</a>
50 + <a href="/docs/configuration.html">Configuration</a>
51 + </div>
52 + <div class="group"><div class="group-title">Help</div>
53 + <a href="/docs/faq.html">FAQ &amp; troubleshooting</a>
54 + </div>
55 +</aside>
56 +
57 +<article class="content">
58 +
59 +<h1>Phase gates &amp; verification</h1>
60 +<p class="lead">“Understand first. Design second. Implement third.” is not a slogan in KHAELOR —
61 +it is a mechanism of the tool runtime, enforced by the permission model.</p>
62 +
63 +<h2 id="phases">The three phases<a class="anchor" href="#phases">#</a></h2>
64 +<p>Every gated session moves through three phases, shown live in the status bar ribbon:</p>
65 +<pre class="term" data-no-copy><code> <span class="t-ember">◐ UNDERSTAND</span> <span class="t-dim">─ design ─ implement</span> read · grep · glob · symbols · refs · read-only bash
66 + <span class="t-dim">✓ understand ─</span> <span class="t-ember">◑ DESIGN</span> <span class="t-dim">─ implement</span> + write docs/design/*.md only
67 + <span class="t-dim">✓ understand ─ ✓ design ─</span> <span class="t-ember">● IMPLEMENT</span> write · edit · full bash unlocked</code></pre>
68 +<p>If the agent tries to edit before its design is approved, the tool call fails with a structured
69 +<code>PHASE_GATE_BLOCKED</code> error telling it to finalize its design first. That is prompt engineering
70 +by architecture: the model learns the workflow because the runtime enforces it.</p>
71 +
72 +<h2 id="artifact">The design artifact<a class="anchor" href="#artifact">#</a></h2>
73 +<p>The agent unlocks implementation by calling the <code>design</code> tool with a structured artifact:</p>
74 +<table>
75 + <tr><th>Field</th><th>Meaning</th></tr>
76 + <tr><td><code>goal</code></td><td class="wrap">The need, restated in the agent's own words.</td></tr>
77 + <tr><td><code>files</code></td><td class="wrap">The files it plans to modify — the auto-approval threshold counts these.</td></tr>
78 + <tr><td><code>approach</code></td><td class="wrap">The technical plan, 5–15 lines.</td></tr>
79 + <tr><td><code>risks</code></td><td class="wrap">Identified risks; lines prefixed <code>out of scope:</code> become explicit non-goals.</td></tr>
80 + <tr><td><code>verification</code></td><td class="wrap">How the agent will prove the change works.</td></tr>
81 +</table>
82 +<p>The artifact is a durable event in the session log — every approved design is part of the
83 +auditable history, including the ones the daemon records at night.</p>
84 +
85 +<h2 id="modes">Gate modes<a class="anchor" href="#modes">#</a></h2>
86 +<pre><code>khaelor --gate strict # three phases, human approval of every design
87 +khaelor --gate auto # self-approves designs touching ≤ 3 files (default)
88 +khaelor --gate off # v1 behavior — no gate</code></pre>
89 +<p>Configure the threshold in <code>.khaelor/config.json</code>:</p>
90 +<pre><code>{
91 + "gate": { "mode": "auto", "autoApprove": { "maxFiles": 3 } }
92 +}</code></pre>
93 +<p><code>/phase</code> is the escape hatch: it shows the current phase and lets you force a transition —
94 +always logged as a <code>user-override</code> event, never silent.</p>
95 +
96 +<h2 id="verify">Native verification<a class="anchor" href="#verify">#</a></h2>
97 +<p>After each batch of edits, KHAELOR runs your project's checks itself — in parallel — and feeds
98 +failures back to the model <em>before</em> handing back to you, in a bounded repair loop
99 +(default 3 rounds, then an honest failure report). Checks are auto-detected from
100 +<code>package.json</code>, <code>tsconfig.json</code>, <code>Cargo.toml</code>, or <code>pyproject.toml</code>,
101 +and overridable:</p>
102 +<pre><code>// .khaelor/verify.json
103 +{
104 + "typecheck": { "cmd": "npx tsc --noEmit", "timeout": 60 },
105 + "test": { "cmd": "npx vitest run --changed", "timeout": 120 },
106 + "lint": { "cmd": "npx eslint --fix", "timeout": 30, "autofix": true },
107 + "policy": "after-each-edit-batch",
108 + "maxRepairLoops": 3
109 +}</code></pre>
110 +<pre class="term" data-no-copy><code> <span class="t-glow">⟳</span> <span class="t-b">verify</span> typecheck <span class="t-ok">✓</span> <span class="t-dim">1.2s</span> · tests <span class="t-err">✗</span> · lint <span class="t-ok">✓</span>
111 + <span class="t-dim">FAIL src/context/engine.test.ts — compaction preserves running processes</span>
112 + <span class="t-dim">KHAELOR is repairing the failure…</span>
113 + <span class="t-ok">✓ verified 5.3s</span></code></pre>
114 +<p>Run the whole suite on demand with <code>/verify</code>. Every result is a durable
115 +<code>verify.result</code> event: real commands, real exit codes, errors-first truncation.</p>
116 +
117 +<h2 id="memory">Project memory with provenance<a class="anchor" href="#memory">#</a></h2>
118 +<p>When the agent discovers a durable fact — a convention, a build command, a pitfall — it persists
119 +it with the <code>remember</code> tool into <code>.khaelor/MEMORY.md</code>: readable, git-versionable,
120 +and injected into context at every session start. Each entry is anchored to the session and tool
121 +call that produced it:</p>
122 +<pre><code>## Conventions
123 +- Errors flow through Result&lt;T, KError&gt;; never throw in src/core.
124 + &lt;!-- khaelor: session=01J8… tool=toolu_01… confidence=high date=2026-08-10 --&gt;</code></pre>
125 +<p><code>/memory</code> lists the entries with their provenance; low-confidence entries not re-confirmed
126 +become purge candidates at the next <code>/compact</code>. You always know <em>why</em> the agent
127 +believes something.</p>
128 +
129 +<div class="pager">
130 + <div><span class="label">Previous</span><a href="/docs/sessions.html">&larr; Sessions</a></div>
131 + <div class="next"><span class="label">Next</span><a href="/docs/daemon.html">The daemon &rarr;</a></div>
132 +</div>
133 +
134 +</article>
135 +</div></main>
136 +
137 +<footer class="site-footer"><div class="inner">
138 + <span>KHAELOR — Simon-Pierre Boucher &middot; <a href="mailto:contact@spboucher.ai">contact@spboucher.ai</a></span>
139 + <span>Anthropic-powered &middot; terminal-native by design</span>
140 +</div></footer>
141 +
142 +</body>
143 +</html>
modified website/public/docs/sessions.html +25 −1
@@ -21,7 +21,7 @@ Contact: contact@spboucher.ai
21 21 <body>
22 22
23 23 <header class="site-header"><div class="inner">
24 <a class="wordmark" href="/"><span class="glyph">&#10095;</span>KHAELOR</a>
24 + <a class="wordmark" href="/"><span class="glyph">&#10095;</span><span class="g-ember">KHAELOR</span></a>
25 25 <nav>
26 26 <a href="/docs/getting-started.html">Docs</a>
27 27 <a href="/#install">Install</a>
@@ -41,6 +41,10 @@ Contact: contact@spboucher.ai
41 41 <a href="/docs/tools.html">Tools</a>
42 42 <a href="/docs/sessions.html">Sessions</a>
43 43 </div>
44 + <div class="group"><div class="group-title">Autonomy</div>
45 + <a href="/docs/phases.html">Phase gates &amp; verify</a>
46 + <a href="/docs/daemon.html">The daemon</a>
47 + </div>
44 48 <div class="group"><div class="group-title">Control</div>
45 49 <a href="/docs/permissions.html">Permissions</a>
46 50 <a href="/docs/configuration.html">Configuration</a>
@@ -143,6 +147,26 @@ your money goes: KHAELOR keeps its prompts byte-stable specifically to maximize
143 147 external-modification detection — the agent must re-read before touching them.</li>
144 148 </ul>
145 149
150 +<h2 id="fork">Fork, replay, sdiff<a class="anchor" href="#fork">#</a></h2>
151 +<p>Because resume is a replay of the JSONL, three operations come almost for free — and turn
152 +sessions into a comparison instrument no other CLI agent offers:</p>
153 +<table>
154 + <tr><th>Command</th><th>What it does</th></tr>
155 + <tr><td><code>/fork</code></td><td class="wrap">Pick a checkpoint (any user turn, approved design, or context checkpoint) and branch the session there. The JSONL prefix is copied into a fresh session with a <code>meta.json</code> recording <code>parent</code> and <code>forkPoint</code>; KHAELOR opens the fork immediately.</td></tr>
156 + <tr><td><code>/replay</code></td><td class="wrap">Re-run another session's <em>user turns</em> with the current model, inside a throwaway git worktree so re-executed tool calls have zero side effects on your working copy.</td></tr>
157 + <tr><td><code>/sdiff</code></td><td class="wrap">Structured diff between two runs: agent turns, tool calls, files modified, tokens in/out, cache reads, verify failures, outcome — plus which files each run touched exclusively.</td></tr>
158 +</table>
159 +<pre class="term" data-no-copy><code> <span class="t-b">sdiff</span>
160 + <span class="t-dim">01J8K2… (sonnet)</span> <span class="t-dim">01J8K9… (haiku)</span>
161 + agent turns 12 17
162 + tool calls 34 61
163 + files modified 2 3
164 + tokens (in/out) 145.0k / 12.0k 210.0k / 19.0k
165 + verify failures 0 2
166 + outcome completed completed
167 + <span class="t-dim">files only in B: src/c.ts</span></code></pre>
168 +<p><em>Run it twice, diff the runs</em> — the cheapest honest model comparison you can do.</p>
169 +
146 170 <h2 id="storage">Where sessions live<a class="anchor" href="#storage">#</a></h2>
147 171 <p>Session logs are stored under <code>~/.khaelor/sessions/</code> as plain JSONL — one event
148 172 per line, inspectable with standard tools. Associated artifacts (spilled tool output, process
modified website/public/docs/tools.html +5 −1
@@ -21,7 +21,7 @@ Contact: contact@spboucher.ai
21 21 <body>
22 22
23 23 <header class="site-header"><div class="inner">
24 <a class="wordmark" href="/"><span class="glyph">&#10095;</span>KHAELOR</a>
24 + <a class="wordmark" href="/"><span class="glyph">&#10095;</span><span class="g-ember">KHAELOR</span></a>
25 25 <nav>
26 26 <a href="/docs/getting-started.html">Docs</a>
27 27 <a href="/#install">Install</a>
@@ -41,6 +41,10 @@ Contact: contact@spboucher.ai
41 41 <a href="/docs/tools.html">Tools</a>
42 42 <a href="/docs/sessions.html">Sessions</a>
43 43 </div>
44 + <div class="group"><div class="group-title">Autonomy</div>
45 + <a href="/docs/phases.html">Phase gates &amp; verify</a>
46 + <a href="/docs/daemon.html">The daemon</a>
47 + </div>
44 48 <div class="group"><div class="group-title">Control</div>
45 49 <a href="/docs/permissions.html">Permissions</a>
46 50 <a href="/docs/configuration.html">Configuration</a>
modified website/public/docs/usage.html +5 −1
@@ -21,7 +21,7 @@ Contact: contact@spboucher.ai
21 21 <body>
22 22
23 23 <header class="site-header"><div class="inner">
24 <a class="wordmark" href="/"><span class="glyph">&#10095;</span>KHAELOR</a>
24 + <a class="wordmark" href="/"><span class="glyph">&#10095;</span><span class="g-ember">KHAELOR</span></a>
25 25 <nav>
26 26 <a href="/docs/getting-started.html">Docs</a>
27 27 <a href="/#install">Install</a>
@@ -41,6 +41,10 @@ Contact: contact@spboucher.ai
41 41 <a href="/docs/tools.html">Tools</a>
42 42 <a href="/docs/sessions.html">Sessions</a>
43 43 </div>
44 + <div class="group"><div class="group-title">Autonomy</div>
45 + <a href="/docs/phases.html">Phase gates &amp; verify</a>
46 + <a href="/docs/daemon.html">The daemon</a>
47 + </div>
44 48 <div class="group"><div class="group-title">Control</div>
45 49 <a href="/docs/permissions.html">Permissions</a>
46 50 <a href="/docs/configuration.html">Configuration</a>
modified website/public/index.html +119 −89
@@ -2,7 +2,7 @@
2 2 <!--
3 3 KHAELOR
4 4 File: website/public/index.html
5 Description: KHAELOR documentation site — landing page: what KHAELOR is, install, features, demo.
5 +Description: KHAELOR site — landing page v2: the auditable autonomous engineer (phase gates, verify, fork/replay/sdiff, daemon).
6 6 Author: Simon-Pierre Boucher
7 7 Contact: contact@spboucher.ai
8 8 -->
@@ -10,8 +10,8 @@ Contact: contact@spboucher.ai
10 10 <head>
11 11 <meta charset="utf-8">
12 12 <meta name="viewport" content="width=device-width, initial-scale=1">
13 <title>KHAELOR — a terminal-native autonomous engineering agent</title>
14 <meta name="description" content="KHAELOR is a terminal-native autonomous engineering agent powered by Anthropic. Install with one command: curl -fsSL https://www.khaelor.sh/install.sh | sh">
13 +<title>KHAELOR — the autonomous engineer whose every decision you can replay</title>
14 +<meta name="description" content="KHAELOR is a terminal-native autonomous engineering agent powered by Anthropic. Phase-gated, self-verifying, event-sourced — fork it, replay it, diff it, or let the daemon run it at 3 AM. curl -fsSL https://www.khaelor.sh/install.sh | sh">
15 15 <link rel="stylesheet" href="/styles.css">
16 16 <script>
17 17 (function(){try{var t=localStorage.getItem("khaelor-theme");if(!t&&window.matchMedia&&window.matchMedia("(prefers-color-scheme: light)").matches)t="light";if(t==="light")document.documentElement.setAttribute("data-theme","light");}catch(e){}})();
@@ -21,9 +21,11 @@ Contact: contact@spboucher.ai
21 21 <body>
22 22
23 23 <header class="site-header"><div class="inner">
24 <a class="wordmark" href="/"><span class="glyph">&#10095;</span>KHAELOR</a>
24 + <a class="wordmark" href="/"><span class="glyph">&#10095;</span><span class="g-ember">KHAELOR</span></a>
25 25 <nav>
26 26 <a href="/docs/getting-started.html">Docs</a>
27 + <a href="/docs/phases.html">Phase gates</a>
28 + <a href="/docs/daemon.html">Daemon</a>
27 29 <a href="/#install">Install</a>
28 30 <button id="theme-toggle" type="button" aria-label="Toggle color theme">light</button>
29 31 </nav>
@@ -32,12 +34,12 @@ Contact: contact@spboucher.ai
32 34 <main class="page">
33 35
34 36 <section class="hero">
35 <div class="kicker">TERMINAL-NATIVE &middot; POWERED BY ANTHROPIC</div>
36 <h1>An autonomous engineering agent that treats your terminal as the product.</h1>
37 + <div class="kicker">TERMINAL-NATIVE &middot; EVENT-SOURCED &middot; POWERED BY ANTHROPIC</div>
38 + <h1>An autonomous engineer whose <span class="g-ember">every decision</span> is an event you can replay, fork, and diff.</h1>
37 39 <p class="tagline">
38 KHAELOR reads, searches, edits, runs, and verifies code in your repository —
39 streaming every action into a calm, fast, keyboard-native interface.
40 No web UI. No Electron. Just your terminal, done properly.
40 + KHAELOR designs before it implements, verifies before it claims done, and writes
41 + every action — even the ones at 3&nbsp;AM — into an append-only log you can audit.
42 + Autonomy without auditability is scary. KHAELOR sells auditable autonomy.
41 43 </p>
42 44
43 45 <div class="install-line" id="install">
@@ -48,117 +50,145 @@ Contact: contact@spboucher.ai
48 50
49 51 <div class="cta-row">
50 52 <a href="/docs/getting-started.html">Getting started &rarr;</a>
51 <a href="/docs/usage.html">The TUI in depth &rarr;</a>
53 + <a href="/docs/phases.html">Understand &rarr; Design &rarr; Implement &rarr;</a>
54 + <a href="/docs/daemon.html">The daemon &rarr;</a>
52 55 </div>
53 56
54 <div class="principle">Understand first. Design second. Implement third.</div>
57 + <div class="principle">Understand first. Design second. Implement third. <span class="g-ember">Verify always. Even at 3&nbsp;AM.</span></div>
55 58 </section>
56 59
57 <div class="section-title">What it feels like</div>
60 + <div class="section-title">The cockpit</div>
58 61
59 <pre class="term" data-no-copy><code> <span class="t-b">KHAELOR</span>
60 <span class="t-dim">~/dev/my-project &middot; main</span>
61 <span class="t-dim">claude-sonnet-4-5 &middot; thinking adaptive</span>
62 <span class="t-dim">────────────────────────────────────────────────────</span>
63 What do you want to build?
64 <span class="t-accent">&#10095;</span> add retry logic to the session store
62 + <pre class="term" data-no-copy><code> <span class="t-b g-ember">KHAELOR</span> <span class="t-dim">▸ my-project · main</span>
65 63
66 <span class="t-dim">&#9656;</span> Read <span class="t-b">src/session/store.ts</span> <span class="t-dim">&middot; 212 lines</span>
67 <span class="t-dim">&#9656;</span> Search <span class="t-b">"retry"</span> <span class="t-dim">&middot; 6 matches</span>
64 + <span class="t-ember">❯</span> fix the memory leak in the renderer
68 65
69 I found the failure point in SessionStore.append — writes
70 are not retried on transient EAGAIN. Fixing that first.
66 + <span class="t-dim">├─</span> symbols <span class="t-teal">"RenderBuffer"</span> <span class="t-dim">· 3 hits · 12ms</span>
67 + <span class="t-dim">├─</span> refs <span class="t-teal">appendLine · callers</span> <span class="t-dim">· 14 sites</span>
68 + <span class="t-dim">╰─</span> read <span class="t-teal">src/tui/render.ts</span> <span class="t-dim">· 240 lines</span>
71 69
72 <span class="t-dim">&#9656;</span> Edit <span class="t-b">src/session/store.ts</span> <span class="t-dim">&middot; +31 −12</span>
73 <span class="t-dim">&#9656;</span> Run <span class="t-b">npm test</span> <span class="t-dim">&middot;</span> <span class="t-ok">passed</span> <span class="t-dim">&middot; 4.2s</span>
70 + <span class="t-ember">◑</span> design submitted · 2 files <span class="t-dim">· replace growing buffer with a ring</span>
71 + <span class="t-ok">✓ design auto-approved · 2 files</span>
74 72
75 <span class="t-ok">&#10003;</span> src/session/store.ts <span class="t-ok">+31</span> <span class="t-err">−12</span> <span class="t-dim">d expand diff</span>
73 + <span class="t-dim">├─</span> edit <span class="t-teal">src/tui/render.ts</span> <span class="t-ok">+31</span> <span class="t-err">−12</span>
74 + <span class="t-dim">╰─</span> edit <span class="t-teal">src/tui/buffer.ts</span> <span class="t-ok">+8</span> <span class="t-err">−3</span>
76 75
77 <span class="t-accent">&#10095;</span> _
78 <span class="t-dim">main +1 −0 │ claude-sonnet-4-5 │ context 24% │ $0.31</span></code></pre>
76 + <span class="t-glow">⟳</span> <span class="t-b">verify</span> typecheck <span class="t-ok">✓</span> <span class="t-dim">1.2s</span> · tests <span class="t-ok">✓</span> <span class="t-dim">4.1s</span> · lint <span class="t-ok">✓</span>
77 + <span class="t-ok">✓ verified 4.1s</span>
79 78
80 <div class="section-title">Why KHAELOR</div>
79 + <span class="t-ember">●</span> IMPLEMENT <span class="t-dim">─</span> <span class="t-dim">main</span> <span class="t-dim">│</span> sonnet <span class="t-dim">│</span> <span class="gauge">▰▰▰▱▱▱▱▱ 38%</span> <span class="t-dim">│ in 45k · out 3.2k</span></code></pre>
81 80
82 <div class="features">
83 <div class="feature">
84 <span class="glyph">&#10095;_</span>
85 <h3>The terminal is the product</h3>
86 <p>Print-once scrollback, a bounded flicker-free live region, sub-16&nbsp;ms input echo. Native selection and copy always work — nothing repaints behind your back.</p>
87 </div>
88 <div class="feature">
89 <span class="glyph">&#9654;&#9654;</span>
90 <h3>First-class streaming</h3>
91 <p>Everything is event-driven — model text, tool calls, process output — coalesced into smooth 16&nbsp;ms frames. No spinners pretending to be progress.</p>
92 </div>
93 <div class="feature">
94 <span class="glyph">7</span>
95 <h3>Seven powerful tools</h3>
96 <p><code>read</code> &middot; <code>write</code> &middot; <code>edit</code> (9-strategy replacer) &middot; <code>grep</code> &middot; <code>glob</code> &middot; <code>bash</code> &middot; <code>process</code> — a real background process manager, not blocking shells.</p>
97 </div>
98 <div class="feature">
99 <span class="glyph">&#9679;&#9675;</span>
100 <h3>Background processes</h3>
101 <p>Start a dev server, keep editing, read its output later, run tests alongside. Long commands migrate to the background instead of hanging the agent.</p>
81 + <div class="section-title">Not another coding agent</div>
82 +
83 + <div class="grid3">
84 + <div class="card">
85 + <h3><span class="glyph">◐◑●</span> Phase gates</h3>
86 + <p>“Understand → design → implement” is a runtime mechanism, not a slogan. Write tools stay
87 + locked until a design artifact — goal, approach, files, risks, verification — is approved.
88 + Small designs auto-approve; big ones ask you. <code>--gate strict|auto|off</code>.</p>
102 89 </div>
103 <div class="feature">
104 <span class="glyph">&#8942;</span>
105 <h3>Capability-based permissions</h3>
106 <p>Allow / ask / deny by capability — never opaque tool names. Approve once with <kbd>Enter</kbd>, persist a precise grant with <kbd>A</kbd>, deny with <kbd>Esc</kbd>.</p>
90 + <div class="card">
91 + <h3><span class="glyph">⟳</span> Native verification</h3>
92 + <p>After each edit batch, KHAELOR runs your typecheck, tests, and lint itself — in parallel —
93 + and repairs failures in a bounded loop before handing back. No PR-shaped guesses:
94 + <code>.khaelor/verify.json</code> or auto-detected.</p>
107 95 </div>
108 <div class="feature">
109 <span class="glyph">&#8635;</span>
110 <h3>Persistent sessions</h3>
111 <p>Every session is an append-only event log. Resume is replay: your transcript, cost, file state, and even pending permission prompts survive restarts.</p>
96 + <div class="card">
97 + <h3><span class="glyph">⑂</span> Fork · replay · sdiff</h3>
98 + <p>Every session is an append-only event log. <code>/fork</code> any checkpoint,
99 + <code>/replay</code> a run against another model in a throwaway worktree, then
100 + <code>/sdiff</code> the two runs — turns, tool calls, files, tokens. Run it twice, diff the runs.</p>
112 101 </div>
113 <div class="feature">
114 <span class="glyph">%</span>
115 <h3>Honest numbers</h3>
116 <p>Token usage, cost, and context pressure come from real API metadata — never estimates, never invented progress bars. If pricing is unknown, it says <code>n/a</code>.</p>
102 + <div class="card">
103 + <h3><span class="glyph">◈</span> Semantic index</h3>
104 + <p>A RepoGraph over your codebase: <code>symbols</code> finds definitions with signatures,
105 + <code>refs</code> maps callers, callees, and importers before an edit. Skeletons instead of
106 + whole files — exploration for a fraction of the tokens.</p>
117 107 </div>
118 <div class="feature">
119 <span class="glyph">&#8853;</span>
120 <h3>Context engine</h3>
121 <p>Token-pressure-aware compaction with structured checkpoints. Inspect exactly what the model sees with <code>/context</code>; compact on demand with <code>/compact</code>.</p>
108 + <div class="card">
109 + <h3><span class="glyph">✎</span> Memory with provenance</h3>
110 + <p>The agent maintains <code>.khaelor/MEMORY.md</code> itself — conventions, commands, pitfalls —
111 + and every entry is anchored to the exact session and event that produced it.
112 + You always know <em>why</em> it believes something.</p>
122 113 </div>
123 <div class="feature">
124 <span class="glyph">&#9998;</span>
125 <h3>Steer while it works</h3>
126 <p>Type while the agent runs — messages queue and inject safely at tool boundaries. <kbd>Esc</kbd> interrupts instantly without corrupting the session.</p>
114 + <div class="card">
115 + <h3><span class="glyph">⇄</span> Parallel worktrees</h3>
116 + <p><code>/spawn</code> runs subtasks in isolated git worktrees with attenuated capabilities and
117 + their own child sessions. Supervised <code>--no-ff</code> merges; conflicts abort cleanly.
118 + Your working copy is never touched.</p>
127 119 </div>
128 120 </div>
129 121
130 <div class="section-title">Install</div>
122 + <div class="section-title">khaelord — the engineer that lives</div>
131 123
132 <p>One line (checks Node&nbsp;&ge;&nbsp;22, installs the CLI globally, verifies it):</p>
133 <pre><code>curl -fsSL https://www.khaelor.sh/install.sh | sh</code></pre>
124 + <p class="lead" style="max-width: 46rem;">
125 + A persistent daemon with structured, event-sourced goals — not a prose checklist. Each run gets a
126 + throwaway worktree, phase gates even at night, mandatory verification, hard budget ceilings, and an
127 + asynchronous approval queue: a suspended run costs zero.
128 + </p>
134 129
135 <p>Or install the tarball directly with npm:</p>
136 <pre><code>npm install -g https://www.khaelor.sh/khaelor.tgz</code></pre>
130 + <pre class="term" data-no-copy><code> <span class="t-dim">$</span> khaelord goal add <span class="t-teal">"keep npm deps fresh without breaking changes"</span> \
131 + --type maintain --schedule <span class="t-teal">"0 6 * * 1"</span> --budget 5 --escalation draft-pr
137 132
138 <p>Then set your Anthropic API key and start it inside any project:</p>
139 <pre><code>export ANTHROPIC_API_KEY=sk-ant-...
140 khaelor</code></pre>
133 + <span class="t-dim">$</span> khaelord start
134 + <span class="t-ok">khaelord running</span> <span class="t-dim">— project ~/dev/my-project</span>
141 135
142 <p>Full walkthrough: <a href="/docs/getting-started.html">Getting started</a>.</p>
136 + <span class="t-dim">03:12</span> <span class="t-ember">●</span> goal <span class="t-teal">deps-fresh</span> check failed → run r7f2 in worktree khaelor/goal-r7f2
137 + <span class="t-dim">03:14</span> <span class="t-ember">◑</span> design recorded <span class="t-dim">· 3 files · in the log, replayable</span>
138 + <span class="t-dim">03:17</span> <span class="t-ok">✓ verified</span> <span class="t-dim">· typecheck ✓ tests ✓</span> · branch left for review
139 + <span class="t-dim">03:17</span> <span class="t-dim">spent today: $1.84 / $20 · runs 1/8</span></code></pre>
143 140
144 <div class="section-title">Documentation</div>
141 + <div class="pitch">
142 + <p>Other agents give you an assistant that lives. <strong>KHAELOR gives you an engineer that
143 + lives</strong> — and whose every nocturnal decision is an event you can replay, fork, and diff
144 + the next morning.</p>
145 + <p class="motto">understand · design · implement · <span class="g-ember">verify always — even at 3 AM</span></p>
146 + </div>
145 147
146 <table>
147 <tr><td><a href="/docs/getting-started.html">Getting started</a></td><td class="wrap">Install, API key setup, your first session.</td></tr>
148 <tr><td><a href="/docs/usage.html">The TUI</a></td><td class="wrap">Composer, file mentions, shell mode, interruption, steering.</td></tr>
149 <tr><td><a href="/docs/commands.html">Commands &amp; keyboard</a></td><td class="wrap">Every slash command, the <kbd>Ctrl+K</kbd> palette, the complete key reference.</td></tr>
150 <tr><td><a href="/docs/tools.html">Tools</a></td><td class="wrap">What the agent can do: the seven tools, truncation, background processes.</td></tr>
151 <tr><td><a href="/docs/permissions.html">Permissions</a></td><td class="wrap">Capabilities, allow/ask/deny, the permission panel, persisted grants.</td></tr>
152 <tr><td><a href="/docs/configuration.html">Configuration</a></td><td class="wrap">Config files, precedence, model selection, thinking and output budget.</td></tr>
153 <tr><td><a href="/docs/sessions.html">Sessions</a></td><td class="wrap">Persistence, resume, the event log, cost tracking.</td></tr>
154 <tr><td><a href="/docs/faq.html">FAQ</a></td><td class="wrap">Troubleshooting: keys, terminals, NO_COLOR, logs.</td></tr>
148 + <div class="section-title">Approval, when it matters</div>
149 +
150 + <pre class="term panel breath" data-no-copy><code> <span class="t-ember">╭─ ◑ DESIGN — approval required ─────────────────────────╮</span>
151 + <span class="t-ember">│</span> <span class="t-dim">Goal</span> Fix the renderer memory leak <span class="t-ember">│</span>
152 + <span class="t-ember">│</span> <span class="t-dim">Files</span> src/tui/render.ts · src/tui/buffer.ts <span class="t-ember">│</span>
153 + <span class="t-ember">│</span> <span class="t-dim">Approach</span> Replace the growing buffer with a ring… <span class="t-ember">│</span>
154 + <span class="t-ember">│</span> <span class="t-dim">Risks</span> ▪ scrollback regression (medium) <span class="t-ember">│</span>
155 + <span class="t-ember">│</span> <span class="t-dim">Verify</span> vitest run tests/tui + manual demo <span class="t-ember">│</span>
156 + <span class="t-ember">├────────────────────────────────────────────────────────┤</span>
157 + <span class="t-ember">│</span> <span class="t-b">[a]</span> approve <span class="t-b">[r]</span> reject <span class="t-ember">│</span>
158 + <span class="t-ember">╰────────────────────────────────────────────────────────╯</span></code></pre>
159 +
160 + <div class="section-title">Everything is real</div>
161 +
162 + <table class="vs">
163 + <tr><th></th><th>KHAELOR</th></tr>
164 + <tr><td>Costs &amp; tokens</td><td>summed from real API usage metadata — never estimated, never invented</td></tr>
165 + <tr><td>Test results</td><td>your own commands, real exit codes, errors-first truncation</td></tr>
166 + <tr><td>History</td><td>append-only JSONL, torn-write recovery, byte-exact replay</td></tr>
167 + <tr><td>Permissions</td><td>capability-based (file.write.project, git.modify, network.access…), deny &gt; ask &gt; allow, silence is never consent</td></tr>
168 + <tr><td>3 AM decisions</td><td>one event log per goal run — <code>Enter</code> on a run replays exactly what happened</td></tr>
155 169 </table>
156 170
171 + <div class="section-title">Start</div>
172 +
173 + <pre><code>curl -fsSL https://www.khaelor.sh/install.sh | sh
174 +export ANTHROPIC_API_KEY=&lt;your key&gt;
175 +cd your-project
176 +khaelor</code></pre>
177 +
178 + <div class="cta-row" style="margin-bottom: 3rem;">
179 + <a href="/docs/getting-started.html">Getting started &rarr;</a>
180 + <a href="/docs/commands.html">All commands &rarr;</a>
181 + <a href="/docs/faq.html">FAQ &rarr;</a>
182 + </div>
183 +
157 184 </main>
158 185
159 186 <footer class="site-footer"><div class="inner">
160 <span>KHAELOR — Simon-Pierre Boucher &middot; <a href="mailto:contact@spboucher.ai">contact@spboucher.ai</a></span>
161 <span>Anthropic-powered &middot; terminal-native by design</span>
187 + <span>KHAELOR — Simon-Pierre Boucher</span>
188 + <a href="mailto:contact@spboucher.ai">contact@spboucher.ai</a>
189 + <a href="/docs/getting-started.html">Docs</a>
190 + <a href="/docs/phases.html">Phase gates</a>
191 + <a href="/docs/daemon.html">Daemon</a>
162 192 </div></footer>
163 193
164 194 </body>
modified website/public/styles.css +125 −21
@@ -9,43 +9,52 @@
9 9
10 10 /* ───────────────────────── tokens ───────────────────────── */
11 11
12 +/* Khaelis — obsidian + magma. The ember flows through the whole site (TUI v2 §1). */
12 13 :root {
13 --bg: #0d1017;
14 --bg-alt: #12161f;
15 --bg-code: #10141d;
16 --bg-inline: #1a1f2b;
17 --border: #222836;
18 --border-soft: #1a1f2a;
19 --text: #d8dee9;
14 + --bg: #0b0e14;
15 + --bg-alt: #10141d;
16 + --bg-code: #0e121a;
17 + --bg-inline: #171c28;
18 + --border: #202636;
19 + --border-soft: #181d2a;
20 + --text: #c9d1e3;
20 21 --text-strong: #eceff4;
21 22 --dim: #8b93a5;
22 --dimmer: #5c6372;
23 --accent: #7aa2f7;
24 --accent-soft: rgba(122, 162, 247, 0.12);
25 --success: #9ece6a;
26 --warning: #e0af68;
27 --error: #f7768e;
23 + --dimmer: #5b657a;
24 + --accent: #ff6b35;
25 + --accent2: #ffb86b;
26 + --teal: #2dd4bf;
27 + --accent-soft: rgba(255, 107, 53, 0.12);
28 + --teal-soft: rgba(45, 212, 191, 0.1);
29 + --success: #7ee787;
30 + --warning: #f0b429;
31 + --error: #ff5c57;
32 + --ember-gradient: linear-gradient(100deg, #ff6b35 0%, #ffb86b 55%, #ff6b35 100%);
28 33 --mono: ui-monospace, "SF Mono", SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
29 34 --sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
30 35 color-scheme: dark;
31 36 }
32 37
33 38 html[data-theme="light"] {
34 --bg: #fbfbfc;
35 --bg-alt: #f3f4f7;
36 --bg-code: #f5f6f8;
37 --bg-inline: #eceef3;
38 --border: #dde0e8;
39 --border-soft: #e8eaf0;
39 + --bg: #fbfaf8;
40 + --bg-alt: #f4f2ee;
41 + --bg-code: #f6f4f1;
42 + --bg-inline: #eeebe5;
43 + --border: #e2ddd4;
44 + --border-soft: #eae6de;
40 45 --text: #2a3040;
41 46 --text-strong: #14181f;
42 47 --dim: #646b7a;
43 48 --dimmer: #949bab;
44 --accent: #3661c4;
45 --accent-soft: rgba(54, 97, 196, 0.09);
49 + --accent: #d84f16;
50 + --accent2: #b06722;
51 + --teal: #0f766e;
52 + --accent-soft: rgba(216, 79, 22, 0.09);
53 + --teal-soft: rgba(15, 118, 110, 0.08);
46 54 --success: #467a1c;
47 55 --warning: #96660e;
48 56 --error: #c53b58;
57 + --ember-gradient: linear-gradient(100deg, #d84f16 0%, #b06722 55%, #d84f16 100%);
49 58 color-scheme: light;
50 59 }
51 60
@@ -441,3 +450,98 @@ kbd {
441 450 .hero h1 { font-size: 2rem; }
442 451 body { font-size: 15px; }
443 452 }
453 +
454 +/* ───────────────────────── v2 "Terminal Cinema" additions ───────────────────────── */
455 +
456 +/* Ember gradient wordmark + headings — the signature. */
457 +.g-ember {
458 + background: var(--ember-gradient);
459 + background-size: 200% 100%;
460 + -webkit-background-clip: text;
461 + background-clip: text;
462 + color: transparent;
463 + animation: emberflow 6s linear infinite;
464 +}
465 +@keyframes emberflow {
466 + from { background-position: 0% 0; }
467 + to { background-position: 200% 0; }
468 +}
469 +@media (prefers-reduced-motion: reduce) {
470 + .g-ember { animation: none; }
471 + .breath { animation: none !important; }
472 +}
473 +
474 +/* Phase ribbon (the cockpit, TUI v2 §3). */
475 +.phase-ribbon {
476 + display: inline-flex;
477 + gap: 0.6rem;
478 + align-items: baseline;
479 + font-family: var(--mono);
480 + font-size: 0.82rem;
481 +}
482 +.phase-ribbon .active { color: var(--accent); font-weight: 700; }
483 +.phase-ribbon .done { color: var(--dimmer); text-decoration: line-through; }
484 +.phase-ribbon .todo { color: var(--dimmer); }
485 +.phase-ribbon .sep { color: var(--dimmer); }
486 +
487 +/* Terminal mock accents */
488 +.term .t-ember { color: var(--accent); }
489 +.term .t-glow { color: var(--accent2); }
490 +.term .t-teal { color: var(--teal); }
491 +.term .t-ok { color: var(--success); }
492 +.term .t-err { color: var(--error); }
493 +.term .t-warn { color: var(--warning); }
494 +
495 +/* Design-panel mock: bordered, breathing while pending. */
496 +.term.panel { border: 1px solid rgba(255, 107, 53, 0.45); }
497 +.breath { animation: breathe 2.4s ease-in-out infinite; }
498 +@keyframes breathe {
499 + 0%, 100% { border-color: rgba(255, 107, 53, 0.35); }
500 + 50% { border-color: rgba(255, 184, 107, 0.75); }
501 +}
502 +
503 +/* Feature grid v2 */
504 +.grid3 {
505 + display: grid;
506 + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
507 + gap: 1rem;
508 + margin: 1.4rem 0 2.6rem;
509 +}
510 +.card {
511 + border: 1px solid var(--border);
512 + border-radius: 10px;
513 + background: var(--bg-alt);
514 + padding: 1.1rem 1.2rem;
515 +}
516 +.card h3 {
517 + margin: 0 0 0.45rem;
518 + font-size: 0.98rem;
519 + color: var(--text-strong);
520 + display: flex;
521 + align-items: baseline;
522 + gap: 0.5rem;
523 +}
524 +.card h3 .glyph { color: var(--accent); font-family: var(--mono); }
525 +.card p { margin: 0; font-size: 0.88rem; color: var(--dim); line-height: 1.55; }
526 +.card code { font-size: 0.8rem; }
527 +.card:hover { border-color: rgba(255, 107, 53, 0.45); }
528 +
529 +/* The pitch band */
530 +.pitch {
531 + margin: 3rem 0;
532 + padding: 2rem 1.6rem;
533 + border: 1px solid var(--border);
534 + border-left: 3px solid var(--accent);
535 + border-radius: 10px;
536 + background: var(--bg-alt);
537 +}
538 +.pitch p { margin: 0 0 0.8rem; font-size: 1.05rem; color: var(--text-strong); line-height: 1.65; }
539 +.pitch p:last-child { margin: 0; }
540 +.pitch .motto { color: var(--accent2); font-family: var(--mono); font-size: 0.92rem; }
541 +
542 +/* Context gauge mock */
543 +.gauge { font-family: var(--mono); color: var(--teal); }
544 +.gauge.hot { color: var(--error); }
545 +
546 +/* Comparison table accent */
547 +table.vs td:first-child { color: var(--text-strong); }
444 548