@erclx/canon 4.112.0 → 4.113.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "canon",
3
3
  "description": "Automated governance, versioning, and discovery tools for Claude Code.",
4
- "version": "4.112.0",
4
+ "version": "4.113.0",
5
5
  "author": {
6
6
  "name": "Eric Le",
7
7
  "url": "https://github.com/erclx"
@@ -5,7 +5,7 @@ description: Format a report about something broken, missing, or off in canon an
5
5
 
6
6
  # Canon feedback
7
7
 
8
- Format a `## Toolkit feedback` block from the current session, then open it as a GitHub issue labeled `feedback` on the toolkit repo. `canon-feedback-triage` reads that queue.
8
+ Format a `## Toolkit feedback` block from the current session, then open it as a GitHub issue on the toolkit repo, labeled `feedback` plus the domain label its surface type names. `canon-feedback-triage` reads that queue.
9
9
 
10
10
  ## Guards
11
11
 
@@ -18,7 +18,7 @@ Format a `## Toolkit feedback` block from the current session, then open it as a
18
18
  From the conversation so far, identify:
19
19
 
20
20
  - Target project name or kind, never its full path. The path names a folder on one machine and says nothing a triage session can route on, where the project's own name does. Name a private project by its kind instead, such as `a Next.js app`, since the report leaves the machine.
21
- - Toolkit surface and its type (plugin skill, snippet, tooling config, governance rule, seed, or CLI)
21
+ - Toolkit surface and its type (plugin skill, snippet, tooling config, governance rule, seed, or CLI). The type leads the `### Surface` field, ahead of the first comma, because the CLI picks the issue's domain labels from that text alone.
22
22
  - Specific toolkit file or name when the session cites one
23
23
  - Observed behavior
24
24
  - Expected behavior, or `unclear`
@@ -81,7 +81,7 @@ cat <<'EOF' | canon feedback --github
81
81
  EOF
82
82
  ```
83
83
 
84
- The CLI opens a GitHub issue labeled `feedback` on the toolkit repo and prints the issue URL, which this skill reports back on its own line. It needs `gh` authenticated. `git-issue` is not the route, since it files on the current repository and this report belongs on the toolkit's.
84
+ The CLI opens a GitHub issue on the toolkit repo, labeled `feedback` and each domain label the surface type names, such as `skills` for a plugin skill, and prints the issue URL, which this skill reports back on its own line. It needs `gh` authenticated. `git-issue` is not the route, since it files on the current repository and this report belongs on the toolkit's.
85
85
 
86
86
  From a toolkit checkout, a call that produces no URL falls back to `.canon/feedback/feedback-<slug>-<ts>.md` and prints the reason on stderr. Say so in the same reply, naming the file and the reason, since a silent fallback leaves the report where nothing reads it. A target project has no such folder, so a failed call there prints the block in chat.
87
87
 
@@ -198,6 +198,8 @@ No `canon teach` verb serves the workspace. `canon serve` does, taking the teach
198
198
  canon serve .canon/teach --entry 03-fde-system-design/index.html --json
199
199
  ```
200
200
 
201
+ `canon teach list <topic>` ends its human output with this line for that workspace, with the teach folder written relative to the cwd, or absolute when it sits outside it, since `canon serve` resolves its directory against the cwd while the list verb reads the main worktree root. The JSON record carries no such field.
202
+
201
203
  It stays general rather than becoming `canon teach serve`, because nothing about serving a directory is specific to a learning workspace, and the same verb carries a slide render and a design preview.
202
204
 
203
205
  Read `url` off the record rather than building one from the port that was asked for. The verb walks forward past a port already in use, which is routine when a second workspace is already open, and the port it took is the one thing a composed URL gets wrong.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@erclx/canon",
3
3
  "type": "module",
4
- "version": "4.112.0",
4
+ "version": "4.113.0",
5
5
  "description": "Infrastructure and quality tooling for developer workflows",
6
6
  "license": "MIT",
7
7
  "bin": {
@@ -49,6 +49,8 @@
49
49
  "web:previews": "bun scripts/core/regen-web-previews.ts",
50
50
  "web:build": "bun run web:tokens && bun run web:favicon && bun run web:previews && cd web && astro check && astro build",
51
51
  "web:gallery": "cd web && astro build --config gallery.config.mjs",
52
+ "web:card": "cd web && astro dev --config card.config.mjs",
53
+ "web:card:check": "PROJECT_ROOT=web bash tooling/astro/configs/scripts/check-card-exclusion.sh",
52
54
  "web:preview": "cd web && astro preview",
53
55
  "web:e2e": "cd web && playwright test"
54
56
  },
@@ -1,10 +1,6 @@
1
1
  import { existsSync } from 'node:fs'
2
2
  import { dirname, join } from 'node:path'
3
- import {
4
- type Frontmatter,
5
- parseFrontmatter,
6
- readField,
7
- } from '@/indexes/frontmatter'
3
+ import { parseFrontmatter, readField } from '@/indexes/frontmatter'
8
4
 
9
5
  /** Returned when a skill folder carries no `REQUIREMENT.md`, the gating check. */
10
6
  export const EXIT_MISSING_REQUIREMENT = 2
@@ -149,7 +145,7 @@ async function readSkill(
149
145
  : Promise.resolve(undefined),
150
146
  ])
151
147
 
152
- const fields = readFields(body)
148
+ const fields = parseFrontmatter(body)
153
149
 
154
150
  return {
155
151
  rel: join(rel, folder),
@@ -162,19 +158,6 @@ async function readSkill(
162
158
  }
163
159
  }
164
160
 
165
- /**
166
- * Returns undefined rather than throwing on unparseable YAML, so one malformed
167
- * body reports as missing its required fields instead of ending the audit
168
- * before it reaches the rest of the corpus.
169
- */
170
- function readFields(body: string): Frontmatter | undefined {
171
- try {
172
- return parseFrontmatter(body)
173
- } catch {
174
- return undefined
175
- }
176
- }
177
-
178
161
  /**
179
162
  * A key carrying an empty value declares nothing, so blank reads as absent
180
163
  * rather than as a name that disagrees with every folder.
@@ -89,6 +89,30 @@ export function deriveSlug(body: string): string {
89
89
  return slug || SLUG_FALLBACK
90
90
  }
91
91
 
92
+ /**
93
+ * Each label must exist on the toolkit repository, because `gh issue create`
94
+ * refuses the whole issue on an unknown label rather than dropping it.
95
+ */
96
+ const DOMAIN_LABELS: readonly { pattern: RegExp; label: string }[] = [
97
+ { pattern: /\bskills?\b/, label: 'skills' },
98
+ { pattern: /\b(?:cli)s?\b/, label: 'cli' },
99
+ { pattern: /\b(?:tooling|seeds?)\b/, label: 'tooling' },
100
+ { pattern: /\b(?:governance)s?\b/, label: 'governance' },
101
+ { pattern: /\bsnippets?\b/, label: 'snippets' },
102
+ ]
103
+
104
+ /**
105
+ * Reads only the surface type, the text before the first comma, so a path or
106
+ * name after it never adds a label.
107
+ */
108
+ export function deriveDomainLabels(body: string): string[] {
109
+ const type = (surfaceField(body) ?? '').split(',')[0]?.toLowerCase() ?? ''
110
+ const labels = DOMAIN_LABELS.filter(({ pattern }) => pattern.test(type)).map(
111
+ ({ label }) => label,
112
+ )
113
+ return [...new Set(labels)]
114
+ }
115
+
92
116
  export function deriveTitle(body: string): string {
93
117
  const surface = surfaceField(body)
94
118
  const title = surface ? `feedback: ${surface}` : 'toolkit feedback'
@@ -2,6 +2,7 @@ import { existsSync, mkdirSync, writeFileSync } from 'node:fs'
2
2
  import { join } from 'node:path'
3
3
  import type { Command } from 'commander'
4
4
  import {
5
+ deriveDomainLabels,
5
6
  deriveSlug,
6
7
  deriveTitle,
7
8
  missingField,
@@ -107,7 +108,7 @@ export function register(program: Command): void {
107
108
  const result = await createGithubIssue({
108
109
  title: deriveTitle(body),
109
110
  body,
110
- labels: ['feedback'],
111
+ labels: ['feedback', ...deriveDomainLabels(body)],
111
112
  })
112
113
  if (result.ok) {
113
114
  frameSuccess('canon feedback', result.url)
@@ -1,4 +1,4 @@
1
- import { relative } from 'node:path'
1
+ import { isAbsolute, relative, resolve, sep } from 'node:path'
2
2
  import type { Command } from 'commander'
3
3
  import { type LessonOutcome, planLesson } from '@/teach/lesson'
4
4
  import { type NavOutcome, generateNav } from '@/teach/nav'
@@ -18,6 +18,7 @@ import {
18
18
  type TeachRefused,
19
19
  type Term,
20
20
  type TermOutcome,
21
+ teachDir,
21
22
  type WorkspaceSummary,
22
23
  writeStylesheet,
23
24
  } from '@/teach/workspace'
@@ -91,6 +92,18 @@ export function register(program: Command): void {
91
92
  .command('teach')
92
93
  .description('Manage learning workspaces in .canon/teach/')
93
94
  .helpOption('-h, --help', 'Show this help message')
95
+ .addHelpText(
96
+ 'after',
97
+ [
98
+ '',
99
+ 'Opening a workspace:',
100
+ ' canon serve .canon/teach --entry <nn>-<topic>/index.html',
101
+ '',
102
+ 'canon teach list <topic> prints the exact line, with the teach folder',
103
+ 'written from where you stand rather than as this literal.',
104
+ '',
105
+ ].join('\n'),
106
+ )
94
107
 
95
108
  teach
96
109
  .command('list')
@@ -118,6 +131,9 @@ export function register(program: Command): void {
118
131
  'A workspace not named NN-<topic> is still listed. It sorts last and',
119
132
  'moves no ordinal, since dropping it hides the folder needing a fix.',
120
133
  '',
134
+ 'The human output ends with the canon serve line that opens a',
135
+ 'workspace, a template with no topic and the exact line with one.',
136
+ '',
121
137
  'Examples:',
122
138
  ' canon teach list',
123
139
  ' canon teach list regular-expressions --json',
@@ -852,6 +868,22 @@ function describe(workspace: WorkspaceSummary): string {
852
868
  return `${workspace.slug}: ${workspace.lessons} lesson(s), ${workspace.records} record(s), ${workspace.reference} reference page(s), ${workspace.terms} term(s)`
853
869
  }
854
870
 
871
+ /**
872
+ * `canon serve` resolves its folder against the caller's cwd, while the list
873
+ * verb reads the teach folder at the main worktree root, so the line carries
874
+ * the folder actually read. A literal `.canon/teach` serves an absent folder
875
+ * from a linked worktree.
876
+ */
877
+ function serveLine(root: string, entry: string): string {
878
+ const dir = resolve(teachDir(root))
879
+ const fromCwd = relative(process.cwd(), dir)
880
+ const isOutsideCwd =
881
+ fromCwd === '..' || fromCwd.startsWith(`..${sep}`) || isAbsolute(fromCwd)
882
+ const printed = fromCwd === '' ? '.' : isOutsideCwd ? dir : fromCwd
883
+
884
+ return `canon serve ${printed} --entry ${entry}`
885
+ }
886
+
855
887
  function reportList(
856
888
  outcome: ListOutcome,
857
889
  emitJson: boolean,
@@ -890,6 +922,12 @@ function reportList(
890
922
 
891
923
  logStep('Next ordinal')
892
924
  logInfo(outcome.next)
925
+
926
+ if (outcome.workspaces.length > 0) {
927
+ logStep('Open')
928
+ logInfo(serveLine(root, '<nn>-<topic>/index.html'))
929
+ }
930
+
893
931
  outro()
894
932
 
895
933
  return 0
@@ -940,6 +978,8 @@ function reportWorkspace(
940
978
  logWarn(`no ${workspace.missing.join(' and no ')}`)
941
979
  }
942
980
 
981
+ logStep('Open')
982
+ logInfo(serveLine(root, `${workspace.slug}/index.html`))
943
983
  outro()
944
984
 
945
985
  return 0
@@ -11,12 +11,21 @@ export interface Frontmatter {
11
11
  * `raw` keeps the delimiters so an index can be rewritten with its own
12
12
  * frontmatter untouched, including comments and key order that a parse and
13
13
  * re-emit would lose.
14
+ *
15
+ * A block that will not parse reads as absent rather than throwing, so one
16
+ * malformed file reports through the path a caller already takes for a
17
+ * missing block instead of ending the caller's whole run.
14
18
  */
15
19
  export function parseFrontmatter(source: string): Frontmatter | undefined {
16
20
  const match = source.match(FRONTMATTER)
17
21
  if (!match) return undefined
18
22
 
19
- const parsed = Bun.YAML.parse(match[1])
23
+ let parsed: unknown
24
+ try {
25
+ parsed = Bun.YAML.parse(match[1])
26
+ } catch {
27
+ return undefined
28
+ }
20
29
  if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
21
30
  return undefined
22
31
  }
@@ -106,8 +106,10 @@ export async function groupEvidence(
106
106
  return { kind: 'read', states }
107
107
  }
108
108
 
109
+ // The raw host serves nothing to a private repository's browser, while the
110
+ // blob form redirects to the bytes carrying the viewer's session.
109
111
  function rawUrl(repo: string, sha: string, path: string): string {
110
- return `https://raw.githubusercontent.com/${repo}/${sha}/${path}`
112
+ return `https://github.com/${repo}/blob/${sha}/${path}?raw=true`
111
113
  }
112
114
 
113
115
  export function evidenceMarker(head: string): string {
@@ -579,8 +579,8 @@ function missionText(
579
579
 
580
580
  return [
581
581
  '---',
582
- `title: ${title}`,
583
- `description: ${request.subject}`,
582
+ `title: ${yamlValue(title)}`,
583
+ `description: ${yamlValue(request.subject)}`,
584
584
  `date: ${date}`,
585
585
  '---',
586
586
  '',
@@ -606,8 +606,8 @@ function missionText(
606
606
  function resourcesText(title: string): string {
607
607
  return [
608
608
  '---',
609
- `title: Sources for ${title}`,
610
- `description: Sources read for ${title}, and leads found but not opened`,
609
+ `title: ${yamlValue(`Sources for ${title}`)}`,
610
+ `description: ${yamlValue(`Sources read for ${title}, and leads found but not opened`)}`,
611
611
  '---',
612
612
  '',
613
613
  `# Sources for ${title}`,
@@ -628,8 +628,8 @@ function resourcesText(title: string): string {
628
628
  function glossaryText(title: string): string {
629
629
  return [
630
630
  '---',
631
- `title: Glossary for ${title}`,
632
- `description: Terms ${title} defines, one entry each`,
631
+ `title: ${yamlValue(`Glossary for ${title}`)}`,
632
+ `description: ${yamlValue(`Terms ${title} defines, one entry each`)}`,
633
633
  '---',
634
634
  '',
635
635
  `# Glossary for ${title}`,
@@ -641,6 +641,14 @@ function glossaryText(title: string): string {
641
641
  ].join('\n')
642
642
  }
643
643
 
644
+ /**
645
+ * Serializes a frontmatter value, quoting only what YAML would otherwise read
646
+ * as something other than the string, so a plain title stays byte-identical.
647
+ */
648
+ function yamlValue(value: string): string {
649
+ return Bun.YAML.stringify(value)
650
+ }
651
+
644
652
  /** Today as `YYYY-MM-DD`, which is the one field the mission dates. */
645
653
  function today(): string {
646
654
  return new Date().toISOString().slice(0, DATE_LENGTH)
@@ -16,5 +16,5 @@
16
16
  }
17
17
  ],
18
18
  "dictionaries": ["project-terms", "tech-stack"],
19
- "ignorePaths": [".cspell/**", ".git/**"]
19
+ "ignorePaths": [".cspell/**", ".git/**", ".claude/rules/canon/**"]
20
20
  }
@@ -49,19 +49,18 @@ paths=$(tail -n +"$line_start" "$transcript_path" | jq -r '
49
49
 
50
50
  # Two written paths can share a basename, and a bare basename match would then
51
51
  # read one mention as covering both. Counting first is what lets a collision
52
- # fall back to the full path, which the ordinary case never needs.
53
- declare -A base_count
54
- while IFS= read -r path; do
52
+ # fall back to the full path, which the ordinary case never needs. The count
53
+ # avoids an associative array, since macOS ships bash 3.2.
54
+ colliding=$(while IFS= read -r path; do
55
55
  [ -n "$path" ] || continue
56
- base=$(basename "$path")
57
- base_count["$base"]=$((${base_count["$base"]:-0} + 1))
58
- done <<<"$paths"
56
+ printf '%s\n' "${path##*/}"
57
+ done <<<"$paths" | sort | uniq -d)
59
58
 
60
59
  unmentioned=()
61
60
  while IFS= read -r path; do
62
61
  [ -n "$path" ] || continue
63
- base=$(basename "$path")
64
- if [ "${base_count[$base]}" -gt 1 ]; then
62
+ base=${path##*/}
63
+ if [ -n "$colliding" ] && printf '%s\n' "$colliding" | grep -Fxq -- "$base"; then
65
64
  needle="$path"
66
65
  else
67
66
  needle="$base"
@@ -5,7 +5,7 @@ import { defineConfig } from 'vitest/config'
5
5
  export default defineConfig({
6
6
  resolve: {
7
7
  alias: {
8
- '@': path.resolve(__dirname, 'src'),
8
+ '@': path.resolve(import.meta.dirname, 'src'),
9
9
  },
10
10
  },
11
11
  test: {
@@ -2,7 +2,7 @@
2
2
  name = "python"
3
3
  extends = "base"
4
4
  runtime = "uv"
5
- scaffold = "uv init --app {{name}}"
5
+ scaffold = "uv init --package {{name}}"
6
6
 
7
7
  [scripts]
8
8
  "lint" = "uv run ruff check . && uv run ruff format --check ."
@@ -10,14 +10,13 @@ Configs ship as sidecar files (`ruff.toml`, `mypy.ini`, `pytest.ini`, `.coverage
10
10
 
11
11
  ## Scaffold checklist
12
12
 
13
- 1. Scaffold with `uv init --app <name>`. This creates `pyproject.toml`, `.python-version` pinned to 3.14, `src/<name>/`, and a starter `main.py`. `uv init` defaults `requires-python` to `>=3.14`, which matches the `.python-version` pin this stack ships.
13
+ 1. Scaffold with `uv init --package <name>`. This creates `pyproject.toml` with a `uv_build` backend, `.python-version` pinned to 3.14, and `src/<name with underscores>/__init__.py` holding an annotated `def main() -> None:`, so source lands under `src/` where `mypy.ini`, `pytest.ini`, and `.coveragerc` look for it. `uv init` defaults `requires-python` to `>=3.14`, which matches the `.python-version` pin this stack ships.
14
14
  2. Seed `package.json` so the base layer's bun-side tools (husky, prettier, cspell, commitlint) have a target to install into: `bun init -y`. Without this the sync drops base configs but skips the dep install, since `resolve_missing_deps` short-circuits when `package.json` is absent.
15
15
  3. Install base tooling: `canon tooling sync base . --write`
16
16
  4. Install python tooling: `canon tooling sync python . --write`
17
17
  5. Install Python tooling deps: `uv add --dev ruff mypy pytest pytest-cov`. v1 of this stack does not declare these in `[dependencies.dev]` because manifest injection hardcodes `bun add -D`, which can not install Python packages. Until the injector branches on `runtime`, this step is manual.
18
18
  6. Sync the lockfile and create the venv: `uv sync`.
19
- 7. Annotate `main()` in the scaffold-generated `main.py` with `-> None`. `uv init --app` ships an unannotated `main()` that fails strict mypy on the first run.
20
- 8. Run `bun run lint:fix` then `bun run check`.
19
+ 7. Run `bun run lint:fix` then `bun run check`.
21
20
 
22
21
  The python stack also ships `tests/test_smoke.py` as a copy-once seed. `pytest` collects at least one test on first run, so `bun run test:run` exits 0 instead of the empty-collection exit code 5. Delete or replace the smoke test with real tests.
23
22
 
@@ -27,7 +26,7 @@ The python stack also ships `tests/test_smoke.py` as a copy-once seed. `pytest`
27
26
  - `mypy.ini`: strict mode, `python_version = 3.14`, `mypy_path = src`, excludes `tests/` and `docs/`.
28
27
  - `pytest.ini`: tests live under `tests/`, source under `src/`.
29
28
  - `.coveragerc`: branch coverage on `src/`, html report under `.coverage_cache/html`.
30
- - `.python-version`: pinned to `3.14` to match `uv init --app` defaults.
29
+ - `.python-version`: pinned to `3.14` to match `uv init` defaults.
31
30
  - `scripts/verify.sh`: overrides base verify to add Typecheck (`mypy`), Lint (`ruff check && ruff format --check`), and Tests (`pytest -v`) phases.
32
31
 
33
32
  ## Hybrid project shape
@@ -9,7 +9,7 @@ export default defineConfig({
9
9
  plugins: [react(), tailwindcss()],
10
10
  resolve: {
11
11
  alias: {
12
- '@': path.resolve(__dirname, './src'),
12
+ '@': path.resolve(import.meta.dirname, './src'),
13
13
  },
14
14
  },
15
15
  base: process.env.VITE_BASE_URL ?? '/',