@erclx/canon 4.48.1 → 4.50.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 +31 -3
- package/claude/skills/claude-teach/references/lesson-craft.md +48 -1
- package/claude/skills/claude-teach/references/pedagogy.md +11 -1
- package/docs/agents/commands.md +1 -1
- package/docs/agents/index.md +1 -1
- package/docs/agents/teach.md +15 -1
- package/package.json +1 -1
- package/src/commands/teach.ts +20 -0
- package/src/design/base.css +8 -0
- package/src/design/css.ts +1 -0
- package/src/design/tokens.ts +26 -2
- package/src/tasks/archive.ts +44 -20
- package/src/tasks/record.ts +4 -3
- package/src/teach/nav.ts +60 -2
- package/src/teach/workspace.ts +151 -0
- package/standards/teach.md +19 -2
- package/tooling/astro/configs/playwright.config.ts +1 -0
|
@@ -43,6 +43,8 @@ A topic the listing already carries is a resume, and anything else is a new work
|
|
|
43
43
|
|
|
44
44
|
On a resume, run `canon 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.
|
|
45
45
|
|
|
46
|
+
It also carries `due`, what the learning records schedule, soonest first. Each entry names the item, its date, the rung it has reached, whether it is `overdue`, and the two dates the next record could write. Read it here rather than later: it is the input to what this session opens on, and `canon teach lesson` takes a `--slug` naming a choice already made. Report what is overdue before proposing a lesson. An empty `due` on a workspace holding records means no record scheduled anything, which is a gap in the records rather than a clear schedule.
|
|
47
|
+
|
|
46
48
|
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.
|
|
47
49
|
|
|
48
50
|
Then open the workspace, which derives the ordinal and writes all three required files:
|
|
@@ -75,13 +77,15 @@ A claim nothing was read for is the failure this step exists against. Where no s
|
|
|
75
77
|
|
|
76
78
|
Pick the next lesson from the learning records rather than from the subject's own order. The target is the band immediately past what the learner can already do unaided, which `${CLAUDE_SKILL_DIR}/references/pedagogy.md` states in full.
|
|
77
79
|
|
|
78
|
-
Open
|
|
80
|
+
Open on what `due` reports as overdue, oldest date first, before anything new. Where nothing is overdue, open on retrieval of what the last session got wrong. A learner who cannot retrieve the previous lesson is not ready for the next one, and moving on anyway buys fluency that decays.
|
|
81
|
+
|
|
82
|
+
Reading the last record alone is what the schedule replaces. A topic missed three sessions ago and never revisited is invisible to that read and sits in `due` with a date already past, which is the whole reason the field exists.
|
|
79
83
|
|
|
80
84
|
## Step 4: write the lesson and the reference
|
|
81
85
|
|
|
82
86
|
Two outputs with two lifetimes, and the split decides the format.
|
|
83
87
|
|
|
84
|
-
- A lesson is a self-contained page carrying its own quiz and the feedback for each
|
|
88
|
+
- A lesson is a self-contained page carrying its own quiz, a teach-back block, and the feedback for each question. It embeds one shared stylesheet rather than restating styles, and it is disposable and never promoted.
|
|
85
89
|
- 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.
|
|
86
90
|
|
|
87
91
|
Resolve the lesson before writing it, rather than composing its name or its quiz order by hand:
|
|
@@ -101,6 +105,14 @@ It writes nothing and reports three things:
|
|
|
101
105
|
|
|
102
106
|
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
107
|
|
|
108
|
+
### The quiz and the teach-back block
|
|
109
|
+
|
|
110
|
+
The quiz markup is a contract rather than a convention. `canon teach nav` splices a stepper into the lesson that gates on exactly the class names and nesting `${CLAUDE_SKILL_DIR}/references/lesson-craft.md` states under `## Quiz construction`, so a quiz in any other shape shows every question at once and nothing reports it. That is the leak this shape closes: a later stem naming what an earlier question asked for answers it while both sit on screen.
|
|
111
|
+
|
|
112
|
+
Write each option as a `<label class="opt" data-k="<letter>">` holding a radio `<input>` and a `<span>`, every option in one question sharing a `name` and each question taking a different one, the correct option carrying `data-a="1"`, and one `.fb` block as the last child of the `.q`. Never write a `<button class="opt">` in a new lesson. That is the shape written before the stepper, kept working by an injected script the button itself still triggers, and a lesson mixing the two gets both mechanisms.
|
|
113
|
+
|
|
114
|
+
Then carry a teach-back block beside the quiz. A quiz is recognition and the pedagogy prefers production, so a lesson offering only a quiz tests the weak form. Ask for an explanation to a named audience, and carry a `<details>` listing what a complete explanation covers, closed by default, so a learner reading with no session in the room can grade themselves. The reference states both shapes in full.
|
|
115
|
+
|
|
104
116
|
Write the chrome as four empty marker pairs rather than composing it by hand: `<!-- canon:teach:style -->`/`<!-- /canon:teach:style -->` inside `<head>`, and `<!-- canon:teach:header -->`, `<!-- canon:teach:footnav -->`, and `<!-- canon:teach:scripts -->` each with its own close marker, in that order in `<body>`. Write the authored `<h1>`, lede, body, and quiz between the header's close marker and the footnav's open marker, and nothing else anywhere in the file. Then run:
|
|
105
117
|
|
|
106
118
|
```bash
|
|
@@ -145,10 +157,26 @@ Do this on every run that opens or resumes a workspace, including one that write
|
|
|
145
157
|
|
|
146
158
|
## Step 5: record what happened
|
|
147
159
|
|
|
148
|
-
Write `learning-records/<nnnn>-<slug>.md` before the session ends, carrying the lessons covered, what the learner retrieved unaided, what they got wrong with the wrong answer itself, and
|
|
160
|
+
Write `learning-records/<nnnn>-<slug>.md` before the session ends, carrying the lessons covered, what the learner retrieved unaided, what they got wrong with the wrong answer itself, and a `## Revisit` section.
|
|
149
161
|
|
|
150
162
|
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.
|
|
151
163
|
|
|
164
|
+
Record what the teach-back left out under what the learner got wrong, naming the concept and where the explanation broke. A produced answer and a selected one are both retrieval failures the next session places from, so a second heading would split one input across two sections.
|
|
165
|
+
|
|
166
|
+
Then write `## Revisit`, one bullet per item, in the shape `${CLAUDE_SKILL_DIR}/../../standards/teach.md` fixes:
|
|
167
|
+
|
|
168
|
+
```markdown
|
|
169
|
+
## Revisit
|
|
170
|
+
|
|
171
|
+
- **<what comes back up>**: due <YYYY-MM-DD>, rung <n>
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
Take both the date and the rung from the `due` entry Step 1 already read, copying `hit` for an item the learner retrieved unaided and `miss` for one they did not, with the rung stepped the same way. Do not compute a date from the ladder. A session told to widen a gap still picks the number by judgment, which is the reason the verb reports both dates at all.
|
|
175
|
+
|
|
176
|
+
An item the records have never scheduled has no `due` entry to copy from. Open it at rung 1, dated the day after this session, which is the ladder's floor and the one number this body states. Every later date for it comes from the verb.
|
|
177
|
+
|
|
178
|
+
An entry outside this shape schedules nothing and nothing reports that it was skipped, so write the shape exactly. A later record naming the same item supersedes an earlier entry, so revise a schedule by writing the new bullet rather than editing the record it was set in.
|
|
179
|
+
|
|
152
180
|
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.
|
|
153
181
|
|
|
154
182
|
## Step 6: propose where the durable half belongs
|
|
@@ -49,9 +49,56 @@ The quiz is the retrieval, so a leak in its construction turns it into a reading
|
|
|
49
49
|
- Write every option to the same length, in words and in characters. A longer option reads as the considered one.
|
|
50
50
|
- 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.
|
|
51
51
|
- Make each wrong option a misconception someone actually holds. An obviously wrong option removes itself and shrinks the question.
|
|
52
|
-
- Write feedback
|
|
52
|
+
- Write one feedback block per question, covering the correct option and why each distractor fails, saying why rather than whether. One block reads as an explanation where a block per option reads as four verdicts, and only the explanation names what separates a wrong option from the answer.
|
|
53
53
|
- Give feedback after the attempt, never alongside the question
|
|
54
54
|
|
|
55
|
+
### The markup
|
|
56
|
+
|
|
57
|
+
The quiz shape is a contract rather than a convention. `canon teach nav` splices a stepper into the lesson that gates on exactly these class names and this nesting, so a quiz written in any other shape renders with every question on screen at once and nothing reports it.
|
|
58
|
+
|
|
59
|
+
```html
|
|
60
|
+
<div class="quiz">
|
|
61
|
+
<h2>Retrieval check</h2>
|
|
62
|
+
|
|
63
|
+
<div class="q"><p class="q-stem">1. <stem></p>
|
|
64
|
+
<label class="opt" data-k="A"><input type="radio" name="q1"><span><the option text></span></label>
|
|
65
|
+
<label class="opt" data-k="B"><input type="radio" name="q1" data-a="1"><span><the option text></span></label>
|
|
66
|
+
<div class="fb"><b>Correct: <the answer in a few words>.</b> <why each distractor fails.></div></div>
|
|
67
|
+
</div>
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
- Give every option in one question the same `name`, and a different `name` per question. Options sharing a name across two questions let one answer clear another.
|
|
71
|
+
- Mark the correct option with `data-a="1"` and leave the others without the attribute
|
|
72
|
+
- Carry `data-k` on the `label`, since the workspace stylesheet renders the key letter from it
|
|
73
|
+
- Keep `.fb` the last child of its `.q`. The stepper reveals it as a direct child, and a feedback block nested deeper stays hidden.
|
|
74
|
+
- Put the option text in a `<span>` inside the label rather than bare beside the input, so the workspace can lay the two out
|
|
75
|
+
- Never write a `<button class="opt">` in a new lesson. That is the shape written before the stepper, and it is kept working by an injected script the presence of a button is what still triggers.
|
|
76
|
+
|
|
77
|
+
The stepper hides and shows and sets nothing else, so how a selected option looks, and whether a wrong one is marked as wrong, is the workspace stylesheet's to grow. Name the correct option in the feedback text for that reason: it is what a learner reads to find out whether they were right.
|
|
78
|
+
|
|
79
|
+
## Teach back
|
|
80
|
+
|
|
81
|
+
A quiz is recognition and a teach-back is production, which is the form retrieval practice prefers. Carry both: the quiz places the learner against options someone could hold, and the teach-back is where the learner finds out what they cannot say without help.
|
|
82
|
+
|
|
83
|
+
- Ask for an explanation to a named audience rather than for an answer. Asking the learner to explain to somebody who has never written a pattern why a lazy quantifier is not an optional one gives the explanation a floor, where asking them to explain lazy quantifiers gives it none.
|
|
84
|
+
- Ask about the thing the lesson was for, not a detail beside it. A teach-back on a footnote tests attention rather than the material.
|
|
85
|
+
- Carry a self-check the learner can open, as a `<details>` listing what a complete explanation covers, closed by default. A learner reading with no session in the room otherwise gets an ask they cannot grade, and closed leaks nothing.
|
|
86
|
+
- Write the self-check as what the explanation has to cover rather than as the explanation itself. A model answer is read instead of produced, which turns the block back into reading.
|
|
87
|
+
|
|
88
|
+
```html
|
|
89
|
+
<div class="teach-back">
|
|
90
|
+
<h2>Explain it back</h2>
|
|
91
|
+
<p>Without looking above: <the ask, naming who it is for.></p>
|
|
92
|
+
<details><summary>What a complete explanation covers</summary>
|
|
93
|
+
<ul>
|
|
94
|
+
<li><a point the explanation has to reach></li>
|
|
95
|
+
</ul>
|
|
96
|
+
</details>
|
|
97
|
+
</div>
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
It needs no styles of its own beyond what the workspace already gives a section and a `<details>`.
|
|
101
|
+
|
|
55
102
|
## Tokens travel with the course
|
|
56
103
|
|
|
57
104
|
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.
|
|
@@ -25,13 +25,23 @@ Pulling an answer out of memory strengthens it far more than reading it again. R
|
|
|
25
25
|
- Let the learner fail an attempt before correcting it. A failed retrieval followed by the answer beats being told first.
|
|
26
26
|
- Do not cue the answer in the question. A question restating the material tests reading rather than memory.
|
|
27
27
|
|
|
28
|
+
The teach-back block is the production form this section prefers, and it is why a lesson carries one beside its quiz rather than a quiz alone. A quiz is recognition, so a lesson offering only one tests the weak form and reports it as retrieval. Asking the learner to explain the material to somebody who does not have it makes them produce the whole structure rather than pick from four, and what the explanation leaves out is the gap the next session places from.
|
|
29
|
+
|
|
30
|
+
- Carry a teach-back block in every lesson, asking for an explanation rather than an answer
|
|
31
|
+
- Name the audience the explanation is for, since an explanation with no reader has no floor to sit above
|
|
32
|
+
- Record what the explanation left out under what the learner got wrong. A produced answer and a selected one are both retrieval failures, and splitting them across two sections splits one placement input.
|
|
33
|
+
|
|
28
34
|
## Spacing
|
|
29
35
|
|
|
30
36
|
The same total time spread across days beats the same time in one sitting. Forgetting between sessions is the mechanism rather than the cost, because effortful retrieval is what strengthens the trace.
|
|
31
37
|
|
|
32
38
|
- Revisit a topic after the learner has begun to forget it, not while it is still fresh
|
|
33
39
|
- Widen the gap each time a topic is retrieved correctly, and shorten it after a miss
|
|
34
|
-
- Name what to revisit and
|
|
40
|
+
- Name what to revisit and when in the learning record, so the next session has a schedule rather than a guess
|
|
41
|
+
|
|
42
|
+
The ladder is 1, 3, 7, 16, and 35 days. A topic retrieved unaided moves up a rung and a miss drops it back one, held inside the ladder at either end, so a topic the learner keeps missing stays on the shortest gap rather than restarting a sequence.
|
|
43
|
+
|
|
44
|
+
Do not compute a date from that ladder by hand. `canon teach list <topic> --json` reports the schedule with both dates already resolved for every item, which is what turns the ladder into a number to copy rather than a rule to apply. An author told to widen a gap still picks the gap by judgment, and this is the same reason quiz option order is drawn by a verb rather than stated here.
|
|
35
45
|
|
|
36
46
|
## Interleaving
|
|
37
47
|
|
package/docs/agents/commands.md
CHANGED
|
@@ -37,7 +37,7 @@ Full help: `canon <command> --help`. Behavior notes for the install and sync ver
|
|
|
37
37
|
| `canon tasks validate` | Report board rows whose shape, order, plan, task file, group, file set, or blocker does not hold (`--json`) |
|
|
38
38
|
| `canon intake list` | Report intake folder counts, or one folder's items, keeping what is unread with `--unread` (`--json`) |
|
|
39
39
|
| `canon intake answer` | Write selections into one cluster's answer slots, repeating `--set <item>=<answer>` (`--json`) |
|
|
40
|
-
| `canon teach list` | Report learning workspaces and the ordinal a new one takes, or what one workspace holds (`--json`)
|
|
40
|
+
| `canon teach list` | Report learning workspaces and the ordinal a new one takes, or what one workspace holds and what its records schedule (`--json`) |
|
|
41
41
|
| `canon teach open` | Open a workspace at the next ordinal with its mission, resources, and glossary files (`--json`) |
|
|
42
42
|
| `canon teach resource` | Record sources and leads in a workspace, repeating `--read` or `--lead` as `<title>=<url>` (`--json`) |
|
|
43
43
|
| `canon teach glossary` | Add terms to a workspace glossary alphabetically, repeating `--term <term>=<definition>` (`--json`) |
|
package/docs/agents/index.md
CHANGED
|
@@ -44,6 +44,6 @@ CLI catalog and invocation rules for agents, split by command domain. Start with
|
|
|
44
44
|
- [Superseded values](superseded.md): Reading where the tree still asserts a value a changed convention no longer produces, why the sweep keys on the value rather than the file, the family stem behind a templated citation, the exemption marker, the blind spots it cannot reach, and why it reports rather than gates
|
|
45
45
|
- [Targets](targets.md): The projects this toolkit installed into, the record the install writes against the sweep that backs it, what bounds each answer, and the cross-target pull request read
|
|
46
46
|
- [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
|
|
47
|
-
- [Teach](teach.md): Listing learning workspaces
|
|
47
|
+
- [Teach](teach.md): Listing learning workspaces with what their records schedule next, opening one with its required files, recording sources and glossary terms, resolving what the next lesson needs before it is written, rewriting the root listing, a contents page, and each lesson's chrome and quiz stepper from its marker regions, the refusal reasons, and why every write here runs through a verb
|
|
48
48
|
- [Test order](test-order.md): Reading where an implementation reached history ahead of its test, how a pair is decided, the three verdicts, the coverage the pairing cannot reach, and why the check reports rather than gates
|
|
49
49
|
- [Worktrees](worktrees.md): Reporting which worktrees are reclaimable, removing the ones that are, the record a hook reads back, why the reading keys on the pull request rather than on git ancestry, the refusals it names, and the two removal shapes
|
package/docs/agents/teach.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
title: Teach
|
|
3
|
-
description: Listing learning workspaces
|
|
3
|
+
description: Listing learning workspaces with what their records schedule next, opening one with its required files, recording sources and glossary terms, resolving what the next lesson needs before it is written, rewriting the root listing, a contents page, and each lesson's chrome and quiz stepper from its marker regions, the refusal reasons, and why every write here runs through a verb
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Teach
|
|
@@ -29,6 +29,12 @@ A folder not named `NN-<topic>` is still listed rather than dropped, since dropp
|
|
|
29
29
|
|
|
30
30
|
The listing also names the required files a workspace does not carry, which is `MISSION.md`, `RESOURCES.md`, and `GLOSSARY.md`. That is a report rather than a refusal, because a workspace missing one is still a workspace a session can resume.
|
|
31
31
|
|
|
32
|
+
Every record carries `due`, read from the `## Revisit` sections of the workspace's learning records and sorted soonest first. Each entry names the `item`, its `date`, the `rung` it has reached on the spacing ladder, `overdue` once that date has arrived or passed, and the `record` the surviving entry was written in. A later record supersedes an earlier entry for the same item, matched without regard to case, since the schedule is the state of one topic rather than a log of every time it was set.
|
|
33
|
+
|
|
34
|
+
Each entry also carries `hit` and `miss`, the two dates the next record could write. The ladder is 1, 3, 7, 16, and 35 days, a retrieval the learner passed unaided moving up a rung and a miss dropping back one, both held inside the ladder at either end. Reporting both dates is what lets a session copy one rather than compute it, on the same reasoning that put quiz option order in `canon teach lesson`.
|
|
35
|
+
|
|
36
|
+
The field rides on the listing rather than on `canon teach lesson`, because a session needs the schedule before it has chosen what to teach and that verb takes a `--slug` naming a choice already made. An entry not written in the shape the standard fixes is skipped rather than reported, since a note carrying no date has no date to schedule.
|
|
37
|
+
|
|
32
38
|
## Open
|
|
33
39
|
|
|
34
40
|
`canon teach open` creates a workspace at the next ordinal and writes all three required files.
|
|
@@ -152,6 +158,14 @@ A lesson carries its chrome as four marker pairs the authoring skill writes empt
|
|
|
152
158
|
|
|
153
159
|
A lesson missing one of the four marker pairs is refused by name rather than rewritten, and every other lesson in the run still rewrites. The record's `skipped` list carries the refused files and which marker each is missing.
|
|
154
160
|
|
|
161
|
+
The `style` region carries the quiz stepper after the workspace stylesheet, for every lesson but the one shape named three paragraphs down, so it wins the cascade at equal specificity and reaches a workspace seeded before it existed. The stepper is CSS over the radio inputs a quiz is written from: it hides every question that follows one holding no checked input, and hides a question's feedback until that question does. The general sibling combinator is what keeps that from depending on the markup contract holding, since it selects the same questions as the adjacent one for a quiz written correctly and goes on gating when anything sits between two of them, where the adjacent one matches nothing and shows them all at once. Gating there rather than in a script is what keeps a later stem from answering an earlier question with nothing to bind, and the whole block sits inside `@supports selector(:has(*))`, so an engine without `:has()` renders every question rather than a stepper showing nothing. Feedback there follows the workspace stylesheet, shown by one that says nothing about `.fb` and hidden by one grown under the retired script.
|
|
162
|
+
|
|
163
|
+
The block hides and shows and sets nothing else, which leaves a workspace's own layout for a question and its own appearance for an option and a feedback panel. The one value it names is `display: block` on revealed feedback, since every stylesheet grown under the retired script carries `.fb { display: none }` and nothing adds the class that used to reveal it.
|
|
164
|
+
|
|
165
|
+
One detector decides both regions. A lesson carrying `<button class="opt"`, the shape written before the stepper, takes the feedback script and no stepper, and every other lesson takes the stepper and no script. That is a detector rather than a mode, since a lesson has one shape or the other, and removing the script outright would leave the lessons already written showing no feedback at all.
|
|
166
|
+
|
|
167
|
+
Splicing the stepper into a lesson of either shape was measured against a browser and reverted. Its rules match a button question, which can never hold a checked radio, so a lesson given both hides every question past the first for good and outranks the class the script reveals feedback with. Nothing else caught that: the markup is valid, the splice succeeds, and the page renders.
|
|
168
|
+
|
|
155
169
|
## Opening a workspace
|
|
156
170
|
|
|
157
171
|
No `canon teach` verb serves the workspace. `canon serve` does, taking the teach root as its directory and the workspace contents page as its entry:
|
package/package.json
CHANGED
package/src/commands/teach.ts
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
type ReadOutcome,
|
|
12
12
|
readWorkspace,
|
|
13
13
|
recordSources,
|
|
14
|
+
type RevisitItem,
|
|
14
15
|
type Source,
|
|
15
16
|
type SourceOutcome,
|
|
16
17
|
type TeachRefused,
|
|
@@ -104,6 +105,11 @@ export function register(program: Command): void {
|
|
|
104
105
|
'With no topic it reports one line per workspace and the ordinal an',
|
|
105
106
|
'open would take. With one it reports the files behind each count.',
|
|
106
107
|
'',
|
|
108
|
+
'Every record carries due, what the learning records schedule, each',
|
|
109
|
+
'entry with its date, its rung, and the date to write on a hit and',
|
|
110
|
+
'on a miss, so a session picks the next lesson from the schedule',
|
|
111
|
+
'rather than from the last session alone.',
|
|
112
|
+
'',
|
|
107
113
|
'A workspace not named NN-<topic> is still listed. It sorts last and',
|
|
108
114
|
'moves no ordinal, since dropping it hides the folder needing a fix.',
|
|
109
115
|
'',
|
|
@@ -711,6 +717,17 @@ function reportRefusal(
|
|
|
711
717
|
return 1
|
|
712
718
|
}
|
|
713
719
|
|
|
720
|
+
/**
|
|
721
|
+
* One scheduled revisit, carrying both ladder dates rather than the rung alone.
|
|
722
|
+
* A session copies the date the outcome names instead of computing one from a
|
|
723
|
+
* ladder stated in prose.
|
|
724
|
+
*/
|
|
725
|
+
function describeRevisit(item: RevisitItem): string {
|
|
726
|
+
const when = item.overdue ? `due ${item.date}` : item.date
|
|
727
|
+
|
|
728
|
+
return `${item.item}: ${when}, rung ${item.rung} (hit ${item.hit}, miss ${item.miss}), from ${item.record}`
|
|
729
|
+
}
|
|
730
|
+
|
|
714
731
|
function describe(workspace: WorkspaceSummary): string {
|
|
715
732
|
return `${workspace.slug}: ${workspace.lessons} lesson(s), ${workspace.records} record(s), ${workspace.reference} reference page(s), ${workspace.terms} term(s)`
|
|
716
733
|
}
|
|
@@ -795,6 +812,9 @@ function reportWorkspace(
|
|
|
795
812
|
)
|
|
796
813
|
for (const line of workspace.success) logInfo(line)
|
|
797
814
|
|
|
815
|
+
logStep(workspace.due.length > 0 ? 'Revisit' : 'Revisit (nothing scheduled)')
|
|
816
|
+
for (const item of workspace.due) logInfo(describeRevisit(item))
|
|
817
|
+
|
|
798
818
|
if (workspace.missing.length > 0) {
|
|
799
819
|
logStep('Missing a required file')
|
|
800
820
|
logWarn(`no ${workspace.missing.join(' and no ')}`)
|
package/src/design/base.css
CHANGED
|
@@ -28,14 +28,22 @@
|
|
|
28
28
|
--space-frame-top: 44px;
|
|
29
29
|
--space-frame-inline: 52px;
|
|
30
30
|
--space-frame-bottom: 38px;
|
|
31
|
+
--type-display-family: Noto Sans Mono, DejaVu Sans Mono, monospace;
|
|
31
32
|
--type-display-size: 34px;
|
|
32
33
|
--type-display-lh: 1.3;
|
|
34
|
+
--type-page-display-family: Noto Sans, DejaVu Sans, sans-serif;
|
|
35
|
+
--type-page-display-size: 52px;
|
|
36
|
+
--type-page-display-lh: 1.1;
|
|
37
|
+
--type-heading-family: Noto Sans Mono, DejaVu Sans Mono, monospace;
|
|
33
38
|
--type-heading-size: 19px;
|
|
34
39
|
--type-heading-lh: 1.3;
|
|
40
|
+
--type-body-family: Noto Sans Mono, DejaVu Sans Mono, monospace;
|
|
35
41
|
--type-body-size: 16px;
|
|
36
42
|
--type-body-lh: 1.65;
|
|
43
|
+
--type-label-family: Noto Sans Mono, DejaVu Sans Mono, monospace;
|
|
37
44
|
--type-label-size: 12px;
|
|
38
45
|
--type-label-lh: 1.45;
|
|
46
|
+
--type-code-family: Noto Sans Mono, DejaVu Sans Mono, monospace;
|
|
39
47
|
--type-code-size: 14.5px;
|
|
40
48
|
--type-code-lh: 1.3;
|
|
41
49
|
--radius-frame: 12px;
|
package/src/design/css.ts
CHANGED
|
@@ -76,6 +76,7 @@ function tokenProperties(tokens: DesignTokens): string[] {
|
|
|
76
76
|
}
|
|
77
77
|
|
|
78
78
|
for (const role of tokens.typography) {
|
|
79
|
+
lines.push(` --type-${slug(role.role)}-family: ${role.family};`)
|
|
79
80
|
lines.push(` --type-${slug(role.role)}-size: ${role.size};`)
|
|
80
81
|
lines.push(` --type-${slug(role.role)}-lh: ${role.lineHeight};`)
|
|
81
82
|
}
|
package/src/design/tokens.ts
CHANGED
|
@@ -68,9 +68,21 @@ export interface DesignTokens {
|
|
|
68
68
|
readonly preamble: string
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
-
/** The
|
|
71
|
+
/** The monospace stack every surface but the landing page hero declares. */
|
|
72
72
|
const MONO = 'Noto Sans Mono, DejaVu Sans Mono, monospace'
|
|
73
73
|
|
|
74
|
+
/**
|
|
75
|
+
* The proportional stack the landing page hero declares and nothing else does.
|
|
76
|
+
* Noto Sans is the proportional sibling of the mono above rather than a face
|
|
77
|
+
* picked for taste, so the pairing is one superfamily rather than two systems.
|
|
78
|
+
*
|
|
79
|
+
* The stack stops at two names and a generic because `src/design/base.css` is
|
|
80
|
+
* written by `canon design regen` and formatted by prettier, and a declaration
|
|
81
|
+
* past 80 columns is wrapped by the second and flattened by the first. Every
|
|
82
|
+
* emitted line stays inside the width so the two writers never disagree.
|
|
83
|
+
*/
|
|
84
|
+
const SANS = 'Noto Sans, DejaVu Sans, sans-serif'
|
|
85
|
+
|
|
74
86
|
const DARK_GROUNDS = ['background', 'surface'] as const
|
|
75
87
|
const LIGHT_GROUNDS = ['light-background', 'light-surface'] as const
|
|
76
88
|
|
|
@@ -87,6 +99,8 @@ export const TOKENS: DesignTokens = {
|
|
|
87
99
|
|
|
88
100
|
personality: [
|
|
89
101
|
'Warm neutrals carry the frame under a single rust accent, rendered in the same monospace the terminal uses. The subject picks the register rather than taste: a toolkit whose primary surface is a shell has no proportional voice available, so the rendered surfaces match the terminal instead of the reverse. One accent carries every count, link, and primary action. Promoting a second and third into structural roles is what reads as a generated interface, so the palette stays at one.',
|
|
102
|
+
'',
|
|
103
|
+
'One surface is carved out of the sentence above, and it is the landing page hero. A rendered surface here shows a reader what the terminal did, so matching the terminal is what makes it legible. A public page addresses somebody who has never opened the terminal at all, and the shell has no voice available for that, which is the reverse of the case the rule was written for. The `page-display` role below is the whole of the carve-out. Every other role on that page stays monospace, including its body, its controls, and every frame it embeds, so the page reads as two families rather than as a second design system.',
|
|
90
104
|
].join('\n'),
|
|
91
105
|
|
|
92
106
|
colorNote: [
|
|
@@ -193,7 +207,9 @@ export const TOKENS: DesignTokens = {
|
|
|
193
207
|
],
|
|
194
208
|
|
|
195
209
|
typographyNote: [
|
|
196
|
-
'One family covers every role. The size scale runs from 11.5 to
|
|
210
|
+
'One family covers every role but `page-display`, which is the landing page hero and takes the proportional sibling of the same superfamily. The size scale runs from 11.5 to 52 pixels, and six values map onto a role. Five further values are adjustments inside a single component and get no role here, since a scale with five invented steps reads as a system the surfaces do not implement. They are 11.5, 12.5, 13, 14, and 15 pixels.',
|
|
211
|
+
'',
|
|
212
|
+
'The 52 pixel step sits above the 34 the rest of the scale tops out at, and it is the one size no other surface reaches. A hero headline set at the display cap reads as a section heading rather than an opening, which four rendered arms measured on 2026-09-04 before the step was added.',
|
|
197
213
|
'',
|
|
198
214
|
'A tagged cell is one no rendering surface exercises yet, which is a declaration the system has not tested rather than one it has.',
|
|
199
215
|
'',
|
|
@@ -208,6 +224,14 @@ export const TOKENS: DesignTokens = {
|
|
|
208
224
|
size: '34px',
|
|
209
225
|
lineHeight: '1.3',
|
|
210
226
|
},
|
|
227
|
+
{
|
|
228
|
+
role: 'page-display',
|
|
229
|
+
family: SANS,
|
|
230
|
+
weight: '700',
|
|
231
|
+
size: '52px',
|
|
232
|
+
lineHeight: '1.1',
|
|
233
|
+
verify: ['family', 'size', 'lineHeight'],
|
|
234
|
+
},
|
|
211
235
|
{
|
|
212
236
|
role: 'heading',
|
|
213
237
|
family: MONO,
|
package/src/tasks/archive.ts
CHANGED
|
@@ -400,7 +400,8 @@ export async function planCitations(
|
|
|
400
400
|
|
|
401
401
|
const stems = await listTaskStems(dir)
|
|
402
402
|
if (!stems.includes(stem)) {
|
|
403
|
-
|
|
403
|
+
const unmatched = describeUnmatchedStem(stems, stem)
|
|
404
|
+
return refuse(unmatched.reason, unmatched.message, unmatched.detail)
|
|
404
405
|
}
|
|
405
406
|
|
|
406
407
|
const target = readPlanTarget(await readFile(join(dir, `${stem}.md`), 'utf8'))
|
|
@@ -488,6 +489,45 @@ function refuse(
|
|
|
488
489
|
return { ok: false, reason, message, detail }
|
|
489
490
|
}
|
|
490
491
|
|
|
492
|
+
export interface UnmatchedStem {
|
|
493
|
+
readonly reason: 'no-match' | 'ambiguous'
|
|
494
|
+
readonly message: string
|
|
495
|
+
readonly detail: readonly string[]
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* The three-way refusal a bare stem earns once it fails an exact match:
|
|
500
|
+
* exactly one task starts with it, several do, or none does. `archive.ts`'s
|
|
501
|
+
* own `resolveStem`, `planCitations`, and `record.ts`'s `resolveStem` all
|
|
502
|
+
* refuse a stem this way, so the wording lives once rather than three times.
|
|
503
|
+
*/
|
|
504
|
+
export function describeUnmatchedStem(
|
|
505
|
+
stems: readonly string[],
|
|
506
|
+
name: string,
|
|
507
|
+
): UnmatchedStem {
|
|
508
|
+
const prefixed = stems.filter((stem) => stem.startsWith(name))
|
|
509
|
+
if (prefixed.length === 1) {
|
|
510
|
+
const [match] = prefixed
|
|
511
|
+
return {
|
|
512
|
+
reason: 'no-match',
|
|
513
|
+
message: `${name} does not name a task by itself. One task starts with it: ${match}. Pass the full name instead.`,
|
|
514
|
+
detail: [match],
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
if (prefixed.length > 1) {
|
|
518
|
+
return {
|
|
519
|
+
reason: 'ambiguous',
|
|
520
|
+
message: `${name} does not name a task by itself. ${prefixed.length} tasks start with it. Pass the full name instead.`,
|
|
521
|
+
detail: prefixed,
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
return {
|
|
525
|
+
reason: 'no-match',
|
|
526
|
+
message: `No task named ${name} on the board.`,
|
|
527
|
+
detail: stems,
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
491
531
|
async function resolveStem(
|
|
492
532
|
dir: string,
|
|
493
533
|
selector: TaskSelector,
|
|
@@ -496,27 +536,11 @@ async function resolveStem(
|
|
|
496
536
|
|
|
497
537
|
if (selector.kind === 'stem') {
|
|
498
538
|
if (!stems.includes(selector.stem)) {
|
|
499
|
-
const
|
|
500
|
-
if (prefixed.length === 1) {
|
|
501
|
-
const [match] = prefixed
|
|
502
|
-
return refuse(
|
|
503
|
-
'no-match',
|
|
504
|
-
`${selector.stem} does not name a task by itself. One task starts with it: ${match}. Pass the full name to archive it.`,
|
|
505
|
-
[match],
|
|
506
|
-
)
|
|
507
|
-
}
|
|
508
|
-
if (prefixed.length > 1) {
|
|
509
|
-
return refuse(
|
|
510
|
-
'ambiguous',
|
|
511
|
-
`${selector.stem} does not name a task by itself. ${prefixed.length} tasks start with it. Pass the full name to archive one.`,
|
|
512
|
-
prefixed,
|
|
513
|
-
)
|
|
514
|
-
}
|
|
515
|
-
return refuse(
|
|
516
|
-
'no-match',
|
|
517
|
-
`No task named ${selector.stem} on the board.`,
|
|
539
|
+
const { reason, message, detail } = describeUnmatchedStem(
|
|
518
540
|
stems,
|
|
541
|
+
selector.stem,
|
|
519
542
|
)
|
|
543
|
+
return refuse(reason, message, detail)
|
|
520
544
|
}
|
|
521
545
|
return selector.stem
|
|
522
546
|
}
|
package/src/tasks/record.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { readFile, writeFile } from 'node:fs/promises'
|
|
|
3
3
|
import { basename, join, relative } from 'node:path'
|
|
4
4
|
import { planCandidates } from '@/tasks/answers'
|
|
5
5
|
import {
|
|
6
|
+
describeUnmatchedStem,
|
|
6
7
|
fenceMask,
|
|
7
8
|
linkTo,
|
|
8
9
|
listTaskStems,
|
|
@@ -241,11 +242,11 @@ async function resolveStem(
|
|
|
241
242
|
|
|
242
243
|
if (selector.kind === 'stem') {
|
|
243
244
|
if (!stems.includes(selector.stem)) {
|
|
244
|
-
|
|
245
|
-
'no-match',
|
|
246
|
-
`No task named ${selector.stem} on the board.`,
|
|
245
|
+
const { reason, message, detail } = describeUnmatchedStem(
|
|
247
246
|
stems,
|
|
247
|
+
selector.stem,
|
|
248
248
|
)
|
|
249
|
+
return refuse(reason, message, detail)
|
|
249
250
|
}
|
|
250
251
|
return selector.stem
|
|
251
252
|
}
|
package/src/teach/nav.ts
CHANGED
|
@@ -50,6 +50,55 @@ const CLOSE_OUTSIDE_CLICK_SCRIPT = `<script>
|
|
|
50
50
|
})();
|
|
51
51
|
</script>`
|
|
52
52
|
|
|
53
|
+
/**
|
|
54
|
+
* The stepper, as CSS over the radio inputs a lesson's quiz is written from.
|
|
55
|
+
*
|
|
56
|
+
* A quiz showing every question at once lets a later stem answer an earlier
|
|
57
|
+
* question, which is a leak no wording of the questions closes. Gating on
|
|
58
|
+
* `:has()` rather than on a script keeps the page working with nothing to bind,
|
|
59
|
+
* and the radio input is what records an answer with no handler in the loop.
|
|
60
|
+
*
|
|
61
|
+
* The general sibling combinator is what keeps the gate from depending on the
|
|
62
|
+
* markup contract holding. It selects the same questions as `+` for a quiz
|
|
63
|
+
* written correctly, and it goes on gating when anything sits between two of
|
|
64
|
+
* them, where `+` matches nothing and shows every later question at once. That
|
|
65
|
+
* is the leak this block exists to close, arriving through a shape nothing
|
|
66
|
+
* rejects.
|
|
67
|
+
*
|
|
68
|
+
* The two hide rules select the unanswered state alone, so a workspace's own
|
|
69
|
+
* `display` for a question survives being stepped through. Feedback has to be
|
|
70
|
+
* shown by a rule naming a value, because every stylesheet grown under the
|
|
71
|
+
* retired script carries `.fb { display: none }` and nothing adds the class
|
|
72
|
+
* that revealed it. Appearance stays the workspace's: this block hides and
|
|
73
|
+
* shows and sets nothing else.
|
|
74
|
+
*
|
|
75
|
+
* `@supports` wraps every rule, so an engine without `:has()` renders every
|
|
76
|
+
* question rather than a stepper showing nothing. Feedback there follows the
|
|
77
|
+
* workspace: a fresh stylesheet shows it and one grown under the retired script
|
|
78
|
+
* keeps it hidden. Showing it unconditionally outside the wrapper was measured
|
|
79
|
+
* and reverted, since that rule outranks `.fb { display: none }` on every
|
|
80
|
+
* engine and would reveal the feedback in the button-shape lessons the script
|
|
81
|
+
* still drives.
|
|
82
|
+
*/
|
|
83
|
+
const QUIZ_CSS = `@supports selector(:has(*)) {
|
|
84
|
+
.quiz .q:not(:has(input[type="radio"]:checked)) ~ .q { display: none; }
|
|
85
|
+
.quiz .q:not(:has(input[type="radio"]:checked)) .fb { display: none; }
|
|
86
|
+
.quiz .q:has(input[type="radio"]:checked) > .fb { display: block; }
|
|
87
|
+
}`
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The button shape a lesson written before the stepper carries. It is a
|
|
91
|
+
* detector rather than a mode: a lesson has one shape or the other, and which
|
|
92
|
+
* one it has decides both the style and the scripts region.
|
|
93
|
+
*/
|
|
94
|
+
const LEGACY_OPTION = '<button class="opt"'
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* What reveals feedback in a lesson written against `LEGACY_OPTION`. The radio
|
|
98
|
+
* shape needs none of it, so a lesson carrying no button option gets no script,
|
|
99
|
+
* and removing this outright would leave the lessons already written showing no
|
|
100
|
+
* feedback at all.
|
|
101
|
+
*/
|
|
53
102
|
const QUIZ_SCRIPT =
|
|
54
103
|
'<script>document.querySelectorAll(".q").forEach(function(q){var f=q.querySelector(".fb");q.querySelectorAll(".opt").forEach(function(b){b.addEventListener("click",function(){if(f.classList.contains("show"))return;q.querySelectorAll(".opt").forEach(function(o){o.dataset.state=o.dataset.a==="1"?"right":(o===b?"chosen":"wrong");});f.classList.add("show");});});});</script>'
|
|
55
104
|
|
|
@@ -662,11 +711,20 @@ async function rewriteLesson(
|
|
|
662
711
|
metas.map<TrackState>((_, i) => (i === index ? 'here' : 'done')),
|
|
663
712
|
)
|
|
664
713
|
|
|
714
|
+
// One detector decides both mechanisms, because a lesson carries one quiz
|
|
715
|
+
// shape or the other and the two cannot share a page. The stepper's rules
|
|
716
|
+
// match a button question that can never hold a checked radio, so a lesson
|
|
717
|
+
// given both would hide every question past the first for good and outrank
|
|
718
|
+
// the class the script reveals feedback with.
|
|
719
|
+
const legacy = html.includes(LEGACY_OPTION)
|
|
720
|
+
|
|
665
721
|
const regions: ReadonlyArray<readonly [Region, string]> = [
|
|
666
|
-
|
|
722
|
+
// The stepper follows the workspace stylesheet so it wins the cascade at
|
|
723
|
+
// equal specificity, which is what reaches a workspace seeded before it.
|
|
724
|
+
['style', `<style>\n${legacy ? css : `${css}\n${QUIZ_CSS}`}\n</style>`],
|
|
667
725
|
['header', header],
|
|
668
726
|
['footnav', renderFootNav(metas, index)],
|
|
669
|
-
['scripts', renderScripts(
|
|
727
|
+
['scripts', renderScripts(legacy, false)],
|
|
670
728
|
]
|
|
671
729
|
|
|
672
730
|
for (const [region, content] of regions) {
|
package/src/teach/workspace.ts
CHANGED
|
@@ -75,6 +75,44 @@ const ORDINAL_WIDTH = 2
|
|
|
75
75
|
|
|
76
76
|
const DATE_LENGTH = 'YYYY-MM-DD'.length
|
|
77
77
|
|
|
78
|
+
const MS_PER_DAY = 24 * 60 * 60 * 1000
|
|
79
|
+
|
|
80
|
+
/** The heading a learning record schedules its revisits under. */
|
|
81
|
+
const REVISIT_HEADING = '## Revisit'
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The gaps a revisit item moves through, in days. A rung is a one-based index
|
|
85
|
+
* into this list, widening after a retrieval the learner passed unaided and
|
|
86
|
+
* narrowing after a miss, which is the spacing rule the pedagogy reference
|
|
87
|
+
* states. The values are here rather than in a skill body because a session
|
|
88
|
+
* told to widen a gap still picks the number by judgment.
|
|
89
|
+
*/
|
|
90
|
+
const REVISIT_LADDER = [1, 3, 7, 16, 35] as const
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* One scheduled entry, as a learning record spells it. The rung is carried in
|
|
94
|
+
* the record rather than derived from how many records name the item, since a
|
|
95
|
+
* miss drops it back and a count only ever climbs.
|
|
96
|
+
*/
|
|
97
|
+
const REVISIT_ENTRY =
|
|
98
|
+
/^-\s+\*\*(.+?)\*\*:\s*due\s+(\d{4}-\d{2}-\d{2}),\s*rung\s+([1-9]\d*)\s*$/
|
|
99
|
+
|
|
100
|
+
export interface RevisitItem {
|
|
101
|
+
/** What comes back up, as the record names it. */
|
|
102
|
+
readonly item: string
|
|
103
|
+
/** The day it comes back up, as `YYYY-MM-DD`. */
|
|
104
|
+
readonly date: string
|
|
105
|
+
readonly rung: number
|
|
106
|
+
/** True once that day has arrived or passed. */
|
|
107
|
+
readonly overdue: boolean
|
|
108
|
+
/** The date to schedule when the learner retrieves it unaided. */
|
|
109
|
+
readonly hit: string
|
|
110
|
+
/** The date to schedule when they miss it. */
|
|
111
|
+
readonly miss: string
|
|
112
|
+
/** The record the surviving entry was written in. */
|
|
113
|
+
readonly record: string
|
|
114
|
+
}
|
|
115
|
+
|
|
78
116
|
export interface WorkspaceSummary {
|
|
79
117
|
readonly slug: string
|
|
80
118
|
/** `NaN` when the folder name carries no ordinal, which a listing reports. */
|
|
@@ -99,6 +137,12 @@ export interface WorkspaceDetail extends WorkspaceSummary {
|
|
|
99
137
|
readonly glossary: readonly string[]
|
|
100
138
|
/** The mission's success lines, which a session reports progress against. */
|
|
101
139
|
readonly success: readonly string[]
|
|
140
|
+
/**
|
|
141
|
+
* What the learning records schedule, soonest first. A session reads this
|
|
142
|
+
* before it has chosen what to teach, which is why it rides on the listing
|
|
143
|
+
* rather than on the verb that resolves a lesson already picked.
|
|
144
|
+
*/
|
|
145
|
+
readonly due: readonly RevisitItem[]
|
|
102
146
|
}
|
|
103
147
|
|
|
104
148
|
export interface WorkspacesListed {
|
|
@@ -242,6 +286,103 @@ function successLines(text: string): string[] {
|
|
|
242
286
|
)
|
|
243
287
|
}
|
|
244
288
|
|
|
289
|
+
/**
|
|
290
|
+
* Whether a `YYYY-MM-DD` string names a real day.
|
|
291
|
+
*
|
|
292
|
+
* The entry pattern admits `2026-13-45`, since it counts digits rather than
|
|
293
|
+
* reading a calendar, and `addDays` throws on a date that names no day, so a
|
|
294
|
+
* single mistyped record would take down every listing that reads the
|
|
295
|
+
* workspace. Every date reaching `addDays` passes here first.
|
|
296
|
+
*/
|
|
297
|
+
function isDay(date: string): boolean {
|
|
298
|
+
return !Number.isNaN(Date.parse(`${date}T00:00:00Z`))
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** A day offset from a real `YYYY-MM-DD` date, back out in the same form. */
|
|
302
|
+
function addDays(date: string, days: number): string {
|
|
303
|
+
const start = Date.parse(`${date}T00:00:00Z`)
|
|
304
|
+
return new Date(start + days * MS_PER_DAY).toISOString().slice(0, DATE_LENGTH)
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** A rung moved by one step, held inside the ladder at both ends. */
|
|
308
|
+
function stepRung(rung: number, step: number): number {
|
|
309
|
+
return Math.min(REVISIT_LADDER.length, Math.max(1, rung + step))
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function gapFor(rung: number, step: number): number {
|
|
313
|
+
return REVISIT_LADDER[stepRung(rung, step) - 1]
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
export interface RecordText {
|
|
317
|
+
readonly file: string
|
|
318
|
+
readonly text: string
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Every revisit a workspace's learning records schedule, soonest first.
|
|
323
|
+
*
|
|
324
|
+
* Records arrive in read order and a later one supersedes an earlier entry for
|
|
325
|
+
* the same item, since the schedule is the state of one topic rather than a log
|
|
326
|
+
* of every time it was set. Matching is case-insensitive on the item, so a
|
|
327
|
+
* session that capitalized differently in a later record still supersedes
|
|
328
|
+
* rather than opening a second schedule beside the first.
|
|
329
|
+
*
|
|
330
|
+
* An entry not in the recorded shape is skipped rather than reported. A note
|
|
331
|
+
* carrying no date schedules nothing, which is what the standard's anti-pattern
|
|
332
|
+
* names, and this reader has no date to put on it. A date naming no real day is
|
|
333
|
+
* skipped on the same footing, since a schedule nothing can order is a note.
|
|
334
|
+
*/
|
|
335
|
+
export function readRevisits(
|
|
336
|
+
records: readonly RecordText[],
|
|
337
|
+
today: string,
|
|
338
|
+
): RevisitItem[] {
|
|
339
|
+
if (!isDay(today)) return []
|
|
340
|
+
|
|
341
|
+
const scheduled = new Map<
|
|
342
|
+
string,
|
|
343
|
+
{ item: string; date: string; rung: number; record: string }
|
|
344
|
+
>()
|
|
345
|
+
|
|
346
|
+
for (const record of records) {
|
|
347
|
+
const lines = record.text.split('\n')
|
|
348
|
+
const section = sectionRange(
|
|
349
|
+
unfenced(record.text),
|
|
350
|
+
REVISIT_HEADING,
|
|
351
|
+
lines.length,
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
if (!section) continue
|
|
355
|
+
|
|
356
|
+
for (const block of bulletBlocks(lines.slice(section.start, section.end))) {
|
|
357
|
+
const match = REVISIT_ENTRY.exec(block.join(' ').replace(/\s+/g, ' '))
|
|
358
|
+
if (!match || !isDay(match[2])) continue
|
|
359
|
+
|
|
360
|
+
scheduled.set(match[1].toLowerCase(), {
|
|
361
|
+
item: match[1],
|
|
362
|
+
date: match[2],
|
|
363
|
+
rung: Number(match[3]),
|
|
364
|
+
record: record.file,
|
|
365
|
+
})
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
return [...scheduled.values()]
|
|
370
|
+
.map((entry) => ({
|
|
371
|
+
item: entry.item,
|
|
372
|
+
date: entry.date,
|
|
373
|
+
rung: entry.rung,
|
|
374
|
+
overdue: entry.date <= today,
|
|
375
|
+
hit: addDays(today, gapFor(entry.rung, 1)),
|
|
376
|
+
miss: addDays(today, gapFor(entry.rung, -1)),
|
|
377
|
+
record: entry.record,
|
|
378
|
+
}))
|
|
379
|
+
.sort(
|
|
380
|
+
(left, right) =>
|
|
381
|
+
left.date.localeCompare(right.date) ||
|
|
382
|
+
left.item.localeCompare(right.item),
|
|
383
|
+
)
|
|
384
|
+
}
|
|
385
|
+
|
|
245
386
|
/** The ordinal a folder name carries, or `NaN` when it carries none. */
|
|
246
387
|
function ordinalOf(slug: string): number {
|
|
247
388
|
const match = WORKSPACE_NAME.exec(slug)
|
|
@@ -272,6 +413,15 @@ async function summarize(
|
|
|
272
413
|
filesIn(dir, TEACH_REFERENCE, '.md'),
|
|
273
414
|
])
|
|
274
415
|
|
|
416
|
+
// Read order is filename order, which is record order, so a later record
|
|
417
|
+
// supersedes an earlier schedule for the same item.
|
|
418
|
+
const records = await Promise.all(
|
|
419
|
+
recordFiles.map(async (file) => ({
|
|
420
|
+
file,
|
|
421
|
+
text: await readFile(join(dir, TEACH_RECORDS, file), 'utf8'),
|
|
422
|
+
})),
|
|
423
|
+
)
|
|
424
|
+
|
|
275
425
|
return {
|
|
276
426
|
slug,
|
|
277
427
|
ordinal: match ? Number(match[1]) : Number.NaN,
|
|
@@ -291,6 +441,7 @@ async function summarize(
|
|
|
291
441
|
referenceFiles,
|
|
292
442
|
glossary,
|
|
293
443
|
success: mission === undefined ? [] : successLines(mission),
|
|
444
|
+
due: readRevisits(records, today()),
|
|
294
445
|
}
|
|
295
446
|
}
|
|
296
447
|
|
package/standards/teach.md
CHANGED
|
@@ -90,11 +90,27 @@ One record per session the learner worked through, holding what happened rather
|
|
|
90
90
|
|
|
91
91
|
- The lessons covered, by number
|
|
92
92
|
- What the learner retrieved correctly without help
|
|
93
|
-
- What they got wrong, and what the wrong answer was
|
|
94
|
-
-
|
|
93
|
+
- What they got wrong, and what the wrong answer was, including what an explanation the learner produced left out
|
|
94
|
+
- A `## Revisit` section scheduling what comes back up
|
|
95
95
|
|
|
96
96
|
Record the wrong answer rather than the fact of an error. A wrong answer names the misconception, and the count alone names nothing.
|
|
97
97
|
|
|
98
|
+
A produced explanation and a selected option are both retrieval, so a gap in an explanation goes under what the learner got wrong rather than under a heading of its own. Name the concept and where the explanation broke. Splitting the two puts one input to the next session's placement across two sections, and the placement reads both or neither.
|
|
99
|
+
|
|
100
|
+
### The revisit section
|
|
101
|
+
|
|
102
|
+
Write one bullet per item, in this shape and no other:
|
|
103
|
+
|
|
104
|
+
```markdown
|
|
105
|
+
## Revisit
|
|
106
|
+
|
|
107
|
+
- **<what comes back up>**: due <YYYY-MM-DD>, rung <n>
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
The rung is where the item sits on the spacing ladder, and it travels in the record rather than being counted from how many records name the item, since a miss drops it back and a count only climbs. A later record naming the same item supersedes an earlier one, so revise the schedule by writing the new entry rather than editing the old record.
|
|
111
|
+
|
|
112
|
+
Take the date and the rung from what the surface driving the workspace reports rather than picking either. An entry outside this shape schedules nothing, and nothing reports that it was skipped.
|
|
113
|
+
|
|
98
114
|
## GLOSSARY.md
|
|
99
115
|
|
|
100
116
|
Required in every workspace, holding one entry per term the subject defines. The glossary reference the `canon:claude-teach` skill carries fixes what an entry looks like, how the file orders and groups them, and which terms it carries, so this standard states only that the file exists and sits at the workspace root. That reference ships with the plugin rather than installing here, because a promoted glossary keeps its shape wherever it lands and no project folder covers every destination. Say so and stop rather than working the shape from memory when the project has no plugin to read it from.
|
|
@@ -122,6 +138,7 @@ A source found and not read is listed as a lead because a link attached to a pag
|
|
|
122
138
|
- **The mission with no test.** Success stated as understanding rather than as a task leaves nothing able to say the mission is finished.
|
|
123
139
|
- **The lesson filed as reference.** A page written to be worked through once sits in `lessons/`, and putting it in `reference/` puts disposable output in the half that gets promoted.
|
|
124
140
|
- **The record that counts errors.** A tally of how many were missed carries none of the misconception, which is the only part a later session can act on.
|
|
141
|
+
- **The revisit note with no date.** A line naming what to come back to and not when is read by nothing and schedules nothing, so the next session picks from the last record alone, which is the state the section exists to end.
|
|
125
142
|
- **The date left in the body.** A frontmatter field and a sentence both claiming the opening date resolve to whichever a reader happens to hit, and only one of them is readable by a walker.
|
|
126
143
|
- **The renumbered folder.** Closing a gap in the ordinals moves every name a reader or a record already cited.
|
|
127
144
|
|