recess-cli 2.10.0 → 3.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/README.md CHANGED
@@ -9,6 +9,13 @@ authenticated Village home building. It uses the web-server OpenAPI document, au
9
9
  through Recess SSO, emits stable JSON, and refuses live writes until the exact command is rerun with
10
10
  `--confirm` plus the preview's operation key after human approval.
11
11
 
12
+ ## Version 3 changes
13
+
14
+ `goals list` now shows ACTIVE and PAUSED goals by default. Add `--include-archived` to
15
+ include COMPLETED goals (both archived and rewarded completions), and `--query` to search.
16
+ Staff gain `cohorts schedule` and versioned `memories` file commands. Deploy their backend
17
+ and database support before publishing this CLI; writes require a server preview.
18
+
12
19
  ## Install (no checkout needed)
13
20
 
14
21
  Published to npm as [`recess-cli`](https://www.npmjs.com/package/recess-cli). On any machine with Node 20+:
package/dist/args.js CHANGED
@@ -19,6 +19,7 @@ const BOOLEAN_FLAGS = new Set([
19
19
  "help",
20
20
  "immediate",
21
21
  "include-deleted",
22
+ "include-archived",
22
23
  "mirrored",
23
24
  "no-invite",
24
25
  "no-collision",
package/dist/cli.js CHANGED
@@ -9,9 +9,11 @@ import { clearStoredSession, deleteProfile, listProfiles, resolveConfig, savePro
9
9
  import { agentContext, buildCommandSchema, findCommandSchema, remoteCommands, scopedHelp, validateInvocation, } from "./command-schema.js";
10
10
  import { runApplicationsCommand } from "./commands/applications.js";
11
11
  import { runAppsCommand } from "./commands/apps.js";
12
+ import { readAssignmentSelection, runGoalCurriculumCommand, } from "./commands/goal-curriculum.js";
12
13
  import { runChatLogsCommand } from "./commands/chat-logs.js";
13
14
  import { runMasteryCommand } from "./commands/mastery.js";
14
15
  import { runOnboardingCommand } from "./commands/onboarding.js";
16
+ import { runScheduleCommand, runMemoryFilesCommand, } from "./commands/safe-staff-operations.js";
15
17
  import { runSchoolCommand } from "./commands/school.js";
16
18
  import { runVillageEventsCommand } from "./commands/village-events.js";
17
19
  import { assertChoice, flagIdList, positional, readJsonFile, readJsonValue, } from "./commands/shared.js";
@@ -252,7 +254,10 @@ async function localWriteCommand(parsed, preview, execute) {
252
254
  action: preview.action,
253
255
  fingerprint,
254
256
  target: preview.target,
255
- ...(preview.action.startsWith("mastery.") ? { preview } : {}),
257
+ ...(preview.action.startsWith("mastery.") ||
258
+ preview.request.attendanceTakenAt
259
+ ? { preview }
260
+ : {}),
256
261
  });
257
262
  requireConfirmation(false, boundPreview);
258
263
  }
@@ -611,7 +616,7 @@ function assertOperationKeyMatchesPreview(operationKey, fingerprint, preview) {
611
616
  });
612
617
  }
613
618
  }
614
- async function requirePreviewBoundConfirmation(parsed, preview) {
619
+ async function requirePreviewBoundConfirmation(parsed, preview, persistPreview = false) {
615
620
  const approvalToken = approvalTokenFor(preview);
616
621
  const suppliedOperationKey = flagString(parsed, "operation-key");
617
622
  const boundPreview = {
@@ -630,6 +635,9 @@ async function requirePreviewBoundConfirmation(parsed, preview) {
630
635
  action: preview.action,
631
636
  fingerprint: approvalToken,
632
637
  target: preview.target,
638
+ // Opted-in operations retain the approved receipt in the 0600 ledger so
639
+ // retries can reach server idempotency after their own write changed state.
640
+ ...(persistPreview ? { preview } : {}),
633
641
  });
634
642
  requireConfirmation(false, boundPreview);
635
643
  }
@@ -651,13 +659,18 @@ async function requirePreviewBoundConfirmation(parsed, preview) {
651
659
  });
652
660
  return { operationKey: suppliedOperationKey, fingerprint: approvalToken };
653
661
  }
654
- async function previewBoundWrite(parsed, preview, execute) {
655
- const context = await requirePreviewBoundConfirmation(parsed, preview);
662
+ async function previewBoundWrite(parsed, preview, execute, persistPreview = false) {
663
+ const context = await requirePreviewBoundConfirmation(parsed, preview, persistPreview);
656
664
  try {
657
665
  const result = await withIdempotencyContext(context, execute);
658
666
  await appendJobEvent({
659
667
  jobId: context.operationKey,
660
- status: "completed",
668
+ status: typeof result === "object" &&
669
+ result !== null &&
670
+ "outcome" in result &&
671
+ result.outcome === "outcome_unknown"
672
+ ? "unknown"
673
+ : "completed",
661
674
  timestamp: new Date().toISOString(),
662
675
  action: preview.action,
663
676
  fingerprint: context.fingerprint,
@@ -679,6 +692,41 @@ async function previewBoundWrite(parsed, preview, execute) {
679
692
  throw error;
680
693
  }
681
694
  }
695
+ async function readExecutedPreview(parsed) {
696
+ const key = flagString(parsed, "operation-key");
697
+ if (!hasFlag(parsed, "confirm") || !key)
698
+ return null;
699
+ const { history } = await getJob(key);
700
+ const receipt = [...history].reverse().find((event) => event.preview);
701
+ if (!receipt?.preview)
702
+ return null;
703
+ // First confirmation still refreshes server effects. A prior execution
704
+ // attempt is what makes a stale preflight an idempotent-recovery problem.
705
+ if (!history.some((event) => event.status !== "awaiting_confirmation" &&
706
+ event.fingerprint === receipt.fingerprint &&
707
+ event.action === receipt.action))
708
+ return null;
709
+ const saved = receipt.preview;
710
+ const fingerprint = approvalTokenFor(saved);
711
+ if (receipt.fingerprint !== fingerprint ||
712
+ flagString(parsed, "approval-token") !== fingerprint) {
713
+ throw new CliError("approval_mismatch", "This operation key does not match the approved command. Retry the original command unchanged, or review a new preview.");
714
+ }
715
+ assertOperationKeyMatchesPreview(key, fingerprint, saved);
716
+ return saved;
717
+ }
718
+ /** A retried write must retain its approved fence after changing its own source state. */
719
+ async function recoverExecutedPreview(parsed, expected) {
720
+ const saved = await readExecutedPreview(parsed);
721
+ if (!saved)
722
+ return null;
723
+ if (saved.action !== expected.action ||
724
+ JSON.stringify(saved.target) !== JSON.stringify(expected.target) ||
725
+ JSON.stringify(saved.request) !== JSON.stringify(expected.request)) {
726
+ throw new CliError("approval_mismatch", "This operation key does not match the approved command. Retry the original command unchanged, or review a new preview.");
727
+ }
728
+ return saved;
729
+ }
682
730
  async function cleanupPlannerUpload(api, conversationId, signed, includeDelete) {
683
731
  if (signed.kind === "multipart") {
684
732
  await api.client
@@ -2677,6 +2725,14 @@ export async function executeRecessCommand(argv, options = {}) {
2677
2725
  if (noun === "school") {
2678
2726
  return runSchoolCommand({ parsed, api, writeCommand });
2679
2727
  }
2728
+ if (noun === "cohorts" && verb === "schedule")
2729
+ return runScheduleCommand({ parsed, api, writeCommand: previewBoundWrite });
2730
+ if (noun === "memories" && verb !== "context" && verb !== "log")
2731
+ return runMemoryFilesCommand({
2732
+ parsed,
2733
+ api,
2734
+ writeCommand: previewBoundWrite,
2735
+ });
2680
2736
  if (noun === "cohorts" && verb === "search") {
2681
2737
  const search = parsed.positionals.slice(2).join(" ").trim();
2682
2738
  if (!search)
@@ -3159,7 +3215,16 @@ export async function executeRecessCommand(argv, options = {}) {
3159
3215
  excused: true,
3160
3216
  })),
3161
3217
  ];
3162
- const body = { attendanceTakenAt: new Date().toISOString(), attendance };
3218
+ const operationKey = flagString(parsed, "operation-key");
3219
+ const stored = operationKey ? await getJob(operationKey) : undefined;
3220
+ const preparedAt = stored?.history.find((event) => event.preview?.request.attendanceTakenAt)?.preview?.request.attendanceTakenAt;
3221
+ if (hasFlag(parsed, "confirm") && typeof preparedAt !== "string") {
3222
+ throw new CliError("confirmation_required", "The saved attendance preview is unavailable. Request a new preview before confirming.");
3223
+ }
3224
+ const body = {
3225
+ attendanceTakenAt: typeof preparedAt === "string" ? preparedAt : new Date().toISOString(),
3226
+ attendance,
3227
+ };
3163
3228
  return writeCommand(parsed, {
3164
3229
  action: "take attendance (non-excused absentees may trigger automatic 'we missed you' emails to parents)",
3165
3230
  target: { eventId },
@@ -3434,6 +3499,16 @@ export async function executeRecessCommand(argv, options = {}) {
3434
3499
  throw new CliError("invalid_arguments", "Use content-library search, status, set-stage, or submit.");
3435
3500
  }
3436
3501
  if (noun === "goal-templates") {
3502
+ if (verb === "curriculum") {
3503
+ return runGoalCurriculumCommand({
3504
+ parsed,
3505
+ api,
3506
+ writeCommand,
3507
+ resolveTemplateId: (value) => resolveGoalTemplateId(api, value),
3508
+ previewBoundWrite: (args, preview, execute) => previewBoundWrite(args, preview, execute, true),
3509
+ recoverExecutedPreview: (expected) => recoverExecutedPreview(parsed, expected),
3510
+ });
3511
+ }
3437
3512
  const readTemplateDocument = async () => {
3438
3513
  if (!isRemote) {
3439
3514
  return readJsonFile(flagString(parsed, "file", { required: true }), "Template file");
@@ -3592,6 +3667,174 @@ export async function executeRecessCommand(argv, options = {}) {
3592
3667
  body: document,
3593
3668
  })));
3594
3669
  }
3670
+ if (verb === "policy") {
3671
+ const id = await resolveGoalTemplateId(api, positional(parsed, 2, "template ID or slug"));
3672
+ return unwrap(await api.client.GET("/ai/goal-templates/{id}/policy", {
3673
+ params: { path: { id } },
3674
+ }));
3675
+ }
3676
+ if (verb === "set-policy") {
3677
+ const id = await resolveGoalTemplateId(api, positional(parsed, 2, "template ID or slug"));
3678
+ const target = flagString(parsed, "target-modules");
3679
+ const minimum = flagString(parsed, "required-modules");
3680
+ for (const [flag, value] of [
3681
+ ["target-modules", target],
3682
+ ["required-modules", minimum],
3683
+ ]) {
3684
+ if (value === undefined ||
3685
+ (flag === "required-modules" && value === "all"))
3686
+ continue;
3687
+ const count = Number(value);
3688
+ if (!Number.isInteger(count) || count < 1 || count > 20)
3689
+ throw new CliError("invalid_arguments", `--${flag} must be an integer from 1 through 20${flag === "required-modules" ? " or all" : ""}.`);
3690
+ }
3691
+ if (target === undefined && minimum === undefined)
3692
+ throw new CliError("invalid_arguments", "Provide --target-modules or --required-modules N|all");
3693
+ const body = {
3694
+ recipeKey: flagString(parsed, "recipe", { required: true }),
3695
+ ...(target === undefined ? {} : { targetModuleCount: Number(target) }),
3696
+ ...(minimum === undefined
3697
+ ? {}
3698
+ : {
3699
+ requiredModuleCount: minimum === "all" ? null : Number(minimum),
3700
+ }),
3701
+ expectedVersion: requiredExpectedVersion(parsed),
3702
+ };
3703
+ const requestPreview = {
3704
+ action: "edit shared goal-template assigned target or completion minimum",
3705
+ target: { templateId: id, apiOrigin: api.config.apiOrigin },
3706
+ request: body,
3707
+ };
3708
+ const receipt = await recoverExecutedPreview(parsed, requestPreview);
3709
+ const preview = receipt ?? {
3710
+ ...requestPreview,
3711
+ details: unwrap(await api.client.POST("/ai/goal-templates/{id}/policy", {
3712
+ params: { path: { id } },
3713
+ body: { ...body, dryRun: true },
3714
+ })),
3715
+ };
3716
+ // A recovered receipt only skips the now-stale preview. The actual write
3717
+ // still reaches server authorization, idempotency, and expectedVersion.
3718
+ return previewBoundWrite(parsed, preview, async () => unwrap(await api.client.POST("/ai/goal-templates/{id}/policy", {
3719
+ params: { path: { id } },
3720
+ body: { ...body, dryRun: false },
3721
+ })), true);
3722
+ }
3723
+ if (verb === "migrate-policy") {
3724
+ const id = await resolveGoalTemplateId(api, positional(parsed, 2, "template ID or slug"));
3725
+ const studentIds = flagString(parsed, "students", { required: true })
3726
+ .split(",")
3727
+ .map((value) => value.trim());
3728
+ const preflight = unwrap(await api.client.POST("/ai/goal-templates/{id}/policy-migration", {
3729
+ params: { path: { id } },
3730
+ body: { studentIds, dryRun: true },
3731
+ }));
3732
+ const preview = {
3733
+ action: "backfill reviewed goal policy identities and migration holds",
3734
+ target: { templateId: id, studentIds },
3735
+ request: { studentIds, fingerprint: preflight.fingerprint },
3736
+ details: preflight,
3737
+ };
3738
+ return previewBoundWrite(parsed, preview, async () => unwrap(await api.client.POST("/ai/goal-templates/{id}/policy-migration", {
3739
+ params: { path: { id } },
3740
+ body: {
3741
+ studentIds,
3742
+ fingerprint: preflight.fingerprint,
3743
+ dryRun: false,
3744
+ },
3745
+ })));
3746
+ }
3747
+ if (verb === "retire-assignments") {
3748
+ const [id, session] = await Promise.all([
3749
+ resolveGoalTemplateId(api, positional(parsed, 2, "template ID or slug")),
3750
+ api.client.GET("/auth/admin-cli/session/").then(unwrap),
3751
+ ]);
3752
+ if (session.user.role !== "ADMIN" || session.cliScope !== "full_admin")
3753
+ throw new CliError("forbidden", "Assignment retirement requires a full-admin session.");
3754
+ const goalId = flagString(parsed, "goal", { required: true });
3755
+ const rawIds = flagString(parsed, "todos");
3756
+ const selectionPath = flagString(parsed, "todos-file");
3757
+ if (Boolean(rawIds) === Boolean(selectionPath))
3758
+ throw new CliError("invalid_arguments", "Choose exactly one of --todos or --todos-file with explicit assignment IDs.");
3759
+ const selection = selectionPath
3760
+ ? await readAssignmentSelection(selectionPath)
3761
+ : null;
3762
+ const todoIds = selection?.todoIds ?? rawIds.split(",").map((id) => id.trim());
3763
+ if (!todoIds.length ||
3764
+ todoIds.length > 500 ||
3765
+ todoIds.some((id) => !id) ||
3766
+ new Set(todoIds).size !== todoIds.length)
3767
+ throw new CliError("invalid_arguments", "Choose 1–500 distinct assignment IDs explicitly.");
3768
+ const reason = flagString(parsed, "retirement-reason", {
3769
+ required: true,
3770
+ });
3771
+ const requestPreview = {
3772
+ action: "retire the selected curriculum assignments without completing lessons",
3773
+ target: {
3774
+ templateId: id,
3775
+ goalId,
3776
+ actorId: session.user.id,
3777
+ apiOrigin: api.config.apiOrigin,
3778
+ },
3779
+ request: { todoIds, reason, selectionFile: selection?.file ?? null },
3780
+ };
3781
+ const receipt = await recoverExecutedPreview(parsed, requestPreview);
3782
+ const preflight = receipt
3783
+ ? receipt.details
3784
+ : unwrap(await api.client.POST("/ai/goal-templates/{id}/goals/{goalId}/assignments/retire", {
3785
+ params: { path: { id, goalId } },
3786
+ body: { todoIds, reason, dryRun: true },
3787
+ }));
3788
+ const preview = receipt ?? {
3789
+ ...requestPreview,
3790
+ details: preflight,
3791
+ };
3792
+ return previewBoundWrite(parsed, preview, async () => unwrap(await api.client.POST("/ai/goal-templates/{id}/goals/{goalId}/assignments/retire", {
3793
+ params: { path: { id, goalId } },
3794
+ body: {
3795
+ todoIds,
3796
+ reason,
3797
+ fingerprint: preflight.fingerprint,
3798
+ dryRun: false,
3799
+ },
3800
+ })));
3801
+ }
3802
+ if (verb === "student-policy") {
3803
+ const id = await resolveGoalTemplateId(api, positional(parsed, 2, "template ID or slug"));
3804
+ const goalId = flagString(parsed, "goal", { required: true });
3805
+ const filePath = path.resolve(flagString(parsed, "file", { required: true }));
3806
+ const bytes = await fs.readFile(filePath);
3807
+ let raw;
3808
+ try {
3809
+ raw = JSON.parse(bytes.toString("utf8"));
3810
+ }
3811
+ catch {
3812
+ throw new CliError("invalid_arguments", "Student policy edit must contain valid JSON");
3813
+ }
3814
+ // Backend Zod is authoritative; preserve exact file bytes in the approval preview.
3815
+ const body = raw;
3816
+ const preflight = unwrap(await api.client.POST("/ai/goal-templates/{id}/goals/{goalId}/policy", {
3817
+ params: { path: { id, goalId } },
3818
+ body: { ...body, dryRun: true },
3819
+ }));
3820
+ const preview = {
3821
+ action: "review a student policy override or migration hold",
3822
+ target: { templateId: id, goalId },
3823
+ request: body,
3824
+ details: {
3825
+ ...preflight,
3826
+ file: {
3827
+ absolutePath: filePath,
3828
+ sizeBytes: bytes.byteLength,
3829
+ sha256: createHash("sha256").update(bytes).digest("hex"),
3830
+ },
3831
+ },
3832
+ };
3833
+ return previewBoundWrite(parsed, preview, async () => unwrap(await api.client.POST("/ai/goal-templates/{id}/goals/{goalId}/policy", {
3834
+ params: { path: { id, goalId } },
3835
+ body: { ...body, dryRun: false },
3836
+ })));
3837
+ }
3595
3838
  if (verb === "patch-spec") {
3596
3839
  const id = await resolveGoalTemplateId(api, positional(parsed, 2, "template ID or slug"));
3597
3840
  const expectedVersion = requiredExpectedVersion(parsed);
@@ -4048,9 +4291,21 @@ export async function executeRecessCommand(argv, options = {}) {
4048
4291
  }
4049
4292
  if (verb === "list") {
4050
4293
  const userId = flagString(parsed, "student", { required: true });
4051
- return unwrap(await api.client.GET("/tutor/browser/students/{userId}/goals/", {
4294
+ const result = unwrap(await api.client.GET("/tutor/browser/students/{userId}/goals/", {
4052
4295
  params: { path: { userId } },
4053
4296
  }));
4297
+ const query = flagString(parsed, "query")?.toLocaleLowerCase();
4298
+ return {
4299
+ ...result,
4300
+ goals: result.goals.filter((goal) => (goal.status === "ACTIVE" ||
4301
+ goal.status === "PAUSED" ||
4302
+ (hasFlag(parsed, "include-archived") &&
4303
+ goal.status === "COMPLETED")) &&
4304
+ (!query ||
4305
+ `${goal.title}\n${goal.description ?? ""}`
4306
+ .toLocaleLowerCase()
4307
+ .includes(query))),
4308
+ };
4054
4309
  }
4055
4310
  if (verb === "create") {
4056
4311
  const requestedStudentId = flagString(parsed, "student");
@@ -5047,7 +5302,40 @@ export async function executeRecessCommand(argv, options = {}) {
5047
5302
  required: true,
5048
5303
  });
5049
5304
  const checkout = await readRecessGitMetadata(sourceDirectory);
5050
- const local = await readGoalWorkspaceGitChanges(checkout.root, checkout.metadata.upstreamCommit);
5305
+ const isGoal = checkout.metadata.target.kind === "goal";
5306
+ const session = isGoal
5307
+ ? unwrap(await api.client.GET("/auth/admin-cli/session/"))
5308
+ : null;
5309
+ const actionDescription = `push committed Git changes to a student's Mesa ${checkout.metadata.target.kind} workspace`;
5310
+ const boundTarget = session
5311
+ ? {
5312
+ studentId: checkout.metadata.studentId,
5313
+ workspace: checkout.metadata.target,
5314
+ actorId: session.user.id,
5315
+ apiOrigin: api.config.apiOrigin,
5316
+ }
5317
+ : null;
5318
+ const receipt = isGoal ? await readExecutedPreview(parsed) : null;
5319
+ if (receipt &&
5320
+ (receipt.action !== actionDescription ||
5321
+ JSON.stringify(receipt.target) !== JSON.stringify(boundTarget) ||
5322
+ receipt.request.sourceDirectory !== checkout.root)) {
5323
+ throw new CliError("approval_mismatch", "The approved push belongs to a different checkout, account, or API.");
5324
+ }
5325
+ const localBaseCommit = receipt
5326
+ ? String(receipt.request.localBaseCommit)
5327
+ : checkout.metadata.upstreamCommit;
5328
+ const mesaBaseChangeId = receipt
5329
+ ? String(receipt.request.mesaBaseChangeId)
5330
+ : checkout.metadata.changeId;
5331
+ const local = await readGoalWorkspaceGitChanges(checkout.root, localBaseCommit);
5332
+ if (receipt &&
5333
+ ((checkout.metadata.upstreamCommit !== localBaseCommit &&
5334
+ checkout.metadata.upstreamCommit !== local.head) ||
5335
+ (checkout.metadata.upstreamCommit === localBaseCommit &&
5336
+ checkout.metadata.changeId !== mesaBaseChangeId))) {
5337
+ throw new CliError("approval_mismatch", "The checkout's upstream metadata moved outside the approved push.");
5338
+ }
5051
5339
  const defaultMessage = local.commits.length === 1
5052
5340
  ? local.commits[0].subject
5053
5341
  : `recess-cli: push ${local.commits.length} commits (${local.commits.at(-1).subject})`;
@@ -5068,36 +5356,44 @@ export async function executeRecessCommand(argv, options = {}) {
5068
5356
  target: checkout.metadata.target,
5069
5357
  message,
5070
5358
  files,
5071
- expectedChangeId: checkout.metadata.changeId,
5359
+ expectedChangeId: mesaBaseChangeId,
5072
5360
  };
5073
- const preflight = unwrap(await api.client.POST("/tutor/browser/mesa/workspace-files", {
5074
- body: { ...body, dryRun: true },
5075
- }));
5076
- if (preflight.action !== "preview_workspace_files_write") {
5361
+ const preflight = receipt
5362
+ ? null
5363
+ : unwrap(await api.client.POST("/tutor/browser/mesa/workspace-files", {
5364
+ body: { ...body, dryRun: true },
5365
+ }));
5366
+ if (preflight && preflight.action !== "preview_workspace_files_write") {
5077
5367
  throw new CliError("unexpected_response", "Goal workspace push did not return a preview; nothing was written.");
5078
5368
  }
5079
- const preview = {
5080
- action: `push committed Git changes to a student's Mesa ${checkout.metadata.target.kind} workspace`,
5081
- target: preflight.target,
5082
- request: {
5083
- sourceDirectory: checkout.root,
5084
- mesaBaseChangeId: checkout.metadata.changeId,
5085
- localBaseCommit: checkout.metadata.upstreamCommit,
5086
- localHeadCommit: local.head,
5087
- commits: local.commits,
5088
- message,
5089
- changes: local.changes.map((change) => change.action === "delete"
5090
- ? change
5091
- : {
5092
- path: change.path,
5093
- action: change.action,
5094
- sizeBytes: change.sizeBytes,
5095
- sha256: change.sha256,
5096
- }),
5097
- sizeBytes: local.sizeBytes,
5098
- },
5369
+ const request = {
5370
+ sourceDirectory: checkout.root,
5371
+ mesaBaseChangeId,
5372
+ localBaseCommit,
5373
+ localHeadCommit: local.head,
5374
+ commits: local.commits,
5375
+ message,
5376
+ changes: local.changes.map((change) => change.action === "delete"
5377
+ ? change
5378
+ : {
5379
+ path: change.path,
5380
+ action: change.action,
5381
+ sizeBytes: change.sizeBytes,
5382
+ sha256: change.sha256,
5383
+ }),
5384
+ sizeBytes: local.sizeBytes,
5385
+ };
5386
+ if (receipt &&
5387
+ JSON.stringify(receipt.request) !== JSON.stringify(request)) {
5388
+ throw new CliError("approval_mismatch", "The command or committed file bytes differ from the approved push.");
5389
+ }
5390
+ const preview = receipt ?? {
5391
+ action: actionDescription,
5392
+ target: boundTarget ?? preflight.target,
5393
+ request,
5099
5394
  details: {
5100
5395
  currentChangeId: preflight.currentChangeId,
5396
+ resolvedTarget: preflight.target,
5101
5397
  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.",
5102
5398
  },
5103
5399
  };
@@ -5108,6 +5404,11 @@ export async function executeRecessCommand(argv, options = {}) {
5108
5404
  if (result.action !== "write_workspace_files") {
5109
5405
  throw new CliError("unexpected_response", "Goal workspace push returned an unexpected response.");
5110
5406
  }
5407
+ if (receipt &&
5408
+ checkout.metadata.upstreamCommit === local.head &&
5409
+ checkout.metadata.changeId !== result.changeId) {
5410
+ throw new CliError("approval_mismatch", "The acknowledged checkout revision does not match this recovered push. Its local metadata was preserved.");
5411
+ }
5111
5412
  await writeRecessGitMetadata(checkout.root, {
5112
5413
  ...checkout.metadata,
5113
5414
  changeId: result.changeId,
@@ -5120,7 +5421,7 @@ export async function executeRecessCommand(argv, options = {}) {
5120
5421
  upstreamCommit: local.head,
5121
5422
  },
5122
5423
  };
5123
- });
5424
+ }, isGoal);
5124
5425
  }
5125
5426
  if (action === "write") {
5126
5427
  const studentId = flagString(parsed, "student", { required: true });
@@ -5141,25 +5442,47 @@ export async function executeRecessCommand(argv, options = {}) {
5141
5442
  message,
5142
5443
  files: input.files,
5143
5444
  };
5144
- const preflight = unwrap(await api.client.POST("/tutor/browser/mesa/workspace-files", {
5145
- body: { ...body, dryRun: true },
5146
- }));
5147
- if (preflight.action !== "preview_workspace_files_write") {
5445
+ const session = goalId
5446
+ ? unwrap(await api.client.GET("/auth/admin-cli/session/"))
5447
+ : null;
5448
+ const request = {
5449
+ source: input.source,
5450
+ sourceSha256: input.sha256,
5451
+ fileCount: input.files.length,
5452
+ sizeBytes: input.sizeBytes,
5453
+ message,
5454
+ };
5455
+ const boundTarget = session
5456
+ ? {
5457
+ studentId,
5458
+ goalId: goalId,
5459
+ actorId: session.user.id,
5460
+ apiOrigin: api.config.apiOrigin,
5461
+ }
5462
+ : null;
5463
+ const receipt = boundTarget
5464
+ ? await recoverExecutedPreview(parsed, {
5465
+ action: "batch-upsert files in a student's goal workspace",
5466
+ target: boundTarget,
5467
+ request,
5468
+ })
5469
+ : null;
5470
+ const preflight = receipt
5471
+ ? null
5472
+ : unwrap(await api.client.POST("/tutor/browser/mesa/workspace-files", {
5473
+ body: { ...body, dryRun: true },
5474
+ }));
5475
+ if (preflight && preflight.action !== "preview_workspace_files_write") {
5148
5476
  throw new CliError("unexpected_response", "Goal workspace write did not return a preview; nothing was written.");
5149
5477
  }
5150
- const preview = {
5478
+ const preview = receipt ?? {
5151
5479
  action: "batch-upsert files in a student's goal workspace",
5152
- target: preflight.target,
5153
- request: {
5154
- source: input.source,
5155
- sourceSha256: input.sha256,
5156
- fileCount: input.files.length,
5157
- sizeBytes: input.sizeBytes,
5158
- message,
5159
- },
5480
+ target: boundTarget ?? preflight.target,
5481
+ request,
5160
5482
  details: {
5161
5483
  currentChangeId: preflight.currentChangeId,
5162
5484
  files: preflight.files,
5485
+ resolvedTarget: preflight.target,
5163
5486
  note: target.kind === "draft"
5164
5487
  ? "This writes a complete authoring tree under drafts/<slug>/workspace. Capture it only after the server's goal-workspace validation passes."
5165
5488
  : "This directly edits the live goal workspace, including modules/ and state/. The write remains bound to the previewed workspace revision.",
@@ -5169,9 +5492,9 @@ export async function executeRecessCommand(argv, options = {}) {
5169
5492
  body: {
5170
5493
  ...body,
5171
5494
  dryRun: false,
5172
- expectedChangeId: preflight.currentChangeId,
5495
+ expectedChangeId: String(preview.details.currentChangeId),
5173
5496
  },
5174
- })));
5497
+ })), Boolean(goalId));
5175
5498
  }
5176
5499
  throw new CliError("invalid_arguments", "Use goals files list|read|init|checkout|push|write.");
5177
5500
  }
@@ -22,6 +22,7 @@ const BOOLEAN_FLAGS = new Set([
22
22
  "immediate",
23
23
  "keep-tokens",
24
24
  "include-deleted",
25
+ "include-archived",
25
26
  "json",
26
27
  "mirrored",
27
28
  "no-collision",
@@ -180,6 +181,16 @@ const FAMILY_AI_COMMANDS = new Set([
180
181
  "todos edit",
181
182
  ]);
182
183
  const STAFF_COMMANDS = new Set([
184
+ "cohorts schedule get",
185
+ "cohorts schedule set",
186
+ "cohorts schedule changes",
187
+ "cohorts schedule change",
188
+ "memories files",
189
+ "memories read",
190
+ "memories edit",
191
+ "memories history",
192
+ "memories revision",
193
+ "memories restore",
183
194
  "apps assign",
184
195
  "apps list",
185
196
  "apps preview",
@@ -0,0 +1,187 @@
1
+ import { createHash } from "node:crypto";
2
+ import fs from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { unwrap } from "../api.js";
5
+ import { flagString } from "../args.js";
6
+ import { CliError } from "../errors.js";
7
+ import { flagInteger, positional } from "./shared.js";
8
+ /** Selection files only supply explicit IDs; they never expand a filter into a mutation. */
9
+ export async function readAssignmentSelection(filePath) {
10
+ const absolutePath = path.resolve(filePath);
11
+ const stat = await fs.stat(absolutePath);
12
+ if (!stat.isFile() || stat.size > 1024 * 1024) {
13
+ throw new CliError("invalid_arguments", "The assignment selection must be a regular JSON file no larger than 1 MiB.");
14
+ }
15
+ const bytes = await fs.readFile(absolutePath);
16
+ let document;
17
+ try {
18
+ document = JSON.parse(bytes.toString("utf8"));
19
+ }
20
+ catch {
21
+ throw new CliError("invalid_arguments", "The assignment selection must contain valid JSON.");
22
+ }
23
+ if (!document ||
24
+ typeof document !== "object" ||
25
+ Array.isArray(document) ||
26
+ Object.keys(document).length !== 1 ||
27
+ !("todoIds" in document) ||
28
+ !Array.isArray(document.todoIds) ||
29
+ !document.todoIds.every((id) => typeof id === "string")) {
30
+ throw new CliError("invalid_arguments", 'The selection file must contain exactly {"todoIds":["assignment-uuid", ...]}.');
31
+ }
32
+ return {
33
+ todoIds: document.todoIds,
34
+ file: {
35
+ absolutePath,
36
+ sizeBytes: bytes.byteLength,
37
+ sha256: createHash("sha256").update(bytes).digest("hex"),
38
+ },
39
+ };
40
+ }
41
+ async function readResolutions(filePath) {
42
+ const absolutePath = path.resolve(filePath);
43
+ let bytes;
44
+ try {
45
+ const stat = await fs.stat(absolutePath);
46
+ if (!stat.isFile() || stat.size > 1024 * 1024) {
47
+ throw new CliError("invalid_arguments", "The resolution file must be a regular JSON file no larger than 1 MiB.");
48
+ }
49
+ bytes = await fs.readFile(absolutePath);
50
+ }
51
+ catch (error) {
52
+ if (error.code === "ENOENT") {
53
+ throw new CliError("invalid_arguments", `File not found: ${absolutePath}`);
54
+ }
55
+ throw error;
56
+ }
57
+ let document;
58
+ try {
59
+ document = JSON.parse(bytes.toString("utf8"));
60
+ }
61
+ catch {
62
+ throw new CliError("invalid_arguments", "The resolution file must contain valid JSON.");
63
+ }
64
+ if (!document || typeof document !== "object" || Array.isArray(document)) {
65
+ throw new CliError("invalid_arguments", "The resolution file must contain a JSON object.");
66
+ }
67
+ // The backend validates document version, choices, conflict IDs, and whether
68
+ // each choice is supported by the current three-way comparison.
69
+ return {
70
+ document: document,
71
+ file: {
72
+ absolutePath,
73
+ sizeBytes: bytes.byteLength,
74
+ sha256: createHash("sha256").update(bytes).digest("hex"),
75
+ },
76
+ };
77
+ }
78
+ export async function runGoalCurriculumCommand({ parsed, api, resolveTemplateId, previewBoundWrite, recoverExecutedPreview, }) {
79
+ const verb = positional(parsed, 2, "curriculum operation");
80
+ const [id, session] = await Promise.all([
81
+ resolveTemplateId(positional(parsed, 3, "template ID or slug")),
82
+ api.client.GET("/auth/admin-cli/session/").then(unwrap),
83
+ ]);
84
+ if (session.user.role !== "ADMIN" || session.cliScope !== "full_admin") {
85
+ throw new CliError("forbidden", "Curriculum updates require a full-admin session.");
86
+ }
87
+ const environment = {
88
+ actorId: session.user.id,
89
+ apiOrigin: api.config.apiOrigin,
90
+ };
91
+ if (verb === "status" || verb === "retry") {
92
+ const jobId = flagString(parsed, "job", { required: true });
93
+ if (verb === "status") {
94
+ return unwrap(await api.client.GET("/ai/goal-templates/{id}/curriculum/jobs/{jobId}", {
95
+ params: { path: { id, jobId } },
96
+ }));
97
+ }
98
+ const requestPreview = {
99
+ action: "resume the previously approved curriculum job",
100
+ target: { templateId: id, jobId, ...environment },
101
+ request: {},
102
+ };
103
+ const receipt = await recoverExecutedPreview(requestPreview);
104
+ const preview = receipt ?? {
105
+ ...requestPreview,
106
+ details: {
107
+ job: unwrap(await api.client.GET("/ai/goal-templates/{id}/curriculum/jobs/{jobId}", {
108
+ params: { path: { id, jobId } },
109
+ })),
110
+ note: "Resumes this job's stored scope and resolutions. Completed goals are not replayed; stale goals require a new preview.",
111
+ },
112
+ };
113
+ return previewBoundWrite(parsed, preview, async () => unwrap(await api.client.POST("/ai/goal-templates/{id}/curriculum/jobs/{jobId}/retry", {
114
+ params: { path: { id, jobId } },
115
+ body: {},
116
+ })));
117
+ }
118
+ const goalIds = flagString(parsed, "goals", { required: true })
119
+ .split(",")
120
+ .map((value) => value.trim());
121
+ if (goalIds.some((value) => !value) ||
122
+ new Set(goalIds).size !== goalIds.length) {
123
+ throw new CliError("invalid_arguments", "--goals must list distinct, nonempty goal IDs.");
124
+ }
125
+ if (verb === "backlog") {
126
+ const dueBefore = flagString(parsed, "due-before");
127
+ return unwrap(await api.client.GET("/ai/goal-templates/{id}/curriculum/backlog", {
128
+ params: {
129
+ path: { id },
130
+ query: { goalIds: goalIds.join(","), dueBefore },
131
+ },
132
+ }));
133
+ }
134
+ const targetVersion = flagInteger(parsed, "version", {
135
+ required: true,
136
+ min: 1,
137
+ });
138
+ const filePath = flagString(parsed, "resolutions-file", {
139
+ required: verb === "validate-resolutions",
140
+ });
141
+ const resolutionFile = filePath ? await readResolutions(filePath) : undefined;
142
+ const body = {
143
+ goalIds,
144
+ targetVersion,
145
+ resolutions: resolutionFile?.document ?? { version: 1, resolutions: [] },
146
+ };
147
+ const requestPreview = {
148
+ action: verb === "reverse"
149
+ ? "merge a prior template curriculum version into the selected student goals"
150
+ : "merge the selected template curriculum version into the selected student goals",
151
+ target: { templateId: id, goalIds, ...environment },
152
+ request: { ...body, resolutionFile: resolutionFile?.file ?? null },
153
+ };
154
+ const receipt = verb === "apply" || verb === "reverse"
155
+ ? await recoverExecutedPreview(requestPreview)
156
+ : null;
157
+ const comparison = receipt
158
+ ? receipt.details?.comparison
159
+ : unwrap(await api.client.POST("/ai/goal-templates/{id}/curriculum/preview", {
160
+ params: { path: { id } },
161
+ body,
162
+ }));
163
+ if (verb === "preview" || verb === "validate-resolutions") {
164
+ return { ...comparison, resolutionFile: resolutionFile?.file ?? null };
165
+ }
166
+ if (verb !== "apply" && verb !== "reverse") {
167
+ throw new CliError("invalid_arguments", "Use curriculum backlog|preview|validate-resolutions|apply|status|retry|reverse.");
168
+ }
169
+ const preview = receipt ?? {
170
+ ...requestPreview,
171
+ details: {
172
+ comparison,
173
+ note: "Only the exact selected goals are included. Unresolved conflicts block their goal. Assignments and earned history remain protected; reverse updates preserve changes made since the earlier version.",
174
+ },
175
+ };
176
+ return previewBoundWrite(parsed, preview, async () => unwrap(await api.client.POST("/ai/goal-templates/{id}/curriculum/apply", {
177
+ params: { path: { id } },
178
+ body: {
179
+ ...body,
180
+ fingerprint: comparison.fingerprint,
181
+ operationKey: flagString(parsed, "operation-key", {
182
+ required: true,
183
+ }),
184
+ },
185
+ })));
186
+ }
187
+ //# sourceMappingURL=goal-curriculum.js.map
@@ -0,0 +1,183 @@
1
+ import { createHash } from "node:crypto";
2
+ import fs from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { unwrap } from "../api.js";
5
+ import { flagString, hasFlag } from "../args.js";
6
+ import { CliError } from "../errors.js";
7
+ import { appendJobEvent } from "../jobs.js";
8
+ import { assertChoice, positional } from "./shared.js";
9
+ const hash = (value) => createHash("sha256").update(JSON.stringify(value)).digest("hex");
10
+ function recoveryKey(ctx) {
11
+ if (!hasFlag(ctx.parsed, "confirm"))
12
+ return undefined;
13
+ const key = flagString(ctx.parsed, "operation-key");
14
+ const token = flagString(ctx.parsed, "approval-token");
15
+ if (key && (!token || !key.endsWith(`_${token}`)))
16
+ throw new CliError("approval_mismatch", "Recovery requires the original operation key and approval token.");
17
+ return key;
18
+ }
19
+ async function recovered(ctx, row, expectedInputHash) {
20
+ if (row.inputHash !== expectedInputHash)
21
+ throw new CliError("approval_mismatch", "This operation key belongs to different input. No operation was retried.");
22
+ await appendJobEvent({
23
+ jobId: row.operationKey,
24
+ status: row.outcome === "outcome_unknown" ? "unknown" : "completed",
25
+ timestamp: new Date().toISOString(),
26
+ action: "recover staff operation",
27
+ fingerprint: flagString(ctx.parsed, "approval-token"),
28
+ target: { apiOrigin: ctx.api.config.apiOrigin },
29
+ result: row,
30
+ });
31
+ return row;
32
+ }
33
+ export async function runScheduleCommand(ctx) {
34
+ const { api, parsed, writeCommand } = ctx;
35
+ const action = positional(parsed, 2, "schedule action");
36
+ const id = positional(parsed, 3, "cohort or change ID");
37
+ if (action === "change")
38
+ return unwrap(await api.client.GET("/admin/cohorts/schedule-changes/{changeId}", {
39
+ params: { path: { changeId: id } },
40
+ }));
41
+ const params = { path: { id } };
42
+ if (action === "get")
43
+ return unwrap(await api.client.GET("/admin/cohorts/{id}/schedule/", { params }));
44
+ if (action === "changes")
45
+ return unwrap(await api.client.GET("/admin/cohorts/{id}/schedule/changes", {
46
+ params: { ...params, query: { cursor: flagString(parsed, "cursor") } },
47
+ }));
48
+ if (action !== "set")
49
+ throw new CliError("invalid_arguments", "Use cohorts schedule get|set|changes|change.");
50
+ const input = {
51
+ days: flagString(parsed, "days", { required: true })
52
+ .split(",")
53
+ .map((d) => assertChoice(d.trim().toUpperCase(), ["MO", "TU", "WE", "TH", "FR", "SA", "SU"], "weekday")),
54
+ time: flagString(parsed, "time", { required: true }),
55
+ timezone: flagString(parsed, "timezone"),
56
+ effectiveDate: flagString(parsed, "effective-date", { required: true }),
57
+ notify: assertChoice(flagString(parsed, "notify", { required: true }), ["none", "parents"], "notify"),
58
+ reason: flagString(parsed, "reason", { required: true }).trim(),
59
+ };
60
+ const key = recoveryKey(ctx);
61
+ if (key) {
62
+ const history = unwrap(await api.client.GET("/admin/cohorts/{id}/schedule/changes", {
63
+ params: { ...params, query: { operationKey: key } },
64
+ }));
65
+ if (history.changes[0])
66
+ return recovered(ctx, history.changes[0], hash({ id, input }));
67
+ }
68
+ const capability = unwrap(await api.client.GET("/admin/cohorts/{id}/schedule/", { params }));
69
+ if (capability.capabilityVersion !== 1 || !capability.supported)
70
+ throw new CliError("unsupported_backend", capability.unsupportedReason ??
71
+ "Backend does not support safe schedule changes.");
72
+ const preview = unwrap(await api.client.POST("/admin/cohorts/{id}/schedule/preview", {
73
+ params,
74
+ body: input,
75
+ }));
76
+ return writeCommand(parsed, {
77
+ action: "change recurring cohort schedule",
78
+ target: { cohortId: id, apiOrigin: api.config.apiOrigin },
79
+ request: input,
80
+ details: preview,
81
+ }, async () => {
82
+ const result = unwrap(await api.client.POST("/admin/cohorts/{id}/schedule/apply", {
83
+ params,
84
+ body: {
85
+ ...input,
86
+ operationKey: flagString(parsed, "operation-key", {
87
+ required: true,
88
+ }),
89
+ fingerprint: preview.fingerprint,
90
+ },
91
+ }));
92
+ return unwrap(await api.client.GET("/admin/cohorts/schedule-changes/{changeId}", {
93
+ params: { path: { changeId: result.id } },
94
+ }));
95
+ });
96
+ }
97
+ export async function runMemoryFilesCommand(ctx) {
98
+ const { parsed, api, writeCommand } = ctx;
99
+ const action = positional(parsed, 1, "memory action");
100
+ const studentId = flagString(parsed, "student", { required: true });
101
+ const params = { path: { studentId } };
102
+ if (action === "files")
103
+ return unwrap(await api.client.GET("/tutor/students/{studentId}/memory-files", {
104
+ params,
105
+ }));
106
+ const memoryPath = flagString(parsed, "path", { required: true });
107
+ if (action === "read")
108
+ return unwrap(await api.client.GET("/tutor/students/{studentId}/memory-files/read", {
109
+ params: { ...params, query: { path: memoryPath } },
110
+ }));
111
+ if (action === "history")
112
+ return unwrap(await api.client.GET("/tutor/students/{studentId}/memory-files/history", {
113
+ params: {
114
+ ...params,
115
+ query: { path: memoryPath, cursor: flagString(parsed, "cursor") },
116
+ },
117
+ }));
118
+ if (action === "revision")
119
+ return unwrap(await api.client.GET("/tutor/students/{studentId}/memory-files/revision", {
120
+ params: {
121
+ ...params,
122
+ query: {
123
+ path: memoryPath,
124
+ revision: flagString(parsed, "revision", { required: true }),
125
+ },
126
+ },
127
+ }));
128
+ if (action !== "edit" && action !== "restore")
129
+ throw new CliError("invalid_arguments", "Use memories files|read|edit|history|revision|restore.");
130
+ let content;
131
+ let contentFile;
132
+ if (action === "edit") {
133
+ contentFile = path.resolve(flagString(parsed, "content-file", { required: true }));
134
+ const info = await fs.lstat(contentFile);
135
+ if (!info.isFile() ||
136
+ info.isSymbolicLink() ||
137
+ (await fs.realpath(contentFile)) !== contentFile)
138
+ throw new CliError("invalid_file", "Memory input must be a regular file without symlink components.");
139
+ content = new TextDecoder("utf-8", { fatal: true }).decode(await fs.readFile(contentFile));
140
+ }
141
+ const input = {
142
+ path: memoryPath,
143
+ content,
144
+ revision: action === "restore"
145
+ ? flagString(parsed, "revision", { required: true })
146
+ : undefined,
147
+ action,
148
+ reason: flagString(parsed, "reason", { required: true }).trim(),
149
+ };
150
+ const key = recoveryKey(ctx);
151
+ if (key) {
152
+ const result = unwrap(await api.client.GET("/tutor/students/{studentId}/memory-files/operation", { params: { ...params, query: { operationKey: key } } }));
153
+ if (result.operation)
154
+ return recovered(ctx, result.operation, hash({ studentId, input }));
155
+ }
156
+ const preview = unwrap(await api.client.POST("/tutor/students/{studentId}/memory-files/preview", {
157
+ params,
158
+ body: input,
159
+ }));
160
+ if (preview.capabilityVersion !== 1 || preview.writeSupported !== true)
161
+ throw new CliError("unsupported_backend", preview.writeUnsupportedReason ??
162
+ "Backend does not support safe staff memory edits and restores.");
163
+ return writeCommand(parsed, {
164
+ action: `${action} student memory file`,
165
+ target: { studentId, path: memoryPath, apiOrigin: api.config.apiOrigin },
166
+ request: { ...input, contentFile },
167
+ details: {
168
+ ...preview,
169
+ before: { ...preview.before, revision: undefined },
170
+ },
171
+ }, async () => unwrap(await api.client.POST("/tutor/students/{studentId}/memory-files/apply", {
172
+ params,
173
+ body: {
174
+ ...input,
175
+ expectedHash: preview.before.contentSha,
176
+ fingerprint: preview.fingerprint,
177
+ operationKey: flagString(parsed, "operation-key", {
178
+ required: true,
179
+ }),
180
+ },
181
+ })));
182
+ }
183
+ //# sourceMappingURL=safe-staff-operations.js.map
package/dist/help.js CHANGED
@@ -143,6 +143,10 @@ Usage:
143
143
  [--description TEXT] [--date YYYY-MM-DD] [--confirm]
144
144
  recess [--json] payout items delete <item-id> [--confirm]
145
145
  recess [--json] cohorts get <cohort-id> [--events-tab ACTIVE|ENDED|CANCELED|ARCHIVED]
146
+ recess [--json] cohorts schedule get <cohort-id>
147
+ recess [--json] cohorts schedule set <cohort-id> --days <MO,WE> --time <09:30> [--timezone <zone>] --effective-date <YYYY-MM-DD> --notify none|parents [--confirm] [--approval-token <token>]
148
+ recess [--json] cohorts schedule changes <cohort-id> [--cursor <cursor>]
149
+ recess [--json] cohorts schedule change <change-id>
146
150
  recess [--json] cohorts parent-emails <cohort-id>
147
151
  recess [--json] cohorts end <cohort-id> [--cancel-subscriptions] [--confirm]
148
152
  recess [--json] cohorts pause-billing <cohort-id> --weeks 1..6 [--confirm]
@@ -335,6 +339,29 @@ Usage:
335
339
  [--kind SIMPLE|BLUEPRINT] [--starter-only] [--include-deleted]
336
340
  [--limit 20] [--cursor <id>]
337
341
  recess [--json] goal-templates get <template-id|slug> [--spec-only]
342
+ recess [--json] goal-templates policy <template-id|slug>
343
+ recess [--json] goal-templates set-policy <template-id|slug> --recipe KEY
344
+ [--target-modules N] [--required-modules N|all] --expected-version N
345
+ [--confirm --approval-token TOKEN]
346
+ recess [--json] goal-templates migrate-policy <template-id|slug> --students ID,ID
347
+ [--confirm --approval-token TOKEN]
348
+ recess [--json] goal-templates student-policy <template-id|slug> --goal ID
349
+ --file <path/edit.json> [--confirm --approval-token TOKEN]
350
+ recess [--json] goal-templates retire-assignments <template-id|slug> --goal ID
351
+ [--todos ID,ID | --todos-file <path/selection.json>]
352
+ --retirement-reason TEXT [--confirm --approval-token TOKEN]
353
+ recess [--json] goal-templates curriculum backlog <template-id|slug>
354
+ --goals ID,ID [--due-before YYYY-MM-DD]
355
+ recess [--json] goal-templates curriculum preview <template-id|slug>
356
+ --goals ID,ID --version N [--resolutions-file <path/resolutions.json>]
357
+ recess [--json] goal-templates curriculum validate-resolutions <template-id|slug>
358
+ --goals ID,ID --version N --resolutions-file <path/resolutions.json>
359
+ recess [--json] goal-templates curriculum apply|reverse <template-id|slug>
360
+ --goals ID,ID --version N [--resolutions-file <path/resolutions.json>]
361
+ [--confirm --approval-token TOKEN]
362
+ recess [--json] goal-templates curriculum status <template-id|slug> --job ID
363
+ recess [--json] goal-templates curriculum retry <template-id|slug> --job ID
364
+ [--confirm --approval-token TOKEN]
338
365
  recess [--json] goal-templates versions <template-id> [--version N]
339
366
  recess [--json] goal-templates validate-spec --file <path/template.json>
340
367
  recess [--json] goal-templates create --file <path/template.json>
@@ -360,7 +387,7 @@ Usage:
360
387
  [--dry-run] [--confirm --approval-token TOKEN]
361
388
  recess [--json] goal-templates apply-starter <template-id> --student <kid-id>
362
389
  [--answers-file <path>] [--confirm]
363
- recess [--json] goals list --student <kid-id>
390
+ recess [--json] goals list --student <kid-id> [--query <text>] [--include-archived]
364
391
  recess [--json] goals create [--student <kid-id>] --title TEXT
365
392
  (--description TEXT | --description-file <path>) [--target-date <iso>]
366
393
  [--schedule TEXT] [(--draft <draft-slug> | --source-dir <local-checkout>)
@@ -395,6 +422,14 @@ Usage:
395
422
  recess [--json] todos generate-learning-analysis <todo-id> [--confirm]
396
423
  recess [--json] todos generate-applet <todo-id> --student <kid-id>
397
424
  [--due-date YYYY-MM-DD] [--confirm --approval-token TOKEN]
425
+ recess [--json] memories files --student <kid-id>
426
+ recess [--json] memories read --student <kid-id> --path <path>
427
+ recess [--json] memories edit --student <kid-id> --path <path> --content-file <file> [--confirm] [--approval-token <token>]
428
+ recess [--json] memories history --student <kid-id> --path <path> [--cursor <cursor>]
429
+ recess [--json] memories revision --student <kid-id> --path <path> --revision <revision>
430
+ recess [--json] memories restore --student <kid-id> --path <path> --revision <revision> [--confirm] [--approval-token <token>]
431
+ Staff memory edit/restore publication is disabled pending recoverable immutable history.
432
+ Reads, history, and recovery of existing operations remain available.
398
433
  recess [--json] memories context --student <kid-id>
399
434
  recess [--json] memories log --student <kid-id> --date YYYY-MM-DD
400
435
  recess [--json] rocky get --student <kid-id>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "recess-cli",
3
- "version": "2.10.0",
3
+ "version": "3.1.0",
4
4
  "description": "Safe Recess administration and family AI tools from the command line.",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {
@@ -72,18 +72,21 @@ Any changed target, payload, local file, server revision, amount, recipient, or
72
72
 
73
73
  ### Hosted MCP transport
74
74
 
75
- When this skill is reached through the hosted Recess connector, use its only tool,
76
- `recess_exec({argv,input?})`. `argv` starts with the command noun, omits both `recess` and `--json`,
77
- and still carries `--reason`. The CLI envelope is returned in MCP `structuredContent`; a
78
- `confirmation_required` envelope is expected control flow, while other failures are tool errors.
79
- Start with `argv:["agent-context"]` or `argv:["help","apps"]` for the remote-only catalog. OAuth
80
- already supplies the live user and scopes: never ask for cookies, profiles, local paths, or
81
- `--deliver`.
82
-
83
- Hosted writes stage the exact argv and structured payload on the server. Preview without
84
- `--confirm`, show the human `error.details.preview`, then confirm with the same command path plus
85
- `--confirm --operation-key <key>` and **omit `input`**. The server executes the stored bytes; changed
86
- source, flags, project/version, or operation keys require a fresh preview.
75
+ Use the connector's individually named tools and their discovered argument schemas, for example
76
+ `recess_students_todos({student:"…",reason:"…"})`. Discovery replaces CLI help. OAuth supplies the
77
+ live user and consented scopes; never ask for cookies, local paths or profiles.
78
+
79
+ Preview writes with named arguments and `reason`, show `error.details.preview`, then confirm
80
+ through the same tool with **only** `{confirm:true,operationKey:"…"}`. Stored arguments and source
81
+ bytes are restored server-side. Changed input or consequences require a new preview. Retain the
82
+ key on interruption and inspect `recess_jobs_get`; never start a replacement for an uncertain
83
+ write. `confirmation_required` is normal control flow in the returned `structuredContent`.
84
+
85
+ Supply JSON/text directly, workspaces as file bundles with `baseRevision`, and binaries as
86
+ `{name,content,encoding}` or scoped `{uploadHandle}` references created by `recess_upload_create`.
87
+ Upload bytes to its signed URL(s); size/hash verification happens before use. Download returned
88
+ bundles/authorized URLs. `/mcp/legacy` temporarily retains `recess_exec({command,input?})` for
89
+ 30 days after rollout. Refresh connector metadata and consent for expanded capabilities.
87
90
 
88
91
  ## Building Studio apps (`recess apps`)
89
92
 
@@ -106,20 +109,16 @@ prompts a model for them.
106
109
  - `recess --json apps standards "<words or notation>"` finds the CCSS standard for `manifest.json`
107
110
  (`"grade 4 adding fractions"` → `4.NF.B.3a`…); guides rarely know the codes, look them up.
108
111
 
109
- Hosted authoring keeps those same contracts but carries no folder:
110
-
111
- - `apps scaffold [--for-todo <id>]` returns authoring instructions, the five editable starter
112
- files, fork guidance, and an optional learner brief. It performs no write.
113
- - Generate `input.appBundle` with exactly `skill.js`, `model.js`, `model.css`,
114
- `params.schema.json`, and `params.json`, plus `contract` and `manifest`. Preview and confirm
115
- `apps validate`; keep the returned project/version/build IDs and poll `apps status <build-id>`.
116
- - Fix findings in the bundle. For an existing project, first `apps pull <project-id>` and preserve
117
- its `baseVersion`; a stale version is a conflict, never an overwrite.
118
- - `apps preview <project-id> --version <n>` returns the short-lived capability URL.
119
- Preview/confirm `apps publish <project-id> --version <n>`, then poll its build. Publication sends
120
- no source and reviews exactly that validated version.
121
- - After publication, preview/confirm `apps assign <project-id> --student <id> [--due YYYY-MM-DD]`.
122
- Assignment is intentionally separate from hosted publish.
112
+ Hosted authoring keeps those same contracts with named tools:
113
+
114
+ - `recess_apps_scaffold` returns instructions, editable files, and an optional `forTodo` brief.
115
+ - Supply `appBundle` (files, contract, manifest) to `recess_apps_validate`; preview and confirm,
116
+ then keep its project/version/build IDs and poll `recess_apps_status`.
117
+ - Before editing an existing project, call `recess_apps_pull` and preserve its `baseVersion`.
118
+ - `recess_apps_preview` previews creation of a temporary capability URL. Preview and confirm
119
+ `recess_apps_publish` for the exact project/version; it reviews that validated version.
120
+ - Use `assign` and optional `due` on publication for recoverable publish-and-assign, or use
121
+ `recess_apps_assign` separately. A pending build retains the same operation key for retry.
123
122
 
124
123
  Two ways in. If the guide names a kid or a todo, pull the analysis first and build against it;
125
124
  otherwise build from their description:
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "2.6.0",
3
- "minCliVersion": "2.6.1"
2
+ "version": "3.0.0",
3
+ "minCliVersion": "3.0.0"
4
4
  }