@erclx/canon 4.118.0 → 4.120.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 (103) hide show
  1. package/README.md +2 -2
  2. package/claude/.claude-plugin/plugin.json +1 -1
  3. package/claude/skills/canon-rollout/SKILL.md +2 -2
  4. package/claude/skills/create-skill/SKILL.md +2 -1
  5. package/claude/skills/design-extract/SKILL.md +1 -1
  6. package/claude/skills/docs-fold/SKILL.md +2 -2
  7. package/claude/skills/draft-ready/REQUIREMENT.md +1 -1
  8. package/claude/skills/draft-ready/SKILL.md +1 -1
  9. package/claude/skills/draft-screencast/SKILL.md +1 -1
  10. package/claude/skills/git-pr/REQUIREMENT.md +1 -0
  11. package/claude/skills/git-pr/SKILL.md +39 -7
  12. package/claude/skills/memory-capture/SKILL.md +3 -3
  13. package/claude/skills/memory-review/SKILL.md +3 -3
  14. package/claude/skills/plan-feature/SKILL.md +1 -1
  15. package/claude/skills/review-branch/SKILL.md +1 -1
  16. package/claude/skills/review-pr/SKILL.md +13 -242
  17. package/claude/skills/review-pr/references/close-out.md +30 -0
  18. package/claude/skills/review-pr/references/post.md +145 -0
  19. package/claude/skills/review-pr/references/scope.md +82 -0
  20. package/claude/skills/role-orchestrator/REQUIREMENT.md +9 -16
  21. package/claude/skills/role-orchestrator/SKILL.md +9 -153
  22. package/claude/skills/role-orchestrator/references/orchestrator-dispatch.md +4 -193
  23. package/claude/skills/role-orchestrator/references/orchestrator-handback.md +22 -0
  24. package/claude/skills/role-orchestrator/references/orchestrator-launch.md +85 -0
  25. package/claude/skills/role-orchestrator/references/orchestrator-output.md +61 -0
  26. package/claude/skills/role-orchestrator/references/orchestrator-parked.md +2 -2
  27. package/claude/skills/role-orchestrator/references/orchestrator-poll.md +1 -1
  28. package/claude/skills/role-orchestrator/references/orchestrator-refill.md +56 -0
  29. package/claude/skills/role-orchestrator/references/orchestrator-review-fallback.md +18 -0
  30. package/claude/skills/role-orchestrator/references/orchestrator-sweep.md +1 -1
  31. package/claude/skills/role-planner/REQUIREMENT.md +1 -1
  32. package/claude/skills/role-planner/SKILL.md +1 -1
  33. package/claude/skills/role-worker/REQUIREMENT.md +1 -1
  34. package/claude/skills/role-worker/SKILL.md +10 -41
  35. package/claude/skills/seed-sync/SKILL.md +1 -1
  36. package/claude/skills/session-compact/SKILL.md +1 -1
  37. package/claude/skills/session-map/SKILL.md +1 -1
  38. package/claude/skills/session-relay/REQUIREMENT.md +7 -1
  39. package/claude/skills/session-relay/SKILL.md +4 -7
  40. package/claude/skills/session-worktree/REQUIREMENT.md +5 -1
  41. package/claude/skills/session-worktree/SKILL.md +7 -1
  42. package/claude/skills/session-worktree/references/main-root-writes.md +24 -0
  43. package/claude/skills/task-board/SKILL.md +4 -3
  44. package/claude/skills/teach-workspace/SKILL.md +4 -4
  45. package/claude/skills/ui-checklist/SKILL.md +1 -1
  46. package/claude/skills/ux-audit/SKILL.md +1 -1
  47. package/claude/skills/ux-measure/SKILL.md +1 -1
  48. package/claude/skills/ux-walkthrough/references/relay.md +1 -1
  49. package/docs/agents/commands.md +4 -3
  50. package/docs/agents/counts.md +1 -1
  51. package/docs/agents/index.md +8 -3
  52. package/docs/agents/install-and-sync.md +11 -6
  53. package/docs/agents/pr-evidence.md +19 -3
  54. package/docs/agents/pr-local.md +102 -0
  55. package/docs/agents/pr-preview.md +2 -2
  56. package/docs/agents/records-push.md +61 -0
  57. package/docs/agents/records-validate.md +73 -0
  58. package/docs/agents/records.md +3 -119
  59. package/docs/agents/sessions-transfer.md +91 -0
  60. package/docs/agents/sessions.md +3 -82
  61. package/docs/agents/skills-audit.md +2 -2
  62. package/docs/agents/tasks.md +4 -4
  63. package/docs/agents/teach-authoring.md +112 -0
  64. package/docs/agents/teach.md +3 -103
  65. package/docs/target-projects.md +2 -2
  66. package/docs/workflow/ai-workflow.md +10 -10
  67. package/docs/workflow/visual-design-workflow.md +2 -2
  68. package/governance/renames.toml +13 -0
  69. package/governance/rules/claude/570-skill.md +2 -1
  70. package/governance/rules/core/085-worktrees.md +2 -8
  71. package/package.json +1 -1
  72. package/scripts/core/check-ignore-parity.sh +1 -1
  73. package/scripts/lib/sandbox-git.sh +1 -1
  74. package/src/claude/cases/workflow.ts +10 -0
  75. package/src/commands/pr.ts +324 -2
  76. package/src/commands/sync.ts +11 -3
  77. package/src/commands/tasks.ts +9 -8
  78. package/src/context/classify/extract.ts +2 -2
  79. package/src/counts/scan.ts +1 -1
  80. package/src/design/base.css +19 -19
  81. package/src/design/board.ts +18 -7
  82. package/src/design/neutral.ts +127 -0
  83. package/src/design/regen.ts +6 -4
  84. package/src/design/tokens.ts +6 -3
  85. package/src/gate/stages.ts +6 -6
  86. package/src/gov/adapter.ts +51 -1
  87. package/src/gov/install.ts +13 -3
  88. package/src/gov/renames.ts +76 -0
  89. package/src/gov/stacks.ts +5 -1
  90. package/src/pr/evidence.ts +42 -2
  91. package/src/pr/local.ts +125 -0
  92. package/src/sync/check.ts +19 -4
  93. package/src/sync/engine.ts +67 -3
  94. package/src/tasks/archive.ts +28 -7
  95. package/src/tasks/record.ts +16 -9
  96. package/src/tasks/validate.ts +3 -1
  97. package/standards/index.md +1 -0
  98. package/standards/skill-requirement.md +72 -0
  99. package/standards/skill.md +34 -152
  100. package/standards/tasks.md +5 -3
  101. package/tooling/astro/reference.md +1 -1
  102. package/tooling/base/configs/.github/workflows/pr-local-preview.yml +32 -0
  103. package/governance/rules/core/091-channel.md +0 -13
@@ -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,66 @@ 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 lsof, or through /proc on a Linux machine without it.',
562
+ '',
563
+ '--remove reads the pull request comment carrying the pr-evidence',
564
+ 'marker and replaces its **Local preview:** line with --note, editing',
565
+ 'the comment itself so a close workflow with no session can call it.',
566
+ '',
567
+ 'Read `reason` on the JSON record:',
568
+ ' ok a page answered, with its address in `url`',
569
+ ' no-server nothing inside this worktree served a page',
570
+ ' no-listener-reader neither lsof nor /proc is available',
571
+ ' removed --remove replaced the line',
572
+ ' no-comment --remove found no marked comment, a no-op',
573
+ ' no-line --remove found no local line, a no-op',
574
+ '',
575
+ 'Exit codes:',
576
+ ' 0 a server was found, or --remove finished, including a no-op',
577
+ ' 1 refused, with the reason on stderr or in the JSON record',
578
+ '',
579
+ 'Examples:',
580
+ ' canon pr local --json',
581
+ ' canon pr local 1341 --remove --json',
582
+ '',
583
+ ].join('\n'),
584
+ )
585
+ .action(async (number: string | undefined, opts: LocalOptions) => {
586
+ process.exitCode =
587
+ opts.remove === true
588
+ ? await runLocalRemove(number, opts)
589
+ : await runLocal(opts)
590
+ })
487
591
  }
488
592
 
489
593
  interface PullRequestRead {
@@ -1319,7 +1423,15 @@ async function runEvidence(
1319
1423
  baseTree.has(path),
1320
1424
  )
1321
1425
 
1322
- if (grouped.kind === 'refused' && opts.preview === undefined) {
1426
+ // A local address rides on a checklist or a hosted preview and never opens
1427
+ // a comment alone, so a docs-only branch with a server up posts nothing.
1428
+ const hasChecklistAndLocal =
1429
+ suppliedChecklist !== undefined && opts.local !== undefined
1430
+ if (
1431
+ grouped.kind === 'refused' &&
1432
+ opts.preview === undefined &&
1433
+ !hasChecklistAndLocal
1434
+ ) {
1323
1435
  logStep('Skipped')
1324
1436
  logInfo(
1325
1437
  'No changed path carries an evidence/ segment, so there is nothing to post.',
@@ -1355,6 +1467,7 @@ async function runEvidence(
1355
1467
  let commentId: number | undefined
1356
1468
  let carriedPreview: string | undefined
1357
1469
  let carriedChecklist: string | undefined
1470
+ let carriedLocal: string | undefined
1358
1471
  if (identity.number !== undefined) {
1359
1472
  const commentsRow = await gh(root, [
1360
1473
  'pr',
@@ -1389,9 +1502,11 @@ async function runEvidence(
1389
1502
  commentId = findEvidenceCommentId(comments)
1390
1503
  carriedPreview = findEvidencePreview(comments)
1391
1504
  carriedChecklist = findEvidenceChecklist(comments)
1505
+ carriedLocal = findEvidenceLocal(comments)
1392
1506
  }
1393
1507
 
1394
1508
  const preview = opts.preview ?? carriedPreview
1509
+ const local = opts.local ?? carriedLocal
1395
1510
  const checklist = suppliedChecklist ?? carriedChecklist
1396
1511
  const states = grouped.kind === 'read' ? grouped.states : []
1397
1512
  const body = renderEvidenceBody(
@@ -1401,6 +1516,7 @@ async function runEvidence(
1401
1516
  identity.head,
1402
1517
  preview,
1403
1518
  checklist,
1519
+ local,
1404
1520
  )
1405
1521
 
1406
1522
  const caseCount = states.reduce((n, s) => n + s.items.length, 0)
@@ -1410,6 +1526,7 @@ async function runEvidence(
1410
1526
  `${plural(caseCount, 'case')} across ${plural(states.length, 'state')}`,
1411
1527
  )
1412
1528
  if (preview !== undefined) logInfo(`preview ${preview}`)
1529
+ if (local !== undefined) logInfo(`local preview ${local}`)
1413
1530
  if (checklist !== undefined) {
1414
1531
  logInfo(
1415
1532
  suppliedChecklist === undefined
@@ -1603,6 +1720,211 @@ async function runPreview(
1603
1720
  return 0
1604
1721
  }
1605
1722
 
1723
+ async function readText(path: string): Promise<string | undefined> {
1724
+ return readFile(path, 'utf8').catch(() => undefined)
1725
+ }
1726
+
1727
+ /**
1728
+ * Every listening socket's owning pid, read from `/proc`. Only a process this
1729
+ * user owns exposes its `fd` table, which is also the only process whose
1730
+ * server this user's worktree could have started.
1731
+ */
1732
+ async function readProcListeners(): Promise<Listener[] | undefined> {
1733
+ const tables = await Promise.all(
1734
+ ['/proc/net/tcp', '/proc/net/tcp6'].map(readText),
1735
+ )
1736
+ if (tables.every((text) => text === undefined)) return undefined
1737
+ const byInode = new Map(
1738
+ tables.flatMap((text) =>
1739
+ text === undefined
1740
+ ? []
1741
+ : parseProcNetTcp(text).map((s) => [s.inode, s.port] as const),
1742
+ ),
1743
+ )
1744
+
1745
+ const processIds = (await readdir('/proc').catch(() => [])).filter((name) =>
1746
+ /^\d+$/.test(name),
1747
+ )
1748
+ const listeners: Listener[] = []
1749
+ for (const pid of processIds) {
1750
+ const fds = await readdir(`/proc/${pid}/fd`).catch(() => [])
1751
+ for (const fd of fds) {
1752
+ const target = await readlink(`/proc/${pid}/fd/${fd}`).catch(() => '')
1753
+ const inode = /^socket:\[(\d+)\]$/.exec(target)?.[1]
1754
+ const port = inode === undefined ? undefined : byInode.get(inode)
1755
+ if (port !== undefined) listeners.push({ pid: Number(pid), port })
1756
+ }
1757
+ }
1758
+ return listeners
1759
+ }
1760
+
1761
+ /**
1762
+ * The machine half of a detection: lsof where it is installed, `/proc` on a
1763
+ * Linux machine without it, and no reader anywhere else.
1764
+ */
1765
+ function machineLocalRunner(): LocalRunner {
1766
+ const hasLsof = Bun.which('lsof') !== null
1767
+ return {
1768
+ async listListeners() {
1769
+ if (hasLsof) {
1770
+ const result = await $`lsof -nP -iTCP -sTCP:LISTEN -Fpn`
1771
+ .quiet()
1772
+ .nothrow()
1773
+ // lsof exits 1 both when it matched nothing and when it could not
1774
+ // read some process, and the rows it did print are real either way.
1775
+ if (result.exitCode <= 1) return parseLsofListeners(result.text())
1776
+ }
1777
+ if (existsSync('/proc/net/tcp')) return readProcListeners()
1778
+ return undefined
1779
+ },
1780
+ async cwdOf(pid) {
1781
+ if (existsSync(`/proc/${pid}/cwd`)) {
1782
+ return readlink(`/proc/${pid}/cwd`).catch(() => undefined)
1783
+ }
1784
+ if (!hasLsof) return undefined
1785
+ const result = await $`lsof -a -p ${pid} -d cwd -Fn`.quiet().nothrow()
1786
+ const line = result
1787
+ .text()
1788
+ .split('\n')
1789
+ .find((row) => row.startsWith('n'))
1790
+ return line?.slice(1)
1791
+ },
1792
+ // An HTML page rather than any answer, since a test runner's server or a
1793
+ // reload socket in the same tree answers too, and a reviewer opening one
1794
+ // gets a bare 404 instead of the branch.
1795
+ async probe(port) {
1796
+ try {
1797
+ const response = await fetch(`http://localhost:${port}/`, {
1798
+ signal: AbortSignal.timeout(LOCAL_PROBE_TIMEOUT_MS),
1799
+ })
1800
+ await response.body?.cancel()
1801
+ return (response.headers.get('content-type') ?? '').includes(
1802
+ 'text/html',
1803
+ )
1804
+ } catch {
1805
+ return false
1806
+ }
1807
+ },
1808
+ }
1809
+ }
1810
+
1811
+ /** The worktree's toplevel with symlinks resolved, since a process cwd is read back resolved. */
1812
+ async function resolveWorktreeRoot(root: string): Promise<string> {
1813
+ const result = await $`git -C ${root} rev-parse --show-toplevel`
1814
+ .env(gitEnv())
1815
+ .quiet()
1816
+ .nothrow()
1817
+ const toplevel = result.exitCode === 0 ? result.text().trim() : root
1818
+ return realpath(toplevel).catch(() => toplevel)
1819
+ }
1820
+
1821
+ async function runLocal(opts: LocalOptions): Promise<number> {
1822
+ const root = resolve(opts.root ?? process.cwd())
1823
+ const emitJson = opts.json ?? false
1824
+
1825
+ intro('canon pr local')
1826
+
1827
+ const worktree = await resolveWorktreeRoot(root)
1828
+ const result = await findLocalServer(worktree, machineLocalRunner())
1829
+ if (result.kind === 'refused') {
1830
+ return refuseWith(
1831
+ result.reason,
1832
+ LOCAL_REFUSALS[result.reason],
1833
+ emitJson,
1834
+ root,
1835
+ )
1836
+ }
1837
+
1838
+ logStep('Local preview')
1839
+ logInfo(result.url)
1840
+ outro()
1841
+
1842
+ if (emitJson) {
1843
+ process.stdout.write(
1844
+ `${JSON.stringify({ root, reason: 'ok', url: result.url, port: result.port })}\n`,
1845
+ )
1846
+ }
1847
+ return 0
1848
+ }
1849
+
1850
+ /**
1851
+ * Replaces the local line on the marked comment. The verb writes here rather
1852
+ * than handing a body back, because its caller is a close workflow with no
1853
+ * session to post one.
1854
+ */
1855
+ async function runLocalRemove(
1856
+ number: string | undefined,
1857
+ opts: LocalOptions,
1858
+ ): Promise<number> {
1859
+ const root = resolve(opts.root ?? process.cwd())
1860
+ const emitJson = opts.json ?? false
1861
+ const note = opts.note ?? LOCAL_REMOVED_NOTE
1862
+
1863
+ intro('canon pr local --remove')
1864
+
1865
+ if (Bun.which('gh') === null) {
1866
+ return refuseWith('gh-missing', PULL_REFUSALS['gh-missing'], emitJson, root)
1867
+ }
1868
+
1869
+ const args = ['pr', 'view']
1870
+ if (number !== undefined) args.push(number)
1871
+ args.push('--json', 'comments')
1872
+ const row = await gh(root, args)
1873
+ let comments: readonly { url?: string; body: string }[] | undefined
1874
+ try {
1875
+ comments =
1876
+ row === null
1877
+ ? undefined
1878
+ : (
1879
+ JSON.parse(row) as {
1880
+ comments?: readonly { url?: string; body: string }[]
1881
+ }
1882
+ ).comments
1883
+ } catch {
1884
+ comments = undefined
1885
+ }
1886
+ if (comments === undefined) {
1887
+ return refuseWith('gh-failed', LOCAL_REFUSALS['gh-failed'], emitJson, root)
1888
+ }
1889
+
1890
+ const finish = (reason: string, message: string): number => {
1891
+ logStep(reason === 'removed' ? 'Removed' : 'Skipped')
1892
+ logInfo(message)
1893
+ outro()
1894
+ if (emitJson) {
1895
+ process.stdout.write(`${JSON.stringify({ root, reason })}\n`)
1896
+ }
1897
+ return 0
1898
+ }
1899
+
1900
+ const commentId = findEvidenceCommentId(comments)
1901
+ const marked = comments.find((comment) =>
1902
+ comment.url?.endsWith(`#issuecomment-${commentId}`),
1903
+ )
1904
+ if (commentId === undefined || marked === undefined) {
1905
+ return finish('no-comment', 'No comment carries the evidence marker.')
1906
+ }
1907
+
1908
+ const stripped = stripLocalLine(marked.body, note)
1909
+ if (stripped === undefined) {
1910
+ return finish('no-line', 'The evidence comment carries no local line.')
1911
+ }
1912
+
1913
+ const patched = await gh(root, [
1914
+ 'api',
1915
+ '-X',
1916
+ 'PATCH',
1917
+ `repos/{owner}/{repo}/issues/comments/${commentId}`,
1918
+ '-f',
1919
+ `body=${stripped}`,
1920
+ ])
1921
+ if (patched === null) {
1922
+ return refuseWith('gh-failed', LOCAL_REFUSALS['gh-failed'], emitJson, root)
1923
+ }
1924
+
1925
+ return finish('removed', `comment ${commentId}`)
1926
+ }
1927
+
1606
1928
  /**
1607
1929
  * Frames a refusal on stderr in both modes and puts the record on stdout alone,
1608
1930
  * so an operator reading the terminal sees the reason rather than a command
@@ -133,9 +133,17 @@ function renderCheck(report: CheckReport): void {
133
133
  logWarn(entry.notice ?? `${entry.rel} (${entry.state})`)
134
134
  }
135
135
 
136
- const { stale, customized, drifted, stranded, retired, orphaned, missing } =
137
- domain.counts
138
- if (stale + customized + drifted + stranded + retired === 0) {
136
+ const {
137
+ stale,
138
+ customized,
139
+ drifted,
140
+ stranded,
141
+ retired,
142
+ renamed,
143
+ orphaned,
144
+ missing,
145
+ } = domain.counts
146
+ if (stale + customized + drifted + stranded + retired + renamed === 0) {
139
147
  logInfo(orphaned === 0 ? 'up to date' : `up to date (${orphaned} local)`)
140
148
  }
141
149
  // A local file is not a deficiency and a missing rule is, so this never
@@ -409,12 +409,14 @@ export function register(program: Command): void {
409
409
  [
410
410
  '',
411
411
  'Exit codes:',
412
- ' 0 the line was added, corrected, or already correct',
412
+ ' 0 the line was added, appended to, corrected, or already correct',
413
413
  ' 1 refused, with the reason on stderr or in the JSON record',
414
414
  '',
415
415
  'It adds Pull request: #NNN under the origin lines the task carries,',
416
- 'and corrects the number in place when the line exists. The board is',
417
- 'shared scratch, so a linked worktree records against the same board.',
416
+ 'and appends the number to the line when one exists, so a task shipped',
417
+ 'in slices lists every pull request. A line that does not read as a',
418
+ 'list is replaced. The board is shared scratch, so a linked worktree',
419
+ 'records against the same board.',
418
420
  '',
419
421
  'Examples:',
420
422
  ' canon tasks pull-request 673 v28.1-trigger-escalation',
@@ -448,11 +450,10 @@ export function register(program: Command): void {
448
450
  ' 0 the line was added, corrected, or already correct',
449
451
  ' 1 refused, with the reason on stderr or in the JSON record',
450
452
  '',
451
- 'It writes Plan: [<label>](<target>) right after the H1, the same',
452
- 'add/correct/unchanged shape canon tasks pull-request writes with, and',
453
- 'resolves both a bare slug and a board-relative path the way',
454
- 'canon tasks plan-answers does. Safe from a linked worktree, since it',
455
- 'resolves the board root in-process.',
453
+ 'It writes Plan: [<label>](<target>) right after the H1, corrects the',
454
+ 'target in place when the line exists, and resolves both a bare slug',
455
+ 'and a board-relative path the way canon tasks plan-answers does. Safe',
456
+ 'from a linked worktree, since it resolves the board root in-process.',
456
457
  '',
457
458
  'Examples:',
458
459
  ' canon tasks plan-link v28.1-trigger-escalation dispatch-answer-gate',
@@ -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.
@@ -1,29 +1,29 @@
1
- /* Generated by `canon design regen` from src/design/tokens.ts. Do not edit.
1
+ /* Generated by `canon design regen` from src/design/neutral.ts. Do not edit.
2
2
  This is the base half of a design install. A target overrides a value in
3
3
  .claude/design/project/, which the three-way merge leaves alone, so this
4
4
  file changes freely and nothing a project wrote lives in it. */
5
5
 
6
6
  :root {
7
- --color-background: #0f0e0c;
8
- --color-surface: #151412;
9
- --color-chrome: #1b1a18;
10
- --color-border: #2a2926;
11
- --color-text: #d9d7d4;
12
- --color-text-body: #aeada9;
13
- --color-text-secondary: #8c8b86;
14
- --color-muted: #7f7f7c;
15
- --color-accent: #c76b5f;
7
+ --color-background: #0e0e0e;
8
+ --color-surface: #141414;
9
+ --color-chrome: #1a1a1a;
10
+ --color-border: #292929;
11
+ --color-text: #dddddd;
12
+ --color-text-body: #b2b2b2;
13
+ --color-text-secondary: #8e8e8e;
14
+ --color-muted: #7f7f7f;
15
+ --color-accent: #ececec;
16
16
  --color-success: #61c454;
17
- --color-light-background: #fbfaf8;
18
- --color-light-surface: #f1f1ee;
19
- --color-light-chrome: #e9e8e5;
20
- --color-light-text: #2c2c29;
21
- --color-light-text-body: #4b4947;
22
- --color-light-text-secondary: #666561;
23
- --color-light-muted: #6e6d6c;
24
- --color-light-accent: #ad4a4b;
17
+ --color-light-background: #fafafa;
18
+ --color-light-surface: #f1f1f1;
19
+ --color-light-chrome: #e8e8e8;
20
+ --color-light-text: #252525;
21
+ --color-light-text-body: #444444;
22
+ --color-light-text-secondary: #5f5f5f;
23
+ --color-light-muted: #6c6c6c;
24
+ --color-light-accent: #191919;
25
25
  --color-light-success: #2d6b22;
26
- --color-light-border: #d5d4d1;
26
+ --color-light-border: #d4d4d4;
27
27
  --space-xs: 0.25rem;
28
28
  --space-sm: 0.5rem;
29
29
  --space-md: 0.875rem;
@@ -10,6 +10,7 @@ import {
10
10
  import { join, relative, sep } from 'node:path'
11
11
  import { DESIGN_DOCUMENT } from '@/design/regen'
12
12
  import { renderDesignDoc } from '@/design/render'
13
+ import { NEUTRAL_TOKENS } from '@/design/neutral'
13
14
  import { colorValue } from '@/design/tokens'
14
15
  import { parseFrontmatter, readField } from '@/indexes/frontmatter'
15
16
  import { recordDir } from '@/record-root'
@@ -131,6 +132,16 @@ const SCROLLSPY_SCRIPT = [
131
132
  '})();</script>',
132
133
  ].join('')
133
134
 
135
+ /**
136
+ * A role's toolkit value, falling back to the neutral install base's where the
137
+ * toolkit record drops the role, so the board never restates a hex of its own.
138
+ */
139
+ function roleColor(role: string): string {
140
+ const value = colorValue(role) ?? colorValue(role, NEUTRAL_TOKENS)
141
+ if (value === undefined) throw new Error(`No design token declares ${role}`)
142
+ return value
143
+ }
144
+
134
145
  /**
135
146
  * The same brand mark `src/design/render.ts` embeds in its own preview,
136
147
  * colored with the dark accent since the board's default chrome is dark
@@ -139,7 +150,7 @@ const SCROLLSPY_SCRIPT = [
139
150
  * copies rather than a shared icon helper for the first second caller.
140
151
  */
141
152
  function faviconLink(): string {
142
- const color = colorValue('accent') ?? '#e0724b'
153
+ const color = roleColor('accent')
143
154
  const href = `data:image/svg+xml,${encodeURIComponent(
144
155
  `<svg xmlns="http://www.w3.org/2000/svg" viewBox="10 10 80 80"><path d="M34,20 L15,28 L15,72 L34,80 Z M66,20 L85,28 L85,72 L66,80 Z" fill="${color}" /><rect x="44" y="15" width="12" height="70" rx="2" fill="${color}" /></svg>`,
145
156
  )}`
@@ -232,12 +243,12 @@ ${SCROLLSPY_SCRIPT}
232
243
  }
233
244
 
234
245
  function panelPage(title: string, body: string): string {
235
- const accent = colorValue('light-accent') ?? '#a4471c'
236
- const surface = colorValue('light-surface') ?? '#f4efe6'
237
- const border = colorValue('light-border') ?? '#e4dcd0'
238
- const muted = colorValue('light-muted') ?? '#726b62'
239
- const text = colorValue('light-text') ?? '#1a1815'
240
- const background = colorValue('light-background') ?? '#faf7f2'
246
+ const accent = roleColor('light-accent')
247
+ const surface = roleColor('light-surface')
248
+ const border = roleColor('light-border')
249
+ const muted = roleColor('light-muted')
250
+ const text = roleColor('light-text')
251
+ const background = roleColor('light-background')
241
252
 
242
253
  return `<!doctype html>
243
254
  <html lang="en">