@erclx/canon 4.69.0 → 4.71.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 +1 -1
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/auto-ship/SKILL.md +1 -1
- package/claude/skills/canon-cli/SKILL.md +4 -0
- package/claude/skills/deploy-cloudflare/REQUIREMENT.md +37 -0
- package/claude/skills/deploy-cloudflare/SKILL.md +61 -0
- package/claude/skills/draft-and-pick/REQUIREMENT.md +1 -1
- package/claude/skills/draft-and-pick/SKILL.md +1 -1
- package/claude/skills/{identity → draft-identity}/REQUIREMENT.md +2 -2
- package/claude/skills/{identity → draft-identity}/SKILL.md +2 -2
- package/claude/skills/{canon-screencast → draft-screencast}/REQUIREMENT.md +4 -4
- package/claude/skills/{canon-screencast → draft-screencast}/SKILL.md +4 -4
- package/claude/skills/{canon-slides-draft → draft-slides}/REQUIREMENT.md +3 -3
- package/claude/skills/{canon-slides-draft → draft-slides}/SKILL.md +2 -2
- package/claude/skills/{canon-frames-read → read-frames}/REQUIREMENT.md +2 -2
- package/claude/skills/{canon-frames-read → read-frames}/SKILL.md +3 -3
- package/claude/skills/{canon-record → record-screencast}/REQUIREMENT.md +5 -5
- package/claude/skills/{canon-record → record-screencast}/SKILL.md +4 -4
- package/claude/skills/review-pr/SKILL.md +55 -7
- package/claude/skills/role-orchestrator/SKILL.md +2 -1
- package/claude/skills/role-orchestrator/references/orchestrator-poll.md +7 -3
- package/claude/skills/role-orchestrator/scripts/poll.sh +79 -34
- package/claude/skills/role-worker/SKILL.md +2 -1
- package/claude/skills/ux-audit/SKILL.md +3 -0
- package/docs/agents/commands.md +2 -0
- package/docs/agents/demo.md +3 -3
- package/docs/agents/index.md +1 -1
- package/docs/agents/pr-reads.md +47 -12
- package/docs/agents/tasks.md +46 -3
- package/docs/workflow/ai-workflow.md +19 -19
- package/docs/workflow/visual-design-workflow.md +1 -1
- package/package.json +3 -2
- package/scripts/core/regen-web-previews.ts +94 -0
- package/src/claude/cases/misc.ts +5 -1
- package/src/claude/cases/workflow.ts +4 -4
- package/src/commands/demo.ts +1 -1
- package/src/commands/pr.ts +130 -1
- package/src/commands/tasks.ts +178 -1
- package/src/commands/teach.ts +2 -0
- package/src/demo/beats.ts +1 -1
- package/src/gate/measures.ts +66 -0
- package/src/gate/stages.ts +11 -0
- package/src/migrate/skill-names.ts +25 -1
- package/src/pr/review-scope.ts +177 -0
- package/src/tasks/archive.ts +206 -3
- package/src/tasks/label.ts +14 -6
- package/src/tasks/validate.ts +22 -0
- package/src/teach/workspace.ts +60 -23
- package/standards/plan.md +1 -1
- package/standards/tasks.md +15 -1
- package/tooling/cloudflare/configs/.github/workflows/deploy.yml +103 -0
- package/tooling/cloudflare/manifest.toml +5 -0
- package/tooling/cloudflare/reference.md +24 -0
package/README.md
CHANGED
|
@@ -8,7 +8,7 @@ canon is a CLI and Claude Code plugin that stops your AI conventions drifting ap
|
|
|
8
8
|
|
|
9
9
|

|
|
10
10
|
|
|
11
|
-
The counts above are read from the catalogs when the image is built, so they're what the repo actually ships today.
|
|
11
|
+
The counts above are read from the catalogs when the image is built, so they're what the repo actually ships today. See it live at [canon.erclx.dev](https://canon.erclx.dev).
|
|
12
12
|
|
|
13
13
|
## It runs on itself
|
|
14
14
|
|
|
@@ -199,7 +199,7 @@ Name the number `git-ship`'s pull request step returned on both calls rather tha
|
|
|
199
199
|
|
|
200
200
|
Report what the read returned rather than what the command printed, since the exit says the call ran and says nothing about the state. A `true` reports a draft. A `false` reports the pull request as opened ready and unsupervised, and the chain stops there. Never re-issue the undo on a disagreeing read, which fights whoever readied it instead of guarding anything.
|
|
201
201
|
|
|
202
|
-
Placement is why the call sits ahead of the watch rather than after it. Marking afterwards leaves the pull request unmarked for the whole CI run, which is the stretch an unattended worker's branch is least supervised. What the mark buys is a reader learning the pull request has had no review yet. It buys no bound on that stretch: readying a pull request to merge lifts the mark, GitHub requires it before a merge, and it is the
|
|
202
|
+
Placement is why the call sits ahead of the watch rather than after it. Marking afterwards leaves the pull request unmarked for the whole CI run, which is the stretch an unattended worker's branch is least supervised. What the mark buys is a reader learning the pull request has had no review yet. It buys no bound on that stretch: readying a pull request to merge lifts the mark, GitHub requires it before a merge, and the act belongs to the operator or to the controlling session that closed the review, whichever it is, taken directly on the pull request rather than delegated to a worker. `role-worker` states the mirroring refusal.
|
|
203
203
|
|
|
204
204
|
`git-ship` verifies again at its own gate, which repeats this chain's Step 3 on the run where nothing stopped. That cost is deliberate: four of the stop points in the table below hand the run straight back to that body, and a gate the chain skips for being redundant is a gate no resumed run ever meets.
|
|
205
205
|
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: deploy-cloudflare
|
|
3
|
+
description: What the Cloudflare Pages setup gap is, which of the four manual steps this skill closes, and why the credential and the custom domain stay the operator's
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Deploy Cloudflare requirement
|
|
7
|
+
|
|
8
|
+
## Gap
|
|
9
|
+
|
|
10
|
+
Without this skill, a Cloudflare Pages setup is a sequence performed from memory. Three of its four repeated acts touch a service rather than the tree, so no workflow file can absorb them, and one of the acts has already cost a broken deploy: the account ID displayed in the Cloudflare dashboard is truncated, and a value copied from there is wrong in a way that only shows up at deploy time.
|
|
11
|
+
|
|
12
|
+
## Must
|
|
13
|
+
|
|
14
|
+
- Create the Pages project through `wrangler pages project create`
|
|
15
|
+
- Fetch the account ID from `wrangler`'s own authenticated session rather than have it typed or pasted
|
|
16
|
+
- Stop for the operator to issue the API token and run `gh secret set CLOUDFLARE_API_TOKEN` themselves
|
|
17
|
+
- Verify both secrets are present with `gh secret list` before continuing
|
|
18
|
+
- Stop for the operator to attach the custom domain in the dashboard
|
|
19
|
+
- Call `wrangler` and `gh` directly for every step those tools already cover
|
|
20
|
+
|
|
21
|
+
## Must not
|
|
22
|
+
|
|
23
|
+
- Accept a Cloudflare API token as input in any form. The toolkit verifies a secret exists, never what it contains.
|
|
24
|
+
- Reimplement `wrangler pages project create` or `gh secret set` as a hand-built API call
|
|
25
|
+
- Open a pull request or merge
|
|
26
|
+
|
|
27
|
+
## Guards
|
|
28
|
+
|
|
29
|
+
- `wrangler` not authenticated: stop and name the login command
|
|
30
|
+
- `gh` not authenticated: stop and name the login command
|
|
31
|
+
- A secret missing after the token stop: stop and name which one
|
|
32
|
+
|
|
33
|
+
## Out of scope
|
|
34
|
+
|
|
35
|
+
- Attaching a custom domain through the Cloudflare REST API instead of the dashboard. Measured absent from `wrangler pages` at plan time and the REST API was not read, so the dashboard stop stays for this pass.
|
|
36
|
+
- Running the deploy itself once secrets and the domain are set. That is the seeded `tooling/cloudflare/configs/.github/workflows/deploy.yml` workflow, triggered by a push to main.
|
|
37
|
+
- Reconfiguring an existing Pages project's build settings.
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: deploy-cloudflare
|
|
3
|
+
description: Sets up a Cloudflare Pages deploy for the current project by creating the Pages project, fetching the account ID, and stopping twice for the operator to issue the API token and attach the custom domain. Calls `wrangler` and `gh` rather than reimplementing them. Use when asked to "set up Cloudflare deploy", "deploy this to Cloudflare Pages", "set up the Pages project", or "connect this repo to Cloudflare". Do NOT use to run an already-configured deploy, which the seeded workflow does on push, or to reconfigure an existing Pages project's build settings.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Deploy Cloudflare
|
|
7
|
+
|
|
8
|
+
Runs the one-time setup a Cloudflare Pages deploy needs before the seeded `deploy.yml` workflow can run, stopping twice for the two acts only the operator can take.
|
|
9
|
+
|
|
10
|
+
## Guards
|
|
11
|
+
|
|
12
|
+
- If `wrangler whoami` fails, stop: `❌ wrangler is not authenticated. Run wrangler login, then re-invoke.`
|
|
13
|
+
- If `gh auth status` fails, stop: `❌ gh is not authenticated. Run gh auth login, then re-invoke.`
|
|
14
|
+
- Never accept a Cloudflare API token as input, in a prompt, an argument, or a file. Verify a secret's presence with `gh secret list` rather than asking for or reading its content.
|
|
15
|
+
- Never reimplement `wrangler pages project create` or `gh secret set` as a hand-built HTTP call. Call the tools directly.
|
|
16
|
+
- If the project name is not supplied, derive it from the repository's own name (`basename` of `git remote get-url origin`, stripped of a trailing `.git`) and confirm it in the preview rather than asking first.
|
|
17
|
+
|
|
18
|
+
## Step 1: create the Pages project
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
wrangler pages project create <project-name>
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Report the command's own failure output and stop rather than retrying on a name collision. A project already existing under that name is the operator's to resolve.
|
|
25
|
+
|
|
26
|
+
## Step 2: fetch and set the account ID
|
|
27
|
+
|
|
28
|
+
Fetch the account ID from `wrangler`'s own authenticated session rather than asking the operator to copy it from the dashboard. The dashboard displays the account ID truncated, and a value copied from there fails the deploy silently:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
wrangler whoami
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Parse the account ID from that output and pipe it into the secret rather than printing it to the transcript first:
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
echo "<account-id>" | gh secret set CLOUDFLARE_ACCOUNT_ID
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Step 3: stop for the API token
|
|
41
|
+
|
|
42
|
+
Stop: `⏸ Create a Cloudflare API token with Pages edit permission at the Cloudflare dashboard, then run: gh secret set CLOUDFLARE_API_TOKEN. Re-invoke this skill once that's done.`
|
|
43
|
+
|
|
44
|
+
This is the one credential the skill never touches. Resume only once the operator confirms the token is set.
|
|
45
|
+
|
|
46
|
+
## Step 4: verify both secrets
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
gh secret list
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
- Both `CLOUDFLARE_ACCOUNT_ID` and `CLOUDFLARE_API_TOKEN` present: continue.
|
|
53
|
+
- Either missing: stop and name which one, with the command that sets it.
|
|
54
|
+
|
|
55
|
+
## Step 5: stop for the custom domain
|
|
56
|
+
|
|
57
|
+
Stop: `⏸ Attach the custom domain to the <project-name> Pages project in the Cloudflare dashboard, then confirm here. Whether this can run through the API instead of the dashboard is out of scope for this skill, per its REQUIREMENT.md.`
|
|
58
|
+
|
|
59
|
+
## Step 6: hand off
|
|
60
|
+
|
|
61
|
+
Report that setup is complete and name the seeded workflow (`tooling/cloudflare/configs/.github/workflows/deploy.yml`, synced via `canon tooling sync cloudflare . --write`) as what deploys on the next push. Do not invoke `git-pr` or `git-ship` from here. The operator or the controlling session decides when to open that pull request.
|
|
@@ -49,7 +49,7 @@ The refusal strings sit in the body, since the runtime loads that file and ignor
|
|
|
49
49
|
- `ux-audit` reads source to find roughness and reports it. This takes its input from the operator and changes nothing until they pick.
|
|
50
50
|
- `ux-measure` measures what a running interface costs to paint. This measures whatever a visual claim depends on, which is usually geometry or contrast rather than cost.
|
|
51
51
|
- `ui-test` writes tests for a change already made. This runs before there is a change to test.
|
|
52
|
-
- `
|
|
52
|
+
- `draft-screencast` scripts a recording of something already built. This has nothing built yet.
|
|
53
53
|
- `canon capture`, `canon serve`, and `canon drive` own the render, the address, and the probes, and are invoked rather than reimplemented.
|
|
54
54
|
|
|
55
55
|
### What did not travel
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: draft-and-pick
|
|
3
|
-
description: Drafts several candidates for a decision judged by looking, renders them side by side on one page, hands the operator the addresses, takes the pick through the structured question surface, and loops on the pick until they stop. Use when asked to "draft some options", "show me a few versions", "try a few variations", "mock up alternatives", "give me candidates for X", or when a choice is taste rather than correctness. Do NOT use when the request already names the answer and asks for it to be built, which is `plan-feature`. Do NOT use to read source for roughness, which is `ux-audit`, to measure what a running interface costs to paint, which is `ux-measure`, to write tests for a change already made, which is `ui-test`, or to script a recording, which is `
|
|
3
|
+
description: Drafts several candidates for a decision judged by looking, renders them side by side on one page, hands the operator the addresses, takes the pick through the structured question surface, and loops on the pick until they stop. Use when asked to "draft some options", "show me a few versions", "try a few variations", "mock up alternatives", "give me candidates for X", or when a choice is taste rather than correctness. Do NOT use when the request already names the answer and asks for it to be built, which is `plan-feature`. Do NOT use to read source for roughness, which is `ux-audit`, to measure what a running interface costs to paint, which is `ux-measure`, to write tests for a change already made, which is `ui-test`, or to script a recording, which is `draft-screencast`.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Draft and pick
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
---
|
|
2
|
-
name: identity
|
|
2
|
+
name: draft-identity
|
|
3
3
|
description: Why the mark and the card are one pick rather than two, and where the size sequence and the write folder come from before either is drafted
|
|
4
4
|
---
|
|
5
5
|
|
|
6
|
-
#
|
|
6
|
+
# Draft identity requirement
|
|
7
7
|
|
|
8
8
|
## Gap
|
|
9
9
|
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
---
|
|
2
|
-
name: identity
|
|
2
|
+
name: draft-identity
|
|
3
3
|
description: Drafts a project's logo mark through draft-and-pick's render-and-pick loop, then composes the picked mark into an icon sequence and a 1200x630 social card. Use when asked to "make a logo", "design a logo mark", "create a favicon", "build the icon set", "generate a social card", "make an og:image", or "draft the logo and social card together". Do NOT use to mutate an existing logo file directly, which is a plain edit, or to record the mark's construction rules in DESIGN.md, which is out of scope.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
|
-
#
|
|
6
|
+
# Draft identity
|
|
7
7
|
|
|
8
8
|
One identity rendered twice: the same mark sized down to an icon sequence and composed with type into the social card sized up. Drafting both from one pick keeps the mark and its card composition from settling on shapes that do not match.
|
|
9
9
|
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
---
|
|
2
|
-
name:
|
|
2
|
+
name: draft-screencast
|
|
3
3
|
description: Why a recording script ships pre-seeded rather than blank, what the four discovery questions buy, and why the draft stays stack-agnostic
|
|
4
4
|
---
|
|
5
5
|
|
|
6
|
-
#
|
|
6
|
+
# Draft screencast requirement
|
|
7
7
|
|
|
8
8
|
## Gap
|
|
9
9
|
|
|
@@ -33,7 +33,7 @@ A draft that names the recording software, the editing software, or the window m
|
|
|
33
33
|
|
|
34
34
|
## Out of scope
|
|
35
35
|
|
|
36
|
-
- Producing the recording, which `
|
|
36
|
+
- Producing the recording, which `record-screencast` owns
|
|
37
37
|
- Refining an existing draft, which is a direct edit of the file
|
|
38
|
-
- Slide decks, which `
|
|
38
|
+
- Slide decks, which `draft-slides` owns
|
|
39
39
|
- Where the recording ships, which the draft lists and the user decides
|
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
---
|
|
2
|
-
name:
|
|
2
|
+
name: draft-screencast
|
|
3
3
|
description: Drafts a screencast script with pre-seeded beats, defaults, and decisions to `.canon/tmp/screencast/<slug>.md`. Reads project context, asks four discovery questions with proposed defaults, then writes a shippable draft. Use when asked to "draft a screencast", "write a recording script", "plan a demo video", or "scaffold a screencast for X". Do NOT re-invoke to refine an existing draft. Re-running overwrites. Edit the draft file directly.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
|
-
#
|
|
6
|
+
# Draft screencast
|
|
7
7
|
|
|
8
8
|
## Guards
|
|
9
9
|
|
|
10
10
|
- If no topic is provided, stop: `❌ No screencast topic. Describe what you are recording.`
|
|
11
|
-
- Draft, then hand off. Do not edit video or generate captions, and do not drive the application. A recording is another skill's job rather than something forbidden: write the draft, name `
|
|
11
|
+
- Draft, then hand off. Do not edit video or generate captions, and do not drive the application. A recording is another skill's job rather than something forbidden: write the draft, name `record-screencast` as the next step, and stop.
|
|
12
12
|
- Stack-agnostic in the draft. Never name a recording tool, an editing tool, a font, or a window manager. Keep selectors, URLs, wait conditions, and timings out of the beats too, since those four are exactly what the compiler adds in a plan of its own. A beat carrying them stops being a document a person can read and edit down.
|
|
13
13
|
|
|
14
14
|
## Step 1: read the project context
|
|
@@ -159,7 +159,7 @@ Print the file path on its own line and a one-line summary. Do not paraphrase th
|
|
|
159
159
|
Draft has 5 beats and pre-seeded defaults. Edit the beats and the resolved decisions.
|
|
160
160
|
|
|
161
161
|
To record it rather than shoot it by hand:
|
|
162
|
-
|
|
162
|
+
record-screencast .canon/tmp/screencast/<slug>.md
|
|
163
163
|
```
|
|
164
164
|
|
|
165
165
|
Name the skill and stop there. Do not compile the draft, do not run it, and do not generate captions. The operator edits the beats first, and the compiler reports which selectors and URLs they still owe it.
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
---
|
|
2
|
-
name:
|
|
2
|
+
name: draft-slides
|
|
3
3
|
description: Why the skill owns deck content and the CLI owns layout, and why one render is read back before the deck is called done
|
|
4
4
|
---
|
|
5
5
|
|
|
6
|
-
#
|
|
6
|
+
# Draft slides requirement
|
|
7
7
|
|
|
8
8
|
## Gap
|
|
9
9
|
|
|
@@ -36,4 +36,4 @@ Reimplementing spacing and palette logic is the failure that survives the sessio
|
|
|
36
36
|
- Rendering, which the CLI owns end to end
|
|
37
37
|
- The palette and the type scale, which live in the CLI rather than in the source
|
|
38
38
|
- Writing the content the deck is about, which the caller brings
|
|
39
|
-
- Recording a demo, which `
|
|
39
|
+
- Recording a demo, which `draft-screencast` owns
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
---
|
|
2
|
-
name:
|
|
2
|
+
name: draft-slides
|
|
3
3
|
description: Drafts a `.claude/SLIDES.md` source from a topic, picks a layout per slide, then renders it to PowerPoint via `canon slides render`. Use when asked to "draft slides", "make a deck", "build a presentation", "turn this into slides", or "render a SLIDES.md". Holds the deck design rules. Do NOT reimplement render logic. The CLI owns layout and styling. Assumes the `canon` CLI is on PATH.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
|
-
#
|
|
6
|
+
# Draft slides
|
|
7
7
|
|
|
8
8
|
Author a `SLIDES.md` source, then shell out to `canon slides render`. The CLI owns all layout and styling. This skill owns content and the design choices encoded in the source.
|
|
9
9
|
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
---
|
|
2
|
-
name:
|
|
2
|
+
name: read-frames
|
|
3
3
|
description: Why a recording gets read back through numbered frames rather than left for a person to open, and why the report stops at description and never reaches a verdict
|
|
4
4
|
---
|
|
5
5
|
|
|
6
|
-
#
|
|
6
|
+
# Read frames requirement
|
|
7
7
|
|
|
8
8
|
## Gap
|
|
9
9
|
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
---
|
|
2
|
-
name:
|
|
3
|
-
description: Pulls numbered still frames from a recorded video through `canon demo frames`, reads each one with the Read tool, and reports one plain description per frame. Never judges the recording, since a frame read is evidence rather than a verdict. Use when asked to "check the recording", "read the demo frames", "see what the video shows", or right after `canon demo run` writes a video and nobody has opened it yet. Do NOT use to record the video, which is `
|
|
2
|
+
name: read-frames
|
|
3
|
+
description: Pulls numbered still frames from a recorded video through `canon demo frames`, reads each one with the Read tool, and reports one plain description per frame. Never judges the recording, since a frame read is evidence rather than a verdict. Use when asked to "check the recording", "read the demo frames", "see what the video shows", or right after `canon demo run` writes a video and nobody has opened it yet. Do NOT use to record the video, which is `record-screencast`, or to state whether the recording looks correct or broken, which is out of scope for every surface in the toolkit today.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
|
-
#
|
|
6
|
+
# Read frames
|
|
7
7
|
|
|
8
8
|
## Guards
|
|
9
9
|
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
---
|
|
2
|
-
name:
|
|
2
|
+
name: record-screencast
|
|
3
3
|
description: Why compiling and running a screencast draft needs a routed skill rather than an operator typing two verbs by hand, and why the unresolved-field refusal cannot be a suggestion
|
|
4
4
|
---
|
|
5
5
|
|
|
6
|
-
#
|
|
6
|
+
# Record screencast requirement
|
|
7
7
|
|
|
8
8
|
## Gap
|
|
9
9
|
|
|
10
|
-
Without this skill, `
|
|
10
|
+
Without this skill, `draft-screencast` writes a draft and names `canon demo compile` as the next step, and nothing after that routes a session there or to `canon demo run`. An operator has to know both verbs, type them in order, and read the compile record for unresolved fields by hand. A session that fills one in on its own reproduces the exact failure `canon demo run`'s `plan-unresolved` reason exists to catch, one layer up where nothing enforces it.
|
|
11
11
|
|
|
12
12
|
## Must
|
|
13
13
|
|
|
@@ -21,7 +21,7 @@ Without this skill, `canon-screencast` writes a draft and names `canon demo comp
|
|
|
21
21
|
- Pass `--force` to compile
|
|
22
22
|
- Guess or fill a target, a URL, or any other unresolved field
|
|
23
23
|
- Drive the application through anything other than `canon demo run`
|
|
24
|
-
- Assume this skill's own invocation frequency needs no check. `
|
|
24
|
+
- Assume this skill's own invocation frequency needs no check. `draft-screencast`'s closing block names it by hand, but whether anything else reaches for it beyond that pointer or an operator typing its name has no answer at creation time, so a review pass some months in should read that back rather than take it on faith.
|
|
25
25
|
|
|
26
26
|
## Guards
|
|
27
27
|
|
|
@@ -29,6 +29,6 @@ Without this skill, `canon-screencast` writes a draft and names `canon demo comp
|
|
|
29
29
|
|
|
30
30
|
## Out of scope
|
|
31
31
|
|
|
32
|
-
- Drafting the beats, which `
|
|
32
|
+
- Drafting the beats, which `draft-screencast` owns
|
|
33
33
|
- Filling an existing plan's target or URL, which is the operator's own edit
|
|
34
34
|
- Verifying a recording beyond its own caption, which nothing in the toolkit does yet
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
---
|
|
2
|
-
name:
|
|
3
|
-
description: Drives a screencast draft through to a recording. Compiles it with `canon demo compile` when no plan exists yet at the default path, skipping compile when one is already there, then runs `canon demo run` once nothing is unresolved. Reports every unresolved field from the compile or run record and stops rather than guessing one. Use when asked to "record the screencast", "run the demo", "compile and record this draft", or right after `
|
|
2
|
+
name: record-screencast
|
|
3
|
+
description: Drives a screencast draft through to a recording. Compiles it with `canon demo compile` when no plan exists yet at the default path, skipping compile when one is already there, then runs `canon demo run` once nothing is unresolved. Reports every unresolved field from the compile or run record and stops rather than guessing one. Use when asked to "record the screencast", "run the demo", "compile and record this draft", or right after `draft-screencast` prints its next-step line. Do NOT use to draft the beats, which is `draft-screencast`, or to fill in a plan's target or URL, which is the operator's own edit.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
|
-
#
|
|
6
|
+
# Record screencast
|
|
7
7
|
|
|
8
8
|
## Guards
|
|
9
9
|
|
|
10
|
-
- If no draft path is given, stop: `❌ No draft path. Pass the path
|
|
10
|
+
- If no draft path is given, stop: `❌ No draft path. Pass the path draft-screencast printed.`
|
|
11
11
|
- Never guess or fill an unresolved field, a target, a URL, or anything else the compile or run record names. Report it and stop. Filling one in reproduces the failure `canon demo run`'s `plan-unresolved` reason exists to catch, one layer up where nothing enforces it.
|
|
12
12
|
- Never pass `--force` to compile. A plan already at the default output path may carry timing tuned by hand, and the draft cannot reproduce that, so leave it untouched.
|
|
13
13
|
- Drive the application through `canon demo run` alone. Never open a browser, click through the app, or write to the output paths some other way.
|
|
@@ -29,7 +29,17 @@ reader scanning the thread finds the current verdict where the last one sat.
|
|
|
29
29
|
|
|
30
30
|
## Step 1: resolve the PR and read context
|
|
31
31
|
|
|
32
|
-
|
|
32
|
+
Capture `<read-at>` first, ahead of every read this pass makes:
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
date -u +%Y-%m-%dT%H:%M:%SZ
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
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.
|
|
39
|
+
|
|
40
|
+
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.
|
|
41
|
+
|
|
42
|
+
`<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.
|
|
33
43
|
|
|
34
44
|
Read these in parallel from the project root, skipping any that do not exist:
|
|
35
45
|
|
|
@@ -46,14 +56,26 @@ Coding standards from `.claude/rules/` are auto-loaded by Claude Code.
|
|
|
46
56
|
Find the commit the last pass covered and the verdict it posted:
|
|
47
57
|
|
|
48
58
|
```bash
|
|
49
|
-
|
|
59
|
+
canon pr review-state <number> --json
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
The fields are `<prior-oid>` off `commit`, `<prior-heading>` off `heading`, and `<prior-at>` off `readAt // submittedAt`. The commit scopes the read below, the heading feeds the repeat guard at the end of this step, and the instant scopes the reply query further down, so one call answers all three rather than three reads of the same review. Branch on the record rather than on the exit code, which a shell function wrapping `canon` can flatten to zero.
|
|
63
|
+
|
|
64
|
+
`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.
|
|
65
|
+
|
|
66
|
+
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. That fired for real on a pull request in this toolkit on 2026-09-07, and what it skipped was a genuine fix.
|
|
67
|
+
|
|
68
|
+
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:
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
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 // ""))'
|
|
50
72
|
```
|
|
51
73
|
|
|
52
|
-
The
|
|
74
|
+
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.
|
|
53
75
|
|
|
54
76
|
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.
|
|
55
77
|
|
|
56
|
-
|
|
78
|
+
A `source` of `none`, or an empty result from the fallback, is a first pass. Read the whole change:
|
|
57
79
|
|
|
58
80
|
```bash
|
|
59
81
|
gh pr diff <number>
|
|
@@ -82,9 +104,11 @@ On exit zero, review `<prior-oid>..<headRefOid>` and nothing else. `git diff` an
|
|
|
82
104
|
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:
|
|
83
105
|
|
|
84
106
|
```bash
|
|
85
|
-
gh pr view <number> --json
|
|
107
|
+
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'
|
|
86
108
|
```
|
|
87
109
|
|
|
110
|
+
`<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.
|
|
111
|
+
|
|
88
112
|
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.
|
|
89
113
|
|
|
90
114
|
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.
|
|
@@ -164,6 +188,8 @@ X critical, Y should-fix, Z minor. Reviewed against project docs and the board.
|
|
|
164
188
|
- bounded confirmation.
|
|
165
189
|
|
|
166
190
|
🤖 Reviewed by Claude Code
|
|
191
|
+
|
|
192
|
+
<!-- review-pr: commit=<headRefOid> read-at=<read-at> -->
|
|
167
193
|
```
|
|
168
194
|
|
|
169
195
|
A stale ticked box goes in a `**PR body**` block, in place of a `**`path/to/file.ext`**` block and ahead of every one of those, since it precedes the code the diff carries rather than sitting inside it.
|
|
@@ -182,6 +208,8 @@ Re-reviewed `<short-sha>`, N commits since the prior pass. X critical, Y should-
|
|
|
182
208
|
- **should-fix**: what breaks and the fix, in two or three sentences.
|
|
183
209
|
|
|
184
210
|
🤖 Reviewed by Claude Code
|
|
211
|
+
|
|
212
|
+
<!-- review-pr: commit=<headRefOid> read-at=<read-at> -->
|
|
185
213
|
```
|
|
186
214
|
|
|
187
215
|
A Testing box the Step 3 check raised goes in a `**Testing**` block placed after the file blocks, one bullet per box, each quoting the box and naming what would drive it. It carries no severity and enters no count, and it is still something owed, so a pass carrying one takes `## Review` and the full body rather than either ✅ line. Say so on the summary line as `plus N testing question(s)`, since the three counts read as zero and would otherwise report the pass as silent.
|
|
@@ -228,6 +256,24 @@ The `What is right` section is optional, capped at three bullets, and included o
|
|
|
228
256
|
|
|
229
257
|
Close the body with `🤖 Reviewed by Claude Code` on its own line so the review reads as an independent machine pass, not a human sign-off.
|
|
230
258
|
|
|
259
|
+
### The marker every body carries
|
|
260
|
+
|
|
261
|
+
End every body with this line, carrying `<headRefOid>` and `<read-at>` from Step 1 verbatim:
|
|
262
|
+
|
|
263
|
+
```markdown
|
|
264
|
+
<!-- review-pr: commit=<headRefOid> read-at=<read-at> -->
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
Every body this step writes carries it, with no exception: the full body under either heading, both ✅ close-out lines, a withdrawal body, and the `PUT` rewrite at the end of this step. A body missing it reads as a pre-marker pass, so the next reader falls back to the stamps and the pass loses the coverage it actually had.
|
|
268
|
+
|
|
269
|
+
Last is load-bearing rather than tidy. `canon pr review-state` reads the last non-empty line and searches nowhere else, so a marker written above the footer is a marker the next pass does not see. That position is also what lets a finding quote the format safely, including inside a fenced block, since a quotation is never the line the reader takes.
|
|
270
|
+
|
|
271
|
+
It is what the review is scoped from. GitHub stamps `commit.oid` and `submittedAt` when a review is submitted, not when it was read, so a push landing in the compose window moves both onto a commit this pass never opened and the next pass reads that commit as covered. The marker is the read-time record those two fields are not, and `canon pr review-state` is the one place it is parsed, so Step 2 here and the orchestrator poll read one answer rather than each carrying a copy of the format.
|
|
272
|
+
|
|
273
|
+
Write it as a comment rather than as prose so a reader of the thread never meets it. HTML comments render as nothing on GitHub, which is why the fact travels here rather than in a footer line a person would have to be told to ignore.
|
|
274
|
+
|
|
275
|
+
It is inert data, so the `publish.md` scan below and `canon labels scan` have nothing to fire on. Confirm that against the posted body rather than assuming it, since a phase label or a board identifier appearing inside a commit sha is not a shape either scan was written against.
|
|
276
|
+
|
|
231
277
|
Before posting, run the scan in `${CLAUDE_SKILL_DIR}/../../standards/publish.md` against the body. The hook skips `.canon/tmp/`, so this scan is the only gate ahead of the post. A finding phrased against an internal phase label is what the label half of the scan catches here. This repository reads the same text again once posted, on `phase-label-gate.yml`'s `pull_request_review` trigger, which is what closes the gap this scan leaves open for a review a person writes and posts by hand with no scripted step in front of it. That workflow now reaches every target on the `base` stack, seeded at `tooling/base/configs/.github/workflows/phase-label-gate.yml` invoking the published CLI rather than this checkout's own source tree, so a target's coverage extends past this pre-post scan to the same post-trigger re-read this repository gets.
|
|
232
278
|
|
|
233
279
|
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, under `### A close-out that repeats the standing one` at the end of this step. 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.
|
|
@@ -250,6 +296,8 @@ A pass carrying only minors is an ordinary finding-carrying pass, so it takes th
|
|
|
250
296
|
- **minor**: finding, and the fix it wants.
|
|
251
297
|
|
|
252
298
|
🤖 Reviewed by Claude Code
|
|
299
|
+
|
|
300
|
+
<!-- review-pr: commit=<headRefOid> read-at=<read-at> -->
|
|
253
301
|
```
|
|
254
302
|
|
|
255
303
|
A pass that closed by withdrawing a finding rather than by reading its fix takes neither ✅ line, per the withdrawal rule in Step 3. Both claim a fix landed, and the second names it, so posting either over a withdrawal credits work nobody did on the one comment a reader treats as the verdict. Write the withdrawal and the fact that settled it in place of the canned line, keeping the heading and the footer.
|
|
@@ -278,9 +326,9 @@ gh api -X PUT repos/{owner}/{repo}/pulls/<number>/reviews/<review-id> -F body=@<
|
|
|
278
326
|
|
|
279
327
|
The guard fires on `## Review closed` alone. Two open passes carry different findings and both are worth reading, so a repeated `## Review` posts normally. A pass carrying anything owed posts normally too, under `## Review`, which is what keeps a finding raised after a close-out from being swallowed by the guard that exists for a silent one.
|
|
280
328
|
|
|
281
|
-
|
|
329
|
+
The rewrite used to cost the review's `commit.oid`, which `PUT` leaves pinned at the commit the standing close-out was first submitted against. Step 2's `<prior-oid>` and the prior commit `poll.sh` derives both read that field, so the next pass read a range wider than its delta, and the poll's `SEEN` branch, which fires on `prior` equalling the head, could never be reached at all: an out-of-band pass reported as `MOVED` for the rest of the pull request's life.
|
|
282
330
|
|
|
283
|
-
The
|
|
331
|
+
The marker closes both, because `PUT` replaces the body and the marker is in it. The rewritten close-out carries the commit this pass read rather than the one the comment was first submitted against, and every reader now takes that in preference to the pinned field. The one thing `PUT` still cannot move is `submittedAt`, which stays at the original submission and is what the poll's age test reads, so a rewritten close-out ages from when it first landed rather than from when it was last rewritten. That is the correct reading for a thread waiting on a human, which is the question the age test asks.
|
|
284
332
|
|
|
285
333
|
## Step 5: output
|
|
286
334
|
|
|
@@ -114,7 +114,7 @@ Write no shape for a correction. A correction is a sentence, and a format for ad
|
|
|
114
114
|
4. Hand off. Read `${CLAUDE_SKILL_DIR}/references/orchestrator-dispatch.md` and follow it: check the branch is unclaimed, check the row's file set against every track in flight, then dispatch a background worker with `claude --bg`. Fall back to the human-launch line it replaces when the check refuses, the sets overlap, or a stated reason serializes the row behind something already out.
|
|
115
115
|
5. Review the PR. When a worker opens a PR, run `review-pr` to post findings to it. This is the deep, independent pass. The worker's autoship self-review was only the green gate.
|
|
116
116
|
- Learning that a PR moved is the mechanical half, so read `${CLAUDE_SKILL_DIR}/references/orchestrator-poll.md` and run the poll under the condition it states rather than checking the board by hand. That runbook holds the routing and the trigger, and a summary of it here is a second source that drifts from it.
|
|
117
|
-
6. Dispatch the handback. A pass posting anything owed, a finding at any severity or a testing question, tells the session holding that branch to run `review-address`, rather than waiting for a person to relay it. Re-review when the worker's own message says the address pass finished, per the channel `role-worker` states, rather than polling for an answer nothing else marks as landed. Then the human merges. Tell the trailing worker to rebase when its branch shares a seam with the merged one.
|
|
117
|
+
6. Dispatch the handback. A pass posting anything owed, a finding at any severity or a testing question, tells the session holding that branch to run `review-address`, rather than waiting for a person to relay it. Re-review when the worker's own message says the address pass finished, per the channel `role-worker` states, rather than polling for an answer nothing else marks as landed. Once a pass posts `## Review closed`, lift the pull request's draft mark yourself, per Boundaries below. Then the human merges. Tell the trailing worker to rebase when its branch shares a seam with the merged one.
|
|
118
118
|
- Read the threshold off `review-pr`, which states it once and governs the heading with it, so an open heading and an owed dispatch answer the same question and either one is enough to send
|
|
119
119
|
- Resolve the target at the moment of sending with `canon sessions list --branch`, never from a mapping written down earlier, since names rotate as sessions end and one recorded earlier in a session has failed inside the hour. The runbook read at step 5 routes on the count and the confidence it answers with
|
|
120
120
|
- Open the message with the worktree and branch the sender believes the reader holds, asking to be corrected, whenever that mapping is inferred rather than confirmed
|
|
@@ -140,6 +140,7 @@ Stamp the block with the commit this session read the tree at, which the same se
|
|
|
140
140
|
- Run one orchestrator at a time. The board is gitignored, so a second session sees none of this one's writes: two task files land minutes apart under different labels for the same work, one session archives a task mid-sweep in the other, and each archives a plan the other had retargeted. An Owner column does not fix this, since neither session can read the other's rows.
|
|
141
141
|
- Do not implement features in this session. Hand the plan to a worker.
|
|
142
142
|
- Do not merge. Recommend merge or changes. The human merges.
|
|
143
|
+
- Lift a pull request's draft mark once this session's own review of it closes, acting directly on the pull request rather than dispatching a worker to do it. `role-worker` states the mirroring refusal: a worker cannot verify who is asking or whether review actually closed, so the act stays with whoever closed the review.
|
|
143
144
|
- Do not spawn a worker with the Agent tool. An in-process subagent shares this session's context and cannot be steered or reached independently, which breaks the property this boundary protects rather than the mechanism it names. The dispatch in `orchestrator-dispatch.md` is a separate `claude --bg` process with its own worktree and its own PR, so it preserves that property instead.
|
|
144
145
|
- Dispatch a background worker only once the collision check in `orchestrator-dispatch.md` clears and the row's file set is disjoint from every track in flight. Colliding with an existing worktree or session is what the check exists to catch rather than a judgment call this session makes case by case. No fixed count binds how many tracks run at once, and Parallelism below states what does.
|
|
145
146
|
- Do not edit tracked files from this session, at any size. The boundary offers no proportionality exception and nothing enforces it.
|
|
@@ -56,7 +56,7 @@ The script exits non-zero and classifies nothing when the open pull request list
|
|
|
56
56
|
|
|
57
57
|
The baseline lives at `.canon/tmp/pr-poll/baseline.txt` under the main worktree root and is per-machine. A first run against a board already in flight reports each open pull request once before it settles.
|
|
58
58
|
|
|
59
|
-
The five review headings the script matches are written by `review-pr` and `review-address`, and the whole set is stated once in the first.
|
|
59
|
+
The five review headings the script matches are written by `review-pr` and `review-address`, and the whole set is stated once in the first. The reply family is matched by jq filters in the script, so a project posting its replies under different headings edits those to match. The review family is matched inside `canon pr review-state` instead, which the script and `review-pr` both read through, so a project renaming either review heading changes the verb rather than the script. Either way, a heading nothing matches reads as a pull request nobody has reviewed.
|
|
60
60
|
|
|
61
61
|
`UNMATCHED` is what a heading outside the five reaches, carried the same way `RESPONSE` is: a rising count against the baseline is what is new to this script, and the message names the heading so a person can tell whether to answer it by hand or add it to the set. It fires on a tracked pull request only, since a first sighting reports `SEEN` or `OPENED` and takes whatever count already sits on the thread as its starting baseline rather than flagging history the poll never watched.
|
|
62
62
|
|
|
@@ -70,9 +70,13 @@ The state reaches every stalled dispatch, since one threshold governs the headin
|
|
|
70
70
|
|
|
71
71
|
The report is also where the count in `## Parallelism` is legible. 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.
|
|
72
72
|
|
|
73
|
-
The count
|
|
73
|
+
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. That fired for real on a pull request in this toolkit on 2026-09-07, and the delta it skipped was a genuine fix.
|
|
74
74
|
|
|
75
|
-
The
|
|
75
|
+
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`. The manual double-check that used to hang off this paragraph is retired with the defect: a `SEEN` is now a claim about what a session read rather than about what GitHub stamped, so it wants no second read to be believed.
|
|
76
|
+
|
|
77
|
+
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.
|
|
78
|
+
|
|
79
|
+
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. Two sessions were wrong off that field on 2026-09-01: a reviewing session posted a finding calling a pushed commit unpushed, and a worker fired a green claim it retracted on its own thread minutes later. 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.
|
|
76
80
|
|
|
77
81
|
## The watch beside it
|
|
78
82
|
|