recess-cli 1.4.0 → 1.6.0

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/dist/cli.js CHANGED
@@ -1,4 +1,4 @@
1
- import { createHash } from "node:crypto";
1
+ import { createHash, randomUUID } 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";
@@ -10,7 +10,7 @@ import { requireConfirmation } from "./safety.js";
10
10
  import { installSkill, isEphemeralInstall, readBundledSkillVersion, readCliVersion, } from "./setup.js";
11
11
  import { compareVersions, updateSkillFromServer } from "./skill-update.js";
12
12
  import { readSkillCache, writeSkillCache } from "./skills-cache.js";
13
- export const HELP = `recess — safe Recess administration from the command line
13
+ export const HELP = `recess — safe Recess administration and family AI tools
14
14
 
15
15
  Usage:
16
16
  recess [--json] --version
@@ -35,6 +35,11 @@ Usage:
35
35
  --first-name TEXT [--last-name TEXT] [--no-invite] [--confirm]
36
36
  recess [--json] students upload-map-scores --student <kid-id>
37
37
  --file </path/to/map-report.pdf> [--confirm]
38
+ recess [--json] students list
39
+ recess [--json] students today --student <kid-id> [--date YYYY-MM-DD]
40
+ recess [--json] students schedule --student <kid-id> [--days 14]
41
+ recess [--json] students xp-history --student <kid-id>
42
+ [--range week|month|quarter|year]
38
43
  recess [--json] enrollments list --user <user-id>
39
44
  recess [--json] subscriptions list --family <family-id> [--kid <kid-id>]
40
45
  recess [--json] invoices list --subscription <subscription-id>
@@ -125,8 +130,12 @@ Usage:
125
130
  recess [--json] store-items set-status <village-store-item-id>
126
131
  --status ACTIVE|INACTIVE|COMING_SOON [--confirm]
127
132
  recess [--json] content-library search <query> [--limit 8]
128
- recess [--json] skills list [--query TEXT] [--category TEXT]
129
- recess [--json] skills get <skill-name> [--reference NAME | --all-references]
133
+ recess [--json] skills guardian list [--query TEXT] [--category TEXT]
134
+ recess [--json] skills guardian get <skill-name>
135
+ [--reference NAME | --all-references] [--refresh]
136
+ recess [--json] skills admin list [--query TEXT] [--category TEXT]
137
+ recess [--json] skills admin get <skill-name>
138
+ [--reference NAME | --all-references]
130
139
  [--refresh]
131
140
  recess [--json] goal-templates list [--query TEXT] [--category TEXT]
132
141
  [--kind SIMPLE|BLUEPRINT] [--starter-only] [--include-deleted]
@@ -147,19 +156,34 @@ Usage:
147
156
  (--source-goal <goal-id> | --source-draft <draft-slug> --student <kid-id>)
148
157
  [--dry-run] [--confirm]
149
158
  recess [--json] goal-templates apply <template-id> --answers-file <path>
150
- [--dry-run] [--confirm]
159
+ [--dry-run] [--confirm --approval-token TOKEN]
151
160
  recess [--json] goal-templates apply-starter <template-id> --student <kid-id>
152
161
  [--answers-file <path>] [--confirm]
153
162
  recess [--json] goals list --student <kid-id>
154
163
  recess [--json] goals create --student <kid-id> --title TEXT
155
164
  (--description TEXT | --description-file <path>) [--target-date <iso>]
156
165
  [--schedule TEXT] [--confirm]
157
- recess [--json] mesa files list --student <kid-id> --goal <goal-id>
158
- recess [--json] mesa files read --student <kid-id> --goal <goal-id> --path P
159
- recess [--json] mesa files write --student <kid-id>
166
+ recess [--json] goals edit <goal-id> --student <kid-id> --patch-file <path/patch.json>
167
+ --delta TEXT [--confirm --approval-token TOKEN]
168
+ recess [--json] todos create --student <kid-id> --title TEXT
169
+ [--due-date YYYY-MM-DD] [--estimated-minutes N] [--url URL] [--confirm]
170
+ recess [--json] todos edit <todo-id> --patch-file <path/patch.json>
171
+ [--confirm --approval-token TOKEN]
172
+ recess [--json] memories context --student <kid-id>
173
+ recess [--json] memories log --student <kid-id> --date YYYY-MM-DD
174
+ recess [--json] rocky get --student <kid-id>
175
+ recess [--json] rocky set --student <kid-id> --patch-file <path/patch.json>
176
+ --expected-updated-at ISO|none [--confirm]
177
+ recess [--json] goals files list --student <kid-id> --goal <goal-id>
178
+ recess [--json] goals files read --student <kid-id> --goal <goal-id> --path P
179
+ recess [--json] goals files write --student <kid-id>
160
180
  (--goal <goal-id> | --draft <draft-slug>)
161
181
  (--source-dir <local-dir> | --source-file <local-file> --path P)
162
- [--message TEXT] [--confirm]
182
+ [--message TEXT] [--confirm --approval-token TOKEN]
183
+ recess [--json] goals pdf upload --student <kid-id>
184
+ (--goal <goal-id> | --draft <draft-slug>) --source-file <local.pdf>
185
+ [--path uploads/name.pdf] [--message TEXT]
186
+ [--confirm --approval-token TOKEN]
163
187
 
164
188
  Authoring notes: "skills" serves the in-product tutor skills (the PRIVATE
165
189
  packages/skills submodule) read-only over your admin session — they are never
@@ -199,8 +223,9 @@ Payout notes: amounts are integer cents. "payout items add" without --date
199
223
  defaults the item date to the penultimate day of the invoice's cycle (its
200
224
  endDate minus one day) and shows the computed date in the preview.
201
225
 
202
- Auth notes: "auth login" runs the browser loopback flow (local use). For a headless
203
- cloud agent, "auth request" prints an approval URL to hand a Recess admin; after they
226
+ Auth notes: "auth login" runs the browser loopback flow for ADMIN or a GUARDIAN with
227
+ access:ai. Guardian sessions are family-scoped and cannot call /admin. For a headless
228
+ cloud agent, the ADMIN-only "auth request" prints an approval URL to hand a Recess admin; after they
204
229
  approve it in a browser, "auth poll" collects the 12h session. When it lapses, run
205
230
  "auth request" again for a fresh link. Both paths yield the same session.
206
231
 
@@ -237,7 +262,7 @@ async function sessionStatus(config) {
237
262
  authSource: config.authSource,
238
263
  status: result.response.status,
239
264
  expiresAt: config.sessionExpiresAt ?? null,
240
- message: "The stored admin session is invalid or expired. Run `recess auth login`.",
265
+ message: "The stored Recess CLI session is invalid, expired, or revoked. Run `recess auth login`.",
241
266
  };
242
267
  }
243
268
  return { ...result.data, authSource: config.authSource };
@@ -281,7 +306,7 @@ async function skillStatus(config) {
281
306
  return { ...base, checked: false, reason: "no session" };
282
307
  }
283
308
  try {
284
- const response = await fetch(new URL("/admin/cli-skill/", config.apiOrigin), {
309
+ const response = await fetch(new URL("/auth/admin-cli/skill/", config.apiOrigin), {
285
310
  headers: { cookie: config.sessionCookie },
286
311
  signal: AbortSignal.timeout(5000),
287
312
  });
@@ -320,9 +345,114 @@ async function writeCommand(parsed, preview, execute) {
320
345
  requireConfirmation(hasFlag(parsed, "confirm"), preview);
321
346
  return execute();
322
347
  }
323
- const MESA_WRITE_MAX_FILES = 1_000;
324
- const MESA_WRITE_MAX_TOTAL_BYTES = 20 * 1024 * 1024;
325
- async function readMesaWriteSource(parsed) {
348
+ const GOAL_WORKSPACE_WRITE_MAX_FILES = 1_000;
349
+ const GOAL_WORKSPACE_WRITE_MAX_TOTAL_BYTES = 20 * 1024 * 1024;
350
+ const GOAL_PDF_UPLOAD_MAX_BYTES = 1024 * 1024 * 1024;
351
+ function approvalTokenFor(preview) {
352
+ return createHash("sha256").update(JSON.stringify(preview)).digest("hex");
353
+ }
354
+ function requirePreviewBoundConfirmation(parsed, preview) {
355
+ const approvalToken = approvalTokenFor(preview);
356
+ const boundPreview = {
357
+ ...preview,
358
+ details: { ...preview.details, approvalToken },
359
+ };
360
+ if (!hasFlag(parsed, "confirm")) {
361
+ requireConfirmation(false, boundPreview);
362
+ }
363
+ const supplied = flagString(parsed, "approval-token");
364
+ if (supplied !== approvalToken) {
365
+ throw new CliError("approval_mismatch", "The approved preview no longer matches this request. Review the current preview and rerun with --confirm --approval-token <token>.", 2, { preview: boundPreview, requiredFlag: "--approval-token" });
366
+ }
367
+ }
368
+ async function cleanupPlannerUpload(api, conversationId, signed, includeDelete) {
369
+ if (signed.kind === "multipart") {
370
+ await api.client
371
+ .POST("/os-v2-mesa/planner/uploads/cleanup", {
372
+ body: {
373
+ action: "abort",
374
+ conversationId,
375
+ key: signed.key,
376
+ uploadId: signed.uploadId,
377
+ },
378
+ })
379
+ .catch(() => undefined);
380
+ }
381
+ if (includeDelete) {
382
+ await api.client
383
+ .POST("/os-v2-mesa/planner/uploads/cleanup", {
384
+ body: { action: "delete", conversationId, key: signed.key },
385
+ })
386
+ .catch(() => undefined);
387
+ }
388
+ }
389
+ function safeGoalUploadName(fileName) {
390
+ return fileName.replace(/[^a-zA-Z0-9._-]/g, "_");
391
+ }
392
+ async function sha256File(absolutePath) {
393
+ const handle = await fs.open(absolutePath, "r");
394
+ const hash = createHash("sha256");
395
+ const buffer = Buffer.allocUnsafe(4 * 1024 * 1024);
396
+ try {
397
+ let position = 0;
398
+ while (true) {
399
+ const { bytesRead } = await handle.read(buffer, 0, buffer.byteLength, position);
400
+ if (bytesRead === 0)
401
+ break;
402
+ hash.update(buffer.subarray(0, bytesRead));
403
+ position += bytesRead;
404
+ }
405
+ }
406
+ finally {
407
+ await handle.close();
408
+ }
409
+ return hash.digest("hex");
410
+ }
411
+ function uploadBody(bytes) {
412
+ return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
413
+ }
414
+ async function putSignedPart(uploadUrl, bytes, contentType) {
415
+ const response = await fetch(uploadUrl, {
416
+ method: "PUT",
417
+ headers: contentType ? { "content-type": contentType } : undefined,
418
+ body: uploadBody(bytes),
419
+ });
420
+ if (!response.ok) {
421
+ throw new CliError("upload_failed", `Textbook upload failed with HTTP ${response.status}. The goal workspace was not changed.`);
422
+ }
423
+ }
424
+ async function uploadPdfToSignedUrls(absolutePath, contentType, signed) {
425
+ if (signed.kind === "single") {
426
+ const bytes = await fs.readFile(absolutePath);
427
+ await putSignedPart(signed.uploadUrl, bytes, contentType);
428
+ return {
429
+ sha256: createHash("sha256").update(bytes).digest("hex"),
430
+ sizeBytes: bytes.byteLength,
431
+ };
432
+ }
433
+ const handle = await fs.open(absolutePath, "r");
434
+ const hash = createHash("sha256");
435
+ let sizeBytes = 0;
436
+ try {
437
+ for (const part of signed.parts) {
438
+ const position = (part.partNumber - 1) * signed.chunkSize;
439
+ const buffer = Buffer.allocUnsafe(signed.chunkSize);
440
+ const { bytesRead } = await handle.read(buffer, 0, signed.chunkSize, position);
441
+ if (bytesRead === 0) {
442
+ throw new CliError("upload_failed", `Textbook upload part ${part.partNumber} had no bytes. The goal workspace was not changed.`);
443
+ }
444
+ const bytes = buffer.subarray(0, bytesRead);
445
+ hash.update(bytes);
446
+ sizeBytes += bytesRead;
447
+ await putSignedPart(part.uploadUrl, bytes);
448
+ }
449
+ }
450
+ finally {
451
+ await handle.close();
452
+ }
453
+ return { sha256: hash.digest("hex"), sizeBytes };
454
+ }
455
+ async function readGoalWorkspaceWriteSource(parsed) {
326
456
  const sourceDirectory = flagString(parsed, "source-dir");
327
457
  const sourceFile = flagString(parsed, "source-file");
328
458
  if (Boolean(sourceDirectory) === Boolean(sourceFile)) {
@@ -333,12 +463,12 @@ async function readMesaWriteSource(parsed) {
333
463
  let sizeBytes = 0;
334
464
  let source;
335
465
  const addFile = async (absolutePath, workspacePath) => {
336
- if (files.length >= MESA_WRITE_MAX_FILES) {
337
- throw new CliError("invalid_arguments", `Mesa workspace writes are limited to ${MESA_WRITE_MAX_FILES} files.`);
466
+ if (files.length >= GOAL_WORKSPACE_WRITE_MAX_FILES) {
467
+ throw new CliError("invalid_arguments", `Goal workspace writes are limited to ${GOAL_WORKSPACE_WRITE_MAX_FILES} files.`);
338
468
  }
339
469
  const fileStat = await fs.stat(absolutePath);
340
- if (sizeBytes + fileStat.size > MESA_WRITE_MAX_TOTAL_BYTES) {
341
- throw new CliError("invalid_arguments", `Mesa workspace writes are limited to ${MESA_WRITE_MAX_TOTAL_BYTES} decoded bytes.`);
470
+ if (sizeBytes + fileStat.size > GOAL_WORKSPACE_WRITE_MAX_TOTAL_BYTES) {
471
+ throw new CliError("invalid_arguments", `Goal workspace writes are limited to ${GOAL_WORKSPACE_WRITE_MAX_TOTAL_BYTES} decoded bytes.`);
342
472
  }
343
473
  const bytes = await fs.readFile(absolutePath);
344
474
  sizeBytes += bytes.byteLength;
@@ -387,7 +517,7 @@ async function readMesaWriteSource(parsed) {
387
517
  await addFile(absolutePath, workspacePath);
388
518
  }
389
519
  if (files.length === 0) {
390
- throw new CliError("invalid_arguments", "The Mesa write source contains no files.");
520
+ throw new CliError("invalid_arguments", "The goal workspace source contains no files.");
391
521
  }
392
522
  for (const file of files) {
393
523
  hash.update(file.path);
@@ -603,7 +733,7 @@ function parseGoalTemplateDocument(doc) {
603
733
  }
604
734
  const spec = doc.setupWorkflowSpec;
605
735
  if (spec === null || typeof spec !== "object" || Array.isArray(spec)) {
606
- throw new CliError("invalid_arguments", 'Template file requires an object "setupWorkflowSpec" (the fixed wizard). Author it with the os-v2-goal-template-builder skill: `recess --json skills get os-v2-goal-template-builder --all-references`.');
736
+ throw new CliError("invalid_arguments", 'Template file requires an object "setupWorkflowSpec" (the fixed wizard). Load the guardian authoring guide first: `recess --json skills guardian get recess-goal-authoring --all-references`.');
607
737
  }
608
738
  const rawTags = doc.tags;
609
739
  let tags = [];
@@ -681,8 +811,8 @@ function parseGoalTemplateDocument(doc) {
681
811
  async function resolveGoalTemplateId(api, reference) {
682
812
  if (UUID_RE.test(reference))
683
813
  return reference;
684
- const list = unwrap(await api.client.GET("/admin/goal-templates/", {
685
- params: { query: { includeDeleted: "true" } },
814
+ const list = unwrap(await api.client.GET("/ai/goal-templates/", {
815
+ params: { query: { includeDeleted: "false" } },
686
816
  }));
687
817
  const match = list.items.find((item) => item.slug === reference);
688
818
  if (!match) {
@@ -2241,21 +2371,23 @@ export async function runCommand(argv) {
2241
2371
  throw new CliError("invalid_arguments", "Use onboarding status|kids|intake-session|intake-session-create|set-stage|set-account-state|attest|set-intake|extract.");
2242
2372
  }
2243
2373
  if (noun === "skills") {
2244
- // Reads only — no gate. The value of this noun is that CLI agents author
2245
- // against the SAME documents the in-product tutor loads; nothing is copied
2246
- // into this package, so a skills-repo change reaches agents with no release.
2374
+ // Reads only — no confirmation gate. Audience is explicit so an agent never
2375
+ // infers whether `get` means guardian guidance or staff-only operations.
2376
+ const audience = assertChoice(verb, ["admin", "guardian"], "skill audience");
2377
+ const action = positional(parsed, 2, "skills action");
2247
2378
  const refresh = hasFlag(parsed, "refresh");
2248
- if (verb === "list") {
2379
+ if (action === "list") {
2249
2380
  const query = flagString(parsed, "query");
2250
2381
  const category = flagString(parsed, "category");
2251
- const cacheKey = `list:${query ?? ""}:${category ?? ""}`;
2382
+ const cacheKey = `${audience}:list:${query ?? ""}:${category ?? ""}`;
2252
2383
  if (!refresh) {
2253
2384
  const cached = await readSkillCache(config.apiOrigin, cacheKey);
2254
2385
  if (cached)
2255
2386
  return { ...cached, cached: true };
2256
2387
  }
2257
- const data = unwrap(await api.client.GET("/admin/skills/", {
2388
+ const data = unwrap(await api.client.GET("/auth/admin-cli/skills/{audience}", {
2258
2389
  params: {
2390
+ path: { audience },
2259
2391
  query: {
2260
2392
  ...(query ? { query } : {}),
2261
2393
  ...(category ? { category } : {}),
@@ -2265,22 +2397,22 @@ export async function runCommand(argv) {
2265
2397
  await writeSkillCache(config.apiOrigin, cacheKey, data);
2266
2398
  return { ...data, cached: false };
2267
2399
  }
2268
- if (verb === "get") {
2269
- const name = positional(parsed, 2, "skill name");
2400
+ if (action === "get") {
2401
+ const name = positional(parsed, 3, "skill name");
2270
2402
  const reference = flagString(parsed, "reference");
2271
2403
  const allReferences = hasFlag(parsed, "all-references");
2272
2404
  if (reference && allReferences) {
2273
2405
  throw new CliError("invalid_arguments", "Pass --reference <name> for one reference or --all-references for the whole tree, not both.");
2274
2406
  }
2275
- const cacheKey = `get:${name}:${reference ?? ""}:${allReferences}`;
2407
+ const cacheKey = `${audience}:get:${name}:${reference ?? ""}:${allReferences}`;
2276
2408
  if (!refresh) {
2277
2409
  const cached = await readSkillCache(config.apiOrigin, cacheKey);
2278
2410
  if (cached)
2279
2411
  return { ...cached, cached: true };
2280
2412
  }
2281
- const data = unwrap(await api.client.GET("/admin/skills/{name}", {
2413
+ const data = unwrap(await api.client.GET("/auth/admin-cli/skills/{audience}/{name}", {
2282
2414
  params: {
2283
- path: { name },
2415
+ path: { audience, name },
2284
2416
  query: {
2285
2417
  ...(reference ? { reference } : {}),
2286
2418
  ...(allReferences ? { references: "all" } : {}),
@@ -2290,7 +2422,7 @@ export async function runCommand(argv) {
2290
2422
  await writeSkillCache(config.apiOrigin, cacheKey, data);
2291
2423
  return { ...data, cached: false };
2292
2424
  }
2293
- throw new CliError("invalid_arguments", "Use skills list|get.");
2425
+ throw new CliError("invalid_arguments", "Use skills guardian list|get or skills admin list|get.");
2294
2426
  }
2295
2427
  if (noun === "content-library") {
2296
2428
  if (verb === "search") {
@@ -2299,7 +2431,7 @@ export async function runCommand(argv) {
2299
2431
  if (!Number.isInteger(limit) || limit < 1 || limit > 40) {
2300
2432
  throw new CliError("invalid_arguments", "--limit must be an integer between 1 and 40.");
2301
2433
  }
2302
- return unwrap(await api.client.GET("/admin/content-library/search", {
2434
+ return unwrap(await api.client.GET("/ai/content-library/search", {
2303
2435
  params: { query: { q: query, limit } },
2304
2436
  }));
2305
2437
  }
@@ -2309,7 +2441,7 @@ export async function runCommand(argv) {
2309
2441
  if (verb === "list") {
2310
2442
  const query = flagString(parsed, "query")?.toLowerCase();
2311
2443
  const kind = flagString(parsed, "kind");
2312
- const data = unwrap(await api.client.GET("/admin/goal-templates/", {
2444
+ const data = unwrap(await api.client.GET("/ai/goal-templates/", {
2313
2445
  params: {
2314
2446
  query: {
2315
2447
  ...(flagString(parsed, "category")
@@ -2344,7 +2476,7 @@ export async function runCommand(argv) {
2344
2476
  }
2345
2477
  if (verb === "get") {
2346
2478
  const id = await resolveGoalTemplateId(api, positional(parsed, 2, "template ID or slug"));
2347
- const template = unwrap(await api.client.GET("/admin/goal-templates/{id}", {
2479
+ const template = unwrap(await api.client.GET("/ai/goal-templates/{id}", {
2348
2480
  params: { path: { id } },
2349
2481
  }));
2350
2482
  // A full spec is large; `--spec-only` is what an agent pipes into a file
@@ -2361,7 +2493,7 @@ export async function runCommand(argv) {
2361
2493
  }
2362
2494
  if (verb === "versions") {
2363
2495
  const id = await resolveGoalTemplateId(api, positional(parsed, 2, "template ID or slug"));
2364
- const data = unwrap(await api.client.GET("/admin/goal-templates/{id}/versions", {
2496
+ const data = unwrap(await api.client.GET("/ai/goal-templates/{id}/versions", {
2365
2497
  params: { path: { id } },
2366
2498
  }));
2367
2499
  const wanted = flagNumber(parsed, "version");
@@ -2388,7 +2520,7 @@ export async function runCommand(argv) {
2388
2520
  return {
2389
2521
  slug: document.slug,
2390
2522
  kind: document.kind,
2391
- ...unwrap(await api.client.POST("/admin/goal-templates/validate-spec", {
2523
+ ...unwrap(await api.client.POST("/ai/goal-templates/validate-spec", {
2392
2524
  body: {
2393
2525
  setupWorkflowSpec: document.setupWorkflowSpec,
2394
2526
  kind: document.kind,
@@ -2405,7 +2537,7 @@ export async function runCommand(argv) {
2405
2537
  // answer keys `apply` will demand all come out of the spec's own
2406
2538
  // validation — a locally guessed preview could name a different handler
2407
2539
  // than the one that lands. Validation writes nothing.
2408
- const validation = unwrap(await api.client.POST("/admin/goal-templates/validate-spec", {
2540
+ const validation = unwrap(await api.client.POST("/ai/goal-templates/validate-spec", {
2409
2541
  body: {
2410
2542
  setupWorkflowSpec: document.setupWorkflowSpec,
2411
2543
  kind: document.kind,
@@ -2433,7 +2565,7 @@ export async function runCommand(argv) {
2433
2565
  specInventory: validation.totals,
2434
2566
  note: "Setup mode is DETERMINISTIC_WORKFLOW and is one-way: this template can never be converted back to a chat-driven setup.",
2435
2567
  },
2436
- }, async () => unwrap(await api.client.POST("/admin/goal-templates/", {
2568
+ }, async () => unwrap(await api.client.POST("/ai/goal-templates/", {
2437
2569
  body: document,
2438
2570
  })));
2439
2571
  }
@@ -2603,11 +2735,11 @@ export async function runCommand(argv) {
2603
2735
  const id = await resolveGoalTemplateId(api, positional(parsed, 2, "template ID or slug"));
2604
2736
  const filePath = flagString(parsed, "path");
2605
2737
  if (filePath) {
2606
- return unwrap(await api.client.GET("/admin/goal-templates/{id}/snapshot/file", {
2738
+ return unwrap(await api.client.GET("/ai/goal-templates/{id}/snapshot/file", {
2607
2739
  params: { path: { id }, query: { path: filePath } },
2608
2740
  }));
2609
2741
  }
2610
- return unwrap(await api.client.GET("/admin/goal-templates/{id}/snapshot/files", {
2742
+ return unwrap(await api.client.GET("/ai/goal-templates/{id}/snapshot/files", {
2611
2743
  params: { path: { id } },
2612
2744
  }));
2613
2745
  }
@@ -2627,7 +2759,7 @@ export async function runCommand(argv) {
2627
2759
  sourceWorkspacePath: `drafts/${sourceDraft}/workspace`,
2628
2760
  sourceStudentUserId,
2629
2761
  };
2630
- const preflight = unwrap(await api.client.POST("/admin/goal-templates/{id}/snapshot/capture", {
2762
+ const preflight = unwrap(await api.client.POST("/ai/goal-templates/{id}/snapshot/capture", {
2631
2763
  params: { path: { id } },
2632
2764
  body: { ...source, dryRun: true },
2633
2765
  }));
@@ -2637,7 +2769,7 @@ export async function runCommand(argv) {
2637
2769
  if (hasFlag(parsed, "dry-run"))
2638
2770
  return preflight;
2639
2771
  const preview = {
2640
- action: "capture a validated Mesa workspace as this global template's instant-apply snapshot",
2772
+ action: "capture a validated goal workspace as this global template's instant-apply snapshot",
2641
2773
  target: {
2642
2774
  templateId: preflight.template.id,
2643
2775
  slug: preflight.template.slug,
@@ -2655,11 +2787,11 @@ export async function runCommand(argv) {
2655
2787
  modules: preflight.snapshot.modules,
2656
2788
  paths: preflight.snapshot.paths,
2657
2789
  warnings: preflight.warnings,
2658
- 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.",
2790
+ note: "The confirmed request is fenced to both this template version and this exact source revision. Capturing bumps the template version unless the snapshot is unchanged.",
2659
2791
  },
2660
2792
  };
2661
2793
  requireConfirmation(hasFlag(parsed, "confirm"), preview);
2662
- return unwrap(await api.client.POST("/admin/goal-templates/{id}/snapshot/capture", {
2794
+ return unwrap(await api.client.POST("/ai/goal-templates/{id}/snapshot/capture", {
2663
2795
  params: { path: { id } },
2664
2796
  body: {
2665
2797
  ...source,
@@ -2692,7 +2824,7 @@ export async function runCommand(argv) {
2692
2824
  ...totals,
2693
2825
  [result.action]: (totals[result.action] ?? 0) + 1,
2694
2826
  }), {});
2695
- return writeCommand(parsed, {
2827
+ const preview = {
2696
2828
  action: "apply a goal template to students (creates goals and todos)",
2697
2829
  target: { templateId: id, students: preflight.results.length },
2698
2830
  request: { answers },
@@ -2707,12 +2839,19 @@ export async function runCommand(argv) {
2707
2839
  warnings: result.warnings,
2708
2840
  })),
2709
2841
  missingCoverage: preflight.missingCoverage,
2842
+ templateVersion: preflight.templateVersion,
2710
2843
  note: "Counts come from the backend's own dry run. `skipped_existing` items are idempotent — re-applying does not duplicate them.",
2711
2844
  },
2712
- }, async () => unwrap(await api.client.POST("/ai/goal-templates/{id}/apply-workflow", {
2845
+ };
2846
+ requirePreviewBoundConfirmation(parsed, preview);
2847
+ return unwrap(await api.client.POST("/ai/goal-templates/{id}/apply-workflow", {
2713
2848
  params: { path: { id } },
2714
- body: { answers, dryRun: false },
2715
- })));
2849
+ body: {
2850
+ answers,
2851
+ dryRun: false,
2852
+ expectedTemplateVersion: preflight.templateVersion,
2853
+ },
2854
+ }));
2716
2855
  }
2717
2856
  if (verb === "apply-starter") {
2718
2857
  const id = await resolveGoalTemplateId(api, positional(parsed, 2, "template ID or slug"));
@@ -2738,10 +2877,10 @@ export async function runCommand(argv) {
2738
2877
  }
2739
2878
  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.");
2740
2879
  }
2741
- if (noun === "goals") {
2880
+ if (noun === "goals" && verb !== "files" && verb !== "pdf") {
2742
2881
  if (verb === "list") {
2743
2882
  const userId = flagString(parsed, "student", { required: true });
2744
- return unwrap(await api.client.GET("/admin/browser/students/{userId}/goals/", {
2883
+ return unwrap(await api.client.GET("/tutor/browser/students/{userId}/goals/", {
2745
2884
  params: { path: { userId } },
2746
2885
  }));
2747
2886
  }
@@ -2778,17 +2917,351 @@ export async function runCommand(argv) {
2778
2917
  schedule: schedule ?? null,
2779
2918
  },
2780
2919
  details: {
2781
- note: "Creates a description-only goal with no GoalModules and no Mesa workspace. For a module-backed course, apply a BLUEPRINT template instead. A 409 GOAL_LIMIT_REACHED means the kid is at capacity and nothing was created.",
2920
+ note: "Creates a description-only goal with no modules or course workspace. For a module-backed course, apply a BLUEPRINT template instead. A 409 GOAL_LIMIT_REACHED means the kid is at capacity and nothing was created.",
2782
2921
  },
2783
- }, async () => unwrap(await api.client.POST("/admin/browser/students/{userId}/goals/", {
2922
+ }, async () => unwrap(await api.client.POST("/tutor/browser/students/{userId}/goals/", {
2784
2923
  params: { path: { userId } },
2785
2924
  body,
2786
2925
  })));
2787
2926
  }
2788
- throw new CliError("invalid_arguments", "Use goals list|create.");
2927
+ if (verb === "edit") {
2928
+ const goalId = positional(parsed, 2, "goal ID");
2929
+ const studentId = flagString(parsed, "student", { required: true });
2930
+ const patchFile = flagString(parsed, "patch-file", { required: true });
2931
+ const patch = (await readJsonFile(patchFile, "Goal patch file"));
2932
+ patch.delta = flagString(parsed, "delta", { required: true });
2933
+ const current = unwrap(await api.client.GET("/tutor/browser/students/{userId}/goals/", {
2934
+ params: { path: { userId: studentId } },
2935
+ })).goals.find((goal) => goal.id === goalId);
2936
+ if (!current) {
2937
+ throw new CliError("not_found", `Goal ${goalId} was not found for student ${studentId}.`);
2938
+ }
2939
+ patch.expectedUpdatedAt = current.updatedAt;
2940
+ const preview = {
2941
+ action: "edit a goal for a managed student",
2942
+ target: { goalId, studentUserId: studentId },
2943
+ request: {
2944
+ patchFile: path.resolve(patchFile),
2945
+ expectedUpdatedAt: current.updatedAt,
2946
+ patch,
2947
+ },
2948
+ };
2949
+ requirePreviewBoundConfirmation(parsed, preview);
2950
+ return unwrap(await api.client.PATCH("/tutor/browser/students/goals/{goalId}/", {
2951
+ params: { path: { goalId } },
2952
+ body: patch,
2953
+ }));
2954
+ }
2955
+ throw new CliError("invalid_arguments", "Use goals list|create|edit|files|pdf.");
2956
+ }
2957
+ if (noun === "students") {
2958
+ if (verb === "list") {
2959
+ return unwrap(await api.client.GET("/family/kids", {
2960
+ params: { query: { includeSelf: "false" } },
2961
+ }));
2962
+ }
2963
+ if (verb === "today") {
2964
+ const kidId = flagString(parsed, "student", { required: true });
2965
+ const date = flagString(parsed, "date");
2966
+ return unwrap(await api.client.GET("/parent/kid/{kidId}/today/", {
2967
+ params: { path: { kidId }, query: date ? { date } : {} },
2968
+ }));
2969
+ }
2970
+ if (verb === "schedule") {
2971
+ const studentId = flagString(parsed, "student", { required: true });
2972
+ const days = flagNumber(parsed, "days");
2973
+ if (days !== undefined &&
2974
+ (!Number.isInteger(days) || days < 1 || days > 60)) {
2975
+ throw new CliError("invalid_arguments", "--days must be an integer from 1 to 60.");
2976
+ }
2977
+ return unwrap(await api.client.GET("/tutor/students/{studentId}/schedule", {
2978
+ params: {
2979
+ path: { studentId },
2980
+ query: days === undefined ? {} : { days },
2981
+ },
2982
+ }));
2983
+ }
2984
+ if (verb === "xp-history") {
2985
+ const kidId = flagString(parsed, "student", { required: true });
2986
+ const range = flagString(parsed, "range");
2987
+ const parsedRange = range
2988
+ ? assertChoice(range, ["week", "month", "quarter", "year"], "--range")
2989
+ : undefined;
2990
+ return unwrap(await api.client.GET("/parent/kid/{kidId}/xp-history/", {
2991
+ params: {
2992
+ path: { kidId },
2993
+ query: parsedRange ? { range: parsedRange } : {},
2994
+ },
2995
+ }));
2996
+ }
2997
+ throw new CliError("invalid_arguments", "Use students list|today|schedule|xp-history|upload-map-scores.");
2789
2998
  }
2790
- if (noun === "mesa" && verb === "files") {
2791
- const action = positional(parsed, 2, "mesa files action");
2999
+ if (noun === "todos") {
3000
+ if (verb === "create") {
3001
+ const studentId = flagString(parsed, "student", { required: true });
3002
+ const title = flagString(parsed, "title", { required: true });
3003
+ const estimatedTimeInMins = flagNumber(parsed, "estimated-minutes");
3004
+ const body = {
3005
+ userId: studentId,
3006
+ title,
3007
+ ...(flagString(parsed, "due-date")
3008
+ ? { dueDate: flagString(parsed, "due-date") }
3009
+ : {}),
3010
+ ...(flagString(parsed, "url")
3011
+ ? { url: flagString(parsed, "url") }
3012
+ : {}),
3013
+ ...(estimatedTimeInMins !== undefined ? { estimatedTimeInMins } : {}),
3014
+ };
3015
+ return writeCommand(parsed, {
3016
+ action: "create a todo for a managed student",
3017
+ target: { studentUserId: studentId },
3018
+ request: body,
3019
+ }, async () => unwrap(await api.client.POST("/tutor/browser/todos/", { body })));
3020
+ }
3021
+ if (verb === "edit") {
3022
+ const todoId = positional(parsed, 2, "todo ID");
3023
+ const patchFile = flagString(parsed, "patch-file", { required: true });
3024
+ const body = (await readJsonFile(patchFile, "Todo patch file"));
3025
+ const current = unwrap(await api.client.GET("/tutor/browser/todos/{id}/", {
3026
+ params: { path: { id: todoId } },
3027
+ }));
3028
+ body.expectedUpdatedAt = current.updatedAt;
3029
+ const preview = {
3030
+ action: "edit a todo for a managed student",
3031
+ target: { todoId },
3032
+ request: {
3033
+ patchFile: path.resolve(patchFile),
3034
+ expectedUpdatedAt: current.updatedAt,
3035
+ patch: body,
3036
+ },
3037
+ };
3038
+ requirePreviewBoundConfirmation(parsed, preview);
3039
+ return unwrap(await api.client.PATCH("/tutor/browser/todos/{id}", {
3040
+ params: { path: { id: todoId } },
3041
+ body,
3042
+ }));
3043
+ }
3044
+ throw new CliError("invalid_arguments", "Use todos create|edit.");
3045
+ }
3046
+ if (noun === "memories") {
3047
+ const studentId = flagString(parsed, "student", { required: true });
3048
+ if (verb === "context") {
3049
+ return unwrap(await api.client.GET("/tutor/students/{studentId}/prompt-memory", {
3050
+ params: { path: { studentId } },
3051
+ }));
3052
+ }
3053
+ if (verb === "log") {
3054
+ const date = flagString(parsed, "date", { required: true });
3055
+ return unwrap(await api.client.GET("/tutor/students/{studentId}/prompt-memory/log", {
3056
+ params: { path: { studentId }, query: { date } },
3057
+ }));
3058
+ }
3059
+ throw new CliError("invalid_arguments", "Use memories context|log.");
3060
+ }
3061
+ if (noun === "rocky") {
3062
+ const studentId = flagString(parsed, "student", { required: true });
3063
+ if (verb === "get") {
3064
+ return unwrap(await api.client.GET("/tutor/students/{studentId}/rocky-config", {
3065
+ params: { path: { studentId } },
3066
+ }));
3067
+ }
3068
+ if (verb === "set") {
3069
+ const patchFile = flagString(parsed, "patch-file", { required: true });
3070
+ const patch = (await readJsonFile(patchFile, "Rocky config patch file"));
3071
+ const expected = flagString(parsed, "expected-updated-at", {
3072
+ required: true,
3073
+ });
3074
+ const expectedUpdatedAt = expected === "none" ? null : expected;
3075
+ if (expectedUpdatedAt !== null &&
3076
+ Number.isNaN(Date.parse(expectedUpdatedAt))) {
3077
+ throw new CliError("invalid_arguments", "--expected-updated-at must be an ISO datetime from `rocky get`, or `none`.");
3078
+ }
3079
+ return writeCommand(parsed, {
3080
+ action: "change Rocky's configuration for a managed student",
3081
+ target: { studentUserId: studentId },
3082
+ request: {
3083
+ patchFile: path.resolve(patchFile),
3084
+ expectedUpdatedAt,
3085
+ patch,
3086
+ },
3087
+ }, async () => unwrap(await api.client.PATCH("/tutor/students/{studentId}/rocky-config", {
3088
+ params: { path: { studentId } },
3089
+ body: { expectedUpdatedAt, patch },
3090
+ })));
3091
+ }
3092
+ throw new CliError("invalid_arguments", "Use rocky get|set.");
3093
+ }
3094
+ if (noun === "goals" && verb === "pdf") {
3095
+ const action = positional(parsed, 2, "goals pdf action");
3096
+ if (action !== "upload") {
3097
+ throw new CliError("invalid_arguments", "Use goals pdf upload.");
3098
+ }
3099
+ const studentId = flagString(parsed, "student", { required: true });
3100
+ const goalId = flagString(parsed, "goal");
3101
+ const draftSlug = flagString(parsed, "draft");
3102
+ if (Boolean(goalId) === Boolean(draftSlug)) {
3103
+ throw new CliError("invalid_arguments", "Pass exactly one of --goal or --draft for goals pdf upload.");
3104
+ }
3105
+ const sourceFile = flagString(parsed, "source-file", { required: true });
3106
+ const absolutePath = path.resolve(sourceFile);
3107
+ const fileStat = await fs.lstat(absolutePath).catch(() => null);
3108
+ if (!fileStat?.isFile() || fileStat.isSymbolicLink()) {
3109
+ throw new CliError("invalid_arguments", `--source-file is not a file: ${absolutePath}`);
3110
+ }
3111
+ if (!absolutePath.toLowerCase().endsWith(".pdf")) {
3112
+ throw new CliError("invalid_arguments", "goals pdf upload accepts PDF files only.");
3113
+ }
3114
+ if (fileStat.size <= 0 || fileStat.size > GOAL_PDF_UPLOAD_MAX_BYTES) {
3115
+ throw new CliError("invalid_arguments", `PDF size must be between 1 byte and ${GOAL_PDF_UPLOAD_MAX_BYTES} bytes.`);
3116
+ }
3117
+ const fileName = path.basename(absolutePath);
3118
+ const workspacePath = flagString(parsed, "path") ?? `uploads/${safeGoalUploadName(fileName)}`;
3119
+ if (!workspacePath.startsWith("uploads/")) {
3120
+ throw new CliError("invalid_arguments", "Textbook PDFs must be attached under uploads/.");
3121
+ }
3122
+ const sourceSha256 = await sha256File(absolutePath);
3123
+ const target = goalId
3124
+ ? { kind: "goal", goalId }
3125
+ : { kind: "draft", draftSlug: draftSlug };
3126
+ const message = flagString(parsed, "message") ??
3127
+ `recess-cli: attach textbook PDF ${fileName}`;
3128
+ const placeholderManifest = JSON.stringify({
3129
+ kind: "pending-pdf-upload",
3130
+ fileName,
3131
+ contentType: "application/pdf",
3132
+ sizeBytes: fileStat.size,
3133
+ });
3134
+ const body = {
3135
+ studentUserId: studentId,
3136
+ target,
3137
+ message,
3138
+ files: [
3139
+ {
3140
+ path: workspacePath,
3141
+ content: placeholderManifest,
3142
+ contentEncoding: "utf8",
3143
+ },
3144
+ ],
3145
+ };
3146
+ const preflight = unwrap(await api.client.POST("/tutor/browser/mesa/workspace-files", {
3147
+ body: { ...body, dryRun: true },
3148
+ }));
3149
+ if (preflight.action !== "preview_workspace_files_write") {
3150
+ throw new CliError("unexpected_response", "Goal PDF upload did not return a preview; nothing was uploaded.");
3151
+ }
3152
+ requirePreviewBoundConfirmation(parsed, {
3153
+ action: "upload a textbook PDF and attach it to a student's goal",
3154
+ target: preflight.target,
3155
+ request: {
3156
+ sourceFile: absolutePath,
3157
+ sourceSha256,
3158
+ sizeBytes: fileStat.size,
3159
+ workspacePath,
3160
+ message,
3161
+ },
3162
+ details: {
3163
+ currentChangeId: preflight.currentChangeId,
3164
+ note: "The PDF is stored separately and the goal workspace receives a small verified reference, matching planner-authored textbook goals.",
3165
+ },
3166
+ });
3167
+ const conversationId = randomUUID();
3168
+ const signed = unwrap(await api.client.POST("/os-v2-mesa/planner/uploads/sign", {
3169
+ body: {
3170
+ conversationId,
3171
+ studentUserId: studentId,
3172
+ fileName,
3173
+ contentType: "application/pdf",
3174
+ sizeBytes: fileStat.size,
3175
+ },
3176
+ }));
3177
+ let uploaded;
3178
+ try {
3179
+ const uploadedSource = await uploadPdfToSignedUrls(absolutePath, "application/pdf", signed);
3180
+ if (uploadedSource.sha256 !== sourceSha256 ||
3181
+ uploadedSource.sizeBytes !== fileStat.size) {
3182
+ throw new CliError("source_changed", "The source PDF changed after approval. The upload was discarded; preview the current file again.");
3183
+ }
3184
+ uploaded =
3185
+ signed.kind === "multipart"
3186
+ ? unwrap(await api.client.POST("/os-v2-mesa/planner/uploads/complete", {
3187
+ body: {
3188
+ conversationId,
3189
+ fileName,
3190
+ contentType: "application/pdf",
3191
+ key: signed.key,
3192
+ uploadId: signed.uploadId,
3193
+ sizeBytes: fileStat.size,
3194
+ },
3195
+ }))
3196
+ : unwrap(await api.client.POST("/os-v2-mesa/planner/uploads/verify", {
3197
+ body: {
3198
+ conversationId,
3199
+ fileName,
3200
+ contentType: "application/pdf",
3201
+ key: signed.key,
3202
+ sizeBytes: fileStat.size,
3203
+ },
3204
+ }));
3205
+ }
3206
+ catch (error) {
3207
+ // A failed multipart complete may already have materialized the object,
3208
+ // so attempt both abort and delete. These are compensation attempts; the
3209
+ // original upload error remains authoritative.
3210
+ await cleanupPlannerUpload(api, conversationId, signed, true);
3211
+ throw error;
3212
+ }
3213
+ const manifest = JSON.stringify({
3214
+ kind: "r2-pdf",
3215
+ version: 2,
3216
+ r2Key: uploaded.key,
3217
+ signature: uploaded.manifestSignature,
3218
+ fileName,
3219
+ contentType: uploaded.contentType,
3220
+ sizeBytes: uploaded.sizeBytes,
3221
+ });
3222
+ let workspace;
3223
+ try {
3224
+ workspace = unwrap(await api.client.POST("/tutor/browser/mesa/workspace-files", {
3225
+ body: {
3226
+ ...body,
3227
+ files: [
3228
+ {
3229
+ path: workspacePath,
3230
+ content: manifest,
3231
+ contentEncoding: "utf8",
3232
+ },
3233
+ ],
3234
+ dryRun: false,
3235
+ expectedChangeId: preflight.currentChangeId,
3236
+ },
3237
+ }));
3238
+ }
3239
+ catch (error) {
3240
+ // Only a returned 4xx from this route is a definitive no-write response.
3241
+ // A transport/5xx failure is ambiguous: the workspace commit may have landed,
3242
+ // so keep the object rather than creating a dangling manifest.
3243
+ const status = error instanceof CliError &&
3244
+ error.details &&
3245
+ typeof error.details === "object" &&
3246
+ "status" in error.details
3247
+ ? Number(error.details.status)
3248
+ : 0;
3249
+ if (status >= 400 && status < 500) {
3250
+ await cleanupPlannerUpload(api, conversationId, signed, true);
3251
+ }
3252
+ throw error;
3253
+ }
3254
+ return {
3255
+ action: "upload_textbook_pdf",
3256
+ sourceSha256,
3257
+ fileName,
3258
+ sizeBytes: uploaded.sizeBytes,
3259
+ workspacePath,
3260
+ workspace,
3261
+ };
3262
+ }
3263
+ if (noun === "goals" && verb === "files") {
3264
+ const action = positional(parsed, 2, "goals files action");
2792
3265
  const studentId = flagString(parsed, "student", { required: true });
2793
3266
  if (action === "list") {
2794
3267
  const goalId = flagString(parsed, "goal", { required: true });
@@ -2807,9 +3280,9 @@ export async function runCommand(argv) {
2807
3280
  const goalId = flagString(parsed, "goal");
2808
3281
  const draftSlug = flagString(parsed, "draft");
2809
3282
  if (Boolean(goalId) === Boolean(draftSlug)) {
2810
- throw new CliError("invalid_arguments", "Pass exactly one of --goal or --draft for mesa files write.");
3283
+ throw new CliError("invalid_arguments", "Pass exactly one of --goal or --draft for goals files write.");
2811
3284
  }
2812
- const input = await readMesaWriteSource(parsed);
3285
+ const input = await readGoalWorkspaceWriteSource(parsed);
2813
3286
  const target = goalId
2814
3287
  ? { kind: "goal", goalId }
2815
3288
  : { kind: "draft", draftSlug: draftSlug };
@@ -2821,14 +3294,14 @@ export async function runCommand(argv) {
2821
3294
  message,
2822
3295
  files: input.files,
2823
3296
  };
2824
- const preflight = unwrap(await api.client.POST("/admin/mesa/workspace-files", {
3297
+ const preflight = unwrap(await api.client.POST("/tutor/browser/mesa/workspace-files", {
2825
3298
  body: { ...body, dryRun: true },
2826
3299
  }));
2827
3300
  if (preflight.action !== "preview_workspace_files_write") {
2828
- throw new CliError("unexpected_response", "Mesa workspace write did not return a preview; nothing was written.");
3301
+ throw new CliError("unexpected_response", "Goal workspace write did not return a preview; nothing was written.");
2829
3302
  }
2830
3303
  const preview = {
2831
- action: "batch-upsert files in a student's Mesa workspace",
3304
+ action: "batch-upsert files in a student's goal workspace",
2832
3305
  target: preflight.target,
2833
3306
  request: {
2834
3307
  source: input.source,
@@ -2845,8 +3318,8 @@ export async function runCommand(argv) {
2845
3318
  : "Live goal modules/ and state/ are blocked here because they have database projections; use a draft + template capture for structural course changes.",
2846
3319
  },
2847
3320
  };
2848
- requireConfirmation(hasFlag(parsed, "confirm"), preview);
2849
- return unwrap(await api.client.POST("/admin/mesa/workspace-files", {
3321
+ requirePreviewBoundConfirmation(parsed, preview);
3322
+ return unwrap(await api.client.POST("/tutor/browser/mesa/workspace-files", {
2850
3323
  body: {
2851
3324
  ...body,
2852
3325
  dryRun: false,
@@ -2854,7 +3327,7 @@ export async function runCommand(argv) {
2854
3327
  },
2855
3328
  }));
2856
3329
  }
2857
- throw new CliError("invalid_arguments", "Use mesa files list|read|write.");
3330
+ throw new CliError("invalid_arguments", "Use goals files list|read|write.");
2858
3331
  }
2859
3332
  if (noun === "request" && verb === "get") {
2860
3333
  return api.rawGet(positional(parsed, 2, "request path"));