@erclx/canon 5.38.0 → 5.40.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 (246) hide show
  1. package/README.md +5 -5
  2. package/claude/.claude-plugin/plugin.json +1 -1
  3. package/claude/skills/api-design/SKILL.md +12 -9
  4. package/claude/skills/auto-ship/SKILL.md +43 -23
  5. package/claude/skills/backlog-triage/SKILL.md +62 -53
  6. package/claude/skills/backlog-triage/references/apply.md +69 -0
  7. package/claude/skills/build-in-slices/SKILL.md +8 -4
  8. package/claude/skills/canon-cli/SKILL.md +5 -1
  9. package/claude/skills/canon-feedback/SKILL.md +18 -8
  10. package/claude/skills/canon-feedback-triage/SKILL.md +10 -3
  11. package/claude/skills/canvas-board/SKILL.md +37 -18
  12. package/claude/skills/ci-workflow/SKILL.md +5 -4
  13. package/claude/skills/code-craft/SKILL.md +10 -6
  14. package/claude/skills/codebase-layout/SKILL.md +3 -5
  15. package/claude/skills/context-fold/REQUIREMENT.md +4 -4
  16. package/claude/skills/context-fold/SKILL.md +42 -23
  17. package/claude/skills/context-fold/references/context-refresh.md +4 -3
  18. package/claude/skills/context-fold/references/context.md +79 -64
  19. package/claude/skills/create-rule/SKILL.md +7 -3
  20. package/claude/skills/create-skill/SKILL.md +27 -7
  21. package/claude/skills/create-skill/references/skill-reference.md +41 -17
  22. package/claude/skills/decision-escalate/SKILL.md +8 -5
  23. package/claude/skills/deploy-app/SKILL.md +1 -1
  24. package/claude/skills/deprecation-migration/SKILL.md +12 -4
  25. package/claude/skills/design-taste/SKILL.md +17 -11
  26. package/claude/skills/docs-sync/REQUIREMENT.md +1 -0
  27. package/claude/skills/docs-sync/SKILL.md +17 -6
  28. package/claude/skills/docs-sync/references/docs.md +54 -21
  29. package/claude/skills/docs-sync/references/readme.md +32 -8
  30. package/claude/skills/document-health/SKILL.md +3 -4
  31. package/claude/skills/draft-and-pick/SKILL.md +73 -47
  32. package/claude/skills/draft-doc/SKILL.md +9 -4
  33. package/claude/skills/draft-doc/references/context.md +4 -4
  34. package/claude/skills/draft-doc/references/docs.md +1 -0
  35. package/claude/skills/draft-doc/references/wiki.md +3 -2
  36. package/claude/skills/draft-figure/SKILL.md +9 -7
  37. package/claude/skills/draft-identity/SKILL.md +24 -52
  38. package/claude/skills/draft-identity/references/card-route.md +46 -0
  39. package/claude/skills/draft-ready/SKILL.md +19 -7
  40. package/claude/skills/draft-screencast/SKILL.md +6 -112
  41. package/claude/skills/draft-screencast/references/beats-template.md +119 -0
  42. package/claude/skills/draft-slides/SKILL.md +9 -7
  43. package/claude/skills/draft-slides/references/deck.md +183 -0
  44. package/claude/skills/git-branch/SKILL.md +3 -3
  45. package/claude/skills/git-commit/SKILL.md +5 -3
  46. package/claude/skills/git-followup/SKILL.md +16 -9
  47. package/claude/skills/git-issue/SKILL.md +6 -5
  48. package/claude/skills/git-pr/SKILL.md +56 -44
  49. package/claude/skills/git-pr/references/evidence.md +2 -2
  50. package/claude/skills/git-pr/references/final-command.md +1 -1
  51. package/claude/skills/git-pr/references/labels.md +1 -1
  52. package/claude/skills/git-pr/references/publish.md +1 -1
  53. package/claude/skills/git-ship/SKILL.md +43 -34
  54. package/claude/skills/git-ship/references/ci-watch.md +31 -0
  55. package/claude/skills/git-split/SKILL.md +28 -100
  56. package/claude/skills/git-split/references/independent.md +40 -0
  57. package/claude/skills/git-split/references/stacked.md +73 -0
  58. package/claude/skills/git-stage/SKILL.md +9 -7
  59. package/claude/skills/git-worktree/SKILL.md +27 -49
  60. package/claude/skills/git-worktree/references/cleanup.md +55 -0
  61. package/claude/skills/markdown-craft/SKILL.md +2 -2
  62. package/claude/skills/markdown-craft/references/markdown.md +82 -53
  63. package/claude/skills/plan-feature/SKILL.md +52 -46
  64. package/claude/skills/plan-feature/references/batches.md +46 -0
  65. package/claude/skills/plan-groundwork/SKILL.md +60 -74
  66. package/claude/skills/plan-groundwork/references/output.md +48 -0
  67. package/claude/skills/plan-intake/SKILL.md +28 -21
  68. package/claude/skills/plan-intake-answer/SKILL.md +14 -13
  69. package/claude/skills/read-frames/SKILL.md +2 -0
  70. package/claude/skills/record-screencast/SKILL.md +31 -11
  71. package/claude/skills/review-address/SKILL.md +76 -158
  72. package/claude/skills/review-address/references/rebase-only.md +34 -0
  73. package/claude/skills/review-craft/SKILL.md +27 -11
  74. package/claude/skills/review-pr/SKILL.md +91 -40
  75. package/claude/skills/review-ui/SKILL.md +63 -37
  76. package/claude/skills/review-ui/references/hosted-preview.md +30 -0
  77. package/claude/skills/role-orchestrator/SKILL.md +105 -74
  78. package/claude/skills/role-orchestrator/references/orchestrator-filing.md +26 -0
  79. package/claude/skills/role-orchestrator/references/orchestrator-handback.md +1 -1
  80. package/claude/skills/role-planner/SKILL.md +66 -64
  81. package/claude/skills/role-reviewer/SKILL.md +57 -46
  82. package/claude/skills/role-worker/SKILL.md +68 -63
  83. package/claude/skills/search-craft/SKILL.md +7 -5
  84. package/claude/skills/session-compact/SKILL.md +16 -11
  85. package/claude/skills/session-map/SKILL.md +31 -10
  86. package/claude/skills/session-relay/SKILL.md +32 -65
  87. package/claude/skills/session-resume/SKILL.md +18 -13
  88. package/claude/skills/session-worktree/SKILL.md +70 -42
  89. package/claude/skills/systematic-debugging/SKILL.md +6 -1
  90. package/claude/skills/target-check/SKILL.md +13 -6
  91. package/claude/skills/target-setup/SKILL.md +39 -27
  92. package/claude/skills/task-board/SKILL.md +68 -60
  93. package/claude/skills/task-board/references/board.md +1 -1
  94. package/claude/skills/task-board/references/create-report.md +36 -0
  95. package/claude/skills/task-board/references/tasks.md +1 -1
  96. package/claude/skills/task-complete/SKILL.md +20 -18
  97. package/claude/skills/teach-workspace/SKILL.md +61 -33
  98. package/claude/skills/test-craft/SKILL.md +2 -1
  99. package/claude/skills/test-first/SKILL.md +15 -9
  100. package/claude/skills/ui-checklist/SKILL.md +18 -16
  101. package/claude/skills/ux-audit/SKILL.md +9 -10
  102. package/claude/skills/ux-measure/SKILL.md +23 -19
  103. package/claude/skills/video-craft/SKILL.md +15 -7
  104. package/claude/skills/write-human/SKILL.md +9 -1
  105. package/claude/skills/youtube-transcripts/SKILL.md +2 -1
  106. package/docs/agents/audits.md +61 -60
  107. package/docs/agents/context-audit.md +40 -58
  108. package/docs/agents/index.md +11 -61
  109. package/docs/agents/indexes.md +25 -29
  110. package/docs/agents/install-and-sync.md +125 -235
  111. package/docs/agents/markdown-audit.md +54 -96
  112. package/docs/agents/migrate.md +41 -16
  113. package/docs/agents/output-shape.md +60 -45
  114. package/docs/agents/overview.md +36 -12
  115. package/docs/agents/records.md +37 -79
  116. package/docs/agents/sessions-transfer.md +26 -70
  117. package/docs/agents/targets.md +25 -77
  118. package/docs/target/index.md +0 -1
  119. package/docs/target/migrations.md +57 -66
  120. package/docs/target/projects.md +36 -78
  121. package/docs/target/scenarios.md +9 -19
  122. package/docs/target/sync.md +27 -68
  123. package/docs/workflow/ai-workflow.md +22 -68
  124. package/docs/workflow/operating-model.md +32 -186
  125. package/docs/workflow/parallel-features.md +29 -66
  126. package/docs/workflow/skill-map-on-request.md +22 -24
  127. package/docs/workflow/skill-map.md +72 -76
  128. package/docs/workflow/visual-design-workflow.md +25 -118
  129. package/docs/workflow/zshrc-aliases.md +18 -37
  130. package/package.json +1 -1
  131. package/src/canvas/client/inspector/fields.css +4 -0
  132. package/src/canvas/index.html +6 -0
  133. package/src/claude/skills-reach.ts +2 -2
  134. package/src/cli/ui.ts +3 -1
  135. package/src/commands/canvas.ts +23 -0
  136. package/src/commands/capture.ts +14 -0
  137. package/src/commands/claude.ts +14 -1
  138. package/src/commands/context.ts +106 -7
  139. package/src/commands/demo.ts +22 -0
  140. package/src/commands/deps.ts +6 -0
  141. package/src/commands/driver.ts +28 -2
  142. package/src/commands/gov.ts +52 -18
  143. package/src/commands/hooks.ts +63 -0
  144. package/src/commands/intake.ts +17 -0
  145. package/src/commands/labels.ts +38 -5
  146. package/src/commands/markdown.ts +169 -32
  147. package/src/commands/pr.ts +160 -43
  148. package/src/commands/records.ts +8 -0
  149. package/src/commands/secrets.ts +6 -0
  150. package/src/commands/sessions.ts +8 -0
  151. package/src/commands/tasks.ts +52 -0
  152. package/src/commands/teach.ts +13 -0
  153. package/src/commands/upstream.ts +25 -1
  154. package/src/commands/worktrees.ts +5 -0
  155. package/src/context/audit.ts +274 -5
  156. package/src/context/classify/prompts.ts +1 -1
  157. package/src/context/folders.ts +1 -1
  158. package/src/context/gate.ts +23 -7
  159. package/src/context/shape-gated.ts +30 -0
  160. package/src/counts/scan.ts +1 -1
  161. package/src/design/components.ts +1 -1
  162. package/src/design/document.ts +1 -1
  163. package/src/design/tokens.ts +1 -1
  164. package/src/gate/boundaries.ts +4 -1
  165. package/src/gate/measures.ts +16 -191
  166. package/src/gate/stages.ts +14 -30
  167. package/src/git/env.ts +6 -0
  168. package/src/hooks/reply-links.ts +110 -0
  169. package/src/markdown/density.ts +258 -0
  170. package/src/markdown/gate.ts +57 -18
  171. package/src/markdown/reference-shape.ts +103 -0
  172. package/src/markdown/scope.ts +162 -0
  173. package/src/markdown/structure.ts +9 -59
  174. package/src/pr/lift.ts +153 -19
  175. package/src/pr/painted.ts +87 -0
  176. package/src/roots/record.ts +18 -1
  177. package/src/sandbox/census.ts +1 -1
  178. package/src/sandbox/headless/run.ts +3 -2
  179. package/src/sandbox/headless/watch.ts +3 -4
  180. package/src/shipped/references.ts +1 -1
  181. package/src/slides/convert/master.ts +3 -0
  182. package/src/sync/format.ts +46 -0
  183. package/src/sync/stamp.ts +6 -0
  184. package/tooling/astro/reference.md +2 -2
  185. package/tooling/base/reference.md +1 -1
  186. package/tooling/claude/seeds/.claude/hooks/reply-links.sh +72 -0
  187. package/tooling/claude/seeds/.claude/hooks/standards-audit.sh +22 -1
  188. package/tooling/claude/seeds/.claude/settings.json +4 -0
  189. package/tooling/go/reference.md +1 -1
  190. package/tooling/nextjs/reference.md +1 -1
  191. package/tooling/php/reference.md +1 -1
  192. package/tooling/python/reference.md +1 -1
  193. package/tooling/vite-react/reference.md +1 -1
  194. package/tooling/web/reference.md +1 -1
  195. package/docs/agents/canvas.md +0 -86
  196. package/docs/agents/capture.md +0 -65
  197. package/docs/agents/census.md +0 -23
  198. package/docs/agents/commands.md +0 -154
  199. package/docs/agents/comments.md +0 -34
  200. package/docs/agents/context-audit-checks.md +0 -90
  201. package/docs/agents/context-classify.md +0 -101
  202. package/docs/agents/counts.md +0 -74
  203. package/docs/agents/demo.md +0 -140
  204. package/docs/agents/docs.md +0 -27
  205. package/docs/agents/drift-surfaces.md +0 -201
  206. package/docs/agents/driver.md +0 -103
  207. package/docs/agents/gate.md +0 -90
  208. package/docs/agents/gov-detect.md +0 -47
  209. package/docs/agents/hooks.md +0 -58
  210. package/docs/agents/intake.md +0 -81
  211. package/docs/agents/key-changes.md +0 -116
  212. package/docs/agents/label-coverage.md +0 -86
  213. package/docs/agents/markdown-audit-weight.md +0 -81
  214. package/docs/agents/pr-evidence-layout.md +0 -70
  215. package/docs/agents/pr-evidence.md +0 -273
  216. package/docs/agents/pr-frames.md +0 -131
  217. package/docs/agents/pr-lift.md +0 -132
  218. package/docs/agents/pr-local.md +0 -111
  219. package/docs/agents/pr-preview.md +0 -155
  220. package/docs/agents/pr-reads.md +0 -111
  221. package/docs/agents/pr-tick.md +0 -72
  222. package/docs/agents/records-push.md +0 -61
  223. package/docs/agents/records-stale.md +0 -40
  224. package/docs/agents/records-validate.md +0 -57
  225. package/docs/agents/restated.md +0 -83
  226. package/docs/agents/routing.md +0 -61
  227. package/docs/agents/rule-citations.md +0 -90
  228. package/docs/agents/sandbox.md +0 -126
  229. package/docs/agents/scripting.md +0 -142
  230. package/docs/agents/sessions.md +0 -128
  231. package/docs/agents/skills-audit.md +0 -120
  232. package/docs/agents/skills-reach.md +0 -64
  233. package/docs/agents/slides.md +0 -176
  234. package/docs/agents/state-scoped-risk.md +0 -105
  235. package/docs/agents/superseded.md +0 -85
  236. package/docs/agents/tasks-archive.md +0 -83
  237. package/docs/agents/tasks-plans.md +0 -152
  238. package/docs/agents/tasks-validate.md +0 -133
  239. package/docs/agents/tasks.md +0 -123
  240. package/docs/agents/teach-authoring.md +0 -118
  241. package/docs/agents/teach.md +0 -157
  242. package/docs/agents/test-order.md +0 -58
  243. package/docs/agents/upstream.md +0 -94
  244. package/docs/agents/worktrees.md +0 -118
  245. package/docs/target/standards-moved.md +0 -34
  246. package/src/markdown/growth.ts +0 -43
@@ -1,6 +1,6 @@
1
1
  import { type BodyLine, visibleText } from '@/markdown/scan'
2
2
 
3
- const HEADING = /^#{1,6}\s/
3
+ export const HEADING = /^#{1,6}\s/
4
4
 
5
5
  /**
6
6
  * A section marker holding its line alone, which is the signpost a document
@@ -65,10 +65,10 @@ const BOLD_LINE = /^\*\*([^*]+)\*\*\s*$/
65
65
  const SPAN_LABEL = /^`[^`]+`$/
66
66
  const MARKER_WIDTH = 20
67
67
 
68
- const LIST_ITEM = /^(\s*)([-*+]|\d+\.)\s+/
69
- const TABLE_ROW = /^\s*\|/
68
+ export const LIST_ITEM = /^(\s*)([-*+]|\d+\.)\s+/
69
+ export const TABLE_ROW = /^\s*\|/
70
70
  const TABLE_SEPARATOR = /^\s*\|[\s:|-]+\|\s*$/
71
- const BLOCKQUOTE = /^\s*>/
71
+ export const BLOCKQUOTE = /^\s*>/
72
72
 
73
73
  /**
74
74
  * A sentence ends on terminal punctuation followed by the start of another
@@ -109,8 +109,8 @@ const SENTENCE_END = /[.!?]["'’”)\]]*(?=\s+(?:["'“([]*[A-Z]|`)|\s*$)/g
109
109
  export const CHECKPOINTS = {
110
110
  run: 40,
111
111
  peerBullet: 130,
112
- bullet: 250,
113
- paragraph: 700,
112
+ bullet: 200,
113
+ paragraph: 350,
114
114
  sentences: 4,
115
115
  renderWidth: 80,
116
116
  cadence: 3,
@@ -336,7 +336,7 @@ function isTableRun(run: readonly BodyLine[]): boolean {
336
336
  * what lets a linked path reach the span test at all, since the markup around
337
337
  * it would fail the pattern the destination is still attached.
338
338
  */
339
- function isSectionMarker(text: string): boolean {
339
+ export function isSectionMarker(text: string): boolean {
340
340
  const marker = text.match(BOLD_LINE)
341
341
  if (!marker) return false
342
342
 
@@ -438,11 +438,7 @@ interface FoldedBullet extends BulletFinding {
438
438
  readonly text: string
439
439
  }
440
440
 
441
- /**
442
- * The walk `heavyBullets` reports from, keeping each past-checkpoint bullet's
443
- * text so `longBlocks` can ask whether a base copy of it exists. One walk
444
- * rather than two, for the reason `paragraphBlocks` is shared.
445
- */
441
+ /** The walk `heavyBullets` reports from. */
446
442
  function foldBullets(
447
443
  lines: readonly BodyLine[],
448
444
  checkpoints: Checkpoints,
@@ -579,10 +575,7 @@ function paragraphText(block: readonly BodyLine[]): string {
579
575
  * paragraph rule whenever the bullet rule was changed, and an author reading
580
576
  * the sentence cap would find no weight rule to read at all.
581
577
  *
582
- * The two numbers coincide today because paragraph and bullet weight measure
583
- * one population, sharing a median near 170 characters with no gap behind
584
- * either candidate. They are separate checkpoints regardless, so either moves
585
- * without dragging the other.
578
+ * They are separate checkpoints, so either moves without dragging the other.
586
579
  */
587
580
  export function heavyParagraphs(
588
581
  lines: readonly BodyLine[],
@@ -606,49 +599,6 @@ export function heavyParagraphs(
606
599
  return findings
607
600
  }
608
601
 
609
- export interface LongBlock {
610
- readonly kind: 'paragraph' | 'bullet'
611
- readonly line: number
612
- readonly characters: number
613
- /** The block's source lines folded into one, as the paragraph and bullet folds read it. */
614
- readonly text: string
615
- }
616
-
617
- /**
618
- * The paragraphs past the paragraph checkpoint and the top-level bullets past
619
- * the bullet checkpoint, each with its text.
620
- *
621
- * Weight alone, never the sentence cap, since the growth stage asks about the
622
- * two numbers the standard states in characters. The definitions are the
623
- * audit's own, so a block this names and a block the weight report names are
624
- * one set.
625
- */
626
- export function longBlocks(
627
- lines: readonly BodyLine[],
628
- checkpoints: Checkpoints,
629
- ): LongBlock[] {
630
- const blocks: LongBlock[] = []
631
-
632
- for (const block of paragraphBlocks(lines)) {
633
- const text = paragraphText(block)
634
- const characters = visibleText(text).length
635
- if (characters > checkpoints.paragraph) {
636
- blocks.push({
637
- kind: 'paragraph',
638
- line: block[0].number,
639
- characters,
640
- text,
641
- })
642
- }
643
- }
644
-
645
- for (const bullet of foldBullets(lines, checkpoints)) {
646
- blocks.push({ kind: 'bullet', ...bullet })
647
- }
648
-
649
- return blocks.sort((a, b) => a.line - b.line)
650
- }
651
-
652
602
  /** Punctuation either side of a word, so an opener is compared on its letters. */
653
603
  const WORD_EDGE = /^[^\p{L}\p{N}]+|[^\p{L}\p{N}]+$/gu
654
604
 
package/src/pr/lift.ts CHANGED
@@ -5,6 +5,7 @@ import {
5
5
  resolveReviewScope,
6
6
  resolveUiScope,
7
7
  } from '@/pr/review-scope'
8
+ import { DEFAULT_PAINTED, isPainted, type PaintedPatterns } from '@/pr/painted'
8
9
 
9
10
  /** How long `--wait` holds for a pending CI run, in seconds, when none is named. */
10
11
  export const DEFAULT_WAIT_SECONDS = 1200
@@ -42,6 +43,17 @@ export interface GateResult {
42
43
  readonly isOk: boolean
43
44
  /** Absent when the gate passed. */
44
45
  readonly reason?: LiftRefusal
46
+ /** The commit a UI close was carried forward from, when it covered an earlier head. */
47
+ readonly carriedFrom?: string
48
+ /** The painted paths that kept a UI close from carrying forward. */
49
+ readonly paintedPaths?: readonly string[]
50
+ }
51
+
52
+ /** How an earlier commit sits against the head, and what changed since. */
53
+ export interface ChangedRead {
54
+ /** False when the commit is rewritten away or does not resolve. */
55
+ readonly isAncestor: boolean
56
+ readonly paths: readonly string[]
45
57
  }
46
58
 
47
59
  /** How the head sits against the base the pull request targets. */
@@ -85,6 +97,10 @@ export interface LiftApi {
85
97
  readChecks(tip: string): Promise<ChecksGate | undefined>
86
98
  /** The one GraphQL call the verb makes. */
87
99
  markReady(nodeId: string): Promise<boolean>
100
+ /** The paths changed from an earlier commit to the head, undefined when the read failed. */
101
+ readChanged(from: string, to: string): Promise<ChangedRead | undefined>
102
+ /** The one GraphQL call that returns a ready pull request to draft. */
103
+ markDraft(nodeId: string): Promise<boolean>
88
104
  sleep(ms: number): Promise<void>
89
105
  now(): number
90
106
  }
@@ -93,10 +109,20 @@ export interface LiftOptions {
93
109
  readonly isCheck: boolean
94
110
  readonly waitSeconds: number
95
111
  readonly pollMs?: number
112
+ readonly painted?: PaintedPatterns
96
113
  }
97
114
 
98
115
  export interface LiftRecord {
99
- readonly reason: 'lifted' | 'already-ready' | 'clear' | LiftRefusal
116
+ readonly reason:
117
+ | 'lifted'
118
+ | 'already-ready'
119
+ | 'redrafted'
120
+ | 'clear'
121
+ | LiftRefusal
122
+ /** The gate that read not clear when a ready pull request went back to draft. */
123
+ readonly failed?: LiftRefusal
124
+ readonly carriedFrom?: string
125
+ readonly paintedPaths?: readonly string[]
100
126
  readonly isLifted: boolean
101
127
  /** Whether the caller can treat the pull request as ready afterwards. */
102
128
  readonly isOk: boolean
@@ -129,22 +155,51 @@ function reviewGate(reviews: readonly ReviewRow[], head: string): GateResult {
129
155
  return pass('review')
130
156
  }
131
157
 
132
- /** Binds only where the evidence record carries a checklist to drive. */
133
- function uiGate(
158
+ /** What the UI gate says about a close carried across commits, for a record. */
159
+ function carried(gates: readonly GateResult[]): {
160
+ carriedFrom?: string
161
+ paintedPaths?: readonly string[]
162
+ } {
163
+ const ui = gates.find((gate) => gate.gate === 'ui')
164
+ return {
165
+ ...(ui?.carriedFrom !== undefined && { carriedFrom: ui.carriedFrom }),
166
+ ...(ui?.paintedPaths !== undefined && { paintedPaths: ui.paintedPaths }),
167
+ }
168
+ }
169
+
170
+ /**
171
+ * Binds only where the evidence record carries a checklist to drive.
172
+ *
173
+ * A close behind the head still holds when it is an ancestor of the head and
174
+ * no painted path changed since, since a push that paints nothing leaves the
175
+ * checklist the reviewer drove as true as it was. A close that is not an
176
+ * ancestor, which a force-push or a rebase leaves, never carries.
177
+ */
178
+ async function uiGate(
179
+ api: LiftApi,
180
+ painted: PaintedPatterns,
134
181
  reviews: readonly ReviewRow[],
135
182
  head: string,
136
183
  hasChecklist: boolean,
137
- ): GateResult {
184
+ ): Promise<GateResult> {
138
185
  if (!hasChecklist) return pass('ui')
139
186
 
140
187
  const scope = resolveUiScope({ reviews })
141
188
  if (scope.state === 'none') return fail('ui', 'ui-missing')
142
189
  if (scope.state === 'open') return fail('ui', 'ui-open')
143
190
  if (scope.source !== 'marker') return fail('ui', 'ui-unmarked')
144
- if (scope.commit === undefined || !coversHead(scope.commit, head)) {
145
- return fail('ui', 'ui-behind')
191
+ if (scope.commit === undefined) return fail('ui', 'ui-behind')
192
+ if (coversHead(scope.commit, head)) return pass('ui')
193
+
194
+ const changed = await api.readChanged(scope.commit, head)
195
+ if (changed === undefined) return fail('ui', 'gh-failed')
196
+ if (!changed.isAncestor) return fail('ui', 'ui-behind')
197
+
198
+ const paintedPaths = changed.paths.filter((path) => isPainted(path, painted))
199
+ if (paintedPaths.length > 0) {
200
+ return { ...fail('ui', 'ui-behind'), paintedPaths }
146
201
  }
147
- return pass('ui')
202
+ return { ...pass('ui'), carriedFrom: scope.commit }
148
203
  }
149
204
 
150
205
  function standingGate(standing: Standing): GateResult {
@@ -207,6 +262,56 @@ function refused(
207
262
  ...(head !== undefined && { head }),
208
263
  gates,
209
264
  waitedSeconds,
265
+ ...carried(gates),
266
+ }
267
+ }
268
+
269
+ /** The gates whose failure sends a ready pull request back to draft. */
270
+ const REDRAFT_GATES: readonly GateName[] = ['review', 'ui', 'evidence']
271
+
272
+ /**
273
+ * Reads a ready pull request against the gates a person's ready mark vouched
274
+ * for. A failing review, UI, or evidence gate returns it to draft, since the
275
+ * mark no longer holds. Standing and CI are reported and never written on, as
276
+ * either would flap a merge-ready pull request on a run still going.
277
+ */
278
+ async function settleReady(
279
+ api: LiftApi,
280
+ opts: LiftOptions,
281
+ pull: PullState,
282
+ tip: string,
283
+ gates: readonly GateResult[],
284
+ ): Promise<LiftRecord> {
285
+ const blocked = gates.find(
286
+ (gate) => REDRAFT_GATES.includes(gate.gate) && !gate.isOk,
287
+ )
288
+ if (blocked?.reason === undefined) {
289
+ return {
290
+ reason: 'already-ready',
291
+ isLifted: false,
292
+ isOk: true,
293
+ head: tip,
294
+ gates,
295
+ waitedSeconds: 0,
296
+ ...carried(gates),
297
+ }
298
+ }
299
+ if (opts.isCheck || blocked.reason === 'gh-failed') {
300
+ return refused(blocked.reason, gates, tip)
301
+ }
302
+
303
+ const isDrafted = await api.markDraft(pull.nodeId)
304
+ if (!isDrafted) return refused('gh-failed', gates, tip)
305
+
306
+ return {
307
+ reason: 'redrafted',
308
+ failed: blocked.reason,
309
+ isLifted: false,
310
+ isOk: false,
311
+ head: tip,
312
+ gates,
313
+ waitedSeconds: 0,
314
+ ...carried(gates),
210
315
  }
211
316
  }
212
317
 
@@ -224,20 +329,11 @@ export async function lift(
224
329
  opts: LiftOptions,
225
330
  ): Promise<LiftRecord> {
226
331
  const pollMs = opts.pollMs ?? CHECKS_POLL_MS
332
+ const painted = opts.painted ?? DEFAULT_PAINTED
227
333
 
228
334
  const pull = await api.readPull()
229
335
  if (pull === undefined) return refused('gh-failed', [], undefined)
230
336
  if (!pull.isOpen) return refused('pull-closed', [], pull.head)
231
- if (!pull.isDraft) {
232
- return {
233
- reason: 'already-ready',
234
- isLifted: false,
235
- isOk: true,
236
- head: pull.head,
237
- gates: [],
238
- waitedSeconds: 0,
239
- }
240
- }
241
337
 
242
338
  // The tip, not the object's head, since the object lags the ref by up to a
243
339
  // minute after a push and a review marker names the commit it read.
@@ -255,10 +351,12 @@ export async function lift(
255
351
 
256
352
  const gates: GateResult[] = [
257
353
  reviewGate(reviews, tip),
258
- uiGate(reviews, tip, evidence.hasChecklist),
354
+ await uiGate(api, painted, reviews, tip, evidence.hasChecklist),
259
355
  evidence.isSettled ? pass('evidence') : fail('evidence', 'evidence-owed'),
260
356
  standingGate(standing),
261
357
  ]
358
+ if (!pull.isDraft) return settleReady(api, opts, pull, tip, gates)
359
+
262
360
  const blocked = gates.find((gate) => !gate.isOk)
263
361
 
264
362
  if (opts.isCheck) {
@@ -284,6 +382,7 @@ export async function lift(
284
382
  head: tip,
285
383
  gates: all,
286
384
  waitedSeconds: 0,
385
+ ...carried(all),
287
386
  }
288
387
  }
289
388
 
@@ -316,13 +415,14 @@ export async function lift(
316
415
  head: nowTip,
317
416
  gates: cleared,
318
417
  waitedSeconds: ci.waited,
418
+ ...carried(cleared),
319
419
  }
320
420
  }
321
421
  if (nowTip !== tip) return refused('head-moved', cleared, nowTip, ci.waited)
322
422
 
323
423
  const reread = [
324
424
  reviewGate(nowReviews, nowTip),
325
- uiGate(nowReviews, nowTip, evidence.hasChecklist),
425
+ await uiGate(api, painted, nowReviews, nowTip, evidence.hasChecklist),
326
426
  ].find((gate) => !gate.isOk)
327
427
  if (reread?.reason !== undefined) {
328
428
  return refused(reread.reason, cleared, nowTip, ci.waited)
@@ -345,6 +445,7 @@ export async function lift(
345
445
  head: nowTip,
346
446
  gates: cleared,
347
447
  waitedSeconds: ci.waited,
448
+ ...carried(cleared),
348
449
  }
349
450
  }
350
451
 
@@ -416,3 +517,36 @@ export async function readBaseStanding(
416
517
 
417
518
  return nonEmptyLines(moved).length > 0 ? 'stale' : 'fresh'
418
519
  }
520
+
521
+ /**
522
+ * Reads what changed between a close commit and the head. A commit that does
523
+ * not resolve, or that the head does not descend from, reads as not an
524
+ * ancestor and carries nothing. A head that does not resolve, or a diff that
525
+ * fails, is a read that failed, so the gate refuses rather than passes.
526
+ *
527
+ * The abbreviated marker resolves first, so the ancestry test runs on the full
528
+ * sha. Renames are split into both sides so a move out of a painted path
529
+ * still reads as painted.
530
+ */
531
+ export async function readChangedPaths(
532
+ git: GitRunner,
533
+ from: string,
534
+ to: string,
535
+ ): Promise<ChangedRead | undefined> {
536
+ if ((await git(['cat-file', '-e', `${to}^{commit}`])) === undefined) {
537
+ return undefined
538
+ }
539
+
540
+ const full = (
541
+ await git(['rev-parse', '--verify', '-q', `${from}^{commit}`])
542
+ )?.trim()
543
+ if (full === undefined || full === '') return { isAncestor: false, paths: [] }
544
+
545
+ const base = (await git(['merge-base', full, to]))?.trim()
546
+ if (base !== full) return { isAncestor: false, paths: [] }
547
+
548
+ const diff = await git(['diff', '--name-only', '--no-renames', full, to])
549
+ if (diff === undefined) return undefined
550
+
551
+ return { isAncestor: true, paths: nonEmptyLines(diff) }
552
+ }
@@ -0,0 +1,87 @@
1
+ import { existsSync, readFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+
4
+ /** Where a project adds to or trims the default list of painted paths. */
5
+ export const PAINTED_CONFIG_REL = join('canon', 'config', 'painted.json')
6
+
7
+ export interface PaintedPatterns {
8
+ /** A path matching any of these is painted unless `unpainted` also matches. */
9
+ readonly painted: readonly string[]
10
+ readonly unpainted: readonly string[]
11
+ }
12
+
13
+ const RENDERED_EXTENSIONS = [
14
+ 'tsx',
15
+ 'jsx',
16
+ 'vue',
17
+ 'svelte',
18
+ 'astro',
19
+ 'html',
20
+ 'css',
21
+ 'scss',
22
+ 'svg',
23
+ 'png',
24
+ 'jpg',
25
+ 'jpeg',
26
+ 'gif',
27
+ 'webp',
28
+ 'avif',
29
+ 'ico',
30
+ 'woff',
31
+ 'woff2',
32
+ 'ttf',
33
+ 'otf',
34
+ ]
35
+
36
+ /**
37
+ * What a browser renders, minus the tests and stories beside it. A test-only
38
+ * push touches nothing a reviewer drove, which is the case the carry-forward
39
+ * exists for.
40
+ */
41
+ export const DEFAULT_PAINTED: PaintedPatterns = {
42
+ painted: [`**/*.{${RENDERED_EXTENSIONS.join(',')}}`],
43
+ unpainted: [
44
+ '**/*.{test,spec,stories}.*',
45
+ '**/__tests__/**',
46
+ '**/__snapshots__/**',
47
+ ],
48
+ }
49
+
50
+ function strings(value: unknown): string[] {
51
+ return Array.isArray(value)
52
+ ? value.filter((item): item is string => typeof item === 'string')
53
+ : []
54
+ }
55
+
56
+ /**
57
+ * The default list plus the project's own, read from `canon/config/painted.json`
58
+ * as `{ "painted": [...], "unpainted": [...] }`. A file that is absent or does
59
+ * not parse leaves the default, since a typo there must not make every push
60
+ * read as unpainted.
61
+ */
62
+ export function loadPaintedPatterns(root: string): PaintedPatterns {
63
+ const path = join(root, PAINTED_CONFIG_REL)
64
+ if (!existsSync(path)) return DEFAULT_PAINTED
65
+
66
+ try {
67
+ const parsed: unknown = JSON.parse(readFileSync(path, 'utf8'))
68
+ if (typeof parsed !== 'object' || parsed === null) return DEFAULT_PAINTED
69
+ const { painted, unpainted } = parsed as Record<string, unknown>
70
+ return {
71
+ painted: [...DEFAULT_PAINTED.painted, ...strings(painted)],
72
+ unpainted: [...DEFAULT_PAINTED.unpainted, ...strings(unpainted)],
73
+ }
74
+ } catch {
75
+ return DEFAULT_PAINTED
76
+ }
77
+ }
78
+
79
+ function matchesAny(path: string, globs: readonly string[]): boolean {
80
+ return globs.some((glob) => new Bun.Glob(glob).match(path))
81
+ }
82
+
83
+ export function isPainted(path: string, patterns: PaintedPatterns): boolean {
84
+ return (
85
+ matchesAny(path, patterns.painted) && !matchesAny(path, patterns.unpainted)
86
+ )
87
+ }
@@ -177,6 +177,16 @@ export function creationRel(
177
177
  return join(at, spell(at, folder), ...rest)
178
178
  }
179
179
 
180
+ /** Whether a root holds the backup history or a record folder, at its spelling. */
181
+ function holdsRecords(root: string, candidate: RecordRoot): boolean {
182
+ return RECORD_ENTRIES.some(
183
+ (entry) =>
184
+ entry !== 'README.md' &&
185
+ entry !== SCRATCH &&
186
+ existsSync(join(root, candidate, spell(candidate, entry))),
187
+ )
188
+ }
189
+
180
190
  /**
181
191
  * The record root itself, for a caller whose subject is the root rather than a
182
192
  * folder inside it.
@@ -186,11 +196,18 @@ export function creationRel(
186
196
  * one answer. Splitting them would let a push resolve a history at one root and
187
197
  * stage a work tree at the other, which stages the deletion of every folder the
188
198
  * move relocated.
199
+ *
200
+ * A root counts when it holds a `.records.git` or a record folder. The bare
201
+ * directory does not, because `.claude/` exists in every installed project for
202
+ * its hooks and rules, so testing it pinned a fresh clone to the retired root.
203
+ * `README.md` and the scratch folder are left out: a records pull writes the
204
+ * first back and the second is deletable, so a root holding only them has
205
+ * nothing to protect.
189
206
  */
190
207
  export function recordRoot(root: string): string {
191
208
  return join(
192
209
  root,
193
- RECORD_ROOTS.find((candidate) => existsSync(join(root, candidate))) ??
210
+ RECORD_ROOTS.find((candidate) => holdsRecords(root, candidate)) ??
194
211
  CREATION_ROOT,
195
212
  )
196
213
  }
@@ -69,7 +69,7 @@ export function listSkills(root: string): string[] {
69
69
  /**
70
70
  * Maps a scenario to the skill it drives, trying two spellings in order.
71
71
  *
72
- * `<category>-<command>` is the rule `canon/context/sandbox/coverage/census.md` states, and it
72
+ * `<category>-<command>` is the rule `canon/context/development/sandbox.md` states, and it
73
73
  * alone pairs 29 of 54 skills. The bare `<command>` fallback is what reaches the
74
74
  * rest: `claude/target-setup.ts` drives the `target-setup` skill, not a `claude-target-setup` that
75
75
  * does not exist. Stating one spelling and shipping two is what let the audit
@@ -417,8 +417,9 @@ async function drive(
417
417
 
418
418
  // Taken after provisioning, so a scenario that stages a live session record
419
419
  // of its own is already in the before snapshot rather than reported as a
420
- // dispatch this run made. `canon/context/sandbox/isolation.md` carries why
421
- // such a scenario writes to the real registry at all.
420
+ // dispatch this run made. A scenario that fakes a live session
421
+ // writes to the real registry, since provisioning and the driven run share
422
+ // no environment to repoint it.
422
423
  const registry = sessionsDir()
423
424
  const sessionsBefore = snapshotSessions(registry)
424
425
 
@@ -65,8 +65,8 @@ export function writesBetween(before: Manifest, after: Manifest): string[] {
65
65
  *
66
66
  * It makes an escape visible rather than impossible. A session can write to a
67
67
  * home directory, to a sibling worktree, or to any path outside these roots, and
68
- * none of that is watched. The standing limits in
69
- * `canon/context/sandbox/overview.md` carry what stays invisible.
68
+ * none of that is watched. `canon/context/development/sandbox.md` names the four
69
+ * directories the watch reads.
70
70
  *
71
71
  * A linked worktree is the normal place to develop this repository, and the
72
72
  * rule an escaping session follows sends scratch to the main root rather than to
@@ -108,8 +108,7 @@ export function escapeRoots(projectRoot: string): string[] {
108
108
  * `dispatch.ts`, so the key defined against this file-write watch keeps one
109
109
  * meaning. An arm whose skill is meant to reach past the sandbox tree declares
110
110
  * `escape_scope` in its `expect.toml`, which bounds a legitimate write rather
111
- * than the run. Read `canon/context/sandbox/isolation.md` and
112
- * `canon/context/sandbox/coverage/workflow-arms.md` before writing a claim past
111
+ * than the run. Read `canon/context/development/sandbox.md` before writing a claim past
113
112
  * that.
114
113
  */
115
114
  export const ESCAPE_SCRATCH_DIRS = [
@@ -132,7 +132,7 @@ const SAME_REPOSITORY =
132
132
  * `canon/context/` carries no equivalent pattern. The same resolution test
133
133
  * cannot separate a path every scaffolded project holds, such as
134
134
  * `canon/context/index.md`, from this repository's own domain entry, such
135
- * as `canon/context/context-model/indexes.md`, since both resolve here and only the
135
+ * as `canon/context/context-model.md`, since both resolve here and only the
136
136
  * second is a defect. That is a semantic read no pattern makes, so it stays
137
137
  * with the rule and the review checklist rather than joining this reader.
138
138
  */
@@ -157,6 +157,9 @@ export function slideNumberProps(theme: DeckTheme): PptxGenJS.SlideNumberProps {
157
157
  }
158
158
  }
159
159
 
160
+ // An SVG mark keeps the broken PNG fallback pptxgenjs writes, because the
161
+ // screenshot patch in export.ts reaches slides and never layouts, so the mark
162
+ // is a raster file.
160
163
  function markImage(path: string, size: MarkSize): PptxGenJS.ImageProps {
161
164
  const scale = Math.min(MARK_BOX.w / size.width, MARK_BOX.h / size.height)
162
165
  const w = Math.round(size.width * scale * 1000) / 1000
@@ -0,0 +1,46 @@
1
+ import { existsSync } from 'node:fs'
2
+ import { extname, join, resolve } from 'node:path'
3
+ import { execa } from 'execa'
4
+
5
+ /**
6
+ * Resolved in order, so a project carrying both formats with the repo's own.
7
+ * Prettier prints to stdout unless told to write, where oxfmt rewrites in place.
8
+ */
9
+ const FORMATTERS = [
10
+ { name: 'oxfmt', flags: [] },
11
+ { name: 'prettier', flags: ['--write'] },
12
+ ] as const
13
+
14
+ /** What a formatter reads. A shell script or a binary seed is left to its own tool. */
15
+ const FORMATTABLE = new Set(['.json', '.jsonc', '.md', '.yml', '.yaml'])
16
+
17
+ /**
18
+ * Runs the project's installed formatter over the files a sync wrote, so a
19
+ * write the formatter would reshape does not leave a whitespace diff on a file
20
+ * nobody edited. Only the named paths are passed, never the project, which
21
+ * keeps a dirty tree unformatted. A path is absolute or relative to `target`.
22
+ *
23
+ * Best effort: no installed formatter, or one that fails, leaves the output as
24
+ * written and the sync unaffected. Never pass a file the sync hashes against
25
+ * its source, since the bytes on disk would stop matching the record.
26
+ */
27
+ export async function formatWritten(
28
+ target: string,
29
+ paths: readonly string[],
30
+ ): Promise<void> {
31
+ const files = paths
32
+ .filter((path) => FORMATTABLE.has(extname(path)))
33
+ .map((path) => resolve(target, path))
34
+ if (files.length === 0) return
35
+
36
+ const formatter = FORMATTERS.map((candidate) => ({
37
+ ...candidate,
38
+ path: join(target, 'node_modules', '.bin', candidate.name),
39
+ })).find((candidate) => existsSync(candidate.path))
40
+ if (formatter === undefined) return
41
+
42
+ await execa(formatter.path, [...formatter.flags, ...files], {
43
+ cwd: target,
44
+ reject: false,
45
+ })
46
+ }
package/src/sync/stamp.ts CHANGED
@@ -4,6 +4,7 @@ import { mkdir, writeFile } from 'node:fs/promises'
4
4
  import { dirname, join, sep } from 'node:path'
5
5
  import { execa } from 'execa'
6
6
  import { resolveExisting } from '@/roots/legacy'
7
+ import { formatWritten } from '@/sync/format'
7
8
  import { recordTarget } from '@/targets/registry'
8
9
  import { compareVersions, parseVersion } from '@/version/compare'
9
10
  import { readInstalled } from '@/version/installed'
@@ -287,6 +288,11 @@ async function putDomain(
287
288
  await mkdir(dirname(path), { recursive: true })
288
289
  await writeFile(path, `${JSON.stringify(stamp, null, 2)}\n`)
289
290
 
291
+ // The stamp hashes every file but itself, so reshaping it cannot read as
292
+ // drift. Seeds and goldens stay as copied for the opposite reason: their
293
+ // hashes are taken against the source bytes.
294
+ await formatWritten(target, [path])
295
+
290
296
  // Every install and sync that stamps a target passes through here, which is
291
297
  // what makes this the one place the machine-level index can be kept without
292
298
  // each command remembering to. Its outcome is dropped rather than reported:
@@ -22,7 +22,7 @@ The astro stack covers Astro + TypeScript projects: content sites, marketing sit
22
22
  - `playwright.config.ts`: all browsers, `webServer` runs `bun run build && bun run preview` on port `4321` plus `WORKTREE_PORT_OFFSET`, `reuseExistingServer: false`. Astro's dev/prod gap is wide (MDX, island hydration, asset optimization), so E2E always tests the built `dist/`. `DIST_PREBUILT` set in the environment drops the `build` half, running `bun run preview` alone against a `dist/` a prior CI job already produced.
23
23
  - `tsconfig.json`: extends `astro/tsconfigs/strict`, adds `skipLibCheck`, `vitest/globals` and `@testing-library/jest-dom/vitest` in types, `@/` paths.
24
24
  - `card.config.mjs`, `card-src/pages/og-card.astro`, and `scripts/check-card-exclusion.sh`: the social card route, its structural build exclusion, and the check enforcing it. See `## Social card route` below.
25
- - `eslint.config.js`: this stack's own flat config, since the web layer lints with oxlint, which parses no `.astro` file. Adds `eslint-plugin-astro` (`.astro` parser via `astro-eslint-parser`). React-hooks scoped to `.jsx`/`.tsx` only (`.astro` is not React). The shared block's `files` selector includes `.astro`, so `check-file/filename-naming-convention` reaches `.ts`, `.tsx`, and `.astro` under `KEBAB_CASE`, overriding Astro's own PascalCase component convention deliberately, on the ground that a component's name in markup comes from the import binding rather than the filename. `.js` and `.jsx` stay out of the rule's own pattern, matching `web` and `nextjs`. `check-file/folder-naming-convention` reaches every `src/**` folder except `__tests__` and the whole `pages/` subtree, which carries its own off-block for the bracket-named dynamic routes and nested slug folders Astro's file-based routing produces. See `canon/context/tooling/stacks.md` for the measurement.
25
+ - `eslint.config.js`: this stack's own flat config, since the web layer lints with oxlint, which parses no `.astro` file. Adds `eslint-plugin-astro` (`.astro` parser via `astro-eslint-parser`). React-hooks scoped to `.jsx`/`.tsx` only (`.astro` is not React). The shared block's `files` selector includes `.astro`, so `check-file/filename-naming-convention` reaches `.ts`, `.tsx`, and `.astro` under `KEBAB_CASE`, overriding Astro's own PascalCase component convention deliberately, on the ground that a component's name in markup comes from the import binding rather than the filename. `.js` and `.jsx` stay out of the rule's own pattern, matching `web` and `nextjs`. `check-file/folder-naming-convention` reaches every `src/**` folder except `__tests__` and the whole `pages/` subtree, which carries its own off-block for the bracket-named dynamic routes and nested slug folders Astro's file-based routing produces. See `canon/context/surfaces/tooling.md` for the measurement.
26
26
 
27
27
  ## Typecheck
28
28
 
@@ -60,7 +60,7 @@ Append to the `## Scripts` table:
60
60
 
61
61
  ## CI docs (extend)
62
62
 
63
- In `canon/context/ci.md`, the Typecheck row's assertion reads: `` `astro check` passes ``. The Build row's assertion reads: `` `astro build` succeeds ``. <!-- audit-ignore-citations: canon/context/ci.md -->
63
+ In `canon/context/development/ci.md`, the Typecheck row's assertion reads: `` `astro check` passes ``. The Build row's assertion reads: `` `astro build` succeeds ``. <!-- audit-ignore-citations: canon/context/development/ci.md -->
64
64
 
65
65
  ## Social card route
66
66