@erclx/canon 5.39.0 → 5.41.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 (162) hide show
  1. package/README.md +39 -22
  2. package/claude/.claude-plugin/plugin.json +1 -1
  3. package/claude/skills/auto-ship/REQUIREMENT.md +1 -0
  4. package/claude/skills/auto-ship/SKILL.md +6 -0
  5. package/claude/skills/auto-ship/references/fan-in.md +59 -0
  6. package/claude/skills/canvas-board/SKILL.md +1 -1
  7. package/claude/skills/create-skill/references/skill.md +1 -1
  8. package/claude/skills/decision-escalate/REQUIREMENT.md +1 -1
  9. package/claude/skills/decision-escalate/SKILL.md +1 -2
  10. package/claude/skills/draft-slides/SKILL.md +2 -3
  11. package/claude/skills/draft-slides/references/deck.md +183 -0
  12. package/claude/skills/git-branch/REQUIREMENT.md +17 -6
  13. package/claude/skills/git-branch/SKILL.md +63 -7
  14. package/claude/skills/git-pr/references/evidence.md +2 -2
  15. package/claude/skills/git-pr/references/final-command.md +1 -1
  16. package/claude/skills/git-pr/references/labels.md +1 -1
  17. package/claude/skills/git-pr/references/publish.md +1 -1
  18. package/claude/skills/plan-feature/references/batches.md +48 -1
  19. package/claude/skills/plan-feature/references/plan.md +2 -0
  20. package/claude/skills/plan-intake-answer/REQUIREMENT.md +1 -1
  21. package/claude/skills/plan-intake-answer/SKILL.md +1 -2
  22. package/claude/skills/review-address/references/rebase-conflicts.md +7 -0
  23. package/claude/skills/review-ui/SKILL.md +3 -0
  24. package/claude/skills/review-ui/references/close-out.md +47 -0
  25. package/claude/skills/review-ui/references/post.md +3 -1
  26. package/claude/skills/role-orchestrator/references/orchestrator-dispatch.md +1 -0
  27. package/claude/skills/role-orchestrator/references/orchestrator-handback.md +1 -1
  28. package/claude/skills/role-worker/REQUIREMENT.md +1 -0
  29. package/claude/skills/role-worker/SKILL.md +9 -0
  30. package/claude/skills/task-board/references/board.md +1 -1
  31. package/claude/skills/task-board/references/tasks.md +1 -1
  32. package/claude/skills/teach-workspace/REQUIREMENT.md +1 -1
  33. package/claude/skills/teach-workspace/SKILL.md +1 -2
  34. package/claude/skills/ux-measure/SKILL.md +1 -1
  35. package/docs/agents/audits.md +61 -60
  36. package/docs/agents/context-audit.md +40 -58
  37. package/docs/agents/index.md +5 -51
  38. package/docs/agents/install-and-sync.md +1 -0
  39. package/docs/agents/markdown-audit.md +53 -99
  40. package/docs/agents/records.md +37 -79
  41. package/docs/agents/sessions-transfer.md +26 -70
  42. package/docs/target/index.md +0 -1
  43. package/docs/target/migrations.md +57 -66
  44. package/docs/target/projects.md +36 -78
  45. package/docs/target/scenarios.md +9 -19
  46. package/docs/target/sync.md +27 -68
  47. package/docs/workflow/ai-workflow.md +22 -68
  48. package/docs/workflow/operating-model.md +32 -186
  49. package/docs/workflow/parallel-features.md +30 -66
  50. package/docs/workflow/skill-map-on-request.md +22 -24
  51. package/docs/workflow/skill-map.md +72 -76
  52. package/docs/workflow/visual-design-workflow.md +25 -118
  53. package/docs/workflow/zshrc-aliases.md +18 -37
  54. package/package.json +1 -1
  55. package/src/canvas/client/inspector/fields.css +4 -0
  56. package/src/canvas/index.html +6 -0
  57. package/src/claude/cases/git.ts +4 -0
  58. package/src/claude/skills-reach.ts +2 -2
  59. package/src/cli/ui.ts +3 -1
  60. package/src/commands/canvas.ts +23 -0
  61. package/src/commands/capture.ts +14 -0
  62. package/src/commands/claude.ts +13 -0
  63. package/src/commands/demo.ts +22 -0
  64. package/src/commands/deps.ts +6 -0
  65. package/src/commands/driver.ts +28 -2
  66. package/src/commands/gov.ts +52 -18
  67. package/src/commands/intake.ts +17 -0
  68. package/src/commands/labels.ts +38 -5
  69. package/src/commands/pr.ts +192 -52
  70. package/src/commands/records.ts +19 -3
  71. package/src/commands/secrets.ts +6 -0
  72. package/src/commands/sessions.ts +8 -0
  73. package/src/commands/tasks.ts +52 -0
  74. package/src/commands/teach.ts +13 -0
  75. package/src/commands/upstream.ts +25 -1
  76. package/src/commands/worktrees.ts +5 -0
  77. package/src/context/audit.ts +22 -4
  78. package/src/context/folders.ts +1 -1
  79. package/src/context/shape-gated.ts +5 -2
  80. package/src/counts/scan.ts +1 -1
  81. package/src/design/components.ts +1 -1
  82. package/src/design/document.ts +1 -1
  83. package/src/design/tokens.ts +1 -1
  84. package/src/gate/boundaries.ts +4 -1
  85. package/src/gate/measures.ts +1 -1
  86. package/src/gate/stages.ts +7 -7
  87. package/src/git/env.ts +6 -0
  88. package/src/markdown/scope.ts +11 -19
  89. package/src/pr/frames.ts +53 -9
  90. package/src/pr/lift.ts +153 -19
  91. package/src/pr/painted.ts +87 -0
  92. package/src/records/validate.ts +1 -0
  93. package/src/roots/record.ts +18 -1
  94. package/src/sandbox/census.ts +1 -1
  95. package/src/sandbox/headless/run.ts +3 -2
  96. package/src/sandbox/headless/watch.ts +3 -4
  97. package/src/slides/convert/master.ts +3 -0
  98. package/src/sync/format.ts +46 -0
  99. package/src/sync/stamp.ts +6 -0
  100. package/src/tasks/answers.ts +79 -2
  101. package/src/tasks/fan-in.ts +176 -0
  102. package/src/tasks/stack.ts +1 -1
  103. package/tooling/astro/reference.md +80 -39
  104. package/tooling/base/reference.md +63 -32
  105. package/tooling/base/seeds/canon/context/ci.md +7 -15
  106. package/tooling/base/seeds/canon/context/development.md +14 -14
  107. package/tooling/claude/reference.md +40 -22
  108. package/tooling/cloudflare/reference.md +31 -12
  109. package/tooling/go/reference.md +47 -23
  110. package/tooling/nextjs/reference.md +52 -19
  111. package/tooling/php/reference.md +35 -16
  112. package/tooling/python/reference.md +47 -30
  113. package/tooling/shell/reference.md +6 -5
  114. package/tooling/vite-react/reference.md +49 -21
  115. package/tooling/web/reference.md +131 -71
  116. package/docs/agents/canvas.md +0 -86
  117. package/docs/agents/capture.md +0 -65
  118. package/docs/agents/census.md +0 -23
  119. package/docs/agents/comments.md +0 -34
  120. package/docs/agents/context-audit-checks.md +0 -108
  121. package/docs/agents/context-classify.md +0 -101
  122. package/docs/agents/counts.md +0 -74
  123. package/docs/agents/demo.md +0 -140
  124. package/docs/agents/driver.md +0 -103
  125. package/docs/agents/gate.md +0 -90
  126. package/docs/agents/hooks.md +0 -78
  127. package/docs/agents/intake.md +0 -81
  128. package/docs/agents/key-changes.md +0 -116
  129. package/docs/agents/label-coverage.md +0 -86
  130. package/docs/agents/markdown-audit-density.md +0 -39
  131. package/docs/agents/markdown-audit-weight.md +0 -83
  132. package/docs/agents/pr-evidence-layout.md +0 -70
  133. package/docs/agents/pr-evidence.md +0 -273
  134. package/docs/agents/pr-frames.md +0 -131
  135. package/docs/agents/pr-lift.md +0 -132
  136. package/docs/agents/pr-local.md +0 -111
  137. package/docs/agents/pr-preview.md +0 -155
  138. package/docs/agents/pr-reads.md +0 -111
  139. package/docs/agents/pr-tick.md +0 -72
  140. package/docs/agents/records-push.md +0 -61
  141. package/docs/agents/records-stale.md +0 -40
  142. package/docs/agents/records-validate.md +0 -57
  143. package/docs/agents/restated.md +0 -83
  144. package/docs/agents/routing.md +0 -61
  145. package/docs/agents/rule-citations.md +0 -90
  146. package/docs/agents/sandbox.md +0 -126
  147. package/docs/agents/sessions.md +0 -128
  148. package/docs/agents/skills-audit.md +0 -120
  149. package/docs/agents/skills-reach.md +0 -64
  150. package/docs/agents/slides.md +0 -176
  151. package/docs/agents/state-scoped-risk.md +0 -105
  152. package/docs/agents/superseded.md +0 -85
  153. package/docs/agents/tasks-archive.md +0 -83
  154. package/docs/agents/tasks-plans.md +0 -152
  155. package/docs/agents/tasks-validate.md +0 -133
  156. package/docs/agents/tasks.md +0 -123
  157. package/docs/agents/teach-authoring.md +0 -118
  158. package/docs/agents/teach.md +0 -157
  159. package/docs/agents/test-order.md +0 -58
  160. package/docs/agents/upstream.md +0 -94
  161. package/docs/agents/worktrees.md +0 -118
  162. package/docs/target/standards-moved.md +0 -34
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
+ }
@@ -48,6 +48,7 @@ export const FINDING_KINDS = [
48
48
  'batch-unsplit',
49
49
  'source-unquoted',
50
50
  'stack-foldable',
51
+ 'fan-in-unmatched',
51
52
  ] as const
52
53
 
53
54
  export type FindingKind = (typeof FINDING_KINDS)[number]
@@ -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:
@@ -1,8 +1,15 @@
1
1
  import { existsSync } from 'node:fs'
2
2
  import { readFile } from 'node:fs/promises'
3
3
  import { basename, isAbsolute, relative, resolve } from 'node:path'
4
+ import { branchRefs } from '@/git/worktree'
5
+ import { fanInBranch, fanInFindings, readFanInMembers } from '@/tasks/fan-in'
4
6
  import { isUnder } from '@/tasks/paths'
5
- import { JUDGED_APART_REMEDY, readLivePlans, stackPairs } from '@/tasks/stack'
7
+ import {
8
+ JUDGED_APART_REMEDY,
9
+ readLivePlans,
10
+ type StackPlan,
11
+ stackPairs,
12
+ } from '@/tasks/stack'
6
13
  import { recordDir, recordDirs } from '@/roots/record'
7
14
  import {
8
15
  hasStagedBatches,
@@ -49,6 +56,28 @@ export interface PlanAnswers {
49
56
 
50
57
  export type AnswersOutcome = PlanAnswers | AnswersRefused
51
58
 
59
+ /**
60
+ * Whether `refs/remotes/origin/<branch>` exists, or `null` when the read
61
+ * itself failed. Injected so the gate is testable without a repository.
62
+ */
63
+ export type RemoteBranchReader = (
64
+ root: string,
65
+ branch: string,
66
+ ) => Promise<boolean | null>
67
+
68
+ /**
69
+ * Reads the remote-tracking ref as the last fetch left it, so a member pushed
70
+ * since then reads as absent and holds its integrator, which fails safe. The
71
+ * local head is not read, since it exists mid-build and a pushed branch is the
72
+ * only signal a member writes when it is done.
73
+ */
74
+ export const originBranchPushed: RemoteBranchReader = async (root, branch) => {
75
+ const report = await branchRefs(branch, root)
76
+ if (!report.readable) return null
77
+
78
+ return report.refs.includes(`refs/remotes/origin/${branch}`)
79
+ }
80
+
52
81
  /**
53
82
  * A reference that named a live plan, carrying the absolute path a reader opens
54
83
  * and the root-relative spelling a record reports.
@@ -217,6 +246,7 @@ export function resolvePlanReference(
217
246
  export async function planAnswers(
218
247
  root: string,
219
248
  reference: string,
249
+ readRemoteBranch: RemoteBranchReader = originBranchPushed,
220
250
  ): Promise<AnswersOutcome> {
221
251
  const resolved = resolvePlanReference(root, reference)
222
252
  if (!resolved.ok) return resolved
@@ -233,7 +263,8 @@ export async function planAnswers(
233
263
  }
234
264
 
235
265
  const stem = basename(resolved.path, '.md')
236
- const pairs = stackPairs(await readLivePlans(root))
266
+ const live = await readLivePlans(root)
267
+ const pairs = stackPairs(live)
237
268
 
238
269
  for (const { child, parent, shared } of pairs) {
239
270
  if (child !== stem) continue
@@ -244,6 +275,8 @@ export async function planAnswers(
244
275
  })
245
276
  }
246
277
 
278
+ open.push(...(await fanInEntries(root, stem, text, live, readRemoteBranch)))
279
+
247
280
  return {
248
281
  ok: true,
249
282
  plan: resolved.plan,
@@ -252,6 +285,50 @@ export async function planAnswers(
252
285
  }
253
286
  }
254
287
 
288
+ /**
289
+ * A plan whose fan-in set disagrees holds on `Fan-in set`, on either side,
290
+ * since an integrator listing an archived member would otherwise read that
291
+ * member's leftover branch as done. An integrator whose set agrees holds on
292
+ * `Fan-in pending` until every member branch is on origin.
293
+ */
294
+ async function fanInEntries(
295
+ root: string,
296
+ stem: string,
297
+ text: string,
298
+ live: readonly StackPlan[],
299
+ readRemoteBranch: RemoteBranchReader,
300
+ ): Promise<OpenQuestion[]> {
301
+ const entries: OpenQuestion[] = fanInFindings(live)
302
+ .filter(({ record }) => record === `${stem}.md`)
303
+ .map(({ message }) => ({ label: 'Fan-in set', why: message }))
304
+
305
+ const branches = readFanInMembers(text).map(fanInBranch)
306
+ if (branches.length === 0 || entries.length > 0) return entries
307
+
308
+ const pushed = await Promise.all(
309
+ branches.map((branch) => readRemoteBranch(root, branch)),
310
+ )
311
+
312
+ if (pushed.includes(null)) {
313
+ return [
314
+ {
315
+ label: 'Fan-in pending',
316
+ why: 'git could not read the member branches on origin, so the set is unread rather than done.',
317
+ },
318
+ ]
319
+ }
320
+
321
+ const absent = branches.filter((_, index) => pushed[index] !== true)
322
+ if (absent.length === 0) return []
323
+
324
+ return [
325
+ {
326
+ label: 'Fan-in pending',
327
+ why: `waits on ${absent.map((branch) => `\`${branch}\``).join(', ')} reaching origin. Fetch, then read again.`,
328
+ },
329
+ ]
330
+ }
331
+
255
332
  function refuse(
256
333
  reason: AnswerRefusal,
257
334
  message: string,