@erclx/canon 5.39.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 (131) hide show
  1. package/README.md +5 -5
  2. package/claude/.claude-plugin/plugin.json +1 -1
  3. package/claude/skills/canvas-board/SKILL.md +1 -1
  4. package/claude/skills/draft-slides/SKILL.md +2 -3
  5. package/claude/skills/draft-slides/references/deck.md +183 -0
  6. package/claude/skills/git-pr/references/evidence.md +2 -2
  7. package/claude/skills/git-pr/references/final-command.md +1 -1
  8. package/claude/skills/git-pr/references/labels.md +1 -1
  9. package/claude/skills/git-pr/references/publish.md +1 -1
  10. package/claude/skills/review-ui/SKILL.md +1 -0
  11. package/claude/skills/role-orchestrator/references/orchestrator-handback.md +1 -1
  12. package/claude/skills/task-board/references/board.md +1 -1
  13. package/claude/skills/task-board/references/tasks.md +1 -1
  14. package/claude/skills/ux-measure/SKILL.md +1 -1
  15. package/docs/agents/audits.md +61 -60
  16. package/docs/agents/context-audit.md +40 -58
  17. package/docs/agents/index.md +5 -51
  18. package/docs/agents/install-and-sync.md +1 -0
  19. package/docs/agents/markdown-audit.md +53 -99
  20. package/docs/agents/records.md +37 -79
  21. package/docs/agents/sessions-transfer.md +26 -70
  22. package/docs/target/index.md +0 -1
  23. package/docs/target/migrations.md +57 -66
  24. package/docs/target/projects.md +36 -78
  25. package/docs/target/scenarios.md +9 -19
  26. package/docs/target/sync.md +27 -68
  27. package/docs/workflow/ai-workflow.md +22 -68
  28. package/docs/workflow/operating-model.md +32 -186
  29. package/docs/workflow/parallel-features.md +29 -66
  30. package/docs/workflow/skill-map-on-request.md +22 -24
  31. package/docs/workflow/skill-map.md +72 -76
  32. package/docs/workflow/visual-design-workflow.md +25 -118
  33. package/docs/workflow/zshrc-aliases.md +18 -37
  34. package/package.json +1 -1
  35. package/src/canvas/client/inspector/fields.css +4 -0
  36. package/src/canvas/index.html +6 -0
  37. package/src/claude/skills-reach.ts +2 -2
  38. package/src/cli/ui.ts +3 -1
  39. package/src/commands/canvas.ts +23 -0
  40. package/src/commands/capture.ts +14 -0
  41. package/src/commands/claude.ts +13 -0
  42. package/src/commands/demo.ts +22 -0
  43. package/src/commands/deps.ts +6 -0
  44. package/src/commands/driver.ts +28 -2
  45. package/src/commands/gov.ts +52 -18
  46. package/src/commands/intake.ts +17 -0
  47. package/src/commands/labels.ts +38 -5
  48. package/src/commands/pr.ts +160 -43
  49. package/src/commands/records.ts +8 -0
  50. package/src/commands/secrets.ts +6 -0
  51. package/src/commands/sessions.ts +8 -0
  52. package/src/commands/tasks.ts +52 -0
  53. package/src/commands/teach.ts +13 -0
  54. package/src/commands/upstream.ts +25 -1
  55. package/src/commands/worktrees.ts +5 -0
  56. package/src/context/audit.ts +22 -4
  57. package/src/context/folders.ts +1 -1
  58. package/src/context/shape-gated.ts +4 -2
  59. package/src/counts/scan.ts +1 -1
  60. package/src/design/components.ts +1 -1
  61. package/src/design/document.ts +1 -1
  62. package/src/design/tokens.ts +1 -1
  63. package/src/gate/boundaries.ts +4 -1
  64. package/src/gate/measures.ts +1 -1
  65. package/src/gate/stages.ts +7 -7
  66. package/src/git/env.ts +6 -0
  67. package/src/markdown/scope.ts +5 -17
  68. package/src/pr/lift.ts +153 -19
  69. package/src/pr/painted.ts +87 -0
  70. package/src/roots/record.ts +18 -1
  71. package/src/sandbox/census.ts +1 -1
  72. package/src/sandbox/headless/run.ts +3 -2
  73. package/src/sandbox/headless/watch.ts +3 -4
  74. package/src/slides/convert/master.ts +3 -0
  75. package/src/sync/format.ts +46 -0
  76. package/src/sync/stamp.ts +6 -0
  77. package/tooling/astro/reference.md +2 -2
  78. package/tooling/base/reference.md +1 -1
  79. package/tooling/go/reference.md +1 -1
  80. package/tooling/nextjs/reference.md +1 -1
  81. package/tooling/php/reference.md +1 -1
  82. package/tooling/python/reference.md +1 -1
  83. package/tooling/vite-react/reference.md +1 -1
  84. package/tooling/web/reference.md +1 -1
  85. package/docs/agents/canvas.md +0 -86
  86. package/docs/agents/capture.md +0 -65
  87. package/docs/agents/census.md +0 -23
  88. package/docs/agents/comments.md +0 -34
  89. package/docs/agents/context-audit-checks.md +0 -108
  90. package/docs/agents/context-classify.md +0 -101
  91. package/docs/agents/counts.md +0 -74
  92. package/docs/agents/demo.md +0 -140
  93. package/docs/agents/driver.md +0 -103
  94. package/docs/agents/gate.md +0 -90
  95. package/docs/agents/hooks.md +0 -78
  96. package/docs/agents/intake.md +0 -81
  97. package/docs/agents/key-changes.md +0 -116
  98. package/docs/agents/label-coverage.md +0 -86
  99. package/docs/agents/markdown-audit-density.md +0 -39
  100. package/docs/agents/markdown-audit-weight.md +0 -83
  101. package/docs/agents/pr-evidence-layout.md +0 -70
  102. package/docs/agents/pr-evidence.md +0 -273
  103. package/docs/agents/pr-frames.md +0 -131
  104. package/docs/agents/pr-lift.md +0 -132
  105. package/docs/agents/pr-local.md +0 -111
  106. package/docs/agents/pr-preview.md +0 -155
  107. package/docs/agents/pr-reads.md +0 -111
  108. package/docs/agents/pr-tick.md +0 -72
  109. package/docs/agents/records-push.md +0 -61
  110. package/docs/agents/records-stale.md +0 -40
  111. package/docs/agents/records-validate.md +0 -57
  112. package/docs/agents/restated.md +0 -83
  113. package/docs/agents/routing.md +0 -61
  114. package/docs/agents/rule-citations.md +0 -90
  115. package/docs/agents/sandbox.md +0 -126
  116. package/docs/agents/sessions.md +0 -128
  117. package/docs/agents/skills-audit.md +0 -120
  118. package/docs/agents/skills-reach.md +0 -64
  119. package/docs/agents/slides.md +0 -176
  120. package/docs/agents/state-scoped-risk.md +0 -105
  121. package/docs/agents/superseded.md +0 -85
  122. package/docs/agents/tasks-archive.md +0 -83
  123. package/docs/agents/tasks-plans.md +0 -152
  124. package/docs/agents/tasks-validate.md +0 -133
  125. package/docs/agents/tasks.md +0 -123
  126. package/docs/agents/teach-authoring.md +0 -118
  127. package/docs/agents/teach.md +0 -157
  128. package/docs/agents/test-order.md +0 -58
  129. package/docs/agents/upstream.md +0 -94
  130. package/docs/agents/worktrees.md +0 -118
  131. package/docs/target/standards-moved.md +0 -34
@@ -95,7 +95,7 @@ function borderRows(tokens: readonly BorderToken[]): string[][] {
95
95
  */
96
96
  const LEAD = [
97
97
  'Authoring guidance: `claude/skills/canvas-board/references/design.md`. Rendered from `src/design/tokens.ts` by `canon design regen`, so edit the module and never this file.',
98
- 'Why each value was chosen lives in `canon/context/design.md`.',
98
+ 'Why each value was chosen lives in `canon/context/features/design.md`.',
99
99
  ].join(' ')
100
100
 
101
101
  function bullets(rules: readonly string[]): string {
@@ -59,7 +59,7 @@ export interface BorderToken {
59
59
  /**
60
60
  * Every prose slot is a list of one-line rules rather than a string, so a
61
61
  * bullet is the unit the renderer emits and the prose budget counts, and a
62
- * value needing a paragraph of why goes to `canon/context/design.md`.
62
+ * value needing a paragraph of why goes to `canon/context/features/design.md`.
63
63
  */
64
64
  export interface DesignTokens {
65
65
  readonly personality: string
@@ -211,7 +211,7 @@ function collectCommands(value: unknown, into: string[] = []): string[] {
211
211
  /**
212
212
  * A hook, a workflow, or a husky script reaching one side of the seed or config
213
213
  * boundary and not the other is a capability withheld with no recorded reason,
214
- * per the criterion in `canon/context/tooling/seeds.md`.
214
+ * per the criterion in `canon/context/surfaces/tooling.md`.
215
215
  *
216
216
  * Presence on either side clears a name, and a `canon-no-seed:` comment in the
217
217
  * file is the recorded reason. The same marker on a destination clears a
@@ -434,6 +434,9 @@ function grepLines(
434
434
  * seed itself installs, so every other spelling reports. No seed root, or roots
435
435
  * carrying no markdown, is unmeasured rather than a pass, which agrees with
436
436
  * `seedStandards` on the no-root case.
437
+ *
438
+ * The token matches as a substring, so `canonical` reports as `canon` does. The
439
+ * walk covers the seed roots alone, so a shipped rule citing the CLI passes.
437
440
  */
438
441
  export const seedIndependence: Measure = async (ctx) => {
439
442
  if (!isDirectory(join(ctx.root, 'tooling'))) {
@@ -1401,7 +1401,7 @@ export const pluginManifests: Measure = async (ctx) => {
1401
1401
  return {
1402
1402
  emissions: [],
1403
1403
  unmeasured:
1404
- 'claude is on PATH and claude --version fails, so the install brought down no platform-native binary and no manifest was read. Raise or lower the pinned version at the Install Plugin CLI step in .github/workflows/verify.yml, and record the move in canon/context/ci/checks.md.',
1404
+ 'claude is on PATH and claude --version fails, so the install brought down no platform-native binary and no manifest was read. Raise or lower the pinned version at the Install Plugin CLI step in .github/workflows/verify.yml, then test the raised pin as `canon/context/development/ci.md` states.',
1405
1405
  }
1406
1406
  }
1407
1407
 
@@ -92,12 +92,12 @@ export interface Stage {
92
92
  }
93
93
 
94
94
  /**
95
- * Corpora a `src/` test asserts over from outside `src/`, censused in
96
- * `canon/context/development/tests.md`. This list and that census are
97
- * two copies of one set with nothing comparing them, so a corpus joining the
98
- * census joins this list in the same change. The first four are directory
99
- * prefixes because their tests walk the tree whole, which is what reaches a
100
- * rule or a skill a branch adds rather than edits.
95
+ * Corpora a `src/` test asserts over from outside `src/`. Nothing compares
96
+ * this list to the tests reading those paths, so a test that starts reading a
97
+ * new corpus joins it here in the same change, or a branch touching only that
98
+ * corpus skips the Tests stage. The first four are directory prefixes because
99
+ * their tests walk the tree whole, which is what reaches a rule or a skill a
100
+ * branch adds rather than edits.
101
101
  */
102
102
  export const TEST_CORPORA_PATTERNS = [
103
103
  '^claude/skills/',
@@ -302,7 +302,7 @@ export const STAGES: readonly Stage[] = [
302
302
  {
303
303
  // A hook, a workflow, or a husky script reaching one side of the seed or
304
304
  // config boundary and not the other is a capability withheld with no
305
- // recorded reason, per the criterion in canon/context/tooling/seeds.md.
305
+ // recorded reason, per the criterion in canon/context/surfaces/tooling.md.
306
306
  id: 'capability-seeding',
307
307
  label: 'Capability seeding',
308
308
  checks: [{ kind: 'measure', measure: capabilitySeeding }],
package/src/git/env.ts CHANGED
@@ -26,6 +26,12 @@ const RESOLUTION_VARS = [
26
26
  * Read per call rather than captured at module load. A long-lived process can
27
27
  * have these set after import, and a snapshot would then hand git the very
28
28
  * variables this exists to strip.
29
+ *
30
+ * Hooks export `GIT_DIR`, `GIT_WORK_TREE`, `GIT_INDEX_FILE`, and `GIT_PREFIX`,
31
+ * which beat `-C`. Under pre-push an inherited `GIT_DIR` made a `-C src` read
32
+ * resolve against the whole repository, and in a fixture it let a config write
33
+ * overwrite the real repository's committer identity, so route every shelled
34
+ * call through this, fixtures included.
29
35
  */
30
36
  export function gitEnv(): Record<string, string> {
31
37
  return Object.fromEntries(
@@ -35,16 +35,11 @@ const DENSITY_SCOPE = [
35
35
  * corpus, which is why the audit reads it only inside a canon checkout.
36
36
  */
37
37
  export const GATED_PATHS: readonly string[] = [
38
- 'canon/context/claude-plugin/',
39
- 'canon/context/claude-internal/',
38
+ 'canon/context/surfaces/',
39
+ 'canon/context/workflow/',
40
+ 'canon/context/development/',
40
41
  'canon/context/features/',
41
- 'canon/context/cli/',
42
- 'canon/context/sandbox/',
43
- 'canon/context/governance/',
44
- 'canon/context/tooling/',
45
- 'canon/context/web.md',
46
42
  'canon/context/context-model.md',
47
- 'canon/context/design.md',
48
43
  'canon/context/index.md',
49
44
  'claude/skills/api-design/SKILL.md',
50
45
  'claude/skills/auto-ship/SKILL.md',
@@ -76,6 +71,7 @@ export const GATED_PATHS: readonly string[] = [
76
71
  'claude/skills/draft-screencast/SKILL.md',
77
72
  'claude/skills/draft-screencast/references/beats-template.md',
78
73
  'claude/skills/draft-slides/SKILL.md',
74
+ 'claude/skills/draft-slides/references/deck.md',
79
75
  'claude/skills/git-branch/SKILL.md',
80
76
  'claude/skills/git-commit/SKILL.md',
81
77
  'claude/skills/git-followup/SKILL.md',
@@ -130,16 +126,8 @@ export const GATED_PATHS: readonly string[] = [
130
126
  'claude/skills/video-craft/SKILL.md',
131
127
  'claude/skills/write-human/SKILL.md',
132
128
  'claude/skills/youtube-transcripts/SKILL.md',
133
- 'docs/agents/indexes.md',
134
- 'docs/agents/install-and-sync.md',
135
- 'docs/agents/migrate.md',
136
- 'docs/agents/output-shape.md',
137
- 'docs/agents/overview.md',
138
- 'docs/agents/targets.md',
139
- 'canon/context/development/',
140
- 'canon/context/ci/',
141
- 'canon/context/scripts/',
142
129
  '.claude/skills/internal-',
130
+ 'docs/',
143
131
  ]
144
132
 
145
133
  /**
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 = [
@@ -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: