diff --git a/README.md b/README.md index 0256bd8..f74f6b0 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,9 @@ Loop is built around six working components: - Durable local memory in `.loop/runs/*.json`, `.loop/runs/*.md`, and `.loop/latest-runs.json`. +- Loop Wiki second-brain storage in `.loop/wiki/user/*.md`, + `.loop/wiki/ai/*.json`, `.loop/wiki/index.json`, and + `.loop/wiki/graph.json`. - A shared run-state schema with objective, phase, budget, stop condition, verification evidence, approval state, and next action. - Budget and stop-condition helpers for bounded agent loops. @@ -63,6 +66,8 @@ Loop is built around six working components: - A dry-run CLI path that writes state without changing source files. - A `loop run` command that can hand an objective to Codex or Claude Code after agent selection and optional goal clarification. +- `loop wiki` commands for listing, reading, opening, and serving local + human-readable run notes. ## Quickstart @@ -101,12 +106,27 @@ If the prompt is too ambiguous for a loop, the CLI asks a short deep-interview style set of questions in the terminal, closes the interview, records the clarified objective, and then starts the selected coding agent. -Dry-run mode is still available when you only want durable state: +Dry-run mode is still available when you only want durable state and a wiki +note without source edits: ```sh loop --dry-run --objective "Build a darkwear luxury exhibition site" ``` +Read the generated second-brain notes locally: + +```sh +loop wiki list +loop wiki read +loop wiki open +loop wiki +``` + +`loop wiki` starts a localhost-only dashboard for `.loop/wiki`. The markdown +note under `.loop/wiki/user` is canonical; AI memory, index, and graph files are +derived from it. `loop run` does not start the dashboard in non-interactive +automation unless `--wiki-dashboard` is passed. + To verify the package: ```sh diff --git a/bin/loop.js b/bin/loop.js index b8d87a6..c87cad6 100755 --- a/bin/loop.js +++ b/bin/loop.js @@ -2,29 +2,44 @@ import { spawnSync } from "node:child_process"; import { readFileSync } from "node:fs"; +import { emitKeypressEvents } from "node:readline"; import { createInterface } from "node:readline/promises"; import { appendEvidence, checkRepoBoundary, createRunState, + dashboardActionForRun, + dashboardUrl, + getDashboardStatus, evaluatePolicyGate, + listWikiNotes, printHelp, + readWikiNote, recordBudgetActivity, + renderWikiList, + serveWikiDashboard, + startDetachedWikiDashboard, transitionRunState, + WIKI_FAILURE_EXIT_CODE, + waitForDashboardReady, + wikiNotePath, + writeWikiForRunState, writeRunState } from "../src/index.js"; const rawArgs = process.argv.slice(2); -const command = rawArgs[0] === "run" ? "run" : undefined; +const command = rawArgs[0] === "run" || rawArgs[0] === "wiki" ? rawArgs[0] : undefined; const args = command ? rawArgs.slice(1) : rawArgs; const packageJson = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")); const flagsWithValues = new Set([ "--agent", "--expected-remote", "--expected-root", + "--host", "--isolation", "--objective", + "--port", "--state-dir" ]); @@ -46,6 +61,30 @@ function has(flag) { return args.includes(flag); } +/** @param {string | undefined} value */ +function parsePort(value) { + if (value === undefined) { + return 3846; + } + const port = Number(value); + if (!Number.isInteger(port) || port < 1 || port > 65535) { + throw new Error(`Invalid port: ${value}`); + } + return port; +} + +function dashboardHost() { + const host = valueFor("--host") ?? "127.0.0.1"; + if (host !== "127.0.0.1") { + throw new Error("Loop Wiki dashboard only supports 127.0.0.1"); + } + return host; +} + +function dashboardPort() { + return parsePort(valueFor("--port")); +} + function positionalArgs() { /** @type {string[]} */ const positional = []; @@ -136,6 +175,68 @@ async function chooseAgent() { } } +async function chooseDashboardStart() { + if (!process.stdin.isTTY || !process.stdout.isTTY) { + return false; + } + emitKeypressEvents(process.stdin); + const previousRawMode = process.stdin.isRaw; + if (typeof process.stdin.setRawMode === "function") { + process.stdin.setRawMode(true); + } + process.stdin.resume(); + const options = ["Yes", "No"]; + let selected = 0; + const render = () => { + process.stdout.write(`\rStart Loop Wiki dashboard? ${options.map((option, index) => ( + index === selected ? `[${option}]` : ` ${option} ` + )).join(" ")} `); + }; + try { + render(); + return await new Promise((resolve) => { + /** + * @param {string} _str + * @param {{ name?: string }} key + */ + const onKeypress = (_str, key) => { + if (key.name === "left" || key.name === "right" || key.name === "tab") { + selected = selected === 0 ? 1 : 0; + render(); + return; + } + if (key.name === "y") { + selected = 0; + render(); + cleanup(true); + return; + } + if (key.name === "n" || key.name === "escape") { + selected = 1; + render(); + cleanup(false); + return; + } + if (key.name === "return" || key.name === "enter") { + cleanup(selected === 0); + } + }; + /** @param {boolean} value */ + const cleanup = (value) => { + process.stdin.off("keypress", onKeypress); + process.stdout.write("\n"); + resolve(value); + }; + process.stdin.on("keypress", onKeypress); + }); + } finally { + if (typeof process.stdin.setRawMode === "function") { + process.stdin.setRawMode(Boolean(previousRawMode)); + } + process.stdin.pause(); + } +} + /** @param {string} objective */ async function clarifyObjective(objective) { if (!needsDeepInterview(objective)) { @@ -163,6 +264,118 @@ async function clarifyObjective(objective) { } } +/** @param {unknown} error */ +function errorMessage(error) { + return error instanceof Error ? error.message : String(error); +} + +/** + * @param {string} target + */ +function openTarget(target) { + if (!process.stdout.isTTY) { + return; + } + /** @type {Partial>} */ + const commandByPlatform = { + darwin: "open", + win32: "cmd", + linux: "xdg-open" + }; + const opener = commandByPlatform[process.platform]; + if (!opener) { + return; + } + const argsForOpen = process.platform === "win32" ? ["/c", "start", "", target] : [target]; + spawnSync(opener, argsForOpen, { stdio: "ignore" }); +} + +async function handleWikiCommand() { + let stateDir; + try { + stateDir = valueFor("--state-dir") ?? ".loop"; + } catch (error) { + process.stderr.write(`${errorMessage(error)}\n\n`); + printHelp(process.stderr); + process.exit(1); + } + + const positionals = positionalArgs(); + const subcommand = positionals[0] ?? "serve"; + const id = positionals[1]; + + if (subcommand === "list") { + try { + const notes = await listWikiNotes({ stateDir }); + process.stdout.write(renderWikiList(notes)); + process.exit(0); + } catch (error) { + process.stderr.write(`Wiki list failed: ${errorMessage(error)}\n`); + process.exit(1); + } + } + + if (subcommand === "read") { + if (!id) { + process.stderr.write("loop wiki read requires a note id\n"); + process.exit(1); + } + try { + const note = await readWikiNote(id, { stateDir }); + process.stdout.write(note.markdown); + process.exit(0); + } catch (error) { + process.stderr.write(`Wiki note read failed: ${errorMessage(error)}\n`); + process.exit(1); + } + } + + if (subcommand === "open") { + if (!id) { + process.stderr.write("loop wiki open requires a note id\n"); + process.exit(1); + } + try { + await readWikiNote(id, { stateDir }); + const notePath = wikiNotePath({ stateDir, id }); + process.stdout.write(`${notePath}\n`); + openTarget(notePath); + process.exit(0); + } catch (error) { + process.stderr.write(`Wiki note open failed: ${errorMessage(error)}\n`); + process.exit(1); + } + } + + if (subcommand === "serve") { + let host; + let port; + try { + host = dashboardHost(); + port = dashboardPort(); + } catch (error) { + process.stderr.write(`${errorMessage(error)}\n\n`); + printHelp(process.stderr); + process.exit(1); + } + try { + const served = await serveWikiDashboard({ stateDir, host, port }); + process.stdout.write(`Loop Wiki dashboard: ${served.url}\n`); + if (served.server) { + await new Promise(() => {}); + } + process.exit(0); + } catch (error) { + process.stderr.write(`Loop Wiki dashboard failed: ${errorMessage(error)}\n`); + process.exit(1); + } + } + + process.stderr.write(`Unknown wiki command: ${subcommand}\n\n`); + printHelp(process.stderr); + process.exit(1); +} + /** * @param {string} objective * @param {{ writeMode: boolean }} options @@ -210,6 +423,68 @@ async function writeInitialRunState({ objective, stateDir, writeMode, agent }) { return { state: active, paths }; } +/** + * @param {import("../src/core/run-state.js").LoopRunState} state + * @param {{ jsonPath?: string, summaryPath?: string }} paths + * @param {{ stateDir: string, context: string }} options + */ +async function writeWikiOrExit(state, paths, { stateDir, context }) { + try { + return await writeWikiForRunState(state, { stateDir, paths }); + } catch (error) { + process.stderr.write(`Wiki write failed after durable state write: ${errorMessage(error)}\n`); + process.stderr.write(`Context: ${context}\n`); + if (paths.jsonPath || paths.summaryPath) { + process.stderr.write(`Durable state paths: ${JSON.stringify(paths)}\n`); + } + process.exit(WIKI_FAILURE_EXIT_CODE); + } +} + +/** + * @param {{ stateDir: string, explicitFlag: boolean, host: string, port: number }} options + */ +async function maybeStartDashboardForRun({ stateDir, explicitFlag, host, port }) { + const status = await getDashboardStatus({ host, port }); + if (status.occupied) { + process.stderr.write(`Loop Wiki dashboard port ${port} is occupied by another service; not starting dashboard.\n`); + return; + } + let consent = false; + const initialAction = dashboardActionForRun({ + dashboardRunning: status.running, + stdinTTY: Boolean(process.stdin.isTTY), + stdoutTTY: Boolean(process.stdout.isTTY), + explicitFlag + }); + let action = initialAction; + if (initialAction === "ask") { + consent = await chooseDashboardStart(); + action = dashboardActionForRun({ + dashboardRunning: false, + stdinTTY: true, + stdoutTTY: true, + explicitFlag: false, + userConsent: consent + }); + } + if (action !== "start") { + return; + } + const pid = startDetachedWikiDashboard({ + scriptPath: new URL(import.meta.url).pathname, + stateDir, + host, + port + }); + const ready = await waitForDashboardReady({ host, port }); + if (ready.running) { + process.stdout.write(`Loop Wiki dashboard: ${dashboardUrl({ host, port })}\n`); + return; + } + process.stderr.write(`Loop Wiki dashboard did not confirm startup${pid ? ` (pid ${pid})` : ""}.\n`); +} + /** * @param {"codex" | "claudecode"} agent * @param {string} prompt @@ -253,6 +528,9 @@ function agentCommand(agent, prompt, writeMode) { * @param {boolean} options.allowNoRemote * @param {string | undefined} options.isolationMode * @param {boolean} options.acknowledgeLocal + * @param {boolean} options.wikiDashboard + * @param {string} options.dashboardHost + * @param {number} options.dashboardPort */ async function runAgent({ agent, @@ -263,7 +541,10 @@ async function runAgent({ expectedRemote, allowNoRemote, isolationMode, - acknowledgeLocal + acknowledgeLocal, + wikiDashboard, + dashboardHost: host, + dashboardPort: port }) { const { state, paths } = await writeInitialRunState({ objective, stateDir, writeMode, agent }); const gate = evaluatePolicyGate(state, { @@ -296,6 +577,14 @@ async function runAgent({ process.exit(3); } + await writeWikiOrExit(state, paths, { stateDir, context: "initial run state" }); + await maybeStartDashboardForRun({ + stateDir, + explicitFlag: wikiDashboard, + host, + port + }); + const prompt = buildAgentPrompt(objective, { writeMode }); const command = agentCommand(agent, prompt, writeMode); const result = spawnSync(command.command, command.args, { @@ -318,7 +607,8 @@ async function runAgent({ }), "failed", { nextAction: `install or authenticate ${agent}, then rerun the loop` }); - await writeRunState(failed, { stateDir }); + const failedPaths = await writeRunState(failed, { stateDir }); + await writeWikiOrExit(failed, failedPaths, { stateDir, context: "agent start failure" }); process.stderr.write(`${agent} agent failed to start: ${result.error.message}\n`); process.exit(5); } @@ -343,12 +633,14 @@ async function runAgent({ nextAction: `inspect ${agent} output and rerun with a smaller objective` }); const finalPaths = await writeRunState(finalState, { stateDir }); + const wikiPaths = await writeWikiOrExit(finalState, finalPaths, { stateDir, context: "final run state" }); process.stdout.write(`${JSON.stringify({ ok: exitCode === 0, agent, stateId: finalState.id, paths: finalPaths, + wikiPaths, initialPaths: paths, exitCode }, null, 2)}\n`); @@ -365,6 +657,10 @@ if (has("--version") || has("-v")) { process.exit(0); } +if (command === "wiki") { + await handleWikiCommand(); +} + let objective; let stateDir; try { @@ -435,8 +731,9 @@ if (has("--dry-run")) { process.stderr.write(`State write failed: ${message}\n`); process.exit(4); } + const wikiPaths = await writeWikiOrExit(state, paths, { stateDir, context: "dry-run state" }); - process.stdout.write(`${JSON.stringify({ ok: true, stateId: state.id, paths }, null, 2)}\n`); + process.stdout.write(`${JSON.stringify({ ok: true, stateId: state.id, paths, wikiPaths }, null, 2)}\n`); process.exit(0); } @@ -445,11 +742,15 @@ if (command === "run" || has("--agent")) { let expectedRoot; let expectedRemote; let isolationMode; + let host; + let port; try { resolvedAgent = normalizeAgent(valueFor("--agent")) ?? await chooseAgent(); expectedRoot = valueFor("--expected-root"); expectedRemote = valueFor("--expected-remote"); isolationMode = valueFor("--isolation") ?? "local"; + host = dashboardHost(); + port = dashboardPort(); } catch (error) { process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n\n`); printHelp(process.stderr); @@ -474,7 +775,10 @@ if (command === "run" || has("--agent")) { expectedRemote, allowNoRemote: has("--allow-no-remote") || command === "run", isolationMode, - acknowledgeLocal: has("--acknowledge-local") || command === "run" + acknowledgeLocal: has("--acknowledge-local") || command === "run", + wikiDashboard: has("--wiki-dashboard"), + dashboardHost: host, + dashboardPort: port }); } diff --git a/docs/compatibility.md b/docs/compatibility.md index 0796236..aee4898 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -5,13 +5,14 @@ | Codex `$loop` | Shipped first. | `$loop ` maps to the Codex skill in `skills/loop/SKILL.md`. | | Loop CLI Codex agent | Prototype. | `loop run --agent codex "prompt"` launches `codex exec` after Loop state and safety checks. | | Loop CLI Claude Code agent | Prototype. | `loop run --agent claudecode "prompt"` launches `claude --print` after Loop state and safety checks. | +| Loop Wiki CLI | Shipped local baseline. | `loop wiki list/read/open/serve` reads `.loop/wiki` notes and serves a localhost dashboard. | | Codex `/goal` | Interop only. | Long-running goal tracking can wrap the durable plan, but the core state remains local. | | Codex automations | Read-only or triage-only by default. | Write-capable automation requires durable human approval. | | Codex worktrees | Supported as an isolation decision. | Code-changing loops should prefer worktree or branch isolation. | | Claude Code namespaced `/loop` | Roadmap. | A future native Claude command should default to a namespaced command to avoid conflicts. | | Claude Code bare `/loop` | Explicit opt-in only. | Bare `/loop` can conflict with built-in or user-customized command behavior. | | Connectors/MCP | Roadmap. | Optional after local durable state and first adapter tests are stable. | -| Issue tracker memory | Roadmap. | Local `.loop` state is the MVP baseline. | +| Issue tracker memory | Roadmap. | Local `.loop` state and Loop Wiki are the MVP baseline. | ## Command Naming diff --git a/docs/roadmap.md b/docs/roadmap.md index 7eb1a9e..53fa5bb 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,9 +1,9 @@ # Roadmap This roadmap keeps the first release honest: the MVP ships durable Loop state, -dry-run safety checks, and a prototype `loop run` surface for Codex and Claude -Code. Rich automation, native command adapters, and long-term knowledge storage -remain future work. +dry-run safety checks, a local Loop Wiki, and a prototype `loop run` surface for +Codex and Claude Code. Rich automation, native command adapters, external sync, +and hosted knowledge storage remain future work. ## Claude Code Adapter @@ -17,12 +17,12 @@ remain future work. ## Knowledge Store -- Accumulate human-readable run summaries, decisions, blockers, and evidence - into a future knowledge store. -- Shape the store like an LLM-readable project wiki so humans can inspect work - without reading raw chat transcripts. -- Keep `.loop/runs/*.json` and `.loop/runs/*.md` as the append-only baseline - until richer sync targets exist. +- Ship a local Loop Wiki baseline under `.loop/wiki`. +- Keep `.loop/wiki/user/*.md` as the canonical human-readable note. +- Derive `.loop/wiki/ai/*.json`, `.loop/wiki/index.json`, and + `.loop/wiki/graph.json` from the canonical note and run metadata. +- Keep exact token usage as `unknown` unless an agent reports it directly. +- Defer external sync to GitHub, Linear, Notion, Obsidian, or cloud services. ## Plugins And Connectors diff --git a/docs/safety.md b/docs/safety.md index 8db4818..5924f67 100644 --- a/docs/safety.md +++ b/docs/safety.md @@ -49,13 +49,28 @@ The repo-boundary preflight fails when the checkout resolves to an unexpected parent git root or remote. Dry-run mode is strict read-only. It writes durable Loop state but does not -expose source edits. +expose source edits. It also writes local Loop Wiki artifacts derived from that +state so humans can inspect the run without reading raw transcripts. Run mode can launch Codex through `codex exec` or Claude Code through `claude --print`. Write-capable agent runs must call the shared policy gate before side effects; the write-mode gate requires durable approval, isolation, and repo-boundary preflight evidence. +## Loop Wiki + +Loop Wiki is local-first: + +- `.loop/wiki/user/*.md` is the canonical human-readable note. +- `.loop/wiki/ai/*.json`, `index.json`, and `graph.json` are derived artifacts. +- Raw transcripts and private agent internals are not captured by default. +- Exact token usage stays `unknown` unless an agent reports it directly. +- The dashboard binds to localhost only and does not start in non-interactive + `loop run` unless `--wiki-dashboard` is explicit. +- After a run passes the policy gate, wiki generation is part of CLI success. + If `.loop/wiki` cannot be written, the command exits with code 6 after + preserving the durable `.loop/runs` state. + ## Human Ownership The loop can collect evidence. The engineer still owns comprehension, diff --git a/src/core/run-state.js b/src/core/run-state.js index 601e5b0..783df07 100644 --- a/src/core/run-state.js +++ b/src/core/run-state.js @@ -1,3 +1,5 @@ +import { randomBytes } from "node:crypto"; + import { isTerminalOutcome } from "./outcomes.js"; const DEFAULT_BUDGET = Object.freeze({ @@ -111,10 +113,11 @@ export function createRunState({ const timestamp = now.toISOString(); const objectiveSlug = slugifyObjective(objective); + const runNonce = randomBytes(4).toString("hex"); return { schemaVersion: 1, - id: `${objectiveSlug}-${timestamp.replace(/[:.]/g, "")}`, + id: `${objectiveSlug}-${timestamp.replace(/[:.]/g, "")}-${runNonce}`, objective, objectiveSlug, phase: "intake", diff --git a/src/core/wiki-dashboard.js b/src/core/wiki-dashboard.js new file mode 100644 index 0000000..95eb76b --- /dev/null +++ b/src/core/wiki-dashboard.js @@ -0,0 +1,235 @@ +import { spawn } from "node:child_process"; +import { createServer } from "node:http"; +import { once } from "node:events"; + +import { + listWikiNotes, + readWikiNote, + renderMarkdownHtml, + renderWikiDashboardHtml +} from "./wiki-store.js"; + +export const DEFAULT_WIKI_HOST = "127.0.0.1"; +export const DEFAULT_WIKI_PORT = 3846; +export const WIKI_FAILURE_EXIT_CODE = 6; + +/** @param {string} host */ +export function assertWikiDashboardHost(host) { + if (host !== DEFAULT_WIKI_HOST) { + throw new Error(`Loop Wiki dashboard only supports ${DEFAULT_WIKI_HOST}`); + } +} + +/** + * @param {{ host?: string, port?: number }} [options] + */ +export function dashboardUrl({ host = DEFAULT_WIKI_HOST, port = DEFAULT_WIKI_PORT } = {}) { + assertWikiDashboardHost(host); + return `http://${host}:${port}`; +} + +/** + * @param {unknown} value + * @returns {value is Record} + */ +function isRecord(value) { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** @param {unknown} error */ +function getErrorCode(error) { + return isRecord(error) && typeof error.code === "string" ? error.code : undefined; +} + +/** @param {unknown} error */ +function getNestedErrorCode(error) { + const code = getErrorCode(error); + if (code || !isRecord(error)) { + return code; + } + return getNestedErrorCode(error.cause); +} + +/** @param {number} ms */ +function delay(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** + * @param {{ host?: string, port?: number, timeoutMs?: number }} [options] + */ +export async function getDashboardStatus({ + host = DEFAULT_WIKI_HOST, + port = DEFAULT_WIKI_PORT, + timeoutMs = 250 +} = {}) { + assertWikiDashboardHost(host); + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + try { + const response = await fetch(`${dashboardUrl({ host, port })}/health`, { + signal: controller.signal + }); + if (!response.ok) { + return { running: false, occupied: true }; + } + const body = await response.json().catch(() => ({})); + return { + running: body && body.name === "loop-wiki", + occupied: !(body && body.name === "loop-wiki") + }; + } catch (error) { + if (getNestedErrorCode(error) === "ECONNREFUSED") { + return { running: false, occupied: false }; + } + return { running: false, occupied: true }; + } finally { + clearTimeout(timeout); + } +} + +/** + * @param {{ host?: string, port?: number, timeoutMs?: number, intervalMs?: number }} [options] + */ +export async function waitForDashboardReady({ + host = DEFAULT_WIKI_HOST, + port = DEFAULT_WIKI_PORT, + timeoutMs = 1500, + intervalMs = 100 +} = {}) { + assertWikiDashboardHost(host); + const deadline = Date.now() + timeoutMs; + let status = await getDashboardStatus({ host, port }); + while (!status.running && !status.occupied && Date.now() < deadline) { + await delay(intervalMs); + status = await getDashboardStatus({ host, port }); + } + return status; +} + +/** + * @param {{ stateDir?: string, host?: string, port?: number }} [options] + */ +export function createWikiServer({ + stateDir = ".loop", + host = DEFAULT_WIKI_HOST, + port = DEFAULT_WIKI_PORT +} = {}) { + assertWikiDashboardHost(host); + const server = createServer(async (request, response) => { + try { + const url = new URL(request.url ?? "/", dashboardUrl({ host, port })); + if (url.pathname === "/health") { + response.writeHead(200, { "content-type": "application/json" }); + response.end(`${JSON.stringify({ ok: true, name: "loop-wiki" })}\n`); + return; + } + if (url.pathname === "/api/index") { + const notes = await listWikiNotes({ stateDir }); + response.writeHead(200, { "content-type": "application/json" }); + response.end(`${JSON.stringify({ notes }, null, 2)}\n`); + return; + } + if (url.pathname.startsWith("/notes/")) { + const id = decodeURIComponent(url.pathname.slice("/notes/".length)); + const note = await readWikiNote(id, { stateDir }); + response.writeHead(200, { "content-type": "text/html; charset=utf-8" }); + response.end(renderMarkdownHtml(note.markdown)); + return; + } + const notes = await listWikiNotes({ stateDir }); + response.writeHead(200, { "content-type": "text/html; charset=utf-8" }); + response.end(renderWikiDashboardHtml(notes)); + } catch (error) { + response.writeHead(500, { "content-type": "text/plain; charset=utf-8" }); + response.end(error instanceof Error ? error.message : String(error)); + } + }); + return server; +} + +/** + * @param {{ stateDir?: string, host?: string, port?: number }} [options] + */ +export async function serveWikiDashboard({ + stateDir = ".loop", + host = DEFAULT_WIKI_HOST, + port = DEFAULT_WIKI_PORT +} = {}) { + assertWikiDashboardHost(host); + const status = await getDashboardStatus({ host, port }); + if (status.running) { + return { status: "already-running", url: dashboardUrl({ host, port }), server: null }; + } + if (status.occupied) { + throw new Error(`Port ${port} is already in use by another service.`); + } + + const server = createWikiServer({ stateDir, host, port }); + server.listen(port, host); + await Promise.race([ + once(server, "listening"), + once(server, "error").then(([error]) => { + throw error instanceof Error ? error : new Error(String(error)); + }) + ]); + return { status: "started", url: dashboardUrl({ host, port }), server }; +} + +/** + * @param {{ scriptPath: string, stateDir?: string, host?: string, port?: number }} options + */ +export function startDetachedWikiDashboard({ + scriptPath, + stateDir = ".loop", + host = DEFAULT_WIKI_HOST, + port = DEFAULT_WIKI_PORT +}) { + assertWikiDashboardHost(host); + const child = spawn(process.execPath, [ + scriptPath, + "wiki", + "serve", + "--state-dir", + stateDir, + "--host", + host, + "--port", + String(port) + ], { + detached: true, + stdio: "ignore" + }); + child.unref(); + return child.pid ?? null; +} + +/** + * @param {object} options + * @param {boolean} options.dashboardRunning + * @param {boolean} options.stdinTTY + * @param {boolean} options.stdoutTTY + * @param {boolean} options.explicitFlag + * @param {boolean} [options.userConsent] + */ +export function dashboardActionForRun({ + dashboardRunning, + stdinTTY, + stdoutTTY, + explicitFlag, + userConsent +}) { + if (dashboardRunning) { + return "skip-running"; + } + if (explicitFlag) { + return "start"; + } + if (stdinTTY && stdoutTTY) { + if (userConsent === undefined) { + return "ask"; + } + return userConsent ? "start" : "skip-declined"; + } + return "skip-non-interactive"; +} diff --git a/src/core/wiki-store.js b/src/core/wiki-store.js new file mode 100644 index 0000000..9454473 --- /dev/null +++ b/src/core/wiki-store.js @@ -0,0 +1,676 @@ +import { createHash } from "node:crypto"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { join, relative, resolve, sep } from "node:path"; + +const DEFAULT_STATE_DIR = ".loop"; +const SAFE_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/; +const INDEX_FILE = "index.json"; +const GRAPH_FILE = "graph.json"; + +/** + * @typedef {{ input: number | null, output: number | null, total: number | null, source: "agent-reported" | "estimated" | "unknown" }} WikiTokenUsage + * @typedef {{ target: string, relationship: string, reason: string }} WikiLink + * @typedef {{ jsonPath?: string, summaryPath?: string }} WikiRunPaths + * @typedef {{ id: string, title: string, objective: string, objectiveSlug: string, status: string, phase: string, canonicalNote: string, aiMemory: string, createdAt: string, updatedAt: string, summary: string, tags: string[], links: WikiLink[], tokens: WikiTokenUsage }} WikiIndexEntry + * @typedef {{ version: 1, updatedAt: string, notes: WikiIndexEntry[] }} WikiIndex + */ + +/** + * @param {unknown} value + * @returns {value is Record} + */ +function isRecord(value) { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** @param {unknown} error */ +function getErrorCode(error) { + return isRecord(error) && typeof error.code === "string" ? error.code : undefined; +} + +/** @param {string} value */ +function escapeMarkdown(value) { + return value.replace(/\|/g, "\\|"); +} + +/** @param {string} value */ +function escapeHtml(value) { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +/** + * @param {string} root + * @param {string} child + */ +function assertInside(root, child) { + const base = resolve(root); + const target = resolve(child); + if (target !== base && !target.startsWith(`${base}${sep}`)) { + throw new Error(`Path escapes wiki directory: ${child}`); + } +} + +/** + * @param {string} id + */ +function assertSafeId(id) { + if (!SAFE_ID_PATTERN.test(id)) { + throw new Error(`Unsafe wiki id: ${id}`); + } +} + +/** + * @param {string} stateDir + */ +export function wikiDir(stateDir = DEFAULT_STATE_DIR) { + return join(stateDir, "wiki"); +} + +/** + * @param {string} stateDir + */ +function wikiPath(stateDir = DEFAULT_STATE_DIR) { + const root = wikiDir(stateDir); + return { + root, + userDir: join(root, "user"), + aiDir: join(root, "ai"), + indexPath: join(root, INDEX_FILE), + graphPath: join(root, GRAPH_FILE) + }; +} + +/** @param {string} text */ +function hashText(text) { + return `sha256:${createHash("sha256").update(text).digest("hex")}`; +} + +/** @param {string} value */ +function shortHash(value) { + return createHash("sha256").update(value).digest("hex").slice(0, 8); +} + +/** + * @param {string} value + */ +function compactTimestamp(value) { + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + throw new Error(`Invalid run timestamp: ${value}`); + } + return date.toISOString().slice(11, 23).replace(/[:.]/g, ""); +} + +/** + * @param {string} value + */ +function datePart(value) { + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + throw new Error(`Invalid run timestamp: ${value}`); + } + return date.toISOString().slice(0, 10); +} + +/** + * @param {import("./run-state.js").LoopRunState} state + */ +export function noteIdForRunState(state) { + const id = `${datePart(state.createdAt)}-${state.objectiveSlug}-${compactTimestamp(state.createdAt)}Z-${shortHash(state.id)}`; + assertSafeId(id); + return id; +} + +/** + * @param {{ stateDir?: string, id: string }} options + */ +export function wikiNotePath({ stateDir = DEFAULT_STATE_DIR, id }) { + assertSafeId(id); + const { root, userDir } = wikiPath(stateDir); + const target = join(userDir, `${id}.md`); + assertInside(root, target); + return target; +} + +/** + * @param {{ stateDir?: string, id: string }} options + */ +export function wikiMemoryPath({ stateDir = DEFAULT_STATE_DIR, id }) { + assertSafeId(id); + const { root, aiDir } = wikiPath(stateDir); + const target = join(aiDir, `${id}.json`); + assertInside(root, target); + return target; +} + +/** + * @param {{ stateDir?: string }} [options] + * @returns {Promise} + */ +export async function readWikiIndex({ stateDir = DEFAULT_STATE_DIR } = {}) { + const { indexPath } = wikiPath(stateDir); + try { + const parsed = JSON.parse(await readFile(indexPath, "utf8")); + if (!isRecord(parsed) || parsed.version !== 1 || !Array.isArray(parsed.notes)) { + throw new Error("wiki index must be a version 1 object with notes"); + } + return { + version: 1, + updatedAt: typeof parsed.updatedAt === "string" ? parsed.updatedAt : new Date(0).toISOString(), + notes: parsed.notes.filter(isRecord).map((entry) => ({ + id: String(entry.id ?? ""), + title: String(entry.title ?? ""), + objective: String(entry.objective ?? ""), + objectiveSlug: String(entry.objectiveSlug ?? ""), + status: String(entry.status ?? ""), + phase: String(entry.phase ?? ""), + canonicalNote: String(entry.canonicalNote ?? ""), + aiMemory: String(entry.aiMemory ?? ""), + createdAt: String(entry.createdAt ?? ""), + updatedAt: String(entry.updatedAt ?? ""), + summary: String(entry.summary ?? ""), + tags: Array.isArray(entry.tags) ? entry.tags.map(String) : [], + links: Array.isArray(entry.links) + ? entry.links.filter(isRecord).map((link) => ({ + target: String(link.target ?? ""), + relationship: String(link.relationship ?? ""), + reason: String(link.reason ?? "") + })) + : [], + tokens: normalizeTokens(entry.tokens) + })).filter((entry) => SAFE_ID_PATTERN.test(entry.id)) + }; + } catch (error) { + if (getErrorCode(error) === "ENOENT") { + return { version: 1, updatedAt: new Date(0).toISOString(), notes: [] }; + } + throw error; + } +} + +/** + * @param {unknown} value + * @returns {WikiTokenUsage} + */ +function normalizeTokens(value) { + if (!isRecord(value)) { + return unknownTokenUsage(); + } + const source = value.source === "agent-reported" || value.source === "estimated" ? value.source : "unknown"; + return { + input: typeof value.input === "number" ? value.input : null, + output: typeof value.output === "number" ? value.output : null, + total: typeof value.total === "number" ? value.total : null, + source + }; +} + +/** + * @returns {WikiTokenUsage} + */ +function unknownTokenUsage() { + return { + input: null, + output: null, + total: null, + source: "unknown" + }; +} + +/** + * @param {import("./run-state.js").LoopRunState} state + */ +function statusSummary(state) { + return `${state.status} run in ${state.phase} phase. Next action: ${state.nextAction}`; +} + +/** + * @param {import("./run-state.js").LoopRunState} state + */ +function flagEntries(state) { + /** @type {{ kind: string, text: string, severity: "low" | "medium" | "high" }[]} */ + const flags = []; + if (state.status !== "complete") { + flags.push({ + kind: state.status === "failed" || state.status === "unsafe" ? "risk" : "follow_up", + text: `Run status is ${state.status}; next action is: ${state.nextAction}`, + severity: state.status === "failed" || state.status === "unsafe" ? "high" : "medium" + }); + } + if (state.verificationEvidence.length === 0) { + flags.push({ + kind: "assumption", + text: "No verification evidence has been recorded yet.", + severity: "medium" + }); + } + return flags; +} + +/** + * @param {WikiIndex} index + * @param {import("./run-state.js").LoopRunState} state + * @param {string} id + * @returns {WikiLink[]} + */ +function relatedLinks(index, state, id) { + return index.notes + .filter((note) => note.id !== id && note.objectiveSlug === state.objectiveSlug) + .slice(-5) + .map((note) => ({ + target: `../user/${note.id}.md`, + relationship: "continues", + reason: `Previous Loop Wiki note for objective slug ${state.objectiveSlug}.` + })); +} + +/** + * @param {import("./run-state.js").LoopRunState} state + * @param {{ id: string, links: WikiLink[], paths?: WikiRunPaths }} options + */ +export function renderWikiNote(state, { id, links, paths = {} }) { + const evidence = state.verificationEvidence.length === 0 + ? "No evidence recorded yet." + : state.verificationEvidence.map((entry) => `- ${entry.status}: ${entry.summary}`).join("\n"); + const flags = flagEntries(state); + const flagText = flags.length === 0 + ? "No flags recorded." + : flags.map((flag) => `- ${flag.severity}: ${flag.kind} - ${flag.text}`).join("\n"); + const linkText = links.length === 0 + ? "No related notes yet." + : links.map((link) => `- [${link.relationship}: ${link.target}](${link.target}) - ${link.reason}`).join("\n"); + const technicalRows = [ + ["Run ID", state.id], + ["Objective slug", state.objectiveSlug], + ["Phase", state.phase], + ["Status", state.status], + ["State JSON", paths.jsonPath ?? "Not provided."], + ["Run summary", paths.summaryPath ?? "Not provided."] + ]; + + return [ + `# ${state.objective}`, + "", + `> Loop Wiki note: ${id}`, + "", + "## Purpose", + "", + state.objective, + "", + "## Decisions", + "", + "No explicit decisions recorded in run state.", + "", + "## Rationale / Evidence", + "", + evidence, + "", + "## Change Summary", + "", + statusSummary(state), + "", + "## Technical Spec", + "", + "| Field | Value |", + "| --- | --- |", + ...technicalRows.map(([field, value]) => `| ${escapeMarkdown(field)} | ${escapeMarkdown(value)} |`), + "", + "## Verification Evidence", + "", + evidence, + "", + "## Flags / Risks / Follow-ups", + "", + flagText, + "", + "## Related Notes", + "", + linkText, + "", + "## Token Usage", + "", + "Exact token usage is not available from the current run state.", + "", + `Budget estimate used: ${state.budget.estimatedTokensUsed}/${state.budget.maxEstimatedTokens} estimated tokens.`, + "", + "## Machine Context", + "", + `- Run state: ${state.id}`, + `- Objective slug: ${state.objectiveSlug}`, + `- Created: ${state.createdAt}`, + `- Updated: ${state.updatedAt}`, + "" + ].join("\n"); +} + +/** + * @param {string} markdown + */ +function shortSummaryFromMarkdown(markdown) { + const paragraph = markdown + .split("\n") + .map((line) => line.trim()) + .find((line) => line && !line.startsWith("#") && !line.startsWith(">")); + return paragraph ?? "Loop Wiki note."; +} + +/** + * @param {import("./run-state.js").LoopRunState} state + * @param {{ id: string, noteRelativePath: string, markdown: string, markdownHash: string, generatedMarkdownHash: string, links: WikiLink[], paths?: WikiRunPaths }} options + */ +function buildAiMemory(state, { id, noteRelativePath, markdown, markdownHash, generatedMarkdownHash, links, paths = {} }) { + const flags = flagEntries(state); + return { + version: 1, + id, + canonicalNote: noteRelativePath, + derivedFromHash: markdownHash, + generator: { + markdownHash: generatedMarkdownHash, + source: "loop-renderer" + }, + runIds: [state.id], + objective: state.objective, + objectiveSlug: state.objectiveSlug, + summary: shortSummaryFromMarkdown(markdown), + status: state.status, + phase: state.phase, + decisions: [], + technicalSpec: { + stack: [], + entrypoints: [], + changedFiles: [], + commands: [], + runState: paths.jsonPath ?? null, + runSummary: paths.summaryPath ?? null + }, + verification: { + commands: [], + evidence: state.verificationEvidence.map((entry) => ({ + kind: entry.kind, + status: entry.status, + summary: entry.summary, + recordedAt: entry.recordedAt + })), + gaps: state.verificationEvidence.length === 0 ? ["No verification evidence recorded yet."] : [] + }, + flags, + graph: { + tags: ["loop-wiki", state.objectiveSlug, state.status], + links + }, + tokens: unknownTokenUsage(), + budgetEstimate: { + estimatedTokensUsed: state.budget.estimatedTokensUsed, + maxEstimatedTokens: state.budget.maxEstimatedTokens, + source: "loop-budget-estimate" + }, + createdAt: state.createdAt, + updatedAt: state.updatedAt + }; +} + +/** + * @param {string} memoryPath + */ +async function readPreviousGeneratedMarkdownHash(memoryPath) { + try { + const parsed = JSON.parse(await readFile(memoryPath, "utf8")); + if (!isRecord(parsed)) { + return null; + } + if (isRecord(parsed.generator) && typeof parsed.generator.markdownHash === "string") { + return parsed.generator.markdownHash; + } + return typeof parsed.generatedMarkdownHash === "string" ? parsed.generatedMarkdownHash : null; + } catch (error) { + if (getErrorCode(error) === "ENOENT") { + return null; + } + throw error; + } +} + +/** + * @param {string} notePath + */ +async function readExistingMarkdown(notePath) { + try { + return await readFile(notePath, "utf8"); + } catch (error) { + if (getErrorCode(error) === "ENOENT") { + return null; + } + throw error; + } +} + +/** + * @param {WikiIndex} index + * @param {WikiIndexEntry} entry + * @param {string} now + * @returns {WikiIndex} + */ +function upsertIndexEntry(index, entry, now) { + const notes = index.notes.filter((note) => note.id !== entry.id); + notes.push(entry); + notes.sort((a, b) => String(b.updatedAt).localeCompare(String(a.updatedAt))); + return { + version: 1, + updatedAt: now, + notes + }; +} + +/** + * @param {WikiIndex} index + * @param {string} now + */ +function buildGraph(index, now) { + const edges = index.notes.flatMap((note) => note.links.map((link) => ({ + source: note.id, + target: link.target, + relationship: link.relationship, + reason: link.reason + }))); + return { + version: 1, + updatedAt: now, + nodes: index.notes.map((note) => ({ + id: note.id, + label: note.title, + path: note.canonicalNote, + status: note.status, + tags: note.tags + })), + edges + }; +} + +/** + * @param {import("./run-state.js").LoopRunState} state + * @param {{ stateDir?: string, paths?: WikiRunPaths, now?: Date }} [options] + */ +export async function writeWikiForRunState(state, { stateDir = DEFAULT_STATE_DIR, paths = {}, now = new Date() } = {}) { + const id = noteIdForRunState(state); + const { root, userDir, aiDir, indexPath, graphPath } = wikiPath(stateDir); + await mkdir(userDir, { recursive: true }); + await mkdir(aiDir, { recursive: true }); + + const index = await readWikiIndex({ stateDir }); + const links = relatedLinks(index, state, id); + const notePath = wikiNotePath({ stateDir, id }); + const memoryPath = wikiMemoryPath({ stateDir, id }); + const noteRelativePath = relative(aiDir, notePath); + const aiRelativeFromRoot = relative(root, memoryPath); + const noteRelativeFromRoot = relative(root, notePath); + const generatedMarkdown = renderWikiNote(state, { id, links, paths }); + const generatedMarkdownHash = hashText(generatedMarkdown); + const existingMarkdown = await readExistingMarkdown(notePath); + const previousGeneratedMarkdownHash = await readPreviousGeneratedMarkdownHash(memoryPath); + const shouldRefreshMarkdown = ( + existingMarkdown === null || + (previousGeneratedMarkdownHash !== null && hashText(existingMarkdown) === previousGeneratedMarkdownHash) + ); + const markdown = shouldRefreshMarkdown ? generatedMarkdown : existingMarkdown; + const markdownHash = hashText(markdown); + const memory = buildAiMemory(state, { + id, + noteRelativePath, + markdown, + markdownHash, + generatedMarkdownHash, + links, + paths + }); + + if (shouldRefreshMarkdown) { + await writeFile(notePath, markdown); + } + await writeFile(memoryPath, `${JSON.stringify(memory, null, 2)}\n`); + + const entry = { + id, + title: state.objective, + objective: state.objective, + objectiveSlug: state.objectiveSlug, + status: state.status, + phase: state.phase, + canonicalNote: noteRelativeFromRoot, + aiMemory: aiRelativeFromRoot, + createdAt: state.createdAt, + updatedAt: state.updatedAt, + summary: memory.summary, + tags: memory.graph.tags, + links, + tokens: memory.tokens + }; + const nextIndex = upsertIndexEntry(index, entry, now.toISOString()); + await writeFile(indexPath, `${JSON.stringify(nextIndex, null, 2)}\n`); + await writeFile(graphPath, `${JSON.stringify(buildGraph(nextIndex, now.toISOString()), null, 2)}\n`); + + return { + id, + notePath, + memoryPath, + indexPath, + graphPath + }; +} + +/** + * @param {{ stateDir?: string }} [options] + */ +export async function listWikiNotes({ stateDir = DEFAULT_STATE_DIR } = {}) { + const index = await readWikiIndex({ stateDir }); + return index.notes; +} + +/** + * @param {string} id + * @param {{ stateDir?: string }} [options] + */ +export async function readWikiNote(id, { stateDir = DEFAULT_STATE_DIR } = {}) { + const notePath = wikiNotePath({ stateDir, id }); + return { + id, + path: notePath, + markdown: await readFile(notePath, "utf8") + }; +} + +/** + * @param {WikiIndexEntry[]} notes + */ +export function renderWikiList(notes) { + if (notes.length === 0) { + return "No Loop Wiki notes found.\n"; + } + return `${[ + "| ID | Status | Objective | Updated |", + "| --- | --- | --- | --- |", + ...notes.map((note) => `| ${escapeMarkdown(note.id)} | ${escapeMarkdown(note.status)} | ${escapeMarkdown(note.objective)} | ${escapeMarkdown(note.updatedAt)} |`) + ].join("\n")}\n`; +} + +/** + * @param {WikiIndexEntry[]} notes + */ +export function renderWikiDashboardHtml(notes) { + const cards = notes.length === 0 + ? "

No Loop Wiki notes found.

" + : notes.map((note) => ` +
+

${escapeHtml(note.title)}

+

${escapeHtml(note.summary)}

+
+
Status
${escapeHtml(note.status)}
+
Tokens
${note.tokens.total === null ? "unknown" : String(note.tokens.total)}
+
+ Read note +
`).join("\n"); + const graph = notes.flatMap((note) => note.links.map((link) => `${note.id} -> ${link.target}`)); + return ` + + + + + Loop Wiki + + + +
+

Loop Wiki

+

Local second brain for delegated agent work.

+
+
+
${cards}
+ +
+ +`; +} + +/** + * @param {string} markdown + */ +export function renderMarkdownHtml(markdown) { + return ` + + + + + Loop Wiki Note + + + +
${escapeHtml(markdown)}
+ +`; +} diff --git a/src/index.js b/src/index.js index 04b27be..d76199d 100644 --- a/src/index.js +++ b/src/index.js @@ -7,6 +7,28 @@ export { appendEvidence, createRunState, slugifyObjective, transitionRunState } export { assertValidRunState, validateRunState } from "./core/schema.js"; export { evaluateStopCondition } from "./core/stop.js"; export { readLatestRunBySlug, readRunState, renderRunSummary, writeRunState } from "./core/state-store.js"; +export { + listWikiNotes, + noteIdForRunState, + readWikiIndex, + readWikiNote, + renderWikiList, + renderWikiNote, + wikiNotePath, + writeWikiForRunState +} from "./core/wiki-store.js"; +export { + DEFAULT_WIKI_HOST, + DEFAULT_WIKI_PORT, + WIKI_FAILURE_EXIT_CODE, + assertWikiDashboardHost, + dashboardActionForRun, + dashboardUrl, + getDashboardStatus, + serveWikiDashboard, + startDetachedWikiDashboard, + waitForDashboardReady +} from "./core/wiki-dashboard.js"; export const packageName = "@rlaope/loop"; @@ -19,6 +41,7 @@ export function printHelp(stream) { stream.write(` loop run "prompt"\n`); stream.write(` loop run --agent codex "prompt"\n`); stream.write(` loop run --agent claudecode "prompt"\n`); + stream.write(` loop wiki [list|read |open |serve]\n`); stream.write(` loop --dry-run --objective "" [--state-dir .loop]\n`); stream.write(`\n`); stream.write(`Run without cloning:\n`); @@ -32,6 +55,8 @@ export function printHelp(stream) { stream.write(` --read-only Run the selected agent without write permissions.\n`); stream.write(` --objective Objective for the Loop run.\n`); stream.write(` --state-dir Directory for durable Loop state. Defaults to .loop.\n`); + stream.write(` --wiki-dashboard Start the local Loop Wiki dashboard for non-interactive run mode.\n`); + stream.write(` --port Port for Loop Wiki dashboard. Defaults to 3846.\n`); stream.write(` --isolation Write isolation mode: branch, worktree, or local.\n`); stream.write(` --acknowledge-local Explicitly acknowledge local-mode write risk.\n`); stream.write(` --expected-root Expected git root for write-capable runs. Defaults to cwd.\n`); @@ -39,6 +64,7 @@ export function printHelp(stream) { stream.write(` --allow-no-remote Allow write-capable runs in a local repo with no origin.\n`); stream.write(` --no-interview Skip ambiguity interview for automation or tests.\n`); stream.write(`\n`); - stream.write(`Dry-run mode writes durable Loop state only.\n`); + stream.write(`Dry-run mode writes durable Loop state and local wiki artifacts only.\n`); stream.write(`Run mode records state, asks clarifying questions when needed, then launches the selected agent.\n`); + stream.write(`Wiki mode reads local .loop/wiki notes and serves a localhost dashboard.\n`); } diff --git a/test/adapter.test.js b/test/adapter.test.js index bcf6f86..bc9ffb5 100644 --- a/test/adapter.test.js +++ b/test/adapter.test.js @@ -1,7 +1,7 @@ import test from "node:test"; import assert from "node:assert/strict"; import { execFileSync, spawnSync } from "node:child_process"; -import { chmod, mkdtemp, readFile, writeFile } from "node:fs/promises"; +import { chmod, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises"; import { join, resolve } from "node:path"; import { tmpdir } from "node:os"; @@ -37,6 +37,47 @@ test("CLI dry-run writes durable state without source edits", async () => { assert.equal(parsed.ok, true); assert.equal(state.objective, "Dry maintenance"); assert.equal(state.verificationEvidence[0].status, "passed"); + assert.match(parsed.wikiPaths.notePath, /wiki\/user/); +}); + +test("CLI wiki list and read expose dry-run notes", async () => { + const stateDir = await mkdtemp(join(tmpdir(), "loop-cli-wiki-state-")); + const output = execFileSync( + process.execPath, + ["bin/loop.js", "--dry-run", "--objective", "Wiki maintenance", "--state-dir", stateDir], + { encoding: "utf8" } + ); + const parsed = JSON.parse(output); + const list = execFileSync( + process.execPath, + ["bin/loop.js", "wiki", "list", "--state-dir", stateDir, "--host", "0.0.0.0", "--port", "not-a-port"], + { encoding: "utf8" } + ); + const read = execFileSync( + process.execPath, + ["bin/loop.js", "wiki", "read", parsed.wikiPaths.id, "--state-dir", stateDir], + { encoding: "utf8" } + ); + + assert.match(list, new RegExp(parsed.wikiPaths.id)); + assert.match(read, /# Wiki maintenance/); + assert.match(read, /## Token Usage/); +}); + +test("CLI dry-run reports wiki failure after durable state write", async () => { + const stateDir = await mkdtemp(join(tmpdir(), "loop-cli-wiki-fail-")); + await writeFile(join(stateDir, "wiki"), "occupied"); + + const result = spawnSync( + process.execPath, + ["bin/loop.js", "--dry-run", "--objective", "Wiki failure", "--state-dir", stateDir], + { encoding: "utf8" } + ); + const runFiles = await readdir(join(stateDir, "runs")); + + assert.equal(result.status, 6); + assert.match(result.stderr, /Wiki write failed after durable state write/); + assert.ok(runFiles.some((file) => file.endsWith(".json"))); }); /** @param {string[]} args @param {string} cwd */ @@ -131,6 +172,121 @@ test("CLI codex agent mode runs through policy gate and records state", async () assert.equal(state.verificationEvidence.at(-1).status, "passed"); assert.deepEqual(codexArgs.slice(0, 2), ["exec", "--sandbox"]); assert.ok(codexArgs.includes("workspace-write")); + assert.match(output.wikiPaths.notePath, /wiki\/user/); +}); + +test("CLI run policy failure keeps exit 3 and skips wiki", async () => { + const repo = await mkdtemp(join(tmpdir(), "loop-policy-fail-repo-")); + const stateDir = join(repo, ".loop"); + git(["init", "-b", "main"], repo); + + const result = spawnSync( + process.execPath, + [ + resolve("bin/loop.js"), + "run", + "--agent", + "codex", + "--no-interview", + "--state-dir", + stateDir, + "--expected-root", + join(repo, "elsewhere"), + "Build a darkwear luxury website MVP" + ], + { cwd: repo, encoding: "utf8" } + ); + + assert.equal(result.status, 3); + assert.match(result.stderr, /Policy gate failed:/); + await assert.rejects(() => readdir(join(stateDir, "wiki")), /ENOENT/); +}); + +test("CLI run stops before agent when initial wiki write fails", async () => { + const repo = await mkdtemp(join(tmpdir(), "loop-initial-wiki-fail-repo-")); + const fakeBin = await mkdtemp(join(tmpdir(), "loop-fake-bin-")); + const stateDir = join(repo, ".loop"); + const fakeCodex = join(fakeBin, "codex"); + await writeFile(fakeCodex, [ + "#!/usr/bin/env node", + "import { writeFileSync } from 'node:fs';", + "writeFileSync('agent-ran', 'yes');" + ].join("\n")); + await chmod(fakeCodex, 0o755); + git(["init", "-b", "main"], repo); + await mkdir(stateDir); + await writeFile(join(stateDir, "wiki"), "occupied"); + + const result = spawnSync( + process.execPath, + [ + resolve("bin/loop.js"), + "run", + "--agent", + "codex", + "--no-interview", + "--state-dir", + stateDir, + "Build a darkwear luxury website MVP" + ], + { + cwd: repo, + encoding: "utf8", + env: { + ...process.env, + PATH: `${fakeBin}:${process.env.PATH ?? ""}` + } + } + ); + + assert.equal(result.status, 6); + assert.match(result.stderr, /Wiki write failed after durable state write/); + await assert.rejects(() => readFile(join(repo, "agent-ran"), "utf8"), /ENOENT/); +}); + +test("CLI run reports final wiki failure after agent output", async () => { + const repo = await mkdtemp(join(tmpdir(), "loop-final-wiki-fail-repo-")); + const fakeBin = await mkdtemp(join(tmpdir(), "loop-fake-bin-")); + const stateDir = join(repo, ".loop"); + const fakeCodex = join(fakeBin, "codex"); + await writeFile(fakeCodex, [ + "#!/usr/bin/env node", + "import { rmSync, writeFileSync } from 'node:fs';", + "console.log('agent completed');", + "rmSync('.loop/wiki', { recursive: true, force: true });", + "writeFileSync('.loop/wiki', 'occupied');" + ].join("\n")); + await chmod(fakeCodex, 0o755); + git(["init", "-b", "main"], repo); + + const result = spawnSync( + process.execPath, + [ + resolve("bin/loop.js"), + "run", + "--agent", + "codex", + "--no-interview", + "--state-dir", + stateDir, + "Build a darkwear luxury website MVP" + ], + { + cwd: repo, + encoding: "utf8", + env: { + ...process.env, + PATH: `${fakeBin}:${process.env.PATH ?? ""}` + } + } + ); + const runFiles = await readdir(join(stateDir, "runs")); + await rm(join(stateDir, "wiki"), { force: true }); + + assert.equal(result.status, 6); + assert.match(result.stdout, /agent completed/); + assert.match(result.stderr, /Wiki write failed after durable state write/); + assert.ok(runFiles.some((file) => file.endsWith(".json"))); }); test("CLI Claude Code agent mode invokes claude print adapter", async () => { diff --git a/test/wiki-store.test.js b/test/wiki-store.test.js new file mode 100644 index 0000000..e08c004 --- /dev/null +++ b/test/wiki-store.test.js @@ -0,0 +1,208 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { once } from "node:events"; +import { mkdtemp, readFile, writeFile } from "node:fs/promises"; +import { createServer as createNetServer } from "node:net"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +import { + appendEvidence, + createRunState, + dashboardActionForRun, + getDashboardStatus, + listWikiNotes, + noteIdForRunState, + readWikiNote, + serveWikiDashboard, + waitForDashboardReady, + writeWikiForRunState +} from "../src/index.js"; + +test("writes canonical markdown and derived AI memory", async () => { + const stateDir = await mkdtemp(join(tmpdir(), "loop-wiki-")); + const state = appendEvidence(createRunState({ + objective: "Build darkwear exhibit", + now: new Date("2026-06-13T08:00:01.000Z") + }), { + kind: "test", + status: "passed", + summary: "npm test passed" + }, new Date("2026-06-13T08:01:00.000Z")); + + const paths = await writeWikiForRunState(state, { + stateDir, + paths: { + jsonPath: join(stateDir, "runs", `${state.id}.json`), + summaryPath: join(stateDir, "runs", `${state.id}.md`) + }, + now: new Date("2026-06-13T08:02:00.000Z") + }); + const note = await readWikiNote(paths.id, { stateDir }); + const memory = JSON.parse(await readFile(paths.memoryPath, "utf8")); + const notes = await listWikiNotes({ stateDir }); + + assert.match(paths.id, /^2026-06-13-build-darkwear-exhibit-080001000Z-[a-f0-9]{8}$/); + assert.match(note.markdown, /## Purpose/); + assert.match(note.markdown, /## Token Usage/); + assert.match(note.markdown, /No explicit decisions recorded in run state/); + assert.equal(memory.canonicalNote, `../user/${paths.id}.md`); + assert.match(memory.derivedFromHash, /^sha256:/); + assert.match(memory.generator.markdownHash, /^sha256:/); + assert.equal(memory.tokens.total, null); + assert.equal(memory.tokens.source, "unknown"); + assert.deepEqual(memory.decisions, []); + assert.equal(notes.length, 1); + assert.equal(notes[0].id, paths.id); +}); + +test("wiki note identity is stable for the same run state", () => { + const state = createRunState({ + objective: "Repeatable note", + now: new Date("2026-06-13T09:10:11.000Z") + }); + + assert.equal(noteIdForRunState(state), noteIdForRunState({ + ...state, + status: "failed", + updatedAt: new Date("2026-06-13T09:20:00.000Z").toISOString() + })); +}); + +test("wiki note identity does not collide for same-millisecond reruns", () => { + const first = createRunState({ + objective: "Fast rerun", + now: new Date("2026-06-13T09:10:11.000Z") + }); + const second = createRunState({ + objective: "Fast rerun", + now: new Date("2026-06-13T09:10:11.000Z") + }); + + assert.notEqual(first.id, second.id); + assert.notEqual(noteIdForRunState(first), noteIdForRunState(second)); +}); + +test("preserves human-edited canonical markdown on regeneration", async () => { + const stateDir = await mkdtemp(join(tmpdir(), "loop-wiki-human-edit-")); + const state = createRunState({ + objective: "Preserve human note", + now: new Date("2026-06-13T10:00:00.000Z") + }); + const paths = await writeWikiForRunState(state, { stateDir }); + const editedMarkdown = "# Human edited note\n\nThis sentence must survive.\n"; + await writeFile(paths.notePath, editedMarkdown); + + await writeWikiForRunState({ + ...state, + status: "failed", + updatedAt: new Date("2026-06-13T10:01:00.000Z").toISOString() + }, { stateDir }); + await writeWikiForRunState({ + ...state, + status: "complete", + updatedAt: new Date("2026-06-13T10:02:00.000Z").toISOString() + }, { stateDir }); + const note = await readFile(paths.notePath, "utf8"); + const memory = JSON.parse(await readFile(paths.memoryPath, "utf8")); + + assert.equal(note, editedMarkdown); + assert.match(memory.derivedFromHash, /^sha256:/); + assert.match(memory.generator.markdownHash, /^sha256:/); + assert.equal(memory.summary, "This sentence must survive."); + assert.equal(memory.status, "complete"); +}); + +test("links previous notes with the same objective slug", async () => { + const stateDir = await mkdtemp(join(tmpdir(), "loop-wiki-links-")); + const first = createRunState({ + objective: "Same wiki objective", + now: new Date("2026-06-13T08:00:00.000Z") + }); + const second = createRunState({ + objective: "Same wiki objective", + now: new Date("2026-06-13T09:00:00.000Z") + }); + + await writeWikiForRunState(first, { stateDir, now: new Date("2026-06-13T08:01:00.000Z") }); + const secondPaths = await writeWikiForRunState(second, { stateDir, now: new Date("2026-06-13T09:01:00.000Z") }); + const memory = JSON.parse(await readFile(secondPaths.memoryPath, "utf8")); + + assert.equal(memory.graph.links.length, 1); + assert.equal(memory.graph.links[0].relationship, "continues"); +}); + +test("dashboard run policy respects TTY and explicit flags", () => { + assert.equal(dashboardActionForRun({ + dashboardRunning: true, + stdinTTY: false, + stdoutTTY: false, + explicitFlag: false + }), "skip-running"); + assert.equal(dashboardActionForRun({ + dashboardRunning: false, + stdinTTY: false, + stdoutTTY: false, + explicitFlag: false + }), "skip-non-interactive"); + assert.equal(dashboardActionForRun({ + dashboardRunning: false, + stdinTTY: false, + stdoutTTY: false, + explicitFlag: true + }), "start"); + assert.equal(dashboardActionForRun({ + dashboardRunning: false, + stdinTTY: true, + stdoutTTY: true, + explicitFlag: false + }), "ask"); + assert.equal(dashboardActionForRun({ + dashboardRunning: false, + stdinTTY: true, + stdoutTTY: true, + explicitFlag: false, + userConsent: false + }), "skip-declined"); +}); + +test("dashboard server rejects non-localhost hosts in core API", async () => { + await assert.rejects( + () => serveWikiDashboard({ host: "0.0.0.0" }), + /only supports 127\.0\.0\.1/ + ); +}); + +test("dashboard status treats occupied non-http ports as occupied", async () => { + const server = createNetServer((socket) => { + socket.write("not http\r\n"); + socket.destroy(); + }); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("Expected TCP server to listen on an address object"); + } + + try { + const status = await getDashboardStatus({ + port: address.port, + timeoutMs: 100 + }); + assert.deepEqual(status, { running: false, occupied: true }); + } finally { + server.close(); + await once(server, "close"); + } +}); + +test("dashboard readiness reports unconfirmed startup without hard failure", async () => { + const status = await waitForDashboardReady({ + port: 65534, + timeoutMs: 25, + intervalMs: 5 + }); + + assert.deepEqual(status, { running: false, occupied: false }); +});