@erclx/canon 5.69.0 → 5.71.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 +25 -0
- package/README.md +33 -159
- package/assets/brand/mark.svg +17 -9
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/app-drive/SKILL.md +3 -2
- package/claude/skills/app-drive/references/drive-record.md +52 -10
- package/claude/skills/app-drive/references/isolate.md +1 -1
- package/claude/skills/design-pick/SKILL.md +1 -0
- package/claude/skills/git-pr/references/task-number.md +13 -0
- package/claude/skills/markdown-craft/references/markdown.md +1 -0
- package/claude/skills/role-orchestrator/references/orchestrator-cloud-launch.md +1 -0
- package/claude/skills/role-orchestrator/references/orchestrator-launch.md +6 -20
- package/claude/skills/role-orchestrator/references/orchestrator-reviewer-launch.md +10 -0
- package/claude/skills/role-orchestrator/references/orchestrator-talk-launch.md +30 -0
- package/claude/skills/task-board/references/tasks-record-lines.md +40 -0
- package/claude/skills/task-board/references/tasks.md +4 -8
- package/claude/skills/ui-craft/references/copy.md +4 -1
- package/claude/skills/ui-film/REQUIREMENT.md +4 -2
- package/claude/skills/ui-film/SKILL.md +24 -11
- package/claude/skills/ui-film/references/compose-craft.md +81 -0
- package/claude/skills/ui-film/references/config.md +6 -1
- package/claude/skills/ui-film/references/handoff.md +13 -3
- package/claude/skills/ui-film/references/take-craft.md +27 -1
- package/docs/index.md +1 -0
- package/docs/migrations/by-hand.md +107 -0
- package/docs/migrations/by-verb.md +118 -0
- package/docs/migrations/index.md +14 -0
- package/docs/target/index.md +1 -2
- package/docs/target/projects.md +2 -2
- package/docs/target/sync.md +22 -2
- package/docs/workflow/ai-workflow.md +1 -1
- package/governance/ledger/skills/ui-craft/copy.md +8 -4
- package/package.json +3 -1
- package/scripts/core/design-sync.ts +204 -0
- package/scripts/core/regen-web-favicon.ts +27 -12
- package/scripts/core/regen-web-previews.ts +8 -1
- package/scripts/core/render-film.ts +179 -0
- package/src/capture/frame-values.ts +12 -12
- package/src/commands/markdown.ts +6 -1
- package/src/commands/records.ts +3 -2
- package/src/commands/sandbox.ts +9 -2
- package/src/commands/take.ts +159 -11
- package/src/commands/tasks.ts +133 -0
- package/src/design/favicon.ts +85 -13
- package/src/design/reference.ts +98 -0
- package/src/design/render.ts +16 -14
- package/src/design/tokens.ts +4 -3
- package/src/gate/stages.ts +6 -0
- package/src/init/steps.ts +2 -2
- package/src/markdown/colons.ts +102 -16
- package/src/migrate/status.ts +3 -3
- package/src/records/validate.ts +14 -2
- package/src/sandbox/expect.ts +32 -0
- package/src/sandbox/headless/run.ts +6 -3
- package/src/take/camera.ts +99 -10
- package/src/take/film.ts +35 -2
- package/src/take/fixtures/input.html +138 -0
- package/src/take/keys.ts +193 -0
- package/src/take/log.ts +94 -1
- package/src/take/perform.ts +165 -10
- package/src/take/plan.ts +101 -6
- package/src/take/screencast.ts +28 -3
- package/src/take/stepper.ts +122 -18
- package/src/tasks/archive.ts +54 -1
- package/src/tasks/record.ts +118 -0
- package/src/tasks/validate.ts +37 -0
- package/docs/target/migrations.md +0 -205
package/src/design/render.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { join } from 'node:path'
|
|
|
3
3
|
import type { Cell, DesignDoc, Row } from '@/design/parse'
|
|
4
4
|
import { parseDesignDoc } from '@/design/parse'
|
|
5
5
|
import { fontFaceBlock } from '@/design/css'
|
|
6
|
+
import { faviconLink } from '@/design/favicon'
|
|
6
7
|
import type { FontFace } from '@/design/fonts'
|
|
7
8
|
import { FONT_FACES } from '@/design/fonts'
|
|
8
9
|
import { colorValue } from '@/design/tokens'
|
|
@@ -12,16 +13,6 @@ export interface RenderResult {
|
|
|
12
13
|
cssPath: string
|
|
13
14
|
}
|
|
14
15
|
|
|
15
|
-
/**
|
|
16
|
-
* The same path data as `assets/brand/mark.svg`, colored via `colorValue`
|
|
17
|
-
* since a data URI has no CSS context to resolve `currentColor` against and
|
|
18
|
-
* this page renders on the light chrome `previewChrome()` sets up.
|
|
19
|
-
*/
|
|
20
|
-
const FAVICON_COLOR = colorValue('light-accent') ?? '#a4471c'
|
|
21
|
-
const FAVICON_HREF = `data:image/svg+xml,${encodeURIComponent(
|
|
22
|
-
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="10 10 80 80"><path d="M34,20 L15,28 L15,72 L34,80 Z M66,20 L85,28 L85,72 L66,80 Z" fill="${FAVICON_COLOR}" /><rect x="44" y="15" width="12" height="70" rx="2" fill="${FAVICON_COLOR}" /></svg>`,
|
|
23
|
-
)}`
|
|
24
|
-
|
|
25
16
|
export interface RenderOptions {
|
|
26
17
|
/**
|
|
27
18
|
* Declare each vendored face the typography table names, and let the page
|
|
@@ -29,6 +20,11 @@ export interface RenderOptions {
|
|
|
29
20
|
* base64 block a target's render would otherwise grow by unasked.
|
|
30
21
|
*/
|
|
31
22
|
readonly embedFonts?: boolean
|
|
23
|
+
/**
|
|
24
|
+
* A rendered favicon SVG to inline in the head. The caller reads and renders
|
|
25
|
+
* the mark, so this module reads no brand file a target does not carry.
|
|
26
|
+
*/
|
|
27
|
+
readonly favicon?: string
|
|
32
28
|
}
|
|
33
29
|
|
|
34
30
|
export function renderDesignDoc(
|
|
@@ -42,7 +38,10 @@ export function renderDesignDoc(
|
|
|
42
38
|
const htmlPath = join(outDir, 'index.html')
|
|
43
39
|
const faces = options.embedFonts ? namedFaces(doc) : []
|
|
44
40
|
writeFileSync(cssPath, buildCss(doc, faces))
|
|
45
|
-
writeFileSync(
|
|
41
|
+
writeFileSync(
|
|
42
|
+
htmlPath,
|
|
43
|
+
buildHtml(doc, bodyFamily(doc, faces), options.favicon),
|
|
44
|
+
)
|
|
46
45
|
return { htmlPath, cssPath }
|
|
47
46
|
}
|
|
48
47
|
|
|
@@ -218,7 +217,11 @@ function previewChrome(): string {
|
|
|
218
217
|
return [' :root {', ...lines, ' }'].join('\n')
|
|
219
218
|
}
|
|
220
219
|
|
|
221
|
-
function buildHtml(
|
|
220
|
+
function buildHtml(
|
|
221
|
+
doc: DesignDoc,
|
|
222
|
+
bodyStack: string,
|
|
223
|
+
favicon: string | undefined,
|
|
224
|
+
): string {
|
|
222
225
|
const sections = [
|
|
223
226
|
sectionPersonality(doc.personality),
|
|
224
227
|
sectionColor(doc.color),
|
|
@@ -239,8 +242,7 @@ function buildHtml(doc: DesignDoc, bodyStack: string): string {
|
|
|
239
242
|
return `<!doctype html>
|
|
240
243
|
<html lang="en">
|
|
241
244
|
<head>
|
|
242
|
-
<meta charset="utf-8"
|
|
243
|
-
<link rel="icon" href="${FAVICON_HREF}">
|
|
245
|
+
<meta charset="utf-8">${favicon === undefined ? '' : `\n${faviconLink(favicon)}`}
|
|
244
246
|
<title>Design tokens</title>
|
|
245
247
|
<link rel="stylesheet" href="design.css">
|
|
246
248
|
<style>
|
package/src/design/tokens.ts
CHANGED
|
@@ -100,7 +100,7 @@ export const REVIEWED = '2026-10-24'
|
|
|
100
100
|
|
|
101
101
|
export const TOKENS: DesignTokens = {
|
|
102
102
|
personality:
|
|
103
|
-
'Warm neutrals
|
|
103
|
+
'Warm neutrals with actions in ink, set in Geist. The page reads as prose, and monospace appears only where a surface shows a path, a command, or what a shell printed. The rust accent marks a status the surface it shows would color, and the landing page carries none, leaving the vermilion pip of the mark as its one hue.',
|
|
104
104
|
|
|
105
105
|
colorRules: [
|
|
106
106
|
'One accent. Never promote a second color into a structural role.',
|
|
@@ -155,7 +155,7 @@ export const TOKENS: DesignTokens = {
|
|
|
155
155
|
},
|
|
156
156
|
{
|
|
157
157
|
role: 'accent',
|
|
158
|
-
intent: '
|
|
158
|
+
intent: 'a status the surface would color, such as an open pull request',
|
|
159
159
|
value: '#c76b5f',
|
|
160
160
|
grounds: DARK_GROUNDS,
|
|
161
161
|
},
|
|
@@ -208,7 +208,7 @@ export const TOKENS: DesignTokens = {
|
|
|
208
208
|
},
|
|
209
209
|
{
|
|
210
210
|
role: 'light-accent',
|
|
211
|
-
intent: '
|
|
211
|
+
intent: 'a status the surface would color, on light',
|
|
212
212
|
value: '#ad4a4b',
|
|
213
213
|
grounds: LIGHT_GROUNDS,
|
|
214
214
|
},
|
|
@@ -361,6 +361,7 @@ export const TOKENS: DesignTokens = {
|
|
|
361
361
|
|
|
362
362
|
iconography: [
|
|
363
363
|
'No icon library. `assets/brand/mark.svg` is the one authored icon.',
|
|
364
|
+
'The mark takes its own colors from `src/design/favicon.ts`, never the accent: the c in the text ink and the pip in a vermilion pair.',
|
|
364
365
|
'The terminal draws glyphs: `│ ├ ✓ ! ✗ + - ◆ ◇ ❯`.',
|
|
365
366
|
],
|
|
366
367
|
}
|
package/src/gate/stages.ts
CHANGED
|
@@ -248,6 +248,12 @@ export const STAGES: readonly Stage[] = [
|
|
|
248
248
|
failure:
|
|
249
249
|
'The tab icon drifted from the token source. Run bun run check and commit web/public/favicon.svg.',
|
|
250
250
|
},
|
|
251
|
+
{
|
|
252
|
+
kind: 'drift',
|
|
253
|
+
pathspec: 'assets/brand/mark-color*.svg',
|
|
254
|
+
failure:
|
|
255
|
+
'The README mark pair drifted from the mark source. Run bun run check and commit assets/brand/mark-color.svg and assets/brand/mark-color-light.svg.',
|
|
256
|
+
},
|
|
251
257
|
{ kind: 'measure', measure: designProse },
|
|
252
258
|
],
|
|
253
259
|
success: 'Design source clean',
|
package/src/init/steps.ts
CHANGED
|
@@ -7,8 +7,8 @@ import { GATED_CONFIG_REL, gateScopeRoots } from '@/markdown/gated'
|
|
|
7
7
|
export type RunFactory = (args: readonly string[]) => () => Promise<boolean>
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
|
-
* States the same fact as `docs/
|
|
11
|
-
* own words. Keep the two in step by hand; nothing compares them.
|
|
10
|
+
* States the same fact as `docs/migrations/by-hand.md`'s backup section
|
|
11
|
+
* in its own words. Keep the two in step by hand; nothing compares them.
|
|
12
12
|
*/
|
|
13
13
|
const RECORDS_SETUP_NOTICE =
|
|
14
14
|
"No private repository to push to yet. Run 'canon records push' once one exists to print the one-time setup command, or --skip records to silence this."
|
package/src/markdown/colons.ts
CHANGED
|
@@ -22,8 +22,14 @@ const QUOTE_MARKER = /^(?:\s*>\s?)+/
|
|
|
22
22
|
/** A colon followed by whitespace and more text, ruling out `10:30` and a scheme. */
|
|
23
23
|
const COLON = /:(?=\s+\S)/g
|
|
24
24
|
|
|
25
|
+
/** A colon followed by whitespace, which a masked code span leaves behind. */
|
|
26
|
+
const LABEL_COLON = /:\s/
|
|
27
|
+
|
|
25
28
|
const BOLD_LABEL = /^(\*\*|__)[^*_]+\1$/
|
|
26
29
|
|
|
30
|
+
/** What may stand in a lead once its code spans and links are masked. */
|
|
31
|
+
const CONNECTIVE = /\b(?:and|or|to)\b|,/g
|
|
32
|
+
|
|
27
33
|
/** Blanks a span while holding its width, so a column stays where it was. */
|
|
28
34
|
function blank(match: string): string {
|
|
29
35
|
return ' '.repeat(match.length)
|
|
@@ -32,14 +38,74 @@ function blank(match: string): string {
|
|
|
32
38
|
/**
|
|
33
39
|
* Whether the text before a line's first colon is a label rather than a
|
|
34
40
|
* clause: the lead of a list item, or a prefix that is only emphasis and a
|
|
35
|
-
* masked code span.
|
|
41
|
+
* masked code span. A list item's lead also reads as a label at any length
|
|
42
|
+
* when only code spans, links, commas, and `and`, `or`, or `to` stand in it,
|
|
43
|
+
* which is a file entry naming several paths.
|
|
36
44
|
*/
|
|
37
45
|
function isLabel(prefix: string, listItem: boolean): boolean {
|
|
38
46
|
if (BOLD_LABEL.test(prefix.trim())) return true
|
|
39
47
|
|
|
40
48
|
const words = prefix.replace(/[*_]/g, ' ').trim()
|
|
41
49
|
if (words === '') return true
|
|
42
|
-
|
|
50
|
+
if (!listItem) return false
|
|
51
|
+
if (words.replace(CONNECTIVE, ' ').trim() === '') return true
|
|
52
|
+
return words.split(/\s+/).length <= 4
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
interface Entry {
|
|
56
|
+
readonly line: BodyLine
|
|
57
|
+
readonly masked: string
|
|
58
|
+
readonly start: number
|
|
59
|
+
/** Width of the quote marker and the item's own leading whitespace. */
|
|
60
|
+
readonly indent: number
|
|
61
|
+
readonly quote: string
|
|
62
|
+
readonly isItem: boolean
|
|
63
|
+
/** Matches at or past the content start, the only ones a line can report. */
|
|
64
|
+
readonly colons: readonly RegExpExecArray[]
|
|
65
|
+
/** A colon stands past the content start, even with only a masked span after it. */
|
|
66
|
+
readonly labelColon: boolean
|
|
67
|
+
/** Items sharing one list at one indent, shared by reference. */
|
|
68
|
+
group?: Entry[]
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Collects consecutive items at one indent into a list. A blank line, a
|
|
73
|
+
* heading, or a change of quote depth ends every open list, and a deeper child
|
|
74
|
+
* neither joins its parent's list nor ends it.
|
|
75
|
+
*/
|
|
76
|
+
function groupItems(entries: readonly Entry[]): void {
|
|
77
|
+
const open = new Map<number, Entry[]>()
|
|
78
|
+
let quote = ''
|
|
79
|
+
|
|
80
|
+
for (const entry of entries) {
|
|
81
|
+
if (entry.line.fenced) continue
|
|
82
|
+
|
|
83
|
+
const empty = entry.masked.slice(entry.quote.length).trim() === ''
|
|
84
|
+
if (entry.quote !== quote || empty || HEADING.test(entry.line.text)) {
|
|
85
|
+
open.clear()
|
|
86
|
+
}
|
|
87
|
+
quote = entry.quote
|
|
88
|
+
if (!entry.isItem) continue
|
|
89
|
+
|
|
90
|
+
for (const level of open.keys()) {
|
|
91
|
+
if (level > entry.indent) open.delete(level)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const group = open.get(entry.indent) ?? []
|
|
95
|
+
open.set(entry.indent, group)
|
|
96
|
+
group.push(entry)
|
|
97
|
+
entry.group = group
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Whether the item sits in a list of two or more where every item has a colon. */
|
|
102
|
+
function uniform(entry: Entry): boolean {
|
|
103
|
+
const group = entry.group
|
|
104
|
+
return (
|
|
105
|
+
group !== undefined &&
|
|
106
|
+
group.length >= 2 &&
|
|
107
|
+
group.every((item) => item.labelColon)
|
|
108
|
+
)
|
|
43
109
|
}
|
|
44
110
|
|
|
45
111
|
/**
|
|
@@ -47,30 +113,50 @@ function isLabel(prefix: string, listItem: boolean): boolean {
|
|
|
47
113
|
*
|
|
48
114
|
* A heading, a table row, a fence, a code span, a link, and a line-final colon
|
|
49
115
|
* are left alone, and so is the first colon of a list item or of a line opening
|
|
50
|
-
* on a bold or code label
|
|
51
|
-
*
|
|
52
|
-
* asked for.
|
|
116
|
+
* on a bold or code label, and of every item in a list where each one carries a
|
|
117
|
+
* colon. The measure is advisory, because a colon is sometimes right and the
|
|
118
|
+
* corpus count shows a gate would be a sweep nobody asked for.
|
|
53
119
|
*/
|
|
54
120
|
export function findColons(lines: readonly BodyLine[]): ColonFinding[] {
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
for (const line of lines) {
|
|
58
|
-
if (line.fenced || HEADING.test(line.text) || TABLE_ROW.test(line.text)) {
|
|
59
|
-
continue
|
|
60
|
-
}
|
|
61
|
-
|
|
121
|
+
const entries: Entry[] = lines.map((line) => {
|
|
62
122
|
const masked = maskDisplayed(line.text.replace(LINK, blank))
|
|
63
123
|
const quote = QUOTE_MARKER.exec(masked)?.[0] ?? ''
|
|
64
124
|
const marker = LIST_MARKER.exec(masked.slice(quote.length))?.[0] ?? ''
|
|
65
125
|
const start = quote.length + marker.length
|
|
66
|
-
|
|
126
|
+
const skipped =
|
|
127
|
+
line.fenced || HEADING.test(line.text) || TABLE_ROW.test(line.text)
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
line,
|
|
131
|
+
masked,
|
|
132
|
+
start,
|
|
133
|
+
indent: quote.length + marker.length - marker.trimStart().length,
|
|
134
|
+
quote: quote.replace(/\s/g, ''),
|
|
135
|
+
isItem: !skipped && marker !== '',
|
|
136
|
+
labelColon: !skipped && LABEL_COLON.test(masked.slice(start)),
|
|
137
|
+
colons: skipped
|
|
138
|
+
? []
|
|
139
|
+
: [...masked.matchAll(COLON)].filter((match) => match.index >= start),
|
|
140
|
+
}
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
groupItems(entries)
|
|
67
144
|
|
|
68
|
-
|
|
69
|
-
|
|
145
|
+
const found: ColonFinding[] = []
|
|
146
|
+
|
|
147
|
+
for (const entry of entries) {
|
|
148
|
+
const { line, masked, start } = entry
|
|
149
|
+
let labelled = false
|
|
70
150
|
|
|
151
|
+
for (const match of entry.colons) {
|
|
71
152
|
if (!labelled) {
|
|
72
153
|
labelled = true
|
|
73
|
-
if (
|
|
154
|
+
if (
|
|
155
|
+
isLabel(masked.slice(start, match.index), entry.isItem) ||
|
|
156
|
+
(entry.isItem && uniform(entry))
|
|
157
|
+
) {
|
|
158
|
+
continue
|
|
159
|
+
}
|
|
74
160
|
}
|
|
75
161
|
|
|
76
162
|
found.push({
|
package/src/migrate/status.ts
CHANGED
|
@@ -8,9 +8,9 @@ interface Migration {
|
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
10
|
* Every `canon migrate` subcommand in the order a project runs them, records
|
|
11
|
-
* first. This is the source for that order, and
|
|
12
|
-
* cites the verb rather than keeping a
|
|
13
|
-
* one line here.
|
|
11
|
+
* first. This is the source for that order, and
|
|
12
|
+
* `docs/migrations/by-verb.md` cites the verb rather than keeping a
|
|
13
|
+
* second list. A new migration joins by one line here.
|
|
14
14
|
*/
|
|
15
15
|
export const MIGRATIONS: readonly Migration[] = [
|
|
16
16
|
{ name: 'records', flags: [] },
|
package/src/records/validate.ts
CHANGED
|
@@ -807,6 +807,13 @@ const STRUCTURE_FILES: Readonly<
|
|
|
807
807
|
intake: (file) => file !== INTAKE_INDEX && file !== INTAKE_HANDOFF,
|
|
808
808
|
}
|
|
809
809
|
|
|
810
|
+
/**
|
|
811
|
+
* An app drive's log, which sits under an intake folder's `evidence/` and
|
|
812
|
+
* holds findings as headings and fields rather than items, so it reads on the
|
|
813
|
+
* plain criteria rather than the cluster exemptions.
|
|
814
|
+
*/
|
|
815
|
+
const DRIVE_LOG = 'evidence/drive-log.md'
|
|
816
|
+
|
|
810
817
|
async function folderStructure(
|
|
811
818
|
dir: string,
|
|
812
819
|
kind: RecordKind,
|
|
@@ -817,9 +824,14 @@ async function folderStructure(
|
|
|
817
824
|
|
|
818
825
|
const report = kind === 'intake' ? intakeStructure : structureFindings
|
|
819
826
|
const files = (await listMarkdown(dir)).filter(reads)
|
|
827
|
+
const reports = files.map((file) => ({ file, read: report }))
|
|
828
|
+
if (kind === 'intake' && existsSync(join(dir, DRIVE_LOG))) {
|
|
829
|
+
reports.push({ file: DRIVE_LOG, read: structureFindings })
|
|
830
|
+
}
|
|
831
|
+
|
|
820
832
|
const perFile = await Promise.all(
|
|
821
|
-
|
|
822
|
-
|
|
833
|
+
reports.map(async ({ file, read }) =>
|
|
834
|
+
read(await readFile(join(dir, file), 'utf8')).map((entry) => ({
|
|
823
835
|
...entry,
|
|
824
836
|
record: slug,
|
|
825
837
|
subject: file,
|
package/src/sandbox/expect.ts
CHANGED
|
@@ -44,7 +44,15 @@ export interface AssertionResult {
|
|
|
44
44
|
*/
|
|
45
45
|
export interface RunEnvelope {
|
|
46
46
|
readonly isError: boolean
|
|
47
|
+
/** The runner cap ended the run, read from `subtype` rather than `turns`. */
|
|
48
|
+
readonly isTruncated: boolean
|
|
47
49
|
readonly turns: number
|
|
50
|
+
/**
|
|
51
|
+
* The cap the run ran under. `num_turns` counts more than the cap does, so
|
|
52
|
+
* an untruncated run can report past it, and a ceiling at or above the cap
|
|
53
|
+
* is held by `isTruncated` instead. A non-finite value reads as absent.
|
|
54
|
+
*/
|
|
55
|
+
readonly turnCap?: number
|
|
48
56
|
readonly denials: number
|
|
49
57
|
readonly reply?: string
|
|
50
58
|
}
|
|
@@ -487,6 +495,23 @@ function checkReply(
|
|
|
487
495
|
}
|
|
488
496
|
}
|
|
489
497
|
|
|
498
|
+
function isFiniteCap(turnCap: number | undefined): turnCap is number {
|
|
499
|
+
return turnCap !== undefined && Number.isFinite(turnCap)
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
function capped(turnCap: number | undefined): string {
|
|
503
|
+
return isFiniteCap(turnCap) ? ` of ${turnCap} turns` : ''
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* A ceiling below the cap was declared in `num_turns` and is compared in it. One
|
|
508
|
+
* at or above the cap skips with no result, since emitting a pass would let the
|
|
509
|
+
* envelope pass a run.
|
|
510
|
+
*/
|
|
511
|
+
function isBelowCap(maxTurns: number, turnCap: number | undefined): boolean {
|
|
512
|
+
return !isFiniteCap(turnCap) || maxTurns < turnCap
|
|
513
|
+
}
|
|
514
|
+
|
|
490
515
|
/**
|
|
491
516
|
* The envelope never determines a pass. It can only fail a run the expectations
|
|
492
517
|
* would otherwise have passed. Under `bypassPermissions` the denial count is
|
|
@@ -516,8 +541,15 @@ function checkEnvelope(
|
|
|
516
541
|
message: `envelope: ${envelope.denials} permission denials`,
|
|
517
542
|
})
|
|
518
543
|
}
|
|
544
|
+
if (envelope.isTruncated) {
|
|
545
|
+
results.push({
|
|
546
|
+
ok: false,
|
|
547
|
+
message: `envelope: truncated by the runner cap${capped(envelope.turnCap)}`,
|
|
548
|
+
})
|
|
549
|
+
}
|
|
519
550
|
if (
|
|
520
551
|
expectation.maxTurns !== undefined &&
|
|
552
|
+
isBelowCap(expectation.maxTurns, envelope.turnCap) &&
|
|
521
553
|
envelope.turns > expectation.maxTurns
|
|
522
554
|
) {
|
|
523
555
|
results.push({
|
|
@@ -45,9 +45,12 @@ import {
|
|
|
45
45
|
*
|
|
46
46
|
* `maxTurns` is the only budget. `max_turns` in an arm's `expect.toml` is a
|
|
47
47
|
* ceiling asserted after the run, not a cap enforced during it, so a
|
|
48
|
-
* declaration cannot raise what it runs under.
|
|
49
|
-
*
|
|
50
|
-
* does
|
|
48
|
+
* declaration cannot raise what it runs under. A truncated run exits 1 with
|
|
49
|
+
* `subtype` `error_max_turns`, which `canon sandbox check` fails by name.
|
|
50
|
+
* `num_turns` counts more than this cap does, so a ceiling at or above it is
|
|
51
|
+
* held by the cap and compares nothing. The check reads the cap from its own
|
|
52
|
+
* environment, so a run under a raised cap, checked later by hand without the
|
|
53
|
+
* variable, reads the default.
|
|
51
54
|
*
|
|
52
55
|
* `bypassPermissions` is the only mode that lets a run write under `.claude/`,
|
|
53
56
|
* which most arms need. The scoping the permission layer would have given
|
package/src/take/camera.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
type Box,
|
|
3
|
+
CLIP_SIZES,
|
|
3
4
|
type LogEvent,
|
|
4
5
|
type Point,
|
|
5
6
|
type TakeLog,
|
|
@@ -21,7 +22,7 @@ import {
|
|
|
21
22
|
/**
|
|
22
23
|
* Turns a take's log into one camera per frame, preset O4 from groundwork
|
|
23
24
|
* track `193-ui-record`, a folder name kept as written (canon-keep-retired).
|
|
24
|
-
* The preset is OpenScreen's timing aimed at each click, a fixed depth, a focus that holds still until a click leaves the inner part of the
|
|
25
|
+
* The preset is OpenScreen's timing aimed at each click, a fixed depth the clip's resolution caps, a focus that holds still until a click leaves the inner part of the
|
|
25
26
|
* view, and a dead zone that keeps the cursor in frame. HyperFrames renders the
|
|
26
27
|
* track as it stands, so it carries no curve a renderer would have to agree on.
|
|
27
28
|
*/
|
|
@@ -31,6 +32,13 @@ export const CAMERA_VERSION = 1
|
|
|
31
32
|
/** The depth every click zooms to, unless its target already fills the view. */
|
|
32
33
|
const DEPTH = 1.8
|
|
33
34
|
|
|
35
|
+
/**
|
|
36
|
+
* How far a clip with one pixel per CSS pixel zooms before it goes soft, which
|
|
37
|
+
* caps a screencast's depth well under `DEPTH` and leaves the stepper's 4K
|
|
38
|
+
* clip at it.
|
|
39
|
+
*/
|
|
40
|
+
const SHARP_DEPTH = 1.35
|
|
41
|
+
|
|
34
42
|
/** How much of the zoomed view a target may fill, which caps the depth for a big one. */
|
|
35
43
|
const FILL = 0.8
|
|
36
44
|
|
|
@@ -57,6 +65,13 @@ const EDGE = 0.9
|
|
|
57
65
|
/** How long the click ring the edit draws takes to expand. */
|
|
58
66
|
const RING_MS = 450
|
|
59
67
|
|
|
68
|
+
/**
|
|
69
|
+
* How far a drag's ring grows before it holds for the drag, then runs on to 1
|
|
70
|
+
* from the release. A held value keeps the ring's one field, so every reader
|
|
71
|
+
* drawing a growing ring draws a held press with no change.
|
|
72
|
+
*/
|
|
73
|
+
const GRAB_RING = 0.3
|
|
74
|
+
|
|
60
75
|
export interface CameraFrame {
|
|
61
76
|
readonly ms: number
|
|
62
77
|
/** How far the view is zoomed, 1 being the whole page. */
|
|
@@ -91,16 +106,30 @@ interface Window {
|
|
|
91
106
|
readonly aims: Aim[]
|
|
92
107
|
}
|
|
93
108
|
|
|
94
|
-
export
|
|
109
|
+
export interface CameraOptions {
|
|
110
|
+
/**
|
|
111
|
+
* How much faster the edit plays the clip, so the track is built on the
|
|
112
|
+
* playback clock and a zoom settles at the speed a viewer sees. 1 by default.
|
|
113
|
+
*/
|
|
114
|
+
readonly rate?: number
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function cameraTrack(
|
|
118
|
+
source: TakeLog,
|
|
119
|
+
options: CameraOptions = {},
|
|
120
|
+
): CameraTrack {
|
|
121
|
+
const log = atRate(source, options.rate ?? 1)
|
|
95
122
|
const { width, height } = log.viewport
|
|
123
|
+
const clip = log.clip ?? CLIP_SIZES[log.route]
|
|
124
|
+
const deepest = Math.min(DEPTH, (SHARP_DEPTH * clip.width) / width)
|
|
96
125
|
const depthFor = (box: Box): number =>
|
|
97
126
|
Math.min(
|
|
98
|
-
|
|
127
|
+
deepest,
|
|
99
128
|
(width * FILL) / Math.max(box.width, 1),
|
|
100
129
|
(height * FILL) / Math.max(box.height, 1),
|
|
101
130
|
)
|
|
102
131
|
|
|
103
|
-
const aims: Aim[] = log.events
|
|
132
|
+
const aims: Aim[] = (log.push === false ? [] : log.events)
|
|
104
133
|
.flatMap((event): Aim[] => {
|
|
105
134
|
if (event.kind === 'click') {
|
|
106
135
|
return [
|
|
@@ -112,6 +141,19 @@ export function cameraTrack(log: TakeLog): CameraTrack {
|
|
|
112
141
|
},
|
|
113
142
|
]
|
|
114
143
|
}
|
|
144
|
+
if (event.kind === 'drag') {
|
|
145
|
+
// One aim over both ends, held to the release, so a short drag stays
|
|
146
|
+
// framed whole and a long one asks for a depth under MIN_ZOOM.
|
|
147
|
+
const box = union(event.box, event.drop)
|
|
148
|
+
return [
|
|
149
|
+
{
|
|
150
|
+
t: event.t,
|
|
151
|
+
until: event.t + event.ms,
|
|
152
|
+
at: { x: box.x + box.width / 2, y: box.y + box.height / 2 },
|
|
153
|
+
depth: depthFor(box),
|
|
154
|
+
},
|
|
155
|
+
]
|
|
156
|
+
}
|
|
115
157
|
if (event.kind === 'type') {
|
|
116
158
|
const box = event.box
|
|
117
159
|
return [
|
|
@@ -129,9 +171,26 @@ export function cameraTrack(log: TakeLog): CameraTrack {
|
|
|
129
171
|
.sort((a, b) => a.t - b.t)
|
|
130
172
|
|
|
131
173
|
const scrolls = log.events.filter((event) => event.kind === 'scroll')
|
|
132
|
-
// Read off every
|
|
174
|
+
// Read off every press rather than off the aims, which drop a click too large
|
|
133
175
|
// to zoom and add a typing burst that is no press.
|
|
134
|
-
const
|
|
176
|
+
const presses = log.events.flatMap((event) =>
|
|
177
|
+
event.kind === 'click'
|
|
178
|
+
? [{ t: event.t, release: event.t }]
|
|
179
|
+
: event.kind === 'drag'
|
|
180
|
+
? [{ t: event.t, release: event.t + event.ms }]
|
|
181
|
+
: [],
|
|
182
|
+
)
|
|
183
|
+
const ringAt = (ms: number): number | null => {
|
|
184
|
+
for (const press of presses) {
|
|
185
|
+
if (ms < press.t) continue
|
|
186
|
+
const grown = (ms - press.t) / RING_MS
|
|
187
|
+
if (ms <= press.release) return Math.min(GRAB_RING, grown)
|
|
188
|
+
const held = Math.min(GRAB_RING, (press.release - press.t) / RING_MS)
|
|
189
|
+
const ring = held + (ms - press.release) / RING_MS
|
|
190
|
+
if (ring <= 1) return ring
|
|
191
|
+
}
|
|
192
|
+
return null
|
|
193
|
+
}
|
|
135
194
|
const windows = zoomWindows(aims, scrolls)
|
|
136
195
|
|
|
137
196
|
const targetAt = (ms: number) => {
|
|
@@ -196,16 +255,14 @@ export function cameraTrack(log: TakeLog): CameraTrack {
|
|
|
196
255
|
cursor.y + half.y * EDGE,
|
|
197
256
|
)
|
|
198
257
|
|
|
199
|
-
const ring =
|
|
200
|
-
(click) => ms >= click.t && ms - click.t <= RING_MS,
|
|
201
|
-
)
|
|
258
|
+
const ring = ringAt(ms)
|
|
202
259
|
frames.push({
|
|
203
260
|
ms: round(ms, 2),
|
|
204
261
|
scale: round(scale, 4),
|
|
205
262
|
x: round(clamp(springs.x.value, half.x, width - half.x) - half.x, 2),
|
|
206
263
|
y: round(clamp(springs.y.value, half.y, height - half.y) - half.y, 2),
|
|
207
264
|
cursor: roundPoint(cursor),
|
|
208
|
-
ring: ring ? round(
|
|
265
|
+
ring: ring === null ? null : round(ring, 3),
|
|
209
266
|
})
|
|
210
267
|
}
|
|
211
268
|
|
|
@@ -217,6 +274,27 @@ export function cameraTrack(log: TakeLog): CameraTrack {
|
|
|
217
274
|
}
|
|
218
275
|
}
|
|
219
276
|
|
|
277
|
+
/** Reads a playback rate off a flag, which has to be a positive number. */
|
|
278
|
+
export function readRate(value: string): number | undefined {
|
|
279
|
+
const rate = Number(value)
|
|
280
|
+
return Number.isFinite(rate) && rate > 0 ? rate : undefined
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** The log as a clip played `rate` times faster shows it, every time divided. */
|
|
284
|
+
function atRate(log: TakeLog, rate: number): TakeLog {
|
|
285
|
+
if (rate === 1) return log
|
|
286
|
+
return {
|
|
287
|
+
...log,
|
|
288
|
+
frames: Math.round(log.frames / rate),
|
|
289
|
+
events: log.events.map((event): LogEvent => {
|
|
290
|
+
const t = event.t / rate
|
|
291
|
+
return 'ms' in event
|
|
292
|
+
? { ...event, t, ms: event.ms / rate }
|
|
293
|
+
: { ...event, t }
|
|
294
|
+
}),
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
220
298
|
/**
|
|
221
299
|
* Groups aims into zooms. One holds from its first aim to the last plus the
|
|
222
300
|
* hold, a scroll ends it since the content moves under the camera, and the next
|
|
@@ -295,6 +373,17 @@ function focusAt(
|
|
|
295
373
|
return focus
|
|
296
374
|
}
|
|
297
375
|
|
|
376
|
+
function union(a: Box, b: Box): Box {
|
|
377
|
+
const x = Math.min(a.x, b.x)
|
|
378
|
+
const y = Math.min(a.y, b.y)
|
|
379
|
+
return {
|
|
380
|
+
x,
|
|
381
|
+
y,
|
|
382
|
+
width: Math.max(a.x + a.width, b.x + b.width) - x,
|
|
383
|
+
height: Math.max(a.y + a.height, b.y + b.height) - y,
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
298
387
|
function clamp(value: number, low: number, high: number): number {
|
|
299
388
|
return Math.min(Math.max(value, low), high)
|
|
300
389
|
}
|
package/src/take/film.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { isBrowserMissing, isServerUnreachable } from '@/browser/engine'
|
|
|
4
4
|
import { cameraTrack } from '@/take/camera'
|
|
5
5
|
import { FfmpegMissing, requireFfmpeg } from '@/take/encode'
|
|
6
6
|
import {
|
|
7
|
+
CLIP_SIZES,
|
|
7
8
|
FPS,
|
|
8
9
|
LOG_VERSION,
|
|
9
10
|
type LogEvent,
|
|
@@ -60,6 +61,10 @@ export type TakeResult =
|
|
|
60
61
|
readonly reason: TakeRefusal
|
|
61
62
|
readonly message: string
|
|
62
63
|
readonly beat?: string
|
|
64
|
+
/** Why the refused beat had fallen back to a screencast, when it had. */
|
|
65
|
+
readonly fallback?: string
|
|
66
|
+
/** The page as the failing step met it, relative to the take's folder. */
|
|
67
|
+
readonly still?: string
|
|
63
68
|
}
|
|
64
69
|
|
|
65
70
|
export interface TakeRequest {
|
|
@@ -121,7 +126,15 @@ export async function take(request: TakeRequest): Promise<TakeResult> {
|
|
|
121
126
|
),
|
|
122
127
|
)
|
|
123
128
|
} catch (error) {
|
|
124
|
-
|
|
129
|
+
const cause = error instanceof FellBack ? error.error : error
|
|
130
|
+
const shot = cause instanceof TargetMissing ? cause.still : undefined
|
|
131
|
+
const still = shot ? join(dir, 'still.jpg') : undefined
|
|
132
|
+
if (shot && still) writeFileSync(join(out, still), shot)
|
|
133
|
+
return {
|
|
134
|
+
...failed(classify(cause), cause, beat.name),
|
|
135
|
+
...(error instanceof FellBack ? { fallback: error.fallback } : {}),
|
|
136
|
+
...(still ? { still } : {}),
|
|
137
|
+
}
|
|
125
138
|
}
|
|
126
139
|
}
|
|
127
140
|
|
|
@@ -159,7 +172,11 @@ async function filmBeat(
|
|
|
159
172
|
if (!(error instanceof StepperStalled)) throw error
|
|
160
173
|
route = 'screencast'
|
|
161
174
|
fallback = error.message
|
|
162
|
-
|
|
175
|
+
try {
|
|
176
|
+
filmed = await filmScreencast({ url, beat, clip: target })
|
|
177
|
+
} catch (cause) {
|
|
178
|
+
throw new FellBack(cause, fallback)
|
|
179
|
+
}
|
|
163
180
|
}
|
|
164
181
|
} else {
|
|
165
182
|
filmed = await filmScreencast({ url, beat, clip: target })
|
|
@@ -170,6 +187,8 @@ async function filmBeat(
|
|
|
170
187
|
viewport: VIEWPORT,
|
|
171
188
|
fps: FPS,
|
|
172
189
|
route,
|
|
190
|
+
clip: CLIP_SIZES[route],
|
|
191
|
+
...(beat.push === false ? { push: false as const } : {}),
|
|
173
192
|
frames: filmed.frames,
|
|
174
193
|
events: filmed.events,
|
|
175
194
|
}
|
|
@@ -196,6 +215,20 @@ async function filmBeat(
|
|
|
196
215
|
}
|
|
197
216
|
}
|
|
198
217
|
|
|
218
|
+
/**
|
|
219
|
+
* A beat that failed in the screencast it fell back to, carrying why it fell
|
|
220
|
+
* back, since the fallback loads the page a second time and a single-use link
|
|
221
|
+
* is spent by then.
|
|
222
|
+
*/
|
|
223
|
+
class FellBack extends Error {
|
|
224
|
+
constructor(
|
|
225
|
+
readonly error: unknown,
|
|
226
|
+
readonly fallback: string,
|
|
227
|
+
) {
|
|
228
|
+
super(error instanceof Error ? error.message : String(error))
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
199
232
|
function classify(error: unknown): TakeRefusal {
|
|
200
233
|
if (error instanceof TargetMissing) return 'target-missing'
|
|
201
234
|
if (error instanceof ShellMissing || isBrowserMissing(error)) {
|