@studiolxd/lxd-cli 0.1.0-next.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/LICENSE +36 -0
- package/README.md +123 -0
- package/dist/cli/index.js +2763 -0
- package/dist/core/schema/design-direction.schema.json +84 -0
- package/dist/core/schema/experience.schema.json +75 -0
- package/dist/core/schema/export-config.schema.json +44 -0
- package/dist/core/schema/mechanic-manifest.schema.json +53 -0
- package/dist/core/schema/package-spec.schema.json +126 -0
- package/dist/core/schema/tracking-config.schema.json +63 -0
- package/dist/core/schema/validation-report.schema.json +42 -0
- package/dist/mechanics/manifests/branching-narrative.yml +25 -0
- package/dist/mechanics/manifests/decision-simulation.yml +25 -0
- package/dist/mechanics/manifests/diagnostic-challenge.yml +24 -0
- package/dist/mechanics/manifests/risk-detection.yml +24 -0
- package/docs/agent-agnostic-verification.md +52 -0
- package/docs/commands.md +56 -0
- package/package.json +59 -0
|
@@ -0,0 +1,2763 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli/index.ts
|
|
4
|
+
import { Command } from "commander";
|
|
5
|
+
import { fileURLToPath as fileURLToPath2 } from "node:url";
|
|
6
|
+
import { realpathSync } from "node:fs";
|
|
7
|
+
|
|
8
|
+
// package.json
|
|
9
|
+
var package_default = {
|
|
10
|
+
name: "@studiolxd/lxd-cli",
|
|
11
|
+
version: "0.1.0-next.0",
|
|
12
|
+
description: "CLI-first authoring tool for AI-native elearning packages (agent-orchestrated, framework- and export-target-agnostic).",
|
|
13
|
+
type: "module",
|
|
14
|
+
license: "UNLICENSED",
|
|
15
|
+
engines: {
|
|
16
|
+
node: ">=20"
|
|
17
|
+
},
|
|
18
|
+
bin: {
|
|
19
|
+
lxd: "./dist/cli/index.js"
|
|
20
|
+
},
|
|
21
|
+
files: [
|
|
22
|
+
"dist",
|
|
23
|
+
"docs"
|
|
24
|
+
],
|
|
25
|
+
publishConfig: {
|
|
26
|
+
access: "public",
|
|
27
|
+
tag: "next"
|
|
28
|
+
},
|
|
29
|
+
scripts: {
|
|
30
|
+
build: 'esbuild src/cli/index.ts --bundle --platform=node --format=esm --packages=external --outfile=dist/cli/index.js --banner:js="#!/usr/bin/env node" && node scripts/copy-assets.mjs',
|
|
31
|
+
prepack: "npm run build",
|
|
32
|
+
prepublishOnly: "npm run typecheck && npm run lint && npm run test",
|
|
33
|
+
"verify:pack": "node scripts/verify-package.mjs",
|
|
34
|
+
dev: "tsx src/cli/index.ts",
|
|
35
|
+
typecheck: "tsc --noEmit",
|
|
36
|
+
lint: "eslint .",
|
|
37
|
+
format: "prettier --write .",
|
|
38
|
+
"format:check": "prettier --check .",
|
|
39
|
+
test: "vitest run",
|
|
40
|
+
"test:watch": "vitest",
|
|
41
|
+
"test:coverage": "vitest run --coverage"
|
|
42
|
+
},
|
|
43
|
+
dependencies: {
|
|
44
|
+
"@clack/prompts": "^0.7.0",
|
|
45
|
+
ajv: "^8.17.1",
|
|
46
|
+
"ajv-formats": "^3.0.1",
|
|
47
|
+
archiver: "^7.0.1",
|
|
48
|
+
commander: "^12.1.0",
|
|
49
|
+
pino: "^9.5.0",
|
|
50
|
+
"pino-pretty": "^13.0.0",
|
|
51
|
+
yaml: "^2.6.0"
|
|
52
|
+
},
|
|
53
|
+
devDependencies: {
|
|
54
|
+
"@types/archiver": "^6.0.3",
|
|
55
|
+
"@types/node": "^20.16.0",
|
|
56
|
+
"@typescript-eslint/eslint-plugin": "^8.13.0",
|
|
57
|
+
"@typescript-eslint/parser": "^8.13.0",
|
|
58
|
+
"@vitest/coverage-v8": "^2.1.4",
|
|
59
|
+
esbuild: "^0.24.0",
|
|
60
|
+
eslint: "^8.57.1",
|
|
61
|
+
"eslint-config-prettier": "^9.1.0",
|
|
62
|
+
prettier: "^3.3.3",
|
|
63
|
+
tsx: "^4.19.0",
|
|
64
|
+
typescript: "^5.6.3",
|
|
65
|
+
vitest: "^2.1.4"
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
// src/cli/commands/init.ts
|
|
70
|
+
import { resolve as resolve2 } from "node:path";
|
|
71
|
+
|
|
72
|
+
// src/core/project/store.ts
|
|
73
|
+
import { mkdir, readFile, writeFile, readdir, access } from "node:fs/promises";
|
|
74
|
+
import { join } from "node:path";
|
|
75
|
+
import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
|
|
76
|
+
|
|
77
|
+
// src/core/model/index.ts
|
|
78
|
+
var SIGNALS = [
|
|
79
|
+
"progress",
|
|
80
|
+
"score",
|
|
81
|
+
"completion",
|
|
82
|
+
"interaction",
|
|
83
|
+
"feedback",
|
|
84
|
+
"suspend"
|
|
85
|
+
];
|
|
86
|
+
var NAVIGATION = ["linear", "free", "guided"];
|
|
87
|
+
var DESIGN_LEVELS = [
|
|
88
|
+
"brief",
|
|
89
|
+
"tokens",
|
|
90
|
+
"patterns",
|
|
91
|
+
"design-system",
|
|
92
|
+
"full-constraints"
|
|
93
|
+
];
|
|
94
|
+
var REPORTED_FIELDS = [
|
|
95
|
+
"completion",
|
|
96
|
+
"success",
|
|
97
|
+
"score",
|
|
98
|
+
"suspend_data",
|
|
99
|
+
"lesson_location"
|
|
100
|
+
];
|
|
101
|
+
var EXPORT_ADAPTERS_V1 = ["web", "scorm-1.2", "scorm-2004"];
|
|
102
|
+
var SLUG_RE = /^[a-z0-9][a-z0-9-]*$/;
|
|
103
|
+
function isSlug(value) {
|
|
104
|
+
return SLUG_RE.test(value);
|
|
105
|
+
}
|
|
106
|
+
function withExperienceDefaults(exp) {
|
|
107
|
+
return {
|
|
108
|
+
...exp,
|
|
109
|
+
required: exp.required ?? true,
|
|
110
|
+
weight: exp.weight ?? 1,
|
|
111
|
+
signals: exp.signals ?? []
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// src/observability/logger.ts
|
|
116
|
+
import pino from "pino";
|
|
117
|
+
var REDACT_PATHS = [
|
|
118
|
+
"password",
|
|
119
|
+
"*.password",
|
|
120
|
+
"token",
|
|
121
|
+
"*.token",
|
|
122
|
+
"secret",
|
|
123
|
+
"*.secret",
|
|
124
|
+
"secrets",
|
|
125
|
+
"*.secrets",
|
|
126
|
+
"credential",
|
|
127
|
+
"*.credential",
|
|
128
|
+
"credentials",
|
|
129
|
+
"*.credentials",
|
|
130
|
+
"apiKey",
|
|
131
|
+
"*.apiKey",
|
|
132
|
+
"authorization",
|
|
133
|
+
"*.authorization"
|
|
134
|
+
];
|
|
135
|
+
function createLogger(opts = {}) {
|
|
136
|
+
const level = opts.level ?? process.env.LOG_LEVEL ?? "warn";
|
|
137
|
+
const redact = { paths: REDACT_PATHS, censor: "[REDACTED]" };
|
|
138
|
+
if (opts.pretty) {
|
|
139
|
+
return pino({
|
|
140
|
+
level,
|
|
141
|
+
redact,
|
|
142
|
+
transport: { target: "pino-pretty", options: { destination: 2 } }
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
return pino({ level, redact }, pino.destination(2));
|
|
146
|
+
}
|
|
147
|
+
var logger = createLogger({ pretty: Boolean(process.stderr.isTTY) });
|
|
148
|
+
|
|
149
|
+
// src/core/project/store.ts
|
|
150
|
+
function assertSafeExperienceId(id) {
|
|
151
|
+
if (!isSlug(id)) {
|
|
152
|
+
throw new Error(`Unsafe experience id "${id}": must be a slug ([a-z0-9-]) \u2014 no path separators or traversal`);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
var PATHS = {
|
|
156
|
+
pkg: ["package.elearning.yml"],
|
|
157
|
+
design: ["design", "design-direction.yml"],
|
|
158
|
+
experiencesDir: ["experiences"],
|
|
159
|
+
tracking: ["tracking", "tracking.yml"],
|
|
160
|
+
export: ["export", "export.config.yml"],
|
|
161
|
+
manifest: [".elearning", "manifest.lock"]
|
|
162
|
+
};
|
|
163
|
+
function at(dir, parts) {
|
|
164
|
+
return join(dir, ...parts);
|
|
165
|
+
}
|
|
166
|
+
async function writeYaml(path, data) {
|
|
167
|
+
await mkdir(join(path, ".."), { recursive: true });
|
|
168
|
+
await writeFile(path, stringifyYaml(data), "utf8");
|
|
169
|
+
}
|
|
170
|
+
async function readYaml(path) {
|
|
171
|
+
return parseYaml(await readFile(path, "utf8"));
|
|
172
|
+
}
|
|
173
|
+
async function initProject(dir, opts) {
|
|
174
|
+
for (const sub of ["design", "experiences", "tracking", "export", ".elearning", "generated", ".agent"]) {
|
|
175
|
+
await mkdir(join(dir, sub), { recursive: true });
|
|
176
|
+
}
|
|
177
|
+
const skeleton = {
|
|
178
|
+
id: opts.id,
|
|
179
|
+
schemaVersion: "1",
|
|
180
|
+
experienceRefs: []
|
|
181
|
+
};
|
|
182
|
+
await writeFile(at(dir, PATHS.pkg), stringifyYaml(skeleton), "utf8");
|
|
183
|
+
const manifest = { cliVersion: "0.0.0" };
|
|
184
|
+
await writeYaml(at(dir, PATHS.manifest), manifest);
|
|
185
|
+
}
|
|
186
|
+
async function saveProject(dir, project) {
|
|
187
|
+
await mkdir(dir, { recursive: true });
|
|
188
|
+
await writeFile(at(dir, PATHS.pkg), stringifyYaml(project.package), "utf8");
|
|
189
|
+
await writeYaml(at(dir, PATHS.design), project.design);
|
|
190
|
+
await writeYaml(at(dir, PATHS.tracking), project.tracking);
|
|
191
|
+
await writeYaml(at(dir, PATHS.export), project.export);
|
|
192
|
+
await writeYaml(at(dir, PATHS.manifest), project.manifest);
|
|
193
|
+
const expDir = at(dir, PATHS.experiencesDir);
|
|
194
|
+
await mkdir(expDir, { recursive: true });
|
|
195
|
+
for (const exp of project.experiences) {
|
|
196
|
+
assertSafeExperienceId(exp.id);
|
|
197
|
+
await writeFile(join(expDir, `${exp.id}.yml`), stringifyYaml(exp), "utf8");
|
|
198
|
+
}
|
|
199
|
+
logger.debug({ dir, experiences: project.experiences.length }, "Saved project");
|
|
200
|
+
}
|
|
201
|
+
async function readPackage(dir) {
|
|
202
|
+
return readYaml(at(dir, PATHS.pkg));
|
|
203
|
+
}
|
|
204
|
+
async function packageExists(dir) {
|
|
205
|
+
try {
|
|
206
|
+
await access(at(dir, PATHS.pkg));
|
|
207
|
+
return true;
|
|
208
|
+
} catch {
|
|
209
|
+
return false;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
async function writePackage(dir, pkg) {
|
|
213
|
+
await mkdir(dir, { recursive: true });
|
|
214
|
+
await writeFile(at(dir, PATHS.pkg), stringifyYaml(pkg), "utf8");
|
|
215
|
+
}
|
|
216
|
+
async function writeDesign(dir, design) {
|
|
217
|
+
await writeYaml(at(dir, PATHS.design), design);
|
|
218
|
+
}
|
|
219
|
+
async function writeTracking(dir, tracking) {
|
|
220
|
+
await writeYaml(at(dir, PATHS.tracking), tracking);
|
|
221
|
+
}
|
|
222
|
+
async function writeExport(dir, exportConfig) {
|
|
223
|
+
await writeYaml(at(dir, PATHS.export), exportConfig);
|
|
224
|
+
}
|
|
225
|
+
async function writeExperienceFile(dir, exp) {
|
|
226
|
+
assertSafeExperienceId(exp.id);
|
|
227
|
+
const expDir = at(dir, PATHS.experiencesDir);
|
|
228
|
+
await mkdir(expDir, { recursive: true });
|
|
229
|
+
await writeFile(join(expDir, `${exp.id}.yml`), stringifyYaml(exp), "utf8");
|
|
230
|
+
}
|
|
231
|
+
async function loadProjectLenient(dir) {
|
|
232
|
+
const empty = {};
|
|
233
|
+
const pkg = await readPackage(dir).catch(
|
|
234
|
+
() => ({ id: "", schemaVersion: "1", experienceRefs: [] })
|
|
235
|
+
);
|
|
236
|
+
const design = await readYaml(at(dir, PATHS.design)).catch(() => empty);
|
|
237
|
+
const tracking = await readYaml(at(dir, PATHS.tracking)).catch(() => empty);
|
|
238
|
+
const exportConfig = await readYaml(at(dir, PATHS.export)).catch(() => empty);
|
|
239
|
+
const manifest = await readYaml(at(dir, PATHS.manifest)).catch(() => ({
|
|
240
|
+
cliVersion: "0.0.0"
|
|
241
|
+
}));
|
|
242
|
+
const experiences = [];
|
|
243
|
+
try {
|
|
244
|
+
const files = (await readdir(at(dir, PATHS.experiencesDir))).filter((f) => f.endsWith(".yml"));
|
|
245
|
+
for (const file of files) {
|
|
246
|
+
experiences.push(await readYaml(join(at(dir, PATHS.experiencesDir), file)));
|
|
247
|
+
}
|
|
248
|
+
} catch {
|
|
249
|
+
}
|
|
250
|
+
return { package: pkg, design, experiences, tracking, export: exportConfig, manifest };
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// src/cli/context.ts
|
|
254
|
+
import { resolve } from "node:path";
|
|
255
|
+
function cwdOf(cmd) {
|
|
256
|
+
const opts = cmd.optsWithGlobals();
|
|
257
|
+
return resolve(opts.cwd ?? process.cwd());
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// src/cli/commands/init.ts
|
|
261
|
+
function register(program) {
|
|
262
|
+
program.command("init [dir]").description("Initialize a new elearning package project").option("--id <id>", "package id (slug)").action(async (dir, opts, cmd) => {
|
|
263
|
+
const base = resolve2(cwdOf(cmd), dir ?? ".");
|
|
264
|
+
await initProject(base, { id: opts.id ?? "my-package" });
|
|
265
|
+
console.log(`Initialized elearning package project at ${base}`);
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// src/cli/commands/goal.ts
|
|
270
|
+
function register2(program) {
|
|
271
|
+
program.command("goal").description("Set or show the learning goal").option("-g, --goal <text>", "the learning goal").action(async (opts, cmd) => {
|
|
272
|
+
const dir = cwdOf(cmd);
|
|
273
|
+
const pkg = await readPackage(dir);
|
|
274
|
+
if (opts.goal === void 0) {
|
|
275
|
+
console.log(pkg.learningGoal ?? "(no learning goal set)");
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
pkg.learningGoal = opts.goal;
|
|
279
|
+
await writePackage(dir, pkg);
|
|
280
|
+
console.log("Learning goal updated.");
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// src/cli/commands/audience.ts
|
|
285
|
+
function register3(program) {
|
|
286
|
+
program.command("audience").description("Set target audience, duration, context, constraints, and tone").option("-d, --description <text>", "audience description").option("-m, --duration <minutes>", "expected learning time in minutes").option("-c, --context <text>", "delivery context").option("-t, --tone <text>", "tone").action(
|
|
287
|
+
async (opts, cmd) => {
|
|
288
|
+
const dir = cwdOf(cmd);
|
|
289
|
+
const pkg = await readPackage(dir);
|
|
290
|
+
if (opts.description) pkg.audience = { ...pkg.audience, description: opts.description };
|
|
291
|
+
if (opts.duration) pkg.duration = { expectedMinutes: Number(opts.duration) };
|
|
292
|
+
if (opts.context) pkg.context = opts.context;
|
|
293
|
+
if (opts.tone) pkg.tone = opts.tone;
|
|
294
|
+
await writePackage(dir, pkg);
|
|
295
|
+
console.log("Audience/context updated.");
|
|
296
|
+
}
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// src/core/model/structure.ts
|
|
301
|
+
function structureCoversExperiences(structure, experienceRefs) {
|
|
302
|
+
const order = new Set(structure?.order ?? []);
|
|
303
|
+
return experienceRefs.every((id) => order.has(id));
|
|
304
|
+
}
|
|
305
|
+
function buildOrder(experienceRefs, existingOrder) {
|
|
306
|
+
const refSet = new Set(experienceRefs);
|
|
307
|
+
const kept = (existingOrder ?? []).filter((id) => refSet.has(id));
|
|
308
|
+
const keptSet = new Set(kept);
|
|
309
|
+
const appended = experienceRefs.filter((id) => !keptSet.has(id));
|
|
310
|
+
return [...kept, ...appended];
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// src/cli/commands/structure.ts
|
|
314
|
+
function register4(program) {
|
|
315
|
+
program.command("structure").description("Set package structure and navigation across experiences").option("-n, --navigation <mode>", `navigation mode (${NAVIGATION.join("|")})`).option("--order <ids...>", "explicit experience order").option("--sync", "reconcile order with current experiences (keep order, drop removed, append new)").action(async (opts, cmd) => {
|
|
316
|
+
const dir = cwdOf(cmd);
|
|
317
|
+
const pkg = await readPackage(dir);
|
|
318
|
+
if (opts.navigation && !NAVIGATION.includes(opts.navigation)) {
|
|
319
|
+
console.error(`Invalid navigation mode: ${opts.navigation}. Use ${NAVIGATION.join("|")}.`);
|
|
320
|
+
process.exitCode = 1;
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
const nav = opts.navigation ?? pkg.structure?.navigation ?? "linear";
|
|
324
|
+
let order = pkg.structure?.order ?? [];
|
|
325
|
+
if (opts.order) {
|
|
326
|
+
order = opts.order;
|
|
327
|
+
} else if (opts.sync || order.length === 0) {
|
|
328
|
+
order = buildOrder(pkg.experienceRefs ?? [], order);
|
|
329
|
+
}
|
|
330
|
+
pkg.structure = {
|
|
331
|
+
navigation: nav,
|
|
332
|
+
order,
|
|
333
|
+
...pkg.structure?.sections ? { sections: pkg.structure.sections } : {}
|
|
334
|
+
};
|
|
335
|
+
await writePackage(dir, pkg);
|
|
336
|
+
console.log(`Structure updated (navigation: ${nav}, ${order.length} experience(s)).`);
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// src/core/authoring/experience-builder.ts
|
|
341
|
+
function buildExperience(a) {
|
|
342
|
+
return withExperienceDefaults({
|
|
343
|
+
id: a.id,
|
|
344
|
+
title: a.title ?? a.id,
|
|
345
|
+
learningPurpose: a.learningPurpose ?? "",
|
|
346
|
+
scenario: { summary: a.scenario ?? "" },
|
|
347
|
+
mechanicRef: a.mechanicRef,
|
|
348
|
+
feedback: { model: a.feedbackModel ?? "immediate" }
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// src/cli/commands/experience.ts
|
|
353
|
+
function register5(program) {
|
|
354
|
+
const experience = program.command("experience").description("Manage interactive experiences");
|
|
355
|
+
experience.command("add").description("Add an experience to the package").requiredOption("--id <id>", "experience id (slug)").option("--title <text>", "title").option("--purpose <text>", "learning purpose").option("--scenario <text>", "scenario summary").option("--mechanic <id>", "mechanic id").option("--feedback <model>", "feedback model", "immediate").action(
|
|
356
|
+
async (opts, cmd) => {
|
|
357
|
+
const dir = cwdOf(cmd.parent);
|
|
358
|
+
const exp = buildExperience({
|
|
359
|
+
id: opts.id,
|
|
360
|
+
title: opts.title,
|
|
361
|
+
learningPurpose: opts.purpose,
|
|
362
|
+
scenario: opts.scenario,
|
|
363
|
+
mechanicRef: opts.mechanic ?? "",
|
|
364
|
+
feedbackModel: opts.feedback
|
|
365
|
+
});
|
|
366
|
+
await writeExperienceFile(dir, exp);
|
|
367
|
+
const pkg = await readPackage(dir);
|
|
368
|
+
if (!pkg.experienceRefs.includes(exp.id)) pkg.experienceRefs.push(exp.id);
|
|
369
|
+
pkg.structure = {
|
|
370
|
+
navigation: pkg.structure?.navigation ?? "linear",
|
|
371
|
+
order: [.../* @__PURE__ */ new Set([...pkg.structure?.order ?? [], exp.id])]
|
|
372
|
+
};
|
|
373
|
+
await writePackage(dir, pkg);
|
|
374
|
+
console.log(`Added experience "${exp.id}".`);
|
|
375
|
+
}
|
|
376
|
+
);
|
|
377
|
+
experience.command("list").description("List experiences in the package").action(async (_opts, cmd) => {
|
|
378
|
+
const project = await loadProjectLenient(cwdOf(cmd.parent));
|
|
379
|
+
for (const e of project.experiences) console.log(`${e.id} ${e.title} [${e.mechanicRef}]`);
|
|
380
|
+
});
|
|
381
|
+
experience.command("remove").description("Remove an experience reference from the package").requiredOption("--id <id>", "experience id").action(async (opts, cmd) => {
|
|
382
|
+
const dir = cwdOf(cmd.parent);
|
|
383
|
+
const pkg = await readPackage(dir);
|
|
384
|
+
pkg.experienceRefs = pkg.experienceRefs.filter((r) => r !== opts.id);
|
|
385
|
+
if (pkg.structure) pkg.structure.order = pkg.structure.order.filter((r) => r !== opts.id);
|
|
386
|
+
await writePackage(dir, pkg);
|
|
387
|
+
console.log(`Removed experience ref "${opts.id}".`);
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// src/mechanics/registry.ts
|
|
392
|
+
import { readdir as readdir2, readFile as readFile2, access as access2 } from "node:fs/promises";
|
|
393
|
+
import { dirname, join as join2 } from "node:path";
|
|
394
|
+
import { fileURLToPath } from "node:url";
|
|
395
|
+
import { parse as parseYaml2 } from "yaml";
|
|
396
|
+
|
|
397
|
+
// src/mechanics/plugins/branching-narrative.ts
|
|
398
|
+
var branchingNarrativePlugin = {
|
|
399
|
+
mechanicId: "branching-narrative",
|
|
400
|
+
version: "0.1.0",
|
|
401
|
+
validate(experience) {
|
|
402
|
+
const exp = experience;
|
|
403
|
+
const inputs = exp.mechanicInputs ?? {};
|
|
404
|
+
const checks = [];
|
|
405
|
+
const hasNodes = Array.isArray(inputs.nodes) && inputs.nodes.length > 0;
|
|
406
|
+
checks.push({
|
|
407
|
+
ok: hasNodes,
|
|
408
|
+
message: hasNodes ? "branching-narrative has at least one node" : "branching-narrative requires at least one node in mechanicInputs.nodes"
|
|
409
|
+
});
|
|
410
|
+
const hasStart = typeof inputs.start === "string" && inputs.start.length > 0;
|
|
411
|
+
checks.push({
|
|
412
|
+
ok: hasStart,
|
|
413
|
+
message: hasStart ? "branching-narrative has a start node" : "branching-narrative requires mechanicInputs.start (the starting node id)"
|
|
414
|
+
});
|
|
415
|
+
return checks;
|
|
416
|
+
},
|
|
417
|
+
enrichHandoff(experience) {
|
|
418
|
+
const exp = experience;
|
|
419
|
+
return `Render experience "${exp.id}" as a branching narrative: track the visited path in suspend data and emit completion at terminal nodes.`;
|
|
420
|
+
}
|
|
421
|
+
};
|
|
422
|
+
|
|
423
|
+
// src/mechanics/plugins/index.ts
|
|
424
|
+
var OFFICIAL_PLUGINS = [branchingNarrativePlugin];
|
|
425
|
+
function loadOfficialPlugins() {
|
|
426
|
+
return OFFICIAL_PLUGINS;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// src/mechanics/registry.ts
|
|
430
|
+
var HERE = dirname(fileURLToPath(import.meta.url));
|
|
431
|
+
var MANIFEST_CANDIDATES = [
|
|
432
|
+
join2(HERE, "manifests"),
|
|
433
|
+
join2(HERE, "..", "mechanics", "manifests")
|
|
434
|
+
];
|
|
435
|
+
async function resolveManifestDir() {
|
|
436
|
+
for (const dir of MANIFEST_CANDIDATES) {
|
|
437
|
+
try {
|
|
438
|
+
await access2(dir);
|
|
439
|
+
return dir;
|
|
440
|
+
} catch {
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
return null;
|
|
444
|
+
}
|
|
445
|
+
async function loadOfficialMechanics() {
|
|
446
|
+
const dir = await resolveManifestDir();
|
|
447
|
+
if (!dir) {
|
|
448
|
+
logger.warn({ candidates: MANIFEST_CANDIDATES }, "No mechanic manifests directory found");
|
|
449
|
+
return [];
|
|
450
|
+
}
|
|
451
|
+
const files = (await readdir2(dir)).filter((f) => f.endsWith(".yml") || f.endsWith(".yaml"));
|
|
452
|
+
const manifests = [];
|
|
453
|
+
for (const file of files.sort()) {
|
|
454
|
+
const raw = await readFile2(join2(dir, file), "utf8");
|
|
455
|
+
manifests.push(parseYaml2(raw));
|
|
456
|
+
}
|
|
457
|
+
logger.debug({ dir, count: manifests.length }, "Loaded official mechanics");
|
|
458
|
+
return manifests;
|
|
459
|
+
}
|
|
460
|
+
async function createMechanicRegistry(manifests, plugins) {
|
|
461
|
+
const all = manifests ?? await loadOfficialMechanics();
|
|
462
|
+
const pluginList = plugins ?? await loadOfficialPlugins();
|
|
463
|
+
const byId = new Map(all.map((m) => [m.id, m]));
|
|
464
|
+
const pluginById = new Map(pluginList.map((p) => [p.mechanicId, p]));
|
|
465
|
+
const get = (id) => {
|
|
466
|
+
const m = byId.get(id);
|
|
467
|
+
if (!m) throw new Error(`Unknown mechanic: "${id}". Available: ${[...byId.keys()].join(", ")}`);
|
|
468
|
+
return m;
|
|
469
|
+
};
|
|
470
|
+
return {
|
|
471
|
+
list: () => [...byId.values()],
|
|
472
|
+
has: (id) => byId.has(id),
|
|
473
|
+
get,
|
|
474
|
+
getPlugin: (id) => pluginById.get(id),
|
|
475
|
+
getResolved: (id) => ({ manifest: get(id), plugin: pluginById.get(id) })
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// src/cli/commands/mechanic.ts
|
|
480
|
+
function register6(program) {
|
|
481
|
+
const mechanic = program.command("mechanic").description("Browse available official mechanics");
|
|
482
|
+
mechanic.command("list").description("List available official mechanics").action(async () => {
|
|
483
|
+
const registry = await createMechanicRegistry();
|
|
484
|
+
for (const m of registry.list()) console.log(`${m.id} ${m.name} (${m.category ?? "uncategorized"})`);
|
|
485
|
+
});
|
|
486
|
+
mechanic.command("show").description("Show a mechanic manifest").requiredOption("--id <id>", "mechanic id").action(async (opts) => {
|
|
487
|
+
const registry = await createMechanicRegistry();
|
|
488
|
+
console.log(JSON.stringify(registry.get(opts.id), null, 2));
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// src/cli/commands/design.ts
|
|
493
|
+
import { readFile as readFile3 } from "node:fs/promises";
|
|
494
|
+
import { resolve as resolve3 } from "node:path";
|
|
495
|
+
import { parse as parseYaml3 } from "yaml";
|
|
496
|
+
|
|
497
|
+
// src/core/schema/loader.ts
|
|
498
|
+
import { Ajv2020 } from "ajv/dist/2020.js";
|
|
499
|
+
import addFormatsModule from "ajv-formats";
|
|
500
|
+
|
|
501
|
+
// src/core/schema/package-spec.schema.json
|
|
502
|
+
var package_spec_schema_default = {
|
|
503
|
+
$schema: "https://json-schema.org/draft/2020-12/schema",
|
|
504
|
+
$id: "https://studiolxd.dev/lxd-cli/package-spec.schema.json",
|
|
505
|
+
title: "PackageSpec",
|
|
506
|
+
description: "Top-level elearning package. The package is the unit of delivery (FR-002); a single experience is never top-level.",
|
|
507
|
+
type: "object",
|
|
508
|
+
additionalProperties: false,
|
|
509
|
+
required: [
|
|
510
|
+
"id",
|
|
511
|
+
"schemaVersion",
|
|
512
|
+
"learningGoal",
|
|
513
|
+
"audience",
|
|
514
|
+
"duration",
|
|
515
|
+
"structure",
|
|
516
|
+
"experienceRefs",
|
|
517
|
+
"designRef",
|
|
518
|
+
"completionRules",
|
|
519
|
+
"trackingRef",
|
|
520
|
+
"exportRef"
|
|
521
|
+
],
|
|
522
|
+
properties: {
|
|
523
|
+
id: { type: "string", pattern: "^[a-z0-9][a-z0-9-]*$" },
|
|
524
|
+
schemaVersion: { type: "string" },
|
|
525
|
+
learningGoal: { type: "string", minLength: 1 },
|
|
526
|
+
title: { type: "string" },
|
|
527
|
+
audience: {
|
|
528
|
+
type: "object",
|
|
529
|
+
additionalProperties: false,
|
|
530
|
+
required: ["description"],
|
|
531
|
+
properties: {
|
|
532
|
+
description: { type: "string", minLength: 1 },
|
|
533
|
+
priorKnowledge: { type: "string" },
|
|
534
|
+
roles: { type: "array", items: { type: "string" } }
|
|
535
|
+
}
|
|
536
|
+
},
|
|
537
|
+
duration: {
|
|
538
|
+
type: "object",
|
|
539
|
+
additionalProperties: false,
|
|
540
|
+
required: ["expectedMinutes"],
|
|
541
|
+
properties: {
|
|
542
|
+
expectedMinutes: { type: "number", exclusiveMinimum: 0 },
|
|
543
|
+
note: { type: "string" }
|
|
544
|
+
}
|
|
545
|
+
},
|
|
546
|
+
context: { type: "string" },
|
|
547
|
+
constraints: { type: "array", items: { type: "string" } },
|
|
548
|
+
tone: { type: "string" },
|
|
549
|
+
structure: {
|
|
550
|
+
type: "object",
|
|
551
|
+
additionalProperties: false,
|
|
552
|
+
required: ["navigation", "order"],
|
|
553
|
+
properties: {
|
|
554
|
+
navigation: { enum: ["linear", "free", "guided"] },
|
|
555
|
+
order: { type: "array", items: { type: "string" }, minItems: 1 },
|
|
556
|
+
sections: {
|
|
557
|
+
type: "array",
|
|
558
|
+
items: {
|
|
559
|
+
type: "object",
|
|
560
|
+
additionalProperties: false,
|
|
561
|
+
required: ["id", "title", "experienceIds"],
|
|
562
|
+
properties: {
|
|
563
|
+
id: { type: "string" },
|
|
564
|
+
title: { type: "string" },
|
|
565
|
+
experienceIds: { type: "array", items: { type: "string" } }
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
},
|
|
571
|
+
experienceRefs: {
|
|
572
|
+
type: "array",
|
|
573
|
+
minItems: 1,
|
|
574
|
+
items: { type: "string", description: "experience id (file in experiences/)" }
|
|
575
|
+
},
|
|
576
|
+
designRef: { type: "string" },
|
|
577
|
+
evaluationRules: {
|
|
578
|
+
type: "object",
|
|
579
|
+
additionalProperties: false,
|
|
580
|
+
properties: {
|
|
581
|
+
assessed: { type: "boolean" },
|
|
582
|
+
scoreRules: { $ref: "#/$defs/scoreRules" },
|
|
583
|
+
criteria: { type: "array", items: { type: "string" } }
|
|
584
|
+
},
|
|
585
|
+
if: { properties: { assessed: { const: true } }, required: ["assessed"] },
|
|
586
|
+
then: { required: ["scoreRules"] }
|
|
587
|
+
},
|
|
588
|
+
completionRules: {
|
|
589
|
+
type: "object",
|
|
590
|
+
additionalProperties: false,
|
|
591
|
+
required: ["rule"],
|
|
592
|
+
properties: {
|
|
593
|
+
rule: { enum: ["all-required", "selected-required"] },
|
|
594
|
+
requiredExperienceIds: { type: "array", items: { type: "string" } }
|
|
595
|
+
},
|
|
596
|
+
if: { properties: { rule: { const: "selected-required" } } },
|
|
597
|
+
then: { required: ["requiredExperienceIds"] }
|
|
598
|
+
},
|
|
599
|
+
trackingRef: { type: "string" },
|
|
600
|
+
exportRef: { type: "string" },
|
|
601
|
+
assetRefs: {
|
|
602
|
+
type: "object",
|
|
603
|
+
additionalProperties: false,
|
|
604
|
+
properties: {
|
|
605
|
+
scormLibraryVersion: { type: "string" },
|
|
606
|
+
scormSkillsSnapshot: {
|
|
607
|
+
type: "object",
|
|
608
|
+
additionalProperties: false,
|
|
609
|
+
properties: { version: { type: "string" }, ref: { type: "string" } }
|
|
610
|
+
},
|
|
611
|
+
cliVersion: { type: "string" }
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
},
|
|
615
|
+
$defs: {
|
|
616
|
+
scoreRules: {
|
|
617
|
+
type: "object",
|
|
618
|
+
additionalProperties: false,
|
|
619
|
+
required: ["method"],
|
|
620
|
+
properties: {
|
|
621
|
+
method: { enum: ["percentage", "points", "scaled"] },
|
|
622
|
+
passThreshold: { type: "number" },
|
|
623
|
+
max: { type: "number" }
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
};
|
|
628
|
+
|
|
629
|
+
// src/core/schema/experience.schema.json
|
|
630
|
+
var experience_schema_default = {
|
|
631
|
+
$schema: "https://json-schema.org/draft/2020-12/schema",
|
|
632
|
+
$id: "https://studiolxd.dev/lxd-cli/experience.schema.json",
|
|
633
|
+
title: "Experience",
|
|
634
|
+
description: "An interactive learning experience inside a package (FR-007). Exactly one mechanic (FR-010).",
|
|
635
|
+
type: "object",
|
|
636
|
+
additionalProperties: false,
|
|
637
|
+
required: ["id", "title", "learningPurpose", "scenario", "mechanicRef", "feedback"],
|
|
638
|
+
properties: {
|
|
639
|
+
id: { type: "string", pattern: "^[a-z0-9][a-z0-9-]*$" },
|
|
640
|
+
title: { type: "string", minLength: 1 },
|
|
641
|
+
learningPurpose: { type: "string", minLength: 1 },
|
|
642
|
+
scenario: {
|
|
643
|
+
type: "object",
|
|
644
|
+
additionalProperties: false,
|
|
645
|
+
required: ["summary"],
|
|
646
|
+
properties: {
|
|
647
|
+
summary: { type: "string", minLength: 1 },
|
|
648
|
+
context: { type: "string" },
|
|
649
|
+
roles: { type: "array", items: { type: "string" } }
|
|
650
|
+
}
|
|
651
|
+
},
|
|
652
|
+
mechanicRef: { type: "string", description: "MechanicManifest.id; MUST resolve in the mechanic registry" },
|
|
653
|
+
mechanicInputs: { type: "object", description: "MUST satisfy the mechanic manifest's requiredInputs" },
|
|
654
|
+
interfaceIntent: { type: "string" },
|
|
655
|
+
evaluation: {
|
|
656
|
+
type: "object",
|
|
657
|
+
additionalProperties: false,
|
|
658
|
+
properties: {
|
|
659
|
+
assessed: { type: "boolean" },
|
|
660
|
+
scoreRules: {
|
|
661
|
+
type: "object",
|
|
662
|
+
additionalProperties: false,
|
|
663
|
+
required: ["method"],
|
|
664
|
+
properties: {
|
|
665
|
+
method: { enum: ["percentage", "points", "scaled"] },
|
|
666
|
+
passThreshold: { type: "number" },
|
|
667
|
+
max: { type: "number" }
|
|
668
|
+
}
|
|
669
|
+
},
|
|
670
|
+
criteria: { type: "array", items: { type: "string" } }
|
|
671
|
+
},
|
|
672
|
+
if: { properties: { assessed: { const: true } }, required: ["assessed"] },
|
|
673
|
+
then: { required: ["scoreRules"] }
|
|
674
|
+
},
|
|
675
|
+
feedback: {
|
|
676
|
+
type: "object",
|
|
677
|
+
additionalProperties: false,
|
|
678
|
+
required: ["model"],
|
|
679
|
+
properties: {
|
|
680
|
+
model: { enum: ["immediate", "delayed", "summary", "adaptive"] },
|
|
681
|
+
messages: {
|
|
682
|
+
type: "array",
|
|
683
|
+
items: {
|
|
684
|
+
type: "object",
|
|
685
|
+
additionalProperties: false,
|
|
686
|
+
required: ["on", "message"],
|
|
687
|
+
properties: {
|
|
688
|
+
on: { type: "string", description: "meaningful learner action" },
|
|
689
|
+
message: { type: "string" }
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
},
|
|
695
|
+
required: { type: "boolean", default: true, description: "counts toward package completion roll-up" },
|
|
696
|
+
weight: { type: "number", exclusiveMinimum: 0, default: 1 },
|
|
697
|
+
signals: {
|
|
698
|
+
type: "array",
|
|
699
|
+
description: "signals this experience emits (subset of the canonical signal set)",
|
|
700
|
+
items: { enum: ["progress", "score", "completion", "interaction", "feedback", "suspend"] },
|
|
701
|
+
uniqueItems: true
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
};
|
|
705
|
+
|
|
706
|
+
// src/core/schema/mechanic-manifest.schema.json
|
|
707
|
+
var mechanic_manifest_schema_default = {
|
|
708
|
+
$schema: "https://json-schema.org/draft/2020-12/schema",
|
|
709
|
+
$id: "https://studiolxd.dev/lxd-cli/mechanic-manifest.schema.json",
|
|
710
|
+
title: "MechanicManifest",
|
|
711
|
+
description: "Declarative, REQUIRED contract for an experience mechanic (FR-008a). Official assets authored by Studio LXD in v1. An optional code plugin (FR-008b) may supplement but never replaces this manifest.",
|
|
712
|
+
type: "object",
|
|
713
|
+
additionalProperties: false,
|
|
714
|
+
required: ["id", "name", "version", "description", "requiredInputs", "trackingContract"],
|
|
715
|
+
properties: {
|
|
716
|
+
id: { type: "string", pattern: "^[a-z0-9][a-z0-9-]*$" },
|
|
717
|
+
name: { type: "string" },
|
|
718
|
+
version: { type: "string" },
|
|
719
|
+
category: { type: "string" },
|
|
720
|
+
description: { type: "string", minLength: 1 },
|
|
721
|
+
requiredInputs: {
|
|
722
|
+
type: "array",
|
|
723
|
+
items: {
|
|
724
|
+
type: "object",
|
|
725
|
+
additionalProperties: false,
|
|
726
|
+
required: ["key", "type"],
|
|
727
|
+
properties: {
|
|
728
|
+
key: { type: "string" },
|
|
729
|
+
type: { enum: ["string", "number", "boolean", "array", "object"] },
|
|
730
|
+
description: { type: "string" },
|
|
731
|
+
required: { type: "boolean", default: true }
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
},
|
|
735
|
+
evaluationContract: {
|
|
736
|
+
type: "object",
|
|
737
|
+
description: "shape the experience's evaluation must satisfy when this mechanic is assessable",
|
|
738
|
+
properties: { assessable: { type: "boolean" } }
|
|
739
|
+
},
|
|
740
|
+
feedbackContract: {
|
|
741
|
+
type: "object",
|
|
742
|
+
description: "shape of feedback this mechanic expects"
|
|
743
|
+
},
|
|
744
|
+
trackingContract: {
|
|
745
|
+
type: "array",
|
|
746
|
+
description: "signals the mechanic is expected to emit",
|
|
747
|
+
items: { enum: ["progress", "score", "completion", "interaction", "feedback", "suspend"] },
|
|
748
|
+
uniqueItems: true
|
|
749
|
+
},
|
|
750
|
+
agentGuidance: {
|
|
751
|
+
type: "string",
|
|
752
|
+
description: "instructions injected into agent handoff materials for this mechanic"
|
|
753
|
+
},
|
|
754
|
+
pluginRef: {
|
|
755
|
+
type: "string",
|
|
756
|
+
description: "OPTIONAL reference to a code plugin implementing mechanic-plugin.contract.md (FR-008b)"
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
};
|
|
760
|
+
|
|
761
|
+
// src/core/schema/design-direction.schema.json
|
|
762
|
+
var design_direction_schema_default = {
|
|
763
|
+
$schema: "https://json-schema.org/draft/2020-12/schema",
|
|
764
|
+
$id: "https://studiolxd.dev/lxd-cli/design-direction.schema.json",
|
|
765
|
+
title: "DesignDirection",
|
|
766
|
+
description: "First-class, multi-level design model (FR-011/012/013). Package-level, shared by experiences. 'level' gates which inputs are expected.",
|
|
767
|
+
type: "object",
|
|
768
|
+
additionalProperties: false,
|
|
769
|
+
required: ["level"],
|
|
770
|
+
properties: {
|
|
771
|
+
level: { enum: ["brief", "tokens", "patterns", "design-system", "full-constraints"] },
|
|
772
|
+
brief: { type: "string" },
|
|
773
|
+
tone: { type: "string" },
|
|
774
|
+
tokens: {
|
|
775
|
+
type: "object",
|
|
776
|
+
additionalProperties: false,
|
|
777
|
+
properties: {
|
|
778
|
+
colors: { type: "object", additionalProperties: { type: "string" } },
|
|
779
|
+
typography: { type: "object" },
|
|
780
|
+
spacing: { type: "object" },
|
|
781
|
+
density: { enum: ["compact", "comfortable", "spacious"] }
|
|
782
|
+
}
|
|
783
|
+
},
|
|
784
|
+
patterns: {
|
|
785
|
+
type: "object",
|
|
786
|
+
additionalProperties: false,
|
|
787
|
+
properties: {
|
|
788
|
+
ui: { type: "array", items: { type: "string" } },
|
|
789
|
+
interaction: { type: "array", items: { type: "string" } },
|
|
790
|
+
componentUsage: { type: "array", items: { type: "string" } }
|
|
791
|
+
}
|
|
792
|
+
},
|
|
793
|
+
accessibility: {
|
|
794
|
+
type: "object",
|
|
795
|
+
additionalProperties: false,
|
|
796
|
+
properties: {
|
|
797
|
+
considered: { type: "boolean" },
|
|
798
|
+
targets: { type: "array", items: { type: "string" }, description: "e.g., WCAG level chosen per project" },
|
|
799
|
+
notes: { type: "string" }
|
|
800
|
+
}
|
|
801
|
+
},
|
|
802
|
+
responsive: {
|
|
803
|
+
type: "object",
|
|
804
|
+
additionalProperties: false,
|
|
805
|
+
properties: {
|
|
806
|
+
approach: { enum: ["mobile-first", "desktop-first", "fluid"] },
|
|
807
|
+
breakpoints: { type: "array", items: { type: "string" } }
|
|
808
|
+
}
|
|
809
|
+
},
|
|
810
|
+
animation: { type: "string" },
|
|
811
|
+
designSystemRefs: {
|
|
812
|
+
type: "array",
|
|
813
|
+
items: {
|
|
814
|
+
type: "object",
|
|
815
|
+
additionalProperties: false,
|
|
816
|
+
required: ["kind", "ref"],
|
|
817
|
+
properties: {
|
|
818
|
+
kind: { enum: ["url", "file"] },
|
|
819
|
+
ref: { type: "string" }
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
},
|
|
823
|
+
constraints: {
|
|
824
|
+
type: "object",
|
|
825
|
+
additionalProperties: false,
|
|
826
|
+
properties: {
|
|
827
|
+
do: { type: "array", items: { type: "string" } },
|
|
828
|
+
dont: { type: "array", items: { type: "string" } },
|
|
829
|
+
implementation: { type: "array", items: { type: "string" } }
|
|
830
|
+
}
|
|
831
|
+
},
|
|
832
|
+
output: {
|
|
833
|
+
type: "array",
|
|
834
|
+
items: { type: "string" },
|
|
835
|
+
description: "e.g., light-mode, dark-mode, mobile-first, corporate, playful, minimal"
|
|
836
|
+
}
|
|
837
|
+
},
|
|
838
|
+
allOf: [
|
|
839
|
+
{ if: { properties: { level: { const: "brief" } }, required: ["level"] }, then: { required: ["brief"] } },
|
|
840
|
+
{ if: { properties: { level: { const: "tokens" } }, required: ["level"] }, then: { required: ["tokens"] } },
|
|
841
|
+
{ if: { properties: { level: { const: "patterns" } }, required: ["level"] }, then: { required: ["patterns"] } },
|
|
842
|
+
{ if: { properties: { level: { const: "design-system" } }, required: ["level"] }, then: { required: ["designSystemRefs"] } },
|
|
843
|
+
{ if: { properties: { level: { const: "full-constraints" } }, required: ["level"] }, then: { required: ["constraints"] } }
|
|
844
|
+
]
|
|
845
|
+
};
|
|
846
|
+
|
|
847
|
+
// src/core/schema/tracking-config.schema.json
|
|
848
|
+
var tracking_config_schema_default = {
|
|
849
|
+
$schema: "https://json-schema.org/draft/2020-12/schema",
|
|
850
|
+
$id: "https://studiolxd.dev/lxd-cli/tracking-config.schema.json",
|
|
851
|
+
title: "TrackingConfig",
|
|
852
|
+
description: "Two-level tracking (FR-027): per-experience signals + explicit package roll-up rules (FR-027a). The roll-up produces the single LMS-reported state (FR-027b).",
|
|
853
|
+
type: "object",
|
|
854
|
+
additionalProperties: false,
|
|
855
|
+
required: ["experienceSignals", "rollUp"],
|
|
856
|
+
properties: {
|
|
857
|
+
experienceSignals: {
|
|
858
|
+
type: "object",
|
|
859
|
+
description: "map of experienceId -> declared signals",
|
|
860
|
+
additionalProperties: {
|
|
861
|
+
type: "array",
|
|
862
|
+
items: { enum: ["progress", "score", "completion", "interaction", "feedback", "suspend"] },
|
|
863
|
+
uniqueItems: true
|
|
864
|
+
}
|
|
865
|
+
},
|
|
866
|
+
rollUp: {
|
|
867
|
+
type: "object",
|
|
868
|
+
additionalProperties: false,
|
|
869
|
+
required: ["completion", "score", "reportedFields"],
|
|
870
|
+
properties: {
|
|
871
|
+
completion: {
|
|
872
|
+
type: "object",
|
|
873
|
+
additionalProperties: false,
|
|
874
|
+
required: ["rule"],
|
|
875
|
+
properties: {
|
|
876
|
+
rule: { enum: ["all-required-complete", "selected-required-complete"] },
|
|
877
|
+
requiredExperienceIds: { type: "array", items: { type: "string" } }
|
|
878
|
+
},
|
|
879
|
+
if: { properties: { rule: { const: "selected-required-complete" } } },
|
|
880
|
+
then: { required: ["requiredExperienceIds"] }
|
|
881
|
+
},
|
|
882
|
+
score: {
|
|
883
|
+
type: "object",
|
|
884
|
+
additionalProperties: false,
|
|
885
|
+
required: ["rule"],
|
|
886
|
+
properties: {
|
|
887
|
+
rule: { enum: ["weighted-average", "final-assessment-only"] },
|
|
888
|
+
finalAssessmentExperienceId: { type: "string" }
|
|
889
|
+
},
|
|
890
|
+
if: { properties: { rule: { const: "final-assessment-only" } } },
|
|
891
|
+
then: { required: ["finalAssessmentExperienceId"] }
|
|
892
|
+
},
|
|
893
|
+
success: {
|
|
894
|
+
type: "object",
|
|
895
|
+
additionalProperties: false,
|
|
896
|
+
properties: {
|
|
897
|
+
scoreThreshold: { type: "number" },
|
|
898
|
+
requireRequiredExperiencesComplete: { type: "boolean", default: true }
|
|
899
|
+
}
|
|
900
|
+
},
|
|
901
|
+
reportedFields: {
|
|
902
|
+
type: "array",
|
|
903
|
+
minItems: 1,
|
|
904
|
+
items: { enum: ["completion", "success", "score", "suspend_data", "lesson_location"] },
|
|
905
|
+
uniqueItems: true
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
};
|
|
911
|
+
|
|
912
|
+
// src/core/schema/export-config.schema.json
|
|
913
|
+
var export_config_schema_default = {
|
|
914
|
+
$schema: "https://json-schema.org/draft/2020-12/schema",
|
|
915
|
+
$id: "https://studiolxd.dev/lxd-cli/export-config.schema.json",
|
|
916
|
+
title: "ExportConfig",
|
|
917
|
+
description: "Adapter-driven export configuration (FR-022/024). adapterId is an open enum; unknown ids without an adapter must be reported clearly (FR-025).",
|
|
918
|
+
type: "object",
|
|
919
|
+
additionalProperties: false,
|
|
920
|
+
required: ["targets"],
|
|
921
|
+
properties: {
|
|
922
|
+
targets: {
|
|
923
|
+
type: "array",
|
|
924
|
+
minItems: 1,
|
|
925
|
+
items: {
|
|
926
|
+
type: "object",
|
|
927
|
+
additionalProperties: false,
|
|
928
|
+
required: ["adapterId"],
|
|
929
|
+
properties: {
|
|
930
|
+
adapterId: {
|
|
931
|
+
type: "string",
|
|
932
|
+
description: "v1 in-tree adapters: web | scorm-1.2 | scorm-2004. Open to future adapters (xapi, cmi5, ...).",
|
|
933
|
+
examples: ["web", "scorm-1.2", "scorm-2004"]
|
|
934
|
+
},
|
|
935
|
+
options: {
|
|
936
|
+
type: "object",
|
|
937
|
+
description: "Adapter-specific options, validated against the adapter's own option schema",
|
|
938
|
+
properties: {
|
|
939
|
+
identifier: { type: "string", description: "SCORM manifest identifier" },
|
|
940
|
+
masteryScore: { type: "number", description: "SCORM 1.2 mastery score / 2004 scaled passing score" },
|
|
941
|
+
title: { type: "string" }
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
},
|
|
947
|
+
framework: {
|
|
948
|
+
type: "object",
|
|
949
|
+
additionalProperties: false,
|
|
950
|
+
properties: {
|
|
951
|
+
adapterId: { type: "string", examples: ["webcomponents"] },
|
|
952
|
+
options: { type: "object" }
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
};
|
|
957
|
+
|
|
958
|
+
// src/core/schema/validation-report.schema.json
|
|
959
|
+
var validation_report_schema_default = {
|
|
960
|
+
$schema: "https://json-schema.org/draft/2020-12/schema",
|
|
961
|
+
$id: "https://studiolxd.dev/lxd-cli/validation-report.schema.json",
|
|
962
|
+
title: "ValidationReport",
|
|
963
|
+
description: "Output of the validation subsystem (FR-032/033). Every check is actionable: it identifies what failed and where.",
|
|
964
|
+
type: "object",
|
|
965
|
+
additionalProperties: false,
|
|
966
|
+
required: ["checks", "summary"],
|
|
967
|
+
properties: {
|
|
968
|
+
checks: {
|
|
969
|
+
type: "array",
|
|
970
|
+
items: {
|
|
971
|
+
type: "object",
|
|
972
|
+
additionalProperties: false,
|
|
973
|
+
required: ["id", "category", "status", "message"],
|
|
974
|
+
properties: {
|
|
975
|
+
id: { type: "string", description: "stable check id, e.g., 'learning-goal-present'" },
|
|
976
|
+
category: {
|
|
977
|
+
enum: ["instructional", "design", "accessibility", "technical", "tracking", "export", "maintainability", "portability"]
|
|
978
|
+
},
|
|
979
|
+
status: { enum: ["pass", "fail", "warn", "skip"] },
|
|
980
|
+
message: { type: "string" },
|
|
981
|
+
location: { type: "string", description: "artifact path / field pointer the result refers to" },
|
|
982
|
+
fix: { type: "string", description: "suggested remediation" }
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
},
|
|
986
|
+
summary: {
|
|
987
|
+
type: "object",
|
|
988
|
+
additionalProperties: false,
|
|
989
|
+
required: ["pass", "fail", "warn", "skip", "overall"],
|
|
990
|
+
properties: {
|
|
991
|
+
pass: { type: "integer", minimum: 0 },
|
|
992
|
+
fail: { type: "integer", minimum: 0 },
|
|
993
|
+
warn: { type: "integer", minimum: 0 },
|
|
994
|
+
skip: { type: "integer", minimum: 0 },
|
|
995
|
+
overall: { enum: ["pass", "fail"] }
|
|
996
|
+
}
|
|
997
|
+
},
|
|
998
|
+
generatedAt: { type: "string", format: "date-time" }
|
|
999
|
+
}
|
|
1000
|
+
};
|
|
1001
|
+
|
|
1002
|
+
// src/core/schema/schemas.ts
|
|
1003
|
+
var SCHEMA_IDS = [
|
|
1004
|
+
"package-spec",
|
|
1005
|
+
"experience",
|
|
1006
|
+
"mechanic-manifest",
|
|
1007
|
+
"design-direction",
|
|
1008
|
+
"tracking-config",
|
|
1009
|
+
"export-config",
|
|
1010
|
+
"validation-report"
|
|
1011
|
+
];
|
|
1012
|
+
var SCHEMAS = {
|
|
1013
|
+
"package-spec": package_spec_schema_default,
|
|
1014
|
+
experience: experience_schema_default,
|
|
1015
|
+
"mechanic-manifest": mechanic_manifest_schema_default,
|
|
1016
|
+
"design-direction": design_direction_schema_default,
|
|
1017
|
+
"tracking-config": tracking_config_schema_default,
|
|
1018
|
+
"export-config": export_config_schema_default,
|
|
1019
|
+
"validation-report": validation_report_schema_default
|
|
1020
|
+
};
|
|
1021
|
+
|
|
1022
|
+
// src/core/schema/loader.ts
|
|
1023
|
+
var addFormats = addFormatsModule;
|
|
1024
|
+
var SchemaValidator = class {
|
|
1025
|
+
ajv;
|
|
1026
|
+
compiled = /* @__PURE__ */ new Map();
|
|
1027
|
+
constructor() {
|
|
1028
|
+
this.ajv = new Ajv2020({ allErrors: true, strict: false });
|
|
1029
|
+
addFormats(this.ajv);
|
|
1030
|
+
for (const id of SCHEMA_IDS) {
|
|
1031
|
+
this.compiled.set(id, this.ajv.compile(SCHEMAS[id]));
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
/** Throws if the named schema failed to compile (used by contract tests). */
|
|
1035
|
+
assertCompiled(id) {
|
|
1036
|
+
if (!this.compiled.has(id)) {
|
|
1037
|
+
throw new Error(`Schema not compiled: ${id}`);
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
validate(id, data) {
|
|
1041
|
+
const fn = this.compiled.get(id);
|
|
1042
|
+
if (!fn) {
|
|
1043
|
+
throw new Error(`Unknown schema id: ${id}`);
|
|
1044
|
+
}
|
|
1045
|
+
const valid = fn(data);
|
|
1046
|
+
return { valid, errors: valid ? [] : toIssues(fn.errors) };
|
|
1047
|
+
}
|
|
1048
|
+
};
|
|
1049
|
+
function toIssues(errors) {
|
|
1050
|
+
if (!errors) return [];
|
|
1051
|
+
return errors.map((e) => ({
|
|
1052
|
+
message: e.message ?? "invalid",
|
|
1053
|
+
path: e.instancePath || "/",
|
|
1054
|
+
keyword: e.keyword
|
|
1055
|
+
}));
|
|
1056
|
+
}
|
|
1057
|
+
function createValidator() {
|
|
1058
|
+
return new SchemaValidator();
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
// src/cli/commands/design.ts
|
|
1062
|
+
function register7(program) {
|
|
1063
|
+
program.command("design").description("Define design direction (simple brief through full constraints) \u2014 CLI-first, no GUI").option("-l, --level <level>", `design level (${DESIGN_LEVELS.join("|")})`, "brief").option("-b, --brief <text>", "natural-language design brief").option("--tone <text>", "tone / visual personality").option("--a11y <targets...>", 'accessibility targets (e.g., "WCAG 2.1 AA")').option("--output <items...>", "output requirements (e.g., light-mode dark-mode mobile-first)").option("--from <file>", "load a full design-direction document (YAML/JSON) for advanced levels").action(
|
|
1064
|
+
async (opts, cmd) => {
|
|
1065
|
+
const dir = cwdOf(cmd);
|
|
1066
|
+
let design;
|
|
1067
|
+
if (opts.from) {
|
|
1068
|
+
design = parseYaml3(await readFile3(resolve3(dir, opts.from), "utf8"));
|
|
1069
|
+
} else {
|
|
1070
|
+
design = { level: opts.level };
|
|
1071
|
+
if (opts.brief) design.brief = opts.brief;
|
|
1072
|
+
if (opts.tone) design.tone = opts.tone;
|
|
1073
|
+
if (opts.a11y) design.accessibility = { considered: true, targets: opts.a11y };
|
|
1074
|
+
if (opts.output) design.output = opts.output;
|
|
1075
|
+
}
|
|
1076
|
+
const result = createValidator().validate("design-direction", design);
|
|
1077
|
+
if (!result.valid) {
|
|
1078
|
+
console.error("Invalid design direction:");
|
|
1079
|
+
for (const e of result.errors) console.error(` ${e.path}: ${e.message}`);
|
|
1080
|
+
process.exitCode = 1;
|
|
1081
|
+
return;
|
|
1082
|
+
}
|
|
1083
|
+
await writeDesign(dir, design);
|
|
1084
|
+
console.log(`Design direction set (level: ${String(design.level)}).`);
|
|
1085
|
+
}
|
|
1086
|
+
);
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
// src/cli/commands/evaluation.ts
|
|
1090
|
+
function register8(program) {
|
|
1091
|
+
program.command("evaluation").description("Set evaluation, feedback, and completion rules").option("-c, --completion <rule>", "completion rule (all-required|selected-required)", "all-required").option("--assessed", "mark the package as assessed").option("--pass-threshold <n>", "passing score threshold").action(
|
|
1092
|
+
async (opts, cmd) => {
|
|
1093
|
+
const dir = cwdOf(cmd);
|
|
1094
|
+
const pkg = await readPackage(dir);
|
|
1095
|
+
pkg.completionRules = { rule: opts.completion };
|
|
1096
|
+
if (opts.assessed) {
|
|
1097
|
+
pkg.evaluationRules = {
|
|
1098
|
+
assessed: true,
|
|
1099
|
+
scoreRules: {
|
|
1100
|
+
method: "percentage",
|
|
1101
|
+
passThreshold: opts.passThreshold ? Number(opts.passThreshold) : 70
|
|
1102
|
+
}
|
|
1103
|
+
};
|
|
1104
|
+
}
|
|
1105
|
+
await writePackage(dir, pkg);
|
|
1106
|
+
console.log("Evaluation/completion rules updated.");
|
|
1107
|
+
}
|
|
1108
|
+
);
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
// src/core/validation/tracking.ts
|
|
1112
|
+
function checkTrackingRollup(project) {
|
|
1113
|
+
const tracking = project.tracking;
|
|
1114
|
+
const rollUp = tracking.rollUp;
|
|
1115
|
+
if (!rollUp) {
|
|
1116
|
+
return { status: "fail", message: "No roll-up rules defined", location: "tracking.rollUp" };
|
|
1117
|
+
}
|
|
1118
|
+
if (!rollUp.reportedFields || rollUp.reportedFields.length === 0) {
|
|
1119
|
+
return {
|
|
1120
|
+
status: "fail",
|
|
1121
|
+
message: "Roll-up declares no reported fields",
|
|
1122
|
+
location: "tracking.rollUp.reportedFields"
|
|
1123
|
+
};
|
|
1124
|
+
}
|
|
1125
|
+
const expIds = new Set(project.experiences.map((e) => e.id));
|
|
1126
|
+
if (rollUp.completion?.rule === "selected-required-complete") {
|
|
1127
|
+
const ids = rollUp.completion.requiredExperienceIds ?? [];
|
|
1128
|
+
if (ids.length === 0) {
|
|
1129
|
+
return {
|
|
1130
|
+
status: "fail",
|
|
1131
|
+
message: "selected-required-complete needs requiredExperienceIds",
|
|
1132
|
+
location: "tracking.rollUp.completion.requiredExperienceIds"
|
|
1133
|
+
};
|
|
1134
|
+
}
|
|
1135
|
+
const unknown = ids.find((id) => !expIds.has(id));
|
|
1136
|
+
if (unknown) {
|
|
1137
|
+
return {
|
|
1138
|
+
status: "fail",
|
|
1139
|
+
message: `Roll-up references unknown experience "${unknown}"`,
|
|
1140
|
+
location: "tracking.rollUp.completion.requiredExperienceIds"
|
|
1141
|
+
};
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
if (rollUp.score?.rule === "final-assessment-only") {
|
|
1145
|
+
const id = rollUp.score.finalAssessmentExperienceId;
|
|
1146
|
+
if (!id || !expIds.has(id)) {
|
|
1147
|
+
return {
|
|
1148
|
+
status: "fail",
|
|
1149
|
+
message: "final-assessment-only needs a valid finalAssessmentExperienceId",
|
|
1150
|
+
location: "tracking.rollUp.score.finalAssessmentExperienceId"
|
|
1151
|
+
};
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
return { status: "pass", message: "Roll-up rules are coherent" };
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
// src/core/validation/export.ts
|
|
1158
|
+
var KNOWN_EXPORT_ADAPTERS = ["web", "scorm-1.2", "scorm-2004"];
|
|
1159
|
+
function checkExportTargets(project) {
|
|
1160
|
+
const cfg = project.export;
|
|
1161
|
+
const targets = cfg.targets ?? [];
|
|
1162
|
+
if (targets.length === 0) {
|
|
1163
|
+
return { status: "skip", message: "No export targets configured yet", location: "export.targets" };
|
|
1164
|
+
}
|
|
1165
|
+
const unknown = targets.find((t) => !t.adapterId || !KNOWN_EXPORT_ADAPTERS.includes(t.adapterId));
|
|
1166
|
+
if (unknown) {
|
|
1167
|
+
return {
|
|
1168
|
+
status: "fail",
|
|
1169
|
+
message: `Unknown export target "${unknown.adapterId ?? "(missing)"}". Known: ${KNOWN_EXPORT_ADAPTERS.join(", ")}`,
|
|
1170
|
+
location: "export.targets"
|
|
1171
|
+
};
|
|
1172
|
+
}
|
|
1173
|
+
return { status: "pass", message: "All export targets map to known adapters" };
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
// src/core/validation/design.ts
|
|
1177
|
+
var REQUIRED_BY_LEVEL = {
|
|
1178
|
+
brief: "brief",
|
|
1179
|
+
tokens: "tokens",
|
|
1180
|
+
patterns: "patterns",
|
|
1181
|
+
"design-system": "designSystemRefs",
|
|
1182
|
+
"full-constraints": "constraints"
|
|
1183
|
+
};
|
|
1184
|
+
function checkDesign(project) {
|
|
1185
|
+
const design = project.design;
|
|
1186
|
+
const level = design.level;
|
|
1187
|
+
if (!level) {
|
|
1188
|
+
return { status: "skip", message: "No design level set", location: "design.level" };
|
|
1189
|
+
}
|
|
1190
|
+
const required = REQUIRED_BY_LEVEL[level];
|
|
1191
|
+
if (required && !design[required]) {
|
|
1192
|
+
return {
|
|
1193
|
+
status: "fail",
|
|
1194
|
+
message: `Design level "${level}" requires "${required}"`,
|
|
1195
|
+
location: `design.${required}`
|
|
1196
|
+
};
|
|
1197
|
+
}
|
|
1198
|
+
return { status: "pass", message: `Design direction is complete for level "${level}"` };
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
// src/core/validation/portability.ts
|
|
1202
|
+
var FRAMEWORK_TOKENS = ["react", "vue", "svelte", "angular", "solidjs", "preact"];
|
|
1203
|
+
var TARGET_TOKENS = ["scorm", "xapi", "cmi5"];
|
|
1204
|
+
function checkPortability(project) {
|
|
1205
|
+
const blob = JSON.stringify({
|
|
1206
|
+
pkg: project.package,
|
|
1207
|
+
design: project.design,
|
|
1208
|
+
experiences: project.experiences
|
|
1209
|
+
}).toLowerCase();
|
|
1210
|
+
const framework = FRAMEWORK_TOKENS.find((t) => blob.includes(`"${t}"`));
|
|
1211
|
+
if (framework) {
|
|
1212
|
+
return {
|
|
1213
|
+
status: "warn",
|
|
1214
|
+
message: `Spec references a specific framework ("${framework}") \u2014 keep spec artifacts framework-agnostic`,
|
|
1215
|
+
fix: "Move framework-specific guidance into the framework adapter, not the package spec"
|
|
1216
|
+
};
|
|
1217
|
+
}
|
|
1218
|
+
const target = TARGET_TOKENS.find((t) => blob.includes(`"${t}`));
|
|
1219
|
+
if (target) {
|
|
1220
|
+
return {
|
|
1221
|
+
status: "warn",
|
|
1222
|
+
message: `Spec references an export target ("${target}") outside the export config`,
|
|
1223
|
+
fix: "Express target-specific needs via export configuration/adapters"
|
|
1224
|
+
};
|
|
1225
|
+
}
|
|
1226
|
+
return { status: "pass", message: "Spec artifacts are not coupled to a specific framework or export target" };
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
// src/core/validation/maintainability.ts
|
|
1230
|
+
var IMPLEMENTATION_MARKERS = ["<script", "<!doctype", "</html>", "</body>"];
|
|
1231
|
+
function checkMaintainability(project) {
|
|
1232
|
+
const blob = JSON.stringify({
|
|
1233
|
+
pkg: project.package,
|
|
1234
|
+
design: project.design,
|
|
1235
|
+
experiences: project.experiences,
|
|
1236
|
+
tracking: project.tracking
|
|
1237
|
+
}).toLowerCase();
|
|
1238
|
+
const marker = IMPLEMENTATION_MARKERS.find((m) => blob.includes(m));
|
|
1239
|
+
if (marker) {
|
|
1240
|
+
return {
|
|
1241
|
+
status: "fail",
|
|
1242
|
+
message: `Spec artifacts appear to embed generated implementation ("${marker}") \u2014 keep spec and generated/ separate`,
|
|
1243
|
+
location: "experiences",
|
|
1244
|
+
fix: "Keep markup/code in generated/, not in the YAML specification"
|
|
1245
|
+
};
|
|
1246
|
+
}
|
|
1247
|
+
return { status: "pass", message: "Specification and generated implementation are kept separate" };
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
// src/core/validation/scorm-runtime.ts
|
|
1251
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
1252
|
+
import { join as join3, extname } from "node:path";
|
|
1253
|
+
var SCORM_TARGETS = ["scorm-1.2", "scorm-2004"];
|
|
1254
|
+
var SCAN_EXT = /* @__PURE__ */ new Set([".js", ".ts", ".mjs", ".cjs", ".html"]);
|
|
1255
|
+
var EXCLUDE_DIRS = /* @__PURE__ */ new Set(["node_modules", "dist", "build", ".agent", "scorm-skills"]);
|
|
1256
|
+
var LMS_12 = /\b(LMSInitialize|LMSFinish|LMSCommit|LMSGetValue|LMSSetValue|LMSGetLastError|LMSGetErrorString|LMSGetDiagnostic)\b/;
|
|
1257
|
+
var API_ASSIGN = /\bwindow\.(API|API_1484_11)\s*=/;
|
|
1258
|
+
function isScormTargeted(project) {
|
|
1259
|
+
const targets = project.export.targets ?? [];
|
|
1260
|
+
return targets.some((t) => t.adapterId !== void 0 && SCORM_TARGETS.includes(t.adapterId));
|
|
1261
|
+
}
|
|
1262
|
+
function detectSignature(content) {
|
|
1263
|
+
if (API_ASSIGN.test(content)) return "window.API assignment";
|
|
1264
|
+
if (LMS_12.test(content)) return "custom SCORM 1.2 LMS* handler";
|
|
1265
|
+
if (/\bInitialize\b/.test(content) && /\bTerminate\b/.test(content) && /\b(GetValue|SetValue)\b/.test(content)) {
|
|
1266
|
+
return "custom SCORM 2004 API cluster (Initialize/Terminate/Get|SetValue)";
|
|
1267
|
+
}
|
|
1268
|
+
return null;
|
|
1269
|
+
}
|
|
1270
|
+
function* walk(dir) {
|
|
1271
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
1272
|
+
if (entry.isDirectory()) {
|
|
1273
|
+
if (EXCLUDE_DIRS.has(entry.name)) continue;
|
|
1274
|
+
yield* walk(join3(dir, entry.name));
|
|
1275
|
+
} else if (SCAN_EXT.has(extname(entry.name))) {
|
|
1276
|
+
yield join3(dir, entry.name);
|
|
1277
|
+
}
|
|
1278
|
+
}
|
|
1279
|
+
}
|
|
1280
|
+
function checkScormRuntime(project, ctx) {
|
|
1281
|
+
if (!ctx?.projectDir) return { status: "skip", message: "No project directory to scan" };
|
|
1282
|
+
if (!isScormTargeted(project)) return { status: "skip", message: "No SCORM export target configured" };
|
|
1283
|
+
const generatedDir = join3(ctx.projectDir, "generated");
|
|
1284
|
+
if (!existsSync(generatedDir)) {
|
|
1285
|
+
return { status: "skip", message: "No generated/ output to inspect yet" };
|
|
1286
|
+
}
|
|
1287
|
+
for (const file of walk(generatedDir)) {
|
|
1288
|
+
const signature = detectSignature(readFileSync(file, "utf8"));
|
|
1289
|
+
if (signature) {
|
|
1290
|
+
const rel = file.slice(ctx.projectDir.length + 1);
|
|
1291
|
+
return {
|
|
1292
|
+
status: "fail",
|
|
1293
|
+
message: `Custom SCORM runtime detected (${signature}) \u2014 integrate @studiolxd/scorm instead of hand-rolling a runtime`,
|
|
1294
|
+
location: rel,
|
|
1295
|
+
fix: "Use @studiolxd/scorm for all SCORM runtime interactions (see .agent/scorm-skills/)"
|
|
1296
|
+
};
|
|
1297
|
+
}
|
|
1298
|
+
}
|
|
1299
|
+
return { status: "pass", message: "No custom SCORM runtime in generated/ (uses @studiolxd/scorm)" };
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
// src/core/validation/engine.ts
|
|
1303
|
+
var CHECKS = [
|
|
1304
|
+
{
|
|
1305
|
+
id: "learning-goal-present",
|
|
1306
|
+
category: "instructional",
|
|
1307
|
+
run: (p) => p.package.learningGoal?.trim() ? ok("Learning goal is defined") : fail("Learning goal is missing", "package.learningGoal")
|
|
1308
|
+
},
|
|
1309
|
+
{
|
|
1310
|
+
id: "audience-defined",
|
|
1311
|
+
category: "instructional",
|
|
1312
|
+
run: (p) => p.package.audience?.description?.trim() ? ok("Audience is defined") : fail("Target audience is not defined", "package.audience.description")
|
|
1313
|
+
},
|
|
1314
|
+
{
|
|
1315
|
+
id: "duration-defined",
|
|
1316
|
+
category: "instructional",
|
|
1317
|
+
run: (p) => (p.package.duration?.expectedMinutes ?? 0) > 0 ? ok("Duration is defined") : fail("Expected learning time is not defined", "package.duration.expectedMinutes")
|
|
1318
|
+
},
|
|
1319
|
+
{
|
|
1320
|
+
id: "structure-present",
|
|
1321
|
+
category: "instructional",
|
|
1322
|
+
run: (p) => (p.package.structure?.order?.length ?? 0) > 0 ? ok("Package structure is defined") : fail("Package structure/order is missing", "package.structure")
|
|
1323
|
+
},
|
|
1324
|
+
{
|
|
1325
|
+
id: "has-experience",
|
|
1326
|
+
category: "instructional",
|
|
1327
|
+
run: (p) => p.experiences.length > 0 && p.package.experienceRefs.length > 0 ? ok("Package contains at least one experience") : fail("Package has no experiences", "package.experienceRefs")
|
|
1328
|
+
},
|
|
1329
|
+
{
|
|
1330
|
+
id: "structure-covers-experiences",
|
|
1331
|
+
category: "instructional",
|
|
1332
|
+
run: (p) => {
|
|
1333
|
+
const refs = p.package.experienceRefs ?? [];
|
|
1334
|
+
if (refs.length === 0) return skip("No experiences to order yet");
|
|
1335
|
+
if (structureCoversExperiences(p.package.structure, refs)) {
|
|
1336
|
+
return ok("Structure order covers all experiences");
|
|
1337
|
+
}
|
|
1338
|
+
const order = new Set(p.package.structure?.order ?? []);
|
|
1339
|
+
const missing = refs.filter((id) => !order.has(id));
|
|
1340
|
+
return fail(
|
|
1341
|
+
`Structure order is missing experience(s): ${missing.join(", ")}`,
|
|
1342
|
+
"package.structure.order"
|
|
1343
|
+
);
|
|
1344
|
+
}
|
|
1345
|
+
},
|
|
1346
|
+
{
|
|
1347
|
+
id: "experience-has-purpose",
|
|
1348
|
+
category: "instructional",
|
|
1349
|
+
run: (p) => {
|
|
1350
|
+
const bad = p.experiences.find((e) => !e.learningPurpose?.trim());
|
|
1351
|
+
return bad ? fail(`Experience "${bad.id}" has no learning purpose`, `experiences.${bad.id}.learningPurpose`) : ok("Every experience has a learning purpose");
|
|
1352
|
+
}
|
|
1353
|
+
},
|
|
1354
|
+
{
|
|
1355
|
+
id: "experience-has-mechanic",
|
|
1356
|
+
category: "instructional",
|
|
1357
|
+
run: (p) => {
|
|
1358
|
+
const bad = p.experiences.find((e) => !e.mechanicRef?.trim());
|
|
1359
|
+
return bad ? fail(`Experience "${bad.id}" has no mechanic`, `experiences.${bad.id}.mechanicRef`) : ok("Every experience has a mechanic");
|
|
1360
|
+
}
|
|
1361
|
+
},
|
|
1362
|
+
{
|
|
1363
|
+
id: "design-minimal",
|
|
1364
|
+
category: "design",
|
|
1365
|
+
run: (p) => {
|
|
1366
|
+
const level = p.design.level;
|
|
1367
|
+
return level && DESIGN_LEVELS.includes(level) ? ok(`Design direction defined at level "${level}"`) : fail("Design direction is not defined at least minimally", "design.level");
|
|
1368
|
+
}
|
|
1369
|
+
},
|
|
1370
|
+
{
|
|
1371
|
+
id: "design-level-complete",
|
|
1372
|
+
category: "design",
|
|
1373
|
+
run: (p) => checkDesign(p)
|
|
1374
|
+
},
|
|
1375
|
+
{
|
|
1376
|
+
id: "accessibility-considered",
|
|
1377
|
+
category: "accessibility",
|
|
1378
|
+
run: (p) => {
|
|
1379
|
+
const design = p.design;
|
|
1380
|
+
if (design.accessibility) return ok("Accessibility requirements are considered");
|
|
1381
|
+
return warn(
|
|
1382
|
+
"Accessibility requirements are not explicitly captured",
|
|
1383
|
+
"design.accessibility",
|
|
1384
|
+
"Add a design.accessibility section (e.g., a WCAG target)"
|
|
1385
|
+
);
|
|
1386
|
+
}
|
|
1387
|
+
},
|
|
1388
|
+
{
|
|
1389
|
+
id: "completion-rules-present",
|
|
1390
|
+
category: "instructional",
|
|
1391
|
+
run: (p) => p.package.completionRules?.rule ? ok("Package completion rules are defined") : fail("Package-level completion rules are missing", "package.completionRules")
|
|
1392
|
+
},
|
|
1393
|
+
{
|
|
1394
|
+
id: "score-rules-when-assessed",
|
|
1395
|
+
category: "instructional",
|
|
1396
|
+
run: (p) => {
|
|
1397
|
+
const ev = p.package.evaluationRules;
|
|
1398
|
+
if (!ev?.assessed) return skip("Assessment not used at package level");
|
|
1399
|
+
return ev.scoreRules ? ok("Score rules are defined for assessment") : fail("Assessment is used but score rules are missing", "package.evaluationRules.scoreRules");
|
|
1400
|
+
}
|
|
1401
|
+
},
|
|
1402
|
+
{
|
|
1403
|
+
id: "feedback-present",
|
|
1404
|
+
category: "instructional",
|
|
1405
|
+
run: (p) => {
|
|
1406
|
+
const bad = p.experiences.find((e) => !e.feedback?.model);
|
|
1407
|
+
return bad ? fail(`Experience "${bad.id}" has no feedback model`, `experiences.${bad.id}.feedback`) : ok("Feedback exists for learner actions");
|
|
1408
|
+
}
|
|
1409
|
+
},
|
|
1410
|
+
{
|
|
1411
|
+
id: "tracking-defined",
|
|
1412
|
+
category: "tracking",
|
|
1413
|
+
run: (p) => {
|
|
1414
|
+
const t = p.tracking;
|
|
1415
|
+
return t.experienceSignals && t.rollUp ? ok("Tracking signals and roll-up rules are defined") : fail("Tracking requirements are not fully defined", "tracking");
|
|
1416
|
+
}
|
|
1417
|
+
},
|
|
1418
|
+
{
|
|
1419
|
+
id: "export-config-complete",
|
|
1420
|
+
category: "export",
|
|
1421
|
+
run: (p) => {
|
|
1422
|
+
const e = p.export;
|
|
1423
|
+
return Array.isArray(e.targets) && e.targets.length > 0 ? ok("Export configuration defines at least one target") : fail("Export configuration is incomplete (no targets)", "export.targets");
|
|
1424
|
+
}
|
|
1425
|
+
},
|
|
1426
|
+
{
|
|
1427
|
+
id: "tracking-rollup-coherent",
|
|
1428
|
+
category: "tracking",
|
|
1429
|
+
run: (p) => checkTrackingRollup(p)
|
|
1430
|
+
},
|
|
1431
|
+
{
|
|
1432
|
+
id: "export-target-known",
|
|
1433
|
+
category: "export",
|
|
1434
|
+
run: (p) => checkExportTargets(p)
|
|
1435
|
+
},
|
|
1436
|
+
{
|
|
1437
|
+
id: "build-preview-ready",
|
|
1438
|
+
category: "technical",
|
|
1439
|
+
run: () => skip("Run `lxd preview` to verify the package builds and previews")
|
|
1440
|
+
},
|
|
1441
|
+
{
|
|
1442
|
+
id: "not-framework-coupled",
|
|
1443
|
+
category: "portability",
|
|
1444
|
+
run: (p) => checkPortability(p)
|
|
1445
|
+
},
|
|
1446
|
+
{
|
|
1447
|
+
id: "maintainable",
|
|
1448
|
+
category: "maintainability",
|
|
1449
|
+
run: (p) => checkMaintainability(p)
|
|
1450
|
+
},
|
|
1451
|
+
{
|
|
1452
|
+
id: "no-custom-scorm-runtime",
|
|
1453
|
+
category: "technical",
|
|
1454
|
+
run: (p, ctx) => checkScormRuntime(p, ctx)
|
|
1455
|
+
}
|
|
1456
|
+
];
|
|
1457
|
+
var CHECK_IDS = CHECKS.map((c) => c.id);
|
|
1458
|
+
function runValidation(project, ctx) {
|
|
1459
|
+
const checks = CHECKS.map((c) => ({ id: c.id, category: c.category, ...c.run(project, ctx) }));
|
|
1460
|
+
const summary = {
|
|
1461
|
+
pass: checks.filter((c) => c.status === "pass").length,
|
|
1462
|
+
fail: checks.filter((c) => c.status === "fail").length,
|
|
1463
|
+
warn: checks.filter((c) => c.status === "warn").length,
|
|
1464
|
+
skip: checks.filter((c) => c.status === "skip").length,
|
|
1465
|
+
overall: checks.some((c) => c.status === "fail") ? "fail" : "pass"
|
|
1466
|
+
};
|
|
1467
|
+
logger.debug({ overall: summary.overall, fail: summary.fail, warn: summary.warn }, "Validation complete");
|
|
1468
|
+
return { checks, summary };
|
|
1469
|
+
}
|
|
1470
|
+
function ok(message) {
|
|
1471
|
+
return { status: "pass", message };
|
|
1472
|
+
}
|
|
1473
|
+
function fail(message, location) {
|
|
1474
|
+
return { status: "fail", message, location };
|
|
1475
|
+
}
|
|
1476
|
+
function warn(message, location, fix) {
|
|
1477
|
+
return { status: "warn", message, location, fix };
|
|
1478
|
+
}
|
|
1479
|
+
function skip(message) {
|
|
1480
|
+
return { status: "skip", message };
|
|
1481
|
+
}
|
|
1482
|
+
|
|
1483
|
+
// src/cli/commands/validate.ts
|
|
1484
|
+
function register9(program) {
|
|
1485
|
+
program.command("validate").description("Run all validation checks on the package").option("--json", "output the validation report as JSON").action(async (opts, cmd) => {
|
|
1486
|
+
const dir = cwdOf(cmd);
|
|
1487
|
+
const project = await loadProjectLenient(dir);
|
|
1488
|
+
const report = runValidation(project, { projectDir: dir });
|
|
1489
|
+
if (opts.json) {
|
|
1490
|
+
console.log(JSON.stringify(report, null, 2));
|
|
1491
|
+
} else {
|
|
1492
|
+
for (const c of report.checks) {
|
|
1493
|
+
const mark = { pass: "\u2713", fail: "\u2717", warn: "!", skip: "\xB7" }[c.status];
|
|
1494
|
+
console.log(`${mark} [${c.category}] ${c.id}: ${c.message}${c.location ? ` (${c.location})` : ""}`);
|
|
1495
|
+
}
|
|
1496
|
+
console.log(
|
|
1497
|
+
`
|
|
1498
|
+
${report.summary.overall.toUpperCase()} \u2014 ${report.summary.pass} pass, ${report.summary.fail} fail, ${report.summary.warn} warn, ${report.summary.skip} skip`
|
|
1499
|
+
);
|
|
1500
|
+
}
|
|
1501
|
+
if (report.summary.overall === "fail") process.exitCode = 1;
|
|
1502
|
+
});
|
|
1503
|
+
}
|
|
1504
|
+
|
|
1505
|
+
// src/core/authoring/tracking-builder.ts
|
|
1506
|
+
function buildTrackingConfig(experiences, opts = {}) {
|
|
1507
|
+
const experienceSignals = {};
|
|
1508
|
+
for (const e of experiences) {
|
|
1509
|
+
experienceSignals[e.id] = e.signals && e.signals.length ? e.signals : [...SIGNALS];
|
|
1510
|
+
}
|
|
1511
|
+
const completionRule = opts.completionRule ?? "all-required-complete";
|
|
1512
|
+
const scoreRule = opts.scoreRule ?? "weighted-average";
|
|
1513
|
+
const rollUp = {
|
|
1514
|
+
completion: completionRule === "selected-required-complete" ? { rule: completionRule, requiredExperienceIds: opts.requiredIds ?? [] } : { rule: "all-required-complete" },
|
|
1515
|
+
score: scoreRule === "final-assessment-only" ? { rule: scoreRule, finalAssessmentExperienceId: opts.finalAssessmentExperienceId } : { rule: "weighted-average" },
|
|
1516
|
+
reportedFields: opts.reportedFields ?? ["completion", "score"]
|
|
1517
|
+
};
|
|
1518
|
+
if (opts.threshold !== void 0) {
|
|
1519
|
+
rollUp.success = { scoreThreshold: opts.threshold, requireRequiredExperiencesComplete: true };
|
|
1520
|
+
}
|
|
1521
|
+
return { experienceSignals, rollUp };
|
|
1522
|
+
}
|
|
1523
|
+
|
|
1524
|
+
// src/cli/commands/tracking.ts
|
|
1525
|
+
function register10(program) {
|
|
1526
|
+
program.command("tracking").description("Define experience tracking signals and package roll-up rules").option("--completion-rule <rule>", "all-required-complete | selected-required-complete", "all-required-complete").option("--required <ids...>", "required experience ids (for selected-required-complete)").option("--score-rule <rule>", "weighted-average | final-assessment-only", "weighted-average").option("--final <id>", "final assessment experience id (for final-assessment-only)").option("--threshold <n>", "success score threshold").option("--report <fields...>", "reported fields", ["completion", "score"]).action(
|
|
1527
|
+
async (opts, cmd) => {
|
|
1528
|
+
const dir = cwdOf(cmd);
|
|
1529
|
+
const project = await loadProjectLenient(dir);
|
|
1530
|
+
const tracking = buildTrackingConfig(project.experiences, {
|
|
1531
|
+
completionRule: opts.completionRule,
|
|
1532
|
+
requiredIds: opts.required,
|
|
1533
|
+
scoreRule: opts.scoreRule,
|
|
1534
|
+
finalAssessmentExperienceId: opts.final,
|
|
1535
|
+
reportedFields: opts.report,
|
|
1536
|
+
threshold: opts.threshold !== void 0 ? Number(opts.threshold) : void 0
|
|
1537
|
+
});
|
|
1538
|
+
await writeTracking(dir, tracking);
|
|
1539
|
+
console.log("Tracking configuration updated.");
|
|
1540
|
+
}
|
|
1541
|
+
);
|
|
1542
|
+
}
|
|
1543
|
+
|
|
1544
|
+
// src/cli/commands/handoff.ts
|
|
1545
|
+
import { join as join9 } from "node:path";
|
|
1546
|
+
import { access as access4 } from "node:fs/promises";
|
|
1547
|
+
import { confirm, isCancel } from "@clack/prompts";
|
|
1548
|
+
|
|
1549
|
+
// src/agent/handoff/producer.ts
|
|
1550
|
+
import { mkdir as mkdir4, writeFile as writeFile4 } from "node:fs/promises";
|
|
1551
|
+
import { join as join6 } from "node:path";
|
|
1552
|
+
|
|
1553
|
+
// src/agent/handoff/scorm-skills.ts
|
|
1554
|
+
import { mkdir as mkdir2, writeFile as writeFile2 } from "node:fs/promises";
|
|
1555
|
+
import { join as join4 } from "node:path";
|
|
1556
|
+
var DEFAULT_REF = "github:studiolxd/scorm-skills";
|
|
1557
|
+
var DEFAULT_VERSION = "main";
|
|
1558
|
+
function renderGuide(ref, version) {
|
|
1559
|
+
return `# Using @studiolxd/scorm (scorm-skills snapshot)
|
|
1560
|
+
|
|
1561
|
+
Source: ${ref}
|
|
1562
|
+
Version: ${version}
|
|
1563
|
+
|
|
1564
|
+
This is a CLI-emitted, version-stamped snapshot of scorm-skills guidance \u2014 not vendored code.
|
|
1565
|
+
Implement all SCORM runtime behaviour through **@studiolxd/scorm**. Do NOT hand-roll a SCORM runtime.
|
|
1566
|
+
|
|
1567
|
+
## Steps
|
|
1568
|
+
|
|
1569
|
+
1. **Add the dependency** to the generated project: \`@studiolxd/scorm\` (use the version pinned in the
|
|
1570
|
+
handoff / package manifest).
|
|
1571
|
+
2. **Import and initialize** the runtime once at startup (do not touch \`window.API\` /
|
|
1572
|
+
\`window.API_1484_11\` directly):
|
|
1573
|
+
\`\`\`js
|
|
1574
|
+
import { Scorm } from '@studiolxd/scorm';
|
|
1575
|
+
const scorm = new Scorm();
|
|
1576
|
+
scorm.initialize();
|
|
1577
|
+
\`\`\`
|
|
1578
|
+
3. **Local preview**: use the library's mock mode (or the CLI \`preview --mock-lms\`) to verify
|
|
1579
|
+
tracking without a real LMS.
|
|
1580
|
+
4. **Report** learner state through the library: completion, success, score, suspend_data,
|
|
1581
|
+
lesson_location, and interactions.
|
|
1582
|
+
5. **Commit** progress periodically (the library's commit), and **terminate** safely on exit.
|
|
1583
|
+
6. **SCORM 1.2 vs 2004**: let \`@studiolxd/scorm\` handle the cmi differences (1.2
|
|
1584
|
+
\`cmi.core.*\`/\`lesson_status\` vs 2004 \`completion_status\`/\`success_status\`/\`score.scaled\`).
|
|
1585
|
+
Select the edition via configuration \u2014 do not branch on raw API objects yourself.
|
|
1586
|
+
|
|
1587
|
+
## Do NOT
|
|
1588
|
+
|
|
1589
|
+
- Do NOT implement or assign \`window.API\` / \`window.API_1484_11\`.
|
|
1590
|
+
- Do NOT write custom \`LMSInitialize\` / \`LMSCommit\` / \`LMSFinish\` / \`LMSGetValue\` / \`LMSSetValue\`
|
|
1591
|
+
handlers, or custom 2004 \`Initialize\` / \`Terminate\` / \`Commit\` / \`GetValue\` / \`SetValue\`.
|
|
1592
|
+
- Do NOT re-implement cmi mapping \u2014 \`@studiolxd/scorm\` owns it.
|
|
1593
|
+
`;
|
|
1594
|
+
}
|
|
1595
|
+
async function writeScormSkillsSnapshot(agentDir, opts = {}) {
|
|
1596
|
+
const dir = join4(agentDir, "scorm-skills");
|
|
1597
|
+
await mkdir2(dir, { recursive: true });
|
|
1598
|
+
const version = opts.version ?? DEFAULT_VERSION;
|
|
1599
|
+
const ref = opts.ref ?? DEFAULT_REF;
|
|
1600
|
+
const snapshot = {
|
|
1601
|
+
source: ref,
|
|
1602
|
+
version,
|
|
1603
|
+
vendored: false,
|
|
1604
|
+
note: "Emitted, version-stamped snapshot of external scorm-skills guidance. Not vendored/forked."
|
|
1605
|
+
};
|
|
1606
|
+
await writeFile2(join4(dir, "SNAPSHOT.json"), JSON.stringify(snapshot, null, 2), "utf8");
|
|
1607
|
+
await writeFile2(join4(dir, "GUIDE.md"), renderGuide(ref, version), "utf8");
|
|
1608
|
+
return { ref: `${ref}@${version}` };
|
|
1609
|
+
}
|
|
1610
|
+
|
|
1611
|
+
// src/core/project/assets.ts
|
|
1612
|
+
import { mkdir as mkdir3, readFile as readFile4, writeFile as writeFile3 } from "node:fs/promises";
|
|
1613
|
+
import { join as join5 } from "node:path";
|
|
1614
|
+
import { parse as parseYaml4, stringify as stringifyYaml2 } from "yaml";
|
|
1615
|
+
var MANIFEST_PATH = [".elearning", "manifest.lock"];
|
|
1616
|
+
var RECOMMENDED_ASSETS = {
|
|
1617
|
+
scormLibraryVersion: "^1.0.0",
|
|
1618
|
+
scormSkillsSnapshot: { version: "main", ref: "github:studiolxd/scorm-skills" }
|
|
1619
|
+
};
|
|
1620
|
+
function manifestFile(dir) {
|
|
1621
|
+
return join5(dir, ...MANIFEST_PATH);
|
|
1622
|
+
}
|
|
1623
|
+
async function readManifest(dir) {
|
|
1624
|
+
try {
|
|
1625
|
+
return parseYaml4(await readFile4(manifestFile(dir), "utf8"));
|
|
1626
|
+
} catch {
|
|
1627
|
+
return { cliVersion: "0.0.0" };
|
|
1628
|
+
}
|
|
1629
|
+
}
|
|
1630
|
+
async function writeManifest(dir, manifest) {
|
|
1631
|
+
await mkdir3(join5(dir, ".elearning"), { recursive: true });
|
|
1632
|
+
await writeFile3(manifestFile(dir), stringifyYaml2(manifest), "utf8");
|
|
1633
|
+
}
|
|
1634
|
+
async function updateAssets(dir, update) {
|
|
1635
|
+
const manifest = await readManifest(dir);
|
|
1636
|
+
if (update.scormLibraryVersion) manifest.scormLibraryVersion = update.scormLibraryVersion;
|
|
1637
|
+
if (update.scormSkillsVersion || update.scormSkillsRef) {
|
|
1638
|
+
manifest.scormSkillsSnapshot = {
|
|
1639
|
+
version: update.scormSkillsVersion ?? manifest.scormSkillsSnapshot?.version ?? RECOMMENDED_ASSETS.scormSkillsSnapshot.version,
|
|
1640
|
+
ref: update.scormSkillsRef ?? manifest.scormSkillsSnapshot?.ref ?? RECOMMENDED_ASSETS.scormSkillsSnapshot.ref
|
|
1641
|
+
};
|
|
1642
|
+
}
|
|
1643
|
+
await writeManifest(dir, manifest);
|
|
1644
|
+
logger.debug({ dir }, "Updated external asset versions (explicit)");
|
|
1645
|
+
return manifest;
|
|
1646
|
+
}
|
|
1647
|
+
|
|
1648
|
+
// src/agent/handoff/producer.ts
|
|
1649
|
+
function isScormTargeted2(targets) {
|
|
1650
|
+
return targets.some((t) => t.startsWith("scorm"));
|
|
1651
|
+
}
|
|
1652
|
+
function renderScormRequirements(input) {
|
|
1653
|
+
const version = input.scormLibraryVersion ?? RECOMMENDED_ASSETS.scormLibraryVersion;
|
|
1654
|
+
return [
|
|
1655
|
+
`## SCORM runtime requirements (MANDATORY)`,
|
|
1656
|
+
`- Do NOT implement a custom SCORM runtime: no \`window.API\`/\`window.API_1484_11\` discovery or`,
|
|
1657
|
+
` assignment, and no hand-written \`LMS*\` (1.2) or \`Initialize\`/\`Terminate\`/\`Commit\`/\`GetValue\`/`,
|
|
1658
|
+
` \`SetValue\` (2004) handlers.`,
|
|
1659
|
+
`- Use \`@studiolxd/scorm\` as the runtime integration layer for ALL SCORM interactions (progress,`,
|
|
1660
|
+
` score, completion, success, suspend_data, lesson_location, interactions, commit, terminate).`,
|
|
1661
|
+
`- Add \`@studiolxd/scorm@${version}\` as a project dependency, then import and use it for the runtime`,
|
|
1662
|
+
` (see \`.agent/scorm-skills/\` for concrete steps).`
|
|
1663
|
+
].join("\n");
|
|
1664
|
+
}
|
|
1665
|
+
var VERSION = "0.1.0";
|
|
1666
|
+
function createHandoffProducer() {
|
|
1667
|
+
return {
|
|
1668
|
+
version: VERSION,
|
|
1669
|
+
async produce(input) {
|
|
1670
|
+
const out = input.outputDir;
|
|
1671
|
+
await mkdir4(join6(out, "prompts"), { recursive: true });
|
|
1672
|
+
const spec = renderSpec(input);
|
|
1673
|
+
const specPath = join6(out, "spec.md");
|
|
1674
|
+
await writeFile4(specPath, spec, "utf8");
|
|
1675
|
+
const prompts = [];
|
|
1676
|
+
const shellPath = join6(out, "prompts", "package-shell.md");
|
|
1677
|
+
await writeFile4(shellPath, renderShellPrompt(input), "utf8");
|
|
1678
|
+
prompts.push({ id: "package-shell", path: shellPath });
|
|
1679
|
+
for (const ref of input.package.experienceRefs) {
|
|
1680
|
+
const p = join6(out, "prompts", `${ref}.md`);
|
|
1681
|
+
await writeFile4(p, renderExperiencePrompt(ref, input), "utf8");
|
|
1682
|
+
prompts.push({ id: ref, path: p });
|
|
1683
|
+
}
|
|
1684
|
+
const instructions = renderInstructions(input);
|
|
1685
|
+
await writeFile4(join6(out, "instructions.md"), instructions, "utf8");
|
|
1686
|
+
let scormSkillsSnapshotRef = input.scormSkills?.snapshotRef;
|
|
1687
|
+
if (input.targets.some((t) => t.startsWith("scorm"))) {
|
|
1688
|
+
const snap = await writeScormSkillsSnapshot(out, {
|
|
1689
|
+
version: input.scormSkills?.version,
|
|
1690
|
+
ref: input.scormSkills?.snapshotRef
|
|
1691
|
+
});
|
|
1692
|
+
scormSkillsSnapshotRef = snap.ref;
|
|
1693
|
+
}
|
|
1694
|
+
logger.debug({ outputDir: out, prompts: prompts.length }, "Agent handoff produced");
|
|
1695
|
+
return {
|
|
1696
|
+
spec,
|
|
1697
|
+
prompts,
|
|
1698
|
+
instructions,
|
|
1699
|
+
scormSkillsSnapshotRef,
|
|
1700
|
+
producedBy: VERSION
|
|
1701
|
+
};
|
|
1702
|
+
}
|
|
1703
|
+
};
|
|
1704
|
+
}
|
|
1705
|
+
function renderSpec(input) {
|
|
1706
|
+
const pkg = input.package;
|
|
1707
|
+
const lines = [`# Package spec: ${pkg.title ?? pkg.id}`, ``, `Learning goal: ${pkg.learningGoal}`];
|
|
1708
|
+
if (pkg.audience?.description) lines.push(`Audience: ${pkg.audience.description}`);
|
|
1709
|
+
if (pkg.context) lines.push(`Context: ${pkg.context}`);
|
|
1710
|
+
if (pkg.tone) lines.push(`Package tone: ${pkg.tone}`);
|
|
1711
|
+
lines.push(`Targets: ${input.targets.join(", ")}`);
|
|
1712
|
+
lines.push(`Experiences: ${pkg.experienceRefs.join(", ")}`);
|
|
1713
|
+
lines.push(``);
|
|
1714
|
+
lines.push(renderDesign(input.design));
|
|
1715
|
+
if (input.experiences?.length) {
|
|
1716
|
+
lines.push(``);
|
|
1717
|
+
lines.push(renderExperiencesSection(input.experiences));
|
|
1718
|
+
}
|
|
1719
|
+
if (isScormTargeted2(input.targets)) {
|
|
1720
|
+
lines.push(``);
|
|
1721
|
+
lines.push(renderScormRequirements(input));
|
|
1722
|
+
}
|
|
1723
|
+
lines.push(``);
|
|
1724
|
+
lines.push(`## Framework implementation contract`);
|
|
1725
|
+
lines.push(input.frameworkContract);
|
|
1726
|
+
return lines.join("\n");
|
|
1727
|
+
}
|
|
1728
|
+
function renderExperiencesSection(experiences) {
|
|
1729
|
+
const blocks = experiences.map((he) => {
|
|
1730
|
+
const e = he.experience;
|
|
1731
|
+
const l = [`### Experience: ${e.id} \u2014 ${e.title}`];
|
|
1732
|
+
if (e.learningPurpose) l.push(`Learning purpose: ${e.learningPurpose}`);
|
|
1733
|
+
if (e.scenario?.summary) l.push(`Scenario: ${e.scenario.summary}`);
|
|
1734
|
+
if (e.scenario?.context) l.push(`Scenario context: ${e.scenario.context}`);
|
|
1735
|
+
if (he.mechanic) {
|
|
1736
|
+
l.push(`Mechanic: ${he.mechanic.name} (${he.mechanic.id})`);
|
|
1737
|
+
if (he.mechanic.description) l.push(`Mechanic description: ${he.mechanic.description}`);
|
|
1738
|
+
if (he.mechanic.requiredInputs?.length)
|
|
1739
|
+
l.push(`Required inputs: ${he.mechanic.requiredInputs.map((i) => i.key).join(", ")}`);
|
|
1740
|
+
if (he.mechanic.agentGuidance) l.push(`Mechanic guidance: ${he.mechanic.agentGuidance}`);
|
|
1741
|
+
} else {
|
|
1742
|
+
l.push(`Mechanic: ${e.mechanicRef} (not resolved)`);
|
|
1743
|
+
}
|
|
1744
|
+
if (he.pluginEnrichment) l.push(`Mechanic plugin guidance: ${he.pluginEnrichment}`);
|
|
1745
|
+
if (e.feedback?.model) l.push(`Feedback model: ${e.feedback.model}`);
|
|
1746
|
+
const signals = he.trackingSignals ?? e.signals;
|
|
1747
|
+
if (signals?.length) l.push(`Tracking expectations: ${signals.join(", ")}`);
|
|
1748
|
+
return l.join("\n");
|
|
1749
|
+
});
|
|
1750
|
+
return [`## Experiences`, ``, blocks.join("\n\n")].join("\n");
|
|
1751
|
+
}
|
|
1752
|
+
function renderDesign(design) {
|
|
1753
|
+
const d = design;
|
|
1754
|
+
const lines = [`## Design direction (level: ${d.level ?? "unset"})`];
|
|
1755
|
+
if (d.brief) lines.push(`Brief: ${d.brief}`);
|
|
1756
|
+
if (d.tone) lines.push(`Tone: ${d.tone}`);
|
|
1757
|
+
if (d.output?.length) lines.push(`Output requirements: ${d.output.join(", ")}`);
|
|
1758
|
+
if (d.accessibility?.targets?.length) lines.push(`Accessibility targets: ${d.accessibility.targets.join(", ")}`);
|
|
1759
|
+
if (d.tokens) lines.push(`Design tokens: ${Object.keys(d.tokens).join(", ")} (see design/design-direction.yml)`);
|
|
1760
|
+
if (d.patterns?.ui?.length) lines.push(`UI patterns: ${d.patterns.ui.join(", ")}`);
|
|
1761
|
+
if (d.patterns?.interaction?.length) lines.push(`Interaction patterns: ${d.patterns.interaction.join(", ")}`);
|
|
1762
|
+
if (d.designSystemRefs?.length)
|
|
1763
|
+
lines.push(`Design system references: ${d.designSystemRefs.map((r) => r.ref).join(", ")}`);
|
|
1764
|
+
if (d.constraints?.do?.length) lines.push(`Do: ${d.constraints.do.join("; ")}`);
|
|
1765
|
+
if (d.constraints?.dont?.length) lines.push(`Don't: ${d.constraints.dont.join("; ")}`);
|
|
1766
|
+
if (d.constraints?.implementation?.length)
|
|
1767
|
+
lines.push(`Implementation constraints (MUST): ${d.constraints.implementation.join("; ")}`);
|
|
1768
|
+
return lines.join("\n");
|
|
1769
|
+
}
|
|
1770
|
+
function renderShellPrompt(input) {
|
|
1771
|
+
return `Implement the package shell for "${input.package.id}" with ${input.package.structure?.navigation ?? "linear"} navigation across experiences: ${input.package.experienceRefs.join(", ")}.`;
|
|
1772
|
+
}
|
|
1773
|
+
function renderExperiencePrompt(ref, input) {
|
|
1774
|
+
const he = input.experiences?.find((x) => x.experience.id === ref);
|
|
1775
|
+
if (!he) {
|
|
1776
|
+
return `Implement experience "${ref}" in package "${input.package.id}" following the framework implementation contract and the package design direction.`;
|
|
1777
|
+
}
|
|
1778
|
+
const e = he.experience;
|
|
1779
|
+
const l = [`# Prompt: implement experience "${e.id}" \u2014 ${e.title}`, ``, `Package: ${input.package.id}`];
|
|
1780
|
+
if (e.learningPurpose) l.push(`Learning purpose: ${e.learningPurpose}`);
|
|
1781
|
+
if (e.scenario?.summary) l.push(`Scenario: ${e.scenario.summary}`);
|
|
1782
|
+
if (he.mechanic) {
|
|
1783
|
+
l.push(`Mechanic: ${he.mechanic.name} (${he.mechanic.id})`);
|
|
1784
|
+
if (he.mechanic.agentGuidance) l.push(`Mechanic guidance: ${he.mechanic.agentGuidance}`);
|
|
1785
|
+
} else {
|
|
1786
|
+
l.push(`Mechanic: ${e.mechanicRef}`);
|
|
1787
|
+
}
|
|
1788
|
+
if (he.pluginEnrichment) l.push(`Mechanic plugin guidance: ${he.pluginEnrichment}`);
|
|
1789
|
+
if (e.feedback?.model) l.push(`Feedback expectations: ${e.feedback.model} feedback for meaningful learner actions.`);
|
|
1790
|
+
const signals = he.trackingSignals ?? e.signals;
|
|
1791
|
+
if (signals?.length) l.push(`Tracking expectations: emit ${signals.join(", ")}.`);
|
|
1792
|
+
l.push(`Export targets: ${input.targets.join(", ")}.`);
|
|
1793
|
+
if (isScormTargeted2(input.targets)) {
|
|
1794
|
+
l.push(`SCORM: integrate \`@studiolxd/scorm\` for the runtime \u2014 do NOT hand-roll a SCORM runtime (see spec.md \u2192 "SCORM runtime requirements").`);
|
|
1795
|
+
}
|
|
1796
|
+
l.push(`Design constraints: follow the package design direction in spec.md (see "Design direction").`);
|
|
1797
|
+
l.push(`Implement under generated/ per the framework implementation contract.`);
|
|
1798
|
+
return l.join("\n");
|
|
1799
|
+
}
|
|
1800
|
+
function renderInstructions(input) {
|
|
1801
|
+
const lines = [
|
|
1802
|
+
`# Agent instructions`,
|
|
1803
|
+
``,
|
|
1804
|
+
`These handoff materials (spec.md and the per-experience prompts under prompts/) are the SOURCE OF TRUTH`,
|
|
1805
|
+
`for the learning design. Implement to them \u2014 do not infer the learning design from code alone.`,
|
|
1806
|
+
``,
|
|
1807
|
+
`Use your own AI coding agent to implement this package under generated/. Keep the implementation`,
|
|
1808
|
+
`framework-agnostic per the implementation contract. Export targets: ${input.targets.join(", ")}.`
|
|
1809
|
+
];
|
|
1810
|
+
if (isScormTargeted2(input.targets)) {
|
|
1811
|
+
lines.push(``);
|
|
1812
|
+
lines.push(renderScormRequirements(input));
|
|
1813
|
+
}
|
|
1814
|
+
return lines.join("\n");
|
|
1815
|
+
}
|
|
1816
|
+
|
|
1817
|
+
// src/adapters/framework/webcomponents/index.ts
|
|
1818
|
+
import { mkdir as mkdir5, writeFile as writeFile5, cp, access as access3 } from "node:fs/promises";
|
|
1819
|
+
import { join as join8 } from "node:path";
|
|
1820
|
+
|
|
1821
|
+
// src/preview/server.ts
|
|
1822
|
+
import { createServer } from "node:http";
|
|
1823
|
+
import { readFile as readFile5, stat } from "node:fs/promises";
|
|
1824
|
+
import { join as join7, normalize, extname as extname2 } from "node:path";
|
|
1825
|
+
|
|
1826
|
+
// src/preview/mock-lms/index.ts
|
|
1827
|
+
function mockLmsBrowserScript() {
|
|
1828
|
+
return `(() => {
|
|
1829
|
+
const data = {};
|
|
1830
|
+
const get = (k) => (k in data ? data[k] : '');
|
|
1831
|
+
const set = (k, v) => { data[k] = String(v); render(); return 'true'; };
|
|
1832
|
+
const ok = () => 'true';
|
|
1833
|
+
// SCORM 1.2 API
|
|
1834
|
+
window.API = {
|
|
1835
|
+
LMSInitialize: ok, LMSFinish: ok, LMSCommit: ok,
|
|
1836
|
+
LMSGetValue: get, LMSSetValue: set,
|
|
1837
|
+
LMSGetLastError: () => '0', LMSGetErrorString: () => '', LMSGetDiagnostic: () => '',
|
|
1838
|
+
};
|
|
1839
|
+
// SCORM 2004 API
|
|
1840
|
+
window.API_1484_11 = {
|
|
1841
|
+
Initialize: ok, Terminate: ok, Commit: ok,
|
|
1842
|
+
GetValue: get, SetValue: set,
|
|
1843
|
+
GetLastError: () => '0', GetErrorString: () => '', GetDiagnostic: () => '',
|
|
1844
|
+
};
|
|
1845
|
+
function render() {
|
|
1846
|
+
let el = document.getElementById('__mock_lms__');
|
|
1847
|
+
if (!el) {
|
|
1848
|
+
el = document.createElement('pre');
|
|
1849
|
+
el.id = '__mock_lms__';
|
|
1850
|
+
el.style.cssText = 'position:fixed;bottom:0;right:0;max-width:40ch;margin:0;padding:8px;background:#111;color:#0f0;font:11px monospace;z-index:99999;opacity:.9';
|
|
1851
|
+
document.body.appendChild(el);
|
|
1852
|
+
}
|
|
1853
|
+
el.textContent = 'MOCK LMS\\n' + JSON.stringify(data, null, 2);
|
|
1854
|
+
}
|
|
1855
|
+
if (document.readyState !== 'loading') render();
|
|
1856
|
+
else document.addEventListener('DOMContentLoaded', render);
|
|
1857
|
+
})();`;
|
|
1858
|
+
}
|
|
1859
|
+
|
|
1860
|
+
// src/preview/server.ts
|
|
1861
|
+
var MIME = {
|
|
1862
|
+
".html": "text/html; charset=utf-8",
|
|
1863
|
+
".js": "text/javascript; charset=utf-8",
|
|
1864
|
+
".css": "text/css; charset=utf-8",
|
|
1865
|
+
".json": "application/json; charset=utf-8",
|
|
1866
|
+
".svg": "image/svg+xml",
|
|
1867
|
+
".png": "image/png",
|
|
1868
|
+
".jpg": "image/jpeg"
|
|
1869
|
+
};
|
|
1870
|
+
async function startPreviewServer(opts) {
|
|
1871
|
+
const root = normalize(opts.dir);
|
|
1872
|
+
const server = createServer((req, res) => {
|
|
1873
|
+
void (async () => {
|
|
1874
|
+
try {
|
|
1875
|
+
const rel = decodeURIComponent((req.url ?? "/").split("?")[0] ?? "/");
|
|
1876
|
+
if (opts.mockLms && rel === "/mock-lms.js") {
|
|
1877
|
+
res.writeHead(200, { "content-type": MIME[".js"] }).end(mockLmsBrowserScript());
|
|
1878
|
+
return;
|
|
1879
|
+
}
|
|
1880
|
+
const safe = normalize(join7(root, rel === "/" ? "index.html" : `.${rel}`));
|
|
1881
|
+
if (!safe.startsWith(root)) {
|
|
1882
|
+
res.writeHead(403).end("Forbidden");
|
|
1883
|
+
return;
|
|
1884
|
+
}
|
|
1885
|
+
const info = await stat(safe).catch(() => null);
|
|
1886
|
+
const file = info?.isDirectory() ? join7(safe, "index.html") : safe;
|
|
1887
|
+
const isHtml = extname2(file) === ".html";
|
|
1888
|
+
if (opts.mockLms && isHtml) {
|
|
1889
|
+
const html = await readFile5(file, "utf8");
|
|
1890
|
+
const injected = html.includes("</head>") ? html.replace("</head>", '<script src="/mock-lms.js"></script></head>') : `<script src="/mock-lms.js"></script>
|
|
1891
|
+
${html}`;
|
|
1892
|
+
res.writeHead(200, { "content-type": MIME[".html"] }).end(injected);
|
|
1893
|
+
return;
|
|
1894
|
+
}
|
|
1895
|
+
const body = await readFile5(file);
|
|
1896
|
+
res.writeHead(200, { "content-type": MIME[extname2(file)] ?? "application/octet-stream" });
|
|
1897
|
+
res.end(body);
|
|
1898
|
+
} catch {
|
|
1899
|
+
res.writeHead(404).end("Not found");
|
|
1900
|
+
}
|
|
1901
|
+
})();
|
|
1902
|
+
});
|
|
1903
|
+
await new Promise((resolve6) => server.listen(opts.port ?? 0, resolve6));
|
|
1904
|
+
const addr = server.address();
|
|
1905
|
+
const port = typeof addr === "object" && addr ? addr.port : opts.port ?? 0;
|
|
1906
|
+
return {
|
|
1907
|
+
url: `http://localhost:${port}`,
|
|
1908
|
+
close: () => new Promise(
|
|
1909
|
+
(resolve6, reject) => server.close((err) => err ? reject(err) : resolve6())
|
|
1910
|
+
)
|
|
1911
|
+
};
|
|
1912
|
+
}
|
|
1913
|
+
|
|
1914
|
+
// src/adapters/framework/webcomponents/index.ts
|
|
1915
|
+
async function exists(path) {
|
|
1916
|
+
try {
|
|
1917
|
+
await access3(path);
|
|
1918
|
+
return true;
|
|
1919
|
+
} catch {
|
|
1920
|
+
return false;
|
|
1921
|
+
}
|
|
1922
|
+
}
|
|
1923
|
+
var IMPLEMENTATION_CONTRACT = `Implement each experience as a standards-based Web Component
|
|
1924
|
+
(Custom Element + Shadow DOM). The package shell renders experiences per the package structure and
|
|
1925
|
+
emits the canonical tracking signals (progress, score, completion, interaction, feedback, suspend).
|
|
1926
|
+
Do not depend on any frontend framework. Place your implementation under generated/.`;
|
|
1927
|
+
var webComponentsAdapter = {
|
|
1928
|
+
id: "webcomponents",
|
|
1929
|
+
displayName: "Vanilla JS + Web Components",
|
|
1930
|
+
version: "0.1.0",
|
|
1931
|
+
optionsSchema: { type: "object", additionalProperties: true },
|
|
1932
|
+
async scaffold(input) {
|
|
1933
|
+
await mkdir5(input.outputDir, { recursive: true });
|
|
1934
|
+
const files = [];
|
|
1935
|
+
const configPath = join8(input.outputDir, "elearning.scaffold.json");
|
|
1936
|
+
await writeFile5(
|
|
1937
|
+
configPath,
|
|
1938
|
+
JSON.stringify({ adapter: this.id, packageId: input.package.id }, null, 2),
|
|
1939
|
+
"utf8"
|
|
1940
|
+
);
|
|
1941
|
+
files.push(configPath);
|
|
1942
|
+
const contractPath = join8(input.outputDir, "AGENT_IMPLEMENTATION.md");
|
|
1943
|
+
await writeFile5(contractPath, IMPLEMENTATION_CONTRACT, "utf8");
|
|
1944
|
+
files.push(contractPath);
|
|
1945
|
+
const generated = join8(input.outputDir, "generated");
|
|
1946
|
+
await mkdir5(generated, { recursive: true });
|
|
1947
|
+
const seed = join8(generated, "index.html");
|
|
1948
|
+
const preserved = [];
|
|
1949
|
+
const overwritten = [];
|
|
1950
|
+
const seedExists = await exists(seed);
|
|
1951
|
+
if (seedExists && !input.force) {
|
|
1952
|
+
preserved.push(seed);
|
|
1953
|
+
logger.debug({ file: seed }, "Preserved existing generated/ file (use force to overwrite)");
|
|
1954
|
+
} else {
|
|
1955
|
+
await writeFile5(seed, "<!doctype html><title>elearning package</title>", "utf8");
|
|
1956
|
+
files.push(seed);
|
|
1957
|
+
if (seedExists) {
|
|
1958
|
+
overwritten.push(seed);
|
|
1959
|
+
logger.debug({ file: seed }, "Overwrote generated/ file (force)");
|
|
1960
|
+
}
|
|
1961
|
+
}
|
|
1962
|
+
logger.debug(
|
|
1963
|
+
{ outputDir: input.outputDir, written: files.length, preserved: preserved.length },
|
|
1964
|
+
"webcomponents scaffold complete"
|
|
1965
|
+
);
|
|
1966
|
+
return { files, implementationContract: IMPLEMENTATION_CONTRACT, preserved, overwritten };
|
|
1967
|
+
},
|
|
1968
|
+
async build(input) {
|
|
1969
|
+
await mkdir5(input.outputDir, { recursive: true });
|
|
1970
|
+
const source = input.generatedDir;
|
|
1971
|
+
if (source && await exists(source)) {
|
|
1972
|
+
await cp(source, input.outputDir, { recursive: true });
|
|
1973
|
+
}
|
|
1974
|
+
logger.debug({ outputDir: input.outputDir, from: source }, "webcomponents build complete");
|
|
1975
|
+
return { outputDir: input.outputDir };
|
|
1976
|
+
},
|
|
1977
|
+
async preview(input) {
|
|
1978
|
+
const dir = input?.builtDir ?? process.cwd();
|
|
1979
|
+
return startPreviewServer({ dir, port: input?.port ?? 0 });
|
|
1980
|
+
}
|
|
1981
|
+
};
|
|
1982
|
+
|
|
1983
|
+
// src/core/authoring/handoff-service.ts
|
|
1984
|
+
async function buildHandoffExperiences(project) {
|
|
1985
|
+
const registry = await createMechanicRegistry();
|
|
1986
|
+
const trackingSignalsById = project.tracking.experienceSignals ?? {};
|
|
1987
|
+
return project.experiences.map((e) => {
|
|
1988
|
+
const view = {
|
|
1989
|
+
experience: e,
|
|
1990
|
+
trackingSignals: trackingSignalsById[e.id] ?? e.signals
|
|
1991
|
+
};
|
|
1992
|
+
if (registry.has(e.mechanicRef)) {
|
|
1993
|
+
const resolved = registry.getResolved(e.mechanicRef);
|
|
1994
|
+
view.mechanic = {
|
|
1995
|
+
id: resolved.manifest.id,
|
|
1996
|
+
name: resolved.manifest.name,
|
|
1997
|
+
description: resolved.manifest.description,
|
|
1998
|
+
requiredInputs: resolved.manifest.requiredInputs,
|
|
1999
|
+
agentGuidance: resolved.manifest.agentGuidance
|
|
2000
|
+
};
|
|
2001
|
+
view.pluginEnrichment = resolved.plugin?.enrichHandoff?.(e);
|
|
2002
|
+
}
|
|
2003
|
+
return view;
|
|
2004
|
+
});
|
|
2005
|
+
}
|
|
2006
|
+
async function produceHandoff(project, opts) {
|
|
2007
|
+
const experiences = await buildHandoffExperiences(project);
|
|
2008
|
+
return createHandoffProducer().produce({
|
|
2009
|
+
package: project.package,
|
|
2010
|
+
design: project.design,
|
|
2011
|
+
mechanics: [],
|
|
2012
|
+
experiences,
|
|
2013
|
+
tracking: project.tracking,
|
|
2014
|
+
scormLibraryVersion: project.manifest?.scormLibraryVersion,
|
|
2015
|
+
frameworkContract: webComponentsAdapter.displayName,
|
|
2016
|
+
targets: opts.targets,
|
|
2017
|
+
outputDir: opts.outputDir
|
|
2018
|
+
});
|
|
2019
|
+
}
|
|
2020
|
+
|
|
2021
|
+
// src/core/validation/handoff-agnostic.ts
|
|
2022
|
+
var PROVIDER_TOKENS = [
|
|
2023
|
+
"claude",
|
|
2024
|
+
"anthropic",
|
|
2025
|
+
"cursor",
|
|
2026
|
+
"codex",
|
|
2027
|
+
"openai",
|
|
2028
|
+
"gpt-",
|
|
2029
|
+
"gemini",
|
|
2030
|
+
"copilot"
|
|
2031
|
+
];
|
|
2032
|
+
var CREDENTIAL_PATTERNS = [
|
|
2033
|
+
// Word boundary avoids false positives like "ri[sk-detection]" (mechanic ids).
|
|
2034
|
+
/\bsk-[a-z0-9]{8,}/i,
|
|
2035
|
+
/api[_-]?key\s*[:=]/i,
|
|
2036
|
+
/bearer\s+[a-z0-9._-]{8,}/i,
|
|
2037
|
+
/authorization\s*:/i
|
|
2038
|
+
];
|
|
2039
|
+
function checkHandoffAgnostic(text) {
|
|
2040
|
+
const lower = text.toLowerCase();
|
|
2041
|
+
const issues = [];
|
|
2042
|
+
for (const token of PROVIDER_TOKENS) {
|
|
2043
|
+
if (lower.includes(token)) issues.push({ kind: "provider", token });
|
|
2044
|
+
}
|
|
2045
|
+
for (const re of CREDENTIAL_PATTERNS) {
|
|
2046
|
+
const m = text.match(re);
|
|
2047
|
+
if (m) issues.push({ kind: "credential", token: m[0] });
|
|
2048
|
+
}
|
|
2049
|
+
return { ok: issues.length === 0, issues };
|
|
2050
|
+
}
|
|
2051
|
+
|
|
2052
|
+
// src/cli/commands/handoff.ts
|
|
2053
|
+
async function exists2(path) {
|
|
2054
|
+
try {
|
|
2055
|
+
await access4(path);
|
|
2056
|
+
return true;
|
|
2057
|
+
} catch {
|
|
2058
|
+
return false;
|
|
2059
|
+
}
|
|
2060
|
+
}
|
|
2061
|
+
function register11(program) {
|
|
2062
|
+
program.command("handoff").description("Generate agent-ready specs/prompts and scaffold the project for an agent").option("-t, --target <ids...>", "export targets", ["web"]).option("-f, --force", "overwrite existing files under generated/ when scaffolding").action(async (opts, cmd) => {
|
|
2063
|
+
const dir = cwdOf(cmd);
|
|
2064
|
+
const project = await loadProjectLenient(dir);
|
|
2065
|
+
const experiences = await buildHandoffExperiences(project);
|
|
2066
|
+
const mechRegistry = await createMechanicRegistry();
|
|
2067
|
+
for (const e of project.experiences) {
|
|
2068
|
+
if (!mechRegistry.has(e.mechanicRef)) continue;
|
|
2069
|
+
for (const issue of mechRegistry.getResolved(e.mechanicRef).plugin?.validate?.(e) ?? []) {
|
|
2070
|
+
if (!issue.ok) console.warn(` ${e.id} (${e.mechanicRef}): ${issue.message}`);
|
|
2071
|
+
}
|
|
2072
|
+
}
|
|
2073
|
+
const producer = createHandoffProducer();
|
|
2074
|
+
const materials = await producer.produce({
|
|
2075
|
+
package: project.package,
|
|
2076
|
+
design: project.design,
|
|
2077
|
+
mechanics: [],
|
|
2078
|
+
experiences,
|
|
2079
|
+
tracking: project.tracking,
|
|
2080
|
+
scormLibraryVersion: project.manifest?.scormLibraryVersion,
|
|
2081
|
+
frameworkContract: webComponentsAdapter.displayName,
|
|
2082
|
+
targets: opts.target,
|
|
2083
|
+
outputDir: join9(dir, ".agent")
|
|
2084
|
+
});
|
|
2085
|
+
console.log(`Handoff materials written to .agent/ (${materials.prompts.length} prompts).`);
|
|
2086
|
+
const agnostic = checkHandoffAgnostic(`${materials.spec}
|
|
2087
|
+
${materials.instructions}`);
|
|
2088
|
+
if (!agnostic.ok) {
|
|
2089
|
+
for (const issue of agnostic.issues) {
|
|
2090
|
+
console.warn(` warning: handoff contains ${issue.kind} reference "${issue.token}" \u2014 keep it agent-agnostic`);
|
|
2091
|
+
}
|
|
2092
|
+
}
|
|
2093
|
+
let force = Boolean(opts.force);
|
|
2094
|
+
const seed = join9(dir, "generated", "index.html");
|
|
2095
|
+
if (!force && await exists2(seed)) {
|
|
2096
|
+
if (process.stdin.isTTY) {
|
|
2097
|
+
const answer = await confirm({
|
|
2098
|
+
message: "generated/ already contains files. Overwrite matching scaffold files?",
|
|
2099
|
+
initialValue: false
|
|
2100
|
+
});
|
|
2101
|
+
force = !isCancel(answer) && answer === true;
|
|
2102
|
+
} else {
|
|
2103
|
+
console.warn(
|
|
2104
|
+
"Warning: generated/ already contains files \u2014 preserving them. Re-run with --force to overwrite."
|
|
2105
|
+
);
|
|
2106
|
+
}
|
|
2107
|
+
}
|
|
2108
|
+
const scaffold = await webComponentsAdapter.scaffold({
|
|
2109
|
+
package: project.package,
|
|
2110
|
+
design: project.design,
|
|
2111
|
+
mechanics: [],
|
|
2112
|
+
outputDir: dir,
|
|
2113
|
+
options: {},
|
|
2114
|
+
force
|
|
2115
|
+
});
|
|
2116
|
+
if (scaffold.preserved?.length) {
|
|
2117
|
+
console.warn(
|
|
2118
|
+
`Preserved ${scaffold.preserved.length} existing file(s) under generated/ (use --force to overwrite).`
|
|
2119
|
+
);
|
|
2120
|
+
}
|
|
2121
|
+
if (scaffold.overwritten?.length) {
|
|
2122
|
+
console.warn(`Overwrote ${scaffold.overwritten.length} file(s) under generated/.`);
|
|
2123
|
+
}
|
|
2124
|
+
console.log("Project scaffolded for the agent (generated/).");
|
|
2125
|
+
});
|
|
2126
|
+
}
|
|
2127
|
+
|
|
2128
|
+
// src/cli/commands/preview.ts
|
|
2129
|
+
import { join as join10 } from "node:path";
|
|
2130
|
+
function register12(program) {
|
|
2131
|
+
program.command("preview").description("Build the package and serve it locally").option("--mock-lms", "serve in mock LMS/SCORM mode (US2)").option("-p, --port <n>", "port", "0").action(async (opts, cmd) => {
|
|
2132
|
+
const dir = cwdOf(cmd);
|
|
2133
|
+
const project = await loadProjectLenient(dir);
|
|
2134
|
+
const built = await webComponentsAdapter.build({
|
|
2135
|
+
package: project.package,
|
|
2136
|
+
outputDir: join10(dir, "build"),
|
|
2137
|
+
generatedDir: join10(dir, "generated")
|
|
2138
|
+
});
|
|
2139
|
+
const handle = await startPreviewServer({
|
|
2140
|
+
dir: built.outputDir,
|
|
2141
|
+
port: Number(opts.port),
|
|
2142
|
+
mockLms: Boolean(opts.mockLms)
|
|
2143
|
+
});
|
|
2144
|
+
console.log(
|
|
2145
|
+
`Preview serving at ${handle.url}${opts.mockLms ? " (mock LMS enabled)" : ""} (press Ctrl+C to stop)`
|
|
2146
|
+
);
|
|
2147
|
+
});
|
|
2148
|
+
}
|
|
2149
|
+
|
|
2150
|
+
// src/cli/commands/export.ts
|
|
2151
|
+
import { join as join13 } from "node:path";
|
|
2152
|
+
|
|
2153
|
+
// src/core/authoring/export-builder.ts
|
|
2154
|
+
function buildExportConfig(targetIds) {
|
|
2155
|
+
return { targets: targetIds.map((id) => ({ adapterId: id })) };
|
|
2156
|
+
}
|
|
2157
|
+
|
|
2158
|
+
// src/adapters/export/registry.ts
|
|
2159
|
+
function createExportRegistry() {
|
|
2160
|
+
const adapters = /* @__PURE__ */ new Map();
|
|
2161
|
+
return {
|
|
2162
|
+
register(adapter) {
|
|
2163
|
+
adapters.set(adapter.id, adapter);
|
|
2164
|
+
},
|
|
2165
|
+
has(id) {
|
|
2166
|
+
return adapters.has(id);
|
|
2167
|
+
},
|
|
2168
|
+
get(id) {
|
|
2169
|
+
const adapter = adapters.get(id);
|
|
2170
|
+
if (!adapter) {
|
|
2171
|
+
throw new Error(
|
|
2172
|
+
`No export adapter for target "${id}". Available: ${[...adapters.keys()].join(", ") || "(none)"}`
|
|
2173
|
+
);
|
|
2174
|
+
}
|
|
2175
|
+
return adapter;
|
|
2176
|
+
},
|
|
2177
|
+
list() {
|
|
2178
|
+
return [...adapters.values()];
|
|
2179
|
+
}
|
|
2180
|
+
};
|
|
2181
|
+
}
|
|
2182
|
+
|
|
2183
|
+
// src/adapters/export/web/index.ts
|
|
2184
|
+
import { mkdir as mkdir6, cp as cp2, access as access5 } from "node:fs/promises";
|
|
2185
|
+
var webExportAdapter = {
|
|
2186
|
+
id: "web",
|
|
2187
|
+
displayName: "Web / static package",
|
|
2188
|
+
version: "0.1.0",
|
|
2189
|
+
optionsSchema: { type: "object", additionalProperties: true },
|
|
2190
|
+
async validate(input) {
|
|
2191
|
+
const built = await access5(input.builtDir).then(
|
|
2192
|
+
() => true,
|
|
2193
|
+
() => false
|
|
2194
|
+
);
|
|
2195
|
+
return [
|
|
2196
|
+
{
|
|
2197
|
+
id: "web-built-dir-present",
|
|
2198
|
+
category: "export",
|
|
2199
|
+
status: built ? "pass" : "fail",
|
|
2200
|
+
message: built ? "Built directory is present" : `Built directory not found: ${input.builtDir}`,
|
|
2201
|
+
location: input.builtDir
|
|
2202
|
+
}
|
|
2203
|
+
];
|
|
2204
|
+
},
|
|
2205
|
+
async export(input) {
|
|
2206
|
+
await mkdir6(input.outputDir, { recursive: true });
|
|
2207
|
+
await cp2(input.builtDir, input.outputDir, { recursive: true });
|
|
2208
|
+
logger.debug({ outputDir: input.outputDir }, "web export complete");
|
|
2209
|
+
return { artifactPath: input.outputDir, target: "web", warnings: [] };
|
|
2210
|
+
}
|
|
2211
|
+
};
|
|
2212
|
+
|
|
2213
|
+
// src/adapters/export/scorm12/index.ts
|
|
2214
|
+
import { mkdir as mkdir7, cp as cp3, writeFile as writeFile6, access as access6 } from "node:fs/promises";
|
|
2215
|
+
import { join as join11 } from "node:path";
|
|
2216
|
+
|
|
2217
|
+
// src/adapters/export/scorm-common.ts
|
|
2218
|
+
import archiver from "archiver";
|
|
2219
|
+
import { createWriteStream } from "node:fs";
|
|
2220
|
+
function buildImsManifest12(pkg) {
|
|
2221
|
+
const title = escapeXml(pkg.title ?? pkg.id);
|
|
2222
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
2223
|
+
<manifest identifier="${escapeXml(pkg.id)}" version="1.0"
|
|
2224
|
+
xmlns="http://www.imsproject.org/xsd/imscp_rootv1p1p2"
|
|
2225
|
+
xmlns:adlcp="http://www.adlnet.org/xsd/adlcp_rootv1p2"
|
|
2226
|
+
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
|
|
2227
|
+
<metadata>
|
|
2228
|
+
<schema>ADL SCORM</schema>
|
|
2229
|
+
<schemaversion>1.2</schemaversion>
|
|
2230
|
+
</metadata>
|
|
2231
|
+
<organizations default="ORG-${escapeXml(pkg.id)}">
|
|
2232
|
+
<organization identifier="ORG-${escapeXml(pkg.id)}">
|
|
2233
|
+
<title>${title}</title>
|
|
2234
|
+
<item identifier="ITEM-1" identifierref="RES-1">
|
|
2235
|
+
<title>${title}</title>
|
|
2236
|
+
</item>
|
|
2237
|
+
</organization>
|
|
2238
|
+
</organizations>
|
|
2239
|
+
<resources>
|
|
2240
|
+
<resource identifier="RES-1" type="webcontent" adlcp:scormtype="sco" href="index.html">
|
|
2241
|
+
<file href="index.html"/>
|
|
2242
|
+
</resource>
|
|
2243
|
+
</resources>
|
|
2244
|
+
</manifest>
|
|
2245
|
+
`;
|
|
2246
|
+
}
|
|
2247
|
+
function buildImsManifest2004(pkg) {
|
|
2248
|
+
const title = escapeXml(pkg.title ?? pkg.id);
|
|
2249
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
2250
|
+
<!-- SCORM 2004 4th Edition (CAM 1.3) -->
|
|
2251
|
+
<manifest identifier="${escapeXml(pkg.id)}" version="1.0"
|
|
2252
|
+
xmlns="http://www.imsglobal.org/xsd/imscp_v1p1"
|
|
2253
|
+
xmlns:adlcp="http://www.adlnet.org/xsd/adlcp_v1p3"
|
|
2254
|
+
xmlns:adlseq="http://www.adlnet.org/xsd/adlseq_v1p3"
|
|
2255
|
+
xmlns:adlnav="http://www.adlnet.org/xsd/adlnav_v1p3"
|
|
2256
|
+
xmlns:imsss="http://www.imsglobal.org/xsd/imsss"
|
|
2257
|
+
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
|
|
2258
|
+
<metadata>
|
|
2259
|
+
<schema>ADL SCORM</schema>
|
|
2260
|
+
<schemaversion>2004 4th Edition</schemaversion>
|
|
2261
|
+
</metadata>
|
|
2262
|
+
<organizations default="ORG-${escapeXml(pkg.id)}">
|
|
2263
|
+
<organization identifier="ORG-${escapeXml(pkg.id)}">
|
|
2264
|
+
<title>${title}</title>
|
|
2265
|
+
<item identifier="ITEM-1" identifierref="RES-1">
|
|
2266
|
+
<title>${title}</title>
|
|
2267
|
+
<imsss:sequencing/>
|
|
2268
|
+
</item>
|
|
2269
|
+
</organization>
|
|
2270
|
+
</organizations>
|
|
2271
|
+
<resources>
|
|
2272
|
+
<resource identifier="RES-1" type="webcontent" adlcp:scormType="sco" href="index.html">
|
|
2273
|
+
<file href="index.html"/>
|
|
2274
|
+
</resource>
|
|
2275
|
+
</resources>
|
|
2276
|
+
</manifest>
|
|
2277
|
+
`;
|
|
2278
|
+
}
|
|
2279
|
+
function zipDir(srcDir, destZip) {
|
|
2280
|
+
return new Promise((resolve6, reject) => {
|
|
2281
|
+
const output = createWriteStream(destZip);
|
|
2282
|
+
const archive = archiver("zip", { zlib: { level: 9 } });
|
|
2283
|
+
output.on("close", () => resolve6());
|
|
2284
|
+
archive.on("error", reject);
|
|
2285
|
+
archive.pipe(output);
|
|
2286
|
+
archive.directory(srcDir, false);
|
|
2287
|
+
void archive.finalize();
|
|
2288
|
+
});
|
|
2289
|
+
}
|
|
2290
|
+
function escapeXml(s) {
|
|
2291
|
+
return s.replace(
|
|
2292
|
+
/[<>&"']/g,
|
|
2293
|
+
(c) => c === "<" ? "<" : c === ">" ? ">" : c === "&" ? "&" : c === '"' ? """ : "'"
|
|
2294
|
+
);
|
|
2295
|
+
}
|
|
2296
|
+
|
|
2297
|
+
// src/adapters/export/scorm12/index.ts
|
|
2298
|
+
var scorm12Adapter = {
|
|
2299
|
+
id: "scorm-1.2",
|
|
2300
|
+
displayName: "SCORM 1.2",
|
|
2301
|
+
version: "0.1.0",
|
|
2302
|
+
optionsSchema: { type: "object", additionalProperties: true },
|
|
2303
|
+
async validate(input) {
|
|
2304
|
+
const built = await access6(input.builtDir).then(
|
|
2305
|
+
() => true,
|
|
2306
|
+
() => false
|
|
2307
|
+
);
|
|
2308
|
+
const results = [
|
|
2309
|
+
{
|
|
2310
|
+
id: "scorm12-built-dir-present",
|
|
2311
|
+
category: "export",
|
|
2312
|
+
status: built ? "pass" : "fail",
|
|
2313
|
+
message: built ? "Built directory is present" : `Built directory not found: ${input.builtDir}`,
|
|
2314
|
+
location: input.builtDir
|
|
2315
|
+
}
|
|
2316
|
+
];
|
|
2317
|
+
if (!input.assets.scormLibraryVersion) {
|
|
2318
|
+
results.push({
|
|
2319
|
+
id: "scorm12-runtime-version",
|
|
2320
|
+
category: "export",
|
|
2321
|
+
status: "warn",
|
|
2322
|
+
message: "@studiolxd/scorm version is not pinned in manifest.lock",
|
|
2323
|
+
fix: "Run `lxd assets update --scorm-version <range>`"
|
|
2324
|
+
});
|
|
2325
|
+
}
|
|
2326
|
+
return results;
|
|
2327
|
+
},
|
|
2328
|
+
async export(input) {
|
|
2329
|
+
await mkdir7(input.outputDir, { recursive: true });
|
|
2330
|
+
await cp3(input.builtDir, input.outputDir, { recursive: true });
|
|
2331
|
+
await writeFile6(join11(input.outputDir, "imsmanifest.xml"), buildImsManifest12(input.package), "utf8");
|
|
2332
|
+
await writeFile6(
|
|
2333
|
+
join11(input.outputDir, "scorm-runtime.json"),
|
|
2334
|
+
JSON.stringify(
|
|
2335
|
+
{ library: "@studiolxd/scorm", version: input.assets.scormLibraryVersion ?? "unspecified", edition: "SCORM 1.2" },
|
|
2336
|
+
null,
|
|
2337
|
+
2
|
|
2338
|
+
),
|
|
2339
|
+
"utf8"
|
|
2340
|
+
);
|
|
2341
|
+
const zip = `${input.outputDir}.zip`;
|
|
2342
|
+
await zipDir(input.outputDir, zip);
|
|
2343
|
+
logger.debug({ zip }, "SCORM 1.2 export complete");
|
|
2344
|
+
const warnings = input.assets.scormLibraryVersion ? [] : ["@studiolxd/scorm version not pinned"];
|
|
2345
|
+
return { artifactPath: zip, target: "scorm-1.2", warnings };
|
|
2346
|
+
}
|
|
2347
|
+
};
|
|
2348
|
+
|
|
2349
|
+
// src/adapters/export/scorm2004/index.ts
|
|
2350
|
+
import { mkdir as mkdir8, cp as cp4, writeFile as writeFile7, access as access7 } from "node:fs/promises";
|
|
2351
|
+
import { join as join12 } from "node:path";
|
|
2352
|
+
var scorm2004Adapter = {
|
|
2353
|
+
id: "scorm-2004",
|
|
2354
|
+
displayName: "SCORM 2004 (4th Edition)",
|
|
2355
|
+
version: "0.1.0",
|
|
2356
|
+
optionsSchema: { type: "object", additionalProperties: true },
|
|
2357
|
+
async validate(input) {
|
|
2358
|
+
const built = await access7(input.builtDir).then(
|
|
2359
|
+
() => true,
|
|
2360
|
+
() => false
|
|
2361
|
+
);
|
|
2362
|
+
const results = [
|
|
2363
|
+
{
|
|
2364
|
+
id: "scorm2004-built-dir-present",
|
|
2365
|
+
category: "export",
|
|
2366
|
+
status: built ? "pass" : "fail",
|
|
2367
|
+
message: built ? "Built directory is present" : `Built directory not found: ${input.builtDir}`,
|
|
2368
|
+
location: input.builtDir
|
|
2369
|
+
}
|
|
2370
|
+
];
|
|
2371
|
+
if (!input.assets.scormLibraryVersion) {
|
|
2372
|
+
results.push({
|
|
2373
|
+
id: "scorm2004-runtime-version",
|
|
2374
|
+
category: "export",
|
|
2375
|
+
status: "warn",
|
|
2376
|
+
message: "@studiolxd/scorm version is not pinned in manifest.lock",
|
|
2377
|
+
fix: "Run `lxd assets update --scorm-version <range>`"
|
|
2378
|
+
});
|
|
2379
|
+
}
|
|
2380
|
+
return results;
|
|
2381
|
+
},
|
|
2382
|
+
async export(input) {
|
|
2383
|
+
await mkdir8(input.outputDir, { recursive: true });
|
|
2384
|
+
await cp4(input.builtDir, input.outputDir, { recursive: true });
|
|
2385
|
+
await writeFile7(join12(input.outputDir, "imsmanifest.xml"), buildImsManifest2004(input.package), "utf8");
|
|
2386
|
+
await writeFile7(
|
|
2387
|
+
join12(input.outputDir, "scorm-runtime.json"),
|
|
2388
|
+
JSON.stringify(
|
|
2389
|
+
{
|
|
2390
|
+
library: "@studiolxd/scorm",
|
|
2391
|
+
version: input.assets.scormLibraryVersion ?? "unspecified",
|
|
2392
|
+
edition: "SCORM 2004 4th Edition"
|
|
2393
|
+
},
|
|
2394
|
+
null,
|
|
2395
|
+
2
|
|
2396
|
+
),
|
|
2397
|
+
"utf8"
|
|
2398
|
+
);
|
|
2399
|
+
const zip = `${input.outputDir}.zip`;
|
|
2400
|
+
await zipDir(input.outputDir, zip);
|
|
2401
|
+
logger.debug({ zip }, "SCORM 2004 export complete");
|
|
2402
|
+
const warnings = input.assets.scormLibraryVersion ? [] : ["@studiolxd/scorm version not pinned"];
|
|
2403
|
+
return { artifactPath: zip, target: "scorm-2004", warnings };
|
|
2404
|
+
}
|
|
2405
|
+
};
|
|
2406
|
+
|
|
2407
|
+
// src/cli/commands/export.ts
|
|
2408
|
+
function register13(program) {
|
|
2409
|
+
program.command("export").description("Export the package to one or more targets").option("-t, --target <ids...>", "export target ids (web|scorm-1.2|scorm-2004)").action(async (opts, cmd) => {
|
|
2410
|
+
const dir = cwdOf(cmd);
|
|
2411
|
+
const project = await loadProjectLenient(dir);
|
|
2412
|
+
const registry = createExportRegistry();
|
|
2413
|
+
registry.register(webExportAdapter);
|
|
2414
|
+
registry.register(scorm12Adapter);
|
|
2415
|
+
registry.register(scorm2004Adapter);
|
|
2416
|
+
const manifest = await readManifest(dir);
|
|
2417
|
+
const targets = opts.target ?? project.export.targets?.map((t) => t.adapterId) ?? [];
|
|
2418
|
+
if (targets.length === 0) {
|
|
2419
|
+
console.error("No export targets specified. Use --target <id> or define export.config.yml.");
|
|
2420
|
+
process.exitCode = 1;
|
|
2421
|
+
return;
|
|
2422
|
+
}
|
|
2423
|
+
const knownForConfig = targets.filter((id) => registry.has(id));
|
|
2424
|
+
if (knownForConfig.length > 0) {
|
|
2425
|
+
await writeExport(dir, buildExportConfig(knownForConfig));
|
|
2426
|
+
}
|
|
2427
|
+
const built = await webComponentsAdapter.build({
|
|
2428
|
+
package: project.package,
|
|
2429
|
+
outputDir: join13(dir, "build"),
|
|
2430
|
+
generatedDir: join13(dir, "generated")
|
|
2431
|
+
});
|
|
2432
|
+
for (const id of targets) {
|
|
2433
|
+
if (!registry.has(id)) {
|
|
2434
|
+
console.error(`No export adapter for target "${id}".`);
|
|
2435
|
+
process.exitCode = 1;
|
|
2436
|
+
continue;
|
|
2437
|
+
}
|
|
2438
|
+
const adapter = registry.get(id);
|
|
2439
|
+
const result = await adapter.export({
|
|
2440
|
+
builtDir: built.outputDir,
|
|
2441
|
+
package: project.package,
|
|
2442
|
+
tracking: project.tracking,
|
|
2443
|
+
options: {},
|
|
2444
|
+
outputDir: join13(dir, "dist", id),
|
|
2445
|
+
assets: {
|
|
2446
|
+
scormLibraryVersion: manifest.scormLibraryVersion,
|
|
2447
|
+
scormSkillsSnapshotRef: manifest.scormSkillsSnapshot?.ref
|
|
2448
|
+
}
|
|
2449
|
+
});
|
|
2450
|
+
for (const w of result.warnings) console.warn(` warning: ${w}`);
|
|
2451
|
+
console.log(`Exported ${id} -> ${result.artifactPath}`);
|
|
2452
|
+
}
|
|
2453
|
+
});
|
|
2454
|
+
}
|
|
2455
|
+
|
|
2456
|
+
// src/cli/commands/assets.ts
|
|
2457
|
+
function register14(program) {
|
|
2458
|
+
const assets = program.command("assets").description("Manage external versioned assets (@studiolxd/scorm, scorm-skills)");
|
|
2459
|
+
assets.command("show").description("Show pinned external asset versions").action(async (_opts, cmd) => {
|
|
2460
|
+
const manifest = await readManifest(cwdOf(cmd.parent));
|
|
2461
|
+
console.log(JSON.stringify(manifest, null, 2));
|
|
2462
|
+
});
|
|
2463
|
+
assets.command("update").description("Explicitly update external asset versions (never silent)").option("--scorm-version <range>", "@studiolxd/scorm version range").option("--skills-version <version>", "scorm-skills snapshot version").option("--skills-ref <ref>", "scorm-skills source ref").action(
|
|
2464
|
+
async (opts, cmd) => {
|
|
2465
|
+
const dir = cwdOf(cmd.parent);
|
|
2466
|
+
await updateAssets(dir, {
|
|
2467
|
+
scormLibraryVersion: opts.scormVersion,
|
|
2468
|
+
scormSkillsVersion: opts.skillsVersion,
|
|
2469
|
+
scormSkillsRef: opts.skillsRef
|
|
2470
|
+
});
|
|
2471
|
+
console.log("External asset versions updated in manifest.lock.");
|
|
2472
|
+
}
|
|
2473
|
+
);
|
|
2474
|
+
}
|
|
2475
|
+
|
|
2476
|
+
// src/cli/commands/create.ts
|
|
2477
|
+
import { join as join14, resolve as resolve5 } from "node:path";
|
|
2478
|
+
import { rm } from "node:fs/promises";
|
|
2479
|
+
|
|
2480
|
+
// src/cli/prompt.ts
|
|
2481
|
+
import {
|
|
2482
|
+
text as clackText,
|
|
2483
|
+
select as clackSelect,
|
|
2484
|
+
multiselect as clackMultiselect,
|
|
2485
|
+
confirm as clackConfirm,
|
|
2486
|
+
isCancel as clackIsCancel
|
|
2487
|
+
} from "@clack/prompts";
|
|
2488
|
+
var CANCEL = Symbol("CANCEL");
|
|
2489
|
+
function isCancel2(v) {
|
|
2490
|
+
return v === CANCEL;
|
|
2491
|
+
}
|
|
2492
|
+
var ClackPrompter = class {
|
|
2493
|
+
async text(opts) {
|
|
2494
|
+
const r = await clackText(opts);
|
|
2495
|
+
return clackIsCancel(r) ? CANCEL : r;
|
|
2496
|
+
}
|
|
2497
|
+
async select(opts) {
|
|
2498
|
+
const r = await clackSelect({ message: opts.message, options: opts.options, initialValue: opts.initialValue });
|
|
2499
|
+
return clackIsCancel(r) ? CANCEL : r;
|
|
2500
|
+
}
|
|
2501
|
+
async multiselect(opts) {
|
|
2502
|
+
const r = await clackMultiselect({ message: opts.message, options: opts.options, required: opts.required ?? false });
|
|
2503
|
+
return clackIsCancel(r) ? CANCEL : r;
|
|
2504
|
+
}
|
|
2505
|
+
async confirm(opts) {
|
|
2506
|
+
const r = await clackConfirm({ message: opts.message, initialValue: opts.initialValue });
|
|
2507
|
+
return clackIsCancel(r) ? CANCEL : r;
|
|
2508
|
+
}
|
|
2509
|
+
};
|
|
2510
|
+
|
|
2511
|
+
// src/core/authoring/project-builder.ts
|
|
2512
|
+
function assembleProject(answers, design) {
|
|
2513
|
+
const experiences = answers.experiences.map(buildExperience);
|
|
2514
|
+
const order = experiences.map((e) => e.id);
|
|
2515
|
+
const pkg = {
|
|
2516
|
+
id: answers.id,
|
|
2517
|
+
schemaVersion: "1",
|
|
2518
|
+
learningGoal: answers.learningGoal,
|
|
2519
|
+
audience: { description: answers.audience },
|
|
2520
|
+
duration: { expectedMinutes: answers.durationMinutes },
|
|
2521
|
+
structure: { navigation: "linear", order },
|
|
2522
|
+
experienceRefs: order,
|
|
2523
|
+
designRef: "design",
|
|
2524
|
+
completionRules: { rule: answers.evaluationRule ?? "all-required" },
|
|
2525
|
+
trackingRef: "tracking",
|
|
2526
|
+
exportRef: "export"
|
|
2527
|
+
};
|
|
2528
|
+
if (answers.tone) pkg.tone = answers.tone;
|
|
2529
|
+
const tracking = buildTrackingConfig(experiences, { reportedFields: answers.trackingReports });
|
|
2530
|
+
const exportConfig = buildExportConfig(answers.exportTargets);
|
|
2531
|
+
const manifest = { cliVersion: "0.0.0" };
|
|
2532
|
+
if (answers.exportTargets.some((t) => t.startsWith("scorm"))) {
|
|
2533
|
+
manifest.scormLibraryVersion = RECOMMENDED_ASSETS.scormLibraryVersion;
|
|
2534
|
+
manifest.scormSkillsSnapshot = { ...RECOMMENDED_ASSETS.scormSkillsSnapshot };
|
|
2535
|
+
}
|
|
2536
|
+
return { package: pkg, design, experiences, tracking, export: exportConfig, manifest };
|
|
2537
|
+
}
|
|
2538
|
+
|
|
2539
|
+
// src/core/authoring/design-loader.ts
|
|
2540
|
+
import { readFile as readFile6 } from "node:fs/promises";
|
|
2541
|
+
import { resolve as resolve4 } from "node:path";
|
|
2542
|
+
import { parse as parseYaml5 } from "yaml";
|
|
2543
|
+
function buildDesignBrief(brief) {
|
|
2544
|
+
return { level: "brief", brief };
|
|
2545
|
+
}
|
|
2546
|
+
async function loadDesignFromFile(path, baseDir = ".") {
|
|
2547
|
+
const design = parseYaml5(await readFile6(resolve4(baseDir, path), "utf8"));
|
|
2548
|
+
const result = createValidator().validate("design-direction", design);
|
|
2549
|
+
if (!result.valid) {
|
|
2550
|
+
throw new Error(
|
|
2551
|
+
`Invalid design direction in ${path}: ${result.errors.map((e) => `${e.path} ${e.message}`).join("; ")}`
|
|
2552
|
+
);
|
|
2553
|
+
}
|
|
2554
|
+
return design;
|
|
2555
|
+
}
|
|
2556
|
+
|
|
2557
|
+
// src/cli/commands/create.ts
|
|
2558
|
+
var WizardCancelled = class extends Error {
|
|
2559
|
+
};
|
|
2560
|
+
async function need(p) {
|
|
2561
|
+
const v = await p;
|
|
2562
|
+
if (isCancel2(v)) throw new WizardCancelled();
|
|
2563
|
+
return v;
|
|
2564
|
+
}
|
|
2565
|
+
async function runWizard(prompter, opts) {
|
|
2566
|
+
const result = {
|
|
2567
|
+
cancelled: false,
|
|
2568
|
+
committed: false,
|
|
2569
|
+
dir: resolve5(opts.cwd, opts.dir ?? "."),
|
|
2570
|
+
handoffGenerated: false
|
|
2571
|
+
};
|
|
2572
|
+
let answers;
|
|
2573
|
+
try {
|
|
2574
|
+
const targetDir = opts.dir ? resolve5(opts.cwd, opts.dir) : resolve5(opts.cwd, await need(prompter.text({ message: "Package location", initialValue: "." })));
|
|
2575
|
+
result.dir = targetDir;
|
|
2576
|
+
let replacing = false;
|
|
2577
|
+
if (await packageExists(targetDir)) {
|
|
2578
|
+
const replace = await need(
|
|
2579
|
+
prompter.confirm({ message: `A package already exists at ${targetDir}. Replace it?`, initialValue: false })
|
|
2580
|
+
);
|
|
2581
|
+
if (!replace) {
|
|
2582
|
+
console.log("Aborted \u2014 existing package left unchanged.");
|
|
2583
|
+
result.cancelled = true;
|
|
2584
|
+
return result;
|
|
2585
|
+
}
|
|
2586
|
+
replacing = true;
|
|
2587
|
+
}
|
|
2588
|
+
const id = await need(
|
|
2589
|
+
prompter.text({ message: "Package id", validate: (v) => isSlug(v) ? void 0 : "Use a slug (a-z, 0-9, -)" })
|
|
2590
|
+
);
|
|
2591
|
+
const learningGoal = await need(prompter.text({ message: "Learning goal" }));
|
|
2592
|
+
const audience = await need(prompter.text({ message: "Audience" }));
|
|
2593
|
+
const durationStr = await need(
|
|
2594
|
+
prompter.text({
|
|
2595
|
+
message: "Expected duration (minutes)",
|
|
2596
|
+
validate: (v) => Number.isFinite(Number(v)) && Number(v) > 0 ? void 0 : "Enter a positive number"
|
|
2597
|
+
})
|
|
2598
|
+
);
|
|
2599
|
+
const tone = await need(prompter.text({ message: "Package tone", initialValue: "" }));
|
|
2600
|
+
const registry = await createMechanicRegistry();
|
|
2601
|
+
const mechOptions = registry.list().map((m) => ({ value: m.id, label: m.name }));
|
|
2602
|
+
const experiences = [];
|
|
2603
|
+
while (await need(
|
|
2604
|
+
prompter.confirm({
|
|
2605
|
+
message: experiences.length ? "Add another experience?" : "Add an experience?",
|
|
2606
|
+
initialValue: true
|
|
2607
|
+
})
|
|
2608
|
+
)) {
|
|
2609
|
+
const eid = await need(
|
|
2610
|
+
prompter.text({ message: "Experience id", validate: (v) => isSlug(v) ? void 0 : "Use a slug" })
|
|
2611
|
+
);
|
|
2612
|
+
const title = await need(prompter.text({ message: "Title", initialValue: eid }));
|
|
2613
|
+
const learningPurpose = await need(prompter.text({ message: "Learning purpose" }));
|
|
2614
|
+
const scenario = await need(prompter.text({ message: "Scenario" }));
|
|
2615
|
+
const mechanicRef = await need(prompter.select({ message: "Mechanic", options: mechOptions }));
|
|
2616
|
+
experiences.push({ id: eid, title, learningPurpose, scenario, mechanicRef });
|
|
2617
|
+
}
|
|
2618
|
+
let design;
|
|
2619
|
+
const useFile = await need(prompter.confirm({ message: "Use an existing design file (advanced)?", initialValue: false }));
|
|
2620
|
+
if (useFile) {
|
|
2621
|
+
const path = await need(prompter.text({ message: "Design file path" }));
|
|
2622
|
+
design = await loadDesignFromFile(path, targetDir);
|
|
2623
|
+
} else {
|
|
2624
|
+
const brief = await need(prompter.text({ message: "Design brief", initialValue: "" }));
|
|
2625
|
+
design = buildDesignBrief(brief);
|
|
2626
|
+
}
|
|
2627
|
+
const evaluationRule = await need(
|
|
2628
|
+
prompter.select({
|
|
2629
|
+
message: "Completion rule",
|
|
2630
|
+
options: [
|
|
2631
|
+
{ value: "all-required", label: "All required experiences complete" },
|
|
2632
|
+
{ value: "selected-required", label: "Selected required experiences complete" }
|
|
2633
|
+
],
|
|
2634
|
+
initialValue: "all-required"
|
|
2635
|
+
})
|
|
2636
|
+
);
|
|
2637
|
+
const trackingReports = await need(
|
|
2638
|
+
prompter.multiselect({ message: "Tracking reports", options: [...REPORTED_FIELDS].map((f) => ({ value: f, label: f })) })
|
|
2639
|
+
);
|
|
2640
|
+
const exportTargets = await need(
|
|
2641
|
+
prompter.multiselect({
|
|
2642
|
+
message: "Export targets",
|
|
2643
|
+
options: [...EXPORT_ADAPTERS_V1].map((t) => ({ value: t, label: t })),
|
|
2644
|
+
required: true
|
|
2645
|
+
})
|
|
2646
|
+
);
|
|
2647
|
+
answers = {
|
|
2648
|
+
id,
|
|
2649
|
+
learningGoal,
|
|
2650
|
+
audience,
|
|
2651
|
+
durationMinutes: Number(durationStr),
|
|
2652
|
+
tone: tone || void 0,
|
|
2653
|
+
experiences,
|
|
2654
|
+
evaluationRule,
|
|
2655
|
+
trackingReports,
|
|
2656
|
+
exportTargets
|
|
2657
|
+
};
|
|
2658
|
+
const commit = await need(
|
|
2659
|
+
prompter.confirm({ message: `Create package "${id}" with ${experiences.length} experience(s)?`, initialValue: true })
|
|
2660
|
+
);
|
|
2661
|
+
if (!commit) {
|
|
2662
|
+
console.log("Cancelled \u2014 no files were written.");
|
|
2663
|
+
result.cancelled = true;
|
|
2664
|
+
return result;
|
|
2665
|
+
}
|
|
2666
|
+
if (replacing) {
|
|
2667
|
+
await rm(join14(targetDir, "experiences"), { recursive: true, force: true });
|
|
2668
|
+
}
|
|
2669
|
+
await saveProject(targetDir, assembleProject(answers, design));
|
|
2670
|
+
result.committed = true;
|
|
2671
|
+
} catch (e) {
|
|
2672
|
+
if (e instanceof WizardCancelled) {
|
|
2673
|
+
console.log("Cancelled \u2014 no files were written.");
|
|
2674
|
+
result.cancelled = true;
|
|
2675
|
+
return result;
|
|
2676
|
+
}
|
|
2677
|
+
throw e;
|
|
2678
|
+
}
|
|
2679
|
+
try {
|
|
2680
|
+
if (await need(prompter.confirm({ message: "Run validation now?", initialValue: true }))) {
|
|
2681
|
+
const report = runValidation(await loadProjectLenient(result.dir), { projectDir: result.dir });
|
|
2682
|
+
result.validation = report;
|
|
2683
|
+
console.log(
|
|
2684
|
+
`Validation: ${report.summary.overall.toUpperCase()} \u2014 ${report.summary.pass} pass, ${report.summary.fail} fail, ${report.summary.warn} warn`
|
|
2685
|
+
);
|
|
2686
|
+
}
|
|
2687
|
+
if (await need(prompter.confirm({ message: "Generate agent handoff now?", initialValue: false }))) {
|
|
2688
|
+
await produceHandoff(await loadProjectLenient(result.dir), {
|
|
2689
|
+
targets: answers.exportTargets,
|
|
2690
|
+
outputDir: join14(result.dir, ".agent")
|
|
2691
|
+
});
|
|
2692
|
+
result.handoffGenerated = true;
|
|
2693
|
+
console.log("Handoff materials written to .agent/.");
|
|
2694
|
+
}
|
|
2695
|
+
} catch (e) {
|
|
2696
|
+
if (e instanceof WizardCancelled) {
|
|
2697
|
+
console.log(`Stopped after commit. The package was already written at ${result.dir}.`);
|
|
2698
|
+
return result;
|
|
2699
|
+
}
|
|
2700
|
+
throw e;
|
|
2701
|
+
}
|
|
2702
|
+
printSummary(answers);
|
|
2703
|
+
return result;
|
|
2704
|
+
}
|
|
2705
|
+
function printSummary(answers) {
|
|
2706
|
+
console.log("\nCreated package:");
|
|
2707
|
+
console.log(` id: ${answers.id}`);
|
|
2708
|
+
console.log(` experiences: ${answers.experiences.length}`);
|
|
2709
|
+
console.log(` export targets: ${answers.exportTargets.join(", ")}`);
|
|
2710
|
+
console.log("\nNext commands:");
|
|
2711
|
+
console.log(" lxd preview --mock-lms");
|
|
2712
|
+
console.log(" lxd validate");
|
|
2713
|
+
console.log(" lxd handoff");
|
|
2714
|
+
console.log(` lxd export --target ${answers.exportTargets.join(" --target ")}`);
|
|
2715
|
+
}
|
|
2716
|
+
function register15(program) {
|
|
2717
|
+
program.command("create [dir]").alias("wizard").description("Guided, interactive creation of a new elearning package").action(async (dir, _opts, cmd) => {
|
|
2718
|
+
await runWizard(new ClackPrompter(), { cwd: cwdOf(cmd), dir });
|
|
2719
|
+
});
|
|
2720
|
+
}
|
|
2721
|
+
|
|
2722
|
+
// src/cli/index.ts
|
|
2723
|
+
var VERSION2 = package_default.version;
|
|
2724
|
+
function registerCommands(program) {
|
|
2725
|
+
register(program);
|
|
2726
|
+
register2(program);
|
|
2727
|
+
register3(program);
|
|
2728
|
+
register4(program);
|
|
2729
|
+
register5(program);
|
|
2730
|
+
register6(program);
|
|
2731
|
+
register7(program);
|
|
2732
|
+
register8(program);
|
|
2733
|
+
register9(program);
|
|
2734
|
+
register10(program);
|
|
2735
|
+
register11(program);
|
|
2736
|
+
register12(program);
|
|
2737
|
+
register13(program);
|
|
2738
|
+
register14(program);
|
|
2739
|
+
register15(program);
|
|
2740
|
+
}
|
|
2741
|
+
function buildProgram() {
|
|
2742
|
+
const program = new Command();
|
|
2743
|
+
program.name("lxd").description("CLI-first authoring tool for AI-native elearning packages").version(VERSION2).option("--log-format <fmt>", "log output format: pretty | json", "pretty").option("--cwd <dir>", "project root directory");
|
|
2744
|
+
registerCommands(program);
|
|
2745
|
+
return program;
|
|
2746
|
+
}
|
|
2747
|
+
function isInvokedDirectly() {
|
|
2748
|
+
if (process.argv[1] === void 0) return false;
|
|
2749
|
+
const self = fileURLToPath2(import.meta.url);
|
|
2750
|
+
try {
|
|
2751
|
+
return realpathSync(self) === realpathSync(process.argv[1]);
|
|
2752
|
+
} catch {
|
|
2753
|
+
return self === process.argv[1];
|
|
2754
|
+
}
|
|
2755
|
+
}
|
|
2756
|
+
if (isInvokedDirectly()) {
|
|
2757
|
+
buildProgram().parse(process.argv);
|
|
2758
|
+
}
|
|
2759
|
+
export {
|
|
2760
|
+
VERSION2 as VERSION,
|
|
2761
|
+
buildProgram,
|
|
2762
|
+
registerCommands
|
|
2763
|
+
};
|