recess-cli 2.2.0 → 2.4.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
@@ -111,7 +111,7 @@ Error or write preview:
111
111
 
112
112
  Exit code `0` means success, `1` means an input/auth/API failure, and `2` means a write is awaiting explicit human confirmation.
113
113
 
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.
114
+ `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
115
 
116
116
  Every command-driven request to the Recess API except `auth` requires `--reason "..."`: a
117
117
  non-empty, human-readable purpose of at most 1024 characters. The CLI sends it as
@@ -121,6 +121,24 @@ row per request. The human-only `recess ui` console is explicitly exempt and ide
121
121
 
122
122
  ## Common flow
123
123
 
124
+ School-onboarding work is family-first: one composite view, then a loop of "what's
125
+ next" → the suggested command → "what's next" again, with `doctor` as the
126
+ gate-observability instrument when something is unexpectedly dark:
127
+
128
+ ```bash
129
+ recess --json onboarding family <family-id> --reason "One-screen view of this school family"
130
+ recess --json onboarding next <family-id> --reason "What should happen next for this family"
131
+ recess --json onboarding doctor --family <family-id> --reason "Why is the school surface dark for them"
132
+ recess --json onboarding starter-coverage --reason "Run the pre-flip coverage gate"
133
+ recess --json onboarding backfill-trackers --reason "Census the WS-H guardian trackers"
134
+ ```
135
+
136
+ `next` returns the mission-control queue's current action plus `suggestedCommands`
137
+ with real ids substituted; every suggested write still previews and requires its
138
+ own `--confirm`. `doctor` reports the env kill-switches, comms mode, and
139
+ per-guardian/per-kid flag + capability-lock + cohort-gate state — its env values
140
+ are the answering service's only (the Worker can differ).
141
+
124
142
  ```bash
125
143
  recess --json users search "Morgan Rivera" --reason "Find the exact student record"
126
144
  recess --json users tier get <kid-id> --reason "Inspect the student's current tier"
@@ -231,6 +249,7 @@ recess --json students schedule --student <kid-id> --days 30 --reason "Review th
231
249
  recess --json students xp-history --student <kid-id> --range month --reason "Review recent XP history"
232
250
  recess --json goals list --student <kid-id> --reason "Review the student's goals"
233
251
  recess --json todos create --student <kid-id> --title "Read chapter 4" --reason "Add the assigned reading"
252
+ recess --json todos complete <todo-id> --xp 35 --reason "Complete the todo with a 35 XP total reward"
234
253
  recess --json goals delete <goal-id> --student <kid-id> --reason "Remove this obsolete goal"
235
254
  recess --json todos delete <todo-id> --reason "Remove this disposable todo"
236
255
  recess --json todos generate-applet <todo-id> --student <kid-id> --reason "Generate this todo's applet"
@@ -240,7 +259,9 @@ recess --json rocky get --student <kid-id> --reason "Inspect the student's Rocky
240
259
  ```
241
260
 
242
261
  All family writes still preview first. Goal/todo/Rocky edits also carry the current server version
243
- into the confirmed request. Applet generation is staff-only: it resolves the todo's latest learning
262
+ into the confirmed request. `todos complete` is staff-only; `--xp` is the target total XP for the
263
+ todo, so its preview reports prior credit and the new delta before the normal completion and reward
264
+ side effects run. Applet generation is also staff-only: it resolves the todo's latest learning
244
265
  analysis, defaults the generated todo to tomorrow in the student's timezone, and lets the server
245
266
  select v1 or v2 for that student. A guardian cannot target another family, inspect frozen/deleted
246
267
  template history, choose todo rewards/completion/internal fields, or use the ADMIN-only
package/dist/api.js CHANGED
@@ -142,6 +142,23 @@ export class RecessAdminApi {
142
142
  throw apiError(response.status, body);
143
143
  return body;
144
144
  }
145
+ async uploadPartnerLogo(image, fileName, mimeType) {
146
+ this.requireAuth();
147
+ const formData = new FormData();
148
+ const arrayBuffer = image.buffer.slice(image.byteOffset, image.byteOffset + image.byteLength);
149
+ formData.append("file", new Blob([arrayBuffer], { type: mimeType }), fileName);
150
+ const headers = cliRequestHeaders({ cookie: this.config.sessionCookie }, this.reason, this.clientTag);
151
+ applyIdempotencyHeaders(headers);
152
+ const response = await fetch(new URL("/admin/partner/logo-upload", this.config.apiOrigin), {
153
+ method: "POST",
154
+ headers,
155
+ body: formData,
156
+ });
157
+ const body = await readResponseBody(response);
158
+ if (!response.ok)
159
+ throw apiError(response.status, body);
160
+ return body;
161
+ }
145
162
  async uploadMapTestScores(studentId, pdf, fileName) {
146
163
  this.requireAuth();
147
164
  const formData = new FormData();
package/dist/auth.js CHANGED
@@ -100,6 +100,11 @@ export async function login(config, options) {
100
100
  // Start the headless (device-authorization) sign-in. Non-blocking: it stores the secret
101
101
  // device code locally and returns the approval URL for the agent to surface to a human, who
102
102
  // approves it in a browser. The agent then calls `pollDeviceAuth` to collect the session.
103
+ //
104
+ // The approver grants their own scope: a guardian approving yields a family-scoped session
105
+ // over their own kids, an admin a full-admin one. That is what lets an agent running
106
+ // anywhere — a cloud box with no browser — hold a session at all, instead of a human having
107
+ // to be the hands for every command.
103
108
  export async function requestDeviceAuth(config, options) {
104
109
  const response = await fetch(new URL("/auth/admin-cli/device/authorize/", config.apiOrigin), {
105
110
  method: "POST",
@@ -123,11 +128,11 @@ export async function requestDeviceAuth(config, options) {
123
128
  userCode: authorize.userCode,
124
129
  expiresAt: authorize.expiresAt,
125
130
  interval: authorize.interval,
126
- instructions: `Open ${authorize.approvalUrl} in a browser, approve as a Recess admin, then run \`recess auth poll\`.`,
131
+ instructions: `Open ${authorize.approvalUrl} in a browser and approve it while signed in to Recess as the person this agent should act as — the session takes on THAT account's scope, so a guardian grants family-only access. Then run \`recess auth poll\`.`,
127
132
  };
128
133
  }
129
134
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
130
- // Poll the device-authorization request until an admin approves it (then store the session),
135
+ // Poll the device-authorization request until a human approves it (then store the session),
131
136
  // or it is denied / expires / the timeout elapses. On a plain timeout the pending request is
132
137
  // preserved so the agent can call `auth poll` again after the human approves.
133
138
  export async function pollDeviceAuth(config, options) {
package/dist/cli.js CHANGED
@@ -24,6 +24,52 @@ import { appendJobEvent, getJob, listJobs, pruneJobs } from "./jobs.js";
24
24
  function requiredRequestReason(parsed) {
25
25
  return requireCliRequestReason(flagString(parsed, "reason", { required: true }));
26
26
  }
27
+ /**
28
+ * Role of whoever this session belongs to.
29
+ *
30
+ * The stored identity only describes an `auth login` session; an env-cookie
31
+ * session may belong to someone else entirely, so ask the server rather than
32
+ * trusting a stale login. Used by commands that must refuse *before* their
33
+ * confirmation gate — an approval a human grants and the server then rejects is
34
+ * worse than no preview at all.
35
+ */
36
+ async function resolveSessionRole(api) {
37
+ if (api.config.authSource === "config" && api.config.user?.role) {
38
+ return api.config.user.role;
39
+ }
40
+ const session = await api.client.GET("/auth/admin-cli/session/");
41
+ return session.data?.user.role;
42
+ }
43
+ /** Status values `goals edit` may send. Deliberately narrow: the backend patch
44
+ * route is pause/resume only, and reaching the terminal state is
45
+ * `goals archive`, which frees an open-goal slot and writes a different audit
46
+ * row. */
47
+ const EDITABLE_GOAL_STATUSES = ["ACTIVE", "PAUSED"];
48
+ /**
49
+ * Reject bad enum values in a goal patch file *before* the preview is built.
50
+ *
51
+ * A preview is what a human approves, so it must not describe a write the
52
+ * server will refuse. Sending `{"status":"COMPLETED"}` used to preview cleanly,
53
+ * resolve a compare-and-set token, mint an approval token, and only then fail
54
+ * at confirm — spending a human approval on an impossible request.
55
+ */
56
+ function assertGoalPatchValues(patch, patchFile) {
57
+ const status = patch.status;
58
+ if (status === undefined)
59
+ return;
60
+ if (typeof status !== "string" ||
61
+ !EDITABLE_GOAL_STATUSES.includes(status)) {
62
+ const archiveHint = status === "COMPLETED"
63
+ ? " To close out a finished course and free an open-goal slot, use `recess goals archive <goal-id> --student <kid-id>` instead — `goals edit` cannot set a terminal status."
64
+ : "";
65
+ throw new CliError("invalid_arguments", `${path.resolve(patchFile)} sets status to ${JSON.stringify(status)}, which \`goals edit\` does not accept. Allowed: ${EDITABLE_GOAL_STATUSES.join(", ")}.${archiveHint}`, 1, {
66
+ field: "status",
67
+ value: status,
68
+ allowed: [...EDITABLE_GOAL_STATUSES],
69
+ patchFile: path.resolve(patchFile),
70
+ });
71
+ }
72
+ }
27
73
  async function sessionStatus(config) {
28
74
  if (!config.sessionCookie) {
29
75
  return {
@@ -45,6 +91,69 @@ async function sessionStatus(config) {
45
91
  }
46
92
  return { ...result.data, authSource: config.authSource };
47
93
  }
94
+ const DISCOVERY_ROLE_BY_SCOPE = {
95
+ full_admin: "ADMIN",
96
+ family_ai: "GUARDIAN",
97
+ guide_students: "GUIDE",
98
+ village_home: "KID",
99
+ };
100
+ /**
101
+ * Command discovery is intentionally local: the signed session cookie already
102
+ * carries the role, CLI scope, and expiry minted at login. Decoding those
103
+ * unverified claims is safe here because they only hide or reveal help text;
104
+ * every real command still sends the cookie to the server for authorization.
105
+ */
106
+ function resolveCommandDiscovery(config) {
107
+ const signedOut = {
108
+ scope: null,
109
+ role: null,
110
+ source: "signed_out",
111
+ };
112
+ const unavailable = {
113
+ scope: null,
114
+ role: null,
115
+ source: "unavailable",
116
+ };
117
+ if (!config.sessionCookie)
118
+ return signedOut;
119
+ try {
120
+ const separator = config.sessionCookie.indexOf("=");
121
+ if (separator < 1)
122
+ return unavailable;
123
+ const cookieValue = decodeURIComponent(config.sessionCookie.slice(separator + 1).split(";", 1)[0]);
124
+ const payloadSegment = cookieValue.split(".")[1];
125
+ if (!payloadSegment)
126
+ return unavailable;
127
+ const value = JSON.parse(Buffer.from(payloadSegment, "base64url").toString("utf8"));
128
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
129
+ return unavailable;
130
+ }
131
+ const claim = value;
132
+ if (typeof claim.exp !== "number" || !Number.isFinite(claim.exp)) {
133
+ return unavailable;
134
+ }
135
+ if (claim.exp * 1000 <= Date.now()) {
136
+ return signedOut;
137
+ }
138
+ const scope = typeof claim.cliScope === "string" &&
139
+ Object.hasOwn(DISCOVERY_ROLE_BY_SCOPE, claim.cliScope)
140
+ ? claim.cliScope
141
+ : claim.role === "ADMIN" && claim.adminCli === true
142
+ ? "full_admin"
143
+ : null;
144
+ if (!scope || claim.role !== DISCOVERY_ROLE_BY_SCOPE[scope]) {
145
+ return unavailable;
146
+ }
147
+ return {
148
+ scope,
149
+ role: DISCOVERY_ROLE_BY_SCOPE[scope],
150
+ source: "session_claim",
151
+ };
152
+ }
153
+ catch {
154
+ return unavailable;
155
+ }
156
+ }
48
157
  async function doctor(config, reason) {
49
158
  const checks = {
50
159
  config: {
@@ -131,7 +240,7 @@ async function writeCommand(parsed, preview, execute) {
131
240
  details: {
132
241
  ...preview.details,
133
242
  operationKey,
134
- retry: "Rerun the unchanged command with --confirm --operation-key <operationKey>. Reuse that same key after an interrupted invocation.",
243
+ retry: "Rerun the unchanged command with --confirm --operation-key <operationKey>. Reuse that same key after an interrupted invocation. Previewing again is harmless: it mints another key that is equally valid, because keys are bound to the payload rather than to one preview run.",
135
244
  },
136
245
  };
137
246
  await appendJobEvent({
@@ -145,7 +254,7 @@ async function writeCommand(parsed, preview, execute) {
145
254
  requireConfirmation(false, boundPreview);
146
255
  }
147
256
  if (!suppliedOperationKey) {
148
- throw new CliError("confirmation_required", "A confirmed write requires the operation key from its approved preview.", 2, { preview, requiredFlag: "--operation-key" });
257
+ throw new CliError("confirmation_required", "A confirmed write requires --operation-key. Run this exact command without --confirm, then pass the operationKey from the preview details. Any preview of this same payload yields a usable key.", 2, { preview, requiredFlag: "--operation-key" });
149
258
  }
150
259
  assertOperationKeyMatchesPreview(suppliedOperationKey, fingerprint, preview);
151
260
  await appendJobEvent({
@@ -477,9 +586,26 @@ function approvalTokenFor(preview) {
477
586
  function operationKeyFor(fingerprint) {
478
587
  return `op_${randomUUID().replaceAll("-", "")}_${fingerprint}`;
479
588
  }
589
+ /**
590
+ * An operation key is `op_<random>_<fingerprint>`, and the fingerprint is a
591
+ * hash of the whole preview. Only the suffix is checked, which means **any**
592
+ * key minted from a preview of this exact payload works — including one from an
593
+ * earlier preview of the same command. That matters: previewing twice mints two
594
+ * different keys, both valid, and reaching for the newer one is not a mistake.
595
+ *
596
+ * The suffix is also what makes the key safe. It cannot be transplanted onto a
597
+ * different write, because a payload that differs by one character hashes
598
+ * differently and no key from the old preview will match.
599
+ */
480
600
  function assertOperationKeyMatchesPreview(operationKey, fingerprint, preview) {
481
601
  if (!operationKey.endsWith(`_${fingerprint}`)) {
482
- 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" });
602
+ throw new CliError("approval_mismatch", `This operation key was minted for a different payload, so it cannot approve this one. The key must end in _${fingerprint} — copy the operationKey out of the preview for THIS exact command (any preview of the same payload will do, newest or oldest). If the payload really did change, that is the gate working: get approval on the current preview.`, 2, {
603
+ preview,
604
+ requiredFlag: "--operation-key",
605
+ expectedKeySuffix: `_${fingerprint}`,
606
+ suppliedKey: operationKey,
607
+ note: "Keys are bound to the payload, not to a particular preview run. Re-previewing an unchanged command mints a new key with the same suffix; every one of them is accepted.",
608
+ });
483
609
  }
484
610
  }
485
611
  async function requirePreviewBoundConfirmation(parsed, preview) {
@@ -509,7 +635,7 @@ async function requirePreviewBoundConfirmation(parsed, preview) {
509
635
  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" });
510
636
  }
511
637
  if (!suppliedOperationKey) {
512
- throw new CliError("confirmation_required", "A confirmed write requires the operation key from its approved preview.", 2, { preview: boundPreview, requiredFlag: "--operation-key" });
638
+ throw new CliError("confirmation_required", "A confirmed write requires --operation-key. Run this exact command without --confirm, then pass the operationKey from the preview details. Any preview of this same payload yields a usable key.", 2, { preview: boundPreview, requiredFlag: "--operation-key" });
513
639
  }
514
640
  assertOperationKeyMatchesPreview(suppliedOperationKey, approvalToken, boundPreview);
515
641
  await appendJobEvent({
@@ -1447,19 +1573,28 @@ export async function runCommand(argv) {
1447
1573
  .map((name) => `--${name}`)
1448
1574
  .join(", ")}.`, 1, { validFlags: ["--deliver", "--help", "--json", "--profile"] });
1449
1575
  }
1450
- return { help: scopedHelp(HELP, commands, []) };
1576
+ const config = await resolveConfig(flagString(parsed, "profile"));
1577
+ const discovery = resolveCommandDiscovery(config);
1578
+ return { help: scopedHelp(HELP, commands, [], discovery) };
1451
1579
  }
1452
1580
  if (noun === "help" || hasFlag(parsed, "help")) {
1453
1581
  const scope = noun === "help" ? parsed.positionals.slice(1) : parsed.positionals;
1454
- return { help: scopedHelp(HELP, commands, scope) };
1582
+ const config = await resolveConfig(flagString(parsed, "profile"));
1583
+ const discovery = resolveCommandDiscovery(config);
1584
+ return { help: scopedHelp(HELP, commands, scope, discovery) };
1455
1585
  }
1456
1586
  validateInvocation(parsed, commands);
1457
1587
  if (noun === "agent-context") {
1458
- const profiles = await listProfiles();
1588
+ const [profiles, config] = await Promise.all([
1589
+ listProfiles(),
1590
+ resolveConfig(flagString(parsed, "profile")),
1591
+ ]);
1592
+ const discovery = resolveCommandDiscovery(config);
1459
1593
  return agentContext(commands, {
1460
1594
  cliVersion: await readCliVersion(),
1461
1595
  availableProfiles: profiles.profiles.map((profile) => profile.name),
1462
1596
  feedbackUpstreamConfigured: Boolean(process.env.RECESS_CLI_FEEDBACK_ENDPOINT),
1597
+ discovery,
1463
1598
  });
1464
1599
  }
1465
1600
  if (noun === "profile") {
@@ -3559,6 +3694,51 @@ export async function runCommand(argv) {
3559
3694
  params: { path: { goalId } },
3560
3695
  })));
3561
3696
  }
3697
+ if (verb === "archive" || verb === "unarchive") {
3698
+ const goalId = positional(parsed, 2, "goal ID");
3699
+ const studentId = flagString(parsed, "student", { required: true });
3700
+ const reopen = verb === "unarchive";
3701
+ const current = unwrap(await api.client.GET("/tutor/browser/students/{userId}/goals/", {
3702
+ params: { path: { userId: studentId } },
3703
+ })).goals.find((goal) => goal.id === goalId);
3704
+ if (!current) {
3705
+ throw new CliError("not_found", `Goal ${goalId} was not found for student ${studentId}.`);
3706
+ }
3707
+ // Refuse the no-op before asking a human to approve it. Approving
3708
+ // "archive a goal" only to be told it was already archived teaches an
3709
+ // agent to re-approve noise.
3710
+ if (!reopen && current.status === "COMPLETED") {
3711
+ throw new CliError("already_archived", `Goal ${goalId} ("${current.title}") is already archived and is not holding an open-goal slot.`);
3712
+ }
3713
+ if (reopen && current.status !== "COMPLETED") {
3714
+ throw new CliError("not_archived", `Goal ${goalId} ("${current.title}") is ${current.status}, not archived. There is nothing to reopen.`);
3715
+ }
3716
+ const preview = {
3717
+ action: reopen
3718
+ ? "reopen an archived goal (consumes one of the kid's open-goal slots)"
3719
+ : "archive a finished goal (frees one of the kid's open-goal slots)",
3720
+ target: {
3721
+ goalId,
3722
+ studentUserId: studentId,
3723
+ title: current.title,
3724
+ status: current.status,
3725
+ progress: current.progress,
3726
+ },
3727
+ request: { goalId },
3728
+ details: {
3729
+ note: reopen
3730
+ ? "Returns the goal to ACTIVE so it generates daily todos again. Refused with GOAL_LIMIT_REACHED if the kid is already at the open-goal cap, and refused for goals completed through the staff reward path (those need `goals undo-completion`, which reverses the coins)."
3731
+ : "Sets the terminal COMPLETED status. Grants no coins and posts no activity — use `goals complete` (staff only) when the kid should receive the goal's reward. The goal, its history, and its past todos all stay; only the open-goal slot is released. Any already-scheduled future todos on this goal are left alone, matching `goals complete`.",
3732
+ },
3733
+ };
3734
+ return previewBoundWrite(parsed, preview, async () => unwrap(reopen
3735
+ ? await api.client.POST("/ai/goals/{goalId}/unarchive", {
3736
+ params: { path: { goalId } },
3737
+ })
3738
+ : await api.client.POST("/ai/goals/{goalId}/archive", {
3739
+ params: { path: { goalId } },
3740
+ })));
3741
+ }
3562
3742
  if (verb === "list") {
3563
3743
  const userId = flagString(parsed, "student", { required: true });
3564
3744
  return unwrap(await api.client.GET("/tutor/browser/students/{userId}/goals/", {
@@ -3727,6 +3907,7 @@ export async function runCommand(argv) {
3727
3907
  const studentId = flagString(parsed, "student", { required: true });
3728
3908
  const patchFile = flagString(parsed, "patch-file", { required: true });
3729
3909
  const patch = (await readJsonFile(patchFile, "Goal patch file"));
3910
+ assertGoalPatchValues(patch, patchFile);
3730
3911
  patch.delta = flagString(parsed, "delta", { required: true });
3731
3912
  const current = unwrap(await api.client.GET("/tutor/browser/students/{userId}/goals/", {
3732
3913
  params: { path: { userId: studentId } },
@@ -3752,6 +3933,15 @@ export async function runCommand(argv) {
3752
3933
  if (verb === "delete") {
3753
3934
  const goalId = positional(parsed, 2, "goal ID");
3754
3935
  const studentId = flagString(parsed, "student", { required: true });
3936
+ // Deletion is an /admin/* route, so a guardian or guide session can never
3937
+ // complete it. Refuse here, before the preview, rather than rendering a
3938
+ // full and entirely plausible preview — resolved goal, named title,
3939
+ // described soft-delete — that a human approves and the server then
3940
+ // answers 401. A preview is a promise about what --confirm will do.
3941
+ const sessionRole = await resolveSessionRole(api);
3942
+ if (sessionRole && sessionRole !== "ADMIN") {
3943
+ throw new CliError("forbidden", "Deleting a goal is staff-only. To free an open-goal slot for a finished course, use `recess goals archive <goal-id> --student <kid-id>` — it sets the terminal status, keeps the goal and its history, and needs no staff involvement.", 1, { sessionRole, requiredRole: "ADMIN", alternative: "goals archive" });
3944
+ }
3755
3945
  const current = unwrap(await api.client.GET("/tutor/browser/students/{userId}/goals/", {
3756
3946
  params: { path: { userId: studentId } },
3757
3947
  })).goals.find((goal) => goal.id === goalId);
@@ -3869,7 +4059,7 @@ export async function runCommand(argv) {
3869
4059
  }
3870
4060
  throw new CliError("invalid_arguments", "Use goals queue get|set.");
3871
4061
  }
3872
- throw new CliError("invalid_arguments", "Use goals list|create|edit|delete|complete|undo-completion|queue|files|pdf.");
4062
+ throw new CliError("invalid_arguments", "Use goals list|create|edit|delete|archive|unarchive|complete|undo-completion|queue|files|pdf.");
3873
4063
  }
3874
4064
  if (noun === "students") {
3875
4065
  if (verb === "list") {
@@ -3970,6 +4160,60 @@ export async function runCommand(argv) {
3970
4160
  body,
3971
4161
  })));
3972
4162
  }
4163
+ if (verb === "complete") {
4164
+ const todoId = positional(parsed, 2, "todo ID");
4165
+ const targetXp = flagNumber(parsed, "xp");
4166
+ if (targetXp === undefined ||
4167
+ !Number.isInteger(targetXp) ||
4168
+ targetXp <= 0) {
4169
+ 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.");
4170
+ }
4171
+ const [currentResponse, detailsResponse] = await Promise.all([
4172
+ api.client.GET("/tutor/browser/todos/{id}/", {
4173
+ params: { path: { id: todoId } },
4174
+ }),
4175
+ api.client.GET("/admin/todos/{id}/details", {
4176
+ params: { path: { id: todoId } },
4177
+ }),
4178
+ ]);
4179
+ const current = unwrap(currentResponse);
4180
+ const completionXpAwarded = unwrap(detailsResponse).totalXpAwarded ?? 0;
4181
+ if (current.status === "COMPLETED") {
4182
+ throw new CliError("already_completed", `Todo ${todoId} ("${current.title}") is already completed with ${completionXpAwarded} completion XP credited.`);
4183
+ }
4184
+ const body = {
4185
+ status: "COMPLETED",
4186
+ xpReward: targetXp,
4187
+ expectedUpdatedAt: current.updatedAt,
4188
+ };
4189
+ const newXp = Math.max(0, targetXp - completionXpAwarded);
4190
+ const preview = {
4191
+ action: "complete a todo through the staff reward path and set its total credited XP target",
4192
+ target: {
4193
+ todoId,
4194
+ studentUserId: current.userId,
4195
+ studentName: [current.user.firstName, current.user.lastName]
4196
+ .filter(Boolean)
4197
+ .join(" ") || null,
4198
+ title: current.title,
4199
+ status: current.status,
4200
+ goal: current.goal,
4201
+ },
4202
+ request: body,
4203
+ details: {
4204
+ currentXpReward: current.xpReward,
4205
+ alreadyAwardedXp: completionXpAwarded,
4206
+ targetTotalXp: targetXp,
4207
+ newlyAwardedXp: newXp,
4208
+ configuredCoinReward: current.reward,
4209
+ 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.`,
4210
+ },
4211
+ };
4212
+ return previewBoundWrite(parsed, preview, async () => unwrap(await api.client.PATCH("/admin/todos/{id}", {
4213
+ params: { path: { id: todoId } },
4214
+ body,
4215
+ })));
4216
+ }
3973
4217
  if (verb === "delete") {
3974
4218
  const todoId = positional(parsed, 2, "todo ID");
3975
4219
  const current = unwrap(await api.client.GET("/tutor/browser/todos/{id}/", {
@@ -4038,7 +4282,7 @@ export async function runCommand(argv) {
4038
4282
  body: targetDueDateISO ? { targetDueDateISO } : {},
4039
4283
  })));
4040
4284
  }
4041
- throw new CliError("invalid_arguments", "Use todos create|edit|delete|generate-applet.");
4285
+ throw new CliError("invalid_arguments", "Use todos create|edit|complete|delete|generate-applet.");
4042
4286
  }
4043
4287
  if (noun === "memories") {
4044
4288
  const studentId = flagString(parsed, "student", { required: true });