@erclx/canon 5.14.1 → 5.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -2
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/draft-screencast/REQUIREMENT.md +2 -1
- package/claude/skills/draft-screencast/SKILL.md +14 -9
- package/claude/skills/read-frames/SKILL.md +3 -2
- package/claude/skills/record-screencast/REQUIREMENT.md +3 -0
- package/claude/skills/record-screencast/SKILL.md +28 -5
- package/docs/agents/commands.md +3 -0
- package/docs/agents/demo.md +34 -7
- package/docs/agents/migrate.md +1 -1
- package/docs/agents/sandbox.md +25 -0
- package/docs/target/migrations.md +1 -1
- package/docs/workflow/ai-workflow.md +2 -3
- package/docs/workflow/skill-map.md +1 -1
- package/governance/renames.toml +7 -5
- package/governance/rules/canon/630-requirements.md +1 -1
- package/package.json +5 -2
- package/src/canvas/client/app.tsx +85 -0
- package/src/canvas/client/main.tsx +62 -0
- package/src/canvas/client/pages-panel.tsx +123 -0
- package/src/canvas/client/shell.css +319 -0
- package/src/canvas/client/state.ts +186 -0
- package/src/canvas/client/surface.tsx +213 -0
- package/src/canvas/content.ts +326 -0
- package/src/canvas/index.html +14 -0
- package/src/canvas/server.ts +302 -0
- package/src/canvas/tokens.ts +71 -0
- package/src/claude/seeds.ts +0 -1
- package/src/cli.ts +2 -0
- package/src/commands/canvas.ts +325 -0
- package/src/commands/claude.ts +9 -14
- package/src/commands/demo.ts +6 -6
- package/src/commands/sandbox.ts +108 -0
- package/src/context/classify/prompts.ts +1 -1
- package/src/context/folders.ts +0 -1
- package/src/demo/compile.ts +8 -4
- package/src/demo/container.ts +1 -1
- package/src/gov/renames.ts +3 -2
- package/src/help.ts +1 -0
- package/src/record-root.ts +1 -0
- package/src/sandbox/equivalence.ts +847 -0
- package/src/serve/static.ts +25 -11
- package/src/surface-root.ts +0 -1
- package/standards/architecture.md +1 -1
- package/standards/context.md +1 -1
- package/standards/design.md +1 -1
- package/standards/index.md +0 -1
- package/standards/requirements.md +4 -6
- package/standards/wireframes.md +2 -2
- package/tooling/claude/reference.md +0 -1
- package/tooling/claude/seeds/canon/REQUIREMENTS.md +0 -2
- package/governance/rules/canon/645-decisions.md +0 -12
- package/standards/decisions.md +0 -105
- package/tooling/claude/seeds/canon/decisions/index.md +0 -8
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
import { statSync } from 'node:fs'
|
|
2
|
+
import { resolve } from 'node:path'
|
|
3
|
+
import type { Command } from 'commander'
|
|
4
|
+
import {
|
|
5
|
+
addFrame,
|
|
6
|
+
addPage,
|
|
7
|
+
type ContentRefused,
|
|
8
|
+
canvasDir,
|
|
9
|
+
DEFAULT_FRAME,
|
|
10
|
+
listPages,
|
|
11
|
+
renamePage,
|
|
12
|
+
} from '@/canvas/content'
|
|
13
|
+
import { CANVAS_PORT, startCanvas } from '@/canvas/server'
|
|
14
|
+
import { resolveFrameTokens } from '@/canvas/tokens'
|
|
15
|
+
import { displayPath, parsePort, waitForInterrupt } from '@/serve/report'
|
|
16
|
+
import {
|
|
17
|
+
intro,
|
|
18
|
+
logAdd,
|
|
19
|
+
logError,
|
|
20
|
+
logInfo,
|
|
21
|
+
logStep,
|
|
22
|
+
logWarn,
|
|
23
|
+
outro,
|
|
24
|
+
plural,
|
|
25
|
+
} from '@/ui'
|
|
26
|
+
import { mainWorktreeRoot } from '@/worktree'
|
|
27
|
+
|
|
28
|
+
const BANNER = 'canon canvas'
|
|
29
|
+
|
|
30
|
+
interface RootOptions {
|
|
31
|
+
readonly root?: string
|
|
32
|
+
readonly json?: boolean
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface Refused {
|
|
36
|
+
readonly ok: false
|
|
37
|
+
readonly reason: ContentRefused['reason'] | 'no-root'
|
|
38
|
+
readonly detail: string
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
type RootOutcome = { readonly ok: true; readonly root: string } | Refused
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Refuses a root that is not a directory rather than letting the first write
|
|
45
|
+
* scaffold `.canon/canvas/` under a mistyped path, the way `canon serve`
|
|
46
|
+
* refuses a directory it cannot find.
|
|
47
|
+
*/
|
|
48
|
+
async function resolveRoot(opts: RootOptions): Promise<RootOutcome> {
|
|
49
|
+
const root = opts.root
|
|
50
|
+
? resolve(process.cwd(), opts.root)
|
|
51
|
+
: await mainWorktreeRoot()
|
|
52
|
+
if (!statSync(root, { throwIfNoEntry: false })?.isDirectory()) {
|
|
53
|
+
return {
|
|
54
|
+
ok: false,
|
|
55
|
+
reason: 'no-root',
|
|
56
|
+
detail: `${root} is not a directory`,
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return { ok: true, root }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function writeJson(record: unknown): void {
|
|
63
|
+
process.stdout.write(`${JSON.stringify(record)}\n`)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Every verb's refusal, framed on stderr and mirrored on stdout under --json. */
|
|
67
|
+
function refuse(refusal: Refused, emitJson: boolean): number {
|
|
68
|
+
if (emitJson) writeJson(refusal)
|
|
69
|
+
intro(BANNER)
|
|
70
|
+
logError(refusal.detail)
|
|
71
|
+
outro()
|
|
72
|
+
return 1
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Parses a size flag. NaN for a value that is not a positive integer, which
|
|
77
|
+
* the content module then refuses as an invalid size.
|
|
78
|
+
*/
|
|
79
|
+
function parseSize(raw: string | undefined, fallback: number): number {
|
|
80
|
+
if (raw === undefined) return fallback
|
|
81
|
+
const value = Number(raw)
|
|
82
|
+
return Number.isInteger(value) && value > 0 ? value : Number.NaN
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function withRoot(command: Command): Command {
|
|
86
|
+
return command
|
|
87
|
+
.helpOption('-h, --help', 'Show this help message')
|
|
88
|
+
.option('--root <path>', 'Project root, defaulting to the main worktree')
|
|
89
|
+
.option('--json', 'Emit a machine-readable record on stdout')
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function register(program: Command): void {
|
|
93
|
+
const canvas = program
|
|
94
|
+
.command('canvas')
|
|
95
|
+
.helpOption('-h, --help', 'Show this help message')
|
|
96
|
+
.description(
|
|
97
|
+
'Local canvas of pages and HTML frames (serve, list, page, frame)',
|
|
98
|
+
)
|
|
99
|
+
.addHelpText(
|
|
100
|
+
'after',
|
|
101
|
+
[
|
|
102
|
+
'',
|
|
103
|
+
'A page is a folder under .canon/canvas/, a frame is one HTML file in',
|
|
104
|
+
'it, and layout.json beside them holds each frame box. Edit a frame by',
|
|
105
|
+
'editing its file, and an open canvas reloads that frame.',
|
|
106
|
+
'',
|
|
107
|
+
'Examples:',
|
|
108
|
+
' canon canvas serve',
|
|
109
|
+
' canon canvas page add drafts',
|
|
110
|
+
' canon canvas frame add drafts hero --width 1440 --height 900',
|
|
111
|
+
' canon canvas list --json',
|
|
112
|
+
'',
|
|
113
|
+
].join('\n'),
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
withRoot(
|
|
117
|
+
canvas
|
|
118
|
+
.command('serve')
|
|
119
|
+
.description('Serve the canvas on localhost and print the link')
|
|
120
|
+
.option('--port <number>', `Port to try first, default ${CANVAS_PORT}`),
|
|
121
|
+
)
|
|
122
|
+
.addHelpText(
|
|
123
|
+
'after',
|
|
124
|
+
[
|
|
125
|
+
'',
|
|
126
|
+
'Exit codes:',
|
|
127
|
+
' 0 the server stopped after running',
|
|
128
|
+
' 1 refused, with the reason on stderr or in the JSON record',
|
|
129
|
+
'',
|
|
130
|
+
'The server binds 127.0.0.1 and nothing else. It runs until',
|
|
131
|
+
'interrupted, so a session wanting the link starts it in the',
|
|
132
|
+
'background and reads the record off stdout.',
|
|
133
|
+
'',
|
|
134
|
+
].join('\n'),
|
|
135
|
+
)
|
|
136
|
+
.action(async (opts: RootOptions & { port?: string }) => {
|
|
137
|
+
process.exitCode = await runServe(opts)
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
withRoot(
|
|
141
|
+
canvas.command('list').description('List pages and the frames on each'),
|
|
142
|
+
).action(async (opts: RootOptions) => {
|
|
143
|
+
const resolved = await resolveRoot(opts)
|
|
144
|
+
if (!resolved.ok) {
|
|
145
|
+
process.exitCode = refuse(resolved, opts.json ?? false)
|
|
146
|
+
return
|
|
147
|
+
}
|
|
148
|
+
const { root } = resolved
|
|
149
|
+
const pages = listPages(root)
|
|
150
|
+
if (opts.json) {
|
|
151
|
+
writeJson({ ok: true, root, content: canvasDir(root), pages })
|
|
152
|
+
}
|
|
153
|
+
intro(BANNER)
|
|
154
|
+
if (pages.length === 0) {
|
|
155
|
+
logStep('No pages')
|
|
156
|
+
logInfo('canon canvas page add <name> adds one')
|
|
157
|
+
}
|
|
158
|
+
for (const page of pages) {
|
|
159
|
+
logStep(`${page.name} (${plural(page.frames.length, 'frame')})`)
|
|
160
|
+
for (const frame of page.frames) {
|
|
161
|
+
logInfo(`${frame.name} ${frame.width} × ${frame.height}`)
|
|
162
|
+
}
|
|
163
|
+
if (page.layoutIssue === 'malformed') {
|
|
164
|
+
logWarn('layout.json does not parse, so frames sit in a default row')
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
outro()
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
const page = canvas
|
|
171
|
+
.command('page')
|
|
172
|
+
.helpOption('-h, --help', 'Show this help message')
|
|
173
|
+
.description('Add or rename a page')
|
|
174
|
+
|
|
175
|
+
withRoot(
|
|
176
|
+
page
|
|
177
|
+
.command('add')
|
|
178
|
+
.description('Add a page')
|
|
179
|
+
.argument('<name>', 'Page name, one path segment'),
|
|
180
|
+
).action(async (name: string, opts: RootOptions) => {
|
|
181
|
+
const resolved = await resolveRoot(opts)
|
|
182
|
+
const outcome = resolved.ok ? addPage(resolved.root, name) : resolved
|
|
183
|
+
if (!outcome.ok) {
|
|
184
|
+
process.exitCode = refuse(outcome, opts.json ?? false)
|
|
185
|
+
return
|
|
186
|
+
}
|
|
187
|
+
if (opts.json) writeJson(outcome)
|
|
188
|
+
intro(BANNER)
|
|
189
|
+
logAdd(`page ${outcome.page}`)
|
|
190
|
+
outro()
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
withRoot(
|
|
194
|
+
page
|
|
195
|
+
.command('rename')
|
|
196
|
+
.description('Rename a page, keeping its frames and layout')
|
|
197
|
+
.argument('<from>', 'Current page name')
|
|
198
|
+
.argument('<to>', 'New page name'),
|
|
199
|
+
).action(async (from: string, to: string, opts: RootOptions) => {
|
|
200
|
+
const resolved = await resolveRoot(opts)
|
|
201
|
+
const outcome = resolved.ok ? renamePage(resolved.root, from, to) : resolved
|
|
202
|
+
if (!outcome.ok) {
|
|
203
|
+
process.exitCode = refuse(outcome, opts.json ?? false)
|
|
204
|
+
return
|
|
205
|
+
}
|
|
206
|
+
if (opts.json) writeJson({ ...outcome, from })
|
|
207
|
+
intro(BANNER)
|
|
208
|
+
logAdd(`page ${from} → ${outcome.page}`)
|
|
209
|
+
outro()
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
const frame = canvas
|
|
213
|
+
.command('frame')
|
|
214
|
+
.helpOption('-h, --help', 'Show this help message')
|
|
215
|
+
.description('Add a frame to a page')
|
|
216
|
+
|
|
217
|
+
withRoot(
|
|
218
|
+
frame
|
|
219
|
+
.command('add')
|
|
220
|
+
.description('Add a frame as a new HTML file, placed after the others')
|
|
221
|
+
.argument('<page>', 'Page to add the frame to')
|
|
222
|
+
.argument('<name>', 'Frame name, which becomes <name>.html')
|
|
223
|
+
.option('--width <px>', `Frame width, default ${DEFAULT_FRAME.width}`)
|
|
224
|
+
.option('--height <px>', `Frame height, default ${DEFAULT_FRAME.height}`),
|
|
225
|
+
).action(
|
|
226
|
+
async (
|
|
227
|
+
pageName: string,
|
|
228
|
+
name: string,
|
|
229
|
+
opts: RootOptions & { width?: string; height?: string },
|
|
230
|
+
) => {
|
|
231
|
+
const resolved = await resolveRoot(opts)
|
|
232
|
+
if (!resolved.ok) {
|
|
233
|
+
process.exitCode = refuse(resolved, opts.json ?? false)
|
|
234
|
+
return
|
|
235
|
+
}
|
|
236
|
+
const { root } = resolved
|
|
237
|
+
const outcome = addFrame(root, pageName, name, {
|
|
238
|
+
width: parseSize(opts.width, DEFAULT_FRAME.width),
|
|
239
|
+
height: parseSize(opts.height, DEFAULT_FRAME.height),
|
|
240
|
+
})
|
|
241
|
+
if (!outcome.ok) {
|
|
242
|
+
process.exitCode = refuse(outcome, opts.json ?? false)
|
|
243
|
+
return
|
|
244
|
+
}
|
|
245
|
+
if (opts.json) {
|
|
246
|
+
writeJson({
|
|
247
|
+
...outcome,
|
|
248
|
+
path: resolve(canvasDir(root), outcome.page, outcome.file),
|
|
249
|
+
})
|
|
250
|
+
}
|
|
251
|
+
intro(BANNER)
|
|
252
|
+
logAdd(`${outcome.page}/${outcome.file}`)
|
|
253
|
+
logInfo(
|
|
254
|
+
`${outcome.box.width} × ${outcome.box.height} at ${outcome.box.x}, ${outcome.box.y}`,
|
|
255
|
+
)
|
|
256
|
+
outro()
|
|
257
|
+
},
|
|
258
|
+
)
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
async function runServe(
|
|
262
|
+
opts: RootOptions & { port?: string },
|
|
263
|
+
): Promise<number> {
|
|
264
|
+
const emitJson = opts.json ?? false
|
|
265
|
+
const port = opts.port === undefined ? CANVAS_PORT : parsePort(opts.port)
|
|
266
|
+
if (port === undefined) {
|
|
267
|
+
return refuseServe('no-port', `${opts.port} is not a port`, emitJson)
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const resolved = await resolveRoot(opts)
|
|
271
|
+
if (!resolved.ok) {
|
|
272
|
+
return refuseServe(resolved.reason, resolved.detail, emitJson)
|
|
273
|
+
}
|
|
274
|
+
const { root } = resolved
|
|
275
|
+
/*
|
|
276
|
+
* Imported here rather than at the top, so a command module the test runner
|
|
277
|
+
* loads never asks it for an HTML module it cannot parse.
|
|
278
|
+
*/
|
|
279
|
+
const { default: shell } = await import('@/canvas/index.html')
|
|
280
|
+
const outcome = startCanvas(root, { port, shell })
|
|
281
|
+
if (!outcome.ok) {
|
|
282
|
+
return refuseServe(outcome.reason, outcome.detail, emitJson)
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const tokens = resolveFrameTokens(root)
|
|
286
|
+
const { css: _css, ...tokenSource } = tokens
|
|
287
|
+
if (emitJson) {
|
|
288
|
+
writeJson({
|
|
289
|
+
ok: true,
|
|
290
|
+
root: outcome.root,
|
|
291
|
+
content: outcome.content,
|
|
292
|
+
host: outcome.host,
|
|
293
|
+
port: outcome.port,
|
|
294
|
+
url: outcome.url,
|
|
295
|
+
tokens: tokenSource,
|
|
296
|
+
})
|
|
297
|
+
}
|
|
298
|
+
intro(BANNER)
|
|
299
|
+
logStep('Serving')
|
|
300
|
+
logInfo(displayPath(outcome.content))
|
|
301
|
+
logStep('Open')
|
|
302
|
+
logInfo(outcome.url)
|
|
303
|
+
logStep('Tokens')
|
|
304
|
+
if (tokens.source === 'none') logWarn(tokens.notice)
|
|
305
|
+
else if (tokens.source === 'installed') {
|
|
306
|
+
for (const file of tokens.files) logInfo(file)
|
|
307
|
+
} else logInfo('this toolkit’s own token module')
|
|
308
|
+
logStep('Stop')
|
|
309
|
+
logInfo('Ctrl-C')
|
|
310
|
+
|
|
311
|
+
await waitForInterrupt(outcome.stop)
|
|
312
|
+
return 0
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function refuseServe(
|
|
316
|
+
reason: string,
|
|
317
|
+
detail: string,
|
|
318
|
+
emitJson: boolean,
|
|
319
|
+
): number {
|
|
320
|
+
if (emitJson) writeJson({ ok: false, reason, detail })
|
|
321
|
+
intro(BANNER)
|
|
322
|
+
logError(detail)
|
|
323
|
+
outro()
|
|
324
|
+
return 1
|
|
325
|
+
}
|
package/src/commands/claude.ts
CHANGED
|
@@ -153,21 +153,16 @@ const SEEDED_FILES: readonly string[] = [
|
|
|
153
153
|
'REQUIREMENTS.md',
|
|
154
154
|
'DESIGN.md',
|
|
155
155
|
]
|
|
156
|
-
const SEEDED_DIRS: readonly string[] = [
|
|
157
|
-
'decisions',
|
|
158
|
-
'memory',
|
|
159
|
-
'tasks',
|
|
160
|
-
'wireframes',
|
|
161
|
-
]
|
|
156
|
+
const SEEDED_DIRS: readonly string[] = ['memory', 'tasks', 'wireframes']
|
|
162
157
|
|
|
163
158
|
/**
|
|
164
159
|
* Where a `SEEDED_DIRS` entry resolves for the sync presence check.
|
|
165
160
|
*
|
|
166
|
-
* `wireframes`
|
|
167
|
-
*
|
|
168
|
-
*
|
|
169
|
-
*
|
|
170
|
-
*
|
|
161
|
+
* `wireframes` is a tracked surface entry, resolved under `canon/` ahead of
|
|
162
|
+
* `.claude/`, while `memory` and `tasks` are session records resolved under
|
|
163
|
+
* `.canon/` ahead of `.claude/`. Routing every name through `recordDir`
|
|
164
|
+
* reported a migrated target's `canon/wireframes/` as missing, since that
|
|
165
|
+
* resolver never checks `canon/`.
|
|
171
166
|
*/
|
|
172
167
|
export function seededDirPath(resolved: string, name: string): string {
|
|
173
168
|
return SURFACE_ENTRIES.includes(name)
|
|
@@ -605,9 +600,9 @@ async function runSync(target: string): Promise<number> {
|
|
|
605
600
|
|
|
606
601
|
// A record folder resolves at either root, so a migrated target is reported as
|
|
607
602
|
// seeded rather than sent to `canon claude init` to re-create records it
|
|
608
|
-
// already holds. `wireframes`
|
|
609
|
-
//
|
|
610
|
-
//
|
|
603
|
+
// already holds. `wireframes` is a tracked surface entry instead, resolved
|
|
604
|
+
// through `surfaceDir`, since a migrated target holds it at `canon/` rather
|
|
605
|
+
// than under either record root.
|
|
611
606
|
logStep('Seeded')
|
|
612
607
|
for (const name of SEEDED_FILES) {
|
|
613
608
|
if (existsSync(join(resolved, '.claude', name))) logInfo(name)
|
package/src/commands/demo.ts
CHANGED
|
@@ -73,7 +73,7 @@ export function register(program: Command): void {
|
|
|
73
73
|
' 1 refused, with the reason on stderr',
|
|
74
74
|
'',
|
|
75
75
|
'Examples:',
|
|
76
|
-
' canon demo compile
|
|
76
|
+
' canon demo compile demos/inline-edit/beats.md --slug inline-edit',
|
|
77
77
|
' canon demo compile draft.md --out demos --force',
|
|
78
78
|
'',
|
|
79
79
|
].join('\n'),
|
|
@@ -114,8 +114,8 @@ export function register(program: Command): void {
|
|
|
114
114
|
' 1 refused, with the reason on stderr',
|
|
115
115
|
'',
|
|
116
116
|
'Examples:',
|
|
117
|
-
' canon demo run demos/inline-edit.json',
|
|
118
|
-
' canon demo run demos/inline-edit.json --cursor ~/cursors/theme',
|
|
117
|
+
' canon demo run demos/inline-edit/plan.json',
|
|
118
|
+
' canon demo run demos/inline-edit/plan.json --cursor ~/cursors/theme',
|
|
119
119
|
'',
|
|
120
120
|
].join('\n'),
|
|
121
121
|
)
|
|
@@ -146,8 +146,8 @@ export function register(program: Command): void {
|
|
|
146
146
|
' 1 refused, with the reason on stderr',
|
|
147
147
|
'',
|
|
148
148
|
'Examples:',
|
|
149
|
-
' canon demo frames demos/inline-edit.webm',
|
|
150
|
-
' canon demo frames demos/inline-edit.webm --fps 2 --out frames',
|
|
149
|
+
' canon demo frames demos/inline-edit/take/inline-edit.webm',
|
|
150
|
+
' canon demo frames demos/inline-edit/take/inline-edit.webm --fps 2 --out frames',
|
|
151
151
|
'',
|
|
152
152
|
].join('\n'),
|
|
153
153
|
)
|
|
@@ -182,7 +182,7 @@ function runCompile(draftPath: string, opts: CompileOptions): number {
|
|
|
182
182
|
}
|
|
183
183
|
|
|
184
184
|
const slug = opts.slug ?? basename(source, extname(source))
|
|
185
|
-
const target = resolve(process.cwd(), opts.out,
|
|
185
|
+
const target = resolve(process.cwd(), opts.out, slug, 'plan.json')
|
|
186
186
|
|
|
187
187
|
if (existsSync(target) && !opts.force) {
|
|
188
188
|
logStep('Plan')
|
package/src/commands/sandbox.ts
CHANGED
|
@@ -13,6 +13,11 @@ import {
|
|
|
13
13
|
coveragePercent,
|
|
14
14
|
type CoverageReport,
|
|
15
15
|
} from '@/sandbox/coverage'
|
|
16
|
+
import {
|
|
17
|
+
equivalenceExitCode,
|
|
18
|
+
runEquivalence as collectEquivalence,
|
|
19
|
+
type EquivalenceRecord,
|
|
20
|
+
} from '@/sandbox/equivalence'
|
|
16
21
|
import {
|
|
17
22
|
expectFilePath,
|
|
18
23
|
parseTarget,
|
|
@@ -338,6 +343,71 @@ function runCoverage(options: CoverageOptions): void {
|
|
|
338
343
|
process.exitCode = wrongExemption || rolloutIncomplete ? 1 : 0
|
|
339
344
|
}
|
|
340
345
|
|
|
346
|
+
interface EquivalenceOptions {
|
|
347
|
+
readonly base: string
|
|
348
|
+
readonly includeAnchor?: boolean
|
|
349
|
+
readonly out?: string
|
|
350
|
+
readonly masks: boolean
|
|
351
|
+
readonly json?: boolean
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Provisions every arm on a base ref and on this checkout, then names what
|
|
356
|
+
* differs. The record is the only thing on stdout, and a red arm that is red the
|
|
357
|
+
* same way on both sides is reported without failing the run, since it proves
|
|
358
|
+
* nothing either way.
|
|
359
|
+
*/
|
|
360
|
+
async function runEquivalence(
|
|
361
|
+
targets: string[],
|
|
362
|
+
options: EquivalenceOptions,
|
|
363
|
+
): Promise<void> {
|
|
364
|
+
if (reportAbsentScenarioTree()) return
|
|
365
|
+
|
|
366
|
+
intro('canon sandbox equivalence')
|
|
367
|
+
|
|
368
|
+
let record: EquivalenceRecord
|
|
369
|
+
try {
|
|
370
|
+
record = await collectEquivalence({
|
|
371
|
+
root: PROJECT_ROOT,
|
|
372
|
+
targets,
|
|
373
|
+
base: options.base,
|
|
374
|
+
includeAnchor: options.includeAnchor === true,
|
|
375
|
+
useMasks: options.masks,
|
|
376
|
+
out: options.out,
|
|
377
|
+
})
|
|
378
|
+
} catch (error) {
|
|
379
|
+
const reason = error instanceof Error ? error.message : String(error)
|
|
380
|
+
logError(`Cannot provision the baseline ${options.base}: ${reason}`)
|
|
381
|
+
outro()
|
|
382
|
+
process.exitCode = 1
|
|
383
|
+
|
|
384
|
+
return
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
logStep(`Base ${record.base} (${record.baseCommit.slice(0, 8)})`)
|
|
388
|
+
logInfo(`canon ${record.canonVersion}`)
|
|
389
|
+
|
|
390
|
+
for (const arm of record.arms) {
|
|
391
|
+
if (arm.state === 'identical' || arm.state === 'skipped-anchor') continue
|
|
392
|
+
const detail = arm.differs.length > 0 ? `: ${arm.differs.join(', ')}` : ''
|
|
393
|
+
if (arm.state === 'differs') logRemove(`${arm.arm} differs${detail}`)
|
|
394
|
+
else logWarn(`${arm.arm} is ${arm.state}`)
|
|
395
|
+
}
|
|
396
|
+
for (const line of record.masksApplied) logInfo(`masked ${line}`)
|
|
397
|
+
for (const error of record.errors) logError(error)
|
|
398
|
+
|
|
399
|
+
const { counts } = record
|
|
400
|
+
logStep('Equivalence')
|
|
401
|
+
const summary = `${counts.identical} identical, ${counts.differs} differs, ${counts['red-on-base']} red-on-base, ${counts['skipped-anchor']} skipped-anchor`
|
|
402
|
+
if (equivalenceExitCode(record) === 0) logInfo(summary)
|
|
403
|
+
else logWarn(summary)
|
|
404
|
+
|
|
405
|
+
if (options.json === true) process.stdout.write(`${JSON.stringify(record)}\n`)
|
|
406
|
+
|
|
407
|
+
outro()
|
|
408
|
+
process.exitCode = equivalenceExitCode(record)
|
|
409
|
+
}
|
|
410
|
+
|
|
341
411
|
function runCheck(
|
|
342
412
|
target: string,
|
|
343
413
|
arm: string | undefined,
|
|
@@ -482,4 +552,42 @@ export function register(program: Command): void {
|
|
|
482
552
|
.action((options: CoverageOptions) => {
|
|
483
553
|
runCoverage(options)
|
|
484
554
|
})
|
|
555
|
+
|
|
556
|
+
sandbox
|
|
557
|
+
.command('equivalence')
|
|
558
|
+
.description(
|
|
559
|
+
'Provision every arm on a base ref and on this checkout, and diff the trees',
|
|
560
|
+
)
|
|
561
|
+
.argument(
|
|
562
|
+
'[target...]',
|
|
563
|
+
'Category or <category>:<command>, defaulting to every category',
|
|
564
|
+
)
|
|
565
|
+
.helpOption('-h, --help', 'Show this help message')
|
|
566
|
+
.option('--base <ref>', 'Ref the baseline is provisioned from', 'main')
|
|
567
|
+
.option(
|
|
568
|
+
'--include-anchor',
|
|
569
|
+
'Also run the arms that push to the shared GitHub anchor, one at a time (attended)',
|
|
570
|
+
)
|
|
571
|
+
.option('--out <dir>', 'Keep the provisioned trees and logs in this folder')
|
|
572
|
+
.option(
|
|
573
|
+
'--no-masks',
|
|
574
|
+
'Compare every path, listing what each mask would hide',
|
|
575
|
+
)
|
|
576
|
+
.option('--json', 'Emit the record as JSON on stdout')
|
|
577
|
+
.addHelpText(
|
|
578
|
+
'after',
|
|
579
|
+
[
|
|
580
|
+
'',
|
|
581
|
+
'Examples:',
|
|
582
|
+
' canon sandbox equivalence claude --json',
|
|
583
|
+
' canon sandbox equivalence claude:plan-feature --base origin/main',
|
|
584
|
+
'',
|
|
585
|
+
'Provisions offline only and never runs sandbox/run.sh or a claude binary.',
|
|
586
|
+
'Exit codes: 0 when every compared arm is identical or red the same way',
|
|
587
|
+
'on both sides, 1 on any difference, exit mismatch, or enumeration error.',
|
|
588
|
+
].join('\n'),
|
|
589
|
+
)
|
|
590
|
+
.action((targets: string[], options: EquivalenceOptions) =>
|
|
591
|
+
runEquivalence(targets, options),
|
|
592
|
+
)
|
|
485
593
|
}
|
|
@@ -20,7 +20,7 @@ File types and what they hold:
|
|
|
20
20
|
Verdicts (pick exactly one):
|
|
21
21
|
- KEEP: states current design, a rule, copy, a decision with its rejected alternative, or a live gotcha, on the right surface.
|
|
22
22
|
- REPLACE: restates a fact or figure the existing section already carries ("moved again", "now", "the count moved", a newer number appended after an older one). The old statement should be rewritten in place, not appended to.
|
|
23
|
-
- HISTORY: narrates how things got here: which branch/PR changed what, "closed on", "did not survive", review-pass stories, pick-by-pick rounds. The current state is what belongs; the trail goes to the PR
|
|
23
|
+
- HISTORY: narrates how things got here: which branch/PR changed what, "closed on", "did not survive", review-pass stories, pick-by-pick rounds. The current state is what belongs; the trail goes to the PR.
|
|
24
24
|
- MOVE: correct content on the wrong surface (domain mechanism in ARCHITECTURE.md, implementation detail in a wireframe).
|
|
25
25
|
|
|
26
26
|
Return only JSON: {"verdict": "...", "quote": "<the shortest added phrase that decided it>", "reason": "<one sentence>"}
|
package/src/context/folders.ts
CHANGED
package/src/demo/compile.ts
CHANGED
|
@@ -134,8 +134,8 @@ export function compilePlan(draft: Draft, options: CompileOptions): DemoPlan {
|
|
|
134
134
|
url: '',
|
|
135
135
|
viewport: VIEWPORT,
|
|
136
136
|
output: {
|
|
137
|
-
video: `${options.outDir}/${options.slug}.webm`,
|
|
138
|
-
still: `${options.outDir}/${options.slug}.png`,
|
|
137
|
+
video: `${options.outDir}/${options.slug}/take/${options.slug}.webm`,
|
|
138
|
+
still: `${options.outDir}/${options.slug}/take/${options.slug}.png`,
|
|
139
139
|
},
|
|
140
140
|
pointer: { travelMs: POINTER_TRAVEL_MS, typeDelayMs: TYPE_DELAY_MS },
|
|
141
141
|
annotations: ANNOTATIONS,
|
|
@@ -252,8 +252,12 @@ export function parsePlan(text: string): PlanParse {
|
|
|
252
252
|
},
|
|
253
253
|
...(colorScheme.value && { colorScheme: colorScheme.value }),
|
|
254
254
|
output: {
|
|
255
|
-
video:
|
|
256
|
-
|
|
255
|
+
video:
|
|
256
|
+
asText(output.video) ||
|
|
257
|
+
`demos/${slug || 'demo'}/take/${slug || 'demo'}.webm`,
|
|
258
|
+
still:
|
|
259
|
+
asText(output.still) ||
|
|
260
|
+
`demos/${slug || 'demo'}/take/${slug || 'demo'}.png`,
|
|
257
261
|
},
|
|
258
262
|
pointer: {
|
|
259
263
|
travelMs: asNumber(pointer.travelMs, POINTER_TRAVEL_MS),
|
package/src/demo/container.ts
CHANGED
|
@@ -135,7 +135,7 @@ export async function convertToGif(
|
|
|
135
135
|
|
|
136
136
|
/**
|
|
137
137
|
* Written beside the video by default, so frames fall under the same
|
|
138
|
-
* `
|
|
138
|
+
* `take` folder gitignore entry the still already uses with no new rule
|
|
139
139
|
* needed. One frame a second by default, matched to the seconds-to-tens-of-
|
|
140
140
|
* seconds length a tuned recording runs, without a smarter sampling strategy
|
|
141
141
|
* nobody has asked for.
|
package/src/gov/renames.ts
CHANGED
|
@@ -34,8 +34,9 @@ export function loadRenames(root: string): Renames {
|
|
|
34
34
|
|
|
35
35
|
/**
|
|
36
36
|
* Follows a declared rename to the name the toolkit ships the rule under now,
|
|
37
|
-
* through every later rename of that name. The ledger is
|
|
38
|
-
* target several releases behind still reaches the
|
|
37
|
+
* through every later rename of that name. The ledger is pruned only once a
|
|
38
|
+
* rule is retired, so a target several releases behind still reaches the
|
|
39
|
+
* current name of every rule that still ships.
|
|
39
40
|
*
|
|
40
41
|
* Returns nothing when the chain ends on a name with no source, or loops,
|
|
41
42
|
* since installing a guess is worse than the retire the caller falls back to.
|
package/src/help.ts
CHANGED
|
@@ -45,6 +45,7 @@ export const COMMAND_GROUPS: { title: string; rows: CommandRow[] }[] = [
|
|
|
45
45
|
['drive <url> <run>', 'Walk a page through interactions, measure each'],
|
|
46
46
|
['transcripts <url>', 'Fetch a YouTube transcript with frontmatter'],
|
|
47
47
|
['teach [cmd]', 'Learning workspaces (list, open, resource, glossary)'],
|
|
48
|
+
['canvas [cmd]', 'Local canvas of pages and frames (serve, list)'],
|
|
48
49
|
],
|
|
49
50
|
},
|
|
50
51
|
{
|