recess-cli 1.9.2 → 2.0.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,21 +1,38 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
+ import { execFile } from "node:child_process";
2
3
  import fs from "node:fs/promises";
3
4
  import path from "node:path";
4
- import { RecessAdminApi, unwrap } from "./api.js";
5
+ import { RecessAdminApi, unwrap, withIdempotencyContext } from "./api.js";
5
6
  import { flagList, flagNumber, flagString, hasFlag, parseArgs, } from "./args.js";
6
7
  import { login, pollDeviceAuth, requestDeviceAuth } from "./auth.js";
7
- import { clearStoredSession, resolveConfig, } from "./config.js";
8
+ import { clearStoredSession, deleteProfile, listProfiles, resolveConfig, saveProfile, useProfile, } from "./config.js";
9
+ import { agentContext, buildCommandSchema, scopedHelp, validateInvocation, } from "./command-schema.js";
8
10
  import { CliError } from "./errors.js";
11
+ import { listFeedback, submitFeedback } from "./feedback.js";
12
+ import { cliRequestHeaders, requireCliRequestReason } from "./http.js";
9
13
  import { requireConfirmation } from "./safety.js";
10
14
  import { installSkill, isEphemeralInstall, readBundledSkillVersion, readCliVersion, } from "./setup.js";
11
15
  import { compareVersions, updateSkillFromServer } from "./skill-update.js";
12
16
  import { readSkillCache, writeSkillCache } from "./skills-cache.js";
17
+ import { appendJobEvent, getJob, listJobs, pruneJobs } from "./jobs.js";
13
18
  export const HELP = `recess — safe Recess administration and family AI tools
14
19
 
15
20
  Usage:
16
21
  recess [--json] --version
22
+ recess [--json] agent-context
17
23
  recess [--json] setup [--skill-only]
18
24
  recess [--json] doctor
25
+ recess [--json] profile list
26
+ recess [--json] profile show <name>
27
+ recess [--json] profile save <name> [--api-origin URL] [--web-origin URL]
28
+ [--oauth-client-id ID]
29
+ recess [--json] profile use <name>
30
+ recess [--json] profile delete <name>
31
+ recess [--json] jobs list [--limit 20]
32
+ recess [--json] jobs get <operation-key>
33
+ recess [--json] jobs prune [--older-than-days 30]
34
+ recess [--json] feedback list [--limit 20]
35
+ recess [--json] feedback submit <text> [--confirm]
19
36
  recess [--json] auth login [--client-id ID] [--callback-port 8765]
20
37
  recess [--json] auth request [--label TEXT]
21
38
  recess [--json] auth poll [--timeout 300]
@@ -53,7 +70,8 @@ Usage:
53
70
  [--family <family-id>] [--unassign <kid-id>] (repeat) [--note TEXT] [--confirm]
54
71
  recess [--json] cohorts search <query>
55
72
  recess [--json] enrollments create --user <kid-id> --cohort <id>
56
- [--first-charge-at ISO_DATETIME] [--send-email] [--force] [--confirm]
73
+ [--first-charge-at ISO_DATETIME] [--send-email] [--force]
74
+ [--confirm --approval-token TOKEN]
57
75
  recess [--json] enrollments register-cohort --enrollment <id>
58
76
  --user <id> --cohort <id> [--confirm]
59
77
  recess [--json] enrollments unregister-cohort --user <id>
@@ -65,6 +83,7 @@ Usage:
65
83
  [--immediate] [--reason TEXT] [--restore] [--confirm]
66
84
  recess [--json] payout payruns list [--status A,B] [--schedule <id>]
67
85
  recess [--json] payout recipients list [--search <name>] [--user <id>] [--id <id>]
86
+ [--limit 20] [--cursor <id>]
68
87
  recess [--json] payout invoices list [--payrun <id>] [--recipient <account-id>]
69
88
  [--user <id>] [--status A,B] (at least one filter)
70
89
  recess [--json] payout invoices get <invoice-id>
@@ -132,11 +151,11 @@ Usage:
132
151
  recess [--json] content-library search <query> [--limit 8]
133
152
  recess [--json] content-library status <gem-id-or-url>
134
153
  recess [--json] content-library set-stage <gem-id-or-url...> [--file <path>]
135
- --stage review|polishing|live|archived [--confirm]
154
+ --stage review|polishing|live|archived [--wait] [--timeout 900] [--confirm]
136
155
  recess [--json] content-library submit <url...> [--file <path>]
137
156
  [--stage review|polish] [--title TEXT] [--summary TEXT]
138
157
  [--lane web-toys|mechanics|explorables|data-stories|sims|maps-scale|sound-art|puzzles|wonder|idea-games]
139
- [--confirm]
158
+ [--wait] [--timeout 900] [--confirm]
140
159
  recess [--json] skills guardian list [--query TEXT] [--category TEXT]
141
160
  recess [--json] skills guardian get <skill-name>
142
161
  [--reference NAME | --all-references] [--refresh]
@@ -146,12 +165,13 @@ Usage:
146
165
  [--refresh]
147
166
  recess [--json] goal-templates list [--query TEXT] [--category TEXT]
148
167
  [--kind SIMPLE|BLUEPRINT] [--starter-only] [--include-deleted]
168
+ [--limit 20] [--cursor <id>]
149
169
  recess [--json] goal-templates get <template-id|slug> [--spec-only]
150
170
  recess [--json] goal-templates versions <template-id> [--version N]
151
171
  recess [--json] goal-templates validate-spec --file <path/template.json>
152
172
  recess [--json] goal-templates create --file <path/template.json> [--confirm]
153
173
  recess [--json] goal-templates patch-spec <template-id|slug> --expected-version N
154
- --patches-file <path/patches.json> [--confirm]
174
+ --patches-file <path/patches.json> [--confirm --approval-token TOKEN]
155
175
  [--confirm-destructive-changes --destructive-change-token TOKEN]
156
176
  recess [--json] goal-templates set-metadata <template-id> --expected-version N
157
177
  [--title TEXT] [--description TEXT] [--emoji X] [--category TEXT] [--tags A,B]
@@ -162,20 +182,23 @@ Usage:
162
182
  recess [--json] goal-templates delete <template-id> --expected-version N [--confirm]
163
183
  recess [--json] goal-templates snapshot-files <template-id> [--path P]
164
184
  recess [--json] goal-templates capture-snapshot <template-id|slug>
165
- (--source-goal <goal-id> | --source-draft <draft-slug> --student <goal-owner-id>)
166
- [--dry-run] [--confirm]
185
+ (--source-goal <goal-id> | --source-draft <draft-slug> --student <goal-owner-id>
186
+ | --source-dir <local-checkout>)
187
+ [--dry-run] [--confirm --approval-token TOKEN]
167
188
  recess [--json] goal-templates apply <template-id> --answers-file <path>
168
189
  [--dry-run] [--confirm --approval-token TOKEN]
169
190
  recess [--json] goal-templates apply-starter <template-id> --student <kid-id>
170
191
  [--answers-file <path>] [--confirm]
171
192
  recess [--json] goals list --student <kid-id>
172
- recess [--json] goals create --student <kid-id> --title TEXT
193
+ recess [--json] goals create [--student <kid-id>] --title TEXT
173
194
  (--description TEXT | --description-file <path>) [--target-date <iso>]
174
- [--schedule TEXT] [--draft <draft-slug>
195
+ [--schedule TEXT] [(--draft <draft-slug> | --source-dir <local-checkout>)
175
196
  (--enable-applet-follow-ups | --disable-applet-follow-ups)]
176
197
  [--confirm --approval-token TOKEN]
177
198
  recess [--json] goals edit <goal-id> --student <kid-id> --patch-file <path/patch.json>
178
199
  --delta TEXT [--confirm --approval-token TOKEN]
200
+ recess [--json] goals delete <goal-id> --student <kid-id>
201
+ [--confirm --approval-token TOKEN]
179
202
  recess [--json] goals queue get <goal-id> --student <kid-id>
180
203
  recess [--json] goals queue set <goal-id> --student <kid-id>
181
204
  --entries-file <path.json> --delta TEXT [--replace-description-pointer]
@@ -184,6 +207,8 @@ Usage:
184
207
  [--due-date YYYY-MM-DD] [--estimated-minutes N] [--url URL] [--confirm]
185
208
  recess [--json] todos edit <todo-id> --patch-file <path/patch.json>
186
209
  [--confirm --approval-token TOKEN]
210
+ recess [--json] todos delete <todo-id>
211
+ [--confirm --approval-token TOKEN]
187
212
  recess [--json] todos generate-applet <todo-id> --student <kid-id>
188
213
  [--due-date YYYY-MM-DD] [--confirm --approval-token TOKEN]
189
214
  recess [--json] memories context --student <kid-id>
@@ -193,6 +218,13 @@ Usage:
193
218
  --expected-updated-at ISO|none [--confirm]
194
219
  recess [--json] goals files list --student <goal-owner-id> --goal <goal-id>
195
220
  recess [--json] goals files read --student <goal-owner-id> --goal <goal-id> --path P
221
+ recess [--json] goals files init --student <goal-owner-id> --draft <draft-slug>
222
+ --output-dir <local-dir>
223
+ recess [--json] goals files checkout --student <goal-owner-id>
224
+ (--goal <goal-id> | --draft <draft-slug>)
225
+ --output-dir <local-dir>
226
+ recess [--json] goals files push --source-dir <local-checkout>
227
+ [--message TEXT] [--confirm --approval-token TOKEN]
196
228
  recess [--json] goals files write --student <goal-owner-id>
197
229
  (--goal <goal-id> | --draft <draft-slug>)
198
230
  (--source-dir <local-dir> | --source-file <local-file> --path P)
@@ -211,10 +243,10 @@ standard. Responses cache under ~/.recess-cli/skills-cache/ (--refresh re-fetche
211
243
  For goal workspace and PDF commands, --student names the goal owner. A
212
244
  full_admin session may use a KID or ADMIN user id, including its own ADMIN id;
213
245
  family_ai sessions remain limited to managed KID profiles.
214
- "goals create --draft" materializes that validated draft directly as a personal
215
- module-backed goal; it does not create or apply a reusable template. Its preview
216
- is bound to the exact workspace revision, hash, module inventory, and applet
217
- follow-up choice.
246
+ "goals create --source-dir" materializes a clean, fully pushed draft checkout as
247
+ a personal module-backed goal and retargets that checkout to the resulting live
248
+ goal. It does not create or apply a reusable template. Its preview is bound to
249
+ the exact workspace revision, hash, module inventory, and applet follow-up choice.
218
250
  Every template created here is setupMode DETERMINISTIC_WORKFLOW and CANNOT be
219
251
  converted back, so "validate-spec" against the same file until it passes, then
220
252
  "create". "create" runs a real server-side validation before its gate, so the
@@ -261,8 +293,14 @@ bundled copy, and an unreachable server is not an error. "doctor" reports whethe
261
293
  a newer skill exists and names the command; it never writes.
262
294
 
263
295
  Writes preview and exit 2 unless --confirm is supplied after explicit human approval.
296
+ Every preview includes an operationKey; confirmed writes must echo it with
297
+ --operation-key so an interrupted invocation can be retried without duplicating the write.
264
298
  Environment overrides: RECESS_CLI_API_ORIGIN, RECESS_CLI_WEB_ORIGIN,
265
- RECESS_CLI_OAUTH_CLIENT_ID, RECESS_CLI_COOKIE, RECESS_CLI_CONFIG.`;
299
+ RECESS_CLI_OAUTH_CLIENT_ID, RECESS_CLI_COOKIE, RECESS_CLI_CONFIG,
300
+ RECESS_CLI_PROFILE, RECESS_CLI_FEEDBACK_ENDPOINT.
301
+
302
+ Every command that calls the Recess API, except auth commands, requires
303
+ --reason TEXT: a non-empty human-readable purpose of at most 1024 characters.`;
266
304
  function positional(parsed, index, label) {
267
305
  const value = parsed.positionals[index];
268
306
  if (!value) {
@@ -270,6 +308,9 @@ function positional(parsed, index, label) {
270
308
  }
271
309
  return value;
272
310
  }
311
+ function requiredRequestReason(parsed) {
312
+ return requireCliRequestReason(flagString(parsed, "reason", { required: true }));
313
+ }
273
314
  async function sessionStatus(config) {
274
315
  if (!config.sessionCookie) {
275
316
  return {
@@ -291,18 +332,20 @@ async function sessionStatus(config) {
291
332
  }
292
333
  return { ...result.data, authSource: config.authSource };
293
334
  }
294
- async function doctor(config) {
335
+ async function doctor(config, reason) {
295
336
  const checks = {
296
337
  config: {
297
338
  path: config.configPath,
298
339
  apiOrigin: config.apiOrigin,
299
340
  webOrigin: config.webOrigin,
300
341
  oauthClientIdConfigured: Boolean(config.oauthClientId),
342
+ profile: config.profileName ?? null,
301
343
  },
302
344
  auth: await sessionStatus(config),
303
345
  };
304
346
  try {
305
347
  const response = await fetch(new URL("/health", config.apiOrigin), {
348
+ headers: cliRequestHeaders(undefined, reason),
306
349
  signal: AbortSignal.timeout(5000),
307
350
  });
308
351
  checks.api = { reachable: response.ok, status: response.status };
@@ -313,7 +356,7 @@ async function doctor(config) {
313
356
  message: error instanceof Error ? error.message : String(error),
314
357
  };
315
358
  }
316
- checks.skill = await skillStatus(config);
359
+ checks.skill = await skillStatus(config, reason);
317
360
  return checks;
318
361
  }
319
362
  /**
@@ -322,7 +365,7 @@ async function doctor(config) {
322
365
  * silently rewrote `~/.claude/skills` mid-session would change what the agent is
323
366
  * reading out from under it. It names the command instead.
324
367
  */
325
- async function skillStatus(config) {
368
+ async function skillStatus(config, reason) {
326
369
  const cliVersion = await readCliVersion();
327
370
  const installedVersion = await readBundledSkillVersion();
328
371
  const base = { cliVersion, bundledSkillVersion: installedVersion };
@@ -331,7 +374,7 @@ async function skillStatus(config) {
331
374
  }
332
375
  try {
333
376
  const response = await fetch(new URL("/auth/admin-cli/skill/", config.apiOrigin), {
334
- headers: { cookie: config.sessionCookie },
377
+ headers: cliRequestHeaders({ cookie: config.sessionCookie }, reason),
335
378
  signal: AbortSignal.timeout(5000),
336
379
  });
337
380
  if (!response.ok) {
@@ -366,30 +409,433 @@ async function skillStatus(config) {
366
409
  }
367
410
  }
368
411
  async function writeCommand(parsed, preview, execute) {
369
- requireConfirmation(hasFlag(parsed, "confirm"), preview);
370
- return execute();
412
+ const fingerprint = approvalTokenFor(preview);
413
+ const suppliedOperationKey = flagString(parsed, "operation-key");
414
+ if (!hasFlag(parsed, "confirm")) {
415
+ const operationKey = operationKeyFor(fingerprint);
416
+ const boundPreview = {
417
+ ...preview,
418
+ details: {
419
+ ...preview.details,
420
+ operationKey,
421
+ retry: "Rerun the unchanged command with --confirm --operation-key <operationKey>. Reuse that same key after an interrupted invocation.",
422
+ },
423
+ };
424
+ await appendJobEvent({
425
+ jobId: operationKey,
426
+ status: "awaiting_confirmation",
427
+ timestamp: new Date().toISOString(),
428
+ action: preview.action,
429
+ fingerprint,
430
+ target: preview.target,
431
+ });
432
+ requireConfirmation(false, boundPreview);
433
+ }
434
+ if (!suppliedOperationKey) {
435
+ throw new CliError("confirmation_required", "A confirmed write requires the operation key from its approved preview.", 2, { preview, requiredFlag: "--operation-key" });
436
+ }
437
+ assertOperationKeyMatchesPreview(suppliedOperationKey, fingerprint, preview);
438
+ await appendJobEvent({
439
+ jobId: suppliedOperationKey,
440
+ status: "running",
441
+ timestamp: new Date().toISOString(),
442
+ action: preview.action,
443
+ fingerprint,
444
+ target: preview.target,
445
+ });
446
+ try {
447
+ const result = await withIdempotencyContext({ operationKey: suppliedOperationKey, fingerprint }, execute);
448
+ await appendJobEvent({
449
+ jobId: suppliedOperationKey,
450
+ status: "completed",
451
+ timestamp: new Date().toISOString(),
452
+ action: preview.action,
453
+ fingerprint,
454
+ target: preview.target,
455
+ result,
456
+ });
457
+ return result;
458
+ }
459
+ catch (error) {
460
+ await appendJobEvent({
461
+ jobId: suppliedOperationKey,
462
+ status: "unknown",
463
+ timestamp: new Date().toISOString(),
464
+ action: preview.action,
465
+ fingerprint,
466
+ target: preview.target,
467
+ message: error instanceof Error ? error.message : String(error),
468
+ });
469
+ throw error;
470
+ }
371
471
  }
372
472
  const GOAL_WORKSPACE_WRITE_MAX_FILES = 1_000;
373
473
  const GOAL_WORKSPACE_WRITE_MAX_TOTAL_BYTES = 20 * 1024 * 1024;
374
474
  const GOAL_PDF_UPLOAD_MAX_BYTES = 1024 * 1024 * 1024;
375
475
  const GOAL_QUEUE_MAX_ENTRIES = 500;
376
476
  const URL_QUEUE_DESCRIPTION_POINTER = "Skill queue managed by the system.";
477
+ const RECESS_GIT_METADATA = "recess-workspace.json";
478
+ async function runGit(cwd, args, options = {}) {
479
+ return new Promise((resolve, reject) => {
480
+ execFile("git", ["-C", cwd, ...args], {
481
+ encoding: "utf8",
482
+ maxBuffer: 32 * 1024 * 1024,
483
+ env: options.identity
484
+ ? {
485
+ ...process.env,
486
+ GIT_AUTHOR_NAME: "Recess CLI",
487
+ GIT_AUTHOR_EMAIL: "cli@recess.gg",
488
+ GIT_COMMITTER_NAME: "Recess CLI",
489
+ GIT_COMMITTER_EMAIL: "cli@recess.gg",
490
+ }
491
+ : process.env,
492
+ }, (error, stdout, stderr) => {
493
+ if (error) {
494
+ reject(new CliError("git_failed", `Git command failed: git ${args.join(" ")} (${stderr.trim() || error.message})`));
495
+ return;
496
+ }
497
+ resolve(stdout);
498
+ });
499
+ });
500
+ }
501
+ async function ensureLocalGitIdentity(root) {
502
+ for (const [key, fallback] of [
503
+ ["user.name", "Recess CLI"],
504
+ ["user.email", "cli@recess.gg"],
505
+ ]) {
506
+ let configured = "";
507
+ try {
508
+ configured = (await runGit(root, ["config", "--get", key])).trim();
509
+ }
510
+ catch {
511
+ // A missing inherited value exits 1. Install a repo-local fallback so
512
+ // the checkout can be committed without changing the user's global Git.
513
+ }
514
+ if (!configured)
515
+ await runGit(root, ["config", key, fallback]);
516
+ }
517
+ }
518
+ function resolveWorkspaceCheckoutPath(root, workspacePath) {
519
+ const normalized = workspacePath.replaceAll("\\", "/");
520
+ const segments = normalized.split("/");
521
+ const isGitMetadataSegment = (segment) => {
522
+ // NTFS canonicalizes trailing dots/spaces, supports alternate data streams,
523
+ // and may expose .git through an 8.3 alias such as GIT~1. Reject those
524
+ // spellings too so a checkout cannot write Git metadata before `git init`.
525
+ const ntfsName = segment
526
+ .split(":", 1)[0]
527
+ .replace(/[ .]+$/g, "")
528
+ .toLowerCase();
529
+ return ntfsName === ".git" || /^git~\d+$/.test(ntfsName);
530
+ };
531
+ if (!normalized ||
532
+ path.posix.isAbsolute(normalized) ||
533
+ segments.some((segment) => !segment ||
534
+ segment === "." ||
535
+ segment === ".." ||
536
+ isGitMetadataSegment(segment))) {
537
+ throw new CliError("invalid_workspace", `Refusing unsafe workspace path from Mesa: ${workspacePath}`);
538
+ }
539
+ const target = path.resolve(root, ...segments);
540
+ if (!target.startsWith(`${root}${path.sep}`)) {
541
+ throw new CliError("invalid_workspace", `Refusing workspace path outside checkout: ${workspacePath}`);
542
+ }
543
+ return target;
544
+ }
545
+ function recessGitMetadataPath(root) {
546
+ return path.join(root, ".git", RECESS_GIT_METADATA);
547
+ }
548
+ async function writeRecessGitMetadata(root, metadata) {
549
+ const destination = recessGitMetadataPath(root);
550
+ const temporary = `${destination}.${process.pid}.tmp`;
551
+ await fs.writeFile(temporary, `${JSON.stringify(metadata, null, 2)}\n`, {
552
+ encoding: "utf8",
553
+ mode: 0o600,
554
+ });
555
+ await fs.rename(temporary, destination);
556
+ }
557
+ async function readRecessGitMetadata(sourceDirectory) {
558
+ const requestedRoot = path.resolve(sourceDirectory);
559
+ const requestedRealRoot = await fs.realpath(requestedRoot).catch(() => null);
560
+ if (!requestedRealRoot) {
561
+ throw new CliError("invalid_checkout", `Recess checkout does not exist: ${requestedRoot}`);
562
+ }
563
+ const root = (await runGit(requestedRoot, ["rev-parse", "--show-toplevel"])).trim();
564
+ const realRoot = await fs.realpath(root);
565
+ if (realRoot !== requestedRealRoot) {
566
+ throw new CliError("invalid_checkout", `--source-dir must be the Recess checkout root: ${root}`);
567
+ }
568
+ let parsed;
569
+ try {
570
+ parsed = JSON.parse(await fs.readFile(recessGitMetadataPath(root), "utf8"));
571
+ }
572
+ catch {
573
+ throw new CliError("invalid_checkout", `${root} is not a Recess goal workspace checkout.`);
574
+ }
575
+ const value = parsed;
576
+ const candidateTarget = value.target;
577
+ const target = value.schemaVersion === 1 && typeof value.goalId === "string"
578
+ ? { kind: "goal", goalId: value.goalId }
579
+ : candidateTarget;
580
+ if (![1, 2].includes(typeof value.schemaVersion === "number" ? value.schemaVersion : 0) ||
581
+ typeof value.studentId !== "string" ||
582
+ !target ||
583
+ (target.kind !== "goal" && target.kind !== "draft") ||
584
+ (target.kind === "goal" && typeof target.goalId !== "string") ||
585
+ (target.kind === "draft" && typeof target.draftSlug !== "string") ||
586
+ typeof value.changeId !== "string" ||
587
+ typeof value.upstreamCommit !== "string") {
588
+ throw new CliError("invalid_checkout", `${root} has invalid Recess workspace metadata.`);
589
+ }
590
+ return {
591
+ root: realRoot,
592
+ metadata: {
593
+ schemaVersion: 2,
594
+ studentId: value.studentId,
595
+ target: target,
596
+ changeId: value.changeId,
597
+ upstreamCommit: value.upstreamCommit,
598
+ },
599
+ };
600
+ }
601
+ async function checkoutGoalWorkspace(input) {
602
+ const outputDirectory = path.resolve(input.outputDirectory);
603
+ if (await fs.lstat(outputDirectory).catch(() => null)) {
604
+ throw new CliError("destination_exists", `Checkout destination already exists: ${outputDirectory}`);
605
+ }
606
+ if (input.snapshot.files.length > GOAL_WORKSPACE_WRITE_MAX_FILES) {
607
+ throw new CliError("workspace_too_large", `Workspace has more than ${GOAL_WORKSPACE_WRITE_MAX_FILES} files.`);
608
+ }
609
+ const sizeBytes = input.snapshot.files.reduce((total, file) => total + file.sizeBytes, 0);
610
+ if (sizeBytes > GOAL_WORKSPACE_WRITE_MAX_TOTAL_BYTES) {
611
+ throw new CliError("workspace_too_large", `Workspace exceeds ${GOAL_WORKSPACE_WRITE_MAX_TOTAL_BYTES} decoded bytes.`);
612
+ }
613
+ const parent = path.dirname(outputDirectory);
614
+ await fs.mkdir(parent, { recursive: true });
615
+ const temporary = await fs.mkdtemp(path.join(parent, ".recess-checkout-"));
616
+ let moved = false;
617
+ try {
618
+ for (const file of input.snapshot.files) {
619
+ const destination = resolveWorkspaceCheckoutPath(temporary, file.path);
620
+ const bytes = Buffer.from(file.content, file.contentEncoding === "base64" ? "base64" : "utf8");
621
+ const sha256 = createHash("sha256").update(bytes).digest("hex");
622
+ if (bytes.byteLength !== file.sizeBytes || sha256 !== file.sha256) {
623
+ throw new CliError("invalid_workspace", `Mesa snapshot integrity check failed for ${file.path}.`);
624
+ }
625
+ await fs.mkdir(path.dirname(destination), { recursive: true });
626
+ await fs.writeFile(destination, bytes);
627
+ }
628
+ await runGit(temporary, ["init", "--quiet", "--initial-branch=main"]);
629
+ await ensureLocalGitIdentity(temporary);
630
+ await runGit(temporary, ["add", "-A"]);
631
+ await runGit(temporary, [
632
+ "commit",
633
+ "--quiet",
634
+ "--allow-empty",
635
+ "-m",
636
+ `Checkout Recess ${input.target.kind} workspace at ${input.snapshot.changeId}`,
637
+ ], { identity: true });
638
+ const upstreamCommit = (await runGit(temporary, ["rev-parse", "HEAD"])).trim();
639
+ await writeRecessGitMetadata(temporary, {
640
+ schemaVersion: 2,
641
+ studentId: input.studentId,
642
+ target: input.target,
643
+ changeId: input.snapshot.changeId,
644
+ upstreamCommit,
645
+ });
646
+ await fs.rename(temporary, outputDirectory);
647
+ moved = true;
648
+ return {
649
+ directory: outputDirectory,
650
+ studentId: input.studentId,
651
+ target: input.target,
652
+ changeId: input.snapshot.changeId,
653
+ upstreamCommit,
654
+ fileCount: input.snapshot.files.length,
655
+ sizeBytes,
656
+ next: `Edit with normal file tools, inspect with git -C ${JSON.stringify(outputDirectory)} diff, commit locally, then run recess --json goals files push --source-dir ${JSON.stringify(outputDirectory)} --reason <purpose>.`,
657
+ };
658
+ }
659
+ finally {
660
+ if (!moved)
661
+ await fs.rm(temporary, { recursive: true, force: true });
662
+ }
663
+ }
664
+ async function readRemoteWorkspaceSnapshot(api, studentId, target) {
665
+ return unwrap(await api.client.GET("/tutor/browser/mesa/workspace-files", {
666
+ params: {
667
+ query: {
668
+ studentUserId: studentId,
669
+ ...(target.kind === "goal"
670
+ ? { goalId: target.goalId }
671
+ : { draftSlug: target.draftSlug }),
672
+ },
673
+ },
674
+ }));
675
+ }
676
+ async function assertCheckoutReadyForMaterialization(checkout) {
677
+ const status = await runGit(checkout.root, ["status", "--porcelain=v1"]);
678
+ if (status.trim()) {
679
+ throw new CliError("uncommitted_changes", "Commit or discard all local workspace changes before creating or capturing.");
680
+ }
681
+ const head = (await runGit(checkout.root, ["rev-parse", "HEAD"])).trim();
682
+ if (head !== checkout.metadata.upstreamCommit) {
683
+ throw new CliError("unpushed_changes", "Push every local workspace commit before creating a goal or capturing a template.");
684
+ }
685
+ return head;
686
+ }
687
+ async function readGoalWorkspaceGitChanges(root, base) {
688
+ const status = await runGit(root, ["status", "--porcelain=v1"]);
689
+ if (status.trim()) {
690
+ throw new CliError("uncommitted_changes", "Commit or discard all local workspace changes before pushing.");
691
+ }
692
+ const head = (await runGit(root, ["rev-parse", "HEAD"])).trim();
693
+ if (head === base) {
694
+ throw new CliError("no_changes", "There are no local commits to push.");
695
+ }
696
+ await runGit(root, ["merge-base", "--is-ancestor", base, head]);
697
+ const commitLines = (await runGit(root, [
698
+ "log",
699
+ "--reverse",
700
+ "--format=%H%x09%s",
701
+ `${base}..${head}`,
702
+ ]))
703
+ .trim()
704
+ .split("\n")
705
+ .filter(Boolean);
706
+ const commits = commitLines.map((line) => {
707
+ const tab = line.indexOf("\t");
708
+ return { id: line.slice(0, tab), subject: line.slice(tab + 1) };
709
+ });
710
+ const raw = await runGit(root, [
711
+ "diff",
712
+ "--name-status",
713
+ "--no-renames",
714
+ "-z",
715
+ `${base}..${head}`,
716
+ ]);
717
+ const fields = raw.split("\0").filter((field) => field.length > 0);
718
+ if (fields.length % 2 !== 0) {
719
+ throw new CliError("git_failed", "Git returned an invalid workspace diff.");
720
+ }
721
+ const changes = [];
722
+ let sizeBytes = 0;
723
+ for (let index = 0; index < fields.length; index += 2) {
724
+ const statusCode = fields[index][0];
725
+ const workspacePath = fields[index + 1];
726
+ resolveWorkspaceCheckoutPath(root, workspacePath);
727
+ if (statusCode === "D") {
728
+ changes.push({ path: workspacePath, action: "delete" });
729
+ continue;
730
+ }
731
+ if (!["A", "M", "T"].includes(statusCode)) {
732
+ throw new CliError("unsupported_git_change", `Unsupported Git change ${fields[index]} for ${workspacePath}.`);
733
+ }
734
+ const absolutePath = resolveWorkspaceCheckoutPath(root, workspacePath);
735
+ const stat = await fs.lstat(absolutePath).catch(() => null);
736
+ if (!stat?.isFile() || stat.isSymbolicLink()) {
737
+ throw new CliError("invalid_workspace", `Refusing non-file workspace entry: ${workspacePath}`);
738
+ }
739
+ const bytes = await fs.readFile(absolutePath);
740
+ sizeBytes += bytes.byteLength;
741
+ changes.push({
742
+ path: workspacePath,
743
+ action: "upsert",
744
+ content: bytes.toString("base64"),
745
+ contentEncoding: "base64",
746
+ sizeBytes: bytes.byteLength,
747
+ sha256: createHash("sha256").update(bytes).digest("hex"),
748
+ });
749
+ }
750
+ if (changes.length === 0) {
751
+ throw new CliError("no_changes", "Local commits do not change workspace file contents.");
752
+ }
753
+ if (changes.length > GOAL_WORKSPACE_WRITE_MAX_FILES) {
754
+ throw new CliError("workspace_too_large", `Push changes more than ${GOAL_WORKSPACE_WRITE_MAX_FILES} files.`);
755
+ }
756
+ if (sizeBytes > GOAL_WORKSPACE_WRITE_MAX_TOTAL_BYTES) {
757
+ throw new CliError("workspace_too_large", `Push exceeds ${GOAL_WORKSPACE_WRITE_MAX_TOTAL_BYTES} decoded bytes.`);
758
+ }
759
+ return { head, commits, changes, sizeBytes };
760
+ }
377
761
  function approvalTokenFor(preview) {
378
762
  return createHash("sha256").update(JSON.stringify(preview)).digest("hex");
379
763
  }
380
- function requirePreviewBoundConfirmation(parsed, preview) {
764
+ function operationKeyFor(fingerprint) {
765
+ return `op_${randomUUID().replaceAll("-", "")}_${fingerprint}`;
766
+ }
767
+ function assertOperationKeyMatchesPreview(operationKey, fingerprint, preview) {
768
+ if (!operationKey.endsWith(`_${fingerprint}`)) {
769
+ throw new CliError("approval_mismatch", "The operation key does not belong to this preview. Start from a new preview and obtain approval again.", 2, { preview, requiredFlag: "--operation-key" });
770
+ }
771
+ }
772
+ async function requirePreviewBoundConfirmation(parsed, preview) {
381
773
  const approvalToken = approvalTokenFor(preview);
774
+ const suppliedOperationKey = flagString(parsed, "operation-key");
382
775
  const boundPreview = {
383
776
  ...preview,
384
- details: { ...preview.details, approvalToken },
777
+ details: {
778
+ ...preview.details,
779
+ approvalToken,
780
+ operationKey: suppliedOperationKey ?? operationKeyFor(approvalToken),
781
+ },
385
782
  };
386
783
  if (!hasFlag(parsed, "confirm")) {
784
+ await appendJobEvent({
785
+ jobId: String(boundPreview.details.operationKey),
786
+ status: "awaiting_confirmation",
787
+ timestamp: new Date().toISOString(),
788
+ action: preview.action,
789
+ fingerprint: approvalToken,
790
+ target: preview.target,
791
+ });
387
792
  requireConfirmation(false, boundPreview);
388
793
  }
389
794
  const supplied = flagString(parsed, "approval-token");
390
795
  if (supplied !== approvalToken) {
391
796
  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" });
392
797
  }
798
+ if (!suppliedOperationKey) {
799
+ throw new CliError("confirmation_required", "A confirmed write requires the operation key from its approved preview.", 2, { preview: boundPreview, requiredFlag: "--operation-key" });
800
+ }
801
+ assertOperationKeyMatchesPreview(suppliedOperationKey, approvalToken, boundPreview);
802
+ await appendJobEvent({
803
+ jobId: suppliedOperationKey,
804
+ status: "running",
805
+ timestamp: new Date().toISOString(),
806
+ action: preview.action,
807
+ fingerprint: approvalToken,
808
+ target: preview.target,
809
+ });
810
+ return { operationKey: suppliedOperationKey, fingerprint: approvalToken };
811
+ }
812
+ async function previewBoundWrite(parsed, preview, execute) {
813
+ const context = await requirePreviewBoundConfirmation(parsed, preview);
814
+ try {
815
+ const result = await withIdempotencyContext(context, execute);
816
+ await appendJobEvent({
817
+ jobId: context.operationKey,
818
+ status: "completed",
819
+ timestamp: new Date().toISOString(),
820
+ action: preview.action,
821
+ fingerprint: context.fingerprint,
822
+ target: preview.target,
823
+ result,
824
+ });
825
+ return result;
826
+ }
827
+ catch (error) {
828
+ await appendJobEvent({
829
+ jobId: context.operationKey,
830
+ status: "unknown",
831
+ timestamp: new Date().toISOString(),
832
+ action: preview.action,
833
+ fingerprint: context.fingerprint,
834
+ target: preview.target,
835
+ message: error instanceof Error ? error.message : String(error),
836
+ });
837
+ throw error;
838
+ }
393
839
  }
394
840
  async function cleanupPlannerUpload(api, conversationId, signed, includeDelete) {
395
841
  if (signed.kind === "multipart") {
@@ -1025,7 +1471,7 @@ function optionalDocString(doc, key) {
1025
1471
  return value;
1026
1472
  }
1027
1473
  /**
1028
- * Coerce an authored template file into the exact `POST /admin/goal-templates/`
1474
+ * Coerce an authored template file into the exact `POST /ai/goal-templates/`
1029
1475
  * body. Every unknown key is dropped rather than forwarded, so a stale field
1030
1476
  * copied from an old export cannot ride along into a create, and the shape
1031
1477
  * failures an agent actually makes (missing slug, spec as a string, tags as a
@@ -1253,6 +1699,48 @@ function flagDateOnly(parsed, name) {
1253
1699
  return raw;
1254
1700
  }
1255
1701
  const DAY_MS = 24 * 60 * 60 * 1000;
1702
+ async function waitForContentLibraryLive(api, gems, timeoutSeconds) {
1703
+ const pending = new Set(gems);
1704
+ const latest = new Map();
1705
+ const deadline = Date.now() + timeoutSeconds * 1000;
1706
+ let delayMs = 2_000;
1707
+ while (pending.size > 0) {
1708
+ const targets = Array.from(pending);
1709
+ for (let index = 0; index < targets.length; index += 3) {
1710
+ const batch = targets.slice(index, index + 3);
1711
+ await Promise.all(batch.map(async (gem) => {
1712
+ const status = unwrap(await api.client.GET("/admin/content-library/status", {
1713
+ params: { query: { gem } },
1714
+ }));
1715
+ latest.set(gem, status);
1716
+ if (status.stage === "live") {
1717
+ pending.delete(gem);
1718
+ return;
1719
+ }
1720
+ const errors = [
1721
+ status.progress.metadata,
1722
+ status.progress.cover,
1723
+ status.progress.searchIndex,
1724
+ ].filter((step) => step.state === "error");
1725
+ if (errors.length > 0) {
1726
+ throw new CliError("async_job_failed", `Content Library polishing failed for ${gem}.`, 1, { status });
1727
+ }
1728
+ }));
1729
+ }
1730
+ if (pending.size === 0)
1731
+ break;
1732
+ if (Date.now() + delayMs >= deadline) {
1733
+ throw new CliError("async_timeout", `Timed out waiting for ${pending.size} Content Library resource(s) to become live. Retry the unchanged confirmed command with the same operation key to resume polling.`, 1, {
1734
+ pending: Array.from(pending),
1735
+ latest: Object.fromEntries(latest),
1736
+ });
1737
+ }
1738
+ const jitter = Math.floor(Math.random() * Math.max(1, delayMs / 4));
1739
+ await new Promise((resolve) => setTimeout(resolve, delayMs + jitter));
1740
+ delayMs = Math.min(15_000, Math.round(delayMs * 1.7));
1741
+ }
1742
+ return { completed: true, resources: Array.from(latest.values()) };
1743
+ }
1256
1744
  // The cohort event routes take zoneless local wall-clock datetimes
1257
1745
  // (interpreted in the cohort's timezone server-side).
1258
1746
  function flagLocalDateTime(parsed, name, options = {}) {
@@ -1325,20 +1813,148 @@ function tierSlots(parsed) {
1325
1813
  export async function runCommand(argv) {
1326
1814
  const parsed = parseArgs(argv);
1327
1815
  const [noun, verb] = parsed.positionals;
1816
+ const commands = buildCommandSchema(HELP);
1328
1817
  // Before the help branch: `--version` parses as a FLAG, so `noun` is
1329
1818
  // undefined and `!noun` would return help instead. (Found by running it.)
1330
1819
  if (noun === "version" || hasFlag(parsed, "version")) {
1820
+ const unknown = Array.from(parsed.flags.keys()).filter((name) => !["deliver", "json", "version"].includes(name));
1821
+ if (unknown.length > 0) {
1822
+ throw new CliError("unknown_flag", `Unknown flag${unknown.length === 1 ? "" : "s"}: ${unknown
1823
+ .map((name) => `--${name}`)
1824
+ .join(", ")}.`, 1, { validFlags: ["--deliver", "--json", "--version"] });
1825
+ }
1331
1826
  return {
1332
1827
  cliVersion: await readCliVersion(),
1333
1828
  skillVersion: await readBundledSkillVersion(),
1334
1829
  };
1335
1830
  }
1336
- if (!noun || noun === "help" || hasFlag(parsed, "help")) {
1337
- return { help: HELP };
1831
+ if (!noun) {
1832
+ const unknown = Array.from(parsed.flags.keys()).filter((name) => !["deliver", "help", "json", "profile"].includes(name));
1833
+ if (unknown.length > 0) {
1834
+ throw new CliError("unknown_flag", `Unknown flag${unknown.length === 1 ? "" : "s"}: ${unknown
1835
+ .map((name) => `--${name}`)
1836
+ .join(", ")}.`, 1, { validFlags: ["--deliver", "--help", "--json", "--profile"] });
1837
+ }
1838
+ return { help: scopedHelp(HELP, commands, []) };
1839
+ }
1840
+ if (noun === "help" || hasFlag(parsed, "help")) {
1841
+ const scope = noun === "help" ? parsed.positionals.slice(1) : parsed.positionals;
1842
+ return { help: scopedHelp(HELP, commands, scope) };
1843
+ }
1844
+ validateInvocation(parsed, commands);
1845
+ if (noun === "agent-context") {
1846
+ const profiles = await listProfiles();
1847
+ return agentContext(commands, {
1848
+ cliVersion: await readCliVersion(),
1849
+ availableProfiles: profiles.profiles.map((profile) => profile.name),
1850
+ feedbackUpstreamConfigured: Boolean(process.env.RECESS_CLI_FEEDBACK_ENDPOINT),
1851
+ });
1852
+ }
1853
+ if (noun === "profile") {
1854
+ const name = parsed.positionals[2];
1855
+ if (verb === "list")
1856
+ return listProfiles();
1857
+ if (verb === "show") {
1858
+ if (!name)
1859
+ throw new CliError("invalid_arguments", "Missing profile name.");
1860
+ const profiles = await listProfiles();
1861
+ const profile = profiles.profiles.find((entry) => entry.name === name);
1862
+ if (!profile)
1863
+ throw new CliError("not_found", `Unknown profile ${name}.`);
1864
+ return { ...profile, active: profiles.activeProfile === name };
1865
+ }
1866
+ if (verb === "save") {
1867
+ if (!name)
1868
+ throw new CliError("invalid_arguments", "Missing profile name.");
1869
+ const current = await resolveConfig(flagString(parsed, "profile"));
1870
+ const apiOrigin = flagString(parsed, "api-origin") ?? current.apiOrigin;
1871
+ const webOrigin = flagString(parsed, "web-origin") ?? current.webOrigin;
1872
+ const oauthClientId = flagString(parsed, "oauth-client-id") ?? current.oauthClientId;
1873
+ for (const [label, value] of [
1874
+ ["--api-origin", apiOrigin],
1875
+ ["--web-origin", webOrigin],
1876
+ ]) {
1877
+ try {
1878
+ new URL(value);
1879
+ }
1880
+ catch {
1881
+ throw new CliError("invalid_arguments", `${label} must be a URL.`);
1882
+ }
1883
+ }
1884
+ await saveProfile(name, { apiOrigin, webOrigin, oauthClientId });
1885
+ return { saved: name, apiOrigin, webOrigin, oauthClientId };
1886
+ }
1887
+ if (verb === "use") {
1888
+ if (!name)
1889
+ throw new CliError("invalid_arguments", "Missing profile name.");
1890
+ await useProfile(name);
1891
+ return { activeProfile: name };
1892
+ }
1893
+ if (verb === "delete") {
1894
+ if (!name)
1895
+ throw new CliError("invalid_arguments", "Missing profile name.");
1896
+ await deleteProfile(name);
1897
+ return { deleted: name };
1898
+ }
1338
1899
  }
1339
- const config = await resolveConfig();
1900
+ if (noun === "jobs") {
1901
+ if (verb === "list") {
1902
+ const limit = flagNumber(parsed, "limit") ?? 20;
1903
+ if (!Number.isInteger(limit) || limit < 1 || limit > 100) {
1904
+ throw new CliError("invalid_arguments", "--limit must be an integer from 1 through 100.");
1905
+ }
1906
+ return listJobs(limit);
1907
+ }
1908
+ if (verb === "get") {
1909
+ const jobId = positional(parsed, 2, "operation key");
1910
+ const result = await getJob(jobId);
1911
+ if (!result.job)
1912
+ throw new CliError("not_found", `Unknown job ${jobId}.`);
1913
+ return result;
1914
+ }
1915
+ if (verb === "prune") {
1916
+ const olderThanDays = flagNumber(parsed, "older-than-days") ?? 30;
1917
+ if (!Number.isInteger(olderThanDays) ||
1918
+ olderThanDays < 1 ||
1919
+ olderThanDays > 3650) {
1920
+ throw new CliError("invalid_arguments", "--older-than-days must be an integer from 1 through 3650.");
1921
+ }
1922
+ return pruneJobs(olderThanDays);
1923
+ }
1924
+ }
1925
+ if (noun === "feedback") {
1926
+ if (verb === "list") {
1927
+ const limit = flagNumber(parsed, "limit") ?? 20;
1928
+ if (!Number.isInteger(limit) || limit < 1 || limit > 100) {
1929
+ throw new CliError("invalid_arguments", "--limit must be an integer from 1 through 100.");
1930
+ }
1931
+ return listFeedback(limit);
1932
+ }
1933
+ if (verb === "submit") {
1934
+ const feedbackText = parsed.positionals.slice(2).join(" ").trim();
1935
+ if (!feedbackText) {
1936
+ throw new CliError("invalid_arguments", "Feedback text must not be empty.");
1937
+ }
1938
+ if (feedbackText.length > 4_000) {
1939
+ throw new CliError("invalid_arguments", "Feedback text must be 4,000 characters or fewer.");
1940
+ }
1941
+ return writeCommand(parsed, {
1942
+ action: "record Recess CLI feedback locally and send it upstream when configured",
1943
+ target: {
1944
+ upstreamConfigured: Boolean(process.env.RECESS_CLI_FEEDBACK_ENDPOINT),
1945
+ },
1946
+ request: { text: feedbackText },
1947
+ }, () => submitFeedback({
1948
+ id: flagString(parsed, "operation-key", { required: true }),
1949
+ text: feedbackText,
1950
+ createdAt: new Date().toISOString(),
1951
+ }));
1952
+ }
1953
+ }
1954
+ const config = await resolveConfig(flagString(parsed, "profile"));
1955
+ const requestReason = noun === "auth" ? undefined : requiredRequestReason(parsed);
1340
1956
  if (noun === "doctor")
1341
- return doctor(config);
1957
+ return doctor(config, requestReason);
1342
1958
  if (noun === "setup") {
1343
1959
  // Always lay down the bundled copy first: it is the floor, and it is the
1344
1960
  // only copy guaranteed to match this binary. The served upgrade below is
@@ -1348,6 +1964,7 @@ export async function runCommand(argv) {
1348
1964
  apiOrigin: config.apiOrigin,
1349
1965
  sessionCookie: config.sessionCookie,
1350
1966
  cliVersion: await readCliVersion(),
1967
+ reason: requestReason,
1351
1968
  });
1352
1969
  const ephemeral = isEphemeralInstall();
1353
1970
  // Reuse a session that is still live; a missing or already-expired one is
@@ -1400,12 +2017,12 @@ export async function runCommand(argv) {
1400
2017
  if (verb === "status")
1401
2018
  return sessionStatus(config);
1402
2019
  if (verb === "logout") {
1403
- await clearStoredSession();
2020
+ await clearStoredSession(config.profileName);
1404
2021
  return { loggedOut: true };
1405
2022
  }
1406
2023
  throw new CliError("invalid_arguments", "Use auth login, request, poll, status, or logout.");
1407
2024
  }
1408
- const api = new RecessAdminApi(config);
2025
+ const api = new RecessAdminApi(config, requestReason);
1409
2026
  api.requireAuth();
1410
2027
  if (noun === "village") {
1411
2028
  const targetWorldId = flagString(parsed, "world") ?? "village-1";
@@ -2001,52 +2618,47 @@ export async function runCommand(argv) {
2001
2618
  ? { firstChargeAt: new Date(firstChargeAt).toISOString() }
2002
2619
  : {}),
2003
2620
  };
2004
- // Unlike other writes, the offline preview here would hide the only facts
2005
- // that matter — the amount charged, whether anything is charged at all, and
2006
- // whether the kid is over capacity or out of school slots. Ask the backend
2007
- // for its own dry run (read-only, writes nothing) and put that in front of
2008
- // the human before the confirmation gate.
2009
- if (!hasFlag(parsed, "confirm")) {
2010
- const preview = unwrap(await api.client.POST("/admin/cohorts/enroll/", {
2011
- body: { ...body, dryRun: true },
2012
- }));
2013
- const b = preview.plan.billing;
2014
- const usd = (cents) => `${(cents / 100).toFixed(2)} USD`;
2015
- const recurrence = b.isRecurring ? `, recurring per ${b.interval}` : "";
2016
- // effectivePriceCents already has the course discount applied; credits are
2017
- // deducted later as an invoice line, so this is an upper bound.
2018
- const amount = b.discountKind || b.creditBalanceCents > 0
2019
- ? `${usd(b.effectivePriceCents)} (list ${usd(b.listPriceCents)}${b.discountKind ? `, ${b.discountKind} discount applied` : ""}${b.creditBalanceCents > 0 ? `; up to ${usd(b.creditBalanceCents)} of Recess credits may reduce this further` : ""})`
2020
- : usd(b.effectivePriceCents);
2021
- requireConfirmation(false, {
2022
- action: preview.plan.reusedEnrollmentId
2023
- ? "enroll kid into cohort by reusing an existing enrollment (no charge)"
2024
- : b.chargesImmediately
2025
- ? `enroll kid into cohort and CHARGE ${amount} NOW${recurrence}`
2026
- : `enroll kid into cohort with first charge of ${amount} on ${b.firstChargeAt}${recurrence}`,
2027
- target: {
2028
- kid: `${preview.plan.kid.firstName ?? ""} ${preview.plan.kid.lastName ?? ""}`.trim(),
2029
- userId,
2030
- cohort: preview.plan.cohort.name,
2031
- cohortId,
2032
- course: preview.plan.cohort.courseName,
2033
- },
2034
- request: { ...body, dryRun: false },
2035
- details: {
2036
- billing: b,
2037
- reusedEnrollmentId: preview.plan.reusedEnrollmentId,
2038
- warnings: preview.warnings,
2039
- ...(preview.warnings.length > 0 && !body.force
2040
- ? {
2041
- blocked: "These warnings will reject the write unless you also pass --force.",
2042
- }
2043
- : {}),
2044
- },
2045
- });
2046
- }
2047
- return unwrap(await api.client.POST("/admin/cohorts/enroll/", {
2048
- body: { ...body, dryRun: false },
2621
+ // The backend owns the price/capacity plan. Recompute it on every run and
2622
+ // bind the confirmation token to that exact plan before the mutation.
2623
+ const preflight = unwrap(await api.client.POST("/admin/cohorts/enroll/", {
2624
+ body: { ...body, dryRun: true },
2049
2625
  }));
2626
+ const billing = preflight.plan.billing;
2627
+ const usd = (cents) => `${(cents / 100).toFixed(2)} USD`;
2628
+ const recurrence = billing.isRecurring
2629
+ ? `, recurring per ${billing.interval}`
2630
+ : "";
2631
+ const amount = billing.discountKind || billing.creditBalanceCents > 0
2632
+ ? `${usd(billing.effectivePriceCents)} (list ${usd(billing.listPriceCents)}${billing.discountKind ? `, ${billing.discountKind} discount applied` : ""}${billing.creditBalanceCents > 0 ? `; up to ${usd(billing.creditBalanceCents)} of Recess credits may reduce this further` : ""})`
2633
+ : usd(billing.effectivePriceCents);
2634
+ const preview = {
2635
+ action: preflight.plan.reusedEnrollmentId
2636
+ ? "enroll kid into cohort by reusing an existing enrollment (no charge)"
2637
+ : billing.chargesImmediately
2638
+ ? `enroll kid into cohort and CHARGE ${amount} NOW${recurrence}`
2639
+ : `enroll kid into cohort with first charge of ${amount} on ${billing.firstChargeAt}${recurrence}`,
2640
+ target: {
2641
+ kid: `${preflight.plan.kid.firstName ?? ""} ${preflight.plan.kid.lastName ?? ""}`.trim(),
2642
+ userId,
2643
+ cohort: preflight.plan.cohort.name,
2644
+ cohortId,
2645
+ course: preflight.plan.cohort.courseName,
2646
+ },
2647
+ request: { ...body, dryRun: false },
2648
+ details: {
2649
+ billing,
2650
+ reusedEnrollmentId: preflight.plan.reusedEnrollmentId,
2651
+ warnings: preflight.warnings,
2652
+ ...(preflight.warnings.length > 0 && !body.force
2653
+ ? {
2654
+ blocked: "These warnings will reject the write unless you also pass --force.",
2655
+ }
2656
+ : {}),
2657
+ },
2658
+ };
2659
+ return previewBoundWrite(parsed, preview, async () => unwrap(await api.client.POST("/admin/cohorts/enroll/", {
2660
+ body: { ...body, dryRun: false },
2661
+ })));
2050
2662
  }
2051
2663
  if (noun === "enrollments" && verb === "register-cohort") {
2052
2664
  const enrollmentId = flagString(parsed, "enrollment", { required: true });
@@ -2141,12 +2753,20 @@ export async function runCommand(argv) {
2141
2753
  const search = flagString(parsed, "search");
2142
2754
  const userId = flagString(parsed, "user");
2143
2755
  const id = flagString(parsed, "id");
2756
+ const limit = flagNumber(parsed, "limit") ?? 20;
2757
+ if (!Number.isInteger(limit) || limit < 1 || limit > 100) {
2758
+ throw new CliError("invalid_arguments", "--limit must be an integer between 1 and 100.");
2759
+ }
2144
2760
  return unwrap(await api.client.GET("/admin/payout/recipient/", {
2145
2761
  params: {
2146
2762
  query: {
2147
2763
  ...(search ? { search } : {}),
2148
2764
  ...(userId ? { userId } : {}),
2149
2765
  ...(id ? { id } : {}),
2766
+ limit,
2767
+ ...(flagString(parsed, "cursor")
2768
+ ? { cursor: flagString(parsed, "cursor") }
2769
+ : {}),
2150
2770
  },
2151
2771
  },
2152
2772
  }));
@@ -2767,6 +3387,19 @@ export async function runCommand(argv) {
2767
3387
  if (verb === "set-stage") {
2768
3388
  const stage = assertChoice(flagString(parsed, "stage", { required: true }), CONTENT_LIBRARY_RESOURCE_STAGES, "--stage");
2769
3389
  const input = await readContentLibraryGemTargets(parsed);
3390
+ const wait = hasFlag(parsed, "wait");
3391
+ const timeoutSeconds = flagNumber(parsed, "timeout") ?? 900;
3392
+ if (hasFlag(parsed, "timeout") && !wait) {
3393
+ throw new CliError("invalid_arguments", "--timeout requires --wait.");
3394
+ }
3395
+ if (wait && stage !== "live") {
3396
+ throw new CliError("invalid_arguments", "--wait is supported for content-library set-stage only when --stage live.");
3397
+ }
3398
+ if (!Number.isInteger(timeoutSeconds) ||
3399
+ timeoutSeconds < 10 ||
3400
+ timeoutSeconds > 7200) {
3401
+ throw new CliError("invalid_arguments", "--timeout must be an integer number of seconds from 10 through 7200.");
3402
+ }
2770
3403
  const preflightBody = {
2771
3404
  gems: input.gems,
2772
3405
  stage,
@@ -2783,16 +3416,39 @@ export async function runCommand(argv) {
2783
3416
  resources: preflight.results,
2784
3417
  lifecycle: "Uses the same lifecycle as Manage. Direct-to-Live never bypasses unfinished polishing; leaving Polishing cancels its bound run before moving.",
2785
3418
  concurrency: 3,
3419
+ waitForLive: wait,
3420
+ ...(wait ? { timeoutSeconds } : {}),
2786
3421
  ...(input.file ? { inputFile: input.file } : {}),
2787
3422
  },
2788
3423
  };
2789
- return writeCommand(parsed, preview, async () => unwrap(await api.client.POST("/admin/content-library/transition", {
2790
- body: { gems: input.gems, stage, dryRun: false },
2791
- })));
3424
+ return writeCommand(parsed, preview, async () => {
3425
+ const result = unwrap(await api.client.POST("/admin/content-library/transition", {
3426
+ body: { gems: input.gems, stage, dryRun: false },
3427
+ }));
3428
+ if (!wait)
3429
+ return result;
3430
+ return {
3431
+ ...result,
3432
+ wait: await waitForContentLibraryLive(api, input.gems, timeoutSeconds),
3433
+ };
3434
+ });
2792
3435
  }
2793
3436
  if (verb === "submit") {
2794
3437
  const stage = assertChoice(flagString(parsed, "stage") ?? "review", CONTENT_LIBRARY_STAGES, "--stage");
2795
3438
  const input = await readContentLibrarySubmitItems(parsed);
3439
+ const wait = hasFlag(parsed, "wait");
3440
+ const timeoutSeconds = flagNumber(parsed, "timeout") ?? 900;
3441
+ if (hasFlag(parsed, "timeout") && !wait) {
3442
+ throw new CliError("invalid_arguments", "--timeout requires --wait.");
3443
+ }
3444
+ if (wait && stage !== "polish") {
3445
+ throw new CliError("invalid_arguments", "--wait is supported for content-library submit only when --stage polish.");
3446
+ }
3447
+ if (!Number.isInteger(timeoutSeconds) ||
3448
+ timeoutSeconds < 10 ||
3449
+ timeoutSeconds > 7200) {
3450
+ throw new CliError("invalid_arguments", "--timeout must be an integer number of seconds from 10 through 7200.");
3451
+ }
2796
3452
  const body = { stage, items: input.items };
2797
3453
  const preview = {
2798
3454
  action: "content-library.submit",
@@ -2804,51 +3460,57 @@ export async function runCommand(argv) {
2804
3460
  : "Holds each new resource in REVIEW until an admin approves it.",
2805
3461
  duplicateBehavior: "Existing URLs are returned as duplicates and are not overwritten.",
2806
3462
  deployOrderFence: "The server verifies the island's review/polish lifecycle capability before its first write.",
3463
+ waitForLive: wait,
3464
+ ...(wait ? { timeoutSeconds } : {}),
2807
3465
  ...(input.file ? { inputFile: input.file } : {}),
2808
3466
  },
2809
3467
  };
2810
- return writeCommand(parsed, preview, async () => unwrap(await api.client.POST("/admin/content-library/submit", {
2811
- body,
2812
- })));
3468
+ return writeCommand(parsed, preview, async () => {
3469
+ const result = unwrap(await api.client.POST("/admin/content-library/submit", {
3470
+ body,
3471
+ }));
3472
+ if (!wait)
3473
+ return result;
3474
+ return {
3475
+ ...result,
3476
+ wait: await waitForContentLibraryLive(api, input.items.map((item) => item.url), timeoutSeconds),
3477
+ };
3478
+ });
2813
3479
  }
2814
3480
  throw new CliError("invalid_arguments", "Use content-library search, status, set-stage, or submit.");
2815
3481
  }
2816
3482
  if (noun === "goal-templates") {
2817
3483
  if (verb === "list") {
2818
- const query = flagString(parsed, "query")?.toLowerCase();
3484
+ const query = flagString(parsed, "query");
2819
3485
  const kind = flagString(parsed, "kind");
2820
- const data = unwrap(await api.client.GET("/ai/goal-templates/", {
3486
+ const wantedKind = kind
3487
+ ? assertChoice(kind, GOAL_TEMPLATE_KINDS, "--kind")
3488
+ : undefined;
3489
+ const limit = flagNumber(parsed, "limit") ?? 20;
3490
+ if (!Number.isInteger(limit) || limit < 1 || limit > 100) {
3491
+ throw new CliError("invalid_arguments", "--limit must be an integer between 1 and 100.");
3492
+ }
3493
+ return unwrap(await api.client.GET("/ai/goal-templates/", {
2821
3494
  params: {
2822
3495
  query: {
2823
3496
  ...(flagString(parsed, "category")
2824
3497
  ? { category: flagString(parsed, "category") }
2825
3498
  : {}),
3499
+ ...(query ? { query } : {}),
3500
+ ...(wantedKind ? { kind: wantedKind } : {}),
2826
3501
  includeDeleted: hasFlag(parsed, "include-deleted")
2827
3502
  ? "true"
2828
3503
  : "false",
2829
3504
  starterOnly: hasFlag(parsed, "starter-only")
2830
3505
  ? "true"
2831
3506
  : "false",
3507
+ limit,
3508
+ ...(flagString(parsed, "cursor")
3509
+ ? { cursor: flagString(parsed, "cursor") }
3510
+ : {}),
2832
3511
  },
2833
3512
  },
2834
3513
  }));
2835
- // The route filters by category/starter/deleted only; `--query` and
2836
- // `--kind` narrow the returned page here rather than pretending the
2837
- // backend supports them.
2838
- const wantedKind = kind
2839
- ? assertChoice(kind, GOAL_TEMPLATE_KINDS, "--kind")
2840
- : undefined;
2841
- const items = data.items.filter((item) => {
2842
- if (wantedKind && item.kind !== wantedKind)
2843
- return false;
2844
- if (!query)
2845
- return true;
2846
- return [item.title, item.slug, item.description, item.category ?? ""]
2847
- .join("\n")
2848
- .toLowerCase()
2849
- .includes(query);
2850
- });
2851
- return { items, totalBeforeFilter: data.items.length };
2852
3514
  }
2853
3515
  if (verb === "get") {
2854
3516
  const id = await resolveGoalTemplateId(api, positional(parsed, 2, "template ID or slug"));
@@ -2953,7 +3615,7 @@ export async function runCommand(argv) {
2953
3615
  // protected-inventory counting, and the token binding the approved loss
2954
3616
  // to this exact version + result hash. Always ask it for a fresh preview,
2955
3617
  // including on a confirmed run, before permitting the write request.
2956
- const preflight = unwrap(await api.client.POST("/admin/goal-templates/{id}/setup-workflow-spec/patch", {
3618
+ const preflight = unwrap(await api.client.POST("/ai/goal-templates/{id}/setup-workflow-spec/patch", {
2957
3619
  params: { path: { id } },
2958
3620
  body: {
2959
3621
  expectedVersion,
@@ -2991,7 +3653,9 @@ export async function runCommand(argv) {
2991
3653
  : {}),
2992
3654
  },
2993
3655
  };
2994
- requireConfirmation(hasFlag(parsed, "confirm"), preview);
3656
+ if (!hasFlag(parsed, "confirm")) {
3657
+ await requirePreviewBoundConfirmation(parsed, preview);
3658
+ }
2995
3659
  const destructiveChangeToken = flagString(parsed, "destructive-change-token");
2996
3660
  if (preflight.preview.destructiveChanges &&
2997
3661
  (!hasFlag(parsed, "confirm-destructive-changes") ||
@@ -3007,7 +3671,7 @@ export async function runCommand(argv) {
3007
3671
  expectedDestructiveChangeToken: preflight.preview.destructiveChangeToken,
3008
3672
  });
3009
3673
  }
3010
- return unwrap(await api.client.POST("/admin/goal-templates/{id}/setup-workflow-spec/patch", {
3674
+ return previewBoundWrite(parsed, preview, async () => unwrap(await api.client.POST("/ai/goal-templates/{id}/setup-workflow-spec/patch", {
3011
3675
  params: { path: { id } },
3012
3676
  body: {
3013
3677
  expectedVersion,
@@ -3016,7 +3680,7 @@ export async function runCommand(argv) {
3016
3680
  confirmDestructiveChanges: preflight.preview.destructiveChanges,
3017
3681
  ...(destructiveChangeToken ? { destructiveChangeToken } : {}),
3018
3682
  },
3019
- }));
3683
+ })));
3020
3684
  }
3021
3685
  if (verb === "set-metadata") {
3022
3686
  const id = await resolveGoalTemplateId(api, positional(parsed, 2, "template ID or slug"));
@@ -3085,7 +3749,7 @@ export async function runCommand(argv) {
3085
3749
  action: "update goal template metadata (never its setupWorkflowSpec)",
3086
3750
  target: { templateId: id, expectedVersion },
3087
3751
  request: body,
3088
- }, async () => unwrap(await api.client.PUT("/admin/goal-templates/{id}", {
3752
+ }, async () => unwrap(await api.client.PUT("/ai/goal-templates/{id}", {
3089
3753
  params: { path: { id } },
3090
3754
  body,
3091
3755
  })));
@@ -3096,7 +3760,7 @@ export async function runCommand(argv) {
3096
3760
  // Read the current version so the gate refuses a stale delete locally,
3097
3761
  // and so the preview names the template a human is being asked to approve
3098
3762
  // rather than only its UUID.
3099
- const current = unwrap(await api.client.GET("/admin/goal-templates/{id}", {
3763
+ const current = unwrap(await api.client.GET("/ai/goal-templates/{id}", {
3100
3764
  params: { path: { id } },
3101
3765
  }));
3102
3766
  if (current.version !== expectedVersion) {
@@ -3114,7 +3778,7 @@ export async function runCommand(argv) {
3114
3778
  details: {
3115
3779
  note: "Soft delete: the row keeps its deletedAt and drops out of every list. Existing goals already applied from it are unaffected.",
3116
3780
  },
3117
- }, async () => unwrap(await api.client.DELETE("/admin/goal-templates/{id}", {
3781
+ }, async () => unwrap(await api.client.DELETE("/ai/goal-templates/{id}", {
3118
3782
  params: { path: { id } },
3119
3783
  })));
3120
3784
  }
@@ -3134,18 +3798,43 @@ export async function runCommand(argv) {
3134
3798
  const id = await resolveGoalTemplateId(api, positional(parsed, 2, "template ID or slug"));
3135
3799
  const sourceGoalId = flagString(parsed, "source-goal");
3136
3800
  const sourceDraft = flagString(parsed, "source-draft");
3137
- if (Boolean(sourceGoalId) === Boolean(sourceDraft)) {
3138
- throw new CliError("invalid_arguments", "Pass exactly one of --source-goal or --source-draft.");
3801
+ const sourceDirectory = flagString(parsed, "source-dir");
3802
+ if ([sourceGoalId, sourceDraft, sourceDirectory].filter(Boolean).length !==
3803
+ 1) {
3804
+ throw new CliError("invalid_arguments", "Pass exactly one of --source-goal, --source-draft, or --source-dir.");
3805
+ }
3806
+ const checkout = sourceDirectory
3807
+ ? await readRecessGitMetadata(sourceDirectory)
3808
+ : null;
3809
+ const checkoutHead = checkout
3810
+ ? await assertCheckoutReadyForMaterialization(checkout)
3811
+ : null;
3812
+ const requestedSourceStudentId = flagString(parsed, "student");
3813
+ if (checkout &&
3814
+ requestedSourceStudentId &&
3815
+ requestedSourceStudentId !== checkout.metadata.studentId) {
3816
+ throw new CliError("invalid_arguments", `--student ${requestedSourceStudentId} does not match checkout owner ${checkout.metadata.studentId}.`);
3139
3817
  }
3140
3818
  const sourceStudentUserId = sourceDraft
3141
3819
  ? flagString(parsed, "student", { required: true })
3142
- : undefined;
3143
- const source = sourceGoalId
3144
- ? { sourceGoalId }
3145
- : {
3146
- sourceWorkspacePath: `drafts/${sourceDraft}/workspace`,
3147
- sourceStudentUserId,
3820
+ : checkout?.metadata.studentId;
3821
+ let source;
3822
+ if (sourceGoalId) {
3823
+ source = { sourceGoalId };
3824
+ }
3825
+ else if (checkout?.metadata.target.kind === "goal") {
3826
+ source = { sourceGoalId: checkout.metadata.target.goalId };
3827
+ }
3828
+ else {
3829
+ const draftSlug = sourceDraft ??
3830
+ (checkout?.metadata.target.kind === "draft"
3831
+ ? checkout.metadata.target.draftSlug
3832
+ : undefined);
3833
+ source = {
3834
+ sourceWorkspacePath: `drafts/${draftSlug}/workspace`,
3835
+ sourceStudentUserId: sourceStudentUserId,
3148
3836
  };
3837
+ }
3149
3838
  const preflight = unwrap(await api.client.POST("/ai/goal-templates/{id}/snapshot/capture", {
3150
3839
  params: { path: { id } },
3151
3840
  body: { ...source, dryRun: true },
@@ -3153,6 +3842,10 @@ export async function runCommand(argv) {
3153
3842
  if (preflight.action !== "preview_capture_snapshot") {
3154
3843
  throw new CliError("unexpected_response", "Snapshot capture did not return a preview; nothing was captured.");
3155
3844
  }
3845
+ if (checkout &&
3846
+ preflight.source.changeId !== checkout.metadata.changeId) {
3847
+ throw new CliError("stale_workspace", `Workspace changed from ${checkout.metadata.changeId} to ${preflight.source.changeId}. Check it out again before capturing; nothing was captured.`);
3848
+ }
3156
3849
  if (hasFlag(parsed, "dry-run"))
3157
3850
  return preflight;
3158
3851
  const preview = {
@@ -3169,6 +3862,13 @@ export async function runCommand(argv) {
3169
3862
  fileCount: preflight.snapshot.fileCount,
3170
3863
  sizeBytes: preflight.snapshot.sizeBytes,
3171
3864
  sha256: preflight.snapshot.sha256,
3865
+ ...(checkout
3866
+ ? {
3867
+ sourceDirectory: checkout.root,
3868
+ localHeadCommit: checkoutHead,
3869
+ mesaChangeId: checkout.metadata.changeId,
3870
+ }
3871
+ : {}),
3172
3872
  },
3173
3873
  details: {
3174
3874
  modules: preflight.snapshot.modules,
@@ -3177,8 +3877,7 @@ export async function runCommand(argv) {
3177
3877
  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.",
3178
3878
  },
3179
3879
  };
3180
- requireConfirmation(hasFlag(parsed, "confirm"), preview);
3181
- return unwrap(await api.client.POST("/ai/goal-templates/{id}/snapshot/capture", {
3880
+ return previewBoundWrite(parsed, preview, async () => unwrap(await api.client.POST("/ai/goal-templates/{id}/snapshot/capture", {
3182
3881
  params: { path: { id } },
3183
3882
  body: {
3184
3883
  ...source,
@@ -3186,7 +3885,7 @@ export async function runCommand(argv) {
3186
3885
  expectedTemplateVersion: preflight.template.version,
3187
3886
  expectedSourceChangeId: preflight.source.changeId,
3188
3887
  },
3189
- }));
3888
+ })));
3190
3889
  }
3191
3890
  if (verb === "apply") {
3192
3891
  const id = await resolveGoalTemplateId(api, positional(parsed, 2, "template ID or slug"));
@@ -3230,15 +3929,14 @@ export async function runCommand(argv) {
3230
3929
  note: "Counts come from the backend's own dry run. `skipped_existing` items are idempotent — re-applying does not duplicate them.",
3231
3930
  },
3232
3931
  };
3233
- requirePreviewBoundConfirmation(parsed, preview);
3234
- return unwrap(await api.client.POST("/ai/goal-templates/{id}/apply-workflow", {
3932
+ return previewBoundWrite(parsed, preview, async () => unwrap(await api.client.POST("/ai/goal-templates/{id}/apply-workflow", {
3235
3933
  params: { path: { id } },
3236
3934
  body: {
3237
3935
  answers,
3238
3936
  dryRun: false,
3239
3937
  expectedTemplateVersion: preflight.templateVersion,
3240
3938
  },
3241
- }));
3939
+ })));
3242
3940
  }
3243
3941
  if (verb === "apply-starter") {
3244
3942
  const id = await resolveGoalTemplateId(api, positional(parsed, 2, "template ID or slug"));
@@ -3272,7 +3970,30 @@ export async function runCommand(argv) {
3272
3970
  }));
3273
3971
  }
3274
3972
  if (verb === "create") {
3275
- const userId = flagString(parsed, "student", { required: true });
3973
+ const requestedStudentId = flagString(parsed, "student");
3974
+ const sourceDirectory = flagString(parsed, "source-dir");
3975
+ const explicitDraftSlug = flagString(parsed, "draft");
3976
+ if (sourceDirectory && explicitDraftSlug) {
3977
+ throw new CliError("invalid_arguments", "Pass either --source-dir or --draft, not both.");
3978
+ }
3979
+ const checkout = sourceDirectory
3980
+ ? await readRecessGitMetadata(sourceDirectory)
3981
+ : null;
3982
+ if (checkout && checkout.metadata.target.kind !== "draft") {
3983
+ throw new CliError("invalid_checkout", "A fresh module goal must be created from a draft checkout, not an existing goal checkout.");
3984
+ }
3985
+ const checkoutHead = checkout
3986
+ ? await assertCheckoutReadyForMaterialization(checkout)
3987
+ : null;
3988
+ if (checkout &&
3989
+ requestedStudentId &&
3990
+ requestedStudentId !== checkout.metadata.studentId) {
3991
+ throw new CliError("invalid_arguments", `--student ${requestedStudentId} does not match checkout owner ${checkout.metadata.studentId}.`);
3992
+ }
3993
+ const userId = requestedStudentId ?? checkout?.metadata.studentId;
3994
+ if (!userId) {
3995
+ throw new CliError("invalid_arguments", "Pass --student for a direct goal create, or use a Recess draft --source-dir.");
3996
+ }
3276
3997
  const title = flagString(parsed, "title", { required: true });
3277
3998
  const descriptionFile = flagString(parsed, "description-file");
3278
3999
  const descriptionFlag = flagString(parsed, "description");
@@ -3288,14 +4009,17 @@ export async function runCommand(argv) {
3288
4009
  throw new CliError("invalid_arguments", "--target-date must be an ISO 8601 datetime.");
3289
4010
  }
3290
4011
  const schedule = flagString(parsed, "schedule");
3291
- const draftSlug = flagString(parsed, "draft");
4012
+ const draftSlug = explicitDraftSlug ??
4013
+ (checkout?.metadata.target.kind === "draft"
4014
+ ? checkout.metadata.target.draftSlug
4015
+ : undefined);
3292
4016
  const enablesAppletFollowUps = hasFlag(parsed, "enable-applet-follow-ups");
3293
4017
  const disablesAppletFollowUps = hasFlag(parsed, "disable-applet-follow-ups");
3294
4018
  if (draftSlug && enablesAppletFollowUps === disablesAppletFollowUps) {
3295
4019
  throw new CliError("invalid_arguments", "A module-backed goal requires exactly one of --enable-applet-follow-ups or --disable-applet-follow-ups.");
3296
4020
  }
3297
4021
  if (!draftSlug && (enablesAppletFollowUps || disablesAppletFollowUps)) {
3298
- throw new CliError("invalid_arguments", "Applet follow-up flags are used with --draft for direct module-backed goals.");
4022
+ throw new CliError("invalid_arguments", "Applet follow-up flags are used with --draft or --source-dir for module-backed goals.");
3299
4023
  }
3300
4024
  if (draftSlug) {
3301
4025
  const body = {
@@ -3313,6 +4037,10 @@ export async function runCommand(argv) {
3313
4037
  if (preflight.action !== "preview_module_goal_create") {
3314
4038
  throw new CliError("unexpected_response", "Direct module goal creation did not return a preview; no goal was created.");
3315
4039
  }
4040
+ if (checkout &&
4041
+ preflight.currentChangeId !== checkout.metadata.changeId) {
4042
+ throw new CliError("stale_workspace", `Draft changed from ${checkout.metadata.changeId} to ${preflight.currentChangeId}. Check it out again before creating the goal; nothing was created.`);
4043
+ }
3316
4044
  const preview = {
3317
4045
  action: "create a module-backed goal directly from a validated draft workspace",
3318
4046
  target: preflight.target,
@@ -3325,6 +4053,13 @@ export async function runCommand(argv) {
3325
4053
  targetDate: targetDate ?? null,
3326
4054
  schedule: schedule ?? null,
3327
4055
  enableAppletFollowUps: enablesAppletFollowUps,
4056
+ ...(checkout
4057
+ ? {
4058
+ sourceDirectory: checkout.root,
4059
+ localHeadCommit: checkoutHead,
4060
+ mesaChangeId: checkout.metadata.changeId,
4061
+ }
4062
+ : {}),
3328
4063
  },
3329
4064
  details: {
3330
4065
  currentChangeId: preflight.currentChangeId,
@@ -3338,14 +4073,35 @@ export async function runCommand(argv) {
3338
4073
  note: "Creates a personal module-backed goal directly. It does not create, capture, or apply a reusable template. After success, Recess removes the draft only when no newer Mesa edit has landed; otherwise it preserves the draft.",
3339
4074
  },
3340
4075
  };
3341
- requirePreviewBoundConfirmation(parsed, preview);
3342
- return unwrap(await api.client.POST("/tutor/browser/mesa/goals", {
3343
- body: {
3344
- ...body,
3345
- dryRun: false,
3346
- expectedWorkspaceChangeId: preflight.currentChangeId,
3347
- },
3348
- }));
4076
+ return previewBoundWrite(parsed, preview, async () => {
4077
+ const result = unwrap(await api.client.POST("/tutor/browser/mesa/goals", {
4078
+ body: {
4079
+ ...body,
4080
+ dryRun: false,
4081
+ expectedWorkspaceChangeId: preflight.currentChangeId,
4082
+ },
4083
+ }));
4084
+ if (result.action !== "create_module_goal") {
4085
+ throw new CliError("unexpected_response", "Module goal creation returned a preview instead of creating the goal.");
4086
+ }
4087
+ if (checkout && result.mesaChangeId) {
4088
+ await writeRecessGitMetadata(checkout.root, {
4089
+ ...checkout.metadata,
4090
+ target: { kind: "goal", goalId: result.goalId },
4091
+ changeId: result.mesaChangeId,
4092
+ upstreamCommit: checkoutHead,
4093
+ });
4094
+ return {
4095
+ ...result,
4096
+ local: {
4097
+ directory: checkout.root,
4098
+ target: { kind: "goal", goalId: result.goalId },
4099
+ upstreamCommit: checkoutHead,
4100
+ },
4101
+ };
4102
+ }
4103
+ return result;
4104
+ });
3349
4105
  }
3350
4106
  const body = {
3351
4107
  title,
@@ -3363,7 +4119,7 @@ export async function runCommand(argv) {
3363
4119
  schedule: schedule ?? null,
3364
4120
  },
3365
4121
  details: {
3366
- note: "Creates a description-only goal with no modules or course workspace. Pass --draft to create a personal module-backed goal directly. A 409 GOAL_LIMIT_REACHED means the kid is at capacity and nothing was created.",
4122
+ note: "Creates a description-only goal with no modules or course workspace. Pass a draft checkout with --source-dir to create a personal module-backed goal. A 409 GOAL_LIMIT_REACHED means the kid is at capacity and nothing was created.",
3367
4123
  },
3368
4124
  }, async () => unwrap(await api.client.POST("/tutor/browser/students/{userId}/goals/", {
3369
4125
  params: { path: { userId } },
@@ -3392,11 +4148,39 @@ export async function runCommand(argv) {
3392
4148
  patch,
3393
4149
  },
3394
4150
  };
3395
- requirePreviewBoundConfirmation(parsed, preview);
3396
- return unwrap(await api.client.PATCH("/tutor/browser/students/goals/{goalId}/", {
4151
+ return previewBoundWrite(parsed, preview, async () => unwrap(await api.client.PATCH("/tutor/browser/students/goals/{goalId}/", {
3397
4152
  params: { path: { goalId } },
3398
4153
  body: patch,
3399
- }));
4154
+ })));
4155
+ }
4156
+ if (verb === "delete") {
4157
+ const goalId = positional(parsed, 2, "goal ID");
4158
+ const studentId = flagString(parsed, "student", { required: true });
4159
+ const current = unwrap(await api.client.GET("/tutor/browser/students/{userId}/goals/", {
4160
+ params: { path: { userId: studentId } },
4161
+ })).goals.find((goal) => goal.id === goalId);
4162
+ if (!current) {
4163
+ throw new CliError("not_found", `Goal ${goalId} was not found for student ${studentId}.`);
4164
+ }
4165
+ const preview = {
4166
+ action: "soft-delete a goal for a managed student",
4167
+ target: {
4168
+ goalId,
4169
+ studentUserId: studentId,
4170
+ title: current.title,
4171
+ status: current.status,
4172
+ },
4173
+ request: { expectedUpdatedAt: current.updatedAt },
4174
+ details: {
4175
+ note: "Soft-deletes the goal and its current/future dated todos. Historical todos and the deletion audit remain available.",
4176
+ },
4177
+ };
4178
+ return previewBoundWrite(parsed, preview, async () => {
4179
+ unwrap(await api.client.DELETE("/admin/browser/students/goals/{goalId}/", {
4180
+ params: { path: { goalId } },
4181
+ }));
4182
+ return { deleted: true, goalId, studentUserId: studentId };
4183
+ });
3400
4184
  }
3401
4185
  if (verb === "queue") {
3402
4186
  const subverb = positional(parsed, 2, "queue action (get|set)");
@@ -3411,7 +4195,7 @@ export async function runCommand(argv) {
3411
4195
  });
3412
4196
  const delta = flagString(parsed, "delta", { required: true });
3413
4197
  const replaceDescriptionPointer = hasFlag(parsed, "replace-description-pointer");
3414
- const { absolutePath, parsed: rawEntries } = await readJsonValue(entriesFile, "Queue entries file");
4198
+ const { absolutePath, raw: rawEntriesText, parsed: rawEntries, } = await readJsonValue(entriesFile, "Queue entries file");
3415
4199
  const entries = parseGoalQueueEntries(rawEntries, "Queue entries file");
3416
4200
  const goal = unwrap(await api.client.GET("/tutor/browser/students/{userId}/goals/", {
3417
4201
  params: { path: { userId: studentId } },
@@ -3422,15 +4206,36 @@ export async function runCommand(argv) {
3422
4206
  const current = unwrap(await api.client.GET("/tutor/browser/students/goals/{goalId}/queue/", { params: { path: { goalId } } }));
3423
4207
  const currentUrls = new Set(current.queue.map((item) => item.url));
3424
4208
  const proposedUrls = new Set(entries.map((entry) => entry.url));
4209
+ const currentByUrl = new Map(current.queue.map((item) => [item.url, item]));
4210
+ const completionChanges = entries.flatMap((entry) => {
4211
+ if (entry.completed === undefined)
4212
+ return [];
4213
+ const existing = currentByUrl.get(entry.url);
4214
+ const currentlyCompleted = existing?.completedAt != null;
4215
+ if (currentlyCompleted === entry.completed)
4216
+ return [];
4217
+ return [
4218
+ {
4219
+ moduleRef: existing?.moduleRef ?? null,
4220
+ url: entry.url,
4221
+ fromCompletedAt: existing?.completedAt ?? null,
4222
+ toCompleted: entry.completed,
4223
+ },
4224
+ ];
4225
+ });
3425
4226
  const preview = {
3426
4227
  action: "replace a goal's URL skill queue for a managed student",
3427
4228
  target: { goalId, studentUserId: studentId },
3428
4229
  request: {
3429
4230
  entriesFile: absolutePath,
4231
+ entriesSha256: createHash("sha256")
4232
+ .update(rawEntriesText)
4233
+ .digest("hex"),
3430
4234
  entryCount: entries.length,
3431
4235
  delta,
3432
4236
  replaceDescriptionPointer,
3433
4237
  expectedUpdatedAt: goal.updatedAt,
4238
+ unchangedTitleForConcurrencyFence: goal.title,
3434
4239
  },
3435
4240
  details: {
3436
4241
  currentQueue: current.queue.map((item) => ({
@@ -3444,26 +4249,31 @@ export async function runCommand(argv) {
3444
4249
  removed: current.queue
3445
4250
  .filter((item) => !proposedUrls.has(item.url))
3446
4251
  .map((item) => item.url),
4252
+ completionChanges,
3447
4253
  note: "Replaces the entire EXTERNAL_URL queue. Completion is preserved for unchanged URLs; WORKSPACE modules are untouched. Daily generation mints the head entry for a flag-on kid.",
3448
4254
  },
3449
4255
  };
3450
- requirePreviewBoundConfirmation(parsed, preview);
3451
4256
  const body = {
3452
4257
  delta,
3453
4258
  expectedUpdatedAt: goal.updatedAt,
4259
+ // The deployed route's queue-only CAS reaches Prisma with an empty
4260
+ // scalar update, which reports zero rows and falsely trips the stale
4261
+ // write fence. Echoing the already-previewed title gives that CAS a
4262
+ // real update field without changing the title.
4263
+ title: goal.title,
3454
4264
  queue: entries,
3455
4265
  ...(replaceDescriptionPointer
3456
4266
  ? { description: URL_QUEUE_DESCRIPTION_POINTER }
3457
4267
  : {}),
3458
4268
  };
3459
- return unwrap(await api.client.PATCH("/tutor/browser/students/goals/{goalId}/", {
4269
+ return previewBoundWrite(parsed, preview, async () => unwrap(await api.client.PATCH("/tutor/browser/students/goals/{goalId}/", {
3460
4270
  params: { path: { goalId } },
3461
4271
  body,
3462
- }));
4272
+ })));
3463
4273
  }
3464
4274
  throw new CliError("invalid_arguments", "Use goals queue get|set.");
3465
4275
  }
3466
- throw new CliError("invalid_arguments", "Use goals list|create|edit|queue|files|pdf.");
4276
+ throw new CliError("invalid_arguments", "Use goals list|create|edit|delete|queue|files|pdf.");
3467
4277
  }
3468
4278
  if (noun === "students") {
3469
4279
  if (verb === "list") {
@@ -3559,11 +4369,37 @@ export async function runCommand(argv) {
3559
4369
  patch: body,
3560
4370
  },
3561
4371
  };
3562
- requirePreviewBoundConfirmation(parsed, preview);
3563
- return unwrap(await api.client.PATCH("/tutor/browser/todos/{id}", {
4372
+ return previewBoundWrite(parsed, preview, async () => unwrap(await api.client.PATCH("/tutor/browser/todos/{id}", {
3564
4373
  params: { path: { id: todoId } },
3565
4374
  body,
4375
+ })));
4376
+ }
4377
+ if (verb === "delete") {
4378
+ const todoId = positional(parsed, 2, "todo ID");
4379
+ const current = unwrap(await api.client.GET("/tutor/browser/todos/{id}/", {
4380
+ params: { path: { id: todoId } },
3566
4381
  }));
4382
+ const preview = {
4383
+ action: "permanently delete a todo for a managed student",
4384
+ target: {
4385
+ todoId,
4386
+ studentUserId: current.userId,
4387
+ title: current.title,
4388
+ status: current.status,
4389
+ goalId: current.goal?.id ?? null,
4390
+ },
4391
+ request: { expectedUpdatedAt: current.updatedAt },
4392
+ details: {
4393
+ dueDate: current.dueDate,
4394
+ creationSource: current.creationSource,
4395
+ creationSourceId: current.creationSourceId,
4396
+ url: "url" in current ? current.url : null,
4397
+ note: "Hard-deletes this todo without awarding XP or running todo-completion side effects. This is permanent; use it only when the preview identifies a disposable todo with no work to preserve.",
4398
+ },
4399
+ };
4400
+ return previewBoundWrite(parsed, preview, async () => unwrap(await api.client.DELETE("/admin/todos/{id}/", {
4401
+ params: { path: { id: todoId } },
4402
+ })));
3567
4403
  }
3568
4404
  if (verb === "generate-applet") {
3569
4405
  const todoId = positional(parsed, 2, "todo ID");
@@ -3601,13 +4437,12 @@ export async function runCommand(argv) {
3601
4437
  versionRouting: "The server evaluates applet-gen-v2 for this student and uses v1 when the flag is off or its evaluation fails.",
3602
4438
  },
3603
4439
  };
3604
- requirePreviewBoundConfirmation(parsed, preview);
3605
- return unwrap(await api.client.POST("/admin/learning-pipeline/{analysisId}/generate-applet/", {
4440
+ return previewBoundWrite(parsed, preview, async () => unwrap(await api.client.POST("/admin/learning-pipeline/{analysisId}/generate-applet/", {
3606
4441
  params: { path: { analysisId: analysis.id } },
3607
4442
  body: targetDueDateISO ? { targetDueDateISO } : {},
3608
- }));
4443
+ })));
3609
4444
  }
3610
- throw new CliError("invalid_arguments", "Use todos create|edit|generate-applet.");
4445
+ throw new CliError("invalid_arguments", "Use todos create|edit|delete|generate-applet.");
3611
4446
  }
3612
4447
  if (noun === "memories") {
3613
4448
  const studentId = flagString(parsed, "student", { required: true });
@@ -3715,7 +4550,7 @@ export async function runCommand(argv) {
3715
4550
  if (preflight.action !== "preview_workspace_files_write") {
3716
4551
  throw new CliError("unexpected_response", "Goal PDF upload did not return a preview; nothing was uploaded.");
3717
4552
  }
3718
- requirePreviewBoundConfirmation(parsed, {
4553
+ const preview = {
3719
4554
  action: "upload a textbook PDF and attach it to a student's goal",
3720
4555
  target: preflight.target,
3721
4556
  request: {
@@ -3729,111 +4564,114 @@ export async function runCommand(argv) {
3729
4564
  currentChangeId: preflight.currentChangeId,
3730
4565
  note: "The PDF is stored separately and the goal workspace receives a small verified reference, matching planner-authored textbook goals.",
3731
4566
  },
3732
- });
3733
- const conversationId = randomUUID();
3734
- const signed = unwrap(await api.client.POST("/os-v2-mesa/planner/uploads/sign", {
3735
- body: {
3736
- conversationId,
3737
- studentUserId: studentId,
3738
- fileName,
3739
- contentType: "application/pdf",
3740
- sizeBytes: fileStat.size,
3741
- },
3742
- }));
3743
- let uploaded;
3744
- try {
3745
- const uploadedSource = await uploadPdfToSignedUrls(absolutePath, "application/pdf", signed);
3746
- if (uploadedSource.sha256 !== sourceSha256 ||
3747
- uploadedSource.sizeBytes !== fileStat.size) {
3748
- throw new CliError("source_changed", "The source PDF changed after approval. The upload was discarded; preview the current file again.");
3749
- }
3750
- uploaded =
3751
- signed.kind === "multipart"
3752
- ? unwrap(await api.client.POST("/os-v2-mesa/planner/uploads/complete", {
3753
- body: {
3754
- conversationId,
3755
- fileName,
3756
- contentType: "application/pdf",
3757
- key: signed.key,
3758
- uploadId: signed.uploadId,
3759
- sizeBytes: fileStat.size,
3760
- },
3761
- }))
3762
- : unwrap(await api.client.POST("/os-v2-mesa/planner/uploads/verify", {
3763
- body: {
3764
- conversationId,
3765
- fileName,
3766
- contentType: "application/pdf",
3767
- key: signed.key,
3768
- sizeBytes: fileStat.size,
3769
- },
3770
- }));
3771
- }
3772
- catch (error) {
3773
- // A failed multipart complete may already have materialized the object,
3774
- // so attempt both abort and delete. These are compensation attempts; the
3775
- // original upload error remains authoritative.
3776
- await cleanupPlannerUpload(api, conversationId, signed, true);
3777
- throw error;
3778
- }
3779
- const manifest = JSON.stringify({
3780
- kind: "r2-pdf",
3781
- version: 2,
3782
- r2Key: uploaded.key,
3783
- signature: uploaded.manifestSignature,
3784
- fileName,
3785
- contentType: uploaded.contentType,
3786
- sizeBytes: uploaded.sizeBytes,
3787
- });
3788
- let workspace;
3789
- try {
3790
- workspace = unwrap(await api.client.POST("/tutor/browser/mesa/workspace-files", {
4567
+ };
4568
+ return previewBoundWrite(parsed, preview, async () => {
4569
+ const conversationId = randomUUID();
4570
+ const signed = unwrap(await api.client.POST("/os-v2-mesa/planner/uploads/sign", {
3791
4571
  body: {
3792
- ...body,
3793
- files: [
3794
- {
3795
- path: workspacePath,
3796
- content: manifest,
3797
- contentEncoding: "utf8",
3798
- },
3799
- ],
3800
- dryRun: false,
3801
- expectedChangeId: preflight.currentChangeId,
4572
+ conversationId,
4573
+ studentUserId: studentId,
4574
+ fileName,
4575
+ contentType: "application/pdf",
4576
+ sizeBytes: fileStat.size,
3802
4577
  },
3803
4578
  }));
3804
- }
3805
- catch (error) {
3806
- // Only a returned 4xx from this route is a definitive no-write response.
3807
- // A transport/5xx failure is ambiguous: the workspace commit may have landed,
3808
- // so keep the object rather than creating a dangling manifest.
3809
- const status = error instanceof CliError &&
3810
- error.details &&
3811
- typeof error.details === "object" &&
3812
- "status" in error.details
3813
- ? Number(error.details.status)
3814
- : 0;
3815
- if (status >= 400 && status < 500) {
4579
+ let uploaded;
4580
+ try {
4581
+ const uploadedSource = await uploadPdfToSignedUrls(absolutePath, "application/pdf", signed);
4582
+ if (uploadedSource.sha256 !== sourceSha256 ||
4583
+ uploadedSource.sizeBytes !== fileStat.size) {
4584
+ throw new CliError("source_changed", "The source PDF changed after approval. The upload was discarded; preview the current file again.");
4585
+ }
4586
+ uploaded =
4587
+ signed.kind === "multipart"
4588
+ ? unwrap(await api.client.POST("/os-v2-mesa/planner/uploads/complete", {
4589
+ body: {
4590
+ conversationId,
4591
+ fileName,
4592
+ contentType: "application/pdf",
4593
+ key: signed.key,
4594
+ uploadId: signed.uploadId,
4595
+ sizeBytes: fileStat.size,
4596
+ },
4597
+ }))
4598
+ : unwrap(await api.client.POST("/os-v2-mesa/planner/uploads/verify", {
4599
+ body: {
4600
+ conversationId,
4601
+ fileName,
4602
+ contentType: "application/pdf",
4603
+ key: signed.key,
4604
+ sizeBytes: fileStat.size,
4605
+ },
4606
+ }));
4607
+ }
4608
+ catch (error) {
4609
+ // A failed multipart complete may already have materialized the object,
4610
+ // so attempt both abort and delete. These are compensation attempts; the
4611
+ // original upload error remains authoritative.
3816
4612
  await cleanupPlannerUpload(api, conversationId, signed, true);
4613
+ throw error;
3817
4614
  }
3818
- throw error;
3819
- }
3820
- return {
3821
- action: "upload_textbook_pdf",
3822
- sourceSha256,
3823
- fileName,
3824
- sizeBytes: uploaded.sizeBytes,
3825
- workspacePath,
3826
- workspace,
3827
- };
4615
+ const manifest = JSON.stringify({
4616
+ kind: "r2-pdf",
4617
+ version: 2,
4618
+ r2Key: uploaded.key,
4619
+ signature: uploaded.manifestSignature,
4620
+ fileName,
4621
+ contentType: uploaded.contentType,
4622
+ sizeBytes: uploaded.sizeBytes,
4623
+ });
4624
+ let workspace;
4625
+ try {
4626
+ workspace = unwrap(await api.client.POST("/tutor/browser/mesa/workspace-files", {
4627
+ body: {
4628
+ ...body,
4629
+ files: [
4630
+ {
4631
+ path: workspacePath,
4632
+ content: manifest,
4633
+ contentEncoding: "utf8",
4634
+ },
4635
+ ],
4636
+ dryRun: false,
4637
+ expectedChangeId: preflight.currentChangeId,
4638
+ },
4639
+ }));
4640
+ }
4641
+ catch (error) {
4642
+ // Only a returned 4xx from this route is a definitive no-write response.
4643
+ // A transport/5xx failure is ambiguous: the workspace commit may have landed,
4644
+ // so keep the object rather than creating a dangling manifest.
4645
+ const status = error instanceof CliError &&
4646
+ error.details &&
4647
+ typeof error.details === "object" &&
4648
+ "status" in error.details
4649
+ ? Number(error.details.status)
4650
+ : 0;
4651
+ if (status >= 400 && status < 500) {
4652
+ await cleanupPlannerUpload(api, conversationId, signed, true);
4653
+ }
4654
+ throw error;
4655
+ }
4656
+ return {
4657
+ action: "upload_textbook_pdf",
4658
+ sourceSha256,
4659
+ fileName,
4660
+ sizeBytes: uploaded.sizeBytes,
4661
+ workspacePath,
4662
+ workspace,
4663
+ };
4664
+ });
3828
4665
  }
3829
4666
  if (noun === "goals" && verb === "files") {
3830
4667
  const action = positional(parsed, 2, "goals files action");
3831
- const studentId = flagString(parsed, "student", { required: true });
3832
4668
  if (action === "list") {
4669
+ const studentId = flagString(parsed, "student", { required: true });
3833
4670
  const goalId = flagString(parsed, "goal", { required: true });
3834
4671
  return unwrap(await api.client.GET("/tutor/students/{studentId}/goals/{goalId}/workspace/files", { params: { path: { studentId, goalId } } }));
3835
4672
  }
3836
4673
  if (action === "read") {
4674
+ const studentId = flagString(parsed, "student", { required: true });
3837
4675
  const goalId = flagString(parsed, "goal", { required: true });
3838
4676
  return unwrap(await api.client.GET("/tutor/students/{studentId}/goals/{goalId}/workspace/file", {
3839
4677
  params: {
@@ -3842,7 +4680,115 @@ export async function runCommand(argv) {
3842
4680
  },
3843
4681
  }));
3844
4682
  }
4683
+ if (action === "init" || action === "checkout") {
4684
+ const studentId = flagString(parsed, "student", { required: true });
4685
+ const goalId = flagString(parsed, "goal");
4686
+ const draftSlug = flagString(parsed, "draft");
4687
+ if (action === "init" && (goalId || !draftSlug)) {
4688
+ throw new CliError("invalid_arguments", "goals files init requires --draft and does not accept --goal.");
4689
+ }
4690
+ if (Boolean(goalId) === Boolean(draftSlug)) {
4691
+ throw new CliError("invalid_arguments", "Pass exactly one of --goal or --draft for goals files checkout.");
4692
+ }
4693
+ const outputDirectory = flagString(parsed, "output-dir", {
4694
+ required: true,
4695
+ });
4696
+ const target = goalId
4697
+ ? { kind: "goal", goalId }
4698
+ : { kind: "draft", draftSlug: draftSlug };
4699
+ const response = await readRemoteWorkspaceSnapshot(api, studentId, target);
4700
+ if (action === "init" && response.snapshot.fileCount > 0) {
4701
+ throw new CliError("draft_exists", `Draft ${draftSlug} already has files. Use goals files checkout --draft to preserve them.`);
4702
+ }
4703
+ return checkoutGoalWorkspace({
4704
+ outputDirectory,
4705
+ studentId,
4706
+ target,
4707
+ snapshot: response.snapshot,
4708
+ });
4709
+ }
4710
+ if (action === "push") {
4711
+ const sourceDirectory = flagString(parsed, "source-dir", {
4712
+ required: true,
4713
+ });
4714
+ const checkout = await readRecessGitMetadata(sourceDirectory);
4715
+ const local = await readGoalWorkspaceGitChanges(checkout.root, checkout.metadata.upstreamCommit);
4716
+ const defaultMessage = local.commits.length === 1
4717
+ ? local.commits[0].subject
4718
+ : `recess-cli: push ${local.commits.length} commits (${local.commits.at(-1).subject})`;
4719
+ const message = (flagString(parsed, "message") ?? defaultMessage).trim();
4720
+ if (!message || message.length > 200) {
4721
+ throw new CliError("invalid_arguments", "--message (or the derived local commit message) must be 1 to 200 characters.");
4722
+ }
4723
+ const files = local.changes.map((change) => change.action === "delete"
4724
+ ? { path: change.path, action: "delete" }
4725
+ : {
4726
+ path: change.path,
4727
+ action: "upsert",
4728
+ content: change.content,
4729
+ contentEncoding: change.contentEncoding,
4730
+ });
4731
+ const body = {
4732
+ studentUserId: checkout.metadata.studentId,
4733
+ target: checkout.metadata.target,
4734
+ message,
4735
+ files,
4736
+ expectedChangeId: checkout.metadata.changeId,
4737
+ };
4738
+ const preflight = unwrap(await api.client.POST("/tutor/browser/mesa/workspace-files", {
4739
+ body: { ...body, dryRun: true },
4740
+ }));
4741
+ if (preflight.action !== "preview_workspace_files_write") {
4742
+ throw new CliError("unexpected_response", "Goal workspace push did not return a preview; nothing was written.");
4743
+ }
4744
+ const preview = {
4745
+ action: `push committed Git changes to a student's Mesa ${checkout.metadata.target.kind} workspace`,
4746
+ target: preflight.target,
4747
+ request: {
4748
+ sourceDirectory: checkout.root,
4749
+ mesaBaseChangeId: checkout.metadata.changeId,
4750
+ localBaseCommit: checkout.metadata.upstreamCommit,
4751
+ localHeadCommit: local.head,
4752
+ commits: local.commits,
4753
+ message,
4754
+ changes: local.changes.map((change) => change.action === "delete"
4755
+ ? change
4756
+ : {
4757
+ path: change.path,
4758
+ action: change.action,
4759
+ sizeBytes: change.sizeBytes,
4760
+ sha256: change.sha256,
4761
+ }),
4762
+ sizeBytes: local.sizeBytes,
4763
+ },
4764
+ details: {
4765
+ currentChangeId: preflight.currentChangeId,
4766
+ note: "The committed local diff is published as one atomic Mesa change. Deletes and renames follow Git's diff. A changed Mesa tip refuses the push.",
4767
+ },
4768
+ };
4769
+ return previewBoundWrite(parsed, preview, async () => {
4770
+ const result = unwrap(await api.client.POST("/tutor/browser/mesa/workspace-files", {
4771
+ body: { ...body, dryRun: false },
4772
+ }));
4773
+ if (result.action !== "write_workspace_files") {
4774
+ throw new CliError("unexpected_response", "Goal workspace push returned an unexpected response.");
4775
+ }
4776
+ await writeRecessGitMetadata(checkout.root, {
4777
+ ...checkout.metadata,
4778
+ changeId: result.changeId,
4779
+ upstreamCommit: local.head,
4780
+ });
4781
+ return {
4782
+ ...result,
4783
+ local: {
4784
+ directory: checkout.root,
4785
+ upstreamCommit: local.head,
4786
+ },
4787
+ };
4788
+ });
4789
+ }
3845
4790
  if (action === "write") {
4791
+ const studentId = flagString(parsed, "student", { required: true });
3846
4792
  const goalId = flagString(parsed, "goal");
3847
4793
  const draftSlug = flagString(parsed, "draft");
3848
4794
  if (Boolean(goalId) === Boolean(draftSlug)) {
@@ -3881,19 +4827,18 @@ export async function runCommand(argv) {
3881
4827
  files: preflight.files,
3882
4828
  note: target.kind === "draft"
3883
4829
  ? "This writes a complete authoring tree under drafts/<slug>/workspace. Capture it only after the server's goal-workspace validation passes."
3884
- : "Live goal modules/ and state/ are blocked here because they have database projections; use a draft + template capture for structural course changes.",
4830
+ : "This directly edits the live goal workspace, including modules/ and state/. The write remains bound to the previewed workspace revision.",
3885
4831
  },
3886
4832
  };
3887
- requirePreviewBoundConfirmation(parsed, preview);
3888
- return unwrap(await api.client.POST("/tutor/browser/mesa/workspace-files", {
4833
+ return previewBoundWrite(parsed, preview, async () => unwrap(await api.client.POST("/tutor/browser/mesa/workspace-files", {
3889
4834
  body: {
3890
4835
  ...body,
3891
4836
  dryRun: false,
3892
4837
  expectedChangeId: preflight.currentChangeId,
3893
4838
  },
3894
- }));
4839
+ })));
3895
4840
  }
3896
- throw new CliError("invalid_arguments", "Use goals files list|read|write.");
4841
+ throw new CliError("invalid_arguments", "Use goals files list|read|init|checkout|push|write.");
3897
4842
  }
3898
4843
  if (noun === "request" && verb === "get") {
3899
4844
  return api.rawGet(positional(parsed, 2, "request path"));