Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 22 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<slug>-<id8> ~/.codehands/worktrees/<repoKey>/<id8>
R->>C: spawn in the worktree, --output-format stream-json
R->>R: save the repo profile, read the repo's notes (~/.codehands/memory/<repoKey>)
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 <base>
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
Expand All @@ -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 |
| --- | --- | --- |
Expand All @@ -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 <sessionId>` 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.

Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -147,11 +155,14 @@ Some details that matter:
| Path | What |
| --- | --- |
| `~/.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 |
| `~/.codehands/memory/<repoKey>/notes.md` | Per-repo notes (planned) |
| `~/.codehands/memory/<repoKey>/notes.md` | Per-repo notes, newest last, trimmed to about 200 lines by dropping the oldest whole notes |
| `~/.codehands/memory/<repoKey>/profile.json` | The repo profile from the latest run |
| `codehands/<slug>-<first 8 of sessionId>` | 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:
Expand Down Expand Up @@ -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.
Expand Down
3 changes: 3 additions & 0 deletions runner/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
49 changes: 37 additions & 12 deletions runner/src/memory/notes.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,48 @@
// Week 1 guide, section 8. Friday.
// ~/.codehands/memory/<repoKey>/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<string> {
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<string> {
throw new Error("not implemented: readNotes (Fri)");
// Memory on disk: ~/.codehands/memory/<repoKey>/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<void> {
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<string | undefined> {
try {
return await readFile(path, "utf8");
} catch (err) {
if ((err as NodeJS.ErrnoException).code === "ENOENT") return undefined;
throw err;
}
}
40 changes: 40 additions & 0 deletions runner/src/memory/profile.ts
Original file line number Diff line number Diff line change
@@ -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:<v>-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<RepoProfile> {
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];
}
53 changes: 53 additions & 0 deletions runner/src/memory/store.ts
Original file line number Diff line number Diff line change
@@ -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<string>; // "" when there are none
addNote(repoKey: string, note: MemoryNote): Promise<void>;
profile(repoKey: string): Promise<RepoProfile | undefined>; // undefined until a run has saved one
saveProfile(repoKey: string, profile: RepoProfile): Promise<void>;
}

// 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<string, MemoryNote[]>();
const profiles = new Map<string, RepoProfile>();
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<string, unknown>, 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);
}
Loading
Loading