@erclx/canon 5.50.0 → 5.52.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 +4037 -0
  2. package/claude/.claude-plugin/plugin.json +1 -1
  3. package/claude/skills/auto-ship/references/fan-in.md +20 -6
  4. package/claude/skills/canon-cli/SKILL.md +9 -7
  5. package/claude/skills/ci-workflow/REQUIREMENT.md +3 -3
  6. package/claude/skills/ci-workflow/SKILL.md +26 -1
  7. package/claude/skills/ci-workflow/references/workflows.md +28 -0
  8. package/claude/skills/design-record/references/folder.md +1 -1
  9. package/claude/skills/design-taste/SKILL.md +2 -1
  10. package/claude/skills/git-followup/SKILL.md +1 -0
  11. package/claude/skills/git-pr/references/pr.md +2 -2
  12. package/claude/skills/plan-feature/references/batches.md +1 -1
  13. package/claude/skills/review-address/SKILL.md +1 -1
  14. package/claude/skills/review-address/references/post-review-findings.md +1 -1
  15. package/claude/skills/review-pr/references/post.md +2 -2
  16. package/claude/skills/target-check/SKILL.md +2 -0
  17. package/claude/skills/target-check/references/domains.md +8 -6
  18. package/claude/skills/target-setup/SKILL.md +1 -1
  19. package/claude/skills/target-update/REQUIREMENT.md +33 -0
  20. package/claude/skills/target-update/SKILL.md +136 -0
  21. package/docs/agents/index.md +1 -1
  22. package/docs/agents/migrate.md +3 -0
  23. package/docs/agents/targets.md +8 -1
  24. package/docs/target/migrations.md +3 -1
  25. package/docs/target/projects.md +1 -1
  26. package/docs/target/sync.md +1 -1
  27. package/docs/workflow/parallel-features.md +1 -1
  28. package/docs/workflow/skill-map.md +1 -0
  29. package/governance/detect.toml +1 -1
  30. package/governance/ledger/tooling/ci-workflow.md +6 -0
  31. package/governance/ledger/ui/design-reference.md +8 -13
  32. package/governance/rules/tooling/ci-workflow.md +4 -0
  33. package/governance/rules/ui/design-reference.md +3 -6
  34. package/package.json +2 -1
  35. package/scripts/ci/check-browsers.sh +13 -0
  36. package/scripts/ci/delete-previews.sh +34 -0
  37. package/scripts/ci/publish-check.sh +29 -0
  38. package/scripts/ci/refresh-frames-pr.sh +63 -0
  39. package/scripts/ci/report-baseline.sh +23 -0
  40. package/scripts/ci/stack-issues.sh +24 -0
  41. package/scripts/ci/verify-npm-token.sh +14 -0
  42. package/src/claude/cases/setup.ts +7 -0
  43. package/src/cli/help.ts +1 -0
  44. package/src/cli.ts +2 -0
  45. package/src/commands/changelog.ts +146 -0
  46. package/src/commands/hooks.ts +7 -5
  47. package/src/commands/labels.ts +9 -7
  48. package/src/commands/migrate.ts +77 -0
  49. package/src/commands/worktrees.ts +273 -0
  50. package/src/gov/detect.ts +28 -10
  51. package/src/hooks/post-merge.ts +45 -6
  52. package/src/migrate/status.ts +132 -0
  53. package/src/version/changelog.ts +156 -0
  54. package/src/worktrees/members.ts +540 -0
  55. package/tooling/base/configs/.husky/post-merge +7 -6
  56. package/tooling/claude/seeds/.claude/hooks/review-post-scan.sh +1 -1
  57. package/tooling/cloudflare/configs/.github/workflows/deploy.yml +10 -28
  58. package/tooling/cloudflare/configs/scripts/delete-previews.sh +36 -0
  59. package/tooling/cloudflare/reference.md +7 -4
  60. package/tooling/web/reference.md +5 -0
  61. package/tooling/web/seeds/canon/design/index.md +23 -0
@@ -22,6 +22,12 @@ The field is continuous integration on GitHub Actions, and the authority is GitH
22
22
  | Skip a fork's pull request | [Workflow syntax, permissions](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax): "if the workflow was triggered by a pull request event other than `pull_request_target` from a forked repository, and the Send write tokens to workflows from pull requests setting is not selected, the permissions are adjusted to change any write permissions to read only" | 2026-10-18 |
23
23
  | Never commit back to the trunk | House choice: a workflow pushing onto the trunk lands a change no pull request carried and no reviewer read | 2026-10-18 |
24
24
 
25
+ ## Steps
26
+
27
+ | Bullet | Source | Read |
28
+ | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- |
29
+ | Long steps call a script, 5 max | House choice, from [issue 2531](https://github.com/erclx/canon/issues/2531): a 33-line publish check copied out of a workflow carried a program no laptop or shellcheck could run. [Workflow syntax, run](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax) documents `run` as a command run in the shell, with no limit on its size. One guard stays inline, since a placeholder or secrets guard of three lines gains nothing from a script | 2026-10-21 |
30
+
25
31
  ## Token scopes
26
32
 
27
33
  | Bullet | Source | Read |
@@ -9,13 +9,14 @@ The rule states canon's own design handoff, generalized from Frameboard's projec
9
9
 
10
10
  ## Where the design lives
11
11
 
12
- | Bullet | Source | Read |
13
- | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- |
14
- | Build against the tracked copy | House choice: a canvas changes under the build with no history, so a reviewer cannot tell which version a surface was built against | 2026-10-18 |
15
- | Load the keeper skill | House choice: the rule governs an edit and carries no procedure, so a session without the skill copies frames in by hand or skips the index row | 2026-10-21 |
16
- | Land a design first | House choice: a design landing inside a feature pull request is reviewed as code, and a second feature building against it meanwhile reads a design nobody accepted | 2026-10-18 |
17
- | No hand edits | House choice: a reference edited by hand drifts from its canvas source, and the next sync overwrites the edit or reports it as a change nobody made | 2026-10-18 |
18
- | Index row before the sync | House choice: the sync writes only the surfaces the index names, so a missing row drops the new surface's reference, and a stale row keeps a removed surface's | 2026-10-18 |
12
+ | Bullet | Source | Read |
13
+ | ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- |
14
+ | Build against the tracked copy | House choice: a canvas changes under the build with no history, so a reviewer cannot tell which version a surface was built against | 2026-10-18 |
15
+ | Load the keeper skill | House choice: the rule governs an edit and carries no procedure, so a session without the skill copies frames in by hand or skips the index row | 2026-10-21 |
16
+ | Land a design with its build | House choice: a design merged as its own pull request waits on a review that reads a picture with no build beside it, and the pick drifts before anything uses it | 2026-10-21 |
17
+ | No hand edits | House choice: a reference edited by hand drifts from its canvas source, and the next sync overwrites the edit or reports it as a change nobody made | 2026-10-18 |
18
+ | Index row before the sync | House choice: the sync writes only the surfaces the index names, so a missing row drops the new surface's reference | 2026-10-21 |
19
+ | Reference lifetime | House choice: a reference kept past its surface misleads the next build, and one redrawn without a design change hides a departure the review should see | 2026-10-21 |
19
20
 
20
21
  ## Building against a reference
21
22
 
@@ -32,12 +33,6 @@ The rule states canon's own design handoff, generalized from Frameboard's projec
32
33
  | Name each departure | House choice: an unnamed departure reads as an accident, and the next build against the same reference repeats or reverts it | 2026-10-18 |
33
34
  | Unexplained departure is a finding | House choice: without the finding, an unexplained departure merges as the new design with no one having chosen it | 2026-10-18 |
34
35
 
35
- ## Naming
36
-
37
- | Bullet | Source | Read |
38
- | ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | ---------- |
39
- | One folder per app part | House choice: a fixed folder and theme suffix let the sync and a reviewer find a surface's pair of references without reading an index | 2026-10-18 |
40
-
41
36
  ## Declined
42
37
 
43
38
  The rewrite kept every bullet. Each was already under 200 characters and states a step a reviewer can check against the pull request.
@@ -17,6 +17,10 @@ paths:
17
17
  - Skip a pull request from a fork with `github.event.pull_request.head.repo.full_name == github.repository`, since the default token cannot push to a fork's branch.
18
18
  - Never commit back to the trunk. A commit with no pull request and no reviewer needs an operator's decision, not a workflow's.
19
19
 
20
+ ## Steps
21
+
22
+ - Keep an inline `run:` step to five lines at most with one guard allowed and no other branching, loop, trap, or function, and call a script for anything longer, per the `canon:ci-workflow` skill.
23
+
20
24
  ## Token scopes
21
25
 
22
26
  - In a workflow with more than one job, add a write scope to the job that needs it, never to the workflow-level block, so every other job keeps the read-only token.
@@ -18,9 +18,10 @@ paths:
18
18
 
19
19
  - Build UI against `canon/design/`, never against the canvas directly.
20
20
  - Load the `canon:design-record` skill before adding, redrawing, or removing a reference under `canon/design/`, and report it rather than proceeding silently when it does not resolve.
21
- - Land a picked or changed canvas design in `canon/design/` through the project's design sync, as its own pull request, before any feature builds against it.
21
+ - Land a picked or changed design in `canon/design/` through the project's design sync, in the first commit of the branch that builds against it or with its design round, never as its own pull request.
22
22
  - Never copy, edit, or delete a reference file under `canon/design/` by hand.
23
- - Add a new surface's row to the index in `canon/design/` before running the sync, and remove it only in the change that removes the surface.
23
+ - Add a new surface's row to the index in `canon/design/` before running the sync.
24
+ - Keep a reference while its surface exists, redraw it only on an intentional design change, and remove it with its index row in the change that removes the surface.
24
25
 
25
26
  ## Building against a reference
26
27
 
@@ -32,7 +33,3 @@ paths:
32
33
  - Put each changed surface's reference image beside its built capture, light and dark, in the pull request description.
33
34
  - Name each place the build departs from its reference, with its reason, in the pull request description.
34
35
  - Report an unexplained departure from a reference as a review finding.
35
-
36
- ## Naming
37
-
38
- - Give each app part one plain kebab-case folder under `canon/design/`, and name each file `<name>-dark` or `<name>-light`.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@erclx/canon",
3
3
  "type": "module",
4
- "version": "5.50.0",
4
+ "version": "5.52.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": {
@@ -14,6 +14,7 @@
14
14
  "tooling",
15
15
  "claude",
16
16
  "docs",
17
+ "CHANGELOG.md",
17
18
  "tsconfig.json",
18
19
  "assets/brand/mark.svg",
19
20
  "!scripts/eval",
@@ -0,0 +1,13 @@
1
+ #!/usr/bin/env bash
2
+ # Fails when a Playwright browser named in the arguments is missing from the
3
+ # cache, called after the cache restore in pr-visual-checks.yml. A cache hit
4
+ # skips the install step, so a restore that came back empty would otherwise
5
+ # surface later as a launch error.
6
+ set -e
7
+
8
+ for browser in "$@"; do
9
+ if ! compgen -G "$HOME/.cache/ms-playwright/${browser}-*" >/dev/null; then
10
+ echo "Missing Playwright browser: ${browser}" >&2
11
+ exit 1
12
+ fi
13
+ done
@@ -0,0 +1,34 @@
1
+ #!/usr/bin/env bash
2
+ # Deletes the Cloudflare Pages preview deployments minted for one branch, called
3
+ # by the cleanup-preview job in deploy-site.yml with the Pages project name and
4
+ # the branch. CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID come from the step
5
+ # env.
6
+ #
7
+ # Deployments are listed through the REST API rather than wrangler, whose
8
+ # listing carries no branch filter. `force=true` is what lets a deployment
9
+ # still holding its branch alias be deleted.
10
+ #
11
+ # tooling/cloudflare/configs/scripts/delete-previews.sh is the seeded copy,
12
+ # which adds a skip for an absent secret. Keep the two otherwise the same.
13
+ set -e
14
+
15
+ PROJECT_NAME=$1
16
+ BRANCH=$2
17
+
18
+ api="https://api.cloudflare.com/client/v4/accounts/$CLOUDFLARE_ACCOUNT_ID/pages/projects/$PROJECT_NAME/deployments"
19
+ auth="Authorization: Bearer $CLOUDFLARE_API_TOKEN"
20
+ page=1
21
+ ids=""
22
+ while :; do
23
+ listing=$(curl -fsS -H "$auth" "$api?env=preview&per_page=25&page=$page")
24
+ [ "$(echo "$listing" | jq '.result | length')" -eq 0 ] && break
25
+ ids="$ids $(echo "$listing" | jq -r --arg branch "$BRANCH" '.result[] | select(.deployment_trigger.metadata.branch == $branch) | .id')"
26
+ page=$((page + 1))
27
+ done
28
+ deleted=0
29
+ for id in $ids; do
30
+ curl -fsS -X DELETE -H "$auth" "$api/$id?force=true" >/dev/null
31
+ echo "Deleted $id"
32
+ deleted=$((deleted + 1))
33
+ done
34
+ echo "Deleted $deleted preview deployments for $BRANCH."
@@ -0,0 +1,29 @@
1
+ #!/usr/bin/env bash
2
+ # Reports whether the newest GitHub release reached npm, called by the
3
+ # publish-check job in release-please.yml. The step passes GH_TOKEN and GH_REPO
4
+ # for gh. A release inside the publish job's 15-minute window passes, since its
5
+ # publish may still be running.
6
+ set -e
7
+
8
+ package=@erclx/canon
9
+
10
+ if ! release=$(gh release view --json tagName,publishedAt --jq '"\(.tagName) \(.publishedAt)"') || [ -z "$release" ]; then
11
+ echo "::error::Could not read the newest GitHub release, so whether it reached npm is unknown."
12
+ exit 1
13
+ fi
14
+ read -r tag published <<<"$release"
15
+ age=$(($(date +%s) - $(date -d "$published" +%s)))
16
+ if [ "$age" -lt 900 ]; then
17
+ echo "$tag is ${age}s old, inside the 15-minute window its publish job runs in."
18
+ exit 0
19
+ fi
20
+ # A missing version is an E404, so read the whole list to tell it from an outage
21
+ if ! versions=$(npm view "$package" versions --json); then
22
+ echo "::error::Could not read $package from npm, so whether $tag reached it is unknown."
23
+ exit 1
24
+ fi
25
+ if ! jq -e --arg v "${tag#v}" '[.] | flatten | index($v) != null' <<<"$versions" >/dev/null; then
26
+ echo "::error::$tag has a GitHub release but npm does not serve it. Publish it with: gh workflow run release-please.yml -f tag=$tag"
27
+ exit 1
28
+ fi
29
+ echo "$tag is on npm."
@@ -0,0 +1,63 @@
1
+ #!/usr/bin/env bash
2
+ # Commits the regenerated capture frames onto the refresh branch and opens or
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
5
+ # read off the workflow.
6
+ #
7
+ # Hooks stay off, since pre-push runs a check whose format step needs shfmt,
8
+ # which the runner lacks. verify.yml checks the pull request.
9
+ #
10
+ # The frames commit where they rendered, then only the files that commit wrote
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
13
+ # request resolves by number, since every merged refresh leaves a closed one on
14
+ # the same head.
15
+ set -e
16
+
17
+ branch=chore/refresh-capture-frames
18
+ title="chore(web): refresh capture frames"
19
+
20
+ if [ -z "$(git status --porcelain assets/captures assets/frames assets/evidence)" ]; then
21
+ echo "Frames are current, nothing to refresh."
22
+ exit 0
23
+ fi
24
+ git config user.name "github-actions[bot]"
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
27
+ git commit -m "$title"
28
+ rendered=$(git rev-parse HEAD)
29
+ written=$(git diff --name-only --no-renames --diff-filter=d HEAD~1 HEAD)
30
+ dropped=$(git diff --name-only --no-renames --diff-filter=D HEAD~1 HEAD)
31
+ git fetch --depth=1 origin +refs/heads/main:refs/remotes/origin/main
32
+ git checkout -B "$branch" origin/main
33
+ if [ -n "$written" ]; then
34
+ printf '%s\n' "$written" | xargs -d '\n' git checkout "$rendered" --
35
+ fi
36
+ if [ -n "$dropped" ]; then
37
+ printf '%s\n' "$dropped" | xargs -d '\n' git rm -q --ignore-unmatch --
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."
41
+ exit 0
42
+ fi
43
+ git commit -m "$title"
44
+ git push --force origin "$branch"
45
+ number=$(gh pr list --head "$branch" --state open --json number --jq '.[0].number')
46
+ if [ -z "$number" ]; then
47
+ gh pr create \
48
+ --base main \
49
+ --head "$branch" \
50
+ --title "$title" \
51
+ --body "## Summary
52
+
53
+ - Refreshes the capture frames from main so their counts and tokens stay current.
54
+
55
+ ## Key changes
56
+
57
+ - Regenerates the count and token bearing frames, their images, and their stamps from main.
58
+ - Recaptures the home page baseline under assets/evidence/home.
59
+ - Merges itself by squash once its required check passes."
60
+ number=$(gh pr list --head "$branch" --state open --json number --jq '.[0].number')
61
+ fi
62
+ # Every run, so a force-push from a later run leaves it queued either way
63
+ gh pr merge "$number" --auto --squash
@@ -0,0 +1,23 @@
1
+ #!/usr/bin/env bash
2
+ # Writes the step summary saying whether the captured landing page frames match
3
+ # the committed baseline, called by the capture job in pr-visual-checks.yml. A
4
+ # moved capture is expected when a catalog the page counts has moved, so it
5
+ # reports and never fails.
6
+ set -e
7
+
8
+ if git diff --quiet -- assets/evidence/home; then
9
+ echo "The captured frames match the committed baseline." >>"$GITHUB_STEP_SUMMARY"
10
+ exit 0
11
+ fi
12
+ {
13
+ echo "### The landing page capture moved"
14
+ echo
15
+ echo "These frames differ from the committed baseline. That is expected"
16
+ echo "when a catalog this page counts has moved, and it is not a failure."
17
+ echo
18
+ echo '```'
19
+ git diff --stat -- assets/evidence/home
20
+ echo '```'
21
+ echo
22
+ echo "Download the \`landing-page-capture\` artifact to view them."
23
+ } >>"$GITHUB_STEP_SUMMARY"
@@ -0,0 +1,24 @@
1
+ #!/usr/bin/env bash
2
+ # Opens, updates, or closes one issue per stack from the outcomes stack-verify.yml
3
+ # recorded under results/<stack>, called by its report job. Takes the stacks as
4
+ # arguments and reads GH_TOKEN, GH_REPO, and RUN_URL from the step env.
5
+ set -euo pipefail
6
+
7
+ for stack in "$@"; do
8
+ title="stack-verify: $stack"
9
+ # A job that died before recording counts as a failure
10
+ outcome=failure
11
+ [ -f "results/$stack" ] && outcome=$(cat "results/$stack")
12
+ number=$(gh issue list --state open --search "\"$title\" in:title" --json number,title \
13
+ --jq "[.[] | select(.title == \"$title\")][0].number // empty")
14
+ if [ "$outcome" = success ]; then
15
+ if [ -n "$number" ]; then
16
+ gh issue close "$number" --comment "Passing again in $RUN_URL"
17
+ fi
18
+ elif [ -n "$number" ]; then
19
+ gh issue comment "$number" --body "Still failing in $RUN_URL"
20
+ else
21
+ gh issue create --title "$title" \
22
+ --body "\`canon tooling verify $stack\` failed in $RUN_URL. The issue closes itself when a later run passes."
23
+ fi
24
+ done
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env bash
2
+ # Checks that the npm publish credential is set and authenticates, so a release
3
+ # is never cut it cannot publish. release-please.yml passes NPM_TOKEN from the
4
+ # step env, which keeps the secret named in the workflow.
5
+ set -e
6
+
7
+ if [ -z "$NPM_TOKEN" ]; then
8
+ echo "::error::NPM_TOKEN is not set. Add the secret before a release can be cut."
9
+ exit 1
10
+ fi
11
+ if ! curl -sf -H "Authorization: Bearer $NPM_TOKEN" https://registry.npmjs.org/-/whoami >/dev/null; then
12
+ echo "::error::NPM_TOKEN did not authenticate. It is likely expired or revoked."
13
+ exit 1
14
+ fi
@@ -66,6 +66,13 @@ export const SETUP_CASES: readonly SkillCase[] = [
66
66
  'Report what this project has fallen behind the toolkit on, domain by domain, and fix nothing.',
67
67
  expect: 'target-check',
68
68
  },
69
+ // The applying half of the same moment. It previews what the check would
70
+ // report and then writes, which is the line between the two.
71
+ {
72
+ prompt:
73
+ 'A new toolkit release is out, bring this project up to it and run whatever migrations it needs.',
74
+ expect: 'target-update',
75
+ },
69
76
  {
70
77
  prompt:
71
78
  "This project needs its own coding rule that the toolkit doesn't ship.",
package/src/cli/help.ts CHANGED
@@ -20,6 +20,7 @@ export const COMMAND_GROUPS: { title: string; rows: CommandRow[] }[] = [
20
20
  ['sync [path]', 'Sync all installed domains in a project'],
21
21
  ['upgrade', 'Reinstall the CLI with the manager that installed it'],
22
22
  ['migrate [cmd]', 'Move a project off a retired name or layout'],
23
+ ['changelog', 'Read the releases between a stamp and this binary'],
23
24
  ['targets [cmd]', 'Projects this toolkit installed into'],
24
25
  ],
25
26
  },
package/src/cli.ts CHANGED
@@ -40,6 +40,7 @@ import { register as census } from '@/commands/census'
40
40
  import { register as upstream } from '@/commands/upstream'
41
41
  import { register as targets } from '@/commands/targets'
42
42
  import { register as upgrade } from '@/commands/upgrade'
43
+ import { register as changelog } from '@/commands/changelog'
43
44
  import { readInstalled, UNKNOWN_LABEL } from '@/version/installed'
44
45
  import { renderHelp } from '@/cli/help'
45
46
  import { renderAll, routeHelp } from '@/cli/help-all'
@@ -100,6 +101,7 @@ upstream(program)
100
101
  audits(program)
101
102
  gate(program)
102
103
  upgrade(program)
104
+ changelog(program)
103
105
 
104
106
  const route = routeHelp(program, process.argv.slice(2))
105
107
  if (route.kind === 'top') showHelp()
@@ -0,0 +1,146 @@
1
+ import { existsSync, readFileSync } from 'node:fs'
2
+ import { join, resolve } from 'node:path'
3
+ import type { Command } from 'commander'
4
+ import { PROJECT_ROOT } from '@/roots/project'
5
+ import { readStamp } from '@/sync/stamp'
6
+ import {
7
+ intro,
8
+ logInfo,
9
+ logStep,
10
+ logWarn,
11
+ outro,
12
+ pipeOutput,
13
+ plural,
14
+ } from '@/cli/ui'
15
+ import { type ChangelogReport, resolveChangelog } from '@/version/changelog'
16
+ import { readInstalled, UNKNOWN_LABEL } from '@/version/installed'
17
+
18
+ interface ChangelogOptions {
19
+ readonly since?: string
20
+ readonly root?: string
21
+ readonly json?: boolean
22
+ }
23
+
24
+ const REFUSALS = {
25
+ 'no-stamp':
26
+ 'the target carries no stamped version and --since named none, so there is no start to range from',
27
+ 'no-changelog':
28
+ 'the installed package carries no CHANGELOG.md with release entries to read',
29
+ 'unknown-version':
30
+ 'the changelog lists no such release, so the range would be a guess',
31
+ } as const
32
+
33
+ const HELP = [
34
+ '',
35
+ 'Prints the release entries after a version, up to the running one, from the',
36
+ 'CHANGELOG.md the installed package ships. It reads no registry and needs no',
37
+ '`gh`, so it answers offline.',
38
+ '',
39
+ 'The start defaults to the oldest `version` across the target stamp domains,',
40
+ 'since the oldest domain is the one furthest behind. --since overrides it.',
41
+ '',
42
+ 'Exit codes:',
43
+ ' 0 the range was read, empty when the target is already current',
44
+ ' 1 refused, with the reason on stderr',
45
+ '',
46
+ 'A refusal names a "reason" rather than printing an empty range. no-stamp',
47
+ 'means no start was recorded or given, no-changelog means the file or its',
48
+ 'release headings are missing, and unknown-version means the start or the',
49
+ 'running version is absent from the file, which covers a pre-release and a',
50
+ 'start newer than the binary.',
51
+ '',
52
+ 'The JSON carries "since", "running", and "entries", each an object with the',
53
+ 'release "version" and its raw "markdown". Read the record rather than the',
54
+ 'exit when a skill consumes this, since a shell function wrapping the binary',
55
+ 'may flatten the status.',
56
+ '',
57
+ 'Examples:',
58
+ ' canon changelog',
59
+ ' canon changelog --since 5.47.0 --json',
60
+ ' canon changelog --root ../frameboard',
61
+ '',
62
+ ].join('\n')
63
+
64
+ export function register(program: Command): void {
65
+ program
66
+ .command('changelog')
67
+ .description(
68
+ 'Print the release entries after a version up to the running one',
69
+ )
70
+ .helpOption('-h, --help', 'Show this help message')
71
+ .option(
72
+ '--since <version>',
73
+ 'Start after this version instead of the stamp',
74
+ )
75
+ .option('--root <path>', 'Target whose stamp sets the start', '.')
76
+ .option('--json', 'Add a machine-readable record on stdout')
77
+ .addHelpText('after', HELP)
78
+ .action((opts: ChangelogOptions) => {
79
+ process.exitCode = runChangelog(opts)
80
+ })
81
+ }
82
+
83
+ function readChangelogText(): string | undefined {
84
+ const path = join(PROJECT_ROOT, 'CHANGELOG.md')
85
+
86
+ return existsSync(path) ? readFileSync(path, 'utf8') : undefined
87
+ }
88
+
89
+ function stampedVersions(root: string): string[] {
90
+ const stamp = readStamp(root)
91
+ if (stamp === undefined) return []
92
+
93
+ return Object.values(stamp.domains)
94
+ .map((domain) => domain?.version)
95
+ .filter((version): version is string => version !== undefined)
96
+ }
97
+
98
+ function runChangelog(opts: ChangelogOptions): number {
99
+ intro('canon changelog')
100
+
101
+ const root = resolve(opts.root ?? '.')
102
+ const report = resolveChangelog({
103
+ text: readChangelogText(),
104
+ running: readInstalled().version ?? UNKNOWN_LABEL,
105
+ since: opts.since,
106
+ stamped: stampedVersions(root),
107
+ })
108
+
109
+ if (report.kind === 'range') {
110
+ reportRange(report)
111
+ } else {
112
+ reportRefusal(report)
113
+ }
114
+ outro()
115
+
116
+ if (opts.json) {
117
+ process.stdout.write(`${JSON.stringify(report)}\n`)
118
+ }
119
+
120
+ return report.kind === 'range' ? 0 : 1
121
+ }
122
+
123
+ function reportRange(
124
+ report: Extract<ChangelogReport, { kind: 'range' }>,
125
+ ): void {
126
+ logStep('Range')
127
+ logInfo(`${report.since} to ${report.running}`)
128
+
129
+ if (report.entries.length === 0) {
130
+ logInfo('No release after the start, so nothing is pending')
131
+ return
132
+ }
133
+
134
+ logStep(plural(report.entries.length, 'release'))
135
+ for (const entry of report.entries) {
136
+ pipeOutput(entry.markdown)
137
+ }
138
+ }
139
+
140
+ function reportRefusal(
141
+ report: Extract<ChangelogReport, { kind: 'refused' }>,
142
+ ): void {
143
+ logStep('Refused')
144
+ const version = report.version === undefined ? '' : ` (${report.version})`
145
+ logWarn(`${report.reason}${version}: ${REFUSALS[report.reason]}`)
146
+ }
@@ -39,14 +39,16 @@ export function register(program: Command): void {
39
39
  'Runs these steps in order, each a child call reading its --json record:',
40
40
  ...Object.values(STEP_VERBS).map((verb) => ` canon ${verb.join(' ')}`),
41
41
  '',
42
- 'The archive runs once per pull request number the merge brought in.',
43
- 'Lines go to stderr, and an ordinary merge prints nothing.',
42
+ 'The archive and the member retirement run once per pull request',
43
+ 'number the merge brought in. Lines go to stderr, and an ordinary merge',
44
+ 'prints nothing.',
44
45
  '',
45
- 'The reclaim runs from the working directory with no root, so a pull',
46
- 'inside a linked worktree never removes the worktree it stands in.',
46
+ 'The member retirement and the reclaim run from the working directory',
47
+ 'with no root, so a pull inside a linked worktree never removes the',
48
+ 'worktree it stands in.',
47
49
  '',
48
50
  'Environment:',
49
- ' CANON_SKIP_RECLAIM=1 skip the worktree reclaim',
51
+ ' CANON_SKIP_RECLAIM=1 skip the member retirement and the reclaim',
50
52
  ' CANON_SKIP_UPGRADE=1 skip the CLI reinstall',
51
53
  ' CANON_SKIP_PLUGIN_UPDATE=1 skip the plugin update',
52
54
  '',
@@ -249,8 +249,8 @@ export function register(program: Command): void {
249
249
  '## Summary or ## Key changes, fails. A review, a release-please body,',
250
250
  'and a scan carrying no title skip this check.',
251
251
  '',
252
- 'It reports the density of that same body per section under `density`,',
253
- 'without failing on it: a bullet past 200 characters, more than 5 plain',
252
+ 'It fails the density of that same body, reported per section under',
253
+ '`density`: a bullet past 200 characters, more than 5 plain',
254
254
  'bullets or 2 paragraphs in a row, a paragraph past 350 characters, any',
255
255
  'H3 or deeper heading, and Technical context mixing bullets with',
256
256
  'paragraphs. A bold label alone on a line splits a Key changes run, a',
@@ -279,11 +279,12 @@ export function register(program: Command): void {
279
279
  'not read, as GitHub does not read it.',
280
280
  '',
281
281
  'Exit codes:',
282
- ' 0 none of the eight found',
282
+ ' 0 none of the nine found',
283
283
  ' 1 refused, with the reason on stderr or in the JSON record',
284
284
  ' 2 the title or body carries a phase label, a board identifier, a',
285
285
  ' session link, a banned character, a heading outside the closed',
286
- ' list or a required one missing, a missing or stray closing line',
286
+ ' list or a required one missing, a density finding in the body, a',
287
+ ' missing or stray closing line',
287
288
  ' under --closes, or a title',
288
289
  " breaking claude/skills/git-pr/references/pr.md's format, casing, or length",
289
290
  '',
@@ -718,9 +719,9 @@ async function runScan(opts: ScanOptions): Promise<number> {
718
719
  }
719
720
  }
720
721
 
721
- // Reported and never gated yet. New bodies come back clean under the
722
- // shape in pr.md first, so an older open pull request fails no edit event.
723
- // A post is never gated, since CI reads one only after it is published.
722
+ // A body's density fails the scan. A post's never does, since the review
723
+ // post hook denies on exit 2 and scans with no title, so a post-density
724
+ // fail would block every dense review or reply post.
724
725
  const post = !sectionsChecked && isPost(resolved.body)
725
726
  const density = sectionsChecked
726
727
  ? bodyDensity(resolved.body)
@@ -859,6 +860,7 @@ async function runScan(opts: ScanOptions): Promise<number> {
859
860
  titleFormatIssues.length === 0 &&
860
861
  sections.outside.length === 0 &&
861
862
  sections.missing.length === 0 &&
863
+ !(sectionsChecked && density.length > 0) &&
862
864
  banFindings.length === 0 &&
863
865
  closing.missing.length === 0 &&
864
866
  closing.stray.length === 0