diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1acb56d..0c609b3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,6 +12,17 @@ concurrency: cancel-in-progress: true jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 + with: + node-version: '22' + cache: npm + - run: npm ci + - run: npm run lint + test: strategy: fail-fast: false diff --git a/CLAUDE.md b/CLAUDE.md index 191cec8..fc37d09 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,6 +14,8 @@ The consequence that matters most: **nothing here executes from the repo.** Edit |---|---| | Install development dependencies | `npm ci` | | Typecheck | `npm run typecheck` | +| Lint and check formatting | `npm run lint` (Biome; CI runs it) | +| Format and apply safe lint fixes | `npm run format` | | Run the tests | `npm test` (stubs qmd; no model needed) | | Run only the KB gate suite | `node --experimental-strip-types --disable-warning=ExperimentalWarning --test tests/kb-gate.test.mts` | | Verify the SYNC blocks agree | `npm run check:sync` | diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..0e58158 --- /dev/null +++ b/biome.json @@ -0,0 +1,17 @@ +{ + "$schema": "./node_modules/@biomejs/biome/configuration_schema.json", + "files": { + "includes": [ + "hooks/**/*.mts", + "scripts/**/*.mts", + "tests/**/*.mts", + "tests/fixtures/*.json", + "config/*.json", + "package.json", + "tsconfig.json", + "biome.json" + ] + }, + "formatter": { "indentStyle": "space", "lineWidth": 140 }, + "javascript": { "formatter": { "arrowParentheses": "asNeeded" } } +} diff --git a/hooks/kb-gate.mts b/hooks/kb-gate.mts index 6f7a6c5..8c45a1d 100755 --- a/hooks/kb-gate.mts +++ b/hooks/kb-gate.mts @@ -1,5 +1,16 @@ #!/usr/bin/env -S node --experimental-strip-types --disable-warning=ExperimentalWarning -import { appendFileSync, existsSync, lstatSync, mkdirSync, readdirSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { + appendFileSync, + existsSync, + lstatSync, + mkdirSync, + readdirSync, + realpathSync, + renameSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; import { basename, dirname, join, resolve } from "node:path"; import { attempt, isDirectory, projectRoot, read } from "./shared.mts"; @@ -24,7 +35,11 @@ const USAGE_COMPACT_FLOOR = 50; const TODAY = new Date().toISOString().slice(0, 10); function memorySkill(name: unknown): string | undefined { - const skill = String(name ?? "").replace(/^\//, "").split(":").at(-1) ?? ""; + const skill = + String(name ?? "") + .replace(/^\//, "") + .split(":") + .at(-1) ?? ""; return Object.hasOwn(MEMORY_SKILL_SCOPES, skill) ? skill : undefined; } @@ -42,7 +57,10 @@ function readIfExists(file: string): string | undefined { } function rows(file: string): string[][] | undefined { - return readIfExists(file)?.split("\n").filter(Boolean).map(line => line.split("\t")); + return readIfExists(file) + ?.split("\n") + .filter(Boolean) + .map(line => line.split("\t")); } function isMemory(memories: string, name: string): boolean { @@ -53,15 +71,15 @@ function parseUsage(content: string): { since: string; dates: Map(); let since = ""; const lines = content.split("\n").filter(Boolean); - for (const [first, date, ...rest] of lines.map(line => line.split("\t"))) { - const header = /^# since: (\S+)$/.exec(first!)?.[1]; + for (const [first = "", date, ...rest] of lines.map(line => line.split("\t"))) { + const header = /^# since: (\S+)$/.exec(first)?.[1]; if (header && DAY.test(header) && header <= TODAY) { if (!since || header < since) since = header; continue; } // A future date would pin a memory as fresh forever, so a skewed clock's row is dropped, not trusted. if (!date || rest.length > 0 || !DAY.test(date) || date > TODAY) continue; - if (date > (dates.get(first!) ?? "")) dates.set(first!, date); + if (date > (dates.get(first) ?? "")) dates.set(first, date); } return { since, dates, lines: lines.length }; } @@ -109,13 +127,15 @@ function gate(): void { if (event === "SubagentStart" && !GATED_AGENTS.includes(agentType)) return; if (!["UserPromptSubmit", "PostToolUse", "PreToolUse", "SubagentStart"].includes(event)) return; const isSkillCall = event === "PostToolUse" && payload.tool_name === "Skill"; - if (isSkillCall && !memorySkill(payload.tool_input?.skill)) return; + const toolSkill = isSkillCall ? memorySkill(payload.tool_input?.skill) : undefined; + if (isSkillCall && !toolSkill) return; const isRetrieval = event === "PostToolUse" && RETRIEVAL_TOOLS.includes(payload.tool_name); const isMemoryWrite = event === "PreToolUse" && MEMORY_WRITE_TOOLS.includes(payload.tool_name); const target = payload.tool_input?.file_path || payload.tool_input?.notebook_path || ""; if (target && !mentionsMemories(target)) return; const command = String(payload.tool_input?.command ?? ""); - const isMemoryDelete = event === "PreToolUse" && payload.tool_name === "Bash" && command.includes("memories") && MEMORY_DELETE.test(command); + const isMemoryDelete = + event === "PreToolUse" && payload.tool_name === "Bash" && command.includes("memories") && MEMORY_DELETE.test(command); if (event === "PreToolUse" && payload.tool_name === "Bash" && !isMemoryDelete) return; const root = projectRoot(); @@ -131,7 +151,12 @@ function gate(): void { const turn = /^\d+$/.test(turnText) ? Number(turnText) : 0; function logEvent(extra: Record): void { - attempt(() => appendFileSync(log, `${JSON.stringify({ ts: new Date().toISOString().replace(/\.\d{3}Z$/, "Z"), session, event, mode: MODE, ...extra })}\n`)); + attempt(() => + appendFileSync( + log, + `${JSON.stringify({ ts: new Date().toISOString().replace(/\.\d{3}Z$/, "Z"), session, event, mode: MODE, ...extra })}\n`, + ), + ); } function output(fields: Record): void { @@ -161,7 +186,10 @@ function gate(): void { } function activeMemorySkill(includeTurnScoped: boolean): string | undefined { - return rows(skillsFile)?.find(([at, name]) => MEMORY_SKILL_SCOPES[name!] === "session" || (includeTurnScoped && MEMORY_SKILL_SCOPES[name!] === "turn" && Number(at) === turn))?.[1]; + return rows(skillsFile)?.find( + ([at, name = ""]) => + MEMORY_SKILL_SCOPES[name] === "session" || (includeTurnScoped && MEMORY_SKILL_SCOPES[name] === "turn" && Number(at) === turn), + )?.[1]; } function memoryName(path: string): string | undefined { @@ -187,25 +215,22 @@ function gate(): void { attempt(() => writeFileSync(denialsFile, "")); attempt(() => writeFileSync(turnFile, `${turn + 1}\n`)); logEvent({ decision: "turn_start", turn: turn + 1 }); - for (const [, skill] of String(payload.prompt ?? "").matchAll(SLASH_SKILL)) { - append(skillsFile, [turn + 1, skill!]); + for (const [, skill = ""] of String(payload.prompt ?? "").matchAll(SLASH_SKILL)) { + append(skillsFile, [turn + 1, skill]); logEvent({ decision: "recorded_skill", skill, source: "prompt" }); } return; } - if (isSkillCall) { - const skill = memorySkill(payload.tool_input.skill)!; - append(skillsFile, [turn, skill]); - logEvent({ decision: "recorded_skill", skill, source: "tool" }); + if (toolSkill) { + append(skillsFile, [turn, toolSkill]); + logEvent({ decision: "recorded_skill", skill: toolSkill, source: "tool" }); return; } if (isRetrieval) { if (!isDirectory(memories)) return; - const names = target - ? [memoryName(target)] - : [...stdin.matchAll(RETRIEVED_RESOURCE)].map(match => match[1]); + const names = target ? [memoryName(target)] : [...stdin.matchAll(RETRIEVED_RESOURCE)].map(match => match[1]); const retrieved = [...new Set(names)].filter((name): name is string => !!name && isMemory(memories, name)); if (retrieved.length === 0) return; const skill = activeMemorySkill(true); @@ -220,7 +245,10 @@ function gate(): void { if (event === "PostToolUse") { const input = payload.tool_input; - const query = (input?.query || (input?.searches ?? []).map((search: { query?: string }) => search.query ?? "").join(" ")).replace(/\n+$/, ""); + const query = (input?.query || (input?.searches ?? []).map((search: { query?: string }) => search.query ?? "").join(" ")).replace( + /\n+$/, + "", + ); if (!query) return; mkdirSync(state, { recursive: true }); attempt(() => appendFileSync(queriesFile, `${turn}\t${query}\n`)); @@ -230,7 +258,8 @@ function gate(): void { if (event === "SubagentStart") { if (!isDirectory(memories)) return; - output({ additionalContext: `This project keeps a knowledge base of past learnings, decisions, and + output({ + additionalContext: `This project keeps a knowledge base of past learnings, decisions, and debugging discoveries in .claude/memories/, searchable with mcp__memory-loop__query. @@ -247,7 +276,8 @@ mcp__memory-loop__query. - Snippets are leads, not evidence — they are capped at 300 characters and often show only a title. mcp__memory-loop__get a document before relying on what it says. -- Report back anything the KB got wrong or left out.` }); +- Report back anything the KB got wrong or left out.`, + }); logEvent({ agent_type: agentType, decision: "briefed" }); return; } @@ -264,7 +294,11 @@ mcp__memory-loop__query. } if (agentId) { logMemory("deny", { skip_reason: "subagent" }); - output({ permissionDecision: "deny", permissionDecisionReason: "Sub-agents cannot change .claude/memories/. Report the proposed memory change back instead; the main thread applies it through the continuous-learning or memory-audit skill, which run the search, pre-write and approval checks." }); + output({ + permissionDecision: "deny", + permissionDecisionReason: + "Sub-agents cannot change .claude/memories/. Report the proposed memory change back instead; the main thread applies it through the continuous-learning or memory-audit skill, which run the search, pre-write and approval checks.", + }); return; } const skill = activeMemorySkill(isMemoryWrite); @@ -283,9 +317,12 @@ mcp__memory-loop__query. } append(denialsFile, [turn, phase]); logMemory("deny", { denials_turn: spent.length + 1 }); - output({ permissionDecision: "deny", permissionDecisionReason: isMemoryWrite - ? "Memory files are written only through the continuous-learning or memory-audit skill, and continuous-learning has not been invoked in this turn. Invoke Skill(continuous-learning) now and run its search and pre-write checks before retrying. Working from a copy loaded in an earlier turn is what skips them." - : "Memory files are deleted or moved only by the memory-audit skill, after the user approves the batch. Run Skill(memory-audit) and follow its approval step before retrying; to correct a memory instead, edit it through Skill(continuous-learning)." }); + output({ + permissionDecision: "deny", + permissionDecisionReason: isMemoryWrite + ? "Memory files are written only through the continuous-learning or memory-audit skill, and continuous-learning has not been invoked in this turn. Invoke Skill(continuous-learning) now and run its search and pre-write checks before retrying. Working from a copy loaded in an earlier turn is what skips them." + : "Memory files are deleted or moved only by the memory-audit skill, after the user approves the batch. Run Skill(memory-audit) and follow its approval step before retrying; to correct a memory instead, edit it through Skill(continuous-learning).", + }); return; } @@ -304,7 +341,15 @@ mcp__memory-loop__query. const queries = (attempt(() => read(queriesFile)) ?? "").split("\n").filter(line => line && Number(line.split("\t")[0]) === turn).length; const freshQuery = queries > 0; function logDecision(satisfied: boolean, decision: string, extra = {}): void { - logEvent({ phase: "discovery", agent_type: agentType, fresh_query: freshQuery, had_kb_block: hadKBBlock, satisfied, decision, ...extra }); + logEvent({ + phase: "discovery", + agent_type: agentType, + fresh_query: freshQuery, + had_kb_block: hadKBBlock, + satisfied, + decision, + ...extra, + }); } if (freshQuery && hadKBBlock) { logDecision(true, "allow"); @@ -334,11 +379,16 @@ mcp__memory-loop__query. } append(denialsFile, [turn, "discovery", queries]); logDecision(false, "deny", { denials_turn: denialsTurn + 1, denials_state: denialsState + 1 }); - output({ permissionDecision: "deny", permissionDecisionReason: `Prerequisite missing: ${missing}. Search the KB with mcp__memory-loop__query first, then re-issue this exact call with a "${KB_MARKER}" block (1-5 bullets of findings, or "${KB_MARKER} none relevant.") at the top of the prompt. Spawning several agents at once: write the findings to a scratchpad file and open each prompt with "${KB_MARKER} see " instead of repeating them. Sub-agents cannot see your KB results — unpasted context is rediscovered from scratch.` }); + output({ + permissionDecision: "deny", + permissionDecisionReason: `Prerequisite missing: ${missing}. Search the KB with mcp__memory-loop__query first, then re-issue this exact call with a "${KB_MARKER}" block (1-5 bullets of findings, or "${KB_MARKER} none relevant.") at the top of the prompt. Spawning several agents at once: write the findings to a scratchpad file and open each prompt with "${KB_MARKER} see " instead of repeating them. Sub-agents cannot see your KB results — unpasted context is rediscovered from scratch.`, + }); return; } logDecision(false, "warn"); - output({ additionalContext: `KB protocol: ${missing}. Sub-agents cannot see your KB results, so this agent will rediscover from scratch. Before the next spawn, search the KB and open the prompt with a "${KB_MARKER}" block.` }); + output({ + additionalContext: `KB protocol: ${missing}. Sub-agents cannot see your KB results, so this agent will rediscover from scratch. Before the next spawn, search the KB and open the prompt with a "${KB_MARKER}" block.`, + }); } attempt(gate); diff --git a/hooks/shared.mts b/hooks/shared.mts index 4f434ea..5f52800 100644 --- a/hooks/shared.mts +++ b/hooks/shared.mts @@ -1,5 +1,5 @@ -import { accessSync, constants, readFileSync, statSync } from "node:fs"; import { spawnSync } from "node:child_process"; +import { accessSync, constants, readFileSync, statSync } from "node:fs"; import { delimiter, join } from "node:path"; export function attempt(action: () => T): T | undefined { @@ -19,12 +19,13 @@ export function isDirectory(path: string): boolean { } export function commandExists(command: string): boolean { - return (process.env.PATH ?? "").split(delimiter).some(directory => - attempt(() => { - const path = join(directory, command); - accessSync(path, constants.X_OK); - return !statSync(path).isDirectory(); - }) ?? false, + return (process.env.PATH ?? "").split(delimiter).some( + directory => + attempt(() => { + const path = join(directory, command); + accessSync(path, constants.X_OK); + return !statSync(path).isDirectory(); + }) ?? false, ); } diff --git a/hooks/sync-memories.mts b/hooks/sync-memories.mts index 5a09f28..97ceeb5 100755 --- a/hooks/sync-memories.mts +++ b/hooks/sync-memories.mts @@ -1,6 +1,6 @@ #!/usr/bin/env -S node --experimental-strip-types --disable-warning=ExperimentalWarning -import { closeSync, mkdirSync, openSync, renameSync, rmSync, rmdirSync, statSync, writeFileSync } from "node:fs"; import { spawnSync } from "node:child_process"; +import { closeSync, mkdirSync, openSync, renameSync, rmdirSync, rmSync, statSync, writeFileSync } from "node:fs"; import { attempt, commandExists, isDirectory, projectRoot, read } from "./shared.mts"; const embedModel = "hf:Qwen/Qwen3-Embedding-0.6B-GGUF/Qwen3-Embedding-0.6B-Q8_0.gguf"; diff --git a/package-lock.json b/package-lock.json index b4d3194..3e8e8ad 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5,6 +5,7 @@ "packages": { "": { "devDependencies": { + "@biomejs/biome": "2.5.15", "@types/node": "22.9.0", "typescript": "5.9.2" }, @@ -12,6 +13,181 @@ "node": ">=22.6" } }, + "node_modules/@biomejs/biome": { + "version": "2.5.15", + "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.5.15.tgz", + "integrity": "sha512-WZTW4slm/pdkh92K6t/3aEN++44JD1PQ7squ7RCMLI1flHGl43DVOIGXvCjHiZgBMO1V4uYxoFNtqrIA4qGuIQ==", + "dev": true, + "license": "MIT OR Apache-2.0", + "bin": { + "biome": "bin/biome" + }, + "engines": { + "node": ">=14.21.3" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/biome" + }, + "optionalDependencies": { + "@biomejs/cli-darwin-arm64": "2.5.15", + "@biomejs/cli-darwin-x64": "2.5.15", + "@biomejs/cli-linux-arm64": "2.5.15", + "@biomejs/cli-linux-arm64-musl": "2.5.15", + "@biomejs/cli-linux-x64": "2.5.15", + "@biomejs/cli-linux-x64-musl": "2.5.15", + "@biomejs/cli-win32-arm64": "2.5.15", + "@biomejs/cli-win32-x64": "2.5.15" + } + }, + "node_modules/@biomejs/cli-darwin-arm64": { + "version": "2.5.15", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.5.15.tgz", + "integrity": "sha512-BZVzFhJ/mUvTLMYbc9x6el0o2Uv5zP7bACYyhFb6fSc1giroXm7PL0a5KqMJ9F+/BnkYRjagD/ROSDVOZ6eapg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-darwin-x64": { + "version": "2.5.15", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.5.15.tgz", + "integrity": "sha512-V5Kw63V+fVGNFhFrizDxMbGXAzZCh8kYzJpy3GI6gTapWDg7bZK9oYC9CbVgQSEdTnupz0U5Efz8Ev+5vdBBXw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64": { + "version": "2.5.15", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.5.15.tgz", + "integrity": "sha512-XaG7P7eeSLYETD3K9grfB0mQmpLQZjygdbsxekWBJAg4am79fGAtRpfRyNnTvXB2fZFNnJZG3nXm7HBat0+VUw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64-musl": { + "version": "2.5.15", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.5.15.tgz", + "integrity": "sha512-tGzZUTcJCV7tj5Adh/Gn6nR4MycqA3iohq2LEcrarAgRBkU0h0OKd2UzCRkOsOYOuUGaUJb4YmqRqyy2feGGSw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64": { + "version": "2.5.15", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.5.15.tgz", + "integrity": "sha512-xE4iEW/3LqlYj9GFgGrFsFSH51dEEpUbYWBfeOv0q87WkUDxK2o/HhreSb7qMJqck70RVM6Lg96hgedcAYcOkA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64-musl": { + "version": "2.5.15", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.5.15.tgz", + "integrity": "sha512-IlxUcyxilVGPsE008x13pWdkTbU3nQpP2i9b5UrbOYj6goBkKsRX1XB7yJLcV+O1H9LBchIIm4adOaVLZBX0ZQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-arm64": { + "version": "2.5.15", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.5.15.tgz", + "integrity": "sha512-2kPKzhNlm8C+Ru3GcO0Me2ODkCBLrVOUNuyi84RJyWDVKAA4+Kjj3jMjL938lF6BRfvltC4vB2nPY9SvN51Ovg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-x64": { + "version": "2.5.15", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.5.15.tgz", + "integrity": "sha512-yAzh4UqEImV6Hcy0zjUqNfJAsUhoD64FBQCdTG8Md/Z2wQeVP/ZmJ3XRYAs1g8J9fjTFyl2fCtc5FB5til47+g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, "node_modules/@types/node": { "version": "22.9.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.9.0.tgz", diff --git a/package.json b/package.json index 0886be9..1ac8b5f 100644 --- a/package.json +++ b/package.json @@ -1,13 +1,18 @@ { "private": true, "type": "module", - "engines": { "node": ">=22.6" }, + "engines": { + "node": ">=22.6" + }, "scripts": { "test": "node --experimental-strip-types --disable-warning=ExperimentalWarning --test tests/*.test.mts", "typecheck": "tsc --noEmit", - "check:sync": "node --experimental-strip-types --disable-warning=ExperimentalWarning scripts/check-sync-blocks.mts" + "check:sync": "node --experimental-strip-types --disable-warning=ExperimentalWarning scripts/check-sync-blocks.mts", + "lint": "biome ci --error-on-warnings", + "format": "biome check --write" }, "devDependencies": { + "@biomejs/biome": "2.5.15", "@types/node": "22.9.0", "typescript": "5.9.2" } diff --git a/techpack.yaml b/techpack.yaml index 6530de3..339d1f1 100644 --- a/techpack.yaml +++ b/techpack.yaml @@ -384,6 +384,7 @@ ignore: - package.json - package-lock.json - tsconfig.json + - biome.json # --------------------------------------------------------------------------- # Templates — CLAUDE.local.md sections diff --git a/tests/contracts.test.mts b/tests/contracts.test.mts index f76ab48..2a60173 100644 --- a/tests/contracts.test.mts +++ b/tests/contracts.test.mts @@ -1,12 +1,18 @@ import assert from "node:assert/strict"; -import { cpSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; import { spawnSync } from "node:child_process"; +import { cpSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; import test from "node:test"; -import { fixture, gateFixture, root, flags } from "./helpers.mts"; +import { fixture, flags, gateFixture, root } from "./helpers.mts"; const messages = JSON.parse(readFileSync(`${root}/tests/fixtures/hook-messages.json`, "utf8")); const manifest = readFileSync(`${root}/techpack.yaml`, "utf8"); +function field(component: string, key: string): string { + const value = new RegExp(`${key}: (\\S+)`).exec(component)?.[1]; + assert.ok(value, `component has no ${key}`); + return value; +} + test("hook messages match the original shell output byte for byte", t => { for (const mode of ["enforce", "warn"]) { const f = gateFixture(t, mode); @@ -22,20 +28,29 @@ for (const type of ["commonjs", "module"]) { test(`manifest hook layout works in a ${type} consumer`, t => { const f = fixture(t); writeFileSync(`${f.project}/package.json`, JSON.stringify({ type })); - const hooks = manifest.split(/^ - id: /m).filter(component => /\n hookEvent:/.test(component)); + const hooks = manifest.split(/^ {2}- id: /m).filter(component => /\n {4}hookEvent:/.test(component)); assert.equal(hooks.length, 10); - assert.match(manifest, /hook:\n source: hooks\/shared.mts\n destination: shared.mts/); + assert.match(manifest, /hook:\n {6}source: hooks\/shared.mts\n {6}destination: shared.mts/); const installed = `${f.project}/.claude/hooks/memory`; mkdirSync(installed, { recursive: true }); cpSync(`${root}/hooks/shared.mts`, `${installed}/shared.mts`); for (const component of hooks) { - const source = component.match(/source: (\S+)/)![1]!; - const destination = component.match(/destination: (\S+)/)![1]!; - const event = component.match(/hookEvent: (\S+)/)![1]!; + const source = field(component, "source"); + const destination = field(component, "destination"); + const event = field(component, "hookEvent"); if (destination === "memory-loop-activator.sh") assert.doesNotMatch(component, /hookInterpreter:/); else assert.match(component, /hookInterpreter: node --experimental-strip-types --disable-warning=ExperimentalWarning/); writeFileSync(`${installed}/${destination}`, readFileSync(`${root}/${source}`, "utf8").replace("__KB_GATE_MODE__", "enforce")); - const output = f.run(`${installed}/${destination}`, { hook_event_name: event, session_id: "install", agent_type: "Explore", tool_input: { subagent_type: "Explore", query: "topic", prompt: "KB context: none relevant." } }, { PATH: `${f.work}:/usr/bin:/bin` }); + const output = f.run( + `${installed}/${destination}`, + { + hook_event_name: event, + session_id: "install", + agent_type: "Explore", + tool_input: { subagent_type: "Explore", query: "topic", prompt: "KB context: none relevant." }, + }, + { PATH: `${f.work}:/usr/bin:/bin` }, + ); if (destination === "memory-loop-activator.sh") assert.equal(output, messages.activator); if (event === "SubagentStart") assert.equal(JSON.parse(output).hookSpecificOutput.hookEventName, event); } @@ -65,6 +80,12 @@ test("the SYNC verifier catches missing and divergent blocks", t => { }); test("only the static activator remains a shell script", () => { - assert.deepEqual(readdirSync(`${root}/hooks`).filter(file => file.endsWith(".sh")), ["memory-loop-activator.sh"]); - assert.equal(readdirSync(`${root}/tests`).some(file => file.endsWith(".sh")), false); + assert.deepEqual( + readdirSync(`${root}/hooks`).filter(file => file.endsWith(".sh")), + ["memory-loop-activator.sh"], + ); + assert.equal( + readdirSync(`${root}/tests`).some(file => file.endsWith(".sh")), + false, + ); }); diff --git a/tests/helpers.mts b/tests/helpers.mts index 59f1062..6c3e8eb 100644 --- a/tests/helpers.mts +++ b/tests/helpers.mts @@ -1,8 +1,8 @@ import assert from "node:assert/strict"; +import { spawn as spawnProcess, spawnSync } from "node:child_process"; import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { resolve } from "node:path"; -import { spawn as spawnProcess, spawnSync } from "node:child_process"; import type { TestContext } from "node:test"; export const root = resolve(import.meta.dirname, ".."); @@ -21,25 +21,29 @@ export function fixture(t: TestContext) { const run = (script: string, payload: unknown = {}, overrides: NodeJS.ProcessEnv = {}, cwd = project) => { const shell = script.endsWith(".sh"); const result = spawnSync(shell ? "/bin/bash" : process.execPath, shell ? [script] : [...flags, script], { - cwd, env: { ...env, ...overrides }, input: typeof payload === "string" ? payload : JSON.stringify(payload), encoding: "utf8", + cwd, + env: { ...env, ...overrides }, + input: typeof payload === "string" ? payload : JSON.stringify(payload), + encoding: "utf8", }); assert.equal(result.status, 0, result.stderr); assert.equal(result.stderr, ""); return result.stdout; }; - const runAsync = (script: string, payload: unknown) => new Promise<{ status: number | null; stdout: string; stderr: string }>(done => { - const child = spawnProcess(process.execPath, [...flags, script], { cwd: project, env }); - let stdout = ""; - let stderr = ""; - child.stdout.on("data", chunk => (stdout += chunk)); - child.stderr.on("data", chunk => (stderr += chunk)); - child.on("close", status => done({ status, stdout, stderr })); - child.stdin.end(JSON.stringify(payload)); - }).then(result => { - assert.equal(result.status, 0, result.stderr); - assert.equal(result.stderr, ""); - return result.stdout; - }); + const runAsync = (script: string, payload: unknown) => + new Promise<{ status: number | null; stdout: string; stderr: string }>(done => { + const child = spawnProcess(process.execPath, [...flags, script], { cwd: project, env }); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", chunk => (stdout += chunk)); + child.stderr.on("data", chunk => (stderr += chunk)); + child.on("close", status => done({ status, stdout, stderr })); + child.stdin.end(JSON.stringify(payload)); + }).then(result => { + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stderr, ""); + return result.stdout; + }); return { work, project, env, run, runAsync }; } @@ -53,19 +57,53 @@ export function gateFixture(t: TestContext, mode = "enforce") { const event = (hook_event_name: string, extra = {}) => f.run(script, payload(hook_event_name, extra)); const turn = (prompt = "do the thing") => event("UserPromptSubmit", { prompt }); const search = (input: unknown = { query: "some topic" }) => event("PostToolUse", { tool_input: input }); - const spawn = (prompt = "find the thing", subagent_type = "Explore", agent_id = "") => event("PreToolUse", { agent_id, tool_input: { subagent_type, prompt } }); + const spawn = (prompt = "find the thing", subagent_type = "Explore", agent_id = "") => + event("PreToolUse", { agent_id, tool_input: { subagent_type, prompt } }); const skill = (name = "continuous-learning") => event("PostToolUse", { tool_name: "Skill", tool_input: { skill: name } }); - const write = ({ file_path = `${f.project}/.claude/memories/learning_x.md`, agent_id = "", tool_name = "Write" } = {}) => event("PreToolUse", { tool_name, agent_id, tool_input: { file_path, content: "x" } }); + const write = ({ file_path = `${f.project}/.claude/memories/learning_x.md`, agent_id = "", tool_name = "Write" } = {}) => + event("PreToolUse", { tool_name, agent_id, tool_input: { file_path, content: "x" } }); const bash = (command: string, agent_id = "") => event("PreToolUse", { tool_name: "Bash", agent_id, tool_input: { command } }); - const retrieval = (names: string[], tool_name = "mcp__memory-loop__get") => ({ tool_name, tool_input: { file: "#abc123" }, tool_response: names.map(name => ({ type: "text", text: `[Resource from memory-loop at qmd://memories/${name}] \n\n1: # Title` })) }); + const retrieval = (names: string[], tool_name = "mcp__memory-loop__get") => ({ + tool_name, + tool_input: { file: "#abc123" }, + tool_response: names.map(name => ({ + type: "text", + text: `[Resource from memory-loop at qmd://memories/${name}] \n\n1: # Title`, + })), + }); const retrieve = (names: string[], tool_name?: string) => event("PostToolUse", retrieval(names, tool_name)); const retrieveConcurrently = (names: string[]) => f.runAsync(script, payload("PostToolUse", retrieval(names))); const readMemory = (file_path: string) => event("PostToolUse", { tool_name: "Read", tool_input: { file_path }, tool_response: {} }); - const memories = (...names: string[]) => names.forEach(name => writeFileSync(`${f.project}/.claude/memories/${name}`, "# x\n")); + const memories = (...names: string[]) => { + for (const name of names) writeFileSync(`${f.project}/.claude/memories/${name}`, "# x\n"); + }; const usagePath = `${f.project}/.claude/memories/.usage.tsv`; const usage = () => readFileSync(usagePath, "utf8"); - const lastLog = () => JSON.parse(readFileSync(log, "utf8").trim().split("\n").at(-1)!); - return { ...f, script, state, log, event, turn, search, spawn, skill, write, bash, retrieve, retrieveConcurrently, readMemory, memories, usagePath, usage, lastLog }; + const lastLog = () => { + const line = readFileSync(log, "utf8").trim().split("\n").at(-1); + assert.ok(line, "gate log is empty"); + return JSON.parse(line); + }; + return { + ...f, + script, + state, + log, + event, + turn, + search, + spawn, + skill, + write, + bash, + retrieve, + retrieveConcurrently, + readMemory, + memories, + usagePath, + usage, + lastLog, + }; } export function denied(output: string): { permissionDecisionReason: string } { @@ -77,7 +115,9 @@ export function denied(output: string): { permissionDecisionReason: string } { export function qmdStub(work: string): string { const bin = `${work}/bin`; mkdirSync(bin); - writeFileSync(`${bin}/qmd`, `#!${process.execPath} + writeFileSync( + `${bin}/qmd`, + `#!${process.execPath} const fs = require('node:fs'); const args = process.argv.slice(2); fs.appendFileSync(process.env.STUB_LOG, JSON.stringify({args, config: process.env.QMD_CONFIG_DIR, index: process.env.INDEX_PATH}) + '\\n'); @@ -91,6 +131,8 @@ if (command === 'status') { console.log(' Total: 2 files indexed'); if (mode === 'pending') console.log(' Pending: 3 need embedding'); } -`, { mode: 0o755 }); +`, + { mode: 0o755 }, + ); return bin; } diff --git a/tests/kb-gate.test.mts b/tests/kb-gate.test.mts index 9b5b7ba..76d0ea4 100644 --- a/tests/kb-gate.test.mts +++ b/tests/kb-gate.test.mts @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, symlinkSync, utimesSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, symlinkSync, utimesSync, writeFileSync } from "node:fs"; import test from "node:test"; import { denied, gateFixture, root } from "./helpers.mts"; @@ -77,7 +77,12 @@ test("both search shapes count, intent alone does not; query text is retained", f.turn(); f.search({ query: "plain query\nwith tabs\tand trailing newline\n" }); assert.equal(readFileSync(`${f.state}/session.queries`, "utf8"), "1\tplain query\nwith tabs\tand trailing newline\n"); - f.search({ searches: [{ type: "lex", query: "counter state" }, { type: "vec", query: "why not wall clock" }] }); + f.search({ + searches: [ + { type: "lex", query: "counter state" }, + { type: "vec", query: "why not wall clock" }, + ], + }); assert.match(readFileSync(`${f.state}/session.queries`, "utf8"), /counter state why not wall clock/); assert.equal(f.spawn(kb), ""); f.turn(); @@ -148,7 +153,10 @@ test("logs retain a bounded tail and leave small files in place", t => { const inode = statSync(f.log).ino; f.turn(); assert.equal(statSync(f.log).ino, inode); - assert.equal(readdirSync(f.state).some(name => name.endsWith(".tmp")), false); + assert.equal( + readdirSync(f.state).some(name => name.endsWith(".tmp")), + false, + ); }); test("an unavailable state directory fails open", t => { @@ -269,7 +277,13 @@ test("unrelated Bash commands pass without evaluation", t => { const f = gateFixture(t); f.turn(); const before = readFileSync(f.log, "utf8"); - for (const command of ["ls .claude/memories", "rm build/tmp.txt", "docker run --rm memories-image", "grep -rn memories . | wc -l", "cat .claude/memories/a.md | grep rm"]) { + for (const command of [ + "ls .claude/memories", + "rm build/tmp.txt", + "docker run --rm memories-image", + "grep -rn memories . | wc -l", + "cat .claude/memories/a.md | grep rm", + ]) { assert.equal(f.bash(command), ""); } assert.equal(readFileSync(f.log, "utf8"), before); @@ -310,7 +324,10 @@ test("reads through a symlinked memories dir count under either path", t => { f.turn(); f.readMemory(`${f.work}/repo/memories/learning_a.md`); f.readMemory(`${f.project}/.claude/memories/learning_b.md`); - assert.equal(readFileSync(`${f.work}/repo/memories/.usage.tsv`, "utf8"), `# since: ${today()}\nlearning_a.md\t${today()}\nlearning_b.md\t${today()}\n`); + assert.equal( + readFileSync(`${f.work}/repo/memories/.usage.tsv`, "utf8"), + `# since: ${today()}\nlearning_a.md\t${today()}\nlearning_b.md\t${today()}\n`, + ); }); test("reads during an audit session or a capture turn are not usage", t => { @@ -334,7 +351,8 @@ test("reads during an audit session or a capture turn are not usage", t => { test("a retrieval appends to a union-merged file without rewriting it", t => { const f = gateFixture(t); f.memories("learning_a.md", "learning_c.md"); - const merged = "# since: 2026-01-10\nlearning_a.md\t2026-02-01\n# since: 2025-12-01\nlearning_a.md\t2026-03-05\ngarbage line\nlearning_c.md\t2026-01-01"; + const merged = + "# since: 2026-01-10\nlearning_a.md\t2026-02-01\n# since: 2025-12-01\nlearning_a.md\t2026-03-05\ngarbage line\nlearning_c.md\t2026-01-01"; writeFileSync(f.usagePath, merged); f.turn(); f.retrieve(["learning_c.md"]); @@ -349,10 +367,16 @@ test("prompt submit compacts a usage file past its threshold and leaves a small f.turn(); assert.equal(f.usage(), small); const history = Array.from({ length: 60 }, (_, i) => `learning_a.md\t2026-02-${String((i % 28) + 1).padStart(2, "0")}\n`).join(""); - writeFileSync(f.usagePath, `# since: 2026-01-10\n${history}# since: 2025-12-01\nlearning_gone.md\t2026-03-01\nlearning_b.md\t2999-01-01\ngarbage line\nlearning_c.md\t2026-01-01\n`); + writeFileSync( + f.usagePath, + `# since: 2026-01-10\n${history}# since: 2025-12-01\nlearning_gone.md\t2026-03-01\nlearning_b.md\t2999-01-01\ngarbage line\nlearning_c.md\t2026-01-01\n`, + ); f.turn(); assert.equal(f.usage(), "# since: 2025-12-01\nlearning_a.md\t2026-02-28\nlearning_c.md\t2026-01-01\n"); - assert.equal(readdirSync(f.state).some(name => name.endsWith(".tmp")), false); + assert.equal( + readdirSync(f.state).some(name => name.endsWith(".tmp")), + false, + ); }); test("concurrent retrievals record every memory", async t => { @@ -363,5 +387,8 @@ test("concurrent retrievals record every memory", async t => { await Promise.all(names.map(name => f.retrieveConcurrently([name]))); const lines = f.usage().split("\n").filter(Boolean); assert.ok(lines.filter(line => line.startsWith("#")).every(line => line === `# since: ${today()}`)); - assert.deepEqual(lines.filter(line => !line.startsWith("#")).sort(), names.map(name => `${name}\t${today()}`)); + assert.deepEqual( + lines.filter(line => !line.startsWith("#")).sort(), + names.map(name => `${name}\t${today()}`), + ); }); diff --git a/tests/sync-memories.test.mts b/tests/sync-memories.test.mts index 1b0afb2..1af4171 100644 --- a/tests/sync-memories.test.mts +++ b/tests/sync-memories.test.mts @@ -1,8 +1,8 @@ import assert from "node:assert/strict"; -import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, symlinkSync, utimesSync, writeFileSync } from "node:fs"; import { spawnSync } from "node:child_process"; -import test from "node:test"; +import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, symlinkSync, utimesSync, writeFileSync } from "node:fs"; import type { TestContext } from "node:test"; +import test from "node:test"; import { fixture, qmdStub } from "./helpers.mts"; function indexer(t: TestContext) { @@ -15,8 +15,15 @@ function indexer(t: TestContext) { const lock = `${directory}/.reindex.lock`; const run = (mode = "ok", env: NodeJS.ProcessEnv = {}, cwd = f.project) => { writeFileSync(calls, ""); - assert.equal(f.run(`${f.work}/hooks/sync-memories.mts`, {}, { PATH: `${bin}:${process.env.PATH}`, STUB_LOG: calls, STUB_MODE: mode, ...env }, cwd), ""); - return readFileSync(calls, "utf8").trim().split("\n").filter(Boolean).map(line => JSON.parse(line)); + assert.equal( + f.run(`${f.work}/hooks/sync-memories.mts`, {}, { PATH: `${bin}:${process.env.PATH}`, STUB_LOG: calls, STUB_MODE: mode, ...env }, cwd), + "", + ); + return readFileSync(calls, "utf8") + .trim() + .split("\n") + .filter(Boolean) + .map(line => JSON.parse(line)); }; return { ...f, bin, directory, config, log, lock, run }; } @@ -31,7 +38,10 @@ for (const linked of [false, true]) { } for (let i = 0; i < 2; i++) { const calls = f.run(); - assert.deepEqual(calls.map(call => call.args), ["update", "embed", "status", "cleanup"].map(command => ["--index", "memory-loop", command])); + assert.deepEqual( + calls.map(call => call.args), + ["update", "embed", "status", "cleanup"].map(command => ["--index", "memory-loop", command]), + ); assert.ok(calls.every(call => call.config === f.directory && call.index === `${f.directory}/memory-loop.sqlite`)); assert.equal(existsSync(`${f.directory}/memory-loop.sqlite`), true); assert.equal(existsSync(f.log), false); @@ -69,7 +79,10 @@ test("config replacement leaves other temporary files alone and cleans up after assert.equal(f.run().length, 4); assert.match(readFileSync(f.config, "utf8"), /global_context:/); assert.equal(readFileSync(`${f.config}.new`, "utf8"), "another writer"); - assert.deepEqual(readdirSync(f.directory).filter(file => file.endsWith(".new")), ["memory-loop.yml.new"]); + assert.deepEqual( + readdirSync(f.directory).filter(file => file.endsWith(".new")), + ["memory-loop.yml.new"], + ); }); for (const mode of ["update_fails", "embed_fails", "status_fails", "pending"]) {