@erclx/canon 4.115.0 → 4.117.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/auto-ship/SKILL.md +1 -1
- package/claude/skills/canon-cli/SKILL.md +12 -12
- package/claude/skills/create-skill/REQUIREMENT.md +3 -0
- package/claude/skills/create-skill/SKILL.md +3 -1
- package/claude/skills/docs-fold/SKILL.md +1 -1
- package/claude/skills/memory-review/SKILL.md +4 -4
- package/claude/skills/review-pr/REQUIREMENT.md +2 -0
- package/claude/skills/review-pr/SKILL.md +2 -2
- package/claude/skills/role-orchestrator/REQUIREMENT.md +2 -0
- package/claude/skills/role-orchestrator/SKILL.md +2 -2
- package/claude/skills/role-orchestrator/references/orchestrator-dispatch.md +14 -14
- package/claude/skills/role-orchestrator/references/orchestrator-handoff.md +1 -1
- package/claude/skills/role-orchestrator/references/orchestrator-poll.md +2 -2
- package/claude/skills/role-worker/REQUIREMENT.md +2 -0
- package/claude/skills/role-worker/SKILL.md +2 -2
- package/claude/skills/session-compact/REQUIREMENT.md +2 -1
- package/claude/skills/session-compact/SKILL.md +5 -1
- package/claude/skills/teach-workspace/SKILL.md +5 -3
- package/claude/skills/teach-workspace/references/lesson-craft.md +7 -3
- package/claude/skills/write-human/references/source-material.md +2 -2
- package/docs/agents/commands.md +3 -1
- package/docs/agents/context-audit-checks.md +1 -1
- package/docs/agents/index.md +1 -1
- package/docs/agents/install-and-sync.md +1 -1
- package/docs/agents/markdown-audit.md +7 -3
- package/docs/agents/sandbox.md +1 -1
- package/docs/agents/sessions.md +84 -1
- package/docs/agents/skills-audit.md +8 -1
- package/docs/agents/teach.md +20 -4
- package/docs/workflow/ai-workflow.md +9 -9
- package/governance/rules/ui/440-surface-capture.md +2 -0
- package/package.json +1 -1
- package/scripts/core/check-capability-seeding.sh +4 -2
- package/src/claude/cases/misc.ts +5 -0
- package/src/claude/skills-audit.ts +59 -2
- package/src/commands/claude.ts +30 -0
- package/src/commands/markdown.ts +63 -2
- package/src/commands/sessions.ts +222 -0
- package/src/commands/teach.ts +18 -3
- package/src/context/audit.ts +6 -8
- package/src/gate/measures.ts +60 -0
- package/src/gate/stages.ts +12 -2
- package/src/markdown/ceiling.ts +70 -0
- package/src/markdown/structure.ts +22 -0
- package/src/records/backup.ts +1 -1
- package/src/sandbox/census.ts +1 -1
- package/src/sessions/registry.ts +11 -7
- package/src/sessions/transfer/bundle.ts +546 -0
- package/src/sessions/transfer/transcript.ts +148 -0
- package/src/teach/components/paragraph.tsx +19 -2
- package/src/teach/components/refs.tsx +28 -0
- package/src/teach/cross-references.ts +78 -0
- package/src/teach/lesson.ts +1 -1
- package/src/teach/nav.ts +75 -7
- package/src/teach/render.ts +94 -6
- package/src/tooling/inject.ts +13 -5
- package/src/tooling/manifest.ts +9 -0
- package/src/tooling/scan.ts +23 -6
- package/src/tooling/subfolder.ts +3 -3
- package/standards/context.md +2 -2
- package/standards/markdown.md +2 -0
- package/standards/skill.md +1 -0
- package/tooling/base/reference.md +2 -2
- package/tooling/web/configs/e2e/screenshot.ts +43 -7
- package/tooling/web/reference.md +1 -1
- /package/tooling/base/{configs → seeds}/.github/pull_request_template.md +0 -0
- /package/tooling/base/{configs → seeds}/.github/workflows/verify.yml +0 -0
|
@@ -27,9 +27,9 @@ A bare run measures every markdown file git lists, tracked plus untracked-and-no
|
|
|
27
27
|
|
|
28
28
|
## Where the rules come from
|
|
29
29
|
|
|
30
|
-
The three ban sets and all
|
|
30
|
+
The three ban sets and all ten checkpoints ship with the `canon` package as data, in `src/markdown/bans.ts` and `src/markdown/structure.ts`. Every project is measured against the same sets whether or not it installed any standards, and no file has to resolve for a run to mean something.
|
|
31
31
|
|
|
32
|
-
|
|
32
|
+
Seven of the ten are stated in `markdown.md` and the three cadence numbers are stated in the `write-human` skill. That split is the content boundary rather than an accident: `markdown.md` carries the enforced rules a scan can decide, and the skill carries the rhythm rules a ban list cannot express. A cadence number moved in the skill and left in the code drifts the same way, so move both.
|
|
33
33
|
|
|
34
34
|
Reading them out of the standards per run was the original design. It put a parser contract on a document authored for people, and the standard had to carry a paragraph of its own warning an author that a one-word backticked example in a `- Do not use ` bullet would be lifted into a literal ban set and ban that word everywhere. A rule existing to protect a parser from the prose it parses is the argument for separating them.
|
|
35
35
|
|
|
@@ -128,9 +128,13 @@ Two of the rules `write-human` states are deliberately not implemented. A senten
|
|
|
128
128
|
|
|
129
129
|
The condition on that was something identifying a finite verb rather than guessing at one, and two parsers have now been run against it over 11,389 paragraph sentences. They disagree by a factor of four. `compromise` reports 2 percent and reads a fronted past participle as a finite verb, so `Measured at <sha> on <date>.` counts as carrying one. `wink-pos-tagger` reports 9 percent, fixes that class, and is still wrong on roughly three in four, because an imperative's verb tags as a proper noun and a noun-ambiguous predicate tags as a noun, which makes `Each maps to a skill.` read verbless. Separating those needs to know which token is the predicate, and that is syntax rather than a tag. The measure stays unimplemented, now against a mechanism rather than against the idea of one.
|
|
130
130
|
|
|
131
|
+
### Length
|
|
132
|
+
|
|
133
|
+
The Length step sums rendered lines over the whole source, frontmatter and fenced blocks included, and lists each document past the 300-line ceiling, longest first. A changelog and a document carrying a whole-line `<!-- canon-length-exempt: <reason> -->` outside a fence are counted in one line rather than listed. Each `--json` entry carries `renderedLines` and `exempt`, the stated reason or `null`, and `checkpoints` carries `ceiling`.
|
|
134
|
+
|
|
131
135
|
## Exit codes
|
|
132
136
|
|
|
133
|
-
Exit codes are `0` for a completed run with no gating finding, `1` for a refusal, `2` for a ban hit or a dead relative link, and `3` for a shipped ban set that arrived empty. A banned character, word, or spelling and a relative link resolving to nothing on disk each fail the run, both facts a scan settles rather than a reader judging. Bullet, paragraph, and depth weight are judgments a reader settles, and cadence is a distribution whose healthy range moves with the surface, so all four report under every code.
|
|
137
|
+
Exit codes are `0` for a completed run with no gating finding, `1` for a refusal, `2` for a ban hit or a dead relative link, and `3` for a shipped ban set that arrived empty. A banned character, word, or spelling and a relative link resolving to nothing on disk each fail the run, both facts a scan settles rather than a reader judging. Bullet, paragraph, and depth weight are judgments a reader settles, and cadence is a distribution whose healthy range moves with the surface, so all four report under every code. Length reports the same way, since the audit also runs on a plan kept out of version control, and the Document ceiling stage in `canon gate run` owns that verdict instead.
|
|
134
138
|
|
|
135
139
|
`3` is separate from `1` because the two want different responses from a caller. A refusal means no corpus was built, and the `Markdown bans` stage in `canon gate run` is right to report it as unmeasured rather than as a pass. An empty set means the corpus was walked and nothing was looked for, so that stage fails the push on `3` rather than skipping.
|
|
136
140
|
|
package/docs/agents/sandbox.md
CHANGED
|
@@ -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/
|
|
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
|
|
package/docs/agents/sessions.md
CHANGED
|
@@ -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,
|
|
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.
|
|
@@ -35,18 +35,25 @@ Each check traces to a line in the standard.
|
|
|
35
35
|
- No `README.md` inside a skill folder
|
|
36
36
|
- Folder name in kebab-case
|
|
37
37
|
- Each `REQUIREMENT.md` declaring `Gap` and `Must`, matched at any heading level
|
|
38
|
+
- No ISO date in `SKILL.md` or any file under `references/`, reported as `datedProvenance` in the JSON record's `findings`
|
|
38
39
|
|
|
39
40
|
A body whose frontmatter does not parse reports as declaring neither field rather than ending the run, so one malformed file cannot hide the corpus behind it. A key present with an empty value reads as absent, since a blank `name` would otherwise report as a name disagreeing with every folder. A folder carrying no requirement is reported once under presence rather than counted again for the sections it therefore lacks.
|
|
40
41
|
|
|
42
|
+
The date check reads prose alone. A date inside a fence or a code span is example data, such as a sample frontmatter block, and a date in the body's own frontmatter is metadata, so neither reports. `REQUIREMENT.md` and `EVAL.md` are not read, since the requirement's `Gap` is where the standard sends the incident and the date that earned a rule.
|
|
43
|
+
|
|
44
|
+
Each finding names the file and the line, such as `claude/skills/<name>/references/<file>.md line 12: <date>`. A date after "measured" or "verified" reports like any other, unlike the context audit, because a skill body states the rule a session follows today rather than a reading of the tree.
|
|
45
|
+
|
|
41
46
|
## What it leaves alone
|
|
42
47
|
|
|
43
48
|
The report names its own blind spots on every run, including the run where everything passed. Whether each `Must` traces to a stated gap is the rule in that standard worth the most and no parser reads it. Whether a gap states an observed failure rather than an intent, and whether a description routes, are the same kind of judgment. The 150-line body checkpoint is the one mechanical rule still absent here, and adding it would print a count rather than a defect, since the standard makes it a prompt to look with nothing enforcing it.
|
|
44
49
|
|
|
50
|
+
The date check reads ISO dates alone. A date written in words, a month with no day, and provenance carrying no date at all pass it.
|
|
51
|
+
|
|
45
52
|
A check with no rule behind it prints an opinion as a defect, which is where the list stops.
|
|
46
53
|
|
|
47
54
|
## Exit codes
|
|
48
55
|
|
|
49
|
-
Exit codes are `0` for a clean run, `1` for a refusal, and `2` for a skill folder carrying no `REQUIREMENT.md`. Only presence sets a failing code. Name, description, folder,
|
|
56
|
+
Exit codes are `0` for a clean run, `1` for a refusal, and `2` for a skill folder carrying no `REQUIREMENT.md`. Only presence sets a failing code. Name, description, folder, requirement-section, and dated-provenance findings print and return `0`, because each is a judgment a reader settles and failing a push on one would make the check something to route around.
|
|
50
57
|
|
|
51
58
|
## The requirement gate
|
|
52
59
|
|
package/docs/agents/teach.md
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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,
|
|
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.
|
|
@@ -273,15 +273,15 @@ This section is the corpus the coverage claim is measured against: every name `c
|
|
|
273
273
|
|
|
274
274
|
### Run several tracks at once
|
|
275
275
|
|
|
276
|
-
| Skill | When to use
|
|
277
|
-
| ------------------------- |
|
|
278
|
-
| `canon:role-orchestrator` | To assert the control session that owns the queue and reviews each worker's PR
|
|
279
|
-
| `canon:role-planner` | To assert the thinking role for a cold session writing a plan, a groundwork track, or an intake pass
|
|
280
|
-
| `canon:role-worker` | To assert the worker role for a cold session building one branch under one plan
|
|
281
|
-
| `canon:session-relay` | When this session owes another session a message, with or without a role
|
|
282
|
-
| `canon:session-resume` | At the start of a session, to pick up what a previous one left
|
|
283
|
-
| `canon:session-compact` | Before a compaction, to write a plain session's handoff note outside the board
|
|
284
|
-
| `canon:session-map` | At the close of an orchestrating session, to write its board-side handoff
|
|
276
|
+
| Skill | When to use |
|
|
277
|
+
| ------------------------- | ----------------------------------------------------------------------------------------------------------- |
|
|
278
|
+
| `canon:role-orchestrator` | To assert the control session that owns the queue and reviews each worker's PR |
|
|
279
|
+
| `canon:role-planner` | To assert the thinking role for a cold session writing a plan, a groundwork track, or an intake pass |
|
|
280
|
+
| `canon:role-worker` | To assert the worker role for a cold session building one branch under one plan |
|
|
281
|
+
| `canon:session-relay` | When this session owes another session a message, with or without a role |
|
|
282
|
+
| `canon:session-resume` | At the start of a session, to pick up what a previous one left |
|
|
283
|
+
| `canon:session-compact` | Before a compaction or a move to another machine, to write a plain session's handoff note outside the board |
|
|
284
|
+
| `canon:session-map` | At the close of an orchestrating session, to write its board-side handoff |
|
|
285
285
|
|
|
286
286
|
### Keep the project current with the toolkit
|
|
287
287
|
|
|
@@ -44,5 +44,7 @@ paths:
|
|
|
44
44
|
- Commit a flagged case's evidence output so the pull request carries the comparison, rather than attaching it by hand.
|
|
45
45
|
- A project whose own established convention already commits that same comparison may route a flagged case's evidence there instead of a separate evidence folder. Only an established convention already committing the same comparison qualifies, not a preference for skipping a second folder.
|
|
46
46
|
- Commit an evidence case for the first time only after running the capture twice with no code change between the runs and confirming the two outputs are byte-identical.
|
|
47
|
+
- Mask the element carrying a value that moves between runs, such as an elapsed-time counter, a clock, or a relative date, in the state's capture case before the double capture, so the check still runs on everything else in the frame. Mask the smallest element holding the value, never the state.
|
|
48
|
+
- Do not commit the state as evidence when the masked outputs still differ, which happens when the value's width reflows its neighbors. Name the variance in the pull request instead, and never re-run toward a match.
|
|
47
49
|
- Recommitting an unbounded sweep on every run reaches a gigabyte of repository history inside a hundred merges. A small, committed evidence set is what a reviewer needs and what lets GitHub draw its own before-and-after comparison on the pull request.
|
|
48
50
|
- `git-pr` and `git-followup` maintain a comparison comment on the pull request automatically once a case is committed, so a reviewer never has to open Files Changed to see it.
|
package/package.json
CHANGED
|
@@ -60,7 +60,8 @@ check_capability "Hooks" "$PROJECT_ROOT/.claude/hooks" \
|
|
|
60
60
|
"$PROJECT_ROOT"/tooling/claude/seeds/.claude/hooks/*
|
|
61
61
|
|
|
62
62
|
check_capability "Workflows" "$PROJECT_ROOT/.github/workflows" \
|
|
63
|
-
"$PROJECT_ROOT"/tooling/*/configs/.github/workflows/*
|
|
63
|
+
"$PROJECT_ROOT"/tooling/*/configs/.github/workflows/* \
|
|
64
|
+
"$PROJECT_ROOT"/tooling/*/seeds/.github/workflows/*
|
|
64
65
|
|
|
65
66
|
check_capability "Husky" "$PROJECT_ROOT/.husky" \
|
|
66
67
|
"$PROJECT_ROOT"/tooling/base/configs/.husky/*
|
|
@@ -90,7 +91,8 @@ check_orphans() {
|
|
|
90
91
|
check_orphans "Hooks" "$PROJECT_ROOT/.claude/hooks" \
|
|
91
92
|
"$PROJECT_ROOT/tooling/claude/seeds/.claude/hooks"
|
|
92
93
|
|
|
93
|
-
for dir in "$PROJECT_ROOT"/tooling/*/configs/.github/workflows
|
|
94
|
+
for dir in "$PROJECT_ROOT"/tooling/*/configs/.github/workflows \
|
|
95
|
+
"$PROJECT_ROOT"/tooling/*/seeds/.github/workflows; do
|
|
94
96
|
check_orphans "Workflows" "$PROJECT_ROOT/.github/workflows" "$dir"
|
|
95
97
|
done
|
|
96
98
|
|
package/src/claude/cases/misc.ts
CHANGED
|
@@ -36,6 +36,11 @@ export const MISC_CASES: readonly SkillCase[] = [
|
|
|
36
36
|
"We're about to hit the context limit, write the handoff before we lose state.",
|
|
37
37
|
expect: 'session-compact',
|
|
38
38
|
},
|
|
39
|
+
{
|
|
40
|
+
prompt:
|
|
41
|
+
"I'm moving this session to another machine, write the handoff before I pack it up.",
|
|
42
|
+
expect: 'session-compact',
|
|
43
|
+
},
|
|
39
44
|
{
|
|
40
45
|
prompt:
|
|
41
46
|
'I am the orchestrating session and about to compact, so write the board handoff as a session map on the task board.',
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs'
|
|
2
2
|
import { dirname, join } from 'node:path'
|
|
3
3
|
import { parseFrontmatter, readField } from '@/indexes/frontmatter'
|
|
4
|
+
import { bodyLines, maskDisplayed } from '@/markdown/scan'
|
|
4
5
|
|
|
5
6
|
/** Returned when a skill folder carries no `REQUIREMENT.md`, the gating check. */
|
|
6
7
|
export const EXIT_MISSING_REQUIREMENT = 2
|
|
@@ -33,6 +34,14 @@ const KEBAB_CASE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
|
|
|
33
34
|
|
|
34
35
|
const HEADING = /^#{1,6}\s+(.+?)\s*$/
|
|
35
36
|
|
|
37
|
+
/**
|
|
38
|
+
* An ISO date, read as provenance wherever a reader is told it. The context
|
|
39
|
+
* audit's detector clears a date after "measured" or "verified", and a skill
|
|
40
|
+
* body keeps no such carve-out, since the standard sends every incident and its
|
|
41
|
+
* date to the requirement's Gap or to git.
|
|
42
|
+
*/
|
|
43
|
+
const ISO_DATE = /\b\d{4}-\d{2}-\d{2}\b/g
|
|
44
|
+
|
|
36
45
|
export interface SkillFinding {
|
|
37
46
|
readonly rel: string
|
|
38
47
|
readonly detail: string
|
|
@@ -59,6 +68,7 @@ export interface SkillsAudit {
|
|
|
59
68
|
readonly nameMismatch: readonly SkillFinding[]
|
|
60
69
|
readonly longDescription: readonly SkillFinding[]
|
|
61
70
|
readonly requirementSections: readonly SkillFinding[]
|
|
71
|
+
readonly datedProvenance: readonly SkillFinding[]
|
|
62
72
|
}
|
|
63
73
|
|
|
64
74
|
interface SkillSource {
|
|
@@ -69,6 +79,17 @@ interface SkillSource {
|
|
|
69
79
|
readonly description: string | undefined
|
|
70
80
|
/** Undefined when the folder carries no `REQUIREMENT.md` at all. */
|
|
71
81
|
readonly requirementHeadings: readonly string[] | undefined
|
|
82
|
+
/**
|
|
83
|
+
* `SKILL.md` and every reference below the folder, keyed by the path a
|
|
84
|
+
* finding names. `REQUIREMENT.md` and `EVAL.md` are left out, since the
|
|
85
|
+
* requirement is where the standard sends a date.
|
|
86
|
+
*/
|
|
87
|
+
readonly bodies: readonly SkillText[]
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
interface SkillText {
|
|
91
|
+
readonly rel: string
|
|
92
|
+
readonly text: string
|
|
72
93
|
}
|
|
73
94
|
|
|
74
95
|
/**
|
|
@@ -110,6 +131,9 @@ export async function auditSkills(root: string): Promise<SkillsAudit> {
|
|
|
110
131
|
nameMismatch: sources.flatMap(nameFindings),
|
|
111
132
|
longDescription: sources.flatMap(lengthFindings),
|
|
112
133
|
requirementSections: sources.flatMap(sectionFindings),
|
|
134
|
+
datedProvenance: sources.flatMap((source) =>
|
|
135
|
+
source.bodies.flatMap(dateFindings),
|
|
136
|
+
),
|
|
113
137
|
}
|
|
114
138
|
}
|
|
115
139
|
|
|
@@ -138,23 +162,41 @@ async function readSkill(
|
|
|
138
162
|
const skillDir = join(dir, folder)
|
|
139
163
|
const requirementPath = join(skillDir, 'REQUIREMENT.md')
|
|
140
164
|
|
|
141
|
-
const
|
|
165
|
+
const references = [
|
|
166
|
+
...new Bun.Glob('references/**/*.md').scanSync({
|
|
167
|
+
cwd: skillDir,
|
|
168
|
+
onlyFiles: true,
|
|
169
|
+
}),
|
|
170
|
+
].sort()
|
|
171
|
+
|
|
172
|
+
const [body, requirement, referenceTexts] = await Promise.all([
|
|
142
173
|
Bun.file(join(skillDir, 'SKILL.md')).text(),
|
|
143
174
|
existsSync(requirementPath)
|
|
144
175
|
? Bun.file(requirementPath).text()
|
|
145
176
|
: Promise.resolve(undefined),
|
|
177
|
+
Promise.all(
|
|
178
|
+
references.map((path) => Bun.file(join(skillDir, path)).text()),
|
|
179
|
+
),
|
|
146
180
|
])
|
|
147
181
|
|
|
148
182
|
const fields = parseFrontmatter(body)
|
|
183
|
+
const skillRel = join(rel, folder)
|
|
149
184
|
|
|
150
185
|
return {
|
|
151
|
-
rel:
|
|
186
|
+
rel: skillRel,
|
|
152
187
|
folder,
|
|
153
188
|
hasReadme: existsSync(join(skillDir, 'README.md')),
|
|
154
189
|
name: declared(readField(fields, 'name')),
|
|
155
190
|
description: declared(readField(fields, 'description')),
|
|
156
191
|
requirementHeadings:
|
|
157
192
|
requirement === undefined ? undefined : headings(requirement),
|
|
193
|
+
bodies: [
|
|
194
|
+
{ rel: join(skillRel, 'SKILL.md'), text: body },
|
|
195
|
+
...references.map((path, index) => ({
|
|
196
|
+
rel: join(skillRel, path),
|
|
197
|
+
text: referenceTexts[index] ?? '',
|
|
198
|
+
})),
|
|
199
|
+
],
|
|
158
200
|
}
|
|
159
201
|
}
|
|
160
202
|
|
|
@@ -209,3 +251,18 @@ function sectionFindings(source: SkillSource): SkillFinding[] {
|
|
|
209
251
|
},
|
|
210
252
|
]
|
|
211
253
|
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* A date inside a fence or a code span is example data a reader is shown, such
|
|
257
|
+
* as a sample frontmatter block, so only prose outside both is read.
|
|
258
|
+
*/
|
|
259
|
+
function dateFindings(source: SkillText): SkillFinding[] {
|
|
260
|
+
return bodyLines(source.text)
|
|
261
|
+
.filter((line) => !line.fenced)
|
|
262
|
+
.flatMap((line) =>
|
|
263
|
+
[...maskDisplayed(line.text).matchAll(ISO_DATE)].map((match) => ({
|
|
264
|
+
rel: source.rel,
|
|
265
|
+
detail: `line ${line.number}: ${match[0]}`,
|
|
266
|
+
})),
|
|
267
|
+
)
|
|
268
|
+
}
|
package/src/commands/claude.ts
CHANGED
|
@@ -1397,6 +1397,7 @@ async function runSkillsAudit(
|
|
|
1397
1397
|
reportFrontmatter(report)
|
|
1398
1398
|
reportFolder(report)
|
|
1399
1399
|
reportRequirementShape(report)
|
|
1400
|
+
reportProvenance(report)
|
|
1400
1401
|
reportUnmeasured()
|
|
1401
1402
|
outro()
|
|
1402
1403
|
}
|
|
@@ -1418,6 +1419,7 @@ async function runSkillsAudit(
|
|
|
1418
1419
|
readme: report.readme,
|
|
1419
1420
|
folderName: report.folderName,
|
|
1420
1421
|
requirementSections: report.requirementSections,
|
|
1422
|
+
datedProvenance: report.datedProvenance,
|
|
1421
1423
|
},
|
|
1422
1424
|
checkpoints: {
|
|
1423
1425
|
descriptionLimit: DESCRIPTION_LIMIT,
|
|
@@ -1588,6 +1590,31 @@ function reportRequirementShape(report: SkillsAudit): void {
|
|
|
1588
1590
|
reportFindings(report.requirementSections)
|
|
1589
1591
|
}
|
|
1590
1592
|
|
|
1593
|
+
/**
|
|
1594
|
+
* Names where the date belongs, since the finding is a date to move rather
|
|
1595
|
+
* than one to delete, and reports without failing the run for the same reason
|
|
1596
|
+
* the frontmatter measures do.
|
|
1597
|
+
*/
|
|
1598
|
+
function reportProvenance(report: SkillsAudit): void {
|
|
1599
|
+
logStep('Provenance')
|
|
1600
|
+
logInfo(
|
|
1601
|
+
'A SKILL.md and its references state the current rule, with no ISO date outside a fence or code span.',
|
|
1602
|
+
)
|
|
1603
|
+
logInfo(
|
|
1604
|
+
'The incident and date that earned a rule go in REQUIREMENT.md under Gap, or stay in git.',
|
|
1605
|
+
)
|
|
1606
|
+
|
|
1607
|
+
if (report.datedProvenance.length === 0) {
|
|
1608
|
+
logInfo('No body or reference carries a date.')
|
|
1609
|
+
return
|
|
1610
|
+
}
|
|
1611
|
+
|
|
1612
|
+
logWarn(
|
|
1613
|
+
`${plural(report.datedProvenance.length, 'date')} in a body or reference`,
|
|
1614
|
+
)
|
|
1615
|
+
reportFindings(report.datedProvenance)
|
|
1616
|
+
}
|
|
1617
|
+
|
|
1591
1618
|
/**
|
|
1592
1619
|
* Stated on every run, including the run where everything above passed. A
|
|
1593
1620
|
* report that lists only what it measured reads as a verdict on the standard
|
|
@@ -1604,4 +1631,7 @@ function reportUnmeasured(): void {
|
|
|
1604
1631
|
logInfo(
|
|
1605
1632
|
'The 150-line body checkpoint is mechanical and still absent here, and it would print a count rather than a defect.',
|
|
1606
1633
|
)
|
|
1634
|
+
logInfo(
|
|
1635
|
+
'Undated provenance, such as "a pull request dropped...", and a date written in words or without its day are not read.',
|
|
1636
|
+
)
|
|
1607
1637
|
}
|
package/src/commands/markdown.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { readFile } from 'node:fs/promises'
|
|
|
3
3
|
import { dirname, relative, resolve } from 'node:path'
|
|
4
4
|
import type { Command } from 'commander'
|
|
5
5
|
import { BAN_SETS, emptyBanSets } from '@/markdown/bans'
|
|
6
|
+
import { lengthExemption } from '@/markdown/ceiling'
|
|
6
7
|
import { type MarkdownAuditRefusal, resolveMarkdown } from '@/markdown/files'
|
|
7
8
|
import { isGating } from '@/markdown/gate'
|
|
8
9
|
import { decodePath, findBrokenLinks, type LinkFinding } from '@/markdown/links'
|
|
@@ -16,6 +17,7 @@ import {
|
|
|
16
17
|
BASELINE,
|
|
17
18
|
CHECKPOINTS,
|
|
18
19
|
type Checkpoints,
|
|
20
|
+
documentHeight,
|
|
19
21
|
measureStructure,
|
|
20
22
|
type StructureReport,
|
|
21
23
|
} from '@/markdown/structure'
|
|
@@ -51,6 +53,9 @@ interface FileReport {
|
|
|
51
53
|
readonly bans: readonly BanFinding[]
|
|
52
54
|
readonly links: readonly LinkFinding[]
|
|
53
55
|
readonly structure: StructureReport
|
|
56
|
+
readonly renderedLines: number
|
|
57
|
+
/** The stated reason, or null where the document is not exempt. */
|
|
58
|
+
readonly exempt: string | null
|
|
54
59
|
}
|
|
55
60
|
|
|
56
61
|
export function register(program: Command): void {
|
|
@@ -62,7 +67,7 @@ export function register(program: Command): void {
|
|
|
62
67
|
markdown
|
|
63
68
|
.command('audit')
|
|
64
69
|
.description(
|
|
65
|
-
'Fail on a banned character, word, or spelling, and report bullet, paragraph, cadence, and
|
|
70
|
+
'Fail on a banned character, word, or spelling, and report bullet, paragraph, cadence, depth, and length',
|
|
66
71
|
)
|
|
67
72
|
.argument(
|
|
68
73
|
'[path...]',
|
|
@@ -85,6 +90,11 @@ export function register(program: Command): void {
|
|
|
85
90
|
'Bullet, paragraph, and depth weight are judgments a reader settles,',
|
|
86
91
|
'so all three report and none of them fails a run.',
|
|
87
92
|
'',
|
|
93
|
+
'Length reports a whole document past the ceiling and never exits 2.',
|
|
94
|
+
'The Document ceiling gate stage owns that verdict, since this audit also',
|
|
95
|
+
'runs on a record kept out of version control, where a long plan is not',
|
|
96
|
+
'a defect.',
|
|
97
|
+
'',
|
|
88
98
|
'Cadence reports the same way and carries one more caveat. Its range is',
|
|
89
99
|
'drawn from prose a person reads, so terse reference prose sits below',
|
|
90
100
|
'it correctly and a flat paragraph there is not a defect.',
|
|
@@ -148,7 +158,8 @@ async function runAudit(
|
|
|
148
158
|
const reports: FileReport[] = await Promise.all(
|
|
149
159
|
scope.files.map(async (rel) => {
|
|
150
160
|
const abs = resolve(root, rel)
|
|
151
|
-
const
|
|
161
|
+
const source = await readFile(abs, 'utf8')
|
|
162
|
+
const lines = bodyLines(source)
|
|
152
163
|
return {
|
|
153
164
|
rel,
|
|
154
165
|
bans: scanBans(lines, bans),
|
|
@@ -156,6 +167,8 @@ async function runAudit(
|
|
|
156
167
|
existsSync(resolve(dirname(abs), path)),
|
|
157
168
|
),
|
|
158
169
|
structure: measureStructure(rel, lines, checkpoints),
|
|
170
|
+
renderedLines: documentHeight(source, checkpoints.renderWidth),
|
|
171
|
+
exempt: lengthExemption(rel, source),
|
|
159
172
|
}
|
|
160
173
|
}),
|
|
161
174
|
)
|
|
@@ -168,6 +181,7 @@ async function runAudit(
|
|
|
168
181
|
reportParagraphs(reports, checkpoints)
|
|
169
182
|
reportCadence(reports, checkpoints)
|
|
170
183
|
reportDepth(reports, checkpoints)
|
|
184
|
+
reportLength(reports, checkpoints)
|
|
171
185
|
outro()
|
|
172
186
|
|
|
173
187
|
if (opts.json) {
|
|
@@ -192,6 +206,7 @@ async function runAudit(
|
|
|
192
206
|
cadence: checkpoints.cadence,
|
|
193
207
|
spread: checkpoints.spread,
|
|
194
208
|
opener: checkpoints.opener,
|
|
209
|
+
ceiling: checkpoints.ceiling,
|
|
195
210
|
},
|
|
196
211
|
entries: reports.map((report) => ({
|
|
197
212
|
path: report.rel,
|
|
@@ -202,6 +217,8 @@ async function runAudit(
|
|
|
202
217
|
heavyBullets: report.structure.heavyBullets,
|
|
203
218
|
heavyParagraphs: report.structure.heavyParagraphs,
|
|
204
219
|
cadence: report.structure.cadence,
|
|
220
|
+
renderedLines: report.renderedLines,
|
|
221
|
+
exempt: report.exempt,
|
|
205
222
|
})),
|
|
206
223
|
})}\n`,
|
|
207
224
|
)
|
|
@@ -606,3 +623,47 @@ function reportDepth(
|
|
|
606
623
|
.join('\n'),
|
|
607
624
|
)
|
|
608
625
|
}
|
|
626
|
+
|
|
627
|
+
/**
|
|
628
|
+
* Lists every document past the ceiling and counts the exempt ones rather than
|
|
629
|
+
* naming them, since an exemption is settled where its marker sits.
|
|
630
|
+
*/
|
|
631
|
+
function reportLength(
|
|
632
|
+
reports: readonly FileReport[],
|
|
633
|
+
checkpoints: Checkpoints,
|
|
634
|
+
): void {
|
|
635
|
+
logStep('Length')
|
|
636
|
+
logInfo(
|
|
637
|
+
`Whole documents measure rendered lines at ${checkpoints.renderWidth} columns, frontmatter and fenced blocks included.`,
|
|
638
|
+
)
|
|
639
|
+
logInfo(
|
|
640
|
+
'A changelog is exempt by name, and so is a document carrying a canon-length-exempt marker that names a reason.',
|
|
641
|
+
)
|
|
642
|
+
|
|
643
|
+
const past = reports.filter(
|
|
644
|
+
(report) => report.renderedLines > checkpoints.ceiling,
|
|
645
|
+
)
|
|
646
|
+
const over = past
|
|
647
|
+
.filter((report) => report.exempt === null)
|
|
648
|
+
.sort((a, b) => b.renderedLines - a.renderedLines)
|
|
649
|
+
const exempt = past.length - over.length
|
|
650
|
+
|
|
651
|
+
if (over.length === 0) {
|
|
652
|
+
logInfo(`No document past the ${checkpoints.ceiling}-line ceiling.`)
|
|
653
|
+
} else {
|
|
654
|
+
logWarn(
|
|
655
|
+
`${plural(over.length, 'document')} past the ${checkpoints.ceiling}-line ceiling`,
|
|
656
|
+
)
|
|
657
|
+
pipeOutput(
|
|
658
|
+
over
|
|
659
|
+
.map(
|
|
660
|
+
(report) => `${report.rel} ${report.renderedLines} rendered lines`,
|
|
661
|
+
)
|
|
662
|
+
.join('\n'),
|
|
663
|
+
)
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
if (exempt > 0) {
|
|
667
|
+
logInfo(`${plural(exempt, 'exempt document')} past the ceiling.`)
|
|
668
|
+
}
|
|
669
|
+
}
|