@erclx/canon 4.98.0 → 4.99.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.
@@ -22,6 +22,7 @@ import {
22
22
  } from '@/pr/bijection'
23
23
  import { type CheckRunListing, collapseChecks } from '@/pr/checks'
24
24
  import {
25
+ findEvidenceChecklist,
25
26
  findEvidenceCommentId,
26
27
  findEvidencePreview,
27
28
  groupEvidence,
@@ -70,6 +71,7 @@ interface ReadOptions {
70
71
 
71
72
  interface EvidenceOptions extends ReadOptions {
72
73
  readonly preview?: string
74
+ readonly checklist?: string
73
75
  }
74
76
 
75
77
  interface PreviewOptions extends ReadOptions {
@@ -137,10 +139,13 @@ type EvidenceRefusal =
137
139
  | 'no-base'
138
140
  | 'unreadable-tree'
139
141
  | 'unreadable-changes'
142
+ | 'unreadable-checklist'
140
143
 
141
144
  const EVIDENCE_REFUSALS: Record<EvidenceRefusal, string> = {
142
145
  'gh-failed':
143
146
  'gh could not answer for this repository. Name the pull request number.',
147
+ 'unreadable-checklist':
148
+ 'The file named by --checklist could not be read, or holds nothing. Rendering without it would drop the only copy, since the caller deletes the handoff once a post reports success.',
144
149
  'no-base': 'No base resolves against the trunk. Fetch origin and re-run.',
145
150
  'unreadable-tree':
146
151
  'git could not read the tree at the base commit, so no path could be judged added or changed.',
@@ -369,6 +374,10 @@ export function register(program: Command): void {
369
374
  '--preview <url>',
370
375
  'Open the body with this preview address, from canon pr preview',
371
376
  )
377
+ .option(
378
+ '--checklist <path>',
379
+ 'Close the body with this visual checklist file, from canon:ui-checklist',
380
+ )
372
381
  .addHelpText(
373
382
  'after',
374
383
  [
@@ -393,6 +402,14 @@ export function register(program: Command): void {
393
402
  'Without --preview, an address the marked comment already opens with',
394
403
  'is carried into the new body, so a re-render after a push keeps it.',
395
404
  '',
405
+ '--checklist closes the body with a visual checklist, below the',
406
+ 'comparison it annotates. A checklist the marked comment already',
407
+ 'carries is read back and carried forward the same way the preview',
408
+ 'address is, which keeps a re-render from wiping ticked boxes. It does',
409
+ 'not decide no-evidence: a branch with a checklist and no evidence',
410
+ 'image still reports no-evidence, and the caller posts the checklist',
411
+ 'on its own.',
412
+ '',
396
413
  'Exit codes:',
397
414
  ' 0 read, whether it produced a body or reported no-evidence',
398
415
  ' 1 refused, with the reason on stderr or in the JSON record',
@@ -401,6 +418,7 @@ export function register(program: Command): void {
401
418
  ' canon pr evidence --json',
402
419
  ' canon pr evidence 1341 --json',
403
420
  ' canon pr evidence 1341 --preview https://feat-x.site.pages.dev --json',
421
+ ' canon pr evidence 1341 --checklist .canon/tmp/handoff/ui-checklist/x.md --json',
404
422
  '',
405
423
  ].join('\n'),
406
424
  )
@@ -1214,6 +1232,23 @@ async function runEvidence(
1214
1232
 
1215
1233
  intro('canon pr evidence')
1216
1234
 
1235
+ // Read ahead of anything else, so a bad path refuses before a `gh` round
1236
+ // trip rather than after one.
1237
+ let suppliedChecklist: string | undefined
1238
+ if (opts.checklist !== undefined) {
1239
+ suppliedChecklist = await readFile(resolve(root, opts.checklist), 'utf8')
1240
+ .then((text) => text.trim() || undefined)
1241
+ .catch(() => undefined)
1242
+ if (suppliedChecklist === undefined) {
1243
+ return refuseWith(
1244
+ 'unreadable-checklist',
1245
+ EVIDENCE_REFUSALS['unreadable-checklist'],
1246
+ emitJson,
1247
+ root,
1248
+ )
1249
+ }
1250
+ }
1251
+
1217
1252
  const read = await readIdentity(root, number)
1218
1253
  if (read.kind === 'refused') {
1219
1254
  return refuseWith(read.reason, PULL_REFUSALS[read.reason], emitJson, root)
@@ -1294,6 +1329,7 @@ async function runEvidence(
1294
1329
 
1295
1330
  let commentId: number | undefined
1296
1331
  let carriedPreview: string | undefined
1332
+ let carriedChecklist: string | undefined
1297
1333
  if (identity.number !== undefined) {
1298
1334
  const commentsRow = await gh(root, [
1299
1335
  'pr',
@@ -1327,11 +1363,20 @@ async function runEvidence(
1327
1363
  }
1328
1364
  commentId = findEvidenceCommentId(comments)
1329
1365
  carriedPreview = findEvidencePreview(comments)
1366
+ carriedChecklist = findEvidenceChecklist(comments)
1330
1367
  }
1331
1368
 
1332
1369
  const preview = opts.preview ?? carriedPreview
1370
+ const checklist = suppliedChecklist ?? carriedChecklist
1333
1371
  const states = grouped.kind === 'read' ? grouped.states : []
1334
- const body = renderEvidenceBody(states, repo, base, identity.head, preview)
1372
+ const body = renderEvidenceBody(
1373
+ states,
1374
+ repo,
1375
+ base,
1376
+ identity.head,
1377
+ preview,
1378
+ checklist,
1379
+ )
1335
1380
 
1336
1381
  const caseCount = states.reduce((n, s) => n + s.items.length, 0)
1337
1382
 
@@ -1340,6 +1385,13 @@ async function runEvidence(
1340
1385
  `${plural(caseCount, 'case')} across ${plural(states.length, 'state')}`,
1341
1386
  )
1342
1387
  if (preview !== undefined) logInfo(`preview ${preview}`)
1388
+ if (checklist !== undefined) {
1389
+ logInfo(
1390
+ suppliedChecklist === undefined
1391
+ ? 'checklist carried forward from the marked comment'
1392
+ : 'checklist folded in below the comparison',
1393
+ )
1394
+ }
1343
1395
 
1344
1396
  outro()
1345
1397
 
@@ -146,6 +146,11 @@ export function register(program: Command): void {
146
146
  'plan, and the archived task keeps a working Plan: pointer at the new',
147
147
  'path. A plan several tasks share stays where it is.',
148
148
  '',
149
+ 'The ready folder the task names moves to .canon/ready/archive/ with its',
150
+ 'plan, and the Ready: line and the plan path to it are retargeted. A',
151
+ 'folder that does not resolve, or a destination already taken, moves',
152
+ 'nothing and refuses nothing.',
153
+ '',
149
154
  'Exit codes:',
150
155
  ' 0 the task was archived',
151
156
  ' 1 refused, with the reason on stderr or in the JSON record',
@@ -1310,6 +1315,11 @@ function report(
1310
1315
  logAdd(relative(root, outcome.plan.to))
1311
1316
  logInfo('retargeted the Plan: line')
1312
1317
  }
1318
+ if (outcome.ready) {
1319
+ logRemove(relative(root, outcome.ready.from))
1320
+ logAdd(relative(root, outcome.ready.to))
1321
+ logInfo('retargeted the Ready: line')
1322
+ }
1313
1323
  if (outcome.priorityRowRemoved) logInfo('cleared the ordering row')
1314
1324
  if (outcome.indexRegenerated) logInfo('regenerated index.md')
1315
1325
  if (outcome.cut > 0) logInfo(`${outcome.cut} outcome(s) cut`)
@@ -1344,6 +1354,12 @@ function recordFor(
1344
1354
  to: relative(root, outcome.plan.to),
1345
1355
  }
1346
1356
  : null,
1357
+ ready: outcome.ready
1358
+ ? {
1359
+ from: relative(root, outcome.ready.from),
1360
+ to: relative(root, outcome.ready.to),
1361
+ }
1362
+ : null,
1347
1363
  closed: outcome.closed,
1348
1364
  cut: outcome.cut,
1349
1365
  }
@@ -107,11 +107,14 @@ export interface DemoStep {
107
107
  readonly note?: string
108
108
  }
109
109
 
110
+ export type ColorScheme = 'light' | 'dark'
111
+
110
112
  export interface DemoPlan {
111
113
  readonly slug: string
112
114
  readonly title: string
113
115
  readonly url: string
114
116
  readonly viewport: { readonly width: number; readonly height: number }
117
+ readonly colorScheme?: ColorScheme
115
118
  readonly output: { readonly video: string; readonly still: string }
116
119
  readonly pointer: { readonly travelMs: number; readonly typeDelayMs: number }
117
120
  readonly annotations: typeof ANNOTATIONS
@@ -229,6 +232,9 @@ export function parsePlan(text: string): PlanParse {
229
232
  steps.push(step.step)
230
233
  }
231
234
 
235
+ const colorScheme = parseColorScheme(raw.colorScheme)
236
+ if (colorScheme.status === 'failed') return colorScheme
237
+
232
238
  const slug = asText(raw.slug)
233
239
  const output = isRecord(raw.output) ? raw.output : {}
234
240
  const pointer = isRecord(raw.pointer) ? raw.pointer : {}
@@ -244,6 +250,7 @@ export function parsePlan(text: string): PlanParse {
244
250
  width: asNumber(viewport.width, VIEWPORT.width),
245
251
  height: asNumber(viewport.height, VIEWPORT.height),
246
252
  },
253
+ ...(colorScheme.value && { colorScheme: colorScheme.value }),
247
254
  output: {
248
255
  video: asText(output.video) || `demos/${slug || 'demo'}.webm`,
249
256
  still: asText(output.still) || `demos/${slug || 'demo'}.png`,
@@ -258,6 +265,19 @@ export function parsePlan(text: string): PlanParse {
258
265
  }
259
266
  }
260
267
 
268
+ function parseColorScheme(
269
+ value: unknown,
270
+ ):
271
+ | { status: 'ok'; value?: ColorScheme }
272
+ | { status: 'failed'; reason: string } {
273
+ if (value === undefined) return { status: 'ok' }
274
+ if (value === 'light' || value === 'dark') return { status: 'ok', value }
275
+ return {
276
+ status: 'failed',
277
+ reason: `colorScheme is ${JSON.stringify(value)}, which is not light or dark`,
278
+ }
279
+ }
280
+
261
281
  function parseStep(
262
282
  entry: unknown,
263
283
  index: number,
package/src/demo/drive.ts CHANGED
@@ -118,6 +118,7 @@ export async function drive(options: DriveOptions): Promise<DriveResult> {
118
118
 
119
119
  context = await browser.value.newContext({
120
120
  viewport: plan.viewport,
121
+ ...(plan.colorScheme && { colorScheme: plan.colorScheme }),
121
122
  // Pointed the opposite way from a test. A recording wants the motion the
122
123
  // interface was designed with, where a test wants it suppressed.
123
124
  reducedMotion: 'no-preference',
@@ -21,6 +21,13 @@ import { defineRenameRules, type RenameRules } from '@/migrate/rename'
21
21
  * reading in sequence with `draft-screencast` as one three-step pipeline with
22
22
  * no prefix forcing that reading.
23
23
  *
24
+ * One row renames a skill rather than retiring a prefix. `ui-test` became
25
+ * `ui-checklist` when the skill stopped writing tests and shrank to the
26
+ * checklist a reviewer reads, so the old name described a job it no longer
27
+ * does. `claude-ui-test` is retargeted onto the same destination rather than
28
+ * left pointing at the retired name, which lands a target on the oldest
29
+ * spelling in one pass instead of two.
30
+ *
24
31
  * Every name takes two words. Ten of these would have landed as a bare single
25
32
  * word under a plain strip, and a bare word such as `review` or `docs` is a
26
33
  * substring of ordinary prose with no token left for a later sweep to find.
@@ -56,7 +63,8 @@ export const SKILL_NAME_MAP: Readonly<Record<string, string>> = {
56
63
  'claude-standards-audit': 'standards-audit',
57
64
  'claude-tasks': 'task-board',
58
65
  'claude-teach': 'teach-workspace',
59
- 'claude-ui-test': 'ui-test',
66
+ 'claude-ui-test': 'ui-checklist',
67
+ 'ui-test': 'ui-checklist',
60
68
  'claude-ux-audit': 'ux-audit',
61
69
  'claude-ux-measure': 'ux-measure',
62
70
  'claude-worker': 'role-worker',
@@ -72,11 +80,11 @@ export const SKILL_NAME_MAP: Readonly<Record<string, string>> = {
72
80
  *
73
81
  * It protects nothing. The `aitk` sweep had to guard a sibling repository
74
82
  * whose name contained the token, and no string here contains a skill name
75
- * while meaning something else: the one overlap in the map is
76
- * `claude-intake` inside `claude-intake-answer`, which the longest-first
77
- * ordering settles rather than a protected form. Reading that off the output
78
- * would report a pass either way, since both rows land on a name opening with
79
- * `plan-intake`, so the ordering is asserted directly.
83
+ * while meaning something else. Two rows overlap another row: `claude-intake`
84
+ * inside `claude-intake-answer`, and `ui-test` inside `claude-ui-test`. The
85
+ * longest-first ordering settles both rather than a protected form. Reading
86
+ * either off the output would report a pass anyway, since each pair lands on
87
+ * one destination, so the ordering is asserted directly.
80
88
  *
81
89
  * It matches whole tokens. A skill name is a complete name rather than a word
82
90
  * stem, and the corpus holds one word that opens with a name and means
@@ -7,6 +7,13 @@ const MARKER_PREFIX = '<!-- pr-evidence:'
7
7
  /** How the first line of a body names the branch's preview deployment. */
8
8
  const PREVIEW_PREFIX = '**Preview:**'
9
9
 
10
+ /** The heading the checklist sits under, printed outside the delimiters so a carried checklist never doubles it. */
11
+ const CHECKLIST_HEADING = '## What to look at'
12
+
13
+ /** Delimiters around the checklist, so a re-render can read back what a reviewer has already ticked. */
14
+ const CHECKLIST_START = '<!-- pr-checklist:start -->'
15
+ const CHECKLIST_END = '<!-- pr-checklist:end -->'
16
+
10
17
  /** A `#issuecomment-<id>` suffix, which is the REST comment id `gh pr view` never returns directly. */
11
18
  const ISSUE_COMMENT_ID = /#issuecomment-(\d+)$/
12
19
 
@@ -116,6 +123,9 @@ export function evidenceMarker(head: string): string {
116
123
  * A preview address opens the body, and with no states it is the whole body
117
124
  * apart from the marker, so a pull request whose screenshots did not change
118
125
  * still gets one comment a later call can find and edit in place.
126
+ *
127
+ * A checklist closes the body, below the comparison it annotates, so one
128
+ * comment carries the preview address, the screenshots, and what to look at.
119
129
  */
120
130
  export function renderEvidenceBody(
121
131
  states: readonly EvidenceState[],
@@ -123,11 +133,16 @@ export function renderEvidenceBody(
123
133
  base: string,
124
134
  head: string,
125
135
  preview?: string,
136
+ checklist?: string,
126
137
  ): string {
127
138
  const opening =
128
139
  preview === undefined ? [] : [`${PREVIEW_PREFIX} ${preview}`, '']
140
+ const closing =
141
+ checklist === undefined
142
+ ? []
143
+ : [CHECKLIST_HEADING, '', CHECKLIST_START, checklist, CHECKLIST_END, '']
129
144
  if (states.length === 0) {
130
- return [...opening, evidenceMarker(head)].join('\n')
145
+ return [...opening, ...closing, evidenceMarker(head)].join('\n')
131
146
  }
132
147
 
133
148
  const sections = states.map((entry) => {
@@ -157,6 +172,7 @@ export function renderEvidenceBody(
157
172
  '',
158
173
  ...sections,
159
174
  '',
175
+ ...closing,
160
176
  evidenceMarker(head),
161
177
  ].join('\n')
162
178
  }
@@ -204,3 +220,22 @@ export function findEvidencePreview(
204
220
  if (!first.startsWith(PREVIEW_PREFIX)) return undefined
205
221
  return first.slice(PREVIEW_PREFIX.length).trim() || undefined
206
222
  }
223
+
224
+ /**
225
+ * The checklist the marked comment already carries, read back out of its
226
+ * delimiters. The handoff file is deleted once posted, so every later render
227
+ * passes no checklist of its own, and carrying this one forward is what keeps
228
+ * a re-render after a push from wiping the boxes a reviewer has ticked.
229
+ */
230
+ export function findEvidenceChecklist(
231
+ comments: readonly EvidenceComment[],
232
+ ): string | undefined {
233
+ const marked = comments.find((comment) => hasEvidenceMarker(comment.body))
234
+ if (marked === undefined) return undefined
235
+ const start = marked.body.indexOf(CHECKLIST_START)
236
+ const end = marked.body.indexOf(CHECKLIST_END, start)
237
+ if (start === -1 || end === -1) return undefined
238
+ return (
239
+ marked.body.slice(start + CHECKLIST_START.length, end).trim() || undefined
240
+ )
241
+ }
@@ -178,7 +178,8 @@ function contentArray(value: unknown): ContentAssertion[] {
178
178
  // A bare key written below a `[[content]]` header belongs to that table in
179
179
  // TOML, not to the document, so a declaration listing `manual` or
180
180
  // `max_turns` after its content blocks parses clean and silently asserts
181
- // neither. The `claude/ui-test` arm shipped that way: a turn ceiling that
181
+ // neither. The arm then named `claude/ui-test`, now
182
+ // `claude/ui-checklist`, shipped that way: a turn ceiling that
182
183
  // never ran and five manual entries that never reached the unchecked count,
183
184
  // while `canon sandbox coverage` counted the arm as armed. Nothing at the
184
185
  // top level can see the difference, so the check belongs here.
@@ -7,6 +7,7 @@ import { recordDir, recordDirs } from '@/record-root'
7
7
 
8
8
  const TASKS = 'tasks'
9
9
  const PLANS = 'plans'
10
+ const READY = 'ready'
10
11
  const ARCHIVE = 'archive'
11
12
  const DECLINED = 'declined'
12
13
  const BACKLOG = 'backlog.md'
@@ -75,6 +76,12 @@ export interface PlanMove {
75
76
  readonly to: string
76
77
  }
77
78
 
79
+ /** A ready folder carried into the archive alongside the plan that is its verbatim source. */
80
+ export interface ReadyMove {
81
+ readonly from: string
82
+ readonly to: string
83
+ }
84
+
78
85
  export interface ArchiveSuccess {
79
86
  readonly ok: true
80
87
  readonly stem: string
@@ -84,6 +91,8 @@ export interface ArchiveSuccess {
84
91
  readonly indexRegenerated: boolean
85
92
  /** Undefined when the task cited no live plan, or when another task still holds it. */
86
93
  readonly plan: PlanMove | undefined
94
+ /** Undefined when the plan stays, or when no ready folder resolves and can move. */
95
+ readonly ready: ReadyMove | undefined
87
96
  readonly closed: number
88
97
  readonly cut: number
89
98
  }
@@ -280,6 +289,32 @@ export function retargetPlanLine(text: string, target: string): string {
280
289
  return text.replace(PLAN_PATTERN, () => planLine(target))
281
290
  }
282
291
 
292
+ /** The `Ready:` line in either form, matched the way `PLAN_PATTERN` matches `Plan:`. */
293
+ const READY_PATTERN = /^Ready:[ \t]*(?:\[[^\]]*\]\(([^)]+)\)|(\S+))[ \t]*$/m
294
+
295
+ /** A ready folder path as a plan's constraints write it, the folder name captured. */
296
+ const READY_PATH_PATTERN = /(\.(?:canon|claude)\/ready\/)([^\s/`)]+)(?=\/)/g
297
+
298
+ export function readReadyTarget(text: string): string | undefined {
299
+ const match = READY_PATTERN.exec(text)
300
+ if (!match) return undefined
301
+ return match[1] ?? match[2]
302
+ }
303
+
304
+ /**
305
+ * Builds a `Ready:` line as a link whose target keeps the trailing slash a
306
+ * folder carries, the label taken from the folder's own name.
307
+ */
308
+ export function readyLine(target: string): string {
309
+ const folder = target.replace(/\/+$/, '')
310
+
311
+ return `Ready: [${basename(folder)}](${folder}/)`
312
+ }
313
+
314
+ export function retargetReadyLine(text: string, target: string): string {
315
+ return text.replace(READY_PATTERN, () => readyLine(target))
316
+ }
317
+
283
318
  /**
284
319
  * Builds the `Declined:` line recording why a task was decided against and by
285
320
  * whom. Free prose after the colon, since the line names no file to link,
@@ -731,22 +766,41 @@ export async function archiveTask(
731
766
  }
732
767
 
733
768
  const plan = await planToArchive(dir, root, stem, text)
769
+ const ready = plan && (await readyToArchive(dir, root, text, plan))
734
770
  const destination = archiveDir(root)
735
771
  const to = join(destination, `${stem}.md`)
736
772
 
737
773
  // The plan moves first so the line written below describes a file already at
738
774
  // its new path. Writing the retarget first and failing the move would leave a
739
775
  // pointer at a folder holding nothing, and `.canon/plans/` is gitignored, so
740
- // no history recovers the target it named.
776
+ // no history recovers the target it named. The folder follows the plan and
777
+ // both precede the task, so a failed rename never leaves the task archived
778
+ // with its folder live, which the unattended post-merge hook cannot repair.
741
779
  if (plan) {
742
780
  await mkdir(dirname(plan.to), { recursive: true })
743
781
  await rename(plan.from, plan.to)
744
782
  }
745
783
 
784
+ if (ready) {
785
+ await mkdir(dirname(ready.to), { recursive: true })
786
+ await rename(ready.from, ready.to)
787
+ await writeFile(
788
+ plan.to,
789
+ retargetReadyPaths(await readFile(plan.to, 'utf8'), basename(ready.from)),
790
+ )
791
+ }
792
+
746
793
  await mkdir(destination, { recursive: true })
747
794
  await rename(from, to)
748
- if (plan)
749
- await writeFile(to, retargetPlanLine(text, linkTo(destination, plan.to)))
795
+ if (plan) {
796
+ const retargeted = retargetPlanLine(text, linkTo(destination, plan.to))
797
+ await writeFile(
798
+ to,
799
+ ready
800
+ ? retargetReadyLine(retargeted, `${linkTo(destination, ready.to)}/`)
801
+ : retargeted,
802
+ )
803
+ }
750
804
 
751
805
  const priorityRowRemoved = await clearPriorityRow(dir, stem)
752
806
  const regen = await regenOne(dir, { dryRun: false })
@@ -759,6 +813,7 @@ export async function archiveTask(
759
813
  priorityRowRemoved,
760
814
  indexRegenerated: regen.action === 'written',
761
815
  plan,
816
+ ready: ready || undefined,
762
817
  closed: closed.length,
763
818
  cut: cut.length,
764
819
  }
@@ -797,6 +852,63 @@ async function planToArchive(
797
852
  }
798
853
  }
799
854
 
855
+ /**
856
+ * The ready folder that travels with the plan, or nothing. It is read from the
857
+ * task's `Ready:` line, falling back to a folder path in the plan's own text for
858
+ * a task written before the line was defined. It moves only with its plan,
859
+ * since the folder is the plan's verbatim source and a plan another task still
860
+ * cites still needs it, so the caller passes the plan `planToArchive` settled.
861
+ *
862
+ * A target naming no folder, one already archived, or a destination taken
863
+ * yields nothing and refuses nothing, the way a mistyped `Plan:` does.
864
+ */
865
+ async function readyToArchive(
866
+ dir: string,
867
+ root: string,
868
+ text: string,
869
+ plan: PlanMove,
870
+ ): Promise<ReadyMove | undefined> {
871
+ const live =
872
+ readyFolder(dir, root, text) ??
873
+ readyFolder(dir, root, await readFile(plan.from, 'utf8'))
874
+ if (!live) return undefined
875
+
876
+ const to = join(recordDir(root, READY, ARCHIVE), basename(live))
877
+ if (existsSync(to)) return undefined
878
+
879
+ return { from: live, to }
880
+ }
881
+
882
+ function readyFolder(
883
+ dir: string,
884
+ root: string,
885
+ text: string,
886
+ ): string | undefined {
887
+ const target = readReadyTarget(text)
888
+ const named = target ? [resolve(dir, target), resolve(root, target)] : []
889
+ const constrained = [...text.matchAll(READY_PATH_PATTERN)].map(([, , name]) =>
890
+ join(recordDir(root, READY), name),
891
+ )
892
+ const archives = recordDirs(root, READY, ARCHIVE)
893
+
894
+ return [...named, ...constrained].find(
895
+ (path) =>
896
+ recordDirs(root, READY).some((base) => dirname(path) === base) &&
897
+ basename(path) !== ARCHIVE &&
898
+ !archives.some((base) => isUnder(path, base)) &&
899
+ existsSync(path),
900
+ )
901
+ }
902
+
903
+ /** Points the plan's literal folder paths at `ready/archive/`, since the folder no longer sits where they say. */
904
+ function retargetReadyPaths(text: string, folder: string): string {
905
+ return text.replace(
906
+ READY_PATH_PATTERN,
907
+ (whole: string, prefix: string, name: string) =>
908
+ name === folder ? `${prefix}${ARCHIVE}/${name}` : whole,
909
+ )
910
+ }
911
+
800
912
  /**
801
913
  * The archived plan as the archived task cites it. Both halves land a folder
802
914
  * deeper than the live pair, so the link is measured between the two
@@ -78,7 +78,7 @@ Below the frontmatter, state in prose what the worker still owns beyond copying
78
78
  ## Lifecycle
79
79
 
80
80
  - Write the ready folder in the same session that writes the files it carries. A folder assembled later from memory is a plan with extra steps, not a handoff.
81
- - Move the folder to `.canon/ready/archive/<nn>-<slug>/` by hand when the task that shipped it archives. No board verb currently automates this move. `canon tasks archive` moves the task and its plan and leaves the ready folder where it is.
81
+ - Leave the move to `canon tasks archive`, which carries the folder to `.canon/ready/archive/<nn>-<slug>/` with its plan when the task that shipped it archives, and retargets the task's `Ready:` line. A plan another live task still cites keeps its folder.
82
82
  - Never delete a ready folder. The archived copy sits beside the merged pull request as the exact text that shipped, the way an archived plan sits beside the reasoning that produced it.
83
83
 
84
84
  ## Anti-patterns
@@ -86,7 +86,7 @@ Below the frontmatter, state in prose what the worker still owns beyond copying
86
86
  - **The folder with an undeclared destination.** A file the plan's `**Files to touch:**` does not list passes the collision check unseen, and a second track can write the same path without either side finding out.
87
87
  - **The rewritten copy.** A worker that reads the folder's files as inspiration and writes its own version loses the exact text the handoff exists to carry.
88
88
  - **The folder as scratch.** Notes, alternates, or draft passages left in the folder beside the real files leave the worker guessing which is the source.
89
- - **The folder left live after shipping.** A ready folder nobody moves to the archive reads as unshipped work to the next session that lists the live folder.
89
+ - **The folder left live after shipping.** A ready folder the archive verb did not move reads as unshipped work to the next session that lists the live folder. The verb skips one whose task carries no `Ready:` line and cites no folder path in its plan.
90
90
 
91
91
  ## Template
92
92
 
@@ -213,7 +213,9 @@ A task with no origin is either lost context or work nobody decided to do. The i
213
213
 
214
214
  An intake folder answers that direction at folder scope rather than item scope, since one dump dispositions many items and most close without ever becoming a task. What names a folder is every item answered and no task citing it, on the board or in the archive. That is a dump nobody acted on. Counting the archive and declined folder beside the board is what separates it from one already promoted and settled, and a check reading the board alone calls every finished folder abandoned.
215
215
 
216
- `Plan:`, `Groundwork:`, and `Intake:` name their target as a markdown link whose text is the file or folder stem, so the line resolves on a ctrl-click the way `priority.md` rows already do. Write the path relative to `.canon/tasks/`, which makes it `../plans/`, `../groundwork/`, and `../intake/`. A path written from the project root renders as a link and resolves to nothing in an editor rooted at the project. `Issue:` stays a bare `#NNN`, since an issue number is not a path and a full URL would write the remote into a gitignored file.
216
+ `Plan:`, `Groundwork:`, and `Intake:` name their target as a markdown link whose text is the file or folder stem, so the line resolves on a ctrl-click the way `priority.md` rows already do. Write the path relative to `.canon/tasks/`, which makes it `../plans/`, `../groundwork/`, and `../intake/`. A path written from the project root renders as a link and resolves to nothing in an editor rooted at the project. `Ready:` names the ready folder a task's plan copies from, as a link to `../ready/<nn>-<slug>/` with the trailing slash a folder carries. Include it only when the folder exists. `canon tasks archive` reads it to move the folder, and falls back to a `.canon/ready/<nn>-<slug>/` path in the plan when a task carries no line.
217
+
218
+ `Issue:` stays a bare `#NNN`, since an issue number is not a path and a full URL would write the remote into a gitignored file.
217
219
 
218
220
  Phase-label format and where labels may appear are governed by `standards/versioning.md`.
219
221
 
@@ -257,7 +259,7 @@ The archive nests inside `.canon/tasks/` rather than sitting beside it as a flat
257
259
 
258
260
  One destination rather than a per-project choice is what lets the move happen without asking. It mirrors the plans archive at `.canon/plans/archive/`, sitting inside the folder it archives the same way, and it inherits the board's own ignore entry rather than needing one of its own. The cost is that an archived task does not appear in diffs, which is the cost the live board already carries.
259
261
 
260
- Archiving a task archives its plan alongside it, when the closing task is that plan's last live citation. The archived task's `Plan:` line is retargeted at `../../plans/archive/feature-<slug>.md`, a folder deeper than the live task wrote it, so a completed task still leads to the reasoning behind it. A plan several tasks share stays live and the task archives anyway, since moving it on the first task to close strands every sibling's pointer at a path that has gone.
262
+ Archiving a task archives its plan alongside it, when the closing task is that plan's last live citation. The archived task's `Plan:` line is retargeted at `../../plans/archive/feature-<slug>.md`, a folder deeper than the live task wrote it, so a completed task still leads to the reasoning behind it. The ready folder the task's `Ready:` line names moves with the plan to `.canon/ready/archive/<nn>-<slug>/`, and the `Ready:` line and the plan's path to the folder are retargeted, since the folder is the plan's verbatim source. A plan several tasks share stays live and the task archives anyway, since moving it on the first task to close strands every sibling's pointer at a path that has gone. Its ready folder stays with it.
261
263
 
262
264
  One act rather than two is what makes the pair safe. The merge is the event that settles a plan, and a `post-merge` hook reaching the archive with nobody watching cannot act on a warning, so a second call after it would be a second failure point leaving the task archived and the plan live.
263
265
 
@@ -1,48 +0,0 @@
1
- ---
2
- name: ui-test
3
- description: Why UI changes split into what a browser can assert and what only an eye can judge, and why the visual half is written to disk rather than printed
4
- ---
5
-
6
- # UI test requirement
7
-
8
- ## Gap
9
-
10
- Without this skill, UI work ships on the confidence of the session that wrote it. What verification does happen mixes the two kinds without noticing, so a checklist item about spacing sits beside one about a form submission, and neither is done properly because they need different work from different people. Tests get written against a config the project does not use, or duplicate what a component test already covers.
11
-
12
- The automatable half fails the other way. Sending every interaction, state, and form to Playwright leaves the layers unrouted, so a loading state lands in a full browser run and the end to end suite grows on each UI change. A state a component test reaches costs a browser to check, and the cost recurs on every later run.
13
-
14
- The visual half is where the record is lost. A checklist printed into chat scrolls away before anyone verifies it, so the ship step has nothing to gate on and the work reads as verified because a list was produced. Written from a linked worktree against `pwd`, the file lands where the caller does not look.
15
-
16
- Writing the checklist correctly to disk does not close the gap either. The file sits under a gitignored folder on the machine that authored it, so a pull request reviewer, on that machine or another, never sees it. A record that only the author can open reads as verified for the same reason a record that never existed does.
17
-
18
- ## Must
19
-
20
- - Split every change into visual-only and automatable before writing anything, then route each automatable one to its layer through `test-craft`
21
- - Write end to end only for a journey or a behavior only a real browser renders, and component or unit tests for the rest
22
- - Name a missing component runner in the output and put the state on the checklist
23
- - Read the project's existing test config and patterns before writing a test against them
24
- - Assert a user action and its outcome per test, covering the happy path and the key edge cases
25
- - Run the tests after writing them and fix what fails
26
- - Write a produced checklist to the `.canon/tmp/handoff/ui-checklist/<slug>.md` handoff at the main worktree root, overwriting
27
- - Report that everything is covered rather than manufacturing a checklist to show work
28
-
29
- ## Must not
30
-
31
- - Try to assert a visual property programmatically
32
- - Re-test what unit or component tests written during implementation already cover
33
- - Route a state a component test reaches into end to end, or fall back to end to end silently when no runner exists
34
- - Repeat the full checklist in chat, which is what made it evaporate
35
- - Stage or commit the checklist, which is gitignored scratch
36
- - Talk to GitHub directly. Posting the checklist to a pull request belongs to `git-pr`, the sole consumer of the handoff file
37
-
38
- ## Guards
39
-
40
- - No implementation context in the session: stop, since there is nothing to derive tests from
41
- - Every change automatable: skip the file write and report the coverage
42
-
43
- ## Out of scope
44
-
45
- - Judging whether the UI is any good, which `ux-audit` owns
46
- - The rules for choosing a layer, which `test-craft` owns
47
- - Deciding whether an outstanding checklist blocks the ship, which the calling pipeline gates on
48
- - Performing the visual verification, which needs a person