@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.
Files changed (80) hide show
  1. package/claude/.claude-plugin/plugin.json +1 -1
  2. package/claude/skills/auto-ship/SKILL.md +3 -3
  3. package/claude/skills/ci-workflow/REQUIREMENT.md +2 -1
  4. package/claude/skills/ci-workflow/SKILL.md +12 -2
  5. package/claude/skills/ci-workflow/references/workflows.md +12 -9
  6. package/claude/skills/create-rule/SKILL.md +1 -0
  7. package/claude/skills/decision-escalate/SKILL.md +1 -2
  8. package/claude/skills/document-health/references/axes.md +2 -1
  9. package/claude/skills/draft-doc/references/wiki-kind.md +1 -1
  10. package/claude/skills/draft-doc/references/wiki.md +1 -1
  11. package/claude/skills/git-commit/references/commit.md +1 -5
  12. package/claude/skills/git-pr/references/pr.md +1 -0
  13. package/claude/skills/plan-feature/SKILL.md +1 -1
  14. package/claude/skills/plan-feature/references/plan.md +1 -1
  15. package/claude/skills/pr-evidence/references/evidence.md +1 -1
  16. package/claude/skills/role-orchestrator/SKILL.md +2 -0
  17. package/claude/skills/role-orchestrator/references/orchestrator-output.md +4 -0
  18. package/claude/skills/role-planner/REQUIREMENT.md +1 -1
  19. package/claude/skills/role-planner/SKILL.md +3 -3
  20. package/claude/skills/role-worker/REQUIREMENT.md +2 -0
  21. package/claude/skills/role-worker/SKILL.md +26 -2
  22. package/claude/skills/search-craft/REQUIREMENT.md +1 -1
  23. package/claude/skills/search-craft/SKILL.md +2 -2
  24. package/docs/agents/audits.md +1 -0
  25. package/docs/agents/migrate.md +11 -8
  26. package/docs/target/migrations.md +11 -0
  27. package/governance/detect.toml +4 -0
  28. package/governance/rules/canon/context.md +2 -1
  29. package/governance/rules/canon/plan.md +3 -5
  30. package/governance/rules/canon/scratch.md +3 -1
  31. package/governance/rules/canon/task-scope.md +2 -1
  32. package/governance/rules/canon/tasks.md +2 -1
  33. package/governance/rules/canon/worktrees.md +5 -2
  34. package/governance/rules/claude/output.md +7 -4
  35. package/governance/rules/claude/skill.md +6 -4
  36. package/governance/rules/tooling/ci-workflow.md +6 -39
  37. package/governance/rules/ui/design-reference.md +37 -0
  38. package/governance/rules/writing/markdown.md +2 -1
  39. package/package.json +6 -6
  40. package/src/audits/catalog.ts +42 -0
  41. package/src/claude/settings.ts +8 -4
  42. package/src/commands/claude.ts +11 -1
  43. package/src/commands/gov.ts +198 -0
  44. package/src/commands/migrate.ts +121 -2
  45. package/src/commands/pr.ts +4 -77
  46. package/src/commands/tooling.ts +3 -3
  47. package/src/commands/worktrees.ts +49 -0
  48. package/src/gate/stages.ts +15 -0
  49. package/src/gov/audit.ts +440 -0
  50. package/src/listeners/belongs.ts +21 -0
  51. package/src/listeners/machine.ts +93 -0
  52. package/src/listeners/parse.ts +41 -0
  53. package/src/migrate/session-defaults.ts +138 -0
  54. package/src/pr/local.ts +4 -69
  55. package/src/sync/check.ts +15 -0
  56. package/src/tooling/stamp.ts +56 -12
  57. package/src/worktrees/stop.ts +92 -0
  58. package/tooling/base/configs/.github/workflows/phase-label-gate.yml +6 -2
  59. package/tooling/base/configs/commitlint.config.js +0 -20
  60. package/tooling/base/reference.md +1 -1
  61. package/tooling/base/seeds/.github/workflows/verify.yml +4 -1
  62. package/tooling/claude/reference.md +13 -7
  63. package/tooling/claude/seeds/.claude/settings.json +4 -0
  64. package/tooling/claude/user/settings.template.json +0 -4
  65. package/tooling/cloudflare/configs/.github/workflows/deploy.yml +5 -5
  66. package/tooling/public/configs/.github/dependabot.yml +12 -0
  67. package/tooling/public/configs/.github/workflows/release-please.yml +30 -0
  68. package/tooling/public/manifest.toml +8 -0
  69. package/tooling/public/reference.md +53 -0
  70. package/tooling/public/seeds/.github/ISSUE_TEMPLATE/bug.yml +48 -0
  71. package/tooling/public/seeds/.github/ISSUE_TEMPLATE/config.yml +5 -0
  72. package/tooling/public/seeds/.github/ISSUE_TEMPLATE/feature.yml +16 -0
  73. package/tooling/public/seeds/.release-please-manifest.json +3 -0
  74. package/tooling/public/seeds/CONTRIBUTING.md +50 -0
  75. package/tooling/public/seeds/LICENSE +21 -0
  76. package/tooling/public/seeds/SECURITY.md +13 -0
  77. package/tooling/public/seeds/release-please-config.json +12 -0
  78. package/tooling/shell/configs/.github/workflows/shell.yml +4 -1
  79. package/tooling/web/configs/.github/workflows/readme-screenshot.yml +6 -3
  80. package/tooling/web/configs/.github/workflows/verify.yml +15 -12
@@ -1,5 +1,5 @@
1
1
  import { existsSync } from 'node:fs'
2
- import { readdir, readFile, readlink, realpath } from 'node:fs/promises'
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
- async function readText(path: string): Promise<string | undefined> {
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
- async listListeners() {
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.
@@ -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 { recordedStacks, recordToolingChain } from '@/tooling/stamp'
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: ${recordedStacks(chain).join(' < ')}`)
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
  }
@@ -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