@erclx/canon 4.115.0 → 4.116.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "canon",
3
3
  "description": "Automated governance, versioning, and discovery tools for Claude Code.",
4
- "version": "4.115.0",
4
+ "version": "4.116.0",
5
5
  "author": {
6
6
  "name": "Eric Le",
7
7
  "url": "https://github.com/erclx"
@@ -113,7 +113,7 @@ Write each option as a `<label class="opt" data-k="<letter>">` holding a radio `
113
113
 
114
114
  Then carry a teach-back block beside the quiz. A quiz is recognition and the pedagogy prefers production, so a lesson offering only a quiz tests the weak form. Ask for an explanation to a named audience, and carry a `<details>` listing what a complete explanation covers, closed by default, so a learner reading with no session in the room can grade themselves. The reference states both shapes in full.
115
115
 
116
- Compose the quiz and the teach-back block as one string. Below, that string is the block list's trailing `raw` entry, rather than markup written into the file by hand.
116
+ Compose the quiz and the teach-back block as one string. Below, that string is the block list's last `raw` entry, followed only by `refs`, rather than markup written into the file by hand.
117
117
 
118
118
  ### Building the lesson body
119
119
 
@@ -126,12 +126,14 @@ echo '[
126
126
  {"type":"heading","level":1,"text":"<title>"},
127
127
  {"type":"paragraph","lede":true,"text":"<the dek>"},
128
128
  {"type":"paragraph","text":"<a body paragraph>"},
129
+ {"type":"paragraph","text":"<a claim resting on a source>","cites":[1]},
129
130
  {"type":"list","ordered":true,"items":["<step one>","<step two>"]},
130
- {"type":"raw","html":"<the quiz and teach-back block composed above>"}
131
+ {"type":"raw","html":"<the quiz and teach-back block composed above>"},
132
+ {"type":"refs","items":[{"title":"<source name>","url":"<https URL>"}]}
131
133
  ]' | canon teach render --json
132
134
  ```
133
135
 
134
- A `heading`, a `paragraph` (`lede: true` for the dek), and a `list` cover the structural body. Reach for `raw` only where none of the three can carry the content, never as a shortcut around composing one, and give the quiz and teach-back block the array's trailing `raw` entry every time, since their fixed contract is not a components concern. Take the call's `html` field and write it between the header's close marker and the footnav's open marker, and nothing else anywhere in the file.
136
+ A `heading`, a `paragraph` (`lede: true` for the dek, `cites` for a claim resting on a source), a `list`, and the one `refs` block the cites number into cover the structural body. Reach for `raw` only where none of those can carry the content, never as a shortcut around composing one, and give the quiz and teach-back block the array's last `raw` entry every time, followed only by `refs`, since their fixed contract is not a components concern. Take the call's `html` field and write it between the header's close marker and the footnav's open marker, and nothing else anywhere in the file.
135
137
 
136
138
  Report it rather than proceeding silently when the verb does not resolve, which is an installed CLI predating it, and never compose the lesson body by hand as a fallback. That is the state this section exists to end, and a target holds this skill body before it holds the verb, since a plugin skill reaches a target the moment it merges while the CLI reaches one only when a release publishes.
137
139
 
@@ -14,6 +14,7 @@ A workspace accumulates lessons over weeks. The learner reads them as one body o
14
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.
15
15
  - Keep the structural furniture identical across lessons: where the title sits, where the quiz sits, what a correct answer looks like
16
16
  - Do not restate styles inside a lesson. A local override is a decision the next lesson has to either copy or contradict.
17
+ - Refer to another lesson in the same workspace as "lesson" followed by its four-digit number, such as "lesson 0003", and let `canon teach nav` turn the mention into a link and report a number naming no lesson. A hand-written link is the one form nav never checks.
17
18
 
18
19
  ## Typography
19
20
 
@@ -73,14 +74,17 @@ The stepper hides and shows and sets nothing else, so how a selected option look
73
74
 
74
75
  ## The block list
75
76
 
76
- `canon teach render` takes a JSON array of blocks and renders it through the same components a lesson body composes. Four 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. Five types, and every structural body composes from them:
77
78
 
78
79
  - `{"type":"heading","level":1|2,"text":"<text>"}`: an `<h1>` or `<h2>`.
79
- - `{"type":"paragraph","text":"<text>","lede":true}`: a `<p>`, marked `lede` for the dek that opens the lesson.
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.
80
81
  - `{"type":"list","items":["<text>", ...],"ordered":true}`: a `<ul>` or, with `ordered`, an `<ol>`.
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.
81
83
  - `{"type":"raw","html":"<markup>"}`: the escape hatch, passed through unescaped.
82
84
 
83
- 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 trailing entry rather than reaching for `raw` anywhere the other three could carry the content instead.
85
+ 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
+
87
+ 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.
84
88
 
85
89
  ## Teach back
86
90
 
@@ -48,7 +48,7 @@ Full help: `canon <command> --help`. Bare `canon --help` lists every top-level c
48
48
  | `canon teach lesson` | Resolve the numbered path, shared stylesheet, mission success lines, and one quiz option order per question for the next lesson, writing nothing (`--json`) |
49
49
  | `canon teach stylesheet` | Seed a workspace stylesheet from the design source, leaving an existing one alone without `--force` (`--json`) |
50
50
  | `canon teach nav` | Rewrite the teach-root listing, a workspace's contents page, each lesson's chrome from its four marker regions, and each reference page's rendered HTML sibling (`--json`) |
51
- | `canon teach render` | Render a JSON block list (heading, paragraph, list, raw) from stdin to HTML through the shared components, refusing `bad-input` by block index (`--json`) |
51
+ | `canon teach render` | Render a JSON block list (heading, paragraph with `cites`, list, refs, raw) from stdin to HTML through the shared components, refusing `bad-input` by block index (`--json`) |
52
52
  | `canon records validate` | Report a session record or a standard against the standard governing it, per kind (`--json`) |
53
53
  | `canon records migrate` | Rewrite the records a validate finding names a recoverable transform for (`--write`, `--json`) |
54
54
  | `canon records ordinal` | Report or claim the next ordinal `intake` and `groundwork` share, creating the folder atomically (`--claim`, `--json`) |
@@ -65,6 +65,8 @@ Full help: `canon <command> --help`. Bare `canon --help` lists every top-level c
65
65
  | `canon migrate scratch-evidence` | Move a folder under `.tmp/` that a durable record cites as evidence to a numbered `.canon/evidence/<nn>-<folder>/`, repointing every citation live or archived, reporting the plan without `--write` (`--root`, `--json`) |
66
66
  | `canon migrate record-layout` | Fold review receipts and the retired-entry archive under the memory pen, and move everything under `.canon/review/` that is not a review out to its own folder, repointing each citation that reaches into a moved folder or names a moved file, live or archived, reporting the plan without `--write` (`--root`, `--json`) |
67
67
  | `canon sessions list` | Resolve live sessions to the worktree and branch each holds, filtered by `--branch` (`--json`) |
68
+ | `canon sessions export` | Pack a session transcript, its side folder, and a manifest into one local gzip bundle, never uploading it (`--out`, `--json`) |
69
+ | `canon sessions import` | Place an exported session under the local project folder so `claude --resume` finds it, refusing to overwrite (`--root`, `--json`) |
68
70
  | `canon worktrees list` | Report which worktrees are reclaimable, keyed on the pull request having merged, with every refusal and the removal route named (`--json`) |
69
71
  | `canon worktrees reclaim` | Remove every reclaimable worktree and the branch behind it, reporting without acting under `--dry-run` (`--json`) |
70
72
  | `canon comments scan` | Measure comment density by language and comment kind, with a trend recomputed from git |
@@ -40,7 +40,7 @@ CLI catalog and invocation rules for agents, split by command domain. Start with
40
40
  - [Rule citations](rule-citations.md): Resolving every path a governance rule cites and every frontmatter glob the internal corpus declares, the three forms a citation is written in, the shapes that look like citations and are not, the two classes where an absent path is correct, why the glob half reads one corpus, and why this one gates
41
41
  - [Sandbox](sandbox.md): Scenario routing, the expectation scoring surface, and the coverage census over scenarios and skills
42
42
  - [Scripting](scripting.md): The runtime catalogs that replace hardcoded names, what each carries, and a headless invocation per domain
43
- - [Sessions](sessions.md): Resolving live peer sessions to the worktree and branch each holds, reading which row is the caller, the liveness confidence field, and what each session surface can see
43
+ - [Sessions](sessions.md): Resolving live peer sessions to the worktree and branch each holds, reading which row is the caller, the liveness confidence field, what each session surface can see, and moving a session to another machine with export and import
44
44
  - [Skill audit](skills-audit.md): Measuring both skill corpora against standards/skill.md, the checks it reads, the requirement gate that is the only failing one, and the drift verb that names bodies rewritten since a ref
45
45
  - [Citation reach](skills-reach.md): Reporting the skill bodies that cite a path no target project receives, which corpus the verb reads, the ownership key that decides what counts, the one-word qualifier that marks a citation as decided, and why the verb reports instead of gating
46
46
  - [Standard success criteria](standards-audit.md): Reading the corpus against the success-criterion gate, the two headings it accepts, why the check scopes to arrival rather than the whole corpus, and the exit codes it sets
@@ -39,7 +39,7 @@ The verdict `state` is `pass`, `fail`, or `unchecked`. An arm with no `expect.to
39
39
 
40
40
  Omitting `--writes`, `--escapes`, or `--envelope` does not silently drop the assertion kinds that need them. Write scope, escape scope, the turn ceiling, and the reply assertion report as unchecked and appear in the count, so the standalone command cannot claim more coverage than it had. Supplying `--escapes` without `--escapes-watched` reports the same way: a zero-escape result with no root confirmed watched is unmeasured rather than a pass. A verdict never reports `pass` with zero assertions.
41
41
 
42
- An arm declaring `escape_scope` asserts a bound on `run.sh`'s own escape watch rather than on the sandbox tree. `write_scope` skips when the run wrote nothing, since a required output missing is itself a finding, but `escape_scope` passes on zero escapes outright, since a clean run producing none is the expected outcome for a destination nothing requires a skill to touch, provided `--escapes-watched` confirms a root held something to watch. Declaring `escape_scope = []` asserts that a correct run reaches none of the watched destinations at all. `canon/context/sandbox/overview.md` names what the watch reaches and what it cannot.
42
+ An arm declaring `escape_scope` asserts a bound on `run.sh`'s own escape watch rather than on the sandbox tree. `write_scope` skips when the run wrote nothing, since a required output missing is itself a finding, but `escape_scope` passes on zero escapes outright, since a clean run producing none is the expected outcome for a destination nothing requires a skill to touch, provided `--escapes-watched` confirms a root held something to watch. Declaring `escape_scope = []` asserts that a correct run reaches none of the watched destinations at all. `canon/context/sandbox/isolation.md` names what the watch reaches and what it cannot.
43
43
 
44
44
  An envelope that parses but carries no `result` field skips the reply assertion the same way an absent file does. An envelope carrying an empty `result` fails it, since a run that returned no text is a finding rather than a gap in the input.
45
45
 
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  title: Sessions
3
- description: Resolving live peer sessions to the worktree and branch each holds, reading which row is the caller, the liveness confidence field, and what each session surface can see
3
+ description: Resolving live peer sessions to the worktree and branch each holds, reading which row is the caller, the liveness confidence field, what each session surface can see, and moving a session to another machine with export and import
4
4
  ---
5
5
 
6
6
  # Sessions
@@ -117,6 +117,89 @@ Every row carries `statusUpdatedAt`, the stamp a client writes beside `status` a
117
117
 
118
118
  The dwell is what separates a status that resolves on its own from one that does not. `busy` and `idle` transition without help, so a long dwell there is ordinary. `waiting` does not: a session in that state is blocked on something outside itself, and a dwell that keeps growing past the ordinary span of a prompt is a session stalled rather than paused. `canon sessions list` renders the dwell beside the status at the coarsest unit that keeps it a whole number, and the JSON record carries both fields on every row.
119
119
 
120
+ ## Export
121
+
122
+ `canon sessions export <id>` packs one session into a single gzip tarball so it can resume on another machine. The bundle holds the transcript, its side folder with every path kept, and a manifest.
123
+
124
+ ```bash
125
+ canon sessions export <id>
126
+ canon sessions export <id> --out ~/session.tar.gz --json
127
+ ```
128
+
129
+ | Option | Behavior |
130
+ | -------------- | ---------------------------------------------------------- |
131
+ | `--out <path>` | Write the bundle here rather than under the scratch folder |
132
+ | `--json` | Add a machine-readable record on stdout |
133
+
134
+ Without `--out` the bundle lands at `.canon/tmp/session-export/<id>.tar.gz` under the main worktree root, which is gitignored and reclaimed by `canon records prune-tmp`. It writes locally and never uploads. A transcript holds whatever tool output passed through the session, credentials included where one was printed, so treat the bundle as private.
135
+
136
+ Exit codes: `0` the bundle was written, `1` refused. The refusal carries a `reason` of `not-found`, where no project folder holds a transcript for the id, or `no-archive`, where the running Bun predates `Bun.Archive`.
137
+
138
+ The lookup walks every folder under the configuration directory's `projects/`, so a transcript filed under any encoding is found. The transcript is read up to its last newline and never rewritten, so a session exporting itself mid-append drops at most the half line it was writing.
139
+
140
+ Export runs no skill. Write a handoff with `canon:session-compact` and push it with `canon records push` first where one is wanted. The manifest reports whether a handoff exists and whether it reached the records history, and whatever calls the verb owns that order.
141
+
142
+ ### The manifest
143
+
144
+ | Field | Holds |
145
+ | ------------------ | ---------------------------------------------------------------------------- |
146
+ | `format` | The bundle format version, `1` |
147
+ | `sessionId` | The exported id |
148
+ | `title` | The last custom title the transcript recorded, or `null` |
149
+ | `cwd` | The last working directory the transcript recorded, or `null` |
150
+ | `root` | The main worktree root of the repository the export read |
151
+ | `origin` | The `origin` remote reduced to `host/path`, or `null` |
152
+ | `branch`, `head` | The branch and commit of the checkout the export ran in |
153
+ | `recordsHead` | The records history `HEAD`, or `null` where no records history exists |
154
+ | `compacted` | Whether the transcript carries a compaction boundary |
155
+ | `live` | Whether the id was live in the roster at export |
156
+ | `handoff` | The newest `.canon/compact/` note written since the session began, or `null` |
157
+ | `handoffCommitted` | Whether that note is committed in the records history |
158
+ | `recommend` | `handoff` where a handoff exists, `transcript` otherwise |
159
+
160
+ `recommend` prefers the handoff even though the transcript resumes in full. A resumed transcript carries the source machine's absolute paths into a session working somewhere else, and a handoff states the work without them.
161
+
162
+ ## Import
163
+
164
+ `canon sessions import <bundle>` places an exported session under this machine's configuration directory so `claude` can resume it.
165
+
166
+ ```bash
167
+ canon sessions import session.tar.gz
168
+ canon sessions import session.tar.gz --root ~/repos/canon --json
169
+ ```
170
+
171
+ | Option | Behavior |
172
+ | --------------- | ---------------------------------------------------------- |
173
+ | `--root <path>` | The repository to resume in. Defaults to the main worktree |
174
+ | `--json` | Add a machine-readable record on stdout |
175
+
176
+ The transcript and side folder land under `projects/<encoding>/`, where the encoding is the root with every character outside `[A-Za-z0-9]` turned into a dash. Import writes new files only and never overwrites.
177
+
178
+ Exit codes: `0` the session was placed, `1` refused. The refusal carries one of these reasons:
179
+
180
+ - `not-a-bundle`: the file is no gzip tarball, carries no manifest at format `1`, or names a path outside its own session, such as one climbing out with `..` or an absolute one
181
+ - `other-repository`: the local `origin` differs from the one the bundle recorded
182
+ - `exists`: the target transcript or side folder is already there, or any other project folder already holds a transcript for the id, which `--resume <id>` could reach first
183
+ - `live-here`: the id is live in the local roster, so a second copy would fork it
184
+ - `write-failed`: a write failed partway for a reason other than a copy appearing, such as a bundle carrying a file where it also needs a folder
185
+ - `no-archive`: the running Bun predates `Bun.Archive`
186
+
187
+ A write that fails partway removes every file this run placed and every folder it created before refusing, so a retry of the same bundle does not refuse on its own leftovers. Any path it could not remove is named in the message. A copy that appears between the existence check and the write refuses as `exists` on the same terms.
188
+
189
+ A placed session can still lag the source. The record's `behind` list carries `repository-behind` when this checkout lacks the exported `HEAD`, and `records-behind` when the records history lacks the exported records commit, which `canon records pull` brings. Neither refuses, since each has a remedy the reader runs before resuming.
190
+
191
+ On success the record carries two `routes`. One runs `claude --resume <id>` from the root, and the other starts `claude` there and runs `/canon:session-resume` bare against the handoff.
192
+
193
+ ### What resume depends on
194
+
195
+ Measured on `claude` 2.1.281 on 2026-09-24, `--resume <id>` found a copied transcript in any folder under `projects/` and chained the next turn onto its full history, run from a repository path the transcript never recorded. `--continue` found it only under the local encoding, which is why import derives the folder rather than keeping the source machine's.
196
+
197
+ The encoding is inferred from observed folders rather than read from a published rule, and how a client shortens a path past its length limit was never observed. A client changing the rule leaves `--continue` looking elsewhere while `--resume <id>` still works. The framed report names the derived folder so a reader can compare.
198
+
199
+ The side folder did not change the conversation sent. What it carries is `tool-results/`, which a `persisted-output` pointer in the transcript names by the source machine's absolute path, and `subagents/`, which holds background agent transcripts. On a machine whose path matches, those pointers resolve. On one whose path differs, they dangle.
200
+
201
+ Resuming on the target while the source session keeps running forks the conversation, and nothing detects that across machines. The manifest's `live` field and the printed routes are the only warning.
202
+
120
203
  ## What the read depends on
121
204
 
122
205
  The records live under the Claude Code configuration directory, which the verb resolves from `CLAUDE_CONFIG_DIR` and falls back to `~/.claude`. Their location, their filenames, and the fields inside them are a client implementation detail rather than a published interface, so a client change can move them. The verb reports an absent registry as a refusal rather than as a machine running no sessions, which is what surfaces the move instead of burying it in an empty roster.
@@ -141,7 +141,7 @@ The order is drawn here rather than instructed, and that is the point of the ver
141
141
 
142
142
  ## Render
143
143
 
144
- `canon teach render` renders a lesson body's structural blocks to HTML, through the three lesson components. It takes no topic and no `--root`, since the verb is a stateless transform reading nothing off a workspace on disk.
144
+ `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.
145
145
 
146
146
  ```bash
147
147
  echo '[{"type":"heading","level":1,"text":"Compass bearings"}]' | canon teach render --json
@@ -151,9 +151,21 @@ echo '[{"type":"heading","level":1,"text":"Compass bearings"}]' | canon teach re
151
151
  | -------- | ---------------------------------------- |
152
152
  | `--json` | Emit a machine-readable record on stdout |
153
153
 
154
- It reads a JSON array of blocks from stdin, each a `heading`, `paragraph`, `list`, or `raw` block, and reports `{ ok: true, html }` on `--json` or the bare rendered HTML on stdout otherwise. Content none of the three 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.
154
+ 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.
155
155
 
156
- It refuses `bad-input` on empty stdin, on malformed JSON, on stdin that does not parse to an array, and on a block carrying an unrecognized `type` or a field of the wrong shape for its type, naming the block's index in the message.
156
+ 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.
157
+
158
+ ```json
159
+ [
160
+ { "type": "paragraph", "text": "North is fixed.", "cites": [1] },
161
+ {
162
+ "type": "refs",
163
+ "items": [{ "title": "Compass manual", "url": "https://example.com" }]
164
+ }
165
+ ]
166
+ ```
167
+
168
+ It refuses `bad-input` on empty stdin, on malformed JSON, on stdin that does not parse to an array, and on a block carrying an unrecognized `type` or a field of the wrong shape for its type, naming the block's index in the message. Citations add four refusals, each naming the offending block: a cite that resolves to no reference, `cites` on a lede paragraph, a second `refs` block, and a reference `url` that is not `http` or `https`.
157
169
 
158
170
  ## Nav
159
171
 
@@ -172,7 +184,9 @@ canon teach nav regular-expressions --json
172
184
 
173
185
  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.
174
186
 
175
- 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 toolkit's favicon, the shared stylesheet, and the script that settles the course panel before the page paints, rebuilding the course sidebar, the breadcrumb and jump menus, the prev/next footer nav, and the behavior scripts. It leaves the authored `<h1>`, lede, body, and quiz between the header and the footnav as written, and wraps them in `<main>` when the lesson holds none, since the column width hangs off that element. A lesson that already carries a `<main>` keeps its own.
187
+ 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 toolkit's favicon, the shared stylesheet, and the script that settles the course panel before the page paints, rebuilding the course sidebar, the breadcrumb and jump menus, the prev/next footer nav, and the behavior scripts. It leaves the authored `<h1>`, lede, body, and quiz between the header and the footnav as written, with two exceptions. It wraps them in `<main>` when the lesson holds none, since the column width hangs off that element, and a lesson that already carries a `<main>` keeps its own.
188
+
189
+ It also links each "lesson NNNN" mention in that region to the lesson file in the same workspace carrying that number, skipping text inside `a`, `code`, `pre`, `script`, `style`, `label`, `summary`, and comments. A link the verb writes carries `data-lesson-ref`, and it is the only kind the verb rewrites: every run points one at the current filename for its number, or unwraps it to plain text once that number names no lesson. A link an author wrote is never touched.
176
190
 
177
191
  Every workspace label in that chrome, being the breadcrumb, the sidebar heading, the workspace switcher, and the root listing's row, reads the mission's `title` and falls back to the folder topic in sentence case where the mission names none.
178
192
 
@@ -184,6 +198,8 @@ That seeding fires on the absent file alone, so a workspace already holding a `c
184
198
 
185
199
  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.
186
200
 
201
+ A mention whose number names no lesson in its workspace stays plain text and lands in the record's `unresolved` list, one `{ file, lesson }` entry per distinct number per lesson, with `file` relative to the root the way a `skipped` entry is. The lesson still rewrites and counts toward `lessons`, and the exit stays 0. A mention of the lesson's own number stays plain and is not reported, and a refused lesson reports nothing.
202
+
187
203
  The `style` region carries the quiz stepper after the workspace stylesheet, for every lesson but the one shape named three paragraphs down, so it wins the cascade at equal specificity and reaches a workspace seeded before it existed. The stepper is CSS over the radio inputs a quiz is written from: it hides every question that follows one holding no checked input, and hides a question's feedback until that question does. The general sibling combinator is what keeps that from depending on the markup contract holding, since it selects the same questions as the adjacent one for a quiz written correctly and goes on gating when anything sits between two of them, where the adjacent one matches nothing and shows them all at once. Gating there rather than in a script is what keeps a later stem from answering an earlier question with nothing to bind, and the whole block sits inside `@supports selector(:has(*))`, so an engine without `:has()` renders every question rather than a stepper showing nothing. Feedback there follows the workspace stylesheet, shown by one that says nothing about `.fb` and hidden by one grown under the retired script.
188
204
 
189
205
  The block hides and shows and sets nothing else, which leaves a workspace's own layout for a question and its own appearance for an option and a feedback panel. The one value it names is `display: block` on revealed feedback, since every stylesheet grown under the retired script carries `.fb { display: none }` and nothing adds the class that used to reveal it.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@erclx/canon",
3
3
  "type": "module",
4
- "version": "4.115.0",
4
+ "version": "4.116.0",
5
5
  "description": "Infrastructure and quality tooling for developer workflows",
6
6
  "license": "MIT",
7
7
  "bin": {
@@ -10,6 +10,11 @@ import {
10
10
  type SessionReport,
11
11
  selfOf,
12
12
  } from '@/sessions/resolve'
13
+ import {
14
+ type Behind,
15
+ exportSession,
16
+ importSession,
17
+ } from '@/sessions/transfer/bundle'
13
18
  import {
14
19
  intro,
15
20
  logInfo,
@@ -19,6 +24,7 @@ import {
19
24
  pipeOutput,
20
25
  plural,
21
26
  } from '@/ui'
27
+ import { mainWorktreeRoot } from '@/worktree'
22
28
 
23
29
  interface ListCommandOptions {
24
30
  readonly json?: boolean
@@ -144,6 +150,222 @@ export function register(program: Command): void {
144
150
  .action(async (opts: ListCommandOptions) => {
145
151
  process.exitCode = await runList(opts)
146
152
  })
153
+
154
+ sessions
155
+ .command('export')
156
+ .description(
157
+ 'Pack a session transcript, its side folder, and a manifest into one local bundle',
158
+ )
159
+ .helpOption('-h, --help', 'Show this help message')
160
+ .argument('<id>', 'The sessionId to export')
161
+ .option(
162
+ '--out <path>',
163
+ 'Write the bundle here rather than under .canon/tmp/session-export/',
164
+ )
165
+ .option('--json', 'Add a machine-readable record on stdout')
166
+ .addHelpText(
167
+ 'after',
168
+ [
169
+ '',
170
+ 'Exit codes:',
171
+ ' 0 the bundle was written',
172
+ ' 1 refused, with the reason on stderr and in the record',
173
+ '',
174
+ 'The bundle is a gzip tarball on local disk and is never uploaded. It',
175
+ 'holds whatever tool output passed through the session, so treat it as',
176
+ 'private. Export runs no skill: write and push a handoff first where one',
177
+ 'is wanted, and the manifest reports whether one was found and pushed.',
178
+ '',
179
+ 'Refusal reasons: "not-found" (no transcript carries the id) and',
180
+ '"no-archive" (this Bun has no Bun.Archive).',
181
+ '',
182
+ 'Examples:',
183
+ ' canon sessions export <id>',
184
+ ' canon sessions export <id> --out ~/session.tar.gz --json',
185
+ '',
186
+ ].join('\n'),
187
+ )
188
+ .action(async (id: string, opts: ExportCommandOptions) => {
189
+ process.exitCode = await runExport(id, opts)
190
+ })
191
+
192
+ sessions
193
+ .command('import')
194
+ .description(
195
+ 'Place an exported session under this machine so claude can resume it',
196
+ )
197
+ .helpOption('-h, --help', 'Show this help message')
198
+ .argument('<bundle>', 'The bundle an export wrote')
199
+ .option(
200
+ '--root <path>',
201
+ 'The repository to resume in. Defaults to the main worktree',
202
+ )
203
+ .option('--json', 'Add a machine-readable record on stdout')
204
+ .addHelpText(
205
+ 'after',
206
+ [
207
+ '',
208
+ 'Exit codes:',
209
+ ' 0 the session was placed, with any lag reported in "behind"',
210
+ ' 1 refused, with the reason on stderr and in the record',
211
+ '',
212
+ 'Refusal reasons: "not-a-bundle", "other-repository" (the origin',
213
+ 'differs), "exists" (the transcript or side folder is already there),',
214
+ '"live-here" (the id is live on this machine), "write-failed" (a',
215
+ 'write failed partway), and "no-archive". A failure partway removes',
216
+ 'every file this run placed, and names any it could not remove.',
217
+ '',
218
+ '"behind" lists "repository-behind" when this checkout lacks the',
219
+ 'exported HEAD, and "records-behind" when the records history lacks',
220
+ 'the exported records commit, which canon records pull brings.',
221
+ '',
222
+ 'Examples:',
223
+ ' canon sessions import session.tar.gz',
224
+ ' canon sessions import session.tar.gz --root ~/repos/canon --json',
225
+ '',
226
+ ].join('\n'),
227
+ )
228
+ .action(async (bundle: string, opts: ImportCommandOptions) => {
229
+ process.exitCode = await runImport(bundle, opts)
230
+ })
231
+ }
232
+
233
+ interface ExportCommandOptions {
234
+ readonly out?: string
235
+ readonly json?: boolean
236
+ }
237
+
238
+ interface ImportCommandOptions {
239
+ readonly root?: string
240
+ readonly json?: boolean
241
+ }
242
+
243
+ /**
244
+ * Whether a session id is live in the local roster. An unreadable roster
245
+ * answers false, since the roster is advisory here and the manifest says so.
246
+ */
247
+ async function isLiveHere(id: string): Promise<boolean> {
248
+ const report = await resolveSessions()
249
+ return (
250
+ report.kind === 'resolved' &&
251
+ report.sessions.some((session) => session.sessionId === id)
252
+ )
253
+ }
254
+
255
+ function writeRecord(json: boolean | undefined, record: object): void {
256
+ if (json) process.stdout.write(`${JSON.stringify(record)}\n`)
257
+ }
258
+
259
+ async function runExport(
260
+ id: string,
261
+ opts: ExportCommandOptions,
262
+ ): Promise<number> {
263
+ const outcome = await exportSession({
264
+ id,
265
+ out: opts.out ? resolve(opts.out) : undefined,
266
+ isLive: isLiveHere,
267
+ })
268
+
269
+ intro('canon sessions export')
270
+
271
+ if (!outcome.ok) {
272
+ logStep('Refused')
273
+ logWarn(outcome.message)
274
+ outro()
275
+ writeRecord(opts.json, { ...outcome, sessionId: id })
276
+ return 1
277
+ }
278
+
279
+ const { manifest } = outcome
280
+ logStep('Bundle')
281
+ logInfo(`Wrote ${outcome.path}`)
282
+ logInfo(`${plural(outcome.files.length, 'side file')} beside the transcript`)
283
+
284
+ if (manifest.live) {
285
+ logWarn(
286
+ 'The session is live, so the copy stops at its last whole line and resuming elsewhere forks it.',
287
+ )
288
+ }
289
+
290
+ logStep('Handoff')
291
+ if (manifest.handoff === null) {
292
+ logInfo(
293
+ 'No compact note since the session began, so resume the transcript.',
294
+ )
295
+ } else if (manifest.handoffCommitted) {
296
+ logInfo(`${manifest.handoff} is in the records history. Prefer it.`)
297
+ } else {
298
+ logWarn(
299
+ `${manifest.handoff} is not in the records history yet. Run canon records push so the other machine can pull it.`,
300
+ )
301
+ }
302
+ outro()
303
+
304
+ writeRecord(opts.json, {
305
+ ok: true,
306
+ path: outcome.path,
307
+ sessionId: id,
308
+ files: outcome.files,
309
+ manifest,
310
+ })
311
+ return 0
312
+ }
313
+
314
+ const BEHIND_REMEDIES: Record<Behind, string> = {
315
+ 'repository-behind':
316
+ 'This checkout lacks the exported HEAD. Fetch and check out the branch before resuming.',
317
+ 'records-behind':
318
+ 'The records history lacks the exported commit. Run canon records pull to bring the handoff.',
319
+ }
320
+
321
+ async function runImport(
322
+ bundle: string,
323
+ opts: ImportCommandOptions,
324
+ ): Promise<number> {
325
+ const root = opts.root ? resolve(opts.root) : await mainWorktreeRoot()
326
+ const outcome = await importSession({
327
+ bundle: resolve(bundle),
328
+ root,
329
+ isLive: isLiveHere,
330
+ })
331
+
332
+ intro('canon sessions import')
333
+
334
+ if (!outcome.ok) {
335
+ logStep('Refused')
336
+ logWarn(outcome.message)
337
+ outro()
338
+ writeRecord(opts.json, outcome)
339
+ return 1
340
+ }
341
+
342
+ logStep('Placed')
343
+ logInfo(outcome.transcript)
344
+ logInfo(
345
+ `Folder ${outcome.folder}, derived from ${root}. --continue looks there, and --resume finds the id in any folder.`,
346
+ )
347
+ for (const lag of outcome.behind) logWarn(BEHIND_REMEDIES[lag])
348
+
349
+ logStep('Resume')
350
+ logInfo(
351
+ outcome.manifest.recommend === 'handoff'
352
+ ? `Recommended: the handoff at ${outcome.manifest.handoff}, since the transcript carries the source machine's absolute paths.`
353
+ : 'Recommended: the transcript, since no handoff was written since the session began.',
354
+ )
355
+ for (const route of outcome.routes) logInfo(route)
356
+ outro()
357
+
358
+ writeRecord(opts.json, {
359
+ ok: true,
360
+ sessionId: outcome.manifest.sessionId,
361
+ folder: outcome.folder,
362
+ transcript: outcome.transcript,
363
+ files: outcome.files,
364
+ behind: outcome.behind,
365
+ routes: outcome.routes,
366
+ manifest: outcome.manifest,
367
+ })
368
+ return 0
147
369
  }
148
370
 
149
371
  async function runList(opts: ListCommandOptions): Promise<number> {
@@ -393,9 +393,16 @@ 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, or raw block, and renders it through the same components the',
397
- 'fixture lesson is generated from. Content the three cannot express',
398
- 'takes type raw, carrying its own html verbatim, unescaped.',
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.',
400
+ '',
401
+ 'A paragraph takes an optional cites array of reference numbers,',
402
+ 'rendered as footnote markers after its text. The one refs block',
403
+ 'lists { title, url?, note? } items numbered from 1, and a cite that',
404
+ 'resolves to no item, a cite on a lede, or a second refs block is',
405
+ 'refused. A url must be http or https.',
399
406
  '',
400
407
  'Takes no topic and no --root: the verb is a stateless transform,',
401
408
  'reading nothing off a workspace on disk.',
@@ -799,6 +806,7 @@ function reportNav(
799
806
  lessons: outcome.lessons,
800
807
  reference: outcome.reference,
801
808
  skipped: outcome.skipped,
809
+ unresolved: outcome.unresolved,
802
810
  })}\n`,
803
811
  )
804
812
  return 0
@@ -821,6 +829,13 @@ function reportNav(
821
829
  }
822
830
  }
823
831
 
832
+ if (outcome.unresolved.length > 0) {
833
+ logStep('Lesson references naming no lesson')
834
+ for (const reference of outcome.unresolved) {
835
+ logWarn(`${reference.file}: lesson ${reference.lesson}`)
836
+ }
837
+ }
838
+
824
839
  outro()
825
840
  return 0
826
841
  }
@@ -318,7 +318,7 @@ function failed(action: string, result: GitResult): BackupRefused {
318
318
  * as written passes a records origin that publishes the payload through the
319
319
  * other protocol.
320
320
  */
321
- function remoteIdentity(url: string): string {
321
+ export function remoteIdentity(url: string): string {
322
322
  return url
323
323
  .trim()
324
324
  .toLowerCase()
@@ -69,7 +69,7 @@ export function listSkills(root: string): string[] {
69
69
  /**
70
70
  * Maps a scenario to the skill it drives, trying two spellings in order.
71
71
  *
72
- * `<category>-<command>` is the rule `canon/context/sandbox/coverage.md` states, and it
72
+ * `<category>-<command>` is the rule `canon/context/sandbox/coverage/census.md` states, and it
73
73
  * alone pairs 29 of 54 skills. The bare `<command>` fallback is what reaches the
74
74
  * rest: `claude/target-setup.sh` drives the `target-setup` skill, not a `claude-target-setup` that
75
75
  * does not exist. Stating one spelling and shipping two is what let the audit
@@ -48,19 +48,23 @@ export interface SessionRecord {
48
48
  }
49
49
 
50
50
  /**
51
- * Resolves the folder holding one file per session.
51
+ * Resolves the client's configuration directory, which holds both the session
52
+ * registry and the projects folder a transcript lives in.
52
53
  *
53
54
  * `CLAUDE_CONFIG_DIR` comes first because a client honouring it writes its
54
55
  * records nowhere near the home directory, and a read that ignored it would
55
56
  * report an empty roster on a machine running sessions.
56
57
  */
57
- export function registryDir(): string {
58
+ export function claudeConfigDir(): string {
58
59
  const configured = process.env.CLAUDE_CONFIG_DIR
59
- const base =
60
- configured && configured.length > 0
61
- ? configured
62
- : join(homedir(), '.claude')
63
- return join(base, 'sessions')
60
+ return configured && configured.length > 0
61
+ ? configured
62
+ : join(homedir(), '.claude')
63
+ }
64
+
65
+ /** Resolves the folder holding one file per session. */
66
+ export function registryDir(): string {
67
+ return join(claudeConfigDir(), 'sessions')
64
68
  }
65
69
 
66
70
  /**