@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
package/src/tasks/validate.ts
CHANGED
|
@@ -6,7 +6,6 @@ import {
|
|
|
6
6
|
declinedDir,
|
|
7
7
|
isReservedStem,
|
|
8
8
|
readOutcomes,
|
|
9
|
-
readPlanTarget,
|
|
10
9
|
readPlanTargets,
|
|
11
10
|
readPullRequest,
|
|
12
11
|
resolveLivePlan,
|
|
@@ -731,11 +730,8 @@ async function planDisagreement(
|
|
|
731
730
|
if (!existsSync(file)) return undefined
|
|
732
731
|
|
|
733
732
|
const text = await readFile(file, 'utf8')
|
|
734
|
-
const
|
|
735
|
-
if (
|
|
736
|
-
const several = severalPlans(text, 'Run now', subject)
|
|
737
|
-
if (several) return several
|
|
738
|
-
|
|
733
|
+
const targets = readPlanTargets(text)
|
|
734
|
+
if (targets.length === 0) {
|
|
739
735
|
return {
|
|
740
736
|
kind: 'plan-uncited',
|
|
741
737
|
group: 'Run now',
|
|
@@ -744,15 +740,22 @@ async function planDisagreement(
|
|
|
744
740
|
}
|
|
745
741
|
}
|
|
746
742
|
|
|
743
|
+
const twice = duplicatePlans(targets, 'Run now', subject, dir, root)
|
|
744
|
+
if (twice) return twice
|
|
745
|
+
|
|
747
746
|
const rowed = planPath(row.plan, dir, root)
|
|
748
|
-
|
|
749
|
-
|
|
747
|
+
if (targets.some((target) => planPath(target, dir, root) === rowed)) {
|
|
748
|
+
return undefined
|
|
749
|
+
}
|
|
750
750
|
|
|
751
751
|
return {
|
|
752
752
|
kind: 'plan-mismatched',
|
|
753
753
|
group: 'Run now',
|
|
754
754
|
subject,
|
|
755
|
-
message:
|
|
755
|
+
message:
|
|
756
|
+
targets.length === 1
|
|
757
|
+
? `is rowed against ${row.plan} and cites ${targets[0]} in its own Plan: line. One task names one plan.`
|
|
758
|
+
: `is rowed against ${row.plan}, which is none of the links on its own Plan: line (${targets.join(', ')}).`,
|
|
756
759
|
}
|
|
757
760
|
}
|
|
758
761
|
|
|
@@ -794,14 +797,15 @@ async function groupClaimFinding(
|
|
|
794
797
|
if (!existsSync(file)) return undefined
|
|
795
798
|
|
|
796
799
|
const text = await readFile(file, 'utf8')
|
|
797
|
-
const
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
if (several) return several
|
|
801
|
-
}
|
|
800
|
+
const targets = readPlanTargets(text)
|
|
801
|
+
const twice = duplicatePlans(targets, row.group, row.stem, dir, root)
|
|
802
|
+
if (twice) return twice
|
|
802
803
|
|
|
803
|
-
const
|
|
804
|
-
|
|
804
|
+
const target = targets.find((link) => {
|
|
805
|
+
const live = resolveLivePlan(link, dir, root)
|
|
806
|
+
return live !== undefined && existsSync(live)
|
|
807
|
+
})
|
|
808
|
+
const hasLivePlan = target !== undefined
|
|
805
809
|
|
|
806
810
|
if (row.group === 'Needs a plan' && hasLivePlan) {
|
|
807
811
|
return {
|
|
@@ -817,9 +821,12 @@ async function groupClaimFinding(
|
|
|
817
821
|
kind: 'plan-absent',
|
|
818
822
|
group: row.group,
|
|
819
823
|
subject: row.stem,
|
|
820
|
-
message:
|
|
821
|
-
|
|
822
|
-
|
|
824
|
+
message:
|
|
825
|
+
targets.length > 1
|
|
826
|
+
? `sits under ${row.group}, which claims a written plan, and its task cites ${targets.join(', ')}, none of which is a live plan.`
|
|
827
|
+
: targets.length === 1
|
|
828
|
+
? `sits under ${row.group}, which claims a written plan, and its task cites ${targets[0]}, which is not a live plan.`
|
|
829
|
+
: `sits under ${row.group}, which claims a written plan, and its task carries no Plan: line.`,
|
|
823
830
|
}
|
|
824
831
|
}
|
|
825
832
|
|
|
@@ -827,23 +834,26 @@ async function groupClaimFinding(
|
|
|
827
834
|
}
|
|
828
835
|
|
|
829
836
|
/**
|
|
830
|
-
* Reports a `Plan:` line linking more than
|
|
831
|
-
*
|
|
832
|
-
*
|
|
837
|
+
* Reports a `Plan:` line linking one plan more than once. Links to distinct
|
|
838
|
+
* plans are the shape a sliced task carries, so only a repeat is a finding.
|
|
839
|
+
* Links compare by the file they resolve to, so `../plans/x.md` beside
|
|
840
|
+
* `.canon/plans/x.md` is one plan listed twice.
|
|
833
841
|
*/
|
|
834
|
-
function
|
|
835
|
-
|
|
842
|
+
function duplicatePlans(
|
|
843
|
+
targets: readonly string[],
|
|
836
844
|
group: Finding['group'],
|
|
837
845
|
subject: string,
|
|
846
|
+
dir: string,
|
|
847
|
+
root: string,
|
|
838
848
|
): Finding | undefined {
|
|
839
|
-
const
|
|
840
|
-
if (
|
|
849
|
+
const paths = targets.map((target) => planPath(target, dir, root))
|
|
850
|
+
if (new Set(paths).size === paths.length) return undefined
|
|
841
851
|
|
|
842
852
|
return {
|
|
843
853
|
kind: 'plan-several',
|
|
844
854
|
group,
|
|
845
855
|
subject,
|
|
846
|
-
message: `
|
|
856
|
+
message: `lists the same plan twice on its Plan: line (${targets.join(', ')}). Drop the repeat, or link a distinct plan per slice.`,
|
|
847
857
|
}
|
|
848
858
|
}
|
|
849
859
|
|
package/src/tooling/manifest.ts
CHANGED
|
@@ -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(
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { execa, type Options } from 'execa'
|
|
4
|
+
import { isStackExcluded, loadManifest } from '@/tooling/manifest'
|
|
5
|
+
import { intro, logError, logInfo, logStep, logWarn, outro } from '@/ui'
|
|
6
|
+
|
|
7
|
+
const SCREENSHOT_TIMEOUT_MS = 60_000
|
|
8
|
+
const TAIL_LINES = 20
|
|
9
|
+
|
|
10
|
+
export interface VerifyOptions {
|
|
11
|
+
readonly root: string
|
|
12
|
+
readonly stack: string
|
|
13
|
+
readonly keep?: boolean
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface PhaseResult {
|
|
17
|
+
readonly name: string
|
|
18
|
+
readonly passed: boolean
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface VerifyOutcome {
|
|
22
|
+
readonly results: readonly PhaseResult[]
|
|
23
|
+
readonly exitCode: number
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface Run {
|
|
27
|
+
readonly dir: string
|
|
28
|
+
readonly results: PhaseResult[]
|
|
29
|
+
isFailed: boolean
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Scaffolds a stack into `.canon/tmp/runs/verify-<stack>/`, syncs its chain
|
|
34
|
+
* from the checkout at `root`, and runs the scaffold's own `lint:fix`,
|
|
35
|
+
* `check`, `test:e2e`, and `screenshot` scripts. Every line goes to stderr.
|
|
36
|
+
*
|
|
37
|
+
* A failed phase is recorded and the run carries on, so the Results block
|
|
38
|
+
* reports every phase rather than the first failure. The tmp dir is removed
|
|
39
|
+
* only on a clean run without `keep`.
|
|
40
|
+
*/
|
|
41
|
+
export async function verifyStack(opts: VerifyOptions): Promise<VerifyOutcome> {
|
|
42
|
+
intro(`canon tooling verify ${opts.stack}`)
|
|
43
|
+
// A signal skips the `finally` below, so the handler closes the frame on
|
|
44
|
+
// that path and leaves the tmp dir in place for inspection.
|
|
45
|
+
let isOpen = true
|
|
46
|
+
const close = (): void => {
|
|
47
|
+
if (isOpen) outro()
|
|
48
|
+
isOpen = false
|
|
49
|
+
}
|
|
50
|
+
const handleSignal = (): void => {
|
|
51
|
+
close()
|
|
52
|
+
process.exit(130)
|
|
53
|
+
}
|
|
54
|
+
process.once('SIGINT', handleSignal)
|
|
55
|
+
process.once('SIGTERM', handleSignal)
|
|
56
|
+
|
|
57
|
+
try {
|
|
58
|
+
return await run(opts)
|
|
59
|
+
} finally {
|
|
60
|
+
process.off('SIGINT', handleSignal)
|
|
61
|
+
process.off('SIGTERM', handleSignal)
|
|
62
|
+
close()
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function run({
|
|
67
|
+
root,
|
|
68
|
+
stack,
|
|
69
|
+
keep = false,
|
|
70
|
+
}: VerifyOptions): Promise<VerifyOutcome> {
|
|
71
|
+
const refuse = (message: string): VerifyOutcome => {
|
|
72
|
+
logError(message)
|
|
73
|
+
return { results: [], exitCode: 1 }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (isStackExcluded(stack)) {
|
|
77
|
+
return refuse(`Stack '${stack}' is excluded from tooling.`)
|
|
78
|
+
}
|
|
79
|
+
const manifest = loadManifest(root, stack)
|
|
80
|
+
if (manifest === undefined) {
|
|
81
|
+
return refuse(`No manifest at tooling/${stack}/manifest.toml`)
|
|
82
|
+
}
|
|
83
|
+
if (manifest.scaffold === undefined) {
|
|
84
|
+
return refuse(`Stack '${stack}' has no scaffold command in manifest.`)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const runsDir = join(root, '.canon/tmp/runs')
|
|
88
|
+
const name = `verify-${stack}`
|
|
89
|
+
const state: Run = { dir: join(runsDir, name), results: [], isFailed: false }
|
|
90
|
+
|
|
91
|
+
mkdirSync(runsDir, { recursive: true })
|
|
92
|
+
rmSync(state.dir, { recursive: true, force: true })
|
|
93
|
+
|
|
94
|
+
logStep(`Scaffolding ${stack}`)
|
|
95
|
+
// Manifests carry shell syntax (`&&`, `cd`, quoted globs), so the command
|
|
96
|
+
// runs under a shell rather than as a split argv.
|
|
97
|
+
const scaffold = await shell(
|
|
98
|
+
manifest.scaffold.replaceAll('{{name}}', name),
|
|
99
|
+
runsDir,
|
|
100
|
+
)
|
|
101
|
+
if (scaffold.failed) {
|
|
102
|
+
logWarn('Scaffold failed')
|
|
103
|
+
writeTail(scaffold.all)
|
|
104
|
+
state.isFailed = true
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (!existsSync(state.dir)) {
|
|
108
|
+
return refuse(`Scaffold did not produce ${state.dir}`)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
await shell(
|
|
112
|
+
'git init -q && git add . && git commit -m "chore: verify scaffold" -q --no-verify --allow-empty',
|
|
113
|
+
state.dir,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
const { prepare } = manifest
|
|
117
|
+
if (prepare !== undefined) {
|
|
118
|
+
await phase(state, 'Prepare', () => shell(prepare, state.dir))
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const cli = join(root, 'src/cli.ts')
|
|
122
|
+
logInfo(`Sync resolves via bun ${cli}, not PATH canon`)
|
|
123
|
+
await phase(state, 'Sync', () =>
|
|
124
|
+
execa('bun', [cli, 'tooling', 'sync', stack, '.', '--write'], {
|
|
125
|
+
...captured(state.dir),
|
|
126
|
+
env: { CI: '1', CANON_NON_INTERACTIVE: '1' },
|
|
127
|
+
}),
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
const packagePath = join(state.dir, 'package.json')
|
|
131
|
+
if (existsSync(packagePath)) {
|
|
132
|
+
await runScripts(state, readScriptKeys(packagePath))
|
|
133
|
+
} else {
|
|
134
|
+
logError(
|
|
135
|
+
`No package.json after Sync for ${stack}, so no phase past Sync can run.`,
|
|
136
|
+
)
|
|
137
|
+
state.isFailed = true
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
logStep('Results')
|
|
141
|
+
for (const result of state.results) {
|
|
142
|
+
if (result.passed) logInfo(result.name)
|
|
143
|
+
else logWarn(result.name)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (!keep && !state.isFailed) {
|
|
147
|
+
rmSync(state.dir, { recursive: true, force: true })
|
|
148
|
+
} else {
|
|
149
|
+
logInfo(`Kept ${state.dir}`)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return { results: state.results, exitCode: state.isFailed ? 1 : 0 }
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* `lint:fix` runs first and reformats the scaffold, so `check` reads the
|
|
157
|
+
* reformatted tree.
|
|
158
|
+
*/
|
|
159
|
+
async function runScripts(state: Run, scripts: Set<string>): Promise<void> {
|
|
160
|
+
await phase(state, 'lint:fix', () => bunRun('lint:fix', state.dir))
|
|
161
|
+
await phase(state, 'check', () => bunRun('check', state.dir))
|
|
162
|
+
|
|
163
|
+
if (scripts.has('test:e2e')) {
|
|
164
|
+
await phase(state, 'test:e2e', () => bunRun('test:e2e', state.dir))
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (!scripts.has('screenshot')) return
|
|
168
|
+
const isShot = await phase(state, 'screenshot', () =>
|
|
169
|
+
bunRun('screenshot', state.dir, SCREENSHOT_TIMEOUT_MS),
|
|
170
|
+
)
|
|
171
|
+
// A killed or failed screenshot run is already a failure, and its partial
|
|
172
|
+
// output says nothing about the artifacts.
|
|
173
|
+
if (!isShot) return
|
|
174
|
+
|
|
175
|
+
const shots = countScreenshots(join(state.dir, 'screenshots'))
|
|
176
|
+
if (shots === 0) logWarn('screenshot produced no png files')
|
|
177
|
+
record(state, 'screenshot-artifacts', shots > 0)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
type Spawned = Awaited<ReturnType<typeof execa>>
|
|
181
|
+
|
|
182
|
+
async function phase(
|
|
183
|
+
state: Run,
|
|
184
|
+
name: string,
|
|
185
|
+
spawn: () => Promise<Spawned>,
|
|
186
|
+
): Promise<boolean> {
|
|
187
|
+
logStep(name)
|
|
188
|
+
const result = await spawn()
|
|
189
|
+
if (!result.failed) {
|
|
190
|
+
logInfo(`${name} passed`)
|
|
191
|
+
record(state, name, true)
|
|
192
|
+
return true
|
|
193
|
+
}
|
|
194
|
+
logWarn(result.timedOut ? `${name} timed out` : `${name} failed`)
|
|
195
|
+
writeTail(result.all)
|
|
196
|
+
record(state, name, false)
|
|
197
|
+
return false
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function record(state: Run, name: string, passed: boolean): void {
|
|
201
|
+
state.results.push({ name, passed })
|
|
202
|
+
if (!passed) state.isFailed = true
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function captured(cwd: string): Options {
|
|
206
|
+
return { cwd, all: true, reject: false, stdin: 'ignore' }
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function shell(command: string, cwd: string): Promise<Spawned> {
|
|
210
|
+
return execa(command, {
|
|
211
|
+
...captured(cwd),
|
|
212
|
+
shell: 'bash',
|
|
213
|
+
env: { CI: '1' },
|
|
214
|
+
})
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function bunRun(
|
|
218
|
+
script: string,
|
|
219
|
+
cwd: string,
|
|
220
|
+
timeout?: number,
|
|
221
|
+
): Promise<Spawned> {
|
|
222
|
+
return execa('bun', ['run', script], {
|
|
223
|
+
...captured(cwd),
|
|
224
|
+
env: { CI: '1' },
|
|
225
|
+
...(timeout !== undefined ? { timeout } : {}),
|
|
226
|
+
})
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function writeTail(output: unknown): void {
|
|
230
|
+
if (typeof output !== 'string' || output === '') return
|
|
231
|
+
const lines = output.replace(/\n$/, '').split('\n').slice(-TAIL_LINES)
|
|
232
|
+
process.stderr.write(`${lines.join('\n')}\n`)
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function readScriptKeys(packagePath: string): Set<string> {
|
|
236
|
+
try {
|
|
237
|
+
const parsed = JSON.parse(readFileSync(packagePath, 'utf8')) as {
|
|
238
|
+
scripts?: Record<string, unknown>
|
|
239
|
+
}
|
|
240
|
+
return new Set(Object.keys(parsed.scripts ?? {}))
|
|
241
|
+
} catch {
|
|
242
|
+
return new Set()
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function countScreenshots(dir: string): number {
|
|
247
|
+
if (!existsSync(dir)) return 0
|
|
248
|
+
return [...new Bun.Glob('**/*.png').scanSync({ cwd: dir, dot: true })].length
|
|
249
|
+
}
|
package/standards/tasks.md
CHANGED
|
@@ -124,7 +124,7 @@ An intake folder answers that direction at folder scope rather than item scope.
|
|
|
124
124
|
|
|
125
125
|
A project that archived plans before the folder nested under `.canon/plans/` holds closed tasks pointing at `../plans-archive/` or `../.tmp/plans-archive/`. Leave those pointers where they are, since each resolves against the files it names and a task retargeted without its plan moving leads nowhere.
|
|
126
126
|
|
|
127
|
-
One plan per task, or one per slice on
|
|
127
|
+
One plan per task, or one per slice, which the board check reads as distinct plans on one line. A plan cited by two tasks is a misfile.
|
|
128
128
|
|
|
129
129
|
`canon tasks plan-link <task> <plan>` writes or corrects the `Plan:` line, and `--add` appends a slice's plan, so the line is never hand-edited.
|
|
130
130
|
|
|
@@ -1,92 +1,45 @@
|
|
|
1
1
|
#!/usr/bin/env sh
|
|
2
2
|
|
|
3
|
-
#
|
|
3
|
+
# Runs the post-merge steps through `canon hooks post-merge`: archive the task a
|
|
4
|
+
# merged pull request closed, push the records, reclaim merged worktrees,
|
|
5
|
+
# reinstall the CLI, and update the plugin cache. The verb owns the order and
|
|
6
|
+
# each line it prints.
|
|
4
7
|
#
|
|
5
|
-
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
+
# CANON_SKIP_RECLAIM=1 turns off the worktree reclaim.
|
|
9
|
+
# CANON_SKIP_UPGRADE=1 turns off the CLI reinstall.
|
|
10
|
+
# CANON_SKIP_PLUGIN_UPDATE=1 turns off the plugin cache update.
|
|
8
11
|
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
# the ship chain, so a task can read closed while its pull request is still open
|
|
12
|
-
# or already abandoned. The board is gitignored and an archived task has nothing
|
|
13
|
-
# to recover it, so `task-board` confirms the work is on `main` before moving
|
|
14
|
-
# anything and this output says so rather than implying the check is done.
|
|
12
|
+
# The reclaim reads merge state through `gh`, so a machine without it on PATH
|
|
13
|
+
# reclaims nothing and says nothing. Install `gh` to turn it on.
|
|
15
14
|
#
|
|
16
|
-
#
|
|
17
|
-
#
|
|
18
|
-
# regenerated by a session-scoped hook that a `mv` from a shell never fires.
|
|
19
|
-
#
|
|
20
|
-
# Husky runs this as `sh -e`, so the shebang is advisory and any bare command
|
|
21
|
-
# returning non-zero aborts the hook and prints a husky failure. Keep every
|
|
22
|
-
# test inside an `if` condition, which errexit exempts.
|
|
15
|
+
# Husky runs this as `sh -e`, and `post-rewrite` sources it on a rebase, so
|
|
16
|
+
# every test sits inside an `if` or an `||` and nothing here exits non-zero.
|
|
23
17
|
|
|
24
18
|
root=$(git worktree list --porcelain 2>/dev/null | grep -m 1 '^worktree ' | cut -d' ' -f2-) || root=''
|
|
25
19
|
[ -n "$root" ] || exit 0
|
|
26
20
|
|
|
27
21
|
# The board is shared scratch at the main worktree root, and `git worktree
|
|
28
|
-
# list` puts that root first. A
|
|
29
|
-
|
|
30
|
-
board="$root/.canon/tasks"
|
|
31
|
-
[ -d "$board" ] || exit 0
|
|
32
|
-
|
|
33
|
-
closed=""
|
|
34
|
-
count=0
|
|
35
|
-
|
|
36
|
-
for file in "$board"/*.md; do
|
|
37
|
-
[ -f "$file" ] || continue
|
|
22
|
+
# list` puts that root first. A checkout with no board runs none of the steps.
|
|
23
|
+
[ -d "$root/.canon/tasks" ] || exit 0
|
|
38
24
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
# Gate on the same condition both archive verbs gate on: at least one closed
|
|
44
|
-
# outcome and none open. A file carrying no outcomes at all falls out here,
|
|
45
|
-
# which is what keeps notes on the board from being announced as tasks.
|
|
46
|
-
if grep -q '^- \[ \]' "$file"; then
|
|
47
|
-
continue
|
|
48
|
-
fi
|
|
25
|
+
if ! command -v canon >/dev/null 2>&1; then
|
|
26
|
+
exit 0
|
|
27
|
+
fi
|
|
49
28
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
29
|
+
# A global binary older than the verb cannot run it, and the verb carries the
|
|
30
|
+
# only step that would update it, so reinstall here and probe again. The probe
|
|
31
|
+
# reads the verb's own usage line rather than the exit, since an older binary
|
|
32
|
+
# answers an unknown command's `--help` with its root help and exits zero.
|
|
33
|
+
has_verb() {
|
|
34
|
+
canon hooks post-merge --help 2>/dev/null | grep -q '^Usage: canon hooks post-merge'
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if ! has_verb; then
|
|
38
|
+
if [ -z "$CANON_SKIP_UPGRADE" ]; then
|
|
39
|
+
CANON_NON_INTERACTIVE=1 canon upgrade >/dev/null 2>&1 || true
|
|
54
40
|
fi
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
[ "$count" -gt 0 ] || exit 0
|
|
58
|
-
|
|
59
|
-
printf '\nš %s archive candidate(s) on the board:\n%s\n' "$count" "$closed"
|
|
60
|
-
printf 'Outcomes are marked on the branch, so each still needs its work confirmed\n'
|
|
61
|
-
printf 'on main. Run /task-board, which checks that and sweeps the plan first.\n\n'
|
|
62
|
-
|
|
63
|
-
# The gitignored record folders live on one disk and nowhere else, and a merge
|
|
64
|
-
# is the event that changes them most. This runs on every merge rather than
|
|
65
|
-
# only on one that closes a task, since a review report and a memory entry
|
|
66
|
-
# both land on runs that archive nothing.
|
|
67
|
-
#
|
|
68
|
-
# Sits last so a slow or unreachable remote delays no candidate announcement,
|
|
69
|
-
# and inside an `if` so a push failing offline, or a machine that never ran
|
|
70
|
-
# the one-time setup, still leaves this hook exiting zero. Guarded by its own
|
|
71
|
-
# `command -v canon` check, since this file carries none above it.
|
|
72
|
-
if command -v canon >/dev/null 2>&1; then
|
|
73
|
-
if backup=$(canon records push --root "$root" --json 2>/dev/null); then
|
|
74
|
-
changed=$(printf '%s' "$backup" | sed -n 's/.*"changed":\([0-9][0-9]*\).*/\1/p')
|
|
75
|
-
if [ -n "$changed" ] && [ "$changed" != "0" ]; then
|
|
76
|
-
printf '\nšļø Backed up %s record path(s).\n\n' "$changed"
|
|
77
|
-
fi
|
|
78
|
-
else
|
|
79
|
-
reason=$(printf '%s' "$backup" | sed -n 's/.*"reason":"\([^"]*\)".*/\1/p')
|
|
41
|
+
fi
|
|
80
42
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
if [ "$reason" != "no-repository" ]; then
|
|
84
|
-
printf '\nšļø Records not backed up: %s\n' "${reason:-unknown}"
|
|
85
|
-
if [ "$reason" = "unsafe-payload" ]; then
|
|
86
|
-
printf 'Run canon records push to see the blocked paths.\n\n'
|
|
87
|
-
else
|
|
88
|
-
printf 'Run canon records push when the remote is reachable.\n\n'
|
|
89
|
-
fi
|
|
90
|
-
fi
|
|
91
|
-
fi
|
|
43
|
+
if has_verb; then
|
|
44
|
+
canon hooks post-merge --root "$root" || true
|
|
92
45
|
fi
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env sh
|
|
2
2
|
|
|
3
|
-
# Runs the `post-merge`
|
|
3
|
+
# Runs the `post-merge` steps for anyone pulling with rebase.
|
|
4
4
|
#
|
|
5
5
|
# `git pull` under `pull.rebase=true` runs `git rebase`, which fires this event
|
|
6
6
|
# and never `post-merge`. Without this the trigger is a silent no-op on that
|
|
@@ -9,8 +9,8 @@
|
|
|
9
9
|
# target, where the pull style is not something this repository controls.
|
|
10
10
|
#
|
|
11
11
|
# Only the `rebase` argument delegates. `post-rewrite` also fires on
|
|
12
|
-
# `commit --amend`, which rewrites nothing on the board and would
|
|
13
|
-
#
|
|
12
|
+
# `commit --amend`, which rewrites nothing on the board and would run the
|
|
13
|
+
# archive, reclaim, and upgrade steps on an ordinary amend.
|
|
14
14
|
|
|
15
15
|
[ "$1" = "rebase" ] || exit 0
|
|
16
16
|
|
|
@@ -52,7 +52,7 @@ Seeds live in `tooling/base/seeds/`. Sync drops each once on first install and n
|
|
|
52
52
|
- `pre-commit` runs `bunx lint-staged`.
|
|
53
53
|
- `commit-msg` runs `bunx commitlint --edit "$1"`.
|
|
54
54
|
- `pre-push` runs `bun run check`.
|
|
55
|
-
- `post-merge`
|
|
55
|
+
- `post-merge` runs `canon hooks post-merge`: archive the task a merged pull request closed, push the records, reclaim merged worktrees, reinstall the CLI, and update the plugin. It does nothing when the board is absent. `CANON_SKIP_RECLAIM`, `CANON_SKIP_UPGRADE`, and `CANON_SKIP_PLUGIN_UPDATE` turn off the three steps that act on the machine. Read `canon docs hooks` for the contract.
|
|
56
56
|
- `post-rewrite` delegates to `post-merge` on `rebase`, so a `pull.rebase=true` machine still gets the check.
|
|
57
57
|
|
|
58
58
|
## lint-staged
|
package/tooling/go/manifest.toml
CHANGED
|
@@ -11,3 +11,6 @@ scaffold = "mkdir {{name}} && cd {{name}} && go mod init example.com/{{name}} &&
|
|
|
11
11
|
"test" = "go test ./..."
|
|
12
12
|
"test:run" = "go test -v ./..."
|
|
13
13
|
"build" = "go build ./..."
|
|
14
|
+
|
|
15
|
+
[verify]
|
|
16
|
+
prepare = "bun init -y && (command -v golangci-lint >/dev/null || go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest)"
|
package/tooling/go/reference.md
CHANGED
|
@@ -32,6 +32,7 @@ The go stack also ships `smoke_test.go` at the module root as a copy-once seed.
|
|
|
32
32
|
- Probe `go` and `golangci-lint` first, and name the `go install` command when `golangci-lint` is missing.
|
|
33
33
|
- Keep `build` out of `verify.json`, even though the stack declares the script. `go build ./...` exits 1 with `no packages to build` on a module holding only a test file, and a seeded `main.go` would assume every module is a binary rather than a library.
|
|
34
34
|
- Keep `-race` out of `test:run`. The race detector needs cgo and a C toolchain, so it belongs in CI rather than in a local `bun run check`.
|
|
35
|
+
- The manifest's `[verify] prepare` runs checklist steps 3 and 6 for `canon tooling verify go`: `bun init -y`, then the `golangci-lint` `go install` when the binary is not already on PATH. The scaffold writes no `package.json`, and a verify that reaches its end with none fails.
|
|
35
36
|
|
|
36
37
|
## CI
|
|
37
38
|
|
|
@@ -13,3 +13,6 @@ scaffold = "mkdir {{name}} && cd {{name}} && composer init -n --name example/{{n
|
|
|
13
13
|
|
|
14
14
|
[gitignore]
|
|
15
15
|
"# PHP" = ["vendor/", ".php-cs-fixer.cache", ".phpunit.cache/", ".phpunit.result.cache"]
|
|
16
|
+
|
|
17
|
+
[verify]
|
|
18
|
+
prepare = "bun init -y && composer require --dev friendsofphp/php-cs-fixer phpstan/phpstan phpunit/phpunit"
|
package/tooling/php/reference.md
CHANGED
|
@@ -44,6 +44,7 @@ The php stack also ships `tests/SmokeTest.php` as a copy-once seed, so `bun run
|
|
|
44
44
|
- Probe `php` and `composer` first.
|
|
45
45
|
- Hold a precondition on `vendor/bin/phpunit`, so the run stops and names `composer install` when it is missing.
|
|
46
46
|
- Require Typecheck (`phpstan analyse`), Lint (`php-cs-fixer check`), and Unit tests (`phpunit --testdox`). Base's runner adds the four base phases wherever the folder declares their scripts, then the markdown bans stage.
|
|
47
|
+
- The manifest's `[verify] prepare` runs checklist steps 2 and 5 for `canon tooling verify php`: `bun init -y`, then the `composer require --dev` line. The scaffold writes no `package.json`, and a verify that reaches its end with none fails.
|
|
47
48
|
|
|
48
49
|
## Gitignore
|
|
49
50
|
|
|
@@ -39,7 +39,7 @@ The manifest declares no `[dependencies.dev]`. Manifest injection currently call
|
|
|
39
39
|
|
|
40
40
|
## Verify command
|
|
41
41
|
|
|
42
|
-
`canon tooling verify
|
|
42
|
+
`canon tooling verify python` runs the manifest's `[verify] prepare` before Sync, which is checklist steps 2, 5, and 6: `bun init -y`, `uv add --dev ruff mypy pytest pytest-cov`, and `uv sync`. `uv init` writes no `package.json`, and a verify that reaches its end with none fails, so the prepare is what lets `bun run lint:fix` and `bun run check` run at all. Python's `verify.json` requires the `lint`, `typecheck`, and `test:run` scripts that delegate to `uv run`, so the run needs `uv` on the machine and a network for the installs. The end-to-end test and screenshot phases are gated on `package.json` script keys that python does not declare, so they skip.
|
|
43
43
|
|
|
44
44
|
## Anti-patterns
|
|
45
45
|
|