Skip to content

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:

  1. Instructions — what the agent is supposed to do and how (you start with course-runs/M01-task.md from Module 01).
  2. Tools — the verbs the agent has access to (the shell, pnpm test, ./bin/noted).
  3. Environment — a deterministic execution context (Node 20, no global state, .noted/ is the only writable dir).
  4. State — durable artifacts the next session can read (.noted/notes.json, .noted/index.json).
  5. Feedback — verification signals (exit codes, stdout, node --test output).

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/store

src/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 test

Inspect the state:

sh
cat .noted/notes.json | head -20
cat .noted/index.json | head -10

You 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 OK

If 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 .ts extensions on imports. With "moduleResolution": "Bundler" and tsx, you can import ./io.ts directly. Older guides use no extension; that breaks here.
  • Not creating .noted/ before writing. writeJson does it for you, but if you bypass it you will hit ENOENT on the first write.
  • Re-running import and seeing duplicate notes. runImport keys by path so re-imports update in place. If you see duplicates, you have a stale .noted/notes.json from a different layout — rm -rf .noted and try again.
  • Tokenizing without a stopword filter. With no filter, the and and dominate 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.