@erclx/canon 5.12.0 → 5.14.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 (74) 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 +4 -3
  4. package/claude/skills/api-design/references/adopted.md +1 -1
  5. package/claude/skills/auto-ship/REQUIREMENT.md +1 -0
  6. package/claude/skills/auto-ship/SKILL.md +2 -2
  7. package/claude/skills/build-in-slices/REQUIREMENT.md +45 -0
  8. package/claude/skills/build-in-slices/SKILL.md +94 -0
  9. package/claude/skills/build-in-slices/references/adopted.md +41 -0
  10. package/claude/skills/code-craft/REQUIREMENT.md +48 -0
  11. package/claude/skills/code-craft/SKILL.md +107 -0
  12. package/claude/skills/code-craft/references/adopted.md +52 -0
  13. package/claude/skills/codebase-layout/REQUIREMENT.md +1 -1
  14. package/claude/skills/deprecation-migration/REQUIREMENT.md +62 -0
  15. package/claude/skills/deprecation-migration/SKILL.md +115 -0
  16. package/claude/skills/deprecation-migration/references/adopted.md +50 -0
  17. package/claude/skills/git-followup/REQUIREMENT.md +1 -0
  18. package/claude/skills/git-followup/SKILL.md +3 -0
  19. package/claude/skills/git-pr/REQUIREMENT.md +1 -0
  20. package/claude/skills/git-pr/SKILL.md +1 -1
  21. package/claude/skills/git-pr/references/evidence.md +2 -2
  22. package/claude/skills/git-ship/REQUIREMENT.md +4 -0
  23. package/claude/skills/git-ship/SKILL.md +13 -2
  24. package/claude/skills/review-craft/REQUIREMENT.md +3 -0
  25. package/claude/skills/review-craft/SKILL.md +1 -1
  26. package/claude/skills/review-craft/references/adopted.md +8 -0
  27. package/claude/skills/review-craft/references/security.md +5 -0
  28. package/claude/skills/review-ui/REQUIREMENT.md +6 -2
  29. package/claude/skills/review-ui/SKILL.md +23 -4
  30. package/claude/skills/review-ui/references/driving.md +5 -3
  31. package/claude/skills/review-ui/references/post.md +3 -2
  32. package/claude/skills/role-worker/REQUIREMENT.md +1 -0
  33. package/claude/skills/role-worker/SKILL.md +4 -0
  34. package/claude/skills/test-first/REQUIREMENT.md +3 -0
  35. package/claude/skills/test-first/SKILL.md +2 -1
  36. package/docs/agents/commands.md +1 -1
  37. package/docs/agents/index.md +1 -0
  38. package/docs/agents/markdown-audit.md +1 -1
  39. package/docs/agents/pr-evidence.md +5 -0
  40. package/docs/agents/pr-frames.md +112 -0
  41. package/docs/agents/pr-preview.md +40 -12
  42. package/docs/workflow/skill-map.md +15 -12
  43. package/governance/rules/code/000-code.md +5 -1
  44. package/package.json +1 -1
  45. package/src/claude/cases/misc.ts +15 -0
  46. package/src/claude/skills-audit.ts +3 -0
  47. package/src/commands/pr.ts +559 -7
  48. package/src/commands/tooling.ts +39 -16
  49. package/src/context/architecture.ts +5 -2
  50. package/src/gate/boundaries.ts +509 -0
  51. package/src/gate/measures.ts +8 -19
  52. package/src/gate/stages.ts +12 -28
  53. package/src/pr/frames.ts +323 -0
  54. package/src/pr/preview.ts +66 -0
  55. package/src/tooling/manifest.ts +18 -3
  56. package/src/tooling/seeds.ts +37 -0
  57. package/src/tooling/verify-stack.ts +249 -0
  58. package/tooling/base/seeds/.cspell/tech-stack.txt +2 -0
  59. package/tooling/claude/manifest.toml +3 -3
  60. package/tooling/go/manifest.toml +3 -0
  61. package/tooling/go/reference.md +1 -0
  62. package/tooling/php/manifest.toml +3 -0
  63. package/tooling/php/reference.md +1 -0
  64. package/tooling/python/manifest.toml +3 -0
  65. package/tooling/python/reference.md +1 -1
  66. package/tooling/python/seeds/.cspell/tech-stack.txt +0 -1
  67. package/scripts/core/check-capability-seeding.sh +0 -134
  68. package/scripts/core/check-ignore-parity.sh +0 -141
  69. package/scripts/core/check-plugin-boundary.sh +0 -45
  70. package/scripts/core/check-seed-independence.sh +0 -62
  71. package/scripts/core/check-skill-paths.sh +0 -24
  72. package/scripts/core/list-seed-roots.sh +0 -18
  73. package/scripts/lib/tooling.sh +0 -37
  74. package/scripts/tooling/verify.sh +0 -184
@@ -1,3 +1,10 @@
1
+ import {
2
+ capabilitySeeding,
3
+ ignoreParity,
4
+ pluginBoundary,
5
+ seedIndependence,
6
+ skillPaths,
7
+ } from '@/gate/boundaries'
1
8
  import {
2
9
  architectureRecord,
3
10
  auditSet,
@@ -99,7 +106,6 @@ export const TEST_CORPORA_PATTERNS = [
99
106
  '^standards/markdown\\.md$',
100
107
  '^tooling/base/reference\\.md$',
101
108
  '^tooling/web/configs/scripts/worktree-port\\.sh$',
102
- '^scripts/core/check-ignore-parity\\.sh$',
103
109
  ]
104
110
 
105
111
  const TESTS_SCOPE = new RegExp(
@@ -277,34 +283,19 @@ export const STAGES: readonly Stage[] = [
277
283
  // produces either list, so there is nothing to regenerate and diff.
278
284
  id: 'ignore-parity',
279
285
  label: 'Ignore parity',
280
- checks: [
281
- script(
282
- 'check-ignore-parity.sh',
283
- "The ignore set a target receives disagrees with this repository's own.",
284
- ),
285
- ],
286
+ checks: [{ kind: 'measure', measure: ignoreParity }],
286
287
  success: 'Ignore parity clean',
287
288
  },
288
289
  {
289
290
  id: 'skill-paths',
290
291
  label: 'Skill paths',
291
- checks: [
292
- script(
293
- 'check-skill-paths.sh',
294
- 'Shipped skills reference a repo-local path.',
295
- ),
296
- ],
292
+ checks: [{ kind: 'measure', measure: skillPaths }],
297
293
  success: 'Skill paths clean',
298
294
  },
299
295
  {
300
296
  id: 'plugin-boundary',
301
297
  label: 'Plugin boundary',
302
- checks: [
303
- script(
304
- 'check-plugin-boundary.sh',
305
- 'Plugin ships toolkit-internal content.',
306
- ),
307
- ],
298
+ checks: [{ kind: 'measure', measure: pluginBoundary }],
308
299
  success: 'Plugin boundary clean',
309
300
  },
310
301
  {
@@ -315,9 +306,7 @@ export const STAGES: readonly Stage[] = [
315
306
  // propagates into every project scaffolded after it.
316
307
  id: 'seed-independence',
317
308
  label: 'Seed independence',
318
- checks: [
319
- script('check-seed-independence.sh', 'Seed prose cites the toolkit CLI.'),
320
- ],
309
+ checks: [{ kind: 'measure', measure: seedIndependence }],
321
310
  success: 'Seed prose cites no toolkit CLI',
322
311
  },
323
312
  {
@@ -326,12 +315,7 @@ export const STAGES: readonly Stage[] = [
326
315
  // recorded reason, per the criterion in canon/context/tooling/seeds.md.
327
316
  id: 'capability-seeding',
328
317
  label: 'Capability seeding',
329
- checks: [
330
- script(
331
- 'check-capability-seeding.sh',
332
- 'A capability reaches one side of the seed or config boundary and not the other.',
333
- ),
334
- ],
318
+ checks: [{ kind: 'measure', measure: capabilitySeeding }],
335
319
  success: 'Capability seeding clean',
336
320
  },
337
321
  {
@@ -0,0 +1,323 @@
1
+ import { readFile } from 'node:fs/promises'
2
+
3
+ /** The branch holding review frames. It never merges into a branch anyone checks out. */
4
+ export const FRAMES_BRANCH = 'canon-frames'
5
+
6
+ /** The eight bytes every PNG opens with. A frame that lacks them embeds as a broken image. */
7
+ const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]
8
+
9
+ const SHORT_HEAD_LENGTH = 7
10
+
11
+ const HEAD_PATTERN = /^[0-9a-f]{7,40}$/
12
+
13
+ /** A compact UTC instant such as `20261002T154450Z`, the form `date -u +%Y%m%dT%H%M%SZ` prints. */
14
+ const PASS_PATTERN = /^\d{8}T\d{6}Z$/
15
+
16
+ /** How many times an add rebuilds on a tip another writer moved. */
17
+ const ADD_ATTEMPTS = 3
18
+
19
+ const FOLDER_PATTERN = /^pr-(\d+)$/
20
+
21
+ const DAY_MS = 86_400_000
22
+
23
+ export interface TreeEntry {
24
+ readonly path: string
25
+ readonly mode: string
26
+ readonly type: 'blob' | 'tree'
27
+ readonly sha: string
28
+ }
29
+
30
+ export type Tip =
31
+ | { readonly kind: 'found'; readonly commit: string; readonly tree: string }
32
+ | { readonly kind: 'missing' }
33
+ | { readonly kind: 'unreadable' }
34
+
35
+ /** How a ref write landed. `conflict` means another writer holds the ref the call expected to own. */
36
+ export type RefWrite = 'ok' | 'conflict' | 'failed'
37
+
38
+ export interface PullClosure {
39
+ readonly state: 'open' | 'closed'
40
+ readonly closedAt?: string
41
+ }
42
+
43
+ /**
44
+ * The git data API calls a frame write needs, injected so the logic runs
45
+ * without a network. Every read that fails returns undefined rather than
46
+ * throwing, and each caller decides what an absent answer means.
47
+ */
48
+ export interface FramesApi {
49
+ readTip(): Promise<Tip>
50
+ listTree(tree: string): Promise<readonly TreeEntry[] | undefined>
51
+ createBlob(base64: string): Promise<string | undefined>
52
+ createTree(
53
+ entries: readonly TreeEntry[],
54
+ base?: string,
55
+ ): Promise<string | undefined>
56
+ createCommit(
57
+ message: string,
58
+ tree: string,
59
+ parents: readonly string[],
60
+ ): Promise<string | undefined>
61
+ createRef(commit: string): Promise<RefWrite>
62
+ moveRef(commit: string, force: boolean): Promise<RefWrite>
63
+ deleteRef(): Promise<boolean>
64
+ readPull(number: number): Promise<PullClosure | undefined>
65
+ }
66
+
67
+ export type FramesRefusal = 'unreadable-tip' | 'push-failed' | 'ref-conflict'
68
+
69
+ export interface FrameInput {
70
+ readonly number: number
71
+ readonly head: string
72
+ readonly pass: string
73
+ readonly box: number
74
+ readonly bytes: Uint8Array
75
+ }
76
+
77
+ export type PushResult =
78
+ | {
79
+ readonly kind: 'pushed'
80
+ readonly commit: string
81
+ readonly path: string
82
+ readonly created: boolean
83
+ }
84
+ | { readonly kind: 'refused'; readonly reason: FramesRefusal }
85
+
86
+ export type DropResult =
87
+ | {
88
+ readonly kind: 'dropped'
89
+ readonly removed: readonly number[]
90
+ readonly commit?: string
91
+ readonly deleted?: boolean
92
+ }
93
+ | { readonly kind: 'refused'; readonly reason: FramesRefusal }
94
+
95
+ export type PruneResult =
96
+ | {
97
+ readonly kind: 'dropped'
98
+ readonly removed: readonly number[]
99
+ readonly kept: readonly number[]
100
+ readonly unread: readonly number[]
101
+ readonly commit?: string
102
+ readonly deleted?: boolean
103
+ }
104
+ | { readonly kind: 'refused'; readonly reason: FramesRefusal }
105
+
106
+ /** Whether a value names a commit, so a branch name or a path never becomes a folder. */
107
+ export function isFrameHead(head: string): boolean {
108
+ return HEAD_PATTERN.test(head)
109
+ }
110
+
111
+ /** Whether a value is a compact UTC stamp, the only pass segment the path takes. */
112
+ export function isFramePass(pass: string): boolean {
113
+ return PASS_PATTERN.test(pass)
114
+ }
115
+
116
+ /**
117
+ * Where a frame lands. The pass segment keeps a second pass at the same head
118
+ * and box from replacing the image an earlier review comment shows, since a
119
+ * branch-path link reads whatever the path holds now.
120
+ */
121
+ export function framePath(
122
+ number: number,
123
+ head: string,
124
+ pass: string,
125
+ box: number,
126
+ ): string {
127
+ return `pr-${number}/${head.slice(0, SHORT_HEAD_LENGTH)}/${pass}/box-${box}.png`
128
+ }
129
+
130
+ /**
131
+ * The embed link, by branch path rather than by commit. Every drop rewrites
132
+ * the branch, so a commit-pinned link to another pull request's frame would
133
+ * stop resolving once GitHub collects the commit, while a path link lives
134
+ * exactly as long as its file. The blob form is the one `canon pr evidence`
135
+ * embeds, which a private repository's browser can open where the raw host
136
+ * serves nothing.
137
+ */
138
+ export function frameLink(repo: string, path: string): string {
139
+ return `https://github.com/${repo}/blob/${FRAMES_BRANCH}/${path}?raw=true`
140
+ }
141
+
142
+ /** The frame's bytes, or undefined when the file is absent, empty, or not a PNG. */
143
+ export async function readFrame(path: string): Promise<Uint8Array | undefined> {
144
+ const bytes = await readFile(path).catch(() => undefined)
145
+ if (bytes === undefined || bytes.length <= PNG_SIGNATURE.length) {
146
+ return undefined
147
+ }
148
+ return PNG_SIGNATURE.every((byte, index) => bytes[index] === byte)
149
+ ? new Uint8Array(bytes)
150
+ : undefined
151
+ }
152
+
153
+ /**
154
+ * Commits one frame onto the branch, creating the branch with a root
155
+ * commit when it is missing. The ref only fast-forwards here, so a drop or a
156
+ * second add landing between the read and the move fails the move, and the
157
+ * add rebuilds on the tip that writer left.
158
+ */
159
+ export async function pushFrame(
160
+ api: FramesApi,
161
+ input: FrameInput,
162
+ ): Promise<PushResult> {
163
+ const path = framePath(input.number, input.head, input.pass, input.box)
164
+ const blob = await api.createBlob(Buffer.from(input.bytes).toString('base64'))
165
+ if (blob === undefined) return { kind: 'refused', reason: 'push-failed' }
166
+ const entry: TreeEntry = { path, mode: '100644', type: 'blob', sha: blob }
167
+ const message = `chore(frames): add box ${input.box} of #${input.number}`
168
+
169
+ for (let attempt = 0; attempt < ADD_ATTEMPTS; attempt += 1) {
170
+ const tip = await api.readTip()
171
+ if (tip.kind === 'unreadable') {
172
+ return { kind: 'refused', reason: 'unreadable-tip' }
173
+ }
174
+
175
+ const base = tip.kind === 'found' ? tip.tree : undefined
176
+ const tree = await api.createTree([entry], base)
177
+ if (tree === undefined) return { kind: 'refused', reason: 'push-failed' }
178
+ const parents = tip.kind === 'found' ? [tip.commit] : []
179
+ const commit = await api.createCommit(message, tree, parents)
180
+ if (commit === undefined) return { kind: 'refused', reason: 'push-failed' }
181
+
182
+ const write =
183
+ tip.kind === 'found'
184
+ ? await api.moveRef(commit, false)
185
+ : await api.createRef(commit)
186
+ if (write === 'failed') return { kind: 'refused', reason: 'push-failed' }
187
+ if (write === 'ok') {
188
+ return { kind: 'pushed', commit, path, created: tip.kind === 'missing' }
189
+ }
190
+ }
191
+ return { kind: 'refused', reason: 'ref-conflict' }
192
+ }
193
+
194
+ function folderNumber(entry: TreeEntry): number | undefined {
195
+ if (entry.type !== 'tree') return undefined
196
+ const match = FOLDER_PATTERN.exec(entry.path)
197
+ return match?.[1] === undefined ? undefined : Number(match[1])
198
+ }
199
+
200
+ async function readFolders(
201
+ api: FramesApi,
202
+ ): Promise<
203
+ | { readonly kind: 'read'; readonly entries: readonly TreeEntry[] }
204
+ | { readonly kind: 'missing' }
205
+ | { readonly kind: 'refused'; readonly reason: FramesRefusal }
206
+ > {
207
+ const tip = await api.readTip()
208
+ if (tip.kind === 'missing') return { kind: 'missing' }
209
+ if (tip.kind === 'unreadable') {
210
+ return { kind: 'refused', reason: 'unreadable-tip' }
211
+ }
212
+ const entries = await api.listTree(tip.tree)
213
+ if (entries === undefined) {
214
+ return { kind: 'refused', reason: 'unreadable-tip' }
215
+ }
216
+ return { kind: 'read', entries }
217
+ }
218
+
219
+ /**
220
+ * Rewrites the branch without the named pull requests' folders, as one
221
+ * root commit the ref is force-moved to. A plain delete commit would
222
+ * keep every dropped image reachable through history. The rewrite builds from
223
+ * the tip it reads, and a branch left empty is deleted rather than holding an
224
+ * empty tree.
225
+ */
226
+ async function rewriteWithout(
227
+ api: FramesApi,
228
+ entries: readonly TreeEntry[],
229
+ drop: ReadonlySet<number>,
230
+ ): Promise<DropResult> {
231
+ const removed = entries
232
+ .map(folderNumber)
233
+ .filter((n): n is number => n !== undefined && drop.has(n))
234
+ .sort((a, b) => a - b)
235
+ if (removed.length === 0) return { kind: 'dropped', removed: [] }
236
+
237
+ const kept = entries.filter((entry) => {
238
+ const n = folderNumber(entry)
239
+ return n === undefined || !drop.has(n)
240
+ })
241
+ if (kept.length === 0) {
242
+ return (await api.deleteRef())
243
+ ? { kind: 'dropped', removed, deleted: true }
244
+ : { kind: 'refused', reason: 'push-failed' }
245
+ }
246
+
247
+ const tree = await api.createTree(kept)
248
+ if (tree === undefined) return { kind: 'refused', reason: 'push-failed' }
249
+ const message = `chore(frames): drop ${removed.map((n) => `#${n}`).join(', ')}`
250
+ const commit = await api.createCommit(message, tree, [])
251
+ if (commit === undefined) return { kind: 'refused', reason: 'push-failed' }
252
+ const write = await api.moveRef(commit, true)
253
+ return write === 'ok'
254
+ ? { kind: 'dropped', removed, commit }
255
+ : { kind: 'refused', reason: 'push-failed' }
256
+ }
257
+
258
+ export async function dropFrames(
259
+ api: FramesApi,
260
+ numbers: readonly number[],
261
+ ): Promise<DropResult> {
262
+ const folders = await readFolders(api)
263
+ if (folders.kind === 'missing') return { kind: 'dropped', removed: [] }
264
+ if (folders.kind === 'refused') return folders
265
+ return rewriteWithout(api, folders.entries, new Set(numbers))
266
+ }
267
+
268
+ /**
269
+ * Whether a pull request's frames are past keeping. Only a pull request
270
+ * reading closed, with a close date strictly older than the window, expires,
271
+ * so a reopened one, an unread one, and one at the boundary all stay.
272
+ */
273
+ export function isExpired(
274
+ closure: PullClosure | undefined,
275
+ days: number,
276
+ now: Date,
277
+ ): boolean {
278
+ if (closure?.state !== 'closed' || closure.closedAt === undefined) {
279
+ return false
280
+ }
281
+ const closed = Date.parse(closure.closedAt)
282
+ if (Number.isNaN(closed)) return false
283
+ return now.getTime() - closed > days * DAY_MS
284
+ }
285
+
286
+ /**
287
+ * Drops every pull request on the branch that closed more than `days` ago, in
288
+ * one rewrite. A pull request whose state could not be read is kept and
289
+ * listed under `unread`, so a failed read never deletes frames.
290
+ */
291
+ export async function pruneFrames(
292
+ api: FramesApi,
293
+ days: number,
294
+ now: Date,
295
+ ): Promise<PruneResult> {
296
+ const folders = await readFolders(api)
297
+ if (folders.kind === 'missing') {
298
+ return { kind: 'dropped', removed: [], kept: [], unread: [] }
299
+ }
300
+ if (folders.kind === 'refused') return folders
301
+
302
+ const numbers = folders.entries
303
+ .map(folderNumber)
304
+ .filter((n): n is number => n !== undefined)
305
+ const closures = await Promise.all(numbers.map((n) => api.readPull(n)))
306
+ const expired = new Set<number>()
307
+ const kept: number[] = []
308
+ const unread: number[] = []
309
+ numbers.forEach((n, index) => {
310
+ const closure = closures[index]
311
+ if (closure === undefined) unread.push(n)
312
+ if (isExpired(closure, days, now)) expired.add(n)
313
+ else kept.push(n)
314
+ })
315
+
316
+ const result = await rewriteWithout(api, folders.entries, expired)
317
+ if (result.kind === 'refused') return result
318
+ return {
319
+ ...result,
320
+ kept: kept.sort((a, b) => a - b),
321
+ unread: unread.sort((a, b) => a - b),
322
+ }
323
+ }
package/src/pr/preview.ts CHANGED
@@ -95,6 +95,8 @@ export interface RunRow {
95
95
  readonly databaseId: number
96
96
  readonly status: string
97
97
  readonly conclusion: string
98
+ readonly headSha: string
99
+ readonly createdAt: string
98
100
  }
99
101
 
100
102
  /** The `gh` calls a preview makes, injected so the wait can be driven without a network. */
@@ -176,3 +178,67 @@ export async function mintPreview(
176
178
 
177
179
  return { kind: 'minted', runId, url }
178
180
  }
181
+
182
+ export type PreviewHeadReason = 'fresh' | 'stale' | 'building' | 'no-build'
183
+
184
+ export type PreviewHeadReading =
185
+ | {
186
+ readonly reason: PreviewHeadReason
187
+ readonly tip: string
188
+ readonly built?: string
189
+ readonly runId?: number
190
+ }
191
+ | { readonly reason: 'gh-failed' }
192
+
193
+ /**
194
+ * Compares the head the newest successful dispatch built with the branch tip.
195
+ *
196
+ * The run listing is the only record of the built head, since the alias serves
197
+ * the newest successful deployment per branch and names no sha. Runs are
198
+ * ordered by creation rather than list position, so the verdict follows the
199
+ * most recent deploy. A run still going at the tip reads `building` rather
200
+ * than `stale`, so a mint already under way is not reported as a failure.
201
+ */
202
+ export async function readPreviewHead(
203
+ runner: PreviewRunner,
204
+ target: {
205
+ readonly workflow: string
206
+ readonly branch: string
207
+ readonly tip: string
208
+ },
209
+ ): Promise<PreviewHeadReading> {
210
+ const rows = await runner.listRuns(target.workflow, target.branch)
211
+ if (rows === undefined) return { reason: 'gh-failed' }
212
+
213
+ const { tip } = target
214
+ const newest = [...rows].sort((a, b) =>
215
+ b.createdAt.localeCompare(a.createdAt),
216
+ )
217
+ const built = newest.find(
218
+ (row) => row.status === 'completed' && row.conclusion === 'success',
219
+ )
220
+
221
+ if (built?.headSha === tip) {
222
+ return {
223
+ reason: 'fresh',
224
+ built: built.headSha,
225
+ tip,
226
+ runId: built.databaseId,
227
+ }
228
+ }
229
+
230
+ const pending = newest.find(
231
+ (row) => row.status !== 'completed' && row.headSha === tip,
232
+ )
233
+ if (pending !== undefined) {
234
+ return {
235
+ reason: 'building',
236
+ tip,
237
+ runId: pending.databaseId,
238
+ ...(built !== undefined && { built: built.headSha }),
239
+ }
240
+ }
241
+
242
+ if (built === undefined) return { reason: 'no-build', tip }
243
+ return { reason: 'stale', built: built.headSha, tip, runId: built.databaseId }
244
+ }
@@ -30,6 +30,14 @@ export interface Manifest {
30
30
  * gitignore entries.
31
31
  */
32
32
  readonly onlyConfigs?: readonly string[]
33
+ /**
34
+ * The command that creates a fresh project for this stack, from `[stack]
35
+ * scaffold`, with `{{name}}` standing for the project folder. Unset on a
36
+ * layer stack, which scaffolds nothing of its own.
37
+ */
38
+ readonly scaffold?: string
39
+ /** Post-scaffold setup `canon tooling verify` runs ahead of Sync. */
40
+ readonly prepare?: string
33
41
  }
34
42
 
35
43
  export interface ChainOptions {
@@ -103,9 +111,8 @@ export function isStackExcluded(stack: string): boolean {
103
111
  }
104
112
 
105
113
  /**
106
- * Lists installable stack names, filtering the `claude` stack the way
107
- * `is_tooling_stack_excluded` in `scripts/lib/tooling.sh` did. Claude is
108
- * managed by `canon claude`, not `canon tooling`.
114
+ * Lists installable stack names, filtering the stacks in `EXCLUDED_STACKS`.
115
+ * Claude is managed by `canon claude`, not `canon tooling`.
109
116
  */
110
117
  export function listStacks(root: string): string[] {
111
118
  const dir = toolingDir(root)
@@ -134,6 +141,8 @@ export function loadManifest(
134
141
  typeof stackTable.extends === 'string' ? stackTable.extends : ''
135
142
  const scriptsTable = asTable(parsed.scripts)
136
143
  const perRoot = readStrings(asTable(parsed.sync).per_root)
144
+ const scaffold = readString(stackTable.scaffold)
145
+ const prepare = readString(asTable(parsed.verify).prepare)
137
146
  const dir = join(toolingDir(root), stack)
138
147
 
139
148
  return {
@@ -147,6 +156,8 @@ export function loadManifest(
147
156
  gitignore: readGitignoreSections(asTable(parsed.gitignore)),
148
157
  devPackages: readDevPackages(parsed.dependencies),
149
158
  ...(perRoot.length > 0 ? { perRoot } : {}),
159
+ ...(scaffold !== undefined ? { scaffold } : {}),
160
+ ...(prepare !== undefined ? { prepare } : {}),
150
161
  }
151
162
  }
152
163
 
@@ -231,6 +242,10 @@ function readDevPackages(dependencies: unknown): string[] {
231
242
  return readStrings(asTable(asTable(dependencies).dev).packages)
232
243
  }
233
244
 
245
+ function readString(value: unknown): string | undefined {
246
+ return typeof value === 'string' && value !== '' ? value : undefined
247
+ }
248
+
234
249
  function readStrings(value: unknown): string[] {
235
250
  if (!Array.isArray(value)) return []
236
251
  return value.filter(
@@ -0,0 +1,37 @@
1
+ import { existsSync, readdirSync, statSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+
4
+ const TOOLING = 'tooling'
5
+ const SURFACE_ROOTS = ['.claude', 'canon']
6
+
7
+ /**
8
+ * Every `tooling/<stack>/seeds` holding a `.claude/` or a `canon/`, sorted and
9
+ * relative to the root.
10
+ *
11
+ * Both seed stages discover through this rather than naming a stack, so a stack
12
+ * seeding either root later reaches both and the two cannot disagree about which
13
+ * roots exist. A stack seeding only tracked surfaces carries `canon/` alone,
14
+ * which a `.claude/` test would drop from both stages. An absent `tooling/` is
15
+ * an empty answer, and each caller decides what an empty answer means.
16
+ */
17
+ export function seedRoots(root: string): string[] {
18
+ const tooling = join(root, TOOLING)
19
+ if (!existsSync(tooling)) return []
20
+
21
+ return readdirSync(tooling)
22
+ .filter((name) => !name.startsWith('.'))
23
+ .sort()
24
+ .map((name) => `${TOOLING}/${name}/seeds`)
25
+ .filter((seeds) => isDirectory(join(root, seeds)))
26
+ .filter((seeds) =>
27
+ SURFACE_ROOTS.some((surface) => isDirectory(join(root, seeds, surface))),
28
+ )
29
+ }
30
+
31
+ function isDirectory(path: string): boolean {
32
+ try {
33
+ return statSync(path).isDirectory()
34
+ } catch {
35
+ return false
36
+ }
37
+ }