@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 +15 -30
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/git-ship/SKILL.md +1 -1
- package/claude/skills/review-address/SKILL.md +4 -3
- package/claude/skills/task-board/REQUIREMENT.md +1 -1
- package/claude/skills/task-board/SKILL.md +2 -2
- package/docs/agents/capture.md +3 -3
- package/docs/agents/commands.md +2 -2
- package/docs/agents/pr-reads.md +2 -0
- package/docs/agents/tasks.md +10 -8
- package/package.json +1 -1
- package/scripts/core/regen-hero.sh +2 -34
- package/src/cli.ts +2 -161
- package/src/commands/pr.ts +29 -4
- package/src/commands/tasks.ts +22 -7
- package/src/help.ts +196 -0
- package/src/pr/checks.ts +29 -0
- package/src/tasks/label.ts +136 -29
- package/standards/readme.md +1 -0
package/README.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
<div align="center">
|
|
2
2
|
|
|
3
3
|
<picture>
|
|
4
|
-
|
|
5
|
-
|
|
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
|
-
|
|
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
|
-
|
|
50
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
104
|
-
|
|
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.
|
|
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
|
|
|
@@ -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
|
|
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
|
-
|
|
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
|
-
-
|
|
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
|
|
package/docs/agents/capture.md
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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`. `
|
|
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
|
|
package/docs/agents/commands.md
CHANGED
|
@@ -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`) |
|
package/docs/agents/pr-reads.md
CHANGED
|
@@ -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.
|
package/docs/agents/tasks.md
CHANGED
|
@@ -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.
|
|
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
|
-
| `--
|
|
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
|
-
|
|
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
|
@@ -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"
|
|
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(["
|
|
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 {
|
|
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
|
-
|
|
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()
|
package/src/commands/pr.ts
CHANGED
|
@@ -20,7 +20,11 @@ import {
|
|
|
20
20
|
compareKeyChanges,
|
|
21
21
|
treeRoots,
|
|
22
22
|
} from '@/pr/bijection'
|
|
23
|
-
import {
|
|
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: {
|
|
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 =
|
|
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.`,
|
package/src/commands/tasks.ts
CHANGED
|
@@ -13,7 +13,12 @@ import {
|
|
|
13
13
|
type PlanCitations,
|
|
14
14
|
planCitations,
|
|
15
15
|
} from '@/tasks/archive'
|
|
16
|
-
import {
|
|
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
|
-
'
|
|
520
|
-
'second can
|
|
521
|
-
'
|
|
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
|
|
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
|
|
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
|
*
|
package/src/tasks/label.ts
CHANGED
|
@@ -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(
|
|
45
|
-
|
|
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.
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
)
|
|
102
|
-
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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:
|
|
115
|
-
|
|
116
|
-
|
|
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
|
}
|
package/standards/readme.md
CHANGED
|
@@ -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.
|