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' --jsonprints{\"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 gating —
state: "passing"is settable only if the verification command has been observed to exit 0 and the timestamp is recorded inevidence. - 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:
- Each feature gains
user_visible_behavior,verification_command,evidence,notes. - 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 statusThe 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-002The 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-002Step 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 OKThe 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_commandrequires 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. "Refactorask.tsto extract a JSON renderer" is invisible to the user. The behavior isnoted ask … --jsonproduces valid JSON. - One enormous verification command. If it is more than ~120 chars or one-line shell, move it into
scripts/<feature-id>.mjsand 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.
