@erclx/canon 4.105.0 → 4.106.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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "canon",
3
3
  "description": "Automated governance, versioning, and discovery tools for Claude Code.",
4
- "version": "4.105.0",
4
+ "version": "4.106.0",
5
5
  "author": {
6
6
  "name": "Eric Le",
7
7
  "url": "https://github.com/erclx"
@@ -38,7 +38,24 @@ Announce which of the two decided the sequence.
38
38
 
39
39
  ## Step 3: detect the write folder
40
40
 
41
- Check for `public/`, `static/`, or a stack-declared asset folder, taking the first that exists. Fall back to the project root when none is detected, mirroring `design-extract`'s own source-versus-greenfield split. Announce the folder so the operator can move the files if the project's own convention differs.
41
+ A root-only check finds a project's assets only where the project keeps them at the root. A repository holding its site in a subfolder keeps them a level down, and a Next project keeps its favicon under the App Router convention with no `public/` at all, so both fall through to the root fallback while the announcement reads as a detected folder. Walk one level down and know that convention rather than falling through.
42
+
43
+ Read both, from the project root:
44
+
45
+ ```bash
46
+ find . -maxdepth 3 \( -name node_modules -o -name .git -o -name dist -o -name .astro \) -prune -o -type d \( -name public -o -name static \) -print | sort
47
+ find . -maxdepth 4 \( -name node_modules -o -name .git \) -prune -o -type d -path '*/src/app' -print | sort
48
+ ```
49
+
50
+ Take the first tier that answers:
51
+
52
+ 1. A root `public/` or `static/`.
53
+ 2. A `public/` or `static/` one level down, such as `web/public/`. Where the first read returns more than one, take the shallowest, and the one whose parent holds the project's own framework config where two sit at the same depth.
54
+ 3. A `src/app/` folder, at the root or one level down, which is the Next App Router convention and where that framework's own favicon and card files sit. Take the folder itself, not a `public/` beside it.
55
+ 4. A stack-declared asset folder, where the project's own config names one.
56
+ 5. The project root, which is the fallback rather than a detection.
57
+
58
+ Announce which tier decided, naming the tier rather than the path alone. On tier 5 say the detection found nothing and the root is a fallback, since a folder reported without that word reads as a folder the project declared. Announce it either way so the operator can move the files where the project's own convention differs.
42
59
 
43
60
  ## Step 4: name the decision and the arms
44
61
 
@@ -50,14 +67,12 @@ Structure every arm as a `.mark` element, the inline SVG alone, nested inside a
50
67
 
51
68
  Follow `${CLAUDE_SKILL_DIR}/../draft-and-pick/SKILL.md` Steps 2 through 5 against the arms from Step 4: author the page, render and hand off, take the pick, and loop on it.
52
69
 
53
- ## Step 6: finalize
70
+ ## Step 6: finalize the mark and the icons
54
71
 
55
- This step replaces `draft-and-pick`'s own Step 6, since the pick here produces several final files rather than one applied surface.
72
+ This step and Step 7 replace `draft-and-pick`'s own Step 6, since the pick here produces several final files rather than one applied surface.
56
73
 
57
74
  1. Extract the picked arm's `.mark` markup as the final vector source. Write it to `<write-folder>/favicon.svg`.
58
- 2. Under `<dest>/render/`, write one page per Step 2 size plus one for the card. Give every page's captured element the shared class `.render`, so one capture call renders the whole batch regardless of the size spread. `canon capture` opens every page at a fixed 2x device scale factor and screenshots the element at that scale, so declare each `.render` element at half its target dimension, `<w>/2` by `<h>/2`, to land the captured PNG on the literal target size rather than double it. Declare a machine-resolved font stack (`system-ui` behind a generic fallback) on each, since `canon capture` refuses a page naming no font at all.
59
- - `icon-<w>x<h>.html`: the picked `.mark` markup, its `.render` wrapper sized `<w>/2` by `<h>/2`
60
- - `og-image.html`: the picked `.card` markup, its `.render` wrapper sized 600x315 to capture at the native 1200x630
75
+ 2. Under `<dest>/render/`, write one `icon-<w>x<h>.html` page per Step 2 size, carrying the picked `.mark` markup. Give every page's captured element the shared class `.render`, so one capture call renders the whole batch regardless of the size spread. `canon capture` opens every page at a fixed 2x device scale factor and screenshots the element at that scale, so declare each `.render` element at half its target dimension, `<w>/2` by `<h>/2`, to land the captured PNG on the literal target size rather than double it. Declare a machine-resolved font stack (`system-ui` behind a generic fallback) on each, since `canon capture` refuses a page naming no font at all.
61
76
  3. Render the batch:
62
77
 
63
78
  ```bash
@@ -65,16 +80,75 @@ This step replaces `draft-and-pick`'s own Step 6, since the pick here produces s
65
80
  ```
66
81
 
67
82
  4. Report each written file's path and the dimensions `canon capture` printed for it, confirming each equals its Step 2 target rather than half of it.
68
- 5. Delete `<dest>`, per `draft-and-pick`'s own scratch-folder rule.
83
+
84
+ The card leaves this batch and takes Step 7 instead. An icon is the mark alone at a fixed size and a scratch page renders it faithfully, where the card is a composition of the mark with the project's own type and color and a scratch page can only carry typed copies of both.
85
+
86
+ ## Step 7: write the card as a route and capture it through the server
87
+
88
+ A card captured from a page this run deletes leaves the project a PNG and nothing that produced it, so renaming the project means another pass through the pick loop with an operator in it to move one string. Write the composition into a route the project keeps, and capture that route through a running server so it reads the project's real stylesheet, tokens and fonts.
89
+
90
+ This is Astro-shaped in v1, because Astro is the one web stack whose config gives a route a structural build exclusion. A project already holding the card route scaffold takes the route path, and everything else falls to the page path below. Say which one decided, since the two leave the project different things.
91
+
92
+ ### The route path
93
+
94
+ 1. Check that the project already holds the scaffold, being a `card.config.mjs` with a `card-src/pages/og-card.astro` beside it. Take the page path below where either is missing, and leave the install to the operator:
95
+
96
+ ```plaintext
97
+ No card route installed, so the card shipped as a PNG this run.
98
+ `canon tooling sync astro . --write` installs the route, and it replaces every other
99
+ golden config in the astro stack, so run it when you mean to sync the stack.
100
+ ```
101
+
102
+ Never run that sync to obtain the route. It copies the whole stack over the project's own framework, lint, typescript, and test configs, which is a cost nobody asking for a logo agreed to. Hand-writing the scaffold instead is the other wrong answer, since a copy written here drifts from the one the stack maintains.
103
+
104
+ The scaffold is a second framework config pointing `srcDir` at a folder beside `src/`, the route inside that folder importing the project's global stylesheet and carrying a fixed `<meta>` marker, and a shell check failing a build that carries the marker. Of the route's three consumers, the published build is the only one a check reaches. Read the shape with `canon tooling reference astro`.
105
+
106
+ 2. Write the picked arm's `.card` markup into the route's `.card` element, leaving the marker, the stylesheet import and the surrounding document alone. Keep the element at 600x315, half of the 1200x630 target, since the capture's 2x scale factor is fixed and a 1200x630 element captures at 2400x1260.
107
+
108
+ 3. Start the card server in the background and read the port off its own output:
109
+
110
+ ```bash
111
+ bun run card:dev
112
+ ```
113
+
114
+ It serves under a strict port, so it refuses rather than walking to the next free one. Set `WORKTREE_PORT_OFFSET` by hand and start it again where something already holds that port.
115
+
116
+ 4. Capture the route to the write folder, naming the destination PNG rather than a directory, which is what a URL source takes for `--out`:
117
+
118
+ ```bash
119
+ canon capture http://localhost:<port>/og-card --selector .card --out <write-folder>/og-image.png
120
+ ```
121
+
122
+ 5. Stop the server. Report the dimensions `canon capture` printed, confirming 1200x630.
123
+
124
+ A refusal naming a font here is the mechanism working rather than a failure to route around. It says the route asked for a family the machine cannot resolve, which is the same rewrap a published card would have shipped silently.
125
+
126
+ ### The page path
127
+
128
+ Where the project holds no route to write into, write `og-image.html` into `<dest>/render/` with its `.render` wrapper sized 600x315, then capture that one page:
129
+
130
+ ```bash
131
+ canon capture <dest>/render/og-image.html --selector .render --out <write-folder>
132
+ ```
133
+
134
+ It takes its own call rather than riding Step 6's batch, since Step 6 runs before this step chooses a path and the batch is already captured by the time the choice is made. Say that the card shipped as a PNG with no source, and that the project gains one when it grows a route to hold the composition.
135
+
136
+ ### Closing the scratch folder
137
+
138
+ Delete `<dest>` per `draft-and-pick`'s own scratch-folder rule. The route is not a leftover variant that rule forbids: a variant is one of several candidates none of which was chosen, and the route holds the arm the operator picked. The losing arms are what that rule is about, and they are already archived and deleted by the step it belongs to.
139
+
140
+ A project whose card was generated before this route existed keeps its PNG. Say so rather than regenerating it, since re-running the pick loop spends an operator's attention on a file nobody complained about, and the next real edit to the card picks up the route.
69
141
 
70
142
  ## Response format
71
143
 
72
144
  ```plaintext
73
145
  📝 Wrote <write-folder>/favicon.svg
74
146
  📝 Wrote <write-folder>/icon-16x16.png (16x16)
147
+ 📝 Wrote card-src/pages/og-card.astro (route path only)
75
148
  📝 Wrote <write-folder>/og-image.png (1200x630)
76
149
 
77
- Write folder: <detected <path>|defaulted to project root>. Move the files if this project's own convention differs.
150
+ Write folder: <tier that decided>. Move the files if this project's own convention differs.
151
+ Card source: <the route, captured through the card server|a scratch page, since this project holds no card route>.
78
152
  ```
79
153
 
80
154
  ## What this delegates
@@ -83,4 +157,5 @@ Cite these rather than restating them.
83
157
 
84
158
  - `draft-and-pick` owns Steps 1 through 5 of the render-and-pick loop, cited above
85
159
  - `design-extract` owns building `canon/DESIGN.md`. This skill only reads it.
86
- - `canon capture` owns the render mechanics, its font refusal, and its reported dimensions
160
+ - `canon capture` owns the render mechanics, its font refusal, its fixed 2x scale factor, and its reported dimensions. It already reads an `http(s)://` source, where `--out` names the destination PNG rather than a directory, so the route capture needs nothing added to it.
161
+ - The `astro` tooling stack owns the card route's scaffold, its second config, and the exclusion check, read with `canon tooling reference astro`. This skill writes into the route and never reshapes it.
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: role-planner
3
- description: What a planning session is, how it reads what is in flight, the surfaces it may not write, and what it hands back to whoever dispatched it
3
+ description: What a thinking session is across planning, groundwork, and intake, how it reads what is in flight, the writes-records-never-source boundary, and what it hands back to whoever dispatched it
4
4
  ---
5
5
 
6
6
  # Role planner requirement
@@ -49,25 +49,30 @@ four carried at least one, caught only because the planner ran
49
49
 
50
50
  ## Must
51
51
 
52
- - Assert what a planning session is, what it may not write, and how long the role lasts, since `plan-feature` carries the procedure and no body carries the role
52
+ - Assert what a thinking session is, what it may not write, and how long the role lasts, since `plan-feature`, `plan-groundwork`, and `plan-intake` each carry their own procedure and no body carries the role the three share
53
+ - Cover all three of those skills rather than `plan-feature` alone, since a groundwork or intake session took no role at all while the body was written for one plan under one task
54
+ - State the writes-records-never-source boundary once, and name the three folders in one line while deferring each scope's exceptions to the skill that owns it, so a change to one scope edits one body
55
+ - Carry `plan-groundwork`'s close-time task file as an exception to the board ban, since widening the role without it makes the role forbid what that skill requires
53
56
  - State the in-flight read as a command composing the session roster with open pull requests, and say that a branch and a worktree are not evidence, since a count of either reported merged work as live
54
57
  - Run that read once per task rather than once per batch, since a reused session ages its picture of the tree while it works
55
58
  - Name every read a plan needed and a launch string did not carry, the task file's findings and the source files among them, since a count quoted from a row was wrong or stale in ten places across four plans
56
59
  - Name the validate and audit calls the written plan passes through, since nothing else opens a plan and the audit caught ban hits in three of four trial plans
57
60
  - State the shared board as read-only and name the cross-feature call as the controller's, since a planner reading blockers and file sets can write a merge order off a partial picture and will not notice that it has
58
- - Owe an announcement when the plan lands, carrying the path and what the row got wrong, since the controller cannot watch the read happen
61
+ - Owe an announcement when the record lands, carrying the path and what the row got wrong, since the controller cannot watch the read happen
59
62
  - Owe a message before a block becomes an interactive prompt, since a session already waiting on input never reaches the tool round an inbound message drains at
60
63
  - Keep correcting the dispatcher a first-class move carrying its evidence, since a trial corrected a brief's premise, planned every row anyway, and carried the consequence into a plan question
61
- - Point at `plan-feature` for the plan's shape and the steps that write it
64
+ - Point at each of the three skills for its record's shape and the steps that write it
65
+ - Defer the addressee resolution to `session-relay` and keep only the last-rung inference, which discriminates differently here than it does for a worker because a session in this role holds no feature branch either
62
66
 
63
67
  ## Must not
64
68
 
65
- - Restate the plan's sections, its suggested-and-answer contract, or the steps `plan-feature` carries, since thinness is what keeps one body correct for a dispatched planner reading it as its whole contract
69
+ - Restate a record's sections, its suggested-and-answer contract, or the steps the running skill carries, since thinness is what keeps one body correct for a dispatched session reading it as its whole contract
70
+ - Restate the write-scope exceptions each of the three skills states about its own folder
66
71
  - Restate a boundary `role-orchestrator` or `role-worker` states about itself
67
72
  - Tell a planner to halt on a plan question, which the suggested line already answers, rather than on what blocks writing the plan at all
68
73
  - Tell a planner to report the path with no account of what the row got wrong. That instruction came from a trial condition holding a blind comparison intact, and it is not a durable obligation.
69
74
  - Report progress through the channel, which rebuilds on the sender's side the poll the announcement exists to retire
70
- - Write the priority board, the backlog, or the task file, at any size
75
+ - Write the priority board or the backlog, at any size, or the task file outside `plan-groundwork`'s close-time exception
71
76
  - Be a skill nothing invokes but its author typing the name. `orchestrator-dispatch.md` names it on the planning launch the way it names `role-worker` on a build, so a stretch where only a typed invocation reaches it is the signal that the role never took.
72
77
 
73
78
  ## Guards
@@ -79,7 +84,9 @@ four carried at least one, caught only because the planner ran
79
84
 
80
85
  ## Out of scope
81
86
 
82
- - The plan's sections and its answer contract, which `standards/plan.md` fixes, and the steps that write the file, which `plan-feature` owns
87
+ - Each record's sections and its answer contract, which the standard behind it fixes, and the steps that write the file, which `plan-feature`, `plan-groundwork`, and `plan-intake` each own for their own
88
+ - The write-scope exceptions, which stay in the three skills: groundwork's close-time task file, its fixture and evidence paths, and intake's statement that it has none
89
+ - The mechanical addressee resolution, which `session-relay` owns for every session with or without a role
83
90
  - The branch, the build, and the pull request, which `role-worker` and `auto-ship` own
84
91
  - Deciding which rows run and in what order, which is the controlling session's call and stated in `role-orchestrator`
85
92
  - The dispatch itself, its collision check, and its disjointness gate, which `orchestrator-dispatch.md` holds
@@ -1,54 +1,71 @@
1
1
  ---
2
2
  name: role-planner
3
- description: Asserts the planner role for a session writing one plan under one task, holding what it reads before deciding, the surfaces it may not write, and what it hands back. Use when asked to "be the planner", "you are a planner session", at the start of a dispatched or hand-launched planning run, or when a planning session needs to know what it may not write. Do NOT use to write the plan itself, which is `plan-feature`, to make the cross-feature merge call, or to implement.
3
+ description: Asserts the planner role for a session writing one plan, groundwork track, or intake pass under one row, holding what it reads before deciding, what may be written and where, how to read what is already in flight, and what it hands back. Use when asked to "be the planner", "you are a planner session", at the start of a dispatched or hand-launched planning, groundwork, or intake run, or when a planning session needs to know what it may not write. Do NOT use to write the record itself, which is `plan-feature`, `plan-groundwork`, or `plan-intake`, to make the cross-feature merge call, or to implement.
4
4
  ---
5
5
 
6
6
  # Role planner
7
7
 
8
- This session is a planner: one session writing one plan for one task. It reads
9
- the row, measures what the row claims against the tree, writes the plan, and
10
- hands back the path.
8
+ This session thinks rather than builds. It reads the row, measures what the row
9
+ claims against the tree, writes one record, and hands back the path. The record
10
+ is a feature plan under `plan-feature`, a groundwork track under
11
+ `plan-groundwork`, or an intake pass under `plan-intake`, and the role is the
12
+ same across all three.
11
13
 
12
14
  It does not implement, it does not decide what merges before what, and it does
13
15
  not review. Those belong to a worker, to the controlling session, and to the
14
16
  human.
15
17
 
16
18
  This body states the role, the reads, the boundaries, and the channel, and it
17
- starts no step of its own. `plan-feature` owns the steps that write the plan
18
- and cites the standard fixing its sections and its suggested-and-answer
19
- contract, so read the procedure there and reach it from the launch rather than
20
- from here.
19
+ starts no step of its own. Each of the three skills owns the steps that write
20
+ its record and cites the standard fixing that record's shape, so read the
21
+ procedure there and reach it from the launch rather than from here.
22
+
23
+ "Planner" stretches to cover measuring and triage. The operator accepted that,
24
+ since what the three share is the boundary below rather than the artifact each
25
+ produces.
21
26
 
22
27
  ## Where the session stands
23
28
 
24
- - Write one file, the plan, at `.canon/plans/feature-<slug>.md` on the main worktree root. Everything else this session touches is a read. This session holds no worktree, so send the file as a plain `Bash` heredoc past the same main-root refusal a linked worktree meets. The route skips the write-matched hooks, and they are no-ops on a plan file.
25
- - Never enter a worktree, never create a branch, and never write a tracked file. A planner that builds has stopped being one, and the row loses the independent read the worker's own session was going to bring to it.
26
- - Resolve `.canon/plans/`, `.canon/tasks/`, and `.canon/review/` at the main worktree root. Those folders are gitignored, so a copy beside a linked worktree is absent rather than empty.
27
- - Plan the row the launch named. Report a second row met while reading rather than planning it, since which rows run is the controller's call.
29
+ - Write records, never source. A tracked file under `src/`, a standard, a rule, a reference doc, and a seed all sit outside what this session may touch, whichever of the three skills it is running.
30
+ - Write only into the folder the running skill names: `plan-feature` at `.canon/plans/`, `plan-groundwork` at `.canon/groundwork/<nn>-<slug>/`, and `plan-intake` at `.canon/intake/<nn>-<slug>/`. Each skill's own `## Write scope` carries its exceptions, which is why they are not listed here. A change to one scope then edits one body.
31
+ - Resolve every one of those folders, and `.canon/tasks/` and `.canon/review/` beside them, at the main worktree root. They are gitignored, so a copy beside a linked worktree is absent rather than empty and reads as a folder nobody has written yet.
32
+ - Never enter a worktree, never create a branch, and never write a tracked file. A thinking session that builds has stopped being one, and the row loses the independent read the worker's own session was going to bring to it.
33
+ - This session holds no worktree, so send each record out as a plain `Bash` heredoc past the same main-root refusal a linked worktree meets. That route skips the write-matched hooks, which are no-ops on a record file.
34
+ - Work the row the launch named. Report a second row met while reading rather than taking it on, since which rows run is the controller's call.
28
35
 
29
36
  ## What to read before deciding
30
37
 
31
- Each item below is something a plan needed and a launch string did not carry.
38
+ Each item below is something a record needed and a launch string did not carry.
32
39
 
33
40
  - The task file, and its `## Findings` before deciding anything. A row can carry its own disproof under a title that still states the original claim, which is how one trial nearly planned against a premise the file had already recorded as dead.
34
41
  - The source files themselves, opened rather than summarized. Never a count quoted from the task file, which was wrong or stale in ten places across four plans.
35
42
  - `CLAUDE.md` and `canon/ARCHITECTURE.md`, for the decision and the alternative it was taken against rather than for the decision alone.
36
- - The plan of any row this one is sequenced behind, including one already moved to `.canon/plans/archive/`. Reading a shipped plan produced the strongest constraint in the first trial and no brief asked for it.
37
43
  - `.canon/tasks/priority.md` for the row's Touches column and its stated blocker. That column is the file set the dispatch disjointness gate already reads.
44
+
45
+ Two reads belong to the `plan-feature` path alone, since neither a groundwork
46
+ track nor an intake pass is sequenced behind another row or validated as a plan:
47
+
48
+ - The plan of any row this one is sequenced behind, including one already moved to `.canon/plans/archive/`. Reading a shipped plan produced the strongest constraint in the first trial and no brief asked for it.
38
49
  - `${CLAUDE_SKILL_DIR}/../../standards/plan.md` for the shape, then `canon records validate plans` and `canon markdown audit <the plan file>` on the file once it is written. Nothing else opens a plan, since `.canon/plans/` is gitignored and the audit's default path set is what git lists.
39
50
 
51
+ A groundwork or intake record passes through whatever its own skill and standard
52
+ name instead. The gitignored-folder problem is the same for all three, so run
53
+ the audit on any record a session writes rather than trusting a gate to reach
54
+ it.
55
+
40
56
  ## Read what is in flight rather than inferring it
41
57
 
42
58
  - Run `canon sessions list --json`, take the `branch` field of every session whose `repository` matches this one and whose branch is neither null nor the trunk branch, union that with every `headRefName` from `gh pr list --json number,headRefName`, dedupe by branch name, then diff each with `git diff --name-only main...<branch>` for its file set. That resolves locally whether or not a pull request exists, since every track in this repository shares one git directory across its worktrees.
43
59
  - A bare branch or worktree with neither a live session nor a pull request behind it is still not evidence, since this repository squash-merges and leaves both behind. The roster read is already filtered to live sessions, so what changed is the second source composed with it rather than a raw count. It still misses a live session that reached its worktree through the direct-path fallback rather than `EnterWorktree`, since that session's registered branch never moves off the trunk.
44
- - Run the read once per task rather than once per batch. A session planning several rows ages its picture of the tree while it works, and this read is what dates it.
45
- - Name each in-flight set as a constraint, say which act it forbids, and stamp the block with the commit the tree was read at, per Constraints in the plan standard. A bare path list leaves the worker guessing.
60
+ - Run the read once per row rather than once per batch. A session working several rows ages its picture of the tree while it goes, and this read is what dates it.
61
+ - Name each in-flight set as a constraint, say which act it forbids, and stamp the block with the commit the tree was read at. A bare path list leaves the reader guessing. The plan standard fixes that block for a feature plan, and a groundwork or intake record carries the same three parts in whatever shape its own standard states.
46
62
 
47
63
  ## The board is read-only
48
64
 
49
65
  - Never write `.canon/tasks/priority.md` or `.canon/tasks/backlog.md`. Both are gitignored, so an overwrite drops a row with no history to recover it from.
50
- - Never write the task file either. The plan is the whole output, and a row edited from here changes what the controller reads back as the state of the board.
51
- - Report what the row got wrong rather than repairing it. A stale count, a moved line, or a path that no longer resolves goes into the plan and into the handback, and the controller decides which of the two carries the correction.
66
+ - Never write the task file. The record is the whole output, and a row edited from here changes what the controller reads back as the state of the board.
67
+ - One exception, and it belongs to `plan-groundwork` alone: that skill writes a single task file at close, recording what the track concluded. Its own `## Write scope` states the exception, and this line exists so the rule above does not forbid what that skill requires.
68
+ - Report what the row got wrong rather than repairing it. A stale count, a moved line, or a path that no longer resolves goes into the record and into the handback, and the controller decides which of the two carries the correction.
52
69
  - Leave the cross-feature call alone. Which rows collide, what merges before what, and whether a row should run at all stay with the controller. `priority.md` shows blockers and file sets and is enough to write a confident merge order off a partial picture, which is the failure this boundary exists to prevent.
53
70
 
54
71
  ## The channel
@@ -56,47 +73,33 @@ Each item below is something a plan needed and a launch string did not carry.
56
73
  The controlling session cannot watch this one read, so two messages are owed and
57
74
  nothing else.
58
75
 
59
- - Announce the plan as the file lands, carrying its path and what the task file got wrong. Write no summary of the plan beside it, which is a second account of a document the reader is about to open.
60
- - Send a block out as a message before it becomes an interactive prompt. A session already waiting on input never reaches the tool round that drains an inbound message, so a relayed answer arrives under the open question and changes nothing.
61
- - Send nothing on progress. A planner reporting progress rebuilds, on this side of the channel, the poll the announcement retires on the other.
62
-
63
- Address the session the launch named. It names a `sessionId` rather than a name,
64
- so read `canon sessions list --json`, find the row carrying that id, and send to
65
- the `name` on it. Resolve that name at the moment of sending rather than at
66
- launch, since a name is derived from what a session turned out to be doing and
67
- goes stale inside the window a plan takes to write.
68
-
69
- Check that name against the agent listing before sending it. A name is not
70
- unique and the roster carries no field separating two live sessions holding one,
71
- so send it bare where the listing shows a single row under it and complete it
72
- with the `[ref]` that listing prints beside each row where it shows more.
73
-
74
- Ask the operator when the launch named nobody and a person is there to answer,
75
- putting the candidate rows through the structured question surface so they pick a
76
- row rather than recall a name. Never filter that roster by name prefix, which
77
- returns a sibling or this session itself.
78
-
79
- Inferring is the last rung and it discriminates less here than it does for a
80
- worker. That read takes the sessions holding no feature branch, which separates a
81
- controller from a worker because a worker holds one, and a planner holds none
82
- either, so every sibling planner comes back beside the controller. Report the
83
- candidates and stop where more than one does, rather than addressing the first.
84
- Send to the single row where exactly one comes back, and say the addressee was
85
- inferred.
86
-
87
- Compose the relay through `canon:session-relay` when this section finds no
88
- message-sending tool to send through, rather than leaving a resolved addressee
89
- with nothing to reach it. It runs the same ladder above and carries the
90
- message already owed, so nothing here is stated twice.
76
+ - Announce the record as the file lands, carrying its path and what the task file got wrong. Write no summary beside it, which is a second account of a document the reader is about to open.
77
+ - Send a block out as a message before it becomes an interactive prompt.
78
+ - Send nothing on progress. A thinking session reporting progress rebuilds, on this side of the channel, the poll the announcement retires on the other.
79
+
80
+ Address the session the launch named, which it names as a `sessionId` rather
81
+ than a name. `canon:session-relay` turns that id into an address and carries the
82
+ send, so invoke it rather than resolving a name here. It reads the roster at the
83
+ moment of sending, checks the result against the agent listing, and falls back
84
+ to a copyable block where this session holds no tool to send through.
85
+
86
+ One rung stays here, because it is the rung that differs between roles.
87
+ Inferring an addressee discriminates less for a thinking session than for a
88
+ worker. The inference takes the sessions holding no feature branch, which
89
+ separates a controller from a worker because a worker holds one, and a session
90
+ in this role holds none either, so every sibling comes back beside the
91
+ controller. Report the candidates and stop where more than one does, rather than
92
+ addressing the first. Send to the single row where exactly one comes back, and
93
+ say the addressee was inferred.
91
94
 
92
95
  ## Refusing is part of the job
93
96
 
94
97
  - Refuse an instruction the tree contradicts, and carry the evidence with it. Name the commands read and what complying would produce, rather than reporting reluctance.
95
98
  - Correct a premise the brief carried when the tree disagrees with it. One trial was told two rows were free of a renamed token and found they carried it as command names rather than path segments, planned all three rows anyway, and put the consequence into a plan question. Correct, continue, and record what the correction changed.
96
- - Answer a plan question rather than halting on it. Every question carries a `- Suggested:` line the operator accepts or overrules, so a judgment written down is the deliverable. Halt only on what blocks writing the plan at all, and send that out as a message before it becomes a prompt.
99
+ - Answer a plan question rather than halting on it. Every question carries a `- Suggested:` line the operator accepts or overrules, so a judgment written down is the deliverable. Halt only on what blocks writing the record at all, and send that out as a message before it becomes a prompt.
97
100
 
98
101
  ## Lifetime
99
102
 
100
- - The role runs from the launch to the plan landing, and one plan closes it. A planner handed a second row plans it under the same role and reads the tree again for that row.
101
- - A reused session pays the context load once and stales differently. Its picture of the tree ages while it works, which is why the in-flight read runs per task, and it accumulates toward a compaction that drops the reasoning behind its earlier plans with nothing reporting it. Cap a reused session rather than letting it run the board.
103
+ - The role runs from the launch to the record landing, and one record closes it. A session handed a second row works it under the same role and reads the tree again for that row.
104
+ - A reused session pays the context load once and stales differently. Its picture of the tree ages while it works, which is why the in-flight read runs per row, and it accumulates toward a compaction that drops the reasoning behind its earlier records with nothing reporting it. Cap a reused session rather than letting it run the board.
102
105
  - Treat this body as possibly older than the branch under it. A plugin skill loads from the marketplace cache rather than from the working tree.
@@ -89,10 +89,18 @@ with no roster row at all. Those nine were driving through Remote Control, which
89
89
  writes no local process record, so a controller working from a phone is exactly
90
90
  the case this read answers nothing for.
91
91
 
92
- Compose the relay through `canon:session-relay` when this section finds no
93
- message-sending tool to send through, rather than leaving a resolved addressee
94
- with nothing to reach it. It runs the same ladder above and carries the
95
- message already owed, so nothing here is stated twice.
92
+ Send through `canon:session-relay`. It holds the mechanical half of the send,
93
+ turning the `sessionId` the launch named into an address at the moment of
94
+ sending, checking the result against the agent listing, and falling back to a
95
+ copyable block where this session holds no tool to send through. The message
96
+ owed stays in its own bullet above.
97
+
98
+ The rungs above restate the resolution the relay also carries. What is a
99
+ worker's own is the last of them, the inference over sessions holding no feature
100
+ branch, which discriminates here because a worker holds one and a controller
101
+ does not. The rest is duplicated deliberately, so this body still reads whole
102
+ for a session that reaches it with the relay unresolved, which is the ordinary
103
+ case in a target that installed governance without the plugin.
96
104
 
97
105
  ## Refusing is part of the job
98
106
 
@@ -1,43 +1,70 @@
1
1
  ---
2
2
  name: session-relay
3
- description: Why a standalone skill with no firing condition never fired, why the first inline draft repeated the same protocol in two bodies, and why the missing sender identity sat unaddressed through both attempts
3
+ description: Why a standalone skill with no firing condition never fired, why the first inline draft repeated the same protocol in two bodies, why the missing sender identity sat unaddressed through both attempts, and why the two guards that scoped it to a session holding a role and no send tool came off
4
4
  ---
5
5
 
6
6
  # Session relay requirement
7
7
 
8
8
  ## Gap
9
9
 
10
- Without this skill, `role-worker` and `role-planner` state three and two messages respectively that a session owes its controller, and both assume a tool to send them through. A session holding none has no route to compose or hand off what it owes, and neither body says what to do about that.
10
+ Without this skill, every session that owes another session a message resolves
11
+ the addressee itself. `role-worker` and `role-planner` each state the messages
12
+ owed and each carried the resolution inline, and a session holding neither role
13
+ carried it nowhere, so the mechanical half of the send existed in two copies
14
+ plus however many a roleless caller improvised.
11
15
 
12
- A standalone skill was proposed for the gap and declined the same day. It fired on no condition of its own, since nothing routes a session to a skill matching no request and reaching for no artifact, so a skill built to close the gap never closed it.
16
+ A standalone skill was proposed for the gap and declined the same day. It fired
17
+ on no condition of its own, since nothing routes a session to a skill matching
18
+ no request and reaching for no artifact, so a skill built to close the gap never
19
+ closed it. That history is why the pointer inside each role's
20
+ `## The channel` section still matters: it is a section both bodies read at
21
+ session start rather than one reached by request match alone, and it is what
22
+ makes the firing survive.
13
23
 
14
- The first draft of this plan closed the gap with a paragraph drafted inline, once inside `role-worker` and once inside `role-planner`. The operator overrode that call: two bodies carrying identical protocol text is the shared-surface case `canon/ARCHITECTURE.md` already decided against duplicating, since a later fix reaching one copy and not the other diverges silently.
24
+ The first draft of this plan closed the gap with a paragraph drafted inline,
25
+ once inside `role-worker` and once inside `role-planner`. The operator overrode
26
+ that call: two bodies carrying identical protocol text is the shared-surface
27
+ case `canon/ARCHITECTURE.md` already decided against duplicating, since a later
28
+ fix reaching one copy and not the other diverges silently.
15
29
 
16
- Neither attempt named who the relay is from. A message an operator relays by hand needs a sender as much as an addressee, and the standalone draft and the inline draft both composed a body and an addressee with no line stating whose turn produced it.
30
+ Neither attempt named who the relay is from. A message an operator relays by
31
+ hand needs a sender as much as an addressee, and the standalone draft and the
32
+ inline draft both composed a body and an addressee with no line stating whose
33
+ turn produced it.
34
+
35
+ Scoping the skill to a session holding a role and no send tool left two holes
36
+ rather than one. A session holding neither role had no `## The channel` section
37
+ to carry the pointer and was refused outright on arrival. A session that held a
38
+ role and a send tool was refused too, which left the resolution ladder
39
+ duplicated in both role bodies for the ordinary case. Both guards came off
40
+ together, since the mechanics are the same whichever route carries the text.
17
41
 
18
42
  ## Must
19
43
 
20
- - Fire from inside `role-worker` or `role-planner`'s own `## The channel` section, at the point that section finds no message-sending tool
21
- - Refuse a caller holding neither role, checked ahead of the tool-availability guard
44
+ - Fire for any session sending to another session, with or without a role and with or without a message-sending tool
22
45
  - Read the sender's own name off `canon sessions list --self --json` before composing anything, and degrade to naming itself from the environment when the installed CLI answers `--self` with an unknown option rather than a refusal carrying a reason
23
- - Resolve the addressee by running the calling role's own ladder rather than a second one
24
- - Carry the message body the calling role's bullet already names, verbatim
46
+ - Resolve the addressee from a `sessionId` or a branch at the moment of sending, never by a name prefix
47
+ - Check the resolved name against the agent listing, sending it bare under a single row and completing it with the `[ref]` under more than one
48
+ - Send through a message-sending tool where one exists, and compose the copyable block where none does or where the listing carries no row to address
49
+ - State block-before-prompt, since the shipped channel rule carrying it for a session holding neither role reaches only a target that installed governance
50
+ - Carry the message body verbatim from whatever names it
25
51
  - Open the composed text with a header naming the sender, the addressee, and what the message is
26
52
  - State the header as a default a session may write around, rather than a fixed vocabulary
27
53
 
28
54
  ## Must not
29
55
 
30
- - Restate the addressee ladder `role-worker` or `role-planner` already states
31
- - Draft a message body of its own. The text it composes belongs to the calling role's bullet.
32
- - Fire when a message-sending tool exists
56
+ - Draft a message body of its own. The text it composes belongs to the calling role's bullet or to the dispatch.
57
+ - Decide who the message goes to. The caller names an id or a branch and this body turns it into an address.
58
+ - Restate the last-rung inference `role-worker` and `role-planner` each carry, which differs between them
33
59
 
34
60
  ## Guards
35
61
 
36
- - The calling session holds neither role: refuse, and state the addressing and timing it owes instead, since the plugin skill it would otherwise defer to reaches a target the moment it merges while a governance rule reaches one only through a separate install. Checked first, since the tool guard read alone let the incident behind this row stop at "you have a tool" without ever learning steps 2 and 3 were unreachable regardless.
37
- - A message-sending tool is available: refuse, name it, and send through it instead
62
+ - The roster cannot name the sender: report the refusal and its reason in place of a name
63
+ - The resolution returns no row: report it rather than guessing, since the roster and the send channel enumerate different populations in both directions
64
+ - The caller named no addressee at all: defer to the calling role's own last rung, and ask the operator where one is present
38
65
 
39
66
  ## Out of scope
40
67
 
41
- - The addressee resolution ladder itself, which stays in `role-worker` and `role-planner`
42
- - The content of each owed message, which stays in the calling role's own bullet
43
- - Inbound reach: a controller relaying back to a worker or planner holding no tool. The gap this closes is the outbound leg alone, since a controller can already reach either side directly.
68
+ - Which messages are owed and when, which stays in the calling role's own bullet or in the dispatch
69
+ - The last-rung inference, which stays in `role-worker` and `role-planner` because a worker holds a feature branch and a planner does not, so the two discriminate differently
70
+ - Inbound reach as a distinct case. A relay in either direction is one session sending to another, which is what this body now covers.
@@ -1,27 +1,92 @@
1
1
  ---
2
2
  name: session-relay
3
- description: Composes the relay a worker or planner owes its controller when the session holds no message-sending tool, carrying the sender's own name, the addressee the calling role's own ladder resolves, and the message body that role already names, opened with a header naming all three so an operator can copy the block whole. Fires only from inside `role-worker` or `role-planner`'s own `## The channel` section, at the point that section finds no tool to send through. Do NOT invoke it when a message-sending tool exists, which is the ordinary channel, and do NOT invoke it to resolve an addressee for anything other than a channel obligation those two roles already state.
3
+ description: Relays a message to another session, turning the sessionId the caller named into an address at the moment of sending and composing a copyable block where no send tool exists. Use when asked to "relay this", "message the controller", "send this over to the session that dispatched me", or "tell the other session", with or without a role. Do NOT use to decide what the message says, and do NOT use to assert what a session may write or is on the hook for, which is `role-worker` or `role-planner`.
4
4
  ---
5
5
 
6
6
  # Session relay
7
7
 
8
- `role-worker` and `role-planner` each owe their controller a set of messages, and each assumes a message-sending tool exists to carry them. A session holding none has nothing to send through, and the paragraph stating what to do about that used to sit inline in both bodies, repeating the same protocol twice. This skill is that paragraph, and both role bodies point to it instead of each carrying their own copy.
8
+ This is the one place a session sends to another session. It holds the
9
+ mechanical half of the send: naming the sender, turning a `sessionId` or a
10
+ branch into an address that reaches, and putting the text through whichever
11
+ route exists. It holds none of the other half. What the message says and who it
12
+ goes to come from whatever dispatched this session, and from the calling role's
13
+ own `## The channel` section where the session holds a role.
9
14
 
10
- Read the calling body's `## The channel` section for the message owed and the ladder that resolves its addressee. This skill does not restate either.
15
+ It fires for a session holding a role and for one holding none. A roleless
16
+ session reaches this body directly and finds everything it owes stated here,
17
+ rather than reading a ladder written for a role it does not have.
11
18
 
12
- ## Guards
19
+ ## Step 1: name the sender
13
20
 
14
- - Refuse to fire when the calling session holds neither `role-worker` nor `role-planner`. Say so, and state what it owes instead: resolve the addressee at send time through `canon sessions list --json`, never by name prefix, and send a block out as a message before it becomes an interactive prompt, leaving the message content to whatever dispatched it. Checked first, since a caller that never held either role never had a ladder here to run, and the tool guard below reads as the whole reason only once this one has cleared.
15
- - Refuse to fire when a message-sending tool is available. Say so, name the tool, and send through it. This skill exists for the gap, never as an alternative to the ordinary channel.
21
+ Read this session's own name from `canon sessions list --self --json`. A
22
+ message an operator relays by hand needs a sender as much as an addressee, and
23
+ a message arriving through a tool is read against whoever sent it.
16
24
 
17
- ## Steps
25
+ A refusal carrying `no-self-identity` or `no-self-row` means the roster holds
26
+ nothing to name the sender with. Report the refusal and its reason in place of a
27
+ name rather than guessing one.
18
28
 
19
- 1. Read this session's own name from `canon sessions list --self --json`. A refusal here, `no-self-identity` or `no-self-row`, means the roster carries nothing to name the sender with. Report the refusal and its reason in place of a name rather than guessing one.
20
- 2. Resolve the addressee. Run the calling role's own `## The channel` ladder to the letter rather than a second one here, and take its outcome, whatever it is, as this skill's addressee.
21
- 3. Carry the message body the calling role's bullet already names, verbatim. This skill composes and never drafts, so the pull request announcement, the address-review announcement, or the blocked question is exactly the text that bullet states, with its own facts filled in.
22
- 4. Open the composed text with a header naming the sender, the addressee, and what the message is. Close the turn with nothing after it, so the block above is the whole of what an operator copies.
29
+ Where the installed CLI answers `--self` with an unknown option, that flag is
30
+ newer than the release this session holds, so there is no refusal and no reason
31
+ to report. Name the sender from what the environment states instead, and say the
32
+ roster did not answer, so the reader knows the name was not confirmed against
33
+ it.
23
34
 
24
- Where the installed CLI answers `--self` with an unknown option, that flag is newer than the release this session holds, so step 1 meets no refusal with a reason to report. Name the sender from what the environment states instead, and say the roster did not answer, so the reader knows the name was not confirmed against it.
35
+ ## Step 2: resolve the addressee
36
+
37
+ The caller names the addressee as a `sessionId` or as a branch, never as a name.
38
+ A name is derived from what a session turns out to be doing and goes stale
39
+ inside the window a build or a plan takes, so resolve it at the moment of
40
+ sending rather than at launch.
41
+
42
+ - From a `sessionId`: run `canon sessions list --json`, find the row carrying that id, and take the `name` on it.
43
+ - From a branch: run the same read and take the row whose `branch` matches. Nothing renames a session when it takes a role, which is why an id is the stronger key and a branch is the fallback rather than the default.
44
+
45
+ Then check that name against the agent listing before sending it. A name is not
46
+ unique, and the roster carries no field separating two live sessions holding
47
+ one, so the resolution can end on a string that reaches the wrong session.
48
+
49
+ - One row under the name: send it bare.
50
+ - More than one row: complete the address with the `[ref]` that listing prints beside each row, rather than sending to the name alone, which lands on whichever row the channel resolves first. Two sessions differing only by a trailing ` (3)` is the shape this meets in practice.
51
+
52
+ Never filter that roster by a name prefix. Every self-dispatched worker is named
53
+ `worker-<project>-<slug>`, so a prefix scan returns a sibling or this session
54
+ itself, which is the defect that sent messages owed to a controller somewhere
55
+ else.
56
+
57
+ Report a resolution that returns nothing rather than falling back to a guess.
58
+ The roster and the send channel enumerate different populations in both
59
+ directions, measured at one moment: a live background session sat on the roster
60
+ that the agent listing did not carry, and nine sessions were addressable there
61
+ with no roster row at all. Those nine were driving through Remote Control, which
62
+ writes no local process record.
63
+
64
+ Where the caller named nobody at all, the calling role's own `## The channel`
65
+ section carries the last rung, since a worker and a planner infer differently
66
+ and a roleless caller has no ladder to defer to. Ask the operator where one is
67
+ present, put the candidate rows through the structured question surface so they
68
+ pick a row rather than recall a name, and say the addressee was inferred
69
+ wherever an inference decided it.
70
+
71
+ ## Step 3: send
72
+
73
+ - A message-sending tool exists: send through it, addressed to what Step 2 resolved. This is the ordinary route.
74
+ - No message-sending tool, or Step 2 found no row to address: compose the block below and close the turn with nothing after it, so the block is the whole of what an operator copies.
75
+
76
+ Send the block out as a message before it becomes an interactive prompt. A
77
+ session already waiting on input never reaches the tool round that drains an
78
+ inbound message, so an answer relayed afterwards arrives under the open question
79
+ and changes nothing. This holds on both routes.
80
+
81
+ The shipped channel rule states the same thing for a session holding neither
82
+ role, so the restatement here is for the target that installed the plugin and
83
+ not governance, where that rule never arrives. A session that does load it reads
84
+ the rule and this body in agreement rather than one of them alone.
85
+
86
+ Carry the message body verbatim from whatever names it. This skill composes and
87
+ never drafts, so a pull request announcement, an address-review announcement, or
88
+ a blocked question is exactly the text the calling bullet or the dispatch
89
+ states, with its own facts filled in.
25
90
 
26
91
  ## Header
27
92
 
@@ -30,8 +95,12 @@ Relay: from <sender> to <addressee>, <what the message is>
30
95
  <message body>
31
96
  ```
32
97
 
33
- States a default rather than a fixed vocabulary. A session may write the header in its own words, since it exists to be read once and copied rather than parsed by anything downstream. Name the same three parts however phrased.
98
+ States a default rather than a fixed vocabulary. A session may write the header
99
+ in its own words, since it exists to be read once and copied rather than parsed
100
+ by anything downstream. Name the same three parts however phrased.
34
101
 
35
102
  ## Output
36
103
 
37
- Nothing beyond the composed block. Report the Guards refusal instead when the roster cannot answer for the sender, and report the calling body's own halt or inference when its ladder cannot resolve the addressee.
104
+ The sent message, or the composed block and nothing beyond it. Report Step 1's
105
+ refusal in place of a sender the roster cannot answer for, and report the halt
106
+ or the inference in place of an addressee Step 2 cannot resolve.
@@ -54,6 +54,8 @@ An outcome whose body is struck reads as cut rather than open or closed, whateve
54
54
 
55
55
  The task carries its plan with it. When the closing task is the last live one whose `Plan:` line resolves onto that file, the plan moves to `.canon/plans/archive/` under its own name and the archived task's line is rewritten as `Plan: [feature-<slug>](../../plans/archive/feature-<slug>.md)`, a folder deeper than the live task wrote it. The `plan` field on the success record carries that `from` and `to`, and is `null` when nothing moved.
56
56
 
57
+ Every other relative link in the task is re-resolved for the deeper folder too, whether its target moved or not: `Groundwork:`, `Intake:`, a `Plan:` or `Ready:` line whose target stayed put, and a link in the body. Fenced blocks, inline code spans, absolute URLs, `#` anchors, and rooted paths are left alone. `canon tasks decline` applies the same rebase, since `declined/` sits at the same depth. The rebase runs before the plan and ready retargets, so a moved plan keeps its own line.
58
+
57
59
  The ready folder the task's `Ready:` line names moves with the plan to `.canon/ready/archive/`, falling back to a `.canon/ready/<nn>-<slug>/` path in the plan when the line is absent. The `Ready:` line and the plan's literal path to the folder are retargeted, and the `ready` field on the success record carries `from` and `to`, or `null` when nothing moved. A folder that resolves to nothing, sits already archived, or meets a taken destination moves nothing and refuses nothing.
58
60
 
59
61
  A plan several tasks share stays where it is, and its ready folder with it, and the task archives anyway. Moving it on the first task to close strands every sibling's pointer at a path that has gone, and `.canon/plans/` is gitignored so no history recovers the target. A `Plan:` line resolving to no file leaves the plan alone too, since a pointer somebody typed wrong is not a plan to move and holding the whole archive over it would park the board behind a repair the merge cannot make.
@@ -272,15 +272,15 @@ This section is the corpus the coverage claim is measured against: every name `c
272
272
 
273
273
  ### Run several tracks at once
274
274
 
275
- | Skill | When to use |
276
- | ------------------------- | ------------------------------------------------------------------------------- |
277
- | `canon:role-orchestrator` | To assert the control session that owns the queue and reviews each worker's PR |
278
- | `canon:role-planner` | To assert the planner role for a cold session writing one plan under one task |
279
- | `canon:role-worker` | To assert the worker role for a cold session building one branch under one plan |
280
- | `canon:session-relay` | When a worker or planner owes its controller a message and holds no send tool |
281
- | `canon:session-resume` | At the start of a session, to pick up what a previous one left |
282
- | `canon:session-compact` | Before a compaction, to write a plain session's handoff note outside the board |
283
- | `canon:session-map` | At the close of an orchestrating session, to write its board-side handoff |
275
+ | Skill | When to use |
276
+ | ------------------------- | ---------------------------------------------------------------------------------------------------- |
277
+ | `canon:role-orchestrator` | To assert the control session that owns the queue and reviews each worker's PR |
278
+ | `canon:role-planner` | To assert the thinking role for a cold session writing a plan, a groundwork track, or an intake pass |
279
+ | `canon:role-worker` | To assert the worker role for a cold session building one branch under one plan |
280
+ | `canon:session-relay` | When this session owes another session a message, with or without a role |
281
+ | `canon:session-resume` | At the start of a session, to pick up what a previous one left |
282
+ | `canon:session-compact` | Before a compaction, to write a plain session's handoff note outside the board |
283
+ | `canon:session-map` | At the close of an orchestrating session, to write its board-side handoff |
284
284
 
285
285
  ### Keep the project current with the toolkit
286
286
 
@@ -42,7 +42,7 @@ A cell no source anchors ends in `? verify`, and the preview shows that marker b
42
42
  - `canon:ux-measure` for what a running surface costs to paint, read against published thresholds
43
43
  - `canon:draft-and-pick` for a call settled by looking, drafting several candidates onto one page and taking your pick
44
44
  - `canon:ux-walkthrough` for a multi-finding inspection pass over a running app with the operator, drafting arms from the app's own markup and recording each pick for a builder
45
- - `canon:draft-identity` to draft a project's logo mark and compose it into an icon sequence and a social card, through `draft-and-pick`'s own render-and-pick loop
45
+ - `canon:draft-identity` to draft a project's logo mark and compose it into an icon sequence and a social card, through `draft-and-pick`'s own render-and-pick loop. On an Astro project holding the card route scaffold, the card lands as a route the project keeps and is captured through a running server, so it reads the project's real stylesheet rather than typed copies of every color. Everywhere else it ships as a PNG and the run says so.
46
46
  - Anthropic's `frontend-design` plugin optional for light visual steering
47
47
 
48
48
  ### When to pick
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@erclx/canon",
3
3
  "type": "module",
4
- "version": "4.105.0",
4
+ "version": "4.106.0",
5
5
  "description": "Infrastructure and quality tooling for developer workflows",
6
6
  "license": "MIT",
7
7
  "bin": {
@@ -155,7 +155,7 @@ export const WORKFLOW_CASES: readonly SkillCase[] = [
155
155
  },
156
156
  {
157
157
  prompt:
158
- 'I owe my controller the pull request announcement but I have no message-sending tool. Compose the relay so an operator can copy it over.',
158
+ 'Relay this over to the session that dispatched me. It gave me a sessionId rather than a name, so work out who that is and send it.',
159
159
  expect: 'session-relay',
160
160
  },
161
161
 
@@ -818,9 +818,17 @@ export const readmeCitations: Measure = async (ctx) => {
818
818
  }
819
819
  }
820
820
 
821
+ /**
822
+ * Globs `pr-visual-checks.yml` carries with no `deploy-site.yml` counterpart,
823
+ * because the path changes no byte of the built site. The capture harness is
824
+ * the one case: a change there should report the capture job and never deploy.
825
+ */
826
+ const VISUAL_ONLY_GLOBS: readonly string[] = ['tooling/web/configs/e2e/**']
827
+
821
828
  /**
822
829
  * `deploy-site.yml` and `pr-visual-checks.yml` each carry their own literal
823
- * copy of the eight path globs a landing-page change should trigger on, per
830
+ * copy of the eight path globs a landing-page change should trigger on, plus
831
+ * the `VISUAL_ONLY_GLOBS` the visual workflow adds for itself, per
824
832
  * Question 2 in `.canon/plans/archive/feature-render-visibility.md`. A YAML
825
833
  * anchor cannot cross files here, so the two copies are read live and compared
826
834
  * rather than one asserting a literal the other could drift behind unnoticed,
@@ -858,14 +866,25 @@ export const visualPathGlobs: Measure = async (ctx) => {
858
866
  }
859
867
  }
860
868
 
861
- const onlyDeploy = deployPaths.filter((path) => !visualPaths.includes(path))
862
- const onlyVisual = visualPaths.filter((path) => !deployPaths.includes(path))
869
+ const onlyDeploy = deployPaths.filter(
870
+ (path) => !visualPaths.includes(path) || VISUAL_ONLY_GLOBS.includes(path),
871
+ )
872
+ const onlyVisual = visualPaths.filter(
873
+ (path) => !deployPaths.includes(path) && !VISUAL_ONLY_GLOBS.includes(path),
874
+ )
875
+ const declaredVisualOnly = visualPaths.filter((path) =>
876
+ VISUAL_ONLY_GLOBS.includes(path),
877
+ )
863
878
 
864
879
  if (onlyDeploy.length === 0 && onlyVisual.length === 0) {
880
+ const visualOnlyNote =
881
+ declaredVisualOnly.length > 0
882
+ ? `, plus visual-only ${declaredVisualOnly.join(', ')}`
883
+ : ''
865
884
  return {
866
885
  emissions: [
867
886
  info(
868
- `${deployPaths.length} path glob(s) agree between ${deployPath} and ${visualPath}`,
887
+ `${deployPaths.length} path glob(s) agree between ${deployPath} and ${visualPath}${visualOnlyNote}`,
869
888
  ),
870
889
  ],
871
890
  }
@@ -874,7 +893,11 @@ export const visualPathGlobs: Measure = async (ctx) => {
874
893
  return {
875
894
  emissions: [
876
895
  ...onlyDeploy.map((path) =>
877
- warn(`${deployPath} carries ${path}, absent from ${visualPath}`),
896
+ warn(
897
+ VISUAL_ONLY_GLOBS.includes(path)
898
+ ? `${deployPath} carries ${path}, which is visual-only and must not trigger a deploy`
899
+ : `${deployPath} carries ${path}, absent from ${visualPath}`,
900
+ ),
878
901
  ),
879
902
  ...onlyVisual.map((path) =>
880
903
  warn(`${visualPath} carries ${path}, absent from ${deployPath}`),
@@ -792,15 +792,16 @@ export async function archiveTask(
792
792
 
793
793
  await mkdir(destination, { recursive: true })
794
794
  await rename(from, to)
795
- if (plan) {
796
- const retargeted = retargetPlanLine(text, linkTo(destination, plan.to))
797
- await writeFile(
798
- to,
799
- ready
800
- ? retargetReadyLine(retargeted, `${linkTo(destination, ready.to)}/`)
801
- : retargeted,
802
- )
803
- }
795
+ const rebased = rebaseRelativeLinks(text, dir, destination)
796
+ const retargeted = plan
797
+ ? retargetPlanLine(rebased, linkTo(destination, plan.to))
798
+ : rebased
799
+ await writeFile(
800
+ to,
801
+ ready
802
+ ? retargetReadyLine(retargeted, `${linkTo(destination, ready.to)}/`)
803
+ : retargeted,
804
+ )
804
805
 
805
806
  const priorityRowRemoved = await clearPriorityRow(dir, stem)
806
807
  const regen = await regenOne(dir, { dryRun: false })
@@ -918,6 +919,69 @@ export function linkTo(taskDir: string, plan: string): string {
918
919
  return relative(taskDir, plan).split(sep).join('/')
919
920
  }
920
921
 
922
+ const MARKDOWN_LINK_PATTERN = /(\]\()([^)\s]+)/g
923
+ /** The capture group makes `split` keep each code span, at the odd positions. */
924
+ const INLINE_CODE_PATTERN = /(`+[^`]*`+)/
925
+ const BARE_ORIGIN_PATTERN =
926
+ /^((?:Plan|Ready|Groundwork|Intake):[ \t]*)([^\s[\]()]+)[ \t]*$/
927
+
928
+ function isRelativeTarget(target: string): boolean {
929
+ return !/^(?:[a-z][a-z0-9+.-]*:|\/|#)/i.test(target)
930
+ }
931
+
932
+ function rebaseTarget(target: string, fromDir: string, toDir: string): string {
933
+ if (!isRelativeTarget(target)) return target
934
+
935
+ const suffixAt = target.search(/[#?]/)
936
+ const path = suffixAt === -1 ? target : target.slice(0, suffixAt)
937
+ const suffix = suffixAt === -1 ? '' : target.slice(suffixAt)
938
+ if (path === '') return target
939
+
940
+ const rebased = relative(toDir, resolve(fromDir, path)).split(sep).join('/')
941
+ const slash = path.endsWith('/') ? '/' : ''
942
+
943
+ return `${rebased}${slash}${suffix}`
944
+ }
945
+
946
+ /**
947
+ * Re-resolves every relative link in a task written for `fromDir` so it reads
948
+ * the same target from `toDir`. Every link rather than the four origin lines,
949
+ * since the body breaks one folder short the same way. Fenced samples stay
950
+ * verbatim, and a URL, an anchor, or a rooted path names nothing depth changes.
951
+ */
952
+ export function rebaseRelativeLinks(
953
+ text: string,
954
+ fromDir: string,
955
+ toDir: string,
956
+ ): string {
957
+ const lines = text.split('\n')
958
+ const fenced = fenceMask(lines)
959
+
960
+ return lines
961
+ .map((line, index) => {
962
+ if (fenced[index]) return line
963
+
964
+ const bare = BARE_ORIGIN_PATTERN.exec(line)
965
+ if (bare?.[2].includes('/')) {
966
+ return `${bare[1]}${rebaseTarget(bare[2], fromDir, toDir)}`
967
+ }
968
+
969
+ return line
970
+ .split(INLINE_CODE_PATTERN)
971
+ .map((part, position) =>
972
+ position % 2 === 1
973
+ ? part
974
+ : part.replace(
975
+ MARKDOWN_LINK_PATTERN,
976
+ (_whole: string, open: string, target: string) =>
977
+ `${open}${rebaseTarget(target, fromDir, toDir)}`,
978
+ ),
979
+ )
980
+ .join('')
981
+ })
982
+ .join('\n')
983
+ }
984
+
921
985
  async function clearPriorityRow(dir: string, stem: string): Promise<boolean> {
922
986
  const path = join(dir, 'priority.md')
923
987
  if (!existsSync(path)) return false
@@ -984,7 +1048,10 @@ export async function declineTask(
984
1048
  await rename(from, to)
985
1049
 
986
1050
  const date = new Date().toISOString().slice(0, 10)
987
- const declined = insertDeclinedLine(text, declineLine(reason, by, date))
1051
+ const declined = insertDeclinedLine(
1052
+ rebaseRelativeLinks(text, dir, destination),
1053
+ declineLine(reason, by, date),
1054
+ )
988
1055
  const final = plan
989
1056
  ? retargetPlanLine(declined, linkTo(destination, plan.to))
990
1057
  : declined
@@ -0,0 +1,98 @@
1
+ ---
2
+ /**
3
+ * The social card as a route the project keeps, rather than a scratch page a
4
+ * render deletes. `canon capture` drives it through the server this config
5
+ * serves, so the composition reads the project's own stylesheet, tokens and
6
+ * fonts instead of carrying typed copies of each. Renaming the project is then
7
+ * an edit to one string here and a re-capture, not another pass through the
8
+ * pick loop with an operator in it.
9
+ *
10
+ * `canon:draft-identity` writes the picked composition into the `.card`
11
+ * element below and leaves everything around it alone. What ships is a working
12
+ * placeholder, so the route serves before any pick has been taken.
13
+ *
14
+ * The card is declared at half of 1200x630. `canon capture` opens every page at
15
+ * a fixed 2x device scale factor and screenshots the element at that scale, so
16
+ * a 600x315 element is what lands the captured PNG on the literal target size.
17
+ * Declaring it at 1200x630 captures at 2400x1260.
18
+ *
19
+ * `card.config.mjs` is the only config whose page router reaches this file, and
20
+ * `scripts/check-card-exclusion.sh` fails a build that carries the marker
21
+ * below, which is what keeps the route out of the published site.
22
+ */
23
+ import '@/styles/global.css'
24
+
25
+ // Retarget the import above when this project keeps its global stylesheet
26
+ // somewhere else. An unresolved path refuses the card server by name, which is
27
+ // the loud direction: a card captured against no stylesheet renders as an
28
+ // unstyled box and reports success.
29
+ ---
30
+
31
+ <html lang="en">
32
+ <head>
33
+ <meta charset="utf-8" />
34
+ <title>Social card</title>
35
+ <!-- Read by scripts/check-card-exclusion.sh. A build carrying this tag
36
+ published the card route. Do not reword it. -->
37
+ <meta name="og-card-marker" content="og-card-marker" />
38
+ <style>
39
+ body {
40
+ margin: 0;
41
+ }
42
+ .card {
43
+ box-sizing: border-box;
44
+ width: 600px;
45
+ height: 315px;
46
+ display: flex;
47
+ flex-direction: column;
48
+ justify-content: center;
49
+ gap: 0.5rem;
50
+ padding: 3rem;
51
+ /* These two names are what `canon design css` emits, so a project
52
+ carrying the design files resolves both and neither fallback fires.
53
+ A name the design system does not emit would take its fallback,
54
+ capture cleanly and report success, which is the silent failure this
55
+ route exists to close. Check the names against the stylesheet when
56
+ retargeting the import above.
57
+
58
+ Written as rgb() rather than hex so the shipped-reference gate does
59
+ not read a six-digit hex fallback as a pull request number. */
60
+ background: var(--color-background, rgb(255 255 255));
61
+ color: var(--color-text, rgb(17 17 17));
62
+ /* No font-family here on purpose. The design system emits no font
63
+ token, so the card inherits the project's own body font rather than
64
+ reading a custom property that does not exist. `canon capture` reads
65
+ the computed family off this element, so inheritance still gives it
66
+ a family to check and it refuses when that family cannot resolve. */
67
+ }
68
+ .mark {
69
+ width: 64px;
70
+ height: 64px;
71
+ }
72
+ .title {
73
+ margin: 0;
74
+ font-size: 2.25rem;
75
+ line-height: 1.1;
76
+ }
77
+ .tagline {
78
+ margin: 0;
79
+ font-size: 1.125rem;
80
+ opacity: 0.75;
81
+ }
82
+ </style>
83
+ </head>
84
+ <body>
85
+ <div class="card">
86
+ <svg
87
+ class="mark"
88
+ viewBox="0 0 64 64"
89
+ role="img"
90
+ aria-label="Project mark"
91
+ >
92
+ <rect width="64" height="64" rx="12" fill="currentColor"></rect>
93
+ </svg>
94
+ <h1 class="title">REPLACE_WITH_PROJECT_NAME</h1>
95
+ <p class="tagline">REPLACE_WITH_PROJECT_TAGLINE</p>
96
+ </div>
97
+ </body>
98
+ </html>
@@ -0,0 +1,39 @@
1
+ import react from '@astrojs/react'
2
+ import tailwindcss from '@tailwindcss/vite'
3
+ import { defineConfig } from 'astro/config'
4
+ import path from 'path'
5
+
6
+ const portOffset = Number(process.env.WORKTREE_PORT_OFFSET) || 0
7
+
8
+ // Never read by `bun run build`. srcDir keeps this config's page router off
9
+ // src/pages entirely, which is what excludes the social card from the published
10
+ // build structurally rather than by a filename convention. A pageExtensions
11
+ // regex over a filename was the alternative, and it leaks the moment a route is
12
+ // renamed.
13
+ //
14
+ // The base port sits outside the band `astro.config.mjs` serves from, so a card
15
+ // server and a dev server of one worktree never contend, and two worktrees
16
+ // separate here the way they separate there.
17
+ export default defineConfig({
18
+ srcDir: './card-src',
19
+ outDir: './card-dist',
20
+ publicDir: './public',
21
+ integrations: [react()],
22
+ server: {
23
+ port: 4421 + portOffset,
24
+ },
25
+ vite: {
26
+ plugins: [tailwindcss()],
27
+ resolve: {
28
+ alias: {
29
+ '@': path.resolve('./src'),
30
+ },
31
+ },
32
+ server: {
33
+ strictPort: true,
34
+ },
35
+ preview: {
36
+ strictPort: true,
37
+ },
38
+ },
39
+ })
@@ -0,0 +1,58 @@
1
+ #!/usr/bin/env bash
2
+ set -e
3
+ set -o pipefail
4
+
5
+ # `card.config.mjs` keeps the social card route out of the published build by
6
+ # never pointing its srcDir at it. A later config change that widens srcDir, or
7
+ # a card file moving under src/pages/, would publish a half-size scaffolding
8
+ # page to a live domain with every other check still green.
9
+ #
10
+ # The card route has three consumers: the card server reads it, the capture
11
+ # reads it, and the published build must not carry it. This is the only one of
12
+ # the three a check can enforce, so the route's exclusion is a check rather than
13
+ # a convention.
14
+
15
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
16
+ PROJECT_ROOT="${PROJECT_ROOT:-$(cd "$SCRIPT_DIR/.." && pwd)}"
17
+
18
+ DIST="$PROJECT_ROOT/dist"
19
+ CARD_CONFIG="$PROJECT_ROOT/card.config.mjs"
20
+ MARKER='name="og-card-marker"'
21
+
22
+ # No card route installed, so there is nothing to exclude. Reported rather than
23
+ # passed silently, since a check that says nothing is indistinguishable from one
24
+ # that looked and found nothing.
25
+ if [ ! -f "$CARD_CONFIG" ]; then
26
+ echo "check-card-exclusion: no card.config.mjs, so no card route to exclude"
27
+ exit 0
28
+ fi
29
+
30
+ # Nothing to grep without a build. Callers run this after `bun run build`, so an
31
+ # absent dist here means the build itself failed rather than something this
32
+ # check can verify.
33
+ if [ ! -d "$DIST" ]; then
34
+ echo "No $DIST. Run bun run build first." >&2
35
+ exit 1
36
+ fi
37
+
38
+ # The marker is a fixed <meta> tag the card route carries for exactly this, so
39
+ # asserting it survives in the route's own source is what turns a dropped marker
40
+ # into a loud failure here rather than a blind spot in the test below. A prose
41
+ # notice is not load-bearing: rewording it silently retires the leak check it
42
+ # backed with every workflow still green.
43
+ if ! grep -rl "$MARKER" "$PROJECT_ROOT/card-src" >/dev/null 2>&1; then
44
+ echo "No $MARKER under $PROJECT_ROOT/card-src. The card route dropped its own marker, which is what the leak test below reads for." >&2
45
+ exit 1
46
+ fi
47
+
48
+ if grep -rl "$MARKER" "$DIST" >/dev/null 2>&1; then
49
+ echo "The card route's marker ($MARKER) reached $DIST. See card.config.mjs's srcDir." >&2
50
+ exit 1
51
+ fi
52
+
53
+ if find "$DIST" -iname '*og-card*' | grep -q .; then
54
+ echo "A file named for the card route reached $DIST. See card.config.mjs's srcDir." >&2
55
+ exit 1
56
+ fi
57
+
58
+ echo "check-card-exclusion: card marker present in card-src, absent from $DIST"
@@ -21,6 +21,8 @@ packages = [
21
21
  "astro" = "astro"
22
22
  "typecheck" = "astro check"
23
23
  "setup" = "./scripts/setup.sh"
24
+ "card:dev" = "WORKTREE_PORT_OFFSET=$(bash scripts/worktree-port.sh) && export WORKTREE_PORT_OFFSET && astro dev --config card.config.mjs"
25
+ "card:check" = "bash scripts/check-card-exclusion.sh"
24
26
 
25
27
  [scripts.override]
26
28
  "screenshot" = "PREVIEW_PORT=$(bash scripts/worktree-port.sh 4321) && export PREVIEW_PORT && bash scripts/screenshot.sh"
@@ -30,6 +32,7 @@ packages = [
30
32
 
31
33
  [gitignore]
32
34
  "# Astro" = [".astro/"]
35
+ "# Social card" = ["card-dist/"]
33
36
 
34
37
  [verify]
35
38
  prepare = "bunx astro add react --yes"
@@ -21,6 +21,7 @@ The astro stack covers Astro + TypeScript projects: content sites, marketing sit
21
21
  - `vitest.config.ts`: uses `getViteConfig` from `astro/config` (not `mergeConfig`). jsdom, globals, setup file, `passWithNoTests: true`, v8 coverage, `**/*.astro` in coverage excludes.
22
22
  - `playwright.config.ts`: all browsers, `webServer` runs `bun run build && bun run preview` on port `4321` plus `WORKTREE_PORT_OFFSET`, `reuseExistingServer: false`. Astro's dev/prod gap is wide (MDX, island hydration, asset optimization), so E2E always tests the built `dist/`. `DIST_PREBUILT` set in the environment drops the `build` half, running `bun run preview` alone against a `dist/` a prior CI job already produced.
23
23
  - `tsconfig.json`: extends `astro/tsconfigs/strict`, adds `skipLibCheck`, `vitest/globals` and `@testing-library/jest-dom` in types, `@/` paths.
24
+ - `card.config.mjs`, `card-src/pages/og-card.astro`, and `scripts/check-card-exclusion.sh`: the social card route, its structural build exclusion, and the check enforcing it. See `## Social card route` below.
24
25
  - `eslint.config.js`: overrides the web layer. Adds `eslint-plugin-astro` (`.astro` parser via `astro-eslint-parser`). React-hooks scoped to `.jsx`/`.tsx` only (`.astro` is not React). The shared block's `files` selector includes `.astro`, so `check-file/filename-naming-convention` reaches `.ts`, `.tsx`, and `.astro` under `KEBAB_CASE`, overriding Astro's own PascalCase component convention deliberately, on the ground that a component's name in markup comes from the import binding rather than the filename. `.js` and `.jsx` stay out of the rule's own pattern, matching `web` and `nextjs`. `check-file/folder-naming-convention` reaches every `src/**` folder except `__tests__` and the whole `pages/` subtree, which carries its own off-block for the bracket-named dynamic routes and nested slug folders Astro's file-based routing produces. See `canon/context/tooling.md` for the measurement.
25
26
 
26
27
  ## Typecheck
@@ -59,11 +60,37 @@ Append to the `## Scripts` table:
59
60
 
60
61
  In `canon/context/ci.md`, the Typecheck row's assertion reads: `` `astro check` passes ``. The Build row's assertion reads: `` `astro build` succeeds ``.
61
62
 
63
+ ## Social card route
64
+
65
+ The social card is a route the project keeps rather than a page a render writes and deletes, so re-rendering it after a rename is an edit to one string and a re-capture instead of another pass through `draft-identity`'s pick loop with an operator in it. `canon:draft-identity` writes the picked composition into the route and captures it through the server below.
66
+
67
+ ### What ships
68
+
69
+ - `card.config.mjs`: a second Astro config whose `srcDir` points at `./card-src`, which the main config's page router never reads. That is what excludes the card from the published build structurally rather than by a filename convention, the same mechanism `scenarios.astro` reaches for with `import.meta.env.DEV`. Its base port is `4421` plus `WORKTREE_PORT_OFFSET`, outside the band `astro.config.mjs` serves from, so a card server and a dev server of one worktree never contend.
70
+ - `card-src/pages/og-card.astro`: the route, shipped as a working placeholder so it serves before any pick has been taken. It imports the project's own global stylesheet, which is what lets the composition read real tokens and real fonts instead of typed copies. It reads `--color-background` and `--color-text`, the names `canon design css` emits, and sets no `font-family` at all so the card inherits the project's body font rather than reading a font token the design system does not emit.
71
+ - `scripts/check-card-exclusion.sh`: fails when the route's `<meta name="og-card-marker">` tag reaches `dist/`, and fails when the route dropped that tag, since the marker is what the leak test reads for.
72
+ - `bun run card:dev` serves the route. `bun run card:check` runs the exclusion check, which the web layer's `scripts/verify.sh` also runs after its build stage whenever the script is installed.
73
+
74
+ ### Why each piece is shaped that way
75
+
76
+ The stylesheet import points at `@/styles/global.css`. A project keeping its stylesheet elsewhere retargets that one line, and an unresolved path then refuses the card server by name, which is the loud direction rather than the convenient one. The quiet failure it replaces is a card captured against no stylesheet at all: it renders as an unstyled box and reports success.
77
+
78
+ A custom property the stylesheet does not define is the same silent failure in a second costume. It takes its fallback, captures cleanly and reports success, so the card ships on typed values while reading as though it read real ones. That is why the route names only properties `canon design css` emits, and why a project retargeting the stylesheet import checks the names against what its own stylesheet defines.
79
+
80
+ The exclusion is a check rather than a convention because the route has three consumers and only one of them can be enforced. The card server reads it, the capture reads it, and the published build must not carry it.
81
+
82
+ `.card` is declared at 600x315, half of the 1200x630 target. `canon capture` opens every page at a fixed 2x device scale factor with no flag to change it, so a half-size element is what lands the captured PNG on the literal size. Declaring it at 1200x630 captures at 2400x1260.
83
+
84
+ ### Why v1 is Astro-only
85
+
86
+ Astro is the one web stack whose config gives a route a structural build exclusion. Next's App Router has no build-time route exclusion at all, and it needs none on the path it will take, since `ImageResponse` is that framework's own production mechanism and a card route there is meant to be served. `vite-react` builds a single-page app with no page router, so a card is a second Rollup entry rather than a route.
87
+
62
88
  ## Gitignore (extend)
63
89
 
64
90
  `[gitignore]` groups this stack edits, restated here per the manifest-to-reference symmetry:
65
91
 
66
92
  - `"# Astro" = [".astro/"]`
93
+ - `"# Social card" = ["card-dist/"]`
67
94
 
68
95
  ## Scenario switcher
69
96
 
@@ -71,6 +71,15 @@ main() {
71
71
  run_check "bun run build" "Build failed"
72
72
  log_info "Build passed"
73
73
 
74
+ # Runs whenever the stack shipped a card exclusion check, which keeps the
75
+ # stage stack-agnostic here and the check itself in the stack that owns the
76
+ # card route's shape. It reads the build above, so it sits after it.
77
+ if [ -f scripts/check-card-exclusion.sh ]; then
78
+ log_step "Card exclusion"
79
+ run_check "bash scripts/check-card-exclusion.sh" "Card exclusion check failed"
80
+ log_info "Card exclusion passed"
81
+ fi
82
+
74
83
  if [ "$NESTED" = false ]; then
75
84
  echo -e "${GREY}└${NC}\n"
76
85
  echo -e "${GREEN}✓ Verification passed${NC}"
@@ -19,7 +19,7 @@ Golden config files live in `tooling/web/configs/` and are copied into the targe
19
19
  - `.github/workflows/readme-screenshot.yml`: refreshes that frame on a pull request and commits it back to the branch. The path list ships as `REPLACE_WITH_PROJECT_PATHS`, and the job's first step after checkout exits 1 while it stays, since a job that never fires leaves no run and no diff to notice. The workflow's own path is in the list so the installing pull request reaches that step. When a commit-back is right and when a report is, `governance/rules/ci/700-ci-workflow.md` states the test.
20
20
  - `.vscode/extensions.json` and `.vscode/settings.json`: editor wiring for ESLint, Tailwind, Playwright, Vitest.
21
21
  - `.github/workflows/verify.yml`: `static-checks`, `unit-tests`, `build-verify`, and `e2e-tests` jobs.
22
- - `scripts/verify.sh`: extends base verify with typecheck, lint, unit tests, and build in the full order.
22
+ - `scripts/verify.sh`: extends base verify with typecheck, lint, unit tests, and build in the full order. Its last stage runs `scripts/check-card-exclusion.sh` whenever that file is installed, which puts the social card route's build exclusion on the default path rather than behind a flag nobody passes. The stage tests for the script rather than for a framework, so this layer owns when the check runs and the stack shipping a card route owns what it reads. The astro reference carries the route itself.
23
23
  - `scripts/worktree-port.sh`: prints a base port plus this working directory's offset. Called with no argument it prints the offset alone. It refuses a folder left under the worktrees directory after its worktree was removed, rather than printing a port for it.
24
24
 
25
25
  ## What stays in per-stack adapters
@@ -128,4 +128,6 @@ The sweep under `screenshots/` is ignored again, and only a flagged case's `evid
128
128
 
129
129
  ## Verify script
130
130
 
131
- The web layer's `scripts/verify.sh` replaces the base version. Order: typecheck, lint, format, spelling, shell, unit tests, build. Stack adapters may override if their typecheck or build differs.
131
+ The web layer's `scripts/verify.sh` replaces the base version. Order: typecheck, lint, format, spelling, shell, unit tests, build, then the card exclusion check where one is installed. Stack adapters may override if their typecheck or build differs.
132
+
133
+ The card stage is conditional on `scripts/check-card-exclusion.sh` existing rather than on the stack, so a project with no card route runs one less stage and needs no override to skip it. A stack that ships a card route ships that script, and the check reads the build the stage before it produced.