@erclx/canon 5.41.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 (220) hide show
  1. package/claude/.claude-plugin/plugin.json +1 -1
  2. package/claude/skills/api-design/references/adopted.md +34 -14
  3. package/claude/skills/auto-ship/REQUIREMENT.md +1 -0
  4. package/claude/skills/auto-ship/SKILL.md +24 -4
  5. package/claude/skills/auto-ship/references/draft-mark.md +32 -6
  6. package/claude/skills/auto-ship/references/failure-recovery.md +15 -13
  7. package/claude/skills/auto-ship/references/tier-failures.md +31 -10
  8. package/claude/skills/auto-ship/references/ui-checklist.md +9 -4
  9. package/claude/skills/auto-ship/references/worktree-name.md +9 -3
  10. package/claude/skills/build-in-slices/references/adopted.md +35 -21
  11. package/claude/skills/canvas-board/references/canvas.md +17 -6
  12. package/claude/skills/canvas-board/references/design.md +26 -7
  13. package/claude/skills/ci-workflow/REQUIREMENT.md +2 -1
  14. package/claude/skills/ci-workflow/SKILL.md +12 -2
  15. package/claude/skills/ci-workflow/references/workflows.md +12 -9
  16. package/claude/skills/code-craft/references/adopted.md +58 -20
  17. package/claude/skills/codebase-layout/references/adopted.md +49 -16
  18. package/claude/skills/codebase-layout/references/frontend.md +11 -4
  19. package/claude/skills/codebase-layout/references/tests.md +4 -2
  20. package/claude/skills/context-fold/references/classify.md +10 -15
  21. package/claude/skills/context-fold/references/completion-report.md +11 -9
  22. package/claude/skills/context-fold/references/context-refresh.md +26 -9
  23. package/claude/skills/context-fold/references/unusable-baseline.md +5 -3
  24. package/claude/skills/create-rule/REQUIREMENT.md +5 -0
  25. package/claude/skills/create-rule/SKILL.md +10 -1
  26. package/claude/skills/create-rule/references/checklist.md +26 -0
  27. package/claude/skills/create-rule/references/rule.md +98 -20
  28. package/claude/skills/create-skill/SKILL.md +1 -0
  29. package/claude/skills/create-skill/references/skill-disclosure.md +10 -2
  30. package/claude/skills/create-skill/references/skill-frontmatter.md +43 -0
  31. package/claude/skills/create-skill/references/skill-paths.md +7 -4
  32. package/claude/skills/create-skill/references/skill-practice.md +6 -3
  33. package/claude/skills/create-skill/references/skill-requirement.md +4 -2
  34. package/claude/skills/create-skill/references/skill.md +67 -44
  35. package/claude/skills/decision-escalate/SKILL.md +1 -2
  36. package/claude/skills/deploy-app/references/vercel.md +2 -1
  37. package/claude/skills/deprecation-migration/references/adopted.md +44 -25
  38. package/claude/skills/design-taste/references/adopted.md +9 -1
  39. package/claude/skills/design-taste/references/craft.md +3 -1
  40. package/claude/skills/design-taste/references/kinds.md +8 -7
  41. package/claude/skills/design-taste/references/preflight.md +12 -0
  42. package/claude/skills/design-taste/references/systems.md +7 -6
  43. package/claude/skills/design-taste/references/tells.md +24 -25
  44. package/claude/skills/design-taste/references/vocabulary.md +7 -5
  45. package/claude/skills/document-health/references/axes.md +55 -30
  46. package/claude/skills/draft-and-pick/references/live-arms.md +2 -2
  47. package/claude/skills/draft-doc/references/context.md +4 -3
  48. package/claude/skills/draft-doc/references/docs.md +6 -3
  49. package/claude/skills/draft-doc/references/readme/agent-facing.md +6 -0
  50. package/claude/skills/draft-doc/references/readme/application.md +8 -2
  51. package/claude/skills/draft-doc/references/readme/cli.md +5 -1
  52. package/claude/skills/draft-doc/references/readme/library.md +4 -0
  53. package/claude/skills/draft-doc/references/readme/plugin.md +9 -1
  54. package/claude/skills/draft-doc/references/readme.md +30 -13
  55. package/claude/skills/draft-doc/references/requirements.md +17 -4
  56. package/claude/skills/draft-doc/references/wiki-kind.md +20 -10
  57. package/claude/skills/draft-doc/references/wiki.md +1 -1
  58. package/claude/skills/draft-figure/references/architecture-set.md +7 -3
  59. package/claude/skills/draft-figure/references/figures.md +8 -6
  60. package/claude/skills/draft-figure/references/hand-drawn.md +3 -1
  61. package/claude/skills/draft-figure/references/mermaid-path.md +19 -9
  62. package/claude/skills/draft-figure/references/mermaid.md +10 -5
  63. package/claude/skills/draft-ready/references/ready.md +22 -15
  64. package/claude/skills/git-branch/references/branch.md +11 -1
  65. package/claude/skills/git-branch/references/slug.md +17 -5
  66. package/claude/skills/git-commit/references/commit.md +9 -5
  67. package/claude/skills/git-commit/references/versioning.md +23 -17
  68. package/claude/skills/git-followup/REQUIREMENT.md +1 -1
  69. package/claude/skills/git-followup/SKILL.md +5 -6
  70. package/claude/skills/git-issue/references/issue.md +29 -16
  71. package/claude/skills/git-pr/REQUIREMENT.md +1 -5
  72. package/claude/skills/git-pr/SKILL.md +11 -22
  73. package/claude/skills/git-pr/references/final-command.md +21 -8
  74. package/claude/skills/git-pr/references/labels.md +45 -28
  75. package/claude/skills/git-pr/references/pr.md +29 -9
  76. package/claude/skills/git-pr/references/publish.md +42 -28
  77. package/claude/skills/git-pr/references/task-number.md +20 -8
  78. package/claude/skills/git-ship/SKILL.md +1 -1
  79. package/claude/skills/plan-feature/SKILL.md +1 -1
  80. package/claude/skills/plan-feature/references/plan-lifecycle.md +10 -3
  81. package/claude/skills/plan-feature/references/plan.md +58 -21
  82. package/claude/skills/plan-groundwork/references/groundwork-spikes.md +4 -1
  83. package/claude/skills/plan-groundwork/references/groundwork.md +14 -16
  84. package/claude/skills/plan-intake/references/intake.md +29 -24
  85. package/claude/skills/pr-evidence/REQUIREMENT.md +40 -0
  86. package/claude/skills/pr-evidence/SKILL.md +53 -0
  87. package/claude/skills/pr-evidence/references/evidence.md +150 -0
  88. package/claude/skills/review-address/references/post-review-findings.md +10 -3
  89. package/claude/skills/review-address/references/rebase-conflicts.md +15 -7
  90. package/claude/skills/review-address/references/rest-reads.md +3 -1
  91. package/claude/skills/review-craft/references/adopted.md +35 -18
  92. package/claude/skills/review-craft/references/security.md +42 -7
  93. package/claude/skills/review-craft/references/ui.md +23 -8
  94. package/claude/skills/review-pr/SKILL.md +6 -6
  95. package/claude/skills/review-pr/references/body-variants.md +29 -9
  96. package/claude/skills/review-pr/references/close-out.md +20 -5
  97. package/claude/skills/review-pr/references/key-changes.md +27 -3
  98. package/claude/skills/review-pr/references/post.md +83 -31
  99. package/claude/skills/review-pr/references/scope.md +54 -19
  100. package/claude/skills/review-ui/references/close-out.md +7 -5
  101. package/claude/skills/review-ui/references/driving.md +24 -4
  102. package/claude/skills/review-ui/references/post.md +43 -15
  103. package/claude/skills/role-orchestrator/SKILL.md +2 -0
  104. package/claude/skills/role-orchestrator/references/orchestrator-cloud-launch.md +67 -17
  105. package/claude/skills/role-orchestrator/references/orchestrator-dispatch.md +80 -40
  106. package/claude/skills/role-orchestrator/references/orchestrator-handback.md +52 -15
  107. package/claude/skills/role-orchestrator/references/orchestrator-handoff.md +48 -26
  108. package/claude/skills/role-orchestrator/references/orchestrator-inbound.md +12 -13
  109. package/claude/skills/role-orchestrator/references/orchestrator-launch.md +72 -36
  110. package/claude/skills/role-orchestrator/references/orchestrator-output.md +16 -4
  111. package/claude/skills/role-orchestrator/references/orchestrator-parked.md +64 -24
  112. package/claude/skills/role-orchestrator/references/orchestrator-poll.md +80 -26
  113. package/claude/skills/role-orchestrator/references/orchestrator-refill.md +41 -18
  114. package/claude/skills/role-orchestrator/references/orchestrator-resume.md +19 -7
  115. package/claude/skills/role-orchestrator/references/orchestrator-review-dispatch.md +22 -12
  116. package/claude/skills/role-orchestrator/references/orchestrator-reviewer-launch.md +15 -6
  117. package/claude/skills/role-orchestrator/references/orchestrator-sweep.md +25 -7
  118. package/claude/skills/role-orchestrator/references/orchestrator-watch.md +34 -8
  119. package/claude/skills/role-orchestrator/scripts/poll.ts +16 -24
  120. package/claude/skills/role-planner/REQUIREMENT.md +1 -1
  121. package/claude/skills/role-planner/SKILL.md +3 -3
  122. package/claude/skills/role-worker/REQUIREMENT.md +3 -0
  123. package/claude/skills/role-worker/SKILL.md +34 -2
  124. package/claude/skills/search-craft/REQUIREMENT.md +1 -1
  125. package/claude/skills/search-craft/SKILL.md +2 -2
  126. package/claude/skills/search-craft/references/adopted.md +18 -6
  127. package/claude/skills/session-map/references/session.md +26 -7
  128. package/claude/skills/session-worktree/references/main-root-writes.md +25 -8
  129. package/claude/skills/systematic-debugging/references/adopted.md +30 -16
  130. package/claude/skills/target-check/references/domains.md +39 -11
  131. package/claude/skills/target-setup/references/detect.md +80 -65
  132. package/claude/skills/target-setup/references/indexes.md +8 -6
  133. package/claude/skills/task-board/references/board.md +86 -30
  134. package/claude/skills/task-board/references/decline.md +29 -13
  135. package/claude/skills/task-board/references/tasks-decline.md +12 -5
  136. package/claude/skills/task-board/references/tasks.md +65 -33
  137. package/claude/skills/task-complete/references/archive.md +41 -17
  138. package/claude/skills/task-complete/references/receipt-sweep.md +23 -9
  139. package/claude/skills/task-complete/references/unusable-baseline.md +10 -3
  140. package/claude/skills/teach-workspace/references/glossary.md +6 -2
  141. package/claude/skills/teach-workspace/references/lesson-build.md +44 -16
  142. package/claude/skills/teach-workspace/references/lesson-craft.md +12 -7
  143. package/claude/skills/teach-workspace/references/pedagogy.md +6 -3
  144. package/claude/skills/teach-workspace/references/promotion-fold.md +17 -11
  145. package/claude/skills/teach-workspace/references/promotion.md +31 -8
  146. package/claude/skills/teach-workspace/references/revisit.md +8 -2
  147. package/claude/skills/teach-workspace/references/teach.md +17 -9
  148. package/claude/skills/test-craft/references/adopted.md +48 -14
  149. package/claude/skills/test-craft/references/component.md +7 -4
  150. package/claude/skills/test-craft/references/e2e.md +25 -7
  151. package/claude/skills/test-craft/references/unit.md +3 -1
  152. package/claude/skills/test-first/references/adopted.md +12 -4
  153. package/claude/skills/ui-checklist/REQUIREMENT.md +1 -1
  154. package/claude/skills/ui-checklist/SKILL.md +1 -1
  155. package/claude/skills/video-craft/references/adopted.md +62 -18
  156. package/claude/skills/write-human/references/adopted.md +21 -8
  157. package/docs/agents/audits.md +1 -0
  158. package/docs/agents/migrate.md +11 -8
  159. package/docs/target/migrations.md +11 -0
  160. package/docs/workflow/parallel-features.md +1 -1
  161. package/docs/workflow/skill-map.md +1 -0
  162. package/governance/detect.toml +4 -0
  163. package/governance/rules/canon/context.md +2 -1
  164. package/governance/rules/canon/plan.md +3 -5
  165. package/governance/rules/canon/scratch.md +3 -1
  166. package/governance/rules/canon/task-scope.md +2 -1
  167. package/governance/rules/canon/tasks.md +2 -1
  168. package/governance/rules/canon/worktrees.md +5 -2
  169. package/governance/rules/claude/output.md +7 -4
  170. package/governance/rules/claude/skill.md +6 -4
  171. package/governance/rules/tooling/ci-workflow.md +6 -39
  172. package/governance/rules/ui/design-reference.md +37 -0
  173. package/governance/rules/writing/markdown.md +2 -1
  174. package/package.json +6 -6
  175. package/src/audits/catalog.ts +42 -0
  176. package/src/claude/cases/workflow.ts +5 -0
  177. package/src/claude/settings.ts +8 -4
  178. package/src/commands/claude.ts +11 -1
  179. package/src/commands/gov.ts +198 -0
  180. package/src/commands/migrate.ts +121 -2
  181. package/src/commands/pr.ts +146 -105
  182. package/src/commands/tooling.ts +3 -3
  183. package/src/commands/worktrees.ts +49 -0
  184. package/src/gate/stages.ts +15 -0
  185. package/src/gov/audit.ts +440 -0
  186. package/src/listeners/belongs.ts +21 -0
  187. package/src/listeners/machine.ts +93 -0
  188. package/src/listeners/parse.ts +41 -0
  189. package/src/markdown/scope.ts +144 -0
  190. package/src/migrate/session-defaults.ts +138 -0
  191. package/src/pr/lift.ts +6 -3
  192. package/src/pr/local.ts +4 -69
  193. package/src/pr/review-state.ts +182 -0
  194. package/src/sync/check.ts +15 -0
  195. package/src/tooling/stamp.ts +56 -12
  196. package/src/worktrees/stop.ts +92 -0
  197. package/tooling/base/configs/.github/workflows/phase-label-gate.yml +6 -2
  198. package/tooling/base/configs/commitlint.config.js +0 -20
  199. package/tooling/base/reference.md +1 -1
  200. package/tooling/base/seeds/.github/workflows/verify.yml +4 -1
  201. package/tooling/claude/reference.md +13 -7
  202. package/tooling/claude/seeds/.claude/settings.json +4 -0
  203. package/tooling/claude/user/settings.template.json +0 -4
  204. package/tooling/cloudflare/configs/.github/workflows/deploy.yml +5 -5
  205. package/tooling/public/configs/.github/dependabot.yml +12 -0
  206. package/tooling/public/configs/.github/workflows/release-please.yml +30 -0
  207. package/tooling/public/manifest.toml +8 -0
  208. package/tooling/public/reference.md +53 -0
  209. package/tooling/public/seeds/.github/ISSUE_TEMPLATE/bug.yml +48 -0
  210. package/tooling/public/seeds/.github/ISSUE_TEMPLATE/config.yml +5 -0
  211. package/tooling/public/seeds/.github/ISSUE_TEMPLATE/feature.yml +16 -0
  212. package/tooling/public/seeds/.release-please-manifest.json +3 -0
  213. package/tooling/public/seeds/CONTRIBUTING.md +50 -0
  214. package/tooling/public/seeds/LICENSE +21 -0
  215. package/tooling/public/seeds/SECURITY.md +13 -0
  216. package/tooling/public/seeds/release-please-config.json +12 -0
  217. package/tooling/shell/configs/.github/workflows/shell.yml +4 -1
  218. package/tooling/web/configs/.github/workflows/readme-screenshot.yml +6 -3
  219. package/tooling/web/configs/.github/workflows/verify.yml +15 -12
  220. package/claude/skills/git-pr/references/evidence.md +0 -124
@@ -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 {
@@ -107,7 +105,12 @@ import {
107
105
  reviewRowOf,
108
106
  selectBranchPull,
109
107
  } from '@/pr/rest'
110
- import { resolveReviewScope } from '@/pr/review-scope'
108
+ import {
109
+ readReviewState,
110
+ type ReviewStateApi,
111
+ type ReviewStateRecord,
112
+ waitForReviews,
113
+ } from '@/pr/review-state'
111
114
  import { PROJECT_ROOT } from '@/roots/project'
112
115
  import { intro, logInfo, logStep, logWarn, outro, plural } from '@/cli/ui'
113
116
 
@@ -140,6 +143,10 @@ interface LiftCommandOptions extends ReadOptions {
140
143
  readonly wait?: string
141
144
  }
142
145
 
146
+ interface ReviewStateOptions extends ReadOptions {
147
+ readonly wait?: string
148
+ }
149
+
143
150
  interface LocalOptions extends ReadOptions {
144
151
  readonly remove?: boolean
145
152
  }
@@ -561,11 +568,17 @@ export function register(program: Command): void {
561
568
  })
562
569
 
563
570
  pr.command('review-state')
564
- .description('Report the commit and instant the last review pass covered')
571
+ .description(
572
+ 'Report what the last code review and UI review passes covered at the tip',
573
+ )
565
574
  .argument('[number]', 'Pull request to read, defaulting to this branch')
566
575
  .helpOption('-h, --help', 'Show this help message')
567
576
  .option('--root <path>', 'Repository to read, defaulting to the cwd')
568
577
  .option('--json', 'Add a machine-readable record on stdout')
578
+ .option(
579
+ '--wait <seconds>',
580
+ `Hold until a pass covers the tip in every bound family, default ${DEFAULT_WAIT_SECONDS}`,
581
+ )
569
582
  .addHelpText(
570
583
  'after',
571
584
  [
@@ -587,6 +600,31 @@ export function register(program: Command): void {
587
600
  ' fallback a pass posted before the marker shipped, off GitHub stamps',
588
601
  ' none the thread carries no pass, so the next one is a first pass',
589
602
  '',
603
+ 'Those code review fields stay at the top level of the record. Beside',
604
+ 'them, `head` is the branch tip read from the remote, and `review` and',
605
+ '`ui` carry each family at that tip:',
606
+ ' reason the refusal `canon pr lift` gives the family, absent when',
607
+ ' its gate passes, read through the same gate function',
608
+ ' isCovered a pass covers the tip: the gate passes, or the newest pass,',
609
+ ' open or unmarked, names the tip',
610
+ '',
611
+ '`ui` also carries the newest `## UI review` pass (`heading`, `state`,',
612
+ '`commit`, `source`), `isBound`, which is whether the evidence comment',
613
+ 'carries a checklist, and `carriedFrom` or `paintedPaths` when the lift',
614
+ 'carried a close forward or refused to. An unbound family reads covered.',
615
+ '',
616
+ 'Both families post as pull request reviews, so this reads',
617
+ '`pulls/<n>/reviews` alone, as the lift does. Issue comments are not',
618
+ 'read: neither review skill posts there, and a heading found only in a',
619
+ 'comment would let a worker move on a pass the lift then refuses.',
620
+ '',
621
+ '`--wait <seconds>` holds until `isCovered` reads true in both families',
622
+ 'or the deadline passes, re-reading the tip every 30 seconds so a push',
623
+ 'moves what a pass must cover. The evidence is read once at the start.',
624
+ 'The record then adds `reason`, `covered` or `timeout`, and',
625
+ '`waitedSeconds`. A wait past 600 seconds outlasts a foreground shell',
626
+ 'call, so run it in the background.',
627
+ '',
590
628
  'Refusals, read off `reason`:',
591
629
  ' ambiguous-pull two open pull requests share the branch against',
592
630
  ' different bases, so name a number',
@@ -599,18 +637,22 @@ export function register(program: Command): void {
599
637
  ' gh-missing `gh` is not on the path, so no pull request resolved',
600
638
  ' gh-failed `gh` could not answer for this branch, so name a number',
601
639
  ' no-object-head the pull request object reported no head commit',
640
+ ' bad-wait --wait takes a whole number of seconds',
602
641
  '',
603
642
  'Exit codes:',
604
- ' 0 the thread was read, whether it carries a pass or not',
605
- ' 1 refused, with the reason on stderr or in the JSON record',
643
+ ' 0 the thread was read, whether it carries a pass or not, or the',
644
+ ' wait ended covered',
645
+ ' 1 refused, with the reason on stderr or in the JSON record, or the',
646
+ ' wait reached its deadline as `timeout`',
606
647
  '',
607
648
  'Examples:',
608
649
  ' canon pr review-state',
609
650
  ' canon pr review-state 1341 --json',
651
+ ' canon pr review-state 1341 --wait 1200 --json',
610
652
  '',
611
653
  ].join('\n'),
612
654
  )
613
- .action(async (number: string | undefined, opts: ReadOptions) => {
655
+ .action(async (number: string | undefined, opts: ReviewStateOptions) => {
614
656
  process.exitCode = await runReviewState(number, opts)
615
657
  })
616
658
 
@@ -1818,13 +1860,21 @@ async function runChecks(
1818
1860
 
1819
1861
  async function runReviewState(
1820
1862
  number: string | undefined,
1821
- opts: ReadOptions,
1863
+ opts: ReviewStateOptions,
1822
1864
  ): Promise<number> {
1823
1865
  const root = resolve(opts.root ?? process.cwd())
1824
1866
  const emitJson = opts.json ?? false
1825
1867
 
1826
1868
  intro('canon pr review-state')
1827
1869
 
1870
+ let waitSeconds: number | undefined
1871
+ if (opts.wait !== undefined) {
1872
+ if (!/^\d+$/.test(opts.wait)) {
1873
+ return refuseWith('bad-wait', LIFT_REFUSALS['bad-wait'], emitJson, root)
1874
+ }
1875
+ waitSeconds = Number(opts.wait)
1876
+ }
1877
+
1828
1878
  if (Bun.which('gh') === null) {
1829
1879
  return refuseWith('gh-missing', PULL_REFUSALS['gh-missing'], emitJson, root)
1830
1880
  }
@@ -1841,20 +1891,84 @@ async function runReviewState(
1841
1891
  )
1842
1892
  }
1843
1893
 
1844
- const reviews = await listReviews(root, resolved.number)
1845
- if (reviews.kind === 'refused') {
1846
- const reason =
1847
- reviews.reason === 'gh-failed' ? 'gh-failed' : 'reviews-unreadable'
1848
- return refuseWith(reason, PULL_REFUSALS[reason], emitJson, root)
1894
+ const read = await readIdentity(root, resolved.number)
1895
+ if (read.kind === 'refused') {
1896
+ return refuseWith(read.reason, PULL_REFUSALS[read.reason], emitJson, root)
1897
+ }
1898
+
1899
+ // The module refuses every failed read as `gh-failed`. The api keeps each
1900
+ // read's own reason, so a caller still tells an unreadable thread from an
1901
+ // unreadable remote.
1902
+ let failure: PullRefusal | HeadRefusal = 'gh-failed'
1903
+ const git = gitRunner(root)
1904
+ const api: ReviewStateApi = {
1905
+ readTip: async () => {
1906
+ const tip = await resolveTip(read.identity.branch, refReader(root))
1907
+ if (tip.kind === 'read') return tip.tip
1908
+ failure = tip.reason
1909
+ return undefined
1910
+ },
1911
+ readReviews: async () => {
1912
+ const reviews = await listReviews(root, resolved.number)
1913
+ if (reviews.kind === 'read') return reviews.rows.map(reviewRowOf)
1914
+ failure =
1915
+ reviews.reason === 'gh-failed' ? 'gh-failed' : 'reviews-unreadable'
1916
+ return undefined
1917
+ },
1918
+ readEvidence: () => readEvidenceGate(root, resolved.number),
1919
+ readChanged: (from, to) => readChangedPaths(git, from, to),
1920
+ sleep: (ms) => new Promise((done) => setTimeout(done, ms)),
1921
+ now: () => Date.now(),
1849
1922
  }
1850
1923
 
1851
- const listing = { number: Number(resolved.number) }
1852
- const scope = resolveReviewScope({
1853
- reviews: reviews.rows.map(reviewRowOf),
1854
- })
1924
+ const painted = loadPaintedPatterns(root)
1925
+ const wait =
1926
+ waitSeconds === undefined
1927
+ ? undefined
1928
+ : await waitForReviews(api, { waitSeconds, painted })
1929
+ const state = wait ?? (await readReviewState(api, painted))
1930
+ if (state.kind === 'refused') {
1931
+ return refuseWith(failure, PULL_REFUSALS[failure], emitJson, root)
1932
+ }
1933
+
1934
+ const { record } = state
1935
+ const pull = Number(resolved.number)
1936
+ logReviewState(pull, record)
1937
+ const waited =
1938
+ wait?.kind === 'read'
1939
+ ? { reason: wait.reason, waitedSeconds: wait.waitedSeconds }
1940
+ : undefined
1941
+ if (waited !== undefined) {
1942
+ logStep(waited.reason === 'covered' ? 'Covered' : 'Timeout')
1943
+ logInfo(
1944
+ waited.reason === 'covered'
1945
+ ? `a pass covers ${record.head.slice(0, 8)} in every bound family after ${waited.waitedSeconds}s`
1946
+ : `no pass covered ${record.head.slice(0, 8)} in every bound family within ${waited.waitedSeconds}s`,
1947
+ )
1948
+ }
1949
+
1950
+ outro()
1951
+
1952
+ if (emitJson) {
1953
+ const { scope, ...families } = record
1954
+ process.stdout.write(
1955
+ `${JSON.stringify({
1956
+ root,
1957
+ number: pull,
1958
+ ...scope,
1959
+ ...families,
1960
+ ...waited,
1961
+ })}\n`,
1962
+ )
1963
+ }
1964
+
1965
+ return waited?.reason === 'timeout' ? 1 : 0
1966
+ }
1855
1967
 
1968
+ function logReviewState(number: number, record: ReviewStateRecord): void {
1969
+ const { scope } = record
1856
1970
  logStep('Scope')
1857
- logInfo(`#${listing.number}`)
1971
+ logInfo(`#${number} at ${record.head.slice(0, 8)}`)
1858
1972
 
1859
1973
  if (scope.source === 'none') {
1860
1974
  logStep('First pass')
@@ -1874,19 +1988,17 @@ async function runReviewState(
1874
1988
  )
1875
1989
  }
1876
1990
 
1877
- outro()
1878
-
1879
- if (emitJson) {
1880
- process.stdout.write(
1881
- `${JSON.stringify({
1882
- root,
1883
- number: listing.number,
1884
- ...scope,
1885
- })}\n`,
1886
- )
1991
+ for (const [name, family] of [
1992
+ ['review', record.review],
1993
+ ['ui', record.ui],
1994
+ ] as const) {
1995
+ const line = `${name}: ${family.reason ?? 'clear'}, ${family.isCovered ? 'a pass covers the tip' : 'no pass covers the tip'}`
1996
+ if (family.isCovered) logInfo(line)
1997
+ else logWarn(line)
1998
+ }
1999
+ if (!record.ui.isBound) {
2000
+ logInfo('ui: the evidence carries no checklist, so the family is unbound')
1887
2001
  }
1888
-
1889
- return 0
1890
2002
  }
1891
2003
 
1892
2004
  interface PullFiles {
@@ -2616,81 +2728,10 @@ async function runPreviewCheck(
2616
2728
  return reading.reason === 'fresh' ? 0 : 1
2617
2729
  }
2618
2730
 
2619
- async function readText(path: string): Promise<string | undefined> {
2620
- return readFile(path, 'utf8').catch(() => undefined)
2621
- }
2622
-
2623
- /**
2624
- * Every listening socket's owning pid, read from `/proc`. Only a process this
2625
- * user owns exposes its `fd` table, which is also the only process whose
2626
- * server this user's worktree could have started.
2627
- */
2628
- async function readProcListeners(): Promise<Listener[] | undefined> {
2629
- const tables = await Promise.all(
2630
- ['/proc/net/tcp', '/proc/net/tcp6'].map(readText),
2631
- )
2632
- if (tables.every((text) => text === undefined)) return undefined
2633
- const byInode = new Map(
2634
- tables.flatMap((text) =>
2635
- text === undefined
2636
- ? []
2637
- : parseProcNetTcp(text).map((s) => [s.inode, s.port] as const),
2638
- ),
2639
- )
2640
-
2641
- const processIds = (await readdir('/proc').catch(() => [])).filter((name) =>
2642
- /^\d+$/.test(name),
2643
- )
2644
- const listeners: Listener[] = []
2645
- for (const pid of processIds) {
2646
- const fds = await readdir(`/proc/${pid}/fd`).catch(() => [])
2647
- for (const fd of fds) {
2648
- const target = await readlink(`/proc/${pid}/fd/${fd}`).catch(() => '')
2649
- const inode = /^socket:\[(\d+)\]$/.exec(target)?.[1]
2650
- const port = inode === undefined ? undefined : byInode.get(inode)
2651
- if (port !== undefined) listeners.push({ pid: Number(pid), port })
2652
- }
2653
- }
2654
- return listeners
2655
- }
2656
-
2657
- /**
2658
- * The machine half of a detection: `/proc` wherever it exists, lsof on a
2659
- * machine without it, and no reader anywhere else.
2660
- */
2731
+ /** The listener reads come from the machine, and the HTML probe from here. */
2661
2732
  function machineLocalRunner(): LocalRunner {
2662
- const hasLsof = Bun.which('lsof') !== null
2663
2733
  return {
2664
- async listListeners() {
2665
- // lsof 4.95 drops every process whose kernel-truncated name holds an
2666
- // unmatched `(`, which Next's `next-server (vX.Y.Z)` title cuts down to,
2667
- // so `/proc` goes first despite costing several times what lsof does.
2668
- if (existsSync('/proc/net/tcp')) {
2669
- const listeners = await readProcListeners()
2670
- if (listeners !== undefined) return listeners
2671
- }
2672
- if (hasLsof) {
2673
- const result = await $`lsof -nP -iTCP -sTCP:LISTEN -Fpn`
2674
- .quiet()
2675
- .nothrow()
2676
- // lsof exits 1 both when it matched nothing and when it could not
2677
- // read some process, and the rows it did print are real either way.
2678
- if (result.exitCode <= 1) return parseLsofListeners(result.text())
2679
- }
2680
- return undefined
2681
- },
2682
- async cwdOf(pid) {
2683
- if (existsSync(`/proc/${pid}/cwd`)) {
2684
- return readlink(`/proc/${pid}/cwd`).catch(() => undefined)
2685
- }
2686
- if (!hasLsof) return undefined
2687
- const result = await $`lsof -a -p ${pid} -d cwd -Fn`.quiet().nothrow()
2688
- const line = result
2689
- .text()
2690
- .split('\n')
2691
- .find((row) => row.startsWith('n'))
2692
- return line?.slice(1)
2693
- },
2734
+ ...machineListenerReader(),
2694
2735
  // An HTML page rather than any answer, since a test runner's server or a
2695
2736
  // reload socket in the same tree answers too, and a reviewer opening one
2696
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