Pairs with: Lecture 02 — What Harness Actually Means. Time: ~75 min. Difficulty: Basic. Prerequisites: Module 01 checkpoint.
Module 02. Anatomy of a Harness
Why this module
Module 01 showed you the gap between "prompt the model" and "spec the work." A spec, though, is only one of the five subsystems a harness needs. This module wires up an end-to-end loop that exercises all five so you can see them touch in code, even if each is minimal. We also implement the first two real noted-cli commands — import and index — because the rest of the course needs something for the harness to defend.
Concepts
Lecture 02 defines a harness as five interlocking subsystems:
- Instructions — what the agent is supposed to do and how (you start with
course-runs/M01-task.mdfrom Module 01). - Tools — the verbs the agent has access to (the shell,
pnpm test,./bin/noted). - Environment — a deterministic execution context (Node 20, no global state,
.noted/is the only writable dir). - State — durable artifacts the next session can read (
.noted/notes.json,.noted/index.json). - Feedback — verification signals (exit codes, stdout,
node --testoutput).
A harness is not five files; it is the interaction between these subsystems. A change to one (say, adding noted index) usually requires updating the others (a test in feedback, a state schema, a help string in instructions). This module makes that coupling concrete by adding two commands and watching all five subsystems move.
→ Read Lecture 02 for the long-form treatment with the kitchen analogy and tool-comparison table.
Lab
Step 1 — Lay down the storage subsystem
Create the on-disk state schema with type definitions but no behavior yet.
sh
mkdir -p src/storesrc/store/types.ts:
ts
export type Note = {
id: string; // sha1(path) truncated to 12
path: string; // absolute path, source of truth
title: string; // first H1, or filename
body: string; // raw markdown
imported_at: string; // ISO 8601
};
export type IndexEntry = {
token: string;
note_ids: string[];
};
export type NotesFile = { notes: Note[] };
export type IndexFile = { tokens: IndexEntry[] };src/store/io.ts:
ts
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { dirname } from "node:path";
import type { NotesFile, IndexFile } from "./types.ts";
const DIR = ".noted";
export async function readNotes(): Promise<NotesFile> {
try {
return JSON.parse(await readFile(`${DIR}/notes.json`, "utf8"));
} catch (e: any) {
if (e.code === "ENOENT") return { notes: [] };
throw e;
}
}
export async function writeJson(rel: string, data: unknown): Promise<void> {
const path = `${DIR}/${rel}`;
await mkdir(dirname(path), { recursive: true });
await writeFile(path, JSON.stringify(data, null, 2) + "\n");
}Step 2 — Implement noted import <dir>
src/commands/import.ts:
ts
import { readdir, readFile, stat } from "node:fs/promises";
import { createHash } from "node:crypto";
import { join, resolve } from "node:path";
import { readNotes, writeJson } from "../store/io.ts";
import type { Note } from "../store/types.ts";
function id(path: string): string {
return createHash("sha1").update(path).digest("hex").slice(0, 12);
}
function titleOf(body: string, fallback: string): string {
const m = body.match(/^#\s+(.+)$/m);
return m ? m[1].trim() : fallback;
}
async function walk(dir: string): Promise<string[]> {
const out: string[] = [];
for (const entry of await readdir(dir, { withFileTypes: true })) {
const p = join(dir, entry.name);
if (entry.isDirectory()) out.push(...(await walk(p)));
else if (entry.isFile() && p.endsWith(".md")) out.push(p);
}
return out;
}
export async function runImport(dir: string): Promise<number> {
const root = resolve(dir);
await stat(root);
const paths = await walk(root);
const existing = await readNotes();
const byPath = new Map(existing.notes.map((n) => [n.path, n]));
for (const p of paths) {
const body = await readFile(p, "utf8");
const note: Note = {
id: id(p),
path: p,
title: titleOf(body, p.split("/").pop()!),
body,
imported_at: new Date().toISOString(),
};
byPath.set(p, note);
}
const notes = Array.from(byPath.values()).sort((a, b) => a.id.localeCompare(b.id));
await writeJson("notes.json", { notes });
console.log(`imported ${paths.length} note(s); total ${notes.length}`);
return 0;
}Step 3 — Implement noted index
src/commands/index.ts:
ts
import { readNotes, writeJson } from "../store/io.ts";
import type { IndexEntry } from "../store/types.ts";
const STOP = new Set(["the", "and", "for", "with", "a", "an", "of", "to", "is", "in", "on"]);
function tokenize(s: string): string[] {
return s.toLowerCase().match(/[a-z0-9]{3,}/g)?.filter((t) => !STOP.has(t)) ?? [];
}
export async function runIndex(): Promise<number> {
const { notes } = await readNotes();
const inv = new Map<string, Set<string>>();
for (const n of notes) {
for (const tok of new Set(tokenize(`${n.title} ${n.body}`))) {
if (!inv.has(tok)) inv.set(tok, new Set());
inv.get(tok)!.add(n.id);
}
}
const tokens: IndexEntry[] = Array.from(inv.entries())
.sort(([a], [b]) => a.localeCompare(b))
.map(([token, ids]) => ({ token, note_ids: Array.from(ids).sort() }));
await writeJson("index.json", { tokens });
console.log(`indexed ${notes.length} note(s); ${tokens.length} token(s)`);
return 0;
}Step 4 — Wire the dispatcher
Replace src/cli.ts with:
ts
import { runImport } from "./commands/import.ts";
import { runIndex } from "./commands/index.ts";
const [cmd, ...rest] = process.argv.slice(2);
const HELP = `noted - a tiny notes CLI
Usage:
noted import <dir> Walk <dir>, ingest .md files into .noted/notes.json
noted index Build .noted/index.json from current notes
noted --help Print this help.
`;
try {
switch (cmd) {
case undefined:
case "--help":
case "-h":
console.log(HELP);
process.exit(0);
case "import":
if (!rest[0]) { console.error("import requires a directory"); process.exit(2); }
process.exit(await runImport(rest[0]));
case "index":
process.exit(await runIndex());
default:
console.error(`unknown command: ${cmd}`);
process.exit(2);
}
} catch (e: any) {
console.error(`error: ${e.message ?? e}`);
process.exit(1);
}Step 5 — Add the feedback subsystem (one real test)
src/commands/import.test.ts:
ts
import { test } from "node:test";
import assert from "node:assert/strict";
import { mkdtemp, writeFile, rm, mkdir } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { runImport } from "./import.ts";
import { readNotes } from "../store/io.ts";
test("import ingests .md files and writes notes.json", async () => {
const src = await mkdtemp(join(tmpdir(), "noted-src-"));
await writeFile(join(src, "a.md"), "# Alpha\nbody-a");
await writeFile(join(src, "b.md"), "# Bravo\nbody-b");
const cwd = process.cwd();
const work = await mkdtemp(join(tmpdir(), "noted-work-"));
await mkdir(work, { recursive: true });
process.chdir(work);
try {
const code = await runImport(src);
assert.equal(code, 0);
const { notes } = await readNotes();
assert.equal(notes.length, 2);
assert.deepEqual(notes.map((n) => n.title).sort(), ["Alpha", "Bravo"]);
} finally {
process.chdir(cwd);
await rm(src, { recursive: true, force: true });
await rm(work, { recursive: true, force: true });
}
});Step 6 — Run the loop end-to-end
Use the lectures directory as your source corpus — it is conveniently full of .md files:
sh
mkdir -p sample-notes
echo "# First note\nThis is alpha." > sample-notes/a.md
echo "# Second note\nThis is bravo." > sample-notes/b.md
./bin/noted import sample-notes
./bin/noted index
pnpm testInspect the state:
sh
cat .noted/notes.json | head -20
cat .noted/index.json | head -10You should see two notes and a sorted token list.
Step 7 — Commit
sh
git add .
git commit -q -m "module-02: import + index commands, first test"Verification
sh
./bin/noted import sample-notes >/dev/null && \
./bin/noted index >/dev/null && \
pnpm test 2>&1 | grep -q "tests 1" && \
echo "M02 OK"Expected:
M02 OKIf pnpm test complains about no tests found, your test glob is wrong — check package.json's "test" script matches src/**/*.test.ts.
Common pitfalls
- Forgetting
.tsextensions on imports. With"moduleResolution": "Bundler"andtsx, you can import./io.tsdirectly. Older guides use no extension; that breaks here. - Not creating
.noted/before writing.writeJsondoes it for you, but if you bypass it you will hitENOENTon the first write. - Re-running
importand seeing duplicate notes.runImportkeys bypathso re-imports update in place. If you see duplicates, you have a stale.noted/notes.jsonfrom a different layout —rm -rf .notedand try again. - Tokenizing without a stopword filter. With no filter,
theandanddominate every query. The skill's "Gotchas" in../../.claude/skills/feature-list-primitive/makes this point about feature definitions; same lesson applies to tokens.
Next
Module 03 — Repo as System of Record. The code works; nothing else in the repo explains what it is or why. Module 03 fixes that with AGENTS.md, ARCHITECTURE.md, and PRODUCT.md.
