@erclx/canon 5.66.0 → 5.67.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 (66) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/claude/.claude-plugin/plugin.json +1 -1
  3. package/claude/skills/app-drive/REQUIREMENT.md +35 -0
  4. package/claude/skills/app-drive/SKILL.md +112 -0
  5. package/claude/skills/app-drive/references/config.md +38 -0
  6. package/claude/skills/app-drive/references/drive-record.md +70 -0
  7. package/claude/skills/app-drive/references/isolate.md +46 -0
  8. package/claude/skills/canon-cli/SKILL.md +2 -0
  9. package/claude/skills/create-skill/references/skill.md +1 -1
  10. package/claude/skills/plan-feature/references/plan.md +1 -1
  11. package/claude/skills/plan-intake/references/intake.md +12 -2
  12. package/claude/skills/review-ui/SKILL.md +5 -4
  13. package/claude/skills/review-ui/references/drive-copy.md +4 -0
  14. package/claude/skills/review-ui/references/driving.md +1 -1
  15. package/claude/skills/role-orchestrator/references/orchestrator-handoff.md +15 -2
  16. package/claude/skills/role-planner/SKILL.md +2 -2
  17. package/claude/skills/role-worker/SKILL.md +4 -6
  18. package/claude/skills/session-map/REQUIREMENT.md +1 -1
  19. package/claude/skills/session-map/SKILL.md +8 -1
  20. package/claude/skills/session-map/references/session.md +19 -5
  21. package/claude/skills/ui-record/REQUIREMENT.md +34 -0
  22. package/claude/skills/ui-record/SKILL.md +103 -0
  23. package/claude/skills/ui-record/references/config.md +40 -0
  24. package/claude/skills/ui-record/references/handoff.md +48 -0
  25. package/claude/skills/ui-record/references/take-craft.md +92 -0
  26. package/docs/agents/records.md +1 -0
  27. package/docs/workflow/parallel-features.md +1 -1
  28. package/docs/workflow/skill-map-on-request.md +1 -0
  29. package/docs/workflow/skill-map.md +1 -0
  30. package/docs/workflow/visual-design-workflow.md +2 -1
  31. package/package.json +1 -1
  32. package/src/audits/catalog.ts +5 -1
  33. package/src/browser/agent-cli.ts +68 -0
  34. package/src/claude/cases/workflow.ts +20 -0
  35. package/src/cli/help.ts +3 -0
  36. package/src/cli.ts +4 -0
  37. package/src/commands/browser.ts +102 -0
  38. package/src/commands/intake.ts +181 -1
  39. package/src/commands/pr.ts +15 -6
  40. package/src/commands/records.ts +18 -1
  41. package/src/commands/take.ts +266 -0
  42. package/src/context/requirements.ts +1 -11
  43. package/src/git/status.ts +73 -0
  44. package/src/intake/drive-check.ts +355 -0
  45. package/src/intake/folder.ts +1 -1
  46. package/src/markdown/scan.ts +10 -0
  47. package/src/pr/drive-copy.ts +97 -84
  48. package/src/records/session-map.ts +75 -0
  49. package/src/records/validate.ts +64 -8
  50. package/src/take/camera.ts +309 -0
  51. package/src/take/encode.ts +170 -0
  52. package/src/take/film.ts +206 -0
  53. package/src/take/fixtures/fade.html +49 -0
  54. package/src/take/fixtures/fetch.html +38 -0
  55. package/src/take/log.ts +109 -0
  56. package/src/take/perform.ts +200 -0
  57. package/src/take/plan.ts +193 -0
  58. package/src/take/screencast.ts +94 -0
  59. package/src/take/shell.ts +287 -0
  60. package/src/take/stepper.ts +252 -0
  61. package/src/take/zoom/NOTICE.md +45 -0
  62. package/src/take/zoom/math-utils.ts +69 -0
  63. package/src/take/zoom/region-utils.ts +55 -0
  64. package/src/take/zoom/spring.ts +133 -0
  65. package/src/tasks/archive.ts +1 -3
  66. package/claude/skills/review-ui/scripts/pw.sh +0 -60
@@ -0,0 +1,355 @@
1
+ import { existsSync } from 'node:fs'
2
+ import { mkdir, readFile, writeFile } from 'node:fs/promises'
3
+ import { basename, isAbsolute, join, normalize } from 'node:path'
4
+ import {
5
+ changedSince,
6
+ readWorktreeStatus,
7
+ type StatusEntry,
8
+ } from '@/git/status'
9
+ import { extractOrdinal, type IntakeRefused, openFolder } from '@/intake/folder'
10
+ import { requirementsRel } from '@/context/requirements'
11
+ import { bodyLines } from '@/markdown/scan'
12
+
13
+ /** The record a drive leaves in its intake folder, which this check reads. */
14
+ export const DRIVE_RECORD = 'drive.json'
15
+
16
+ /** The one folder inside an intake folder that evidence may live in. */
17
+ export const EVIDENCE_DIR = 'evidence'
18
+
19
+ const SCRATCH = join('.canon', 'tmp', 'app-drive')
20
+
21
+ /**
22
+ * The refusals this check adds to the folder lookup's own, which reach a
23
+ * caller unchanged as an `IntakeRefused`.
24
+ */
25
+ export const DRIVE_REFUSALS = [
26
+ 'bad-input',
27
+ 'no-record',
28
+ 'bad-record',
29
+ 'no-requirements',
30
+ 'no-baseline',
31
+ 'unreadable-tree',
32
+ ] as const
33
+
34
+ export type DriveRefusal = (typeof DRIVE_REFUSALS)[number]
35
+
36
+ export interface DriveRefused {
37
+ readonly ok: false
38
+ readonly reason: DriveRefusal
39
+ readonly message: string
40
+ readonly detail: readonly string[]
41
+ }
42
+
43
+ export interface BaselineRecorded {
44
+ readonly ok: true
45
+ readonly slug: string
46
+ readonly path: string
47
+ readonly entries: number
48
+ }
49
+
50
+ /** Each list names what failed, and an empty list is a clean axis. */
51
+ export interface DriveFindings {
52
+ /** Goals the record carries no entry for. */
53
+ readonly unmapped: readonly string[]
54
+ /** Goals marked `not-driven` with no reason given. */
55
+ readonly unreasoned: readonly string[]
56
+ /** Entries naming a goal the requirements record does not hold. */
57
+ readonly unknown: readonly string[]
58
+ /** Paths in the main checkout that differ from the baseline. */
59
+ readonly changed: readonly string[]
60
+ /** Evidence paths that resolve outside the folder's `evidence/`. */
61
+ readonly strayEvidence: readonly string[]
62
+ /** Evidence paths inside `evidence/` that hold no file. */
63
+ readonly missingEvidence: readonly string[]
64
+ /** The drive's worktree, when the record names one still on disk. */
65
+ readonly worktreeLeft: readonly string[]
66
+ }
67
+
68
+ export interface DriveChecked {
69
+ readonly ok: true
70
+ readonly slug: string
71
+ readonly reason: 'clean' | 'failed'
72
+ readonly goals: number
73
+ readonly findings: DriveFindings
74
+ }
75
+
76
+ export type BaselineOutcome = BaselineRecorded | DriveRefused
77
+ export type CheckOutcome = DriveChecked | DriveRefused | IntakeRefused
78
+
79
+ type GoalStatus = 'driven' | 'not-driven'
80
+
81
+ interface DriveGoal {
82
+ readonly goal: string
83
+ readonly status: GoalStatus
84
+ readonly reason?: string
85
+ }
86
+
87
+ interface DriveRecord {
88
+ readonly goals: readonly DriveGoal[]
89
+ readonly evidence: readonly string[]
90
+ readonly worktree?: string
91
+ }
92
+
93
+ function refuse(
94
+ reason: DriveRefusal,
95
+ message: string,
96
+ detail: readonly string[] = [],
97
+ ): DriveRefused {
98
+ return { ok: false, reason, message, detail }
99
+ }
100
+
101
+ /**
102
+ * A goal as both sides compare it: a leading bold label and a trailing period
103
+ * dropped, and whitespace collapsed. Requirements records carry no goal ids,
104
+ * so the text is the identity, and stripping the label lets a drive record
105
+ * paste the bullet whole or name the sentence alone.
106
+ */
107
+ function normalizeGoal(text: string): string {
108
+ return text
109
+ .replace(/\s+/g, ' ')
110
+ .trim()
111
+ .replace(/^\*\*[^*]+\*\*\s*/, '')
112
+ .replace(/\.$/, '')
113
+ .trim()
114
+ }
115
+
116
+ /**
117
+ * Every top-level bullet under `## Goals`, through any H3 grouping, up to the
118
+ * next H2. A wrapped bullet's indented continuation joins it, and a nested
119
+ * bullet is detail of its parent rather than a goal of its own.
120
+ */
121
+ export function readGoals(source: string): string[] {
122
+ const goals: string[] = []
123
+ let inGoals = false
124
+ let current: string | undefined
125
+ let appending = false
126
+
127
+ const close = () => {
128
+ if (current !== undefined) goals.push(normalizeGoal(current))
129
+ current = undefined
130
+ appending = false
131
+ }
132
+
133
+ for (const line of bodyLines(source)) {
134
+ if (line.fenced) continue
135
+ const text = line.text
136
+
137
+ if (/^#{1,2}\s/.test(text)) {
138
+ close()
139
+ inGoals = /^##\s+Goals\s*$/.test(text)
140
+ continue
141
+ }
142
+ if (!inGoals) continue
143
+
144
+ const bullet = /^[-*]\s+(.*)$/.exec(text)
145
+ if (bullet) {
146
+ close()
147
+ current = bullet[1]
148
+ appending = true
149
+ } else if (/^\s+[-*]\s/.test(text) || text.trim() === '') {
150
+ appending = false
151
+ } else if (appending && /^\s+\S/.test(text)) {
152
+ current = `${current} ${text.trim()}`
153
+ } else if (/^#{3,}\s/.test(text)) {
154
+ appending = false
155
+ }
156
+ }
157
+ close()
158
+
159
+ return goals
160
+ }
161
+
162
+ function parseRecord(raw: string): DriveRecord | string {
163
+ let parsed: unknown
164
+ try {
165
+ parsed = JSON.parse(raw)
166
+ } catch {
167
+ return `${DRIVE_RECORD} does not parse as JSON.`
168
+ }
169
+ if (typeof parsed !== 'object' || parsed === null) {
170
+ return `${DRIVE_RECORD} is not an object.`
171
+ }
172
+
173
+ const { goals, evidence, worktree } = parsed as Record<string, unknown>
174
+ if (!Array.isArray(goals)) {
175
+ return `${DRIVE_RECORD} carries no "goals" list.`
176
+ }
177
+
178
+ const entries: DriveGoal[] = []
179
+ for (const [index, entry] of goals.entries()) {
180
+ const { goal, status, reason } = (entry ?? {}) as Record<string, unknown>
181
+ if (typeof goal !== 'string' || goal.trim() === '') {
182
+ return `${DRIVE_RECORD} goals[${index}] carries no "goal" text.`
183
+ }
184
+ if (status !== 'driven' && status !== 'not-driven') {
185
+ return `${DRIVE_RECORD} goals[${index}] has a "status" other than "driven" or "not-driven".`
186
+ }
187
+ entries.push({
188
+ goal,
189
+ status,
190
+ ...(typeof reason === 'string' && { reason }),
191
+ })
192
+ }
193
+
194
+ if (
195
+ evidence !== undefined &&
196
+ (!Array.isArray(evidence) ||
197
+ !evidence.every((item): item is string => typeof item === 'string'))
198
+ ) {
199
+ return `${DRIVE_RECORD} carries an "evidence" field that is not a list of paths.`
200
+ }
201
+ if (worktree !== undefined && typeof worktree !== 'string') {
202
+ return `${DRIVE_RECORD} carries a "worktree" that is not a path.`
203
+ }
204
+
205
+ return {
206
+ goals: entries,
207
+ evidence: evidence ?? [],
208
+ ...(worktree !== undefined && { worktree }),
209
+ }
210
+ }
211
+
212
+ function isEvidencePath(path: string): boolean {
213
+ if (isAbsolute(path)) return false
214
+ const segments = normalize(path).split(/[\\/]/)
215
+ return (
216
+ segments[0] === EVIDENCE_DIR &&
217
+ segments.length > 1 &&
218
+ !segments.includes('..')
219
+ )
220
+ }
221
+
222
+ /**
223
+ * The scratch key for a topic, with any ordinal dropped, so a baseline taken
224
+ * before the intake folder is numbered and a check naming the numbered folder
225
+ * meet at one file. Undefined for a slug that could leave scratch.
226
+ */
227
+ function bareSlug(slug: string): string | undefined {
228
+ if (!/^[a-z0-9][a-z0-9.-]*$/i.test(slug) || slug.includes('..')) {
229
+ return undefined
230
+ }
231
+ const ordinal = extractOrdinal(slug)
232
+ return ordinal === '' ? slug : slug.slice(ordinal.length + 1)
233
+ }
234
+
235
+ function baselinePath(root: string, bare: string): string {
236
+ return join(root, SCRATCH, bare, 'baseline.json')
237
+ }
238
+
239
+ function badSlug(slug: string): DriveRefused {
240
+ return refuse(
241
+ 'bad-input',
242
+ `Not an intake slug: ${slug}. Pass the folder's topic, as in app-drive-1-0.`,
243
+ )
244
+ }
245
+
246
+ /**
247
+ * Stores the main checkout's status before a drive, so the check can tell a
248
+ * write the drive made from one already in the tree. Scratch rather than the
249
+ * intake folder, since the baseline is safe to lose once the check passes and
250
+ * the folder holds findings alone. A second call replaces the first.
251
+ */
252
+ export async function recordBaseline(
253
+ root: string,
254
+ slug: string,
255
+ ): Promise<BaselineOutcome> {
256
+ const bare = bareSlug(slug)
257
+ if (bare === undefined) return badSlug(slug)
258
+
259
+ const status = await readWorktreeStatus(root)
260
+ if (status === undefined) {
261
+ return refuse('unreadable-tree', `git cannot read the status of ${root}.`)
262
+ }
263
+
264
+ const path = baselinePath(root, bare)
265
+ await mkdir(join(path, '..'), { recursive: true })
266
+ await writeFile(path, `${JSON.stringify(status)}\n`)
267
+
268
+ return { ok: true, slug: bare, path, entries: status.length }
269
+ }
270
+
271
+ /**
272
+ * Checks a finished drive against its intake folder's `drive.json`: every
273
+ * goal mapped or skipped with a reason, the main checkout unchanged since the
274
+ * baseline, the evidence inside the folder's `evidence/`, and the drive's
275
+ * worktree gone. It reports what failed and never reverts a changed path,
276
+ * since only the operator knows which of them to keep.
277
+ */
278
+ export async function checkDrive(
279
+ root: string,
280
+ slug: string,
281
+ ): Promise<CheckOutcome> {
282
+ const bare = bareSlug(slug)
283
+ if (bare === undefined) return badSlug(slug)
284
+
285
+ const folder = await openFolder(root, slug)
286
+ if (typeof folder !== 'string') return folder
287
+
288
+ const recordPath = join(folder, DRIVE_RECORD)
289
+ if (!existsSync(recordPath)) {
290
+ return refuse(
291
+ 'no-record',
292
+ `No ${DRIVE_RECORD} in ${basename(folder)}, so no drive was recorded.`,
293
+ )
294
+ }
295
+ const record = parseRecord(await readFile(recordPath, 'utf8'))
296
+ if (typeof record === 'string') return refuse('bad-record', record)
297
+
298
+ const requirements = join(root, requirementsRel(root))
299
+ if (!existsSync(requirements)) {
300
+ return refuse(
301
+ 'no-requirements',
302
+ `No requirements record at ${requirementsRel(root)}, so no goal can be checked.`,
303
+ )
304
+ }
305
+ const goals = readGoals(await readFile(requirements, 'utf8'))
306
+
307
+ let baseline: readonly StatusEntry[]
308
+ try {
309
+ baseline = JSON.parse(await readFile(baselinePath(root, bare), 'utf8'))
310
+ } catch {
311
+ return refuse(
312
+ 'no-baseline',
313
+ `No baseline for ${bare}. Run canon intake drive-check ${bare} --baseline before the drive.`,
314
+ )
315
+ }
316
+ const current = await readWorktreeStatus(root)
317
+ if (current === undefined) {
318
+ return refuse('unreadable-tree', `git cannot read the status of ${root}.`)
319
+ }
320
+
321
+ const entries = new Map(
322
+ record.goals.map((entry) => [normalizeGoal(entry.goal), entry]),
323
+ )
324
+ const known = new Set(goals)
325
+ const unreasoned = goals.filter((goal) => {
326
+ const entry = entries.get(goal)
327
+ return entry?.status === 'not-driven' && !entry.reason?.trim()
328
+ })
329
+
330
+ const findings: DriveFindings = {
331
+ unmapped: goals.filter((goal) => !entries.has(goal)),
332
+ unreasoned,
333
+ unknown: record.goals
334
+ .filter((entry) => !known.has(normalizeGoal(entry.goal)))
335
+ .map((entry) => entry.goal),
336
+ changed: changedSince(baseline, current),
337
+ strayEvidence: record.evidence.filter((path) => !isEvidencePath(path)),
338
+ missingEvidence: record.evidence.filter(
339
+ (path) => isEvidencePath(path) && !existsSync(join(folder, path)),
340
+ ),
341
+ worktreeLeft:
342
+ record.worktree !== undefined && existsSync(record.worktree)
343
+ ? [record.worktree]
344
+ : [],
345
+ }
346
+ const failed = Object.values(findings).some((list) => list.length > 0)
347
+
348
+ return {
349
+ ok: true,
350
+ slug: basename(folder),
351
+ reason: failed ? 'failed' : 'clean',
352
+ goals: goals.length,
353
+ findings,
354
+ }
355
+ }
@@ -142,7 +142,7 @@ export function extractOrdinal(name: string): string {
142
142
  return /^\d{2,}-/.exec(name)?.[0].slice(0, -1) ?? ''
143
143
  }
144
144
 
145
- async function openFolder(
145
+ export async function openFolder(
146
146
  root: string,
147
147
  slug: string,
148
148
  ): Promise<string | IntakeRefused> {
@@ -122,6 +122,16 @@ export function bodyLines(source: string): BodyLine[] {
122
122
  )
123
123
  }
124
124
 
125
+ /**
126
+ * Counts whitespace-delimited tokens, the unit a record's word cap is stated
127
+ * in. A code span counts as the tokens it holds, so a line citing a long path
128
+ * pays for it the way a reader does. A caller gating on the count hands in
129
+ * prose with fenced lines already dropped.
130
+ */
131
+ export function wordCount(text: string): number {
132
+ return text.match(/\S+/g)?.length ?? 0
133
+ }
134
+
125
135
  /**
126
136
  * Drops every fenced block from raw text, frontmatter included as content.
127
137
  *
@@ -1,8 +1,12 @@
1
- import { createHash } from 'node:crypto'
2
- import { cpSync, existsSync, readFileSync, rmSync, statSync } from 'node:fs'
3
- import { isAbsolute, join } from 'node:path'
4
- import { $ } from 'bun'
5
- import { gitEnv } from '@/git/env'
1
+ import {
2
+ cpSync,
3
+ existsSync,
4
+ lstatSync,
5
+ readdirSync,
6
+ readFileSync,
7
+ rmSync,
8
+ } from 'node:fs'
9
+ import { isAbsolute, join, relative, sep } from 'node:path'
6
10
 
7
11
  /** Where a project names the folders its running preview saves into. */
8
12
  export const PREVIEW_CONFIG_REL = join('canon', 'config', 'preview.json')
@@ -28,6 +32,8 @@ const BRANCH_TYPES = new Set([
28
32
  export interface PreviewConfig {
29
33
  readonly writes: readonly string[]
30
34
  readonly env: string
35
+ /** Paths inside a declared path that a rebuild leaves as the server wrote them. */
36
+ readonly keep: readonly string[]
31
37
  }
32
38
 
33
39
  export type PreviewConfigRead =
@@ -39,13 +45,6 @@ export type PreviewConfigRead =
39
45
  readonly detail: string
40
46
  }
41
47
 
42
- export interface StatusEntry {
43
- readonly path: string
44
- readonly status: string
45
- /** The content digest, or null where no regular file stands at the path. */
46
- readonly digest: string | null
47
- }
48
-
49
48
  /**
50
49
  * The branch-to-slug transform every branch-derived name shares: a leading
51
50
  * type segment comes off when a further segment follows it, and each
@@ -68,12 +67,25 @@ function isContained(path: string): boolean {
68
67
  )
69
68
  }
70
69
 
70
+ /** Whether `path` is `parent` or stands below it, both relative to one root. */
71
+ function isWithin(path: string, parent: string): boolean {
72
+ const rel = relative(join('/', parent), join('/', path))
73
+ return (
74
+ rel === '' ||
75
+ (rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel))
76
+ )
77
+ }
78
+
71
79
  /**
72
- * Reads `canon/config/preview.json` as `{ "writes": [...], "env": "<NAME>" }`.
80
+ * Reads `canon/config/preview.json` as
81
+ * `{ "writes": [...], "keep": [...], "env": "<NAME>" }`.
73
82
  * A file that does not parse refuses rather than reading as absent, since a
74
83
  * typo there would otherwise drive the real folders while the pass reports a
75
- * copy. A `writes` path that is absolute or climbs with `..` refuses, so the
76
- * copy never reads or writes outside its two roots.
84
+ * copy. A `writes` or `keep` path that is absolute or climbs with `..`
85
+ * refuses, so the copy never reads or writes outside its two roots, and a
86
+ * `keep` path outside every `writes` path refuses, since the rebuild would
87
+ * never reach it, and so does one equal to a `writes` path, which would be
88
+ * declared both reset and kept.
77
89
  */
78
90
  export function loadPreviewConfig(worktree: string): PreviewConfigRead {
79
91
  const path = join(worktree, PREVIEW_CONFIG_REL)
@@ -97,7 +109,7 @@ export function loadPreviewConfig(worktree: string): PreviewConfigRead {
97
109
  }
98
110
  }
99
111
 
100
- const { writes, env } = parsed as Record<string, unknown>
112
+ const { writes, env, keep = [] } = parsed as Record<string, unknown>
101
113
  if (
102
114
  !Array.isArray(writes) ||
103
115
  !writes.every((item): item is string => typeof item === 'string')
@@ -108,6 +120,16 @@ export function loadPreviewConfig(worktree: string): PreviewConfigRead {
108
120
  detail: `${PREVIEW_CONFIG_REL} carries no "writes" list of paths.`,
109
121
  }
110
122
  }
123
+ if (
124
+ !Array.isArray(keep) ||
125
+ !keep.every((item): item is string => typeof item === 'string')
126
+ ) {
127
+ return {
128
+ kind: 'refused',
129
+ reason: 'bad-config',
130
+ detail: `${PREVIEW_CONFIG_REL} names a "keep" that is not a list of paths.`,
131
+ }
132
+ }
111
133
  if (env !== undefined && (typeof env !== 'string' || env === '')) {
112
134
  return {
113
135
  kind: 'refused',
@@ -116,7 +138,7 @@ export function loadPreviewConfig(worktree: string): PreviewConfigRead {
116
138
  }
117
139
  }
118
140
 
119
- const unsafe = writes.find((item) => !isContained(item))
141
+ const unsafe = [...writes, ...keep].find((item) => !isContained(item))
120
142
  if (unsafe !== undefined) {
121
143
  return {
122
144
  kind: 'refused',
@@ -125,94 +147,85 @@ export function loadPreviewConfig(worktree: string): PreviewConfigRead {
125
147
  }
126
148
  }
127
149
 
150
+ const stray = keep.find(
151
+ (item) => !writes.some((path) => isWithin(item, path)),
152
+ )
153
+ if (stray !== undefined) {
154
+ return {
155
+ kind: 'refused',
156
+ reason: 'bad-config',
157
+ detail: `${PREVIEW_CONFIG_REL} keeps "${stray}", which no "writes" path holds.`,
158
+ }
159
+ }
160
+
161
+ const clash = keep.find((item) =>
162
+ writes.some((path) => isWithin(item, path) && isWithin(path, item)),
163
+ )
164
+ if (clash !== undefined) {
165
+ return {
166
+ kind: 'refused',
167
+ reason: 'bad-config',
168
+ detail: `${PREVIEW_CONFIG_REL} keeps "${clash}", which is also a "writes" path, so the rebuild would never reset it.`,
169
+ }
170
+ }
171
+
128
172
  return {
129
173
  kind: 'read',
130
- config: { writes, env: env ?? DEFAULT_PREVIEW_ENV },
174
+ config: { writes, env: env ?? DEFAULT_PREVIEW_ENV, keep },
131
175
  }
132
176
  }
133
177
 
134
178
  /**
135
- * Replaces the copy root with a fresh copy of each declared path, so the last
136
- * pass's drives never reach the next one. A path the worktree does not hold
137
- * is named rather than failing the copy.
179
+ * Removes `target` except the `keep` paths at or below it, where `rel` is
180
+ * `target` relative to the root `keep` is read against. A folder holding a
181
+ * kept path is emptied around it rather than removed.
182
+ */
183
+ function clearAround(
184
+ target: string,
185
+ rel: string,
186
+ keep: readonly string[],
187
+ ): void {
188
+ if (keep.includes(rel)) return
189
+ const stat = lstatSync(target, { throwIfNoEntry: false })
190
+ if (stat === undefined) return
191
+ if (!stat.isDirectory() || !keep.some((path) => isWithin(path, rel))) {
192
+ rmSync(target, { recursive: true, force: true })
193
+ return
194
+ }
195
+ for (const entry of readdirSync(target)) {
196
+ clearAround(join(target, entry), join(rel, entry), keep)
197
+ }
198
+ }
199
+
200
+ /**
201
+ * Resets each declared path in the copy from the worktree, so the last pass's
202
+ * drives never reach the next one, and leaves everything else under the copy
203
+ * root as it is. A `keep` path inside a declared path is neither cleared nor
204
+ * copied, since the server running on the copy owns it. A path the worktree
205
+ * does not hold has its stale copy removed and is named rather than failing
206
+ * the copy.
138
207
  */
139
208
  export function buildDriveCopy(
140
209
  worktree: string,
141
210
  copyRoot: string,
142
211
  writes: readonly string[],
212
+ keep: readonly string[] = [],
143
213
  ): { readonly copied: readonly string[]; readonly missing: readonly string[] } {
144
- rmSync(copyRoot, { recursive: true, force: true })
214
+ const kept = keep.map((path) => relative(worktree, join(worktree, path)))
145
215
  const copied: string[] = []
146
216
  const missing: string[] = []
147
217
  for (const path of writes) {
148
218
  const source = join(worktree, path)
219
+ clearAround(join(copyRoot, path), relative(worktree, source), kept)
149
220
  if (!existsSync(source)) {
150
221
  missing.push(path)
151
222
  continue
152
223
  }
153
- cpSync(source, join(copyRoot, path), { recursive: true })
224
+ cpSync(source, join(copyRoot, path), {
225
+ recursive: true,
226
+ filter: (item) => !kept.includes(relative(worktree, item)),
227
+ })
154
228
  copied.push(path)
155
229
  }
156
230
  return { copied, missing }
157
231
  }
158
-
159
- function digestOf(path: string): string | null {
160
- try {
161
- if (!statSync(path).isFile()) return null
162
- return createHash('sha1').update(readFileSync(path)).digest('hex')
163
- } catch {
164
- return null
165
- }
166
- }
167
-
168
- /**
169
- * Every entry `git status` reports, untracked files listed one by one so a
170
- * file a drive adds inside a tracked folder names itself, each with a content
171
- * digest so a file already dirty at the baseline still reads as changed when
172
- * a drive writes it again. Undefined when git cannot read the tree.
173
- */
174
- export async function readWorktreeStatus(
175
- worktree: string,
176
- ): Promise<readonly StatusEntry[] | undefined> {
177
- const result =
178
- await $`git -C ${worktree} status --porcelain=v1 -z --untracked-files=all`
179
- .env(gitEnv())
180
- .quiet()
181
- .nothrow()
182
- if (result.exitCode !== 0) return undefined
183
-
184
- const tokens = result.stdout.toString().split('\0')
185
- const entries: StatusEntry[] = []
186
- for (let index = 0; index < tokens.length; index++) {
187
- const token = tokens[index] ?? ''
188
- if (token.length < 4) continue
189
- const status = token.slice(0, 2)
190
- const path = token.slice(3)
191
- // A rename or copy carries its source as the next token.
192
- if (status.includes('R') || status.includes('C')) index++
193
- entries.push({ path, status, digest: digestOf(join(worktree, path)) })
194
- }
195
- return entries
196
- }
197
-
198
- /**
199
- * The paths whose status or content differs from the baseline, in the order
200
- * git reports them. An entry gone since the baseline is not reported, since a
201
- * worker committing its own edit mid-pass clears it the same way.
202
- */
203
- export function changedSince(
204
- baseline: readonly StatusEntry[],
205
- current: readonly StatusEntry[],
206
- ): readonly string[] {
207
- const before = new Map(baseline.map((entry) => [entry.path, entry]))
208
- return current
209
- .filter((entry) => {
210
- const prior = before.get(entry.path)
211
- return (
212
- prior === undefined ||
213
- prior.status !== entry.status ||
214
- prior.digest !== entry.digest
215
- )
216
- })
217
- .map((entry) => entry.path)
218
- }