@ape-egg/codecane 0.0.0-stage → 1.0.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/ui/viewer.js ADDED
@@ -0,0 +1,294 @@
1
+ import vibe from '/node_modules/@ape-egg/vibe/index.js'
2
+ import { navigate, setupSpa } from '/node_modules/@ape-egg/vibe/spa.js'
3
+ import { setupHotModuleRefresh } from '/node_modules/@ape-egg/vibe/hot-module-refresh.js'
4
+ import codie from '/node_modules/@ape-egg/codie/codie.js'
5
+
6
+ import { mounted, pathOf, restyled } from './mounted.js'
7
+
8
+ const source = new URL('/codecane/index.json', location.origin)
9
+
10
+ const fetched = async (address, as = 'json') => {
11
+ const response = await fetch(address)
12
+ if (!response.ok) throw new Error(`codecane: ${address} answered ${response.status}: ${await response.text()}`)
13
+ return response[as]()
14
+ }
15
+
16
+ const digest = text => [...text].reduce((sum, character) => (sum * 31 + character.charCodeAt(0)) | 0, 0).toString(36)
17
+
18
+ let index = await fetched(source)
19
+ let loaded = {}
20
+ const steps = () => Object.fromEntries(index.steps.map(step => [step.id, step]))
21
+ const idOf = address => address.split('/')[0]
22
+ const routes = () => [{ route: '/' }, ...[...new Set([...index.stacks.flat(), ...index.steps.map(step => step.id)])].map(address => ({ route: `/${address}` }))]
23
+ const addressAt = (path, lane = 0) => path.split('/').filter(Boolean).join('/') || index.stacks[lane].at(-1)
24
+ const atRoot = path => !path.split('/').some(Boolean)
25
+ const placed = (path, current) => {
26
+ const lane = [current, ...index.stacks.keys()].find(lane => index.stacks[lane]?.includes(path)) ?? index.timelines.findIndex(line => line.includes(path))
27
+ return { lane, address: index.stacks[lane][index.timelines[lane].indexOf(idOf(path))] }
28
+ }
29
+ const linked = address => address ? (({ title, kind }) => ({ address, title, kind: kind ?? '' }))(steps()[idOf(address)]) : ''
30
+
31
+ const folderOf = path => path.slice(0, path.lastIndexOf('/') + 1)
32
+ const rootFirst = (a, b) => a.path.includes('/') - b.path.includes('/')
33
+
34
+ const opened = async stacked => {
35
+ const address = new URL(steps()[idOf(stacked)].data[stacked], source)
36
+ const step = await (loaded[stacked] ??= fetched(address))
37
+ return { ...step, files: step.files.map(file => ({ ...file, folder: folderOf(file.path) })).sort(rootFirst), base: new URL(step.folder, address).href, texts: Object.fromEntries(step.changes.map(change => [change.path, change.after])) }
38
+ }
39
+
40
+ const running = page => page.files.some(file => file.path === 'index.html') ? `${page.base}index.html?revision=${digest(page.files.map(file => file.hash).join())}` : ''
41
+
42
+ const text = async (page, path) => page.texts[path] ??= await fetched(`${page.base}${path}`, 'text')
43
+
44
+ const changed = ({ page, drafts }) => [
45
+ ...page.changes,
46
+ ...Object.keys(drafts).filter(path => !page.changes.some(change => change.path === path)).map(path => ({ path, state: 'modified', before: page.texts[path], after: page.texts[path] })),
47
+ ]
48
+
49
+ const ways = id => steps()[id].children.length + steps()[id].joins.length
50
+
51
+ const cellsOf = () => index.timelines.flatMap((line, lane) => line
52
+ .map((id, depth) => ({
53
+ id,
54
+ key: `${lane}/${id}`,
55
+ depth,
56
+ lane,
57
+ forks: ways(id) > 1 ? ways(id) : 0,
58
+ follows: depth > 0 && ways(line[depth - 1]) === 1 && steps()[line[depth - 1]].kind !== 'seam' && !steps()[id].merges.length && steps()[id].parents.length < 2,
59
+ }))
60
+ .filter(({ id, depth }) => index.timelines[lane - 1]?.[depth] !== id)
61
+ .map(cell => {
62
+ const end = index.timelines.slice(lane).findIndex(other => other[cell.depth] !== cell.id)
63
+ return { ...cell, span: end === -1 ? index.timelines.length - lane : end }
64
+ }))
65
+
66
+ const bar = ({ lane, fold, through, lit }) => {
67
+ const open = index.timelines.map((line, at) => at === lane || Boolean(fold && line.includes(through)))
68
+ return {
69
+ rows: open.map(on => on ? '1fr' : '0fr').join(' '),
70
+ cells: cellsOf().map(cell => ({
71
+ ...cell,
72
+ address: index.stacks[lane >= cell.lane && lane < cell.lane + cell.span ? lane : cell.lane][cell.depth],
73
+ shown: open.slice(cell.lane, cell.lane + cell.span).some(Boolean) ? 'shown' : '',
74
+ lit: lit.includes(cell.id) ? 'lit' : '',
75
+ })),
76
+ }
77
+ }
78
+
79
+ const barred = changes => {
80
+ const { selected, ...next } = { selected: $.selected, fold: $.fold, through: $.through, lit: $.lit, ...changes }
81
+ return Object.assign($, changes, bar({ ...next, lane: selected }))
82
+ }
83
+
84
+ const alternativesOf = (lane, at) => [...new Set(index.stacks
85
+ .filter(line => line.slice(0, at).join() === index.stacks[lane].slice(0, at).join() && line[at] && idOf(line[at]) !== idOf(index.stacks[lane][at]))
86
+ .map(line => line[at]))].map(linked)
87
+
88
+ const shown = (page, lane) => ({
89
+ page,
90
+ lane,
91
+ selected: lane,
92
+ timeline: index.timelines[lane],
93
+ previous: linked(index.stacks[lane][index.stacks[lane].indexOf(page.address) - 1]),
94
+ next: linked(index.stacks[lane][index.stacks[lane].indexOf(page.address) + 1]),
95
+ alternatives: alternativesOf(lane, index.stacks[lane].indexOf(page.address)),
96
+ steps: steps(),
97
+ fold: '',
98
+ lit: [],
99
+ ...bar({ lane, fold: '', through: null, lit: [] }),
100
+ src: running(page),
101
+ open: '',
102
+ problem: '',
103
+ drafts: {},
104
+ applied: {},
105
+ failure: '',
106
+ changes: page.changes,
107
+ })
108
+
109
+ const edited = (before, after) => [...new Set([...before.files, ...after.files].map(file => file.path))]
110
+ .filter(path => before.files.find(file => file.path === path)?.hash !== after.files.find(file => file.path === path)?.hash)
111
+
112
+ const swap = (link, href) => link.after(Object.assign(link.cloneNode(), { href, onload: () => link.remove() }))
113
+
114
+ const restyle = (doc, paths) => doc.querySelectorAll('link[rel="stylesheet"]').forEach(link => {
115
+ const url = new URL(link.href)
116
+ if (!paths.includes(url.pathname)) return
117
+ url.searchParams.set('t', Date.now())
118
+ swap(link, url.href)
119
+ })
120
+
121
+ const relink = async (doc, page, drafts) => {
122
+ const links = [...doc.querySelectorAll('link[rel="stylesheet"]')]
123
+ const sheet = restyled(page, drafts, path => text(page, path))
124
+ const hrefs = await Promise.all(links.map(link => sheet(link.href)))
125
+ links.forEach((link, at) => hrefs[at] !== link.href && swap(link, hrefs[at]))
126
+ }
127
+
128
+ let pause
129
+ const preview = async () => {
130
+ const { page, drafts, applied, src } = $
131
+ const paths = Object.keys({ ...applied, ...drafts }).filter(path => drafts[path] !== applied[path])
132
+ if (!src || !paths.length) return
133
+ const frame = document.querySelector('stage iframe').contentDocument
134
+ const styled = paths.every(path => path.endsWith('.css')) && frame.URL === src
135
+ const next = styled ? src : await mounted(page, drafts, path => text(page, path))
136
+ if (styled) await relink(frame, page, drafts)
137
+ if ($.drafts === drafts) Object.assign($, { applied: drafts, src: next })
138
+ }
139
+
140
+ const reports = {
141
+ ran: () => $.failure = '',
142
+ failed: ({ message, source, line }) => $.failure = { message, at: source && `${pathOf($.page, source)}:${line}` },
143
+ }
144
+ addEventListener('message', ({ source, data }) => source === document.querySelector('stage iframe')?.contentWindow && reports[data.codecane]?.(data))
145
+
146
+ const first = placed(addressAt(location.pathname))
147
+ window.$ = vibe({ name: index.name, version: index.version, editable: index.editable, kinds: { merge: 'Merge', seam: 'Seam' }, through: null, panel: 50, dragging: false, closing: false, fullscreen: false, returning: false, root: atRoot(location.pathname), ...shown(await opened(first.address), first.lane) })
148
+ const titled = address => document.title = `${index.name} · ${steps()[idOf(address)].title}`
149
+ titled(first.address)
150
+
151
+ setupSpa({
152
+ routes: routes(),
153
+ onNavigate: async ({ path }) => {
154
+ const { lane, address } = placed(addressAt(path, $.selected), $.selected)
155
+ const page = await opened(address)
156
+ if (addressAt(path) !== addressAt(location.pathname)) return
157
+ Object.assign($, { root: atRoot(path), ...shown(page, lane) })
158
+ titled(address)
159
+ },
160
+ })
161
+
162
+ window.codecane = {
163
+ step: address => navigate(`/${address}`),
164
+ fold: parent => barred({ fold: $.fold && $.through === parent ? '' : 'unfolded', through: parent }),
165
+ light: (...ids) => barred({ lit: ids }),
166
+ pick: (event, id, lane, span) => {
167
+ const sibling = id !== $.page.id && steps()[id].parents.join() === steps()[$.page.id].parents.join()
168
+ const at = index.timelines[lane].indexOf($.page.id)
169
+ const before = at > -1 && at < index.timelines[lane].indexOf(id)
170
+ if (!$.fold || sibling || before || ($.selected >= lane && $.selected < lane + span)) return
171
+ event.preventDefault()
172
+ const address = index.stacks[lane][at]
173
+ if (address && address !== $.page.address) return navigate(`/${address}`)
174
+ barred({ selected: lane, fold: '' })
175
+ },
176
+ open: async path => {
177
+ await text($.page, path)
178
+ Object.assign($, { panel: $.open ? $.panel : 50, open: path, closing: false })
179
+ },
180
+ grab: ({ currentTarget, pointerId }) => {
181
+ currentTarget.setPointerCapture(pointerId)
182
+ $.dragging = true
183
+ },
184
+ drag: ({ clientY, currentTarget }) => {
185
+ const { bottom, height } = currentTarget.closest('stage').getBoundingClientRect()
186
+ $.panel = Math.min(100, Math.max(30, (bottom - clientY) / height * 100))
187
+ },
188
+ snap: () => Object.assign($, { panel: Math.round($.panel / 10) * 10, dragging: false }),
189
+ run: async () => {
190
+ clearTimeout(pause)
191
+ await preview()
192
+ $.closing = true
193
+ },
194
+ settle: () => $.closing && Object.assign($, { open: '', closing: false }),
195
+ expand: () => document.documentElement.requestFullscreen(),
196
+ returned: () => $.returning && Object.assign($, { fullscreen: false, returning: false }),
197
+ save: async forks => {
198
+ const { open, page, drafts } = $
199
+ const response = await fetch(`/codecane/save/${open}?at=${encodeURIComponent(page.address)}${forks ? '&forks' : ''}`, { method: 'PUT', body: drafts[open] })
200
+ if (response.status === 409) {
201
+ $.problem = await response.text()
202
+ return
203
+ }
204
+ if (!response.ok) throw new Error(`codecane: saving ${open} answered ${response.status}: ${await response.text()}`)
205
+ index = await fetched(source)
206
+ loaded = {}
207
+ Object.assign($, shown(await opened(page.address), $.lane))
208
+ },
209
+ }
210
+
211
+ document.addEventListener('fullscreenchange', () => Object.assign($, document.fullscreenElement ? { fullscreen: true } : { returning: true }))
212
+
213
+ const reread = async () => {
214
+ index = await fetched(source)
215
+ loaded = {}
216
+ const { page: before, lane: current, fold, through, lit, open, drafts, applied, failure, problem, src } = $
217
+ const page = await opened(before.address)
218
+ const { lane } = placed(page.address, current)
219
+ const paths = edited(before, page)
220
+ const styled = paths.every(path => path.endsWith('.css'))
221
+ const kept = page.files.some(file => file.path === open) ? open : ''
222
+ if (kept) await text(page, kept)
223
+ if ($.page !== before) return
224
+ Object.assign($, {
225
+ ...shown(page, lane),
226
+ ...bar({ lane, fold, through, lit }),
227
+ fold,
228
+ lit,
229
+ open: kept,
230
+ drafts,
231
+ problem,
232
+ changes: changed({ page, drafts }),
233
+ src: styled ? src : running(page),
234
+ applied: styled ? applied : {},
235
+ failure: styled ? failure : '',
236
+ })
237
+ titled(page.address)
238
+ if (styled && src) restyle(document.querySelector('stage iframe').contentDocument, paths.map(path => new URL(path, page.base).pathname))
239
+ await preview()
240
+ }
241
+
242
+ if (index.editable) {
243
+ const events = new EventSource('/codecane/events')
244
+ const on = (event, update) => events.addEventListener(event, ({ data }) => update(JSON.parse(data)))
245
+ setupHotModuleRefresh({
246
+ subscribe: ({ componentUpdate, pageUpdate }) => {
247
+ on('component-update', ({ path }) => componentUpdate(path))
248
+ on('page-update', pageUpdate)
249
+ on('style-update', ({ path }) => restyle(document, [path]))
250
+ },
251
+ })
252
+ on('steps-update', reread)
253
+ }
254
+
255
+ const diffs = new WeakMap()
256
+ const redraw = ({ changes, drafts }) => document.querySelectorAll('changes-list codie').forEach(el => {
257
+ const change = changes.find(change => change.path === el.dataset.path)
258
+ const code = drafts[change.path] ?? change.after
259
+ if (!diffs.has(el)) {
260
+ el.toggleAttribute(`highlight-${change.path.split('.').pop()}`, true)
261
+ diffs.set(el, codie({ el, diff: true, numberedRows: true, before: change.before }))
262
+ }
263
+ if (diffs.get(el).before !== change.before) diffs.get(el).before = change.before
264
+ if (diffs.get(el).code !== code) diffs.get(el).code = code
265
+ const header = el.parentElement.querySelector('change-header')
266
+ header.querySelector('added').textContent = `+${el.querySelectorAll('.codie-diff-added').length}`
267
+ header.querySelector('removed').textContent = `−${el.querySelectorAll('.codie-diff-removed').length}`
268
+ })
269
+
270
+ let editor
271
+ $.on('afterUpdate', ({ page, open, drafts }, previous) => {
272
+ if (open && open === previous.open && !(open in drafts) && page.texts[open] !== previous.page.texts[open]) editor.code = page.texts[open]
273
+ if (!open || open === previous.open) return
274
+ editor = previous.open ? editor : codie(document.querySelector('stage codie'))
275
+ Object.assign(editor, {
276
+ language: open.split('.').pop(),
277
+ code: $.drafts[open] ?? page.texts[open],
278
+ editable: true,
279
+ numberedRows: true,
280
+ })
281
+ editor.onEdit = ({ raw }) => {
282
+ const { [open]: _, ...others } = $.drafts
283
+ $.drafts = raw === $.page.texts[open] ? others : { ...others, [open]: raw }
284
+ $.changes = changed($)
285
+ $.problem = ''
286
+ clearTimeout(pause)
287
+ pause = setTimeout(preview, 300)
288
+ }
289
+ })
290
+
291
+ $.on('ready', () => {
292
+ redraw($)
293
+ $.on('afterUpdate', redraw)
294
+ })
package/workflow.md ADDED
@@ -0,0 +1,81 @@
1
+ ## codecane
2
+
3
+ This project is built up step by step with [codecane](https://github.com/kkortes/codecane). Each folder in `steps/` holds only what its step adds or changes. `codecane build` stacks the steps into complete, runnable folders, one per step, and fails if a step no longer runs.
4
+
5
+ ### Steps
6
+
7
+ - `steps/<id>/` holds the files the step adds. `steps/<id>.html` beside it is its story, and its `<h1>` names the step.
8
+ - A step changes a file from an earlier step with a patch, `steps/<id>/<path>.patch`, which stores only the lines it changes. A patch is unified-diff hunks (`@@ -8,7 +8,9 @@`, then lines starting with a space, `-` or `+`). It applies exactly at its line numbers: there is no fuzz and no searching.
9
+ - A step continues the step before it in natural order (`2` before `10`) unless its `codecane.json` names a `parent`. The parent can be any step, as long as parents never lead back to the step itself, and `"parent": null` starts a new root. Steps stack parents first. Where a step has several children, they follow natural order of their ids, and the first one leads the default timeline.
10
+ - `steps/<id>/codecane.json` is optional and never stacked:
11
+ - `parent`: the step this one continues. Two steps naming the same parent are a pivot, and from there the timelines fork. A list of steps continues all of their timelines together. See Continuing timelines together.
12
+ - `merges`: steps on other timelines whose timelines this step merges into its own. See Merging timelines.
13
+ - `removes`: paths of earlier files this step takes away.
14
+ - `overrides`: paths of earlier files this step replaces with a full copy. A copy that isn't listed fails the build, so every full copy is a deliberate choice. Prefer a patch.
15
+ - Nothing is generated: every stacked file is a step's file with the later steps' patches applied.
16
+ - A change to a step reaches every later step on the next build, because later steps hold no copies.
17
+ - Each step stores the code it wrote, and a replay reuses that code exactly. Nothing is ever written again on a replay, so every replay gives the same code.
18
+ - `steps/<id>.check.js` is the step's optional check, never stacked. Its default export gets the stacked step's files as `{ path: text }` and throws when the step doesn't do what it should. A step without one still stacks and builds, and is marked untested.
19
+
20
+ ### Adding a step
21
+
22
+ - Add new files, and patch earlier files only where the new code has to be wired in, such as a `<script>` tag or an import line.
23
+ - The easy way to write a patch: copy the earlier file into the new step and edit the copy. While `codecane dev` runs, it turns the copy into a patch that holds only the changed lines as soon as the writes settle. Without it, run `codecane patch <id> <path>`. In a step with several parents, the copy is made from and patched against the first parent's timeline.
24
+ - Pick an id. A number continues the step before it without a `parent`. A name, such as `moving-the-center`, names its `parent` in its `codecane.json`. Among steps with the same parent, the id that sorts first leads the default timeline.
25
+ - Write the story: an `<h1>` with the step's name, and a short account of what it adds and why.
26
+ - Add `steps/<id>.check.js` when the step does something a check can prove.
27
+
28
+ ### After editing any step
29
+
30
+ - **Run `codecane check` after every edit to a step folder.** It replays every step and runs its checks. When everything passes it says so in one line. Otherwise it stops at the first step that breaks and says why: a patch that no longer applies, with the step, the file and the lines, or a check that fails, with its message. The stored steps must always stack cleanly. Never leave a step folder in a state where `codecane check` fails.
31
+ - `codecane dev` watches `steps/`, whoever edits it: an agent, the developer or any other tool. Once the writes have settled, it saves copies of earlier files as the step's patches, rebases the later patches, and replays from the changed step forward. When everything passes it says nothing. Otherwise it stops at the first step that breaks and says why. A save in the viewer replays the same way.
32
+ - Edits made in the viewer, or on disk while `codecane dev` runs, rebase the later patches right away. A later patch whose changed lines only moved is rewritten to the new line numbers. A save in the viewer that would break a later patch is refused with the step and lines. A change on disk that breaks one is reported in `codecane dev`'s output, and `codecane check` fails until it's resolved.
33
+ - If a later patch no longer applies, resolve it in that step: rewrite its patch against the new lines, or revert the edit that broke it.
34
+
35
+ ### Fixing and refactoring
36
+
37
+ - A fix goes into the step that last changed the code. That is the step that brought the file, or the last step that patched it. The viewer shows it as "last changed in step N". The fix then flows into every later step.
38
+ - If a new step needs a hook that doesn't exist yet, add the hook to the step that owns the code. Don't work around it in the new step.
39
+ - A refactor is only ever a deliberate step of its own. It patches, overrides or removes the files it reshapes, and its story says why.
40
+
41
+ ### Continuing timelines together
42
+
43
+ A pivot's timelines can be alternatives, such as a square and a hex floor. When the next steps should be written once for all of them, but each timeline should keep what it chose, don't merge them. Continue them together:
44
+
45
+ - Give one step several parents: `{ "parent": ["<step>", "<other-step>", …] }`. The first leads the default timeline.
46
+ - That step, and every step after it, stacks once per timeline, onto that timeline's own files. Nothing is combined, so a timeline never carries another timeline's code.
47
+ - Every step can be visited at `/<step>`, which shows it with the default fork at each fork on the way. Once forks have been chosen, the default one included, its address is `/<step>/<hash>/<hash>…`: the short hashes of the step taken at each fork before it, earliest first, such as `/moving-the-center/05adc6a`. Only forks before the step count, so a step before any fork, or the step a fork takes, never carries one. The viewer keeps to the timeline you are on with every click. With the fork marker unfolded, pressing a fork navigates straight to it when the step you are on comes before it, such as Canvas, or is itself a fork beside it, such as World hex surface beside World triangle surface. From a step after the fork, pressing a step on another fork picks that fork without leaving the step you are on: the address's hashes change to the new fork, such as `/moving-the-center` becoming `/moving-the-center/05adc6a`, and the preview reruns on it. Pressing a step on the picked fork, once it is picked, navigates to that step. `codecane stack <address> --out <dir>` writes that step alone, ready to deploy.
48
+ - The step's patches have to apply on every timeline, and its checks run on every one. Where the timelines differ in what the shared steps need, write a seam: a small internal API that the shared steps use, filled in on each timeline by the step that owns that code there, the way a missing hook is added.
49
+ - A step with several parents that holds code of its own is a **seam**. It shows in yellow, with the fork icon mirrored, like a merge step's seam, and it is always needed: its code is what the timelines share from there on. A step with several parents and no code of its own just joins them, in the normal colour.
50
+
51
+ Merge instead when the timelines each add something the result should hold together.
52
+
53
+ ### Merging timelines
54
+
55
+ Timelines fork wherever steps share a parent, and any number of them can merge again at any step. Merging never merges text. A merge step replays the other timelines' steps onto its own timeline, using each step's stored code exactly, and then runs the checks of every step on all the timelines it joins. The viewer only shows merges. Agents make them, like this:
56
+
57
+ 1. **Try it first.** `codecane merge <step> <other-step…>` replays the other steps' timelines onto `<step>`'s timeline and writes nothing. It reports one of three things: a clean merge, a seam that is needed and why, or the first step that breaks and why. Tell the developer the result.
58
+ 2. **Go ahead only once the developer confirms.** Add one step whose `codecane.json` says `{ "parent": "<step>", "merges": ["<other-step>", …] }`, and a story saying what came together. If it needs a seam, write it only together with the step that needs it.
59
+ 3. **Then the usual loop takes over.** `codecane dev` and `codecane check` replay the merge with everything else, and report any later step that breaks because of it.
60
+
61
+ A merge step is one of three kinds. codecane decides the kind by replaying and running the checks. It never guesses:
62
+
63
+ - **Merge**: the step holds no code of its own. Every step it brings replays cleanly, and every check passes on both sides. It shows in the normal colour.
64
+ - **Seam**: the timelines differ, so a step won't replay or a check fails. The step then holds a seam: a small internal API, what each timeline fills in, and the code that makes the brought steps replay. The viewer shows the steps that required it and why. It shows in yellow, with the fork icon mirrored.
65
+ - **Seam no longer needed**: a replay without the seam's code still passes every check, here and in every later step. The timeline leaves it out, and `codecane check` says so.
66
+
67
+ Every step has a short hash, generated once when codecane first sees the step and stored as `hash` in its `codecane.json`. `codecane check`, `build`, `dev` and the other commands give a new step one. A seam lives in `parts/<hash>/`, beside `steps/`, with its story at `parts/<hash>.html`: its short hash is its id and its folder name, and later steps name it by that hash. Its story has no `<h1>`; the viewer titles it `Seam <hash>`. `codecane check` gives a seam written in `steps/` its hash and fails with where in `parts/` to move it, and fails on a step in `parts/` that holds no seam. A seam that isn't needed is never written.
68
+
69
+ A step's kind is the result of its last replay:
70
+
71
+ - Adding steps after a merge never changes it. When a later step needs what the timelines do differently, put a seam right before that step: a step that names the same timelines in `merges` again and holds only the seam.
72
+ - Editing a step from before a merge replays the merge again. If its steps no longer replay cleanly, or a check fails, the merge needs a seam. codecane stops there and says why, and an agent or the developer writes the seam into that step.
73
+
74
+ ### Commands
75
+
76
+ - `codecane check`: replays every step and runs its checks, and stops at the first step that breaks with why. It names the untested steps and any seam that is no longer needed, which the timeline leaves out.
77
+ - `codecane merge <step> <other-step…>`: tries merging the other steps' timelines into `<step>`'s timeline, and writes nothing.
78
+ - `codecane patch <id> [path…]`: turns a step's copies of earlier files into patches.
79
+ - `codecane dev`: the viewer at `/`, every stacked step at `/steps/<id>/`, live from `steps/`. It watches `steps/`, turns copies into patches, rebases later patches and replays from each changed step forward, and the open viewer updates in place without reloading. Saving in the viewer writes to the step that last changed the file.
80
+ - `codecane build`: writes `dist/` (every stacked step, its JSON data and the viewer) and checks that every step runs. `--bare` leaves out the viewer.
81
+ - `codecane stack <address> --out <dir>`: writes one step on one timeline, such as `moving-the-center/05adc6a`; `codecane stack moving-the-center` writes it with the default forks.