@erclx/canon 5.11.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.
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/api-design/REQUIREMENT.md +1 -1
- package/claude/skills/api-design/SKILL.md +1 -1
- package/claude/skills/api-design/references/adopted.md +1 -1
- package/claude/skills/auto-ship/REQUIREMENT.md +2 -0
- package/claude/skills/auto-ship/SKILL.md +7 -10
- package/claude/skills/auto-ship/references/failure-recovery.md +0 -1
- package/claude/skills/auto-ship/references/ui-checklist.md +21 -0
- package/claude/skills/build-in-slices/REQUIREMENT.md +45 -0
- package/claude/skills/build-in-slices/SKILL.md +94 -0
- package/claude/skills/build-in-slices/references/adopted.md +41 -0
- package/claude/skills/code-craft/REQUIREMENT.md +48 -0
- package/claude/skills/code-craft/SKILL.md +107 -0
- package/claude/skills/code-craft/references/adopted.md +52 -0
- package/claude/skills/codebase-layout/REQUIREMENT.md +1 -1
- package/claude/skills/git-pr/references/evidence.md +7 -7
- package/claude/skills/git-ship/REQUIREMENT.md +2 -1
- package/claude/skills/git-ship/SKILL.md +2 -2
- package/claude/skills/review-craft/REQUIREMENT.md +3 -0
- package/claude/skills/review-craft/SKILL.md +1 -1
- package/claude/skills/review-craft/references/adopted.md +8 -0
- package/claude/skills/review-craft/references/security.md +5 -0
- package/claude/skills/review-craft/references/ui.md +1 -2
- package/claude/skills/review-pr/SKILL.md +1 -1
- package/claude/skills/review-ui/REQUIREMENT.md +1 -0
- package/claude/skills/review-ui/SKILL.md +11 -0
- package/claude/skills/role-worker/REQUIREMENT.md +1 -0
- package/claude/skills/role-worker/SKILL.md +4 -0
- package/claude/skills/test-first/REQUIREMENT.md +3 -0
- package/claude/skills/test-first/SKILL.md +2 -1
- package/claude/skills/ui-checklist/SKILL.md +1 -18
- package/docs/agents/gate.md +1 -1
- package/docs/agents/hooks.md +2 -0
- package/docs/agents/install-and-sync.md +2 -2
- package/docs/agents/pr-evidence.md +26 -25
- package/docs/agents/pr-preview.md +40 -12
- package/docs/agents/tasks-validate.md +3 -3
- package/docs/target/migrations.md +1 -1
- package/docs/workflow/ai-workflow.md +2 -1
- package/docs/workflow/parallel-features.md +3 -3
- package/docs/workflow/skill-map.md +2 -0
- package/governance/rules/code/000-code.md +5 -1
- package/package.json +3 -4
- package/scripts/lib/sandbox-path.sh +1 -1
- package/src/claude/cases/misc.ts +10 -0
- package/src/claude/skills-audit.ts +2 -0
- package/src/commands/pr.ts +123 -21
- package/src/commands/tooling.ts +39 -16
- package/src/gate/stages.ts +10 -2
- package/src/pr/preview.ts +66 -0
- package/src/tasks/validate.ts +37 -27
- package/src/tooling/manifest.ts +16 -0
- package/src/tooling/verify-stack.ts +249 -0
- package/standards/tasks.md +1 -1
- package/tooling/base/configs/.husky/post-merge +30 -77
- package/tooling/base/configs/.husky/post-rewrite +3 -3
- package/tooling/base/reference.md +1 -1
- package/tooling/base/seeds/.cspell/tech-stack.txt +2 -0
- package/tooling/go/manifest.toml +3 -0
- package/tooling/go/reference.md +1 -0
- package/tooling/php/manifest.toml +3 -0
- package/tooling/php/reference.md +1 -0
- package/tooling/python/manifest.toml +3 -0
- package/tooling/python/reference.md +1 -1
- package/tooling/python/seeds/.cspell/tech-stack.txt +0 -1
- package/scripts/core/clean.sh +0 -36
- package/scripts/core/regen-agent-fixture.sh +0 -235
- package/scripts/core/regen-claude-copies.sh +0 -10
- package/scripts/core/regen-indexes.sh +0 -10
- package/scripts/core/snapshot.sh +0 -134
- package/scripts/core/update.sh +0 -35
- package/scripts/tooling/verify.sh +0 -184
|
@@ -70,8 +70,8 @@ Past that shape, it checks what a surviving row claims against what the tree hol
|
|
|
70
70
|
|
|
71
71
|
Archiving a task carries its plan with it, in the same act. The merge is what settles a plan, and the hook below reaches the archive with nobody watching, so a second call after it would be a second failure point leaving the task archived and the plan live. A task whose plan a sibling still cites archives on its own and leaves the plan live, since moving it on the first task to close strands every other pointer at a path that has gone.
|
|
72
72
|
|
|
73
|
-
Nothing chained that archive until the `post-merge` git hook landed. Every earlier step fires from `canon:auto-ship` or `canon:git-ship`, both of which finish while the pull request is still open, so a task archived there would close for work that may be abandoned. The board is gitignored, which rules out reading it from anywhere but the machine that pulled. The hook
|
|
73
|
+
Nothing chained that archive until the `post-merge` git hook landed. Every earlier step fires from `canon:auto-ship` or `canon:git-ship`, both of which finish while the pull request is still open, so a task archived there would close for work that may be abandoned. The board is gitignored, which rules out reading it from anywhere but the machine that pulled. The hook archives the task a merged pull request closed, through the same verb the base stack ships to every target, and does nothing on a project with no board.
|
|
74
74
|
|
|
75
|
-
|
|
75
|
+
The archive gates on a pull request number the merge carried and a closed task, because outcomes are marked on the branch and a task can read all `[x]` while its pull request is still open, so `canon:task-board` still confirms the work reached `main` when a person archives by hand. A companion `post-rewrite` hook carries the same steps for anyone pulling with rebase, which fires that event instead of `post-merge`.
|
|
76
76
|
|
|
77
|
-
|
|
77
|
+
The hook archives through `canon tasks archive`, which carries the refusals above, so a task whose pull request has not merged stays where it is. Both hooks ship with the `base` tooling stack, so a target project running this workflow gets the same trigger, and `CANON_SKIP_RECLAIM`, `CANON_SKIP_UPGRADE`, and `CANON_SKIP_PLUGIN_UPDATE` turn off the three steps that act on the machine.
|
|
@@ -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.
|
|
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": {
|
|
@@ -36,9 +36,8 @@
|
|
|
36
36
|
"prepare": "husky",
|
|
37
37
|
"check": "bun src/cli.ts gate run",
|
|
38
38
|
"check:ci": "bun src/cli.ts gate run --all --no-write",
|
|
39
|
-
"update": "
|
|
40
|
-
"clean": "
|
|
41
|
-
"snapshot": "./scripts/core/snapshot.sh",
|
|
39
|
+
"update": "bun update --interactive && bun run check",
|
|
40
|
+
"clean": "rm -rf node_modules && bun pm cache rm && bun install",
|
|
42
41
|
"bootstrap": "./scripts/core/bootstrap.sh",
|
|
43
42
|
"canon:sandbox": "canon sandbox",
|
|
44
43
|
"canon:sandbox:reset": "canon sandbox reset",
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
# both carry the rule sending shared session scratch to the main worktree root,
|
|
8
8
|
# and nothing decides which root wins. A session picking the toolkit writes its
|
|
9
9
|
# output where no manifest reads it, so the run reports success while the verdict
|
|
10
|
-
# reports no writes at all.
|
|
10
|
+
# reports no writes at all. The retired eval runner kept its fixture outside the
|
|
11
11
|
# repository for the same reason.
|
|
12
12
|
#
|
|
13
13
|
# Mints a short random per-run identifier the first time it is asked for, then
|
package/src/claude/cases/misc.ts
CHANGED
|
@@ -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. */
|
package/src/commands/pr.ts
CHANGED
|
@@ -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<
|
|
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'
|
|
@@ -466,17 +485,17 @@ export function register(program: Command): void {
|
|
|
466
485
|
'--checklist closes the body with a visual checklist, below the',
|
|
467
486
|
'comparison it annotates. A checklist the marked comment already',
|
|
468
487
|
'carries is read back and carried forward the same way the preview',
|
|
469
|
-
'address is, which keeps a re-render from wiping ticked boxes. It
|
|
470
|
-
'
|
|
471
|
-
'image
|
|
472
|
-
'
|
|
488
|
+
'address is, which keeps a re-render from wiping ticked boxes. It',
|
|
489
|
+
'turns no-evidence into ok: a branch with a checklist and no evidence',
|
|
490
|
+
'image renders a marked body holding the checklist, so a later call',
|
|
491
|
+
'finds and edits that comment.',
|
|
473
492
|
'',
|
|
474
493
|
'--local adds a **Local preview:** line under the hosted preview line,',
|
|
475
494
|
'or opens the body with it when there is none, and is carried forward',
|
|
476
495
|
'the same way. It does not by itself turn no-evidence into ok, so a',
|
|
477
496
|
'branch with a server running and nothing to show posts no link-only',
|
|
478
|
-
'comment. Together with --checklist
|
|
479
|
-
'
|
|
497
|
+
'comment. Together with --checklist the link and the checklist land in',
|
|
498
|
+
'one comment.',
|
|
480
499
|
'',
|
|
481
500
|
'Both reasons carry what the marked comment already posted, each field',
|
|
482
501
|
'present only when that comment holds it:',
|
|
@@ -484,9 +503,10 @@ export function register(program: Command): void {
|
|
|
484
503
|
' local the **Local preview:** address under it',
|
|
485
504
|
' checklist the checklist between its delimiters, ticks included',
|
|
486
505
|
'These come from the comment already posted, never from the flags this',
|
|
487
|
-
'call passed. A checklist
|
|
488
|
-
'record reports none of the three for it. An
|
|
489
|
-
'gh-failed on both reasons rather than
|
|
506
|
+
'call passed. A checklist the caller posted raw after a refused render',
|
|
507
|
+
'carries no marker, so the record reports none of the three for it. An',
|
|
508
|
+
'unread thread refuses as gh-failed on both reasons rather than',
|
|
509
|
+
'reporting the fields absent.',
|
|
490
510
|
'',
|
|
491
511
|
'--check reads the same changed set and thread, renders no body, and',
|
|
492
512
|
'reports what the ship chain still owes the pull request:',
|
|
@@ -527,8 +547,11 @@ export function register(program: Command): void {
|
|
|
527
547
|
.option('--json', 'Add a machine-readable record on stdout')
|
|
528
548
|
.option(
|
|
529
549
|
'--timeout <minutes>',
|
|
530
|
-
|
|
531
|
-
|
|
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',
|
|
532
555
|
)
|
|
533
556
|
.addHelpText(
|
|
534
557
|
'after',
|
|
@@ -553,13 +576,22 @@ export function register(program: Command): void {
|
|
|
553
576
|
' run-failed the deploy run finished without succeeding',
|
|
554
577
|
' timeout the run did not finish inside --timeout minutes',
|
|
555
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
|
+
'',
|
|
556
587
|
'Exit codes:',
|
|
557
|
-
' 0 a preview was published',
|
|
558
|
-
' 1 refused,
|
|
588
|
+
' 0 a preview was published, or --check read fresh',
|
|
589
|
+
' 1 refused, or --check read anything but fresh',
|
|
559
590
|
'',
|
|
560
591
|
'Examples:',
|
|
561
592
|
' canon pr preview --json',
|
|
562
593
|
' canon pr preview 1341 --json --timeout 20',
|
|
594
|
+
' canon pr preview 1341 --check --json',
|
|
563
595
|
'',
|
|
564
596
|
].join('\n'),
|
|
565
597
|
)
|
|
@@ -1605,14 +1637,15 @@ async function runEvidence(
|
|
|
1605
1637
|
return refuseWith('no-base', EVIDENCE_REFUSALS['no-base'], emitJson, root)
|
|
1606
1638
|
}
|
|
1607
1639
|
|
|
1608
|
-
// A
|
|
1609
|
-
// a
|
|
1610
|
-
|
|
1611
|
-
|
|
1640
|
+
// A checklist opens a comment on its own, so it always lands in the marked
|
|
1641
|
+
// comment a later call edits. A local address rides on a checklist or a
|
|
1642
|
+
// hosted preview and never opens one alone, so a docs-only branch with a
|
|
1643
|
+
// server up posts nothing.
|
|
1644
|
+
const hasChecklist = suppliedChecklist !== undefined
|
|
1612
1645
|
if (
|
|
1613
1646
|
grouped.kind === 'refused' &&
|
|
1614
1647
|
opts.preview === undefined &&
|
|
1615
|
-
!
|
|
1648
|
+
!hasChecklist
|
|
1616
1649
|
) {
|
|
1617
1650
|
logStep('Skipped')
|
|
1618
1651
|
logInfo(
|
|
@@ -1771,7 +1804,7 @@ function ghPreviewRunner(root: string): PreviewRunner {
|
|
|
1771
1804
|
'--limit',
|
|
1772
1805
|
'20',
|
|
1773
1806
|
'--json',
|
|
1774
|
-
'databaseId,status,conclusion',
|
|
1807
|
+
'databaseId,status,conclusion,headSha,createdAt',
|
|
1775
1808
|
])
|
|
1776
1809
|
if (out === null) return undefined
|
|
1777
1810
|
try {
|
|
@@ -1787,7 +1820,7 @@ function ghPreviewRunner(root: string): PreviewRunner {
|
|
|
1787
1820
|
'view',
|
|
1788
1821
|
String(id),
|
|
1789
1822
|
'--json',
|
|
1790
|
-
'databaseId,status,conclusion',
|
|
1823
|
+
'databaseId,status,conclusion,headSha,createdAt',
|
|
1791
1824
|
]),
|
|
1792
1825
|
)
|
|
1793
1826
|
},
|
|
@@ -1808,6 +1841,15 @@ async function runPreview(
|
|
|
1808
1841
|
|
|
1809
1842
|
intro('canon pr preview')
|
|
1810
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
|
+
|
|
1811
1853
|
const minutes = Number(opts.timeout ?? PREVIEW_TIMEOUT_MINUTES)
|
|
1812
1854
|
if (!Number.isFinite(minutes) || minutes <= 0) {
|
|
1813
1855
|
return refuseWith(
|
|
@@ -1836,6 +1878,10 @@ async function runPreview(
|
|
|
1836
1878
|
}
|
|
1837
1879
|
const { identity } = read
|
|
1838
1880
|
|
|
1881
|
+
if (opts.check === true) {
|
|
1882
|
+
return runPreviewCheck(root, emitJson, pick.path, identity)
|
|
1883
|
+
}
|
|
1884
|
+
|
|
1839
1885
|
logStep('Deploy')
|
|
1840
1886
|
logInfo(`${pick.path} on ${identity.branch}`)
|
|
1841
1887
|
|
|
@@ -1885,6 +1931,62 @@ async function runPreview(
|
|
|
1885
1931
|
return 0
|
|
1886
1932
|
}
|
|
1887
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
|
+
|
|
1888
1990
|
async function readText(path: string): Promise<string | undefined> {
|
|
1889
1991
|
return readFile(path, 'utf8').catch(() => undefined)
|
|
1890
1992
|
}
|
package/src/commands/tooling.ts
CHANGED
|
@@ -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
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
|
231
|
-
*
|
|
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/gate/stages.ts
CHANGED
|
@@ -163,7 +163,11 @@ export const STAGES: readonly Stage[] = [
|
|
|
163
163
|
id: 'indexes',
|
|
164
164
|
label: 'Indexes',
|
|
165
165
|
checks: [
|
|
166
|
-
|
|
166
|
+
{
|
|
167
|
+
kind: 'cli',
|
|
168
|
+
argv: ['indexes', 'regen'],
|
|
169
|
+
failure: 'Index regen failed',
|
|
170
|
+
},
|
|
167
171
|
{
|
|
168
172
|
kind: 'drift',
|
|
169
173
|
pathspec: '*index.md',
|
|
@@ -177,7 +181,11 @@ export const STAGES: readonly Stage[] = [
|
|
|
177
181
|
id: 'consumed-copies',
|
|
178
182
|
label: 'Consumed copies',
|
|
179
183
|
checks: [
|
|
180
|
-
|
|
184
|
+
{
|
|
185
|
+
kind: 'cli',
|
|
186
|
+
argv: ['gov', 'regen'],
|
|
187
|
+
failure: 'Consumed-copy regen failed',
|
|
188
|
+
},
|
|
181
189
|
{
|
|
182
190
|
kind: 'drift',
|
|
183
191
|
pathspec: '.claude/rules',
|
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
|
+
}
|