@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.
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/draft-identity/SKILL.md +84 -9
- package/claude/skills/role-planner/REQUIREMENT.md +14 -7
- package/claude/skills/role-planner/SKILL.md +56 -53
- package/claude/skills/role-worker/SKILL.md +12 -4
- package/claude/skills/session-relay/REQUIREMENT.md +44 -17
- package/claude/skills/session-relay/SKILL.md +83 -14
- package/docs/agents/tasks.md +14 -10
- package/docs/workflow/ai-workflow.md +9 -9
- package/docs/workflow/visual-design-workflow.md +1 -1
- package/governance/rules/ui/410-a11y.md +5 -0
- package/governance/rules/ui/440-surface-capture.md +7 -1
- package/package.json +1 -1
- package/src/claude/cases/workflow.ts +1 -1
- package/src/commands/tasks.ts +1 -0
- package/src/design/document.ts +2 -0
- package/src/design/tokens.ts +4 -0
- package/src/gate/measures.ts +28 -5
- package/src/tasks/archive.ts +77 -10
- package/src/tasks/validate.ts +68 -0
- package/standards/design.md +1 -1
- package/tooling/astro/configs/card-src/pages/og-card.astro +98 -0
- package/tooling/astro/configs/card.config.mjs +39 -0
- package/tooling/astro/configs/scripts/check-card-exclusion.sh +58 -0
- package/tooling/astro/manifest.toml +3 -0
- package/tooling/astro/reference.md +27 -0
- package/tooling/web/configs/scripts/verify.sh +9 -0
- package/tooling/web/reference.md +4 -2
|
@@ -1,27 +1,92 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: session-relay
|
|
3
|
-
description:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
##
|
|
19
|
+
## Step 1: name the sender
|
|
13
20
|
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
package/docs/agents/tasks.md
CHANGED
|
@@ -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.
|
|
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
|
|
279
|
-
| `canon:role-worker` | To assert the worker role for a cold session building one branch under one plan
|
|
280
|
-
| `canon:session-relay` | When
|
|
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
|
|
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
|
@@ -155,7 +155,7 @@ export const WORKFLOW_CASES: readonly SkillCase[] = [
|
|
|
155
155
|
},
|
|
156
156
|
{
|
|
157
157
|
prompt:
|
|
158
|
-
'
|
|
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
|
|
package/src/commands/tasks.ts
CHANGED
|
@@ -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',
|
package/src/design/document.ts
CHANGED
package/src/design/tokens.ts
CHANGED
|
@@ -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
|
|
package/src/gate/measures.ts
CHANGED
|
@@ -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,
|
|
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(
|
|
862
|
-
|
|
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(
|
|
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}`),
|
package/src/tasks/archive.ts
CHANGED
|
@@ -792,15 +792,16 @@ export async function archiveTask(
|
|
|
792
792
|
|
|
793
793
|
await mkdir(destination, { recursive: true })
|
|
794
794
|
await rename(from, to)
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
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(
|
|
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
|
package/src/tasks/validate.ts
CHANGED
|
@@ -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,
|
package/standards/design.md
CHANGED
|
@@ -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
|