@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.
Files changed (41) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/claude/.claude-plugin/plugin.json +1 -1
  3. package/claude/skills/ci-workflow/REQUIREMENT.md +8 -2
  4. package/claude/skills/ci-workflow/SKILL.md +3 -0
  5. package/claude/skills/design-identity/REQUIREMENT.md +1 -1
  6. package/claude/skills/design-identity/SKILL.md +13 -4
  7. package/claude/skills/design-identity/references/card-route.md +11 -4
  8. package/claude/skills/git-issue/REQUIREMENT.md +4 -2
  9. package/claude/skills/git-issue/SKILL.md +32 -3
  10. package/claude/skills/git-issue/references/issue.md +7 -0
  11. package/claude/skills/issue-triage/REQUIREMENT.md +35 -0
  12. package/claude/skills/issue-triage/SKILL.md +130 -0
  13. package/claude/skills/role-orchestrator/SKILL.md +1 -1
  14. package/claude/skills/role-orchestrator/references/orchestrator-handoff.md +1 -1
  15. package/claude/skills/role-orchestrator/references/orchestrator-poll.md +15 -15
  16. package/claude/skills/role-orchestrator/scripts/poll.ts +211 -96
  17. package/claude/skills/role-orchestrator/scripts/repo.ts +53 -1
  18. package/docs/agents/migrate.md +10 -10
  19. package/docs/agents/targets.md +3 -1
  20. package/docs/migrations/by-verb.md +8 -4
  21. package/docs/workflow/ai-workflow.md +1 -1
  22. package/docs/workflow/skill-map.md +1 -0
  23. package/package.json +2 -2
  24. package/src/capture/render.ts +30 -5
  25. package/src/claude/cases/git.ts +5 -0
  26. package/src/claude/cases/workflow.ts +4 -0
  27. package/src/commands/capture.ts +38 -1
  28. package/src/commands/claude.ts +4 -0
  29. package/src/commands/targets.ts +11 -1
  30. package/src/markdown/scope.ts +2 -0
  31. package/src/migrate/session-defaults.ts +36 -8
  32. package/src/targets/registry.ts +99 -14
  33. package/src/targets/resolve.ts +3 -1
  34. package/tooling/astro/configs/card.config.mjs +4 -0
  35. package/tooling/astro/configs/scripts/check-card-exclusion.sh +7 -2
  36. package/tooling/astro/reference.md +2 -1
  37. package/tooling/claude/seeds/.claude/settings.json +4 -0
  38. package/tooling/web/configs/.github/workflows/readme-screenshot.yml +3 -1
  39. package/tooling/web/configs/.github/workflows/verify.yml +5 -2
  40. package/tooling/web/configs/scripts/install-playwright-deps.sh +52 -0
  41. package/tooling/web/reference.md +5 -0
@@ -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: string,
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
  }
@@ -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
@@ -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: { out?: string; selector?: string; width?: string },
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
@@ -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')
@@ -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.`,
@@ -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 PreToolUse wiring where the
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
- if (missing.length > 0) {
100
- next.permissions = { ...permissions, deny: [...deny, ...missing] }
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
- if (permissions?.deny !== undefined && !Array.isArray(permissions.deny)) {
226
- return {
227
- kind: 'refused',
228
- reason: `${path}: permissions.deny is not a list`,
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
 
@@ -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
- * Reads every recorded target, sorted by path.
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
- /** Why a record attempt did not land, so a caller can say so rather than assume it did. */
159
- export type RecordOutcome = 'recorded' | 'unwritten'
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
- * Nothing removes a row either, so a target that was deleted or that dropped
173
- * the toolkit stays here and the count drifts upward. `canon targets pulls`
174
- * meets that on use, since it refuses a path it cannot open rather than
175
- * reading it as a target with no work, but `canon targets list` does not: it
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
- const current = readTargetRegistry(path)
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
  }
@@ -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
- if find "$DIST" -iname '*og-card*' | grep -q .; then
54
- echo "A file named for the card route reached $DIST. See card.config.mjs's srcDir." >&2
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
 
@@ -1,6 +1,10 @@
1
1
  {
2
2
  "autoMemoryEnabled": false,
3
3
  "permissions": {
4
+ "allow": [
5
+ "Bash(git push --force-with-lease)",
6
+ "Bash(git push --force-with-lease origin HEAD)"
7
+ ],
4
8
  "deny": ["Agent", "Task", "Bash(canon pr merge:*)", "Bash(gh pr merge:*)"]
5
9
  },
6
10
  "hooks": {
@@ -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
- run: bunx playwright install-deps chromium
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: bunx playwright install-deps chromium
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
- run: bunx playwright install-deps chromium
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