@erclx/canon 4.119.0 → 4.121.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 (112) hide show
  1. package/README.md +2 -2
  2. package/claude/.claude-plugin/plugin.json +1 -1
  3. package/claude/skills/auto-ship/SKILL.md +29 -115
  4. package/claude/skills/auto-ship/references/draft-mark.md +30 -0
  5. package/claude/skills/auto-ship/references/failure-recovery.md +27 -0
  6. package/claude/skills/auto-ship/references/tier-failures.md +26 -0
  7. package/claude/skills/auto-ship/references/verb-absent.md +37 -0
  8. package/claude/skills/auto-ship/references/worktree-name.md +22 -0
  9. package/claude/skills/bash-script/SKILL.md +1 -1
  10. package/claude/skills/bash-script/references/patterns.md +0 -99
  11. package/claude/skills/bash-script/references/prompts.md +100 -0
  12. package/claude/skills/create-skill/SKILL.md +3 -2
  13. package/claude/skills/create-snippet/SKILL.md +1 -1
  14. package/claude/skills/create-standard/SKILL.md +1 -1
  15. package/claude/skills/design-extract/SKILL.md +1 -1
  16. package/claude/skills/design-taste/REQUIREMENT.md +2 -2
  17. package/claude/skills/docs-sync/SKILL.md +1 -1
  18. package/claude/skills/draft-context/SKILL.md +1 -1
  19. package/claude/skills/draft-docs/SKILL.md +1 -1
  20. package/claude/skills/draft-figure/SKILL.md +1 -1
  21. package/claude/skills/draft-readme/SKILL.md +1 -1
  22. package/claude/skills/draft-wiki/SKILL.md +1 -1
  23. package/claude/skills/draft-wireframes/SKILL.md +1 -1
  24. package/claude/skills/git-issue/SKILL.md +1 -1
  25. package/claude/skills/git-issue/references/issue.md +1 -1
  26. package/claude/skills/git-pr/REQUIREMENT.md +1 -0
  27. package/claude/skills/git-pr/SKILL.md +35 -5
  28. package/claude/skills/git-stage/SKILL.md +1 -1
  29. package/claude/skills/memory-capture/SKILL.md +1 -1
  30. package/claude/skills/restate-plainly/REQUIREMENT.md +1 -1
  31. package/claude/skills/review-address/SKILL.md +4 -3
  32. package/claude/skills/review-branch/REQUIREMENT.md +1 -0
  33. package/claude/skills/review-branch/SKILL.md +5 -6
  34. package/claude/skills/review-craft/REQUIREMENT.md +43 -0
  35. package/claude/skills/review-craft/SKILL.md +50 -0
  36. package/claude/skills/review-craft/references/security.md +21 -0
  37. package/claude/skills/review-craft/references/ui.md +72 -0
  38. package/claude/skills/review-pr/REQUIREMENT.md +1 -0
  39. package/claude/skills/review-pr/SKILL.md +14 -243
  40. package/claude/skills/review-pr/references/close-out.md +30 -0
  41. package/claude/skills/review-pr/references/post.md +145 -0
  42. package/claude/skills/review-pr/references/scope.md +82 -0
  43. package/claude/skills/role-orchestrator/REQUIREMENT.md +9 -16
  44. package/claude/skills/role-orchestrator/SKILL.md +9 -153
  45. package/claude/skills/role-orchestrator/references/orchestrator-dispatch.md +4 -193
  46. package/claude/skills/role-orchestrator/references/orchestrator-handback.md +22 -0
  47. package/claude/skills/role-orchestrator/references/orchestrator-launch.md +85 -0
  48. package/claude/skills/role-orchestrator/references/orchestrator-output.md +61 -0
  49. package/claude/skills/role-orchestrator/references/orchestrator-parked.md +2 -2
  50. package/claude/skills/role-orchestrator/references/orchestrator-poll.md +1 -1
  51. package/claude/skills/role-orchestrator/references/orchestrator-refill.md +56 -0
  52. package/claude/skills/role-orchestrator/references/orchestrator-review-fallback.md +18 -0
  53. package/claude/skills/role-orchestrator/references/orchestrator-sweep.md +1 -1
  54. package/claude/skills/role-planner/REQUIREMENT.md +2 -2
  55. package/claude/skills/standards-audit/SKILL.md +1 -1
  56. package/claude/skills/write-human/REQUIREMENT.md +7 -4
  57. package/claude/skills/write-human/SKILL.md +4 -2
  58. package/claude/skills/write-human/references/machine-tells.md +1 -1
  59. package/claude/skills/write-human/references/source-material.md +5 -2
  60. package/docs/agents/audits.md +1 -1
  61. package/docs/agents/commands.md +3 -2
  62. package/docs/agents/counts.md +1 -1
  63. package/docs/agents/index.md +8 -3
  64. package/docs/agents/markdown-audit.md +13 -19
  65. package/docs/agents/pr-evidence.md +19 -3
  66. package/docs/agents/pr-local.md +105 -0
  67. package/docs/agents/pr-preview.md +2 -2
  68. package/docs/agents/records-push.md +61 -0
  69. package/docs/agents/records-validate.md +73 -0
  70. package/docs/agents/records.md +3 -119
  71. package/docs/agents/sessions-transfer.md +91 -0
  72. package/docs/agents/sessions.md +3 -82
  73. package/docs/agents/skills-audit.md +2 -2
  74. package/docs/agents/teach-authoring.md +112 -0
  75. package/docs/agents/teach.md +3 -103
  76. package/docs/target-projects.md +2 -2
  77. package/docs/workflow/ai-workflow.md +2 -1
  78. package/docs/workflow/visual-design-workflow.md +1 -1
  79. package/governance/rules/claude/500-prose.md +1 -1
  80. package/governance/rules/claude/501-markdown.md +3 -3
  81. package/governance/rules/claude/570-skill.md +2 -1
  82. package/package.json +1 -1
  83. package/scripts/core/check-ignore-parity.sh +1 -1
  84. package/scripts/lib/sandbox-git.sh +1 -1
  85. package/src/autoship/paths.ts +3 -2
  86. package/src/claude/cases/workflow.ts +5 -0
  87. package/src/commands/audits.ts +2 -2
  88. package/src/commands/markdown.ts +7 -9
  89. package/src/commands/pr.ts +331 -2
  90. package/src/context/classify/extract.ts +2 -2
  91. package/src/counts/scan.ts +1 -1
  92. package/src/design/tokens.ts +1 -1
  93. package/src/gate/measures.ts +4 -4
  94. package/src/gate/stages.ts +2 -3
  95. package/src/markdown/bans.ts +11 -68
  96. package/src/markdown/gate.ts +1 -1
  97. package/src/markdown/scan.ts +7 -88
  98. package/src/pr/evidence.ts +42 -2
  99. package/src/pr/local.ts +125 -0
  100. package/standards/docs.md +1 -1
  101. package/standards/index.md +2 -1
  102. package/standards/markdown.md +5 -7
  103. package/standards/pr.md +1 -1
  104. package/standards/publish.md +1 -1
  105. package/standards/readme.md +2 -2
  106. package/standards/skill-requirement.md +72 -0
  107. package/standards/skill.md +34 -152
  108. package/tooling/astro/reference.md +1 -1
  109. package/tooling/base/configs/.github/workflows/pr-local-preview.yml +32 -0
  110. package/tooling/base/configs/scripts/verify.sh +2 -2
  111. package/tooling/base/seeds/canon/context/development.md +1 -1
  112. package/tooling/claude/seeds/.claude/hooks/standards-audit.sh +5 -6
@@ -1,5 +1,5 @@
1
1
  import { existsSync } from 'node:fs'
2
- import { readFile } from 'node:fs/promises'
2
+ import { readdir, readFile, readlink, 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'
@@ -28,11 +28,21 @@ import {
28
28
  import {
29
29
  findEvidenceChecklist,
30
30
  findEvidenceCommentId,
31
+ findEvidenceLocal,
31
32
  findEvidencePreview,
32
33
  groupEvidence,
33
34
  renderEvidenceBody,
34
35
  } from '@/pr/evidence'
35
36
  import { type HeadRefusal, resolveHead, resolveTip } from '@/pr/head'
37
+ import {
38
+ findLocalServer,
39
+ type Listener,
40
+ type LocalRefusal,
41
+ type LocalRunner,
42
+ parseLsofListeners,
43
+ parseProcNetTcp,
44
+ stripLocalLine,
45
+ } from '@/pr/local'
36
46
  import { KEY_CHANGES } from '@/pr/paths'
37
47
  import {
38
48
  findDeployWorkflow,
@@ -76,6 +86,28 @@ interface ReadOptions {
76
86
  interface EvidenceOptions extends ReadOptions {
77
87
  readonly preview?: string
78
88
  readonly checklist?: string
89
+ readonly local?: string
90
+ }
91
+
92
+ interface LocalOptions extends ReadOptions {
93
+ readonly remove?: boolean
94
+ readonly note?: string
95
+ }
96
+
97
+ /** What replaces the local address line once the pull request closes. */
98
+ const LOCAL_REMOVED_NOTE =
99
+ '_Local preview removed when the pull request closed._'
100
+
101
+ /** How long the probe waits on a listener before reading it as not a server. */
102
+ const LOCAL_PROBE_TIMEOUT_MS = 2_000
103
+
104
+ const LOCAL_REFUSALS: Record<LocalRefusal | 'gh-failed', string> = {
105
+ 'no-server':
106
+ 'Nothing listening inside this worktree served an HTML page. Start the dev server here and re-run to get a link.',
107
+ 'no-listener-reader':
108
+ 'This machine offers neither lsof nor /proc, so no listening socket could be read. Nothing is posted.',
109
+ 'gh-failed':
110
+ 'gh could not read or edit the marked comment on this pull request.',
79
111
  }
80
112
 
81
113
  interface PreviewOptions extends ReadOptions {
@@ -387,6 +419,10 @@ export function register(program: Command): void {
387
419
  '--checklist <path>',
388
420
  'Close the body with this visual checklist file, from canon:ui-checklist',
389
421
  )
422
+ .option(
423
+ '--local <url>',
424
+ "Add this worktree's running server under any preview line, from canon pr local",
425
+ )
390
426
  .addHelpText(
391
427
  'after',
392
428
  [
@@ -419,6 +455,13 @@ export function register(program: Command): void {
419
455
  'image still reports no-evidence, and the caller posts the checklist',
420
456
  'on its own.',
421
457
  '',
458
+ '--local adds a **Local preview:** line under the hosted preview line,',
459
+ 'or opens the body with it when there is none, and is carried forward',
460
+ 'the same way. It does not by itself turn no-evidence into ok, so a',
461
+ 'branch with a server running and nothing to show posts no link-only',
462
+ 'comment. Together with --checklist it does, so the checklist and the',
463
+ 'link land in one comment.',
464
+ '',
422
465
  'Exit codes:',
423
466
  ' 0 read, whether it produced a body or reported no-evidence',
424
467
  ' 1 refused, with the reason on stderr or in the JSON record',
@@ -428,6 +471,7 @@ export function register(program: Command): void {
428
471
  ' canon pr evidence 1341 --json',
429
472
  ' canon pr evidence 1341 --preview https://feat-x.site.pages.dev --json',
430
473
  ' canon pr evidence 1341 --checklist .canon/tmp/handoff/ui-checklist/x.md --json',
474
+ ' canon pr evidence 1341 --local http://localhost:5173 --json',
431
475
  '',
432
476
  ].join('\n'),
433
477
  )
@@ -484,6 +528,67 @@ export function register(program: Command): void {
484
528
  .action(async (number: string | undefined, opts: PreviewOptions) => {
485
529
  process.exitCode = await runPreview(number, opts)
486
530
  })
531
+
532
+ pr.command('local')
533
+ .description(
534
+ "Find the server this worktree is running and report its localhost address, or remove it from the pull request's evidence comment",
535
+ )
536
+ .argument('[number]', 'Pull request to edit on --remove')
537
+ .helpOption('-h, --help', 'Show this help message')
538
+ .option('--root <path>', 'Worktree to read, defaulting to the cwd')
539
+ .option('--json', 'Add a machine-readable record on stdout')
540
+ .option(
541
+ '--remove',
542
+ "Replace the evidence comment's local preview line with a note",
543
+ )
544
+ .option(
545
+ '--note <text>',
546
+ 'What replaces the line on --remove',
547
+ LOCAL_REMOVED_NOTE,
548
+ )
549
+ .addHelpText(
550
+ 'after',
551
+ [
552
+ '',
553
+ 'Reads every TCP socket in LISTEN, keeps those whose owning process runs',
554
+ "inside this worktree's toplevel, and requests / on each. The lowest",
555
+ 'port serving an HTML page is reported, which skips a test runner or a',
556
+ 'reload socket answering in the same tree.',
557
+ '',
558
+ 'A process in a sibling worktree, in the main checkout seen from a',
559
+ 'linked worktree, or in a linked worktree seen from the main checkout',
560
+ 'is never reported, since its server shows another branch. Sockets are',
561
+ 'read through /proc where it exists, or through lsof on a machine',
562
+ 'without it.',
563
+ '',
564
+ '--remove reads the pull request comment carrying the pr-evidence',
565
+ 'marker and replaces its **Local preview:** line with --note, editing',
566
+ 'the comment itself so a close workflow with no session can call it.',
567
+ '',
568
+ 'Read `reason` on the JSON record:',
569
+ ' ok a page answered, with its address in `url`',
570
+ ' no-server nothing inside this worktree served a page',
571
+ ' no-listener-reader neither lsof nor /proc is available',
572
+ ' removed --remove replaced the line',
573
+ ' no-comment --remove found no marked comment, a no-op',
574
+ ' no-line --remove found no local line, a no-op',
575
+ '',
576
+ 'Exit codes:',
577
+ ' 0 a server was found, or --remove finished, including a no-op',
578
+ ' 1 refused, with the reason on stderr or in the JSON record',
579
+ '',
580
+ 'Examples:',
581
+ ' canon pr local --json',
582
+ ' canon pr local 1341 --remove --json',
583
+ '',
584
+ ].join('\n'),
585
+ )
586
+ .action(async (number: string | undefined, opts: LocalOptions) => {
587
+ process.exitCode =
588
+ opts.remove === true
589
+ ? await runLocalRemove(number, opts)
590
+ : await runLocal(opts)
591
+ })
487
592
  }
488
593
 
489
594
  interface PullRequestRead {
@@ -1319,7 +1424,15 @@ async function runEvidence(
1319
1424
  baseTree.has(path),
1320
1425
  )
1321
1426
 
1322
- if (grouped.kind === 'refused' && opts.preview === undefined) {
1427
+ // A local address rides on a checklist or a hosted preview and never opens
1428
+ // a comment alone, so a docs-only branch with a server up posts nothing.
1429
+ const hasChecklistAndLocal =
1430
+ suppliedChecklist !== undefined && opts.local !== undefined
1431
+ if (
1432
+ grouped.kind === 'refused' &&
1433
+ opts.preview === undefined &&
1434
+ !hasChecklistAndLocal
1435
+ ) {
1323
1436
  logStep('Skipped')
1324
1437
  logInfo(
1325
1438
  'No changed path carries an evidence/ segment, so there is nothing to post.',
@@ -1355,6 +1468,7 @@ async function runEvidence(
1355
1468
  let commentId: number | undefined
1356
1469
  let carriedPreview: string | undefined
1357
1470
  let carriedChecklist: string | undefined
1471
+ let carriedLocal: string | undefined
1358
1472
  if (identity.number !== undefined) {
1359
1473
  const commentsRow = await gh(root, [
1360
1474
  'pr',
@@ -1389,9 +1503,11 @@ async function runEvidence(
1389
1503
  commentId = findEvidenceCommentId(comments)
1390
1504
  carriedPreview = findEvidencePreview(comments)
1391
1505
  carriedChecklist = findEvidenceChecklist(comments)
1506
+ carriedLocal = findEvidenceLocal(comments)
1392
1507
  }
1393
1508
 
1394
1509
  const preview = opts.preview ?? carriedPreview
1510
+ const local = opts.local ?? carriedLocal
1395
1511
  const checklist = suppliedChecklist ?? carriedChecklist
1396
1512
  const states = grouped.kind === 'read' ? grouped.states : []
1397
1513
  const body = renderEvidenceBody(
@@ -1401,6 +1517,7 @@ async function runEvidence(
1401
1517
  identity.head,
1402
1518
  preview,
1403
1519
  checklist,
1520
+ local,
1404
1521
  )
1405
1522
 
1406
1523
  const caseCount = states.reduce((n, s) => n + s.items.length, 0)
@@ -1410,6 +1527,7 @@ async function runEvidence(
1410
1527
  `${plural(caseCount, 'case')} across ${plural(states.length, 'state')}`,
1411
1528
  )
1412
1529
  if (preview !== undefined) logInfo(`preview ${preview}`)
1530
+ if (local !== undefined) logInfo(`local preview ${local}`)
1413
1531
  if (checklist !== undefined) {
1414
1532
  logInfo(
1415
1533
  suppliedChecklist === undefined
@@ -1603,6 +1721,217 @@ async function runPreview(
1603
1721
  return 0
1604
1722
  }
1605
1723
 
1724
+ async function readText(path: string): Promise<string | undefined> {
1725
+ return readFile(path, 'utf8').catch(() => undefined)
1726
+ }
1727
+
1728
+ /**
1729
+ * Every listening socket's owning pid, read from `/proc`. Only a process this
1730
+ * user owns exposes its `fd` table, which is also the only process whose
1731
+ * server this user's worktree could have started.
1732
+ */
1733
+ async function readProcListeners(): Promise<Listener[] | undefined> {
1734
+ const tables = await Promise.all(
1735
+ ['/proc/net/tcp', '/proc/net/tcp6'].map(readText),
1736
+ )
1737
+ if (tables.every((text) => text === undefined)) return undefined
1738
+ const byInode = new Map(
1739
+ tables.flatMap((text) =>
1740
+ text === undefined
1741
+ ? []
1742
+ : parseProcNetTcp(text).map((s) => [s.inode, s.port] as const),
1743
+ ),
1744
+ )
1745
+
1746
+ const processIds = (await readdir('/proc').catch(() => [])).filter((name) =>
1747
+ /^\d+$/.test(name),
1748
+ )
1749
+ const listeners: Listener[] = []
1750
+ for (const pid of processIds) {
1751
+ const fds = await readdir(`/proc/${pid}/fd`).catch(() => [])
1752
+ for (const fd of fds) {
1753
+ const target = await readlink(`/proc/${pid}/fd/${fd}`).catch(() => '')
1754
+ const inode = /^socket:\[(\d+)\]$/.exec(target)?.[1]
1755
+ const port = inode === undefined ? undefined : byInode.get(inode)
1756
+ if (port !== undefined) listeners.push({ pid: Number(pid), port })
1757
+ }
1758
+ }
1759
+ return listeners
1760
+ }
1761
+
1762
+ /**
1763
+ * The machine half of a detection: `/proc` wherever it exists, lsof on a
1764
+ * machine without it, and no reader anywhere else.
1765
+ */
1766
+ function machineLocalRunner(): LocalRunner {
1767
+ const hasLsof = Bun.which('lsof') !== null
1768
+ return {
1769
+ async listListeners() {
1770
+ // lsof 4.95 drops every process whose kernel-truncated name holds an
1771
+ // unmatched `(`, which Next's `next-server (vX.Y.Z)` title cuts down to,
1772
+ // so `/proc` goes first despite costing several times what lsof does.
1773
+ if (existsSync('/proc/net/tcp')) {
1774
+ const listeners = await readProcListeners()
1775
+ if (listeners !== undefined) return listeners
1776
+ }
1777
+ if (hasLsof) {
1778
+ const result = await $`lsof -nP -iTCP -sTCP:LISTEN -Fpn`
1779
+ .quiet()
1780
+ .nothrow()
1781
+ // lsof exits 1 both when it matched nothing and when it could not
1782
+ // read some process, and the rows it did print are real either way.
1783
+ if (result.exitCode <= 1) return parseLsofListeners(result.text())
1784
+ }
1785
+ return undefined
1786
+ },
1787
+ async cwdOf(pid) {
1788
+ if (existsSync(`/proc/${pid}/cwd`)) {
1789
+ return readlink(`/proc/${pid}/cwd`).catch(() => undefined)
1790
+ }
1791
+ if (!hasLsof) return undefined
1792
+ const result = await $`lsof -a -p ${pid} -d cwd -Fn`.quiet().nothrow()
1793
+ const line = result
1794
+ .text()
1795
+ .split('\n')
1796
+ .find((row) => row.startsWith('n'))
1797
+ return line?.slice(1)
1798
+ },
1799
+ // An HTML page rather than any answer, since a test runner's server or a
1800
+ // reload socket in the same tree answers too, and a reviewer opening one
1801
+ // gets a bare 404 instead of the branch.
1802
+ async probe(port) {
1803
+ try {
1804
+ const response = await fetch(`http://localhost:${port}/`, {
1805
+ signal: AbortSignal.timeout(LOCAL_PROBE_TIMEOUT_MS),
1806
+ })
1807
+ await response.body?.cancel()
1808
+ return (response.headers.get('content-type') ?? '').includes(
1809
+ 'text/html',
1810
+ )
1811
+ } catch {
1812
+ return false
1813
+ }
1814
+ },
1815
+ }
1816
+ }
1817
+
1818
+ /** The worktree's toplevel with symlinks resolved, since a process cwd is read back resolved. */
1819
+ async function resolveWorktreeRoot(root: string): Promise<string> {
1820
+ const result = await $`git -C ${root} rev-parse --show-toplevel`
1821
+ .env(gitEnv())
1822
+ .quiet()
1823
+ .nothrow()
1824
+ const toplevel = result.exitCode === 0 ? result.text().trim() : root
1825
+ return realpath(toplevel).catch(() => toplevel)
1826
+ }
1827
+
1828
+ async function runLocal(opts: LocalOptions): Promise<number> {
1829
+ const root = resolve(opts.root ?? process.cwd())
1830
+ const emitJson = opts.json ?? false
1831
+
1832
+ intro('canon pr local')
1833
+
1834
+ const worktree = await resolveWorktreeRoot(root)
1835
+ const result = await findLocalServer(worktree, machineLocalRunner())
1836
+ if (result.kind === 'refused') {
1837
+ return refuseWith(
1838
+ result.reason,
1839
+ LOCAL_REFUSALS[result.reason],
1840
+ emitJson,
1841
+ root,
1842
+ )
1843
+ }
1844
+
1845
+ logStep('Local preview')
1846
+ logInfo(result.url)
1847
+ outro()
1848
+
1849
+ if (emitJson) {
1850
+ process.stdout.write(
1851
+ `${JSON.stringify({ root, reason: 'ok', url: result.url, port: result.port })}\n`,
1852
+ )
1853
+ }
1854
+ return 0
1855
+ }
1856
+
1857
+ /**
1858
+ * Replaces the local line on the marked comment. The verb writes here rather
1859
+ * than handing a body back, because its caller is a close workflow with no
1860
+ * session to post one.
1861
+ */
1862
+ async function runLocalRemove(
1863
+ number: string | undefined,
1864
+ opts: LocalOptions,
1865
+ ): Promise<number> {
1866
+ const root = resolve(opts.root ?? process.cwd())
1867
+ const emitJson = opts.json ?? false
1868
+ const note = opts.note ?? LOCAL_REMOVED_NOTE
1869
+
1870
+ intro('canon pr local --remove')
1871
+
1872
+ if (Bun.which('gh') === null) {
1873
+ return refuseWith('gh-missing', PULL_REFUSALS['gh-missing'], emitJson, root)
1874
+ }
1875
+
1876
+ const args = ['pr', 'view']
1877
+ if (number !== undefined) args.push(number)
1878
+ args.push('--json', 'comments')
1879
+ const row = await gh(root, args)
1880
+ let comments: readonly { url?: string; body: string }[] | undefined
1881
+ try {
1882
+ comments =
1883
+ row === null
1884
+ ? undefined
1885
+ : (
1886
+ JSON.parse(row) as {
1887
+ comments?: readonly { url?: string; body: string }[]
1888
+ }
1889
+ ).comments
1890
+ } catch {
1891
+ comments = undefined
1892
+ }
1893
+ if (comments === undefined) {
1894
+ return refuseWith('gh-failed', LOCAL_REFUSALS['gh-failed'], emitJson, root)
1895
+ }
1896
+
1897
+ const finish = (reason: string, message: string): number => {
1898
+ logStep(reason === 'removed' ? 'Removed' : 'Skipped')
1899
+ logInfo(message)
1900
+ outro()
1901
+ if (emitJson) {
1902
+ process.stdout.write(`${JSON.stringify({ root, reason })}\n`)
1903
+ }
1904
+ return 0
1905
+ }
1906
+
1907
+ const commentId = findEvidenceCommentId(comments)
1908
+ const marked = comments.find((comment) =>
1909
+ comment.url?.endsWith(`#issuecomment-${commentId}`),
1910
+ )
1911
+ if (commentId === undefined || marked === undefined) {
1912
+ return finish('no-comment', 'No comment carries the evidence marker.')
1913
+ }
1914
+
1915
+ const stripped = stripLocalLine(marked.body, note)
1916
+ if (stripped === undefined) {
1917
+ return finish('no-line', 'The evidence comment carries no local line.')
1918
+ }
1919
+
1920
+ const patched = await gh(root, [
1921
+ 'api',
1922
+ '-X',
1923
+ 'PATCH',
1924
+ `repos/{owner}/{repo}/issues/comments/${commentId}`,
1925
+ '-f',
1926
+ `body=${stripped}`,
1927
+ ])
1928
+ if (patched === null) {
1929
+ return refuseWith('gh-failed', LOCAL_REFUSALS['gh-failed'], emitJson, root)
1930
+ }
1931
+
1932
+ return finish('removed', `comment ${commentId}`)
1933
+ }
1934
+
1606
1935
  /**
1607
1936
  * Frames a refusal on stderr in both modes and puts the record on stdout alone,
1608
1937
  * so an operator reading the terminal sees the reason rather than a command
@@ -285,8 +285,8 @@ export function sweepSections(
285
285
  // An H3 sitting before the document's first H2, or in a document that
286
286
  // carries no H2 at all, has no enclosing section for the main loop below
287
287
  // to attribute it to. Sweep it as a section of its own instead of
288
- // dropping it, which is the shape `canon/context/development/scratch.md`
289
- // takes: an H1 followed directly by eight H3s and no H2 anywhere.
288
+ // dropping it, which is the shape of an entry whose H1 is followed
289
+ // directly by H3s with no H2 anywhere.
290
290
  const firstH2 = headings.findIndex((h) => h.level === 2)
291
291
  const leadingBound = firstH2 === -1 ? headings.length : firstH2
292
292
 
@@ -129,7 +129,7 @@ const QUANTIFIERS = ['all']
129
129
  * of the number.
130
130
  *
131
131
  * Found on this module's own first review, against a live instance the verb
132
- * gate alone reported clean: `canon/context/development/gates.md` stated
132
+ * gate alone reported clean: the development gating-stages entry stated
133
133
  * `the twelve audits` while the tree held 20, sitting inside the plausibility
134
134
  * bound this design already carries, so nothing but the missing shape kept it
135
135
  * from reporting.
@@ -381,7 +381,7 @@ export const TOKENS: DesignTokens = {
381
381
  iconography: [
382
382
  'No icon library is installed. `assets/brand/mark.svg` is the one authored icon, embedded inline in the hero topbar, and the surfaces otherwise draw literal glyph characters: `│ ├ ✓ ! ✗ + - ◆ ◇ ❯` for the terminal framing.',
383
383
  '',
384
- 'The same mark ships in five independently-maintained copies that carry different colors by design rather than by drift, each fitted to the chrome it renders on: the dark accent for a dark-chrome surface, the light accent for a light-chrome one, and the pair the web favicon takes from `src/design/favicon.ts`, which teach pages also take by rendering the live mark. Unifying them or repairing the one that looks drifted would break the fit each was chosen for. `canon/context/design.md` carries which file holds each copy.',
384
+ 'The same mark ships in five independently-maintained copies that carry different colors by design rather than by drift, each fitted to the chrome it renders on: the dark accent for a dark-chrome surface, the light accent for a light-chrome one, and the pair the web favicon takes from `src/design/favicon.ts`, which teach pages also take by rendering the live mark. Unifying them or repairing the one that looks drifted would break the fit each was chosen for. `canon/context/design/tokens.md` carries which file holds each copy.',
385
385
  ].join('\n'),
386
386
  }
387
387
 
@@ -438,8 +438,8 @@ export const recordIdempotence: Measure = async (ctx) => {
438
438
  }
439
439
 
440
440
  /**
441
- * A banned character, word, or spelling is a fact rather than a threshold, so
442
- * it fails the push while bullet, paragraph, and depth weight stay advisory.
441
+ * A banned character is a fact rather than a threshold, so it fails the push
442
+ * while bullet, paragraph, and depth weight stay advisory.
443
443
  *
444
444
  * The whole corpus is measured rather than the changed files, because a
445
445
  * `Do not use` bullet added to a standard bans a token retroactively and no
@@ -453,7 +453,7 @@ export const markdownBans: Measure = async (ctx) => {
453
453
  const { exitCode } = await ctx.cli(['markdown', 'audit', '--json'])
454
454
 
455
455
  if (exitCode === 0) {
456
- return { emissions: [info('No banned character, word, or spelling')] }
456
+ return { emissions: [info('No banned character')] }
457
457
  }
458
458
 
459
459
  if (exitCode === 1) {
@@ -476,7 +476,7 @@ export const markdownBans: Measure = async (ctx) => {
476
476
  return {
477
477
  emissions: [output(frame.all)],
478
478
  failure:
479
- 'Markdown prose carries a banned character, word, or spelling, or a relative link resolves to nothing on disk. Rewrite the sentence or fix the link, and reach for a code span only where the token is genuinely an identifier under discussion.',
479
+ 'Markdown prose carries a banned character, or a relative link resolves to nothing on disk. Rewrite the sentence or fix the link, and reach for a code span only where the token is genuinely an identifier under discussion.',
480
480
  }
481
481
  }
482
482
 
@@ -83,7 +83,7 @@ export interface Stage {
83
83
 
84
84
  /**
85
85
  * Corpora a `src/` test asserts over from outside `src/`, censused in
86
- * `canon/context/development/verification.md`. This list and that census are
86
+ * `canon/context/development/tests.md`. This list and that census are
87
87
  * two copies of one set with nothing comparing them, so a corpus joining the
88
88
  * census joins this list in the same change. The first four are directory
89
89
  * prefixes because their tests walk the tree whole, which is what reaches a
@@ -97,7 +97,6 @@ export const TEST_CORPORA_PATTERNS = [
97
97
  '^standards/markdown\\.md$',
98
98
  '^tooling/base/reference\\.md$',
99
99
  '^tooling/web/configs/scripts/worktree-port\\.sh$',
100
- '^\\.cspell/banned-spellings\\.txt$',
101
100
  '^scripts/lib/worktree\\.sh$',
102
101
  '^scripts/core/check-ignore-parity\\.sh$',
103
102
  ]
@@ -314,7 +313,7 @@ export const STAGES: readonly Stage[] = [
314
313
  {
315
314
  // A hook, a workflow, or a husky script reaching one side of the seed or
316
315
  // config boundary and not the other is a capability withheld with no
317
- // recorded reason, per the criterion in canon/context/tooling.md.
316
+ // recorded reason, per the criterion in canon/context/tooling/seeds.md.
318
317
  id: 'capability-seeding',
319
318
  label: 'Capability seeding',
320
319
  checks: [
@@ -10,85 +10,28 @@ import type { BanSets } from '@/markdown/scan'
10
10
  const CHARACTERS = ['—', ';'] as const
11
11
 
12
12
  /**
13
- * Single lowercase words `markdown.md` bans under `## Language`.
13
+ * The one closed set the audit measures, owned here rather than harvested
14
+ * from the standard stating it.
14
15
  *
15
- * A multi-word ban is absent by the same test the character set applies. The
16
- * standard bans a pattern like `It's not X, it's Y` with a placeholder standing
17
- * in for the rest of the sentence, so no literal match reaches it and the
18
- * phrase bans stay a reader's judgment.
19
- */
20
- const WORDS = [
21
- 'seamless',
22
- 'robust',
23
- 'powerful',
24
- 'revolutionary',
25
- 'enhanced',
26
- 'allows',
27
- 'leverage',
28
- 'simply',
29
- 'just',
30
- 'easily',
31
- 'quickly',
32
- 'very',
33
- 'really',
34
- ] as const
35
-
36
- /**
37
- * British spellings of the American examples `markdown.md` lists.
38
- *
39
- * The set is carried whole rather than derived from a suffix rule, because a
40
- * suffix pattern run over prose produced 46 of the 58 false positives measured
41
- * during intake: `exercises`, `promises`, and `revised` all end in `-ise` and
42
- * none is a British spelling. Matching whole words reaches none of them.
43
- *
44
- * `analyse` is absent because the standard's example is `analyze`, which ends
45
- * in `-yze` rather than the `-ize` its rule states. The set records what the
46
- * standard reaches rather than what a reader would extend it to, so adding the
47
- * spelling here would widen the check past the prose it answers to.
48
- */
49
- const SPELLINGS = [
50
- 'organise',
51
- 'summarise',
52
- 'recognise',
53
- 'behaviour',
54
- 'colour',
55
- 'centre',
56
- ] as const
57
-
58
- /**
59
- * The three closed sets the audit measures, owned here rather than harvested
60
- * from the standards stating them.
61
- *
62
- * Parsing the prose was the alternative and it put a parser contract on two
63
- * documents authored for people, which the prose standard had to carry a
64
- * paragraph of its own to protect. The sets are a prior an author already
65
- * knows rather than a filter: measured at `60fc97bf` on 2026-08-19, these 21
66
- * terms ran across 483 markdown files for a clean exit, and every one of the
67
- * 70 occurrences of a banned word sat inside the ban list itself or inside an
68
- * example demonstrating the ban.
69
- *
70
- * The set is closed rather than extensible. Enumeration cannot close the gap
71
- * it aims at, and each addition costs a false-positive class, since `just`,
72
- * `allows`, and `very` have honest uses no literal match separates. A project
73
- * wanting its own vocabulary is asking for a different feature than this one.
16
+ * Parsing the prose was the alternative and it put a parser contract on a
17
+ * document authored for people. The set is closed rather than extensible,
18
+ * since a character is the one class a literal match settles. A word is left
19
+ * to `write-human` as guidance and a spelling to cspell, because a word ban
20
+ * catches the token and misses the habit behind it.
74
21
  */
75
22
  export const BAN_SETS: BanSets = {
76
23
  characters: CHARACTERS,
77
- words: WORDS,
78
- spellings: SPELLINGS,
79
24
  }
80
25
 
81
26
  /**
82
- * Names any set that arrived empty, so a run measuring nothing says so.
27
+ * Names the set when it arrived empty, so a run measuring nothing says so.
83
28
  *
84
29
  * Empty is not the same state as finding no hit. A scan with no terms reports
85
30
  * a clean file having looked for nothing, which is the silence the audit
86
- * refused to ship back when a standard could go missing. The sets ship with
87
- * the package now, so the only way one empties is an edit to this file, and
31
+ * refused to ship back when a standard could go missing. The set ships with
32
+ * the package now, so the only way it empties is an edit to this file, and
88
33
  * the guard is what keeps that edit loud rather than quiet.
89
34
  */
90
35
  export function emptyBanSets(sets: BanSets = BAN_SETS): string[] {
91
- return (['characters', 'words', 'spellings'] as const).filter(
92
- (name) => sets[name].length === 0,
93
- )
36
+ return (['characters'] as const).filter((name) => sets[name].length === 0)
94
37
  }
@@ -21,7 +21,7 @@ export interface GateInput {
21
21
  /**
22
22
  * Whether the audit found something that should fail the caller.
23
23
  *
24
- * A banned character, word, or spelling and a relative link resolving to
24
+ * A banned character and a relative link resolving to
25
25
  * nothing on disk are both facts rather than a threshold, so either gates
26
26
  * unconditionally and there is no widened mode to reach for. The standards
27
27
  * decide what counts as banned, which keeps this answering how many rather