@erclx/canon 4.59.0 → 4.61.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/claude-groundwork/REQUIREMENT.md +1 -0
- package/claude/skills/claude-groundwork/SKILL.md +1 -1
- package/claude/skills/claude-memory-review/SKILL.md +2 -2
- package/claude/skills/claude-tasks/REQUIREMENT.md +6 -2
- package/claude/skills/claude-tasks/SKILL.md +12 -6
- package/claude/skills/setup-indexes/REQUIREMENT.md +3 -0
- package/claude/skills/setup-indexes/SKILL.md +8 -0
- package/docs/agents/commands.md +1 -0
- package/docs/agents/tasks.md +44 -3
- package/governance/rules/ui/440-surface-capture.md +4 -2
- package/package.json +1 -1
- package/src/commands/labels.ts +7 -9
- package/src/commands/tasks.ts +101 -0
- package/src/tasks/label.ts +110 -0
- package/src/tasks/validate.ts +46 -19
- package/tooling/astro/reference.md +6 -0
- package/tooling/nextjs/manifest.toml +3 -0
- package/tooling/nextjs/reference.md +6 -0
- package/tooling/web/configs/e2e/screenshot.ts +28 -1
- package/tooling/web/manifest.toml +1 -0
- package/tooling/web/reference.md +3 -2
|
@@ -22,6 +22,7 @@ A track that closes with several separable findings writes only one task, and th
|
|
|
22
22
|
- Carry a lean and the finding that would overturn it on every open question, or admit that a measurement is missing
|
|
23
23
|
- Confine writes to the track folder, with the close-time task file, the experiment fixture, and the intake routing below as the only exceptions
|
|
24
24
|
- Route a closing-track finding the required task does not cover through `claude-intake`, rather than leaving it to be asked about. The route runs in the same session, so it is a write outside the folder rather than a handoff to a later one.
|
|
25
|
+
- Place the closing task's row through `claude-tasks` Step 4 rather than writing `priority.md` or `backlog.md` directly
|
|
25
26
|
- Link every claim about a source outside the project, and list an unread source as a lead rather than citing it
|
|
26
27
|
- Put a fixture a headless run is pointed at outside the repository
|
|
27
28
|
- Write the next-session file self-contained, since the folder is unbacked and dies with the machine
|
|
@@ -92,7 +92,7 @@ The standard sets the open question format and requires it inside a topic file a
|
|
|
92
92
|
1. Write `06-decision.md`. It states the problem once, names the goal, lists what to do, and lists what was considered and dropped.
|
|
93
93
|
2. Write `07-next-session.md` self-contained, so it survives a compaction that loses the conversation.
|
|
94
94
|
3. Update the file map in `README.md`.
|
|
95
|
-
4. Write one task file in `.canon/tasks/` recording what the track concluded, even when the conclusion is to do nothing. Follow `${CLAUDE_SKILL_DIR}/../../standards/tasks.md` for the filename and frontmatter. Aside from an experiment fixture, this and the routing in Step 5 are the only ways close mode reaches outside the folder.
|
|
95
|
+
4. Write one task file in `.canon/tasks/` recording what the track concluded, even when the conclusion is to do nothing. Follow `${CLAUDE_SKILL_DIR}/../../standards/tasks.md` for the filename and frontmatter. Place the row through `claude-tasks` Step 4, which checks the roster for a live orchestrator before writing `priority.md` or `backlog.md` directly. Aside from an experiment fixture, this and the routing in Step 5 are the only ways close mode reaches outside the folder.
|
|
96
96
|
5. When the task written in Step 4 does not cover every finding the track surfaced, route what it leaves out through `claude-intake`. Skip this step when it does.
|
|
97
97
|
6. Report uncited external claims. Closing already reads every file in the folder, so list any statement about a source outside the project that carries neither a link nor a lead entry. Report and do not block, because judging whether a sentence makes an external claim is the call a checker gets wrong.
|
|
98
98
|
|
|
@@ -65,7 +65,7 @@ For each in-scope entry (see Scope), pick one action:
|
|
|
65
65
|
- **Promote to `CLAUDE.md`**: the rule is cross-domain behavior or a design principle applied across the whole project.
|
|
66
66
|
- **Promote to a skill body**: the rule fires only when editing a specific path-scoped domain. Name the target skill.
|
|
67
67
|
- **Promote to a standards file**: the rule is an authoring reference that belongs in the project's own standards folder as `<domain>.md`.
|
|
68
|
-
- **Promote to a context entry**: the entry states a fact about a domain carrying an entry in `.claude/context/index.md`. Append it to `.canon/tmp/memory-routing/<slug>.md` in the format `claude-memory-capture` writes, and tell the user to run `/claude-docs` from a branch. Do not edit the context entry here.
|
|
68
|
+
- **Promote to a context entry**: the entry states a fact about a domain carrying an entry in `.claude/context/index.md`. Append it to `.canon/tmp/memory-routing/<slug>.md` at the main worktree root, in the format `claude-memory-capture` writes, and tell the user to run `/claude-docs` from a branch. Do not edit the context entry here.
|
|
69
69
|
- **Hand off to governance**: the rule is coding-standards class (typescript, testing, naming, error-handling, performance, logging, concurrency, planning). Do not author the rule file inline. Never edit the synced `.claude/rules/` copies of toolkit rules, because `canon gov sync` overwrites them. Stop at handoff.
|
|
70
70
|
- In the toolkit repo, point the user at `internal-governance` and `${CLAUDE_SKILL_DIR}/../../standards/rule.md`, which own the source-of-truth rules under `governance/rules/`.
|
|
71
71
|
- In a target project, point the user at the `create-rule` skill, which scaffolds a project-local rule under `.claude/rules/`.
|
|
@@ -150,7 +150,7 @@ Free-form text after the verb is a reason. Capture it in the receipt but do not
|
|
|
150
150
|
Action by action type:
|
|
151
151
|
|
|
152
152
|
- **Promote**: use `Edit` to insert the rewritten rule into the target surface, then archive the memory file.
|
|
153
|
-
- **Promote to a context entry**: append the fact to `.canon/tmp/memory-routing/<slug>.md
|
|
153
|
+
- **Promote to a context entry**: append the fact to `.canon/tmp/memory-routing/<slug>.md` at the main worktree root, then archive the memory file. `claude-docs` folds it in on its next run from a branch, which is what keeps one skill writing context entries.
|
|
154
154
|
- **Hand off**: do not edit governance. Archive the memory file only if the user confirmed the handoff explicitly. Otherwise leave it in place.
|
|
155
155
|
- **Retire**: archive the memory file.
|
|
156
156
|
|
|
@@ -7,16 +7,19 @@ description: Why creating and archiving a task file is one skill, the origin inv
|
|
|
7
7
|
|
|
8
8
|
## Gap
|
|
9
9
|
|
|
10
|
-
Without this skill, a task file gets a filename and frontmatter invented on the spot, so the board sorts wrong and the regenerated index reads fields that are not there. A task arrives with no origin, which is either lost context or work nobody decided to do, and by the time anyone notices there is no way to recover which it was. The phase label gets derived from a version file rather than from what the board already means, and a single-digit phase sorts after a double-digit one because nobody padded it.
|
|
10
|
+
Without this skill, a task file gets a filename and frontmatter invented on the spot, so the board sorts wrong and the regenerated index reads fields that are not there. A task arrives with no origin, which is either lost context or work nobody decided to do, and by the time anyone notices there is no way to recover which it was. The phase label gets derived from a version file rather than from what the board already means, and a single-digit phase sorts after a double-digit one because nobody padded it. A label picked off the live board alone repeats one the archive already spent, since the archive holds most of what has ever been allocated and a board-only scan cannot see it.
|
|
11
11
|
|
|
12
12
|
Archiving fails in two ways that both lose work. Moving the file, editing the ordering file, and regenerating the index as three separate acts drifts from the one command the merge hook calls, so the attended and unattended paths stop agreeing. And an all-`[x]` task gets archived while its pull request is still open, because marking outcomes happens on the branch as the first step of shipping. The board is gitignored, so nothing restores a task archived early.
|
|
13
13
|
|
|
14
|
+
Placing a row without checking for another writer collides the same way. Two sessions filing work at once can claim one phase label twice or land two rows beside each other unread, since neither reads the board before writing it.
|
|
15
|
+
|
|
14
16
|
## Must
|
|
15
17
|
|
|
16
18
|
- Resolve the board at the main worktree root, since a linked worktree writing to `pwd` creates a second board nothing reads
|
|
17
19
|
- Read the tasks standard before writing, rather than working the filename and frontmatter from memory
|
|
18
20
|
- Require an origin at creation, because that is the only moment the invariant is enforceable
|
|
19
|
-
-
|
|
21
|
+
- Read the phase label from `canon tasks next-label` rather than proposing one by hand
|
|
22
|
+
- Check the roster for a live orchestrator before writing a row, and hand off rather than write when one is found
|
|
20
23
|
- Confirm the work reached the default branch before archiving
|
|
21
24
|
- Run the archive command and route on the reason it refuses, since each reason has one resolution
|
|
22
25
|
- Report an origin that carries no task, as a list rather than a prompt
|
|
@@ -28,6 +31,7 @@ Archiving fails in two ways that both lose work. Moving the file, editing the or
|
|
|
28
31
|
- Write a pull request line at creation, when any number is a guess at someone else's work
|
|
29
32
|
- Archive around a refusal
|
|
30
33
|
- Split the legacy single-file archive into per-task files nobody wrote
|
|
34
|
+
- Fall through to a direct write when no orchestrator is found and this session's own name starts with `worker-` or `planner-`, since those role bodies ban the write with no exception
|
|
31
35
|
|
|
32
36
|
## Guards
|
|
33
37
|
|
|
@@ -27,11 +27,11 @@ A task with no origin is either lost context or work nobody decided to do. This
|
|
|
27
27
|
|
|
28
28
|
Accept work whose origin is the conversation itself only when the user says so explicitly, and record what it was in the intro paragraph instead of writing a link line to a file that does not exist.
|
|
29
29
|
|
|
30
|
-
### Step 2:
|
|
30
|
+
### Step 2: allocate the phase label
|
|
31
31
|
|
|
32
|
-
|
|
32
|
+
Run `canon tasks next-label --json` and take its `label` field. The verb reads the live board and its `archive/` sibling together, so the label it returns accounts for what the board alone no longer shows.
|
|
33
33
|
|
|
34
|
-
Do not derive the label from a version file. `${CLAUDE_SKILL_DIR}/../../standards/versioning.md` permits free renumbering, so the
|
|
34
|
+
Do not derive the label from a version file. `${CLAUDE_SKILL_DIR}/../../standards/versioning.md` permits free renumbering, so the verb's two folders are the only surface that knows what a label currently means.
|
|
35
35
|
|
|
36
36
|
### Step 3: write the file
|
|
37
37
|
|
|
@@ -47,9 +47,15 @@ Write it immediately. Claude Code's tool permission dialog is the confirmation g
|
|
|
47
47
|
|
|
48
48
|
A task file with no row is a dropped task, so name the surface it lands on in the same pass that creates it. A task that would plausibly be planned within the next few waves takes a row under `## Needs a plan` in `.canon/tasks/priority.md`, positioned by where it sits against the rows already there, with the reason for that position in its `Waiting on` cell. Anything else takes a line in `.canon/tasks/backlog.md`, which is unordered and where in the file it goes means nothing.
|
|
49
49
|
|
|
50
|
-
The test and both file shapes are in `${CLAUDE_SKILL_DIR}/../../standards/tasks.md`.
|
|
50
|
+
The test and both file shapes are in `${CLAUDE_SKILL_DIR}/../../standards/tasks.md`.
|
|
51
51
|
|
|
52
|
-
|
|
52
|
+
Check the roster for a live orchestrator before writing either file. Read `canon sessions list --self --json` for this session's own `sessionId` and `name`, then read `canon sessions list --json` and match a row whose `repository` matches this session's own, whose `sessionId` differs from it, and whose `name` starts with `orchestrator-`. That is the convention every hand-launched controller on this machine currently uses, and it excludes every `worker-` and `planner-` session cleanly. Treat a refusal from either call the same as a roster read that failed.
|
|
53
|
+
|
|
54
|
+
- **Found.** Do not write `priority.md` or `backlog.md`. Report the surface the row would take and the reason worked out above, then hand off by messaging that session with the same information so it places the row itself rather than two sessions writing the board at once.
|
|
55
|
+
- **Not found, and this session's own name (already read above) starts with `worker-` or `planner-`.** Do not write either file. Those two role bodies ban a board write with no exception, and an orchestrator absent from the roster is not consent to break it. Report the row and the surface it would take, so the operator or a later orchestrator places it.
|
|
56
|
+
- **Not found otherwise, or the roster read fails.** Write the row directly, as today. A failed read is indistinguishable from a solo project holding no orchestrator, and stopping would strand the row on the one path this section otherwise keeps unconditional. From a linked worktree the file-editing tools refuse the main root, so the row lands through the same `Bash` route the file itself took.
|
|
57
|
+
|
|
58
|
+
Say which branch fired and why in the report. The call is a judgment restated on every sweep rather than a property of the task, and a placement with no stated reason is one the next sweep re-derives from nothing.
|
|
53
59
|
|
|
54
60
|
### Step 5: report unlinked origins
|
|
55
61
|
|
|
@@ -132,7 +138,7 @@ Create:
|
|
|
132
138
|
```plaintext
|
|
133
139
|
✅ Created: .canon/tasks/vXX.Y-<slug>.md
|
|
134
140
|
|
|
135
|
-
<label
|
|
141
|
+
<label>, next after <highest>.
|
|
136
142
|
<board or backlog, and why it landed there>.
|
|
137
143
|
|
|
138
144
|
**Origin with no task:**
|
|
@@ -15,6 +15,8 @@ One failure predates the scan. `setup-init` declines an install that wants the C
|
|
|
15
15
|
|
|
16
16
|
Drafted frontmatter is a proposal, and a session that writes it before the user sees it turns a review into a cleanup. The convention block has the mirror failure. A session that paraphrases it into `CLAUDE.md` produces a copy that reads correctly and no longer matches its source, so the two drift with nothing reporting it. And a project with no `CLAUDE.md` gets one scaffolded to hold the block, which installs a file the project declined.
|
|
17
17
|
|
|
18
|
+
A fourth failure sits after the scan rather than inside it. `## Present candidates` had no branch for a scan that finds zero folders, so an empty result walked straight into the ask with nothing to choose from. `setup-init` folds this skill into the onboarding chain, and a fresh scaffold usually carries no markdown-heavy folder yet, so the ordinary onboarding run reached the undefined case.
|
|
19
|
+
|
|
18
20
|
## Must
|
|
19
21
|
|
|
20
22
|
- Bootstrap all-or-nothing per chosen folder, since a folder carrying partial frontmatter hard-errors on regen
|
|
@@ -36,6 +38,7 @@ Drafted frontmatter is a proposal, and a session that writes it before the user
|
|
|
36
38
|
|
|
37
39
|
- A folder with fewer than the sibling threshold stays out of the candidate list unless the user names it explicitly, so the scan proposes and the user overrides
|
|
38
40
|
- A `CLAUDE.md` already carrying the convention section skips the seed silently rather than appending a second copy
|
|
41
|
+
- A scan that finds no candidate reports a one-line result and returns, skipping the ask and every step between it and the seed offer, rather than opening a prompt with nothing in it
|
|
39
42
|
|
|
40
43
|
## Out of scope
|
|
41
44
|
|
|
@@ -29,6 +29,14 @@ For each candidate, capture:
|
|
|
29
29
|
|
|
30
30
|
## Present candidates
|
|
31
31
|
|
|
32
|
+
If the scan found no candidate, report the flat result and skip ahead:
|
|
33
|
+
|
|
34
|
+
```plaintext
|
|
35
|
+
No folder has three or more markdown siblings without an index.md already. Nothing to bootstrap.
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Skip past the ask below and every step between it and the seed offer, straight to `## Offer the convention seed`. There is no candidate to ask about, so this stops rather than opening a prompt with nothing in it.
|
|
39
|
+
|
|
32
40
|
Output one line per candidate:
|
|
33
41
|
|
|
34
42
|
```plaintext
|
package/docs/agents/commands.md
CHANGED
|
@@ -36,6 +36,7 @@ Full help: `canon <command> --help`. Behavior notes for the install and sync ver
|
|
|
36
36
|
| `canon tasks plan-link` | Write or correct a task's `Plan:` line to point at a plan, by stem and plan path or slug (`--json`) |
|
|
37
37
|
| `canon tasks outcome` | Mark outcomes `[x]` on a task by position, repeating `--close` (`--json`) |
|
|
38
38
|
| `canon tasks validate` | Report board rows whose shape, order, plan, task file, group, file set, or blocker does not hold (`--json`) |
|
|
39
|
+
| `canon tasks next-label` | Report the next unused phase label across the board and its archive (`--json`) |
|
|
39
40
|
| `canon intake list` | Report intake folder counts, or one folder's items, keeping what is unread with `--unread` (`--json`) |
|
|
40
41
|
| `canon intake answer` | Write selections into one cluster's answer slots, repeating `--set <item>=<answer>` (`--json`) |
|
|
41
42
|
| `canon teach list` | Report learning workspaces and the ordinal a new one takes, or what one workspace holds and what its records schedule (`--json`) |
|
package/docs/agents/tasks.md
CHANGED
|
@@ -5,6 +5,32 @@ description: Selecting a shipped task by stem or pull request, recording a numbe
|
|
|
5
5
|
|
|
6
6
|
# Tasks
|
|
7
7
|
|
|
8
|
+
## Next label
|
|
9
|
+
|
|
10
|
+
`canon tasks next-label` reports the next unused phase label, reading `.canon/tasks/` and its `archive/` sibling together. A scan confined to the live board is blind to every label the archive already spent, which is what let two sessions hand out the same label within minutes of each other. It reports and never writes.
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
canon tasks next-label
|
|
14
|
+
canon tasks next-label --json
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
| Option | Effect |
|
|
18
|
+
| --------------- | ------------------------------------------- |
|
|
19
|
+
| `--json` | Emit a machine-readable record on stdout |
|
|
20
|
+
| `--root <path>` | Board root, defaulting to the main worktree |
|
|
21
|
+
|
|
22
|
+
The record carries `label`, the next free `vXX.Y`, and `highest`, the label it was derived from. `highest` is absent when neither folder carries a label yet, and `label` reads `v01.0` in that case, matching the zero-padded-major shape every other label already takes. <!-- canon-allow-reference: illustrates the verb's answer for a board holding no label yet, not a citation of a real task -->
|
|
23
|
+
|
|
24
|
+
Exit codes: `0` derived, `1` refused with `no-board`.
|
|
25
|
+
|
|
26
|
+
The minor digit rolls from 9 to 0 on the next major rather than growing a second digit, which is the single-digit-minor shape every phase label already takes. `canon tasks archive` moves a task's file from the live folder into the archive without renumbering it, so the same label counts toward the maximum wherever it currently sits, and a label claimed by two different files folds into the same scan without a dedicated check.
|
|
27
|
+
|
|
28
|
+
It reports rather than gates. Two sessions calling it in the same second can still take the same answer, since the board is gitignored files rather than a store with a lock, and `standards/versioning.md` permits free renumbering, so a collision costs a rename rather than anything worse.
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
canon tasks next-label --json | jq -r '.label'
|
|
32
|
+
```
|
|
33
|
+
|
|
8
34
|
## Archive
|
|
9
35
|
|
|
10
36
|
`canon tasks archive` moves a shipped task from `.canon/tasks/` into `.canon/tasks/archive/`, drops its row from `priority.md`, and regenerates the board index. The three run as one unit, so the attended and unattended callers cannot archive differently.
|
|
@@ -214,7 +240,7 @@ Seven checks run. Plan and Collisions reach one half each of the `## Run now` te
|
|
|
214
240
|
| ---------- | ----------------------------------------------------------------------------------------------------------------------- |
|
|
215
241
|
| Shape | A row whose cell count disagrees with its table's header, or one stranded behind a table a blank line already closed |
|
|
216
242
|
| Plan | A `## Run now` row whose Plan column carries no link, resolves to no file, or disagrees with the task's own line |
|
|
217
|
-
| Mapping | A row or backlog line naming no task file
|
|
243
|
+
| Mapping | A row or backlog line naming no task file |
|
|
218
244
|
| Grouping | A task carrying a row in more than one readiness group, or on both surfaces |
|
|
219
245
|
| Ordering | A `## Needs a plan` row whose stated position disagrees with where it actually sits, or which states no position at all |
|
|
220
246
|
| Collisions | Two `## Run now` rows whose Touches columns name a path in common |
|
|
@@ -224,9 +250,9 @@ Shape runs before any other check reads a row, since a row failing it carries no
|
|
|
224
250
|
|
|
225
251
|
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.
|
|
226
252
|
|
|
227
|
-
Mapping spans two surfaces, because a task sits on `priority.md` when it would plausibly be planned soon and on `backlog.md` otherwise. A
|
|
253
|
+
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.
|
|
228
254
|
|
|
229
|
-
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
|
|
255
|
+
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.
|
|
230
256
|
|
|
231
257
|
Ordering reads a `## Needs a plan` row's `Waiting on` cell for the position it claims, and reports two failures off one walk. A row stating an ordinal is checked against where it actually sits, which is `row-misordered`. A row claiming no position in either form it may take is `row-unranked`, since its cell argues the task matters and ranks it against nothing, which leaves the order recording when each row was filed.
|
|
232
258
|
|
|
@@ -282,6 +308,21 @@ That claim collides with every row a later session writes under the folder, and
|
|
|
282
308
|
|
|
283
309
|
The scan reaches `## Run now` and stops, where the collision check stops. A cell in another group describes work nobody has planned, written as a sentence and rewritten once a plan exists, so a claim read off one reports on prose rather than on a file set. A parked folder claim surfaces when its row is promoted, which is when the cell becomes something a dispatcher can act on.
|
|
284
310
|
|
|
311
|
+
A task file neither surface names lands in a fourth array, on the same reasoning:
|
|
312
|
+
|
|
313
|
+
```json
|
|
314
|
+
{
|
|
315
|
+
"unplaced": [
|
|
316
|
+
{
|
|
317
|
+
"subject": "v50.6-a-standard-no-skill-reads", // canon-allow-reference: shows the subject field's real vXX.Y-slug shape, not a citation of a real task
|
|
318
|
+
"message": "is a task file with no row on the board and no line on the backlog."
|
|
319
|
+
}
|
|
320
|
+
]
|
|
321
|
+
}
|
|
322
|
+
```
|
|
323
|
+
|
|
324
|
+
Under the roster-checked hand-off `claude-tasks` and `claude-groundwork` state, filing a task and placing its row are two acts a different session each may take, so a task caught between the two is ordinary rather than a finding and this array moves no exit code. It still reports, since it is the only local detector for a row a hand-edit dropped, a handoff message that never arrived, or an orchestrator that ended before placing it.
|
|
325
|
+
|
|
285
326
|
Exit codes: `0` every check passed, `1` refused, `2` at least one finding. The `reason` field carries which gate refused: `no-board`, `no-ordering`, or `no-groups`. A board grouping under headings of its own trips `no-groups` rather than being read against columns it never declared.
|
|
286
327
|
|
|
287
328
|
Columns are read from each table's own header rather than by position, so a project whose board differs from this one is reported for what it lacks. The `index`, `priority`, and `backlog` siblings are skipped, along with every pre-compaction handoff, which takes one file per session under a `session-` prefix. None of them is a task, and a handoff counted as one would be reported as a task carrying no row on every session that wrote one.
|
|
@@ -30,5 +30,7 @@ paths:
|
|
|
30
30
|
|
|
31
31
|
## Sharing a capture
|
|
32
32
|
|
|
33
|
-
-
|
|
34
|
-
-
|
|
33
|
+
- Do not commit the sweep. It stays ignored.
|
|
34
|
+
- Commit a flagged case's evidence output so the pull request carries the comparison, rather than attaching it by hand.
|
|
35
|
+
- Commit an evidence case for the first time only after running the capture twice with no code change between the runs and confirming the two outputs are byte-identical.
|
|
36
|
+
- Recommitting an unbounded sweep on every run reaches a gigabyte of repository history inside a hundred merges. A small, committed evidence set is what a reviewer needs and what lets GitHub draw its own before-and-after comparison on the pull request.
|
package/package.json
CHANGED
package/src/commands/labels.ts
CHANGED
|
@@ -155,9 +155,9 @@ export function register(program: Command): void {
|
|
|
155
155
|
"It also grades the title alone against standards/pr.md's ## Title",
|
|
156
156
|
'section: the `<type>(<scope>): <subject>` structure, lowercase casing',
|
|
157
157
|
'for the type, the scope, and the first subject word, and a 72-',
|
|
158
|
-
'character length cap. A
|
|
159
|
-
'
|
|
160
|
-
'string.',
|
|
158
|
+
'character length cap. A scan given no title, whether from a review',
|
|
159
|
+
'or from a bare --body or --body-file invocation, skips this check',
|
|
160
|
+
'rather than grading an empty string.',
|
|
161
161
|
'',
|
|
162
162
|
'Exit codes:',
|
|
163
163
|
' 0 none of the five found',
|
|
@@ -287,12 +287,10 @@ async function runScan(opts: ScanOptions): Promise<number> {
|
|
|
287
287
|
|
|
288
288
|
const result = scanPhaseLabels(resolved)
|
|
289
289
|
const spelling = await scanTitleSpelling(resolved.title, process.cwd())
|
|
290
|
-
// A
|
|
291
|
-
//
|
|
290
|
+
// A scan given no title, whatever produced that absence, has nothing to
|
|
291
|
+
// grade and would otherwise fail as `structure` for the wrong reason.
|
|
292
292
|
const titleFormat =
|
|
293
|
-
resolved.
|
|
294
|
-
? checkTitleFormat(resolved.title)
|
|
295
|
-
: undefined
|
|
293
|
+
resolved.title !== '' ? checkTitleFormat(resolved.title) : undefined
|
|
296
294
|
|
|
297
295
|
logStep(resolved.source === 'review' ? 'Review comment' : 'Pull request')
|
|
298
296
|
logInfo(
|
|
@@ -373,7 +371,7 @@ async function runScan(opts: ScanOptions): Promise<number> {
|
|
|
373
371
|
: 'Title format issue found',
|
|
374
372
|
)
|
|
375
373
|
if (titleFormat === undefined) {
|
|
376
|
-
logInfo('
|
|
374
|
+
logInfo('the scan carries no title, so there is no format to grade')
|
|
377
375
|
} else if (titleFormat.conforms) {
|
|
378
376
|
logInfo(
|
|
379
377
|
'the title matches <type>(<scope>): <subject> and its casing and length rules',
|
package/src/commands/tasks.ts
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
type PlanCitations,
|
|
10
10
|
planCitations,
|
|
11
11
|
} from '@/tasks/archive'
|
|
12
|
+
import { type LabelOutcome, nextLabel } from '@/tasks/label'
|
|
12
13
|
import {
|
|
13
14
|
type CloseOutcome,
|
|
14
15
|
closeOutcomes,
|
|
@@ -22,6 +23,7 @@ import {
|
|
|
22
23
|
import {
|
|
23
24
|
type Finding,
|
|
24
25
|
type FolderClaim,
|
|
26
|
+
type Unplaced,
|
|
25
27
|
type Untested,
|
|
26
28
|
type ValidateOutcome,
|
|
27
29
|
validateBoard,
|
|
@@ -86,6 +88,11 @@ interface OutcomeCommandOptions {
|
|
|
86
88
|
readonly root?: string
|
|
87
89
|
}
|
|
88
90
|
|
|
91
|
+
interface NextLabelCommandOptions {
|
|
92
|
+
readonly json?: boolean
|
|
93
|
+
readonly root?: string
|
|
94
|
+
}
|
|
95
|
+
|
|
89
96
|
export function register(program: Command): void {
|
|
90
97
|
const tasks = program
|
|
91
98
|
.command('tasks')
|
|
@@ -378,6 +385,40 @@ export function register(program: Command): void {
|
|
|
378
385
|
.action(async (task: string | undefined, opts: OutcomeCommandOptions) => {
|
|
379
386
|
process.exitCode = await runOutcome(task, opts)
|
|
380
387
|
})
|
|
388
|
+
|
|
389
|
+
tasks
|
|
390
|
+
.command('next-label')
|
|
391
|
+
.description(
|
|
392
|
+
'Report the next unused phase label across the board and its archive',
|
|
393
|
+
)
|
|
394
|
+
.helpOption('-h, --help', 'Show this help message')
|
|
395
|
+
.option('--json', 'Emit a machine-readable record on stdout')
|
|
396
|
+
.option('--root <path>', 'Board root, defaulting to the main worktree')
|
|
397
|
+
.addHelpText(
|
|
398
|
+
'after',
|
|
399
|
+
[
|
|
400
|
+
'',
|
|
401
|
+
'Reads .canon/tasks/ and its archive/ sibling together, since the',
|
|
402
|
+
'archive holds labels the live board no longer shows and a scan',
|
|
403
|
+
'confined to the board hands out one already spent.',
|
|
404
|
+
'',
|
|
405
|
+
'Exit codes:',
|
|
406
|
+
' 0 the label is derived',
|
|
407
|
+
' 1 refused with no-board',
|
|
408
|
+
'',
|
|
409
|
+
'It reports and never writes. Two sessions calling it in the same',
|
|
410
|
+
'second can still take the same answer, since the board is',
|
|
411
|
+
'gitignored files rather than a store with a lock.',
|
|
412
|
+
'',
|
|
413
|
+
'Examples:',
|
|
414
|
+
' canon tasks next-label',
|
|
415
|
+
' canon tasks next-label --json',
|
|
416
|
+
'',
|
|
417
|
+
].join('\n'),
|
|
418
|
+
)
|
|
419
|
+
.action(async (opts: NextLabelCommandOptions) => {
|
|
420
|
+
process.exitCode = await runNextLabel(opts)
|
|
421
|
+
})
|
|
381
422
|
}
|
|
382
423
|
|
|
383
424
|
function collectPosition(value: string, previous: string[]): string[] {
|
|
@@ -648,6 +689,50 @@ function reportOutcome(
|
|
|
648
689
|
return 0
|
|
649
690
|
}
|
|
650
691
|
|
|
692
|
+
async function runNextLabel(opts: NextLabelCommandOptions): Promise<number> {
|
|
693
|
+
const root = opts.root ?? (await mainWorktreeRoot())
|
|
694
|
+
const outcome = await nextLabel(root)
|
|
695
|
+
|
|
696
|
+
return reportNextLabel(outcome, opts.json ?? false, root)
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
function reportNextLabel(
|
|
700
|
+
outcome: LabelOutcome,
|
|
701
|
+
emitJson: boolean,
|
|
702
|
+
root: string,
|
|
703
|
+
): number {
|
|
704
|
+
if (!outcome.ok) {
|
|
705
|
+
if (emitJson) {
|
|
706
|
+
process.stdout.write(
|
|
707
|
+
`${JSON.stringify({ ok: false, reason: outcome.reason, message: outcome.message })}\n`,
|
|
708
|
+
)
|
|
709
|
+
return 1
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
intro('canon tasks next-label')
|
|
713
|
+
logStep('Refused')
|
|
714
|
+
logError(outcome.message)
|
|
715
|
+
outro()
|
|
716
|
+
return 1
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
if (emitJson) {
|
|
720
|
+
process.stdout.write(`${JSON.stringify({ ...outcome, root })}\n`)
|
|
721
|
+
return 0
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
intro('canon tasks next-label')
|
|
725
|
+
logStep(outcome.label)
|
|
726
|
+
logInfo(
|
|
727
|
+
outcome.highest
|
|
728
|
+
? `next after ${outcome.highest}.`
|
|
729
|
+
: 'the board and its archive hold no label yet.',
|
|
730
|
+
)
|
|
731
|
+
outro()
|
|
732
|
+
|
|
733
|
+
return 0
|
|
734
|
+
}
|
|
735
|
+
|
|
651
736
|
async function runValidate(opts: ValidateCommandOptions): Promise<number> {
|
|
652
737
|
const root = opts.root ?? (await mainWorktreeRoot())
|
|
653
738
|
const outcome = await validateBoard(root)
|
|
@@ -887,6 +972,17 @@ function reportValidation(
|
|
|
887
972
|
} else {
|
|
888
973
|
for (const claim of outcome.claims) logWarn(describeClaim(claim))
|
|
889
974
|
}
|
|
975
|
+
|
|
976
|
+
// Unplaced is the normal state between a session filing a task and an
|
|
977
|
+
// orchestrator placing its row, so it moves no exit code. It still reports,
|
|
978
|
+
// since it is the only local detector for a row a hand-edit dropped or a
|
|
979
|
+
// handoff that never arrived.
|
|
980
|
+
logStep('Unplaced')
|
|
981
|
+
if (outcome.unplaced.length === 0) {
|
|
982
|
+
logInfo('every task file sits on the board or the backlog')
|
|
983
|
+
} else {
|
|
984
|
+
for (const task of outcome.unplaced) logWarn(describeUnplaced(task))
|
|
985
|
+
}
|
|
890
986
|
outro()
|
|
891
987
|
}
|
|
892
988
|
|
|
@@ -901,6 +997,7 @@ function reportValidation(
|
|
|
901
997
|
findings: outcome.findings,
|
|
902
998
|
untested: outcome.untested,
|
|
903
999
|
claims: outcome.claims,
|
|
1000
|
+
unplaced: outcome.unplaced,
|
|
904
1001
|
})}\n`,
|
|
905
1002
|
)
|
|
906
1003
|
}
|
|
@@ -921,6 +1018,10 @@ function describeClaim(claim: FolderClaim): string {
|
|
|
921
1018
|
return `${claim.group}: ${claim.subject} ${claim.message}`
|
|
922
1019
|
}
|
|
923
1020
|
|
|
1021
|
+
function describeUnplaced(task: Unplaced): string {
|
|
1022
|
+
return `${task.subject} ${task.message}`
|
|
1023
|
+
}
|
|
1024
|
+
|
|
924
1025
|
async function runArchive(
|
|
925
1026
|
task: string | undefined,
|
|
926
1027
|
opts: ArchiveCommandOptions,
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
import { relative } from 'node:path'
|
|
3
|
+
import { archiveDir, listTaskStems, tasksDir } from '@/tasks/archive'
|
|
4
|
+
|
|
5
|
+
/** Every label in the corpus today stops here before rolling to the next major. */
|
|
6
|
+
const MINOR_ROLLOVER = 9
|
|
7
|
+
|
|
8
|
+
/** The label a board with no live or archived task yet allocates first. */
|
|
9
|
+
const FIRST_LABEL: Label = { major: 1, minor: 0 }
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Matches a task filename stem's leading label. Anchored, so a sibling such as
|
|
13
|
+
* `TASK-ARCHIVE` fails it outright, and a reserved stem such as `index` or
|
|
14
|
+
* `priority` never reaches it at all, since `listTaskStems` already filters
|
|
15
|
+
* those out before this pattern sees a stem.
|
|
16
|
+
*/
|
|
17
|
+
export const LABEL_PATTERN = /^v(\d+)\.(\d+)-/
|
|
18
|
+
|
|
19
|
+
interface Label {
|
|
20
|
+
readonly major: number
|
|
21
|
+
readonly minor: number
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface NextLabel {
|
|
25
|
+
readonly ok: true
|
|
26
|
+
readonly label: string
|
|
27
|
+
/** The label this run was derived from, absent when neither folder holds one. */
|
|
28
|
+
readonly highest: string | undefined
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface LabelRefused {
|
|
32
|
+
readonly ok: false
|
|
33
|
+
readonly reason: 'no-board'
|
|
34
|
+
readonly message: string
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export type LabelOutcome = NextLabel | LabelRefused
|
|
38
|
+
|
|
39
|
+
function parseLabel(stem: string): Label | undefined {
|
|
40
|
+
const match = LABEL_PATTERN.exec(stem)
|
|
41
|
+
if (!match) return undefined
|
|
42
|
+
|
|
43
|
+
return { major: Number(match[1]), minor: Number(match[2]) }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function isHigher(candidate: Label, current: Label): boolean {
|
|
47
|
+
return candidate.major !== current.major
|
|
48
|
+
? candidate.major > current.major
|
|
49
|
+
: candidate.minor > current.minor
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function formatLabel(label: Label): string {
|
|
53
|
+
return `v${String(label.major).padStart(2, '0')}.${label.minor}`
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The label after the one given, rolling a minor of 9 to the next major rather
|
|
58
|
+
* than continuing to a second minor digit. Every one of the 587 labels measured
|
|
59
|
+
* across the live board and its archive on 2026-09-06 stops at a single digit,
|
|
60
|
+
* so this is the rollover the whole corpus already follows rather than a rule
|
|
61
|
+
* this verb introduces.
|
|
62
|
+
*/
|
|
63
|
+
function next(label: Label): Label {
|
|
64
|
+
return label.minor >= MINOR_ROLLOVER
|
|
65
|
+
? { major: label.major + 1, minor: 0 }
|
|
66
|
+
: { major: label.major, minor: label.minor + 1 }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Reports the next unused phase label, read off the true maximum across
|
|
71
|
+
* `.canon/tasks/` and its `archive/` sibling together. A scan confined to the
|
|
72
|
+
* live board is blind to every label the archive already spent, which is what
|
|
73
|
+
* let two sessions hand out the same label within minutes of each other.
|
|
74
|
+
*
|
|
75
|
+
* It reports and never writes. Two sessions calling it in the same second can
|
|
76
|
+
* still take the same answer, since the board is gitignored files rather than
|
|
77
|
+
* a store with a lock, and `standards/versioning.md` permits free renumbering,
|
|
78
|
+
* so a collision costs a rename rather than anything worse. A duplicate label
|
|
79
|
+
* already sitting in the tree, and a gap left by a renumbering, both fold into
|
|
80
|
+
* the same max scan without needing a dedicated check.
|
|
81
|
+
*/
|
|
82
|
+
export async function nextLabel(root: string): Promise<LabelOutcome> {
|
|
83
|
+
const dir = tasksDir(root)
|
|
84
|
+
|
|
85
|
+
if (!existsSync(dir)) {
|
|
86
|
+
return {
|
|
87
|
+
ok: false,
|
|
88
|
+
reason: 'no-board',
|
|
89
|
+
message: `No task board at ${relative(root, dir)}.`,
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const archive = archiveDir(root)
|
|
94
|
+
const dirs = existsSync(archive) ? [dir, archive] : [dir]
|
|
95
|
+
const stems = (await Promise.all(dirs.map((d) => listTaskStems(d)))).flat()
|
|
96
|
+
|
|
97
|
+
const highest = stems
|
|
98
|
+
.map(parseLabel)
|
|
99
|
+
.filter((label): label is Label => label !== undefined)
|
|
100
|
+
.reduce<Label | undefined>(
|
|
101
|
+
(max, label) => (max === undefined || isHigher(label, max) ? label : max),
|
|
102
|
+
undefined,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
ok: true,
|
|
107
|
+
label: formatLabel(highest === undefined ? FIRST_LABEL : next(highest)),
|
|
108
|
+
highest: highest === undefined ? undefined : formatLabel(highest),
|
|
109
|
+
}
|
|
110
|
+
}
|
package/src/tasks/validate.ts
CHANGED
|
@@ -38,7 +38,6 @@ export const FINDING_KINDS = [
|
|
|
38
38
|
'plan-uncited',
|
|
39
39
|
'plan-mismatched',
|
|
40
40
|
'task-unresolved',
|
|
41
|
-
'row-missing',
|
|
42
41
|
'row-duplicated',
|
|
43
42
|
'row-misshapen',
|
|
44
43
|
'row-untabled',
|
|
@@ -83,6 +82,18 @@ export interface FolderClaim {
|
|
|
83
82
|
readonly message: string
|
|
84
83
|
}
|
|
85
84
|
|
|
85
|
+
/**
|
|
86
|
+
* A task file neither surface names. That is the normal state between a
|
|
87
|
+
* session filing it and a live orchestrator placing it, so it reports beside
|
|
88
|
+
* the findings and moves no exit code. It is still the only local detector for
|
|
89
|
+
* a row lost to a hand-edit, a handoff message that never arrived, or an
|
|
90
|
+
* orchestrator that ended before placing it.
|
|
91
|
+
*/
|
|
92
|
+
export interface Unplaced {
|
|
93
|
+
readonly subject: string
|
|
94
|
+
readonly message: string
|
|
95
|
+
}
|
|
96
|
+
|
|
86
97
|
/**
|
|
87
98
|
* A backlog line, which carries a pointer and nothing else. The backlog is
|
|
88
99
|
* explicitly unordered, so a line has no position to read and no columns to
|
|
@@ -116,6 +127,7 @@ export interface ValidateReport {
|
|
|
116
127
|
readonly findings: readonly Finding[]
|
|
117
128
|
readonly untested: readonly Untested[]
|
|
118
129
|
readonly claims: readonly FolderClaim[]
|
|
130
|
+
readonly unplaced: readonly Unplaced[]
|
|
119
131
|
}
|
|
120
132
|
|
|
121
133
|
export interface ValidateRefused {
|
|
@@ -556,15 +568,15 @@ function resolves(target: string, dir: string, root: string): boolean {
|
|
|
556
568
|
}
|
|
557
569
|
|
|
558
570
|
/**
|
|
559
|
-
*
|
|
560
|
-
*
|
|
561
|
-
*
|
|
562
|
-
*
|
|
571
|
+
* Reports a task cited from two places on the board, which claims two
|
|
572
|
+
* contradictory things about itself: a task belongs to exactly one group,
|
|
573
|
+
* board or backlog. A task file neither surface names is no longer reported
|
|
574
|
+
* here, since that is the normal state between a session filing it and a live
|
|
575
|
+
* orchestrator placing it.
|
|
563
576
|
*/
|
|
564
577
|
function checkMapping(
|
|
565
578
|
rows: readonly BoardRow[],
|
|
566
579
|
backlog: readonly BacklogRow[],
|
|
567
|
-
stems: readonly string[],
|
|
568
580
|
dir: string,
|
|
569
581
|
): Finding[] {
|
|
570
582
|
const findings: Finding[] = []
|
|
@@ -638,18 +650,6 @@ function checkMapping(
|
|
|
638
650
|
}
|
|
639
651
|
}
|
|
640
652
|
|
|
641
|
-
for (const stem of stems) {
|
|
642
|
-
if (!seen.has(stem) && !listed.has(stem)) {
|
|
643
|
-
findings.push({
|
|
644
|
-
kind: 'row-missing',
|
|
645
|
-
group: undefined,
|
|
646
|
-
subject: stem,
|
|
647
|
-
message:
|
|
648
|
-
'is a task file with no row on the board and no line on the backlog.',
|
|
649
|
-
})
|
|
650
|
-
}
|
|
651
|
-
}
|
|
652
|
-
|
|
653
653
|
return findings
|
|
654
654
|
}
|
|
655
655
|
|
|
@@ -878,6 +878,32 @@ function checkFolderClaims(
|
|
|
878
878
|
return claims
|
|
879
879
|
}
|
|
880
880
|
|
|
881
|
+
/**
|
|
882
|
+
* Reports a task file neither the board nor the backlog names. Under the
|
|
883
|
+
* roster-checked hand-off, filing a task and placing its row are two acts a
|
|
884
|
+
* different session each may perform, so this state is ordinary rather than
|
|
885
|
+
* an error, and it moves no exit code. It still surfaces here rather than
|
|
886
|
+
* nowhere, since it is the only local detector for a row a hand-edit dropped,
|
|
887
|
+
* a handoff message that never arrived, or an orchestrator that ended before
|
|
888
|
+
* placing it.
|
|
889
|
+
*/
|
|
890
|
+
function checkUnplaced(
|
|
891
|
+
rows: readonly BoardRow[],
|
|
892
|
+
backlog: readonly BacklogRow[],
|
|
893
|
+
stems: readonly string[],
|
|
894
|
+
): Unplaced[] {
|
|
895
|
+
const seen = new Set(rows.flatMap((row) => (row.stem ? [row.stem] : [])))
|
|
896
|
+
const listed = new Set(backlog.flatMap((row) => (row.stem ? [row.stem] : [])))
|
|
897
|
+
|
|
898
|
+
return stems
|
|
899
|
+
.filter((stem) => !seen.has(stem) && !listed.has(stem))
|
|
900
|
+
.map((stem) => ({
|
|
901
|
+
subject: stem,
|
|
902
|
+
message:
|
|
903
|
+
'is a task file with no row on the board and no line on the backlog.',
|
|
904
|
+
}))
|
|
905
|
+
}
|
|
906
|
+
|
|
881
907
|
/**
|
|
882
908
|
* Reads the task a blocker cell cites. A task pointer is a bare sibling
|
|
883
909
|
* filename, the way every `Task` column spells one, so a target carrying a
|
|
@@ -1136,7 +1162,7 @@ export async function validateBoard(
|
|
|
1136
1162
|
|
|
1137
1163
|
const findings = [
|
|
1138
1164
|
...shapeFindings,
|
|
1139
|
-
...checkMapping(rows, backlog,
|
|
1165
|
+
...checkMapping(rows, backlog, dir),
|
|
1140
1166
|
...checkPlans(rows, dir, root),
|
|
1141
1167
|
...(await checkPlanAgreement(rows, dir, root)),
|
|
1142
1168
|
...checkCollisions(rows),
|
|
@@ -1152,5 +1178,6 @@ export async function validateBoard(
|
|
|
1152
1178
|
findings,
|
|
1153
1179
|
untested: parked.untested,
|
|
1154
1180
|
claims: checkFolderClaims(rows, root),
|
|
1181
|
+
unplaced: checkUnplaced(rows, backlog, stems),
|
|
1155
1182
|
}
|
|
1156
1183
|
}
|
|
@@ -59,6 +59,12 @@ Append to the `## Scripts` table:
|
|
|
59
59
|
|
|
60
60
|
In `.claude/context/ci.md`, the Typecheck row's assertion reads: `` `astro check` passes ``. The Build row's assertion reads: `` `astro build` succeeds ``.
|
|
61
61
|
|
|
62
|
+
## Gitignore (extend)
|
|
63
|
+
|
|
64
|
+
`[gitignore]` groups this stack edits, restated here per the manifest-to-reference symmetry:
|
|
65
|
+
|
|
66
|
+
- `"# Astro" = [".astro/"]`
|
|
67
|
+
|
|
62
68
|
## Scenario switcher
|
|
63
69
|
|
|
64
70
|
- `src/components/dev/scenarios.astro` ships as a golden config, always overwritten on sync, since it is toolkit-authored infrastructure rather than a file a project hand-edits.
|
|
@@ -17,3 +17,6 @@ packages = [
|
|
|
17
17
|
[scripts.override]
|
|
18
18
|
"dev" = "WORKTREE_PORT_OFFSET=$(bash scripts/worktree-port.sh) && export WORKTREE_PORT_OFFSET && next dev --port $((3000 + WORKTREE_PORT_OFFSET))"
|
|
19
19
|
"screenshot" = "PREVIEW_PORT=$(bash scripts/worktree-port.sh 3000) && export PREVIEW_PORT && bash scripts/screenshot.sh"
|
|
20
|
+
|
|
21
|
+
[gitignore]
|
|
22
|
+
"# Next" = [".next/"]
|
|
@@ -33,6 +33,12 @@ Next has no config-file port hook, unlike `astro.config.mjs`'s `server.port` or
|
|
|
33
33
|
|
|
34
34
|
`create-next-app`'s own default `tsconfig.json` needs no changes beyond project-specific path aliases, so a golden copy here would ship nothing the scaffold does not already write.
|
|
35
35
|
|
|
36
|
+
## Gitignore (extend)
|
|
37
|
+
|
|
38
|
+
`[gitignore]` groups this stack edits, restated here per the manifest-to-reference symmetry:
|
|
39
|
+
|
|
40
|
+
- `"# Next" = [".next/"]`
|
|
41
|
+
|
|
36
42
|
## Development docs (extend)
|
|
37
43
|
|
|
38
44
|
Append to the `## Scripts` table:
|
|
@@ -9,11 +9,19 @@ interface CaptureCase {
|
|
|
9
9
|
route: string
|
|
10
10
|
width: number
|
|
11
11
|
height: number
|
|
12
|
+
evidence?: boolean
|
|
12
13
|
setup?: (page: Page) => Promise<void>
|
|
13
14
|
}
|
|
14
15
|
|
|
15
16
|
const CASES: CaptureCase[] = [
|
|
16
|
-
{
|
|
17
|
+
{
|
|
18
|
+
section: 'home',
|
|
19
|
+
theme: 'default',
|
|
20
|
+
route: '/',
|
|
21
|
+
width: 1280,
|
|
22
|
+
height: 800,
|
|
23
|
+
evidence: true,
|
|
24
|
+
},
|
|
17
25
|
{
|
|
18
26
|
section: 'home',
|
|
19
27
|
theme: 'dark',
|
|
@@ -47,8 +55,12 @@ const OUT_DIR = path.join('screenshots', hostname)
|
|
|
47
55
|
|
|
48
56
|
const browser = await chromium.launch()
|
|
49
57
|
const consoleErrors: string[] = []
|
|
58
|
+
let ranCases = 0
|
|
50
59
|
|
|
51
60
|
for (const captureCase of CASES) {
|
|
61
|
+
if (captureCase.evidence && requireBaseUrl) continue
|
|
62
|
+
|
|
63
|
+
ranCases++
|
|
52
64
|
const context = await browser.newContext({
|
|
53
65
|
viewport: { width: captureCase.width, height: captureCase.height },
|
|
54
66
|
})
|
|
@@ -76,11 +88,26 @@ for (const captureCase of CASES) {
|
|
|
76
88
|
await page.screenshot({ path: file, fullPage: true })
|
|
77
89
|
console.log(`captured ${file}`)
|
|
78
90
|
|
|
91
|
+
if (captureCase.evidence) {
|
|
92
|
+
const evidenceDir = path.join('evidence', captureCase.section)
|
|
93
|
+
await mkdir(evidenceDir, { recursive: true })
|
|
94
|
+
const evidenceFile = path.join(evidenceDir, `${captureCase.theme}.png`)
|
|
95
|
+
await page.screenshot({ path: evidenceFile, fullPage: true })
|
|
96
|
+
console.log(`captured ${evidenceFile}`)
|
|
97
|
+
}
|
|
98
|
+
|
|
79
99
|
await context.close()
|
|
80
100
|
}
|
|
81
101
|
|
|
82
102
|
await browser.close()
|
|
83
103
|
|
|
104
|
+
if (requireBaseUrl && ranCases === 0) {
|
|
105
|
+
console.error(
|
|
106
|
+
'every CASES entry is flagged evidence: true, so --require-base-url skipped all of them and checked nothing',
|
|
107
|
+
)
|
|
108
|
+
process.exit(1)
|
|
109
|
+
}
|
|
110
|
+
|
|
84
111
|
if (checkConsoleClean && consoleErrors.length > 0) {
|
|
85
112
|
console.error('console errors detected:')
|
|
86
113
|
for (const error of consoleErrors) console.error(` ${error}`)
|
|
@@ -57,4 +57,5 @@ packages = [
|
|
|
57
57
|
"# Build" = ["dist/"]
|
|
58
58
|
"# Coverage" = ["coverage/"]
|
|
59
59
|
"# Playwright" = ["test-results/", "playwright-report/", "blob-report/", "playwright/.cache/"]
|
|
60
|
+
"# Screenshots" = ["screenshots/"]
|
|
60
61
|
"# VSCode" = [".vscode/*", "!.vscode/extensions.json", "!.vscode/settings.json"]
|
package/tooling/web/reference.md
CHANGED
|
@@ -12,7 +12,7 @@ Golden config files live in `tooling/web/configs/` and are copied into the targe
|
|
|
12
12
|
|
|
13
13
|
- `eslint.config.js`: flat config with `@eslint/js`, `typescript-eslint`, React hooks, import sort, check-file, vitest rules scoped to test files, `eslint-config-prettier` last.
|
|
14
14
|
- `src/test/setup.ts`: `@testing-library/jest-dom` import, `cleanup` after each test.
|
|
15
|
-
- `e2e/screenshot.ts`: capture template. A single `CASES` record at the top carries one entry per output file, each naming a section, a theme, a route, and its own viewport, and the loop below writes `screenshots/<hostname>/<section>/<theme>.png`, keyed on `SCREENSHOT_BASE_URL`'s hostname so a local and a deployed run land in different folders. Per-project cases extend the one record. A route's themes sit together under its section folder, so the filename carries the theme alone. `--check-console-clean` collects `console`-level error messages per case and exits 1 with the list if any fired, turning the capture into a smoke check. `--require-base-url` exits 1 before launching a browser when `SCREENSHOT_BASE_URL` is unset, guarding a script meant to run against a real deployment from silently capturing `localhost`.
|
|
15
|
+
- `e2e/screenshot.ts`: capture template. A single `CASES` record at the top carries one entry per output file, each naming a section, a theme, a route, and its own viewport, and the loop below writes `screenshots/<hostname>/<section>/<theme>.png`, keyed on `SCREENSHOT_BASE_URL`'s hostname so a local and a deployed run land in different folders. A case flagged `evidence: true` additionally writes `evidence/<section>/<theme>.png`, with no hostname segment. `--require-base-url` skips a flagged case entirely, so a production smoke run neither writes to the committed path nor counts an evidence route in its console-clean check, and exits 1 when that leaves zero cases run, so flagging every case cannot silence the production check without saying so. Per-project cases extend the one record. A route's themes sit together under its section folder, so the filename carries the theme alone. `--check-console-clean` collects `console`-level error messages per case and exits 1 with the list if any fired, turning the capture into a smoke check. `--require-base-url` exits 1 before launching a browser when `SCREENSHOT_BASE_URL` is unset, guarding a script meant to run against a real deployment from silently capturing `localhost`.
|
|
16
16
|
- `.vscode/extensions.json` and `.vscode/settings.json`: editor wiring for ESLint, Tailwind, Playwright, Vitest.
|
|
17
17
|
- `.github/workflows/verify.yml`: `static-checks`, `unit-tests`, `build-verify`, and `e2e-tests` jobs.
|
|
18
18
|
- `scripts/verify.sh`: extends base verify with typecheck, lint, unit tests, and build in the full order.
|
|
@@ -112,13 +112,14 @@ Append rows:
|
|
|
112
112
|
|
|
113
113
|
`governance/rules/ui/440-surface-capture.md` is what asks a session to run the capture after a route changes. It fires on route and page files rather than on every component, so a shared component changing every screen fires nothing and the operator runs the capture by hand.
|
|
114
114
|
|
|
115
|
-
The
|
|
115
|
+
The sweep under `screenshots/` is ignored again, and only a flagged case's `evidence/` output tracks in git, so the first capture a scaffolded target runs after this change is the baseline it commits there.
|
|
116
116
|
|
|
117
117
|
## Gitignore (extend)
|
|
118
118
|
|
|
119
119
|
`[gitignore]` groups this stack edits, restated here per the manifest-to-reference symmetry:
|
|
120
120
|
|
|
121
121
|
- `"# Playwright" = ["test-results/", "playwright-report/", "blob-report/", "playwright/.cache/"]`
|
|
122
|
+
- `"# Screenshots" = ["screenshots/"]`
|
|
122
123
|
|
|
123
124
|
## Verify script
|
|
124
125
|
|