@erclx/canon 5.2.0 → 5.4.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.
Files changed (37) hide show
  1. package/README.md +1 -0
  2. package/claude/.claude-plugin/plugin.json +1 -1
  3. package/claude/skills/plan-groundwork/SKILL.md +1 -1
  4. package/claude/skills/teach-workspace/REQUIREMENT.md +4 -2
  5. package/claude/skills/teach-workspace/SKILL.md +2 -2
  6. package/claude/skills/teach-workspace/references/lesson-craft.md +4 -1
  7. package/claude/skills/teach-workspace/references/pedagogy.md +1 -0
  8. package/docs/agents/markdown-audit.md +1 -1
  9. package/docs/agents/pr-evidence.md +17 -0
  10. package/docs/agents/teach-authoring.md +9 -3
  11. package/docs/agents/teach.md +1 -1
  12. package/docs/workflow/operating-model.md +1 -1
  13. package/docs/workflow/skill-map.md +10 -8
  14. package/package.json +2 -1
  15. package/src/commands/pr.ts +63 -41
  16. package/src/commands/teach.ts +12 -5
  17. package/src/design/components.ts +52 -0
  18. package/src/teach/components/code.tsx +24 -0
  19. package/src/teach/highlight.ts +71 -0
  20. package/src/teach/nav.ts +35 -4
  21. package/src/teach/render.ts +27 -1
  22. package/standards/context.md +1 -1
  23. package/standards/decisions.md +6 -1
  24. package/standards/groundwork.md +1 -0
  25. package/standards/requirements.md +1 -1
  26. package/standards/rule.md +1 -0
  27. package/standards/skill.md +1 -1
  28. package/standards/standard.md +1 -0
  29. package/standards/teach.md +15 -0
  30. package/tooling/go/configs/scripts/verify.sh +47 -0
  31. package/tooling/go/reference.md +1 -1
  32. package/tooling/php/configs/scripts/verify.sh +47 -0
  33. package/tooling/php/reference.md +1 -1
  34. package/tooling/python/configs/scripts/verify.sh +47 -0
  35. package/tooling/python/reference.md +1 -1
  36. package/tooling/web/configs/scripts/verify.sh +47 -0
  37. package/tooling/web/reference.md +1 -1
package/README.md CHANGED
@@ -94,6 +94,7 @@ Working with an agent rather than reading this yourself? It loads `CLAUDE.md` at
94
94
 
95
95
  - [AI workflow](docs/workflow/ai-workflow.md): feature-development loop inside a toolkit-managed project
96
96
  - [Operating model](docs/workflow/operating-model.md): orchestrator, planner, and worker roles for building across parallel sessions
97
+ - [Skill map](docs/workflow/skill-map.md): plugin skills grouped by the moment a project meets them
97
98
  - [Visual design workflow](docs/workflow/visual-design-workflow.md): tiered guide for design and wireframe authoring
98
99
  - [Target projects](docs/target/projects.md): scaffold, add a domain later, sync upstream drift
99
100
  - [Agents](docs/agents/index.md): CLI flags, exit codes, and JSON output shapes
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "canon",
3
3
  "description": "Automated governance, versioning, and discovery tools for Claude Code.",
4
- "version": "5.2.0",
4
+ "version": "5.4.0",
5
5
  "author": {
6
6
  "name": "Eric Le",
7
7
  "url": "https://github.com/erclx"
@@ -91,7 +91,7 @@ The standard sets the open question format and requires it inside a topic file a
91
91
 
92
92
  ## Close mode
93
93
 
94
- 1. Write `06-decision.md`. It states the problem once, names the goal, lists what to do, and lists what was considered and dropped.
94
+ 1. Write `06-decision.md`. It states the problem once, names the goal, lists what to do, and lists what was considered and dropped. Carry the "Overturned by" line of each lean that became the decision into a `## Revisit when` section, and carry none from a lean the decision did not take.
95
95
  2. Write `07-next-session.md` self-contained, so it survives a compaction that loses the conversation.
96
96
  3. Update the file map in `README.md`.
97
97
  4. Write one task file in `.canon/tasks/` recording what the track concluded, even when the conclusion is to do nothing. Follow `${CLAUDE_SKILL_DIR}/../../standards/tasks.md` for the filename and frontmatter. Place the row through `task-board` Step 4, which checks the roster for a live orchestrator before writing `priority.md` or `backlog.md` directly. Aside from an experiment fixture, this and the routing in Step 5 are the only ways close mode reaches outside the folder.
@@ -7,7 +7,9 @@ description: Scope boundary for learning a subject across sessions, and the spli
7
7
 
8
8
  ## Gap
9
9
 
10
- Without this skill, a session asked to teach a subject answers in the conversation, so everything it produced dies with the session and the next one starts from nothing. It teaches from recall rather than from a source, so the learner is handed claims nothing was read for and cannot tell which ones. It picks the next topic from the subject's own order rather than from what the learner got wrong, so it teaches past a gap it never measured. It asks nothing about what the learner already knows, so difficulty has no floor and the session lands either below or far above them.
10
+ Without this skill, a session asked to teach a subject answers in the conversation, so everything it produced dies with the session and the next one starts from nothing. It teaches from recall rather than from a source, so the learner is handed claims nothing was read for and cannot tell which ones. When it does read, it takes whatever a search returned first, so a course rests on a few thin sources, and it cites the learner's own projects or an employer's material as proof of a general concept.
11
+
12
+ It picks the next topic from the subject's own order rather than from what the learner got wrong, so it teaches past a gap it never measured. It asks nothing about what the learner already knows, so difficulty has no floor and the session lands either below or far above them.
11
13
 
12
14
  It also produces one output where two are needed. A page written to be worked through once and a page written to be looked up later have different lifetimes and different readers, and merging them yields material that is disposable and gets kept, or durable and carries a quiz nobody can promote. Writing the durable half in a format the authoring gates do not read costs a conversion at the moment it matters most, which is the moment someone tries to promote it.
13
15
 
@@ -25,7 +27,7 @@ Two failures land specifically on where the folder sits. A workspace resolved ag
25
27
  - Name the write route for a main-root path from a linked worktree, since a body naming only the destination reports a success that did not happen
26
28
  - Route every edit inside a workspace file that already exists through a verb resolving the root in-process, since a heredoc reaches a whole-file create alone and the stream editors that would reach the rest are banned
27
29
  - Settle the learner's starting point by asking, so difficulty sits above a measured floor
28
- - Research the subject from sources before teaching it, and record what was read and what was found and not opened
30
+ - Research the subject from sources before teaching it, choosing them by the teach standard's ladder, and record what was read and what was found and not opened
29
31
  - Place each lesson from the learning records rather than from the subject's order, and open with retrieval on the last wrong answer
30
32
  - Split the output by lifetime, sending the worked-through half to lessons and the looked-up half to reference pages in the format the authoring gates read
31
33
  - Record the wrong answer itself rather than the count, since that is what the next session places the learner from
@@ -61,7 +61,7 @@ Repeat `--success` and `--out-of-scope` per line. The verb refuses a topic anoth
61
61
 
62
62
  ## Step 2: research before teaching
63
63
 
64
- Read the subject from sources rather than from recall. Record what was read and what was only found through the verb that owns the file:
64
+ Read the subject from sources rather than from recall, chosen by the standard's `### Choosing sources` ladder, with a third-rung source's reason in its title. Record what was read and what was only found through the verb that owns the file:
65
65
 
66
66
  ```bash
67
67
  canon teach resource <topic> --json \
@@ -83,7 +83,7 @@ Open on what `due` reports as overdue, oldest date first, before anything new. W
83
83
 
84
84
  Two outputs with two lifetimes, and the split decides the format.
85
85
 
86
- - 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.
86
+ - 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. Its `refs` pass the source ladder, and a learner's or organization's file goes in the body, labeled.
87
87
  - 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. `canon teach nav` renders it to a `reference/<slug>.html` sibling, and a lesson links that sibling, since a served `.md` opens as plain text.
88
88
 
89
89
  Resolve the lesson before writing it, rather than composing its name or its quiz order by hand:
@@ -74,14 +74,17 @@ The stepper hides and shows and sets nothing else, so how a selected option look
74
74
 
75
75
  ## The block list
76
76
 
77
- `canon teach render` takes a JSON array of blocks and renders it through the same components a lesson body composes. Five types, and every structural body composes from them:
77
+ `canon teach render` takes a JSON array of blocks and renders it through the same components a lesson body composes. Six types, and every structural body composes from them:
78
78
 
79
79
  - `{"type":"heading","level":1|2,"text":"<text>"}`: an `<h1>` or `<h2>`.
80
80
  - `{"type":"paragraph","text":"<text>","lede":true,"cites":[1]}`: a `<p>`, marked `lede` for the dek that opens the lesson. `cites` adds a footnote marker per reference number after the text, and a lede takes none.
81
81
  - `{"type":"list","items":["<text>", ...],"ordered":true}`: a `<ul>` or, with `ordered`, an `<ol>`.
82
82
  - `{"type":"refs","items":[{"title":"<name>","url":"<https URL>","note":"<text>"}, ...]}`: the lesson's one reference list, numbered from 1 in item order. `url` and `note` are optional, and a `url` must be http or https.
83
+ - `{"type":"code","lang":"ts","text":"<source>"}`: a code sample, highlighted at render time with no script. `lang` is optional and takes a highlight.js name or alias. One the renderer does not register renders as plain escaped code.
83
84
  - `{"type":"raw","html":"<markup>"}`: the escape hatch, passed through unescaped.
84
85
 
86
+ Code never travels through `raw`. A hand-written `<pre><code>` renders with no color, and `nav` never highlights markup already in a lesson body, so a sample written that way stays plain for good. A fenced block in a reference page's markdown is highlighted by `nav` on its own.
87
+
85
88
  The quiz and the teach-back block above are the one case `raw` is always needed for, since their fixed contract is not a components concern. Give them the array's last `raw` entry, followed only by `refs`, rather than reaching for `raw` anywhere the other types could carry the content instead.
86
89
 
87
90
  A claim resting on a source carries a `cites` number, and the source goes in the lesson's one `refs` block rather than into the sentence. Draw every reference from what `canon teach resource --read` recorded, never from a lead, since a lead is a source nobody opened. Render refuses a cite no reference resolves, so number the list before citing into it.
@@ -75,3 +75,4 @@ Parametric recall is confident where it is wrong, and a learner cannot audit it.
75
75
  - Read the subject from sources before teaching, and record each one with its link
76
76
  - Mark any claim resting on recall as resting on recall, in the lesson itself
77
77
  - List a source found and not opened as a lead, and never cite it
78
+ - Choose which sources to read by the ladder under `### Choosing sources` in the teach standard, which also rules out the learner's own work as proof of a general claim
@@ -87,7 +87,7 @@ A hit the closed set could not separate from correct prose was the case with no
87
87
 
88
88
  ### Where the rules are enforced
89
89
 
90
- Five surfaces apply the ban set and four of them go through this verb. `.claude/hooks/standards-audit.sh` runs it against a single file after each markdown edit, the seed copy a project installs does the same, the `Markdown bans` stage in `canon gate run` runs it across the whole corpus before this repository's own push, and the same-named stage in `tooling/base/configs/scripts/verify.sh` runs it across a scaffolded project's tracked markdown before its own push. Each hook parsed its own copy of the word bans in awk before that, which left a British spelling passing at edit time and failing the push with nothing in between explaining the difference.
90
+ Five surfaces apply the ban set and four of them go through this verb. `.claude/hooks/standards-audit.sh` runs it against a single file after each markdown edit, the seed copy a project installs does the same, the `Markdown bans` stage in `canon gate run` runs it across the whole corpus before this repository's own push, and the same-named stage in every stack's `scripts/verify.sh` runs it across a scaffolded project's tracked markdown before its own push. Each hook parsed its own copy of the word bans in awk before that, which left a British spelling passing at edit time and failing the push with nothing in between explaining the difference.
91
91
 
92
92
  The seed copy moved onto the verb when the sets became data, since its awk had nothing left to parse. It resolves one runner where the toolkit copy resolves two, looking for no checkout source, and a machine carrying no `canon` gets a report naming the binary to install rather than a silent pass. `scripts/core/check-seed-independence.sh` scopes its walk to markdown and leaves the seed hooks outside it, which its own comment records as deliberate.
93
93
 
@@ -64,6 +64,23 @@ carries no evidence path, such as `base` or `python`, hits this reason on
64
64
  every pull request and posts nothing, which is the correct behavior rather
65
65
  than a gap.
66
66
 
67
+ Both `ok` and `no-evidence` also report what the marked comment already
68
+ shows a reviewer, each field present only when that comment holds it:
69
+
70
+ | Field | What it carries |
71
+ | ----------- | ----------------------------------------------------------------- |
72
+ | `preview` | The hosted `**Preview:**` address the comment opens with. |
73
+ | `local` | The `**Local preview:**` address under it. |
74
+ | `checklist` | The checklist between its delimiters, with any ticked boxes kept. |
75
+
76
+ These come from the comment already posted, never from the flags the call
77
+ passed, so a skill driving a review reads which address and which checklist a
78
+ reviewer has without parsing the comment itself. A checklist posted on its own
79
+ carries no marker, so the record reports none of the three for it. Reading
80
+ them means the thread is read before the `no-evidence` answer too, so an
81
+ unreadable thread refuses as `gh-failed` on both reasons rather than
82
+ reporting the fields absent.
83
+
67
84
  ## What `[number]` selects, and what it does not
68
85
 
69
86
  Naming a number picks which pull request the rendered body claims to
@@ -38,7 +38,7 @@ The order is drawn here rather than instructed, and that is the point of the ver
38
38
 
39
39
  ## Render
40
40
 
41
- `canon teach render` renders a lesson body's structural blocks to HTML, through the four lesson components. It takes no topic and no `--root`, since the verb is a stateless transform reading nothing off a workspace on disk.
41
+ `canon teach render` renders a lesson body's structural blocks to HTML, through the five lesson components. It takes no topic and no `--root`, since the verb is a stateless transform reading nothing off a workspace on disk.
42
42
 
43
43
  ```bash
44
44
  echo '[{"type":"heading","level":1,"text":"Compass bearings"}]' | canon teach render --json
@@ -48,7 +48,13 @@ echo '[{"type":"heading","level":1,"text":"Compass bearings"}]' | canon teach re
48
48
  | -------- | ---------------------------------------- |
49
49
  | `--json` | Emit a machine-readable record on stdout |
50
50
 
51
- It reads a JSON array of blocks from stdin, each a `heading`, `paragraph`, `list`, `refs`, or `raw` block, and reports `{ ok: true, html }` on `--json` or the bare rendered HTML on stdout otherwise. Content none of the components can express takes a `raw` block, carrying its own HTML verbatim and unescaped, which is the shape the quiz and the teach-back block travel in.
51
+ It reads a JSON array of blocks from stdin, each a `heading`, `paragraph`, `list`, `refs`, `code`, or `raw` block, and reports `{ ok: true, html }` on `--json` or the bare rendered HTML on stdout otherwise. Content none of the components can express takes a `raw` block, carrying its own HTML verbatim and unescaped, which is the shape the quiz and the teach-back block travel in.
52
+
53
+ A `code` block carries `text` and an optional `lang`, a highlight.js name or alias. The source is highlighted at render time into `hljs-*` class spans the workspace stylesheet colors in both themes, so the page carries no script. A `lang` the renderer does not register renders as plain escaped code, and one that does not match `^[a-z0-9+#-]+$` is refused. A workspace seeded before the syntax colors existed shows the spans uncolored until `canon teach stylesheet <topic>` rewrites its base stylesheet.
54
+
55
+ ```json
56
+ [{ "type": "code", "lang": "ts", "text": "const a: number = 1" }]
57
+ ```
52
58
 
53
59
  A citation takes two shapes. A `paragraph` carries an optional `cites` array of reference numbers, rendered as `sup.cite` markers after its text. The one `refs` block carries `items` of `{ title, url?, note? }`, rendered as `ol.refs` and numbered from 1, so cite `1` links to the first item.
54
60
 
@@ -107,6 +113,6 @@ Splicing the stepper into a lesson of either shape was measured against a browse
107
113
 
108
114
  ### Reference pages
109
115
 
110
- Each reference page is rendered to a `reference/<slug>.html` sibling carrying the same chrome, and the contents page links that sibling rather than the markdown, which `canon serve` hands over as plain text. The render drops the frontmatter, turns tables into `<table>` markup, and escapes raw HTML written in the markdown rather than passing it through. A body with no H1 gets one from the frontmatter `title`. A relative link to another reference page's markdown points at that page's `.html` sibling, and one to the workspace `GLOSSARY.md` points at the glossary on the contents page, `../index.html#gloss`. Every other link, such as `../RESOURCES.md`, stays as written. The markdown file is never touched.
116
+ Each reference page is rendered to a `reference/<slug>.html` sibling carrying the same chrome, and the contents page links that sibling rather than the markdown, which `canon serve` hands over as plain text. The render drops the frontmatter, turns tables into `<table>` markup, and escapes raw HTML written in the markdown rather than passing it through. A fenced code block in a language the highlighter registers is highlighted the same way a `code` block is, and one in any other language stays plain. A hand-written code block in a lesson body is never highlighted. A body with no H1 gets one from the frontmatter `title`. A relative link to another reference page's markdown points at that page's `.html` sibling, and one to the workspace `GLOSSARY.md` points at the glossary on the contents page, `../index.html#gloss`. Every other link, such as `../RESOURCES.md`, stays as written. The markdown file is never touched.
111
117
 
112
118
  Every rendered page carries a `<meta name="generator" content="canon teach nav">` tag. A `.html` file under `reference/` whose markdown is gone is deleted when it carries that tag, and a hand-written HTML file there, carrying none, is left alone. The record's `reference` field counts the pages rendered. On a Bun with no `Bun.markdown`, each reference page lands in `skipped` with `Bun.markdown` as what is missing, and the rest of the run goes on.
@@ -81,7 +81,7 @@ canon teach resource regular-expressions \
81
81
  | `--json` | Emit a machine-readable record on stdout |
82
82
  | `--root <path>` | Teach root, defaulting to the main worktree |
83
83
 
84
- The pair splits on the first `=`, so a URL carrying its own separator survives intact. Say in the title which claims rest on the source, since the entry is the only place a later session reads that from.
84
+ The pair splits on the first `=`, so a URL carrying its own separator survives intact. Say in the title which claims rest on the source, since the entry is the only place a later session reads that from. Which sources qualify is the source ladder in `canon standards teach`, and a source below its first two rungs also names why no higher one covers the claim.
85
85
 
86
86
  A URL either heading already lists is refused rather than written twice. Two entries for one source split what rests on it across two lines, and a reader checking a claim then finds half of the answer.
87
87
 
@@ -232,5 +232,5 @@ port-collision detail.
232
232
  ## Related
233
233
 
234
234
  - [Claude Code and git worktrees](../../wiki/claude/worktrees.md) for the isolation and fan-out mechanics
235
- - [Claude Code subagents](../../wiki/claude/subagents.md) for in-session parallelism without worktrees
235
+ - [Claude Code subagents](../../wiki/claude/subagents.md) for in-session parallelism without worktrees, and [blocking subagents](../../wiki/claude/permissions.md#blocking-subagents) for the `permissions.deny` rule that stops every spawn
236
236
  - `canon/context/claude-plugin/skill-strategy/overview.md` for how the skills in the loop are categorized
@@ -12,14 +12,15 @@ This page is the corpus the coverage claim is measured against: every name `cano
12
12
 
13
13
  ## Set up a project
14
14
 
15
- | Skill | When to use |
16
- | ---------------------- | ------------------------------------------------------------------------------------------------------ |
17
- | `canon:target-setup` | On a fresh scaffold, to detect the stack and run the install chain, or to reach one phase of it alone |
18
- | `canon:canon-operator` | On a project that already exists, to read what it carries before an install is picked |
19
- | `canon:sketch-design` | Before `design-extract`'s greenfield path, to trace a design direction from reference images or URLs |
20
- | `canon:design-extract` | Before the first UI feature, to draft `canon/DESIGN.md` |
21
- | `canon:draft-diagram` | Once the architecture is written, to draft per-kind entries under `.canon/diagrams/` |
22
- | `canon:repo-metadata` | When the GitHub About text, homepage, or topics may have drifted, to reconcile them against the README |
15
+ | Skill | When to use |
16
+ | ------------------------- | ----------------------------------------------------------------------------------------------------------- |
17
+ | `canon:target-setup` | On a fresh scaffold, to detect the stack and run the install chain, or to reach one phase of it alone |
18
+ | `canon:canon-operator` | On a project that already exists, to read what it carries before an install is picked |
19
+ | `canon:sketch-design` | Before `design-extract`'s greenfield path, to trace a design direction from reference images or URLs |
20
+ | `canon:design-extract` | Before the first UI feature, to draft `canon/DESIGN.md` |
21
+ | `canon:draft-diagram` | Once the architecture is written, to draft per-kind entries under `.canon/diagrams/` |
22
+ | `canon:deploy-cloudflare` | Once a project is ready to publish, to set up its Cloudflare Pages deploy and stop for the token and domain |
23
+ | `canon:repo-metadata` | When the GitHub About text, homepage, or topics may have drifted, to reconcile them against the README |
23
24
 
24
25
  ## Decide what to build
25
26
 
@@ -46,6 +47,7 @@ This page is the corpus the coverage claim is measured against: every name `cano
46
47
  | `canon:test-craft` | When writing or changing any test, to pick its layer and filter what it asserts |
47
48
  | `canon:review-craft` | When reviewing a change, for what to look for and how much evidence a finding needs |
48
49
  | `canon:systematic-debugging` | When a test fails or a bug surfaces, to force root cause first |
50
+ | `canon:design-taste` | When drafting or judging an interface, or when output reads generic, to settle which decision comes first |
49
51
  | `canon:ui-checklist` | After a UI change, to write what to look at and name what ships untested |
50
52
 
51
53
  ## Check the work before it leaves the branch
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@erclx/canon",
3
3
  "type": "module",
4
- "version": "5.2.0",
4
+ "version": "5.4.0",
5
5
  "description": "Infrastructure and quality tooling for developer workflows",
6
6
  "license": "MIT",
7
7
  "bin": {
@@ -56,6 +56,7 @@
56
56
  "dependencies": {
57
57
  "commander": "^13.1.0",
58
58
  "execa": "^9.5.2",
59
+ "highlight.js": "^11.12.0",
59
60
  "playwright-core": "1.59.1",
60
61
  "pptxgenjs": "^4.0.1"
61
62
  },
@@ -462,6 +462,16 @@ export function register(program: Command): void {
462
462
  'comment. Together with --checklist it does, so the checklist and the',
463
463
  'link land in one comment.',
464
464
  '',
465
+ 'Both reasons carry what the marked comment already posted, each field',
466
+ 'present only when that comment holds it:',
467
+ ' preview the hosted **Preview:** address the comment opens with',
468
+ ' local the **Local preview:** address under it',
469
+ ' checklist the checklist between its delimiters, ticks included',
470
+ 'These come from the comment already posted, never from the flags this',
471
+ 'call passed. A checklist posted on its own carries no marker, so the',
472
+ 'record reports none of the three for it. An unread thread refuses as',
473
+ 'gh-failed on both reasons rather than reporting the fields absent.',
474
+ '',
465
475
  'Exit codes:',
466
476
  ' 0 read, whether it produced a body or reported no-evidence',
467
477
  ' 1 refused, with the reason on stderr or in the JSON record',
@@ -1424,47 +1434,6 @@ async function runEvidence(
1424
1434
  baseTree.has(path),
1425
1435
  )
1426
1436
 
1427
- // A local address rides on a checklist or a hosted preview and never opens
1428
- // a comment alone, so a docs-only branch with a server up posts nothing.
1429
- const hasChecklistAndLocal =
1430
- suppliedChecklist !== undefined && opts.local !== undefined
1431
- if (
1432
- grouped.kind === 'refused' &&
1433
- opts.preview === undefined &&
1434
- !hasChecklistAndLocal
1435
- ) {
1436
- logStep('Skipped')
1437
- logInfo(
1438
- 'No changed path carries an evidence/ segment, so there is nothing to post.',
1439
- )
1440
- outro()
1441
- if (emitJson) {
1442
- process.stdout.write(
1443
- `${JSON.stringify({
1444
- root,
1445
- ...(identity.number !== undefined && { number: identity.number }),
1446
- reason: 'no-evidence',
1447
- })}\n`,
1448
- )
1449
- }
1450
- return 0
1451
- }
1452
-
1453
- const repoRow = await gh(root, ['repo', 'view', '--json', 'nameWithOwner'])
1454
- const repo =
1455
- repoRow === null
1456
- ? undefined
1457
- : (JSON.parse(repoRow) as { nameWithOwner?: string }).nameWithOwner
1458
-
1459
- if (repo === undefined) {
1460
- return refuseWith(
1461
- 'gh-failed',
1462
- EVIDENCE_REFUSALS['gh-failed'],
1463
- emitJson,
1464
- root,
1465
- )
1466
- }
1467
-
1468
1437
  let commentId: number | undefined
1469
1438
  let carriedPreview: string | undefined
1470
1439
  let carriedChecklist: string | undefined
@@ -1480,6 +1449,8 @@ async function runEvidence(
1480
1449
  // An unread thread refuses rather than rendering, since a body built
1481
1450
  // without it knows neither the comment to edit nor the preview address
1482
1451
  // to carry, and posting it would duplicate the comment and drop the link.
1452
+ // It refuses ahead of no-evidence too, so that record never reports an
1453
+ // absent field it did not read.
1483
1454
  let comments: readonly { url?: string; body: string }[] | undefined
1484
1455
  if (commentsRow !== null) {
1485
1456
  try {
@@ -1506,6 +1477,56 @@ async function runEvidence(
1506
1477
  carriedLocal = findEvidenceLocal(comments)
1507
1478
  }
1508
1479
 
1480
+ // What the marked comment already shows a reviewer, kept apart from the
1481
+ // flags this call passed so a reader learns what is posted.
1482
+ const carried = {
1483
+ ...(carriedPreview !== undefined && { preview: carriedPreview }),
1484
+ ...(carriedLocal !== undefined && { local: carriedLocal }),
1485
+ ...(carriedChecklist !== undefined && { checklist: carriedChecklist }),
1486
+ }
1487
+
1488
+ // A local address rides on a checklist or a hosted preview and never opens
1489
+ // a comment alone, so a docs-only branch with a server up posts nothing.
1490
+ const hasChecklistAndLocal =
1491
+ suppliedChecklist !== undefined && opts.local !== undefined
1492
+ if (
1493
+ grouped.kind === 'refused' &&
1494
+ opts.preview === undefined &&
1495
+ !hasChecklistAndLocal
1496
+ ) {
1497
+ logStep('Skipped')
1498
+ logInfo(
1499
+ 'No changed path carries an evidence/ segment, so there is nothing to post.',
1500
+ )
1501
+ outro()
1502
+ if (emitJson) {
1503
+ process.stdout.write(
1504
+ `${JSON.stringify({
1505
+ root,
1506
+ ...(identity.number !== undefined && { number: identity.number }),
1507
+ reason: 'no-evidence',
1508
+ ...carried,
1509
+ })}\n`,
1510
+ )
1511
+ }
1512
+ return 0
1513
+ }
1514
+
1515
+ const repoRow = await gh(root, ['repo', 'view', '--json', 'nameWithOwner'])
1516
+ const repo =
1517
+ repoRow === null
1518
+ ? undefined
1519
+ : (JSON.parse(repoRow) as { nameWithOwner?: string }).nameWithOwner
1520
+
1521
+ if (repo === undefined) {
1522
+ return refuseWith(
1523
+ 'gh-failed',
1524
+ EVIDENCE_REFUSALS['gh-failed'],
1525
+ emitJson,
1526
+ root,
1527
+ )
1528
+ }
1529
+
1509
1530
  const preview = opts.preview ?? carriedPreview
1510
1531
  const local = opts.local ?? carriedLocal
1511
1532
  const checklist = suppliedChecklist ?? carriedChecklist
@@ -1548,6 +1569,7 @@ async function runEvidence(
1548
1569
  head: identity.head,
1549
1570
  body,
1550
1571
  ...(commentId !== undefined && { commentId }),
1572
+ ...carried,
1551
1573
  })}\n`,
1552
1574
  )
1553
1575
  }
@@ -2,7 +2,7 @@ import { isAbsolute, relative, resolve, sep } from 'node:path'
2
2
  import type { Command } from 'commander'
3
3
  import { type LessonOutcome, planLesson } from '@/teach/lesson'
4
4
  import { type NavOutcome, generateNav } from '@/teach/nav'
5
- import { type RenderOutcome, renderLessonBody } from '@/teach/render'
5
+ import type { RenderOutcome } from '@/teach/render'
6
6
  import {
7
7
  defineTerms,
8
8
  type ListOutcome,
@@ -393,10 +393,14 @@ export function register(program: Command): void {
393
393
  ' 1 refused, with the reason on stderr or in the JSON record',
394
394
  '',
395
395
  'Reads a JSON array of blocks from stdin, each a heading, paragraph,',
396
- 'list, refs, or raw block, and renders it through the same components',
397
- 'the fixture lesson is generated from. Content the structured blocks',
398
- 'cannot express takes type raw, carrying its own html verbatim,',
399
- 'unescaped.',
396
+ 'list, refs, code, or raw block, and renders it through the same',
397
+ 'components the fixture lesson is generated from. Content the',
398
+ 'structured blocks cannot express takes type raw, carrying its own',
399
+ 'html verbatim, unescaped.',
400
+ '',
401
+ 'A code block takes text and an optional lang, a highlight.js name or',
402
+ 'alias matching ^[a-z0-9+#-]+$, and renders highlighted at build time',
403
+ 'with no script. An unregistered lang renders as plain escaped code.',
400
404
  '',
401
405
  'A paragraph takes an optional cites array of reference numbers,',
402
406
  'rendered as footnote markers after its text. The one refs block',
@@ -752,6 +756,9 @@ async function runRender(opts: RenderCommandOptions): Promise<number> {
752
756
  )
753
757
  }
754
758
 
759
+ // Loaded here rather than at the top, since the renderer pulls in the
760
+ // highlighter and every other verb would pay for its grammars at startup.
761
+ const { renderLessonBody } = await import('@/teach/render')
755
762
  return reportRender(renderLessonBody(parsed), emitJson)
756
763
  }
757
764
 
@@ -210,6 +210,39 @@ export const COMPONENTS: readonly Component[] = [
210
210
  */
211
211
  export const TEACH_SIDEBAR_BREAKPOINT = 1100
212
212
 
213
+ /**
214
+ * The colors highlighted code renders in, one per token class the teach
215
+ * article maps highlight.js spans onto. Hex rather than `color-mix` so the
216
+ * contrast test can measure each against the code surface, and dark by default
217
+ * with a light override, following the same `data-theme` switch as the ground
218
+ * they sit on. Teach-local until a second surface renders highlighted code.
219
+ * `keyword`, `comment` and `meta` repeat the accent, muted and secondary
220
+ * values, so code reads in the page's own palette.
221
+ */
222
+ export const TEACH_SYNTAX: readonly {
223
+ readonly name: string
224
+ readonly dark: string
225
+ readonly light: string
226
+ }[] = [
227
+ { name: 'keyword', dark: '#c76b5f', light: '#ad4a4b' },
228
+ { name: 'string', dark: '#8dbb6c', light: '#2d6b22' },
229
+ { name: 'number', dark: '#d4a15a', light: '#8a5300' },
230
+ { name: 'title', dark: '#b99ad8', light: '#72489a' },
231
+ { name: 'attr', dark: '#6fb3c4', light: '#1e6478' },
232
+ { name: 'comment', dark: '#7f7f7c', light: '#6e6d6c' },
233
+ { name: 'meta', dark: '#8c8b86', light: '#666561' },
234
+ ]
235
+
236
+ const syntaxProperty = (name: string): string => `--teach-syntax-${name}`
237
+
238
+ const TEACH_SYNTAX_DARK = TEACH_SYNTAX.map(
239
+ ({ name, dark }) => ` ${syntaxProperty(name)}: ${dark};`,
240
+ ).join('\n')
241
+
242
+ const TEACH_SYNTAX_LIGHT = TEACH_SYNTAX.map(
243
+ ({ name, light }) => ` ${syntaxProperty(name)}: ${light};`,
244
+ ).join('\n')
245
+
213
246
  const TEACH_CHROME: Component = {
214
247
  name: 'teach-chrome',
215
248
  note: [
@@ -263,6 +296,12 @@ const TEACH_CHROME: Component = {
263
296
  --ink-faint: var(--color-muted);
264
297
  --accent: var(--color-accent);
265
298
  --accent-bg: var(--color-teach-accent-bg);
299
+
300
+ ${TEACH_SYNTAX_DARK}
301
+ }
302
+
303
+ [data-theme='light'] {
304
+ ${TEACH_SYNTAX_LIGHT}
266
305
  }
267
306
 
268
307
  @media (prefers-color-scheme: dark) {
@@ -1007,6 +1046,7 @@ const TEACH_ARTICLE: Component = {
1007
1046
  '--color-accent',
1008
1047
  '--teach-hand',
1009
1048
  '--teach-mono',
1049
+ ...TEACH_SYNTAX.map(({ name }) => syntaxProperty(name)),
1010
1050
  '--t1',
1011
1051
  '--t2',
1012
1052
  '--t3',
@@ -1092,6 +1132,18 @@ pre {
1092
1132
 
1093
1133
  pre code { background: none; padding: 0; font-size: 1em; }
1094
1134
 
1135
+ /* Build-time highlight.js spans. Every class reads a --teach-syntax-* token,
1136
+ so the dark and light values switch with the ground rather than per rule. */
1137
+ .hljs-keyword, .hljs-selector-tag, .hljs-literal, .hljs-built_in, .hljs-doctag { color: var(--teach-syntax-keyword); }
1138
+ .hljs-string, .hljs-regexp, .hljs-addition, .hljs-template-tag, .hljs-symbol { color: var(--teach-syntax-string); }
1139
+ .hljs-number, .hljs-type, .hljs-selector-class, .hljs-selector-id { color: var(--teach-syntax-number); }
1140
+ .hljs-title, .hljs-section, .hljs-name, .hljs-bullet { color: var(--teach-syntax-title); }
1141
+ .hljs-attr, .hljs-attribute, .hljs-variable, .hljs-template-variable, .hljs-property { color: var(--teach-syntax-attr); }
1142
+ .hljs-comment, .hljs-quote { color: var(--teach-syntax-comment); font-style: italic; }
1143
+ .hljs-meta, .hljs-deletion, .hljs-subst { color: var(--teach-syntax-meta); }
1144
+ .hljs-emphasis { font-style: italic; }
1145
+ .hljs-strong { font-weight: 700; }
1146
+
1095
1147
  /* ---- Callout ---- */
1096
1148
 
1097
1149
  .hard {
@@ -0,0 +1,24 @@
1
+ /** @jsxImportSource ../html */
2
+ import { highlight } from '@/teach/highlight'
3
+ import type { Element } from '@/teach/html/jsx-runtime'
4
+ import { raw } from '@/teach/html/raw'
5
+
6
+ export interface CodeProps {
7
+ /** A highlight.js language name or alias. Unregistered ones render plain. */
8
+ readonly lang?: string
9
+ readonly text: string
10
+ }
11
+
12
+ /**
13
+ * The one way code reaches a lesson body. The highlighter escapes every source
14
+ * character before wrapping tokens in spans, which is what makes passing its
15
+ * output through `raw` safe.
16
+ */
17
+ export function Code({ lang, text }: CodeProps): Element {
18
+ const className = lang === undefined ? 'hljs' : `hljs language-${lang}`
19
+ return (
20
+ <pre>
21
+ <code class={className}>{raw(highlight(text, lang).html)}</code>
22
+ </pre>
23
+ )
24
+ }
@@ -0,0 +1,71 @@
1
+ import hljs from 'highlight.js/lib/core'
2
+ import bash from 'highlight.js/lib/languages/bash'
3
+ import css from 'highlight.js/lib/languages/css'
4
+ import diff from 'highlight.js/lib/languages/diff'
5
+ import dockerfile from 'highlight.js/lib/languages/dockerfile'
6
+ import go from 'highlight.js/lib/languages/go'
7
+ import javascript from 'highlight.js/lib/languages/javascript'
8
+ import json from 'highlight.js/lib/languages/json'
9
+ import markdown from 'highlight.js/lib/languages/markdown'
10
+ import python from 'highlight.js/lib/languages/python'
11
+ import shell from 'highlight.js/lib/languages/shell'
12
+ import sql from 'highlight.js/lib/languages/sql'
13
+ import typescript from 'highlight.js/lib/languages/typescript'
14
+ import xml from 'highlight.js/lib/languages/xml'
15
+ import yaml from 'highlight.js/lib/languages/yaml'
16
+
17
+ /**
18
+ * A fixed set rather than all of highlight.js, since each grammar costs load
19
+ * time and a workspace rarely reaches past these. An unlisted language renders
20
+ * plain rather than refusing, so adding one here is the whole change.
21
+ */
22
+ const LANGUAGES = {
23
+ bash,
24
+ css,
25
+ diff,
26
+ dockerfile,
27
+ go,
28
+ javascript,
29
+ json,
30
+ markdown,
31
+ python,
32
+ shell,
33
+ sql,
34
+ typescript,
35
+ xml,
36
+ yaml,
37
+ }
38
+
39
+ for (const [name, grammar] of Object.entries(LANGUAGES)) {
40
+ hljs.registerLanguage(name, grammar)
41
+ }
42
+
43
+ export interface Highlighted {
44
+ /** Markup safe to embed inside `<code>`: every source character escaped. */
45
+ readonly html: string
46
+ /** The language as asked for, or `undefined` when it rendered plain. */
47
+ readonly language: string | undefined
48
+ }
49
+
50
+ function escapeCode(text: string): string {
51
+ return text
52
+ .replaceAll('&', '&amp;')
53
+ .replaceAll('<', '&lt;')
54
+ .replaceAll('>', '&gt;')
55
+ }
56
+
57
+ /**
58
+ * Highlights code at build time into `hljs-*` class spans the teach
59
+ * stylesheet colors, so an emitted page needs no script. An absent or
60
+ * unregistered language falls back to escaped plain text rather than
61
+ * throwing or guessing.
62
+ */
63
+ export function highlight(text: string, lang?: string): Highlighted {
64
+ if (lang === undefined || hljs.getLanguage(lang) === undefined) {
65
+ return { html: escapeCode(text), language: undefined }
66
+ }
67
+ return {
68
+ html: hljs.highlight(text, { language: lang, ignoreIllegals: true }).value,
69
+ language: lang,
70
+ }
71
+ }
package/src/teach/nav.ts CHANGED
@@ -1170,6 +1170,35 @@ function retargetReferenceLinks(
1170
1170
  )
1171
1171
  }
1172
1172
 
1173
+ const FENCED_CODE =
1174
+ /<pre><code class="language-([^"]+)">([\s\S]*?)<\/code><\/pre>/g
1175
+
1176
+ function decodeHtml(html: string): string {
1177
+ return html
1178
+ .replaceAll('&lt;', '<')
1179
+ .replaceAll('&gt;', '>')
1180
+ .replaceAll('&quot;', '"')
1181
+ .replaceAll('&#39;', "'")
1182
+ .replaceAll('&amp;', '&')
1183
+ }
1184
+
1185
+ /**
1186
+ * Highlights the fenced blocks `Bun.markdown` emitted, decoding each once
1187
+ * since the renderer already escaped it. A fence in a language the highlighter
1188
+ * does not register is left exactly as the markdown rendered it. The module is
1189
+ * imported here rather than at the top so a verb that never reaches a
1190
+ * reference page does not load its grammars.
1191
+ */
1192
+ async function highlightFences(html: string): Promise<string> {
1193
+ if (!html.includes('<pre><code class="language-')) return html
1194
+ const { highlight } = await import('@/teach/highlight')
1195
+ return html.replace(FENCED_CODE, (match, lang: string, escaped: string) => {
1196
+ const result = highlight(decodeHtml(escaped), lang)
1197
+ if (result.language === undefined) return match
1198
+ return `<pre><code class="hljs language-${lang}">${result.html}</code></pre>`
1199
+ })
1200
+ }
1201
+
1173
1202
  /**
1174
1203
  * One reference page as a styled page carrying the workspace chrome. The
1175
1204
  * markdown beside it stays the durable, promotable half, and this file is
@@ -1191,10 +1220,12 @@ async function renderReferencePage(
1191
1220
  readField(parseFrontmatter(source), 'title') ?? referenceFallbackTitle(file)
1192
1221
 
1193
1222
  const rendered = retargetReferenceLinks(
1194
- Bun.markdown.html(stripFrontmatter(source), {
1195
- noHtmlBlocks: true,
1196
- noHtmlSpans: true,
1197
- }),
1223
+ await highlightFences(
1224
+ Bun.markdown.html(stripFrontmatter(source), {
1225
+ noHtmlBlocks: true,
1226
+ noHtmlSpans: true,
1227
+ }),
1228
+ ),
1198
1229
  new Set(detail.referenceFiles),
1199
1230
  )
1200
1231
  const body = /<h1[\s>]/.test(rendered)
@@ -1,3 +1,4 @@
1
+ import { Code } from '@/teach/components/code'
1
2
  import { Heading } from '@/teach/components/heading'
2
3
  import { List } from '@/teach/components/list'
3
4
  import { Paragraph } from '@/teach/components/paragraph'
@@ -19,6 +20,7 @@ export type LessonBlock =
19
20
  readonly ordered?: boolean
20
21
  }
21
22
  | { readonly type: 'refs'; readonly items: readonly Reference[] }
23
+ | { readonly type: 'code'; readonly text: string; readonly lang?: string }
22
24
  | { readonly type: 'raw'; readonly html: string }
23
25
 
24
26
  export interface RenderRendered {
@@ -28,7 +30,17 @@ export interface RenderRendered {
28
30
 
29
31
  export type RenderOutcome = RenderRendered | TeachRefused
30
32
 
31
- const BLOCK_TYPES = ['heading', 'paragraph', 'list', 'refs', 'raw'] as const
33
+ const BLOCK_TYPES = [
34
+ 'heading',
35
+ 'paragraph',
36
+ 'list',
37
+ 'refs',
38
+ 'code',
39
+ 'raw',
40
+ ] as const
41
+
42
+ /** Bounds a lang to what a class name can carry, so it never injects. */
43
+ const CODE_LANG = /^[a-z0-9+#-]+$/
32
44
 
33
45
  const LINK_PROTOCOLS = new Set(['http:', 'https:'])
34
46
 
@@ -170,6 +182,20 @@ function renderBlock(
170
182
  referenceCount: items.length,
171
183
  }
172
184
  }
185
+ case 'code': {
186
+ if (!isString(fields.text)) {
187
+ return badBlock(index, 'code needs a string text')
188
+ }
189
+ if (
190
+ fields.lang !== undefined &&
191
+ !(isString(fields.lang) && CODE_LANG.test(fields.lang))
192
+ ) {
193
+ return badBlock(index, `code lang must match ${CODE_LANG.source}`)
194
+ }
195
+ return {
196
+ html: render(Code({ lang: fields.lang, text: fields.text })),
197
+ }
198
+ }
173
199
  case 'raw': {
174
200
  if (!isString(fields.html)) {
175
201
  return badBlock(index, 'raw needs a string html')
@@ -56,7 +56,7 @@ Both fields feed `canon/context/index.md` when regenerated.
56
56
 
57
57
  Include whenever the domain has content for them. Never pad an entry with an empty heading.
58
58
 
59
- - `## Decisions`: non-obvious choices and the reasoning behind them. The highest-value section in the folder and the one most often missing.
59
+ - `## Decisions`: non-obvious choices and the reasoning behind them. The highest-value section in the folder and the one most often missing. A decision resting on how an outside tool, an installer, or the model behaves may close with one `Revisit when <finding>.` sentence naming the harm the decision prevents, and a decision resting on the project's own code takes none.
60
60
  - `## Gotchas`: workarounds, things tried and rejected, scar tissue worth preserving.
61
61
 
62
62
  ## Optional sections
@@ -45,11 +45,12 @@ A record failing these is non-conforming even when it satisfies every shape rule
45
45
 
46
46
  ## Sections
47
47
 
48
- Use `## Context`, `## Decision`, `## Alternatives`, and `## Measurements`.
48
+ Use `## Context`, `## Decision`, `## Alternatives`, `## Revisit when`, and `## Measurements`.
49
49
 
50
50
  - `## Context`: the problem as it stood, stated so a reader needs nothing else open. Restate a fact rather than pointing at where it was found.
51
51
  - `## Decision`: what was chosen, and why, in enough detail that a reader can tell it apart from an alternative that sounds similar.
52
52
  - `## Alternatives`: each one considered and dropped, with the reason it lost. An alternative with no stated reason reads as a claim nobody checked.
53
+ - `## Revisit when`: one bullet per finding that would reopen the decision. Required when the record came from a groundwork track whose deciding leans carried "Overturned by" lines, which carry over as written. It is optional otherwise, for a decision resting on how an outside tool, an installer, or the model behaves. Skip it for a decision resting on the project's own code.
53
54
  - `## Measurements`: skip when the decision cites no number. When it does, state the number and close with the commit it was read against, per Verification anchors below.
54
55
 
55
56
  ## Verification anchors
@@ -94,6 +95,10 @@ description: <one line naming what was decided>
94
95
  - **<Alternative>.** <Why it lost.>
95
96
  - **<Alternative>.** <Why it lost.>
96
97
 
98
+ ## Revisit when
99
+
100
+ - <The finding that would reopen the decision.>
101
+
97
102
  ## Measurements
98
103
 
99
104
  <The number the decision rests on.> Measured at <short-sha> on <YYYY-MM-DD>.
@@ -102,6 +102,7 @@ Closes the folder. Everything above it is input.
102
102
  - The goal
103
103
  - The items to do
104
104
  - What was considered and dropped
105
+ - `## Revisit when`: the "Overturned by" line of each lean that became the decision, carried over as written. A lean the decision did not take carries nothing, and a track whose deciding leans carried no overturn line omits the section.
105
106
 
106
107
  The dropped list is what stops a future session re-proposing something already rejected.
107
108
 
@@ -21,7 +21,7 @@ Does not govern:
21
21
 
22
22
  - The problem being solved and for whom
23
23
  - User-facing goals stated as outcomes, not implementation
24
- - Explicit non-goals that prevent feature creep. Mark deferred items "(deferred)" so they read as paused, not excluded.
24
+ - Explicit non-goals that prevent feature creep. Mark deferred items "(deferred)" so they read as paused, not excluded. A non-goal resting on how an outside tool, an installer, or the model behaves may close with one `Revisit when <finding>.` sentence, and a non-goal resting on scope alone takes none.
25
25
  - MVP features as a numbered list: feature name and one-line description
26
26
  - Tech stack as a plain list of tools
27
27
  - Hard constraints that shape every decision
package/standards/rule.md CHANGED
@@ -79,6 +79,7 @@ paths:
79
79
  - Use imperative voice for every rule (`Prefix booleans with is`, not `Booleans should be prefixed`)
80
80
  - State one rule per bullet as a single directive line
81
81
  - State what to do and what not to do. Do not explain the reasoning behind a rule.
82
+ - End the directive line of a ban asserting how the model or a tool behaves with one `Revisit when <finding>.` sentence, which states a condition rather than the reasoning. A style, safety, or consistency ban takes none.
82
83
  - Phrase a rule as a ban on the forbidden shape when it could otherwise enumerate allowed options, so it stays stable as categories grow
83
84
  - Cut any rule that resists crisp one-line phrasing. Vague guidance is worse than none.
84
85
  - Keep the file to one topic. A second topic is a second rule file.
@@ -105,7 +105,7 @@ allowed-tools: <tools required>
105
105
  - Group bullets under H2 headings by domain concern. Keep dos and don'ts together under the topic they belong to rather than splitting them into flat rules and constraints sections.
106
106
  - One actionable constraint per bullet. Prefer the `X over Y` form for preferences.
107
107
  - Do not include code examples unless a one to three line inline snippet captures a pattern the model cannot infer.
108
- - Do not duplicate general knowledge the model already has. Focus on project-specific conventions and preferences.
108
+ - Do not duplicate general knowledge the model already has. Focus on project-specific conventions and preferences. Revisit when removing a line of general knowledge from a skill is shown to change what the agent does.
109
109
 
110
110
  ### Progressive disclosure
111
111
 
@@ -79,6 +79,7 @@ A standard failing these questions is non-conforming even when it satisfies ever
79
79
  - State the forbidden shape rather than enumerating allowed options, so a rule survives new categories
80
80
  - Cut any rule that resists a crisp one-line phrasing
81
81
  - Do not pad with filler prose. Every line earns its place as a usable reference entry.
82
+ - Close a ban asserting how the model or a tool behaves with one `Revisit when <finding>.` sentence, since that claim can stop holding while the ban reads the same. A style, safety, or consistency ban takes none, and filling one with a condition is padding.
82
83
 
83
84
  ## Success criterion
84
85
 
@@ -128,6 +128,20 @@ Splits what was read from what was found.
128
128
 
129
129
  A source found and not read is listed as a lead because a link attached to a page nobody opened is worse than no link. Listing it still pays, since it stops a later session searching for what this one already surfaced.
130
130
 
131
+ ### Choosing sources
132
+
133
+ Rest each general claim on the highest rung that covers it:
134
+
135
+ 1. Documentation or a specification published by whoever owns the subject
136
+ 2. A canonical work on the subject, such as the standard text, a reference book, or a primary paper
137
+ 3. Other material, such as a tutorial, a course, or a post, and only for a claim the first two rungs do not cover
138
+
139
+ A subject nobody owns, such as a mathematical topic or a craft, has no first rung, so its list starts at the second and is not missing anything. Record a third-rung source with the claim it covers and why no higher rung does, since that reason is what a later session checks the choice against.
140
+
141
+ The learner's own projects and material specific to one employer or organization are never a source for a general claim. Either may appear in the workspace as a worked application labeled with whose it is, and neither stands in `RESOURCES.md` as proof of what the subject says.
142
+
143
+ A source reachable only through a paywall or a summary stays a lead until it is opened. A review or summary of a book is a different source from the book, so list the summary as read on its own rung and the book as a lead.
144
+
131
145
  ## Conventions
132
146
 
133
147
  - Write the markdown half in the format the authoring gates read, so a page promoted later needs no conversion.
@@ -141,6 +155,7 @@ A source found and not read is listed as a lead because a link attached to a pag
141
155
  - **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.
142
156
  - **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.
143
157
  - **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.
158
+ - **The first search result as the source list.** A workspace resting on whatever a search returned first teaches the weakest account of the subject, and nothing in the folder says a stronger one was never looked for.
144
159
  - **The renumbered folder.** Closing a gap in the ordinals moves every name a reader or a record already cited.
145
160
 
146
161
  ## Template
@@ -52,6 +52,50 @@ run_base_phase() {
52
52
  log_info "$pass_msg"
53
53
  }
54
54
 
55
+ check_markdown_bans() {
56
+ if ! command -v canon >/dev/null 2>&1; then
57
+ log_info "Skipped: no canon binary on PATH. Install with \`bun install --global @erclx/canon\`."
58
+ return 0
59
+ fi
60
+
61
+ local files=()
62
+ while IFS= read -r file; do
63
+ files+=("$file")
64
+ done < <(git ls-files --cached --others --exclude-standard -- '*.md' ':(exclude)CHANGELOG.md' ':(exclude)**/CHANGELOG.md')
65
+
66
+ if [ ${#files[@]} -eq 0 ]; then
67
+ log_info "No markdown file to check outside the exclusion set."
68
+ return 0
69
+ fi
70
+
71
+ local output code
72
+ if output=$(canon markdown audit "${files[@]}" 2>&1); then
73
+ code=0
74
+ else
75
+ code=$?
76
+ fi
77
+
78
+ case "$code" in
79
+ 0)
80
+ log_info "No banned character"
81
+ ;;
82
+ 1)
83
+ log_info "Skipped: canon markdown audit refused and measured nothing."
84
+ ;;
85
+ 2)
86
+ echo "$output" | pipe_output
87
+ log_error "Markdown prose carries a banned character, or a relative link resolves to nothing on disk"
88
+ ;;
89
+ 3)
90
+ echo "$output" | pipe_output
91
+ log_error "The markdown audit shipped an empty ban set, so the corpus was walked and nothing was looked for"
92
+ ;;
93
+ *)
94
+ log_error "canon markdown audit exited $code, which is neither a pass nor a finding"
95
+ ;;
96
+ esac
97
+ }
98
+
55
99
  run_check() {
56
100
  local cmd=$1
57
101
  local err_msg=$2
@@ -86,6 +130,9 @@ main() {
86
130
  run_check "bun run test:run" "Unit tests failed"
87
131
  log_info "Unit tests passed"
88
132
 
133
+ log_step "Markdown bans"
134
+ check_markdown_bans
135
+
89
136
  if [ "$NESTED" = false ]; then
90
137
  echo -e "${GREY}└${NC}\n"
91
138
  echo -e "${GREEN}✓ Verification passed${NC}"
@@ -28,7 +28,7 @@ The go stack also ships `smoke_test.go` at the module root as a copy-once seed.
28
28
 
29
29
  ## verify.sh
30
30
 
31
- - Run Typecheck (`go vet`), Lint (`golangci-lint run`), and Unit tests (`go test -v`), plus the four base phases wherever the folder declares their scripts.
31
+ - Run Typecheck (`go vet`), Lint (`golangci-lint run`), and Unit tests (`go test -v`), plus the four base phases wherever the folder declares their scripts, then the markdown bans stage base ships.
32
32
  - Keep `build` out of `verify.sh`. `go build ./...` exits 1 with `no packages to build` on a module holding only a test file, and a seeded `main.go` would assume every module is a binary rather than a library.
33
33
  - Keep `-race` out of `test:run`. The race detector needs cgo and a C toolchain, so it belongs in CI rather than in a local `bun run check`.
34
34
 
@@ -56,6 +56,50 @@ run_base_phase() {
56
56
  log_info "$pass_msg"
57
57
  }
58
58
 
59
+ check_markdown_bans() {
60
+ if ! command -v canon >/dev/null 2>&1; then
61
+ log_info "Skipped: no canon binary on PATH. Install with \`bun install --global @erclx/canon\`."
62
+ return 0
63
+ fi
64
+
65
+ local files=()
66
+ while IFS= read -r file; do
67
+ files+=("$file")
68
+ done < <(git ls-files --cached --others --exclude-standard -- '*.md' ':(exclude)CHANGELOG.md' ':(exclude)**/CHANGELOG.md')
69
+
70
+ if [ ${#files[@]} -eq 0 ]; then
71
+ log_info "No markdown file to check outside the exclusion set."
72
+ return 0
73
+ fi
74
+
75
+ local output code
76
+ if output=$(canon markdown audit "${files[@]}" 2>&1); then
77
+ code=0
78
+ else
79
+ code=$?
80
+ fi
81
+
82
+ case "$code" in
83
+ 0)
84
+ log_info "No banned character"
85
+ ;;
86
+ 1)
87
+ log_info "Skipped: canon markdown audit refused and measured nothing."
88
+ ;;
89
+ 2)
90
+ echo "$output" | pipe_output
91
+ log_error "Markdown prose carries a banned character, or a relative link resolves to nothing on disk"
92
+ ;;
93
+ 3)
94
+ echo "$output" | pipe_output
95
+ log_error "The markdown audit shipped an empty ban set, so the corpus was walked and nothing was looked for"
96
+ ;;
97
+ *)
98
+ log_error "canon markdown audit exited $code, which is neither a pass nor a finding"
99
+ ;;
100
+ esac
101
+ }
102
+
59
103
  run_check() {
60
104
  local cmd=$1
61
105
  local err_msg=$2
@@ -91,6 +135,9 @@ main() {
91
135
  run_check "bun run test:run" "Unit tests failed"
92
136
  log_info "Unit tests passed"
93
137
 
138
+ log_step "Markdown bans"
139
+ check_markdown_bans
140
+
94
141
  if [ "$NESTED" = false ]; then
95
142
  echo -e "${GREY}└${NC}\n"
96
143
  echo -e "${GREEN}✓ Verification passed${NC}"
@@ -42,7 +42,7 @@ The php stack also ships `tests/SmokeTest.php` as a copy-once seed, so `bun run
42
42
  ## verify.sh
43
43
 
44
44
  - Stop and name `composer install` when `vendor/bin/phpunit` is missing.
45
- - Run Typecheck (`phpstan analyse`), Lint (`php-cs-fixer check`), and Unit tests (`phpunit --testdox`), plus the four base phases wherever the folder declares their scripts.
45
+ - Run Typecheck (`phpstan analyse`), Lint (`php-cs-fixer check`), and Unit tests (`phpunit --testdox`), plus the four base phases wherever the folder declares their scripts, then the markdown bans stage base ships.
46
46
 
47
47
  ## Gitignore
48
48
 
@@ -51,6 +51,50 @@ run_base_phase() {
51
51
  log_info "$pass_msg"
52
52
  }
53
53
 
54
+ check_markdown_bans() {
55
+ if ! command -v canon >/dev/null 2>&1; then
56
+ log_info "Skipped: no canon binary on PATH. Install with \`bun install --global @erclx/canon\`."
57
+ return 0
58
+ fi
59
+
60
+ local files=()
61
+ while IFS= read -r file; do
62
+ files+=("$file")
63
+ done < <(git ls-files --cached --others --exclude-standard -- '*.md' ':(exclude)CHANGELOG.md' ':(exclude)**/CHANGELOG.md')
64
+
65
+ if [ ${#files[@]} -eq 0 ]; then
66
+ log_info "No markdown file to check outside the exclusion set."
67
+ return 0
68
+ fi
69
+
70
+ local output code
71
+ if output=$(canon markdown audit "${files[@]}" 2>&1); then
72
+ code=0
73
+ else
74
+ code=$?
75
+ fi
76
+
77
+ case "$code" in
78
+ 0)
79
+ log_info "No banned character"
80
+ ;;
81
+ 1)
82
+ log_info "Skipped: canon markdown audit refused and measured nothing."
83
+ ;;
84
+ 2)
85
+ echo "$output" | pipe_output
86
+ log_error "Markdown prose carries a banned character, or a relative link resolves to nothing on disk"
87
+ ;;
88
+ 3)
89
+ echo "$output" | pipe_output
90
+ log_error "The markdown audit shipped an empty ban set, so the corpus was walked and nothing was looked for"
91
+ ;;
92
+ *)
93
+ log_error "canon markdown audit exited $code, which is neither a pass nor a finding"
94
+ ;;
95
+ esac
96
+ }
97
+
54
98
  run_check() {
55
99
  local cmd=$1
56
100
  local err_msg=$2
@@ -85,6 +129,9 @@ main() {
85
129
  run_check "bun run test:run" "Unit tests failed"
86
130
  log_info "Unit tests passed"
87
131
 
132
+ log_step "Markdown bans"
133
+ check_markdown_bans
134
+
88
135
  if [ "$NESTED" = false ]; then
89
136
  echo -e "${GREY}└${NC}\n"
90
137
  echo -e "${GREEN}✓ Verification passed${NC}"
@@ -27,7 +27,7 @@ The python stack also ships `tests/test_smoke.py` as a copy-once seed. `pytest`
27
27
  - `pytest.ini`: tests live under `tests/`, source under `src/`.
28
28
  - `.coveragerc`: branch coverage on `src/`, html report under `.coverage_cache/html`.
29
29
  - `.python-version`: pinned to `3.14` to match `uv init` defaults.
30
- - `scripts/verify.sh`: overrides base verify to add Typecheck (`mypy`), Lint (`ruff check && ruff format --check`), and Tests (`pytest -v`) phases.
30
+ - `scripts/verify.sh`: overrides base verify to add Typecheck (`mypy`), Lint (`ruff check && ruff format --check`), and Tests (`pytest -v`) phases, then the same markdown bans phase base ships.
31
31
 
32
32
  ## Hybrid project shape
33
33
 
@@ -50,6 +50,50 @@ run_base_phase() {
50
50
  log_info "$pass_msg"
51
51
  }
52
52
 
53
+ check_markdown_bans() {
54
+ if ! command -v canon >/dev/null 2>&1; then
55
+ log_info "Skipped: no canon binary on PATH. Install with \`bun install --global @erclx/canon\`."
56
+ return 0
57
+ fi
58
+
59
+ local files=()
60
+ while IFS= read -r file; do
61
+ files+=("$file")
62
+ done < <(git ls-files --cached --others --exclude-standard -- '*.md' ':(exclude)CHANGELOG.md' ':(exclude)**/CHANGELOG.md')
63
+
64
+ if [ ${#files[@]} -eq 0 ]; then
65
+ log_info "No markdown file to check outside the exclusion set."
66
+ return 0
67
+ fi
68
+
69
+ local output code
70
+ if output=$(canon markdown audit "${files[@]}" 2>&1); then
71
+ code=0
72
+ else
73
+ code=$?
74
+ fi
75
+
76
+ case "$code" in
77
+ 0)
78
+ log_info "No banned character"
79
+ ;;
80
+ 1)
81
+ log_info "Skipped: canon markdown audit refused and measured nothing."
82
+ ;;
83
+ 2)
84
+ echo "$output" | pipe_output
85
+ log_error "Markdown prose carries a banned character, or a relative link resolves to nothing on disk"
86
+ ;;
87
+ 3)
88
+ echo "$output" | pipe_output
89
+ log_error "The markdown audit shipped an empty ban set, so the corpus was walked and nothing was looked for"
90
+ ;;
91
+ *)
92
+ log_error "canon markdown audit exited $code, which is neither a pass nor a finding"
93
+ ;;
94
+ esac
95
+ }
96
+
53
97
  run_check() {
54
98
  local cmd=$1
55
99
  local err_msg=$2
@@ -88,6 +132,9 @@ main() {
88
132
  run_check "bun run build" "Build failed"
89
133
  log_info "Build passed"
90
134
 
135
+ log_step "Markdown bans"
136
+ check_markdown_bans
137
+
91
138
  # Runs whenever the stack shipped a card exclusion check, which keeps the
92
139
  # stage stack-agnostic here and the check itself in the stack that owns the
93
140
  # card route's shape. It reads the build above, so it sits after it.
@@ -128,6 +128,6 @@ The sweep under `screenshots/` is ignored again, and only a flagged case's `evid
128
128
 
129
129
  ## Verify script
130
130
 
131
- The web layer's `scripts/verify.sh` replaces the base version. Order: typecheck, lint, format, spelling, shell, unit tests, build, then the card exclusion check where one is installed. Stack adapters may override if their typecheck or build differs.
131
+ The web layer's `scripts/verify.sh` replaces the base version. Order: typecheck, lint, format, spelling, shell, unit tests, build, markdown bans, then the card exclusion check where one is installed. Stack adapters may override if their typecheck or build differs.
132
132
 
133
133
  The card stage is conditional on `scripts/check-card-exclusion.sh` existing rather than on the stack, so a project with no card route runs one less stage and needs no override to skip it. A stack that ships a card route ships that script, and the check reads the build the stage before it produced.