@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.
- package/README.md +2 -2
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/create-skill/SKILL.md +2 -1
- package/claude/skills/git-pr/REQUIREMENT.md +1 -0
- package/claude/skills/git-pr/SKILL.md +34 -4
- package/claude/skills/review-pr/SKILL.md +13 -242
- package/claude/skills/review-pr/references/close-out.md +30 -0
- package/claude/skills/review-pr/references/post.md +145 -0
- package/claude/skills/review-pr/references/scope.md +82 -0
- package/claude/skills/role-orchestrator/REQUIREMENT.md +9 -16
- package/claude/skills/role-orchestrator/SKILL.md +9 -153
- package/claude/skills/role-orchestrator/references/orchestrator-dispatch.md +4 -193
- package/claude/skills/role-orchestrator/references/orchestrator-handback.md +22 -0
- package/claude/skills/role-orchestrator/references/orchestrator-launch.md +85 -0
- package/claude/skills/role-orchestrator/references/orchestrator-output.md +61 -0
- package/claude/skills/role-orchestrator/references/orchestrator-parked.md +2 -2
- package/claude/skills/role-orchestrator/references/orchestrator-poll.md +1 -1
- package/claude/skills/role-orchestrator/references/orchestrator-refill.md +56 -0
- package/claude/skills/role-orchestrator/references/orchestrator-review-fallback.md +18 -0
- package/claude/skills/role-orchestrator/references/orchestrator-sweep.md +1 -1
- package/claude/skills/role-planner/REQUIREMENT.md +1 -1
- package/docs/agents/commands.md +2 -1
- package/docs/agents/counts.md +1 -1
- package/docs/agents/index.md +8 -3
- package/docs/agents/pr-evidence.md +19 -3
- package/docs/agents/pr-local.md +102 -0
- package/docs/agents/pr-preview.md +2 -2
- package/docs/agents/records-push.md +61 -0
- package/docs/agents/records-validate.md +73 -0
- package/docs/agents/records.md +3 -119
- package/docs/agents/sessions-transfer.md +91 -0
- package/docs/agents/sessions.md +3 -82
- package/docs/agents/skills-audit.md +2 -2
- package/docs/agents/teach-authoring.md +112 -0
- package/docs/agents/teach.md +3 -103
- package/docs/target-projects.md +1 -1
- package/docs/workflow/ai-workflow.md +1 -1
- package/docs/workflow/visual-design-workflow.md +1 -1
- package/governance/rules/claude/570-skill.md +2 -1
- package/package.json +1 -1
- package/scripts/core/check-ignore-parity.sh +1 -1
- package/scripts/lib/sandbox-git.sh +1 -1
- package/src/commands/pr.ts +324 -2
- package/src/context/classify/extract.ts +2 -2
- package/src/counts/scan.ts +1 -1
- package/src/design/tokens.ts +1 -1
- package/src/gate/stages.ts +2 -2
- package/src/pr/evidence.ts +42 -2
- package/src/pr/local.ts +125 -0
- package/standards/index.md +1 -0
- package/standards/skill-requirement.md +72 -0
- package/standards/skill.md +34 -152
- package/tooling/astro/reference.md +1 -1
- package/tooling/base/configs/.github/workflows/pr-local-preview.yml +32 -0
package/src/commands/pr.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
289
|
-
//
|
|
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
|
|
package/src/counts/scan.ts
CHANGED
|
@@ -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:
|
|
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.
|
package/src/design/tokens.ts
CHANGED
|
@@ -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
|
|
package/src/gate/stages.ts
CHANGED
|
@@ -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/
|
|
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: [
|
package/src/pr/evidence.ts
CHANGED
|
@@ -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
|
|
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
|
package/src/pr/local.ts
ADDED
|
@@ -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
|
+
}
|
package/standards/index.md
CHANGED
|
@@ -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
|