recess-cli 1.0.0 → 1.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/setup.js CHANGED
@@ -11,13 +11,51 @@ const skillSource = path.resolve(here, "..", "skill", "recess-cli");
11
11
  * so setup removes them.
12
12
  */
13
13
  const SUPERSEDED_SKILL_DIRECTORIES = ["recess-admin"];
14
- export async function installSkill() {
14
+ /**
15
+ * Where the skill is installed for each agent. Exported so the served-bundle
16
+ * updater (`skill-update.ts`) writes to exactly the same places rather than
17
+ * keeping a second, driftable copy of this list.
18
+ */
19
+ export function skillDestinations() {
15
20
  const codexHome = process.env.CODEX_HOME ?? path.join(os.homedir(), ".codex");
16
21
  const claudeHome = process.env.CLAUDE_CONFIG_DIR ?? path.join(os.homedir(), ".claude");
17
- const destinations = [
22
+ return [
18
23
  { agent: "Codex", path: path.join(codexHome, "skills", "recess-cli") },
19
24
  { agent: "Claude", path: path.join(claudeHome, "skills", "recess-cli") },
20
25
  ];
26
+ }
27
+ /**
28
+ * The CLI's own version, read from the package manifest. npm always ships
29
+ * `package.json` in the tarball regardless of the `files` field, and `dist/` sits
30
+ * one level below it — the same relative hop `skillSource` above already makes.
31
+ * Used for `--version` and as the left-hand side of the `minCliVersion` fence.
32
+ */
33
+ export async function readCliVersion() {
34
+ try {
35
+ const manifestPath = path.resolve(here, "..", "package.json");
36
+ const manifest = JSON.parse(await fs.readFile(manifestPath, "utf8"));
37
+ return manifest.version ?? "0.0.0";
38
+ }
39
+ catch {
40
+ // A version we cannot read must not read as "newer than everything", or the
41
+ // fence would fail open and install a bundle meant for a later binary.
42
+ return "0.0.0";
43
+ }
44
+ }
45
+ /** The bundle version shipped inside this package, from SKILL.md frontmatter. */
46
+ export async function readBundledSkillVersion() {
47
+ try {
48
+ const contents = await fs.readFile(path.join(skillSource, "SKILL.md"), "utf8");
49
+ // Deliberately a narrow regex over the frontmatter rather than a YAML
50
+ // dependency: one scalar field, and the CLI ships exactly one runtime dep.
51
+ return /^version:\s*(\S+)\s*$/m.exec(contents)?.[1] ?? "0.0.0";
52
+ }
53
+ catch {
54
+ return "0.0.0";
55
+ }
56
+ }
57
+ export async function installSkill() {
58
+ const destinations = skillDestinations();
21
59
  for (const destination of destinations) {
22
60
  const skillsRoot = path.dirname(destination.path);
23
61
  await fs.mkdir(skillsRoot, { recursive: true });
@@ -0,0 +1,150 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { skillDestinations } from "./setup.js";
4
+ /**
5
+ * Compare dotted numeric versions. Deliberately hand-rolled rather than adding a
6
+ * semver dependency (repo rule R2 — avoid new dependencies): these are the
7
+ * CLI's own `x.y.z` versions, and prerelease/range semantics are not in play.
8
+ * Non-numeric or missing segments read as 0, so a malformed version compares low
9
+ * and the fence fails CLOSED (the bundle is not installed).
10
+ */
11
+ export function compareVersions(left, right) {
12
+ const parse = (value) => value
13
+ .split(".")
14
+ .slice(0, 3)
15
+ .map((part) => {
16
+ const parsed = Number.parseInt(part, 10);
17
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : 0;
18
+ });
19
+ const a = parse(left);
20
+ const b = parse(right);
21
+ for (let index = 0; index < 3; index += 1) {
22
+ const diff = (a[index] ?? 0) - (b[index] ?? 0);
23
+ if (diff !== 0)
24
+ return diff > 0 ? 1 : -1;
25
+ }
26
+ return 0;
27
+ }
28
+ /**
29
+ * Validate a bundle-relative path from the SERVER before it is used to write a
30
+ * file into the operator's home directory.
31
+ *
32
+ * This is the one genuinely dangerous edge in the whole feature: the response
33
+ * supplies both the path and the content, and the destination is inside
34
+ * `~/.claude` / `~/.codex`. A path of `../../.claude/settings.json` or an
35
+ * absolute path would let a compromised or misconfigured server write anywhere
36
+ * the user can. Mirrors `isSafeReferenceName` + the realpath containment check
37
+ * in the web-server's `skills-loader.ts`.
38
+ */
39
+ export function isSafeBundlePath(candidate) {
40
+ if (!candidate || candidate.includes("\0"))
41
+ return false;
42
+ if (path.isAbsolute(candidate) || path.win32.isAbsolute(candidate)) {
43
+ return false;
44
+ }
45
+ // Normalize both separators so a Windows-style `..\` cannot slip past a
46
+ // POSIX-only check.
47
+ const normalized = path.posix.normalize(candidate.replace(/\\/g, "/"));
48
+ if (normalized.startsWith("..") || normalized.split("/").includes("..")) {
49
+ return false;
50
+ }
51
+ if (normalized.startsWith("/"))
52
+ return false;
53
+ // Only markdown belongs in a skill bundle; refuse anything else outright
54
+ // rather than reasoning about what an unexpected file type could do.
55
+ return normalized.endsWith(".md");
56
+ }
57
+ async function writeBundle(bundle, destinations) {
58
+ // Resolve and containment-check EVERY target before writing ANY of them.
59
+ // Doing this inline per file would write the safe files that happen to come
60
+ // first and only then throw, leaving a half-applied skill on disk — which is
61
+ // worse than either outcome, because the agent reads a bundle that never
62
+ // existed. `isSafeBundlePath` already rejected these paths upstream; this
63
+ // pre-pass is the backstop, and a backstop that half-applies is not one.
64
+ const planned = [];
65
+ for (const destination of destinations) {
66
+ const resolvedRoot = path.resolve(destination.path);
67
+ for (const file of bundle.files) {
68
+ const resolvedTarget = path.resolve(path.join(destination.path, file.path));
69
+ if (!resolvedTarget.startsWith(`${resolvedRoot}${path.sep}`)) {
70
+ throw new Error(`Refusing to write outside the skill directory: ${file.path}`);
71
+ }
72
+ planned.push({ target: resolvedTarget, content: file.content });
73
+ }
74
+ }
75
+ for (const { target, content } of planned) {
76
+ await fs.mkdir(path.dirname(target), { recursive: true });
77
+ await fs.writeFile(target, content, "utf8");
78
+ }
79
+ return bundle.files.length;
80
+ }
81
+ /**
82
+ * Fetch and install the served bundle. NEVER throws: every failure path
83
+ * (unreachable server, missing route, malformed payload, old binary) resolves to
84
+ * a descriptive outcome and leaves the bundled copy untouched, because this runs
85
+ * inside `setup` and must not turn a working install into a failed one.
86
+ */
87
+ export async function updateSkillFromServer(input) {
88
+ let bundle;
89
+ try {
90
+ const response = await fetch(new URL("/admin/cli-skill/", input.apiOrigin), {
91
+ headers: input.sessionCookie ? { cookie: input.sessionCookie } : {},
92
+ signal: AbortSignal.timeout(10_000),
93
+ });
94
+ if (!response.ok) {
95
+ return {
96
+ status: "unavailable",
97
+ reason: `server returned ${response.status}`,
98
+ };
99
+ }
100
+ bundle = (await response.json());
101
+ }
102
+ catch (error) {
103
+ return {
104
+ status: "unavailable",
105
+ reason: error instanceof Error ? error.message : String(error),
106
+ };
107
+ }
108
+ if (typeof bundle?.version !== "string" ||
109
+ typeof bundle?.minCliVersion !== "string" ||
110
+ !Array.isArray(bundle.files)) {
111
+ return { status: "unavailable", reason: "malformed bundle response" };
112
+ }
113
+ if (compareVersions(input.cliVersion, bundle.minCliVersion) < 0) {
114
+ return {
115
+ status: "cli_too_old",
116
+ version: bundle.version,
117
+ minCliVersion: bundle.minCliVersion,
118
+ cliVersion: input.cliVersion,
119
+ };
120
+ }
121
+ const unsafe = bundle.files.filter((file) => !isSafeBundlePath(file.path));
122
+ if (unsafe.length > 0) {
123
+ return {
124
+ status: "unavailable",
125
+ reason: `refused unsafe bundle paths: ${unsafe
126
+ .map((file) => file.path)
127
+ .join(", ")}`,
128
+ };
129
+ }
130
+ if (bundle.files.length === 0) {
131
+ return { status: "unavailable", reason: "bundle contained no files" };
132
+ }
133
+ const destinations = skillDestinations();
134
+ try {
135
+ const fileCount = await writeBundle(bundle, destinations);
136
+ return {
137
+ status: "updated",
138
+ version: bundle.version,
139
+ agents: destinations.map((destination) => destination.agent),
140
+ fileCount,
141
+ };
142
+ }
143
+ catch (error) {
144
+ return {
145
+ status: "unavailable",
146
+ reason: error instanceof Error ? error.message : String(error),
147
+ };
148
+ }
149
+ }
150
+ //# sourceMappingURL=skill-update.js.map
Binary file
package/package.json CHANGED
@@ -1,8 +1,13 @@
1
1
  {
2
2
  "name": "recess-cli",
3
- "version": "1.0.0",
3
+ "version": "1.2.0",
4
4
  "description": "Safe Recess staff administration from the command line, for humans and coding agents.",
5
5
  "license": "UNLICENSED",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/tryrecess/monolith.git",
9
+ "directory": "apps/admin-cli"
10
+ },
6
11
  "type": "module",
7
12
  "bin": {
8
13
  "recess": "dist/index.js"
@@ -10,6 +15,7 @@
10
15
  "files": [
11
16
  "dist/**/*.js",
12
17
  "!dist/**/*.test.js",
18
+ "scripts/postinstall.mjs",
13
19
  "skill",
14
20
  "README.md"
15
21
  ],
@@ -43,6 +49,7 @@
43
49
  "test": "vitest run",
44
50
  "install-local": "pnpm run build && make install-local",
45
51
  "install-persistent": "pnpm run build && make install-persistent",
46
- "install-skill": "pnpm run build && node scripts/install-skill.mjs"
52
+ "install-skill": "pnpm run build && node scripts/install-skill.mjs",
53
+ "postinstall": "node scripts/postinstall.mjs"
47
54
  }
48
55
  }
@@ -0,0 +1,44 @@
1
+ // npm `postinstall`: refresh the on-disk agent skill after an upgrade.
2
+ //
3
+ // Without this, `npm install -g recess-cli` updates the binary but leaves the
4
+ // OLD skill sitting in ~/.codex/skills and ~/.claude/skills until someone
5
+ // remembers to rerun `recess setup` — so an agent reads last release's command
6
+ // reference against this release's CLI. That gap is the whole reason skill
7
+ // changes felt like they needed a separate delivery channel.
8
+ //
9
+ // Three rules, because this runs unattended inside every install:
10
+ // 1. Never fail the install. A read-only HOME, a sandboxed CI runner, or a
11
+ // missing build all exit 0 — a skill copy is not worth a failed upgrade.
12
+ // 2. Never run from a source checkout. In the monorepo `pnpm install` fires
13
+ // postinstall for the workspace package too, where dist/ may be stale or
14
+ // absent; writing a developer's half-built skill into their real agent
15
+ // directories would be a surprising side effect of `pnpm install`.
16
+ // 3. Stay quiet on success. One line, on stderr, so it cannot pollute a
17
+ // piped `--json` envelope if anything ever invokes this indirectly.
18
+ import fs from "node:fs";
19
+ import path from "node:path";
20
+ import { fileURLToPath } from "node:url";
21
+
22
+ const here = path.dirname(fileURLToPath(import.meta.url));
23
+ const setupModule = path.resolve(here, "..", "dist", "setup.js");
24
+
25
+ async function main() {
26
+ // Rule 2: a published tarball ships `dist/` and no `src/`; a checkout has
27
+ // both. Only the former is a real end-user install.
28
+ const isCheckout = fs.existsSync(path.resolve(here, "..", "src"));
29
+ if (isCheckout || !fs.existsSync(setupModule)) return;
30
+
31
+ const { installSkill } = await import(setupModule);
32
+ const destinations = await installSkill();
33
+ process.stderr.write(
34
+ `recess-cli: refreshed the agent skill for ${destinations
35
+ .map((destination) => destination.agent)
36
+ .join(" and ")}\n`
37
+ );
38
+ }
39
+
40
+ try {
41
+ await main();
42
+ } catch {
43
+ // Rule 1. `recess setup` remains the explicit, error-reporting path.
44
+ }
@@ -1,21 +1,33 @@
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); or process class-cancellation credits (the "Please credit these students accordingly" Slack message — credit every registered kid for a guide-canceled session).
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, and read the Mesa workspace files behind a goal or a template snapshot.
4
+ # Bundle version. Bump on every substantive edit; the CLI reports it and `doctor`
5
+ # compares it against the served copy to tell an operator a refresh is available.
6
+ version: 1.3.0
7
+ # The lowest `recess` version this bundle is safe to install onto. Raise it ONLY
8
+ # when the bundle documents a command, flag, or changed semantic that an older
9
+ # binary does not have — an older CLI keeps its bundled copy instead of taking
10
+ # this one. Prose, formatting, and Gotcha edits must NOT raise it; that is the
11
+ # whole point of serving the bundle.
12
+ minCliVersion: 1.2.0
4
13
  ---
5
14
 
6
15
  # Recess CLI (`recess`)
7
16
 
8
17
  Use the installed `recess` command. Never bypass it with direct production database writes, Stripe calls, browser clicks, or hand-written API requests. Every command here operates on **live production data — real families, real kids, real money.**
9
18
 
10
- > **Maintain this skill.** Every time you discover a new failure mode, a working invocation, or a surprising behavior, add it to the "Gotchas" notebook at the bottom (append-only, dated). When a whole workflow changes, update the matching playbook in `reference/`. The skill's source of truth lives in the monolith at `apps/admin-cli/skill/recess-cli/` — fix it there, then reinstall (`pnpm --dir apps/admin-cli run install-skill`) — the published npm package ships the same bundle, so a skill change reaches non-checkout installs only on the next `recess-cli` release.
19
+ > **Maintain this skill.** Every time you discover a new failure mode, a working invocation, or a surprising behavior, add it to the "Gotchas" notebook at the bottom (append-only, dated). When a whole workflow changes, update the matching playbook in `reference/`. The skill's source of truth lives in the monolith at `apps/admin-cli/skill/recess-cli/` — fix it there, then reinstall (`pnpm --dir apps/admin-cli run install-skill`).
20
+ >
21
+ > **This bundle is both published and served.** The npm package ships it (the offline, pre-auth floor), and `GET /admin/cli-skill/` serves it, so an installed CLI picks up wording and Gotcha edits via `recess setup --skill-only` **without waiting for an npm release** — the served copy just has to deploy. Bump `version:` in the frontmatter on every substantive edit. Raise `minCliVersion:` **only** when the bundle documents a command, flag, or changed semantic an older binary lacks; an older CLI then keeps its bundled copy instead. Raising it for a prose edit needlessly strands everyone who has not upgraded.
11
22
 
12
23
  ## The safety model — non-negotiable
13
24
 
14
25
  Every mutating command is two-step. Run it **without** `--confirm` first: the CLI returns `confirmation_required` (exit code 2) plus the exact `action`, `target`, and `request` body it would send, and makes **no network write**.
15
26
 
16
27
  Some previews also carry a `details` object — server-resolved facts that cannot be known offline.
17
- Today those are `enrollments create` (real price, no-charge reuse, and capacity/slot violations) and
18
- `users tier set` (current/proposed capability locks and class-slot consequences); getting either
28
+ Today those include `enrollments create` (real price, no-charge reuse, and capacity/slot
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); getting one
19
31
  costs read-only calls, never a write. **When `details` is present it is part of the preview — show it
20
32
  to the human too.** Approving from `action`/`request` alone while ignoring `details` is how an
21
33
  override gets rubber-stamped.
@@ -39,6 +51,7 @@ Never infer approval from the original task, prior approval, urgency, or a succe
39
51
  | `cohorts email` | real outward email blast to families (include the recipient count from `cohorts parent-emails`) |
40
52
  | `events cancel` | family-facing fan-out: chat messages, parent email blast, credit-owed notes, Slack |
41
53
  | `payout invoices set-status` to `OPEN`/`PAID`/`CANCELED` | moves real account balances; `--send-email` additionally emails the guide |
54
+ | destructive `goal-templates patch-spec` | removes subjects, recipes, plans, queue items, source URLs, or missing-coverage records from a global template; quote the exact `details.safety.removed` inventory and hashes, then use the fresh token only after approval |
42
55
 
43
56
  ## Setup, auth, and troubleshooting
44
57
 
@@ -194,6 +207,27 @@ recess --json onboarding attest <family-id> --condition app_downloaded|tutor_met
194
207
  recess --json onboarding set-intake <family-id> --session <id> --data <json> [--expected-updated-at <iso>] [--confirm]
195
208
  recess --json onboarding extract <family-id> --session <id> (--transcript-file <path> | --granola <ref>) [--confirm]
196
209
 
210
+ # Tutor skills — the authoring know-how (served, never bundled)
211
+ recess --json skills list [--query TEXT] [--category TEXT]
212
+ recess --json skills get <skill-name> [--reference NAME | --all-references] [--refresh]
213
+
214
+ # Learning content (reference/goal-authoring.md)
215
+ recess --json goal-templates list [--query TEXT] [--kind SIMPLE|BLUEPRINT] [--starter-only]
216
+ recess --json goal-templates get <id-or-slug> [--spec-only]
217
+ recess --json goal-templates versions <id-or-slug> [--version N]
218
+ recess --json goal-templates validate-spec --file <path/template.json>
219
+ recess --json goal-templates create --file <path/template.json> [--confirm]
220
+ 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]
222
+ recess --json goal-templates delete <id-or-slug> --expected-version N [--confirm]
223
+ recess --json goal-templates snapshot-files <id-or-slug> [--path P]
224
+ recess --json goal-templates apply <id-or-slug> --answers-file <path> [--dry-run] [--confirm]
225
+ recess --json goal-templates apply-starter <id-or-slug> --student <kid-id> [--confirm]
226
+ recess --json goals list --student <kid-id>
227
+ recess --json goals create --student <kid-id> --title TEXT --description TEXT [--confirm]
228
+ recess --json mesa files list --student <kid-id> --goal <goal-id>
229
+ recess --json mesa files read --student <kid-id> --goal <goal-id> --path P
230
+
197
231
  # Read-only escape hatch (GET only — no raw writes exist)
198
232
  recess --json request get /path?query=value
199
233
  ```
@@ -214,6 +248,122 @@ override, not a retry hint: use it only after the human explicitly approves leav
214
248
  registrations than class slots. A 409 `STALE_WRITE` means the kid changed after the read; fetch a new
215
249
  token, preview again, and discard the old approval.
216
250
 
251
+ ### Learning-content contract — load the tutor skills FIRST
252
+
253
+ Structurally-valid content is not good content. The `recess.gg/ai` agent writes good templates
254
+ because it loads the in-product tutor skills before authoring, and `recess skills` serves you those
255
+ same documents over your admin session. **Before writing any template, goal, or workspace file:**
256
+
257
+ ```bash
258
+ recess --json skills get os-v2-goal-template-builder --all-references
259
+ ```
260
+
261
+ That is not optional politeness — the skill carries the kind decision (Simple vs Blueprint vs
262
+ Prebuilt), the tutor-facing language rules, and `references/deterministic-workflow-setup.md`, which
263
+ is the catalog of what the deterministic handlers can actually express. **If the requested behavior
264
+ cannot be expressed by a supported handler, say so and stop.** Do not substitute something
265
+ structurally valid but wrong.
266
+
267
+ Which skill for which job:
268
+
269
+ | Job | Load |
270
+ |---|---|
271
+ | Build or revise a GoalTemplate | `os-v2-goal-template-builder` (+ all references) |
272
+ | Build a module-backed goal / course workspace | `os-v2-goal-builder` (+ all references) |
273
+ | Create a goal for one specific kid | `goal-creation`, plus `student-research` to read them first |
274
+ | Reading/ELA content | `ela-reading-goal` |
275
+ | Judge whether an existing goal is any good | `goal-health-evaluation` |
276
+ | How the runtime consumes what you authored | `os-v2-curriculum-runtime` |
277
+ | Research an external learning platform before wiring it in | `pipeline-platform-research` |
278
+ | Adapt an existing goal to new evidence | `pipeline-goal-adaptation` |
279
+
280
+ These are proprietary and fetched at runtime; they are **not** in this package and must never be
281
+ copied into it, into a repo, or into a chat transcript you publish.
282
+
283
+ #### Tool → command mapping
284
+
285
+ The skills are written for the in-product agent and name OSAgent **tools** that do not exist here.
286
+ Translate as you read:
287
+
288
+ | Skill says (tool) | You run (command) |
289
+ |---|---|
290
+ | `manage_goal_template action:"list"` | `goal-templates list` |
291
+ | `manage_goal_template action:"get"` | `goal-templates get <id-or-slug>` (`--spec-only` for the spec alone) |
292
+ | `manage_goal_template action:"list_versions"` / `"get_version"` | `goal-templates versions <id>` / `versions <id> --version N` |
293
+ | `manage_goal_template action:"create"` | `goal-templates validate-spec --file …` then `goal-templates create --file … --confirm` |
294
+ | `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
+ | `manage_goal_template action:"update"` (metadata) | `goal-templates set-metadata <id> --expected-version N … --confirm` |
296
+ | `manage_goal_template action:"delete"` | `goal-templates delete <id> --expected-version N --confirm` |
297
+ | `create_goal` | `goals create --student <kid-id> … --confirm`, or `goal-templates apply` when a template exists |
298
+ | `mesa_list_files` / `mesa_read_file` | `mesa files list` / `mesa files read` |
299
+ | `load_skill` / `load_skill_reference` | `skills get <name>` / `skills get <name> --reference <ref>` |
300
+ | `restore_version`, `capture_snapshot`, `clear_snapshot`, `mesa_write_file`, `mesa_edit_file`, `mesa_manage_module` | **No CLI command.** Restoring a version, capturing/clearing a snapshot, and writing workspace files stay in `recess.gg/ai`. Say so and hand the human the web tool. |
301
+
302
+ #### Research tools → your own harness
303
+
304
+ The research protocols (`deterministic-workflow-setup.md` §"The research loop",
305
+ `os-v2-goal-builder`'s `research-protocol.md`, `goal-creation`'s `structured-plan.md` S4 +
306
+ `subagent-tasks.md`, `pipeline-platform-research`) lean on a **different** tool family. Almost none
307
+ of it maps to a CLI command — you substitute your own harness, and the method transfers even though
308
+ the tool names do not:
309
+
310
+ | Skill says (tool) | You use |
311
+ |---|---|
312
+ | `search_web`, `fetch_webpage` | your own web search / page fetch |
313
+ | `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
+ | `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
+ | `search_videos`, `search_books`, `recommend_videos`, `search_gem_library` | your own search. There is no CLI command for the gem library |
316
+ | `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
+ | `search_standards`, `navigate_standards` | no CLI command |
318
+ | `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 |
319
+
320
+ **The one real capability gap: `fetch_page_structure`.** The template research loop is built around
321
+ it — it returns a page's `window.__NAME__` JSON globals, and `globalJsonPath` lets you drill into an
322
+ oversized skill-plan config section by section (`"sections"`, then `"sections[0]"`, …) to assemble a
323
+ queue across several calls without blowing your context. You have no equivalent, so you are parsing
324
+ the page yourself. Two consequences worth stating out loud rather than discovering late:
325
+
326
+ - Read §"The research loop" for the *method* — operational pages over reference pages, embedded JSON
327
+ over anchor scraping, recurse `childSections`, dedupe by `permacode`, record unmatched rows as
328
+ `missingCoverage` — and implement it with what you have. The reasoning is what matters; the tool
329
+ was only ever the means.
330
+ - A large skill-plan page can exceed your context in one read. Fetch it, extract the embedded JSON
331
+ to a file, and walk that file section by section — do not try to hold the whole tree in the
332
+ conversation. If you cannot get a clean structured extraction, **say so and stop** rather than
333
+ shipping a half-mined queue: a short queue looks identical to a correct one in the spec, and the
334
+ skill's own count sanity-check exists precisely because this failure is silent.
335
+
336
+ #### Where the shapes genuinely differ
337
+
338
+ The skills assume a conversational tool loop. This CLI is one-shot and confirm-gated, so adapt
339
+ rather than pretend:
340
+
341
+ - **The skill asks the tutor a question** (`ask_user_question` — e.g. the Simple/Blueprint/Prebuilt
342
+ card). You have no such tool. Ask the human in your own message, in the skill's tutor-facing
343
+ language, and wait for the answer before authoring.
344
+ - **The skill iterates a tool call until the spec validates.** Your loop is: write the file →
345
+ `validate-spec` → read `error` verbatim → fix the file → repeat. It is a read; it writes nothing
346
+ and needs no approval, so iterate freely.
347
+ - **The skill treats create as one step.** Here it is two: the unconfirmed run returns the
348
+ SERVER-resolved `details` (`resolvedSetupHandler`, `resolvedGoalShape`, `wizardStepKeys`,
349
+ `specInventory`). Put those in the approval request — they are what the template will actually do.
350
+ - **Prebuilt templates are half-reachable.** You can create the BLUEPRINT and read a snapshot
351
+ (`snapshot-files`), but `capture_snapshot` has no command, so a template only becomes Prebuilt
352
+ through `recess.gg/ai`. Applying a MODULE_BACKED template with no snapshot 400s — that is the
353
+ server refusing correctly, not a bug to route around.
354
+
355
+ #### The one-way rules
356
+
357
+ - **Every template created here is `setupMode: DETERMINISTIC_WORKFLOW`, and it can never be
358
+ converted back.** There is no AI_CHAT creation path and no downgrade. The confirmation gate
359
+ matters more than usual: an approval here is permanent in a way `set-metadata` is not.
360
+ - **`kind` is the real choice**, not the setup mode: `SIMPLE` (description-only goal + linked todos)
361
+ vs `BLUEPRINT` (a planner-built or snapshot-backed learning path). A MODULE_BACKED spec *requires*
362
+ `BLUEPRINT` and the server enforces it.
363
+ - **`set-metadata` cannot touch the spec, by construction.** That is the guardrail, not a gap.
364
+
365
+ Full workflow: [`reference/goal-authoring.md`](reference/goal-authoring.md).
366
+
217
367
  ## Workflow playbooks
218
368
 
219
369
  Each domain has a full playbook in this skill's `reference/` directory. Read the matching one BEFORE running that domain's writes — they carry the semantics that make the writes correct (which command notifies families, how amounts are computed, which statuses permit which operations).
@@ -227,6 +377,7 @@ Each domain has a full playbook in this skill's `reference/` directory. Read the
227
377
  | Class-cancellation credits — the "Please credit these students accordingly" Slack workflow | [`reference/cancellation-credits.md`](reference/cancellation-credits.md) |
228
378
  | Non-flexible course one-off time shift when `events reschedule` 400s (`allowFlexibleScheduling: false`) | [`reference/class-ops-reschedule.md`](reference/class-ops-reschedule.md) |
229
379
  | 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, reading Mesa workspace files | [`reference/goal-authoring.md`](reference/goal-authoring.md) |
230
381
 
231
382
  ## Deliberately out of scope
232
383
 
@@ -236,6 +387,7 @@ These are excluded from the CLI on purpose. If asked, direct the human to the we
236
387
  - **Money movement:** initiating Mercury payouts, advancing whole pay runs, generating/regenerating payout invoices → `/admin/payout` in the web admin.
237
388
  - **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).
238
389
  - **Cohort creation and schedule editing:** create, start, full-edit/RRULE regeneration, generate-events, guides management → web admin cohort pages.
390
+ - **Restoring a template version, capturing/clearing a snapshot, and writing Mesa workspace files.** Reads exist; these writes do not. Existing-spec edits are the exception: use only `goal-templates patch-spec`, never `set-metadata` or a raw request.
239
391
 
240
392
  ## Guardrails
241
393
 
@@ -248,6 +400,26 @@ These are excluded from the CLI on purpose. If asked, direct the human to the we
248
400
  - `enrollments create` is the ONLY command that spends a family's money. Its unconfirmed run performs a server-side dry run and returns the real resolved price, so never quote a price from the course catalog or from memory — quote `details.billing.effectivePriceCents` — the post-discount amount, never `listPriceCents` — and mention `creditBalanceCents` when non-zero, since credits reduce the first invoice further. If `details.reusedEnrollmentId` is set, say plainly that nothing will be charged.
249
401
  - `enrollments create` replaces impersonating a guardian and walking their checkout. Never suggest impersonation to sign a kid up.
250
402
  - Never retry `enrollments create` after an `ENROLLMENT_PROVISION_FAILED` error. The Stripe subscription already exists; retrying sells a second one. Escalate to engineering with the subscription ID from the error message.
403
+ - **Never author learning content without loading the tutor skills first.** A spec that passes
404
+ `validate-spec` is structurally valid, not good. `validate-spec` cannot tell you the template asks
405
+ a 7-year-old a question phrased for an adult, picks the wrong `kind`, or wires a platform that
406
+ does not work that way — only the skills can.
407
+ - `goal-templates patch-spec` accepts a JSON **array** of 1–50 `add`/`copy`/`replace`/`remove`
408
+ operations. It always asks the server to apply and validate them in memory before the confirmation
409
+ gate. Never manufacture a destructive token: quote `details.safety.removed`, preserve the patch
410
+ file and `--expected-version`, and use only the fresh `details.safety.destructiveChangeToken`
411
+ after the human approves that exact loss.
412
+ - **Never write a person's name, email, Stripe object id, or a real UUID into this skill.** This
413
+ bundle is published to **public npm** and served to every agent — anything added here is
414
+ published. Identifiers belong in Recess API responses at runtime, never in the repo. When a Gotcha
415
+ needs a real incident to be legible, keep the *mechanism* and drop the identity: "one kid's
416
+ Foundations invoice", "two same-week invoices sharing their first 18 characters". A Gotcha has
417
+ never needed a name to be useful, and a test fails the build on id-shaped strings.
418
+ - **Never copy a tutor skill's text into a file, a repo, or anything you publish.** They are
419
+ proprietary and served at runtime precisely so there is exactly one copy. Quote what you need in
420
+ your reasoning; do not persist it.
421
+ - Creating a goal template is creating a **global** record every future apply reads, and its
422
+ deterministic setup mode is permanent. Treat the approval accordingly.
251
423
  - Stop on ambiguous search results and ask the human which family or user they mean.
252
424
  - Report the final API response and re-read the affected resource when a read command can verify the new state.
253
425
 
@@ -272,19 +444,31 @@ Dated, newest last. Add an entry every time reality surprises you.
272
444
  - 2026-07-17 — Before any refund/credit, read the invoice's payment composition from `invoices list` (`token_deduction_cents`, `applied_balance`, `amount` vs `subtotal`, `paymentIntent.status`) and state it in the approval request — token-paid portions go back as `--method tokens`, balance-covered portions mean a "full" cash refund over-refunds. Checklist in `reference/billing.md`.
273
445
  - 2026-07-17 — The cancellation Slack message's "Registered students" list is a cancel-time snapshot and can under-report: a kid registered since 2025 and invoiced for the canceled week was absent from it (Honey Squad, 7/16). Build the roster live from the cohort's REGISTERED registrations when processing credits (`reference/cancellation-credits.md`).
274
446
  - 2026-07-17 — Mixed-composition invoices (tokens + cash) need TWO refund commands, one per portion; observed a manual pass refund the $4 token portion of a $15 line and miss the $11 cash portion. Skip kids whose line already shows `credited_amount`/`token_refunded_cents` > 0 — the manual process runs days late and may race you.
275
- - 2026-07-17 — Stripe invoice IDs can share their first ~18 chars within the same week (`in_1TsAobBBeAjEgjlseDHTGOT1` vs `in_1TsAobBBeAjEgjlsHynCvkUi`) — never prefix-match invoice/line IDs; compare in full.
447
+ - 2026-07-17 — Stripe invoice IDs can share their first ~18 chars within the same week (shape: `in_<18 identical chars>eDHTGOT1` vs `in_<the same 18 chars>HynCvkUi`) — never prefix-match invoice/line IDs; compare in full.
276
448
  - 2026-07-17 — `register-cohort` enforces cohort capacity server-side (400 `RA_REG_NOT_ALLOWED` "This cohort is at capacity") — the admin quiet-link path does NOT bypass it, and capacity editing is out of CLI scope (web admin). Before a batch cohort move, compare `cohorts get` `capacity` against incoming headcount; register-first/unregister-second per kid means a capacity failure leaves that kid safely in the old cohort.
277
449
  - 2026-07-17 — Signup billing shape: a new class signup creates an IMMEDIATE real invoice (backdated to the last Sunday anchor — line reads "Time on <course> from <Sun> until <Sun>", often with a first-week coupon) plus a $0 "Trial period" invoice; the "trial" is an anchor-reset bridge to Sunday billing (`apps/web-server/src/libs/stripe/create-subscription.ts`), NOT a free period. A recently signed-up kid can be `status: trialing` with `trial_end` a Sunday 1–2 weeks out, so a given session week may legitimately have NO invoice (family genuinely not charged) even though the kid paid the immediate invoice for an earlier week. Same-cohort same-week signups can differ (observed: one kid trial_end 7/12 and charged $25 for the 7/12 week; another trial_end 7/19, never charged for it). Before refunding a session for a new signup, read `subscriptions list` `trial_end` to learn which week each invoice actually covers — don't map "Trial period invoice" to "never paid" or the immediate invoice to the current week.
278
450
  - 2026-07-18 — An `install-local` symlink dies when its source worktree is deleted: every invocation exits 127 "no such file or directory". Fix by reinstalling with `pnpm --dir apps/admin-cli run install-persistent` (self-contained copy in `~/.recess-cli/cli/`), which no worktree deletion can break.
279
451
  - 2026-07-18 — Per-kid excused-absence credits (guide asks to credit specific absent kids; class still runs) follow the `cancellation-credits.md` mechanics minus the roster sweep: same Sunday-anchor invoice lookup, same composition check, `invoices refund --method credit --full`, reason "Excused absence - <cohort> <date>". Surface the `--who-pays` call explicitly — the guide default means the requesting guide absorbs the cost, which the human may want to override for a courtesy credit.
280
- - 2026-07-18 — A fully balance-paid invoice (`applied_balance` = -subtotal, `amount` 0, no tokens) takes `--method credit --full` cleanly: the credit note restores the consumed customer balance. The billing.md over-refund warning for `applied_balance < 0` is about CASH refunds (`--method refund`), not balance credits. Verified live on two CoLab invoices (cn_1Tufqt…, cn_1Tufqz…).
281
- - 2026-07-18 — Never call a Slack credit/absence request "unprocessed" from the thread alone — processed requests routinely get no Slack reply. The read-before-assert discipline applies to volunteered recommendations and status summaries, not just writes you're about to execute. Completion evidence lives in billing state and takes TWO reads per kid: `invoices list` (a past week handled by credit shows `credited_amount` + memo) AND `subscriptions list` (a future week can be pre-handled by a billing pause, which leaves NO invoice or enrollment trace — only Stripe `pause_collection`). Observed live: flagged two Brannock requests as open when both credits and a pre-emptive pause through 7/29 were already in place.
452
+ - 2026-07-18 — A fully balance-paid invoice (`applied_balance` = -subtotal, `amount` 0, no tokens) takes `--method credit --full` cleanly: the credit note restores the consumed customer balance. The billing.md over-refund warning for `applied_balance < 0` is about CASH refunds (`--method refund`), not balance credits. Verified live on two CoLab invoices, each returning its own credit note.
453
+ - 2026-07-18 — Never call a Slack credit/absence request "unprocessed" from the thread alone — processed requests routinely get no Slack reply. The read-before-assert discipline applies to volunteered recommendations and status summaries, not just writes you're about to execute. Completion evidence lives in billing state and takes TWO reads per kid: `invoices list` (a past week handled by credit shows `credited_amount` + memo) AND `subscriptions list` (a future week can be pre-handled by a billing pause, which leaves NO invoice or enrollment trace — only Stripe `pause_collection`). Observed live: flagged two requests from one family as open when both credits and a pre-emptive pause through 7/29 were already in place.
282
454
  - 2026-07-20 — "Skip next week" from a family is ambiguous mid-week and the two readings need OPPOSITE commands — resolve it with the human before previewing. The Sunday anchor means the week already in progress is ALREADY INVOICED AND PAID, so a pause does nothing for it (that week needs `invoices refund`); only the not-yet-issued Sunday tick can be voided by `billing pause`. Read `invoices list` for the latest `created_date` (a Sunday 00:00 UTC stamp) and the subscription's `period_end` to see exactly where the paid/unpaid boundary sits, then ask which session they mean. Observed live: Mon 7/20 request to skip "next week" on a Thursday 1-on-1 — Thu 7/23 was already paid, Thu 7/30 was not.
283
455
  - 2026-07-20 — A billing pause deliberately leaves the session ACTIVE on the calendar; skipping the charge and canceling the class are separate decisions with wildly different blast radii (pause = silent, `events cancel` = family email blast + chat + Slack). Ask which one the human wants rather than assuming a skipped week implies a canceled session.
284
- - 2026-07-20 — **A fully-credited MIXED invoice does NOT show `credited_amount` == the line's full amount** — the token half lands in the invoice-level `token_refunded_cents` while the line's `credited_amount` only ever reflects the CASH half. Foundations of Science 8-11, Natan Rocklin: a complete $30.00 credit verifies as `credited_amount: 2001` + `token_refunded_cents: 999` + `token_refundable_remaining_cents: 0` on a line whose `amount` is 3000. Read it as under-refunded and you will double-credit the family. The reliable "is this line fully made whole?" test is `credited_amount + token_refunded_cents == line amount` AND `token_refundable_remaining_cents == 0`. Note the two writes also return different envelopes: `--method credit` gives `{noteId: "cn_…"}`, `--method tokens` gives `{creditTransactionId, newBalance}` (no credit note exists for a token refund).
456
+ - 2026-07-20 — **A fully-credited MIXED invoice does NOT show `credited_amount` == the line's full amount** — the token half lands in the invoice-level `token_refunded_cents` while the line's `credited_amount` only ever reflects the CASH half. Observed on a Foundations of Science 8-11 line: a complete $30.00 credit verifies as `credited_amount: 2001` + `token_refunded_cents: 999` + `token_refundable_remaining_cents: 0` on a line whose `amount` is 3000. Read it as under-refunded and you will double-credit the family. The reliable "is this line fully made whole?" test is `credited_amount + token_refunded_cents == line amount` AND `token_refundable_remaining_cents == 0`. Note the two writes also return different envelopes: `--method credit` gives `{noteId: "cn_…"}`, `--method tokens` gives `{creditTransactionId, newBalance}` (no credit note exists for a token refund).
285
457
  - 2026-07-20 — Payment composition varies PER KID inside a single cohort week — never read one kid's invoice and apply that instrument to the roster. One 3-kid cancellation sweep hit all three shapes at once: fully cash-paid, fully balance-paid (`applied_balance` −3000, `amount` 0, no paymentIntent), and mixed tokens+cash. Same course, same $30 price, same Sunday invoice batch, three different correct commands (and four total writes for three kids).
286
- - 2026-07-20 — The invoice-ID prefix collision is not rare enough to ignore: within ONE kid-cohort sweep, Zayn Kacem's Foundations invoice `in_1TuiGeBBeAjEgjlsrwjle3Oz` and Andromeda Brand's Terraria invoice `in_1TuiGeBBeAjEgjlszZROm407` shared their first 18 chars (both minted in the same Sunday 00:07 UTC batch run). Scope every `invoices list` to the specific subscription and compare IDs in full — the same-second batch anchor is exactly what manufactures these near-twins.
458
+ - 2026-07-20 — The invoice-ID prefix collision is not rare enough to ignore: within ONE kid-cohort sweep, one kid's Foundations invoice and another kid's Terraria invoice shared their first 18 characters, differing only in the final 8 (both minted in the same Sunday 00:07 UTC batch run). Scope every `invoices list` to the specific subscription and compare IDs in full — the same-second batch anchor is exactly what manufactures these near-twins.
287
459
  - 2026-07-21 — The earlier browser-only auth limitation is resolved for headless agents: run `auth request`, give the returned approval URL/code to a human admin, then run `auth poll`. The device secret remains in the mode-0600 config and must never be surfaced. The resulting session still expires after 12 hours and cannot refresh itself.
288
- - 2026-07-21 — **Never answer "was this cancellation refund handled?" from the DB alone.** `CreditTransactionLog` only shows `--method tokens` grants; Stripe cash/balance path (`--method credit` / `--method refund`) lands only as invoice `credited_amount` + credit-note memo via `invoices list`. Observed live: Foundations of Science 8-11 7/20Zayn (full cash credit $30) and Andromeda (full balance credit $30) looked "open" in DB/token logs while admin CLI invoices already showed `credited_amount: 3000` with memo `Guide cancellation - Foundations of Science (8-11) 2026-07-20`. Always verify completion with `recess --json invoices list --subscription <sub>` (and the mixed-invoice test `credited_amount + token_refunded_cents == line amount`).
289
- - 2026-07-21 — **Future canceled session = pause, not credit.** When the cancel lands before the Sunday that starts the canceled week, no invoice exists yet — `invoices refund` has nothing to target. Use `billing pause --until` mid-week after that Sunday to void only that tick (Scratch 'n Hack 2 Aug 4 cancel → pause until 2026-08-06 on `sub_1TlVBBB…`). Documented in `reference/cancellation-credits.md` §3b. Don't route these threads to Linear; load this skill + cancellation-credits playbook immediately on `#cohort-cancellations` / "Please credit these students accordingly".
460
+ - 2026-07-21 — **Never answer "was this cancellation refund handled?" from the DB alone.** `CreditTransactionLog` only shows `--method tokens` grants; Stripe cash/balance path (`--method credit` / `--method refund`) lands only as invoice `credited_amount` + credit-note memo via `invoices list`. Observed live on one Foundations of Science 8-11 session: two kids one credited in cash, one against their customer balance both looked "open" in DB/token logs while admin CLI invoices already showed `credited_amount: 3000` with a `Guide cancellation - <course> <date>` memo. Always verify completion with `recess --json invoices list --subscription <sub>` (and the mixed-invoice test `credited_amount + token_refunded_cents == line amount`).
461
+ - 2026-07-21 — **Future canceled session = pause, not credit.** When the cancel lands before the Sunday that starts the canceled week, no invoice exists yet — `invoices refund` has nothing to target. Use `billing pause --until` mid-week after that Sunday to void only that tick (Scratch 'n Hack 2 Aug 4 cancel → pause the affected subscription until 2026-08-06). Documented in `reference/cancellation-credits.md` §3b. Don't route these threads to Linear; load this skill + cancellation-credits playbook immediately on `#cohort-cancellations` / "Please credit these students accordingly".
290
462
  - 2026-07-23 — `events reschedule` on a non-flexible course 400s (`allowFlexibleScheduling: false`) even for a simple ±10 min move (Space Technology & Rocket Launches / Starship). Check `course.allowFlexibleScheduling` on `cohorts get` **before** promising a reschedule. Staff workaround already in use: `events add` at the new cohort-local time + `events set-status CANCELED` on the original (silent — not `events cancel`). Enabling flexible scheduling is web-admin-only. Details: `reference/class-ops-reschedule.md`.
463
+ - 2026-08-04 — **`fetch failed` from any command means the agent's sandbox blocked the network, not that the session died.** Observed in Codex: `command -v recess` and `recess --help` work, but `recess --json doctor` returns `{"ok":false,"error":{"code":"unexpected_error","message":"fetch failed"}}`. Codex's default `workspace-write` sandbox has no network, and every non-preview command here calls the API. Do NOT respond by re-running `auth login` / `auth request` — the session is fine. Fix the sandbox instead: in `~/.codex/config.toml` set `sandbox_mode = "workspace-write"` plus a `[sandbox_workspace_write]` table with `network_access = true` (that table must sit AFTER any bare top-level keys, or TOML folds them into it), or run `codex --sandbox danger-full-access`. A genuinely expired session looks different: a clean `auth_required` / 401 from the API, not a transport failure.
464
+ - 2026-08-04 — **`skills get` serves proprietary content and is not in this package.** The tutor skills live in a private submodule and are fetched over `/admin/skills/*` behind the admin session; nothing is bundled. Two consequences: (a) a skills-repo update reaches you with no CLI release, so `--refresh` is how you defeat the ~1h local cache after someone edits a skill; (b) if `skills get` 403s, your session is not ADMIN — GUIDE/PROGRAM are refused on this family even though they pass the general `/admin` gate.
465
+ - 2026-08-04 — **The template file feeds `validate-spec` and `create` unchanged.** One JSON document with the metadata AND the `setupWorkflowSpec`; the CLI drops unknown keys, so a file produced by piping `goal-templates get <id>` into an editor works — `version`, `createdById`, `updatedAt` are ignored rather than rejected. It also accepts `tags` as a comma string. What it will NOT accept: `setupWorkflowSpec` as a JSON *string* (a common serialization slip — it must be a real object) or any `setupMode` other than DETERMINISTIC_WORKFLOW.
466
+ - 2026-08-04 — **Hand-writing a `setupWorkflowSpec` from memory does not work and `validate-spec` is how you find out cheaply.** The handler configs are `.strict()` — a plausible-looking `{tool, studentsStepKey}` for TOOL_GENERATED_TODO_SETUP fails with both "Required" errors AND `unrecognized_keys`. The real config needs `platformName`, `toolName` (`create_passage`|`create_writing_passage`), `goalTitleTemplate`, `todoTitle`. Read `os-v2-goal-template-builder`'s `references/deterministic-workflow-setup.md` for the catalog instead of guessing; then iterate on `validate-spec`, which writes nothing.
467
+ - 2026-08-04 — **`goal-templates apply` without `--dry-run` still calls the backend once before the gate** (its own `dryRun:true`) to build the preview. That is a read, not a write, and it is the only way to know which kids get goals, which are `skipped_existing`, and what `missingCoverage` is unfilled. Show `details.counts` and `details.results` in the approval request. `apply-starter` has no dry run at all, so its preview is offline — and it 404s when the acting admin lacks the `school-onboarding-v1` flag, which reads like "template missing" but is not.
468
+ - 2026-08-04 — `goal-templates get|versions|delete|apply` accept a **slug** as well as a UUID; the CLI resolves it through the list route (one extra read). `versions` without `--version` deliberately omits each version's frozen spec and agent instructions — history stays scannable; pull one with `--version N`.
469
+ - 2026-08-04 — **The research protocols assume tools the CLI does not have, and one of them has no substitute.** `search_web`/`fetch_webpage`/`spawn_subagents`/`validate_urls` map cleanly onto your own harness (the five researcher prompts in `goal-creation`'s `subagent-tasks.md` are prompts, not tool calls — reusable almost verbatim). `fetch_page_structure` does not: the template research loop is designed around its JSON-globals extraction plus `globalJsonPath` drilling, which is how a huge skill-plan config gets mined section-by-section without blowing context. Extract the page's embedded JSON to a file and walk the file instead. And note what has no command at all: kid memory / activity SQL (use the `query-db` skill), `search_standards`, the gem library, and `save_platform_research` — research can inform a spec you write, but a reusable platform profile is only persistable from `recess.gg/ai`.
470
+ - 2026-08-04 — **A half-mined queue is invisible.** A spec with 40 of 120 skills validates exactly like a complete one — `validate-spec` checks shape, never coverage. The skill's count sanity-check (queue length vs. what a human skimming the source page would expect) is the only guard, and you are running it by hand now that `fetch_page_structure` is unavailable. Under-length usually means you stopped recursing `childSections` too early; over-length means you did not dedupe by `permacode`. If a clean structured extraction is not achievable, stop and say so rather than shipping the partial queue.
471
+ - 2026-08-04 — **This skill now updates without an npm release, but only after the server redeploys.** `recess setup --skill-only` installs the bundled copy then pulls the served one from `GET /admin/cli-skill/`. The server **memoizes** the bundle at first read (it only changes on deploy), so editing SKILL.md in a checkout does NOT change what a local dev server serves until it restarts — a genuinely confusing few minutes if you are testing the flow. `recess --json --version` reports `{cliVersion, skillVersion}`; `doctor.skill` reports whether a newer one exists and names the command, and never writes.
472
+ - 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
+ - 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
+ - 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.
@@ -29,7 +29,7 @@ Find the CANCELED event matching the stated date (cohort-local time — "1:30 PM
29
29
  recess --json enrollments list --user <kid-id> # → the enrollment registered to THIS cohort → stripeSubscriptionId
30
30
  recess --json invoices list --subscription <sub-id>
31
31
  ```
32
- Weekly subscriptions bill on Sunday-00:00-UTC anchors ([`billing.md`](billing.md)): the invoice created on the **Sunday starting the session's week** is the one owed back (session Thu 7/16 → the Sunday 7/12 invoice). No such invoice (pause, trial, joined mid-week) → skip, say so. Match invoice IDs **in full** — two distinct same-week invoices have shared their first 18 characters (`in_1TsAobBBeAjEgjls…` twice).
32
+ Weekly subscriptions bill on Sunday-00:00-UTC anchors ([`billing.md`](billing.md)): the invoice created on the **Sunday starting the session's week** is the one owed back (session Thu 7/16 → the Sunday 7/12 invoice). No such invoice (pause, trial, joined mid-week) → skip, say so. Match invoice IDs **in full** — two distinct same-week invoices have shared their first 18 characters, differing only in the final 8.
33
33
 
34
34
  **New-signup trap:** signups create an immediate backdated invoice + a $0 "Trial period" invoice, and the sub sits `trialing` until a Sunday anchor (see the SKILL.md 2026-07-17 signup-billing gotcha). The immediate invoice pays for the kid's FIRST class week, not necessarily the session's week; if `trial_end` (from `subscriptions list`) is at/after the Sunday ending the session's week, the family was never charged for that session → skip, and don't claw back the immediate invoice (it maps to an earlier session).
35
35
 
@@ -14,7 +14,7 @@ CLI cannot toggle the flag — web admin course settings only. Do not stop at "e
14
14
 
15
15
  ## Worked example (2026-07-23)
16
16
 
17
- - Cohort: Space Technology & Rocket Launches (`1be248c5-acb9-472e-91d7-7d1dc9f37dd0`)
17
+ - Cohort: Space Technology & Rocket Launches (resolve its id with `cohorts search`)
18
18
  - Course: Current Events in Space Tech & Rockets — `allowFlexibleScheduling: false`
19
19
  - Ask: move today up 10 minutes (Starship)
20
20
  - Usual slot: Thu 15:45 America/Edmonton → UTC `21:45`