@erclx/canon 5.42.0 → 5.43.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/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/auto-ship/SKILL.md +3 -3
- package/claude/skills/ci-workflow/REQUIREMENT.md +2 -1
- package/claude/skills/ci-workflow/SKILL.md +12 -2
- package/claude/skills/ci-workflow/references/workflows.md +12 -9
- package/claude/skills/create-rule/SKILL.md +1 -0
- package/claude/skills/decision-escalate/SKILL.md +1 -2
- package/claude/skills/document-health/references/axes.md +2 -1
- package/claude/skills/draft-doc/references/wiki-kind.md +1 -1
- package/claude/skills/draft-doc/references/wiki.md +1 -1
- package/claude/skills/git-commit/references/commit.md +1 -5
- package/claude/skills/git-pr/references/pr.md +1 -0
- package/claude/skills/plan-feature/SKILL.md +1 -1
- package/claude/skills/plan-feature/references/plan.md +1 -1
- package/claude/skills/pr-evidence/references/evidence.md +1 -1
- package/claude/skills/role-orchestrator/SKILL.md +2 -0
- package/claude/skills/role-orchestrator/references/orchestrator-output.md +4 -0
- package/claude/skills/role-planner/REQUIREMENT.md +1 -1
- package/claude/skills/role-planner/SKILL.md +3 -3
- package/claude/skills/role-worker/REQUIREMENT.md +2 -0
- package/claude/skills/role-worker/SKILL.md +26 -2
- package/claude/skills/search-craft/REQUIREMENT.md +1 -1
- package/claude/skills/search-craft/SKILL.md +2 -2
- package/docs/agents/audits.md +1 -0
- package/docs/agents/migrate.md +11 -8
- package/docs/target/migrations.md +11 -0
- package/governance/detect.toml +4 -0
- package/governance/rules/canon/context.md +2 -1
- package/governance/rules/canon/plan.md +3 -5
- package/governance/rules/canon/scratch.md +3 -1
- package/governance/rules/canon/task-scope.md +2 -1
- package/governance/rules/canon/tasks.md +2 -1
- package/governance/rules/canon/worktrees.md +5 -2
- package/governance/rules/claude/output.md +7 -4
- package/governance/rules/claude/skill.md +6 -4
- package/governance/rules/tooling/ci-workflow.md +6 -39
- package/governance/rules/ui/design-reference.md +37 -0
- package/governance/rules/writing/markdown.md +2 -1
- package/package.json +6 -6
- package/src/audits/catalog.ts +42 -0
- package/src/claude/settings.ts +8 -4
- package/src/commands/claude.ts +11 -1
- package/src/commands/gov.ts +198 -0
- package/src/commands/migrate.ts +121 -2
- package/src/commands/pr.ts +4 -77
- package/src/commands/tooling.ts +3 -3
- package/src/commands/worktrees.ts +49 -0
- package/src/gate/stages.ts +15 -0
- package/src/gov/audit.ts +440 -0
- package/src/listeners/belongs.ts +21 -0
- package/src/listeners/machine.ts +93 -0
- package/src/listeners/parse.ts +41 -0
- package/src/migrate/session-defaults.ts +138 -0
- package/src/pr/local.ts +4 -69
- package/src/sync/check.ts +15 -0
- package/src/tooling/stamp.ts +56 -12
- package/src/worktrees/stop.ts +92 -0
- package/tooling/base/configs/.github/workflows/phase-label-gate.yml +6 -2
- package/tooling/base/configs/commitlint.config.js +0 -20
- package/tooling/base/reference.md +1 -1
- package/tooling/base/seeds/.github/workflows/verify.yml +4 -1
- package/tooling/claude/reference.md +13 -7
- package/tooling/claude/seeds/.claude/settings.json +4 -0
- package/tooling/claude/user/settings.template.json +0 -4
- package/tooling/cloudflare/configs/.github/workflows/deploy.yml +5 -5
- package/tooling/public/configs/.github/dependabot.yml +12 -0
- package/tooling/public/configs/.github/workflows/release-please.yml +30 -0
- package/tooling/public/manifest.toml +8 -0
- package/tooling/public/reference.md +53 -0
- package/tooling/public/seeds/.github/ISSUE_TEMPLATE/bug.yml +48 -0
- package/tooling/public/seeds/.github/ISSUE_TEMPLATE/config.yml +5 -0
- package/tooling/public/seeds/.github/ISSUE_TEMPLATE/feature.yml +16 -0
- package/tooling/public/seeds/.release-please-manifest.json +3 -0
- package/tooling/public/seeds/CONTRIBUTING.md +50 -0
- package/tooling/public/seeds/LICENSE +21 -0
- package/tooling/public/seeds/SECURITY.md +13 -0
- package/tooling/public/seeds/release-please-config.json +12 -0
- package/tooling/shell/configs/.github/workflows/shell.yml +4 -1
- package/tooling/web/configs/.github/workflows/readme-screenshot.yml +6 -3
- package/tooling/web/configs/.github/workflows/verify.yml +15 -12
package/src/commands/pr.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs'
|
|
2
|
-
import {
|
|
2
|
+
import { readFile, realpath } from 'node:fs/promises'
|
|
3
3
|
import { basename, join, resolve } from 'node:path'
|
|
4
4
|
import { $ } from 'bun'
|
|
5
5
|
import type { Command } from 'commander'
|
|
@@ -72,15 +72,13 @@ import {
|
|
|
72
72
|
readBaseStanding,
|
|
73
73
|
readChangedPaths,
|
|
74
74
|
} from '@/pr/lift'
|
|
75
|
+
import { machineListenerReader } from '@/listeners/machine'
|
|
75
76
|
import { loadPaintedPatterns } from '@/pr/painted'
|
|
76
77
|
import {
|
|
77
78
|
dropAddressLine,
|
|
78
79
|
findLocalServer,
|
|
79
|
-
type Listener,
|
|
80
80
|
type LocalRefusal,
|
|
81
81
|
type LocalRunner,
|
|
82
|
-
parseLsofListeners,
|
|
83
|
-
parseProcNetTcp,
|
|
84
82
|
} from '@/pr/local'
|
|
85
83
|
import { KEY_CHANGES } from '@/pr/paths'
|
|
86
84
|
import {
|
|
@@ -2730,81 +2728,10 @@ async function runPreviewCheck(
|
|
|
2730
2728
|
return reading.reason === 'fresh' ? 0 : 1
|
|
2731
2729
|
}
|
|
2732
2730
|
|
|
2733
|
-
|
|
2734
|
-
return readFile(path, 'utf8').catch(() => undefined)
|
|
2735
|
-
}
|
|
2736
|
-
|
|
2737
|
-
/**
|
|
2738
|
-
* Every listening socket's owning pid, read from `/proc`. Only a process this
|
|
2739
|
-
* user owns exposes its `fd` table, which is also the only process whose
|
|
2740
|
-
* server this user's worktree could have started.
|
|
2741
|
-
*/
|
|
2742
|
-
async function readProcListeners(): Promise<Listener[] | undefined> {
|
|
2743
|
-
const tables = await Promise.all(
|
|
2744
|
-
['/proc/net/tcp', '/proc/net/tcp6'].map(readText),
|
|
2745
|
-
)
|
|
2746
|
-
if (tables.every((text) => text === undefined)) return undefined
|
|
2747
|
-
const byInode = new Map(
|
|
2748
|
-
tables.flatMap((text) =>
|
|
2749
|
-
text === undefined
|
|
2750
|
-
? []
|
|
2751
|
-
: parseProcNetTcp(text).map((s) => [s.inode, s.port] as const),
|
|
2752
|
-
),
|
|
2753
|
-
)
|
|
2754
|
-
|
|
2755
|
-
const processIds = (await readdir('/proc').catch(() => [])).filter((name) =>
|
|
2756
|
-
/^\d+$/.test(name),
|
|
2757
|
-
)
|
|
2758
|
-
const listeners: Listener[] = []
|
|
2759
|
-
for (const pid of processIds) {
|
|
2760
|
-
const fds = await readdir(`/proc/${pid}/fd`).catch(() => [])
|
|
2761
|
-
for (const fd of fds) {
|
|
2762
|
-
const target = await readlink(`/proc/${pid}/fd/${fd}`).catch(() => '')
|
|
2763
|
-
const inode = /^socket:\[(\d+)\]$/.exec(target)?.[1]
|
|
2764
|
-
const port = inode === undefined ? undefined : byInode.get(inode)
|
|
2765
|
-
if (port !== undefined) listeners.push({ pid: Number(pid), port })
|
|
2766
|
-
}
|
|
2767
|
-
}
|
|
2768
|
-
return listeners
|
|
2769
|
-
}
|
|
2770
|
-
|
|
2771
|
-
/**
|
|
2772
|
-
* The machine half of a detection: `/proc` wherever it exists, lsof on a
|
|
2773
|
-
* machine without it, and no reader anywhere else.
|
|
2774
|
-
*/
|
|
2731
|
+
/** The listener reads come from the machine, and the HTML probe from here. */
|
|
2775
2732
|
function machineLocalRunner(): LocalRunner {
|
|
2776
|
-
const hasLsof = Bun.which('lsof') !== null
|
|
2777
2733
|
return {
|
|
2778
|
-
|
|
2779
|
-
// lsof 4.95 drops every process whose kernel-truncated name holds an
|
|
2780
|
-
// unmatched `(`, which Next's `next-server (vX.Y.Z)` title cuts down to,
|
|
2781
|
-
// so `/proc` goes first despite costing several times what lsof does.
|
|
2782
|
-
if (existsSync('/proc/net/tcp')) {
|
|
2783
|
-
const listeners = await readProcListeners()
|
|
2784
|
-
if (listeners !== undefined) return listeners
|
|
2785
|
-
}
|
|
2786
|
-
if (hasLsof) {
|
|
2787
|
-
const result = await $`lsof -nP -iTCP -sTCP:LISTEN -Fpn`
|
|
2788
|
-
.quiet()
|
|
2789
|
-
.nothrow()
|
|
2790
|
-
// lsof exits 1 both when it matched nothing and when it could not
|
|
2791
|
-
// read some process, and the rows it did print are real either way.
|
|
2792
|
-
if (result.exitCode <= 1) return parseLsofListeners(result.text())
|
|
2793
|
-
}
|
|
2794
|
-
return undefined
|
|
2795
|
-
},
|
|
2796
|
-
async cwdOf(pid) {
|
|
2797
|
-
if (existsSync(`/proc/${pid}/cwd`)) {
|
|
2798
|
-
return readlink(`/proc/${pid}/cwd`).catch(() => undefined)
|
|
2799
|
-
}
|
|
2800
|
-
if (!hasLsof) return undefined
|
|
2801
|
-
const result = await $`lsof -a -p ${pid} -d cwd -Fn`.quiet().nothrow()
|
|
2802
|
-
const line = result
|
|
2803
|
-
.text()
|
|
2804
|
-
.split('\n')
|
|
2805
|
-
.find((row) => row.startsWith('n'))
|
|
2806
|
-
return line?.slice(1)
|
|
2807
|
-
},
|
|
2734
|
+
...machineListenerReader(),
|
|
2808
2735
|
// An HTML page rather than any answer, since a test runner's server or a
|
|
2809
2736
|
// reload socket in the same tree answers too, and a reviewer opening one
|
|
2810
2737
|
// gets a bare 404 instead of the branch.
|
package/src/commands/tooling.ts
CHANGED
|
@@ -18,7 +18,7 @@ import {
|
|
|
18
18
|
} from '@/tooling/manifest'
|
|
19
19
|
import { readReference, resolveReference } from '@/tooling/read'
|
|
20
20
|
import { scan, type ScanResult } from '@/tooling/scan'
|
|
21
|
-
import {
|
|
21
|
+
import { recordToolingChain } from '@/tooling/stamp'
|
|
22
22
|
import { subfolderPath } from '@/tooling/subfolder'
|
|
23
23
|
import { verifyStack } from '@/tooling/verify-stack'
|
|
24
24
|
import {
|
|
@@ -513,8 +513,8 @@ async function stampChain(chain: Manifest[], target: string): Promise<void> {
|
|
|
513
513
|
new Date(),
|
|
514
514
|
)
|
|
515
515
|
|
|
516
|
-
if (recorded) {
|
|
517
|
-
logInfo(`Recorded chain: ${
|
|
516
|
+
if (recorded !== undefined) {
|
|
517
|
+
logInfo(`Recorded chain: ${recorded.join(' < ')}`)
|
|
518
518
|
return
|
|
519
519
|
}
|
|
520
520
|
|
|
@@ -17,7 +17,9 @@ import {
|
|
|
17
17
|
type WorktreeVerdict,
|
|
18
18
|
} from '@/worktrees/reclaim'
|
|
19
19
|
import { mainWorktreeRoot } from '@/git/worktree'
|
|
20
|
+
import { machineListenerReader } from '@/listeners/machine'
|
|
20
21
|
import { repairBareFlag } from '@/worktrees/bare-flag'
|
|
22
|
+
import { type StopReport, stopListeners } from '@/worktrees/stop'
|
|
21
23
|
import {
|
|
22
24
|
removeReclaimable,
|
|
23
25
|
type RemovalOutcome,
|
|
@@ -57,6 +59,12 @@ export interface ReclaimRecord {
|
|
|
57
59
|
readonly removed: number
|
|
58
60
|
readonly failed: number
|
|
59
61
|
readonly pruned: boolean
|
|
62
|
+
/**
|
|
63
|
+
* Processes signaled, or that a dry run would signal, for listening inside a
|
|
64
|
+
* reclaimed worktree. Null when the machine offered no way to read
|
|
65
|
+
* listeners, which says nothing about what is still running.
|
|
66
|
+
*/
|
|
67
|
+
readonly stopped: number | null
|
|
60
68
|
readonly outcomes: readonly RemovalOutcome[]
|
|
61
69
|
}
|
|
62
70
|
|
|
@@ -79,6 +87,8 @@ export type ReclaimSource =
|
|
|
79
87
|
* from a run that was asked not to act.
|
|
80
88
|
*/
|
|
81
89
|
readonly removal: RemovalReport | null
|
|
90
|
+
/** Null when no stop ran, which is a reading with nothing reclaimable. */
|
|
91
|
+
readonly stop?: StopReport | null
|
|
82
92
|
}
|
|
83
93
|
|
|
84
94
|
const REFUSALS: Record<Refusal, string> = {
|
|
@@ -198,6 +208,14 @@ export function register(program: Command): void {
|
|
|
198
208
|
'deletes its branch, and a run carrying a directory that is already',
|
|
199
209
|
'gone sweeps stale registrations once before the branch deletes.',
|
|
200
210
|
'',
|
|
211
|
+
'Ahead of each removal it sends SIGTERM to every process listening from',
|
|
212
|
+
'inside a reclaimable worktree, so no dev server outlives the tree it',
|
|
213
|
+
'serves. A listener in the main checkout, in a refused worktree, or in',
|
|
214
|
+
'a worktree nested under another is left alone. A dry run signals',
|
|
215
|
+
'nothing and still reports what it would stop. "stopped" in the record',
|
|
216
|
+
'counts those processes, and reads null on a machine with neither lsof',
|
|
217
|
+
'nor /proc, where no listener could be read.',
|
|
218
|
+
'',
|
|
201
219
|
'It deletes only what a reading called reclaimable. A worktree a live',
|
|
202
220
|
'session holds is refused rather than removed here. `claude rm <id>`',
|
|
203
221
|
'is meant to remove the session and its worktree together, but its',
|
|
@@ -324,11 +342,13 @@ export function reclaimRecord(source: ReclaimSource): ReclaimRecord {
|
|
|
324
342
|
removed: 0,
|
|
325
343
|
failed: 0,
|
|
326
344
|
pruned: false,
|
|
345
|
+
stopped: 0,
|
|
327
346
|
outcomes: [],
|
|
328
347
|
}
|
|
329
348
|
}
|
|
330
349
|
|
|
331
350
|
const outcomes = source.removal?.outcomes ?? []
|
|
351
|
+
const stop = source.stop ?? null
|
|
332
352
|
|
|
333
353
|
return {
|
|
334
354
|
reason: null,
|
|
@@ -338,10 +358,30 @@ export function reclaimRecord(source: ReclaimSource): ReclaimRecord {
|
|
|
338
358
|
removed: outcomes.filter((outcome) => outcome.removed).length,
|
|
339
359
|
failed: outcomes.filter((outcome) => !outcome.removed).length,
|
|
340
360
|
pruned: source.removal?.pruned ?? false,
|
|
361
|
+
stopped: stop === null ? 0 : countStopped(stop),
|
|
341
362
|
outcomes,
|
|
342
363
|
}
|
|
343
364
|
}
|
|
344
365
|
|
|
366
|
+
function countStopped(stop: StopReport): number | null {
|
|
367
|
+
if (stop.kind === 'unread') return null
|
|
368
|
+
return stop.stopped.reduce((sum, server) => sum + server.pids.length, 0)
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/** Names each server a stop took, or would take on a dry run, and says when none could be read. */
|
|
372
|
+
function reportStop(stop: StopReport, dryRun: boolean): void {
|
|
373
|
+
if (stop.kind === 'unread') {
|
|
374
|
+
logWarn(
|
|
375
|
+
'This machine offers neither lsof nor /proc, so listening servers were not read and none was stopped.',
|
|
376
|
+
)
|
|
377
|
+
return
|
|
378
|
+
}
|
|
379
|
+
const verb = dryRun ? 'Would stop' : 'Stopped'
|
|
380
|
+
for (const { path, ports } of stop.stopped) {
|
|
381
|
+
logInfo(`${verb} the server on ${ports.join(', ')} in ${path}`)
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
345
385
|
function emitReclaim(opts: ReclaimCommandOptions, source: ReclaimSource): void {
|
|
346
386
|
if (opts.json !== true) return
|
|
347
387
|
process.stdout.write(`${JSON.stringify(reclaimRecord(source))}\n`)
|
|
@@ -381,8 +421,14 @@ async function runReclaim(opts: ReclaimCommandOptions): Promise<number> {
|
|
|
381
421
|
logInfo(plural(reclaimable.length, 'worktree'))
|
|
382
422
|
pipeOutput(reclaimable.map(describe).join('\n'))
|
|
383
423
|
|
|
424
|
+
const stop = await stopListeners(
|
|
425
|
+
reclaimable.map((entry) => entry.path),
|
|
426
|
+
{ reader: machineListenerReader(), dryRun },
|
|
427
|
+
)
|
|
428
|
+
|
|
384
429
|
if (dryRun) {
|
|
385
430
|
logStep('Dry run')
|
|
431
|
+
reportStop(stop, dryRun)
|
|
386
432
|
logWarn('Nothing was removed. Run this again without --dry-run to remove.')
|
|
387
433
|
outro()
|
|
388
434
|
emitReclaim(opts, {
|
|
@@ -390,6 +436,7 @@ async function runReclaim(opts: ReclaimCommandOptions): Promise<number> {
|
|
|
390
436
|
dryRun,
|
|
391
437
|
reclaimable: reclaimable.length,
|
|
392
438
|
removal: null,
|
|
439
|
+
stop,
|
|
393
440
|
})
|
|
394
441
|
return 0
|
|
395
442
|
}
|
|
@@ -403,6 +450,7 @@ async function runReclaim(opts: ReclaimCommandOptions): Promise<number> {
|
|
|
403
450
|
})
|
|
404
451
|
|
|
405
452
|
logStep('Removed')
|
|
453
|
+
reportStop(stop, dryRun)
|
|
406
454
|
for (const outcome of removal.outcomes) reportOutcome(outcome)
|
|
407
455
|
if (removal.pruned) {
|
|
408
456
|
logInfo(
|
|
@@ -423,6 +471,7 @@ async function runReclaim(opts: ReclaimCommandOptions): Promise<number> {
|
|
|
423
471
|
dryRun,
|
|
424
472
|
reclaimable: reclaimable.length,
|
|
425
473
|
removal,
|
|
474
|
+
stop,
|
|
426
475
|
})
|
|
427
476
|
return failed.length > 0 ? 1 : 0
|
|
428
477
|
}
|
package/src/gate/stages.ts
CHANGED
|
@@ -380,6 +380,21 @@ export const STAGES: readonly Stage[] = [
|
|
|
380
380
|
],
|
|
381
381
|
success: 'Rule citations resolve',
|
|
382
382
|
},
|
|
383
|
+
{
|
|
384
|
+
// Reads the whole rule corpus and fails only on a folder the verb lists as
|
|
385
|
+
// gated, so the finding count prints on every push while the rewrite moves
|
|
386
|
+
// folder by folder toward zero.
|
|
387
|
+
id: 'rule-shape',
|
|
388
|
+
label: 'Rule shape',
|
|
389
|
+
checks: [
|
|
390
|
+
{
|
|
391
|
+
kind: 'cli',
|
|
392
|
+
argv: ['gov', 'audit', '--gate'],
|
|
393
|
+
failure:
|
|
394
|
+
'A rule in a gated folder is past a shape limit or short of its ledger. Run bun src/cli.ts gov audit.',
|
|
395
|
+
},
|
|
396
|
+
],
|
|
397
|
+
},
|
|
383
398
|
{
|
|
384
399
|
// A citation naming the old record root resolves to nothing once the
|
|
385
400
|
// folders have moved, and the sweep that would repoint it only runs when a
|