@erclx/canon 5.75.0 → 5.77.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/CHANGELOG.md +24 -0
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/ci-workflow/REQUIREMENT.md +8 -2
- package/claude/skills/ci-workflow/SKILL.md +3 -0
- package/claude/skills/design-identity/REQUIREMENT.md +1 -1
- package/claude/skills/design-identity/SKILL.md +13 -4
- package/claude/skills/design-identity/references/card-route.md +11 -4
- package/claude/skills/git-issue/REQUIREMENT.md +4 -2
- package/claude/skills/git-issue/SKILL.md +32 -3
- package/claude/skills/git-issue/references/issue.md +7 -0
- package/claude/skills/issue-triage/REQUIREMENT.md +35 -0
- package/claude/skills/issue-triage/SKILL.md +130 -0
- package/claude/skills/role-orchestrator/SKILL.md +1 -1
- package/claude/skills/role-orchestrator/references/orchestrator-handoff.md +1 -1
- package/claude/skills/role-orchestrator/references/orchestrator-poll.md +15 -15
- package/claude/skills/role-orchestrator/scripts/poll.ts +211 -96
- package/claude/skills/role-orchestrator/scripts/repo.ts +53 -1
- package/docs/agents/migrate.md +10 -10
- package/docs/agents/targets.md +3 -1
- package/docs/migrations/by-verb.md +8 -4
- package/docs/workflow/ai-workflow.md +1 -1
- package/docs/workflow/skill-map.md +1 -0
- package/package.json +2 -2
- package/src/capture/render.ts +30 -5
- package/src/claude/cases/git.ts +5 -0
- package/src/claude/cases/workflow.ts +4 -0
- package/src/commands/capture.ts +38 -1
- package/src/commands/claude.ts +4 -0
- package/src/commands/targets.ts +11 -1
- package/src/markdown/scope.ts +2 -0
- package/src/migrate/session-defaults.ts +36 -8
- package/src/targets/registry.ts +99 -14
- package/src/targets/resolve.ts +3 -1
- package/tooling/astro/configs/card.config.mjs +4 -0
- package/tooling/astro/configs/scripts/check-card-exclusion.sh +7 -2
- package/tooling/astro/reference.md +2 -1
- package/tooling/claude/seeds/.claude/settings.json +4 -0
- package/tooling/web/configs/.github/workflows/readme-screenshot.yml +3 -1
- package/tooling/web/configs/.github/workflows/verify.yml +5 -2
- package/tooling/web/configs/scripts/install-playwright-deps.sh +52 -0
- package/tooling/web/reference.md +5 -0
package/src/capture/render.ts
CHANGED
|
@@ -34,10 +34,13 @@ const FONT_PROBE_SIZE = 72
|
|
|
34
34
|
const FONT_PROBE_TEXT = 'canon capture 0123456789'
|
|
35
35
|
const ABSENT_FAMILY = '__canon_absent_family__'
|
|
36
36
|
|
|
37
|
+
export type ColorScheme = 'light' | 'dark'
|
|
38
|
+
|
|
37
39
|
export interface CaptureOptions {
|
|
38
40
|
selector: string
|
|
39
41
|
outDir?: string
|
|
40
42
|
width?: number
|
|
43
|
+
colorScheme?: ColorScheme
|
|
41
44
|
}
|
|
42
45
|
|
|
43
46
|
export type CaptureResult =
|
|
@@ -60,9 +63,7 @@ export async function captureSources(
|
|
|
60
63
|
const browser = await chromium.launch()
|
|
61
64
|
try {
|
|
62
65
|
return await Promise.all(
|
|
63
|
-
sources.map((source) =>
|
|
64
|
-
captureOne(browser, source, options.selector, options.width),
|
|
65
|
-
),
|
|
66
|
+
sources.map((source) => captureOne(browser, source, options)),
|
|
66
67
|
)
|
|
67
68
|
} finally {
|
|
68
69
|
await browser.close()
|
|
@@ -76,8 +77,7 @@ export async function captureSources(
|
|
|
76
77
|
async function captureOne(
|
|
77
78
|
browser: Browser,
|
|
78
79
|
source: CaptureSource,
|
|
79
|
-
selector:
|
|
80
|
-
width: number | undefined,
|
|
80
|
+
{ selector, width, colorScheme }: CaptureOptions,
|
|
81
81
|
): Promise<CaptureResult> {
|
|
82
82
|
/**
|
|
83
83
|
* A viewport rather than a wrapper, since a media query answers to the
|
|
@@ -90,11 +90,13 @@ async function captureOne(
|
|
|
90
90
|
...(width === undefined
|
|
91
91
|
? {}
|
|
92
92
|
: { viewport: { width, height: DEFAULT_VIEWPORT_HEIGHT } }),
|
|
93
|
+
...(colorScheme === undefined ? {} : { colorScheme }),
|
|
93
94
|
})
|
|
94
95
|
try {
|
|
95
96
|
await page.goto(
|
|
96
97
|
source.kind === 'url' ? source.url : pathToFileURL(source.htmlPath).href,
|
|
97
98
|
)
|
|
99
|
+
if (colorScheme !== undefined) await applyColorScheme(page, colorScheme)
|
|
98
100
|
const element = page.locator(selector).first()
|
|
99
101
|
if ((await element.count()) === 0) {
|
|
100
102
|
return failed(source, `no element matched ${selector}`)
|
|
@@ -177,6 +179,29 @@ function sourceIdentifier(source: CaptureSource): string {
|
|
|
177
179
|
return source.kind === 'url' ? source.url : source.htmlPath
|
|
178
180
|
}
|
|
179
181
|
|
|
182
|
+
/**
|
|
183
|
+
* Writes the theme the three switches a stylesheet reads, being the media
|
|
184
|
+
* query, a `data-theme` attribute, and a `light` or `dark` class on the root,
|
|
185
|
+
* which is the set Frameboard's theme toggle writes on every frame. The media
|
|
186
|
+
* query alone leaves a stylesheet keyed on the attribute in its default set,
|
|
187
|
+
* which is dark for one `canon design css` emits.
|
|
188
|
+
*
|
|
189
|
+
* Written after the load event, so a page script restoring a stored theme on
|
|
190
|
+
* load has already run and cannot overwrite it.
|
|
191
|
+
*/
|
|
192
|
+
async function applyColorScheme(
|
|
193
|
+
page: Page,
|
|
194
|
+
scheme: ColorScheme,
|
|
195
|
+
): Promise<void> {
|
|
196
|
+
await page.evaluate((theme) => {
|
|
197
|
+
const root = document.documentElement
|
|
198
|
+
root.setAttribute('data-theme', theme)
|
|
199
|
+
root.style.colorScheme = theme
|
|
200
|
+
root.classList.remove('light', 'dark')
|
|
201
|
+
root.classList.add(theme)
|
|
202
|
+
}, scheme)
|
|
203
|
+
}
|
|
204
|
+
|
|
180
205
|
function failed(source: CaptureSource, reason: string): CaptureResult {
|
|
181
206
|
return { status: 'failed', source: sourceIdentifier(source), reason }
|
|
182
207
|
}
|
package/src/claude/cases/git.ts
CHANGED
|
@@ -22,6 +22,11 @@ export const GIT_CASES: readonly SkillCase[] = [
|
|
|
22
22
|
prompt: 'Turn this bug into a GitHub issue and file it.',
|
|
23
23
|
expect: 'git-issue',
|
|
24
24
|
},
|
|
25
|
+
{
|
|
26
|
+
prompt:
|
|
27
|
+
'Log this bug, but add it to the open issue if one already covers it.',
|
|
28
|
+
expect: 'git-issue',
|
|
29
|
+
},
|
|
25
30
|
{
|
|
26
31
|
prompt: 'Close #42 without merging, #57 supersedes it.',
|
|
27
32
|
expect: 'git-pr-close',
|
|
@@ -96,6 +96,10 @@ export const WORKFLOW_CASES: readonly SkillCase[] = [
|
|
|
96
96
|
'Our backlog only ever grows, measure each row and suggest which to decline.',
|
|
97
97
|
expect: 'backlog-triage',
|
|
98
98
|
},
|
|
99
|
+
{
|
|
100
|
+
prompt: 'File the open issues on this repo into an intake folder.',
|
|
101
|
+
expect: 'issue-triage',
|
|
102
|
+
},
|
|
99
103
|
// Rot across documents nobody changed is what this reads, rather than a
|
|
100
104
|
// stated rule broken on a given line. It is also not `target-check`, which
|
|
101
105
|
// asks whether a document exists and holds its sections rather than whether
|
package/src/commands/capture.ts
CHANGED
|
@@ -31,6 +31,9 @@ const DEFAULT_SOURCE = 'assets'
|
|
|
31
31
|
*/
|
|
32
32
|
type Renderer = typeof import('@/capture/render')
|
|
33
33
|
type CaptureResult = Awaited<ReturnType<Renderer['captureSources']>>[number]
|
|
34
|
+
type ColorScheme = import('@/capture/render').ColorScheme
|
|
35
|
+
|
|
36
|
+
const COLOR_SCHEMES: readonly ColorScheme[] = ['light', 'dark']
|
|
34
37
|
|
|
35
38
|
/**
|
|
36
39
|
* Holds wiring only. Every browser reference sits behind a dynamic import,
|
|
@@ -58,6 +61,10 @@ export function register(program: Command): void {
|
|
|
58
61
|
'--width <px>',
|
|
59
62
|
'Viewport width in pixels, so a media query resolves at that width',
|
|
60
63
|
)
|
|
64
|
+
.option(
|
|
65
|
+
'--color-scheme <scheme>',
|
|
66
|
+
'Theme to render in, light or dark; defaults to whichever theme the page renders unset',
|
|
67
|
+
)
|
|
61
68
|
.addHelpText(
|
|
62
69
|
'after',
|
|
63
70
|
[
|
|
@@ -70,12 +77,21 @@ export function register(program: Command): void {
|
|
|
70
77
|
'font-family. --width takes one whole number and sets the viewport, so',
|
|
71
78
|
'run it once per breakpoint with its own --out.',
|
|
72
79
|
'',
|
|
80
|
+
'--color-scheme sets prefers-color-scheme, a data-theme attribute, and',
|
|
81
|
+
'a light or dark class on the root, so a stylesheet keyed on any of the',
|
|
82
|
+
'three renders that theme. Run it once per scheme with its own --out.',
|
|
83
|
+
'',
|
|
73
84
|
].join('\n'),
|
|
74
85
|
)
|
|
75
86
|
.action(
|
|
76
87
|
async (
|
|
77
88
|
source: string,
|
|
78
|
-
opts: {
|
|
89
|
+
opts: {
|
|
90
|
+
out?: string
|
|
91
|
+
selector?: string
|
|
92
|
+
width?: string
|
|
93
|
+
colorScheme?: string
|
|
94
|
+
},
|
|
79
95
|
): Promise<void> => {
|
|
80
96
|
/**
|
|
81
97
|
* Refused rather than defaulted, and refused ahead of every other
|
|
@@ -102,6 +118,15 @@ export function register(program: Command): void {
|
|
|
102
118
|
return
|
|
103
119
|
}
|
|
104
120
|
|
|
121
|
+
const colorScheme = parseColorScheme(opts.colorScheme)
|
|
122
|
+
if (colorScheme === null) {
|
|
123
|
+
frameError(
|
|
124
|
+
`--color-scheme takes ${COLOR_SCHEMES.join(' or ')}, got ${JSON.stringify(opts.colorScheme)}.`,
|
|
125
|
+
)
|
|
126
|
+
process.exitCode = 1
|
|
127
|
+
return
|
|
128
|
+
}
|
|
129
|
+
|
|
105
130
|
const isUrl = isUrlSource(source)
|
|
106
131
|
const sourcePath = isUrl ? source : resolve(process.cwd(), source)
|
|
107
132
|
if (!isUrl && !existsSync(sourcePath)) {
|
|
@@ -117,6 +142,7 @@ export function register(program: Command): void {
|
|
|
117
142
|
results = await renderer.captureSources(sourcePath, {
|
|
118
143
|
selector,
|
|
119
144
|
width,
|
|
145
|
+
colorScheme,
|
|
120
146
|
outDir: opts.out ? resolve(process.cwd(), opts.out) : undefined,
|
|
121
147
|
})
|
|
122
148
|
} catch (error) {
|
|
@@ -160,6 +186,17 @@ function parseWidth(raw: string | undefined): number | undefined {
|
|
|
160
186
|
return Number.isInteger(value) && value >= 1 ? value : Number.NaN
|
|
161
187
|
}
|
|
162
188
|
|
|
189
|
+
/**
|
|
190
|
+
* Undefined for an absent flag and `null` for a value naming no scheme, so the
|
|
191
|
+
* caller can tell no scheme from a bad one.
|
|
192
|
+
*/
|
|
193
|
+
function parseColorScheme(
|
|
194
|
+
raw: string | undefined,
|
|
195
|
+
): ColorScheme | undefined | null {
|
|
196
|
+
if (raw === undefined) return undefined
|
|
197
|
+
return COLOR_SCHEMES.find((scheme) => scheme === raw) ?? null
|
|
198
|
+
}
|
|
199
|
+
|
|
163
200
|
/**
|
|
164
201
|
* Closes an open frame around a failure that stopped the whole run rather than
|
|
165
202
|
* one source. Two of them are setup states rather than defects and each names
|
package/src/commands/claude.ts
CHANGED
|
@@ -713,6 +713,10 @@ async function runSync(target: string): Promise<number> {
|
|
|
713
713
|
logWarn(
|
|
714
714
|
'settings.json lacks the merge deny entries. Run `canon migrate session-defaults --write`',
|
|
715
715
|
)
|
|
716
|
+
} else if (defaults.kind === 'planned' && defaults.plan.lacksPushAllow) {
|
|
717
|
+
logWarn(
|
|
718
|
+
'settings.json lacks the lease push allow. Run `canon migrate session-defaults --write`',
|
|
719
|
+
)
|
|
716
720
|
}
|
|
717
721
|
|
|
718
722
|
logStep('Scanning .gitignore')
|
package/src/commands/targets.ts
CHANGED
|
@@ -267,6 +267,8 @@ function refuseDepth(opts: ListOptions): number {
|
|
|
267
267
|
interface BackfillSummary {
|
|
268
268
|
readonly recorded: number
|
|
269
269
|
readonly skipped: number
|
|
270
|
+
/** Skipped too, but for sitting under a scratch root, so its line names that cause. */
|
|
271
|
+
readonly scratch: number
|
|
270
272
|
readonly failed: number
|
|
271
273
|
}
|
|
272
274
|
|
|
@@ -285,16 +287,18 @@ async function runBackfill(
|
|
|
285
287
|
|
|
286
288
|
let recorded = 0
|
|
287
289
|
let skipped = 0
|
|
290
|
+
let scratch = 0
|
|
288
291
|
let failed = 0
|
|
289
292
|
|
|
290
293
|
for (const path of paths) {
|
|
291
294
|
const outcome = backfillTarget({ path, stampedAt: stampedAtOf(path) })
|
|
292
295
|
if (outcome === 'recorded') recorded++
|
|
293
296
|
else if (outcome === 'no-stamp') skipped++
|
|
297
|
+
else if (outcome === 'scratch') scratch++
|
|
294
298
|
else failed++
|
|
295
299
|
}
|
|
296
300
|
|
|
297
|
-
return { recorded, skipped, failed }
|
|
301
|
+
return { recorded, skipped, scratch, failed }
|
|
298
302
|
}
|
|
299
303
|
|
|
300
304
|
/**
|
|
@@ -340,6 +344,12 @@ function reportBackfill(summary: BackfillSummary): void {
|
|
|
340
344
|
)
|
|
341
345
|
}
|
|
342
346
|
|
|
347
|
+
if (summary.scratch > 0) {
|
|
348
|
+
logWarn(
|
|
349
|
+
`${plural(summary.scratch, 'target')} skipped, sitting under a scratch folder the index never records.`,
|
|
350
|
+
)
|
|
351
|
+
}
|
|
352
|
+
|
|
343
353
|
if (summary.failed > 0) {
|
|
344
354
|
logError(
|
|
345
355
|
`${plural(summary.failed, 'target')} failed to write, so its row is unchanged.`,
|
package/src/markdown/scope.ts
CHANGED
|
@@ -260,6 +260,8 @@ export const GATED_PATHS: readonly string[] = [
|
|
|
260
260
|
'claude/skills/git-worktree/REQUIREMENT.md',
|
|
261
261
|
'claude/skills/git-worktree/SKILL.md',
|
|
262
262
|
'claude/skills/git-worktree/references/cleanup.md',
|
|
263
|
+
'claude/skills/issue-triage/REQUIREMENT.md',
|
|
264
|
+
'claude/skills/issue-triage/SKILL.md',
|
|
263
265
|
'claude/skills/markdown-craft/REQUIREMENT.md',
|
|
264
266
|
'claude/skills/markdown-craft/SKILL.md',
|
|
265
267
|
'claude/skills/markdown-craft/references/markdown.md',
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Adds the session defaults the seeded `.claude/settings.json` carries to a
|
|
3
3
|
* target that was seeded before them: the subagent deny, the memory default,
|
|
4
|
-
* the merge deny entries, and the plan guard's
|
|
5
|
-
* project holds the guard script.
|
|
4
|
+
* the merge deny entries, the lease push allow entries, and the plan guard's
|
|
5
|
+
* PreToolUse wiring where the project holds the guard script.
|
|
6
6
|
*
|
|
7
7
|
* `canon claude init` copies a seed only when the file is absent, so a target
|
|
8
8
|
* holding its own settings never sees a later seed change. This verb adds each
|
|
@@ -29,6 +29,17 @@ export const MERGE_DENIED: readonly string[] = [
|
|
|
29
29
|
'Bash(gh pr merge:*)',
|
|
30
30
|
]
|
|
31
31
|
|
|
32
|
+
/**
|
|
33
|
+
* The two lease pushes a worker runs after rebasing onto a merged pull request,
|
|
34
|
+
* each pushing the current branch to its own name. An allow rule resolves
|
|
35
|
+
* before auto mode's classifier, so each entry is exact: a wildcard would also
|
|
36
|
+
* approve a refspec, a remote, or a flag that reaches the default branch.
|
|
37
|
+
*/
|
|
38
|
+
export const PUSH_ALLOWED: readonly string[] = [
|
|
39
|
+
'Bash(git push --force-with-lease)',
|
|
40
|
+
'Bash(git push --force-with-lease origin HEAD)',
|
|
41
|
+
]
|
|
42
|
+
|
|
32
43
|
/** The command the seed wires, as the seeded settings spell it. */
|
|
33
44
|
export const PLAN_GUARD_COMMAND =
|
|
34
45
|
'"$CLAUDE_PROJECT_DIR"/.claude/hooks/plan-guard.sh'
|
|
@@ -61,6 +72,8 @@ export interface SessionDefaultsPlan {
|
|
|
61
72
|
readonly predates: boolean
|
|
62
73
|
/** True when the deny list lacks any merge entry, whatever else it holds. */
|
|
63
74
|
readonly lacksMergeDeny: boolean
|
|
75
|
+
/** True when the allow list lacks any lease push entry. */
|
|
76
|
+
readonly lacksPushAllow: boolean
|
|
64
77
|
}
|
|
65
78
|
|
|
66
79
|
export type SessionDefaultsRead =
|
|
@@ -92,12 +105,20 @@ export function planSessionDefaults(
|
|
|
92
105
|
const next: SettingsRecord = { ...current }
|
|
93
106
|
const permissions = isRecord(current.permissions) ? current.permissions : {}
|
|
94
107
|
const deny = Array.isArray(permissions.deny) ? permissions.deny : []
|
|
108
|
+
const allow = Array.isArray(permissions.allow) ? permissions.allow : []
|
|
95
109
|
|
|
96
110
|
const subagentsMissing = DENIED_TOOLS.filter((tool) => !deny.includes(tool))
|
|
97
111
|
const mergeMissing = MERGE_DENIED.filter((entry) => !deny.includes(entry))
|
|
98
112
|
const missing = [...subagentsMissing, ...mergeMissing]
|
|
99
|
-
|
|
100
|
-
|
|
113
|
+
const allowMissing = PUSH_ALLOWED.filter((entry) => !allow.includes(entry))
|
|
114
|
+
|
|
115
|
+
const nextPermissions: SettingsRecord = { ...permissions }
|
|
116
|
+
if (missing.length > 0) nextPermissions.deny = [...deny, ...missing]
|
|
117
|
+
if (allowMissing.length > 0) {
|
|
118
|
+
nextPermissions.allow = [...allow, ...allowMissing]
|
|
119
|
+
}
|
|
120
|
+
if (missing.length + allowMissing.length > 0) {
|
|
121
|
+
next.permissions = nextPermissions
|
|
101
122
|
}
|
|
102
123
|
|
|
103
124
|
const memoryAbsent = !('autoMemoryEnabled' in current)
|
|
@@ -111,6 +132,7 @@ export function planSessionDefaults(
|
|
|
111
132
|
|
|
112
133
|
const keys: SettingsKey[] = [
|
|
113
134
|
{ label: 'permissions.deny', changed: missing.length > 0 },
|
|
135
|
+
{ label: 'permissions.allow', changed: allowMissing.length > 0 },
|
|
114
136
|
{ label: 'autoMemoryEnabled', changed: memoryAbsent },
|
|
115
137
|
{ label: 'hooks.PreToolUse', changed: preToolUse !== undefined },
|
|
116
138
|
]
|
|
@@ -123,6 +145,7 @@ export function planSessionDefaults(
|
|
|
123
145
|
subagentsMissing.length === DENIED_TOOLS.length &&
|
|
124
146
|
mergeMissing.length === MERGE_DENIED.length,
|
|
125
147
|
lacksMergeDeny: mergeMissing.length > 0,
|
|
148
|
+
lacksPushAllow: allowMissing.length > 0,
|
|
126
149
|
}
|
|
127
150
|
}
|
|
128
151
|
|
|
@@ -222,10 +245,15 @@ export async function readSessionDefaults(
|
|
|
222
245
|
if (permissions !== undefined && !isRecord(permissions)) {
|
|
223
246
|
return { kind: 'refused', reason: `${path}: permissions is not an object` }
|
|
224
247
|
}
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
248
|
+
for (const list of ['deny', 'allow'] as const) {
|
|
249
|
+
if (
|
|
250
|
+
permissions?.[list] !== undefined &&
|
|
251
|
+
!Array.isArray(permissions[list])
|
|
252
|
+
) {
|
|
253
|
+
return {
|
|
254
|
+
kind: 'refused',
|
|
255
|
+
reason: `${path}: permissions.${list} is not a list`,
|
|
256
|
+
}
|
|
229
257
|
}
|
|
230
258
|
}
|
|
231
259
|
|
package/src/targets/registry.ts
CHANGED
|
@@ -2,11 +2,12 @@ import {
|
|
|
2
2
|
existsSync,
|
|
3
3
|
mkdirSync,
|
|
4
4
|
readFileSync,
|
|
5
|
+
realpathSync,
|
|
5
6
|
renameSync,
|
|
6
7
|
writeFileSync,
|
|
7
8
|
} from 'node:fs'
|
|
8
|
-
import { homedir } from 'node:os'
|
|
9
|
-
import { dirname, join, resolve } from 'node:path'
|
|
9
|
+
import { homedir, tmpdir } from 'node:os'
|
|
10
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'
|
|
10
11
|
|
|
11
12
|
/**
|
|
12
13
|
* One project the toolkit has installed into, as the install recorded it.
|
|
@@ -116,15 +117,90 @@ function isRecord(value: Partial<TargetRecord>): value is TargetRecord {
|
|
|
116
117
|
}
|
|
117
118
|
|
|
118
119
|
/**
|
|
119
|
-
*
|
|
120
|
+
* The folders whose contents are never a project, resolved at call time so a
|
|
121
|
+
* test's `CANON_STATE_DIR` override moves the state root with it.
|
|
122
|
+
*
|
|
123
|
+
* Sandbox runs and replays live under the state folder, background jobs keep
|
|
124
|
+
* their scratch under `~/.claude/jobs/`, and unit tests stamp into the OS temp
|
|
125
|
+
* folder. Each one passes through a stamping sync, so without this list every
|
|
126
|
+
* run leaves a row behind for a folder that is gone a minute later.
|
|
127
|
+
*/
|
|
128
|
+
export function scratchRoots(): readonly string[] {
|
|
129
|
+
return [tmpdir(), stateDir(), join(homedir(), '.claude', 'jobs')]
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Whether a path sits under a scratch root or inside any `.canon/tmp/` folder.
|
|
134
|
+
*
|
|
135
|
+
* Both sides are compared in their given and their real form, since macOS
|
|
136
|
+
* hands out its temp folder as `/var/folders/...` while a path that went
|
|
137
|
+
* through a symlink resolution reads `/private/var/folders/...`. A
|
|
138
|
+
* `.claude/worktrees/` checkout is not scratch: it is a real checkout of a real
|
|
139
|
+
* project, and only a root on the list or a `.canon/tmp/` segment refuses it.
|
|
140
|
+
*/
|
|
141
|
+
export function isScratchPath(
|
|
142
|
+
target: string,
|
|
143
|
+
roots: readonly string[] = scratchRoots(),
|
|
144
|
+
): boolean {
|
|
145
|
+
const forms = bothForms(resolve(target))
|
|
146
|
+
const rootForms = roots.flatMap((root) => bothForms(resolve(root)))
|
|
147
|
+
|
|
148
|
+
return forms.some(
|
|
149
|
+
(form) =>
|
|
150
|
+
hasCanonTmpSegment(form) ||
|
|
151
|
+
rootForms.some((root) => isWithin(form, root)),
|
|
152
|
+
)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function bothForms(path: string): readonly string[] {
|
|
156
|
+
try {
|
|
157
|
+
const real = realpathSync(path)
|
|
158
|
+
return real === path ? [path] : [path, real]
|
|
159
|
+
} catch {
|
|
160
|
+
return [path]
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function hasCanonTmpSegment(path: string): boolean {
|
|
165
|
+
const segments = path.split(sep)
|
|
166
|
+
return segments.some(
|
|
167
|
+
(segment, index) => segment === '.canon' && segments[index + 1] === 'tmp',
|
|
168
|
+
)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function isWithin(path: string, root: string): boolean {
|
|
172
|
+
const rel = relative(root, path)
|
|
173
|
+
return (
|
|
174
|
+
rel === '' ||
|
|
175
|
+
(rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel))
|
|
176
|
+
)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Whether a row still names a project worth listing: a folder that exists and
|
|
181
|
+
* sits under no scratch root. The read filter and the write prune both call
|
|
182
|
+
* this, so the two can never disagree on a row.
|
|
183
|
+
*/
|
|
184
|
+
function isLive(row: TargetRecord, roots: readonly string[]): boolean {
|
|
185
|
+
return existsSync(row.path) && !isScratchPath(row.path, roots)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Reads every recorded target that is still live, sorted by path.
|
|
120
190
|
*
|
|
121
191
|
* A row missing either field is dropped rather than reported. This module is
|
|
122
192
|
* the file's only writer, so a malformed row is a hand edit or a truncated
|
|
123
193
|
* write and neither is a finding the caller can act on. What a caller can act
|
|
124
194
|
* on is the file being absent, which is its own kind above.
|
|
195
|
+
*
|
|
196
|
+
* A row whose folder is gone or under a scratch root is left out too, so the
|
|
197
|
+
* list is right on a machine whose file predates the prune, before any sync
|
|
198
|
+
* rewrites it. A target on a drive that is unmounted reads as gone here, and
|
|
199
|
+
* its next sync records it again.
|
|
125
200
|
*/
|
|
126
201
|
export function readTargetRegistry(
|
|
127
202
|
path: string = registryPath(),
|
|
203
|
+
roots: readonly string[] = scratchRoots(),
|
|
128
204
|
): TargetRegistry {
|
|
129
205
|
let text: string
|
|
130
206
|
try {
|
|
@@ -150,38 +226,46 @@ export function readTargetRegistry(
|
|
|
150
226
|
.filter((row): row is TargetRecord =>
|
|
151
227
|
isRecord(row as Partial<TargetRecord>),
|
|
152
228
|
)
|
|
229
|
+
.filter((row) => isLive(row, roots))
|
|
153
230
|
.sort((a, b) => a.path.localeCompare(b.path))
|
|
154
231
|
|
|
155
232
|
return { kind: 'read', path, targets }
|
|
156
233
|
}
|
|
157
234
|
|
|
158
|
-
/**
|
|
159
|
-
|
|
235
|
+
/**
|
|
236
|
+
* Why a record attempt did not land, so a caller can say so rather than assume
|
|
237
|
+
* it did. `scratch` is a deliberate refusal of a path under a scratch root,
|
|
238
|
+
* not a failure.
|
|
239
|
+
*/
|
|
240
|
+
export type RecordOutcome = 'recorded' | 'unwritten' | 'scratch'
|
|
160
241
|
|
|
161
242
|
/**
|
|
162
243
|
* Records one target, keyed by its resolved path and replacing any row already
|
|
163
|
-
* held for it.
|
|
244
|
+
* held for it, and refuses a path under a scratch root.
|
|
164
245
|
*
|
|
165
246
|
* The write is a temp file plus a rename, so a reader never meets a half
|
|
166
247
|
* written file. Two syncs finishing together still resolve last-writer-wins on
|
|
167
248
|
* the merged set, which can drop the row the loser added. That is left rather
|
|
168
249
|
* than locked: the authoritative record of an install is the stamp inside the
|
|
169
250
|
* target, this index is a cache over those, and the next sync of the dropped
|
|
170
|
-
* target restores its row.
|
|
251
|
+
* target restores its row. The prune reads the same merged set the write
|
|
252
|
+
* already reads, so it adds no second read and does not widen that window.
|
|
171
253
|
*
|
|
172
|
-
*
|
|
173
|
-
*
|
|
174
|
-
*
|
|
175
|
-
*
|
|
176
|
-
* never opens a recorded path, and the count is its whole output.
|
|
254
|
+
* Every write also drops the rows `readTargetRegistry` leaves out, being a
|
|
255
|
+
* folder that is gone or one under a scratch root, so the file shrinks back to
|
|
256
|
+
* the live projects rather than growing with every sandbox run. A target on a
|
|
257
|
+
* drive that is unmounted drops with them, and its next sync restores it.
|
|
177
258
|
*/
|
|
178
259
|
export function recordTarget(
|
|
179
260
|
target: string,
|
|
180
261
|
now: Date,
|
|
181
262
|
path: string = registryPath(),
|
|
263
|
+
roots: readonly string[] = scratchRoots(),
|
|
182
264
|
): RecordOutcome {
|
|
183
265
|
const resolved = resolve(target)
|
|
184
|
-
|
|
266
|
+
if (isScratchPath(resolved, roots)) return 'scratch'
|
|
267
|
+
|
|
268
|
+
const current = readTargetRegistry(path, roots)
|
|
185
269
|
const existing = current.kind === 'read' ? current.targets : []
|
|
186
270
|
|
|
187
271
|
const targets = [
|
|
@@ -220,7 +304,8 @@ export type BackfillOutcome = RecordOutcome | 'no-stamp'
|
|
|
220
304
|
export function backfillTarget(
|
|
221
305
|
target: BackfillTarget,
|
|
222
306
|
path: string = registryPath(),
|
|
307
|
+
roots: readonly string[] = scratchRoots(),
|
|
223
308
|
): BackfillOutcome {
|
|
224
309
|
if (target.stampedAt === null) return 'no-stamp'
|
|
225
|
-
return recordTarget(target.path, new Date(target.stampedAt), path)
|
|
310
|
+
return recordTarget(target.path, new Date(target.stampedAt), path, roots)
|
|
226
311
|
}
|
package/src/targets/resolve.ts
CHANGED
|
@@ -45,6 +45,8 @@ export interface ResolveTargetsOptions extends SweepOptions {
|
|
|
45
45
|
/** Roots to walk, supplementing the record rather than replacing it. */
|
|
46
46
|
readonly sweep?: readonly string[]
|
|
47
47
|
readonly registryFile?: string
|
|
48
|
+
/** Folders whose recorded rows are left out, defaulting to `scratchRoots()`. */
|
|
49
|
+
readonly scratchRoots?: readonly string[]
|
|
48
50
|
}
|
|
49
51
|
|
|
50
52
|
/**
|
|
@@ -71,7 +73,7 @@ export async function resolveTargets(
|
|
|
71
73
|
}
|
|
72
74
|
|
|
73
75
|
const file = opts.registryFile ?? registryPath()
|
|
74
|
-
const registry = readTargetRegistry(file)
|
|
76
|
+
const registry = readTargetRegistry(file, opts.scratchRoots)
|
|
75
77
|
|
|
76
78
|
const recorded: KnownTarget[] =
|
|
77
79
|
registry.kind === 'read'
|
|
@@ -14,7 +14,11 @@ const portOffset = Number(process.env.WORKTREE_PORT_OFFSET) || 0
|
|
|
14
14
|
// The base port sits outside the band `astro.config.mjs` serves from, so a card
|
|
15
15
|
// server and a dev server of one worktree never contend, and two worktrees
|
|
16
16
|
// separate here the way they separate there.
|
|
17
|
+
//
|
|
18
|
+
// The dev toolbar is off because this server exists to be captured, and the
|
|
19
|
+
// toolbar paints over the viewport the capture cuts the card from.
|
|
17
20
|
export default defineConfig({
|
|
21
|
+
devToolbar: { enabled: false },
|
|
18
22
|
srcDir: './card-src',
|
|
19
23
|
outDir: './card-dist',
|
|
20
24
|
publicDir: './public',
|
|
@@ -50,8 +50,13 @@ if grep -rl "$MARKER" "$DIST" >/dev/null 2>&1; then
|
|
|
50
50
|
exit 1
|
|
51
51
|
fi
|
|
52
52
|
|
|
53
|
-
|
|
54
|
-
|
|
53
|
+
# Built pages only, since an image or font may carry the route's name without
|
|
54
|
+
# being the route. The path test covers both formats Astro emits, being
|
|
55
|
+
# og-card/index.html under build.format 'directory' and og-card.html under
|
|
56
|
+
# 'file'. The search runs from inside dist so a project checked out under a
|
|
57
|
+
# directory like blog-card-site cannot match on its own prefix.
|
|
58
|
+
if (cd "$DIST" && find . -ipath '*og-card*' -name '*.html' | grep -q .); then
|
|
59
|
+
echo "A page named for the card route reached $DIST. See card.config.mjs's srcDir." >&2
|
|
55
60
|
exit 1
|
|
56
61
|
fi
|
|
57
62
|
|
|
@@ -64,7 +64,7 @@ The astro stack covers Astro + TypeScript projects, being content sites, marketi
|
|
|
64
64
|
## Social card route
|
|
65
65
|
|
|
66
66
|
- Keep the social card as a route the project owns, so a re-render after a rename is one string edit and a re-capture rather than another `design-identity` pick loop
|
|
67
|
-
- Expect `canon:design-identity` to write the picked composition into the route and capture it through the card server
|
|
67
|
+
- Expect `canon:design-identity` to write the picked composition into the route and capture it through the card server, which runs with Astro's dev toolbar off so it never paints over the card
|
|
68
68
|
- `card.config.mjs` is a second Astro config whose `srcDir` points at `./card-src`, which the main page router never reads, so the published build excludes the card structurally
|
|
69
69
|
- It serves on base port `4421` plus `WORKTREE_PORT_OFFSET`, outside the band `astro.config.mjs` serves from, so a worktree's card and dev servers never contend
|
|
70
70
|
- `bun run card:dev` serves the route and `bun run card:check` runs the exclusion check, which base's `scripts/verify.ts` also runs after its build stage
|
|
@@ -72,6 +72,7 @@ The astro stack covers Astro + TypeScript projects, being content sites, marketi
|
|
|
72
72
|
## Card exclusion and size
|
|
73
73
|
|
|
74
74
|
- `scripts/check-card-exclusion.sh` fails when the route's `<meta name="og-card-marker">` tag reaches `dist/`, and when the route dropped the tag the leak test reads
|
|
75
|
+
- The filename test reads built HTML pages only, under an `og-card` path in either build format, so an image or font may carry the route's name
|
|
75
76
|
- Keep the exclusion a check rather than a convention, since the card server and the capture read the route and the published build must not carry it
|
|
76
77
|
- Declare `.card` at 600x315, half the 1200x630 target, since `canon capture` shoots at a fixed 2x device scale and 1200x630 would capture at 2400x1260
|
|
77
78
|
|
|
@@ -74,10 +74,12 @@ jobs:
|
|
|
74
74
|
# Unconditional: the cache holds browser binaries and nothing apt
|
|
75
75
|
# wrote, so gating this on a cache hit leaves a warm run launching
|
|
76
76
|
# chromium without the libraries it needs.
|
|
77
|
-
|
|
77
|
+
timeout-minutes: 10
|
|
78
|
+
run: bash scripts/install-playwright-deps.sh chromium
|
|
78
79
|
|
|
79
80
|
- name: Install Playwright Browser
|
|
80
81
|
if: steps.playwright-cache.outputs.cache-hit != 'true'
|
|
82
|
+
timeout-minutes: 5
|
|
81
83
|
run: bunx playwright install chromium
|
|
82
84
|
|
|
83
85
|
- name: Capture README Screenshot
|
|
@@ -72,7 +72,7 @@ jobs:
|
|
|
72
72
|
- name: Install Playwright System Dependencies
|
|
73
73
|
if: steps.browser.outputs.enabled == 'true'
|
|
74
74
|
timeout-minutes: 10
|
|
75
|
-
run:
|
|
75
|
+
run: bash scripts/install-playwright-deps.sh chromium
|
|
76
76
|
|
|
77
77
|
- name: Cache Playwright Browsers
|
|
78
78
|
id: playwright-cache
|
|
@@ -84,6 +84,7 @@ jobs:
|
|
|
84
84
|
|
|
85
85
|
- name: Install Playwright Chromium
|
|
86
86
|
if: steps.browser.outputs.enabled == 'true' && steps.playwright-cache.outputs.cache-hit != 'true'
|
|
87
|
+
timeout-minutes: 5
|
|
87
88
|
run: bunx playwright install chromium
|
|
88
89
|
|
|
89
90
|
- name: Run Tests
|
|
@@ -151,7 +152,8 @@ jobs:
|
|
|
151
152
|
path: dist/
|
|
152
153
|
|
|
153
154
|
- name: Install Playwright System Dependencies
|
|
154
|
-
|
|
155
|
+
timeout-minutes: 10
|
|
156
|
+
run: bash scripts/install-playwright-deps.sh chromium
|
|
155
157
|
|
|
156
158
|
- name: Cache Playwright Browsers
|
|
157
159
|
id: playwright-cache
|
|
@@ -162,6 +164,7 @@ jobs:
|
|
|
162
164
|
|
|
163
165
|
- name: Install Playwright Chromium
|
|
164
166
|
if: steps.playwright-cache.outputs.cache-hit != 'true'
|
|
167
|
+
timeout-minutes: 5
|
|
165
168
|
run: bunx playwright install chromium
|
|
166
169
|
|
|
167
170
|
- name: Run E2E Tests
|