@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.
Files changed (72) 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 +2 -0
  6. package/claude/skills/auto-ship/SKILL.md +7 -10
  7. package/claude/skills/auto-ship/references/failure-recovery.md +0 -1
  8. package/claude/skills/auto-ship/references/ui-checklist.md +21 -0
  9. package/claude/skills/build-in-slices/REQUIREMENT.md +45 -0
  10. package/claude/skills/build-in-slices/SKILL.md +94 -0
  11. package/claude/skills/build-in-slices/references/adopted.md +41 -0
  12. package/claude/skills/code-craft/REQUIREMENT.md +48 -0
  13. package/claude/skills/code-craft/SKILL.md +107 -0
  14. package/claude/skills/code-craft/references/adopted.md +52 -0
  15. package/claude/skills/codebase-layout/REQUIREMENT.md +1 -1
  16. package/claude/skills/git-pr/references/evidence.md +7 -7
  17. package/claude/skills/git-ship/REQUIREMENT.md +2 -1
  18. package/claude/skills/git-ship/SKILL.md +2 -2
  19. package/claude/skills/review-craft/REQUIREMENT.md +3 -0
  20. package/claude/skills/review-craft/SKILL.md +1 -1
  21. package/claude/skills/review-craft/references/adopted.md +8 -0
  22. package/claude/skills/review-craft/references/security.md +5 -0
  23. package/claude/skills/review-craft/references/ui.md +1 -2
  24. package/claude/skills/review-pr/SKILL.md +1 -1
  25. package/claude/skills/review-ui/REQUIREMENT.md +1 -0
  26. package/claude/skills/review-ui/SKILL.md +11 -0
  27. package/claude/skills/role-worker/REQUIREMENT.md +1 -0
  28. package/claude/skills/role-worker/SKILL.md +4 -0
  29. package/claude/skills/test-first/REQUIREMENT.md +3 -0
  30. package/claude/skills/test-first/SKILL.md +2 -1
  31. package/claude/skills/ui-checklist/SKILL.md +1 -18
  32. package/docs/agents/gate.md +1 -1
  33. package/docs/agents/hooks.md +2 -0
  34. package/docs/agents/install-and-sync.md +2 -2
  35. package/docs/agents/pr-evidence.md +26 -25
  36. package/docs/agents/pr-preview.md +40 -12
  37. package/docs/agents/tasks-validate.md +3 -3
  38. package/docs/target/migrations.md +1 -1
  39. package/docs/workflow/ai-workflow.md +2 -1
  40. package/docs/workflow/parallel-features.md +3 -3
  41. package/docs/workflow/skill-map.md +2 -0
  42. package/governance/rules/code/000-code.md +5 -1
  43. package/package.json +3 -4
  44. package/scripts/lib/sandbox-path.sh +1 -1
  45. package/src/claude/cases/misc.ts +10 -0
  46. package/src/claude/skills-audit.ts +2 -0
  47. package/src/commands/pr.ts +123 -21
  48. package/src/commands/tooling.ts +39 -16
  49. package/src/gate/stages.ts +10 -2
  50. package/src/pr/preview.ts +66 -0
  51. package/src/tasks/validate.ts +37 -27
  52. package/src/tooling/manifest.ts +16 -0
  53. package/src/tooling/verify-stack.ts +249 -0
  54. package/standards/tasks.md +1 -1
  55. package/tooling/base/configs/.husky/post-merge +30 -77
  56. package/tooling/base/configs/.husky/post-rewrite +3 -3
  57. package/tooling/base/reference.md +1 -1
  58. package/tooling/base/seeds/.cspell/tech-stack.txt +2 -0
  59. package/tooling/go/manifest.toml +3 -0
  60. package/tooling/go/reference.md +1 -0
  61. package/tooling/php/manifest.toml +3 -0
  62. package/tooling/php/reference.md +1 -0
  63. package/tooling/python/manifest.toml +3 -0
  64. package/tooling/python/reference.md +1 -1
  65. package/tooling/python/seeds/.cspell/tech-stack.txt +0 -1
  66. package/scripts/core/clean.sh +0 -36
  67. package/scripts/core/regen-agent-fixture.sh +0 -235
  68. package/scripts/core/regen-claude-copies.sh +0 -10
  69. package/scripts/core/regen-indexes.sh +0 -10
  70. package/scripts/core/snapshot.sh +0 -134
  71. package/scripts/core/update.sh +0 -35
  72. package/scripts/tooling/verify.sh +0 -184
@@ -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 target = readPlanTarget(text)
735
- if (!target) {
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
- const cited = planPath(target, dir, root)
749
- if (rowed === cited) return undefined
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: `is rowed against ${row.plan} and cites ${target} in its own Plan: line. One task names one plan.`,
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 target = readPlanTarget(text)
798
- if (!target) {
799
- const several = severalPlans(text, row.group, row.stem)
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 live = target ? resolveLivePlan(target, dir, root) : undefined
804
- const hasLivePlan = live !== undefined && existsSync(live)
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: target
821
- ? `sits under ${row.group}, which claims a written plan, and its task cites ${target}, which is not a live plan.`
822
- : `sits under ${row.group}, which claims a written plan, and its task carries no Plan: line.`,
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 one plan. `readPlanTarget` reads
831
- * such a line as naming nothing, so without this a caller would report the
832
- * line as absent when it is there and breaks the one-plan rule instead.
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 severalPlans(
835
- text: string,
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 targets = readPlanTargets(text)
840
- if (targets.length < 2) return undefined
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: `cites ${targets.length} plans on its Plan: line (${targets.join(', ')}). One task names one plan, so split the task or drop the extra links.`,
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
 
@@ -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
+ }
@@ -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 a sliced task. A plan cited by two tasks is a misfile rather than a shape to design for.
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
- # Names archive candidates after a pull merges work in.
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
- # Every step before the merge fires from a chain and the archive fires when a
6
- # person remembers, so shipped tasks accumulate on a board that reads as ready
7
- # to hand a worker. This is the only event that lands after a merge.
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
- # Candidates rather than closed tasks, because an all-`[x]` board entry does not
10
- # mean the work merged. `context-fold` marks outcomes on the branch as step 1 of
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
- # It announces and never moves a file. The board is gitignored, so a shell-side
17
- # archive leaves no diff to review with nobody watching, and `index.md` is
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 pull inside a linked worktree fires this same
29
- # hook, so trusting the working directory would read a board that is not there.
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
- case "$file" in
40
- */index.md | */priority.md) continue ;;
41
- esac
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
- if grep -q '^- \[x\]' "$file"; then
51
- closed="$closed $(basename "$file" .md)
52
- "
53
- count=$((count + 1))
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
- done
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
- # No records history on this machine, which is every checkout that never
82
- # ran the one-time setup. Nothing to report.
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` board check for anyone pulling with rebase.
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 print the
13
- # candidate list on an ordinary amend.
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` names `.canon/tasks/` archive candidates, staying silent otherwise and when the board is absent.
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
@@ -3,11 +3,13 @@ bradlc
3
3
  bunx
4
4
  commitlint
5
5
  dbaeumer
6
+ dotenv
6
7
  dotfolders
7
8
  erclx
8
9
  errexit
9
10
  esac
10
11
  esbenp
12
+ eslintcache
11
13
  frontmatter
12
14
  lintstagedrc
13
15
  mkhl
@@ -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)"
@@ -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"
@@ -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
 
@@ -14,3 +14,6 @@ scaffold = "uv init --package {{name}}"
14
14
 
15
15
  [gitignore]
16
16
  "# Python" = [".pytest_cache/", ".mypy_cache/", ".ruff_cache/", ".coverage_cache/"]
17
+
18
+ [verify]
19
+ prepare = "bun init -y && uv add --dev ruff mypy pytest pytest-cov && uv sync"
@@ -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 <stack>` currently runs `bun run lint:fix` and `bun run check` against any stack with a `package.json`. Since base ships a `package.json`, those run for python too. Python's `verify.json` requires the package.json `lint`/`typecheck`/`test:run` scripts that delegate to `uv run`, so the verify path works end-to-end as long as `uv` is installed in the verify environment. The end-to-end test and screenshot phases are gated on `package.json` script keys that python does not declare, so they cleanly skip.
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
 
@@ -2,7 +2,6 @@ addopts
2
2
  appuser
3
3
  asyncio
4
4
  coveragerc
5
- dotenv
6
5
  fastapi
7
6
  flake8
8
7
  htmlcov