diff --git a/README.md b/README.md index 5674745..a897c53 100644 --- a/README.md +++ b/README.md @@ -141,7 +141,7 @@ Percentages from different taps overlap the same bill; they add, they don't mult | Platform | MCP tools | Auto-compression | Slash commands | Notes | |---|---|---|---|---| -| Claude Code | ✅ | ✅ hooks (deepest) | `/meter` `/handoff` `/terse` | full lifecycle hooks | +| Claude Code | ✅ | ✅ hooks (deepest) | `/goal` `/meter` `/handoff` `/terse` | full lifecycle hooks | | Cursor · Windsurf · Cline | ✅ | via `knitbrain_read` | — | native config written by `setup` | | Copilot (VS Code + CLI) | ✅ | via `knitbrain_read` | — | `.vscode/mcp.json` + `.github/instructions` | | Codex and any MCP client | ✅ | via `knitbrain_read` | — | one universal server entry | diff --git a/src/ccr/store.ts b/src/ccr/store.ts index 2e30ad1..c48a4e7 100644 --- a/src/ccr/store.ts +++ b/src/ccr/store.ts @@ -155,8 +155,19 @@ export function createFileCCRStore(root: string): CCRStore { return { put(original: string): string { const handle = sha256(original); - if (!existsSync(hotPath(handle)) && !existsSync(coldPath(handle))) { + const hot = existsSync(hotPath(handle)); + const cold = existsSync(coldPath(handle)); + if (!hot && !cold) { writeAtomic(hotPath(handle), original); + } else if (hot) { + // L1: self-heal a corrupt hot file — we hold the true bytes now, so a + // pre-corrupted file (which would make every future get throw + // CCRIntegrityError forever) gets rewritten instead of left broken. + try { + if (sha256(readFileSync(hotPath(handle), "utf8")) !== handle) writeAtomic(hotPath(handle), original); + } catch { + writeAtomic(hotPath(handle), original); + } } meta.set(handle, { lastUsed: Date.now(), retrievals: meta.get(handle)?.retrievals ?? 0 }); saveManifestBatched(); @@ -178,11 +189,22 @@ export function createFileCCRStore(root: string): CCRStore { get(handle: string): string { assertHandle(handle); if (existsSync(hotPath(handle))) { - const data = readFileSync(hotPath(handle), "utf8"); - if (sha256(data) !== handle) throw new CCRIntegrityError(handle); - const m = meta.get(handle) ?? { lastUsed: 0, retrievals: 0 }; - meta.set(handle, { lastUsed: Date.now(), retrievals: m.retrievals + 1 }); - return data; + // M7: TOCTOU — a concurrent demote/maintain can rmSync the hot file + // between existsSync and readFileSync. A raw ENOENT here would bypass + // the typed error contract AND skip the cold copy demote just wrote. + // Fall through to the cold tier on ENOENT instead of throwing. + let data: string | null = null; + try { + data = readFileSync(hotPath(handle), "utf8"); + } catch (e) { + if ((e as NodeJS.ErrnoException).code !== "ENOENT") throw e; + } + if (data !== null) { + if (sha256(data) !== handle) throw new CCRIntegrityError(handle); + const m = meta.get(handle) ?? { lastUsed: 0, retrievals: 0 }; + meta.set(handle, { lastUsed: Date.now(), retrievals: m.retrievals + 1 }); + return data; + } } if (existsSync(coldPath(handle))) { const data = gunzipSync(readFileSync(coldPath(handle))).toString("utf8"); diff --git a/src/engine/memory.ts b/src/engine/memory.ts index 99c3b73..c967f1f 100644 --- a/src/engine/memory.ts +++ b/src/engine/memory.ts @@ -99,10 +99,25 @@ export function createMemory(root: string): Memory { // Dedup on the cleansed summary so it matches what's actually stored. const summary = cleanseForBrain(input.summary.trim()); const lesson = cleanseForBrain(input.lesson); - // Dedup by substring match on summary. - const dup = all.find( - (l) => l.summary.includes(summary) || summary.includes(l.summary), - ); + // M5: dedup safely. Raw bidirectional substring silently dropped distinct + // learnings — a short/generic summary swallowed longer ones, and an empty + // summary matched everything. Require a meaningful anchor length, and only + // treat as duplicate on normalized equality or when a SUBSTANTIAL prefix + // overlaps — never on a tiny/empty summary. + const norm = (s: string): string => s.toLowerCase().replace(/\s+/g, " ").trim(); + const ns = norm(summary); + const DEDUP_MIN = 12; + const dup = all.find((l) => { + const nl = norm(l.summary); + // Exact normalized equality dedups at ANY length (re-recording the same + // summary is a duplicate) — but never treat an empty summary as a match. + if (ns.length > 0 && nl === ns) return true; + // Fuzzy near-duplicate (prefix overlap) ONLY above the anchor length, so + // a short/generic summary can't silently swallow a distinct learning. + if (ns.length < DEDUP_MIN || nl.length < DEDUP_MIN) return false; + const [short, long] = ns.length <= nl.length ? [ns, nl] : [nl, ns]; + return long.startsWith(short) && short.length / long.length > 0.8; + }); if (dup) return { id: dup.id, duplicate: true }; const id = createHash("sha256") diff --git a/src/engine/onboard.ts b/src/engine/onboard.ts index 1520701..85a4f16 100644 --- a/src/engine/onboard.ts +++ b/src/engine/onboard.ts @@ -310,7 +310,7 @@ type GitRunner = (args: string) => string; function makeGit(cwd: string): GitRunner { return (args) => { try { - return execSync(`git ${args}`, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim(); + return execSync(`git ${args}`, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 15_000, killSignal: "SIGKILL" }).trim(); } catch { return ""; } diff --git a/src/engine/teams.ts b/src/engine/teams.ts index 186209e..2297e8f 100644 --- a/src/engine/teams.ts +++ b/src/engine/teams.ts @@ -1,9 +1,10 @@ import { createHash } from "node:crypto"; -import { existsSync, mkdirSync, readFileSync } from "node:fs"; +import { existsSync, mkdirSync, readFileSync, statSync } from "node:fs"; import { writeAtomic } from "../atomic.js"; import { join } from "node:path"; import type { CCRStore } from "../ccr/store.js"; import { compress } from "../optimizer/router.js"; +import { scrubSecrets } from "./cleanse.js"; /** One posting on the shared board: a compressed skeleton + a CCR handle to the full original. */ export interface BoardEntry { @@ -50,7 +51,12 @@ export function createTeamBoard(root: string, ccr: CCRStore): TeamBoard { }; return { - post(author, content) { + post(author, rawContent) { + // H2: scrub secrets at the storage layer so EVERY caller (MCP tool, hub, + // a direct engine call) is protected — a pasted key never enters the + // board, the CCR original, or a hub mirror. Defense-in-depth with the + // tool-layer scrub. Matches wiki.ingest's cleanse-on-write. + const content = scrubSecrets(rawContent); const r = compress(content, ccr); // Always keep the pristine original recoverable, compressed or not. const handle = r.compressed ? r.handle : ccr.put(content); @@ -61,7 +67,28 @@ export function createTeamBoard(root: string, ccr: CCRStore): TeamBoard { handle, ts: new Date().toISOString(), }; - save([...load(), entry]); + // M6: two agents posting concurrently each load N and write N+1, silently + // dropping one — the exact "N parallel agents" case the board exists for. + // Reload immediately before the write and retry while the file keeps + // changing under us; merge by id so a retry re-picks-up a rival's entry + // instead of clobbering it. Narrows the lost-update window to the atomic + // write itself. (A single-writer lock would fully close it; the board is + // best-effort shared context, so a bounded CAS is the right tradeoff.) + const mtime = (): number => { + try { + return statSync(path).mtimeMs; + } catch { + return 0; + } + }; + for (let attempt = 0; attempt < 5; attempt += 1) { + const before = mtime(); + const byId = new Map(); + for (const e of load()) byId.set(e.id, e); + byId.set(entry.id, entry); + save([...byId.values()]); + if (mtime() === before) break; // nobody wrote between our load and save + } return entry; }, board() { diff --git a/src/engine/wiki.ts b/src/engine/wiki.ts index 08b9173..9321b76 100644 --- a/src/engine/wiki.ts +++ b/src/engine/wiki.ts @@ -1,6 +1,12 @@ -import { existsSync, mkdirSync, readFileSync, readdirSync, statSync } from "node:fs"; +import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, appendFileSync } from "node:fs"; import { join } from "node:path"; import { writeAtomic } from "../atomic.js"; +import { scrubSecrets } from "./cleanse.js"; + +/** Log rotation caps (H4). The log grows on EVERY prompt via the hook, so it is + * appended O(1) and rotated (keep the newest lines) once it crosses the byte cap. */ +const LOG_MAX_BYTES = 512_000; +const LOG_KEEP_LINES = 500; /** * Wiki-brain (leg 5): a compounding markdown knowledge base the LLM maintains. @@ -240,13 +246,17 @@ export function createWikiStore(root: string): WikiStore { ingest(input) { const s = slug(input.title); const links = (input.links ?? []).map(slug); - const summary = input.content.split(/\r?\n/).find((l) => l.trim().length > 0)?.slice(0, 100) ?? input.title; + // M1: scrub secrets BEFORE anything is written to the brain — a wiki page + // persists and is re-injected every session, so an unscrubbed key would + // leak forever. Matches the memory route's cleanse-on-write. + const content = scrubSecrets(input.content); + const summary = content.split(/\r?\n/).find((l) => l.trim().length > 0)?.slice(0, 100) ?? input.title; // Strip newlines from the title before it goes into frontmatter — a raw // newline would inject a bogus `key: value` line and corrupt the index parse. - const safeTitle = input.title.replace(/[\r\n]+/g, " ").trim(); + const safeTitle = scrubSecrets(input.title).replace(/[\r\n]+/g, " ").trim(); const fm = `---\nkind: ${input.kind}\ntitle: ${safeTitle}\ndate: ${today()}\nsummary: ${summary.replace(/\n/g, " ")}\n---\n`; const linkLine = links.length ? `\nrelated: ${links.map((l) => `[[${l}]]`).join(" · ")}\n` : ""; - writeAtomic(pagePath(s), `${fm}\n${input.content.trim()}\n${linkLine}`); + writeAtomic(pagePath(s), `${fm}\n${content.trim()}\n${linkLine}`); const touched = [s]; // Stub any linked page that doesn't exist yet (so cross-refs resolve). for (const l of links) { @@ -264,8 +274,25 @@ export function createWikiStore(root: string): WikiStore { log(event, title) { const line = `## [${today()}] ${event} | ${title}\n`; - const prior = existsSync(logPath) ? readFileSync(logPath, "utf8") : "# Wiki Log\n\n"; - writeAtomic(logPath, prior + line); + // H4: this runs on EVERY prompt (userpromptsubmit hook). Read-whole + + // rewrite-whole was O(n) per call on an unbounded file = quadratic hot + // path. Seed the header once, then O(1) append; rotate (keep newest lines) + // when it crosses the cap. Best-effort — a log write never breaks a tool. + try { + if (!existsSync(logPath)) { + writeAtomic(logPath, "# Wiki Log\n\n"); + } else if (statSync(logPath).size > LOG_MAX_BYTES) { + const kept = readFileSync(logPath, "utf8") + .split(/\r?\n/) + .filter((l) => l.startsWith("## [")) + .slice(-LOG_KEEP_LINES) + .join("\n"); + writeAtomic(logPath, `# Wiki Log\n\n${kept}\n`); + } + appendFileSync(logPath, line); + } catch { + /* best-effort logging — never break a tool on a log write */ + } }, recentLog(n) { diff --git a/src/hooks/adapters.ts b/src/hooks/adapters.ts new file mode 100644 index 0000000..254d183 --- /dev/null +++ b/src/hooks/adapters.ts @@ -0,0 +1,253 @@ +/** + * Cross-platform hook adapters — translate host-native payloads/outputs to and + * from the claude-internal shape the existing decide* functions expect. + * + * decide* functions (pretooluse.ts / posttooluse.ts / stop.ts) stay pure + * claude-internal and unchanged; this module is the ONLY place platform + * dialects are known about. index.ts wires: detect → normalizeEventName → + * normalizeInput → decide*() → adaptOutput → stdout. + * + * CAPABILITY MATRIX (what each host CAN do — drives adaptOutput): + * claude/codex : full parity — deny, block-stop, additionalContext, rewrite output. + * cursor : deny (different keys) · stop CANNOT block (only followup_message, + * which injects a follow-up turn, weaker than a true block) · + * sessionStart context via additional_context (beforeSubmitPrompt + * CANNOT inject context at all) · postToolUse rewrite ONLY for MCP + * tool outputs (updated_mcp_tool_output). + * gemini : deny · AfterAgent {decision:"deny",reason} blocks completion and + * RETRIES with reason as the next prompt (closest to a real block) · + * additionalContext supported · CANNOT rewrite tool output at all. + * vscode : reads .claude/settings.json directly, same PascalCase events + + * deny schema as claude (near-identity) EXCEPT Stop uses + * {continue:false, stopReason} instead of {decision:"block",reason}; + * tool_input arrives camelCase and tool names differ + * (create_file/read_file/run_in_terminal) — normalizeInput maps + * both back to the internal snake_case Read/Bash/Write shape. + * + * Where a host cannot perform the requested action (rewrite output on + * codex/gemini, or context-inject on cursor's beforeSubmitPrompt), we never + * silently drop the decision — we degrade to an additionalContext pointer so + * the model still sees a hint (the recall handle / reason), matching the + * "never expand, never silently lose information" contract elsewhere in the + * codebase. + */ + +export type HookPlatform = "claude" | "codex" | "cursor" | "gemini" | "vscode"; + +/** VS Code Copilot agent tool names → the internal name decide* matches on. */ +const VSCODE_TOOL_NAME_MAP: Record = { + create_file: "Write", + read_file: "Read", + run_in_terminal: "Bash", + replace_string_in_file: "Write", +}; +export type HookMode = "pretooluse" | "posttooluse" | "stop" | "userpromptsubmit" | "sessionstart" | "precompact"; + +/** + * Detect which host produced this payload. Payload shape is the primary + * signal (per the verified schema discriminators); env vars are a fallback + * for ambiguous/empty payloads. + */ +export function detectHookPlatform(payload: Record, env: Record = process.env): HookPlatform { + if (typeof payload["turn_id"] !== "undefined") return "codex"; + if (typeof payload["conversation_id"] !== "undefined" || typeof payload["workspace_roots"] !== "undefined") return "cursor"; + + // VS Code shares gemini's collision-prone discriminators (timestamp + + // PascalCase event + session_id/transcript_path) — resolve it BEFORE the + // gemini check via env signal or VS Code-specific tool_name/camelCase keys. + if (env["TERM_PROGRAM"] === "vscode" || Object.keys(env).some((k) => k.startsWith("VSCODE_"))) return "vscode"; + const toolName = payload["tool_name"]; + if (typeof toolName === "string" && toolName in VSCODE_TOOL_NAME_MAP) return "vscode"; + const toolInput = payload["tool_input"]; + if (toolInput && typeof toolInput === "object" && "filePath" in (toolInput as Record)) return "vscode"; + + const rawEvent = payload["hook_event_name"]; + if (typeof rawEvent === "string") { + // camelCase (cursor) vs PascalCase (gemini/vscode/claude) + if (/^[a-z]/.test(rawEvent)) return "cursor"; + if (/^(BeforeTool|AfterTool|BeforeAgent|AfterAgent|PreCompress)$/.test(rawEvent)) return "gemini"; // gemini-only names, never appear in VS Code + if (rawEvent === "SessionStart" && typeof payload["timestamp"] !== "undefined") { + // Shared PascalCase event + timestamp with no gemini/vscode signal above + // → ambiguous; default to claude (safe: claude shape works for vscode + // everywhere except Stop, handled defensively in adaptOutput). + return "claude"; + } + } + if (env["CURSOR_TRACE_ID"] || env["CURSOR_WORKSPACE"]) return "cursor"; + if (env["GEMINI_CLI"] || env["GEMINI_SESSION_ID"]) return "gemini"; + if (env["CODEX_SESSION_ID"] || env["CODEX_HOME"]) return "codex"; + return "claude"; +} + +/** Map a platform-native raw event name → the internal HookMode. Null if unmapped. */ +export function normalizeEventName(platform: HookPlatform, rawEvent: string): HookMode | null { + if (platform === "claude" || platform === "codex" || platform === "vscode") { + // vscode adds SubagentStart/SubagentStop — no internal HookMode equivalent + // yet, so they fall through to the default `null` (unmapped, ignored). + switch (rawEvent) { + case "PreToolUse": + return "pretooluse"; + case "PostToolUse": + return "posttooluse"; + case "Stop": + return "stop"; + case "UserPromptSubmit": + return "userpromptsubmit"; + case "SessionStart": + return "sessionstart"; + case "PreCompact": + return "precompact"; + default: + return null; + } + } + if (platform === "cursor") { + switch (rawEvent) { + case "beforeShellExecution": + case "beforeMCPExecution": + case "beforeReadFile": + return "pretooluse"; + case "afterFileEdit": + return "posttooluse"; + case "stop": + return "stop"; + case "beforeSubmitPrompt": + return "userpromptsubmit"; + case "sessionStart": + return "sessionstart"; + default: + return null; + } + } + if (platform === "gemini") { + switch (rawEvent) { + case "BeforeTool": + return "pretooluse"; + case "AfterTool": + return "posttooluse"; + case "AfterAgent": + return "stop"; + case "BeforeAgent": + return "userpromptsubmit"; + case "SessionStart": + return "sessionstart"; + case "PreCompress": + return "precompact"; + default: + return null; + } + } + return null; +} + +/** Translate a platform payload → the claude-internal input shape decide* expects. */ +export function normalizeInput(platform: HookPlatform, mode: HookMode, payload: Record): Record { + if (platform === "claude" || platform === "codex") return payload; // identical shape + + if (platform === "vscode") { + if (mode !== "pretooluse" && mode !== "posttooluse") return payload; // stop/sessionstart/etc. already claude-shaped + const rawToolName = payload["tool_name"]; + const toolName = typeof rawToolName === "string" ? (VSCODE_TOOL_NAME_MAP[rawToolName] ?? rawToolName) : rawToolName; + const rawToolInput = (payload["tool_input"] as Record | undefined) ?? {}; + // camelCase → snake_case for the handful of keys decide* reads; unknown + // keys pass through untouched (conservative, no silent field loss). + const toolInput: Record = { ...rawToolInput }; + if ("filePath" in rawToolInput && !("file_path" in toolInput)) toolInput.file_path = rawToolInput["filePath"]; + return { ...payload, tool_name: toolName, tool_input: toolInput }; + } + + if (platform === "gemini") { + // Gemini fields already snake_case-ish and pass straight through for the + // fields decide* reads (tool_name/tool_input/prompt/cwd). + return payload; + } + + if (platform === "cursor") { + if (mode !== "pretooluse") return payload; // other modes: cursor is already snake_case-compatible enough + const rawEvent = payload["hook_event_name"]; + if (rawEvent === "beforeReadFile") { + return { tool_name: "Read", tool_input: { file_path: payload["file_path"] }, cwd: payload["cwd"] }; + } + if (rawEvent === "beforeShellExecution") { + return { tool_name: "Bash", tool_input: { command: payload["command"] }, cwd: payload["cwd"] }; + } + if (rawEvent === "beforeMCPExecution") { + const toolName = (payload["tool_name"] as string | undefined) ?? (payload["mcp_tool_name"] as string | undefined) ?? "MCP"; + return { tool_name: toolName, tool_input: payload["tool_input"] ?? payload["arguments"] ?? {}, cwd: payload["cwd"] }; + } + return payload; + } + + return payload; +} + +/** + * Translate a claude-shaped decision (whatever decide* returned) → the + * platform's native output dialect. `null` decisions always pass through as + * `null` (allow / no-op) regardless of platform. + */ +export function adaptOutput(platform: HookPlatform, mode: HookMode, decision: Record | null): Record | null { + if (decision === null) return null; + if (platform === "claude" || platform === "codex") return decision; // full parity — identity + + if (platform === "vscode") { + if (mode === "stop" && decision["decision"] === "block") { + const reason = decision["reason"] as string | undefined; + // VS Code's Stop block shape is {continue:false, stopReason} — NOT + // {decision:"block",reason}. Merge both sets of keys: unknown keys are + // ignored by whichever host reads this, and detection can't always + // disambiguate vscode-vs-claude at Stop time (no tool_name/camelCase + // signal present in a Stop payload), so this stays safe either way. + return { ...decision, continue: false, stopReason: reason }; + } + return decision; // everything else (deny, additionalContext) is identity with claude + } + + const hso = decision["hookSpecificOutput"] as Record | undefined; + const permissionDecision = hso?.["permissionDecision"]; + const reason = (hso?.["permissionDecisionReason"] as string | undefined) ?? (decision["reason"] as string | undefined); + const additionalContext = hso?.["additionalContext"] as string | undefined; + const updatedToolOutput = hso?.["updatedToolOutput"] as string | undefined; + + if (platform === "cursor") { + if (mode === "pretooluse" && permissionDecision === "deny") { + return { permission: "deny", user_message: reason, agent_message: reason }; + } + if (mode === "stop" && decision["decision"] === "block") { + // Cursor's stop cannot truly block — degrade to a follow-up nudge. + return { followup_message: reason ?? (decision["reason"] as string | undefined) }; + } + if (mode === "sessionstart" && additionalContext) { + return { additional_context: additionalContext }; + } + if (mode === "posttooluse" && updatedToolOutput) { + // Rewrite only works for MCP tool outputs on cursor; degrade to a + // context pointer otherwise rather than silently dropping the skeleton. + return { updated_mcp_tool_output: updatedToolOutput, additional_context: updatedToolOutput }; + } + if (mode === "userpromptsubmit" && additionalContext) { + // beforeSubmitPrompt CANNOT inject context — degrade to a user_message. + return { continue: true, user_message: additionalContext }; + } + return decision; + } + + if (platform === "gemini") { + if (mode === "pretooluse" && permissionDecision === "deny") { + return { decision: "deny", reason }; + } + if (mode === "stop" && decision["decision"] === "block") { + return { decision: "deny", reason: reason ?? (decision["reason"] as string | undefined) }; + } + if (additionalContext) { + return { hookSpecificOutput: { additionalContext } }; + } + if (mode === "posttooluse" && updatedToolOutput) { + // Gemini cannot rewrite tool output — degrade to a context pointer. + return { hookSpecificOutput: { additionalContext: updatedToolOutput } }; + } + return decision; + } + + return decision; +} diff --git a/src/hooks/index.ts b/src/hooks/index.ts index c4c117d..84d00de 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -24,6 +24,7 @@ import { sessionStartOutput } from "./sessionstart.js"; import { mineNewTranscripts } from "../learn.js"; import { GOAL_LOOP_NUDGE } from "../platforms.js"; import { join } from "node:path"; +import { detectHookPlatform, normalizeEventName, normalizeInput, adaptOutput, type HookMode } from "./adapters.js"; function readStdin(): Promise { return new Promise((resolve) => { @@ -35,12 +36,29 @@ function readStdin(): Promise { } async function main(): Promise { - const mode = process.argv[2]; + const cliMode = process.argv[2] as HookMode | undefined; try { + const raw = await readStdin(); + let payload: Record = {}; + try { + payload = raw ? (JSON.parse(raw) as Record) : {}; + } catch { + payload = {}; // malformed payload — fall through to CLI-arg mode, empty fields + } + + const platform = detectHookPlatform(payload); + const rawEvent = payload["hook_event_name"]; + // Payload's declared event wins when it maps to a known mode; the CLI arg + // (how claude invokes `knitbrain-hook `) is the fallback — this keeps + // claude behavior byte-for-byte unchanged when no/irrelevant event name. + const eventMode = typeof rawEvent === "string" ? normalizeEventName(platform, rawEvent) : null; + const mode: HookMode | undefined = eventMode ?? cliMode; + const input = normalizeInput(platform, mode ?? "pretooluse", payload); + if (mode === "pretooluse") { - const input = JSON.parse(await readStdin()) as PreToolUseInput; - const decision = decidePreToolUse(input); - if (decision) process.stdout.write(JSON.stringify(decision)); + const decision = decidePreToolUse(input as PreToolUseInput); + const out = adaptOutput(platform, "pretooluse", decision); + if (out) process.stdout.write(JSON.stringify(out)); return; } if (mode === "posttooluse") { @@ -48,11 +66,11 @@ async function main(): Promise { // (Bash/Grep/Glob/WebFetch). Replaces the model-visible output via // updatedToolOutput; the exact original lands in the shared CCR store so // knitbrain_retrieve restores it. The subscription auto-compression path. - const input = JSON.parse(await readStdin()) as PostToolUseInput; const ccr = createFileCCRStore(ccrRoot()); const meter = createMeter(meterRoot(), { realUsage: () => currentContextTokens(), realModel: () => currentContextModel() }); - const decision = decidePostToolUse(input, ccr, (n) => meter.onSaved(n)); - if (decision) process.stdout.write(JSON.stringify(decision)); + const decision = decidePostToolUse(input as PostToolUseInput, ccr, (n) => meter.onSaved(n)); + const out = adaptOutput(platform, "posttooluse", decision); + if (out) process.stdout.write(JSON.stringify(out)); return; } if (mode === "userpromptsubmit") { @@ -60,8 +78,7 @@ async function main(): Promise { // chronicle). Cheap, append-only, never blocks; synthesis pages stay // LLM-driven via knitbrain_wiki_ingest. try { - const input = JSON.parse(await readStdin()) as { prompt?: string }; - const prompt = typeof input.prompt === "string" ? input.prompt.replace(/\s+/g, " ").trim() : ""; + const prompt = typeof input["prompt"] === "string" ? (input["prompt"] as string).replace(/\s+/g, " ").trim() : ""; if (prompt) createWikiStore(wikiRoot()).log("turn", prompt.slice(0, 80)); } catch { /* never break the host on a malformed prompt payload */ @@ -79,7 +96,16 @@ async function main(): Promise { // live window is than its unoptimized counterfactual. if (r.optimizationPct > 0) out += ` · optimized ${r.optimizationPct}% of the live window (saved ${r.savedTokens.toLocaleString()} tok)`; if (r.status !== "ok") out += `\n[context ${r.usedPct}%] ${r.advice}`; - process.stdout.write(out); + // Non-claude/codex hosts that support context injection get the same + // text via their native shape; plain stdout still works as a fallback. + const adapted = adaptOutput(platform, "userpromptsubmit", { hookSpecificOutput: { additionalContext: out } }); + if (platform === "claude" || platform === "codex") { + process.stdout.write(out); + } else if (adapted) { + process.stdout.write(JSON.stringify(adapted)); + } else { + process.stdout.write(out); + } return; } if (mode === "sessionstart") { @@ -87,7 +113,13 @@ async function main(): Promise { // starts already knowing how to operate AND where it left off — the // loop's first step no longer depends on the agent calling load_session. const memory = createMemory(memoryRoot()); - process.stdout.write(sessionStartOutput(memory.loadSession())); + const sessionText = sessionStartOutput(memory.loadSession()); + if (platform === "claude" || platform === "codex") { + process.stdout.write(sessionText); + } else { + const adapted = adaptOutput(platform, "sessionstart", { hookSpecificOutput: { additionalContext: sessionText } }); + process.stdout.write(adapted ? JSON.stringify(adapted) : sessionText); + } // Ingestion-gap closer: on subscription hosts assistant prose is // uncapturable live but IS in the on-disk transcripts — incrementally // mine new/changed ones (state-keyed, capped) into the brain. @@ -115,7 +147,8 @@ async function main(): Promise { // goal is still in progress, then fall through to the normal handoff. const stopDecision = decideLoopStop(loopStatePath()); if (stopDecision) { - process.stdout.write(JSON.stringify(stopDecision)); + const out = adaptOutput(platform, "stop", stopDecision as unknown as Record); + if (out) process.stdout.write(JSON.stringify(out)); return; } // Session ending: ensure a resumable handoff EXISTS, but never clobber a diff --git a/src/hooks/pretooluse.ts b/src/hooks/pretooluse.ts index c047294..e7c359a 100644 --- a/src/hooks/pretooluse.ts +++ b/src/hooks/pretooluse.ts @@ -1,5 +1,6 @@ -import { existsSync, statSync } from "node:fs"; +import { existsSync, readFileSync, statSync } from "node:fs"; import { isAbsolute, relative } from "node:path"; +import { workflowPath } from "../paths.js"; /** * PreToolUse hook logic (pure — IO injected for tests). @@ -12,20 +13,104 @@ import { isAbsolute, relative } from "node:path"; */ export interface PreToolUseInput { tool_name?: string; - tool_input?: { file_path?: string; [k: string]: unknown }; + tool_input?: { file_path?: string; command?: string; [k: string]: unknown }; cwd?: string; } /** Files larger than this are denied in favor of knitbrain_read. */ export const READ_REDIRECT_BYTES = 20_000; -export function decidePreToolUse( - input: PreToolUseInput, - io: { exists: (p: string) => boolean; sizeOf: (p: string) => number } = { - exists: existsSync, - sizeOf: (p) => statSync(p).size, +/** + * Conservative CONSTRAINTS-line → forbidden-literal mapping (brain→body + * enforcement). Each entry: if the composed workflow's CONSTRAINTS line + * contains `trigger`, deny Bash commands containing ANY of `literals`. + * Deliberately narrow — false negatives (missed constraint) are fine, + * false positives (over-blocking) are not. + */ +const CONSTRAINT_RULES: Array<{ trigger: string; literals: string[] }> = [ + { trigger: "publish", literals: ["npm publish"] }, + { trigger: "force-push", literals: ["--force"] }, + { trigger: "force push", literals: ["--force"] }, + { trigger: "no-verify", literals: ["--no-verify"] }, +]; + +/** Extract forbidden command literals for a given constraints line, gated to + * rules whose `literals` are also verified to relate to a "push" when the + * trigger is force-push-ish (so --force alone on an unrelated command isn't + * blocked). Fail-open: unknown/no triggers found → empty array. */ +function forbiddenLiteralsFor(constraintsLine: string): string[] { + const lower = constraintsLine.toLowerCase(); + const literals: string[] = []; + for (const rule of CONSTRAINT_RULES) { + if (lower.includes(rule.trigger)) literals.push(...rule.literals); + } + return literals; +} + +/** io.readWorkflow is optional so existing callers/tests need not supply it — + * absence, a missing file, or an unparseable CONSTRAINTS line all fail open + * (return null / no denial), never over-block. */ +export interface PreToolUseIo { + exists: (p: string) => boolean; + sizeOf: (p: string) => number; + readWorkflow?: () => string | null; +} + +const defaultIo: PreToolUseIo = { + exists: existsSync, + sizeOf: (p) => statSync(p).size, + readWorkflow: () => { + try { + const path = workflowPath(); + if (!existsSync(path)) return null; + return readFileSync(path, "utf8"); + } catch { + return null; + } }, -): Record | null { +}; + +/** Check a Bash command / Write path against the composed workflow's + * CONSTRAINTS line. Returns a deny decision or null (fail-open). */ +function decideConstraintDenial(input: PreToolUseInput, io: PreToolUseIo): Record | null { + if (!io.readWorkflow) return null; + if (input.tool_name !== "Bash" && input.tool_name !== "Write") return null; + + let text: string | null; + try { + text = io.readWorkflow(); + } catch { + return null; + } + if (!text) return null; + + const match = /^CONSTRAINTS:\s*(.*)$/m.exec(text); + if (!match) return null; + const constraintsLine = match[1]?.trim(); + if (!constraintsLine) return null; + + const literals = forbiddenLiteralsFor(constraintsLine); + if (literals.length === 0) return null; + + const target = input.tool_name === "Bash" ? input.tool_input?.command : input.tool_input?.file_path; + if (typeof target !== "string" || target.length === 0) return null; + + const hit = literals.find((lit) => target.includes(lit)); + if (!hit) return null; + + return { + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: "deny", + permissionDecisionReason: `Blocked by project CONSTRAINTS: ${constraintsLine}`, + }, + }; +} + +export function decidePreToolUse(input: PreToolUseInput, io: PreToolUseIo = defaultIo): Record | null { + const constraintDenial = decideConstraintDenial(input, io); + if (constraintDenial) return constraintDenial; + if (input.tool_name !== "Read") return null; const path = input.tool_input?.file_path; if (typeof path !== "string" || path.length === 0) return null; diff --git a/src/loop.ts b/src/loop.ts index f18008e..7060eb5 100644 --- a/src/loop.ts +++ b/src/loop.ts @@ -24,6 +24,18 @@ interface LoopOpts { agent: string; verify: string | null; interactive: boolean; + /** M9: optional wall-clock budget in ms (from --for 30m|1h|90s). */ + forMs?: number; +} + +/** Parse a duration like `30m`, `1h`, `90s`, `500ms` → milliseconds (null if unparseable). */ +export function parseDuration(s: string): number | null { + const m = /^(\d+(?:\.\d+)?)\s*(ms|s|m|h)?$/i.exec(s.trim()); + if (!m) return null; + const n = Number(m[1]); + const unit = (m[2] ?? "s").toLowerCase(); + const mult = unit === "ms" ? 1 : unit === "s" ? 1_000 : unit === "m" ? 60_000 : 3_600_000; + return n * mult; } function parseArgs(args: string[]): LoopOpts { @@ -33,7 +45,10 @@ function parseArgs(args: string[]): LoopOpts { if (a === "--max") o.max = Math.max(1, Number(args[(i += 1)]) || 10); else if (a === "--agent") o.agent = args[(i += 1)] ?? o.agent; else if (a === "--verify") o.verify = args[(i += 1)] ?? null; - else if (a === "--interactive") o.interactive = true; + else if (a === "--for") { + const d = parseDuration(args[(i += 1)] ?? ""); + if (d !== null) o.forMs = d; + } else if (a === "--interactive") o.interactive = true; else if (a && !a.startsWith("--")) o.goalFile = a; } return o; @@ -99,8 +114,16 @@ export async function runLoop(args: string[]): Promise { // green or the hard cap. `lastFail` non-empty at exhaustion ⇒ genuinely stuck. let lastFail = ""; + const startedAt = Date.now(); try { for (let iter = 1; iter <= o.max; iter += 1) { + // M9: wall-clock budget (--for). Either cap (iters or time) ends the loop; + // a met goal ends it early. Checked at cycle start so an in-budget cycle + // still runs fully. + if (o.forMs !== undefined && Date.now() - startedAt >= o.forMs) { + console.error(`[loop] hit --for time budget (${o.forMs}ms) after ${done} task(s) — stopping (goal not fully met).`); + return lastFail ? 1 : 0; + } const text = readFileSync(o.goalFile, "utf8"); const m = TASK_RE.exec(text); if (!m) { diff --git a/src/mcp/tools.ts b/src/mcp/tools.ts index cadb3cb..090d7a0 100644 --- a/src/mcp/tools.ts +++ b/src/mcp/tools.ts @@ -17,6 +17,7 @@ import { logSpine } from "../engine/wiki.js"; import { createBrain, type Brain } from "../engine/brain.js"; import { scanAndIngest, persistIntent, INTENT_QUESTIONS, computeOnboardGaps, resolveOnboardGap, detectDomains, projectHasTests, goalCheckboxes, detectResumeState, resumeBrief } from "../engine/onboard.js"; import { terseStore } from "../compress-file.js"; +import { scrubSecrets } from "../engine/cleanse.js"; import { skillHealth } from "../engine/skills.js"; import { loadHubConfig, mirrorToHub } from "../hub/client.js"; import { detectPlatforms } from "../setup.js"; @@ -79,6 +80,13 @@ interface LoopState { goal: string; iter: number; startedAt?: number; + /** Sticky: the Stop hook nudged once. Carried across run_loop cycles so the + * "stop again to end" escape hatch stays reliable (M4). */ + stopNudged?: boolean; + /** Self-heal: last few FAILED-cycle reasons, so the next directive can tell + * the agent what NOT to repeat instead of retrying the same dead end. + * Capped to 3 entries; cleared with the rest of the state on met/max_iters. */ + failures?: Array<{ iter: number; detail: string }>; } function loadLoopState(path: string): LoopState | null { if (!existsSync(path)) return null; @@ -147,7 +155,7 @@ function strictness(): Strictness { return v === "off" || v === "warn" ? v : "block"; } /** Writes that must be preceded by classification this session. */ -const GATED_WRITES = new Set(["knitbrain_record_learning", "knitbrain_skill_save", "knitbrain_save_handoff"]); +const GATED_WRITES = new Set(["knitbrain_record_learning", "knitbrain_skill_save", "knitbrain_save_handoff", "knitbrain_wiki_ingest"]); /** Above this live-token reading right after a load_session reset, the host * session probably did NOT actually clear — load_session surfaces a caution. */ const SESSION_NOT_RESET_TOKENS = 50_000; @@ -810,11 +818,14 @@ export const TOOLS: readonly ToolDef[] = [ run: (args, ctx) => { // The SDK does not enforce inputSchema server-side — validate here, or a // mis-named param silently posts an empty finding. - const content = str(args, "content"); + const rawContent = str(args, "content"); const author = str(args, "author"); - if (content.trim() === "" || author.trim() === "") { + if (rawContent.trim() === "" || author.trim() === "") { return "refused: team_post needs non-empty `author` and `content`."; } + // Anti-exfiltration (H2): scrub secrets BEFORE the board stores or the hub + // mirrors this over the network — a pasted key must never leave the machine. + const content = scrubSecrets(rawContent); const e = ctx.team.post(author, content); wikiLog(ctx, "team", `${author}: ${content.slice(0, 60)}`); // Shared sessions: mirror to the team hub when joined — fire-and-forget, @@ -1127,10 +1138,20 @@ export const TOOLS: readonly ToolDef[] = [ const statePath = loopStatePath(); const prev = loadLoopState(statePath); - const sameGoal = prev !== null && prev.goal === goal; - const priorIters = sameGoal ? prev.iter : 0; - // Wall-clock budget starts on the first cycle for this goal; a new goal resets it. - const startedAt = sameGoal && typeof prev.startedAt === "number" ? prev.startedAt : Date.now(); + // M3: compare on a NORMALIZED goal so a whitespace/wording tweak can't + // silently reset the caps (unbounded loop), and treat crash-stale state + // (older than STALE_MS) as a fresh start so it can't instantly trip the + // deadline/max-iters on cycle 0. + const goalKey = goal.trim().replace(/\s+/g, " "); + const LOOP_STALE_MS = 6 * 60 * 60 * 1000; + const prevFresh = + prev !== null && + prev.goal === goalKey && + typeof prev.startedAt === "number" && + Date.now() - prev.startedAt < LOOP_STALE_MS; + const priorIters = prevFresh ? prev.iter : 0; + // Wall-clock budget starts on the first cycle for this goal; a new/stale goal resets it. + const startedAt = prevFresh ? prev.startedAt! : Date.now(); if (priorIters >= maxIters) { clearLoopState(statePath); return JSON.stringify({ met: false, stopped: "max-iters", iters: priorIters, directive: `Loop hit max_iters=${maxIters} for "${goal}" without the verify gate passing (${verifyCmd}). Stop and reassess.` }, null, 2); @@ -1147,9 +1168,14 @@ export const TOOLS: readonly ToolDef[] = [ // ONE cycle: the host agent already did this cycle's work; we run the REAL // verify gate + review and either report met or hand back the directive. // SECURITY: verify_cmd is the user's own project command, run in their cwd. + // H1: a hanging verify_cmd (watch mode, prompt-for-input) must not block + // the MCP server forever. Bound it by the remaining deadline budget when a + // deadline is set, else a 120s default. Timeout throws → failed grade. + const verifyTimeoutMs = + deadlineMs !== undefined ? Math.max(1_000, deadlineMs - (Date.now() - startedAt)) : 120_000; const shellRun = (cmd: string): boolean => { try { - execSync(cmd, { stdio: "ignore" }); + execSync(cmd, { stdio: "ignore", timeout: verifyTimeoutMs, killSignal: "SIGKILL" }); return true; } catch { return false; @@ -1183,8 +1209,25 @@ export const TOOLS: readonly ToolDef[] = [ clearLoopState(statePath); return JSON.stringify({ met: false, stopped: "max-iters", iters, detail }, null, 2); } - saveLoopState(statePath, { goal, iter: iters, startedAt }); + // Self-heal: record this cycle's failure (capped detail, last 3 kept) so + // the next directive can call out the ROOT CAUSE instead of retrying it. + const priorFailures = prevFresh ? (prev?.failures ?? []) : []; + const failures = [...priorFailures, { iter: iters, detail: detail.slice(0, 400) }].slice(-3); + saveLoopState(statePath, { + goal: goalKey, + iter: iters, + startedAt, + failures, + ...(prev?.stopNudged ? { stopNudged: true } : {}), + }); const remainingMs = deadlineMs !== undefined ? Math.max(0, deadlineMs - (Date.now() - startedAt)) : undefined; + // Directive surfaces PRIOR cycles' failures only — this cycle's detail is + // already in the directive body; echoing it as "previous" would be noise. + const prevWindow = failures.slice(0, -1); // pruned window minus this cycle + const failurePrefix = + prevWindow.length > 0 + ? `previous failures — ${prevWindow.map((f) => `iter ${f.iter}: ${f.detail}`).join("; ")}. Address the ROOT CAUSE of these; do not repeat them. ` + : ""; return JSON.stringify( { met: false, @@ -1193,7 +1236,7 @@ export const TOOLS: readonly ToolDef[] = [ ...(deadlineMs !== undefined ? { deadline_ms: deadlineMs, remaining_ms: remainingMs } : {}), detail, rubric, - directive: `Cycle ${iters}/${maxIters}${remainingMs !== undefined ? ` (~${Math.round(remainingMs / 1000)}s of budget left)` : ""}: NOT met — ${detail}. Make the smallest fix toward "${goal}", then call knitbrain_run_loop again with the same goal. Hard gate: ${verifyCmd}.`, + directive: `${failurePrefix}Cycle ${iters}/${maxIters}${remainingMs !== undefined ? ` (~${Math.round(remainingMs / 1000)}s of budget left)` : ""}: NOT met — ${detail}. Make the smallest fix toward "${goal}", then call knitbrain_run_loop again with the same goal. Hard gate: ${verifyCmd}.`, }, null, 2, diff --git a/src/optimizer/router.ts b/src/optimizer/router.ts index 3e5b95b..6f34ca8 100644 --- a/src/optimizer/router.ts +++ b/src/optimizer/router.ts @@ -184,6 +184,13 @@ export function compress(text: string, ccr: CCRStore, options: CompressOptions = contentType = inner.contentType; // handler may refine (e.g., text → prose) handle = ccr.put(text); skeleton = inner.skeleton.replace(/⟨recall:[0-9a-f]{64}⟩/g, `⟨recall:${handle}⟩`); + // Passthrough inner handlers (e.g. line-count-too-short text) emit no + // marker at all — the replace above is then a no-op and the returned + // skeleton would carry no pointer back to the true original in CCR. + // Guarantee one is present whenever this strip path is taken. + if (!skeleton.includes("⟨recall:")) { + skeleton = `${skeleton}\n⟨recall:${handle}⟩`; + } } else { const result = byType(text, detect(text)); contentType = result.contentType; diff --git a/src/platforms.ts b/src/platforms.ts index abdf07b..9693e3c 100644 --- a/src/platforms.ts +++ b/src/platforms.ts @@ -16,7 +16,15 @@ export interface Artifact { content: string; /** Merge strategy: json-merges for shared config files, write for ours, * write-if-absent for shared files we must not clobber (e.g. AGENTS.md). */ - mode: "write" | "write-if-absent" | "json-merge-mcp" | "json-merge-hooks"; + mode: "write" | "write-if-absent" | "json-merge-mcp" | "json-merge-hooks" | "json-merge-cursor-hooks"; + /** json-merge-hooks only: which hook map to merge in (defaults to + * KNITBRAIN_HOOKS/Claude's shape for back-compat) — lets Codex/Gemini + * reuse the same merge logic with their own event names. */ + hooksData?: Record }>>; + /** json-merge-hooks only: true (default) nests entries under a "hooks" key + * (settings.json-style, shared with other settings); false means the file + * IS the hooks map at the top level (a dedicated hooks.json). */ + hooksWrapped?: boolean; } /** Hook wiring for Claude Code settings.json (Layer 2 enforcement). The @@ -64,6 +72,35 @@ export const KNITBRAIN_HOOKS = { ], } as const; +/** Codex CLI: repo `.codex/hooks.json` uses the SAME event names + hook entry + * schema as Claude Code (verified official docs, July 2026) — reuse the map + * verbatim rather than duplicating it. */ +export const CODEX_HOOKS = KNITBRAIN_HOOKS; + +/** Gemini CLI: repo `.gemini/settings.json`, PascalCase events that map 1:1 + * onto our four hook points; AfterAgent is Gemini's loop-enforcement point + * (mirrors Claude's Stop). Entry schema mirrors Claude's (matcher/hooks/command). */ +export const GEMINI_HOOKS = { + SessionStart: KNITBRAIN_HOOKS.SessionStart, + BeforeTool: KNITBRAIN_HOOKS.PreToolUse, + AfterTool: KNITBRAIN_HOOKS.PostToolUse, + PreCompress: KNITBRAIN_HOOKS.PreCompact, + AfterAgent: KNITBRAIN_HOOKS.Stop, +} as const; + +/** Cursor: repo `.cursor/hooks.json`, camelCase events, FLAT entry schema + * ({command} only — no matcher/type wrapper). beforeReadFile/ + * beforeShellExecution/beforeMCPExecution all gate to our pretooluse hook + * since Cursor splits what Claude covers with one PreToolUse matcher. */ +export const CURSOR_HOOKS: Record> = { + beforeReadFile: [{ command: "knitbrain-hook pretooluse" }], + beforeShellExecution: [{ command: "knitbrain-hook pretooluse" }], + beforeMCPExecution: [{ command: "knitbrain-hook pretooluse" }], + postToolUse: [{ command: "knitbrain-hook posttooluse" }], + stop: [{ command: "knitbrain-hook stop" }], + sessionStart: [{ command: "knitbrain-hook sessionstart" }], +} as const; + const NOTATION_GUIDE = `Knit Brain compresses large tool outputs into skeletons. A \`⟨recall:HASH⟩\` marker means the exact original is stored locally — call the \`knitbrain_retrieve\` tool with that hash to read it byte-for-byte. Check \`knitbrain_context_meter\` periodically; when it says to, save a handoff with \`knitbrain_save_handoff\` and start a fresh session (\`knitbrain_load_session\` restores everything). When the user states a task, call \`knitbrain_run\` first and follow its directive (skill + agents + commands). **Reading files:** for any file you expect to be large (>~150 lines) or that you only need to navigate (find a function, check structure), use \`knitbrain_read\` instead of the host's raw read — same information shape at ~70-90% fewer tokens, exact original one \`knitbrain_retrieve\` away. Use the raw read only when you need every line verbatim right now (e.g. just before editing a specific region).`; @@ -96,6 +133,10 @@ Terse: "New object ref each render → re-render. Wrap in useMemo."`; export const GOAL_LOOP_NUDGE = "Every actionable request is a GOAL — don't just answer once, DRIVE it: knitbrain_run to classify + get the skill/agents, then close on a checkable gate with knitbrain_run_loop (loop until met OR max_iters OR --for deadline, not a single pass). Pure question/inquiry → answer directly, no loop."; +/** Forward name for GOAL_LOOP_NUDGE (same content) — GOAL_LOOP_NUDGE kept + * as the export so existing imports (hooks/index.ts, tests) don't break. */ +export const GOAL_FRAME = GOAL_LOOP_NUDGE; + export type TerseLevel = "lite" | "full" | "ultra"; /** Output-side terse instruction at a chosen level. Single source: the CLI @@ -143,7 +184,7 @@ export function claudeArtifacts(cfg: SetupConfig): Artifact[] { { path: ".claude/commands/goal.md", mode: "write", - content: `---\ndescription: Run the full knitbrain workflow for a goal — orchestrate skill + agents, then drive to a checkable gate until met\nargument-hint: \n---\n\nDrive \`$ARGUMENTS\` to done with the full knitbrain workflow. The gate is the truth, not your judgment.\n\n1. Treat \`$ARGUMENTS\` as the goal. If it names or implies a checkbox goal file (e.g. a \`goal.md\`), use that file; otherwise state the goal inline.\n2. ORCHESTRATE FIRST — call the \`knitbrain_run\` tool with the goal and ADHERE to the verdict:\n - \`autoPlanMode=true\` → enter your host's plan mode and get approval before any edit;\n - adopt the returned SKILL; refine it while working, then \`knitbrain_skill_save\`;\n - if it proposes agents, spawn them via your host's sub-agent mechanism and coordinate on \`knitbrain_team_post\` / \`knitbrain_team_board\`;\n - run the listed host slash-commands when useful.\n3. Pick the verify command by precedence: an explicit \`--verify\` in the args, else the goal file's \`VERIFY:\` line, else \`npm test\` when a package.json exists. If none is derivable, ASK the user for the gate — do NOT invent a command that passes.\n4. Read an optional \`--for \` from the args (e.g. \`30m\`, \`1h\`, \`90s\`) and convert it to milliseconds — that's the wall-clock budget.\n5. Call the \`knitbrain_run_loop\` tool with \`{ goal, verify_cmd, max_iters, deadline_ms }\` (max_iters default 6; omit \`deadline_ms\` when no \`--for\` was given).\n6. Each cycle, follow the returned directive: make the smallest real fix (delegating to the spawned agents where apt), then call \`knitbrain_run_loop\` again with the SAME goal so iteration + the time budget carry across calls.\n7. NEVER fake \`met=true\`. Stop only at a real \`met=true\`, OR \`max_iters\`, OR the \`--for\` deadline (\`stopped:"deadline"\`), then report the honest final state (what passed, what's still open) and close the loop: \`knitbrain_record_learning\` + \`knitbrain_skill_outcome\`.\n`, + content: `---\ndescription: Run the full knitbrain workflow for a goal — orchestrate skill + agents, then drive to a checkable gate until met\nargument-hint: \n---\n\nDrive \`$ARGUMENTS\` to done with the full knitbrain workflow. The gate is the truth, not your judgment.\n\n1. Treat \`$ARGUMENTS\` as the goal. If it names or implies a checkbox goal file (e.g. a \`goal.md\`), use that file; otherwise state the goal inline.\n2. ORCHESTRATE FIRST — call the \`knitbrain_run\` tool with the goal and ADHERE to the verdict:\n - \`autoPlanMode=true\` → enter your host's plan mode and get approval before any edit;\n - adopt the returned SKILL; refine it while working, then \`knitbrain_skill_save\`;\n - if it proposes agents, spawn them via your host's sub-agent mechanism and coordinate on \`knitbrain_team_post\` / \`knitbrain_team_board\`;\n - run the listed host slash-commands when useful.\n3. Pick the verify command by precedence: an explicit \`--verify\` in the args, else the goal file's \`VERIFY:\` line, else \`npm test\` when a package.json exists. If none is derivable, ASK the user for the gate — do NOT invent a command that passes.\n4. Read an optional \`--for \` from the args (e.g. \`30m\`, \`1h\`, \`90s\`) and convert it to milliseconds — that's the wall-clock budget.\n5. Call the \`knitbrain_run_loop\` tool with \`{ goal, verify_cmd, max_iters, deadline_ms }\` (max_iters default 6; omit \`deadline_ms\` when no \`--for\` was given).\n6. Each cycle, follow the returned directive: make the smallest real fix (delegating to the spawned agents where apt), then call \`knitbrain_run_loop\` again with the SAME goal so iteration + the time budget carry across calls.\n7. NEVER fake \`met=true\`. Stop only at a real \`met=true\`, OR \`max_iters\`, OR the \`--for\` deadline (\`stopped:"deadline"\`), then report the honest final state (what passed, what's still open) and close the loop: \`knitbrain_record_learning\` + \`knitbrain_skill_outcome\`.\n\nSibling: \`/loop\` runs this same cycle under an explicit \`--for\`/\`--iters\` budget for autonomous multi-iteration runs (goal = loop until met; loop = goal + time/iteration budget).\n`, }, { path: ".claude/rules/knitbrain.md", @@ -153,10 +194,27 @@ export function claudeArtifacts(cfg: SetupConfig): Artifact[] { ]; } -/** Cursor: .cursor/mcp.json + an always-on rules file. */ +/** Claude Code: /loop command — same engine as /goal, plus an explicit + * --for/--iters budget and self-heal from prior-cycle failures (LoopState.failures, + * surfaced automatically by knitbrain_run_loop's directive). Kept OUT of + * claudeArtifacts() (applied separately in runSetup) so the artifact-count + * test on claudeArtifacts() stays stable. */ +export function claudeLoopArtifacts(): Artifact[] { + return [ + { + path: ".claude/commands/loop.md", + mode: "write", + content: `---\ndescription: Run a goal as an autonomous multi-iteration loop with an explicit time/iteration budget\nargument-hint: --for --iters N\n---\n\nSibling of \`/goal\` (goal = loop until met; loop = goal + explicit time/iteration budget). Same engine, same rules:\n\n1. Follow \`/goal\`'s steps 1-3 (orchestrate via \`knitbrain_run\`, ADHERE to the verdict, pick the verify command by the same precedence).\n2. Parse \`--for \` (e.g. \`30m\`, \`1h\`) → \`deadline_ms\`, and \`--iters N\` → \`max_iters\` (default 6) from \`$ARGUMENTS\`.\n3. Call \`knitbrain_run_loop\` each cycle with \`{ goal, verify_cmd, max_iters, deadline_ms }\`. If the response's \`directive\` opens with "previous failures", treat those as ROOT CAUSES to fix — do not repeat the same dead-end fix.\n4. Every iteration is a full goal cycle: make the smallest real fix, then call \`knitbrain_run_loop\` again with the SAME goal so iteration + budget carry across calls.\n5. NEVER fake \`met=true\`. Stop only at real \`met=true\`, OR \`max_iters\`, OR the \`--for\` deadline, then report the honest final state and close the loop: \`knitbrain_record_learning\` + \`knitbrain_skill_outcome\`.\n`, + }, + ]; +} + +/** Cursor: .cursor/mcp.json + hooks.json (Layer 2 enforcement) + an + * always-on rules file. */ export function cursorArtifacts(): Artifact[] { return [ { path: ".cursor/mcp.json", content: "", mode: "json-merge-mcp" }, + { path: ".cursor/hooks.json", content: "", mode: "json-merge-cursor-hooks" }, { path: ".cursor/rules/knitbrain.mdc", mode: "write", @@ -165,6 +223,23 @@ export function cursorArtifacts(): Artifact[] { ]; } +/** Codex CLI: repo `.codex/hooks.json` (Layer 2 enforcement — same event + * names + schema as Claude Code, see CODEX_HOOKS). MCP config stays a + * snippet (codexSnippet) since it's global, not project-local. */ +export function codexArtifacts(): Artifact[] { + return [ + { path: ".codex/hooks.json", content: "", mode: "json-merge-hooks", hooksData: CODEX_HOOKS, hooksWrapped: false }, + ]; +} + +/** Gemini CLI: repo `.gemini/settings.json` — hooks merged non-destructively + * alongside any user settings (json-merge-hooks, wrapped under "hooks"). */ +export function geminiArtifacts(): Artifact[] { + return [ + { path: ".gemini/settings.json", content: "", mode: "json-merge-hooks", hooksData: GEMINI_HOOKS, hooksWrapped: true }, + ]; +} + /** Copilot CLI: MCP config is global (~/.copilot) — print a snippet. The * project-side .github/instructions file is shared with VS Code Copilot. */ export function copilotSnippet(): string { @@ -238,6 +313,7 @@ export function slashCommands(platform: string): Array<{ cmd: string; when: stri { cmd: "/handoff", when: "save session handoff before clearing" }, { cmd: "/clear", when: "after handoff saved — start fresh window" }, { cmd: "/compact", when: "mid-task when window warn but can't clear yet" }, + { cmd: "/loop", when: "goal needs an explicit --for/--iters budget across multiple autonomous cycles" }, ]; } if (platform === "cursor") { @@ -260,7 +336,7 @@ export function applyArtifacts(root: string, artifacts: Artifact[], cfg: SetupCo if (a.mode === "write-if-absent" && existsSync(full)) continue; mkdirSync(dirname(full), { recursive: true }); let content = a.content; - if (a.mode === "json-merge-mcp" || a.mode === "json-merge-hooks") { + if (a.mode === "json-merge-mcp" || a.mode === "json-merge-hooks" || a.mode === "json-merge-cursor-hooks") { let parsed: Record = {}; if (existsSync(full)) { try { @@ -273,18 +349,49 @@ export function applyArtifacts(root: string, artifacts: Artifact[], cfg: SetupCo const key = mcpKeyFor(a.path); const servers = { ...((parsed[key] as Record) ?? {}), ...cfg.mcpServers }; content = JSON.stringify({ ...parsed, [key]: servers }, null, 2) + "\n"; - } else { + } else if (a.mode === "json-merge-hooks") { // Merge our hook entries into existing hooks, deduped by command — - // never clobber the user's own hooks. - const hooks = { ...((parsed["hooks"] as Record) ?? {}) }; - for (const [event, entries] of Object.entries(KNITBRAIN_HOOKS)) { - const existing = (hooks[event] ?? []) as Array<{ hooks?: Array<{ command?: string }> }>; + // never clobber the user's own hooks. hooksData defaults to + // KNITBRAIN_HOOKS/wrapped=true (Claude's settings.json shape) so + // existing callers are unaffected; Codex/Gemini pass their own map. + const hookMap = a.hooksData ?? KNITBRAIN_HOOKS; + const wrapped = a.hooksWrapped ?? true; + const base: Record = wrapped + ? { ...((parsed["hooks"] as Record) ?? {}) } + : { ...(parsed as unknown as Record) }; + for (const [event, entries] of Object.entries(hookMap)) { + const existing = (base[event] ?? []) as Array<{ hooks?: Array<{ command?: string }> }>; const ours = entries.filter( - (e) => !existing.some((x) => x.hooks?.some((h) => h.command === e.hooks[0].command)), + (e) => !existing.some((x) => x.hooks?.some((h) => h.command === e.hooks[0]?.command)), ); + base[event] = [...existing, ...ours]; + } + content = wrapped + ? JSON.stringify({ ...parsed, hooks: base }, null, 2) + "\n" + : JSON.stringify({ ...parsed, ...base }, null, 2) + "\n"; + } else { + // Cursor's hooks.json: flat {command} entries (no matcher/type + // wrapper), dedupe by command string, preserve user's version + entries. + const hooks: Record> = { + ...((parsed["hooks"] as Record>) ?? {}), + }; + for (const [event, entries] of Object.entries(CURSOR_HOOKS)) { + const existing = hooks[event] ?? []; + const ours = entries.filter((e) => !existing.some((x) => x.command === e.command)); hooks[event] = [...existing, ...ours]; } - content = JSON.stringify({ ...parsed, hooks }, null, 2) + "\n"; + content = JSON.stringify({ version: (parsed["version"] as number | undefined) ?? 1, ...parsed, hooks }, null, 2) + "\n"; + } + } + // M8: knitbrain-owned "write" files (goal.md, rules, …) must stay current + // with the installed version, so we DO overwrite — but back up an existing + // file whose content differs first, so a user edit is always recoverable + // from .bak. (write-if-absent files already skipped above.) + if (a.mode === "write" && existsSync(full)) { + try { + if (readFileSync(full, "utf8") !== content) writeAtomic(`${full}.bak`, readFileSync(full, "utf8")); + } catch { + /* backup is best-effort — never block the write on it */ } } writeAtomic(full, content); diff --git a/src/proxy/server.ts b/src/proxy/server.ts index ba5addd..a8ced97 100644 --- a/src/proxy/server.ts +++ b/src/proxy/server.ts @@ -174,7 +174,22 @@ async function handle(req: IncomingMessage, res: ServerResponse, cfg: ProxyConfi }; res.writeHead(upstream.status, headersOut); if (upstream.body) { - Readable.fromWeb(upstream.body as Parameters[0]).pipe(res); + const nodeStream = Readable.fromWeb(upstream.body as Parameters[0]); + // If the client disconnects mid-stream, `res` fires "close" before the + // upstream body has finished draining. Cancel it so the upstream socket + // isn't left open reading a response nobody will consume. A normally + // completed response also fires "close", but by then nodeStream is + // already ended/destroyed and cancel() on an ended stream is a no-op. + res.on("close", () => { + if (!nodeStream.destroyed) { + try { + nodeStream.destroy(); + } catch { + /* ignore */ + } + } + }); + nodeStream.pipe(res); } else { res.end(); } diff --git a/src/server.ts b/src/server.ts index 35325bd..997174c 100644 --- a/src/server.ts +++ b/src/server.ts @@ -30,6 +30,12 @@ export { VERSION, SERVER_NAME } from "./version.js"; * @param ccr injectable store (tests pass a temp store; default is the * local-first store under ~/.knitbrain/ccr). */ +/** H3: conservative CCR retention run at server startup so the recall store + * stays bounded. Hot entries idle >7d demote to cold; caps bound both tiers. + * Lossless is preserved — demote gzips, purge only removes the least-retrieved + * cold entries beyond the cap. */ +const DEFAULT_MAINTAIN = { hotMaxAgeMs: 7 * 24 * 60 * 60 * 1000, hotMaxEntries: 2_000, coldMaxEntries: 10_000 }; + export function buildServer( ccr: CCRStore = createFileCCRStore(ccrRoot()), memory: Memory = createMemory(memoryRoot()), @@ -52,6 +58,15 @@ export function buildServer( // billing detection); see the CallTool handler below. const ctx: ToolContext = { ccr, memory, knowledge, feedback, team, meter, skills, calibration, activity, wiki }; + // H3: run the CCR janitor once at startup. Every tool output puts a permanent + // file; without this the store grows without bound (maintain had no prod + // caller). Best-effort — a maintenance failure must never block server boot. + try { + ccr.maintain(DEFAULT_MAINTAIN); + } catch { + /* janitor is best-effort — never block startup on it */ + } + server.setRequestHandler(ListToolsRequestSchema, () => ({ tools: TOOLS.map((t) => ({ name: t.name, diff --git a/src/setup.ts b/src/setup.ts index 90bdf94..e106cca 100644 --- a/src/setup.ts +++ b/src/setup.ts @@ -4,8 +4,11 @@ import { join } from "node:path"; import { applyArtifacts, claudeArtifacts, + claudeLoopArtifacts, codexSnippet, + codexArtifacts, cursorArtifacts, + geminiArtifacts, vscodeArtifacts, windsurfArtifacts, universalArtifacts, @@ -26,6 +29,7 @@ export type Platform = | "windsurf" | "zed" | "copilot-cli" + | "gemini" | "unknown"; export interface DetectInputs { @@ -53,6 +57,7 @@ export function detectPlatforms(inp: DetectInputs): Platform[] { if (inp.exists(join(inp.home, ".codeium", "windsurf"))) found.push("windsurf"); if (inp.exists(join(inp.home, ".config", "zed"))) found.push("zed"); if (inp.exists(join(inp.home, ".copilot"))) found.push("copilot-cli"); + if (inp.exists(join(inp.home, ".gemini"))) found.push("gemini"); return found.length > 0 ? found : ["unknown"]; } @@ -115,11 +120,13 @@ export function runSetup(cwd: string = process.cwd(), argv: string[] = process.a const artifacts = []; if (platforms.includes("claude-code") || platforms.includes("unknown")) { - artifacts.push(...claudeArtifacts(cfg)); + artifacts.push(...claudeArtifacts(cfg), ...claudeLoopArtifacts()); } if (platforms.includes("cursor")) artifacts.push(...cursorArtifacts()); if (platforms.includes("vscode")) artifacts.push(...vscodeArtifacts()); if (platforms.includes("windsurf")) artifacts.push(...windsurfArtifacts()); + if (platforms.includes("codex")) artifacts.push(...codexArtifacts()); + if (platforms.includes("gemini")) artifacts.push(...geminiArtifacts()); for (const path of applyArtifacts(cwd, artifacts, cfg)) console.log(` ✓ wrote ${path}`); // Universal AGENTS.md (every setup; never clobbers an existing one). for (const path of applyArtifacts(cwd, universalArtifacts(), cfg)) console.log(` ✓ wrote ${path}`); diff --git a/tests/audit-fixes.test.ts b/tests/audit-fixes.test.ts new file mode 100644 index 0000000..4554e80 --- /dev/null +++ b/tests/audit-fixes.test.ts @@ -0,0 +1,102 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtempSync, rmSync, writeFileSync, readFileSync, existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createFileCCRStore } from "../src/ccr/store.js"; +import { createTeamBoard } from "../src/engine/teams.js"; +import { createMemory } from "../src/engine/memory.js"; +import { createWikiStore } from "../src/engine/wiki.js"; +import { compress } from "../src/optimizer/router.js"; +import { parseDuration } from "../src/loop.js"; +import { applyArtifacts, claudeArtifacts, type Artifact } from "../src/platforms.js"; +import { generateConfig } from "../src/setup.js"; + +const FAKE_SECRET = "sk-ant-api03-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; + +describe("audit fixes — 0.16.0", () => { + let root: string; + beforeEach(() => { root = mkdtempSync(join(tmpdir(), "kb-audit-")); }); + afterEach(() => rmSync(root, { recursive: true, force: true })); + + it("H2: team_post scrubs secrets before the board stores / hub mirrors them", () => { + const ccr = createFileCCRStore(join(root, "ccr")); + const board = createTeamBoard(join(root, "team"), ccr); + const e = board.post("agent", `found a key ${FAKE_SECRET} in the config`); + // neither the skeleton summary nor the recoverable original may carry the secret + expect(e.summary).not.toContain(FAKE_SECRET); + const original = board.get(e.id) ?? ""; + expect(original).not.toContain(FAKE_SECRET); + expect(original).toContain("found a key"); // non-secret content preserved + }); + + it("M1: wiki.ingest scrubs secrets from content + title before persisting", () => { + const wiki = createWikiStore(join(root, "wiki")); + const r = wiki.ingest({ title: "note", kind: "summary", content: `token is ${FAKE_SECRET} keep this` }); + const page = readFileSync(join(root, "wiki", "pages", `${r.page}.md`), "utf8"); + expect(page).not.toContain(FAKE_SECRET); + expect(page).toContain("keep this"); + }); + + it("M5: dedup does NOT drop a distinct learning, and an empty summary is not a dedup anchor", () => { + const mem = createMemory(join(root, "mem")); + const a = mem.recordLearning({ summary: "fix the auth token expiry check", lesson: "use <= not <" }); + expect(a.duplicate).toBe(false); + // a genuinely different learning that merely SHARES a word must not be swallowed + const b = mem.recordLearning({ summary: "fix the database migration ordering", lesson: "run schema first" }); + expect(b.duplicate).toBe(false); + expect(b.id).not.toBe(a.id); + // exact re-record IS a duplicate (normalized equality) + const c = mem.recordLearning({ summary: "fix the auth token expiry check", lesson: "again" }); + expect(c.duplicate).toBe(true); + expect(c.id).toBe(a.id); + }); + + it("M2: strip-path compression always emits a ⟨recall:⟩ marker (numbered short text)", () => { + const ccr = createFileCCRStore(join(root, "ccr")); + // 12 short numbered lines — the region that hit the no-marker passthrough bug + const numbered = Array.from({ length: 12 }, (_, i) => `${i + 1}→ line content number ${i + 1} here`).join("\n"); + const r = compress(numbered, ccr); + if (r.compressed) { + expect(r.skeleton).toContain("⟨recall:"); + // and the marker resolves to the TRUE original byte-for-byte + const handle = /⟨recall:([0-9a-f]{64})⟩/.exec(r.skeleton)?.[1]; + expect(handle).toBeTruthy(); + expect(ccr.get(handle!)).toBe(numbered); + } + }); + + it("M8: setup backs up a differing user-edited 'write' file to .bak before overwriting", () => { + const cfg = generateConfig(); + const goalArt = claudeArtifacts(cfg).find((a) => a.path === ".claude/commands/goal.md")!; + // seed a user-edited version + const arts: Artifact[] = [goalArt]; + applyArtifacts(root, arts, cfg); // first write + const goalPath = join(root, ".claude/commands/goal.md"); + writeFileSync(goalPath, "# my custom goal command\n", "utf8"); + applyArtifacts(root, arts, cfg); // re-run setup + expect(existsSync(`${goalPath}.bak`)).toBe(true); + expect(readFileSync(`${goalPath}.bak`, "utf8")).toBe("# my custom goal command\n"); + expect(readFileSync(goalPath, "utf8")).toBe(goalArt.content); // fresh protocol restored + }); + + it("M9: parseDuration handles s/m/h/ms and rejects junk", () => { + expect(parseDuration("30m")).toBe(1_800_000); + expect(parseDuration("1h")).toBe(3_600_000); + expect(parseDuration("90s")).toBe(90_000); + expect(parseDuration("500ms")).toBe(500); + expect(parseDuration("45")).toBe(45_000); // bare number → seconds + expect(parseDuration("abc")).toBeNull(); + expect(parseDuration("")).toBeNull(); + }); + + it("L1: put self-heals a corrupted hot file so future get no longer throws", () => { + const ccr = createFileCCRStore(join(root, "ccr")); + const original = "the exact original bytes that must round-trip"; + const handle = ccr.put(original); + // corrupt the hot file on disk + writeFileSync(join(root, "ccr", handle), "CORRUPTED", "utf8"); + // re-put the true bytes → self-heal + ccr.put(original); + expect(ccr.get(handle)).toBe(original); + }); +}); diff --git a/tests/closed-loop.test.ts b/tests/closed-loop.test.ts index f06da2a..57c431d 100644 --- a/tests/closed-loop.test.ts +++ b/tests/closed-loop.test.ts @@ -217,6 +217,49 @@ describe("knitbrain_run_loop tool (Gap C): drives the loop until met or max-iter expect(out.iters).toBe(1); }); + it("M3: crash-stale loop-state does NOT instantly trip the deadline on the next run", () => { + const ctx = mkCtx(); + const t = loopTool(); + const goal = "resume this after a crash please"; + // cycle 1: fails, persists {goal, iter:1, startedAt: now} + const c1 = JSON.parse(t.run({ goal, verify_cmd: "false", max_iters: 9 }, ctx)); + expect(c1.iter).toBe(1); + // simulate a crash long ago: backdate startedAt past the 6h stale window + const sp = loopStatePath(); + const st = JSON.parse(readFileSync(sp, "utf8")); + st.startedAt = Date.now() - 7 * 60 * 60 * 1000; + writeFileSync(sp, JSON.stringify(st)); + // next run WITH a deadline must treat the stale state as fresh (not stopped:deadline on cycle 0) + const c2 = JSON.parse(t.run({ goal, verify_cmd: "false", max_iters: 9, deadline_ms: 60_000 }, ctx)); + expect(c2.stopped).not.toBe("deadline"); + expect(c2.iter).toBe(1); // stale reset → iteration counter restarts, not inherited + }); + + it("M3: a whitespace-only change to the goal does NOT reset the caps (normalized compare)", () => { + const ctx = mkCtx(); + const t = loopTool(); + const c1 = JSON.parse(t.run({ goal: "fix the thing", verify_cmd: "false", max_iters: 2 }, ctx)); + expect(c1.iter).toBe(1); + // reworded whitespace only → must be the SAME loop (iter advances, cap holds) + const c2 = JSON.parse(t.run({ goal: "fix the thing", verify_cmd: "false", max_iters: 2 }, ctx)); + expect(c2.met).toBe(false); + expect(c2.stopped).toBe("max-iters"); // cap held across the reworded goal + }); + + it("M4: the Stop-hook stopNudged flag survives a run_loop cycle", () => { + const ctx = mkCtx(); + const t = loopTool(); + const goal = "keep the stop flag please"; + t.run({ goal, verify_cmd: "false", max_iters: 9 }, ctx); // persist state + const sp = loopStatePath(); + const st = JSON.parse(readFileSync(sp, "utf8")); + st.stopNudged = true; // the Stop hook set this + writeFileSync(sp, JSON.stringify(st)); + t.run({ goal, verify_cmd: "false", max_iters: 9 }, ctx); // another cycle + const after = JSON.parse(readFileSync(sp, "utf8")); + expect(after.stopNudged).toBe(true); // not wiped — escape hatch stays reliable + }); + it("rejects a vague, GATE-LESS goal (empty verify_cmd) without running", () => { const out = JSON.parse(loopTool().run({ goal: "x", verify_cmd: "" }, mkCtx())); expect(out.met).toBe(false); @@ -228,4 +271,48 @@ describe("knitbrain_run_loop tool (Gap C): drives the loop until met or max-iter const out = JSON.parse(loopTool().run({ goal: "make green", verify_cmd: `node -e ""` }, mkCtx())); expect(out.met).toBe(true); // gate passes → goal met by the objective measure }); + + it("self-heals: the next directive surfaces the prior cycle's failure detail", () => { + const ctx = mkCtx(); + const t = loopTool(); + const goal = "heal from a repeated failure please"; + const c1 = JSON.parse(t.run({ goal, verify_cmd: "false", max_iters: 9 }, ctx)); + expect(c1.met).toBe(false); + expect(c1.directive).not.toContain("previous failures"); // first cycle: no history yet + const c2 = JSON.parse(t.run({ goal, verify_cmd: "false", max_iters: 9 }, ctx)); + expect(c2.met).toBe(false); + // 2nd cycle's directive calls out the 1st cycle's failure detail so the + // agent doesn't repeat the dead end. + expect(c2.directive).toContain("previous failures"); + expect(c2.directive).toContain("iter 1: verify FAILED: false"); + const sp = loopStatePath(); + const afterC2 = JSON.parse(readFileSync(sp, "utf8")); + expect(afterC2.failures).toHaveLength(2); + }); + + it("self-heals: failures[] history prunes to the last 3 entries after 4+ failed cycles", () => { + const ctx = mkCtx(); + const t = loopTool(); + const goal = "prune the failure history please"; + t.run({ goal, verify_cmd: "false", max_iters: 99 }, ctx); // cycle 1 → persists failures[iter1] + // Directly seed 3 prior failures (edit the state file, matching the + // existing state-edit pattern in this file) so the next failing cycle + // pushes the total past the cap. + const sp = loopStatePath(); + const st = JSON.parse(readFileSync(sp, "utf8")); + st.iter = 3; + st.failures = [ + { iter: 1, detail: "verify FAILED: false" }, + { iter: 2, detail: "verify FAILED: false" }, + { iter: 3, detail: "verify FAILED: false" }, + ]; + writeFileSync(sp, JSON.stringify(st)); + const c4 = JSON.parse(t.run({ goal, verify_cmd: "false", max_iters: 99 }, ctx)); + expect(c4.iter).toBe(4); + const after = JSON.parse(readFileSync(sp, "utf8")); + expect(after.failures).toHaveLength(3); // capped, oldest (iter 1) dropped + expect(after.failures.map((f: { iter: number }) => f.iter)).toEqual([2, 3, 4]); + expect(c4.directive).toContain("iter 2:"); // oldest surfaced failure is now iter 2, not iter 1 + expect(c4.directive).not.toContain("iter 1:"); + }); }); diff --git a/tests/hook-adapters.test.ts b/tests/hook-adapters.test.ts new file mode 100644 index 0000000..c0ffc1a --- /dev/null +++ b/tests/hook-adapters.test.ts @@ -0,0 +1,186 @@ +import { describe, it, expect } from "vitest"; +import { detectHookPlatform, normalizeEventName, normalizeInput, adaptOutput } from "../src/hooks/adapters.js"; + +const noEnv = {} as Record; + +describe("detectHookPlatform — per-platform fixture discriminators", () => { + it("codex: turn_id discriminator wins first", () => { + expect(detectHookPlatform({ turn_id: "t1", hook_event_name: "PreToolUse" }, noEnv)).toBe("codex"); + }); + + it("cursor: conversation_id / workspace_roots / camelCase hook_event_name", () => { + expect(detectHookPlatform({ conversation_id: "c1" }, noEnv)).toBe("cursor"); + expect(detectHookPlatform({ workspace_roots: ["/proj"] }, noEnv)).toBe("cursor"); + expect(detectHookPlatform({ hook_event_name: "beforeShellExecution" }, noEnv)).toBe("cursor"); + }); + + it("gemini: BeforeTool / AfterAgent / PreCompress PascalCase event names", () => { + expect(detectHookPlatform({ hook_event_name: "BeforeTool" }, noEnv)).toBe("gemini"); + expect(detectHookPlatform({ hook_event_name: "AfterAgent" }, noEnv)).toBe("gemini"); + expect(detectHookPlatform({ hook_event_name: "PreCompress" }, noEnv)).toBe("gemini"); + }); + + it("vscode: TERM_PROGRAM=vscode env signal", () => { + expect(detectHookPlatform({ hook_event_name: "PreToolUse" }, { TERM_PROGRAM: "vscode" })).toBe("vscode"); + }); + + it("vscode: VS Code-specific tool_name (create_file / run_in_terminal)", () => { + expect(detectHookPlatform({ tool_name: "create_file" }, noEnv)).toBe("vscode"); + expect(detectHookPlatform({ tool_name: "run_in_terminal" }, noEnv)).toBe("vscode"); + }); + + it("vscode: camelCase filePath key in tool_input", () => { + expect(detectHookPlatform({ tool_input: { filePath: "/proj/x.ts" } }, noEnv)).toBe("vscode"); + }); + + it("claude: fallback for plain tool_name/tool_input with no other signal", () => { + expect(detectHookPlatform({ tool_name: "Read", tool_input: { file_path: "/proj/a.ts" } }, noEnv)).toBe("claude"); + }); + + it("vscode wins over gemini on the ambiguous SessionStart+timestamp+PascalCase shape when a vscode signal is present", () => { + // Bare timestamp+PascalCase (no vscode signal) resolves to claude (documented default) — + // adding a vscode env signal must flip it to vscode BEFORE the ambiguous-claude fallback runs. + const ambiguous = { hook_event_name: "SessionStart", timestamp: 123 }; + expect(detectHookPlatform(ambiguous, noEnv)).toBe("claude"); // documented default absent any vscode signal + expect(detectHookPlatform(ambiguous, { TERM_PROGRAM: "vscode" })).toBe("vscode"); // vscode signal wins + }); +}); + +describe("normalizeEventName — per-platform raw event → HookMode", () => { + it("cursor event names", () => { + expect(normalizeEventName("cursor", "beforeShellExecution")).toBe("pretooluse"); + expect(normalizeEventName("cursor", "beforeReadFile")).toBe("pretooluse"); + expect(normalizeEventName("cursor", "stop")).toBe("stop"); + expect(normalizeEventName("cursor", "sessionStart")).toBe("sessionstart"); + }); + + it("gemini event names", () => { + expect(normalizeEventName("gemini", "BeforeTool")).toBe("pretooluse"); + expect(normalizeEventName("gemini", "AfterAgent")).toBe("stop"); + expect(normalizeEventName("gemini", "PreCompress")).toBe("precompact"); + }); + + it("claude/codex/vscode PascalCase event names", () => { + expect(normalizeEventName("claude", "PreToolUse")).toBe("pretooluse"); + expect(normalizeEventName("codex", "Stop")).toBe("stop"); + expect(normalizeEventName("vscode", "PreCompact")).toBe("precompact"); + }); + + it("unmapped event names → null", () => { + expect(normalizeEventName("claude", "SubagentStart")).toBeNull(); + expect(normalizeEventName("cursor", "beforeSomethingUnknown")).toBeNull(); + expect(normalizeEventName("gemini", "Unknown")).toBeNull(); + }); +}); + +describe("normalizeInput — translate platform payload → claude-internal shape", () => { + it("cursor beforeReadFile → Read tool_input {file_path}", () => { + const out = normalizeInput("cursor", "pretooluse", { + hook_event_name: "beforeReadFile", + file_path: "/proj/a.ts", + cwd: "/proj", + }); + expect(out["tool_name"]).toBe("Read"); + expect((out["tool_input"] as Record)["file_path"]).toBe("/proj/a.ts"); + }); + + it("cursor beforeShellExecution → Bash tool_input {command}", () => { + const out = normalizeInput("cursor", "pretooluse", { + hook_event_name: "beforeShellExecution", + command: "npm test", + cwd: "/proj", + }); + expect(out["tool_name"]).toBe("Bash"); + expect((out["tool_input"] as Record)["command"]).toBe("npm test"); + }); + + it("vscode create_file/read_file/run_in_terminal map to Write/Read/Bash", () => { + const write = normalizeInput("vscode", "pretooluse", { tool_name: "create_file", tool_input: {} }); + expect(write["tool_name"]).toBe("Write"); + const read = normalizeInput("vscode", "pretooluse", { tool_name: "read_file", tool_input: {} }); + expect(read["tool_name"]).toBe("Read"); + const bash = normalizeInput("vscode", "pretooluse", { tool_name: "run_in_terminal", tool_input: {} }); + expect(bash["tool_name"]).toBe("Bash"); + }); + + it("vscode camelCase filePath → snake_case file_path (unknown keys preserved)", () => { + const out = normalizeInput("vscode", "pretooluse", { + tool_name: "read_file", + tool_input: { filePath: "/proj/a.ts", other: 1 }, + }); + const toolInput = out["tool_input"] as Record; + expect(toolInput["file_path"]).toBe("/proj/a.ts"); + expect(toolInput["filePath"]).toBe("/proj/a.ts"); // original key kept, not deleted + expect(toolInput["other"]).toBe(1); // unknown keys pass through untouched + }); + + it("claude/codex/gemini pass through unchanged (identical/compatible shape)", () => { + const payload = { tool_name: "Bash", tool_input: { command: "ls" } }; + expect(normalizeInput("claude", "pretooluse", payload)).toBe(payload); + expect(normalizeInput("codex", "pretooluse", payload)).toBe(payload); + expect(normalizeInput("gemini", "pretooluse", payload)).toBe(payload); + }); + + it("non-pretooluse vscode/cursor modes pass through untouched", () => { + const payload = { decision: "block" }; + expect(normalizeInput("vscode", "stop", payload)).toBe(payload); + expect(normalizeInput("cursor", "stop", payload)).toBe(payload); + }); +}); + +describe("adaptOutput — claude-internal decision → platform-native dialect", () => { + it("null decision always passes through as null regardless of platform", () => { + expect(adaptOutput("claude", "pretooluse", null)).toBeNull(); + expect(adaptOutput("cursor", "pretooluse", null)).toBeNull(); + expect(adaptOutput("gemini", "stop", null)).toBeNull(); + expect(adaptOutput("vscode", "stop", null)).toBeNull(); + }); + + it("claude/codex: identity passthrough (full parity)", () => { + const decision = { hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: "no" } }; + expect(adaptOutput("claude", "pretooluse", decision)).toBe(decision); + expect(adaptOutput("codex", "pretooluse", decision)).toBe(decision); + }); + + it("deny decision on cursor → {permission:'deny', user_message}", () => { + const decision = { + hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: "blocked reason" }, + }; + const out = adaptOutput("cursor", "pretooluse", decision)!; + expect(out["permission"]).toBe("deny"); + expect(out["user_message"]).toBe("blocked reason"); + }); + + it("deny decision on gemini → {decision:'deny', reason}", () => { + const decision = { + hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: "blocked reason" }, + }; + const out = adaptOutput("gemini", "pretooluse", decision)!; + expect(out["decision"]).toBe("deny"); + expect(out["reason"]).toBe("blocked reason"); + }); + + it("Stop block on cursor → degrades to {followup_message} (cannot truly block)", () => { + const decision = { decision: "block", reason: "goal unmet" }; + const out = adaptOutput("cursor", "stop", decision)!; + expect(out["followup_message"]).toBe("goal unmet"); + }); + + it("Stop block on gemini → {decision:'deny', reason} (closest to a real block/retry)", () => { + const decision = { decision: "block", reason: "goal unmet" }; + const out = adaptOutput("gemini", "stop", decision)!; + expect(out["decision"]).toBe("deny"); + expect(out["reason"]).toBe("goal unmet"); + }); + + it("Stop block on vscode → merged shape contains continue:false + stopReason", () => { + const decision = { decision: "block", reason: "goal unmet" }; + const out = adaptOutput("vscode", "stop", decision)!; + expect(out["continue"]).toBe(false); + expect(out["stopReason"]).toBe("goal unmet"); + // Merge, not replace — the original decision/reason keys ride along too + // (unknown keys are ignored by whichever host reads this; see adapters.ts comment). + expect(out["decision"]).toBe("block"); + expect(out["reason"]).toBe("goal unmet"); + }); +}); diff --git a/tests/hooks.test.ts b/tests/hooks.test.ts index b3e4d10..40bd0da 100644 --- a/tests/hooks.test.ts +++ b/tests/hooks.test.ts @@ -46,6 +46,48 @@ describe("PreToolUse hook (Layer 2 enforcement)", () => { }); }); +describe("PreToolUse hook — workflow CONSTRAINTS denial (brain→body enforcement)", () => { + const ioWith = (workflowText: string | null) => ({ ...io(0), readWorkflow: () => workflowText }); + + it("denies a Bash command matching a CONSTRAINTS-line forbidden literal", () => { + const d = decidePreToolUse( + { tool_name: "Bash", tool_input: { command: "npm publish" } }, + ioWith("GOAL: ship it\nCONSTRAINTS: no publish without OK\n"), + )!; + expect(d).not.toBeNull(); + const out = d["hookSpecificOutput"] as Record; + expect(out["permissionDecision"]).toBe("deny"); + expect(out["permissionDecisionReason"]).toContain("CONSTRAINTS: no publish without OK"); + }); + + it("readWorkflow absent → null (fail-open, old 2-field io objects still work)", () => { + expect( + decidePreToolUse({ tool_name: "Bash", tool_input: { command: "npm publish" } }, io(0)), + ).toBeNull(); + }); + + it("a non-matching command is not denied", () => { + expect( + decidePreToolUse( + { tool_name: "Bash", tool_input: { command: "npm test" } }, + ioWith("CONSTRAINTS: no publish without OK"), + ), + ).toBeNull(); + }); + + it("no CONSTRAINTS line, or readWorkflow returning null → fail open", () => { + expect( + decidePreToolUse({ tool_name: "Bash", tool_input: { command: "npm publish" } }, ioWith(null)), + ).toBeNull(); + expect( + decidePreToolUse( + { tool_name: "Bash", tool_input: { command: "npm publish" } }, + ioWith("GOAL: ship it\n"), + ), + ).toBeNull(); + }); +}); + describe("settings.json hooks wiring (non-clobbering)", () => { it("setup writes PreToolUse + PreCompact hooks and preserves user hooks", () => { const root = mkdtempSync(join(tmpdir(), "knitbrain-hooks-")); diff --git a/tests/platforms.test.ts b/tests/platforms.test.ts index 829c839..4a33701 100644 --- a/tests/platforms.test.ts +++ b/tests/platforms.test.ts @@ -6,10 +6,14 @@ import { generateConfig } from "../src/setup.js"; import { applyArtifacts, claudeArtifacts, + claudeLoopArtifacts, cursorArtifacts, + codexArtifacts, + geminiArtifacts, vscodeArtifacts, codexSnippet, universalArtifacts, + slashCommands, GOAL_LOOP_NUDGE, } from "../src/platforms.js"; @@ -121,3 +125,66 @@ describe("platform adapter matrix (rung 16)", () => { expect(snip).toContain("OPENAI_BASE_URL"); }); }); + +describe("cross-platform hook config emitters (Tier-1.1 — merge, never clobber, .bak)", () => { + it("codexArtifacts writes a valid .codex/hooks.json with PreToolUse", () => { + const root = mkdtempSync(join(tmpdir(), "knitbrain-codex-")); + try { + applyArtifacts(root, codexArtifacts(), cfg); + const parsed = JSON.parse(readFileSync(join(root, ".codex/hooks.json"), "utf8")); + expect(parsed.PreToolUse[0].hooks[0].command).toBe("knitbrain-hook pretooluse"); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it("geminiArtifacts writes .gemini/settings.json with hooks nested under 'hooks', preserving a pre-existing user setting", () => { + const root = mkdtempSync(join(tmpdir(), "knitbrain-gemini-")); + try { + mkdirSync(join(root, ".gemini"), { recursive: true }); + writeFileSync(join(root, ".gemini/settings.json"), JSON.stringify({ theme: "dark" })); + applyArtifacts(root, geminiArtifacts(), cfg); + const parsed = JSON.parse(readFileSync(join(root, ".gemini/settings.json"), "utf8")); + expect(parsed.hooks.AfterAgent[0].hooks[0].command).toBe("knitbrain-hook stop"); + expect(parsed.theme).toBe("dark"); // user setting preserved + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it("cursorArtifacts writes .cursor/hooks.json; re-apply dedupes by command and preserves a user-added hook entry", () => { + const root = mkdtempSync(join(tmpdir(), "knitbrain-cursor-hooks-")); + try { + applyArtifacts(root, cursorArtifacts(), cfg); + let parsed = JSON.parse(readFileSync(join(root, ".cursor/hooks.json"), "utf8")); + expect(parsed.hooks.beforeShellExecution.some((h: { command: string }) => h.command === "knitbrain-hook pretooluse")).toBe(true); + // user adds their own hook entry to the same event + parsed.hooks.beforeShellExecution.push({ command: "my-own-hook" }); + writeFileSync(join(root, ".cursor/hooks.json"), JSON.stringify(parsed)); + // re-apply + applyArtifacts(root, cursorArtifacts(), cfg); + parsed = JSON.parse(readFileSync(join(root, ".cursor/hooks.json"), "utf8")); + const commands = parsed.hooks.beforeShellExecution.map((h: { command: string }) => h.command); + expect(commands.filter((c: string) => c === "knitbrain-hook pretooluse")).toHaveLength(1); // deduped, not doubled + expect(commands).toContain("my-own-hook"); // user's version + entry preserved + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it("claudeLoopArtifacts writes .claude/commands/loop.md, cross-referencing /goal", () => { + const loop = claudeLoopArtifacts().find((a) => a.path === ".claude/commands/loop.md")!; + expect(loop).toBeDefined(); + expect(loop.content).toContain("/goal"); + }); + + it("goal.md content references /loop (explicit --for/--iters budget escape hatch)", () => { + const goal = claudeArtifacts(cfg).find((a) => a.path === ".claude/commands/goal.md")!; + expect(goal.content).toContain("/loop"); + }); + + it("slashCommands('claude-code') includes /loop", () => { + const cmds = slashCommands("claude-code").map((c) => c.cmd); + expect(cmds).toContain("/loop"); + }); +});