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>
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">❯</span>KHAELOR</a> | |
| 24 | + <a class="wordmark" href="/"><span class="glyph">❯</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 & 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 & 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">❯</span>KHAELOR</a> | |
| 24 | + <a class="wordmark" href="/"><span class="glyph">❯</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 & 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">❯</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 & 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 & 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 & 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 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/<id>.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 <id> · khaelord goal resume <id></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 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 <id> # or: khaelord deny <id></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">← Phase gates & verify</a></div> | |
| 139 | + <div class="next"><span class="label">Next</span><a href="/docs/permissions.html">Permissions →</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 · <a href="mailto:contact@spboucher.ai">contact@spboucher.ai</a></span> | |
| 147 | + <span>Anthropic-powered · 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">❯</span>KHAELOR</a> | |
| 24 | + <a class="wordmark" href="/"><span class="glyph">❯</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 & 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">❯</span>KHAELOR</a> | |
| 24 | + <a class="wordmark" href="/"><span class="glyph">❯</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 & 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">❯</span>KHAELOR</a> | |
| 24 | + <a class="wordmark" href="/"><span class="glyph">❯</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 & 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 & 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">❯</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 & 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 & 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 & troubleshooting</a> | |
| 54 | + </div> | |
| 55 | +</aside> | |
| 56 | + | |
| 57 | +<article class="content"> | |
| 58 | + | |
| 59 | +<h1>Phase gates & 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<T, KError>; never throw in src/core. | |
| 124 | + <!-- khaelor: session=01J8… tool=toolu_01… confidence=high date=2026-08-10 --></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">← Sessions</a></div> | |
| 131 | + <div class="next"><span class="label">Next</span><a href="/docs/daemon.html">The daemon →</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 · <a href="mailto:contact@spboucher.ai">contact@spboucher.ai</a></span> | |
| 139 | + <span>Anthropic-powered · 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">❯</span>KHAELOR</a> | |
| 24 | + <a class="wordmark" href="/"><span class="glyph">❯</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 & 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">❯</span>KHAELOR</a> | |
| 24 | + <a class="wordmark" href="/"><span class="glyph">❯</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 & 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">❯</span>KHAELOR</a> | |
| 24 | + <a class="wordmark" href="/"><span class="glyph">❯</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 & 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">❯</span>KHAELOR</a> | |
| 24 | + <a class="wordmark" href="/"><span class="glyph">❯</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 · POWERED BY ANTHROPIC</div> | |
| 36 | − <h1>An autonomous engineering agent that treats your terminal as the product.</h1> | |
| 37 | + <div class="kicker">TERMINAL-NATIVE · EVENT-SOURCED · 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 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 →</a> |
| 51 | − <a href="/docs/usage.html">The TUI in depth →</a> | |
| 53 | + <a href="/docs/phases.html">Understand → Design → Implement →</a> | |
| 54 | + <a href="/docs/daemon.html">The daemon →</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 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 · main</span> | |
| 61 | − <span class="t-dim">claude-sonnet-4-5 · thinking adaptive</span> | |
| 62 | −<span class="t-dim">────────────────────────────────────────────────────</span> | |
| 63 | − What do you want to build? | |
| 64 | − <span class="t-accent">❯</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">▸</span> Read <span class="t-b">src/session/store.ts</span> <span class="t-dim">· 212 lines</span> | |
| 67 | − <span class="t-dim">▸</span> Search <span class="t-b">"retry"</span> <span class="t-dim">· 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">▸</span> Edit <span class="t-b">src/session/store.ts</span> <span class="t-dim">· +31 −12</span> | |
| 73 | − <span class="t-dim">▸</span> Run <span class="t-b">npm test</span> <span class="t-dim">·</span> <span class="t-ok">passed</span> <span class="t-dim">· 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">✓</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">❯</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">❯_</span> | |
| 85 | − <h3>The terminal is the product</h3> | |
| 86 | − <p>Print-once scrollback, a bounded flicker-free live region, sub-16 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">▶▶</span> | |
| 90 | − <h3>First-class streaming</h3> | |
| 91 | − <p>Everything is event-driven — model text, tool calls, process output — coalesced into smooth 16 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> · <code>write</code> · <code>edit</code> (9-strategy replacer) · <code>grep</code> · <code>glob</code> · <code>bash</code> · <code>process</code> — a real background process manager, not blocking shells.</p> | |
| 97 | − </div> | |
| 98 | − <div class="feature"> | |
| 99 | − <span class="glyph">●○</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">⋮</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">↻</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">⊕</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">✎</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 ≥ 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 & 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 & 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 > ask > 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=<your key> | |
| 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 →</a> | |
| 180 | + <a href="/docs/commands.html">All commands →</a> | |
| 181 | + <a href="/docs/faq.html">FAQ →</a> | |
| 182 | + </div> | |
| 183 | + | |
| 157 | 184 | </main> |
| 158 | 185 | |
| 159 | 186 | <footer class="site-footer"><div class="inner"> |
| 160 | − <span>KHAELOR — Simon-Pierre Boucher · <a href="mailto:contact@spboucher.ai">contact@spboucher.ai</a></span> | |
| 161 | − <span>Anthropic-powered · 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 | |