@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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "canon",
3
3
  "description": "Automated governance, versioning, and discovery tools for Claude Code.",
4
- "version": "4.48.0",
4
+ "version": "4.49.0",
5
5
  "author": {
6
6
  "name": "Eric Le",
7
7
  "url": "https://github.com/erclx"
@@ -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's plan path: stop and route to the planning skill
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. Run /claude-feature first.`
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, then delete the handoff file so a later run does not fold it twice.
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 existing `.claude/context/<domain>.md`:
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 with retrieval on what the last session got wrong, before anything new. A learner who cannot retrieve the previous lesson is not ready for the next one, and moving on anyway buys fluency that decays.
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 answer. It embeds one shared stylesheet rather than restating styles, and it is disposable and never promoted.
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 what to revisit.
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 for every option, including the correct one, saying why rather than whether
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 roughly when in the learning record, so the next session has a schedule rather than a guess
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
@@ -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 | Behavior |
18
- | --------------- | ---------------------------------------------------------------- |
19
- | `--json` | Add a machine-readable record on stdout, keeping the frame |
20
- | `--root <path>` | Measure this tree instead of the current worktree |
21
- | `--record` | Write this run's tracked counts to `.claude/canon/baseline.json` |
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 the set as 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.
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 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 whole set runs rather than only the part those stages skip: one verdict over every audit is the value, and a stage measuring a subset would report a health nobody took.
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.
@@ -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`) |
@@ -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
- "stages": [{ "id": "indexes", "label": "Indexes", "status": "passed" }]
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.
@@ -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 and the ordinal a new one takes, opening one with its required files, recording sources and glossary terms, resolving what the next lesson needs before it is written, rewriting the root listing, a contents page, and each lesson's chrome from its marker regions, the refusal reasons, and why every write here runs through a verb
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
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  title: Teach
3
- description: Listing learning workspaces and the ordinal a new one takes, opening one with its required files, recording sources and glossary terms, resolving what the next lesson needs before it is written, rewriting the root listing, a contents page, and each lesson's chrome from its marker regions, the refusal reasons, and why every write here runs through a verb
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
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@erclx/canon",
3
3
  "type": "module",
4
- "version": "4.48.0",
4
+ "version": "4.49.0",
5
5
  "description": "Infrastructure and quality tooling for developer workflows",
6
6
  "license": "MIT",
7
7
  "bin": {
@@ -131,7 +131,8 @@ main() {
131
131
  run_phase "Prepare" bash -c "cd '$tmp_dir' && $prepare"
132
132
  fi
133
133
 
134
- run_phase "Sync" bash -c "cd '$tmp_dir' && CANON_NON_INTERACTIVE=1 canon tooling sync $stack . --write"
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
- main "$@"
182
+ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
183
+ main "$@"
184
+ fi
@@ -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 { type AuditResult, type AuditSpec, classify } from '@/audits/catalog'
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
  }),
@@ -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(AUDITS, spawnAudit(root))
234
+ const results = await runAudits(audits, spawnAudit(root))
199
235
  const deltas = compareBaseline(baseline, results)
200
236
  const summary = summarize(results, deltas)
201
237
 
@@ -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
- stages: results.map(({ id, label, status, failure }) => ({
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
  )
@@ -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.stderr.trim().split('\n').pop() ?? 'no output on stdout',
174
+ message: noRecordMessage(result),
149
175
  }
150
176
  }
151
177
 
@@ -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 the whole set anyway rather than
690
- * only what those stages skip, because the aggregate's own value is one verdict
691
- * over every audit, and a stage measuring a subset would report a health this
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(['audits', 'run', '--json'])
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 {
@@ -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,
@@ -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
- ['style', `<style>\n${css}\n</style>`],
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(html.includes('class="quiz"'), false)],
727
+ ['scripts', renderScripts(legacy, false)],
670
728
  ]
671
729
 
672
730
  for (const [region, content] of regions) {
@@ -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
 
@@ -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
- - What to revisit, and roughly when
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
 
@@ -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