@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,6 +1,5 @@
1
1
  import { resolve } from 'node:path'
2
2
  import type { Command } from 'commander'
3
- import { execScript } from '@/exec'
4
3
  import { checkoutMismatchWarning, PROJECT_ROOT } from '@/project-root'
5
4
  import {
6
5
  injectConfigs,
@@ -21,6 +20,7 @@ import { readReference, resolveReference } from '@/tooling/read'
21
20
  import { scan, type ScanResult } from '@/tooling/scan'
22
21
  import { recordedStacks, recordToolingChain } from '@/tooling/stamp'
23
22
  import { subfolderPath } from '@/tooling/subfolder'
23
+ import { verifyStack } from '@/tooling/verify-stack'
24
24
  import {
25
25
  intro,
26
26
  isNonInteractive,
@@ -34,8 +34,6 @@ import {
34
34
  select,
35
35
  } from '@/ui'
36
36
 
37
- const PASS_THROUGH_VERBS = ['verify'] as const
38
-
39
37
  interface SyncOptions {
40
38
  readonly skip?: string
41
39
  readonly check?: boolean
@@ -60,6 +58,10 @@ interface PruneOptions {
60
58
  readonly nested?: boolean
61
59
  }
62
60
 
61
+ interface VerifyOptions {
62
+ readonly keep?: boolean
63
+ }
64
+
63
65
  interface ListOptions {
64
66
  readonly json?: boolean
65
67
  }
@@ -207,18 +209,39 @@ export function register(program: Command): void {
207
209
  process.exitCode = printReference(stack)
208
210
  })
209
211
 
210
- for (const verb of PASS_THROUGH_VERBS) {
211
- tooling
212
- .command(verb)
213
- .description(`Run the tooling ${verb} command`)
214
- .allowUnknownOption()
215
- .allowExcessArguments(true)
216
- .passThroughOptions()
217
- .helpOption(false)
218
- .action(async (_opts: unknown, cmd: Command) => {
219
- await execScript(`tooling/${verb}.sh`, cmd.args)
212
+ tooling
213
+ .command('verify')
214
+ .description('Scaffold a stack fresh, sync it, and run its checks')
215
+ .argument('<stack>', 'Tooling stack name (e.g. vite-react, astro)')
216
+ .helpOption('-h, --help', 'Show this help message')
217
+ .option('--keep', 'Keep the tmp dir after a clean run for inspection')
218
+ .addHelpText(
219
+ 'after',
220
+ [
221
+ '',
222
+ 'Scaffolds <stack> into .canon/tmp/runs/verify-<stack>/, runs the',
223
+ "manifest's [verify] prepare, syncs the chain from this checkout, then",
224
+ 'runs lint:fix, check, test:e2e, and screenshot where the scaffold',
225
+ 'declares them. A scaffold with no package.json after Sync fails.',
226
+ '',
227
+ 'Exit codes:',
228
+ ' 0 every phase passed',
229
+ ' 1 a phase failed, or the stack cannot be verified',
230
+ '',
231
+ 'Examples:',
232
+ ' canon tooling verify vite-react',
233
+ ' canon tooling verify astro --keep',
234
+ '',
235
+ ].join('\n'),
236
+ )
237
+ .action(async (stack: string, opts: VerifyOptions) => {
238
+ const outcome = await verifyStack({
239
+ root: PROJECT_ROOT,
240
+ stack,
241
+ keep: opts.keep === true,
220
242
  })
221
- }
243
+ process.exitCode = outcome.exitCode
244
+ })
222
245
  }
223
246
 
224
247
  /**
@@ -227,8 +250,8 @@ export function register(program: Command): void {
227
250
  * quote emitted output a consuming skill could not parse.
228
251
  *
229
252
  * The frame opens after the `--json` return. The bash emitted a closing `└`
230
- * from its EXIT trap with no `┌` above it, because the hand-rolled
231
- * pass-through loop below opens no frame before it runs the script.
253
+ * from its EXIT trap with no `┌` above it, because the pass-through loop that
254
+ * ran it opened no frame first.
232
255
  */
233
256
  function runList(opts: ListOptions): number {
234
257
  // The warning is a frame-interior line, so it goes out after `intro` on the
@@ -122,8 +122,11 @@ const QUANTIFIES_PATH = new RegExp(
122
122
  'i',
123
123
  )
124
124
 
125
- /** A code span naming a shell check this repository could run. */
126
- const SCRIPT_SPAN = /^scripts\/[\w./-]+\.sh$/
125
+ /**
126
+ * A code span naming a check this repository could run: a shell script, or a
127
+ * gate module whose measures read the tree in-process.
128
+ */
129
+ const SCRIPT_SPAN = /^(?:scripts\/[\w./-]+\.sh|src\/gate\/[\w./-]+\.ts)$/
127
130
  /** A code span invoking the CLI, which may or may not name a registered audit. */
128
131
  const CANON_SPAN = /^canon\s+(.+)$/
129
132
 
@@ -0,0 +1,509 @@
1
+ import { readdirSync, readFileSync, realpathSync, statSync } from 'node:fs'
2
+ import { join, relative, sep } from 'node:path'
3
+
4
+ import type { Measure, MeasureReport } from '@/gate/measures'
5
+ import { seedRoots } from '@/tooling/seeds'
6
+
7
+ /**
8
+ * The checks guarding what crosses from this repository into a target: the
9
+ * ignore set a manifest ships, the capabilities a seed carries, the plugin's
10
+ * reach, the prose a seed installs, and the paths a shipped skill cites.
11
+ *
12
+ * Each reads the tree in-process and reports `unmeasured` wherever its walk
13
+ * covered nothing, since a verdict over an empty walk reports the pass the
14
+ * check exists to withhold.
15
+ */
16
+
17
+ const GITIGNORE = '.gitignore'
18
+ const CLAUDE_MANIFEST = 'tooling/claude/manifest.toml'
19
+ const IGNORE_SECTION = '# Claude'
20
+ const NO_SEED_MARKER = 'canon-no-seed:'
21
+ const TOOLKIT_TOKEN = 'canon'
22
+ const BANNED_SKILL_PATH = /(^|[^A-Za-z0-9._/-])wiki\//
23
+
24
+ const posixRel = (root: string, path: string): string =>
25
+ relative(root, path).split(sep).join('/')
26
+
27
+ const pass = (): MeasureReport => ({ emissions: [] })
28
+
29
+ const unmeasured = (text: string): MeasureReport => ({
30
+ emissions: [],
31
+ unmeasured: text,
32
+ })
33
+
34
+ /**
35
+ * The stage's one-line `failure` frames as a single line, so the header, the
36
+ * offender lines, and the remediation sentence travel as borrowed output, the
37
+ * way the script's own stderr did.
38
+ */
39
+ const failureOf = (
40
+ failure: string,
41
+ header: string,
42
+ lines: readonly string[],
43
+ remediation: string,
44
+ ): MeasureReport => ({
45
+ emissions: [
46
+ { kind: 'output', text: [header, ...lines, remediation].join('\n') },
47
+ ],
48
+ failure,
49
+ })
50
+
51
+ function isDirectory(path: string): boolean {
52
+ try {
53
+ return statSync(path).isDirectory()
54
+ } catch {
55
+ return false
56
+ }
57
+ }
58
+
59
+ function isFile(path: string): boolean {
60
+ try {
61
+ return statSync(path).isFile()
62
+ } catch {
63
+ return false
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Plain files directly inside a folder, the way a shell `*` lists them: no
69
+ * dotfile and no directory, so `.husky/_` is never read as a capability.
70
+ */
71
+ function listFiles(dir: string): string[] {
72
+ if (!isDirectory(dir)) return []
73
+ return readdirSync(dir)
74
+ .filter((name) => !name.startsWith('.'))
75
+ .sort()
76
+ .map((name) => join(dir, name))
77
+ .filter(isFile)
78
+ }
79
+
80
+ function stackDirs(root: string): string[] {
81
+ const tooling = join(root, 'tooling')
82
+ if (!isDirectory(tooling)) return []
83
+ return readdirSync(tooling)
84
+ .filter((name) => !name.startsWith('.'))
85
+ .sort()
86
+ .map((name) => join(tooling, name))
87
+ .filter(isDirectory)
88
+ }
89
+
90
+ const basename = (path: string): string => path.slice(path.lastIndexOf('/') + 1)
91
+
92
+ const hasReason = (path: string): boolean =>
93
+ readFileSync(path, 'utf8').includes(NO_SEED_MARKER)
94
+
95
+ const dropTrailingSlash = (value: string): string =>
96
+ value.endsWith('/') ? value.slice(0, -1) : value
97
+
98
+ function gitignorePatterns(text: string): string[] {
99
+ return text
100
+ .split('\n')
101
+ .map((line) => line.replace(/\s+$/, ''))
102
+ .filter((line) => line !== '' && !/^\s*#/.test(line))
103
+ .map(dropTrailingSlash)
104
+ }
105
+
106
+ function manifestEntries(text: string): string[] | undefined {
107
+ let parsed: unknown
108
+ try {
109
+ parsed = Bun.TOML.parse(text)
110
+ } catch {
111
+ return undefined
112
+ }
113
+ const table = (parsed as { gitignore?: Record<string, unknown> }).gitignore
114
+ const entries = table?.[IGNORE_SECTION]
115
+ if (!Array.isArray(entries)) return []
116
+ return entries
117
+ .filter((entry): entry is string => typeof entry === 'string')
118
+ .map(dropTrailingSlash)
119
+ }
120
+
121
+ /**
122
+ * The claude manifest is the only route a target's ignore set travels, and it
123
+ * is hand-maintained beside this repository's own `.gitignore` with nothing
124
+ * comparing the two. Parity is exact, with no exception list behind it: an
125
+ * empty one cannot be exercised, and a check nobody can test is a check nobody
126
+ * should trust.
127
+ *
128
+ * Both lists are compared with the trailing slash dropped. The `.gitignore` side
129
+ * reads the whole file rather than one header, since a claude-scoped entry filed
130
+ * under a header of its own would otherwise read as missing from a list that
131
+ * carries it. It is scoped to `.claude/*` and `.canon` as a bare root as well as
132
+ * a prefix, because the manifest says nothing about `node_modules/` or `.env`
133
+ * and `.canon` is one line covering a whole tree.
134
+ */
135
+ export const ignoreParity: Measure = async (ctx) => {
136
+ const gitignorePath = join(ctx.root, GITIGNORE)
137
+ if (!isFile(gitignorePath)) {
138
+ return unmeasured(
139
+ `No .gitignore at ${GITIGNORE}, ignore parity unverifiable.`,
140
+ )
141
+ }
142
+
143
+ const manifestPath = join(ctx.root, CLAUDE_MANIFEST)
144
+ if (!isFile(manifestPath)) {
145
+ return unmeasured(
146
+ `No claude manifest at ${CLAUDE_MANIFEST}, ignore parity unverifiable.`,
147
+ )
148
+ }
149
+
150
+ const ignored = gitignorePatterns(readFileSync(gitignorePath, 'utf8'))
151
+ const shipped = manifestEntries(readFileSync(manifestPath, 'utf8'))
152
+
153
+ // An empty read on either side is a parse that failed rather than a
154
+ // repository ignoring nothing, and reporting parity off it would say the two
155
+ // lists agree having compared none of their entries.
156
+ if (ignored.length === 0) {
157
+ return unmeasured(
158
+ 'No patterns read from .gitignore, ignore parity unverifiable.',
159
+ )
160
+ }
161
+ if (shipped === undefined || shipped.length === 0) {
162
+ return unmeasured(
163
+ `No entries read from the "${IGNORE_SECTION}" array in ${CLAUDE_MANIFEST}, ignore parity unverifiable.`,
164
+ )
165
+ }
166
+
167
+ const failures = [
168
+ ...shipped
169
+ .filter((entry) => !ignored.includes(entry))
170
+ .map(
171
+ (entry) =>
172
+ ` ${entry} is shipped by the manifest and absent from .gitignore`,
173
+ ),
174
+ ...ignored
175
+ .filter(
176
+ (pattern) =>
177
+ pattern === '.canon' ||
178
+ pattern.startsWith('.canon/') ||
179
+ pattern.startsWith('.claude/'),
180
+ )
181
+ .filter((pattern) => !shipped.includes(pattern))
182
+ .map(
183
+ (pattern) =>
184
+ ` ${pattern} is ignored here and absent from the manifest`,
185
+ ),
186
+ ]
187
+
188
+ if (failures.length === 0) return pass()
189
+
190
+ return failureOf(
191
+ "The ignore set a target receives disagrees with this repository's own.",
192
+ "The ignore set a target receives disagrees with this repository's own:",
193
+ failures,
194
+ `Add the entry to the "${IGNORE_SECTION}" array in ${CLAUDE_MANIFEST} and to .gitignore. The two lists are compared exactly, and nothing here records an exception.`,
195
+ )
196
+ }
197
+
198
+ /** Every `command` string at any depth, which is what `jq '.. | .command?'` read. */
199
+ function collectCommands(value: unknown, into: string[] = []): string[] {
200
+ if (Array.isArray(value)) {
201
+ for (const item of value) collectCommands(item, into)
202
+ } else if (value !== null && typeof value === 'object') {
203
+ for (const [key, child] of Object.entries(value)) {
204
+ if (key === 'command' && typeof child === 'string') into.push(child)
205
+ collectCommands(child, into)
206
+ }
207
+ }
208
+ return into
209
+ }
210
+
211
+ /**
212
+ * A hook, a workflow, or a husky script reaching one side of the seed or config
213
+ * boundary and not the other is a capability withheld with no recorded reason,
214
+ * per the criterion in `canon/context/tooling/seeds.md`.
215
+ *
216
+ * Presence on either side clears a name, and a `canon-no-seed:` comment in the
217
+ * file is the recorded reason. The same marker on a destination clears a
218
+ * target-only capability with no root counterpart by design. A seeded hook with
219
+ * no wiring in the seeded `settings.json` is installed dead, which fails the
220
+ * same way. Wiring compares whole path segments, so `log.sh` cannot pass on the
221
+ * wiring of `pr-create-log.sh`.
222
+ */
223
+ export const capabilitySeeding: Measure = async (ctx) => {
224
+ const { root } = ctx
225
+ if (!isDirectory(join(root, 'tooling'))) {
226
+ return unmeasured(
227
+ 'No tooling root at tooling, capability seeding unverifiable.',
228
+ )
229
+ }
230
+
231
+ const stacks = stackDirs(root)
232
+ const failures: string[] = []
233
+
234
+ const checkCapability = (
235
+ label: string,
236
+ srcDir: string,
237
+ destFiles: readonly string[],
238
+ ): void => {
239
+ const destNames = new Set(destFiles.map(basename))
240
+ for (const file of listFiles(join(root, srcDir))) {
241
+ if (destNames.has(basename(file)) || hasReason(file)) continue
242
+ failures.push(
243
+ ` ${label}: ${posixRel(root, file)} reaches no seed or config and carries no ${NO_SEED_MARKER} reason`,
244
+ )
245
+ }
246
+ }
247
+
248
+ const checkOrphans = (
249
+ label: string,
250
+ srcDir: string,
251
+ destDir: string,
252
+ ): void => {
253
+ for (const file of listFiles(join(root, destDir))) {
254
+ if (isFile(join(root, srcDir, basename(file))) || hasReason(file)) {
255
+ continue
256
+ }
257
+ failures.push(
258
+ ` ${label}: ${posixRel(root, file)} is seeded or configured with no source at ${srcDir}/${basename(file)}`,
259
+ )
260
+ }
261
+ }
262
+
263
+ const workflowDirs = [
264
+ ...stacks.map((stack) => join(stack, 'configs/.github/workflows')),
265
+ ...stacks.map((stack) => join(stack, 'seeds/.github/workflows')),
266
+ ]
267
+ const seedHooksDir = 'tooling/claude/seeds/.claude/hooks'
268
+
269
+ checkCapability('Hooks', '.claude/hooks', listFiles(join(root, seedHooksDir)))
270
+ checkCapability(
271
+ 'Workflows',
272
+ '.github/workflows',
273
+ workflowDirs.flatMap(listFiles),
274
+ )
275
+ checkCapability(
276
+ 'Husky',
277
+ '.husky',
278
+ listFiles(join(root, 'tooling/base/configs/.husky')),
279
+ )
280
+
281
+ // The reverse direction: a destination whose source here is gone reaches
282
+ // neither pass reading forward from the source, and it ships a target a
283
+ // capability this repository has already deleted.
284
+ checkOrphans('Hooks', '.claude/hooks', seedHooksDir)
285
+ for (const dir of workflowDirs) {
286
+ checkOrphans('Workflows', '.github/workflows', posixRel(root, dir))
287
+ }
288
+ checkOrphans('Husky', '.husky', 'tooling/base/configs/.husky')
289
+
290
+ const seedSettings = join(root, 'tooling/claude/seeds/.claude/settings.json')
291
+ const seedHooks = listFiles(join(root, seedHooksDir)).filter((file) =>
292
+ file.endsWith('.sh'),
293
+ )
294
+
295
+ if (isDirectory(join(root, seedHooksDir))) {
296
+ if (!isFile(seedSettings)) {
297
+ failures.push(
298
+ ` Seed settings: no settings.json at ${posixRel(root, seedSettings)} to confirm a seeded hook is wired`,
299
+ )
300
+ } else {
301
+ let settings: unknown
302
+ try {
303
+ settings = JSON.parse(readFileSync(seedSettings, 'utf8'))
304
+ } catch {
305
+ settings = undefined
306
+ failures.push(
307
+ ` Seed settings: ${posixRel(root, seedSettings)} is not valid JSON, so seeded-hook wiring cannot be read`,
308
+ )
309
+ }
310
+ if (settings !== undefined) {
311
+ const wired = new Set(
312
+ collectCommands(settings).flatMap((command) =>
313
+ command.split(/[/\s]+/).filter((segment) => segment !== ''),
314
+ ),
315
+ )
316
+ for (const file of seedHooks) {
317
+ if (wired.has(basename(file))) continue
318
+ failures.push(
319
+ ` Seed settings: ${basename(file)} is seeded and wired into no command in ${posixRel(root, seedSettings)}`,
320
+ )
321
+ }
322
+ }
323
+ }
324
+ }
325
+
326
+ if (failures.length === 0) return pass()
327
+
328
+ return failureOf(
329
+ 'A capability reaches one side of the seed or config boundary and not the other.',
330
+ 'A capability reaches one side of the seed or config boundary and not the other:',
331
+ failures,
332
+ `Seed or configure the capability, or mark the source line with # ${NO_SEED_MARKER} <reason>.`,
333
+ )
334
+ }
335
+
336
+ /**
337
+ * Every file under `claude/` resolved through its symlinks and rejected when it
338
+ * lands under `internal/`.
339
+ *
340
+ * The plugin reaches `standards/` through a symlink, which an installer
341
+ * dereferences with no code in the path to filter. Walking with symlinks
342
+ * followed is what an install copies, so this is the only place the boundary
343
+ * can be measured. The walk tracks the real directories on its current descent
344
+ * and does not enter one a second time, the way `find -L` stops on a loop.
345
+ */
346
+ export const pluginBoundary: Measure = async (ctx) => {
347
+ const pluginRoot = join(ctx.root, 'claude')
348
+ if (!isDirectory(pluginRoot)) {
349
+ return unmeasured('No plugin root at claude, boundary unverifiable.')
350
+ }
351
+
352
+ const internalRoot = join(realpathSync(ctx.root), 'internal')
353
+ const leaked: string[] = []
354
+ let walked = 0
355
+
356
+ const walk = (dir: string, ancestors: readonly string[]): void => {
357
+ const real = realpathSync(dir)
358
+ if (ancestors.includes(real)) return
359
+ const chain = [...ancestors, real]
360
+
361
+ for (const name of readdirSync(dir).sort()) {
362
+ const path = join(dir, name)
363
+ let stat
364
+ try {
365
+ stat = statSync(path)
366
+ } catch {
367
+ continue
368
+ }
369
+ if (stat.isDirectory()) {
370
+ walk(path, chain)
371
+ continue
372
+ }
373
+ if (!stat.isFile()) continue
374
+
375
+ walked += 1
376
+ const resolved = realpathSync(path)
377
+ if (resolved.startsWith(`${internalRoot}${sep}`)) {
378
+ leaked.push(
379
+ ` ${posixRel(ctx.root, path)} -> ${posixRel(realpathSync(ctx.root), resolved)}`,
380
+ )
381
+ }
382
+ }
383
+ }
384
+
385
+ walk(pluginRoot, [])
386
+
387
+ if (walked === 0) {
388
+ return unmeasured('No file under claude, boundary unverifiable.')
389
+ }
390
+ if (leaked.length === 0) return pass()
391
+
392
+ return failureOf(
393
+ 'Plugin ships toolkit-internal content.',
394
+ 'Plugin ships toolkit-internal content:',
395
+ leaked,
396
+ 'Author internal content under internal/, which nothing under claude/ reaches.',
397
+ )
398
+ }
399
+
400
+ /** Regular files under a folder, symlinks left unfollowed the way `find -type f` leaves them. */
401
+ function walkFiles(dir: string): string[] {
402
+ const found: string[] = []
403
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
404
+ const path = join(dir, entry.name)
405
+ if (entry.isDirectory()) found.push(...walkFiles(path))
406
+ else if (entry.isFile()) found.push(path)
407
+ }
408
+ return found.sort()
409
+ }
410
+
411
+ function grepLines(
412
+ root: string,
413
+ file: string,
414
+ pattern: RegExp,
415
+ ): string[] | undefined {
416
+ const text = readFileSync(file, 'utf8')
417
+ if (text.includes('\0')) return undefined
418
+ const lines = text.split('\n')
419
+ if (lines.at(-1) === '') lines.pop()
420
+ return lines.flatMap((line, index) =>
421
+ pattern.test(line) ? [`${posixRel(root, file)}:${index + 1}:${line}`] : [],
422
+ )
423
+ }
424
+
425
+ /**
426
+ * Seed prose is installed into every scaffolded project and read there as
427
+ * instruction about that project, so a line naming this repository's CLI hands a
428
+ * target a verb it may not be able to run. This gates for the reason the seed
429
+ * standards stage gates: a defect authored once propagates into every project
430
+ * scaffolded after it.
431
+ *
432
+ * Markdown alone, since the seed tree also ships hooks that call the CLI on
433
+ * purpose. A token followed by `/` is the tracked surface root, a folder the
434
+ * seed itself installs, so every other spelling reports. No seed root, or roots
435
+ * carrying no markdown, is unmeasured rather than a pass, which agrees with
436
+ * `seedStandards` on the no-root case.
437
+ */
438
+ export const seedIndependence: Measure = async (ctx) => {
439
+ if (!isDirectory(join(ctx.root, 'tooling'))) {
440
+ return unmeasured(
441
+ 'No tooling root at tooling, seed independence unverifiable.',
442
+ )
443
+ }
444
+
445
+ const roots = seedRoots(ctx.root)
446
+ if (roots.length === 0) {
447
+ return unmeasured(
448
+ 'No seed root carries .claude/ or canon/, nothing to check.',
449
+ )
450
+ }
451
+
452
+ const pattern = new RegExp(`${TOOLKIT_TOKEN}([^/]|$)`)
453
+ const cited: string[] = []
454
+ let measured = 0
455
+
456
+ for (const seedRoot of roots) {
457
+ for (const file of walkFiles(join(ctx.root, seedRoot))) {
458
+ if (!file.endsWith('.md')) continue
459
+ measured += 1
460
+ for (const hit of grepLines(ctx.root, file, pattern) ?? []) {
461
+ cited.push(` ${hit}`)
462
+ }
463
+ }
464
+ }
465
+
466
+ if (measured === 0) {
467
+ return unmeasured(
468
+ 'Seed roots resolved but carry no markdown, seed independence unverifiable.',
469
+ )
470
+ }
471
+ if (cited.length === 0) return pass()
472
+
473
+ return failureOf(
474
+ 'Seed prose cites the toolkit CLI.',
475
+ 'Seed prose cites the toolkit CLI:',
476
+ cited,
477
+ `A scaffolded project may not have ${TOOLKIT_TOKEN} installed. State the capability the line needs rather than the binary that supplies it.`,
478
+ )
479
+ }
480
+
481
+ /**
482
+ * A shipped skill runs from a target project, where the toolkit's own `wiki/`
483
+ * does not exist. Anchoring on a non-path character keeps a target's
484
+ * `.claude/wiki/` legal. A tree with no `claude/skills/`, or one holding no
485
+ * file, is unmeasured rather than clean.
486
+ */
487
+ export const skillPaths: Measure = async (ctx) => {
488
+ const skills = join(ctx.root, 'claude/skills')
489
+ if (!isDirectory(skills)) {
490
+ return unmeasured('No skills folder at claude/skills, no skill was read.')
491
+ }
492
+
493
+ const files = walkFiles(skills)
494
+ if (files.length === 0) {
495
+ return unmeasured('No file under claude/skills, no skill was read.')
496
+ }
497
+
498
+ const matches = files.flatMap(
499
+ (file) => grepLines(ctx.root, file, BANNED_SKILL_PATH) ?? [],
500
+ )
501
+ if (matches.length === 0) return pass()
502
+
503
+ return failureOf(
504
+ 'Shipped skills reference a repo-local path.',
505
+ 'Shipped skills reference a repo-local path that does not exist in a target project:',
506
+ [...matches, ''],
507
+ 'Reach supporting prose through a canon docs command, a standard cited at the flat root, or inlined text.',
508
+ )
509
+ }
@@ -31,6 +31,7 @@ import {
31
31
  type ShippedReference,
32
32
  } from '@/shipped/references'
33
33
  import { surfaceDir } from '@/surface-root'
34
+ import { seedRoots } from '@/tooling/seeds'
34
35
  import { plural } from '@/ui'
35
36
  import {
36
37
  README_PARAPHRASE_MARKER,
@@ -585,26 +586,14 @@ export const markdownBans: Measure = async (ctx) => {
585
586
  * required section and index drift, and leaves the thresholds advisory for the
586
587
  * reason the stage above leaves its own so.
587
588
  *
588
- * The roots are discovered rather than listed, through the one bash definition
589
- * `check-seed-independence.sh` already reads, so a stack seeding `.claude/`
590
- * later is covered with no edit here and the two stages cannot disagree about
591
- * which roots exist.
589
+ * The roots are discovered rather than listed, through `seedRoots`, which the
590
+ * Seed independence measure reads too, so a stack seeding `.claude/` later is
591
+ * covered with no edit here and the two stages cannot disagree about which
592
+ * roots exist.
592
593
  */
593
594
  export const seedStandards: Measure = async (ctx) => {
594
- const roots = await ctx.run([
595
- 'bash',
596
- join(ctx.root, 'scripts/core/list-seed-roots.sh'),
597
- ])
598
-
599
- if (roots.exitCode !== 0) {
600
- return {
601
- emissions: [],
602
- unmeasured: 'The seed roots could not be listed, so no seed was read.',
603
- }
604
- }
605
-
606
- const seedRoots = roots.stdout.split('\n').filter((line) => line !== '')
607
- if (seedRoots.length === 0) {
595
+ const roots = seedRoots(ctx.root)
596
+ if (roots.length === 0) {
608
597
  return {
609
598
  emissions: [],
610
599
  unmeasured:
@@ -615,7 +604,7 @@ export const seedStandards: Measure = async (ctx) => {
615
604
  const emissions: Emission[] = []
616
605
  let measured = 0
617
606
 
618
- for (const seedRoot of seedRoots) {
607
+ for (const seedRoot of roots) {
619
608
  const run = await ctx.cli([
620
609
  'context',
621
610
  'audit',