recess-cli 1.9.2 → 2.1.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,274 +1,27 @@
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 { flagList, flagNumber, flagString, hasFlag, parseArgs, } from "./args.js";
5
+ import { RecessAdminApi, unwrap, withIdempotencyContext } from "./api.js";
6
+ import { 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";
10
+ import { runApplicationsCommand } from "./commands/applications.js";
11
+ import { runOnboardingCommand } from "./commands/onboarding.js";
12
+ import { assertChoice, flagIdList, positional, readJsonFile, readJsonValue, } from "./commands/shared.js";
8
13
  import { CliError } from "./errors.js";
14
+ import { listFeedback, submitFeedback } from "./feedback.js";
15
+ import { HELP } from "./help.js";
16
+ export { HELP };
17
+ import { cliRequestHeaders, requireCliRequestReason } from "./http.js";
9
18
  import { requireConfirmation } from "./safety.js";
10
19
  import { installSkill, isEphemeralInstall, readBundledSkillVersion, readCliVersion, } from "./setup.js";
11
20
  import { compareVersions, updateSkillFromServer } from "./skill-update.js";
12
21
  import { readSkillCache, writeSkillCache } from "./skills-cache.js";
13
- export const HELP = `recess safe Recess administration and family AI tools
14
-
15
- Usage:
16
- recess [--json] --version
17
- recess [--json] setup [--skill-only]
18
- recess [--json] doctor
19
- recess [--json] auth login [--client-id ID] [--callback-port 8765]
20
- recess [--json] auth request [--label TEXT]
21
- recess [--json] auth poll [--timeout 300]
22
- recess [--json] auth status|logout
23
- recess [--json] users search <name-or-id> [--limit 10]
24
- recess [--json] users get <user-id>
25
- recess [--json] users tier list-tiers
26
- recess [--json] users tier get <kid-id>
27
- recess [--json] users tier preview <kid-id> --tier social|academics|lite|complete|platform
28
- [--slots N]
29
- recess [--json] users tier set <kid-id> --tier social|academics|lite|complete|platform
30
- [--slots N] --expected-updated-at <iso> [--allow-strand] [--confirm]
31
- recess [--json] guides create --email <email> --first-name TEXT
32
- [--last-name TEXT] [--no-invite] [--confirm]
33
- recess [--json] guides invite --user <user-id> [--confirm]
34
- recess [--json] guardians invite --family <family-id> --email <email>
35
- --first-name TEXT [--last-name TEXT] [--no-invite] [--confirm]
36
- recess [--json] students upload-map-scores --student <kid-id>
37
- --file </path/to/map-report.pdf> [--confirm]
38
- recess [--json] students list
39
- recess [--json] students today --student <kid-id> [--date YYYY-MM-DD]
40
- recess [--json] students schedule --student <kid-id> [--days 14]
41
- recess [--json] students xp-history --student <kid-id>
42
- [--range week|month|quarter|year]
43
- recess [--json] enrollments list --user <user-id>
44
- recess [--json] subscriptions list --family <family-id> [--kid <kid-id>]
45
- recess [--json] invoices list --subscription <subscription-id>
46
- recess [--json] billing pause --subscription <id> [--until ISO_DATE] [--confirm]
47
- recess [--json] billing resume --subscription <id> [--confirm]
48
- recess [--json] invoices refund --invoice <id> --line-item <id>
49
- --method refund|credit|tokens [--full | --amount-cents N]
50
- [--who-pays guide|recess] [--reason TEXT] [--confirm]
51
- recess [--json] applications enroll <application-id> --quote <quote-id>
52
- --school <institution-slug> --kid "<quoteLineId>:<firstName>[:<age>]" (repeat per line)
53
- [--family <family-id>] [--unassign <kid-id>] (repeat) [--note TEXT] [--confirm]
54
- recess [--json] cohorts search <query>
55
- recess [--json] enrollments create --user <kid-id> --cohort <id>
56
- [--first-charge-at ISO_DATETIME] [--send-email] [--force] [--confirm]
57
- recess [--json] enrollments register-cohort --enrollment <id>
58
- --user <id> --cohort <id> [--confirm]
59
- recess [--json] enrollments unregister-cohort --user <id>
60
- --cohort <id> [--confirm]
61
- recess [--json] enrollments get-for-subscription --subscription <id>
62
- recess [--json] billing extend-trial --subscription <id>
63
- --trial-end ISO_DATE [--confirm]
64
- recess [--json] billing cancel-subscription --subscription <id>
65
- [--immediate] [--reason TEXT] [--restore] [--confirm]
66
- recess [--json] payout payruns list [--status A,B] [--schedule <id>]
67
- recess [--json] payout recipients list [--search <name>] [--user <id>] [--id <id>]
68
- recess [--json] payout invoices list [--payrun <id>] [--recipient <account-id>]
69
- [--user <id>] [--status A,B] (at least one filter)
70
- recess [--json] payout invoices get <invoice-id>
71
- recess [--json] payout invoices set-status <invoice-id>
72
- --status IN_REVIEW|OPEN|PAID|CANCELED [--send-email] [--confirm]
73
- recess [--json] payout items add --invoice <id> --amount-cents N
74
- --description TEXT [--date YYYY-MM-DD] [--confirm]
75
- recess [--json] payout items edit <item-id> [--amount-cents N]
76
- [--description TEXT] [--date YYYY-MM-DD] [--confirm]
77
- recess [--json] payout items delete <item-id> [--confirm]
78
- recess [--json] cohorts get <cohort-id> [--events-tab ACTIVE|ENDED|CANCELED|ARCHIVED]
79
- recess [--json] cohorts parent-emails <cohort-id>
80
- recess [--json] cohorts end <cohort-id> [--cancel-subscriptions] [--confirm]
81
- recess [--json] cohorts pause-billing <cohort-id> --weeks 1..6 [--confirm]
82
- recess [--json] cohorts resume-billing <cohort-id> [--confirm]
83
- recess [--json] cohorts email <cohort-id> --target ALL_PARENTS|ALL_PARENTS_GUIDES
84
- --content TEXT [--confirm]
85
- recess [--json] events get <event-id>
86
- recess [--json] events take-attendance <event-id> --attended <id,id,...>
87
- [--absent <id,id,...>] [--excused <id,id,...>] [--confirm]
88
- recess [--json] events cancel <event-id> --reason TEXT [--confirm]
89
- recess [--json] events set-status <event-id> --status ACTIVE|ENDED|CANCELED [--confirm]
90
- recess [--json] events reschedule --cohort <id> --event <event-id>
91
- --starts-at "YYYY-MM-DDTHH:MM" [--timezone <iana>] [--length-mins N] [--confirm]
92
- recess [--json] events add --cohort <id> --starts-at "YYYY-MM-DDTHH:MM"
93
- [--timezone <iana>] [--length-mins N] [--confirm]
94
- recess [--json] registrations approve --registration <id> [--confirm]
95
- recess [--json] registrations deny --cohort <id> --user <id> [--confirm]
96
- recess [--json] request get </path?query=value>
97
- recess [--json] onboarding status <family-id>
98
- recess [--json] onboarding kids [--time-period-days N] [--cohort <id>]
99
- [--limit N] [--stage-filter all|scheduled|oriented|course|converted|lost]
100
- recess [--json] onboarding intake-session <family-id>
101
- recess [--json] onboarding intake-session-create <family-id> [--confirm]
102
- recess [--json] onboarding set-stage <family-id>
103
- --stage LEGACY|PROVISIONED|PARENT_CONFIRMED|CLEARED_FOR_COHORT|COMPLETE [--confirm]
104
- recess [--json] onboarding set-account-state <family-id>
105
- --state ACTIVE|PENDING_PAYMENT|PAUSED|BOOTED [--note TEXT] [--confirm]
106
- recess [--json] onboarding attest <family-id>
107
- --condition app_downloaded|tutor_met|goals_loaded|ma_diagnostic [--revoke]
108
- [--note TEXT] [--confirm]
109
- recess [--json] onboarding set-intake <family-id> --session <id>
110
- --data <json> [--expected-updated-at <iso>] [--confirm]
111
- recess [--json] onboarding extract <family-id> --session <id>
112
- (--transcript-file <path> | --granola <ref>) [--confirm]
113
- recess [--json] village models list [--world village-1] [--query TEXT] [--archived]
114
- recess [--json] village models upload --file </path/model.glb>
115
- [--world village-1] [--name TEXT] [--id ID] [--description TEXT] [--tags A,B]
116
- [--visual-only] [--confirm]
117
- recess [--json] village models publish|archive <model-id> [--world village-1] [--confirm]
118
- recess [--json] village models place <model-id> --x N --z N
119
- [--y N] [--rotation 0..3] [--mirrored] [--no-collision] [--batch ID]
120
- [--world village-1] [--confirm]
121
- recess [--json] village models move <placement-id> --x N --z N
122
- [--y N] [--rotation 0..3] [--mirrored] [--world village-1] [--confirm]
123
- recess [--json] village models remove <placement-id> [--world village-1] [--confirm]
124
- recess [--json] village render --min-x N --min-z N --max-x N --max-z N
125
- [--world village-1]
126
- recess [--json] store-items list [--search TEXT]
127
- [--status ACTIVE|INACTIVE|COMING_SOON] [--item-type TYPE]
128
- [--page 0] [--limit 20] [--sort-by name|price|createdAt|updatedAt|order]
129
- [--sort-order asc|desc]
130
- recess [--json] store-items set-status <village-store-item-id>
131
- --status ACTIVE|INACTIVE|COMING_SOON [--confirm]
132
- recess [--json] content-library search <query> [--limit 8]
133
- recess [--json] content-library status <gem-id-or-url>
134
- recess [--json] content-library set-stage <gem-id-or-url...> [--file <path>]
135
- --stage review|polishing|live|archived [--confirm]
136
- recess [--json] content-library submit <url...> [--file <path>]
137
- [--stage review|polish] [--title TEXT] [--summary TEXT]
138
- [--lane web-toys|mechanics|explorables|data-stories|sims|maps-scale|sound-art|puzzles|wonder|idea-games]
139
- [--confirm]
140
- recess [--json] skills guardian list [--query TEXT] [--category TEXT]
141
- recess [--json] skills guardian get <skill-name>
142
- [--reference NAME | --all-references] [--refresh]
143
- recess [--json] skills admin list [--query TEXT] [--category TEXT]
144
- recess [--json] skills admin get <skill-name>
145
- [--reference NAME | --all-references]
146
- [--refresh]
147
- recess [--json] goal-templates list [--query TEXT] [--category TEXT]
148
- [--kind SIMPLE|BLUEPRINT] [--starter-only] [--include-deleted]
149
- recess [--json] goal-templates get <template-id|slug> [--spec-only]
150
- recess [--json] goal-templates versions <template-id> [--version N]
151
- recess [--json] goal-templates validate-spec --file <path/template.json>
152
- recess [--json] goal-templates create --file <path/template.json> [--confirm]
153
- recess [--json] goal-templates patch-spec <template-id|slug> --expected-version N
154
- --patches-file <path/patches.json> [--confirm]
155
- [--confirm-destructive-changes --destructive-change-token TOKEN]
156
- recess [--json] goal-templates set-metadata <template-id> --expected-version N
157
- [--title TEXT] [--description TEXT] [--emoji X] [--category TEXT] [--tags A,B]
158
- [--sort-order N] [--is-starter true|false]
159
- [--setup-audience KID_FRIENDLY|PARENT_SETUP] [--kind SIMPLE|BLUEPRINT]
160
- [--agent-instructions-file <path>]
161
- [--output-template-file <path>] [--confirm]
162
- recess [--json] goal-templates delete <template-id> --expected-version N [--confirm]
163
- recess [--json] goal-templates snapshot-files <template-id> [--path P]
164
- 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]
167
- recess [--json] goal-templates apply <template-id> --answers-file <path>
168
- [--dry-run] [--confirm --approval-token TOKEN]
169
- recess [--json] goal-templates apply-starter <template-id> --student <kid-id>
170
- [--answers-file <path>] [--confirm]
171
- recess [--json] goals list --student <kid-id>
172
- recess [--json] goals create --student <kid-id> --title TEXT
173
- (--description TEXT | --description-file <path>) [--target-date <iso>]
174
- [--schedule TEXT] [--draft <draft-slug>
175
- (--enable-applet-follow-ups | --disable-applet-follow-ups)]
176
- [--confirm --approval-token TOKEN]
177
- recess [--json] goals edit <goal-id> --student <kid-id> --patch-file <path/patch.json>
178
- --delta TEXT [--confirm --approval-token TOKEN]
179
- recess [--json] goals queue get <goal-id> --student <kid-id>
180
- recess [--json] goals queue set <goal-id> --student <kid-id>
181
- --entries-file <path.json> --delta TEXT [--replace-description-pointer]
182
- [--confirm --approval-token TOKEN]
183
- recess [--json] todos create --student <kid-id> --title TEXT
184
- [--due-date YYYY-MM-DD] [--estimated-minutes N] [--url URL] [--confirm]
185
- recess [--json] todos edit <todo-id> --patch-file <path/patch.json>
186
- [--confirm --approval-token TOKEN]
187
- recess [--json] todos generate-applet <todo-id> --student <kid-id>
188
- [--due-date YYYY-MM-DD] [--confirm --approval-token TOKEN]
189
- recess [--json] memories context --student <kid-id>
190
- recess [--json] memories log --student <kid-id> --date YYYY-MM-DD
191
- recess [--json] rocky get --student <kid-id>
192
- recess [--json] rocky set --student <kid-id> --patch-file <path/patch.json>
193
- --expected-updated-at ISO|none [--confirm]
194
- recess [--json] goals files list --student <goal-owner-id> --goal <goal-id>
195
- recess [--json] goals files read --student <goal-owner-id> --goal <goal-id> --path P
196
- recess [--json] goals files write --student <goal-owner-id>
197
- (--goal <goal-id> | --draft <draft-slug>)
198
- (--source-dir <local-dir> | --source-file <local-file> --path P)
199
- [--message TEXT] [--confirm --approval-token TOKEN]
200
- recess [--json] goals pdf upload --student <goal-owner-id>
201
- (--goal <goal-id> | --draft <draft-slug>) --source-file <local.pdf>
202
- [--path uploads/name.pdf] [--message TEXT]
203
- [--confirm --approval-token TOKEN]
204
-
205
- Authoring notes: "skills" serves the in-product tutor skills (the private
206
- packages/skills workspace package) read-only over your admin session — they are never
207
- bundled into this npm package. Load os-v2-goal-template-builder and its
208
- references/deterministic-workflow-setup.md BEFORE authoring a template; that is
209
- the same guidance the recess.gg/ai agent follows, so there is exactly one
210
- standard. Responses cache under ~/.recess-cli/skills-cache/ (--refresh re-fetches).
211
- For goal workspace and PDF commands, --student names the goal owner. A
212
- full_admin session may use a KID or ADMIN user id, including its own ADMIN id;
213
- 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.
218
- Every template created here is setupMode DETERMINISTIC_WORKFLOW and CANNOT be
219
- converted back, so "validate-spec" against the same file until it passes, then
220
- "create". "create" runs a real server-side validation before its gate, so the
221
- preview shows the handler/goalShape/step keys the SERVER resolved. "apply" runs
222
- the backend's own dryRun before the gate and previews the per-student outcome.
223
- "set-metadata" and "delete" require --expected-version (from "get"); a stale one
224
- 409s STALE_WRITE and writes nothing. The spec is unreachable from "set-metadata"
225
- by design — an existing spec is edited only through the guarded /ai patch path.
226
-
227
- Onboarding notes: "status" and "intake-session" are reads — "intake-session"
228
- looks up the current IN_PROGRESS session without creating one (prints a "none
229
- yet" result when absent). "intake-session-create" is the explicit write that
230
- mints a blank session, so it is gated behind --confirm. "set-stage" reads the
231
- current stage first and warns when a move re-locks progress; CLEARED_FOR_COHORT
232
- unlocks cohort registration. "set-account-state" PAUSED/BOOTED lock the family out of paid
233
- capabilities (--note is analytics-only). "set-intake --data" takes JSON (agents
234
- drive it with --json; humans rarely will); the preview is offline. Its
235
- optimistic-concurrency token resolves only on --confirm: pass
236
- --expected-updated-at <iso> (from an intake-session read's updatedAt) for strict
237
- CAS that 409s if a parent autosaved since; omit it and the CLI fetches the
238
- current token at confirm time and warns that post-preview edits are unprotected.
239
- "extract" is LLM-bound and can take ~30s; a 503 means Granola is not configured
240
- server-side.
241
-
242
- Class-ops notes: "events cancel" notifies families (chat + parent email blast +
243
- credit notes + Slack); "events set-status --status CANCELED" is a silent status
244
- change. Reschedule times are cohort-local wall-clock (zoneless).
245
-
246
- Payout notes: amounts are integer cents. "payout items add" without --date
247
- defaults the item date to the penultimate day of the invoice's cycle (its
248
- endDate minus one day) and shows the computed date in the preview.
249
-
250
- Auth notes: "auth login" runs the browser loopback flow for ADMIN or a GUARDIAN with
251
- access:ai. Guardian sessions are family-scoped and cannot call /admin. For a headless
252
- cloud agent, the ADMIN-only "auth request" prints an approval URL to hand a Recess admin; after they
253
- approve it in a browser, "auth poll" collects the 12h session. When it lapses, run
254
- "auth request" again for a fresh link. Both paths yield the same session.
255
-
256
- Skill notes: this CLI's own agent skill ships inside the npm package AND is served
257
- by the server, so wording/Gotcha updates arrive without an npm release. "setup"
258
- (or "setup --skill-only") installs the bundled copy, then upgrades it from the
259
- server when the served bundle's minCliVersion allows — an older binary keeps the
260
- bundled copy, and an unreachable server is not an error. "doctor" reports whether
261
- a newer skill exists and names the command; it never writes.
262
-
263
- Writes preview and exit 2 unless --confirm is supplied after explicit human approval.
264
- Environment overrides: RECESS_CLI_API_ORIGIN, RECESS_CLI_WEB_ORIGIN,
265
- RECESS_CLI_OAUTH_CLIENT_ID, RECESS_CLI_COOKIE, RECESS_CLI_CONFIG.`;
266
- function positional(parsed, index, label) {
267
- const value = parsed.positionals[index];
268
- if (!value) {
269
- throw new CliError("invalid_arguments", `Missing ${label}.`);
270
- }
271
- return value;
22
+ import { appendJobEvent, getJob, listJobs, pruneJobs } from "./jobs.js";
23
+ function requiredRequestReason(parsed) {
24
+ return requireCliRequestReason(flagString(parsed, "reason", { required: true }));
272
25
  }
273
26
  async function sessionStatus(config) {
274
27
  if (!config.sessionCookie) {
@@ -291,18 +44,20 @@ async function sessionStatus(config) {
291
44
  }
292
45
  return { ...result.data, authSource: config.authSource };
293
46
  }
294
- async function doctor(config) {
47
+ async function doctor(config, reason) {
295
48
  const checks = {
296
49
  config: {
297
50
  path: config.configPath,
298
51
  apiOrigin: config.apiOrigin,
299
52
  webOrigin: config.webOrigin,
300
53
  oauthClientIdConfigured: Boolean(config.oauthClientId),
54
+ profile: config.profileName ?? null,
301
55
  },
302
56
  auth: await sessionStatus(config),
303
57
  };
304
58
  try {
305
59
  const response = await fetch(new URL("/health", config.apiOrigin), {
60
+ headers: cliRequestHeaders(undefined, reason),
306
61
  signal: AbortSignal.timeout(5000),
307
62
  });
308
63
  checks.api = { reachable: response.ok, status: response.status };
@@ -313,7 +68,7 @@ async function doctor(config) {
313
68
  message: error instanceof Error ? error.message : String(error),
314
69
  };
315
70
  }
316
- checks.skill = await skillStatus(config);
71
+ checks.skill = await skillStatus(config, reason);
317
72
  return checks;
318
73
  }
319
74
  /**
@@ -322,7 +77,7 @@ async function doctor(config) {
322
77
  * silently rewrote `~/.claude/skills` mid-session would change what the agent is
323
78
  * reading out from under it. It names the command instead.
324
79
  */
325
- async function skillStatus(config) {
80
+ async function skillStatus(config, reason) {
326
81
  const cliVersion = await readCliVersion();
327
82
  const installedVersion = await readBundledSkillVersion();
328
83
  const base = { cliVersion, bundledSkillVersion: installedVersion };
@@ -331,7 +86,7 @@ async function skillStatus(config) {
331
86
  }
332
87
  try {
333
88
  const response = await fetch(new URL("/auth/admin-cli/skill/", config.apiOrigin), {
334
- headers: { cookie: config.sessionCookie },
89
+ headers: cliRequestHeaders({ cookie: config.sessionCookie }, reason),
335
90
  signal: AbortSignal.timeout(5000),
336
91
  });
337
92
  if (!response.ok) {
@@ -366,30 +121,433 @@ async function skillStatus(config) {
366
121
  }
367
122
  }
368
123
  async function writeCommand(parsed, preview, execute) {
369
- requireConfirmation(hasFlag(parsed, "confirm"), preview);
370
- return execute();
124
+ const fingerprint = approvalTokenFor(preview);
125
+ const suppliedOperationKey = flagString(parsed, "operation-key");
126
+ if (!hasFlag(parsed, "confirm")) {
127
+ const operationKey = operationKeyFor(fingerprint);
128
+ const boundPreview = {
129
+ ...preview,
130
+ details: {
131
+ ...preview.details,
132
+ operationKey,
133
+ retry: "Rerun the unchanged command with --confirm --operation-key <operationKey>. Reuse that same key after an interrupted invocation.",
134
+ },
135
+ };
136
+ await appendJobEvent({
137
+ jobId: operationKey,
138
+ status: "awaiting_confirmation",
139
+ timestamp: new Date().toISOString(),
140
+ action: preview.action,
141
+ fingerprint,
142
+ target: preview.target,
143
+ });
144
+ requireConfirmation(false, boundPreview);
145
+ }
146
+ if (!suppliedOperationKey) {
147
+ throw new CliError("confirmation_required", "A confirmed write requires the operation key from its approved preview.", 2, { preview, requiredFlag: "--operation-key" });
148
+ }
149
+ assertOperationKeyMatchesPreview(suppliedOperationKey, fingerprint, preview);
150
+ await appendJobEvent({
151
+ jobId: suppliedOperationKey,
152
+ status: "running",
153
+ timestamp: new Date().toISOString(),
154
+ action: preview.action,
155
+ fingerprint,
156
+ target: preview.target,
157
+ });
158
+ try {
159
+ const result = await withIdempotencyContext({ operationKey: suppliedOperationKey, fingerprint }, execute);
160
+ await appendJobEvent({
161
+ jobId: suppliedOperationKey,
162
+ status: "completed",
163
+ timestamp: new Date().toISOString(),
164
+ action: preview.action,
165
+ fingerprint,
166
+ target: preview.target,
167
+ result,
168
+ });
169
+ return result;
170
+ }
171
+ catch (error) {
172
+ await appendJobEvent({
173
+ jobId: suppliedOperationKey,
174
+ status: "unknown",
175
+ timestamp: new Date().toISOString(),
176
+ action: preview.action,
177
+ fingerprint,
178
+ target: preview.target,
179
+ message: error instanceof Error ? error.message : String(error),
180
+ });
181
+ throw error;
182
+ }
371
183
  }
372
184
  const GOAL_WORKSPACE_WRITE_MAX_FILES = 1_000;
373
185
  const GOAL_WORKSPACE_WRITE_MAX_TOTAL_BYTES = 20 * 1024 * 1024;
374
186
  const GOAL_PDF_UPLOAD_MAX_BYTES = 1024 * 1024 * 1024;
375
187
  const GOAL_QUEUE_MAX_ENTRIES = 500;
376
188
  const URL_QUEUE_DESCRIPTION_POINTER = "Skill queue managed by the system.";
189
+ const RECESS_GIT_METADATA = "recess-workspace.json";
190
+ async function runGit(cwd, args, options = {}) {
191
+ return new Promise((resolve, reject) => {
192
+ execFile("git", ["-C", cwd, ...args], {
193
+ encoding: "utf8",
194
+ maxBuffer: 32 * 1024 * 1024,
195
+ env: options.identity
196
+ ? {
197
+ ...process.env,
198
+ GIT_AUTHOR_NAME: "Recess CLI",
199
+ GIT_AUTHOR_EMAIL: "cli@recess.gg",
200
+ GIT_COMMITTER_NAME: "Recess CLI",
201
+ GIT_COMMITTER_EMAIL: "cli@recess.gg",
202
+ }
203
+ : process.env,
204
+ }, (error, stdout, stderr) => {
205
+ if (error) {
206
+ reject(new CliError("git_failed", `Git command failed: git ${args.join(" ")} (${stderr.trim() || error.message})`));
207
+ return;
208
+ }
209
+ resolve(stdout);
210
+ });
211
+ });
212
+ }
213
+ async function ensureLocalGitIdentity(root) {
214
+ for (const [key, fallback] of [
215
+ ["user.name", "Recess CLI"],
216
+ ["user.email", "cli@recess.gg"],
217
+ ]) {
218
+ let configured = "";
219
+ try {
220
+ configured = (await runGit(root, ["config", "--get", key])).trim();
221
+ }
222
+ catch {
223
+ // A missing inherited value exits 1. Install a repo-local fallback so
224
+ // the checkout can be committed without changing the user's global Git.
225
+ }
226
+ if (!configured)
227
+ await runGit(root, ["config", key, fallback]);
228
+ }
229
+ }
230
+ function resolveWorkspaceCheckoutPath(root, workspacePath) {
231
+ const normalized = workspacePath.replaceAll("\\", "/");
232
+ const segments = normalized.split("/");
233
+ const isGitMetadataSegment = (segment) => {
234
+ // NTFS canonicalizes trailing dots/spaces, supports alternate data streams,
235
+ // and may expose .git through an 8.3 alias such as GIT~1. Reject those
236
+ // spellings too so a checkout cannot write Git metadata before `git init`.
237
+ const ntfsName = segment
238
+ .split(":", 1)[0]
239
+ .replace(/[ .]+$/g, "")
240
+ .toLowerCase();
241
+ return ntfsName === ".git" || /^git~\d+$/.test(ntfsName);
242
+ };
243
+ if (!normalized ||
244
+ path.posix.isAbsolute(normalized) ||
245
+ segments.some((segment) => !segment ||
246
+ segment === "." ||
247
+ segment === ".." ||
248
+ isGitMetadataSegment(segment))) {
249
+ throw new CliError("invalid_workspace", `Refusing unsafe workspace path from Mesa: ${workspacePath}`);
250
+ }
251
+ const target = path.resolve(root, ...segments);
252
+ if (!target.startsWith(`${root}${path.sep}`)) {
253
+ throw new CliError("invalid_workspace", `Refusing workspace path outside checkout: ${workspacePath}`);
254
+ }
255
+ return target;
256
+ }
257
+ function recessGitMetadataPath(root) {
258
+ return path.join(root, ".git", RECESS_GIT_METADATA);
259
+ }
260
+ async function writeRecessGitMetadata(root, metadata) {
261
+ const destination = recessGitMetadataPath(root);
262
+ const temporary = `${destination}.${process.pid}.tmp`;
263
+ await fs.writeFile(temporary, `${JSON.stringify(metadata, null, 2)}\n`, {
264
+ encoding: "utf8",
265
+ mode: 0o600,
266
+ });
267
+ await fs.rename(temporary, destination);
268
+ }
269
+ async function readRecessGitMetadata(sourceDirectory) {
270
+ const requestedRoot = path.resolve(sourceDirectory);
271
+ const requestedRealRoot = await fs.realpath(requestedRoot).catch(() => null);
272
+ if (!requestedRealRoot) {
273
+ throw new CliError("invalid_checkout", `Recess checkout does not exist: ${requestedRoot}`);
274
+ }
275
+ const root = (await runGit(requestedRoot, ["rev-parse", "--show-toplevel"])).trim();
276
+ const realRoot = await fs.realpath(root);
277
+ if (realRoot !== requestedRealRoot) {
278
+ throw new CliError("invalid_checkout", `--source-dir must be the Recess checkout root: ${root}`);
279
+ }
280
+ let parsed;
281
+ try {
282
+ parsed = JSON.parse(await fs.readFile(recessGitMetadataPath(root), "utf8"));
283
+ }
284
+ catch {
285
+ throw new CliError("invalid_checkout", `${root} is not a Recess goal workspace checkout.`);
286
+ }
287
+ const value = parsed;
288
+ const candidateTarget = value.target;
289
+ const target = value.schemaVersion === 1 && typeof value.goalId === "string"
290
+ ? { kind: "goal", goalId: value.goalId }
291
+ : candidateTarget;
292
+ if (![1, 2].includes(typeof value.schemaVersion === "number" ? value.schemaVersion : 0) ||
293
+ typeof value.studentId !== "string" ||
294
+ !target ||
295
+ (target.kind !== "goal" && target.kind !== "draft") ||
296
+ (target.kind === "goal" && typeof target.goalId !== "string") ||
297
+ (target.kind === "draft" && typeof target.draftSlug !== "string") ||
298
+ typeof value.changeId !== "string" ||
299
+ typeof value.upstreamCommit !== "string") {
300
+ throw new CliError("invalid_checkout", `${root} has invalid Recess workspace metadata.`);
301
+ }
302
+ return {
303
+ root: realRoot,
304
+ metadata: {
305
+ schemaVersion: 2,
306
+ studentId: value.studentId,
307
+ target: target,
308
+ changeId: value.changeId,
309
+ upstreamCommit: value.upstreamCommit,
310
+ },
311
+ };
312
+ }
313
+ async function checkoutGoalWorkspace(input) {
314
+ const outputDirectory = path.resolve(input.outputDirectory);
315
+ if (await fs.lstat(outputDirectory).catch(() => null)) {
316
+ throw new CliError("destination_exists", `Checkout destination already exists: ${outputDirectory}`);
317
+ }
318
+ if (input.snapshot.files.length > GOAL_WORKSPACE_WRITE_MAX_FILES) {
319
+ throw new CliError("workspace_too_large", `Workspace has more than ${GOAL_WORKSPACE_WRITE_MAX_FILES} files.`);
320
+ }
321
+ const sizeBytes = input.snapshot.files.reduce((total, file) => total + file.sizeBytes, 0);
322
+ if (sizeBytes > GOAL_WORKSPACE_WRITE_MAX_TOTAL_BYTES) {
323
+ throw new CliError("workspace_too_large", `Workspace exceeds ${GOAL_WORKSPACE_WRITE_MAX_TOTAL_BYTES} decoded bytes.`);
324
+ }
325
+ const parent = path.dirname(outputDirectory);
326
+ await fs.mkdir(parent, { recursive: true });
327
+ const temporary = await fs.mkdtemp(path.join(parent, ".recess-checkout-"));
328
+ let moved = false;
329
+ try {
330
+ for (const file of input.snapshot.files) {
331
+ const destination = resolveWorkspaceCheckoutPath(temporary, file.path);
332
+ const bytes = Buffer.from(file.content, file.contentEncoding === "base64" ? "base64" : "utf8");
333
+ const sha256 = createHash("sha256").update(bytes).digest("hex");
334
+ if (bytes.byteLength !== file.sizeBytes || sha256 !== file.sha256) {
335
+ throw new CliError("invalid_workspace", `Mesa snapshot integrity check failed for ${file.path}.`);
336
+ }
337
+ await fs.mkdir(path.dirname(destination), { recursive: true });
338
+ await fs.writeFile(destination, bytes);
339
+ }
340
+ await runGit(temporary, ["init", "--quiet", "--initial-branch=main"]);
341
+ await ensureLocalGitIdentity(temporary);
342
+ await runGit(temporary, ["add", "-A"]);
343
+ await runGit(temporary, [
344
+ "commit",
345
+ "--quiet",
346
+ "--allow-empty",
347
+ "-m",
348
+ `Checkout Recess ${input.target.kind} workspace at ${input.snapshot.changeId}`,
349
+ ], { identity: true });
350
+ const upstreamCommit = (await runGit(temporary, ["rev-parse", "HEAD"])).trim();
351
+ await writeRecessGitMetadata(temporary, {
352
+ schemaVersion: 2,
353
+ studentId: input.studentId,
354
+ target: input.target,
355
+ changeId: input.snapshot.changeId,
356
+ upstreamCommit,
357
+ });
358
+ await fs.rename(temporary, outputDirectory);
359
+ moved = true;
360
+ return {
361
+ directory: outputDirectory,
362
+ studentId: input.studentId,
363
+ target: input.target,
364
+ changeId: input.snapshot.changeId,
365
+ upstreamCommit,
366
+ fileCount: input.snapshot.files.length,
367
+ sizeBytes,
368
+ 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>.`,
369
+ };
370
+ }
371
+ finally {
372
+ if (!moved)
373
+ await fs.rm(temporary, { recursive: true, force: true });
374
+ }
375
+ }
376
+ async function readRemoteWorkspaceSnapshot(api, studentId, target) {
377
+ return unwrap(await api.client.GET("/tutor/browser/mesa/workspace-files", {
378
+ params: {
379
+ query: {
380
+ studentUserId: studentId,
381
+ ...(target.kind === "goal"
382
+ ? { goalId: target.goalId }
383
+ : { draftSlug: target.draftSlug }),
384
+ },
385
+ },
386
+ }));
387
+ }
388
+ async function assertCheckoutReadyForMaterialization(checkout) {
389
+ const status = await runGit(checkout.root, ["status", "--porcelain=v1"]);
390
+ if (status.trim()) {
391
+ throw new CliError("uncommitted_changes", "Commit or discard all local workspace changes before creating or capturing.");
392
+ }
393
+ const head = (await runGit(checkout.root, ["rev-parse", "HEAD"])).trim();
394
+ if (head !== checkout.metadata.upstreamCommit) {
395
+ throw new CliError("unpushed_changes", "Push every local workspace commit before creating a goal or capturing a template.");
396
+ }
397
+ return head;
398
+ }
399
+ async function readGoalWorkspaceGitChanges(root, base) {
400
+ const status = await runGit(root, ["status", "--porcelain=v1"]);
401
+ if (status.trim()) {
402
+ throw new CliError("uncommitted_changes", "Commit or discard all local workspace changes before pushing.");
403
+ }
404
+ const head = (await runGit(root, ["rev-parse", "HEAD"])).trim();
405
+ if (head === base) {
406
+ throw new CliError("no_changes", "There are no local commits to push.");
407
+ }
408
+ await runGit(root, ["merge-base", "--is-ancestor", base, head]);
409
+ const commitLines = (await runGit(root, [
410
+ "log",
411
+ "--reverse",
412
+ "--format=%H%x09%s",
413
+ `${base}..${head}`,
414
+ ]))
415
+ .trim()
416
+ .split("\n")
417
+ .filter(Boolean);
418
+ const commits = commitLines.map((line) => {
419
+ const tab = line.indexOf("\t");
420
+ return { id: line.slice(0, tab), subject: line.slice(tab + 1) };
421
+ });
422
+ const raw = await runGit(root, [
423
+ "diff",
424
+ "--name-status",
425
+ "--no-renames",
426
+ "-z",
427
+ `${base}..${head}`,
428
+ ]);
429
+ const fields = raw.split("\0").filter((field) => field.length > 0);
430
+ if (fields.length % 2 !== 0) {
431
+ throw new CliError("git_failed", "Git returned an invalid workspace diff.");
432
+ }
433
+ const changes = [];
434
+ let sizeBytes = 0;
435
+ for (let index = 0; index < fields.length; index += 2) {
436
+ const statusCode = fields[index][0];
437
+ const workspacePath = fields[index + 1];
438
+ resolveWorkspaceCheckoutPath(root, workspacePath);
439
+ if (statusCode === "D") {
440
+ changes.push({ path: workspacePath, action: "delete" });
441
+ continue;
442
+ }
443
+ if (!["A", "M", "T"].includes(statusCode)) {
444
+ throw new CliError("unsupported_git_change", `Unsupported Git change ${fields[index]} for ${workspacePath}.`);
445
+ }
446
+ const absolutePath = resolveWorkspaceCheckoutPath(root, workspacePath);
447
+ const stat = await fs.lstat(absolutePath).catch(() => null);
448
+ if (!stat?.isFile() || stat.isSymbolicLink()) {
449
+ throw new CliError("invalid_workspace", `Refusing non-file workspace entry: ${workspacePath}`);
450
+ }
451
+ const bytes = await fs.readFile(absolutePath);
452
+ sizeBytes += bytes.byteLength;
453
+ changes.push({
454
+ path: workspacePath,
455
+ action: "upsert",
456
+ content: bytes.toString("base64"),
457
+ contentEncoding: "base64",
458
+ sizeBytes: bytes.byteLength,
459
+ sha256: createHash("sha256").update(bytes).digest("hex"),
460
+ });
461
+ }
462
+ if (changes.length === 0) {
463
+ throw new CliError("no_changes", "Local commits do not change workspace file contents.");
464
+ }
465
+ if (changes.length > GOAL_WORKSPACE_WRITE_MAX_FILES) {
466
+ throw new CliError("workspace_too_large", `Push changes more than ${GOAL_WORKSPACE_WRITE_MAX_FILES} files.`);
467
+ }
468
+ if (sizeBytes > GOAL_WORKSPACE_WRITE_MAX_TOTAL_BYTES) {
469
+ throw new CliError("workspace_too_large", `Push exceeds ${GOAL_WORKSPACE_WRITE_MAX_TOTAL_BYTES} decoded bytes.`);
470
+ }
471
+ return { head, commits, changes, sizeBytes };
472
+ }
377
473
  function approvalTokenFor(preview) {
378
474
  return createHash("sha256").update(JSON.stringify(preview)).digest("hex");
379
475
  }
380
- function requirePreviewBoundConfirmation(parsed, preview) {
476
+ function operationKeyFor(fingerprint) {
477
+ return `op_${randomUUID().replaceAll("-", "")}_${fingerprint}`;
478
+ }
479
+ function assertOperationKeyMatchesPreview(operationKey, fingerprint, preview) {
480
+ if (!operationKey.endsWith(`_${fingerprint}`)) {
481
+ 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" });
482
+ }
483
+ }
484
+ async function requirePreviewBoundConfirmation(parsed, preview) {
381
485
  const approvalToken = approvalTokenFor(preview);
486
+ const suppliedOperationKey = flagString(parsed, "operation-key");
382
487
  const boundPreview = {
383
488
  ...preview,
384
- details: { ...preview.details, approvalToken },
489
+ details: {
490
+ ...preview.details,
491
+ approvalToken,
492
+ operationKey: suppliedOperationKey ?? operationKeyFor(approvalToken),
493
+ },
385
494
  };
386
495
  if (!hasFlag(parsed, "confirm")) {
496
+ await appendJobEvent({
497
+ jobId: String(boundPreview.details.operationKey),
498
+ status: "awaiting_confirmation",
499
+ timestamp: new Date().toISOString(),
500
+ action: preview.action,
501
+ fingerprint: approvalToken,
502
+ target: preview.target,
503
+ });
387
504
  requireConfirmation(false, boundPreview);
388
505
  }
389
506
  const supplied = flagString(parsed, "approval-token");
390
507
  if (supplied !== approvalToken) {
391
508
  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
509
  }
510
+ if (!suppliedOperationKey) {
511
+ throw new CliError("confirmation_required", "A confirmed write requires the operation key from its approved preview.", 2, { preview: boundPreview, requiredFlag: "--operation-key" });
512
+ }
513
+ assertOperationKeyMatchesPreview(suppliedOperationKey, approvalToken, boundPreview);
514
+ await appendJobEvent({
515
+ jobId: suppliedOperationKey,
516
+ status: "running",
517
+ timestamp: new Date().toISOString(),
518
+ action: preview.action,
519
+ fingerprint: approvalToken,
520
+ target: preview.target,
521
+ });
522
+ return { operationKey: suppliedOperationKey, fingerprint: approvalToken };
523
+ }
524
+ async function previewBoundWrite(parsed, preview, execute) {
525
+ const context = await requirePreviewBoundConfirmation(parsed, preview);
526
+ try {
527
+ const result = await withIdempotencyContext(context, execute);
528
+ await appendJobEvent({
529
+ jobId: context.operationKey,
530
+ status: "completed",
531
+ timestamp: new Date().toISOString(),
532
+ action: preview.action,
533
+ fingerprint: context.fingerprint,
534
+ target: preview.target,
535
+ result,
536
+ });
537
+ return result;
538
+ }
539
+ catch (error) {
540
+ await appendJobEvent({
541
+ jobId: context.operationKey,
542
+ status: "unknown",
543
+ timestamp: new Date().toISOString(),
544
+ action: preview.action,
545
+ fingerprint: context.fingerprint,
546
+ target: preview.target,
547
+ message: error instanceof Error ? error.message : String(error),
548
+ });
549
+ throw error;
550
+ }
393
551
  }
394
552
  async function cleanupPlannerUpload(api, conversationId, signed, includeDelete) {
395
553
  if (signed.kind === "multipart") {
@@ -553,12 +711,6 @@ async function readGoalWorkspaceWriteSource(parsed) {
553
711
  }
554
712
  return { files, source, sizeBytes, sha256: hash.digest("hex") };
555
713
  }
556
- function assertChoice(value, choices, label) {
557
- if (!choices.includes(value)) {
558
- throw new CliError("invalid_arguments", `${label} must be one of: ${choices.join(", ")}.`);
559
- }
560
- return value;
561
- }
562
714
  const PAYRUN_STATUSES = [
563
715
  "UPCOMING",
564
716
  "DRAFT",
@@ -582,36 +734,6 @@ const PAYOUT_STATUS_SIDE_EFFECTS = {
582
734
  PAID: "marks the invoice paid and deducts its total from the recipient's account balance",
583
735
  CANCELED: "cancels the invoice and reverses any balance items carried on it",
584
736
  };
585
- // Ascending onboarding stage order (mirrors STAGE_ORDER in
586
- // apps/web-server/src/libs/onboarding-stage.ts); a lower index is earlier
587
- // progress, so a target below the current stage is a backward move.
588
- const ONBOARDING_STAGES = [
589
- "LEGACY",
590
- "PROVISIONED",
591
- "PARENT_CONFIRMED",
592
- "CLEARED_FOR_COHORT",
593
- "COMPLETE",
594
- ];
595
- // What each stage unlocks/implies, surfaced in the set-stage preview.
596
- const ONBOARDING_STAGE_SIDE_EFFECTS = {
597
- LEGACY: "the pre-onboarding baseline",
598
- PROVISIONED: "kid accounts provisioned, awaiting parent confirmation",
599
- PARENT_CONFIRMED: "parent has confirmed setup",
600
- CLEARED_FOR_COHORT: "unlocks cohort registration for school families",
601
- COMPLETE: "onboarding finished",
602
- };
603
- const ONBOARDING_ACCOUNT_STATES = [
604
- "ACTIVE",
605
- "PENDING_PAYMENT",
606
- "PAUSED",
607
- "BOOTED",
608
- ];
609
- const ONBOARDING_CONDITIONS = [
610
- "app_downloaded",
611
- "tutor_met",
612
- "goals_loaded",
613
- "ma_diagnostic",
614
- ];
615
737
  const SCHOOL_TIER_OPTIONS = [
616
738
  { id: "social", name: "Social", defaultClassSlots: 2 },
617
739
  { id: "academics", name: "Academics", defaultClassSlots: 0 },
@@ -664,41 +786,6 @@ const CONTENT_LIBRARY_DISCOVERY_LANES = [
664
786
  "idea-games",
665
787
  ];
666
788
  const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
667
- /**
668
- * Read and parse a JSON file supplied by an authoring agent. Large payloads —
669
- * a setupWorkflowSpec, an answers map, a goal description — go through files
670
- * rather than argv: a shell mangles embedded quotes and newlines, and a spec
671
- * that survived a round trip through `--json '<...>'` is not the spec that was
672
- * reviewed.
673
- */
674
- async function readJsonValue(filePath, label) {
675
- const absolutePath = path.resolve(filePath);
676
- let raw;
677
- try {
678
- raw = await fs.readFile(absolutePath, "utf8");
679
- }
680
- catch (error) {
681
- if (error.code === "ENOENT") {
682
- throw new CliError("invalid_arguments", `${label} does not exist: ${absolutePath}`);
683
- }
684
- throw error;
685
- }
686
- let parsed;
687
- try {
688
- parsed = JSON.parse(raw);
689
- }
690
- catch (error) {
691
- throw new CliError("invalid_arguments", `${label} is not valid JSON (${absolutePath}): ${error instanceof Error ? error.message : String(error)}`);
692
- }
693
- return { absolutePath, raw, parsed };
694
- }
695
- async function readJsonFile(filePath, label) {
696
- const { absolutePath, parsed } = await readJsonValue(filePath, label);
697
- if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
698
- throw new CliError("invalid_arguments", `${label} must be a JSON object (${absolutePath}).`);
699
- }
700
- return parsed;
701
- }
702
789
  function parseGoalQueueEntries(value, label) {
703
790
  if (!Array.isArray(value)) {
704
791
  throw new CliError("invalid_arguments", `${label} must be a JSON array of queue entries.`);
@@ -1025,7 +1112,7 @@ function optionalDocString(doc, key) {
1025
1112
  return value;
1026
1113
  }
1027
1114
  /**
1028
- * Coerce an authored template file into the exact `POST /admin/goal-templates/`
1115
+ * Coerce an authored template file into the exact `POST /ai/goal-templates/`
1029
1116
  * body. Every unknown key is dropped rather than forwarded, so a stale field
1030
1117
  * copied from an old export cannot ride along into a create, and the shape
1031
1118
  * failures an agent actually makes (missing slug, spec as a string, tags as a
@@ -1184,34 +1271,6 @@ function flagStatusList(parsed, name, choices) {
1184
1271
  }
1185
1272
  return values.map((value) => assertChoice(value, choices, `--${name}`));
1186
1273
  }
1187
- /**
1188
- * Parse one `--kid` spec into an enroll roster entry.
1189
- *
1190
- * Shape: `<quoteLineId>:<firstName>[:<age>]` — the quote line FIRST, because
1191
- * the line id is the identity the enrollment matches on and the name is only a
1192
- * label. A staffer typing these reads them off the quote, in order.
1193
- *
1194
- * ⚠️ THE NAME MAY CONTAIN NOTHING SURPRISING, but an age must be a real number:
1195
- * age decides `birthdate` at creation AND rides in the frozen payment snapshot
1196
- * that a later takeover is compared against, so a silently-dropped or
1197
- * mistyped age is a money-adjacent error, not a cosmetic one. Absent is fine
1198
- * (the server treats it as "not stated"); unparseable is refused here.
1199
- */
1200
- function parseEnrollKidSpec(raw) {
1201
- const parts = raw.split(":").map((part) => part.trim());
1202
- const [quoteLineId, firstName, ageRaw, ...extra] = parts;
1203
- if (!quoteLineId || !firstName || extra.length > 0) {
1204
- throw new CliError("invalid_arguments", `--kid must be "<quoteLineId>:<firstName>[:<age>]"; got "${raw}".`);
1205
- }
1206
- if (ageRaw === undefined || ageRaw === "") {
1207
- return { quoteLineId, firstName };
1208
- }
1209
- const age = Number(ageRaw);
1210
- if (!Number.isInteger(age) || age < 1 || age > 25) {
1211
- throw new CliError("invalid_arguments", `--kid age must be a whole number from 1 to 25; got "${ageRaw}".`);
1212
- }
1213
- return { quoteLineId, firstName, age };
1214
- }
1215
1274
  function flagCents(parsed, name, options = {}) {
1216
1275
  const value = flagNumber(parsed, name);
1217
1276
  if (value === undefined) {
@@ -1253,6 +1312,48 @@ function flagDateOnly(parsed, name) {
1253
1312
  return raw;
1254
1313
  }
1255
1314
  const DAY_MS = 24 * 60 * 60 * 1000;
1315
+ async function waitForContentLibraryLive(api, gems, timeoutSeconds) {
1316
+ const pending = new Set(gems);
1317
+ const latest = new Map();
1318
+ const deadline = Date.now() + timeoutSeconds * 1000;
1319
+ let delayMs = 2_000;
1320
+ while (pending.size > 0) {
1321
+ const targets = Array.from(pending);
1322
+ for (let index = 0; index < targets.length; index += 3) {
1323
+ const batch = targets.slice(index, index + 3);
1324
+ await Promise.all(batch.map(async (gem) => {
1325
+ const status = unwrap(await api.client.GET("/admin/content-library/status", {
1326
+ params: { query: { gem } },
1327
+ }));
1328
+ latest.set(gem, status);
1329
+ if (status.stage === "live") {
1330
+ pending.delete(gem);
1331
+ return;
1332
+ }
1333
+ const errors = [
1334
+ status.progress.metadata,
1335
+ status.progress.cover,
1336
+ status.progress.searchIndex,
1337
+ ].filter((step) => step.state === "error");
1338
+ if (errors.length > 0) {
1339
+ throw new CliError("async_job_failed", `Content Library polishing failed for ${gem}.`, 1, { status });
1340
+ }
1341
+ }));
1342
+ }
1343
+ if (pending.size === 0)
1344
+ break;
1345
+ if (Date.now() + delayMs >= deadline) {
1346
+ 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, {
1347
+ pending: Array.from(pending),
1348
+ latest: Object.fromEntries(latest),
1349
+ });
1350
+ }
1351
+ const jitter = Math.floor(Math.random() * Math.max(1, delayMs / 4));
1352
+ await new Promise((resolve) => setTimeout(resolve, delayMs + jitter));
1353
+ delayMs = Math.min(15_000, Math.round(delayMs * 1.7));
1354
+ }
1355
+ return { completed: true, resources: Array.from(latest.values()) };
1356
+ }
1256
1357
  // The cohort event routes take zoneless local wall-clock datetimes
1257
1358
  // (interpreted in the cohort's timezone server-side).
1258
1359
  function flagLocalDateTime(parsed, name, options = {}) {
@@ -1265,19 +1366,6 @@ function flagLocalDateTime(parsed, name, options = {}) {
1265
1366
  }
1266
1367
  return match[2] ? raw : `${raw}:00`;
1267
1368
  }
1268
- function flagIdList(parsed, name) {
1269
- const raw = flagString(parsed, name);
1270
- if (raw === undefined)
1271
- return [];
1272
- const ids = raw
1273
- .split(",")
1274
- .map((value) => value.trim())
1275
- .filter(Boolean);
1276
- if (ids.length === 0) {
1277
- throw new CliError("invalid_arguments", `--${name} requires a value.`);
1278
- }
1279
- return ids;
1280
- }
1281
1369
  export function penultimateCycleDayMs(cycleEndDate) {
1282
1370
  const endMs = Date.parse(cycleEndDate);
1283
1371
  if (!Number.isFinite(endMs)) {
@@ -1325,20 +1413,148 @@ function tierSlots(parsed) {
1325
1413
  export async function runCommand(argv) {
1326
1414
  const parsed = parseArgs(argv);
1327
1415
  const [noun, verb] = parsed.positionals;
1416
+ const commands = buildCommandSchema(HELP);
1328
1417
  // Before the help branch: `--version` parses as a FLAG, so `noun` is
1329
1418
  // undefined and `!noun` would return help instead. (Found by running it.)
1330
1419
  if (noun === "version" || hasFlag(parsed, "version")) {
1420
+ const unknown = Array.from(parsed.flags.keys()).filter((name) => !["deliver", "json", "version"].includes(name));
1421
+ if (unknown.length > 0) {
1422
+ throw new CliError("unknown_flag", `Unknown flag${unknown.length === 1 ? "" : "s"}: ${unknown
1423
+ .map((name) => `--${name}`)
1424
+ .join(", ")}.`, 1, { validFlags: ["--deliver", "--json", "--version"] });
1425
+ }
1331
1426
  return {
1332
1427
  cliVersion: await readCliVersion(),
1333
1428
  skillVersion: await readBundledSkillVersion(),
1334
1429
  };
1335
1430
  }
1336
- if (!noun || noun === "help" || hasFlag(parsed, "help")) {
1337
- return { help: HELP };
1431
+ if (!noun) {
1432
+ const unknown = Array.from(parsed.flags.keys()).filter((name) => !["deliver", "help", "json", "profile"].includes(name));
1433
+ if (unknown.length > 0) {
1434
+ throw new CliError("unknown_flag", `Unknown flag${unknown.length === 1 ? "" : "s"}: ${unknown
1435
+ .map((name) => `--${name}`)
1436
+ .join(", ")}.`, 1, { validFlags: ["--deliver", "--help", "--json", "--profile"] });
1437
+ }
1438
+ return { help: scopedHelp(HELP, commands, []) };
1439
+ }
1440
+ if (noun === "help" || hasFlag(parsed, "help")) {
1441
+ const scope = noun === "help" ? parsed.positionals.slice(1) : parsed.positionals;
1442
+ return { help: scopedHelp(HELP, commands, scope) };
1443
+ }
1444
+ validateInvocation(parsed, commands);
1445
+ if (noun === "agent-context") {
1446
+ const profiles = await listProfiles();
1447
+ return agentContext(commands, {
1448
+ cliVersion: await readCliVersion(),
1449
+ availableProfiles: profiles.profiles.map((profile) => profile.name),
1450
+ feedbackUpstreamConfigured: Boolean(process.env.RECESS_CLI_FEEDBACK_ENDPOINT),
1451
+ });
1452
+ }
1453
+ if (noun === "profile") {
1454
+ const name = parsed.positionals[2];
1455
+ if (verb === "list")
1456
+ return listProfiles();
1457
+ if (verb === "show") {
1458
+ if (!name)
1459
+ throw new CliError("invalid_arguments", "Missing profile name.");
1460
+ const profiles = await listProfiles();
1461
+ const profile = profiles.profiles.find((entry) => entry.name === name);
1462
+ if (!profile)
1463
+ throw new CliError("not_found", `Unknown profile ${name}.`);
1464
+ return { ...profile, active: profiles.activeProfile === name };
1465
+ }
1466
+ if (verb === "save") {
1467
+ if (!name)
1468
+ throw new CliError("invalid_arguments", "Missing profile name.");
1469
+ const current = await resolveConfig(flagString(parsed, "profile"));
1470
+ const apiOrigin = flagString(parsed, "api-origin") ?? current.apiOrigin;
1471
+ const webOrigin = flagString(parsed, "web-origin") ?? current.webOrigin;
1472
+ const oauthClientId = flagString(parsed, "oauth-client-id") ?? current.oauthClientId;
1473
+ for (const [label, value] of [
1474
+ ["--api-origin", apiOrigin],
1475
+ ["--web-origin", webOrigin],
1476
+ ]) {
1477
+ try {
1478
+ new URL(value);
1479
+ }
1480
+ catch {
1481
+ throw new CliError("invalid_arguments", `${label} must be a URL.`);
1482
+ }
1483
+ }
1484
+ await saveProfile(name, { apiOrigin, webOrigin, oauthClientId });
1485
+ return { saved: name, apiOrigin, webOrigin, oauthClientId };
1486
+ }
1487
+ if (verb === "use") {
1488
+ if (!name)
1489
+ throw new CliError("invalid_arguments", "Missing profile name.");
1490
+ await useProfile(name);
1491
+ return { activeProfile: name };
1492
+ }
1493
+ if (verb === "delete") {
1494
+ if (!name)
1495
+ throw new CliError("invalid_arguments", "Missing profile name.");
1496
+ await deleteProfile(name);
1497
+ return { deleted: name };
1498
+ }
1499
+ }
1500
+ if (noun === "jobs") {
1501
+ if (verb === "list") {
1502
+ const limit = flagNumber(parsed, "limit") ?? 20;
1503
+ if (!Number.isInteger(limit) || limit < 1 || limit > 100) {
1504
+ throw new CliError("invalid_arguments", "--limit must be an integer from 1 through 100.");
1505
+ }
1506
+ return listJobs(limit);
1507
+ }
1508
+ if (verb === "get") {
1509
+ const jobId = positional(parsed, 2, "operation key");
1510
+ const result = await getJob(jobId);
1511
+ if (!result.job)
1512
+ throw new CliError("not_found", `Unknown job ${jobId}.`);
1513
+ return result;
1514
+ }
1515
+ if (verb === "prune") {
1516
+ const olderThanDays = flagNumber(parsed, "older-than-days") ?? 30;
1517
+ if (!Number.isInteger(olderThanDays) ||
1518
+ olderThanDays < 1 ||
1519
+ olderThanDays > 3650) {
1520
+ throw new CliError("invalid_arguments", "--older-than-days must be an integer from 1 through 3650.");
1521
+ }
1522
+ return pruneJobs(olderThanDays);
1523
+ }
1524
+ }
1525
+ if (noun === "feedback") {
1526
+ if (verb === "list") {
1527
+ const limit = flagNumber(parsed, "limit") ?? 20;
1528
+ if (!Number.isInteger(limit) || limit < 1 || limit > 100) {
1529
+ throw new CliError("invalid_arguments", "--limit must be an integer from 1 through 100.");
1530
+ }
1531
+ return listFeedback(limit);
1532
+ }
1533
+ if (verb === "submit") {
1534
+ const feedbackText = parsed.positionals.slice(2).join(" ").trim();
1535
+ if (!feedbackText) {
1536
+ throw new CliError("invalid_arguments", "Feedback text must not be empty.");
1537
+ }
1538
+ if (feedbackText.length > 4_000) {
1539
+ throw new CliError("invalid_arguments", "Feedback text must be 4,000 characters or fewer.");
1540
+ }
1541
+ return writeCommand(parsed, {
1542
+ action: "record Recess CLI feedback locally and send it upstream when configured",
1543
+ target: {
1544
+ upstreamConfigured: Boolean(process.env.RECESS_CLI_FEEDBACK_ENDPOINT),
1545
+ },
1546
+ request: { text: feedbackText },
1547
+ }, () => submitFeedback({
1548
+ id: flagString(parsed, "operation-key", { required: true }),
1549
+ text: feedbackText,
1550
+ createdAt: new Date().toISOString(),
1551
+ }));
1552
+ }
1338
1553
  }
1339
- const config = await resolveConfig();
1554
+ const config = await resolveConfig(flagString(parsed, "profile"));
1555
+ const requestReason = noun === "auth" ? undefined : requiredRequestReason(parsed);
1340
1556
  if (noun === "doctor")
1341
- return doctor(config);
1557
+ return doctor(config, requestReason);
1342
1558
  if (noun === "setup") {
1343
1559
  // Always lay down the bundled copy first: it is the floor, and it is the
1344
1560
  // only copy guaranteed to match this binary. The served upgrade below is
@@ -1348,6 +1564,7 @@ export async function runCommand(argv) {
1348
1564
  apiOrigin: config.apiOrigin,
1349
1565
  sessionCookie: config.sessionCookie,
1350
1566
  cliVersion: await readCliVersion(),
1567
+ reason: requestReason,
1351
1568
  });
1352
1569
  const ephemeral = isEphemeralInstall();
1353
1570
  // Reuse a session that is still live; a missing or already-expired one is
@@ -1400,12 +1617,12 @@ export async function runCommand(argv) {
1400
1617
  if (verb === "status")
1401
1618
  return sessionStatus(config);
1402
1619
  if (verb === "logout") {
1403
- await clearStoredSession();
1620
+ await clearStoredSession(config.profileName);
1404
1621
  return { loggedOut: true };
1405
1622
  }
1406
1623
  throw new CliError("invalid_arguments", "Use auth login, request, poll, status, or logout.");
1407
1624
  }
1408
- const api = new RecessAdminApi(config);
1625
+ const api = new RecessAdminApi(config, requestReason);
1409
1626
  api.requireAuth();
1410
1627
  if (noun === "village") {
1411
1628
  const targetWorldId = flagString(parsed, "world") ?? "village-1";
@@ -1424,17 +1641,180 @@ export async function runCommand(argv) {
1424
1641
  return value;
1425
1642
  };
1426
1643
  if (verb === "render") {
1644
+ const renderWorldId = flagString(parsed, "world") ??
1645
+ (config.user && config.user.role !== "ADMIN"
1646
+ ? `home-${config.user.id}`
1647
+ : targetWorldId);
1427
1648
  const params = new URLSearchParams({
1428
- worldId: targetWorldId,
1429
1649
  minX: String(numberFlag("min-x")),
1430
1650
  minZ: String(numberFlag("min-z")),
1431
1651
  maxX: String(numberFlag("max-x")),
1432
1652
  maxZ: String(numberFlag("max-z")),
1433
1653
  });
1434
- return api.villageRequest(`/admin/village/render?${params}`);
1654
+ return api.villageRead(`/api/cli/worlds/${encodeURIComponent(renderWorldId)}/render?${params}`);
1655
+ }
1656
+ const directWorldId = flagString(parsed, "world") ??
1657
+ (config.user?.id ? `home-${config.user.id}` : targetWorldId);
1658
+ if (verb === "library") {
1659
+ const action = positional(parsed, 2, "Village library action");
1660
+ const base = `/api/cli/worlds/${encodeURIComponent(directWorldId)}/library`;
1661
+ if (action === "search") {
1662
+ const limit = flagNumber(parsed, "limit") ?? 20;
1663
+ if (!Number.isInteger(limit) || limit < 1 || limit > 50) {
1664
+ throw new CliError("invalid_arguments", "--limit must be an integer from 1 through 50.");
1665
+ }
1666
+ const query = flagString(parsed, "query") ??
1667
+ parsed.positionals.slice(3).join(" ").trim();
1668
+ const params = new URLSearchParams({ limit: String(limit) });
1669
+ if (query)
1670
+ params.set("q", query);
1671
+ return api.villageRead(`${base}?${params}`);
1672
+ }
1673
+ if (action === "get") {
1674
+ const modelId = positional(parsed, 3, "model ID");
1675
+ return api.villageRead(`${base}/${encodeURIComponent(modelId)}`);
1676
+ }
1677
+ throw new CliError("invalid_arguments", "Use village library search|get.");
1678
+ }
1679
+ if (verb === "objects") {
1680
+ const action = positional(parsed, 2, "Village objects action");
1681
+ const base = `/api/cli/worlds/${encodeURIComponent(directWorldId)}/objects`;
1682
+ if (action === "list") {
1683
+ const region = {
1684
+ minX: numberFlag("min-x", -20),
1685
+ minZ: numberFlag("min-z", -20),
1686
+ maxX: numberFlag("max-x", 20),
1687
+ maxZ: numberFlag("max-z", 20),
1688
+ };
1689
+ if (region.minX > region.maxX ||
1690
+ region.minZ > region.maxZ ||
1691
+ region.maxX - region.minX > 128 ||
1692
+ region.maxZ - region.minZ > 128) {
1693
+ throw new CliError("invalid_arguments", "Object bounds must be ordered and no wider than 128m per side.");
1694
+ }
1695
+ const params = new URLSearchParams(Object.fromEntries(Object.entries(region).map(([key, value]) => [key, String(value)])));
1696
+ return api.villageRead(`${base}?${params}`);
1697
+ }
1698
+ if (action === "get") {
1699
+ const objectId = positional(parsed, 3, "object ID");
1700
+ return api.villageRead(`${base}/${encodeURIComponent(objectId)}`);
1701
+ }
1702
+ throw new CliError("invalid_arguments", "Use village objects list|get.");
1703
+ }
1704
+ if (verb === "build") {
1705
+ const action = positional(parsed, 2, "Village build action");
1706
+ if (action !== "cmd") {
1707
+ throw new CliError("invalid_arguments", "Use village build cmd '<command-json>' or --file <command.json>.");
1708
+ }
1709
+ const commandFile = flagString(parsed, "file");
1710
+ const inlineCommand = parsed.positionals[3];
1711
+ if (Boolean(commandFile) === Boolean(inlineCommand)) {
1712
+ throw new CliError("invalid_arguments", "Pass exactly one command JSON argument or --file <command.json>.");
1713
+ }
1714
+ let command;
1715
+ let source;
1716
+ if (commandFile) {
1717
+ const value = await readJsonValue(commandFile, "Village command file");
1718
+ if (value.parsed === null ||
1719
+ typeof value.parsed !== "object" ||
1720
+ Array.isArray(value.parsed)) {
1721
+ throw new CliError("invalid_arguments", "Village command file must contain one JSON object.");
1722
+ }
1723
+ command = value.parsed;
1724
+ source = {
1725
+ file: value.absolutePath,
1726
+ sha256: createHash("sha256").update(value.raw).digest("hex"),
1727
+ };
1728
+ }
1729
+ else {
1730
+ try {
1731
+ const value = JSON.parse(inlineCommand);
1732
+ if (value === null ||
1733
+ typeof value !== "object" ||
1734
+ Array.isArray(value)) {
1735
+ throw new Error("command must be an object");
1736
+ }
1737
+ command = value;
1738
+ }
1739
+ catch (error) {
1740
+ throw new CliError("invalid_arguments", `Village command is not a JSON object: ${error instanceof Error ? error.message : String(error)}`);
1741
+ }
1742
+ source = { inline: true };
1743
+ }
1744
+ const buildWorldId = flagString(parsed, "world") ??
1745
+ (config.user?.id ? `home-${config.user.id}` : undefined);
1746
+ if (!buildWorldId) {
1747
+ throw new CliError("invalid_arguments", "Missing --world. Login normally to default to your home world, or pass the exact world ID.");
1748
+ }
1749
+ return writeCommand(parsed, {
1750
+ action: "run one authenticated Village build command",
1751
+ target: { worldId: buildWorldId },
1752
+ request: { command, ...source },
1753
+ details: {
1754
+ consequence: "The Village server applies the same ownership, block-limit, and broadcast rules as an in-world wrench edit.",
1755
+ },
1756
+ }, () => api.villageCommand(buildWorldId, command));
1757
+ }
1758
+ if (verb === "worlds") {
1759
+ const action = positional(parsed, 2, "Village world action");
1760
+ const worldId = positional(parsed, 3, "world ID");
1761
+ const bridgePath = `/admin/village/worlds/${encodeURIComponent(worldId)}`;
1762
+ if (action === "export") {
1763
+ const output = flagString(parsed, "out");
1764
+ if (!output) {
1765
+ return api.villageRequest(`${bridgePath}/export`);
1766
+ }
1767
+ const absolutePath = path.resolve(output);
1768
+ return writeCommand(parsed, {
1769
+ action: "export a Village world bundle to a local file",
1770
+ target: { worldId, file: absolutePath },
1771
+ request: { overwrite: true },
1772
+ }, async () => {
1773
+ const bundle = await api.villageRequest(`${bridgePath}/export`);
1774
+ const contents = `${JSON.stringify(bundle, null, 2)}\n`;
1775
+ await fs.writeFile(absolutePath, contents, "utf8");
1776
+ return {
1777
+ worldId,
1778
+ file: absolutePath,
1779
+ sizeBytes: Buffer.byteLength(contents),
1780
+ };
1781
+ });
1782
+ }
1783
+ if (action === "import") {
1784
+ const value = await readJsonValue(flagString(parsed, "file", { required: true }), "Village world bundle");
1785
+ return writeCommand(parsed, {
1786
+ action: "replace a Village world from an imported bundle",
1787
+ target: { worldId },
1788
+ request: {
1789
+ file: value.absolutePath,
1790
+ sizeBytes: Buffer.byteLength(value.raw),
1791
+ sha256: createHash("sha256").update(value.raw).digest("hex"),
1792
+ },
1793
+ details: {
1794
+ consequence: "The imported rows replace this world's current data and broadcast a live world reset.",
1795
+ },
1796
+ }, () => api.villageRequest(`${bridgePath}/import`, {
1797
+ method: "POST",
1798
+ body: { bundle: value.parsed, confirm: "import" },
1799
+ }));
1800
+ }
1801
+ if (action === "promote") {
1802
+ return writeCommand(parsed, {
1803
+ action: "promote a Village mirror or archive into the live hub",
1804
+ target: { worldId },
1805
+ request: { confirm: "promote" },
1806
+ details: {
1807
+ consequence: "The current hub is archived, this world replaces it atomically, and connected players receive a live world reset.",
1808
+ },
1809
+ }, () => api.villageRequest(`${bridgePath}/promote`, {
1810
+ method: "POST",
1811
+ body: { confirm: "promote" },
1812
+ }));
1813
+ }
1814
+ throw new CliError("invalid_arguments", "Use village worlds export|import|promote.");
1435
1815
  }
1436
1816
  if (verb !== "models") {
1437
- throw new CliError("invalid_arguments", "Use village models or village render …");
1817
+ throw new CliError("invalid_arguments", "Use village build …, village library …, village objects …, village worlds …, village models …, or village render …");
1438
1818
  }
1439
1819
  const action = positional(parsed, 2, "Village model action");
1440
1820
  if (action === "list") {
@@ -1865,47 +2245,8 @@ export async function runCommand(argv) {
1865
2245
  params: { query: { subscriptionId } },
1866
2246
  }));
1867
2247
  }
1868
- if (noun === "applications" && verb === "enroll") {
1869
- const applicationId = positional(parsed, 2, "application ID");
1870
- const quoteId = flagString(parsed, "quote", { required: true });
1871
- const institutionSlug = flagString(parsed, "school", { required: true });
1872
- const familyId = flagString(parsed, "family");
1873
- const note = flagString(parsed, "note");
1874
- // Repeatable --kid, one per PRICED LINE on the quote. The server refuses a
1875
- // partial roster (every priced student must be enrolled), so this is
1876
- // deliberately not a convenience list — it is the whole quote, echoed back.
1877
- const kidSpecs = flagList(parsed, "kid");
1878
- if (kidSpecs.length === 0) {
1879
- throw new CliError("invalid_arguments", 'Missing --kid. Pass one per quote line: --kid "<quoteLineId>:<firstName>[:<age>]".');
1880
- }
1881
- const kids = kidSpecs.map(parseEnrollKidSpec);
1882
- // Every OTHER live child in the family must be named explicitly. The server
1883
- // refuses the whole enrollment otherwise, listing who was unlisted — so the
1884
- // failure is legible either way, but naming them here is how a staffer says
1885
- // "yes, I know, they are not enrolling".
1886
- const dispositions = flagList(parsed, "unassign").map((kidUserId) => ({
1887
- kidUserId,
1888
- action: "unassigned",
1889
- }));
1890
- return writeCommand(parsed, {
1891
- // One short clause, like every other preview in this file. The
1892
- // consequences are enumerated in `request` below, which is what the
1893
- // confirmation prompt prints in full — restating them here would make
1894
- // this the only preview a staffer has to read twice.
1895
- action: "enroll an application from its accepted quote (creates children, charges the first month, cancels marketplace subscriptions)",
1896
- target: { applicationId, quoteId, institutionSlug, familyId },
1897
- request: { kids, dispositions, note },
1898
- }, async () => unwrap(await api.client.POST("/admin/applications/{applicationId}/enroll", {
1899
- params: { path: { applicationId } },
1900
- body: {
1901
- quoteId,
1902
- institutionSlug,
1903
- kids,
1904
- ...(familyId ? { familyId } : {}),
1905
- ...(dispositions.length > 0 ? { dispositions } : {}),
1906
- ...(note ? { note } : {}),
1907
- },
1908
- })));
2248
+ if (noun === "applications" || noun === "quotes") {
2249
+ return runApplicationsCommand({ parsed, api, writeCommand });
1909
2250
  }
1910
2251
  if (noun === "cohorts" && verb === "search") {
1911
2252
  const search = parsed.positionals.slice(2).join(" ").trim();
@@ -2001,52 +2342,47 @@ export async function runCommand(argv) {
2001
2342
  ? { firstChargeAt: new Date(firstChargeAt).toISOString() }
2002
2343
  : {}),
2003
2344
  };
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 },
2345
+ // The backend owns the price/capacity plan. Recompute it on every run and
2346
+ // bind the confirmation token to that exact plan before the mutation.
2347
+ const preflight = unwrap(await api.client.POST("/admin/cohorts/enroll/", {
2348
+ body: { ...body, dryRun: true },
2049
2349
  }));
2350
+ const billing = preflight.plan.billing;
2351
+ const usd = (cents) => `${(cents / 100).toFixed(2)} USD`;
2352
+ const recurrence = billing.isRecurring
2353
+ ? `, recurring per ${billing.interval}`
2354
+ : "";
2355
+ const amount = billing.discountKind || billing.creditBalanceCents > 0
2356
+ ? `${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` : ""})`
2357
+ : usd(billing.effectivePriceCents);
2358
+ const preview = {
2359
+ action: preflight.plan.reusedEnrollmentId
2360
+ ? "enroll kid into cohort by reusing an existing enrollment (no charge)"
2361
+ : billing.chargesImmediately
2362
+ ? `enroll kid into cohort and CHARGE ${amount} NOW${recurrence}`
2363
+ : `enroll kid into cohort with first charge of ${amount} on ${billing.firstChargeAt}${recurrence}`,
2364
+ target: {
2365
+ kid: `${preflight.plan.kid.firstName ?? ""} ${preflight.plan.kid.lastName ?? ""}`.trim(),
2366
+ userId,
2367
+ cohort: preflight.plan.cohort.name,
2368
+ cohortId,
2369
+ course: preflight.plan.cohort.courseName,
2370
+ },
2371
+ request: { ...body, dryRun: false },
2372
+ details: {
2373
+ billing,
2374
+ reusedEnrollmentId: preflight.plan.reusedEnrollmentId,
2375
+ warnings: preflight.warnings,
2376
+ ...(preflight.warnings.length > 0 && !body.force
2377
+ ? {
2378
+ blocked: "These warnings will reject the write unless you also pass --force.",
2379
+ }
2380
+ : {}),
2381
+ },
2382
+ };
2383
+ return previewBoundWrite(parsed, preview, async () => unwrap(await api.client.POST("/admin/cohorts/enroll/", {
2384
+ body: { ...body, dryRun: false },
2385
+ })));
2050
2386
  }
2051
2387
  if (noun === "enrollments" && verb === "register-cohort") {
2052
2388
  const enrollmentId = flagString(parsed, "enrollment", { required: true });
@@ -2141,12 +2477,20 @@ export async function runCommand(argv) {
2141
2477
  const search = flagString(parsed, "search");
2142
2478
  const userId = flagString(parsed, "user");
2143
2479
  const id = flagString(parsed, "id");
2144
- return unwrap(await api.client.GET("/admin/payout/recipient/", {
2145
- params: {
2480
+ const limit = flagNumber(parsed, "limit") ?? 20;
2481
+ if (!Number.isInteger(limit) || limit < 1 || limit > 100) {
2482
+ throw new CliError("invalid_arguments", "--limit must be an integer between 1 and 100.");
2483
+ }
2484
+ return unwrap(await api.client.GET("/admin/payout/recipient/", {
2485
+ params: {
2146
2486
  query: {
2147
2487
  ...(search ? { search } : {}),
2148
2488
  ...(userId ? { userId } : {}),
2149
2489
  ...(id ? { id } : {}),
2490
+ limit,
2491
+ ...(flagString(parsed, "cursor")
2492
+ ? { cursor: flagString(parsed, "cursor") }
2493
+ : {}),
2150
2494
  },
2151
2495
  },
2152
2496
  }));
@@ -2483,215 +2827,7 @@ export async function runCommand(argv) {
2483
2827
  }, async () => unwrap(await api.client.POST("/admin/cohorts/unregister/", { body })));
2484
2828
  }
2485
2829
  if (noun === "onboarding") {
2486
- if (verb === "status") {
2487
- const familyId = positional(parsed, 2, "family ID");
2488
- return unwrap(await api.client.GET("/admin/onboarding/families/{familyId}/status", {
2489
- params: { path: { familyId } },
2490
- }));
2491
- }
2492
- if (verb === "kids") {
2493
- const timePeriodDays = flagNumber(parsed, "time-period-days");
2494
- const cohortId = flagString(parsed, "cohort");
2495
- const limit = flagNumber(parsed, "limit");
2496
- const stageFilterRaw = flagString(parsed, "stage-filter");
2497
- const stageFilter = stageFilterRaw
2498
- ? assertChoice(stageFilterRaw, ["all", "scheduled", "oriented", "course", "converted", "lost"], "--stage-filter")
2499
- : undefined;
2500
- return unwrap(await api.client.GET("/admin/onboarding/kids/", {
2501
- params: {
2502
- query: {
2503
- ...(timePeriodDays !== undefined ? { timePeriodDays } : {}),
2504
- ...(cohortId ? { cohortId } : {}),
2505
- ...(limit !== undefined ? { limit } : {}),
2506
- ...(stageFilter ? { stageFilter } : {}),
2507
- },
2508
- },
2509
- }));
2510
- }
2511
- if (verb === "intake-session") {
2512
- const familyId = positional(parsed, 2, "family ID");
2513
- // A true read: look up the family's current IN_PROGRESS intake session
2514
- // WITHOUT minting one (get.family-intake-session.ts). Merely viewing a
2515
- // family must not create a blank session — use intake-session-create to
2516
- // mint one. A 404 means "none yet" and is a clean result, not an error.
2517
- try {
2518
- return await api.rawGet(`/admin/onboarding/families/${encodeURIComponent(familyId)}/intake-session`);
2519
- }
2520
- catch (error) {
2521
- if (error instanceof CliError &&
2522
- typeof error.details === "object" &&
2523
- error.details !== null &&
2524
- error.details.status === 404) {
2525
- return {
2526
- intakeSession: null,
2527
- familyId,
2528
- message: "No intake session for this family yet.",
2529
- };
2530
- }
2531
- throw error;
2532
- }
2533
- }
2534
- if (verb === "intake-session-create") {
2535
- const familyId = positional(parsed, 2, "family ID");
2536
- // The explicit create: get-or-create the intake session. Mints a blank
2537
- // IN_PROGRESS session when none exists (post.family-intake-session.ts,
2538
- // freshIfFinished), so it is a write and goes through the confirmation
2539
- // gate like every other mutating command.
2540
- return writeCommand(parsed, {
2541
- action: "create the family's parent intake session (mints a blank IN_PROGRESS session if none exists)",
2542
- target: { familyId },
2543
- request: {},
2544
- }, async () => unwrap(await api.client.POST("/admin/onboarding/families/{familyId}/intake-session", { params: { path: { familyId } } })));
2545
- }
2546
- if (verb === "set-stage") {
2547
- const familyId = positional(parsed, 2, "family ID");
2548
- const stage = assertChoice(flagString(parsed, "stage", { required: true }), ONBOARDING_STAGES, "--stage");
2549
- // Read the current stage first so the preview can name the transition and
2550
- // warn when it moves progress backward (re-locks capabilities).
2551
- const current = unwrap(await api.client.GET("/admin/onboarding/families/{familyId}/status", {
2552
- params: { path: { familyId } },
2553
- }));
2554
- const from = current.onboardingStage;
2555
- const backward = ONBOARDING_STAGES.indexOf(stage) < ONBOARDING_STAGES.indexOf(from);
2556
- const body = { stage };
2557
- return writeCommand(parsed, {
2558
- action: `${backward ? "MOVE BACKWARD — re-locks progress: " : ""}set onboarding stage to ${stage} — ${ONBOARDING_STAGE_SIDE_EFFECTS[stage]}`,
2559
- target: { familyId, from },
2560
- request: body,
2561
- }, async () => unwrap(await api.client.PATCH("/admin/onboarding/families/{familyId}/stage", { params: { path: { familyId } }, body })));
2562
- }
2563
- if (verb === "set-account-state") {
2564
- const familyId = positional(parsed, 2, "family ID");
2565
- const state = assertChoice(flagString(parsed, "state", { required: true }), ONBOARDING_ACCOUNT_STATES, "--state");
2566
- const note = flagString(parsed, "note");
2567
- const lockout = state === "PAUSED" || state === "BOOTED"
2568
- ? " — locks the family out of paid capabilities"
2569
- : "";
2570
- const body = { state, ...(note !== undefined ? { note } : {}) };
2571
- return writeCommand(parsed, {
2572
- action: `set account state to ${state}${lockout}`,
2573
- target: { familyId },
2574
- request: body,
2575
- }, async () => unwrap(await api.client.PATCH("/admin/onboarding/families/{familyId}/account-state", { params: { path: { familyId } }, body })));
2576
- }
2577
- if (verb === "attest") {
2578
- const familyId = positional(parsed, 2, "family ID");
2579
- const condition = assertChoice(flagString(parsed, "condition", { required: true }), ONBOARDING_CONDITIONS, "--condition");
2580
- const revoke = hasFlag(parsed, "revoke");
2581
- const note = flagString(parsed, "note");
2582
- const body = {
2583
- condition,
2584
- attested: !revoke,
2585
- ...(note !== undefined ? { note } : {}),
2586
- };
2587
- return writeCommand(parsed, {
2588
- action: revoke
2589
- ? `REVOKE attestation ${condition}`
2590
- : `attest ${condition}`,
2591
- target: { familyId },
2592
- request: body,
2593
- }, async () => unwrap(await api.client.POST("/admin/onboarding/families/{familyId}/attest", { params: { path: { familyId } }, body })));
2594
- }
2595
- if (verb === "set-intake") {
2596
- const familyId = positional(parsed, 2, "family ID");
2597
- const sessionId = flagString(parsed, "session", { required: true });
2598
- const dataRaw = flagString(parsed, "data", { required: true });
2599
- const expectedUpdatedAt = flagString(parsed, "expected-updated-at");
2600
- let collectedData;
2601
- try {
2602
- collectedData = JSON.parse(dataRaw);
2603
- }
2604
- catch {
2605
- throw new CliError("invalid_arguments", "--data must be valid JSON for the intake collectedData object.");
2606
- }
2607
- if (expectedUpdatedAt !== undefined &&
2608
- Number.isNaN(Date.parse(expectedUpdatedAt))) {
2609
- throw new CliError("invalid_arguments", "--expected-updated-at must be an ISO-8601 timestamp (the intake-session read's updatedAt).");
2610
- }
2611
- const collected = collectedData;
2612
- // The preview must do ZERO network so an offline preview stays honest
2613
- // (admin-cli offline-preview contract). The optimistic-concurrency token
2614
- // is resolved only on --confirm, inside the execute closure below — so a
2615
- // plain preview never reaches out. Strict mode (--expected-updated-at)
2616
- // shows the pinned token in the preview; the default fetches it at confirm.
2617
- const previewRequest = {
2618
- sessionId,
2619
- collectedData: collected,
2620
- ...(expectedUpdatedAt !== undefined ? { expectedUpdatedAt } : {}),
2621
- };
2622
- return writeCommand(parsed, {
2623
- action: "write structured parent intake data for the family",
2624
- target: { familyId, sessionId },
2625
- request: previewRequest,
2626
- }, async () => {
2627
- // Resolve the CAS token now (only reached on --confirm):
2628
- // - strict mode: pin the operator-supplied token. A stale one 409s
2629
- // (unwrap → CliError → non-zero exit), protecting a parent's
2630
- // concurrent confirm-flow autosave.
2631
- // - default: fetch the CURRENT token and warn that edits made since
2632
- // the preview are unprotected. The CLI is a last-resort admin tool;
2633
- // pragmatic default + honest warning + strict opt-in.
2634
- let token = expectedUpdatedAt;
2635
- if (token === undefined) {
2636
- const current = unwrap(await api.client.GET("/admin/onboarding/families/{familyId}/intake-session", { params: { path: { familyId } } }));
2637
- token = current.updatedAt;
2638
- process.stderr.write("Warning: using current session token; edits made since your preview are not protected — pass --expected-updated-at for strict CAS.\n");
2639
- }
2640
- const body = {
2641
- sessionId,
2642
- collectedData: collected,
2643
- expectedUpdatedAt: token,
2644
- };
2645
- return unwrap(await api.client.PUT("/admin/onboarding/families/{familyId}/intake", { params: { path: { familyId } }, body }));
2646
- });
2647
- }
2648
- if (verb === "extract") {
2649
- const familyId = positional(parsed, 2, "family ID");
2650
- const sessionId = flagString(parsed, "session", { required: true });
2651
- const transcriptFile = flagString(parsed, "transcript-file");
2652
- const granolaRef = flagString(parsed, "granola");
2653
- if ((transcriptFile && granolaRef) || (!transcriptFile && !granolaRef)) {
2654
- throw new CliError("invalid_arguments", "Pass exactly one source: --transcript-file <path> or --granola <ref>.");
2655
- }
2656
- let transcript;
2657
- if (transcriptFile) {
2658
- try {
2659
- transcript = await fs.readFile(path.resolve(transcriptFile), "utf8");
2660
- }
2661
- catch (error) {
2662
- throw new CliError("invalid_arguments", `Could not read transcript file ${transcriptFile}: ${error instanceof Error ? error.message : String(error)}`);
2663
- }
2664
- }
2665
- const body = {
2666
- sessionId,
2667
- ...(transcript !== undefined ? { transcript } : {}),
2668
- ...(granolaRef ? { granolaRef } : {}),
2669
- };
2670
- // Keep raw transcript PII out of the confirmation preview (it prints to
2671
- // stderr, agent logs, and approval records); the full body still goes to
2672
- // the API on --confirm.
2673
- const previewRequest = transcript !== undefined && transcriptFile
2674
- ? {
2675
- sessionId,
2676
- path: path.resolve(transcriptFile),
2677
- byteCount: Buffer.byteLength(transcript, "utf8"),
2678
- sha256Prefix: createHash("sha256")
2679
- .update(transcript)
2680
- .digest("hex")
2681
- .slice(0, 12),
2682
- }
2683
- : { sessionId, ...(granolaRef ? { granolaRef } : {}) };
2684
- return writeCommand(parsed, {
2685
- action: "extract intake from transcript via Claude and merge into session",
2686
- target: {
2687
- familyId,
2688
- sessionId,
2689
- source: transcriptFile ? "transcript" : "granola",
2690
- },
2691
- request: previewRequest,
2692
- }, async () => unwrap(await api.client.POST("/admin/onboarding/families/{familyId}/intake-extract", { params: { path: { familyId } }, body })));
2693
- }
2694
- throw new CliError("invalid_arguments", "Use onboarding status|kids|intake-session|intake-session-create|set-stage|set-account-state|attest|set-intake|extract.");
2830
+ return runOnboardingCommand({ parsed, api, writeCommand });
2695
2831
  }
2696
2832
  if (noun === "skills") {
2697
2833
  // Reads only — no confirmation gate. Audience is explicit so an agent never
@@ -2767,6 +2903,19 @@ export async function runCommand(argv) {
2767
2903
  if (verb === "set-stage") {
2768
2904
  const stage = assertChoice(flagString(parsed, "stage", { required: true }), CONTENT_LIBRARY_RESOURCE_STAGES, "--stage");
2769
2905
  const input = await readContentLibraryGemTargets(parsed);
2906
+ const wait = hasFlag(parsed, "wait");
2907
+ const timeoutSeconds = flagNumber(parsed, "timeout") ?? 900;
2908
+ if (hasFlag(parsed, "timeout") && !wait) {
2909
+ throw new CliError("invalid_arguments", "--timeout requires --wait.");
2910
+ }
2911
+ if (wait && stage !== "live") {
2912
+ throw new CliError("invalid_arguments", "--wait is supported for content-library set-stage only when --stage live.");
2913
+ }
2914
+ if (!Number.isInteger(timeoutSeconds) ||
2915
+ timeoutSeconds < 10 ||
2916
+ timeoutSeconds > 7200) {
2917
+ throw new CliError("invalid_arguments", "--timeout must be an integer number of seconds from 10 through 7200.");
2918
+ }
2770
2919
  const preflightBody = {
2771
2920
  gems: input.gems,
2772
2921
  stage,
@@ -2783,16 +2932,39 @@ export async function runCommand(argv) {
2783
2932
  resources: preflight.results,
2784
2933
  lifecycle: "Uses the same lifecycle as Manage. Direct-to-Live never bypasses unfinished polishing; leaving Polishing cancels its bound run before moving.",
2785
2934
  concurrency: 3,
2935
+ waitForLive: wait,
2936
+ ...(wait ? { timeoutSeconds } : {}),
2786
2937
  ...(input.file ? { inputFile: input.file } : {}),
2787
2938
  },
2788
2939
  };
2789
- return writeCommand(parsed, preview, async () => unwrap(await api.client.POST("/admin/content-library/transition", {
2790
- body: { gems: input.gems, stage, dryRun: false },
2791
- })));
2940
+ return writeCommand(parsed, preview, async () => {
2941
+ const result = unwrap(await api.client.POST("/admin/content-library/transition", {
2942
+ body: { gems: input.gems, stage, dryRun: false },
2943
+ }));
2944
+ if (!wait)
2945
+ return result;
2946
+ return {
2947
+ ...result,
2948
+ wait: await waitForContentLibraryLive(api, input.gems, timeoutSeconds),
2949
+ };
2950
+ });
2792
2951
  }
2793
2952
  if (verb === "submit") {
2794
2953
  const stage = assertChoice(flagString(parsed, "stage") ?? "review", CONTENT_LIBRARY_STAGES, "--stage");
2795
2954
  const input = await readContentLibrarySubmitItems(parsed);
2955
+ const wait = hasFlag(parsed, "wait");
2956
+ const timeoutSeconds = flagNumber(parsed, "timeout") ?? 900;
2957
+ if (hasFlag(parsed, "timeout") && !wait) {
2958
+ throw new CliError("invalid_arguments", "--timeout requires --wait.");
2959
+ }
2960
+ if (wait && stage !== "polish") {
2961
+ throw new CliError("invalid_arguments", "--wait is supported for content-library submit only when --stage polish.");
2962
+ }
2963
+ if (!Number.isInteger(timeoutSeconds) ||
2964
+ timeoutSeconds < 10 ||
2965
+ timeoutSeconds > 7200) {
2966
+ throw new CliError("invalid_arguments", "--timeout must be an integer number of seconds from 10 through 7200.");
2967
+ }
2796
2968
  const body = { stage, items: input.items };
2797
2969
  const preview = {
2798
2970
  action: "content-library.submit",
@@ -2804,51 +2976,57 @@ export async function runCommand(argv) {
2804
2976
  : "Holds each new resource in REVIEW until an admin approves it.",
2805
2977
  duplicateBehavior: "Existing URLs are returned as duplicates and are not overwritten.",
2806
2978
  deployOrderFence: "The server verifies the island's review/polish lifecycle capability before its first write.",
2979
+ waitForLive: wait,
2980
+ ...(wait ? { timeoutSeconds } : {}),
2807
2981
  ...(input.file ? { inputFile: input.file } : {}),
2808
2982
  },
2809
2983
  };
2810
- return writeCommand(parsed, preview, async () => unwrap(await api.client.POST("/admin/content-library/submit", {
2811
- body,
2812
- })));
2984
+ return writeCommand(parsed, preview, async () => {
2985
+ const result = unwrap(await api.client.POST("/admin/content-library/submit", {
2986
+ body,
2987
+ }));
2988
+ if (!wait)
2989
+ return result;
2990
+ return {
2991
+ ...result,
2992
+ wait: await waitForContentLibraryLive(api, input.items.map((item) => item.url), timeoutSeconds),
2993
+ };
2994
+ });
2813
2995
  }
2814
2996
  throw new CliError("invalid_arguments", "Use content-library search, status, set-stage, or submit.");
2815
2997
  }
2816
2998
  if (noun === "goal-templates") {
2817
2999
  if (verb === "list") {
2818
- const query = flagString(parsed, "query")?.toLowerCase();
3000
+ const query = flagString(parsed, "query");
2819
3001
  const kind = flagString(parsed, "kind");
2820
- const data = unwrap(await api.client.GET("/ai/goal-templates/", {
3002
+ const wantedKind = kind
3003
+ ? assertChoice(kind, GOAL_TEMPLATE_KINDS, "--kind")
3004
+ : undefined;
3005
+ const limit = flagNumber(parsed, "limit") ?? 20;
3006
+ if (!Number.isInteger(limit) || limit < 1 || limit > 100) {
3007
+ throw new CliError("invalid_arguments", "--limit must be an integer between 1 and 100.");
3008
+ }
3009
+ return unwrap(await api.client.GET("/ai/goal-templates/", {
2821
3010
  params: {
2822
3011
  query: {
2823
3012
  ...(flagString(parsed, "category")
2824
3013
  ? { category: flagString(parsed, "category") }
2825
3014
  : {}),
3015
+ ...(query ? { query } : {}),
3016
+ ...(wantedKind ? { kind: wantedKind } : {}),
2826
3017
  includeDeleted: hasFlag(parsed, "include-deleted")
2827
3018
  ? "true"
2828
3019
  : "false",
2829
3020
  starterOnly: hasFlag(parsed, "starter-only")
2830
3021
  ? "true"
2831
3022
  : "false",
3023
+ limit,
3024
+ ...(flagString(parsed, "cursor")
3025
+ ? { cursor: flagString(parsed, "cursor") }
3026
+ : {}),
2832
3027
  },
2833
3028
  },
2834
3029
  }));
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
3030
  }
2853
3031
  if (verb === "get") {
2854
3032
  const id = await resolveGoalTemplateId(api, positional(parsed, 2, "template ID or slug"));
@@ -2953,7 +3131,7 @@ export async function runCommand(argv) {
2953
3131
  // protected-inventory counting, and the token binding the approved loss
2954
3132
  // to this exact version + result hash. Always ask it for a fresh preview,
2955
3133
  // 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", {
3134
+ const preflight = unwrap(await api.client.POST("/ai/goal-templates/{id}/setup-workflow-spec/patch", {
2957
3135
  params: { path: { id } },
2958
3136
  body: {
2959
3137
  expectedVersion,
@@ -2991,7 +3169,9 @@ export async function runCommand(argv) {
2991
3169
  : {}),
2992
3170
  },
2993
3171
  };
2994
- requireConfirmation(hasFlag(parsed, "confirm"), preview);
3172
+ if (!hasFlag(parsed, "confirm")) {
3173
+ await requirePreviewBoundConfirmation(parsed, preview);
3174
+ }
2995
3175
  const destructiveChangeToken = flagString(parsed, "destructive-change-token");
2996
3176
  if (preflight.preview.destructiveChanges &&
2997
3177
  (!hasFlag(parsed, "confirm-destructive-changes") ||
@@ -3007,7 +3187,7 @@ export async function runCommand(argv) {
3007
3187
  expectedDestructiveChangeToken: preflight.preview.destructiveChangeToken,
3008
3188
  });
3009
3189
  }
3010
- return unwrap(await api.client.POST("/admin/goal-templates/{id}/setup-workflow-spec/patch", {
3190
+ return previewBoundWrite(parsed, preview, async () => unwrap(await api.client.POST("/ai/goal-templates/{id}/setup-workflow-spec/patch", {
3011
3191
  params: { path: { id } },
3012
3192
  body: {
3013
3193
  expectedVersion,
@@ -3016,7 +3196,7 @@ export async function runCommand(argv) {
3016
3196
  confirmDestructiveChanges: preflight.preview.destructiveChanges,
3017
3197
  ...(destructiveChangeToken ? { destructiveChangeToken } : {}),
3018
3198
  },
3019
- }));
3199
+ })));
3020
3200
  }
3021
3201
  if (verb === "set-metadata") {
3022
3202
  const id = await resolveGoalTemplateId(api, positional(parsed, 2, "template ID or slug"));
@@ -3085,7 +3265,7 @@ export async function runCommand(argv) {
3085
3265
  action: "update goal template metadata (never its setupWorkflowSpec)",
3086
3266
  target: { templateId: id, expectedVersion },
3087
3267
  request: body,
3088
- }, async () => unwrap(await api.client.PUT("/admin/goal-templates/{id}", {
3268
+ }, async () => unwrap(await api.client.PUT("/ai/goal-templates/{id}", {
3089
3269
  params: { path: { id } },
3090
3270
  body,
3091
3271
  })));
@@ -3096,7 +3276,7 @@ export async function runCommand(argv) {
3096
3276
  // Read the current version so the gate refuses a stale delete locally,
3097
3277
  // and so the preview names the template a human is being asked to approve
3098
3278
  // rather than only its UUID.
3099
- const current = unwrap(await api.client.GET("/admin/goal-templates/{id}", {
3279
+ const current = unwrap(await api.client.GET("/ai/goal-templates/{id}", {
3100
3280
  params: { path: { id } },
3101
3281
  }));
3102
3282
  if (current.version !== expectedVersion) {
@@ -3114,7 +3294,7 @@ export async function runCommand(argv) {
3114
3294
  details: {
3115
3295
  note: "Soft delete: the row keeps its deletedAt and drops out of every list. Existing goals already applied from it are unaffected.",
3116
3296
  },
3117
- }, async () => unwrap(await api.client.DELETE("/admin/goal-templates/{id}", {
3297
+ }, async () => unwrap(await api.client.DELETE("/ai/goal-templates/{id}", {
3118
3298
  params: { path: { id } },
3119
3299
  })));
3120
3300
  }
@@ -3134,18 +3314,43 @@ export async function runCommand(argv) {
3134
3314
  const id = await resolveGoalTemplateId(api, positional(parsed, 2, "template ID or slug"));
3135
3315
  const sourceGoalId = flagString(parsed, "source-goal");
3136
3316
  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.");
3317
+ const sourceDirectory = flagString(parsed, "source-dir");
3318
+ if ([sourceGoalId, sourceDraft, sourceDirectory].filter(Boolean).length !==
3319
+ 1) {
3320
+ throw new CliError("invalid_arguments", "Pass exactly one of --source-goal, --source-draft, or --source-dir.");
3321
+ }
3322
+ const checkout = sourceDirectory
3323
+ ? await readRecessGitMetadata(sourceDirectory)
3324
+ : null;
3325
+ const checkoutHead = checkout
3326
+ ? await assertCheckoutReadyForMaterialization(checkout)
3327
+ : null;
3328
+ const requestedSourceStudentId = flagString(parsed, "student");
3329
+ if (checkout &&
3330
+ requestedSourceStudentId &&
3331
+ requestedSourceStudentId !== checkout.metadata.studentId) {
3332
+ throw new CliError("invalid_arguments", `--student ${requestedSourceStudentId} does not match checkout owner ${checkout.metadata.studentId}.`);
3139
3333
  }
3140
3334
  const sourceStudentUserId = sourceDraft
3141
3335
  ? flagString(parsed, "student", { required: true })
3142
- : undefined;
3143
- const source = sourceGoalId
3144
- ? { sourceGoalId }
3145
- : {
3146
- sourceWorkspacePath: `drafts/${sourceDraft}/workspace`,
3147
- sourceStudentUserId,
3336
+ : checkout?.metadata.studentId;
3337
+ let source;
3338
+ if (sourceGoalId) {
3339
+ source = { sourceGoalId };
3340
+ }
3341
+ else if (checkout?.metadata.target.kind === "goal") {
3342
+ source = { sourceGoalId: checkout.metadata.target.goalId };
3343
+ }
3344
+ else {
3345
+ const draftSlug = sourceDraft ??
3346
+ (checkout?.metadata.target.kind === "draft"
3347
+ ? checkout.metadata.target.draftSlug
3348
+ : undefined);
3349
+ source = {
3350
+ sourceWorkspacePath: `drafts/${draftSlug}/workspace`,
3351
+ sourceStudentUserId: sourceStudentUserId,
3148
3352
  };
3353
+ }
3149
3354
  const preflight = unwrap(await api.client.POST("/ai/goal-templates/{id}/snapshot/capture", {
3150
3355
  params: { path: { id } },
3151
3356
  body: { ...source, dryRun: true },
@@ -3153,6 +3358,10 @@ export async function runCommand(argv) {
3153
3358
  if (preflight.action !== "preview_capture_snapshot") {
3154
3359
  throw new CliError("unexpected_response", "Snapshot capture did not return a preview; nothing was captured.");
3155
3360
  }
3361
+ if (checkout &&
3362
+ preflight.source.changeId !== checkout.metadata.changeId) {
3363
+ throw new CliError("stale_workspace", `Workspace changed from ${checkout.metadata.changeId} to ${preflight.source.changeId}. Check it out again before capturing; nothing was captured.`);
3364
+ }
3156
3365
  if (hasFlag(parsed, "dry-run"))
3157
3366
  return preflight;
3158
3367
  const preview = {
@@ -3169,6 +3378,13 @@ export async function runCommand(argv) {
3169
3378
  fileCount: preflight.snapshot.fileCount,
3170
3379
  sizeBytes: preflight.snapshot.sizeBytes,
3171
3380
  sha256: preflight.snapshot.sha256,
3381
+ ...(checkout
3382
+ ? {
3383
+ sourceDirectory: checkout.root,
3384
+ localHeadCommit: checkoutHead,
3385
+ mesaChangeId: checkout.metadata.changeId,
3386
+ }
3387
+ : {}),
3172
3388
  },
3173
3389
  details: {
3174
3390
  modules: preflight.snapshot.modules,
@@ -3177,8 +3393,7 @@ export async function runCommand(argv) {
3177
3393
  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
3394
  },
3179
3395
  };
3180
- requireConfirmation(hasFlag(parsed, "confirm"), preview);
3181
- return unwrap(await api.client.POST("/ai/goal-templates/{id}/snapshot/capture", {
3396
+ return previewBoundWrite(parsed, preview, async () => unwrap(await api.client.POST("/ai/goal-templates/{id}/snapshot/capture", {
3182
3397
  params: { path: { id } },
3183
3398
  body: {
3184
3399
  ...source,
@@ -3186,7 +3401,7 @@ export async function runCommand(argv) {
3186
3401
  expectedTemplateVersion: preflight.template.version,
3187
3402
  expectedSourceChangeId: preflight.source.changeId,
3188
3403
  },
3189
- }));
3404
+ })));
3190
3405
  }
3191
3406
  if (verb === "apply") {
3192
3407
  const id = await resolveGoalTemplateId(api, positional(parsed, 2, "template ID or slug"));
@@ -3230,15 +3445,14 @@ export async function runCommand(argv) {
3230
3445
  note: "Counts come from the backend's own dry run. `skipped_existing` items are idempotent — re-applying does not duplicate them.",
3231
3446
  },
3232
3447
  };
3233
- requirePreviewBoundConfirmation(parsed, preview);
3234
- return unwrap(await api.client.POST("/ai/goal-templates/{id}/apply-workflow", {
3448
+ return previewBoundWrite(parsed, preview, async () => unwrap(await api.client.POST("/ai/goal-templates/{id}/apply-workflow", {
3235
3449
  params: { path: { id } },
3236
3450
  body: {
3237
3451
  answers,
3238
3452
  dryRun: false,
3239
3453
  expectedTemplateVersion: preflight.templateVersion,
3240
3454
  },
3241
- }));
3455
+ })));
3242
3456
  }
3243
3457
  if (verb === "apply-starter") {
3244
3458
  const id = await resolveGoalTemplateId(api, positional(parsed, 2, "template ID or slug"));
@@ -3272,7 +3486,30 @@ export async function runCommand(argv) {
3272
3486
  }));
3273
3487
  }
3274
3488
  if (verb === "create") {
3275
- const userId = flagString(parsed, "student", { required: true });
3489
+ const requestedStudentId = flagString(parsed, "student");
3490
+ const sourceDirectory = flagString(parsed, "source-dir");
3491
+ const explicitDraftSlug = flagString(parsed, "draft");
3492
+ if (sourceDirectory && explicitDraftSlug) {
3493
+ throw new CliError("invalid_arguments", "Pass either --source-dir or --draft, not both.");
3494
+ }
3495
+ const checkout = sourceDirectory
3496
+ ? await readRecessGitMetadata(sourceDirectory)
3497
+ : null;
3498
+ if (checkout && checkout.metadata.target.kind !== "draft") {
3499
+ throw new CliError("invalid_checkout", "A fresh module goal must be created from a draft checkout, not an existing goal checkout.");
3500
+ }
3501
+ const checkoutHead = checkout
3502
+ ? await assertCheckoutReadyForMaterialization(checkout)
3503
+ : null;
3504
+ if (checkout &&
3505
+ requestedStudentId &&
3506
+ requestedStudentId !== checkout.metadata.studentId) {
3507
+ throw new CliError("invalid_arguments", `--student ${requestedStudentId} does not match checkout owner ${checkout.metadata.studentId}.`);
3508
+ }
3509
+ const userId = requestedStudentId ?? checkout?.metadata.studentId;
3510
+ if (!userId) {
3511
+ throw new CliError("invalid_arguments", "Pass --student for a direct goal create, or use a Recess draft --source-dir.");
3512
+ }
3276
3513
  const title = flagString(parsed, "title", { required: true });
3277
3514
  const descriptionFile = flagString(parsed, "description-file");
3278
3515
  const descriptionFlag = flagString(parsed, "description");
@@ -3288,14 +3525,17 @@ export async function runCommand(argv) {
3288
3525
  throw new CliError("invalid_arguments", "--target-date must be an ISO 8601 datetime.");
3289
3526
  }
3290
3527
  const schedule = flagString(parsed, "schedule");
3291
- const draftSlug = flagString(parsed, "draft");
3528
+ const draftSlug = explicitDraftSlug ??
3529
+ (checkout?.metadata.target.kind === "draft"
3530
+ ? checkout.metadata.target.draftSlug
3531
+ : undefined);
3292
3532
  const enablesAppletFollowUps = hasFlag(parsed, "enable-applet-follow-ups");
3293
3533
  const disablesAppletFollowUps = hasFlag(parsed, "disable-applet-follow-ups");
3294
3534
  if (draftSlug && enablesAppletFollowUps === disablesAppletFollowUps) {
3295
3535
  throw new CliError("invalid_arguments", "A module-backed goal requires exactly one of --enable-applet-follow-ups or --disable-applet-follow-ups.");
3296
3536
  }
3297
3537
  if (!draftSlug && (enablesAppletFollowUps || disablesAppletFollowUps)) {
3298
- throw new CliError("invalid_arguments", "Applet follow-up flags are used with --draft for direct module-backed goals.");
3538
+ throw new CliError("invalid_arguments", "Applet follow-up flags are used with --draft or --source-dir for module-backed goals.");
3299
3539
  }
3300
3540
  if (draftSlug) {
3301
3541
  const body = {
@@ -3313,6 +3553,10 @@ export async function runCommand(argv) {
3313
3553
  if (preflight.action !== "preview_module_goal_create") {
3314
3554
  throw new CliError("unexpected_response", "Direct module goal creation did not return a preview; no goal was created.");
3315
3555
  }
3556
+ if (checkout &&
3557
+ preflight.currentChangeId !== checkout.metadata.changeId) {
3558
+ 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.`);
3559
+ }
3316
3560
  const preview = {
3317
3561
  action: "create a module-backed goal directly from a validated draft workspace",
3318
3562
  target: preflight.target,
@@ -3325,6 +3569,13 @@ export async function runCommand(argv) {
3325
3569
  targetDate: targetDate ?? null,
3326
3570
  schedule: schedule ?? null,
3327
3571
  enableAppletFollowUps: enablesAppletFollowUps,
3572
+ ...(checkout
3573
+ ? {
3574
+ sourceDirectory: checkout.root,
3575
+ localHeadCommit: checkoutHead,
3576
+ mesaChangeId: checkout.metadata.changeId,
3577
+ }
3578
+ : {}),
3328
3579
  },
3329
3580
  details: {
3330
3581
  currentChangeId: preflight.currentChangeId,
@@ -3338,14 +3589,35 @@ export async function runCommand(argv) {
3338
3589
  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
3590
  },
3340
3591
  };
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
- }));
3592
+ return previewBoundWrite(parsed, preview, async () => {
3593
+ const result = unwrap(await api.client.POST("/tutor/browser/mesa/goals", {
3594
+ body: {
3595
+ ...body,
3596
+ dryRun: false,
3597
+ expectedWorkspaceChangeId: preflight.currentChangeId,
3598
+ },
3599
+ }));
3600
+ if (result.action !== "create_module_goal") {
3601
+ throw new CliError("unexpected_response", "Module goal creation returned a preview instead of creating the goal.");
3602
+ }
3603
+ if (checkout && result.mesaChangeId) {
3604
+ await writeRecessGitMetadata(checkout.root, {
3605
+ ...checkout.metadata,
3606
+ target: { kind: "goal", goalId: result.goalId },
3607
+ changeId: result.mesaChangeId,
3608
+ upstreamCommit: checkoutHead,
3609
+ });
3610
+ return {
3611
+ ...result,
3612
+ local: {
3613
+ directory: checkout.root,
3614
+ target: { kind: "goal", goalId: result.goalId },
3615
+ upstreamCommit: checkoutHead,
3616
+ },
3617
+ };
3618
+ }
3619
+ return result;
3620
+ });
3349
3621
  }
3350
3622
  const body = {
3351
3623
  title,
@@ -3363,7 +3635,7 @@ export async function runCommand(argv) {
3363
3635
  schedule: schedule ?? null,
3364
3636
  },
3365
3637
  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.",
3638
+ 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
3639
  },
3368
3640
  }, async () => unwrap(await api.client.POST("/tutor/browser/students/{userId}/goals/", {
3369
3641
  params: { path: { userId } },
@@ -3392,11 +3664,39 @@ export async function runCommand(argv) {
3392
3664
  patch,
3393
3665
  },
3394
3666
  };
3395
- requirePreviewBoundConfirmation(parsed, preview);
3396
- return unwrap(await api.client.PATCH("/tutor/browser/students/goals/{goalId}/", {
3667
+ return previewBoundWrite(parsed, preview, async () => unwrap(await api.client.PATCH("/tutor/browser/students/goals/{goalId}/", {
3397
3668
  params: { path: { goalId } },
3398
3669
  body: patch,
3399
- }));
3670
+ })));
3671
+ }
3672
+ if (verb === "delete") {
3673
+ const goalId = positional(parsed, 2, "goal ID");
3674
+ const studentId = flagString(parsed, "student", { required: true });
3675
+ const current = unwrap(await api.client.GET("/tutor/browser/students/{userId}/goals/", {
3676
+ params: { path: { userId: studentId } },
3677
+ })).goals.find((goal) => goal.id === goalId);
3678
+ if (!current) {
3679
+ throw new CliError("not_found", `Goal ${goalId} was not found for student ${studentId}.`);
3680
+ }
3681
+ const preview = {
3682
+ action: "soft-delete a goal for a managed student",
3683
+ target: {
3684
+ goalId,
3685
+ studentUserId: studentId,
3686
+ title: current.title,
3687
+ status: current.status,
3688
+ },
3689
+ request: { expectedUpdatedAt: current.updatedAt },
3690
+ details: {
3691
+ note: "Soft-deletes the goal and its current/future dated todos. Historical todos and the deletion audit remain available.",
3692
+ },
3693
+ };
3694
+ return previewBoundWrite(parsed, preview, async () => {
3695
+ unwrap(await api.client.DELETE("/admin/browser/students/goals/{goalId}/", {
3696
+ params: { path: { goalId } },
3697
+ }));
3698
+ return { deleted: true, goalId, studentUserId: studentId };
3699
+ });
3400
3700
  }
3401
3701
  if (verb === "queue") {
3402
3702
  const subverb = positional(parsed, 2, "queue action (get|set)");
@@ -3411,7 +3711,7 @@ export async function runCommand(argv) {
3411
3711
  });
3412
3712
  const delta = flagString(parsed, "delta", { required: true });
3413
3713
  const replaceDescriptionPointer = hasFlag(parsed, "replace-description-pointer");
3414
- const { absolutePath, parsed: rawEntries } = await readJsonValue(entriesFile, "Queue entries file");
3714
+ const { absolutePath, raw: rawEntriesText, parsed: rawEntries, } = await readJsonValue(entriesFile, "Queue entries file");
3415
3715
  const entries = parseGoalQueueEntries(rawEntries, "Queue entries file");
3416
3716
  const goal = unwrap(await api.client.GET("/tutor/browser/students/{userId}/goals/", {
3417
3717
  params: { path: { userId: studentId } },
@@ -3422,15 +3722,36 @@ export async function runCommand(argv) {
3422
3722
  const current = unwrap(await api.client.GET("/tutor/browser/students/goals/{goalId}/queue/", { params: { path: { goalId } } }));
3423
3723
  const currentUrls = new Set(current.queue.map((item) => item.url));
3424
3724
  const proposedUrls = new Set(entries.map((entry) => entry.url));
3725
+ const currentByUrl = new Map(current.queue.map((item) => [item.url, item]));
3726
+ const completionChanges = entries.flatMap((entry) => {
3727
+ if (entry.completed === undefined)
3728
+ return [];
3729
+ const existing = currentByUrl.get(entry.url);
3730
+ const currentlyCompleted = existing?.completedAt != null;
3731
+ if (currentlyCompleted === entry.completed)
3732
+ return [];
3733
+ return [
3734
+ {
3735
+ moduleRef: existing?.moduleRef ?? null,
3736
+ url: entry.url,
3737
+ fromCompletedAt: existing?.completedAt ?? null,
3738
+ toCompleted: entry.completed,
3739
+ },
3740
+ ];
3741
+ });
3425
3742
  const preview = {
3426
3743
  action: "replace a goal's URL skill queue for a managed student",
3427
3744
  target: { goalId, studentUserId: studentId },
3428
3745
  request: {
3429
3746
  entriesFile: absolutePath,
3747
+ entriesSha256: createHash("sha256")
3748
+ .update(rawEntriesText)
3749
+ .digest("hex"),
3430
3750
  entryCount: entries.length,
3431
3751
  delta,
3432
3752
  replaceDescriptionPointer,
3433
3753
  expectedUpdatedAt: goal.updatedAt,
3754
+ unchangedTitleForConcurrencyFence: goal.title,
3434
3755
  },
3435
3756
  details: {
3436
3757
  currentQueue: current.queue.map((item) => ({
@@ -3444,26 +3765,31 @@ export async function runCommand(argv) {
3444
3765
  removed: current.queue
3445
3766
  .filter((item) => !proposedUrls.has(item.url))
3446
3767
  .map((item) => item.url),
3768
+ completionChanges,
3447
3769
  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
3770
  },
3449
3771
  };
3450
- requirePreviewBoundConfirmation(parsed, preview);
3451
3772
  const body = {
3452
3773
  delta,
3453
3774
  expectedUpdatedAt: goal.updatedAt,
3775
+ // The deployed route's queue-only CAS reaches Prisma with an empty
3776
+ // scalar update, which reports zero rows and falsely trips the stale
3777
+ // write fence. Echoing the already-previewed title gives that CAS a
3778
+ // real update field without changing the title.
3779
+ title: goal.title,
3454
3780
  queue: entries,
3455
3781
  ...(replaceDescriptionPointer
3456
3782
  ? { description: URL_QUEUE_DESCRIPTION_POINTER }
3457
3783
  : {}),
3458
3784
  };
3459
- return unwrap(await api.client.PATCH("/tutor/browser/students/goals/{goalId}/", {
3785
+ return previewBoundWrite(parsed, preview, async () => unwrap(await api.client.PATCH("/tutor/browser/students/goals/{goalId}/", {
3460
3786
  params: { path: { goalId } },
3461
3787
  body,
3462
- }));
3788
+ })));
3463
3789
  }
3464
3790
  throw new CliError("invalid_arguments", "Use goals queue get|set.");
3465
3791
  }
3466
- throw new CliError("invalid_arguments", "Use goals list|create|edit|queue|files|pdf.");
3792
+ throw new CliError("invalid_arguments", "Use goals list|create|edit|delete|queue|files|pdf.");
3467
3793
  }
3468
3794
  if (noun === "students") {
3469
3795
  if (verb === "list") {
@@ -3559,11 +3885,37 @@ export async function runCommand(argv) {
3559
3885
  patch: body,
3560
3886
  },
3561
3887
  };
3562
- requirePreviewBoundConfirmation(parsed, preview);
3563
- return unwrap(await api.client.PATCH("/tutor/browser/todos/{id}", {
3888
+ return previewBoundWrite(parsed, preview, async () => unwrap(await api.client.PATCH("/tutor/browser/todos/{id}", {
3564
3889
  params: { path: { id: todoId } },
3565
3890
  body,
3891
+ })));
3892
+ }
3893
+ if (verb === "delete") {
3894
+ const todoId = positional(parsed, 2, "todo ID");
3895
+ const current = unwrap(await api.client.GET("/tutor/browser/todos/{id}/", {
3896
+ params: { path: { id: todoId } },
3566
3897
  }));
3898
+ const preview = {
3899
+ action: "permanently delete a todo for a managed student",
3900
+ target: {
3901
+ todoId,
3902
+ studentUserId: current.userId,
3903
+ title: current.title,
3904
+ status: current.status,
3905
+ goalId: current.goal?.id ?? null,
3906
+ },
3907
+ request: { expectedUpdatedAt: current.updatedAt },
3908
+ details: {
3909
+ dueDate: current.dueDate,
3910
+ creationSource: current.creationSource,
3911
+ creationSourceId: current.creationSourceId,
3912
+ url: "url" in current ? current.url : null,
3913
+ 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.",
3914
+ },
3915
+ };
3916
+ return previewBoundWrite(parsed, preview, async () => unwrap(await api.client.DELETE("/admin/todos/{id}/", {
3917
+ params: { path: { id: todoId } },
3918
+ })));
3567
3919
  }
3568
3920
  if (verb === "generate-applet") {
3569
3921
  const todoId = positional(parsed, 2, "todo ID");
@@ -3601,13 +3953,12 @@ export async function runCommand(argv) {
3601
3953
  versionRouting: "The server evaluates applet-gen-v2 for this student and uses v1 when the flag is off or its evaluation fails.",
3602
3954
  },
3603
3955
  };
3604
- requirePreviewBoundConfirmation(parsed, preview);
3605
- return unwrap(await api.client.POST("/admin/learning-pipeline/{analysisId}/generate-applet/", {
3956
+ return previewBoundWrite(parsed, preview, async () => unwrap(await api.client.POST("/admin/learning-pipeline/{analysisId}/generate-applet/", {
3606
3957
  params: { path: { analysisId: analysis.id } },
3607
3958
  body: targetDueDateISO ? { targetDueDateISO } : {},
3608
- }));
3959
+ })));
3609
3960
  }
3610
- throw new CliError("invalid_arguments", "Use todos create|edit|generate-applet.");
3961
+ throw new CliError("invalid_arguments", "Use todos create|edit|delete|generate-applet.");
3611
3962
  }
3612
3963
  if (noun === "memories") {
3613
3964
  const studentId = flagString(parsed, "student", { required: true });
@@ -3715,7 +4066,7 @@ export async function runCommand(argv) {
3715
4066
  if (preflight.action !== "preview_workspace_files_write") {
3716
4067
  throw new CliError("unexpected_response", "Goal PDF upload did not return a preview; nothing was uploaded.");
3717
4068
  }
3718
- requirePreviewBoundConfirmation(parsed, {
4069
+ const preview = {
3719
4070
  action: "upload a textbook PDF and attach it to a student's goal",
3720
4071
  target: preflight.target,
3721
4072
  request: {
@@ -3729,111 +4080,114 @@ export async function runCommand(argv) {
3729
4080
  currentChangeId: preflight.currentChangeId,
3730
4081
  note: "The PDF is stored separately and the goal workspace receives a small verified reference, matching planner-authored textbook goals.",
3731
4082
  },
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", {
4083
+ };
4084
+ return previewBoundWrite(parsed, preview, async () => {
4085
+ const conversationId = randomUUID();
4086
+ const signed = unwrap(await api.client.POST("/os-v2-mesa/planner/uploads/sign", {
3791
4087
  body: {
3792
- ...body,
3793
- files: [
3794
- {
3795
- path: workspacePath,
3796
- content: manifest,
3797
- contentEncoding: "utf8",
3798
- },
3799
- ],
3800
- dryRun: false,
3801
- expectedChangeId: preflight.currentChangeId,
4088
+ conversationId,
4089
+ studentUserId: studentId,
4090
+ fileName,
4091
+ contentType: "application/pdf",
4092
+ sizeBytes: fileStat.size,
3802
4093
  },
3803
4094
  }));
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) {
4095
+ let uploaded;
4096
+ try {
4097
+ const uploadedSource = await uploadPdfToSignedUrls(absolutePath, "application/pdf", signed);
4098
+ if (uploadedSource.sha256 !== sourceSha256 ||
4099
+ uploadedSource.sizeBytes !== fileStat.size) {
4100
+ throw new CliError("source_changed", "The source PDF changed after approval. The upload was discarded; preview the current file again.");
4101
+ }
4102
+ uploaded =
4103
+ signed.kind === "multipart"
4104
+ ? unwrap(await api.client.POST("/os-v2-mesa/planner/uploads/complete", {
4105
+ body: {
4106
+ conversationId,
4107
+ fileName,
4108
+ contentType: "application/pdf",
4109
+ key: signed.key,
4110
+ uploadId: signed.uploadId,
4111
+ sizeBytes: fileStat.size,
4112
+ },
4113
+ }))
4114
+ : unwrap(await api.client.POST("/os-v2-mesa/planner/uploads/verify", {
4115
+ body: {
4116
+ conversationId,
4117
+ fileName,
4118
+ contentType: "application/pdf",
4119
+ key: signed.key,
4120
+ sizeBytes: fileStat.size,
4121
+ },
4122
+ }));
4123
+ }
4124
+ catch (error) {
4125
+ // A failed multipart complete may already have materialized the object,
4126
+ // so attempt both abort and delete. These are compensation attempts; the
4127
+ // original upload error remains authoritative.
3816
4128
  await cleanupPlannerUpload(api, conversationId, signed, true);
4129
+ throw error;
3817
4130
  }
3818
- throw error;
3819
- }
3820
- return {
3821
- action: "upload_textbook_pdf",
3822
- sourceSha256,
3823
- fileName,
3824
- sizeBytes: uploaded.sizeBytes,
3825
- workspacePath,
3826
- workspace,
3827
- };
4131
+ const manifest = JSON.stringify({
4132
+ kind: "r2-pdf",
4133
+ version: 2,
4134
+ r2Key: uploaded.key,
4135
+ signature: uploaded.manifestSignature,
4136
+ fileName,
4137
+ contentType: uploaded.contentType,
4138
+ sizeBytes: uploaded.sizeBytes,
4139
+ });
4140
+ let workspace;
4141
+ try {
4142
+ workspace = unwrap(await api.client.POST("/tutor/browser/mesa/workspace-files", {
4143
+ body: {
4144
+ ...body,
4145
+ files: [
4146
+ {
4147
+ path: workspacePath,
4148
+ content: manifest,
4149
+ contentEncoding: "utf8",
4150
+ },
4151
+ ],
4152
+ dryRun: false,
4153
+ expectedChangeId: preflight.currentChangeId,
4154
+ },
4155
+ }));
4156
+ }
4157
+ catch (error) {
4158
+ // Only a returned 4xx from this route is a definitive no-write response.
4159
+ // A transport/5xx failure is ambiguous: the workspace commit may have landed,
4160
+ // so keep the object rather than creating a dangling manifest.
4161
+ const status = error instanceof CliError &&
4162
+ error.details &&
4163
+ typeof error.details === "object" &&
4164
+ "status" in error.details
4165
+ ? Number(error.details.status)
4166
+ : 0;
4167
+ if (status >= 400 && status < 500) {
4168
+ await cleanupPlannerUpload(api, conversationId, signed, true);
4169
+ }
4170
+ throw error;
4171
+ }
4172
+ return {
4173
+ action: "upload_textbook_pdf",
4174
+ sourceSha256,
4175
+ fileName,
4176
+ sizeBytes: uploaded.sizeBytes,
4177
+ workspacePath,
4178
+ workspace,
4179
+ };
4180
+ });
3828
4181
  }
3829
4182
  if (noun === "goals" && verb === "files") {
3830
4183
  const action = positional(parsed, 2, "goals files action");
3831
- const studentId = flagString(parsed, "student", { required: true });
3832
4184
  if (action === "list") {
4185
+ const studentId = flagString(parsed, "student", { required: true });
3833
4186
  const goalId = flagString(parsed, "goal", { required: true });
3834
4187
  return unwrap(await api.client.GET("/tutor/students/{studentId}/goals/{goalId}/workspace/files", { params: { path: { studentId, goalId } } }));
3835
4188
  }
3836
4189
  if (action === "read") {
4190
+ const studentId = flagString(parsed, "student", { required: true });
3837
4191
  const goalId = flagString(parsed, "goal", { required: true });
3838
4192
  return unwrap(await api.client.GET("/tutor/students/{studentId}/goals/{goalId}/workspace/file", {
3839
4193
  params: {
@@ -3842,7 +4196,115 @@ export async function runCommand(argv) {
3842
4196
  },
3843
4197
  }));
3844
4198
  }
4199
+ if (action === "init" || action === "checkout") {
4200
+ const studentId = flagString(parsed, "student", { required: true });
4201
+ const goalId = flagString(parsed, "goal");
4202
+ const draftSlug = flagString(parsed, "draft");
4203
+ if (action === "init" && (goalId || !draftSlug)) {
4204
+ throw new CliError("invalid_arguments", "goals files init requires --draft and does not accept --goal.");
4205
+ }
4206
+ if (Boolean(goalId) === Boolean(draftSlug)) {
4207
+ throw new CliError("invalid_arguments", "Pass exactly one of --goal or --draft for goals files checkout.");
4208
+ }
4209
+ const outputDirectory = flagString(parsed, "output-dir", {
4210
+ required: true,
4211
+ });
4212
+ const target = goalId
4213
+ ? { kind: "goal", goalId }
4214
+ : { kind: "draft", draftSlug: draftSlug };
4215
+ const response = await readRemoteWorkspaceSnapshot(api, studentId, target);
4216
+ if (action === "init" && response.snapshot.fileCount > 0) {
4217
+ throw new CliError("draft_exists", `Draft ${draftSlug} already has files. Use goals files checkout --draft to preserve them.`);
4218
+ }
4219
+ return checkoutGoalWorkspace({
4220
+ outputDirectory,
4221
+ studentId,
4222
+ target,
4223
+ snapshot: response.snapshot,
4224
+ });
4225
+ }
4226
+ if (action === "push") {
4227
+ const sourceDirectory = flagString(parsed, "source-dir", {
4228
+ required: true,
4229
+ });
4230
+ const checkout = await readRecessGitMetadata(sourceDirectory);
4231
+ const local = await readGoalWorkspaceGitChanges(checkout.root, checkout.metadata.upstreamCommit);
4232
+ const defaultMessage = local.commits.length === 1
4233
+ ? local.commits[0].subject
4234
+ : `recess-cli: push ${local.commits.length} commits (${local.commits.at(-1).subject})`;
4235
+ const message = (flagString(parsed, "message") ?? defaultMessage).trim();
4236
+ if (!message || message.length > 200) {
4237
+ throw new CliError("invalid_arguments", "--message (or the derived local commit message) must be 1 to 200 characters.");
4238
+ }
4239
+ const files = local.changes.map((change) => change.action === "delete"
4240
+ ? { path: change.path, action: "delete" }
4241
+ : {
4242
+ path: change.path,
4243
+ action: "upsert",
4244
+ content: change.content,
4245
+ contentEncoding: change.contentEncoding,
4246
+ });
4247
+ const body = {
4248
+ studentUserId: checkout.metadata.studentId,
4249
+ target: checkout.metadata.target,
4250
+ message,
4251
+ files,
4252
+ expectedChangeId: checkout.metadata.changeId,
4253
+ };
4254
+ const preflight = unwrap(await api.client.POST("/tutor/browser/mesa/workspace-files", {
4255
+ body: { ...body, dryRun: true },
4256
+ }));
4257
+ if (preflight.action !== "preview_workspace_files_write") {
4258
+ throw new CliError("unexpected_response", "Goal workspace push did not return a preview; nothing was written.");
4259
+ }
4260
+ const preview = {
4261
+ action: `push committed Git changes to a student's Mesa ${checkout.metadata.target.kind} workspace`,
4262
+ target: preflight.target,
4263
+ request: {
4264
+ sourceDirectory: checkout.root,
4265
+ mesaBaseChangeId: checkout.metadata.changeId,
4266
+ localBaseCommit: checkout.metadata.upstreamCommit,
4267
+ localHeadCommit: local.head,
4268
+ commits: local.commits,
4269
+ message,
4270
+ changes: local.changes.map((change) => change.action === "delete"
4271
+ ? change
4272
+ : {
4273
+ path: change.path,
4274
+ action: change.action,
4275
+ sizeBytes: change.sizeBytes,
4276
+ sha256: change.sha256,
4277
+ }),
4278
+ sizeBytes: local.sizeBytes,
4279
+ },
4280
+ details: {
4281
+ currentChangeId: preflight.currentChangeId,
4282
+ 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.",
4283
+ },
4284
+ };
4285
+ return previewBoundWrite(parsed, preview, async () => {
4286
+ const result = unwrap(await api.client.POST("/tutor/browser/mesa/workspace-files", {
4287
+ body: { ...body, dryRun: false },
4288
+ }));
4289
+ if (result.action !== "write_workspace_files") {
4290
+ throw new CliError("unexpected_response", "Goal workspace push returned an unexpected response.");
4291
+ }
4292
+ await writeRecessGitMetadata(checkout.root, {
4293
+ ...checkout.metadata,
4294
+ changeId: result.changeId,
4295
+ upstreamCommit: local.head,
4296
+ });
4297
+ return {
4298
+ ...result,
4299
+ local: {
4300
+ directory: checkout.root,
4301
+ upstreamCommit: local.head,
4302
+ },
4303
+ };
4304
+ });
4305
+ }
3845
4306
  if (action === "write") {
4307
+ const studentId = flagString(parsed, "student", { required: true });
3846
4308
  const goalId = flagString(parsed, "goal");
3847
4309
  const draftSlug = flagString(parsed, "draft");
3848
4310
  if (Boolean(goalId) === Boolean(draftSlug)) {
@@ -3881,19 +4343,18 @@ export async function runCommand(argv) {
3881
4343
  files: preflight.files,
3882
4344
  note: target.kind === "draft"
3883
4345
  ? "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.",
4346
+ : "This directly edits the live goal workspace, including modules/ and state/. The write remains bound to the previewed workspace revision.",
3885
4347
  },
3886
4348
  };
3887
- requirePreviewBoundConfirmation(parsed, preview);
3888
- return unwrap(await api.client.POST("/tutor/browser/mesa/workspace-files", {
4349
+ return previewBoundWrite(parsed, preview, async () => unwrap(await api.client.POST("/tutor/browser/mesa/workspace-files", {
3889
4350
  body: {
3890
4351
  ...body,
3891
4352
  dryRun: false,
3892
4353
  expectedChangeId: preflight.currentChangeId,
3893
4354
  },
3894
- }));
4355
+ })));
3895
4356
  }
3896
- throw new CliError("invalid_arguments", "Use goals files list|read|write.");
4357
+ throw new CliError("invalid_arguments", "Use goals files list|read|init|checkout|push|write.");
3897
4358
  }
3898
4359
  if (noun === "request" && verb === "get") {
3899
4360
  return api.rawGet(positional(parsed, 2, "request path"));