Skip to content

Commit 31a69cf

Browse files
runner: repo memory (notes + profile), read before a run and written after
- MemoryStore with an in-memory stub (tests) and a file store under ~/.codehands/memory - detectRepoProfile(): language, package manager, test command, Node version, needsInstall. Read from the worktree, no model call - start() saves the profile and passes the notes to Claude as --append-system-prompt (capped at 6,000 chars) - A finished run adds a one-line-summary note before approval.requested. Stopped and failed runs add nothing - Memory errors never fail a run - Tests: 9 profile, 13 memory, 4 runner memory (58 total). README updated, including approval.requested
1 parent 65bc5cd commit 31a69cf

10 files changed

Lines changed: 407 additions & 28 deletions

File tree

‎README.md‎

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -34,14 +34,16 @@ sequenceDiagram
3434
D->>R: start({ sessionId, repoPath, prompt, agent, model, budgetUsd })
3535
R->>G: rev-parse HEAD (base commit)
3636
R->>G: worktree add -b codehands/<slug>-<id8> ~/.codehands/worktrees/<repoKey>/<id8>
37-
R->>C: spawn in the worktree, --output-format stream-json
37+
R->>R: save the repo profile, read the repo's notes (~/.codehands/memory/<repoKey>)
38+
R->>C: spawn in the worktree, --output-format stream-json, notes as --append-system-prompt
3839
loop every stdout line
3940
C-->>R: one JSON line
4041
R-->>D: task.started, message, file.read, file.changed, command.*, test.result
4142
end
4243
C-->>R: result line, process exits
4344
R->>G: add -A, diff --cached --stat <base>
44-
R-->>D: task.finished with diffStat (status: awaiting_approval)
45+
R->>R: add a note about this run
46+
R-->>D: task.finished with diffStat, then approval.requested (status: awaiting_approval)
4547
D->>R: diff(sessionId)
4648
R-->>D: { patch, stat }
4749
alt approve
@@ -62,7 +64,7 @@ When it ends:
6264

6365
## Status
6466

65-
Week 1 (Oct 19 to 23, 2026).
67+
Week 1 (Oct 19 to 23, 2026), plus the first part of week 2.
6668

6769
| Piece | File | State |
6870
| --- | --- | --- |
@@ -76,7 +78,9 @@ Week 1 (Oct 19 to 23, 2026).
7678
| `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` |
7779
| `resume`: same worktree, `seq` continues | [runner/src/runner.ts](runner/src/runner.ts) | Done. Runs `claude -p --resume <sessionId>` through the same `runAgent()` as `start()`, so it ends in `awaiting_approval` or `task.failed` |
7880
| 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 |
79-
| Repo notes read before a run, summary appended after | [runner/src/memory/notes.ts](runner/src/memory/notes.ts) | To do. `repoKey()` is done |
81+
| `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 |
82+
| 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 |
83+
| 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 |
8084

8185
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.
8286

@@ -109,11 +113,15 @@ const { branch, commit } = await runner.approve(sessionId, "Fix the date bug");
109113

110114
| Export | What |
111115
| --- | --- |
112-
| `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 |
113-
| `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 |
116+
| `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 |
117+
| `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 |
114118
| `EventSequence` | Hands out events with the next `seq` |
115119
| `SessionStore`, `SessionRecord` | In-memory session records with the legal-move check from the contracts |
116120
| `branchName(prompt, sessionId)`, `slugify(text)` | `codehands/fix-the-failing-test-3f2a9c1e` |
121+
| `MemoryStore`, `createMemoryStub()`, `createFileMemory(root?)` | Repo memory: `notes`, `addNote`, `profile`, `saveProfile`. The stub keeps it in a `Map` for tests |
122+
| `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 |
123+
| `detectRepoProfile(dir)`, `RepoProfile` | What a repo is, read from its files. Never throws: an unknown repo gives `language: "unknown"` |
124+
| `repoKey(repoPath)` | The repo's id, which names its worktree and memory folders |
117125

118126
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.
119127

@@ -147,11 +155,14 @@ Some details that matter:
147155
| Path | What |
148156
| --- | --- |
149157
| `~/.codehands/worktrees/<repoKey>/<first 8 of sessionId>` | The task's worktree. Outside your repo so worktrees never nest, and short because of Windows path limits |
150-
| `~/.codehands/memory/<repoKey>/notes.md` | Per-repo notes (planned) |
158+
| `~/.codehands/memory/<repoKey>/notes.md` | Per-repo notes, newest last, trimmed to about 200 lines by dropping the oldest whole notes |
159+
| `~/.codehands/memory/<repoKey>/profile.json` | The repo profile from the latest run |
151160
| `codehands/<slug>-<first 8 of sessionId>` | The task's branch, in your repo |
152161

153162
`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.
154163

164+
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.
165+
155166
## Safety rules
156167

157168
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/`:
211222
| File | Covers |
212223
| --- | --- |
213224
| [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 |
214-
| [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 |
215-
| [fake-runner.test.ts](runner/test/fake-runner.test.ts) | Event order and `seq`; approve only after finishing; stop then resume with `seq` continuing |
225+
| [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 |
226+
| [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 |
227+
| [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 |
228+
| [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 |
216229
| [worktree.test.ts](runner/test/worktree.test.ts) | Branch names |
217230

218231
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.

‎runner/src/index.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,6 @@ export { createFakeRunner, type FakeRunnerOptions } from "./fake-runner.js";
33
export { EventSequence } from "./events.js";
44
export { SessionStore, type SessionRecord } from "./session-store.js";
55
export { branchName, slugify } from "./git/worktree.js";
6+
export { createMemoryStub, formatNote, notesForPrompt, runNote, trimNotes, type MemoryNote, type MemoryStore } from "./memory/store.js";
7+
export { createFileMemory, repoKey } from "./memory/notes.js";
8+
export { detectRepoProfile, type RepoProfile } from "./memory/profile.js";

‎runner/src/memory/notes.ts‎

Lines changed: 37 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,48 @@
1-
// Week 1 guide, section 8. Friday.
2-
// ~/.codehands/memory/<repoKey>/notes.md, repoKey = first 12 chars of sha1(origin url or absolute path).
3-
// Never write CLAUDE.md or AGENTS.md into the worktree, they would show up in the diff.
4-
51
import { createHash } from "node:crypto";
6-
import { resolve } from "node:path";
2+
import { mkdir, readFile, writeFile } from "node:fs/promises";
3+
import { homedir } from "node:os";
4+
import { join, resolve } from "node:path";
75
import { git } from "../git/worktree.js";
6+
import type { RepoProfile } from "./profile.js";
7+
import { formatNote, trimNotes, type MemoryStore } from "./store.js";
88

9-
// Also names the repo's worktree folder, so start() needs it before Friday.
9+
// One id per repo: names its worktree folder and its memory folder.
1010
export async function repoKey(repoPath: string): Promise<string> {
1111
const origin = await git(repoPath, ["remote", "get-url", "origin"]).catch(() => resolve(repoPath));
1212
return createHash("sha1").update(origin).digest("hex").slice(0, 12);
1313
}
1414

15-
// TODO(Fri): read notes.md, empty string if missing.
16-
export async function readNotes(_repoKey: string): Promise<string> {
17-
throw new Error("not implemented: readNotes (Fri)");
15+
// Memory on disk: ~/.codehands/memory/<repoKey>/notes.md and profile.json.
16+
// Outside the worktree on purpose: CLAUDE.md or AGENTS.md there would show up in the diff.
17+
export function createFileMemory(root = join(homedir(), ".codehands", "memory")): MemoryStore {
18+
const file = (key: string, name: string) => join(root, key, name);
19+
20+
return {
21+
async notes(key) {
22+
return (await readIfExists(file(key, "notes.md"))) ?? "";
23+
},
24+
async addNote(key, note) {
25+
const old = (await readIfExists(file(key, "notes.md"))) ?? "";
26+
await mkdir(join(root, key), { recursive: true });
27+
await writeFile(file(key, "notes.md"), trimNotes(old + formatNote(note)));
28+
},
29+
async profile(key) {
30+
const text = await readIfExists(file(key, "profile.json"));
31+
return text ? (JSON.parse(text) as RepoProfile) : undefined;
32+
},
33+
async saveProfile(key, profile) {
34+
await mkdir(join(root, key), { recursive: true });
35+
await writeFile(file(key, "profile.json"), JSON.stringify(profile, null, 2));
36+
},
37+
};
1838
}
1939

20-
// TODO(Fri): append a dated summary, keep the file under about 200 lines, drop oldest first.
21-
export async function appendNotes(_repoKey: string, _entry: string): Promise<void> {
22-
throw new Error("not implemented: appendNotes (Fri)");
40+
// undefined when the file isn't there. Any other error is real, so it throws.
41+
async function readIfExists(path: string): Promise<string | undefined> {
42+
try {
43+
return await readFile(path, "utf8");
44+
} catch (err) {
45+
if ((err as NodeJS.ErrnoException).code === "ENOENT") return undefined;
46+
throw err;
47+
}
2348
}

‎runner/src/memory/profile.ts‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { existsSync, readFileSync } from "node:fs";
2+
import { join } from "node:path";
3+
4+
// What the runner knows about a repo without asking a model. Read from files only.
5+
export interface RepoProfile {
6+
language: "node" | "python" | "unknown";
7+
packageManager?: "npm" | "pnpm" | "yarn";
8+
testCommand?: string; // "npm test", "pnpm test" … undefined when the repo has none
9+
nodeVersion: string; // major only, "22". Picks the sandbox image node:<v>-slim
10+
needsInstall: boolean; // has dependencies and no node_modules
11+
}
12+
13+
const NPM_DEFAULT_TEST = /no test specified/;
14+
15+
// Never throws: a broken or unknown repo gives "unknown", not an error.
16+
export async function detectRepoProfile(dir: string): Promise<RepoProfile> {
17+
const has = (name: string) => existsSync(join(dir, name));
18+
const pkg = readJson(join(dir, "package.json"));
19+
if (!pkg) return { language: has("pyproject.toml") ? "python" : "unknown", nodeVersion: "22", needsInstall: false };
20+
21+
const packageManager = has("pnpm-lock.yaml") ? "pnpm" : has("yarn.lock") ? "yarn" : "npm";
22+
const test = pkg.scripts?.test;
23+
const testCommand = typeof test === "string" && !NPM_DEFAULT_TEST.test(test) ? `${packageManager} test` : undefined;
24+
const nodeVersion = majorVersion(readText(join(dir, ".nvmrc"))) ?? majorVersion(pkg.engines?.node) ?? "22";
25+
const hasDeps = Object.keys({ ...pkg.dependencies, ...pkg.devDependencies }).length > 0;
26+
return { language: "node", packageManager, testCommand, nodeVersion, needsInstall: hasDeps && !has("node_modules") };
27+
}
28+
29+
function readJson(path: string): any {
30+
try { return JSON.parse(readFileSync(path, "utf8")); } catch { return undefined; }
31+
}
32+
33+
function readText(path: string): string | undefined {
34+
try { return readFileSync(path, "utf8"); } catch { return undefined; }
35+
}
36+
37+
// "v22.18.0", ">=22", "22" -> "22". Anything without a number -> undefined.
38+
export function majorVersion(text: string | undefined): string | undefined {
39+
return text?.match(/\d+/)?.[0];
40+
}

‎runner/src/memory/store.ts‎

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import type { RepoProfile } from "./profile.js";
2+
3+
// What the runner remembers about a repo between runs, outside the worktree.
4+
// The runner and the engine only see this interface: tests give it a Map, real runs give it files.
5+
export interface MemoryNote { id: string; at: string; sessionId: string; text: string } // id + at: ready to sync later
6+
7+
export interface MemoryStore {
8+
notes(repoKey: string): Promise<string>; // "" when there are none
9+
addNote(repoKey: string, note: MemoryNote): Promise<void>;
10+
profile(repoKey: string): Promise<RepoProfile | undefined>; // undefined until a run has saved one
11+
saveProfile(repoKey: string, profile: RepoProfile): Promise<void>;
12+
}
13+
14+
// One note as text. The stub and the file store both use it, so they read the same.
15+
export function formatNote(n: MemoryNote): string {
16+
return `## ${n.at} · ${n.sessionId.slice(0, 8)}\n${n.text.trim()}\n\n`;
17+
}
18+
19+
export function createMemoryStub(): MemoryStore {
20+
const notes = new Map<string, MemoryNote[]>();
21+
const profiles = new Map<string, RepoProfile>();
22+
return {
23+
async notes(key) { return (notes.get(key) ?? []).map(formatNote).join(""); },
24+
async addNote(key, note) { notes.set(key, [...(notes.get(key) ?? []), note]); },
25+
async profile(key) { return profiles.get(key); },
26+
async saveProfile(key, profile) { profiles.set(key, profile); },
27+
};
28+
}
29+
30+
// Built from the run's own result. No model call.
31+
export function runNote(prompt: string, payload: Record<string, unknown>, diffStat = ""): string {
32+
const cost = typeof payload.total_cost_usd === "number" ? `$${payload.total_cost_usd.toFixed(2)}` : "unknown";
33+
const changed = diffStat.trim().split("\n").at(-1)?.trim() || "no changes";
34+
const oneLine = (s: string) => s.replace(/\s+/g, " ").trim();
35+
const summary = oneLine(String(payload.result ?? "")).slice(0, 500);
36+
return `Task: ${oneLine(prompt)}\nOutcome: ${changed}, cost ${cost}\nSummary: ${summary}`;
37+
}
38+
39+
// Drops whole notes from the top until it fits. Always keeps the newest one.
40+
export function trimNotes(text: string, maxLines = 200): string {
41+
const blocks = text.split(/^(?=## )/m).filter((b) => b.trim());
42+
while (blocks.length > 1 && blocks.join("").split("\n").length > maxLines) blocks.shift();
43+
return blocks.join("");
44+
}
45+
46+
// Notes go to Claude as one CLI argument: Windows caps a command line near 32,000 characters, and every character costs tokens.
47+
const MAX_PROMPT_NOTES = 6000;
48+
49+
// Newest notes win when it's too long. undefined, so no flag is passed at all, when there are none.
50+
export function notesForPrompt(notes: string): string | undefined {
51+
if (!notes.trim()) return undefined;
52+
return "Notes from earlier Code Hands runs on this repo. They may be out of date:\n\n" + notes.slice(-MAX_PROMPT_NOTES);
53+
}

0 commit comments

Comments
 (0)