recess-cli 3.0.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/dist/cli.js CHANGED
@@ -9,6 +9,7 @@ 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";
@@ -615,7 +616,7 @@ function assertOperationKeyMatchesPreview(operationKey, fingerprint, preview) {
615
616
  });
616
617
  }
617
618
  }
618
- async function requirePreviewBoundConfirmation(parsed, preview) {
619
+ async function requirePreviewBoundConfirmation(parsed, preview, persistPreview = false) {
619
620
  const approvalToken = approvalTokenFor(preview);
620
621
  const suppliedOperationKey = flagString(parsed, "operation-key");
621
622
  const boundPreview = {
@@ -634,6 +635,9 @@ async function requirePreviewBoundConfirmation(parsed, preview) {
634
635
  action: preview.action,
635
636
  fingerprint: approvalToken,
636
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 } : {}),
637
641
  });
638
642
  requireConfirmation(false, boundPreview);
639
643
  }
@@ -655,8 +659,8 @@ async function requirePreviewBoundConfirmation(parsed, preview) {
655
659
  });
656
660
  return { operationKey: suppliedOperationKey, fingerprint: approvalToken };
657
661
  }
658
- async function previewBoundWrite(parsed, preview, execute) {
659
- const context = await requirePreviewBoundConfirmation(parsed, preview);
662
+ async function previewBoundWrite(parsed, preview, execute, persistPreview = false) {
663
+ const context = await requirePreviewBoundConfirmation(parsed, preview, persistPreview);
660
664
  try {
661
665
  const result = await withIdempotencyContext(context, execute);
662
666
  await appendJobEvent({
@@ -688,6 +692,41 @@ async function previewBoundWrite(parsed, preview, execute) {
688
692
  throw error;
689
693
  }
690
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
+ }
691
730
  async function cleanupPlannerUpload(api, conversationId, signed, includeDelete) {
692
731
  if (signed.kind === "multipart") {
693
732
  await api.client
@@ -3460,6 +3499,16 @@ export async function executeRecessCommand(argv, options = {}) {
3460
3499
  throw new CliError("invalid_arguments", "Use content-library search, status, set-stage, or submit.");
3461
3500
  }
3462
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
+ }
3463
3512
  const readTemplateDocument = async () => {
3464
3513
  if (!isRemote) {
3465
3514
  return readJsonFile(flagString(parsed, "file", { required: true }), "Template file");
@@ -3618,6 +3667,174 @@ export async function executeRecessCommand(argv, options = {}) {
3618
3667
  body: document,
3619
3668
  })));
3620
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
+ }
3621
3838
  if (verb === "patch-spec") {
3622
3839
  const id = await resolveGoalTemplateId(api, positional(parsed, 2, "template ID or slug"));
3623
3840
  const expectedVersion = requiredExpectedVersion(parsed);
@@ -5085,7 +5302,40 @@ export async function executeRecessCommand(argv, options = {}) {
5085
5302
  required: true,
5086
5303
  });
5087
5304
  const checkout = await readRecessGitMetadata(sourceDirectory);
5088
- 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
+ }
5089
5339
  const defaultMessage = local.commits.length === 1
5090
5340
  ? local.commits[0].subject
5091
5341
  : `recess-cli: push ${local.commits.length} commits (${local.commits.at(-1).subject})`;
@@ -5106,36 +5356,44 @@ export async function executeRecessCommand(argv, options = {}) {
5106
5356
  target: checkout.metadata.target,
5107
5357
  message,
5108
5358
  files,
5109
- expectedChangeId: checkout.metadata.changeId,
5359
+ expectedChangeId: mesaBaseChangeId,
5110
5360
  };
5111
- const preflight = unwrap(await api.client.POST("/tutor/browser/mesa/workspace-files", {
5112
- body: { ...body, dryRun: true },
5113
- }));
5114
- 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") {
5115
5367
  throw new CliError("unexpected_response", "Goal workspace push did not return a preview; nothing was written.");
5116
5368
  }
5117
- const preview = {
5118
- action: `push committed Git changes to a student's Mesa ${checkout.metadata.target.kind} workspace`,
5119
- target: preflight.target,
5120
- request: {
5121
- sourceDirectory: checkout.root,
5122
- mesaBaseChangeId: checkout.metadata.changeId,
5123
- localBaseCommit: checkout.metadata.upstreamCommit,
5124
- localHeadCommit: local.head,
5125
- commits: local.commits,
5126
- message,
5127
- changes: local.changes.map((change) => change.action === "delete"
5128
- ? change
5129
- : {
5130
- path: change.path,
5131
- action: change.action,
5132
- sizeBytes: change.sizeBytes,
5133
- sha256: change.sha256,
5134
- }),
5135
- sizeBytes: local.sizeBytes,
5136
- },
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,
5137
5394
  details: {
5138
5395
  currentChangeId: preflight.currentChangeId,
5396
+ resolvedTarget: preflight.target,
5139
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.",
5140
5398
  },
5141
5399
  };
@@ -5146,6 +5404,11 @@ export async function executeRecessCommand(argv, options = {}) {
5146
5404
  if (result.action !== "write_workspace_files") {
5147
5405
  throw new CliError("unexpected_response", "Goal workspace push returned an unexpected response.");
5148
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
+ }
5149
5412
  await writeRecessGitMetadata(checkout.root, {
5150
5413
  ...checkout.metadata,
5151
5414
  changeId: result.changeId,
@@ -5158,7 +5421,7 @@ export async function executeRecessCommand(argv, options = {}) {
5158
5421
  upstreamCommit: local.head,
5159
5422
  },
5160
5423
  };
5161
- });
5424
+ }, isGoal);
5162
5425
  }
5163
5426
  if (action === "write") {
5164
5427
  const studentId = flagString(parsed, "student", { required: true });
@@ -5179,25 +5442,47 @@ export async function executeRecessCommand(argv, options = {}) {
5179
5442
  message,
5180
5443
  files: input.files,
5181
5444
  };
5182
- const preflight = unwrap(await api.client.POST("/tutor/browser/mesa/workspace-files", {
5183
- body: { ...body, dryRun: true },
5184
- }));
5185
- 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") {
5186
5476
  throw new CliError("unexpected_response", "Goal workspace write did not return a preview; nothing was written.");
5187
5477
  }
5188
- const preview = {
5478
+ const preview = receipt ?? {
5189
5479
  action: "batch-upsert files in a student's goal workspace",
5190
- target: preflight.target,
5191
- request: {
5192
- source: input.source,
5193
- sourceSha256: input.sha256,
5194
- fileCount: input.files.length,
5195
- sizeBytes: input.sizeBytes,
5196
- message,
5197
- },
5480
+ target: boundTarget ?? preflight.target,
5481
+ request,
5198
5482
  details: {
5199
5483
  currentChangeId: preflight.currentChangeId,
5200
5484
  files: preflight.files,
5485
+ resolvedTarget: preflight.target,
5201
5486
  note: target.kind === "draft"
5202
5487
  ? "This writes a complete authoring tree under drafts/<slug>/workspace. Capture it only after the server's goal-workspace validation passes."
5203
5488
  : "This directly edits the live goal workspace, including modules/ and state/. The write remains bound to the previewed workspace revision.",
@@ -5207,9 +5492,9 @@ export async function executeRecessCommand(argv, options = {}) {
5207
5492
  body: {
5208
5493
  ...body,
5209
5494
  dryRun: false,
5210
- expectedChangeId: preflight.currentChangeId,
5495
+ expectedChangeId: String(preview.details.currentChangeId),
5211
5496
  },
5212
- })));
5497
+ })), Boolean(goalId));
5213
5498
  }
5214
5499
  throw new CliError("invalid_arguments", "Use goals files list|read|init|checkout|push|write.");
5215
5500
  }
@@ -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
package/dist/help.js CHANGED
@@ -339,6 +339,29 @@ Usage:
339
339
  [--kind SIMPLE|BLUEPRINT] [--starter-only] [--include-deleted]
340
340
  [--limit 20] [--cursor <id>]
341
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]
342
365
  recess [--json] goal-templates versions <template-id> [--version N]
343
366
  recess [--json] goal-templates validate-spec --file <path/template.json>
344
367
  recess [--json] goal-templates create --file <path/template.json>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "recess-cli",
3
- "version": "3.0.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": {