@erclx/canon 5.27.0 → 5.28.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 (139) hide show
  1. package/claude/.claude-plugin/plugin.json +1 -1
  2. package/claude/skills/draft-ready/SKILL.md +3 -1
  3. package/claude/skills/git-pr/references/evidence.md +1 -1
  4. package/claude/skills/review-address/REQUIREMENT.md +4 -2
  5. package/claude/skills/review-address/SKILL.md +16 -7
  6. package/claude/skills/review-address/references/rebase-conflicts.md +4 -2
  7. package/claude/skills/review-craft/references/ui.md +4 -4
  8. package/claude/skills/review-ui/REQUIREMENT.md +2 -2
  9. package/claude/skills/review-ui/SKILL.md +13 -11
  10. package/claude/skills/review-ui/references/driving.md +8 -8
  11. package/claude/skills/review-ui/references/post.md +13 -3
  12. package/claude/skills/review-ui/references/tick.md +22 -0
  13. package/claude/skills/role-orchestrator/REQUIREMENT.md +15 -15
  14. package/claude/skills/role-orchestrator/SKILL.md +24 -26
  15. package/claude/skills/role-orchestrator/references/orchestrator-dispatch.md +18 -16
  16. package/claude/skills/role-orchestrator/references/orchestrator-handback.md +12 -2
  17. package/claude/skills/role-orchestrator/references/orchestrator-inbound.md +1 -1
  18. package/claude/skills/role-orchestrator/references/orchestrator-launch.md +1 -1
  19. package/claude/skills/role-orchestrator/references/orchestrator-output.md +4 -4
  20. package/claude/skills/role-orchestrator/references/orchestrator-parked.md +5 -5
  21. package/claude/skills/role-orchestrator/references/orchestrator-poll.md +11 -2
  22. package/claude/skills/role-orchestrator/references/orchestrator-refill.md +6 -6
  23. package/claude/skills/role-orchestrator/references/orchestrator-sweep.md +2 -2
  24. package/claude/skills/role-orchestrator/scripts/poll.ts +304 -10
  25. package/claude/skills/role-planner/REQUIREMENT.md +1 -1
  26. package/claude/skills/role-planner/SKILL.md +2 -2
  27. package/claude/skills/role-reviewer/SKILL.md +1 -1
  28. package/claude/skills/role-worker/SKILL.md +3 -2
  29. package/claude/skills/ui-checklist/SKILL.md +1 -1
  30. package/docs/agents/canvas.md +4 -4
  31. package/docs/agents/gate.md +3 -1
  32. package/docs/agents/index.md +2 -1
  33. package/docs/agents/pr-evidence.md +9 -8
  34. package/docs/agents/pr-tick.md +72 -0
  35. package/docs/agents/sandbox.md +2 -2
  36. package/docs/agents/tasks-plans.md +1 -1
  37. package/docs/agents/tasks-validate.md +18 -18
  38. package/docs/agents/tasks.md +1 -1
  39. package/docs/workflow/operating-model.md +10 -15
  40. package/docs/workflow/parallel-features.md +7 -7
  41. package/package.json +1 -1
  42. package/src/audits/baseline.ts +1 -1
  43. package/src/audits/run.ts +1 -1
  44. package/src/canvas/capture.ts +1 -1
  45. package/src/canvas/client/inspector/field.tsx +61 -2
  46. package/src/canvas/client/inspector/fields.css +78 -0
  47. package/src/canvas/client/inspector/token-select.tsx +109 -0
  48. package/src/canvas/client/inspector/values.ts +74 -2
  49. package/src/canvas/client/inspector.tsx +121 -11
  50. package/src/canvas/client/keys.ts +54 -0
  51. package/src/canvas/client/selection.tsx +3 -1
  52. package/src/canvas/client/shell.css +108 -2
  53. package/src/canvas/client/state.ts +96 -5
  54. package/src/canvas/client/surface.tsx +194 -17
  55. package/src/canvas/client/tools.tsx +64 -0
  56. package/src/canvas/content.ts +1 -1
  57. package/src/canvas/edit.ts +12 -2
  58. package/src/canvas/tokens.ts +1 -1
  59. package/src/claude/seeds.ts +2 -2
  60. package/src/claude/skills-reach.ts +1 -1
  61. package/src/commands/canvas.ts +1 -1
  62. package/src/commands/claude.ts +3 -3
  63. package/src/commands/design.ts +3 -3
  64. package/src/commands/docs.ts +1 -1
  65. package/src/commands/feedback.ts +2 -2
  66. package/src/commands/gate.ts +9 -2
  67. package/src/commands/gov.ts +2 -2
  68. package/src/commands/init.ts +1 -1
  69. package/src/commands/migrate.ts +1 -1
  70. package/src/commands/pr.ts +183 -7
  71. package/src/commands/sandbox.ts +10 -13
  72. package/src/commands/slides.ts +1 -1
  73. package/src/commands/sync.ts +1 -1
  74. package/src/commands/tasks.ts +19 -2
  75. package/src/commands/tooling.ts +1 -1
  76. package/src/commands/transcripts.ts +1 -1
  77. package/src/commands/upgrade.ts +1 -1
  78. package/src/commands/wiki.ts +1 -1
  79. package/src/context/architecture.ts +1 -1
  80. package/src/context/citations.ts +2 -2
  81. package/src/context/classify/extract.ts +1 -1
  82. package/src/context/folders.ts +2 -2
  83. package/src/context/requirements.ts +1 -1
  84. package/src/docs/read.ts +1 -1
  85. package/src/exec.ts +1 -1
  86. package/src/feedback/github.ts +1 -1
  87. package/src/gate/measures.ts +1 -1
  88. package/src/gate/sequencer.ts +55 -3
  89. package/src/gate/stages.ts +8 -0
  90. package/src/gate/suite-lock.ts +214 -0
  91. package/src/intake/folder.ts +1 -1
  92. package/src/labels/map.ts +1 -1
  93. package/src/labels/phase.ts +3 -3
  94. package/src/migrate/record-layout.ts +1 -1
  95. package/src/migrate/records.ts +11 -11
  96. package/src/migrate/scratch-evidence.ts +1 -1
  97. package/src/migrate/surface-roots.ts +6 -6
  98. package/src/pr/evidence.ts +154 -16
  99. package/src/pr/tick.ts +56 -0
  100. package/src/process/harness.ts +1 -1
  101. package/src/records/backup.ts +1 -1
  102. package/src/records/ordinal.ts +1 -1
  103. package/src/records/prune.ts +1 -1
  104. package/src/records/size.ts +1 -1
  105. package/src/records/stale-canonical.ts +1 -1
  106. package/src/records/stale.ts +1 -1
  107. package/src/records/validate.ts +2 -2
  108. package/src/{project-root.ts → roots/project.ts} +1 -1
  109. package/src/{record-root.ts → roots/record.ts} +1 -1
  110. package/src/sandbox/coverage.ts +11 -12
  111. package/src/sandbox/equivalence.ts +57 -26
  112. package/src/sandbox/hooks.ts +1 -1
  113. package/src/sandbox/provision.ts +78 -28
  114. package/src/sandbox/scenario.ts +588 -0
  115. package/src/sessions/live.ts +4 -1
  116. package/src/sessions/transfer/bundle.ts +1 -1
  117. package/src/slides/decks.ts +1 -1
  118. package/src/standards/read.ts +1 -1
  119. package/src/sync/engine.ts +1 -1
  120. package/src/sync/layout.ts +2 -2
  121. package/src/sync/stamp.ts +1 -1
  122. package/src/targets/validate.ts +1 -1
  123. package/src/tasks/answers.ts +1 -1
  124. package/src/tasks/archive.ts +1 -1
  125. package/src/tasks/label.ts +1 -1
  126. package/src/tasks/reach.ts +1 -1
  127. package/src/tasks/validate.ts +38 -14
  128. package/src/teach/nav.ts +1 -1
  129. package/src/teach/workspace.ts +1 -1
  130. package/src/tooling/inject.ts +1 -1
  131. package/src/tooling/read.ts +1 -1
  132. package/src/tooling/scan.ts +1 -1
  133. package/src/version/installed.ts +1 -1
  134. package/src/version/skew.ts +1 -1
  135. package/standards/board.md +3 -3
  136. package/standards/plan.md +13 -7
  137. package/tsconfig.json +1 -1
  138. /package/src/{legacy-path.ts → roots/legacy.ts} +0 -0
  139. /package/src/{surface-root.ts → roots/surface.ts} +0 -0
@@ -40,19 +40,33 @@ export interface Row {
40
40
  passAt: number
41
41
  prior: string
42
42
  replyAt: number
43
+ /** What the pull request writes, null when its diff could not be read. */
44
+ paths: null | string[]
43
45
  resp: number
46
+ stale: Staleness
47
+ staleFiles: string[]
44
48
  ui: string
45
49
  unmatchedCount: number
46
50
  unmatchedHeading: string
47
51
  }
48
52
 
53
+ /**
54
+ * Whether the base moved under a path the pull request writes since the commit
55
+ * it branched from. `unknown` is a written set this run could not read, which
56
+ * must never pass for `fresh`.
57
+ */
58
+ export type Staleness = 'fresh' | 'stale' | 'unknown'
59
+
49
60
  /** One baseline line, kept as the strings it was written with. */
50
61
  export interface Baseline {
51
62
  head: string
52
63
  merges: string
53
64
  number: string
65
+ /** Open pull requests this one conflicts with, comma-separated, or `none`. */
66
+ overlaps: string
54
67
  prior: string
55
68
  resp: string
69
+ stale: string
56
70
  state: string
57
71
  ui: string
58
72
  unmatched: string
@@ -211,7 +225,7 @@ export const uiState = (payload: Payload, head: string): string => {
211
225
  return `${heading}-${covered ? 'head' : 'behind'}-${commit.slice(0, 7)}`
212
226
  }
213
227
 
214
- /** Takes the first eight fields and ignores the rest, so a short or padded line still reads. */
228
+ /** Takes the first ten fields and ignores the rest, so a short or padded line still reads. */
215
229
  export const parseBaseline = (line: string): Baseline => {
216
230
  const fields = line.split(' ')
217
231
 
@@ -219,14 +233,21 @@ export const parseBaseline = (line: string): Baseline => {
219
233
  head: fields[1] ?? '',
220
234
  merges: fields[4] ?? '',
221
235
  number: fields[0] ?? '',
236
+ overlaps: fields[9] ?? '',
222
237
  prior: fields[2] ?? '',
223
238
  resp: fields[3] ?? '',
239
+ stale: fields[8] ?? '',
224
240
  state: fields[5] ?? '',
225
241
  ui: fields[7] ?? '',
226
242
  unmatched: fields[6] ?? '',
227
243
  }
228
244
  }
229
245
 
246
+ /**
247
+ * New fields append at the end, so every positional read of an older line still
248
+ * lands. A field an older line never carried stays empty and is not written
249
+ * back as a trailing space.
250
+ */
230
251
  export const formatBaseline = (entry: Baseline): string =>
231
252
  [
232
253
  entry.number,
@@ -237,10 +258,30 @@ export const formatBaseline = (entry: Baseline): string =>
237
258
  entry.state,
238
259
  entry.unmatched,
239
260
  entry.ui,
240
- ].join(' ')
261
+ entry.stale,
262
+ entry.overlaps,
263
+ ]
264
+ .join(' ')
265
+ .trimEnd()
241
266
 
242
267
  const short = (head: string): string => head.slice(0, 7)
243
268
 
269
+ const listFiles = (files: string[]): string =>
270
+ files.length > 3
271
+ ? `${files.slice(0, 3).join(', ')}, +${files.length - 3} more`
272
+ : files.join(', ')
273
+
274
+ /**
275
+ * A conflict already sends the branch to its owner for a rebase, so a pull
276
+ * request reading both reports the conflict alone and earns one handback.
277
+ */
278
+ const staleLine = (row: Row, base: string): string[] =>
279
+ row.stale === 'stale' && row.merges !== 'conflict'
280
+ ? [
281
+ `STALE #${row.number} ${base} changed ${row.staleFiles.length} file(s) it writes since its base: ${listFiles(row.staleFiles)}`,
282
+ ]
283
+ : []
284
+
244
285
  export interface Classification {
245
286
  baseline: Baseline
246
287
  isChanged: boolean
@@ -266,8 +307,10 @@ const classifyFirstSighting = (
266
307
  head: row.head,
267
308
  merges: row.merges,
268
309
  number: row.number,
310
+ overlaps: 'none',
269
311
  prior: row.prior,
270
312
  resp: String(row.resp),
313
+ stale: row.stale,
271
314
  state: row.heading,
272
315
  ui: row.ui,
273
316
  unmatched: String(row.unmatchedCount),
@@ -280,7 +323,11 @@ const classifyFirstSighting = (
280
323
  line = `OPENED #${row.number} at ${at}`
281
324
  }
282
325
 
283
- return { baseline, isChanged: true, lines: [line] }
326
+ return {
327
+ baseline,
328
+ isChanged: true,
329
+ lines: [line, ...staleLine(row, context.base)],
330
+ }
284
331
  }
285
332
 
286
333
  const classifyUi = (row: Row, old: Baseline): string[] => {
@@ -312,11 +359,23 @@ export const classify = (
312
359
  let state = row.heading
313
360
 
314
361
  // A conflict arrives from the base moving, not from the branch, so it is
315
- // reported on the transition rather than only when the head changes.
316
- if (row.merges === 'conflict' && old.merges !== 'conflict') {
362
+ // reported on the transition rather than only when the head changes. A push
363
+ // that leaves it conflicting reports it again, for the same reason STALE does.
364
+ if (
365
+ row.merges === 'conflict' &&
366
+ (old.merges !== 'conflict' || row.head !== old.head)
367
+ ) {
317
368
  lines.push(`CONFLICT #${row.number} no longer merges into ${context.base}`)
318
369
  }
319
370
 
371
+ // Staleness arrives the same way, and reports on its transition so a branch
372
+ // left behind across several runs earns one handback rather than one a run.
373
+ // A push that leaves it behind reports it again, since the draft lift reads a
374
+ // MOVED with no STALE beside it as the rebase having landed.
375
+ if (old.stale !== 'stale' || row.head !== old.head) {
376
+ lines.push(...staleLine(row, context.base))
377
+ }
378
+
320
379
  // A rising count is what is new here. It fires on a tracked pull request
321
380
  // only: a first sighting takes whatever already sits on the thread as its
322
381
  // baseline rather than flagging history retroactively.
@@ -386,8 +445,10 @@ export const classify = (
386
445
  head: row.head,
387
446
  merges: row.merges,
388
447
  number: row.number,
448
+ overlaps: old.overlaps,
389
449
  prior: row.prior,
390
450
  resp: String(row.resp),
451
+ stale: row.stale,
391
452
  state,
392
453
  ui: row.ui === '' ? old.ui : row.ui,
393
454
  unmatched: String(row.unmatchedCount),
@@ -396,6 +457,29 @@ export const classify = (
396
457
  return { baseline, isChanged: lines.length > 0, lines }
397
458
  }
398
459
 
460
+ /**
461
+ * One pull request's base movement as a single line. STALE and CONFLICT print
462
+ * in the poll only on a transition or a push, so a reader deciding a draft
463
+ * lift reads this instead. A reading that could not be taken is unknown, never
464
+ * clear.
465
+ */
466
+ export const standingLine = (
467
+ entry: Pick<Baseline, 'head' | 'merges' | 'number' | 'stale'>,
468
+ ): string => {
469
+ const at = short(entry.head)
470
+ if (entry.merges === 'conflict') {
471
+ return `CONFLICT #${entry.number} standing at ${at}`
472
+ }
473
+ if (entry.stale === 'stale') {
474
+ return `STALE #${entry.number} standing at ${at}`
475
+ }
476
+ if (entry.merges === 'clean' && entry.stale === 'fresh') {
477
+ return `CLEAR #${entry.number} at ${at}`
478
+ }
479
+
480
+ return `UNKNOWN #${entry.number} at ${at}, whether it merges or is current could not be read`
481
+ }
482
+
399
483
  /**
400
484
  * A pull request this run could not read keeps the line it had, so the GONE
401
485
  * sweep does not read the gap as a merge. Its heading is blanked to a value no
@@ -532,6 +616,152 @@ const readMerges = (number: string, head: string, ref: string): string => {
532
616
  : 'conflict'
533
617
  }
534
618
 
619
+ interface Written {
620
+ forkPoint: string
621
+ paths: string[]
622
+ }
623
+
624
+ const nonEmptyLines = (output: string): string[] =>
625
+ output.split('\n').filter((line) => line !== '')
626
+
627
+ /**
628
+ * The paths a pull request writes, read from its own diff against the commit it
629
+ * branched from. `--no-renames` lists both sides of a rename, since a change on
630
+ * the base to either one is a change under the branch. A head or base that does
631
+ * not resolve, which a fork or a deleted head leaves, reads as null.
632
+ */
633
+ const readWritten = (head: string, ref: string): null | Written => {
634
+ const forkPoint = run('git', ['merge-base', ref, head])
635
+ if (!forkPoint.isOk) return null
636
+ const point = forkPoint.stdout.trim()
637
+ const diff = run('git', ['diff', '--name-only', '--no-renames', point, head])
638
+ if (!diff.isOk) return null
639
+
640
+ return { forkPoint: point, paths: nonEmptyLines(diff.stdout) }
641
+ }
642
+
643
+ /**
644
+ * Re-testing after every merge to the base is a merge queue's cost without its
645
+ * batching, so only a change to a path the branch writes makes it stale. The
646
+ * net diff rather than the log is read, so a change the base made and then
647
+ * reverted moves nothing.
648
+ */
649
+ const readStale = (
650
+ written: null | Written,
651
+ ref: string,
652
+ ): { files: string[]; stale: Staleness } => {
653
+ if (written === null) return { files: [], stale: 'unknown' }
654
+ if (written.paths.length === 0) return { files: [], stale: 'fresh' }
655
+
656
+ const moved = run('git', [
657
+ '--literal-pathspecs',
658
+ 'diff',
659
+ '--name-only',
660
+ '--no-renames',
661
+ written.forkPoint,
662
+ ref,
663
+ '--',
664
+ ...written.paths,
665
+ ])
666
+ if (!moved.isOk) return { files: [], stale: 'unknown' }
667
+
668
+ const files = nonEmptyLines(moved.stdout)
669
+
670
+ return { files, stale: files.length > 0 ? 'stale' : 'fresh' }
671
+ }
672
+
673
+ export interface Overlap {
674
+ files: string[]
675
+ pair: readonly [string, string]
676
+ }
677
+
678
+ /**
679
+ * Pairwise merge-tree grows with the square of open pull requests, so only a
680
+ * pair sharing a written path is tested. Each pair comes lower number first,
681
+ * which fixes the order its line and its baseline entry are read in.
682
+ */
683
+ export const sharedPairs = (rows: Row[]): [Row, Row][] => {
684
+ const readable = rows
685
+ .filter((row) => row.paths !== null && row.paths.length > 0)
686
+ .toSorted((a, b) => Number(a.number) - Number(b.number))
687
+ const pairs: [Row, Row][] = []
688
+ for (const [index, first] of readable.entries()) {
689
+ const written = new Set(first.paths)
690
+ for (const second of readable.slice(index + 1)) {
691
+ if ((second.paths ?? []).some((path) => written.has(path))) {
692
+ pairs.push([first, second])
693
+ }
694
+ }
695
+ }
696
+
697
+ return pairs
698
+ }
699
+
700
+ const partnersOf = (entry: Baseline | undefined): string[] =>
701
+ entry === undefined || entry.overlaps === '' || entry.overlaps === 'none'
702
+ ? []
703
+ : entry.overlaps.split(',')
704
+
705
+ /**
706
+ * A partner this run could not read keeps its place, so a pull request that
707
+ * drops out of one run and returns does not report the same pair twice.
708
+ */
709
+ export const overlapPartners = (
710
+ number: string,
711
+ overlaps: Overlap[],
712
+ old: Baseline | undefined,
713
+ carried: Set<string>,
714
+ ): string => {
715
+ const partners = new Set(
716
+ overlaps.flatMap(({ pair: [first, second] }) =>
717
+ first === number ? [second] : second === number ? [first] : [],
718
+ ),
719
+ )
720
+ for (const partner of partnersOf(old)) {
721
+ if (carried.has(partner)) partners.add(partner)
722
+ }
723
+ const sorted = [...partners].toSorted((a, b) => Number(a) - Number(b))
724
+
725
+ return sorted.length === 0 ? 'none' : sorted.join(',')
726
+ }
727
+
728
+ /** A pair reports once, read off the lower-numbered side's baseline entry. */
729
+ export const overlapLines = (
730
+ overlaps: Overlap[],
731
+ known: Map<string, Baseline>,
732
+ ): string[] =>
733
+ overlaps
734
+ .filter(
735
+ ({ pair: [first, second] }) =>
736
+ !partnersOf(known.get(first)).includes(second),
737
+ )
738
+ .map(
739
+ ({ files, pair: [first, second] }) =>
740
+ `OVERLAP #${first} #${second} conflict on ${listFiles(files)}`,
741
+ )
742
+
743
+ /**
744
+ * Two pull request heads merged against each other, which is the merge the
745
+ * second to land will have to make. merge-tree exits 1 on a conflict and
746
+ * prints the tree then one conflicted path a line. Any other failure prints no
747
+ * tree, and reads as null beside a clean merge, since neither names a file.
748
+ */
749
+ const readOverlap = (first: string, second: string): null | string[] => {
750
+ const merged = run('git', [
751
+ 'merge-tree',
752
+ '--write-tree',
753
+ '--name-only',
754
+ '--no-messages',
755
+ first,
756
+ second,
757
+ ])
758
+ if (merged.isOk) return null
759
+ const lines = nonEmptyLines(merged.stdout)
760
+ if (lines.length < 2) return null
761
+
762
+ return [...new Set(lines.slice(1))]
763
+ }
764
+
535
765
  const buildRow = (
536
766
  number: string,
537
767
  payload: Payload,
@@ -545,17 +775,23 @@ const buildRow = (
545
775
  run('canon', ['pr', 'review-state', number, '--json']).stdout,
546
776
  )
547
777
  const scope = scopeFromVerb(record, now) ?? scopeFallback(payload, now)
778
+ const merges = readMerges(number, head, ref)
779
+ const written = merges === 'unknown' ? null : readWritten(head, ref)
780
+ const stale = readStale(written, ref)
548
781
 
549
782
  return {
550
783
  age: scope.age,
551
784
  head,
552
785
  heading: scope.state,
553
- merges: readMerges(number, head, ref),
786
+ merges,
554
787
  number,
555
788
  passAt: scope.readAt,
789
+ paths: written?.paths ?? null,
556
790
  prior: scope.commit,
557
791
  replyAt: replies.newestAt,
558
792
  resp: replies.count,
793
+ stale: stale.stale,
794
+ staleFiles: stale.files,
559
795
  ui: uiState(payload, head),
560
796
  unmatchedCount: unmatched.count,
561
797
  unmatchedHeading: unmatched.heading,
@@ -573,7 +809,37 @@ const countSince =
573
809
  return log.isOk ? log.stdout.split('\n').filter((l) => l !== '').length : 0
574
810
  }
575
811
 
576
- const main = (): number => {
812
+ /**
813
+ * `--check <number>` reads the base and the head at call time and writes
814
+ * nothing. The baseline is as old as the last full run, and a sibling merging
815
+ * after it would leave a stored reading claiming clear.
816
+ */
817
+ const check = (number: string): number => {
818
+ const ref = baseRef()
819
+ run('git', ['fetch', '-q', 'origin', baseBranch(ref)])
820
+ const head = readHead(number)
821
+ if (head === '') {
822
+ console.error(
823
+ `poll: #${number} returned no head, so nothing is known about it`,
824
+ )
825
+
826
+ return 1
827
+ }
828
+ const merges = readMerges(number, head, ref)
829
+ const written = merges === 'unknown' ? null : readWritten(head, ref)
830
+ console.log(
831
+ standingLine({
832
+ head,
833
+ merges,
834
+ number,
835
+ stale: readStale(written, ref).stale,
836
+ }),
837
+ )
838
+
839
+ return 0
840
+ }
841
+
842
+ const main = (args: string[]): number => {
577
843
  const root = mainRoot()
578
844
  if (root === null) {
579
845
  console.error('poll: not a git repository, so nothing is classified')
@@ -583,6 +849,7 @@ const main = (): number => {
583
849
 
584
850
  // The baseline is per-machine mutable state, so it stays in gitignored
585
851
  // scratch even though the script is tracked.
852
+ if (args[0] === '--check') return check(args[1] ?? '')
586
853
  const stateDir = join(root, '.canon', 'tmp', 'pr', 'poll')
587
854
  mkdirSync(stateDir, { recursive: true })
588
855
  const statePath = join(stateDir, 'baseline.txt')
@@ -655,6 +922,22 @@ const main = (): number => {
655
922
  snapshots.push({ row: buildRow(number, payload, head, ref) })
656
923
  }
657
924
 
925
+ const rows = snapshots.flatMap((snapshot) =>
926
+ 'row' in snapshot ? [snapshot.row] : [],
927
+ )
928
+ const carried = new Set(
929
+ snapshots.flatMap((snapshot) =>
930
+ 'baseline' in snapshot ? [snapshot.baseline.number] : [],
931
+ ),
932
+ )
933
+ const overlaps = sharedPairs(rows).flatMap(([first, second]) => {
934
+ const files = readOverlap(first.head, second.head)
935
+
936
+ return files === null
937
+ ? []
938
+ : [{ files, pair: [first.number, second.number] as const }]
939
+ })
940
+
658
941
  let isChanged = false
659
942
  const final: string[] = []
660
943
  const seen = new Set<string>()
@@ -667,13 +950,24 @@ const main = (): number => {
667
950
 
668
951
  const { row } = snapshot
669
952
  seen.add(row.number)
670
- const result = classify(row, known.get(row.number), {
953
+ const old = known.get(row.number)
954
+ const result = classify(row, old, {
671
955
  base,
672
956
  countSince: countSince(row.number),
673
957
  })
674
958
  for (const line of result.lines) console.log(line)
675
959
  if (result.isChanged) isChanged = true
676
- final.push(formatBaseline(result.baseline))
960
+ final.push(
961
+ formatBaseline({
962
+ ...result.baseline,
963
+ overlaps: overlapPartners(row.number, overlaps, old, carried),
964
+ }),
965
+ )
966
+ }
967
+
968
+ for (const line of overlapLines(overlaps, known)) {
969
+ console.log(line)
970
+ isChanged = true
677
971
  }
678
972
 
679
973
  for (const entry of previous) {
@@ -688,4 +982,4 @@ const main = (): number => {
688
982
  return 0
689
983
  }
690
984
 
691
- if (import.meta.main) process.exitCode = main()
985
+ if (import.meta.main) process.exitCode = main(process.argv.slice(2))
@@ -89,4 +89,4 @@ four carried at least one, caught only because the planner ran
89
89
  - The mechanical addressee resolution, which `session-relay` owns for every session with or without a role
90
90
  - The branch, the build, and the pull request, which `role-worker` and `auto-ship` own
91
91
  - Deciding which rows run and in what order, which is the controlling session's call and stated in `role-orchestrator`
92
- - The dispatch itself, its collision check, and its disjointness gate, which `orchestrator-dispatch.md` holds
92
+ - The dispatch itself, its collision check, and its conflict check, which `orchestrator-dispatch.md` holds
@@ -42,7 +42,7 @@ Each item below is something a record needed and a launch string did not carry.
42
42
  - The task file, and its `## Findings` before deciding anything. A row can carry its own disproof under a title that still states the original claim, which is how one trial nearly planned against a premise the file had already recorded as dead.
43
43
  - The source files themselves, opened rather than summarized. Never a count quoted from the task file, which was wrong or stale in ten places across four plans.
44
44
  - `CLAUDE.md` and `canon/ARCHITECTURE.md`, for the decision and the alternative it was taken against rather than for the decision alone.
45
- - `.canon/tasks/priority.md` for the row's Touches column and its stated blocker. That column is the file set the dispatch disjointness gate already reads.
45
+ - `.canon/tasks/priority.md` for the row's Touches column and its stated blocker. That column is the file set the dispatch conflict check already reads. Read it for the shared-path lines and the holds of the constraint block, never as a gate a plan must clear.
46
46
 
47
47
  Two reads belong to the `plan-feature` path alone, since neither a groundwork
48
48
  track nor an intake pass is sequenced behind another row or validated as a plan:
@@ -60,7 +60,7 @@ it.
60
60
  - Run `canon sessions list --json`, take the `branch` field of every session whose `repository` matches this one and whose branch is neither null nor the trunk branch, union that with every `headRefName` from `gh pr list --json number,headRefName`, dedupe by branch name, then diff each with `git diff --name-only main...<branch>` for its file set. That resolves locally whether or not a pull request exists, since every track in this repository shares one git directory across its worktrees.
61
61
  - A bare branch or worktree with neither a live session nor a pull request behind it is still not evidence, since this repository squash-merges and leaves both behind. The roster read is already filtered to live sessions, so what changed is the second source composed with it rather than a raw count. It still misses a live session that reached its worktree through the direct-path fallback rather than `EnterWorktree`, since that session's registered branch never moves off the trunk.
62
62
  - Run the read once per row rather than once per batch. A session working several rows ages its picture of the tree while it goes, and this read is what dates it.
63
- - Name each in-flight set as a constraint, say which act it forbids, and stamp the block with the commit the tree was read at. A bare path list leaves the reader guessing. The plan standard fixes that block for a feature plan, and a groundwork or intake record carries the same three parts in whatever shape its own standard states.
63
+ - Name each in-flight set as a constraint, being a hold that says which act it forbids or a shared path that says which branch rebases, and stamp the block with the commit the tree was read at. A bare path list leaves the reader guessing. The plan standard fixes that block for a feature plan, and a groundwork or intake record carries the same three parts in whatever shape its own standard states.
64
64
 
65
65
  ## The board is read-only
66
66
 
@@ -25,7 +25,7 @@ from here.
25
25
 
26
26
  ## Where the session stands
27
27
 
28
- - Write the review comment through `review-pr` and the body file it names under `.canon/tmp/pr/review/`, and nothing else. A UI reviewer writes the comment `review-ui` posts, its body file, and the browser's snapshots and frames, all under `.canon/tmp/pr/review-ui/`. A tracked file, a branch, a worktree, a board file, and a commit all sit outside what this session may touch.
28
+ - Write the review comment through `review-pr` and the body file it names under `.canon/tmp/pr/review/`, and nothing else. A UI reviewer writes the comment `review-ui` posts, its body file, and the browser's snapshots and frames, all under `.canon/tmp/pr/review-ui/`, and ticks the boxes it passed on the evidence comment through `canon pr tick`. A tracked file, a branch, a worktree, a board file, and a commit all sit outside what this session may touch.
29
29
  - Never enter a worktree and never check the branch out. The pass reads a change, and a checkout is the first step toward editing it.
30
30
  - Read a file a finding rests on at the head `review-pr` resolved, through `git show <head>:<path>`. The main worktree holds the trunk, so a finding read off its copy is about a tree the pull request never changed, and it reads as confidently as one that is right.
31
31
  - Resolve `.canon/plans/`, `.canon/tasks/`, and `.canon/tmp/` at the main worktree root, and send the body file as a heredoc, the main-root route `session-worktree` states. Report a plan or task that fails to resolve there as unreadable, naming the path, rather than reviewing as though the branch had none.
@@ -49,7 +49,7 @@ The controlling session cannot watch this one build, so the messages below are
49
49
  owed and nothing else.
50
50
 
51
51
  - Announce the pull request as the ship chain's pull request step returns, carrying the number, the branch, and the task it closes. That transition is the one moment only this session knows, and the controller's review poll no longer starts on a dispatch because of it.
52
- - Carry the reach in that same message: the paths this branch wrote that another live plan or `## Run now` row holds, and who holds each, which `canon tasks plan-reach` reports as `claimed`. The gate cleared this branch against a prediction it has since outgrown, so the controller holds a disjointness reading that stopped being true hours ago and nothing else tells it. Say the reach was unread rather than clear when the installed binary carries no such subcommand, and leave the undeclared list to the pull request.
52
+ - Carry the reach in that same message: the paths this branch wrote that another live plan or `## Run now` row holds, and who holds each, which `canon tasks plan-reach` reports as `claimed`. Report each as a rebase the controller should order, never as a breach, since sharing a path is not a hold. The gate cleared this branch against a prediction it has since outgrown, so the controller holds a reading of what this branch shares that stopped being true hours ago and nothing else tells it. Say the reach was unread rather than clear when the installed binary carries no such subcommand, and leave the undeclared list to the pull request.
53
53
  - Carry a row this build turned up in that same message, with its title, its origin, and the surface it would take. Send it as its own message only when the session will end before a pull request opens, since the row has no other way to reach the controller.
54
54
  - Announce when an address-review pass finishes, as `review-address` Step 8 returns, carrying what was addressed and the PR's new CI state. That transition is the other moment only this session knows, and it is what tells the controller to re-review rather than leaving it to poll for an answer nothing marks as landed.
55
55
  - Send a block out as a message before it becomes an interactive prompt.
@@ -84,7 +84,8 @@ feature branch and a controller does not.
84
84
  - Refuse an instruction the tree contradicts, and carry the evidence with it. Name the commands read and the consequences of complying, rather than reporting reluctance.
85
85
  - Halt on a plan question written as needing the operator's call. The plan standard defines that as a stop for an executing session, so the dispatch that sent it unattended is the defect and the halt is not.
86
86
  - Halting is cheap and guessing is not. Four measured halts each cost the dispatcher one reply, and the correction that mattered most reached the right place because a worker argued back rather than complying.
87
- - Read a file named as held, whether by a plan's Constraints block or by a controller's message sent mid-build, at the hunk against the branch or pull request holding it before conceding it.
87
+ - Write a file a constraint names as shared, and keep both sides on the rebase. A shared path forbids nothing, so the hunk comparison below does not apply to it.
88
+ - Read a file named as held, whether by a plan's Constraints block or by a controller's message sent mid-build, at the hunk against the branch or pull request holding it before conceding it. A hold is a dependency, a contract, a relocation, or a sweep.
88
89
  - Report that reading to the controller as the hunks or line ranges on each side, and take its reply as the call on whether the file is kept or given up, not a decision this session makes alone.
89
90
  - When the holding track carries no commits yet, there is nothing to diff. Compare against the file set its plan declares instead, and report that the hunk comparison could not be taken, rather than conceding on the name or proceeding as if the sets never collided.
90
91
  - A dispatcher once named three files a branch was writing. The worker diffed that branch, found its hunks at two lines against its own at three others, and reported the comparison, and the dispatcher reversed the instruction and returned every file.
@@ -41,7 +41,7 @@ Write each box so a session driving the running app can run it without guessing:
41
41
  - **Taste.** End the line with `(taste)` when the result is a judgment of look or feel rather than a fact a driver can observe. A driver reports such a box as needing a person's eyes instead of passing it.
42
42
  - **Setup.** A box needing a state the page does not open in states the setup inline. When a hosted preview cannot reach that state, such as a stopped service or a seeded record, end the line with `(local only)` instead.
43
43
 
44
- The checkbox stays `- [ ]` followed by the action and the result, since the evidence comment carries ticked boxes forward by that shape.
44
+ The checkbox stays `- [ ]` followed by the action and the result, since the evidence comment reads boxes by that shape. Only the UI reviewer ticks a box, stamping it with the commit it passed at, and the comment carries that tick forward only while the head it names is current.
45
45
 
46
46
  ## The missing-test list
47
47
 
@@ -39,23 +39,23 @@ A pick is refused as `address-mismatch` when the browser builds elements the fil
39
39
 
40
40
  ## Edit
41
41
 
42
- `canvas edit <page>/<frame> --element <index> --set <property>=<value>` sets one property of the element at that index, the same index `canvas selection` reports. The properties are `text`, `color`, `background-color`, `font-size`, `font-weight`, `width`, `height`, `padding`, `gap`, `display`, `flex-direction`, `justify-content`, `align-items`, `flex-wrap`, `opacity`, `border-radius`, `border-top-left-radius`, `border-top-right-radius`, `border-bottom-right-radius`, and `border-bottom-left-radius`. A style property is written into the element's inline `style`, replacing that property and leaving the others as they were, and an empty value drops it. Writing `border-radius` or `padding` also drops that shorthand's longhands from the same `style`, so no corner or side left over overrides the new value. Writing a longhand leaves its shorthand in place, since the longhand comes later and wins.
42
+ `canvas edit <page>/<frame> --element <index> --set <property>=<value>` sets one property of the element at that index, the same index `canvas selection` reports. The properties are `text`, `color`, `background-color`, `font-size`, `font-weight`, `font-family`, `line-height`, `letter-spacing`, `text-align`, `width`, `height`, `padding`, `gap`, `display`, `flex-direction`, `justify-content`, `align-items`, `flex-wrap`, `opacity`, `border-radius`, `border-top-left-radius`, `border-top-right-radius`, `border-bottom-right-radius`, and `border-bottom-left-radius`. A style property is written into the element's inline `style`, replacing that property and leaving the others as they were, and an empty value drops it. Writing `border-radius` or `padding` also drops that shorthand's longhands from the same `style`, so no corner or side left over overrides the new value. Writing a longhand leaves its shorthand in place, since the longhand comes later and wins.
43
43
 
44
44
  `text` replaces the text of an element holding text alone and refuses one holding other elements as `not-text-only`. Every byte outside the element stays as it was. `--json` emits the page, frame, file, path, and the file's new `hash`.
45
45
 
46
46
  The operator edits the same set from the inspector, which posts to the same writer with the hash the frame was served with. Dragging a selected element's corner handle writes `width` and then `height` in pixels through that writer, the second edit carrying the hash the first answered. An edit made against a version of the file Claude has since rewritten is refused as `stale-address` and the shell reloads the frame, so nothing lands on a shifted element. Both writers take the frame file's lock, and an edit that keeps the selected element where it was keeps the selection fresh.
47
47
 
48
- Size, padding, opacity, and radius show in the inspector for every element. The flex fields show only on a flex container, a grid container gets none, and the inspector sets `display` only to `flex`, so any other `display` change goes through the verb.
48
+ Size, padding, opacity, radius, and the type properties show in the inspector for every element. Family and font size list the sheet's `font-family` and `font-size` token groups first and write a pick as `var(--<name>)`, the same as a color. The flex fields show only on a flex container, a grid container gets none, and the inspector sets `display` only to `flex`, so any other `display` change goes through the verb.
49
49
 
50
50
  A color picked from the project's tokens is written as `var(--<name>)`, and below full opacity as a `color-mix()` of that `var()` with `transparent` at the opacity percent, so it keeps following the theme. A browser older than 2023 drops a `color-mix()` value, which leaves the property unset there. A hex is written as six digits, or eight below full opacity. The inspector's full color picker writes a hex for any color it sets, so the field marks the value raw, and only its alpha keeps a token through the `color-mix()` form.
51
51
 
52
- Inline style beats a class, so an operator's edit masks a class change Claude makes later. Read a frame's inline styles before restyling it, and remove one the change should replace. The inspector marks a color or background set inline with the word `raw` when its value names no `var()` and is not `currentColor` or a CSS-wide keyword, since that value stays fixed when the theme changes. A property outside the set, or a value carrying `;`, `{`, `}`, `<`, `>`, or a line break, is refused as `invalid-edit`.
52
+ Inline style beats a class, so an operator's edit masks a class change Claude makes later. Read a frame's inline styles before restyling it, and remove one the change should replace. The inspector marks a color, a background, or a family or font size the sheet has tokens for, set inline, with the word `raw` when its value names no `var()` and is not `currentColor` or a CSS-wide keyword, since that value stays fixed when the theme changes. A property outside the set, or a value carrying `;`, `{`, `}`, `<`, `>`, or a line break, is refused as `invalid-edit`.
53
53
 
54
54
  The Theme tab in the shell lists what the token stylesheet defines, grouped as color, spacing, radius, font family, font size, and other. The page record at `/api/pages` carries the same list as `tokens.groups`.
55
55
 
56
56
  ## Mark a frame being edited
57
57
 
58
- `canvas editing <page>/<frame>` marks a frame as being edited, so an open canvas can show who is working on it before the write lands. Run it before writing a frame and again with `--done` once the edits are written. A bare `canvas editing` lists the live marks, and `--json` emits `{ ok, editing }` after each of the three, with each mark carrying `page`, `frame`, `by`, `since`, and `until`.
58
+ `canvas editing <page>/<frame>` marks a frame as being edited, so an open canvas shows who is working on it before the write lands. The shell draws the mark as `<by> editing` on that frame's label with a dashed outline around it, and drops it on the reread after `--done` or at the mark's `until` with no reread. Run it before writing a frame and again with `--done` once the edits are written. A bare `canvas editing` lists the live marks, and `--json` emits `{ ok, editing }` after each of the three, with each mark carrying `page`, `frame`, `by`, `since`, and `until`.
59
59
 
60
60
  The label is `--by <name>` when given. Without it, the verb reads the caller's name off the session roster, and falls back to `a session` when the environment names no session or the roster holds none matching it.
61
61
 
@@ -86,4 +86,6 @@ One code for a failure, which is what a `bun run` caller and a git hook both rea
86
86
 
87
87
  A failing stage carries its remedy in `failure`, and the same line goes to stderr so a caller reading neither the record nor the frame is still told what to fix.
88
88
 
89
- `ms` carries the wall time each stage's checks took, including every process spawn, and the top-level `ms` sums them. Neither reading changes what a stage measures. Both exist so a slow run is attributable to a stage rather than read off a stopwatch held against the whole thing.
89
+ `ms` carries the wall time each stage took, including every process spawn, and the top-level `ms` sums them. Neither reading changes what a stage measures. Both exist so a slow run is attributable to a stage rather than read off a stopwatch held against the whole thing.
90
+
91
+ The Tests stage holds a machine lock so one box runs one suite at a time, and its `ms` includes any time it queued behind another worktree's suite. A stage that queued carries `queuedMs`, the part of `ms` spent waiting, which is how to separate a queue from a slow suite. The field is absent on a stage that never waited. The wait is also announced on stderr when it starts, naming the holder's pid and worktree, in every mode.
@@ -40,6 +40,7 @@ CLI catalog and invocation rules for agents, split by command domain. Start with
40
40
  - [The pull request local preview](pr-local.md): How canon pr local finds the server a worktree is running, why a server in another worktree is never reported, the refusal reasons it names, and how a close workflow drops the address line with --remove
41
41
  - [The pull request preview deploy](pr-preview.md): How canon pr preview finds a fenced Cloudflare Pages deploy, refuses a branch its push filter does not serve, dispatches it on a pull request's branch, reads the alias the run prints, the refusal reasons it names, and why an unfenced deploy is refused before anything runs
42
42
  - [Head-sensitive pull request reads](pr-reads.md): Resolving a branch tip from the remote rather than from the pull request object, reading what a review pass covered off its own marker rather than off GitHub's submission stamps, why an empty run list is not a pass, the refusal reasons each verb names, and what the remote read costs
43
+ - [The pull request checklist tick](pr-tick.md): How canon pr tick marks the checklist boxes a UI pass drove, the stamp that names the commit each passed at, why a render at a newer head clears a tick, the refusal reasons it names, and why a taste box is never ticked
43
44
  - [Records push](records-push.md): Backing the record folders to a private remote and restoring them, which folders the payload carries, the one-time setup, the refusal reasons, and the hook that runs the push
44
45
  - [Records stale](records-stale.md): Reading the memory pen as a review queue, what makes an entry due, how a cited path is read and resolved, the order entries come back in, counting the releases since the requirements and architecture records were last reviewed, and the exit codes and refusals
45
46
  - [Records validate](records-validate.md): Validating the session records and the standards corpus, the per-kind checks, which root each kind defaults to, the exit codes, and the refusal reasons
@@ -61,7 +62,7 @@ CLI catalog and invocation rules for agents, split by command domain. Start with
61
62
  - [Targets](targets.md): The projects this toolkit installed into, the record the install writes against the sweep that backs it, what bounds each answer, and the cross-target pull request read
62
63
  - [Tasks archive](tasks-archive.md): Archiving a shipped task by stem or pull request, declining one decided against, the refusal reasons each carries, and how the plan and ready folder move with the task
63
64
  - [Tasks plans](tasks-plans.md): The five verbs that read or write a task's plan, being where it sits and who cites it, whether it waits on the operator, the branch it derives, what a branch reached against what it declared, and writing its Plan line
64
- - [Tasks validate](tasks-validate.md): Validating the board against the tree, the seven checks, the untested, claims, wide, and unplaced arrays, the exit codes, and the refusal reasons
65
+ - [Tasks validate](tasks-validate.md): Validating the board against the tree, the seven checks, the five report arrays, the exit codes, and the refusal reasons
65
66
  - [Tasks](tasks.md): Claiming the next phase label, recording a pull request number and closing an outcome, listing each task's readiness, and where archiving, the plan verbs, and board validation are described
66
67
  - [Teach authoring](teach-authoring.md): Resolving what the next lesson needs before it is written, rendering a lesson body's blocks to HTML, and rewriting the root listing, a contents page, and each lesson's chrome and quiz stepper from its marker regions
67
68
  - [Teach](teach.md): Listing learning workspaces with what their records schedule next, opening one with its required files, recording sources and glossary terms, viewing one with its chrome refreshed, where the lesson authoring verbs are described, the refusal reasons, and why every write here runs through a verb
@@ -33,16 +33,16 @@ its filename carries an image extension (`png`, `jpg`, `jpeg`, `gif`, `webp`,
33
33
  folder holds whatever else a project keeps beside its captures. This
34
34
  repository's own tree carries eight `.md` files, two `.sh` scripts, a `.tsv`,
35
35
  a `.json`, and an `.html` file against a single `.png`, and every one of them
36
- would render as a broken `![]()` embed without the extension filter.
36
+ would render as a broken image embed without the extension filter.
37
37
 
38
38
  ## What the record carries
39
39
 
40
40
  The record groups every evidence path by state, the remainder of its
41
41
  directory under the `evidence/` segment, and within a state by filename stem,
42
42
  so a `before/hero.png` and an `after/hero.png` naming the same case group as
43
- one entry rather than two unrelated files. Each entry carries whether it
44
- existed at the base commit, which decides whether the comment shows a base
45
- image or marks the case new.
43
+ one entry, not two files. Each entry carries whether it existed at the base
44
+ commit, so the comment shows a base image or marks it new. All-new states drop
45
+ the Base column and each image is an `<img width="1000">`.
46
46
 
47
47
  `reason` on the record is what a caller branches on, not the exit code:
48
48
 
@@ -73,6 +73,7 @@ shows a reviewer, each field present only when that comment holds it:
73
73
  | `preview` | The hosted `**Preview:**` address on the comment's address line. |
74
74
  | `local` | The `**Local preview:**` address beside it. |
75
75
  | `checklist` | The checklist between its delimiters, with any ticked boxes kept. |
76
+ | `boxes` | Each box's `number`, `text`, tick, `stamp`, and `isTaste`. |
76
77
 
77
78
  These come from the comment already posted, never from the flags the call
78
79
  passed, so a skill driving a review reads which address and which checklist a
@@ -102,7 +103,7 @@ The trade is that the verb no longer sees evidence that is uncommitted or
102
103
  unpushed, which the comment's head-pinned image links could not show anyway.
103
104
  A path the pull request renamed or copied counts as added, since the new path
104
105
  has no counterpart at the merge base, so a rename loses its before image and
105
- renders `*(new)*`. A path the pull request removed is dropped, since it has no
106
+ reads as new. A path the pull request removed is dropped, since it has no
106
107
  head image to show. A failed files read refuses as `unreadable-changes` rather
107
108
  than rendering a short set, and a missing merge base refuses as `no-base`.
108
109
 
@@ -181,9 +182,9 @@ screenshots, and what a reviewer has to look at. The file is what
181
182
 
182
183
  A run without the option carries forward whatever checklist the marked comment
183
184
  already holds, the same way the preview address is carried. That is what keeps
184
- `git-followup`'s re-render after a push from wiping boxes a reviewer has
185
- already ticked, since `git-pr` deletes the handoff once the first post reports
186
- success and no later call has a file to pass.
185
+ `git-followup`'s re-render after a push from dropping the checklist, since
186
+ `git-pr` deletes the handoff once the first post reports success and no later
187
+ call has a file to pass. `canon docs pr-tick` covers what a tick carries.
187
188
 
188
189
  The option decides `no-evidence` on its own. A branch with a checklist and no
189
190
  changed evidence image renders `ok` with a marked body holding the checklist,