@erclx/canon 5.27.0 → 5.29.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/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/draft-ready/SKILL.md +3 -1
- package/claude/skills/git-pr/references/evidence.md +1 -1
- package/claude/skills/review-address/REQUIREMENT.md +4 -2
- package/claude/skills/review-address/SKILL.md +16 -7
- package/claude/skills/review-address/references/rebase-conflicts.md +4 -2
- package/claude/skills/review-craft/references/ui.md +4 -4
- package/claude/skills/review-ui/REQUIREMENT.md +2 -2
- package/claude/skills/review-ui/SKILL.md +13 -11
- package/claude/skills/review-ui/references/driving.md +8 -8
- package/claude/skills/review-ui/references/post.md +19 -7
- package/claude/skills/review-ui/references/tick.md +22 -0
- package/claude/skills/role-orchestrator/REQUIREMENT.md +15 -15
- package/claude/skills/role-orchestrator/SKILL.md +24 -26
- package/claude/skills/role-orchestrator/references/orchestrator-dispatch.md +18 -16
- package/claude/skills/role-orchestrator/references/orchestrator-handback.md +12 -2
- package/claude/skills/role-orchestrator/references/orchestrator-inbound.md +1 -1
- package/claude/skills/role-orchestrator/references/orchestrator-launch.md +1 -1
- package/claude/skills/role-orchestrator/references/orchestrator-output.md +4 -4
- package/claude/skills/role-orchestrator/references/orchestrator-parked.md +5 -5
- package/claude/skills/role-orchestrator/references/orchestrator-poll.md +11 -2
- package/claude/skills/role-orchestrator/references/orchestrator-refill.md +6 -6
- package/claude/skills/role-orchestrator/references/orchestrator-sweep.md +2 -2
- package/claude/skills/role-orchestrator/scripts/poll.ts +304 -10
- package/claude/skills/role-planner/REQUIREMENT.md +1 -1
- package/claude/skills/role-planner/SKILL.md +2 -2
- package/claude/skills/role-reviewer/SKILL.md +1 -1
- package/claude/skills/role-worker/SKILL.md +3 -2
- package/claude/skills/ui-checklist/SKILL.md +1 -1
- package/docs/agents/canvas.md +12 -4
- package/docs/agents/gate.md +3 -1
- package/docs/agents/index.md +2 -1
- package/docs/agents/pr-evidence.md +15 -14
- package/docs/agents/pr-tick.md +72 -0
- package/docs/agents/sandbox.md +2 -2
- package/docs/agents/tasks-plans.md +1 -1
- package/docs/agents/tasks-validate.md +18 -18
- package/docs/agents/tasks.md +1 -1
- package/docs/workflow/operating-model.md +10 -15
- package/docs/workflow/parallel-features.md +7 -7
- package/package.json +1 -1
- package/src/audits/baseline.ts +1 -1
- package/src/audits/run.ts +1 -1
- package/src/canvas/capture.ts +1 -1
- package/src/canvas/client/app.tsx +105 -4
- package/src/canvas/client/inspector/field.tsx +61 -2
- package/src/canvas/client/inspector/fields.css +78 -0
- package/src/canvas/client/inspector/token-select.tsx +109 -0
- package/src/canvas/client/inspector/values.ts +74 -2
- package/src/canvas/client/inspector.tsx +134 -15
- package/src/canvas/client/keys.ts +71 -0
- package/src/canvas/client/pages-panel.tsx +5 -1
- package/src/canvas/client/panel-handle.tsx +90 -0
- package/src/canvas/client/selection.tsx +3 -1
- package/src/canvas/client/shell.css +194 -4
- package/src/canvas/client/state.ts +255 -11
- package/src/canvas/client/surface.tsx +231 -18
- package/src/canvas/client/tools.tsx +153 -0
- package/src/canvas/content.ts +66 -1
- package/src/canvas/edit.ts +207 -25
- package/src/canvas/history.ts +116 -0
- package/src/canvas/server.ts +119 -13
- package/src/canvas/tokens.ts +1 -1
- package/src/claude/seeds.ts +2 -2
- package/src/claude/skills-reach.ts +1 -1
- package/src/{exec.ts → cli/exec.ts} +2 -2
- package/src/{help.ts → cli/help.ts} +1 -1
- package/src/cli.ts +1 -1
- package/src/commands/audits.ts +9 -1
- package/src/commands/autoship.ts +1 -1
- package/src/commands/canvas.ts +2 -2
- package/src/commands/capture.ts +1 -1
- package/src/commands/census.ts +1 -1
- package/src/commands/claude.ts +5 -5
- package/src/commands/comments.ts +1 -1
- package/src/commands/context.ts +1 -1
- package/src/commands/demo.ts +9 -1
- package/src/commands/deps.ts +1 -1
- package/src/commands/design.ts +12 -4
- package/src/commands/docs.ts +2 -2
- package/src/commands/driver.ts +9 -1
- package/src/commands/feedback.ts +3 -3
- package/src/commands/gate.ts +10 -3
- package/src/commands/gov.ts +3 -3
- package/src/commands/indexes.ts +1 -1
- package/src/commands/init.ts +11 -3
- package/src/commands/intake.ts +1 -1
- package/src/commands/inventory.ts +9 -1
- package/src/commands/labels.ts +1 -1
- package/src/commands/markdown.ts +1 -1
- package/src/commands/migrate.ts +2 -2
- package/src/commands/pr.ts +324 -9
- package/src/commands/records.ts +1 -1
- package/src/commands/repo.ts +1 -1
- package/src/commands/sandbox.ts +11 -14
- package/src/commands/secrets.ts +9 -1
- package/src/commands/sessions.ts +1 -1
- package/src/commands/slides.ts +2 -2
- package/src/commands/standards.ts +1 -1
- package/src/commands/sync.ts +3 -3
- package/src/commands/targets.ts +1 -1
- package/src/commands/tasks.ts +20 -3
- package/src/commands/teach.ts +1 -1
- package/src/commands/tooling.ts +2 -2
- package/src/commands/transcripts.ts +2 -2
- package/src/commands/upgrade.ts +2 -2
- package/src/commands/wiki.ts +2 -2
- package/src/commands/worktrees.ts +1 -1
- package/src/context/architecture.ts +1 -1
- package/src/context/citations.ts +2 -2
- package/src/context/classify/extract.ts +1 -1
- package/src/context/folders.ts +2 -2
- package/src/context/requirements.ts +1 -1
- package/src/docs/read.ts +1 -1
- package/src/feedback/github.ts +1 -1
- package/src/gate/measures.ts +2 -2
- package/src/gate/sequencer.ts +55 -3
- package/src/gate/stages.ts +8 -0
- package/src/gate/suite-lock.ts +214 -0
- package/src/init/run.ts +1 -1
- package/src/intake/folder.ts +1 -1
- package/src/labels/map.ts +1 -1
- package/src/labels/phase.ts +3 -3
- package/src/migrate/record-layout.ts +1 -1
- package/src/migrate/records.ts +11 -11
- package/src/migrate/rename.ts +1 -1
- package/src/migrate/scratch-evidence.ts +1 -1
- package/src/migrate/surface-roots.ts +6 -6
- package/src/pr/evidence.ts +188 -16
- package/src/pr/tick.ts +56 -0
- package/src/process/harness.ts +1 -1
- package/src/records/backup.ts +1 -1
- package/src/records/ordinal.ts +1 -1
- package/src/records/prune.ts +1 -1
- package/src/records/size.ts +1 -1
- package/src/records/stale-canonical.ts +1 -1
- package/src/records/stale.ts +1 -1
- package/src/records/validate.ts +2 -2
- package/src/{project-root.ts → roots/project.ts} +2 -2
- package/src/{record-root.ts → roots/record.ts} +1 -1
- package/src/sandbox/coverage.ts +11 -12
- package/src/sandbox/equivalence.ts +57 -26
- package/src/sandbox/headless/run.ts +1 -1
- package/src/sandbox/hooks.ts +1 -1
- package/src/sandbox/provision.ts +79 -29
- package/src/sandbox/scenario.ts +588 -0
- package/src/serve/report.ts +1 -1
- package/src/sessions/live.ts +4 -1
- package/src/sessions/transfer/bundle.ts +1 -1
- package/src/slides/decks.ts +1 -1
- package/src/standards/read.ts +1 -1
- package/src/sync/engine.ts +2 -2
- package/src/sync/git.ts +1 -1
- package/src/sync/layout.ts +2 -2
- package/src/sync/stamp.ts +1 -1
- package/src/sync/workflow.ts +1 -1
- package/src/targets/validate.ts +2 -2
- package/src/tasks/answers.ts +1 -1
- package/src/tasks/archive.ts +1 -1
- package/src/tasks/label.ts +1 -1
- package/src/tasks/reach.ts +1 -1
- package/src/tasks/validate.ts +38 -14
- package/src/teach/nav.ts +1 -1
- package/src/teach/workspace.ts +1 -1
- package/src/tooling/inject.ts +2 -2
- package/src/tooling/read.ts +1 -1
- package/src/tooling/scan.ts +1 -1
- package/src/tooling/verify-stack.ts +1 -1
- package/src/version/installed.ts +1 -1
- package/src/version/skew.ts +1 -1
- package/standards/board.md +3 -3
- package/standards/plan.md +13 -7
- package/tsconfig.json +1 -1
- /package/src/{cli-run.ts → cli/run.ts} +0 -0
- /package/src/{ui.ts → cli/ui.ts} +0 -0
- /package/src/{legacy-path.ts → roots/legacy.ts} +0 -0
- /package/src/{surface-root.ts → roots/surface.ts} +0 -0
package/src/canvas/edit.ts
CHANGED
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
withFileLock,
|
|
18
18
|
writeSelection,
|
|
19
19
|
} from '@/canvas/content'
|
|
20
|
+
import type { ElementState } from '@/canvas/history'
|
|
20
21
|
|
|
21
22
|
/**
|
|
22
23
|
* The basic set an operator edits by hand. Each is a CSS property written into
|
|
@@ -27,6 +28,10 @@ export const STYLE_PROPERTIES = [
|
|
|
27
28
|
'background-color',
|
|
28
29
|
'font-size',
|
|
29
30
|
'font-weight',
|
|
31
|
+
'font-family',
|
|
32
|
+
'line-height',
|
|
33
|
+
'letter-spacing',
|
|
34
|
+
'text-align',
|
|
30
35
|
'width',
|
|
31
36
|
'height',
|
|
32
37
|
'padding',
|
|
@@ -99,6 +104,11 @@ const DECLARATION_BREAK = /[;{}<>\n\r]/
|
|
|
99
104
|
|
|
100
105
|
const BOM = ''
|
|
101
106
|
|
|
107
|
+
/* The rewriter drops a leading byte order mark, so a read skips it first. */
|
|
108
|
+
function withoutBom(html: string): string {
|
|
109
|
+
return html.startsWith(BOM) ? html.slice(BOM.length) : html
|
|
110
|
+
}
|
|
111
|
+
|
|
102
112
|
function refuse(reason: EditRefusal, detail: string): EditRefused {
|
|
103
113
|
return { ok: false, reason, detail }
|
|
104
114
|
}
|
|
@@ -133,10 +143,13 @@ function checkChange(change: EditChange): EditRefused | undefined {
|
|
|
133
143
|
return undefined
|
|
134
144
|
}
|
|
135
145
|
|
|
146
|
+
const CHARACTER_REFERENCE = /&(?:#\d+|#x[0-9a-f]+|[a-z][a-z0-9]*);/iy
|
|
147
|
+
|
|
136
148
|
/**
|
|
137
149
|
* Splits a style attribute at each semicolon outside parentheses and quotes,
|
|
138
150
|
* keeping every segment's own spacing so an untouched one is written back as
|
|
139
|
-
* it was.
|
|
151
|
+
* it was. The rewriter hands the attribute over undecoded and writes a quote
|
|
152
|
+
* back as `"`, so a character reference's own semicolon never splits.
|
|
140
153
|
*/
|
|
141
154
|
function declarations(style: string): string[] {
|
|
142
155
|
const segments: string[] = []
|
|
@@ -145,7 +158,10 @@ function declarations(style: string): string[] {
|
|
|
145
158
|
let start = 0
|
|
146
159
|
for (let at = 0; at < style.length; at += 1) {
|
|
147
160
|
const char = style[at]
|
|
148
|
-
|
|
161
|
+
CHARACTER_REFERENCE.lastIndex = at
|
|
162
|
+
if (char === '&' && CHARACTER_REFERENCE.test(style)) {
|
|
163
|
+
at = CHARACTER_REFERENCE.lastIndex - 1
|
|
164
|
+
} else if (quote) {
|
|
149
165
|
if (char === quote) quote = undefined
|
|
150
166
|
} else if (char === '"' || char === "'") quote = char
|
|
151
167
|
else if (char === '(') depth += 1
|
|
@@ -282,11 +298,106 @@ export function applyEdit(
|
|
|
282
298
|
return { ok: true, html: `${bom}${rewritten}` }
|
|
283
299
|
}
|
|
284
300
|
|
|
301
|
+
interface ElementRead {
|
|
302
|
+
readonly tag: string
|
|
303
|
+
readonly style: string | undefined
|
|
304
|
+
/** The raw text inside it, entities still encoded, when it holds text alone. */
|
|
305
|
+
readonly inner: string | undefined
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* What the element at an index holds, read as the file states it. Each text
|
|
310
|
+
* chunk the rewriter hands over is the source text, so the inner content keeps
|
|
311
|
+
* every entity as written.
|
|
312
|
+
*/
|
|
313
|
+
function readElement(html: string, index: number): ElementRead | undefined {
|
|
314
|
+
let found: { tag: string; style: string | undefined } | undefined
|
|
315
|
+
let isOpen = false
|
|
316
|
+
let hasChild = false
|
|
317
|
+
let inner = ''
|
|
318
|
+
countElements(new HTMLRewriter(), (element, at) => {
|
|
319
|
+
if (isOpen) hasChild = true
|
|
320
|
+
if (at !== index) return undefined
|
|
321
|
+
found = {
|
|
322
|
+
tag: element.tagName.toLowerCase(),
|
|
323
|
+
style: element.getAttribute('style') ?? undefined,
|
|
324
|
+
}
|
|
325
|
+
if (!element.canHaveContent || element.selfClosing) return undefined
|
|
326
|
+
isOpen = true
|
|
327
|
+
return () => {
|
|
328
|
+
isOpen = false
|
|
329
|
+
}
|
|
330
|
+
})
|
|
331
|
+
.onDocument({
|
|
332
|
+
text(chunk) {
|
|
333
|
+
if (isOpen) inner += chunk.text
|
|
334
|
+
},
|
|
335
|
+
})
|
|
336
|
+
.transform(html)
|
|
337
|
+
if (!found) return undefined
|
|
338
|
+
return { ...found, inner: hasChild ? undefined : inner }
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function stateOf(read: ElementRead, withInner: boolean): ElementState {
|
|
342
|
+
return withInner
|
|
343
|
+
? { style: read.style, inner: read.inner }
|
|
344
|
+
: { style: read.style }
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function sameState(read: ElementRead, state: ElementState): boolean {
|
|
348
|
+
if (read.style !== state.style) return false
|
|
349
|
+
return state.inner === undefined || read.inner === state.inner
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Restamps a selection that was fresh before a write, since neither an edit
|
|
354
|
+
* nor a restore moves an element.
|
|
355
|
+
*/
|
|
356
|
+
function restampSelection(
|
|
357
|
+
root: string,
|
|
358
|
+
page: string,
|
|
359
|
+
frame: string,
|
|
360
|
+
before: ReturnType<typeof readSelection>,
|
|
361
|
+
html: string,
|
|
362
|
+
): void {
|
|
363
|
+
if (
|
|
364
|
+
before?.page !== page ||
|
|
365
|
+
before.frame !== frame ||
|
|
366
|
+
!before.element ||
|
|
367
|
+
before.element.stale
|
|
368
|
+
) {
|
|
369
|
+
return
|
|
370
|
+
}
|
|
371
|
+
writeSelection(root, {
|
|
372
|
+
page,
|
|
373
|
+
frame,
|
|
374
|
+
element: {
|
|
375
|
+
index: before.element.index,
|
|
376
|
+
tag: before.element.tag,
|
|
377
|
+
count: sourceElements(html).length,
|
|
378
|
+
},
|
|
379
|
+
})
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function writeFrame(path: string, html: string): void {
|
|
383
|
+
const temp = `${path}.${process.pid}${TEMP_SUFFIX}`
|
|
384
|
+
writeFileSync(temp, html)
|
|
385
|
+
renameSync(temp, path)
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** The element's state on each side of an edit, which undo restores. */
|
|
389
|
+
export type EditObserver = (states: {
|
|
390
|
+
readonly before: ElementState
|
|
391
|
+
readonly after: ElementState
|
|
392
|
+
}) => void
|
|
393
|
+
|
|
285
394
|
/**
|
|
286
395
|
* Edits one element of a frame file under the file's lock. An address carrying
|
|
287
396
|
* a hash the file no longer has is refused rather than applied at an index the
|
|
288
397
|
* change may have shifted. A selection that was fresh before the edit is
|
|
289
|
-
* restamped after it, since the edit moves no element.
|
|
398
|
+
* restamped after it, since the edit moves no element. An observer hears the
|
|
399
|
+
* element's state before and after a write that landed, apart from the
|
|
400
|
+
* returned record, which the CLI prints.
|
|
290
401
|
*/
|
|
291
402
|
export function editFrame(
|
|
292
403
|
root: string,
|
|
@@ -294,6 +405,7 @@ export function editFrame(
|
|
|
294
405
|
frame: string,
|
|
295
406
|
address: ElementAddress,
|
|
296
407
|
change: EditChange,
|
|
408
|
+
observe?: EditObserver,
|
|
297
409
|
): FrameEdit {
|
|
298
410
|
const invalid = checkChange(change)
|
|
299
411
|
if (invalid) return invalid
|
|
@@ -314,30 +426,25 @@ export function editFrame(
|
|
|
314
426
|
`${page}/${frame} changed after this edit was made, so nothing was written`,
|
|
315
427
|
)
|
|
316
428
|
}
|
|
317
|
-
const
|
|
318
|
-
const
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
before.element !== undefined &&
|
|
322
|
-
!before.element.stale
|
|
323
|
-
|
|
324
|
-
const applied = applyEdit(bytes.toString('utf8'), address, change)
|
|
429
|
+
const selected = readSelection(root)
|
|
430
|
+
const source = bytes.toString('utf8')
|
|
431
|
+
|
|
432
|
+
const applied = applyEdit(source, address, change)
|
|
325
433
|
if (!applied.ok) return applied
|
|
326
434
|
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
})
|
|
435
|
+
writeFrame(path, applied.html)
|
|
436
|
+
restampSelection(root, page, frame, selected, applied.html)
|
|
437
|
+
|
|
438
|
+
if (observe) {
|
|
439
|
+
const withInner = change.property === 'text'
|
|
440
|
+
const before = readElement(withoutBom(source), address.index)
|
|
441
|
+
const after = readElement(withoutBom(applied.html), address.index)
|
|
442
|
+
if (before && after) {
|
|
443
|
+
observe({
|
|
444
|
+
before: stateOf(before, withInner),
|
|
445
|
+
after: stateOf(after, withInner),
|
|
446
|
+
})
|
|
447
|
+
}
|
|
341
448
|
}
|
|
342
449
|
return {
|
|
343
450
|
ok: true,
|
|
@@ -349,6 +456,81 @@ export function editFrame(
|
|
|
349
456
|
})
|
|
350
457
|
}
|
|
351
458
|
|
|
459
|
+
export type RestoreRefusal = EditRefusal | 'changed'
|
|
460
|
+
|
|
461
|
+
export type ElementRestore =
|
|
462
|
+
| { readonly ok: true; readonly hash: string }
|
|
463
|
+
| {
|
|
464
|
+
readonly ok: false
|
|
465
|
+
readonly reason: RestoreRefusal
|
|
466
|
+
readonly detail: string
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* Writes a recorded state back to one element under the file's lock, only
|
|
471
|
+
* while the file holds as many elements as it did at the edit and the element
|
|
472
|
+
* at that index still carries the tag and the state the record expects. An
|
|
473
|
+
* unstyled state matches any unstyled sibling, so the count is what catches
|
|
474
|
+
* an element inserted or removed ahead of the target. Anything else means the
|
|
475
|
+
* file moved on since, so the restore refuses as `changed` rather than
|
|
476
|
+
* overwrite a newer state or land on a neighbor. The state comes from the
|
|
477
|
+
* server's own record, never a request, since inner content is written back
|
|
478
|
+
* as markup.
|
|
479
|
+
*/
|
|
480
|
+
export function restoreElement(
|
|
481
|
+
root: string,
|
|
482
|
+
page: string,
|
|
483
|
+
frame: string,
|
|
484
|
+
target: {
|
|
485
|
+
readonly index: number
|
|
486
|
+
readonly tag: string
|
|
487
|
+
readonly count: number
|
|
488
|
+
},
|
|
489
|
+
expected: ElementState,
|
|
490
|
+
to: ElementState,
|
|
491
|
+
): ElementRestore {
|
|
492
|
+
const onPage = readPage(root, page)
|
|
493
|
+
if (!onPage) return refuse('no-page', `page ${page} does not exist`)
|
|
494
|
+
const found = onPage.frames.find((candidate) => candidate.name === frame)
|
|
495
|
+
if (!found) {
|
|
496
|
+
return refuse('no-frame', `frame ${frame} does not exist on ${page}`)
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
const path = join(canvasDir(root), page, found.file)
|
|
500
|
+
return withFileLock(path, (): ElementRestore => {
|
|
501
|
+
const source = readFileSync(path, 'utf8')
|
|
502
|
+
const bom = source.startsWith(BOM) ? BOM : ''
|
|
503
|
+
const html = source.slice(bom.length)
|
|
504
|
+
const current = readElement(html, target.index)
|
|
505
|
+
if (
|
|
506
|
+
sourceElements(html).length !== target.count ||
|
|
507
|
+
current?.tag !== target.tag ||
|
|
508
|
+
!sameState(current, expected)
|
|
509
|
+
) {
|
|
510
|
+
return {
|
|
511
|
+
ok: false,
|
|
512
|
+
reason: 'changed',
|
|
513
|
+
detail: `<${target.tag}> in ${page}/${frame} changed since this edit, so nothing was written`,
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
const selected = readSelection(root)
|
|
517
|
+
const rewritten = countElements(new HTMLRewriter(), (element, at) => {
|
|
518
|
+
if (at !== target.index) return undefined
|
|
519
|
+
if (to.style === undefined) element.removeAttribute('style')
|
|
520
|
+
else element.setAttribute('style', to.style)
|
|
521
|
+
if (to.inner !== undefined) {
|
|
522
|
+
element.setInnerContent(to.inner, { html: true })
|
|
523
|
+
}
|
|
524
|
+
return undefined
|
|
525
|
+
}).transform(html)
|
|
526
|
+
const written = `${bom}${rewritten}`
|
|
527
|
+
|
|
528
|
+
writeFrame(path, written)
|
|
529
|
+
restampSelection(root, page, frame, selected, written)
|
|
530
|
+
return { ok: true, hash: contentHash(written) }
|
|
531
|
+
})
|
|
532
|
+
}
|
|
533
|
+
|
|
352
534
|
/**
|
|
353
535
|
* Edits the element at an index of the file as it stands, for a caller that
|
|
354
536
|
* holds no served frame to take an address from. The address it builds
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import type { Box } from '@/canvas/content'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The operator's shell edits, held by one `canon canvas serve` process. Only
|
|
5
|
+
* the server's write routes record here, so an edit made through a file or a
|
|
6
|
+
* CLI verb never enters it. An entry carries the state it replaced and the
|
|
7
|
+
* state it wrote, and applying it checks the second is still there.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** What an element entry restores: the raw style attribute, and raw inner content for a text edit. */
|
|
11
|
+
export interface ElementState {
|
|
12
|
+
readonly style: string | undefined
|
|
13
|
+
readonly inner?: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface ElementEntry {
|
|
17
|
+
readonly kind: 'element'
|
|
18
|
+
readonly page: string
|
|
19
|
+
readonly frame: string
|
|
20
|
+
readonly index: number
|
|
21
|
+
readonly tag: string
|
|
22
|
+
/** How many elements the file held, which an insert or removal ahead of the target changes. */
|
|
23
|
+
readonly count: number
|
|
24
|
+
readonly before: ElementState
|
|
25
|
+
readonly after: ElementState
|
|
26
|
+
/** Edits a control posts as one gesture share this, and merge into one entry. */
|
|
27
|
+
readonly step?: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface BoxEntry {
|
|
31
|
+
readonly kind: 'box'
|
|
32
|
+
readonly page: string
|
|
33
|
+
readonly frame: string
|
|
34
|
+
/** Undefined when the layout held no box, so the reader placed the frame. */
|
|
35
|
+
readonly before: Box | undefined
|
|
36
|
+
readonly after: Box
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export type HistoryEntry = ElementEntry | BoxEntry
|
|
40
|
+
|
|
41
|
+
export type Direction = 'undo' | 'redo'
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* `dropped` means the target changed since the entry was recorded, so the
|
|
45
|
+
* entry is discarded unwritten. `busy` leaves it in place to try again.
|
|
46
|
+
*/
|
|
47
|
+
export type ApplyResult = 'applied' | 'dropped' | 'busy'
|
|
48
|
+
|
|
49
|
+
export type HistoryOutcome = ApplyResult | 'empty'
|
|
50
|
+
|
|
51
|
+
export interface HistoryFlags {
|
|
52
|
+
readonly canUndo: boolean
|
|
53
|
+
readonly canRedo: boolean
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export const HISTORY_CAP = 100
|
|
57
|
+
|
|
58
|
+
function isSameTarget(a: ElementEntry, b: ElementEntry): boolean {
|
|
59
|
+
return (
|
|
60
|
+
a.page === b.page &&
|
|
61
|
+
a.frame === b.frame &&
|
|
62
|
+
a.index === b.index &&
|
|
63
|
+
a.tag === b.tag
|
|
64
|
+
)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export class History {
|
|
68
|
+
private readonly undone: HistoryEntry[] = []
|
|
69
|
+
private readonly done: HistoryEntry[] = []
|
|
70
|
+
|
|
71
|
+
constructor(private readonly cap = HISTORY_CAP) {}
|
|
72
|
+
|
|
73
|
+
record(entry: HistoryEntry): void {
|
|
74
|
+
this.undone.length = 0
|
|
75
|
+
const last = this.done.at(-1)
|
|
76
|
+
if (
|
|
77
|
+
entry.kind === 'element' &&
|
|
78
|
+
entry.step !== undefined &&
|
|
79
|
+
last?.kind === 'element' &&
|
|
80
|
+
last.step === entry.step &&
|
|
81
|
+
isSameTarget(last, entry)
|
|
82
|
+
) {
|
|
83
|
+
this.done[this.done.length - 1] = { ...last, after: entry.after }
|
|
84
|
+
return
|
|
85
|
+
}
|
|
86
|
+
this.done.push(entry)
|
|
87
|
+
if (this.done.length > this.cap) this.done.shift()
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
undo(apply: (entry: HistoryEntry, direction: Direction) => ApplyResult) {
|
|
91
|
+
return this.move(this.done, this.undone, 'undo', apply)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
redo(apply: (entry: HistoryEntry, direction: Direction) => ApplyResult) {
|
|
95
|
+
return this.move(this.undone, this.done, 'redo', apply)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
flags(): HistoryFlags {
|
|
99
|
+
return { canUndo: this.done.length > 0, canRedo: this.undone.length > 0 }
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
private move(
|
|
103
|
+
from: HistoryEntry[],
|
|
104
|
+
to: HistoryEntry[],
|
|
105
|
+
direction: Direction,
|
|
106
|
+
apply: (entry: HistoryEntry, direction: Direction) => ApplyResult,
|
|
107
|
+
): HistoryOutcome {
|
|
108
|
+
const entry = from.at(-1)
|
|
109
|
+
if (!entry) return 'empty'
|
|
110
|
+
const result = apply(entry, direction)
|
|
111
|
+
if (result === 'busy') return result
|
|
112
|
+
from.pop()
|
|
113
|
+
if (result === 'applied') to.push(entry)
|
|
114
|
+
return result
|
|
115
|
+
}
|
|
116
|
+
}
|
package/src/canvas/server.ts
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
TOKENS_ATTRIBUTE,
|
|
8
8
|
} from '@/canvas/address'
|
|
9
9
|
import {
|
|
10
|
+
type BoxObserver,
|
|
10
11
|
type ContentRefused,
|
|
11
12
|
canvasDir,
|
|
12
13
|
contentHash,
|
|
@@ -15,11 +16,18 @@ import {
|
|
|
15
16
|
moveFrame,
|
|
16
17
|
readSelection,
|
|
17
18
|
resizeFrame,
|
|
19
|
+
restoreBox,
|
|
18
20
|
TEMP_SUFFIX,
|
|
19
21
|
writeSelection,
|
|
20
22
|
} from '@/canvas/content'
|
|
21
|
-
import { type EditRefused, editFrame } from '@/canvas/edit'
|
|
23
|
+
import { type EditRefused, editFrame, restoreElement } from '@/canvas/edit'
|
|
22
24
|
import { readEditing } from '@/canvas/editing'
|
|
25
|
+
import {
|
|
26
|
+
type ApplyResult,
|
|
27
|
+
type Direction,
|
|
28
|
+
History,
|
|
29
|
+
type HistoryEntry,
|
|
30
|
+
} from '@/canvas/history'
|
|
23
31
|
import {
|
|
24
32
|
resolveFrameTokens,
|
|
25
33
|
type TokenOptions,
|
|
@@ -311,6 +319,59 @@ function isLoopbackOrigin(origin: string, port: number): boolean {
|
|
|
311
319
|
)
|
|
312
320
|
}
|
|
313
321
|
|
|
322
|
+
/**
|
|
323
|
+
* Writes one entry back through the writer that recorded it. A busy lock
|
|
324
|
+
* keeps the entry for another try, and any other refusal means the target
|
|
325
|
+
* moved on or went, so the entry drops.
|
|
326
|
+
*/
|
|
327
|
+
function applyEntry(
|
|
328
|
+
root: string,
|
|
329
|
+
entry: HistoryEntry,
|
|
330
|
+
direction: Direction,
|
|
331
|
+
): ApplyResult {
|
|
332
|
+
const isUndo = direction === 'undo'
|
|
333
|
+
const outcome =
|
|
334
|
+
entry.kind === 'element'
|
|
335
|
+
? restoreElement(
|
|
336
|
+
root,
|
|
337
|
+
entry.page,
|
|
338
|
+
entry.frame,
|
|
339
|
+
entry,
|
|
340
|
+
isUndo ? entry.after : entry.before,
|
|
341
|
+
isUndo ? entry.before : entry.after,
|
|
342
|
+
)
|
|
343
|
+
: restoreBox(
|
|
344
|
+
root,
|
|
345
|
+
entry.page,
|
|
346
|
+
entry.frame,
|
|
347
|
+
isUndo ? entry.after : entry.before,
|
|
348
|
+
isUndo ? entry.before : entry.after,
|
|
349
|
+
)
|
|
350
|
+
if (outcome.ok) return 'applied'
|
|
351
|
+
return outcome.reason === 'busy' ? 'busy' : 'dropped'
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function stepHistory(
|
|
355
|
+
root: string,
|
|
356
|
+
history: History,
|
|
357
|
+
direction: Direction,
|
|
358
|
+
): Response {
|
|
359
|
+
const apply = (entry: HistoryEntry) => applyEntry(root, entry, direction)
|
|
360
|
+
const outcome =
|
|
361
|
+
direction === 'undo' ? history.undo(apply) : history.redo(apply)
|
|
362
|
+
if (outcome === 'busy') {
|
|
363
|
+
return json(
|
|
364
|
+
{
|
|
365
|
+
ok: false,
|
|
366
|
+
reason: 'busy',
|
|
367
|
+
detail: 'the file is being written, try again',
|
|
368
|
+
},
|
|
369
|
+
409,
|
|
370
|
+
)
|
|
371
|
+
}
|
|
372
|
+
return json({ ok: true, outcome, history: history.flags() })
|
|
373
|
+
}
|
|
374
|
+
|
|
314
375
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
315
376
|
return typeof value === 'object' && value !== null
|
|
316
377
|
}
|
|
@@ -444,6 +505,16 @@ export function startCanvas(
|
|
|
444
505
|
embedFonts: true,
|
|
445
506
|
})
|
|
446
507
|
const changes = new ChangeStream(content)
|
|
508
|
+
/*
|
|
509
|
+
* Held here and written only by the shell's write routes, so an edit a CLI
|
|
510
|
+
* verb makes in its own process never enters it.
|
|
511
|
+
*/
|
|
512
|
+
const history = new History()
|
|
513
|
+
const recordBox =
|
|
514
|
+
(page: string, frame: string): BoxObserver =>
|
|
515
|
+
(before, after) => {
|
|
516
|
+
history.record({ kind: 'box', page, frame, before, after })
|
|
517
|
+
}
|
|
447
518
|
const first = options.port ?? CANVAS_PORT
|
|
448
519
|
|
|
449
520
|
let bound: ReturnType<typeof bindFirstFree>
|
|
@@ -487,8 +558,15 @@ export function startCanvas(
|
|
|
487
558
|
editing: readEditing(root, new Date()).map(
|
|
488
559
|
({ page, frame, by, until }) => ({ page, frame, by, until }),
|
|
489
560
|
),
|
|
561
|
+
history: history.flags(),
|
|
490
562
|
})
|
|
491
563
|
}),
|
|
564
|
+
'/api/history/undo': {
|
|
565
|
+
POST: guarded(mutation(() => stepHistory(root, history, 'undo'))),
|
|
566
|
+
},
|
|
567
|
+
'/api/history/redo': {
|
|
568
|
+
POST: guarded(mutation(() => stepHistory(root, history, 'redo'))),
|
|
569
|
+
},
|
|
492
570
|
'/api/frames/move': {
|
|
493
571
|
POST: guarded(
|
|
494
572
|
mutation((body) => {
|
|
@@ -501,10 +579,14 @@ export function startCanvas(
|
|
|
501
579
|
) {
|
|
502
580
|
return badBody('send page, frame, x, and y')
|
|
503
581
|
}
|
|
504
|
-
const
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
582
|
+
const { page, frame } = body
|
|
583
|
+
const outcome = moveFrame(
|
|
584
|
+
root,
|
|
585
|
+
page,
|
|
586
|
+
frame,
|
|
587
|
+
{ x: body.x, y: body.y },
|
|
588
|
+
recordBox(page, frame),
|
|
589
|
+
)
|
|
508
590
|
return outcome.ok ? json(outcome) : refusal(outcome)
|
|
509
591
|
}),
|
|
510
592
|
),
|
|
@@ -523,12 +605,19 @@ export function startCanvas(
|
|
|
523
605
|
) {
|
|
524
606
|
return badBody('send page, frame, x, y, width, and height')
|
|
525
607
|
}
|
|
526
|
-
const
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
608
|
+
const { page, frame } = body
|
|
609
|
+
const outcome = resizeFrame(
|
|
610
|
+
root,
|
|
611
|
+
page,
|
|
612
|
+
frame,
|
|
613
|
+
{
|
|
614
|
+
x: body.x,
|
|
615
|
+
y: body.y,
|
|
616
|
+
width: body.width,
|
|
617
|
+
height: body.height,
|
|
618
|
+
},
|
|
619
|
+
recordBox(page, frame),
|
|
620
|
+
)
|
|
532
621
|
return outcome.ok ? json(outcome) : refusal(outcome)
|
|
533
622
|
}),
|
|
534
623
|
),
|
|
@@ -557,15 +646,32 @@ export function startCanvas(
|
|
|
557
646
|
'send an element as index, tag, count, and the hash it was served with',
|
|
558
647
|
)
|
|
559
648
|
}
|
|
649
|
+
const { page, frame, step } = body
|
|
650
|
+
if (step !== undefined && typeof step !== 'string') {
|
|
651
|
+
return badBody('send step as a string, or leave it out')
|
|
652
|
+
}
|
|
560
653
|
const outcome = editFrame(
|
|
561
654
|
root,
|
|
562
|
-
|
|
563
|
-
|
|
655
|
+
page,
|
|
656
|
+
frame,
|
|
564
657
|
element,
|
|
565
658
|
{
|
|
566
659
|
property: body.property,
|
|
567
660
|
value: body.value,
|
|
568
661
|
},
|
|
662
|
+
({ before, after }) => {
|
|
663
|
+
history.record({
|
|
664
|
+
kind: 'element',
|
|
665
|
+
page,
|
|
666
|
+
frame,
|
|
667
|
+
index: element.index,
|
|
668
|
+
tag: element.tag,
|
|
669
|
+
count: element.count,
|
|
670
|
+
before,
|
|
671
|
+
after,
|
|
672
|
+
...(step !== undefined && { step }),
|
|
673
|
+
})
|
|
674
|
+
},
|
|
569
675
|
)
|
|
570
676
|
return outcome.ok ? json(outcome) : refusal(outcome)
|
|
571
677
|
}),
|
package/src/canvas/tokens.ts
CHANGED
package/src/claude/seeds.ts
CHANGED
|
@@ -2,9 +2,9 @@ import { existsSync } from 'node:fs'
|
|
|
2
2
|
import { chmod, mkdir, readFile, writeFile } from 'node:fs/promises'
|
|
3
3
|
import { dirname, join, relative } from 'node:path'
|
|
4
4
|
import { copyPreservingMode } from '@/sync/copy'
|
|
5
|
-
import { creationRel, isRecordEntry } from '@/record
|
|
5
|
+
import { creationRel, isRecordEntry } from '@/roots/record'
|
|
6
6
|
import { rewritesOnInstall, stripSeedMarker } from '@/tooling/seed-marker'
|
|
7
|
-
import { SURFACE_ENTRIES, surfaceDir } from '@/surface
|
|
7
|
+
import { SURFACE_ENTRIES, surfaceDir } from '@/roots/surface'
|
|
8
8
|
|
|
9
9
|
const SEEDS_DIR = join('tooling', 'claude', 'seeds')
|
|
10
10
|
const CLAUDE_DIR = '.claude'
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from 'node:fs'
|
|
2
2
|
import { join } from 'node:path'
|
|
3
3
|
import { resolveSkillsCorpus } from '@/claude/skills-list'
|
|
4
|
-
import { SURFACE_ROOTS } from '@/surface
|
|
4
|
+
import { SURFACE_ROOTS } from '@/roots/surface'
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* The authoring roots this repository owns and no install channel delivers.
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { join } from 'node:path'
|
|
2
2
|
import { execa } from 'execa'
|
|
3
|
-
import { checkoutMismatchWarning, PROJECT_ROOT } from '@/project
|
|
4
|
-
import { logWarn } from '@/ui'
|
|
3
|
+
import { checkoutMismatchWarning, PROJECT_ROOT } from '@/roots/project'
|
|
4
|
+
import { logWarn } from '@/cli/ui'
|
|
5
5
|
|
|
6
6
|
export async function execScript(
|
|
7
7
|
script: string,
|
package/src/cli.ts
CHANGED
|
@@ -44,7 +44,7 @@ import { register as census } from '@/commands/census'
|
|
|
44
44
|
import { register as targets } from '@/commands/targets'
|
|
45
45
|
import { register as upgrade } from '@/commands/upgrade'
|
|
46
46
|
import { readInstalled, UNKNOWN_LABEL } from '@/version/installed'
|
|
47
|
-
import { renderHelp } from '@/help'
|
|
47
|
+
import { renderHelp } from '@/cli/help'
|
|
48
48
|
|
|
49
49
|
function showHelp(): void {
|
|
50
50
|
console.log(renderHelp(process.stdout))
|
package/src/commands/audits.ts
CHANGED
|
@@ -20,7 +20,15 @@ import {
|
|
|
20
20
|
summarize,
|
|
21
21
|
} from '@/audits/run'
|
|
22
22
|
import { gitEnv } from '@/git/env'
|
|
23
|
-
import {
|
|
23
|
+
import {
|
|
24
|
+
intro,
|
|
25
|
+
logError,
|
|
26
|
+
logInfo,
|
|
27
|
+
logStep,
|
|
28
|
+
logWarn,
|
|
29
|
+
outro,
|
|
30
|
+
plural,
|
|
31
|
+
} from '@/cli/ui'
|
|
24
32
|
import { currentWorktreeRoot } from '@/git/worktree'
|
|
25
33
|
|
|
26
34
|
interface RunCommandOptions {
|