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
4 changes: 4 additions & 0 deletions labs/start/01-first-prompt.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,10 @@ Answer: Nothing prints and the answer lands in the file, because -p writes one a
2. `-p` prints one answer and exits, so `>` can capture it.
3. If `claude` is not found, install it from the official docs, then run `make doctor` again.

## Trick

Add `--output-format json` to `claude -p` and the answer comes back as one JSON result that a script can parse.

## What you just learned

`claude -p` is Claude Code as a command: a question in, an answer out, no chat window.
4 changes: 4 additions & 0 deletions labs/start/02-run-the-gate.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,10 @@ Answer: The full test summary, with a passed line and no failed line, because a
2. `2>&1` sends errors into the same log as the output.
3. If a test fails, you changed something under `shared/`. Undo it and run the step again.

## Trick

Type `/gate` in Claude Code. The project command in `.claude/commands/gate.md` runs `make check` and reports only what failed.

## What you just learned

A change is only done when a command you can run says so. Know the green output first, then judge Claude's work against it.
4 changes: 4 additions & 0 deletions labs/start/04-local-memory.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,10 @@ Answer: No. CLAUDE.local.md is gitignored, so the note stays on your machine and
2. The file is already in `.gitignore`, so it never reaches a commit.
3. Use `>>` to append. A single `>` replaces the file.

## Trick

Type `/memory` in Claude Code to see which instruction files loaded, so you can confirm your `CLAUDE.local.md` came in.

## What you just learned

`AGENTS.md` is for the team and `CLAUDE.local.md` is for you. Private habits never belong in a shared file.
19 changes: 19 additions & 0 deletions scripts/labs/catalog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,25 @@ describe("parseLab", () => {
expect(lab.hints).toHaveLength(2);
});

it("reads an optional one-line trick and defaults to none", () => {
expect(parseLab("start", "01-first-prompt.md", good).trick).toBeUndefined();
const withTrick = good.replace(
"## What you just learned",
"## Trick\nPress Esc twice to rewind.\n\n## What you just learned",
);
expect(parseLab("start", "01-first-prompt.md", withTrick).trick).toBe(
"Press Esc twice to rewind.",
);
});

it("rejects a trick over 200 characters or on more than one line", () => {
const put = (t: string) =>
good.replace("## What you just learned", `## Trick\n${t}\n\n## What you just learned`);
expect(() => parseLab("start", "01-first-prompt.md", put("x".repeat(201)))).toThrow();
expect(() => parseLab("start", "01-first-prompt.md", put("one\ntwo"))).toThrow();
expect(parseLab("start", "01-first-prompt.md", put("x".repeat(200))).trick).toHaveLength(200);
});

it("rejects an unknown window tag", () => {
expect(() => parseLab("start", "01-x.md", good.replace("[claude]", "[browser]"))).toThrow(
/window tag/,
Expand Down
8 changes: 8 additions & 0 deletions scripts/labs/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,13 @@ export const labSchema = z.object({
proof: z.array(proofItem).min(1),
// Two or more, so the first nudges and the last gives the answer away.
hints: z.array(z.string().min(1)).min(2),
// A Claude Code habit the learner did not ask about but now uses; shown once the lab is done.
trick: z
.string()
.min(1)
.max(200)
.regex(/^[^\n]+$/, "must be one line")
.optional(),
learned: oneLine,
});
export type Lab = z.infer<typeof labSchema>;
Expand Down Expand Up @@ -161,6 +168,7 @@ export function parseLab(lane: string, file: string, text: string): Lab {
steps: listItems(parts.get("steps") ?? "").map(stepOf),
proof: proofOf(parts.get("proof") ?? ""),
hints: listItems(parts.get("hints") ?? ""),
trick: parts.get("trick"),
learned: parts.get("what you just learned"),
});
}
Expand Down
39 changes: 39 additions & 0 deletions scripts/labs/cli-fixtures.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { Lab } from "./catalog.ts";
import type { CliDeps } from "./cli.ts";
import { saveProgress } from "./progress.ts";

// Shared by the cli tests: a lab, and deps whose output and proof are fakes.
export const lab = (id: string, lane: Lab["lane"]): Lab => ({
id,
lane,
title: `Title ${id}`,
pairs: "docs/README.md",
goal: `Goal ${id}`,
help: "guided",
steps: [{ where: "shell", text: "Do it." }],
proof: [{ text: `Item for ${id}.`, command: `proof-${id}` }],
hints: ["first hint", "second hint"],
learned: `Learned ${id}`,
predict: { question: `Question ${id}?`, answer: `Because reason ${id}.` },
});

export function setup(passes: boolean, withPath = true, guess?: string) {
const out: string[] = [];
const dir = mkdtempSync(join(tmpdir(), "labs-cli-"));
const deps: CliDeps = {
labs: [lab("start-01", "start"), lab("start-02", "start")],
progressPath: join(dir, "progress.json"),
color: false,
proof: () => ({ ok: passes, output: "boom line\n" }),
guess,
now: () => "2026-10-30",
out: (s) => out.push(s),
status: (s) => out.push(s),
isTTY: false,
};
if (withPath) saveProgress(deps.progressPath, { done: {}, hints: {}, attempts: {}, path: "pro" });
return { deps, out, text: () => out.join("\n") };
}
44 changes: 44 additions & 0 deletions scripts/labs/cli-live.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { describe, expect, it } from "vitest";
import type { Lab } from "./catalog.ts";
import { runLab } from "./cli.ts";
import { lab, setup } from "./cli-fixtures.ts";

describe("LIVE=1", () => {
const withTrick = (): Lab => ({
...lab("start-01", "start"),
trick: "Use /memory.",
steps: [{ where: "shell", text: "Do it.", expect: "EXPECT-HIDDEN" }],
});

it("walks the lab through the injected reader, one Enter per step", async () => {
const s = setup(true);
s.deps.labs = [withTrick()];
let presses = 0;
s.deps.env = { LIVE: "1" };
s.deps.enter = async () => {
presses += 1;
};
expect(await runLab(["lab"], s.deps)).toBe(0);
expect(presses).toBe(1);
expect(s.text()).toContain("Trick to keep: Use /memory.");
});

it("without LIVE the normal flow runs and never waits", async () => {
const s = setup(true);
s.deps.labs = [withTrick()];
s.deps.enter = async () => {
throw new Error("must not wait");
};
expect(await runLab(["lab"], s.deps)).toBe(0);
expect(s.text()).toContain("Trick to keep: Use /memory.");
expect(s.text()).toContain("EXPECT-HIDDEN");
});

it("LIVE with no path chosen still asks for the path", async () => {
const s = setup(true, false);
s.deps.env = { LIVE: "1" };
s.deps.enter = async () => {};
expect(await runLab(["lab"], s.deps)).toBe(2);
expect(s.text()).toContain("PATH_ID=pro");
});
});
41 changes: 3 additions & 38 deletions scripts/labs/cli.test.ts
Original file line number Diff line number Diff line change
@@ -1,42 +1,7 @@
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import type { Lab } from "./catalog.ts";
import { type CliDeps, run, runWatch } from "./cli.ts";
import { loadProgress, saveProgress } from "./progress.ts";

const lab = (id: string, lane: Lab["lane"]): Lab => ({
id,
lane,
title: `Title ${id}`,
pairs: "docs/README.md",
goal: `Goal ${id}`,
help: "guided",
steps: [{ where: "shell", text: "Do it." }],
proof: [{ text: `Item for ${id}.`, command: `proof-${id}` }],
hints: ["first hint", "second hint"],
learned: `Learned ${id}`,
predict: { question: `Question ${id}?`, answer: `Because reason ${id}.` },
});

function setup(passes: boolean, withPath = true, guess?: string) {
const out: string[] = [];
const dir = mkdtempSync(join(tmpdir(), "labs-cli-"));
const deps: CliDeps = {
labs: [lab("start-01", "start"), lab("start-02", "start")],
progressPath: join(dir, "progress.json"),
color: false,
proof: () => ({ ok: passes, output: "boom line\n" }),
guess,
now: () => "2026-10-30",
out: (s) => out.push(s),
status: (s) => out.push(s),
isTTY: false,
};
if (withPath) saveProgress(deps.progressPath, { done: {}, hints: {}, attempts: {}, path: "pro" });
return { deps, out, text: () => out.join("\n") };
}
import { run, runWatch } from "./cli.ts";
import { lab, setup } from "./cli-fixtures.ts";
import { loadProgress } from "./progress.ts";

describe("cli", () => {
it("with no path set, lab prints the choice, exits 2 and runs no proof", () => {
Expand Down
32 changes: 29 additions & 3 deletions scripts/labs/cli.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { type Lab, PATH_IDS, type PathId } from "./catalog.ts";
import { runLive } from "./live.ts";
import {
currentLab,
laneStats,
Expand All @@ -10,7 +11,7 @@ import {
saveGuess,
saveProgress,
} from "./progress.ts";
import { paint, renderBrief, renderMap } from "./render.ts";
import { paint, renderBrief, renderMap, renderTrick } from "./render.ts";

export type CliDeps = {
labs: Lab[];
Expand All @@ -27,6 +28,10 @@ export type CliDeps = {
watch?: (onChange: () => void) => () => void;
/** Calls onKey for each key typed; returns a stop function. */
keys?: (onKey: (key: string) => void) => () => void;
/** The process environment; `LIVE=1` turns on live mode. */
env?: Record<string, string | undefined>;
/** Resolves when the learner presses Enter. Injected so tests need no terminal. */
enter?: () => Promise<void>;
};

const tail = (text: string, n: number) => text.trimEnd().split("\n").slice(-n).join("\n");
Expand All @@ -50,13 +55,14 @@ function showMap(deps: CliDeps, progress: Progress): void {
deps.out(`\nThe other workshop's labs are hidden. Switch with: make lab PATH_ID=${other}`);
}

function finish(deps: CliDeps, progress: Progress, lab: Lab): void {
export function finish(deps: CliDeps, progress: Progress, lab: Lab): void {
const c = deps.color;
deps.out(`${paint(c, "green", `PASS ${lab.id}`)} ${lab.title}`);
const guess = progress.guesses?.[lab.id];
deps.out(guess ? `Your guess: ${guess}` : "You saved no guess for this lab.");
deps.out(`${paint(c, "bold", "Answer")} ${lab.predict.answer}`);
deps.out(`${paint(c, "bold", "What you just learned")} ${lab.learned}`);
if (lab.trick) deps.out(renderTrick(lab.trick, c));
const after = markDone(progress, lab.id, deps.now());
saveProgress(deps.progressPath, after);
const next = currentLab(deps.labs, after);
Expand All @@ -72,7 +78,10 @@ function hint(deps: CliDeps, progress: Progress, lab: Lab): void {
deps.out(renderBrief(lab, loadProgress(deps.progressPath), deps.color));
}

export function run(args: string[], deps: CliDeps): number {
type Picked = { lab: Lab; progress: Progress };

/** Everything before the proof: parse, pick the path, find the lab. A number is an early exit code. */
function prepare(args: string[], deps: CliDeps): number | Picked {
const [command = "progress", ...rest] = args;
const id = rest.find((a) => !a.startsWith("--"));
let progress = loadProgress(deps.progressPath);
Expand Down Expand Up @@ -119,6 +128,14 @@ export function run(args: string[], deps: CliDeps): number {
hint(deps, progress, lab);
return 0;
}
return { lab, progress };
}

export function run(args: string[], deps: CliDeps): number {
const picked = prepare(args, deps);
if (typeof picked === "number") return picked;
const { lab } = picked;
let { progress } = picked;

const guess = deps.guess?.trim();
if (guess) {
Expand Down Expand Up @@ -156,6 +173,15 @@ export function run(args: string[], deps: CliDeps): number {
return 1;
}

/** `make lab` with `LIVE=1` walks the lab step by step; anything else is the plain run. */
export async function runLab(args: string[], deps: CliDeps): Promise<number> {
const { enter } = deps;
if (deps.env?.LIVE !== "1" || !enter) return run(args, deps);
const picked = prepare(args, deps);
if (typeof picked === "number") return picked;
return runLive(picked.lab, picked.progress, { ...deps, enter });
}

const failLine = (deps: CliDeps, lab: Lab) =>
`${paint(deps.color, "red", `FAIL ${lab.id}`)} the proof is red. It reruns when you save. h hint, q quit.`;

Expand Down
Loading
Loading