@erclx/canon 5.42.0 → 5.44.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 (195) hide show
  1. package/claude/.claude-plugin/plugin.json +1 -1
  2. package/claude/skills/auto-ship/SKILL.md +3 -3
  3. package/claude/skills/backlog-triage/references/apply.md +6 -6
  4. package/claude/skills/canon-cli/SKILL.md +4 -4
  5. package/claude/skills/canvas-board/REQUIREMENT.md +1 -1
  6. package/claude/skills/canvas-board/SKILL.md +1 -1
  7. package/claude/skills/ci-workflow/REQUIREMENT.md +2 -1
  8. package/claude/skills/ci-workflow/SKILL.md +12 -2
  9. package/claude/skills/ci-workflow/references/workflows.md +12 -9
  10. package/claude/skills/context-fold/references/context.md +1 -1
  11. package/claude/skills/create-rule/SKILL.md +1 -0
  12. package/claude/skills/create-skill/REQUIREMENT.md +1 -1
  13. package/claude/skills/create-skill/SKILL.md +2 -2
  14. package/claude/skills/create-skill/references/skill-requirement.md +68 -9
  15. package/claude/skills/create-skill/references/skill.md +1 -1
  16. package/claude/skills/decision-escalate/SKILL.md +1 -2
  17. package/claude/skills/design-identity/REQUIREMENT.md +40 -0
  18. package/claude/skills/design-identity/SKILL.md +138 -0
  19. package/claude/skills/{draft-identity → design-identity}/references/card-route.md +2 -2
  20. package/claude/skills/design-taste/REQUIREMENT.md +4 -4
  21. package/claude/skills/design-taste/SKILL.md +2 -2
  22. package/claude/skills/design-taste/references/kinds.md +1 -1
  23. package/claude/skills/design-taste/references/preflight.md +1 -1
  24. package/claude/skills/document-health/references/axes.md +2 -1
  25. package/claude/skills/draft-and-pick/REQUIREMENT.md +8 -69
  26. package/claude/skills/draft-and-pick/SKILL.md +3 -153
  27. package/claude/skills/draft-doc/REQUIREMENT.md +9 -30
  28. package/claude/skills/draft-doc/SKILL.md +4 -76
  29. package/claude/skills/draft-figure/REQUIREMENT.md +8 -42
  30. package/claude/skills/draft-figure/SKILL.md +3 -77
  31. package/claude/skills/draft-identity/REQUIREMENT.md +8 -23
  32. package/claude/skills/draft-identity/SKILL.md +3 -130
  33. package/claude/skills/draft-ready/REQUIREMENT.md +8 -37
  34. package/claude/skills/draft-ready/SKILL.md +3 -128
  35. package/claude/skills/draft-screencast/REQUIREMENT.md +8 -23
  36. package/claude/skills/draft-screencast/SKILL.md +3 -62
  37. package/claude/skills/draft-slides/REQUIREMENT.md +8 -27
  38. package/claude/skills/draft-slides/SKILL.md +3 -72
  39. package/claude/skills/draw-figure/REQUIREMENT.md +59 -0
  40. package/claude/skills/draw-figure/SKILL.md +85 -0
  41. package/claude/skills/{draft-figure → draw-figure}/references/architecture-set.md +2 -2
  42. package/claude/skills/{draft-figure → draw-figure}/references/hand-drawn.md +2 -2
  43. package/claude/skills/{draft-figure → draw-figure}/references/mermaid-path.md +2 -2
  44. package/claude/skills/{draft-figure → draw-figure}/references/mermaid.md +1 -1
  45. package/claude/skills/git-commit/references/commit.md +1 -5
  46. package/claude/skills/git-pr/references/pr.md +1 -0
  47. package/claude/skills/make-deck/REQUIREMENT.md +44 -0
  48. package/claude/skills/make-deck/SKILL.md +80 -0
  49. package/claude/skills/markdown-craft/REQUIREMENT.md +1 -1
  50. package/claude/skills/markdown-craft/SKILL.md +2 -2
  51. package/claude/skills/pick-by-eye/REQUIREMENT.md +86 -0
  52. package/claude/skills/pick-by-eye/SKILL.md +161 -0
  53. package/claude/skills/plan-feature/SKILL.md +3 -2
  54. package/claude/skills/plan-feature/references/plan.md +25 -11
  55. package/claude/skills/plan-groundwork/SKILL.md +1 -1
  56. package/claude/skills/plan-intake/SKILL.md +1 -1
  57. package/claude/skills/plan-ready/REQUIREMENT.md +54 -0
  58. package/claude/skills/plan-ready/SKILL.md +136 -0
  59. package/claude/skills/{draft-ready → plan-ready}/references/assembly.md +1 -1
  60. package/claude/skills/{draft-ready → plan-ready}/references/ready.md +1 -1
  61. package/claude/skills/pr-evidence/references/evidence.md +1 -1
  62. package/claude/skills/record-screencast/REQUIREMENT.md +3 -3
  63. package/claude/skills/record-screencast/SKILL.md +2 -2
  64. package/claude/skills/role-orchestrator/REQUIREMENT.md +1 -1
  65. package/claude/skills/role-orchestrator/SKILL.md +2 -0
  66. package/claude/skills/role-orchestrator/references/orchestrator-dispatch.md +5 -2
  67. package/claude/skills/role-orchestrator/references/orchestrator-output.md +4 -0
  68. package/claude/skills/role-orchestrator/references/orchestrator-parked.md +2 -2
  69. package/claude/skills/role-orchestrator/references/orchestrator-refill.md +18 -13
  70. package/claude/skills/role-planner/REQUIREMENT.md +1 -1
  71. package/claude/skills/role-planner/SKILL.md +5 -4
  72. package/claude/skills/role-worker/REQUIREMENT.md +2 -0
  73. package/claude/skills/role-worker/SKILL.md +35 -2
  74. package/claude/skills/script-screencast/REQUIREMENT.md +40 -0
  75. package/claude/skills/script-screencast/SKILL.md +70 -0
  76. package/claude/skills/{draft-screencast → script-screencast}/references/beats-template.md +2 -2
  77. package/claude/skills/search-craft/REQUIREMENT.md +4 -4
  78. package/claude/skills/search-craft/SKILL.md +2 -2
  79. package/claude/skills/target-setup/SKILL.md +1 -0
  80. package/claude/skills/task-board/SKILL.md +11 -9
  81. package/claude/skills/task-board/references/board.md +15 -12
  82. package/claude/skills/teach-workspace/SKILL.md +1 -1
  83. package/claude/skills/teach-workspace/references/lesson-craft.md +2 -2
  84. package/claude/skills/ui-checklist/SKILL.md +2 -0
  85. package/claude/skills/write-doc/REQUIREMENT.md +46 -0
  86. package/claude/skills/write-doc/SKILL.md +83 -0
  87. package/claude/skills/{draft-doc → write-doc}/references/context.md +2 -2
  88. package/claude/skills/{draft-doc → write-doc}/references/docs.md +2 -2
  89. package/claude/skills/{draft-doc → write-doc}/references/readme.md +2 -2
  90. package/claude/skills/{draft-doc → write-doc}/references/wiki-kind.md +4 -4
  91. package/claude/skills/{draft-doc → write-doc}/references/wiki.md +1 -1
  92. package/docs/agents/audits.md +1 -0
  93. package/docs/agents/markdown-audit.md +4 -2
  94. package/docs/agents/migrate.md +13 -10
  95. package/docs/target/migrations.md +28 -4
  96. package/docs/target/projects.md +1 -1
  97. package/docs/workflow/parallel-features.md +3 -2
  98. package/docs/workflow/skill-map-on-request.md +18 -12
  99. package/docs/workflow/skill-map.md +13 -12
  100. package/docs/workflow/visual-design-workflow.md +2 -2
  101. package/governance/detect.toml +4 -0
  102. package/governance/ledger/framework/astro.md +73 -0
  103. package/governance/ledger/framework/fastapi.md +76 -0
  104. package/governance/ledger/framework/nextjs.md +86 -0
  105. package/governance/ledger/framework/react.md +83 -0
  106. package/governance/ledger/framework/shadcn.md +60 -0
  107. package/governance/ledger/framework/tailwind.md +55 -0
  108. package/governance/rules/canon/context.md +2 -1
  109. package/governance/rules/canon/plan.md +3 -5
  110. package/governance/rules/canon/scratch.md +3 -1
  111. package/governance/rules/canon/task-scope.md +2 -1
  112. package/governance/rules/canon/tasks.md +2 -1
  113. package/governance/rules/canon/worktrees.md +5 -2
  114. package/governance/rules/claude/output.md +7 -4
  115. package/governance/rules/claude/skill.md +6 -4
  116. package/governance/rules/framework/astro.md +12 -14
  117. package/governance/rules/framework/fastapi.md +20 -24
  118. package/governance/rules/framework/nextjs.md +19 -27
  119. package/governance/rules/framework/react.md +22 -33
  120. package/governance/rules/framework/shadcn.md +11 -15
  121. package/governance/rules/framework/tailwind.md +12 -14
  122. package/governance/rules/tooling/ci-workflow.md +6 -39
  123. package/governance/rules/ui/design-reference.md +37 -0
  124. package/governance/rules/writing/markdown.md +3 -2
  125. package/package.json +6 -6
  126. package/src/audits/catalog.ts +42 -0
  127. package/src/claude/cases/authoring.ts +15 -3
  128. package/src/claude/cases/misc.ts +12 -0
  129. package/src/claude/cases/workflow.ts +19 -1
  130. package/src/claude/settings.ts +8 -4
  131. package/src/claude/skills-audit.ts +113 -1
  132. package/src/commands/claude.ts +45 -12
  133. package/src/commands/demo.ts +1 -1
  134. package/src/commands/gov.ts +198 -0
  135. package/src/commands/markdown.ts +1 -1
  136. package/src/commands/migrate.ts +125 -2
  137. package/src/commands/pr.ts +34 -83
  138. package/src/commands/tasks.ts +383 -2
  139. package/src/commands/tooling.ts +3 -3
  140. package/src/commands/worktrees.ts +49 -0
  141. package/src/context/audit.ts +1 -1
  142. package/src/demo/beats.ts +1 -1
  143. package/src/gate/stages.ts +15 -0
  144. package/src/gov/audit.ts +441 -0
  145. package/src/listeners/belongs.ts +21 -0
  146. package/src/listeners/machine.ts +93 -0
  147. package/src/listeners/parse.ts +41 -0
  148. package/src/markdown/scope.ts +45 -35
  149. package/src/migrate/record-layout.ts +1 -1
  150. package/src/migrate/session-defaults.ts +138 -0
  151. package/src/migrate/skill-names.ts +68 -21
  152. package/src/pr/frames.ts +14 -3
  153. package/src/pr/local.ts +4 -69
  154. package/src/records/validate.ts +31 -0
  155. package/src/sync/check.ts +15 -0
  156. package/src/tasks/board.ts +854 -0
  157. package/src/tasks/record.ts +20 -1
  158. package/src/tasks/validate.ts +206 -9
  159. package/src/tooling/stamp.ts +56 -12
  160. package/src/worktrees/stop.ts +92 -0
  161. package/tooling/astro/configs/card-src/pages/og-card.astro +1 -1
  162. package/tooling/astro/reference.md +2 -2
  163. package/tooling/base/configs/.github/workflows/phase-label-gate.yml +6 -2
  164. package/tooling/base/configs/commitlint.config.js +0 -20
  165. package/tooling/base/reference.md +1 -1
  166. package/tooling/base/seeds/.github/workflows/verify.yml +4 -1
  167. package/tooling/claude/reference.md +13 -7
  168. package/tooling/claude/seeds/.claude/hooks/board-validate.sh +65 -0
  169. package/tooling/claude/seeds/.claude/settings.json +8 -0
  170. package/tooling/claude/user/settings.template.json +0 -4
  171. package/tooling/cloudflare/configs/.github/workflows/deploy.yml +5 -5
  172. package/tooling/public/configs/.github/dependabot.yml +12 -0
  173. package/tooling/public/configs/.github/workflows/release-please.yml +30 -0
  174. package/tooling/public/manifest.toml +8 -0
  175. package/tooling/public/reference.md +53 -0
  176. package/tooling/public/seeds/.github/ISSUE_TEMPLATE/bug.yml +48 -0
  177. package/tooling/public/seeds/.github/ISSUE_TEMPLATE/config.yml +5 -0
  178. package/tooling/public/seeds/.github/ISSUE_TEMPLATE/feature.yml +16 -0
  179. package/tooling/public/seeds/.release-please-manifest.json +3 -0
  180. package/tooling/public/seeds/CONTRIBUTING.md +50 -0
  181. package/tooling/public/seeds/LICENSE +21 -0
  182. package/tooling/public/seeds/SECURITY.md +13 -0
  183. package/tooling/public/seeds/release-please-config.json +12 -0
  184. package/tooling/shell/configs/.github/workflows/shell.yml +4 -1
  185. package/tooling/web/configs/.github/workflows/readme-screenshot.yml +6 -3
  186. package/tooling/web/configs/.github/workflows/verify.yml +15 -12
  187. /package/claude/skills/{draft-figure → draw-figure}/references/figures.md +0 -0
  188. /package/claude/skills/{draft-slides → make-deck}/references/deck.md +0 -0
  189. /package/claude/skills/{draft-and-pick → pick-by-eye}/references/live-arms.md +0 -0
  190. /package/claude/skills/{draft-doc → write-doc}/references/readme/agent-facing.md +0 -0
  191. /package/claude/skills/{draft-doc → write-doc}/references/readme/application.md +0 -0
  192. /package/claude/skills/{draft-doc → write-doc}/references/readme/cli.md +0 -0
  193. /package/claude/skills/{draft-doc → write-doc}/references/readme/library.md +0 -0
  194. /package/claude/skills/{draft-doc → write-doc}/references/readme/plugin.md +0 -0
  195. /package/claude/skills/{draft-doc → write-doc}/references/requirements.md +0 -0
@@ -1,5 +1,5 @@
1
1
  import { existsSync, readFileSync } from 'node:fs'
2
- import { join } from 'node:path'
2
+ import { join, resolve } from 'node:path'
3
3
  import type { Command } from 'commander'
4
4
  import { listRepositoryFiles } from '@/git/files'
5
5
  import { indexSourceRules } from '@/gov/adapter'
@@ -25,6 +25,11 @@ import {
25
25
  type RuleLayoutPlan,
26
26
  walkFlatRules,
27
27
  } from '@/migrate/rule-layout'
28
+ import {
29
+ applySessionDefaults,
30
+ readSessionDefaults,
31
+ type SessionDefaultsRead,
32
+ } from '@/migrate/session-defaults'
28
33
  import {
29
34
  SKILL_CITATION_MAP,
30
35
  SKILL_NAME_MAP,
@@ -50,7 +55,7 @@ import {
50
55
  } from '@/migrate/scratch-evidence'
51
56
  import { checkoutMismatchWarning, PROJECT_ROOT } from '@/roots/project'
52
57
  import { readStamp, stampedHashes } from '@/sync/stamp'
53
- import { logError, logInfo, logStep, logWarn, plural } from '@/cli/ui'
58
+ import { logAdd, logError, logInfo, logStep, logWarn, plural } from '@/cli/ui'
54
59
 
55
60
  interface SweepOptions {
56
61
  readonly json?: boolean
@@ -959,6 +964,81 @@ function toRuleLayoutRecord(
959
964
  }
960
965
  }
961
966
 
967
+ interface SessionDefaultsOptions {
968
+ readonly json?: boolean
969
+ readonly write?: boolean
970
+ readonly root?: string
971
+ }
972
+
973
+ /**
974
+ * Adds the seeded session defaults to a target settings file that predates
975
+ * them. A missing file is nothing to do, since `canon claude init` seeds the
976
+ * whole file with the defaults already in it.
977
+ */
978
+ async function runSessionDefaults(
979
+ opts: SessionDefaultsOptions,
980
+ ): Promise<number> {
981
+ const root = resolve(opts.root ?? process.cwd())
982
+
983
+ const mismatch = checkoutMismatchWarning(process.cwd())
984
+ if (mismatch !== undefined) logWarn(mismatch)
985
+
986
+ const read = await readSessionDefaults(root, PROJECT_ROOT)
987
+
988
+ if (opts.json) {
989
+ process.stdout.write(
990
+ `${JSON.stringify(toSessionDefaultsRecord(read, opts.write))}\n`,
991
+ )
992
+ }
993
+
994
+ if (read.kind === 'refused') {
995
+ logError(read.reason)
996
+ return 1
997
+ }
998
+
999
+ if (read.kind === 'absent') {
1000
+ logInfo(`No ${read.path}, so there is nothing to migrate.`)
1001
+ return 0
1002
+ }
1003
+
1004
+ for (const key of read.plan.keys) {
1005
+ if (key.changed) logAdd(key.label)
1006
+ else logInfo(key.label)
1007
+ }
1008
+
1009
+ if (!read.plan.changed) {
1010
+ logInfo('Session defaults already present.')
1011
+ return 0
1012
+ }
1013
+
1014
+ if (!opts.write) {
1015
+ logWarn('Nothing was written. Pass --write to apply this plan.')
1016
+ return 2
1017
+ }
1018
+
1019
+ await applySessionDefaults(read)
1020
+ logStep(`Wrote ${read.path}.`)
1021
+ return 0
1022
+ }
1023
+
1024
+ function toSessionDefaultsRecord(
1025
+ read: SessionDefaultsRead,
1026
+ wrote: boolean | undefined,
1027
+ ): unknown {
1028
+ if (read.kind === 'refused') {
1029
+ return { ok: false, wrote: false, reason: read.reason }
1030
+ }
1031
+ if (read.kind === 'absent') {
1032
+ return { ok: true, wrote: false, path: read.path, keys: [] }
1033
+ }
1034
+ return {
1035
+ ok: true,
1036
+ wrote: wrote === true && read.plan.changed,
1037
+ path: read.path,
1038
+ keys: read.plan.keys,
1039
+ }
1040
+ }
1041
+
962
1042
  export function register(program: Command): void {
963
1043
  const migrate = program
964
1044
  .command('migrate')
@@ -1255,6 +1335,10 @@ export function register(program: Command): void {
1255
1335
  '',
1256
1336
  `Rewrites ${Object.keys(SKILL_NAME_MAP).length} skill names and moves the folders that carry them.`,
1257
1337
  `It also carries ${Object.keys(SKILL_CITATION_MAP).length} namespaced-only rows for a skill renamed from a single word.`,
1338
+ 'The seven draft- skills move onto verb-first names, such as',
1339
+ // The old spellings below are the rename itself. canon-keep-retired
1340
+ 'draft-doc to write-doc and draft-and-pick to pick-by-eye, and a',
1341
+ 'pointer left under each old name is never moved.',
1258
1342
  'It takes no scope. The skill folders it moves are authored in the',
1259
1343
  'toolkit and no target holds a copy of that catalog, so in a target it',
1260
1344
  'rewrites citations of a renamed skill and moves nothing.',
@@ -1322,4 +1406,43 @@ export function register(program: Command): void {
1322
1406
  .action(async (opts: RuleLayoutOptions) => {
1323
1407
  process.exitCode = await runRuleLayout(opts)
1324
1408
  })
1409
+
1410
+ migrate
1411
+ .command('session-defaults')
1412
+ .description('Add the seeded session defaults to a target settings file')
1413
+ .helpOption('-h, --help', 'Show this help message')
1414
+ .option('--json', 'Add a machine-readable record on stdout')
1415
+ .option('--write', 'Apply the plan rather than reporting it')
1416
+ .option(
1417
+ '--root <path>',
1418
+ 'Project root, defaulting to the working directory',
1419
+ )
1420
+ .addHelpText(
1421
+ 'after',
1422
+ [
1423
+ '',
1424
+ 'Adds Agent and Task to permissions.deny in .claude/settings.json,',
1425
+ 'appending to the list without reordering it, and sets',
1426
+ 'autoMemoryEnabled to false only when the key is absent. A deny entry',
1427
+ 'is never removed and an existing autoMemoryEnabled is never changed.',
1428
+ '',
1429
+ 'A missing settings file is nothing to do. A file that is not valid',
1430
+ 'JSON, or whose permissions or deny has an unexpected shape, is',
1431
+ 'refused by name and never partly written.',
1432
+ '',
1433
+ 'Exit codes:',
1434
+ ' 0 nothing to add, or --write applied the plan',
1435
+ ' 1 refused: the toolkit root, or an unreadable settings file',
1436
+ ' 2 a plan exists and --write was not passed',
1437
+ '',
1438
+ 'Examples:',
1439
+ ' canon migrate session-defaults',
1440
+ ' canon migrate session-defaults --write',
1441
+ ' canon migrate session-defaults --json',
1442
+ '',
1443
+ ].join('\n'),
1444
+ )
1445
+ .action(async (opts: SessionDefaultsOptions) => {
1446
+ process.exitCode = await runSessionDefaults(opts)
1447
+ })
1325
1448
  }
@@ -1,5 +1,5 @@
1
- import { existsSync } from 'node:fs'
2
- import { readdir, readFile, readlink, realpath } from 'node:fs/promises'
1
+ import { existsSync, statSync } from 'node:fs'
2
+ import { readFile, realpath } from 'node:fs/promises'
3
3
  import { basename, join, resolve } from 'node:path'
4
4
  import { $ } from 'bun'
5
5
  import type { Command } from 'commander'
@@ -57,6 +57,7 @@ import {
57
57
  pruneFramesWithLegacy,
58
58
  pushFrame,
59
59
  readFrame,
60
+ isDesignReference,
60
61
  referenceSlug,
61
62
  type TreeEntry,
62
63
  } from '@/pr/frames'
@@ -72,15 +73,13 @@ import {
72
73
  readBaseStanding,
73
74
  readChangedPaths,
74
75
  } from '@/pr/lift'
76
+ import { machineListenerReader } from '@/listeners/machine'
75
77
  import { loadPaintedPatterns } from '@/pr/painted'
76
78
  import {
77
79
  dropAddressLine,
78
80
  findLocalServer,
79
- type Listener,
80
81
  type LocalRefusal,
81
82
  type LocalRunner,
82
- parseLsofListeners,
83
- parseProcNetTcp,
84
83
  } from '@/pr/local'
85
84
  import { KEY_CHANGES } from '@/pr/paths'
86
85
  import {
@@ -224,9 +223,9 @@ const FRAMES_REFUSALS: Record<FramesCommandRefusal, string> = {
224
223
  'no-number':
225
224
  '--add and --drop write for one pull request, so name its number.',
226
225
  'bad-box':
227
- '--add takes --box <n>, a positive whole number naming the checklist box, or --reference <page>/<frame>, never both.',
226
+ '--add takes --box <n>, a positive whole number naming the checklist box, or --reference, as <page>/<frame> or a tracked canon/design/<surface>/<name> path, never both.',
228
227
  'bad-reference':
229
- '--reference takes <page>/<frame>, each half opening on a letter or digit and holding only letters, digits, dots, underscores, and dashes, with no `--` in either, so the capture files under a name that reads back as the frame.',
228
+ '--reference takes <page>/<frame> or a tracked canon/design/<surface>/<name> path, each half opening on a letter or digit and holding only letters, digits, dots, underscores, and dashes, with no `--` in either, so the capture files under a name that reads back as the frame.',
230
229
  'bad-head':
231
230
  '--head takes a commit sha, 7 to 40 lowercase hex characters. A branch name or a path would file the frame under a folder that names no commit.',
232
231
  'bad-pass':
@@ -1062,7 +1061,7 @@ export function register(program: Command): void {
1062
1061
  .option('--box <n>', 'The checklist box the frame shows, with --add')
1063
1062
  .option(
1064
1063
  '--reference <address>',
1065
- 'The design reference the capture shows, as <page>/<frame>, with --add in place of --box',
1064
+ 'The design reference the capture shows, as <page>/<frame> or a tracked canon/design/<surface>/<name> path, with --add in place of --box',
1066
1065
  )
1067
1066
  .option(
1068
1067
  '--head <sha>',
@@ -1089,7 +1088,9 @@ export function register(program: Command): void {
1089
1088
  'so nothing is checked out and no other branch is touched. A frame lands',
1090
1089
  'at pr-<number>/<short-head>/<pass>/box-<n>.png, or box-<n>-<theme>.png',
1091
1090
  'with --theme, or ref-<page>--<frame>.png for a design reference capture',
1092
- 'passed as --reference <page>/<frame> in place of --box, and `link` names that',
1091
+ 'passed as --reference <page>/<frame> in place of --box, or',
1092
+ 'ref-design--<surface>--<name>.png for a tracked canon/design/<surface>/<name>',
1093
+ 'reference, whose --add must name that same file, and `link` names that',
1093
1094
  `path on ${FRAMES_BRANCH}, so it survives a rewrite that drops other pull`,
1094
1095
  'requests and resolves until its own pull request is dropped. The pass',
1095
1096
  'segment keeps a second pass at one head and box from replacing an image',
@@ -1111,7 +1112,7 @@ export function register(program: Command): void {
1111
1112
  ' bad-mode not exactly one of --add, --drop, --prune',
1112
1113
  ' no-number --add or --drop named no pull request',
1113
1114
  ' bad-box --add with both --box and --reference, or neither',
1114
- ' bad-reference --reference is not <page>/<frame> in the safe name form',
1115
+ ' bad-reference --reference is not <page>/<frame> or canon/design/<surface>/<name> in the safe name form, or the tracked path is no file',
1115
1116
  ' bad-head --head is not 7 to 40 lowercase hex characters',
1116
1117
  ' bad-pass --add without a compact UTC --pass stamp',
1117
1118
  ' bad-theme --theme is not dark or light, or no --add, or rides a --reference',
@@ -2730,81 +2731,10 @@ async function runPreviewCheck(
2730
2731
  return reading.reason === 'fresh' ? 0 : 1
2731
2732
  }
2732
2733
 
2733
- async function readText(path: string): Promise<string | undefined> {
2734
- return readFile(path, 'utf8').catch(() => undefined)
2735
- }
2736
-
2737
- /**
2738
- * Every listening socket's owning pid, read from `/proc`. Only a process this
2739
- * user owns exposes its `fd` table, which is also the only process whose
2740
- * server this user's worktree could have started.
2741
- */
2742
- async function readProcListeners(): Promise<Listener[] | undefined> {
2743
- const tables = await Promise.all(
2744
- ['/proc/net/tcp', '/proc/net/tcp6'].map(readText),
2745
- )
2746
- if (tables.every((text) => text === undefined)) return undefined
2747
- const byInode = new Map(
2748
- tables.flatMap((text) =>
2749
- text === undefined
2750
- ? []
2751
- : parseProcNetTcp(text).map((s) => [s.inode, s.port] as const),
2752
- ),
2753
- )
2754
-
2755
- const processIds = (await readdir('/proc').catch(() => [])).filter((name) =>
2756
- /^\d+$/.test(name),
2757
- )
2758
- const listeners: Listener[] = []
2759
- for (const pid of processIds) {
2760
- const fds = await readdir(`/proc/${pid}/fd`).catch(() => [])
2761
- for (const fd of fds) {
2762
- const target = await readlink(`/proc/${pid}/fd/${fd}`).catch(() => '')
2763
- const inode = /^socket:\[(\d+)\]$/.exec(target)?.[1]
2764
- const port = inode === undefined ? undefined : byInode.get(inode)
2765
- if (port !== undefined) listeners.push({ pid: Number(pid), port })
2766
- }
2767
- }
2768
- return listeners
2769
- }
2770
-
2771
- /**
2772
- * The machine half of a detection: `/proc` wherever it exists, lsof on a
2773
- * machine without it, and no reader anywhere else.
2774
- */
2734
+ /** The listener reads come from the machine, and the HTML probe from here. */
2775
2735
  function machineLocalRunner(): LocalRunner {
2776
- const hasLsof = Bun.which('lsof') !== null
2777
2736
  return {
2778
- async listListeners() {
2779
- // lsof 4.95 drops every process whose kernel-truncated name holds an
2780
- // unmatched `(`, which Next's `next-server (vX.Y.Z)` title cuts down to,
2781
- // so `/proc` goes first despite costing several times what lsof does.
2782
- if (existsSync('/proc/net/tcp')) {
2783
- const listeners = await readProcListeners()
2784
- if (listeners !== undefined) return listeners
2785
- }
2786
- if (hasLsof) {
2787
- const result = await $`lsof -nP -iTCP -sTCP:LISTEN -Fpn`
2788
- .quiet()
2789
- .nothrow()
2790
- // lsof exits 1 both when it matched nothing and when it could not
2791
- // read some process, and the rows it did print are real either way.
2792
- if (result.exitCode <= 1) return parseLsofListeners(result.text())
2793
- }
2794
- return undefined
2795
- },
2796
- async cwdOf(pid) {
2797
- if (existsSync(`/proc/${pid}/cwd`)) {
2798
- return readlink(`/proc/${pid}/cwd`).catch(() => undefined)
2799
- }
2800
- if (!hasLsof) return undefined
2801
- const result = await $`lsof -a -p ${pid} -d cwd -Fn`.quiet().nothrow()
2802
- const line = result
2803
- .text()
2804
- .split('\n')
2805
- .find((row) => row.startsWith('n'))
2806
- return line?.slice(1)
2807
- },
2737
+ ...machineListenerReader(),
2808
2738
  // An HTML page rather than any answer, since a test runner's server or a
2809
2739
  // reload socket in the same tree answers too, and a reviewer opening one
2810
2740
  // gets a bare 404 instead of the branch.
@@ -3265,6 +3195,27 @@ async function runFrames(
3265
3195
  const invalid = framesArgumentRefusal(number, opts)
3266
3196
  if (invalid !== undefined) return refuseFrames(invalid)
3267
3197
 
3198
+ if (opts.reference !== undefined && isDesignReference(opts.reference)) {
3199
+ const tracked = resolve(
3200
+ root,
3201
+ opts.reference.endsWith('.png')
3202
+ ? opts.reference
3203
+ : `${opts.reference}.png`,
3204
+ )
3205
+ const refuseTracked = (message: string) =>
3206
+ refuseWith('bad-reference', message, emitJson, root)
3207
+ if (!statSync(tracked, { throwIfNoEntry: false })?.isFile()) {
3208
+ return refuseTracked(
3209
+ `${opts.reference} is not a file under the repository, so no tracked reference can be filed from it.`,
3210
+ )
3211
+ }
3212
+ if (opts.add === undefined || resolve(opts.add) !== tracked) {
3213
+ return refuseTracked(
3214
+ `--add must name ${tracked}, since a tracked reference files its own image and a different one would disagree with its label.`,
3215
+ )
3216
+ }
3217
+ }
3218
+
3268
3219
  const bytes =
3269
3220
  opts.add === undefined ? undefined : await readFrame(resolve(opts.add))
3270
3221
  if (opts.add !== undefined && bytes === undefined) {