recess-cli 3.0.0 → 3.2.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,46 @@ 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
+ /**
663
+ * A tutor template's `body` is the `## Subjects` block markdown (no `###`
664
+ * heading — the server renders that from `title` + `cadence`).
665
+ */
666
+ async function readTutorTemplateBody(filePath) {
667
+ const absolutePath = path.resolve(filePath);
668
+ const contents = await fs.readFile(absolutePath, "utf8").catch(() => null);
669
+ if (contents === null) {
670
+ throw new CliError("invalid_arguments", `--body-file is not readable: ${absolutePath}`);
671
+ }
672
+ if (contents.trim().length === 0) {
673
+ throw new CliError("invalid_arguments", "--body-file is empty.");
674
+ }
675
+ return contents;
676
+ }
677
+ /**
678
+ * Every `*.md` in the directory becomes one `lessons[name]` entry, referenced
679
+ * from a module line as `lesson:<slug>/<name>`.
680
+ */
681
+ async function readTutorTemplateLessons(directory) {
682
+ const absolutePath = path.resolve(directory);
683
+ const entries = await fs
684
+ .readdir(absolutePath, { withFileTypes: true })
685
+ .catch(() => null);
686
+ if (entries === null) {
687
+ throw new CliError("invalid_arguments", `--lessons-dir is not a readable directory: ${absolutePath}`);
688
+ }
689
+ const lessons = {};
690
+ for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
691
+ if (!entry.isFile() || !entry.name.toLowerCase().endsWith(".md"))
692
+ continue;
693
+ lessons[entry.name] = await fs.readFile(path.join(absolutePath, entry.name), "utf8");
694
+ }
695
+ if (Object.keys(lessons).length === 0) {
696
+ throw new CliError("invalid_arguments", `--lessons-dir contains no .md files: ${absolutePath}`);
697
+ }
698
+ return lessons;
699
+ }
700
+ async function previewBoundWrite(parsed, preview, execute, persistPreview = false) {
701
+ const context = await requirePreviewBoundConfirmation(parsed, preview, persistPreview);
660
702
  try {
661
703
  const result = await withIdempotencyContext(context, execute);
662
704
  await appendJobEvent({
@@ -688,6 +730,41 @@ async function previewBoundWrite(parsed, preview, execute) {
688
730
  throw error;
689
731
  }
690
732
  }
733
+ async function readExecutedPreview(parsed) {
734
+ const key = flagString(parsed, "operation-key");
735
+ if (!hasFlag(parsed, "confirm") || !key)
736
+ return null;
737
+ const { history } = await getJob(key);
738
+ const receipt = [...history].reverse().find((event) => event.preview);
739
+ if (!receipt?.preview)
740
+ return null;
741
+ // First confirmation still refreshes server effects. A prior execution
742
+ // attempt is what makes a stale preflight an idempotent-recovery problem.
743
+ if (!history.some((event) => event.status !== "awaiting_confirmation" &&
744
+ event.fingerprint === receipt.fingerprint &&
745
+ event.action === receipt.action))
746
+ return null;
747
+ const saved = receipt.preview;
748
+ const fingerprint = approvalTokenFor(saved);
749
+ if (receipt.fingerprint !== fingerprint ||
750
+ flagString(parsed, "approval-token") !== fingerprint) {
751
+ throw new CliError("approval_mismatch", "This operation key does not match the approved command. Retry the original command unchanged, or review a new preview.");
752
+ }
753
+ assertOperationKeyMatchesPreview(key, fingerprint, saved);
754
+ return saved;
755
+ }
756
+ /** A retried write must retain its approved fence after changing its own source state. */
757
+ async function recoverExecutedPreview(parsed, expected) {
758
+ const saved = await readExecutedPreview(parsed);
759
+ if (!saved)
760
+ return null;
761
+ if (saved.action !== expected.action ||
762
+ JSON.stringify(saved.target) !== JSON.stringify(expected.target) ||
763
+ JSON.stringify(saved.request) !== JSON.stringify(expected.request)) {
764
+ throw new CliError("approval_mismatch", "This operation key does not match the approved command. Retry the original command unchanged, or review a new preview.");
765
+ }
766
+ return saved;
767
+ }
691
768
  async function cleanupPlannerUpload(api, conversationId, signed, includeDelete) {
692
769
  if (signed.kind === "multipart") {
693
770
  await api.client
@@ -809,6 +886,9 @@ async function readGoalWorkspaceWriteSource(parsed) {
809
886
  const entries = await fs.readdir(directory, { withFileTypes: true });
810
887
  entries.sort((a, b) => a.name.localeCompare(b.name));
811
888
  for (const entry of entries) {
889
+ // Git metadata is local checkout state, including worktree .git files.
890
+ if (entry.name === ".git")
891
+ continue;
812
892
  const relativePath = relativeRoot
813
893
  ? path.join(relativeRoot, entry.name)
814
894
  : entry.name;
@@ -1877,6 +1957,10 @@ export async function executeRecessCommand(argv, options = {}) {
1877
1957
  };
1878
1958
  }
1879
1959
  if (noun === "auth") {
1960
+ const duration = flagString(parsed, "duration");
1961
+ if (duration !== undefined && duration !== "12h" && duration !== "30d") {
1962
+ throw new CliError("invalid_arguments", "--duration must be 12h or 30d (admins only).");
1963
+ }
1880
1964
  if (verb === "login") {
1881
1965
  const oauthClientId = flagString(parsed, "client-id") ?? config.oauthClientId;
1882
1966
  const callbackPort = flagNumber(parsed, "callback-port") ?? 8765;
@@ -1885,11 +1969,15 @@ export async function executeRecessCommand(argv, options = {}) {
1885
1969
  callbackPort > 65535) {
1886
1970
  throw new CliError("invalid_arguments", "--callback-port must be an integer from 1024 through 65535.");
1887
1971
  }
1888
- return login(config, { callbackPort, oauthClientId });
1972
+ return login(config, {
1973
+ callbackPort,
1974
+ oauthClientId,
1975
+ sessionDuration: duration,
1976
+ });
1889
1977
  }
1890
1978
  if (verb === "request") {
1891
1979
  const label = flagString(parsed, "label");
1892
- return requestDeviceAuth(config, label ? { label } : {});
1980
+ return requestDeviceAuth(config, { label, sessionDuration: duration });
1893
1981
  }
1894
1982
  if (verb === "poll") {
1895
1983
  const timeoutSeconds = flagNumber(parsed, "timeout") ?? 300;
@@ -3460,6 +3548,16 @@ export async function executeRecessCommand(argv, options = {}) {
3460
3548
  throw new CliError("invalid_arguments", "Use content-library search, status, set-stage, or submit.");
3461
3549
  }
3462
3550
  if (noun === "goal-templates") {
3551
+ if (verb === "curriculum") {
3552
+ return runGoalCurriculumCommand({
3553
+ parsed,
3554
+ api,
3555
+ writeCommand,
3556
+ resolveTemplateId: (value) => resolveGoalTemplateId(api, value),
3557
+ previewBoundWrite: (args, preview, execute) => previewBoundWrite(args, preview, execute, true),
3558
+ recoverExecutedPreview: (expected) => recoverExecutedPreview(parsed, expected),
3559
+ });
3560
+ }
3463
3561
  const readTemplateDocument = async () => {
3464
3562
  if (!isRemote) {
3465
3563
  return readJsonFile(flagString(parsed, "file", { required: true }), "Template file");
@@ -3618,6 +3716,174 @@ export async function executeRecessCommand(argv, options = {}) {
3618
3716
  body: document,
3619
3717
  })));
3620
3718
  }
3719
+ if (verb === "policy") {
3720
+ const id = await resolveGoalTemplateId(api, positional(parsed, 2, "template ID or slug"));
3721
+ return unwrap(await api.client.GET("/ai/goal-templates/{id}/policy", {
3722
+ params: { path: { id } },
3723
+ }));
3724
+ }
3725
+ if (verb === "set-policy") {
3726
+ const id = await resolveGoalTemplateId(api, positional(parsed, 2, "template ID or slug"));
3727
+ const target = flagString(parsed, "target-modules");
3728
+ const minimum = flagString(parsed, "required-modules");
3729
+ for (const [flag, value] of [
3730
+ ["target-modules", target],
3731
+ ["required-modules", minimum],
3732
+ ]) {
3733
+ if (value === undefined ||
3734
+ (flag === "required-modules" && value === "all"))
3735
+ continue;
3736
+ const count = Number(value);
3737
+ if (!Number.isInteger(count) || count < 1 || count > 20)
3738
+ throw new CliError("invalid_arguments", `--${flag} must be an integer from 1 through 20${flag === "required-modules" ? " or all" : ""}.`);
3739
+ }
3740
+ if (target === undefined && minimum === undefined)
3741
+ throw new CliError("invalid_arguments", "Provide --target-modules or --required-modules N|all");
3742
+ const body = {
3743
+ recipeKey: flagString(parsed, "recipe", { required: true }),
3744
+ ...(target === undefined ? {} : { targetModuleCount: Number(target) }),
3745
+ ...(minimum === undefined
3746
+ ? {}
3747
+ : {
3748
+ requiredModuleCount: minimum === "all" ? null : Number(minimum),
3749
+ }),
3750
+ expectedVersion: requiredExpectedVersion(parsed),
3751
+ };
3752
+ const requestPreview = {
3753
+ action: "edit shared goal-template assigned target or completion minimum",
3754
+ target: { templateId: id, apiOrigin: api.config.apiOrigin },
3755
+ request: body,
3756
+ };
3757
+ const receipt = await recoverExecutedPreview(parsed, requestPreview);
3758
+ const preview = receipt ?? {
3759
+ ...requestPreview,
3760
+ details: unwrap(await api.client.POST("/ai/goal-templates/{id}/policy", {
3761
+ params: { path: { id } },
3762
+ body: { ...body, dryRun: true },
3763
+ })),
3764
+ };
3765
+ // A recovered receipt only skips the now-stale preview. The actual write
3766
+ // still reaches server authorization, idempotency, and expectedVersion.
3767
+ return previewBoundWrite(parsed, preview, async () => unwrap(await api.client.POST("/ai/goal-templates/{id}/policy", {
3768
+ params: { path: { id } },
3769
+ body: { ...body, dryRun: false },
3770
+ })), true);
3771
+ }
3772
+ if (verb === "migrate-policy") {
3773
+ const id = await resolveGoalTemplateId(api, positional(parsed, 2, "template ID or slug"));
3774
+ const studentIds = flagString(parsed, "students", { required: true })
3775
+ .split(",")
3776
+ .map((value) => value.trim());
3777
+ const preflight = unwrap(await api.client.POST("/ai/goal-templates/{id}/policy-migration", {
3778
+ params: { path: { id } },
3779
+ body: { studentIds, dryRun: true },
3780
+ }));
3781
+ const preview = {
3782
+ action: "backfill reviewed goal policy identities and migration holds",
3783
+ target: { templateId: id, studentIds },
3784
+ request: { studentIds, fingerprint: preflight.fingerprint },
3785
+ details: preflight,
3786
+ };
3787
+ return previewBoundWrite(parsed, preview, async () => unwrap(await api.client.POST("/ai/goal-templates/{id}/policy-migration", {
3788
+ params: { path: { id } },
3789
+ body: {
3790
+ studentIds,
3791
+ fingerprint: preflight.fingerprint,
3792
+ dryRun: false,
3793
+ },
3794
+ })));
3795
+ }
3796
+ if (verb === "retire-assignments") {
3797
+ const [id, session] = await Promise.all([
3798
+ resolveGoalTemplateId(api, positional(parsed, 2, "template ID or slug")),
3799
+ api.client.GET("/auth/admin-cli/session/").then(unwrap),
3800
+ ]);
3801
+ if (session.user.role !== "ADMIN" || session.cliScope !== "full_admin")
3802
+ throw new CliError("forbidden", "Assignment retirement requires a full-admin session.");
3803
+ const goalId = flagString(parsed, "goal", { required: true });
3804
+ const rawIds = flagString(parsed, "todos");
3805
+ const selectionPath = flagString(parsed, "todos-file");
3806
+ if (Boolean(rawIds) === Boolean(selectionPath))
3807
+ throw new CliError("invalid_arguments", "Choose exactly one of --todos or --todos-file with explicit assignment IDs.");
3808
+ const selection = selectionPath
3809
+ ? await readAssignmentSelection(selectionPath)
3810
+ : null;
3811
+ const todoIds = selection?.todoIds ?? rawIds.split(",").map((id) => id.trim());
3812
+ if (!todoIds.length ||
3813
+ todoIds.length > 500 ||
3814
+ todoIds.some((id) => !id) ||
3815
+ new Set(todoIds).size !== todoIds.length)
3816
+ throw new CliError("invalid_arguments", "Choose 1–500 distinct assignment IDs explicitly.");
3817
+ const reason = flagString(parsed, "retirement-reason", {
3818
+ required: true,
3819
+ });
3820
+ const requestPreview = {
3821
+ action: "retire the selected curriculum assignments without completing lessons",
3822
+ target: {
3823
+ templateId: id,
3824
+ goalId,
3825
+ actorId: session.user.id,
3826
+ apiOrigin: api.config.apiOrigin,
3827
+ },
3828
+ request: { todoIds, reason, selectionFile: selection?.file ?? null },
3829
+ };
3830
+ const receipt = await recoverExecutedPreview(parsed, requestPreview);
3831
+ const preflight = receipt
3832
+ ? receipt.details
3833
+ : unwrap(await api.client.POST("/ai/goal-templates/{id}/goals/{goalId}/assignments/retire", {
3834
+ params: { path: { id, goalId } },
3835
+ body: { todoIds, reason, dryRun: true },
3836
+ }));
3837
+ const preview = receipt ?? {
3838
+ ...requestPreview,
3839
+ details: preflight,
3840
+ };
3841
+ return previewBoundWrite(parsed, preview, async () => unwrap(await api.client.POST("/ai/goal-templates/{id}/goals/{goalId}/assignments/retire", {
3842
+ params: { path: { id, goalId } },
3843
+ body: {
3844
+ todoIds,
3845
+ reason,
3846
+ fingerprint: preflight.fingerprint,
3847
+ dryRun: false,
3848
+ },
3849
+ })));
3850
+ }
3851
+ if (verb === "student-policy") {
3852
+ const id = await resolveGoalTemplateId(api, positional(parsed, 2, "template ID or slug"));
3853
+ const goalId = flagString(parsed, "goal", { required: true });
3854
+ const filePath = path.resolve(flagString(parsed, "file", { required: true }));
3855
+ const bytes = await fs.readFile(filePath);
3856
+ let raw;
3857
+ try {
3858
+ raw = JSON.parse(bytes.toString("utf8"));
3859
+ }
3860
+ catch {
3861
+ throw new CliError("invalid_arguments", "Student policy edit must contain valid JSON");
3862
+ }
3863
+ // Backend Zod is authoritative; preserve exact file bytes in the approval preview.
3864
+ const body = raw;
3865
+ const preflight = unwrap(await api.client.POST("/ai/goal-templates/{id}/goals/{goalId}/policy", {
3866
+ params: { path: { id, goalId } },
3867
+ body: { ...body, dryRun: true },
3868
+ }));
3869
+ const preview = {
3870
+ action: "review a student policy override or migration hold",
3871
+ target: { templateId: id, goalId },
3872
+ request: body,
3873
+ details: {
3874
+ ...preflight,
3875
+ file: {
3876
+ absolutePath: filePath,
3877
+ sizeBytes: bytes.byteLength,
3878
+ sha256: createHash("sha256").update(bytes).digest("hex"),
3879
+ },
3880
+ },
3881
+ };
3882
+ return previewBoundWrite(parsed, preview, async () => unwrap(await api.client.POST("/ai/goal-templates/{id}/goals/{goalId}/policy", {
3883
+ params: { path: { id, goalId } },
3884
+ body: { ...body, dryRun: false },
3885
+ })));
3886
+ }
3621
3887
  if (verb === "patch-spec") {
3622
3888
  const id = await resolveGoalTemplateId(api, positional(parsed, 2, "template ID or slug"));
3623
3889
  const expectedVersion = requiredExpectedVersion(parsed);
@@ -4090,6 +4356,65 @@ export async function executeRecessCommand(argv, options = {}) {
4090
4356
  .includes(query))),
4091
4357
  };
4092
4358
  }
4359
+ // Moves ONE kid's lesson, not the template's. The daily queue serves
4360
+ // modules by `ordinal ASC`, so pushing the one a kid is stuck on past the
4361
+ // rest is what hands them the next lesson tomorrow — no template version,
4362
+ // no retiring the work they have already done, nobody else's goal touched.
4363
+ if (verb === "move-module") {
4364
+ const moduleRef = positional(parsed, 2, "module ref (e.g. Q006)");
4365
+ const studentId = flagString(parsed, "student", { required: true });
4366
+ const goalId = flagString(parsed, "goal", { required: true });
4367
+ const toEnd = hasFlag(parsed, "to-end");
4368
+ const position = flagNumber(parsed, "position");
4369
+ if (toEnd === (position !== undefined)) {
4370
+ throw new CliError("invalid_arguments", "Pass exactly one of --to-end or --position <n>.");
4371
+ }
4372
+ const goal = unwrap(await api.client.GET("/tutor/browser/students/{userId}/goals/", {
4373
+ params: { path: { userId: studentId } },
4374
+ })).goals.find((candidate) => candidate.id === goalId);
4375
+ const modules = goal?.osV2?.modules ?? [];
4376
+ const target = modules.find((module) => module.moduleRef.toLocaleLowerCase() === moduleRef.toLocaleLowerCase());
4377
+ if (!target) {
4378
+ throw new CliError("not_found", `Module ${moduleRef} was not found on goal ${goalId}. Known refs: ${modules.map((m) => m.moduleRef).join(", ") || "none"}.`);
4379
+ }
4380
+ // Sparse past the current maximum, so no sibling has to be renumbered.
4381
+ const last = modules.reduce((max, m) => Math.max(max, m.ordinal), 0);
4382
+ const ordinal = toEnd ? last + 1 : position;
4383
+ if (ordinal === target.ordinal) {
4384
+ throw new CliError("already_positioned", `Module ${target.moduleRef} ("${target.title}") is already at position ${ordinal}.`);
4385
+ }
4386
+ const collision = modules.find((m) => m.id !== target.id && m.ordinal === ordinal);
4387
+ const preview = {
4388
+ action: toEnd
4389
+ ? "move a lesson to the end of this kid's curriculum"
4390
+ : "move a lesson to a specific position in this kid's curriculum",
4391
+ target: {
4392
+ studentUserId: studentId,
4393
+ goalId,
4394
+ goalTitle: goal?.title ?? null,
4395
+ moduleRef: target.moduleRef,
4396
+ title: target.title,
4397
+ from: target.ordinal,
4398
+ to: ordinal,
4399
+ completed: target.completedAt !== null,
4400
+ },
4401
+ request: { moduleId: target.id, ordinal },
4402
+ details: {
4403
+ note: "Affects only this student's copy of the curriculum — the goal template and every other kid on it are untouched. Completion state is preserved: an unfinished lesson stays unfinished and is simply served later.",
4404
+ ...(collision
4405
+ ? {
4406
+ warning: `Position ${ordinal} is already held by ${collision.moduleRef} ("${collision.title}"). Both will sit at the same position and the queue will order them by creation date — pass --to-end instead if you just want this one out of the way.`,
4407
+ }
4408
+ : {}),
4409
+ },
4410
+ };
4411
+ return previewBoundWrite(parsed, preview, async () => unwrap(await api.client.PATCH("/tutor/students/{studentId}/goals/{goalId}/modules/{moduleId}", {
4412
+ params: {
4413
+ path: { studentId, goalId, moduleId: target.id },
4414
+ },
4415
+ body: { ordinal },
4416
+ })));
4417
+ }
4093
4418
  if (verb === "create") {
4094
4419
  const requestedStudentId = flagString(parsed, "student");
4095
4420
  const sourceDirectory = flagString(parsed, "source-dir");
@@ -4437,7 +4762,7 @@ export async function executeRecessCommand(argv, options = {}) {
4437
4762
  }
4438
4763
  throw new CliError("invalid_arguments", "Use goals queue get|set.");
4439
4764
  }
4440
- throw new CliError("invalid_arguments", "Use goals list|create|edit|delete|restore|archive|unarchive|complete|undo-completion|queue|files|pdf.");
4765
+ throw new CliError("invalid_arguments", "Use goals list|create|edit|delete|restore|archive|unarchive|complete|undo-completion|move-module|queue|files|pdf.");
4441
4766
  }
4442
4767
  if (noun === "chat-logs") {
4443
4768
  return runChatLogsCommand({ parsed, api, writeCommand });
@@ -4864,6 +5189,156 @@ export async function executeRecessCommand(argv, options = {}) {
4864
5189
  }
4865
5190
  throw new CliError("invalid_arguments", "Use rocky get|set.");
4866
5191
  }
5192
+ if (noun === "tutor-templates") {
5193
+ if (verb === "list") {
5194
+ const grade = flagNumber(parsed, "grade");
5195
+ return unwrap(await api.client.GET("/tutor-templates/", {
5196
+ params: {
5197
+ query: {
5198
+ q: flagString(parsed, "q"),
5199
+ subject: flagString(parsed, "subject"),
5200
+ ...(grade === undefined ? {} : { grade }),
5201
+ },
5202
+ },
5203
+ }));
5204
+ }
5205
+ if (verb === "get") {
5206
+ const slug = positional(parsed, 2, "template slug");
5207
+ return unwrap(await api.client.GET("/tutor-templates/{slug}", {
5208
+ params: { path: { slug } },
5209
+ }));
5210
+ }
5211
+ if (verb === "assign" || verb === "unassign") {
5212
+ const slug = positional(parsed, 2, "template slug");
5213
+ const studentId = flagString(parsed, "student");
5214
+ const body = studentId ? { studentId } : {};
5215
+ const template = unwrap(await api.client.GET("/tutor-templates/{slug}", {
5216
+ params: { path: { slug } },
5217
+ }));
5218
+ return writeCommand(parsed, {
5219
+ action: verb === "assign"
5220
+ ? "add a tutor template's subject block to a kid's plan"
5221
+ : "remove a tutor template's subject block from a kid's plan",
5222
+ target: { slug, studentUserId: studentId ?? "the signed-in kid" },
5223
+ request: body,
5224
+ details: {
5225
+ title: template.title,
5226
+ cadence: template.cadence,
5227
+ consequence: verb === "assign"
5228
+ ? "Appends `### <title> — <cadence>` to rocky-v3/plan.md, rebuilds Today, and writes an ACTIVE assignment row."
5229
+ : "Deletes that subject block from rocky-v3/plan.md and marks the assignment REMOVED.",
5230
+ },
5231
+ }, async () => verb === "assign"
5232
+ ? unwrap(await api.client.POST("/tutor-templates/{slug}/assign", {
5233
+ params: { path: { slug } },
5234
+ body,
5235
+ }))
5236
+ : unwrap(await api.client.POST("/tutor-templates/{slug}/unassign", {
5237
+ params: { path: { slug } },
5238
+ body,
5239
+ })));
5240
+ }
5241
+ if (verb === "create" || verb === "update") {
5242
+ const bodyFile = flagString(parsed, "body-file", {
5243
+ required: verb === "create",
5244
+ });
5245
+ const lessonsDir = flagString(parsed, "lessons-dir");
5246
+ const visibility = flagString(parsed, "visibility");
5247
+ const emoji = flagString(parsed, "emoji");
5248
+ const gradeMin = flagNumber(parsed, "grade-min");
5249
+ const gradeMax = flagNumber(parsed, "grade-max");
5250
+ const shared = {
5251
+ ...(flagString(parsed, "title") === undefined
5252
+ ? {}
5253
+ : { title: flagString(parsed, "title") }),
5254
+ ...(flagString(parsed, "summary") === undefined
5255
+ ? {}
5256
+ : { summary: flagString(parsed, "summary") }),
5257
+ ...(flagString(parsed, "subject") === undefined
5258
+ ? {}
5259
+ : { subject: flagString(parsed, "subject") }),
5260
+ ...(flagString(parsed, "cadence") === undefined
5261
+ ? {}
5262
+ : { cadence: flagString(parsed, "cadence") }),
5263
+ ...(emoji === undefined ? {} : { emoji }),
5264
+ ...(gradeMin === undefined ? {} : { gradeMin }),
5265
+ ...(gradeMax === undefined ? {} : { gradeMax }),
5266
+ ...(visibility === undefined
5267
+ ? {}
5268
+ : {
5269
+ visibility: assertChoice(visibility, ["PUBLIC", "STAFF"], "--visibility"),
5270
+ }),
5271
+ ...(bodyFile === undefined
5272
+ ? {}
5273
+ : { body: await readTutorTemplateBody(bodyFile) }),
5274
+ ...(lessonsDir === undefined
5275
+ ? {}
5276
+ : { lessons: await readTutorTemplateLessons(lessonsDir) }),
5277
+ };
5278
+ if (verb === "create") {
5279
+ const missing = ["title", "summary", "subject", "cadence"].filter((name) => !(name in shared));
5280
+ if (missing.length > 0) {
5281
+ throw new CliError("invalid_arguments", `tutor-templates create requires ${missing
5282
+ .map((name) => `--${name}`)
5283
+ .join(", ")}.`);
5284
+ }
5285
+ const slug = flagString(parsed, "slug", { required: true });
5286
+ const body = { slug, ...shared };
5287
+ return writeCommand(parsed, {
5288
+ action: "create a tutor template in the subject catalog",
5289
+ target: { slug },
5290
+ request: {
5291
+ ...body,
5292
+ bodyFile: bodyFile ? path.resolve(bodyFile) : null,
5293
+ lessonsDir: lessonsDir ? path.resolve(lessonsDir) : null,
5294
+ },
5295
+ details: {
5296
+ lessonNames: Object.keys(shared.lessons ?? {}),
5297
+ renamingNote: "`title` is the `### <heading>` join key in every kid's plan.md — renaming later orphans existing plans.",
5298
+ },
5299
+ }, async () => unwrap(await api.client.POST("/tutor-templates/", { body })));
5300
+ }
5301
+ const slug = positional(parsed, 2, "template slug");
5302
+ const body = shared;
5303
+ if (Object.keys(body).length === 0) {
5304
+ throw new CliError("invalid_arguments", "tutor-templates update needs at least one field to change.");
5305
+ }
5306
+ return writeCommand(parsed, {
5307
+ action: "update a tutor template in the subject catalog",
5308
+ target: { slug },
5309
+ request: {
5310
+ ...body,
5311
+ bodyFile: bodyFile ? path.resolve(bodyFile) : null,
5312
+ lessonsDir: lessonsDir ? path.resolve(lessonsDir) : null,
5313
+ },
5314
+ details: {
5315
+ fields: Object.keys(body),
5316
+ renamingNote: "Changing `title` orphans the `### <heading>` in every plan.md already carrying this template.",
5317
+ },
5318
+ }, async () => unwrap(await api.client.PATCH("/tutor-templates/{slug}", {
5319
+ params: { path: { slug } },
5320
+ body,
5321
+ })));
5322
+ }
5323
+ if (verb === "delete") {
5324
+ const slug = positional(parsed, 2, "template slug");
5325
+ const template = unwrap(await api.client.GET("/tutor-templates/{slug}", {
5326
+ params: { path: { slug } },
5327
+ }));
5328
+ return writeCommand(parsed, {
5329
+ action: "soft-delete a tutor template from the subject catalog",
5330
+ target: { slug },
5331
+ request: {},
5332
+ details: {
5333
+ title: template.title,
5334
+ consequence: "The row is soft-deleted; subject blocks already written into kids' plan.md stay where they are.",
5335
+ },
5336
+ }, async () => unwrap(await api.client.DELETE("/tutor-templates/{slug}", {
5337
+ params: { path: { slug } },
5338
+ })));
5339
+ }
5340
+ throw new CliError("invalid_arguments", "Use tutor-templates list|get|create|update|delete|assign|unassign.");
5341
+ }
4867
5342
  if (noun === "goals" && verb === "pdf") {
4868
5343
  const action = positional(parsed, 2, "goals pdf action");
4869
5344
  if (action !== "upload") {
@@ -5085,7 +5560,40 @@ export async function executeRecessCommand(argv, options = {}) {
5085
5560
  required: true,
5086
5561
  });
5087
5562
  const checkout = await readRecessGitMetadata(sourceDirectory);
5088
- const local = await readGoalWorkspaceGitChanges(checkout.root, checkout.metadata.upstreamCommit);
5563
+ const isGoal = checkout.metadata.target.kind === "goal";
5564
+ const session = isGoal
5565
+ ? unwrap(await api.client.GET("/auth/admin-cli/session/"))
5566
+ : null;
5567
+ const actionDescription = `push committed Git changes to a student's Mesa ${checkout.metadata.target.kind} workspace`;
5568
+ const boundTarget = session
5569
+ ? {
5570
+ studentId: checkout.metadata.studentId,
5571
+ workspace: checkout.metadata.target,
5572
+ actorId: session.user.id,
5573
+ apiOrigin: api.config.apiOrigin,
5574
+ }
5575
+ : null;
5576
+ const receipt = isGoal ? await readExecutedPreview(parsed) : null;
5577
+ if (receipt &&
5578
+ (receipt.action !== actionDescription ||
5579
+ JSON.stringify(receipt.target) !== JSON.stringify(boundTarget) ||
5580
+ receipt.request.sourceDirectory !== checkout.root)) {
5581
+ throw new CliError("approval_mismatch", "The approved push belongs to a different checkout, account, or API.");
5582
+ }
5583
+ const localBaseCommit = receipt
5584
+ ? String(receipt.request.localBaseCommit)
5585
+ : checkout.metadata.upstreamCommit;
5586
+ const mesaBaseChangeId = receipt
5587
+ ? String(receipt.request.mesaBaseChangeId)
5588
+ : checkout.metadata.changeId;
5589
+ const local = await readGoalWorkspaceGitChanges(checkout.root, localBaseCommit);
5590
+ if (receipt &&
5591
+ ((checkout.metadata.upstreamCommit !== localBaseCommit &&
5592
+ checkout.metadata.upstreamCommit !== local.head) ||
5593
+ (checkout.metadata.upstreamCommit === localBaseCommit &&
5594
+ checkout.metadata.changeId !== mesaBaseChangeId))) {
5595
+ throw new CliError("approval_mismatch", "The checkout's upstream metadata moved outside the approved push.");
5596
+ }
5089
5597
  const defaultMessage = local.commits.length === 1
5090
5598
  ? local.commits[0].subject
5091
5599
  : `recess-cli: push ${local.commits.length} commits (${local.commits.at(-1).subject})`;
@@ -5101,41 +5609,61 @@ export async function executeRecessCommand(argv, options = {}) {
5101
5609
  content: change.content,
5102
5610
  contentEncoding: change.contentEncoding,
5103
5611
  });
5612
+ const assignedEdits = hasFlag(parsed, "allow-assigned-edits")
5613
+ ? { allowAssignedEdits: true }
5614
+ : {};
5615
+ const stateModification = hasFlag(parsed, "allow-state-modification")
5616
+ ? { allowStateModification: true }
5617
+ : {};
5104
5618
  const body = {
5619
+ ...stateModification,
5620
+ ...assignedEdits,
5105
5621
  studentUserId: checkout.metadata.studentId,
5106
5622
  target: checkout.metadata.target,
5107
5623
  message,
5108
5624
  files,
5109
- expectedChangeId: checkout.metadata.changeId,
5625
+ expectedChangeId: mesaBaseChangeId,
5110
5626
  };
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") {
5627
+ const preflight = receipt
5628
+ ? null
5629
+ : unwrap(await api.client.POST("/tutor/browser/mesa/workspace-files", {
5630
+ body: { ...body, dryRun: true },
5631
+ }));
5632
+ if (preflight && preflight.action !== "preview_workspace_files_write") {
5115
5633
  throw new CliError("unexpected_response", "Goal workspace push did not return a preview; nothing was written.");
5116
5634
  }
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
- },
5635
+ const request = {
5636
+ ...stateModification,
5637
+ ...assignedEdits,
5638
+ sourceDirectory: checkout.root,
5639
+ mesaBaseChangeId,
5640
+ localBaseCommit,
5641
+ localHeadCommit: local.head,
5642
+ commits: local.commits,
5643
+ message,
5644
+ changes: local.changes.map((change) => change.action === "delete"
5645
+ ? change
5646
+ : {
5647
+ path: change.path,
5648
+ action: change.action,
5649
+ sizeBytes: change.sizeBytes,
5650
+ sha256: change.sha256,
5651
+ }),
5652
+ sizeBytes: local.sizeBytes,
5653
+ };
5654
+ if (receipt &&
5655
+ JSON.stringify(receipt.request) !== JSON.stringify(request)) {
5656
+ throw new CliError("approval_mismatch", "The command or committed file bytes differ from the approved push.");
5657
+ }
5658
+ const preview = receipt ?? {
5659
+ action: actionDescription,
5660
+ target: boundTarget ?? preflight.target,
5661
+ request,
5137
5662
  details: {
5663
+ stateModificationPaths: preflight.stateModificationPaths ?? [],
5664
+ warnings: preflight.warnings ?? [],
5138
5665
  currentChangeId: preflight.currentChangeId,
5666
+ resolvedTarget: preflight.target,
5139
5667
  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
5668
  },
5141
5669
  };
@@ -5146,6 +5674,11 @@ export async function executeRecessCommand(argv, options = {}) {
5146
5674
  if (result.action !== "write_workspace_files") {
5147
5675
  throw new CliError("unexpected_response", "Goal workspace push returned an unexpected response.");
5148
5676
  }
5677
+ if (receipt &&
5678
+ checkout.metadata.upstreamCommit === local.head &&
5679
+ checkout.metadata.changeId !== result.changeId) {
5680
+ throw new CliError("approval_mismatch", "The acknowledged checkout revision does not match this recovered push. Its local metadata was preserved.");
5681
+ }
5149
5682
  await writeRecessGitMetadata(checkout.root, {
5150
5683
  ...checkout.metadata,
5151
5684
  changeId: result.changeId,
@@ -5158,7 +5691,7 @@ export async function executeRecessCommand(argv, options = {}) {
5158
5691
  upstreamCommit: local.head,
5159
5692
  },
5160
5693
  };
5161
- });
5694
+ }, isGoal);
5162
5695
  }
5163
5696
  if (action === "write") {
5164
5697
  const studentId = flagString(parsed, "student", { required: true });
@@ -5173,31 +5706,65 @@ export async function executeRecessCommand(argv, options = {}) {
5173
5706
  : { kind: "draft", draftSlug: draftSlug };
5174
5707
  const message = flagString(parsed, "message") ??
5175
5708
  `recess-cli: write ${input.files.length} workspace file${input.files.length === 1 ? "" : "s"}`;
5709
+ const assignedEdits = hasFlag(parsed, "allow-assigned-edits")
5710
+ ? { allowAssignedEdits: true }
5711
+ : {};
5712
+ const stateModification = hasFlag(parsed, "allow-state-modification")
5713
+ ? { allowStateModification: true }
5714
+ : {};
5176
5715
  const body = {
5716
+ ...stateModification,
5717
+ ...assignedEdits,
5177
5718
  studentUserId: studentId,
5178
5719
  target,
5179
5720
  message,
5180
5721
  files: input.files,
5181
5722
  };
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") {
5723
+ const session = goalId
5724
+ ? unwrap(await api.client.GET("/auth/admin-cli/session/"))
5725
+ : null;
5726
+ const request = {
5727
+ ...stateModification,
5728
+ ...assignedEdits,
5729
+ source: input.source,
5730
+ sourceSha256: input.sha256,
5731
+ fileCount: input.files.length,
5732
+ sizeBytes: input.sizeBytes,
5733
+ message,
5734
+ };
5735
+ const boundTarget = session
5736
+ ? {
5737
+ studentId,
5738
+ goalId: goalId,
5739
+ actorId: session.user.id,
5740
+ apiOrigin: api.config.apiOrigin,
5741
+ }
5742
+ : null;
5743
+ const receipt = boundTarget
5744
+ ? await recoverExecutedPreview(parsed, {
5745
+ action: "batch-upsert files in a student's goal workspace",
5746
+ target: boundTarget,
5747
+ request,
5748
+ })
5749
+ : null;
5750
+ const preflight = receipt
5751
+ ? null
5752
+ : unwrap(await api.client.POST("/tutor/browser/mesa/workspace-files", {
5753
+ body: { ...body, dryRun: true },
5754
+ }));
5755
+ if (preflight && preflight.action !== "preview_workspace_files_write") {
5186
5756
  throw new CliError("unexpected_response", "Goal workspace write did not return a preview; nothing was written.");
5187
5757
  }
5188
- const preview = {
5758
+ const preview = receipt ?? {
5189
5759
  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
- },
5760
+ target: boundTarget ?? preflight.target,
5761
+ request,
5198
5762
  details: {
5763
+ stateModificationPaths: preflight.stateModificationPaths ?? [],
5764
+ warnings: preflight.warnings ?? [],
5199
5765
  currentChangeId: preflight.currentChangeId,
5200
5766
  files: preflight.files,
5767
+ resolvedTarget: preflight.target,
5201
5768
  note: target.kind === "draft"
5202
5769
  ? "This writes a complete authoring tree under drafts/<slug>/workspace. Capture it only after the server's goal-workspace validation passes."
5203
5770
  : "This directly edits the live goal workspace, including modules/ and state/. The write remains bound to the previewed workspace revision.",
@@ -5207,9 +5774,9 @@ export async function executeRecessCommand(argv, options = {}) {
5207
5774
  body: {
5208
5775
  ...body,
5209
5776
  dryRun: false,
5210
- expectedChangeId: preflight.currentChangeId,
5777
+ expectedChangeId: String(preview.details.currentChangeId),
5211
5778
  },
5212
- })));
5779
+ })), Boolean(goalId));
5213
5780
  }
5214
5781
  throw new CliError("invalid_arguments", "Use goals files list|read|init|checkout|push|write.");
5215
5782
  }