@erclx/canon 4.100.0 → 4.102.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
@@ -99,7 +99,10 @@ Two of the outputs are documents you open rather than files you run. A `SLIDES.m
99
99
 
100
100
  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
101
 
102
- ![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](demos/agent-view.gif)
102
+ <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">
105
+ </picture>
103
106
 
104
107
  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.
105
108
 
@@ -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.100.0",
4
+ "version": "4.102.0",
5
5
  "author": {
6
6
  "name": "Eric Le",
7
7
  "url": "https://github.com/erclx"
@@ -11,7 +11,7 @@ Chain the post-plan pipeline in a single run. Every step has a stop condition. S
11
11
  ## Guards
12
12
 
13
13
  - All `.canon/plans/` and `.canon/review/` reads resolve at the main worktree root, not the current worktree. Resolve that root the way `session-worktree` does.
14
- - Derive `<slug>` per `${CLAUDE_SKILL_DIR}/../../standards/slug.md`. This skill takes the stop rather than the `latest` fallback, since it commits and opens a pull request. If empty, stop: `❌ Detached HEAD. Checkout the feature branch first.` This slug is provisional. It is superseded once `session-worktree` runs, whether at Step 0 or before this chain began. Every later step keys its output on the slug that run resolves, being the worktree, the review receipt, the branch, and the memory proposal, regardless of which plan Step 1 reads.
14
+ - Derive `<slug>` per `${CLAUDE_SKILL_DIR}/../../standards/slug.md`. This skill takes the stop rather than the `latest` fallback, since it commits and opens a pull request. If empty, stop: `❌ Detached HEAD. Checkout the feature branch first.` This slug is provisional. It is superseded once `session-worktree` runs, whether at Step 0 or before this chain began. Every later step keys its output on the slug that run resolves, being the worktree, the review receipt, and the branch, regardless of which plan Step 1 reads.
15
15
  - Resolve `<plan>` in Step 1, ahead of any other read.
16
16
  - If the working tree has uncommitted changes unrelated to the plan, stop: `❌ Uncommitted changes outside the plan. Commit or stash before autoshipping.`
17
17
 
@@ -188,7 +188,7 @@ This chain owns the receipt's lifetime, which is what makes the Output block's c
188
188
 
189
189
  ## Step 8: ship
190
190
 
191
- Invoke `canon:git-ship`. That body owns the sequence, being the verify gate, memory capture, both doc syncs, staging, the commit grouping, the branch rename, the pull request, the CI watch, and the scoped memory review, along with the reason each step sits where it does. This step used to restate that list and the two drifted apart with nothing comparing them, so read the order there and never here.
191
+ Invoke `canon:git-ship`. That body owns the sequence, being the verify gate, memory capture, both doc syncs, staging, the commit grouping, the branch rename, the pull request, and the CI watch, along with the reason each step sits where it does. This step used to restate that list and the two drifted apart with nothing comparing them, so read the order there and never here.
192
192
 
193
193
  One thing this chain adds. Mark the pull request as a draft as soon as `git-ship`'s pull request step returns, ahead of its CI watch, then read the flag back:
194
194
 
@@ -207,21 +207,20 @@ Placement is why the call sits ahead of the watch rather than after it. Marking
207
207
 
208
208
  ## Output
209
209
 
210
- Respond with up to five lines:
210
+ Respond with up to four lines:
211
211
 
212
212
  ```plaintext
213
213
  ✅ Autoshipped (<state>): <PR url>
214
214
  <N minor findings kept in .canon/review/branch-<slug>.md>
215
215
  <N facts routed to context entries>
216
216
  <N memories captured in .canon/memory/>
217
- <Memory proposal at .canon/memory/review/memory-review-<slug>.md>
218
217
  ```
219
218
 
220
219
  `<state>` is whatever the Step 8 read returned, being `draft` or `ready, unsupervised`, rather than the state the undo asked for. Writing the word `draft` there unconditionally is what this line used to do, and it named a state no step had read.
221
220
 
222
- Omit the second line if there were no minor findings, and the third if nothing routed. Omit the fourth and fifth if `memory-capture` wrote no memory file this session, since an empty pen means no scoped review and no proposal. A run that routes every fact and writes none is the shape to expect, and it reports three lines.
221
+ Omit the second line if there were no minor findings, and the third if nothing routed. Omit the fourth if `memory-capture` wrote no memory file this session. A run that routes every fact and writes none is the shape to expect, and it reports three lines.
223
222
 
224
- This block replaces the one `git-ship` closes on rather than following it. The two carry the same three trailing lines and differ on the two above them, since the first names the state the read returned and the second reports the minor findings Step 7 kept, neither of which that body has a counterpart for. Emitting both reports one run twice and buries the state under a `✅ Shipped` that does not name it.
223
+ This block replaces the one `git-ship` closes on rather than following it. The two carry the same two trailing lines and differ on the two above them, since the first names the state the read returned and the second reports the minor findings Step 7 kept, neither of which that body has a counterpart for. Emitting both reports one run twice and buries the state under a `✅ Shipped` that does not name it.
225
224
 
226
225
  ## Failure recovery
227
226
 
@@ -31,7 +31,7 @@ Consult before running an unfamiliar `canon` verb, before a sync or install, or
31
31
 
32
32
  A golden config is any file a stack ships under `configs/`, and the category is wider than its name suggests. It carries the CI workflow, the git hooks, the end-to-end harness, the shell scripts under `scripts/`, and the editor settings, alongside the linters and compilers a reader expects. A stack inherits its parent's configs, so syncing `astro` also writes everything `web` and `base` hold.
33
33
 
34
- Run `canon tooling sync <stack> <target> --check` for the list resolved against a real target. It reports every path and writes nothing. The list below is what the stacks hold as shipped, before any chain resolution.
34
+ Run `canon tooling diff <stack> <target>` for the list resolved against a real target. It reports every path, writes nothing, and exits 1 when any differs. The list below is what the stacks hold as shipped, before any chain resolution.
35
35
 
36
36
  <!-- generated:tooling-paths -->
37
37
 
@@ -32,7 +32,6 @@ Run `git diff --cached --name-only 2>/dev/null` to check for staged files. If ou
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
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.
35
- 10. If step 1 wrote or updated at least one memory file, invoke `canon:memory-review` scoped to those entries to propose fixes while session context is fresh. If the pen got nothing, skip this step.
36
35
 
37
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.
38
37
 
@@ -48,19 +47,16 @@ An installed binary carrying no `plan-reach` subcommand reports the reach unread
48
47
 
49
48
  Capture leads the sequence because a routed fact lands in a context entry, which is a tracked file. Running it after the pull request opens leaves that edit off the branch entirely, so the fact reaches nothing. Memory files are gitignored either way, which is what hid the ordering while capture wrote only those.
50
49
 
51
- Stop at the Propose phase. Do not run Apply. Promoting an entry to an always-loaded rule or a skill body ships as its own change, separate from this feature.
52
-
53
50
  ## After completion
54
51
 
55
- Output up to four lines:
52
+ Output up to three lines:
56
53
 
57
54
  ```plaintext
58
55
  ✅ Shipped
59
56
  <N facts routed to context entries>
60
57
  <N memories captured in .canon/memory/>
61
- <Memory proposal at .canon/memory/review/memory-review-<slug>.md>
62
58
  ```
63
59
 
64
- Omit the second line if nothing routed. Omit the third and fourth if `memory-capture` wrote no memory file this session, since an empty pen means no scoped review and no proposal.
60
+ Omit the second line if nothing routed and the third if `memory-capture` wrote no memory file this session.
65
61
 
66
- Emit nothing here when a wrapping caller states it closes on its own block. `auto-ship` is that caller and its block carries these same three trailing lines above a first line naming the draft state, so emitting both reports one run twice. A caller that states no such thing gets this block, which is every direct invocation.
62
+ Emit nothing here when a wrapping caller states it closes on its own block. `auto-ship` is that caller and its block carries these same two trailing lines above a first line naming the draft state, so emitting both reports one run twice. A caller that states no such thing gets this block, which is every direct invocation.
@@ -100,12 +100,6 @@ When anything routed, add a line naming the handoff so the caller knows a `docs-
100
100
 
101
101
  Omit that line when the caller runs `docs-fold` itself later in its own chain.
102
102
 
103
- When at least one memory file was written or updated, add a closing line so the standalone caller proposes fixes while context is fresh:
104
-
105
- `→ Run /memory-review to propose fixes for the pen before the session ends.`
106
-
107
- The ship skills run review Propose themselves, so this line is for the standalone `/memory-capture` path.
108
-
109
103
  If nothing was captured, output:
110
104
 
111
105
  `✅ Nothing worth capturing.`
@@ -9,7 +9,7 @@ description: What memory review is for, the gaps it closes, and why every action
9
9
 
10
10
  Without this skill, the memory folder grows and never drains. Entries pile up restating rules a durable surface already carries, nothing decides which memory has earned a place in one, and a rule that does get promoted arrives verbatim in a file whose voice it does not match. The folder then reads as a second source of truth that no surface points at.
11
11
 
12
- A pen the caller cannot face is the same gap wearing a different shape. Routing at capture takes the domain facts and whatever the ownership test leaves stays, in whatever mix of types that is, so the folder still reaches a size nobody reads in one sitting. A ship-time run is scoped to the entries that session captured for exactly that reason, and the full sweep is what an operator asks for when the cross-session duplicates are the point. An entry leaving the pen is archived rather than deleted, because a folder git does not hold gives a wrong bulk call no undo.
12
+ A pen the caller cannot face is the same gap wearing a different shape. Routing at capture takes the domain facts and whatever the ownership test leaves stays, in whatever mix of types that is, so the folder still reaches a size nobody reads in one sitting. The full sweep is what an operator asks for when the cross-session duplicates are the point. An entry leaving the pen is archived rather than deleted, because a folder git does not hold gives a wrong bulk call no undo.
13
13
 
14
14
  ## Must
15
15
 
@@ -30,12 +30,11 @@ If the user re-pings the skill with no new phrase and a receipt exists, default
30
30
 
31
31
  ## Propose phase
32
32
 
33
- Propose is the ship-time entry point. The ship skills run it right after capture, so the fix is written while session context is fresh.
33
+ Propose is the entry point for a standalone run. The ship skills stop at capture and never invoke it.
34
34
 
35
35
  ### Scope
36
36
 
37
- - **Ship-scoped:** when a ship caller (`git-ship`, `auto-ship`) names the entries captured this session, classify only those entries. Read the full pen for merge and absorbed comparison, but do not propose actions on carried entries the captures do not touch. Each carried entry was already proposed on in its own ship cycle.
38
- - **Full sweep:** when invoked standalone with no named set, classify every entry in the pen, including entries carried from earlier sessions, so cross-session duplicates merge into one rule.
37
+ - **Full sweep:** classify every entry in the pen, including entries carried from earlier sessions, so cross-session duplicates merge into one rule.
39
38
 
40
39
  ### Step 1: read the memory folder
41
40
 
@@ -21,7 +21,7 @@ Read `${CLAUDE_SKILL_DIR}/../../standards/groundwork.md` before writing any file
21
21
 
22
22
  - Write only inside `.canon/groundwork/<nn>-<slug>/`. A feature plan, source changes, a standard, a rule, and a reference doc all live outside that folder, so this one rule forbids every one of them.
23
23
  - One exception, at close only: write one task file recording what the track concluded.
24
- - A second exception, for what a spike reads: write an input under `.canon/tmp/runs/groundwork-fixtures/<slug>/`. Keep it out of `.canon/groundwork/` so mode detection never matches a fixture as a track. A fixture a headless run is pointed at goes outside the repository instead, per the rule in `## Running a spike`.
24
+ - A second exception, for what a spike reads as bulk input: write a large fixture under `.canon/tmp/runs/groundwork-fixtures/<slug>/`. Keep it out of `.canon/groundwork/` so mode detection never matches a fixture as a track. A fixture a headless run is pointed at goes outside the repository instead, per the rule in `## Running a spike`. An arm script and a checkout a claim rests on are not fixtures, and go inside the track under `scripts/` and `clones/`, per the standard.
25
25
  - What a spike produces stays inside the track rather than joining the two exceptions above: write evidence a spike file cites under `.canon/groundwork/<nn>-<slug>/evidence/`, which the first rule already permits. Mode detection matches entries at the top level of the tracks directory, so the sibling the fixtures rule guards against cannot be a folder nested inside a track, and the scratch path holds only what can be deleted without loss, which a recording a finding rests on is not.
26
26
  - A `draft-and-pick` run invoked from inside a live track follows its own branch for this, writing to `evidence/` here rather than `.canon/tmp/<slug>/`.
27
27
  - Reading is not restricted. External research is in scope, so read documentation, comparable projects, and papers whenever a live question needs them.
@@ -33,12 +33,13 @@ Read `${CLAUDE_SKILL_DIR}/../../standards/groundwork.md` before writing any file
33
33
  A track may run an experiment to settle an open question without stopping to ask. What it takes depends on what the experiment does.
34
34
 
35
35
  - Reading or computing: run it. Unrestricted reading already covers this, and it is what most spikes turn out to be.
36
+ - Anything that processes an input: start against a sample and scale up after it works, per the sample rule in the standard's spikes section, which bounds it on size, duration, and spend.
36
37
  - Writing a fixture this session reads or provisions itself: run it, under the fixtures path above. Provisioning is the usual blocker rather than spend, and it costs nothing.
37
38
  - Spawning a billed headless session: run up to three, then ask before spawning more. The bound is a run count rather than a budget, because a headless run reports its total cost only after it finishes, so a dollar ceiling is reportable and not enforceable while a run count is checkable before spawning.
38
39
 
39
40
  A fixture a headless run is pointed at goes outside the repository, under `mktemp -d`, and never under `.canon/tmp/`. A session started anywhere beneath the project root loads that project's `CLAUDE.md` and `.claude/rules/` through the ancestor chain, so an in-repo fixture measures the repository instead of the arm. Record the fixture as contaminated and re-run it outside if this is discovered after the fact, because the result reads as evidence either way.
40
41
 
41
- Record method, result, measured cost, and caveats in `08-spikes.md`. Put whatever the run produced that the record cites in `evidence/` beside it, so a reader checking a claim opens the artifact rather than taking the prose for it. Reach for a test harness the project already carries before building one, and copy how it isolates its fixture rather than only what it asserts.
42
+ Record method, result, measured cost, and caveats in `08-spikes.md`. Put whatever the run produced that the record cites in `evidence/` beside it, so a reader checking a claim opens the artifact rather than taking the prose for it, and keep the arm scripts in `scripts/`. Reach for a test harness the project already carries before building one, and copy how it isolates its fixture rather than only what it asserts.
42
43
 
43
44
  ## Step 1: detect the mode
44
45
 
@@ -26,6 +26,8 @@ The plan is committed, not scratch. Its timing is a starting point tuned by watc
26
26
 
27
27
  A plan can carry an optional `colorScheme` of `light` or `dark`, which the recording context starts in so a page following the system preference paints that scheme with no click beat of its own. An absent field keeps the engine's default, which is light, and `compile` never seeds it. Any other value makes the plan unreadable. A page keyed on a stored theme rather than the preference ignores the field.
28
28
 
29
+ A recording that has to match both themes takes one plan per scheme, committed side by side, rather than one plan run twice. The pair shares its beats and differs in `colorScheme`, `slug`, and the `output` paths. Neither plan clicks a theme toggle, since a stored theme overrides the preference. Nothing checks that the two plans keep the same beats, so an edit to one is made to both.
30
+
29
31
  `record-screencast` is the routed way to run both verbs from a draft path: it compiles only when no plan exists yet, then runs, stopping to report any field still unresolved rather than guessing one.
30
32
 
31
33
  | Option | Behavior |
@@ -75,6 +75,12 @@ toolkit's own `snippets/`, so a session reaches one at its `@` reference with
75
75
  no copy to reconcile. `canon snippets list --json` carries the catalog and
76
76
  `canon snippets create` is the one verb left that still writes a file.
77
77
 
78
+ ## Tooling diff and sync
79
+
80
+ `canon tooling diff <stack> [target]` is the read-only comparison, and a session asking what differs reaches for it by default. It writes nothing and exits 1 when anything differs and 0 when nothing does, so it gates CI. `--json` adds the scan record on stdout with `ok: true`, and the frame goes to stderr. A refusal such as an unknown stack, an excluded stack, or a toolkit-root target still exits 1 and answers `{ ok: false, reason, message }`, so a caller branches on the record and reads a typo'd stack name as a refusal rather than as drift.
81
+
82
+ `canon tooling sync <stack> [target] --check` reports the same list and always exits 0. It stays for scripts that already call it. `sync` itself writes only under `--write`, and a headless run with neither flag reports and exits 1.
83
+
78
84
  ## Install guards
79
85
 
80
86
  `canon gov install` requires its first argument under
@@ -188,7 +194,7 @@ since `src/tooling/` runs its own inject machinery and has no walk to attribute.
188
194
  The chain is ordered nearest stack first, which is what a `--skip` run needs:
189
195
  recording the leaf alone would send the next report measuring against a layer the
190
196
  target deliberately does not carry. The report loads exactly those stacks, scans
191
- them the way `canon tooling sync` would, and counts what differs per category
197
+ them the way `canon tooling diff` would, and counts what differs per category
192
198
  under `tooling.counts`.
193
199
 
194
200
  `measured` is the field the section exists for. A target carrying no chain
@@ -78,7 +78,7 @@ surface is in `skills-audit.md`.
78
78
  CANON_NON_INTERACTIVE=1 canon tooling create astro
79
79
 
80
80
  # Report what a stack would change, writing nothing
81
- CANON_NON_INTERACTIVE=1 canon tooling sync astro /path/to/project --check
81
+ CANON_NON_INTERACTIVE=1 canon tooling diff astro /path/to/project
82
82
 
83
83
  # Sync a stack into a target project, overwriting its golden configs
84
84
  CANON_NON_INTERACTIVE=1 canon tooling sync astro /path/to/project --write
@@ -292,6 +292,30 @@ Exit codes: `0` closed, `1` refused. The `reason` field adds `no-outcomes`, `out
292
292
 
293
293
  Both verbs exist because the write is an edit inside a file that already exists. `Edit` and `Write` refuse a main-root path from a linked worktree, and a shell stream editor is banned for in-place edits, so a verb resolving the board root in-process is the only route a skill body has. Creating a whole file needs no verb, because a heredoc through `Bash` writes it safely.
294
294
 
295
+ ## List
296
+
297
+ `canon tasks list` reports each live task file with its readiness, so a reader learns which file is parked and which is live without opening `priority.md` or `backlog.md`. It reads and never writes, and every file stays in the folder it is in.
298
+
299
+ ```bash
300
+ canon tasks list
301
+ canon tasks list --json
302
+ ```
303
+
304
+ | Option | Effect |
305
+ | --------------- | ------------------------------------------- |
306
+ | `--json` | Emit a machine-readable record on stdout |
307
+ | `--root <path>` | Board root, defaulting to the main worktree |
308
+
309
+ The record carries `tasks`, one entry per file with its `stem` and its `readiness`. Readiness is one of `Run now`, `Up next`, `Needs a plan`, `backlog`, `unplaced`, or `both`. The first three are the group names the board standard fixes, `backlog` is a line on `backlog.md`, `unplaced` is a file neither surface names, and `both` is a file both surfaces name, which `validate` reports as a finding and this verb does not judge.
310
+
311
+ The verb calls the parsers `validate` uses, so the two cannot disagree on what a surface names. It lists the live root only, never `archive/` or `declined/`, and skips the same siblings `validate` skips.
312
+
313
+ Exit codes: `0` derived, `1` refused with `no-board`.
314
+
315
+ ```bash
316
+ canon tasks list --json | jq -r '.tasks[] | select(.readiness == "backlog") | .stem'
317
+ ```
318
+
295
319
  ## Validate
296
320
 
297
321
  `canon tasks validate` reports what each row of `priority.md` claims against what the tree holds. It reads and never writes, because a row is a session's claim about readiness and a validator that repaired one would be asserting the claim it exists to test.
@@ -265,7 +265,7 @@ Add `--json` for the machine-readable report, and `--exit-code` to fail a CI job
265
265
 
266
266
  Tooling reports under a section of its own, and `measured` there says whether the target ever recorded a chain. One that never ran a tooling sync reports unmeasured rather than clean, which is what separates tooling nobody has looked at from tooling that is current. A workspace root records nothing either way, since each package resolves its own chain.
267
267
 
268
- Reconcile the configs with `canon tooling sync <stack> <path> --check` to read which files differ, then re-run it with `--write` to apply them. The drift report counts categories and the sync names paths, which is the difference worth knowing before a golden config the project edited is replaced.
268
+ Reconcile the configs with `canon tooling diff <stack> <path>` to read which files differ, then run `canon tooling sync <stack> <path> --write` to apply them. The drift report counts categories and the diff names paths, which is the difference worth knowing before a golden config the project edited is replaced.
269
269
 
270
270
  ### Catch-all
271
271
 
@@ -65,7 +65,9 @@ The routing test is whether the repository can answer an item today. A session g
65
65
 
66
66
  A groundwork track may run experiments to settle a question, writing a fixture it reads itself under `.canon/tmp/runs/groundwork-fixtures/<slug>/` and spawning up to three billed headless runs before it asks. A fixture a headless run is pointed at sits outside the repository, since a session started under the project root inherits that project's `CLAUDE.md` and rules and would measure them instead of the arm.
67
67
 
68
- What a spike produces goes somewhere else again. An input the run reads is re-runnable and cited by nothing, so the scratch path above is the right lifetime for it, while a recording or a render the track cites as evidence for a finding is what a later reader opens to check the claim. Evidence therefore lives in `evidence/` inside the track beside the file citing it, since the scratch tree holds only what can be deleted without loss.
68
+ A spike starts against a sample, bounded on input size, duration, and spend, and scales up once the sample shows the method works. Nothing checks this, so it holds while a session reads the standard.
69
+
70
+ What a spike leaves behind splits on whether a later reader needs the file to check a claim. Bulk input, such as a large fixture, is re-runnable and cited by nothing, so the scratch path above is the right lifetime for it. Everything a reader opens to verify a result lives inside the track in one of three folders: `evidence/` for a recording or a render, `scripts/` for the arm scripts, and `clones/` for checkouts and copies of outside material a claim rests on. The scratch tree holds only what can be deleted without loss, which none of those is.
69
71
 
70
72
  #### Session 2
71
73
 
@@ -166,7 +168,7 @@ The list stays written in the skill body as the fallback for a target whose inst
166
168
 
167
169
  Capture leads rather than trails because a routed fact edits a tracked file, which has to reach the branch before the commit steps run.
168
170
 
169
- If capture wrote at least one memory file, `memory-review` then proposes a decision-ready fix scoped to those entries while context is fresh, otherwise it is skipped. It stops at Propose. Review the receipt and run Apply yourself, on its own commit separate from the feature.
171
+ The chain ends at capture, and review is run on its own.
170
172
 
171
173
  Run `memory-review` standalone to curate the whole pen. An entry it retires moves to `.canon/memory/archive/` rather than being deleted, since a bulk pass has no undo.
172
174
 
@@ -260,7 +262,7 @@ This section is the corpus the coverage claim is measured against: every name `c
260
262
  | `canon:git-commit` | When the staged set is one concern, or was staged hunk by hand |
261
263
  | `canon:git-branch` | When a branch name needs generating or renaming to conventional form |
262
264
  | `canon:git-pr` | When a pull request needs a title and body written from the diff |
263
- | `canon:memory-review` | After capture writes an entry, to propose where each one belongs |
265
+ | `canon:memory-review` | When the pen has grown, to propose where each entry belongs |
264
266
 
265
267
  ### After the pull request opens
266
268
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@erclx/canon",
3
3
  "type": "module",
4
- "version": "4.100.0",
4
+ "version": "4.102.0",
5
5
  "description": "Infrastructure and quality tooling for developer workflows",
6
6
  "license": "MIT",
7
7
  "bin": {
@@ -1,6 +1,6 @@
1
1
  /**
2
- * Writes web/public/favicon.svg from assets/brand/mark.svg and the design
3
- * source's two accent values.
2
+ * Writes web/public/favicon.svg from assets/brand/mark.svg and the favicon's
3
+ * own colors in src/design/favicon.ts, apart from the page accent tokens.
4
4
  *
5
5
  * The page is the fourth surface to carry the mark as a favicon and the only
6
6
  * one that can answer for itself. `regen-hero.sh` and `src/design/render.ts`
@@ -17,6 +17,8 @@
17
17
  */
18
18
  import { writeFileSync } from 'node:fs'
19
19
 
20
+ import { FAVICON_COLORS, renderFavicon } from '../../src/design/favicon'
21
+
20
22
  const root = new URL('../..', import.meta.url).pathname
21
23
 
22
24
  const mark = (await Bun.file(`${root}assets/brand/mark.svg`).text()).trim()
@@ -25,43 +27,10 @@ if (!mark) {
25
27
  process.exit(1)
26
28
  }
27
29
 
28
- const tokenCss = await Bun.file(`${root}web/src/styles/tokens.css`).text()
29
- const read = (name: string): string => {
30
- const match = tokenCss.match(
31
- new RegExp(`--color-${name}:\\s*(#[0-9a-fA-F]{3,8})`),
32
- )
33
- if (!match) {
34
- console.error(
35
- `regen-web-favicon: tokens.css carries no --color-${name}, refusing to write a colorless favicon`,
36
- )
37
- process.exit(1)
38
- }
39
- return match[1] as string
40
- }
41
-
42
- const dark = read('accent')
43
- const light = read('light-accent')
44
-
45
- // The shapes only, with the source's authoring comment dropped. Both fills are
46
- // replaced by a rule rather than an attribute so one branch can flip both.
47
- const shapes = mark
48
- .replace(/<!--[\s\S]*?-->/, '')
49
- .trim()
50
- .replace(/^<svg[^>]*>/, '')
51
- .replace(/<\/svg>$/, '')
52
- .replaceAll(' fill="currentColor"', '')
53
- .trim()
54
-
55
- const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="10 10 80 80">
56
- <style>
57
- path, rect { fill: ${light}; }
58
- @media (prefers-color-scheme: dark) { path, rect { fill: ${dark}; } }
59
- </style>
60
- ${shapes.replace(/\n\s*/g, '\n ')}
61
- </svg>
62
- `
63
-
64
- writeFileSync(`${root}web/public/favicon.svg`, svg)
30
+ writeFileSync(
31
+ `${root}web/public/favicon.svg`,
32
+ renderFavicon(mark, FAVICON_COLORS),
33
+ )
65
34
  console.log(
66
- `regen-web-favicon: wrote web/public/favicon.svg (${light} / ${dark})`,
35
+ `regen-web-favicon: wrote web/public/favicon.svg (${FAVICON_COLORS.light} / ${FAVICON_COLORS.dark})`,
67
36
  )
@@ -14,6 +14,7 @@ import {
14
14
  planCitations,
15
15
  } from '@/tasks/archive'
16
16
  import { type LabelOutcome, nextLabel } from '@/tasks/label'
17
+ import { listTasks } from '@/tasks/list'
17
18
  import {
18
19
  type Claim,
19
20
  type Holder,
@@ -114,6 +115,11 @@ interface OutcomeCommandOptions {
114
115
  readonly root?: string
115
116
  }
116
117
 
118
+ interface ListCommandOptions {
119
+ readonly json?: boolean
120
+ readonly root?: string
121
+ }
122
+
117
123
  interface NextLabelCommandOptions {
118
124
  readonly json?: boolean
119
125
  readonly root?: string
@@ -522,6 +528,37 @@ export function register(program: Command): void {
522
528
  .action(async (opts: NextLabelCommandOptions) => {
523
529
  process.exitCode = await runNextLabel(opts)
524
530
  })
531
+
532
+ tasks
533
+ .command('list')
534
+ .description('Report each live task file with its readiness')
535
+ .helpOption('-h, --help', 'Show this help message')
536
+ .option('--json', 'Emit a machine-readable record on stdout')
537
+ .option('--root <path>', 'Board root, defaulting to the main worktree')
538
+ .addHelpText(
539
+ 'after',
540
+ [
541
+ '',
542
+ 'Readiness is one of Run now, Up next, Needs a plan, backlog, unplaced,',
543
+ 'or both, read from priority.md and backlog.md through the parsers',
544
+ 'validate uses. It lists the live root only, never archive/ or declined/.',
545
+ '',
546
+ 'Exit codes:',
547
+ ' 0 the list is derived',
548
+ ' 1 refused with no-board',
549
+ '',
550
+ 'It reports and never writes, and judges nothing: a file named on both',
551
+ 'surfaces reads as both, and validate owns the finding.',
552
+ '',
553
+ 'Examples:',
554
+ ' canon tasks list',
555
+ ' canon tasks list --json',
556
+ '',
557
+ ].join('\n'),
558
+ )
559
+ .action(async (opts: ListCommandOptions) => {
560
+ process.exitCode = await runList(opts)
561
+ })
525
562
  }
526
563
 
527
564
  function collectPosition(value: string, previous: string[]): string[] {
@@ -792,6 +829,38 @@ function reportOutcome(
792
829
  return 0
793
830
  }
794
831
 
832
+ async function runList(opts: ListCommandOptions): Promise<number> {
833
+ const root = opts.root ?? (await mainWorktreeRoot())
834
+ const outcome = await listTasks(root)
835
+
836
+ if (!outcome.ok) {
837
+ if (opts.json) {
838
+ process.stdout.write(
839
+ `${JSON.stringify({ ok: false, reason: outcome.reason, message: outcome.message })}\n`,
840
+ )
841
+ return 1
842
+ }
843
+
844
+ intro('canon tasks list')
845
+ logStep('Refused')
846
+ logError(outcome.message)
847
+ outro()
848
+ return 1
849
+ }
850
+
851
+ if (opts.json) {
852
+ process.stdout.write(`${JSON.stringify({ ...outcome, root })}\n`)
853
+ return 0
854
+ }
855
+
856
+ intro('canon tasks list')
857
+ logStep(`${outcome.tasks.length} task files`)
858
+ for (const task of outcome.tasks) logInfo(`${task.readiness}: ${task.stem}`)
859
+ outro()
860
+
861
+ return 0
862
+ }
863
+
795
864
  async function runNextLabel(opts: NextLabelCommandOptions): Promise<number> {
796
865
  const root = opts.root ?? (await mainWorktreeRoot())
797
866
  const outcome = await nextLabel(root)
@@ -39,6 +39,12 @@ interface SyncOptions {
39
39
  readonly skip?: string
40
40
  readonly check?: boolean
41
41
  readonly write?: boolean
42
+ readonly diff?: { readonly json: boolean }
43
+ }
44
+
45
+ interface DiffOptions {
46
+ readonly skip?: string
47
+ readonly json?: boolean
42
48
  }
43
49
 
44
50
  interface InjectOptions {
@@ -59,7 +65,7 @@ interface ListOptions {
59
65
 
60
66
  type Prepared =
61
67
  | { readonly ok: true; readonly chain: Manifest[]; readonly target: string }
62
- | { readonly ok: false; readonly error: string }
68
+ | { readonly ok: false; readonly reason: string; readonly error: string }
63
69
 
64
70
  export function register(program: Command): void {
65
71
  const tooling = program
@@ -74,7 +80,10 @@ export function register(program: Command): void {
74
80
  .argument('[target]', 'Target directory', '.')
75
81
  .helpOption('-h, --help', 'Show this help message')
76
82
  .option('--skip <stack>', 'Drop a layer from the extends chain')
77
- .option('--check', 'Report what would change and write nothing')
83
+ .option(
84
+ '--check',
85
+ 'Report what would change and write nothing, always exiting 0. Prefer `canon tooling diff`',
86
+ )
78
87
  .option('--write', 'Apply every change without prompting')
79
88
  .addHelpText(
80
89
  'after',
@@ -98,6 +107,42 @@ export function register(program: Command): void {
98
107
  },
99
108
  )
100
109
 
110
+ tooling
111
+ .command('diff')
112
+ .description('Report how a target differs from a stack and write nothing')
113
+ .argument('[stack]', 'Tooling stack name (e.g. base, vite-react)')
114
+ .argument('[target]', 'Target directory', '.')
115
+ .helpOption('-h, --help', 'Show this help message')
116
+ .option('--skip <stack>', 'Drop a layer from the extends chain')
117
+ .option('--json', 'Add a machine-readable record on stdout')
118
+ .addHelpText(
119
+ 'after',
120
+ [
121
+ '',
122
+ 'The comparison `canon tooling sync --check` reports, under a name that',
123
+ 'says it only asks. Exit codes:',
124
+ ' 0 the target matches the stack',
125
+ ' 1 a file, script, dependency, or gitignore entry differs, or a refusal',
126
+ '',
127
+ '`sync --check` reports the same list and always exits 0. Use `diff` to',
128
+ 'gate CI, and keep `--check` for scripts that already call it.',
129
+ '',
130
+ 'Examples:',
131
+ ' canon tooling diff base',
132
+ ' canon tooling diff base --json',
133
+ '',
134
+ ].join('\n'),
135
+ )
136
+ .action(
137
+ async (stack: string | undefined, target: string, opts: DiffOptions) => {
138
+ process.exitCode = await runSync(stack, target, {
139
+ skip: opts.skip,
140
+ check: true,
141
+ diff: { json: opts.json === true },
142
+ })
143
+ },
144
+ )
145
+
101
146
  tooling
102
147
  .command('inject')
103
148
  .description('Apply one stack to a target without scanning or prompting')
@@ -239,18 +284,27 @@ function prepare(stack: string, target: string, skip?: string): Prepared {
239
284
  if (mismatch !== undefined) logWarn(mismatch)
240
285
 
241
286
  if (!stackExists(PROJECT_ROOT, stack)) {
242
- return { ok: false, error: `Stack not found: ${stack}` }
287
+ return {
288
+ ok: false,
289
+ reason: 'unknown-stack',
290
+ error: `Stack not found: ${stack}`,
291
+ }
243
292
  }
244
293
 
245
294
  if (skip !== undefined) {
246
295
  if (skip === stack) {
247
296
  return {
248
297
  ok: false,
298
+ reason: 'bad-skip',
249
299
  error: `Cannot --skip the stack being synced: ${skip}`,
250
300
  }
251
301
  }
252
302
  if (!stackExists(PROJECT_ROOT, skip)) {
253
- return { ok: false, error: `Stack to skip not found: ${skip}` }
303
+ return {
304
+ ok: false,
305
+ reason: 'unknown-skip',
306
+ error: `Stack to skip not found: ${skip}`,
307
+ }
254
308
  }
255
309
  }
256
310
 
@@ -258,6 +312,7 @@ function prepare(stack: string, target: string, skip?: string): Prepared {
258
312
  if (resolved === PROJECT_ROOT) {
259
313
  return {
260
314
  ok: false,
315
+ reason: 'toolkit-root',
261
316
  error:
262
317
  'Cannot run against toolkit root. Files here are the source of truth.',
263
318
  }
@@ -275,40 +330,44 @@ async function runSync(
275
330
  target: string,
276
331
  opts: SyncOptions,
277
332
  ): Promise<number> {
278
- intro('canon tooling sync')
333
+ intro(opts.diff === undefined ? 'canon tooling sync' : 'canon tooling diff')
279
334
 
280
335
  if (opts.check === true && opts.write === true) {
281
- logWarn('Pass --check or --write, not both.')
282
- outro()
283
- return 1
336
+ return refuse(
337
+ opts,
338
+ 'conflicting-flags',
339
+ 'Pass --check or --write, not both.',
340
+ )
284
341
  }
285
342
 
286
343
  const selected = stack ?? (await promptForStack())
287
344
  if (selected === undefined) {
288
- logWarn('No tooling stacks found')
289
- outro()
290
- return 1
345
+ return refuse(opts, 'no-stacks', 'No tooling stacks found')
291
346
  }
292
347
 
293
348
  if (isStackExcluded(selected)) {
294
- logWarn(
349
+ return refuse(
350
+ opts,
351
+ 'excluded-stack',
295
352
  'Claude is managed by `canon claude`, not `canon tooling`. Run `canon claude sync` instead.',
296
353
  )
297
- outro()
298
- return 1
299
354
  }
300
355
 
301
356
  const prepared = prepare(selected, target, opts.skip)
302
- if (!prepared.ok) {
303
- logWarn(prepared.error)
304
- outro()
305
- return 1
306
- }
357
+ if (!prepared.ok) return refuse(opts, prepared.reason, prepared.error)
307
358
 
308
359
  const result = scan(prepared.chain, prepared.target)
309
360
 
310
361
  report(result)
311
362
 
363
+ if (opts.diff !== undefined) {
364
+ if (opts.diff.json) {
365
+ process.stdout.write(`${JSON.stringify({ ok: true, ...result })}\n`)
366
+ }
367
+ outro()
368
+ return result.totalChanges === 0 ? 0 : 1
369
+ }
370
+
312
371
  const mode = resolveWriteMode(opts)
313
372
  const { GREEN, NC } = palette(process.stderr)
314
373
 
@@ -353,6 +412,20 @@ async function runSync(
353
412
  return 0
354
413
  }
355
414
 
415
+ /**
416
+ * A diff caller branches on the record rather than the exit, since a shell
417
+ * wrapper can flatten the status. Every refusal therefore answers with a reason
418
+ * on stdout under `--json`, beside the exit 1 that keeps a gate failing.
419
+ */
420
+ function refuse(opts: SyncOptions, reason: string, message: string): number {
421
+ logWarn(message)
422
+ if (opts.diff?.json) {
423
+ process.stdout.write(`${JSON.stringify({ ok: false, reason, message })}\n`)
424
+ }
425
+ outro()
426
+ return 1
427
+ }
428
+
356
429
  type WriteMode = 'apply' | 'prompt' | 'report' | 'unauthorized'
357
430
 
358
431
  type ApplyDecision = 'apply' | 'cancelled' | 'reported' | 'unauthorized'
@@ -0,0 +1,33 @@
1
+ export interface FaviconColors {
2
+ readonly light: string
3
+ readonly dark: string
4
+ }
5
+
6
+ /** The favicon's own fills, chosen for tab size and apart from the page accent tokens. */
7
+ export const FAVICON_COLORS: FaviconColors = {
8
+ light: '#c42938',
9
+ dark: '#e54e40',
10
+ }
11
+
12
+ /**
13
+ * The shapes only, with the source's authoring comment dropped. Both fills are
14
+ * replaced by a rule rather than an attribute so one branch can flip both.
15
+ */
16
+ export function renderFavicon(mark: string, colors: FaviconColors): string {
17
+ const shapes = mark
18
+ .replace(/<!--[\s\S]*?-->/, '')
19
+ .trim()
20
+ .replace(/^<svg[^>]*>/, '')
21
+ .replace(/<\/svg>$/, '')
22
+ .replaceAll(' fill="currentColor"', '')
23
+ .trim()
24
+
25
+ return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="10 10 80 80">
26
+ <style>
27
+ path, rect { fill: ${colors.light}; }
28
+ @media (prefers-color-scheme: dark) { path, rect { fill: ${colors.dark}; } }
29
+ </style>
30
+ ${shapes.replace(/\n\s*/g, '\n ')}
31
+ </svg>
32
+ `
33
+ }
@@ -377,7 +377,7 @@ export const TOKENS: DesignTokens = {
377
377
  iconography: [
378
378
  'No icon library is installed. `assets/brand/mark.svg` is the one authored icon, embedded inline in the hero topbar, and the surfaces otherwise draw literal glyph characters: `│ ├ ✓ ! ✗ + - ◆ ◇ ❯` for the terminal framing.',
379
379
  '',
380
- 'The same mark ships as a favicon on every rendered surface, as three independently-maintained copies that track different accents by design rather than by drift, colored to fit the chrome each renders on: the dark accent (`#e0724b`) for a dark-chrome surface and the light accent (`#a4471c`) for a light-chrome one. Unifying the three or repairing the one that looks drifted would break the fit each was chosen for. `canon/context/design.md` carries which file holds each copy.',
380
+ 'The same mark ships in six independently-maintained copies that carry different colors by design rather than by drift, each fitted to the chrome it renders on: the dark accent for a dark-chrome surface, the light accent for a light-chrome one, and the pair the web favicon takes from `src/design/favicon.ts`. Unifying them or repairing the one that looks drifted would break the fit each was chosen for. `canon/context/design.md` carries which file holds each copy.',
381
381
  ].join('\n'),
382
382
  }
383
383
 
@@ -0,0 +1,98 @@
1
+ import { existsSync } from 'node:fs'
2
+ import { readFile } from 'node:fs/promises'
3
+ import { listTaskStems, tasksDir } from '@/tasks/archive'
4
+ import {
5
+ BOARD_GROUPS,
6
+ type BoardGroup,
7
+ backlogPath,
8
+ orderingPath,
9
+ readBacklog,
10
+ readBoard,
11
+ } from '@/tasks/validate'
12
+
13
+ /**
14
+ * The three board group names plus the states outside them. A file named on
15
+ * both surfaces is `validate`'s finding, so it reports as `both` rather than
16
+ * being judged here.
17
+ */
18
+ export const READINESS_VALUES = [
19
+ ...BOARD_GROUPS,
20
+ 'backlog',
21
+ 'unplaced',
22
+ 'both',
23
+ ] as const
24
+
25
+ export type Readiness = (typeof READINESS_VALUES)[number]
26
+
27
+ export interface ListedTask {
28
+ readonly stem: string
29
+ readonly readiness: Readiness
30
+ }
31
+
32
+ export interface TaskList {
33
+ readonly ok: true
34
+ readonly tasks: readonly ListedTask[]
35
+ }
36
+
37
+ export interface ListRefused {
38
+ readonly ok: false
39
+ readonly reason: 'no-board'
40
+ readonly message: string
41
+ }
42
+
43
+ export type ListOutcome = TaskList | ListRefused
44
+
45
+ /**
46
+ * Reports each live task file's readiness from the board and the backlog. It
47
+ * reads through the parsers `validate` exports rather than reading either file
48
+ * a second time, so the two verbs cannot disagree on what a surface names. An
49
+ * absent ordering file or backlog reads as empty, since a file in neither
50
+ * surface is reported `unplaced` rather than refused.
51
+ */
52
+ export async function listTasks(root: string): Promise<ListOutcome> {
53
+ const dir = tasksDir(root)
54
+ if (!existsSync(dir)) {
55
+ return {
56
+ ok: false,
57
+ reason: 'no-board',
58
+ message: `No task board at ${dir}.`,
59
+ }
60
+ }
61
+
62
+ const ordering = orderingPath(root)
63
+ const { rows } = existsSync(ordering)
64
+ ? readBoard(await readFile(ordering, 'utf8'))
65
+ : { rows: [] }
66
+
67
+ const backlogFile = backlogPath(root)
68
+ const backlog = existsSync(backlogFile)
69
+ ? readBacklog(await readFile(backlogFile, 'utf8'))
70
+ : []
71
+
72
+ const groupOf = new Map<string, BoardGroup>()
73
+ for (const row of rows) {
74
+ if (row.stem) groupOf.set(row.stem, row.group)
75
+ }
76
+ const parked = new Set(backlog.flatMap((row) => (row.stem ? [row.stem] : [])))
77
+
78
+ const stems = await listTaskStems(dir)
79
+
80
+ return {
81
+ ok: true,
82
+ tasks: stems.map((stem) => ({
83
+ stem,
84
+ readiness: readinessOf(groupOf.get(stem), parked.has(stem)),
85
+ })),
86
+ }
87
+ }
88
+
89
+ function readinessOf(
90
+ group: BoardGroup | undefined,
91
+ isParked: boolean,
92
+ ): Readiness {
93
+ if (group && isParked) return 'both'
94
+ if (group) return group
95
+ if (isParked) return 'backlog'
96
+
97
+ return 'unplaced'
98
+ }
@@ -130,9 +130,15 @@ Each spike carries four things:
130
130
 
131
131
  Cost is a report rather than a limit, and it is what makes the next spike estimable before anyone commits to it. Record it even when it comes to a single read.
132
132
 
133
- A spike also leaves files behind, and they split on whether the track cites them rather than on whether they are markdown. An input the run reads, being a fixture page, an arm script, or a copied asset, is re-runnable and cited by nothing, so it stays outside the track wherever the surface driving the spike puts it. Evidence the record cites, being a recording, a render, or a frame pulled from one, is what a later reader opens to check a claim, so it lives in `evidence/` inside the track beside the file citing it. Name the split rather than the file types, which is what keeps this from going stale on the next kind of artifact a spike produces.
133
+ Start a spike against a sample and scale up only once the sample shows the method works. Bound the sample on all three of input size, duration, and spend, since fifty minutes of audio is a runtime and a bill before it is a file size, and three minutes of it usually shows whether the pipeline runs at all. The same holds for a dataset, an image set, or a batch of queries. Record the sample's bounds and the figure for the full input in the spike, so a reader can tell what the result was measured on and what the scale-up costs. The rule reaches only a track, since that is the surface that reads this standard, and nothing checks that a spike ran small first, so it holds only while a session reads it.
134
134
 
135
- `evidence/` takes no number, since numbering is the read order over the files a reader opens in sequence and an artifact is reached from the claim that cites it instead.
135
+ A spike also leaves files behind, and they split on whether a later reader needs the file to check a claim rather than on whether it is markdown or re-runnable. A file a reader opens to verify how a result was produced or what it showed lives inside the track. A file that is only bulk input, such as a large fixture or a copied asset, stays outside it wherever the surface driving the spike puts it. Three subfolders inside the track carry the first kind, and none takes a number, since numbering is the read order over the files a reader opens in sequence and these are reached from the claim that cites them instead:
136
+
137
+ - `evidence/`: what a run produced and the record cites, being a recording, a render, or a frame pulled from one, beside the file citing it.
138
+ - `scripts/`: the arm scripts and harnesses a spike ran. An arm script is re-runnable, and it is also what a reader opens to check the method, so this refines the earlier split, which sorted on re-runnable alone, and does not contradict it.
139
+ - `clones/`: checkouts and copies of outside material a claim rests on, kept small enough to hold in the folder. A checkout too large to keep is cited by its address and commit instead.
140
+
141
+ `spikes/`, `web/`, and `research/` were considered and folded into these, so do not reopen them. A spike's files sit under the three above, and a fetched page belongs in `clones/`. The `web/` case is the arguable one, since a screenshot of a reference site could sit apart, and it folds into `evidence/` because that is where a cited artifact already lives.
136
142
 
137
143
  Reach for a test harness the project already carries before building one. A track needing an experiment no existing harness can express has found a finding, and it belongs in the folder rather than in a new abstraction.
138
144
 
@@ -37,6 +37,8 @@ Does not govern:
37
37
  └── v13.0-toolkit-drift.md # canon-allow-reference: illustrates the vXX.Y-slug filename this section defines
38
38
  ```
39
39
 
40
+ `canon tasks list` reports each file's readiness from those two surfaces, so a reader never infers it from the folder.
41
+
40
42
  One file per task is what keeps the board safe under parallel sessions. Two sessions working different tasks never write the same file, which matters because a gitignored board has no history to recover a clobbered write from.
41
43
 
42
44
  Siblings sit in the folder without being tasks, and each earns its place by being governed somewhere. `index.md`, `priority.md`, and `backlog.md` are governed here. Every `session-` file is a pre-compaction handoff governed by `session.md`, and each is optional: a project whose sessions never approach a compaction carries none. Anything filtering the folder to tasks skips all of them, so a name outside the set is a task whatever it holds.
@@ -18,9 +18,9 @@ packages = [
18
18
 
19
19
  [scripts]
20
20
  "check:spell" = "cspell '**' '.*/**' '.*' --no-progress --color --show-context"
21
- "check:format" = "prettier --check --log-level warn --ignore-path .gitignore --ignore-path .prettierignore . && shfmt --diff --indent 2 scripts/"
21
+ "check:format" = "prettier --check --log-level warn --ignore-path .gitignore --ignore-path .prettierignore . && find scripts -name '*.sh' -exec shfmt --diff --indent 2 {} +"
22
22
  "check:shell" = "find scripts -name '*.sh' -exec shellcheck --severity=warning {} +"
23
- "format" = "prettier --write --log-level warn --ignore-path .gitignore --ignore-path .prettierignore . && shfmt --write --indent 2 scripts/"
23
+ "format" = "prettier --write --log-level warn --ignore-path .gitignore --ignore-path .prettierignore . && find scripts -name '*.sh' -exec shfmt --write --indent 2 {} +"
24
24
  "prepare" = "husky"
25
25
  "check" = "./scripts/verify.sh"
26
26
 
@@ -35,7 +35,7 @@ Seeds live in `tooling/base/seeds/`. Sync drops each once on first install and n
35
35
  - Runtime: `bun` as package manager and script runner, `bunx` over `npx` for a one-off executable.
36
36
  - Formatting: Prettier for what it parses, shfmt for shell. Two formatters because Prettier has no shell parser.
37
37
  - Spelling: cspell over the whole tree, with project vocabulary split into a project-terms dictionary and a tech-stack one.
38
- - Shell: shfmt formats and shellcheck lints at warning severity. shfmt takes a directory argument, shellcheck has no directory mode and needs `find`.
38
+ - Shell: shfmt formats and shellcheck lints at warning severity. Both take their files from `find scripts -name '*.sh'`, since `scripts/` can mix shell with TypeScript and shellcheck has no directory mode. A shell script with a shebang and no `.sh` extension is covered by neither, so give it the extension.
39
39
  - Commits: commitlint against conventional commits, wired through the husky `commit-msg` hook. Format is `<type>(<scope>): <subject>` in imperative mood with no trailing period.
40
40
  - Dev dependencies: `prettier`, `cspell`, `husky`, `@commitlint/cli`, `@commitlint/config-conventional`. Install via `bun add -D`.
41
41
 
@@ -137,10 +137,15 @@ async function settle(
137
137
  for (const frame of document.querySelectorAll('iframe')) {
138
138
  try {
139
139
  const inner = frame.contentDocument
140
- if (inner !== null && inner.readyState !== 'complete') return false
140
+ if (inner === null) continue
141
+ if (inner.readyState !== 'complete') return false
142
+ // A frame's own fonts load after its `readyState` reads complete, and
143
+ // the swap reflows its text. The outer document's fonts never cover
144
+ // them.
145
+ if (inner.fonts.status !== 'loaded') return false
141
146
  } catch {
142
- // Cross-origin, so there is no readyState to read and nothing here
143
- // can wait on it.
147
+ // Cross-origin, so there is nothing here to read and nothing to
148
+ // wait on.
144
149
  }
145
150
  }
146
151
  return true