diff --git a/README.md b/README.md index 144264b..1f4fe8a 100644 --- a/README.md +++ b/README.md @@ -34,14 +34,16 @@ sequenceDiagram D->>R: start({ sessionId, repoPath, prompt, agent, model, budgetUsd }) R->>G: rev-parse HEAD (base commit) R->>G: worktree add -b codehands/- ~/.codehands/worktrees// - R->>C: spawn in the worktree, --output-format stream-json + R->>R: save the repo profile, read the repo's notes (~/.codehands/memory/) + R->>C: spawn in the worktree, --output-format stream-json, notes as --append-system-prompt loop every stdout line C-->>R: one JSON line R-->>D: task.started, message, file.read, file.changed, command.*, test.result end C-->>R: result line, process exits R->>G: add -A, diff --cached --stat - R-->>D: task.finished with diffStat (status: awaiting_approval) + R->>R: add a note about this run + R-->>D: task.finished with diffStat, then approval.requested (status: awaiting_approval) D->>R: diff(sessionId) R-->>D: { patch, stat } alt approve @@ -62,7 +64,7 @@ When it ends: ## Status -Week 1 (Oct 19 to 23, 2026). +Week 1 (Oct 19 to 23, 2026), plus the first part of week 2. | Piece | File | State | | --- | --- | --- | @@ -76,7 +78,9 @@ Week 1 (Oct 19 to 23, 2026). | `stop`: kill the whole process tree, keep the worktree | [runner/src/process/kill-tree.ts](runner/src/process/kill-tree.ts) | Done. `task.stopped` comes out of the `start()` stream; `stop()` resolves once the session is `stopped` | | `resume`: same worktree, `seq` continues | [runner/src/runner.ts](runner/src/runner.ts) | Done. Runs `claude -p --resume ` through the same `runAgent()` as `start()`, so it ends in `awaiting_approval` or `task.failed` | | Codex: `codex exec --json` to events | [runner/src/agents/codex.ts](runner/src/agents/codex.ts) | To do. `start()` with `agent: "codex"` fails cleanly for now | -| Repo notes read before a run, summary appended after | [runner/src/memory/notes.ts](runner/src/memory/notes.ts) | To do. `repoKey()` is done | +| `approval.requested` after `task.finished` | [runner/src/runner.ts](runner/src/runner.ts) | Done (contracts v0.2.0). Real and fake runners, never after a stop or a failure | +| Repo memory: notes read before a run, a note added after | [runner/src/memory/](runner/src/memory/) | Done. `MemoryStore` with an in-memory stub and a file store. Stopped and failed runs add nothing, and a memory error never fails a run | +| Repo profile: language, package manager, test command, Node version | [runner/src/memory/profile.ts](runner/src/memory/profile.ts) | Done. Read from files in the worktree, no model call. The sandbox reads it next | Next, from the [design](https://github.com/devweekends/codehands-docs#who-owns-what): a model per phase, naming provider errors as one `provider.status` event (`rate_limited`, `auth_expired`, `usage_limit`, `subscription_inactive`, `down`), memory and user preferences, and two models on one task. @@ -109,11 +113,15 @@ const { branch, commit } = await runner.approve(sessionId, "Fix the date bug"); | Export | What | | --- | --- | -| `createRunner(options?)` | The real runner. `worktreeRoot` overrides `~/.codehands/worktrees`. `agentCommand` swaps the program it runs, which is how the tests replay a recording instead of calling Claude | -| `createFakeRunner({ delayMs? })` | Emits 12 scripted events, 300 ms apart, and supports every method including stop and resume. Costs nothing, needs no repo. Build against it, and keep it as the demo backup | +| `createRunner(options?)` | The real runner. `worktreeRoot` overrides `~/.codehands/worktrees`. `memory` swaps where repo memory lives (default: files under `~/.codehands/memory`). `agentCommand` swaps the program it runs, which is how the tests replay a recording instead of calling Claude | +| `createFakeRunner({ delayMs? })` | Emits 12 scripted events, 300 ms apart, then `approval.requested`, and supports every method including stop and resume. Costs nothing, needs no repo. Build against it, and keep it as the demo backup | | `EventSequence` | Hands out events with the next `seq` | | `SessionStore`, `SessionRecord` | In-memory session records with the legal-move check from the contracts | | `branchName(prompt, sessionId)`, `slugify(text)` | `codehands/fix-the-failing-test-3f2a9c1e` | +| `MemoryStore`, `createMemoryStub()`, `createFileMemory(root?)` | Repo memory: `notes`, `addNote`, `profile`, `saveProfile`. The stub keeps it in a `Map` for tests | +| `formatNote`, `runNote`, `trimNotes`, `notesForPrompt` | How a note is written, built from a run, trimmed to about 200 lines, and capped at 6,000 characters for the prompt | +| `detectRepoProfile(dir)`, `RepoProfile` | What a repo is, read from its files. Never throws: an unknown repo gives `language: "unknown"` | +| `repoKey(repoPath)` | The repo's id, which names its worktree and memory folders | The runner keeps session records in memory only. The daemon owns the real database. A runner that restarts forgets its sessions; stop, resume and crash recovery are where that changes. @@ -147,11 +155,14 @@ Some details that matter: | Path | What | | --- | --- | | `~/.codehands/worktrees//` | The task's worktree. Outside your repo so worktrees never nest, and short because of Windows path limits | -| `~/.codehands/memory//notes.md` | Per-repo notes (planned) | +| `~/.codehands/memory//notes.md` | Per-repo notes, newest last, trimmed to about 200 lines by dropping the oldest whole notes | +| `~/.codehands/memory//profile.json` | The repo profile from the latest run | | `codehands/-` | The task's branch, in your repo | `repoKey` is the first 12 characters of the SHA-1 of the repo's `origin` URL, or of its absolute path when there is no origin. Two clones of the same repo share a key. +All of it stays on this machine. Memory reaches Claude only as `--append-system-prompt` on the same laptop. Nothing is synced or uploaded yet. + ## Safety rules The agent runs on your machine with no sandbox until the Docker box lands in week 2. Until then: @@ -211,8 +222,10 @@ The scripts in `runner/scripts/`: | File | Covers | | --- | --- | | [claude-parser.test.ts](runner/test/claude-parser.test.ts) | The whole recorded run replays in order; each mapping above; blocked commands; failed tests hidden behind exit code 0; error results; broken lines; `raw` on every event | -| [runner.test.ts](runner/test/runner.test.ts) | A worktree on a new branch with your repo untouched; clean failures for a repo with no commits, an agent with no result line, a missing binary and an unsupported agent; diff with new files; approve commits on the branch and leaves `main` alone; reject deletes the branch; nothing to approve; stop kills the agent and its children, keeps the worktree and refuses a session that is not running; resume continues the numbering, passes the prompt, can be stopped again and refuses a session that is not stopped | -| [fake-runner.test.ts](runner/test/fake-runner.test.ts) | Event order and `seq`; approve only after finishing; stop then resume with `seq` continuing | +| [runner.test.ts](runner/test/runner.test.ts) | A worktree on a new branch with your repo untouched; clean failures for a repo with no commits, an agent with no result line, a missing binary and an unsupported agent; diff with new files; approve commits on the branch and leaves `main` alone; reject deletes the branch; nothing to approve; stop kills the agent and its children, keeps the worktree and refuses a session that is not running; resume continues the numbering, passes the prompt, can be stopped again and refuses a session that is not stopped; `approval.requested` right after `task.finished`; memory: the second run gets the first run's note, the profile is saved, and failed or stopped runs add no note | +| [fake-runner.test.ts](runner/test/fake-runner.test.ts) | Event order and `seq`; approve only after finishing; stop then resume with `seq` continuing; no `approval.requested` after a stop | +| [memory.test.ts](runner/test/memory.test.ts) | The stub keeps notes in order and repos apart; trimming drops whole notes and keeps the newest; the prompt cap keeps the newest end; a note's summary stays on one line; the file store reads back from disk and treats missing files as empty | +| [profile.test.ts](runner/test/profile.test.ts) | npm, pnpm, npm's placeholder test script, `.nvmrc`, `engines.node`, needs install, broken `package.json`, Python, empty folder | | [worktree.test.ts](runner/test/worktree.test.ts) | Branch names | To record a new fixture, run `scripts/spike.sh` (or a Codex equivalent), read every line of the output, then write tests that replay it. Never call a real agent from a test. diff --git a/runner/src/index.ts b/runner/src/index.ts index 29db983..cbd66c3 100644 --- a/runner/src/index.ts +++ b/runner/src/index.ts @@ -3,3 +3,6 @@ export { createFakeRunner, type FakeRunnerOptions } from "./fake-runner.js"; export { EventSequence } from "./events.js"; export { SessionStore, type SessionRecord } from "./session-store.js"; export { branchName, slugify } from "./git/worktree.js"; +export { createMemoryStub, formatNote, notesForPrompt, runNote, trimNotes, type MemoryNote, type MemoryStore } from "./memory/store.js"; +export { createFileMemory, repoKey } from "./memory/notes.js"; +export { detectRepoProfile, type RepoProfile } from "./memory/profile.js"; diff --git a/runner/src/memory/notes.ts b/runner/src/memory/notes.ts index dd646c8..0713c39 100644 --- a/runner/src/memory/notes.ts +++ b/runner/src/memory/notes.ts @@ -1,23 +1,48 @@ -// Week 1 guide, section 8. Friday. -// ~/.codehands/memory//notes.md, repoKey = first 12 chars of sha1(origin url or absolute path). -// Never write CLAUDE.md or AGENTS.md into the worktree, they would show up in the diff. - import { createHash } from "node:crypto"; -import { resolve } from "node:path"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import { join, resolve } from "node:path"; import { git } from "../git/worktree.js"; +import type { RepoProfile } from "./profile.js"; +import { formatNote, trimNotes, type MemoryStore } from "./store.js"; -// Also names the repo's worktree folder, so start() needs it before Friday. +// One id per repo: names its worktree folder and its memory folder. export async function repoKey(repoPath: string): Promise { const origin = await git(repoPath, ["remote", "get-url", "origin"]).catch(() => resolve(repoPath)); return createHash("sha1").update(origin).digest("hex").slice(0, 12); } -// TODO(Fri): read notes.md, empty string if missing. -export async function readNotes(_repoKey: string): Promise { - throw new Error("not implemented: readNotes (Fri)"); +// Memory on disk: ~/.codehands/memory//notes.md and profile.json. +// Outside the worktree on purpose: CLAUDE.md or AGENTS.md there would show up in the diff. +export function createFileMemory(root = join(homedir(), ".codehands", "memory")): MemoryStore { + const file = (key: string, name: string) => join(root, key, name); + + return { + async notes(key) { + return (await readIfExists(file(key, "notes.md"))) ?? ""; + }, + async addNote(key, note) { + const old = (await readIfExists(file(key, "notes.md"))) ?? ""; + await mkdir(join(root, key), { recursive: true }); + await writeFile(file(key, "notes.md"), trimNotes(old + formatNote(note))); + }, + async profile(key) { + const text = await readIfExists(file(key, "profile.json")); + return text ? (JSON.parse(text) as RepoProfile) : undefined; + }, + async saveProfile(key, profile) { + await mkdir(join(root, key), { recursive: true }); + await writeFile(file(key, "profile.json"), JSON.stringify(profile, null, 2)); + }, + }; } -// TODO(Fri): append a dated summary, keep the file under about 200 lines, drop oldest first. -export async function appendNotes(_repoKey: string, _entry: string): Promise { - throw new Error("not implemented: appendNotes (Fri)"); +// undefined when the file isn't there. Any other error is real, so it throws. +async function readIfExists(path: string): Promise { + try { + return await readFile(path, "utf8"); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "ENOENT") return undefined; + throw err; + } } diff --git a/runner/src/memory/profile.ts b/runner/src/memory/profile.ts new file mode 100644 index 0000000..515ade9 --- /dev/null +++ b/runner/src/memory/profile.ts @@ -0,0 +1,40 @@ +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; + +// What the runner knows about a repo without asking a model. Read from files only. +export interface RepoProfile { + language: "node" | "python" | "unknown"; + packageManager?: "npm" | "pnpm" | "yarn"; + testCommand?: string; // "npm test", "pnpm test" … undefined when the repo has none + nodeVersion: string; // major only, "22". Picks the sandbox image node:-slim + needsInstall: boolean; // has dependencies and no node_modules +} + +const NPM_DEFAULT_TEST = /no test specified/; + +// Never throws: a broken or unknown repo gives "unknown", not an error. +export async function detectRepoProfile(dir: string): Promise { + const has = (name: string) => existsSync(join(dir, name)); + const pkg = readJson(join(dir, "package.json")); + if (!pkg) return { language: has("pyproject.toml") ? "python" : "unknown", nodeVersion: "22", needsInstall: false }; + + const packageManager = has("pnpm-lock.yaml") ? "pnpm" : has("yarn.lock") ? "yarn" : "npm"; + const test = pkg.scripts?.test; + const testCommand = typeof test === "string" && !NPM_DEFAULT_TEST.test(test) ? `${packageManager} test` : undefined; + const nodeVersion = majorVersion(readText(join(dir, ".nvmrc"))) ?? majorVersion(pkg.engines?.node) ?? "22"; + const hasDeps = Object.keys({ ...pkg.dependencies, ...pkg.devDependencies }).length > 0; + return { language: "node", packageManager, testCommand, nodeVersion, needsInstall: hasDeps && !has("node_modules") }; +} + +function readJson(path: string): any { + try { return JSON.parse(readFileSync(path, "utf8")); } catch { return undefined; } +} + +function readText(path: string): string | undefined { + try { return readFileSync(path, "utf8"); } catch { return undefined; } +} + +// "v22.18.0", ">=22", "22" -> "22". Anything without a number -> undefined. +export function majorVersion(text: string | undefined): string | undefined { + return text?.match(/\d+/)?.[0]; +} diff --git a/runner/src/memory/store.ts b/runner/src/memory/store.ts new file mode 100644 index 0000000..d7d03a2 --- /dev/null +++ b/runner/src/memory/store.ts @@ -0,0 +1,53 @@ +import type { RepoProfile } from "./profile.js"; + +// What the runner remembers about a repo between runs, outside the worktree. +// The runner and the engine only see this interface: tests give it a Map, real runs give it files. +export interface MemoryNote { id: string; at: string; sessionId: string; text: string } // id + at: ready to sync later + +export interface MemoryStore { + notes(repoKey: string): Promise; // "" when there are none + addNote(repoKey: string, note: MemoryNote): Promise; + profile(repoKey: string): Promise; // undefined until a run has saved one + saveProfile(repoKey: string, profile: RepoProfile): Promise; +} + +// One note as text. The stub and the file store both use it, so they read the same. +export function formatNote(n: MemoryNote): string { + return `## ${n.at} · ${n.sessionId.slice(0, 8)}\n${n.text.trim()}\n\n`; +} + +export function createMemoryStub(): MemoryStore { + const notes = new Map(); + const profiles = new Map(); + return { + async notes(key) { return (notes.get(key) ?? []).map(formatNote).join(""); }, + async addNote(key, note) { notes.set(key, [...(notes.get(key) ?? []), note]); }, + async profile(key) { return profiles.get(key); }, + async saveProfile(key, profile) { profiles.set(key, profile); }, + }; +} + +// Built from the run's own result. No model call. +export function runNote(prompt: string, payload: Record, diffStat = ""): string { + const cost = typeof payload.total_cost_usd === "number" ? `$${payload.total_cost_usd.toFixed(2)}` : "unknown"; + const changed = diffStat.trim().split("\n").at(-1)?.trim() || "no changes"; + const oneLine = (s: string) => s.replace(/\s+/g, " ").trim(); + const summary = oneLine(String(payload.result ?? "")).slice(0, 500); + return `Task: ${oneLine(prompt)}\nOutcome: ${changed}, cost ${cost}\nSummary: ${summary}`; +} + +// Drops whole notes from the top until it fits. Always keeps the newest one. +export function trimNotes(text: string, maxLines = 200): string { + const blocks = text.split(/^(?=## )/m).filter((b) => b.trim()); + while (blocks.length > 1 && blocks.join("").split("\n").length > maxLines) blocks.shift(); + return blocks.join(""); +} + +// Notes go to Claude as one CLI argument: Windows caps a command line near 32,000 characters, and every character costs tokens. +const MAX_PROMPT_NOTES = 6000; + +// Newest notes win when it's too long. undefined, so no flag is passed at all, when there are none. +export function notesForPrompt(notes: string): string | undefined { + if (!notes.trim()) return undefined; + return "Notes from earlier Code Hands runs on this repo. They may be out of date:\n\n" + notes.slice(-MAX_PROMPT_NOTES); +} diff --git a/runner/src/runner.ts b/runner/src/runner.ts index 83fa502..39677b3 100644 --- a/runner/src/runner.ts +++ b/runner/src/runner.ts @@ -1,3 +1,4 @@ +import { randomUUID } from "node:crypto"; import { createInterface } from "node:readline"; import type { Runner, RunnerEvent, StartOptions } from "@codehands/contracts"; import { execa } from "execa"; @@ -5,7 +6,9 @@ import { claudeArgs, claudeResumeArgs, newClaudeParseState, parseClaudeLine } fr import { EventSequence } from "./events.js"; import { commitAll, diffSince } from "./git/diff.js"; import { addWorktree, baseCommit, branchName, pruneWorktrees, removeWorktree, worktreePath } from "./git/worktree.js"; -import { repoKey } from "./memory/notes.js"; +import { createFileMemory, repoKey } from "./memory/notes.js"; +import { detectRepoProfile } from "./memory/profile.js"; +import { notesForPrompt, runNote, type MemoryStore } from "./memory/store.js"; import { stopProcessTree } from "./process/kill-tree.js"; import { SessionStore, type SessionRecord } from "./session-store.js"; @@ -13,18 +16,21 @@ import { SessionStore, type SessionRecord } from "./session-store.js"; export interface RunnerOptions { worktreeRoot?: string; // default ~/.codehands/worktrees + memory?: MemoryStore; // default: files under ~/.codehands/memory. Tests pass createMemoryStub() // Which program to run for a task. Tests swap in a script that replays a recording. - agentCommand?: (o: StartOptions) => { file: string; args: string[] }; + // notes: the repo's memory for the prompt, undefined when there is none. + agentCommand?: (o: StartOptions, notes?: string) => { file: string; args: string[] }; resumeCommand?: (o: StartOptions, prompt: string) => { file: string; args: string[] }; } -const claudeCommand = (o: StartOptions) => ({ file: "claude", args: claudeArgs(o) }); +const claudeCommand = (o: StartOptions, notes?: string) => ({ file: "claude", args: claudeArgs(o, notes) }); // start() passes our sessionId as Claude's --session-id, so the same id resumes it. const claudeResumeCommand = (o: StartOptions, prompt: string) => ({ file: "claude", args: claudeResumeArgs(o.sessionId, prompt, o.budgetUsd) }); export function createRunner({ worktreeRoot, + memory = createFileMemory(), agentCommand = claudeCommand, resumeCommand = claudeResumeCommand, }: RunnerOptions = {}): Runner { @@ -59,15 +65,18 @@ export function createRunner({ try { record.baseCommit = await baseCommit(o.repoPath); record.branch = branchName(o.prompt, o.sessionId); - record.worktreePath = worktreePath(await repoKey(o.repoPath), o.sessionId, worktreeRoot); + record.repoKey = await repoKey(o.repoPath); + record.worktreePath = worktreePath(record.repoKey, o.sessionId, worktreeRoot); await pruneWorktrees(o.repoPath); await addWorktree(o.repoPath, record.branch, record.worktreePath, record.baseCommit); } catch (err) { yield fail(`could not make the worktree: ${(err as Error).message}`); return; } + // Memory helps a run but must never fail one. + const notes = await loadMemory(record.repoKey!, record.worktreePath!).catch(() => undefined); store.transition(o.sessionId, "running"); - yield* runAgent(record, seq, agentCommand(o)); + yield* runAgent(record, seq, agentCommand(o, notes)); } // Steps 2 to 5 of a run. start() and resume() share them, so a resumed run ends the same way. @@ -132,6 +141,14 @@ export function createRunner({ } catch (err) { finished.payload.diffError = (err as Error).message; } + + if (record.repoKey) { + await memory.addNote(record.repoKey, { + id: randomUUID(), at: new Date().toISOString(), sessionId, + text: runNote(record.options.prompt, finished.payload, record.diff?.stat), + }).catch(() => {}); // memory never fails a run + } + store.transition(sessionId, "awaiting_approval"); yield emit(finished); yield emit(seq.next("approval.requested", { diffStat: record.diff?.stat ?? "" })); @@ -146,6 +163,12 @@ export function createRunner({ return { record, worktreePath: record.worktreePath, baseCommit: record.baseCommit, branch: record.branch }; } + // Saves what the worktree looks like and returns the notes for the prompt. + async function loadMemory(key: string, worktree: string): Promise { + await memory.saveProfile(key, await detectRepoProfile(worktree)); + return notesForPrompt(await memory.notes(key)); + } + return { start, // Kills the agent and everything it started. task.stopped comes out of the start() stream, diff --git a/runner/src/session-store.ts b/runner/src/session-store.ts index 9df57f3..bbabf96 100644 --- a/runner/src/session-store.ts +++ b/runner/src/session-store.ts @@ -6,6 +6,7 @@ export interface SessionRecord { options: StartOptions; status: SessionStatus; worktreePath?: string; + repoKey?: string; // set by start(). Where this repo's memory lives branch?: string; baseCommit?: string; pid?: number; diff --git a/runner/test/memory.test.ts b/runner/test/memory.test.ts new file mode 100644 index 0000000..7026c29 --- /dev/null +++ b/runner/test/memory.test.ts @@ -0,0 +1,108 @@ +import { randomUUID } from "node:crypto"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterAll, describe, expect, it } from "vitest"; +import { createFileMemory } from "../src/memory/notes.js"; +import { createMemoryStub, formatNote, notesForPrompt, runNote, trimNotes, type MemoryNote } from "../src/memory/store.js"; + +const note = (text: string, at = new Date().toISOString()): MemoryNote => ({ id: randomUUID(), at, sessionId: randomUUID(), text }); +const blocks = (text: string) => text.split(/^(?=## )/m).filter((b) => b.trim()); + +describe("memory stub", () => { + it("returns notes in the order they were added", async () => { + const memory = createMemoryStub(); + await memory.addNote("repo1", note("first run")); + await memory.addNote("repo1", note("second run")); + const text = await memory.notes("repo1"); + expect(text.indexOf("first run")).toBeGreaterThan(-1); + expect(text.indexOf("first run")).toBeLessThan(text.indexOf("second run")); + }); + + it("keeps each repo's memory apart, and an unknown repo has none", async () => { + const memory = createMemoryStub(); + await memory.addNote("repo1", note("only here")); + await memory.saveProfile("repo1", { language: "node", nodeVersion: "22", needsInstall: false }); + expect(await memory.notes("repo2")).toBe(""); + expect(await memory.profile("repo2")).toBeUndefined(); + expect(await memory.profile("repo1")).toMatchObject({ language: "node" }); + }); +}); + +describe("trimNotes", () => { + it("drops whole notes from the top and keeps the newest", () => { + const text = Array.from({ length: 30 }, (_, i) => formatNote(note(`note ${i}\n${"line\n".repeat(7)}`))).join(""); + const trimmed = trimNotes(text, 200); + expect(trimmed.split("\n").length).toBeLessThanOrEqual(200); + expect(trimmed).not.toContain("note 0\n"); + expect(trimmed).toContain("note 29\n"); + expect(blocks(trimmed).every((b) => b.startsWith("## "))).toBe(true); + }); + + it("never empties the file, even when one note is too long", () => { + const huge = formatNote(note("line\n".repeat(500))); + expect(trimNotes(huge, 200)).toBe(huge); + }); +}); + +describe("notesForPrompt", () => { + it("gives undefined when there is nothing to say, so no flag is passed", () => { + expect(notesForPrompt("")).toBeUndefined(); + expect(notesForPrompt(" \n\n ")).toBeUndefined(); + }); + + it("caps long notes and keeps the newest end", () => { + const long = "x".repeat(9_000) + "NEWEST"; + const out = notesForPrompt(long)!; + expect(out.startsWith("Notes from earlier Code Hands runs")).toBe(true); + expect(out.endsWith("NEWEST")).toBe(true); + expect(out.length).toBeLessThan(6_200); + }); +}); + +describe("runNote", () => { + it("has the task, the diff summary line and the cost", () => { + const text = runNote("Fix the failing test", { total_cost_usd: 0.123, result: "Fixed it." }, " src/date.js | 2 +-\n 1 file changed, 1 insertion(+), 1 deletion(-)\n"); + expect(text).toContain("Task: Fix the failing test"); + expect(text).toContain("Outcome: 1 file changed, 1 insertion(+), 1 deletion(-), cost $0.12"); + expect(text).toContain("Summary: Fixed it."); + }); + + it("cuts the summary to 500 characters", () => { + const text = runNote("t", { result: "y".repeat(900) }); + expect(text.split("Summary: ")[1]!.length).toBe(500); + }); + + it("keeps a markdown summary on one line, so it can't look like a new note", () => { + const text = runNote("t", { result: "## Summary\nFixed the bug.\n\n## Files\nsrc/date.js" }); + expect(blocks(formatNote(note(text)))).toHaveLength(1); + }); + + it("says so when the cost or the change is missing", () => { + expect(runNote("t", {})).toContain("no changes, cost unknown"); + }); +}); + +describe("file memory", () => { + const tmp = mkdtempSync(join(tmpdir(), "codehands-memory-")); + afterAll(() => rmSync(tmp, { recursive: true, force: true })); + + it("writes notes to disk, so a new store on the same folder reads them back", async () => { + const root = join(tmp, "a"); + await createFileMemory(root).addNote("repo1", note("remember me")); + expect(await createFileMemory(root).notes("repo1")).toContain("remember me"); + }); + + it("gives nothing, without throwing, when the files don't exist", async () => { + const memory = createFileMemory(join(tmp, "empty")); + expect(await memory.notes("repo1")).toBe(""); + expect(await memory.profile("repo1")).toBeUndefined(); + }); + + it("round-trips the profile", async () => { + const memory = createFileMemory(join(tmp, "b")); + const profile = { language: "node", packageManager: "npm", testCommand: "npm test", nodeVersion: "22", needsInstall: false } as const; + await memory.saveProfile("repo1", profile); + expect(await createFileMemory(join(tmp, "b")).profile("repo1")).toEqual(profile); + }); +}); diff --git a/runner/test/profile.test.ts b/runner/test/profile.test.ts new file mode 100644 index 0000000..9dd8e61 --- /dev/null +++ b/runner/test/profile.test.ts @@ -0,0 +1,71 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { afterAll, describe, expect, it } from "vitest"; +import { detectRepoProfile } from "../src/memory/profile.js"; + +// Each test makes a tiny fake repo in a temp folder. Only files, no git, no API cost. +const tmp = mkdtempSync(join(tmpdir(), "codehands-profile-")); +afterAll(() => rmSync(tmp, { recursive: true, force: true })); + +function repoWith(files: Record): string { + const dir = mkdtempSync(join(tmp, "repo-")); + for (const [name, text] of Object.entries(files)) { + mkdirSync(dirname(join(dir, name)), { recursive: true }); + writeFileSync(join(dir, name), text); + } + return dir; +} + +const pkg = (body: object) => JSON.stringify(body); + +describe("detectRepoProfile", () => { + it("reads a plain npm repo like the fixture", async () => { + const dir = repoWith({ "package.json": pkg({ scripts: { test: "node --test" } }) }); + expect(await detectRepoProfile(dir)).toEqual({ + language: "node", packageManager: "npm", testCommand: "npm test", nodeVersion: "22", needsInstall: false, + }); + }); + + it("picks pnpm from its lockfile", async () => { + const dir = repoWith({ "package.json": pkg({ scripts: { test: "vitest run" } }), "pnpm-lock.yaml": "" }); + expect(await detectRepoProfile(dir)).toMatchObject({ packageManager: "pnpm", testCommand: "pnpm test" }); + }); + + it("ignores npm's placeholder test script", async () => { + const dir = repoWith({ "package.json": pkg({ scripts: { test: 'echo "Error: no test specified" && exit 1' } }) }); + expect((await detectRepoProfile(dir)).testCommand).toBeUndefined(); + }); + + it("reads the Node version from .nvmrc", async () => { + const dir = repoWith({ "package.json": pkg({}), ".nvmrc": "v20.11.0\n" }); + expect((await detectRepoProfile(dir)).nodeVersion).toBe("20"); + }); + + it("falls back to engines.node", async () => { + const dir = repoWith({ "package.json": pkg({ engines: { node: ">=18" } }) }); + expect((await detectRepoProfile(dir)).nodeVersion).toBe("18"); + }); + + it("needs install when there are dependencies and no node_modules", async () => { + const dir = repoWith({ "package.json": pkg({ dependencies: { x: "1" } }) }); + expect((await detectRepoProfile(dir)).needsInstall).toBe(true); + + writeFileSync(join(mkdirSync(join(dir, "node_modules"), { recursive: true })!, ".keep"), ""); + expect((await detectRepoProfile(dir)).needsInstall).toBe(false); + }); + + it("treats a broken package.json as unknown instead of throwing", async () => { + const dir = repoWith({ "package.json": "{oops" }); + expect(await detectRepoProfile(dir)).toMatchObject({ language: "unknown", nodeVersion: "22", needsInstall: false }); + }); + + it("spots a python repo", async () => { + const dir = repoWith({ "pyproject.toml": "[project]\nname = \"x\"\n" }); + expect((await detectRepoProfile(dir)).language).toBe("python"); + }); + + it("gives unknown for an empty folder", async () => { + expect(await detectRepoProfile(repoWith({}))).toEqual({ language: "unknown", nodeVersion: "22", needsInstall: false }); + }); +}); diff --git a/runner/test/runner.test.ts b/runner/test/runner.test.ts index 95b3f3b..1d8e69b 100644 --- a/runner/test/runner.test.ts +++ b/runner/test/runner.test.ts @@ -6,7 +6,7 @@ import { join } from "node:path"; import { fileURLToPath } from "node:url"; import type { Runner, RunnerEvent, StartOptions } from "@codehands/contracts"; import { afterAll, describe, expect, it, vi } from "vitest"; -import { createRunner, type RunnerOptions } from "../src/index.js"; +import { createMemoryStub, createRunner, repoKey, type RunnerOptions } from "../src/index.js"; // start() against a throwaway git repo. The agent is a script that replays spike-1.jsonl, so no API cost. const replay = fileURLToPath(new URL("./helpers/replay-agent.mjs", import.meta.url)); @@ -38,7 +38,8 @@ async function collect(o: StartOptions, runnerOptions: RunnerOptions = {}, runne return events; } -const newRunner = (runnerOptions: RunnerOptions = {}) => createRunner({ worktreeRoot: join(tmp, "worktrees"), ...runnerOptions }); +const newRunner = (runnerOptions: RunnerOptions = {}) => + createRunner({ worktreeRoot: join(tmp, "worktrees"), memory: createMemoryStub(), ...runnerOptions }); const replayAgent = (file: string, exitCode = 0, mode = "", pidFile = ""): RunnerOptions["agentCommand"] => () => ({ file: process.execPath, args: [replay, file, String(exitCode), mode, pidFile] }); @@ -269,3 +270,44 @@ describe("runner.resume", () => { await expect(drain(runner.resume(randomUUID()))).rejects.toThrow(/unknown session/); }); }); + +describe("runner memory", () => { + it("passes the first run's note to the second run", async () => { + const memory = createMemoryStub(); + const seen: (string | undefined)[] = []; + const agentCommand: RunnerOptions["agentCommand"] = (_o, notes) => { + seen.push(notes); + return { file: process.execPath, args: [replay, spike] }; + }; + const runner = newRunner({ memory, agentCommand }); + const repo = makeRepo(); + + await collect(options(repo), {}, runner); + await collect(options(repo, { prompt: "Second task" }), {}, runner); + + expect(seen[0]).toBeUndefined(); // first run: nothing to remember yet + expect(seen[1]).toContain("Task: Fix the failing test"); + }); + + it("saves the worktree's profile", async () => { + const memory = createMemoryStub(); + const repo = makeRepo(); + await collect(options(repo), { memory, agentCommand: replayAgent(spike) }); + expect(await memory.profile(await repoKey(repo))).toMatchObject({ language: "unknown", nodeVersion: "22" }); + }); + + it("writes no note when the run fails", async () => { + const memory = createMemoryStub(); + const repo = makeRepo(); + const empty = join(tmp, "empty-memory.jsonl"); + writeFileSync(empty, ""); + await collect(options(repo), { memory, agentCommand: replayAgent(empty, 1) }); + expect(await memory.notes(await repoKey(repo))).toBe(""); + }); + + it("writes no note when the run is stopped", async () => { + const memory = createMemoryStub(); + const { repo } = await stoppedRun({ memory }); + expect(await memory.notes(await repoKey(repo))).toBe(""); + }); +});