@erclx/canon 4.104.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,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.
@@ -325,22 +327,24 @@ canon tasks validate
325
327
  canon tasks validate --json
326
328
  ```
327
329
 
328
- Seven checks run. Plan and Collisions reach one half each of the `## Run now` test the board standard states. Mapping and Grouping test the folder contract and hold for every group, and Shape holds for every group too, ahead of the four. Ordering reaches only the `## Needs a plan` rows, and Blockers reaches every row outside `## Run now`:
330
+ Seven checks run. Collisions reaches one half of the `## Run now` test the board standard states, and Plan reaches every group, reading the Plan column under `## Run now` and the task file's own line under the other two. Mapping and Grouping test the folder contract and hold for every group, and Shape holds for every group too, ahead of the four. Ordering reaches only the `## Needs a plan` rows, and Blockers reaches every row outside `## Run now`:
329
331
 
330
- | Check | What it reports |
331
- | ---------- | ----------------------------------------------------------------------------------------------------------------------- |
332
- | Shape | A row whose cell count disagrees with its table's header, or one stranded behind a table a blank line already closed |
333
- | Plan | A `## Run now` row whose Plan column carries no link, resolves to no file, or disagrees with the task's own line |
334
- | Mapping | A row or backlog line naming no task file |
335
- | Grouping | A task carrying a row in more than one readiness group, or on both surfaces |
336
- | Ordering | A `## Needs a plan` row whose stated position disagrees with where it actually sits, or which states no position at all |
337
- | Collisions | Two `## Run now` rows whose Touches columns name a path in common |
338
- | Blockers | A parked row whose blocker has stopped holding, whose cited task resolves nowhere, or whose cited task was declined |
332
+ | Check | What it reports |
333
+ | ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
334
+ | Shape | A row whose cell count disagrees with its table's header, or one stranded behind a table a blank line already closed |
335
+ | Plan | A `## Run now` row whose Plan column carries no link, resolves to no file, or disagrees with the task's own line, a `## Needs a plan` row whose task cites a live plan, or an `## Up next` row whose task cites none |
336
+ | Mapping | A row or backlog line naming no task file |
337
+ | Grouping | A task carrying a row in more than one readiness group, or on both surfaces |
338
+ | Ordering | A `## Needs a plan` row whose stated position disagrees with where it actually sits, or which states no position at all |
339
+ | Collisions | Two `## Run now` rows whose Touches columns name a path in common |
340
+ | Blockers | A parked row whose blocker has stopped holding, whose cited task resolves nowhere, or whose cited task was declined |
339
341
 
340
342
  Shape runs before any other check reads a row, since a row failing it carries no dependable fields for the rest to check. A blank or prose line closes the table above it, so the walk treats the next pipe line as a fresh header candidate rather than as a continuation. That candidate counts as a header only when the line behind it is a separator carrying the same cell count, and one that fails is `row-untabled`, stranded behind a table that already closed. Cell count still has to match the header on every row that clears that test, and a row whose count disagrees is `row-misshapen`, the shape a dropped pipe or a merged column produces.
341
343
 
342
344
  The Plan check reads the row and the task file both, because the two are written by different hands and only the task's own `Plan:` line reaches the archive. A row carrying a plan whose task states none is `plan-uncited`, and a pair naming two different plans is `plan-mismatched`. Both sides resolve against the board and against the project root before they compare, so a row writing `../plans/x.md` and a task writing `.canon/plans/x.md` name one file rather than two.
343
345
 
346
+ The other two groups have no Plan column, so the same check reads the task file's own `Plan:` line against what the group name claims. A `## Needs a plan` row whose task cites a plan that sits live on disk is `plan-parked`, since the group says no such plan exists and the row is either misplaced or its plan is stale and belongs in the archive. An `## Up next` row whose task cites no live plan is `plan-absent`, since that group says one is written. A plan already archived, or a cited path with no file behind it, reads as not live on both sides. A row whose task file is gone is left to `task-unresolved`. An `## Up next` row with nothing left to build, waiting on an act only a person can take, reports `plan-absent` once its plan archives while the task stays live. That report is expected, since the check reads the task file and not the `Waiting on` prose, so a row in that state moves to `## Needs a plan` or closes.
347
+
344
348
  Mapping spans two surfaces, because a task sits on `priority.md` when it would plausibly be planned soon and on `backlog.md` otherwise. A row or a backlog line naming no task file is `task-unresolved`, and a file both surfaces name is `row-duplicated` for the reason a task in two groups is: it claims two things about itself and only one can hold. A task file neither surface names is `unplaced` rather than a finding, since that is the normal state between a session filing it and a live orchestrator placing it on the board. One check across both is what lets a task move between them without the move reading as a dropped file.
345
349
 
346
350
  A backlog line is a bullet carrying a link to a sibling task, since the backlog is a flat unordered list rather than a table. A bullet holding prose is skipped rather than reported, which keeps the file's own intro out of the findings, and the task that bullet meant to name still lands in the `unplaced` array below rather than the findings. A project carrying no `backlog.md` reads as an empty backlog rather than a refusal, which leaves the one-to-one mapping this check ran before the second surface existed.
@@ -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
@@ -55,3 +55,8 @@ paths:
55
55
  - Alt text for functional images (icons, buttons) should describe the action, not the graphic.
56
56
  - Give the visual boundary of a control and every graphic that carries meaning at least 3:1 contrast against adjacent colors (WCAG Non-text Contrast, 1.4.11).
57
57
  - Never make color the only carrier of a state, error, or distinction. Pair it with text, an icon, or a pattern (WCAG Use of Color, 1.4.1).
58
+
59
+ ## Reflow
60
+
61
+ - Let content reflow at a viewport 320 CSS pixels wide with no loss of content or function and no two-dimensional scrolling. Only content whose meaning depends on both axes, such as a data table, a map, or a diagram, may scroll sideways (WCAG Reflow, 1.4.10, Level AA).
62
+ - Treat 320 as the criterion's own test condition rather than a device width. It is equivalent to a 1280 pixel window at 400 percent zoom, so a layout that breaks there breaks for a person who zooms.
@@ -25,7 +25,13 @@ paths:
25
25
 
26
26
  ## What a capture covers
27
27
 
28
- - Capture the full page at the viewport its case declares. Do not capture a component in isolation.
28
+ - Capture the full page. Do not capture a component in isolation.
29
+ - Declare the widths a layout case captures. Always include 320, the reflow floor from the reflow criterion in `410-a11y`, then add each breakpoint bucket the project writes rules for, read from the `## Layout` section of `DESIGN.md`.
30
+ - Fall back to the 320 floor alone when the project has no `DESIGN.md` or its record carries no `## Layout` section. Do not impose another project's breakpoints.
31
+ - Apply the declaration to a case that covers layout only. A case driving a menu open or an answer chosen tests a state, so it takes one width rather than the full set.
32
+ - Read a capture at 320 as proof that a frame was taken there, not that the page reflows correctly. The criterion is stated in `410-a11y` and nothing here enforces it.
33
+ - Write the capture into a folder named `evidence`. The image is collected for the pull request comment when any segment of its path is literally `evidence` and its filename carries an image extension, so the folder name is fixed and its position in the tree is the project's own.
34
+ - Several `evidence/` folders in one project is the ordinary shape rather than a problem to consolidate. One per surface is how a project with more than one rendered surface ends up.
29
35
  - Drive a state a screenshot cannot reach. An initial render reports nothing about a menu that opens, an answer that is chosen, or a rail that tracks scrolling.
30
36
  - Add a case to the capture record when adding a surface.
31
37
  - Remove a surface's case in the change that removes the surface.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@erclx/canon",
3
3
  "type": "module",
4
- "version": "4.104.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
 
@@ -218,6 +218,7 @@ export function register(program: Command): void {
218
218
  '',
219
219
  'Checks:',
220
220
  ' every Run now row points at a plan file that resolves',
221
+ ' no Needs a plan row has a live plan, and every Up next row has one',
221
222
  ' every task file carries a board row or a backlog line, never both',
222
223
  ' no task carries more than one row',
223
224
  ' no two Run now rows touch the same file',
@@ -134,6 +134,8 @@ export function renderDesignDocument(tokens: DesignTokens = TOKENS): string {
134
134
  ),
135
135
  ),
136
136
  '',
137
+ section('Layout', tokens.layout),
138
+ '',
137
139
  section('Motion', tokens.motion),
138
140
  '',
139
141
  section('Iconography', tokens.iconography),
@@ -70,6 +70,7 @@ export interface DesignTokens {
70
70
  readonly spacingNote: string
71
71
  readonly borders: readonly BorderToken[]
72
72
  readonly bordersNote: string
73
+ readonly layout: string
73
74
  readonly motion: string
74
75
  readonly iconography: string
75
76
  readonly preamble: string
@@ -371,6 +372,9 @@ export const TOKENS: DesignTokens = {
371
372
  },
372
373
  ],
373
374
 
375
+ layout:
376
+ 'Capture widths are 320, 768, 1280 and 1536 pixels, declared once in `tooling/web/configs/e2e/screenshot.ts`, with 320 and 1280 committed as evidence. 320 is the reflow floor and the rest are the breakpoint buckets. The landing page under `web/` sets its own `max-width` media queries between 430 and 1100 pixels per component rather than from a shared scale.',
377
+
374
378
  motion:
375
379
  'Motion is not used. No transition, animation, or keyframe declaration appears on any rendered surface, and the capture pipeline screenshots a static frame.',
376
380
 
@@ -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
@@ -8,6 +8,7 @@ import {
8
8
  readOutcomes,
9
9
  readPlanTarget,
10
10
  readPullRequest,
11
+ resolveLivePlan,
11
12
  tasksDir,
12
13
  } from '@/tasks/archive'
13
14
  import { gitTrunkReader, type TrunkReader } from '@/tasks/trunk'
@@ -38,6 +39,8 @@ export const FINDING_KINDS = [
38
39
  'plan-unresolved',
39
40
  'plan-uncited',
40
41
  'plan-mismatched',
42
+ 'plan-parked',
43
+ 'plan-absent',
41
44
  'task-unresolved',
42
45
  'row-duplicated',
43
46
  'row-misshapen',
@@ -747,6 +750,70 @@ async function planDisagreement(
747
750
  }
748
751
  }
749
752
 
753
+ /**
754
+ * Tests the claim a group name makes about a plan. `## Needs a plan` says none
755
+ * is live and `## Up next` says one is written, and neither group has a `Plan`
756
+ * column, so the task file's own `Plan:` line is the only place either claim
757
+ * can be read against.
758
+ *
759
+ * A live plan is one the archive's resolution accepts and the disk holds, so a
760
+ * plan already archived reads as not live and a cited path with no file behind
761
+ * it reads the same way. A row whose task file is gone is left to
762
+ * `task-unresolved`, which owns that report.
763
+ */
764
+ async function checkGroupClaims(
765
+ rows: readonly BoardRow[],
766
+ dir: string,
767
+ root: string,
768
+ ): Promise<Finding[]> {
769
+ const parked = rows.filter(
770
+ (row) => row.group === 'Needs a plan' || row.group === 'Up next',
771
+ )
772
+
773
+ const found = await Promise.all(
774
+ parked.map(async (row) => groupClaimFinding(row, dir, root)),
775
+ )
776
+
777
+ return found.filter((finding): finding is Finding => finding !== undefined)
778
+ }
779
+
780
+ async function groupClaimFinding(
781
+ row: BoardRow,
782
+ dir: string,
783
+ root: string,
784
+ ): Promise<Finding | undefined> {
785
+ if (!row.stem) return undefined
786
+
787
+ const file = join(dir, `${row.stem}.md`)
788
+ if (!existsSync(file)) return undefined
789
+
790
+ const target = readPlanTarget(await readFile(file, 'utf8'))
791
+ const live = target ? resolveLivePlan(target, dir, root) : undefined
792
+ const hasLivePlan = live !== undefined && existsSync(live)
793
+
794
+ if (row.group === 'Needs a plan' && hasLivePlan) {
795
+ return {
796
+ kind: 'plan-parked',
797
+ group: row.group,
798
+ subject: row.stem,
799
+ message: `sits under ${row.group} and its task cites ${target}, a live plan. A row with a plan belongs under Up next or Run now, or the plan is stale and belongs in the archive.`,
800
+ }
801
+ }
802
+
803
+ if (row.group === 'Up next' && !hasLivePlan) {
804
+ return {
805
+ kind: 'plan-absent',
806
+ group: row.group,
807
+ subject: row.stem,
808
+ message: target
809
+ ? `sits under ${row.group}, which claims a written plan, and its task cites ${target}, which is not a live plan.`
810
+ : `sits under ${row.group}, which claims a written plan, and its task carries no Plan: line.`,
811
+ }
812
+ }
813
+
814
+ return undefined
815
+ }
816
+
750
817
  /**
751
818
  * Where a plan pointer lands, resolved against the board and against the
752
819
  * project root the way the archive resolves the same line. Neither base
@@ -1186,6 +1253,7 @@ export async function validateBoard(
1186
1253
  ...checkMapping(rows, backlog, dir),
1187
1254
  ...checkPlans(rows, dir, root),
1188
1255
  ...(await checkPlanAgreement(rows, dir, root)),
1256
+ ...(await checkGroupClaims(rows, dir, root)),
1189
1257
  ...checkCollisions(rows),
1190
1258
  ...checkOrdering(rows),
1191
1259
  ...parked.findings,
@@ -51,7 +51,7 @@ Use `## Personality`, `## Color`, `## Typography`, `## Spacing`, `## Borders`, `
51
51
 
52
52
  Add either optional section when the project has content for it:
53
53
 
54
- - `## Layout`: page width, breakpoints, and grid rules, as current constraints
54
+ - `## Layout`: page width, breakpoints, and grid rules, as current constraints. The surface-capture rule reads the breakpoints from here, so a project with none declared here is captured at the 320 reflow floor alone.
55
55
  - `## Mark`: the logo's construction rules and the files that carry it
56
56
 
57
57
  ## Template