@erclx/canon 4.119.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 (54) hide show
  1. package/README.md +2 -2
  2. package/claude/.claude-plugin/plugin.json +1 -1
  3. package/claude/skills/create-skill/SKILL.md +2 -1
  4. package/claude/skills/git-pr/REQUIREMENT.md +1 -0
  5. package/claude/skills/git-pr/SKILL.md +34 -4
  6. package/claude/skills/review-pr/SKILL.md +13 -242
  7. package/claude/skills/review-pr/references/close-out.md +30 -0
  8. package/claude/skills/review-pr/references/post.md +145 -0
  9. package/claude/skills/review-pr/references/scope.md +82 -0
  10. package/claude/skills/role-orchestrator/REQUIREMENT.md +9 -16
  11. package/claude/skills/role-orchestrator/SKILL.md +9 -153
  12. package/claude/skills/role-orchestrator/references/orchestrator-dispatch.md +4 -193
  13. package/claude/skills/role-orchestrator/references/orchestrator-handback.md +22 -0
  14. package/claude/skills/role-orchestrator/references/orchestrator-launch.md +85 -0
  15. package/claude/skills/role-orchestrator/references/orchestrator-output.md +61 -0
  16. package/claude/skills/role-orchestrator/references/orchestrator-parked.md +2 -2
  17. package/claude/skills/role-orchestrator/references/orchestrator-poll.md +1 -1
  18. package/claude/skills/role-orchestrator/references/orchestrator-refill.md +56 -0
  19. package/claude/skills/role-orchestrator/references/orchestrator-review-fallback.md +18 -0
  20. package/claude/skills/role-orchestrator/references/orchestrator-sweep.md +1 -1
  21. package/claude/skills/role-planner/REQUIREMENT.md +1 -1
  22. package/docs/agents/commands.md +2 -1
  23. package/docs/agents/counts.md +1 -1
  24. package/docs/agents/index.md +8 -3
  25. package/docs/agents/pr-evidence.md +19 -3
  26. package/docs/agents/pr-local.md +102 -0
  27. package/docs/agents/pr-preview.md +2 -2
  28. package/docs/agents/records-push.md +61 -0
  29. package/docs/agents/records-validate.md +73 -0
  30. package/docs/agents/records.md +3 -119
  31. package/docs/agents/sessions-transfer.md +91 -0
  32. package/docs/agents/sessions.md +3 -82
  33. package/docs/agents/skills-audit.md +2 -2
  34. package/docs/agents/teach-authoring.md +112 -0
  35. package/docs/agents/teach.md +3 -103
  36. package/docs/target-projects.md +1 -1
  37. package/docs/workflow/ai-workflow.md +1 -1
  38. package/docs/workflow/visual-design-workflow.md +1 -1
  39. package/governance/rules/claude/570-skill.md +2 -1
  40. package/package.json +1 -1
  41. package/scripts/core/check-ignore-parity.sh +1 -1
  42. package/scripts/lib/sandbox-git.sh +1 -1
  43. package/src/commands/pr.ts +324 -2
  44. package/src/context/classify/extract.ts +2 -2
  45. package/src/counts/scan.ts +1 -1
  46. package/src/design/tokens.ts +1 -1
  47. package/src/gate/stages.ts +2 -2
  48. package/src/pr/evidence.ts +42 -2
  49. package/src/pr/local.ts +125 -0
  50. package/standards/index.md +1 -0
  51. package/standards/skill-requirement.md +72 -0
  52. package/standards/skill.md +34 -152
  53. package/tooling/astro/reference.md +1 -1
  54. package/tooling/base/configs/.github/workflows/pr-local-preview.yml +32 -0
@@ -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
@@ -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
 
@@ -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
@@ -314,7 +314,7 @@ export const STAGES: readonly Stage[] = [
314
314
  {
315
315
  // A hook, a workflow, or a husky script reaching one side of the seed or
316
316
  // config boundary and not the other is a capability withheld with no
317
- // recorded reason, per the criterion in canon/context/tooling.md.
317
+ // recorded reason, per the criterion in canon/context/tooling/seeds.md.
318
318
  id: 'capability-seeding',
319
319
  label: 'Capability seeding',
320
320
  checks: [
@@ -7,6 +7,9 @@ const MARKER_PREFIX = '<!-- pr-evidence:'
7
7
  /** How the first line of a body names the branch's preview deployment. */
8
8
  const PREVIEW_PREFIX = '**Preview:**'
9
9
 
10
+ /** How the opening block names the branch's server on the operator's own machine, under any hosted preview line. */
11
+ export const LOCAL_PREFIX = '**Local preview:**'
12
+
10
13
  /** The heading the checklist sits under, printed outside the delimiters so a carried checklist never doubles it. */
11
14
  const CHECKLIST_HEADING = '## What to look at'
12
15
 
@@ -128,6 +131,9 @@ export function evidenceMarker(head: string): string {
128
131
  *
129
132
  * A checklist closes the body, below the comparison it annotates, so one
130
133
  * comment carries the preview address, the screenshots, and what to look at.
134
+ *
135
+ * A local address sits under the hosted one, never above it, so the hosted
136
+ * line keeps the first-line position `findEvidencePreview` reads.
131
137
  */
132
138
  export function renderEvidenceBody(
133
139
  states: readonly EvidenceState[],
@@ -136,9 +142,13 @@ export function renderEvidenceBody(
136
142
  head: string,
137
143
  preview?: string,
138
144
  checklist?: string,
145
+ local?: string,
139
146
  ): string {
140
- const opening =
141
- preview === undefined ? [] : [`${PREVIEW_PREFIX} ${preview}`, '']
147
+ const addresses = [
148
+ ...(preview === undefined ? [] : [`${PREVIEW_PREFIX} ${preview}`]),
149
+ ...(local === undefined ? [] : [`${LOCAL_PREFIX} ${local}`]),
150
+ ]
151
+ const opening = addresses.length === 0 ? [] : [...addresses, '']
142
152
  const closing =
143
153
  checklist === undefined
144
154
  ? []
@@ -223,6 +233,36 @@ export function findEvidencePreview(
223
233
  return first.slice(PREVIEW_PREFIX.length).trim() || undefined
224
234
  }
225
235
 
236
+ /**
237
+ * Where the local address line sits in a body, searched only through the
238
+ * opening block above the first blank line, so a line quoted further down in
239
+ * a checklist is never taken for it. -1 when the block carries none.
240
+ */
241
+ export function findLocalLineIndex(body: string): number {
242
+ const lines = body.split('\n')
243
+ for (let index = 0; index < lines.length; index += 1) {
244
+ const line = (lines[index] ?? '').trim()
245
+ if (line === '') return -1
246
+ if (line.startsWith(LOCAL_PREFIX)) return index
247
+ }
248
+ return -1
249
+ }
250
+
251
+ /**
252
+ * The local address the marked comment already carries, carried forward on a
253
+ * re-render the way the hosted preview is.
254
+ */
255
+ export function findEvidenceLocal(
256
+ comments: readonly EvidenceComment[],
257
+ ): string | undefined {
258
+ const marked = comments.find((comment) => hasEvidenceMarker(comment.body))
259
+ if (marked === undefined) return undefined
260
+ const index = findLocalLineIndex(marked.body)
261
+ if (index === -1) return undefined
262
+ const line = marked.body.split('\n')[index]?.trim() ?? ''
263
+ return line.slice(LOCAL_PREFIX.length).trim() || undefined
264
+ }
265
+
226
266
  /**
227
267
  * The checklist the marked comment already carries, read back out of its
228
268
  * delimiters. The handoff file is deleted once posted, so every later render
@@ -0,0 +1,125 @@
1
+ import { findLocalLineIndex } from '@/pr/evidence'
2
+
3
+ /**
4
+ * The folder linked worktrees sit under. A listener there belongs to another
5
+ * branch even though its cwd sits inside the main checkout's toplevel.
6
+ */
7
+ const LINKED_WORKTREES = '.claude/worktrees'
8
+
9
+ /** The state `/proc/net/tcp` writes for a socket in LISTEN. */
10
+ const PROC_LISTEN_STATE = '0A'
11
+
12
+ export type LocalRefusal = 'no-server' | 'no-listener-reader'
13
+
14
+ export interface Listener {
15
+ readonly port: number
16
+ readonly pid: number
17
+ }
18
+
19
+ /** The machine reads a detection makes, injected so it can be driven without binding a socket. */
20
+ export interface LocalRunner {
21
+ /** Every TCP socket in LISTEN with its owning pid, or undefined when this machine offers no way to read them. */
22
+ listListeners(): Promise<readonly Listener[] | undefined>
23
+ cwdOf(pid: number): Promise<string | undefined>
24
+ /** Whether the port serves an HTML page at its root inside the runner's timeout. */
25
+ probe(port: number): Promise<boolean>
26
+ }
27
+
28
+ export type LocalResult =
29
+ | { readonly kind: 'found'; readonly port: number; readonly url: string }
30
+ | { readonly kind: 'refused'; readonly reason: LocalRefusal }
31
+
32
+ function isInside(path: string, root: string): boolean {
33
+ return path === root || path.startsWith(`${root}/`)
34
+ }
35
+
36
+ /**
37
+ * Whether a process running in `cwd` serves this worktree. A tool that
38
+ * changes into a subfolder before listening still counts, while a sibling
39
+ * worktree nested under the main checkout's `.claude/worktrees/` does not.
40
+ */
41
+ function belongsTo(cwd: string, worktreeRoot: string): boolean {
42
+ return (
43
+ isInside(cwd, worktreeRoot) &&
44
+ !isInside(cwd, `${worktreeRoot}/${LINKED_WORKTREES}`)
45
+ )
46
+ }
47
+
48
+ /**
49
+ * Finds the server this worktree is running: the lowest port whose listening
50
+ * process runs inside the worktree and answers an HTTP probe. A port rather
51
+ * than a process is the unit, since one dev server often holds a second
52
+ * socket for its reload channel, and the lower one is the page by convention.
53
+ */
54
+ export async function findLocalServer(
55
+ worktreeRoot: string,
56
+ runner: LocalRunner,
57
+ ): Promise<LocalResult> {
58
+ const listeners = await runner.listListeners()
59
+ if (listeners === undefined) {
60
+ return { kind: 'refused', reason: 'no-listener-reader' }
61
+ }
62
+
63
+ const cwdByPid = new Map<number, string | undefined>()
64
+ const ports = new Set<number>()
65
+ for (const { pid, port } of listeners) {
66
+ if (!cwdByPid.has(pid)) cwdByPid.set(pid, await runner.cwdOf(pid))
67
+ const cwd = cwdByPid.get(pid)
68
+ if (cwd !== undefined && belongsTo(cwd, worktreeRoot)) ports.add(port)
69
+ }
70
+
71
+ for (const port of [...ports].sort((a, b) => a - b)) {
72
+ if (await runner.probe(port)) {
73
+ return { kind: 'found', port, url: `http://localhost:${port}` }
74
+ }
75
+ }
76
+ return { kind: 'refused', reason: 'no-server' }
77
+ }
78
+
79
+ /** Reads `lsof -nP -iTCP -sTCP:LISTEN -Fpn` output, where a `p` line opens a process and each `n` line names one of its sockets. */
80
+ export function parseLsofListeners(text: string): Listener[] {
81
+ const listeners: Listener[] = []
82
+ let pid: number | undefined
83
+ for (const line of text.split('\n')) {
84
+ if (line.startsWith('p')) pid = Number(line.slice(1))
85
+ if (!line.startsWith('n') || pid === undefined) continue
86
+ const port = Number(line.slice(line.lastIndexOf(':') + 1))
87
+ if (Number.isInteger(port) && port > 0) listeners.push({ pid, port })
88
+ }
89
+ return listeners
90
+ }
91
+
92
+ export interface ProcSocket {
93
+ readonly port: number
94
+ readonly inode: string
95
+ }
96
+
97
+ /** Reads `/proc/net/tcp` or `tcp6`, keeping each listening socket's port and inode for matching against `/proc/<pid>/fd`. */
98
+ export function parseProcNetTcp(text: string): ProcSocket[] {
99
+ const sockets: ProcSocket[] = []
100
+ for (const line of text.split('\n').slice(1)) {
101
+ const fields = line.trim().split(/\s+/)
102
+ const local = fields[1]
103
+ const state = fields[3]
104
+ const inode = fields[9]
105
+ if (local === undefined || inode === undefined) continue
106
+ if (state !== PROC_LISTEN_STATE) continue
107
+ const port = Number.parseInt(local.slice(local.lastIndexOf(':') + 1), 16)
108
+ if (Number.isInteger(port) && port > 0) sockets.push({ port, inode })
109
+ }
110
+ return sockets
111
+ }
112
+
113
+ /**
114
+ * Replaces the local address line of a marked body with `note`, leaving
115
+ * every other byte in place so the trailing marker, the checklist delimiters,
116
+ * and any ticked box survive. Undefined when the body carries no such line,
117
+ * which is also what a second strip of the same body returns.
118
+ */
119
+ export function stripLocalLine(body: string, note: string): string | undefined {
120
+ const index = findLocalLineIndex(body)
121
+ if (index === -1) return undefined
122
+ const lines = body.split('\n')
123
+ lines[index] = note
124
+ return lines.join('\n')
125
+ }
@@ -30,6 +30,7 @@ Reference docs for consistent authoring across the toolkit and target projects.
30
30
  - [Requirements reference](requirements.md): Shape and content rules for canon/REQUIREMENTS.md
31
31
  - [Governance rule reference](rule.md): Rule frontmatter, body shape, and voice for .claude/rules files
32
32
  - [Session map reference](session.md): Filename and location, the sections a handoff carries, the write and read procedures, and how a role extends it
33
+ - [Skill requirement reference](skill-requirement.md): Shape and content rules for the REQUIREMENT.md beside a skill
33
34
  - [Claude skill reference](skill.md): Claude skill structure and authoring rules
34
35
  - [Slug reference](slug.md): Transform from a git branch name to a slug, and the three responses to an empty result
35
36
  - [Snippet reference](snippets.md): Snippet reference and authoring conventions