@erclx/canon 4.48.0 → 4.49.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-autoship/REQUIREMENT.md +1 -1
- package/claude/skills/claude-autoship/SKILL.md +1 -1
- package/claude/skills/claude-docs/SKILL.md +7 -7
- package/claude/skills/claude-memory-capture/SKILL.md +12 -2
- 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/claude/skills/claude-teach/references/promotion.md +5 -5
- package/claude/skills/migration-claude-md/SKILL.md +2 -1
- package/claude/skills/migration-context/SKILL.md +1 -1
- package/docs/agents/audits.md +12 -8
- package/docs/agents/commands.md +2 -2
- package/docs/agents/gate.md +6 -1
- package/docs/agents/index.md +1 -1
- package/docs/agents/teach.md +15 -1
- package/package.json +1 -1
- package/scripts/tooling/verify.sh +5 -2
- package/src/audits/catalog.ts +4 -0
- package/src/audits/run.ts +26 -2
- package/src/commands/audits.ts +38 -2
- package/src/commands/gate.ts +3 -1
- package/src/commands/teach.ts +20 -0
- package/src/deps/audit.ts +27 -1
- package/src/gate/measures.ts +18 -5
- package/src/gate/sequencer.ts +13 -0
- package/src/tasks/archive.ts +16 -0
- package/src/teach/nav.ts +60 -2
- package/src/teach/workspace.ts +151 -0
- package/standards/teach.md +19 -2
- package/standards/wireframes.md +10 -2
|
@@ -38,7 +38,7 @@ Review is the step that varies most. It gets skipped on a diff that needed one,
|
|
|
38
38
|
## Guards
|
|
39
39
|
|
|
40
40
|
- Detached HEAD: stop, no slug resolves
|
|
41
|
-
- No approved plan at the branch
|
|
41
|
+
- No approved plan at the branch-derived plan path: stop, name the branch the slug came from, and say the invocation carried no argument, so a plan or a task path may be passed
|
|
42
42
|
- Uncommitted changes unrelated to the plan: stop
|
|
43
43
|
- No diff baseline against main: stop
|
|
44
44
|
- Empty changed-file list: stop, and never advise removing the output from `.gitignore` to get past it
|
|
@@ -47,7 +47,7 @@ Resolve `<plan>` in this order, stopping at the first match:
|
|
|
47
47
|
|
|
48
48
|
1. **Caller-supplied task.** The invocation carried a path under `.canon/tasks/`. If it does not resolve to a file, stop: `❌ No task at <path>. Path was supplied, not derived, so check it and re-run.` Read that task's first `Plan:` line and take what it names as `<plan>`, per `${CLAUDE_SKILL_DIR}/../../standards/tasks.md`.
|
|
49
49
|
2. **Caller-supplied plan.** The invocation carried something else. Accept it as a plan path or a bare slug, in the same position `claude-worktree` tier 0 accepts its name. A bare slug resolves to `.canon/plans/feature-<slug>.md`, and a path is taken as given from the main worktree root. If it does not resolve to a file, stop: `❌ No plan at <path>. Path was supplied, not derived, so check it and re-run.`
|
|
50
|
-
3. **Derived.** `.canon/plans/feature-<slug>.md`, from the `<slug>` the Guards derived. If it does not exist, stop: `❌ No approved plan at .canon/plans/feature-<slug>.md
|
|
50
|
+
3. **Derived.** `.canon/plans/feature-<slug>.md`, from the `<slug>` the Guards derived. If it does not exist, stop: `❌ No approved plan at .canon/plans/feature-<slug>.md, where <slug> was derived from the current branch, <branch>. The invocation carried no argument, so pass a plan or a task path, or run /claude-feature first.`
|
|
51
51
|
|
|
52
52
|
Only a path reaches tier 1, and a bare slug is read as a plan's throughout. The two would collide on any similar name, and a caller who means the task holds its path already, having read it off the board. One plan per task is what makes the tier 1 read unambiguous, so it takes the first `Plan:` line and never scans for a second.
|
|
53
53
|
|
|
@@ -148,7 +148,7 @@ Read `.claude/context/index.md` at `pwd` to see which domain entries exist. Skip
|
|
|
148
148
|
|
|
149
149
|
Two sources feed this step, the same split Step 2 runs on. The diff carries what the repository changed. The routed facts carry what the session learned, which a diff cannot show.
|
|
150
150
|
|
|
151
|
-
**Routed facts.** Derive `<slug>` per `${CLAUDE_SKILL_DIR}/../../standards/slug.md`, falling back to `latest` on an empty result, and read `.canon/tmp/memory-routing/<slug>.md` at the main worktree root. `claude-memory-capture` writes it, one H2 per target entry naming the path, with the fact underneath. Fold each fact into the entry its heading names,
|
|
151
|
+
**Routed facts.** Derive `<slug>` per `${CLAUDE_SKILL_DIR}/../../standards/slug.md`, falling back to `latest` on an empty result, and read `.canon/tmp/memory-routing/<slug>.md` at the main worktree root. `claude-memory-capture` writes it, one H2 per target entry naming the path, with the fact underneath. Fold each fact into the entry its heading names, which for a nested `.claude/context/<domain>/index.md` heading is the sibling file the fact belongs under rather than the generated index itself. Then delete the handoff file so a later run does not fold it twice.
|
|
152
152
|
|
|
153
153
|
This half is not diff-scoped and must not be. A gotcha a session hit while working is exactly the fact the diff never shows, and scoping it to changed files would drop the entries worth keeping. The handoff is a named input rather than a scan, so the reach stays bounded to what capture decided.
|
|
154
154
|
|
|
@@ -156,10 +156,10 @@ Skip this half silently when the file is absent, which is every run where nothin
|
|
|
156
156
|
|
|
157
157
|
**The diff.** When the baseline is unusable, scope this half to the working tree and untracked files, and skip it only when that set is empty, reporting `⚠ No diff to scope against. Skipped the context refresh.` The routed half still runs, since it reads a file rather than a diff.
|
|
158
158
|
|
|
159
|
-
Reuse the diff from the baseline above, names and content both. For each
|
|
159
|
+
Reuse the diff from the baseline above, names and content both. For each domain listed in `.claude/context/index.md`, read its own entry: a flat `.claude/context/<domain>.md`, or, for a domain split into a folder, its `.claude/context/<domain>/index.md` and every sibling file that index links. Follow the index rather than globbing the folder, since a folder can hold a file the index does not list yet.
|
|
160
160
|
|
|
161
|
-
- Map the entry's section headings to the changed files. An entry is relevant when its prose references files, modules, or decisions touched by the diff.
|
|
162
|
-
- For each relevant entry, rewrite only the sections affected by the diff. Same pattern as `docs-sync`. Do not touch unrelated sections.
|
|
161
|
+
- Map the entry's section headings, whether they sit in one flat file or spread across a nested domain's sibling files, to the changed files. An entry is relevant when its prose references files, modules, or decisions touched by the diff.
|
|
162
|
+
- For each relevant entry, rewrite only the sections affected by the diff. Same pattern as `docs-sync`. Do not touch unrelated sections. Never rewrite a split domain's own `index.md` directly, since a regen overwrites it the same way it overwrites the top-level catalog. Rewrite the sibling file the affected section actually lives in instead.
|
|
163
163
|
- Write a reference to another entry as the path that entry sits at, rather than as its bare filename. `${CLAUDE_SKILL_DIR}/../../standards/context.md` states the form, and a bare name strands the reference once a domain splits into subfolders.
|
|
164
164
|
|
|
165
165
|
### When the diff removes a capability
|
|
@@ -173,11 +173,11 @@ Grep the tree for the name that went, rather than for the paths the diff carries
|
|
|
173
173
|
|
|
174
174
|
Report each hit as an ordinary rewrite.
|
|
175
175
|
|
|
176
|
-
Do not create new entries automatically. New entries are a deliberate decision: the user invokes `claude-docs --new-context <domain>` (future flag) or hand-creates the file following `${CLAUDE_SKILL_DIR}/../../standards/context.md`. Auto-creation risks padding `.claude/context/` with low-signal entries.
|
|
176
|
+
Do not create new entries automatically. Before treating a domain as new, confirm it holds no entry under either spelling, `.claude/context/<domain>.md` or `.claude/context/<domain>/index.md`, since a domain already split into a folder still passes a check that only looked for the flat file. New entries are a deliberate decision: the user invokes `claude-docs --new-context <domain>` (future flag) or hand-creates the file following `${CLAUDE_SKILL_DIR}/../../standards/context.md`. Auto-creation risks padding `.claude/context/` with low-signal entries.
|
|
177
177
|
|
|
178
|
-
Write each updated entry immediately. Output one line per file:
|
|
178
|
+
Write each updated entry immediately. Output one line per file, naming the path this run actually wrote rather than always the flat template:
|
|
179
179
|
|
|
180
|
-
`✅ Context: .claude/context/<domain>.md`
|
|
180
|
+
`✅ Context: .claude/context/<domain>.md` for a flat entry, or `✅ Context: .claude/context/<domain>/<sub-area>.md` for the sibling file a nested edit landed in
|
|
181
181
|
|
|
182
182
|
Add a line naming the handoff when one was consumed:
|
|
183
183
|
|
|
@@ -44,7 +44,9 @@ For each project candidate, match its subject against `.claude/context/index.md`
|
|
|
44
44
|
|
|
45
45
|
Fail closed. A project candidate matching no entry stays a memory file, and so does one matching two entries where neither is clearly the owner. The residue is what the folder is for, and a fact filed under the wrong entry is worse than one in memory because a context entry is a surface sessions trust.
|
|
46
46
|
|
|
47
|
-
Do not edit a context entry here. `claude-docs` owns those edits and folds the routed facts in on its own pass, or two skills write one file at the same step. Write each routed fact to `.canon/tmp/memory-routing/<slug>.md` at the main worktree root instead, appending when the file exists. An append is a whole-file operation the shell does directly, so send it as a plain single `Bash` command carrying a heredoc:
|
|
47
|
+
Do not edit a context entry here. `claude-docs` owns those edits and folds the routed facts in on its own pass, or two skills write one file at the same step. Write each routed fact to `.canon/tmp/memory-routing/<slug>.md` at the main worktree root instead, appending when the file exists. Name the heading with the entry's own path from `.claude/context/index.md`, flat or the nested `index.md`, since that heading is what tells `claude-docs`'s routed-facts fold which file to open. An append is a whole-file operation the shell does directly, so send it as a plain single `Bash` command carrying a heredoc:
|
|
48
|
+
|
|
49
|
+
A flat domain takes:
|
|
48
50
|
|
|
49
51
|
```markdown
|
|
50
52
|
## .claude/context/<domain>.md
|
|
@@ -52,6 +54,14 @@ Do not edit a context entry here. `claude-docs` owns those edits and folds the r
|
|
|
52
54
|
<the fact in one or two sentences, stated as a fact about the domain rather than as a session narrative>
|
|
53
55
|
```
|
|
54
56
|
|
|
57
|
+
A domain split into a folder takes its own generated index instead:
|
|
58
|
+
|
|
59
|
+
```markdown
|
|
60
|
+
## .claude/context/<domain>/index.md
|
|
61
|
+
|
|
62
|
+
<the fact in one or two sentences, stated as a fact about the domain rather than as a session narrative>
|
|
63
|
+
```
|
|
64
|
+
|
|
55
65
|
Derive `<slug>` per `${CLAUDE_SKILL_DIR}/../../standards/slug.md`. Fall back to `latest` on an empty result.
|
|
56
66
|
|
|
57
67
|
The handoff is a file rather than a spoken result so the routed fact survives a compaction between this step and the `claude-docs` pass, and so the standalone caller leaves something behind for a later `/claude-docs` to consume.
|
|
@@ -80,7 +90,7 @@ Run `canon records validate memory` when the writes are done and fix what it nam
|
|
|
80
90
|
|
|
81
91
|
Respond with one line per fact routed, written, or updated:
|
|
82
92
|
|
|
83
|
-
- `➡️ Routed: <fact subject> → .claude/context/<domain>.md`
|
|
93
|
+
- `➡️ Routed: <fact subject> → .claude/context/<domain>.md` for a flat entry, or `→ .claude/context/<domain>/index.md` for a nested one, matching the heading the routing file carries
|
|
84
94
|
- `✅ Wrote: .canon/memory/<file> (<type>)`
|
|
85
95
|
- `✏️ Updated: .canon/memory/<file> (<type>)`
|
|
86
96
|
|
|
@@ -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
|
|
|
@@ -14,11 +14,11 @@ A reference page that reads like a lesson is a lesson wearing the wrong extensio
|
|
|
14
14
|
|
|
15
15
|
Sort by who owns the subject, which is the test the wiki already runs. The reader's activity decided the workspace and decides nothing here.
|
|
16
16
|
|
|
17
|
-
| The page's subject | Destination
|
|
18
|
-
| -------------------------------------------------------------- |
|
|
19
|
-
| Owned by someone outside the project, such as a tool or vendor | the project's wiki
|
|
20
|
-
| How this project itself works, for the people who build it | `.claude/context/<domain>.md` |
|
|
21
|
-
| Written for someone consuming what this project ships | the project's public docs
|
|
17
|
+
| The page's subject | Destination |
|
|
18
|
+
| -------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- |
|
|
19
|
+
| Owned by someone outside the project, such as a tool or vendor | the project's wiki |
|
|
20
|
+
| How this project itself works, for the people who build it | `.claude/context/<domain>.md`, or the domain's own nested `index.md` when it is already split into a folder |
|
|
21
|
+
| Written for someone consuming what this project ships | the project's public docs |
|
|
22
22
|
|
|
23
23
|
A page a reader cannot sort by that question is a page whose subject has not been settled. Say which two destinations it sits between and let the operator choose, rather than picking the nearer one.
|
|
24
24
|
|
|
@@ -37,7 +37,7 @@ Split `CLAUDE.md` by `##` heading. Score each section against this order:
|
|
|
37
37
|
For each section proposed for a move:
|
|
38
38
|
|
|
39
39
|
- Path-scoped: propose `.claude/rules/<subdir>/<n>-<slug>.md` with a `paths:` glob. Pick the subdir and a free number the way `create-rule` does. If `.claude/rules/<subdir>/` already holds a rule on the topic, mark as "conflict" and skip.
|
|
40
|
-
- Domain narrative: propose `.claude/context/<domain>.md`. If the entry already exists, propose appending rather than creating.
|
|
40
|
+
- Domain narrative: propose `.claude/context/<domain>.md`. If the entry already exists, flat or as a same-named `.claude/context/<domain>/` folder, propose appending to the file that owns it rather than creating a duplicate flat entry beside a domain already split.
|
|
41
41
|
|
|
42
42
|
## Step 4: output
|
|
43
43
|
|
|
@@ -55,6 +55,7 @@ Print one grouped proposal block. Omit empty groups.
|
|
|
55
55
|
## Extract to a context entry
|
|
56
56
|
|
|
57
57
|
- `## <heading>` → `.claude/context/<domain>.md` (<reason>)
|
|
58
|
+
- `## <heading>` → `.claude/context/<domain>/<sub-area>.md`, appending to the domain's existing sibling file (<reason>)
|
|
58
59
|
|
|
59
60
|
## Needs manual split
|
|
60
61
|
|
|
@@ -44,7 +44,7 @@ For each file, score against this rule order:
|
|
|
44
44
|
|
|
45
45
|
For every file proposed for a move:
|
|
46
46
|
|
|
47
|
-
- If `.claude/context/<filename>` already exists, mark as "conflict" and skip the move.
|
|
47
|
+
- If `.claude/context/<filename>` already exists, or a same-named folder exists at `.claude/context/` with `<filename>`'s `.md` extension dropped, mark as "conflict" and skip the move. A folder of the same name is still a collision, since the domain already lives there split into siblings.
|
|
48
48
|
- Run `git grep -n "docs/<filename>"` (or grep equivalent) to list inbound references in `CLAUDE.md`, `README.md`, and other markdown. Report each as a TODO line. Do not rewrite.
|
|
49
49
|
|
|
50
50
|
## Step 4: output
|
package/docs/agents/audits.md
CHANGED
|
@@ -11,20 +11,22 @@ description: Running every health check as one set, what the single verdict mean
|
|
|
11
11
|
canon audits run
|
|
12
12
|
canon audits run --json
|
|
13
13
|
canon audits run --record
|
|
14
|
+
canon audits run --corpus tracked --corpus per-machine
|
|
14
15
|
canon audits list --json
|
|
15
16
|
```
|
|
16
17
|
|
|
17
|
-
| Option
|
|
18
|
-
|
|
|
19
|
-
| `--json`
|
|
20
|
-
| `--root <path>`
|
|
21
|
-
| `--record`
|
|
18
|
+
| Option | Behavior |
|
|
19
|
+
| ----------------- | ---------------------------------------------------------------------------------------------------------- |
|
|
20
|
+
| `--json` | Add a machine-readable record on stdout, keeping the frame |
|
|
21
|
+
| `--root <path>` | Measure this tree instead of the current worktree |
|
|
22
|
+
| `--record` | Write this run's tracked counts to `.claude/canon/baseline.json` |
|
|
23
|
+
| `--corpus <name>` | Limit the run to one corpus (`tracked`, `per-machine`, `upstream`), repeatable, defaulting to every corpus |
|
|
22
24
|
|
|
23
25
|
## What it runs
|
|
24
26
|
|
|
25
27
|
Nineteen verbs, listed by `canon audits list`. Each runs once in its fullest form, and the aggregate reads that verb's own record rather than imposing a shared envelope on it. Every one of those records already has consumers naming its keys, so a common shape would be a breaking change bought for tidiness.
|
|
26
28
|
|
|
27
|
-
The verbs walk separate trees and share no state, so they run together. Measured on the authoring machine at twelve verbs, a run finished in 0.8 seconds of wall clock against 4.4 seconds of processor, which is under every other stage in `bun run check`. `canon deps audit` is the one that changes that reading, since it reaches a network rather than a tree and its latency is the index's rather than this machine's.
|
|
29
|
+
The verbs walk separate trees and share no state, so they run together. Measured on the authoring machine at twelve verbs, a run finished in 0.8 seconds of wall clock against 4.4 seconds of processor, which is under every other stage in `bun run check`. `canon deps audit` is the one that changes that reading, since it reaches a network rather than a tree and its latency is the index's rather than this machine's: three clean runs measured 44.7, 77.6, and 62.9 seconds, and a stalled lookup runs to an explicit 120-second timeout rather than to bun's own 299-second ceiling. `--corpus` is what keeps a caller from paying that cost when it wants only the corpora on this disk.
|
|
28
30
|
|
|
29
31
|
Sixteen of the nineteen read a tree on this disk, the one added by `restated.md` and the one added by `census.md` among them, since the first reads four such trees against each other and the second counts every file in one. The two added by `state-scoped-risk.md` read committed state rather than an arriving change, which is the gap every review surface here leaves by construction, and the one added by `label-coverage.md` reads a branch range against a map the project declares.
|
|
30
32
|
|
|
@@ -93,6 +95,8 @@ A hand-edited baseline that does not parse refuses the whole run. Reading a brok
|
|
|
93
95
|
|
|
94
96
|
## In the verify pipeline
|
|
95
97
|
|
|
96
|
-
`bun run check` runs
|
|
98
|
+
`bun run check` runs a reporting stage after the three gating stages, and never fails on it. Those three stages keep their own specific remedies, so the aggregate reports the rest and the growth, and a fact still fails the push at the stage that names what to do about it.
|
|
97
99
|
|
|
98
|
-
The stage
|
|
100
|
+
The stage runs `--corpus tracked --corpus per-machine` rather than the full set, so `deps` and its network latency sit outside the gate entirely. A weekly job reads the advisory index instead, on a schedule rather than on every push. `summary.audited` in the gate's own record therefore reads 19 against the 20 `canon audits run` reaches with no filter.
|
|
101
|
+
|
|
102
|
+
The stage reads `summary`, a flat object of scalars published beside the nested arrays. Every key in it is unique across the whole record, so a shell stage greps one out without a JSON parser. The three verbs the gating stages already ran walk their trees a second time here, which is the 0.8 seconds measured above and the reason the scoped set runs rather than only the part those stages skip: one verdict over the corpora describing this tree is the value, and a stage measuring a narrower subset would report a health nobody took.
|
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`) |
|
|
@@ -79,7 +79,7 @@ Full help: `canon <command> --help`. Behavior notes for the install and sync ver
|
|
|
79
79
|
| `canon repo metadata propose` | Compare a description, homepage, and topic set computed from the README and `package.json` against what the remote carries, writing nothing (`--root`, `--json`) |
|
|
80
80
|
| `canon repo metadata apply` | Write an explicitly supplied description, homepage, or topic set to the remote through `gh repo edit` (`--description`, `--homepage`, `--topics`, `--root`, `--json`) |
|
|
81
81
|
| `canon census [path]` | Report tracked file count, a breakdown by extension, and a line total that skips whatever reads as binary (`--json`) |
|
|
82
|
-
| `canon audits run` | Run every audit as one set, report per check under one verdict, and compare each count to the recorded baseline (`--json`, `--record`)
|
|
82
|
+
| `canon audits run` | Run every audit as one set, report per check under one verdict, and compare each count to the recorded baseline (`--json`, `--record`, `--corpus`) |
|
|
83
83
|
| `canon audits list` | List every audit the set runs, with the corpus each reads and whether it gates (`--json`) |
|
|
84
84
|
| `canon gate run` | Run every stage that guards a branch here, scoping shell, types, and tests to the changed set (`--all`, `--no-write`, `--nested`, `--json`) |
|
|
85
85
|
| `canon inventory [subject]` | Walk every route a project declares and group its elements by the property each computes, as a listing rather than a gate (`--json`) |
|
package/docs/agents/gate.md
CHANGED
|
@@ -77,8 +77,13 @@ One code for a failure, which is what a `bun run` caller and a git hook both rea
|
|
|
77
77
|
"unmeasured": 0,
|
|
78
78
|
"failed": 0
|
|
79
79
|
},
|
|
80
|
-
"
|
|
80
|
+
"ms": 44453,
|
|
81
|
+
"stages": [
|
|
82
|
+
{ "id": "indexes", "label": "Indexes", "status": "passed", "ms": 210 }
|
|
83
|
+
]
|
|
81
84
|
}
|
|
82
85
|
```
|
|
83
86
|
|
|
84
87
|
A failing stage carries its remedy in `failure`, and the same line goes to stderr so a caller reading neither the record nor the frame is still told what to fix.
|
|
88
|
+
|
|
89
|
+
`ms` carries the wall time each stage's checks took, including every process spawn, and the top-level `ms` sums them. Neither reading changes what a stage measures. Both exist so a slow run is attributable to a stage rather than read off a stopwatch held against the whole thing.
|
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
|
@@ -131,7 +131,8 @@ main() {
|
|
|
131
131
|
run_phase "Prepare" bash -c "cd '$tmp_dir' && $prepare"
|
|
132
132
|
fi
|
|
133
133
|
|
|
134
|
-
|
|
134
|
+
log_info "Sync resolves via bun $PROJECT_ROOT/src/cli.ts, not PATH canon"
|
|
135
|
+
run_phase "Sync" bash -c "cd '$tmp_dir' && CANON_NON_INTERACTIVE=1 bun '$PROJECT_ROOT/src/cli.ts' tooling sync $stack . --write"
|
|
135
136
|
|
|
136
137
|
if [ -f "$tmp_dir/package.json" ]; then
|
|
137
138
|
run_phase "lint:fix" bash -c "cd '$tmp_dir' && bun run lint:fix"
|
|
@@ -178,4 +179,6 @@ main() {
|
|
|
178
179
|
fi
|
|
179
180
|
}
|
|
180
181
|
|
|
181
|
-
|
|
182
|
+
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
|
183
|
+
main "$@"
|
|
184
|
+
fi
|
package/src/audits/catalog.ts
CHANGED
|
@@ -100,6 +100,8 @@ export interface AuditResult {
|
|
|
100
100
|
readonly counts?: Record<string, number>
|
|
101
101
|
/** Why the audit did not report, present only on `unmeasured`. */
|
|
102
102
|
readonly reason?: string
|
|
103
|
+
/** Wall time the spawn took, from the aggregate's own clock. */
|
|
104
|
+
readonly ms: number
|
|
103
105
|
}
|
|
104
106
|
|
|
105
107
|
/** The exit code every command here sets when it refuses. */
|
|
@@ -816,6 +818,7 @@ export function classify(
|
|
|
816
818
|
spec: AuditSpec,
|
|
817
819
|
exitCode: number,
|
|
818
820
|
stdout: string,
|
|
821
|
+
ms = 0,
|
|
819
822
|
): AuditResult {
|
|
820
823
|
const base = {
|
|
821
824
|
id: spec.id,
|
|
@@ -823,6 +826,7 @@ export function classify(
|
|
|
823
826
|
tracked: isTracked(spec),
|
|
824
827
|
corpus: spec.corpus,
|
|
825
828
|
exitCode,
|
|
829
|
+
ms,
|
|
826
830
|
}
|
|
827
831
|
|
|
828
832
|
let record: unknown
|
package/src/audits/run.ts
CHANGED
|
@@ -1,10 +1,32 @@
|
|
|
1
1
|
import { join } from 'node:path'
|
|
2
2
|
import { execa } from 'execa'
|
|
3
3
|
import type { Delta } from '@/audits/baseline'
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
type AuditResult,
|
|
6
|
+
type AuditSpec,
|
|
7
|
+
classify,
|
|
8
|
+
type Corpus,
|
|
9
|
+
} from '@/audits/catalog'
|
|
5
10
|
import { gitEnv } from '@/git-env'
|
|
6
11
|
import { PROJECT_ROOT } from '@/project-root'
|
|
7
12
|
|
|
13
|
+
/** Every value a corpus filter accepts, matching the `Corpus` union. */
|
|
14
|
+
export const CORPORA: readonly Corpus[] = ['tracked', 'per-machine', 'upstream']
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The catalog scoped to the requested corpora, or the whole set when none
|
|
18
|
+
* were named. `--corpus` on `audits run` reads this, and so does `auditSet`
|
|
19
|
+
* in `src/gate/measures.ts`, which excludes the upstream corpus so the gate
|
|
20
|
+
* reads only what describes this tree.
|
|
21
|
+
*/
|
|
22
|
+
export function auditsFor(
|
|
23
|
+
specs: readonly AuditSpec[],
|
|
24
|
+
requested: readonly string[],
|
|
25
|
+
): readonly AuditSpec[] {
|
|
26
|
+
if (requested.length === 0) return specs
|
|
27
|
+
return specs.filter((spec) => requested.includes(spec.corpus))
|
|
28
|
+
}
|
|
29
|
+
|
|
8
30
|
/** At least one audit reported a finding that is a fact. */
|
|
9
31
|
export const EXIT_FINDING = 2
|
|
10
32
|
|
|
@@ -77,9 +99,10 @@ export async function runAudits(
|
|
|
77
99
|
): Promise<AuditResult[]> {
|
|
78
100
|
return Promise.all(
|
|
79
101
|
specs.map(async (spec) => {
|
|
102
|
+
const startedAt = performance.now()
|
|
80
103
|
try {
|
|
81
104
|
const { exitCode, stdout } = await spawn(spec)
|
|
82
|
-
return classify(spec, exitCode, stdout)
|
|
105
|
+
return classify(spec, exitCode, stdout, performance.now() - startedAt)
|
|
83
106
|
} catch (error) {
|
|
84
107
|
return {
|
|
85
108
|
id: spec.id,
|
|
@@ -89,6 +112,7 @@ export async function runAudits(
|
|
|
89
112
|
corpus: spec.corpus,
|
|
90
113
|
exitCode: 1,
|
|
91
114
|
reason: `could not be started: ${error instanceof Error ? error.message : String(error)}`,
|
|
115
|
+
ms: performance.now() - startedAt,
|
|
92
116
|
}
|
|
93
117
|
}
|
|
94
118
|
}),
|
package/src/commands/audits.ts
CHANGED
|
@@ -9,8 +9,10 @@ import {
|
|
|
9
9
|
readBaseline,
|
|
10
10
|
writeBaseline,
|
|
11
11
|
} from '@/audits/baseline'
|
|
12
|
-
import { AUDITS, type AuditResult } from '@/audits/catalog'
|
|
12
|
+
import { AUDITS, type AuditResult, type Corpus } from '@/audits/catalog'
|
|
13
13
|
import {
|
|
14
|
+
auditsFor,
|
|
15
|
+
CORPORA,
|
|
14
16
|
exitCodeFor,
|
|
15
17
|
runAudits,
|
|
16
18
|
spawnAudit,
|
|
@@ -25,6 +27,11 @@ interface RunCommandOptions {
|
|
|
25
27
|
readonly json?: boolean
|
|
26
28
|
readonly record?: boolean
|
|
27
29
|
readonly root?: string
|
|
30
|
+
readonly corpus?: readonly string[]
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function collectCorpus(value: string, previous: string[]): string[] {
|
|
34
|
+
return [...previous, value]
|
|
28
35
|
}
|
|
29
36
|
|
|
30
37
|
interface ListCommandOptions {
|
|
@@ -52,6 +59,12 @@ export function register(program: Command): void {
|
|
|
52
59
|
'--record',
|
|
53
60
|
`Write this run's tracked counts to ${BASELINE_REL} as the new baseline`,
|
|
54
61
|
)
|
|
62
|
+
.option(
|
|
63
|
+
'--corpus <corpus>',
|
|
64
|
+
`Limit the run to one corpus (${CORPORA.join(', ')}), repeatable. Defaults to every corpus`,
|
|
65
|
+
collectCorpus,
|
|
66
|
+
[] as string[],
|
|
67
|
+
)
|
|
55
68
|
.addHelpText(
|
|
56
69
|
'after',
|
|
57
70
|
[
|
|
@@ -81,6 +94,7 @@ export function register(program: Command): void {
|
|
|
81
94
|
' canon audits run',
|
|
82
95
|
' canon audits run --json',
|
|
83
96
|
' canon audits run --record',
|
|
97
|
+
' canon audits run --corpus tracked --corpus per-machine',
|
|
84
98
|
'',
|
|
85
99
|
].join('\n'),
|
|
86
100
|
)
|
|
@@ -175,6 +189,28 @@ async function headCommit(root: string): Promise<string> {
|
|
|
175
189
|
async function runAll(opts: RunCommandOptions): Promise<number> {
|
|
176
190
|
const emitJson = opts.json ?? false
|
|
177
191
|
const root = opts.root ?? (await currentWorktreeRoot())
|
|
192
|
+
const requested = opts.corpus ?? []
|
|
193
|
+
|
|
194
|
+
const invalid = requested.filter(
|
|
195
|
+
(corpus) => !CORPORA.includes(corpus as Corpus),
|
|
196
|
+
)
|
|
197
|
+
if (invalid.length > 0) {
|
|
198
|
+
const message = `Unknown corpus ${invalid.join(', ')}. Valid corpora: ${CORPORA.join(', ')}.`
|
|
199
|
+
if (emitJson) {
|
|
200
|
+
process.stderr.write(`${message}\n`)
|
|
201
|
+
process.stdout.write(
|
|
202
|
+
`${JSON.stringify({ ok: false, reason: 'bad-corpus', message })}\n`,
|
|
203
|
+
)
|
|
204
|
+
return 1
|
|
205
|
+
}
|
|
206
|
+
intro('canon audits run')
|
|
207
|
+
logStep('Refused')
|
|
208
|
+
logError(message)
|
|
209
|
+
outro()
|
|
210
|
+
return 1
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const audits = auditsFor(AUDITS, requested)
|
|
178
214
|
|
|
179
215
|
let baseline: Baseline | undefined
|
|
180
216
|
try {
|
|
@@ -195,7 +231,7 @@ async function runAll(opts: RunCommandOptions): Promise<number> {
|
|
|
195
231
|
return 1
|
|
196
232
|
}
|
|
197
233
|
|
|
198
|
-
const results = await runAudits(
|
|
234
|
+
const results = await runAudits(audits, spawnAudit(root))
|
|
199
235
|
const deltas = compareBaseline(baseline, results)
|
|
200
236
|
const summary = summarize(results, deltas)
|
|
201
237
|
|
package/src/commands/gate.ts
CHANGED
|
@@ -134,11 +134,13 @@ async function runGate(opts: RunCommandOptions): Promise<number> {
|
|
|
134
134
|
scoped: changed.scoped,
|
|
135
135
|
changed: changed.files.length,
|
|
136
136
|
summary,
|
|
137
|
-
|
|
137
|
+
ms: results.reduce((total, result) => total + result.ms, 0),
|
|
138
|
+
stages: results.map(({ id, label, status, failure, ms }) => ({
|
|
138
139
|
id,
|
|
139
140
|
label,
|
|
140
141
|
status,
|
|
141
142
|
failure,
|
|
143
|
+
ms,
|
|
142
144
|
})),
|
|
143
145
|
})}\n`,
|
|
144
146
|
)
|
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/deps/audit.ts
CHANGED
|
@@ -38,6 +38,15 @@ export interface Advisory {
|
|
|
38
38
|
*/
|
|
39
39
|
export type AuditRefusal = 'no-manifest' | 'no-lockfile' | 'no-record'
|
|
40
40
|
|
|
41
|
+
/**
|
|
42
|
+
* The ceiling on `bun audit --json`, which carries no timeout of its own and
|
|
43
|
+
* runs to bun's own 299-second ceiling on a stall. Three consecutive clean
|
|
44
|
+
* runs on this tree measured 44.7, 77.6, and 62.9 seconds, so 120 seconds
|
|
45
|
+
* leaves margin over the slowest observed pass while still bounding a stall
|
|
46
|
+
* well under bun's own ceiling.
|
|
47
|
+
*/
|
|
48
|
+
const AUDIT_TIMEOUT_MS = 120_000
|
|
49
|
+
|
|
41
50
|
/** The lockfiles a resolved dependency set leaves behind, in any manager. */
|
|
42
51
|
const LOCKFILES = [
|
|
43
52
|
'bun.lock',
|
|
@@ -115,6 +124,22 @@ export function countBySeverity(
|
|
|
115
124
|
return counts
|
|
116
125
|
}
|
|
117
126
|
|
|
127
|
+
/**
|
|
128
|
+
* The message a caller reads for a refusal with no advisory record, naming a
|
|
129
|
+
* stall as its own cause rather than folding it into "no output on stdout",
|
|
130
|
+
* which is what an empty stdout reads as either way.
|
|
131
|
+
*/
|
|
132
|
+
export function noRecordMessage(result: {
|
|
133
|
+
readonly timedOut: boolean
|
|
134
|
+
readonly stderr: string
|
|
135
|
+
}): string {
|
|
136
|
+
if (result.timedOut) {
|
|
137
|
+
return `timed out after ${AUDIT_TIMEOUT_MS}ms waiting on the advisory index`
|
|
138
|
+
}
|
|
139
|
+
const line = result.stderr.trim().split('\n').pop()
|
|
140
|
+
return line !== undefined && line !== '' ? line : 'no output on stdout'
|
|
141
|
+
}
|
|
142
|
+
|
|
118
143
|
/**
|
|
119
144
|
* Shells the runtime's own advisory command rather than carrying an index.
|
|
120
145
|
*
|
|
@@ -138,6 +163,7 @@ export async function auditDependencies(root: string): Promise<DepsAudit> {
|
|
|
138
163
|
const result = await execa('bun', ['audit', '--json'], {
|
|
139
164
|
cwd: root,
|
|
140
165
|
reject: false,
|
|
166
|
+
timeout: AUDIT_TIMEOUT_MS,
|
|
141
167
|
})
|
|
142
168
|
|
|
143
169
|
const advisories = parseAdvisories(result.stdout)
|
|
@@ -145,7 +171,7 @@ export async function auditDependencies(root: string): Promise<DepsAudit> {
|
|
|
145
171
|
return {
|
|
146
172
|
kind: 'refused',
|
|
147
173
|
reason: 'no-record',
|
|
148
|
-
message: result
|
|
174
|
+
message: noRecordMessage(result),
|
|
149
175
|
}
|
|
150
176
|
}
|
|
151
177
|
|
package/src/gate/measures.ts
CHANGED
|
@@ -686,10 +686,15 @@ interface AuditSummary {
|
|
|
686
686
|
|
|
687
687
|
/**
|
|
688
688
|
* The three stages gating on the three findings here that are facts sit above,
|
|
689
|
-
* and this stage reports the rest. It runs
|
|
690
|
-
* only what those stages skip, because the aggregate's own value
|
|
691
|
-
*
|
|
692
|
-
* repository never took.
|
|
689
|
+
* and this stage reports the rest. It runs every tracked and per-machine audit
|
|
690
|
+
* rather than only what those stages skip, because the aggregate's own value
|
|
691
|
+
* is one verdict over the corpora describing this tree, and a stage measuring
|
|
692
|
+
* a narrower subset would report a health this repository never took.
|
|
693
|
+
*
|
|
694
|
+
* The upstream corpus is excluded. `deps` reaches a network index rather than
|
|
695
|
+
* this tree, and its wall time dwarfs every other stage combined, so a push
|
|
696
|
+
* gated on it waits on a lookup this tree's own health never depended on. A
|
|
697
|
+
* scheduled `verify.yml` job reports the advisory instead.
|
|
693
698
|
*
|
|
694
699
|
* This reports and never fails. Growth in a judgment count is the thing the
|
|
695
700
|
* baseline exists to make visible, and failing a push on one would teach a
|
|
@@ -697,7 +702,15 @@ interface AuditSummary {
|
|
|
697
702
|
* specific stage above that names its own remedy.
|
|
698
703
|
*/
|
|
699
704
|
export const auditSet: Measure = async (ctx) => {
|
|
700
|
-
const run = await ctx.cli([
|
|
705
|
+
const run = await ctx.cli([
|
|
706
|
+
'audits',
|
|
707
|
+
'run',
|
|
708
|
+
'--corpus',
|
|
709
|
+
'tracked',
|
|
710
|
+
'--corpus',
|
|
711
|
+
'per-machine',
|
|
712
|
+
'--json',
|
|
713
|
+
])
|
|
701
714
|
|
|
702
715
|
if (run.stdout.trim() === '') {
|
|
703
716
|
return {
|
package/src/gate/sequencer.ts
CHANGED
|
@@ -27,6 +27,8 @@ export interface StageResult {
|
|
|
27
27
|
readonly emissions: readonly Emission[]
|
|
28
28
|
/** The remedy line a failed stage prints, naming what to do about it. */
|
|
29
29
|
readonly failure?: string
|
|
30
|
+
/** Wall time the stage's checks took, including every process spawn. */
|
|
31
|
+
readonly ms: number
|
|
30
32
|
}
|
|
31
33
|
|
|
32
34
|
export interface GateContext extends MeasureContext {
|
|
@@ -133,6 +135,17 @@ export async function runStage(
|
|
|
133
135
|
stage: Stage,
|
|
134
136
|
ctx: GateContext,
|
|
135
137
|
): Promise<StageResult> {
|
|
138
|
+
const startedAt = performance.now()
|
|
139
|
+
const result = await executeStage(stage, ctx)
|
|
140
|
+
return { ...result, ms: performance.now() - startedAt }
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
type StageOutcome = Omit<StageResult, 'ms'>
|
|
144
|
+
|
|
145
|
+
async function executeStage(
|
|
146
|
+
stage: Stage,
|
|
147
|
+
ctx: GateContext,
|
|
148
|
+
): Promise<StageOutcome> {
|
|
136
149
|
if (stage.scope !== undefined && !hasChanged(stage.scope, ctx.changed)) {
|
|
137
150
|
return {
|
|
138
151
|
id: stage.id,
|
package/src/tasks/archive.ts
CHANGED
|
@@ -496,6 +496,22 @@ async function resolveStem(
|
|
|
496
496
|
|
|
497
497
|
if (selector.kind === 'stem') {
|
|
498
498
|
if (!stems.includes(selector.stem)) {
|
|
499
|
+
const prefixed = stems.filter((stem) => stem.startsWith(selector.stem))
|
|
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
|
+
}
|
|
499
515
|
return refuse(
|
|
500
516
|
'no-match',
|
|
501
517
|
`No task named ${selector.stem} on the board.`,
|
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
|
|
package/standards/wireframes.md
CHANGED
|
@@ -30,6 +30,14 @@ A wireframe works when someone can rebuild the surface from it without opening t
|
|
|
30
30
|
|
|
31
31
|
A wireframe that fails these is non-conforming regardless of whether it satisfies every section rule below. The fences are the means. These three questions are the test.
|
|
32
32
|
|
|
33
|
+
## Transcription wireframes
|
|
34
|
+
|
|
35
|
+
A wireframe regenerated from an already-built surface's own render code may carry what the sections below forbid: the source file and function, the class or token names driving each region, and pixel-exact geometry. Open the file by naming that source: the render function, the stylesheet rule, or the built file the block was read off. The citation is what separates a transcription wireframe from one that merely broke the rules below, since a role label with nothing behind it is unchecked and a class name traced to a real file and line is not.
|
|
36
|
+
|
|
37
|
+
A drafted-ahead wireframe states intent in role terms because nothing exists yet to check it against. A regenerated one is checkable against the code it mirrors, and a role label standing in for the real class name would throw that check away. The two documents answer different questions, so one set of rules cannot govern both.
|
|
38
|
+
|
|
39
|
+
Route detail the source citation does not already carry to `.claude/context/` as usual: algorithms, event-handler code, workarounds, and rejected approaches stay out of the wireframe regardless of which mode it is written in.
|
|
40
|
+
|
|
33
41
|
## Frontmatter
|
|
34
42
|
|
|
35
43
|
- `title` (required): sentence case, names the surface (`Header`, `Section nav`, `Footer`).
|
|
@@ -41,7 +49,7 @@ Both fields feed `.claude/wireframes/index.md` when regenerated.
|
|
|
41
49
|
|
|
42
50
|
- Draw each surface as an ASCII block inside a `plaintext` fence. One fence per distinct layout.
|
|
43
51
|
- Label regions with `←` annotations. Never use `#` for annotations.
|
|
44
|
-
- Show a region's role, not its styling. `← status pill` reads better than a class name or hex value.
|
|
52
|
+
- Show a region's role, not its styling. `← status pill` reads better than a class name or hex value. A transcription wireframe is the exception: see `## Transcription wireframes`.
|
|
45
53
|
- Keep the grid honest. The ASCII proportions should match the intended widths, since conveying proportion is the wireframe's job.
|
|
46
54
|
|
|
47
55
|
## Variants
|
|
@@ -63,7 +71,7 @@ Both fields feed `.claude/wireframes/index.md` when regenerated.
|
|
|
63
71
|
|
|
64
72
|
## What moves to .claude/context/
|
|
65
73
|
|
|
66
|
-
Implementation detail that answers how the surface is built belongs in a `.claude/context/` entry, not the wireframe:
|
|
74
|
+
This section does not govern a transcription wireframe's citation of its own source. See `## Transcription wireframes`. Implementation detail that answers how the surface is built belongs in a `.claude/context/` entry, not the wireframe:
|
|
67
75
|
|
|
68
76
|
- Algorithms, event-handler code, and lifecycle ordering
|
|
69
77
|
- Framework component names, props, and CSS class or token names
|