recess-cli 2.3.0 → 2.5.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/README.md CHANGED
@@ -22,6 +22,29 @@ recess setup --reason "Install and update the Recess agent skill"
22
22
 
23
23
  Publishing rides the production deploy (`.github/workflows/admin-cli-publish.yml`): bump `version` in `apps/admin-cli/package.json` in a normal PR to `staging`, and it publishes when `staging` promotes to `production`. A production deploy that did not bump the version is a no-op — a `gate` job checks the version against npm first. The same workflow is still dispatchable by hand for out-of-band releases. pnpm packs the CLI so the workspace `catalog:` dependency becomes a real range; npm then publishes that tarball through the workflow's OIDC trusted-publishing path. The registry-side publisher must be configured as described in `docs/codebase/admin-cli.md`.
24
24
 
25
+ ## Building Studio apps with your agent
26
+
27
+ ```bash
28
+ recess apps init my-applet --reason "New applet" # scaffold + AGENTS.md (the contract your agent follows)
29
+ # build it in your editor with Claude Code / Codex / Cursor, then:
30
+ recess --json apps validate my-applet --reason "Check it" # Studio's validation, nothing published
31
+ recess --json apps publish my-applet --reason "Ship it" # validation + independent review → live at appUrl
32
+ recess --json apps list --reason "See my apps"
33
+ recess apps pull <project-id> my-applet --reason "Keep editing"
34
+ ```
35
+
36
+ Guides and staff only. The app folder keeps its project id in `.recess/app.json`, so publishing again updates the same app.
37
+
38
+ For a specific kid, start from their session instead of a blank description:
39
+
40
+ ```bash
41
+ recess --json students todos --student <kid-id> --analyzed --reason "What did they struggle with"
42
+ recess --json students analysis --todo <todo-id> --reason "Read the analysis"
43
+ recess apps init fix-it --for-todo <todo-id> --reason "Build for this gap" # scaffold + brief.md
44
+ recess --json apps publish fix-it --assign <kid-id> --due 2026-09-01 --reason "Ship it to the kid"
45
+ recess --json apps standards "grade 4 adding fractions" --reason "Find the code"
46
+ ```
47
+
25
48
  ## Install (from a checkout — CLI development)
26
49
 
27
50
  From the monolith root:
@@ -111,7 +134,7 @@ Error or write preview:
111
134
 
112
135
  Exit code `0` means success, `1` means an input/auth/API failure, and `2` means a write is awaiting explicit human confirmation.
113
136
 
114
- `recess --json agent-context` returns the canonical command/flag/positional schema. `recess --json help payout recipients` returns scoped help. Unknown flags, duplicate non-repeatable flags, missing values, and extra positionals are errors instead of being silently ignored.
137
+ `recess --json agent-context` returns the canonical command/flag/positional schema filtered to the scope claim already stored in the current CLI session. Bare help, scoped help, and `agent-context` make no API request; real commands still go through server authorization, while `auth status` and `doctor` perform live session checks. `recess --json help payout recipients` returns scoped help, and human help marks exact-admin commands with `◆`. Unknown flags, duplicate non-repeatable flags, missing values, and extra positionals are errors instead of being silently ignored.
115
138
 
116
139
  Every command-driven request to the Recess API except `auth` requires `--reason "..."`: a
117
140
  non-empty, human-readable purpose of at most 1024 characters. The CLI sends it as
@@ -249,6 +272,7 @@ recess --json students schedule --student <kid-id> --days 30 --reason "Review th
249
272
  recess --json students xp-history --student <kid-id> --range month --reason "Review recent XP history"
250
273
  recess --json goals list --student <kid-id> --reason "Review the student's goals"
251
274
  recess --json todos create --student <kid-id> --title "Read chapter 4" --reason "Add the assigned reading"
275
+ recess --json todos complete <todo-id> --xp 35 --reason "Complete the todo with a 35 XP total reward"
252
276
  recess --json goals delete <goal-id> --student <kid-id> --reason "Remove this obsolete goal"
253
277
  recess --json todos delete <todo-id> --reason "Remove this disposable todo"
254
278
  recess --json todos generate-applet <todo-id> --student <kid-id> --reason "Generate this todo's applet"
@@ -258,7 +282,9 @@ recess --json rocky get --student <kid-id> --reason "Inspect the student's Rocky
258
282
  ```
259
283
 
260
284
  All family writes still preview first. Goal/todo/Rocky edits also carry the current server version
261
- into the confirmed request. Applet generation is staff-only: it resolves the todo's latest learning
285
+ into the confirmed request. `todos complete` is staff-only; `--xp` is the target total XP for the
286
+ todo, so its preview reports prior credit and the new delta before the normal completion and reward
287
+ side effects run. Applet generation is also staff-only: it resolves the todo's latest learning
262
288
  analysis, defaults the generated todo to tomorrow in the student's timezone, and lets the server
263
289
  select v1 or v2 for that student. A guardian cannot target another family, inspect frozen/deleted
264
290
  template history, choose todo rewards/completion/internal fields, or use the ADMIN-only
package/dist/args.js CHANGED
@@ -30,6 +30,7 @@ const BOOLEAN_FLAGS = new Set([
30
30
  "skill-only",
31
31
  "spec-only",
32
32
  "starter-only",
33
+ "unlink",
33
34
  "visual-only",
34
35
  "version",
35
36
  "wait",
package/dist/cli.js CHANGED
@@ -8,8 +8,10 @@ import { login, pollDeviceAuth, requestDeviceAuth } from "./auth.js";
8
8
  import { clearStoredSession, deleteProfile, listProfiles, resolveConfig, saveProfile, useProfile, } from "./config.js";
9
9
  import { agentContext, buildCommandSchema, scopedHelp, validateInvocation, } from "./command-schema.js";
10
10
  import { runApplicationsCommand } from "./commands/applications.js";
11
+ import { runAppsCommand } from "./commands/apps.js";
11
12
  import { runOnboardingCommand } from "./commands/onboarding.js";
12
13
  import { runSchoolCommand } from "./commands/school.js";
14
+ import { runVillageEventsCommand } from "./commands/village-events.js";
13
15
  import { assertChoice, flagIdList, positional, readJsonFile, readJsonValue, } from "./commands/shared.js";
14
16
  import { CliError } from "./errors.js";
15
17
  import { listFeedback, submitFeedback } from "./feedback.js";
@@ -91,6 +93,69 @@ async function sessionStatus(config) {
91
93
  }
92
94
  return { ...result.data, authSource: config.authSource };
93
95
  }
96
+ const DISCOVERY_ROLE_BY_SCOPE = {
97
+ full_admin: "ADMIN",
98
+ family_ai: "GUARDIAN",
99
+ guide_students: "GUIDE",
100
+ village_home: "KID",
101
+ };
102
+ /**
103
+ * Command discovery is intentionally local: the signed session cookie already
104
+ * carries the role, CLI scope, and expiry minted at login. Decoding those
105
+ * unverified claims is safe here because they only hide or reveal help text;
106
+ * every real command still sends the cookie to the server for authorization.
107
+ */
108
+ function resolveCommandDiscovery(config) {
109
+ const signedOut = {
110
+ scope: null,
111
+ role: null,
112
+ source: "signed_out",
113
+ };
114
+ const unavailable = {
115
+ scope: null,
116
+ role: null,
117
+ source: "unavailable",
118
+ };
119
+ if (!config.sessionCookie)
120
+ return signedOut;
121
+ try {
122
+ const separator = config.sessionCookie.indexOf("=");
123
+ if (separator < 1)
124
+ return unavailable;
125
+ const cookieValue = decodeURIComponent(config.sessionCookie.slice(separator + 1).split(";", 1)[0]);
126
+ const payloadSegment = cookieValue.split(".")[1];
127
+ if (!payloadSegment)
128
+ return unavailable;
129
+ const value = JSON.parse(Buffer.from(payloadSegment, "base64url").toString("utf8"));
130
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
131
+ return unavailable;
132
+ }
133
+ const claim = value;
134
+ if (typeof claim.exp !== "number" || !Number.isFinite(claim.exp)) {
135
+ return unavailable;
136
+ }
137
+ if (claim.exp * 1000 <= Date.now()) {
138
+ return signedOut;
139
+ }
140
+ const scope = typeof claim.cliScope === "string" &&
141
+ Object.hasOwn(DISCOVERY_ROLE_BY_SCOPE, claim.cliScope)
142
+ ? claim.cliScope
143
+ : claim.role === "ADMIN" && claim.adminCli === true
144
+ ? "full_admin"
145
+ : null;
146
+ if (!scope || claim.role !== DISCOVERY_ROLE_BY_SCOPE[scope]) {
147
+ return unavailable;
148
+ }
149
+ return {
150
+ scope,
151
+ role: DISCOVERY_ROLE_BY_SCOPE[scope],
152
+ source: "session_claim",
153
+ };
154
+ }
155
+ catch {
156
+ return unavailable;
157
+ }
158
+ }
94
159
  async function doctor(config, reason) {
95
160
  const checks = {
96
161
  config: {
@@ -806,6 +871,10 @@ const SCHOOL_TIER_OPTIONS = [
806
871
  { id: "platform", name: "Platform Only", defaultClassSlots: 0 },
807
872
  ];
808
873
  const SCHOOL_TIER_IDS = SCHOOL_TIER_OPTIONS.map((tier) => tier.id);
874
+ // Roles `users restore` may hand back. ADMIN and MODERATOR are absent on
875
+ // purpose — a CLI session must never be able to mint staff; promotion has its
876
+ // own paths. The server's POST /admin/users/change-role/ enforces the same set.
877
+ const RESTORABLE_ROLES = ["KID", "GUARDIAN", "GUIDE", "PROGRAM"];
809
878
  const MAX_MAP_PDF_BYTES = 15 * 1024 * 1024;
810
879
  const GOAL_TEMPLATE_KINDS = ["SIMPLE", "BLUEPRINT"];
811
880
  const GOAL_TEMPLATE_SETUP_AUDIENCES = ["KID_FRIENDLY", "PARENT_SETUP"];
@@ -1510,19 +1579,28 @@ export async function runCommand(argv) {
1510
1579
  .map((name) => `--${name}`)
1511
1580
  .join(", ")}.`, 1, { validFlags: ["--deliver", "--help", "--json", "--profile"] });
1512
1581
  }
1513
- return { help: scopedHelp(HELP, commands, []) };
1582
+ const config = await resolveConfig(flagString(parsed, "profile"));
1583
+ const discovery = resolveCommandDiscovery(config);
1584
+ return { help: scopedHelp(HELP, commands, [], discovery) };
1514
1585
  }
1515
1586
  if (noun === "help" || hasFlag(parsed, "help")) {
1516
1587
  const scope = noun === "help" ? parsed.positionals.slice(1) : parsed.positionals;
1517
- return { help: scopedHelp(HELP, commands, scope) };
1588
+ const config = await resolveConfig(flagString(parsed, "profile"));
1589
+ const discovery = resolveCommandDiscovery(config);
1590
+ return { help: scopedHelp(HELP, commands, scope, discovery) };
1518
1591
  }
1519
1592
  validateInvocation(parsed, commands);
1520
1593
  if (noun === "agent-context") {
1521
- const profiles = await listProfiles();
1594
+ const [profiles, config] = await Promise.all([
1595
+ listProfiles(),
1596
+ resolveConfig(flagString(parsed, "profile")),
1597
+ ]);
1598
+ const discovery = resolveCommandDiscovery(config);
1522
1599
  return agentContext(commands, {
1523
1600
  cliVersion: await readCliVersion(),
1524
1601
  availableProfiles: profiles.profiles.map((profile) => profile.name),
1525
1602
  feedbackUpstreamConfigured: Boolean(process.env.RECESS_CLI_FEEDBACK_ENDPOINT),
1603
+ discovery,
1526
1604
  });
1527
1605
  }
1528
1606
  if (noun === "profile") {
@@ -1888,8 +1966,11 @@ export async function runCommand(argv) {
1888
1966
  }
1889
1967
  throw new CliError("invalid_arguments", "Use village worlds export|import|promote.");
1890
1968
  }
1969
+ if (verb === "events" || verb === "link-event") {
1970
+ return runVillageEventsCommand({ parsed, api, writeCommand }, verb);
1971
+ }
1891
1972
  if (verb !== "models") {
1892
- throw new CliError("invalid_arguments", "Use village build …, village library …, village objects …, village worlds …, village models …, or village render …");
1973
+ throw new CliError("invalid_arguments", "Use village build …, village library …, village objects …, village worlds …, village models …, village events, village link-event …, or village render …");
1893
1974
  }
1894
1975
  const action = positional(parsed, 2, "Village model action");
1895
1976
  if (action === "list") {
@@ -2187,6 +2268,140 @@ export async function runCommand(argv) {
2187
2268
  params: { path: { userId } },
2188
2269
  }));
2189
2270
  }
2271
+ if (noun === "users" && (verb === "lock" || verb === "unlock")) {
2272
+ const userId = positional(parsed, 2, "user ID");
2273
+ const locking = verb === "lock";
2274
+ const profile = unwrap(await api.client.GET("/admin/users/{userId}", {
2275
+ params: { path: { userId } },
2276
+ }));
2277
+ const target = profile.user;
2278
+ // Mirrors the recess.gg/ai admin Lock/Unlock buttons exactly: a KID is
2279
+ // locked together with its GUARDIANs (an unlocked kid under a locked
2280
+ // guardian is not actually unlocked), anyone else is treated as the
2281
+ // guardian side alone.
2282
+ const guardians = (target.family?.users ?? []).filter((member) => member.role === "GUARDIAN");
2283
+ const kidIds = target.role === "KID" ? [target.id] : [];
2284
+ const guardianIds = target.role === "KID"
2285
+ ? guardians.map((member) => member.id)
2286
+ : [target.id];
2287
+ const isUnlocked = (attributes) => (attributes ?? []).some((attribute) => attribute.attribute === "FLAG_ACCOUNT_UNLOCKED");
2288
+ const describe = (member) => ({
2289
+ id: member.id,
2290
+ name: [member.firstName, member.lastName].filter(Boolean).join(" "),
2291
+ role: member.role,
2292
+ unlockedNow: isUnlocked(member.attributes),
2293
+ });
2294
+ const affected = [
2295
+ ...(target.role === "KID" ? [describe(target)] : []),
2296
+ ...(target.role === "KID" ? guardians.map(describe) : [describe(target)]),
2297
+ ];
2298
+ const preview = {
2299
+ userId: target.id,
2300
+ name: [target.firstName, target.lastName].filter(Boolean).join(" "),
2301
+ role: target.role,
2302
+ familyId: target.familyId,
2303
+ accountUnlockedNow: target.role === "KID"
2304
+ ? isUnlocked(target.attributes) &&
2305
+ guardians.every((member) => isUnlocked(member.attributes))
2306
+ : isUnlocked(target.attributes),
2307
+ accountUnlockedAfter: !locking,
2308
+ kidIds,
2309
+ guardianIds,
2310
+ affected,
2311
+ };
2312
+ if (target.role === "KID" && guardianIds.length === 0) {
2313
+ throw new CliError("invalid_arguments", `${preview.name || target.id} has no GUARDIAN in their family, so ${verb}ing the kid alone would leave the account in a mixed state. Resolve the family's guardians first.`, 1, { preview });
2314
+ }
2315
+ const body = { kidIds, guardianIds };
2316
+ return writeCommand(parsed, {
2317
+ action: locking
2318
+ ? "lock the account: revoke the kid and guardian access roles, drop the kid from their Recess channels, and clear FLAG_ACCOUNT_UNLOCKED"
2319
+ : "unlock the account: grant the kid and guardian access roles, restore the kid's Recess channel memberships, and set FLAG_ACCOUNT_UNLOCKED (the kid's clients are notified live)",
2320
+ target: { userId: target.id, kidIds, guardianIds },
2321
+ request: body,
2322
+ details: preview,
2323
+ }, async () => unwrap(await api.client.POST(locking ? "/admin/users/lock/" : "/admin/users/unlock/", { body })));
2324
+ }
2325
+ if (noun === "users" && (verb === "disable" || verb === "restore")) {
2326
+ const userId = positional(parsed, 2, "user ID");
2327
+ const disabling = verb === "disable";
2328
+ // Restoring is one person at a time on purpose: DISABLED does not record
2329
+ // what the account used to be, so the caller must name the role. --family
2330
+ // would have to guess one per member.
2331
+ const wholeFamily = disabling && hasFlag(parsed, "whole-family");
2332
+ const role = disabling
2333
+ ? "DISABLED"
2334
+ : assertChoice(flagString(parsed, "role", { required: true }).toUpperCase(), RESTORABLE_ROLES, "--role");
2335
+ // Server-resolved preflight (admin-cli.md C3): who else is in the family,
2336
+ // who is already DISABLED, and who still has billable enrollments. It reads
2337
+ // through findUnique, so it also resolves an already-disabled target — that
2338
+ // is what makes a rerun (and every restore) previewable.
2339
+ const state = unwrap(await api.client.GET("/admin/users/{userId}/role-preflight/", {
2340
+ params: { path: { userId } },
2341
+ }));
2342
+ const describe = (member) => ({
2343
+ id: member.id,
2344
+ name: [member.firstName, member.lastName].filter(Boolean).join(" "),
2345
+ role: member.role,
2346
+ email: member.email,
2347
+ billableEnrollments: member.billableEnrollments,
2348
+ // Informational: a role change does NOT revoke these. Stripping access
2349
+ // roles is what `users lock` does — run it alongside a disable when the
2350
+ // account should also lose its grants.
2351
+ accessRoles: member.accessRoles,
2352
+ });
2353
+ const candidates = wholeFamily ? state.members : [state.target];
2354
+ const skipped = [];
2355
+ const acting = [];
2356
+ for (const member of candidates) {
2357
+ const entry = describe(member);
2358
+ if (member.id === state.actorId) {
2359
+ skipped.push({ ...entry, reason: "this is your own account" });
2360
+ continue;
2361
+ }
2362
+ if (member.role === "ADMIN" || member.role === "MODERATOR") {
2363
+ skipped.push({
2364
+ ...entry,
2365
+ reason: `${member.role} accounts are never role-changed from the CLI`,
2366
+ });
2367
+ continue;
2368
+ }
2369
+ if (disabling && member.disabled) {
2370
+ skipped.push({ ...entry, reason: "already DISABLED" });
2371
+ continue;
2372
+ }
2373
+ if (!disabling && !member.disabled) {
2374
+ skipped.push({ ...entry, reason: `already ${member.role}` });
2375
+ continue;
2376
+ }
2377
+ acting.push(entry);
2378
+ }
2379
+ const preview = {
2380
+ userId,
2381
+ family: state.family,
2382
+ role,
2383
+ [disabling ? "disabling" : "restoring"]: acting,
2384
+ skipped,
2385
+ };
2386
+ if (acting.length === 0) {
2387
+ throw new CliError("no_op", `Nothing to ${verb}: every candidate was skipped. See preview.skipped for why.`, 1, { preview });
2388
+ }
2389
+ const billing = acting.filter((member) => member.billableEnrollments > 0);
2390
+ if (disabling && billing.length > 0 && !hasFlag(parsed, "allow-billing")) {
2391
+ throw new CliError("billable_enrollments", `${billing
2392
+ .map((member) => `${member.name} (${member.billableEnrollments})`)
2393
+ .join(", ")} still hold ACTIVE or PAST_DUE enrollments. Cancel billing first, or rerun with --allow-billing to orphan those paid seats deliberately.`, 1, { preview });
2394
+ }
2395
+ const body = { userIds: acting.map((member) => member.id), role };
2396
+ return writeCommand(parsed, {
2397
+ action: disabling
2398
+ ? `disable ${acting.length} account(s): set User.role to DISABLED, which hides them from every dashboard, search, chat, and todo query, and purges their coherent-feed personal data. Soft and reversible via users restore — this is NOT a GDPR erasure`
2399
+ : `restore ${acting.length} account(s) from DISABLED to ${role}`,
2400
+ target: { userId, userIds: body.userIds, family: state.family },
2401
+ request: body,
2402
+ details: preview,
2403
+ }, async () => unwrap(await api.client.POST("/admin/users/change-role/", { body })));
2404
+ }
2190
2405
  if (noun === "guides" && verb === "create") {
2191
2406
  const email = flagString(parsed, "email", { required: true })
2192
2407
  .trim()
@@ -2323,6 +2538,9 @@ export async function runCommand(argv) {
2323
2538
  if (noun === "applications" || noun === "quotes") {
2324
2539
  return runApplicationsCommand({ parsed, api, writeCommand });
2325
2540
  }
2541
+ if (noun === "apps") {
2542
+ return runAppsCommand({ parsed, api, writeCommand });
2543
+ }
2326
2544
  if (noun === "school") {
2327
2545
  return runSchoolCommand({ parsed, api, writeCommand });
2328
2546
  }
@@ -4029,6 +4247,25 @@ export async function runCommand(argv) {
4029
4247
  },
4030
4248
  }));
4031
4249
  }
4250
+ if (verb === "todos") {
4251
+ const studentId = flagString(parsed, "student", { required: true });
4252
+ const limit = flagNumber(parsed, "limit");
4253
+ return unwrap(await api.client.GET("/studio/students/{studentId}/todos", {
4254
+ params: {
4255
+ path: { studentId },
4256
+ query: {
4257
+ ...(limit === undefined ? {} : { limit }),
4258
+ ...(hasFlag(parsed, "analyzed") ? { analyzed: true } : {}),
4259
+ },
4260
+ },
4261
+ }));
4262
+ }
4263
+ if (verb === "analysis") {
4264
+ const todoId = flagString(parsed, "todo", { required: true });
4265
+ return unwrap(await api.client.GET("/studio/todos/{todoId}/analysis", {
4266
+ params: { path: { todoId } },
4267
+ }));
4268
+ }
4032
4269
  if (verb === "xp-history") {
4033
4270
  const kidId = flagString(parsed, "student", { required: true });
4034
4271
  const range = flagString(parsed, "range");
@@ -4042,7 +4279,7 @@ export async function runCommand(argv) {
4042
4279
  },
4043
4280
  }));
4044
4281
  }
4045
- throw new CliError("invalid_arguments", "Use students list|today|schedule|xp-history|upload-map-scores.");
4282
+ throw new CliError("invalid_arguments", "Use students list|today|schedule|todos|analysis|xp-history|upload-map-scores.");
4046
4283
  }
4047
4284
  if (noun === "todos") {
4048
4285
  if (verb === "create") {
@@ -4070,6 +4307,7 @@ export async function runCommand(argv) {
4070
4307
  const todoId = positional(parsed, 2, "todo ID");
4071
4308
  const patchFile = flagString(parsed, "patch-file", { required: true });
4072
4309
  const body = (await readJsonFile(patchFile, "Todo patch file"));
4310
+ const sessionRole = await resolveSessionRole(api);
4073
4311
  const current = unwrap(await api.client.GET("/tutor/browser/todos/{id}/", {
4074
4312
  params: { path: { id: todoId } },
4075
4313
  }));
@@ -4082,8 +4320,73 @@ export async function runCommand(argv) {
4082
4320
  expectedUpdatedAt: current.updatedAt,
4083
4321
  patch: body,
4084
4322
  },
4323
+ details: sessionRole === "ADMIN"
4324
+ ? {
4325
+ writeRoute: "/admin/todos/{id} (staff route; supports the full typed todo patch)",
4326
+ }
4327
+ : {
4328
+ writeRoute: "/tutor/browser/todos/{id} (family-scoped field allowlist)",
4329
+ },
4330
+ };
4331
+ return previewBoundWrite(parsed, preview, async () => sessionRole === "ADMIN"
4332
+ ? unwrap(await api.client.PATCH("/admin/todos/{id}", {
4333
+ params: { path: { id: todoId } },
4334
+ body: body,
4335
+ }))
4336
+ : unwrap(await api.client.PATCH("/tutor/browser/todos/{id}", {
4337
+ params: { path: { id: todoId } },
4338
+ body,
4339
+ })));
4340
+ }
4341
+ if (verb === "complete") {
4342
+ const todoId = positional(parsed, 2, "todo ID");
4343
+ const targetXp = flagNumber(parsed, "xp");
4344
+ if (targetXp === undefined ||
4345
+ !Number.isInteger(targetXp) ||
4346
+ targetXp <= 0) {
4347
+ throw new CliError("invalid_arguments", "--xp must be a positive integer. It is the target total XP credited for this todo; prior awards count toward that total.");
4348
+ }
4349
+ const [currentResponse, detailsResponse] = await Promise.all([
4350
+ api.client.GET("/tutor/browser/todos/{id}/", {
4351
+ params: { path: { id: todoId } },
4352
+ }),
4353
+ api.client.GET("/admin/todos/{id}/details", {
4354
+ params: { path: { id: todoId } },
4355
+ }),
4356
+ ]);
4357
+ const current = unwrap(currentResponse);
4358
+ const completionXpAwarded = unwrap(detailsResponse).totalXpAwarded ?? 0;
4359
+ if (current.status === "COMPLETED") {
4360
+ throw new CliError("already_completed", `Todo ${todoId} ("${current.title}") is already completed with ${completionXpAwarded} completion XP credited.`);
4361
+ }
4362
+ const body = {
4363
+ status: "COMPLETED",
4364
+ xpReward: targetXp,
4365
+ expectedUpdatedAt: current.updatedAt,
4366
+ };
4367
+ const newXp = Math.max(0, targetXp - completionXpAwarded);
4368
+ const preview = {
4369
+ action: "complete a todo through the staff reward path and set its total credited XP target",
4370
+ target: {
4371
+ todoId,
4372
+ studentUserId: current.userId,
4373
+ studentName: [current.user.firstName, current.user.lastName]
4374
+ .filter(Boolean)
4375
+ .join(" ") || null,
4376
+ title: current.title,
4377
+ status: current.status,
4378
+ goal: current.goal,
4379
+ },
4380
+ request: body,
4381
+ details: {
4382
+ alreadyAwardedXp: completionXpAwarded,
4383
+ targetTotalXp: targetXp,
4384
+ newlyAwardedXp: newXp,
4385
+ configuredCoinReward: current.reward,
4386
+ note: `The XP ledger is delta-guarded: this writes at most ${newXp} new XP so the todo reaches ${targetXp} total. Completion also claims any unawarded portion of the todo's configured ${current.reward}-coin reward, replaces its active completion analysis with a manual-completion record, records completion activity, may complete a linked goal module and its configured rewards, and may notify the parent through the normal completion pipeline.`,
4387
+ },
4085
4388
  };
4086
- return previewBoundWrite(parsed, preview, async () => unwrap(await api.client.PATCH("/tutor/browser/todos/{id}", {
4389
+ return previewBoundWrite(parsed, preview, async () => unwrap(await api.client.PATCH("/admin/todos/{id}", {
4087
4390
  params: { path: { id: todoId } },
4088
4391
  body,
4089
4392
  })));
@@ -4156,7 +4459,7 @@ export async function runCommand(argv) {
4156
4459
  body: targetDueDateISO ? { targetDueDateISO } : {},
4157
4460
  })));
4158
4461
  }
4159
- throw new CliError("invalid_arguments", "Use todos create|edit|delete|generate-applet.");
4462
+ throw new CliError("invalid_arguments", "Use todos create|edit|complete|delete|generate-applet.");
4160
4463
  }
4161
4464
  if (noun === "memories") {
4162
4465
  const studentId = flagString(parsed, "student", { required: true });