Skip to content

Pairs with: Lecture 08 — Why Feature Lists Are Harness Primitives. Time: ~75 min. Difficulty: Advanced. Prerequisites: Module 07 checkpoint.

Module 08. Feature Lists as Primitives

Why this module

A feature list that says "ask should support JSON output" is a memo. A feature list that says "running ./bin/noted ask 'alpha' --json exits 0 and produces JSON with a results array of length ≤ 3" is a primitive — the harness can mechanically check it. Lecture 08 is uncompromising on this: a feature list is not documentation, it is machine-executable contract, and a state of passing requires the verification command to actually have passed.

Module 08 promotes the JSON file you sketched in Module 07 into that primitive: every feature is a (behavior, verification, state) triple with evidence, and a script enforces pass-state gating — you cannot mark passing without proof.

Concepts

  • Feature primitive — the (behavior, verification_command, state, evidence) tuple. Each field is required; each is checkable from the file alone.
  • User-visible behavior — what an outsider observes. Not "refactor ask.ts" — that's an implementation detail. "noted ask 'q' --json prints {\"results\":[…]}" is a behavior.
  • Verification command — a single shell command the harness can execute and inspect the exit code of. No hand-waving.
  • Pass-state gatingstate: "passing" is settable only if the verification command has been observed to exit 0 and the timestamp is recorded in evidence.
  • Schema as code — the feature-list JSON is validated by a script (added in this module). A malformed list breaks the bootstrap contract; this is intentional.

→ Read Lecture 08 for the long-form treatment, including the case study where pass-state gating cut false-completion claims by ~70%.

Lab

Step 1 — Replace your feature_list.json with the primitive form

Use ../../resources/templates/feature_list.json as the starting schema. Adapt to noted-cli:

json
{
  "project": "noted-cli",
  "last_updated": "2025-MM-DD",
  "rules": {
    "wip_limit": 1,
    "vcr_target": 1.0,
    "passing_requires_evidence": true,
    "do_not_skip_verification": true
  },
  "status_legend": {
    "not_started": "Work has not begun.",
    "in_progress": "Active task. Subject to WIP=1.",
    "blocked": "Cannot continue until a documented blocker resolves.",
    "passing": "Verification command exited 0; evidence recorded."
  },
  "features": [
    {
      "id": "ask-002",
      "priority": 1,
      "area": "ask",
      "title": "noted ask supports --json output",
      "user_visible_behavior": "`noted ask <q> --json` prints valid JSON with a top-level `results` array of length <= 3 and exits 0.",
      "verification_command": "./bin/noted import sample-notes >/dev/null && ./bin/noted index >/dev/null && ./bin/noted ask alpha --json | node -e 'const j=JSON.parse(require(\"fs\").readFileSync(0,\"utf8\")); if(!Array.isArray(j.results)||j.results.length>3) process.exit(1)'",
      "scope": ["src/commands/ask.ts", "src/cli.ts", "src/commands/ask.test.ts"],
      "state": "not_started",
      "evidence": [],
      "notes": ""
    },
    {
      "id": "import-003",
      "priority": 2,
      "area": "import",
      "title": "noted import skips files larger than 1 MB",
      "user_visible_behavior": "When `noted import` encounters a `.md` file > 1 MB, it logs `skip:<path>` and excludes it from `notes.json`. Smaller files import normally.",
      "verification_command": "node scripts/test-import-skip.mjs",
      "scope": ["src/commands/import.ts", "src/commands/import.test.ts", "scripts/test-import-skip.mjs"],
      "state": "not_started",
      "evidence": [],
      "notes": ""
    },
    {
      "id": "status-002",
      "priority": 3,
      "area": "status",
      "title": "noted status reports active feature id",
      "user_visible_behavior": "`noted status` prints a line `active: <id-or-(none)>` derived from `feature_list.json`.",
      "verification_command": "./bin/noted status | grep -q '^active: '",
      "scope": ["src/commands/status.ts", "src/commands/status.test.ts"],
      "state": "not_started",
      "evidence": [],
      "notes": ""
    }
  ]
}

Two changes from Module 07:

  1. Each feature gains user_visible_behavior, verification_command, evidence, notes.
  2. Top-level rules now include passing_requires_evidence: true — the gate the next step enforces.

Step 2 — Promote the WIP enforcer to a feature-list validator

Replace scripts/wip.mjs with a more capable script. It still does WIP=1 transitions, but pass now runs the verification command and refuses unless it succeeds.

js
import { readFile, writeFile } from "node:fs/promises";
import { execSync } from "node:child_process";

const PATH = "feature_list.json";
const fl = JSON.parse(await readFile(PATH, "utf8"));
const cmd = process.argv[2];
const id = process.argv[3];

const REQUIRED = ["id","priority","area","title","user_visible_behavior","verification_command","scope","state","evidence"];
const STATES = new Set(["not_started","in_progress","blocked","passing"]);

function fail(msg) { console.error(msg); process.exit(2); }
function save() { return writeFile(PATH, JSON.stringify(fl, null, 2) + "\n"); }

// Always validate first; a malformed list is a hard error.
for (const f of fl.features) {
  for (const k of REQUIRED) if (!(k in f)) fail(`feature ${f.id ?? "?"} missing field: ${k}`);
  if (!STATES.has(f.state)) fail(`feature ${f.id} has invalid state: ${f.state}`);
  if (f.state === "passing" && fl.rules.passing_requires_evidence && f.evidence.length === 0) {
    fail(`feature ${f.id} is 'passing' with no evidence; corrupted state.`);
  }
}

const active = fl.features.filter((f) => f.state === "in_progress");
const blocked = fl.features.filter((f) => f.state === "blocked");
const passed = fl.features.filter((f) => f.state === "passing").length;
const activated = passed + active.length + blocked.length;
const vcr = activated === 0 ? 1 : passed / activated;

switch (cmd) {
  case "status": {
    console.log(`active:   ${active.map((f) => f.id).join(", ") || "(none)"}`);
    console.log(`blocked:  ${blocked.map((f) => f.id).join(", ") || "(none)"}`);
    console.log(`vcr:      ${vcr.toFixed(2)}  (${passed}/${activated})`);
    break;
  }
  case "activate": {
    if (!id) fail("activate requires an id");
    if (active.length >= fl.rules.wip_limit) fail(`WIP=${fl.rules.wip_limit}; ${active[0].id} already active.`);
    if (vcr < fl.rules.vcr_target) fail(`VCR=${vcr.toFixed(2)} below ${fl.rules.vcr_target}; resolve blocked features first.`);
    const f = fl.features.find((x) => x.id === id) ?? fail(`unknown id: ${id}`);
    if (f.state !== "not_started") fail(`${id} is in state '${f.state}'`);
    f.state = "in_progress";
    await save();
    console.log(`activated ${id}`);
    break;
  }
  case "pass": {
    if (!id) fail("pass requires an id");
    const f = fl.features.find((x) => x.id === id) ?? fail(`unknown id: ${id}`);
    if (f.state !== "in_progress") fail(`${id} is not in_progress`);
    console.log(`==> running verification: ${f.verification_command}`);
    try {
      execSync(f.verification_command, { stdio: "inherit" });
    } catch {
      fail(`verification failed; ${id} stays in_progress`);
    }
    f.state = "passing";
    f.evidence.push({ ts: new Date().toISOString(), command: f.verification_command, exit: 0 });
    await save();
    console.log(`passed ${id}; evidence recorded`);
    break;
  }
  case "block": {
    if (!id) fail("block requires an id");
    const f = fl.features.find((x) => x.id === id) ?? fail(`unknown id: ${id}`);
    f.state = "blocked";
    f.notes = process.argv.slice(4).join(" ") || f.notes;
    await save();
    console.log(`blocked ${id}`);
    break;
  }
  default:
    fail("usage: wip status | activate <id> | pass <id> | block <id> [reason]");
}

Step 3 — Try to cheat

Manually edit feature_list.json and set ask-002 to state: "passing" without evidence. Then run:

sh
pnpm wip status

The validator must refuse:

feature ask-002 is 'passing' with no evidence; corrupted state.

Revert your edit. The point is the schema is no longer aspirational — corruption is a hard error.

Step 4 — Make ask-002 actually pass

Implement the --json flag in src/commands/ask.ts (extend the existing function to detect --json in the arg list and emit a JSON payload). Add src/commands/ask.test.ts to cover both modes.

Then:

sh
pnpm wip activate ask-002
# write code, run pnpm test until green
pnpm wip pass ask-002

The script will run the verification command from the feature definition and refuse to flip the state if it exits non-zero. When it succeeds, the evidence array gets a timestamped entry.

Step 5 — Make the bootstrap probe the feature list

Append to init.sh (right before the final OK: line):

sh
if [ ! -f feature_list.json ]; then
  echo "FAIL: can-pick-next-steps (feature_list.json missing)"; exit 14
fi
node scripts/wip.mjs status >/dev/null || { echo "FAIL: feature_list invalid"; exit 15; }

Update docs/BOOTSTRAP.md's contract table to include the new exit code 15.

Step 6 — Wire noted status to read the feature list

In src/commands/status.ts, replace the placeholder progress block with the actual active feature:

ts
import { readFile } from "node:fs/promises";
import { readNotes } from "../store/io.ts";

export async function runStatus(): Promise<number> {
  const { notes } = await readNotes();
  let active = "(none)";
  try {
    const fl = JSON.parse(await readFile("feature_list.json", "utf8"));
    const a = fl.features.find((f: any) => f.state === "in_progress");
    if (a) active = a.id;
  } catch {}

  console.log(`notes:    ${notes.length}`);
  console.log(`active:   ${active}`);
  return 0;
}

This satisfies the status-002 feature in the list. Activate, implement (already done), and pass it:

sh
pnpm wip activate status-002
pnpm wip pass status-002

Step 7 — Update artifacts and commit

PROGRESS.md:

md
## Current State
- Last verified: Module 08 checkpoint (`./init.sh` exit 0, `pnpm wip status` shows VCR ≥ 0.67).
- Active feature: import-003 (still not_started; activate in Module 09 lab).

Append to docs/cold-start-log.md:

md
## 2025-MM-DD (after Module 08)

Knowledge visibility gap closed. All five cold-start questions answer
from files: PROGRESS.md (Q4) and feature_list.json (Q5). Gap = 0%.
sh
git add .
git commit -q -m "module-08: feature_list primitives + pass-state gating"

Verification

sh
node scripts/wip.mjs status >/tmp/m08.log 2>&1 && \
grep -q "active:" /tmp/m08.log && \
grep -q '"passing_requires_evidence": true' feature_list.json && \
node -e 'const fl=JSON.parse(require("fs").readFileSync("feature_list.json","utf8")); const passing=fl.features.filter(f=>f.state==="passing"); for (const f of passing) if(!f.evidence.length) process.exit(1)' && \
echo "M08 OK"

Expected:

M08 OK

The fourth line is the load-bearing check: every passing feature has at least one evidence entry. The harness no longer trusts your editor.

Common pitfalls

  • Verification commands that depend on session state. If verification_command requires sample notes to be imported first, include the import in the command — chain with &&. The verifier runs cold.
  • Putting implementation steps in user_visible_behavior. "Refactor ask.ts to extract a JSON renderer" is invisible to the user. The behavior is noted ask … --json produces valid JSON.
  • One enormous verification command. If it is more than ~120 chars or one-line shell, move it into scripts/<feature-id>.mjs and reference the script. Keep the JSON readable.
  • Dropping evidence to "clean up." Evidence is the audit trail. If a feature regresses, you want the prior evidence visible to compare against.

Next

Module 09 — Three-Layer Termination. Each feature has its own verification; the project still does not have a single command that proves it ships. Module 09 builds verify.sh.