@erclx/canon 5.74.0 → 5.76.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 (61) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/claude/.claude-plugin/plugin.json +1 -1
  3. package/claude/skills/create-rule/SKILL.md +5 -1
  4. package/claude/skills/create-rule/references/ledger.md +73 -0
  5. package/claude/skills/create-rule/references/rule.md +1 -1
  6. package/claude/skills/create-skill/references/skill-practice.md +1 -1
  7. package/claude/skills/design-identity/REQUIREMENT.md +1 -1
  8. package/claude/skills/design-identity/SKILL.md +13 -4
  9. package/claude/skills/design-identity/references/card-route.md +11 -4
  10. package/claude/skills/git-issue/REQUIREMENT.md +4 -2
  11. package/claude/skills/git-issue/SKILL.md +32 -3
  12. package/claude/skills/git-issue/references/issue.md +7 -0
  13. package/claude/skills/issue-triage/REQUIREMENT.md +35 -0
  14. package/claude/skills/issue-triage/SKILL.md +130 -0
  15. package/claude/skills/pr-evidence/references/reel.md +9 -9
  16. package/claude/skills/proposal-critique/references/adopted.md +44 -0
  17. package/claude/skills/role-orchestrator/SKILL.md +1 -1
  18. package/claude/skills/role-orchestrator/references/orchestrator-handoff.md +1 -1
  19. package/claude/skills/role-orchestrator/references/orchestrator-poll.md +15 -15
  20. package/claude/skills/role-orchestrator/scripts/poll.ts +211 -96
  21. package/claude/skills/role-orchestrator/scripts/repo.ts +53 -1
  22. package/claude/skills/ui-film/SKILL.md +8 -7
  23. package/docs/agents/audits.md +24 -24
  24. package/docs/agents/migrate.md +10 -10
  25. package/docs/migrations/by-verb.md +10 -4
  26. package/docs/workflow/ai-workflow.md +1 -1
  27. package/docs/workflow/skill-map.md +1 -0
  28. package/governance/detect.toml +4 -0
  29. package/governance/ledger/claude/rule.md +16 -0
  30. package/governance/rules/claude/rule.md +13 -0
  31. package/package.json +1 -1
  32. package/scripts/ci/refresh-frames-pr.sh +11 -12
  33. package/src/audits/catalog.ts +7 -2
  34. package/src/capture/render.ts +30 -5
  35. package/src/capture/stamp.ts +3 -5
  36. package/src/claude/cases/git.ts +5 -0
  37. package/src/claude/cases/workflow.ts +4 -0
  38. package/src/claude/skills-audit.ts +65 -0
  39. package/src/cli/help.ts +1 -1
  40. package/src/commands/capture.ts +38 -1
  41. package/src/commands/claude.ts +10 -2
  42. package/src/commands/gov.ts +12 -7
  43. package/src/commands/migrate.ts +6 -2
  44. package/src/commands/take.ts +36 -7
  45. package/src/counts/catalogs.ts +3 -5
  46. package/src/gate/measures.ts +1 -144
  47. package/src/gate/stages.ts +0 -23
  48. package/src/gov/audit.ts +59 -13
  49. package/src/markdown/scope.ts +4 -0
  50. package/src/migrate/session-defaults.ts +146 -8
  51. package/src/take/encode.ts +9 -1
  52. package/src/take/film.ts +17 -2
  53. package/src/take/fixtures/busy.html +107 -0
  54. package/src/take/perform.ts +99 -25
  55. package/src/take/screencast.ts +4 -3
  56. package/src/take/stepper.ts +33 -20
  57. package/tooling/claude/reference.md +4 -2
  58. package/tooling/claude/seeds/.claude/hooks/plan-guard.sh +239 -0
  59. package/tooling/claude/seeds/.claude/settings.json +12 -0
  60. package/scripts/core/regen-hero.ts +0 -113
  61. package/src/capture/frame-values.ts +0 -326
@@ -63,30 +63,30 @@ An audit that finds no corpus reports `absent`, which moves neither the verdict
63
63
 
64
64
  `canon audits list` reports the first 18 rows. The rows marked `outside` run by hand or from a skill and sit beside the aggregate.
65
65
 
66
- | Id | Verb | Measures | Finding |
67
- | -------------------- | ----------------------------------- | ---------------------------------------------------------------------------------- | -------------- |
68
- | `context` | `canon context audit` | Context folder structure, citations, and the requirements record's word cap | Gates |
69
- | `markdown` | `canon markdown audit` | Bans, dead links, density, and reference shape | Gates |
70
- | `skills` | `canon claude skills audit` | Both skill corpora against the skill standards | Gates |
71
- | `skills-reach` | `canon claude skills reach` | Skill bodies citing a path no target receives | Reports |
72
- | `skills-rank` | `canon claude skills rank` | Whether skill descriptions separate against a routing case corpus | Reports |
73
- | `tasks` | `canon tasks validate` | Board rows nothing resolves | Reports |
74
- | `records-plans` | `canon records validate plans` | Plan records against their shape | Reports |
75
- | `records-groundwork` | `canon records validate groundwork` | Groundwork folders against their shape | Reports |
76
- | `records-intake` | `canon records validate intake` | Intake folders against their shape | Reports |
77
- | `comments` | `canon comments scan` | Comment density by language and kind, and `TODO`, `FIXME`, `HACK`, `XXX` | Reports |
78
- | `test-order` | `canon gov test-order` | Implementations that reached branch history before their test | Reports |
79
- | `secrets` | `canon secrets scan` | Credential-shaped values in the shipped tree | Gates |
80
- | `labels` | `canon labels audit` | Changed paths no row of the label map reaches | Reports |
81
- | `restated` | `canon gov restated` | Instructions `CLAUDE.md` states that a second surface states too | Reports |
82
- | `rules` | `canon gov audit` | Rules past a shape limit, and here rules short of a ledger or out of step with one | Reports |
83
- | `counts` | `canon gov counts` | Sentences stating a closed catalog's size that disagree with the tree | Reports |
84
- | `census` | `canon census` | File count, extension breakdown, and line total | Reports |
85
- | `deps` | `canon deps audit` | Advisories against the resolved dependencies | Reports |
86
- | `outside` | `canon gov citations` | Rule citations and internal frontmatter globs reaching nothing | Gates in check |
87
- | `outside` | `canon gov superseded <old> <new>` | Where the tree still asserts a value a changed convention no longer produces | Reports |
88
- | `outside` | `canon claude skills drift <ref>` | Shipped skill bodies rewritten since a ref | Reports |
89
- | `outside` | `canon claude routing` | Per `CLAUDE.md` section, how many bullets name a path and whether a rule covers it | Reports |
66
+ | Id | Verb | Measures | Finding |
67
+ | -------------------- | ----------------------------------- | ------------------------------------------------------------------------------------------- | -------------- |
68
+ | `context` | `canon context audit` | Context folder structure, citations, and the requirements record's word cap | Gates |
69
+ | `markdown` | `canon markdown audit` | Bans, dead links, density, and reference shape | Gates |
70
+ | `skills` | `canon claude skills audit` | Both skill corpora against the skill standards, and any ledger under `canon/ledger/skills/` | Gates |
71
+ | `skills-reach` | `canon claude skills reach` | Skill bodies citing a path no target receives | Reports |
72
+ | `skills-rank` | `canon claude skills rank` | Whether skill descriptions separate against a routing case corpus | Reports |
73
+ | `tasks` | `canon tasks validate` | Board rows nothing resolves | Reports |
74
+ | `records-plans` | `canon records validate plans` | Plan records against their shape | Reports |
75
+ | `records-groundwork` | `canon records validate groundwork` | Groundwork folders against their shape | Reports |
76
+ | `records-intake` | `canon records validate intake` | Intake folders against their shape | Reports |
77
+ | `comments` | `canon comments scan` | Comment density by language and kind, and `TODO`, `FIXME`, `HACK`, `XXX` | Reports |
78
+ | `test-order` | `canon gov test-order` | Implementations that reached branch history before their test | Reports |
79
+ | `secrets` | `canon secrets scan` | Credential-shaped values in the shipped tree | Gates |
80
+ | `labels` | `canon labels audit` | Changed paths no row of the label map reaches | Reports |
81
+ | `restated` | `canon gov restated` | Instructions `CLAUDE.md` states that a second surface states too | Reports |
82
+ | `rules` | `canon gov audit` | Rules past a shape limit or out of step with a ledger, and here rules short of one | Reports |
83
+ | `counts` | `canon gov counts` | Sentences stating a closed catalog's size that disagree with the tree | Reports |
84
+ | `census` | `canon census` | File count, extension breakdown, and line total | Reports |
85
+ | `deps` | `canon deps audit` | Advisories against the resolved dependencies | Reports |
86
+ | `outside` | `canon gov citations` | Rule citations and internal frontmatter globs reaching nothing | Gates in check |
87
+ | `outside` | `canon gov superseded <old> <new>` | Where the tree still asserts a value a changed convention no longer produces | Reports |
88
+ | `outside` | `canon claude skills drift <ref>` | Shipped skill bodies rewritten since a ref | Reports |
89
+ | `outside` | `canon claude routing` | Per `CLAUDE.md` section, how many bullets name a path and whether a rule covers it | Reports |
90
90
 
91
91
  A deliberate secret takes a `canon-allow-secret: <reason>` marker. A deliberate citation takes `canon-allow-citation: <reason>` and a deliberate superseded value takes `canon-allow-superseded: <reason>`, each on its own line or the one above. Run any verb with `--help` for its flags and refusal reasons.
92
92
 
@@ -21,7 +21,7 @@ kind: reference
21
21
  | `record-layout` | Review receipts, memory archives, feedback, renders, picks, evidence, and the design board into their current folders |
22
22
  | `session-defaults` | The seeded session defaults into an existing `.claude/settings.json`, listed below the table |
23
23
 
24
- `record-tree` follows citations in `diagrams`, `memory`, `plans`, `proposals`, `review`, `tasks`, and `teach`. `session-defaults` denies `Agent`, `Task`, and both merge commands, and turns auto memory off. The bullets below add detail per verb.
24
+ `record-tree` follows citations in `diagrams`, `memory`, `plans`, `proposals`, `review`, `tasks`, and `teach`. `session-defaults` denies subagents and both merge commands, allows the two lease push forms workers run, turns auto memory off, and wires an installed `plan-guard.sh`. The bullets below add detail per verb.
25
25
 
26
26
  - `skill-names` takes no `--scope`, and skips the changelog, eval transcripts, replay fixtures, and the pointer each renamed `draft-` skill leaves at its old name
27
27
  - `surface-roots` moves `context/`, `wireframes/`, `ARCHITECTURE.md`, `REQUIREMENTS.md`, `DESIGN.md`, and the stamp folder, and leaves `rules/`, `skills/`, `hooks/`, and `settings.json` in place
@@ -56,15 +56,15 @@ Three markers keep an old spelling on a line. Each also covers the nearest non-b
56
56
 
57
57
  ## Refusals
58
58
 
59
- | Verb | Refuses when |
60
- | ------------------ | ------------------------------------------------------------------------------------- |
61
- | `records` | The project does not ignore `.canon/`. Run `canon tooling sync --write` first. |
62
- | `records` | A record folder already exists at the destination |
63
- | `surface-roots` | Any destination already exists under `canon/`, refusing the whole plan |
64
- | `scratch-evidence` | A destination is occupied |
65
- | `record-layout` | A destination already exists, each move refused on its own |
66
- | `rule-layout` | A destination holds different bytes. Neither file is touched and other moves apply. |
67
- | `session-defaults` | The settings file is not valid JSON, or `permissions` or its `deny` has another shape |
59
+ | Verb | Refuses when |
60
+ | ------------------ | ------------------------------------------------------------------------------------------------------------------ |
61
+ | `records` | The project does not ignore `.canon/`. Run `canon tooling sync --write` first. |
62
+ | `records` | A record folder already exists at the destination |
63
+ | `surface-roots` | Any destination already exists under `canon/`, refusing the whole plan |
64
+ | `scratch-evidence` | A destination is occupied |
65
+ | `record-layout` | A destination already exists, each move refused on its own |
66
+ | `rule-layout` | A destination holds different bytes. Neither file is touched and other moves apply. |
67
+ | `session-defaults` | The settings file is not valid JSON, or `permissions`, `deny`, `allow`, `hooks`, or `PreToolUse` has another shape |
68
68
 
69
69
  A `rule-layout` destination holding the same bytes marks a duplicate, which `--write` deletes.
70
70
 
@@ -75,8 +75,8 @@ Replace a citation of a skill with no successor by hand, or delete the sentence.
75
75
 
76
76
  - `canvas` is now `canvas-board`. The sweep rewrites only `canon:canvas` and `skills/canvas`, so replace a bare mention by hand. <!-- canon-keep-retired -->
77
77
  - `draft-ready` is rewritten only as `canon:draft-ready` and `skills/draft-ready`, since bare it also describes a pull request, so replace a bare mention by hand. <!-- canon-keep-retired -->
78
- - `design-extract` and `sketch-design` are replaced by `canon:canvas-board`. <!-- canon-keep-retired -->
79
- - `review-branch`, `seed-sync`, `restate-plainly`, `bash-cli-script`, `canon-operator`, `canon-rollout`, and `canon-feedback-triage` have no successor <!-- canon-keep-retired -->
78
+ - `design-extract` and `sketch-design` are replaced by `canon:canvas-board`, and `canon-feedback-triage` by `canon:issue-triage`. <!-- canon-keep-retired -->
79
+ - `review-branch`, `seed-sync`, `restate-plainly`, `bash-cli-script`, `canon-operator`, and `canon-rollout` have no successor <!-- canon-keep-retired -->
80
80
  - `create-standard`, `markdown-propose`, `memory-review`, `project-commands`, `repo-metadata`, `ux-walkthrough`, and `plan-ready` have no successor <!-- canon-keep-retired -->
81
81
 
82
82
  The `memory` record kind left `canon records`, so `canon records validate memory` refuses as an unknown kind and `canon records migrate` is gone.
@@ -108,11 +108,17 @@ It moves each surface with `git mv` and repoints every tracked citation. No othe
108
108
 
109
109
  The seeded `.claude/settings.json` now denies the `Agent` and `Task` tools and both merge commands, and sets `autoMemoryEnabled` to `false`. `canon claude sync` never rewrites a settings file you hold, so it warns while the file holds no default or lacks a merge entry. Removing `Agent` and `Task` later draws no warning.
110
110
 
111
+ The plan guard takes two steps. `canon claude init` adds the absent `.claude/hooks/plan-guard.sh`, and the verb below then wires it into `hooks.PreToolUse` for `Bash` and `Write|Edit`. It skips a tool whose matcher already runs the guard, and wires nothing while the script is absent.
112
+
111
113
  ```bash
112
114
  canon migrate session-defaults --json
113
115
  canon migrate session-defaults --write --json
114
116
  ```
115
117
 
116
- The verb only adds a key that is absent. A person who wants auto memory back sets it in their own `.claude/settings.local.json`.
118
+ The verb only adds a key or entry that is absent, so a project migrated before an entry runs `--write` once more.
117
119
 
118
- The verb adds `Bash(canon pr merge:*)` and `Bash(gh pr merge:*)` so a session cannot merge. A project migrated before them runs `--write` once more, which also restores `Agent` and `Task` for a person who removed them to allow subagents, so remove them again after.
120
+ - A person who wants auto memory back sets it in their own `.claude/settings.local.json`
121
+ - `Bash(canon pr merge:*)` and `Bash(gh pr merge:*)` stop a session merging
122
+ - A rerun restores `Agent` and `Task` for a person who removed them to allow subagents, so remove them again after
123
+ - `git push --force-with-lease` and its `origin HEAD` form are allowed exactly, so a worker pushes after a rebase without waiting
124
+ - Any other lease push still goes through auto mode's review, and `canon claude sync` warns while either entry is missing
@@ -41,7 +41,7 @@ See [target projects](../target/projects.md).
41
41
 
42
42
  Plan and build in session 1, then review and ship in a fresh session 2 when you want a cold reviewer on the diff.
43
43
 
44
- - For a pile of findings, invoke `canon:plan-intake`, and for a grown backlog `canon:backlog-triage`
44
+ - For a pile of findings, invoke `canon:plan-intake`, for a grown backlog `canon:backlog-triage`, and for open GitHub issues `canon:issue-triage`
45
45
  - For an unmeasured state with live approaches, invoke `canon:plan-groundwork`
46
46
  - Invoke `canon:plan-feature`, implement, and run the `CLAUDE.md` commands until they pass
47
47
  - For UI changes, invoke `canon:ui-checklist`
@@ -22,6 +22,7 @@ Groups run in the order a project meets them. Every name `canon claude skills li
22
22
  | `canon:plan-intake` | The input is a pile of findings |
23
23
  | `canon:plan-intake-answer` | An intake folder holds unread slots |
24
24
  | `canon:backlog-triage` | The backlog needs pruning |
25
+ | `canon:issue-triage` | Open GitHub issues need measuring before planning |
25
26
  | `canon:plan-groundwork` | The state is unmeasured and approaches compete |
26
27
  | `canon:decision-escalate` | Open decisions turn on your preference |
27
28
  | `canon:proposal-critique` | A proposal needs questioning before it is built |
@@ -20,6 +20,10 @@
20
20
  deps = []
21
21
  files = [".claude/skills", "claude/skills"]
22
22
 
23
+ ["rule"]
24
+ deps = []
25
+ files = [".claude/rules/project"]
26
+
23
27
  ["ci-workflow"]
24
28
  deps = []
25
29
  files = [".github/workflows"]
@@ -0,0 +1,16 @@
1
+ ---
2
+ title: Rule rule ledger
3
+ description: Decisions behind governance/rules/claude/rule.md, one row per bullet
4
+ ---
5
+
6
+ # Rule rule ledger
7
+
8
+ The rule is a core rule, so every row is a house choice rather than a citation. Each names what goes wrong without its bullet and the option the bullet beat, with the date it was decided.
9
+
10
+ ## Rule files
11
+
12
+ | Bullet | Source | Read |
13
+ | ------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- |
14
+ | Load create-rule before an edit | House choice: a session hand-editing rules without the skill shipped defects `canon gov audit` passed (issue 2842). It beat relying on the audit alone | 2026-10-29 |
15
+ | Report an unresolved skill | House choice: a project that installed governance without the plugin has no `create-rule`, and a silent skip hides that. It beat omitting the line | 2026-10-29 |
16
+ | Audit each touched rule | House choice: the audit catches the shape and ledger defects the skill's checklist names, so running it before the commit closes the loop. It beat leaving the audit to the pre-push gate alone | 2026-10-29 |
@@ -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.74.0",
4
+ "version": "5.76.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": {
@@ -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 {
@@ -34,10 +34,13 @@ const FONT_PROBE_SIZE = 72
34
34
  const FONT_PROBE_TEXT = 'canon capture 0123456789'
35
35
  const ABSENT_FAMILY = '__canon_absent_family__'
36
36
 
37
+ export type ColorScheme = 'light' | 'dark'
38
+
37
39
  export interface CaptureOptions {
38
40
  selector: string
39
41
  outDir?: string
40
42
  width?: number
43
+ colorScheme?: ColorScheme
41
44
  }
42
45
 
43
46
  export type CaptureResult =
@@ -60,9 +63,7 @@ export async function captureSources(
60
63
  const browser = await chromium.launch()
61
64
  try {
62
65
  return await Promise.all(
63
- sources.map((source) =>
64
- captureOne(browser, source, options.selector, options.width),
65
- ),
66
+ sources.map((source) => captureOne(browser, source, options)),
66
67
  )
67
68
  } finally {
68
69
  await browser.close()
@@ -76,8 +77,7 @@ export async function captureSources(
76
77
  async function captureOne(
77
78
  browser: Browser,
78
79
  source: CaptureSource,
79
- selector: string,
80
- width: number | undefined,
80
+ { selector, width, colorScheme }: CaptureOptions,
81
81
  ): Promise<CaptureResult> {
82
82
  /**
83
83
  * A viewport rather than a wrapper, since a media query answers to the
@@ -90,11 +90,13 @@ async function captureOne(
90
90
  ...(width === undefined
91
91
  ? {}
92
92
  : { viewport: { width, height: DEFAULT_VIEWPORT_HEIGHT } }),
93
+ ...(colorScheme === undefined ? {} : { colorScheme }),
93
94
  })
94
95
  try {
95
96
  await page.goto(
96
97
  source.kind === 'url' ? source.url : pathToFileURL(source.htmlPath).href,
97
98
  )
99
+ if (colorScheme !== undefined) await applyColorScheme(page, colorScheme)
98
100
  const element = page.locator(selector).first()
99
101
  if ((await element.count()) === 0) {
100
102
  return failed(source, `no element matched ${selector}`)
@@ -177,6 +179,29 @@ function sourceIdentifier(source: CaptureSource): string {
177
179
  return source.kind === 'url' ? source.url : source.htmlPath
178
180
  }
179
181
 
182
+ /**
183
+ * Writes the theme the three switches a stylesheet reads, being the media
184
+ * query, a `data-theme` attribute, and a `light` or `dark` class on the root,
185
+ * which is the set Frameboard's theme toggle writes on every frame. The media
186
+ * query alone leaves a stylesheet keyed on the attribute in its default set,
187
+ * which is dark for one `canon design css` emits.
188
+ *
189
+ * Written after the load event, so a page script restoring a stored theme on
190
+ * load has already run and cannot overwrite it.
191
+ */
192
+ async function applyColorScheme(
193
+ page: Page,
194
+ scheme: ColorScheme,
195
+ ): Promise<void> {
196
+ await page.evaluate((theme) => {
197
+ const root = document.documentElement
198
+ root.setAttribute('data-theme', theme)
199
+ root.style.colorScheme = theme
200
+ root.classList.remove('light', 'dark')
201
+ root.classList.add(theme)
202
+ }, scheme)
203
+ }
204
+
180
205
  function failed(source: CaptureSource, reason: string): CaptureResult {
181
206
  return { status: 'failed', source: sourceIdentifier(source), reason }
182
207
  }
@@ -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 {
@@ -22,6 +22,11 @@ export const GIT_CASES: readonly SkillCase[] = [
22
22
  prompt: 'Turn this bug into a GitHub issue and file it.',
23
23
  expect: 'git-issue',
24
24
  },
25
+ {
26
+ prompt:
27
+ 'Log this bug, but add it to the open issue if one already covers it.',
28
+ expect: 'git-issue',
29
+ },
25
30
  {
26
31
  prompt: 'Close #42 without merging, #57 supersedes it.',
27
32
  expect: 'git-pr-close',
@@ -96,6 +96,10 @@ export const WORKFLOW_CASES: readonly SkillCase[] = [
96
96
  'Our backlog only ever grows, measure each row and suggest which to decline.',
97
97
  expect: 'backlog-triage',
98
98
  },
99
+ {
100
+ prompt: 'File the open issues on this repo into an intake folder.',
101
+ expect: 'issue-triage',
102
+ },
99
103
  // Rot across documents nobody changed is what this reads, rather than a
100
104
  // stated rule broken on a given line. It is also not `target-check`, which
101
105
  // asks whether a document exists and holds its sections rather than whether
@@ -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[] {
@@ -125,6 +126,7 @@ export const BULLET_LEDGER_SKILLS: Readonly<Record<string, BulletLedgerScope>> =
125
126
  [join(SHIPPED_CORPUS, 'write-human')]: { exempt: [], body: true },
126
127
  [join(SHIPPED_CORPUS, 'systematic-debugging')]: { exempt: [], body: true },
127
128
  [join(SHIPPED_CORPUS, 'build-in-slices')]: { exempt: [], body: true },
129
+ [join(SHIPPED_CORPUS, 'proposal-critique')]: { exempt: [], body: true },
128
130
  }
129
131
 
130
132
  /** Where a per-bullet ledger lives, as `<skill>/<file>.md` below it. */
@@ -139,6 +141,15 @@ export function skillLedgerPath(folder: string, file: string): string {
139
141
  return join(SKILL_LEDGERS, folder, inside.startsWith('..') ? file : inside)
140
142
  }
141
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
+
142
153
  /** The closing H2s a practice skill carries, matched exactly outside fences. */
143
154
  export const PRACTICE_SECTIONS: readonly string[] = [
144
155
  'Excuses and rebuttals',
@@ -312,6 +323,9 @@ export async function auditSkills(root: string): Promise<SkillsAudit> {
312
323
  present.map((corpus) => readCorpus(root, corpus.rel, corpus.dir)),
313
324
  )
314
325
  const sources = perCorpus.flat()
326
+ const projectLedgers = existsSync(join(root, TOOLKIT_MARKER))
327
+ ? []
328
+ : await readProjectLedgers(root, sources)
315
329
 
316
330
  return {
317
331
  corpora: present.map((corpus, index) => ({
@@ -344,6 +358,7 @@ export async function auditSkills(root: string): Promise<SkillsAudit> {
344
358
  sources,
345
359
  ),
346
360
  ...sources.flatMap(practiceFindings),
361
+ ...projectLedgers,
347
362
  ],
348
363
  referenceContents: sources.flatMap((source) =>
349
364
  source.bodies.flatMap(contentsFindings),
@@ -483,6 +498,56 @@ async function readReferenceLedgers(
483
498
  )
484
499
  }
485
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
+
486
551
  /**
487
552
  * Directives per H2, `null` for any above the first. A contents list repeats
488
553
  * the headings rather than stating a rule, so `## Contents` takes no rows. A
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',
@@ -31,6 +31,9 @@ const DEFAULT_SOURCE = 'assets'
31
31
  */
32
32
  type Renderer = typeof import('@/capture/render')
33
33
  type CaptureResult = Awaited<ReturnType<Renderer['captureSources']>>[number]
34
+ type ColorScheme = import('@/capture/render').ColorScheme
35
+
36
+ const COLOR_SCHEMES: readonly ColorScheme[] = ['light', 'dark']
34
37
 
35
38
  /**
36
39
  * Holds wiring only. Every browser reference sits behind a dynamic import,
@@ -58,6 +61,10 @@ export function register(program: Command): void {
58
61
  '--width <px>',
59
62
  'Viewport width in pixels, so a media query resolves at that width',
60
63
  )
64
+ .option(
65
+ '--color-scheme <scheme>',
66
+ 'Theme to render in, light or dark; defaults to whichever theme the page renders unset',
67
+ )
61
68
  .addHelpText(
62
69
  'after',
63
70
  [
@@ -70,12 +77,21 @@ export function register(program: Command): void {
70
77
  'font-family. --width takes one whole number and sets the viewport, so',
71
78
  'run it once per breakpoint with its own --out.',
72
79
  '',
80
+ '--color-scheme sets prefers-color-scheme, a data-theme attribute, and',
81
+ 'a light or dark class on the root, so a stylesheet keyed on any of the',
82
+ 'three renders that theme. Run it once per scheme with its own --out.',
83
+ '',
73
84
  ].join('\n'),
74
85
  )
75
86
  .action(
76
87
  async (
77
88
  source: string,
78
- opts: { out?: string; selector?: string; width?: string },
89
+ opts: {
90
+ out?: string
91
+ selector?: string
92
+ width?: string
93
+ colorScheme?: string
94
+ },
79
95
  ): Promise<void> => {
80
96
  /**
81
97
  * Refused rather than defaulted, and refused ahead of every other
@@ -102,6 +118,15 @@ export function register(program: Command): void {
102
118
  return
103
119
  }
104
120
 
121
+ const colorScheme = parseColorScheme(opts.colorScheme)
122
+ if (colorScheme === null) {
123
+ frameError(
124
+ `--color-scheme takes ${COLOR_SCHEMES.join(' or ')}, got ${JSON.stringify(opts.colorScheme)}.`,
125
+ )
126
+ process.exitCode = 1
127
+ return
128
+ }
129
+
105
130
  const isUrl = isUrlSource(source)
106
131
  const sourcePath = isUrl ? source : resolve(process.cwd(), source)
107
132
  if (!isUrl && !existsSync(sourcePath)) {
@@ -117,6 +142,7 @@ export function register(program: Command): void {
117
142
  results = await renderer.captureSources(sourcePath, {
118
143
  selector,
119
144
  width,
145
+ colorScheme,
120
146
  outDir: opts.out ? resolve(process.cwd(), opts.out) : undefined,
121
147
  })
122
148
  } catch (error) {
@@ -160,6 +186,17 @@ function parseWidth(raw: string | undefined): number | undefined {
160
186
  return Number.isInteger(value) && value >= 1 ? value : Number.NaN
161
187
  }
162
188
 
189
+ /**
190
+ * Undefined for an absent flag and `null` for a value naming no scheme, so the
191
+ * caller can tell no scheme from a bad one.
192
+ */
193
+ function parseColorScheme(
194
+ raw: string | undefined,
195
+ ): ColorScheme | undefined | null {
196
+ if (raw === undefined) return undefined
197
+ return COLOR_SCHEMES.find((scheme) => scheme === raw) ?? null
198
+ }
199
+
163
200
  /**
164
201
  * Closes an open frame around a failure that stopped the whole run rather than
165
202
  * one source. Two of them are setup states rather than defects and each names