@erclx/canon 4.40.1 → 4.41.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/claude-teach/SKILL.md +9 -2
- package/claude/skills/claude-teach/references/lesson-craft.md +1 -3
- package/docs/agents/commands.md +7 -1
- package/docs/agents/index.md +1 -1
- package/docs/agents/install-and-sync.md +9 -0
- package/docs/agents/teach.md +25 -4
- package/docs/target-projects.md +13 -0
- package/package.json +1 -1
- package/src/commands/migrate.ts +150 -0
- package/src/commands/teach.ts +91 -0
- package/src/migrate/rule-layout.ts +305 -0
- package/src/sync/layout.ts +9 -6
- package/src/teach/nav.ts +778 -0
- package/tooling/astro/configs/playwright.config.ts +1 -1
- package/tooling/vite-react/configs/playwright.config.ts +1 -1
|
@@ -93,15 +93,22 @@ canon teach lesson <topic> --json \
|
|
|
93
93
|
--options <how many options each question carries>
|
|
94
94
|
```
|
|
95
95
|
|
|
96
|
-
It writes nothing and reports
|
|
96
|
+
It writes nothing and reports three things:
|
|
97
97
|
|
|
98
98
|
- `lesson`, the numbered path the lesson takes. Write it there.
|
|
99
|
-
- `stylesheet` with `stylesheetExists`. Read it and embed its content into a `<style>` block in `<head>`, seeding it through the verb below when `stylesheetExists` is false, which is the first lesson in the workspace. Every lesson after that embeds the file's current content and adds to it rather than replacing it, since overwriting discards what the last lesson put there.
|
|
100
99
|
- `success`, the mission's success lines, carried here so Step 5 needs no second read.
|
|
101
100
|
- `quiz`, one entry per question, carrying `order` and `answer`.
|
|
102
101
|
|
|
103
102
|
Write the correct option first, then present the options in the order `order` reports, reading it as authored indices where `0` is the correct one. Take the order as given. Position drawn here rather than chosen is the whole reason the verb exists, and a lesson that reorders on its own judgment puts the answer back in the first slot.
|
|
104
103
|
|
|
104
|
+
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
|
+
|
|
106
|
+
```bash
|
|
107
|
+
canon teach nav <topic> --json
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
It fills every marker pair from what the workspace holds on disk: the embedded stylesheet, the header with its breadcrumb and jump menus, the prev/next footer nav, and the behavior scripts, and it rewrites the workspace's contents page and the teach-root listing in the same run. It refuses a lesson missing one of the four marker pairs by name rather than guessing at the boundary, so a marker dropped while writing the lesson is caught here rather than read back later as a lesson nothing links to. Report it rather than proceeding silently when the verb does not resolve, which is an installed CLI predating it, and never compose the chrome by hand as a fallback.
|
|
111
|
+
|
|
105
112
|
Seed the stylesheet through the verb rather than authoring a palette, on the first lesson in a workspace:
|
|
106
113
|
|
|
107
114
|
```bash
|
|
@@ -11,9 +11,7 @@ Judgment rather than shape. The workspace standard fixes where a lesson sits and
|
|
|
11
11
|
|
|
12
12
|
A workspace accumulates lessons over weeks. The learner reads them as one body of material, so a lesson that invents its own look reads as someone else's work.
|
|
13
13
|
|
|
14
|
-
-
|
|
15
|
-
- Promote anything used a second time into that stylesheet. A second use makes it a component of the course.
|
|
16
|
-
- Add to that stylesheet rather than replacing it. The lesson writing it is rarely the lesson that needs it changed, and a rewrite drops what every earlier lesson embeds it for.
|
|
14
|
+
- `canon teach nav` embeds and re-embeds the shared stylesheet into every lesson on each run, so growing that stylesheet, by promoting anything used a second time into it, is the only step left by hand.
|
|
17
15
|
- Keep the structural furniture identical across lessons: where the title sits, where the quiz sits, what a correct answer looks like
|
|
18
16
|
- Do not restate styles inside a lesson. A local override is a decision the next lesson has to either copy or contradict.
|
|
19
17
|
|
package/docs/agents/commands.md
CHANGED
|
@@ -41,6 +41,7 @@ Full help: `canon <command> --help`. Behavior notes for the install and sync ver
|
|
|
41
41
|
| `canon teach resource` | Record sources and leads in a workspace, repeating `--read` or `--lead` as `<title>=<url>` (`--json`) |
|
|
42
42
|
| `canon teach glossary` | Add terms to a workspace glossary alphabetically, repeating `--term <term>=<definition>` (`--json`) |
|
|
43
43
|
| `canon teach stylesheet` | Seed a workspace stylesheet from the design source, leaving an existing one alone without `--force` (`--json`) |
|
|
44
|
+
| `canon teach nav` | Rewrite the teach-root listing, a workspace's contents page, and each lesson's chrome from its four marker regions (`--json`) |
|
|
44
45
|
| `canon records validate` | Report a session record or a standard against the standard governing it, per kind (`--json`) |
|
|
45
46
|
| `canon records migrate` | Rewrite the records a validate finding names a recoverable transform for (`--write`, `--json`) |
|
|
46
47
|
| `canon records size` | Report what each record folder holds and how much of it is recent, heaviest first (`--json`) |
|
|
@@ -49,6 +50,7 @@ Full help: `canon <command> --help`. Behavior notes for the install and sync ver
|
|
|
49
50
|
| `canon migrate rename` | Rewrite every unprotected `aitk` token to `canon` and move the paths that carry the name, reporting the plan without `--write` (`--scope`, `--json`) |
|
|
50
51
|
| `canon migrate records` | Move the gitignored session records to `.canon/` and repoint every tracked citation, reporting the plan without `--write` (`--root`, `--json`) |
|
|
51
52
|
| `canon migrate record-tree` | Repoint the old-root citations inside the records themselves, scoped to the live folders and reporting every line without `--write` (`--root`, `--json`) |
|
|
53
|
+
| `canon migrate rule-layout` | Move a target's installed rules from the flat `.claude/rules/<subdir>/` layout to `.claude/rules/canon/<subdir>/`, reporting the plan without `--write` (`--root`, `--json`) |
|
|
52
54
|
| `canon sessions list` | Resolve live sessions to the worktree and branch each holds, filtered by `--branch` (`--json`) |
|
|
53
55
|
| `canon worktrees list` | Report which worktrees are reclaimable, keyed on the pull request having merged, with every refusal and the removal route named (`--json`) |
|
|
54
56
|
| `canon worktrees reclaim` | Remove every reclaimable worktree and the branch behind it, reporting without acting under `--dry-run` (`--json`) |
|
|
@@ -125,7 +127,7 @@ Each domain exposes a consistent shape where applicable: `list`, `install`, `syn
|
|
|
125
127
|
| `slides` | `render`, `list` |
|
|
126
128
|
| `tasks` | `archive`, `validate` |
|
|
127
129
|
| `intake` | `list`, `answer` |
|
|
128
|
-
| `teach` | `list`, `open`, `resource`, `glossary`, `lesson`, `stylesheet`
|
|
130
|
+
| `teach` | `list`, `open`, `resource`, `glossary`, `lesson`, `stylesheet`, `nav` |
|
|
129
131
|
| `comments` | `scan` |
|
|
130
132
|
| `context` | `audit` |
|
|
131
133
|
| `markdown` | `audit` |
|
|
@@ -155,6 +157,10 @@ A record folder already present at the destination is a refusal rather than a me
|
|
|
155
157
|
|
|
156
158
|
Every citation in scope is reported with its file, its line number, and the line text. The record tree is untracked, so a wrong rewrite has no git undo, and that report is what a reader judges before passing `--write`. Marking a line that has to keep the old spelling uses the same `canon-keep-record-root` comment, and a second run rewriting nothing is the idempotence check.
|
|
157
159
|
|
|
160
|
+
`migrate rule-layout` moves a target still on the flat installed-rule layout onto the `canon/`-wrapped one. It classifies each flat file against the target's own recorded stamp: an unchanged hash is clean, a changed one is edited, and a name neither the stamp nor the current rule catalog can vouch for is unclaimed and left in place. Clean and edited files both relocate with their content untouched, since moving a rule is a different decision from re-syncing it. The one renumbered rule reads its destination from a table rather than from its own current name, so it never lands as a second copy under the retired number.
|
|
161
|
+
|
|
162
|
+
A destination that already holds the same bytes marks the flat file a duplicate, which `--write` deletes rather than moves. One holding different bytes is a real collision: neither file is touched, it is reported by name, and every other planned move still applies independently. Run this before `canon gov install` or `canon gov sync` against a target still on the flat layout, since neither bootstrap verb detects or clears it on its own.
|
|
163
|
+
|
|
158
164
|
## Version skew
|
|
159
165
|
|
|
160
166
|
`canon sync --check` and `canon claude skills drift` are the two moments a target
|
package/docs/agents/index.md
CHANGED
|
@@ -44,6 +44,6 @@ CLI catalog and invocation rules for agents, split by command domain. Start with
|
|
|
44
44
|
- [Superseded values](superseded.md): Reading where the tree still asserts a value a changed convention no longer produces, why the sweep keys on the value rather than the file, the family stem behind a templated citation, the exemption marker, the blind spots it cannot reach, and why it reports rather than gates
|
|
45
45
|
- [Targets](targets.md): The projects this toolkit installed into, the record the install writes against the sweep that backs it, what bounds each answer, and the cross-target pull request read
|
|
46
46
|
- [Tasks](tasks.md): Selecting a shipped task by stem or pull request, recording a number and closing an outcome, the refusal reasons, the board and backlog checks validate runs, and why the board root defaults to the main worktree
|
|
47
|
-
- [Teach](teach.md): Listing learning workspaces and the ordinal a new one takes, opening one with its required files, recording sources and glossary terms, resolving what the next lesson needs before it is written, the refusal reasons, and why every write here runs through a verb
|
|
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
|
|
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
|
|
@@ -91,6 +91,15 @@ headlessly, so a call that names its stack is unchanged.
|
|
|
91
91
|
target before anything else, so a path that does not exist fails rather than
|
|
92
92
|
being scaffolded.
|
|
93
93
|
|
|
94
|
+
A target still on the flat installed-rule layout from an older release reports
|
|
95
|
+
"No governance surfaces found in target" from `canon gov sync` and exits 0,
|
|
96
|
+
since sync reads only the current wrapped destination. `canon gov install`
|
|
97
|
+
against that same target writes a second, wrapped copy beside the flat one
|
|
98
|
+
rather than reading or clearing it, so the target ends up holding both, and
|
|
99
|
+
Claude Code loads both copies of an edited rule at session start. Run `canon
|
|
100
|
+
migrate rule-layout` first to move a target off the flat layout, which reports
|
|
101
|
+
what it would move and applies it under `--write`.
|
|
102
|
+
|
|
94
103
|
## Standards resolution
|
|
95
104
|
|
|
96
105
|
`canon standards <name>` writes the document to stdout and the root it answered
|
package/docs/agents/teach.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
title: Teach
|
|
3
|
-
description: Listing learning workspaces and the ordinal a new one takes, opening one with its required files, recording sources and glossary terms, resolving what the next lesson needs before it is written, the refusal reasons, and why every write here runs through a verb
|
|
3
|
+
description: Listing learning workspaces and the ordinal a new one takes, opening one with its required files, recording sources and glossary terms, resolving what the next lesson needs before it is written, 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
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Teach
|
|
@@ -121,16 +121,37 @@ canon teach lesson regular-expressions \
|
|
|
121
121
|
| `--json` | Emit a machine-readable record on stdout |
|
|
122
122
|
| `--root <path>` | Teach root, defaulting to the main worktree |
|
|
123
123
|
|
|
124
|
-
It reports
|
|
124
|
+
It reports three things. `lesson` is the numbered path the lesson takes, derived from the highest ordinal already in `lessons/` the way an open derives a workspace ordinal. `success` carries the mission's success lines, so a session reports progress against the exit criteria without a second read of `MISSION.md`. `quiz` carries one entry per question.
|
|
125
125
|
|
|
126
|
-
|
|
126
|
+
A lesson written against this record carries its chrome as four empty marker pairs rather than hand-composed markup: `canon:teach:style`, `canon:teach:header`, `canon:teach:footnav`, and `canon:teach:scripts`. `canon teach nav`, below, fills them from what the workspace holds on disk.
|
|
127
127
|
|
|
128
|
-
`canon teach stylesheet <topic>`
|
|
128
|
+
`canon teach stylesheet <topic>` writes the one file every lesson in the workspace embeds, seeded from the design source so a workspace renders in the system every other surface does. It refuses to overwrite, reporting `written` as false where the workspace already carries one, and `--force` takes the seed back over it. Each workspace used to carry a hand-authored palette, which is how the course styling forked once per workspace, so a lesson adds its own rules under the seed and reaches a value through its custom property rather than restating the hex.
|
|
129
129
|
|
|
130
130
|
Each `quiz` entry carries `order`, the authored option indices in presentation order where index `0` is the correct answer, and `answer`, the one-based position that answer lands in. Both travel together because a caller deriving the position itself is a caller that can derive it wrongly.
|
|
131
131
|
|
|
132
132
|
The order is drawn here rather than instructed, and that is the point of the verb. An author told to vary the position still varies it by judgment, and the judgment settles on the first slot, which is the defect this design departs from. The draw is uniform over the options, so the position carries no information about which answer is correct.
|
|
133
133
|
|
|
134
|
+
## Nav
|
|
135
|
+
|
|
136
|
+
`canon teach nav` rewrites the teach-root listing, a workspace's contents page, and each of its lessons' chrome, from what the workspace holds on disk.
|
|
137
|
+
|
|
138
|
+
```bash
|
|
139
|
+
canon teach nav
|
|
140
|
+
canon teach nav regular-expressions --json
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
| Option | Behavior |
|
|
144
|
+
| --------------- | ------------------------------------------------- |
|
|
145
|
+
| `[topic]` | Workspace folder or topic, scoping the run to one |
|
|
146
|
+
| `--json` | Emit a machine-readable record on stdout |
|
|
147
|
+
| `--root <path>` | Teach root, defaulting to the main worktree |
|
|
148
|
+
|
|
149
|
+
With no topic it rewrites every workspace. The teach-root listing is always rewritten regardless, since it reports on every workspace and a scoped run leaving it stale would be a second kind of drift this verb exists to end.
|
|
150
|
+
|
|
151
|
+
A lesson carries its chrome as four marker pairs the authoring skill writes empty: `canon:teach:style`, `canon:teach:header`, `canon:teach:footnav`, and `canon:teach:scripts`. This verb splices each one from the current workspace state, embedding the shared stylesheet, rebuilding the breadcrumb and jump menus, the prev/next footer nav, and the behavior scripts, and leaves the authored `<h1>`, lede, body, and quiz between the header and the footnav untouched.
|
|
152
|
+
|
|
153
|
+
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
|
+
|
|
134
155
|
## Opening a workspace
|
|
135
156
|
|
|
136
157
|
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/docs/target-projects.md
CHANGED
|
@@ -157,6 +157,19 @@ This is a separate step rather than a fourth line in the block above because the
|
|
|
157
157
|
|
|
158
158
|
Read the report before `--write` here more carefully than above. The record tree is untracked, so a wrong rewrite has no git undo, and the report names every citation with its line number and the line text for exactly that reason. The same `canon-keep-record-root` marker protects a line that has to keep the old spelling.
|
|
159
159
|
|
|
160
|
+
### Move rules off the flat layout, once
|
|
161
|
+
|
|
162
|
+
Installed rules moved from a flat `.claude/rules/<subdir>/` layout to `.claude/rules/canon/<subdir>/`. A project that installed governance before that release still holds the flat layout, and neither of the two governance verbs below notices: `canon gov sync` reports `No governance surfaces found in target` and exits 0, and `canon gov install` lands a second, wrapped copy beside the stale one rather than replacing it.
|
|
163
|
+
|
|
164
|
+
Run `canon upgrade` first if you have not, then run this from inside the project:
|
|
165
|
+
|
|
166
|
+
```bash
|
|
167
|
+
canon migrate rule-layout --json
|
|
168
|
+
canon migrate rule-layout --write --json
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
The first line reports the plan and the second applies it, relocating each rule and carrying an edited one's recorded hash forward so it still reports as edited on the next sync rather than reading clean. Run it once, ahead of `canon gov sync` or `canon gov install`, on any project scaffolded before this move landed.
|
|
172
|
+
|
|
160
173
|
### Check first
|
|
161
174
|
|
|
162
175
|
`canon sync --check <path>` reports what has drifted without writing anything. It splits each difference by cause, which is the question that decides what to do next.
|
package/package.json
CHANGED
package/src/commands/migrate.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { readFileSync } from 'node:fs'
|
|
|
2
2
|
import { join } from 'node:path'
|
|
3
3
|
import type { Command } from 'commander'
|
|
4
4
|
import { listRepositoryFiles } from '@/git-files'
|
|
5
|
+
import { indexSourceRules } from '@/gov/adapter'
|
|
5
6
|
import { applyRecordsMove, applyRename, readSources } from '@/migrate/apply'
|
|
6
7
|
import { isToolkitOwned, planRename, type RenamePlan } from '@/migrate/plan'
|
|
7
8
|
import {
|
|
@@ -17,6 +18,14 @@ import {
|
|
|
17
18
|
planRecordsMove,
|
|
18
19
|
type RecordsPlan,
|
|
19
20
|
} from '@/migrate/records'
|
|
21
|
+
import {
|
|
22
|
+
applyRuleLayout,
|
|
23
|
+
planRuleLayout,
|
|
24
|
+
type RuleLayoutPlan,
|
|
25
|
+
walkFlatRules,
|
|
26
|
+
} from '@/migrate/rule-layout'
|
|
27
|
+
import { PROJECT_ROOT } from '@/project-root'
|
|
28
|
+
import { readStamp, stampedHashes } from '@/sync/stamp'
|
|
20
29
|
import { logError, logInfo, logStep, logWarn, pipeOutput, plural } from '@/ui'
|
|
21
30
|
|
|
22
31
|
interface RenameOptions {
|
|
@@ -416,6 +425,103 @@ function toRecordTreeRecord(
|
|
|
416
425
|
}
|
|
417
426
|
}
|
|
418
427
|
|
|
428
|
+
interface RuleLayoutOptions {
|
|
429
|
+
readonly json?: boolean
|
|
430
|
+
readonly write?: boolean
|
|
431
|
+
readonly root?: string
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* Moves a target's flat `.claude/rules/<subdir>/` tree onto
|
|
436
|
+
* `.claude/rules/canon/<subdir>/`.
|
|
437
|
+
*
|
|
438
|
+
* No ignore gate here, unlike `runRecords`. Nothing this verb moves is
|
|
439
|
+
* gitignored: an installed rule is a file the target tracks, so relocating it
|
|
440
|
+
* publishes nothing that was not already committed.
|
|
441
|
+
*/
|
|
442
|
+
async function runRuleLayout(opts: RuleLayoutOptions): Promise<number> {
|
|
443
|
+
const root = opts.root ?? process.cwd()
|
|
444
|
+
|
|
445
|
+
const files = await walkFlatRules(root)
|
|
446
|
+
const hashes = stampedHashes(readStamp(root), 'governance')
|
|
447
|
+
const catalog = new Set(indexSourceRules(PROJECT_ROOT).keys())
|
|
448
|
+
const plan = planRuleLayout(root, files, hashes, catalog)
|
|
449
|
+
|
|
450
|
+
if (opts.json) {
|
|
451
|
+
process.stdout.write(
|
|
452
|
+
`${JSON.stringify(toRuleLayoutRecord(plan, opts.write))}\n`,
|
|
453
|
+
)
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
reportRuleLayout(plan)
|
|
457
|
+
|
|
458
|
+
if (plan.collisions.length > 0) {
|
|
459
|
+
logError(
|
|
460
|
+
`${plural(plan.collisions.length, 'file')} collide with an existing canon/ copy carrying different bytes. Neither side moved.`,
|
|
461
|
+
)
|
|
462
|
+
for (const collision of plan.collisions) {
|
|
463
|
+
logError(` ${collision.path} -> ${collision.destination}`)
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
if (plan.moves.length === 0 && plan.duplicates.length === 0) {
|
|
468
|
+
return plan.collisions.length > 0 ? 1 : 0
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
if (!opts.write) {
|
|
472
|
+
logWarn('Nothing was written. Pass --write to apply this plan.')
|
|
473
|
+
return 2
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
const applied = await applyRuleLayout(root, plan, PROJECT_ROOT)
|
|
477
|
+
logStep(
|
|
478
|
+
`Moved ${plural(applied.moved, 'file')} and deleted ${plural(applied.deleted, 'duplicate')}.`,
|
|
479
|
+
)
|
|
480
|
+
|
|
481
|
+
if (applied.failed.length > 0) {
|
|
482
|
+
logError(`Could not move ${plural(applied.failed.length, 'file')}.`)
|
|
483
|
+
for (const path of applied.failed) logError(` ${path}`)
|
|
484
|
+
return 1
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
return plan.collisions.length > 0 ? 1 : 0
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
function reportRuleLayout(plan: RuleLayoutPlan): void {
|
|
491
|
+
logInfo(`${plural(plan.moves.length, 'file')} to move.`)
|
|
492
|
+
for (const move of plan.moves) {
|
|
493
|
+
logInfo(` ${move.from} -> ${move.to} (${move.status})`)
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
if (plan.duplicates.length > 0) {
|
|
497
|
+
logInfo(
|
|
498
|
+
`${plural(plan.duplicates.length, 'file')} already duplicated at their destination, to delete.`,
|
|
499
|
+
)
|
|
500
|
+
for (const duplicate of plan.duplicates) logInfo(` ${duplicate.path}`)
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
if (plan.unclaimed.length > 0) {
|
|
504
|
+
logWarn(
|
|
505
|
+
`${plural(plan.unclaimed.length, 'file')} neither the stamp nor the current catalog can vouch for. Left in place.`,
|
|
506
|
+
)
|
|
507
|
+
for (const path of plan.unclaimed) logWarn(` ${path}`)
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
function toRuleLayoutRecord(
|
|
512
|
+
plan: RuleLayoutPlan,
|
|
513
|
+
wrote: boolean | undefined,
|
|
514
|
+
): unknown {
|
|
515
|
+
return {
|
|
516
|
+
ok: plan.collisions.length === 0,
|
|
517
|
+
wrote: wrote === true,
|
|
518
|
+
moves: plan.moves,
|
|
519
|
+
duplicates: plan.duplicates,
|
|
520
|
+
collisions: plan.collisions,
|
|
521
|
+
unclaimed: plan.unclaimed,
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
419
525
|
export function register(program: Command): void {
|
|
420
526
|
const migrate = program
|
|
421
527
|
.command('migrate')
|
|
@@ -551,4 +657,48 @@ export function register(program: Command): void {
|
|
|
551
657
|
.action(async (opts: RenameOptions) => {
|
|
552
658
|
process.exitCode = await runRename(opts)
|
|
553
659
|
})
|
|
660
|
+
|
|
661
|
+
migrate
|
|
662
|
+
.command('rule-layout')
|
|
663
|
+
.description('Move installed rules from the flat layout onto canon/')
|
|
664
|
+
.helpOption('-h, --help', 'Show this help message')
|
|
665
|
+
.option('--json', 'Add a machine-readable record on stdout')
|
|
666
|
+
.option('--write', 'Apply the plan rather than reporting it')
|
|
667
|
+
.option(
|
|
668
|
+
'--root <path>',
|
|
669
|
+
'Project root, defaulting to the working directory',
|
|
670
|
+
)
|
|
671
|
+
.addHelpText(
|
|
672
|
+
'after',
|
|
673
|
+
[
|
|
674
|
+
'',
|
|
675
|
+
'Moves .claude/rules/<subdir>/<rule>.md to',
|
|
676
|
+
'.claude/rules/canon/<subdir>/<rule>.md. A file is never overwritten:',
|
|
677
|
+
'this verb only relocates a rule, even one classified as edited.',
|
|
678
|
+
'',
|
|
679
|
+
'Classification: clean when the target stamp still hashes to the',
|
|
680
|
+
'installed content, edited when it does not, and unclaimed when',
|
|
681
|
+
'neither the stamp nor the current rule catalog can vouch for the',
|
|
682
|
+
'name. An unclaimed file is reported and never moved.',
|
|
683
|
+
'',
|
|
684
|
+
'A destination already holding the same bytes marks the flat file a',
|
|
685
|
+
'duplicate, which --write deletes rather than moves. A destination',
|
|
686
|
+
'holding different bytes is a collision: neither file is touched,',
|
|
687
|
+
'it is reported by name, and every other planned move still applies.',
|
|
688
|
+
'',
|
|
689
|
+
'Exit codes:',
|
|
690
|
+
' 0 nothing to move, or --write applied the whole plan clean',
|
|
691
|
+
' 1 a move failed, or an unresolved collision remains',
|
|
692
|
+
' 2 a plan exists and --write was not passed',
|
|
693
|
+
'',
|
|
694
|
+
'Examples:',
|
|
695
|
+
' canon migrate rule-layout',
|
|
696
|
+
' canon migrate rule-layout --write',
|
|
697
|
+
' canon migrate rule-layout --json',
|
|
698
|
+
'',
|
|
699
|
+
].join('\n'),
|
|
700
|
+
)
|
|
701
|
+
.action(async (opts: RuleLayoutOptions) => {
|
|
702
|
+
process.exitCode = await runRuleLayout(opts)
|
|
703
|
+
})
|
|
554
704
|
}
|
package/src/commands/teach.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { relative } from 'node:path'
|
|
2
2
|
import type { Command } from 'commander'
|
|
3
3
|
import { type LessonOutcome, planLesson } from '@/teach/lesson'
|
|
4
|
+
import { type NavOutcome, generateNav } from '@/teach/nav'
|
|
4
5
|
import {
|
|
5
6
|
defineTerms,
|
|
6
7
|
type ListOutcome,
|
|
@@ -74,6 +75,11 @@ interface GlossaryCommandOptions {
|
|
|
74
75
|
readonly term?: readonly string[]
|
|
75
76
|
}
|
|
76
77
|
|
|
78
|
+
interface NavCommandOptions {
|
|
79
|
+
readonly json?: boolean
|
|
80
|
+
readonly root?: string
|
|
81
|
+
}
|
|
82
|
+
|
|
77
83
|
export function register(program: Command): void {
|
|
78
84
|
const teach = program
|
|
79
85
|
.command('teach')
|
|
@@ -310,6 +316,41 @@ export function register(program: Command): void {
|
|
|
310
316
|
.action(async (topic: string, opts: StylesheetCommandOptions) => {
|
|
311
317
|
process.exitCode = await runStylesheet(topic, opts)
|
|
312
318
|
})
|
|
319
|
+
|
|
320
|
+
teach
|
|
321
|
+
.command('nav')
|
|
322
|
+
.description('Rewrite the teach root, contents pages, and lesson chrome')
|
|
323
|
+
.argument('[topic]', 'Workspace folder or topic, scoping the run to one')
|
|
324
|
+
.helpOption('-h, --help', 'Show this help message')
|
|
325
|
+
.option('--json', 'Emit a machine-readable record on stdout')
|
|
326
|
+
.option('--root <path>', 'Teach root, defaulting to the main worktree')
|
|
327
|
+
.addHelpText(
|
|
328
|
+
'after',
|
|
329
|
+
[
|
|
330
|
+
'',
|
|
331
|
+
'Exit codes:',
|
|
332
|
+
' 0 the root, the contents page(s), and every found lesson chrome were written',
|
|
333
|
+
' 1 refused, with the reason on stderr or in the JSON record',
|
|
334
|
+
'',
|
|
335
|
+
'Rewrites the teach-root listing, the contents page of every workspace',
|
|
336
|
+
"or of the one topic named, and each of its lessons' chrome: the",
|
|
337
|
+
'embedded stylesheet, the header with its breadcrumb and jump menus,',
|
|
338
|
+
'the prev/next footer nav, and the behavior scripts. The authored',
|
|
339
|
+
'<h1>, lede, body, and quiz are left untouched.',
|
|
340
|
+
'',
|
|
341
|
+
'A lesson missing one of the four chrome markers is refused rather',
|
|
342
|
+
"than rewritten, reported by name in the JSON record's skipped list",
|
|
343
|
+
'and on stderr, while every other lesson still rewrites.',
|
|
344
|
+
'',
|
|
345
|
+
'Examples:',
|
|
346
|
+
' canon teach nav',
|
|
347
|
+
' canon teach nav regular-expressions --json',
|
|
348
|
+
'',
|
|
349
|
+
].join('\n'),
|
|
350
|
+
)
|
|
351
|
+
.action(async (topic: string | undefined, opts: NavCommandOptions) => {
|
|
352
|
+
process.exitCode = await runNav(topic, opts)
|
|
353
|
+
})
|
|
313
354
|
}
|
|
314
355
|
|
|
315
356
|
interface StylesheetCommandOptions {
|
|
@@ -589,6 +630,56 @@ async function runLesson(
|
|
|
589
630
|
)
|
|
590
631
|
}
|
|
591
632
|
|
|
633
|
+
async function runNav(
|
|
634
|
+
topic: string | undefined,
|
|
635
|
+
opts: NavCommandOptions,
|
|
636
|
+
): Promise<number> {
|
|
637
|
+
const emitJson = opts.json ?? false
|
|
638
|
+
const root = await rootFor(opts.root)
|
|
639
|
+
|
|
640
|
+
return reportNav(await generateNav(root, topic), emitJson, root)
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
function reportNav(
|
|
644
|
+
outcome: NavOutcome,
|
|
645
|
+
emitJson: boolean,
|
|
646
|
+
root: string,
|
|
647
|
+
): number {
|
|
648
|
+
if (!outcome.ok)
|
|
649
|
+
return reportRefusal('canon teach nav', outcome, emitJson, root)
|
|
650
|
+
|
|
651
|
+
if (emitJson) {
|
|
652
|
+
process.stdout.write(
|
|
653
|
+
`${JSON.stringify({
|
|
654
|
+
ok: true,
|
|
655
|
+
root: outcome.root,
|
|
656
|
+
contents: outcome.contents,
|
|
657
|
+
lessons: outcome.lessons,
|
|
658
|
+
skipped: outcome.skipped,
|
|
659
|
+
})}\n`,
|
|
660
|
+
)
|
|
661
|
+
return 0
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
intro('canon teach nav')
|
|
665
|
+
logStep('Root')
|
|
666
|
+
logInfo(outcome.root)
|
|
667
|
+
logStep('Contents')
|
|
668
|
+
for (const path of outcome.contents) logInfo(path)
|
|
669
|
+
logStep('Lessons rewritten')
|
|
670
|
+
logInfo(String(outcome.lessons))
|
|
671
|
+
|
|
672
|
+
if (outcome.skipped.length > 0) {
|
|
673
|
+
logStep('Refused, missing a chrome marker')
|
|
674
|
+
for (const skip of outcome.skipped) {
|
|
675
|
+
logWarn(`${skip.file}: no ${skip.missing}`)
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
outro()
|
|
680
|
+
return 0
|
|
681
|
+
}
|
|
682
|
+
|
|
592
683
|
function reportRefusal(
|
|
593
684
|
title: string,
|
|
594
685
|
refused: TeachRefused,
|