@erclx/canon 5.13.0 → 5.14.1

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 (68) hide show
  1. package/claude/.claude-plugin/plugin.json +1 -1
  2. package/claude/skills/api-design/SKILL.md +3 -2
  3. package/claude/skills/deprecation-migration/REQUIREMENT.md +62 -0
  4. package/claude/skills/deprecation-migration/SKILL.md +115 -0
  5. package/claude/skills/deprecation-migration/references/adopted.md +50 -0
  6. package/claude/skills/git-followup/REQUIREMENT.md +1 -0
  7. package/claude/skills/git-followup/SKILL.md +3 -0
  8. package/claude/skills/git-pr/REQUIREMENT.md +1 -0
  9. package/claude/skills/git-pr/SKILL.md +1 -1
  10. package/claude/skills/git-pr/references/evidence.md +2 -2
  11. package/claude/skills/git-ship/REQUIREMENT.md +3 -0
  12. package/claude/skills/git-ship/SKILL.md +12 -1
  13. package/claude/skills/review-pr/SKILL.md +1 -1
  14. package/claude/skills/review-pr/references/close-out.md +1 -1
  15. package/claude/skills/review-pr/references/post.md +1 -1
  16. package/claude/skills/review-ui/REQUIREMENT.md +5 -2
  17. package/claude/skills/review-ui/SKILL.md +12 -4
  18. package/claude/skills/review-ui/references/driving.md +5 -3
  19. package/claude/skills/review-ui/references/post.md +3 -2
  20. package/claude/skills/role-orchestrator/SKILL.md +1 -1
  21. package/claude/skills/role-orchestrator/references/orchestrator-poll.md +10 -10
  22. package/claude/skills/role-orchestrator/scripts/poll.ts +628 -0
  23. package/claude/skills/role-orchestrator/scripts/repo.ts +69 -0
  24. package/claude/skills/role-orchestrator/scripts/watch.ts +267 -0
  25. package/docs/agents/commands.md +1 -1
  26. package/docs/agents/index.md +1 -0
  27. package/docs/agents/markdown-audit.md +1 -1
  28. package/docs/agents/pr-evidence.md +5 -0
  29. package/docs/agents/pr-frames.md +112 -0
  30. package/docs/agents/pr-reads.md +1 -1
  31. package/docs/agents/sandbox.md +3 -3
  32. package/docs/agents/state-scoped-risk.md +2 -2
  33. package/docs/workflow/skill-map.md +15 -14
  34. package/package.json +4 -5
  35. package/scripts/core/check-color-source.sh +11 -1
  36. package/scripts/core/install-check.sh +13 -4
  37. package/scripts/lib/sandbox-dispatch.sh +1 -1
  38. package/scripts/lib/sandbox-fixtures.sh +2 -2
  39. package/scripts/lib/sandbox-path.sh +1 -1
  40. package/scripts/manage-sandbox.sh +2 -2
  41. package/src/claude/cases/misc.ts +5 -0
  42. package/src/claude/skills-audit.ts +1 -0
  43. package/src/commands/pr.ts +452 -0
  44. package/src/commands/sandbox.ts +2 -2
  45. package/src/context/architecture.ts +5 -2
  46. package/src/context/citations.ts +1 -1
  47. package/src/gate/boundaries.ts +509 -0
  48. package/src/gate/measures.ts +8 -19
  49. package/src/gate/stages.ts +12 -28
  50. package/src/pr/frames.ts +323 -0
  51. package/src/sandbox/census.ts +1 -1
  52. package/src/sandbox/coverage.ts +1 -1
  53. package/src/sandbox/expect.ts +0 -1
  54. package/src/shipped/references.ts +8 -10
  55. package/src/targets/pulls.ts +2 -2
  56. package/src/tooling/manifest.ts +2 -3
  57. package/src/tooling/seeds.ts +37 -0
  58. package/tooling/claude/manifest.toml +3 -3
  59. package/tsconfig.json +1 -1
  60. package/claude/skills/role-orchestrator/scripts/poll.sh +0 -504
  61. package/claude/skills/role-orchestrator/scripts/watch.sh +0 -181
  62. package/scripts/core/check-capability-seeding.sh +0 -134
  63. package/scripts/core/check-ignore-parity.sh +0 -141
  64. package/scripts/core/check-plugin-boundary.sh +0 -45
  65. package/scripts/core/check-seed-independence.sh +0 -62
  66. package/scripts/core/check-skill-paths.sh +0 -24
  67. package/scripts/core/list-seed-roots.sh +0 -18
  68. package/scripts/lib/tooling.sh +0 -37
@@ -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',
@@ -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
  {