@kidsinai/kids-opencode-plugin 0.0.1
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/course-packs/README.md +38 -0
- package/course-packs/portfolio-site/mission-1/acceptance.yml +57 -0
- package/course-packs/portfolio-site/mission-1/brief.md +83 -0
- package/course-packs/portfolio-site/mission-2/acceptance.yml +53 -0
- package/course-packs/portfolio-site/mission-2/brief.md +77 -0
- package/course-packs/portfolio-site/mission-3/acceptance.yml +66 -0
- package/course-packs/portfolio-site/mission-3/brief.md +89 -0
- package/course-packs/portfolio-site/pack.yml +82 -0
- package/package.json +43 -0
- package/src/acceptance.ts +386 -0
- package/src/check-runner.ts +131 -0
- package/src/course-pack.ts +119 -0
- package/src/index.ts +262 -0
- package/src/system-prompt.ts +64 -0
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
// Acceptance check runner.
|
|
2
|
+
//
|
|
3
|
+
// Walks a Mission's `acceptance.yml` against the kid's current project folder
|
|
4
|
+
// (process.cwd()) and reports pass/fail per check. Used by:
|
|
5
|
+
//
|
|
6
|
+
// kids-opencode check <mission-id>
|
|
7
|
+
//
|
|
8
|
+
// Pure functions; the CLI entry point is in ./check-runner.ts.
|
|
9
|
+
|
|
10
|
+
import { existsSync, readFileSync, statSync } from "node:fs"
|
|
11
|
+
import { join, resolve, isAbsolute } from "node:path"
|
|
12
|
+
import { parse as parseYaml } from "yaml"
|
|
13
|
+
import { bundledCoursePacksDir, findMission, loadCoursePack } from "./course-pack.js"
|
|
14
|
+
|
|
15
|
+
export interface AcceptanceFile {
|
|
16
|
+
mission_id: string
|
|
17
|
+
title?: string
|
|
18
|
+
checks: AcceptanceCheck[]
|
|
19
|
+
completion_message?: string
|
|
20
|
+
parent_summary_fields?: string[]
|
|
21
|
+
pack_completion_message?: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type AcceptanceCheck =
|
|
25
|
+
| FileExistsCheck
|
|
26
|
+
| FileContainsRegexCheck
|
|
27
|
+
| FileContainsCountMinCheck
|
|
28
|
+
| FileContainsAnyRegexCheck
|
|
29
|
+
| FileTextLengthMinCheck
|
|
30
|
+
| AllMustExistCheck
|
|
31
|
+
| AuditLogCheck
|
|
32
|
+
|
|
33
|
+
interface CheckBase {
|
|
34
|
+
id: string
|
|
35
|
+
description: string
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface FileExistsCheck extends CheckBase {
|
|
39
|
+
type: "file_exists"
|
|
40
|
+
path: string
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface FileContainsRegexCheck extends CheckBase {
|
|
44
|
+
type: "file_contains_regex"
|
|
45
|
+
path: string
|
|
46
|
+
pattern: string
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface FileContainsCountMinCheck extends CheckBase {
|
|
50
|
+
type: "file_contains_count_min"
|
|
51
|
+
path: string
|
|
52
|
+
pattern: string
|
|
53
|
+
min_count: number
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface FileContainsAnyRegexCheck extends CheckBase {
|
|
57
|
+
type: "file_contains_any_regex"
|
|
58
|
+
path: string
|
|
59
|
+
patterns: string[]
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface FileTextLengthMinCheck extends CheckBase {
|
|
63
|
+
type: "file_text_length_min"
|
|
64
|
+
path: string
|
|
65
|
+
min_chars: number
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface AllMustExistCheck extends CheckBase {
|
|
69
|
+
type: "all_must_exist"
|
|
70
|
+
paths: string[]
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface AuditLogCheck extends CheckBase {
|
|
74
|
+
type: "audit_log_check"
|
|
75
|
+
audit_rule: string
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface CheckResult {
|
|
79
|
+
id: string
|
|
80
|
+
description: string
|
|
81
|
+
type: string
|
|
82
|
+
status: "pass" | "fail" | "skip"
|
|
83
|
+
detail?: string
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface MissionResult {
|
|
87
|
+
mission_id: string
|
|
88
|
+
title?: string
|
|
89
|
+
passed: number
|
|
90
|
+
failed: number
|
|
91
|
+
skipped: number
|
|
92
|
+
total: number
|
|
93
|
+
ok: boolean
|
|
94
|
+
results: CheckResult[]
|
|
95
|
+
completion_message?: string
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Resolve a path that's guaranteed to be inside `projectDir`. Refuses absolute
|
|
100
|
+
* paths and `..` escapes, matching the plugin's path-guard discipline.
|
|
101
|
+
*/
|
|
102
|
+
function resolveProjectPath(projectDir: string, rel: string): string | null {
|
|
103
|
+
if (isAbsolute(rel)) return null
|
|
104
|
+
if (rel.includes("..")) return null
|
|
105
|
+
return resolve(projectDir, rel)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Translate PCRE-style inline modifier prefixes (`(?i)`, `(?s)`, `(?m)`) into
|
|
110
|
+
* JavaScript RegExp flags. Acceptance YAMLs are authored by the curriculum
|
|
111
|
+
* team; supporting the conventional `(?i)` prefix is friendlier than asking
|
|
112
|
+
* authors to know JS regex specifics.
|
|
113
|
+
*
|
|
114
|
+
* Anything we don't recognise is left in place (so JS regex callers still
|
|
115
|
+
* get the syntax error they'd otherwise get).
|
|
116
|
+
*/
|
|
117
|
+
function compileRegex(pattern: string, extraFlags = ""): RegExp {
|
|
118
|
+
let flags = extraFlags
|
|
119
|
+
let remaining = pattern
|
|
120
|
+
const modifierRe = /^\(\?([imsux]+)\)/
|
|
121
|
+
const match = modifierRe.exec(remaining)
|
|
122
|
+
if (match) {
|
|
123
|
+
const modifiers = match[1] ?? ""
|
|
124
|
+
if (modifiers.includes("i") && !flags.includes("i")) flags += "i"
|
|
125
|
+
if (modifiers.includes("s") && !flags.includes("s")) flags += "s"
|
|
126
|
+
if (modifiers.includes("m") && !flags.includes("m")) flags += "m"
|
|
127
|
+
// u/x silently ignored — x (extended) has no JS equivalent
|
|
128
|
+
remaining = remaining.slice(match[0].length)
|
|
129
|
+
}
|
|
130
|
+
return new RegExp(remaining, flags)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Read text from a project file; returns null if missing or unreadable.
|
|
135
|
+
*/
|
|
136
|
+
function readProjectFile(projectDir: string, rel: string): string | null {
|
|
137
|
+
const full = resolveProjectPath(projectDir, rel)
|
|
138
|
+
if (!full) return null
|
|
139
|
+
if (!existsSync(full)) return null
|
|
140
|
+
try {
|
|
141
|
+
const st = statSync(full)
|
|
142
|
+
if (!st.isFile()) return null
|
|
143
|
+
return readFileSync(full, "utf8")
|
|
144
|
+
} catch {
|
|
145
|
+
return null
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function evalCheck(projectDir: string, check: AcceptanceCheck): CheckResult {
|
|
150
|
+
switch (check.type) {
|
|
151
|
+
case "file_exists": {
|
|
152
|
+
const full = resolveProjectPath(projectDir, check.path)
|
|
153
|
+
const ok = !!full && existsSync(full)
|
|
154
|
+
return {
|
|
155
|
+
id: check.id,
|
|
156
|
+
description: check.description,
|
|
157
|
+
type: check.type,
|
|
158
|
+
status: ok ? "pass" : "fail",
|
|
159
|
+
detail: ok ? `${check.path} exists` : `${check.path} missing`,
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
case "file_contains_regex": {
|
|
163
|
+
const text = readProjectFile(projectDir, check.path)
|
|
164
|
+
if (text === null) {
|
|
165
|
+
return {
|
|
166
|
+
id: check.id,
|
|
167
|
+
description: check.description,
|
|
168
|
+
type: check.type,
|
|
169
|
+
status: "fail",
|
|
170
|
+
detail: `${check.path} could not be read`,
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
let re: RegExp
|
|
174
|
+
try {
|
|
175
|
+
re = compileRegex(check.pattern)
|
|
176
|
+
} catch (err) {
|
|
177
|
+
return {
|
|
178
|
+
id: check.id,
|
|
179
|
+
description: check.description,
|
|
180
|
+
type: check.type,
|
|
181
|
+
status: "fail",
|
|
182
|
+
detail: `invalid regex: ${(err as Error).message}`,
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
const ok = re.test(text)
|
|
186
|
+
return {
|
|
187
|
+
id: check.id,
|
|
188
|
+
description: check.description,
|
|
189
|
+
type: check.type,
|
|
190
|
+
status: ok ? "pass" : "fail",
|
|
191
|
+
detail: ok ? `pattern matched` : `pattern not found`,
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
case "file_contains_count_min": {
|
|
195
|
+
const text = readProjectFile(projectDir, check.path)
|
|
196
|
+
if (text === null) {
|
|
197
|
+
return {
|
|
198
|
+
id: check.id,
|
|
199
|
+
description: check.description,
|
|
200
|
+
type: check.type,
|
|
201
|
+
status: "fail",
|
|
202
|
+
detail: `${check.path} could not be read`,
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
let re: RegExp
|
|
206
|
+
try {
|
|
207
|
+
re = compileRegex(check.pattern, "g")
|
|
208
|
+
} catch (err) {
|
|
209
|
+
return {
|
|
210
|
+
id: check.id,
|
|
211
|
+
description: check.description,
|
|
212
|
+
type: check.type,
|
|
213
|
+
status: "fail",
|
|
214
|
+
detail: `invalid regex: ${(err as Error).message}`,
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
const matches = text.match(re) ?? []
|
|
218
|
+
const ok = matches.length >= check.min_count
|
|
219
|
+
return {
|
|
220
|
+
id: check.id,
|
|
221
|
+
description: check.description,
|
|
222
|
+
type: check.type,
|
|
223
|
+
status: ok ? "pass" : "fail",
|
|
224
|
+
detail: `found ${matches.length} (need ≥ ${check.min_count})`,
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
case "file_contains_any_regex": {
|
|
228
|
+
const text = readProjectFile(projectDir, check.path)
|
|
229
|
+
if (text === null) {
|
|
230
|
+
return {
|
|
231
|
+
id: check.id,
|
|
232
|
+
description: check.description,
|
|
233
|
+
type: check.type,
|
|
234
|
+
status: "fail",
|
|
235
|
+
detail: `${check.path} could not be read`,
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
const ok = check.patterns.some((p) => {
|
|
239
|
+
try {
|
|
240
|
+
return compileRegex(p).test(text)
|
|
241
|
+
} catch {
|
|
242
|
+
return false
|
|
243
|
+
}
|
|
244
|
+
})
|
|
245
|
+
return {
|
|
246
|
+
id: check.id,
|
|
247
|
+
description: check.description,
|
|
248
|
+
type: check.type,
|
|
249
|
+
status: ok ? "pass" : "fail",
|
|
250
|
+
detail: ok ? `one of the patterns matched` : `none of the patterns matched`,
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
case "file_text_length_min": {
|
|
254
|
+
const text = readProjectFile(projectDir, check.path)
|
|
255
|
+
if (text === null) {
|
|
256
|
+
return {
|
|
257
|
+
id: check.id,
|
|
258
|
+
description: check.description,
|
|
259
|
+
type: check.type,
|
|
260
|
+
status: "fail",
|
|
261
|
+
detail: `${check.path} could not be read`,
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
// Tag-stripped length for the "actual content" heuristic.
|
|
265
|
+
const stripped = text.replace(/<[^>]*>/g, "").replace(/\s+/g, " ").trim()
|
|
266
|
+
const ok = stripped.length >= check.min_chars
|
|
267
|
+
return {
|
|
268
|
+
id: check.id,
|
|
269
|
+
description: check.description,
|
|
270
|
+
type: check.type,
|
|
271
|
+
status: ok ? "pass" : "fail",
|
|
272
|
+
detail: `text length ${stripped.length} (need ≥ ${check.min_chars})`,
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
case "all_must_exist": {
|
|
276
|
+
const missing = check.paths.filter((p) => {
|
|
277
|
+
const full = resolveProjectPath(projectDir, p)
|
|
278
|
+
return !full || !existsSync(full)
|
|
279
|
+
})
|
|
280
|
+
const ok = missing.length === 0
|
|
281
|
+
return {
|
|
282
|
+
id: check.id,
|
|
283
|
+
description: check.description,
|
|
284
|
+
type: check.type,
|
|
285
|
+
status: ok ? "pass" : "fail",
|
|
286
|
+
detail: ok ? `all paths present` : `missing: ${missing.join(", ")}`,
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
case "audit_log_check": {
|
|
290
|
+
// V0: audit log is stderr-only. We don't have a way to retroactively
|
|
291
|
+
// walk it from the runner. Skip with a note so the kid sees the
|
|
292
|
+
// intent but isn't blocked by a check we can't yet enforce.
|
|
293
|
+
return {
|
|
294
|
+
id: check.id,
|
|
295
|
+
description: check.description,
|
|
296
|
+
type: check.type,
|
|
297
|
+
status: "skip",
|
|
298
|
+
detail: `audit_log_check '${check.audit_rule}' requires platform-backend Phase 5 ingestion; not evaluated offline`,
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
default: {
|
|
302
|
+
// Exhaustiveness check.
|
|
303
|
+
const _: never = check
|
|
304
|
+
return {
|
|
305
|
+
id: (check as CheckBase).id,
|
|
306
|
+
description: (check as CheckBase).description,
|
|
307
|
+
type: (check as { type: string }).type,
|
|
308
|
+
status: "fail",
|
|
309
|
+
detail: `unsupported check type`,
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Load `acceptance.yml` for a Mission by (packId, missionId).
|
|
317
|
+
* Returns null if the file is missing or unparseable.
|
|
318
|
+
*/
|
|
319
|
+
export function loadAcceptanceForMission(
|
|
320
|
+
packId: string,
|
|
321
|
+
missionId: string,
|
|
322
|
+
): AcceptanceFile | null {
|
|
323
|
+
if (!packId || !missionId) return null
|
|
324
|
+
if (packId.includes("/") || packId.includes("..")) return null
|
|
325
|
+
if (missionId.includes("/") || missionId.includes("..")) return null
|
|
326
|
+
|
|
327
|
+
const acceptancePath = join(bundledCoursePacksDir(), packId, missionId, "acceptance.yml")
|
|
328
|
+
if (!existsSync(acceptancePath)) return null
|
|
329
|
+
|
|
330
|
+
try {
|
|
331
|
+
const raw = readFileSync(acceptancePath, "utf8")
|
|
332
|
+
return parseYaml(raw) as AcceptanceFile
|
|
333
|
+
} catch {
|
|
334
|
+
return null
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Run all checks for a Mission against `projectDir` (defaults to process.cwd()).
|
|
340
|
+
* Resolves the pack from KIDS_COURSE_PACK or an explicit argument.
|
|
341
|
+
*/
|
|
342
|
+
export function runMissionChecks(
|
|
343
|
+
missionId: string,
|
|
344
|
+
options: { packId?: string; projectDir?: string } = {},
|
|
345
|
+
): MissionResult | { error: string } {
|
|
346
|
+
const packId = options.packId ?? process.env.KIDS_COURSE_PACK ?? ""
|
|
347
|
+
const projectDir = options.projectDir ?? process.cwd()
|
|
348
|
+
|
|
349
|
+
if (!packId) {
|
|
350
|
+
return {
|
|
351
|
+
error:
|
|
352
|
+
"no Course Pack specified (set KIDS_COURSE_PACK or pass --course on the kids-opencode wrapper)",
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const pack = loadCoursePack(packId)
|
|
357
|
+
if (!pack) {
|
|
358
|
+
return { error: `Course Pack not found: ${packId}` }
|
|
359
|
+
}
|
|
360
|
+
const mission = findMission(pack, missionId)
|
|
361
|
+
if (!mission) {
|
|
362
|
+
return { error: `Mission '${missionId}' not in pack '${packId}'` }
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const acceptance = loadAcceptanceForMission(packId, missionId)
|
|
366
|
+
if (!acceptance) {
|
|
367
|
+
return { error: `acceptance.yml missing or unreadable for ${packId}/${missionId}` }
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const results = (acceptance.checks ?? []).map((c) => evalCheck(projectDir, c))
|
|
371
|
+
const passed = results.filter((r) => r.status === "pass").length
|
|
372
|
+
const failed = results.filter((r) => r.status === "fail").length
|
|
373
|
+
const skipped = results.filter((r) => r.status === "skip").length
|
|
374
|
+
|
|
375
|
+
return {
|
|
376
|
+
mission_id: missionId,
|
|
377
|
+
title: acceptance.title ?? mission.title,
|
|
378
|
+
passed,
|
|
379
|
+
failed,
|
|
380
|
+
skipped,
|
|
381
|
+
total: results.length,
|
|
382
|
+
ok: failed === 0,
|
|
383
|
+
results,
|
|
384
|
+
completion_message: acceptance.completion_message,
|
|
385
|
+
}
|
|
386
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
//
|
|
3
|
+
// `kids-opencode check <mission-id>` — CLI entry point.
|
|
4
|
+
//
|
|
5
|
+
// Walks the bundled acceptance.yml for the given Mission against the kid's
|
|
6
|
+
// current project folder (cwd) and prints a friendly report.
|
|
7
|
+
//
|
|
8
|
+
// Resolves the Course Pack from $KIDS_COURSE_PACK or `--course <id>`.
|
|
9
|
+
// Exit codes:
|
|
10
|
+
// 0 — all checks passed
|
|
11
|
+
// 1 — one or more checks failed
|
|
12
|
+
// 2 — usage error (missing args, pack not found, etc.)
|
|
13
|
+
|
|
14
|
+
import { runMissionChecks } from "./acceptance.js"
|
|
15
|
+
|
|
16
|
+
function usage(): never {
|
|
17
|
+
process.stderr.write(
|
|
18
|
+
[
|
|
19
|
+
"Usage: kids-opencode check <mission-id> [--course <pack-id>]",
|
|
20
|
+
"",
|
|
21
|
+
"Walks the Mission's acceptance criteria against the current folder.",
|
|
22
|
+
"",
|
|
23
|
+
"Examples:",
|
|
24
|
+
" cd ~/my-portfolio",
|
|
25
|
+
" kids-opencode check mission-1 --course portfolio-site",
|
|
26
|
+
"",
|
|
27
|
+
" # Or set the env var once and check multiple times:",
|
|
28
|
+
" export KIDS_COURSE_PACK=portfolio-site",
|
|
29
|
+
" kids-opencode check mission-1",
|
|
30
|
+
" kids-opencode check mission-2",
|
|
31
|
+
"",
|
|
32
|
+
].join("\n"),
|
|
33
|
+
)
|
|
34
|
+
process.exit(2)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface ParsedArgs {
|
|
38
|
+
missionId: string
|
|
39
|
+
packId?: string
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function parseArgs(argv: string[]): ParsedArgs | null {
|
|
43
|
+
let missionId = ""
|
|
44
|
+
let packId: string | undefined
|
|
45
|
+
let i = 0
|
|
46
|
+
while (i < argv.length) {
|
|
47
|
+
const a = argv[i]
|
|
48
|
+
if (a === "--course") {
|
|
49
|
+
packId = argv[i + 1]
|
|
50
|
+
i += 2
|
|
51
|
+
continue
|
|
52
|
+
}
|
|
53
|
+
if (a?.startsWith("--course=")) {
|
|
54
|
+
packId = a.slice("--course=".length)
|
|
55
|
+
i += 1
|
|
56
|
+
continue
|
|
57
|
+
}
|
|
58
|
+
if (a === "--help" || a === "-h") {
|
|
59
|
+
return null
|
|
60
|
+
}
|
|
61
|
+
if (a && !a.startsWith("-") && !missionId) {
|
|
62
|
+
missionId = a
|
|
63
|
+
i += 1
|
|
64
|
+
continue
|
|
65
|
+
}
|
|
66
|
+
i += 1
|
|
67
|
+
}
|
|
68
|
+
if (!missionId) return null
|
|
69
|
+
return { missionId, packId }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function statusGlyph(status: string): string {
|
|
73
|
+
switch (status) {
|
|
74
|
+
case "pass":
|
|
75
|
+
return "✅"
|
|
76
|
+
case "fail":
|
|
77
|
+
return "❌"
|
|
78
|
+
case "skip":
|
|
79
|
+
return "⏭ "
|
|
80
|
+
default:
|
|
81
|
+
return " "
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function main(): Promise<void> {
|
|
86
|
+
const args = parseArgs(process.argv.slice(2))
|
|
87
|
+
if (!args) usage()
|
|
88
|
+
|
|
89
|
+
const result = runMissionChecks(args.missionId, { packId: args.packId })
|
|
90
|
+
|
|
91
|
+
if ("error" in result) {
|
|
92
|
+
process.stderr.write(`kids-opencode check: ${result.error}\n`)
|
|
93
|
+
process.exit(2)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Render report.
|
|
97
|
+
const lines: string[] = []
|
|
98
|
+
lines.push("")
|
|
99
|
+
lines.push(`Mission: ${result.title ?? result.mission_id}`)
|
|
100
|
+
lines.push(`Folder: ${process.cwd()}`)
|
|
101
|
+
lines.push("")
|
|
102
|
+
lines.push(`Checks (${result.passed}/${result.total} pass, ${result.failed} fail, ${result.skipped} skip):`)
|
|
103
|
+
for (const r of result.results) {
|
|
104
|
+
lines.push(` ${statusGlyph(r.status)} ${r.description}`)
|
|
105
|
+
if (r.detail) {
|
|
106
|
+
lines.push(` → ${r.detail}`)
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
lines.push("")
|
|
110
|
+
if (result.ok) {
|
|
111
|
+
lines.push("🎉 All required checks passed.")
|
|
112
|
+
if (result.completion_message) {
|
|
113
|
+
lines.push("")
|
|
114
|
+
lines.push(result.completion_message)
|
|
115
|
+
}
|
|
116
|
+
} else {
|
|
117
|
+
lines.push("Some checks failed. Open the file(s) above and ask the AI for help:")
|
|
118
|
+
lines.push("")
|
|
119
|
+
lines.push(" kids-opencode")
|
|
120
|
+
lines.push("")
|
|
121
|
+
lines.push("Then say what's missing and walk through the fix together.")
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
process.stdout.write(lines.join("\n") + "\n")
|
|
125
|
+
process.exit(result.ok ? 0 : 1)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
main().catch((err) => {
|
|
129
|
+
process.stderr.write(`kids-opencode check: ${(err as Error).message}\n`)
|
|
130
|
+
process.exit(2)
|
|
131
|
+
})
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// Course Pack loader.
|
|
2
|
+
//
|
|
3
|
+
// Resolves a pack by ID against the bundled `course-packs/` directory
|
|
4
|
+
// (shipped with this npm package). Returns the system-prompt overlay,
|
|
5
|
+
// learning objectives, and mission metadata so the plugin can prepend
|
|
6
|
+
// them to the kid-safe system prompt.
|
|
7
|
+
|
|
8
|
+
import { existsSync, readFileSync } from "node:fs"
|
|
9
|
+
import { dirname, join, resolve } from "node:path"
|
|
10
|
+
import { fileURLToPath } from "node:url"
|
|
11
|
+
import { parse as parseYaml } from "yaml"
|
|
12
|
+
|
|
13
|
+
export interface CoursePack {
|
|
14
|
+
id: string
|
|
15
|
+
version: string
|
|
16
|
+
title: string
|
|
17
|
+
short_description?: string
|
|
18
|
+
target_age_band?: string
|
|
19
|
+
estimated_duration_minutes?: number
|
|
20
|
+
estimated_stars_budget?: number
|
|
21
|
+
learning_objectives?: string[]
|
|
22
|
+
prerequisites?: string[]
|
|
23
|
+
missions: CoursePackMission[]
|
|
24
|
+
system_prompt_overlay?: string
|
|
25
|
+
teacher_notes?: string[]
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface CoursePackMission {
|
|
29
|
+
id: string
|
|
30
|
+
title: string
|
|
31
|
+
estimated_minutes?: number
|
|
32
|
+
estimated_stars?: number
|
|
33
|
+
file?: string
|
|
34
|
+
acceptance?: string
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Resolve the bundled `course-packs/` directory relative to this source file.
|
|
39
|
+
* Works in dev (running from `src/`) and post-publish (running from `dist/`).
|
|
40
|
+
*/
|
|
41
|
+
export function bundledCoursePacksDir(): string {
|
|
42
|
+
// import.meta.url points at the .ts (dev) or .js (built); either way
|
|
43
|
+
// the `course-packs/` directory is two levels up from this file.
|
|
44
|
+
// - dev: src/course-pack.ts → ../course-packs/
|
|
45
|
+
// - built: dist/course-pack.js → ../course-packs/
|
|
46
|
+
const here = dirname(fileURLToPath(import.meta.url))
|
|
47
|
+
return resolve(here, "..", "course-packs")
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Load a Course Pack by ID. Returns null if the pack does not exist.
|
|
52
|
+
* Logs (does not throw) on parse error to avoid crashing kid sessions.
|
|
53
|
+
*/
|
|
54
|
+
export function loadCoursePack(packId: string): CoursePack | null {
|
|
55
|
+
if (!packId) return null
|
|
56
|
+
// Defence: refuse path-like IDs.
|
|
57
|
+
if (packId.includes("/") || packId.includes("..") || packId.includes("\\")) {
|
|
58
|
+
return null
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const packYamlPath = join(bundledCoursePacksDir(), packId, "pack.yml")
|
|
62
|
+
if (!existsSync(packYamlPath)) {
|
|
63
|
+
return null
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
const raw = readFileSync(packYamlPath, "utf8")
|
|
68
|
+
const parsed = parseYaml(raw) as CoursePack
|
|
69
|
+
return parsed
|
|
70
|
+
} catch (err) {
|
|
71
|
+
process.stderr.write(
|
|
72
|
+
`[kids-audit] {"event":"course_pack.load_error","pack":"${packId}","error":"${(err as Error).message}"}\n`,
|
|
73
|
+
)
|
|
74
|
+
return null
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Get the mission within a pack, or null if missing.
|
|
80
|
+
*/
|
|
81
|
+
export function findMission(
|
|
82
|
+
pack: CoursePack,
|
|
83
|
+
missionId: string,
|
|
84
|
+
): CoursePackMission | null {
|
|
85
|
+
return pack.missions?.find((m) => m.id === missionId) ?? null
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Build a stitched overlay block to prepend to the kid-safe system prompt.
|
|
90
|
+
* Composes: pack.system_prompt_overlay + current Mission context block.
|
|
91
|
+
*
|
|
92
|
+
* Returns an empty string if the pack is null (free-play mode).
|
|
93
|
+
*/
|
|
94
|
+
export function buildOverlay(
|
|
95
|
+
pack: CoursePack | null,
|
|
96
|
+
missionId: string | undefined,
|
|
97
|
+
): string {
|
|
98
|
+
if (!pack) return ""
|
|
99
|
+
|
|
100
|
+
const parts: string[] = []
|
|
101
|
+
if (pack.system_prompt_overlay) {
|
|
102
|
+
parts.push(pack.system_prompt_overlay.trim())
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (missionId) {
|
|
106
|
+
const m = findMission(pack, missionId)
|
|
107
|
+
if (m) {
|
|
108
|
+
parts.push(
|
|
109
|
+
`\n## Active mission\n` +
|
|
110
|
+
`- Mission ID: ${m.id}\n` +
|
|
111
|
+
`- Title: ${m.title}\n` +
|
|
112
|
+
(m.estimated_minutes ? `- Estimated time: ${m.estimated_minutes} min\n` : "") +
|
|
113
|
+
(m.estimated_stars ? `- Estimated cost: ${m.estimated_stars}⭐\n` : ""),
|
|
114
|
+
)
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return parts.join("\n\n").trim()
|
|
119
|
+
}
|