@erclx/canon 5.73.0 → 5.75.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/claude/.claude-plugin/plugin.json +1 -1
  3. package/claude/skills/api-design/references/adopted.md +5 -3
  4. package/claude/skills/build-in-slices/references/adopted.md +4 -2
  5. package/claude/skills/code-craft/references/adopted.md +9 -7
  6. package/claude/skills/codebase-layout/references/adopted.md +8 -6
  7. package/claude/skills/create-rule/SKILL.md +5 -1
  8. package/claude/skills/create-rule/references/ledger.md +73 -0
  9. package/claude/skills/create-rule/references/rule.md +1 -1
  10. package/claude/skills/create-skill/references/skill-practice.md +6 -1
  11. package/claude/skills/deprecation-migration/references/adopted.md +5 -3
  12. package/claude/skills/design-pick/references/board.md +2 -2
  13. package/claude/skills/plan-feature/SKILL.md +2 -1
  14. package/claude/skills/plan-groundwork/SKILL.md +2 -0
  15. package/claude/skills/pr-evidence/REQUIREMENT.md +1 -0
  16. package/claude/skills/pr-evidence/SKILL.md +2 -0
  17. package/claude/skills/pr-evidence/references/evidence.md +11 -4
  18. package/claude/skills/pr-evidence/references/reel.md +108 -0
  19. package/claude/skills/proposal-critique/REQUIREMENT.md +34 -0
  20. package/claude/skills/proposal-critique/SKILL.md +126 -0
  21. package/claude/skills/proposal-critique/references/adopted.md +44 -0
  22. package/claude/skills/review-craft/references/adopted.md +4 -4
  23. package/claude/skills/search-craft/references/adopted.md +9 -7
  24. package/claude/skills/systematic-debugging/references/adopted.md +4 -2
  25. package/claude/skills/teach-craft/references/adopted.md +8 -6
  26. package/claude/skills/test-craft/references/adopted.md +14 -12
  27. package/claude/skills/test-first/SKILL.md +1 -1
  28. package/claude/skills/test-first/references/adopted.md +9 -5
  29. package/claude/skills/ui-film/REQUIREMENT.md +4 -5
  30. package/claude/skills/ui-film/SKILL.md +21 -19
  31. package/claude/skills/ui-film/references/compose-craft.md +2 -2
  32. package/claude/skills/ui-film/references/handoff.md +2 -7
  33. package/claude/skills/write-human/references/adopted.md +8 -3
  34. package/docs/agents/audits.md +24 -24
  35. package/docs/agents/migrate.md +10 -10
  36. package/docs/migrations/by-verb.md +2 -0
  37. package/docs/target/index.md +1 -0
  38. package/docs/target/outside-tools.md +39 -0
  39. package/docs/workflow/skill-map.md +1 -0
  40. package/governance/detect.toml +4 -0
  41. package/governance/ledger/claude/rule.md +16 -0
  42. package/governance/ledger/skills/build-in-slices/SKILL.md +55 -0
  43. package/governance/ledger/skills/proposal-critique/SKILL.md +66 -0
  44. package/governance/ledger/skills/search-craft/SKILL.md +54 -0
  45. package/governance/ledger/skills/systematic-debugging/SKILL.md +64 -0
  46. package/governance/ledger/skills/test-first/SKILL.md +28 -0
  47. package/governance/ledger/skills/write-human/SKILL.md +62 -0
  48. package/governance/ledger/skills/write-human/density.md +36 -0
  49. package/governance/ledger/skills/write-human/machine-tells.md +30 -0
  50. package/governance/rules/claude/rule.md +13 -0
  51. package/package.json +1 -1
  52. package/scripts/ci/docs-only.sh +24 -0
  53. package/scripts/ci/refresh-frames-pr.sh +11 -12
  54. package/src/audits/catalog.ts +7 -2
  55. package/src/capture/stamp.ts +3 -5
  56. package/src/claude/cases/misc.ts +14 -0
  57. package/src/claude/skills-audit.ts +154 -8
  58. package/src/cli/help.ts +1 -1
  59. package/src/commands/claude.ts +6 -2
  60. package/src/commands/gov.ts +12 -7
  61. package/src/commands/migrate.ts +6 -2
  62. package/src/commands/pr.ts +110 -13
  63. package/src/commands/take.ts +36 -7
  64. package/src/counts/catalogs.ts +3 -5
  65. package/src/gate/measures.ts +2 -145
  66. package/src/gate/sequencer.ts +7 -1
  67. package/src/gate/stages.ts +36 -47
  68. package/src/gov/audit.ts +59 -13
  69. package/src/markdown/scope.ts +4 -0
  70. package/src/migrate/session-defaults.ts +114 -4
  71. package/src/pr/checks.ts +75 -3
  72. package/src/pr/evidence.ts +62 -1
  73. package/src/pr/lift.ts +23 -2
  74. package/src/pr/merge.ts +13 -3
  75. package/src/take/encode.ts +9 -1
  76. package/src/take/film.ts +17 -2
  77. package/src/take/fixtures/busy.html +107 -0
  78. package/src/take/perform.ts +99 -25
  79. package/src/take/screencast.ts +4 -3
  80. package/src/take/stepper.ts +33 -20
  81. package/src/targets/pulls.ts +42 -0
  82. package/tooling/claude/reference.md +4 -2
  83. package/tooling/claude/seeds/.claude/hooks/plan-guard.sh +239 -0
  84. package/tooling/claude/seeds/.claude/settings.json +8 -0
  85. package/claude/skills/ui-film/references/pr-clip.md +0 -108
  86. package/scripts/core/regen-hero.ts +0 -113
  87. package/src/capture/frame-values.ts +0 -326
@@ -0,0 +1,62 @@
1
+ ---
2
+ title: Write human skill ledger
3
+ description: Sources for claude/skills/write-human/SKILL.md, one row per bullet and ordered step in its concern groups
4
+ ---
5
+
6
+ # Write human skill ledger
7
+
8
+ The field is plain-language writing and technical editing. Government plain-language guidance from GOV.UK and digital.gov, the Google and Microsoft developer style guides, and Nielsen Norman Group's reading research carry most rows. Purdue OWL carries the cadence rows, whose thresholds are the toolkit's own.
9
+
10
+ `## The bans stay in force`, `## Machine tells`, and `## Source material` point elsewhere and hold no bullet, so they take no table. The five ordered steps under `## Revising a passage` each take a row, the way a bullet does.
11
+
12
+ ## What yields and what does not
13
+
14
+ | Bullet | Source | Read |
15
+ | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------- |
16
+ | A surface's own voice wins | House choice: a changelog or commit standard that fixes its own voice would otherwise answer to two authorities, and the session picks whichever it read last | 2026-10-28 |
17
+ | The yield covers voice alone | House choice: a declared warmer voice would otherwise excuse the fragment, the padding, and the tells, which read as defects in every register | 2026-10-28 |
18
+ | Terse reference prose is the default | [Microsoft Learn style quick start](https://learn.microsoft.com/en-us/contribute/content/style-quick-start): "Don't waste words. Be affirmative and don't use extra words or lots of qualifiers" | 2026-10-28 |
19
+
20
+ ## Voice
21
+
22
+ | Bullet | Source | Read |
23
+ | ------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- |
24
+ | A reader scanning, not studying | [How users read on the web](https://www.nngroup.com/articles/how-users-read-on-the-web/): "79 percent of our test users always scanned any new page they came across", and 16 percent read word by word | 2026-10-28 |
25
+ | Active voice and present tense | [digital.gov, Writing for understanding](https://digital.gov/guides/plain-language/writing): "Active voice makes it clear who should do what", and "The simplest and strongest form of a verb is present tense" | 2026-10-28 |
26
+ | Name the actor | [Google style guide, Active voice](https://developers.google.com/style/voice): "In passive voice, it's easy to neglect to indicate who or what is performing a particular action" | 2026-10-28 |
27
+ | Direct verbs and plain words | [GOV.UK, Use clear language](https://guidance.publishing.service.gov.uk/writing-to-gov-uk-standards/writing-guidelines/clear-language/): "Do not use formal or long words when easy or short ones will do" | 2026-10-28 |
28
+ | Drop marketing words | [GOV.UK A to Z, Words to avoid](https://guidance.publishing.service.gov.uk/writing-to-gov-uk-standards/style-guides/a-to-z-style-guide/#words-to-avoid): "leverage (unless in the financial sense), use 'influence' or 'use'" | 2026-10-28 |
29
+ | Drop vague qualifiers | [Google style guide, Word list](https://developers.google.com/style/word-list): "just" is "Usually, just is a filler word that you can delete without affecting your meaning" | 2026-10-28 |
30
+ | Direct on fact, hedged on doubt | [Microsoft Learn style quick start](https://learn.microsoft.com/en-us/contribute/content/style-quick-start): "Be affirmative and don't use extra words or lots of qualifiers" | 2026-10-28 |
31
+ | Assume developer-level knowledge | [GOV.UK, Meet user needs](https://guidance.publishing.service.gov.uk/writing-to-gov-uk-standards/writing-guidelines/meet-user-needs/): "only include what someone needs to know so they can complete a task" | 2026-10-28 |
32
+ | Front-load, and every sentence is new | [Microsoft, Top 10 tips](https://learn.microsoft.com/en-us/style-guide/top-10-tips-style-voice): "Lead with what's most important. Front-load keywords for scanning" | 2026-10-28 |
33
+
34
+ ## Rhythm
35
+
36
+ | Bullet | Source | Read |
37
+ | ----------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- |
38
+ | Read the paragraph as one block | House choice: cadence is a property of the run, so an edit made line by line clears each sentence and leaves the paragraph as flat as it was | 2026-10-28 |
39
+ | Longest and shortest within five | [Purdue OWL, Sentence variety](https://owl.purdue.edu/owl/general_writing/academic_writing/sentence_variety/index.html): "Too many sentences with the same structure and length can grow monotonous for readers". The five-word spread is the toolkit's own threshold | 2026-10-28 |
40
+ | Three alike openings is a pattern | [Purdue OWL, Sentence variety](https://owl.purdue.edu/owl/general_writing/academic_writing/sentence_variety/index.html): "If too many sentences start with the same word, especially The, It, This, or I, prose can grow tedious for readers" | 2026-10-28 |
41
+ | Vary the shape, not the word | [Purdue OWL, Sentence variety](https://owl.purdue.edu/owl/general_writing/academic_writing/sentence_variety/index.html): the monotony it names comes from "the same structure and length", so a new first word over one structure leaves it in place | 2026-10-28 |
42
+ | Never buy variety with a fragment | [Microsoft Learn style quick start](https://learn.microsoft.com/en-us/contribute/content/style-quick-start): small words "(such as "a," "the," "that," and "is") are crucial for machine translation. Be sure to include them" | 2026-10-28 |
43
+ | A connective for flow, never rhythm | House choice: a connective added to vary cadence names no relation the sentences carry, so it reads as the padding the density rules cut | 2026-10-28 |
44
+
45
+ ## Density
46
+
47
+ | Bullet | Source | Read |
48
+ | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------- |
49
+ | One claim per sentence | [digital.gov, Write short sentences](https://digital.gov/guides/plain-language/writing/clear-short): "Express only one idea in each sentence" | 2026-10-28 |
50
+ | Cut a restatement | [Microsoft, Top 10 tips](https://learn.microsoft.com/en-us/style-guide/top-10-tips-style-voice): "Give customers just enough information to make decisions confidently. Prune every excess word" | 2026-10-28 |
51
+ | Compress without the verb going | [Microsoft Learn style quick start](https://learn.microsoft.com/en-us/contribute/content/style-quick-start): "Include the "small words"", since words "understood for context" are still needed | 2026-10-28 |
52
+ | Resolve a pronoun to its noun | [Google style guide, Pronouns](https://developers.google.com/style/pronouns): "Avoid vague and confusing references between a pronoun and its antecedent" | 2026-10-28 |
53
+
54
+ ## Revising a passage
55
+
56
+ | Bullet | Source | Read |
57
+ | ---------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- |
58
+ | Read the whole passage first | House choice: an edit made before the whole is read polishes a sentence a later paragraph makes redundant | 2026-10-28 |
59
+ | Mark lengths and openings | House choice: the counts the rhythm rules name are invisible until written down, and the flattest run carries most of the uniformity a reader hears | 2026-10-28 |
60
+ | Cut a sentence adding no claim | House choice: shortening a sentence that carries no claim leaves the padding in place at a smaller size | 2026-10-28 |
61
+ | Re-read model text for the tells | [Wikipedia, Signs of AI writing](https://en.wikipedia.org/wiki/Wikipedia:Signs_of_AI_writing): it catalogs patterns model output carries, such as "LLMs overuse the rule of three" | 2026-10-28 |
62
+ | Run the audit and rewrite each hit | House choice: a near-synonym swapped for a flagged token clears the scan and keeps the habit, the failure that narrowed the markdown standard to characters | 2026-10-28 |
@@ -0,0 +1,36 @@
1
+ ---
2
+ title: Information density reference ledger
3
+ description: Sources for claude/skills/write-human/references/density.md, one row per bullet
4
+ ---
5
+
6
+ # Information density reference ledger
7
+
8
+ The field is plain-language writing and technical editing. The Google and Microsoft developer style guides and digital.gov's plain-language guide carry the sourced rows. `## Measuring a paragraph` holds prose and no bullet, so it takes no table.
9
+
10
+ ## What to cut
11
+
12
+ | Bullet | Source | Read |
13
+ | ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- |
14
+ | A restatement at another altitude | [Microsoft, Top 10 tips](https://learn.microsoft.com/en-us/style-guide/top-10-tips-style-voice): "Give customers just enough information to make decisions confidently. Prune every excess word" | 2026-10-28 |
15
+ | A setup clause naming the subject | [Microsoft, Top 10 tips](https://learn.microsoft.com/en-us/style-guide/top-10-tips-style-voice): "Edit out you can when it isn't necessary. Avoid weak phrasing like there is, there are, and there were" | 2026-10-28 |
16
+ | A qualifier changing no truth | [Microsoft Learn style quick start](https://learn.microsoft.com/en-us/contribute/content/style-quick-start): "Be affirmative and don't use extra words or lots of qualifiers" | 2026-10-28 |
17
+ | An example repeating the one above | House choice: a second example teaching what the first taught doubles the reading for no new case, and the reader goes looking for the difference | 2026-10-28 |
18
+ | A transition naming the relation | House choice: a transition such as `Additionally` before an addition restates the relation the sentence order already shows, so it adds a word and no claim | 2026-10-28 |
19
+
20
+ ## What never to cut
21
+
22
+ | Bullet | Source | Read |
23
+ | ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- |
24
+ | The verb | [Microsoft Learn style quick start](https://learn.microsoft.com/en-us/contribute/content/style-quick-start): small words "(such as "a," "the," "that," and "is") are crucial for machine translation" | 2026-10-28 |
25
+ | A qualifier that bounds a claim | [Google style guide, Excessive claims](https://developers.google.com/style/excessive-claims): "avoid superlatives like best, simplest, fastest, never, and always", the claim a dropped bound turns `most` into | 2026-10-28 |
26
+ | The source and the date of a claim | House choice: a measurement with no ref or date cannot be re-run, so a later reader cannot tell a current number from a stale one | 2026-10-28 |
27
+ | The actor | [Google style guide, Active voice](https://developers.google.com/style/voice): "Make clear who's performing the action" | 2026-10-28 |
28
+
29
+ ## Keeping a compressed sentence unambiguous
30
+
31
+ | Bullet | Source | Read |
32
+ | -------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- |
33
+ | Resolve every pronoun | [Google style guide, Write for a global audience](https://developers.google.com/style/translation): "if a pronoun is ambiguous, then replace it with the appropriate noun" | 2026-10-28 |
34
+ | Attach every modifier | [Google style guide, Write for a global audience](https://developers.google.com/style/translation): "place a word like only immediately before the word or phrase that it relates to" | 2026-10-28 |
35
+ | Quantify an amount | [Google style guide, Excessive claims](https://developers.google.com/style/excessive-claims): avoid a statement "about performance or cost that isn't easily verifiable with data that's available to the reader" | 2026-10-28 |
36
+ | Split two claims joined by `and` | [digital.gov, Write short sentences](https://digital.gov/guides/plain-language/writing/clear-short): "Break up your idea into parts and make each one the subject of its own sentence" | 2026-10-28 |
@@ -0,0 +1,30 @@
1
+ ---
2
+ title: Machine tells reference ledger
3
+ description: Sources for claude/skills/write-human/references/machine-tells.md, one entry per tell, held as lists since the reference carries no ledgered bullet
4
+ ---
5
+
6
+ # Machine tells reference ledger
7
+
8
+ The field is plain-language writing and technical editing, narrowed to the patterns editors flag in model-written text. Wikipedia's Signs of AI writing, which the humanizer catalog `adopted.md` names is built on, carries most tells. Every source below was read on 2026-10-28.
9
+
10
+ The reference states each tell as prose under its own H2, and its one list is `## Contents`, which the audit skips. A table here would count rows against no bullet and fail, so each tell takes a list entry instead.
11
+
12
+ ## Sourced to the catalog of model tells
13
+
14
+ - Rule of three: [Signs of AI writing](https://en.wikipedia.org/wiki/Wikipedia:Signs_of_AI_writing) says "LLMs overuse the rule of three"
15
+ - Inline-header lists: the same page says model output "often includes vertical lists formatted in a specific way"
16
+ - Closing restatement: the same page names section summaries, which older models "often added" to longer outputs
17
+ - Escalating parallel clauses: the same page names negative parallelisms, which read "as though it is clearing up a common misconception"
18
+ - Empty superlative frames: the same page says "LLM writing often puffs up the importance of the subject matter"
19
+
20
+ ## Sourced to a style guide
21
+
22
+ - Synonym cycling: [Google, Write for a global audience](https://developers.google.com/style/translation) says "If you use different names for the same thing", readers "might think you're referring to different concepts"
23
+ - Adverb propping: [Google, Excessive claims](https://developers.google.com/style/excessive-claims) rules out a claim "that isn't easily verifiable with data that's available to the reader"
24
+ - Passive voice with an unnamed actor: [Google, Active voice](https://developers.google.com/style/voice) says "Make clear who's performing the action"
25
+
26
+ ## House choices
27
+
28
+ - False ranges: a `from X to Y` over items with no scale claims a coverage the sentence never lists, so a reader cannot check what falls inside
29
+ - Question as transition: a question standing between paragraphs delays the answer by one line and adds no claim, the padding the density rules cut
30
+ - A colon or comma where the dash was: a session told to drop a banned dash swaps the mark and keeps the join, so the ban clears and the habit stays
@@ -0,0 +1,13 @@
1
+ ---
2
+ description: Route edits to a rule file to the create-rule skill for its shape and checklist
3
+ paths:
4
+ - '.claude/rules/project/**/*.md'
5
+ ---
6
+
7
+ # Rule standards
8
+
9
+ ## Rule files
10
+
11
+ - Load the `canon:create-rule` skill before a hand edit to a rule, since it carries the frontmatter, the shape limits, and the bullet checklist.
12
+ - Report it rather than proceeding silently when `canon:create-rule` does not resolve, as in a project that installed governance without the plugin.
13
+ - Run `canon gov audit` over each rule the edit touched before committing it.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@erclx/canon",
3
3
  "type": "module",
4
- "version": "5.73.0",
4
+ "version": "5.75.0",
5
5
  "description": "Runs your agent's work and keeps its code good, on one setup for every repo.",
6
6
  "license": "MIT",
7
7
  "bin": {
@@ -0,0 +1,24 @@
1
+ #!/usr/bin/env bash
2
+ # Reports whether a pull request changes anything besides Markdown, called by
3
+ # the changes job in verify.yml to skip the macOS tests on a docs-only change.
4
+ # Prints code=false only when git lists at least one changed file and every
5
+ # one ends in .md, so a missing base, a failed diff, or an empty diff runs the
6
+ # tests rather than skipping them. Renames are split into a delete and an add,
7
+ # so a code file renamed to .md still counts as code.
8
+ #
9
+ # Usage: docs-only.sh <base>
10
+ # Writes to $GITHUB_OUTPUT, or stdout outside Actions, and always exits 0.
11
+
12
+ set -e
13
+
14
+ out=${GITHUB_OUTPUT:-/dev/stdout}
15
+ code=true
16
+
17
+ if [ $# -eq 1 ] && files=$(git diff --name-only --no-renames "$1" HEAD) && [ -n "$files" ]; then
18
+ if ! grep -qv '\.md$' <<<"$files"; then
19
+ code=false
20
+ fi
21
+ fi
22
+
23
+ echo "code=$code" >>"$out"
24
+ echo "code=$code" >&2
@@ -1,29 +1,29 @@
1
1
  #!/usr/bin/env bash
2
- # Commits the regenerated capture frames onto the refresh branch and opens or
2
+ # Commits the recaptured home baseline onto the refresh branch and opens or
3
3
  # updates its pull request, called by refresh-capture-frames.yml after the
4
- # capture steps. The step holds GH_TOKEN and HUSKY, since the token scope is
4
+ # capture step. The step holds GH_TOKEN and HUSKY, since the token scope is
5
5
  # read off the workflow.
6
6
  #
7
7
  # Hooks stay off, since pre-push runs a check whose format step needs shfmt,
8
8
  # which the runner lacks. verify.yml checks the pull request.
9
9
  #
10
- # The frames commit where they rendered, then only the files that commit wrote
10
+ # The baseline commits where they rendered, then only the files that commit wrote
11
11
  # or dropped carry onto main fetched now, so a merge landing mid-run neither
12
- # leaves the branch on an older base nor loses a frame it moved. The pull
12
+ # leaves the branch on an older base nor loses a baseline frame it moved. The pull
13
13
  # request resolves by number, since every merged refresh leaves a closed one on
14
14
  # the same head.
15
15
  set -e
16
16
 
17
17
  branch=chore/refresh-capture-frames
18
- title="chore(web): refresh capture frames"
18
+ title="chore(web): refresh home baseline"
19
19
 
20
- if [ -z "$(git status --porcelain assets/captures assets/frames assets/evidence)" ]; then
21
- echo "Frames are current, nothing to refresh."
20
+ if [ -z "$(git status --porcelain assets/evidence)" ]; then
21
+ echo "The home baseline is current, nothing to refresh."
22
22
  exit 0
23
23
  fi
24
24
  git config user.name "github-actions[bot]"
25
25
  git config user.email "41898282+github-actions[bot]@users.noreply.github.com" # canon-allow-reference: the bot account id, not a commit
26
- git add assets/captures assets/frames assets/evidence
26
+ git add assets/evidence
27
27
  git commit -m "$title"
28
28
  rendered=$(git rev-parse HEAD)
29
29
  written=$(git diff --name-only --no-renames --diff-filter=d HEAD~1 HEAD)
@@ -36,8 +36,8 @@ fi
36
36
  if [ -n "$dropped" ]; then
37
37
  printf '%s\n' "$dropped" | xargs -d '\n' git rm -q --ignore-unmatch --
38
38
  fi
39
- if git diff --cached --quiet -- assets/captures assets/frames assets/evidence; then
40
- echo "Main already carries these frames, nothing to refresh."
39
+ if git diff --cached --quiet -- assets/evidence; then
40
+ echo "Main already carries this baseline, nothing to refresh."
41
41
  exit 0
42
42
  fi
43
43
  git commit -m "$title"
@@ -50,11 +50,10 @@ if [ -z "$number" ]; then
50
50
  --title "$title" \
51
51
  --body "## Summary
52
52
 
53
- - Refreshes the capture frames from main so their counts and tokens stay current.
53
+ - Refreshes the home page baseline from main so canon pr evidence compares against a current render.
54
54
 
55
55
  ## Key changes
56
56
 
57
- - Regenerates the count and token bearing frames, their images, and their stamps from main.
58
57
  - Recaptures the home page baseline under assets/evidence/home.
59
58
  - Merges itself by squash once its required check passes."
60
59
  number=$(gh pr list --head "$branch" --state open --json number --jq '.[0].number')
@@ -379,7 +379,8 @@ function restatedCounts(record: unknown): Record<string, number> | undefined {
379
379
  * a short one are fixed by different passes.
380
380
  */
381
381
  function ruleCounts(record: unknown): Record<string, number> | undefined {
382
- const rules = asObject(record)?.rules
382
+ const root = asObject(record)
383
+ const rules = root?.rules
383
384
  if (!Array.isArray(rules)) return undefined
384
385
 
385
386
  let shapeRules = 0
@@ -397,7 +398,11 @@ function ruleCounts(record: unknown): Record<string, number> | undefined {
397
398
  if ((lengthOf(ledger.mismatches) ?? 0) > 0) ledgerMismatches += 1
398
399
  }
399
400
 
400
- return { shapeRules, missingLedgers, ledgerMismatches }
401
+ // A target ledger with no rule sits beside `rules` rather than in it. A
402
+ // binary predating it carries no `orphans` key, which reads as none.
403
+ const orphanLedgers = lengthOf(root?.orphans) ?? 0
404
+
405
+ return { shapeRules, missingLedgers, ledgerMismatches, orphanLedgers }
401
406
  }
402
407
 
403
408
  function findingsOnly(record: unknown): Record<string, number> | undefined {
@@ -6,13 +6,11 @@ const STAMP_EXTENSION = '.stamp'
6
6
 
7
7
  /**
8
8
  * Provenance for one rendered PNG: the markup it came from and a digest over
9
- * each side of the pair. `assertStampField` in `src/gate/measures.ts` reads both
10
- * digests back off the first whitespace-separated token, so the field names, the
11
- * colon, and the space after it are a contract between the writer and the gate
12
- * rather than formatting.
9
+ * each side of the pair. Nothing reads the digests back today, so no check
10
+ * fails when either side moves.
13
11
  *
14
12
  * Both sides are recorded because either can move alone. The markup digest
15
- * catches an edit committed with no capture, and the image digest catches a PNG
13
+ * would show an edit committed with no capture, and the image digest a PNG
16
14
  * replaced under markup that never changed.
17
15
  */
18
16
  export interface CaptureStamp {
@@ -7,6 +7,20 @@ export const MISC_CASES: readonly SkillCase[] = [
7
7
  'Bundle up every open call that only I can make and ask me all at once.',
8
8
  expect: 'decision-escalate',
9
9
  },
10
+ {
11
+ prompt: 'What do you need me to decide before you keep going?',
12
+ expect: 'decision-escalate',
13
+ },
14
+ {
15
+ prompt:
16
+ 'Poke holes in this plan before we build it. Is the problem even real?',
17
+ expect: 'proposal-critique',
18
+ },
19
+ {
20
+ prompt:
21
+ 'CI keeps failing on the install step and the plan is to add a retry. Is the cause actually ours, and is this fix worth it?',
22
+ expect: 'proposal-critique',
23
+ },
10
24
  {
11
25
  prompt:
12
26
  'Show me a few different treatments for this callout so I can pick one by looking.',
@@ -82,6 +82,7 @@ export const LEDGER_SKILLS: readonly string[] = [
82
82
  join(SHIPPED_CORPUS, 'write-human'),
83
83
  join(SHIPPED_CORPUS, 'test-first'),
84
84
  join(SHIPPED_CORPUS, 'ui-craft'),
85
+ join(SHIPPED_CORPUS, 'proposal-critique'),
85
86
  ]
86
87
 
87
88
  function references(...names: string[]): string[] {
@@ -119,7 +120,13 @@ export const BULLET_LEDGER_SKILLS: Readonly<Record<string, BulletLedgerScope>> =
119
120
  },
120
121
  [join(SHIPPED_CORPUS, 'teach-craft')]: { exempt: [], body: true },
121
122
  [join(SHIPPED_CORPUS, 'test-craft')]: { exempt: [], body: true },
123
+ [join(SHIPPED_CORPUS, 'test-first')]: { exempt: [], body: true },
122
124
  [join(SHIPPED_CORPUS, 'codebase-layout')]: { exempt: [], body: true },
125
+ [join(SHIPPED_CORPUS, 'search-craft')]: { exempt: [], body: true },
126
+ [join(SHIPPED_CORPUS, 'write-human')]: { exempt: [], body: true },
127
+ [join(SHIPPED_CORPUS, 'systematic-debugging')]: { exempt: [], body: true },
128
+ [join(SHIPPED_CORPUS, 'build-in-slices')]: { exempt: [], body: true },
129
+ [join(SHIPPED_CORPUS, 'proposal-critique')]: { exempt: [], body: true },
123
130
  }
124
131
 
125
132
  /** Where a per-bullet ledger lives, as `<skill>/<file>.md` below it. */
@@ -134,6 +141,15 @@ export function skillLedgerPath(folder: string, file: string): string {
134
141
  return join(SKILL_LEDGERS, folder, inside.startsWith('..') ? file : inside)
135
142
  }
136
143
 
144
+ /**
145
+ * Where a target keeps the ledgers behind its own skills, as `<skill>/<file>.md`
146
+ * below it. A tree holding `governance/rules/` is the toolkit, which keeps its
147
+ * ledgers at `SKILL_LEDGERS`, so this folder is never read there.
148
+ */
149
+ export const PROJECT_SKILL_LEDGERS = join('canon', 'ledger', 'skills')
150
+
151
+ const TOOLKIT_MARKER = join('governance', 'rules')
152
+
137
153
  /** The closing H2s a practice skill carries, matched exactly outside fences. */
138
154
  export const PRACTICE_SECTIONS: readonly string[] = [
139
155
  'Excuses and rebuttals',
@@ -161,6 +177,9 @@ export const PRACTICE_LEDGER = join('references', 'adopted.md')
161
177
  */
162
178
  const EXTERNAL_LINK = /\]\(\s*https?:\/\/|<https?:\/\//
163
179
 
180
+ /** The address an inline link or an autolink cites, captured for comparison. */
181
+ const EXTERNAL_ADDRESS = /\]\(\s*(https?:\/\/[^)\s]+)|<(https?:\/\/[^>\s]+)>/g
182
+
164
183
  /**
165
184
  * A table cell opening on the marker the governance ledgers already use for a
166
185
  * rule the toolkit chose, so one check reads every ledger alike.
@@ -277,6 +296,8 @@ interface ReferenceLedger {
277
296
  readonly ledgerRel: string
278
297
  /** Undefined when the ledger file is absent. */
279
298
  readonly mismatches: readonly LedgerMismatch[] | undefined
299
+ /** Every source the ledger links, normalized by `sourceKey`. */
300
+ readonly sources: readonly string[]
280
301
  }
281
302
 
282
303
  interface SkillText {
@@ -302,6 +323,9 @@ export async function auditSkills(root: string): Promise<SkillsAudit> {
302
323
  present.map((corpus) => readCorpus(root, corpus.rel, corpus.dir)),
303
324
  )
304
325
  const sources = perCorpus.flat()
326
+ const projectLedgers = existsSync(join(root, TOOLKIT_MARKER))
327
+ ? []
328
+ : await readProjectLedgers(root, sources)
305
329
 
306
330
  return {
307
331
  corpora: present.map((corpus, index) => ({
@@ -334,6 +358,7 @@ export async function auditSkills(root: string): Promise<SkillsAudit> {
334
358
  sources,
335
359
  ),
336
360
  ...sources.flatMap(practiceFindings),
361
+ ...projectLedgers,
337
362
  ],
338
363
  referenceContents: sources.flatMap((source) =>
339
364
  source.bodies.flatMap(contentsFindings),
@@ -453,17 +478,76 @@ async function readReferenceLedgers(
453
478
  files.map(async ({ path, text }) => {
454
479
  const ledgerRel = skillLedgerPath(folder, path)
455
480
  const ledgerPath = join(root, ledgerRel)
456
- const mismatches = existsSync(ledgerPath)
457
- ? compareLedger(
458
- ledgerGroups(text, path === 'SKILL.md'),
459
- countLedgerRows(await Bun.file(ledgerPath).text()),
460
- )
481
+ const ledger = existsSync(ledgerPath)
482
+ ? await Bun.file(ledgerPath).text()
461
483
  : undefined
462
- return { rel: join(skillRel, path), ledgerRel, mismatches }
484
+ const mismatches =
485
+ ledger === undefined
486
+ ? undefined
487
+ : compareLedger(
488
+ ledgerGroups(text, path === 'SKILL.md'),
489
+ countLedgerRows(ledger),
490
+ )
491
+ return {
492
+ rel: join(skillRel, path),
493
+ ledgerRel,
494
+ mismatches,
495
+ sources: ledger === undefined ? [] : linkedSources(ledger),
496
+ }
463
497
  }),
464
498
  )
465
499
  }
466
500
 
501
+ /**
502
+ * Pairs each file a target wrote under `PROJECT_SKILL_LEDGERS` with the skill
503
+ * file of that name, `SKILL.md` with the body and any other with the reference
504
+ * at `references/<file>`. Nothing is owed, since a project has no list to owe a
505
+ * ledger against, so only a ledger that exists is read, and one naming a skill
506
+ * or a file that is gone is reported rather than skipped.
507
+ */
508
+ async function readProjectLedgers(
509
+ root: string,
510
+ sources: readonly SkillSource[],
511
+ ): Promise<SkillFinding[]> {
512
+ const dir = join(root, PROJECT_SKILL_LEDGERS)
513
+ if (!existsSync(dir)) return []
514
+ const paths = [
515
+ ...new Bun.Glob('**/*.md').scanSync({ cwd: dir, onlyFiles: true }),
516
+ ].sort()
517
+
518
+ const perLedger = await Promise.all(
519
+ paths.map(async (path): Promise<SkillFinding[]> => {
520
+ const ledgerRel = join(PROJECT_SKILL_LEDGERS, path)
521
+ const [folder, ...rest] = path.split('/')
522
+ const owners = sources.filter((source) => source.folder === folder)
523
+ if (rest.length === 0 || owners.length === 0) {
524
+ return [{ rel: ledgerRel, detail: `missing skill: ${folder}` }]
525
+ }
526
+ const file = rest.join('/')
527
+ const target = file === 'SKILL.md' ? file : join('references', file)
528
+ const rows = countLedgerRows(await Bun.file(join(dir, path)).text())
529
+
530
+ return owners.flatMap((owner) => {
531
+ const rel = join(owner.rel, target)
532
+ const body = owner.bodies.find((entry) => entry.rel === rel)
533
+ if (body === undefined) {
534
+ return [{ rel: ledgerRel, detail: `missing file: ${rel}` }]
535
+ }
536
+ return referenceLedgerFindings({
537
+ rel,
538
+ ledgerRel,
539
+ mismatches: compareLedger(
540
+ ledgerGroups(body.text, target === 'SKILL.md'),
541
+ rows,
542
+ ),
543
+ sources: [],
544
+ })
545
+ })
546
+ }),
547
+ )
548
+ return perLedger.flat()
549
+ }
550
+
467
551
  /**
468
552
  * Directives per H2, `null` for any above the first. A contents list repeats
469
553
  * the headings rather than stating a rule, so `## Contents` takes no rows. A
@@ -681,6 +765,7 @@ function practiceFindings(source: SkillSource): SkillFinding[] {
681
765
  return [
682
766
  ...sections,
683
767
  ...ledgerFindings(source),
768
+ ...duplicateFindings(source),
684
769
  ...source.referenceLedgers.flatMap(referenceLedgerFindings),
685
770
  ...source.staleExemptions.map((rel) => ({
686
771
  rel,
@@ -710,17 +795,78 @@ function ledgerFindings(source: SkillSource): SkillFinding[] {
710
795
  return [{ rel: source.rel, detail: `missing ledger: ${PRACTICE_LEDGER}` }]
711
796
  }
712
797
 
713
- const cites = bodyLines(source.ledger)
714
- .filter((line) => !line.fenced)
798
+ const lines = bodyLines(source.ledger).filter((line) => !line.fenced)
799
+ const cites = lines
715
800
  .map((line) => maskCodeSpans(line.text))
716
801
  .some((text) => EXTERNAL_LINK.test(text) || HOUSE_CHOICE.test(text))
717
802
  if (cites) return []
803
+ if (pointsAtLedgers(source, lines)) return []
718
804
 
719
805
  return [
720
806
  { rel: source.rel, detail: `ledger cites nothing: ${PRACTICE_LEDGER}` },
721
807
  ]
722
808
  }
723
809
 
810
+ /**
811
+ * A skill on the per-bullet list cites its sources in its ledger folder, so a
812
+ * record whose every link moved there is left pointing at the folder instead.
813
+ * A skill off the list has nowhere else for a source to live.
814
+ */
815
+ function pointsAtLedgers(
816
+ source: SkillSource,
817
+ lines: readonly { text: string }[],
818
+ ): boolean {
819
+ if (BULLET_LEDGER_SKILLS[source.rel] === undefined) return false
820
+ const folder = `${join(SKILL_LEDGERS, source.folder)}/`
821
+ return lines.some((line) => line.text.includes(folder))
822
+ }
823
+
824
+ /**
825
+ * The source record keeps a source no ledger row cites, so a link in both
826
+ * places is a second copy that drifts on the first edit to either.
827
+ */
828
+ function duplicateFindings(source: SkillSource): SkillFinding[] {
829
+ if (source.ledger === undefined) return []
830
+ if (BULLET_LEDGER_SKILLS[source.rel] === undefined) return []
831
+
832
+ const ledgered = new Map<string, string>()
833
+ for (const entry of source.referenceLedgers) {
834
+ for (const key of entry.sources) {
835
+ if (!ledgered.has(key)) ledgered.set(key, entry.ledgerRel)
836
+ }
837
+ }
838
+
839
+ return [...new Set(linkedSources(source.ledger))].flatMap((key) => {
840
+ const ledgerRel = ledgered.get(key)
841
+ if (ledgerRel === undefined) return []
842
+ return [
843
+ {
844
+ rel: join(source.rel, PRACTICE_LEDGER),
845
+ detail: `duplicate source: ${key} in ${ledgerRel}`,
846
+ },
847
+ ]
848
+ })
849
+ }
850
+
851
+ /** Every external address a text links outside fences and code spans. */
852
+ function linkedSources(text: string): string[] {
853
+ return bodyLines(text)
854
+ .filter((line) => !line.fenced)
855
+ .flatMap((line) =>
856
+ [...maskCodeSpans(line.text).matchAll(EXTERNAL_ADDRESS)].map((match) =>
857
+ sourceKey(match[1] ?? match[2] ?? ''),
858
+ ),
859
+ )
860
+ }
861
+
862
+ /**
863
+ * An anchor and a trailing slash reach the same page, so both drop. A commit
864
+ * pin names a different version, so the path stays as written.
865
+ */
866
+ function sourceKey(address: string): string {
867
+ return address.replace(/#.*$/, '').replace(/\/+$/, '')
868
+ }
869
+
724
870
  /**
725
871
  * Asks only the shipped corpus for a family, since the family names the skill
726
872
  * map group a skill's row sits under and an internal skill takes no row.
package/src/cli/help.ts CHANGED
@@ -99,7 +99,7 @@ const EXAMPLES = [
99
99
  'canon indexes regen --dry-run --json',
100
100
  'canon docs list --json',
101
101
  'canon docs agents',
102
- 'canon capture assets/captures/install.html --selector .window --out assets/frames',
102
+ 'canon capture web/public/previews/design-tokens/index.html --selector body --out assets/evidence',
103
103
  'canon inventory focus --json',
104
104
  'canon drive http://localhost:4173 run.json --json',
105
105
  'canon take http://localhost:4173 plan.json --out takes --json',
@@ -34,6 +34,7 @@ import {
34
34
  SKILL_LEDGERS,
35
35
  PRACTICE_SECTIONS,
36
36
  PRACTICE_SKILLS,
37
+ PROJECT_SKILL_LEDGERS,
37
38
  REQUIREMENT_LIMITS,
38
39
  REQUIREMENT_SECTIONS,
39
40
  RETIRED_REQUIREMENT_SECTION,
@@ -1772,7 +1773,7 @@ function reportProvenance(report: SkillsAudit): void {
1772
1773
  /**
1773
1774
  * Reports without failing the run for the reason provenance does. The list
1774
1775
  * names shipped folders alone, which the step says so a target reading a pass
1775
- * knows its own skills were never in scope.
1776
+ * knows its own skills were read only against a ledger it wrote.
1776
1777
  */
1777
1778
  function reportPracticeShape(report: SkillsAudit): void {
1778
1779
  logStep('Practice shape')
@@ -1783,7 +1784,10 @@ function reportPracticeShape(report: SkillsAudit): void {
1783
1784
  `A skill on the per-bullet list, ${plural(Object.keys(BULLET_LEDGER_SKILLS).length, 'skill')} here, also matches each reference it does not exempt, and a listed body, to a ledger under ${SKILL_LEDGERS}, one row per bullet under each H2.`,
1784
1785
  )
1785
1786
  logInfo(
1786
- `Both lists are kept here and name ${plural(PRACTICE_SKILLS.length, 'practice skill')} and ${plural(LEDGER_SKILLS.length, 'ledger skill')}, so a skill outside ${SHIPPED_CORPUS} is never read.`,
1787
+ `Both lists are kept here and name ${plural(PRACTICE_SKILLS.length, 'practice skill')} and ${plural(LEDGER_SKILLS.length, 'ledger skill')}, so a skill outside ${SHIPPED_CORPUS} is never read against them.`,
1788
+ )
1789
+ logInfo(
1790
+ `A tree with no governance/rules/ has each file under ${PROJECT_SKILL_LEDGERS} matched to the skill file of its name, one row per bullet under each H2, and owes none.`,
1787
1791
  )
1788
1792
 
1789
1793
  if (report.practiceShape.length === 0) {
@@ -646,7 +646,9 @@ export function register(program: Command): void {
646
646
  ` A rule under ${CORE_FOLDERS.join(', ')} has its ledger checked where`,
647
647
  ' one exists, and under --touched owes one once the branch changed it.',
648
648
  ' With no baseline against main, --touched reads no rule as touched.',
649
- ' Any other tree reads .claude/rules/project/ alone and asks no ledger.',
649
+ ' Any other tree reads .claude/rules/project/ and checks the ledger of',
650
+ ' each rule that has one at canon/ledger/rules/<subfolder>/<slug>.md.',
651
+ ' A ledger there with no rule is a finding on a whole-corpus read.',
650
652
  ' .claude/rules/canon/ is fixed at the toolkit source and never read',
651
653
  ' unless named. A named path resolves against --root and must sit',
652
654
  ' inside it.',
@@ -757,7 +759,10 @@ function reportAudit(
757
759
  intro('canon gov audit')
758
760
 
759
761
  const shaped = report.rules.filter((reading) => reading.findings.length > 0)
760
- const ledgers = report.rules.flatMap(describeLedger)
762
+ const ledgers = [
763
+ ...report.rules.flatMap(describeLedger),
764
+ ...report.orphans.map((path) => `${path}: no rule for this ledger`),
765
+ ]
761
766
 
762
767
  logStep('Corpus')
763
768
  logInfo(
@@ -791,12 +796,12 @@ function reportAudit(
791
796
  'no baseline against main, so --touched reads no core rule as touched',
792
797
  )
793
798
  }
794
- if (report.scope === 'target') {
795
- logInfo('not read, since ledgers live in the toolkit and never ship')
796
- } else if (ledgers.length === 0) {
797
- logInfo('every rule owing a ledger has one, with a row per bullet')
798
- } else {
799
+ if (ledgers.length > 0) {
799
800
  for (const line of ledgers) logWarn(line)
801
+ } else if (report.scope === 'target') {
802
+ logInfo('every project ledger has its rule and a row per bullet')
803
+ } else {
804
+ logInfo('every rule owing a ledger has one, with a row per bullet')
800
805
  }
801
806
 
802
807
  const failing = report.rules.filter(hasFinding).length