@erclx/canon 5.23.0 → 5.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/claude/.claude-plugin/plugin.json +1 -1
  2. package/claude/skills/backlog-triage/REQUIREMENT.md +2 -2
  3. package/claude/skills/backlog-triage/SKILL.md +19 -6
  4. package/claude/skills/canon-operator/REQUIREMENT.md +1 -0
  5. package/claude/skills/canon-operator/SKILL.md +5 -1
  6. package/claude/skills/canvas/REQUIREMENT.md +49 -0
  7. package/claude/skills/canvas/SKILL.md +106 -0
  8. package/claude/skills/design-extract/REQUIREMENT.md +1 -1
  9. package/claude/skills/draft-doc/REQUIREMENT.md +2 -1
  10. package/claude/skills/draft-doc/references/readme/agent-facing.md +59 -0
  11. package/claude/skills/draft-doc/references/readme/application.md +66 -0
  12. package/claude/skills/draft-doc/references/readme/cli.md +64 -0
  13. package/claude/skills/draft-doc/references/readme/library.md +57 -0
  14. package/claude/skills/draft-doc/references/readme/plugin.md +66 -0
  15. package/claude/skills/draft-doc/references/readme.md +33 -4
  16. package/claude/skills/draft-figure/REQUIREMENT.md +14 -3
  17. package/claude/skills/draft-figure/SKILL.md +24 -51
  18. package/claude/skills/draft-figure/references/architecture-set.md +35 -0
  19. package/claude/skills/draft-figure/references/hand-drawn.md +31 -0
  20. package/claude/skills/draft-figure/references/mermaid.md +55 -0
  21. package/claude/skills/git-followup/SKILL.md +17 -6
  22. package/claude/skills/git-pr/references/evidence.md +4 -2
  23. package/claude/skills/git-split/SKILL.md +1 -1
  24. package/claude/skills/index-lookup/SKILL.md +2 -2
  25. package/claude/skills/review-address/SKILL.md +6 -13
  26. package/claude/skills/review-address/references/rest-reads.md +43 -0
  27. package/claude/skills/review-craft/references/ui.md +7 -1
  28. package/claude/skills/review-pr/REQUIREMENT.md +1 -0
  29. package/claude/skills/review-pr/SKILL.md +9 -3
  30. package/claude/skills/review-pr/references/close-out.md +2 -2
  31. package/claude/skills/review-pr/references/post.md +17 -5
  32. package/claude/skills/review-pr/references/scope.md +10 -8
  33. package/claude/skills/review-ui/SKILL.md +12 -4
  34. package/claude/skills/role-orchestrator/references/orchestrator-cloud-launch.md +30 -1
  35. package/claude/skills/role-orchestrator/references/orchestrator-launch.md +4 -0
  36. package/claude/skills/role-orchestrator/references/orchestrator-poll.md +4 -4
  37. package/claude/skills/role-orchestrator/references/orchestrator-review-dispatch.md +6 -0
  38. package/claude/skills/role-orchestrator/scripts/poll.ts +90 -27
  39. package/claude/skills/role-orchestrator/scripts/repo.ts +3 -5
  40. package/claude/skills/role-orchestrator/scripts/watch.ts +4 -7
  41. package/docs/agents/canvas.md +6 -2
  42. package/docs/agents/commands.md +45 -45
  43. package/docs/agents/context-audit-checks.md +2 -2
  44. package/docs/agents/index.md +0 -1
  45. package/docs/agents/key-changes.md +3 -3
  46. package/docs/agents/pr-evidence.md +11 -2
  47. package/docs/agents/pr-reads.md +5 -4
  48. package/docs/target/projects.md +4 -4
  49. package/docs/workflow/ai-workflow.md +0 -1
  50. package/docs/workflow/skill-map.md +3 -3
  51. package/docs/workflow/visual-design-workflow.md +2 -1
  52. package/governance/renames.toml +0 -4
  53. package/governance/rules/standards/markdown.md +1 -0
  54. package/package.json +1 -2
  55. package/scripts/core/install-check.sh +1 -1
  56. package/src/audits/catalog.ts +2 -2
  57. package/src/canvas/client/inspector/field.tsx +200 -0
  58. package/src/canvas/client/inspector/fields.css +151 -0
  59. package/src/canvas/client/inspector/section.tsx +27 -0
  60. package/src/canvas/client/inspector/values.ts +60 -0
  61. package/src/canvas/client/inspector.tsx +192 -111
  62. package/src/canvas/client/shell.css +0 -94
  63. package/src/canvas/tokens.ts +9 -1
  64. package/src/claude/cases/misc.ts +5 -0
  65. package/src/claude/cases/workflow.ts +1 -1
  66. package/src/claude/seeds.ts +0 -1
  67. package/src/commands/canvas.ts +29 -0
  68. package/src/commands/design.ts +2 -64
  69. package/src/commands/pr.ts +219 -202
  70. package/src/context/audit.ts +7 -6
  71. package/src/design/budget.ts +149 -0
  72. package/src/design/document.ts +22 -10
  73. package/src/design/render.ts +11 -1
  74. package/src/design/tokens.ts +40 -61
  75. package/src/gate/measures.ts +49 -0
  76. package/src/gate/stages.ts +5 -1
  77. package/src/help.ts +2 -2
  78. package/src/migrate/skill-names.ts +7 -1
  79. package/src/pr/evidence.ts +25 -5
  80. package/src/pr/rest.ts +137 -0
  81. package/standards/canvas.md +112 -0
  82. package/standards/context.md +2 -1
  83. package/standards/design.md +13 -3
  84. package/standards/figures.md +0 -1
  85. package/standards/index.md +1 -1
  86. package/standards/markdown.md +1 -0
  87. package/standards/mermaid.md +1 -7
  88. package/tooling/claude/reference.md +0 -5
  89. package/tooling/claude/seeds/.claude/hooks/scratch-guard.sh +1 -1
  90. package/claude/skills/draft-diagram/REQUIREMENT.md +0 -45
  91. package/claude/skills/draft-diagram/SKILL.md +0 -177
  92. package/docs/agents/design-board.md +0 -33
  93. package/governance/rules/standards/diagrams.md +0 -14
  94. package/governance/rules/standards/mermaid.md +0 -15
  95. package/scripts/core/check-gallery-exclusion.sh +0 -50
  96. package/src/design/board.ts +0 -576
  97. package/standards/diagrams.md +0 -132
  98. package/tooling/claude/seeds/.claude/diagrams/index.md +0 -8
@@ -12,7 +12,13 @@ The diff touches a file that paints: markup, a stylesheet, a component or templa
12
12
 
13
13
  Run this before any other section, since a pass that finds nothing to open otherwise reads the stylesheet instead and reports as if it had looked. Read its answer before composing any report or body, never in the same batch as the write, since what it decides is a finding the report has to carry.
14
14
 
15
- Read `gh pr view <number> --json comments,headRefOid` and take the comment whose body carries a `<!-- pr-evidence: head=<sha> -->` marker. Match the marker, never the first line, since the body opens with a `**Preview:**` line when the project has a preview address.
15
+ Read the thread over REST, which a cloud session's GitHub proxy serves where it refuses GraphQL, and take the head off `canon pr head <number> --json`, from the record's `tip`:
16
+
17
+ ```bash
18
+ gh api --paginate 'repos/{owner}/{repo}/issues/<number>/comments?per_page=100' --jq '.[] | select((.body // "") | contains("<!-- pr-evidence: head=")) | .body'
19
+ ```
20
+
21
+ Take the comment whose body carries a `<!-- pr-evidence: head=<sha> -->` marker. Match the marker, never the first line, since the body opens with a `**Preview:**` line when the project has a preview address.
16
22
 
17
23
  Flag the change as shipping no screenshot when it paints and the pull request carries no marked comment, carries one with no `## Evidence` section, or carries only a checklist posted alone. Then decide how to raise it by whether the project keeps captures, which is the same test `canon pr evidence` applies:
18
24
 
@@ -27,6 +27,7 @@ Scoping a later pass off GitHub's review stamp skipped real work. On 2026-09-07
27
27
  - Post the closing pass even with nothing to report, since a review left unanswered reads as one nobody closed
28
28
  - State a withdrawal or a regrade on the thread with the fact that settled it, since a finding dropped in silence reads the same as one nobody answered
29
29
  - Key the body file on the pull request number and the head commit, and on the response it answers once the head repeats, so no two passes overwrite each other
30
+ - Link a file-bound finding to its line at the reviewed head, since a bare file block leaves a reader hunting the diff for the spot
30
31
  - Scan the comment for banned characters and internal phase labels before posting, since a finding phrased against a phase label reaches a reader with no task board
31
32
  - Answer a `## For the reviewer` bullet in the body, bounded to the section itself rather than the Summary or the Technical Context around it, so the independent vantage stays clear of the author's framing
32
33
  - Confirm each `**Review focus:**` item the plan names on a first pass, and turn one the pass cannot confirm into a finding, since the planning session named the breaking inputs before the diff existed and no other reader holds that list
@@ -30,7 +30,7 @@ reader scanning the thread finds the current verdict where the last one sat.
30
30
 
31
31
  ## Guards
32
32
 
33
- - If no open PR resolves for the target branch via `gh pr view`, stop: `❌ No open PR to review. Open one first, or use /review-branch for local changes.`
33
+ - If no open PR resolves for the target branch via `canon pr head --json`, stop: `❌ No open PR to review. Open one first, or use /review-branch for local changes.`
34
34
  - Review and post. Do not merge. Merging is the human's gate.
35
35
 
36
36
  ## Step 1: resolve the PR and read context
@@ -43,9 +43,15 @@ date -u +%Y-%m-%dT%H:%M:%SZ
43
43
 
44
44
  Everything from that line to the post is the compose window, and a commit pushed inside it is one this pass never saw. Stamping the body with the instant the window opened is what leaves that commit outside the covered range, so the next pass reads it rather than assuming it covered. Taking the stamp later, at the head resolution below or at Step 4 where the body is composed, claims a stretch this pass had already stopped reading through.
45
45
 
46
- Then resolve the PR: `gh pr view --json number,headRefName,headRefOid,title,body` for the current branch, or use a PR number the user names. Take `<headRefOid>` from `canon pr head <number> --json`, off that record's `tip`, and fall back to the `headRefOid` field above when no record comes back, which is a target whose CLI predates the verb. The first seven characters are `<short-sha>`, which names the body file in Step 4.
46
+ Then resolve the PR through `canon pr head --json` for the current branch, or `canon pr head <number> --json` for a number the user names. Take `<number>` off the record's `number` and `<headRefOid>` off its `tip`. The first seven characters are `<short-sha>`, which names the body file in Step 4. Read the title and body over REST:
47
47
 
48
- `<headRefOid>` and `<read-at>` travel together into Step 4's marker, and neither is re-derived after this point. Re-reading the head later in the pass would name a commit this pass did not review, which is the defect the marker exists against, reached from the inside.
48
+ ```bash
49
+ gh api repos/{owner}/{repo}/pulls/<number> --jq '{title, body, head: .head.sha, repo: .base.repo.html_url}'
50
+ ```
51
+
52
+ Every read in this skill runs on REST, since the `gh pr` lookups run on GraphQL and a cloud session's GitHub proxy refuses it. A target whose CLI predates the verb gets no record back. Fall back there to the `head` field above for `<headRefOid>`, and to `gh api "repos/{owner}/{repo}/pulls?head={owner}:<branch>&state=open" --jq '.[].number'` for a branch with no number named, stopping on the guard above when that prints anything but one number.
53
+
54
+ `<headRefOid>` and `<read-at>` travel into Step 4's marker, and `<headRefOid>` with the `repo` field as `<repo-url>` into its finding links. None is re-derived after this point. Re-reading the head later in the pass would name a commit this pass did not review, which is the defect the marker exists against, reached from the inside.
49
55
 
50
56
  Read these in parallel from the project root, skipping any that do not exist:
51
57
 
@@ -9,10 +9,10 @@ Part of Step 4 of `review-pr`, reached only on the branch `post.md` names. Every
9
9
 
10
10
  When `<prior-heading>` from Step 2 reads `## Review closed` and this pass carries nothing owed, the thread already holds this verdict. Replace the standing close-out rather than posting a second one beside it.
11
11
 
12
- Resolve its numeric id. `gh pr view --json reviews` carries a GraphQL node id under `id`, which no REST route accepts, so read the id off the REST listing instead:
12
+ Resolve its numeric id off the REST listing, keeping the newest match across every page:
13
13
 
14
14
  ```bash
15
- gh api repos/{owner}/{repo}/pulls/<number>/reviews --jq '[.[] | select((.body // "") | split("\n")[0] | rtrimstr("\r") == "## Review closed")] | last | .id'
15
+ gh api --paginate 'repos/{owner}/{repo}/pulls/<number>/reviews?per_page=100' --jq '.[] | select((.body // "") | split("\n")[0] | rtrimstr("\r") == "## Review closed") | .id' | tail -n 1
16
16
  ```
17
17
 
18
18
  Write the replacement body to `<body-file>`, the name `post.md` already derived at the top of Step 4, keeping the heading and the footer and naming what this pass covered on the scope line in place of what the old one covered. Derive that name the same way whichever path reached here, since the guard reads `<prior-heading>` alone and a repeated head resolves the third segment as usual. The folder then gains a record of every covered head rather than losing the one the standing comment named. Then replace the comment:
@@ -13,6 +13,16 @@ Derive both segments from Step 1. Never pick a suffix by hand, and never reuse a
13
13
 
14
14
  When `<prior-oid>` from Step 2 equals `headRefOid`, the head repeats and the folder already holds `body-<number>-<short-sha>.md`. Add a third segment taking the id of the reply Step 2 resolved, giving `body-<number>-<short-sha>-r<comment-id>.md`, which is `<body-file>` on that path. That satisfies both prohibitions above rather than carving an exception into either. Step 2 already stopped the pass when that resolution came back empty, so reaching this line means the comment id is in hand.
15
15
 
16
+ A finding bound to a line links to it, so a reader skips the hunt:
17
+
18
+ ```markdown
19
+ - **should-fix** ([line 12](<repo-url>/blob/<headRefOid>/<path>#L12)): what breaks and the fix
20
+ ```
21
+
22
+ A finding that spans lines anchors to the span as `#L12-L14`, with no spaces and no en dash, and a single-line finding anchors as `#L12`. Take the line number from the file read at `<headRefOid>`, `git show <headRefOid>:<path>`, never from another checkout. The URL carries the full 40-character `<headRefOid>`, never `<short-sha>`, `HEAD`, or the branch name, so the link survives a push. A later pass restating an open finding links it again at its own `<headRefOid>` rather than copying the old bullet.
23
+
24
+ Four findings take the plain bullet with no link: a `**PR body**` entry, a finding on a file the head deleted, a finding on a line the diff removed from a file that survives, since the head holds no number for it, and a finding about a whole file with no one line. A path holding a character a URL needs encoded, such as a space or `#`, is percent-encoded in the link or keeps the plain bullet. Keep the file block header a bare backticked path, so the section-marker reading in `${CLAUDE_SKILL_DIR}/../../standards/markdown.md` does not change.
25
+
16
26
  The comment is a rendered-for-human GitHub surface, so load the `write-human` skill for voice and word choice and follow `${CLAUDE_SKILL_DIR}/../../standards/markdown.md` for punctuation: cut editorializing, and keep every sentence load-bearing. Match this shape on a first pass:
17
27
 
18
28
  ```markdown
@@ -22,8 +32,8 @@ X critical, Y should-fix, Z minor. Reviewed against project docs and the board.
22
32
 
23
33
  **`path/to/file.ext`**
24
34
 
25
- - **should-fix**: what breaks and the fix, in two or three sentences.
26
- - **minor**: finding.
35
+ - **should-fix** ([line 12](<repo-url>/blob/<headRefOid>/path/to/file.ext#L12)): what breaks and the fix, in two or three sentences.
36
+ - **minor** ([lines 30-34](<repo-url>/blob/<headRefOid>/path/to/file.ext#L30-L34)): finding.
27
37
 
28
38
  **What is right**
29
39
 
@@ -47,7 +57,7 @@ Re-reviewed `<short-sha>`, N commits since the prior pass. X critical, Y should-
47
57
 
48
58
  **`path/to/file.ext`**
49
59
 
50
- - **should-fix**: what breaks and the fix, in two or three sentences.
60
+ - **should-fix** ([line 12](<repo-url>/blob/<headRefOid>/path/to/file.ext#L12)): what breaks and the fix, in two or three sentences.
51
61
 
52
62
  🤖 Reviewed by Claude Code
53
63
 
@@ -117,9 +127,11 @@ Before posting, run the scan in `${CLAUDE_SKILL_DIR}/../../standards/publish.md`
117
127
  Do not run the command below when `<prior-heading>` from Step 2 reads `## Review closed` and this pass carries nothing owed. That pass replaces the standing comment rather than adding one, so read `${CLAUDE_SKILL_DIR}/references/close-out.md` instead of posting. Posting first and reaching that section afterward leaves two close-outs both naming the new head, which is worse than the pair the guard exists against.
118
128
 
119
129
  ```bash
120
- gh pr review <number> --comment --body-file .canon/tmp/pr/review/body-<number>-<short-sha>.md
130
+ gh api -X POST 'repos/{owner}/{repo}/pulls/<number>/reviews' -f event=COMMENT -F body=@.canon/tmp/pr/review/body-<number>-<short-sha>.md --jq .html_url
121
131
  ```
122
132
 
133
+ The post goes through the REST reviews endpoint rather than the `gh pr` review subcommand, which runs on GraphQL and fails where a cloud session's GitHub proxy refuses it. `event=COMMENT` submits the review as a comment, the same state the old `--comment` flag produced.
134
+
123
135
  A pass carrying nothing at all takes `## Review closed` and a short body, with the footer line included either way. On a first pass, post `✅ No findings. Reviewed against project docs and the board.` On a later pass, post `✅ Prior findings addressed. Re-reviewed <short-sha>, N commits since the prior pass.`
124
136
 
125
137
  A pass carrying only minors is an ordinary finding-carrying pass, so it takes the open heading and the full shape rather than either short line, since the minors have to be readable and neither line reports them. A pass carrying only Testing questions, or only an unanswered reviewer request, takes the same route for the same reason. Keep whichever scope sentence the pass owes on the summary line:
@@ -131,7 +143,7 @@ A pass carrying only minors is an ordinary finding-carrying pass, so it takes th
131
143
 
132
144
  **`path/to/file.ext`**
133
145
 
134
- - **minor**: finding, and the fix it wants.
146
+ - **minor** ([line 12](<repo-url>/blob/<headRefOid>/path/to/file.ext#L12)): finding, and the fix it wants.
135
147
 
136
148
  🤖 Reviewed by Claude Code
137
149
 
@@ -17,28 +17,30 @@ The fields are `<prior-oid>` off `commit`, `<prior-heading>` off `heading`, and
17
17
 
18
18
  `source` says which stamp answered. `marker` is the pass's own read-time record and is the authority. `fallback` is a pass posted before this mechanism shipped, so its commit is whatever the head was when GitHub recorded the review rather than what that session read, and a push inside its compose window is invisible. `none` is a thread carrying no pass at all.
19
19
 
20
- Do not read `commit.oid` or `submittedAt` off `gh pr view --json reviews` here. Both are stamped at submission, so a push landing between a pass's read and its post moves them onto a commit that pass never saw, and this step then scopes the delta past it and reports it covered.
20
+ Do not read `commit_id` or `submitted_at` off the reviews listing here. Both are stamped at submission, so a push landing between a pass's read and its post moves them onto a commit that pass never saw, and this step then scopes the delta past it and reports it covered.
21
21
 
22
- A target whose CLI predates the verb meets a missing subcommand rather than a record. Fall back there to the jq below, which reads the stamps and carries the defect above, and say the fallback answered so a reader can tell a marker read from a stamped one:
22
+ A target whose CLI predates the verb meets a missing subcommand rather than a record. Fall back there to the REST read below, which reads the stamps and carries the defect above, and say the fallback answered so a reader can tell a marker read from a stamped one:
23
23
 
24
24
  ```bash
25
- gh pr view <number> --json reviews --jq '[.reviews[] | select(.body // "" | split("\n")[0] | rtrimstr("\r") | . == "## Review" or . == "## Review closed")] | last | select(. != null) | ((.commit.oid // "") + "\t" + (.body | split("\n")[0] | rtrimstr("\r")) + "\t" + (.submittedAt // ""))'
25
+ gh api --paginate 'repos/{owner}/{repo}/pulls/<number>/reviews?per_page=100' --jq '.[] | select(.body // "" | split("\n")[0] | rtrimstr("\r") | . == "## Review" or . == "## Review closed") | ((.commit_id // "") + "\t" + (.body | split("\n")[0] | rtrimstr("\r")) + "\t" + (.submitted_at // ""))' | tail -n 1
26
26
  ```
27
27
 
28
- The three fields are `<prior-oid>`, `<prior-heading>`, and `<prior-at>`. Keep the `select(. != null)` guard, since the string concatenation aborts jq on the null an empty selection returns, and an aborted command reaches the session as an error rather than as the empty result the first-pass branch reads.
28
+ The three fields are `<prior-oid>`, `<prior-heading>`, and `<prior-at>`. The filter emits one line per matching review and `tail` keeps the newest, since `--paginate` runs the filter once per page and a `last` inside it would answer for each page rather than for the thread.
29
29
 
30
30
  Match the first line for equality against the two headings this skill posts. A prefix test also matches `## Review response` and any heading merely starting with those words, which would scope the pass to whatever commit that comment carried. The `\r` trim covers a body composed in the GitHub web editor, which stores CRLF.
31
31
 
32
32
  A `source` of `none`, or an empty result from the fallback, is a first pass. Read the whole change:
33
33
 
34
34
  ```bash
35
- gh pr diff <number>
35
+ gh api 'repos/{owner}/{repo}/pulls/<number>' -H 'Accept: application/vnd.github.diff'
36
36
  ```
37
37
 
38
38
  ```bash
39
- gh pr diff <number> --name-only
39
+ gh api --paginate 'repos/{owner}/{repo}/pulls/<number>/files?per_page=100' --jq '.[].filename'
40
40
  ```
41
41
 
42
+ Both reads go through REST, since the `gh pr` diff subcommand runs on GraphQL and a cloud session's GitHub proxy refuses it. The diff media type returns the same unified diff, and the paginated files listing returns every path rather than the first page.
43
+
42
44
  A commit is a later pass. Fetch the pull request head so both commits are local:
43
45
 
44
46
  ```bash
@@ -58,14 +60,14 @@ On exit zero, review `<prior-oid>..<headRefOid>` and nothing else. `git diff` an
58
60
  A commit is its own ancestor, so an unchanged head passes that test too, with an empty range. When `<prior-oid>` equals `<headRefOid>`, decide whether this pass has anything to add before reading anything else, since the empty range itself cannot answer that:
59
61
 
60
62
  ```bash
61
- gh pr view <number> --json comments --jq '[.comments[] | select(.body // "" | split("\n")[0] | rtrimstr("\r") | . == "## Review response" or . == "## Rebase" or . == "## Post-review findings") | select(.createdAt > "<prior-at>")] | last | .url // empty | split("-") | last'
63
+ gh api --paginate 'repos/{owner}/{repo}/issues/<number>/comments?per_page=100' --jq '.[] | select(.body // "" | split("\n")[0] | rtrimstr("\r") | . == "## Review response" or . == "## Rebase" or . == "## Post-review findings") | select(.created_at > "<prior-at>") | .id' | tail -n 1
62
64
  ```
63
65
 
64
66
  `<prior-at>` is the instant Step 2 resolved above, which is the prior pass's `readAt` where it wrote one. Reading `submittedAt` off the thread here instead is the same submission-time defect on the time axis: a reply posted inside that pass's compose window sorts before the stamp and reads as already answered, when in fact the pass had stopped reading before it landed.
65
67
 
66
68
  Scope the replies to those newer than the prior pass, never to every reply the thread carries. A pass answering the newest reply and a pass answering an older one derive the same third segment (Step 4), so an unscoped read hands a re-run after a close-out the name its own prior pass already wrote. That is the collision this case exists to prevent, reached without a rebase or an error.
67
69
 
68
- Read the number off `.url`. The `id` field carries a GraphQL node id, which the thread never displays. Keep the `// empty` guard, since `split` aborts jq on the null an empty selection returns, and an aborted command reaches the session as an error rather than as the empty result the stop below reads.
70
+ The REST `id` is the number the thread displays in each comment's anchor. Keep `tail` outside the filter for the same per-page reason as the reviews read above.
69
71
 
70
72
  An empty result means no reply arrived since the prior pass, so the head is unchanged and this pass has nothing new to add. Stop here, before Step 3 or Step 4 run: `❌ The head is unchanged since the prior pass on <short-sha>. Nothing new to review.` This is the earliest point every path crosses, which is why the check sits here rather than inside Step 4's filename derivation. A path that decides there is nothing to add never reaches a step reached only when composing a body, so a stop written there is a stop a shortcut path can route around.
71
73
 
@@ -26,15 +26,23 @@ rather than inside it, so the two passes finish on their own clocks.
26
26
 
27
27
  ## Step 1: resolve the pull request
28
28
 
29
- Resolve the number with `gh pr view --json number,headRefName,headRefOid`, or take the one the launch names. Take `<head>` off `canon pr head <number> --json`, reading its `tip`, and fall back to `headRefOid` when no record comes back. The first seven characters are `<short-sha>`. Record `<pass>` once, as the instant this pass starts, with `date -u +%Y%m%dT%H%M%SZ`. Every frame Step 5 pushes files under it, so a later pass at the same head never replaces an image this one's comment shows.
29
+ Resolve the number and the head through `canon pr head <number> --json` for the number the launch names, or `canon pr head --json` for the current branch when it names none. Take `<number>` off the record's `number` and `<head>` off its `tip`. The first seven characters are `<short-sha>`. Record `<pass>` once, as the instant this pass starts, with `date -u +%Y%m%dT%H%M%SZ`. Every frame Step 5 pushes files under it, so a later pass at the same head never replaces an image this one's comment shows.
30
30
 
31
- Read the newest UI verdict for the head guard:
31
+ Every read in this step runs on REST, since the `gh pr` lookups run on GraphQL and a cloud session's GitHub proxy refuses it. A target whose CLI predates the verb gets no record back. When the launch named no number, fall back there to the open pull requests on the current branch, encoding it so a `/` in its name reaches the query as `%2F`, and stop when that prints anything but one number. Two open pull requests on one head against different bases leave nothing to say which one the launch meant. A named number skips that lookup, since this checkout's branch need not be the pull request's. Either way, read the head off the pull request itself:
32
32
 
33
33
  ```bash
34
- gh pr view <number> --json reviews,comments --jq '([.reviews[] | select(.body // "" | split("\n")[0] | rtrimstr("\r") | startswith("## UI review"))] | last) as $v | [($v.body // ""), ($v.submittedAt // ""), ([.comments[] | select(.body // "" | split("\n")[0] | rtrimstr("\r") | . == "## Review response" or . == "## Post-review findings") | .createdAt] | max // "")]'
34
+ gh api "repos/{owner}/{repo}/pulls?head={owner}:$(git branch --show-current | jq -Rr @uri)&state=open" --jq '.[].number'
35
+ gh api repos/{owner}/{repo}/pulls/<number> --jq .head.sha
35
36
  ```
36
37
 
37
- The three values are the verdict's body, when it was posted, and when the newest reply was. Take the sha off the body's last-line `<!-- review-ui: head=<sha> -->` marker. A body carrying none covers no head this pass can trust, so drive.
38
+ Read the newest UI verdict for the head guard, and the newest reply:
39
+
40
+ ```bash
41
+ gh api --paginate 'repos/{owner}/{repo}/pulls/<number>/reviews?per_page=100' --jq '.[] | select(.body // "" | split("\n")[0] | rtrimstr("\r") | startswith("## UI review")) | {body, submitted_at} | @json' | tail -n 1
42
+ gh api --paginate 'repos/{owner}/{repo}/issues/<number>/comments?per_page=100' --jq '.[] | select(.body // "" | split("\n")[0] | rtrimstr("\r") | . == "## Review response" or . == "## Post-review findings") | .created_at' | tail -n 1
43
+ ```
44
+
45
+ The first read prints the verdict's body and when it was posted as one JSON line, and the second prints when the newest reply was. `--paginate` runs the filter once per page, so `tail` outside it keeps the newest row in the thread rather than the newest on each page. The verdict goes out as JSON because its body spans many lines, and `tail` over the raw body would keep only its last one. An empty first read is a pull request carrying no verdict yet. Take the sha off the body's last-line `<!-- review-ui: head=<sha> -->` marker. A body carrying none covers no head this pass can trust, so drive.
38
46
 
39
47
  Read nothing else about the change. The diff, the plan, the task, and the description's Summary and Technical Context all carry the author's argument for it, and a pass that has heard it drives towards what it expects.
40
48
 
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  title: Orchestrator cloud launch
3
- description: The cloud build shape, being the launch prompt that carries the plan as text and announces over GitHub, the create under a pseudo-terminal with its trust keys, and the readout that fails loudly on a missing session id
3
+ description: The cloud build and review shapes, being the launch prompt that carries the plan as text and announces over GitHub, the reviewer prompt whose posted review is its announcement, the create under a pseudo-terminal with its trust keys, and the readout that fails loudly on a missing session id
4
4
  ---
5
5
 
6
6
  # Orchestrator cloud launch
@@ -74,3 +74,32 @@ gh api 'repos/{owner}/{repo}/pulls/<number>/commits' --jq '.[].commit.message' |
74
74
  ```
75
75
 
76
76
  A count above zero matches the row. A count of zero is someone else's pull request, whatever its title says. Once it matches, record the number on the row's task yourself with `canon tasks pull-request <number> <task>`, since `git-pr` on the VM has no task file to write, then review it the way step 5 reviews any worker's pull request.
77
+
78
+ ## Dispatch a review to a cloud reviewer
79
+
80
+ Run this once `orchestrator-review-dispatch.md` has sent a code review to a dispatched reviewer and the operator picked cloud for it. The reviewer needs the same `SessionStart` hook as the build shape, and a `canon` on the VM whose `pr` reads run on REST, since the proxy refuses GraphQL. A UI review never takes this shape, because it drives a browser the VM does not have.
81
+
82
+ Confirm the VM's `canon` reads over REST before dispatching. In this repository the setup hook links the checkout's own CLI, so a pushed branch carries the reads. Any other repository's VM installs `canon` from npm, so its `pr` reads run on REST only once a release carrying them is published, and its copied skills carry the REST lookups only once that repository's skills are re-scaffolded from that release.
83
+
84
+ Read `npm view @erclx/canon version` against the first release whose changelog names the REST reads, and check the target's copied `review-pr` the same way, by running `grep -rnE 'gh pr (list|view|review|diff|comment)'` over it and expecting no output. A version short of that release, or a hit, dispatches a reviewer that fails on its first GraphQL call, so take the local shape instead.
85
+
86
+ Check this pass's launched record for a cloud reviewer already holding the number, and run `canon pr review-state <number> --json` for a pass already posted. A cloud reviewer never appears in `canon sessions list`, so the live-session check the local shape runs reads clear on a pull request one already holds.
87
+
88
+ The create targets whichever repository the checkout it runs from names as `origin`. For a pull request in this repository, write the prompt to `.canon/tmp/cloud-review-<number>/prompt.md` at the main worktree root and run the create there. For a pull request in another repository, run the create from a checkout of that repository and write the prompt under that checkout's own `.canon/tmp/cloud-review-<number>/`, which its `.gitignore` has to cover before the create runs. A fresh clone made for this is untrusted, so the create takes the trust-prompt key variant under `## The create`:
89
+
90
+ ```plaintext
91
+ /role-reviewer
92
+ Then run /review-pr <number> on <owner>/<repo>.
93
+ Your controller cannot receive a message from this session. The review you post is the announcement, so post nothing else for it. If you stop on a question before the review posts, comment on the pull request starting with QUESTION: review <number>.
94
+ <the cross-branch facts>
95
+ ```
96
+
97
+ The cross-branch facts are the ones `### What the brief may carry` in `orchestrator-launch.md` allows a reviewer brief, and nothing of this session's own read. Neither skill carries the invocation flag, so no position-zero expansion is at stake and the bare names reach the copied project skills.
98
+
99
+ Create the session with the wrapper under `## The create`, setting `dir` to that prompt's folder, and keep its readout rule: no `session_` id means nothing was dispatched. Record the session id beside the number in this pass's launched record, and name the number, the repository, and the session id in the dispatch report.
100
+
101
+ `poll.ts` lists every open pull request from this machine and reads each one's review state, so the posted `## Review` reaches the next poll like a local reviewer's. Start the poll when the dispatch goes out rather than at its fallback. A `QUESTION: review <number>` comment reaches the same poll as an unclassified reply, so answer it on the pull request.
102
+
103
+ The pull request is the record to act on, and the transcript is read back only to check for a refused call. Run `claude --teleport <session>` under `script` from a throwaway clone of the target outside this repository, answer its trust prompt with the same piped keys, and set the terminal to 500 rows first, since only the screen tail is captured otherwise. Search the capture for `HTTP 403` and `graphql`. A hit names a call still on GraphQL, so report it as a finding rather than reading the posted review as clean.
104
+
105
+ A cloud reviewer cannot be messaged after it posts. A re-review after a worker's address pass is a fresh dispatch, local or cloud by the same pick, rather than the message `orchestrator-handback.md` sends a live reviewer.
@@ -103,6 +103,10 @@ claude --bg --model <model> -n "reviewer-<project>-<number>" "Run /canon:role-re
103
103
 
104
104
  Check `canon sessions list --json` for a live `reviewer-<project>-<number>` before launching, and message that session instead when one holds the pull request.
105
105
 
106
+ ## Dispatch a review to a cloud reviewer
107
+
108
+ Read `${CLAUDE_SKILL_DIR}/references/orchestrator-cloud-launch.md` when the operator picked cloud for a code review. Its prompt opens with `/role-reviewer`, carries the cross-branch facts, and makes the posted review the announcement.
109
+
106
110
  ## Dispatch to drive a pull request's checklist
107
111
 
108
112
  `review-ui` is the second pass `role-reviewer` may run, so this shape reaches the role first the way the code reviewer's does. `orchestrator-review-dispatch.md` decides whether a pull request takes it, and it runs beside the code reviewer rather than after it.
@@ -9,7 +9,7 @@ Run the orchestrator's review trigger. The poll reports pull request movement an
9
9
 
10
10
  ## When to run it
11
11
 
12
- Start the poll on an open pull request and stop it when the last one merges with nothing else out. That is the whole condition and it resolves from `gh pr list` without asking the operator. A release pull request alone does not qualify, since its sweep carries no findings.
12
+ Start the poll on an open pull request and stop it when the last one merges with nothing else out. That is the whole condition and it resolves from the open list without asking the operator. A release pull request alone does not qualify, since its sweep carries no findings.
13
13
 
14
14
  A dispatch no longer starts it. The script reads pull requests and a building worker has none, so every interval between the launch and the push is a fixed cost returning nothing, measured as five consecutive runs reporting no movement across roughly fifteen minutes while one worker built. What replaces it is the worker announcing its own pull request the moment it opens one, per the `role-worker` skill that session loads, which covers exactly the transition the poll cannot observe.
15
15
 
@@ -71,15 +71,15 @@ The state reaches every stalled dispatch, since one threshold governs the headin
71
71
 
72
72
  ### The count behind the review dispatch
73
73
 
74
- The report is also where the load count in `orchestrator-review-dispatch.md` is legible, and that count decides where a first pass runs, here or in a dispatched reviewer. That threshold trips on open pull requests awaiting a first pass, which is what `OPENED` and a pull request with no prior pass name here and what `SEEN` excludes, so read the count off these lines rather than off `gh pr list`, which counts a branch closed out and waiting on a merge the same as one nobody has read. It is a separate condition from the poll-start fallback above, which decides when this loop runs rather than where a review runs.
74
+ The report is also where the load count in `orchestrator-review-dispatch.md` is legible, and that count decides where a first pass runs, here or in a dispatched reviewer. That threshold trips on open pull requests awaiting a first pass, which is what `OPENED` and a pull request with no prior pass name here and what `SEEN` excludes, so read the count off these lines rather than off the open list, which counts a branch closed out and waiting on a merge the same as one nobody has read. It is a separate condition from the poll-start fallback above, which decides when this loop runs rather than where a review runs.
75
75
 
76
76
  The count used to read low, and it erred in the direction that breaks the trigger. A review's `commit.oid` is stamped with the head at submission rather than with the commit the reviewer read, so an author pushing between the diff read and the post left the pass recorded against a commit it never saw, and `SEEN` then fired on a head still awaiting its first look at that delta.
77
77
 
78
78
  The pass now carries its own record instead. `review-pr` writes the commit it read and the instant it read it as a marker on the last line of every body it posts, and both this script and that skill resolve the covered state through `canon pr review-state`, which is the one place the marker is parsed. A commit pushed inside a pass's compose window falls outside the marked range, so it reads as `MOVED` rather than as `SEEN`. A `SEEN` is a claim about what a session read rather than about what GitHub stamped, so it wants no second read to be believed.
79
79
 
80
- Read `source` on the record before trusting a `SEEN` on a thread whose newest pass is old. `marker` is the read-time record. `fallback` is a pass posted before the mechanism shipped, or a target whose CLI predates the verb, and it carries the defect above unchanged, so a `SEEN` under it is worth one `gh pr view --json reviews` before it is believed. `none` is a thread carrying no pass at all.
80
+ Read `source` on the record before trusting a `SEEN` on a thread whose newest pass is old. `marker` is the read-time record. `fallback` is a pass posted before the mechanism shipped, or a target whose CLI predates the verb, and it carries the defect above unchanged, so a `SEEN` under it is worth one read of the pull's reviews before it is believed. `none` is a thread carrying no pass at all.
81
81
 
82
- The other side of that comparison used to lag as well, which made the two errors compound rather than cancel. `gh pr view --json headRefOid` answers from the pull request object and that object trails the branch ref by up to a minute after a push, reporting nothing about the trail, so a pass stamped ahead of the commit it read was compared against a head stamped behind the commit that exists. The head now comes from `canon pr head`, which resolves the tip through `git ls-remote` and reports which commit each source names, and the object's head stays behind it as the fallback for a target whose CLI predates the verb. Both halves of the comparison now answer about a commit somebody named rather than one GitHub stamped, which is what lets a `SEEN` be read as covered.
82
+ The other side of that comparison used to lag as well, which made the two errors compound rather than cancel. The pull read's `head.sha` answers from the pull request object and that object trails the branch ref by up to a minute after a push, reporting nothing about the trail, so a pass stamped ahead of the commit it read was compared against a head stamped behind the commit that exists. The head now comes from `canon pr head`, which resolves the tip through `git ls-remote` and reports which commit each source names, and the object's head stays behind it as the fallback for a target whose CLI predates the verb. Both halves of the comparison now answer about a commit somebody named rather than one GitHub stamped, which is what lets a `SEEN` be read as covered.
83
83
 
84
84
  ## The watch beside it
85
85
 
@@ -33,3 +33,9 @@ Hand what it finds to each reviewer as facts in the brief, per `### What the bri
33
33
  ## Launching the reviewer
34
34
 
35
35
  Take the reviewer shape in `orchestrator-launch.md`, or its UI reviewer shape for the UI trigger. Check `canon sessions list --json` for a live `reviewer-<project>-<number>`, or `reviewer-ui-<project>-<number>`, first and message it instead of launching a second, since two reviewers on one pull request post two verdicts. The re-review after a worker's address pass goes back the same way, per `orchestrator-handback.md`.
36
+
37
+ ## Local or cloud
38
+
39
+ A code review goes to a cloud reviewer rather than `claude --bg` only on the operator's explicit pick for that dispatch, the same standing the build shape's pick holds in `orchestrator-dispatch.md`. The cloud session costs no local context and no machine slot, which matters when the load trigger fired. It also reads nothing this machine holds, so a review that needs the board, a local preview, or a sandbox stays local.
40
+
41
+ This session hears a cloud reviewer through `poll.ts` alone, which already lists every open pull request from this machine, so the posted review is the announcement and no message arrives. `orchestrator-cloud-launch.md` carries the shape under `## Dispatch a review to a cloud reviewer`. A UI review always stays local.
@@ -20,7 +20,6 @@ interface Review {
20
20
 
21
21
  export interface Payload {
22
22
  comments?: Comment[]
23
- headRefOid?: string
24
23
  reviews?: Review[]
25
24
  }
26
25
 
@@ -416,11 +415,80 @@ const parseJson = (text: string): unknown => {
416
415
  }
417
416
  }
418
417
 
418
+ /**
419
+ * One JSON object per line, which is what `.[] | <projection> | @json` prints
420
+ * across every page. The projection keeps stdout to the fields the mappers
421
+ * read, since a whole REST row carries user objects and links and a long thread
422
+ * could overrun the spawn buffer and read as unreadable. A failed read and a
423
+ * line that does not parse both return null, so neither reads as a thread with
424
+ * nothing on it.
425
+ */
426
+ const readRows = (
427
+ path: string,
428
+ fields: string,
429
+ ): null | Record<string, unknown>[] => {
430
+ const read = run('gh', [
431
+ 'api',
432
+ '--paginate',
433
+ `repos/{owner}/{repo}/${path}`,
434
+ '--jq',
435
+ `.[] | {${fields}} | @json`,
436
+ ])
437
+ if (!read.isOk) return null
438
+
439
+ const rows: Record<string, unknown>[] = []
440
+ for (const line of read.stdout.split('\n')) {
441
+ if (line.trim() === '') continue
442
+ const row = parseJson(line)
443
+ if (typeof row !== 'object' || row === null) return null
444
+ rows.push(row as Record<string, unknown>)
445
+ }
446
+
447
+ return rows
448
+ }
449
+
450
+ const text = (value: unknown): null | string =>
451
+ typeof value === 'string' ? value : null
452
+
453
+ /**
454
+ * The REST rows carry snake_case names, mapped onto the camelCase shape the
455
+ * classifiers read. `commit_id` becomes `commit.oid`. A pending review has no
456
+ * `submitted_at`, which maps to null.
457
+ */
458
+ export const toReview = (row: Record<string, unknown>): Review => ({
459
+ body: text(row.body),
460
+ commit: { oid: text(row.commit_id) },
461
+ submittedAt: text(row.submitted_at),
462
+ })
463
+
464
+ export const toComment = (row: Record<string, unknown>): Comment => ({
465
+ body: text(row.body),
466
+ createdAt: text(row.created_at),
467
+ })
468
+
469
+ /**
470
+ * Two reads feed one payload, so either failing makes the pull request
471
+ * unreadable rather than a thread missing half its rows.
472
+ */
473
+ const readPayload = (number: string): null | Payload => {
474
+ const reviews = readRows(
475
+ `pulls/${number}/reviews?per_page=100`,
476
+ 'body, commit_id, submitted_at',
477
+ )
478
+ const comments = readRows(
479
+ `issues/${number}/comments?per_page=100`,
480
+ 'body, created_at',
481
+ )
482
+ if (reviews === null || comments === null) return null
483
+
484
+ return { comments: comments.map(toComment), reviews: reviews.map(toReview) }
485
+ }
486
+
419
487
  type Snapshot = { baseline: Baseline } | { row: Row }
420
488
 
421
489
  const nowSeconds = (): number => Math.floor(Date.now() / 1000)
422
490
 
423
- const readHead = (number: string, payload: Payload): string => {
491
+ const readHead = (number: string): string => {
424
492
  // The tip is the authority for the head, not the pull request object, which
425
493
  // lags the ref by up to a minute after a push. The object's head is the
426
494
  // fallback for a target on an older binary with no `pr head` verb.
@@ -432,13 +500,20 @@ const readHead = (number: string, payload: Payload): string => {
432
500
  ? (record as Record<string, unknown>).tip
433
501
  : undefined
434
502
 
435
- return typeof tip === 'string' && tip !== ''
436
- ? tip
437
- : (payload.headRefOid ?? '')
503
+ if (typeof tip === 'string' && tip !== '') return tip
504
+
505
+ const pull = run('gh', [
506
+ 'api',
507
+ `repos/{owner}/{repo}/pulls/${number}`,
508
+ '--jq',
509
+ '.head.sha',
510
+ ])
511
+
512
+ return pull.isOk ? pull.stdout.trim() : ''
438
513
  }
439
514
 
440
515
  /**
441
- * `gh pr view --json mergeable` reports UNKNOWN until GitHub finishes
516
+ * The pull read's `mergeable_state` reports unknown until GitHub finishes
442
517
  * computing it, which is exactly when a poll asks. merge-tree answers locally
443
518
  * against the base this machine has. It exits non-zero on a ref it cannot
444
519
  * resolve as well as on a real conflict, so both sides are checked first.
@@ -530,12 +605,9 @@ const main = (): number => {
530
605
  // one as GONE. That is a louder wrong answer than the one this script was
531
606
  // fixed for, so the run aborts rather than classify on it.
532
607
  const listed = run('gh', [
533
- 'pr',
534
- 'list',
535
- '--state',
536
- 'open',
537
- '--json',
538
- 'number',
608
+ 'api',
609
+ '--paginate',
610
+ 'repos/{owner}/{repo}/pulls?state=open&per_page=100',
539
611
  '--jq',
540
612
  '.[].number',
541
613
  ])
@@ -550,19 +622,10 @@ const main = (): number => {
550
622
 
551
623
  const snapshots: Snapshot[] = []
552
624
  for (const number of listed.stdout.split(/\s+/).filter((n) => n !== '')) {
553
- // A query that failed and a pull request with no reviews both arrive as
554
- // an empty result, so one query per pull request gives the failure a
555
- // single place to surface.
556
- const view = run('gh', [
557
- 'pr',
558
- 'view',
559
- number,
560
- '--json',
561
- 'headRefOid,reviews,comments',
562
- ])
563
- const payload = view.isOk
564
- ? (parseJson(view.stdout) as Payload | null)
565
- : null
625
+ // A read that failed and a pull request with no reviews both arrive as an
626
+ // empty result, so each read returns null on failure and the payload gives
627
+ // the failure a single place to surface.
628
+ const payload = readPayload(number)
566
629
  const old = known.get(number)
567
630
  const unreadable = (reason: string): void => {
568
631
  if (old === undefined) {
@@ -578,12 +641,12 @@ const main = (): number => {
578
641
  snapshots.push({ baseline: carryForward(old) })
579
642
  }
580
643
 
581
- if (payload === null || typeof payload !== 'object') {
644
+ if (payload === null) {
582
645
  unreadable('could not be read')
583
646
  continue
584
647
  }
585
648
 
586
- const head = readHead(number, payload)
649
+ const head = readHead(number)
587
650
  if (head === '') {
588
651
  unreadable('returned no head')
589
652
  continue
@@ -53,12 +53,10 @@ export const baseRef = (): string => {
53
53
  if (symbolic.isOk && ref !== '') return ref
54
54
 
55
55
  const repo = run('gh', [
56
- 'repo',
57
- 'view',
58
- '--json',
59
- 'defaultBranchRef',
56
+ 'api',
57
+ 'repos/{owner}/{repo}',
60
58
  '--jq',
61
- '.defaultBranchRef.name',
59
+ '.default_branch',
62
60
  ])
63
61
  const name = repo.isOk ? repo.stdout.trim() : ''
64
62
 
@@ -175,14 +175,11 @@ export const pass = (
175
175
 
176
176
  const readPulls = (): Read<string[]> => {
177
177
  const listed = run('gh', [
178
- 'pr',
179
- 'list',
180
- '--state',
181
- 'open',
182
- '--json',
183
- 'number,headRefName,title',
178
+ 'api',
179
+ '--paginate',
180
+ 'repos/{owner}/{repo}/pulls?state=open&per_page=100',
184
181
  '--jq',
185
- '.[] | "PR-OPEN #\\(.number) \\(.headRefName) \\(.title)"',
182
+ '.[] | "PR-OPEN #\\(.number) \\(.head.ref) \\(.title)"',
186
183
  ])
187
184
  if (!listed.isOk) return { isOk: false }
188
185
 
@@ -5,12 +5,16 @@ description: The canvas server, its content format, and the verbs that list, arr
5
5
 
6
6
  # Canvas
7
7
 
8
- `canon canvas` serves a local canvas of pages and HTML frames and gives an agent the verbs the operator's mouse has. A page is a folder under `.canon/canvas/`, a frame is one HTML file in it, and `layout.json` beside them holds each frame's box.
8
+ `canon canvas` serves a local canvas of pages and HTML frames and gives an agent the verbs the operator's mouse has. A page is a folder under `.canon/canvas/`, a frame is one HTML file in it, and `layout.json` beside them holds each frame's box. `standards/canvas.md` fixes that content format, and the `canvas` skill carries the procedure a session follows to drive the canvas.
9
9
 
10
10
  ## Serve
11
11
 
12
12
  `canvas serve` serves the canvas on `127.0.0.1`, walks ports like `canon serve`, refuses a `Host` other than loopback on its own port, and runs until interrupted. Each frame gets the project's token stylesheet injected, being this toolkit's tokens in its own checkout, else `.claude/design/base.css` plus anything under `.claude/design/project/`, else none with a notice. An open canvas reloads a frame whose file changes.
13
13
 
14
+ The toolkit's own tokens arrive with the font faces they name embedded, so a frame renders in them on a machine that never installed them. An installed base owns its fonts and gets no faces added.
15
+
16
+ Before it binds a port, `canvas serve` checks that `preact` and `@preact/signals` resolve from the package the CLI runs from, never from the served project. When either is missing it refuses with reason `missing-client-deps` and names `bun install` in the detail, rather than serving a shell that loads as a blank page.
17
+
14
18
  The write routes the shell posts to also refuse a request carrying an `Origin` other than the server's own, and a body that is not JSON.
15
19
 
16
20
  ## List and add
@@ -37,7 +41,7 @@ A pick is refused as `address-mismatch` when the browser builds elements the fil
37
41
 
38
42
  The operator edits the same set from the inspector, which posts to the same writer with the hash the frame was served with. An edit made against a version of the file Claude has since rewritten is refused as `stale-address` and the shell reloads the frame, so nothing lands on a shifted element. A color picked from the project's tokens is written as `var(--<name>)`. Both writers take the frame file's lock, and an edit that keeps the selected element where it was keeps the selection fresh.
39
43
 
40
- Inline style beats a class, so an operator's edit masks a class change Claude makes later. Read a frame's inline styles before restyling it, and remove one the change should replace. A property outside the set, or a value carrying `;`, `{`, `}`, `<`, `>`, or a line break, is refused as `invalid-edit`.
44
+ Inline style beats a class, so an operator's edit masks a class change Claude makes later. Read a frame's inline styles before restyling it, and remove one the change should replace. The inspector marks a color or background set inline with the word `raw` when its value names no `var()` and is not `currentColor` or a CSS-wide keyword, since that value stays fixed when the theme changes. A property outside the set, or a value carrying `;`, `{`, `}`, `<`, `>`, or a line break, is refused as `invalid-edit`.
41
45
 
42
46
  The Theme tab in the shell lists what the token stylesheet defines, grouped as color, spacing, radius, font family, font size, and other. The page record at `/api/pages` carries the same list as `tokens.groups`.
43
47