feat(api): add backend with kanban, docs store, and orchestrator

Introduce the @void-nav/api Hono + SQLite backend that powers the docs site:
a persisted implementation board (kanban), a custom documentation-pages store
with AI beautify, and an agentic orchestrator that runs `pi` agents per card.

The orchestrator spawns `pi --mode rpc` inside an isolated git worktree per run,
streams slim events over SSE, and lets the agent drive the board/docs via
token-gated internal endpoints (all SQLite writes stay in-process). Interrupted
runs are reconciled to "stopped" on boot.

Workspace wiring: root `dev:api`/`dev:web` scripts with `concurrently`, the
docs Vite `/api` proxy, and `.worktrees/` gitignore.
This commit is contained in:
2026-06-16 15:43:58 -04:00
parent 57633addfe
commit efdc34637e
24 changed files with 3825 additions and 981 deletions

3
apps/api/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
# Local SQLite database and runtime artifacts
.data/
dist/

24
apps/api/package.json Normal file
View File

@@ -0,0 +1,24 @@
{
"name": "@void-nav/api",
"private": true,
"version": "0.1.0",
"type": "module",
"description": "Backend API for the VOID::NAV docs site (Implementation Board persistence).",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js",
"check": "tsc --noEmit"
},
"dependencies": {
"@hono/node-server": "^2.0.5",
"better-sqlite3": "^12.11.1",
"hono": "^4.12.25"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.13",
"@types/node": "^25.9.3",
"tsx": "^4.22.4",
"typescript": "^5.8.3"
}
}

124
apps/api/src/ai.ts Normal file
View File

@@ -0,0 +1,124 @@
/**
* AI "beautify" helper.
*
* Turns a user's raw text into structured HTML documentation with embedded
* Mermaid diagram blocks.
*
* Uses the **same model pi uses**: GLM-5.2 via the z.ai coding endpoint
* (`https://api.z.ai/api/coding/paas/v4`), an OpenAI-compatible Chat
* Completions API, with a 1,000,000-token context window and 131,072 max
* output tokens. See `~/.pi/agent/models.json` (provider `zai-coding`) for the
* canonical configuration this mirrors.
*
* The API key is resolved the same way pi resolves it: from
* `~/.pi/agent/auth.json` under `zai.key`. It can be overridden with the
* `ZAI_API_KEY` env var (useful for CI / containers). Other aspects are
* overridable too:
* - ZAI_BASE_URL (defaults to the z.ai coding v4 endpoint)
* - ZAI_MODEL (defaults to `glm-5.2`)
* - ZAI_MAX_TOKENS (defaults to 131072, matching pi's maxTokens for glm-5.2)
*
* When no key is available, `beautify` throws `AiNotConfiguredError` so the
* route layer can return a clear 503 instead of a generic failure.
*/
import { readFileSync } from 'node:fs';
import { homedir } from 'node:os';
import path from 'node:path';
const DEFAULT_BASE_URL = 'https://api.z.ai/api/coding/paas/v4';
const DEFAULT_MODEL = 'glm-5.2';
const DEFAULT_MAX_TOKENS = 131_072;
/** GLM-5.2 supports a 1,000,000-token context window (pi: contextWindow: 1000000). */
export const GLM_CONTEXT_WINDOW = 1_000_000;
export class AiNotConfiguredError extends Error {
constructor() {
super(
'AI beautify is not configured — add a key to ~/.pi/agent/auth.json (zai.key) or set ZAI_API_KEY',
);
this.name = 'AiNotConfiguredError';
}
}
/**
* Resolve the z.ai API key the same way pi does: prefer the explicit env var,
* then fall back to `~/.pi/agent/auth.json` → `zai.key`.
*/
function resolveApiKey(): string | undefined {
if (process.env.ZAI_API_KEY) return process.env.ZAI_API_KEY;
try {
const authPath = path.join(homedir(), '.pi', 'agent', 'auth.json');
const raw = readFileSync(authPath, 'utf8');
const parsed = JSON.parse(raw) as { zai?: { key?: string } };
return parsed.zai?.key;
} catch {
return undefined;
}
}
export function isAiConfigured(): boolean {
return Boolean(resolveApiKey());
}
const SYSTEM_PROMPT = `You are a technical documentation writer for VOID::NAV, a single-player
narrative-driven space exploration RPG. Convert the user's rough notes into polished,
well-structured HTML documentation that fits a dark, monospace-accented sci-fi UI.
Rules:
- Return a SINGLE HTML fragment (no <html>, <head>, <body>, or <script>).
- Use semantic tags: <h1> (once, as the page title), <h2>, <h3>, <p>, <ul>, <ol>, <li>,
<table>, <thead>, <tbody>, <tr>, <th>, <td>, <blockquote>, <code>, <pre>, <strong>, <em>.
- Do NOT add classes or inline styles; the host app provides styling.
- For any flow / architecture / sequence / relationship, include a Mermaid diagram
inside a <div class="mermaid"> ... </div> block using valid Mermaid syntax
(flowchart, sequenceDiagram, classDiagram, erDiagram, stateDiagram-v2, etc.).
- Be concise and information-dense. Preserve all factual content from the user's input.
- If the input is ambiguous, make reasonable, evocative choices consistent with a space RPG.
- Output ONLY the HTML fragment, nothing else.`;
interface BeautifyResult {
html: string;
model: string;
}
export async function beautify(rawText: string): Promise<BeautifyResult> {
const apiKey = resolveApiKey();
if (!apiKey) throw new AiNotConfiguredError();
const baseUrl = (process.env.ZAI_BASE_URL ?? DEFAULT_BASE_URL).replace(/\/$/, '');
const model = process.env.ZAI_MODEL ?? DEFAULT_MODEL;
const maxTokens = Number(process.env.ZAI_MAX_TOKENS ?? DEFAULT_MAX_TOKENS);
const res = await fetch(`${baseUrl}/chat/completions`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
model,
// Mirror pi's defaults for glm-5.2: high thinking, 1M context, 131072 max output.
thinking: { type: 'enabled' },
max_tokens: maxTokens,
temperature: 0.5,
messages: [
{ role: 'system', content: SYSTEM_PROMPT },
{ role: 'user', content: rawText },
],
}),
});
if (!res.ok) {
const detail = await res.text().catch(() => res.statusText);
throw new Error(`AI provider error ${res.status}: ${detail}`);
}
const data = (await res.json()) as {
choices?: { message?: { content?: string } }[];
};
const html = data.choices?.[0]?.message?.content?.trim() ?? '';
if (!html) throw new Error('AI provider returned an empty response');
return { html, model };
}

View File

@@ -0,0 +1,32 @@
import type { DocPage } from '../types.js';
/**
* Registry of documentation pages that kanban cards can reference.
*
* Mirrors the routes defined in `apps/docs/src/data/nav.ts` and
* `apps/docs/src/App.tsx`. When a doc page is added/removed there, update this
* list so the board's "add reference" picker and canonical links stay accurate.
*/
export const DOC_PAGES: DocPage[] = [
{ path: '/docs/overview', title: 'Overview', icon: '◈', blurb: 'Vision, pillars, core loop, onboarding flow.' },
{ path: '/docs/architecture', title: 'Architecture', icon: '⬡', blurb: 'System architecture, persistence model, audio.' },
{ path: '/docs/tech-stack', title: 'Tech Stack', icon: '⟐', blurb: 'Technology decisions and rationale.' },
{ path: '/docs/backend', title: 'Backend Model', icon: '⊞', blurb: 'Database tables, movement model, ER diagram.' },
{ path: '/docs/agents', title: 'Agent Lifecycle', icon: '⏣', blurb: 'Scheduled agent system and galaxy story agents.' },
{ path: '/docs/gameplay', title: 'Gameplay Loop', icon: '◉', blurb: 'Core loop, security, combat, missions, travel, events.' },
{ path: '/docs/ships', title: 'Ships & Fitting', icon: '◇', blurb: 'Ship classes, fitting, acquisition, AI crew.' },
{ path: '/docs/economy', title: 'Economy & Industry', icon: '⇄', blurb: 'Trade routes, refining, manufacturing, faucets/sinks.' },
{ path: '/docs/social', title: 'Progression & World', icon: '✧', blurb: 'XP & skills, chat, bounty, kill feed, corporations.' },
{ path: '/docs/ship-ai', title: 'Ship AI — Zora', icon: '◈', blurb: 'Zora companion AI, soul depth, module gating.' },
{ path: '/docs/roadmap', title: 'Roadmap', icon: '⊞', blurb: '16 phases, 2 eras, integration gates.' },
{ path: '/docs/kanban-board', title: 'Implementation Board', icon: '▦', blurb: 'This board — implementation status vs. design.' },
{ path: '/docs/risks', title: 'Risks & Questions', icon: '◬', blurb: 'Open risks and open questions.' },
{ path: '/docs/gap-analysis', title: 'Gap Analysis', icon: '□', blurb: 'Specs vs demos vs readiness.' },
{ path: '/docs/vertical-slice-evaluation', title: 'Slice Evaluation', icon: '▤', blurb: 'Alignment matrix, phase readiness.' },
{ path: '/docs/design-doc', title: 'Design Doc', icon: '▣', blurb: 'Original GDD reference.' },
];
/** Look up a page by its route path. */
export function findPage(path: string): DocPage | undefined {
return DOC_PAGES.find((p) => p.path === path);
}

View File

@@ -0,0 +1,505 @@
import type { Column, ReferenceType } from '../types.js';
/**
* Canonical card definitions — the single source of truth for card *content*
* (title, description, details, category, files, notes).
*
* On startup `seed()` upserts these into SQLite. The `status` (column) is only
* applied when a card is first created; afterwards the user's moves are
* preserved across reseeds. User-entered comments, tags, and references live
* entirely in the database.
*
* `references` link each card to the design doc pages that specify it — turning
* the board into a bridge between implementation status and design.
*/
export interface SeedReference {
label: string;
type: ReferenceType;
href: string;
}
export interface SeedCard {
id: string;
title: string;
description: string;
details: string;
category: string;
files: string | null;
notes: string | null;
status: Column;
sortOrder: number;
references: SeedReference[];
}
/** Helper for a doc-page reference. */
function doc(path: string, label: string): SeedReference {
return { label, type: 'doc', href: path };
}
const N = (s: string | null) => s;
export const CARDS: SeedCard[] = [
// ── DONE ──────────────────────────────────────────────────────────────────
{
id: 'G1',
title: 'Galaxy Generation & Viewer',
description: 'Complete procedural spiral galaxy with core cluster, disk layers, and beam structures. Interactive 3D viewer with star selection, parameter controls, and system connections.',
details: 'Fully functional: generates star systems with faction distribution, nearest-neighbor connections, and POI contents. Control panel allows real-time parameter adjustment.',
category: 'Onboarding',
files: 'apps/game/src/gameplay/galaxy/mod.rs, apps/game/src/gameplay/galaxy/contents.rs',
notes: N(null),
status: 'done',
sortOrder: 10,
references: [doc('/docs/overview', 'Overview'), doc('/docs/backend', 'Backend Model')],
},
{
id: 'G2',
title: 'Character Creation',
description: 'Bannerlord-style character generation with name selection, 4 origin archetypes, 3 ship choices, and 5-step backstory questionnaire with stat bonuses.',
details: 'Fully functional: complete UI with scrollable panels, stat calculation display, and character draft persistence for campaign.',
category: 'Onboarding',
files: 'apps/game/src/gameplay/character_creation/mod.rs',
notes: N(null),
status: 'done',
sortOrder: 11,
references: [doc('/docs/gameplay', 'Gameplay Loop'), doc('/docs/overview', 'Overview')],
},
{
id: 'G3',
title: 'Starting Base Selection',
description: 'System selection from outer-galaxy candidates with 3D visualization, POI display, and interactive camera controls.',
details: 'Fully functional: shows candidate systems from generated galaxy, with camera focus on selection and system contents preview.',
category: 'Onboarding',
files: 'apps/game/src/gameplay/starting_base/mod.rs',
notes: N(null),
status: 'done',
sortOrder: 12,
references: [doc('/docs/gameplay', 'Gameplay Loop')],
},
{
id: 'S1',
title: 'Game State Management',
description: 'AppState with clean transitions: MainMenu, Galaxy, CharacterCreation, StartingBaseSelection, InGame, Options.',
details: 'Fully functional: state system works properly with OnEnter/OnExit/Update schedules for each game phase.',
category: 'Core',
files: 'apps/game/src/state.rs',
notes: N(null),
status: 'done',
sortOrder: 20,
references: [doc('/docs/architecture', 'Architecture')],
},
{
id: 'S2',
title: 'Campaign Persistence',
description: 'Galaxy draft, character draft, and starting base selection persisted through game flow.',
details: 'Fully functional: Resources carry data through state transitions. New game properly clears previous state.',
category: 'Core',
files: 'apps/game/src/gameplay/campaign.rs',
notes: N(null),
status: 'done',
sortOrder: 21,
references: [doc('/docs/architecture', 'Architecture'), doc('/docs/backend', 'Backend Model')],
},
{
id: 'C1',
title: 'Camera System',
description: 'Three camera modes: Orbit (for galaxy inspection), Follow (for flight), Cinematic (for docked views).',
details: 'Fully functional: orbit camera with mouse controls, camera transitions between modes, proper target following.',
category: 'Core',
files: 'apps/game/src/camera.rs',
notes: N(null),
status: 'done',
sortOrder: 22,
references: [doc('/docs/gameplay', 'Gameplay Loop')],
},
{
id: 'P1',
title: 'POI Data Structures',
description: 'Complete POI type system: 7 planet types, asteroid belts, stations, stargates, anomalies, gas clouds.',
details: 'Fully functional: POI generation with orbital mechanics, faction-based biasing, and system context awareness.',
category: 'Content',
files: 'apps/game/src/gameplay/galaxy/poi.rs, apps/game/src/gameplay/galaxy/contents.rs',
notes: N(null),
status: 'done',
sortOrder: 30,
references: [doc('/docs/gameplay', 'Gameplay Loop'), doc('/docs/backend', 'Backend Model')],
},
// ── IN PROGRESS ───────────────────────────────────────────────────────────
{
id: 'I1',
title: 'In-System Scene Framework',
description: 'Scene setup with star, POIs, and player ship. Docked/Flight state management exists.',
details: 'Framework exists: scene spawns correctly, state transitions work. But UI is commented out and most gameplay features are stubs.',
category: 'In-System',
files: 'apps/game/src/gameplay/in_system/mod.rs, apps/game/src/gameplay/in_system/scene.rs',
notes: 'UI systems removed/commented. No flight HUD visible to player.',
status: 'in-progress',
sortOrder: 110,
references: [doc('/docs/gameplay', 'Gameplay Loop'), doc('/docs/architecture', 'Architecture')],
},
{
id: 'I2',
title: 'Target Selection',
description: 'Click-to-select POIs (stations, asteroid belts) with selection tracking.',
details: 'Framework exists: target selection works, but no gameplay uses the target yet. No distance display or spatial grouping.',
category: 'In-System',
files: 'apps/game/src/gameplay/in_system/target.rs',
notes: 'Selection system works but no gameplay features depend on it yet.',
status: 'in-progress',
sortOrder: 111,
references: [doc('/docs/gameplay', 'Gameplay Loop')],
},
{
id: 'I3',
title: 'Operations Framework',
description: 'Timed operations system with undock (3s), travel (5s). Framework for docking (4s) and mining (8s).',
details: 'Partial: undock and travel work. Docking and mining are skeleton operations marked TODO. No actual gameplay consequences.',
category: 'In-System',
files: 'apps/game/src/gameplay/in_system/operations.rs, apps/game/src/gameplay/in_system/flight.rs',
notes: 'Only undock and approach are implemented. Docking and mining are TODO.',
status: 'in-progress',
sortOrder: 112,
references: [doc('/docs/gameplay', 'Gameplay Loop'), doc('/docs/economy', 'Economy & Industry')],
},
{
id: 'M1',
title: 'Basic Movement',
description: 'Click-to-move navigation with kinematic steering and velocity integration.',
details: 'Partial: ship uses click-to-move + kinematic steering + velocity integration. Celestial-body orbital motion exists (movement/orbit.rs: Orbit component + update_orbits, registered in MovementPlugin). Ship flight itself is still kinematic — no physics thrust, no mass/inertia, no collision avoidance.',
category: 'In-System',
files: 'apps/game/src/gameplay/movement/{mod,kinematic,orbit,input,components}.rs',
notes: 'Orbit component drives POIs/celestial bodies, not ship flight. AI ships reuse MoveTarget for navigation.',
status: 'in-progress',
sortOrder: 120,
references: [doc('/docs/gameplay', 'Gameplay Loop'), doc('/docs/architecture', 'Architecture')],
},
{
id: 'U1',
title: 'Basic UI Shell',
description: 'Main menu, galaxy control panel, info panels. Foundation for UI exists.',
details: 'Partial: main menu and galaxy UI work. In-game flight UI is commented out. No station mode panel swap.',
category: 'UI',
files: 'apps/game/src/ui/',
notes: 'Flight HUD and station UI are removed/commented in code.',
status: 'in-progress',
sortOrder: 130,
references: [doc('/docs/overview', 'Overview')],
},
{
id: 'I5',
title: 'Contextual Action Panel',
description: 'State-aware action buttons (Undock, Approach, Dock, Start Mining, Market, Fitting, Engage) shown bottom-right during InGame.',
details: 'Built: ContextualActionPlugin registered in InSystemPlugin. Buttons are spawned/refreshed from game state (docked vs. flight, selected TargetKind) and fire ActionTriggeredEvent. Undock + Approach are wired to timed operations.',
category: 'In-System',
files: 'apps/game/src/gameplay/in_system/actions.rs',
notes: 'Dock and StartMining handlers are TODO stubs; OpenMarket/OpenFitting trigger nothing yet (await those systems).',
status: 'in-progress',
sortOrder: 113,
references: [doc('/docs/gameplay', 'Gameplay Loop')],
},
{
id: 'N1',
title: 'NPC Ships & AI',
description: 'NPC spawning by system security & faction, state-machine behaviour (Idle/Patrol/Combat/Flee/Mining/Trading), perception, navigation, faction profiles.',
details: 'Built & registered: AiPlugin runs on enter InGame + every Update. ~1.4k lines across ai/{behavior,states,navigation,perception,spawning,faction}. Perception emits events; behaviours assign MoveTargets; faction profiles drive aggression & engagement distance.',
category: 'NPCs',
files: 'apps/game/src/gameplay/ai/',
notes: 'Framework complete but unbacked by gameplay: combat "attack" is a log line (no damage system), mining/trading have no economy, flee safe-distance check is TODO.',
status: 'in-progress',
sortOrder: 140,
references: [doc('/docs/gameplay', 'Gameplay Loop'), doc('/docs/agents', 'Agent Lifecycle'), doc('/docs/social', 'Progression & World')],
},
{
id: 'ST1.1',
title: 'Event Logging System',
description: 'Capture all player actions (mining, trading, combat, exploration, docking, missions) as structured GameEvents on an event bus.',
details: 'Framework built: GameEvent enum (7 variants) + log_game_events system registered in NarrativePlugin. BUT no gameplay system emits GameEvents yet, so the bus stays empty.',
category: 'Narrative',
files: 'apps/game/src/gameplay/narrative/events.rs',
notes: 'Next step: wire emitters into mining/docking/trade/combat once those systems exist.',
status: 'in-progress',
sortOrder: 150,
references: [doc('/docs/agents', 'Agent Lifecycle'), doc('/docs/ship-ai', 'Ship AI — Zora')],
},
{
id: 'ST1.3',
title: 'Campaign History Tracking',
description: 'Persistent timeline of events with automatic chapter detection, key-moment extraction, and aggregate statistics.',
details: 'Framework built: CampaignHistory resource + detect_chapters system registered. Maintains chapters, key moments, and per-category statistics. Depends on ST1.1 emitters to actually populate.',
category: 'Narrative',
files: 'apps/game/src/gameplay/narrative/history.rs',
notes: 'History is only as rich as the events fed into it — currently empty.',
status: 'in-progress',
sortOrder: 151,
references: [doc('/docs/agents', 'Agent Lifecycle')],
},
// ── TODO ──────────────────────────────────────────────────────────────────
{
id: 'I4',
title: 'Complete In-System Gameplay',
description: 'Docking operation, mining operation with output, cargo collection, station interactions.',
details: 'Missing: docking only undocks, mining has no resource output. No station services, no refueling, no repairs.',
category: 'In-System',
files: 'apps/game/src/gameplay/in_system/operations.rs',
notes: 'Docks and mines marked as TODO in operations.rs.',
status: 'todo',
sortOrder: 210,
references: [doc('/docs/gameplay', 'Gameplay Loop'), doc('/docs/economy', 'Economy & Industry')],
},
{
id: 'U2',
title: 'Flight HUD / In-Game UI',
description: 'Target reticle, ship status display, operation progress bars, contextual action buttons.',
details: 'Missing: all in-game UI is commented out. Player has no visual feedback during flight.',
category: 'UI',
files: 'apps/game/src/gameplay/in_system/',
notes: 'UI setup and update systems are commented out in mod.rs.',
status: 'todo',
sortOrder: 220,
references: [doc('/docs/gameplay', 'Gameplay Loop')],
},
{
id: 'M2',
title: 'Star Map Plugin',
description: 'Galaxy-scale navigation showing all systems, hyperspace lanes, and current position.',
details: 'Missing: plugin structure exists but implementation is empty. No camera, no rendering, no interaction.',
category: 'Navigation',
files: 'apps/game/src/gameplay/star_map/mod.rs',
notes: 'File contains only TODO comments and empty plugin struct.',
status: 'todo',
sortOrder: 230,
references: [doc('/docs/gameplay', 'Gameplay Loop')],
},
{
id: 'P2',
title: 'Physics & Collision',
description: 'Mass-based interactions, collision detection between ships and objects, spatial queries.',
details: 'Missing: only basic geometry primitives exist. No collision system, no physics simulation.',
category: 'Core',
files: 'apps/game/src/gameplay/physics/mod.rs',
notes: 'Module exports marker components only. No actual physics systems.',
status: 'todo',
sortOrder: 23,
references: [doc('/docs/architecture', 'Architecture'), doc('/docs/gameplay', 'Gameplay Loop')],
},
{
id: 'R1',
title: 'Resource System',
description: 'Ore types, cargo inventory, resource collection during mining.',
details: 'Missing: no resource types defined, no inventory system, no cargo hold.',
category: 'Economy',
files: 'N/A',
notes: 'No resource or inventory modules exist in codebase.',
status: 'todo',
sortOrder: 240,
references: [doc('/docs/economy', 'Economy & Industry'), doc('/docs/demos/refining', 'Refining demo')],
},
{
id: 'E1',
title: 'Economy System',
description: 'Credits, market prices, trading, station markets.',
details: 'Missing: no economy simulation, no pricing, no trade mechanics.',
category: 'Economy',
files: 'N/A',
notes: 'No economy modules exist in codebase.',
status: 'todo',
sortOrder: 241,
references: [doc('/docs/economy', 'Economy & Industry'), doc('/docs/demos/market', 'Market demo')],
},
{
id: 'C2',
title: 'Ship Components',
description: 'Shields, armor, hull, capacitor, power grid, CPU.',
details: 'Missing: ships have no stats beyond position. No component systems.',
category: 'Ships',
files: 'N/A',
notes: 'Ship is just a mesh with position. No stats or systems.',
status: 'todo',
sortOrder: 250,
references: [doc('/docs/ships', 'Ships & Fitting'), doc('/docs/gameplay', 'Gameplay Loop')],
},
{
id: 'S4',
title: 'Local Save / Disk Persistence',
description: 'Continuous local persistence of campaign, character, galaxy, and story state (no manual save button).',
details: 'Missing: Cargo.toml has only `bevy` — no serde/ron/fs. CampaignDraft + CampaignHistory are in-memory Resources lost on exit. Architecture doc mandates continuous auto-save as the source of truth.',
category: 'Core',
files: 'N/A — needs serde + a save module (e.g. gameplay/save.rs)',
notes: 'Design refs: Architecture ("no save button, persists continuously"), Overview ("local persistence for V1"), AGENTS.md ("V1 is single-player with local persistence").',
status: 'todo',
sortOrder: 24,
references: [doc('/docs/architecture', 'Architecture'), doc('/docs/backend', 'Backend Model'), doc('/docs/overview', 'Overview')],
},
{
id: 'U3',
title: 'Options / Settings Screen',
description: 'Settings menu: graphics, audio, controls, accessibility. Reached from the main-menu Options button.',
details: 'Missing: AppState::Options exists and the main-menu Options button transitions to it, but there are no OnEnter/OnExit/Update systems — selecting it dead-ends.',
category: 'UI',
files: 'apps/game/src/state.rs, apps/game/src/ui/main_menu.rs',
notes: 'State variant + menu button exist; no screen implementation.',
status: 'todo',
sortOrder: 231,
references: [doc('/docs/overview', 'Overview'), doc('/docs/architecture', 'Architecture')],
},
// ── BACKLOG ───────────────────────────────────────────────────────────────
{
id: 'C3',
title: 'Combat System',
description: 'Weapons, damage resolution, shield/armor/hull pools, combat AI, target locking.',
details: 'Not started: no weapon systems, no damage calculations, no combat state. FTL-style auto-combat planned.',
category: 'Combat',
files: 'N/A',
notes: 'No combat modules exist. Design specifies FTL-style power allocation combat.',
status: 'backlog',
sortOrder: 310,
references: [doc('/docs/gameplay', 'Gameplay Loop'), doc('/docs/ships', 'Ships & Fitting'), doc('/docs/demos/combat', 'Combat demo')],
},
{
id: 'S3',
title: 'Multiple Ship Types',
description: 'Frigate, hauler, scout, warship, battleship classes with different stats.',
details: 'Not started: only generic starter ship mesh exists. No ship catalog or stat variations.',
category: 'Ships',
files: 'N/A',
notes: 'Single cone+box+sphere ship. No classes or variations.',
status: 'backlog',
sortOrder: 320,
references: [doc('/docs/ships', 'Ships & Fitting')],
},
{
id: 'F1',
title: 'Module Fitting',
description: 'High/Med/Low slots, CPU/PG constraints, module catalog, fitting screen.',
details: 'Not started: no fitting system. Design specifies full fitting with drag-and-drop.',
category: 'Ships',
files: 'N/A',
notes: 'Reference: apps/docs/src/prototypes/existing-demos/FittingDemo.tsx',
status: 'backlog',
sortOrder: 321,
references: [doc('/docs/ships', 'Ships & Fitting'), doc('/docs/demos/fitting', 'Fitting demo')],
},
{
id: 'Q1',
title: 'Quest & Mission System',
description: 'NPC agents, mission templates (kill, courier, mining), active mission tracking.',
details: 'Not started: no quest system. Design specifies 6 mission types with rewards.',
category: 'Content',
files: 'N/A',
notes: 'Reference: design docs describe faction quests and mission templates.',
status: 'backlog',
sortOrder: 330,
references: [doc('/docs/gameplay', 'Gameplay Loop'), doc('/docs/social', 'Progression & World'), doc('/docs/demos/bounty', 'Bounty demo')],
},
{
id: 'A1',
title: 'Faction System',
description: 'Faction standing, loyalty points, reputation effects, faction-controlled space.',
details: 'Not started: factions are colors only. No standing or reputation tracking.',
category: 'Social',
files: 'N/A',
notes: 'Factions exist as labels only. No gameplay effects.',
status: 'backlog',
sortOrder: 340,
references: [doc('/docs/social', 'Progression & World'), doc('/docs/gameplay', 'Gameplay Loop')],
},
{
id: 'W1',
title: 'Dynamic World Events',
description: 'Random events, anomalies, faction conflicts, world state changes.',
details: 'Not started: no event system. Design describes 6 event categories.',
category: 'Content',
files: 'N/A',
notes: 'No world event modules exist.',
status: 'backlog',
sortOrder: 331,
references: [doc('/docs/gameplay', 'Gameplay Loop'), doc('/docs/agents', 'Agent Lifecycle')],
},
{
id: 'PR1',
title: 'Skill & Progression',
description: 'XP awards, skill catalog (Mining, Industry, Trade, Gunnery, Navigation), level 0-5.',
details: 'Not started: no XP or skills. Design specifies 5 skills with tier bonuses.',
category: 'Progression',
files: 'N/A',
notes: 'Reference: apps/docs/src/prototypes/existing-demos/ProgressionDemo.tsx',
status: 'backlog',
sortOrder: 350,
references: [doc('/docs/social', 'Progression & World'), doc('/docs/demos/progression', 'Progression demo')],
},
{
id: 'MF1',
title: 'Manufacturing',
description: 'Blueprints, production jobs, material requirements, output queues.',
details: 'Not started: no manufacturing. Design: Ore→Mineral→Component→Module→Ship chain.',
category: 'Industry',
files: 'N/A',
notes: 'Reference: apps/docs/src/prototypes/existing-demos/RefiningDemo.tsx',
status: 'backlog',
sortOrder: 360,
references: [doc('/docs/economy', 'Economy & Industry'), doc('/docs/demos/refining', 'Refining demo')],
},
{
id: 'Z1',
title: 'Zora Ship AI',
description: 'AI companion with personality, soul depth, tier progression (0-5).',
details: 'Not started: no Zora implementation. Design specifies full AI companion system.',
category: 'Ships',
files: 'N/A',
notes: 'Reference: apps/docs/src/prototypes/existing-demos/ZoraDemo.tsx',
status: 'backlog',
sortOrder: 370,
references: [doc('/docs/ship-ai', 'Ship AI — Zora'), doc('/docs/demos/zora', 'Zora demo')],
},
{
id: 'ST1.2',
title: 'Narrative Synthesis Engine',
description: 'LLM-based system that weaves events into coherent narrative text.',
details: 'Stub only: synthesis.rs exists with a placeholder NarrativeSynthesis (available=false) and generate_narrative() returns a TODO string. No LLM/Claude wiring, no event aggregation; NarrativeConfig.use_llm_synthesis defaults off.',
category: 'Narrative',
files: 'apps/game/src/gameplay/narrative/synthesis.rs',
notes: 'Core differentiator. Use Claude API for story generation.',
status: 'backlog',
sortOrder: 352,
references: [doc('/docs/agents', 'Agent Lifecycle'), doc('/docs/ship-ai', 'Ship AI — Zora')],
},
{
id: 'ST1.4',
title: 'Story Log UI',
description: 'In-game interface for reading campaign narrative with chapter navigation.',
details: 'Stub only: ui.rs defines StoryLogUi/ChapterListItem components but setup/update/export fns are TODO placeholders that only log. Not registered in NarrativePlugin.',
category: 'Narrative',
files: 'apps/game/src/gameplay/narrative/ui.rs',
notes: 'Integrates with existing UI system. Should support export/save.',
status: 'backlog',
sortOrder: 353,
references: [doc('/docs/agents', 'Agent Lifecycle')],
},
{
id: 'T1',
title: 'Tutorial & Onboarding',
description: 'Guided mission sequence, skip option, tutorial hints, objective tracking.',
details: 'Not started: no tutorial system. New players have no guidance.',
category: 'UX',
files: 'N/A',
notes: 'Design specifies 7-objective tutorial chain.',
status: 'backlog',
sortOrder: 380,
references: [doc('/docs/overview', 'Overview'), doc('/docs/gameplay', 'Gameplay Loop')],
},
{
id: 'MP1',
title: 'Multiplayer / Backend',
description: 'Server auth, multiplayer sessions, cloud persistence.',
details: 'Removed: multiplayer services deleted. Game is now single-player only with local persistence.',
category: 'Infrastructure',
files: 'Removed (services/spacetimedb, services/auth)',
notes: 'Multiplayer infrastructure has been removed to focus on single-player experience.',
status: 'backlog',
sortOrder: 390,
references: [doc('/docs/architecture', 'Architecture'), doc('/docs/tech-stack', 'Tech Stack')],
},
];

196
apps/api/src/db.ts Normal file
View File

@@ -0,0 +1,196 @@
import Database from 'better-sqlite3';
import fs from 'node:fs';
import path from 'node:path';
import type { Database as BetterSqlite3Database } from 'better-sqlite3';
/**
* Local SQLite store for the kanban board.
*
* The database file lives at `<package>/.data/kanban.db` (gitignored). Using
* SQLite (rather than a JSON file) gives us proper relational integrity for
* comments/tags/references and concurrent-safe writes for free.
*/
const DATA_DIR = path.join(process.cwd(), '.data');
fs.mkdirSync(DATA_DIR, { recursive: true });
const DB_PATH = path.join(DATA_DIR, 'kanban.db');
export const db: BetterSqlite3Database = new Database(DB_PATH);
db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON');
/** Row shapes returned by better-sqlite3 (snake_case columns). */
export interface CardRow {
id: string;
title: string;
description: string;
details: string;
category: string;
files: string | null;
notes: string | null;
status: string;
sort_order: number;
updated_at: string;
}
export interface ReferenceRow {
label: string;
type: string;
href: string;
canonical: number;
}
export interface CommentRow {
id: string;
card_id: string;
text: string;
author: string;
created_at: number;
}
/** Create tables if missing. Idempotent. */
export function migrate(): void {
// The agent_* tables are new and empty in dev; if an earlier schema created
// them with non-nullable timestamp columns, recreate them so the nullable
// columns below take effect without requiring a manual DB wipe.
const cols = db.prepare('PRAGMA table_info(agent_runs)').all() as
{ name: string; notnull: number }[];
const finishedCol = cols.find((c) => c.name === 'finished_at');
if (finishedCol && finishedCol.notnull === 1) {
db.exec('DROP TABLE IF EXISTS agent_run_events');
db.exec('DROP TABLE IF EXISTS agent_runs');
}
db.exec(`
CREATE TABLE IF NOT EXISTS cards (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
description TEXT NOT NULL,
details TEXT NOT NULL,
category TEXT NOT NULL,
files TEXT,
notes TEXT,
status TEXT NOT NULL DEFAULT 'backlog',
sort_order REAL NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS card_references (
card_id TEXT NOT NULL,
label TEXT NOT NULL,
type TEXT NOT NULL,
href TEXT NOT NULL,
canonical INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (card_id, href)
);
CREATE TABLE IF NOT EXISTS comments (
id TEXT PRIMARY KEY,
card_id TEXT NOT NULL,
text TEXT NOT NULL,
author TEXT NOT NULL,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS tags (
card_id TEXT NOT NULL,
tag TEXT NOT NULL,
PRIMARY KEY (card_id, tag)
);
CREATE TABLE IF NOT EXISTS custom_pages (
id TEXT PRIMARY KEY,
slug TEXT NOT NULL UNIQUE,
title TEXT NOT NULL,
icon TEXT NOT NULL DEFAULT '◈',
blurb TEXT NOT NULL DEFAULT '',
content TEXT NOT NULL DEFAULT '',
html TEXT NOT NULL DEFAULT '',
beautified INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
-- Agentic orchestrator runs. One run works a single kanban card inside an
-- isolated git worktree, driving the board/docs via internal HTTP endpoints.
CREATE TABLE IF NOT EXISTS agent_runs (
id TEXT PRIMARY KEY,
card_id TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'queued',
use_worktree INTEGER NOT NULL DEFAULT 1,
branch TEXT,
worktree_path TEXT,
prompt TEXT NOT NULL DEFAULT '',
summary TEXT,
token TEXT NOT NULL,
commit_sha TEXT,
error TEXT,
created_at TEXT NOT NULL,
started_at TEXT,
finished_at TEXT
);
-- Slim, replayable event log streamed from each agent run.
CREATE TABLE IF NOT EXISTS agent_run_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
run_id TEXT NOT NULL,
seq INTEGER NOT NULL,
type TEXT NOT NULL,
data TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL,
UNIQUE(run_id, seq)
);
CREATE INDEX IF NOT EXISTS idx_comments_card ON comments(card_id, created_at);
CREATE INDEX IF NOT EXISTS idx_tags_card ON tags(card_id);
CREATE INDEX IF NOT EXISTS idx_refs_card ON card_references(card_id);
CREATE INDEX IF NOT EXISTS idx_agent_runs_card ON agent_runs(card_id);
CREATE INDEX IF NOT EXISTS idx_agent_runs_status ON agent_runs(status);
CREATE INDEX IF NOT EXISTS idx_agent_events_run ON agent_run_events(run_id, seq);
`);
}
/** Row shape for the agent_runs table. */
export interface AgentRunRow {
id: string;
card_id: string;
status: string;
use_worktree: number;
branch: string | null;
worktree_path: string | null;
prompt: string;
summary: string | null;
token: string;
commit_sha: string | null;
error: string | null;
created_at: string;
started_at: string | null;
finished_at: string | null;
}
/** Row shape for the agent_run_events table. */
export interface AgentRunEventRow {
id: number;
run_id: string;
seq: number;
type: string;
data: string;
created_at: string;
}
/** Row shape for the custom_pages table. */
export interface CustomPageRow {
id: string;
slug: string;
title: string;
icon: string;
blurb: string;
content: string;
html: string;
beautified: number;
created_at: string;
updated_at: string;
}
// Ensure the schema exists the moment this module is imported. Route modules
// prepare statements at top level, so tables must exist before they load.
migrate();

38
apps/api/src/index.ts Normal file
View File

@@ -0,0 +1,38 @@
import { serve } from '@hono/node-server';
import { Hono } from 'hono';
import './db.js'; // importing db runs migrate()
import { db } from './db.js';
import { seed } from './seed.js';
import { kanban } from './routes/kanban.js';
import { pages } from './routes/pages.js';
import { orchestrator } from './routes/orchestrator.js';
import { internal } from './routes/internal.js';
// Seed canonical cards on boot.
seed();
// Reconcile orchestrator runs orphaned by a previous process: anything still
// marked running/queued has no live subprocess, so mark it stopped.
{
const now = new Date().toISOString();
const result = db
.prepare("UPDATE agent_runs SET status = 'stopped', finished_at = COALESCE(finished_at, ?), error = COALESCE(error, 'interrupted by server restart') WHERE status IN ('running', 'queued')")
.run(now);
if (result.changes > 0) {
console.log(`[api] reconciled ${result.changes} interrupted agent run(s) → stopped`);
}
}
const app = new Hono();
app.get('/api/health', (c) => c.json({ ok: true }));
app.route('/api/kanban', kanban);
app.route('/api/pages', pages);
app.route('/api/orchestrator', orchestrator);
app.route('/api/internal', internal);
const port = Number(process.env.PORT ?? 3001);
serve({ fetch: app.fetch, port }, (info) => {
console.log(`[api] VOID::NAV Kanban API on http://localhost:${info.port}`);
});

109
apps/api/src/markdown.ts Normal file
View File

@@ -0,0 +1,109 @@
/**
* Minimal, dependency-free Markdown → HTML converter used to render a custom
* page's raw content when the user has not yet run "Beautify with AI".
*
* Intentionally tiny: headings, paragraphs, lists, bold/italic/code, hr, and
* fenced code blocks. The AI-beautified output is already HTML, so this is only
* for the live-edit preview / pre-beautify state.
*/
function escapeHtml(s: string): string {
return s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
function inline(s: string): string {
return s
.replace(/`([^`]+)`/g, (_, c) => `<code>${escapeHtml(c)}</code>`)
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
.replace(/\*([^*]+)\*/g, '<em>$1</em>');
}
export function renderMarkdown(src: string): string {
const lines = src.replace(/\r\n/g, '\n').split('\n');
const out: string[] = [];
let i = 0;
let inUl = false;
let inOl = false;
const closeLists = () => {
if (inUl) { out.push('</ul>'); inUl = false; }
if (inOl) { out.push('</ol>'); inOl = false; }
};
while (i < lines.length) {
const line = lines[i];
// Fenced code block — ```` ```mermaid ```` becomes a renderable diagram div.
if (line.trimStart().startsWith('```')) {
closeLists();
const info = line.trim().slice(3);
const lang = info.split(/\s+/)[0] ?? '';
const buf: string[] = [];
i++;
while (i < lines.length && !lines[i].trimStart().startsWith('```')) {
buf.push(lines[i]);
i++;
}
i++; // skip closing fence
if (lang === 'mermaid') {
out.push(`<div class="mermaid">${escapeHtml(buf.join('\n'))}</div>`);
} else {
out.push(`<pre><code data-lang="${escapeHtml(lang)}">${escapeHtml(buf.join('\n'))}</code></pre>`);
}
continue;
}
// Horizontal rule
if (/^\s*([-*_])\1\1[-*_\s]*$/.test(line)) {
closeLists();
out.push('<hr/>');
i++;
continue;
}
// Heading
const h = /^(#{1,6})\s+(.*)$/.exec(line);
if (h) {
closeLists();
const level = h[1].length;
out.push(`<h${level}>${inline(escapeHtml(h[2]))}</h${level}>`);
i++;
continue;
}
// Ordered list item
const ol = /^\s*\d+\.\s+(.*)$/.exec(line);
if (ol) {
if (!inOl) { closeLists(); out.push('<ol>'); inOl = true; }
out.push(`<li>${inline(escapeHtml(ol[1]))}</li>`);
i++;
continue;
}
// Unordered list item
const ul = /^\s*[-*+]\s+(.*)$/.exec(line);
if (ul) {
if (!inUl) { closeLists(); out.push('<ul>'); inUl = true; }
out.push(`<li>${inline(escapeHtml(ul[1]))}</li>`);
i++;
continue;
}
// Blank line
if (line.trim() === '') {
closeLists();
i++;
continue;
}
// Paragraph
closeLists();
out.push(`<p>${inline(escapeHtml(line))}</p>`);
i++;
}
closeLists();
return out.join('\n');
}

View File

@@ -0,0 +1,56 @@
/**
* Orchestrator configuration: paths, ports, and tokens.
*
* The orchestrator embeds pi as the agentic/model layer by spawning the `pi`
* CLI in RPC mode (one subprocess per run) inside an isolated git worktree. All
* kanban/documentation state still lives in this process's SQLite store; the
* agent drives it by calling this server's own token-gated HTTP endpoints.
*/
import { execSync } from 'node:child_process';
import { randomBytes } from 'node:crypto';
import path from 'node:path';
/** Resolve the git repository root from the current process cwd. */
function resolveRepoRoot(): string {
try {
return execSync('git rev-parse --show-toplevel', {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore'],
}).trim();
} catch {
return process.cwd();
}
}
/** Absolute path to the monorepo root (used for git worktrees). */
export const REPO_ROOT = resolveRepoRoot();
/** Port this API server listens on (mirrors `index.ts`). */
export const PORT = Number(process.env.PORT ?? 3001);
/** Base URL the agent subprocess uses to reach this server over loopback. */
export const API_BASE = process.env.AGENT_API_BASE ?? `http://127.0.0.1:${PORT}`;
/** Directory holding agent worktrees (gitignored). */
export const WORKTREE_ROOT = path.join(REPO_ROOT, '.worktrees');
/** Branch prefix for agent-created branches. */
export const BRANCH_PREFIX = 'agent';
/** Path to the `pi` binary (override with PI_BIN for non-standard installs). */
export const PI_BIN = process.env.PI_BIN ?? 'pi';
/** Default tool allowlist handed to pi (explore + edit + run commands). */
export const PI_TOOLS = process.env.PI_TOOLS ?? 'read,bash,edit,write,ls,find,grep';
/**
* Optional model override for orchestrator runs (e.g. `anthropic/claude-sonnet-4-5`).
* When unset, pi uses the user's configured default model.
*/
export const PI_MODEL = process.env.PI_ORCHESTRATOR_MODEL ?? '';
/** Issue a fresh secret token authorising a single run's internal API calls. */
export function newToken(): string {
return randomBytes(12).toString('hex');
}

View File

@@ -0,0 +1,155 @@
/**
* Builds the initial prompt handed to each orchestrator agent run.
*
* The agent runs inside an isolated git worktree with pi's built-in coding
* tools (read/bash/edit/write/…). It reaches this server over loopback to read
* context (the kanban board + documentation) and to record its work (card
* moves, comments, references, and documentation updates). The documentation is
* the central store of truth: the agent reads it to learn how things should
* work and updates it when it makes design decisions or completes work.
*/
import type { Card } from '../types.js';
import { API_BASE } from './config.js';
export interface PromptContext {
/** Secret token authorising this run's internal API calls. */
token: string;
/** This run's id (used in some callbacks). */
runId: string;
}
/** A compact, LLM-readable snapshot of the card being worked. */
function describeCard(card: Card): string {
const refs = card.references.length
? card.references.map((r) => `- [${r.type}] ${r.label}${r.href}`).join('\n')
: '(none)';
const tags = card.tags.length ? card.tags.join(', ') : '(none)';
const comments = card.comments.length
? card.comments
.map((c) => `- ${c.author}: ${c.text}`)
.join('\n')
: '(none)';
return [
`card_id: ${card.id}`,
`title: ${card.title}`,
`category: ${card.category}`,
`status: ${card.status}`,
`description: ${card.description}`,
`details: ${card.details}`,
`files: ${card.files ?? '(unspecified)'}`,
`notes: ${card.notes ?? '(none)'}`,
`references:\n${refs}`,
`tags: ${tags}`,
`existing comments:\n${comments}`,
].join('\n');
}
/** The full agent contract: how to read state and record work via HTTP. */
function apiContract(ctx: PromptContext): string {
const auth = `-H "x-agent-token: ${ctx.token}"`;
return `
## API contract (this server, over loopback)
All endpoints are relative to \`${API_BASE}\`. GET/read endpoints are public; the
**mutation** endpoints require the header \`x-agent-token: ${ctx.token}\`. Use \`curl\`.
### Read context (the store of truth)
- \`GET /api/kanban/board\` — every card, its status, references, comments.
- \`GET /api/kanban/pages\` — registry of static design doc pages (path/title/blurb).
- \`GET /api/pages\` — all custom (dynamic) documentation pages.
- \`GET /api/pages/:slug\` — one custom page (its \`content\` + rendered \`html\`).
### Record your work (token required)
- Post a progress/decision note onto the card:
\`\`\`bash
curl -sX POST ${API_BASE}/api/internal/cards/<card_id>/comments ${auth} \\
-H 'Content-Type: application/json' \\
-d '{"text":"..."}'
\`\`\`
- Move the card between \`backlog\`|\`todo\`|\`in-progress\` as its status changes.
**NEVER move a card to \`done\` — that returns \`403\`; only a human may complete a
card.** When you finish, leave the card \`in-progress\` and post a summary
comment so the operator can review and complete it:
\`\`\`bash
curl -sX PATCH ${API_BASE}/api/internal/cards/<card_id> ${auth} \\
-H 'Content-Type: application/json' -d '{"status":"in-progress"}'
\`\`\`
- Link the card to a doc page (existing or one you just created):
\`\`\`bash
curl -sX POST ${API_BASE}/api/internal/cards/<card_id>/references ${auth} \\
-H 'Content-Type: application/json' \\
-d '{"label":"Decision Log","type":"custom","href":"/docs/custom/decision-log"}'
\`\`\`
- Create or update a custom documentation page (upsert by slug). Use this to log
design decisions, architecture, or completed-work summaries — the docs are the
store of truth, so persist important decisions here, not just in chat:
\`\`\`bash
curl -sX POST ${API_BASE}/api/internal/pages ${auth} \\
-H 'Content-Type: application/json' -d '{
"title":"Combat Resolution — Decision Log",
"icon":"⚔",
"blurb":"How damage and hit resolution were implemented.",
"content":"# Combat resolution\\n\\n...markdown or HTML..."
}'
\`\`\`
If a page with the same slug (derived from the title) already exists it is updated.
`;
}
/**
* Compose the initial user prompt for a run.
*
* @param card the card to implement
* @param ctx run credentials
* @param userPrompt optional extra instructions from the human (steer the run)
*/
export function buildPrompt(card: Card, ctx: PromptContext, userPrompt?: string): string {
return `You are the autonomous engineering agent for VOID::NAV, a single-player
narrative space-exploration RPG. You are working one item from the implementation
board. You operate inside an isolated git worktree that is ALREADY checked out on
a dedicated branch — commit your work there.
## Repository layout
- \`apps/game\` — the playable client (Rust + Bevy 0.16). READ \`apps/game/AGENTS.md\`
and the root \`AGENTS.md\` for conventions. CRITICAL: never suppress compiler
warnings/errors (no \`#[allow(...)]\`, no \`_unused\` prefixes) — fix the cause.
- \`apps/docs\` — the living design docs (Vite + React). The documentation is the
central store of truth for HOW the game should work.
- \`apps/api\` — this backend (Hono + SQLite): kanban board + documentation store.
## Documentation is the store of truth
- READ the docs first to understand intent. Update them when you make a design
decision or finish work — persist decisions as custom pages (see API contract)
and link the card to them. Do not leave important decisions only in chat.
- Prefer small, focused doc pages with clear titles.
## Workflow
1. Read the card below and any referenced design docs.
2. Explore the relevant code (use \`read\`/\`grep\`/\`find\`/\`ls\`).
3. Plan briefly, then implement on this branch. Keep changes scoped to the card.
4. Verify: in \`apps/game\` run \`cargo check\` (and \`cargo clippy\` if relevant); in
the TS apps run \`pnpm --filter @void-nav/<pkg> check\` from the repo root.
5. Commit your work with a clear message.
6. Record outcomes: post a concise summary comment on the card, create/update the
relevant documentation page(s), and link them to the card. Leave the card in
\`in-progress\` when your work is ready for review — you may NEVER mark a card
\`done\`; only a human completes cards. (Moving it to \`done\` is rejected by the
API.) Move it to \`todo\`/\`backlog\` only if you determine it is not yet ready.
7. Stop when your work for this card is finished or genuinely blocked. Leave the
card in \`in-progress\` either way (with a comment explaining any blocker); a
human reviews and marks it \`done\`.
Be autonomous and thorough, but keep each run focused on THIS card.
${apiContract(ctx)}
${userPrompt?.trim() ? `## Additional instructions from the operator\n${userPrompt.trim()}\n` : ''}
## Your card
\`\`\`
${describeCard(card)}
\`\`\`
Begin now. Start by reading the relevant docs and code, then implement.
`;
}

View File

@@ -0,0 +1,395 @@
/**
* Runs a single orchestrator agent by spawning the `pi` CLI in RPC mode and
* translating its JSONL event stream into a slim, persisted + streamable log.
*
* Responsibilities:
* - spawn `pi --mode rpc` in the run's worktree (or the repo root)
* - send the initial prompt, plus later steer / follow-up / abort commands
* - parse stdout JSONL (commands-responses vs. agent events)
* - emit a compact event stream (`SlimEvent`) to subscribers AND persist it
* - finalize the run status (completed / failed / stopped) on exit
*
* The agent uses pi's built-in coding tools directly; kanban/docs mutations
* happen via the server's own HTTP endpoints (the agent curls them), so no
* SQLite writes cross the process boundary.
*/
import { type ChildProcess, spawn } from 'node:child_process';
import { db } from '../db.js';
import type { RunStatus } from '../types.js';
import { PI_BIN, PI_MODEL, PI_TOOLS } from './config.js';
/** Compact, UI-friendly event derived from pi's richer event stream. */
export type SlimEvent =
| { type: 'status'; status: RunStatus }
| { type: 'text'; text: string }
| { type: 'tool_start'; tool: string; preview: string }
| { type: 'tool_end'; tool: string; ok: boolean; preview: string }
| { type: 'log'; level: 'info' | 'warn' | 'error'; text: string }
| { type: 'done'; summary: string }
| { type: 'error'; message: string };
/** A slim event after it has been persisted: it now carries its sequence
* number and timestamp so SSE consumers can dedup and order reliably. */
export type PersistedEvent = SlimEvent & { seq: number; createdAt: string };
type Listener = (event: PersistedEvent) => void;
export interface RunnerOptions {
runId: string;
/** Working directory for the agent (a worktree path, or the repo root). */
cwd: string;
/** Initial prompt. */
prompt: string;
/** Called once when the run settles (status is final). */
onSettled: (status: RunStatus, summary: string | null) => void;
}
export class Runner {
readonly runId: string;
private readonly cwd: string;
private readonly prompt: string;
private readonly onSettled: (status: RunStatus, summary: string | null) => void;
private proc: ChildProcess | null = null;
private listeners = new Set<Listener>();
private buffer = ''; // stdin line buffer
private textBuffer = ''; // accumulating assistant text for the current message
private lastAssistantText = '';
private stopping = false;
private settled = false;
/** Pending steer/follow-up messages pi hasn't processed yet. The run only
* settles when the agent finishes a turn with an empty queue. */
private pending = 0;
/** Watchdog that force-settles a run if the agent stalls with a queued msg. */
private watchdog: ReturnType<typeof setTimeout> | null = null;
constructor(opts: RunnerOptions) {
this.runId = opts.runId;
this.cwd = opts.cwd;
this.prompt = opts.prompt;
this.onSettled = opts.onSettled;
}
/** Subscribe to live slim events. Returns an unsubscribe function. */
subscribe(fn: Listener): () => void {
this.listeners.add(fn);
return () => this.listeners.delete(fn);
}
/** Begin the run: spawn pi and send the prompt. */
start(): void {
const args = ['--mode', 'rpc', '--no-session', '--approve', '-n', `kanban:${this.runId.slice(0, 8)}`, '--tools', PI_TOOLS];
if (PI_MODEL) args.push('--model', PI_MODEL);
this.proc = spawn(PI_BIN, args, {
cwd: this.cwd,
stdio: ['pipe', 'pipe', 'pipe'],
env: {
...process.env,
// Keep the subprocess quiet and deterministic.
PI_SKIP_VERSION_CHECK: '1',
NO_COLOR: '1',
CLICOLOR: '0',
},
});
this.proc.stdout?.setEncoding('utf8');
this.proc.stderr?.setEncoding('utf8');
this.proc.stdout?.on('data', (chunk: string) => this.onStdout(chunk));
this.proc.stderr?.on('data', (chunk: string) => this.onStderr(chunk));
this.proc.on('error', (err) => this.fail(`failed to spawn pi: ${err.message}`));
this.proc.on('exit', (code, signal) => this.onExit(code, signal));
this.send({ type: 'prompt', message: this.prompt });
this.emit({ type: 'log', level: 'info', text: `Started pi (cwd ${this.cwd})` });
this.emit({ type: 'status', status: 'running' });
}
/** Queue a steering or follow-up message mid-run. */
message(text: string, mode: 'steer' | 'followUp'): void {
const cmd =
mode === 'followUp' ? { type: 'follow_up', message: text } : { type: 'steer', message: text };
this.send(cmd);
// Optimistically mark one more turn pending so an imminent agent_end for the
// current turn doesn't settle the run before the queued message is served.
this.pending += 1;
this.clearWatchdog();
this.emit({ type: 'log', level: 'info', text: `${mode === 'followUp' ? 'Follow-up' : 'Steer'} queued: ${text}` });
}
/** Abort the run. Status becomes 'stopped' once the process exits. */
stop(): void {
if (!this.proc || this.stopping) return;
this.stopping = true;
this.emit({ type: 'log', level: 'warn', text: 'Stopping run…' });
try {
this.send({ type: 'abort' });
} catch {
/* stdin may already be closed */
}
// Hard kill backstop if pi doesn't exit promptly after abort.
setTimeout(() => {
if (this.proc && !this.settled) {
try {
this.proc.kill('SIGTERM');
} catch {
/* ignore */
}
}
}, 15_000);
}
// --- stdin / stdout handling --------------------------------------------
private send(cmd: Record<string, unknown>): void {
this.proc?.stdin?.write(`${JSON.stringify(cmd)}\n`);
}
private onStdout(chunk: string): void {
this.buffer += chunk;
let idx: number;
while ((idx = this.buffer.indexOf('\n')) >= 0) {
const line = this.buffer.slice(0, idx).replace(/\r$/, '');
this.buffer = this.buffer.slice(idx + 1);
if (line.trim()) this.handleLine(line);
}
}
private onStderr(chunk: string): void {
const text = chunk.trim();
if (text) this.emit({ type: 'log', level: 'warn', text: `[pi stderr] ${text.slice(0, 500)}` });
}
private handleLine(line: string): void {
if (!line.startsWith('{')) return; // ignore banners / non-JSON
let msg: Record<string, unknown>;
try {
msg = JSON.parse(line);
} catch {
return;
}
switch (msg.type) {
case 'response':
// Command ack; surface failures.
if (msg.success === false) {
this.emit({ type: 'log', level: 'error', text: `command ${String(msg.command ?? '?')} rejected` });
}
break;
case 'agent_start':
// A new turn is starting (initial prompt or a queued steer/follow-up).
this.clearWatchdog();
break;
case 'agent_end':
this.lastAssistantText = extractAssistantText(msg.messages) || this.lastAssistantText;
this.maybeComplete();
break;
case 'message_update': {
const ev = msg.assistantMessageEvent as { type?: string; delta?: string } | undefined;
if (ev?.type === 'text_delta' && typeof ev.delta === 'string') this.textBuffer += ev.delta;
break;
}
case 'message_end': {
const text = extractAssistantText(msg.message) || this.textBuffer.trim();
if (text) {
this.lastAssistantText = text;
this.emit({ type: 'text', text });
}
this.textBuffer = '';
break;
}
case 'tool_execution_start':
this.emit({
type: 'tool_start',
tool: String(msg.toolName ?? 'tool'),
preview: previewArgs(msg.args),
});
break;
case 'tool_execution_end':
this.emit({
type: 'tool_end',
tool: String(msg.toolName ?? 'tool'),
ok: msg.isError !== true,
preview: previewResult(msg.result),
});
break;
case 'queue_update': {
// Reconcile our pending count with pi's authoritative queue state.
const q = msg as { steering?: unknown[]; followUp?: unknown[] };
this.pending = (q.steering?.length ?? 0) + (q.followUp?.length ?? 0);
break;
}
default:
// Other event types (compaction, retry, …) are intentionally dropped.
break;
}
}
private onExit(code: number | null, signal: NodeJS.Signals | null): void {
// Backstop: if pi exits before we settled (crash, abort, or unexpected
// close), finalize now. Normal completion is driven by `maybeComplete()`.
if (this.settled) return;
this.clearWatchdog();
const crashed = code !== null && code !== 0;
let status: RunStatus;
if (this.stopping) status = 'stopped';
else if (crashed || signal) status = 'failed';
else status = 'completed';
this.settle(status, this.lastAssistantText || null);
}
/**
* Decide whether the run is finished after an `agent_end`. RPC-mode pi does
* not exit on its own after answering; it waits for more commands. So we treat
* the run as complete when the agent finishes a turn with nothing queued, and
* close pi's stdin so it exits cleanly. A queued steer/follow-up keeps the run
* alive for another turn (guarded by a watchdog in case pi stalls).
*/
private maybeComplete(): void {
if (this.settled) return;
// An abort was requested: honor it as 'stopped' rather than 'completed'
// (pi still emits agent_end after aborting the current turn).
if (this.stopping) {
this.clearWatchdog();
this.settle('stopped', this.lastAssistantText || null);
return;
}
if (this.pending > 0) {
this.armWatchdog();
return;
}
this.clearWatchdog();
this.settle('completed', this.lastAssistantText || null);
}
private armWatchdog(): void {
this.clearWatchdog();
this.watchdog = setTimeout(() => {
if (!this.settled) {
this.emit({ type: 'log', level: 'warn', text: 'Agent stalled with a queued message; finalizing.' });
this.settle('completed', this.lastAssistantText || null);
}
}, 90_000);
}
private clearWatchdog(): void {
if (this.watchdog) {
clearTimeout(this.watchdog);
this.watchdog = null;
}
}
private fail(message: string): void {
if (this.settled) return;
this.emit({ type: 'error', message });
this.settle('failed', this.lastAssistantText || null);
}
private settle(status: RunStatus, summary: string | null): void {
if (this.settled) return;
this.settled = true;
this.clearWatchdog();
this.emit({ type: 'status', status });
if (status === 'completed') this.emit({ type: 'done', summary: summary ?? '' });
if (status === 'failed' && summary) this.emit({ type: 'text', text: summary });
try {
this.proc?.stdin?.end();
} catch {
/* ignore */
}
this.onSettled(status, summary);
}
// --- persistence + fan-out ----------------------------------------------
private emit(event: SlimEvent): void {
const persisted = persistEvent(this.runId, event);
const enriched: PersistedEvent = { ...event, seq: persisted.seq, createdAt: persisted.createdAt };
for (const fn of this.listeners) {
try {
fn(enriched);
} catch {
/* a bad listener must not break the run */
}
}
}
}
// --- helpers ---------------------------------------------------------------
const insertEvent = db.prepare(
`INSERT INTO agent_run_events (run_id, seq, type, data, created_at)
VALUES (?, ?, ?, ?, ?)`,
);
/** Persist a slim event with a per-run monotonic sequence number. */
function persistEvent(runId: string, event: SlimEvent): { seq: number; createdAt: string } {
const seq = (
db
.prepare('SELECT COALESCE(MAX(seq), 0) + 1 AS n FROM agent_run_events WHERE run_id = ?')
.get(runId) as { n: number }
).n;
const createdAt = new Date().toISOString();
const { type, ...data } = event;
insertEvent.run(runId, seq, type, JSON.stringify(data), createdAt);
return { seq, createdAt };
}
/** Extract concatenated assistant text from a message-like object or from the
* `messages` array of an `agent_end` event (last assistant message wins). */
function extractAssistantText(source: unknown): string {
if (Array.isArray(source)) {
for (let i = source.length - 1; i >= 0; i--) {
const m = source[i] as { role?: string };
if (m && m.role === 'assistant') {
const text = textFromMessage(m);
if (text) return text;
}
}
return '';
}
return textFromMessage(source);
}
function textFromMessage(message: unknown): string {
if (!message || typeof message !== 'object') return '';
const content = (message as { content?: unknown }).content;
if (!Array.isArray(content)) return '';
return content
.filter((b): b is { type: string; text: string } => typeof b === 'object' && b !== null && (b as { type?: string }).type === 'text' && typeof (b as { text?: unknown }).text === 'string')
.map((b) => b.text)
.join('\n')
.trim();
}
/** Short, readable preview of a tool call's arguments. */
function previewArgs(args: unknown): string {
if (!args || typeof args !== 'object') return '';
const a = args as Record<string, unknown>;
if (typeof a.command === 'string') return truncate(a.command);
const path = a.path ?? a.file_path;
if (typeof path === 'string') return truncate(String(path));
return truncate(JSON.stringify(a));
}
/** Short, readable preview of a tool result. */
function previewResult(result: unknown): string {
if (!result || typeof result !== 'object') return '';
const content = (result as { content?: unknown }).content;
if (!Array.isArray(content)) return '';
const text = content
.map((b) =>
b && typeof b === 'object' && (b as { type?: string }).type === 'text'
? String((b as { text?: unknown }).text ?? '')
: '',
)
.join('\n')
.trim();
return truncate(text);
}
function truncate(s: string, max = 280): string {
const oneLine = s.replace(/\s+/g, ' ').trim();
return oneLine.length > max ? `${oneLine.slice(0, max)}` : oneLine;
}

View File

@@ -0,0 +1,244 @@
/**
* Run manager: the single source of truth for orchestrator runs.
*
* Each run works one kanban card inside an (optional) isolated git worktree.
* The manager persists run state to SQLite, keeps active `Runner` instances in
* memory (so routes can steer/stop/stream them), and finalizes the run record
* when a runner settles — capturing the head commit and a work summary.
*/
import { randomUUID } from 'node:crypto';
import { db, type AgentRunRow } from '../db.js';
import type { AgentRun, AgentRunEvent, Card, RunStatus } from '../types.js';
import { newToken, REPO_ROOT } from './config.js';
import { buildPrompt } from './prompt.js';
import { Runner, type PersistedEvent } from './runner.js';
import { createWorktree, dirtySummary, headSha, removeWorktree, type Worktree } from './worktrees.js';
export interface StartRunInput {
cardId: string;
/** Extra operator instructions appended to the agent's prompt. */
prompt?: string;
/** Default true: run inside an isolated git worktree. */
useWorktree?: boolean;
/** If true, delete the worktree + branch when the run settles. */
cleanupOnFinish?: boolean;
}
class RunManager {
/** Active runners keyed by run id. */
private active = new Map<string, { runner: Runner; worktree: Worktree | null; cleanup: boolean }>();
/** Create and start a run for a card. Returns the persisted run. */
start(card: Card, input: StartRunInput): AgentRun {
const id = randomUUID();
const token = newToken();
const useWorktree = input.useWorktree ?? true;
const now = new Date().toISOString();
// Provision the worktree (or fall back to the repo root).
let worktree: Worktree | null = null;
let branch: string | null = null;
let worktreePath: string | null = null;
if (useWorktree) {
worktree = createWorktree(id, card.id);
branch = worktree.branch;
worktreePath = worktree.path;
}
const prompt = buildPrompt(card, { token, runId: id }, input.prompt);
try {
db.prepare(
`INSERT INTO agent_runs
(id, card_id, status, use_worktree, branch, worktree_path, prompt, token, created_at, started_at)
VALUES (?, ?, 'running', ?, ?, ?, ?, ?, ?, ?)`,
).run(id, card.id, useWorktree ? 1 : 0, branch, worktreePath, prompt, token, now, now);
const cwd = worktreePath ?? REPO_ROOT;
const runner = new Runner({
runId: id,
cwd,
prompt,
onSettled: (status, summary) => this.settle(id, status, summary),
});
this.active.set(id, { runner, worktree, cleanup: input.cleanupOnFinish ?? false });
runner.start();
} catch (err) {
// Persisting or starting failed — reclaim the worktree so we never leak one.
if (worktree) removeWorktree(id, worktree.branch);
throw err;
}
return this.get(id)!;
}
/** Whether a run is currently active (steerable / stoppable). */
isActive(id: string): boolean {
return this.active.has(id);
}
/** Send a steer/follow-up message to an active run. */
message(id: string, text: string, mode: 'steer' | 'followUp'): void {
const entry = this.active.get(id);
if (!entry) throw new RunNotFoundError(id);
entry.runner.message(text, mode);
}
/** Abort an active run. */
stop(id: string): void {
const entry = this.active.get(id);
if (!entry) throw new RunNotFoundError(id);
entry.runner.stop();
}
/** Subscribe to a run's live slim-event stream (best-effort; may be inactive). */
subscribe(id: string, fn: (event: PersistedEvent) => void): () => void {
const entry = this.active.get(id);
if (entry) return entry.runner.subscribe(fn);
return () => {};
}
/** Delete a run record (and its worktree if still present). */
remove(id: string): void {
const entry = this.active.get(id);
if (entry) throw new Error('cannot delete an active run; stop it first');
const row = this.get(id);
if (row?.worktreePath && row.branch) removeWorktree(id, row.branch);
db.prepare('DELETE FROM agent_run_events WHERE run_id = ?').run(id);
db.prepare('DELETE FROM agent_runs WHERE id = ?').run(id);
}
// --- reads ---------------------------------------------------------------
get(id: string): AgentRun | undefined {
const row = db.prepare('SELECT * FROM agent_runs WHERE id = ?').get(id) as AgentRunRow | undefined;
return row ? hydrateRun(row) : undefined;
}
/** Resolve a run by its agent token (used to authorize internal calls). */
getByToken(token: string): AgentRun | undefined {
const row = db
.prepare('SELECT * FROM agent_runs WHERE token = ? ORDER BY created_at DESC LIMIT 1')
.get(token) as AgentRunRow | undefined;
return row ? hydrateRun(row) : undefined;
}
/** Recent runs, newest first. */
list(limit = 50): AgentRun[] {
const rows = db
.prepare('SELECT * FROM agent_runs ORDER BY created_at DESC LIMIT ?')
.all(limit) as AgentRunRow[];
return rows.map(hydrateRun);
}
/** Runs for a given card (newest first). */
listForCard(cardId: string): AgentRun[] {
const rows = db
.prepare('SELECT * FROM agent_runs WHERE card_id = ? ORDER BY created_at DESC')
.all(cardId) as AgentRunRow[];
return rows.map(hydrateRun);
}
/** Replayable event history for a run, optionally after a sequence number. */
events(id: string, sinceSeq = 0): AgentRunEvent[] {
const rows = db
.prepare('SELECT * FROM agent_run_events WHERE run_id = ? AND seq > ? ORDER BY seq ASC')
.all(id, sinceSeq) as { id: number; run_id: string; seq: number; type: string; data: string; created_at: string }[];
return rows.map((r) => ({
id: r.id,
runId: r.run_id,
seq: r.seq,
type: r.type,
data: safeParse(r.data),
createdAt: r.created_at,
}));
}
// --- internal: finalize a run -------------------------------------------
private settle(id: string, status: RunStatus, summary: string | null): void {
const entry = this.active.get(id);
const now = new Date().toISOString();
let commitSha: string | null = null;
let extraError: string | null = null;
if (entry?.worktree) {
commitSha = headSha(entry.worktree.path);
const dirty = dirtySummary(entry.worktree.path);
if (dirty) {
// Uncommitted changes left behind — surface as an error note.
extraError = `worktree has uncommitted changes:\n${dirty}`;
// Persist it as a run event so it shows in the feed.
db.prepare(
`INSERT INTO agent_run_events (run_id, seq, type, data, created_at)
VALUES (?, ?, 'log', ?, ?)`,
).run(
id,
nextSeq(id),
JSON.stringify({ level: 'warn', text: extraError }),
now,
);
}
}
db.prepare(
`UPDATE agent_runs
SET status = ?, summary = ?, commit_sha = ?, error = COALESCE(?, error), finished_at = ?
WHERE id = ?`,
).run(status, summary, commitSha, extraError, now, id);
this.active.delete(id);
// Optional cleanup of the worktree once the run is done.
if (entry?.cleanup && entry.worktree) {
removeWorktree(id, entry.worktree.branch);
}
}
}
export class RunNotFoundError extends Error {
constructor(id: string) {
super(`agent run not found: ${id}`);
this.name = 'RunNotFoundError';
}
}
// --- helpers ---------------------------------------------------------------
function hydrateRun(row: AgentRunRow): AgentRun {
return {
id: row.id,
cardId: row.card_id,
status: row.status as RunStatus,
useWorktree: row.use_worktree === 1,
branch: row.branch,
worktreePath: row.worktree_path,
prompt: row.prompt,
summary: row.summary,
commitSha: row.commit_sha,
error: row.error,
createdAt: row.created_at,
startedAt: row.started_at,
finishedAt: row.finished_at,
};
}
function nextSeq(runId: string): number {
return (
db
.prepare('SELECT COALESCE(MAX(seq), 0) + 1 AS n FROM agent_run_events WHERE run_id = ?')
.get(runId) as { n: number }
).n;
}
function safeParse(s: string): Record<string, unknown> {
try {
return JSON.parse(s) as Record<string, unknown>;
} catch {
return {};
}
}
/** Process-wide singleton. */
export const runManager = new RunManager();

View File

@@ -0,0 +1,90 @@
/**
* Git worktree management for isolated agent runs.
*
* Each run optionally gets its own worktree on a dedicated branch off the
* current `HEAD`. This keeps `main` clean, isolates heavy per-task build
* artifacts (e.g. the Rust `target/` dir), and lets multiple runs proceed in
* parallel without clobbering each other's working tree.
*/
import { execSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import { BRANCH_PREFIX, REPO_ROOT, WORKTREE_ROOT } from './config.js';
export interface Worktree {
path: string;
branch: string;
}
/** Run a git command, returning trimmed stdout. */
function git(args: string[], opts?: { cwd?: string }): string {
return execSync(['git', ...args].join(' '), {
encoding: 'utf8',
cwd: opts?.cwd ?? REPO_ROOT,
stdio: ['ignore', 'pipe', 'pipe'],
}).trim();
}
/** Filesystem path for a run's worktree. */
export function worktreePathFor(runId: string): string {
return path.join(WORKTREE_ROOT, runId);
}
/** Stable branch name derived from the card id + run id. */
export function branchNameFor(cardId: string, runId: string): string {
const safe = cardId.replace(/[^a-z0-9-]+/gi, '-').toLowerCase().replace(/^-+|-+$/g, '').slice(0, 24);
return `${BRANCH_PREFIX}/${safe || 'card'}-${runId.slice(0, 8)}`;
}
/**
* Create a new worktree on a fresh branch off `HEAD`.
*
* Throws if the branch already exists or the worktree path is taken; callers
* are expected to use a unique run id.
*/
export function createWorktree(runId: string, cardId: string): Worktree {
fs.mkdirSync(WORKTREE_ROOT, { recursive: true });
const wtPath = worktreePathFor(runId);
const branch = branchNameFor(cardId, runId);
git(['worktree', 'add', '-b', branch, JSON.stringify(wtPath)]);
return { path: wtPath, branch };
}
/** Short SHA at the head of a worktree (best-effort). */
export function headSha(wtPath: string): string | null {
try {
return git(['rev-parse', '--short', 'HEAD'], { cwd: wtPath });
} catch {
return null;
}
}
/** Best-effort uncommitted change summary in a worktree (`git status --short`). */
export function dirtySummary(wtPath: string): string {
try {
const out = git(['status', '--short'], { cwd: wtPath });
if (!out) return '';
const files = out.split('\n');
return `${files.length} changed file(s):\n${files.slice(0, 12).join('\n')}${files.length > 12 ? '\n…' : ''}`;
} catch {
return '';
}
}
/** Remove a worktree and delete its branch (best-effort, never throws). */
export function removeWorktree(runId: string, branch: string | null): void {
const wtPath = worktreePathFor(runId);
try {
git(['worktree', 'remove', '--force', JSON.stringify(wtPath)]);
} catch {
/* already gone */
}
if (branch) {
try {
git(['branch', '-D', branch]);
} catch {
/* branch may be checked out elsewhere or already deleted */
}
}
}

View File

@@ -0,0 +1,202 @@
/**
* Internal, agent-facing routes.
*
* These are the endpoints the orchestrator agent calls (via `curl` from its
* `pi` subprocess) to record its work: posting card comments, moving cards,
* adding references, and upserting documentation pages. They are the only way
* the kanban/docs SQLite store is mutated by an agent, keeping all writes in
* this process.
*
* Every mutation requires an `x-agent-token` header matching a run's token.
* Tokens are single-use-per-run secrets issued at run creation and baked into
* the agent's prompt, so they never need to be shared with the UI.
*/
import { Hono } from 'hono';
import { randomUUID } from 'node:crypto';
import { db } from '../db.js';
import type { Column } from '../types.js';
import { isColumn } from '../types.js';
import { renderMarkdown } from '../markdown.js';
import { runManager } from '../orchestrator/runs.js';
export const internal = new Hono<{
Variables: { runId: string; cardId: string };
}>();
/** Middleware: require a valid agent token and attach the owning run's card id. */
internal.use('*', async (c, next) => {
const token = c.req.header('x-agent-token');
if (!token) return c.json({ error: 'x-agent-token header required' }, 401);
const run = runManager.getByToken(token);
if (!run) return c.json({ error: 'invalid or expired agent token' }, 401);
c.set('runId', run.id);
c.set('cardId', run.cardId);
await next();
});
/** Post a comment on the run's card as the agent. */
internal.post('/cards/:id/comments', async (c) => {
const cardId = c.req.param('id');
if (!(cardExists(cardId))) return c.json({ error: 'card not found' }, 404);
const body = await c.req.json().catch(() => ({}) as { text?: unknown });
const text = typeof body.text === 'string' ? body.text.trim() : '';
if (!text) return c.json({ error: "'text' is required" }, 400);
const runId = c.get('runId');
const comment = {
id: randomUUID(),
cardId,
text,
author: 'Agent',
createdAt: Date.now(),
};
db.prepare(
'INSERT INTO comments (id, card_id, text, author, created_at) VALUES (?, ?, ?, ?, ?)',
).run(comment.id, comment.cardId, comment.text, comment.author, comment.createdAt);
if (runId) {
logEvent(runId, 'log', { level: 'info', text: `Comment on ${cardId}: ${text.slice(0, 160)}` });
}
return c.json(comment, 201);
});
/** Move the run's card to a new column. */
internal.patch('/cards/:id', async (c) => {
const cardId = c.req.param('id');
if (!cardExists(cardId)) return c.json({ error: 'card not found' }, 404);
const body = await c.req.json().catch(() => ({}) as { status?: unknown });
if (!isColumn(body.status)) return c.json({ error: "body must include a valid 'status'" }, 400);
// Only a human may mark a card done — an agent must never complete cards.
if (body.status === 'done') {
return c.json(
{
error:
"agents cannot set a card to 'done'. Leave it 'in-progress' with a summary comment; a human reviews and completes the card.",
},
403,
);
}
const now = new Date().toISOString();
db.prepare('UPDATE cards SET status = ?, updated_at = ? WHERE id = ?').run(
body.status as Column,
now,
cardId,
);
const runId = c.get('runId');
if (runId) logEvent(runId, 'log', { level: 'info', text: `Moved ${cardId}${body.status}` });
return c.json({ cardId, status: body.status });
});
/** Add a user reference (doc/custom/external) to a card. */
internal.post('/cards/:id/references', async (c) => {
const cardId = c.req.param('id');
if (!cardExists(cardId)) return c.json({ error: 'card not found' }, 404);
const body = await c.req.json().catch(() => ({}) as Record<string, unknown>);
const label = typeof body.label === 'string' ? body.label.trim() : '';
const href = typeof body.href === 'string' ? body.href.trim() : '';
const type = typeof body.type === 'string' ? body.type : 'doc';
if (!label || !href) return c.json({ error: "'label' and 'href' are required" }, 400);
db.prepare(
'INSERT OR REPLACE INTO card_references (card_id, label, type, href, canonical) VALUES (?, ?, ?, ?, 0)',
).run(cardId, label, type, href);
const runId = c.get('runId');
if (runId) logEvent(runId, 'log', { level: 'info', text: `Linked ${cardId}${href}` });
return c.json({ cardId, label, type, href, removable: true }, 201);
});
interface UpsertPageBody {
title?: unknown;
icon?: unknown;
blurb?: unknown;
content?: unknown;
slug?: unknown;
}
/**
* Create or update a custom documentation page (upsert by slug).
*
* This is how the agent persists design decisions and completed-work summaries
* into the documentation — the central store of truth. Content containing HTML
* is stored verbatim as beautified HTML; otherwise it is rendered from markdown.
*/
internal.post('/pages', async (c) => {
const body = await c.req.json().catch(() => ({}) as UpsertPageBody);
const title = typeof body.title === 'string' && body.title.trim() ? body.title.trim() : 'Untitled';
const icon = typeof body.icon === 'string' && body.icon.trim() ? body.icon.trim().slice(0, 4) : '◈';
const blurb = typeof body.blurb === 'string' ? body.blurb.trim() : '';
const content = typeof body.content === 'string' ? body.content : '';
if (!content.trim()) return c.json({ error: "'content' is required" }, 400);
const slug = (typeof body.slug === 'string' && body.slug.trim() ? body.slug.trim() : slugify(title)).slice(0, 64);
const looksHtml = /<[a-z][\s\S]*>/i.test(content);
const html = looksHtml ? content : renderMarkdown(content);
const now = new Date().toISOString();
const existing = db.prepare('SELECT id FROM custom_pages WHERE slug = ?').get(slug) as { id: string } | undefined;
let id: string;
if (existing) {
id = existing.id;
db.prepare(
`UPDATE custom_pages SET title = ?, icon = ?, blurb = ?, content = ?, html = ?, beautified = ?, updated_at = ? WHERE id = ?`,
).run(title, icon, blurb, content, html, looksHtml ? 1 : 0, now, id);
} else {
id = randomUUID();
db.prepare(
`INSERT INTO custom_pages (id, slug, title, icon, blurb, content, html, beautified, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
).run(id, slug, title, icon, blurb, content, html, looksHtml ? 1 : 0, now, now);
}
const runId = c.get('runId');
if (runId) logEvent(runId, 'log', { level: 'info', text: `Documented "${title}" → /docs/custom/${slug}` });
const row = db.prepare('SELECT * FROM custom_pages WHERE id = ?').get(id);
return c.json(hydratePage(row), existing ? 200 : 201);
});
// --- helpers ---------------------------------------------------------------
function cardExists(id: string): boolean {
return Boolean(db.prepare('SELECT 1 FROM cards WHERE id = ?').get(id));
}
function slugify(input: string): string {
return (
input
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 64) || `page-${randomUUID().slice(0, 8)}`
);
}
function hydratePage(row: unknown): Record<string, unknown> {
const r = row as {
id: string; slug: string; title: string; icon: string; blurb: string;
content: string; html: string; beautified: number; created_at: string; updated_at: string;
};
return {
id: r.id, slug: r.slug, title: r.title, icon: r.icon, blurb: r.blurb,
content: r.content, html: r.html, beautified: r.beautified === 1,
createdAt: r.created_at, updatedAt: r.updated_at,
};
}
/** Append a slim event to a run's persisted history (mirrors the Runner). */
function logEvent(runId: string, type: string, data: Record<string, unknown>): void {
const seq = (
db.prepare('SELECT COALESCE(MAX(seq), 0) + 1 AS n FROM agent_run_events WHERE run_id = ?').get(runId) as { n: number }
).n;
db.prepare(
'INSERT INTO agent_run_events (run_id, seq, type, data, created_at) VALUES (?, ?, ?, ?, ?)',
).run(runId, seq, type, JSON.stringify(data), new Date().toISOString());
}

View File

@@ -0,0 +1,167 @@
import { Hono } from 'hono';
import { randomUUID } from 'node:crypto';
import { db, type CardRow, type CommentRow, type ReferenceRow } from '../db.js';
import { isColumn, type Board, type Card, type Column, type Comment, type Reference } from '../types.js';
import { DOC_PAGES } from '../data/docPages.js';
import { resetBoard } from '../seed.js';
export const kanban = new Hono();
// --- hydration helpers -----------------------------------------------------
const refsForCard = db.prepare(
'SELECT label, type, href, canonical FROM card_references WHERE card_id = ?',
);
const tagsForCard = db.prepare('SELECT tag FROM tags WHERE card_id = ?');
const commentsForCard = db.prepare(
'SELECT id, card_id, text, author, created_at FROM comments WHERE card_id = ? ORDER BY created_at ASC',
);
export function hydrateCard(row: CardRow): Card {
const references: Reference[] = (refsForCard.all(row.id) as ReferenceRow[]).map((r) => ({
label: r.label,
type: r.type as Reference['type'],
href: r.href,
removable: r.canonical === 0,
}));
const tags: string[] = (tagsForCard.all(row.id) as { tag: string }[]).map((t) => t.tag);
const comments: Comment[] = (commentsForCard.all(row.id) as CommentRow[]).map((c) => ({
id: c.id,
cardId: c.card_id,
text: c.text,
author: c.author,
createdAt: c.created_at,
}));
return {
id: row.id,
title: row.title,
description: row.description,
details: row.details,
category: row.category,
files: row.files,
notes: row.notes,
status: row.status as Column,
sortOrder: row.sort_order,
comments,
tags,
references,
};
}
// --- routes ----------------------------------------------------------------
/** Full board, cards ordered by sort_order. */
kanban.get('/board', (c) => {
const rows = db.prepare('SELECT * FROM cards ORDER BY sort_order ASC').all() as CardRow[];
const cards = rows.map(hydrateCard);
const lastRow = db.prepare('SELECT MAX(updated_at) AS u FROM cards').get() as { u: string | null } | undefined;
const body: Board = { cards, lastUpdated: lastRow?.u ?? new Date().toISOString() };
return c.json(body);
});
/** Doc-page registry — drives the "add reference" picker on the frontend. */
kanban.get('/pages', (c) => c.json(DOC_PAGES));
/** Move a card to a different column. */
kanban.patch('/cards/:id', async (c) => {
const id = c.req.param('id');
const body = await c.req.json().catch(() => ({}));
if (!isColumn(body.status)) {
return c.json({ error: "body must include a valid 'status'" }, 400);
}
const result = db
.prepare('UPDATE cards SET status = ?, updated_at = ? WHERE id = ?')
.run(body.status, new Date().toISOString(), id);
if (result.changes === 0) return c.json({ error: 'card not found' }, 404);
const row = db.prepare('SELECT * FROM cards WHERE id = ?').get(id) as CardRow | undefined;
return c.json(hydrateCard(row!));
});
/** Add a comment. */
kanban.post('/cards/:id/comments', async (c) => {
const cardId = c.req.param('id');
const body = await c.req.json().catch(() => ({}));
const text = typeof body.text === 'string' ? body.text.trim() : '';
const author = typeof body.author === 'string' && body.author.trim() ? body.author.trim() : 'Anonymous';
if (!text) return c.json({ error: "'text' is required" }, 400);
if (!(db.prepare('SELECT 1 FROM cards WHERE id = ?').get(cardId))) {
return c.json({ error: 'card not found' }, 404);
}
const comment: Comment = {
id: randomUUID(),
cardId,
text,
author,
createdAt: Date.now(),
};
db.prepare(
'INSERT INTO comments (id, card_id, text, author, created_at) VALUES (?, ?, ?, ?, ?)',
).run(comment.id, comment.cardId, comment.text, comment.author, comment.createdAt);
return c.json(comment, 201);
});
/** Delete a comment. */
kanban.delete('/comments/:commentId', (c) => {
const result = db.prepare('DELETE FROM comments WHERE id = ?').run(c.req.param('commentId'));
if (result.changes === 0) return c.json({ error: 'comment not found' }, 404);
return c.body(null, 204);
});
/** Add a tag. */
kanban.post('/cards/:id/tags', async (c) => {
const cardId = c.req.param('id');
const body = await c.req.json().catch(() => ({}));
const tag = typeof body.tag === 'string' ? body.tag.trim() : '';
if (!tag) return c.json({ error: "'tag' is required" }, 400);
if (!(db.prepare('SELECT 1 FROM cards WHERE id = ?').get(cardId))) {
return c.json({ error: 'card not found' }, 404);
}
db.prepare('INSERT OR IGNORE INTO tags (card_id, tag) VALUES (?, ?)').run(cardId, tag);
return c.json({ cardId, tag }, 201);
});
/** Remove a tag. */
kanban.delete('/cards/:id/tags/:tag', (c) => {
const result = db
.prepare('DELETE FROM tags WHERE card_id = ? AND tag = ?')
.run(c.req.param('id'), decodeURIComponent(c.req.param('tag')));
if (result.changes === 0) return c.json({ error: 'tag not found' }, 404);
return c.body(null, 204);
});
/** Add a user reference (canonical references are seeded, not added here). */
kanban.post('/cards/:id/references', async (c) => {
const cardId = c.req.param('id');
const body = await c.req.json().catch(() => ({}));
const label = typeof body.label === 'string' ? body.label.trim() : '';
const href = typeof body.href === 'string' ? body.href.trim() : '';
const type = typeof body.type === 'string' ? body.type : 'doc';
if (!label || !href) return c.json({ error: "'label' and 'href' are required" }, 400);
if (!(db.prepare('SELECT 1 FROM cards WHERE id = ?').get(cardId))) {
return c.json({ error: 'card not found' }, 404);
}
db.prepare(
'INSERT OR REPLACE INTO card_references (card_id, label, type, href, canonical) VALUES (?, ?, ?, ?, 0)',
).run(cardId, label, type, href);
return c.json({ cardId, label, type, href, removable: true }, 201);
});
/** Remove a user reference (canonical references cannot be removed). */
kanban.delete('/cards/:id/references', (c) => {
const href = c.req.query('href');
if (!href) return c.json({ error: "'href' query param is required" }, 400);
const result = db
.prepare('DELETE FROM card_references WHERE card_id = ? AND href = ? AND canonical = 0')
.run(c.req.param('id'), href);
if (result.changes === 0) {
return c.json({ error: 'reference not found or is canonical' }, 404);
}
return c.body(null, 204);
});
/** Reset board to canonical state (destructive: clears user data). */
kanban.post('/reset', (c) => {
resetBoard();
return c.json({ ok: true });
});

View File

@@ -0,0 +1,148 @@
/**
* Orchestrator HTTP routes.
*
* Exposes run lifecycle (start/list/get/delete), interactive control
* (steer/stop), and a Server-Sent-Events stream of a run's slim event log. The
* agent itself runs in a `pi` subprocess and drives the board via the separate
* `internal` router; these routes are for the human-facing UI.
*/
import { Hono } from 'hono';
import { streamSSE } from 'hono/streaming';
import { db } from '../db.js';
import type { Card } from '../types.js';
import { hydrateCard } from './kanban.js';
import { RunNotFoundError, runManager } from '../orchestrator/runs.js';
export const orchestrator = new Hono();
/** Hydrate a full card (with references/tags/comments) for prompt building. */
function loadCard(cardId: string): Card | undefined {
const row = db.prepare('SELECT * FROM cards WHERE id = ?').get(cardId);
if (!row) return undefined;
return hydrateCard(row as Parameters<typeof hydrateCard>[0]);
}
/** List recent runs (optionally filtered to a card). */
orchestrator.get('/runs', (c) => {
const cardId = c.req.query('cardId');
const runs = cardId ? runManager.listForCard(cardId) : runManager.list();
return c.json({ runs });
});
/** Get a single run, including its persisted event history. */
orchestrator.get('/runs/:id', (c) => {
const run = runManager.get(c.req.param('id'));
if (!run) return c.json({ error: 'run not found' }, 404);
const since = Number(c.req.query('since') ?? '0');
return c.json({ run, events: runManager.events(run.id, Number.isFinite(since) ? since : 0) });
});
/** Start a new run for a card. */
orchestrator.post('/runs', async (c) => {
const body = await c.req.json().catch(() => ({}) as Record<string, unknown>);
const cardId = typeof body.cardId === 'string' ? body.cardId : '';
if (!cardId) return c.json({ error: "'cardId' is required" }, 400);
const card = loadCard(cardId);
if (!card) return c.json({ error: 'card not found' }, 404);
const prompt = typeof body.prompt === 'string' ? body.prompt : undefined;
const useWorktree = body.useWorktree !== false; // default true
const cleanupOnFinish = body.cleanupOnFinish === true;
try {
const run = runManager.start(card, { cardId, prompt, useWorktree, cleanupOnFinish });
return c.json({ run }, 201);
} catch (err) {
const message = err instanceof Error ? err.message : 'failed to start run';
return c.json({ error: message }, 500);
}
});
/** Steer or follow-up an active run. */
orchestrator.post('/runs/:id/message', async (c) => {
const id = c.req.param('id');
const body = await c.req.json().catch(() => ({}) as Record<string, unknown>);
const text = typeof body.text === 'string' ? body.text.trim() : '';
const mode = body.mode === 'followUp' ? 'followUp' : 'steer';
if (!text) return c.json({ error: "'text' is required" }, 400);
try {
runManager.message(id, text, mode);
return c.json({ ok: true });
} catch (err) {
if (err instanceof RunNotFoundError) return c.json({ error: 'run is not active' }, 409);
throw err;
}
});
/** Stop an active run. */
orchestrator.post('/runs/:id/stop', (c) => {
const id = c.req.param('id');
try {
runManager.stop(id);
return c.json({ ok: true });
} catch (err) {
if (err instanceof RunNotFoundError) return c.json({ error: 'run is not active' }, 409);
throw err;
}
});
/** Delete a (settled) run and reclaim its worktree. */
orchestrator.delete('/runs/:id', (c) => {
const id = c.req.param('id');
try {
runManager.remove(id);
return c.body(null, 204);
} catch (err) {
const message = err instanceof Error ? err.message : 'failed to delete run';
const status = message.includes('active') ? 409 : 404;
return c.json({ error: message }, status);
}
});
/** Live event stream for a run (Server-Sent Events). */
orchestrator.get('/runs/:id/stream', (c) => {
const id = c.req.param('id');
const run = runManager.get(id);
if (!run) return c.json({ error: 'run not found' }, 404);
return streamSSE(c, async (stream) => {
// Replay any persisted history the client hasn't seen yet.
let lastSeq = Number(c.req.query('since') ?? '0');
if (!Number.isFinite(lastSeq)) lastSeq = 0;
const flushHistory = () => {
for (const ev of runManager.events(id, lastSeq)) {
lastSeq = ev.seq;
void stream.writeSSE({ event: 'event', data: JSON.stringify({ seq: ev.seq, type: ev.type, data: ev.data, createdAt: ev.createdAt }) });
}
};
flushHistory();
// If the run is still active, subscribe to live events. Each carries its
// persisted `seq`, so advancing `lastSeq` here keeps the periodic history
// flush below from ever re-emitting the same event.
const unsubscribe = runManager.subscribe(id, (ev) => {
if (ev.seq <= lastSeq) return;
lastSeq = ev.seq;
const { seq, createdAt, type, ...data } = ev;
void stream.writeSSE({ event: 'event', data: JSON.stringify({ seq, type, data, createdAt }) });
});
// Periodic flush of persisted history catches events written straight to the
// DB (agent `curl` mutations, settle-time warnings) that bypass the live
// subscriber. Since `lastSeq` tracks everything delivered, this is a no-op
// except for those DB-direct events.
const timer = setInterval(flushHistory, 1500);
// Keep the stream open until the client disconnects. `onAbort` fires on
// disconnect; resolving the promise lets `streamSSE` finish cleanly.
await new Promise<void>((resolve) => {
stream.onAbort(() => resolve());
});
clearInterval(timer);
unsubscribe();
});
});

View File

@@ -0,0 +1,170 @@
import { Hono } from 'hono';
import { randomUUID } from 'node:crypto';
import { db, type CustomPageRow } from '../db.js';
import type { CustomPage } from '../types.js';
import { renderMarkdown } from '../markdown.js';
import { beautify, AiNotConfiguredError, isAiConfigured } from '../ai.js';
export const pages = new Hono();
/** Convert a snake_case DB row into the camelCase API shape. */
function hydrate(row: CustomPageRow): CustomPage {
return {
id: row.id,
slug: row.slug,
title: row.title,
icon: row.icon,
blurb: row.blurb,
content: row.content,
html: row.html,
beautified: row.beautified === 1,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
/** Make a string safe for use as a URL slug. */
function slugify(input: string): string {
return input
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 64) || `page-${randomUUID().slice(0, 8)}`;
}
/** Read all custom pages ordered by creation time. */
pages.get('/', (c) => {
const rows = db
.prepare('SELECT * FROM custom_pages ORDER BY created_at ASC')
.all() as CustomPageRow[];
return c.json(rows.map(hydrate));
});
/** Read a single page by slug. */
pages.get('/:slug', (c) => {
const row = db
.prepare('SELECT * FROM custom_pages WHERE slug = ?')
.get(c.req.param('slug')) as CustomPageRow | undefined;
if (!row) return c.json({ error: 'page not found' }, 404);
return c.json(hydrate(row));
});
interface CreateBody {
title?: unknown;
icon?: unknown;
blurb?: unknown;
content?: unknown;
}
/** Create a new custom page. */
pages.post('/', async (c) => {
const body = await c.req.json().catch(() => ({}) as CreateBody);
const title = typeof body.title === 'string' && body.title.trim() ? body.title.trim() : 'Untitled Page';
const icon = typeof body.icon === 'string' && body.icon.trim() ? body.icon.trim().slice(0, 4) : '◈';
const blurb = typeof body.blurb === 'string' ? body.blurb.trim() : '';
const content = typeof body.content === 'string' ? body.content : '';
const id = randomUUID();
const now = new Date().toISOString();
// Guarantee a unique slug.
let slug = slugify(title);
const exists = db.prepare('SELECT 1 FROM custom_pages WHERE slug = ?');
if (exists.get(slug)) {
slug = `${slug}-${randomUUID().slice(0, 6)}`;
}
const html = content ? renderMarkdown(content) : '';
db.prepare(
`INSERT INTO custom_pages (id, slug, title, icon, blurb, content, html, beautified, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, ?)`,
).run(id, slug, title, icon, blurb, content, html, now, now);
const row = db.prepare('SELECT * FROM custom_pages WHERE id = ?').get(id) as CustomPageRow;
return c.json(hydrate(row), 201);
});
interface UpdateBody {
title?: unknown;
icon?: unknown;
blurb?: unknown;
content?: unknown;
}
/** Update a page's editable fields. A beautified page already stores its HTML
* in `content`, so edits are kept verbatim; an un-beautified page's markdown
* `content` is re-rendered to HTML whenever it changes. */
pages.patch('/:id', async (c) => {
const id = c.req.param('id');
const row = db.prepare('SELECT * FROM custom_pages WHERE id = ?').get(id) as CustomPageRow | undefined;
if (!row) return c.json({ error: 'page not found' }, 404);
const body = await c.req.json().catch(() => ({}) as UpdateBody);
const title = typeof body.title === 'string' && body.title.trim() ? body.title.trim() : row.title;
const icon = typeof body.icon === 'string' && body.icon.trim() ? body.icon.trim().slice(0, 4) : row.icon;
const blurb = typeof body.blurb === 'string' ? body.blurb.trim() : row.blurb;
const content = typeof body.content === 'string' ? body.content : row.content;
const now = new Date().toISOString();
// A beautified page's `content` is already the AI-authored HTML, so it is
// kept verbatim (never re-rendered as markdown). An un-beautified page's
// `content` is raw markdown and is re-rendered whenever it changes.
const wasBeautified = row.beautified === 1;
const contentChanged = body.content !== undefined;
const beautified = contentChanged && !wasBeautified ? 0 : row.beautified;
const html = beautified === 1 ? content : renderMarkdown(content);
db.prepare(
`UPDATE custom_pages
SET title = ?, icon = ?, blurb = ?, content = ?, html = ?, beautified = ?, updated_at = ?
WHERE id = ?`,
).run(title, icon, blurb, content, html, beautified, now, id);
const updated = db.prepare('SELECT * FROM custom_pages WHERE id = ?').get(id) as CustomPageRow;
return c.json(hydrate(updated));
});
/** Delete a custom page. */
pages.delete('/:id', (c) => {
const result = db.prepare('DELETE FROM custom_pages WHERE id = ?').run(c.req.param('id'));
if (result.changes === 0) return c.json({ error: 'page not found' }, 404);
return c.body(null, 204);
});
/** Beautify a page's content with the AI model, replacing the raw markdown
* source with the generated HTML (which also becomes the displayed `html`). */
pages.post('/:id/beautify', async (c) => {
const id = c.req.param('id');
const row = db.prepare('SELECT * FROM custom_pages WHERE id = ?').get(id) as CustomPageRow | undefined;
if (!row) return c.json({ error: 'page not found' }, 404);
if (!row.content.trim()) return c.json({ error: 'cannot beautify an empty page' }, 400);
if (!isAiConfigured()) {
return c.json(
{ error: 'AI beautify is not configured. Set OPENAI_API_KEY on the API server.' },
503,
);
}
let result;
try {
result = await beautify(row.content);
} catch (err) {
if (err instanceof AiNotConfiguredError) {
return c.json({ error: err.message }, 503);
}
const message = err instanceof Error ? err.message : 'AI beautify failed';
return c.json({ error: message }, 502);
}
const now = new Date().toISOString();
// The beautified HTML replaces the raw markdown so the page's `content` is
// the beautified version (the old markdown is discarded).
db.prepare(
'UPDATE custom_pages SET content = ?, html = ?, beautified = 1, updated_at = ? WHERE id = ?',
).run(result.html, result.html, now, id);
const updated = db.prepare('SELECT * FROM custom_pages WHERE id = ?').get(id) as CustomPageRow;
return c.json(hydrate(updated));
});

77
apps/api/src/seed.ts Normal file
View File

@@ -0,0 +1,77 @@
import { db } from './db.js';
import { CARDS } from './data/kanbanCards.js';
/**
* Upsert canonical cards and their references into the database.
*
* - Card *content* (title, description, …) is always refreshed from the seed,
* so editing card text in `kanbanCards.ts` flows through on next startup.
* - Card *status* (column) is intentionally NOT overwritten on conflict, so a
* user's board moves survive reseeds.
* - Canonical references use `INSERT OR IGNORE`, so they self-heal if missing
* but never duplicate. They are flagged `canonical = 1` and are not removable
* from the UI.
*/
export function seed(): void {
const upsertCard = db.prepare(`
INSERT INTO cards (id, title, description, details, category, files, notes, status, sort_order, updated_at)
VALUES (@id, @title, @description, @details, @category, @files, @notes, @status, @sort_order, @updated_at)
ON CONFLICT(id) DO UPDATE SET
title = excluded.title,
description = excluded.description,
details = excluded.details,
category = excluded.category,
files = excluded.files,
notes = excluded.notes,
sort_order = excluded.sort_order,
updated_at = excluded.updated_at
`);
const upsertRef = db.prepare(`
INSERT OR IGNORE INTO card_references (card_id, label, type, href, canonical)
VALUES (?, ?, ?, ?, 1)
`);
const now = new Date().toISOString();
const tx = db.transaction(() => {
for (const c of CARDS) {
upsertCard.run({
id: c.id,
title: c.title,
description: c.description,
details: c.details,
category: c.category,
files: c.files,
notes: c.notes,
status: c.status,
sort_order: c.sortOrder,
updated_at: now,
});
for (const r of c.references) {
upsertRef.run(c.id, r.label, r.type, r.href);
}
}
});
tx();
}
/**
* Reset the board: drop all user data (comments, tags, user references) and
* restore every card's status to its canonical column. Called by the
* `POST /api/kanban/reset` endpoint.
*/
export function resetBoard(): void {
const canonical = new Map(CARDS.map((c) => [c.id, c.status]));
const tx = db.transaction(() => {
db.prepare('DELETE FROM comments').run();
db.prepare('DELETE FROM tags').run();
db.prepare('DELETE FROM card_references WHERE canonical = 0').run();
const set = db.prepare('UPDATE cards SET status = ? WHERE id = ?');
for (const [id, status] of canonical) {
set.run(status, id);
}
});
tx();
}

112
apps/api/src/types.ts Normal file
View File

@@ -0,0 +1,112 @@
/**
* Shared domain types for the Kanban API.
*
* These describe the shape of data exchanged with the docs frontend. The
* frontend re-declares a compatible set in `kanbanApi.ts` (it cannot import
* across packages without a build step); keep them in sync.
*/
/** Kanban columns, in left-to-right board order. */
export type Column = 'done' | 'in-progress' | 'todo' | 'backlog';
export const COLUMNS: Column[] = ['done', 'in-progress', 'todo', 'backlog'];
export function isColumn(value: unknown): value is Column {
return typeof value === 'string' && (COLUMNS as string[]).includes(value);
}
export type ReferenceType = 'doc' | 'code' | 'demo' | 'external' | 'custom';
/** A cross-reference shown on a card — usually a link to a design doc page. */
export interface Reference {
label: string;
type: ReferenceType;
href: string;
/** Canonical references come from the seed and cannot be removed by users. */
removable: boolean;
}
export interface Comment {
id: string;
cardId: string;
text: string;
author: string;
/** Epoch milliseconds. */
createdAt: number;
}
export interface Card {
id: string;
title: string;
description: string;
details: string;
category: string;
files: string | null;
notes: string | null;
status: Column;
sortOrder: number;
comments: Comment[];
tags: string[];
references: Reference[];
}
export interface Board {
cards: Card[];
lastUpdated: string;
}
export interface DocPage {
path: string;
title: string;
icon: string;
blurb: string;
}
/** A user-created documentation page. Before beautification, `content` is raw
* markdown-ish source and `html` is its rendered form; after beautification,
* `content` is overwritten with the AI-authored HTML (which also equals
* `html`). `beautified` records which state the page is in. */
export interface CustomPage {
id: string;
slug: string;
title: string;
icon: string;
blurb: string;
content: string;
html: string;
beautified: boolean;
createdAt: string;
updatedAt: string;
}
// --- Agentic orchestrator --------------------------------------------------
/** Lifecycle state of an agent run. */
export type RunStatus = 'queued' | 'running' | 'completed' | 'failed' | 'stopped';
/** A single agent run working one kanban card inside a git worktree. */
export interface AgentRun {
id: string;
cardId: string;
status: RunStatus;
useWorktree: boolean;
branch: string | null;
worktreePath: string | null;
prompt: string;
summary: string | null;
commitSha: string | null;
error: string | null;
createdAt: string;
startedAt: string | null;
finishedAt: string | null;
}
/** A slim, replayable event emitted during a run (persisted + streamed). */
export interface AgentRunEvent {
id: number;
runId: string;
seq: number;
type: string;
data: Record<string, unknown>;
createdAt: string;
}

19
apps/api/tsconfig.json Normal file
View File

@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2023"],
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": false,
"sourceMap": true,
"types": ["node"]
},
"include": ["src"]
}