@erclx/aitk 0.103.0 → 0.104.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/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/claude-teach/SKILL.md +21 -3
- package/claude/skills/claude-teach/references/lesson-craft.md +8 -1
- package/docs/agents/index.md +1 -1
- package/docs/agents/teach.md +29 -2
- package/package.json +1 -1
- package/src/commands/teach.ts +136 -0
- package/src/records/validate.ts +5 -3
- package/src/sandbox/expect.ts +29 -12
- package/src/teach/lesson.ts +180 -0
- package/src/teach/workspace.ts +48 -3
|
@@ -40,7 +40,7 @@ From a linked worktree the file-editing tools refuse every path under the main r
|
|
|
40
40
|
|
|
41
41
|
A topic the listing already carries is a resume, and anything else is a new workspace. An invocation asking to promote is neither: read the named workspace through the listing and go to Step 6, which teaches nothing and writes no lesson.
|
|
42
42
|
|
|
43
|
-
On a resume, run `aitk teach list <topic> --json` for the files behind each count, then read
|
|
43
|
+
On a resume, run `aitk teach list <topic> --json` for the files behind each count, then read the highest-numbered learning record and `GLOSSARY.md`. Those carry where the learner stopped and what they got wrong. The listing record carries `success`, the mission's success lines, which are the exit criteria this workspace is finished against. Report them with what is already met before teaching anything.
|
|
44
44
|
|
|
45
45
|
On a new workspace, settle the starting point first, by asking rather than by assuming. Difficulty with no floor under it teaches nobody, and the mission cannot be written without it.
|
|
46
46
|
|
|
@@ -80,9 +80,27 @@ Open with retrieval on what the last session got wrong, before anything new. A l
|
|
|
80
80
|
|
|
81
81
|
Two outputs with two lifetimes, and the split decides the format.
|
|
82
82
|
|
|
83
|
-
- A lesson
|
|
83
|
+
- A lesson is a self-contained page carrying its own quiz and the feedback for each answer. It links one shared stylesheet rather than restating styles, and it is disposable and never promoted.
|
|
84
84
|
- A reference page goes to `reference/<slug>.md`, written for a reader with no learner in it. This is the half that survives the workspace, so it is written in markdown to pass the authoring gates a promotion would put it through.
|
|
85
85
|
|
|
86
|
+
Resolve the lesson before writing it, rather than composing its name or its quiz order by hand:
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
aitk teach lesson <topic> --json \
|
|
90
|
+
--slug <kebab slug for what this lesson covers> \
|
|
91
|
+
--questions <how many questions the quiz carries> \
|
|
92
|
+
--options <how many options each question carries>
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
It writes nothing and reports four things:
|
|
96
|
+
|
|
97
|
+
- `lesson`, the numbered path the lesson takes. Write it there.
|
|
98
|
+
- `stylesheet` with `stylesheetExists` and `stylesheetHref`. Write the stylesheet only when it reports absent, which is the first lesson in the workspace. Every lesson after that links the file already on disk and adds to it rather than replacing it, since overwriting discards what the last lesson put there.
|
|
99
|
+
- `success`, the mission's success lines, carried here so Step 5 needs no second read.
|
|
100
|
+
- `quiz`, one entry per question, carrying `order` and `answer`.
|
|
101
|
+
|
|
102
|
+
Write the correct option first, then present the options in the order `order` reports, reading it as authored indices where `0` is the correct one. Take the order as given. Position drawn here rather than chosen is the whole reason the verb exists, and a lesson that reorders on its own judgment puts the answer back in the first slot.
|
|
103
|
+
|
|
86
104
|
Add every term the lesson defines to `GLOSSARY.md` through the verb, which places the entries alphabetically in the shape the standard fixes:
|
|
87
105
|
|
|
88
106
|
```bash
|
|
@@ -101,7 +119,7 @@ Write `learning-records/<nnnn>-<slug>.md` before the session ends, carrying the
|
|
|
101
119
|
|
|
102
120
|
Record the wrong answer rather than the count. The next session places the learner from this file, and a tally carries no misconception to work against.
|
|
103
121
|
|
|
104
|
-
Then restate the mission's success lines with what is now met. A mission whose lines are all met is finished, and saying so is what closes a workspace.
|
|
122
|
+
Then restate the mission's success lines with what is now met, reading them from the `success` the lesson verb already reported rather than from `MISSION.md` by eye. Report each line as met or not met, and name what the learner did that meets it. A mission whose lines are all met is finished, and saying so is what closes a workspace.
|
|
105
123
|
|
|
106
124
|
## Step 6: propose where the durable half belongs
|
|
107
125
|
|
|
@@ -13,6 +13,7 @@ A workspace accumulates lessons over weeks. The learner reads them as one body o
|
|
|
13
13
|
|
|
14
14
|
- Write the shared stylesheet into the workspace assets on the first lesson, and link it from every lesson after
|
|
15
15
|
- Promote anything used a second time into that stylesheet. A second use makes it a component of the course.
|
|
16
|
+
- Add to that stylesheet rather than replacing it. The lesson writing it is rarely the lesson that needs it changed, and a rewrite drops what every earlier lesson links it for.
|
|
16
17
|
- Keep the structural furniture identical across lessons: where the title sits, where the quiz sits, what a correct answer looks like
|
|
17
18
|
- Do not restate styles inside a lesson. A local override is a decision the next lesson has to either copy or contradict.
|
|
18
19
|
|
|
@@ -39,11 +40,17 @@ Every element competes with the material for attention.
|
|
|
39
40
|
The quiz is the retrieval, so a leak in its construction turns it into a reading test.
|
|
40
41
|
|
|
41
42
|
- Write every option to the same length, in words and in characters. A longer option reads as the considered one.
|
|
42
|
-
-
|
|
43
|
+
- Write the correct option first and let the ordering verb place it. Position is not the author's to pick: an author who varies it by hand still varies it by judgment, and the judgment settles on the first slot.
|
|
43
44
|
- Make each wrong option a misconception someone actually holds. An obviously wrong option removes itself and shrinks the question.
|
|
44
45
|
- Write feedback for every option, including the correct one, saying why rather than whether
|
|
45
46
|
- Give feedback after the attempt, never alongside the question
|
|
46
47
|
|
|
48
|
+
## Tokens travel with the course
|
|
49
|
+
|
|
50
|
+
A lesson carries its own values in the shared stylesheet rather than reading a host project's. A workspace runs in any project and most carry no token record at all, so a lesson inheriting one is a lesson that renders unstyled wherever the record is absent, with nothing reporting it.
|
|
51
|
+
|
|
52
|
+
Pick values the material needs rather than values the project happens to hold. Inheriting is worth offering as something a learner asks for once, and it is the wrong default in every project that cannot answer.
|
|
53
|
+
|
|
47
54
|
## What makes a lesson worth returning to
|
|
48
55
|
|
|
49
56
|
- The worked example is complete. A learner returning for the example finds the whole thing rather than a fragment they have to reconstruct.
|
package/docs/agents/index.md
CHANGED
|
@@ -26,4 +26,4 @@ CLI catalog and invocation rules for agents, split by command domain. Start with
|
|
|
26
26
|
- [Sessions](sessions.md): Resolving live peer sessions to the worktree and branch each holds, the liveness confidence field, the unresolved reasons, and what the read depends on
|
|
27
27
|
- [Skill audit](skills-audit.md): Measuring both skill corpora against standards/skill.md, the checks it reads, the requirement gate that is the only failing one, and the drift verb that names bodies rewritten since a ref
|
|
28
28
|
- [Tasks](tasks.md): Selecting a shipped task by stem or pull request, recording a number and closing an outcome, the refusal reasons, the board and backlog checks validate runs, and why the board root defaults to the main worktree
|
|
29
|
-
- [Teach](teach.md): Listing learning workspaces and the ordinal a new one takes, opening one with its required files, recording sources and glossary terms, the refusal reasons, and why every write here runs through a verb
|
|
29
|
+
- [Teach](teach.md): Listing learning workspaces and the ordinal a new one takes, opening one with its required files, recording sources and glossary terms, resolving what the next lesson needs before it is written, the refusal reasons, and why every write here runs through a verb
|
package/docs/agents/teach.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
title: Teach
|
|
3
|
-
description: Listing learning workspaces and the ordinal a new one takes, opening one with its required files, recording sources and glossary terms, the refusal reasons, and why every write here runs through a verb
|
|
3
|
+
description: Listing learning workspaces and the ordinal a new one takes, opening one with its required files, recording sources and glossary terms, resolving what the next lesson needs before it is written, the refusal reasons, and why every write here runs through a verb
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Teach
|
|
@@ -102,6 +102,33 @@ A term already defined is refused rather than replaced. A definition the subject
|
|
|
102
102
|
|
|
103
103
|
The entry lands as the standard's shape, leading with the term as a bolded span. A definition not ending in sentence punctuation is terminated before the citation is appended, so a bare phrase does not run into the sentence naming where the term first appears.
|
|
104
104
|
|
|
105
|
+
## Lesson
|
|
106
|
+
|
|
107
|
+
`aitk teach lesson` resolves what the next lesson needs before it is written. It reads and never writes.
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
aitk teach lesson regular-expressions \
|
|
111
|
+
--slug capture-groups \
|
|
112
|
+
--questions 3 \
|
|
113
|
+
--options 4
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
| Option | Behavior |
|
|
117
|
+
| ----------------- | --------------------------------------------------------- |
|
|
118
|
+
| `--slug <kebab>` | The lesson's own topic, required |
|
|
119
|
+
| `--questions <n>` | How many questions the quiz carries, required |
|
|
120
|
+
| `--options <n>` | How many options each question carries, defaulting to `4` |
|
|
121
|
+
| `--json` | Emit a machine-readable record on stdout |
|
|
122
|
+
| `--root <path>` | Teach root, defaulting to the main worktree |
|
|
123
|
+
|
|
124
|
+
It reports four things. `lesson` is the numbered path the lesson takes, derived from the highest ordinal already in `lessons/` the way an open derives a workspace ordinal. `stylesheet` names the one file every lesson in the workspace links, with `stylesheetExists` saying whether it is on disk yet and `stylesheetHref` carrying what the lesson's own link element holds. `success` carries the mission's success lines, so a session reports progress against the exit criteria without a second read of `MISSION.md`. `quiz` carries one entry per question.
|
|
125
|
+
|
|
126
|
+
The stylesheet is reported rather than written. A verb that wrote it on every lesson would discard whatever the last lesson added, and the second lesson in a workspace is the one that needs the file the first one left.
|
|
127
|
+
|
|
128
|
+
Each `quiz` entry carries `order`, the authored option indices in presentation order where index `0` is the correct answer, and `answer`, the one-based position that answer lands in. Both travel together because a caller deriving the position itself is a caller that can derive it wrongly.
|
|
129
|
+
|
|
130
|
+
The order is drawn here rather than instructed, and that is the point of the verb. An author told to vary the position still varies it by judgment, and the judgment settles on the first slot, which is the defect this design departs from. The draw is uniform over the options, so the position carries no information about which answer is correct.
|
|
131
|
+
|
|
105
132
|
## Refusal reasons
|
|
106
133
|
|
|
107
134
|
| Reason | Raised when |
|
|
@@ -116,4 +143,4 @@ The entry lands as the standard's shape, leading with the term as a bolded span.
|
|
|
116
143
|
| `defined` | A term already carries a glossary entry |
|
|
117
144
|
| `bad-input` | The command line is malformed, before any folder is read |
|
|
118
145
|
|
|
119
|
-
A `bad-input` refusal reports the working directory as its root rather than the resolved one, since the command line is rejected before the root is worth resolving.
|
|
146
|
+
A `bad-input` refusal reports the working directory as its root rather than the resolved one, since the command line is rejected before the root is worth resolving. `lesson` raises it for a slug that is not kebab-case, for a quiz carrying no question, and for a question carrying fewer than two options, since a question with one option has nothing to confuse the right answer with.
|
package/package.json
CHANGED
package/src/commands/teach.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { relative } from 'node:path'
|
|
2
2
|
import type { Command } from 'commander'
|
|
3
|
+
import { type LessonOutcome, planLesson } from '@/teach/lesson'
|
|
3
4
|
import {
|
|
4
5
|
defineTerms,
|
|
5
6
|
type ListOutcome,
|
|
@@ -57,6 +58,14 @@ interface ResourceCommandOptions {
|
|
|
57
58
|
readonly root?: string
|
|
58
59
|
}
|
|
59
60
|
|
|
61
|
+
interface LessonCommandOptions {
|
|
62
|
+
readonly json?: boolean
|
|
63
|
+
readonly options?: string
|
|
64
|
+
readonly questions?: string
|
|
65
|
+
readonly root?: string
|
|
66
|
+
readonly slug?: string
|
|
67
|
+
}
|
|
68
|
+
|
|
60
69
|
interface GlossaryCommandOptions {
|
|
61
70
|
readonly firstSeen?: string
|
|
62
71
|
readonly json?: boolean
|
|
@@ -231,6 +240,42 @@ export function register(program: Command): void {
|
|
|
231
240
|
.action(async (topic: string, opts: GlossaryCommandOptions) => {
|
|
232
241
|
process.exitCode = await runGlossary(topic, opts)
|
|
233
242
|
})
|
|
243
|
+
|
|
244
|
+
teach
|
|
245
|
+
.command('lesson')
|
|
246
|
+
.description('Resolve what the next lesson needs before it is written')
|
|
247
|
+
.argument('<topic>', 'Workspace folder or topic, as in regular-expressions')
|
|
248
|
+
.helpOption('-h, --help', 'Show this help message')
|
|
249
|
+
.option('--slug <kebab>', "The lesson's own topic, as in capture-groups")
|
|
250
|
+
.option('--questions <n>', 'How many questions the quiz carries')
|
|
251
|
+
.option('--options <n>', 'How many options each question carries', '4')
|
|
252
|
+
.option('--json', 'Emit a machine-readable record on stdout')
|
|
253
|
+
.option('--root <path>', 'Teach root, defaulting to the main worktree')
|
|
254
|
+
.addHelpText(
|
|
255
|
+
'after',
|
|
256
|
+
[
|
|
257
|
+
'',
|
|
258
|
+
'Exit codes:',
|
|
259
|
+
' 0 the lesson was planned',
|
|
260
|
+
' 1 refused, with the reason on stderr or in the JSON record',
|
|
261
|
+
'',
|
|
262
|
+
'It writes nothing. It reports the numbered path the lesson takes, the',
|
|
263
|
+
'shared stylesheet with whether that file exists yet, the mission',
|
|
264
|
+
'success lines to report progress against, and one option order per',
|
|
265
|
+
'question.',
|
|
266
|
+
'',
|
|
267
|
+
'Write the correct option first and present the options in the order',
|
|
268
|
+
'reported. Position is drawn here rather than chosen, so an answer',
|
|
269
|
+
'cannot settle into the first slot.',
|
|
270
|
+
'',
|
|
271
|
+
'Examples:',
|
|
272
|
+
' aitk teach lesson regular-expressions --slug capture-groups --questions 3 --json',
|
|
273
|
+
'',
|
|
274
|
+
].join('\n'),
|
|
275
|
+
)
|
|
276
|
+
.action(async (topic: string, opts: LessonCommandOptions) => {
|
|
277
|
+
process.exitCode = await runLesson(topic, opts)
|
|
278
|
+
})
|
|
234
279
|
}
|
|
235
280
|
|
|
236
281
|
function collect(value: string, previous: string[]): string[] {
|
|
@@ -435,6 +480,43 @@ async function runGlossary(
|
|
|
435
480
|
)
|
|
436
481
|
}
|
|
437
482
|
|
|
483
|
+
async function runLesson(
|
|
484
|
+
topic: string,
|
|
485
|
+
opts: LessonCommandOptions,
|
|
486
|
+
): Promise<number> {
|
|
487
|
+
const emitJson = opts.json ?? false
|
|
488
|
+
|
|
489
|
+
if (!opts.slug) {
|
|
490
|
+
return reportRefusal(
|
|
491
|
+
'aitk teach lesson',
|
|
492
|
+
badInput('No lesson slug. Pass --slug <kebab>.'),
|
|
493
|
+
emitJson,
|
|
494
|
+
process.cwd(),
|
|
495
|
+
)
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
if (!opts.questions) {
|
|
499
|
+
return reportRefusal(
|
|
500
|
+
'aitk teach lesson',
|
|
501
|
+
badInput('No question count. Pass --questions <n>.'),
|
|
502
|
+
emitJson,
|
|
503
|
+
process.cwd(),
|
|
504
|
+
)
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
const root = await rootFor(opts.root)
|
|
508
|
+
|
|
509
|
+
return reportLesson(
|
|
510
|
+
await planLesson(root, topic, {
|
|
511
|
+
slug: opts.slug,
|
|
512
|
+
questions: Number(opts.questions),
|
|
513
|
+
options: Number(opts.options ?? '4'),
|
|
514
|
+
}),
|
|
515
|
+
emitJson,
|
|
516
|
+
root,
|
|
517
|
+
)
|
|
518
|
+
}
|
|
519
|
+
|
|
438
520
|
function reportRefusal(
|
|
439
521
|
title: string,
|
|
440
522
|
refused: TeachRefused,
|
|
@@ -545,6 +627,11 @@ function reportWorkspace(
|
|
|
545
627
|
logStep(workspace.glossary.length > 0 ? 'Glossary' : 'Glossary (empty)')
|
|
546
628
|
for (const entry of workspace.glossary) logInfo(entry)
|
|
547
629
|
|
|
630
|
+
logStep(
|
|
631
|
+
workspace.success.length > 0 ? 'Exit criteria' : 'Exit criteria (none)',
|
|
632
|
+
)
|
|
633
|
+
for (const line of workspace.success) logInfo(line)
|
|
634
|
+
|
|
548
635
|
if (workspace.missing.length > 0) {
|
|
549
636
|
logStep('Missing a required file')
|
|
550
637
|
logWarn(`no ${workspace.missing.join(' and no ')}`)
|
|
@@ -648,3 +735,52 @@ function reportGlossary(
|
|
|
648
735
|
|
|
649
736
|
return 0
|
|
650
737
|
}
|
|
738
|
+
|
|
739
|
+
function reportLesson(
|
|
740
|
+
outcome: LessonOutcome,
|
|
741
|
+
emitJson: boolean,
|
|
742
|
+
root: string,
|
|
743
|
+
): number {
|
|
744
|
+
if (!outcome.ok) {
|
|
745
|
+
return reportRefusal('aitk teach lesson', outcome, emitJson, root)
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
if (emitJson) {
|
|
749
|
+
process.stdout.write(
|
|
750
|
+
`${JSON.stringify({
|
|
751
|
+
ok: true,
|
|
752
|
+
root,
|
|
753
|
+
slug: outcome.slug,
|
|
754
|
+
path: outcome.path,
|
|
755
|
+
lesson: outcome.lesson,
|
|
756
|
+
stylesheet: outcome.stylesheet,
|
|
757
|
+
stylesheetHref: outcome.stylesheetHref,
|
|
758
|
+
stylesheetExists: outcome.stylesheetExists,
|
|
759
|
+
success: outcome.success,
|
|
760
|
+
quiz: outcome.quiz,
|
|
761
|
+
})}\n`,
|
|
762
|
+
)
|
|
763
|
+
return 0
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
intro('aitk teach lesson')
|
|
767
|
+
logStep('Lesson')
|
|
768
|
+
logInfo(outcome.lesson)
|
|
769
|
+
|
|
770
|
+
logStep(outcome.stylesheetExists ? 'Stylesheet' : 'Stylesheet (to write)')
|
|
771
|
+
logInfo(`${outcome.stylesheet} linked as ${outcome.stylesheetHref}`)
|
|
772
|
+
|
|
773
|
+
logStep(outcome.success.length > 0 ? 'Exit criteria' : 'Exit criteria (none)')
|
|
774
|
+
for (const line of outcome.success) logInfo(line)
|
|
775
|
+
|
|
776
|
+
logStep('Option order')
|
|
777
|
+
for (const question of outcome.quiz) {
|
|
778
|
+
logInfo(
|
|
779
|
+
`question ${question.question}: present ${question.order.join(', ')}, correct answer in position ${question.answer}`,
|
|
780
|
+
)
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
outro()
|
|
784
|
+
|
|
785
|
+
return 0
|
|
786
|
+
}
|
package/src/records/validate.ts
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
TEACH_RECORDS,
|
|
10
10
|
TEACH_REFERENCE,
|
|
11
11
|
TEACH_RESOURCES,
|
|
12
|
+
TEACH_SUCCESS_HEADING,
|
|
12
13
|
WORKSPACE_NAME,
|
|
13
14
|
} from '@/teach/workspace'
|
|
14
15
|
|
|
@@ -576,7 +577,6 @@ async function checkDump(dir: string, slug: string): Promise<Finding[]> {
|
|
|
576
577
|
return [...findings, ...perCluster.flat()]
|
|
577
578
|
}
|
|
578
579
|
|
|
579
|
-
const TEACH_SUCCESS = /^##[ \t]+Success looks like[ \t]*$/
|
|
580
580
|
const NUMBERED_RECORD = /^\d{4}-[a-z0-9]+(-[a-z0-9]+)*\.md$/
|
|
581
581
|
/**
|
|
582
582
|
* A kebab slug that does not open with an ordinal. The lookahead rejects a
|
|
@@ -705,13 +705,15 @@ async function checkWorkspace(dir: string, slug: string): Promise<Finding[]> {
|
|
|
705
705
|
}
|
|
706
706
|
|
|
707
707
|
if (
|
|
708
|
-
!linesOutsideFences(text).some(
|
|
708
|
+
!linesOutsideFences(text).some(
|
|
709
|
+
(line) => line.trim() === TEACH_SUCCESS_HEADING,
|
|
710
|
+
)
|
|
709
711
|
) {
|
|
710
712
|
findings.push(
|
|
711
713
|
finding(
|
|
712
714
|
'section-missing',
|
|
713
715
|
slug,
|
|
714
|
-
|
|
716
|
+
TEACH_SUCCESS_HEADING,
|
|
715
717
|
'is absent, so the mission names no observable thing the learner will be able to do.',
|
|
716
718
|
),
|
|
717
719
|
)
|
package/src/sandbox/expect.ts
CHANGED
|
@@ -209,23 +209,35 @@ export function countMechanicalAssertions(expectation: Expectation): number {
|
|
|
209
209
|
)
|
|
210
210
|
}
|
|
211
211
|
|
|
212
|
+
/**
|
|
213
|
+
* An entry carrying `*` is matched as a glob, so an arm can assert a file whose
|
|
214
|
+
* name a run derives. Reports the matched path rather than the pattern, since a
|
|
215
|
+
* pass on `lessons/0001-*.html` says nothing until the name it found is named.
|
|
216
|
+
*/
|
|
212
217
|
function checkPaths(
|
|
213
218
|
expectation: Expectation,
|
|
214
219
|
sandboxDir: string,
|
|
215
220
|
): AssertionResult[] {
|
|
216
|
-
return expectation.paths.map((path) =>
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
+
return expectation.paths.map((path) => {
|
|
222
|
+
const written = writtenUnder(path, sandboxDir)
|
|
223
|
+
|
|
224
|
+
return written
|
|
225
|
+
? { ok: true, message: `exists: ${written}` }
|
|
226
|
+
: { ok: false, message: `missing: ${path}` }
|
|
227
|
+
})
|
|
221
228
|
}
|
|
222
229
|
|
|
223
230
|
/**
|
|
224
|
-
*
|
|
225
|
-
*
|
|
226
|
-
*
|
|
227
|
-
*
|
|
228
|
-
*
|
|
231
|
+
* The first file an entry matches, or undefined when it matches none. An entry
|
|
232
|
+
* carrying `*` is matched as a glob, which is what lets an arm name a file whose
|
|
233
|
+
* name a run derives rather than fixes. Pinning one spelling of a derived name
|
|
234
|
+
* passes vacuously against every other spelling, which reads as coverage the arm
|
|
235
|
+
* does not have.
|
|
236
|
+
*
|
|
237
|
+
* Returning the match rather than a boolean is what lets a result name the file
|
|
238
|
+
* the run wrote instead of the pattern that found it. A glob matching several
|
|
239
|
+
* files answers with one of them in no fixed order, so an arm asserting content
|
|
240
|
+
* through a glob seeds a folder holding one.
|
|
229
241
|
*/
|
|
230
242
|
function writtenUnder(pattern: string, sandboxDir: string): string | undefined {
|
|
231
243
|
if (!pattern.includes('*')) {
|
|
@@ -260,14 +272,18 @@ function checkAbsent(
|
|
|
260
272
|
* throwing, since an arm may assert content without also listing the path. A
|
|
261
273
|
* pattern that does not compile is a defect in the declaration, so it fails the
|
|
262
274
|
* assertion it belongs to rather than aborting the whole verdict.
|
|
275
|
+
*
|
|
276
|
+
* A path carrying `*` resolves to the file it matched, and falls back to itself
|
|
277
|
+
* when it matched none so the miss is reported against the entry as written.
|
|
263
278
|
*/
|
|
264
279
|
function checkContent(
|
|
265
280
|
expectation: Expectation,
|
|
266
281
|
sandboxDir: string,
|
|
267
282
|
): AssertionResult[] {
|
|
268
283
|
return expectation.content.map(({ path, pattern }) => {
|
|
269
|
-
const
|
|
270
|
-
const
|
|
284
|
+
const matched = writtenUnder(path, sandboxDir) ?? path
|
|
285
|
+
const full = join(sandboxDir, matched)
|
|
286
|
+
const label = `${matched} =~ ${pattern}`
|
|
271
287
|
|
|
272
288
|
let matcher: RegExp
|
|
273
289
|
try {
|
|
@@ -279,6 +295,7 @@ function checkContent(
|
|
|
279
295
|
if (!existsSync(full) || !statSync(full).isFile()) {
|
|
280
296
|
return { ok: false, message: `no file to match: ${path}` }
|
|
281
297
|
}
|
|
298
|
+
|
|
282
299
|
if (matcher.test(readFileSync(full, 'utf8'))) {
|
|
283
300
|
return { ok: true, message: `matches: ${label}` }
|
|
284
301
|
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import {
|
|
4
|
+
readWorkspace,
|
|
5
|
+
refuse,
|
|
6
|
+
TEACH_ASSETS,
|
|
7
|
+
TEACH_LESSONS,
|
|
8
|
+
TEACH_STYLESHEET,
|
|
9
|
+
type TeachRefused,
|
|
10
|
+
} from '@/teach/workspace'
|
|
11
|
+
|
|
12
|
+
/** Four digits inside a workspace, per the standard, because one holds many. */
|
|
13
|
+
const LESSON_WIDTH = 4
|
|
14
|
+
|
|
15
|
+
/** The ordinal a lesson filename opens with, which fixes its read order. */
|
|
16
|
+
const LESSON_NUMBER = /^(\d{4})-/
|
|
17
|
+
|
|
18
|
+
const LESSON_SLUG = /^[a-z0-9]+(-[a-z0-9]+)*$/
|
|
19
|
+
|
|
20
|
+
/** A quiz needs a right answer and at least one thing to confuse it with. */
|
|
21
|
+
const MIN_OPTIONS = 2
|
|
22
|
+
|
|
23
|
+
export interface QuizOrder {
|
|
24
|
+
/** One-based, so a report reads the same way the lesson numbers them. */
|
|
25
|
+
readonly question: number
|
|
26
|
+
/**
|
|
27
|
+
* Authored indices in the order the lesson presents them, where the authored
|
|
28
|
+
* index `0` is the correct answer. The author writes the correct option first
|
|
29
|
+
* and reads its position back off this list.
|
|
30
|
+
*/
|
|
31
|
+
readonly order: readonly number[]
|
|
32
|
+
/** Where the correct answer lands, one-based, so no caller derives it. */
|
|
33
|
+
readonly answer: number
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface LessonPlanned {
|
|
37
|
+
readonly ok: true
|
|
38
|
+
readonly slug: string
|
|
39
|
+
/** Relative to the root, so a caller prints a path a reader can open. */
|
|
40
|
+
readonly path: string
|
|
41
|
+
readonly lesson: string
|
|
42
|
+
readonly stylesheet: string
|
|
43
|
+
/** What the lesson's own `link` element carries, resolved from `lessons/`. */
|
|
44
|
+
readonly stylesheetHref: string
|
|
45
|
+
/** False on the first lesson in a workspace, which writes the stylesheet. */
|
|
46
|
+
readonly stylesheetExists: boolean
|
|
47
|
+
/** The mission's success lines, reported as the exit criteria they are. */
|
|
48
|
+
readonly success: readonly string[]
|
|
49
|
+
readonly quiz: readonly QuizOrder[]
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export type LessonOutcome = LessonPlanned | TeachRefused
|
|
53
|
+
|
|
54
|
+
export interface LessonRequest {
|
|
55
|
+
readonly slug: string
|
|
56
|
+
readonly questions: number
|
|
57
|
+
readonly options: number
|
|
58
|
+
/**
|
|
59
|
+
* Injected so a test can assert the shape of an order against a generator it
|
|
60
|
+
* controls. Every caller outside a test takes the default, which is what
|
|
61
|
+
* keeps the position of a correct answer off the author's judgment.
|
|
62
|
+
*/
|
|
63
|
+
readonly random?: () => number
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* A uniform permutation of `0 .. count - 1` by Fisher-Yates.
|
|
68
|
+
*
|
|
69
|
+
* The bias this exists against is the authored order surviving into the
|
|
70
|
+
* lesson, which puts the correct answer first whenever the author wrote it
|
|
71
|
+
* first. Drawing the permutation here rather than in a prompt is what makes the
|
|
72
|
+
* position unguessable from the outside.
|
|
73
|
+
*/
|
|
74
|
+
function shuffled(count: number, random: () => number): number[] {
|
|
75
|
+
const order = Array.from({ length: count }, (_, index) => index)
|
|
76
|
+
|
|
77
|
+
for (let index = count - 1; index > 0; index -= 1) {
|
|
78
|
+
const pick = Math.floor(random() * (index + 1))
|
|
79
|
+
const held = order[index]
|
|
80
|
+
order[index] = order[pick]
|
|
81
|
+
order[pick] = held
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return order
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* One presentation order per question, each carrying where the correct answer
|
|
89
|
+
* landed. Both halves travel together because a caller deriving the position
|
|
90
|
+
* itself is a caller that can derive it wrongly.
|
|
91
|
+
*/
|
|
92
|
+
export function orderQuiz(
|
|
93
|
+
questions: number,
|
|
94
|
+
options: number,
|
|
95
|
+
random: () => number = Math.random,
|
|
96
|
+
): QuizOrder[] {
|
|
97
|
+
return Array.from({ length: questions }, (_, index) => {
|
|
98
|
+
const order = shuffled(options, random)
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
question: index + 1,
|
|
102
|
+
order,
|
|
103
|
+
answer: order.indexOf(0) + 1,
|
|
104
|
+
}
|
|
105
|
+
})
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The number the next lesson takes, read off the filenames already there.
|
|
110
|
+
*
|
|
111
|
+
* A file whose name carries no ordinal moves nothing, the way a workspace
|
|
112
|
+
* folder with no ordinal moves no workspace number. Numbering past it would
|
|
113
|
+
* renumber nothing and skipping it would hide it.
|
|
114
|
+
*/
|
|
115
|
+
function nextLesson(files: readonly string[]): string {
|
|
116
|
+
const highest = files
|
|
117
|
+
.map((file) => LESSON_NUMBER.exec(file)?.[1])
|
|
118
|
+
.filter((ordinal): ordinal is string => ordinal !== undefined)
|
|
119
|
+
.reduce((carry, ordinal) => Math.max(carry, Number(ordinal)), 0)
|
|
120
|
+
|
|
121
|
+
return String(highest + 1).padStart(LESSON_WIDTH, '0')
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Everything a lesson needs resolved before it is written: where it goes, which
|
|
126
|
+
* stylesheet it links and whether that file is already on disk, the mission's
|
|
127
|
+
* exit criteria, and the order each quiz presents its options in.
|
|
128
|
+
*
|
|
129
|
+
* The stylesheet is reported rather than written. Every lesson after the first
|
|
130
|
+
* reads the one the first wrote, so a verb that rewrote it on every lesson
|
|
131
|
+
* would discard whatever the last one added.
|
|
132
|
+
*/
|
|
133
|
+
export async function planLesson(
|
|
134
|
+
root: string,
|
|
135
|
+
selector: string,
|
|
136
|
+
request: LessonRequest,
|
|
137
|
+
): Promise<LessonOutcome> {
|
|
138
|
+
if (!LESSON_SLUG.test(request.slug)) {
|
|
139
|
+
return refuse('bad-input', `Not a kebab-case slug: ${request.slug}.`, [
|
|
140
|
+
request.slug,
|
|
141
|
+
])
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (!Number.isInteger(request.questions) || request.questions < 1) {
|
|
145
|
+
return refuse(
|
|
146
|
+
'bad-input',
|
|
147
|
+
'A lesson carries at least one question. Pass --questions <n>.',
|
|
148
|
+
)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (!Number.isInteger(request.options) || request.options < MIN_OPTIONS) {
|
|
152
|
+
return refuse(
|
|
153
|
+
'bad-input',
|
|
154
|
+
`A question carries at least ${MIN_OPTIONS} options. Pass --options <n>.`,
|
|
155
|
+
)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const found = await readWorkspace(root, selector)
|
|
159
|
+
if (!found.ok) return found
|
|
160
|
+
|
|
161
|
+
const workspace = found.workspace
|
|
162
|
+
const lesson = join(
|
|
163
|
+
workspace.path,
|
|
164
|
+
TEACH_LESSONS,
|
|
165
|
+
`${nextLesson(workspace.lessonFiles)}-${request.slug}.html`,
|
|
166
|
+
)
|
|
167
|
+
const stylesheet = join(workspace.path, TEACH_ASSETS, TEACH_STYLESHEET)
|
|
168
|
+
|
|
169
|
+
return {
|
|
170
|
+
ok: true,
|
|
171
|
+
slug: workspace.slug,
|
|
172
|
+
path: workspace.path,
|
|
173
|
+
lesson,
|
|
174
|
+
stylesheet,
|
|
175
|
+
stylesheetHref: `../${TEACH_ASSETS}/${TEACH_STYLESHEET}`,
|
|
176
|
+
stylesheetExists: existsSync(join(root, stylesheet)),
|
|
177
|
+
success: workspace.success,
|
|
178
|
+
quiz: orderQuiz(request.questions, request.options, request.random),
|
|
179
|
+
}
|
|
180
|
+
}
|
package/src/teach/workspace.ts
CHANGED
|
@@ -32,6 +32,22 @@ export const TEACH_GLOSSARY = 'GLOSSARY.md'
|
|
|
32
32
|
export const TEACH_REFERENCE = 'reference'
|
|
33
33
|
export const TEACH_RECORDS = 'learning-records'
|
|
34
34
|
export const TEACH_LESSONS = 'lessons'
|
|
35
|
+
export const TEACH_ASSETS = 'assets'
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The one stylesheet every lesson in a workspace links. The name is fixed here
|
|
39
|
+
* rather than chosen per lesson, because the second lesson has to reach the
|
|
40
|
+
* file the first one wrote and a name composed twice is a name that can differ.
|
|
41
|
+
*/
|
|
42
|
+
export const TEACH_STYLESHEET = 'course.css'
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The mission heading whose list a session reads as exit criteria. The writer,
|
|
46
|
+
* the reader below, and the record validator all match this one spelling, so a
|
|
47
|
+
* heading none of them can find fails the validator rather than reading as an
|
|
48
|
+
* empty list.
|
|
49
|
+
*/
|
|
50
|
+
export const TEACH_SUCCESS_HEADING = '## Success looks like'
|
|
35
51
|
|
|
36
52
|
/**
|
|
37
53
|
* A workspace folder as the standard names it, capturing the ordinal and the
|
|
@@ -79,6 +95,8 @@ export interface WorkspaceDetail extends WorkspaceSummary {
|
|
|
79
95
|
readonly recordFiles: readonly string[]
|
|
80
96
|
readonly referenceFiles: readonly string[]
|
|
81
97
|
readonly glossary: readonly string[]
|
|
98
|
+
/** The mission's success lines, which a session reports progress against. */
|
|
99
|
+
readonly success: readonly string[]
|
|
82
100
|
}
|
|
83
101
|
|
|
84
102
|
export interface WorkspacesListed {
|
|
@@ -141,7 +159,7 @@ export interface OpenRequest {
|
|
|
141
159
|
readonly date?: string
|
|
142
160
|
}
|
|
143
161
|
|
|
144
|
-
function refuse(
|
|
162
|
+
export function refuse(
|
|
145
163
|
reason: TeachRefusal,
|
|
146
164
|
message: string,
|
|
147
165
|
detail: readonly string[] = [],
|
|
@@ -198,6 +216,30 @@ function glossaryTerms(text: string): string[] {
|
|
|
198
216
|
.map((line) => line.trim().slice('- '.length))
|
|
199
217
|
}
|
|
200
218
|
|
|
219
|
+
/**
|
|
220
|
+
* The mission's success lines, each one an observable thing the learner will be
|
|
221
|
+
* able to do. A session reads them as exit criteria, so a wrapped entry is
|
|
222
|
+
* joined back into one line rather than reported as two criteria.
|
|
223
|
+
*
|
|
224
|
+
* A mission carrying no such heading yields nothing rather than refusing. The
|
|
225
|
+
* record validator is what reports the absent section, and a listing that
|
|
226
|
+
* refused would take the whole workspace down with it.
|
|
227
|
+
*/
|
|
228
|
+
function successLines(text: string): string[] {
|
|
229
|
+
const lines = text.split('\n')
|
|
230
|
+
const section = sectionRange(
|
|
231
|
+
unfenced(text),
|
|
232
|
+
TEACH_SUCCESS_HEADING,
|
|
233
|
+
lines.length,
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
if (!section) return []
|
|
237
|
+
|
|
238
|
+
return bulletBlocks(lines.slice(section.start, section.end)).map((block) =>
|
|
239
|
+
block.join(' ').trim().slice('- '.length).replace(/\s+/g, ' ').trim(),
|
|
240
|
+
)
|
|
241
|
+
}
|
|
242
|
+
|
|
201
243
|
/** The ordinal a folder name carries, or `NaN` when it carries none. */
|
|
202
244
|
function ordinalOf(slug: string): number {
|
|
203
245
|
const match = WORKSPACE_NAME.exec(slug)
|
|
@@ -211,9 +253,11 @@ async function summarize(
|
|
|
211
253
|
): Promise<WorkspaceDetail> {
|
|
212
254
|
const match = WORKSPACE_NAME.exec(slug)
|
|
213
255
|
const missionPath = join(dir, TEACH_MISSION)
|
|
214
|
-
const
|
|
215
|
-
?
|
|
256
|
+
const mission = existsSync(missionPath)
|
|
257
|
+
? await readFile(missionPath, 'utf8')
|
|
216
258
|
: undefined
|
|
259
|
+
const frontmatter =
|
|
260
|
+
mission === undefined ? undefined : parseFrontmatter(mission)
|
|
217
261
|
|
|
218
262
|
const glossaryPath = join(dir, TEACH_GLOSSARY)
|
|
219
263
|
const glossary = existsSync(glossaryPath)
|
|
@@ -244,6 +288,7 @@ async function summarize(
|
|
|
244
288
|
recordFiles,
|
|
245
289
|
referenceFiles,
|
|
246
290
|
glossary,
|
|
291
|
+
success: mission === undefined ? [] : successLines(mission),
|
|
247
292
|
}
|
|
248
293
|
}
|
|
249
294
|
|