recess-cli 1.2.0 → 1.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -25,7 +25,7 @@ pnpm --dir apps/admin-cli run client:generate
25
25
  pnpm --dir apps/admin-cli run install-persistent
26
26
  ```
27
27
 
28
- `install-persistent` copies a self-contained build (non-test `dist/` JS + the `openapi-fetch` runtime dep) to `~/.recess-cli/cli/` and points `~/.local/bin/recess` at it — the install keeps working after the checkout or worktree it was built from is deleted. Use it on any machine that operates on production. `install-local` instead symlinks `~/.local/bin/recess` straight to this checkout's `dist/index.js` so rebuilds are picked up live — use it only while actively developing the CLI, and expect the link to die with the worktree. Both targets install the bundled skill for Codex at `${CODEX_HOME:-~/.codex}/skills/recess-cli` and Claude at `${CLAUDE_CONFIG_DIR:-~/.claude}/skills/recess-cli`. The skill is a multi-file bundle: `skill/recess-cli/SKILL.md` carries the safety model, auth troubleshooting, JSON contract, and command quick reference, and routes to the deep workflow playbooks in `skill/recess-cli/reference/` (billing, MAP scores, payouts, class ops).
28
+ `install-persistent` copies a self-contained build (non-test `dist/` JS + the `openapi-fetch` runtime dep) to `~/.recess-cli/cli/` and points `~/.local/bin/recess` at it — the install keeps working after the checkout or worktree it was built from is deleted. Use it on any machine that operates on production. `install-local` instead symlinks `~/.local/bin/recess` straight to this checkout's `dist/index.js` so rebuilds are picked up live — use it only while actively developing the CLI, and expect the link to die with the worktree. Both targets install the bundled skill for Codex at `${CODEX_HOME:-~/.codex}/skills/recess-cli` and Claude at `${CLAUDE_CONFIG_DIR:-~/.claude}/skills/recess-cli`. The skill is a multi-file bundle: `skill/recess-cli/SKILL.md` carries the safety model, auth troubleshooting, JSON contract, and command quick reference, and routes to the deep workflow playbooks in `skill/recess-cli/reference/` (billing, MAP scores, payouts, class ops, onboarding, and goal authoring).
29
29
 
30
30
  ## One-time SSO setup
31
31
 
@@ -96,10 +96,13 @@ MAP uploads accept one PDF up to 15 MB. The preview includes the resolved path,
96
96
 
97
97
  ```bash
98
98
  recess --json skills get os-v2-goal-template-builder --all-references
99
+ recess --json content-library search "fractions through visual puzzles" --limit 8
99
100
  recess --json goal-templates validate-spec --file ./template.json # iterate; writes nothing
100
101
  recess --json goal-templates create --file ./template.json # preview, exit 2
101
102
  recess --json goal-templates create --file ./template.json --confirm
102
103
  recess --json goal-templates patch-spec <id-or-slug> --expected-version 7 --patches-file ./patches.json
104
+ recess --json mesa files write --student <kid-id> --draft <draft-slug> --source-dir ./workspace
105
+ recess --json goal-templates capture-snapshot <id-or-slug> --source-draft <draft-slug> --student <kid-id> --dry-run
103
106
  recess --json goal-templates apply <id-or-slug> --answers-file ./answers.json --dry-run
104
107
  recess --json goals create --student <kid-id> --title "..." --description-file ./goal.md
105
108
  recess --json mesa files list --student <kid-id> --goal <goal-id>
@@ -137,4 +140,10 @@ previews the per-student outcome. `set-metadata` and `delete` require `--expecte
137
140
  it always runs the backend's guarded preview first and requires the preview's exact loss token in
138
141
  addition to `--confirm` when protected template data would be removed.
139
142
 
143
+ MODULE_BACKED BLUEPRINT content is authored as a local workspace tree, batch-upserted to a named
144
+ Mesa draft, then attached with `capture-snapshot`. Both mutations run server previews before the
145
+ confirmation gate. Mesa upserts are compare-and-set against the previewed repo change; capture is
146
+ fenced to both that source change and the template version. Direct live-goal `modules/` and
147
+ `state/` writes are blocked because those files have database projections.
148
+
140
149
  See `recess --help` for the complete command surface. The raw escape hatch is intentionally read-only: `recess --json request get /path`.
package/dist/args.js CHANGED
@@ -1,4 +1,10 @@
1
1
  import { CliError } from "./errors.js";
2
+ /**
3
+ * Flags that may legitimately appear more than once. Reading one through
4
+ * `flagString` still yields the LAST value, which is why every consumer of a
5
+ * repeatable flag must use `flagList` instead.
6
+ */
7
+ export const REPEATABLE_FLAGS = new Set(["kid", "unassign"]);
2
8
  const BOOLEAN_FLAGS = new Set([
3
9
  "all-references",
4
10
  "allow-strand",
@@ -24,6 +30,16 @@ const BOOLEAN_FLAGS = new Set([
24
30
  export function parseArgs(args) {
25
31
  const positionals = [];
26
32
  const flags = new Map();
33
+ const repeated = new Map();
34
+ const record = (name, value) => {
35
+ if (!REPEATABLE_FLAGS.has(name))
36
+ return;
37
+ const existing = repeated.get(name);
38
+ if (existing)
39
+ existing.push(value);
40
+ else
41
+ repeated.set(name, [value]);
42
+ };
27
43
  for (let index = 0; index < args.length; index += 1) {
28
44
  const value = args[index];
29
45
  if (!value.startsWith("--")) {
@@ -32,7 +48,10 @@ export function parseArgs(args) {
32
48
  }
33
49
  const equalsAt = value.indexOf("=");
34
50
  if (equalsAt > 2) {
35
- flags.set(value.slice(2, equalsAt), value.slice(equalsAt + 1));
51
+ const name = value.slice(2, equalsAt);
52
+ const flagValue = value.slice(equalsAt + 1);
53
+ flags.set(name, flagValue);
54
+ record(name, flagValue);
36
55
  continue;
37
56
  }
38
57
  const name = value.slice(2);
@@ -43,13 +62,29 @@ export function parseArgs(args) {
43
62
  const next = args[index + 1];
44
63
  if (next && !next.startsWith("--")) {
45
64
  flags.set(name, next);
65
+ record(name, next);
46
66
  index += 1;
47
67
  }
48
68
  else {
49
69
  flags.set(name, true);
50
70
  }
51
71
  }
52
- return { positionals, flags };
72
+ return { positionals, flags, repeated };
73
+ }
74
+ /**
75
+ * Every value given for a repeatable flag, in order.
76
+ *
77
+ * Returns `[]` when the flag is absent, so a caller distinguishes "none given"
78
+ * from "given empty" by checking length rather than by a null dance.
79
+ */
80
+ export function flagList(parsed, name) {
81
+ if (!REPEATABLE_FLAGS.has(name)) {
82
+ // A wiring defect, not a user error: reading a non-repeatable flag as a
83
+ // list would silently return [] however many times it was passed.
84
+ throw new CliError("invalid_arguments", `--${name} is not declared repeatable; add it to REPEATABLE_FLAGS.`);
85
+ }
86
+ const values = parsed.repeated.get(name) ?? [];
87
+ return values.map((value) => value.trim()).filter(Boolean);
53
88
  }
54
89
  export function flagString(parsed, name, options = {}) {
55
90
  const value = parsed.flags.get(name);
package/dist/cli.js CHANGED
@@ -2,7 +2,7 @@ import { createHash } from "node:crypto";
2
2
  import fs from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { RecessAdminApi, unwrap } from "./api.js";
5
- import { flagNumber, flagString, hasFlag, parseArgs, } from "./args.js";
5
+ import { flagList, flagNumber, flagString, hasFlag, parseArgs, } from "./args.js";
6
6
  import { login, pollDeviceAuth, requestDeviceAuth } from "./auth.js";
7
7
  import { clearStoredSession, resolveConfig, } from "./config.js";
8
8
  import { CliError } from "./errors.js";
@@ -38,6 +38,9 @@ Usage:
38
38
  recess [--json] invoices refund --invoice <id> --line-item <id>
39
39
  --method refund|credit|tokens [--full | --amount-cents N]
40
40
  [--who-pays guide|recess] [--reason TEXT] [--confirm]
41
+ recess [--json] applications enroll <application-id> --quote <quote-id>
42
+ --school <institution-slug> --kid "<quoteLineId>:<firstName>[:<age>]" (repeat per line)
43
+ [--family <family-id>] [--unassign <kid-id>] (repeat) [--note TEXT] [--confirm]
41
44
  recess [--json] cohorts search <query>
42
45
  recess [--json] enrollments create --user <kid-id> --cohort <id>
43
46
  [--first-charge-at ISO_DATETIME] [--send-email] [--force] [--confirm]
@@ -110,6 +113,7 @@ Usage:
110
113
  recess [--json] village models remove <placement-id> [--world village-1] [--confirm]
111
114
  recess [--json] village render --min-x N --min-z N --max-x N --max-z N
112
115
  [--world village-1]
116
+ recess [--json] content-library search <query> [--limit 8]
113
117
  recess [--json] skills list [--query TEXT] [--category TEXT]
114
118
  recess [--json] skills get <skill-name> [--reference NAME | --all-references]
115
119
  [--refresh]
@@ -125,9 +129,12 @@ Usage:
125
129
  recess [--json] goal-templates set-metadata <template-id> --expected-version N
126
130
  [--title TEXT] [--description TEXT] [--emoji X] [--category TEXT] [--tags A,B]
127
131
  [--sort-order N] [--kind SIMPLE|BLUEPRINT] [--agent-instructions-file <path>]
128
- [--confirm]
132
+ [--output-template-file <path>] [--confirm]
129
133
  recess [--json] goal-templates delete <template-id> --expected-version N [--confirm]
130
134
  recess [--json] goal-templates snapshot-files <template-id> [--path P]
135
+ recess [--json] goal-templates capture-snapshot <template-id|slug>
136
+ (--source-goal <goal-id> | --source-draft <draft-slug> --student <kid-id>)
137
+ [--dry-run] [--confirm]
131
138
  recess [--json] goal-templates apply <template-id> --answers-file <path>
132
139
  [--dry-run] [--confirm]
133
140
  recess [--json] goal-templates apply-starter <template-id> --student <kid-id>
@@ -138,6 +145,10 @@ Usage:
138
145
  [--schedule TEXT] [--confirm]
139
146
  recess [--json] mesa files list --student <kid-id> --goal <goal-id>
140
147
  recess [--json] mesa files read --student <kid-id> --goal <goal-id> --path P
148
+ recess [--json] mesa files write --student <kid-id>
149
+ (--goal <goal-id> | --draft <draft-slug>)
150
+ (--source-dir <local-dir> | --source-file <local-file> --path P)
151
+ [--message TEXT] [--confirm]
141
152
 
142
153
  Authoring notes: "skills" serves the in-product tutor skills (the PRIVATE
143
154
  packages/skills submodule) read-only over your admin session — they are never
@@ -298,6 +309,83 @@ async function writeCommand(parsed, preview, execute) {
298
309
  requireConfirmation(hasFlag(parsed, "confirm"), preview);
299
310
  return execute();
300
311
  }
312
+ const MESA_WRITE_MAX_FILES = 1_000;
313
+ const MESA_WRITE_MAX_TOTAL_BYTES = 20 * 1024 * 1024;
314
+ async function readMesaWriteSource(parsed) {
315
+ const sourceDirectory = flagString(parsed, "source-dir");
316
+ const sourceFile = flagString(parsed, "source-file");
317
+ if (Boolean(sourceDirectory) === Boolean(sourceFile)) {
318
+ throw new CliError("invalid_arguments", "Pass exactly one of --source-dir or --source-file.");
319
+ }
320
+ const files = [];
321
+ const hash = createHash("sha256");
322
+ let sizeBytes = 0;
323
+ let source;
324
+ const addFile = async (absolutePath, workspacePath) => {
325
+ if (files.length >= MESA_WRITE_MAX_FILES) {
326
+ throw new CliError("invalid_arguments", `Mesa workspace writes are limited to ${MESA_WRITE_MAX_FILES} files.`);
327
+ }
328
+ const fileStat = await fs.stat(absolutePath);
329
+ if (sizeBytes + fileStat.size > MESA_WRITE_MAX_TOTAL_BYTES) {
330
+ throw new CliError("invalid_arguments", `Mesa workspace writes are limited to ${MESA_WRITE_MAX_TOTAL_BYTES} decoded bytes.`);
331
+ }
332
+ const bytes = await fs.readFile(absolutePath);
333
+ sizeBytes += bytes.byteLength;
334
+ files.push({
335
+ path: workspacePath.replaceAll(path.sep, "/"),
336
+ content: bytes.toString("base64"),
337
+ contentEncoding: "base64",
338
+ });
339
+ };
340
+ if (sourceDirectory) {
341
+ const root = path.resolve(sourceDirectory);
342
+ const rootStat = await fs.lstat(root).catch(() => null);
343
+ if (!rootStat?.isDirectory() || rootStat.isSymbolicLink()) {
344
+ throw new CliError("invalid_arguments", `--source-dir is not a directory: ${root}`);
345
+ }
346
+ source = { kind: "directory", absolutePath: root };
347
+ const walk = async (directory, relativeRoot = "") => {
348
+ const entries = await fs.readdir(directory, { withFileTypes: true });
349
+ entries.sort((a, b) => a.name.localeCompare(b.name));
350
+ for (const entry of entries) {
351
+ const relativePath = relativeRoot
352
+ ? path.join(relativeRoot, entry.name)
353
+ : entry.name;
354
+ const absolutePath = path.join(directory, entry.name);
355
+ if (entry.isSymbolicLink()) {
356
+ throw new CliError("invalid_arguments", `Refusing symbolic link in --source-dir: ${absolutePath}`);
357
+ }
358
+ if (entry.isDirectory()) {
359
+ await walk(absolutePath, relativePath);
360
+ }
361
+ else if (entry.isFile()) {
362
+ await addFile(absolutePath, relativePath);
363
+ }
364
+ }
365
+ };
366
+ await walk(root);
367
+ }
368
+ else {
369
+ const absolutePath = path.resolve(sourceFile);
370
+ const fileStat = await fs.lstat(absolutePath).catch(() => null);
371
+ if (!fileStat?.isFile() || fileStat.isSymbolicLink()) {
372
+ throw new CliError("invalid_arguments", `--source-file is not a file: ${absolutePath}`);
373
+ }
374
+ const workspacePath = flagString(parsed, "path", { required: true });
375
+ source = { kind: "file", absolutePath };
376
+ await addFile(absolutePath, workspacePath);
377
+ }
378
+ if (files.length === 0) {
379
+ throw new CliError("invalid_arguments", "The Mesa write source contains no files.");
380
+ }
381
+ for (const file of files) {
382
+ hash.update(file.path);
383
+ hash.update("\0");
384
+ hash.update(file.content, "base64");
385
+ hash.update("\0");
386
+ }
387
+ return { files, source, sizeBytes, sha256: hash.digest("hex") };
388
+ }
301
389
  function assertChoice(value, choices, label) {
302
390
  if (!choices.includes(value)) {
303
391
  throw new CliError("invalid_arguments", `${label} must be one of: ${choices.join(", ")}.`);
@@ -625,6 +713,34 @@ function flagStatusList(parsed, name, choices) {
625
713
  }
626
714
  return values.map((value) => assertChoice(value, choices, `--${name}`));
627
715
  }
716
+ /**
717
+ * Parse one `--kid` spec into an enroll roster entry.
718
+ *
719
+ * Shape: `<quoteLineId>:<firstName>[:<age>]` — the quote line FIRST, because
720
+ * the line id is the identity the enrollment matches on and the name is only a
721
+ * label. A staffer typing these reads them off the quote, in order.
722
+ *
723
+ * ⚠️ THE NAME MAY CONTAIN NOTHING SURPRISING, but an age must be a real number:
724
+ * age decides `birthdate` at creation AND rides in the frozen payment snapshot
725
+ * that a later takeover is compared against, so a silently-dropped or
726
+ * mistyped age is a money-adjacent error, not a cosmetic one. Absent is fine
727
+ * (the server treats it as "not stated"); unparseable is refused here.
728
+ */
729
+ function parseEnrollKidSpec(raw) {
730
+ const parts = raw.split(":").map((part) => part.trim());
731
+ const [quoteLineId, firstName, ageRaw, ...extra] = parts;
732
+ if (!quoteLineId || !firstName || extra.length > 0) {
733
+ throw new CliError("invalid_arguments", `--kid must be "<quoteLineId>:<firstName>[:<age>]"; got "${raw}".`);
734
+ }
735
+ if (ageRaw === undefined || ageRaw === "") {
736
+ return { quoteLineId, firstName };
737
+ }
738
+ const age = Number(ageRaw);
739
+ if (!Number.isInteger(age) || age < 1 || age > 25) {
740
+ throw new CliError("invalid_arguments", `--kid age must be a whole number from 1 to 25; got "${ageRaw}".`);
741
+ }
742
+ return { quoteLineId, firstName, age };
743
+ }
628
744
  function flagCents(parsed, name, options = {}) {
629
745
  const value = flagNumber(parsed, name);
630
746
  if (value === undefined) {
@@ -1077,6 +1193,48 @@ export async function runCommand(argv) {
1077
1193
  params: { query: { subscriptionId } },
1078
1194
  }));
1079
1195
  }
1196
+ if (noun === "applications" && verb === "enroll") {
1197
+ const applicationId = positional(parsed, 2, "application ID");
1198
+ const quoteId = flagString(parsed, "quote", { required: true });
1199
+ const institutionSlug = flagString(parsed, "school", { required: true });
1200
+ const familyId = flagString(parsed, "family");
1201
+ const note = flagString(parsed, "note");
1202
+ // Repeatable --kid, one per PRICED LINE on the quote. The server refuses a
1203
+ // partial roster (every priced student must be enrolled), so this is
1204
+ // deliberately not a convenience list — it is the whole quote, echoed back.
1205
+ const kidSpecs = flagList(parsed, "kid");
1206
+ if (kidSpecs.length === 0) {
1207
+ throw new CliError("invalid_arguments", 'Missing --kid. Pass one per quote line: --kid "<quoteLineId>:<firstName>[:<age>]".');
1208
+ }
1209
+ const kids = kidSpecs.map(parseEnrollKidSpec);
1210
+ // Every OTHER live child in the family must be named explicitly. The server
1211
+ // refuses the whole enrollment otherwise, listing who was unlisted — so the
1212
+ // failure is legible either way, but naming them here is how a staffer says
1213
+ // "yes, I know, they are not enrolling".
1214
+ const dispositions = flagList(parsed, "unassign").map((kidUserId) => ({
1215
+ kidUserId,
1216
+ action: "unassigned",
1217
+ }));
1218
+ return writeCommand(parsed, {
1219
+ // One short clause, like every other preview in this file. The
1220
+ // consequences are enumerated in `request` below, which is what the
1221
+ // confirmation prompt prints in full — restating them here would make
1222
+ // this the only preview a staffer has to read twice.
1223
+ action: "enroll an application from its accepted quote (creates children, charges the first month, cancels marketplace subscriptions)",
1224
+ target: { applicationId, quoteId, institutionSlug, familyId },
1225
+ request: { kids, dispositions, note },
1226
+ }, async () => unwrap(await api.client.POST("/admin/applications/{applicationId}/enroll", {
1227
+ params: { path: { applicationId } },
1228
+ body: {
1229
+ quoteId,
1230
+ institutionSlug,
1231
+ kids,
1232
+ ...(familyId ? { familyId } : {}),
1233
+ ...(dispositions.length > 0 ? { dispositions } : {}),
1234
+ ...(note ? { note } : {}),
1235
+ },
1236
+ })));
1237
+ }
1080
1238
  if (noun === "cohorts" && verb === "search") {
1081
1239
  const search = parsed.positionals.slice(2).join(" ").trim();
1082
1240
  if (!search)
@@ -1915,6 +2073,19 @@ export async function runCommand(argv) {
1915
2073
  }
1916
2074
  throw new CliError("invalid_arguments", "Use skills list|get.");
1917
2075
  }
2076
+ if (noun === "content-library") {
2077
+ if (verb === "search") {
2078
+ const query = positional(parsed, 2, "search query");
2079
+ const limit = flagNumber(parsed, "limit") ?? 8;
2080
+ if (!Number.isInteger(limit) || limit < 1 || limit > 40) {
2081
+ throw new CliError("invalid_arguments", "--limit must be an integer between 1 and 40.");
2082
+ }
2083
+ return unwrap(await api.client.GET("/admin/content-library/search", {
2084
+ params: { query: { q: query, limit } },
2085
+ }));
2086
+ }
2087
+ throw new CliError("invalid_arguments", "Use content-library search.");
2088
+ }
1918
2089
  if (noun === "goal-templates") {
1919
2090
  if (verb === "list") {
1920
2091
  const query = flagString(parsed, "query")?.toLowerCase();
@@ -2124,6 +2295,7 @@ export async function runCommand(argv) {
2124
2295
  const id = await resolveGoalTemplateId(api, positional(parsed, 2, "template ID or slug"));
2125
2296
  const expectedVersion = requiredExpectedVersion(parsed);
2126
2297
  const agentInstructionsFile = flagString(parsed, "agent-instructions-file");
2298
+ const outputTemplateFile = flagString(parsed, "output-template-file");
2127
2299
  const tags = flagString(parsed, "tags");
2128
2300
  const kind = flagString(parsed, "kind");
2129
2301
  const sortOrder = flagNumber(parsed, "sort-order");
@@ -2158,13 +2330,18 @@ export async function runCommand(argv) {
2158
2330
  agentInstructions: await fs.readFile(path.resolve(agentInstructionsFile), "utf8"),
2159
2331
  }
2160
2332
  : {}),
2333
+ ...(outputTemplateFile
2334
+ ? {
2335
+ outputTemplate: await fs.readFile(path.resolve(outputTemplateFile), "utf8"),
2336
+ }
2337
+ : {}),
2161
2338
  };
2162
2339
  // `setupWorkflowSpec` is unreachable from this command by construction.
2163
2340
  // The route still accepts one, but a wholesale spec replacement is the
2164
2341
  // shape that caused the template incident; editing an existing spec goes
2165
2342
  // through the guarded /ai patch path with its destructive-change token.
2166
2343
  if (Object.keys(body).length === 1) {
2167
- throw new CliError("invalid_arguments", "Pass at least one field to change (--title, --description, --emoji, --category, --tags, --sort-order, --kind, --agent-instructions-file).");
2344
+ throw new CliError("invalid_arguments", "Pass at least one field to change (--title, --description, --emoji, --category, --tags, --sort-order, --kind, --agent-instructions-file, --output-template-file).");
2168
2345
  }
2169
2346
  return writeCommand(parsed, {
2170
2347
  action: "update goal template metadata (never its setupWorkflowSpec)",
@@ -2215,6 +2392,64 @@ export async function runCommand(argv) {
2215
2392
  params: { path: { id } },
2216
2393
  }));
2217
2394
  }
2395
+ if (verb === "capture-snapshot") {
2396
+ const id = await resolveGoalTemplateId(api, positional(parsed, 2, "template ID or slug"));
2397
+ const sourceGoalId = flagString(parsed, "source-goal");
2398
+ const sourceDraft = flagString(parsed, "source-draft");
2399
+ if (Boolean(sourceGoalId) === Boolean(sourceDraft)) {
2400
+ throw new CliError("invalid_arguments", "Pass exactly one of --source-goal or --source-draft.");
2401
+ }
2402
+ const sourceStudentUserId = sourceDraft
2403
+ ? flagString(parsed, "student", { required: true })
2404
+ : undefined;
2405
+ const source = sourceGoalId
2406
+ ? { sourceGoalId }
2407
+ : {
2408
+ sourceWorkspacePath: `drafts/${sourceDraft}/workspace`,
2409
+ sourceStudentUserId,
2410
+ };
2411
+ const preflight = unwrap(await api.client.POST("/admin/goal-templates/{id}/snapshot/capture", {
2412
+ params: { path: { id } },
2413
+ body: { ...source, dryRun: true },
2414
+ }));
2415
+ if (preflight.action !== "preview_capture_snapshot") {
2416
+ throw new CliError("unexpected_response", "Snapshot capture did not return a preview; nothing was captured.");
2417
+ }
2418
+ if (hasFlag(parsed, "dry-run"))
2419
+ return preflight;
2420
+ const preview = {
2421
+ action: "capture a validated Mesa workspace as this global template's instant-apply snapshot",
2422
+ target: {
2423
+ templateId: preflight.template.id,
2424
+ slug: preflight.template.slug,
2425
+ title: preflight.template.title,
2426
+ version: preflight.template.version,
2427
+ },
2428
+ request: {
2429
+ source: preflight.source,
2430
+ moduleCount: preflight.snapshot.moduleCount,
2431
+ fileCount: preflight.snapshot.fileCount,
2432
+ sizeBytes: preflight.snapshot.sizeBytes,
2433
+ sha256: preflight.snapshot.sha256,
2434
+ },
2435
+ details: {
2436
+ modules: preflight.snapshot.modules,
2437
+ paths: preflight.snapshot.paths,
2438
+ warnings: preflight.warnings,
2439
+ note: "The confirmed request is fenced to both this template version and this exact Mesa source change. Capturing bumps the template version unless the snapshot is unchanged.",
2440
+ },
2441
+ };
2442
+ requireConfirmation(hasFlag(parsed, "confirm"), preview);
2443
+ return unwrap(await api.client.POST("/admin/goal-templates/{id}/snapshot/capture", {
2444
+ params: { path: { id } },
2445
+ body: {
2446
+ ...source,
2447
+ dryRun: false,
2448
+ expectedTemplateVersion: preflight.template.version,
2449
+ expectedSourceChangeId: preflight.source.changeId,
2450
+ },
2451
+ }));
2452
+ }
2218
2453
  if (verb === "apply") {
2219
2454
  const id = await resolveGoalTemplateId(api, positional(parsed, 2, "template ID or slug"));
2220
2455
  const answers = await readJsonFile(flagString(parsed, "answers-file", { required: true }), "Answers file");
@@ -2282,7 +2517,7 @@ export async function runCommand(argv) {
2282
2517
  body: { studentUserId, answers },
2283
2518
  })));
2284
2519
  }
2285
- throw new CliError("invalid_arguments", "Use goal-templates list|get|versions|validate-spec|create|patch-spec|set-metadata|delete|snapshot-files|apply|apply-starter.");
2520
+ throw new CliError("invalid_arguments", "Use goal-templates list|get|versions|validate-spec|create|patch-spec|set-metadata|delete|snapshot-files|capture-snapshot|apply|apply-starter.");
2286
2521
  }
2287
2522
  if (noun === "goals") {
2288
2523
  if (verb === "list") {
@@ -2336,11 +2571,12 @@ export async function runCommand(argv) {
2336
2571
  if (noun === "mesa" && verb === "files") {
2337
2572
  const action = positional(parsed, 2, "mesa files action");
2338
2573
  const studentId = flagString(parsed, "student", { required: true });
2339
- const goalId = flagString(parsed, "goal", { required: true });
2340
2574
  if (action === "list") {
2575
+ const goalId = flagString(parsed, "goal", { required: true });
2341
2576
  return unwrap(await api.client.GET("/tutor/students/{studentId}/goals/{goalId}/workspace/files", { params: { path: { studentId, goalId } } }));
2342
2577
  }
2343
2578
  if (action === "read") {
2579
+ const goalId = flagString(parsed, "goal", { required: true });
2344
2580
  return unwrap(await api.client.GET("/tutor/students/{studentId}/goals/{goalId}/workspace/file", {
2345
2581
  params: {
2346
2582
  path: { studentId, goalId },
@@ -2348,7 +2584,58 @@ export async function runCommand(argv) {
2348
2584
  },
2349
2585
  }));
2350
2586
  }
2351
- throw new CliError("invalid_arguments", "Use mesa files list|read.");
2587
+ if (action === "write") {
2588
+ const goalId = flagString(parsed, "goal");
2589
+ const draftSlug = flagString(parsed, "draft");
2590
+ if (Boolean(goalId) === Boolean(draftSlug)) {
2591
+ throw new CliError("invalid_arguments", "Pass exactly one of --goal or --draft for mesa files write.");
2592
+ }
2593
+ const input = await readMesaWriteSource(parsed);
2594
+ const target = goalId
2595
+ ? { kind: "goal", goalId }
2596
+ : { kind: "draft", draftSlug: draftSlug };
2597
+ const message = flagString(parsed, "message") ??
2598
+ `recess-cli: write ${input.files.length} workspace file${input.files.length === 1 ? "" : "s"}`;
2599
+ const body = {
2600
+ studentUserId: studentId,
2601
+ target,
2602
+ message,
2603
+ files: input.files,
2604
+ };
2605
+ const preflight = unwrap(await api.client.POST("/admin/mesa/workspace-files", {
2606
+ body: { ...body, dryRun: true },
2607
+ }));
2608
+ if (preflight.action !== "preview_workspace_files_write") {
2609
+ throw new CliError("unexpected_response", "Mesa workspace write did not return a preview; nothing was written.");
2610
+ }
2611
+ const preview = {
2612
+ action: "batch-upsert files in a student's Mesa workspace",
2613
+ target: preflight.target,
2614
+ request: {
2615
+ source: input.source,
2616
+ sourceSha256: input.sha256,
2617
+ fileCount: input.files.length,
2618
+ sizeBytes: input.sizeBytes,
2619
+ message,
2620
+ },
2621
+ details: {
2622
+ currentChangeId: preflight.currentChangeId,
2623
+ files: preflight.files,
2624
+ note: target.kind === "draft"
2625
+ ? "This writes a complete authoring tree under drafts/<slug>/workspace. Capture it only after the server's goal-workspace validation passes."
2626
+ : "Live goal modules/ and state/ are blocked here because they have database projections; use a draft + template capture for structural course changes.",
2627
+ },
2628
+ };
2629
+ requireConfirmation(hasFlag(parsed, "confirm"), preview);
2630
+ return unwrap(await api.client.POST("/admin/mesa/workspace-files", {
2631
+ body: {
2632
+ ...body,
2633
+ dryRun: false,
2634
+ expectedChangeId: preflight.currentChangeId,
2635
+ },
2636
+ }));
2637
+ }
2638
+ throw new CliError("invalid_arguments", "Use mesa files list|read|write.");
2352
2639
  }
2353
2640
  if (noun === "request" && verb === "get") {
2354
2641
  return api.rawGet(positional(parsed, 2, "request path"));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "recess-cli",
3
- "version": "1.2.0",
3
+ "version": "1.3.2",
4
4
  "description": "Safe Recess staff administration from the command line, for humans and coding agents.",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {
@@ -1,15 +1,15 @@
1
1
  ---
2
2
  name: recess-cli
3
- description: Safely perform Recess staff administration through the recess CLI. Use when a Recess admin asks Codex to find a kid, parent, family, enrollment, subscription, invoice, or cohort; inspect or change a school kid's tier and capability gates; upload a kid's MAP Growth report; pause or resume billing; refund or credit an invoice item; extend a trial; cancel or restore a subscription; register or unregister a cohort against an enrollment; switch or move kids from one cohort to another; manage guide payout invoices (biweekly pay-cycle line-item changes, invoice status moves, payout recipient lookups); run class operations (take attendance, cancel or reschedule a class session, add a one-off session, end a cohort, pause cohort billing, email a cohort's families, approve or deny pending registrations); process class-cancellation credits (the "Please credit these students accordingly" Slack message — credit every registered kid for a guide-canceled session); or author learning content — build, validate, publish, and loss-safely patch a deterministic GoalTemplate, apply a template to a kid or a roster, create a goal directly on a kid, and read the Mesa workspace files behind a goal or a template snapshot.
3
+ description: Safely perform Recess staff administration through the recess CLI. Use when a Recess admin asks Codex to find a kid, parent, family, enrollment, subscription, invoice, or cohort; search the curated Content Library; inspect or change a school kid's tier and capability gates; upload a kid's MAP Growth report; pause or resume billing; refund or credit an invoice item; extend a trial; cancel or restore a subscription; register or unregister a cohort against an enrollment; switch or move kids from one cohort to another; manage guide payout invoices (biweekly pay-cycle line-item changes, invoice status moves, payout recipient lookups); run class operations (take attendance, cancel or reschedule a class session, add a one-off session, end a cohort, pause cohort billing, email a cohort's families, approve or deny pending registrations); process class-cancellation credits (the "Please credit these students accordingly" Slack message — credit every registered kid for a guide-canceled session); or author learning content — build, validate, publish, and loss-safely patch a deterministic GoalTemplate, apply a template to a kid or a roster, create a goal directly on a kid, write a Mesa draft or goal workspace, capture a template snapshot, and read Mesa workspace or snapshot files.
4
4
  # Bundle version. Bump on every substantive edit; the CLI reports it and `doctor`
5
5
  # compares it against the served copy to tell an operator a refresh is available.
6
- version: 1.3.0
6
+ version: 1.4.3
7
7
  # The lowest `recess` version this bundle is safe to install onto. Raise it ONLY
8
8
  # when the bundle documents a command, flag, or changed semantic that an older
9
9
  # binary does not have — an older CLI keeps its bundled copy instead of taking
10
10
  # this one. Prose, formatting, and Gotcha edits must NOT raise it; that is the
11
11
  # whole point of serving the bundle.
12
- minCliVersion: 1.2.0
12
+ minCliVersion: 1.3.2
13
13
  ---
14
14
 
15
15
  # Recess CLI (`recess`)
@@ -27,10 +27,12 @@ Every mutating command is two-step. Run it **without** `--confirm` first: the CL
27
27
  Some previews also carry a `details` object — server-resolved facts that cannot be known offline.
28
28
  Today those include `enrollments create` (real price, no-charge reuse, and capacity/slot
29
29
  violations), `users tier set` (current/proposed capability locks and class-slot consequences), and
30
- `goal-templates patch-spec` (validated before/after hashes and protected-inventory loss); getting one
31
- costs read-only calls, never a write. **When `details` is present it is part of the preview show it
32
- to the human too.** Approving from `action`/`request` alone while ignoring `details` is how an
33
- override gets rubber-stamped.
30
+ `goal-templates patch-spec` (validated before/after hashes and protected-inventory loss),
31
+ `mesa files write` (resolved workspace root, Mesa change fence, and file hashes), and
32
+ `goal-templates capture-snapshot` (validated modules/files plus template/source fences); getting
33
+ one costs read-only calls, never a write. **When `details` is present it is part of the preview —
34
+ show it to the human too.** Approving from `action`/`request` alone while ignoring `details` is how
35
+ an override gets rubber-stamped.
34
36
 
35
37
  1. Show that preview to the human, verbatim.
36
38
  2. Request explicit escalated approval for that exact action using the execution tool's approval mechanism.
@@ -211,6 +213,9 @@ recess --json onboarding extract <family-id> --session <id> (--transcript-file <
211
213
  recess --json skills list [--query TEXT] [--category TEXT]
212
214
  recess --json skills get <skill-name> [--reference NAME | --all-references] [--refresh]
213
215
 
216
+ # Curated learning-resource research (read-only)
217
+ recess --json content-library search <query> [--limit 8]
218
+
214
219
  # Learning content (reference/goal-authoring.md)
215
220
  recess --json goal-templates list [--query TEXT] [--kind SIMPLE|BLUEPRINT] [--starter-only]
216
221
  recess --json goal-templates get <id-or-slug> [--spec-only]
@@ -218,15 +223,17 @@ recess --json goal-templates versions <id-or-slug> [--version N]
218
223
  recess --json goal-templates validate-spec --file <path/template.json>
219
224
  recess --json goal-templates create --file <path/template.json> [--confirm]
220
225
  recess --json goal-templates patch-spec <id-or-slug> --expected-version N --patches-file <path/patches.json> [--confirm] [--confirm-destructive-changes --destructive-change-token TOKEN]
221
- recess --json goal-templates set-metadata <id-or-slug> --expected-version N [--title …] [--confirm]
226
+ recess --json goal-templates set-metadata <id-or-slug> --expected-version N [--title …] [--agent-instructions-file <path>] [--output-template-file <path>] [--confirm]
222
227
  recess --json goal-templates delete <id-or-slug> --expected-version N [--confirm]
223
228
  recess --json goal-templates snapshot-files <id-or-slug> [--path P]
229
+ recess --json goal-templates capture-snapshot <id-or-slug> (--source-goal <goal-id> | --source-draft <draft-slug> --student <kid-id>) [--dry-run] [--confirm]
224
230
  recess --json goal-templates apply <id-or-slug> --answers-file <path> [--dry-run] [--confirm]
225
231
  recess --json goal-templates apply-starter <id-or-slug> --student <kid-id> [--confirm]
226
232
  recess --json goals list --student <kid-id>
227
233
  recess --json goals create --student <kid-id> --title TEXT --description TEXT [--confirm]
228
234
  recess --json mesa files list --student <kid-id> --goal <goal-id>
229
235
  recess --json mesa files read --student <kid-id> --goal <goal-id> --path P
236
+ recess --json mesa files write --student <kid-id> (--goal <goal-id> | --draft <draft-slug>) (--source-dir <local-dir> | --source-file <local-file> --path P) [--message TEXT] [--confirm]
230
237
 
231
238
  # Read-only escape hatch (GET only — no raw writes exist)
232
239
  recess --json request get /path?query=value
@@ -294,10 +301,12 @@ Translate as you read:
294
301
  | `manage_goal_template action:"preview_setup_workflow_spec_patch"` / `"patch_setup_workflow_spec"` | `goal-templates patch-spec <id-or-slug> --expected-version N --patches-file …` (omit `--confirm` for the required server preview; destructive changes also require its exact token) |
295
302
  | `manage_goal_template action:"update"` (metadata) | `goal-templates set-metadata <id> --expected-version N … --confirm` |
296
303
  | `manage_goal_template action:"delete"` | `goal-templates delete <id> --expected-version N --confirm` |
304
+ | `manage_goal_template action:"capture_snapshot"` | `goal-templates capture-snapshot <id-or-slug> --source-goal <goal-id>` or `--source-draft <slug> --student <kid-id>` |
297
305
  | `create_goal` | `goals create --student <kid-id> … --confirm`, or `goal-templates apply` when a template exists |
298
306
  | `mesa_list_files` / `mesa_read_file` | `mesa files list` / `mesa files read` |
307
+ | `mesa_write_file` (batch upserts) | `mesa files write --student … (--goal … | --draft …) (--source-dir … | --source-file … --path …)` |
299
308
  | `load_skill` / `load_skill_reference` | `skills get <name>` / `skills get <name> --reference <ref>` |
300
- | `restore_version`, `capture_snapshot`, `clear_snapshot`, `mesa_write_file`, `mesa_edit_file`, `mesa_manage_module` | **No CLI command.** Restoring a version, capturing/clearing a snapshot, and writing workspace files stay in `recess.gg/ai`. Say so and hand the human the web tool. |
309
+ | `restore_version`, `clear_snapshot`, `mesa_edit_file`, `mesa_manage_module` | **No CLI command.** Version restore, snapshot clearing, string edits/deletes, and projected live-goal module/state mutations stay in `recess.gg/ai`. |
301
310
 
302
311
  #### Research tools → your own harness
303
312
 
@@ -312,7 +321,8 @@ the tool names do not:
312
321
  | `search_web`, `fetch_webpage` | your own web search / page fetch |
313
322
  | `spawn_subagents` | your own subagents. The five researcher prompts in `goal-creation`'s `subagent-tasks.md` (curriculum-map, platforms, resources, learning-paths, prerequisites) are usable almost verbatim — they are prompts, not tool calls |
314
323
  | `validate_urls` | check the URLs yourself before baking them into a spec. Do not skip this: a rotted queue URL is invisible until a kid clicks it |
315
- | `search_videos`, `search_books`, `recommend_videos`, `search_gem_library` | your own search. There is no CLI command for the gem library |
324
+ | `search_gem_library` | `content-library search <query>` (same hybrid/vector + rerank funnel; no content or Pipeline writes) |
325
+ | `search_videos`, `search_books`, `recommend_videos` | your own search |
316
326
  | `get_student_profile`, `read_memory`, `query_student_activity_sql` | **partial.** `users search`/`users get` give account context, `goals list` what is already authored, `mesa files` the workspace. There is **no** command for kid memory or activity SQL — for those, the separate `query-db` skill's read-only production access is the honest route, not a guess |
317
327
  | `search_standards`, `navigate_standards` | no CLI command |
318
328
  | `save_platform_research` | **no CLI command** — a researched platform profile can only be persisted from `recess.gg/ai`. Your research still informs the spec you write; it just does not get saved as a reusable profile |
@@ -347,10 +357,10 @@ rather than pretend:
347
357
  - **The skill treats create as one step.** Here it is two: the unconfirmed run returns the
348
358
  SERVER-resolved `details` (`resolvedSetupHandler`, `resolvedGoalShape`, `wizardStepKeys`,
349
359
  `specInventory`). Put those in the approval request — they are what the template will actually do.
350
- - **Prebuilt templates are half-reachable.** You can create the BLUEPRINT and read a snapshot
351
- (`snapshot-files`), but `capture_snapshot` has no command, so a template only becomes Prebuilt
352
- through `recess.gg/ai`. Applying a MODULE_BACKED template with no snapshot 400s that is the
353
- server refusing correctly, not a bug to route around.
360
+ - **Prebuilt templates are a draft validate → capture flow.** Write the complete OS-V2 tree to a
361
+ named Mesa draft, preview `capture-snapshot`, then confirm it. Capture rejects an incomplete
362
+ draft and filters student `state/`, conversations, and `.recess/` bookkeeping from the frozen
363
+ snapshot. Applying a MODULE_BACKED template with no snapshot still 400s correctly.
354
364
 
355
365
  #### The one-way rules
356
366
 
@@ -377,7 +387,7 @@ Each domain has a full playbook in this skill's `reference/` directory. Read the
377
387
  | Class-cancellation credits — the "Please credit these students accordingly" Slack workflow | [`reference/cancellation-credits.md`](reference/cancellation-credits.md) |
378
388
  | Non-flexible course one-off time shift when `events reschedule` 400s (`allowFlexibleScheduling: false`) | [`reference/class-ops-reschedule.md`](reference/class-ops-reschedule.md) |
379
389
  | Family onboarding — stage, account state, attestation checklist, parent intake session (fill / LLM-extract) | [`reference/onboarding.md`](reference/onboarding.md) |
380
- | **Authoring learning content** — deterministic GoalTemplates, applying a template to a kid or roster, creating a goal on a kid, reading Mesa workspace files | [`reference/goal-authoring.md`](reference/goal-authoring.md) |
390
+ | **Authoring learning content** — deterministic GoalTemplates, Mesa draft/goal workspace writes, snapshot capture, applying a template to a kid or roster, creating a goal on a kid | [`reference/goal-authoring.md`](reference/goal-authoring.md) |
381
391
 
382
392
  ## Deliberately out of scope
383
393
 
@@ -387,7 +397,7 @@ These are excluded from the CLI on purpose. If asked, direct the human to the we
387
397
  - **Money movement:** initiating Mercury payouts, advancing whole pay runs, generating/regenerating payout invoices → `/admin/payout` in the web admin.
388
398
  - **Enrollment cancellation** as a standalone action (`unregister-cohort` deliberately preserves the enrollment; only `registrations deny` cancels one, because that is what the web Deny button does).
389
399
  - **Cohort creation and schedule editing:** create, start, full-edit/RRULE regeneration, generate-events, guides management → web admin cohort pages.
390
- - **Restoring a template version, capturing/clearing a snapshot, and writing Mesa workspace files.** Reads exist; these writes do not. Existing-spec edits are the exception: use only `goal-templates patch-spec`, never `set-metadata` or a raw request.
400
+ - **Restoring a template version or clearing a snapshot; deleting/editing Mesa files; direct live-goal `modules/` or `state/` writes.** Use `recess.gg/ai` for these projected mutations. The CLI intentionally supports upserts only and routes structural course authoring through a draft + capture.
391
401
 
392
402
  ## Guardrails
393
403
 
@@ -472,3 +482,7 @@ Dated, newest last. Add an entry every time reality surprises you.
472
482
  - 2026-08-04 — **`--version` is a flag, not a noun.** Anything the arg parser sees starting with `--` lands in `flags`, leaving `positionals` empty — so a `noun`-based check for it sits behind the `!noun → print help` branch and is unreachable. Same trap for any future `--foo` top-level command: check the flag before the help branch. Observed live: `recess --json --version` printed the whole help text.
473
483
  - 2026-08-04 — A `Makefile install-persistent` copy is NOT the npm package: it synthesizes its own `package.json`. If that manifest lacks `version`, or `skill/` is not copied alongside `dist/`, then `--version` reports `0.0.0` and the `minCliVersion` fence **fails closed** — every served skill upgrade is silently refused with `cli_too_old`. Both are now copied; if you add another packaged artifact the CLI reads at runtime, add it to that target too.
474
484
  - 2026-08-04 — **`goal-templates patch-spec` performs a server preview even on a confirmed run.** The first POST is `dryRun:true`, never a write; it binds the current template version, before/after hashes, and protected removals. A destructive second POST is impossible without `--confirm-destructive-changes` and that fresh preview token. If the template or patch file changes, preview again and obtain new approval.
485
+ - 2026-08-05 — **A deterministic MODULE_BACKED spec is not the course content.** `goal-templates create` can create a valid BLUEPRINT while `snapshot` remains null; Goal Preview stays empty and apply correctly says there is no valid snapshot. Build the complete tree locally, `mesa files write --draft … --source-dir …`, then `goal-templates capture-snapshot --source-draft … --student …`. Both writes server-preview first. Mesa writes are fenced to the previewed repo change; capture is fenced to that source change and the template version. Direct live-goal `modules/`/`state/` writes are deliberately blocked because those paths have DB projections.
486
+ - 2026-08-05 — **The goal-audit GET cannot investigate an already-soft-deleted goal.** `request get /admin/browser/students/goals/<goal-id>/audit/` returns 404 because the handler's `ensureGoalAccess` calls `canManageGoal`, which requires `Goal.deletedAt: null` before it loads `GoalAuditLog`. The 404 is not evidence that the audit row is absent. Use the `query-db` skill's guarded read-only production query for deletion forensics.
487
+ - 2026-08-05 — **`set-metadata --output-template-file` edits an AI_CHAT template's `outputTemplate`** (the goal-description payload DailyTodoGeneration consumes for description-only goals). Like `--agent-instructions-file`, it reads a local file and the server bumps the version + freezes a `GoalTemplateVersion` row. Editing a template never rewrites goals already created from it — the description was copied at goal-creation time; re-apply or edit live goals separately.
488
+ - 2026-08-05 — **Content Library search now has a content/Pipeline-read-only CLI command.** `content-library search <query> [--limit 8]` calls the same hybrid/vector + rerank `/agent/search` funnel as Rocky's `search_gem_library`, returns the full fit/gist/coverage payload, and attributes the funnel's standard `offered` telemetry to `admin-cli`. It cannot inspect, ingest, file requests, curate, or edit the Pipeline; the permanent library token stays on the web server.
@@ -66,8 +66,8 @@ the human directly, in the skill's language, and wait. Do not say "materialize",
66
66
  "module-backed", or "snapshot" to a tutor.
67
67
 
68
68
  A MODULE_BACKED spec **requires** `BLUEPRINT`; the server enforces it, and it additionally refuses to
69
- apply a MODULE_BACKED template that has no snapshot. Capturing a snapshot has no CLI command that
70
- step is `recess.gg/ai` only.
69
+ apply a MODULE_BACKED template that has no snapshot. Build a complete Mesa draft and capture it with
70
+ the CLI workflow in §8 before applying the template.
71
71
 
72
72
  ## 2. Write one template file
73
73
 
@@ -194,7 +194,41 @@ the human which goal to retire.
194
194
  Load `goal-creation` (and `student-research` to read the kid first) before writing the description.
195
195
  An unresearched goal is the failure mode this whole surface exists to prevent.
196
196
 
197
- ## 8. Read the workspaces
197
+ ## 8. Author, capture, and read workspaces
198
+
199
+ For a new Prebuilt/instant-apply course, author the complete OS-V2 tree locally and upsert it into a
200
+ named draft. The directory root becomes `drafts/<slug>/workspace` in the student's Mesa repo:
201
+
202
+ ```bash
203
+ recess --json mesa files write --student <kid-id> --draft <draft-slug> --source-dir ./workspace
204
+ # show the preview, obtain approval, then rerun unchanged:
205
+ recess --json mesa files write --student <kid-id> --draft <draft-slug> --source-dir ./workspace --confirm
206
+
207
+ recess --json goal-templates capture-snapshot <id-or-slug> --source-draft <draft-slug> --student <kid-id> --dry-run
208
+ recess --json goal-templates capture-snapshot <id-or-slug> --source-draft <draft-slug> --student <kid-id>
209
+ # show the preview, obtain approval, then rerun unchanged:
210
+ recess --json goal-templates capture-snapshot <id-or-slug> --source-draft <draft-slug> --student <kid-id> --confirm
211
+ ```
212
+
213
+ The draft must pass the full goal-builder contract: required top-level instructions, required fresh
214
+ `state/` files, directory content, and at least one parseable runtime module. Capture validates that
215
+ tree, then excludes `state/`, `conversations/`, and `.recess/` from the reusable snapshot. Its
216
+ preview carries the template version, Mesa change id, module/file inventory, size, and SHA-256; the
217
+ confirmed request is compare-and-set against both fences. If either changed, preview again and get
218
+ fresh approval.
219
+
220
+ Use `--source-file <local-file> --path <workspace-relative-path>` for one-file upserts. A live goal
221
+ may be targeted with `--goal <goal-id>`, but the CLI rejects direct `modules/` and `state/` writes
222
+ because those paths have database projections. Structural course changes belong in a draft, then a
223
+ captured/applied template.
224
+
225
+ An already-built OS-V2 goal can be captured directly:
226
+
227
+ ```bash
228
+ recess --json goal-templates capture-snapshot <id-or-slug> --source-goal <goal-id> --dry-run
229
+ ```
230
+
231
+ Read the resulting workspaces and snapshot with:
198
232
 
199
233
  ```bash
200
234
  recess --json mesa files list --student <kid-id> --goal <goal-id>
@@ -203,9 +237,6 @@ recess --json goal-templates snapshot-files <id-or-slug>
203
237
  recess --json goal-templates snapshot-files <id-or-slug> --path modules/01/index.md
204
238
  ```
205
239
 
206
- Reads only — there is no `mesa files write`. Writing workspace files stays in `recess.gg/ai`, which
207
- validates a workspace before it lands in a live kid's session.
208
-
209
240
  `mesa files list` returning `{"files":[]}` means the goal has no Mesa workspace at all — expected for
210
241
  a SIMPLE goal, and the signal that a BLUEPRINT goal has not been built yet.
211
242