@erclx/canon 5.67.0 → 5.69.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 +31 -0
- package/README.md +1 -1
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/app-drive/REQUIREMENT.md +5 -5
- package/claude/skills/app-drive/SKILL.md +14 -4
- package/claude/skills/app-drive/references/drive-record.md +12 -1
- package/claude/skills/canon-cli/SKILL.md +2 -2
- package/claude/skills/canon-feedback/REQUIREMENT.md +1 -1
- package/claude/skills/canon-feedback/SKILL.md +1 -1
- package/claude/skills/design-identity/REQUIREMENT.md +4 -4
- package/claude/skills/design-identity/SKILL.md +7 -7
- package/claude/skills/{pick-by-eye → design-pick}/REQUIREMENT.md +6 -6
- package/claude/skills/{pick-by-eye → design-pick}/SKILL.md +18 -10
- package/claude/skills/{pick-by-eye → design-pick}/references/board.md +17 -11
- package/claude/skills/design-pick/references/design-config.md +33 -0
- package/claude/skills/design-record/REQUIREMENT.md +2 -2
- package/claude/skills/design-record/SKILL.md +1 -1
- package/claude/skills/design-taste/REQUIREMENT.md +2 -2
- package/claude/skills/design-taste/SKILL.md +3 -3
- package/claude/skills/design-taste/references/preflight.md +1 -1
- package/claude/skills/git-issue/REQUIREMENT.md +1 -1
- package/claude/skills/git-issue/references/issue.md +1 -1
- package/claude/skills/plan-feature/SKILL.md +1 -1
- package/claude/skills/plan-feature/references/plan.md +21 -6
- package/claude/skills/plan-groundwork/SKILL.md +1 -1
- package/claude/skills/pr-evidence/REQUIREMENT.md +1 -1
- package/claude/skills/pr-evidence/SKILL.md +7 -1
- package/claude/skills/pr-evidence/references/evidence.md +33 -3
- package/claude/skills/review-craft/references/ui.md +30 -10
- package/claude/skills/role-orchestrator/references/orchestrator-launch.md +3 -3
- package/claude/skills/role-orchestrator/references/orchestrator-parked.md +1 -1
- package/claude/skills/role-orchestrator/references/orchestrator-planning-wait.md +24 -0
- package/claude/skills/role-planner/SKILL.md +4 -2
- package/claude/skills/role-worker/SKILL.md +5 -6
- package/claude/skills/task-board/references/tasks.md +1 -1
- package/claude/skills/task-complete/references/receipt-sweep.md +1 -1
- package/claude/skills/{ux-audit → ui-audit}/REQUIREMENT.md +3 -3
- package/claude/skills/{ux-audit → ui-audit}/SKILL.md +7 -7
- package/claude/skills/ui-checklist/REQUIREMENT.md +1 -1
- package/claude/skills/ui-craft/SKILL.md +1 -1
- package/claude/skills/{ui-record → ui-film}/REQUIREMENT.md +7 -5
- package/claude/skills/{ui-record → ui-film}/SKILL.md +23 -12
- package/claude/skills/{ui-record → ui-film}/references/config.md +3 -3
- package/claude/skills/{ui-record → ui-film}/references/handoff.md +28 -9
- package/claude/skills/ui-film/references/pr-clip.md +84 -0
- package/claude/skills/{ui-record → ui-film}/references/take-craft.md +8 -1
- package/claude/skills/{ux-measure → ui-measure}/REQUIREMENT.md +3 -3
- package/claude/skills/{ux-measure → ui-measure}/SKILL.md +6 -6
- package/docs/agents/audits.md +1 -1
- package/docs/agents/migrate.md +13 -13
- package/docs/agents/records.md +1 -0
- package/docs/target/migrations.md +12 -10
- package/docs/target/projects.md +1 -1
- package/docs/target/sync.md +1 -1
- package/docs/workflow/skill-map-on-request.md +1 -2
- package/docs/workflow/skill-map.md +9 -10
- package/docs/workflow/visual-design-workflow.md +6 -1
- package/governance/ledger/claude/background.md +26 -0
- package/governance/rules/claude/background.md +19 -0
- package/governance/stacks/base.toml +1 -0
- package/package.json +8 -5
- package/scripts/ci/install-playwright-deps.sh +52 -0
- package/src/claude/cases/authoring.ts +2 -2
- package/src/claude/cases/misc.ts +1 -1
- package/src/claude/cases/setup.ts +0 -5
- package/src/claude/cases/workflow.ts +6 -6
- package/src/commands/fixtures/gh.sh +64 -0
- package/src/commands/gov.ts +33 -3
- package/src/commands/migrate.ts +12 -2
- package/src/commands/pr.ts +237 -79
- package/src/commands/take.ts +44 -8
- package/src/commands/tasks.ts +183 -0
- package/src/gate/stages.ts +2 -2
- package/src/gov/audit.ts +48 -11
- package/src/gov/detect.ts +1 -1
- package/src/markdown/scope.ts +9 -14
- package/src/migrate/record-layout.ts +5 -3
- package/src/migrate/skill-names.ts +21 -4
- package/src/pr/evidence.ts +208 -78
- package/src/pr/frames.ts +42 -4
- package/src/pr/pixels.ts +44 -0
- package/src/records/backup.ts +28 -4
- package/src/records/validate.ts +1 -0
- package/src/sandbox/headless/dispatch-shim.sh +15 -0
- package/src/sandbox/headless/dispatch.ts +12 -14
- package/src/sandbox/stub-gh.sh +26 -0
- package/src/sandbox/stub-remote.ts +8 -29
- package/src/sync/engine.ts +1 -1
- package/src/take/camera.ts +2 -2
- package/src/take/encode.ts +38 -1
- package/src/take/film.ts +26 -3
- package/src/take/pointer.ts +169 -0
- package/src/take/reel.ts +215 -0
- package/src/take/screencast.ts +5 -2
- package/src/take/stepper.ts +23 -6
- package/src/take/zoom/NOTICE.md +1 -1
- package/src/tasks/launch-wait.ts +160 -0
- package/tooling/claude/reference.md +1 -1
- package/claude/skills/canon-feedback-triage/REQUIREMENT.md +0 -34
- package/claude/skills/canon-feedback-triage/SKILL.md +0 -98
- package/claude/skills/plan-ready/REQUIREMENT.md +0 -35
- package/claude/skills/plan-ready/SKILL.md +0 -136
- package/claude/skills/plan-ready/references/assembly.md +0 -75
- package/claude/skills/plan-ready/references/ready.md +0 -128
- /package/claude/skills/{pick-by-eye → design-pick}/references/live-arms.md +0 -0
package/src/pr/evidence.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { isPainted, type PaintedPatterns } from '@/pr/painted'
|
|
2
|
+
|
|
1
3
|
/** The path segment that marks an image as evidence a pull request should compare. */
|
|
2
4
|
const EVIDENCE_SEGMENT = 'evidence'
|
|
3
5
|
|
|
@@ -16,9 +18,22 @@ const PREVIEW_PREFIX = '**Preview:**'
|
|
|
16
18
|
/** How the address line names the branch's server on the operator's own machine, after any hosted segment. */
|
|
17
19
|
const LOCAL_PREFIX = '**Local preview:**'
|
|
18
20
|
|
|
21
|
+
/** How the reel line under the addresses opens. */
|
|
22
|
+
const REEL_PREFIX = '**Reel** filmed at'
|
|
23
|
+
|
|
24
|
+
/** The reel line as `renderEvidenceBody` writes it, read back for the link and the head label. */
|
|
25
|
+
const REEL_LINE =
|
|
26
|
+
/^\*\*Reel\*\* filmed at `([0-9a-f]{7,40})`:<br><img src="([^"]+)"/
|
|
27
|
+
|
|
28
|
+
/** The width a reel is filmed at, which it asks for in the comment. */
|
|
29
|
+
const REEL_WIDTH = 960
|
|
30
|
+
|
|
19
31
|
/** What joins the hosted and local segments on the one address line. */
|
|
20
32
|
const ADDRESS_SEPARATOR = ' · '
|
|
21
33
|
|
|
34
|
+
/** The summary of the block holding every case whose pixels matched its base. */
|
|
35
|
+
const FOLDED_SUMMARY = 'Within tolerance'
|
|
36
|
+
|
|
22
37
|
/** The heading every body opens with. */
|
|
23
38
|
const EVIDENCE_HEADING = '## Evidence'
|
|
24
39
|
|
|
@@ -61,6 +76,8 @@ export interface EvidenceItem {
|
|
|
61
76
|
readonly width?: number
|
|
62
77
|
/** Pixel width of the base image, absent for an added case or an unread header. */
|
|
63
78
|
readonly baseWidth?: number
|
|
79
|
+
/** Whether the head differs from its base by no more than sub-visual noise. Absent when the pair was not compared. */
|
|
80
|
+
readonly withinTolerance?: boolean
|
|
64
81
|
}
|
|
65
82
|
|
|
66
83
|
export interface EvidenceState {
|
|
@@ -144,9 +161,9 @@ function rawUrl(repo: string, sha: string, path: string): string {
|
|
|
144
161
|
}
|
|
145
162
|
|
|
146
163
|
/**
|
|
147
|
-
* The most pixels a screenshot asks for. GitHub
|
|
148
|
-
*
|
|
149
|
-
*
|
|
164
|
+
* The most pixels a screenshot asks for. GitHub clamps an image to the comment
|
|
165
|
+
* column, so a width past any comment fills the column, where `width="100%"`
|
|
166
|
+
* would stretch a narrow screenshot to fill it too. An image
|
|
150
167
|
* asks for the smaller of its own width and this ceiling, so a small crop
|
|
151
168
|
* shows at its true size and is never scaled up. An image whose width was not
|
|
152
169
|
* read asks for the ceiling.
|
|
@@ -171,7 +188,7 @@ export function readPngWidth(bytes: Uint8Array): number | undefined {
|
|
|
171
188
|
return width > 0 ? width : undefined
|
|
172
189
|
}
|
|
173
190
|
|
|
174
|
-
/** A
|
|
191
|
+
/** A label and an attribute value share one escape, since a stem is a path segment placed in both. The pipe stays escaped, since a label once sat in a table cell. */
|
|
175
192
|
function escapeCell(text: string): string {
|
|
176
193
|
return text
|
|
177
194
|
.replaceAll('&', '&')
|
|
@@ -238,14 +255,21 @@ function commitLink(repo: string, sha: string): string {
|
|
|
238
255
|
return `[\`${sha.slice(0, SHORT_SHA_LENGTH)}\`](https://github.com/${repo}/commit/${sha})`
|
|
239
256
|
}
|
|
240
257
|
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
)
|
|
258
|
+
/** The base folded under its head, so a reviewer reads the new state first and opens the old one on a click. */
|
|
259
|
+
function baseFold(item: EvidenceItem, repo: string, base: string): string[] {
|
|
260
|
+
return [
|
|
261
|
+
'<details>',
|
|
262
|
+
'<summary>Base</summary>',
|
|
263
|
+
'',
|
|
264
|
+
evidenceImage(
|
|
265
|
+
rawUrl(repo, base, item.path),
|
|
266
|
+
`${escapeCell(item.stem)} base`,
|
|
267
|
+
item.baseWidth,
|
|
268
|
+
),
|
|
269
|
+
'',
|
|
270
|
+
'</details>',
|
|
271
|
+
'',
|
|
272
|
+
]
|
|
249
273
|
}
|
|
250
274
|
|
|
251
275
|
function headImage(item: EvidenceItem, repo: string, head: string): string {
|
|
@@ -256,25 +280,45 @@ function headImage(item: EvidenceItem, repo: string, head: string): string {
|
|
|
256
280
|
)
|
|
257
281
|
}
|
|
258
282
|
|
|
283
|
+
/**
|
|
284
|
+
* One case as a label line, its head image on a line of its own, and its base
|
|
285
|
+
* folded beneath. An added case has no base, so it shows its head alone, and
|
|
286
|
+
* its label carries the new mark only where the block also holds a base for
|
|
287
|
+
* the mark to set it apart from.
|
|
288
|
+
*/
|
|
289
|
+
function caseLines(
|
|
290
|
+
label: string,
|
|
291
|
+
item: EvidenceItem,
|
|
292
|
+
hasBase: boolean,
|
|
293
|
+
repo: string,
|
|
294
|
+
base: string,
|
|
295
|
+
head: string,
|
|
296
|
+
): string[] {
|
|
297
|
+
const mark = item.added && hasBase ? ' *(new)*' : ''
|
|
298
|
+
return [
|
|
299
|
+
`**${label}**${mark}`,
|
|
300
|
+
'',
|
|
301
|
+
headImage(item, repo, head),
|
|
302
|
+
'',
|
|
303
|
+
...(item.added ? [] : baseFold(item, repo, base)),
|
|
304
|
+
]
|
|
305
|
+
}
|
|
306
|
+
|
|
259
307
|
function block(
|
|
260
308
|
opener: string,
|
|
261
309
|
summary: string,
|
|
262
|
-
|
|
263
|
-
rows: readonly string[],
|
|
310
|
+
cases: readonly string[][],
|
|
264
311
|
): string {
|
|
265
312
|
return [
|
|
266
313
|
opener,
|
|
267
314
|
`<summary>${summary}</summary>`,
|
|
268
315
|
'',
|
|
269
|
-
|
|
270
|
-
`| ${header.map(() => '---').join(' | ')} |`,
|
|
271
|
-
...rows,
|
|
272
|
-
'',
|
|
316
|
+
...cases.flat(),
|
|
273
317
|
'</details>',
|
|
274
318
|
].join('\n')
|
|
275
319
|
}
|
|
276
320
|
|
|
277
|
-
/** One block for the cases of a state that did not name a view,
|
|
321
|
+
/** One block for the cases of a state that did not name a view, each labeled by its name. */
|
|
278
322
|
function renderFlat(
|
|
279
323
|
label: string,
|
|
280
324
|
items: readonly EvidenceItem[],
|
|
@@ -284,22 +328,13 @@ function renderFlat(
|
|
|
284
328
|
opener: string,
|
|
285
329
|
): string {
|
|
286
330
|
const hasBase = items.some((item) => !item.added)
|
|
287
|
-
const
|
|
288
|
-
|
|
289
|
-
const after = headImage(item, repo, head)
|
|
290
|
-
return hasBase
|
|
291
|
-
? `| ${stem} | ${baseImage(item, repo, base)} | ${after} |`
|
|
292
|
-
: `| ${stem} | ${after} |`
|
|
293
|
-
})
|
|
294
|
-
return block(
|
|
295
|
-
opener,
|
|
296
|
-
`${label} (${items.length})`,
|
|
297
|
-
hasBase ? ['Case', 'Base', 'Head'] : ['Case', 'Head'],
|
|
298
|
-
rows,
|
|
331
|
+
const cases = items.map((item) =>
|
|
332
|
+
caseLines(escapeCell(item.stem), item, hasBase, repo, base, head),
|
|
299
333
|
)
|
|
334
|
+
return block(opener, `${label} (${items.length})`, cases)
|
|
300
335
|
}
|
|
301
336
|
|
|
302
|
-
/** One block for a view,
|
|
337
|
+
/** One block for a view, its cases by width and then dark before light, each labeled by width and theme, so a theme missing at a width renders nothing. */
|
|
303
338
|
function renderView(
|
|
304
339
|
label: string,
|
|
305
340
|
cases: readonly ViewCase[],
|
|
@@ -308,38 +343,29 @@ function renderView(
|
|
|
308
343
|
head: string,
|
|
309
344
|
opener: string,
|
|
310
345
|
): string {
|
|
311
|
-
const themes = THEMES.filter((theme) =>
|
|
312
|
-
cases.some((entry) => entry.theme === theme),
|
|
313
|
-
)
|
|
314
346
|
const hasBase = cases.some((entry) => !entry.item.added)
|
|
315
|
-
const
|
|
316
|
-
|
|
347
|
+
const ordered = [...cases].sort(
|
|
348
|
+
(a, b) =>
|
|
349
|
+
compareWidths(a.width, b.width) ||
|
|
350
|
+
THEMES.indexOf(a.theme) - THEMES.indexOf(b.theme),
|
|
317
351
|
)
|
|
318
|
-
const
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
)
|
|
330
|
-
if (found === undefined) return hasBase ? ['', ''] : ['']
|
|
331
|
-
const after = headImage(found.item, repo, head)
|
|
332
|
-
return hasBase ? [baseImage(found.item, repo, base), after] : [after]
|
|
333
|
-
})
|
|
334
|
-
return `| ${[width, ...cells].join(' | ')} |`
|
|
335
|
-
})
|
|
336
|
-
return block(opener, `${label} (${cases.length})`, header, rows)
|
|
352
|
+
const lines = ordered.map((entry) =>
|
|
353
|
+
caseLines(
|
|
354
|
+
`${entry.width} · ${entry.theme}`,
|
|
355
|
+
entry.item,
|
|
356
|
+
hasBase,
|
|
357
|
+
repo,
|
|
358
|
+
base,
|
|
359
|
+
head,
|
|
360
|
+
),
|
|
361
|
+
)
|
|
362
|
+
return block(opener, `${label} (${cases.length})`, lines)
|
|
337
363
|
}
|
|
338
364
|
|
|
339
365
|
/**
|
|
340
366
|
* Renders the blocks of one state: a block per view the case names split
|
|
341
367
|
* into, then one flat block for the names that did not. Nothing is dropped,
|
|
342
|
-
* so a state of unparsed names reads as
|
|
368
|
+
* so a state of unparsed names reads as one flat block.
|
|
343
369
|
*/
|
|
344
370
|
function renderState(
|
|
345
371
|
entry: EvidenceState,
|
|
@@ -387,18 +413,57 @@ function renderState(
|
|
|
387
413
|
return blocks
|
|
388
414
|
}
|
|
389
415
|
|
|
416
|
+
/** A folded case's label, naming its state and view, since the block gathers cases from every state. */
|
|
417
|
+
function foldedLabel(state: string, item: EvidenceItem): string {
|
|
418
|
+
const name = readCaseName(item.stem)
|
|
419
|
+
const caseLabel =
|
|
420
|
+
name === undefined
|
|
421
|
+
? escapeCell(item.stem)
|
|
422
|
+
: `${escapeCell(name.view)} · ${name.width} · ${name.theme}`
|
|
423
|
+
return state === '' ? caseLabel : `${escapeCell(state)} / ${caseLabel}`
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/** One closed block for every case whose pixels matched its base, so the open blocks hold only what visibly moved. */
|
|
427
|
+
function renderFolded(
|
|
428
|
+
states: readonly EvidenceState[],
|
|
429
|
+
repo: string,
|
|
430
|
+
base: string,
|
|
431
|
+
head: string,
|
|
432
|
+
): string {
|
|
433
|
+
const cases = states.flatMap((entry) =>
|
|
434
|
+
entry.items.map((item) =>
|
|
435
|
+
caseLines(foldedLabel(entry.state, item), item, false, repo, base, head),
|
|
436
|
+
),
|
|
437
|
+
)
|
|
438
|
+
return block('<details>', `${FOLDED_SUMMARY} (${cases.length})`, cases)
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/** A reel embedded in the comment, and the head it was filmed at. */
|
|
442
|
+
export interface EvidenceReel {
|
|
443
|
+
readonly link: string
|
|
444
|
+
readonly head: string
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function reelLine(reel: EvidenceReel): string {
|
|
448
|
+
const head = reel.head.slice(0, SHORT_SHA_LENGTH)
|
|
449
|
+
return `${REEL_PREFIX} \`${head}\`:<br><img src="${escapeCell(reel.link)}" width="${REEL_WIDTH}" alt="reel filmed at ${head}">`
|
|
450
|
+
}
|
|
451
|
+
|
|
390
452
|
export function evidenceMarker(head: string): string {
|
|
391
453
|
return `${MARKER_PREFIX} head=${head} -->`
|
|
392
454
|
}
|
|
393
455
|
|
|
394
456
|
/**
|
|
395
457
|
* Renders the whole comment body: one `<details>` block per view within each
|
|
396
|
-
* state, a
|
|
397
|
-
* the
|
|
398
|
-
* the
|
|
458
|
+
* state, a flat block for the case names that do not split into a view, and
|
|
459
|
+
* the trailing marker naming the head this body describes. Each case shows its
|
|
460
|
+
* head at the comment's width with its base folded beneath, since a table
|
|
461
|
+
* split the column between the two and shrank both to a quarter of their size.
|
|
399
462
|
* Every state opens while the comment carries at most `EVIDENCE_OPEN_LIMIT`
|
|
400
|
-
* images,
|
|
401
|
-
*
|
|
463
|
+
* head images, and every state closes past it, so a comment is never half
|
|
464
|
+
* open. A folded base takes no room until opened, so it is not counted. A case
|
|
465
|
+
* whose pixels matched its base moves to one closed `Within tolerance` block
|
|
466
|
+
* after the states, still named and embedded, and is not counted either. A visible line under
|
|
402
467
|
* `## Evidence` names the base and head as short shas, each linking its commit. Every image URL is
|
|
403
468
|
* pinned to a commit sha rather than a branch, so the comment keeps showing
|
|
404
469
|
* what it claimed even after the branch moves.
|
|
@@ -412,6 +477,9 @@ export function evidenceMarker(head: string): string {
|
|
|
412
477
|
*
|
|
413
478
|
* A checklist closes the body, below the comparison it annotates, so one
|
|
414
479
|
* comment carries the addresses, the screenshots, and what to look at.
|
|
480
|
+
*
|
|
481
|
+
* A reel sits on its own line under the addresses, labeled with the head it
|
|
482
|
+
* was filmed at, so a reel carried past a later push never reads as current.
|
|
415
483
|
*/
|
|
416
484
|
export function renderEvidenceBody(
|
|
417
485
|
states: readonly EvidenceState[],
|
|
@@ -421,6 +489,7 @@ export function renderEvidenceBody(
|
|
|
421
489
|
preview?: string,
|
|
422
490
|
checklist?: string,
|
|
423
491
|
local?: string,
|
|
492
|
+
reel?: EvidenceReel,
|
|
424
493
|
): string {
|
|
425
494
|
const segments = [
|
|
426
495
|
...(preview === undefined ? [] : [`${PREVIEW_PREFIX} ${preview}`]),
|
|
@@ -430,6 +499,7 @@ export function renderEvidenceBody(
|
|
|
430
499
|
EVIDENCE_HEADING,
|
|
431
500
|
'',
|
|
432
501
|
...(segments.length === 0 ? [] : [segments.join(ADDRESS_SEPARATOR), '']),
|
|
502
|
+
...(reel === undefined ? [] : [reelLine(reel), '']),
|
|
433
503
|
]
|
|
434
504
|
const closing =
|
|
435
505
|
checklist === undefined
|
|
@@ -439,17 +509,21 @@ export function renderEvidenceBody(
|
|
|
439
509
|
return [...opening, ...closing, evidenceMarker(head)].join('\n')
|
|
440
510
|
}
|
|
441
511
|
|
|
442
|
-
const
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
)
|
|
512
|
+
const split = (keep: (item: EvidenceItem) => boolean): EvidenceState[] =>
|
|
513
|
+
states
|
|
514
|
+
.map((entry) => ({ ...entry, items: entry.items.filter(keep) }))
|
|
515
|
+
.filter((entry) => entry.items.length > 0)
|
|
516
|
+
const open = split((item) => item.withinTolerance !== true)
|
|
517
|
+
const folded = split((item) => item.withinTolerance === true)
|
|
518
|
+
|
|
519
|
+
const headCount = open.reduce((total, entry) => total + entry.items.length, 0)
|
|
447
520
|
const opener =
|
|
448
|
-
|
|
521
|
+
headCount <= EVIDENCE_OPEN_LIMIT ? '<details open>' : '<details>'
|
|
449
522
|
|
|
450
|
-
const sections =
|
|
451
|
-
renderState(entry, repo, base, head, opener),
|
|
452
|
-
|
|
523
|
+
const sections = [
|
|
524
|
+
...open.flatMap((entry) => renderState(entry, repo, base, head, opener)),
|
|
525
|
+
...(folded.length === 0 ? [] : [renderFolded(folded, repo, base, head)]),
|
|
526
|
+
]
|
|
453
527
|
|
|
454
528
|
return [
|
|
455
529
|
...opening,
|
|
@@ -464,18 +538,18 @@ export function renderEvidenceBody(
|
|
|
464
538
|
|
|
465
539
|
/**
|
|
466
540
|
* How many cases a rendered body carries. Every case has exactly one head
|
|
467
|
-
* image, so
|
|
468
|
-
*
|
|
469
|
-
* its head image counts as one case when it matches the old row
|
|
470
|
-
* comment posted
|
|
471
|
-
* `would-empty` guard holds.
|
|
541
|
+
* image, so the count is the head images on any line, whether a table row
|
|
542
|
+
* holding one or two or a line of its own. A table row from before the alt
|
|
543
|
+
* text named its head image counts as one case when it matches the old row
|
|
544
|
+
* shape, so a comment posted in any earlier layout still counts its cases and
|
|
545
|
+
* the `would-empty` guard holds.
|
|
472
546
|
*/
|
|
473
547
|
export function countEvidenceCases(body: string): number {
|
|
474
548
|
let total = 0
|
|
475
549
|
for (const line of body.split('\n')) {
|
|
476
|
-
if (!line.startsWith('| ')) continue
|
|
477
550
|
const heads = line.match(HEAD_IMAGE_PATTERN)?.length ?? 0
|
|
478
|
-
|
|
551
|
+
if (heads > 0) total += heads
|
|
552
|
+
else if (line.startsWith('| ') && LEGACY_ROW_PATTERN.test(line)) total += 1
|
|
479
553
|
}
|
|
480
554
|
return total
|
|
481
555
|
}
|
|
@@ -529,6 +603,34 @@ export function readOwed(input: OwedInput): OwedStep[] {
|
|
|
529
603
|
return owed
|
|
530
604
|
}
|
|
531
605
|
|
|
606
|
+
/**
|
|
607
|
+
* Design references are redrawn rather than captured, so recapturing them
|
|
608
|
+
* neither owes nor stands in for an evidence capture.
|
|
609
|
+
*/
|
|
610
|
+
const DESIGN_FOLDER = 'canon/design/'
|
|
611
|
+
|
|
612
|
+
/**
|
|
613
|
+
* The painted paths a pull request changed while no evidence image did, in a
|
|
614
|
+
* project whose base keeps captures. Undefined when the base keeps none or
|
|
615
|
+
* was not read, since an empty list reads as checked and clean. Any changed
|
|
616
|
+
* evidence image clears the list, since this names a skipped capture rather
|
|
617
|
+
* than judging which views a capture covered.
|
|
618
|
+
*/
|
|
619
|
+
export function readUncaptured(
|
|
620
|
+
changed: readonly string[],
|
|
621
|
+
patterns: PaintedPatterns,
|
|
622
|
+
keepsCaptures: boolean | undefined,
|
|
623
|
+
): string[] | undefined {
|
|
624
|
+
if (keepsCaptures !== true) return undefined
|
|
625
|
+
if (changed.some(isEvidencePath)) return []
|
|
626
|
+
return changed.filter(
|
|
627
|
+
(path) =>
|
|
628
|
+
evidencePosition(path) === -1 &&
|
|
629
|
+
!path.startsWith(DESIGN_FOLDER) &&
|
|
630
|
+
isPainted(path, patterns),
|
|
631
|
+
)
|
|
632
|
+
}
|
|
633
|
+
|
|
532
634
|
/**
|
|
533
635
|
* The REST id of the comment this module already posted, read off its `url`
|
|
534
636
|
* field, since `gh pr view --json comments` reports only a GraphQL id there
|
|
@@ -639,6 +741,34 @@ export function findEvidenceLocal(
|
|
|
639
741
|
return marked === undefined ? undefined : readAddresses(marked.body).local
|
|
640
742
|
}
|
|
641
743
|
|
|
744
|
+
/** The reel the marked comment already embeds, with the head it was filmed at. */
|
|
745
|
+
export function findEvidenceReel(
|
|
746
|
+
comments: readonly EvidenceComment[],
|
|
747
|
+
): EvidenceReel | undefined {
|
|
748
|
+
const marked = findMarked(comments)
|
|
749
|
+
if (marked === undefined) return undefined
|
|
750
|
+
for (const line of marked.body.split('\n')) {
|
|
751
|
+
const match = REEL_LINE.exec(line.trim())
|
|
752
|
+
if (match?.[1] !== undefined && match[2] !== undefined) {
|
|
753
|
+
return { link: match[2].replaceAll('&', '&'), head: match[1] }
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
return undefined
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
/**
|
|
760
|
+
* The reel a render embeds: a passed link, filmed at this head, replaces
|
|
761
|
+
* whatever the comment carries, and with none passed the posted reel carries
|
|
762
|
+
* forward under the head it was filmed at.
|
|
763
|
+
*/
|
|
764
|
+
export function nextEvidenceReel(
|
|
765
|
+
link: string | undefined,
|
|
766
|
+
head: string,
|
|
767
|
+
comments: readonly EvidenceComment[],
|
|
768
|
+
): EvidenceReel | undefined {
|
|
769
|
+
return link === undefined ? findEvidenceReel(comments) : { link, head }
|
|
770
|
+
}
|
|
771
|
+
|
|
642
772
|
/**
|
|
643
773
|
* The checklist the marked comment already carries, read back out of its
|
|
644
774
|
* delimiters. The handoff file is deleted once posted, so every later render
|
package/src/pr/frames.ts
CHANGED
|
@@ -9,6 +9,16 @@ export const LEGACY_FRAMES_BRANCH = 'canon-frames'
|
|
|
9
9
|
/** The eight bytes every PNG opens with. A frame that lacks them embeds as a broken image. */
|
|
10
10
|
const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]
|
|
11
11
|
|
|
12
|
+
/** A WebP opens on `RIFF`, four bytes of size, then `WEBP`. */
|
|
13
|
+
const RIFF = [0x52, 0x49, 0x46, 0x46]
|
|
14
|
+
const WEBP = [0x57, 0x45, 0x42, 0x50]
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The largest reel an add takes, GitHub's cap on an image in a comment, so a
|
|
18
|
+
* reel that would not render is refused before it reaches the branch.
|
|
19
|
+
*/
|
|
20
|
+
export const REEL_MAX_BYTES = 10_000_000
|
|
21
|
+
|
|
12
22
|
const SHORT_HEAD_LENGTH = 7
|
|
13
23
|
|
|
14
24
|
const HEAD_PATTERN = /^[0-9a-f]{7,40}$/
|
|
@@ -86,9 +96,16 @@ export interface FrameInput {
|
|
|
86
96
|
readonly box?: number
|
|
87
97
|
readonly reference?: string
|
|
88
98
|
readonly theme?: FrameTheme
|
|
99
|
+
/** The pass's reel, in place of a box or a reference. */
|
|
100
|
+
readonly reel?: boolean
|
|
89
101
|
readonly bytes: Uint8Array
|
|
90
102
|
}
|
|
91
103
|
|
|
104
|
+
export type ReelRead =
|
|
105
|
+
| { readonly kind: 'read'; readonly bytes: Uint8Array }
|
|
106
|
+
| { readonly kind: 'unreadable' }
|
|
107
|
+
| { readonly kind: 'too-large'; readonly size: number }
|
|
108
|
+
|
|
92
109
|
export type PushResult =
|
|
93
110
|
| {
|
|
94
111
|
readonly kind: 'pushed'
|
|
@@ -206,6 +223,25 @@ export function referencePath(
|
|
|
206
223
|
return `pr-${number}/${head.slice(0, SHORT_HEAD_LENGTH)}/${pass}/ref-${slug}.png`
|
|
207
224
|
}
|
|
208
225
|
|
|
226
|
+
/** Where a pass's reel lands, one per pass beside its box frames. */
|
|
227
|
+
export function reelPath(number: number, head: string, pass: string): string {
|
|
228
|
+
return `pr-${number}/${head.slice(0, SHORT_HEAD_LENGTH)}/${pass}/reel.webp`
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** The reel's bytes, refusing a file that is absent, not a WebP, or past the cap. */
|
|
232
|
+
export async function readReel(path: string): Promise<ReelRead> {
|
|
233
|
+
const bytes = await readFile(path).catch(() => undefined)
|
|
234
|
+
if (bytes === undefined || bytes.length <= 12) return { kind: 'unreadable' }
|
|
235
|
+
const webp =
|
|
236
|
+
RIFF.every((byte, index) => bytes[index] === byte) &&
|
|
237
|
+
WEBP.every((byte, index) => bytes[index + 8] === byte)
|
|
238
|
+
if (!webp) return { kind: 'unreadable' }
|
|
239
|
+
if (bytes.length > REEL_MAX_BYTES) {
|
|
240
|
+
return { kind: 'too-large', size: bytes.length }
|
|
241
|
+
}
|
|
242
|
+
return { kind: 'read', bytes: new Uint8Array(bytes) }
|
|
243
|
+
}
|
|
244
|
+
|
|
209
245
|
/** The frame's bytes, or undefined when the file is absent, empty, or not a PNG. */
|
|
210
246
|
export async function readFrame(path: string): Promise<Uint8Array | undefined> {
|
|
211
247
|
const bytes = await readFile(path).catch(() => undefined)
|
|
@@ -227,8 +263,9 @@ export async function pushFrame(
|
|
|
227
263
|
api: FramesApi,
|
|
228
264
|
input: FrameInput,
|
|
229
265
|
): Promise<PushResult> {
|
|
230
|
-
const path =
|
|
231
|
-
input.
|
|
266
|
+
const path = input.reel
|
|
267
|
+
? reelPath(input.number, input.head, input.pass)
|
|
268
|
+
: input.reference === undefined
|
|
232
269
|
? framePath(
|
|
233
270
|
input.number,
|
|
234
271
|
input.head,
|
|
@@ -241,8 +278,9 @@ export async function pushFrame(
|
|
|
241
278
|
const blob = await api.createBlob(Buffer.from(input.bytes).toString('base64'))
|
|
242
279
|
if (blob === undefined) return { kind: 'refused', reason: 'push-failed' }
|
|
243
280
|
const entry: TreeEntry = { path, mode: '100644', type: 'blob', sha: blob }
|
|
244
|
-
const message =
|
|
245
|
-
input.
|
|
281
|
+
const message = input.reel
|
|
282
|
+
? `chore(frames): add reel of #${input.number}`
|
|
283
|
+
: input.reference === undefined
|
|
246
284
|
? `chore(frames): add box ${input.box} of #${input.number}`
|
|
247
285
|
: `chore(frames): add reference ${input.reference} of #${input.number}`
|
|
248
286
|
|
package/src/pr/pixels.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import pixelmatch from 'pixelmatch'
|
|
2
|
+
import { PNG } from 'pngjs'
|
|
3
|
+
|
|
4
|
+
/** How different two colors must be for a pixel to count, where 1 is black against white. Playwright's 0.2 passed a real contrast fix. */
|
|
5
|
+
const COLOR_THRESHOLD = 0.1
|
|
6
|
+
|
|
7
|
+
/** The side of the square the count is taken over, so noise scattered across the image never adds up while a compact change does. */
|
|
8
|
+
const WINDOW_SIZE = 16
|
|
9
|
+
|
|
10
|
+
/** The most differing pixels any window may hold and still read as noise. Measured on Frameboard, noise counted 2 or fewer and real changes 14 or more. */
|
|
11
|
+
const MAX_WINDOW_PIXELS = 6
|
|
12
|
+
|
|
13
|
+
function decode(bytes: Uint8Array): PNG | undefined {
|
|
14
|
+
try {
|
|
15
|
+
return PNG.sync.read(Buffer.from(bytes))
|
|
16
|
+
} catch {
|
|
17
|
+
return undefined
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Whether two PNG captures differ by no more than sub-visual noise. Anti-aliased
|
|
23
|
+
* pixels are counted rather than skipped, since antialias detection reads a
|
|
24
|
+
* changed 1px border as an edge artifact, and the windowed count separates
|
|
25
|
+
* scattered edge noise from a compact change on its own. A pair that does not decode or differs in size reads as changed, so
|
|
26
|
+
* nothing the comparison cannot read is ever folded.
|
|
27
|
+
*/
|
|
28
|
+
export function isWithinTolerance(base: Uint8Array, head: Uint8Array): boolean {
|
|
29
|
+
const before = decode(base)
|
|
30
|
+
const after = decode(head)
|
|
31
|
+
if (before === undefined || after === undefined) return false
|
|
32
|
+
if (before.width !== after.width || before.height !== after.height) {
|
|
33
|
+
return false
|
|
34
|
+
}
|
|
35
|
+
const count = pixelmatch(
|
|
36
|
+
before.data,
|
|
37
|
+
after.data,
|
|
38
|
+
null,
|
|
39
|
+
before.width,
|
|
40
|
+
before.height,
|
|
41
|
+
{ threshold: COLOR_THRESHOLD, includeAA: true, windowSize: WINDOW_SIZE },
|
|
42
|
+
)
|
|
43
|
+
return count <= MAX_WINDOW_PIXELS
|
|
44
|
+
}
|
package/src/records/backup.ts
CHANGED
|
@@ -104,6 +104,17 @@ export const EXCLUDED_ENTRIES = [
|
|
|
104
104
|
RECORDS_GIT_NAME,
|
|
105
105
|
] as const
|
|
106
106
|
|
|
107
|
+
/** Media under a video workspace, remade from its plan and composition, so never backed up. */
|
|
108
|
+
export const MEDIA_PATHSPECS = [
|
|
109
|
+
':(exclude,glob)video/*/takes/**',
|
|
110
|
+
':(exclude,glob)video/**/renders/**',
|
|
111
|
+
':(exclude,glob)video/**/snapshots/**',
|
|
112
|
+
':(exclude,glob)video/**/node_modules/**',
|
|
113
|
+
':(exclude,glob,icase)video/**/*.mp4',
|
|
114
|
+
':(exclude,glob,icase)video/**/*.webm',
|
|
115
|
+
':(exclude,glob,icase)video/**/*.mov',
|
|
116
|
+
] as const
|
|
117
|
+
|
|
107
118
|
/**
|
|
108
119
|
* Whether a top-level name stays out of every push, at either root.
|
|
109
120
|
*
|
|
@@ -598,8 +609,8 @@ async function pendingFiles(
|
|
|
598
609
|
if (scope.length === 0) return { added: [], pending: [] }
|
|
599
610
|
|
|
600
611
|
const [others, modified] = await Promise.all([
|
|
601
|
-
records(root, ['ls-files', '-z', '-o', '--', ...scope]),
|
|
602
|
-
records(root, ['ls-files', '-z', '-m', '--', ...scope]),
|
|
612
|
+
records(root, ['ls-files', '-z', '-o', '--', ...scope, ...MEDIA_PATHSPECS]),
|
|
613
|
+
records(root, ['ls-files', '-z', '-m', '--', ...scope, ...MEDIA_PATHSPECS]),
|
|
603
614
|
])
|
|
604
615
|
if (!others.ok) return failed('ls-files', others)
|
|
605
616
|
if (!modified.ok) return failed('ls-files', modified)
|
|
@@ -699,7 +710,14 @@ export async function pushRecords(root: string): Promise<PushOutcome> {
|
|
|
699
710
|
// enclosing repository, and the pathspecs are the whole list, so nothing
|
|
700
711
|
// outside them can enter the index however the ignore rules read. An
|
|
701
712
|
// excluded name never reaches the list, whatever the records index holds.
|
|
702
|
-
const staged = await records(root, [
|
|
713
|
+
const staged = await records(root, [
|
|
714
|
+
'add',
|
|
715
|
+
'-A',
|
|
716
|
+
'-f',
|
|
717
|
+
'--',
|
|
718
|
+
...scope,
|
|
719
|
+
...MEDIA_PATHSPECS,
|
|
720
|
+
])
|
|
703
721
|
if (!staged.ok) return failed('add', staged)
|
|
704
722
|
}
|
|
705
723
|
|
|
@@ -837,7 +855,13 @@ export async function pullRecords(root: string): Promise<PullOutcome> {
|
|
|
837
855
|
const { scope } = await scopedFolders(root)
|
|
838
856
|
|
|
839
857
|
if (scope.length > 0) {
|
|
840
|
-
const dirty = await records(root, [
|
|
858
|
+
const dirty = await records(root, [
|
|
859
|
+
'status',
|
|
860
|
+
'--porcelain',
|
|
861
|
+
'--',
|
|
862
|
+
...scope,
|
|
863
|
+
...MEDIA_PATHSPECS,
|
|
864
|
+
])
|
|
841
865
|
if (!dirty.ok) return failed('status', dirty)
|
|
842
866
|
|
|
843
867
|
if (dirty.text.length > 0) {
|
package/src/records/validate.ts
CHANGED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Run by the claude wrapper installDispatchShim writes in src/sandbox/headless/dispatch.ts.
|
|
3
|
+
# $1 is the real claude binary, and everything after it is the caller's arguments.
|
|
4
|
+
real="$1"
|
|
5
|
+
shift
|
|
6
|
+
|
|
7
|
+
for arg in "$@"; do
|
|
8
|
+
case "$arg" in
|
|
9
|
+
--bg | --background)
|
|
10
|
+
echo "canon sandbox harness: refusing $arg. A sandbox run may not dispatch a background session, so this shim stands first on PATH in place of the real claude binary. Drive the refusal the fixture asks for, or run this outside the harness." >&2
|
|
11
|
+
exit 64
|
|
12
|
+
;;
|
|
13
|
+
esac
|
|
14
|
+
done
|
|
15
|
+
exec "$real" "$@"
|