@erclx/canon 4.60.0 → 4.62.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.
@@ -193,16 +193,17 @@ This section is the corpus the coverage claim is measured against: every name `c
193
193
 
194
194
  ### Set up a project
195
195
 
196
- | Skill | When to use |
197
- | ----------------------------- | --------------------------------------------------------------------------------------- |
198
- | `canon:setup-init` | On a fresh scaffold, to detect the stack and run the whole install chain in one pass |
199
- | `canon:canon-operator` | On a project that already exists, to read what it carries before an install is picked |
200
- | `canon:setup-gov` | When the governance rules are wanted without the tooling chain |
201
- | `canon:setup-indexes` | When a markdown-heavy folder needs an `index.md` a session can browse |
202
- | `canon:setup-plugins` | On a new machine, to install the community and official plugins user-scoped |
203
- | `canon:setup-verify` | After the agent generates configs, to run the installed scripts and report pass or fail |
204
- | `canon:claude-design-extract` | Before the first UI feature, to draft `.claude/DESIGN.md` |
205
- | `canon:claude-diagram` | Once the architecture is written, to draft per-kind entries under `.canon/diagrams/` |
196
+ | Skill | When to use |
197
+ | ----------------------------- | --------------------------------------------------------------------------------------------------------------- |
198
+ | `canon:setup-init` | On a fresh scaffold, to detect the stack and run the whole install chain in one pass |
199
+ | `canon:canon-operator` | On a project that already exists, to read what it carries before an install is picked |
200
+ | `canon:setup-gov` | When the governance rules are wanted without the tooling chain |
201
+ | `canon:setup-indexes` | When a markdown-heavy folder needs an `index.md` a session can browse |
202
+ | `canon:setup-plugins` | On a new machine, to install the community and official plugins user-scoped |
203
+ | `canon:setup-verify` | After the agent generates configs, to run the installed scripts and report pass or fail |
204
+ | `canon:setup-smoke` | After `setup-verify` passes, to check the dev and preview servers, end-to-end tests, and the screenshot harness |
205
+ | `canon:claude-design-extract` | Before the first UI feature, to draft `.claude/DESIGN.md` |
206
+ | `canon:claude-diagram` | Once the architecture is written, to draft per-kind entries under `.canon/diagrams/` |
206
207
 
207
208
  ### Decide what to build
208
209
 
@@ -30,5 +30,7 @@ paths:
30
30
 
31
31
  ## Sharing a capture
32
32
 
33
- - Attach a capture to the pull request by hand when a reviewer needs to see it.
34
- - Do not commit a capture. Do not remove the capture folder from `.gitignore`.
33
+ - Do not commit the sweep. It stays ignored.
34
+ - Commit a flagged case's evidence output so the pull request carries the comparison, rather than attaching it by hand.
35
+ - Commit an evidence case for the first time only after running the capture twice with no code change between the runs and confirming the two outputs are byte-identical.
36
+ - Recommitting an unbounded sweep on every run reaches a gigabyte of repository history inside a hundred merges. A small, committed evidence set is what a reviewer needs and what lets GitHub draw its own before-and-after comparison on the pull request.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@erclx/canon",
3
3
  "type": "module",
4
- "version": "4.60.0",
4
+ "version": "4.62.0",
5
5
  "description": "Infrastructure and quality tooling for developer workflows",
6
6
  "license": "MIT",
7
7
  "bin": {
@@ -30,6 +30,11 @@ export const SETUP_CASES: readonly SkillCase[] = [
30
30
  "Run through the generated scaffold's scripts and confirm each one passes.",
31
31
  expect: 'setup-verify',
32
32
  },
33
+ {
34
+ prompt:
35
+ 'Check that the dev server actually starts and the end-to-end suite passes against the scaffold.',
36
+ expect: 'setup-smoke',
37
+ },
33
38
  {
34
39
  prompt:
35
40
  'This CLAUDE.md file has grown huge, break it apart into the tiered context model.',
@@ -24,6 +24,13 @@ import {
24
24
  type RuleLayoutPlan,
25
25
  walkFlatRules,
26
26
  } from '@/migrate/rule-layout'
27
+ import {
28
+ applyScratchEvidence,
29
+ planScratchEvidence,
30
+ readScratchEvidenceCorpus,
31
+ type ScratchEvidencePlan,
32
+ walkScratchEvidenceCorpus,
33
+ } from '@/migrate/scratch-evidence'
27
34
  import { PROJECT_ROOT } from '@/project-root'
28
35
  import { readStamp, stampedHashes } from '@/sync/stamp'
29
36
  import { logError, logInfo, logStep, logWarn, pipeOutput, plural } from '@/ui'
@@ -470,6 +477,96 @@ function toRecordTreeRecord(
470
477
  }
471
478
  }
472
479
 
480
+ interface ScratchEvidenceOptions {
481
+ readonly json?: boolean
482
+ readonly write?: boolean
483
+ readonly root?: string
484
+ }
485
+
486
+ /**
487
+ * Moves the nine cited-and-unwritten folders under `.canon/tmp/` to
488
+ * `.canon/review/evidence/`, and repoints the citations that name them,
489
+ * archives included.
490
+ */
491
+ async function runScratchEvidence(
492
+ opts: ScratchEvidenceOptions,
493
+ ): Promise<number> {
494
+ const root = opts.root ?? process.cwd()
495
+
496
+ const files = await walkScratchEvidenceCorpus(root)
497
+ const sources = await readScratchEvidenceCorpus(files)
498
+ const plan = planScratchEvidence(root, sources)
499
+
500
+ if (opts.json) {
501
+ process.stdout.write(
502
+ `${JSON.stringify(toScratchEvidenceRecord(plan, opts.write))}\n`,
503
+ )
504
+ }
505
+
506
+ reportScratchEvidence(plan)
507
+
508
+ if (plan.collisions.length > 0) {
509
+ logError(
510
+ `${plural(plan.collisions.length, 'destination')} already occupied. Neither side moved.`,
511
+ )
512
+ for (const collision of plan.collisions) logError(` ${collision}`)
513
+ }
514
+
515
+ if (plan.moves.length === 0 && plan.entries.length === 0) {
516
+ return plan.collisions.length > 0 ? 1 : 0
517
+ }
518
+
519
+ if (!opts.write) {
520
+ logWarn('Nothing was written. Pass --write to apply this plan.')
521
+ return 2
522
+ }
523
+
524
+ const applied = await applyScratchEvidence(plan)
525
+ logStep(
526
+ `Moved ${plural(applied.moved, 'folder')} and rewrote ${plural(applied.written, 'file')}.`,
527
+ )
528
+
529
+ if (applied.failed.length > 0) {
530
+ logError(`Could not write ${plural(applied.failed.length, 'file')}.`)
531
+ for (const path of applied.failed) logError(` ${path}`)
532
+ return 1
533
+ }
534
+
535
+ return plan.collisions.length > 0 ? 1 : 0
536
+ }
537
+
538
+ function reportScratchEvidence(plan: ScratchEvidencePlan): void {
539
+ logInfo(`${plural(plan.moves.length, 'folder')} to move.`)
540
+ for (const move of plan.moves) {
541
+ logInfo(` ${move.from} -> ${move.to}`)
542
+ }
543
+
544
+ logInfo(
545
+ `${plural(plan.entries.length, 'file')} to change, ${plural(plan.rewritten, 'citation')} to rewrite.`,
546
+ )
547
+ for (const entry of plan.entries) {
548
+ logInfo(` ${entry.path}: ${plural(entry.rewritten, 'citation')}`)
549
+ }
550
+ }
551
+
552
+ function toScratchEvidenceRecord(
553
+ plan: ScratchEvidencePlan,
554
+ wrote: boolean | undefined,
555
+ ): unknown {
556
+ return {
557
+ ok: true,
558
+ wrote: wrote === true,
559
+ moves: plan.moves,
560
+ collisions: plan.collisions,
561
+ files: plan.entries.length,
562
+ rewritten: plan.rewritten,
563
+ paths: plan.entries.map((entry) => ({
564
+ path: entry.path,
565
+ rewritten: entry.rewritten,
566
+ })),
567
+ }
568
+ }
569
+
473
570
  interface RuleLayoutOptions {
474
571
  readonly json?: boolean
475
572
  readonly write?: boolean
@@ -673,6 +770,45 @@ export function register(program: Command): void {
673
770
  process.exitCode = await runRecordTree(opts)
674
771
  })
675
772
 
773
+ migrate
774
+ .command('scratch-evidence')
775
+ .description('Promote cited measurement folders out of tmp into review')
776
+ .helpOption('-h, --help', 'Show this help message')
777
+ .option('--json', 'Add a machine-readable record on stdout')
778
+ .option('--write', 'Apply the plan rather than reporting it')
779
+ .option(
780
+ '--root <path>',
781
+ 'Project root, defaulting to the working directory',
782
+ )
783
+ .addHelpText(
784
+ 'after',
785
+ [
786
+ '',
787
+ 'Moves nine folders cited as measurement evidence from .canon/tmp/ to',
788
+ '.canon/review/evidence/, which canon records push already backs, and',
789
+ 'repoints every citation that names one, live or archived.',
790
+ '',
791
+ 'A promoted folder is one a durable record cites and no source file',
792
+ 'under claude/, scripts/, src/, governance/, or standards/ names by',
793
+ 'path. A folder a script still writes into stays in scratch, since',
794
+ 'moving it needs a code change first.',
795
+ '',
796
+ 'Exit codes:',
797
+ ' 0 nothing to move, or --write applied the whole plan',
798
+ ' 1 a write failed, or an unresolved collision remains',
799
+ ' 2 a plan exists and --write was not passed',
800
+ '',
801
+ 'Examples:',
802
+ ' canon migrate scratch-evidence',
803
+ ' canon migrate scratch-evidence --write',
804
+ ' canon migrate scratch-evidence --json',
805
+ '',
806
+ ].join('\n'),
807
+ )
808
+ .action(async (opts: ScratchEvidenceOptions) => {
809
+ process.exitCode = await runScratchEvidence(opts)
810
+ })
811
+
676
812
  migrate
677
813
  .command('rename')
678
814
  .description('Rewrite every unprotected aitk token to canon')
@@ -9,6 +9,7 @@ import {
9
9
  type PlanCitations,
10
10
  planCitations,
11
11
  } from '@/tasks/archive'
12
+ import { type LabelOutcome, nextLabel } from '@/tasks/label'
12
13
  import {
13
14
  type CloseOutcome,
14
15
  closeOutcomes,
@@ -87,6 +88,11 @@ interface OutcomeCommandOptions {
87
88
  readonly root?: string
88
89
  }
89
90
 
91
+ interface NextLabelCommandOptions {
92
+ readonly json?: boolean
93
+ readonly root?: string
94
+ }
95
+
90
96
  export function register(program: Command): void {
91
97
  const tasks = program
92
98
  .command('tasks')
@@ -379,6 +385,40 @@ export function register(program: Command): void {
379
385
  .action(async (task: string | undefined, opts: OutcomeCommandOptions) => {
380
386
  process.exitCode = await runOutcome(task, opts)
381
387
  })
388
+
389
+ tasks
390
+ .command('next-label')
391
+ .description(
392
+ 'Report the next unused phase label across the board and its archive',
393
+ )
394
+ .helpOption('-h, --help', 'Show this help message')
395
+ .option('--json', 'Emit a machine-readable record on stdout')
396
+ .option('--root <path>', 'Board root, defaulting to the main worktree')
397
+ .addHelpText(
398
+ 'after',
399
+ [
400
+ '',
401
+ 'Reads .canon/tasks/ and its archive/ sibling together, since the',
402
+ 'archive holds labels the live board no longer shows and a scan',
403
+ 'confined to the board hands out one already spent.',
404
+ '',
405
+ 'Exit codes:',
406
+ ' 0 the label is derived',
407
+ ' 1 refused with no-board',
408
+ '',
409
+ 'It reports and never writes. Two sessions calling it in the same',
410
+ 'second can still take the same answer, since the board is',
411
+ 'gitignored files rather than a store with a lock.',
412
+ '',
413
+ 'Examples:',
414
+ ' canon tasks next-label',
415
+ ' canon tasks next-label --json',
416
+ '',
417
+ ].join('\n'),
418
+ )
419
+ .action(async (opts: NextLabelCommandOptions) => {
420
+ process.exitCode = await runNextLabel(opts)
421
+ })
382
422
  }
383
423
 
384
424
  function collectPosition(value: string, previous: string[]): string[] {
@@ -649,6 +689,50 @@ function reportOutcome(
649
689
  return 0
650
690
  }
651
691
 
692
+ async function runNextLabel(opts: NextLabelCommandOptions): Promise<number> {
693
+ const root = opts.root ?? (await mainWorktreeRoot())
694
+ const outcome = await nextLabel(root)
695
+
696
+ return reportNextLabel(outcome, opts.json ?? false, root)
697
+ }
698
+
699
+ function reportNextLabel(
700
+ outcome: LabelOutcome,
701
+ emitJson: boolean,
702
+ root: string,
703
+ ): number {
704
+ if (!outcome.ok) {
705
+ if (emitJson) {
706
+ process.stdout.write(
707
+ `${JSON.stringify({ ok: false, reason: outcome.reason, message: outcome.message })}\n`,
708
+ )
709
+ return 1
710
+ }
711
+
712
+ intro('canon tasks next-label')
713
+ logStep('Refused')
714
+ logError(outcome.message)
715
+ outro()
716
+ return 1
717
+ }
718
+
719
+ if (emitJson) {
720
+ process.stdout.write(`${JSON.stringify({ ...outcome, root })}\n`)
721
+ return 0
722
+ }
723
+
724
+ intro('canon tasks next-label')
725
+ logStep(outcome.label)
726
+ logInfo(
727
+ outcome.highest
728
+ ? `next after ${outcome.highest}.`
729
+ : 'the board and its archive hold no label yet.',
730
+ )
731
+ outro()
732
+
733
+ return 0
734
+ }
735
+
652
736
  async function runValidate(opts: ValidateCommandOptions): Promise<number> {
653
737
  const root = opts.root ?? (await mainWorktreeRoot())
654
738
  const outcome = await validateBoard(root)
@@ -0,0 +1,319 @@
1
+ /**
2
+ * The promotion of nine cited measurement folders out of `.canon/tmp/` into
3
+ * `.canon/review/evidence/`, which `canon records push` already backs.
4
+ *
5
+ * The scratch root is the one record root a disk loss takes with it, and a
6
+ * durable record naming a folder under it as its evidence is a citation into
7
+ * something the backup never covers. The nine promoted here are every folder
8
+ * under scratch that a live or archived record cites and no source file under
9
+ * `claude/`, `scripts/`, `src/`, `governance/`, or `standards/` names by path,
10
+ * which is what separates them from a folder a script still writes into on its
11
+ * own schedule.
12
+ *
13
+ * Distinct from `record-tree.ts`, which repoints a citation of the `.claude/`
14
+ * to `.canon/` root move and prunes every `archive` segment on the way in,
15
+ * since an archived record describes work that already closed. This move
16
+ * reaches into an archive on purpose: `tasks/archive/`, `plans/archive/`, and
17
+ * `groundwork/` are where most of the citations broken here already sit, and
18
+ * a folder promoted out from under them stays gone whether the citing record
19
+ * is open or closed.
20
+ *
21
+ * Honors the same `canon-keep-record-root` marker `records.ts` reads, since a
22
+ * sentence describing what no target holds is not a live pointer this checkout
23
+ * has to keep resolving.
24
+ */
25
+
26
+ import { existsSync } from 'node:fs'
27
+ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
28
+ import { dirname, join } from 'node:path'
29
+ import { BACKED_FOLDERS } from '@/records/backup'
30
+ import { recordDir, SCRATCH } from '@/record-root'
31
+
32
+ /**
33
+ * Every folder this promotion moves, at the name it carries under scratch.
34
+ *
35
+ * Derived by the two-clause test `canon migrate scratch-evidence` exists to
36
+ * apply mechanically: a durable record under a `BACKED_FOLDERS` entry, live
37
+ * or archived, names the folder as its evidence, and no file under `claude/`,
38
+ * `scripts/`, `src/`, `governance/`, or `standards/` names that path. Measured
39
+ * 2026-09-06 against nine folders holding thirteen files.
40
+ *
41
+ * `verify-astro` and `verify-vite-react` pass the same test and are excluded
42
+ * by name: both are scaffolds a command generates rather than records a
43
+ * session wrote, and each is 100+ MB, which the review remote is not sized
44
+ * for. `ablation`, `eval-runs`, `sandbox-runs`, `memory-archive`,
45
+ * `groundwork-fixtures`, `precompact-handoff`, `pr-poll`, `pr`,
46
+ * `address-review`, and `memory-routing` fail the second clause: a script or
47
+ * a skill body names each of those paths, so moving one needs a code change
48
+ * first rather than a promotion.
49
+ */
50
+ export const PROMOTED_FOLDERS: readonly string[] = [
51
+ 'hero-probe',
52
+ 'markdown-corpus-sweep',
53
+ 'orchestrator-output',
54
+ 'orchestrator-watch',
55
+ 'review-calibration',
56
+ 'sandbox-drift',
57
+ 'skill-requirement-pass',
58
+ 'system-map',
59
+ 'target-survey',
60
+ ]
61
+
62
+ const EVIDENCE_ROOT = ['review', 'evidence'] as const
63
+
64
+ /** Where a promoted folder sits before the move. */
65
+ export function sourcePath(root: string, folder: string): string {
66
+ return recordDir(root, SCRATCH, folder)
67
+ }
68
+
69
+ /** Where it lands after, always under whichever root `review/` resolves at. */
70
+ export function destinationPath(root: string, folder: string): string {
71
+ return recordDir(root, ...EVIDENCE_ROOT, folder)
72
+ }
73
+
74
+ function escape(value: string): string {
75
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
76
+ }
77
+
78
+ /**
79
+ * The prefixes a citation of a promoted folder is spelled with, absolute at
80
+ * either record root and relative from one directory below it. A tail
81
+ * rejecting a following name character is what keeps `target-survey` from
82
+ * swallowing a sibling folder whose name extends it.
83
+ */
84
+ const OLD_PREFIXES = [
85
+ '.claude/.tmp/',
86
+ '.canon/tmp/',
87
+ '../.tmp/',
88
+ '../tmp/',
89
+ '../../.tmp/',
90
+ '../../tmp/',
91
+ ] as const
92
+
93
+ function citationPattern(folder: string): RegExp {
94
+ const alternation = OLD_PREFIXES.map(escape).join('|')
95
+ return new RegExp(
96
+ `(?:${alternation})${escape(folder)}(?![A-Za-z0-9._-])`,
97
+ 'g',
98
+ )
99
+ }
100
+
101
+ /** One promoted folder's citation pattern, paired with its replacement. */
102
+ const REWRITES: readonly {
103
+ readonly pattern: RegExp
104
+ readonly folder: string
105
+ }[] = PROMOTED_FOLDERS.map((folder) => ({
106
+ pattern: citationPattern(folder),
107
+ folder,
108
+ }))
109
+
110
+ /**
111
+ * Marks a line naming a promoted folder's old path on purpose, the same
112
+ * marker `records.ts` reads: on the line itself or on the nearest non-blank
113
+ * line above it. A sentence describing what no target holds, rather than
114
+ * pointing a reader at this checkout's own evidence, needs the old spelling
115
+ * kept, and a mechanical rewrite cannot tell that apart from a live citation.
116
+ */
117
+ const KEEP_MARKER = 'canon-keep-record-root'
118
+
119
+ function isKept(lines: readonly string[], index: number): boolean {
120
+ if (lines[index]?.includes(KEEP_MARKER)) return true
121
+
122
+ let above = index - 1
123
+ while (above >= 0 && lines[above]?.trim() === '') above -= 1
124
+
125
+ return above >= 0 && (lines[above]?.includes(KEEP_MARKER) ?? false)
126
+ }
127
+
128
+ function rewriteLine(line: string): string {
129
+ return REWRITES.reduce(
130
+ (current, { pattern, folder }) =>
131
+ current.replace(pattern, `.canon/review/evidence/${folder}`),
132
+ line,
133
+ )
134
+ }
135
+
136
+ /**
137
+ * Rewrites every unmarked citation of a promoted folder into its destination
138
+ * under `.canon/review/evidence/`, absolute regardless of how the source
139
+ * citation was spelled. A file naming no promoted folder returns
140
+ * byte-identical, and a marked line is returned unchanged.
141
+ */
142
+ export function rewriteScratchEvidence(text: string): string {
143
+ const lines = text.split('\n')
144
+ return lines
145
+ .map((line, index) => (isKept(lines, index) ? line : rewriteLine(line)))
146
+ .join('\n')
147
+ }
148
+
149
+ /** How many unmarked citations `rewriteScratchEvidence` would change. */
150
+ export function countScratchEvidenceCitations(text: string): number {
151
+ const lines = text.split('\n')
152
+ let count = 0
153
+
154
+ for (const [index, line] of lines.entries()) {
155
+ if (isKept(lines, index)) continue
156
+ for (const { pattern } of REWRITES) {
157
+ count += [...line.matchAll(pattern)].length
158
+ }
159
+ }
160
+
161
+ return count
162
+ }
163
+
164
+ /** The files under every `BACKED_FOLDERS` entry, archives included. */
165
+ export async function walkScratchEvidenceCorpus(
166
+ root: string,
167
+ ): Promise<string[]> {
168
+ const files: string[] = []
169
+
170
+ for (const folder of BACKED_FOLDERS) {
171
+ const dir = recordDir(root, folder)
172
+ if (!existsSync(dir)) continue
173
+
174
+ const glob = new Bun.Glob('**/*')
175
+ for await (const path of glob.scan({
176
+ cwd: dir,
177
+ onlyFiles: true,
178
+ dot: true,
179
+ })) {
180
+ files.push(join(dir, path))
181
+ }
182
+ }
183
+
184
+ return files.sort()
185
+ }
186
+
187
+ export interface ScratchEvidenceSource {
188
+ readonly path: string
189
+ readonly text: string
190
+ }
191
+
192
+ /** Reads every file the walk found, skipping one that carries a NUL byte. */
193
+ export async function readScratchEvidenceCorpus(
194
+ paths: readonly string[],
195
+ ): Promise<ScratchEvidenceSource[]> {
196
+ const sources: ScratchEvidenceSource[] = []
197
+
198
+ for (const path of paths) {
199
+ const bytes = await readFile(path).catch(() => undefined)
200
+ if (bytes === undefined || bytes.includes(0)) continue
201
+
202
+ sources.push({ path, text: bytes.toString('utf8') })
203
+ }
204
+
205
+ return sources
206
+ }
207
+
208
+ export interface FolderMove {
209
+ readonly folder: string
210
+ readonly from: string
211
+ readonly to: string
212
+ }
213
+
214
+ export interface CitationEntry {
215
+ readonly path: string
216
+ readonly text: string
217
+ readonly rewritten: number
218
+ }
219
+
220
+ export interface ScratchEvidencePlan {
221
+ readonly moves: readonly FolderMove[]
222
+ readonly collisions: readonly string[]
223
+ readonly entries: readonly CitationEntry[]
224
+ readonly rewritten: number
225
+ }
226
+
227
+ /**
228
+ * Every promoted folder found on disk, with its destination, refusing a
229
+ * folder whose destination is already occupied rather than merging into it.
230
+ */
231
+ export function planFolderMoves(root: string): {
232
+ moves: FolderMove[]
233
+ collisions: string[]
234
+ } {
235
+ const moves: FolderMove[] = []
236
+ const collisions: string[] = []
237
+
238
+ for (const folder of PROMOTED_FOLDERS) {
239
+ const from = sourcePath(root, folder)
240
+ if (!existsSync(from)) continue
241
+
242
+ const to = destinationPath(root, folder)
243
+ if (existsSync(to)) {
244
+ collisions.push(to)
245
+ continue
246
+ }
247
+
248
+ moves.push({ folder, from, to })
249
+ }
250
+
251
+ return { moves, collisions }
252
+ }
253
+
254
+ /**
255
+ * What the promotion would do, without doing it. Pure over the sources it is
256
+ * handed, the way `planRecordTree` is, so a caller reports and applies from
257
+ * the same value. A file whose text does not change is dropped.
258
+ */
259
+ export function planScratchEvidence(
260
+ root: string,
261
+ sources: readonly ScratchEvidenceSource[],
262
+ ): ScratchEvidencePlan {
263
+ const { moves, collisions } = planFolderMoves(root)
264
+ const entries: CitationEntry[] = []
265
+
266
+ for (const source of sources) {
267
+ const rewritten = countScratchEvidenceCitations(source.text)
268
+ if (rewritten === 0) continue
269
+
270
+ entries.push({
271
+ path: source.path,
272
+ text: rewriteScratchEvidence(source.text),
273
+ rewritten,
274
+ })
275
+ }
276
+
277
+ return {
278
+ moves,
279
+ collisions,
280
+ entries,
281
+ rewritten: entries.reduce((sum, entry) => sum + entry.rewritten, 0),
282
+ }
283
+ }
284
+
285
+ export interface ScratchEvidenceResult {
286
+ readonly moved: number
287
+ readonly written: number
288
+ readonly failed: readonly string[]
289
+ }
290
+
291
+ /** Writes the plan: every folder move, then every citation rewrite. */
292
+ export async function applyScratchEvidence(
293
+ plan: ScratchEvidencePlan,
294
+ ): Promise<ScratchEvidenceResult> {
295
+ let moved = 0
296
+ let written = 0
297
+ const failed: string[] = []
298
+
299
+ for (const move of plan.moves) {
300
+ await mkdir(dirname(move.to), { recursive: true })
301
+ const done = await rename(move.from, move.to)
302
+ .then(() => true)
303
+ .catch(() => false)
304
+
305
+ if (done) moved += 1
306
+ else failed.push(move.from)
307
+ }
308
+
309
+ for (const entry of plan.entries) {
310
+ const done = await writeFile(entry.path, entry.text)
311
+ .then(() => true)
312
+ .catch(() => false)
313
+
314
+ if (done) written += 1
315
+ else failed.push(entry.path)
316
+ }
317
+
318
+ return { moved, written, failed }
319
+ }