@erclx/canon 5.29.0 → 5.31.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 (88) hide show
  1. package/claude/.claude-plugin/plugin.json +1 -1
  2. package/claude/skills/build-in-slices/REQUIREMENT.md +1 -1
  3. package/claude/skills/build-in-slices/references/adopted.md +1 -1
  4. package/claude/skills/canon-feedback-triage/SKILL.md +2 -0
  5. package/claude/skills/context-fold/SKILL.md +1 -1
  6. package/claude/skills/create-skill/SKILL.md +1 -1
  7. package/claude/skills/create-standard/SKILL.md +1 -1
  8. package/claude/skills/design-extract/SKILL.md +2 -2
  9. package/claude/skills/design-taste/SKILL.md +1 -1
  10. package/claude/skills/docs-sync/SKILL.md +1 -1
  11. package/claude/skills/draft-doc/SKILL.md +1 -1
  12. package/claude/skills/draft-figure/SKILL.md +1 -1
  13. package/claude/skills/draft-figure/references/mermaid.md +1 -1
  14. package/claude/skills/draft-ready/REQUIREMENT.md +1 -1
  15. package/claude/skills/git-issue/SKILL.md +1 -1
  16. package/claude/skills/git-pr/SKILL.md +1 -1
  17. package/claude/skills/git-stage/SKILL.md +1 -1
  18. package/claude/skills/markdown-craft/REQUIREMENT.md +23 -0
  19. package/claude/skills/markdown-craft/SKILL.md +17 -0
  20. package/{standards → claude/skills/markdown-craft/references}/markdown.md +14 -7
  21. package/claude/skills/memory-capture/SKILL.md +1 -1
  22. package/claude/skills/memory-review/SKILL.md +1 -1
  23. package/claude/skills/plan-feature/REQUIREMENT.md +2 -1
  24. package/claude/skills/plan-feature/SKILL.md +12 -2
  25. package/claude/skills/review-address/SKILL.md +1 -1
  26. package/claude/skills/review-address/references/post-review-findings.md +1 -1
  27. package/claude/skills/review-pr/references/post.md +2 -2
  28. package/claude/skills/review-ui/references/post.md +1 -1
  29. package/claude/skills/role-orchestrator/references/orchestrator-dispatch.md +1 -0
  30. package/claude/skills/target-setup/references/indexes.md +1 -1
  31. package/claude/skills/test-first/REQUIREMENT.md +1 -1
  32. package/claude/skills/write-human/SKILL.md +1 -1
  33. package/docs/agents/commands.md +5 -4
  34. package/docs/agents/context-audit-checks.md +1 -1
  35. package/docs/agents/counts.md +3 -3
  36. package/docs/agents/index.md +1 -0
  37. package/docs/agents/install-and-sync.md +3 -3
  38. package/docs/agents/markdown-audit.md +1 -1
  39. package/docs/agents/routing.md +1 -1
  40. package/docs/agents/tasks-plans.md +1 -1
  41. package/docs/agents/test-order.md +1 -1
  42. package/docs/agents/upstream.md +70 -0
  43. package/docs/workflow/skill-map-on-request.md +1 -1
  44. package/governance/renames.toml +0 -4
  45. package/governance/rules/standards/plan.md +1 -0
  46. package/governance/rules/writing/markdown.md +23 -0
  47. package/package.json +1 -1
  48. package/scripts/core/install-check.sh +1 -1
  49. package/src/canvas/client/main.tsx +9 -0
  50. package/src/canvas/server.ts +7 -0
  51. package/src/claude/cases/authoring.ts +5 -0
  52. package/src/claude/cases/misc.ts +0 -4
  53. package/src/claude/skills-reach.ts +1 -1
  54. package/src/cli/help.ts +2 -0
  55. package/src/cli.ts +2 -0
  56. package/src/commands/claude.ts +1 -1
  57. package/src/commands/gov.ts +2 -2
  58. package/src/commands/records.ts +19 -1
  59. package/src/commands/upstream.ts +295 -0
  60. package/src/context/audit.ts +1 -1
  61. package/src/design/brand-favicon.ts +18 -0
  62. package/src/gate/stages.ts +0 -1
  63. package/src/markdown/structure.ts +1 -1
  64. package/src/records/backup.ts +1 -0
  65. package/src/records/validate.ts +55 -0
  66. package/src/roots/record.ts +1 -0
  67. package/src/standards/read.ts +21 -1
  68. package/src/tasks/answers.ts +14 -1
  69. package/src/tasks/stack.ts +176 -0
  70. package/src/teach/nav.ts +5 -12
  71. package/src/upstream/catalog.ts +112 -0
  72. package/src/upstream/cursor.ts +120 -0
  73. package/src/upstream/digest.ts +75 -0
  74. package/src/upstream/releases.ts +169 -0
  75. package/standards/index.md +0 -1
  76. package/standards/plan.md +25 -12
  77. package/standards/rule.md +1 -1
  78. package/tooling/claude/seeds/.claude/hooks/index-reminder.sh +1 -1
  79. package/claude/skills/index-lookup/REQUIREMENT.md +0 -35
  80. package/claude/skills/index-lookup/SKILL.md +0 -44
  81. package/governance/rules/canon/601-indexes.md +0 -9
  82. package/governance/rules/claude/565-behavior.md +0 -27
  83. package/governance/rules/claude/567-planning.md +0 -16
  84. package/governance/rules/claude/575-hooks.md +0 -16
  85. package/governance/rules/claude/576-settings.md +0 -13
  86. package/governance/rules/claude/592-claude-md.md +0 -19
  87. package/governance/rules/standards/markdown.md +0 -16
  88. package/governance/rules/writing/800-prose.md +0 -15
@@ -0,0 +1,70 @@
1
+ ---
2
+ title: Upstream
3
+ description: The fetch, catalog, and advance verbs that read Claude Code releases against the toolkit, the cursor record, and each refusal reason
4
+ ---
5
+
6
+ # Upstream
7
+
8
+ `canon upstream` reads the Claude Code release feed from a stored cursor and lists canon's own mechanisms beside it, so a triage can match a release line to something canon does. The verbs ship in the published CLI as `canon gate` and `canon sandbox` do, and only this repository has the skill that drives them. A target has no use for them.
9
+
10
+ Run them as `bun src/cli.ts upstream ...` from this repository's root. The global `canon` resolves to the installed package, which lacks the verbs until a release ships them.
11
+
12
+ ## Verbs
13
+
14
+ | Verb | Behavior |
15
+ | ----------------------------------- | ---------------------------------------------------------------------------- |
16
+ | `fetch [--since <version>]` | List the release lines since the cursor, with the mechanical noise dropped |
17
+ | `catalog` | List the skills, verbs, hooks, and gate stages the tree holds, one line each |
18
+ | `advance <version> --intake <slug>` | Move the cursor to a version, and store the current `llms.txt` beside it |
19
+
20
+ Every verb takes `--root <path>` for the repository to read and `--json` for a record on stdout. `fetch` and `catalog` write nothing. `advance` is the only writer, and the digest skill runs it after the findings are filed, so a run that dies midway reads the same range next time.
21
+
22
+ ## fetch
23
+
24
+ ```bash
25
+ bun src/cli.ts upstream fetch --json
26
+ bun src/cli.ts upstream fetch --since 2.1.256 --json
27
+ ```
28
+
29
+ The range runs from the cursor, or from `--since` when given, to the newest release. The walk stops at the first release whose version is not newer than the cursor, so a cursor naming a version the feed never carried still bounds the range, and a range crossing a page boundary is read whole.
30
+
31
+ A line survives when it opens with `Added`, `Changed`, `Removed`, `Improved`, or `Deprecated` and carries none of the dropped surfaces: `[VSCode]`, `[Claude Tag]`, `[Code Review]`, `Windows:`, `Self-hosted`, the apps gateway, managed settings, OpenTelemetry, Bedrock, Vertex, and screen reader. A nested bullet and a `*` bullet are read as well as a `-` one.
32
+
33
+ Without `--json`, stdout carries one `version<TAB>text` row per line. With it, stdout carries one record:
34
+
35
+ | Field | Holds |
36
+ | ---------- | -------------------------------------------------------------------------- |
37
+ | `from` | The cursor version the range starts after |
38
+ | `to` | The newest release version, or `from` when nothing is newer |
39
+ | `releases` | How many releases the range spans |
40
+ | `lines` | Each kept line as `version`, `date`, `text`, and `identifiers` |
41
+ | `llms` | `added` and `removed` lines of `llms.txt` against the stored copy, or null |
42
+
43
+ An identifier is a backticked name on a `Changed`, `Removed`, or `Deprecated` line that holds a dash, a slash, a dot, or a capital. It carries up to three tracked files naming it, and one with no hit is left out. `llms` is null when the page could not be fetched, and every line reads as added until `advance` has stored a first copy.
44
+
45
+ The request sends `GH_TOKEN` or `GITHUB_TOKEN` when either is set. Unauthenticated, GitHub allows 60 requests an hour, and a range takes about three.
46
+
47
+ ## catalog
48
+
49
+ Prints a heading per section and a line per entry. With `--json` it prints one record whose fields are `skills`, `internalSkills`, `verbs`, `hooks`, `gate`, and `gaps`, each entry as `name` and `text`. A skill folder with no `SKILL.md` or no description appears in `gaps` rather than dropping out. A hook's text is the first comment line after its shebang.
50
+
51
+ ## advance
52
+
53
+ Writes `cursor.json` and the fetched `llms.txt` under the upstream record folder at the main worktree root, each through a temp file and a rename. `--no-llms` keeps the stored `llms.txt` and fetches nothing. The record is `version`, `intake`, `advanced`, and `llms`, which reads `stored` when a fresh copy was written and `kept` when the stored one stayed, whether by `--no-llms` or because the fetch failed.
54
+
55
+ The cursor file holds `version`, `date`, and `intake`. The folder is a backed record folder, so `canon records push` carries it.
56
+
57
+ ## Refusals
58
+
59
+ A refusal exits 1, prints the reason on stderr, and with `--json` adds `reason` and `message` to stdout. Branch on `reason`.
60
+
61
+ | Reason | Verb | Meaning |
62
+ | ------------------- | --------- | ------------------------------------------------------------------- |
63
+ | `no-cursor` | `fetch` | No cursor is stored and no `--since` was given. Pass the flag. |
64
+ | `bad-since` | `fetch` | `--since` is not a dotted version |
65
+ | `cursor-missing` | `fetch` | Every release the feed returned is newer than the cursor |
66
+ | `rate-limited` | `fetch` | GitHub refused the request. Set `GH_TOKEN` or `GITHUB_TOKEN`. |
67
+ | `network` | `fetch` | The feed could not be reached |
68
+ | `http` | `fetch` | GitHub answered with an error status |
69
+ | `invalid-version` | `advance` | The version is not a dotted number |
70
+ | `older-than-cursor` | `advance` | The version is older than the stored cursor, which never moves back |
@@ -30,10 +30,10 @@ These two groups hold the skills no single moment in a project's life calls for,
30
30
  | Skill | When to use |
31
31
  | --------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
32
32
  | `canon:canon-cli` | Before running an unfamiliar verb, a sync, or an install, to learn which command to run, which reference doc covers it, or what it overwrites, merges, or leaves alone |
33
- | `canon:index-lookup` | To find where a topic is documented across the tracked `index.md` catalogs |
34
33
  | `canon:youtube-transcripts` | When a video transcript is wanted in the repo as context |
35
34
  | `canon:read-frames` | To read a recorded demo back frame by frame and report what each one shows, with no verdict on whether the recording looks right |
36
35
  | `canon:teach-workspace` | To learn a subject across sessions, in a workspace that holds the progress |
36
+ | `canon:markdown-craft` | Before a substantial markdown edit, for the format and structure rules and the audit that follows |
37
37
  | `canon:write-human` | Before drafting or revising prose, for voice, rhythm, and density |
38
38
  | `canon:restate-plainly` | When an answer or a document has to be read again in plain words |
39
39
 
@@ -18,10 +18,7 @@
18
18
  # standard is a rename too: the generated rule keeps every glob and bullet, so
19
19
  # the pair is one-to-one identity under the standard's name.
20
20
  [renamed]
21
- "005-behavior" = "565-behavior"
22
21
  "015-output" = "566-output"
23
- "070-planning" = "567-planning"
24
- "025-indexes" = "601-indexes"
25
22
  "035-tasks" = "602-tasks"
26
23
  "045-memory" = "603-memory"
27
24
  "055-scratch" = "604-scratch"
@@ -41,7 +38,6 @@
41
38
  "561-teach" = "661-teach"
42
39
  "562-session" = "662-session"
43
40
  "563-ready" = "663-ready"
44
- "500-prose" = "800-prose"
45
41
  "501-markdown" = "801-markdown"
46
42
  "580-readme" = "880-readme"
47
43
  "591-standard-authoring" = "891-standard-authoring"
@@ -13,6 +13,7 @@ paths:
13
13
  - Never fill an `- Answer:` slot on behalf of the person who owns it. A blank slot accepts the suggestion at execution time.
14
14
  - Never ship a question without a `- Suggested:` line. Write `- Suggested: needs your call, <why>` where the answer turns on preference.
15
15
  - Rewrite the `- Suggested:` line as `overridden at execution to <pick>,` plus the measurement when execution deviates from an unanswered question, leaving the slot blank. Put the same deviation in one line under the open task's `## Findings`.
16
+ - Fetch the source a decision rests on and quote the passage under `**Sources:**`, or mark the entry `unverified`. Never cite an outside source from recall.
16
17
  - Move a shipped plan to `.canon/plans/archive/`. Never delete one.
17
18
  - Amend a plan in place when a decision changes. Do not append a second passage narrating the change.
18
19
  - Follow the plan lifecycle standard for when a plan is written, how it is amended, and its move to the archive. Read it with `canon standards plan-lifecycle`.
@@ -0,0 +1,23 @@
1
+ ---
2
+ description: Route markdown edits to the markdown-craft skill for format and structure, the write-human skill for voice, and the audit after the edit
3
+ paths:
4
+ - '**/*.md'
5
+ ---
6
+
7
+ # Markdown standards
8
+
9
+ ## Format
10
+
11
+ - Never write an em dash or a semicolon. `canon markdown audit` reads the full ban list from the package.
12
+ - Load the `canon:markdown-craft` skill before a substantial markdown edit for headings, paragraph and list structure, code spans, the date form, punctuation, emphasis, file references, American spelling, and frontmatter wording. Report it rather than proceeding silently when the skill does not resolve.
13
+ - Run `canon markdown audit <path>` after the edit, and rewrite the sentence carrying a hit rather than swapping the token for a near-synonym.
14
+
15
+ ## Voice
16
+
17
+ - Load the `canon:write-human` skill for voice, rhythm, sentence construction, and information density. Load it before drafting a passage, not after revising one.
18
+ - Report it rather than proceeding silently when the skill does not resolve.
19
+ - Do not work these rules from memory.
20
+
21
+ ## Figures
22
+
23
+ - Load the `canon:draft-figure` skill before drafting or revising a Mermaid fence or a figure, and report it rather than proceeding silently when it does not resolve.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@erclx/canon",
3
3
  "type": "module",
4
- "version": "5.29.0",
4
+ "version": "5.31.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": {
@@ -103,7 +103,7 @@ log_step "Assert scaffold"
103
103
  # rule paths stand for the `claude` and `canon` groups `base` takes whole.
104
104
  for path in "CLAUDE.md" ".claude/wiki/index.md" ".claude" "canon/context/index.md" "canon/wireframes/index.md" \
105
105
  ".prettierrc" ".editorconfig" ".lintstagedrc" ".husky/pre-commit" ".github/workflows/verify.yml" "scripts/verify.sh" \
106
- ".claude/rules/canon/claude/565-behavior.md" ".claude/rules/canon/canon/604-scratch.md"; do
106
+ ".claude/rules/canon/claude/566-output.md" ".claude/rules/canon/canon/604-scratch.md"; do
107
107
  if [ ! -e "$TARGET_DIR/$path" ]; then
108
108
  log_error "Missing after canon init: $path"
109
109
  fi
@@ -51,6 +51,15 @@ chrome.rel = 'stylesheet'
51
51
  chrome.href = '/api/chrome.css'
52
52
  document.head.prepend(chrome)
53
53
 
54
+ /*
55
+ * The page declares an empty icon so the browser never asks for /favicon.ico,
56
+ * since the bundler cannot resolve a route the server answers, and this points
57
+ * it at the brand icon once the shell loads.
58
+ */
59
+ document
60
+ .querySelector('link[rel="icon"]')
61
+ ?.setAttribute('href', '/api/icon.svg')
62
+
54
63
  const events = new EventSource('/api/events')
55
64
  events.addEventListener('message', (message) => {
56
65
  void applyChange(JSON.parse(message.data) as ChangeEvent)
@@ -34,6 +34,7 @@ import {
34
34
  tokenGroups,
35
35
  } from '@/canvas/tokens'
36
36
  import { buildDesignCss } from '@/design/css'
37
+ import { brandFavicon } from '@/design/brand-favicon'
37
38
  import {
38
39
  bindFirstFree,
39
40
  PORT_ATTEMPTS,
@@ -713,6 +714,12 @@ export function startCanvas(
713
714
  },
714
715
  }),
715
716
  ),
717
+ '/api/icon.svg': guarded(
718
+ () =>
719
+ new Response(brandFavicon(), {
720
+ headers: { 'content-type': 'image/svg+xml' },
721
+ }),
722
+ ),
716
723
  '/api/events': guarded(() => changes.open()),
717
724
  },
718
725
  fetch: guarded((request) => {
@@ -65,6 +65,11 @@ export const AUTHORING_CASES: readonly SkillCase[] = [
65
65
  prompt: 'This passage reads flat and robotic, give it some real cadence.',
66
66
  expect: 'write-human',
67
67
  },
68
+ {
69
+ prompt:
70
+ 'Which characters does the markdown standard ban, and how should I shape the headings in this doc?',
71
+ expect: 'markdown-craft',
72
+ },
68
73
  {
69
74
  prompt:
70
75
  'Pull the captions off this YouTube link and save them with metadata.',
@@ -108,10 +108,6 @@ export const MISC_CASES: readonly SkillCase[] = [
108
108
  prompt: 'Does our github about text still match what the readme says?',
109
109
  expect: 'repo-metadata',
110
110
  },
111
- {
112
- prompt: 'Is there a page anywhere in this repo that covers retries?',
113
- expect: 'index-lookup',
114
- },
115
111
  {
116
112
  prompt: 'Connect this repo to Cloudflare Pages and set up the deploy.',
117
113
  expect: 'deploy-app',
@@ -51,7 +51,7 @@ const TOKEN = /`([^`\s]+)`/g
51
51
  *
52
52
  * Requires an extension and a separator, and admits no `<`, `$`, or `*`. A
53
53
  * body writes `canon/context/<domain>.md` to name a shape rather than a
54
- * file, and `${CLAUDE_SKILL_DIR}/../../standards/markdown.md` to resolve
54
+ * file, and `${CLAUDE_SKILL_DIR}/../../standards/<name>.md` to resolve
55
55
  * against the plugin root, which is self-contained by construction.
56
56
  */
57
57
  const CONCRETE = /^[.A-Za-z0-9_][A-Za-z0-9._/-]*\.[a-z]{1,4}$/
package/src/cli/help.ts CHANGED
@@ -60,6 +60,7 @@ export const COMMAND_GROUPS: { title: string; rows: CommandRow[] }[] = [
60
60
  ['hooks [cmd]', 'Run the steps a git hook drives (post-merge)'],
61
61
  ['autoship [cmd]', 'Decide whether a changed set needs review'],
62
62
  ['pr [cmd]', 'Read a pull request (key-changes, head, checks)'],
63
+ ['upstream [cmd]', 'Read Claude Code releases (fetch, catalog, advance)'],
63
64
  ['feedback', 'Write toolkit feedback from stdin'],
64
65
  ],
65
66
  },
@@ -132,6 +133,7 @@ const EXAMPLES = [
132
133
  'canon audits run --json',
133
134
  'canon gate run --all --no-write',
134
135
  'canon upgrade --json',
136
+ 'canon upstream fetch --since 2.1.256 --json',
135
137
  ]
136
138
 
137
139
  // The frame follows stdout's TTY state rather than `palette`, whose blank
package/src/cli.ts CHANGED
@@ -41,6 +41,7 @@ import { register as autoship } from '@/commands/autoship'
41
41
  import { register as pr } from '@/commands/pr'
42
42
  import { register as repo } from '@/commands/repo'
43
43
  import { register as census } from '@/commands/census'
44
+ import { register as upstream } from '@/commands/upstream'
44
45
  import { register as targets } from '@/commands/targets'
45
46
  import { register as upgrade } from '@/commands/upgrade'
46
47
  import { readInstalled, UNKNOWN_LABEL } from '@/version/installed'
@@ -102,6 +103,7 @@ autoship(program)
102
103
  pr(program)
103
104
  repo(program)
104
105
  census(program)
106
+ upstream(program)
105
107
  audits(program)
106
108
  gate(program)
107
109
  upgrade(program)
@@ -296,7 +296,7 @@ export function register(program: Command): void {
296
296
  ' 1 refused, with the reason on stderr',
297
297
  '',
298
298
  'Reports rather than gates. Whether a bullet belongs in a rule is the',
299
- 'judgment 592-claude-md states, and naming a path is evidence for it',
299
+ 'judgment canon/context/context-model/overview.md states, and naming a path is evidence for it',
300
300
  'rather than the answer.',
301
301
  '',
302
302
  'Examples:',
@@ -239,8 +239,8 @@ export function register(program: Command): void {
239
239
  'after',
240
240
  [
241
241
  '',
242
- 'Measures the rule in .claude/rules/canon/claude/567-planning.md that asks for',
243
- 'the test before the code. It reports and never gates, because pairing',
242
+ 'Measures the test-first order the test-first skill and auto-ship hold: the',
243
+ 'test before the code. It reports and never gates, because pairing',
244
244
  'a test to an implementation is a judgment.',
245
245
  '',
246
246
  'Coverage:',
@@ -57,6 +57,7 @@ import {
57
57
  plural,
58
58
  } from '@/cli/ui'
59
59
  import { currentWorktreeRoot, mainWorktreeRoot } from '@/git/worktree'
60
+ import { readLivePlans, stackFindings } from '@/tasks/stack'
60
61
 
61
62
  /** Returned when a record carries a finding, which is the gating result. */
62
63
  const EXIT_FINDINGS = 2
@@ -911,7 +912,24 @@ async function runValidate(
911
912
 
912
913
  const root = opts.root ?? (await defaultRoot(kind))
913
914
 
914
- return report(await validateRecords(root, kind), emitJson, root)
915
+ return report(await validateWithStacks(root, kind), emitJson, root)
916
+ }
917
+
918
+ /**
919
+ * Appends the one plans finding that reads across files. It sits in the command
920
+ * layer because `src/records/` sits below `src/tasks/`, and `migrate` iterates
921
+ * `validateRecords` alone, so it never meets a finding with no safe repair.
922
+ */
923
+ async function validateWithStacks(
924
+ root: string,
925
+ kind: RecordKind,
926
+ ): Promise<ValidateOutcome> {
927
+ const outcome = await validateRecords(root, kind)
928
+ if (!outcome.ok || kind !== 'plans') return outcome
929
+
930
+ const stacked = stackFindings(await readLivePlans(root))
931
+
932
+ return { ...outcome, findings: [...outcome.findings, ...stacked] }
915
933
  }
916
934
 
917
935
  function report(
@@ -0,0 +1,295 @@
1
+ import { resolve } from 'node:path'
2
+ import type { Command } from 'commander'
3
+ import { intro, logInfo, logStep, logWarn, outro, plural } from '@/cli/ui'
4
+ import { currentWorktreeRoot, mainWorktreeRoot } from '@/git/worktree'
5
+ import { buildCatalog, renderCatalog } from '@/upstream/catalog'
6
+ import {
7
+ advanceCursor,
8
+ llmsDifference,
9
+ readCursor,
10
+ readLastLlms,
11
+ } from '@/upstream/cursor'
12
+ import { buildDigest } from '@/upstream/digest'
13
+ import {
14
+ githubPageFetcher,
15
+ grepHint,
16
+ type FetchFailure,
17
+ } from '@/upstream/releases'
18
+
19
+ const LLMS_URL = 'https://code.claude.com/docs/llms.txt'
20
+ const VERSION = /^\d+(\.\d+)*$/
21
+
22
+ interface RootOption {
23
+ readonly root?: string
24
+ readonly json?: boolean
25
+ }
26
+
27
+ interface FetchOptions extends RootOption {
28
+ readonly since?: string
29
+ }
30
+
31
+ interface AdvanceOptions extends RootOption {
32
+ readonly intake: string
33
+ readonly llms: boolean
34
+ }
35
+
36
+ type Refusal =
37
+ | 'no-cursor'
38
+ | 'bad-since'
39
+ | 'cursor-missing'
40
+ | FetchFailure
41
+ | 'older-than-cursor'
42
+ | 'invalid-version'
43
+
44
+ const REFUSALS: Record<Refusal, string> = {
45
+ 'no-cursor':
46
+ 'No cursor is stored, so there is no range to read. Pass --since <version> with the last version a digest covered.',
47
+ 'bad-since': 'The --since value is not a dotted version such as 2.1.256.',
48
+ 'cursor-missing':
49
+ 'The releases feed ran out before reaching a release at or below the cursor, so the range cannot be bounded. Pass --since with a more recent version.',
50
+ 'rate-limited':
51
+ 'GitHub refused the request. Set GH_TOKEN or GITHUB_TOKEN to lift the hourly limit.',
52
+ network: 'The releases feed could not be reached.',
53
+ http: 'GitHub answered with an error for the releases feed.',
54
+ 'older-than-cursor':
55
+ 'The version is older than the stored cursor, and the cursor never moves back.',
56
+ 'invalid-version': 'The version is not a dotted number such as 2.1.289.',
57
+ }
58
+
59
+ export function register(program: Command): void {
60
+ const upstream = program
61
+ .command('upstream')
62
+ .description(
63
+ 'Read Claude Code releases against the toolkit (fetch, catalog, advance)',
64
+ )
65
+ .helpOption('-h, --help', 'Show this help message')
66
+ .addHelpText(
67
+ 'after',
68
+ [
69
+ '',
70
+ 'Ships in the published CLI as `canon gate` and `canon sandbox` do, but',
71
+ 'only the toolkit repository has the skill that drives it, so a target',
72
+ 'has no use for these verbs.',
73
+ '',
74
+ ].join('\n'),
75
+ )
76
+
77
+ upstream
78
+ .command('fetch')
79
+ .description(
80
+ 'List the release lines since the cursor, with the mechanical noise dropped',
81
+ )
82
+ .helpOption('-h, --help', 'Show this help message')
83
+ .option('--since <version>', 'Read from this version instead of the cursor')
84
+ .option('--root <path>', 'Repository to read, defaulting to the cwd')
85
+ .option('--json', 'Add a machine-readable record on stdout')
86
+ .addHelpText(
87
+ 'after',
88
+ [
89
+ '',
90
+ 'Writes nothing. The cursor moves only through `upstream advance`, so a',
91
+ 'run that dies midway reads the same range next time.',
92
+ '',
93
+ 'Exit codes:',
94
+ ' 0 the range was read, possibly empty',
95
+ ' 1 refused, with the reason on stderr or in the JSON record',
96
+ '',
97
+ 'Examples:',
98
+ ' canon upstream fetch --json',
99
+ ' canon upstream fetch --since 2.1.256 --json',
100
+ '',
101
+ ].join('\n'),
102
+ )
103
+ .action(async (opts: FetchOptions) => {
104
+ process.exitCode = await runFetch(opts)
105
+ })
106
+
107
+ upstream
108
+ .command('catalog')
109
+ .description("List the toolkit's own mechanisms, one line each")
110
+ .helpOption('-h, --help', 'Show this help message')
111
+ .option('--root <path>', 'Repository to read, defaulting to the cwd')
112
+ .option('--json', 'Emit the sections as a JSON record instead of text')
113
+ .addHelpText(
114
+ 'after',
115
+ [
116
+ '',
117
+ 'Writes nothing. Skills, verbs, hooks, and gate stages are read from the',
118
+ 'tree, so the list cannot drift from it. A row that exists but cannot be',
119
+ 'described is named under Gaps.',
120
+ '',
121
+ 'Examples:',
122
+ ' canon upstream catalog',
123
+ ' canon upstream catalog --json',
124
+ '',
125
+ ].join('\n'),
126
+ )
127
+ .action(async (opts: RootOption) => {
128
+ process.exitCode = await runCatalog(opts)
129
+ })
130
+
131
+ upstream
132
+ .command('advance')
133
+ .description('Move the cursor to a version once its digest is filed')
134
+ .helpOption('-h, --help', 'Show this help message')
135
+ .argument('<version>', 'Last version the digest covered')
136
+ .requiredOption('--intake <slug>', 'Intake folder the digest was filed in')
137
+ .option('--no-llms', 'Keep the stored llms.txt rather than fetching it')
138
+ .option('--root <path>', 'Repository to write, defaulting to the cwd')
139
+ .option('--json', 'Add a machine-readable record on stdout')
140
+ .addHelpText(
141
+ 'after',
142
+ [
143
+ '',
144
+ 'Writes the cursor and the current llms.txt under the upstream record',
145
+ 'folder through a temp file and a rename. It refuses a version older than',
146
+ 'the stored cursor.',
147
+ '',
148
+ 'Exit codes:',
149
+ ' 0 the cursor moved',
150
+ ' 1 refused, with the reason on stderr or in the JSON record',
151
+ '',
152
+ 'Examples:',
153
+ ' canon upstream advance 2.1.289 --intake claude-code-2-1-257-to-2-1-289',
154
+ '',
155
+ ].join('\n'),
156
+ )
157
+ .action(async (version: string, opts: AdvanceOptions) => {
158
+ process.exitCode = await runAdvance(version, opts)
159
+ })
160
+ }
161
+
162
+ function refuse(
163
+ reason: Refusal,
164
+ extra: Record<string, unknown>,
165
+ emitJson: boolean,
166
+ ): number {
167
+ logStep('Refused')
168
+ logWarn(REFUSALS[reason])
169
+ outro()
170
+
171
+ if (emitJson) {
172
+ process.stdout.write(
173
+ `${JSON.stringify({ ...extra, reason, message: REFUSALS[reason] })}\n`,
174
+ )
175
+ }
176
+
177
+ return 1
178
+ }
179
+
180
+ async function fetchLlms(): Promise<string | null> {
181
+ try {
182
+ const response = await fetch(LLMS_URL)
183
+
184
+ return response.ok ? await response.text() : null
185
+ } catch {
186
+ return null
187
+ }
188
+ }
189
+
190
+ async function runFetch(opts: FetchOptions): Promise<number> {
191
+ const emitJson = opts.json ?? false
192
+ const cursorRoot = opts.root ? resolve(opts.root) : await mainWorktreeRoot()
193
+ const readRoot = opts.root ? resolve(opts.root) : await currentWorktreeRoot()
194
+
195
+ intro('canon upstream fetch')
196
+
197
+ if (opts.since !== undefined && !VERSION.test(opts.since)) {
198
+ return refuse('bad-since', { since: opts.since }, emitJson)
199
+ }
200
+ const cursor = opts.since ?? readCursor(cursorRoot)?.version
201
+ if (!cursor) return refuse('no-cursor', {}, emitJson)
202
+
203
+ const digest = await buildDigest(cursor, githubPageFetcher(), (name) =>
204
+ grepHint(readRoot, name),
205
+ )
206
+ if (digest.kind === 'failed') {
207
+ return refuse(digest.reason, {}, emitJson)
208
+ }
209
+ if (digest.kind === 'cursor-missing') {
210
+ return refuse('cursor-missing', { cursor: digest.cursor }, emitJson)
211
+ }
212
+
213
+ const llms = await fetchLlms()
214
+ const difference =
215
+ llms === null ? null : llmsDifference(readLastLlms(cursorRoot), llms)
216
+
217
+ logStep('Range')
218
+ logInfo(
219
+ `${digest.from} to ${digest.to}, ${plural(digest.releases, 'release')}, ${plural(digest.lines.length, 'line')} kept`,
220
+ )
221
+ if (difference === null) logWarn('llms.txt could not be fetched')
222
+ outro()
223
+
224
+ if (!emitJson) {
225
+ for (const line of digest.lines) {
226
+ process.stdout.write(`${line.version}\t${line.text}\n`)
227
+ }
228
+ } else {
229
+ process.stdout.write(
230
+ `${JSON.stringify({
231
+ from: digest.from,
232
+ to: digest.to,
233
+ releases: digest.releases,
234
+ lines: digest.lines,
235
+ llms: difference,
236
+ })}\n`,
237
+ )
238
+ }
239
+
240
+ return 0
241
+ }
242
+
243
+ async function runCatalog(opts: RootOption): Promise<number> {
244
+ const root = opts.root ? resolve(opts.root) : await currentWorktreeRoot()
245
+ const catalog = buildCatalog(root)
246
+
247
+ if (opts.json) {
248
+ process.stdout.write(`${JSON.stringify(catalog)}\n`)
249
+ } else {
250
+ process.stdout.write(`${renderCatalog(catalog)}\n`)
251
+ }
252
+
253
+ return 0
254
+ }
255
+
256
+ async function runAdvance(
257
+ version: string,
258
+ opts: AdvanceOptions,
259
+ ): Promise<number> {
260
+ const emitJson = opts.json ?? false
261
+ const root = opts.root ? resolve(opts.root) : await mainWorktreeRoot()
262
+
263
+ intro('canon upstream advance')
264
+
265
+ const llms = opts.llms ? await fetchLlms() : null
266
+ const outcome = await advanceCursor(root, {
267
+ version,
268
+ intake: opts.intake,
269
+ today: new Date().toISOString().slice(0, 10),
270
+ llms,
271
+ })
272
+ if (outcome.kind === 'refused') {
273
+ return refuse(outcome.reason, { version }, emitJson)
274
+ }
275
+
276
+ logStep('Cursor')
277
+ logInfo(`Moved to ${version}, filed as ${opts.intake}`)
278
+ if (opts.llms && llms === null) {
279
+ logWarn('llms.txt could not be fetched, so the stored copy is unchanged')
280
+ }
281
+ outro()
282
+
283
+ if (emitJson) {
284
+ process.stdout.write(
285
+ `${JSON.stringify({
286
+ version,
287
+ intake: opts.intake,
288
+ advanced: true,
289
+ llms: llms === null ? 'kept' : 'stored',
290
+ })}\n`,
291
+ )
292
+ }
293
+
294
+ return 0
295
+ }
@@ -166,7 +166,7 @@ const SENTENCE_END = /[.!?:;]\s(?=[^.!?:;]*$)/
166
166
  * entry types while a rule about what an entry may say does not.
167
167
  *
168
168
  * What gates here is a rule only this standard states. Bullet weight does not,
169
- * since `standards/markdown.md` owns that checkpoint across document types and
169
+ * since `claude/skills/markdown-craft/references/markdown.md` owns that checkpoint across document types and
170
170
  * its remedy sends the overflow to prose, which any entry type can act on.
171
171
  * `standards/context.md` specializes that remedy for an entry carrying
172
172
  * decisions, and specializing a rule narrows the advice rather than the measure.
@@ -0,0 +1,18 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import { FAVICON_COLORS, renderFavicon } from '@/design/favicon'
4
+ import { PROJECT_ROOT } from '@/roots/project'
5
+
6
+ const BRAND_MARK = 'assets/brand/mark.svg'
7
+
8
+ /**
9
+ * The brand icon, built from the mark and the favicon's own pair, the same two
10
+ * inputs `web/public/favicon.svg` is generated from. Every surface that shows
11
+ * the icon reads it here rather than keeping a copy that drifts from the mark.
12
+ * It sits apart from `favicon.ts` because the card build imports that module
13
+ * through an `@` alias pointed at `web/src`, so it must import nothing.
14
+ */
15
+ export function brandFavicon(): string {
16
+ const mark = readFileSync(join(PROJECT_ROOT, BRAND_MARK), 'utf8')
17
+ return renderFavicon(mark, FAVICON_COLORS)
18
+ }
@@ -110,7 +110,6 @@ export const TEST_CORPORA_PATTERNS = [
110
110
  '^governance/rules/',
111
111
  '^\\.claude/hooks/',
112
112
  '^tooling/claude/seeds/\\.claude/hooks/',
113
- '^standards/markdown\\.md$',
114
113
  '^tooling/base/reference\\.md$',
115
114
  '^tooling/web/configs/scripts/worktree-port\\.sh$',
116
115
  ]