@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.
- package/README.md +2 -2
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/canon-rollout/SKILL.md +2 -2
- package/claude/skills/create-skill/SKILL.md +2 -1
- package/claude/skills/design-extract/SKILL.md +1 -1
- package/claude/skills/docs-fold/SKILL.md +2 -2
- package/claude/skills/draft-ready/REQUIREMENT.md +1 -1
- package/claude/skills/draft-ready/SKILL.md +1 -1
- package/claude/skills/draft-screencast/SKILL.md +1 -1
- package/claude/skills/git-pr/REQUIREMENT.md +1 -0
- package/claude/skills/git-pr/SKILL.md +39 -7
- package/claude/skills/memory-capture/SKILL.md +3 -3
- package/claude/skills/memory-review/SKILL.md +3 -3
- package/claude/skills/plan-feature/SKILL.md +1 -1
- package/claude/skills/review-branch/SKILL.md +1 -1
- package/claude/skills/review-pr/SKILL.md +13 -242
- package/claude/skills/review-pr/references/close-out.md +30 -0
- package/claude/skills/review-pr/references/post.md +145 -0
- package/claude/skills/review-pr/references/scope.md +82 -0
- package/claude/skills/role-orchestrator/REQUIREMENT.md +9 -16
- package/claude/skills/role-orchestrator/SKILL.md +9 -153
- package/claude/skills/role-orchestrator/references/orchestrator-dispatch.md +4 -193
- package/claude/skills/role-orchestrator/references/orchestrator-handback.md +22 -0
- package/claude/skills/role-orchestrator/references/orchestrator-launch.md +85 -0
- package/claude/skills/role-orchestrator/references/orchestrator-output.md +61 -0
- package/claude/skills/role-orchestrator/references/orchestrator-parked.md +2 -2
- package/claude/skills/role-orchestrator/references/orchestrator-poll.md +1 -1
- package/claude/skills/role-orchestrator/references/orchestrator-refill.md +56 -0
- package/claude/skills/role-orchestrator/references/orchestrator-review-fallback.md +18 -0
- package/claude/skills/role-orchestrator/references/orchestrator-sweep.md +1 -1
- package/claude/skills/role-planner/REQUIREMENT.md +1 -1
- package/claude/skills/role-planner/SKILL.md +1 -1
- package/claude/skills/role-worker/REQUIREMENT.md +1 -1
- package/claude/skills/role-worker/SKILL.md +10 -41
- package/claude/skills/seed-sync/SKILL.md +1 -1
- package/claude/skills/session-compact/SKILL.md +1 -1
- package/claude/skills/session-map/SKILL.md +1 -1
- package/claude/skills/session-relay/REQUIREMENT.md +7 -1
- package/claude/skills/session-relay/SKILL.md +4 -7
- package/claude/skills/session-worktree/REQUIREMENT.md +5 -1
- package/claude/skills/session-worktree/SKILL.md +7 -1
- package/claude/skills/session-worktree/references/main-root-writes.md +24 -0
- package/claude/skills/task-board/SKILL.md +4 -3
- package/claude/skills/teach-workspace/SKILL.md +4 -4
- package/claude/skills/ui-checklist/SKILL.md +1 -1
- package/claude/skills/ux-audit/SKILL.md +1 -1
- package/claude/skills/ux-measure/SKILL.md +1 -1
- package/claude/skills/ux-walkthrough/references/relay.md +1 -1
- package/docs/agents/commands.md +4 -3
- package/docs/agents/counts.md +1 -1
- package/docs/agents/index.md +8 -3
- package/docs/agents/install-and-sync.md +11 -6
- package/docs/agents/pr-evidence.md +19 -3
- package/docs/agents/pr-local.md +102 -0
- package/docs/agents/pr-preview.md +2 -2
- package/docs/agents/records-push.md +61 -0
- package/docs/agents/records-validate.md +73 -0
- package/docs/agents/records.md +3 -119
- package/docs/agents/sessions-transfer.md +91 -0
- package/docs/agents/sessions.md +3 -82
- package/docs/agents/skills-audit.md +2 -2
- package/docs/agents/tasks.md +4 -4
- package/docs/agents/teach-authoring.md +112 -0
- package/docs/agents/teach.md +3 -103
- package/docs/target-projects.md +2 -2
- package/docs/workflow/ai-workflow.md +10 -10
- package/docs/workflow/visual-design-workflow.md +2 -2
- package/governance/renames.toml +13 -0
- package/governance/rules/claude/570-skill.md +2 -1
- package/governance/rules/core/085-worktrees.md +2 -8
- package/package.json +1 -1
- package/scripts/core/check-ignore-parity.sh +1 -1
- package/scripts/lib/sandbox-git.sh +1 -1
- package/src/claude/cases/workflow.ts +10 -0
- package/src/commands/pr.ts +324 -2
- package/src/commands/sync.ts +11 -3
- package/src/commands/tasks.ts +9 -8
- package/src/context/classify/extract.ts +2 -2
- package/src/counts/scan.ts +1 -1
- package/src/design/base.css +19 -19
- package/src/design/board.ts +18 -7
- package/src/design/neutral.ts +127 -0
- package/src/design/regen.ts +6 -4
- package/src/design/tokens.ts +6 -3
- package/src/gate/stages.ts +6 -6
- package/src/gov/adapter.ts +51 -1
- package/src/gov/install.ts +13 -3
- package/src/gov/renames.ts +76 -0
- package/src/gov/stacks.ts +5 -1
- package/src/pr/evidence.ts +42 -2
- package/src/pr/local.ts +125 -0
- package/src/sync/check.ts +19 -4
- package/src/sync/engine.ts +67 -3
- package/src/tasks/archive.ts +28 -7
- package/src/tasks/record.ts +16 -9
- package/src/tasks/validate.ts +3 -1
- package/standards/index.md +1 -0
- package/standards/skill-requirement.md +72 -0
- package/standards/skill.md +34 -152
- package/standards/tasks.md +5 -3
- package/tooling/astro/reference.md +1 -1
- package/tooling/base/configs/.github/workflows/pr-local-preview.yml +32 -0
- 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 }
|
package/src/design/regen.ts
CHANGED
|
@@ -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
|
|
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/
|
|
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(
|
|
55
|
+
buildDesignCss(NEUTRAL_TOKENS, { banner: BASE_BANNER }),
|
|
54
56
|
),
|
|
55
57
|
}
|
|
56
58
|
}
|
package/src/design/tokens.ts
CHANGED
|
@@ -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(
|
|
390
|
-
|
|
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
|
/**
|
package/src/gate/stages.ts
CHANGED
|
@@ -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/
|
|
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
|
|
213
|
-
//
|
|
214
|
-
//
|
|
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: [
|
package/src/gov/adapter.ts
CHANGED
|
@@ -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`
|
package/src/gov/install.ts
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
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))
|
package/src/pr/evidence.ts
CHANGED
|
@@ -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
|
|
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
|
package/src/pr/local.ts
ADDED
|
@@ -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
|
+
}
|