@erclx/canon 4.6.1 → 4.8.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 (145) hide show
  1. package/claude/.claude-plugin/plugin.json +1 -1
  2. package/claude/skills/canon-feedback-file/SKILL.md +3 -3
  3. package/claude/skills/canon-feedback-triage/SKILL.md +1 -1
  4. package/claude/skills/canon-rollout/SKILL.md +1 -1
  5. package/claude/skills/canon-screencast/SKILL.md +5 -5
  6. package/claude/skills/canon-slides-draft/SKILL.md +1 -1
  7. package/claude/skills/claude-address-review/SKILL.md +4 -4
  8. package/claude/skills/claude-autoship/SKILL.md +17 -17
  9. package/claude/skills/claude-design-extract/SKILL.md +2 -2
  10. package/claude/skills/claude-diagram/SKILL.md +13 -13
  11. package/claude/skills/claude-docs/SKILL.md +23 -23
  12. package/claude/skills/claude-feature/SKILL.md +6 -6
  13. package/claude/skills/claude-groundwork/SKILL.md +16 -16
  14. package/claude/skills/claude-intake/SKILL.md +8 -8
  15. package/claude/skills/claude-intake-answer/SKILL.md +1 -1
  16. package/claude/skills/claude-markdown-propose/REQUIREMENT.md +2 -2
  17. package/claude/skills/claude-markdown-propose/SKILL.md +8 -8
  18. package/claude/skills/claude-markdown-propose/references/format.md +3 -3
  19. package/claude/skills/claude-memory-capture/SKILL.md +13 -13
  20. package/claude/skills/claude-memory-review/SKILL.md +28 -28
  21. package/claude/skills/claude-memory-review/references/receipt-format.md +1 -1
  22. package/claude/skills/claude-orchestrate/SKILL.md +9 -9
  23. package/claude/skills/claude-orchestrate/references/orchestrator-dispatch.md +1 -1
  24. package/claude/skills/claude-orchestrate/references/orchestrator-parked.md +1 -1
  25. package/claude/skills/claude-orchestrate/references/orchestrator-poll.md +1 -1
  26. package/claude/skills/claude-orchestrate/references/orchestrator-resume.md +1 -1
  27. package/claude/skills/claude-orchestrate/scripts/poll.sh +1 -1
  28. package/claude/skills/claude-planner/REQUIREMENT.md +1 -1
  29. package/claude/skills/claude-planner/SKILL.md +6 -6
  30. package/claude/skills/claude-pr-review/SKILL.md +5 -5
  31. package/claude/skills/claude-review/SKILL.md +4 -4
  32. package/claude/skills/claude-seed-sync/SKILL.md +2 -2
  33. package/claude/skills/claude-tasks/SKILL.md +16 -16
  34. package/claude/skills/claude-teach/SKILL.md +9 -9
  35. package/claude/skills/claude-ui-test/SKILL.md +3 -3
  36. package/claude/skills/claude-ux-audit/SKILL.md +5 -5
  37. package/claude/skills/claude-ux-measure/SKILL.md +3 -3
  38. package/claude/skills/claude-worker/SKILL.md +2 -2
  39. package/claude/skills/claude-worktree/SKILL.md +3 -3
  40. package/claude/skills/git-issue/SKILL.md +4 -4
  41. package/claude/skills/git-pr/SKILL.md +6 -6
  42. package/claude/skills/git-ship/SKILL.md +3 -3
  43. package/claude/skills/git-split/REQUIREMENT.md +1 -1
  44. package/claude/skills/git-split/SKILL.md +20 -20
  45. package/claude/skills/migration-superseded/SKILL.md +4 -4
  46. package/claude/skills/session-map/SKILL.md +2 -2
  47. package/claude/skills/session-resume/SKILL.md +8 -8
  48. package/docs/agents/capture.md +17 -11
  49. package/docs/agents/commands.md +8 -5
  50. package/docs/agents/demo.md +2 -2
  51. package/docs/agents/driver.md +2 -2
  52. package/docs/agents/index.md +1 -1
  53. package/docs/agents/indexes.md +1 -1
  54. package/docs/agents/install-and-sync.md +1 -1
  55. package/docs/agents/intake.md +1 -1
  56. package/docs/agents/key-changes.md +1 -1
  57. package/docs/agents/records.md +12 -9
  58. package/docs/agents/restated.md +2 -2
  59. package/docs/agents/rule-citations.md +1 -1
  60. package/docs/agents/sandbox.md +1 -1
  61. package/docs/agents/state-scoped-risk.md +1 -1
  62. package/docs/agents/tasks.md +6 -6
  63. package/docs/agents/teach.md +4 -4
  64. package/docs/ai-workflow.md +26 -19
  65. package/docs/operating-model.md +3 -3
  66. package/docs/target-projects.md +22 -2
  67. package/docs/visual-design-workflow.md +1 -1
  68. package/governance/rules/claude/555-tasks.md +3 -3
  69. package/governance/rules/claude/556-groundwork.md +2 -2
  70. package/governance/rules/claude/557-intake.md +2 -2
  71. package/governance/rules/claude/558-plan.md +3 -3
  72. package/governance/rules/claude/559-memory.md +2 -2
  73. package/governance/rules/claude/560-diagrams.md +3 -3
  74. package/governance/rules/claude/561-teach.md +2 -2
  75. package/governance/rules/claude/562-session.md +2 -2
  76. package/governance/rules/core/035-tasks.md +2 -2
  77. package/governance/rules/core/045-memory.md +3 -3
  78. package/governance/rules/core/055-scratch.md +2 -1
  79. package/governance/rules/core/085-worktrees.md +1 -1
  80. package/package.json +1 -2
  81. package/scripts/core/check-ignore-parity.sh +24 -51
  82. package/scripts/core/install-check.sh +4 -4
  83. package/scripts/core/regen-hero.sh +6 -3
  84. package/scripts/core/snapshot.sh +1 -1
  85. package/scripts/manage-sandbox.sh +1 -1
  86. package/scripts/tooling/verify.sh +2 -2
  87. package/snippets/claude/decision-memo.md +1 -1
  88. package/snippets/claude/feature-recap.md +1 -1
  89. package/src/audits/catalog.ts +1 -1
  90. package/src/capture/render.ts +166 -0
  91. package/src/capture/sources.ts +42 -0
  92. package/src/capture/stamp.ts +52 -0
  93. package/src/claude/cases/all.ts +1 -1
  94. package/src/claude/seeds.ts +13 -2
  95. package/src/claude/skills-rank.ts +1 -1
  96. package/src/cli.ts +5 -5
  97. package/src/commands/capture.ts +52 -41
  98. package/src/commands/demo.ts +1 -1
  99. package/src/commands/design.ts +1 -1
  100. package/src/commands/feedback.ts +5 -5
  101. package/src/commands/gov.ts +17 -6
  102. package/src/commands/intake.ts +1 -1
  103. package/src/commands/migrate.ts +173 -2
  104. package/src/commands/pr.ts +1 -1
  105. package/src/commands/records.ts +1 -1
  106. package/src/commands/serve.ts +2 -2
  107. package/src/commands/slides.ts +1 -1
  108. package/src/commands/tasks.ts +5 -5
  109. package/src/commands/teach.ts +1 -1
  110. package/src/context/audit.ts +1 -1
  111. package/src/demo/compile.ts +1 -1
  112. package/src/demo/drive.ts +5 -8
  113. package/src/driver/drive.ts +7 -8
  114. package/src/gate/measures.ts +1 -1
  115. package/src/gate/stages.ts +1 -1
  116. package/src/gov/citations.ts +1 -1
  117. package/src/gov/restated.ts +3 -3
  118. package/src/inventory/walk.ts +3 -3
  119. package/src/migrate/apply.ts +67 -1
  120. package/src/migrate/records.ts +276 -0
  121. package/src/paths.ts +1 -1
  122. package/src/pr/paths.ts +1 -1
  123. package/src/record-root.ts +77 -27
  124. package/src/records/backup.ts +71 -17
  125. package/src/records/validate.ts +1 -1
  126. package/src/sync/layout.ts +11 -2
  127. package/src/tasks/answers.ts +1 -1
  128. package/src/tasks/archive.ts +20 -3
  129. package/standards/diagrams.md +4 -4
  130. package/standards/glossary.md +1 -1
  131. package/standards/groundwork.md +3 -3
  132. package/standards/index.md +2 -2
  133. package/standards/intake.md +3 -3
  134. package/standards/memory.md +2 -2
  135. package/standards/plan.md +3 -3
  136. package/standards/session.md +2 -2
  137. package/standards/standard.md +1 -1
  138. package/standards/tasks.md +9 -9
  139. package/standards/teach.md +2 -2
  140. package/standards/versioning.md +1 -1
  141. package/tooling/base/configs/.husky/post-merge +1 -1
  142. package/tooling/base/reference.md +1 -1
  143. package/tooling/claude/manifest.toml +13 -3
  144. package/tooling/claude/reference.md +28 -15
  145. package/tooling/claude/seeds/.claude/hooks/scratch-guard.sh +8 -8
@@ -9,22 +9,22 @@ GITIGNORE="$PROJECT_ROOT/.gitignore"
9
9
  MANIFEST="$PROJECT_ROOT/tooling/claude/manifest.toml"
10
10
  SECTION="# Claude"
11
11
 
12
- # Divergences this repository has decided to keep, one `<entry>|<reason>` per
13
- # line, matched after the trailing slash is stripped. Each names a path the
14
- # enclosing repository ignores and the claude manifest deliberately does not
15
- # ship, so a target keeps tracking it.
12
+ # Parity is exact, with no exception list behind it, and the move to `.canon/`
13
+ # is what removed the one there was. Its two members each named a path inside
14
+ # `.claude/` that this repository ignored and the manifest withheld, so a target
15
+ # kept tracking it. A single root entry can withhold nothing inside itself: git
16
+ # does not descend into an excluded directory, so a re-inclusion like
17
+ # `!.canon/diagrams/` matches nothing. Retiring both is what buys the one line.
16
18
  #
17
- # This is the canonical list. `.gitignore` and the manifest carry a pointer
18
- # beside their own entries rather than a second copy of the reason, and
19
- # `.claude/context/tooling.md` carries the narrative behind both.
19
+ # The diagrams divergence is the one that cost something. A target used to track
20
+ # its diagrams and keep the context audit's default coverage of them, and it now
21
+ # ignores them with the rest of its records, so that coverage is gone unless the
22
+ # project names the folder itself. `.claude/context/tooling.md` carries the
23
+ # narrative.
20
24
  #
21
- # A sanction that no longer describes a divergence fails the same as an
22
- # unsanctioned one. An exception outliving its reason is what teaches the next
23
- # reader to widen the list rather than to read it.
24
- SANCTIONED=(
25
- ".claude/diagrams|a target that tracks its diagrams keeps the context audit's default coverage of them, which ignoring the folder for every target would take away. This repository backs the folder through its records remote instead."
26
- ".claude/README.md|the landing page a records pull writes back into a repository that set up a records remote. A target has not, so it never receives the file."
27
- )
25
+ # An exception mechanism is not kept against a future divergence, because an
26
+ # empty one cannot be exercised and a check nobody can test is a check nobody
27
+ # should trust. It comes back with its first real member or not at all.
28
28
 
29
29
  if [ ! -f "$GITIGNORE" ]; then
30
30
  echo "No .gitignore at ${GITIGNORE#"$PROJECT_ROOT/"}, ignore parity unverifiable." >&2
@@ -38,15 +38,15 @@ fi
38
38
 
39
39
  # A gitignore pattern and a manifest entry describe the same folder whether or
40
40
  # not either spells the trailing slash, so presence is compared with it dropped.
41
- # `.claude/.tmp` and `.claude/.tmp/` are the live instance of that pair.
41
+ # No live pair differs that way now that both lists carry the same two entries,
42
+ # and the normalization stays because either side may be written without one.
42
43
  normalize() {
43
44
  printf '%s\n' "${1%/}"
44
45
  }
45
46
 
46
47
  # Every pattern the file carries, comments and blank lines dropped. The whole
47
- # file rather than one header, because `.claude/teach/` sits under a header of
48
- # its own and reading the `# Claude` header alone would report it missing from a
49
- # list that carries it.
48
+ # file rather than one header, since a claude-scoped entry filed under a header
49
+ # of its own would otherwise read as missing from a list that carries it.
50
50
  gitignore_patterns() {
51
51
  awk '{ sub(/[[:space:]]+$/, "") } $0 ~ /^[[:space:]]*#/ || $0 == "" { next } { print }' "$GITIGNORE"
52
52
  }
@@ -106,7 +106,6 @@ if [ ${#shipped[@]} -eq 0 ]; then
106
106
  fi
107
107
 
108
108
  failures=""
109
- sanction_notes=""
110
109
 
111
110
  # Entries the manifest ships that this repository does not ignore. A target is
112
111
  # told to ignore a folder the toolkit itself tracks, which no decision sanctions,
@@ -121,48 +120,22 @@ done
121
120
  # and says nothing about `node_modules/` or `.env`.
122
121
  #
123
122
  # `.canon` is read as a bare root as well as a prefix, since it is one line
124
- # covering a whole tree where `.claude/` is thirteen lines naming folders inside
125
- # a root that also holds tracked content. Leaving it out is what would let a new
126
- # entry sit outside the only stage comparing the two lists, which is the
127
- # direction that goes silently blind.
123
+ # covering a whole tree where `.claude/` names a folder inside a root that also
124
+ # holds tracked content. Leaving it out is what would let a new entry sit outside
125
+ # the only stage comparing the two lists, which is the direction that goes
126
+ # silently blind.
128
127
  for pattern in "${ignored[@]}"; do
129
128
  case "$pattern" in
130
129
  .claude/* | .canon | .canon/*) ;;
131
130
  *) continue ;;
132
131
  esac
133
132
  contains "$pattern" "${shipped[@]}" && continue
134
-
135
- matched=false
136
- for sanction in "${SANCTIONED[@]}"; do
137
- [ "${sanction%%|*}" = "$pattern" ] || continue
138
- matched=true
139
- sanction_notes="$sanction_notes $pattern stays out of the manifest: ${sanction#*|}"$'\n'
140
- break
141
- done
142
-
143
- if [ "$matched" = false ]; then
144
- failures="$failures $pattern is ignored here and absent from the manifest"$'\n'
145
- fi
146
- done
147
-
148
- # A sanction naming a path that is no longer divergent, either because the
149
- # manifest took it or because .gitignore dropped it.
150
- for sanction in "${SANCTIONED[@]}"; do
151
- entry="${sanction%%|*}"
152
- if contains "$entry" "${ignored[@]}" && ! contains "$entry" "${shipped[@]}"; then
153
- continue
154
- fi
155
- failures="$failures $entry is sanctioned as a divergence and is no longer one"$'\n'
133
+ failures="$failures $pattern is ignored here and absent from the manifest"$'\n'
156
134
  done
157
135
 
158
136
  if [ -n "$failures" ]; then
159
137
  echo "The ignore set a target receives disagrees with this repository's own:" >&2
160
138
  printf '%s' "$failures" >&2
161
- echo "Add the entry to the \"$SECTION\" array in ${MANIFEST#"$PROJECT_ROOT/"} and to .gitignore, or record it in SANCTIONED in scripts/core/check-ignore-parity.sh with the reason it stays apart." >&2
139
+ echo "Add the entry to the \"$SECTION\" array in ${MANIFEST#"$PROJECT_ROOT/"} and to .gitignore. The two lists are compared exactly, and nothing here records an exception." >&2
162
140
  exit 1
163
141
  fi
164
-
165
- if [ -n "$sanction_notes" ]; then
166
- echo "Sanctioned divergences from the ignore set a target receives:"
167
- printf '%s' "$sanction_notes"
168
- fi
@@ -7,7 +7,7 @@ PROJECT_ROOT="${PROJECT_ROOT:-$(cd "$SCRIPT_DIR/../.." && pwd)}"
7
7
 
8
8
  source "$PROJECT_ROOT/scripts/lib/ui.sh"
9
9
 
10
- TMP_ROOT="$PROJECT_ROOT/.claude/.tmp/install-check"
10
+ TMP_ROOT="$PROJECT_ROOT/.canon/tmp/install-check"
11
11
  CLONE_DIR="$TMP_ROOT/clone"
12
12
  TARGET_DIR="$TMP_ROOT/target"
13
13
  KEEP=0
@@ -20,10 +20,10 @@ for arg in "$@"; do
20
20
  Usage: scripts/core/install-check.sh [--keep]
21
21
 
22
22
  Verifies the README install path end to end:
23
- 1. Clones this repo into .claude/.tmp/install-check/clone
23
+ 1. Clones this repo into .canon/tmp/install-check/clone
24
24
  2. Runs bun install in the clone
25
25
  3. Runs the CLI with --help to confirm it executes
26
- 4. Scaffolds a fresh project in .claude/.tmp/install-check/target
26
+ 4. Scaffolds a fresh project in .canon/tmp/install-check/target
27
27
  5. Runs canon init and asserts a scaffold landed
28
28
 
29
29
  Flags:
@@ -83,7 +83,7 @@ log_step "Assert scaffold"
83
83
  # `@`-reference convention rule is the one snippets-domain file that still
84
84
  # installs, since `base` carries `governance/rules/snippets/` as a
85
85
  # folder-whole entry the same way it does `core` and `claude`.
86
- for path in "CLAUDE.md" ".claude/wiki/index.md" ".claude" ".claude/context/index.md" ".claude/wireframes/index.md" ".claude/diagrams/index.md" \
86
+ for path in "CLAUDE.md" ".claude/wiki/index.md" ".claude" ".claude/context/index.md" ".claude/wireframes/index.md" ".canon/diagrams/index.md" \
87
87
  ".prettierrc" ".editorconfig" ".lintstagedrc" ".husky/pre-commit" ".github/workflows/verify.yml" "scripts/verify.sh" \
88
88
  ".claude/rules/core/000-constitution.md" ".claude/rules/snippets/505-at-references.md"; do
89
89
  if [ ! -e "$TARGET_DIR/$path" ]; then
@@ -4,15 +4,18 @@
4
4
  # Only the HTML regenerates here. The PNG beside it is a chromium render whose
5
5
  # bytes move with the browser version, so asserting it in verify.sh would fail
6
6
  # on a machine whose chromium differs rather than on a stale count. Rebuild the
7
- # image with `canon capture assets/hero.html` after this script reports a change.
7
+ # image with `canon capture assets/hero.html --selector .window` after this
8
+ # script reports a change. The selector has no default, since the element a
9
+ # capture crops to belongs to the page rather than to the command, and `.window`
10
+ # is the class this repository's own two sources declare.
8
11
  # That capture also writes assets/hero.stamp, which records the digest of the
9
12
  # markup it rendered and is what the Hero stage compares, so all three files
10
13
  # commit together. The frame carries no version. `package.json` is bumped on main by the release
11
14
  # tooling, so embedding it drifts every open branch on the next release and the
12
15
  # stage then fails for work that touched nothing.
13
16
  #
14
- # Clone-only. `src/capture` is excluded from the published tarball, and this
15
- # script reads the repository's own catalogs, so a registry install has neither.
17
+ # Clone-only. `canon capture` ships now, but this script reads the repository's
18
+ # own catalogs, which a registry install does not carry.
16
19
  set -e
17
20
  set -o pipefail
18
21
 
@@ -10,7 +10,7 @@ source "$PROJECT_ROOT/scripts/lib/ui.sh"
10
10
 
11
11
  NESTED="${VERIFY_NESTED:-false}"
12
12
 
13
- OUTPUT_FILE=".claude/.tmp/project/PROJECT-SNAPSHOT.md"
13
+ OUTPUT_FILE=".canon/tmp/project/PROJECT-SNAPSHOT.md"
14
14
 
15
15
  check_dependencies() {
16
16
  command -v find >/dev/null 2>&1 || log_error "find not installed"
@@ -163,7 +163,7 @@ init_empty_sandbox() {
163
163
  mkdir -p "$SANDBOX"
164
164
 
165
165
  cat <<EOF >"$SANDBOX/.gitignore"
166
- .claude/.tmp/
166
+ .canon/tmp/
167
167
  node_modules
168
168
  EOF
169
169
  (
@@ -16,7 +16,7 @@ show_help() {
16
16
  echo -e "${GREY}│${NC} --keep ${GREY}# Keep tmp dir after run for inspection${NC}"
17
17
  echo -e "${GREY}│${NC} -h, --help ${GREY}# Show this help message${NC}"
18
18
  echo -e "${GREY}│${NC}"
19
- echo -e "${GREY}│${NC} Scaffolds <stack> into .claude/.tmp/verify-<stack>/, syncs the full"
19
+ echo -e "${GREY}│${NC} Scaffolds <stack> into .canon/tmp/verify-<stack>/, syncs the full"
20
20
  echo -e "${GREY}│${NC} layer chain, and runs check + test:e2e + screenshot."
21
21
  echo -e "${GREY}└${NC}"
22
22
  exit 0
@@ -100,7 +100,7 @@ main() {
100
100
  open_timeline "canon tooling verify $stack"
101
101
  trap close_timeline EXIT
102
102
 
103
- local tmp_root="$PROJECT_ROOT/.claude/.tmp"
103
+ local tmp_root="$PROJECT_ROOT/.canon/tmp"
104
104
  local tmp_dir="$tmp_root/verify-$stack"
105
105
 
106
106
  mkdir -p "$tmp_root"
@@ -1,4 +1,4 @@
1
- Produce a decision memo for a research or should-we question. This is the decision-heavy counterpart to `claude-feature`, which plans a build. Write one file per independent concern under `.claude/plans/feature-<slug>.md`, or answer inline when the plan is small. Use the same plans folder as `claude-feature`.
1
+ Produce a decision memo for a research or should-we question. This is the decision-heavy counterpart to `claude-feature`, which plans a build. Write one file per independent concern under `.canon/plans/feature-<slug>.md`, or answer inline when the plan is small. Use the same plans folder as `claude-feature`.
2
2
 
3
3
  Read the current state from the code and docs before recommending. Do not assume it.
4
4
 
@@ -1,4 +1,4 @@
1
- Recap the implementation this session completed. Read the plan file referenced in the session (usually `.claude/plans/feature-<slug>.md`) and produce a verification block.
1
+ Recap the implementation this session completed. Read the plan file referenced in the session (usually `.canon/plans/feature-<slug>.md`) and produce a verification block.
2
2
 
3
3
  Output this shape, nothing else:
4
4
 
@@ -488,7 +488,7 @@ export const AUDITS: readonly AuditSpec[] = [
488
488
  corpus: 'tracked',
489
489
  // The one reason this verb refuses for that is an absence rather than a
490
490
  // break, on the same test the reach check and the skill audit take: no
491
- // target adopts `.claude/context/`, `.claude/diagrams/`, and
491
+ // target adopts `.claude/context/`, `.canon/diagrams/`, and
492
492
  // `.claude/wireframes/` all at once, so without the allowance every such
493
493
  // project reports the verb unmeasured on every run and never changes.
494
494
  absentReasons: ['no-folders'] satisfies ContextAuditRefusal[],
@@ -0,0 +1,166 @@
1
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
2
+ import { basename, dirname } from 'node:path'
3
+ import { pathToFileURL } from 'node:url'
4
+ import { chromium } from 'playwright-core'
5
+ import type { Browser, Page } from 'playwright-core'
6
+ import type { CaptureSource } from '@/capture/sources'
7
+ import { primaryFontFamily, resolveCaptureSources } from '@/capture/sources'
8
+ import { formatStamp, hashSource, stampPath } from '@/capture/stamp'
9
+
10
+ /**
11
+ * Every browser reference the capture command makes lives in this module, and
12
+ * `src/commands/capture.ts` reaches it through a dynamic import so `src/cli.ts`
13
+ * never resolves the engine at startup.
14
+ *
15
+ * It ships, like `@/demo/drive`, `@/inventory/walk`, and `@/driver/drive`.
16
+ * Regenerating this repository's own committed images was the reason it stayed
17
+ * behind, and it was a reason about one caller rather than about the mechanism:
18
+ * a target renders its own generated pages and proves its own fonts resolved,
19
+ * which is the whole of what this module does.
20
+ *
21
+ * It imports `playwright-core` rather than `@playwright/test` for the same
22
+ * reason the three siblings do. The test runner is a development dependency the
23
+ * published tarball never carries, so the earlier import resolved here and
24
+ * threw `ERR_MODULE_NOT_FOUND` in every target install.
25
+ */
26
+
27
+ const DEVICE_SCALE_FACTOR = 2
28
+ const FONT_PROBE_SIZE = 72
29
+ const FONT_PROBE_TEXT = 'canon capture 0123456789'
30
+ const ABSENT_FAMILY = '__canon_absent_family__'
31
+
32
+ export interface CaptureOptions {
33
+ selector: string
34
+ outDir?: string
35
+ }
36
+
37
+ export type CaptureResult =
38
+ | {
39
+ status: 'rendered'
40
+ htmlPath: string
41
+ pngPath: string
42
+ width: number
43
+ height: number
44
+ }
45
+ | { status: 'failed'; htmlPath: string; reason: string }
46
+
47
+ export async function captureSources(
48
+ sourcePath: string,
49
+ options: CaptureOptions,
50
+ ): Promise<CaptureResult[]> {
51
+ const sources = resolveCaptureSources(sourcePath, options.outDir)
52
+ if (!sources.length) return []
53
+
54
+ const browser = await chromium.launch()
55
+ try {
56
+ return await Promise.all(
57
+ sources.map((source) => captureOne(browser, source, options.selector)),
58
+ )
59
+ } finally {
60
+ await browser.close()
61
+ }
62
+ }
63
+
64
+ /**
65
+ * Resolves to a failed result rather than throwing, so one source that cannot
66
+ * render does not drop the rest of the batch.
67
+ */
68
+ async function captureOne(
69
+ browser: Browser,
70
+ source: CaptureSource,
71
+ selector: string,
72
+ ): Promise<CaptureResult> {
73
+ const page = await browser.newPage({
74
+ deviceScaleFactor: DEVICE_SCALE_FACTOR,
75
+ })
76
+ try {
77
+ await page.goto(pathToFileURL(source.htmlPath).href)
78
+ const element = page.locator(selector).first()
79
+ if ((await element.count()) === 0) {
80
+ return failed(source, `no element matched ${selector}`)
81
+ }
82
+
83
+ await page.evaluate(() => document.fonts.ready.then(() => undefined))
84
+ const family = primaryFontFamily(
85
+ await element.evaluate((node) => getComputedStyle(node).fontFamily),
86
+ )
87
+ if (family && !(await resolvesFont(page, family))) {
88
+ return failed(
89
+ source,
90
+ `${family} is not installed, so the capture would rewrap against a fallback`,
91
+ )
92
+ }
93
+
94
+ mkdirSync(dirname(source.pngPath), { recursive: true })
95
+ const png = await element.screenshot({ omitBackground: true })
96
+ writeFileSync(source.pngPath, png)
97
+ writeStamp(source, png)
98
+ return {
99
+ status: 'rendered',
100
+ htmlPath: source.htmlPath,
101
+ pngPath: source.pngPath,
102
+ width: png.readUInt32BE(16),
103
+ height: png.readUInt32BE(20),
104
+ }
105
+ } catch (error) {
106
+ return failed(
107
+ source,
108
+ error instanceof Error ? error.message : String(error),
109
+ )
110
+ } finally {
111
+ await page.close()
112
+ }
113
+ }
114
+
115
+ /**
116
+ * Runs inside the render rather than in a wrapper around it, so a capture
117
+ * cannot succeed and leave the provenance unrecorded. A throw here reaches the
118
+ * caller's catch and reports the source as failed, which is correct: a PNG
119
+ * whose stamp never landed is the state the verify stage exists to reject.
120
+ *
121
+ * The source is stored as a bare filename. An absolute path would record the
122
+ * machine that ran the capture into a tracked file and differ per checkout.
123
+ *
124
+ * The image digest is taken over the buffer the screenshot returned rather than
125
+ * by reading the file back, so the stamp describes the bytes this run wrote.
126
+ */
127
+ function writeStamp(source: CaptureSource, png: Uint8Array): void {
128
+ writeFileSync(
129
+ stampPath(source.pngPath),
130
+ formatStamp({
131
+ source: basename(source.htmlPath),
132
+ sourceSha256: hashSource(readFileSync(source.htmlPath)),
133
+ imageSha256: hashSource(png),
134
+ }),
135
+ )
136
+ }
137
+
138
+ function failed(source: CaptureSource, reason: string): CaptureResult {
139
+ return { status: 'failed', htmlPath: source.htmlPath, reason }
140
+ }
141
+
142
+ /**
143
+ * Compares the family's text metrics against a family that cannot exist. Equal
144
+ * widths mean the browser fell through to its default for both, which is the
145
+ * only signal available: `document.fonts.check` reports every system family as
146
+ * present, including invented ones.
147
+ */
148
+ async function resolvesFont(page: Page, family: string): Promise<boolean> {
149
+ return page.evaluate(
150
+ ({ family, absent, size, text }) => {
151
+ const context = document.createElement('canvas').getContext('2d')
152
+ if (!context) return false
153
+ const widthOf = (name: string): number => {
154
+ context.font = `${size}px "${name}"`
155
+ return context.measureText(text).width
156
+ }
157
+ return widthOf(family) !== widthOf(absent)
158
+ },
159
+ {
160
+ family,
161
+ absent: ABSENT_FAMILY,
162
+ size: FONT_PROBE_SIZE,
163
+ text: FONT_PROBE_TEXT,
164
+ },
165
+ )
166
+ }
@@ -0,0 +1,42 @@
1
+ import { readdirSync, statSync } from 'node:fs'
2
+ import { basename, dirname, extname, join } from 'node:path'
3
+
4
+ export interface CaptureSource {
5
+ htmlPath: string
6
+ pngPath: string
7
+ }
8
+
9
+ /**
10
+ * Pairs each HTML source with the PNG it renders to. A directory expands to
11
+ * every `.html` directly inside it, so adding a capture is a file drop rather
12
+ * than a flag. Omitting `outDir` writes the PNG beside its source.
13
+ */
14
+ export function resolveCaptureSources(
15
+ sourcePath: string,
16
+ outDir?: string,
17
+ ): CaptureSource[] {
18
+ const htmlPaths = statSync(sourcePath).isDirectory()
19
+ ? readdirSync(sourcePath)
20
+ .filter((name) => extname(name) === '.html')
21
+ .sort()
22
+ .map((name) => join(sourcePath, name))
23
+ : [sourcePath]
24
+
25
+ return htmlPaths.map((htmlPath) => ({
26
+ htmlPath,
27
+ pngPath: join(
28
+ outDir ?? dirname(htmlPath),
29
+ `${basename(htmlPath, '.html')}.png`,
30
+ ),
31
+ }))
32
+ }
33
+
34
+ /**
35
+ * Reads the first family from a computed `font-family` value, which is the font
36
+ * the source asks for and the one a capture has to prove resolved. Quoting is
37
+ * the source's choice, so both spellings arrive here.
38
+ */
39
+ export function primaryFontFamily(declaration: string): string {
40
+ const first = declaration.split(',')[0]?.trim() ?? ''
41
+ return first.replace(/^['"]|['"]$/g, '')
42
+ }
@@ -0,0 +1,52 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { basename, dirname, extname, join } from 'node:path'
3
+
4
+ const HASH_ALGORITHM = 'sha256'
5
+ const STAMP_EXTENSION = '.stamp'
6
+
7
+ /**
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.
13
+ *
14
+ * 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
16
+ * replaced under markup that never changed.
17
+ */
18
+ export interface CaptureStamp {
19
+ readonly source: string
20
+ readonly sourceSha256: string
21
+ readonly imageSha256: string
22
+ }
23
+
24
+ /**
25
+ * A capture writes its stamp beside its PNG rather than beside its HTML,
26
+ * because `--out` moves the image away from the source and the stamp answers a
27
+ * question about the image.
28
+ */
29
+ export function stampPath(pngPath: string): string {
30
+ return join(
31
+ dirname(pngPath),
32
+ `${basename(pngPath, extname(pngPath))}${STAMP_EXTENSION}`,
33
+ )
34
+ }
35
+
36
+ /**
37
+ * Digests the whole file rather than the counts inside it. A template edit
38
+ * changes what the image shows without moving any count, and the whole point of
39
+ * the stamp is that it needs no knowledge of what the markup renders.
40
+ */
41
+ export function hashSource(bytes: Uint8Array): string {
42
+ return createHash(HASH_ALGORITHM).update(bytes).digest('hex')
43
+ }
44
+
45
+ export function formatStamp(stamp: CaptureStamp): string {
46
+ return [
47
+ `source: ${stamp.source}`,
48
+ `source-sha256: ${stamp.sourceSha256}`,
49
+ `image-sha256: ${stamp.imageSha256}`,
50
+ '',
51
+ ].join('\n')
52
+ }
@@ -12,7 +12,7 @@ import { SETUP_CASES } from '@/claude/cases/setup'
12
12
  * Bootstrapped from each skill's own quoted trigger phrases and then
13
13
  * rephrased and supplemented rather than shipped verbatim, since a positive
14
14
  * lifted unchanged from a trigger passes by construction and never tests a
15
- * person's own words. See `.claude/groundwork/42-ai-blueprint/08-spikes.md`
15
+ * person's own words. See `.canon/groundwork/42-ai-blueprint/08-spikes.md`
16
16
  * for the extraction arm this corpus replaces as the shipped measure.
17
17
  */
18
18
  export const SKILL_CASES: readonly SkillCase[] = [
@@ -2,6 +2,7 @@ import { existsSync } from 'node:fs'
2
2
  import { chmod, mkdir, readFile, writeFile } from 'node:fs/promises'
3
3
  import { dirname, join } from 'node:path'
4
4
  import { copyPreservingMode } from '@/copy'
5
+ import { creationRel, isRecordEntry } from '@/record-root'
5
6
  import { rewritesOnInstall, stripSeedMarker } from '@/seed-marker'
6
7
 
7
8
  const SEEDS_DIR = join('tooling', 'claude', 'seeds')
@@ -81,13 +82,23 @@ export function planSeeds(root: string, target: string): SeedEntry[] {
81
82
  }
82
83
 
83
84
  for (const subdir of SUBDIRS) {
85
+ // The seed tree authors every subdirectory under `.claude/`, and three of
86
+ // them are record folders that install under the record root instead. A
87
+ // target that has not migrated resolves back to `.claude/`, so the same seed
88
+ // lands beside the records already there rather than opening a second root.
89
+ // Scaffolding one under `.claude/` now would also land it outside the single
90
+ // `.canon/` ignore entry a target receives, which tracks the memory pen.
91
+ const installRel = isRecordEntry(subdir)
92
+ ? creationRel(target, subdir)
93
+ : join(CLAUDE_DIR, subdir)
94
+
84
95
  for (const name of listLevel(join(source, subdir))) {
85
96
  const rel = `${subdir}/${name}`
86
97
  seeds.push({
87
98
  src: join(source, subdir, name),
88
- dest: join(destDir, subdir, name),
99
+ dest: join(target, installRel, name),
89
100
  scanLabel: rel,
90
- applyLabel: join(CLAUDE_DIR, subdir, name),
101
+ applyLabel: join(installRel, name),
91
102
  scope: 'claude',
92
103
  executable: subdir === HOOKS,
93
104
  })
@@ -8,7 +8,7 @@ import { listSkillsAt, resolveSkillsCorpus } from '@/claude/skills-list'
8
8
  * asks whether the descriptions are separable by the words they use, and
9
9
  * Claude Code does not route this way.
10
10
  *
11
- * Ported from `.claude/groundwork/42-ai-blueprint/evidence/rank.ts`, which
11
+ * Ported from `.canon/groundwork/42-ai-blueprint/evidence/rank.ts`, which
12
12
  * ran once against this catalog and named the collisions this measure now
13
13
  * tracks on a cadence.
14
14
  */
package/src/cli.ts CHANGED
@@ -72,11 +72,11 @@ function showHelp(): void {
72
72
  `${GREY}│${NC} demo [cmd] ${GREY}# Record a running app (compile, run)${NC}`,
73
73
  `${GREY}│${NC} inventory [subj] ${GREY}# Report one computed property across every route${NC}`,
74
74
  `${GREY}│${NC} drive <url> <run> ${GREY}# Walk a page through named interactions and measure each state${NC}`,
75
- `${GREY}│${NC} feedback ${GREY}# Write toolkit feedback from stdin to .claude/review/feedback/${NC}`,
75
+ `${GREY}│${NC} feedback ${GREY}# Write toolkit feedback from stdin to .canon/review/feedback/${NC}`,
76
76
  `${GREY}│${NC} transcripts <url> ${GREY}# Fetch a YouTube transcript with metadata frontmatter${NC}`,
77
77
  `${GREY}│${NC} tasks [cmd] ${GREY}# Task board commands (archive)${NC}`,
78
- `${GREY}│${NC} intake [cmd] ${GREY}# Intake folders under .claude/intake/ (list, answer)${NC}`,
79
- `${GREY}│${NC} teach [cmd] ${GREY}# Learning workspaces under .claude/teach/ (list, open, resource, glossary)${NC}`,
78
+ `${GREY}│${NC} intake [cmd] ${GREY}# Intake folders under .canon/intake/ (list, answer)${NC}`,
79
+ `${GREY}│${NC} teach [cmd] ${GREY}# Learning workspaces under .canon/teach/ (list, open, resource, glossary)${NC}`,
80
80
  `${GREY}│${NC} comments [cmd] ${GREY}# Measure comment density and trend (scan)${NC}`,
81
81
  `${GREY}│${NC} context [cmd] ${GREY}# Report context folder health (audit)${NC}`,
82
82
  `${GREY}│${NC} markdown [cmd] ${GREY}# Report markdown against the attribute standards (audit)${NC}`,
@@ -120,8 +120,8 @@ function showHelp(): void {
120
120
  `${GREY}│${NC} canon design render`,
121
121
  `${GREY}│${NC} canon slides render`,
122
122
  `${GREY}│${NC} canon slides list --json`,
123
- `${GREY}│${NC} canon capture assets/install.html`,
124
- `${GREY}│${NC} canon serve .claude/teach`,
123
+ `${GREY}│${NC} canon capture assets/install.html --selector .window`,
124
+ `${GREY}│${NC} canon serve .canon/teach`,
125
125
  `${GREY}│${NC} canon inventory focus --json`,
126
126
  `${GREY}│${NC} canon drive http://localhost:4173 run.json --json`,
127
127
  `${GREY}│${NC} pbpaste | canon feedback`,