@erclx/canon 4.118.0 → 4.120.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. package/README.md +2 -2
  2. package/claude/.claude-plugin/plugin.json +1 -1
  3. package/claude/skills/canon-rollout/SKILL.md +2 -2
  4. package/claude/skills/create-skill/SKILL.md +2 -1
  5. package/claude/skills/design-extract/SKILL.md +1 -1
  6. package/claude/skills/docs-fold/SKILL.md +2 -2
  7. package/claude/skills/draft-ready/REQUIREMENT.md +1 -1
  8. package/claude/skills/draft-ready/SKILL.md +1 -1
  9. package/claude/skills/draft-screencast/SKILL.md +1 -1
  10. package/claude/skills/git-pr/REQUIREMENT.md +1 -0
  11. package/claude/skills/git-pr/SKILL.md +39 -7
  12. package/claude/skills/memory-capture/SKILL.md +3 -3
  13. package/claude/skills/memory-review/SKILL.md +3 -3
  14. package/claude/skills/plan-feature/SKILL.md +1 -1
  15. package/claude/skills/review-branch/SKILL.md +1 -1
  16. package/claude/skills/review-pr/SKILL.md +13 -242
  17. package/claude/skills/review-pr/references/close-out.md +30 -0
  18. package/claude/skills/review-pr/references/post.md +145 -0
  19. package/claude/skills/review-pr/references/scope.md +82 -0
  20. package/claude/skills/role-orchestrator/REQUIREMENT.md +9 -16
  21. package/claude/skills/role-orchestrator/SKILL.md +9 -153
  22. package/claude/skills/role-orchestrator/references/orchestrator-dispatch.md +4 -193
  23. package/claude/skills/role-orchestrator/references/orchestrator-handback.md +22 -0
  24. package/claude/skills/role-orchestrator/references/orchestrator-launch.md +85 -0
  25. package/claude/skills/role-orchestrator/references/orchestrator-output.md +61 -0
  26. package/claude/skills/role-orchestrator/references/orchestrator-parked.md +2 -2
  27. package/claude/skills/role-orchestrator/references/orchestrator-poll.md +1 -1
  28. package/claude/skills/role-orchestrator/references/orchestrator-refill.md +56 -0
  29. package/claude/skills/role-orchestrator/references/orchestrator-review-fallback.md +18 -0
  30. package/claude/skills/role-orchestrator/references/orchestrator-sweep.md +1 -1
  31. package/claude/skills/role-planner/REQUIREMENT.md +1 -1
  32. package/claude/skills/role-planner/SKILL.md +1 -1
  33. package/claude/skills/role-worker/REQUIREMENT.md +1 -1
  34. package/claude/skills/role-worker/SKILL.md +10 -41
  35. package/claude/skills/seed-sync/SKILL.md +1 -1
  36. package/claude/skills/session-compact/SKILL.md +1 -1
  37. package/claude/skills/session-map/SKILL.md +1 -1
  38. package/claude/skills/session-relay/REQUIREMENT.md +7 -1
  39. package/claude/skills/session-relay/SKILL.md +4 -7
  40. package/claude/skills/session-worktree/REQUIREMENT.md +5 -1
  41. package/claude/skills/session-worktree/SKILL.md +7 -1
  42. package/claude/skills/session-worktree/references/main-root-writes.md +24 -0
  43. package/claude/skills/task-board/SKILL.md +4 -3
  44. package/claude/skills/teach-workspace/SKILL.md +4 -4
  45. package/claude/skills/ui-checklist/SKILL.md +1 -1
  46. package/claude/skills/ux-audit/SKILL.md +1 -1
  47. package/claude/skills/ux-measure/SKILL.md +1 -1
  48. package/claude/skills/ux-walkthrough/references/relay.md +1 -1
  49. package/docs/agents/commands.md +4 -3
  50. package/docs/agents/counts.md +1 -1
  51. package/docs/agents/index.md +8 -3
  52. package/docs/agents/install-and-sync.md +11 -6
  53. package/docs/agents/pr-evidence.md +19 -3
  54. package/docs/agents/pr-local.md +102 -0
  55. package/docs/agents/pr-preview.md +2 -2
  56. package/docs/agents/records-push.md +61 -0
  57. package/docs/agents/records-validate.md +73 -0
  58. package/docs/agents/records.md +3 -119
  59. package/docs/agents/sessions-transfer.md +91 -0
  60. package/docs/agents/sessions.md +3 -82
  61. package/docs/agents/skills-audit.md +2 -2
  62. package/docs/agents/tasks.md +4 -4
  63. package/docs/agents/teach-authoring.md +112 -0
  64. package/docs/agents/teach.md +3 -103
  65. package/docs/target-projects.md +2 -2
  66. package/docs/workflow/ai-workflow.md +10 -10
  67. package/docs/workflow/visual-design-workflow.md +2 -2
  68. package/governance/renames.toml +13 -0
  69. package/governance/rules/claude/570-skill.md +2 -1
  70. package/governance/rules/core/085-worktrees.md +2 -8
  71. package/package.json +1 -1
  72. package/scripts/core/check-ignore-parity.sh +1 -1
  73. package/scripts/lib/sandbox-git.sh +1 -1
  74. package/src/claude/cases/workflow.ts +10 -0
  75. package/src/commands/pr.ts +324 -2
  76. package/src/commands/sync.ts +11 -3
  77. package/src/commands/tasks.ts +9 -8
  78. package/src/context/classify/extract.ts +2 -2
  79. package/src/counts/scan.ts +1 -1
  80. package/src/design/base.css +19 -19
  81. package/src/design/board.ts +18 -7
  82. package/src/design/neutral.ts +127 -0
  83. package/src/design/regen.ts +6 -4
  84. package/src/design/tokens.ts +6 -3
  85. package/src/gate/stages.ts +6 -6
  86. package/src/gov/adapter.ts +51 -1
  87. package/src/gov/install.ts +13 -3
  88. package/src/gov/renames.ts +76 -0
  89. package/src/gov/stacks.ts +5 -1
  90. package/src/pr/evidence.ts +42 -2
  91. package/src/pr/local.ts +125 -0
  92. package/src/sync/check.ts +19 -4
  93. package/src/sync/engine.ts +67 -3
  94. package/src/tasks/archive.ts +28 -7
  95. package/src/tasks/record.ts +16 -9
  96. package/src/tasks/validate.ts +3 -1
  97. package/standards/index.md +1 -0
  98. package/standards/skill-requirement.md +72 -0
  99. package/standards/skill.md +34 -152
  100. package/standards/tasks.md +5 -3
  101. package/tooling/astro/reference.md +1 -1
  102. package/tooling/base/configs/.github/workflows/pr-local-preview.yml +32 -0
  103. package/governance/rules/core/091-channel.md +0 -13
@@ -0,0 +1,127 @@
1
+ import type { ColorToken, DesignTokens } from '@/design/tokens'
2
+ import { TOKENS } from '@/design/tokens'
3
+
4
+ /**
5
+ * The token set `canon design install` hands a target, achromatic so the only
6
+ * palette a target renders is one it wrote under `.claude/design/project/`.
7
+ * Type, spacing, and radii are this repository's by spread, and only the color
8
+ * list is a second copy, held to the same roles by `neutral.test.ts`.
9
+ *
10
+ * Grounds, surface, chrome, and border take the OKLCH lightness of the toolkit
11
+ * role they replace at chroma 0. Text roles are solved against `surface` for
12
+ * the contrast targets `TOKENS` records (13.5, 8.6, 5.6, 4.6), and the accent
13
+ * for 15.5, past `text` so it still reads apart from body copy. The state
14
+ * roles keep the toolkit's values, since a state color is a meaning rather
15
+ * than a palette.
16
+ */
17
+
18
+ const DARK_GROUNDS = ['background', 'surface'] as const
19
+ const LIGHT_GROUNDS = ['light-background', 'light-surface'] as const
20
+
21
+ function toolkitRole(role: string): ColorToken {
22
+ const token = TOKENS.color.find((candidate) => candidate.role === role)
23
+ if (token === undefined) throw new Error(`TOKENS declares no ${role} role`)
24
+ return token
25
+ }
26
+
27
+ const NEUTRAL_COLOR: readonly ColorToken[] = [
28
+ { role: 'background', intent: 'page canvas', value: '#0e0e0e' },
29
+ {
30
+ role: 'surface',
31
+ intent: 'cards, panels, raised blocks',
32
+ value: '#141414',
33
+ },
34
+ {
35
+ role: 'chrome',
36
+ intent: 'the window titlebar, one step above the canvas',
37
+ value: '#1a1a1a',
38
+ },
39
+ { role: 'border', intent: 'every rule and panel edge', value: '#292929' },
40
+ {
41
+ role: 'text',
42
+ intent: 'headings, counts, emphasized runs',
43
+ value: '#dddddd',
44
+ grounds: DARK_GROUNDS,
45
+ },
46
+ {
47
+ role: 'text-body',
48
+ intent: 'default body copy',
49
+ value: '#b2b2b2',
50
+ grounds: DARK_GROUNDS,
51
+ },
52
+ {
53
+ role: 'text-secondary',
54
+ intent: 'labels, captions, supporting copy',
55
+ value: '#8e8e8e',
56
+ grounds: DARK_GROUNDS,
57
+ },
58
+ {
59
+ role: 'muted',
60
+ intent: 'the faintest step, trailing notes',
61
+ value: '#7f7f7f',
62
+ grounds: DARK_GROUNDS,
63
+ },
64
+ {
65
+ role: 'accent',
66
+ intent: 'install command, mark, primary action',
67
+ value: '#ececec',
68
+ grounds: DARK_GROUNDS,
69
+ },
70
+ toolkitRole('success'),
71
+ toolkitRole('warning'),
72
+ toolkitRole('error'),
73
+ {
74
+ role: 'light-background',
75
+ intent: 'page canvas on a light ground',
76
+ value: '#fafafa',
77
+ },
78
+ {
79
+ role: 'light-surface',
80
+ intent: 'cards and panels on a light ground',
81
+ value: '#f1f1f1',
82
+ },
83
+ {
84
+ role: 'light-chrome',
85
+ intent: 'the window titlebar, one step above the canvas',
86
+ value: '#e8e8e8',
87
+ },
88
+ {
89
+ role: 'light-text',
90
+ intent: 'primary text on a light ground',
91
+ value: '#252525',
92
+ grounds: LIGHT_GROUNDS,
93
+ },
94
+ {
95
+ role: 'light-text-body',
96
+ intent: 'default body copy on a light ground',
97
+ value: '#444444',
98
+ grounds: LIGHT_GROUNDS,
99
+ },
100
+ {
101
+ role: 'light-text-secondary',
102
+ intent: 'labels, captions, supporting copy on a light ground',
103
+ value: '#5f5f5f',
104
+ grounds: LIGHT_GROUNDS,
105
+ },
106
+ {
107
+ role: 'light-muted',
108
+ intent: 'secondary text on a light ground',
109
+ value: '#6c6c6c',
110
+ grounds: LIGHT_GROUNDS,
111
+ },
112
+ {
113
+ role: 'light-accent',
114
+ intent: 'links and primary action on light',
115
+ value: '#191919',
116
+ grounds: LIGHT_GROUNDS,
117
+ },
118
+ toolkitRole('light-success'),
119
+ {
120
+ role: 'light-border',
121
+ intent: 'rules and panel edges on light',
122
+ value: '#d4d4d4',
123
+ verify: true,
124
+ },
125
+ ]
126
+
127
+ export const NEUTRAL_TOKENS: DesignTokens = { ...TOKENS, color: NEUTRAL_COLOR }
@@ -2,10 +2,12 @@ import { mkdirSync, writeFileSync } from 'node:fs'
2
2
  import { dirname, join } from 'node:path'
3
3
  import { buildDesignCss } from '@/design/css'
4
4
  import { renderDesignDocument } from '@/design/document'
5
+ import { NEUTRAL_TOKENS } from '@/design/neutral'
5
6
 
6
7
  /**
7
- * The two artifacts the token source produces, written by `canon design regen`
8
- * and asserted for drift by the `design` stage of `bun run check`.
8
+ * The two artifacts the token sources produce, written by `canon design regen`
9
+ * and asserted for drift by the `design` stage of `bun run check`. The
10
+ * document renders from `TOKENS` and the stylesheet from `NEUTRAL_TOKENS`.
9
11
  *
10
12
  * The document is what a person reads and the stylesheet is what a target
11
13
  * installs, and neither is edited by hand. That is the cost the operator took
@@ -26,7 +28,7 @@ export const DESIGN_DOCUMENT = join('canon', 'DESIGN.md')
26
28
  export const DESIGN_BASE_CSS = join('src', 'design', 'base.css')
27
29
 
28
30
  const BASE_BANNER = [
29
- 'Generated by `canon design regen` from src/design/tokens.ts. Do not edit.',
31
+ 'Generated by `canon design regen` from src/design/neutral.ts. Do not edit.',
30
32
  'This is the base half of a design install. A target overrides a value in',
31
33
  '.claude/design/project/, which the three-way merge leaves alone, so this',
32
34
  'file changes freely and nothing a project wrote lives in it.',
@@ -50,7 +52,7 @@ export function regenDesign(root: string): RegenResult {
50
52
  cssPath: write(
51
53
  root,
52
54
  DESIGN_BASE_CSS,
53
- buildDesignCss(undefined, { banner: BASE_BANNER }),
55
+ buildDesignCss(NEUTRAL_TOKENS, { banner: BASE_BANNER }),
54
56
  ),
55
57
  }
56
58
  }
@@ -381,13 +381,16 @@ export const TOKENS: DesignTokens = {
381
381
  iconography: [
382
382
  'No icon library is installed. `assets/brand/mark.svg` is the one authored icon, embedded inline in the hero topbar, and the surfaces otherwise draw literal glyph characters: `│ ├ ✓ ! ✗ + - ◆ ◇ ❯` for the terminal framing.',
383
383
  '',
384
- 'The same mark ships in five independently-maintained copies that carry different colors by design rather than by drift, each fitted to the chrome it renders on: the dark accent for a dark-chrome surface, the light accent for a light-chrome one, and the pair the web favicon takes from `src/design/favicon.ts`, which teach pages also take by rendering the live mark. Unifying them or repairing the one that looks drifted would break the fit each was chosen for. `canon/context/design.md` carries which file holds each copy.',
384
+ 'The same mark ships in five independently-maintained copies that carry different colors by design rather than by drift, each fitted to the chrome it renders on: the dark accent for a dark-chrome surface, the light accent for a light-chrome one, and the pair the web favicon takes from `src/design/favicon.ts`, which teach pages also take by rendering the live mark. Unifying them or repairing the one that looks drifted would break the fit each was chosen for. `canon/context/design/tokens.md` carries which file holds each copy.',
385
385
  ].join('\n'),
386
386
  }
387
387
 
388
388
  /** A role's value, or `undefined` where the record declares no such role. */
389
- export function colorValue(role: string): string | undefined {
390
- return TOKENS.color.find((token) => token.role === role)?.value
389
+ export function colorValue(
390
+ role: string,
391
+ tokens: DesignTokens = TOKENS,
392
+ ): string | undefined {
393
+ return tokens.color.find((token) => token.role === role)?.value
391
394
  }
392
395
 
393
396
  /**
@@ -83,7 +83,7 @@ export interface Stage {
83
83
 
84
84
  /**
85
85
  * Corpora a `src/` test asserts over from outside `src/`, censused in
86
- * `canon/context/development/verification.md`. This list and that census are
86
+ * `canon/context/development/tests.md`. This list and that census are
87
87
  * two copies of one set with nothing comparing them, so a corpus joining the
88
88
  * census joins this list in the same change. The first four are directory
89
89
  * prefixes because their tests walk the tree whole, which is what reaches a
@@ -209,10 +209,10 @@ export const STAGES: readonly Stage[] = [
209
209
  success: 'Hero clean',
210
210
  },
211
211
  {
212
- // `canon/DESIGN.md`, the base stylesheet, the web stylesheet, and the
213
- // tab icon are all written from `src/design/tokens.ts` and none is edited
214
- // by hand. Four artifacts from one source is the cost of the token move,
215
- // and a render step that has to run is only safe while something fails
212
+ // `canon/DESIGN.md`, the web stylesheet, and the tab icon are written
213
+ // from `src/design/tokens.ts`, the base stylesheet from
214
+ // `src/design/neutral.ts`, and none is edited by hand. Four artifacts
215
+ // from two token sources is the cost of the token move, and a render step that has to run is only safe while something fails
216
216
  // when it did not, which is this.
217
217
  id: 'design',
218
218
  label: 'Design',
@@ -314,7 +314,7 @@ export const STAGES: readonly Stage[] = [
314
314
  {
315
315
  // A hook, a workflow, or a husky script reaching one side of the seed or
316
316
  // config boundary and not the other is a capability withheld with no
317
- // recorded reason, per the criterion in canon/context/tooling.md.
317
+ // recorded reason, per the criterion in canon/context/tooling/seeds.md.
318
318
  id: 'capability-seeding',
319
319
  label: 'Capability seeding',
320
320
  checks: [
@@ -1,6 +1,7 @@
1
1
  import { existsSync } from 'node:fs'
2
2
  import { basename, join, relative, resolve } from 'node:path'
3
- import { canonRulesDir } from '@/gov/install'
3
+ import { canonRulesDir, ruleSubdir } from '@/gov/install'
4
+ import { loadRenames, resolveSuccessor } from '@/gov/renames'
4
5
  import { resolveMissingRules } from '@/gov/stacks'
5
6
  import type { InstalledFile, RetiredSurface, SyncAdapter } from '@/sync/engine'
6
7
  import { readStamp, stampedChain } from '@/sync/stamp'
@@ -49,11 +50,14 @@ export function indexSourceRules(root: string): Map<string, string> {
49
50
  * `.claude/rules/canon/` leaves outside the walk, so everything the walk does
50
51
  * reach is the toolkit's. A rule there with no source is one the toolkit
51
52
  * retired or renamed, and the sync deletes it rather than leaving it loaded.
53
+ * A rename `governance/renames.toml` declares also installs the rule under its
54
+ * new name.
52
55
  * A `projectSubdir` would compute a stale `canon/project/` destination, which
53
56
  * is wrong rather than merely redundant.
54
57
  */
55
58
  export function createGovAdapter(root: string): SyncAdapter {
56
59
  const index = indexSourceRules(root)
60
+ const renames = loadRenames(root)
57
61
 
58
62
  return {
59
63
  banner: 'canon gov sync',
@@ -67,10 +71,56 @@ export function createGovAdapter(root: string): SyncAdapter {
67
71
  collectRetired: (target: string) => collectRetiredGov(target),
68
72
  collectMissing: (target: string) => collectMissingGov(root, target),
69
73
  ownsInstalledRoot: true,
74
+ locateSuccessor: (file: InstalledFile, target: string) => {
75
+ const successor = resolveSuccessor(
76
+ renames,
77
+ index,
78
+ basename(file.path, '.md'),
79
+ )
80
+ if (successor === undefined) return undefined
81
+
82
+ const source = index.get(successor)
83
+ if (source === undefined) return undefined
84
+
85
+ return {
86
+ source,
87
+ dest:
88
+ heldRulePath(target, successor) ??
89
+ join(
90
+ canonRulesDir(target),
91
+ ruleSubdir(source, rulesSourceDir(root)),
92
+ `${successor}.md`,
93
+ ),
94
+ }
95
+ },
70
96
  stamp: { domain: 'governance', toolkitRoot: root },
71
97
  }
72
98
  }
73
99
 
100
+ /**
101
+ * Where the target already holds a rule, in whichever band folder. A
102
+ * successor held outside the source's band stays where it is, the same way
103
+ * `locateSource` syncs a moved rule in place, rather than gaining a second copy.
104
+ *
105
+ * Holding the predecessor is the whole entitlement test, so no stamped chain
106
+ * is read here. That covers a rule `--add` layered on and a target installed
107
+ * before chains were stamped alike.
108
+ */
109
+ function heldRulePath(target: string, rule: string): string | undefined {
110
+ const dir = canonRulesDir(target)
111
+ if (!existsSync(dir)) return undefined
112
+
113
+ for (const rel of new Bun.Glob(`**/${rule}.md`).scanSync({
114
+ cwd: dir,
115
+ onlyFiles: true,
116
+ dot: true,
117
+ })) {
118
+ return join(dir, rel)
119
+ }
120
+
121
+ return undefined
122
+ }
123
+
74
124
  /**
75
125
  * Rules the target's recorded chain entitles it to and its tree does not
76
126
  * hold. Reports as `notice` text through the same shape `collectRetired`
@@ -38,8 +38,14 @@ export function installedInternalRulesDir(target: string): string {
38
38
  * Rule names a target already holds, read off the installed `canon/` tree by
39
39
  * basename rather than off a recorded stack, since a target may hold rules
40
40
  * `--add` layered on that no stack lists.
41
+ *
42
+ * `successorOf` adds the name a held rule was renamed to, since the next sync
43
+ * installs it and a reader treating it as missing would name it twice.
41
44
  */
42
- export function installedRuleNames(target: string): Set<string> {
45
+ export function installedRuleNames(
46
+ target: string,
47
+ successorOf?: (name: string) => string | undefined,
48
+ ): Set<string> {
43
49
  const dir = canonRulesDir(target)
44
50
  const names = new Set<string>()
45
51
  if (!existsSync(dir)) return names
@@ -49,7 +55,11 @@ export function installedRuleNames(target: string): Set<string> {
49
55
  onlyFiles: true,
50
56
  dot: true,
51
57
  })) {
52
- names.add(basename(rel, '.md'))
58
+ const name = basename(rel, '.md')
59
+ names.add(name)
60
+
61
+ const successor = successorOf?.(name)
62
+ if (successor !== undefined) names.add(successor)
53
63
  }
54
64
 
55
65
  return names
@@ -84,7 +94,7 @@ export function listRuleSourcePaths(root: string): string[] {
84
94
  * in two subdirectories resolves deterministically rather than by whichever
85
95
  * entry the filesystem yielded first.
86
96
  */
87
- function indexRuleSources(root: string): Map<string, string> {
97
+ export function indexRuleSources(root: string): Map<string, string> {
88
98
  const rulesRoot = rulesSourceDir(root)
89
99
  const byName = new Map<string, string>()
90
100
 
@@ -0,0 +1,76 @@
1
+ import { existsSync, readFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import { indexRuleSources } from '@/gov/install'
4
+
5
+ /** Old rule basename to the basename the same rule ships under now. */
6
+ export type Renames = ReadonlyMap<string, string>
7
+
8
+ function renamesPath(root: string): string {
9
+ return join(root, 'governance', 'renames.toml')
10
+ }
11
+
12
+ /**
13
+ * Reads the shipped rename ledger. A toolkit without one declares no renames,
14
+ * which leaves every sourceless rule on the retire path it took before the
15
+ * ledger existed.
16
+ */
17
+ export function loadRenames(root: string): Renames {
18
+ const path = renamesPath(root)
19
+ if (!existsSync(path)) return new Map()
20
+
21
+ const parsed = Bun.TOML.parse(readFileSync(path, 'utf8')) as Record<
22
+ string,
23
+ unknown
24
+ >
25
+ const table = parsed.renamed
26
+ if (typeof table !== 'object' || table === null) return new Map()
27
+
28
+ return new Map(
29
+ Object.entries(table).filter(
30
+ (entry): entry is [string, string] => typeof entry[1] === 'string',
31
+ ),
32
+ )
33
+ }
34
+
35
+ /**
36
+ * Follows a declared rename to the name the toolkit ships the rule under now,
37
+ * through every later rename of that name. The ledger is never pruned, so a
38
+ * target several releases behind still reaches the current name.
39
+ *
40
+ * Returns nothing when the chain ends on a name with no source, or loops,
41
+ * since installing a guess is worse than the retire the caller falls back to.
42
+ */
43
+ export function resolveSuccessor(
44
+ renames: Renames,
45
+ index: ReadonlyMap<string, string>,
46
+ name: string,
47
+ ): string | undefined {
48
+ const visited = new Set([name])
49
+ let current = renames.get(name)
50
+
51
+ while (current !== undefined) {
52
+ if (visited.has(current)) return undefined
53
+ visited.add(current)
54
+
55
+ const next = renames.get(current)
56
+ if (next === undefined) break
57
+ current = next
58
+ }
59
+
60
+ if (current === undefined || !index.has(current)) return undefined
61
+ return current
62
+ }
63
+
64
+ /**
65
+ * Binds the ledger and the source tree at `root` once, for a caller asking
66
+ * about many held names.
67
+ */
68
+ export function successorResolver(
69
+ root: string,
70
+ ): (name: string) => string | undefined {
71
+ const renames = loadRenames(root)
72
+ if (renames.size === 0) return () => undefined
73
+
74
+ const index = indexRuleSources(root)
75
+ return (name) => resolveSuccessor(renames, index, name)
76
+ }
package/src/gov/stacks.ts CHANGED
@@ -7,6 +7,7 @@ import {
7
7
  type RuleSource,
8
8
  rulesSourceDir,
9
9
  } from '@/gov/install'
10
+ import { successorResolver } from '@/gov/renames'
10
11
 
11
12
  export interface GovStack {
12
13
  readonly name: string
@@ -163,6 +164,9 @@ export function unreferencedRules(root: string): string[] {
163
164
  * so reading its leaf entry is enough; no second walk resolves the chain
164
165
  * itself. A stack the toolkit no longer ships resolves to nothing rather than
165
166
  * throwing, the same way `readNewRules`'s band fallback already treats it.
167
+ *
168
+ * A held rule's declared successor counts as held, since the sync that
169
+ * renames the old file installs it.
166
170
  */
167
171
  export function resolveMissingRules(
168
172
  root: string,
@@ -176,7 +180,7 @@ export function resolveMissingRules(
176
180
  if (!resolution.ok) return []
177
181
 
178
182
  const { found } = lookupRules(root, resolution.rules)
179
- const held = installedRuleNames(target)
183
+ const held = installedRuleNames(target, successorResolver(root))
180
184
 
181
185
  return found
182
186
  .filter((source) => !held.has(source.rule))
@@ -7,6 +7,9 @@ const MARKER_PREFIX = '<!-- pr-evidence:'
7
7
  /** How the first line of a body names the branch's preview deployment. */
8
8
  const PREVIEW_PREFIX = '**Preview:**'
9
9
 
10
+ /** How the opening block names the branch's server on the operator's own machine, under any hosted preview line. */
11
+ export const LOCAL_PREFIX = '**Local preview:**'
12
+
10
13
  /** The heading the checklist sits under, printed outside the delimiters so a carried checklist never doubles it. */
11
14
  const CHECKLIST_HEADING = '## What to look at'
12
15
 
@@ -128,6 +131,9 @@ export function evidenceMarker(head: string): string {
128
131
  *
129
132
  * A checklist closes the body, below the comparison it annotates, so one
130
133
  * comment carries the preview address, the screenshots, and what to look at.
134
+ *
135
+ * A local address sits under the hosted one, never above it, so the hosted
136
+ * line keeps the first-line position `findEvidencePreview` reads.
131
137
  */
132
138
  export function renderEvidenceBody(
133
139
  states: readonly EvidenceState[],
@@ -136,9 +142,13 @@ export function renderEvidenceBody(
136
142
  head: string,
137
143
  preview?: string,
138
144
  checklist?: string,
145
+ local?: string,
139
146
  ): string {
140
- const opening =
141
- preview === undefined ? [] : [`${PREVIEW_PREFIX} ${preview}`, '']
147
+ const addresses = [
148
+ ...(preview === undefined ? [] : [`${PREVIEW_PREFIX} ${preview}`]),
149
+ ...(local === undefined ? [] : [`${LOCAL_PREFIX} ${local}`]),
150
+ ]
151
+ const opening = addresses.length === 0 ? [] : [...addresses, '']
142
152
  const closing =
143
153
  checklist === undefined
144
154
  ? []
@@ -223,6 +233,36 @@ export function findEvidencePreview(
223
233
  return first.slice(PREVIEW_PREFIX.length).trim() || undefined
224
234
  }
225
235
 
236
+ /**
237
+ * Where the local address line sits in a body, searched only through the
238
+ * opening block above the first blank line, so a line quoted further down in
239
+ * a checklist is never taken for it. -1 when the block carries none.
240
+ */
241
+ export function findLocalLineIndex(body: string): number {
242
+ const lines = body.split('\n')
243
+ for (let index = 0; index < lines.length; index += 1) {
244
+ const line = (lines[index] ?? '').trim()
245
+ if (line === '') return -1
246
+ if (line.startsWith(LOCAL_PREFIX)) return index
247
+ }
248
+ return -1
249
+ }
250
+
251
+ /**
252
+ * The local address the marked comment already carries, carried forward on a
253
+ * re-render the way the hosted preview is.
254
+ */
255
+ export function findEvidenceLocal(
256
+ comments: readonly EvidenceComment[],
257
+ ): string | undefined {
258
+ const marked = comments.find((comment) => hasEvidenceMarker(comment.body))
259
+ if (marked === undefined) return undefined
260
+ const index = findLocalLineIndex(marked.body)
261
+ if (index === -1) return undefined
262
+ const line = marked.body.split('\n')[index]?.trim() ?? ''
263
+ return line.slice(LOCAL_PREFIX.length).trim() || undefined
264
+ }
265
+
226
266
  /**
227
267
  * The checklist the marked comment already carries, read back out of its
228
268
  * delimiters. The handoff file is deleted once posted, so every later render
@@ -0,0 +1,125 @@
1
+ import { findLocalLineIndex } from '@/pr/evidence'
2
+
3
+ /**
4
+ * The folder linked worktrees sit under. A listener there belongs to another
5
+ * branch even though its cwd sits inside the main checkout's toplevel.
6
+ */
7
+ const LINKED_WORKTREES = '.claude/worktrees'
8
+
9
+ /** The state `/proc/net/tcp` writes for a socket in LISTEN. */
10
+ const PROC_LISTEN_STATE = '0A'
11
+
12
+ export type LocalRefusal = 'no-server' | 'no-listener-reader'
13
+
14
+ export interface Listener {
15
+ readonly port: number
16
+ readonly pid: number
17
+ }
18
+
19
+ /** The machine reads a detection makes, injected so it can be driven without binding a socket. */
20
+ export interface LocalRunner {
21
+ /** Every TCP socket in LISTEN with its owning pid, or undefined when this machine offers no way to read them. */
22
+ listListeners(): Promise<readonly Listener[] | undefined>
23
+ cwdOf(pid: number): Promise<string | undefined>
24
+ /** Whether the port serves an HTML page at its root inside the runner's timeout. */
25
+ probe(port: number): Promise<boolean>
26
+ }
27
+
28
+ export type LocalResult =
29
+ | { readonly kind: 'found'; readonly port: number; readonly url: string }
30
+ | { readonly kind: 'refused'; readonly reason: LocalRefusal }
31
+
32
+ function isInside(path: string, root: string): boolean {
33
+ return path === root || path.startsWith(`${root}/`)
34
+ }
35
+
36
+ /**
37
+ * Whether a process running in `cwd` serves this worktree. A tool that
38
+ * changes into a subfolder before listening still counts, while a sibling
39
+ * worktree nested under the main checkout's `.claude/worktrees/` does not.
40
+ */
41
+ function belongsTo(cwd: string, worktreeRoot: string): boolean {
42
+ return (
43
+ isInside(cwd, worktreeRoot) &&
44
+ !isInside(cwd, `${worktreeRoot}/${LINKED_WORKTREES}`)
45
+ )
46
+ }
47
+
48
+ /**
49
+ * Finds the server this worktree is running: the lowest port whose listening
50
+ * process runs inside the worktree and answers an HTTP probe. A port rather
51
+ * than a process is the unit, since one dev server often holds a second
52
+ * socket for its reload channel, and the lower one is the page by convention.
53
+ */
54
+ export async function findLocalServer(
55
+ worktreeRoot: string,
56
+ runner: LocalRunner,
57
+ ): Promise<LocalResult> {
58
+ const listeners = await runner.listListeners()
59
+ if (listeners === undefined) {
60
+ return { kind: 'refused', reason: 'no-listener-reader' }
61
+ }
62
+
63
+ const cwdByPid = new Map<number, string | undefined>()
64
+ const ports = new Set<number>()
65
+ for (const { pid, port } of listeners) {
66
+ if (!cwdByPid.has(pid)) cwdByPid.set(pid, await runner.cwdOf(pid))
67
+ const cwd = cwdByPid.get(pid)
68
+ if (cwd !== undefined && belongsTo(cwd, worktreeRoot)) ports.add(port)
69
+ }
70
+
71
+ for (const port of [...ports].sort((a, b) => a - b)) {
72
+ if (await runner.probe(port)) {
73
+ return { kind: 'found', port, url: `http://localhost:${port}` }
74
+ }
75
+ }
76
+ return { kind: 'refused', reason: 'no-server' }
77
+ }
78
+
79
+ /** Reads `lsof -nP -iTCP -sTCP:LISTEN -Fpn` output, where a `p` line opens a process and each `n` line names one of its sockets. */
80
+ export function parseLsofListeners(text: string): Listener[] {
81
+ const listeners: Listener[] = []
82
+ let pid: number | undefined
83
+ for (const line of text.split('\n')) {
84
+ if (line.startsWith('p')) pid = Number(line.slice(1))
85
+ if (!line.startsWith('n') || pid === undefined) continue
86
+ const port = Number(line.slice(line.lastIndexOf(':') + 1))
87
+ if (Number.isInteger(port) && port > 0) listeners.push({ pid, port })
88
+ }
89
+ return listeners
90
+ }
91
+
92
+ export interface ProcSocket {
93
+ readonly port: number
94
+ readonly inode: string
95
+ }
96
+
97
+ /** Reads `/proc/net/tcp` or `tcp6`, keeping each listening socket's port and inode for matching against `/proc/<pid>/fd`. */
98
+ export function parseProcNetTcp(text: string): ProcSocket[] {
99
+ const sockets: ProcSocket[] = []
100
+ for (const line of text.split('\n').slice(1)) {
101
+ const fields = line.trim().split(/\s+/)
102
+ const local = fields[1]
103
+ const state = fields[3]
104
+ const inode = fields[9]
105
+ if (local === undefined || inode === undefined) continue
106
+ if (state !== PROC_LISTEN_STATE) continue
107
+ const port = Number.parseInt(local.slice(local.lastIndexOf(':') + 1), 16)
108
+ if (Number.isInteger(port) && port > 0) sockets.push({ port, inode })
109
+ }
110
+ return sockets
111
+ }
112
+
113
+ /**
114
+ * Replaces the local address line of a marked body with `note`, leaving
115
+ * every other byte in place so the trailing marker, the checklist delimiters,
116
+ * and any ticked box survive. Undefined when the body carries no such line,
117
+ * which is also what a second strip of the same body returns.
118
+ */
119
+ export function stripLocalLine(body: string, note: string): string | undefined {
120
+ const index = findLocalLineIndex(body)
121
+ if (index === -1) return undefined
122
+ const lines = body.split('\n')
123
+ lines[index] = note
124
+ return lines.join('\n')
125
+ }