@novedu/cli 0.12.0 → 0.13.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.
Files changed (3) hide show
  1. package/README.md +119 -25
  2. package/dist/main.js +321 -171
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -1,43 +1,46 @@
1
1
  # @novedu/cli
2
2
 
3
3
  Command-line companion for the Novedu chat app (installed command: `novedu-cli`;
4
- requires Node >= 20). It validates every activity YAML the app accepts — **tutors**,
5
- **fragment libraries**, **quizzes**, **writing activities**, and **coding
6
- activities** — and signs in to Microsoft Entra ID (`login` / `logout` / `whoami`)
7
- to call the app's protected APIs; more commands will follow. Validating a tutor
8
- also fully validates every fragment library it references; pass `--kind` to
9
- validate any other kind on its own.
4
+ requires Node >= 20). It covers two jobs:
10
5
 
11
- It reuses the app's exact validation pipeline (`lib/tutors`, `lib/quiz-validate`,
12
- `lib/writing-validate`, `lib/coding-validate`), so an activity that passes here is
13
- the same one the app would accept — no separate, drifting rules.
6
+ - **Validate activity YAML** tutors, fragment libraries, quizzes, writing
7
+ activities, and coding activities with the app's exact validation pipeline,
8
+ offline and without signing in.
9
+ - **Manage the app as a teacher** — sign in with Microsoft Entra ID, then mint
10
+ activity codes, upload app-hosted YAML files, and triage student reports,
11
+ straight from the terminal (or from a coding agent, see below).
14
12
 
15
- ## Usage
13
+ No install needed:
16
14
 
17
15
  ```bash
18
- # Validate a local file (relative fragment_files resolve against the file's location)
16
+ npx @novedu/cli --help
17
+ ```
18
+
19
+ ## Validating activities: `validate`
20
+
21
+ ```bash
22
+ # Validate a local tutor (relative fragment_files resolve against the file's location)
19
23
  npx @novedu/cli validate ./activities/examples/sorting-algorithms/sorting-tutor.yaml
20
24
 
21
- # Validate a published tutor by URL
25
+ # Validate a published activity by URL
22
26
  npx @novedu/cli validate https://raw.githubusercontent.com/Teaching-HTL-Leonding/novedu-chat-mvp/refs/heads/main/activities/examples/sorting-algorithms/sorting-tutor.yaml
23
27
 
24
- # Validate a fragment library on its own
28
+ # Other kinds: fragment library, quiz, writing activity, coding activity
25
29
  npx @novedu/cli validate ./activities/examples/shared/general-fragments.yaml --kind fragment
26
-
27
- # Validate a quiz, a writing activity, or a coding activity
28
30
  npx @novedu/cli validate ./activities/examples/sorting-algorithms/sorting-quiz.yaml --kind quiz
29
- npx @novedu/cli validate ./activities/examples/review-writing/restaurant-review-letter.yaml --kind writing
30
- npx @novedu/cli validate ./activities/examples/sorting-algorithms/sorting-visualizer.yaml --kind coding
31
31
 
32
32
  # Machine-readable output (the raw validation result)
33
- npx @novedu/cli validate ./activities/examples/sorting-algorithms/sorting-tutor.yaml --json
33
+ npx @novedu/cli validate ./my-quiz.yaml --kind quiz --json
34
34
  ```
35
35
 
36
- `--kind` accepts `tutor` (default), `fragment`, `quiz`, `writing`, or `coding`; it
37
- is caller-declared, not auto-detected.
38
-
39
- Exit code is `0` when the activity is valid and `1` when it has errors, so it works
40
- as a pre-commit / CI gate.
36
+ - `--kind` accepts `tutor` (default), `fragment`, `quiz`, `writing`, or
37
+ `coding`; it is caller-declared, not auto-detected.
38
+ - The CLI reuses the app's exact validation pipeline (`lib/prompt-fragments`,
39
+ `lib/tutors`, `lib/quiz-validate`, `lib/writing-validate`,
40
+ `lib/coding-validate`), so an activity that passes here is the same one the
41
+ app accepts — no separate, drifting rules. Validating a tutor also fully
42
+ validates every fragment library it references.
43
+ - Exit code `0` = valid, `1` = errors found — usable as a pre-commit / CI gate.
41
44
 
42
45
  ## Authentication
43
46
 
@@ -61,6 +64,9 @@ npx @novedu/cli logout # remove the cached credentials from this machine
61
64
  file `0600`). The cache holds a refresh token, so after the one sign-in every
62
65
  command runs non-interactively; treat the file like a credential. `logout`
63
66
  is purely local — issued tokens expire on their own (~1 h).
67
+ - `whoami` proves the full round-trip and shows your display name, user id, and
68
+ whether the account is a teacher (`Teacher: yes/no`) — the management
69
+ commands below need a teacher account.
64
70
  - The server defaults to the production app; override per command with
65
71
  `--server <url>` or the `NOVEDU_SERVER` env var (e.g.
66
72
  `http://localhost:3000` for development). Other deployments of the app can
@@ -69,6 +75,94 @@ npx @novedu/cli logout # remove the cached credentials from this machine
69
75
  - Not signed in (or the cached token expired for good)? Commands exit 1 with
70
76
  `Not signed in — run "novedu-cli login".`
71
77
 
78
+ ## Managing codes & files (teacher account required)
79
+
80
+ The `codes` and `files` groups call the app's API as the signed-in teacher. The
81
+ server runs the identical validation pipeline as the web forms and is
82
+ authoritative — the CLI sends your input as-is and relays the server's answer.
83
+
84
+ ```
85
+ codes create --module <tutor|quiz|writing|coding> --file <url>
86
+ [--start <iso>] [--end <iso>] [--note <text>]
87
+ [--llm-provider <p> --llm-model <m>]
88
+ codes list [--search <q>] [--module <m>] [--all]
89
+ files upload <name> [--kind <tutor|fragment|quiz|writing|coding>]
90
+ (--file <path> | reads stdin)
91
+ files list [--search <q>] [--all]
92
+ ```
93
+
94
+ - **Output is JSON only.** Success: the API's objects verbatim on stdout, exit
95
+ 0 (pipe into `jq`). Failure: JSON on stderr — `{ message }` or
96
+ `{ errors: [...] }` with the full structured validation detail — and exit 1.
97
+ - `codes create` mints a shareable code for an activity YAML at a public URL
98
+ (or an app-hosted `…/api/files/<name>` URL); the YAML is validated
99
+ server-side before the code is stored, and the response includes the
100
+ shareable `url`. `--start`/`--end` must be ISO 8601 **with an explicit
101
+ offset or `Z`** (e.g. `2026-07-07T08:00:00Z`); the
102
+ `--llm-provider`/`--llm-model` override pair is both-or-nothing.
103
+ - `files upload <name>` is an **upsert**: creating a new file requires
104
+ `--kind`; an existing file's kind is frozen at create time (a contradicting
105
+ `--kind` fails with 409). The YAML comes from `--file <path>` or stdin.
106
+ Every hosted file is public at the `url` the list returns — no download
107
+ command needed.
108
+ - Both `list` commands default to **only your own** codes/files (like the web
109
+ lists); `--all` widens to every teacher's, `--search` is a contains-filter.
110
+
111
+ Example — host a quiz and share it:
112
+
113
+ ```bash
114
+ npx @novedu/cli files upload sorting-quiz --kind quiz --file ./sorting-quiz.yaml
115
+ # { "name": "sorting-quiz", "kind": "quiz", "url": "https://…/api/files/sorting-quiz", "action": "created" }
116
+
117
+ npx @novedu/cli codes create --module quiz \
118
+ --file https://…/api/files/sorting-quiz \
119
+ --start 2026-07-07T08:00:00Z --note "3A Monday"
120
+ # { "code": "…", "url": "https://…/<code>", … } — hand the url to students
121
+ ```
122
+
123
+ ## Triaging student reports (teacher account required)
124
+
125
+ Students can flag an AI interaction — a chat or a graded quiz answer — with a
126
+ reaction and an optional note. The `reports` group reads and resolves those
127
+ flags; same JSON stdout/stderr contract as above.
128
+
129
+ ```
130
+ reports list [--status <open|resolved|all>] [--reaction <good|omg|bad|holysh>]
131
+ [--search <q>] [--all]
132
+ reports show <id>
133
+ reports resolve <id...>
134
+ ```
135
+
136
+ - `reports list` defaults to **open reports on your own codes** (like the web
137
+ inbox), most urgent first; `--all` widens to every teacher's codes.
138
+ - `reports show <id>` prints one report in full. A **chat** report embeds the
139
+ conversation transcript as a `messages` array; a **quiz-answer** report
140
+ already carries its question / answer / feedback snapshot inline.
141
+ - `reports resolve <id...>` resolves one or more reports in a single request;
142
+ unknown or already-resolved ids are silently ignored.
143
+ - The CLI deliberately cannot file, reopen, or delete a report — those stay in
144
+ the web `/reports` inbox.
145
+
146
+ The typical loop for turning a report into a better activity:
147
+
148
+ ```bash
149
+ npx @novedu/cli reports list --reaction holysh # find the urgent flags
150
+ npx @novedu/cli reports show 3f2c… # read the report + transcript
151
+ # fix the activity YAML, then check it offline:
152
+ npx @novedu/cli validate ./sorting-quiz.yaml --kind quiz
153
+ npx @novedu/cli files upload sorting-quiz --file ./sorting-quiz.yaml
154
+ npx @novedu/cli reports resolve 3f2c… # existing codes already serve the fix
155
+ ```
156
+
157
+ ## Using the CLI from a coding agent
158
+
159
+ The app repo ships a Claude Code skill that teaches coding agents the full CLI
160
+ workflow — validation, the sign-in hand-off, code/file management, and the
161
+ report-triage loop:
162
+ [`.claude/skills/novedu-tutor-cli/SKILL.md`](https://github.com/Teaching-HTL-Leonding/novedu-chat-mvp/blob/main/.claude/skills/novedu-tutor-cli/SKILL.md)
163
+ (mirrored at `.agents/skills/novedu-tutor-cli/`). Agents working inside that
164
+ repo pick it up automatically.
165
+
72
166
  ## Development
73
167
 
74
168
  The CLI lives in the app repo as an npm workspace.
@@ -79,5 +173,5 @@ npm run cli:build # bundle to cli/dist via ts
79
173
  npm run test:cli # build + integration tests (local & live URLs)
80
174
  ```
81
175
 
82
- The fast in-process unit test (`cli/src/commands/validate.unit.test.ts`) runs in
83
- CI; the integration tests hit the network and are local-only.
176
+ The fast in-process unit tests (`cli/src/commands/*.unit.test.ts`) run in CI;
177
+ the integration tests hit the network and are local-only.
package/dist/main.js CHANGED
@@ -396,25 +396,13 @@ const COMPILE_OPTIONS = {
396
396
  noEscape: true
397
397
  };
398
398
  /**
399
- * Render each fragment in priority order and, when provided, append the caller's
400
- * trailing instructions last (they carry no priority, so "after everything" is the
401
- * only deterministic position the exact role `tutor_instructions` plays for a
402
- * tutor, and the activity frame / `instructions` play for quiz / writing / coding).
403
- * May throw if a template references a missing variable.
404
- *
405
- * `trailingInstructions` is optional so a consumer can assemble a fragment-only
406
- * PREAMBLE (quiz / writing / coding) and concatenate its own frame afterwards. An
407
- * empty plan with no trailing text renders to the empty string (so an activity that
408
- * declares no fragments gets no stray whitespace); every non-empty result ends in a
409
- * single trailing newline, byte-identical to the historic tutor output.
399
+ * Compile and render a single fragment's `content` with the merged variables. Shared
400
+ * by the host-template `fragment` helper (real render) so the produced text is
401
+ * byte-identical to what the standalone fragment check exercises. May throw if the
402
+ * template references a variable not in `variables` (strict mode).
410
403
  */
411
- function assembleSystemPrompt(plan, trailingInstructions) {
412
- const parts = plan.map((fragment) => {
413
- return Handlebars.compile(fragment.content, COMPILE_OPTIONS)(fragment.variables).trimEnd();
414
- });
415
- if (trailingInstructions !== void 0) parts.push(trailingInstructions.trimEnd());
416
- if (parts.length === 0) return "";
417
- return `${parts.join("\n\n")}\n`;
404
+ function renderFragmentContent(content, variables) {
405
+ return Handlebars.compile(content, COMPILE_OPTIONS)(variables);
418
406
  }
419
407
  //#endregion
420
408
  //#region ../lib/prompt-fragments/errors.ts
@@ -473,118 +461,151 @@ function typeMismatch(prop, value) {
473
461
  };
474
462
  }
475
463
  }
476
- function checkConsistency(block, fragmentFilesByAlias) {
464
+ /** Split an inline reference at the FIRST dot: aliases cannot contain dots, ids may. */
465
+ function splitFragmentRef(ref) {
466
+ const dot = ref.indexOf(".");
467
+ if (dot === -1) return {
468
+ alias: ref,
469
+ fragmentId: ""
470
+ };
471
+ return {
472
+ alias: ref.slice(0, dot),
473
+ fragmentId: ref.slice(dot + 1)
474
+ };
475
+ }
476
+ /**
477
+ * Resolve one `"alias.id"` reference against the fetched libraries and validate its
478
+ * inline args against the fragment's `input_schema` — required present, types correct,
479
+ * undeclared flagged, optional defaults filled in (a supplied value always wins). The
480
+ * same required/type/undeclared/defaults machinery that ran per document-level ref
481
+ * before, now per placement.
482
+ */
483
+ function resolveAndMerge(ref, args, filesByAlias) {
477
484
  const errors = [];
478
485
  const warnings = [];
479
- const aliasCounts = /* @__PURE__ */ new Map();
480
- for (const ref of block.fragment_files) aliasCounts.set(ref.id, (aliasCounts.get(ref.id) ?? 0) + 1);
481
- for (const [alias, count] of aliasCounts) if (count > 1) errors.push(error("DUPLICATE_FRAGMENT_FILE_ALIAS", `Fragment-file alias "${alias}" is declared ${count} times`, { fileAlias: alias }));
482
- const fragmentIndex = /* @__PURE__ */ new Map();
483
- for (const [alias, file] of fragmentFilesByAlias) {
484
- const byId = /* @__PURE__ */ new Map();
485
- for (const frag of file.fragments) {
486
- if (byId.has(frag.id)) {
487
- errors.push(error("DUPLICATE_FRAGMENT_ID_IN_FILE", `Fragment "${frag.id}" is declared more than once in file "${alias}"`, {
488
- fileAlias: alias,
489
- fragmentId: frag.id
490
- }));
491
- continue;
492
- }
493
- byId.set(frag.id, frag);
494
- }
495
- fragmentIndex.set(alias, byId);
486
+ const { alias, fragmentId } = splitFragmentRef(ref);
487
+ const file = filesByAlias.get(alias);
488
+ if (!file) {
489
+ errors.push(error("UNKNOWN_FRAGMENT_FILE_ALIAS", `Fragment marker "${ref}" uses unknown file alias "${alias}"`, {
490
+ fileAlias: alias,
491
+ fragmentId
492
+ }));
493
+ return {
494
+ errors,
495
+ warnings,
496
+ variables: args,
497
+ content: null
498
+ };
496
499
  }
497
- const resolved = [];
498
- const seenRefs = /* @__PURE__ */ new Set();
499
- for (const ref of block.fragments) {
500
- const refKey = `${ref.file}::${ref.id}`;
501
- if (seenRefs.has(refKey)) warnings.push(warning("DUPLICATE_FRAGMENT_REFERENCE", `Fragment "${ref.id}" from "${ref.file}" is referenced more than once`, {
502
- fileAlias: ref.file,
503
- fragmentId: ref.id
500
+ const fragment = file.fragments.find((f) => f.id === fragmentId);
501
+ if (!fragment) {
502
+ errors.push(error("FRAGMENT_NOT_FOUND", `Fragment "${fragmentId}" not found in file "${alias}"`, {
503
+ fileAlias: alias,
504
+ fragmentId
505
+ }));
506
+ return {
507
+ errors,
508
+ warnings,
509
+ variables: args,
510
+ content: null
511
+ };
512
+ }
513
+ return {
514
+ errors,
515
+ warnings,
516
+ variables: mergeVariables(alias, fragment, args, errors, warnings),
517
+ content: fragment.content
518
+ };
519
+ }
520
+ /** Validate `args` against `fragment.input_schema` and merge in optional defaults. */
521
+ function mergeVariables(alias, fragment, args, errors, warnings) {
522
+ const merged = { ...args };
523
+ const schema = fragment.input_schema;
524
+ const id = fragment.id;
525
+ if (!schema) {
526
+ for (const name of Object.keys(args)) warnings.push(warning("UNDECLARED_VARIABLE", `Variable "${name}" supplied to "${id}", which declares no input schema`, {
527
+ fileAlias: alias,
528
+ fragmentId: id,
529
+ variable: name
504
530
  }));
505
- seenRefs.add(refKey);
506
- const byId = fragmentIndex.get(ref.file);
507
- if (!byId) {
508
- errors.push(error("UNKNOWN_FRAGMENT_FILE_ALIAS", `Fragment reference uses unknown file alias "${ref.file}"`, {
509
- fileAlias: ref.file,
510
- fragmentId: ref.id
531
+ return merged;
532
+ }
533
+ for (const name of schema.required) if (!(name in args)) errors.push(error("MISSING_REQUIRED_VARIABLE", `Fragment "${id}" requires variable "${name}", which is not supplied`, {
534
+ fileAlias: alias,
535
+ fragmentId: id,
536
+ variable: name
537
+ }));
538
+ for (const [name, value] of Object.entries(args)) {
539
+ const prop = schema.properties[name];
540
+ if (!prop) {
541
+ warnings.push(warning("UNDECLARED_VARIABLE", `Variable "${name}" supplied to "${id}" is not declared in its input schema`, {
542
+ fileAlias: alias,
543
+ fragmentId: id,
544
+ variable: name
511
545
  }));
512
546
  continue;
513
547
  }
514
- const fragment = byId.get(ref.id);
515
- if (!fragment) {
516
- errors.push(error("FRAGMENT_NOT_FOUND", `Fragment "${ref.id}" not found in file "${ref.file}"`, {
517
- fileAlias: ref.file,
518
- fragmentId: ref.id
548
+ const mismatch = typeMismatch(prop, value);
549
+ if (mismatch) errors.push(error("VARIABLE_TYPE_MISMATCH", `Variable "${name}" of "${id}" should be ${mismatch.expected} but got ${mismatch.actual}`, {
550
+ fileAlias: alias,
551
+ fragmentId: id,
552
+ variable: name,
553
+ expectedType: mismatch.expected,
554
+ actualType: mismatch.actual
555
+ }));
556
+ }
557
+ const requiredSet = new Set(schema.required);
558
+ for (const [name, prop] of Object.entries(schema.properties)) {
559
+ if (prop.default === void 0) continue;
560
+ if (requiredSet.has(name)) {
561
+ warnings.push(warning("REQUIRED_PROPERTY_HAS_DEFAULT", `Variable "${name}" of "${id}" is required, so its default is never used`, {
562
+ fileAlias: alias,
563
+ fragmentId: id,
564
+ variable: name
519
565
  }));
520
566
  continue;
521
567
  }
522
- const variables = ref.variables ?? {};
523
- const schema = fragment.input_schema;
524
- const merged = { ...variables };
525
- if (schema) {
526
- for (const name of schema.required) if (!(name in variables)) errors.push(error("MISSING_REQUIRED_VARIABLE", `Fragment "${ref.id}" requires variable "${name}", which is not supplied`, {
527
- fileAlias: ref.file,
528
- fragmentId: ref.id,
529
- variable: name
530
- }));
531
- for (const [name, value] of Object.entries(variables)) {
532
- const prop = schema.properties[name];
533
- if (!prop) {
534
- warnings.push(warning("UNDECLARED_VARIABLE", `Variable "${name}" supplied to "${ref.id}" is not declared in its input schema`, {
535
- fileAlias: ref.file,
536
- fragmentId: ref.id,
537
- variable: name
538
- }));
539
- continue;
540
- }
541
- const mismatch = typeMismatch(prop, value);
542
- if (mismatch) errors.push(error("VARIABLE_TYPE_MISMATCH", `Variable "${name}" of "${ref.id}" should be ${mismatch.expected} but got ${mismatch.actual}`, {
543
- fileAlias: ref.file,
544
- fragmentId: ref.id,
545
- variable: name,
546
- expectedType: mismatch.expected,
547
- actualType: mismatch.actual
568
+ if (!(name in merged)) merged[name] = prop.default;
569
+ }
570
+ return merged;
571
+ }
572
+ /**
573
+ * Cross-check every inline placement against the declared libraries: duplicate
574
+ * aliases, duplicate fragment ids within a file, each placement's `alias.id`
575
+ * resolution + variable validation (via `resolveAndMerge`), and a warning for any
576
+ * declared library no placement ever uses. Errors block the build; the rest are
577
+ * warnings. Order-independent placements carry their own textual position.
578
+ */
579
+ function checkPlacements(placements, filesByAlias, fileRefs) {
580
+ const errors = [];
581
+ const warnings = [];
582
+ const aliasCounts = /* @__PURE__ */ new Map();
583
+ for (const ref of fileRefs) aliasCounts.set(ref.id, (aliasCounts.get(ref.id) ?? 0) + 1);
584
+ for (const [alias, count] of aliasCounts) if (count > 1) errors.push(error("DUPLICATE_FRAGMENT_FILE_ALIAS", `Fragment-file alias "${alias}" is declared ${count} times`, { fileAlias: alias }));
585
+ for (const [alias, file] of filesByAlias) {
586
+ const seen = /* @__PURE__ */ new Set();
587
+ for (const frag of file.fragments) {
588
+ if (seen.has(frag.id)) {
589
+ errors.push(error("DUPLICATE_FRAGMENT_ID_IN_FILE", `Fragment "${frag.id}" is declared more than once in file "${alias}"`, {
590
+ fileAlias: alias,
591
+ fragmentId: frag.id
548
592
  }));
593
+ continue;
549
594
  }
550
- const requiredSet = new Set(schema.required);
551
- for (const [name, prop] of Object.entries(schema.properties)) {
552
- if (prop.default === void 0) continue;
553
- if (requiredSet.has(name)) {
554
- warnings.push(warning("REQUIRED_PROPERTY_HAS_DEFAULT", `Variable "${name}" of "${ref.id}" is required, so its default is never used`, {
555
- fileAlias: ref.file,
556
- fragmentId: ref.id,
557
- variable: name
558
- }));
559
- continue;
560
- }
561
- if (!(name in merged)) merged[name] = prop.default;
562
- }
563
- } else for (const name of Object.keys(variables)) warnings.push(warning("UNDECLARED_VARIABLE", `Variable "${name}" supplied to "${ref.id}", which declares no input schema`, {
564
- fileAlias: ref.file,
565
- fragmentId: ref.id,
566
- variable: name
567
- }));
568
- resolved.push({
569
- fileAlias: ref.file,
570
- fragmentId: ref.id,
571
- priority: fragment.priority,
572
- content: fragment.content,
573
- variables: merged
574
- });
595
+ seen.add(frag.id);
596
+ }
575
597
  }
576
- const plan = [...resolved].sort((a, b) => a.priority - b.priority);
577
- const priorityOwners = /* @__PURE__ */ new Map();
578
- for (const r of resolved) {
579
- const owners = priorityOwners.get(r.priority) ?? [];
580
- owners.push(r.fragmentId);
581
- priorityOwners.set(r.priority, owners);
598
+ const usedAliases = /* @__PURE__ */ new Set();
599
+ for (const placement of placements) {
600
+ usedAliases.add(splitFragmentRef(placement.ref).alias);
601
+ const resolved = resolveAndMerge(placement.ref, placement.args, filesByAlias);
602
+ errors.push(...resolved.errors);
603
+ warnings.push(...resolved.warnings);
582
604
  }
583
- for (const [priority, owners] of priorityOwners) if (owners.length > 1) errors.push(error("DUPLICATE_PRIORITY", `Priority ${priority} is shared by fragments: ${owners.join(", ")} ordering is ambiguous`));
605
+ for (const ref of fileRefs) if (!usedAliases.has(ref.id)) warnings.push(warning("UNUSED_FRAGMENT_FILE", `Fragment library "${ref.id}" is declared but no {{fragment}} marker uses it`, { fileAlias: ref.id }));
584
606
  return {
585
607
  errors,
586
- warnings,
587
- plan
608
+ warnings
588
609
  };
589
610
  }
590
611
  //#endregion
@@ -704,8 +725,7 @@ const ClassificationSchema = z.strictObject({
704
725
  });
705
726
  const FragmentSchema = z.strictObject({
706
727
  id: z.string().meta({ description: "Unique fragment id within this library." }),
707
- version: z.number().meta({ description: "Fragment version number." }),
708
- priority: z.number().meta({ description: "Assembly order. Lower priorities appear earlier." }),
728
+ version: z.number().optional().meta({ description: "Optional fragment version number." }),
709
729
  input_schema: InputSchema.optional(),
710
730
  classification: ClassificationSchema.optional(),
711
731
  content: z.string().meta({ description: "Prompt text as a Handlebars template." })
@@ -717,8 +737,7 @@ const FragmentFileSchema = z.strictObject({
717
737
  id: z.string().meta({ description: "Machine-readable id of this fragment library." }),
718
738
  fragments: z.array(FragmentSchema).min(1).meta({ description: "The reusable fragments this library provides (at least one)." })
719
739
  });
720
- /** A supplied variable value mirrors what `input_schema` can declare. */
721
- const VariableValueSchema = z.union([
740
+ z.union([
722
741
  z.string(),
723
742
  z.boolean(),
724
743
  z.array(z.string())
@@ -727,22 +746,15 @@ const VariableValueSchema = z.union([
727
746
  description: "A literal variable value: a string, a boolean, or an array of strings."
728
747
  });
729
748
  const FragmentFileRefSchema = z.strictObject({
730
- id: z.string().meta({ description: "Local alias for this library, referenced by each fragment's `file`." }),
749
+ id: z.string().regex(/^[^.]+$/, { message: "Alias must not contain a dot" }).meta({
750
+ pattern: "^[^.]+$",
751
+ description: "Local alias for this library, used before the dot in `{{fragment \"alias.id\"}}`. Must not contain a dot."
752
+ }),
731
753
  url: FragmentUrlRef
732
754
  }).meta({
733
755
  id: "fragmentFileRef",
734
756
  description: "A reference to a fragment library by alias + URL."
735
757
  });
736
- const FragmentRefSchema = z.strictObject({
737
- file: z.string().meta({ description: "The alias of the fragment library this fragment is drawn from." }),
738
- id: z.string().meta({ description: "Fragment id inside the referenced library." }),
739
- variables: z.record(z.string(), VariableValueSchema).optional().meta({ description: "Literal values passed into the fragment template." }),
740
- bind: z.record(z.string(), z.string()).optional().meta({ description: "Accepted for compatibility but ignored by the current assembler." }),
741
- required: z.boolean().optional().meta({ description: "Marker for important fragments. Accepted but not currently enforced." })
742
- }).meta({
743
- id: "fragmentRef",
744
- description: "Selects one fragment from a referenced library."
745
- });
746
758
  //#endregion
747
759
  //#region ../lib/prompt-fragments/fragment.ts
748
760
  /**
@@ -793,8 +805,8 @@ function checkFragmentTemplates(file, opts = {}) {
793
805
  }
794
806
  /**
795
807
  * Fragment ids declared more than once within a single file. The standalone
796
- * validator runs this directly; the tutor path gets the same check from
797
- * `checkConsistency` (so the whole-library pass must NOT repeat it).
808
+ * validator runs this directly; the activity path gets the same check from
809
+ * `checkPlacements` (so the whole-library pass must NOT repeat it).
798
810
  */
799
811
  function findDuplicateFragmentIds(file) {
800
812
  const errors = [];
@@ -835,6 +847,142 @@ function checkFragmentFileValue(parsed, url) {
835
847
  };
836
848
  }
837
849
  //#endregion
850
+ //#region ../lib/prompt-fragments/host-template.ts
851
+ const FRAGMENT_HELPER = "fragment";
852
+ const ARRAY_HELPER = "array";
853
+ const isFragmentNode = (node) => node.path?.original === FRAGMENT_HELPER;
854
+ /** A structural error stamped with the node's 1-based position. */
855
+ function markerInvalid(loc, detail) {
856
+ return error("FRAGMENT_MARKER_INVALID", `${detail} (line ${loc.start.line})`, {
857
+ line: loc.start.line,
858
+ column: loc.start.column + 1
859
+ });
860
+ }
861
+ /**
862
+ * Read a hash-pair value into a supported literal. The supported set IS the contract:
863
+ * a string, a boolean, or an `(array "…" …)` of string literals. Anything else — a
864
+ * number, a path reference, a nested `(fragment …)`, an array with a non-string
865
+ * element — is a hard error, because at RENDER time the real Handlebars runtime hands
866
+ * the helper the raw value regardless, so silently dropping it here would let the
867
+ * placement validate against different args than it renders with (fail-open drift).
868
+ */
869
+ function readHashValue(key, node, loc) {
870
+ switch (node.type) {
871
+ case "StringLiteral": return { value: node.value };
872
+ case "BooleanLiteral": return { value: node.value };
873
+ case "SubExpression": {
874
+ const sub = node;
875
+ if (sub.path.original !== ARRAY_HELPER) return { error: markerInvalid(loc, `Argument "${key}" must be a string, boolean, or (array …)`) };
876
+ if (!sub.params.every((p) => p.type === "StringLiteral")) return { error: markerInvalid(loc, `Every element of (array …) for "${key}" must be a string`) };
877
+ return { value: sub.params.map((p) => p.value) };
878
+ }
879
+ default: return { error: markerInvalid(loc, `Argument "${key}" must be a string, boolean, or (array …)`) };
880
+ }
881
+ }
882
+ /** Extract one inline `{{fragment "alias.id" …}}` mustache's ref + validated args. */
883
+ function extractInlineMarker(node, out) {
884
+ const first = node.params?.[0];
885
+ if (first?.type !== "StringLiteral") {
886
+ out.errors.push(error("FRAGMENT_REF_NOT_LITERAL", `A {{fragment}} marker at line ${node.loc.start.line} needs a quoted "alias.id" reference`, {
887
+ line: node.loc.start.line,
888
+ column: node.loc.start.column + 1
889
+ }));
890
+ return;
891
+ }
892
+ if ((node.params?.length ?? 0) > 1) {
893
+ out.errors.push(markerInvalid(node.loc, "A {{fragment}} marker takes exactly one \"alias.id\" reference"));
894
+ return;
895
+ }
896
+ const args = {};
897
+ for (const pair of node.hash?.pairs ?? []) {
898
+ const read = readHashValue(pair.key, pair.value, node.loc);
899
+ if ("error" in read) {
900
+ out.errors.push(read.error);
901
+ return;
902
+ }
903
+ args[pair.key] = read.value;
904
+ }
905
+ out.placements.push({
906
+ ref: first.value,
907
+ args,
908
+ line: node.loc.start.line,
909
+ column: node.loc.start.column + 1
910
+ });
911
+ }
912
+ /** Reject a `fragment` helper used anywhere but a simple inline mustache. */
913
+ function visitExpression(node, out) {
914
+ if (node.type !== "SubExpression") return;
915
+ const sub = node;
916
+ if (isFragmentNode(sub)) out.errors.push(markerInvalid(sub.loc, "{{fragment}} must be a standalone inline marker, not a subexpression"));
917
+ for (const p of sub.params ?? []) visitExpression(p, out);
918
+ for (const pair of sub.hash?.pairs ?? []) visitExpression(pair.value, out);
919
+ }
920
+ /** Collect every `{{fragment}}` marker in a program body (recursing into blocks). */
921
+ function collectPlacements(program, out) {
922
+ for (const node of program.body) {
923
+ if (isFragmentNode(node)) if (node.type === "MustacheStatement") extractInlineMarker(node, out);
924
+ else out.errors.push(markerInvalid(node.loc, "{{fragment}} must be a simple inline marker, not a block {{#fragment}}…{{/fragment}}"));
925
+ else {
926
+ for (const p of node.params ?? []) visitExpression(p, out);
927
+ for (const pair of node.hash?.pairs ?? []) visitExpression(pair.value, out);
928
+ }
929
+ if (node.program) collectPlacements(node.program, out);
930
+ if (node.inverse) collectPlacements(node.inverse, out);
931
+ }
932
+ }
933
+ /**
934
+ * Parse the host text and extract every inline `{{fragment}}` placement in textual
935
+ * order, WITHOUT rendering. A whole-template syntax error (a malformed marker, an
936
+ * unescaped literal `{{`) becomes a single `HOST_TEMPLATE_PARSE_ERROR`; its position
937
+ * is regexed out of Handlebars' message (`Parse error on line N:` — the parser
938
+ * carries no structured position fields). A well-formed marker whose reference is not
939
+ * a quoted string literal becomes a per-marker `FRAGMENT_REF_NOT_LITERAL`.
940
+ */
941
+ function parseHostPlacements(text) {
942
+ const result = {
943
+ placements: [],
944
+ errors: []
945
+ };
946
+ let program;
947
+ try {
948
+ program = Handlebars.parse(text);
949
+ } catch (e) {
950
+ const message = e instanceof Error ? e.message : String(e);
951
+ const lineMatch = message.match(/line (\d+)/i);
952
+ result.errors.push(error("HOST_TEMPLATE_PARSE_ERROR", `Host text is not a valid template: ${message}`, { ...lineMatch ? { line: Number(lineMatch[1]) } : {} }));
953
+ return result;
954
+ }
955
+ collectPlacements(program, result);
956
+ return result;
957
+ }
958
+ /** Build the isolated Handlebars instance carrying ONLY the `fragment` + `array` helpers. */
959
+ function createHostInstance(resolver) {
960
+ const hb = Handlebars.create();
961
+ hb.registerHelper(FRAGMENT_HELPER, (...args) => {
962
+ const ref = args.length >= 2 ? args[0] : void 0;
963
+ if (typeof ref !== "string") throw new Error("a {{fragment}} marker needs a quoted \"alias.id\" reference");
964
+ const options = args[args.length - 1];
965
+ return resolver(ref, options?.hash ?? {});
966
+ });
967
+ hb.registerHelper(ARRAY_HELPER, (...args) => {
968
+ return args.slice(0, -1);
969
+ });
970
+ return hb;
971
+ }
972
+ /**
973
+ * Compile + render the host text with the isolated instance under
974
+ * `{ strict: true, noEscape: true }` — `strict` so any stray `{{…}}` that is not a
975
+ * `fragment` / `array` marker fails closed instead of rendering empty; `noEscape` so
976
+ * the prompt text (ASCII diagrams, quotes) passes through verbatim. Each placement is
977
+ * replaced by its `resolver` result. May throw — the caller wraps it as ASSEMBLY_ERROR.
978
+ */
979
+ function renderHostTemplate(text, resolver) {
980
+ return createHostInstance(resolver).compile(text, {
981
+ strict: true,
982
+ noEscape: true
983
+ })({});
984
+ }
985
+ //#endregion
838
986
  //#region ../lib/relative-url.ts
839
987
  /**
840
988
  * Resolve a reference to an absolute URL. An absolute http(s) ref is used as-is;
@@ -926,23 +1074,27 @@ async function loadYaml(url, fetchImpl, opts = {}) {
926
1074
  };
927
1075
  }
928
1076
  /**
929
- * Resolve a document-level fragment block to a finished prompt string: fetch every
930
- * declared fragment file in parallel (relative refs resolved against `baseUrl`),
931
- * schema-validate each, (optionally) run the thorough whole-library check, check
932
- * consistency, and assemble the priority-ordered plan followed by the optional
933
- * `trailingInstructions`.
1077
+ * Render an activity's host text (`tutor_instructions` / `instructions`) into a
1078
+ * finished prompt string, inserting each inline `{{fragment "alias.id" …}}` marker in
1079
+ * place. Fetches every declared fragment library in parallel (relative refs resolved
1080
+ * against `baseUrl`), schema-validates each, (optionally) runs the thorough
1081
+ * whole-library check, extracts + checks the placements, then compiles + renders the
1082
+ * host template under strict Handlebars.
934
1083
  *
935
1084
  * The single seam every activity kind shares — the sole owner of the fetch → validate
936
- * → consistencyassemble pipeline. Consumers concatenate their own frame only when
937
- * they pass no `trailingInstructions` (a fragment-only preamble); tutors pass their
938
- * `tutor_instructions` and get a complete prompt.
1085
+ * → checkrender pipeline. `hostText` IS the template: fragments appear only where
1086
+ * the author placed a marker; there is no ordering concept and no prepend fallback.
1087
+ *
1088
+ * TEMPLATE-SEMANTICS OPT-IN: an activity that declares no `fragment_files:` is NEVER
1089
+ * compiled — its host text returns byte-verbatim (protecting plain activities and the
1090
+ * authoring tutors whose prose contains sample markers as teaching content).
939
1091
  */
940
- async function assembleFragmentPrompt(block, baseUrl, fetchImpl, opts = {}, trailingInstructions) {
1092
+ async function assembleFragmentPrompt(block, baseUrl, fetchImpl, opts = {}, hostText = "") {
941
1093
  const warnings = [];
942
1094
  const allowedSchemes = opts.allowedSchemes ?? DEFAULT_ALLOWED_SCHEMES;
943
- if (block.fragment_files.length === 0 && block.fragments.length === 0) return {
1095
+ if (block.fragment_files.length === 0) return {
944
1096
  ok: true,
945
- prompt: assembleSystemPrompt([], trailingInstructions),
1097
+ prompt: hostText,
946
1098
  warnings
947
1099
  };
948
1100
  const settled = await Promise.all(block.fragment_files.map(async (ref) => {
@@ -1012,18 +1164,28 @@ async function assembleFragmentPrompt(block, baseUrl, fetchImpl, opts = {}, trai
1012
1164
  libraryErrors.push(...checked.errors);
1013
1165
  warnings.push(...checked.warnings);
1014
1166
  }
1015
- const consistency = checkConsistency(block, fragmentFilesByAlias);
1016
- warnings.push(...consistency.warnings);
1017
- const preAssemblyErrors = [...libraryErrors, ...consistency.errors];
1018
- if (preAssemblyErrors.length > 0) return {
1167
+ const parsed = parseHostPlacements(hostText);
1168
+ if (parsed.errors.length > 0) return {
1169
+ ok: false,
1170
+ errors: [...libraryErrors, ...parsed.errors],
1171
+ warnings
1172
+ };
1173
+ const placementCheck = checkPlacements(parsed.placements, fragmentFilesByAlias, block.fragment_files);
1174
+ warnings.push(...placementCheck.warnings);
1175
+ const preRenderErrors = [...libraryErrors, ...placementCheck.errors];
1176
+ if (preRenderErrors.length > 0) return {
1019
1177
  ok: false,
1020
- errors: preAssemblyErrors,
1178
+ errors: preRenderErrors,
1021
1179
  warnings
1022
1180
  };
1023
1181
  try {
1024
1182
  return {
1025
1183
  ok: true,
1026
- prompt: assembleSystemPrompt(consistency.plan, trailingInstructions),
1184
+ prompt: renderHostTemplate(hostText, (ref, args) => {
1185
+ const resolved = resolveAndMerge(ref, args, fragmentFilesByAlias);
1186
+ if (resolved.content === null || resolved.errors.length > 0) throw new Error(`Fragment "${ref}" could not be resolved`);
1187
+ return renderFragmentContent(resolved.content, resolved.variables);
1188
+ }),
1027
1189
  warnings
1028
1190
  };
1029
1191
  } catch (e) {
@@ -1065,8 +1227,7 @@ const CodingYamlSchema = z.strictObject({
1065
1227
  description: "The pinned model and provider that answer coding requests."
1066
1228
  }),
1067
1229
  fragment_files: z.array(FragmentFileRefSchema).default([]).meta({ description: "Optional fragment libraries this activity pulls shared prompt fragments from." }),
1068
- fragments: z.array(FragmentRefSchema).default([]).meta({ description: "Optional fragments selected from fragment_files. Assembled in priority order and prepended AHEAD of instructions." }),
1069
- instructions: z.string().min(1).meta({ description: "The assistant's system prompt. SERVER-ONLY: never sent to the browser or the coding agent, and appended AFTER the coding tool's own prompt (so the teacher has the final word). Constrain the assistant to what your class has learned." })
1230
+ instructions: z.string().min(1).meta({ description: "The assistant's system prompt. SERVER-ONLY: never sent to the browser or the coding agent, and appended AFTER the coding tool's own prompt (so the teacher has the final word). Constrain the assistant to what your class has learned. When any fragment_files are declared it is a Handlebars template: place fragments inline with {{fragment \"alias.id\" }} markers (escape a literal {{ as \\{{)." })
1070
1231
  });
1071
1232
  //#endregion
1072
1233
  //#region ../lib/coding-validate.ts
@@ -1105,13 +1266,10 @@ async function loadAndCheckCoding(url, fetchImpl, opts = {}) {
1105
1266
  };
1106
1267
  const checked = checkCodingParsed(valid.data);
1107
1268
  if (!checked.ok) return checked;
1108
- const assembled = await assembleFragmentPrompt({
1109
- fragment_files: valid.data.fragment_files,
1110
- fragments: valid.data.fragments
1111
- }, url, fetchImpl, {
1269
+ const assembled = await assembleFragmentPrompt({ fragment_files: valid.data.fragment_files }, url, fetchImpl, {
1112
1270
  allowedSchemes: opts.allowedSchemes,
1113
1271
  validateLibraries: opts.validateLibraries ?? true
1114
- });
1272
+ }, valid.data.instructions);
1115
1273
  const warnings = [...checked.warnings, ...assembled.warnings];
1116
1274
  if (!assembled.ok) return {
1117
1275
  ok: false,
@@ -1183,7 +1341,7 @@ const QuizYamlSchema = z.strictObject({
1183
1341
  description: "Optional guidance for the per-question follow-up discussion chat."
1184
1342
  }),
1185
1343
  fragment_files: z.array(FragmentFileRefSchema).default([]).meta({ description: "Optional fragment libraries this quiz pulls shared prompt fragments from." }),
1186
- fragments: z.array(FragmentRefSchema).default([]).meta({ description: "Optional fragments selected from fragment_files. Assembled in priority order and prepended to BOTH the grader prompt and the discussion chat's system prompt." }),
1344
+ instructions: z.string().optional().meta({ description: "Optional quiz-level preamble prepended to BOTH the grader prompt and the discussion chat. When any fragment_files are declared, place fragments inline here with {{fragment \"alias.id\" }} markers (escape a literal {{ as \\{{)." }),
1187
1345
  questions: z.array(QuizQuestionSchema).min(1).meta({ description: "The quiz questions. Each is open-ended and graded by the LLM via its evaluation prompt." })
1188
1346
  });
1189
1347
  //#endregion
@@ -1248,13 +1406,10 @@ async function loadAndCheckQuiz(url, fetchImpl, opts = {}) {
1248
1406
  };
1249
1407
  const checked = checkQuizParsed(valid.data);
1250
1408
  if (!checked.ok) return checked;
1251
- const assembled = await assembleFragmentPrompt({
1252
- fragment_files: valid.data.fragment_files,
1253
- fragments: valid.data.fragments
1254
- }, url, fetchImpl, {
1409
+ const assembled = await assembleFragmentPrompt({ fragment_files: valid.data.fragment_files }, url, fetchImpl, {
1255
1410
  allowedSchemes: opts.allowedSchemes,
1256
1411
  validateLibraries: opts.validateLibraries ?? true
1257
- });
1412
+ }, valid.data.instructions ?? "");
1258
1413
  const warnings = [...checked.warnings, ...assembled.warnings];
1259
1414
  if (!assembled.ok) return {
1260
1415
  ok: false,
@@ -1303,11 +1458,10 @@ const TutorSchema = z.strictObject({
1303
1458
  }),
1304
1459
  prompt: z.strictObject({
1305
1460
  fragment_files: z.array(FragmentFileRefSchema).default([]).meta({ description: "Optional fragment libraries used by this tutor." }),
1306
- fragments: z.array(FragmentRefSchema).default([]).meta({ description: "Optional fragments selected from fragment_files." }),
1307
- tutor_instructions: z.string().meta({ description: "Final tutor-specific system-prompt instructions. For single-file tutors, this can be the whole prompt." })
1461
+ tutor_instructions: z.string().meta({ description: "The tutor's system prompt. When any fragment_files are declared this is a Handlebars template: place fragments inline with {{fragment \"alias.id\" key=\"v\"}} markers (escape a literal {{ as \\{{). For single-file tutors it is the whole prompt." })
1308
1462
  }).meta({
1309
1463
  id: "prompt",
1310
- description: "The assembled system prompt: fragments plus tutor instructions."
1464
+ description: "The tutor system prompt: a host template with inline fragment markers."
1311
1465
  })
1312
1466
  });
1313
1467
  //#endregion
@@ -1366,8 +1520,7 @@ const WritingYamlSchema = z.strictObject({
1366
1520
  description: "The model and provider that back the writing coach."
1367
1521
  }),
1368
1522
  fragment_files: z.array(FragmentFileRefSchema).default([]).meta({ description: "Optional fragment libraries this activity pulls shared prompt fragments from." }),
1369
- fragments: z.array(FragmentRefSchema).default([]).meta({ description: "Optional fragments selected from fragment_files. Assembled in priority order and prepended AHEAD of instructions." }),
1370
- instructions: z.string().min(1).meta({ description: "The writing coach's system prompt. SERVER-ONLY: never sent to the browser, so it may describe the assessment criteria and coaching strategy." }),
1523
+ instructions: z.string().min(1).meta({ description: "The writing coach's system prompt. SERVER-ONLY: never sent to the browser, so it may describe the assessment criteria and coaching strategy. When any fragment_files are declared it is a Handlebars template: place fragments inline with {{fragment \"alias.id\" }} markers (escape a literal {{ as \\{{)." }),
1371
1524
  placeholder: z.string().optional().meta({ description: "Optional starter text prefilled into the editor. Empty for a blank page." })
1372
1525
  });
1373
1526
  //#endregion
@@ -1410,13 +1563,10 @@ async function loadAndCheckWriting(url, fetchImpl, opts = {}) {
1410
1563
  };
1411
1564
  const checked = checkWritingParsed(valid.data);
1412
1565
  if (!checked.ok) return checked;
1413
- const assembled = await assembleFragmentPrompt({
1414
- fragment_files: valid.data.fragment_files,
1415
- fragments: valid.data.fragments
1416
- }, url, fetchImpl, {
1566
+ const assembled = await assembleFragmentPrompt({ fragment_files: valid.data.fragment_files }, url, fetchImpl, {
1417
1567
  allowedSchemes: opts.allowedSchemes,
1418
1568
  validateLibraries: opts.validateLibraries ?? true
1419
- });
1569
+ }, valid.data.instructions);
1420
1570
  const warnings = [...checked.warnings, ...assembled.warnings];
1421
1571
  if (!assembled.ok) return {
1422
1572
  ok: false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@novedu/cli",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "description": "Command-line companion for the Novedu chat app. Validates tutor, fragment, quiz, writing and coding YAML definitions; signs in with Entra ID and manages codes and app-hosted files over the app's API.",
5
5
  "type": "module",
6
6
  "repository": {