@erclx/canon 5.50.0 → 5.52.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 (61) hide show
  1. package/CHANGELOG.md +4037 -0
  2. package/claude/.claude-plugin/plugin.json +1 -1
  3. package/claude/skills/auto-ship/references/fan-in.md +20 -6
  4. package/claude/skills/canon-cli/SKILL.md +9 -7
  5. package/claude/skills/ci-workflow/REQUIREMENT.md +3 -3
  6. package/claude/skills/ci-workflow/SKILL.md +26 -1
  7. package/claude/skills/ci-workflow/references/workflows.md +28 -0
  8. package/claude/skills/design-record/references/folder.md +1 -1
  9. package/claude/skills/design-taste/SKILL.md +2 -1
  10. package/claude/skills/git-followup/SKILL.md +1 -0
  11. package/claude/skills/git-pr/references/pr.md +2 -2
  12. package/claude/skills/plan-feature/references/batches.md +1 -1
  13. package/claude/skills/review-address/SKILL.md +1 -1
  14. package/claude/skills/review-address/references/post-review-findings.md +1 -1
  15. package/claude/skills/review-pr/references/post.md +2 -2
  16. package/claude/skills/target-check/SKILL.md +2 -0
  17. package/claude/skills/target-check/references/domains.md +8 -6
  18. package/claude/skills/target-setup/SKILL.md +1 -1
  19. package/claude/skills/target-update/REQUIREMENT.md +33 -0
  20. package/claude/skills/target-update/SKILL.md +136 -0
  21. package/docs/agents/index.md +1 -1
  22. package/docs/agents/migrate.md +3 -0
  23. package/docs/agents/targets.md +8 -1
  24. package/docs/target/migrations.md +3 -1
  25. package/docs/target/projects.md +1 -1
  26. package/docs/target/sync.md +1 -1
  27. package/docs/workflow/parallel-features.md +1 -1
  28. package/docs/workflow/skill-map.md +1 -0
  29. package/governance/detect.toml +1 -1
  30. package/governance/ledger/tooling/ci-workflow.md +6 -0
  31. package/governance/ledger/ui/design-reference.md +8 -13
  32. package/governance/rules/tooling/ci-workflow.md +4 -0
  33. package/governance/rules/ui/design-reference.md +3 -6
  34. package/package.json +2 -1
  35. package/scripts/ci/check-browsers.sh +13 -0
  36. package/scripts/ci/delete-previews.sh +34 -0
  37. package/scripts/ci/publish-check.sh +29 -0
  38. package/scripts/ci/refresh-frames-pr.sh +63 -0
  39. package/scripts/ci/report-baseline.sh +23 -0
  40. package/scripts/ci/stack-issues.sh +24 -0
  41. package/scripts/ci/verify-npm-token.sh +14 -0
  42. package/src/claude/cases/setup.ts +7 -0
  43. package/src/cli/help.ts +1 -0
  44. package/src/cli.ts +2 -0
  45. package/src/commands/changelog.ts +146 -0
  46. package/src/commands/hooks.ts +7 -5
  47. package/src/commands/labels.ts +9 -7
  48. package/src/commands/migrate.ts +77 -0
  49. package/src/commands/worktrees.ts +273 -0
  50. package/src/gov/detect.ts +28 -10
  51. package/src/hooks/post-merge.ts +45 -6
  52. package/src/migrate/status.ts +132 -0
  53. package/src/version/changelog.ts +156 -0
  54. package/src/worktrees/members.ts +540 -0
  55. package/tooling/base/configs/.husky/post-merge +7 -6
  56. package/tooling/claude/seeds/.claude/hooks/review-post-scan.sh +1 -1
  57. package/tooling/cloudflare/configs/.github/workflows/deploy.yml +10 -28
  58. package/tooling/cloudflare/configs/scripts/delete-previews.sh +36 -0
  59. package/tooling/cloudflare/reference.md +7 -4
  60. package/tooling/web/reference.md +5 -0
  61. package/tooling/web/seeds/canon/design/index.md +23 -0
@@ -1,6 +1,7 @@
1
1
  import { existsSync, readFileSync } from 'node:fs'
2
2
  import { join, resolve } from 'node:path'
3
3
  import type { Command } from 'commander'
4
+ import { cliRunner } from '@/gate/sequencer'
4
5
  import { listRepositoryFiles } from '@/git/files'
5
6
  import { indexSourceRules } from '@/gov/adapter'
6
7
  import { applyRecordsMove, applyRename, readSources } from '@/migrate/apply'
@@ -35,6 +36,7 @@ import {
35
36
  SKILL_NAME_MAP,
36
37
  SKILL_NAME_RULES,
37
38
  } from '@/migrate/skill-names'
39
+ import { readMigrationStatus, statusExitCode } from '@/migrate/status'
38
40
  import {
39
41
  planSurfaceRootsMove,
40
42
  type SurfaceRootsPlan,
@@ -1039,6 +1041,43 @@ function toSessionDefaultsRecord(
1039
1041
  }
1040
1042
  }
1041
1043
 
1044
+ interface StatusOptions {
1045
+ readonly json?: boolean
1046
+ readonly root?: string
1047
+ }
1048
+
1049
+ /**
1050
+ * Lists every migration a project still needs by running each subcommand's own
1051
+ * dry run, so it reads the contract they share and writes nothing.
1052
+ */
1053
+ async function runStatus(opts: StatusOptions): Promise<number> {
1054
+ const root = resolve(opts.root ?? process.cwd())
1055
+
1056
+ const mismatch = checkoutMismatchWarning(process.cwd())
1057
+ if (mismatch !== undefined) logWarn(mismatch)
1058
+
1059
+ const report = await readMigrationStatus(cliRunner(root))
1060
+
1061
+ if (opts.json) {
1062
+ process.stdout.write(
1063
+ `${JSON.stringify({ ok: report.refused === 0, ...report })}\n`,
1064
+ )
1065
+ }
1066
+
1067
+ for (const entry of report.migrations) {
1068
+ if (entry.state === 'pending') logInfo(`Pending: ${entry.verb}`)
1069
+ if (entry.state === 'refused') {
1070
+ logError(`Refused: ${entry.verb}: ${entry.reason ?? 'no reason given'}`)
1071
+ }
1072
+ }
1073
+
1074
+ if (report.pending === 0 && report.refused === 0) {
1075
+ logInfo('No migration is pending.')
1076
+ }
1077
+
1078
+ return statusExitCode(report)
1079
+ }
1080
+
1042
1081
  export function register(program: Command): void {
1043
1082
  const migrate = program
1044
1083
  .command('migrate')
@@ -1446,4 +1485,42 @@ export function register(program: Command): void {
1446
1485
  .action(async (opts: SessionDefaultsOptions) => {
1447
1486
  process.exitCode = await runSessionDefaults(opts)
1448
1487
  })
1488
+
1489
+ migrate
1490
+ .command('status')
1491
+ .description('List every migration a project still needs, in run order')
1492
+ .helpOption('-h, --help', 'Show this help message')
1493
+ .option('--json', 'Add a machine-readable record on stdout')
1494
+ .option(
1495
+ '--root <path>',
1496
+ 'Project root, defaulting to the working directory',
1497
+ )
1498
+ .addHelpText(
1499
+ 'after',
1500
+ [
1501
+ '',
1502
+ 'Runs each migrate subcommand as a dry run and sorts it by its exit',
1503
+ 'code, in the order a project runs them, records first. It passes',
1504
+ 'no --write and writes nothing. A migration is pending or not, with',
1505
+ "the subcommand's own record attached, since the records share no",
1506
+ 'count field.',
1507
+ '',
1508
+ "Each dry run reads today's tree, so a clean entry can turn pending",
1509
+ 'once an earlier one is applied. Run status again after each --write.',
1510
+ '',
1511
+ 'Exit codes:',
1512
+ ' 0 no migration is pending',
1513
+ ' 1 a subcommand refused, which outranks a pending plan',
1514
+ ' 2 at least one migration is pending',
1515
+ '',
1516
+ 'Examples:',
1517
+ ' canon migrate status',
1518
+ ' canon migrate status --json',
1519
+ ' canon migrate status --root ../project',
1520
+ '',
1521
+ ].join('\n'),
1522
+ )
1523
+ .action(async (opts: StatusOptions) => {
1524
+ process.exitCode = await runStatus(opts)
1525
+ })
1449
1526
  }
@@ -17,6 +17,14 @@ import {
17
17
  type WorktreeVerdict,
18
18
  } from '@/worktrees/reclaim'
19
19
  import { mainWorktreeRoot } from '@/git/worktree'
20
+ import {
21
+ type Half,
22
+ type MemberOutcome,
23
+ type MemberRefusal,
24
+ type MembersRefusal,
25
+ type MembersReport,
26
+ retireMembers,
27
+ } from '@/worktrees/members'
20
28
  import { machineListenerReader } from '@/listeners/machine'
21
29
  import { repairBareFlag } from '@/worktrees/bare-flag'
22
30
  import { type StopReport, stopListeners } from '@/worktrees/stop'
@@ -40,6 +48,31 @@ interface ReclaimCommandOptions {
40
48
  readonly json?: boolean
41
49
  }
42
50
 
51
+ interface RetireMembersCommandOptions {
52
+ readonly pullRequest: string
53
+ readonly dryRun?: boolean
54
+ readonly json?: boolean
55
+ }
56
+
57
+ /**
58
+ * What the post-merge hook reads after an integrator merges. Every field it
59
+ * branches on is a number or a fixed word, and `members` sits last, for the
60
+ * reason `ReclaimRecord` states.
61
+ */
62
+ export interface RetireMembersRecord {
63
+ readonly pullRequest: number
64
+ readonly reason: MembersRefusal | null
65
+ readonly detail: string | null
66
+ readonly dryRun: boolean
67
+ /** Members with a half deleted, or that a dry run would delete, and none kept or failed. */
68
+ readonly retired: number
69
+ /** Members with a half kept and none failed. */
70
+ readonly kept: number
71
+ /** Members with a half whose deletion failed. */
72
+ readonly failed: number
73
+ readonly members: readonly MemberOutcome[]
74
+ }
75
+
43
76
  /**
44
77
  * What a caller reads instead of the frame.
45
78
  *
@@ -145,6 +178,59 @@ const CONDITIONS = [
145
178
  'keeps a directory rather than removing one.',
146
179
  ]
147
180
 
181
+ const MEMBERS_REFUSALS: Record<MembersRefusal, string> = {
182
+ 'gh-missing': 'gh is not on the path, so the pull request could not be read',
183
+ 'gh-failed': 'the pull request read failed',
184
+ 'sessions-unreadable': 'the session roster could not be read',
185
+ 'sessions-unchecked':
186
+ 'some session records come from another pid namespace and no socket answers for them',
187
+ 'not-merged': 'the pull request has not merged',
188
+ 'no-members': 'the body carries no "Fan-in members:" line',
189
+ 'trunk-named': 'the line names the branch the pull request merged into',
190
+ 'head-named': "the line names the integrator's own branch",
191
+ 'open-pull-request': 'a named branch has an open pull request of its own',
192
+ 'head-unreadable':
193
+ 'refs/pull/<n>/head could not be fetched, or names another commit than the merged head',
194
+ }
195
+
196
+ const MEMBER_REFUSALS: Record<MemberRefusal, string> = {
197
+ 'not-in-merged-head':
198
+ "its tip is not an ancestor of the integrator's merged head, so it holds work the merge did not carry",
199
+ 'remote-unreadable': 'origin could not be read for this branch',
200
+ 'worktree-kept':
201
+ 'its worktree was kept, and git refuses to delete a branch a worktree holds',
202
+ 'main-worktree': 'it is checked out in the main worktree',
203
+ 'current-worktree': 'its worktree is the one this command runs in',
204
+ 'detached-head': 'its worktree has a detached HEAD',
205
+ 'uncommitted-changes': 'its worktree holds uncommitted work',
206
+ 'unreadable-worktree': 'its worktree could not be read',
207
+ 'held-by-session': 'a live session holds its worktree',
208
+ }
209
+
210
+ const RETIRE_CONDITIONS = [
211
+ 'Reads the "Fan-in members:" line of a merged integrator pull request,',
212
+ "whose backticked branches are the members, and deletes each one's",
213
+ 'origin branch, worktree, and local branch. Nothing outside that line',
214
+ 'is read.',
215
+ '',
216
+ "A member half is merged when its tip is an ancestor of the integrator's",
217
+ 'merged head, fetched from refs/pull/<n>/head, rather than of the trunk,',
218
+ 'which a squash merge never makes it. Origin and local are judged apart,',
219
+ 'so an unpushed commit keeps the local branch and a commit pushed after',
220
+ 'the merge keeps the origin branch. The worktree takes the same reading',
221
+ '`canon worktrees list` gives, with the members counted as merged.',
222
+ '',
223
+ 'These refuse the whole run, and nothing is deleted:',
224
+ ...Object.entries(MEMBERS_REFUSALS).map(
225
+ ([reason, meaning]) => ` ${reason.padEnd(22)}${meaning}`,
226
+ ),
227
+ '',
228
+ 'These keep one half of one member, named in its "reasons":',
229
+ ...Object.entries(MEMBER_REFUSALS).map(
230
+ ([reason, meaning]) => ` ${reason.padEnd(22)}${meaning}`,
231
+ ),
232
+ ]
233
+
148
234
  export function register(program: Command): void {
149
235
  const worktrees = program
150
236
  .command('worktrees')
@@ -240,6 +326,42 @@ export function register(program: Command): void {
240
326
  process.exitCode = await runReclaim(opts)
241
327
  })
242
328
 
329
+ worktrees
330
+ .command('retire-members')
331
+ .description(
332
+ 'Delete the fan-in members a merged integrator pull request names, unless --dry-run',
333
+ )
334
+ .helpOption('-h, --help', 'Show this help message')
335
+ .requiredOption(
336
+ '--pull-request <number>',
337
+ 'The merged integrator pull request',
338
+ )
339
+ .option('--dry-run', 'Report what would be deleted without deleting it')
340
+ .option('--json', 'Add a machine-readable record on stdout')
341
+ .addHelpText(
342
+ 'after',
343
+ [
344
+ '',
345
+ 'Exit codes:',
346
+ ' 0 every member was retired or kept, or the body names none',
347
+ ' 1 refused, or a deletion failed, with the reason on stderr',
348
+ '',
349
+ ...RETIRE_CONDITIONS,
350
+ '',
351
+ 'The post-merge hook runs this once per merged pull request number,',
352
+ 'and CANON_SKIP_RECLAIM turns it off there. Run it by hand for a',
353
+ 'merge the hook missed.',
354
+ '',
355
+ 'Examples:',
356
+ ' canon worktrees retire-members --pull-request 2570 --dry-run',
357
+ ' canon worktrees retire-members --pull-request 2570 --json',
358
+ '',
359
+ ].join('\n'),
360
+ )
361
+ .action(async (opts: RetireMembersCommandOptions) => {
362
+ process.exitCode = await runRetireMembers(opts)
363
+ })
364
+
243
365
  worktrees
244
366
  .command('repair-bare-flag', { hidden: true })
245
367
  .description('Clear a core.bare flag that worktree entry left set')
@@ -563,3 +685,154 @@ export function describe(verdict: WorktreeVerdict): string {
563
685
  ` Reclaimable. Pull request #${verdict.pullRequest} merged it, ${tree}, and no session holds it.`,
564
686
  ].join('\n')
565
687
  }
688
+
689
+ function halves(member: MemberOutcome): readonly Half[] {
690
+ return member.worktree === null
691
+ ? [member.origin, member.local]
692
+ : [member.origin, member.worktree, member.local]
693
+ }
694
+
695
+ /**
696
+ * Builds the record from the report, separate from the run so the counts are
697
+ * testable without a repository. A member already gone everywhere counts
698
+ * nowhere, so a run repeated by hand does not report a retirement it did not
699
+ * make.
700
+ */
701
+ export function retireMembersRecord(
702
+ pullRequest: number,
703
+ dryRun: boolean,
704
+ report: MembersReport,
705
+ ): RetireMembersRecord {
706
+ if (report.kind === 'refused') {
707
+ return {
708
+ pullRequest,
709
+ reason: report.reason,
710
+ detail: report.detail,
711
+ dryRun,
712
+ retired: 0,
713
+ kept: 0,
714
+ failed: 0,
715
+ members: [],
716
+ }
717
+ }
718
+
719
+ const states = report.members.map((member) =>
720
+ halves(member).map((half) => half.state),
721
+ )
722
+ const failed = states.filter((each) => each.includes('failed')).length
723
+ const kept = states.filter(
724
+ (each) => !each.includes('failed') && each.includes('kept'),
725
+ ).length
726
+ const retired = states.filter(
727
+ (each) =>
728
+ !each.includes('failed') &&
729
+ !each.includes('kept') &&
730
+ each.some((state) => state === 'deleted' || state === 'would-delete'),
731
+ ).length
732
+
733
+ return {
734
+ pullRequest,
735
+ reason: null,
736
+ detail: null,
737
+ dryRun,
738
+ retired,
739
+ kept,
740
+ failed,
741
+ members: report.members,
742
+ }
743
+ }
744
+
745
+ /**
746
+ * A kept half is a refusal doing its job and a body naming no members is the
747
+ * ordinary merge, so neither fails the run.
748
+ */
749
+ export function retireMembersExit(record: RetireMembersRecord): number {
750
+ if (record.reason !== null && record.reason !== 'no-members') return 1
751
+ return record.failed > 0 ? 1 : 0
752
+ }
753
+
754
+ function describeHalf(name: string, half: Half): string {
755
+ if (half.state === 'kept') {
756
+ const reasons = half.reasons.map((reason) => MEMBER_REFUSALS[reason])
757
+ return ` ${name}: kept, since ${reasons.join('; ')}.`
758
+ }
759
+ const state = half.state.replace('-', ' ')
760
+ return half.detail === null
761
+ ? ` ${name}: ${state}.`
762
+ : ` ${name}: ${state}. ${half.detail}`
763
+ }
764
+
765
+ async function runRetireMembers(
766
+ opts: RetireMembersCommandOptions,
767
+ ): Promise<number> {
768
+ const dryRun = opts.dryRun ?? false
769
+ const pullRequest = Number(opts.pullRequest)
770
+
771
+ intro(
772
+ dryRun
773
+ ? 'canon worktrees retire-members (dry-run)'
774
+ : 'canon worktrees retire-members',
775
+ )
776
+
777
+ if (!Number.isInteger(pullRequest) || pullRequest <= 0) {
778
+ logStep('Refused')
779
+ logWarn(
780
+ `--pull-request takes a pull request number, and "${opts.pullRequest}" is not one.`,
781
+ )
782
+ outro()
783
+ return 1
784
+ }
785
+
786
+ // The reading stays on the caller's directory, which is what refuses the
787
+ // worktree it stands in, and every write runs from the main root.
788
+ const report = await retireMembers(pullRequest, {
789
+ cwd: process.cwd(),
790
+ root: await mainWorktreeRoot(),
791
+ dryRun,
792
+ })
793
+ const record = retireMembersRecord(pullRequest, dryRun, report)
794
+
795
+ if (report.kind === 'refused') {
796
+ logStep(report.reason === 'no-members' ? 'Members' : 'Refused')
797
+ if (report.reason === 'no-members') logInfo(report.detail)
798
+ else {
799
+ logWarn(`Nothing was deleted: ${MEMBERS_REFUSALS[report.reason]}.`)
800
+ logInfo(report.detail)
801
+ }
802
+ } else {
803
+ logStep('Members')
804
+ pipeOutput(
805
+ report.members
806
+ .map((member) =>
807
+ [
808
+ member.branch,
809
+ describeHalf('origin', member.origin),
810
+ ...(member.worktree === null
811
+ ? []
812
+ : [
813
+ describeHalf(
814
+ `worktree ${member.worktree.path}`,
815
+ member.worktree,
816
+ ),
817
+ ]),
818
+ describeHalf('local', member.local),
819
+ ].join('\n'),
820
+ )
821
+ .join('\n'),
822
+ )
823
+ if (record.failed > 0) {
824
+ logWarn(
825
+ `${plural(record.failed, 'member')} could not be fully deleted. Read the reason above and clear it by hand.`,
826
+ )
827
+ }
828
+ if (dryRun) {
829
+ logWarn(
830
+ 'Nothing was deleted. Run this again without --dry-run to delete.',
831
+ )
832
+ }
833
+ }
834
+
835
+ outro()
836
+ if (opts.json === true) process.stdout.write(`${JSON.stringify(record)}\n`)
837
+ return retireMembersExit(record)
838
+ }
package/src/gov/detect.ts CHANGED
@@ -99,25 +99,35 @@ function walkFiles(target: string): string[] {
99
99
  return files.sort()
100
100
  }
101
101
 
102
+ interface Candidates {
103
+ readonly paths: readonly string[]
104
+ readonly folders: readonly string[]
105
+ }
106
+
102
107
  /**
103
108
  * The paths a file glob is matched against: every file to depth two, plus
104
109
  * every folder on the way to any listed file, so an evidence folder such as
105
- * `.github/workflows` matches without naming the file inside it.
110
+ * `.github/workflows` matches without naming the file inside it. `folders`
111
+ * holds the folders alone, which a pattern ending in `/` is matched against.
106
112
  */
107
- async function candidatePaths(target: string): Promise<string[]> {
113
+ async function candidatePaths(target: string): Promise<Candidates> {
108
114
  const listed = (await listRepositoryFiles(target)) ?? walkFiles(target)
109
115
  const paths = new Set<string>()
116
+ const folders = new Set<string>()
110
117
 
111
118
  for (const file of listed) {
112
119
  let dir = dirname(file)
113
120
  while (dir !== '.') {
114
- if (depth(dir) <= MAX_DIRS) paths.add(dir)
121
+ if (depth(dir) <= MAX_DIRS) {
122
+ paths.add(dir)
123
+ folders.add(dir)
124
+ }
115
125
  dir = dirname(dir)
116
126
  }
117
127
  if (depth(file) <= MAX_DIRS) paths.add(file)
118
128
  }
119
129
 
120
- return [...paths].sort()
130
+ return { paths: [...paths].sort(), folders: [...folders].sort() }
121
131
  }
122
132
 
123
133
  /** A PEP 508 requirement's distribution name, normalized per PEP 503. */
@@ -231,6 +241,11 @@ function readDependencies(
231
241
  return deps
232
242
  }
233
243
 
244
+ /** A pattern ending in `/` matches folders only, so a file at that depth misses. */
245
+ function isFolderPattern(pattern: string): boolean {
246
+ return pattern.endsWith('/')
247
+ }
248
+
234
249
  /** A glob carrying no wildcard names one path rather than a pattern. */
235
250
  function isLiteral(pattern: string): boolean {
236
251
  return !/[*?[\]{}]/.test(pattern)
@@ -245,7 +260,7 @@ function matchEntry(
245
260
  entry: DetectEntry,
246
261
  target: string,
247
262
  deps: ReadonlyMap<string, readonly string[]>,
248
- paths: readonly string[],
263
+ candidates: Candidates,
249
264
  ): Evidence[] {
250
265
  const evidence: Evidence[] = []
251
266
 
@@ -255,9 +270,12 @@ function matchEntry(
255
270
  }
256
271
 
257
272
  for (const pattern of entry.files) {
258
- const glob = new Bun.Glob(pattern)
273
+ const folderOnly = isFolderPattern(pattern)
274
+ const glob = new Bun.Glob(folderOnly ? pattern.slice(0, -1) : pattern)
259
275
  const hit =
260
- paths.find((path) => glob.match(path)) ??
276
+ (folderOnly ? candidates.folders : candidates.paths).find((path) =>
277
+ glob.match(path),
278
+ ) ??
261
279
  (isLiteral(pattern) && existsSync(join(target, pattern))
262
280
  ? pattern
263
281
  : undefined)
@@ -287,13 +305,13 @@ export async function detectRules(
287
305
  }
288
306
 
289
307
  const live = indexRuleSources(root)
290
- const paths = await candidatePaths(target)
291
- const deps = readDependencies(target, paths)
308
+ const candidates = await candidatePaths(target)
309
+ const deps = readDependencies(target, candidates.paths)
292
310
 
293
311
  const proposals = loadDetectEntries(root)
294
312
  .filter((entry) => live.has(entry.rule) && !covered.has(entry.rule))
295
313
  .flatMap((entry) => {
296
- const evidence = matchEntry(entry, target, deps, paths)
314
+ const evidence = matchEntry(entry, target, deps, candidates)
297
315
  return evidence.length === 0 ? [] : [{ rule: entry.rule, evidence }]
298
316
  })
299
317
 
@@ -39,6 +39,7 @@ export interface PostMergeInput {
39
39
  export const STEP_VERBS = {
40
40
  archive: ['tasks', 'archive'],
41
41
  push: ['records', 'push'],
42
+ members: ['worktrees', 'retire-members'],
42
43
  reclaim: ['worktrees', 'reclaim'],
43
44
  upgrade: ['upgrade'],
44
45
  pluginUpdate: ['claude', 'plugin-update'],
@@ -225,6 +226,33 @@ function reclaimLines(fields: Fields): string | undefined {
225
226
  return lines.length > 0 ? lines : undefined
226
227
  }
227
228
 
229
+ /**
230
+ * Keyed on the fields, like the reclaim, since a run that retires one member
231
+ * and fails on another exits 1 with a branch already deleted. A body naming no
232
+ * members is every merge but an integrator's, so it stays quiet.
233
+ */
234
+ function memberLines(number: number, fields: Fields): string | undefined {
235
+ let lines = ''
236
+ const retired = count(fields, 'retired')
237
+ if (retired > 0) {
238
+ lines += `\n🌿 Retired ${retired} fan-in member(s) of #${number}.\n\n`
239
+ }
240
+
241
+ const hint = `Run canon worktrees retire-members --pull-request ${number} --dry-run to read why.\n\n`
242
+ const reason = text(fields, 'reason')
243
+ const unfinished = count(fields, 'kept') + count(fields, 'failed')
244
+ if (
245
+ reason !== undefined &&
246
+ reason !== 'no-members' &&
247
+ reason !== 'gh-missing'
248
+ ) {
249
+ lines += `\n🌿 Fan-in members of #${number} not retired: ${reason}\n${hint}`
250
+ } else if (unfinished > 0) {
251
+ lines += `\n🌿 ${unfinished} fan-in member(s) of #${number} kept or not fully deleted.\n${hint}`
252
+ }
253
+ return lines.length > 0 ? lines : undefined
254
+ }
255
+
228
256
  /**
229
257
  * Shared by the binary reinstall and the plugin update. `current` is the
230
258
  * ordinary outcome on most merges and stays quiet, and `quiet` names the
@@ -271,8 +299,10 @@ async function step(
271
299
  }
272
300
 
273
301
  /**
274
- * Runs the five post-merge steps in order: archive, records push, reclaim,
275
- * upgrade, plugin update. The reclaim sits before the upgrade, since the
302
+ * Runs the six post-merge steps in order: archive, records push, member
303
+ * retirement, reclaim, upgrade, plugin update. The member retirement sits
304
+ * before the reclaim, so the reclaim reads a retired member's worktree as
305
+ * gone rather than racing it. Both sit before the upgrade, since the
276
306
  * upgrade reinstalls the binary every later child runs under, and the two
277
307
  * network-bound moves sit last so a slow registry delays nothing else.
278
308
  *
@@ -309,11 +339,20 @@ export async function runPostMerge(input: PostMergeInput): Promise<void> {
309
339
  pushLines,
310
340
  )
311
341
 
312
- // No `--root` here, and that is load-bearing. The verb reads its own working
313
- // directory to refuse the worktree it stands in, so a root would turn the
314
- // running worktree into an ordinary candidate and let a pull inside a
315
- // linked worktree delete the ground under itself.
342
+ // No `--root` on either worktree step, and that is load-bearing. Each verb
343
+ // reads its own working directory to refuse the worktree it stands in, so a
344
+ // root would turn the running worktree into an ordinary candidate and let a
345
+ // pull inside a linked worktree delete the ground under itself. One switch
346
+ // covers both, since both remove worktrees.
316
347
  if (!isSkipped('CANON_SKIP_RECLAIM')) {
348
+ for (const number of pullRequestNumbers(input.subjects)) {
349
+ await step(
350
+ input,
351
+ at([...STEP_VERBS.members, '--pull-request', String(number), '--json']),
352
+ (_, fields) => memberLines(number, fields),
353
+ )
354
+ }
355
+
317
356
  await step(input, at([...STEP_VERBS.reclaim, '--json']), (_, fields) =>
318
357
  reclaimLines(fields),
319
358
  )