recess-cli 1.2.0 → 1.3.2
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 +10 -1
- package/dist/args.js +37 -2
- package/dist/cli.js +293 -6
- package/package.json +1 -1
- package/skill/recess-cli/SKILL.md +30 -16
- package/skill/recess-cli/reference/goal-authoring.md +37 -6
package/README.md
CHANGED
|
@@ -25,7 +25,7 @@ pnpm --dir apps/admin-cli run client:generate
|
|
|
25
25
|
pnpm --dir apps/admin-cli run install-persistent
|
|
26
26
|
```
|
|
27
27
|
|
|
28
|
-
`install-persistent` copies a self-contained build (non-test `dist/` JS + the `openapi-fetch` runtime dep) to `~/.recess-cli/cli/` and points `~/.local/bin/recess` at it — the install keeps working after the checkout or worktree it was built from is deleted. Use it on any machine that operates on production. `install-local` instead symlinks `~/.local/bin/recess` straight to this checkout's `dist/index.js` so rebuilds are picked up live — use it only while actively developing the CLI, and expect the link to die with the worktree. Both targets install the bundled skill for Codex at `${CODEX_HOME:-~/.codex}/skills/recess-cli` and Claude at `${CLAUDE_CONFIG_DIR:-~/.claude}/skills/recess-cli`. The skill is a multi-file bundle: `skill/recess-cli/SKILL.md` carries the safety model, auth troubleshooting, JSON contract, and command quick reference, and routes to the deep workflow playbooks in `skill/recess-cli/reference/` (billing, MAP scores, payouts, class ops).
|
|
28
|
+
`install-persistent` copies a self-contained build (non-test `dist/` JS + the `openapi-fetch` runtime dep) to `~/.recess-cli/cli/` and points `~/.local/bin/recess` at it — the install keeps working after the checkout or worktree it was built from is deleted. Use it on any machine that operates on production. `install-local` instead symlinks `~/.local/bin/recess` straight to this checkout's `dist/index.js` so rebuilds are picked up live — use it only while actively developing the CLI, and expect the link to die with the worktree. Both targets install the bundled skill for Codex at `${CODEX_HOME:-~/.codex}/skills/recess-cli` and Claude at `${CLAUDE_CONFIG_DIR:-~/.claude}/skills/recess-cli`. The skill is a multi-file bundle: `skill/recess-cli/SKILL.md` carries the safety model, auth troubleshooting, JSON contract, and command quick reference, and routes to the deep workflow playbooks in `skill/recess-cli/reference/` (billing, MAP scores, payouts, class ops, onboarding, and goal authoring).
|
|
29
29
|
|
|
30
30
|
## One-time SSO setup
|
|
31
31
|
|
|
@@ -96,10 +96,13 @@ MAP uploads accept one PDF up to 15 MB. The preview includes the resolved path,
|
|
|
96
96
|
|
|
97
97
|
```bash
|
|
98
98
|
recess --json skills get os-v2-goal-template-builder --all-references
|
|
99
|
+
recess --json content-library search "fractions through visual puzzles" --limit 8
|
|
99
100
|
recess --json goal-templates validate-spec --file ./template.json # iterate; writes nothing
|
|
100
101
|
recess --json goal-templates create --file ./template.json # preview, exit 2
|
|
101
102
|
recess --json goal-templates create --file ./template.json --confirm
|
|
102
103
|
recess --json goal-templates patch-spec <id-or-slug> --expected-version 7 --patches-file ./patches.json
|
|
104
|
+
recess --json mesa files write --student <kid-id> --draft <draft-slug> --source-dir ./workspace
|
|
105
|
+
recess --json goal-templates capture-snapshot <id-or-slug> --source-draft <draft-slug> --student <kid-id> --dry-run
|
|
103
106
|
recess --json goal-templates apply <id-or-slug> --answers-file ./answers.json --dry-run
|
|
104
107
|
recess --json goals create --student <kid-id> --title "..." --description-file ./goal.md
|
|
105
108
|
recess --json mesa files list --student <kid-id> --goal <goal-id>
|
|
@@ -137,4 +140,10 @@ previews the per-student outcome. `set-metadata` and `delete` require `--expecte
|
|
|
137
140
|
it always runs the backend's guarded preview first and requires the preview's exact loss token in
|
|
138
141
|
addition to `--confirm` when protected template data would be removed.
|
|
139
142
|
|
|
143
|
+
MODULE_BACKED BLUEPRINT content is authored as a local workspace tree, batch-upserted to a named
|
|
144
|
+
Mesa draft, then attached with `capture-snapshot`. Both mutations run server previews before the
|
|
145
|
+
confirmation gate. Mesa upserts are compare-and-set against the previewed repo change; capture is
|
|
146
|
+
fenced to both that source change and the template version. Direct live-goal `modules/` and
|
|
147
|
+
`state/` writes are blocked because those files have database projections.
|
|
148
|
+
|
|
140
149
|
See `recess --help` for the complete command surface. The raw escape hatch is intentionally read-only: `recess --json request get /path`.
|
package/dist/args.js
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
import { CliError } from "./errors.js";
|
|
2
|
+
/**
|
|
3
|
+
* Flags that may legitimately appear more than once. Reading one through
|
|
4
|
+
* `flagString` still yields the LAST value, which is why every consumer of a
|
|
5
|
+
* repeatable flag must use `flagList` instead.
|
|
6
|
+
*/
|
|
7
|
+
export const REPEATABLE_FLAGS = new Set(["kid", "unassign"]);
|
|
2
8
|
const BOOLEAN_FLAGS = new Set([
|
|
3
9
|
"all-references",
|
|
4
10
|
"allow-strand",
|
|
@@ -24,6 +30,16 @@ const BOOLEAN_FLAGS = new Set([
|
|
|
24
30
|
export function parseArgs(args) {
|
|
25
31
|
const positionals = [];
|
|
26
32
|
const flags = new Map();
|
|
33
|
+
const repeated = new Map();
|
|
34
|
+
const record = (name, value) => {
|
|
35
|
+
if (!REPEATABLE_FLAGS.has(name))
|
|
36
|
+
return;
|
|
37
|
+
const existing = repeated.get(name);
|
|
38
|
+
if (existing)
|
|
39
|
+
existing.push(value);
|
|
40
|
+
else
|
|
41
|
+
repeated.set(name, [value]);
|
|
42
|
+
};
|
|
27
43
|
for (let index = 0; index < args.length; index += 1) {
|
|
28
44
|
const value = args[index];
|
|
29
45
|
if (!value.startsWith("--")) {
|
|
@@ -32,7 +48,10 @@ export function parseArgs(args) {
|
|
|
32
48
|
}
|
|
33
49
|
const equalsAt = value.indexOf("=");
|
|
34
50
|
if (equalsAt > 2) {
|
|
35
|
-
|
|
51
|
+
const name = value.slice(2, equalsAt);
|
|
52
|
+
const flagValue = value.slice(equalsAt + 1);
|
|
53
|
+
flags.set(name, flagValue);
|
|
54
|
+
record(name, flagValue);
|
|
36
55
|
continue;
|
|
37
56
|
}
|
|
38
57
|
const name = value.slice(2);
|
|
@@ -43,13 +62,29 @@ export function parseArgs(args) {
|
|
|
43
62
|
const next = args[index + 1];
|
|
44
63
|
if (next && !next.startsWith("--")) {
|
|
45
64
|
flags.set(name, next);
|
|
65
|
+
record(name, next);
|
|
46
66
|
index += 1;
|
|
47
67
|
}
|
|
48
68
|
else {
|
|
49
69
|
flags.set(name, true);
|
|
50
70
|
}
|
|
51
71
|
}
|
|
52
|
-
return { positionals, flags };
|
|
72
|
+
return { positionals, flags, repeated };
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Every value given for a repeatable flag, in order.
|
|
76
|
+
*
|
|
77
|
+
* Returns `[]` when the flag is absent, so a caller distinguishes "none given"
|
|
78
|
+
* from "given empty" by checking length rather than by a null dance.
|
|
79
|
+
*/
|
|
80
|
+
export function flagList(parsed, name) {
|
|
81
|
+
if (!REPEATABLE_FLAGS.has(name)) {
|
|
82
|
+
// A wiring defect, not a user error: reading a non-repeatable flag as a
|
|
83
|
+
// list would silently return [] however many times it was passed.
|
|
84
|
+
throw new CliError("invalid_arguments", `--${name} is not declared repeatable; add it to REPEATABLE_FLAGS.`);
|
|
85
|
+
}
|
|
86
|
+
const values = parsed.repeated.get(name) ?? [];
|
|
87
|
+
return values.map((value) => value.trim()).filter(Boolean);
|
|
53
88
|
}
|
|
54
89
|
export function flagString(parsed, name, options = {}) {
|
|
55
90
|
const value = parsed.flags.get(name);
|
package/dist/cli.js
CHANGED
|
@@ -2,7 +2,7 @@ import { createHash } from "node:crypto";
|
|
|
2
2
|
import fs from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { RecessAdminApi, unwrap } from "./api.js";
|
|
5
|
-
import { flagNumber, flagString, hasFlag, parseArgs, } from "./args.js";
|
|
5
|
+
import { flagList, flagNumber, flagString, hasFlag, parseArgs, } from "./args.js";
|
|
6
6
|
import { login, pollDeviceAuth, requestDeviceAuth } from "./auth.js";
|
|
7
7
|
import { clearStoredSession, resolveConfig, } from "./config.js";
|
|
8
8
|
import { CliError } from "./errors.js";
|
|
@@ -38,6 +38,9 @@ Usage:
|
|
|
38
38
|
recess [--json] invoices refund --invoice <id> --line-item <id>
|
|
39
39
|
--method refund|credit|tokens [--full | --amount-cents N]
|
|
40
40
|
[--who-pays guide|recess] [--reason TEXT] [--confirm]
|
|
41
|
+
recess [--json] applications enroll <application-id> --quote <quote-id>
|
|
42
|
+
--school <institution-slug> --kid "<quoteLineId>:<firstName>[:<age>]" (repeat per line)
|
|
43
|
+
[--family <family-id>] [--unassign <kid-id>] (repeat) [--note TEXT] [--confirm]
|
|
41
44
|
recess [--json] cohorts search <query>
|
|
42
45
|
recess [--json] enrollments create --user <kid-id> --cohort <id>
|
|
43
46
|
[--first-charge-at ISO_DATETIME] [--send-email] [--force] [--confirm]
|
|
@@ -110,6 +113,7 @@ Usage:
|
|
|
110
113
|
recess [--json] village models remove <placement-id> [--world village-1] [--confirm]
|
|
111
114
|
recess [--json] village render --min-x N --min-z N --max-x N --max-z N
|
|
112
115
|
[--world village-1]
|
|
116
|
+
recess [--json] content-library search <query> [--limit 8]
|
|
113
117
|
recess [--json] skills list [--query TEXT] [--category TEXT]
|
|
114
118
|
recess [--json] skills get <skill-name> [--reference NAME | --all-references]
|
|
115
119
|
[--refresh]
|
|
@@ -125,9 +129,12 @@ Usage:
|
|
|
125
129
|
recess [--json] goal-templates set-metadata <template-id> --expected-version N
|
|
126
130
|
[--title TEXT] [--description TEXT] [--emoji X] [--category TEXT] [--tags A,B]
|
|
127
131
|
[--sort-order N] [--kind SIMPLE|BLUEPRINT] [--agent-instructions-file <path>]
|
|
128
|
-
[--confirm]
|
|
132
|
+
[--output-template-file <path>] [--confirm]
|
|
129
133
|
recess [--json] goal-templates delete <template-id> --expected-version N [--confirm]
|
|
130
134
|
recess [--json] goal-templates snapshot-files <template-id> [--path P]
|
|
135
|
+
recess [--json] goal-templates capture-snapshot <template-id|slug>
|
|
136
|
+
(--source-goal <goal-id> | --source-draft <draft-slug> --student <kid-id>)
|
|
137
|
+
[--dry-run] [--confirm]
|
|
131
138
|
recess [--json] goal-templates apply <template-id> --answers-file <path>
|
|
132
139
|
[--dry-run] [--confirm]
|
|
133
140
|
recess [--json] goal-templates apply-starter <template-id> --student <kid-id>
|
|
@@ -138,6 +145,10 @@ Usage:
|
|
|
138
145
|
[--schedule TEXT] [--confirm]
|
|
139
146
|
recess [--json] mesa files list --student <kid-id> --goal <goal-id>
|
|
140
147
|
recess [--json] mesa files read --student <kid-id> --goal <goal-id> --path P
|
|
148
|
+
recess [--json] mesa files write --student <kid-id>
|
|
149
|
+
(--goal <goal-id> | --draft <draft-slug>)
|
|
150
|
+
(--source-dir <local-dir> | --source-file <local-file> --path P)
|
|
151
|
+
[--message TEXT] [--confirm]
|
|
141
152
|
|
|
142
153
|
Authoring notes: "skills" serves the in-product tutor skills (the PRIVATE
|
|
143
154
|
packages/skills submodule) read-only over your admin session — they are never
|
|
@@ -298,6 +309,83 @@ async function writeCommand(parsed, preview, execute) {
|
|
|
298
309
|
requireConfirmation(hasFlag(parsed, "confirm"), preview);
|
|
299
310
|
return execute();
|
|
300
311
|
}
|
|
312
|
+
const MESA_WRITE_MAX_FILES = 1_000;
|
|
313
|
+
const MESA_WRITE_MAX_TOTAL_BYTES = 20 * 1024 * 1024;
|
|
314
|
+
async function readMesaWriteSource(parsed) {
|
|
315
|
+
const sourceDirectory = flagString(parsed, "source-dir");
|
|
316
|
+
const sourceFile = flagString(parsed, "source-file");
|
|
317
|
+
if (Boolean(sourceDirectory) === Boolean(sourceFile)) {
|
|
318
|
+
throw new CliError("invalid_arguments", "Pass exactly one of --source-dir or --source-file.");
|
|
319
|
+
}
|
|
320
|
+
const files = [];
|
|
321
|
+
const hash = createHash("sha256");
|
|
322
|
+
let sizeBytes = 0;
|
|
323
|
+
let source;
|
|
324
|
+
const addFile = async (absolutePath, workspacePath) => {
|
|
325
|
+
if (files.length >= MESA_WRITE_MAX_FILES) {
|
|
326
|
+
throw new CliError("invalid_arguments", `Mesa workspace writes are limited to ${MESA_WRITE_MAX_FILES} files.`);
|
|
327
|
+
}
|
|
328
|
+
const fileStat = await fs.stat(absolutePath);
|
|
329
|
+
if (sizeBytes + fileStat.size > MESA_WRITE_MAX_TOTAL_BYTES) {
|
|
330
|
+
throw new CliError("invalid_arguments", `Mesa workspace writes are limited to ${MESA_WRITE_MAX_TOTAL_BYTES} decoded bytes.`);
|
|
331
|
+
}
|
|
332
|
+
const bytes = await fs.readFile(absolutePath);
|
|
333
|
+
sizeBytes += bytes.byteLength;
|
|
334
|
+
files.push({
|
|
335
|
+
path: workspacePath.replaceAll(path.sep, "/"),
|
|
336
|
+
content: bytes.toString("base64"),
|
|
337
|
+
contentEncoding: "base64",
|
|
338
|
+
});
|
|
339
|
+
};
|
|
340
|
+
if (sourceDirectory) {
|
|
341
|
+
const root = path.resolve(sourceDirectory);
|
|
342
|
+
const rootStat = await fs.lstat(root).catch(() => null);
|
|
343
|
+
if (!rootStat?.isDirectory() || rootStat.isSymbolicLink()) {
|
|
344
|
+
throw new CliError("invalid_arguments", `--source-dir is not a directory: ${root}`);
|
|
345
|
+
}
|
|
346
|
+
source = { kind: "directory", absolutePath: root };
|
|
347
|
+
const walk = async (directory, relativeRoot = "") => {
|
|
348
|
+
const entries = await fs.readdir(directory, { withFileTypes: true });
|
|
349
|
+
entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
350
|
+
for (const entry of entries) {
|
|
351
|
+
const relativePath = relativeRoot
|
|
352
|
+
? path.join(relativeRoot, entry.name)
|
|
353
|
+
: entry.name;
|
|
354
|
+
const absolutePath = path.join(directory, entry.name);
|
|
355
|
+
if (entry.isSymbolicLink()) {
|
|
356
|
+
throw new CliError("invalid_arguments", `Refusing symbolic link in --source-dir: ${absolutePath}`);
|
|
357
|
+
}
|
|
358
|
+
if (entry.isDirectory()) {
|
|
359
|
+
await walk(absolutePath, relativePath);
|
|
360
|
+
}
|
|
361
|
+
else if (entry.isFile()) {
|
|
362
|
+
await addFile(absolutePath, relativePath);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
};
|
|
366
|
+
await walk(root);
|
|
367
|
+
}
|
|
368
|
+
else {
|
|
369
|
+
const absolutePath = path.resolve(sourceFile);
|
|
370
|
+
const fileStat = await fs.lstat(absolutePath).catch(() => null);
|
|
371
|
+
if (!fileStat?.isFile() || fileStat.isSymbolicLink()) {
|
|
372
|
+
throw new CliError("invalid_arguments", `--source-file is not a file: ${absolutePath}`);
|
|
373
|
+
}
|
|
374
|
+
const workspacePath = flagString(parsed, "path", { required: true });
|
|
375
|
+
source = { kind: "file", absolutePath };
|
|
376
|
+
await addFile(absolutePath, workspacePath);
|
|
377
|
+
}
|
|
378
|
+
if (files.length === 0) {
|
|
379
|
+
throw new CliError("invalid_arguments", "The Mesa write source contains no files.");
|
|
380
|
+
}
|
|
381
|
+
for (const file of files) {
|
|
382
|
+
hash.update(file.path);
|
|
383
|
+
hash.update("\0");
|
|
384
|
+
hash.update(file.content, "base64");
|
|
385
|
+
hash.update("\0");
|
|
386
|
+
}
|
|
387
|
+
return { files, source, sizeBytes, sha256: hash.digest("hex") };
|
|
388
|
+
}
|
|
301
389
|
function assertChoice(value, choices, label) {
|
|
302
390
|
if (!choices.includes(value)) {
|
|
303
391
|
throw new CliError("invalid_arguments", `${label} must be one of: ${choices.join(", ")}.`);
|
|
@@ -625,6 +713,34 @@ function flagStatusList(parsed, name, choices) {
|
|
|
625
713
|
}
|
|
626
714
|
return values.map((value) => assertChoice(value, choices, `--${name}`));
|
|
627
715
|
}
|
|
716
|
+
/**
|
|
717
|
+
* Parse one `--kid` spec into an enroll roster entry.
|
|
718
|
+
*
|
|
719
|
+
* Shape: `<quoteLineId>:<firstName>[:<age>]` — the quote line FIRST, because
|
|
720
|
+
* the line id is the identity the enrollment matches on and the name is only a
|
|
721
|
+
* label. A staffer typing these reads them off the quote, in order.
|
|
722
|
+
*
|
|
723
|
+
* ⚠️ THE NAME MAY CONTAIN NOTHING SURPRISING, but an age must be a real number:
|
|
724
|
+
* age decides `birthdate` at creation AND rides in the frozen payment snapshot
|
|
725
|
+
* that a later takeover is compared against, so a silently-dropped or
|
|
726
|
+
* mistyped age is a money-adjacent error, not a cosmetic one. Absent is fine
|
|
727
|
+
* (the server treats it as "not stated"); unparseable is refused here.
|
|
728
|
+
*/
|
|
729
|
+
function parseEnrollKidSpec(raw) {
|
|
730
|
+
const parts = raw.split(":").map((part) => part.trim());
|
|
731
|
+
const [quoteLineId, firstName, ageRaw, ...extra] = parts;
|
|
732
|
+
if (!quoteLineId || !firstName || extra.length > 0) {
|
|
733
|
+
throw new CliError("invalid_arguments", `--kid must be "<quoteLineId>:<firstName>[:<age>]"; got "${raw}".`);
|
|
734
|
+
}
|
|
735
|
+
if (ageRaw === undefined || ageRaw === "") {
|
|
736
|
+
return { quoteLineId, firstName };
|
|
737
|
+
}
|
|
738
|
+
const age = Number(ageRaw);
|
|
739
|
+
if (!Number.isInteger(age) || age < 1 || age > 25) {
|
|
740
|
+
throw new CliError("invalid_arguments", `--kid age must be a whole number from 1 to 25; got "${ageRaw}".`);
|
|
741
|
+
}
|
|
742
|
+
return { quoteLineId, firstName, age };
|
|
743
|
+
}
|
|
628
744
|
function flagCents(parsed, name, options = {}) {
|
|
629
745
|
const value = flagNumber(parsed, name);
|
|
630
746
|
if (value === undefined) {
|
|
@@ -1077,6 +1193,48 @@ export async function runCommand(argv) {
|
|
|
1077
1193
|
params: { query: { subscriptionId } },
|
|
1078
1194
|
}));
|
|
1079
1195
|
}
|
|
1196
|
+
if (noun === "applications" && verb === "enroll") {
|
|
1197
|
+
const applicationId = positional(parsed, 2, "application ID");
|
|
1198
|
+
const quoteId = flagString(parsed, "quote", { required: true });
|
|
1199
|
+
const institutionSlug = flagString(parsed, "school", { required: true });
|
|
1200
|
+
const familyId = flagString(parsed, "family");
|
|
1201
|
+
const note = flagString(parsed, "note");
|
|
1202
|
+
// Repeatable --kid, one per PRICED LINE on the quote. The server refuses a
|
|
1203
|
+
// partial roster (every priced student must be enrolled), so this is
|
|
1204
|
+
// deliberately not a convenience list — it is the whole quote, echoed back.
|
|
1205
|
+
const kidSpecs = flagList(parsed, "kid");
|
|
1206
|
+
if (kidSpecs.length === 0) {
|
|
1207
|
+
throw new CliError("invalid_arguments", 'Missing --kid. Pass one per quote line: --kid "<quoteLineId>:<firstName>[:<age>]".');
|
|
1208
|
+
}
|
|
1209
|
+
const kids = kidSpecs.map(parseEnrollKidSpec);
|
|
1210
|
+
// Every OTHER live child in the family must be named explicitly. The server
|
|
1211
|
+
// refuses the whole enrollment otherwise, listing who was unlisted — so the
|
|
1212
|
+
// failure is legible either way, but naming them here is how a staffer says
|
|
1213
|
+
// "yes, I know, they are not enrolling".
|
|
1214
|
+
const dispositions = flagList(parsed, "unassign").map((kidUserId) => ({
|
|
1215
|
+
kidUserId,
|
|
1216
|
+
action: "unassigned",
|
|
1217
|
+
}));
|
|
1218
|
+
return writeCommand(parsed, {
|
|
1219
|
+
// One short clause, like every other preview in this file. The
|
|
1220
|
+
// consequences are enumerated in `request` below, which is what the
|
|
1221
|
+
// confirmation prompt prints in full — restating them here would make
|
|
1222
|
+
// this the only preview a staffer has to read twice.
|
|
1223
|
+
action: "enroll an application from its accepted quote (creates children, charges the first month, cancels marketplace subscriptions)",
|
|
1224
|
+
target: { applicationId, quoteId, institutionSlug, familyId },
|
|
1225
|
+
request: { kids, dispositions, note },
|
|
1226
|
+
}, async () => unwrap(await api.client.POST("/admin/applications/{applicationId}/enroll", {
|
|
1227
|
+
params: { path: { applicationId } },
|
|
1228
|
+
body: {
|
|
1229
|
+
quoteId,
|
|
1230
|
+
institutionSlug,
|
|
1231
|
+
kids,
|
|
1232
|
+
...(familyId ? { familyId } : {}),
|
|
1233
|
+
...(dispositions.length > 0 ? { dispositions } : {}),
|
|
1234
|
+
...(note ? { note } : {}),
|
|
1235
|
+
},
|
|
1236
|
+
})));
|
|
1237
|
+
}
|
|
1080
1238
|
if (noun === "cohorts" && verb === "search") {
|
|
1081
1239
|
const search = parsed.positionals.slice(2).join(" ").trim();
|
|
1082
1240
|
if (!search)
|
|
@@ -1915,6 +2073,19 @@ export async function runCommand(argv) {
|
|
|
1915
2073
|
}
|
|
1916
2074
|
throw new CliError("invalid_arguments", "Use skills list|get.");
|
|
1917
2075
|
}
|
|
2076
|
+
if (noun === "content-library") {
|
|
2077
|
+
if (verb === "search") {
|
|
2078
|
+
const query = positional(parsed, 2, "search query");
|
|
2079
|
+
const limit = flagNumber(parsed, "limit") ?? 8;
|
|
2080
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 40) {
|
|
2081
|
+
throw new CliError("invalid_arguments", "--limit must be an integer between 1 and 40.");
|
|
2082
|
+
}
|
|
2083
|
+
return unwrap(await api.client.GET("/admin/content-library/search", {
|
|
2084
|
+
params: { query: { q: query, limit } },
|
|
2085
|
+
}));
|
|
2086
|
+
}
|
|
2087
|
+
throw new CliError("invalid_arguments", "Use content-library search.");
|
|
2088
|
+
}
|
|
1918
2089
|
if (noun === "goal-templates") {
|
|
1919
2090
|
if (verb === "list") {
|
|
1920
2091
|
const query = flagString(parsed, "query")?.toLowerCase();
|
|
@@ -2124,6 +2295,7 @@ export async function runCommand(argv) {
|
|
|
2124
2295
|
const id = await resolveGoalTemplateId(api, positional(parsed, 2, "template ID or slug"));
|
|
2125
2296
|
const expectedVersion = requiredExpectedVersion(parsed);
|
|
2126
2297
|
const agentInstructionsFile = flagString(parsed, "agent-instructions-file");
|
|
2298
|
+
const outputTemplateFile = flagString(parsed, "output-template-file");
|
|
2127
2299
|
const tags = flagString(parsed, "tags");
|
|
2128
2300
|
const kind = flagString(parsed, "kind");
|
|
2129
2301
|
const sortOrder = flagNumber(parsed, "sort-order");
|
|
@@ -2158,13 +2330,18 @@ export async function runCommand(argv) {
|
|
|
2158
2330
|
agentInstructions: await fs.readFile(path.resolve(agentInstructionsFile), "utf8"),
|
|
2159
2331
|
}
|
|
2160
2332
|
: {}),
|
|
2333
|
+
...(outputTemplateFile
|
|
2334
|
+
? {
|
|
2335
|
+
outputTemplate: await fs.readFile(path.resolve(outputTemplateFile), "utf8"),
|
|
2336
|
+
}
|
|
2337
|
+
: {}),
|
|
2161
2338
|
};
|
|
2162
2339
|
// `setupWorkflowSpec` is unreachable from this command by construction.
|
|
2163
2340
|
// The route still accepts one, but a wholesale spec replacement is the
|
|
2164
2341
|
// shape that caused the template incident; editing an existing spec goes
|
|
2165
2342
|
// through the guarded /ai patch path with its destructive-change token.
|
|
2166
2343
|
if (Object.keys(body).length === 1) {
|
|
2167
|
-
throw new CliError("invalid_arguments", "Pass at least one field to change (--title, --description, --emoji, --category, --tags, --sort-order, --kind, --agent-instructions-file).");
|
|
2344
|
+
throw new CliError("invalid_arguments", "Pass at least one field to change (--title, --description, --emoji, --category, --tags, --sort-order, --kind, --agent-instructions-file, --output-template-file).");
|
|
2168
2345
|
}
|
|
2169
2346
|
return writeCommand(parsed, {
|
|
2170
2347
|
action: "update goal template metadata (never its setupWorkflowSpec)",
|
|
@@ -2215,6 +2392,64 @@ export async function runCommand(argv) {
|
|
|
2215
2392
|
params: { path: { id } },
|
|
2216
2393
|
}));
|
|
2217
2394
|
}
|
|
2395
|
+
if (verb === "capture-snapshot") {
|
|
2396
|
+
const id = await resolveGoalTemplateId(api, positional(parsed, 2, "template ID or slug"));
|
|
2397
|
+
const sourceGoalId = flagString(parsed, "source-goal");
|
|
2398
|
+
const sourceDraft = flagString(parsed, "source-draft");
|
|
2399
|
+
if (Boolean(sourceGoalId) === Boolean(sourceDraft)) {
|
|
2400
|
+
throw new CliError("invalid_arguments", "Pass exactly one of --source-goal or --source-draft.");
|
|
2401
|
+
}
|
|
2402
|
+
const sourceStudentUserId = sourceDraft
|
|
2403
|
+
? flagString(parsed, "student", { required: true })
|
|
2404
|
+
: undefined;
|
|
2405
|
+
const source = sourceGoalId
|
|
2406
|
+
? { sourceGoalId }
|
|
2407
|
+
: {
|
|
2408
|
+
sourceWorkspacePath: `drafts/${sourceDraft}/workspace`,
|
|
2409
|
+
sourceStudentUserId,
|
|
2410
|
+
};
|
|
2411
|
+
const preflight = unwrap(await api.client.POST("/admin/goal-templates/{id}/snapshot/capture", {
|
|
2412
|
+
params: { path: { id } },
|
|
2413
|
+
body: { ...source, dryRun: true },
|
|
2414
|
+
}));
|
|
2415
|
+
if (preflight.action !== "preview_capture_snapshot") {
|
|
2416
|
+
throw new CliError("unexpected_response", "Snapshot capture did not return a preview; nothing was captured.");
|
|
2417
|
+
}
|
|
2418
|
+
if (hasFlag(parsed, "dry-run"))
|
|
2419
|
+
return preflight;
|
|
2420
|
+
const preview = {
|
|
2421
|
+
action: "capture a validated Mesa workspace as this global template's instant-apply snapshot",
|
|
2422
|
+
target: {
|
|
2423
|
+
templateId: preflight.template.id,
|
|
2424
|
+
slug: preflight.template.slug,
|
|
2425
|
+
title: preflight.template.title,
|
|
2426
|
+
version: preflight.template.version,
|
|
2427
|
+
},
|
|
2428
|
+
request: {
|
|
2429
|
+
source: preflight.source,
|
|
2430
|
+
moduleCount: preflight.snapshot.moduleCount,
|
|
2431
|
+
fileCount: preflight.snapshot.fileCount,
|
|
2432
|
+
sizeBytes: preflight.snapshot.sizeBytes,
|
|
2433
|
+
sha256: preflight.snapshot.sha256,
|
|
2434
|
+
},
|
|
2435
|
+
details: {
|
|
2436
|
+
modules: preflight.snapshot.modules,
|
|
2437
|
+
paths: preflight.snapshot.paths,
|
|
2438
|
+
warnings: preflight.warnings,
|
|
2439
|
+
note: "The confirmed request is fenced to both this template version and this exact Mesa source change. Capturing bumps the template version unless the snapshot is unchanged.",
|
|
2440
|
+
},
|
|
2441
|
+
};
|
|
2442
|
+
requireConfirmation(hasFlag(parsed, "confirm"), preview);
|
|
2443
|
+
return unwrap(await api.client.POST("/admin/goal-templates/{id}/snapshot/capture", {
|
|
2444
|
+
params: { path: { id } },
|
|
2445
|
+
body: {
|
|
2446
|
+
...source,
|
|
2447
|
+
dryRun: false,
|
|
2448
|
+
expectedTemplateVersion: preflight.template.version,
|
|
2449
|
+
expectedSourceChangeId: preflight.source.changeId,
|
|
2450
|
+
},
|
|
2451
|
+
}));
|
|
2452
|
+
}
|
|
2218
2453
|
if (verb === "apply") {
|
|
2219
2454
|
const id = await resolveGoalTemplateId(api, positional(parsed, 2, "template ID or slug"));
|
|
2220
2455
|
const answers = await readJsonFile(flagString(parsed, "answers-file", { required: true }), "Answers file");
|
|
@@ -2282,7 +2517,7 @@ export async function runCommand(argv) {
|
|
|
2282
2517
|
body: { studentUserId, answers },
|
|
2283
2518
|
})));
|
|
2284
2519
|
}
|
|
2285
|
-
throw new CliError("invalid_arguments", "Use goal-templates list|get|versions|validate-spec|create|patch-spec|set-metadata|delete|snapshot-files|apply|apply-starter.");
|
|
2520
|
+
throw new CliError("invalid_arguments", "Use goal-templates list|get|versions|validate-spec|create|patch-spec|set-metadata|delete|snapshot-files|capture-snapshot|apply|apply-starter.");
|
|
2286
2521
|
}
|
|
2287
2522
|
if (noun === "goals") {
|
|
2288
2523
|
if (verb === "list") {
|
|
@@ -2336,11 +2571,12 @@ export async function runCommand(argv) {
|
|
|
2336
2571
|
if (noun === "mesa" && verb === "files") {
|
|
2337
2572
|
const action = positional(parsed, 2, "mesa files action");
|
|
2338
2573
|
const studentId = flagString(parsed, "student", { required: true });
|
|
2339
|
-
const goalId = flagString(parsed, "goal", { required: true });
|
|
2340
2574
|
if (action === "list") {
|
|
2575
|
+
const goalId = flagString(parsed, "goal", { required: true });
|
|
2341
2576
|
return unwrap(await api.client.GET("/tutor/students/{studentId}/goals/{goalId}/workspace/files", { params: { path: { studentId, goalId } } }));
|
|
2342
2577
|
}
|
|
2343
2578
|
if (action === "read") {
|
|
2579
|
+
const goalId = flagString(parsed, "goal", { required: true });
|
|
2344
2580
|
return unwrap(await api.client.GET("/tutor/students/{studentId}/goals/{goalId}/workspace/file", {
|
|
2345
2581
|
params: {
|
|
2346
2582
|
path: { studentId, goalId },
|
|
@@ -2348,7 +2584,58 @@ export async function runCommand(argv) {
|
|
|
2348
2584
|
},
|
|
2349
2585
|
}));
|
|
2350
2586
|
}
|
|
2351
|
-
|
|
2587
|
+
if (action === "write") {
|
|
2588
|
+
const goalId = flagString(parsed, "goal");
|
|
2589
|
+
const draftSlug = flagString(parsed, "draft");
|
|
2590
|
+
if (Boolean(goalId) === Boolean(draftSlug)) {
|
|
2591
|
+
throw new CliError("invalid_arguments", "Pass exactly one of --goal or --draft for mesa files write.");
|
|
2592
|
+
}
|
|
2593
|
+
const input = await readMesaWriteSource(parsed);
|
|
2594
|
+
const target = goalId
|
|
2595
|
+
? { kind: "goal", goalId }
|
|
2596
|
+
: { kind: "draft", draftSlug: draftSlug };
|
|
2597
|
+
const message = flagString(parsed, "message") ??
|
|
2598
|
+
`recess-cli: write ${input.files.length} workspace file${input.files.length === 1 ? "" : "s"}`;
|
|
2599
|
+
const body = {
|
|
2600
|
+
studentUserId: studentId,
|
|
2601
|
+
target,
|
|
2602
|
+
message,
|
|
2603
|
+
files: input.files,
|
|
2604
|
+
};
|
|
2605
|
+
const preflight = unwrap(await api.client.POST("/admin/mesa/workspace-files", {
|
|
2606
|
+
body: { ...body, dryRun: true },
|
|
2607
|
+
}));
|
|
2608
|
+
if (preflight.action !== "preview_workspace_files_write") {
|
|
2609
|
+
throw new CliError("unexpected_response", "Mesa workspace write did not return a preview; nothing was written.");
|
|
2610
|
+
}
|
|
2611
|
+
const preview = {
|
|
2612
|
+
action: "batch-upsert files in a student's Mesa workspace",
|
|
2613
|
+
target: preflight.target,
|
|
2614
|
+
request: {
|
|
2615
|
+
source: input.source,
|
|
2616
|
+
sourceSha256: input.sha256,
|
|
2617
|
+
fileCount: input.files.length,
|
|
2618
|
+
sizeBytes: input.sizeBytes,
|
|
2619
|
+
message,
|
|
2620
|
+
},
|
|
2621
|
+
details: {
|
|
2622
|
+
currentChangeId: preflight.currentChangeId,
|
|
2623
|
+
files: preflight.files,
|
|
2624
|
+
note: target.kind === "draft"
|
|
2625
|
+
? "This writes a complete authoring tree under drafts/<slug>/workspace. Capture it only after the server's goal-workspace validation passes."
|
|
2626
|
+
: "Live goal modules/ and state/ are blocked here because they have database projections; use a draft + template capture for structural course changes.",
|
|
2627
|
+
},
|
|
2628
|
+
};
|
|
2629
|
+
requireConfirmation(hasFlag(parsed, "confirm"), preview);
|
|
2630
|
+
return unwrap(await api.client.POST("/admin/mesa/workspace-files", {
|
|
2631
|
+
body: {
|
|
2632
|
+
...body,
|
|
2633
|
+
dryRun: false,
|
|
2634
|
+
expectedChangeId: preflight.currentChangeId,
|
|
2635
|
+
},
|
|
2636
|
+
}));
|
|
2637
|
+
}
|
|
2638
|
+
throw new CliError("invalid_arguments", "Use mesa files list|read|write.");
|
|
2352
2639
|
}
|
|
2353
2640
|
if (noun === "request" && verb === "get") {
|
|
2354
2641
|
return api.rawGet(positional(parsed, 2, "request path"));
|
package/package.json
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: recess-cli
|
|
3
|
-
description: Safely perform Recess staff administration through the recess CLI. Use when a Recess admin asks Codex to find a kid, parent, family, enrollment, subscription, invoice, or cohort; inspect or change a school kid's tier and capability gates; upload a kid's MAP Growth report; pause or resume billing; refund or credit an invoice item; extend a trial; cancel or restore a subscription; register or unregister a cohort against an enrollment; switch or move kids from one cohort to another; manage guide payout invoices (biweekly pay-cycle line-item changes, invoice status moves, payout recipient lookups); run class operations (take attendance, cancel or reschedule a class session, add a one-off session, end a cohort, pause cohort billing, email a cohort's families, approve or deny pending registrations); process class-cancellation credits (the "Please credit these students accordingly" Slack message — credit every registered kid for a guide-canceled session); or author learning content — build, validate, publish, and loss-safely patch a deterministic GoalTemplate, apply a template to a kid or a roster, create a goal directly on a kid,
|
|
3
|
+
description: Safely perform Recess staff administration through the recess CLI. Use when a Recess admin asks Codex to find a kid, parent, family, enrollment, subscription, invoice, or cohort; search the curated Content Library; inspect or change a school kid's tier and capability gates; upload a kid's MAP Growth report; pause or resume billing; refund or credit an invoice item; extend a trial; cancel or restore a subscription; register or unregister a cohort against an enrollment; switch or move kids from one cohort to another; manage guide payout invoices (biweekly pay-cycle line-item changes, invoice status moves, payout recipient lookups); run class operations (take attendance, cancel or reschedule a class session, add a one-off session, end a cohort, pause cohort billing, email a cohort's families, approve or deny pending registrations); process class-cancellation credits (the "Please credit these students accordingly" Slack message — credit every registered kid for a guide-canceled session); or author learning content — build, validate, publish, and loss-safely patch a deterministic GoalTemplate, apply a template to a kid or a roster, create a goal directly on a kid, write a Mesa draft or goal workspace, capture a template snapshot, and read Mesa workspace or snapshot files.
|
|
4
4
|
# Bundle version. Bump on every substantive edit; the CLI reports it and `doctor`
|
|
5
5
|
# compares it against the served copy to tell an operator a refresh is available.
|
|
6
|
-
version: 1.3
|
|
6
|
+
version: 1.4.3
|
|
7
7
|
# The lowest `recess` version this bundle is safe to install onto. Raise it ONLY
|
|
8
8
|
# when the bundle documents a command, flag, or changed semantic that an older
|
|
9
9
|
# binary does not have — an older CLI keeps its bundled copy instead of taking
|
|
10
10
|
# this one. Prose, formatting, and Gotcha edits must NOT raise it; that is the
|
|
11
11
|
# whole point of serving the bundle.
|
|
12
|
-
minCliVersion: 1.2
|
|
12
|
+
minCliVersion: 1.3.2
|
|
13
13
|
---
|
|
14
14
|
|
|
15
15
|
# Recess CLI (`recess`)
|
|
@@ -27,10 +27,12 @@ Every mutating command is two-step. Run it **without** `--confirm` first: the CL
|
|
|
27
27
|
Some previews also carry a `details` object — server-resolved facts that cannot be known offline.
|
|
28
28
|
Today those include `enrollments create` (real price, no-charge reuse, and capacity/slot
|
|
29
29
|
violations), `users tier set` (current/proposed capability locks and class-slot consequences), and
|
|
30
|
-
`goal-templates patch-spec` (validated before/after hashes and protected-inventory loss)
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
30
|
+
`goal-templates patch-spec` (validated before/after hashes and protected-inventory loss),
|
|
31
|
+
`mesa files write` (resolved workspace root, Mesa change fence, and file hashes), and
|
|
32
|
+
`goal-templates capture-snapshot` (validated modules/files plus template/source fences); getting
|
|
33
|
+
one costs read-only calls, never a write. **When `details` is present it is part of the preview —
|
|
34
|
+
show it to the human too.** Approving from `action`/`request` alone while ignoring `details` is how
|
|
35
|
+
an override gets rubber-stamped.
|
|
34
36
|
|
|
35
37
|
1. Show that preview to the human, verbatim.
|
|
36
38
|
2. Request explicit escalated approval for that exact action using the execution tool's approval mechanism.
|
|
@@ -211,6 +213,9 @@ recess --json onboarding extract <family-id> --session <id> (--transcript-file <
|
|
|
211
213
|
recess --json skills list [--query TEXT] [--category TEXT]
|
|
212
214
|
recess --json skills get <skill-name> [--reference NAME | --all-references] [--refresh]
|
|
213
215
|
|
|
216
|
+
# Curated learning-resource research (read-only)
|
|
217
|
+
recess --json content-library search <query> [--limit 8]
|
|
218
|
+
|
|
214
219
|
# Learning content (reference/goal-authoring.md)
|
|
215
220
|
recess --json goal-templates list [--query TEXT] [--kind SIMPLE|BLUEPRINT] [--starter-only]
|
|
216
221
|
recess --json goal-templates get <id-or-slug> [--spec-only]
|
|
@@ -218,15 +223,17 @@ recess --json goal-templates versions <id-or-slug> [--version N]
|
|
|
218
223
|
recess --json goal-templates validate-spec --file <path/template.json>
|
|
219
224
|
recess --json goal-templates create --file <path/template.json> [--confirm]
|
|
220
225
|
recess --json goal-templates patch-spec <id-or-slug> --expected-version N --patches-file <path/patches.json> [--confirm] [--confirm-destructive-changes --destructive-change-token TOKEN]
|
|
221
|
-
recess --json goal-templates set-metadata <id-or-slug> --expected-version N [--title …] [--confirm]
|
|
226
|
+
recess --json goal-templates set-metadata <id-or-slug> --expected-version N [--title …] [--agent-instructions-file <path>] [--output-template-file <path>] [--confirm]
|
|
222
227
|
recess --json goal-templates delete <id-or-slug> --expected-version N [--confirm]
|
|
223
228
|
recess --json goal-templates snapshot-files <id-or-slug> [--path P]
|
|
229
|
+
recess --json goal-templates capture-snapshot <id-or-slug> (--source-goal <goal-id> | --source-draft <draft-slug> --student <kid-id>) [--dry-run] [--confirm]
|
|
224
230
|
recess --json goal-templates apply <id-or-slug> --answers-file <path> [--dry-run] [--confirm]
|
|
225
231
|
recess --json goal-templates apply-starter <id-or-slug> --student <kid-id> [--confirm]
|
|
226
232
|
recess --json goals list --student <kid-id>
|
|
227
233
|
recess --json goals create --student <kid-id> --title TEXT --description TEXT [--confirm]
|
|
228
234
|
recess --json mesa files list --student <kid-id> --goal <goal-id>
|
|
229
235
|
recess --json mesa files read --student <kid-id> --goal <goal-id> --path P
|
|
236
|
+
recess --json mesa files write --student <kid-id> (--goal <goal-id> | --draft <draft-slug>) (--source-dir <local-dir> | --source-file <local-file> --path P) [--message TEXT] [--confirm]
|
|
230
237
|
|
|
231
238
|
# Read-only escape hatch (GET only — no raw writes exist)
|
|
232
239
|
recess --json request get /path?query=value
|
|
@@ -294,10 +301,12 @@ Translate as you read:
|
|
|
294
301
|
| `manage_goal_template action:"preview_setup_workflow_spec_patch"` / `"patch_setup_workflow_spec"` | `goal-templates patch-spec <id-or-slug> --expected-version N --patches-file …` (omit `--confirm` for the required server preview; destructive changes also require its exact token) |
|
|
295
302
|
| `manage_goal_template action:"update"` (metadata) | `goal-templates set-metadata <id> --expected-version N … --confirm` |
|
|
296
303
|
| `manage_goal_template action:"delete"` | `goal-templates delete <id> --expected-version N --confirm` |
|
|
304
|
+
| `manage_goal_template action:"capture_snapshot"` | `goal-templates capture-snapshot <id-or-slug> --source-goal <goal-id>` or `--source-draft <slug> --student <kid-id>` |
|
|
297
305
|
| `create_goal` | `goals create --student <kid-id> … --confirm`, or `goal-templates apply` when a template exists |
|
|
298
306
|
| `mesa_list_files` / `mesa_read_file` | `mesa files list` / `mesa files read` |
|
|
307
|
+
| `mesa_write_file` (batch upserts) | `mesa files write --student … (--goal … | --draft …) (--source-dir … | --source-file … --path …)` |
|
|
299
308
|
| `load_skill` / `load_skill_reference` | `skills get <name>` / `skills get <name> --reference <ref>` |
|
|
300
|
-
| `restore_version`, `
|
|
309
|
+
| `restore_version`, `clear_snapshot`, `mesa_edit_file`, `mesa_manage_module` | **No CLI command.** Version restore, snapshot clearing, string edits/deletes, and projected live-goal module/state mutations stay in `recess.gg/ai`. |
|
|
301
310
|
|
|
302
311
|
#### Research tools → your own harness
|
|
303
312
|
|
|
@@ -312,7 +321,8 @@ the tool names do not:
|
|
|
312
321
|
| `search_web`, `fetch_webpage` | your own web search / page fetch |
|
|
313
322
|
| `spawn_subagents` | your own subagents. The five researcher prompts in `goal-creation`'s `subagent-tasks.md` (curriculum-map, platforms, resources, learning-paths, prerequisites) are usable almost verbatim — they are prompts, not tool calls |
|
|
314
323
|
| `validate_urls` | check the URLs yourself before baking them into a spec. Do not skip this: a rotted queue URL is invisible until a kid clicks it |
|
|
315
|
-
| `
|
|
324
|
+
| `search_gem_library` | `content-library search <query>` (same hybrid/vector + rerank funnel; no content or Pipeline writes) |
|
|
325
|
+
| `search_videos`, `search_books`, `recommend_videos` | your own search |
|
|
316
326
|
| `get_student_profile`, `read_memory`, `query_student_activity_sql` | **partial.** `users search`/`users get` give account context, `goals list` what is already authored, `mesa files` the workspace. There is **no** command for kid memory or activity SQL — for those, the separate `query-db` skill's read-only production access is the honest route, not a guess |
|
|
317
327
|
| `search_standards`, `navigate_standards` | no CLI command |
|
|
318
328
|
| `save_platform_research` | **no CLI command** — a researched platform profile can only be persisted from `recess.gg/ai`. Your research still informs the spec you write; it just does not get saved as a reusable profile |
|
|
@@ -347,10 +357,10 @@ rather than pretend:
|
|
|
347
357
|
- **The skill treats create as one step.** Here it is two: the unconfirmed run returns the
|
|
348
358
|
SERVER-resolved `details` (`resolvedSetupHandler`, `resolvedGoalShape`, `wizardStepKeys`,
|
|
349
359
|
`specInventory`). Put those in the approval request — they are what the template will actually do.
|
|
350
|
-
- **Prebuilt templates are
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
360
|
+
- **Prebuilt templates are a draft → validate → capture flow.** Write the complete OS-V2 tree to a
|
|
361
|
+
named Mesa draft, preview `capture-snapshot`, then confirm it. Capture rejects an incomplete
|
|
362
|
+
draft and filters student `state/`, conversations, and `.recess/` bookkeeping from the frozen
|
|
363
|
+
snapshot. Applying a MODULE_BACKED template with no snapshot still 400s correctly.
|
|
354
364
|
|
|
355
365
|
#### The one-way rules
|
|
356
366
|
|
|
@@ -377,7 +387,7 @@ Each domain has a full playbook in this skill's `reference/` directory. Read the
|
|
|
377
387
|
| Class-cancellation credits — the "Please credit these students accordingly" Slack workflow | [`reference/cancellation-credits.md`](reference/cancellation-credits.md) |
|
|
378
388
|
| Non-flexible course one-off time shift when `events reschedule` 400s (`allowFlexibleScheduling: false`) | [`reference/class-ops-reschedule.md`](reference/class-ops-reschedule.md) |
|
|
379
389
|
| Family onboarding — stage, account state, attestation checklist, parent intake session (fill / LLM-extract) | [`reference/onboarding.md`](reference/onboarding.md) |
|
|
380
|
-
| **Authoring learning content** — deterministic GoalTemplates, applying a template to a kid or roster, creating a goal on a kid
|
|
390
|
+
| **Authoring learning content** — deterministic GoalTemplates, Mesa draft/goal workspace writes, snapshot capture, applying a template to a kid or roster, creating a goal on a kid | [`reference/goal-authoring.md`](reference/goal-authoring.md) |
|
|
381
391
|
|
|
382
392
|
## Deliberately out of scope
|
|
383
393
|
|
|
@@ -387,7 +397,7 @@ These are excluded from the CLI on purpose. If asked, direct the human to the we
|
|
|
387
397
|
- **Money movement:** initiating Mercury payouts, advancing whole pay runs, generating/regenerating payout invoices → `/admin/payout` in the web admin.
|
|
388
398
|
- **Enrollment cancellation** as a standalone action (`unregister-cohort` deliberately preserves the enrollment; only `registrations deny` cancels one, because that is what the web Deny button does).
|
|
389
399
|
- **Cohort creation and schedule editing:** create, start, full-edit/RRULE regeneration, generate-events, guides management → web admin cohort pages.
|
|
390
|
-
- **Restoring a template version
|
|
400
|
+
- **Restoring a template version or clearing a snapshot; deleting/editing Mesa files; direct live-goal `modules/` or `state/` writes.** Use `recess.gg/ai` for these projected mutations. The CLI intentionally supports upserts only and routes structural course authoring through a draft + capture.
|
|
391
401
|
|
|
392
402
|
## Guardrails
|
|
393
403
|
|
|
@@ -472,3 +482,7 @@ Dated, newest last. Add an entry every time reality surprises you.
|
|
|
472
482
|
- 2026-08-04 — **`--version` is a flag, not a noun.** Anything the arg parser sees starting with `--` lands in `flags`, leaving `positionals` empty — so a `noun`-based check for it sits behind the `!noun → print help` branch and is unreachable. Same trap for any future `--foo` top-level command: check the flag before the help branch. Observed live: `recess --json --version` printed the whole help text.
|
|
473
483
|
- 2026-08-04 — A `Makefile install-persistent` copy is NOT the npm package: it synthesizes its own `package.json`. If that manifest lacks `version`, or `skill/` is not copied alongside `dist/`, then `--version` reports `0.0.0` and the `minCliVersion` fence **fails closed** — every served skill upgrade is silently refused with `cli_too_old`. Both are now copied; if you add another packaged artifact the CLI reads at runtime, add it to that target too.
|
|
474
484
|
- 2026-08-04 — **`goal-templates patch-spec` performs a server preview even on a confirmed run.** The first POST is `dryRun:true`, never a write; it binds the current template version, before/after hashes, and protected removals. A destructive second POST is impossible without `--confirm-destructive-changes` and that fresh preview token. If the template or patch file changes, preview again and obtain new approval.
|
|
485
|
+
- 2026-08-05 — **A deterministic MODULE_BACKED spec is not the course content.** `goal-templates create` can create a valid BLUEPRINT while `snapshot` remains null; Goal Preview stays empty and apply correctly says there is no valid snapshot. Build the complete tree locally, `mesa files write --draft … --source-dir …`, then `goal-templates capture-snapshot --source-draft … --student …`. Both writes server-preview first. Mesa writes are fenced to the previewed repo change; capture is fenced to that source change and the template version. Direct live-goal `modules/`/`state/` writes are deliberately blocked because those paths have DB projections.
|
|
486
|
+
- 2026-08-05 — **The goal-audit GET cannot investigate an already-soft-deleted goal.** `request get /admin/browser/students/goals/<goal-id>/audit/` returns 404 because the handler's `ensureGoalAccess` calls `canManageGoal`, which requires `Goal.deletedAt: null` before it loads `GoalAuditLog`. The 404 is not evidence that the audit row is absent. Use the `query-db` skill's guarded read-only production query for deletion forensics.
|
|
487
|
+
- 2026-08-05 — **`set-metadata --output-template-file` edits an AI_CHAT template's `outputTemplate`** (the goal-description payload DailyTodoGeneration consumes for description-only goals). Like `--agent-instructions-file`, it reads a local file and the server bumps the version + freezes a `GoalTemplateVersion` row. Editing a template never rewrites goals already created from it — the description was copied at goal-creation time; re-apply or edit live goals separately.
|
|
488
|
+
- 2026-08-05 — **Content Library search now has a content/Pipeline-read-only CLI command.** `content-library search <query> [--limit 8]` calls the same hybrid/vector + rerank `/agent/search` funnel as Rocky's `search_gem_library`, returns the full fit/gist/coverage payload, and attributes the funnel's standard `offered` telemetry to `admin-cli`. It cannot inspect, ingest, file requests, curate, or edit the Pipeline; the permanent library token stays on the web server.
|
|
@@ -66,8 +66,8 @@ the human directly, in the skill's language, and wait. Do not say "materialize",
|
|
|
66
66
|
"module-backed", or "snapshot" to a tutor.
|
|
67
67
|
|
|
68
68
|
A MODULE_BACKED spec **requires** `BLUEPRINT`; the server enforces it, and it additionally refuses to
|
|
69
|
-
apply a MODULE_BACKED template that has no snapshot.
|
|
70
|
-
|
|
69
|
+
apply a MODULE_BACKED template that has no snapshot. Build a complete Mesa draft and capture it with
|
|
70
|
+
the CLI workflow in §8 before applying the template.
|
|
71
71
|
|
|
72
72
|
## 2. Write one template file
|
|
73
73
|
|
|
@@ -194,7 +194,41 @@ the human which goal to retire.
|
|
|
194
194
|
Load `goal-creation` (and `student-research` to read the kid first) before writing the description.
|
|
195
195
|
An unresearched goal is the failure mode this whole surface exists to prevent.
|
|
196
196
|
|
|
197
|
-
## 8.
|
|
197
|
+
## 8. Author, capture, and read workspaces
|
|
198
|
+
|
|
199
|
+
For a new Prebuilt/instant-apply course, author the complete OS-V2 tree locally and upsert it into a
|
|
200
|
+
named draft. The directory root becomes `drafts/<slug>/workspace` in the student's Mesa repo:
|
|
201
|
+
|
|
202
|
+
```bash
|
|
203
|
+
recess --json mesa files write --student <kid-id> --draft <draft-slug> --source-dir ./workspace
|
|
204
|
+
# show the preview, obtain approval, then rerun unchanged:
|
|
205
|
+
recess --json mesa files write --student <kid-id> --draft <draft-slug> --source-dir ./workspace --confirm
|
|
206
|
+
|
|
207
|
+
recess --json goal-templates capture-snapshot <id-or-slug> --source-draft <draft-slug> --student <kid-id> --dry-run
|
|
208
|
+
recess --json goal-templates capture-snapshot <id-or-slug> --source-draft <draft-slug> --student <kid-id>
|
|
209
|
+
# show the preview, obtain approval, then rerun unchanged:
|
|
210
|
+
recess --json goal-templates capture-snapshot <id-or-slug> --source-draft <draft-slug> --student <kid-id> --confirm
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
The draft must pass the full goal-builder contract: required top-level instructions, required fresh
|
|
214
|
+
`state/` files, directory content, and at least one parseable runtime module. Capture validates that
|
|
215
|
+
tree, then excludes `state/`, `conversations/`, and `.recess/` from the reusable snapshot. Its
|
|
216
|
+
preview carries the template version, Mesa change id, module/file inventory, size, and SHA-256; the
|
|
217
|
+
confirmed request is compare-and-set against both fences. If either changed, preview again and get
|
|
218
|
+
fresh approval.
|
|
219
|
+
|
|
220
|
+
Use `--source-file <local-file> --path <workspace-relative-path>` for one-file upserts. A live goal
|
|
221
|
+
may be targeted with `--goal <goal-id>`, but the CLI rejects direct `modules/` and `state/` writes
|
|
222
|
+
because those paths have database projections. Structural course changes belong in a draft, then a
|
|
223
|
+
captured/applied template.
|
|
224
|
+
|
|
225
|
+
An already-built OS-V2 goal can be captured directly:
|
|
226
|
+
|
|
227
|
+
```bash
|
|
228
|
+
recess --json goal-templates capture-snapshot <id-or-slug> --source-goal <goal-id> --dry-run
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
Read the resulting workspaces and snapshot with:
|
|
198
232
|
|
|
199
233
|
```bash
|
|
200
234
|
recess --json mesa files list --student <kid-id> --goal <goal-id>
|
|
@@ -203,9 +237,6 @@ recess --json goal-templates snapshot-files <id-or-slug>
|
|
|
203
237
|
recess --json goal-templates snapshot-files <id-or-slug> --path modules/01/index.md
|
|
204
238
|
```
|
|
205
239
|
|
|
206
|
-
Reads only — there is no `mesa files write`. Writing workspace files stays in `recess.gg/ai`, which
|
|
207
|
-
validates a workspace before it lands in a live kid's session.
|
|
208
|
-
|
|
209
240
|
`mesa files list` returning `{"files":[]}` means the goal has no Mesa workspace at all — expected for
|
|
210
241
|
a SIMPLE goal, and the signal that a BLUEPRINT goal has not been built yet.
|
|
211
242
|
|