@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.
Files changed (67) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.md +33 -159
  3. package/assets/brand/mark.svg +17 -9
  4. package/claude/.claude-plugin/plugin.json +1 -1
  5. package/claude/skills/app-drive/SKILL.md +3 -2
  6. package/claude/skills/app-drive/references/drive-record.md +52 -10
  7. package/claude/skills/app-drive/references/isolate.md +1 -1
  8. package/claude/skills/design-pick/SKILL.md +1 -0
  9. package/claude/skills/git-pr/references/task-number.md +13 -0
  10. package/claude/skills/markdown-craft/references/markdown.md +1 -0
  11. package/claude/skills/role-orchestrator/references/orchestrator-cloud-launch.md +1 -0
  12. package/claude/skills/role-orchestrator/references/orchestrator-launch.md +6 -20
  13. package/claude/skills/role-orchestrator/references/orchestrator-reviewer-launch.md +10 -0
  14. package/claude/skills/role-orchestrator/references/orchestrator-talk-launch.md +30 -0
  15. package/claude/skills/task-board/references/tasks-record-lines.md +40 -0
  16. package/claude/skills/task-board/references/tasks.md +4 -8
  17. package/claude/skills/ui-craft/references/copy.md +4 -1
  18. package/claude/skills/ui-film/REQUIREMENT.md +4 -2
  19. package/claude/skills/ui-film/SKILL.md +24 -11
  20. package/claude/skills/ui-film/references/compose-craft.md +81 -0
  21. package/claude/skills/ui-film/references/config.md +6 -1
  22. package/claude/skills/ui-film/references/handoff.md +13 -3
  23. package/claude/skills/ui-film/references/take-craft.md +27 -1
  24. package/docs/index.md +1 -0
  25. package/docs/migrations/by-hand.md +107 -0
  26. package/docs/migrations/by-verb.md +118 -0
  27. package/docs/migrations/index.md +14 -0
  28. package/docs/target/index.md +1 -2
  29. package/docs/target/projects.md +2 -2
  30. package/docs/target/sync.md +22 -2
  31. package/docs/workflow/ai-workflow.md +1 -1
  32. package/governance/ledger/skills/ui-craft/copy.md +8 -4
  33. package/package.json +3 -1
  34. package/scripts/core/design-sync.ts +204 -0
  35. package/scripts/core/regen-web-favicon.ts +27 -12
  36. package/scripts/core/regen-web-previews.ts +8 -1
  37. package/scripts/core/render-film.ts +179 -0
  38. package/src/capture/frame-values.ts +12 -12
  39. package/src/commands/markdown.ts +6 -1
  40. package/src/commands/records.ts +3 -2
  41. package/src/commands/sandbox.ts +9 -2
  42. package/src/commands/take.ts +159 -11
  43. package/src/commands/tasks.ts +133 -0
  44. package/src/design/favicon.ts +85 -13
  45. package/src/design/reference.ts +98 -0
  46. package/src/design/render.ts +16 -14
  47. package/src/design/tokens.ts +4 -3
  48. package/src/gate/stages.ts +6 -0
  49. package/src/init/steps.ts +2 -2
  50. package/src/markdown/colons.ts +102 -16
  51. package/src/migrate/status.ts +3 -3
  52. package/src/records/validate.ts +14 -2
  53. package/src/sandbox/expect.ts +32 -0
  54. package/src/sandbox/headless/run.ts +6 -3
  55. package/src/take/camera.ts +99 -10
  56. package/src/take/film.ts +35 -2
  57. package/src/take/fixtures/input.html +138 -0
  58. package/src/take/keys.ts +193 -0
  59. package/src/take/log.ts +94 -1
  60. package/src/take/perform.ts +165 -10
  61. package/src/take/plan.ts +101 -6
  62. package/src/take/screencast.ts +28 -3
  63. package/src/take/stepper.ts +122 -18
  64. package/src/tasks/archive.ts +54 -1
  65. package/src/tasks/record.ts +118 -0
  66. package/src/tasks/validate.ts +37 -0
  67. package/docs/target/migrations.md +0 -205
package/src/take/plan.ts CHANGED
@@ -6,19 +6,44 @@
6
6
  * keyboard leaves nothing in the log to draw.
7
7
  */
8
8
 
9
+ import { MODIFIERS, type Modifier, isModifier, parseKeys } from '@/take/keys'
10
+
9
11
  /** How a beat is filmed: frame stepping, or Playwright's real-time screencast. */
10
12
  export const ROUTES = ['step', 'screencast'] as const
11
13
 
12
14
  export type Route = (typeof ROUTES)[number]
13
15
 
16
+ /** The keys held through a press, absent when none is. */
17
+ interface Held {
18
+ readonly modifiers?: readonly Modifier[]
19
+ }
20
+
14
21
  export type TakeStep =
15
22
  | { readonly kind: 'move'; readonly target: string }
16
23
  | { readonly kind: 'move'; readonly x: number; readonly y: number }
17
- | { readonly kind: 'click'; readonly target: string }
24
+ | ({ readonly kind: 'click'; readonly target: string } & Held)
25
+ | ({ readonly kind: 'dblclick'; readonly target: string } & Held)
26
+ | ({
27
+ readonly kind: 'drag'
28
+ readonly from: string
29
+ readonly to: string
30
+ } & Held)
31
+ | { readonly kind: 'key'; readonly keys: string }
18
32
  | { readonly kind: 'fill'; readonly target: string; readonly text: string }
19
33
  | { readonly kind: 'scroll'; readonly target: string; readonly dy: number }
20
34
  | { readonly kind: 'wait'; readonly ms: number }
21
35
 
36
+ const KINDS = [
37
+ 'move',
38
+ 'click',
39
+ 'dblclick',
40
+ 'drag',
41
+ 'key',
42
+ 'fill',
43
+ 'scroll',
44
+ 'wait',
45
+ ] as const
46
+
22
47
  export interface Beat {
23
48
  readonly name: string
24
49
  /**
@@ -26,6 +51,11 @@ export interface Beat {
26
51
  * back to `screencast` on its own, and the take's record says which ran.
27
52
  */
28
53
  readonly route: Route
54
+ /**
55
+ * False turns the camera's zoom off for this beat, while its track still
56
+ * carries the cursor and the click ring. Absent means the zoom runs.
57
+ */
58
+ readonly push?: false
29
59
  /** Runs after the page loads and before the first frame is kept. */
30
60
  readonly setup: readonly TakeStep[]
31
61
  readonly steps: readonly TakeStep[]
@@ -89,16 +119,25 @@ function readPlan(document: unknown): TakePlan {
89
119
  throw new Refusal('unreadable-plan', 'the document is not an object')
90
120
  }
91
121
  const { beats } = document
122
+ const route = document.route ?? 'step'
123
+ if (!ROUTES.includes(route as Route)) {
124
+ throw new Refusal(
125
+ 'unknown-route',
126
+ `the plan names route ${String(route)}. This build records on: ${ROUTES.join(', ')}.`,
127
+ )
128
+ }
92
129
  if (!Array.isArray(beats) || beats.length === 0) {
93
130
  throw new Refusal(
94
131
  'no-beats',
95
132
  'beats carries no entry, so nothing is filmed',
96
133
  )
97
134
  }
98
- return { beats: beats.map((entry, index) => readBeat(entry, index)) }
135
+ return {
136
+ beats: beats.map((entry, index) => readBeat(entry, index, route as Route)),
137
+ }
99
138
  }
100
139
 
101
- function readBeat(entry: unknown, index: number): Beat {
140
+ function readBeat(entry: unknown, index: number, fallback: Route): Beat {
102
141
  const at = `beat ${index + 1}`
103
142
  if (!isRecord(entry)) throw new Refusal('bad-beat', `${at} is not an object`)
104
143
 
@@ -110,7 +149,7 @@ function readBeat(entry: unknown, index: number): Beat {
110
149
  )
111
150
  }
112
151
 
113
- const route = entry.route ?? 'step'
152
+ const route = entry.route ?? fallback
114
153
  if (!ROUTES.includes(route as Route)) {
115
154
  throw new Refusal(
116
155
  'unknown-route',
@@ -127,10 +166,17 @@ function readBeat(entry: unknown, index: number): Beat {
127
166
  if (entry.setup !== undefined && !Array.isArray(entry.setup)) {
128
167
  throw new Refusal('bad-beat', `${name} carries a setup that is not a list`)
129
168
  }
169
+ if (entry.push !== undefined && typeof entry.push !== 'boolean') {
170
+ throw new Refusal(
171
+ 'bad-beat',
172
+ `${name} carries a push that is not true or false`,
173
+ )
174
+ }
130
175
 
131
176
  return {
132
177
  name,
133
178
  route: route as Route,
179
+ ...(entry.push === false ? { push: false as const } : {}),
134
180
  setup: (entry.setup ?? []).map((step: unknown, i: number) =>
135
181
  readStep(step, `${name}, setup step ${i + 1}`),
136
182
  ),
@@ -160,7 +206,35 @@ function readStep(entry: unknown, at: string): TakeStep {
160
206
  `${at} is a move with neither a target nor an x and y`,
161
207
  )
162
208
  case 'click':
163
- return { kind: 'click', target: needTarget() }
209
+ case 'dblclick':
210
+ return {
211
+ kind: entry.kind,
212
+ target: needTarget(),
213
+ ...readModifiers(entry.modifiers, at),
214
+ }
215
+ case 'drag':
216
+ if (typeof entry.from !== 'string' || !entry.from) {
217
+ throw new Refusal('bad-step', `${at} is a drag with no from target`)
218
+ }
219
+ if (typeof entry.to !== 'string' || !entry.to) {
220
+ throw new Refusal('bad-step', `${at} is a drag with no to target`)
221
+ }
222
+ return {
223
+ kind: 'drag',
224
+ from: entry.from,
225
+ to: entry.to,
226
+ ...readModifiers(entry.modifiers, at),
227
+ }
228
+ case 'key': {
229
+ if (typeof entry.keys !== 'string') {
230
+ throw new Refusal('bad-step', `${at} is a key with no keys to press`)
231
+ }
232
+ const read = parseKeys(entry.keys)
233
+ if (read.kind === 'refused') {
234
+ throw new Refusal('bad-step', `${at} is a key where ${read.detail}`)
235
+ }
236
+ return { kind: 'key', keys: entry.keys }
237
+ }
164
238
  case 'fill':
165
239
  if (typeof entry.text !== 'string') {
166
240
  throw new Refusal('bad-step', `${at} is a fill with no text`)
@@ -179,9 +253,30 @@ function readStep(entry: unknown, at: string): TakeStep {
179
253
  default:
180
254
  throw new Refusal(
181
255
  'bad-step',
182
- `${at} names kind ${String(entry.kind)}, which is not move, click, fill, scroll, or wait`,
256
+ `${at} names kind ${String(entry.kind)}, which is not ${KINDS.join(', ')}`,
257
+ )
258
+ }
259
+ }
260
+
261
+ function readModifiers(value: unknown, at: string): Held {
262
+ if (value === undefined) return {}
263
+ if (!Array.isArray(value)) {
264
+ throw new Refusal('bad-step', `${at} carries modifiers that are not a list`)
265
+ }
266
+ const modifiers: Modifier[] = []
267
+ for (const name of value) {
268
+ if (!isModifier(name)) {
269
+ throw new Refusal(
270
+ 'bad-step',
271
+ `${at} holds ${String(name)}, which is not ${MODIFIERS.join(', ')}`,
183
272
  )
273
+ }
274
+ if (modifiers.includes(name)) {
275
+ throw new Refusal('bad-step', `${at} holds ${name} twice`)
276
+ }
277
+ modifiers.push(name)
184
278
  }
279
+ return modifiers.length > 0 ? { modifiers } : {}
185
280
  }
186
281
 
187
282
  function isRecord(value: unknown): value is Record<string, unknown> {
@@ -4,7 +4,12 @@ import { join } from 'node:path'
4
4
  import { chromium } from 'playwright-core'
5
5
  import { lastFrame, probeFrames, transcode } from '@/take/encode'
6
6
  import { FPS, type LogEvent, START, VIEWPORT } from '@/take/log'
7
- import { type PointerInput, type Stage, performSteps } from '@/take/perform'
7
+ import {
8
+ type PointerInput,
9
+ type Stage,
10
+ TargetMissing,
11
+ performSteps,
12
+ } from '@/take/perform'
8
13
  import type { Beat } from '@/take/plan'
9
14
  import { HOLD_MS } from '@/take/stepper'
10
15
 
@@ -19,6 +24,9 @@ const FRAME_MS = 1000 / FPS
19
24
 
20
25
  const SETTLE_MS = 2500
21
26
 
27
+ /** How long a refusal waits on the screenshot it shows. */
28
+ const STILL_CAP_MS = 2000
29
+
22
30
  export interface ScreencastTake {
23
31
  readonly frames: number
24
32
  readonly events: readonly LogEvent[]
@@ -33,8 +41,15 @@ export async function filmScreencast(request: {
33
41
  }): Promise<ScreencastTake> {
34
42
  const scratch = mkdtempSync(join(tmpdir(), 'canon-take-cast-'))
35
43
  const browser = await chromium.launch()
44
+ let still: (() => Promise<Buffer | undefined>) | undefined
36
45
  try {
37
46
  const page = await browser.newPage({ viewport: VIEWPORT })
47
+ // Best effort, so a closed or crashed page leaves the refusal standing
48
+ // without a still rather than replacing it.
49
+ still = () =>
50
+ page
51
+ .screenshot({ type: 'jpeg', quality: 95, timeout: STILL_CAP_MS })
52
+ .catch(() => undefined)
38
53
  await page.goto(request.url, { waitUntil: 'load' })
39
54
  await page.waitForTimeout(SETTLE_MS)
40
55
 
@@ -51,6 +66,13 @@ export async function filmScreencast(request: {
51
66
  await page.keyboard.insertText(text)
52
67
  await page.waitForTimeout(FRAME_MS)
53
68
  },
69
+ // Playwright's mouse reads the modifiers its keyboard holds, so a press
70
+ // here is all a modified click or drag needs on this route.
71
+ press: async (input) => {
72
+ if (input.type === 'down') await page.keyboard.down(input.key)
73
+ else await page.keyboard.up(input.key)
74
+ await page.waitForTimeout(FRAME_MS)
75
+ },
54
76
  evaluate: (expression) => page.evaluate(expression),
55
77
  })
56
78
 
@@ -75,6 +97,9 @@ export async function filmScreencast(request: {
75
97
  await transcode(webm, request.clip, FPS)
76
98
  const frames = await probeFrames(request.clip)
77
99
  return { frames, events, still: await lastFrame(request.clip, frames) }
100
+ } catch (error) {
101
+ if (error instanceof TargetMissing && still) error.still = await still()
102
+ throw error
78
103
  } finally {
79
104
  await browser.close()
80
105
  rmSync(scratch, { recursive: true, force: true })
@@ -88,9 +113,9 @@ async function send(mouse: Mouse, input: PointerInput): Promise<void> {
88
113
  case 'move':
89
114
  return mouse.move(input.x, input.y)
90
115
  case 'down':
91
- return mouse.down()
116
+ return mouse.down({ clickCount: input.clickCount })
92
117
  case 'up':
93
- return mouse.up()
118
+ return mouse.up({ clickCount: input.clickCount })
94
119
  case 'wheel':
95
120
  return mouse.wheel(0, input.dy)
96
121
  }
@@ -1,6 +1,13 @@
1
1
  import { openFrameEncoder } from '@/take/encode'
2
+ import { describeKey, modifierBits } from '@/take/keys'
2
3
  import { FPS, type LogEvent, START, VIEWPORT } from '@/take/log'
3
- import { type PointerInput, type Stage, performSteps } from '@/take/perform'
4
+ import {
5
+ type KeyInput,
6
+ type PointerInput,
7
+ type Stage,
8
+ TargetMissing,
9
+ performSteps,
10
+ } from '@/take/perform'
4
11
  import type { Beat } from '@/take/plan'
5
12
  import { type ShellPage, openShell } from '@/take/shell'
6
13
 
@@ -36,6 +43,9 @@ const LOAD_CAP_MS = 15_000
36
43
  const SETTLE_MS = 2500
37
44
  const LOADING_FRAME_CAP_MS = 250
38
45
 
46
+ /** How long a refusal waits on the frame it shows, since the page may be wedged. */
47
+ const STILL_CAP_MS = 2000
48
+
39
49
  /** A frame that took longer than `STALL_MS`, so the beat is filmed another way. */
40
50
  export class StepperStalled extends Error {
41
51
  constructor(
@@ -68,6 +78,7 @@ export async function filmStepped(
68
78
  const page = await openShell(request.executable, VIEWPORT)
69
79
  const encoder = openFrameEncoder(request.clip, FPS)
70
80
  const stallMs = request.stallMs ?? STALL_MS
81
+ let still: (() => Promise<Buffer | undefined>) | undefined
71
82
  try {
72
83
  let ticks = await load(page, request.url)
73
84
  const beginFrame = (screenshot: boolean) =>
@@ -81,6 +92,7 @@ export async function filmStepped(
81
92
  : {}),
82
93
  },
83
94
  )
95
+ still = () => frameStill(beginFrame)
84
96
  await pump(beginFrame, SETTLE_MS)
85
97
 
86
98
  const live = liveStage(page, beginFrame, stallMs)
@@ -128,16 +140,14 @@ export async function filmStepped(
128
140
  hold: async (ms) => {
129
141
  for (let i = 0; i < Math.round(ms / FRAME_MS); i += 1) await frame()
130
142
  },
131
- pointer: async (input) => {
132
- const sent = page.send('Input.dispatchMouseEvent', mouseEvent(input))
133
- await frame()
134
- await sent
135
- },
136
- key: async (text) => {
137
- const sent = page.send('Input.insertText', { text })
138
- await frame()
139
- await sent
140
- },
143
+ pointer: (input) =>
144
+ stepThen(
145
+ page.send('Input.dispatchMouseEvent', mouseEvent(input)),
146
+ frame,
147
+ ),
148
+ key: (text) => stepThen(page.send('Input.insertText', { text }), frame),
149
+ press: (input) =>
150
+ stepThen(page.send('Input.dispatchKeyEvent', keyEvent(input)), frame),
141
151
  evaluate: (expression) => page.evaluate(expression),
142
152
  }
143
153
 
@@ -153,12 +163,31 @@ export async function filmStepped(
153
163
  return { frames, events, still: last }
154
164
  } catch (error) {
155
165
  encoder.abort()
166
+ if (error instanceof TargetMissing && still) error.still = await still()
156
167
  throw error
157
168
  } finally {
158
169
  page.close()
159
170
  }
160
171
  }
161
172
 
173
+ /**
174
+ * Begins one frame with a screenshot for a refusal to show, through the same
175
+ * begin-frame call every kept frame uses, since a separate capture call can
176
+ * wait on a frame that never begins. Best effort, so a page that answers with
177
+ * nothing leaves the refusal standing without a still.
178
+ */
179
+ async function frameStill(
180
+ beginFrame: (screenshot: boolean) => Promise<{ screenshotData?: string }>,
181
+ ): Promise<Buffer | undefined> {
182
+ const reply = await Promise.race([
183
+ beginFrame(true).catch(() => undefined),
184
+ sleep(STILL_CAP_MS),
185
+ ])
186
+ return reply?.screenshotData
187
+ ? Buffer.from(reply.screenshotData, 'base64')
188
+ : undefined
189
+ }
190
+
162
191
  /**
163
192
  * Navigates and resolves with Chromium's monotonic clock in milliseconds, read
164
193
  * off the load event's own timestamp. The runtime's clock cannot stand in for
@@ -217,7 +246,7 @@ function liveStage(
217
246
  pointer: (input) => {
218
247
  const sent = page.send('Input.dispatchMouseEvent', mouseEvent(input))
219
248
  return withinStall(
220
- beginFrame(false).then(() => sent),
249
+ Promise.all([beginFrame(false), sent]),
221
250
  0,
222
251
  stallMs,
223
252
  ).then(() => {})
@@ -225,7 +254,15 @@ function liveStage(
225
254
  key: (text) => {
226
255
  const sent = page.send('Input.insertText', { text })
227
256
  return withinStall(
228
- beginFrame(false).then(() => sent),
257
+ Promise.all([beginFrame(false), sent]),
258
+ 0,
259
+ stallMs,
260
+ ).then(() => {})
261
+ },
262
+ press: (input) => {
263
+ const sent = page.send('Input.dispatchKeyEvent', keyEvent(input))
264
+ return withinStall(
265
+ Promise.all([beginFrame(false), sent]),
229
266
  0,
230
267
  stallMs,
231
268
  ).then(() => {})
@@ -234,18 +271,83 @@ function liveStage(
234
271
  }
235
272
  }
236
273
 
274
+ /**
275
+ * A pointer input as CDP takes it. `buttons` carries the held button through a
276
+ * drag's moves, or the page reads them as a hover, and `modifiers` carries the
277
+ * held keys as a bit field beside the key events that pressed them.
278
+ */
237
279
  function mouseEvent(input: PointerInput): Record<string, unknown> {
238
280
  const at = { x: input.x, y: input.y }
281
+ if (input.type === 'wheel') {
282
+ return { type: 'mouseWheel', ...at, deltaX: 0, deltaY: input.dy }
283
+ }
284
+ const modifiers = modifierBits(input.modifiers ?? [])
239
285
  switch (input.type) {
240
286
  case 'move':
241
- return { type: 'mouseMoved', ...at, button: 'none' }
287
+ return input.held
288
+ ? { type: 'mouseMoved', ...at, button: 'left', buttons: 1, modifiers }
289
+ : { type: 'mouseMoved', ...at, button: 'none', buttons: 0, modifiers }
242
290
  case 'down':
243
- return { type: 'mousePressed', ...at, button: 'left', clickCount: 1 }
244
291
  case 'up':
245
- return { type: 'mouseReleased', ...at, button: 'left', clickCount: 1 }
246
- case 'wheel':
247
- return { type: 'mouseWheel', ...at, deltaX: 0, deltaY: input.dy }
292
+ return {
293
+ type: input.type === 'down' ? 'mousePressed' : 'mouseReleased',
294
+ ...at,
295
+ button: 'left',
296
+ buttons: input.type === 'down' ? 1 : 0,
297
+ clickCount: input.clickCount,
298
+ modifiers,
299
+ }
300
+ }
301
+ }
302
+
303
+ /**
304
+ * A key input as CDP takes it. A key that types sends `keyDown` with its text
305
+ * only when no modifier but Shift is held, as Chromium's own keyboard does, so
306
+ * `Control+K` in a focused field inserts no K.
307
+ */
308
+ function keyEvent(input: KeyInput): Record<string, unknown> {
309
+ const descriptor = describeKey(input.key, {
310
+ shift: input.modifiers.includes('Shift'),
311
+ })
312
+ if (!descriptor) throw new Error(`no key descriptor for ${input.key}`)
313
+ const event = {
314
+ key: descriptor.key,
315
+ code: descriptor.code,
316
+ windowsVirtualKeyCode: descriptor.keyCode,
317
+ nativeVirtualKeyCode: descriptor.keyCode,
318
+ location: descriptor.location,
319
+ modifiers: modifierBits(input.modifiers),
320
+ }
321
+ if (input.type === 'up') return { type: 'keyUp', ...event }
322
+ const types =
323
+ descriptor.text !== undefined &&
324
+ input.modifiers.every((modifier) => modifier === 'Shift')
325
+ return types
326
+ ? {
327
+ type: 'keyDown',
328
+ ...event,
329
+ text: descriptor.text,
330
+ unmodifiedText: descriptor.text,
331
+ }
332
+ : { type: 'rawKeyDown', ...event }
333
+ }
334
+
335
+ /**
336
+ * Steps a frame, then awaits the input sent ahead of it. When the step fails,
337
+ * the input is left settling against a shell about to close, so its rejection
338
+ * is handled here rather than surfacing as an unhandled one.
339
+ */
340
+ async function stepThen(
341
+ sent: Promise<unknown>,
342
+ step: () => Promise<void>,
343
+ ): Promise<void> {
344
+ try {
345
+ await step()
346
+ } catch (error) {
347
+ sent.catch(() => {})
348
+ throw error
248
349
  }
350
+ await sent
249
351
  }
250
352
 
251
353
  async function withinStall<T>(
@@ -257,6 +359,8 @@ async function withinStall<T>(
257
359
  const stalled = new Promise<never>((_, fail) => {
258
360
  timer = setTimeout(() => fail(new StepperStalled(frame, stallMs)), stallMs)
259
361
  })
362
+ // A stall leaves the work pending until the shell closes and rejects it.
363
+ work.catch(() => {})
260
364
  try {
261
365
  return await Promise.race([work, stalled])
262
366
  } finally {
@@ -325,6 +325,57 @@ export function readPendingBranches(text: string): readonly string[] {
325
325
  .filter((entry) => entry.length > 0)
326
326
  }
327
327
 
328
+ /**
329
+ * A local session's full sessionId, or the `session_` id a cloud create reads
330
+ * out. Only the full form reaches `canon sessions export`, so a short prefix is
331
+ * refused rather than written.
332
+ */
333
+ const SESSION_ID_SOURCE =
334
+ '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|session_[A-Za-z0-9]+'
335
+
336
+ export const SESSION_ID_PATTERN = new RegExp(`^(?:${SESSION_ID_SOURCE})$`)
337
+
338
+ export const SESSIONS_PREFIX = 'Sessions:'
339
+
340
+ /**
341
+ * One entry is a name and its parenthesized id, and the parse keys on that
342
+ * trailing id, so a roster name carrying a comma or its own parentheses still
343
+ * reads as one entry.
344
+ */
345
+ const SESSION_ENTRY_PATTERN = new RegExp(
346
+ `(.+?) \\((${SESSION_ID_SOURCE})\\)(?:, |$)`,
347
+ 'y',
348
+ )
349
+
350
+ export interface SessionEntry {
351
+ readonly name: string
352
+ readonly sessionId: string
353
+ }
354
+
355
+ /**
356
+ * Reads the `Sessions:` line `canon tasks session` writes. A line that does not
357
+ * parse whole reads as naming none, the way `readPullRequest` reads a line
358
+ * carrying anything besides its list.
359
+ */
360
+ export function readSessions(text: string): readonly SessionEntry[] {
361
+ const line = text
362
+ .split('\n')
363
+ .find((entry) => entry.startsWith(SESSIONS_PREFIX))
364
+ if (line === undefined) return []
365
+
366
+ const body = line.slice(SESSIONS_PREFIX.length).trim()
367
+ const entries: SessionEntry[] = []
368
+ const pattern = new RegExp(SESSION_ENTRY_PATTERN)
369
+
370
+ while (pattern.lastIndex < body.length) {
371
+ const match = pattern.exec(body)
372
+ if (!match) return []
373
+ entries.push({ name: match[1], sessionId: match[2] })
374
+ }
375
+
376
+ return entries
377
+ }
378
+
328
379
  /** A markdown link on a `Plan:` line, its target captured. */
329
380
  const PLAN_LINK_PATTERN = /\[[^\]]*\]\(([^)]+)\)/g
330
381
 
@@ -514,7 +565,8 @@ export function declineLine(reason: string, by: string, date: string): string {
514
565
  /**
515
566
  * Lines a `Declined:` line anchors after, mirroring `record.ts`'s
516
567
  * `ORIGIN_PREFIXES` with `Pull request:` folded in, since a decline can follow
517
- * a pull request that never merged.
568
+ * a pull request that never merged. `Sessions:` joins them, since the sessions
569
+ * that worked a task sit above whatever ended it.
518
570
  */
519
571
  const DECLINE_ANCHOR_PREFIXES = [
520
572
  'Plan:',
@@ -522,6 +574,7 @@ const DECLINE_ANCHOR_PREFIXES = [
522
574
  'Intake:',
523
575
  'Issue:',
524
576
  'Pull request:',
577
+ SESSIONS_PREFIX,
525
578
  ] as const
526
579
 
527
580
  function lastAnchorLine(lines: readonly string[]): number | undefined {