@erclx/canon 5.12.0 → 5.13.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.
Files changed (45) hide show
  1. package/claude/.claude-plugin/plugin.json +1 -1
  2. package/claude/skills/api-design/REQUIREMENT.md +1 -1
  3. package/claude/skills/api-design/SKILL.md +1 -1
  4. package/claude/skills/api-design/references/adopted.md +1 -1
  5. package/claude/skills/auto-ship/REQUIREMENT.md +1 -0
  6. package/claude/skills/auto-ship/SKILL.md +2 -2
  7. package/claude/skills/build-in-slices/REQUIREMENT.md +45 -0
  8. package/claude/skills/build-in-slices/SKILL.md +94 -0
  9. package/claude/skills/build-in-slices/references/adopted.md +41 -0
  10. package/claude/skills/code-craft/REQUIREMENT.md +48 -0
  11. package/claude/skills/code-craft/SKILL.md +107 -0
  12. package/claude/skills/code-craft/references/adopted.md +52 -0
  13. package/claude/skills/codebase-layout/REQUIREMENT.md +1 -1
  14. package/claude/skills/git-ship/REQUIREMENT.md +1 -0
  15. package/claude/skills/git-ship/SKILL.md +1 -1
  16. package/claude/skills/review-craft/REQUIREMENT.md +3 -0
  17. package/claude/skills/review-craft/SKILL.md +1 -1
  18. package/claude/skills/review-craft/references/adopted.md +8 -0
  19. package/claude/skills/review-craft/references/security.md +5 -0
  20. package/claude/skills/review-ui/REQUIREMENT.md +1 -0
  21. package/claude/skills/review-ui/SKILL.md +11 -0
  22. package/claude/skills/role-worker/REQUIREMENT.md +1 -0
  23. package/claude/skills/role-worker/SKILL.md +4 -0
  24. package/claude/skills/test-first/REQUIREMENT.md +3 -0
  25. package/claude/skills/test-first/SKILL.md +2 -1
  26. package/docs/agents/pr-preview.md +40 -12
  27. package/docs/workflow/skill-map.md +2 -0
  28. package/governance/rules/code/000-code.md +5 -1
  29. package/package.json +1 -1
  30. package/src/claude/cases/misc.ts +10 -0
  31. package/src/claude/skills-audit.ts +2 -0
  32. package/src/commands/pr.ts +107 -7
  33. package/src/commands/tooling.ts +39 -16
  34. package/src/pr/preview.ts +66 -0
  35. package/src/tooling/manifest.ts +16 -0
  36. package/src/tooling/verify-stack.ts +249 -0
  37. package/tooling/base/seeds/.cspell/tech-stack.txt +2 -0
  38. package/tooling/go/manifest.toml +3 -0
  39. package/tooling/go/reference.md +1 -0
  40. package/tooling/php/manifest.toml +3 -0
  41. package/tooling/php/reference.md +1 -0
  42. package/tooling/python/manifest.toml +3 -0
  43. package/tooling/python/reference.md +1 -1
  44. package/tooling/python/seeds/.cspell/tech-stack.txt +0 -1
  45. package/scripts/tooling/verify.sh +0 -184
@@ -21,6 +21,7 @@ rather than inside it, so the two passes finish on their own clocks.
21
21
  - No address resolves under Step 3: stop and post nothing, `❌ No address to drive. PR #<number> carries no preview and no local preview, and the launch named none.`
22
22
  - The wrapper refuses to open the browser because its revision is absent: stop and post nothing, passing its install command to whoever launched the pass. Never install mid-pass.
23
23
  - The newest UI verdict is `## UI review closed` at the current head: stop, `The UI review already covers <short-sha>.` An open verdict at the head stops the same way only when nothing has arrived since it: drive again when a `## Review response` or `## Post-review findings` comment is newer than the verdict, or the launch names an address.
24
+ - The hosted preview was not built from the head: stop and post nothing, naming `built`, `tip`, and `canon pr preview <number> --json` for the dispatcher. A verdict posted against an older build carries a marker naming a head nobody drove.
24
25
  - Post and stop. Never merge, never tick a box on the evidence comment, and never lift the draft mark.
25
26
 
26
27
  ## Step 1: resolve the pull request
@@ -55,6 +56,16 @@ Take the first rung that answers, testing each with `curl -s -o /dev/null -w '%{
55
56
  2. The `local` address off the record.
56
57
  3. An address the launch names.
57
58
 
59
+ When the hosted rung answers, check that it was built from the head before driving it:
60
+
61
+ ```bash
62
+ canon pr preview <number> --check --json
63
+ ```
64
+
65
+ Branch on the record's `reason`. Only `fresh` continues. Any other stops with nothing posted, naming `built`, `tip`, and the next act: `stale` and `no-build` ask the dispatcher to run `canon pr preview <number> --json`, and `building` asks it to wait, since a mint is already running for the tip. This pass never mints. A `canon` that rejects `--check` predates the verb. Report that this pass needs a `canon` whose `pr preview` takes `--check`, naming the installed `canon --version`, and stop rather than reading the missing flag as `fresh`, the way Step 2 does for its own older binaries. A `reason` of `no-deploy`, `unfenced`, or `no-alias` means the project has no fenced deploy to read, so the hosted build cannot be verified. Stop on the hosted address then too, and say so.
66
+
67
+ The check covers the hosted address only. The local rung and an address the launch names have no deploy run to read, so say in the comment that they were driven unverified rather than implying the same coverage.
68
+
58
69
  An address that exists and does not answer is a different outcome from no address at all. When every rung that exists fails to answer, skip the drive, record each box as not driven with the addresses tried and what each returned, and go to Step 7. A local address on another machine is the usual cause, and it says nothing about the change.
59
70
 
60
71
  ## Step 4: open the browser
@@ -52,5 +52,6 @@ The draft mark fails from both ends. On 2026-08-31 four sessions re-drafted a pu
52
52
 
53
53
  - Entering the worktree, which `session-worktree` owns
54
54
  - The chain from implement through pull request, which `auto-ship` owns
55
+ - How the build moves through the plan, slice by slice with a commit each, which `build-in-slices` owns
55
56
  - Answering a posted review, which `review-address` owns
56
57
  - The controlling session's half of the channel, the review poll, and the watch beside it, which `role-orchestrator` and its runbooks hold
@@ -20,6 +20,10 @@ from the skill that owns it and invoke none of the three from here. The ordinary
20
20
  path arrives through `auto-ship` Step 0, which means that chain is already
21
21
  running and re-invoking it would restart the build.
22
22
 
23
+ This body holds what the session may write. How it builds, in committed slices
24
+ with an out-of-scope finding noted rather than fixed, is `build-in-slices`,
25
+ which `auto-ship` Step 2 loads.
26
+
23
27
  ## Where the session stands
24
28
 
25
29
  - Resolve `.canon/plans/`, `.canon/tasks/`, `.canon/review/`, `.canon/memory/`, and `.canon/ready/` at the main worktree root, never against the linked worktree this session builds in. Those folders are gitignored, so `git worktree add` never creates them and the copy beside the build is absent rather than empty.
@@ -14,6 +14,7 @@ Without this skill, a session implementing a planned change writes the test afte
14
14
  - Write or extend the test before the implementation it covers, for any behavior whose shape is already known
15
15
  - Run the test before implementing and confirm it fails for the missing behavior, not for an unrelated mistake in the test itself
16
16
  - Implement the minimum the current test demands, and start a new test before extending past it
17
+ - Load `canon:code-craft` before writing the implementation when its shape is still open and the session has not loaded the skill, so the shape of the code is settled before the body rather than during refactor
17
18
  - Re-run the test after implementing and confirm the pass is the one the test was written to prove
18
19
  - Refactor the implementation and the test once the suite is green, re-running it after each change, since the smallest change that passes leaves duplication and shortcuts the loop exists to remove before they reach history
19
20
 
@@ -33,4 +34,6 @@ Without this skill, a session implementing a planned change writes the test afte
33
34
  - Choosing the layer a test belongs at and judging whether it is a good test: `canon:test-craft`, loaded at step 1 rather than restated here
34
35
  - Finding the cause of an unexplained failure: `canon:systematic-debugging`
35
36
  - Confirming visual output after a change lands: `567-planning.md` states the order and `ui-checklist` writes the list to confirm against
37
+ - Shaping the function, class, or module the implementation writes: `canon:code-craft`, loaded at step 4 rather than restated here
38
+ - Committing the behavior as a slice and ordering the slices of a larger build: `canon:build-in-slices`
36
39
  - The mechanical audit of whether an implementation reached history ahead of its test: `canon gov test-order`, invoked from `auto-ship`
@@ -10,7 +10,7 @@ description: Write the failing test for a behavior before writing the code that
10
10
  1. Load `canon:test-craft` and pick the layer the test belongs at before writing it. Then find or write the test at that layer before touching the code it covers. Follow the pairing convention the project already uses, which is commonly a test sitting beside its subject under one name, so that `foo.ts` takes `foo.test.ts` beside it. Report it rather than proceeding silently when `canon:test-craft` does not resolve, and write the test at the smallest layer that can observe the behavior.
11
11
  2. Write the test against the behavior as it should exist once the change lands, naming the case for what it proves rather than for the function it calls.
12
12
  3. Run only that test and read the failure. Confirm it fails because the behavior is missing, not because of a typo, a missing import, or a signature the test itself got wrong. A test failing for the wrong reason still reads green the moment any code exists to satisfy that wrong reason, so treat any other failure as a defect in the test and fix the test before moving on.
13
- 4. Implement the smallest change that turns the failure into a pass. Do not add behavior the test does not ask for. A second behavior wants its own test written first, not a shortcut folded into the change already open.
13
+ 4. Load `canon:code-craft` and settle the shape of the code before writing it, unless the shape is already fixed or this session already loaded the skill. Report it rather than proceeding silently when `canon:code-craft` does not resolve, and write the plainest code the test asks for. Then implement the smallest change that turns the failure into a pass. Do not add behavior the test does not ask for. A second behavior wants its own test written first, not a shortcut folded into the change already open.
14
14
  5. Run the test again and confirm it passes for the reason it was written for, not by accident. Then run the surrounding suite so a change made to satisfy this test has not broken another.
15
15
  6. Refactor with the suite green. Remove the duplication and the shortcuts step 4 left, in the implementation and in the test, without changing what either does. Run the suite after each change, and undo a change that turns anything red rather than fixing forward.
16
16
 
@@ -23,4 +23,5 @@ description: Write the failing test for a behavior before writing the code that
23
23
 
24
24
  - Which layer a test belongs at and what makes it a good one. `canon:test-craft` owns that judgment, and step 1 loads it.
25
25
  - A failure with no known cause. Route through `canon:systematic-debugging` first, whose own fix phase already writes the reproducing test as part of finding the cause. This skill starts once the shape of the fix or the feature is already decided.
26
+ - Committing the green, refactored behavior as one slice of a larger build. `canon:build-in-slices` owns the commit and the order slices land in.
26
27
  - Confirming a change looks right once it has landed, which is a separate concern from whether the test passed and runs after implementation rather than before it.
@@ -14,6 +14,7 @@ rather than screenshots and a checklist alone.
14
14
  ```bash
15
15
  canon pr preview --json
16
16
  canon pr preview 1341 --json --timeout 20
17
+ canon pr preview 1341 --check --json
17
18
  ```
18
19
 
19
20
  ## Which workflow it dispatches
@@ -61,22 +62,49 @@ keeps the match independent of the local clock.
61
62
 
62
63
  `reason` on the record is what a caller branches on, not the exit code:
63
64
 
64
- | Reason | What it means |
65
- | ------------- | -------------------------------------------------------------------------- |
66
- | `ok` | The preview was published. `url` carries the address and `runId` the run. |
67
- | `no-deploy` | No dispatchable workflow runs `pages deploy`. |
68
- | `unfenced` | The deploy passes no `--branch`, so nothing was dispatched. |
69
- | `no-alias` | The workflow prints no alias line, or the finished run's log carried none. |
70
- | `run-failed` | The deploy run finished without succeeding. `runId` names it. |
71
- | `timeout` | The run did not finish inside `--timeout` minutes, 15 by default. |
72
- | `bad-timeout` | `--timeout` was not a positive number. |
73
- | `gh-missing` | `gh` is not on the path. |
74
- | `gh-failed` | `gh` could not read the pull request, or list or dispatch the workflow. |
75
- | `no-branch` | The pull request carries no head branch name. |
65
+ | Reason | What it means |
66
+ | --------------- | -------------------------------------------------------------------------- |
67
+ | `ok` | The preview was published. `url` carries the address and `runId` the run. |
68
+ | `no-deploy` | No dispatchable workflow runs `pages deploy`. |
69
+ | `unfenced` | The deploy passes no `--branch`, so nothing was dispatched. |
70
+ | `no-alias` | The workflow prints no alias line, or the finished run's log carried none. |
71
+ | `run-failed` | The deploy run finished without succeeding. `runId` names it. |
72
+ | `timeout` | The run did not finish inside `--timeout` minutes, 15 by default. |
73
+ | `bad-timeout` | `--timeout` was not a positive number. |
74
+ | `gh-missing` | `gh` is not on the path. |
75
+ | `gh-failed` | `gh` could not read the pull request, or list or dispatch the workflow. |
76
+ | `no-branch` | The pull request carries no head branch name. |
77
+ | `check-timeout` | `--check` was combined with `--timeout`. |
76
78
 
77
79
  The exit is 0 on `ok` and 1 on every refusal. A `timeout` leaves the run
78
80
  going, so the preview may still land after the verb has given up on it.
79
81
 
82
+ ## Checking which head a preview was built from
83
+
84
+ `--check` dispatches nothing and waits on nothing. It lists the branch's
85
+ `workflow_dispatch` runs, takes the newest successful one by creation time, and
86
+ compares the head it ran at with the branch tip read from the remote. The tip
87
+ comes from the remote rather than the pull request object's `headRefOid`, which
88
+ lags a push. `--check` takes no `--timeout` and refuses one as `check-timeout`.
89
+
90
+ The alias serves the newest successful deployment for the branch and names no
91
+ sha, so the run listing is the only record of the built head. The
92
+ `workflow_dispatch` filter also drops the cleanup run a closed pull request
93
+ fires, which concludes `success` with its deploy skipped. A project that deploys
94
+ on `push` to a feature branch would need that filter widened.
95
+
96
+ | Reason | What it means |
97
+ | ---------- | --------------------------------------------------------------------- |
98
+ | `fresh` | The newest successful deploy built the tip. Exit 0. |
99
+ | `stale` | It built an earlier head. `built` and `tip` name both. Mint again. |
100
+ | `building` | A deploy of the tip is still running. Wait rather than minting again. |
101
+ | `no-build` | No deploy of the branch has succeeded. |
102
+
103
+ The record also carries `runId` for the deploy it read. The check covers the
104
+ hosted address only, so a local address has no run to read and stays unverified.
105
+ `review-ui` runs it before driving a hosted preview and posts nothing on any
106
+ reason but `fresh`.
107
+
80
108
  ## How the address reaches the pull request
81
109
 
82
110
  `canon pr evidence --preview <url>` puts `**Preview:** <url>` on the first line
@@ -45,7 +45,9 @@ This page is the corpus the coverage claim is measured against: every name `cano
45
45
  | `canon:project-commands` | When the project's own command needs running |
46
46
  | `canon:test-first` | Before implementing a planned change, to run its test red, green, then refactor |
47
47
  | `canon:test-craft` | When writing or changing any test, to pick its layer and filter what it asserts |
48
+ | `canon:build-in-slices` | While building a plan, to commit each tested slice and note out-of-scope defects rather than fix them |
48
49
  | `canon:api-design` | When changing what a caller sees, being an export, a flag, an output shape, or a retried write |
50
+ | `canon:code-craft` | Before writing a function, class, or module, to pick the shape the next change needs |
49
51
  | `canon:review-craft` | When reviewing a change, for what to look for and how much evidence a finding needs |
50
52
  | `canon:systematic-debugging` | When a test fails or a bug surfaces, to force root cause first |
51
53
  | `canon:design-taste` | When drafting or judging an interface, or when output reads generic, to settle which decision comes first |
@@ -1,5 +1,5 @@
1
1
  ---
2
- description: Code placement, dependency, error, and naming choices a model does not make by default
2
+ description: Code placement, shape, dependency, error, and naming choices a model does not make by default
3
3
  ---
4
4
 
5
5
  # Code standards
@@ -10,6 +10,10 @@ description: Code placement, dependency, error, and naming choices a model does
10
10
  - Prioritize native platform capabilities over third-party libraries.
11
11
  - Ensure data and configuration reside in designated Single Source of Truth locations.
12
12
 
13
+ ## Shape
14
+
15
+ - Load the `canon:code-craft` skill before deciding the shape of a new function, class, or module, and report it rather than proceeding silently when it does not resolve.
16
+
13
17
  ## Errors
14
18
 
15
19
  - Return structured error types for recoverable failures.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@erclx/canon",
3
3
  "type": "module",
4
- "version": "5.12.0",
4
+ "version": "5.13.0",
5
5
  "description": "Runs your agent's work and keeps its code good, on one setup for every repo.",
6
6
  "license": "MIT",
7
7
  "bin": {
@@ -74,6 +74,16 @@ export const MISC_CASES: readonly SkillCase[] = [
74
74
  "I'm adding a field to the JSON this command prints and renaming one flag, anything to watch?",
75
75
  expect: 'api-design',
76
76
  },
77
+ {
78
+ prompt:
79
+ 'The same discount rule sits in two files and I need a third variant. Should I extract it, or add a strategy class for the two cases?',
80
+ expect: 'code-craft',
81
+ },
82
+ {
83
+ prompt:
84
+ "I've got the plan approved and it touches the parser, the command, and the docs. How should I build it so I don't end up with one giant diff?",
85
+ expect: 'build-in-slices',
86
+ },
77
87
  {
78
88
  prompt: 'Does our github about text still match what the readme says?',
79
89
  expect: 'repo-metadata',
@@ -37,6 +37,8 @@ export const PRACTICE_SKILLS: readonly string[] = [
37
37
  join(SHIPPED_CORPUS, 'review-craft'),
38
38
  join(SHIPPED_CORPUS, 'systematic-debugging'),
39
39
  join(SHIPPED_CORPUS, 'api-design'),
40
+ join(SHIPPED_CORPUS, 'code-craft'),
41
+ join(SHIPPED_CORPUS, 'build-in-slices'),
40
42
  ]
41
43
 
42
44
  /** The closing H2s a practice skill carries, matched exactly outside fences. */
@@ -50,6 +50,8 @@ import { KEY_CHANGES } from '@/pr/paths'
50
50
  import {
51
51
  findDeployWorkflow,
52
52
  mintPreview,
53
+ type PreviewHeadReason,
54
+ readPreviewHead,
53
55
  type PreviewRefusal,
54
56
  type PreviewRunner,
55
57
  type RunRow,
@@ -116,6 +118,7 @@ const LOCAL_REFUSALS: Record<LocalRefusal | 'gh-failed', string> = {
116
118
 
117
119
  interface PreviewOptions extends ReadOptions {
118
120
  readonly timeout?: string
121
+ readonly check?: boolean
119
122
  }
120
123
 
121
124
  /** How long `canon pr preview` waits on the deploy run by default, in minutes. */
@@ -124,8 +127,13 @@ const PREVIEW_TIMEOUT_MINUTES = 15
124
127
  /** How often the deploy run is re-read while waiting on it. */
125
128
  const PREVIEW_POLL_MS = 15_000
126
129
 
127
- const PREVIEW_REFUSALS: Record<PreviewRefusal | 'bad-timeout', string> = {
130
+ const PREVIEW_REFUSALS: Record<
131
+ PreviewRefusal | 'bad-timeout' | 'check-timeout',
132
+ string
133
+ > = {
128
134
  'bad-timeout': '--timeout takes a positive number of minutes.',
135
+ 'check-timeout':
136
+ '--check only reads the deploy runs and waits on nothing, so it takes no --timeout.',
129
137
  'no-deploy':
130
138
  'No workflow under .github/workflows/ runs pages deploy and carries a workflow_dispatch trigger, so there is nothing to dispatch.',
131
139
  // biome-ignore lint/suspicious/noTemplateCurlyInString: a workflow expression, not a template
@@ -141,6 +149,17 @@ const PREVIEW_REFUSALS: Record<PreviewRefusal | 'bad-timeout', string> = {
141
149
  'The deploy run did not finish inside the bound. The preview may still land. Re-run with a longer --timeout or read the run.',
142
150
  }
143
151
 
152
+ /** What a reader does about each way `canon pr preview --check` answers. */
153
+ const PREVIEW_HEAD_NOTES: Record<PreviewHeadReason, string> = {
154
+ fresh: 'The newest successful deploy built the branch tip.',
155
+ stale:
156
+ 'The newest successful deploy built an earlier head than the branch tip. Mint again with canon pr preview before driving the preview.',
157
+ building:
158
+ 'A deploy of the branch tip is still running. Wait for it rather than minting again.',
159
+ 'no-build':
160
+ 'No successful deploy exists for this branch. Mint one with canon pr preview.',
161
+ }
162
+
144
163
  /** Why a head-sensitive read produced no answer about a commit. */
145
164
  type PullRefusal =
146
165
  | 'gh-missing'
@@ -528,8 +547,11 @@ export function register(program: Command): void {
528
547
  .option('--json', 'Add a machine-readable record on stdout')
529
548
  .option(
530
549
  '--timeout <minutes>',
531
- 'How long to wait on the deploy run',
532
- String(PREVIEW_TIMEOUT_MINUTES),
550
+ `How long to wait on the deploy run (default ${PREVIEW_TIMEOUT_MINUTES})`,
551
+ )
552
+ .option(
553
+ '--check',
554
+ 'Read whether the newest successful deploy built the branch tip, dispatching nothing',
533
555
  )
534
556
  .addHelpText(
535
557
  'after',
@@ -554,13 +576,22 @@ export function register(program: Command): void {
554
576
  ' run-failed the deploy run finished without succeeding',
555
577
  ' timeout the run did not finish inside --timeout minutes',
556
578
  '',
579
+ 'With --check nothing is dispatched or waited on. The newest successful',
580
+ 'workflow_dispatch run is compared with the branch tip read from the remote.',
581
+ 'Read `reason` on the JSON record, with `built`, `tip`, and `runId`:',
582
+ ' fresh the newest successful deploy built the tip',
583
+ ' stale the newest successful deploy built an earlier head',
584
+ ' building a deploy of the tip is still running, so wait',
585
+ ' no-build no deploy of this branch has succeeded',
586
+ '',
557
587
  'Exit codes:',
558
- ' 0 a preview was published',
559
- ' 1 refused, with the reason on stderr or in the JSON record',
588
+ ' 0 a preview was published, or --check read fresh',
589
+ ' 1 refused, or --check read anything but fresh',
560
590
  '',
561
591
  'Examples:',
562
592
  ' canon pr preview --json',
563
593
  ' canon pr preview 1341 --json --timeout 20',
594
+ ' canon pr preview 1341 --check --json',
564
595
  '',
565
596
  ].join('\n'),
566
597
  )
@@ -1773,7 +1804,7 @@ function ghPreviewRunner(root: string): PreviewRunner {
1773
1804
  '--limit',
1774
1805
  '20',
1775
1806
  '--json',
1776
- 'databaseId,status,conclusion',
1807
+ 'databaseId,status,conclusion,headSha,createdAt',
1777
1808
  ])
1778
1809
  if (out === null) return undefined
1779
1810
  try {
@@ -1789,7 +1820,7 @@ function ghPreviewRunner(root: string): PreviewRunner {
1789
1820
  'view',
1790
1821
  String(id),
1791
1822
  '--json',
1792
- 'databaseId,status,conclusion',
1823
+ 'databaseId,status,conclusion,headSha,createdAt',
1793
1824
  ]),
1794
1825
  )
1795
1826
  },
@@ -1810,6 +1841,15 @@ async function runPreview(
1810
1841
 
1811
1842
  intro('canon pr preview')
1812
1843
 
1844
+ if (opts.check === true && opts.timeout !== undefined) {
1845
+ return refuseWith(
1846
+ 'check-timeout',
1847
+ PREVIEW_REFUSALS['check-timeout'],
1848
+ emitJson,
1849
+ root,
1850
+ )
1851
+ }
1852
+
1813
1853
  const minutes = Number(opts.timeout ?? PREVIEW_TIMEOUT_MINUTES)
1814
1854
  if (!Number.isFinite(minutes) || minutes <= 0) {
1815
1855
  return refuseWith(
@@ -1838,6 +1878,10 @@ async function runPreview(
1838
1878
  }
1839
1879
  const { identity } = read
1840
1880
 
1881
+ if (opts.check === true) {
1882
+ return runPreviewCheck(root, emitJson, pick.path, identity)
1883
+ }
1884
+
1841
1885
  logStep('Deploy')
1842
1886
  logInfo(`${pick.path} on ${identity.branch}`)
1843
1887
 
@@ -1887,6 +1931,62 @@ async function runPreview(
1887
1931
  return 0
1888
1932
  }
1889
1933
 
1934
+ /**
1935
+ * Reads the built head against the tip. The tip comes from the remote rather
1936
+ * than `headRefOid`, which lags a push, so a build of the last head reads stale
1937
+ * the moment a newer commit lands.
1938
+ */
1939
+ async function runPreviewCheck(
1940
+ root: string,
1941
+ emitJson: boolean,
1942
+ workflow: string,
1943
+ identity: { readonly branch: string; readonly number?: number },
1944
+ ): Promise<number> {
1945
+ const resolved = await resolveTip(identity.branch, refReader(root))
1946
+ if (resolved.kind === 'refused') {
1947
+ return refuseWith(
1948
+ resolved.reason,
1949
+ PULL_REFUSALS[resolved.reason],
1950
+ emitJson,
1951
+ root,
1952
+ )
1953
+ }
1954
+
1955
+ const reading = await readPreviewHead(ghPreviewRunner(root), {
1956
+ workflow,
1957
+ branch: identity.branch,
1958
+ tip: resolved.tip,
1959
+ })
1960
+ if (reading.reason === 'gh-failed') {
1961
+ return refuseWith(
1962
+ 'gh-failed',
1963
+ PREVIEW_REFUSALS['gh-failed'],
1964
+ emitJson,
1965
+ root,
1966
+ )
1967
+ }
1968
+
1969
+ logStep('Preview head')
1970
+ logInfo(PREVIEW_HEAD_NOTES[reading.reason])
1971
+ outro()
1972
+
1973
+ if (emitJson) {
1974
+ process.stdout.write(
1975
+ `${JSON.stringify({
1976
+ root,
1977
+ ...(identity.number !== undefined && { number: identity.number }),
1978
+ reason: reading.reason,
1979
+ message: PREVIEW_HEAD_NOTES[reading.reason],
1980
+ workflow,
1981
+ tip: reading.tip,
1982
+ ...(reading.built !== undefined && { built: reading.built }),
1983
+ ...(reading.runId !== undefined && { runId: reading.runId }),
1984
+ })}\n`,
1985
+ )
1986
+ }
1987
+ return reading.reason === 'fresh' ? 0 : 1
1988
+ }
1989
+
1890
1990
  async function readText(path: string): Promise<string | undefined> {
1891
1991
  return readFile(path, 'utf8').catch(() => undefined)
1892
1992
  }
@@ -1,6 +1,5 @@
1
1
  import { resolve } from 'node:path'
2
2
  import type { Command } from 'commander'
3
- import { execScript } from '@/exec'
4
3
  import { checkoutMismatchWarning, PROJECT_ROOT } from '@/project-root'
5
4
  import {
6
5
  injectConfigs,
@@ -21,6 +20,7 @@ import { readReference, resolveReference } from '@/tooling/read'
21
20
  import { scan, type ScanResult } from '@/tooling/scan'
22
21
  import { recordedStacks, recordToolingChain } from '@/tooling/stamp'
23
22
  import { subfolderPath } from '@/tooling/subfolder'
23
+ import { verifyStack } from '@/tooling/verify-stack'
24
24
  import {
25
25
  intro,
26
26
  isNonInteractive,
@@ -34,8 +34,6 @@ import {
34
34
  select,
35
35
  } from '@/ui'
36
36
 
37
- const PASS_THROUGH_VERBS = ['verify'] as const
38
-
39
37
  interface SyncOptions {
40
38
  readonly skip?: string
41
39
  readonly check?: boolean
@@ -60,6 +58,10 @@ interface PruneOptions {
60
58
  readonly nested?: boolean
61
59
  }
62
60
 
61
+ interface VerifyOptions {
62
+ readonly keep?: boolean
63
+ }
64
+
63
65
  interface ListOptions {
64
66
  readonly json?: boolean
65
67
  }
@@ -207,18 +209,39 @@ export function register(program: Command): void {
207
209
  process.exitCode = printReference(stack)
208
210
  })
209
211
 
210
- for (const verb of PASS_THROUGH_VERBS) {
211
- tooling
212
- .command(verb)
213
- .description(`Run the tooling ${verb} command`)
214
- .allowUnknownOption()
215
- .allowExcessArguments(true)
216
- .passThroughOptions()
217
- .helpOption(false)
218
- .action(async (_opts: unknown, cmd: Command) => {
219
- await execScript(`tooling/${verb}.sh`, cmd.args)
212
+ tooling
213
+ .command('verify')
214
+ .description('Scaffold a stack fresh, sync it, and run its checks')
215
+ .argument('<stack>', 'Tooling stack name (e.g. vite-react, astro)')
216
+ .helpOption('-h, --help', 'Show this help message')
217
+ .option('--keep', 'Keep the tmp dir after a clean run for inspection')
218
+ .addHelpText(
219
+ 'after',
220
+ [
221
+ '',
222
+ 'Scaffolds <stack> into .canon/tmp/runs/verify-<stack>/, runs the',
223
+ "manifest's [verify] prepare, syncs the chain from this checkout, then",
224
+ 'runs lint:fix, check, test:e2e, and screenshot where the scaffold',
225
+ 'declares them. A scaffold with no package.json after Sync fails.',
226
+ '',
227
+ 'Exit codes:',
228
+ ' 0 every phase passed',
229
+ ' 1 a phase failed, or the stack cannot be verified',
230
+ '',
231
+ 'Examples:',
232
+ ' canon tooling verify vite-react',
233
+ ' canon tooling verify astro --keep',
234
+ '',
235
+ ].join('\n'),
236
+ )
237
+ .action(async (stack: string, opts: VerifyOptions) => {
238
+ const outcome = await verifyStack({
239
+ root: PROJECT_ROOT,
240
+ stack,
241
+ keep: opts.keep === true,
220
242
  })
221
- }
243
+ process.exitCode = outcome.exitCode
244
+ })
222
245
  }
223
246
 
224
247
  /**
@@ -227,8 +250,8 @@ export function register(program: Command): void {
227
250
  * quote emitted output a consuming skill could not parse.
228
251
  *
229
252
  * The frame opens after the `--json` return. The bash emitted a closing `└`
230
- * from its EXIT trap with no `┌` above it, because the hand-rolled
231
- * pass-through loop below opens no frame before it runs the script.
253
+ * from its EXIT trap with no `┌` above it, because the pass-through loop that
254
+ * ran it opened no frame first.
232
255
  */
233
256
  function runList(opts: ListOptions): number {
234
257
  // The warning is a frame-interior line, so it goes out after `intro` on the
package/src/pr/preview.ts CHANGED
@@ -95,6 +95,8 @@ export interface RunRow {
95
95
  readonly databaseId: number
96
96
  readonly status: string
97
97
  readonly conclusion: string
98
+ readonly headSha: string
99
+ readonly createdAt: string
98
100
  }
99
101
 
100
102
  /** The `gh` calls a preview makes, injected so the wait can be driven without a network. */
@@ -176,3 +178,67 @@ export async function mintPreview(
176
178
 
177
179
  return { kind: 'minted', runId, url }
178
180
  }
181
+
182
+ export type PreviewHeadReason = 'fresh' | 'stale' | 'building' | 'no-build'
183
+
184
+ export type PreviewHeadReading =
185
+ | {
186
+ readonly reason: PreviewHeadReason
187
+ readonly tip: string
188
+ readonly built?: string
189
+ readonly runId?: number
190
+ }
191
+ | { readonly reason: 'gh-failed' }
192
+
193
+ /**
194
+ * Compares the head the newest successful dispatch built with the branch tip.
195
+ *
196
+ * The run listing is the only record of the built head, since the alias serves
197
+ * the newest successful deployment per branch and names no sha. Runs are
198
+ * ordered by creation rather than list position, so the verdict follows the
199
+ * most recent deploy. A run still going at the tip reads `building` rather
200
+ * than `stale`, so a mint already under way is not reported as a failure.
201
+ */
202
+ export async function readPreviewHead(
203
+ runner: PreviewRunner,
204
+ target: {
205
+ readonly workflow: string
206
+ readonly branch: string
207
+ readonly tip: string
208
+ },
209
+ ): Promise<PreviewHeadReading> {
210
+ const rows = await runner.listRuns(target.workflow, target.branch)
211
+ if (rows === undefined) return { reason: 'gh-failed' }
212
+
213
+ const { tip } = target
214
+ const newest = [...rows].sort((a, b) =>
215
+ b.createdAt.localeCompare(a.createdAt),
216
+ )
217
+ const built = newest.find(
218
+ (row) => row.status === 'completed' && row.conclusion === 'success',
219
+ )
220
+
221
+ if (built?.headSha === tip) {
222
+ return {
223
+ reason: 'fresh',
224
+ built: built.headSha,
225
+ tip,
226
+ runId: built.databaseId,
227
+ }
228
+ }
229
+
230
+ const pending = newest.find(
231
+ (row) => row.status !== 'completed' && row.headSha === tip,
232
+ )
233
+ if (pending !== undefined) {
234
+ return {
235
+ reason: 'building',
236
+ tip,
237
+ runId: pending.databaseId,
238
+ ...(built !== undefined && { built: built.headSha }),
239
+ }
240
+ }
241
+
242
+ if (built === undefined) return { reason: 'no-build', tip }
243
+ return { reason: 'stale', built: built.headSha, tip, runId: built.databaseId }
244
+ }
@@ -30,6 +30,14 @@ export interface Manifest {
30
30
  * gitignore entries.
31
31
  */
32
32
  readonly onlyConfigs?: readonly string[]
33
+ /**
34
+ * The command that creates a fresh project for this stack, from `[stack]
35
+ * scaffold`, with `{{name}}` standing for the project folder. Unset on a
36
+ * layer stack, which scaffolds nothing of its own.
37
+ */
38
+ readonly scaffold?: string
39
+ /** Post-scaffold setup `canon tooling verify` runs ahead of Sync. */
40
+ readonly prepare?: string
33
41
  }
34
42
 
35
43
  export interface ChainOptions {
@@ -134,6 +142,8 @@ export function loadManifest(
134
142
  typeof stackTable.extends === 'string' ? stackTable.extends : ''
135
143
  const scriptsTable = asTable(parsed.scripts)
136
144
  const perRoot = readStrings(asTable(parsed.sync).per_root)
145
+ const scaffold = readString(stackTable.scaffold)
146
+ const prepare = readString(asTable(parsed.verify).prepare)
137
147
  const dir = join(toolingDir(root), stack)
138
148
 
139
149
  return {
@@ -147,6 +157,8 @@ export function loadManifest(
147
157
  gitignore: readGitignoreSections(asTable(parsed.gitignore)),
148
158
  devPackages: readDevPackages(parsed.dependencies),
149
159
  ...(perRoot.length > 0 ? { perRoot } : {}),
160
+ ...(scaffold !== undefined ? { scaffold } : {}),
161
+ ...(prepare !== undefined ? { prepare } : {}),
150
162
  }
151
163
  }
152
164
 
@@ -231,6 +243,10 @@ function readDevPackages(dependencies: unknown): string[] {
231
243
  return readStrings(asTable(asTable(dependencies).dev).packages)
232
244
  }
233
245
 
246
+ function readString(value: unknown): string | undefined {
247
+ return typeof value === 'string' && value !== '' ? value : undefined
248
+ }
249
+
234
250
  function readStrings(value: unknown): string[] {
235
251
  if (!Array.isArray(value)) return []
236
252
  return value.filter(