@erclx/canon 4.108.0 → 4.109.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.
package/README.md CHANGED
@@ -1,8 +1,8 @@
1
1
  <div align="center">
2
2
 
3
3
  <picture>
4
- <source media="(prefers-color-scheme: dark)" srcset="assets/brand/mark-accent.svg">
5
- <img src="assets/brand/mark-accent-light.svg" alt="canon" width="56" height="56">
4
+ <source media="(prefers-color-scheme: dark)" srcset="assets/brand/mark-accent.svg">
5
+ <img src="assets/brand/mark-accent-light.svg" alt="canon" width="56" height="56">
6
6
  </picture>
7
7
 
8
8
  # canon
@@ -17,12 +17,7 @@ canon is a CLI and Claude Code plugin that stops your AI conventions drifting ap
17
17
 
18
18
  </div>
19
19
 
20
- <picture>
21
- <source media="(prefers-color-scheme: dark)" srcset="assets/evidence/arrival.png">
22
- <img src="assets/evidence/arrival-light.png" alt="The first screen of the canon landing page, a project before and after one canon init, with the file counts each step lays down">
23
- </picture>
24
-
25
- The page is one real session the toolkit ran on itself, and every count on it is read from the repo when the page builds.
20
+ That page is one real session the toolkit ran on itself, and every count on it is read from this repository when the page builds.
26
21
 
27
22
  ## Why
28
23
 
@@ -46,8 +41,8 @@ claude plugin install canon@canon
46
41
  ```
47
42
 
48
43
  <picture>
49
- <source media="(prefers-color-scheme: dark)" srcset="assets/evidence/install.png">
50
- <img src="assets/evidence/install-light.png" alt="Adding the canon marketplace and installing the plugin in Claude Code">
44
+ <source media="(prefers-color-scheme: dark)" srcset="assets/evidence/install.png">
45
+ <img src="assets/evidence/install-light.png" alt="Adding the canon marketplace and installing the plugin in Claude Code">
51
46
  </picture>
52
47
 
53
48
  The skills land as `/canon:<name>`. If your session was already open, run `/reload-plugins` to pick them up.
@@ -73,35 +68,21 @@ Each domain has a canonical source in this repo and a thin install or sync CLI o
73
68
  | Tooling stacks | Golden configs, seeds, and a reference per framework | Laid down by `canon init`, reconciled by `canon tooling sync` |
74
69
  | Design system | Ships a `DESIGN.md` token format, a skill that drafts one from an existing project or from scratch, and a render command | `canon design render` |
75
70
  | Slides | A `SLIDES.md` source format with a layout catalog | `canon slides render` writes PowerPoint |
71
+ | Teach | Lessons, sources, and a glossary in one workspace, rendered to a small site | `canon teach` |
76
72
  | Transcripts | A YouTube transcript with metadata frontmatter | `canon transcripts <url>` writes it into any repo |
77
73
  | Sandbox | Scenario scaffolds that provision an isolated project state for verifying each domain flow | `canon sandbox` |
78
74
 
79
- <picture>
80
- <source media="(prefers-color-scheme: dark)" srcset="assets/evidence/catalog.png">
81
- <img src="assets/evidence/catalog-light.png" alt="The canon catalog as a ledger of five rows, with the count each ships and a few real names: skills, rules, standards, commands, and stacks">
82
- </picture>
83
-
84
- Every count in that image is read from the catalogs when it is built, so they're what the repo ships today. A tooling stack lands as real files under version control, because a config is something your build reads and your project owns. A standard stays here and is opened by name, so there is no copy in your repo to drift from this one. Governance is the third shape. A rule with a path glob loads only when a matching path is edited, and a rule with none loads every session. Run `canon gov list` to see the glob beside each rule.
85
-
86
- Two of the outputs are documents you open rather than files you run. A `SLIDES.md` renders to a deck, and a learning workspace renders to a small site.
75
+ Those domains reach you in three shapes. A tooling stack lands as real files under version control, because a config is something your build reads and your project owns. A standard stays here and is opened by name, so there is no copy in your repo to drift from this one.
87
76
 
88
- <picture>
89
- <source media="(prefers-color-scheme: dark)" srcset="examples/slides/evidence/showcase-dark-04.png">
90
- <img src="examples/slides/evidence/showcase-light-04.png" alt="A slide from the showcase deck rendered by canon slides render, a bulleted list under a heading on a warm background">
91
- </picture>
92
-
93
- <picture>
94
- <source media="(prefers-color-scheme: dark)" srcset="examples/teach/evidence/root-listing.png">
95
- <img src="examples/teach/evidence/root-listing-light.png" alt="The learning workspace listing rendered by canon teach, three workspaces with their lesson and term counts, one still a stub">
96
- </picture>
77
+ Governance is the third shape. A rule with a path glob loads only when a matching path is edited, and a rule with none loads every session. Run `canon gov list` to see the glob beside each rule.
97
78
 
98
79
  ## It runs on itself
99
80
 
100
81
  The workflow this toolkit ships is the workflow that built it. Several Claude Code sessions run at once, each in its own git worktree on its own branch, and each opens its own pull request.
101
82
 
102
83
  <picture>
103
- <source media="(prefers-color-scheme: dark)" srcset="demos/agent-view.gif">
104
- <img src="demos/agent-view-light.gif" alt="The canon landing page scrolling from the dispatch section to the branch graph, three workers and a planner on disjoint file sets with the pull request each opened">
84
+ <source media="(prefers-color-scheme: dark)" srcset="demos/agent-view.gif">
85
+ <img src="demos/agent-view-light.gif" alt="The canon landing page scrolling from the dispatch section to the branch graph, three workers and a planner on disjoint file sets with the pull request each opened">
105
86
  </picture>
106
87
 
107
88
  That recording is the landing page's own `dispatch` and `workers` sections, driven by `canon demo run` against a local build. The branch graph beside the pull request numbers is authored rather than read, because nothing on a build machine records which files four sessions held.
@@ -110,6 +91,8 @@ That recording is the landing page's own `dispatch` and `workers` sections, driv
110
91
 
111
92
  Scaffolding your first project? Start with target projects, then the AI workflow loop. Everything else answers questions that arrive later.
112
93
 
94
+ Working with an agent rather than reading this yourself? It loads `CLAUDE.md` at your project root first, then whichever rules under `.claude/rules/` match the paths it edits. Run `canon docs agents` for the command catalog with flags, exit codes, and JSON shapes.
95
+
113
96
  - [AI workflow](docs/workflow/ai-workflow.md): feature-development loop inside a toolkit-managed project
114
97
  - [Operating model](docs/workflow/operating-model.md): orchestrator, planner, and worker roles for building across parallel sessions
115
98
  - [Visual design workflow](docs/workflow/visual-design-workflow.md): tiered guide for design and wireframe authoring
@@ -165,7 +148,9 @@ git init
165
148
  canon init
166
149
  ```
167
150
 
168
- `canon init` installs base tooling configs, Claude seeds, and governance rules in one pass, and scaffolds a `.claude/wiki/` stub for your project's own reference pages. Governance defaults to the `base` stack, so a bare init lands the coding and doc-authoring rules in `.claude/rules/`. Each rule names the standard it answers to and reads it with `canon standards <name>`, so no corpus is copied into your project. Pass `--stack <name>` for a framework stack, or `--skip governance` to leave rules out. A snippet resolves the same way, reached at its `@` reference through the plugin's live `claude/snippets` symlink rather than a copy. Run `canon tooling list --json` to see the catalog.
151
+ `canon init` installs base tooling configs, Claude seeds, and governance rules in one pass, and scaffolds a `.claude/wiki/` stub for your project's own reference pages. Governance defaults to the `base` stack, so a bare init lands the coding and doc-authoring rules in `.claude/rules/`. Each rule names the standard it answers to and reads it with `canon standards <name>`, so no corpus is copied into your project.
152
+
153
+ Pass `--stack <name>` for a framework stack, or `--skip governance` to leave rules out. A snippet resolves the same way, reached at its `@` reference through the plugin's live `claude/snippets` symlink rather than a copy. Run `canon tooling list --json` to see the catalog.
169
154
 
170
155
  For the full journey from scaffold through adding a domain later to syncing upstream drift, see [target projects](docs/target-projects.md).
171
156
 
@@ -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.108.0",
4
+ "version": "4.109.0",
5
5
  "author": {
6
6
  "name": "Eric Le",
7
7
  "url": "https://github.com/erclx"
@@ -31,7 +31,7 @@ Run `git diff --cached --name-only 2>/dev/null` to check for staged files. If ou
31
31
  6. Invoke `canon:git-stage` to group staged changes and commit by concern
32
32
  7. Invoke `canon:git-branch` to rename branch to match conventional format
33
33
  8. Invoke `canon:git-pr` to push branch and open pull request
34
- 9. After the PR opens, watch CI. Poll `canon pr checks <number> --json` until the record's `state` leaves `pending`, branching on that field rather than on the exit, and fall back to `gh pr checks <number>` when no record comes back at all, which is a target whose CLI predates the verb. On `passing`, continue. On `failing`, stop the sequence and report the failing check with its URL. Do not auto-fix. This step may output on failure, the one exception to the no-text-between-steps rule.
34
+ 9. After the PR opens, watch CI. Poll `canon pr checks <number> --json` until the record's `state` leaves `pending` or the record carries `conflicted: true`, branching on those fields rather than on the exit, and fall back to `gh pr checks <number>` when no record comes back at all, which is a target whose CLI predates the verb. On `passing`, continue. On `failing`, stop the sequence and report the failing check with its URL. On `conflicted`, stop the sequence and report that the branch conflicts with its base, since no run will start for it and polling on never ends. A rebase is the repair. Do not auto-fix. This step may output on failure, the one exception to the no-text-between-steps rule.
35
35
 
36
36
  A caller wrapping this sequence may act between step 8 and step 9, which is the one gap the order leaves open, since the pull request exists there and nothing has read its checks yet. `auto-ship` marks the pull request draft in it. Nothing else may go there, and a caller that needs a step anywhere else in the sequence is asking for a change to this body rather than for a place to stand.
37
37
 
@@ -30,7 +30,7 @@ Also read the CI check status so the fixes cover failing checks, not only review
30
30
  canon pr checks <number> --json
31
31
  ```
32
32
 
33
- Read the verdict off the record's `state` rather than off the exit. Treat `failing` as a finding to resolve alongside the review comments. A `pending` covers a tip whose runs have yet to conclude and a tip carrying no run at all, which the record separates on `matched`, and neither is a green to continue on. Fall back to `gh pr checks <number>` when no record comes back at all, which is a target whose CLI predates the verb.
33
+ Read the verdict off the record's `state` rather than off the exit. Treat `failing` as a finding to resolve alongside the review comments. A `pending` covers a tip whose runs have yet to conclude and a tip carrying no run at all, which the record separates on `matched`, and neither is a green to continue on. A record carrying `conflicted: true` is a third case: the branch conflicts with its base, so no run will start and the wait never ends. Stop and report it, since a rebase is the repair. Fall back to `gh pr checks <number>` when no record comes back at all, which is a target whose CLI predates the verb.
34
34
 
35
35
  ## Step 2: address each finding
36
36
 
@@ -170,8 +170,9 @@ echo "${comment_url##*issuecomment-}" > .canon/tmp/pr/reply/reply-<number>.id
170
170
  ## Step 7: confirm resolution
171
171
 
172
172
  After the follow-up push, watch CI on the PR. Poll
173
- `canon pr checks <number> --json` until the record's `state` leaves `pending`,
174
- then read it. When every finding is addressed and the state is `passing`, append
173
+ `canon pr checks <number> --json` until the record's `state` leaves `pending`
174
+ or it carries `conflicted: true`, then read it. On `conflicted`, stop and report
175
+ that a rebase is the repair rather than polling on. When every finding is addressed and the state is `passing`, append
175
176
  the closing confirmation to the reply file Step 6 already posted, so the thread
176
177
  carries one terminal state rather than a second comment under no heading:
177
178
 
@@ -18,7 +18,7 @@ Placing a row without checking for another writer collides the same way. Two ses
18
18
  - Resolve the board at the main worktree root, since a linked worktree writing to `pwd` creates a second board nothing reads
19
19
  - Read the tasks standard before writing, rather than working the filename and frontmatter from memory
20
20
  - Require an origin at creation, because that is the only moment the invariant is enforceable
21
- - Read the phase label from `canon tasks next-label` rather than proposing one by hand
21
+ - Claim the phase label with `canon tasks next-label --claim` rather than reading or proposing one by hand
22
22
  - Check the roster for a live orchestrator before writing a row, and hand off rather than write when one is found
23
23
  - Confirm the work reached the default branch before archiving
24
24
  - Run the archive command and route on the reason it refuses, since each reason has one resolution
@@ -29,13 +29,13 @@ Accept work whose origin is the conversation itself only when the user says so e
29
29
 
30
30
  ### Step 2: allocate the phase label
31
31
 
32
- Run `canon tasks next-label --json` and take its `label` field. The verb reads the live board and its `archive/` sibling together, so the label it returns accounts for what the board alone no longer shows.
32
+ Run `canon tasks next-label --claim --json` and take its `label` field. The verb reads the live board and its `archive/` sibling together, so the label it returns accounts for what the board alone no longer shows, and `--claim` reserves it under `.canon/ordinal-locks/` so a second session filing at the same moment gets the next one. Branch on the record's `reason` rather than the exit: `label-contended` means re-run the claim, and a usage error from an unknown `--claim` means the installed binary predates the flag, so report the verb as unavailable rather than falling back to a bare read, which reopens the race.
33
33
 
34
34
  Do not derive the label from a version file. `${CLAUDE_SKILL_DIR}/../../standards/versioning.md` permits free renumbering, so the verb's two folders are the only surface that knows what a label currently means.
35
35
 
36
36
  ### Step 3: write the file
37
37
 
38
- Write `.canon/tasks/vXX.Y-<slug>.md` following the format in `${CLAUDE_SKILL_DIR}/../../standards/tasks.md`. Include a link line only when the file or folder it names exists. A link to a plan nobody has written yet is the broken pointer the archive rules exist to prevent.
38
+ Write `.canon/tasks/vXX.Y-<slug>.md` under the claimed label, reading the file first if a session ever stubbed it, following the format in `${CLAUDE_SKILL_DIR}/../../standards/tasks.md`. Include a link line only when the file or folder it names exists. A link to a plan nobody has written yet is the broken pointer the archive rules exist to prevent.
39
39
 
40
40
  Write `Plan:`, `Groundwork:`, and `Intake:` as markdown links relative to `.canon/tasks/`, as in `Plan: [feature-<slug>](../plans/feature-<slug>.md)`. Leave `Issue:` a bare `#NNN`. A task written in the older bare-path form still parses, so it costs the board a clickable line rather than an archive, but it leaves the board in two shapes for every reader after.
41
41
 
@@ -18,11 +18,11 @@ canon capture https://example.com --selector .window --out preview.png
18
18
 
19
19
  ## What this repository captures
20
20
 
21
- A capture set here spans two folders. `assets/captures/` holds five sources and the three templates they are written from, and `assets/evidence/` holds their images and the built-page capture a document points at with the stamp answering for each, under the segment `canon pr evidence` compares. One run over the source folder rebuilds every image, and it takes `--out assets/evidence` to put each PNG where its document points rather than beside the markup it rendered. None of the sources is edited by hand. `scripts/core/regen-hero.sh` writes each `.html` from the template beside it, filling one shared value map into all of them. A branch commits none of them for a moved count or token, since the refresh workflow regenerates them from `main` and opens one pull request, and `bun run check` runs the script with `--check`, which fills every template into a temp folder and fails when the regeneration cannot run.
21
+ A capture set here spans two folders. `assets/captures/` holds three sources and the two templates they are written from, and `assets/evidence/` holds their images and the built-page capture a document points at with the stamp answering for each, under the segment `canon pr evidence` compares. One run over the source folder rebuilds every image, and it takes `--out assets/evidence` to put each PNG where its document points rather than beside the markup it rendered. None of the sources is edited by hand. `scripts/core/regen-hero.sh` writes each `.html` from the template beside it, filling one shared value map into all of them. A branch commits none of them for a moved count or token, since the refresh workflow regenerates them from `main` and opens one pull request, and `bun run check` runs the script with `--check`, which fills every template into a temp folder and fails when the regeneration cannot run.
22
22
 
23
- Two of the three templates take catalog data, so a stack gaining a rule moves the frame on the next run. `install.html` is the exception, since it holds terminal text from a real run in its template rather than derived from a live catalog at build time. `assets/evidence/arrival.png` is the one image with no source here: it is the built page's `.fold` element captured in dark mode against a local `web/` preview, since `canon capture` takes no color scheme and the page follows the browser's default of light, so it is retaken by hand when the page's first screen changes.
23
+ The `hero` template takes catalog data, so a stack gaining a rule moves the frame on the next run. `install.html` is the exception, since it holds terminal text from a real run in its template rather than derived from a live catalog at build time. `assets/evidence/arrival.png` is the one image with no source here: it is the built page's `.fold` element captured in dark mode against a local `web/` preview, since `canon capture` takes no color scheme and the page follows the browser's default of light, so it is retaken by hand when the page's first screen changes.
24
24
 
25
- The README shows each still in two themes through `<picture>`, so a frame is a pair named `<name>` and `<name>-light`. `catalog` and `install` take the light half from the same template, which `regen-hero.sh` fills a second time with the plain color roles pointed at the light ones, and each half is its own capture set with its own stamp. `arrival-light.png` is the same `.fold` capture of the same address taken without forcing dark, so it is retaken by hand alongside `arrival.png`, and nothing catches one drifting from the other.
25
+ The README shows each still in two themes through `<picture>`, so a frame is a pair named `<name>` and `<name>-light`. `install` takes the light half from its template, which `regen-hero.sh` fills a second time with the plain color roles pointed at the light ones, and each half is its own capture set with its own stamp. `arrival-light.png` is the same `.fold` capture of the same address taken without forcing dark, so it is retaken by hand alongside `arrival.png`, and nothing catches one drifting from the other.
26
26
 
27
27
  A stamp for a URL source digests the address string rather than the page it served, so two themes of one address carry one `source-sha256` and only `image-sha256` tells the frames apart. That holds for `examples/teach/evidence/root-listing-light.png`, which is retaken by serving a copy of the fixture with `data-theme="light"` on its `<html>` element at the same address, since the page follows the dark default and the command takes no scheme.
28
28
 
@@ -5,7 +5,7 @@ description: Every project-level command and every domain subcommand, plus the s
5
5
 
6
6
  # Command catalog
7
7
 
8
- Full help: `canon <command> --help`. Bare `canon --help` lists every top-level command under five headings, Project, Domains, Author and render, Session and ship, and Repo reports, with descriptions cut at 80 columns. Behavior notes for the install and sync verbs live in `install-and-sync.md`.
8
+ Full help: `canon <command> --help`. Bare `canon --help` lists every top-level command under five headings, Project, Domains, Author and render, Session and ship, and Repo reports, with descriptions cut at 80 columns. Piped into another command, the block prints without its frame glyphs and carries the same text. Behavior notes for the install and sync verbs live in `install-and-sync.md`.
9
9
 
10
10
  ## Project-level
11
11
 
@@ -92,7 +92,7 @@ Full help: `canon <command> --help`. Bare `canon --help` lists every top-level c
92
92
  | `canon autoship classify` | Decide whether a changed set needs the review pass, naming the file and the test that decided it (`--json`) |
93
93
  | `canon pr key-changes` | Compare the files a pull request body's Key Changes names against its own diff, in both directions (`--body`, `--base`, `--json`) |
94
94
  | `canon pr head` | Compare the head a pull request object reports against the branch tip the remote carries, naming both shas (`--root`, `--json`) |
95
- | `canon pr checks` | Report the check runs belonging to the branch tip, reading pending for a tip carrying none rather than clean (`--root`, `--json`) |
95
+ | `canon pr checks` | Report the check runs belonging to the branch tip, reading pending for a tip carrying none rather than clean, and `conflicted` when the branch conflicts with its base (`--root`, `--json`) |
96
96
  | `canon pr review-state` | Report the commit and instant the last review pass covered, off the pass's own marker rather than the fields GitHub stamps at submission (`--root`, `--json`) |
97
97
  | `canon pr evidence` | Render a before-and-after comment comparing changed `evidence/` images against the merge base, editing its own prior comment in place rather than posting a second (`--root`, `--json`, `--preview`) |
98
98
  | `canon pr preview` | Dispatch the Pages deploy on a pull request's branch and report the preview address the run prints, refusing a deploy that passes no `--branch` (`--root`, `--json`, `--timeout`) |
@@ -74,6 +74,8 @@ Both empty cases report `pending` instead: a tip carrying no run yet, and a list
74
74
 
75
75
  The verb keeps the newest run per check name rather than rolling every matched run into the state. A workflow that fires twice on one commit used to leave its first, stale run beside the rerun, where a failing one could outvote a passing rerun. Grouping the matched set by name and keeping the highest `id` per group removes that stale run before the rollup runs. `collapsed` on the record counts what the fold discards, so a caller reading it against `matched` sees the dedupe directly, instead of reading a `matched` above the check count as the verb disagreeing with `gh pr checks`.
76
76
 
77
+ A conflicted branch produces no `pull_request` check runs rather than a failing one. GitHub builds no merge ref for it, so no `pull_request` workflow starts and a repository running only that trigger leaves the listing empty, which reads as `pending` and never leaves it. A workflow triggered by a push to the branch still runs on the tip, so `matched` rises above zero, `conflicted` stays false, and the wait ends on those runs rather than on the reading. `mergeStateStatus` is the field that separates the two, and the record carries `conflicted: true` and `mergeState` when it reads `DIRTY` with no run belonging to the tip. A `DIRTY` reading beside runs on the tip leaves the state as those runs say, since a conflict arising after they completed does not unsay them. `UNKNOWN` is transient while GitHub computes the merge state, right after a push, so it reads as not conflicted and costs one more poll. A binary predating the field keeps answering `pending`.
78
+
77
79
  A listing carrying even one foreign run reports `pending` whatever the matching half says. A set that describes another commit says nothing about this one, so answering off the rows that happen to match would put a verdict on a set already known to be incomplete.
78
80
 
79
81
  A count above the rows returned reports `pending` for the same reason. The query asks for 100 rows against the endpoint's default of 30, and a commit carrying more than that comes back short, so the verb reports the tip as unread rather than collapsing the page it happens to hold. `reported` against `matched` and `foreign` is where a reader sees which of the three shapes produced the answer.
@@ -7,25 +7,27 @@ description: Selecting a shipped task by stem or pull request, recording a numbe
7
7
 
8
8
  ## Next label
9
9
 
10
- `canon tasks next-label` reports the next unused phase label, reading `.canon/tasks/` and its `archive/` sibling together. A scan confined to the live board is blind to every label the archive already spent, which is what let two sessions hand out the same label within minutes of each other. It reports and never writes.
10
+ `canon tasks next-label` reports the next unused phase label, reading `.canon/tasks/` and its `archive/` sibling together. A scan confined to the live board is blind to every label the archive already spent, which is what let two sessions hand out the same label within minutes of each other. Bare, it reports and never writes. With `--claim` it reserves the label first.
11
11
 
12
12
  ```bash
13
13
  canon tasks next-label
14
14
  canon tasks next-label --json
15
+ canon tasks next-label --claim --json
15
16
  ```
16
17
 
17
- | Option | Effect |
18
- | --------------- | ------------------------------------------- |
19
- | `--json` | Emit a machine-readable record on stdout |
20
- | `--root <path>` | Board root, defaulting to the main worktree |
18
+ | Option | Effect |
19
+ | --------------- | ------------------------------------------------ |
20
+ | `--json` | Emit a machine-readable record on stdout |
21
+ | `--claim` | Reserve the label so a second claim gets another |
22
+ | `--root <path>` | Board root, defaulting to the main worktree |
21
23
 
22
- The record carries `label`, the next free `vXX.Y`, and `highest`, the label it was derived from. `highest` is absent when neither folder carries a label yet, and `label` reads `v01.0` in that case, matching the zero-padded-major shape every other label already takes. <!-- canon-allow-reference: illustrates the verb's answer for a board holding no label yet, not a citation of a real task -->
24
+ The record carries `label`, the next free `vXX.Y`, `claimed`, whether the label was reserved, and `highest`, the label it was derived from. `highest` is absent when neither folder carries a label yet, and `label` reads `v01.0` in that case, matching the zero-padded-major shape every other label already takes. <!-- canon-allow-reference: illustrates the verb's answer for a board holding no label yet, not a citation of a real task -->
23
25
 
24
- Exit codes: `0` derived, `1` refused with `no-board`.
26
+ Exit codes: `0` derived, `1` refused with `no-board`, `2` refused with `label-contended` after five lost reservations, on `--claim` only.
25
27
 
26
28
  The minor digit rolls from 9 to 0 on the next major rather than growing a second digit, which is the single-digit-minor shape every phase label already takes. `canon tasks archive` moves a task's file from the live folder into the archive without renumbering it, so the same label counts toward the maximum wherever it currently sits, and a label claimed by two different files folds into the same scan without a dedicated check.
27
29
 
28
- It reports rather than gates. Two sessions calling it in the same second can still take the same answer, since the board is gitignored files rather than a store with a lock, and `standards/versioning.md` permits free renumbering, so a collision costs a rename rather than anything worse.
30
+ A claim is an exclusive `mkdir` at `.canon/ordinal-locks/<label>`, and the scan counts every reservation beside the task files, since the file lands after the verb returns. Reservations never expire, so a claim that died leaves a gap, which `standards/versioning.md` permits. A bare read still reports without reserving, and it does not count reservations either, so it can return a label a claim already holds until that claim's task file lands. Two bare reads in the same second can also take the same answer.
29
31
 
30
32
  ```bash
31
33
  canon tasks next-label --json | jq -r '.label'
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@erclx/canon",
3
3
  "type": "module",
4
- "version": "4.108.0",
4
+ "version": "4.109.0",
5
5
  "description": "Infrastructure and quality tooling for developer workflows",
6
6
  "license": "MIT",
7
7
  "bin": {
@@ -96,11 +96,8 @@ GOV_JSON="$(catalog gov list)"
96
96
  STANDARDS_JSON="$(catalog standards list)"
97
97
  SNIPPETS_JSON="$(catalog snippets list)"
98
98
  TOOLING_JSON="$(catalog tooling list)"
99
- # The catalog ledger names three commands, and the registered set is only
100
- # readable from the root help, which lists one per line under `Commands:`.
101
- COMMANDS_HELP="$(cd "$PROJECT_ROOT" && CANON_NON_INTERACTIVE=1 bun src/cli.ts --help 2>/dev/null)"
102
99
 
103
- for payload in "$COUNTS_JSON" "$SKILLS_JSON" "$GOV_JSON" "$STANDARDS_JSON" "$SNIPPETS_JSON" "$TOOLING_JSON" "$COMMANDS_HELP"; do
100
+ for payload in "$COUNTS_JSON" "$SKILLS_JSON" "$GOV_JSON" "$STANDARDS_JSON" "$SNIPPETS_JSON" "$TOOLING_JSON"; do
104
101
  if [ -z "$payload" ]; then
105
102
  echo "regen-hero: a catalog returned nothing, refusing to write a zeroed hero" >&2
106
103
  exit 1
@@ -120,7 +117,6 @@ printf '%s' "$GOV_JSON" >"$PAYLOAD_DIR/gov.json"
120
117
  printf '%s' "$STANDARDS_JSON" >"$PAYLOAD_DIR/standards.json"
121
118
  printf '%s' "$SNIPPETS_JSON" >"$PAYLOAD_DIR/snippets.json"
122
119
  printf '%s' "$TOOLING_JSON" >"$PAYLOAD_DIR/tooling.json"
123
- printf '%s' "$COMMANDS_HELP" >"$PAYLOAD_DIR/commands-help.txt"
124
120
  printf '%s' "$TOKEN_CSS" >"$PAYLOAD_DIR/tokens.css"
125
121
 
126
122
  export PAYLOAD_DIR
@@ -349,36 +345,8 @@ for (const [label, rows] of [
349
345
  }
350
346
  }
351
347
 
352
- // The ledger names a few real entries per domain. Chosen by hand for the same
353
- // reason FEATURED_SKILLS is, and asserted against the catalog so a rename fails
354
- // the run rather than leaving a name the repository no longer ships.
355
- const commandNames = readFileSync(PAYLOAD_DIR + "/commands-help.txt", "utf8")
356
- .split("\n")
357
- .map((line) => line.match(/^[│\s]{2,}(?<name>[a-z][a-z-]*)\s/)?.groups?.name)
358
- .filter(Boolean)
359
- const LEDGER = [
360
- ["skills", skills.length, ["plan-feature", "role-orchestrator", "review-pr"], skills],
361
- ["rules", rules.length, ["behavior", "memory", "testing"], rules],
362
- ["standards", standards.length, ["markdown", "plan", "teach"], standards],
363
- ["commands", commandCount, ["gov", "tasks", "design"], commandNames],
364
- ["stacks", toolingStacks.length, ["astro", "python", "web"], toolingStacks.map((stack) => stack.name)],
365
- ]
366
- for (const [domain, , chosen, names] of LEDGER) {
367
- const known = new Set(names)
368
- const missing = chosen.filter((name) => !known.has(name))
369
- if (missing.length > 0) {
370
- console.error(`regen-hero: ledger ${domain} names missing from the catalog: ${missing.join(", ")}`)
371
- process.exit(1)
372
- }
373
- }
374
- const ledgerRows = LEDGER.map(
375
- ([domain, count, chosen]) =>
376
- ` <div class="row"><span class="count">${count}</span><span class="domain">${domain}</span><span class="names">${escape(chosen.join(", "))}</span></div>`,
377
- ).join("\n")
378
-
379
348
  const values = {
380
349
  TOKENS: tokenCss,
381
- LEDGER_ROWS: ledgerRows,
382
350
  GOV_STACK_ROWS: govStackRows,
383
351
  GOV_RULE_ROWS: govRuleRows,
384
352
  STANDARD_ROWS: standardRows,
@@ -417,7 +385,7 @@ if (templates.length === 0) {
417
385
  // same template with the plain roles pointed at the light ones rather than a
418
386
  // second copy of any hex value. The hero is a landing-page frame no README
419
387
  // shows, so it stays dark.
420
- const LIGHT_TEMPLATES = new Set(["catalog.html.tmpl", "install.html.tmpl"])
388
+ const LIGHT_TEMPLATES = new Set(["install.html.tmpl"])
421
389
 
422
390
  const lightRoles = [...tokenCss.matchAll(/--color-light-([a-z-]+):/g)].map((match) => match[1])
423
391
  if (lightRoles.length === 0) {
package/src/cli.ts CHANGED
@@ -43,169 +43,10 @@ import { register as census } from '@/commands/census'
43
43
  import { register as targets } from '@/commands/targets'
44
44
  import { register as upgrade } from '@/commands/upgrade'
45
45
  import { readInstalled, UNKNOWN_LABEL } from '@/version/installed'
46
- import { palette } from '@/ui'
47
-
48
- const HELP_WIDTH = 80
49
- const SIGNATURE_WIDTH = 19
50
- const ROW_INDENT = '│ '.length
51
-
52
- type CommandRow = [signature: string, description: string]
53
-
54
- // The grouping mirrors the README's domain table and has no other source, so a
55
- // command added to the program needs a row here or help-option.test.ts fails.
56
- const COMMAND_GROUPS: { title: string; rows: CommandRow[] }[] = [
57
- {
58
- title: 'Project',
59
- rows: [
60
- ['init [path]', 'Bootstrap a project with toolkit domains'],
61
- ['sync [path]', 'Sync all installed domains in a project'],
62
- ['upgrade', 'Reinstall the CLI with the manager that installed it'],
63
- ['migrate [cmd]', 'Move a project off a retired name or layout'],
64
- ['targets [cmd]', 'Projects this toolkit installed into (list, pulls)'],
65
- ],
66
- },
67
- {
68
- title: 'Domains',
69
- rows: [
70
- ['gov [cmd]', 'Governance rules (install, sync, list)'],
71
- ['standards [cmd]', 'Standards (install, sync, list, <name>)'],
72
- ['snippets [cmd]', 'Prompt snippets (create, list)'],
73
- ['tooling [cmd]', 'Tooling stacks (sync, ref, create)'],
74
- ['claude [cmd]', 'Claude workflow (init, sync, setup)'],
75
- ['wiki [cmd]', 'Wiki pages (init)'],
76
- ['indexes [cmd]', 'Regenerate index.md files (regen)'],
77
- ['docs [cmd|topic]', 'Emit toolkit reference docs (list, <topic>)'],
78
- ],
79
- },
80
- {
81
- title: 'Author and render',
82
- rows: [
83
- ['design [cmd]', 'Design system (render, board)'],
84
- ['slides [cmd]', 'Slide decks (render, list)'],
85
- ['capture [source]', 'Render HTML capture sources to PNG'],
86
- ['serve [dir]', 'Serve a directory on localhost, print the link'],
87
- ['demo [cmd]', 'Record a running app (compile, run)'],
88
- ['inventory [subj]', 'Report one computed property across every route'],
89
- ['drive <url> <run>', 'Walk a page through interactions, measure each'],
90
- ['transcripts <url>', 'Fetch a YouTube transcript with frontmatter'],
91
- ['teach [cmd]', 'Learning workspaces (list, open, resource, glossary)'],
92
- ],
93
- },
94
- {
95
- title: 'Session and ship',
96
- rows: [
97
- ['sandbox [cat:cmd]', 'Provision and run sandbox scenarios'],
98
- ['tasks [cmd]', 'Task board (validate, archive)'],
99
- ['intake [cmd]', 'Intake folders under .canon/intake/ (list, answer)'],
100
- ['records [cmd]', 'Session records (validate, size, push, pull)'],
101
- ['sessions [cmd]', 'Resolve live sessions to worktree and branch'],
102
- ['worktrees [cmd]', 'Reclaim worktrees whose branches merged'],
103
- ['autoship [cmd]', 'Decide whether a changed set needs review'],
104
- ['pr [cmd]', 'Read a pull request (key-changes, head, checks)'],
105
- ['feedback', 'Write toolkit feedback from stdin'],
106
- ],
107
- },
108
- {
109
- title: 'Repo reports',
110
- rows: [
111
- ['audits [cmd]', 'Run every health check as one set (run, list)'],
112
- ['gate [cmd]', 'Run the merge gate stage by stage (run)'],
113
- ['secrets [cmd]', 'Scan the shipped tree for credentials (scan)'],
114
- ['deps [cmd]', 'Read the dependency set for advisories (audit)'],
115
- ['labels [cmd]', 'Read a changed set against the label map (audit)'],
116
- ['comments [cmd]', 'Measure comment density and trend (scan)'],
117
- ['context [cmd]', 'Report context folder health (audit)'],
118
- ['markdown [cmd]', 'Report markdown against the standards (audit)'],
119
- ['repo [cmd]', 'Remote metadata (metadata propose, apply)'],
120
- ['census [path]', 'Report file count, extensions, and line totals'],
121
- ],
122
- },
123
- ]
124
-
125
- // A row past the width is cut rather than wrapped, since a wrapped row breaks
126
- // the alignment the grouping exists to buy.
127
- function commandRow(signature: string, description: string): string {
128
- const { GREY, NC } = palette(process.stdout)
129
- const room = HELP_WIDTH - ROW_INDENT - SIGNATURE_WIDTH - '# '.length
130
- const text =
131
- description.length > room
132
- ? `${description.slice(0, room - 1)}…`
133
- : description
134
- return `${GREY}│${NC} ${signature.padEnd(SIGNATURE_WIDTH)}${GREY}# ${text}${NC}`
135
- }
46
+ import { renderHelp } from '@/help'
136
47
 
137
48
  function showHelp(): void {
138
- // The help text is the one framed surface written to stdout, so it asks
139
- // about that stream rather than the stderr every other writer here uses.
140
- const { GREY, NC, WHITE } = palette(process.stdout)
141
- const lines = [
142
- `${GREY}┌${NC}`,
143
- `${GREY}├${NC} ${WHITE}Usage:${NC} canon [command]`,
144
- `${GREY}│${NC}`,
145
- `${GREY}│${NC} ${WHITE}Commands:${NC}`,
146
- ...COMMAND_GROUPS.flatMap((group) => [
147
- `${GREY}│${NC}`,
148
- `${GREY}│${NC} ${WHITE}${group.title}${NC}`,
149
- ...group.rows.map(([signature, description]) =>
150
- commandRow(signature, description),
151
- ),
152
- ]),
153
- `${GREY}│${NC}`,
154
- `${GREY}│${NC} ${WHITE}Sandbox:${NC}`,
155
- `${GREY}│${NC} canon sandbox ${GREY}# Interactive scenario picker${NC}`,
156
- `${GREY}│${NC} canon sandbox git:commit ${GREY}# Run specific scenario${NC}`,
157
- `${GREY}│${NC} canon sandbox reset ${GREY}# Reset sandbox to baseline${NC}`,
158
- `${GREY}│${NC} canon sandbox clean ${GREY}# Wipe the sandbox${NC}`,
159
- `${GREY}│${NC}`,
160
- `${GREY}│${NC} ${WHITE}Examples:${NC}`,
161
- `${GREY}│${NC} canon sync ../my-app`,
162
- `${GREY}│${NC} canon sandbox git:commit`,
163
- `${GREY}│${NC} canon gov install react`,
164
- `${GREY}│${NC} canon gov sync ../my-app`,
165
- `${GREY}│${NC} canon gov restated --json`,
166
- `${GREY}│${NC} canon standards markdown`,
167
- `${GREY}│${NC} canon snippets list`,
168
- `${GREY}│${NC} canon init ../my-app`,
169
- `${GREY}│${NC} canon tooling sync base`,
170
- `${GREY}│${NC} canon tooling create`,
171
- `${GREY}│${NC} canon claude init`,
172
- `${GREY}│${NC} canon indexes regen`,
173
- `${GREY}│${NC} canon indexes regen --dry-run --json`,
174
- `${GREY}│${NC} canon docs list --json`,
175
- `${GREY}│${NC} canon docs agents`,
176
- `${GREY}│${NC} canon design render`,
177
- `${GREY}│${NC} canon design board`,
178
- `${GREY}│${NC} canon slides render`,
179
- `${GREY}│${NC} canon slides list --json`,
180
- `${GREY}│${NC} canon capture assets/captures/install.html --selector .window --out assets/evidence`,
181
- `${GREY}│${NC} canon serve .canon/teach`,
182
- `${GREY}│${NC} canon inventory focus --json`,
183
- `${GREY}│${NC} canon drive http://localhost:4173 run.json --json`,
184
- `${GREY}│${NC} pbpaste | canon feedback`,
185
- `${GREY}│${NC} canon transcripts https://youtu.be/VIDEO_ID`,
186
- `${GREY}│${NC} canon tasks archive --pull-request 673 --json`,
187
- `${GREY}│${NC} canon intake list toolkit-overview --unread --json`,
188
- `${GREY}│${NC} canon teach list --json`,
189
- `${GREY}│${NC} canon comments scan src --json`,
190
- `${GREY}│${NC} canon context audit --json`,
191
- `${GREY}│${NC} canon markdown audit .claude/rules --json`,
192
- `${GREY}│${NC} canon records validate plans`,
193
- `${GREY}│${NC} canon records size --json`,
194
- `${GREY}│${NC} canon records push --json`,
195
- `${GREY}│${NC} canon sessions list --json`,
196
- `${GREY}│${NC} canon worktrees list --json`,
197
- `${GREY}│${NC} canon worktrees reclaim --dry-run`,
198
- `${GREY}│${NC} canon secrets scan --json`,
199
- `${GREY}│${NC} canon deps audit --json`,
200
- `${GREY}│${NC} canon labels audit --json`,
201
- `${GREY}│${NC} canon repo metadata propose --json`,
202
- `${GREY}│${NC} canon census --json`,
203
- `${GREY}│${NC} canon audits run --json`,
204
- `${GREY}│${NC} canon gate run --all --no-write`,
205
- `${GREY}│${NC} canon upgrade --json`,
206
- `${GREY}└${NC}`,
207
- ]
208
- console.log(lines.join('\n'))
49
+ console.log(renderHelp(process.stdout))
209
50
  }
210
51
 
211
52
  const program = new Command()
@@ -20,7 +20,11 @@ import {
20
20
  compareKeyChanges,
21
21
  treeRoots,
22
22
  } from '@/pr/bijection'
23
- import { type CheckRunListing, collapseChecks } from '@/pr/checks'
23
+ import {
24
+ type CheckRunListing,
25
+ collapseChecks,
26
+ withMergeState,
27
+ } from '@/pr/checks'
24
28
  import {
25
29
  findEvidenceChecklist,
26
30
  findEvidenceCommentId,
@@ -307,6 +311,11 @@ export function register(program: Command): void {
307
311
  'an empty row list, and reading that as passing is the false green the',
308
312
  'sha key alone does not close.',
309
313
  '',
314
+ 'A branch conflicting with its base gets no merge ref, so no run ever',
315
+ 'starts for it and the state stays pending. The record carries',
316
+ '`conflicted: true` beside it when `mergeStateStatus` reads DIRTY and no',
317
+ 'run belongs to the tip, and a caller stops waiting and rebases.',
318
+ '',
310
319
  'Exit codes:',
311
320
  ' 0 the runs for the tip were read, whatever they say',
312
321
  ' 1 refused, with the reason on stderr or in the JSON record',
@@ -865,6 +874,7 @@ interface PullIdentity {
865
874
  readonly number: number | undefined
866
875
  readonly branch: string
867
876
  readonly head?: string
877
+ readonly mergeState?: string
868
878
  }
869
879
 
870
880
  type IdentityRead =
@@ -911,12 +921,17 @@ async function readIdentity(
911
921
 
912
922
  const args = ['pr', 'view']
913
923
  if (number !== undefined) args.push(number)
914
- args.push('--json', 'number,headRefName,headRefOid')
924
+ args.push('--json', 'number,headRefName,headRefOid,mergeStateStatus')
915
925
 
916
926
  const stdout = await gh(cwd, args)
917
927
  if (stdout === null) return { kind: 'refused', reason: 'gh-failed' }
918
928
 
919
- let row: { number?: number; headRefName?: string; headRefOid?: string }
929
+ let row: {
930
+ number?: number
931
+ headRefName?: string
932
+ headRefOid?: string
933
+ mergeStateStatus?: string
934
+ }
920
935
  try {
921
936
  row = JSON.parse(stdout)
922
937
  } catch {
@@ -933,6 +948,9 @@ async function readIdentity(
933
948
  number: row.number,
934
949
  branch: row.headRefName,
935
950
  ...(row.headRefOid !== undefined && { head: row.headRefOid }),
951
+ ...(row.mergeStateStatus !== undefined && {
952
+ mergeState: row.mergeStateStatus,
953
+ }),
936
954
  },
937
955
  }
938
956
  }
@@ -1071,7 +1089,10 @@ async function runChecks(
1071
1089
  )
1072
1090
  }
1073
1091
 
1074
- const reading = collapseChecks(resolved.tip, listing)
1092
+ const reading = withMergeState(
1093
+ identity.mergeState,
1094
+ collapseChecks(resolved.tip, listing),
1095
+ )
1075
1096
 
1076
1097
  logStep('Scope')
1077
1098
  logInfo(
@@ -1101,6 +1122,10 @@ async function runChecks(
1101
1122
  logInfo('every run on the tip completed and none failed')
1102
1123
  } else if (reading.state === 'failing') {
1103
1124
  logWarn('a run on the tip failed, which no run still going can clear')
1125
+ } else if (reading.conflicted) {
1126
+ logWarn(
1127
+ 'the branch conflicts with its base, so no run will start for the tip. Rebase onto the base rather than waiting.',
1128
+ )
1104
1129
  } else if (reading.matched === 0) {
1105
1130
  logInfo(
1106
1131
  `no run belongs to the tip yet${reading.reported > 0 ? `, against a reported count of ${reading.reported}` : ''}. That is unread rather than clean.`,
@@ -13,7 +13,12 @@ import {
13
13
  type PlanCitations,
14
14
  planCitations,
15
15
  } from '@/tasks/archive'
16
- import { type LabelOutcome, nextLabel } from '@/tasks/label'
16
+ import {
17
+ type LabelOutcome,
18
+ type LabelRefused,
19
+ claimLabel,
20
+ nextLabel,
21
+ } from '@/tasks/label'
17
22
  import { listTasks } from '@/tasks/list'
18
23
  import {
19
24
  type Claim,
@@ -122,6 +127,7 @@ interface ListCommandOptions {
122
127
 
123
128
  interface NextLabelCommandOptions {
124
129
  readonly json?: boolean
130
+ readonly claim?: boolean
125
131
  readonly root?: string
126
132
  }
127
133
 
@@ -503,6 +509,7 @@ export function register(program: Command): void {
503
509
  )
504
510
  .helpOption('-h, --help', 'Show this help message')
505
511
  .option('--json', 'Emit a machine-readable record on stdout')
512
+ .option('--claim', 'Reserve the label so a second claim gets another')
506
513
  .option('--root <path>', 'Board root, defaulting to the main worktree')
507
514
  .addHelpText(
508
515
  'after',
@@ -515,14 +522,18 @@ export function register(program: Command): void {
515
522
  'Exit codes:',
516
523
  ' 0 the label is derived',
517
524
  ' 1 refused with no-board',
525
+ ' 2 refused with label-contended, on --claim only',
518
526
  '',
519
- 'It reports and never writes. Two sessions calling it in the same',
520
- 'second can still take the same answer, since the board is',
521
- 'gitignored files rather than a store with a lock.',
527
+ 'Bare, it reports and never writes, so two sessions calling it in the',
528
+ 'same second can take the same answer. --claim reserves the label',
529
+ 'under .canon/ordinal-locks/ and counts every reservation in the',
530
+ 'scan, so two claims return different labels. A dead claim leaves a',
531
+ 'gap, which standards/versioning.md permits.',
522
532
  '',
523
533
  'Examples:',
524
534
  ' canon tasks next-label',
525
535
  ' canon tasks next-label --json',
536
+ ' canon tasks next-label --claim --json',
526
537
  '',
527
538
  ].join('\n'),
528
539
  )
@@ -864,11 +875,15 @@ async function runList(opts: ListCommandOptions): Promise<number> {
864
875
 
865
876
  async function runNextLabel(opts: NextLabelCommandOptions): Promise<number> {
866
877
  const root = opts.root ?? (await mainWorktreeRoot())
867
- const outcome = await nextLabel(root)
878
+ const outcome = opts.claim ? await claimLabel(root) : await nextLabel(root)
868
879
 
869
880
  return reportNextLabel(outcome, opts.json ?? false, root)
870
881
  }
871
882
 
883
+ function refusalExit(outcome: LabelRefused): number {
884
+ return outcome.reason === 'label-contended' ? 2 : 1
885
+ }
886
+
872
887
  function reportNextLabel(
873
888
  outcome: LabelOutcome,
874
889
  emitJson: boolean,
@@ -879,14 +894,14 @@ function reportNextLabel(
879
894
  process.stdout.write(
880
895
  `${JSON.stringify({ ok: false, reason: outcome.reason, message: outcome.message })}\n`,
881
896
  )
882
- return 1
897
+ return refusalExit(outcome)
883
898
  }
884
899
 
885
900
  intro('canon tasks next-label')
886
901
  logStep('Refused')
887
902
  logError(outcome.message)
888
903
  outro()
889
- return 1
904
+ return refusalExit(outcome)
890
905
  }
891
906
 
892
907
  if (emitJson) {
package/src/help.ts ADDED
@@ -0,0 +1,196 @@
1
+ import { palette } from '@/ui'
2
+
3
+ const HELP_WIDTH = 80
4
+ const SIGNATURE_WIDTH = 19
5
+ const SANDBOX_SIGNATURE_WIDTH = 26
6
+ const ROW_INDENT = '│ '.length
7
+
8
+ type CommandRow = [signature: string, description: string]
9
+ type HelpStream = { isTTY?: boolean }
10
+
11
+ // The grouping mirrors the README's domain table and has no other source, so a
12
+ // command added to the program needs a row here or help-option.test.ts fails.
13
+ export const COMMAND_GROUPS: { title: string; rows: CommandRow[] }[] = [
14
+ {
15
+ title: 'Project',
16
+ rows: [
17
+ ['init [path]', 'Bootstrap a project with toolkit domains'],
18
+ ['sync [path]', 'Sync all installed domains in a project'],
19
+ ['upgrade', 'Reinstall the CLI with the manager that installed it'],
20
+ ['migrate [cmd]', 'Move a project off a retired name or layout'],
21
+ ['targets [cmd]', 'Projects this toolkit installed into (list, pulls)'],
22
+ ],
23
+ },
24
+ {
25
+ title: 'Domains',
26
+ rows: [
27
+ ['gov [cmd]', 'Governance rules (install, sync, list)'],
28
+ ['standards [cmd]', 'Standards (install, sync, list, <name>)'],
29
+ ['snippets [cmd]', 'Prompt snippets (create, list)'],
30
+ ['tooling [cmd]', 'Tooling stacks (sync, ref, create)'],
31
+ ['claude [cmd]', 'Claude workflow (init, sync, setup)'],
32
+ ['wiki [cmd]', 'Wiki pages (init)'],
33
+ ['indexes [cmd]', 'Regenerate index.md files (regen)'],
34
+ ['docs [cmd|topic]', 'Emit toolkit reference docs (list, <topic>)'],
35
+ ],
36
+ },
37
+ {
38
+ title: 'Author and render',
39
+ rows: [
40
+ ['design [cmd]', 'Design system (render, board)'],
41
+ ['slides [cmd]', 'Slide decks (render, list)'],
42
+ ['capture [source]', 'Render HTML capture sources to PNG'],
43
+ ['serve [dir]', 'Serve a directory on localhost, print the link'],
44
+ ['demo [cmd]', 'Record a running app (compile, run)'],
45
+ ['inventory [subj]', 'Report one computed property across every route'],
46
+ ['drive <url> <run>', 'Walk a page through interactions, measure each'],
47
+ ['transcripts <url>', 'Fetch a YouTube transcript with frontmatter'],
48
+ ['teach [cmd]', 'Learning workspaces (list, open, resource, glossary)'],
49
+ ],
50
+ },
51
+ {
52
+ title: 'Session and ship',
53
+ rows: [
54
+ ['sandbox [cat:cmd]', 'Provision and run sandbox scenarios'],
55
+ ['tasks [cmd]', 'Task board (validate, archive)'],
56
+ ['intake [cmd]', 'Intake folders under .canon/intake/ (list, answer)'],
57
+ ['records [cmd]', 'Session records (validate, size, push, pull)'],
58
+ ['sessions [cmd]', 'Resolve live sessions to worktree and branch'],
59
+ ['worktrees [cmd]', 'Reclaim worktrees whose branches merged'],
60
+ ['autoship [cmd]', 'Decide whether a changed set needs review'],
61
+ ['pr [cmd]', 'Read a pull request (key-changes, head, checks)'],
62
+ ['feedback', 'Write toolkit feedback from stdin'],
63
+ ],
64
+ },
65
+ {
66
+ title: 'Repo reports',
67
+ rows: [
68
+ ['audits [cmd]', 'Run every health check as one set (run, list)'],
69
+ ['gate [cmd]', 'Run the merge gate stage by stage (run)'],
70
+ ['secrets [cmd]', 'Scan the shipped tree for credentials (scan)'],
71
+ ['deps [cmd]', 'Read the dependency set for advisories (audit)'],
72
+ ['labels [cmd]', 'Read a changed set against the label map (audit)'],
73
+ ['comments [cmd]', 'Measure comment density and trend (scan)'],
74
+ ['context [cmd]', 'Report context folder health (audit)'],
75
+ ['markdown [cmd]', 'Report markdown against the standards (audit)'],
76
+ ['repo [cmd]', 'Remote metadata (metadata propose, apply)'],
77
+ ['census [path]', 'Report file count, extensions, and line totals'],
78
+ ],
79
+ },
80
+ ]
81
+
82
+ const SANDBOX_ROWS: CommandRow[] = [
83
+ ['canon sandbox', 'Interactive scenario picker'],
84
+ ['canon sandbox git:commit', 'Run specific scenario'],
85
+ ['canon sandbox reset', 'Reset sandbox to baseline'],
86
+ ['canon sandbox clean', 'Wipe the sandbox'],
87
+ ]
88
+
89
+ const EXAMPLES = [
90
+ 'canon sync ../my-app',
91
+ 'canon sandbox git:commit',
92
+ 'canon gov install react',
93
+ 'canon gov sync ../my-app',
94
+ 'canon gov restated --json',
95
+ 'canon standards markdown',
96
+ 'canon snippets list',
97
+ 'canon init ../my-app',
98
+ 'canon tooling sync base',
99
+ 'canon tooling create',
100
+ 'canon claude init',
101
+ 'canon indexes regen',
102
+ 'canon indexes regen --dry-run --json',
103
+ 'canon docs list --json',
104
+ 'canon docs agents',
105
+ 'canon design render',
106
+ 'canon design board',
107
+ 'canon slides render',
108
+ 'canon slides list --json',
109
+ 'canon capture assets/captures/install.html --selector .window --out assets/evidence',
110
+ 'canon serve .canon/teach',
111
+ 'canon inventory focus --json',
112
+ 'canon drive http://localhost:4173 run.json --json',
113
+ 'pbpaste | canon feedback',
114
+ 'canon transcripts https://youtu.be/VIDEO_ID',
115
+ 'canon tasks archive --pull-request 673 --json',
116
+ 'canon intake list toolkit-overview --unread --json',
117
+ 'canon teach list --json',
118
+ 'canon comments scan src --json',
119
+ 'canon context audit --json',
120
+ 'canon markdown audit .claude/rules --json',
121
+ 'canon records validate plans',
122
+ 'canon records size --json',
123
+ 'canon records push --json',
124
+ 'canon sessions list --json',
125
+ 'canon worktrees list --json',
126
+ 'canon worktrees reclaim --dry-run',
127
+ 'canon secrets scan --json',
128
+ 'canon deps audit --json',
129
+ 'canon labels audit --json',
130
+ 'canon repo metadata propose --json',
131
+ 'canon census --json',
132
+ 'canon audits run --json',
133
+ 'canon gate run --all --no-write',
134
+ 'canon upgrade --json',
135
+ ]
136
+
137
+ // The frame follows stdout's TTY state rather than `palette`, whose blank
138
+ // palette keeps every glyph so the stderr frames a captured run quotes stay
139
+ // framed. A glyph is rendering rather than color, so NO_COLOR keeps it.
140
+ function isFramed(stream: HelpStream): boolean {
141
+ return stream.isTTY === true
142
+ }
143
+
144
+ function gutter(stream: HelpStream): string {
145
+ if (!isFramed(stream)) return ''
146
+ const { GREY, NC } = palette(stream)
147
+ return `${GREY}│${NC}`
148
+ }
149
+
150
+ // A row past the width is cut rather than wrapped, since a wrapped row breaks
151
+ // the alignment the grouping exists to buy. The cut is computed from the framed
152
+ // prefix in both forms, so a pipe carries the same text as a terminal.
153
+ export function commandRow(
154
+ signature: string,
155
+ description: string,
156
+ stream: HelpStream,
157
+ ): string {
158
+ const { GREY, NC } = palette(stream)
159
+ const room = HELP_WIDTH - ROW_INDENT - SIGNATURE_WIDTH - '# '.length
160
+ const text =
161
+ description.length > room
162
+ ? `${description.slice(0, room - 1)}…`
163
+ : description
164
+ return `${gutter(stream)} ${signature.padEnd(SIGNATURE_WIDTH)}${GREY}# ${text}${NC}`
165
+ }
166
+
167
+ export function renderHelp(stream: HelpStream): string {
168
+ const { GREY, NC, WHITE } = palette(stream)
169
+ const bar = gutter(stream)
170
+ const usage = `${WHITE}Usage:${NC} canon [command]`
171
+ const lines = [
172
+ ...(isFramed(stream)
173
+ ? [`${GREY}┌${NC}`, `${GREY}├${NC} ${usage}`]
174
+ : [usage]),
175
+ bar,
176
+ `${bar} ${WHITE}Commands:${NC}`,
177
+ ...COMMAND_GROUPS.flatMap((group) => [
178
+ bar,
179
+ `${bar} ${WHITE}${group.title}${NC}`,
180
+ ...group.rows.map(([signature, description]) =>
181
+ commandRow(signature, description, stream),
182
+ ),
183
+ ]),
184
+ bar,
185
+ `${bar} ${WHITE}Sandbox:${NC}`,
186
+ ...SANDBOX_ROWS.map(
187
+ ([signature, description]) =>
188
+ `${bar} ${signature.padEnd(SANDBOX_SIGNATURE_WIDTH)}${GREY}# ${description}${NC}`,
189
+ ),
190
+ bar,
191
+ `${bar} ${WHITE}Examples:${NC}`,
192
+ ...EXAMPLES.map((example) => `${bar} ${example}`),
193
+ ...(isFramed(stream) ? [`${GREY}└${NC}`] : []),
194
+ ]
195
+ return lines.join('\n')
196
+ }
package/src/pr/checks.ts CHANGED
@@ -29,6 +29,35 @@ export interface ChecksReading {
29
29
  readonly collapsed: number
30
30
  }
31
31
 
32
+ export interface ConflictedReading extends ChecksReading {
33
+ /** The branch conflicts with its base, so no run will ever start for the tip. */
34
+ readonly conflicted: boolean
35
+ /** The pull request's `mergeStateStatus`, absent when the read carried none. */
36
+ readonly mergeState?: string
37
+ }
38
+
39
+ /**
40
+ * Tells a conflicted pull request from a tip whose runs have not started.
41
+ *
42
+ * A branch that conflicts with its base gets no merge ref, so no
43
+ * `pull_request` workflow runs and the listing stays empty, which
44
+ * `collapseChecks` can only call pending. Only `DIRTY` separates the two, and
45
+ * only while no run belongs to the tip: a conflict arising after runs
46
+ * completed leaves those runs standing as a real answer. `UNKNOWN` is
47
+ * transient while GitHub computes it, so it reads as not conflicted and costs
48
+ * a caller one more poll.
49
+ */
50
+ export function withMergeState(
51
+ mergeState: string | undefined,
52
+ reading: ChecksReading,
53
+ ): ConflictedReading {
54
+ return {
55
+ ...reading,
56
+ conflicted: mergeState === 'DIRTY' && reading.matched === 0,
57
+ ...(mergeState !== undefined && { mergeState }),
58
+ }
59
+ }
60
+
32
61
  /**
33
62
  * Puts a check-run row into the shape `rollup` reads.
34
63
  *
@@ -1,5 +1,7 @@
1
1
  import { existsSync } from 'node:fs'
2
+ import { mkdir, readdir } from 'node:fs/promises'
2
3
  import { relative } from 'node:path'
4
+ import { recordDir } from '@/record-root'
3
5
  import {
4
6
  archiveDir,
5
7
  declinedDir,
@@ -21,6 +23,12 @@ const FIRST_LABEL: Label = { major: 1, minor: 0 }
21
23
  */
22
24
  export const LABEL_PATTERN = /^v(\d+)\.(\d+)-/
23
25
 
26
+ /** A reservation is named by the bare label, with no slug behind it. */
27
+ const RESERVATION_PATTERN = /^v(\d+)\.(\d+)$/
28
+
29
+ /** Bounds the claim loop. A fifth loss in a row is not a race. */
30
+ const MAX_ATTEMPTS = 5
31
+
24
32
  interface Label {
25
33
  readonly major: number
26
34
  readonly minor: number
@@ -31,18 +39,23 @@ export interface NextLabel {
31
39
  readonly label: string
32
40
  /** The label this run was derived from, absent when neither folder holds one. */
33
41
  readonly highest: string | undefined
42
+ /** Whether this label was reserved on disk rather than only read. */
43
+ readonly claimed: boolean
34
44
  }
35
45
 
36
46
  export interface LabelRefused {
37
47
  readonly ok: false
38
- readonly reason: 'no-board'
48
+ readonly reason: 'no-board' | 'label-contended'
39
49
  readonly message: string
40
50
  }
41
51
 
42
52
  export type LabelOutcome = NextLabel | LabelRefused
43
53
 
44
- function parseLabel(stem: string): Label | undefined {
45
- const match = LABEL_PATTERN.exec(stem)
54
+ function parseLabel(
55
+ stem: string,
56
+ pattern: RegExp = LABEL_PATTERN,
57
+ ): Label | undefined {
58
+ const match = pattern.exec(stem)
46
59
  if (!match) return undefined
47
60
 
48
61
  return { major: Number(match[1]), minor: Number(match[2]) }
@@ -71,6 +84,54 @@ function next(label: Label): Label {
71
84
  : { major: label.major, minor: label.minor + 1 }
72
85
  }
73
86
 
87
+ function noBoard(root: string, dir: string): LabelRefused {
88
+ return {
89
+ ok: false,
90
+ reason: 'no-board',
91
+ message: `No task board at ${relative(root, dir)}.`,
92
+ }
93
+ }
94
+
95
+ function reservationsDir(root: string): string {
96
+ return recordDir(root, 'ordinal-locks')
97
+ }
98
+
99
+ async function listReservations(root: string): Promise<string[]> {
100
+ try {
101
+ const entries = await readdir(reservationsDir(root), {
102
+ withFileTypes: true,
103
+ })
104
+ return entries.filter((entry) => entry.isDirectory()).map((e) => e.name)
105
+ } catch (error) {
106
+ if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
107
+ return []
108
+ }
109
+ throw error
110
+ }
111
+ }
112
+
113
+ function highestOf(
114
+ names: readonly string[],
115
+ pattern: RegExp,
116
+ ): Label | undefined {
117
+ return names
118
+ .map((name) => parseLabel(name, pattern))
119
+ .filter((label): label is Label => label !== undefined)
120
+ .reduce<Label | undefined>(
121
+ (max, label) => (max === undefined || isHigher(label, max) ? label : max),
122
+ undefined,
123
+ )
124
+ }
125
+
126
+ async function readStems(root: string): Promise<string[]> {
127
+ const settled = [archiveDir(root), declinedDir(root)].filter((candidate) =>
128
+ existsSync(candidate),
129
+ )
130
+ const dirs = [tasksDir(root), ...settled]
131
+
132
+ return (await Promise.all(dirs.map((d) => listTaskStems(d)))).flat()
133
+ }
134
+
74
135
  /**
75
136
  * Reports the next unused phase label, read off the true maximum across
76
137
  * `.canon/tasks/` and its `archive/` and `declined/` siblings together. A scan
@@ -78,41 +139,87 @@ function next(label: Label): Label {
78
139
  * spent, which is what let two sessions hand out the same label within
79
140
  * minutes of each other.
80
141
  *
81
- * It reports and never writes. Two sessions calling it in the same second can
82
- * still take the same answer, since the board is gitignored files rather than
83
- * a store with a lock, and `standards/versioning.md` permits free renumbering,
84
- * so a collision costs a rename rather than anything worse. A duplicate label
85
- * already sitting in the tree, and a gap left by a renumbering, both fold into
86
- * the same max scan without needing a dedicated check.
142
+ * It reports and never writes. `claimLabel` is the variant that reserves. A
143
+ * duplicate label already sitting in the tree, and a gap left by a
144
+ * renumbering, both fold into the same max scan without needing a dedicated
145
+ * check.
87
146
  */
88
147
  export async function nextLabel(root: string): Promise<LabelOutcome> {
89
148
  const dir = tasksDir(root)
149
+ if (!existsSync(dir)) return noBoard(root, dir)
90
150
 
91
- if (!existsSync(dir)) {
92
- return {
93
- ok: false,
94
- reason: 'no-board',
95
- message: `No task board at ${relative(root, dir)}.`,
96
- }
151
+ const highest = highestOf(await readStems(root), LABEL_PATTERN)
152
+
153
+ return {
154
+ ok: true,
155
+ label: formatLabel(highest === undefined ? FIRST_LABEL : next(highest)),
156
+ highest: highest === undefined ? undefined : formatLabel(highest),
157
+ claimed: false,
97
158
  }
159
+ }
98
160
 
99
- const settled = [archiveDir(root), declinedDir(root)].filter((candidate) =>
100
- existsSync(candidate),
101
- )
102
- const dirs = [dir, ...settled]
103
- const stems = (await Promise.all(dirs.map((d) => listTaskStems(d)))).flat()
161
+ interface ClaimOptions {
162
+ /** Takes the reservation, resolving false when another claim holds it. */
163
+ readonly reserve?: (root: string, label: string) => Promise<boolean>
164
+ }
104
165
 
105
- const highest = stems
106
- .map(parseLabel)
107
- .filter((label): label is Label => label !== undefined)
108
- .reduce<Label | undefined>(
109
- (max, label) => (max === undefined || isHigher(label, max) ? label : max),
110
- undefined,
166
+ async function reserveLabel(root: string, label: string): Promise<boolean> {
167
+ await mkdir(reservationsDir(root), { recursive: true })
168
+
169
+ try {
170
+ await mkdir(recordDir(root, 'ordinal-locks', label), { recursive: false })
171
+ return true
172
+ } catch (error) {
173
+ if (error instanceof Error && 'code' in error && error.code === 'EEXIST') {
174
+ return false
175
+ }
176
+ throw error
177
+ }
178
+ }
179
+
180
+ /**
181
+ * Reserves the next label through an exclusive `mkdir` under
182
+ * `.canon/ordinal-locks/`, so a second claim lands on a different one.
183
+ *
184
+ * The scan counts every reservation beside the task stems. The skill writes
185
+ * the task file after this returns, so a reservation with no file behind it is
186
+ * the ordinary state for a few seconds, and a scan blind to it would offer the
187
+ * same label to every retry. Reservations are never expired: a claim that died
188
+ * leaves a gap, and `standards/versioning.md` permits gaps.
189
+ */
190
+ export async function claimLabel(
191
+ root: string,
192
+ options: ClaimOptions = {},
193
+ ): Promise<LabelOutcome> {
194
+ const dir = tasksDir(root)
195
+ if (!existsSync(dir)) return noBoard(root, dir)
196
+
197
+ const reserve = options.reserve ?? reserveLabel
198
+
199
+ for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt += 1) {
200
+ const stems = highestOf(await readStems(root), LABEL_PATTERN)
201
+ const held = highestOf(await listReservations(root), RESERVATION_PATTERN)
202
+ const highest =
203
+ stems !== undefined && (held === undefined || isHigher(stems, held))
204
+ ? stems
205
+ : held
206
+ const label = formatLabel(
207
+ highest === undefined ? FIRST_LABEL : next(highest),
111
208
  )
112
209
 
210
+ if (await reserve(root, label)) {
211
+ return {
212
+ ok: true,
213
+ label,
214
+ highest: highest === undefined ? undefined : formatLabel(highest),
215
+ claimed: true,
216
+ }
217
+ }
218
+ }
219
+
113
220
  return {
114
- ok: true,
115
- label: formatLabel(highest === undefined ? FIRST_LABEL : next(highest)),
116
- highest: highest === undefined ? undefined : formatLabel(highest),
221
+ ok: false,
222
+ reason: 'label-contended',
223
+ message: `Could not reserve a label after ${MAX_ATTEMPTS} attempts. Re-run the claim.`,
117
224
  }
118
225
  }
@@ -63,6 +63,7 @@ The opening of a root README is a block of up to six elements in a fixed order.
63
63
  The description of 2-3 sentences follows the block and expands the claim rather than repeating it.
64
64
 
65
65
  - A project with no page omits the link and the screenshot. A library or a CLI reads as a complete block with a title, badges, and a claim.
66
+ - A project whose own page carries the screenshot omits it here and keeps the live link, which answers the slot rather than skipping it. Say so in the pull request that drops it.
66
67
  - The order is the block's rather than the page's. The screenshot sits last inside the block, which puts it in the first row of content a reader meets below the title, badges, and claim.
67
68
  - A themed light and dark pair counts as one element in the slot rather than two. The block does not require a pair and leaves the handling of themes to the project.
68
69
  - Centering the block in an HTML wrapper is optional. Plain markdown reaches every host a README renders on, and a wrapper is styling a project can add.