recess-cli 2.6.1 → 2.7.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
@@ -274,7 +274,10 @@ recess --json goals list --student <kid-id> --reason "Review the student's goals
274
274
  recess --json todos create --student <kid-id> --title "Read chapter 4" --reason "Add the assigned reading"
275
275
  recess --json todos complete <todo-id> --xp 35 --reason "Complete the todo with a 35 XP total reward"
276
276
  recess --json goals delete <goal-id> --student <kid-id> --reason "Remove this obsolete goal"
277
+ recess --json goals restore <goal-id> --student <kid-id> --reason "Restore this goal"
277
278
  recess --json todos delete <todo-id> --reason "Remove this disposable todo"
279
+ recess --json todos restore <todo-id> --reason "Restore this todo"
280
+ recess --json todos generate-learning-analysis <todo-id> --reason "Backfill this todo's missing learning analysis"
278
281
  recess --json todos generate-applet <todo-id> --student <kid-id> --reason "Generate this todo's applet"
279
282
  recess --json memories context --student <kid-id> --reason "Review durable tutor context"
280
283
  recess --json memories log --student <kid-id> --date 2026-08-12 --reason "Review the learning log for this date"
@@ -286,7 +289,12 @@ into the confirmed request. `todos complete` is staff-only; `--xp` is the target
286
289
  todo, so its preview reports prior credit and the new delta before the normal completion and reward
287
290
  side effects run. Applet generation is also staff-only: it resolves the todo's latest learning
288
291
  analysis, defaults the generated todo to tomorrow in the student's timezone, and lets the server
289
- select v1 or v2 for that student. A guardian cannot target another family, inspect frozen/deleted
292
+ select v1 or v2 for that student. Learning-analysis generation is ADMIN-only and queues the
293
+ idempotent forensic pipeline from the latest completed Gemini analysis without rerunning completion
294
+ or rewards. Goal and todo deletion is soft-only. Restoring a goal retains its Mesa workspace,
295
+ rechecks open-goal capacity, and restores only linked todos sharing that deletion's tombstone;
296
+ standalone todo restore uses the same preview-bound tombstone check. A guardian cannot target
297
+ another family, inspect frozen/deleted
290
298
  template history, choose todo rewards/completion/internal fields, or use the ADMIN-only
291
299
  device-authorization flow. `memories context` and `memories log` read only the tutor repository's
292
300
  spine, rules, reminders, and session logs; private guide remarks are never returned. The CLI does
package/dist/cli.js CHANGED
@@ -36,9 +36,6 @@ function requiredRequestReason(parsed) {
36
36
  * worse than no preview at all.
37
37
  */
38
38
  async function resolveSessionRole(api) {
39
- if (api.config.authSource === "config" && api.config.user?.role) {
40
- return api.config.user.role;
41
- }
42
39
  const session = await api.client.GET("/auth/admin-cli/session/");
43
40
  return session.data?.user.role;
44
41
  }
@@ -4086,7 +4083,7 @@ export async function runCommand(argv) {
4086
4083
  // answers 401. A preview is a promise about what --confirm will do.
4087
4084
  const sessionRole = await resolveSessionRole(api);
4088
4085
  if (sessionRole && sessionRole !== "ADMIN") {
4089
- 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" });
4086
+ throw new CliError("forbidden", "Deleting a goal is admin-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" });
4090
4087
  }
4091
4088
  const current = unwrap(await api.client.GET("/tutor/browser/students/{userId}/goals/", {
4092
4089
  params: { path: { userId: studentId } },
@@ -4104,7 +4101,7 @@ export async function runCommand(argv) {
4104
4101
  },
4105
4102
  request: { expectedUpdatedAt: current.updatedAt },
4106
4103
  details: {
4107
- note: "Soft-deletes the goal and its current/future dated todos. Historical todos and the deletion audit remain available.",
4104
+ note: "Soft-deletes the goal and its current/future dated todos. Historical todos, the deletion audit, and any Mesa workspace remain available; `goals restore` reverses this tombstone.",
4108
4105
  },
4109
4106
  };
4110
4107
  return previewBoundWrite(parsed, preview, async () => {
@@ -4114,6 +4111,39 @@ export async function runCommand(argv) {
4114
4111
  return { deleted: true, goalId, studentUserId: studentId };
4115
4112
  });
4116
4113
  }
4114
+ if (verb === "restore") {
4115
+ const goalId = positional(parsed, 2, "goal ID");
4116
+ const studentId = flagString(parsed, "student", { required: true });
4117
+ const sessionRole = await resolveSessionRole(api);
4118
+ if (sessionRole && sessionRole !== "ADMIN") {
4119
+ throw new CliError("forbidden", "Restoring a deleted goal is admin-only.", 1, { sessionRole, requiredRole: "ADMIN" });
4120
+ }
4121
+ const state = unwrap(await api.client.GET("/admin/browser/students/goals/{goalId}/deletion-state/", { params: { path: { goalId } } }));
4122
+ if (state.userId !== studentId) {
4123
+ throw new CliError("not_found", `Goal ${goalId} was not found for student ${studentId}.`);
4124
+ }
4125
+ if (!state.deletedAt) {
4126
+ throw new CliError("not_deleted", `Goal ${goalId} is not deleted.`);
4127
+ }
4128
+ if (!state.restorable) {
4129
+ throw new CliError("restore_unavailable", state.restoreBlockedReason ?? "This goal cannot be restored safely.");
4130
+ }
4131
+ const body = { expectedDeletedAt: state.deletedAt };
4132
+ const preview = {
4133
+ action: "restore a soft-deleted goal for a managed student",
4134
+ target: {
4135
+ goalId,
4136
+ studentUserId: studentId,
4137
+ title: state.title,
4138
+ status: state.status,
4139
+ },
4140
+ request: body,
4141
+ details: {
4142
+ note: "Clears the goal tombstone and restores only linked todos that were tombstoned by the same goal deletion. Restoring an ACTIVE or PAUSED goal rechecks the student's open-goal capacity.",
4143
+ },
4144
+ };
4145
+ return previewBoundWrite(parsed, preview, async () => unwrap(await api.client.POST("/admin/browser/students/goals/{goalId}/restore/", { params: { path: { goalId } }, body })));
4146
+ }
4117
4147
  if (verb === "queue") {
4118
4148
  const subverb = positional(parsed, 2, "queue action (get|set)");
4119
4149
  const goalId = positional(parsed, 3, "goal ID");
@@ -4205,7 +4235,7 @@ export async function runCommand(argv) {
4205
4235
  }
4206
4236
  throw new CliError("invalid_arguments", "Use goals queue get|set.");
4207
4237
  }
4208
- throw new CliError("invalid_arguments", "Use goals list|create|edit|delete|archive|unarchive|complete|undo-completion|queue|files|pdf.");
4238
+ throw new CliError("invalid_arguments", "Use goals list|create|edit|delete|restore|archive|unarchive|complete|undo-completion|queue|files|pdf.");
4209
4239
  }
4210
4240
  if (noun === "students") {
4211
4241
  if (verb === "list") {
@@ -4397,7 +4427,7 @@ export async function runCommand(argv) {
4397
4427
  params: { path: { id: todoId } },
4398
4428
  }));
4399
4429
  const preview = {
4400
- action: "permanently delete a todo for a managed student",
4430
+ action: "soft-delete a todo for a managed student",
4401
4431
  target: {
4402
4432
  todoId,
4403
4433
  studentUserId: current.userId,
@@ -4411,13 +4441,91 @@ export async function runCommand(argv) {
4411
4441
  creationSource: current.creationSource,
4412
4442
  creationSourceId: current.creationSourceId,
4413
4443
  url: "url" in current ? current.url : null,
4414
- 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.",
4444
+ note: "Sets deletedAt without awarding XP or running todo-completion side effects. The todo, its work evidence, and related records remain available; `todos restore` reverses the tombstone.",
4415
4445
  },
4416
4446
  };
4417
4447
  return previewBoundWrite(parsed, preview, async () => unwrap(await api.client.DELETE("/admin/todos/{id}/", {
4418
4448
  params: { path: { id: todoId } },
4419
4449
  })));
4420
4450
  }
4451
+ if (verb === "restore") {
4452
+ const todoId = positional(parsed, 2, "todo ID");
4453
+ const state = unwrap(await api.client.GET("/admin/todos/{id}/deletion-state", {
4454
+ params: { path: { id: todoId } },
4455
+ }));
4456
+ if (!state.deletedAt) {
4457
+ throw new CliError("not_deleted", `Todo ${todoId} is not deleted.`);
4458
+ }
4459
+ if (state.goal?.deletedAt) {
4460
+ throw new CliError("restore_parent_goal", `Todo ${todoId} belongs to deleted goal ${state.goal.id}. Restore the goal instead so its deletion set is reversed together.`);
4461
+ }
4462
+ const body = { expectedDeletedAt: state.deletedAt };
4463
+ const preview = {
4464
+ action: "restore a soft-deleted todo for a managed student",
4465
+ target: {
4466
+ todoId,
4467
+ studentUserId: state.userId,
4468
+ title: state.title,
4469
+ status: state.status,
4470
+ goalId: state.goal?.id ?? null,
4471
+ },
4472
+ request: body,
4473
+ details: {
4474
+ dueDate: state.dueDate,
4475
+ note: "Clears deletedAt only when the tombstone still matches this preview. All preserved work evidence and related records become visible with the todo again.",
4476
+ },
4477
+ };
4478
+ return previewBoundWrite(parsed, preview, async () => unwrap(await api.client.POST("/admin/todos/{id}/restore", {
4479
+ params: { path: { id: todoId } },
4480
+ body,
4481
+ })));
4482
+ }
4483
+ if (verb === "generate-learning-analysis") {
4484
+ const todoId = positional(parsed, 2, "todo ID");
4485
+ const sessionRole = await resolveSessionRole(api);
4486
+ if (sessionRole !== "ADMIN") {
4487
+ throw new CliError("forbidden", "Generating a learning analysis is admin-only.", 1, { sessionRole: sessionRole ?? null, requiredRole: "ADMIN" });
4488
+ }
4489
+ const [todoResponse, detailsResponse, geminiResponse] = await Promise.all([
4490
+ api.client.GET("/tutor/browser/todos/{id}/", {
4491
+ params: { path: { id: todoId } },
4492
+ }),
4493
+ api.client.GET("/admin/todos/{id}/details", {
4494
+ params: { path: { id: todoId } },
4495
+ }),
4496
+ api.client.GET("/admin/todos/{id}/gemini-analysis", {
4497
+ params: { path: { id: todoId } },
4498
+ }),
4499
+ ]);
4500
+ const currentTodo = unwrap(todoResponse);
4501
+ const details = unwrap(detailsResponse);
4502
+ const gemini = unwrap(geminiResponse);
4503
+ const latestCompletedGeminiAnalysis = gemini.newPipelineAnalyses.find((analysis) => analysis.status === "completed");
4504
+ if (!latestCompletedGeminiAnalysis) {
4505
+ throw new CliError("not_found", "This todo does not have a completed Gemini analysis to use.");
4506
+ }
4507
+ const body = {
4508
+ geminiAnalysisId: latestCompletedGeminiAnalysis.id,
4509
+ };
4510
+ const preview = {
4511
+ action: "queue a learning analysis from a todo's latest completed Gemini analysis",
4512
+ target: {
4513
+ todoId,
4514
+ studentUserId: currentTodo.userId,
4515
+ title: currentTodo.title,
4516
+ status: currentTodo.status,
4517
+ },
4518
+ request: body,
4519
+ details: {
4520
+ existingLearningAnalysisId: details.learningAnalysis?.id ?? null,
4521
+ note: "The forensic learning pipeline is idempotent for this Gemini analysis. The command does not rerun completion or change rewards.",
4522
+ },
4523
+ };
4524
+ return writeCommand(parsed, preview, async () => unwrap(await api.client.POST("/admin/learning-pipeline/{todoId}/start/", {
4525
+ params: { path: { todoId } },
4526
+ body,
4527
+ })));
4528
+ }
4421
4529
  if (verb === "generate-applet") {
4422
4530
  const todoId = positional(parsed, 2, "todo ID");
4423
4531
  const studentId = flagString(parsed, "student", { required: true });
@@ -4459,7 +4567,7 @@ export async function runCommand(argv) {
4459
4567
  body: targetDueDateISO ? { targetDueDateISO } : {},
4460
4568
  })));
4461
4569
  }
4462
- throw new CliError("invalid_arguments", "Use todos create|edit|complete|delete|generate-applet.");
4570
+ throw new CliError("invalid_arguments", "Use todos create|edit|complete|delete|restore|generate-learning-analysis|generate-applet.");
4463
4571
  }
4464
4572
  if (noun === "memories") {
4465
4573
  const studentId = flagString(parsed, "student", { required: true });
@@ -199,6 +199,7 @@ const STAFF_COMMANDS = new Set([
199
199
  "students upload-map-scores",
200
200
  "todos complete",
201
201
  "todos delete",
202
+ "todos restore",
202
203
  "todos generate-applet",
203
204
  "users get",
204
205
  "users tier list-tiers",
@@ -2,6 +2,7 @@ import { unwrap } from "../api.js";
2
2
  import { flagString, hasFlag } from "../args.js";
3
3
  import { apiError, CliError } from "../errors.js";
4
4
  const NONE = "—";
5
+ const WEEKDAYS = ["MO", "TU", "WE", "TH", "FR", "SA", "SU"];
5
6
  async function fetchTemplatesAndRooms(ctx) {
6
7
  const [templatesResponse, rooms] = await Promise.all([
7
8
  ctx.api.client.GET("/recess/event-templates-editable/"),
@@ -17,6 +18,17 @@ function schedule(template) {
17
18
  return template.singleEventDate.slice(0, 10);
18
19
  return template.rrule || NONE;
19
20
  }
21
+ function templateDays(template) {
22
+ if (template.singleEventDate)
23
+ return null;
24
+ const match = /BYDAY=([A-Z,]+)/.exec(template.rrule);
25
+ if (!match)
26
+ return null;
27
+ const days = match[1]
28
+ .split(",")
29
+ .filter((day) => WEEKDAYS.includes(day));
30
+ return days.length > 0 ? days : null;
31
+ }
20
32
  function renderColumns(rows) {
21
33
  const widths = rows[0].map((_, column) => Math.max(...rows.map((row) => row[column].length)));
22
34
  return rows
@@ -45,7 +57,7 @@ async function listEvents(ctx) {
45
57
  };
46
58
  });
47
59
  const unlinkedRooms = rooms
48
- .filter((room) => !room.linkedTemplateId)
60
+ .filter((room) => room.bookings.length === 0)
49
61
  .map((room) => ({ id: room.id, name: room.name }));
50
62
  if (hasFlag(ctx.parsed, "json")) {
51
63
  return { templates: templateRows, unlinkedRooms };
@@ -97,14 +109,22 @@ async function linkEvent(ctx) {
97
109
  throw new CliError("not_found", `No editable event template found for ${templateId}. Run \`recess village events\` to list them.`);
98
110
  }
99
111
  let room;
112
+ let informationalBookings = [];
100
113
  if (roomId) {
101
114
  room = rooms.find((row) => row.id === roomId);
102
115
  if (!room) {
103
116
  throw new CliError("not_found", `No Village Town Center zone found for ${roomId}. Run \`recess village events\` to list linkable zones.`);
104
117
  }
105
- if (room.linkedTemplateId && room.linkedTemplateId !== templateId) {
106
- throw new CliError("invalid_arguments", `Zone ${room.id} (${room.name}) is already linked to template ${room.linkedTemplateId} (${room.linkedTemplateName ?? "unnamed"}). Unlink that template first.`);
118
+ const otherBookings = room.bookings.filter((booking) => booking.templateId !== templateId);
119
+ const days = templateDays(template);
120
+ const conflict = days
121
+ ? otherBookings.find((booking) => booking.days.some((day) => days.includes(day)))
122
+ : undefined;
123
+ if (conflict) {
124
+ throw new CliError("invalid_arguments", `Zone ${room.id} (${room.name}) is already linked to template ${conflict.templateId} (${conflict.name}). Unlink that template first.`);
107
125
  }
126
+ if (!days)
127
+ informationalBookings = otherBookings;
108
128
  }
109
129
  const previousRoom = template.villageRoomId
110
130
  ? rooms.find((row) => row.id === template.villageRoomId)
@@ -123,6 +143,9 @@ async function linkEvent(ctx) {
123
143
  currentVillageRoomId: template.villageRoomId,
124
144
  },
125
145
  request: body,
146
+ ...(informationalBookings.length > 0 && {
147
+ details: { bookings: informationalBookings },
148
+ }),
126
149
  }, async () => {
127
150
  const result = await api.client.PATCH("/recess/event-templates/{id}/", {
128
151
  params: { path: { id: template.id } },
package/dist/help.js CHANGED
@@ -353,6 +353,8 @@ Usage:
353
353
  --delta TEXT [--confirm --approval-token TOKEN]
354
354
  recess [--json] goals delete <goal-id> --student <kid-id>
355
355
  [--confirm --approval-token TOKEN]
356
+ recess [--json] goals restore <goal-id> --student <kid-id>
357
+ [--confirm --approval-token TOKEN]
356
358
  recess [--json] goals complete <goal-id> [--confirm]
357
359
  recess [--json] goals undo-completion <goal-id> [--confirm]
358
360
  recess [--json] goals archive <goal-id> --student <kid-id>
@@ -371,6 +373,9 @@ Usage:
371
373
  [--confirm --approval-token TOKEN]
372
374
  recess [--json] todos delete <todo-id>
373
375
  [--confirm --approval-token TOKEN]
376
+ recess [--json] todos restore <todo-id>
377
+ [--confirm --approval-token TOKEN]
378
+ recess [--json] todos generate-learning-analysis <todo-id> [--confirm]
374
379
  recess [--json] todos generate-applet <todo-id> --student <kid-id>
375
380
  [--due-date YYYY-MM-DD] [--confirm --approval-token TOKEN]
376
381
  recess [--json] memories context --student <kid-id>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "recess-cli",
3
- "version": "2.6.1",
3
+ "version": "2.7.0",
4
4
  "description": "Safe Recess administration and family AI tools from the command line.",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {